Всем доброго дня, сегодня разберём, зачем нам вообще пакет «context» в Go и что интересного внутри там есть. Если тебя на собеседовании спрашивают про контекст в гошке и твой ответ звучит примерно так: «ну… это хуйня, которую я в функцию первым аргументом прокидываю», то этот пост для тебя
Что такое context
Это объект, который передаётся между функциями и хранит некую информацию, которая может пригодиться нескольким частям твоего приложения: допустим, к тебе пришёл запрос от клиента на покупку бургера, а у тебя приложение архитектурно построено так:
Handler -> Service -> Repository -> DB (1)
То есть в хэндлере ты принимаешь запрос, вызывая нужный сервис для обработки нового заказа, а сервис в свою очередь идёт в репозиторий, а репозиторий уже обращается к базе данных для создания заказа. Так вот, представим, что в моменте (например, когда исполнение запроса уже дошло до репозитория), пользователь отменил запрос, потому что случайно мискликнул и теперь нажал на крестик. Нужно ли теперь продолжать флоу и идти в базу, создавая заказ? Ну конечно же нет, в этом больше нет смысла
Но сам репозиторий не узнает о том, что запрос был отменён, кто-то ему должен об этом сказать. И вот этот «кто-то» — это и есть context. Сам контекст не несёт никакой бизнес-логики, его основная задача: дать информацию о том, что что-то изменилось, пока мы обрабатывали заказ, то есть дать некий сигнал, на который уже мы должны отреагировать
Как это работает
Схема (1), если накидать немного кода, выглядит примерно так:
//handler.go
func Handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
service.CreateOrder(ctx)
}
//service.go
func (s *Service) CreateOrder(ctx context.Context) error {
return s.repository.CreateOrder(ctx)
}
//repo.go
func (r *Repository) CreateOrder(ctx context.Context) error {
_, err := r.db.ExecContext(
ctx, "INSERT INTO orders (...) VALUES (...)",
)
return err
}
А если посмотреть в интерфейс Context, то выглядит он так
type Context interface {
Deadline() (deadline time.Time, ok bool) //возвращает дедлайн (при наличии), после которого контекст должен быть отменён. второй аргумент (ok) и говорит о том, есть ли deadline у данного контекста
Done() <-chan struct{} //возвращает канал, который закроется при отмене контекста (может быть nil)
Err() error //пока канал открыт, возвращает nil, после — возвращает ошибку context.Canceled или context.DeadlineExceeded
Value(key any) any //context может хранить значение по ключу, связанное с ним, а фактически с конкретным запросом или операцией
}
В комментариях есть описание каждого метода. Мы поняли, что метод Done() возвращает канал, который закроется, когда context будет отменён, а значит мы можем с помощью инструкции select дожидаться, когда закрытие канала разблокирует наш case и мы вернём ошибку контекста:
//service.go
func (s *Service) CreateOrder(ctx context.Context) error {
select {
case <-ctx.Done(): //этот кейс выберется только тогда, когда канал будет закрыт, в любом другом случае идем в default и в repository
return ctx.Err() //возвращаем причину, по которой контекст отменился
default:
}
return s.repository.CreateOrder(ctx)
}
В репозитории мы используем метод ExecContext, поэтому гошка на этом уровне разруливает сама, нам нужно лишь передать контекст, если он отменится — отменится и запрос (ну это если упростить, по факту там database/sql передает отмену драйверу, и запрос может быть прерван, если драйвер его поддерживает)
Важный момент, перед тем как пойти дальше: сам по себе context и его отмена не останавливает выполнение функции, он даёт лишь сигнал через канал, а нам нужно на него реагировать
Как нам самим объявлять context?
Пока у нас был только r.Context(), который мы вытащили из http.Request, но нам бы хотелось самим делать контекст, потому что таким образом отменять хочется не только запросы пользователей, но и другие, более внутренние операции
func main() {
ctx := context.Background()
service.Run(ctx)
}
context.Background() создаёт корневой контекст и у него пока нет ни дедлайна, ни функции отмены, ни каких-либо значений, пока мы просто создали оболочку, которую можем куда-либо прокинуть
Есть ещё context.TODO(), но он используется только в том случае, когда ты понимаешь, что он здесь должен быть, но пока не знаешь, какой именно context сюда нужно передать. Например, если ты рефакторишь старый код и ещё не успел протащить нормальный контекст сверху:
func CreateOrder() error {
ctx := context.TODO()
return repository.CreateOrder(ctx)
}
Окей, у нас есть контекст. Как его отменять, то есть слать те самые сигналы?
context.WithCancel() — функция, которая возвращает нам новый контекст + функцию отмены этого контекста
func main() {
ctx := context.Background()
newCtx, cancel := context.WithCancel(ctx)
go worker(newCtx)
cancel()
}
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("останавливаем работу:", ctx.Err())
return
default:
fmt.Println("работаем...")
time.Sleep(time.Second)
}
}
}
Что тут произошло?
1. Мы создали корневой контекст через context.Background()
2. С помощью context.WithCancel(ctx) создали дочерний контекст, который мы можем отменить
3. Запустили в отдельной горутине worker, в котором итерируемся в цикле for пока наш контекст, переданный в функцию, не отменился
4. Отменяем этот контекст в функции main
5. Получаем срабатывание кейса с <-ctx.Done() и выход из функции worker
В этом кейсе мы отменяем контекст вручную, когда захотим этого сами, но если же мы хотим отменить контекст через какое-то время автоматически, то есть ограничить выполнение функции по времени, то лучше сделать это через context.WithTimeout():
func (s *Service) CreateOrder(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
return s.repository.CreateOrder(ctx)
}
Фактически, WithTimeout от WithCancel отличается тем, что принимает время (time.Duration) вторым аргументом и собственно через это время автоматически отменяется контекст
Внимательный читатель спросит: «а нахрена нам здесь defer cancel(), если ты говоришь, что контекст сам отменится», а я отвечу:
Суть в том, что в примере выше контекст отменяется автоматически только через 2 секунды, но поход в репозиторий (а дальше и в бд) может закончиться сильно раньше, например через 150 мс, и тогда нам этот контекст уже не нужен и его можно отменить раньше, чем запланировано по таймауту
Немного про наследование контекстов
Как вы поняли, во все функции по типу WithCancel и WithTimeout мы передаем уже существующий контекст и на его основе создаём новый, и поэтому тут работает следующее правило: если отменяется контекст, то и отменяются все контексты, которые были созданы от него
Например:
func main() {
parentCtx, parentCancel := context.WithCancel(context.Background())
childCtx, childCancel := context.WithTimeout(parentCtx, 10*time.Second)
defer parentCancel()
defer childCancel()
}
Несмотря на то, что в childCtx есть таймаут в 10 секунд, он отменится вместе с вызовом parentCancel(), причем контекст отменяется сверху вниз, если отменяется childCtx, то parentCtx будет жить
Помимо функции WithTimeout существует ещё и WithDeadline — функция, которая работает так же, как и WithTimeout, но задаёшь ты не время работы контекста, а время, до которого он работает
Вернёмся к Err() у интерфейса Context
Какая конкретно ошибка вернётся при отмене контекста зависит от того, как мы его отменили:
context.Canceled — контекст был отменён через cancel
context.DeadlineExceeded — контекст был отменён через timeout/deadline
Теперь про Value() у интерфейса Context
Мы можем в наш контекст положить значение по ключу, например для логов (про это отдельно), с помощью context.WithValue
//main.go
ctx := context.WithValue(ctx, "requestID", "abc-123")
А потом, в другом месте приложения, можем вытащить значение по ключу
//service.go
requestID := ctx.Value("requestID")
В качестве домашки почитайте про WithoutCancel, у меня лимиты :)
Кто я | Чат айтишников | Мои услуги