---
title: "context.Context: прокидываем данные в хендлеры"
description: "chi context: context.WithValue и r.WithContext в middleware, свой тип ключа вместо строки, helper с ok-формой. Паника на nil и подмена строкового \"user\"."
course: "Chi: роутер для Go HTTP-сервисов"
url: "https://golangmaster.ru/courses/chi/lessons/10-context/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 18
---

# context.Context: прокидываем данные в хендлеры

*Курс «Chi: роутер для Go HTTP-сервисов», урок 10 из 25. ~18 минут. Обновлено 27.09.2026. Источник: https://golangmaster.ru/courses/chi/lessons/10-context/*

chi context — механизм переноса данных вниз по цепочке: middleware проверил токен и обязан донести пользователя до хендлера, не заводя глобальных переменных. Разберём r.Context(), context.WithValue и r.WithContext, непросвечиваемый тип ключа вместо строкового "user" из туториалов, helper-функции с ok-формой и цену паники на отсутствующем значении.

В [цепочке middleware из пятого урока](/courses/chi/lessons/05-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 кладёт, хендлер достаёт

```go
func 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-функции

```go
type 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)?

- Присвоит user = nil и продолжит работу
- ✅ Запаникует: type assertion без ok-формы падает на nil
- Вернёт пустую структуру User{}
- Останется any: каст на nil невозможен

Голая форма type assertion на nil-значении паникует прямо на строке каста — это не обращение к nil-указателю потом, а падение здесь и сейчас. ok-форма u, ok := ....(*User) в той же ситуации вернула бы ok = false и никуда не падала. Отсюда правило урока: Value без ok-формы — только когда значение гарантированно положено выше.

### Что кладём → кто кладёт → кто читает

| Что кладём | Кто кладёт | Кто читает |
| --- | --- | --- |
| Пользователь | 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 в скрытую бизнес-логику.

### Какое имя прочитает хендлер?

```go
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"))
```

**Что выведет программа?**

- ✅ bob: значение mwB лежит на самой свежей обёртке
- ann: первое зарегистрированное значение выигрывает
- ann, а затем bob: хендлер увидит оба значения
- Панику: два значения под одним ключом запрещены

Цепочка контекстов растёт как стек: mwA кладёт ann поверх базового контекста, mwB — bob поверх ann. Value начинает поиск с самой верхней обёртки и останавливается на первом совпадении — bob. Это и есть механика подмены из врезки ниже: строковые ключи делают порядок middleware частью бизнес-логики.

> **Туториальный ключ "user" подменили в проде.** Развязка истории из середины урока: два middleware со строковым ключом "user" — свой из туториала и чужой из библиотеки — компилировались без единой ошибки, оба работали с any. Чужой слой встал ниже нашего, его обёртка оказалась свежее, и хендлеры четыре дня отдавали пользователям чужие профили, пока один из клиентов не заметил чужое имя в шапке страницы. Тесты прошли: на CI чужой библиотеки не было. Правило, которое выжило в ревью: строковых ключей в WithValue не существует, только собственные непросвечиваемые типы — их коллизия невозможна, потому что разные типы никогда не равны.

> **Контекст — не чемодан для бизнес-данных.** Как только WithValue освоен, появляется соблазн возить в контексте всё: конфиг, соединение с базой, модель заказа. Граница, которой держусь я: в контексте живут данные про этот запрос — кто его отправил, какой у запроса id, на каком языке нужен ответ. Бизнес-объекты едут аргументами функций: их состав виден в сигнатуре, а не добывается раскопками Value. Нарушение допустимо, когда объект действительно общий для всего запроса, а его протаскивание захламляет пять слоёв подряд, — но тогда это осознанный компромисс, а не привычка.

### Отмена и дедлайны — вторая половина контекста

Перенос данных — половина работы контекста; вторая половина — отмена, и у входящего запроса она встроена: контекст из r.Context() гасится, когда клиент закрыл соединение или ServeHTTP вернулся. Отсюда бесплатная экономика: сервисный слой, который слушает ctx.Done(), перестаёт считать работу, никому не нужную. Второе применение — ограничить время хендлера: middleware оборачивает контекст в context.WithTimeout и передаёт эстафету с ним, и любое долгое обращение к базе получает дедлайн бесплатно. Здесь это одна фраза намеренно: отмена, дерево контекстов и errors.Is на причинах — тема [урока про контекст продвинутого курса](/courses/go-advanced/lessons/09-context/), где всё разобрано на горутинах. В chi же запомните связку: r.Context() — база, WithValue кладёт данные, WithTimeout ограничивает время, и всё это едет в хендлер через r.WithContext.

