
Данная статья - это вырезка из книги Джона Боднера под названием "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. Копируйте значения из контекста в явные параметры, если они требуются бизнес-логике. Служебная системная информация может извлекаться прямо из контекста.