[Перевод] Разработка REST-серверов на Go. Часть 5: ПО промежуточного уровня

Это — пятый материал из серии статей, посвящённой разработке REST-серверов на Go. Здесь мы поговорим о ПО промежуточного уровня. У меня есть материал, посвящённый жизненному циклу HTTP-запросов в серверах, написанных на Go. Для того чтобы разобраться в том, о чём пойдёт речь ниже, вам нужно ориентироваться в этой теме.

qvedoqzdlzyt8k7i-hwwsumf0eu.png

ПО промежуточного уровня, созданное с использованием стандартных средств


Пришло время снова переработать наш сервер, являющийся частью системы управления задачами. Следующий пример основан на базовой версии сервера, описанного в первой части этой серии материалов, при разработке которого используются лишь возможности стандартной библиотеки 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-серверах?

oug5kh6sjydt9llengsiebnp40w.png

© Habrahabr.ru