Часть 3. Создание повторно используемых обработчиков HTTP

Перефразируя Билла Кеннеди: «последовательность побеждает в игре». Согласованность, о которой здесь идет речь, — это способность обрабатывать веб-запросы и ответы согласованным образом; это включает в себя постоянную обработку ошибок, запросов и сообщений журнала — эксперты называют это хорошим кодом. Билл достигает этого, устанавливая рекомендации по слоям и структурируя свой код для обеспечения большей согласованности.

Это утверждение находит во мне отклик, потому что раньше я писал веб-обработчик и задавался вопросом, какой ключ ошибки я отправляю с каждым ответом; Я колебался, угадывал и просматривал код, чтобы понять, какое сообщение я использовал. Делая это, я теряю ход мыслей. Удивительно, как структурирование обработчика может уменьшить потребность в постоянной проверке документации.

В предыдущем посте мы рассмотрели дизайн интерфейса оболочки базы данных и некоторые преимущества его наличия. Давайте сделаем еще один шаг вперед и разработаем обработчики HTTP, которые откроют доступ к базе данных из Интернета. Прежде чем писать код обработчика, я добавлю две реализации обёртки базы данных; эти итерации будут иметь пользовательские типы, которые напоминают объекты API, которые вы найдете в дикой природе; одна реализация будет управлять сообщениями, а другая — комментариями.

Умный дизайн данных

Каждый объект API будет иметь поле created at и id; чтобы избежать переопределения этих полей, я определю базовый объект для встраивания с каждым новым типом. В Go этот подход известен как композиция, которая представляет собой процесс объединения небольших объектов для создания сложных.

Листинг 1

type Object struct {
 ID        int       `json:"id"`
 CreatedAt time.Time `json:"created_at"`
}

В листинге 1 показан базовый объект, который будет служить отправной точкой для будущих определяемых пользователем типов.

Листинг 2

type Post struct {
 Object
 Text         string    `json:"text"`
 CommentCount int       `json:"comment_count"`
 Comments     []Comment `json:"comments,omitempty"`
}

Листинг 2 — это первый объект API, который я определяю; Сообщение представляет собой сообщение в социальной сети, есть текстовое поле, количество комментариев и массив комментариев, принадлежащих сообщению. Если вы заметили, первое поле в списке называется Object, и именно так достигается композиция; в результате поля из Object (ID и CreatedAt) теперь будут существовать в типе Post. Однако есть одна ошибка; чтобы заполнить унаследованные поля при построении структуры, вы должны передать поля Object в качестве имени поля Object. Это имя будет меняться в зависимости от имени определяемого пользователем типа.

Листинг 3

Post{
   Object: Object{
    CreatedAt: time.Unix(
     int64(timestamp),
     0,
    ),
    ID: id,
   },
   Text:         text,
   CommentCount: comments,
}

В листинге 3 показано, как можно инициализировать структуру, использующую композицию, и как передаются унаследованные поля.

Листинг 4

type Comment struct {
 Object
 Comment string `json:"comment"`
 PostId  int    `json:"post_id,omitempty"`
}

В листинге 4 показан комментарий. Комментарий будет хранить текст комментария и идентификатор сообщения, которому принадлежит комментарий.

Оболочка базы данных для Интернета

Листинг 5

type DatabaseWrapper interface {
 Create(any) error
 Read(int, any) error
 ReadAll(int, any) error
 Update(int, any) error
 Delete(int) error
}

В листинге 5 показана обновленная версия интерфейса оболочки базы данных, определенного в предыдущем посте. эта версия имеет дополнительный метод (ReadAll) для получения всех записей.

Листинг 6

type PostTable struct {
 db           *sql.DB
 commentTable DatabaseWrapper
}

В листинге 6 показан определяемый пользователем тип, который будет реализовывать интерфейс оболочки базы данных. Существует поле db, которое будет использоваться для запросов к базе данных, и поле с именем commentTable, представляющее оболочку для таблицы комментариев. Я выбрал эту структуру, чтобы сохранить свободу выбора поставщика базы данных, который я могу использовать для хранения своих комментариев; это важно упомянуть, потому что я чувствую, что мастера SQL готовятся сказать: «почему бы не использовать оператор соединения для получения комментариев».

Листинг 7

func NewPostTable(
 db *sql.DB,
 comments DatabaseWrapper,
) *PostTable {

 return &PostTable{
  db,
  comments,
 }
}

В листинге 7 показана фабричная функция, создающая структуру PostTable. Я не буду раскрывать всю реализацию, кроме метода ReadOne; вы также можете найти всю кодовую базу по ссылке ниже.

