Пользователь
12,0
рейтинг
22 октября 2015 в 10:38

Разработка → Изучаем net/context в Go tutorial

Не секрет, что основная ниша использования 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-запросами. Давайте разберем это на примерах.

Пример


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

package main

import (
	"net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Hello World"))
}

func main() {
	http.HandleFunc("/", handler)
	http.ListenAndServe(":1234", nil)
}

$ curl http://localhost:1234
Hello World


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

func needPin(h http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if r.FormValue("pin") != "9999" {
			http.Error(w, "wrong pin", http.StatusForbidden)
			return
		}
		h(w, r)
	}
}
...
http.HandleFunc("/", needPin(handler))
...

$ curl http://localhost:1234
wrong pin
$ curl http://localhost:1234?pin=9999
Hello World

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

func needPin(h http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var pin string
		if err := db.QueryRow("SELECT pin FROM pins").Scan(&pin); err != nil {
			http.Error(w, "database error", http.StatusInternalServerError)
			return
		}
		...

Код целиком
$ sqlite3 pins.db
SQLite version 3.8.4.3 2014-04-03 16:53:12
Enter ".help" for usage hints.
sqlite> CREATE TABLE pins(pin STRING);
sqlite> INSERT INTO pins(pin) VALUES ("9999");
sqlite> ^D

package main

import (
	"database/sql"
	_ "github.com/mattn/go-sqlite3"
	"net/http"
)

var (
	db  *sql.DB
	err error
)

func handler(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Hello World"))
}

func needPin(h http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var pin string
		if err := db.QueryRow("SELECT pin FROM pins").Scan(&pin); err != nil {
			http.Error(w, "database error", http.StatusInternalServerError)
			return
		}
		if r.FormValue("pin") != pin {
			http.Error(w, "wrong pin", http.StatusForbidden)
			return
		}
		h(w, r)
	}
}

func main() {
	if db, err = sql.Open("sqlite3", "./pins.db"); err != nil {
		panic(err)
	}
	defer db.Close()
	http.HandleFunc("/", needPin(handler))
	http.ListenAndServe(":1234", nil)
}


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

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

«Конечно можно», думаете вы и пишете для каждого нового таска свой middleware, начиная их собирать в цепочки. Есть даже отдельные пакаджи для этого, например Alice. Но как передать ошибку авторизации из authMiddleware в logMiddleware? А если нужно ещё бегать в Vault за временными паролями, извлекая сначала токен из сессии? А как избавиться от глобальных переменных и передавать объект соединения с базой хендлеру?

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

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

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

net/context изнутри


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

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

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

func Background() Context
func TODO() Context
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key interface{}, val interface{}) Context

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

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

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

  • context.Background() — это пустой контекст, у него нет значений, нет таймаута или возможности отмены; как правило Background() используется в функции, первой принимающей входящий запрос и является основой для всех последующих производных запросов.
    Вот пример из camilstore:
    client := oauth2.NewClient(context.Background(), google.ComputeTokenSource(""))
    
  • context.TODO() — это специальный контекст, тоже пустой, но использующийся тогда, когда неясно какой контекст использовать, или функция ещё не была отрефакторена, чтобы принимать контекст. Имя TODO было выбрано специально, чтобы статические анализаторы кода могли легко находить этот случай. На данный момент, впрочем, линтера, который анализирует контексты еще нет в открытых исходниках, но есть в Google и будет открыт в будущем.
  • context.WithCancel(parent Context) (ctx Context, cancel CancelFunc) — возвращает копию контекста parent с новым каналом Done и функцию CancelFunc, которая инициирует закрытие этого канала.
    Давайте посмотрим, как это работает:
    func handler(w http.ResponseWriter, r *http.Request) {
        // создаем новый контекст с отменой
        ctx, cancel = context.WithCancel(context.Background())
        defer cancel()
    
        // ждем одну секунду и посылаем пустую структуру в Done, чтобы закрыть контекст
        go func() {
            time.Sleep(1 * time.Second)
            ctx.Done <- struct{}{}
        }()
       
        // запускаем потенциально долгий запрос
        res, err:= startLongQuery(ctx, w)
        if err != nil {
            http.Error(w, "cancelled", http.StatusInternalError)
            return
        }
        // ...encode res to json
        w.Write(encodedResult)
    }
    
    func startLongQuery(ctx context.Context, w http.ResponseWriter) (*Result, error) {
        resCh, transport := runLongQueryToJavaService(someArgs)
        select {
        case <-ctx.Done():
             // дожидаемся отмены запроса и возвращаем ошибку
            transport.CancelRequest(req)
            <-resCh
            return nil, ctx.Err()
        case result := <-resCh:
             // получили ответ раньше, чем закрылся Done, все ок
             return Result{result}, nil
        }
    }
    

    Этот пример нарочито упрощенный, и как использовать контекст для таймаутов мы рассмотрим чуть ниже, но идея должна быть понятна.
    Принудительный Cancel может быть полезен, скажем, в случае одновременного запроса к разным репликам сервиса — после ответа самой быстрой реплики, запросы к остальным можно смело отменять, чтобы не использовались зря ресурсы.
  • context.WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) и context.WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) — аналогично WithCancel(), но сами закрывают Done после истечения таймаута или достижения дедлайна. Собственно, WithTimeout — это WithDeadline с параметром time.Now().Add(timeout).

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

        timeout, err := time.ParseDuration(req.FormValue("timeout"))
        if err == nil {
            ctx, cancel = context.WithTimeout(context.Background(), timeout)
        }
    

    чтобы указать сколько ждать долгий запрос.
  • context.WithValue(parent Context, key interface{}, val interface{}) Context — служит для описанной чуть выше задачи хранения данных или ресурсов, связанных с контекстом

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


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

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

