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" рано чи пізно
зіткнеться з чиїмось іще.
Використовуйте неекспортований тип. Інший пакет не може його назвати, тож зіткнення неможливе:
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. Кожна функція, що виконує
введення-виведення чи може заблокуватися:
Ніколи не зберігайте його в структурі. Контекст описує час життя
однієї операції; структура переживає її. Виняток — 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() як маркер |