https://github.com/cheikh2shift/miwfy/tree/main/p2

Листинг 8

func (db *PostTable) Read(q int, r any) error {

 var text string
 var timestamp,
  comment_count, id int
 var comments []Comment

 row := db.db.QueryRow("SELECT id,text,comment_count, created_at FROM posts WHERE id = ?", q)
 err := row.Scan(&id, &text, &comment_count, &timestamp)

 if err != nil {
  return err
 }

 result := Post{
  Object: Object{
   CreatedAt: time.Unix(
    int64(timestamp),
    0,
   ),
   ID: id,
  },
  Text:         text,
  CommentCount: comment_count,
 }

 err = db.commentTable.ReadAll(q, &comments)

 if err != nil {
  return err
 }

 result.Comments = comments
 applyDataToPointer(result, r)

 return nil
}

В листинге 8 показан метод Read класса PostTable; этот фрагмент демонстрирует, как функция использует поле commentTable для загрузки всех комментариев к сообщению при индивидуальном запросе. Этот дизайн добавит этот аспект изменчивости в ваш код и позволит вам свободно выбирать базу данных, в которой вы хотите хранить свои комментарии, потому что вы можете заменить реализацию SQL оболочки базы данных на другую по вашему выбору.

Листинг 9

type CommentTable struct {
 db *sql.DB
}

func NewCommentTable(db *sql.DB) *CommentTable {
 return &CommentTable{
  db: db,
 }
}

В листинге 9 показаны CommentTable и фабричная функция, использованная для ее создания.

Общий обработчик

Я буду использовать инфраструктуру GIN для создания этого псевдо-API для социальных сетей. Подход грубой силы для достижения согласованности заключается в определении универсальных функций для обработки веб-запросов, потому что в конечном итоге это будет тот же базовый код, который обрабатывает веб-запросы.

Листинг 10

func Add[T any](db DatabaseWrapper) gin.HandlerFunc {
 return func(c *gin.Context) {
  var req T

  err := c.BindJSON(&req)

  if err != nil {
   c.JSON(
    http.StatusBadRequest,
    gin.H{
     "error": err.Error(),
    },
   )
   return
  }

 }
}

В листинге 10 показана начальная функция Add; эта функция является универсальной, и ее тип ограничен any (или интерфейсом{}), другими словами, функция будет принимать любой тип. Функция имеет один параметр, db , и это позволит мне передать обработчикам любую реализацию оболочки базы данных. Цель этого обработчика — добавить запись в базу данных.

Листинг 11

var req T

err := c.BindJSON(&req)

Листинг 11 взят из функции, определенной в листинге 10. переменная req имеет тип T; таким образом я сообщаю компилятору Go, что эта переменная должна получить свой тип из переданного при вызове функции, и это позволит мне воспользоваться привязками JSON для переданного типа структуры.

Листинг 12

func Add[T any](db DatabaseWrapper) gin.HandlerFunc {
 return func(c *gin.Context) {
  var req T

  err := c.BindJSON(&req)

  if err != nil {
   c.JSON(
    http.StatusBadRequest,
    gin.H{
     "error": err.Error(),
    },
   )
   return
  }

  err = db.Create(req)

  if err != nil {
   c.JSON(
    http.StatusInternalServerError,
    gin.H{
     "error": err.Error(),
    },
   )
   return
  }

  c.JSON(
   http.StatusOK,
   gin.H{
    "result": "Data created",
   },
  )
 }
}

В листинге 12 показана вся функция Add. Одним из неявных требований является то, что тип, передаваемый при вызове универсальной функции, должен поддерживаться базовой оболочкой базы данных.

Листинг 13

func Update[T any](db DatabaseWrapper) gin.HandlerFunc {
 return func(c *gin.Context) {
  var req T

  id := c.Param("id")

  if id == "" {
   c.JSON(
    http.StatusBadRequest,
    gin.H{
     "error": "URL parameter `id` is required",
    },
   )
   return
  }

  err := c.BindJSON(&req)

  if err != nil {
   c.JSON(
    http.StatusBadRequest,
    gin.H{
     "error": err.Error(),
    },
   )
   return
  }

  _, err = strconv.Atoi(id)

  if err != nil {
   c.JSON(
    http.StatusBadRequest,
    gin.H{"error": err.Error()},
   )
   return
  }

 }
}

