Содержание урока · 10 разделов
В REST-каркасе из прошлого урока хендлеры научились отвечать по делу: карточка заметки, список, обновление. Добавьте к этому нормальный сервис — и каждый хендлер обрастает служебным кодом: замерить время, записать в лог запрос, проверить токен. Три хендлера — три копии одного и того же. Десять — десять, и рано или поздно одну копию правят, а другую забывают. Middleware в chi снимает эти повторы с уровня хендлеров на уровень роутера: поведение описывается один раз как обёртка func(http.Handler) http.Handler и вешается на цепочку одним r.Use. Обёртка сама решает, что сделать до хендлера, что после — и вызвать ли его вообще. Это та же сигнатура, что во всей экосистеме net/http, поэтому сторонние слои подключаются без адаптеров. В этом уроке — механика цепочки, порядок прохождения запроса, ранние ответы и цена забытого вызова next.
Middleware в chi: сигнатура func(http.Handler) http.Handler#
Middleware — функция, которая принимает следующий хендлер и возвращает хендлер. Разберём по частям: func(http.Handler) http.Handler. Аргумент next — всё, что стоит дальше по цепочке: другие middleware и в конце ваш хендлер. Возвращаемое значение — новый http.Handler, обычно замыкание, внутри которого живёт старая цепочка. Внутри замыкания три места для кода. До next.ServeHTTP(w, r) — то, что должно случиться раньше хендлера: замер времени, присвоение id запроса, проверка заголовка. Вызов next.ServeHTTP(w, r) сам — передача эстафеты дальше; без него до маршрутизации и хендлера дело не доходит. После вызова — то, чему нужен результат: длительность, итоговый статус. Приём с defer здесь любимец: код после next гарантированно сработает, даже если хендлер запаниковал. Сигнатура не изобретена chi — это общий язык net/http-экосистемы, и любой middleware из интернета компилируется с chi как есть.
Первый middleware: таймер
GOfunc timer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
defer func() {
log.Printf("%s %s: %v", r.Method, r.URL.Path, time.Since(start))
}()
next.ServeHTTP(w, r)
})
}
func main() {
r := chi.NewRouter()
r.Use(timer)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
http.ListenAndServe(":8080", r)
}Таймер — классический первый middleware. start фиксируется до передачи эстафеты, а log.Printf уходит в defer: строка в логе появится после того, как цепочка отработает, — с методом, путём и длительностью. next.ServeHTTP(w, r) — сердце функции: именно он запускает маршрутизацию и хендлер. r.Use(timer) регистрирует слой на весь роутер. Для запуска нужен локальный Go: песочница сайта TCP-сервер не поднимает.
Луковица: порядок запроса и ответа#
r.Use(timer, logger, auth) складывает слои в стек, и порядок в списке — это направление входа. Запрос идёт снаружи внутрь: сначала timer, потом logger, потом auth, и только затем маршрутизация и хендлер. Ответ возвращается в обратном порядке: auth досмотрел на результат, logger дописал строку, timer посчитал общее время — уже с учётом всех, кто работал внутри. Картинка, которая прилипла к теме, — луковица: каждый middleware — слой, через который запрос проходит туда и обратно. Отсюда два практических вывода. Замер длительности имеет смысл ставить раньше других слоёв — иначе время, потраченное внутренними middleware, в замер не попадёт. А слои, которым нужен результат, например логгер со статусом ответа, располагают так, чтобы их вызов next стоял над тем, кто этот результат меняет; разбору порядка в chi посвящён отдельный урок дальше по курсу. Пока запомните простое: регистрация слева направо — это путь запроса.
Какой будет порядок строк?
Прочитайте код и выберите, что он выведет:
func alpha(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println("alpha: до")
next.ServeHTTP(w, r)
fmt.Println("alpha: после")
})
}
func beta(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println("beta: до")
next.ServeHTTP(w, r)
fmt.Println("beta: после")
})
}
// регистрация: r.Use(alpha, beta); запрос пришёл на существующий маршрутr.Use: регистрация и правила#
r.Use принимает сколько угодно middleware за один вызов: r.Use(timer, logger) эквивалентен двум строкам r.Use(timer) и r.Use(logger). Слои вешаются на весь роутер; отдельный стек для ветки маршрутов делается через Route и Group — об этом урок через один. И правило, о которое спотыкаются все: Use вызывают до регистрации маршрутов. Chi собирает цепочку один раз — на первом r.Get стек замораживается, и поздний r.Use приводит к панике с говорящим текстом: all middlewares must be defined before routes on a mux. Паника честная: middleware после маршрутов — почти всегда ошибка чтения кода, и chi предпочитает упасть на старте, а не удивлять в проде. Практика сборки: сначала все r.Use сверху вниз, потом маршруты — тогда файл читается как путь запроса, что сверху, то и выполнится раньше.
Middleware, который не вызвал next.ServeHTTP и не записал ничего в w, оставляет клиента с открытым соединением и без ответа: браузер крутится, curl висит до собственного таймаута или до WriteTimeout у http.Server. Типичный способ поймать это — новый слой с return внутри условия, где return сработал и на легитимных запросах. Отладка проста: клиент завис — эстафета оборвалась где-то в цепочке, проверяйте каждый слой на вызов next. Обратный случай легален: middleware отвечает сам — авторизация с 401 — но это осознанное решение, а не потерянный вызов.
Три места поведения в middleware#
| Фаза | Что доступно | Примеры |
|---|---|---|
| До next.ServeHTTP | запрос целиком: заголовки, путь, тело | замер времени, проверка токена, id запроса |
| Сам вызов next | эстафета дальше по цепочке | обязателен, если слой не отвечает сам |
| После next или в defer | результат: статус, длительность, паника | строка лога, метрика, восстановление |
Собираем цепочку из двух слоёв
GOfunc main() {
r := chi.NewRouter()
// порядок Use = путь запроса
r.Use(timer) // снаружи: засекает время первым
r.Use(logger) // внутри: логирует уже после таймера
r.Get("/health", health)
r.Get("/notes/{id}", getNote)
// GET /notes/42 идёт: timer -> logger -> маршрутизация -> getNote,
// ответ возвращается в обратном порядке
http.ListenAndServe(":8080", r)
}Два слоя плюс хендлер — уже луковица: timer обёрнут вокруг logger, logger вокруг маршрутизации и хендлера. Комментарии в коде — то, что стоит проговаривать при ревью: Use сверху вниз, запрос снаружи внутрь, ответ обратно. Запустите локальным Go и посмотрите в лог: одна строка таймера на каждый запрос, каким бы ни был маршрут — включая 404, о них следующий блок.
Middleware видит и 404#
Запрос на несуществующий путь всё равно проходит через весь зарегистрированный стек. Chi сначала прогоняет запрос через middleware и только потом ищет маршрут; когда ни один шаблон не совпал, в работу включается обработчик 404 — но уже внутри цепочки. Для логов это ключевой сценарий: кривые запросы сканеров, опечатки после редизайна, пробы чужих API — всё попадает в журнал с длительностью и id, как обычные запросы. В фреймворках с другим устройством 404 нередко отдаётся до middleware, и лог молчит ровно тогда, когда интереснее всего. Побочный эффект стоит запомнить: тяжёлые слои считаются и на мусорных запросах, так что middleware, который на каждом входе лезет в базу, начнёт есть ресурсы и на переборе адресов. Служебные проверки делайте дешёвыми и ранними — или ставьте дешёвый фильтр первым слоем.
Проверка: 404 и цепочка
Запрос пришёл на путь, которого нет ни в одном маршруте. Что произойдёт с зарегистрированными middleware?
Ранний ответ: middleware отвечает сам#
Вызов next — не обязанность любой ценой, а выбор. Если дальше идти нельзя, middleware отвечает сам и эстафету не передаёт: проверка токена не сошлась — w.WriteHeader(http.StatusUnauthorized) и return; квота исчерпана — 429. Клиент получает ответ, остальные слои и хендлер не тратят ни такта. Заметьте: «после» в слоях выше всё равно сработает — defer в таймере напечатает строку и для отклонённого запроса, ведь ответ вернулся по цепочке, просто не дошёл до хендлера. Обратная задача — передать данные вниз, в хендлер: в Go это делается через context.Context, к которому доступ из *http.Request. Разбор контекста — в уроке продвинутого курса, а собственный middleware с пользователем в контексте будет позже в курсе chi, после знакомства с готовыми слоями.
Middleware с ранним ответом
GOfunc auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
http.Error(w, "нет токена", http.StatusUnauthorized)
return // эстафета не передаётся
}
next.ServeHTTP(w, r)
})
}
r := chi.NewRouter()
r.Use(auth)
r.Get("/notes", listNotes)Две ветки: без заголовка Authorization слой сам отвечает 401 Unauthorized через http.Error и возвращает управление — хендлер listNotes не выполнится никогда для такого запроса. С заголовком — next.ServeHTTP(w, r), и запрос уходит дальше. Ранний ответ — штатная функция middleware, а не обходное правило; настоящую проверку токена и передачу пользователя в контексте соберём позже. Здесь — упрощение ради механики.
Проверка: пропущенный next
Middleware не вызвал next.ServeHTTP и не записал ничего в http.ResponseWriter. Что получит клиент?
Соберите таймер
Восстановите замер времени, строку лога в defer и передачу эстафеты.
Пять правил первого middleware#
- Одна ответственность на слой: таймер меряет, логгер пишет, авторизация пускает — слияние трёх задач ломает переиспользование.
- Либо next.ServeHTTP, либо собственный ответ — третьего варианта нет; пропущенный вызов оставляет клиента без ответа.
- Код после next выносите в defer: он сработает и на панике хендлера.
- r.Use — до регистрации маршрутов: поздний Use в chi приводит к панике на старте.
- Порядок Use = путь запроса: тот, кто должен посчитать всех, — первым.
Как только r.Use появляется в проекте, возникает соблазн: не дублировать ли сюда бизнес-правила? Ограничения — в middleware, скидки — в middleware... Моё правило: в цепочке живёт инфраструктура плюс то, что обязано решаться до хендлера по безопасности — авторизация, лимиты. Граница простая: если логику нужно тестировать отдельно от HTTP и переиспользовать без запросов — это сервисный слой, а не middleware. Правило нарушаемо — rate limiter, понимающий бизнес-квоты, законный middleware, — но нарушение должно быть осознанным.
Соберите таймер по строкам
Расставьте строки middleware-таймера в правильном порядке: от сигнатуры до передачи эстафеты.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: цепочка middleware
Механика собрана: сигнатура, луковица, правила r.Use, ранние ответы и 404, которые не теряются из логов. Писать каждый слой руками, впрочем, долго — большая часть обвязки уже написана авторами chi. В следующем уроке — встроенные middleware: RequestID, RealIP, Logger и Recoverer, из которых боевой стек собирается четырьмя строками r.Use. К собственному middleware с авторизацией и контекстом вернёмся после знакомства с готовыми слоями, а группы маршрутов со своим стеком — тема урока про Route и Group, через один. Попробуйте напоследок поменять местами r.Use(timer) и r.Use(logger): разница между строками двух слоёв покажет на живом коде, кто кого оборачивает.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Middleware — func(http.Handler) http.Handler: обёртка принимает следующий хендлер и возвращает новый; поведение живёт до, после или вместо next.ServeHTTP.
- Луковица: запрос проходит слои в порядке регистрации, ответ — в обратном; код после next выносится в defer.
- r.Use(timer, logger) регистрирует стек на весь роутер и вызывается до маршрутов — поздний Use в chi приводит к панике на старте.
- next.ServeHTTP обязателен: без него и без собственного ответа клиент висит до таймаута — маршрутизация не запускается.
- Middleware может ответить сам — 401, 429 — и не вызывать next; внешние слои всё равно досчитают своё «после».
- 404 проходит через весь стек: логи, id и таймеры видят и мусорные запросы — вместе с их ценой.
- Бизнес-логика в цепочке — редкая удача, а не правило: middleware держат для инфраструктуры и проверок безопасности.
Домашнее задание
Напишите к каркасу заметок два middleware: timer с defer и log.Printf, и headerCheck, отвечающий 401 без заголовка X-Role. Зарегистрируйте их r.Use до маршрутов и проверьте curl-ом три ответа: обычный запрос, запрос без роли и путь к несуществующему маршруту — таймер должен отработать во всех трёх.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем middleware в chi отличается от middleware в Gin?
Сигнатурой и типами: chi — func(http.Handler) http.Handler со стандартными w и r, Gin — func(*gin.Context), и свойство слоя завязано на gin-контекст. Механика луковицы одинакова, но chi-слой компилируется с любым net/http-кодом, а gin-слой живёт только внутри Gin.
Что произойдёт, если вызвать r.Use после регистрации маршрутов?
Chi запаникует на старте с текстом all middlewares must be defined before routes on a mux: стек замораживается на первом r.Get. Это fail-fast вместо сюрпризов в проде — собирайте r.Use до маршрутов, тогда файл читается как путь запроса.
Можно ли поставить middleware только на часть маршрутов?
На весь роутер — r.Use, на ветку — Route или Group со своим стеком, о них отдельный урок дальше; на одиночный маршрут слой вешается вручную оборачиванием хендлера. Так авторизация живёт в ветке /admin, а логи остаются общими для всего сервиса.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Встроенные middleware: Logger, Recoverer, RequestID
- Route и Group: группируем маршруты
- Mount и статика: FileServer
- Wildcards: маршруты со звёздочкой
- URLParam: параметры пути и query-строка
- Маршруты и HTTP-методы: Get, Post, Put, Delete
- Первый сервер на chi
- Знакомство с Chi: роутер с чистой кровью net/http
- Все курсы по Go
Следующая ступень учебного пути
Redis в Go: кэш, сессии и очереди на go-redis
Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.
- Подключаться к Redis из Go и настраивать пул соединений
- Кэшировать результаты запросов и придумывать TTL
- Работать с хэшами, списками, множествами и ZSET
- Строить сессии, счётчики, лидерборды и rate limiter