---
title: "Порядок middleware: почему он важен"
description: "Порядок middleware в chi: луковица цепочки, RequestID первым, Logger до Recoverer по исходникам, cors до auth, предохранители до бизнес-логики и таблица боевого стека."
course: "Chi: роутер для Go HTTP-сервисов"
url: "https://golangmaster.ru/courses/chi/lessons/18-middleware-poryadok/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 16
---

# Порядок middleware: почему он важен

*Курс «Chi: роутер для Go HTTP-сервисов», урок 18 из 25. ~16 минут. Обновлено 27.09.2026. Источник: https://golangmaster.ru/courses/chi/lessons/18-middleware-poryadok/*

Порядок middleware в chi — не вкусовщина, а поведение: поменять два слоя местами — и лог покажет 200 для упавшего с паникой запроса, а preflight получит 401 вместо согласия. Разберём луковицу цепочки, требование исходников chi к Logger и Recoverer и соберём боевой порядок слоёв в таблицу.

Инцидент из жизни сервиса: паника в новом хендлере, клиент получил 500, а в журнале — строка со статусом 200. Мониторинг, построенный на логах, показывает зелёный день, поддержка ловит жалобы, и эти две картинки не сходятся минут двадцать. Разбор по git-истории: на прошлой неделе кто-то «для чистоты стека» поставил Recoverer выше Logger — и строка лога ослепла. Второй баг из того же спринта: auth-слой перенесли выше cors-а, и preflight от браузера фронтенда стал получать 401 — фронтенд не может даже спросить разрешение, а в логах хендлеров пусто, потому что до хендлеров дело не доходит. Код слоёв в обоих случаях не менялся: не менялся вообще ни один хендлер. Менялся порядок r.Use — и этого хватило, чтобы сервис врал в лог и ломал браузер. middleware в chi — не список настроек, а конвейер, и перестановка двух строк меняет поведение так, как не меняет ни один рефакторинг.

### Порядок middleware: луковица chi

r.Use(A) и следом r.Use(B) собирают луковицу: слой A оборачивает слой B, B оборачивает хендлер. Запрос входит в A, потом в B, доходит до хендлера — и выходит наоборот: сначала своё дописывает B, потом A. Первый r.Use — самый внешний слой, последний — самый внутренний, хендлер — самое нутро. В исходниках chi middleware цепочка собирается из среза слоёв и оборачивает каждый вызов маршрута целиком — механика r.Use из [урока про цепочку middleware](/courses/chi/lessons/05-middleware/) здесь та же, меняется только вопрос: не «как подключить», а «в каком порядке». Отсюда правило чтения чужого стека: сверху вниз — путь запроса, снизу вверх — путь ответа. Каждый слой видит всё, что внутри него: лог видит статусы всех внутренних слоёв, recovery ловит паники всех внутренних слоёв, таймер меряет всё, что происходит под ним. Порядок middleware в chi и есть ответ на вопрос «кто кого видит»: подняли слой выше — он ослеп, опустили ниже — ослепли все над ним.

### Луковица двумя слоями из Println

```go
func trace(name string) func(http.Handler) http.Handler {
        return func(next http.Handler) http.Handler {
                return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                        fmt.Println("вход:", name)
                        next.ServeHTTP(w, r)
                        fmt.Println("выход:", name)
                })
        }
}

r.Use(trace("внешний"))
r.Use(trace("внутренний"))

// GET /notes печатает по порядку:
// вход: внешний
// вход: внутренний
// выход: внутренний
// выход: внешний
```

Два слоя из Println показывают луковицу буквально: вход по порядку регистрации, выход строго наоборот. Каждая пара строк обнимает всё, что зарегистрировано ниже, — на этом строятся и лог, который обнимает ответ, и recovery, который обнимает панику, и таймер из собственного middleware. Проверьте на локальном Go с любым маршрутом — песочница сеть не поднимает.

### Проверка: кто снаружи

**Вопрос:** В роутере сначала r.Use(requestTimer), затем r.Use(authRequired). В каком порядке запрос пройдёт слои?

