Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 6 из 25 ~18 мин +50 XP

Middleware: встроенные

Цепочка звеньев перед хендлером — это и есть gin middleware: в ядре Gin их ровно два, gin.Logger и gin.Recovery, и оба заслуживают разбора по строкам. Пройдём сигнатуру HandlerFunc, луковицу выполнения вокруг c.Next(), прерывание c.Abort(), передачу пользователя через c.Set и c.Get — и научимся читать строку лога [GIN] так, чтобы она говорила всё.

Содержание урока · 13 разделов

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

GO
r := 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 в хендлере: что видят клиент и консоль

GO
r := 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(). Что получит клиент?

Порядок звеньев: 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-телом.

func authGate(c *gin.Context) { token := c.GetHeader("Authorization") if token == "" { c.(http.StatusUnauthorized, gin.H{"error": "нет токена"}) return } c.("user", User{Name: "Аня"}) c.() }

Пять правил встроенных звеньев#

  • middleware — это HandlerFunc: та же сигнатура func(*gin.Context), роль задаёт позиция в цепочке
  • код до c.Next() — вход, код после — выход; замеры латентности живут только после
  • Abort не прерывает текущее звено: за ним всегда return
  • Use до регистрации маршрутов — иначе звено не попадёт в цепочку
  • c.Set и c.Get живут ровно один запрос; для горутин — c.Copy()

Карточки: встроенные middleware Gin

Термин

HandlerFunc

нажмите, чтобы перевернуть
Определение

func(*gin.Context): хендлер и middleware — один тип. Роль определяет позиция в цепочке вызовов, собираемой combineHandlers при регистрации маршрута.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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()) и найдите, какая строка пропала на паникующем запросе.

Личные заметкиПусто
Хранятся только в вашем браузере (Gin · Middleware: встроенные)

Вопросы и ответы по уроку

Обязательно ли вызывать 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().

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Echo25 уроков~13 чпрактика25 уроков уже доступны

Echo: минималистичный быстрый фреймворк

Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.

  • Строить REST API на Echo
  • Использовать типизированные запросы/ответы (Request/Response)
  • Управлять ошибками через echo.HTTPError
  • Писать middleware, включая JWT-аутентификацию