Содержание урока · 12 разделов
- Пишем кастомный middleware gin: своя функция в цепочке
- Фабрика-замыкание: LoggerWithConfig из исходников
- Тайминг-звено и Abort: что видно после c.Next()
- c.Set и c.Get: константные ключи и типизация
- gin abort в своих звеньях: выбор помощника
- requireAPIKey на группе /admin: Group и Use
- Отказ в своём middleware: что выбрать
- Шесть правил собственного middleware
- Что дальше: от своего таймера к собственному логу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Пишем кастомный middleware gin: своя функция в цепочке#
Встроенных звеньев в ядре Gin ровно два — это выяснили в прошлом уроке, всё остальное разнообразие пишет ваш код. Свой кастомный middleware gin — не новый тип и не интерфейс: обычная функция с сигнатурой func(*gin.Context), поставленная в цепочку через Use или аргументы маршрута. Урок 06 мельком показал tracer-фабрику и authGate; теперь разберём их всерьёз, потому что на практике собственных звеньев набираются десятки: тайминги и ловля медленных запросов, проверка API-ключей, сквозные идентификаторы для трассировки, метрики. Три темы образуют каркас. Первая — фабрика-замыкание: как gin сам пишет конфигурируемые middleware и почему подготовку делают один раз, а не на каждый запрос. Вторая — тайминг-звено: замер латентности вокруг c.Next() и то, что видно после Abort. Третья — передача данных: c.Set и c.Get с константными ключами и честной типизацией. В финале соберём всё вместе: группу /admin с requireAPIKey и Envelope-ответами из урока про JSON.
Первое звено: функция с сигнатурой HandlerFunc
GO// requestID — звено без фабрики: обычная функция, поставленная в цепочку.
const requestIDHeader = "X-Request-ID"
func requestID(c *gin.Context) {
// Метка запроса: наносекунд хватает для учебного примера.
id := fmt.Sprintf("req-%d", time.Now().UnixNano())
// Пишем в контекст: читатели — звенья ниже и хендлер.
c.Set("requestID", id)
c.Writer.Header().Set(requestIDHeader, id)
c.Next() // весь остаток цепочки исполняется внутри этого вызова
}
func main() {
r := gin.New()
r.Use(requestID) // глобально: маршруты, 404 и 405 тоже
r.GET("/ping", func(c *gin.Context) {
c.String(http.StatusOK, "pong")
})
r.Run(":8080")
}Никакой регистрации типов и интерфейсов: requestID — функция нужной сигнатуры, и r.Use(requestID) принимает её как есть. Обратите внимание на отсутствие скобок: в Use передаётся сама функция, а не результат вызова. Такая форма годится, когда звену не нужны настройки; стоит появиться параметру — секрету, порогу, адресу — и функция превращается в фабрику, о которой дальше.
Фабрика-замыкание: LoggerWithConfig из исходников#
Эталон того, как gin делает конфигурируемые middleware, лежит в его же исходниках: LoggerWithConfig(conf LoggerConfig) HandlerFunc в logger.go. Устройство показательное. Сначала — подготовка, которая выполняется один раз при регистрации: если конфиг не задал форматтер, берётся defaultLogFormatter; если не задал вывод, подставляется gin.DefaultWriter; из SkipPaths собирается карта путей-исключений; проверкой isatty выясняется, умеет ли терминал в цвета. И только затем — return func(c *Context) {...}, само звено, которое на каждый запрос стартует таймер, зовёт c.Next() и после него печатает строку лога. Такая конструкция и называется фабрика-замыкание: внешняя функция принимает конфиг, разрешает дефолты и возвращает замыкание, захватившее готовые значения. Выгода прямая: дорогие проверки и карты не пересоздаются на каждый запрос. Секундомер здесь стартует до c.Next() и снимается после — тот же приём мы положим в основу собственного тайминг-звена.
TimerWithConfig: тайминг-звено с конфигом
GO// TimerConfig — настройки тайминг-звена по образцу LoggerConfig.
type TimerConfig struct {
Slow time.Duration // порог: медленнее — попадает в лог
Out io.Writer // nil — пишем в os.Stdout
}
// Timer — короткая обёртка с дефолтами, как gin.Logger().
func Timer() gin.HandlerFunc {
return TimerWithConfig(TimerConfig{})
}
// TimerWithConfig — фабрика-замыкание: подготовка один раз.
func TimerWithConfig(conf TimerConfig) gin.HandlerFunc {
// Разрешаем дефолты ДО return — на этапе регистрации.
out := conf.Out
if out == nil {
out = os.Stdout
}
return func(c *gin.Context) {
start := time.Now() // замер ДО c.Next(), как в gin.Logger
c.Next() // внутри — вся оставшаяся цепочка
// Мы снова здесь, даже если звено ниже сделало Abort:
// abortIndex выводит из цепочки, но не из нашего звена.
latency := time.Since(start)
if latency < conf.Slow {
return // быстрый запрос не засоряет лог
}
tag := "ok"
if c.IsAborted() {
tag = "aborted"
}
fmt.Fprintf(out, "%s %s: %d за %v [%s]\n",
c.Request.Method, c.Request.URL.Path,
c.Writer.Status(), latency, tag)
}
}Каркас один в один с LoggerWithConfig: конфигурация с дефолтами, подготовка до return, звено-замыкание после. На каждый запрос исполняется только тело замыкания: start до c.Next(), time.Since(start) после. Код после c.Next() выполняется при любом исходе — даже когда звено ниже оборвало цепочку, — поэтому строка лога появится и для отклонённого запроса, со статусом из c.Writer.Status() и меткой aborted от IsAborted(). Импорты для полного файла: fmt, io, os, time.
Проверка: когда исполняется подготовка фабрики
В TimerWithConfig резолв дефолта out == nil стоит до return func. Когда он исполняется?
Тайминг-звено и Abort: что видно после c.Next()#
Самый частый вопрос про собственные тайминги: а что будет с замером, если ниже по цепочке кто-то вызвал Abort? Ответ упирается в механику из прошлого урока. Abort() присваивает внутреннему индексу константу abortIndex — 63 — и тем самым гарантирует выход из цепочки: цикл внутри Next() видит индекс больше длины срезов звеньев и завершается. Но завершается он обычным возвратом: управление возвращается в то звено, которое вызвало c.Next(), и его код после c.Next() исполняется как ни в чём не бывало. Поэтому таймер честно снимает латентность и для отклонённого запроса, а c.Writer.Status() возвращает уже финальный статус — тот, что записал AbortWithStatusJSON. Отличить прерванный запрос от штатного можно вызовом c.IsAborted(): она возвращает true, когда индекс дотянулся до abortIndex. Пара уточнений: Abort не прерывает текущую функцию — в звене, где его вызвали, всё равно нужен return; а звенья, стоявшие в цепочке ПОСЛЕ прервавшего, не выполняются вовсе — их «после» не наступает никогда.
Проверка: таймер выше requireAPIKey
Таймер стоит в цепочке выше requireAPIKey, клиент прислал запрос без ключа. Что таймер увидит после c.Next()?
Порядок звеньев группы и маршрута
Прочитайте код и выберите, что он выведет:
r := gin.Default()
admin := r.Group("/admin", requireAPIKey("k"))
admin.Use(tracer("G"))
admin.GET("/stats", tracer("M"), stats)
// GET /admin/stats, ключ верный.
// Что напечатают tracer'ы и в каком порядке?c.Set и c.Get: константные ключи и типизация#
Механику c.Set и c.Get мы разобрали в уроке 06: под капотом карта Keys, Get возвращает (any, bool), MustGet паникует. Здесь углубимся в то, что на практике решает, жить ли этой механике в большом проекте, — в соглашения о ключах. Ключи и значения имеют тип any, и голые строки-ключи быстро начинают конфликтовать: два пакета пишут в контекст ключ "user" с разными типами, и type assertion у читателя начинает срабатывать через раз. Лекарство — константные ключи своего типа: объявляете type ctxKey string и константы под каждый ключ, коллизии исчезают сами собой. Вторая половина договорённости — типизация на выходе: вытащенное значение приводится через type assertion, и ok-форма u, ok := v.(User) безопаснее голой, которая паникует на чужом типе. В исходниках gin ровно так устроены типизированные помощники: обобщённая getTyped проверяет ok и делает приведение с игнором ошибки — GetString на пропуске вернёт пустую строку молча. Свои же хелперы withUser и userFromContext закрывают оба вопроса разом: кто пишет, кто читает и каким типом всё это пахнет.
Ключи-константы и типизированные хелперы
GO// ctxKey — свой тип ключей контекста: исключает коллизии со строками.
type ctxKey string
const (
ctxUserKey ctxKey = "app/user"
ctxClientKey ctxKey = "app/client"
)
// User — кто делает запрос (канонический тип из урока 06).
type User struct {
Name string
}
// withUser — единственная точка записи пользователя в контекст.
func withUser(c *gin.Context, u User) {
c.Set(ctxUserKey, u)
}
// userFromContext — единственная точка чтения: без паник на любом исходе.
func userFromContext(c *gin.Context) (User, bool) {
v, ok := c.Get(ctxUserKey)
if !ok {
return User{}, false
}
u, ok := v.(User) // type assertion в ok-форме
return u, ok
}Два маленьких хелпера заменяют разрозненные c.Set("user", ...) и v.(User) по всему проекту. Пишет ключ один пакет и одна функция — withUser; читает одна — userFromContext, и она возвращает (User, bool): false и на отсутствующем ключе, и на значении неожиданного типа. Названия констант с префиксом пакета — app/... — пригождаются, когда ключ приходит из сторонней библиотеки и хочется отличать свои от чужих.
Проверка: голая форма type assertion
Под ключом в контексте лежит строка, а код делает u := v.(User), игнорируя ok. Что произойдёт?
gin abort в своих звеньях: выбор помощника#
Своему звену нужен честный отказ, и тут весь набор gin abort-методов из прошлого урока превращается в рабочий выбор. c.Abort() — только прерывание: тело не пишется, ответ остаётся за кем-то другим; без ответа клиент получит пустой 200 — net/http дорисует статус сам. c.AbortWithStatus(code) — статус и заголовки без тела: компактный вариант, когда сказать нечего. c.AbortWithStatusJSON(code, obj) — Abort, затем JSON: штатный путь JSON API, в который просится Envelope-конверт из урока про JSON. c.AbortWithError(code, err) добавляет ошибку в c.Errors, откуда её подхватит логгер. У всех — общее правило, ставшее идиомой одной строки: сразу за abort-вызовом идёт return, потому что Abort не прерывает текущую функцию. И обратная ловушка: return без Abort — анти-паттерн, цепочка продолжится, защищаемый хендлер выполнится. Насчёт c.Next() в звеньях-стражах: формально он не обязателен — после return из вашего звена gin вызовет оставшиеся сам, но я ставлю его всегда: единообразный «выход» важнее экономии строки.
requireAPIKey: отказ с Envelope или пропуск дальше
GO// headerAPIKey — имя заголовка в константе: опечататься труднее.
const headerAPIKey = "X-API-Key"
// requireAPIKey — фабрика: секрет закрывается в замыкании один раз.
func requireAPIKey(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
if c.GetHeader(headerAPIKey) != secret {
// Abort, затем JSON: конверт из урока 05.
c.AbortWithStatusJSON(http.StatusUnauthorized, Envelope{
Error: &APIError{
Code: "UNAUTHORIZED",
Message: "неверный или пустой X-API-Key",
},
})
return // без return строки ниже всё равно исполнятся
}
// Успех: помечаем клиента для звеньев ниже.
c.Set(ctxClientKey, "api")
c.Next()
}
}Фабрика получила секрет и вернула замыкание, которое сравнивает заголовок с захваченным значением — секрет живёт в замыкании, а не в глобальной переменной. Отказ — AbortWithStatusJSON с Envelope и немедленный return: клиент получает 401 и тело вида {"error":{"code":"UNAUTHORIZED",...}}, а хендлеры группы не выполняются. На успехе звено пишет метку в контекст и передаёт ход.
requireAPIKey на группе /admin: Group и Use#
Писать requireAPIKey рядом с каждым маршрутом — та же копипаста, от которой мы уходили. У RouterGroup есть два способа прикрепить звено. Первый — аргументы Group: r.Group("/admin", requireAPIKey("s3cr3t")) — сигнатура Group(relativePath string, handlers ...HandlerFunc) принимает variadic-цепочку, и combineHandlers приклеивает её к каждому будущему маршруту группы. Второй — admin.Use(...), дописывающий звенья в группу позже; порядок аргументов Group и вызовов Use — это и есть порядок в цепочке. Группы вкладываются: Group внутри Group наследует звенья родителя, префиксы путей склеиваются. Разница с глобальным Use принципиальна: движковые звенья видят всё, включая 404 и 405, а групповые — только маршруты своей группы. Для requireAPIKey это ровно то, что нужно: незалогиненный запрос к /admin/stats получает 401 от звена группы, а /ping и статика ходят без ключа. Проверки маршрута при этом не дублируются — одна точка правды на всю админку.
Группа /admin целиком: stats и createTodo
GOfunc main() {
r := gin.New()
r.Use(gin.Recovery()) // паники превращаются в 500
r.Use(TimerWithConfig(TimerConfig{Slow: 200 * time.Millisecond}))
// Звено requireAPIKey получит каждый маршрут группы.
admin := r.Group("/admin", requireAPIKey("s3cr3t"))
{
// stats читает метку, которую requireAPIKey оставил в контексте.
admin.GET("/stats", func(c *gin.Context) {
client, _ := c.Get(ctxClientKey)
respondData(c, http.StatusOK, gin.H{
"client": client,
"todos": 3,
})
})
// createTodo: ShouldBindJSON из урока 04 + Envelope из 05.
admin.POST("/todos", func(c *gin.Context) {
var in TodoInput
if err := c.ShouldBindJSON(&in); err != nil {
respondError(c, http.StatusBadRequest,
"BAD_REQUEST", err.Error())
return
}
respondData(c, http.StatusCreated, gin.H{
"id": 42, "title": in.Title,
})
})
}
r.Run(":8080")
}Здесь встретились все нити урока: тайминг-звено глобально, requireAPIKey — аргументом группы, ShouldBindJSON разбирает ошибку вручную, потому что Should-семейство оставляет ответ вам (Bind-семейство само отвечает 400 — разбор в уроке про binding). respondData и respondError — помощники Envelope-конверта из урока про JSON: success несёт data, ошибка — error. Типы TodoInput, Envelope и APIError взяты канонические, чтобы пример собирался с предыдущими уроками курса.
Ответ клиенту без ключа
Прочитайте код и выберите, что он выведет:
r := gin.New()
r.Use(TimerWithConfig(TimerConfig{Slow: time.Second}))
admin := r.Group("/admin", requireAPIKey("s3cr3t"))
admin.GET("/stats", stats)
// curl -i localhost:8080/admin/stats
// Заголовок X-API-Key не передан.Отказ в своём middleware: что выбрать#
| Решение | Что происходит | Когда брать |
|---|---|---|
| c.Abort() + return | index = abortIndex, тело не пишется; клиенту уйдёт пустой 200 | почти никогда: только если ответ за вас напишет другое звено |
| c.AbortWithStatus(code) | Status + WriteHeaderNow + Abort: статус и заголовки без тела | компактный отказ, когда телу нечего сказать |
| c.AbortWithStatusJSON(code, obj) | Abort, затем c.JSON: статус и JSON-тело | штатный путь API: 401/403/429 в Envelope-конверте |
| c.AbortWithError(code, err) | AbortWithStatus плюс c.Error: ошибка попадает в c.Errors | когда отказ должен дойти до Logger и мониторинга |
| return без Abort | цепочка продолжается: защищаемый хендлер выполнится | никогда — анти-паттерн и источник дыр в авторизации |
Keys защищены мьютексом, но только на чтение и запись пары ключ-значение; сами значения никто не стерегёт. Если хендлер положил в контекст изменяемый срез и отдал его горутине — добро пожаловать в гонку. Правило: всё, что живёт дольше запроса, работает с c.Copy(). Копия переносит срез звеньев и слепок Keys на момент копирования и уже «прервана» — её index выставлен в abortIndex, запустить из неё цепочку нельзя. Это безопасно ровно потому, что горутина не может продолжить запрос, которого больше нет. И да, тайминги в горутине снимайте по-взрослому: через свой срез параметров, а не через чтение Writer после возврата соединения в пул.
Допишите requireAPIKey
Восстановите чтение заголовка, abort-ответ с телом, выход из звена и передачу хода.
Соберите запрос /admin/stats без ключа
Таймер стоит глобально выше группы, клиент не передал X-API-Key. Разложите события в порядке исполнения.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Шесть правил собственного middleware#
- сигнатура func(*gin.Context) — уже middleware; фабрика нужна, как только появляется конфиг
- дорогую подготовку — дефолты, карты, проверки окружения — делайте до return: один раз на регистрацию
- start := time.Now() до c.Next(), латентность — после: код после Next() исполняется даже при Abort ниже
- отказ — это abort-помощник плюс return; return без Abort пропускает запрос дальше
- ключи контекста — константы своего типа, запись и чтение — через маленькие хелперы в ok-форме
- для горутин — только c.Copy(): копия уже прервана и не может продолжить цепочку
Что дальше: от своего таймера к собственному логу#
Посмотрите на TimerWithConfig ещё раз: это же половина gin.Logger — таймер вокруг c.Next() и печать собранных фактов. До полноценного логгера не хватает форматтера с параметрами запроса, фильтров путей и интеграции с c.Errors — и это ровно территория следующего урока, 08-logirovanie-recovery, где разбираются формат логов, свои logger-и и паники в хендлерах. Если проект дорос до структурированных журналов, своё звено пишется по тому же каркасу фабрики, а вывод уходит не в os.Stdout, а в библиотечный logger — каркас менять не придётся, только тело замыкания. Ключи-константы из середины урока пригодятся там же: RequestID-подобное звено удобно добавлять идентификатор в каждый лог через контекст.
Карточки: кастомные middleware
Соберите main из урока и постучите дважды: curl -i localhost:8080/admin/stats без заголовка и с заголовком X-API-Key: s3cr3t. Первый запрос вернёт 401 с error-конвертом и строкой таймера с [aborted]; второй — 200 с data. В браузерной песочнице сайта сетевой код не исполняется — примеры этого урока, как и весь курс gin, живут в локальном go run; интерактивы здесь — quiz, predict, fill, order и флешкарты. Сравните лог обоих запросов: латентность снимается в обоих случаях.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Кастомное middleware — это функция с сигнатурой HandlerFunc: Use или аргументы маршрута ставят её в цепочку без всякой регистрации.
- Конфигурируемое звено пишется фабрикой-замыканием по образцу LoggerWithConfig: дефолты и подготовка до return, замыкание — на каждый запрос.
- Тайминг строится вокруг c.Next(): start до, time.Since после; Abort ниже по цепочке не мешает — цикл Next() завершается обычным возвратом.
- После Abort видно финальный статус через c.Writer.Status() и флаг c.IsAborted(); звенья после прервавшего не выполняются вовсе.
- Ключи контекста — константы своего типа, чтение — ok-формой Get с type assertion; соглашение закрепляют хелперы withUser и userFromContext.
- Отказ — abort-помощник плюс return: AbortWithStatusJSON для JSON API, AbortWithStatus для пустого ответа, AbortWithError для c.Errors; return без Abort — анти-паттерн.
- r.Group("/admin", requireAPIKey) приклеивает звено ко всем маршрутам группы; групповые звенья, в отличие от глобального Use, не видят 404.
Домашнее задание
Достройте админку урока: добавьте звено requireRole("admin"), которое после requireAPIKey читает из контекста пользователя (положите его вторым звеном-фабрикой с ключом-константой) и отвечает AbortWithStatusJSON(403) с Envelope, если роль не совпала. Повесьте на группу /admin ещё один маршрут DELETE /todos/:id. Снимите таймингом латентность обоих запросов — 403 и 201 — и убедитесь, что строки лога приходят в обоих случаях, с меткой [aborted] только у отказа.
Личные заметкиПусто
Вопросы и ответы по уроку
Обязательно ли делать middleware фабрикой-замыканием?
Нет: функция с сигнатурой func(*gin.Context) уже middleware, как requestID из начала урока. Фабрика нужна, когда звену нужен конфиг — секрет, порог, writer: параметры закрываются в замыкании, подготовка исполняется один раз при регистрации.
Выполнится ли код после c.Next(), если цепочку прервали ниже?
Да. Abort выставляет индексу abortIndex (63), цикл внутри Next() завершается обычным возвратом, и управление возвращается в ваше звено. Не выполняются только звенья, стоявшие в цепочке после прервавшего; флаг виден через c.IsAborted(), статус — через c.Writer.Status().
Почему нельзя хранить значения контекста в глобальной переменной вместо c.Set?
Контекст живёт ровно один запрос и изолирован: параллельные запросы не перетирают данные друг друга. Глобальная переменная превратит эти данные в гонку. Для горутин, доживающих дольше запроса, есть c.Copy() — слепок Keys плюс уже прерванная цепочка.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Echo: минималистичный быстрый фреймворк
Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.
- Строить REST API на Echo
- Использовать типизированные запросы/ответы (Request/Response)
- Управлять ошибками через echo.HTTPError
- Писать middleware, включая JWT-аутентификацию