Содержание урока · 10 разделов
- RequestID: метка каждого запроса
- RealIP: адрес клиента за прокси
- Обзор встроенных middleware chi
- Logger: строка на каждый запрос
- Recoverer: паника не роняет процесс
- Порядок стека: почему Logger раньше Recoverer
- Минимальный боевой стек — и что добавить следом
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Сервис из прошлого урока умеет оборачивать запросы своими слоями. Два события проверяют его на прочность: в логах копятся строки без общего знака — какой запрос из них чей, — и паника в одном хендлере роняет весь процесс вместе с остальными живыми запросами. Обе проблемы решает пакет chi/middleware: встроенные middleware chi — RequestID, RealIP, Logger, Recoverer и ещё десяток утилит — собраны авторами библиотеки и подключаются одной строкой r.Use каждая. Импорт — github.com/go-chi/chi/v5/middleware: подпакет живёт рядом с корневым, и имя пакета middleware в коде читается почти как список покупок. Здесь разберём четыре главных слоя, коротко — Timeout, Heartbeat и NoCache, а в конце соберём боевой стек из четырёх middleware и поговорим о порядке установки, который chi фиксирует прямо в исходниках.
RequestID: метка каждого запроса#
middleware.RequestID достаёт идентификатор из заголовка X-Request-Id, а если его нет — генерирует свой: префикс с именем хоста и случайной строкой плюс счётчик запросов. Идентификатор кладётся в контекст запроса, откуда его достаёт middleware.GetReqID(r.Context()) — из любого middleware ниже по цепочке. Зачем это нужно: одна пользовательская операция в распределённом сервисе порождает запросы к нескольким службам, и общий id — единственная ниточка, по которой журнал одной службы сшивается с журналом другой. Прокладка простая: балансировщик генерирует X-Request-Id, chi на входе его переиспользует, ваши слои логируют тот же id. Когда что-то сломалось, поддержка первым делом запрашивает идентификатор запроса — и вторым делом советует печатать его в ответах об ошибках. Logger и Recoverer из этого пакета подхватывают id автоматически — но только если RequestID стоит в стеке раньше них; к порядку вернёмся после знакомства со слоями.
RealIP: адрес клиента за прокси#
За nginx или HAProxy все запросы приходят с адресом прокси: r.RemoteAddr показывает 127.0.0.1, и лимиты по IP, гео-фильтры и статистика работают с одним и тем же «клиентом». middleware.RealIP смотрит на заголовки True-Client-IP, X-Real-IP и X-Forwarded-For — в этом порядке — и подменяет r.RemoteAddr на найденный адрес, после чего вся цепочка видит реального клиента. У слоя тяжёлая оговорка, которую chi теперь пишет прямо в коде: middleware помечен как deprecated. Причина — подмена адреса: заголовки слепо верят отправителю, и клиент без прокси может выдать себе любой X-Forwarded-For. Свежие версии chi предлагают семейство ClientIPFrom* с явным списком доверенных прокси и чтение адреса через GetClientIP. Для действующих проектов с nginx спереди RealIP остаётся рабочим, но правило неизменно: слой ставится только там, где перед сервисом стоит прокси, которому вы доверяете.
Сервис без прокси включил RealIP «для статистики» — и вот в логах клиенты из Лондона и Токио, а лимит по IP не работает вовсе: заголовок X-Forwarded-For пишет клиент, а не только nginx. Спуфинг здесь не уязвимость внешнего мира, а свойство слоя: chi переписывает RemoteAddr по значению заголовка, не проверяя, кто его поставил. Граница простая: RealIP — только за доверенным прокси, который перезаписывает заголовок сам; без прокси слой не включается вообще. В новых chi — ClientIPFromXFFTrustedProxies со списком доверенных адресов.
Боевой стек из четырёх middleware
GOpackage main
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
http.ListenAndServe(":8080", r)
}Боевой минимум из документации chi. RequestID раньше Logger — тогда каждая строка лога несёт [id]; RealIP до Logger — в логе адрес клиента, а не прокси. Logger раньше Recoverer — chi прямо документирует это в комментарии к Logger: 500 от Recoverer проходит через обёртку ответа Logger, и строка лога показывает честный статус. Проверьте стек локальным Go — песочница сайт сеть не поднимает.
Обзор встроенных middleware chi#
| Middleware | Что делает | Что будет без него |
|---|---|---|
| RequestID | id из X-Request-Id или генерация, в контекст запроса | логи служб не сшиваются между собой |
| RealIP | True-Client-IP, X-Real-IP, X-Forwarded-For в r.RemoteAddr | за прокси все клиенты выглядят как прокси; в новых chi deprecated |
| Logger | текстовая строка на запрос: метод, адрес, статус, байты, время | в логе пусто, пока не написан свой слой |
| Recoverer | ловит панику, пишет стек в лог, отдаёт 500 | паника одного хендлера роняет процесс |
| Timeout | дедлайн в контексте запроса, после него 504 | зависший хендлер держит соединение бесконечно |
| Heartbeat | крошечный ответ на заданный путь вроде /ping | балансировщик проверяет здоровье через боевые маршруты |
| NoCache | заголовки против кэширования, чистка ETag | прокси и браузеры кэшируют по своему усмотрению |
Logger: строка на каждый запрос#
middleware.Logger печатает строку на каждый завершённый запрос: время, [request id], метод и адрес в кавычках, источник — from, затем статус, размер ответа в байтах и длительность; в терминале статус покрашен. Строка собирается после выполнения цепочки: Logger оборачивает ResponseWriter, запоминает статус и размер, а печатает всё разом в defer — поэтому в лог попадают и ранние ответы из middleware, и 500 от Recoverer. Для разработки это идеальный инструмент: включил — и видишь всё. В продакшене текстовых строк быстро перестаёт хватать: их нельзя фильтровать по уровню и полям, а поиск по десяткам сервисов превращается в grep-марафон. Стандартное движение — структурный логгер: в курсе zap этому посвящён урок про HTTP-middleware, где chi-стек соединяется с zap. Logger остаётся для консоли и стендов, структурный логгер — для централизованных журналов.
Recoverer: паника не роняет процесс#
Паника в Go не перехватывается между горутинами: то, что упало внутри хендлера, убивает весь процесс — вместе с сотнями обрабатываемых запросов. middleware.Recoverer разрывает сценарий: в defer слой ловит recover(), печатает стек в журнал и отдаёт клиенту 500 Internal Server Error. С Logger в стеке стек-трейс попадает в общий журнал запросов, без него — на stderr в виде раскрашенного стека. Клиент получает честный 500, а не обрыв соединения, и мониторинг видит ошибку в статус-кодах. Деталь из исходников: панику http.ErrAbortHandler Recoverer сознательно не перехватывает — это сигнал net/http «оборвать соединение», и мешать ему нельзя. Recoverer — предохранитель, а не исправление: данные могли быть записаны наполовину, транзакция брошена. Правильная реакция на 500 из паники — починить код, который её вызвал, а не радоваться живучести.
Проверка: паника под Recoverer
В стеке RequestID, RealIP, Logger, Recoverer. Хендлер вызвал panic("boom"). Что получит клиент?
Что покажет строка лога?
Прочитайте код и выберите, что он выведет:
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Get("/boom", func(w http.ResponseWriter, r *http.Request) {
panic("boom")
})
// запрос: GET /boomХендлер запустил go func() { ... }(), и паника случилась в горутине — Recoverer её не увидит: recover() перехватывает паники только в своей горутине, а middleware работает в горутине запроса. Процесс упадёт целиком, со стеком на stderr и всеми активными запросами. Правило: каждая goroutine, которую порождает хендлер или библиотека, получает собственный defer с recover или уходит в воркер-пул с обработкой паник. Это самая дорогая иллюзия безопасности, которую даёт Recoverer в стеке.
Heartbeat и NoCache: по месту
GOr := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// health-check: балансировщик стучится в /ping
r.Use(middleware.Heartbeat("/ping"))
// динамическое API не должно кэшироваться
r.Use(middleware.NoCache)Heartbeat("/ping") отвечает точкой и 200 на GET и HEAD по заданному пути — маршруты и авторизации даже не затрагиваются, поэтому слой ставят первым в стеке. NoCache расставляет заголовки no-cache и no-store и вычищает ETag — для динамических API, где кэшировать имеет право только явный Cache-Control. Утилит в пакете шире: Throttle с очередью и Compress с gzip — у каждого свой урок дальше по курсу.
Timeout и дедлайн в контексте
GOr := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
r.Get("/report", func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
select {
case <-ctx.Done():
// дедлайн истёк — выходим, 504 напишет Timeout
return
case <-time.After(10 * time.Second):
w.Write([]byte("готово"))
}
})Timeout(30 time.Second) кладёт дедлайн в контекст запроса: по истечении клиенту пишется 504 Gateway Timeout — но хендлер сам не прервётся, он обязан следить за r.Context().Done(), иначе сигнал уйдёт впустую. Честный контракт, а не кнопка «убить зависший код». В примере работа на десять секунд помещается в дедлайн тридцать — хендлер отвечает «готово»; поставьте Timeout(5 time.Second) — и запрос завершится 504.
Порядок стека: почему Logger раньше Recoverer#
Порядок r.Use(a, b) — это путь запроса из прошлого урока, и для боевого стека chi фиксирует рекомендации в исходниках. RequestID первым: идентификатор должен лежать в контексте до того, как Logger соберёт строку, — тогда в каждой записи есть [id]. RealIP до Logger: иначе в логе адрес прокси, а не клиента. Logger раньше Recoverer — контринтуитивно для привыкших, что recovery ставят первым слоем, но у chi расчёт на то: Recoverer, поймав панику, пишет 500 в обёрнутый Logger-ом ResponseWriter, и строка лога показывает 500, а сам стек паники уходит в общий журнал через запись, которую Logger положил в контекст. Поменяйте местами — клиент всё равно получит 500, но строка лога покажет 200: обёртка не видела записи статуса. Recoverer замыкает стек — ближе всего к хендлерам, где паники и рождаются.
Проверка: место RealIP
Реальный адрес клиента нужен в строках лога. Где место middleware.RealIP?
Соберите боевой стек
Восстановите три слоя стека в порядке, который рекомендует chi: идентификатор, лог, защита от паники.
Минимальный боевой стек — и что добавить следом#
- r.Use(middleware.RequestID) — id каждого запроса; переиспользует X-Request-Id от балансировщика.
- r.Use(middleware.RealIP) — адрес клиента за прокси; без доверенного прокси слой не включается.
- r.Use(middleware.Logger) — строка на запрос с id, статусом и длительностью; в продакшене — структурный логгер.
- r.Use(middleware.Recoverer) — паника превращается в 500 со стеком в журнале.
- Следом по надобности: Timeout(30 * time.Second) и Throttle(100) — дедлайны и предохранитель от перегрузки.
- Для health-check балансировщика — Heartbeat("/ping") первым слоем; для API без кэша — NoCache.
Соберите стек по строкам
Расставьте четыре слоя боевого стека в порядке, который рекомендует chi: от самого внешнего слоя к самому внутреннему.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: встроенные middleware
Стек собран: четыре строки r.Use — и сервис умеет называть запросы, видеть клиента за прокси, вести журнал и переживать панику. Дальше по курсу слои станут адресными: Route и Group дают веткам маршрутов собственные стеки — авторизация только для /admin, лимиты только для API, — а общий боевой каркас остаётся на весь роутер. Полная карта курса — на странице chi: от JSON API и централизованных ошибок до graceful shutdown и деплоя за прокси. Осталась одна честная оговорка урока: стек проверяется локальным Go — curl-ом на /ping и с нарочно запаниковавшим /boom, чтобы увидеть 500 и стек в журнале своими глазами.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Пакет chi/middleware подключается как github.com/go-chi/chi/v5/middleware; каждый слой включается одной строкой r.Use.
- RequestID берёт X-Request-Id или генерирует id и кладёт его в контекст; GetReqID достаёт его из любого слоя ниже по цепочке.
- RealIP переписывает RemoteAddr по True-Client-IP, X-Real-IP и X-Forwarded-For — только за доверенным прокси; в новых chi слой deprecated в пользу ClientIPFrom*.
- Logger печатает строку с [id], методом, адресом, статусом, размером и длительностью; для продакшена — структурный логгер вроде zap.
- Recoverer ловит панику, пишет стек в журнал, возвращает 500 и сознательно пропускает http.ErrAbortHandler; горутины хендлера он не защищает.
- Timeout кладёт дедлайн в контекст и пишет 504, Heartbeat отвечает точкой на /ping, NoCache чистит кэширующие заголовки.
- Боевой порядок: RequestID, RealIP, Logger, Recoverer — Logger раньше Recoverer, чтобы строка лога показала 500, а паника ушла в общий журнал.
Домашнее задание
Соберите боевой стек из урока в каркасе заметок: RequestID, RealIP, Logger, Recoverer. Добавьте маршрут /boom с panic, Heartbeat("/ping") и Timeout(30 * time.Second). Запустите локальным Go, вызовите /boom и убедитесь, что клиент получил 500, строка лога показывает 500, а стек паники виден в журнале.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем middleware.Logger из chi отличается от zap?
Logger печатает текстовую строку на каждый запрос в stdout — без уровней и структуры: удобно в разработке и на стендах. Zap пишет структурные события с уровнями и полями, которые фильтруются и собираются централизованно. В продакшене строковый Logger меняют на структурный логгер.
Нужно ли ставить все встроенные middleware?
Нет: стек собирается под сервис. RequestID и Recoverer оправданы почти всегда, Logger — почти везде, RealIP — только за доверенным прокси. Timeout, Throttle, NoCache и Heartbeat добавляются по мере появления дедлайнов, нагрузки и health-check — по одному слою с пониманием его работы.
Что получит клиент при панике, если Recoverer не в стеке?
Соединение оборвётся без ответа: net/http напишет сообщение о панике в свой журнал и закроет соединение, а процесс вместе с активными запросами погибнет. Recoverer держит 500 и живой процесс, но это предохранитель — причину паники чинит разработчик, а не middleware.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Redis в Go: кэш, сессии и очереди на go-redis
Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.
- Подключаться к Redis из Go и настраивать пул соединений
- Кэшировать результаты запросов и придумывать TTL
- Работать с хэшами, списками, множествами и ZSET
- Строить сессии, счётчики, лидерборды и rate limiter