В листинге 13 показана функция Update, которая будет использоваться для обновления записи в базе данных. Обработчик ожидает передачи параметра URL с именем id; этот параметр должен быть числом, и обработчик выполняет псевдопроверку, вызывая функцию Atoi пакета strconv.

Листинг 14

func Update[T any](db DatabaseWrapper) gin.HandlerFunc {
 return func(c *gin.Context) {
  var req T

  id := c.Param("id")

  if id == "" {
   c.JSON(
    http.StatusBadRequest,
    gin.H{
     "error": "URL parameter `id` is required",
    },
   )
   return
  }

  err := c.BindJSON(&req)

  if err != nil {
   c.JSON(
    http.StatusBadRequest,
    gin.H{
     "error": err.Error(),
    },
   )
   return
  }

  intId, err := strconv.Atoi(id)

  if err != nil {
   c.JSON(
    http.StatusBadRequest,
    gin.H{"error": err.Error()},
   )
   return
  }

  err = db.Update(intId, req)

  if err != nil {
   c.JSON(
    http.StatusInternalServerError,
    gin.H{
     "error": err.Error(),
    },
   )
   return
  }

  c.JSON(
   http.StatusOK,
   gin.H{
    "result": "Row updated",
   },
  )
 }
}

В листинге 14 показан весь обработчик Update; как видно, функция вызывает метод Update интерфейса оболочки базы данных.

Листинг 15

func Read[T any](db DatabaseWrapper) gin.HandlerFunc {
 return func(c *gin.Context) {

  var r []T

  err := db.ReadAll(0, &r)

  if err != nil {
   c.JSON(
    http.StatusInternalServerError,
    gin.H{
     "error": err.Error(),
    },
   )
   return
  }

  c.JSON(
   http.StatusOK,
   gin.H{
    "result": r,
   },
  )

 }
}

В листинге 15 показан обработчик Read; этот обработчик будет использоваться для перечисления всех записей в базе данных. Если вы заметили, переменная r имеет тип []T; это сообщает компилятору, что переменная является массивом типа, переданного при вызове универсальной функции.

Листинг 16

func ReadOne[T any](db DatabaseWrapper) gin.HandlerFunc {
 return func(c *gin.Context) {

  query := c.Param("id")
  var r T

  idInt, err := strconv.Atoi(query)

  if err != nil {
   c.JSON(
    http.StatusBadRequest,
    gin.H{
     "error": err.Error(),
    },
   )
   return
  }

  err = db.Read(idInt, &r)

  if err != nil {
   c.JSON(
    http.StatusNotFound,
    gin.H{
     "error": err.Error(),
    },
   )
   return
  }

  c.JSON(
   http.StatusOK,
   gin.H{
    "result": r,
   },
  )

 }
}

В листинге 16 показана функция для чтения одной записи из базы данных; в отличие от функции в листинге 15 переменная r имеет тип T.

Листинг 17

func Remove[T any](db DatabaseWrapper) gin.HandlerFunc {
 return func(c *gin.Context) {

  id := c.Param("id")

  if id == "" {
   c.JSON(
    http.StatusBadRequest,
    gin.H{
     "error": "URL parameter `id` is required",
    },
   )
   return
  }

  idInt, err := strconv.Atoi(id)

  if err != nil {
   c.JSON(
    http.StatusBadRequest,
    gin.H{"error": err.Error()},
   )
   return
  }

  err = db.Delete(idInt)

  if err != nil {
   c.JSON(
    http.StatusInternalServerError,
    gin.H{
     "error": err.Error(),
    },
   )
   return
  }

  c.JSON(
   http.StatusOK,
   gin.H{
    "result": "row removed",
   },
  )
 }
}

В листинге 17 показан обработчик удаления; эта функция вызовет метод удаления оболочки базы данных, чтобы удалить запись из базы данных. В моем предыдущем посте я описал обработчик, который возвращает интерфейс и выполняет переключение типа, чтобы определить, как обрабатывать запрос; в этом посте я добиваюсь согласованности, повторно используя одни и те же методы для изменения и чтения из базы данных. Первое решение лучше, однако мне удается добиться согласованности, повторно используя одни и те же обработчики для каждого типа запроса.

Собираем вместе

Когда обработчики готовы, пора переходить к реализации.

Листинг 18

