Содержание урока · 10 разделов
- Сигнатура func(http.Handler) http.Handler: свой слой
- APIKeyMiddleware: 401 без ключа, 403 с чужим
- r.Use или группа: где вешать авторизацию
- Встроенный middleware chi и момент для своего
- Ошибки в middleware: ранний ответ без next
- Тест своего middleware: httptest без сети
- Чек-лист собственного middleware
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Сценарий из жизни сервиса: проверка ключа скопирована из хендлера в хендлер — двенадцать раз, с той же валидацией, — и на ревью кто-то находит в одиннадцатой копии потерянный return после writeErr(w, http.StatusUnauthorized, ...). Клиент получает 401, а код ниже всё равно выполняется: заметка создаётся, платёж списывается. Компилятор такое не ловит, тесты — почти: в остальных одиннадцати копиях всё правильно. Собственный middleware в chi лечит болезнь целиком: проверка пишется один раз как func(http.Handler) http.Handler и вешается r.Use или на группу маршрутов. В этом уроке соберём два боевых слоя — apilogger с RequestID из контекста и авторизацию APIKeyMiddleware с пользователем в контексте, — закрепим правило ранних ответов и прогоним слой тестом httptest, которому не нужны ни порт, ни сеть.
Сигнатура func(http.Handler) http.Handler: свой слой#
Сигнатура собственного middleware совпадает с сигнатурой из урока про Use и цепочку — func(http.Handler) http.Handler, без единого chi-типа. Внутри — замыкание, которое ловит next, и три места для кода: до next.ServeHTTP, сам вызов и после него. Первый слой урока — apilogger, таймер-лог из того урока во взрослом виде. Две добавки. middleware.NewWrapResponseWriter(w, r.ProtoMajor) возвращает обёртку, которая запоминает статус ответа: без неё лог не может честно показать, чем закончился запрос, — базовый ResponseWriter своего статуса не отдаёт. А middleware.GetReqID(r.Context()) достаёт идентификатор, который встроенный RequestID положил в контекст запроса: каждая строка лога сшивается с этим id, и по нему же её находят соседние сервисы. Слои из стандартного пакета и ваши собственные живут в одной цепочке на равных — различие только в авторе.
apilogger: таймер с RequestID и честным статусом
GOfunc apilogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
defer func() {
log.Printf("[%s] %s %s: %d (%v)",
middleware.GetReqID(r.Context()),
r.Method, r.URL.Path,
ww.Status(), time.Since(start))
}()
next.ServeHTTP(ww, r)
})
}WrapResponseWriter подменяет w в эстафете: next.ServeHTTP(ww, r) — весь стек ниже пишет в обёртку, а defer после возврата читает ww.Status() и BytesWritten(). Строка лога собирается после завершения всей цепочки, поэтому показывает финальный статус — включая ранние 401 и 403 из авторизации ниже по стеку. Для запуска нужен локальный Go: песочница сайта TCP-сервер не поднимает.
APIKeyMiddleware: 401 без ключа, 403 с чужим#
Авторизация читает один заголовок и делает два отказа. r.Header.Get("X-API-Key") возвращает пустую строку, если заголовка нет, — клиент не представился: 401 Unauthorized и return. Заголовок есть, но ключа нет в map[string]user, — клиент представился, но доступа нет: 403 Forbidden. Разница не эстетическая: клиентские библиотеки на 401 повторяют запрос с новым токеном, на 403 прекращают попытки — перепутанные коды порождают бесконечные ретраи. Каждому валидному ключу соответствует user с именем и ролями, и middleware — последнее место, где уместно решать, кто стучится. Дальше пользователь отправляется вниз по цепочке: context.WithValue(r.Context(), userKey, u) создаёт новый контекст, r.WithContext(ctx) приклеивает его к запросу, и next.ServeHTTP(w, r.WithContext(ctx)) передаёт эстафету уже с пользователем. Ключ контекста — приватный тип, а не строка: вся механика разобрана в уроке про context.Context.
apiKeyAuth: три ветки и контекст
GOtype ctxKey string
const userKey ctxKey = "user"
type user struct {
Name string
Roles []string
}
func apiKeyAuth(keys map[string]user) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key")
if key == "" {
writeErr(w, http.StatusUnauthorized, "нет заголовка X-API-Key")
return
}
u, ok := keys[key]
if !ok {
writeErr(w, http.StatusForbidden, "ключ неизвестен")
return
}
ctx := context.WithValue(r.Context(), userKey, u)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}Три ветки на три исхода: нет заголовка — 401, неизвестный ключ — 403, успех — контекст с пользователем и next. writeErr из прошлого урока держит формат ошибок единым. Для параноидальных сценариев сравнение секретов делают subtle.ConstantTimeCompare, как в chi-вом middleware.BasicAuth, — для внутреннего API хватает map-lookup. В хендлере пользователь достаётся type assertion с ok-формой: u, ok := r.Context().Value(userKey).(user).
Скопированная в каждый хендлер проверка умирает медленно: первая копия правильная, вторая тоже, десятая — ещё. Одиннадцатая получает опечатку — return после 401 потерян. Клиент видит 401, а код ниже выполняется: заметка создаётся, деньги списываются. Логи показывают 401, и все уверены, что запрос «отклонён». Такого класса баги не ловит ни компилятор, ни большинство тестов — они ловятся только структурой: одна функция, одна точка правки, r.Use на группу. Копипаста авторизации на ревью — повод для рефакторинга, а не мелочь.
Проверка: 401 или 403
Клиент прислал заголовок X-API-Key, но такого ключа нет в map. Какой статус вернёт apiKeyAuth и почему?
r.Use или группа: где вешать авторизацию#
r.Use(apiKeyAuth(keys)) на корневом роутере закрывает весь сервис — включая /health, который мониторинг должен опрашивать без ключей. Практичная раскладка: публичные маршруты живут на корне, защищённые — в ветке. r.Route("/api", ...) из урока про Route и Group даёт подроутер со своим стеком: r.Use(apiKeyAuth) внутри видит только API-запросы, а корневой Logger остаётся общим; Group работает так же, но без префикса. Два напоминания из механики цепочки. Use вызывается до первого маршрута — поздний Use приводит к панике на старте. И порядок слоёв равен пути запроса: apilogger регистрируют раньше auth, чтобы лог видел и отказы. Бонус правильного порядка — таймер считает и время авторизации: слои выше всегда платят за нижние.
Встроенный middleware chi и момент для своего#
| Встроенный middleware | Покрывает | Когда пишут свой |
|---|---|---|
| middleware.Logger | строку в консоль на каждый запрос | нужны структурные поля: request_id, user, статус в JSON — свой логгер или zap |
| middleware.Recoverer | 500 и стек при панике | нужен единый формат ошибки и метрика инцидентов |
| middleware.BasicAuth | Basic-схему и map паролей | API-ключи, роли, хранилище пользователей — всё мимо его контракта |
| middleware.Throttle | глобальный лимит соединений | лимит на пользователя или ключ — считать нужно в своём слое |
| middleware.RequestID | id запроса и GetReqID | трейсинг по traceparent (W3C) — идентификатор берут из заголовков клиента |
Ошибки в middleware: ранний ответ без next#
Правило одного предложения: если дальше идти нельзя — middleware отвечает сам и не вызывает next. Клиент получает ответ, хендлер не тратит ни такта, а слои выше досчитывают своё «после» в defer: apilogger напечатает строку и для отказанного запроса. Обратная ловушка — вызвать next после собственного ответа: статус уже отправлен, и тело хендлера допишется к нему вторым куском. Ещё один способ выстрелить в ногу — вызвать next дважды: двойная строка лога, двойной счёт метрики, а в худшем случае двойная запись в ответ. Страховка от человеческого фактора — ранний return в каждой ветке отказа по образцу writeErr: ответил и вернулся. И про данные: в контекст их кладут строго до next — после собственного ответа они никому не нужны, а до вызова они уже видны всем нижним слоям.
Проверка: потерянный return
В копии проверки потерялся return: writeErr отправил 401, но выполнение продолжилось — хендлер создал заметку и что-то записал в ответ. Что получит клиент?
X-API-Key — простой заголовок без схемы: одна строка, никаких правил, идеален для машинных клиентов и MVP. Authorization — стандарт: RFC 6750 задаёт схему Bearer, инструменты и прокси его понимают, а путь к JWT и OAuth открывается без смены заголовка. Моё правило с границей: внутренние интеграции и старт проекта — X-API-Key; пользователи в браузере или план на федерацию логинов — Authorization: Bearer с первого дня. Смена заголовка позже — это сломанные клиенты разом, дешевле выбрать его честно сразу.
Что напечатает apilogger?
Прочитайте код и выберите, что он выведет:
r := chi.NewRouter()
r.Use(apilogger)
r.Use(apiKeyAuth(keys))
r.Post("/notes", createNote)
// запрос: POST /notes с заголовком X-API-Key: чужой-ключТест своего middleware: httptest без сети#
Middleware тестируется проще хендлеров: не нужны ни база, ни порт. Собирается минимальный роутер chi.NewRouter с одним слоем и хендлером-заглушкой; httptest.NewRequest создаёт запрос с нужным заголовком, httptest.NewRecorder — подставной ResponseWriter, который запоминает статус и тело. Вызов r.ServeHTTP(rec, req) прогоняет всю цепочку синхронно: rec.Code — итоговый статус, rec.Body — ответ. Три кейса закрывают контракт: нет заголовка — 401, чужой ключ — 403, правильный ключ — 201 и пользователь в контексте хендлера. Библиотека testify сокращает assert-обвязку, а системному тестированию хендлеров chi посвящён отдельный урок ближе к финалу курса. Честная оговорка урока: go test, как и сам сервер, запускается локальным Go — песочница сайта компилирует синтаксис, но сети у неё нет.
Тест ветки 403
GOfunc TestAPIKeyAuth_WrongKey(t *testing.T) {
r := chi.NewRouter()
r.Use(apiKeyAuth(map[string]user{"secret-1": {Name: "frontend"}}))
r.Post("/notes", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
})
req := httptest.NewRequest("POST", "/notes", nil)
req.Header.Set("X-API-Key", "wrong")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("хотели 403, пришло %d", rec.Code)
}
}Тест чужого ключа: роутер, слой, заглушка-хендлер, запрос с неверным X-API-Key. rec.Code равен 403 — слой отработал, до хендлера дело не дошло. Соседние проверки на 401 без заголовка и на 201 с правильным ключом делают контракт исчерпанным: три ветки middleware — три теста, и все без сети.
Чек-лист собственного middleware#
- Одна ответственность на слой: таймер меряет, авторизация пускает, логгер пишет.
- Либо next.ServeHTTP, либо собственный ответ — и return в каждой ветке отказа.
- Данные вниз — context.WithValue с приватным типом ключа, строго до вызова next.
- Код после next — в defer: он сработает и на панике нижних слоёв.
- Статус ловите через middleware.NewWrapResponseWriter — иначе у лога нет честного кода ответа.
- Тест httptest без сети: recorder, запрос с заголовком и три кейса на три ветки.
Соберите apiKeyAuth по строкам
Восстановите сигнатуру, чтение заголовка, код отказа и передачу эстафеты с контекстом.
Соберите поток авторизации
Расставьте строки middleware в правильном порядке: от сигнатуры до передачи эстафеты с контекстом.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: свои слои в цепочке
Слои собраны: apilogger с RequestID и честным статусом, apiKeyAuth с парой 401/403 и пользователем в контексте, тест без сети. По той же выкройке пишется всё остальное — замер для метрик, лимит на ключ, свой recover с JSON-ошибкой из таблицы выше. Дальше по курсу стоят предохранители chi — таймауты и троттлинг, — и обе темы опираются на механику этого урока: слой, который умеет ответить сам и пропустить дальше, уже у вас есть. Напоследок поменяйте местами r.Use(apilogger) и r.Use(apiKeyAuth) и посмотрите на лог отказанных запросов: разница между двумя строками регистрации покажет, кто кого оборачивает.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Собственный middleware — та же func(http.Handler) http.Handler: chi-типов нет, слой встаёт в цепочку как встроенный.
- Статус ответа ловит middleware.NewWrapResponseWriter, идентификатор запроса — middleware.GetReqID(r.Context()).
- Нет заголовка X-API-Key — 401, ключ неизвестен — 403: первый просит представиться, второй отказывает окончательно.
- Успех — context.WithValue с приватным ключом-типом и next.ServeHTTP(w, r.WithContext(ctx)); хендлер достаёт пользователя из контекста.
- Ошибка в middleware — собственный ответ и return; вызов next после своего ответа дописывает тело хендлера к чужому статусу.
- r.Use(auth) закрывает весь сервис; группа из Route/Group ограничивает слой своей веткой — /health остаётся публичным.
- Тест слоя — httptest без сети: recorder вместо ResponseWriter и по тесту на каждую ветку.
Домашнее задание
Напишите apiKeyAuth с map[string]user и повесьте его на группу /api, оставив /health публичным. Проверьте curl-ом три случая: без заголовка — 401, с чужим ключом — 403, с валидным — 201 на POST /notes и имя пользователя в строке лога apilogger.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем собственный middleware отличается от встроенного?
Только автором: сигнатура, механизм цепочки и способ регистрации идентичны. Встроенные слои chi — те же func(http.Handler) http.Handler в отдельном пакете, и ваш apiKeyAuth взаимозаменяем с ними, комбинируется в любом порядке. Разница в правах: встроенные могут трогать приватные поля пакета, ваши — только публичный API.
Почему у 401 нет заголовка WWW-Authenticate?
По RFC 7235 ответ 401 сопровождается WWW-Authenticate со схемой — для Basic и Bearer это работает. API-ключи лежат вне стандартных схем аутентификации, и на практике заголовок часто опускают: клиент всё равно не знает, что с ним делать. Перейдёте на Authorization: Bearer — заголовок появится сам собой.
Куда класть роли и права — в middleware или хендлер?
Граница простая: факт «кто это» решает middleware — он кладёт user с ролями в контекст. Решение «может ли он это» — хендлер или отдельный слой-владелец ресурса: проверка «заметка принадлежит этому пользователю» живёт там, где про заметки всё знают. Авторизация не зависит от бизнеса, а бизнес — от способа аутентификации.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Timeout и Throttle: защита от перегрузки
- CORS: чиним браузерные запросы
- Централизованные ошибки: errorHandler и NotFound
- Graceful shutdown: выключаемся вежливо
- JSON API: парсим и отдаём JSON
- context.Context: прокидываем данные в хендлеры
- Wildcards: маршруты со звёздочкой
- Mount и статика: FileServer
- Все курсы по Go
Следующая ступень учебного пути
Redis в Go: кэш, сессии и очереди на go-redis
Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.
- Подключаться к Redis из Go и настраивать пул соединений
- Кэшировать результаты запросов и придумывать TTL
- Работать с хэшами, списками, множествами и ZSET
- Строить сессии, счётчики, лидерборды и rate limiter