Содержание урока · 10 разделов
- Порядок middleware: луковица chi
- Logger до Recoverer: что говорят исходники chi
- RequestID и RealIP: служебные слои в начале
- cors до auth: preflight без ключа
- Timeout и Throttle: предохранители до бизнес-логики
- Рекомендованный порядок слоёв
- Чек-лист порядка слоёв
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Инцидент из жизни сервиса: паника в новом хендлере, клиент получил 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 здесь та же, меняется только вопрос: не «как подключить», а «в каком порядке». Отсюда правило чтения чужого стека: сверху вниз — путь запроса, снизу вверх — путь ответа. Каждый слой видит всё, что внутри него: лог видит статусы всех внутренних слоёв, recovery ловит паники всех внутренних слоёв, таймер меряет всё, что происходит под ним. Порядок middleware в chi и есть ответ на вопрос «кто кого видит»: подняли слой выше — он ослеп, опустили ниже — ослепли все над ним.
Луковица двумя слоями из Println
GOfunc 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). В каком порядке запрос пройдёт слои?
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.
Слои поменяли местами — что в логе?
Прочитайте код и выберите, что он выведет:
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 /boomRequestID и 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 регистрируется раньше auth: на preflight он отвечает сам, сверив Origin с настройками, и до авторизации дело не доходит, а настоящие запросы идут дальше — к проверке токена уже только там, где браузер его реально приложил. Общее правило из этого случая: слой, который должен отвечать на запрос особым образом, ставится выше слоя, который отсекает запросы по общему признаку.
cors и auth в правильном порядке
GOr := 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. Что получит фронтенд?
Timeout и Throttle: предохранители до бизнес-логики#
Дедлайн и лимит параллелизма включаются до дорогой работы — иначе они охраняют уже потраченные силы. Timeout из урока про предохранители кладёт контексту дедлайн: чем раньше слой в цепочке, тем больше работы покрыто таймером, поэтому его место — сразу после страховок и задолго до хендлеров. 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 перехватывает паники только тех слоёв, что зарегистрированы после него — внутри его обёртки. Auth-слой, поставленный первым «чтобы отсекать сразу», паникует на кривом заголовке — и рядом нет чужого defer: панику подхватывает net/http, пишет в свой журнал http: panic serving и закрывает соединение. Клиент видит обрыв без ответа, в журнале chi пусто — ни 500, ни [id], и мониторинг по статусам слеп. Живое правило: выше Recoverer стоят только крошечные слои без собственной логики — RequestID, RealIP, cors; всё, что может упасть, — после него.
Новичок ставит Recoverer самым первым: страховка должна обнимать весь стек. Логика понятная — и всё же chi документирует обратное: Logger раньше Recoverer. Цена ошибки несимметрична: recovery снаружи лога превращает каждую панику в лживую строку 200, а слои выше Recoverer просто обязаны быть тривиальными. Я фиксирую порядок комментарием над стеком и тестом на два маршрута — /boom и preflight; интуиция имеет право на голос, но не на последний.
Боевой стек целиком
GOr := 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 не имеют страховки от паники — держите их крошечными.
Соберите начало боевого стека
Восстановите три слоя, от которых зависят строки лога.
Соберите стек по строкам
Расставьте шесть слоёв боевого стека: от самого внешнего к самому внутреннему.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: порядок middleware
Стек перестал быть стопкой случайностей: каждый слой стоит там, где его выход видят остальные, и поведение сервиса описывается таблицей, а не догадками. В живом проекте порядок съезжает незаметно — слой добавили в конец списка Use «чтобы не мешать», и частично ослеп лог или лёг preflight, — поэтому проверка порядка становится частью ревью: два маршрута, /boom и preflight, показывают всю луковицу за минуту. errorHandler из урока про централизованные ошибки и пагинация из урока про валидацию встраиваются в таблицу на своих местах — страховки под логом, проверки у самой логики. Дальше по курсу — разбор структуры большого сервиса на subrouters, где каждый кусок API получит собственный стек поверх общего.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Порядок 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.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Subrouters и структура большого сервиса
- Тестирование хендлеров: httptest и chi
- Аутентификация: JWT и сессии как middleware
- Производительность chi: что под капотом
- Валидация параметров и тел запросов
- Graceful shutdown: выключаемся вежливо
- Централизованные ошибки: errorHandler и NotFound
- CORS: чиним браузерные запросы
- Все курсы по Go
Следующая ступень учебного пути
Redis в Go: кэш, сессии и очереди на go-redis
Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.
- Подключаться к Redis из Go и настраивать пул соединений
- Кэшировать результаты запросов и придумывать TTL
- Работать с хэшами, списками, множествами и ZSET
- Строить сессии, счётчики, лидерборды и rate limiter