func main() {

 // Database setup complete
 // sqlite test. init database in memory
 sqldb, err := sql.Open("sqlite3", "./test.db")

 if err != nil {
  log.Fatal(err)
 }

 // close connection
 // after function returns.
 defer sqldb.Close()

 if _, err := sqldb.Exec(`
 DROP TABLE IF EXISTS posts;
 DROP TABLE IF EXISTS comments;
 CREATE TABLE posts(id INTEGER PRIMARY KEY, text TEXT,comment_count INT, created_at INT);
 CREATE TABLE comments(id INTEGER PRIMARY KEY,post_id INTEGER,text TEXT,created_at INT);`); err != nil {
  panic(err)
 }
 ...
}

В листинге 18 показан код, который я буду использовать для инициализации подключения к базе данных и добавления таблиц, необходимых для работы API. Я добавляю две таблицы, одну для постов, а другую для комментариев.

Листинг 19

func main(){
   ...
   commentsDB := NewCommentTable(sqldb)
   postTable := NewPostTable(sqldb, commentsDB)

   r := gin.Default()
   ...
}

В листинге 19 я создаю оболочку базы данных комментариев и сообщений; идея состоит в том, что каждая таблица будет иметь свою собственную оболочку базы данных.

Листинг 20

func main(){
 ...
 posts := r.Group("/posts")
 {
  posts.POST(  "",
   Add[Post](postTable),
  )

  posts.DELETE(
   "/:id",
   Read[Post](postTable),
  )

  posts.PUT(
   "/:id",
   Update[Post](postTable),
  )

  posts.GET(
   "/:id",
   ReadOne[Post](postTable),
  )

  posts.GET(
   "",
   Read[Post](postTable),
  )
  }
  ...
}

В листинге 20 я назначаю разные маршруты соответствующей функции-обработчику.

Листинг 21

...

comments := posts.Group("/comment")
{
   comments.POST(
    "",
    Add[Comment](commentsDB),
   )
 }
...

В листинге 21 показано, как можно повторно использовать функцию Add для добавления нового комментария в базу данных; пока базовая оболочка базы данных поддерживает переданный тип, код будет работать.

Листинг 22

...
comments.PUT(
    "/:id",
    Update[Comment](commentsDB),
)
...

Листинг 22 — еще один пример повторного использования обработчика и передачи другого типа.

Чтобы протестировать этот код, я собрал небольшой bash-скрипт, который добавит сообщение, перечислит все сообщения, прокомментирует сообщение, а затем прочитает сообщение со всеми его комментариями. Вот содержимое этого скрипта:

curl -X POST http://localhost:3000/posts \
   -H 'Content-Type: application/json' \
   -d '{"text" : "hello world" }' | json_pp

echo "\n"

curl http://localhost:3000/posts | json_pp

echo "\n"

curl -X POST http://localhost:3000/posts/comment \
   -H 'Content-Type: application/json' \
   -d '{"comment" : "hello world" , "post_id" : 1 }' | json_pp

echo "\n"

curl http://localhost:3000/posts/1 | json_pp

Листинг 23

$ sh cmds.sh 
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    50  100    25  100    25   3436   3436 --:--:-- --:--:-- --:--:--  8333
{
   "result" : "Data created"
}


  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   264  100   264    0     0  14184      0 --:--:-- --:--:-- --:--:-- 14666
{
   "result" : [
      {
         "comment_count" : 1,
         "created_at" : "2023-08-13T15:46:51Z",
         "id" : 1,
         "text" : "hello world"
      },
   ]
}


  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    69  100    25  100    44   1525   2685 --:--:-- --:--:-- --:--:--  4600
{
   "result" : "Data created"
}


  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   314  100   314    0     0  30703      0 --:--:-- --:--:-- --:--:-- 34888
{
   "result" : {
      "comment_count" : 1,
      "comments" : [
         {
            "comment" : "hello world",
            "created_at" : "2023-08-13T15:46:52Z",
            "id" : 1
         },
      ],
      "created_at" : "2023-08-13T15:46:51Z",
      "id" : 1,
      "text" : "hello world"
   }
}

Вывод сценария bash показан в листинге 23. Опять же, это один и тот же обработчик, который используется для обоих типов объектов API; та же функция для добавления сообщения используется для добавления комментария.

Заключение

Объединение дженериков с интерфейсами позволяет получить гибкий и повторно используемый код. Я не хотел вдаваться в подробности интерфейса оболочки базы данных, потому что эта статья посвящена созданию обработчиков для веб-API. Вы можете найти весь код для тестирования здесь:

https://github.com/cheikh2shift/miwfy/tree/main/p2

В следующем посте я хотел бы подняться на уровень выше и добавить аутентификацию на сервер; как и ожидалось, мой подход будет заключаться в определении интерфейса с методами, которые мне понадобятся для авторизации пользователя.