Перейти до змісту

Context як носій значень

context.Context виконує три роботи: несе скасування, дедлайн і значення в межах запиту. Context покрив перші дві. Третя — та, що має гострі кути.

type userKey struct{}

func WithUser(ctx context.Context, name string) context.Context {
    return context.WithValue(ctx, userKey{}, name)
}

func UserFrom(ctx context.Context) (string, bool) {
    s, ok := ctx.Value(userKey{}).(string)
    return s, ok
}

Ключ має бути неекспортованим типом

Value шукає за ключем, який порівнюють через ==, по всьому контексту — включно зі значеннями, покладеними туди бібліотеками, які ви не контролюєте. Ключ-string під назвою "user" рано чи пізно зіткнеться з чиїмось іще.

Використовуйте неекспортований тип. Інший пакет не може його назвати, тож зіткнення неможливе:

type userKey struct{}

struct{}{} не коштує пам'яті, і сам тип є ідентичністю. Неекспортований type ctxKey int із константами на iota працює так само добре, коли у вас їх кілька.

Експортуйте акцесори, не ключ. Виклики (callers) використовують WithUser і UserFrom; ніхто зовні не може сконструювати ключ чи прочитати сире значення. Це також дає вам одне місце, щоб пізніше змінити тип.

ctx := WithUser(context.Background(), "ada")
u, ok := UserFrom(ctx)
fmt.Println(u, ok)              // output: ada true

_, ok = UserFrom(context.Background())
fmt.Println(ok)                 // output: false

Завжди повертайте ok. Відсутнє значення дає nil, а перевірка comma-ok — це те, як ви відрізняєте "відсутнє" від "присутнє й порожнє".

Що там має бути

Факти в межах запиту, що перетинають багато шарів і які більшість функцій просто передають далі:

  • автентифікований користувач чи орендар (tenant)
  • id запиту чи трейсу
  • логер, уже позначений цими тегами

Що не має бути:

  • Залежності. Сховище, клієнт, конфігурація. Це параметри конструктора, де їх перевіряє компілятор.
  • Необов'язкові аргументи. Якщо функції потрібне значення, кладіть його в сигнатуру. Значення контексту невидиме для виклику й неперевірене.
  • Будь-що змінюване. Контекст незмінний за задумом; покласти туди вказівник і мутувати через нього — це гонитва даних, що чекає свого часу.

Перевірка: якщо видалення значення з контексту перетворило б помилку компіляції на несподіванку під час виконання, воно мало бути параметром.

Несемо транзакцію

Справді межовий випадок — це транзакція бази даних, зі статті про транзакції. Покласти її в контекст означає, що методи сховища тримають однорідну сигнатуру, і будь-який з них може взяти участь у транзакції виклику, навіть не знаючи про це.

Ціна реальна: сигнатура більше не каже, чи функція пише всередині транзакції. Це компроміс — однорідні сигнатури й композиційність проти явності. Малі кодові бази мають передавати tx явно; це стає виправданим, коли дерево викликів достатньо глибоке, щоб протягування tx торкалося всього.

Якщо ви так робите, надайте WithTx і резолвер, ніколи не сирі виклики Value у місцях виклику.

Відв'язуємо роботу, яка має пережити запит

Коли обробник повертається, його контекст скасовується. Фонова робота, запущена з цим контекстом, гине негайно — зазвичай посеред запису.

context.WithoutCancel зберігає значення й скидає скасування:

ctx, cancel := context.WithCancel(WithUser(context.Background(), "bo"))
detached := context.WithoutCancel(ctx)
cancel()

fmt.Println("original err:", ctx.Err())      // output: original err: context canceled
fmt.Println("detached err:", detached.Err()) // output: detached err: <nil>

u, _ := UserFrom(detached)
fmt.Println(u)                               // output: bo

Відв'язаний контекст зберігає користувача — а це зазвичай саме те, що потрібно, бо фонове завдання все ще має знати, хто його спричинив.

Дві обережності. Він копіює кожне значення, включно з тими, чиє життя було прив'язане до запиту. Це саме та пастка, де сходяться дві половини цієї статті: транзакція, що несеться в контексті, переживає відв'язування.

txCtx := context.WithValue(ctx, txKey{}, tx)
cancelCtx, cancel := context.WithCancel(txCtx)

detached := context.WithoutCancel(cancelCtx)
cancel()

fmt.Println(detached.Err())                  // output: <nil>
fmt.Println(detached.Value(txKey{}) != nil)  // output: true

Фонова робота тепер розв'язує транзакцію, яку запит уже закомітив або відкотив. Записи через неї або повертають помилку, або мовчки відкидаються — і саме "мовчки відкинуто" той результат, на пошук якого ви витратите цілий день.

Зніміть усе, прив'язане до запиту, як частину відв'язування:

func detach(ctx context.Context) context.Context {
    return context.WithValue(context.WithoutCancel(ctx), txKey{}, nil)
}

Ще краще — зробіть це єдиним способом відв'язування у вашій кодовій базі, щоб ніхто не викликав WithoutCancel напряму і не мусив пам'ятати про це.

І відв'язаний контекст не має жодного дедлайну. Дайте йому один, інакше ви створили роботу, яка може виконуватися вічно:

detached, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
go doBackgroundWork(detached)

Домовленості

Перший параметр, завжди названий ctx. Кожна функція, що виконує введення-виведення чи може заблокуватися:

func (s *store) ByID(ctx context.Context, id int64) (User, error)

Ніколи не зберігайте його в структурі. Контекст описує час життя однієї операції; структура переживає її. Виняток — http.Request, що несе власний і роздає його через r.Context().

Ніколи не передавайте nil. Використовуйте context.Background() на початку main чи в тесті, а context.TODO() — як свідомий маркер того, що ви ще не вирішили.

Передавайте його вниз, не ховайте про запас. Горутина (goroutine), що переживає виклик, потребує контексту, який ви обрали для неї, а не той, що трапився під рукою.

З досвіду Python: найближче — contextvars, але явний варіант — немає навколишнього поточного контексту, тож значення існує лише там, куди його передали. Більше набирати руками, зате без загадок, звідки взялося значення.

Швидка довідка

Питання Форма
ключ неекспортований type k struct{} — ніколи не рядок
API експортовані WithX(ctx, v) і XFrom(ctx) (T, bool)
читання перевірка comma-ok, завжди перевіряти
що кладемо користувач, id запиту, спан трейсу
що не кладемо залежності, обов'язкові аргументи, змінний стан
пережити запит context.WithoutCancel, потім додати таймаут
відв'язати транзакцію спершу зняти — WithoutCancel її копіює
сигнатура ctx context.Context першим, завжди
у структурі ні
корені context.Background(), або context.TODO() як маркер

Джерела