package userIP
// тип, который будет использоваться в качестве ключа
type key int
// ключ для IP адреса. Если этот пакет будет класть в контекст еще какие-то ключи, это будут другие константы.
const userIPKey key = 0

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

type valueCtx struct {
	Context
	key, val interface{}
}

// функция получения Value из контекста
func (c *valueCtx) Value(key interface{}) interface{} {
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}

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

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

полный код
// Package userip provides functions for extracting a user IP address from a
// request and associating it with a Context.
package userip

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

	"golang.org/x/net/context"
)

// FromRequest extracts the user IP address from req, if present.
func FromRequest(req *http.Request) (net.IP, error) {
	ip, _, err := net.SplitHostPort(req.RemoteAddr)
	if err != nil {
		return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
	}

	userIP := net.ParseIP(ip)
	if userIP == nil {
		return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
	}
	return userIP, nil
}

// The key type is unexported to prevent collisions with context keys defined in
// other packages.
type key int

// userIPkey is the context key for the user IP address.  Its value of zero is
// arbitrary.  If this package defined other context keys, they would have
// different integer values.
const userIPKey key = 0

// NewContext returns a new Context carrying userIP.
func NewContext(ctx context.Context, userIP net.IP) context.Context {
	return context.WithValue(ctx, userIPKey, userIP)
}

// FromContext extracts the user IP address from ctx, if present.
func FromContext(ctx context.Context) (net.IP, bool) {
	// ctx.Value returns nil if ctx has no value for the key;
	// the net.IP type assertion returns ok=false for nil.
	userIP, ok := ctx.Value(userIPKey).(net.IP)
	return userIP, ok
}


Что и как вы будете класть в контекст — зависит от конкретной задачи. Это может быть как простой 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, скорее всего пропадет.

Ссылки


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

Хорошая статья о контекстах в блоге Go — blog.golang.org/context
Доклад на GothamGo 2014 про контексты — vimeo.com/115309491
Ещё одна статья о http.Handler и net/context — joeshaw.org/net-context-and-http-handler
Детальные слайды неплохого доклада на тему net/context — go-talks.appspot.com/github.com/guregu/slides/kami/kami.slide#1
Статья о продвинутых пайплайнах и отменах в Go — blog.golang.org/pipelines
Обзор реализаций контекста в различных фреймворках — www.nicolasmerouze.com/share-values-between-middlewares-context-golang
Исходный код net/context — github.com/golang/net/tree/master/context
divan0 @divan0
карма
132,0
рейтинг 12,0
Пользователь
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

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

  • +1
    Контекст это супер, а кто что использует для хранения и шаринга единого контекста между несколькими отдельными микро-сервисами в рамках единого приложения? Также как решаете вопрос единого логирования из всех микро-сервисов с привязыванием к единому контексту?
    • 0
      Например, gologin активно использует контексты.
      github.com/dghubble/gologin/blob/master/facebook/context.go
    • 0
      Можно прокидывать между сервисами uid запроса или объекта который нужно логировать. В логи пишется только uid плюс контекст текущего сервиса. Далее с помощью grep\logstash\graylog\etc запросы фильтруются и группируются по uid.
    • 0
      Посмотрите appdash от Sourcegraph. Сам ещё не использовал, но выглядит неплохо: sourcegraph.com/blog/117580140734/announcing-appdash-an-open-source-perf-tracing
  • 0
    для хранения и шаринга единого контекста между несколькими отдельными микро-сервисами в рамках единого приложения

    Зависит от задачи

    единого логирования из всех микро-сервисов с привязыванием к единому контексту

    ElasticSearch + Logstash + Kibana
    • 0
      > ElasticSearch + Logstash + Kibana
      Это хороший вариант, но без первой задачи, собирать в единое целое собрать сложно.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.