Middleware в Go: как строить цепочки обработчиков
HTTP-middleware в Go — это обычные функции, которые оборачивают хендлер и добавляют логику до или после него. Разбираем, как собирать цепочки без фреймворков, чем отличаются подходы стандартной библиотеки и chi, и где порядок звеньев ломает приложение.

Зачем middleware, если есть хендлер
Любой нетривиальный HTTP-сервис на Go быстро обрастает повторяющейся логикой: залогировать запрос, проверить токен, выставить CORS-заголовки, сжать ответ, ограничить rate. Дублировать этот код в каждом хендлере — прямой путь к багам при рефакторинге. Middleware решает проблему: каждая задача живёт в отдельной функции, функции нанизываются на хендлер как звенья цепочки, а сам хендлер знает только про бизнес-логику.
Ниже — практика: конкретные приёмы сборки цепочек, сравнение подходов и типичные ошибки, на которых горят даже опытные разработчики. Весь код проверен на Go 1.22.
Контракт: что такое middleware в терминах стандартной библиотеки
В пакете net/http хендлер — это любой объект, реализующий интерфейс http.Handler, то есть имеющий метод ServeHTTP(http.ResponseWriter, *http.Request). Middleware — функция, которая принимает http.Handler и возвращает новый http.Handler:
// Go 1.22, стандартная библиотека
package main
import (
"log"
"net/http"
"time"
)
// Тип middleware — функция-обёртка над Handler
type Middleware func(http.Handler) http.Handler
// Логирование: замеряем время обработки запроса
func WithLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s — %s", r.Method, r.URL.Path, time.Since(start))
})
}
Ключевая деталь: вызов next.ServeHTTP — точка, в которой управление передаётся следующему звену. Код до этого вызова выполняется на входе запроса, код после — на выходе ответа. Это не декоратор в Python и не Express-middleware с вызовом next(): здесь нет объекта «цепочка», а есть простая вложенность функций.
Middleware, которая может прервать цепочку
Если middleware не вызывает next.ServeHTTP, цепочка обрывается. Это штатный сценарий для авторизации:
// Проверяем заголовок Authorization
func WithAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return // next.ServeHTTP не вызван — цепочка оборвана
}
// В реальном коде здесь валидация токена
next.ServeHTTP(w, r)
})
}
Сборка цепочки: три подхода
Подход 1 — ручная вложенность
Самый наглядный способ:
func main() {
api := http.HandlerFunc(handleAPI)
http.Handle("/api", WithLogging(WithAuth(api)))
log.Fatal(http.ListenAndServe(":8080", nil))
}
func handleAPI(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}
Проблема: читается справа налево. При пяти-шести middleware строка превращается в лапшу скобок.
Подход 2 — функция Chain
Складываем middleware в слайс и применяем в цикле. Порядок в слайсе совпадает с порядком выполнения — читается сверху вниз:
// Chain применяет middleware в порядке «первый оборачивает внешним слоем»
func Chain(h http.Handler, mws ...Middleware) http.Handler {
// Идём с конца, чтобы первый элемент стал внешней обёрткой
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
func main() {
api := http.HandlerFunc(handleAPI)
stack := Chain(api,
WithLogging, // выполнится первой на входе
WithAuth, // выполнится второй
)
http.Handle("/api", stack)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Обратите внимание на цикл с конца: без него порядок будет инвертирован, и логирование окажется внутри авторизации, а не снаружи.
Подход 3 — роутер chi
Роутер go-chi/chi (v5) встраивает middleware через метод Use. Порядок вызовов Use равен порядку выполнения — никакой инверсии:
// chi v5, Go 1.22
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.Logger)
r.Use(middleware.Recoverer)
// Группа со своей цепочкой
r.Route("/admin", func(r chi.Router) {
r.Use(WithAuth) // WithAuth — наша функция выше
r.Get("/", handleAdmin)
})
r.Get("/", handlePublic)
http.ListenAndServe(":8080", r)
}
Middleware — не фреймворк, а паттерн. Стандартная библиотека Go даёт для него всё необходимое: интерфейсhttp.Handlerи типhttp.HandlerFunc. Любой роутер, от chi до gorilla/mux, просто упрощает управление порядком.
Сравнение подходов
| Критерий | Ручная вложенность | Функция Chain | chi.Use |
|---|---|---|---|
| Зависимости | Нет | Нет | go-chi/chi/v5 |
| Читаемость при 5+ middleware | Низкая | Высокая | Высокая |
| Группы маршрутов с разными цепочками | Вручную | Вручную | Встроено |
| Контроль порядка | Справа налево | Сверху вниз (если цикл правильный) | Сверху вниз |
| Подходит для | Прототипы, 1–2 middleware | Средние проекты без внешнего роутера | Любые проекты с маршрутизацией |
Передача данных по цепочке через context
Middleware часто извлекает данные (ID пользователя, trace ID), которые нужны хендлеру ниже. Единственный идиоматичный способ — context.WithValue:
type ctxKey string
const userIDKey ctxKey = "user_id"
func WithUserID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Представим, что ID извлечён из токена
userID := "u-42"
ctx := context.WithValue(r.Context(), userIDKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func handleAPI(w http.ResponseWriter, r *http.Request) {
uid, ok := r.Context().Value(userIDKey).(string)
if !ok {
http.Error(w, "no user", http.StatusInternalServerError)
return
}
w.Write([]byte("hello, " + uid))
}
Ключ — неэкспортируемый тип ctxKey. Если использовать обычную строку, любой пакет с той же строкой перезапишет значение. Это не теоретическая опасность: в монорепозитории с десятком middleware-пакетов коллизии случаются.
Типичные ошибки
- Запись в ResponseWriter после next.ServeHTTP. Если хендлер уже отправил заголовки (вызвал
WriteHeaderилиWrite), попытка выставить заголовок в middleware послеnext.ServeHTTPмолча проигнорируется. Хотите добавить заголовок ответа — делайте это до вызоваnextили оборачивайтеResponseWriterв буферизирующую обёртку. - Перепутанный порядок. Логирование должно быть снаружи авторизации, иначе отклонённые запросы не попадут в лог. Recovery (перехват паники) — самый внешний слой, чтобы паника в любой middleware не обрушила горутину.
- Горутина внутри middleware без копии контекста. Если middleware запускает горутину и передаёт туда
r.Context(), контекст будет отменён после завершения хендлера. Для фоновой задачи создавайте отдельный контекст. - context.WithValue с экспортируемым строковым ключом. Ведёт к коллизиям между пакетами — описано выше.
- Паника вместо http.Error. Middleware авторизации иногда пишут с
panicпри невалидном токене. Без Recoverer наверху это убьёт горутину и закроет соединение без ответа клиенту.
* http.Handler — интерфейс стандартной библиотеки Go (net/http) с единственным методом ServeHTTP(ResponseWriter, *Request). Любой объект, реализующий этот метод, может обрабатывать HTTP-запросы. Тип http.HandlerFunc — адаптер, позволяющий использовать обычную функцию как http.Handler.
Что дальше
Собрав цепочку, стоит сделать три вещи. Во-первых, написать тест: middleware тестируется через httptest.NewRecorder и httptest.NewRequest — передаёте фейковый запрос, проверяете заголовки и статус ответа. Во-вторых, замерить оверхед: каждая middleware добавляет вызов функции и, возможно, аллокацию контекста; на горячем пути при тысячах RPS это ощутимо — профилируйте через pprof. В-третьих, задокументировать порядок звеньев: через полгода никто не вспомнит, почему RateLimiter стоит после Logger, а не до.
Универсальный ассистент: Обычный чат: тексты, идеи, разбор, перевод, расчёты.
Спросить за 8 ₽Источники: Пакет net/http — официальная документация Go, go-chi/chi — GitHub-репозиторий роутера chi
Частые вопросы
Чем middleware в Go отличается от middleware в Express или Django?
Можно ли применять разные цепочки middleware к разным маршрутам?
Как протестировать middleware отдельно от всего сервера?
Что будет, если в middleware забыть вызвать next.ServeHTTP?
Не замедлит ли длинная цепочка middleware обработку запросов?
А если у меня другая версия Go — код будет работать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.