Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 12 из 25 ~18 мин +20 XP

Собственный middleware: таймер, лог, авторизация

Собственный middleware go-сервиса — это та же func(http.Handler) http.Handler из урока про цепочку, только написанная под вашу боль: таймер с RequestID, авторизация по X-API-Key, пользователь в контексте. Напишем два боевых слоя, повесим их на группу и прогоним тестом httptest без сети.

Содержание урока · 10 разделов

Сценарий из жизни сервиса: проверка ключа скопирована из хендлера в хендлер — двенадцать раз, с той же валидацией, — и на ревью кто-то находит в одиннадцатой копии потерянный 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 и честным статусом

GO
func 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: три ветки и контекст

GO
type 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).

Проверка: 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.Recoverer500 и стек при паникенужен единый формат ошибки и метрика инцидентов
middleware.BasicAuthBasic-схему и map паролейAPI-ключи, роли, хранилище пользователей — всё мимо его контракта
middleware.Throttleглобальный лимит соединенийлимит на пользователя или ключ — считать нужно в своём слое
middleware.RequestIDid запроса и GetReqIDтрейсинг по traceparent (W3C) — идентификатор берут из заголовков клиента

Ошибки в middleware: ранний ответ без next#

Правило одного предложения: если дальше идти нельзя — middleware отвечает сам и не вызывает next. Клиент получает ответ, хендлер не тратит ни такта, а слои выше досчитывают своё «после» в defer: apilogger напечатает строку и для отказанного запроса. Обратная ловушка — вызвать next после собственного ответа: статус уже отправлен, и тело хендлера допишется к нему вторым куском. Ещё один способ выстрелить в ногу — вызвать next дважды: двойная строка лога, двойной счёт метрики, а в худшем случае двойная запись в ответ. Страховка от человеческого фактора — ранний return в каждой ветке отказа по образцу writeErr: ответил и вернулся. И про данные: в контекст их кладут строго до next — после собственного ответа они никому не нужны, а до вызова они уже видны всем нижним слоям.

Проверка: потерянный return

В копии проверки потерялся return: writeErr отправил 401, но выполнение продолжилось — хендлер создал заметку и что-то записал в ответ. Что получит клиент?

Что напечатает 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

GO
func 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 по строкам

Восстановите сигнатуру, чтение заголовка, код отказа и передачу эстафеты с контекстом.

func apiKeyAuth(keys map[string]user) func(http.Handler) http. { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { key := r..Get("X-API-Key") if key == "" { w.WriteHeader(http.) return } ctx := context.WithValue(r.Context(), userKey, keys[key]) next.(w, r.WithContext(ctx)) }) } }

Соберите поток авторизации

Расставьте строки middleware в правильном порядке: от сигнатуры до передачи эстафеты с контекстом.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: свои слои в цепочке

Термин

func(http.Handler) http.Handler

нажмите, чтобы перевернуть
Определение

Сигнатура любого middleware chi — встроенного и вашего: замыкание ловит next, код живёт до next.ServeHTTP, после него или вместо него. chi-типов внутри нет.

1 / 6

Слои собраны: 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.

Личные заметкиПусто
Хранятся только в вашем браузере (Chi · Собственный middleware: таймер, лог, авторизация)

Вопросы и ответы по уроку

Чем собственный 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 с ролями в контекст. Решение «может ли он это» — хендлер или отдельный слой-владелец ресурса: проверка «заметка принадлежит этому пользователю» живёт там, где про заметки всё знают. Авторизация не зависит от бизнеса, а бизнес — от способа аутентификации.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Redis25 уроков~12 чпрактика25 уроков уже доступны

Redis в Go: кэш, сессии и очереди на go-redis

Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.

  • Подключаться к Redis из Go и настраивать пул соединений
  • Кэшировать результаты запросов и придумывать TTL
  • Работать с хэшами, списками, множествами и ZSET
  • Строить сессии, счётчики, лидерборды и rate limiter