Содержание урока · 10 разделов
- r.Context(): контекст внутри http.Request
- Почему строковый ключ "user" приводит к подмене
- Типобезопасность: ok-форма обязательна
- Что кладём → кто кладёт → кто читает
- Значение иммутабельно: только новая обёртка
- Отмена и дедлайны — вторая половина контекста
- Пять правил контекста запроса
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
В цепочке middleware из пятого урока осталась незакрытая задача: auth-слой проверил заголовок Authorization — а хендлер listNotes всё ещё не знает, кто отправил запрос. Нельзя ни положить пользователя в глобальную переменную — запросы конкурентны, и данные перепутаются между ними, — ни тащить его пятым аргументом через каждый слой: сигнатуры middleware фиксированы стандартной библиотекой. Решение живёт в самих *http.Request: у каждого запроса есть контекст, r.Context(), и chi, и любой net/http-middleware кладут в него то, что относится к этому конкретному запросу. Мостик отсюда в прошлое: chi.URLParam из четвёртого урока сам читает из контекста — роутер складывает разбор пути туда же, под свой ключ. В этом уроке — механика WithValue и WithContext, собственный тип ключа, helper-функции и две аварии, которые случаются, когда ключ оставляют строкой.
r.Context(): контекст внутри http.Request#
r.Context() возвращает context.Context — интерфейс с методами Value, Done, Err и парой служебных; для переноса данных важен Value. Контекст устроен как слоёный пирог: каждый context.WithValue(parent, key, val) возвращает новый контекст с одной парой поверх родителя, а Value ищет сверху вниз — самая свежая обёртка выигрывает. Отсюда ключевой ход middleware: положить значение мало, надо, чтобы его увидели следующие слои. Новый контекст не заменяет старый сам по себе — он доезжает дальше в новой обёртке запроса: r = r.WithContext(ctx), и только затем next.ServeHTTP(w, r). Вызов next со старым r теряет значение молча: компилятор не ругается, тесты с одним middleware зелёные, а хендлер читает пустоту. У контекста запроса есть и встроенная жизнь: сервер гасит его, когда соединение с клиентом закрылось или ServeHTTP вернулся, — но отмене и дедлайнам посвящён отдельный разговор, здесь речь про перенос данных.
Middleware кладёт, хендлер достаёт
GOfunc userToCtx(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), "user", "ann")
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func profile(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("user").(string)
fmt.Fprintf(w, "привет, %s", user)
}Схема ровно из README chi: middleware строит новый контекст поверх r.Context(), оборачивает запрос через r.WithContext и передаёт эстафету уже с новым r. Хендлер достаёт значение двумя ходами: r.Context().Value("user") возвращает any, а .(string) превращает его в строку. Пока middleware один и ключ один, код работает — и именно так его пишут в туториалах. Оставаться таким в проде он не должен: строковый ключ и голый type assertion — две мины, на обеих наступаем ниже. Для запуска нужен локальный Go: песочница сайта TCP-сервер не поднимает.
Почему строковый ключ "user" приводит к подмене#
Документация пакета context прямо просит: ключ должен быть сравнимым типом, но не строкой и не другим встроенным типом — иначе коллизии между пакетами неизбежны. Механика подмены простая: Value ищет с самой свежей обёртки вниз, и кто позже положил значение под тот же ключ, того и значение. История из жизни: в сервис поставили middleware из туториала со строковым ключом "user", а полгода спустя — стороннюю библиотеку, чей middleware кладёт под "user" собственный объект сессии. Зарегистрировали её ниже — и хендлеры начали читать чужой объект: тип всё тот же any, данные уже не те. Тесты при этом зелёные: на стенде чужой middleware просто не установлен. Лекарство — непросвечиваемый тип ключа, и chi делает это сам дважды: в ядре ключ маршрутизации — непросвечиваемая структура contextKey, а в middleware/request_id.go — собственный тип ctxKeyRequestID на базе int. Два разных типа — два разных ключа: совпасть они не могут физически.
Свой тип ключа и helper-функции
GOtype ctxKey int
const userKey ctxKey = 0
type User struct {
Name string
}
func UserToCtx(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := &User{Name: "ann"}
next.ServeHTTP(w, r.WithContext(
context.WithValue(r.Context(), userKey, u),
))
})
}
func UserFromCtx(r *http.Request) *User {
u, _ := r.Context().Value(userKey).(*User)
return u
}type ctxKey int — непросвечиваемый тип: пользовательский ключ больше не равен ни строке "user", ни чужому ctxKey из другого пакета, потому что типы разные. Константа userKey не экспортируется — ключ живёт внутри пакета, а наружу смотрят две функции. UserToCtx кладёт значение и передаёт эстафету с новой обёрткой запроса — ровно тот ход, пропуск которого теряет данные. UserFromCtx инкапсулирует вторую половину: ключ, Value и type assertion с ok-формой. Хендлер читает одну строку: user := UserFromCtx(r).
Типобезопасность: ok-форма обязательна#
Value возвращает any — интерфейс без типа, и без приведения значение бесполезно. Приведение бывает двух форм, и разница между ними — цена продовой аварии. Голая форма user := r.Context().Value(key).(User) паникует, если значения под ключом нет или оно другого типа: запрос мимо auth-ветки, опечатка в ключе, чужой middleware — любое из этого даёт пустоту, и каст nil в User роняет горутину; chi Recoverer из шестого урока превратит панику в 500, но осадок останется. ok-форма u, ok := ....Value(key).(*User) панику не поднимает: false говорит «значения нет», и хендлер решает сам — 401 без пользователя или дефолт для гостя. Helper из прошлого блока делает выбор за вас: он возвращает nil вместо паники, а проверка if user == nil остаётся единственной обязанностью хендлера. Третьей формы нет: любое Value — сначала ok-форма, потом логика.
Проверка: паника на пустом контексте
Значение под ключом в контексте не установлено. Что сделает строка user := r.Context().Value(userKey).(*User)?
Что кладём → кто кладёт → кто читает#
| Что кладём | Кто кладёт | Кто читает |
|---|---|---|
| Пользователь | auth-middleware после проверки токена | хендлеры: имя в ответ, владельца в лог |
| RequestID | middleware.RequestID из chi | логи и хендлеры через middleware.GetReqID |
| Разбор пути | сам роутер chi под ключом RouteCtxKey | ваши хендлеры через chi.URLParam |
| Язык из Accept-Language | WithValue из chi/middleware | хендлеры локализации ответа |
| Время на обработку | обёртка с context.WithTimeout | сервисный слой: дедлайн в вызовах базы |
Значение иммутабельно: только новая обёртка#
Значение, положенное в контекст, не редактируется: ни SetValue, ни Delete в API нет — пирог наращивают слоями, а не переписывают. Нужно «изменить» данные — строится новый контекст с новой парой поверх существующего: старый слой остаётся на месте и живёт для тех, кто успел его прочитать выше по цепочке. Точнее всего это видно на двух auth-слоях: внешний положил гостя, внутренний после проверки токена положил настоящего пользователя под тем же ключом — хендлер увидит пользователя, потому что Value начинает поиск с верхней обёртки, а слой лога, стоящий выше, всё ещё читает гостя. Это свойство не баг, а следствие иммутабельности: параллельные ветки цепочки не затирают друг друга, гонок на контексте не бывает. Практический вывод: один ключ — один смысл, а не один ключ на пакет; два разных «пользователя» под одним ключом превращают порядок middleware в скрытую бизнес-логику.
Какое имя прочитает хендлер?
Прочитайте код и выберите, что он выведет:
func mwA(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), "user", "ann")
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func mwB(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), "user", "bob")
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// регистрация: r.Use(mwA, mwB)
// хендлер: fmt.Fprint(w, r.Context().Value("user"))Развязка истории из середины урока: два middleware со строковым ключом "user" — свой из туториала и чужой из библиотеки — компилировались без единой ошибки, оба работали с any. Чужой слой встал ниже нашего, его обёртка оказалась свежее, и хендлеры четыре дня отдавали пользователям чужие профили, пока один из клиентов не заметил чужое имя в шапке страницы. Тесты прошли: на CI чужой библиотеки не было. Правило, которое выжило в ревью: строковых ключей в WithValue не существует, только собственные непросвечиваемые типы — их коллизия невозможна, потому что разные типы никогда не равны.
Как только WithValue освоен, появляется соблазн возить в контексте всё: конфиг, соединение с базой, модель заказа. Граница, которой держусь я: в контексте живут данные про этот запрос — кто его отправил, какой у запроса id, на каком языке нужен ответ. Бизнес-объекты едут аргументами функций: их состав виден в сигнатуре, а не добывается раскопками Value. Нарушение допустимо, когда объект действительно общий для всего запроса, а его протаскивание захламляет пять слоёв подряд, — но тогда это осознанный компромисс, а не привычка.
Отмена и дедлайны — вторая половина контекста#
Перенос данных — половина работы контекста; вторая половина — отмена, и у входящего запроса она встроена: контекст из r.Context() гасится, когда клиент закрыл соединение или ServeHTTP вернулся. Отсюда бесплатная экономика: сервисный слой, который слушает ctx.Done(), перестаёт считать работу, никому не нужную. Второе применение — ограничить время хендлера: middleware оборачивает контекст в context.WithTimeout и передаёт эстафету с ним, и любое долгое обращение к базе получает дедлайн бесплатно. Здесь это одна фраза намеренно: отмена, дерево контекстов и errors.Is на причинах — тема урока про контекст продвинутого курса, где всё разобрано на горутинах. В chi же запомните связку: r.Context() — база, WithValue кладёт данные, WithTimeout ограничивает время, и всё это едет в хендлер через r.WithContext.
Авторизация как middleware с контекстом
GOfunc auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
u, err := parseToken(token)
if err != nil {
http.Error(w, "нет доступа", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r.WithContext(
context.WithValue(r.Context(), userKey, u),
))
})
}
r := chi.NewRouter()
r.Use(auth)
r.Get("/profile", func(w http.ResponseWriter, r *http.Request) {
user := UserFromCtx(r)
fmt.Fprintf(w, "привет, %s", user.Name)
})Полная цепочка из практики: auth проверяет токен и на ошибке отвечает 401 сам — ранний ответ из урока про middleware, эстафета не передаётся. Успешная ветка кладёт пользователя под непросвечиваемым ключом и передаёт дальше новую обёртку запроса. Хендлер достаёт пользователя одной строкой через helper: ключ, каст и ok-форма спрятаны внутри UserFromCtx. parseToken здесь заглушка — настоящую проверку токена соберём в уроке про собственный middleware.
Проверка: потерянная обёртка
Middleware построил ctx := context.WithValue(r.Context(), userKey, u), но вызвал next.ServeHTTP(w, r) — со старым r. Что прочитает UserFromCtx в хендлере?
Соберите перенос пользователя
Восстановите запись в контекст, передачу новой обёртки, чтение и проверку ok-формы.
Пять правил контекста запроса#
- Ключ — собственный непросвечиваемый тип: type ctxKey int; строковые ключи подменяются чужими middleware.
- Значение доезжает до хендлера только через r.WithContext(ctx) и next.ServeHTTP(w, r) с новым r.
- Value возвращает any: приведение — в ok-форме, иначе паника на отсутствующем значении.
- Значение иммутабельно: «изменение» — новая обёртка поверх; Value читает самую свежую.
- Helper-функции UserToCtx/UserFromCtx прячут ключ, каст и дефолты — хендлер не знает про механику.
Соберите middleware с контекстом
Расставьте строки middleware: от сигнатуры до передачи эстафеты с новой обёрткой.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: контекст запроса
Пайплайн данных собран: middleware кладёт под непросвечиваемым ключом, хендлер достаёт через helper с ok-формой, и вся механика спрятана в двух функциях на пакет. Следующий шаг — положить там не заглушку, а настоящего пользователя: урок про собственный middleware собирает авторизацию целиком, от проверки заголовка до ролей. А после него — JSON: хендлер научится принимать и отдавать тела запросов, и пользователь из контекста станет автором заметок в REST API на chi. До того проверьте связку руками на живом сервере: добавьте второй middleware со своим типом ключа и убедитесь, что подмена значений больше невозможна, даже если рядом живёт туториальный строковый "user".
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- r.Context() — контекст запроса: chi кладёт туда разбор пути, middleware — пользователя и id; хендлер достаёт Value.
- WithValue строит новую обёртку поверх родителя; до хендлера она доезжает только через r.WithContext(ctx) в next.ServeHTTP.
- Ключ — собственный непросвечиваемый тип (type ctxKey int): строковый "user" подменяется чужим middleware с тем же ключом.
- Value возвращает any: приведение в ok-форме, иначе паника на nil; helper UserFromCtx прячет каст и возвращает nil.
- Значение иммутабельно: «изменение» — новая обёртка, Value читает самую свежую — последний записавший выигрывает.
- Контекст — не чемодан: в нём данные запроса, бизнес-объекты едут аргументами функций.
- Отмена и дедлайны — вторая половина контекста: r.Context() гасится при закрытии клиента, WithTimeout ограничивает хендлер.
Домашнее задание
Соберите auth-middleware из урока: проверка заголовка Authorization, пользователь кладётся под типом ctxKey, хендлер /profile читает через UserFromCtx и отвечает 401, если там nil. Добавьте второй middleware со строковым ключом "user" и убедитесь, что непросвечиваемый тип защищает данные от коллизии.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему нельзя использовать строковые ключи в context.WithValue?
Ключ типа string равен такой же строке из любого другого пакета: чужой middleware с ключом "user" затмит ваше значение — Value вернёт его первым, ведь свежая обёртка выигрывает. Документация context прямо просит определять собственные типы ключей; chi и его middleware так и делают.
Чем r.Context() отличается от chi.URLParam?
Уровнем: URLParam — готовая функция над контекстом, она сама достаёт разбор пути, который роутер положил в r.Context() под своим ключом RouteCtxKey. Свои данные прячутся так же: ключ и каст внутри пакета, наружу — helper с типизированным результатом.
Можно ли изменить значение, уже лежащее в контексте?
Нет: значения иммутабельны, API изменения не существует. Строится новый контекст через WithValue поверх старого — до хендлера доедет самая свежая обёртка, а слои выше по цепочке продолжат читать прежнее значение. Поэтому на порядок middleware ради разных версий данных полагаться нельзя.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- JSON API: парсим и отдаём JSON
- Собственный middleware: таймер, лог, авторизация
- Timeout и Throttle: защита от перегрузки
- CORS: чиним браузерные запросы
- Wildcards: маршруты со звёздочкой
- Mount и статика: FileServer
- Route и Group: группируем маршруты
- Встроенные middleware: Logger, Recoverer, RequestID
- Все курсы по Go
Следующая ступень учебного пути
Redis в Go: кэш, сессии и очереди на go-redis
Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.
- Подключаться к Redis из Go и настраивать пул соединений
- Кэшировать результаты запросов и придумывать TTL
- Работать с хэшами, списками, множествами и ZSET
- Строить сессии, счётчики, лидерборды и rate limiter