open atlas
↑ К треку
Go с нуля до senior GO · 03 · 02

Паттерны middleware: цепочки, порядок, context и ловушки обёртки ResponseWriter

Middleware — это func(http.Handler) http.Handler, собранный снаружи внутрь: логирование вне recovery вне auth. Данные запроса едут в context с неэкспортируемыми типизированными ключами. Обёртка ResponseWriter ради статуса грозит двойным WriteHeader и потерей Flusher и Hijacker.

GO Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Дашборды говорили, что деплой идеален: 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.

Вспомните перед уходом
  1. 01
    Обоснуй канонический порядок middleware — логирование, recovery, auth, роутер — через правило наблюдения.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.