- ✅ Вход: requestTimer, затем authRequired; выход: authRequired, затем requestTimer
- Вход: authRequired, затем requestTimer — последний Use выполняется первым
- Оба слоя одновременно: chi запускает middleware параллельно
- Порядок не влияет: chi выстраивает слои по алфавиту имён

Первый r.Use — самый внешний слой: запрос входит в него первым, до хендлера порядок совпадает с регистрацией. После хендлера порядок обратный: внутренний слой дописывает ответ раньше внешнего. На этом держится и связка лога с recovery — лог снаружи успевает увидеть статус, который recovery записал внутри его обёртки.

### Logger до Recoverer: что говорят исходники chi

Механика обоих слоёв видна в исходниках v5. Logger создаёт запись, оборачивает ResponseWriter обёрткой и пишет строку в defer — после next.ServeHTTP: в строке оказывается статус, который обёртка успела увидеть. Recoverer в defer ловит панику и вызывает WriteHeader(500) — на том ResponseWriter, который ему передали. Если Logger снаружи, пятисотой от Recoverer проходит через обёртку, и строка лога показывает честный 500. Если Logger внутри Recoverer, при раскрутке его defer срабатывает раньше recover-а: в обёртке на тот момент нет ни одной записи статуса, и в лог уходит 200 по умолчанию, пока клиент получает 500 мимо обёртки. chi фиксирует это требование прямо в доккомментарии Logger: «Logger should go before any other middleware that may change the response, such as middleware.Recoverer». Стек паники при этом Recoverer печатает через PrintPrettyStack в stderr — с Logger и без него, а вот строка запроса зависит от порядка. Правило шире одной пары: до Logger не ставят ни один слой, который сам пишет ответ, — из урока про встроенные middleware это Logger и Recoverer, дальше к списку добавятся Timeout и Throttle.

### Слои поменяли местами — что в логе?

```go
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer) // кто-то поменял слои местами
r.Use(middleware.Logger)

r.Get("/boom", func(w http.ResponseWriter, r *http.Request) {
        panic("boom")
})

// запрос: GET /boom
```

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

- Строка лога со статусом 500: Recoverer вернул статус через обёртку Logger
- ✅ Строка лога со статусом 200: обёртка не видела записи статуса, клиент получит 500
- В логе ничего: Logger не выполняется, если запрос упал с паникой
- Строка лога со статусом 404: до хендлера дело не дошло

Паника раскручивает стек снизу вверх: defer Logger-а срабатывает раньше recover-а в Recoverer, и в обёртке на тот момент нет записанного статуса — печатается 200 по умолчанию. Затем Recoverer перехватывает панику и пишет 500 прямо в исходный ResponseWriter, мимо обёртки. Клиент получает 500, журнал — зелёный 200: ровно инцидент из начала урока.

### RequestID и RealIP: служебные слои в начале

RequestID — первый содержательный слой стека. Logger собирает строку один раз, при завершении запроса, и в момент сборки читает GetReqID из контекста: идентификатор, положенный позже Logger-а, в строку не попадает — журнал останется без [id], и сшивать записи между службами будет нечем. RealIP ставится рядом и тоже до Logger: from-адрес в строке лога берётся из RemoteAddr в момент сборки, и слой должен переписать его на адрес клиента заранее — иначе в логе один и тот же прокси на каждую запись. Оба слоя ничего не пишут в ответ и не решают судьбу запроса: их работа — подготовить данные для всех, кто ниже. По этой же причине они почти не могут паниковать — и это не мелочь, а условие места в стеке, к которому вернёмся в красной врезке. Если балансировщик долбит /ping, слой Heartbeat ставят совсем первым: проба отвечает точкой и 200, не доходя ни до лога, ни до чего-либо ещё.

### cors до auth: preflight без ключа

