Часть 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, ×tamp) 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
В следующем посте я хотел бы подняться на уровень выше и добавить аутентификацию на сервер; как и ожидалось, мой подход будет заключаться в определении интерфейса с методами, которые мне понадобятся для авторизации пользователя.