Изучаем net/context в Go

Не секрет, что основная ниша использования Go это сетевые сервисы: всевозможные серверы, бекенды, микросервисы, распределенные базы данных и файловые хранилища. Такой класс программ очень активно использует сетевые запросы, весь необходимый функционал для которых есть в стандартной библиотеке, но один аспект разработки сетевых архитектур остается для многих темным пятном — контексты запросов. В этой статье я хочу рассмотреть этот аспект повнимательней и показать, какой это мощный и важный инструмент.

Что такое контекст?

Начнём с главного вопроса — что это вообще за понятие такое, «контекст»? Контекст, в данном, простите за тавтологию, контексте — это некоторая информация об объекте, которая передается между границами функций API. Под объектом обычно подразумевается сетевой запрос, а границами API — различные middleware, разные пакаджи и слои абстракций, но само понятие «контекста» не специфично только лишь для сетевых запросов. Но в данной статье речь будет преимущественно о них.

Типичный пример — извлечь из JWT-токена данные о пользователе и передать дальше в обработчики эту информацию для проверки доступа, записи в лог и так далее.

Эта концепция контекста, как минимум для сетевых запросов, есть во многих языках и фреймворках — в C# это HttpContext, в Java-вовском Netty — ChannelHandlerContext, в питонвском Twisted — twisted.python.context и так далее.

Контексты в Go

В стандартной библиотеке Go есть отличный HTTP стек, который позволяет очень быстро создавать многопоточные сервера, не боясь «проблемы 10К соединений», и легко реализовывать самые разнообразные сценарии обработки запросов с помощью интерфейса Handler. Но понятия контекста для http-хендлеров в стандартной библиотеке нет.

Но у Go, помимо основной стандартной библиотеки, есть от авторов Go и отдельная группа пакетов, которые разрабатываются вне основного кода Go, и не имеют жесткого обещания обратной совместимости, как в стандартной библиотеке. Это все пакеты, которые лежат в golang/x/. Среди них достаточно давно есть пакет net/context, реализующий ту самую сущность контекстов и о котором мы сегодня и поговорим. На момент написания статьи, этот пакет уже используется, согласно GoDoc, в 1560 пакетах.

Отчасти отсутствие контекста в стандартной библиотеке было причиной появления целого зоопарка веб-фреймворков, каждый из которых решал вопрос передачи контекста запроса по-своему.

Зоопарк фреймворков

Используя слово «зоопарк» я немного утрирую, поскольку проблемы особой нет. Стандартный net/http хорош как фундамент, но как-только вы начинаете писать какой-нибудь веб-сервис, вы рано или поздно приходите к необходимости реализовывать более продвинутый функционал — сложный роутинг с группировкой, продвинутое логгирование, обработку авторизации и разграничения доступа и так далее, и это естественным образом превращается в некий фреймворк — многим наверняка такой же функционал будет нужен и такой же подход будет по душе. Но в целом, обычно есть пара наиболее популярных фреймворков, и они время от времени меняются, соревнуясь в zero-memory allocations и скорости. Сейчас, пальма первенства по популярности, вроде как у gin-gonic.

Каждый из фреймворков подходил к проблеме передачи контекстов по-своему. GorillaToolkit держит глобальный мап значений для каждого запроса, охраняя запросы к нему мьютексами. Goji и другие хранят отдельный мап для каждого запроса. gocraft/web работает с контекстами через reflection. У вышеупомянутого Gin — Context вообще ключевая структура, с которой работают все хендлеры. В echo на понятие Context вообще навесили все функции обработки запроса.

Каждый из этих подходов может иметь свои плюсы и минусы, но у всех у них один минус — привязка к фреймворку. Как уже упоминалось выше, контекст есть понятие достаточно абстрактное и не ограниченное http-запросами. Давайте разберем это на примерах.

Пример

Начнём с простого веб-сервиса:

Но тут мы захотели примитивную авторизацию по пин-коду, создадим простой middleware с помощью http.HandlerFunc:

Отлично, но теперь мы хотим пин читать из SQL-базы, а не хардкодить. Окей, пока что добавим глобальную переменную *sql.DB:

И дальше список наших требований и желаний начинает расти:

  • а можно каждый запрос с неправильным пин-кодом логгировать в syslog?
  • а можно ещё проверять и записывать IP адрес?
  • а можно для разных IP делать шардинг и бегать в разные базы?
  • а можно .

Вот тут и приходит на помощь понятие контекста. Каждая middleware-функция устанавливает свои значения в переменную контекста, и передает запрос и контекст дальше. В этот контекст вы вольны засовывать всё что угодно — статус авторизации, имя пользователя, разрешения доступа, информацию вытащенную из заголовков начального запроса, указатель на соединение с базой данных, уникальный ID для дальнейшего трекинга и так далее.

Обычно context.Context передается как параметр функции (как middleware, так и любой функции, работающий с запросом). В принципе, можно Context сохранять, как поле структуры, но это нужно делать осторожно, так как Context должен быть привязан к запросу — создаваться на каждый запрос и удаляться после завершения обработки запроса.

Но net/context это не только о хранении значений, это и унифицированный подход к управлению таймаутами и отменой запроса. Давайте посмотрим поподробнее.

net/context изнутри

API работы с net/context немного необычное, поэтому будьте готовы к удивлению вначале.

