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

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

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

Урок 7 из 25 ~20 мин +50 XP

Кастомные middleware

Встроенных звеньев в ядре Gin ровно два, а в живом проекте их нужны десятки — поэтому кастомный middleware gin пишут на каждый чих: тайминги, проверка API-ключей, сквозные идентификаторы запроса. Разберём фабрику-замыкание на манер LoggerWithConfig из исходников, тайминг-звено вокруг c.Next(), честный abort-паттерн с return, типизированные c.Set и c.Get — и соберём группу /admin с requireAPIKey.

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

Пишем кастомный 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

GO
func 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() + returnindex = 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цепочка продолжается: защищаемый хендлер выполнитсяникогда — анти-паттерн и источник дыр в авторизации

Допишите requireAPIKey

Восстановите чтение заголовка, abort-ответ с телом, выход из звена и передачу хода.

func requireAPIKey(secret string) gin.HandlerFunc { return func(c *gin.Context) { if c.(headerAPIKey) != secret { c.(http.StatusUnauthorized, Envelope{ Error: &APIError{Code: "UNAUTHORIZED", Message: "нет доступа"}, }) } c.Set(ctxClientKey, "api") c.() } }

Соберите запрос /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

Термин

Фабрика-замыкание

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

func(conf C) gin.HandlerFunc: дорогая подготовка до return — один раз, замыкание захватывает готовые значения. Эталон — LoggerWithConfig в исходниках gin.

1 / 6

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

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

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

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

Обязательно ли делать middleware фабрикой-замыканием?

Нет: функция с сигнатурой func(*gin.Context) уже middleware, как requestID из начала урока. Фабрика нужна, когда звену нужен конфиг — секрет, порог, writer: параметры закрываются в замыкании, подготовка исполняется один раз при регистрации.

Выполнится ли код после c.Next(), если цепочку прервали ниже?

Да. Abort выставляет индексу abortIndex (63), цикл внутри Next() завершается обычным возвратом, и управление возвращается в ваше звено. Не выполняются только звенья, стоявшие в цепочке после прервавшего; флаг виден через c.IsAborted(), статус — через c.Writer.Status().

Почему нельзя хранить значения контекста в глобальной переменной вместо c.Set?

Контекст живёт ровно один запрос и изолирован: параллельные запросы не перетирают данные друг друга. Глобальная переменная превратит эти данные в гонку. Для горутин, доживающих дольше запроса, есть c.Copy() — слепок Keys плюс уже прерванная цепочка.

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

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

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

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

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

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

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

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

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