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

# Встроенные middleware: Logger, Recoverer, RequestID

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

Встроенные middleware chi из пакета chi/middleware закрывают базовую обвязку сервиса: RequestID присваивает каждому запросу идентификатор, RealIP выправляет адрес клиента за прокси, Logger пишет строку на запрос, Recoverer ловит панику со стеком. Разберём каждый слой, честные ловушки и порядок установки боевого стека из четырёх middleware.

Сервис из [прошлого урока](/courses/chi/lessons/05-middleware/) умеет оборачивать запросы своими слоями. Два события проверяют его на прочность: в логах копятся строки без общего знака — какой запрос из них чей, — и паника в одном хендлере роняет весь процесс вместе с остальными живыми запросами. Обе проблемы решает пакет 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 верит тому, что ему написали.** Сервис без прокси включил RealIP «для статистики» — и вот в логах клиенты из Лондона и Токио, а лимит по IP не работает вовсе: заголовок X-Forwarded-For пишет клиент, а не только nginx. Спуфинг здесь не уязвимость внешнего мира, а свойство слоя: chi переписывает RemoteAddr по значению заголовка, не проверяя, кто его поставил. Граница простая: RealIP — только за доверенным прокси, который перезаписывает заголовок сам; без прокси слой не включается вообще. В новых chi — ClientIPFromXFFTrustedProxies со списком доверенных адресов.

### Боевой стек из четырёх middleware

```go
package 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](/courses/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"). Что получит клиент?

- ✅ 500 Internal Server Error, паника и стек — в журнале
- Процесс упадёт: панику перехватить нельзя
- 200 OK: Recoverer молча съедает панику
- Обрыв соединения без ответа

Recoverer перехватит панику в defer, запишет её со стеком в общий журнал и вернёт клиенту 500. Исключение — http.ErrAbortHandler: его Recoverer сознательно не ловит, это служебный сигнал обрыва соединения. Без Recoverer процесс действительно упал бы — recover не работает между горутинами.

### Что покажет строка лога?

```go
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
```

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

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

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

> **Recoverer не спасает горутины.** Хендлер запустил go func() { ... }(), и паника случилась в горутине — Recoverer её не увидит: recover() перехватывает паники только в своей горутине, а middleware работает в горутине запроса. Процесс упадёт целиком, со стеком на stderr и всеми активными запросами. Правило: каждая goroutine, которую порождает хендлер или библиотека, получает собственный defer с recover или уходит в воркер-пул с обработкой паник. Это самая дорогая иллюзия безопасности, которую даёт Recoverer в стеке.

### Heartbeat и NoCache: по месту

```go
r := 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 и дедлайн в контексте

```go
r := 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?

- После Logger: важно, чтобы лог печатался первым
- ✅ До Logger: RemoteAddr исправляется до того, как его прочитает кто-то ещё
- Порядок не влияет: RemoteAddr всегда реальный
- RealIP ставят в самом конце стека

RealIP переписывает r.RemoteAddr на входе цепочки, а Logger печатает from с этим адресом. Слой должен отработать раньше всех, кто смотрит RemoteAddr, — поэтому в боевом стеке RealIP стоит сразу после RequestID. И помните границу: только за доверенным прокси.

### Соберите боевой стек

Восстановите три слоя стека в порядке, который рекомендует chi: идентификатор, лог, защита от паники.

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

// идентификатор запроса — раньше всех
r.Use(middleware.___)

// строка лога с [id] и статусом
r.Use(middleware.___)

// паника не уронит процесс
r.Use(middleware.___)
```

Подсказка: RequestID кладёт id в контекст, Logger печатает строку, Recoverer ловит панику — именно в этом порядке.

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

### Минимальный боевой стек — и что добавить следом

- 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: от самого внешнего слоя к самому внутреннему.

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

1. `func main() {`
2. `r := chi.NewRouter()`
3. `r.Use(middleware.RequestID)`
4. `r.Use(middleware.RealIP)`
5. `r.Use(middleware.Logger)`
6. `r.Use(middleware.Recoverer)`

### Карточки: встроенные middleware

- **middleware.RequestID** — Берёт X-Request-Id из заголовка или генерирует id (хост, случайный префикс, счётчик) и кладёт в контекст; достаётся middleware.GetReqID(r.Context()).
- **middleware.RealIP** — True-Client-IP, X-Real-IP или X-Forwarded-For переписывают r.RemoteAddr. В новых chi помечен deprecated из-за подмены адреса; ставить только за доверенным прокси.
- **middleware.Logger** — Строка на запрос: время, [id], метод и адрес, from, статус, байты, длительность. Для продакшена — структурный логгер вроде zap.
- **middleware.Recoverer** — Ловит панику, пишет стек в журнал, возвращает 500. http.ErrAbortHandler не перехватывает. Горутины, порождённые хендлером, не защищает.
- **middleware.Timeout** — Кладёт дедлайн в контекст запроса; после истечения пишет 504 Gateway Timeout. Хендлер обязан следить за r.Context().Done().
- **middleware.Heartbeat** — Отвечает точкой и 200 на заданный путь для GET и HEAD — до маршрутов и авторизаций. Health-check для балансировщика без захода в логику.

Стек собран: четыре строки r.Use — и сервис умеет называть запросы, видеть клиента за прокси, вести журнал и переживать панику. Дальше по курсу слои станут адресными: [Route и Group](/courses/chi/lessons/07-groups-routes/) дают веткам маршрутов собственные стеки — авторизация только для /admin, лимиты только для API, — а общий боевой каркас остаётся на весь роутер. Полная карта курса — на [странице chi](/courses/chi/): от JSON API и централизованных ошибок до graceful shutdown и деплоя за прокси. Осталась одна честная оговорка урока: стек проверяется локальным Go — curl-ом на /ping и с нарочно запаниковавшим /boom, чтобы увидеть 500 и стек в журнале своими глазами.

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

- Пакет 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.
