1. WithValue: зачем он нужен и почему им легко злоупотребить
Когда вы впервые видите context.WithValue, возникает очень понятная мысль: «О, круто, можно же туда складывать всё подряд — и параметры, и настройки, и любимую музыку». И вот тут важно остановиться. WithValue — это не сумка‑авоська «на всякий случай», а маленький конверт для метаданных операции: того, что относится к текущему запросу/операции и может понадобиться на разных уровнях кода.
Идея контекста в Go исторически связана с серверами: один входящий запрос порождает работу в нескольких функциях и даже горутинах, и всем им могут понадобиться request‑scoped данные (например, дедлайн запроса, идентификатор запроса, информация о пользователе), а при отмене нужно быстро завершиться. Именно это и описывают авторы подхода: контекст помогает переносить request‑scoped values вместе с дедлайнами и сигналами отмены через границы API.
Проблема в том, что если начать использовать WithValue как замену нормальным аргументам функций, код превращается в квест: «а где это значение положили?», «а почему тут nil?», «а почему тип не тот?». Поэтому цель простая: научиться использовать WithValue строго по назначению и так, чтобы его было приятно читать.
Контекст как «конверт»: WithValue создаёт новый контекст
Важно подойти к WithValue без мистики. Контекст — это интерфейс, а WithValue(parent, key, value) возвращает новый контекст, производный от родителя. У родителя ничего не «вшивается» и не «меняется». Это соответствует общей модели производных контекстов: контексты образуют дерево, и дочерние наследуют свойства отмены/дедлайна родителя.
Представьте папку с документами. parent — это папка, WithValue — это «положить внутрь папки ещё один листочек», но технически получается новая папка‑обёртка: она ссылается на старую папку и добавляет свой листочек сверху. Чем больше WithValue, тем больше таких «обложек». Поэтому мы не хотим складывать туда мегабайты данных или делать WithValue в цикле тысячу раз — это не для этого.
Мини‑пример «родитель не изменяется, создаётся новый контекст»:
package main
import (
"context"
"fmt"
)
type key struct{}
func main() {
parent := context.Background()
child := context.WithValue(parent, key{}, "hello")
fmt.Println(parent.Value(key{})) // <nil>
fmt.Println(child.Value(key{})) // hello
}
Здесь хорошо видно: у parent значения нет, у child есть.
2. Чтение значений и типобезопасные ключи
ctx.Value: почему возвращается any
После WithValue появляется зеркальный вопрос: как доставать?
Метод Value(key) возвращает any (в старых текстах вы увидите interface{}). Это означает: «я не знаю, что там лежит по этому ключу». Поэтому доставать нужно аккуратно, через type assertion, с ok. Это тот же приём, который вы уже встречали в других местах Go: мы пытаемся привести значение к нужному типу и проверяем ok, чтобы не получить panic.
Мини‑пример извлечения:
package main
import (
"context"
"fmt"
)
type userIDKey struct{}
func main() {
ctx := context.WithValue(context.Background(), userIDKey{}, 101)
v := ctx.Value(userIDKey{})
id, ok := v.(int)
fmt.Println(id, ok) // 101 true
}
Обратите внимание на механику: мы сначала берём any, затем спрашиваем «ты точно int?». Если нет — получаем ok == false и решаем, что делать дальше.
Типобезопасные ключи: почему string — это «мина»
Если вы пишете context.WithValue(ctx, "request_id", rid), оно работает. Иногда даже долго. А потом в проекте появляется ещё один пакет, который тоже решил хранить значение по ключу "request_id" (или "id", или "user"), и у вас начинается «шахматная партия» с багами: значения перетираются, типы расходятся, часть кода видит одно, часть — другое.
Поэтому в Go принято делать ключ не строкой, а отдельным типом, чаще всего пустой структурой:
type requestIDKey struct{}
Почему это работает? Потому что requestIDKey{} — это ключ, который никто случайно не повторит «просто строкой». Ключ становится уникальным по типу, а тип — это уже «пространство имён», только на уровне компилятора.
Ещё одна важная деталь: контекст безопасен для одновременного использования из нескольких горутин. Но это не означает, что любое значение внутри автоматически становится потокобезопасным. Требование как раз обратное: если вы кладёте что-то в Value, это «что-то» должно быть безопасно читать параллельно и не должно предполагать конкурентные записи.
Делаем человеческий API: WithRequestID и RequestID
Когда вы используете «ключ‑тип», в коде возникает повтор: в одном месте положили requestIDKey{}, в другом достали requestIDKey{}. Повтор сам по себе не страшен, но он создаёт риск: где-то случайно использовать другой ключ или неверный тип значения.
Хороший стиль — спрятать ключ и дать наружу две функции‑помощники: одна кладёт, другая достаёт. Тогда внешний код говорит «положи request id» и «достань request id», а не «положи value по странному ключу».
Пример такого мини‑пакета (условно internal/requestctx).
Функция «положить»:
package requestctx
import "context"
type requestIDKey struct{}
func WithRequestID(ctx context.Context, rid string) context.Context {
return context.WithValue(ctx, requestIDKey{}, rid)
}
Функция «достать» (ключ переиспользуем, а не объявляем заново):
package requestctx
import "context"
func RequestID(ctx context.Context) (string, bool) {
v := ctx.Value(requestIDKey{})
rid, ok := v.(string)
return rid, ok
}
Ключ не «утекает» наружу, а API получается читаемым и устойчивым к ошибкам.
3. request_id в HTTP и логировании
На границе приложения: HTTP middleware
Когда у вас есть request_id, он особенно полезен на границе приложения: в HTTP‑сервере, в middleware, в логировании. Тогда вы можете быстро сопоставить «вот этот лог» с «вот этим запросом». И в результате отладка становится похожа на работу инженера, а не археолога.
В Go‑серверной модели каждый входящий запрос обычно обрабатывается в своей горутине, а внутри обработчика могут быть вызовы в другие системы и новые горутины. Именно для таких сценариев контекст и задуман: request‑scoped данные, дедлайны и отмена должны быть доступны всем участникам работы по запросу.
Мини‑пример: берём X-Request-ID из заголовка и кладём в ctx. Здесь мы не генерируем «идеальные UUID», чтобы не отвлекаться: если заголовка нет — ставим простую заглушку (в реальном проекте вы бы генерировали нормальный идентификатор).
package main
import (
"net/http"
"example/internal/requestctx"
)
func withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rid := r.Header.Get("X-Request-ID")
if rid == "" {
rid = "unknown"
}
ctx := requestctx.WithRequestID(r.Context(), rid)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Обратите внимание на механику: мы берём r.Context(), делаем WithRequestID, затем создаём новый *http.Request через r.WithContext(ctx).
Логирование: когда польза WithValue становится видимой
Пока вы просто «кладёте значение в контекст», кажется, что это какая-то формальность. Польза становится заметной, когда вы делаете маленькую функцию логирования, которая автоматически добавляет request_id, если он есть.
И да, это тот редкий случай, когда «скрытая» информация действительно уместна: request_id — это метаданные операции, а не бизнес‑параметр функции. Бизнес‑параметры мы передаём явно, а request_id — это как номер заказа на кухне: повару важно его видеть, но котлета от этого не меняет рецепт.
Пример «лог‑строчки с request_id»:
package main
import (
"context"
"fmt"
"example/internal/requestctx"
)
func logLine(ctx context.Context, msg string) {
if rid, ok := requestctx.RequestID(ctx); ok {
fmt.Println("request_id=", rid, msg) // request_id= abc-1 started
return
}
fmt.Println(msg) // started
}
Эта функция ещё не структурный логгер, но как учебная ступенька она очень хороша: вы видите, зачем вообще прокидывали request_id.
Схема: как request_id «едет» по вызовам
Иногда полезно увидеть общую картинку, чтобы перестать думать о WithValue как о странной особенности языка. Ниже схема одного запроса: граница (HTTP) кладёт request_id, дальше код в основном только читает.
flowchart TD
A[HTTP запрос] --> B[Middleware: достали X-Request-ID]
B --> C["ctx = WithRequestID(r.Context(), rid)"]
C --> D[Handler]
D --> E[Service/Usecase]
E --> F[Storage/Client]
D --> G[logLine: печать с request_id]
Важная мысль: большинство уровней не создают request_id, они только «уважают контекст» и при необходимости читают метаданные. Так WithValue остаётся простым, а не превращается в хаос.
4. Что можно класть в context.Value
На этом месте хочется «выпустить внутреннего архитектора» и придумать 48 правил. Но для начинающих полезнее короткая и честная модель: WithValue — для метаданных операции, а не для бизнес‑данных и не для конфигурации приложения.
Контекст задуман как способ переносить request‑scoped values вместе с дедлайнами и сигналами отмены. И эти значения должны быть безопасны для одновременного использования.
Вот удобная таблица (она не заменяет мышление, но экономит нервы):
| Категория | Пример | Можно в WithValue? | Почему |
|---|---|---|---|
| Трассировка/диагностика | |
Да | Метаданные операции, полезны «поперёк» слоёв |
| Аутентификация/идентификация | user id, claims | Иногда (аккуратно) | Тоже метаданные запроса, но не превращайте контекст в «сейф» |
| Дедлайн/таймаут | deadline внутри ctx | Да | Контекст для этого и придуман |
| Бизнес‑параметры | |
Нет | Их должно быть видно в сигнатуре функции |
| Большие данные | списки, ответы API, файлы | Нет | Раздувает память и усложняет владение данными |
| Конфигурация приложения | , «режим прод/дев» |
Нет | Это не request‑scoped, это конфиг процесса |
Формулировка «иногда можно» про user id означает: если у вас уже есть слой аутентификации, и вам нужно, чтобы бизнес‑логика могла узнать «кто вызывает», вы можете положить минимальные метаданные (например, UserID). Но «положить токен доступа и таскать его везде» — почти всегда плохая идея.
5. Анти‑паттерны WithValue
Очень легко перейти грань и начать делать так: вместо func Do(ctx, userID, taskID) написать func Do(ctx) и доставать всё из контекста. Первую неделю это кажется удобным, а потом вы ловите баг: taskID не положили, тип значения поменяли, тесты стали зависеть от неявного состояния, а новичок в команде тихо плачет в go test.
Проблема тут не в том, что «контекст плохой». Проблема в том, что контракт функции становится неявным. Сигнатура перестаёт быть честной. Вместо того чтобы видеть «мне нужен taskID», вы видите «мне нужен контекст, а внутри угадай сам».
Ещё один анти‑паттерн — строковые ключи. Он коварен тем, что долго не ломается, а потом ломается в самый неподходящий момент: когда проект вырос и разные пакеты начали использовать похожие имена ключей.
И финальный классический анти‑паттерн — класть в контекст изменяемый объект (например, *bytes.Buffer или map) и модифицировать его из нескольких мест. Контекст можно безопасно читать из нескольких горутин, но это не делает ваше значение потокобезопасным автоматически.
7. Типичные ошибки при работе с WithValue
Ошибка №1: использовать string‑ключи (“request_id”, “user”, “id”).
Сначала кажется, что это проще и быстрее. Потом в кодовой базе появляется второй пакет, который тоже решил, что "id" — хороший ключ, и начинается весёлое соревнование «кто перетрёт значение последним». Делайте ключи отдельным типом, чаще всего struct{}, и храните helper‑функции в одном месте.
Ошибка №2: доставать значение без проверки типа (ctx.Value(k).(T) без ok).
Такой код работает ровно до первого случая, когда значение отсутствует или неожиданно другого типа. А потом вы получаете panic в проде и ощущение «ну почему опять я». Правильный паттерн — v, ok := ..., и дальше аккуратная обработка.
Ошибка №3: класть в контекст бизнес‑параметры вместо явных аргументов функций.
Это превращает ваш код в «секретный клуб»: чтобы понять, что нужно функции, надо читать её внутренности и все вызовы до неё. Контекст предназначен для request‑scoped метаданных и сигналов отмены/дедлайнов, а не для замены дизайна API.
Ошибка №4: хранить в контексте изменяемые объекты и менять их из разных мест.
Контекст можно безопасно читать из нескольких горутин, но это не делает ваше значение потокобезопасным автоматически. Если положили map и кто-то его пишет параллельно — получите гонку данных. Требование ровно противоположное: значения в контексте должны быть безопасны для одновременного использования.
Ошибка №5: раздувать контекст большими объектами (“давайте положим весь ответ API”).
Технически вы можете, но вы сами себе усложняете владение памятью и жизненный цикл данных. WithValue — это маленькие метаданные, а большие данные должны жить в нормальных переменных, структурах и возвращаемых значениях функций.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