Этот пост является конспектом моего видео про контексты:
Я заметил, что тема контекстов в языке Go у многих почему-то вызывает сложности с пониманием. Возможно, это связано с тем, что контекст — это очень абстрактная сущность и не встречается в других языках программирования в таком виде, по крайней мере в тех языках, что довелось использовать мне.
В общем, я решил написать материал по данной теме с примерами и лучшими практиками. Вам будет легче понять то, о чем я буду рассказывать, если вы уже знакомы с основами языка Go, в частности знаете, что такое горутины и каналы.
Что такое контекст?
Если мы взглянем на документацию к пакету context
, то первый абзац будет таким:
Package context defines the Context type, which carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes.
Пакет context определяет тип Context, который позволяет управлять дедлайнами, сигналами отмены и другими значениями области действия запросов между границами API и процессами.
Что это, чёрт побери, значит?
А значит это примерно следующее:
Контекст — это объект, который предназначен в первую очередь для того, чтобы иметь возможность отменить извне выполнение потенциально долгой операции. Кроме того, с помощью контекста можно хранить и передавать информацию между функциями и методами внутри вашей программы.
Отменять долгие операции с помощью контекста можно несколькими способами:
- По явному сигналу отмены (
context.WithCancel
) - По истечению промежутка времени (
context.WithTimeout
) - По наступлению временной отметки или дедлайна (
context.WithDeadline
)
Пример. Столик в ресторане
Вы хотите забронировать столик в ресторане. Для этого вы набираете ресторан, и ждете, пока на той стороне возьмут трубку. Далее происходит одно из двух:
Сотрудник ресторана берёт трубку. В таком случае вы начинаете диалог — всё хорошо;
На той стороне никто не берёт трубку в течение минуты, двух, трёх…
Во втором случае вы не будете ждать вечно, и положите трубку, когда вам надоест ждать, либо вы поймёте, что это не имеет смысла.
Похожим образом работает контекст с таймаутом: как только проходит определённый промежуток времени, исполнение операции останавливается.
context.WithTimeout()
Ваше приложение отправляет запрос во внешнюю систему, например, в API другого сервиса, который владеет интересующими вас данными. Так как мы не контролируем внешние системы, мы не можем быть на 100% уверены, что API ответит за приемлемое время, или вообще ответит когда-либо. Чтобы не зависнуть навечно в ожидании ответа от API, в запрос можно передать контекст:
|
|
Давайте разбираться, что здесь написано.
|
|
Первым делом происходит инициализация контекста с таймаутом в 15 секунд. Конструкция defer cancel()
гарантирует, что после выхода из функции или горутины контекст будёт отменён, и таким образом вы избежите утекания горутины — явления, когда горутина продолжает выполняться и существовать в памяти, но результат её работы больше никого не интересует.
В следующем блоке ничего особенного, создаётся объект *http.Request
, куда встраивается созданный нами контекст:
|
|
Ну, и непосредственно исполнение запроса:
|
|
Дерево контекстов
Вы можете спросить: а что за context.Background()
?
Дело в том, что любой контекст должен наследоваться от какого-то другого, родительского контекста. Исключения: Background
и TODO
. Background
— это контекст-заглушка, используемый как правило как самый верхний родитель для всех дочерних контекстов в иерархии. TODO
— это тоже заглушка, но используется в тех случаях, когда мы ещё не определились, какой тип контекста мы хотим использовать. Эти два типа контекста по сути одно и тоже, и разница исключительно семантическая.
Окей, зачем нужна схема с родительскими и дочерними контекстами? Это сделано для того, чтобы внутри функции, куда был проброшен контекст, не было возможности повлиять на условия отмены сверху. Таким образом мы имеем гарантию (с некоторым оговорками), что контекст с дедлайном отменится не позже данного дедлайна. Кроме того, это даёт возможность дополнять родительский контекст и передавать дальше по цепочке новый контекст, обогащённый новыми данными.
Для наглядности рассмотрим ещё один пример. Если мы запустим программу ниже, мы увидим: несмотря на то, что внутри функции doWork
таймаут переопределяется на больший, отмена контекста все равно наступит через 10 секунд:
|
|
▶️️ https://goplay.tools/snippet/zH-183nLSAl
context.WithDeadline()
Контекст с таймаутом по сути является удобной обёрткой над контекстом с дедлайном. Программу из предыдущего примера можно выразить немного по-другому:
|
|
▶️ https://goplay.tools/snippet/arCghGGB2Xm
time.Now().Add(10*time.Second)
— это ровно то, что делает функция context.WithTimeout()
, вызывая внутри себя context.WithDeadline()
.
context.WithCancel()
Представьте, что вы очень торопитесь на важную встречу. Чтобы добраться побыстрее, вы решаете вызвать такси. Где быстрее найдётся машина, вы не знаете, поэтому решаете начать поиск в нескольких сервисах одновременно. Когда в одном из них найдётся машина, вы отмените поиск в остальных. Для похожих и других задач можно использовать контекст с функцией отмены.
Давайте представим, как описанная ситуация могла бы выглядеть в виде кода:
|
|
▶️ https://goplay.tools/snippet/TzKwMTLhpT5
context.WithValue()
Окей, а что насчёт передачи значений через контекст? Для этого в пакете существует функция WithValue
. Давайте взглянем, как это работает:
|
|
▶️ https://goplay.tools/snippet/ZT44wzp9XXp
Обратите внимание, что метод Value
возвращает значение типа interface{}
, поэтому скорее всего вам будет необходимо привести его к нужному типу. Кроме того, если ключ не представлен в контексте, метод вернёт nil
.
Когда стоит передавать данные через контекст?
Короткий ответ — никогда. Передача данных через контекст является антипаттерном, поскольку это порождает неявный контракт между компонентами вашего приложения, к тому же ещё и ненадёжный. Исключение составляют случаи, когда вам нужно предоставить компоненту из внешней библиотеку вашу реализацию интерфейса, который вы не можете менять. Например, middleware в HTTP сервере.
Пример. HTTP Middleware
Представьте, что вы хотите, чтобы ваш API принимал запросы только от аутентифицированных клиентов. Однако вызывать методы для аутентификации в каждом обработчике не кажется удачной идеей. Но вы можете сделать так, чтобы перед тем как вызовется обработчик запроса, вызвался метод, который проведёт аутентификацию, и либо вызовет следующий метод в цепочке (в данном случае обработчик), либо вернёт HTTP с ошибкой аутентификации. Это и есть пример классического middleware.
Вот как это может выглядеть:
|
|
Когда использовать контекст?
- Метод ходит куда-то по сети;
- Горутина исполняется потенциально «долго».
Если у вас есть сомнения насчёт того, соответствует ли функция одному из этих критериев, то лучше всё-таки добавить контекст. Это не усложнит вам жизнь, но потенциально упростит её в будущем. Особенно это касается объявляемых вами интерфейсов — внутри реализации может происходить всё что угодно, в том числе сетевые вызовы и долгие операции.
Советы и лучшие практики
- Передавайте контекст всегда первым аргументом — это общепринятое соглашение;
- Передавайте контекст только в функции и методы, не храните в состоянии (внутри структуры). Контексты спроектированы так, чтобы их использовали как одноразовые и неизменяемые объекты. Например, если вы сохраните контекст с таймутом в 15 секунд в поле структуры, а спустя 15 секунд попробуете выполнить операцию с данным контекстом, у вас ничего не получится. Обнулить счётчик таймаута вы тоже не сможете;
- Используйте
context.WithValue
только в крайних случаях. В 99,(9)% случаев вы сможете передать данные через аргументы функции; context.Background
должен использоваться только как самый верхний родительский контекст, поскольку он является заглушкой и не предоставляет средств контроля;- Используйте
context.TODO
, если пока не уверены, какой контекст нужно использовать; - Не забывайте вызывать функцию отмены контекста, т.к. функции, принимающей контекст может потребоваться время на завершение перед выходом;
- Передавайте только контекст, без функции отмены. Контроль за завершением контекста должен оставаться на вызывающей стороне, иначе логика приложения может стать очень запутанной.
Полезные ссылки
- pkg.go.dev / Документация к пакету
context
- The Go Blog / Больше примеров от авторов языка
- Go by Example / Раздел о контекстах