1. Введение
Если вы пишете сервер «в лоб», то почти сразу появляются сквозные требования: добавить заголовок всем ответам, логировать каждый запрос, проверять доступ, ограничивать частоту, ловить паники. И внезапно вы обнаруживаете, что один и тот же код дублируется в каждом handler’е. В какой-то момент вы меняете одно правило (например, формат ошибки) — и начинаете искать «ещё 17 мест, где это поправить».
Middleware решает эту проблему так же, как хорошая привычка мыть руки решает проблему «почему я опять простыл»: это не магия, а системность.
Ключевая идея: handler занимается бизнес‑логикой, а middleware занимается сквозной политикой вокруг этой логики.
Контракт net/http: http.Handler как точка обёртки
Перед тем как строить цепочки, важно ещё раз увидеть контракт, вокруг которого мы будем плясать. В net/http обработчик — это любой тип, у которого есть метод ServeHTTP(w, r). Именно поэтому в Go легко писать адаптеры и обёртки: вы просто возвращаете другой http.Handler, который внутри вызывает следующий. Это тот же «паттерн адаптера», только без пафосных слов.
Вспомним минимальный каркас сервера (сильно упрощённо):
package main
import (
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
log.Fatal(http.ListenAndServe(":8080", mux))
}
Почти всё интересное в дальнейшей архитектуре сервера крутится вокруг того, что именно мы передаём в ListenAndServe вторым аргументом.
Middleware как функция-обёртка
Когда впервые слышишь «middleware», хочется спросить: «Это библиотека? Это фреймворк? Это религия?» На самом деле это просто функция, которая берёт один handler и возвращает другой handler. То есть, буквально «обёртка» вокруг http.Handler.
Самая популярная сигнатура выглядит так:
func(next http.Handler) http.Handler
Чтобы код читался легче, обычно вводят именованный тип:
package httpmw
import "net/http"
type Middleware func(http.Handler) http.Handler
После этого любая middleware читается как «прими следующий слой (next) и верни новый слой».
2. Механика middleware: «до → next → после»
Почти любая middleware внутри выглядит как «сделать что-то до запроса», затем передать управление ниже, и «сделать что-то после». Это удобно представлять как луковицу: внешний слой видит запрос первым, а ответ — последним.
Давайте сделаем минимальную middleware, которая добавляет заголовок всем ответам. (Да, это простенько, но идеально для понимания механики.)
package httpmw
import "net/http"
func WithHeader(name, value string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(name, value) // "до"
next.ServeHTTP(w, r) // "передать ниже"
})
}
}
Обратите внимание: мы используем http.HandlerFunc, потому что это адаптер «функция как handler». То есть мы не обязаны объявлять отдельный type X struct{} только ради ServeHTTP.
Short-circuit: middleware может остановить запрос
У новичков часто есть внутреннее ощущение, что next.ServeHTTP(...) надо вызывать всегда, иначе «как же оно дальше-то пойдёт?». Но смысл некоторых middleware как раз в том, чтобы не пускать запрос дальше: проверка доступа, лимиты, maintenance‑режим.
Сделаем пример «сервер на техобслуживании»: если включён флаг — отвечаем сразу.
package httpmw
import "net/http"
func MaintenanceMode(enabled bool) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if enabled {
w.WriteHeader(http.StatusServiceUnavailable)
return // short-circuit: ниже не идём
}
next.ServeHTTP(w, r)
})
}
}
Здесь главное — понять мораль: middleware имеет право завершить запрос самостоятельно. Это не «хак», а основной инструмент построения политик.
4. Цепочка middleware и порядок применения
Когда middleware становится больше одной, мы хотим применить их все к одному mux (или к одному handler’у). И тут рождается важный вопрос: в каком порядке они применяются?
Если у нас есть:
- A — middleware №1
- B — middleware №2
- C — middleware №3
- H — финальный handler (например mux)
то логическая «луковица» обычно выглядит так:
A(B(C(H)))
То есть A — самый внешний слой: он видит запрос первым и «провожает» ответ последним.
Для понимания порядка полезна схема:
flowchart TD
R[HTTP request] --> A[Middleware A]
A --> B[Middleware B]
B --> C[Middleware C]
C --> H["Final handler (mux)"]
H --> C
C --> B
B --> A
A --> W[HTTP response]
Тут важный момент: «туда» мы идём A → B → C → H, а «обратно» (когда обработчик уже закончил) — H → C → B → A. Поэтому порядок middleware влияет даже на такие вещи, как логирование статуса или перехват паник: внешний слой «видит всё», что произошло внутри.
Функция Chain: собираем цепочку с читаемым порядком
Теперь хочется сделать так, чтобы порядок был:
- явно виден в коде,
- всегда собирался одинаково (а не «где-то так, где-то иначе»),
- не требовал ручного вложения A(B(C(H))), потому что это больно даже смотреть.
Сделаем helper Chain. Ключевой трюк: применяем middleware в обратном порядке, чтобы первая в списке стала внешней.
package httpmw
import "net/http"
type Middleware func(http.Handler) http.Handler
func Chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
И вот это место — «магия без магии»: список читается слева направо (как мы привыкли), но применяется справа налево, чтобы итог был mws[0](mws[1](...h)).
Кто выполнится первым: мини-таблица
Чтобы окончательно не запутаться (а вы запутаетесь, это традиция), держите простую таблицу. Представим:
h := Chain(mux, A, B, C)
Тогда:
| Сущность | Кто видит запрос первым | Кто вызывает next первым | Кто «закрывает» ответ последним |
|---|---|---|---|
|
да | да | да |
|
нет | после |
перед |
|
нет | после |
перед |
|
нет | самый внутренний | самый внутренний |
Если вы чувствуете лёгкое головокружение — это нормально. Middleware‑порядок сначала ощущается как «провода в наушниках»: кажется, что ты ничего не делал, а оно всё равно запуталось.
«Ручная матрёшка» vs Chain
Чтобы вы умели читать чужой код, полезно увидеть оба варианта. Иногда вы встретите вот такое:
h := A(B(C(mux)))
Это честно, просто, и работает. Но через неделю A(B(C(D(E(F(...))))) выглядит как пароль от Wi‑Fi в кафе: вроде символы есть, но радости мало.
С Chain то же самое превращается в:
h := Chain(mux, A, B, C)
И это уже читается как «A снаружи, потом B, потом C». Именно это мы и хотим.
5. Подключаем chaining в приложение без переписывания сервера
До этого дня мы передавали в ListenAndServe наш mux. Теперь мы будем передавать обёрнутый mux. И это важный architectural win: мы меняем поведение всего сервера, не трогая сами endpoints.
Скелет main.go (упрощённый, но по сути правильный):
package main
import (
"log"
"net/http"
"time"
"example.com/taskapi/httpmw"
)
func main() {
mux := http.NewServeMux()
registerRoutes(mux)
h := httpmw.Chain(mux,
httpmw.WithHeader("Content-Type", "application/json; charset=utf-8"),
httpmw.MaintenanceMode(false),
httpmw.WithTimeoutHint(2*time.Second),
)
log.Fatal(http.ListenAndServe(":8080", h))
}
Здесь registerRoutes(mux) — это ваш привычный код регистрации эндпоинтов (/api/v1/tasks, /api/v1/tasks/{id} и т.д.), который мы трогать не хотим. А WithTimeoutHint — ещё одна простая middleware, которую сейчас напишем, чтобы видеть пример «параметризованной» обёртки.
Пример middleware с параметром: WithTimeoutHint
Хочется показать middleware, которая принимает параметр и делает что-то полезное, но не требует сложных тем. Заголовки подходят идеально.
Допустим, мы хотим добавлять «намёк» клиенту: сервер ожидает, что запросы будут быстрые (это не настоящий таймаут, а просто демонстрация «сквозного поведения»).
package httpmw
import (
"net/http"
"time"
)
func WithTimeoutHint(d time.Duration) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Timeout-Hint", d.String())
next.ServeHTTP(w, r)
})
}
}
Плюс этого примера в том, что он показывает «middleware как фабрика»: вы один раз задаёте параметр при сборке сервера, и дальше для каждого запроса работает одинаковое правило.
Почему цепочку лучше собирать в одном месте
Когда цепочка middleware собирается в одном месте (обычно в main), вы получаете почти бесплатные преимущества.
Во‑первых, порядок можно прочитать глазами как договор сервера: сверху вниз перечислены политики, которые применяются ко всем запросам. Во‑вторых, если что-то сломалось в логике (например, внезапно пропали заголовки или сервер перестал отвечать на часть запросов), вы проверяете один файл, а не бегаете по десяткам handler’ов.
И, наконец, это дисциплинирует: одна middleware — одна ответственность. Когда вы начинаете смешивать в одной обёртке и заголовки, и auth, и логирование, и обработку паник, вы возвращаетесь к той самой «лапше», только в более модном соусе.
6. Нюанс про Context: как middleware протаскивает данные вниз
Сейчас мы не будем углубляться в хранение данных в контексте (это отдельная большая тема), но важно зафиксировать: middleware часто не только ставит заголовки, но и добавляет что-то в r.Context() и прокидывает дальше через r.WithContext(...).
Почему это вообще возможно? Потому что http.Request уже содержит Context, и стандартная библиотека ожидает, что вы будете уважать жизнь запроса через контекст (отмена, дедлайны, метаданные).
Пока просто запомним мысль: если middleware «создала новый контекст», его нужно положить обратно в r, иначе нижние слои не увидят изменений.
7. Типичные ошибки при chaining и порядке middleware
Ошибка №1: неправильное ожидание порядка («почему логирование не видит то, что я хотел?»).
Очень частая путаница: вы поставили middleware в список первой, но ждёте, что она «сработает после всех». На самом деле первая в списке обычно является самой внешней: она видит запрос первой и завершает ответ последней. Если вам нужно «выполниться ближе к handler’у», значит слой должен быть глубже (правее в списке).
Ошибка №2: забыли вызвать next.ServeHTTP(w, r) там, где надо пропускать запрос дальше.
Это классика: вы написали middleware, проверили один кейс, всё вроде хорошо. Потом оказывается, что сервер отвечает только 401/403/что‑то ещё — а «нормальные» запросы просто не проходят. Лечится дисциплиной: держать в голове каркас «до → next → после» и явно проверять все ветки return.
Ошибка №3: случайный двойной вызов next.ServeHTTP.
Иногда новички пытаются сделать «повторный вызов» или ошибочно вызывают next и в if, и после if. Итог: handler пишет в ResponseWriter дважды, появляются странные ответы, а иногда — паники. Почти всегда middleware должна вызывать next ровно один раз (или ноль раз, если short-circuit).
Ошибка №4: запись заголовков слишком поздно.
Заголовки нужно выставлять до того, как кто-то ниже начнёт писать тело ответа или вызовет WriteHeader. Если внутренний handler уже записал body, то «поменять заголовок потом» — как пытаться переписать титры после выхода фильма в кинотеатрах: теоретически хочется, practically уже поздно.
Ошибка №5: сборка цепочки «в разных местах» и дрейф порядка.
Сначала вы оборачиваете mux в одном файле, потом какой-то handler оборачивает другой handler «локально», потом в тестах появляется третья схема — и вы теряете понимание, что является глобальной политикой, а что локальным исключением. Хорошее правило: общий порядок middleware задаётся один раз на границе приложения, а локальные обёртки используются только если вы очень хорошо понимаете, зачем.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