Данная статья - это вырезка из книги Джона Боднера под названием "Go идиомы и паттерны проектирования". На момент чтения 14-й главы, посвященной теме контекста, мне показался полезным её подраздел про работу со значениями посредством контекста. Полезным в том смысле, что этот подраздел вполне может служить справкой для новичков сам по себе, взятый автономно из содержащей его книги. Справкой по конкретному вопросу чтения и записи значений из контекста, разумеется, а не обозревающей тему контекста целиком. Помимо освещения API работы с контекстом для хранения значений, Боднер приводит и объяснение, в каких случаях это может быть уместно.

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


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

Однако, иногда невозможно передать данные явно. Типичный пример - обработчик HTTP-запросов и ассоциированный с ним промежуточный слой. Любой обработчик HTTP-запросов имеет два параметра: один для запроса и один для ответа. Чтобы сделать значение доступным для обработчика в промежуточном слое, его нужно сохранить в контексте. Примером такой ситуации может служить извлечение пользователя из веб-токена JSON (JWT, JSON Web Token) или создание для каждого запроса глобального уникального идентификатора, который передается в обработчик и бизнес-логику через несколько промежуточных слоев.

Наряду с фабричными методами для создания контекстов, отменяемых таймаутом или функцией отмены, в пакете context имеется и фабричный метод для записи значений в контекст, context.WithValue. Он принимает три параметра: обертываемый контекст, ключ для извлечения значения и само значение. Параметры для передачи ключа и значения объявлены с типом any. В качестве результата этот метод возвращает дочерний контекст с парой "ключ - значение" и обернутым родительским контекстом context.Context.

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

С помощью метода Value типа context.Context можно проверить наличие значения в контексте или в одном из его родителей. Этот метод принимает ключ и возвращает ассоциированное с ним значение. При этом параметр ключа и возвращаемое значение опять же объявлены с типом any. Если искомый ключ отсутствует, то метод возвращает nil. Чтобы привести возвращаемое значение к подходящему типу, используйте идиому "запятая-ok":

ctx := context.Background()
if myVal, ok := ctx.Value(myKey).(int); !ok {
  fmt.Println("no value")
} else {
  fmt.Println("value:", myVal)
}

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

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

Есть два паттерна, гарантирующие уникальность ключа и поддержку сравнения. Первый заключается в создании нового неэкспортируемого типа для ключа на основе int:

type userKey int

После объявления неэкспортируемого типа объявляется неэкспортируемая константа этого типа:

const (
  _ userKey = iota
  key
)

Так как тип и константа будут неэкспортируемыми, никакой внешний код не сможет записать данные в контекст с тем же ключом и вызвать конфликт. Если вам нужно записать в контекст несколько значений в своем пакете, определите для каждого значения разные ключи одного и того же типа с помощью паттерна iota, который прекрасно подойдет для этого случая, поскольку мы применяем здесь значение константы лишь как способ различить несколько ключей.

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

func ContextWithUser(ctx context.Context, user string) context.Context {
  return context.WithValue(ctx, key, user)
}

func UserFromContext(ctx context.Context) (string, book) {
  user, ok := ctx.Value(key).(string)
  return user, ok
}

Другой вариант - определить неэкспортируемый тип ключа, используя пустую структуру:

type userKey struct{}

И соответственно изменить функции доступа к значению контекста:

func ContextWithUser(ctx context.Context, user string) context.Context {
  return context.WithValue(ctx, userKey{}, user)
}

func UserFromContext(ctx context.Context) (string, bool) {
  user, ok := ctx.Value(userKey{}).(string)
  return user, ok
}

Как правильно выбрать стиль ключа в каждом конкретном случае? Если требуется сохранить в контексте набор связанных ключей с различными значениями, используйте прием на основе int и iota. Если задействуется только один ключ, то подойдет любой из способов. Важно лишь обеспечить невозможность конфликтов ключей в контексте.

Теперь, располагая кодом для управления пользователями, посмотрим, как его можно применить. Напишем промежуточный слой, извлекающий ID пользователя из файла cookie:

// в реальной реализации следует использовать подпись,
// чтобы исключить возможность подделки идентификатора пользователя
func extractUser(req *http.Request) (string, error) {
  userCookie, err := req.Cookie("identity")
  if err != nil {
    return "", err
  }
  return userCookie.Value, nil
}

func Middleware(h http.Handler) http.Handler {
  return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
    user, err := extractUser(req)
    if err != nil {
      rw.WriteHeader(http.StatusUnauthorized)
      rw.Write([]byte("unauthorized"))
      return
    }
    ctx := req.Context()
    ctx = ContextWithUser(ctx, user)
    req = req.WithContext(ctx)
    h.ServeHTTP(rw, req)
  })
}

В промежуточном слое мы сначала получаем значение ID пользователя. Затем извлекаем контекст из запроса с помощью метода Context и создаем новый контекст со значением ID пользователя вызовом функции ContextWithUser. После этого создаем новый запрос на основе старого запроса и нового контекста с помощью метода WithContext. Наконец, вызываем следующую функцию в цепочке обработчиков, передавая ей новый запрос и полученный в качестве параметра экземпляр типа http.ResponseWriter.

