---
title: "Кастомные middleware"
description: "Пишем кастомный middleware gin сами: фабрика-замыкание, тайминг вокруг c.Next(), abort-паттерны, типизированные c.Set и c.Get, requireAPIKey на группе."
course: "Gin: самый популярный веб-фреймворк Go"
url: "https://golangmaster.ru/courses/gin/lessons/07-kastomnye-middleware/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Кастомные middleware

*Курс «Gin: самый популярный веб-фреймворк Go», урок 7 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gin/lessons/07-kastomnye-middleware/*

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

### Пишем кастомный middleware gin: своя функция в цепочке

Встроенных звеньев в ядре Gin ровно два — это выяснили в [прошлом уроке](/courses/gin/lessons/06-middleware/), всё остальное разнообразие пишет ваш код. Свой кастомный middleware gin — не новый тип и не интерфейс: обычная функция с сигнатурой func(*gin.Context), поставленная в цепочку через Use или аргументы маршрута. Урок 06 мельком показал tracer-фабрику и authGate; теперь разберём их всерьёз, потому что на практике собственных звеньев набираются десятки: тайминги и ловля медленных запросов, проверка API-ключей, сквозные идентификаторы для трассировки, метрики. Три темы образуют каркас. Первая — фабрика-замыкание: как gin сам пишет конфигурируемые middleware и почему подготовку делают один раз, а не на каждый запрос. Вторая — тайминг-звено: замер латентности вокруг c.Next() и то, что видно после Abort. Третья — передача данных: c.Set и c.Get с константными ключами и честной типизацией. В финале соберём всё вместе: группу /admin с requireAPIKey и Envelope-ответами из [урока про JSON](/courses/gin/lessons/05-json-otvety/).

### Первое звено: функция с сигнатурой 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. Когда он исполняется?

- ✅ Один раз при регистрации звена: замыкание переиспользует готовую переменную
- На каждый запрос: gin вызывает фабрику заново для каждого соединения
- Только для первого запроса, дальше gin кеширует результат
- Ни разу: код до return не исполняется никогда

Фабрика вызывается ровно однажды — в момент r.Use(TimerWithConfig(...)) или регистрации маршрута; в цепочку попадает возвращённое замыкание. Перенеси подготовку внутрь замыкания — она станет платить на каждый запрос: именно поэтому gin в LoggerWithConfig поднимает дефолты, собирает skip-карту и проверяет isatty до return.

### Тайминг-звено и 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()?

- ✅ Латентность снята, c.Writer.Status() вернёт 401, IsAborted() — true
- c.Next() не вернётся: код после него не выполнится, строки лога не будет
- Паника: Abort роняет цепочку до ближайшего Recovery
- Латентность равна нулю: во время Abort таймер не тикает

Abort меняет только индекс: цикл Next() завершается штатно, и код таймера после c.Next() исполняется. Статус 401 в Writer остался от AbortWithStatusJSON, флаг прерывания виден через IsAborted(). Строка лога с меткой aborted — норма, а не сбой: ровно так gin.Logger печатает и отклонённые запросы.

### Порядок звеньев группы и маршрута

```go
r := gin.Default()
admin := r.Group("/admin", requireAPIKey("k"))
admin.Use(tracer("G"))
admin.GET("/stats", tracer("M"), stats)

// GET /admin/stats, ключ верный.
// Что напечатают tracer'ы и в каком порядке?
```

**Что выведет программа?**

- ✅ requireAPIKey (без печати) → G до → M до → stats → M после → G после
- G до → requireAPIKey → M до → stats → M после → G после
- M до → G до → requireAPIKey → stats → G после → M после
- requireAPIKey → M до → G до → stats → G после → M после

Group принимает звенья аргументами: requireAPIKey приклеился к группе первым, admin.Use дописал tracer("G") следом, а маршрутный GET добавил tracer("M") и stats в конец — combineHandlers склеивает срезы слева направо. Вход в луковицу в порядке регистрации, выход в обратном; Logger и Recovery из gin.Default() стоят ещё раньше всех. tracer здесь — фабрика из прошлого урока, печатающая «до c.Next()» и «после».

### 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. Что произойдёт?

- ✅ Паника interface conversion: в any лежит не User
- u получит нулевой User{} без всякой паники
- Ошибка компиляции: типы не совпадают
- u получит строку как есть — any хранит что угодно

Голая форма type assertion паникует в рантайме, если динамический тип не совпал; компилятор тут бессилен — any на этапе компиляции совместим с чем угодно. ok-форма u, ok := v.(User) вернёт false и нулевое значение. Отдельная ловушка — GetString и другие типизированные помощники: на чужом типе они тоже молча дают пустую строку, поэтому константные ключи своего типа важнее, чем кажется.

### 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](/courses/gin/lessons/04-binding/)). respondData и respondError — помощники Envelope-конверта из урока про JSON: success несёт data, ошибка — error. Типы TodoInput, Envelope и APIError взяты канонические, чтобы пример собирался с предыдущими уроками курса.

### Ответ клиенту без ключа

```go
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 не передан.
```

**Что выведет программа?**

- ✅ HTTP 401 и тело {"error":{"code":"UNAUTHORIZED",...}}; строка таймера появится с [aborted] и статусом 401
- HTTP 401 с пустым телом; строки таймера не будет
- HTTP 200 и JSON stats: requireAPIKey ничего не пишет без ключа
- Соединение оборвётся: код таймера после c.Next() не выполнится

requireAPIKey не находит заголовок и делает AbortWithStatusJSON(401, Envelope): Abort ставит индексу abortIndex, JSON пишет статус и тело. stats остаётся за бортом. Таймер при этом отработает до конца: c.Next() вернулся, латентность снята, Writer.Status() показал 401, IsAborted() дал true — в лог уходит строка с [aborted]. Пустое тело — это поведение AbortWithStatus, а не AbortWithStatusJSON.

### Отказ в своём 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 | цепочка продолжается: защищаемый хендлер выполнится | никогда — анти-паттерн и источник дыр в авторизации |

> **Горутины и контекст: c.Copy() не опция.** Keys защищены мьютексом, но только на чтение и запись пары ключ-значение; сами значения никто не стерегёт. Если хендлер положил в контекст изменяемый срез и отдал его горутине — добро пожаловать в гонку. Правило: всё, что живёт дольше запроса, работает с c.Copy(). Копия переносит срез звеньев и слепок Keys на момент копирования и уже «прервана» — её index выставлен в abortIndex, запустить из неё цепочку нельзя. Это безопасно ровно потому, что горутина не может продолжить запрос, которого больше нет. И да, тайминги в горутине снимайте по-взрослому: через свой срез параметров, а не через чтение Writer после возврата соединения в пул.

### Допишите requireAPIKey

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

```go
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.___()
        }
}
```

Подсказка: Чтение заголовка — c.GetHeader; отказ с JSON-телом — AbortWithStatusJSON; за ним немедленный return; передача хода — c.Next().

Правильные ответы в пропусках: `GetHeader`, `AbortWithStatusJSON`, `return`, `Next`

### Соберите запрос /admin/stats без ключа

Таймер стоит глобально выше группы, клиент не передал X-API-Key. Разложите события в порядке исполнения.

Правильный порядок строк:

1. `Таймер: start := time.Now()`
2. `Таймер: вызов c.Next() — цепочка уходит внутрь`
3. `requireAPIKey: c.GetHeader("X-API-Key") вернул пустую строку`
4. `requireAPIKey: c.AbortWithStatusJSON(401, Envelope) — Abort ставит index = 63`
5. `requireAPIKey: return — Abort не прерывает текущую функцию`
6. `stats: осталось за abortIndex и не вызвалось`
7. `Клиент получает 401 и тело с error-конвертом`
8. `Таймер: c.Next() вернулся — цикл увидел index = 63 и вышел`
9. `Таймер: latency := time.Since(start)`
10. `Таймер: c.IsAborted() вернул true`
11. `Таймер: c.Writer.Status() вернул 401`
12. `Таймер: строка с меткой [aborted] напечатана`

### Шесть правил собственного 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.
- **Тайминг-звено** — start := time.Now() до c.Next(), latency := time.Since(start) после. Код после Next() исполняется и при Abort ниже: индекс 63 завершает цикл Next() обычным возвратом.
- **Ключи-константы контекста** — type ctxKey string + константы: исключают коллизии строковых ключей между пакетами. Чтение — через хелпер с ok-формой Get и type assertion.
- **c.AbortWithStatusJSON** — Abort, затем JSON: статус и тело за один вызов. За abort-вызовом всегда return — Abort не прерывает текущую функцию. return без Abort — дыра: цепочка продолжится.
- **r.Group(path, mw)** — Group принимает variadic handlers: combineHandlers приклеивает их к каждому маршруту группы; admin.Use дописывает позже. Групповые звенья не видят 404 — в отличие от глобального Use.
- **c.Copy()** — Копия контекста для горутин: переносит Keys на момент копирования, index выставлен в abortIndex — из копии нельзя запустить цепочку. Значения под ключами мьютексом не защищены.

> **Проверьте всё с curl за пять минут.** Соберите main из урока и постучите дважды: curl -i localhost:8080/admin/stats без заголовка и с заголовком X-API-Key: s3cr3t. Первый запрос вернёт 401 с error-конвертом и строкой таймера с [aborted]; второй — 200 с data. В браузерной песочнице сайта сетевой код не исполняется — примеры этого урока, как и весь [курс gin](/courses/gin/), живут в локальном go run; интерактивы здесь — quiz, predict, fill, order и флешкарты. Сравните лог обоих запросов: латентность снимается в обоих случаях.

## Коротко: что запомнить

- Кастомное 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 плюс уже прерванная цепочка.