Preflight — OPTIONS-запрос, который браузер шлёт перед непростым запросом, и у него есть свойство, ломающее порядок слоёв: Authorization в нём нет. Браузер не может приложить токен, потому что ещё не получил согласие сервера, — токен появится только в настоящем запросе. Если authRequired стоит выше cors.Handler, preflight доходит до проверки токена, токена нет — 401, заголовков согласия в ответе тоже нет, и браузер бракует запрос сам: фронтенд лежит, сервер невиновен, в логах хендлеров пусто. Поэтому [cors.Handler](/courses/chi/lessons/14-cors/) регистрируется раньше auth: на preflight он отвечает сам, сверив Origin с настройками, и до авторизации дело не доходит, а настоящие запросы идут дальше — к проверке токена уже только там, где браузер его реально приложил. Общее правило из этого случая: слой, который должен отвечать на запрос особым образом, ставится выше слоя, который отсекает запросы по общему признаку.

### cors и auth в правильном порядке

```go
r := chi.NewRouter()

r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(cors.Handler(cors.Options{
        AllowedOrigins: []string{"https://notes.example.com"},
        AllowedHeaders: []string{"Authorization", "Content-Type"},
}))

// auth ПОСЛЕ cors: preflight OPTIONS приходит без токена
r.Use(authRequired)

r.Get("/api/notes", listNotes)
r.Post("/api/notes", createNote)
```

cors.Handler стоит раньше authRequired: preflight он отвечает сам и до проверки токена дело не доходит, а настоящие запросы проходят дальше с Authorization в заголовке. Поменяйте два слоя — и каждый preflight станет 401 без заголовков согласия: фронтенд ляжет молча, потому что в логах хендлеров отражений этой аварии не будет вовсе.

### Проверка: preflight мимо cors

**Вопрос:** authRequired зарегистрирован выше cors.Handler. Браузер отправил preflight OPTIONS. Что получит фронтенд?

- ✅ 401 от authRequired без заголовков согласия: fetch падает ещё до настоящего запроса
- 200 с заголовками Access-Control-*: auth пропускает OPTIONS как безобидный
- 403 от cors.Handler: origin не разрешён настройками
- Preflight обойдёт оба слоя и попадёт прямо в хендлер

Preflight не несёт Authorization — приложить токен до получения согласия браузер не может. authRequired, стоящий снаружи cors-а, честно отвечает 401, заголовков Access-Control-* в ответе нет, и браузер бракует запрос самостоятельно. Отладка занимает часы: в логах хендлеров пусто, жалобу показывает только консоль браузера — при том что виноват порядок двух строк.

### Timeout и Throttle: предохранители до бизнес-логики

Дедлайн и лимит параллелизма включаются до дорогой работы — иначе они охраняют уже потраченные силы. Timeout из [урока про предохранители](/courses/chi/lessons/13-timeout-throttle/) кладёт контексту дедлайн: чем раньше слой в цепочке, тем больше работы покрыто таймером, поэтому его место — сразу после страховок и задолго до хендлеров. Throttle отказывает при перегрузке до авторизации и декодирования тела: толпа не должна тратить сервис на проверки, результат которых всё равно будет выброшен. Относительно Logger оба стоят после него — отказы должны попадать в строку лога с честным статусом: обёртка видит и 504 от Timeout, и отказ Throttle, который по исходникам v5.0.8 уходит как 429 Too Many Requests с опциональным Retry-After. И заметьте направление: предохранители — тоже слои, меняющие ответ, поэтому они внутри Logger-а. Всё, что пишет ответ, живёт под логом; всё, что только готовит данные, — над ним.

### Рекомендованный порядок слоёв

| Место | Слой | Зачем здесь |
| --- | --- | --- |
| 1 | Heartbeat("/ping") — по желанию | проба балансировщика отвечает 200 до лога, авторизации и всего остального |
| 2 | middleware.RequestID | id в контексте до первого, кто его читает: строки лога и ответы об ошибках |
| 3 | middleware.RealIP | адрес клиента в from до сборки строки лога; только за доверенным прокси |
| 4 | cors.Handler | preflight без токена получает согласие раньше auth-проверки |
| 5 | middleware.Logger | пишет после next — обязан быть снаружи всех, кто меняет ответ |
| 6 | errorHandler / Recoverer, Timeout, Throttle | страховки и отказы внутри обёртки лога: их статусы видны строке |
| 7 | authRequired и общая валидация | проверки с ресурсным смыслом — последними перед хендлерами |

