1. Зачем прокидывать ctx по слоям
Если вы уже писали HTTP-обработчики или команды CLI, то наверняка ловили ощущение «ну я же вернул ответ/напечатал результат — какая разница». Разница появляется в момент, когда пользователь ушёл, соединение оборвалось, таймаут истёк или операция стала слишком долгой. Без прокидывания контекста нижние слои продолжают работать «в пустоту»: читают файл, считают, ходят в хранилище, держат блокировки. Это как продолжать готовить пиццу после того, как клиент отменил заказ — вкусно, но бизнес-модель страдает.
В Go контекст задуман как стандартный способ протащить через границы API три вещи: отмену, дедлайн и метаданные операции. Это особенно важно в серверном коде, где один входящий запрос часто запускает ещё несколько подопераций.
Ментальная модель: одна операция → один ctx, который идёт вниз
Когда мы говорим «прокидывать контекст», это не магия и не ритуал. Это прямой договор: у нас есть верхняя граница операции (HTTP-запрос или команда CLI), и именно там мы получаем/создаём корневой ctx. Дальше этот же ctx (или его дочерний контекст) передаётся вниз по стеку вызовов: handler → app/service → storage. И ни один слой по дороге не «сбрасывает» его на context.Background().
Если представить систему как эстафету, то ctx — это палочка. Уронили палочку — команда дальше бежит, но результат уже не считается, потому что отмена и дедлайн потерялись. А ещё потеряются метаданные вроде request_id, и логи превратятся в квест «угадай, какой запрос это был».
2. Пример: Tasker и контракты слоёв
Чтобы примеры не были набором абстрактных кусочков, будем считать, что у нас есть учебное приложение Tasker: оно хранит задачи и умеет их импортировать (например, из CSV). Мы не уходим в сложную архитектуру, но придерживаемся понятного деления:
- HTTP слой: принимает запрос, ставит таймаут, вызывает app-логику.
- App слой: бизнес-операции (создать задачу, импортировать задачи).
- Storage слой: хранение (для примера — in-memory).
Схема (упрощённо):
flowchart TD HTTP[HTTP handler<br/>r.Context + WithTimeout] --> APP[app.Service] APP --> ST[storage.TaskStorage] CLI[CLI command<br/>Background + WithTimeout] --> APP APP --> ST
Главная идея: и HTTP, и CLI ведут в один и тот же app-слой, а контекст идёт первым параметром везде.
Контракты слоёв: ctx — первый параметр
Сейчас будет важный момент, который выглядит как «придирка к стилю», но потом экономит часы. Мы делаем так, чтобы любая операция, которая может быть долгой или отменяемой, принимала ctx первым параметром. Это визуальный маркер: «эта функция уважает контекст».
Storage интерфейс
Начнём снизу: storage умеет создать задачу и вернуть список.
package storage
import "context"
type Task struct {
ID int
Title string
Done bool
}
type TaskStorage interface {
Create(ctx context.Context, t Task) (Task, error)
List(ctx context.Context) ([]Task, error)
}
Обратите внимание: мы не храним ctx внутри TaskStorage. Контекст относится к вызову Create/List, а не к «хранилищу как объекту». Это ровно тот принцип, который рекомендуют придерживать в Go API: контекст передаётся аргументом, а не сохраняется в поле.
App/service слой
App слой «говорит человеческими словами»: AddTask, ImportCSV.
package app
import (
"context"
"tasker/internal/storage"
)
type Service struct {
st storage.TaskStorage
}
func NewService(st storage.TaskStorage) *Service {
return &Service{st: st}
}
И метод (коротко, без деталей валидации):
package app
import (
"context"
"tasker/internal/storage"
)
func (s *Service) AddTask(ctx context.Context, title string) (storage.Task, error) {
t := storage.Task{Title: title}
return s.st.Create(ctx, t)
}
Важная мысль: app слой не придумывает новый контекст, он уважает входной и передаёт его дальше.
3. HTTP → app → storage: как не потерять ctx
HTTP — это идеальный пример «границы операции»: запрос начался, запрос закончился. У Go HTTP сервера уже есть готовый контекст запроса: r.Context(). Он отменяется, когда клиент уходит или когда handler завершился (в зависимости от ситуации). Поэтому стартовая точка у нас есть из коробки.
Handler: берём r.Context() и добавляем таймаут
Частая практика — ограничить время обработки запроса. Не внутри storage, не «где-то глубоко», а прямо в handler: это место, где вы понимаете ожидания клиента.
package httpapi
import (
"context"
"net/http"
"time"
)
func withRequestTimeout(r *http.Request, d time.Duration) (context.Context, func()) {
ctx, cancel := context.WithTimeout(r.Context(), d)
return ctx, cancel
}
Теперь применим в handler:
package httpapi
import (
"net/http"
"time"
)
func (h *Handler) handleAddTask(w http.ResponseWriter, r *http.Request) {
ctx, cancel := withRequestTimeout(r, 2*time.Second)
defer cancel()
_, _ = h.svc.AddTask(ctx, "buy milk")
w.WriteHeader(http.StatusCreated)
}
Да, пример нарочно упрощён (мы не читаем JSON/форму), потому что сейчас нас интересует маршрут контекста. Таймаут задан на границе, cancel() вызван дисциплинированно.
Почему defer cancel() обязателен даже при таймауте? Потому что WithTimeout создаёт внутренний таймер/ресурсы, и ранняя отмена освобождает их быстрее. Это часть нормальной гигиены.
App слой ничего не «сбрасывает»
Правильный app слой выглядит скучно. И это комплимент.
package app
import (
"context"
"tasker/internal/storage"
)
func (s *Service) ListTasks(ctx context.Context) ([]storage.Task, error) {
return s.st.List(ctx)
}
Самая опасная «оптимизация» новичка — написать так:
// ПЛОХО: мы потеряли отмену/таймаут/метаданные.
return s.st.List(context.Background())
Это ломает всю идею: handler мог быть отменён, но storage этого уже не узнает.
Storage: хотя бы минимально проверяем ctx.Done()
В in-memory хранилище операция обычно быстрая, но привычку формируем здесь же. Пусть List проверяет отмену перед работой (минимум) — так код ведёт себя предсказуемо, даже если позже вы замените storage на файловый или сетевой.
package memstore
import (
"context"
"sync"
"tasker/internal/storage"
)
type Store struct {
mu sync.Mutex
tasks []storage.Task
next int
}
func (s *Store) List(ctx context.Context) ([]storage.Task, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
s.mu.Lock()
defer s.mu.Unlock()
out := append([]storage.Task(nil), s.tasks...)
return out, nil
}
Здесь мы сделали два важных шага: проверили отмену и скопировали слайс (чтобы не отдавать наружу «живую» внутреннюю память). В контексте этой лекции ключевое — ctx.Err() как корректная причина завершения.
4. CLI → import → storage: Background() на границе
CLI отличается от HTTP одной важной деталью: у вас нет готового r.Context(). Зато у вас есть «граница операции» в виде команды. Это значит: корневой контекст вы создаёте сами, обычно через context.Background().
В реальном приложении часто добавляют отмену по сигналам ОС, но сегодня нам достаточно понять маршрут: CLI создаёт ctx и прокидывает его в app и storage.
Точка входа CLI: создаём ctx и ставим таймаут команды
Представим команду tasker import --file tasks.csv. Мы хотим, чтобы импорт не висел бесконечно.
package main
import (
"context"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = ctx
// дальше: открыть файл и вызвать svc.ImportCSV(ctx, f)
}
Здесь опять таймаут задан на границе операции. Это тот же принцип, что и в HTTP.
App метод импорта принимает ctx и io.Reader
Импорт — отличный пример «слоёв»: CLI работает с файлом, но app слой не обязан знать про os.File. Ему достаточно io.Reader. И — контекст.
package app
import (
"context"
"encoding/csv"
"fmt"
"io"
)
func (s *Service) ImportCSV(ctx context.Context, r io.Reader) (int, error) {
cr := csv.NewReader(r)
n := 0
for {
select {
case <-ctx.Done():
return n, ctx.Err()
default:
}
rec, err := cr.Read()
if err == io.EOF {
return n, nil
}
if err != nil {
return n, fmt.Errorf("read csv: %w", err)
}
if len(rec) == 0 {
continue
}
if _, err := s.AddTask(ctx, rec[0]); err != nil {
return n, err
}
n++
}
}
Этот код делает две вещи, которые нас сегодня интересуют больше всего. Во-первых, он регулярно проверяет ctx.Done() внутри цикла — это «точка выхода» для долгой операции. Во-вторых, он не создаёт новый контекст: использует ровно тот, что пришёл сверху.
CLI слой: открыл файл, передал ctx вниз
Теперь свяжем это с os.Open. CLI слой занимается файловыми деталями, app слой — импортом как бизнес-операцией.
package main
import (
"context"
"os"
"time"
)
func runImport(path string, svc *Service) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
_, err = svc.ImportCSV(ctx, f)
return err
}
Обратите внимание на «культурный слой» кода: ctx появился на верхнем уровне функции, и дальше он идёт вниз, как чемодан без ручки, который всё равно несём, потому что это наш чемодан.
5. Таймауты и жизненный цикл контекста
Где задавать таймауты: на границе или внутри
Вопрос «где ставить таймаут» кажется философским, пока вы не поймали баг: запрос умер, а ваш storage продолжает работать. Общая практика такая: таймаут задаётся там, где понятен смысл операции для пользователя.
Ниже небольшая таблица «как думать», без претензии на единственную истину:
| Слой | Имеет право задавать таймаут? | Почему |
|---|---|---|
| HTTP handler | Да | Он ближе всего к клиенту и понимает SLA запроса |
| CLI команда | Да | Она описывает «операцию пользователя» (импорт, экспорт, list) |
| App слой | Иногда | Может задавать подтаймаут для подоперации, но только от входного ctx |
| Storage слой | Обычно нет | Storage должен уважать ctx, а не навязывать свои правила всем |
Если app слою нужен более короткий лимит на подоперацию, это делается только так: context.WithTimeout(parentCtx, d). Контексты образуют дерево, и дочерний унаследует отмену родителя, а не заменит её.
Почему нельзя «для удобства» хранить ctx в struct
Это искушение выглядит очень невинно: «ну я же всё равно всегда вызываю методы сервиса, почему бы не положить ctx внутрь Service?». Потому что тогда вы смешиваете времена жизни. Контекст относится к одной операции, а Service живёт долго. В результате следующий вызов сервиса может случайно работать с уже отменённым контекстом или с дедлайном «вчера». И вы получите поведение уровня «иногда работает, иногда нет» — любимый жанр багов, который отнимает сон.
Официальная рекомендация Go для большинства случаев проста: контексты не хранят в структурах, их передают аргументом в методы, чтобы у каждого вызова был свой жизненный цикл и свои дедлайны/метаданные.
6. Схемы последовательностей: HTTP и CLI
Иногда полезно увидеть не код, а «мультик» про то, кто кого вызывает и куда течёт ctx.
HTTP запрос
sequenceDiagram participant C as Client participant H as HTTP handler participant S as app.Service participant ST as storage C->>H: Request Note over H: ctx = r.Context()<br/>ctx = WithTimeout(ctx, 2s) H->>S: AddTask(ctx, title) S->>ST: Create(ctx, Task) ST-->>S: (Task, nil) или ctx.Err() S-->>H: result H-->>C: 201 Created
CLI import
sequenceDiagram
participant U as User
participant CLI as CLI main
participant S as app.Service
participant ST as storage
U->>CLI: tasker import tasks.csv
Note over CLI: ctx = Background()<br/>ctx = WithTimeout(ctx, 10s)
CLI->>S: ImportCSV(ctx, file)
loop each row
S->>ST: Create(ctx, Task)
end
ST-->>S: ok / ctx.Err()
S-->>CLI: count / error
CLI-->>U: exit code + message
И в обеих ветках одна и та же ключевая дисциплина: ctx не «обнуляется» и не заменяется на Background() в середине.
7. Типичные ошибки
Ошибка №1: «Сбросить» контекст на context.Background() где-то в app/storage, потому что так проще.
Это ломает отмену и дедлайны, и делает верхний слой (HTTP/CLI) бессильным. В результате клиент ушёл, а вы продолжаете работу, тратите ресурсы и иногда держите блокировки дольше, чем нужно. Лечится просто: ctx всегда приходит сверху и уходит вниз, без самодеятельности.
Ошибка №2: Забыть defer cancel() после WithTimeout/WithDeadline.
Даже если кажется, что «таймаут сам всё отменит», cancel() — это дисциплина освобождения внутренних ресурсов (таймеров, ссылок на дерево контекстов). Правильная привычка: создали (ctx, cancel) — сразу следующей строкой defer cancel().
Ошибка №3: Дать ctx не первым параметром, а где-то в середине сигнатуры.
Функции начинают выглядеть непоследовательно, а код-ревью превращается в игру «найди контекст». Go-экосистема привыкла к правилу: ctx context.Context идёт первым. Это не закон физики, но это очень удобный «стандарт чтения».
Ошибка №4: Не проверять ctx.Done() в долгих циклах (например, импорт/обход/обработка больших данных).
Если операция состоит из тысячи шагов, но вы не смотрите на ctx.Done(), то отмена «не работает» на практике: пользователь отменил, а вы дочитываете файл до конца. Решение — ставить точки проверки внутри цикла и корректно возвращать ctx.Err() как причину остановки.
Ошибка №5: Хранить ctx в поле структуры сервиса или хранилища «на всякий случай».
Так вы смешиваете жизненные циклы разных операций: один вызов может случайно отменить другой, а дедлайны становятся непредсказуемыми. Вместо этого контекст передают аргументом в каждый метод, который относится к конкретной операции.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