Тут важно понимать ещё, как обычно в Go создаются сложные пайплайны обработки запроса — обычно это горутина, принимающая один запрос, которая порождает одну или больше горутин, обрабатывающую этот запрос или поток запросов, которые, в свою очередь, возвращают результат наверх. Это может быть как простые синхронные вызовы функций, возможно, разнесенных в отдельные пакаджи, так и целые каскады новых горутин и каналов, передающих каналы. Главное то, что есть четкие границы ответственности каждого middleware, каждого пакаджа, и каждый из них что-то хочет знать о запросе, и что-то хочет над ним сделать.

Поэтому первый и главный принцип работы net/context заключается в том, что контексты являются вложенными и имеют древодвидную структуру. Собственно, основные функции пакета net/context и занимаются тем. что создают новые вариации контекста из уже существующего, порождают новый «подконтекст»:

Второй важный момент — это то, что context.Context является интерфейсом, и пакет net/context предоставляет лишь несколько вариаций, но вы вольны создавать свои контексты какой угодно сложности.

Давайте разберемя с основными видами существующих контекстов:

  • context.Background() — это пустой контекст, у него нет значений, нет таймаута или возможности отмены; как правило Background() используется в функции, первой принимающей входящий запрос и является основой для всех последующих производных запросов.
    Вот пример из camilstore:
  • context.TODO() — это специальный контекст, тоже пустой, но использующийся тогда, когда неясно какой контекст использовать, или функция ещё не была отрефакторена, чтобы принимать контекст. Имя TODO было выбрано специально, чтобы статические анализаторы кода могли легко находить этот случай. На данный момент, впрочем, линтера, который анализирует контексты еще нет в открытых исходниках, но есть в Google и будет открыт в будущем.
  • context.WithCancel(parent Context) (ctx Context, cancel CancelFunc) — возвращает копию контекста parent с новым каналом Done и функцию CancelFunc, которая инициирует закрытие этого канала.
    Давайте посмотрим, как это работает:

Если вы поняли, как работает WithCancel, то с этими двумя функциями проблем не должно быть. Пример выше упростится на пару строчек, и можно делать например вот так:

Чуть подробнее про context.WithValue

В отличие от фреймворков, упоминавшихся выше net/context не использует мапы или какие-либо подобные структуры данных по причине своей древовидной вложенной архитектуры. Один контекст несет одно значение плюс родительский контекст. Новое значение — это уже будет новый контекст. Как ключ так и само значение могут быть любого типа.

Стандартный механизм использования этого следующий — ключ должен быть неэкспортируемым типом, чтобы избежать коллизий с другими пакетами/API, которые могут работать с контекстом. Например:

Сам же контекст со значением выглядит следующим образом (https://github.com/golang/net/blob/master/context/context.go#L433):

Как видите, при любой вложенности контекста, Value() будет подниматься вверх по дереву контекста, пока не встретит нужное значение нужного типа. Ну, или вернет nil, поскольку Value() определен для всех контекстов (Context, как вы помните, это интерфейс, а, следовательно, определен и для Background-контекста тоже).

Полный код примера пакета, работающим со значениями контекста может быть примерно таким:

Что и как вы будете класть в контекст — зависит от конкретной задачи. Это может быть как простой ID пользователя, так сложная структура с массой информации внутри, так и объект вроде sql.DB для работы с базой данных.

Плюшки

Реализация контекста в виде универсального интерфейса позволяет дружить код, который использует контексты из других фреймворков с кодом, использующим net/context.

Вот пример контекста, использущего gorilla/context: blog.golang.org/context/gorilla/gorilla.go

А вот пример пример работы с отменой запроса в другом фреймворке, tomb: blog.golang.org/context/tomb/tomb.go

Или пакет net/trace, как пример использования контекста для трейсинга жизни запроса в стиле Dapper. Родительский контекст порождает context.WithValue(ctx, trace) и все последующие вызовы и контексты, которые будут порождены в процессе обработки запроса — будут содержать ID трейса, а уже код net/trace содержит нужные хендлеры, которые предоставляют информацию о трейсах на веб странице по пути /debug/requests и /debug/events.

Очевидно, что наибольшая выгода будет, если весь код, который общается с внешними ресурсами или порождает новые горутины, будет использовать net/context. К примеру, кодовая база Google на Go, которая насчитывает уже около 10+млн строк кода, везде использует context.Context. Новый RPC-фреймворк gRPC на Protobuf3, когда генерирует код для Go, также везде передает context.Context.

Планы на будущее

Вот тут идёт активное обсуждение будущих планов net/context, и, вполне вероятно, что context появится в стандартной библиотеке в Go 1.7. Возможно с небольшими изменениями, возможно без, но, в любом случае, интерес и желание есть, поэтому стоит держать руку на пульсе.

Стандартная библиотека, само собой, будет обратно совместима, и вещей вроде разделения на http и ctxhttp не будет, чтобы не фрагментировать кодовую базу (хотя пакет ctxhttp сейчас, в качестве эксперимента существует). Возможно в http.Request добавится поле Context, а возможно, придут к какому-нибудь ещё варианту.

Слово net из net/context, скорее всего пропадет.

Ссылки

Если хотите более подробно разобраться и посмотреть примеры, очень рекомендую следующие ссылки:


Источник: habr.com