> **Паника выше Recoverer не ловится.** Recoverer перехватывает паники только тех слоёв, что зарегистрированы после него — внутри его обёртки. Auth-слой, поставленный первым «чтобы отсекать сразу», паникует на кривом заголовке — и рядом нет чужого defer: панику подхватывает net/http, пишет в свой журнал http: panic serving и закрывает соединение. Клиент видит обрыв без ответа, в журнале chi пусто — ни 500, ни [id], и мониторинг по статусам слеп. Живое правило: выше Recoverer стоят только крошечные слои без собственной логики — RequestID, RealIP, cors; всё, что может упасть, — после него.

> **Спор: стандарт команды против интуиции новичка.** Новичок ставит Recoverer самым первым: страховка должна обнимать весь стек. Логика понятная — и всё же chi документирует обратное: Logger раньше Recoverer. Цена ошибки несимметрична: recovery снаружи лога превращает каждую панику в лживую строку 200, а слои выше Recoverer просто обязаны быть тривиальными. Я фиксирую порядок комментарием над стеком и тестом на два маршрута — /boom и preflight; интуиция имеет право на голос, но не на последний.

### Боевой стек целиком

```go
r := chi.NewRouter()

r.Use(middleware.RequestID)                  // id для всех логов и ошибок
r.Use(middleware.RealIP)                     // адрес клиента до Logger
r.Use(cors.Handler(cors.Options{             // preflight до auth
        AllowedOrigins: []string{"https://notes.example.com"},
}))
r.Use(middleware.Logger)                     // строка лога
r.Use(errorHandler)                          // единые ошибки: внутри Logger
r.Use(middleware.Timeout(30 * time.Second))  // дедлайн до логики
r.Use(middleware.Throttle(100))              // лимит параллелизма
r.Use(authRequired)                          // токены — у самой логики

r.Get("/healthz", health)
r.Post("/api/notes", createNote)
```

Восемь слоёв в порядке из таблицы. errorHandler из урока про централизованные ошибки здесь стоит после Logger — уточнение к тому стеку: ответы, отрендеренные после recover, пишутся в собственный w слоя, и внутри обёртки лога их статус виден строке, а снаружи — нет. Проверка на локальном Go: OPTIONS с Origin возвращает согласие без 401, /boom даёт 500 и строку лога со статусом 500.

### Чек-лист порядка слоёв

- r.Use читается сверху вниз как вход запроса и снизу вверх как выход ответа.
- RequestID — первый содержательный слой: id нужен каждой строке лога и каждому ответу об ошибке.
- Logger раньше любого слоя, который пишет ответ: Recoverer, errorHandler, Timeout, Throttle.
- cors.Handler раньше authRequired: preflight OPTIONS приходит без токена.
- Timeout и Throttle — до бизнес-логики и внутри Logger: отказ виден логу честным статусом.
- Слои выше Recoverer не имеют страховки от паники — держите их крошечными.

### Соберите начало боевого стека

Восстановите три слоя, от которых зависят строки лога.

```go
r := chi.NewRouter()

// идентификатор нужен каждой строке лога
r.Use(middleware.___)

// адрес клиента чинится до сборки строки лога
r.Use(middleware.___)

// chi документирует: Logger должен стоять до Recoverer
r.Use(middleware.___)
r.Use(middleware.Recoverer)
```

Подсказка: RequestID кладёт id в контекст, RealIP чинит RemoteAddr — оба до Logger, а Logger ставится до Recoverer, иначе строка покажет 200 вместо 500.

Правильные ответы в пропусках: `RequestID`, `RealIP`, `Logger`

### Соберите стек по строкам

Расставьте шесть слоёв боевого стека: от самого внешнего к самому внутреннему.

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

1. `r := chi.NewRouter()`
2. `r.Use(middleware.RequestID)`
3. `r.Use(middleware.RealIP)`
4. `r.Use(middleware.Logger)`
5. `r.Use(middleware.Recoverer)`
6. `r.Use(middleware.Timeout(30 * time.Second))`

### Карточки: порядок middleware