В большинстве случаев вы должны извлекать значение из контекста в своем обработчике запросов и явно передавать его в свою бизнес-логику. Функции языка Go позволяют применять явные параметры для этой цели, и вы не должны использовать контекст для неявной передачи значений в обход API:

func (c Controller) DoLogic(rw http.ResponseWriter, req *http.Request) {
  ctx := req.Context()
  user, ok := identity.UserFromContext(ctx)
  if !ok {
    rw.WriteHeader(http.StatusInternalServerError)
    return
  }
  data := req.URL.Query().Get("data")
  result, err := c.Logic.BusinessLogic(ctx, user, data)
  if err != nil {
    rw.WriteHeader(http.StatusInternalServerError)
    rw.Write([]byte(err.Error()))
    return
  }
  rw.Write([]byte(result))
}

Наш обработчик получает контекст, вызывая метод Context экземпляра запроса, извлекает ID пользователя из контекста с помощью функции UserFromContext и вызывает бизнес-логику. Этот код показывает ценность паттерна разделения обязанностей: контроллер не имеет ни малейшего понимания, откуда берется ID пользователя. Такой подход позволяет разместить реальную систему управления пользователями в промежуточном слое и изменять ее без изменения кода контроллера.

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

Вот как выглядит простая реализация глобального уникального идентификатора (GUID) с поддержкой контекста, позволяющая следить за передачей запроса от сервиса к сервису и создавать в журнале записи, содержащие GUID-идентификатор:

package tracker

import (
  "context"
  "fmt"
  "net/http"

  "github.com/google/uuid"
)

type guidKey int

const key guidKey = 1

func contextWithGUID(ctx context.Context, guid string) context.Context {
  return context.WithContext(ctx, key, guid)
}

func guidFromContext(ctx context.Context) (string, bool) {
  g, ok := ctx.Value(key).(string)
  return g, ok
}

func Middleware(h http.Handler) http.Handler {
  return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
    ctx := req.Context()
    if guid := req.Header.Get("X-GUID"); guid != "" {
      ctx = contextWithGUID(ctx, guid)
    } else {
      ctx = contextWithGUID(ctx, uuid.New().String())
    }
    req = req.WithContext(ctx)
    h.ServeHTTP(rw, req)
  })
}

type Logger struct{}

func (Logger) Log(ctx context.Context, message string) {
  if guid, ok := guidFromContext(ctx); ok {
    message = fmt.Sprintf("GUID: %s - %s", guid, message)
  }
  // выполняем журналирование
  fmt.Println(message)
}

func Request(req *http.Request) *http.Request {
  ctx := req.Context()
  if guid, ok := guidFromContext(ctx); ok {
    req.Header.Add("X-GUID", guid)
  }
  return req
}

Функция Middleware либо извлекает GUID-идентификатор из входящего запроса, либо генерирует новый. В обоих случаях она записывает GUID-идентификатор в контекст, создает новый запрос с обновленным контекстом и выполняет следующий вызов в цепочке вызовов.

Далее мы видим, как используется этот GUID-идентификатор. Структура Logger предоставляет универсальный метод журналирования, который принимает в качестве параметров контекст и строку. Если в контексте содержится GUID-идентификатор, он добавляется в начало сообщения журнала, которое затем выводится на экран. Функция Request применяется в том случае, когда данный сервис вызывает другой сервис. Она принимает экземпляр типа http.Request, добавляет заголовок с GUID-идентификатором при его наличии в контексте и возвращает экземпляр типа *http.Request.

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

type Logger interface {
  Log(context.Context, string)
}

type RequestDecorator func(*http.Request) *http.Request

type LogicImpl struct {
  RequestDecorator RequestDecorator
  Logger           Logger
  Remote           string
}

Затем реализуем бизнес-логику:

func (l LogicImpl) Process(ctx context.Context, data string) (string, error) {
  l.Logger.Log(ctx, "starting Process with " + data)
  req, err := http.NewRequestWithContext(ctx, http.MethodGet, l.Remote+"/second?query="+data, nil)
  
  if err != nil {
    l.Logger.Log(ctx, "error building remote request:"+err.Error())
    return "", err
  }
  req = l.RequestDecorator(req)
  resp, err := http.DefaultClient.Do(req)
  // продолжение обработки
}

GUID-идентификатор передается диспетчеру журналирования и декоратору запросов так, чтобы бизнес-логика не знала о его наличии, то есть мы отделяем данные, необходимые для логики программы, от данных, необходимых для управления программой. О том, что мы ассоциируем эти данные, знает только код, выполняющий подключение зависимостей внутри функции main:

controller := Controller{
  Logic: LogicImpl{
    RequestDecorator: tracker.Request,
    Logger:           tracker.Logger{},
    Remote:           "http://localhost:4000"
  }
}

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

Комментарии (0)