Содержание урока · 13 разделов
- gin middleware: HandlerFunc и цепочка вызовов
- c.Next() и луковица: код до и после
- c.Abort(): прерывание цепочки
- c.Set и c.Get: обмен данными между звеньями
- gin.Default(): New плюс Logger и Recovery
- gin.Logger: анатомия строки лога
- gin.Recovery: паника не убивает сервер
- Порядок звеньев: Use до маршрутов и Logger перед Recovery
- Abort-семейство: что внутри и когда брать
- Пять правил встроенных звеньев
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
gin middleware: HandlerFunc и цепочка вызовов#
Отдельного типа «middleware» в Gin нет: любой разбор gin middleware начинается с HandlerFunc — func(gin.Context), того же типа, что и у обычного хендлера. Роль определяет позиция: звено стоит раньше финального обработчика и обычно зовёт c.Next(), чтобы пропустить ход дальше. Цепочка вызовов собирается при регистрации маршрута: r.GET("/todos", authGate, listTodos) — первый аргумент после пути, а дальше variadic, звеньев может быть сколько угодно. Глобальные звенья через r.Use(...) приклеиваются впереди: combineHandlers копирует накопленные звенья группы и звенья маршрута в один срез, и на каждый запрос этот срез исполняется слева направо по индексу внутри Context. У Use есть любопытное свойство: он глобален для движка — звено попадает и в 404, и в 405, и в отдачу статики, поэтому документация прямо называет Use правильным местом для Logger и Recovery. Любая ваша функция с сигнатурой func(gin.Context) может встать в цепочку: middleware — не магия, а обычный код с удачной позицией.
Цепочка из трёх звеньев на одном маршруте
GO// tracer — простейшее middleware: печатает вход и выход звена.
func tracer(tag string) gin.HandlerFunc {
return func(c *gin.Context) {
fmt.Println(tag, "до c.Next()")
c.Next() // всё, что после, сработает «на выходе»
fmt.Println(tag, "после c.Next()")
}
}
func main() {
r := gin.Default() // Logger и Recovery уже в цепочке
// Звенья исполняются слева направо: tracer, authGate, listTodos.
r.GET("/todos", tracer("звено-1"), authGate, listTodos)
r.Run(":8080")
}Полная цепочка этого маршрута: Logger, Recovery, tracer, authGate, listTodos — первые два добавлены gin.Default() через Use, остальные перечислены в r.GET. tracer — замыкание-фабрика: оно принимает тег и возвращает HandlerFunc, готовый к постановке в цепочку. Тот же tracer можно прицепить ко всем маршрутам разом: r.Use(tracer("общий")). Тел authGate и listTodos пока нет — ниже разберём их целиком; сейчас важен порядок, а не содержимое.
c.Next() и луковица: код до и после#
Механика в исходниках на удивление скромная: Next() увеличивает внутренний индекс и в цикле вызывает оставшиеся звенья среза до конца. Вся «магия порядка» — следствие одного цикла. Код до c.Next() выполняется на входе, до всех последующих звеньев; код после — на выходе, когда хендлер уже отработал и, возможно, что-то написал в ответ. Отсюда форма луковицы: звенья входят по очереди, а выходят в обратном порядке — и gin.Logger именно так замеряет латентность: старт таймера до c.Next(), снятие показаний после. Тот же эффект достижим через defer: отложенная функция сработает на выходе звена даже при панике ниже по цепочке — Recovery построен ровно на defer и recover. Есть тонкость, о которой редко пишут: если middleware не вызовет c.Next() вовсе, цепочка не оборвётся — gin вызовет оставшиеся звенья после возврата из вашего. Но «выход» вы потеряете: код после c.Next() — единственное место, откуда видно, что хендлер уже завершился, поэтому замеры и итоговые логи живут именно там.
Что напечатает цепочка из двух tracer'ов
Прочитайте код и выберите, что он выведет:
r := gin.Default()
r.GET("/ping", tracer("A"), tracer("B"), func(c *gin.Context) {
fmt.Println("хендлер")
c.String(http.StatusOK, "ok")
})c.Abort(): прерывание цепочки#
Abort() — единственный способ остановить цепочку вызовов: он присваивает внутреннему индексу константу abortIndex (63), и ни один последующий вызов уже не находит звеньев. Документация подчёркивает неудобную деталь: Abort не останавливает текущее звено — строки после него в той же функции продолжат исполняться, поэтому после Abort почти всегда нужен return. Проверить состояние можно c.IsAborted(). Вокруг Abort собрано семейство помощников: AbortWithStatus пишет статус с заголовками, AbortWithStatusJSON — это Abort плюс c.JSON из прошлого урока, AbortWithError ещё и кладёт ошибку в c.Errors, откуда её потом подхватит Logger. У цепочки есть технический предел: суммарное число звеньев обязано быть меньше 63, иначе combineHandlers паникует с сообщением too many handlers — страховка от случайно зацикленного Use. И обратная ловушка: если в authGate просто вернуть return без Abort, цепочка продолжится — listTodos выполнится без авторизации, ведь gin вызовет его сразу после выхода из вашего звена. Отказ — это всегда Abort плюс return, а не только return.
Проверка: Abort и текущая функция
В middleware вызвали c.Abort() и забыли return. Что произойдёт с оставшимися строками этого же звена?
authGate: c.Set, c.Next и выход из луковицы
GO// User — кто делает запрос; authGate кладёт его в контекст.
type User struct {
Name string
}
// authGate — пускает дальше или отвечает 401 с JSON-телом.
func authGate(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
// Прерываем цепочку и сразу пишем ответ.
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "требуется заголовок Authorization",
})
return // без return отработают и строки ниже
}
// Пишем пользователя в контекст для следующих звеньев.
c.Set("user", User{Name: "Аня"})
c.Next() // listTodos отработает внутри этого вызова
// Выход из луковицы: хендлер уже завершился.
fmt.Println("ответ ушёл, пользователь:", c.MustGet("user"))
}
func listTodos(c *gin.Context) {
// Get возвращает (any, bool): приводим тип сами.
v, ok := c.Get("user")
if !ok {
// authGate не стоял в цепочке — страхуемся.
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "нет пользователя в контексте",
})
return
}
user := v.(User)
c.JSON(http.StatusOK, gin.H{
"user": user.Name,
"todos": []Todo{{ID: 1, Title: "разобрать c.Next", Done: false}},
})
}Маршрут тот же, что в тизере прошлого урока: r.GET("/todos", authGate, listTodos). С заголовком Authorization authGate пишет User в контекст, c.Next() запускает listTodos, тот собирает ответ из контекста; после возврата хендлера печатается строка на выходе звена — MustGet здесь оправдан, authGate гарантированно отработал раньше. Без заголовка клиент получает 401 и тело {"error":"требуется заголовок Authorization"} — до listTodos дело не доходит.
c.Set и c.Get: обмен данными между звеньями#
Под капотом — карта Keys с мьютексом: Set лениво создаёт её и записывает пару ключ-значение, Get возвращает (value, exists). Ключ и значение имеют тип any: Gin не заставляет регистрировать типы, но и не проверяет их — вытащенное значение приводится через type assertion, и форма v, ok := c.Get(...) безопаснее голой. Для уверенных мест есть MustGet, паникующий на отсутствующем ключе, и типизированные помощники GetString, GetBool и другие — они тихо вернут нулевое значение, если ключа нет. Время жизни — ровно один запрос: Keys обнуляется при переиспользовании Context из пула, поэтому контекст — не кеш сессий и не хранилище конфига. Для передачи контекста в горутину берите c.Copy(): копия защищена от дальнейших изменений и уже «прервана» — из неё нельзя запустить цепочку. Соглашения о наборе ключей, кто пишет и кто читает, — тема урока 07-kastomnye-middleware; здесь важен сам механизм.
Проверка: c.Get на пустом контексте
Хендлер вызывает v, ok := c.Get("user") — ни одно звено не писало ключ "user". Что в переменных?
gin.Default(): New плюс Logger и Recovery#
В исходниках gin.Default() — две строки: gin.New(), затем engine.Use(Logger(), Recovery()). Встроенных middleware в ядре ровно два: если где-то видели RequestID, CORS или gzip «из коробки», это gin-contrib и сторонние пакеты, а не gin.New. Чистый движок New не содержит ни одного звена, но при этом у него уже выставлены настройки по умолчанию — редиректы trailing slash, доверие к X-Forwarded-For, буфер для multipart. Default при старте печатает предупреждение [GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached — то самое, что мелькало в логах ещё на первом сервере. Порядок аргументов Use(Logger(), Recovery()) не случайность: Logger ставится первым, Recovery вторым, и к концу урока станет ясно, что поменять их местами — значит потерять логи паник.
gin.Logger: анатомия строки лога#
Строка вида [GIN] 2025/06/01 - 15:04:05 | 200 | 341µs | 127.0.0.1 | GET "/todos" печатается звеном gin.Logger — это и есть тот самый gin logger, который виден в каждом туториале. Работает он через луковицу: запоминает time.Now() до c.Next(), а после собирает структуру LogFormatterParams и печатает её в gin.DefaultWriter — по умолчанию os.Stdout. StatusCode берётся из c.Writer.Status() — то есть уже финальный, каким его сделал любой ответ, включая AbortWithStatusJSON; Latency — разница таймеров; ClientIP — c.ClientIP() с учётом доверенных прокси; ErrorMessage — строка из c.Errors приватного типа; BodySize — сколько байт ушло клиенту. Если в URL была query-строка, она дописывается к пути — и токен из ?token= оседает в логах. Латентность перед печатью аккуратно обрезается: больше минуты — до десятков секунд, больше секунды — до десятков миллисекунд, больше миллисекунды — до десятков микросекунд. Цвета: 2xx зелёным, 4xx жёлтым, 5xx красным; GET синим, POST бирюзовым, DELETE красным — но только в терминале: при выводе в файл проверка isatty выключает ANSI-коды, и лог остаётся плоским текстом.
Своя строка лога: LoggerWithFormatter и SkipPaths
GOr := gin.New()
// Свой формат: LogFormatterParams несёт всё собранное о запросе.
r.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
// Ошибки из c.Errors попадают в строку сами.
return fmt.Sprintf("%s %s -> %d за %v\n",
param.Method, param.Path, param.StatusCode, param.Latency)
}))
// /health в лог не нужен: путь должен совпасть точно.
r.Use(gin.LoggerWithWriter(os.Stdout, "/health"))
// Query-строка по умолчанию дописывается к пути; чтобы не светить
// токены: gin.LoggerWithConfig(gin.LoggerConfig{SkipQueryString: true})
r.GET("/health", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
r.Run(":8080")Форматтер — функция от LogFormatterParams: вернуть нужно готовую строку, и перевод строки в конце обязателен — fmt.Fprint его не добавляет. LoggerWithWriter(out, notlogged...) принимает пути, которые не логируются: сравнение точное, /health будет молчать, а /health/ — уже нет. Полный набор настроек живёт в LoggerConfig: Output, Formatter, SkipPaths, SkipQueryString и свежий Skip — функция-фильтр, решающая по контексту, логировать ли запрос.
Что Logger напишет в поле пути
Прочитайте код и выберите, что он выведет:
r := gin.Default() // SkipQueryString не трогали
r.GET("/search", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
// curl "localhost:8080/search?q=gin&page=2"gin.Recovery: паника не убивает сервер#
Документация Recovery звучит буднично: middleware, который восстанавливается после любых паник и пишет 500, если паника была. Устройство — ровно то, что мы разобрали на луковице: звено регистрирует defer с recover() и лишь затем зовёт c.Next(). Паника в любом более глубоком звене раскручивает стек ровно до этого defer. Дальше два пути. Обычный: в DefaultErrorWriter (по умолчанию os.Stderr) уходит строка [Recovery] <время> panic recovered: с дампом запроса, значением паники и стеком, где видны файл, строка и даже исходный код; после этого Recovery вызывает defaultHandleRecovery — ошибка через c.Error оседает в c.Errors, а клиент получает AbortWithStatus(http.StatusInternalServerError): только статус, тело пустое. Отдельная ветка — обрыв соединения (EPIPE, ECONNRESET): писать 500 в мёртвый сокет бессмысленно, Recovery лишь кладёт ошибку в c.Errors и делает Abort. Дамп запроса при этом аккуратен: заголовок Authorization маскируется звёздочкой, чтобы токен не уехал в логи.
panic в хендлере: что видят клиент и консоль
GOr := gin.Default() // Recovery уже в цепочке
// GET /boom падает: деление на ноль — классическая паника рантайма.
r.GET("/boom", func(c *gin.Context) {
var zero int
_ = 100 / zero // паника: integer divide by zero
c.JSON(http.StatusOK, gin.H{"ok": true}) // сюда не дойдём
})
r.GET("/ping", func(c *gin.Context) {
c.String(http.StatusOK, "pong")
})curl -i localhost:8080/boom вернёт HTTP/1.1 500 с пустым телом: AbortWithStatus пишет только статус и заголовки. В консоли — [Recovery] panic recovered: значение паники (integer divide by zero), дамп запроса и стек до строки хендлера. Соседний /ping продолжает отвечать: recover съел панику на уровне звена, процесс не пострадал. Свой формат ошибки клиенту — CustomRecovery с функцией-обработчиком вместо дефолтной.
Проверка: ответ клиенту при панике
Хендлер вызвал panic("boom") при движке gin.Default(). Что получит клиент?
Без Recovery панику ловит уже net/http: он пишет в лог http: panic serving 127.0.0.1:xxxx, закрывает соединение и продолжает жить — клиент видит обрыв без всякого статуса. Recovery не защищает процесс (тот не падает и без него), он превращает панику в честный 500 со стеком. Отсюда моё правило: паника — не ветка бизнес-логики. Ошибки валидации и сервиса возвращайте значениями, как ShouldBindJSON из урока 04, который сам делает AbortWithError(400); Recovery держите как подстраховку от чужих багов и ошибок рантайма, а не как штатный error-handler.
Порядок звеньев: Use до маршрутов и Logger перед Recovery#
combineHandlers склеивает цепочку в момент регистрации маршрута: всё, что добавлено через Use до r.GET, войдёт в его срез звеньев; middleware, объявленное после, маршрут не увидит. Правило простое — сначала Use, потом маршруты; для групп то же самое: g.Use(...) до g.GET(...). Внутри цепочки порядок не декорация. Default ставит Logger перед Recovery не случайно: паникует хендлер — Recovery, звено ниже, ловит её defer'ом, ставит 500 — и когда исполнение возвращается в Logger, тот логирует уже финальный статус и сообщение ошибки из c.Errors. Переверните порядок: Recovery окажется выше, Logger ниже, паника пролетит сквозь кадр Logger'а, не дав выполниться коду после c.Next(), — строка лога на этот запрос не появится вовсе. Запрос восстановит всё тот же Recovery, но след в журнале потерян. Держите Logger первым: он обязан видеть всё, включая 500.
Соберите луковицу запроса GET /todos
Цепочка: gin.Default() плюс r.GET("/todos", authGate, listTodos), заголовок Authorization передан. Разложите события в порядке исполнения.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Abort-семейство: что внутри и когда брать#
| Метод | Что делает внутри | Когда брать |
|---|---|---|
| c.Abort() | index = abortIndex: следующие звенья не вызываются, тело не пишется | ответ за вас напишет кто-то другой или он вообще не нужен |
| c.AbortWithStatus(code) | Status + WriteHeaderNow + Abort | отказ без тела: редкие 403/429 с пустым ответом |
| c.AbortWithStatusJSON(code, obj) | Abort, затем c.JSON | штатные ошибки middleware с телом: 401, 403, 429 |
| c.AbortWithStatusPureJSON(code, obj) | Abort, затем PureJSON без экранирования < > & | как выше, когда важны точные байты (урок 05) |
| c.AbortWithError(code, err) | AbortWithStatus плюс c.Error в c.Errors | когда отказ должен дойти до Logger и мониторинга |
Допишите authGate
Восстановите запись в контекст, передачу хода и abort-ответ с JSON-телом.
Пять правил встроенных звеньев#
- middleware — это HandlerFunc: та же сигнатура func(*gin.Context), роль задаёт позиция в цепочке
- код до c.Next() — вход, код после — выход; замеры латентности живут только после
- Abort не прерывает текущее звено: за ним всегда return
- Use до регистрации маршрутов — иначе звено не попадёт в цепочку
- c.Set и c.Get живут ровно один запрос; для горутин — c.Copy()
Карточки: встроенные middleware Gin
Соберите примеры урока в main.go и постучите curl -i localhost:8080/boom: увидите и 500 с пустым телом, и [Recovery]-стек в консоли, и отсутствие строки для /health при SkipPaths. В браузерной песочнице сайта сетевой код не исполняется — как и весь курс gin, эти примеры живут в локальном go run; интерактивы здесь — quiz, predict, fill, order и флешкарты. Пара минут с curl даёт больше, чем третий перечитанный абзац.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Middleware — это HandlerFunc: func(*gin.Context); цепочка собирается combineHandlers из звеньев Use и звеньев маршрута и исполняется слева направо.
- c.Next() вкладывает остаток цепочки внутрь звена: код до — вход, после — выход в обратном порядке; без Next звенья всё равно вызовутся, но «выхода» не будет.
- c.Abort() обнуляет дальнейшую цепочку (index = abortIndex), но не прерывает текущую функцию — за Abort нужен return; abort-семейство добавляет статус, JSON и ошибку в c.Errors.
- c.Set/c.Get — карта Keys на один запрос: Get возвращает (any, bool), MustGet паникует, типизированные помощники дают нулевое значение; для горутин — c.Copy().
- gin.Default() = New() + Use(Logger(), Recovery()); встроенных middleware в ядре два, остальное — gin-contrib.
- gin.Logger меряет латентность вокруг c.Next() и печатает статус, латентность, IP, метод и путь с ?query; свои форматы — LoggerWithFormatter, отключение путей — SkipPaths.
- gin.Recovery ловит панику defer'ом: [Recovery] panic recovered со стеком в stderr, клиенту 500 без тела; Logger ставят перед Recovery, чтобы логировать итоговый статус.
Домашнее задание
Соберите движок на gin.New(): Logger с SkipPaths для /health, Recovery и authGate, который пишет c.Set("user", ...) и отвечает 401 без токена. Добавьте /boom с делением на ноль и убедитесь curl -i: статус 500 с пустым телом, [Recovery]-стек в консоли, /health в логах молчит. Затем переверните порядок на Use(Recovery(), Logger()) и найдите, какая строка пропала на паникующем запросе.
Личные заметкиПусто
Вопросы и ответы по уроку
Обязательно ли вызывать c.Next() в middleware?
Нет: без него gin вызовет оставшиеся звенья сразу после возврата из вашего. Но тогда не будет «выхода» — код после c.Next(), где живут замеры латентности и итоговые логи. Все встроенные middleware вызывают c.Next() явно.
Что вернёт gin.Recovery клиенту — JSON с ошибкой?
Нет: 500 с пустым телом через AbortWithStatus. Свой формат даёт CustomRecovery с функцией-обработчиком: положите ошибку в c.Errors и напишите тело сами — например, через c.AbortWithStatusJSON.
Почему после c.Abort() код всё равно исполняется?
Abort меняет только индекс цепочки: следующие звенья не вызовутся, но текущая функция работает до конца. Выход — return сразу за Abort; проверить, была ли прервана цепочка, можно c.IsAborted().
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Echo: минималистичный быстрый фреймворк
Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.
- Строить REST API на Echo
- Использовать типизированные запросы/ответы (Request/Response)
- Управлять ошибками через echo.HTTPError
- Писать middleware, включая JWT-аутентификацию