- **Луковица middleware** — Первый r.Use — внешний слой: запрос входит по порядку регистрации, ответ выходит наоборот. Хендлер — самый внутренний слой.
- **Logger до Recoverer** — Требование из доккомментария chi: Logger пишет строку в defer после next и должен увидеть статус, который Recoverer записал при панике. Наоборот — в логе 200 вместо 500.
- **RequestID первым** — Logger читает GetReqID в момент сборки строки: id, положенный позже, в строку не попадает. Нужен и ответам об ошибках, где он сшивает жалобу с логом.
- **cors до auth** — Preflight OPTIONS не несёт Authorization: auth выше cors отвечает 401 без заголовков согласия, и браузер бракует запрос сам, а логи хендлеров пусты.
- **Зона Recoverer** — Перехватывает паники только зарегистрированных после него слоёв. Паника выше — обрыв соединения и http: panic serving в журнале net/http.
- **Timeout и Throttle** — Предохранители до бизнес-логики: дедлайн и лимит включаются до дорогой работы, отказ — внутри Logger, чтобы лог видел честный статус (504 и 429).

Стек перестал быть стопкой случайностей: каждый слой стоит там, где его выход видят остальные, и поведение сервиса описывается таблицей, а не догадками. В живом проекте порядок съезжает незаметно — слой добавили в конец списка Use «чтобы не мешать», и частично ослеп лог или лёг preflight, — поэтому проверка порядка становится частью ревью: два маршрута, /boom и preflight, показывают всю луковицу за минуту. errorHandler из [урока про централизованные ошибки](/courses/chi/lessons/15-error-handling/) и пагинация из урока про валидацию встраиваются в таблицу на своих местах — страховки под логом, проверки у самой логики. Дальше по курсу — разбор структуры большого сервиса на subrouters, где каждый кусок API получит собственный стек поверх общего.

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

- Порядок middleware в chi — луковица: вход по порядку регистрации, выход строго наоборот.
- Logger стоит до Recoverer по доккомментарию chi: строка лога должна увидеть 500, а не 200.
- RequestID — первый содержательный слой, RealIP — до Logger: id и адрес клиента в каждой строке.
- cors.Handler раньше authRequired: preflight OPTIONS не несёт токена и не должен получать 401.
- Timeout и Throttle — до бизнес-логики и внутри Logger: отказ виден логу честным статусом.
- Recoverer защищает только слои после себя: выше него — крошечные слои без собственной логики.
- Порядок фиксируется таблицей и тестом на /boom и preflight — иначе он съезжает незаметно.

## Домашнее задание

Соберите стек из таблицы урока в каркасе заметок и проверьте порядок двумя маршрутами: /boom с паникой должен вернуть 500 и строку лога со статусом 500, preflight OPTIONS с разрешённым Origin — заголовки согласия без 401. Затем поменяйте Logger и Recoverer местами и убедитесь, что лог покажет 200, — лучший способ запомнить луковицу.

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

### Почему chi рекомендует Logger до Recoverer, а не наоборот?

Logger пишет строку после next.ServeHTTP и видит только записи в свою обёртку ответа. Recoverer при панике вызывает WriteHeader(500): если он внутри Logger, строка показывает честный 500; если Logger внутри Recoverer, обёртка фиксирует 200, а 500 уходит клиенту мимо лога. Требование зафиксировано в доккомментарии Logger в исходниках.

### Куда ставить свой errorHandler относительно Logger?

После Logger: errorHandler рендерит ответы сам, и внутри обёртки лога каждый его статус виден строке. В уроке про централизованные ошибки слой стоял до Logger — для честной строки при паниках поменяйте их местами: RequestID остаётся первым, и id в контексте от перестановки не теряется.

### Что будет, если RequestID поставить не первым?

Формально всё заработает, но Logger собирает строку с GetReqID в момент завершения запроса: id, положенный позже Logger-а, в строки не попадёт — журнал потеряет сквозной идентификатор. Его же не увидят ответы об ошибках, если те рендерят request_id из контекста. RequestID — всегда первый содержательный слой, сразу после Heartbeat.