### Авторизация как middleware с контекстом

```go
func 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 в хендлере?

- Пользователя u: значение уже в контексте запроса
- ✅ nil: новый контекст не приехал — хендлер видит старый r.Context()
- Панику: значение лежит в отклонённом контексте
- any без типа: каст не выполнен

WithValue не меняет существующий запрос — он возвращает новый контекст, который доезжает дальше только через r.WithContext(ctx). Со старым r по цепочке поедет старый контекст без пары: ok-форма в helper честно вернёт ok = false, и хендлер получит nil. Самый тихий баг темы — всё компилируется, тесты с одним middleware зелёные.

### Соберите перенос пользователя

Восстановите запись в контекст, передачу новой обёртки, чтение и проверку ok-формы.

```go
func userToCtx(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                ctx := context.___(r.Context(), userKey, u)

                next.ServeHTTP(w, r.___(ctx))
        })
}

func UserFromCtx(r *http.Request) *User {
        u, ok := r.Context().___(userKey).(*User)
        if !___ {
                return nil
        }

        return u
}
```

Подсказка: Пара пишется context.WithValue, в запрос оборачивается r.WithContext, достаётся Value, а отсутствие значения ловит ok.

Правильные ответы в пропусках: `WithValue`, `WithContext`, `Value`, `ok`

### Пять правил контекста запроса

- Ключ — собственный непросвечиваемый тип: type ctxKey int; строковые ключи подменяются чужими middleware.
- Значение доезжает до хендлера только через r.WithContext(ctx) и next.ServeHTTP(w, r) с новым r.
- Value возвращает any: приведение — в ok-форме, иначе паника на отсутствующем значении.
- Значение иммутабельно: «изменение» — новая обёртка поверх; Value читает самую свежую.
- Helper-функции UserToCtx/UserFromCtx прячут ключ, каст и дефолты — хендлер не знает про механику.

### Соберите middleware с контекстом

Расставьте строки middleware: от сигнатуры до передачи эстафеты с новой обёрткой.

Правильный порядок строк:

1. `func userToCtx(next http.Handler) http.Handler {`
2. `return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {`
3. `ctx := context.WithValue(r.Context(), userKey, u)`
4. `next.ServeHTTP(w, r.WithContext(ctx))`
5. `})`
6. `}`

### Карточки: контекст запроса

- **r.Context()** — Контекст текущего запроса из *http.Request: сюда middleware и роутер кладут данные запроса. Гасится сервером при закрытии соединения клиентом.
- **context.WithValue(parent, key, val)** — Возвращает новый контекст с одной парой поверх родителя. Ключ — собственный непросвечиваемый тип, не строка и не встроенный тип.
- **r.WithContext(ctx)** — Копия запроса с новым контекстом: без неё значение не доедет — по цепочке поедет старый r.Context() без пары.
- **type ctxKey int** — Непросвечиваемый тип ключа — официальная практика Go: разные типы никогда не совпадают, коллизия между пакетами невозможна. Так делает и chi в request_id.go.
- **ok-форма type assertion** — u, ok := Value(key).(*User): false вместо паники на отсутствующем значении. Голая форма .(*User) на nil падает прямо на строке каста.
- **Иммутабельность значения** — Положенное значение не меняется: «новая версия» — ещё один WithValue поверх. Value ищет с самой свежей обёртки — последний записавший выигрывает.

Пайплайн данных собран: middleware кладёт под непросвечиваемым ключом, хендлер достаёт через helper с ok-формой, и вся механика спрятана в двух функциях на пакет. Следующий шаг — положить там не заглушку, а настоящего пользователя: [урок про собственный middleware](/courses/chi/lessons/12-own-middleware/) собирает авторизацию целиком, от проверки заголовка до ролей. А после него — JSON: хендлер научится принимать и отдавать тела запросов, и пользователь из контекста станет автором заметок в [REST API на chi](/courses/chi/). До того проверьте связку руками на живом сервере: добавьте второй middleware со своим типом ключа и убедитесь, что подмена значений больше невозможна, даже если рядом живёт туториальный строковый "user".

## Коротко: что запомнить

- 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 ради разных версий данных полагаться нельзя.
