[Перевод] Разработка REST-серверов на Go. Часть 5: ПО промежуточного уровня
ПО промежуточного уровня, созданное с использованием стандартных средств
Пришло время снова переработать наш сервер, являющийся частью системы управления задачами. Следующий пример основан на базовой версии сервера, описанного в первой части этой серии материалов, при разработке которого используются лишь возможности стандартной библиотеки Go. Тут мы поговорим о том, как оснастить этот сервер ПО промежуточного уровня, и о том, какие у нас имеются варианты интеграции этого ПО с существующим кодом. Полный код сервера, который мы будем сейчас обсуждать, можно найти здесь.
В исходном варианте сервера в начале каждого обработчика присутствует вызов log.Printf
, предназначенный для логирования обрабатываемого запроса. Это — одна из задач, которую можно решить средствами ПО промежуточного уровня, и при этом обойтись меньшими объёмами повторяющегося кода. Вот простой пример кода такого ПО, решающего задачу логирования:
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
start := time.Now()
next.ServeHTTP(w, req)
log.Printf("%s %s %s", req.Method, req.RequestURI, time.Since(start))
})
}
Этот код, в дополнение к логированию метода и URI запроса, подсчитывает и логирует время, необходимое обработчику на решение его задачи.
Для того чтобы подключить это ПО к нашим обработчикам, приведём код main
к следующему виду:
func main() {
mux := http.NewServeMux()
server := NewTaskServer()
mux.HandleFunc("/task/", server.taskHandler)
mux.HandleFunc("/tag/", server.tagHandler)
mux.HandleFunc("/due/", server.dueHandler)
handler := middleware.Logging(mux)
log.Fatal(http.ListenAndServe("localhost:"+os.Getenv("SERVERPORT"), handler))
}
В данном случае ПО промежуточного уровня устанавливается на глобальном уровне, воздействуя на все обработчики. Но такое ПО можно устанавливать и для каждого конкретного маршрута. Например, если надо, чтобы логирование выполнялось бы только для
server.tagHandler
— мы можем поступить так: func main() {
mux := http.NewServeMux()
server := NewTaskServer()
mux.HandleFunc("/task/", server.taskHandler)
mux.Handle("/tag/", middleware.Logging(http.HandlerFunc(server.tagHandler)))
mux.HandleFunc("/due/", server.dueHandler)
log.Fatal(http.ListenAndServe("localhost:"+os.Getenv("SERVERPORT"), mux))
}
Обратите внимание на то, что нам необходимо в этом случае вызывать
mux.Handle
, а не mux.HandleFunc
. Дело в том, что наш код промежуточного уровня возвращает http.Handler
, а не http.HandlerFunc
. По похожей (но, в сущности, по обратной) причине мы, передавая обработчик ПО промежуточного уровня, должны подготовить его к использованию с помощью http.HandlerFunc
.Ещё два вышеописанных подхода можно использовать совместно. А именно, одно ПО промежуточного уровня может использоваться для отдельных маршрутов, а другое может применяться на глобальном уровне. Обратите внимание на то, что в двух предыдущих примерах последовательность вызова mux
и кода промежуточного уровня различается. Можете сами обнаружить это различие?
В первом примере последовательность выполнения кода выглядит так:
request --> [Logging] --> [Mux] --> [Handler]
Во втором примере, для
/tag/
, порядок выполнения кода выглядит так: request --> [Mux] --> [Logging] --> [tagHandler]
В целом — рекомендуется быть в курсе того, в каком порядке выполняется код ПО промежуточного уровня. В данном случае последовательность вызова
mux
и кода промежуточного уровня особой роли не играет, но в некоторых случаях это может быть не так.Добавление в проект дополнительного ПО промежуточного уровня
Давайте оснастим сервер дополнительным ПО промежуточного уровня. В моём материале про жизненный цикл HTTP-запросов в Go-серверах я рассказывал о том, как обрабатывать ошибки времени выполнения категории panic. Если в обработчике возникает такая ошибка — при её обработке выполняется закрытие соединения с клиентом и логируется сообщение об ошибке. Если хочется сделать что-то другое — нужно написать собственный код ПО промежуточного уровня. Попробуем это сделать:
func PanicRecovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
defer func() {
if err := recover(); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
log.Println(string(debug.Stack()))
}
}()
next.ServeHTTP(w, req)
})
}
В этом коде применяется оператор
defer
, с помощью которого к обработчику прикрепляется отложенная функция. Она выполняет обработку ошибки, отправляет клиенту HTTP-ответ с кодом 500 (Internal Server Error) и логирует результаты трассировки стека.Вот как мы можем воспользоваться этим кодом в main
:
func main() {
mux := http.NewServeMux()
server := NewTaskServer()
mux.HandleFunc("/task/", server.taskHandler)
mux.HandleFunc("/tag/", server.tagHandler)
mux.HandleFunc("/due/", server.dueHandler)
handler := middleware.Logging(mux)
handler = middleware.PanicRecovery(handler)
log.Fatal(http.ListenAndServe("localhost:"+os.Getenv("SERVERPORT"), handler))
}
Теперь порядок выполнения кода ПО промежуточного уровня выглядит так:
request --> [Panic Recovery] --> [Logging] --> [Mux] --> [tagHandler]
Тут, как и ранее, можно по-разному сочетать и комбинировать код. Например, можно сделать так, чтобы код
PanicRecovery
использовался бы лишь для некоторых маршрутов, а Logging
— для всех.Создание цепочек ПО промежуточного уровня
Только что было показано то, что, добавляя в сервер новое ПО промежуточного уровня, мы должны знать о том, в каком порядке вызываются соответствующие фрагменты кода. Это справедливо и для глобального ПО, и для того, которое применяется при обработке отдельных маршрутов. В результате неудивительно то, что существуют программные пакеты, ориентированные на построение «цепочек» из ПО промежуточного уровня, дающие больше удобств, чем у нас есть при вышеописанном стиле работы. Подобные пакеты, кроме того, обычно позволяют организовать многократное использование цепочек ПО промежуточного уровня в разных маршрутах. Примером такого пакета является alice.
Я, как это обычно бывает при разговорах о зависимостях, хочу напомнить о том, что к зависимостям стоит относиться очень осторожно. Не надо перегружать ими проект. Особенно — если они дают лишь незначительную выгоду. Пожалуй, без особых раздумий добавлять в проект новые зависимости может лишь тот, кто очень спешит, или тот, кто не особенно заботится о читабельности и поддерживаемости кода в долгосрочной перспективе. Если, например, оказывается, что использование для работы с ПО промежуточного уровня чего-то вроде alice выглядит для кого-то как нечто гораздо более адекватное, чем применение стандартных механизмов, он вполне может добавить в проект подобный пакет. В противном случае лучше начать с написания собственного кода (как в нашем примере), а потом, если возникнет такая необходимость, подумать о переходе на что-то другое.
В любом случае — если вы используете пакет для организации маршрутизации, вроде gorilla/mux, или полномасштабный фреймворк вроде Gin, это значит, что в вашем распоряжении уже есть стандартные средства этих инструментов, ориентированные на работу с ПО промежуточного уровня.
ПО промежуточного уровня и gorilla/mux
При использовании пакета для организации маршрутизации gorilla/mux в нашем распоряжении оказываются и некоторые вспомогательные инструменты для работы с ПО промежуточного уровня. Тип
mux.Router
имеет метод Use(...)
, использование которого облегчает настройку цепочек глобального ПО промежуточного уровня. Более того, пакет gorilla/handlers включает в себя кое-какое готовое к использованию ПО промежуточного уровня. При этом самописное ПО, вроде того, которым мы уже пользовались, тоже легко применять с gorilla/mux. Это так благодаря тому, что для обработчиков используются стандартные интерфейсы net/http
. А если говорить о gorilla/handlers, то можно отметить, что там имеются, кроме прочих, аналоги наших разработок, направленных на логирование и на обработку ошибок panic.Вот — пример кода (полный код сервера можно найти здесь):
func main() {
router := mux.NewRouter()
router.StrictSlash(true)
server := NewTaskServer()
router.HandleFunc("/task/", server.createTaskHandler).Methods("POST")
router.HandleFunc("/task/", server.getAllTasksHandler).Methods("GET")
router.HandleFunc("/task/", server.deleteAllTasksHandler).Methods("DELETE")
router.HandleFunc("/task/{id:[0-9]+}/", server.getTaskHandler).Methods("GET")
router.HandleFunc("/task/{id:[0-9]+}/", server.deleteTaskHandler).Methods("DELETE")
router.HandleFunc("/tag/{tag}/", server.tagHandler).Methods("GET")
router.HandleFunc("/due/{year:[0-9]+}/{month:[0-9]+}/{day:[0-9]+}/", server.dueHandler).Methods("GET")
// Настройка ПО промежуточного уровня для логирования и обработки ошибок panic.
router.Use(func(h http.Handler) http.Handler {
return handlers.LoggingHandler(os.Stdout, h)
})
router.Use(handlers.RecoveryHandler(handlers.PrintRecoveryStack(true)))
log.Fatal(http.ListenAndServe("localhost:"+os.Getenv("SERVERPORT"), router))
}
Функция
main
нового варианта сервера очень похожа на такую же функцию его исходного варианта, написанного во второй части этой серии материалов с использованием gorilla/mux. Но теперь там, где мы настраиваем ПО промежуточного уровня, добавлены два вызова router.Use
. Я использовал тут два отдельных вызова Use
для того чтобы сделать код понятнее. На самом деле, Use
принимает произвольное количество обработчиков, вызовы которых нужно объединить в цепочку.ПО промежуточного уровня для обработки ошибок panic очень просто пользоваться. Тут продемонстрирован интересный подход к настройке такого ПО с использованием функциональных опций. В данном случае мы настраиваем логирование результатов трассировки стека при обработке ошибки panic (по умолчанию используется значение false
).
API ПО промежуточного уровня handlers.LoggingHandler
устроен немного необычно. Нам нужна маленькая функция-адаптер для использования его в router.Use
. Мне не вполне ясна причина, по которой этот API устроен именно так. Полагаю, что, возможно, передачу io.writer
можно организовать с применением функциональной опции — примерно так же, как это сделано в RecoveryHandler
.
В этом примере показана настройка глобального ПО промежуточного уровня (затрагивающего весь маршрутизатор). А как, пользуясь gorilla/mux, настраивать такое ПО для отдельных маршрутов?
Один из способов похож на то, чем мы занимались, пользуясь возможностями стандартной библиотеки в предыдущем примере. Альтернативой ему является применение субмаршрутизаторов gorilla/mux с Use
. Второй подход показался мне неоправданно сложным для тех случаев, когда нужно лишь добавить какое-то ПО промежуточного уровня к одному пути. Но если система маршрутизации уже представлена несколькими субмаршрутизаторами — эта задача решается очень просто.
ПО промежуточного уровня и Gin
А теперь давайте переработаем сервер из третьей части этой серии материалов, основанный на Gin. В том материале было сказано, что при создании нового экземпляра Gin с использованием
gin.Default()
регистрируется и кое-какое ПО промежуточного уровня. В частности — это ПО для логирования и для обработки ошибок panic.Того же эффекта можно достичь, меньше полагаясь на стандартные механизмы, создав экземпляр маршрутизатора через gin.New
(при таком подходе ПО промежуточного уровня автоматически не подключается) и затем самостоятельно подключив то, что нам нужно:
func main() {
// Ручная настройка ПО промежуточного уровня для логирования и обработки ошибок panic.
router := gin.New()
router.Use(gin.Logger())
router.Use(gin.Recovery())
server := NewTaskServer()
router.POST("/task/", server.createTaskHandler)
router.GET("/task/", server.getAllTasksHandler)
router.DELETE("/task/", server.deleteAllTasksHandler)
router.GET("/task/:id", server.getTaskHandler)
router.DELETE("/task/:id", server.deleteTaskHandler)
router.GET("/tag/:tag", server.tagHandler)
router.GET("/due/:year/:month/:day", server.dueHandler)
router.Run("localhost:" + os.Getenv("SERVERPORT"))
}
Метод Gin
Use
позволяет прикрепить к маршрутизатору цепочку ПО промежуточного уровня. Настройка такого ПО для отдельных маршрутов в Gin никаких сложностей не вызывает и производится с использованием групп маршрутизации. Для каждой группы можно зарегистрировать собственное ПО. Так же, как и в случае с обработчиками Gin, ПО промежуточного уровня Gin не использует стандартную сигнатуру для такого ПО. Тут используется тип, объявленный в пакете gin
следующим образом: type HandlerFunc func(*Context)
Поэтому для использования в Gin ПО промежуточного уровня со стандартной сигнатурой понадобится адаптер.
Если вы пользуетесь Gin — знайте, что в репозиториях gin-contrib представлена обширная коллекция модулей ПО промежуточного уровня, которые вы можете применить в своих проектах.
Другие варианты использования ПО промежуточного уровня
Паттерн «ПО промежуточного уровня» универсален, он широко используется в коде REST-серверов для решения множества самых разных задач. В примерах, приведённых в этом материале, мы рассматриваем лишь простое ПО, нацеленное на логирование данных и на обработку ошибок panic, так как моей целью было описание механизма работы с таким ПО, а не подробное обсуждение разных вариантов его использования.
В реальности ПО промежуточного уровня применяется в следующих сферах: стандартизированная проверка запросов, CORS, организация логирования данных, сжатие данных, работа с сессиями, отслеживание запросов, кеширование, шифрование, аутентификация. Об аутентификации, кстати, мы поговорим в одном из следующих материалов.
Итоги
В этом материале мы подробно рассмотрели паттерн «ПО промежуточного уровня», обращая особое внимание на интеграцию такого ПО в код REST-сервера с использованием разных подходов. А именно — мы работали с таким ПО, применяя лишь средства стандартной библиотеки Go, пользовались маршрутизатором gorilla/mux и полномасштабным фреймворком Gin. Надеюсь, что вы, прочитав этот материал, разобрались с тем, как работает ПО промежуточного уровня, и с тем, как пользоваться им в собственных проектах.
В заключение этого материала хочу вас кое о чём предупредить: ПО промежуточного уровня — это не универсальный инструмент, идеально подходящий для решения всех задач. Им, как и любым другим инструментом, не стоит злоупотреблять. Использование такого ПО усложняет процесс прохождения запросов по внутренним каналам серверов, ухудшает читабельность кода и делает более сложной его отладку. Я очень порекомендовал бы описывать весь код ПО промежуточного уровня в одном месте и избегать конструкций, где такое ПО встраивается в код обработки маршрутов динамически, условно, или внутри другого такого ПО. Если вы так и поступите, то, через некоторое время занимаясь отладкой собственного кода, будете очень себе благодарны.
Какой подход к работе с ПО промежуточного уровня вы используете в своих Go-серверах?