четверг, 25 марта 2021 г.

Контексты и структуры в Golang

Во многих API Go, особенно современных, первым аргументом функций и методов часто является context.Context. Контекст предоставляет средства передачи крайних сроков, отмены вызывающего абонента и других значений области запроса через границы API и между процессами. Он часто используется, когда библиотека напрямую или транзитивно взаимодействует с удаленными серверами, такими как базы данных, API-интерфейсы и т. д.

В документации для контекста указано:

Контексты не должны храниться внутри типа структуры, а вместо этого передаваться каждой функции, которая в этом нуждается.

Этот пост дополняет этот совет причинами и примерами, описывающими, почему важно передавать Context, а не сохранять его в другом типе. Он также подчеркивает редкий случай, когда хранение Context в типе структуры может иметь смысл, и как это сделать безопасно.

Предпочитать контексты, передаваемые в качестве аргументов

Чтобы понять совет не хранить контекст в структурах, давайте рассмотрим предпочтительный подход контекста как аргумента:

type Worker struct { /* … */ }

type Work struct { /* … */ }

func New() *Worker {
  return &Worker{}
}

func (w *Worker) Fetch(ctx context.Context) (*Work, error) {
  _ = ctx 
  // ctx для каждого вызова используется для отмены, 
  // крайних сроков и метаданных.
}

func (w *Worker) Process(ctx context.Context, work *Work) error {
  _ = ctx
  // ctx для каждого вызова используется для отмены, 
  // крайних сроков и метаданных.
}

Здесь методы (*Worker).Fetch и (*Worker).Process принимают контекст напрямую. Благодаря этой конструкции передачи в качестве аргумента пользователи могут устанавливать для каждого вызова крайние сроки, отмену и метаданные. И ясно, как будет использоваться context.Context, переданный каждому методу: нет никаких ожиданий, что context.Context, переданный одному методу, будет использоваться любым другим методом. Это связано с тем, что контекст ограничен настолько малой операцией, насколько это необходимо, что значительно увеличивает полезность и ясность контекста в этом пакете.

Хранение контекста в структурах приводит к путанице

Давайте еще раз проверим приведенный выше пример Worker с нежелательным подходом контекст-в-структуре. Проблема в том, что когда вы сохраняете контекст в структуре, вы скрываете время жизни для вызывающих или, что еще хуже, смешиваете две области вместе непредсказуемым образом:

type Worker struct {
  ctx context.Context
}

func New(ctx context.Context) *Worker {
  return &Worker{ctx: ctx}
}

func (w *Worker) Fetch() (*Work, error) {
  _ = w.ctx
  // Общий w.ctx используется для отмены, 
  // крайних сроков и метаданных.
}

func (w *Worker) Process(work *Work) error {
  _ = w.ctx 
  // Общий w.ctx используется для отмены, 
  // крайних сроков и метаданных.
}

Оба метода (*Worker).Fetch и (*Worker).Process используют контекст, хранящийся в Worker. Это не позволяет вызывающим объектам Fetch и Process (которые сами могут иметь разные контексты) указывать крайний срок, запрашивать отмену и прикреплять метаданные для каждого вызова. Например: пользователь не может указать крайний срок только для (*Worker).Fetch или отменить только вызов (*Worker).Process. Время жизни вызывающей стороны смешано с общим контекстом, а контекст ограничен временем жизни, в котором создается Worker.

API также намного больше сбивает пользователей с толку по сравнению с подходом с передачей в качестве аргумента. Пользователи могут спросить себя:

  • Поскольку New принимает context.Context, выполняет ли конструктор работу, требующую отмены или крайних сроков?
  • Применяется ли context.Context, переданный в New, для работы в (*Worker).Fetch и (*Worker).Process? Ни один? Один, а не другой?

API потребуется много документации, чтобы точно сообщить пользователю, для чего используется context.Context. Пользователю, возможно, также придется читать код вместо того, чтобы полагаться на структуру передаваемого API.

И, наконец, может быть довольно опасно проектировать сервер производственного уровня, запросы которого не имеют контекста и, следовательно, не могут должным образом учитывать отмену. Без возможности устанавливать крайние сроки для каждого вызова ваш процесс может исчерпать свои ресурсы (например, память)!

Исключение из правила: сохранение обратной совместимости

Когда был выпущен Go 1.7, который представил context.Context, большому количеству API пришлось добавить поддержку контекста обратно совместимыми способами. Например, методы Client net/http, такие как Get и Do, были отличными кандидатами в контекст. Каждый внешний запрос, отправленный с помощью этих методов, выиграет от наличия крайнего срока, отмены и поддержки метаданных, которые поставляются с context.Context.

Существует два подхода к добавлению поддержки context.Context обратно совместимыми способами: включение контекста в структуру, как мы вскоре увидим, и дублирование функций с дублированием, принимающим context.Context и имеющим Context в качестве суффикса имени функции. Подход с дублированием должен быть предпочтительнее контекста-в-структуре и более подробно обсуждается в посте Обеспечение совместимости ваших модулей. Однако в некоторых случаях это непрактично: например, если ваш API предоставляет большое количество функций, то дублирование их всех может оказаться невозможным.

Пакет net/http выбрал подход контекст-в-структуре, который представляет собой полезный пример. Давайте посмотрим на Do. До введения context.Context Do определялся следующим образом:

func (c *Client) Do(req *Request) (*Response, error)

После Go 1.7 Do мог бы выглядеть следующим образом, если бы не тот факт, что это нарушило бы обратную совместимость:

func (c *Client) Do(ctx context.Context, req *Request) (*Response, error)

Но сохранение обратной совместимости и соблюдение обещания совместимости Go 1 имеет решающее значение для стандартной библиотеки. Поэтому вместо этого разработчики решили добавить context.Context в структуру http.Request, чтобы разрешить поддержку context.Context без нарушения обратной совместимости:

type Request struct {
  ctx context.Context

  // ...
}

func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
  // Упрощено для краткости.
  return &Request{
    ctx: ctx,
    // ...
  }
}

func (c *Client) Do(req *Request) (*Response, error)

При модернизации вашего API для поддержки контекста может иметь смысл добавить context.Context в структуру, как указано выше. Однако не забудьте сначала подумать о дублировании ваших функций, что позволяет модифицировать context.Context для обеспечения обратной совместимости без ущерба для полезности и понимания. Например:

func (c *Client) Call() error {
  return c.CallContext(context.Background())
}

func (c *Client) CallContext(ctx context.Context) error {
  // ...
}

Вывод

Контекст упрощает передачу важной информации между библиотеками и API вниз по стеку вызовов. Но он должен использоваться последовательно и четко, чтобы оставаться понятным, легко поддающимся отладке и эффективным.

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

При разработке API с контекстом помните совет: передайте context.Context в качестве аргумента; не храните его в структурах.


Читайте также:


Комментариев нет:

Отправить комментарий