Паттерны middleware: цепочки, порядок, context и ловушки обёртки ResponseWriter
Middleware — это func(http.Handler) http.Handler, собранный снаружи внутрь: логирование вне recovery вне auth. Данные запроса едут в context с неэкспортируемыми типизированными ключами. Обёртка ResponseWriter ради статуса грозит двойным WriteHeader и потерей Flusher и Hijacker.
Дашборды говорили, что деплой идеален: error rate 0.0%, каждый ответ — 200. Очередь поддержки говорила обратное — клиенты видели пустые страницы. Час ушёл на поиск зазора: команда выкатила новый логирующий middleware, оборачивающий ResponseWriter, чтобы записывать код статуса, — поле по умолчанию 200, обновляется в WriteHeader. Но middleware восстановления после паник стоял снаружи логгера, поэтому когда хендлер паниковал, recovery писал свою 500 в сырой writer — обёртка логгера этого не видела и отчитывалась своим дефолтом. На той же неделе всплыла вторая жертва: эндпоинт server-sent events перестал стримить. Структура-обёртка не реализовывала Flusher, так что type assertion w.(http.Flusher) в SSE-хендлере тихо проваливался, и события буферизовались, пока соединение не умирало. Два инцидента, одна причина: обёртка ResponseWriter не прозрачна, а порядок middleware — не вопрос стиля. И то и другое — семантика, которую нужно проектировать.
Форма: функция, которая ест Handler и возвращает Handler
В Go нет фреймворка middleware, потому что интерфейс Handler уже им является. Middleware — любая функция такой сигнатуры:
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r) // вызов внутрь
slog.Info("request", "method", r.Method, "path", r.URL.Path,
"dur", time.Since(start))
})
}
// Композиция — просто применение функций, изнутри наружу:
handler := Logging(Recover(Auth(mux)))Замыкание выполняет код до и после next.ServeHTTP — это весь механизм. Поскольку ServeMux сам является Handler, один и тот же middleware оборачивает и один маршрут, и весь роутер. Цепочки читаются изнутри наружу: Logging(Recover(Auth(mux))) означает, что запрос проходит Logging → Recover → Auth → mux, а ответ разматывается через них в обратном порядке. Команды, которым не нравится пирамида вложенных вызовов, пишут десятистрочный помощник Chain(mux, Auth, Recover, Logging), сворачивающий слайс, — та же семантика, более плоский код. Не тащите ради этого фреймворк middleware: композиция функций и есть фича, и любой сторонний middleware, принимающий http.Handler, подключается напрямую.
Порядок — это политика, а не вкус
Когда видишь таинственные 200 в access-логе при жалобах клиентов на пустые страницы — первым делом проверь порядок middleware, а не код хендлера.
Каждый слой может наблюдать только слои внутри себя и защищать только слои внутри себя. Одно это правило диктует канонический порядок:
- Логирование — самым внешним: оно должно видеть каждый запрос, включая отвергнутые auth, и фиксировать статус, который пишет recovery. Поставьте его внутрь auth — и access-лог ослепнет к 401-м.
- Recovery — снаружи auth и бизнес-слоёв: паника в любом внутреннем слое должна стать 500 плюс стектрейс в логе, а не убитым соединением. (Встроенный recover net/http лишь закрывает соединение; клиент видит reset, а не статус.)
- Auth — раньше всего, что делает работу: rate limiting и парсинг тела для неаутентифицированного клиента — это оплата трафика атакующего.
Первый инцидент из приёма-крючка — нарушение этого правила: recovery стоял снаружи логирования, поэтому его 500 уходила в сырой writer, где логгер её не видел. Переверните — Logging(Recover(...)) — и recovery пишет через обёртку логгера. Универсально правильного полного порядка нет; есть правильный порядок относительно того, что каждый слой обязан наблюдать, и каждую позицию в своей цепочке вы должны уметь оправдать одним предложением.
Цепочка собрана как Recover(Logging(mux)) — recovery снаружи, затем логирование, затем роутер. Хендлер паникует. Что попадёт в access-лог для этого запроса?
Данные запроса: context с типизированными ключами
Auth-middleware аутентифицирует; хендлеру тремя слоями глубже нужен пользователь. Канал между ними — контекст запроса:
type ctxKey struct{} // неэкспортируемый тип: коллизия с другим пакетом невозможна
func WithUser(ctx context.Context, u *User) context.Context {
return context.WithValue(ctx, ctxKey{}, u)
}
func UserFrom(ctx context.Context) (*User, bool) {
u, ok := ctx.Value(ctxKey{}).(*User)
return u, ok
}
// мидлвэр: r = r.WithContext(WithUser(r.Context(), user))
// хендлер: u, ok := UserFrom(r.Context())Три правила удерживают это от гниения. Используйте неэкспортируемый тип ключа, никогда строку — строковые ключи сталкиваются между пакетами молча. Оборачивайте доступ в типизированные помощники, чтобы API Value с типом any не протекал в хендлеры. И держите в контексте только факты масштаба запроса, которые навешивает обобщённый слой: личность пользователя, trace ID, дедлайн. Как только значение становится обязательным входом бизнес-логики — ему место в параметре функции: значения контекста невидимы в сигнатурах, не проверяются компилятором и находятся в одном nil-assertion от паники. Честная цена: каждый WithValue — аллокация, а поиск — линейный проход вверх по цепочке; неважно при четырёх значениях, ощутимо при сорока.
Обёртка ResponseWriter и две ловушки
Чтобы логировать код статуса, придётся оборачивать — у ResponseWriter нет геттера:
type statusWriter struct {
http.ResponseWriter
status int
wrote bool
}
func (sw *statusWriter) WriteHeader(code int) {
if sw.wrote { return } // глотаем второй вызов: иначе net/http пишет в лог
sw.status, sw.wrote = code, true // "superfluous WriteHeader"
sw.ResponseWriter.WriteHeader(code)
}
func (sw *statusWriter) Write(b []byte) (int, error) {
if !sw.wrote { sw.WriteHeader(http.StatusOK) } // путь неявных 200
return sw.ResponseWriter.Write(b)
}Ловушка первая — WriteHeader, вызванный дважды: хендлер пишет 200, затем ветка ошибки пишет 500 — второй вызов на проводе уже no-op (заголовки отправлены), и net/http логирует superfluous response.WriteHeader. Обёртка обязана это моделировать, включая неявный WriteHeader(200), который вызывает первый Write. Ловушка вторая — стирание интерфейсов: встроенное поле пробрасывает три метода ResponseWriter, но конкретный writer под ним реализует ещё http.Flusher (стриминг), http.Hijacker (веб-сокеты забирают сырое соединение) и поддерживает io.ReaderFrom (путь sendfile, делающий http.ServeContent быстрым). Ваша обёртка не реализует ни одного, поэтому каждый w.(http.Flusher) ниже по течению теперь проваливается — мёртвый SSE-эндпоинт из приёма-крючка. Либо реализуйте и пробрасывайте нужные дополнительные интерфейсы, либо используйте http.NewResponseController(w) (Go 1.20+), который разворачивает слои в поисках поддержки flush и hijack, — это, плюс метод Unwrap() http.ResponseWriter на вашей обёртке, и есть современный контракт.
После деплоя обёртки ResponseWriter с захватом статуса эндпоинт websocket-апгрейда начал падать, а обычные JSON-эндпоинты работают. Каков наиболее вероятный механизм?
▸Почему это работает
Почему stdlib добавила http.NewResponseController, а не запретила обёртки и не сделала проброс автоматическим? Потому что обе крайности проигрывают: запрет обёрток убивает всю экосистему middleware, а автопроброс опциональных интерфейсов невозможен — тип обёртки либо имеет метод Flush, либо нет, а интерфейсы Go удовлетворяются на этапе компиляции. Контроллер — аварийный люк, который в рантайме идёт по цепочке Unwrap и спрашивает каждый слой о нужной способности; логирующая обёртка, ничего не знающая о flush, больше не ломает стриминг тремя слоями ниже — пока каждая обёртка в цепочке соблюдает конвенцию Unwrap.
- 01Обоснуй канонический порядок middleware — логирование, recovery, auth, роутер — через правило наблюдения.
- 02Перечисли ловушки обёртки http.ResponseWriter для захвата статуса и современные способы их обхода.
Middleware в Go — это интерфейс Handler, использованный дважды: middleware берёт следующий Handler и возвращает новый, выполняя код до и после next.ServeHTTP внутри замыкания. Цепочки композируются вложением — Logging(Recover(Auth(mux))) — читаются снаружи внутрь, а ответ разматывается обратно через каждый слой. Порядок — семантика, выводимая из одного правила: слой наблюдает только то, что оборачивает. Логирование снаружи фиксирует отказы auth и пятисотки recovery через собственную обёртку; recovery снаружи бизнес-слоёв превращает паники в залогированные 500 вместо голых обрывов соединения; auth выполняется раньше всего, что стоит денег. Факты масштаба запроса едут в контексте: неэкспортируемые типы ключей исключают межпакетные коллизии, типизированные помощники изолируют нетипизированный API Value, а всё, что требуется бизнес-логике, передаётся параметром, не значением контекста — WithValue аллоцирует, а поиск идёт по цепочке. Захват статуса требует обёртки ResponseWriter, а с ней две ловушки: WriteHeader фактически может сработать дважды (включая неявные 200 на первом Write), поэтому обёртка ведёт состояние записи; и встраивание стирает Flusher, Hijacker и ReaderFrom нижележащего writer, ломая SSE, веб-сокеты и путь sendfile для любых type assertion ниже. Пробрасывайте нужное, выставляйте Unwrap, используйте http.NewResponseController, чтобы доставать способности сквозь цепочки обёрток. Теперь, когда видишь 200 в access-логе, а клиент получил 500 — первый вопрос: какой слой стоит снаружи какого?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.