GolangのContext
Golang
Lastmod: 2020-10-19

Web アプリケーションでよく使う Context についてまとめた。

Golang における Context とは

大事なことは全部 Document に書いてある。おしまい。

context - The Go Programming Language

・・・と言ってしまうと元も子もないので、自分なりに整理してみる。

Go の Context を要約すると、

Web アプリケーションで横断的に使用するリクエストスコープ変数や処理を取り回す役割を持つもの

といえる。例えば、

  • 認証トークンやタイムアウト時間などの、リクエストごとに固有の変数
  • タイムアウトやリクエストキャンセルなどの、アプリケーション横断的処理

が Context 内で管理される。

Go の net/http ライブラリによって起動する Web サーバーは、ひとつのリクエストにひとつのゴルーチンを割り当てて処理を開始する。リクエストを受けて実行される処理の中で、ゴルーチンは更に増えていく。Context はそのように増えていくゴルーチン間で容易に値や処理を共有できる仕組みとして提供される。

Context=文脈といった意味だが、それこそアプリケーションの文脈では「あるスコープ内で横断的に共有されるモノ」といった意味合いで使用される。Go の Context も「1 リクエスト内で共有されるデータ・処理」といった意味合いで扱われている気がする。

Context の中身

Context は以下のインタフェースによって定義されている。

type Context interface {
    Done() <-chan struct{}

    Err() error

    Deadline() (deadline time.Time, ok bool)

    Value(key interface{}) interface{}
}

Done()

キャンセルかタイムアウトを検知できるチャネルを返す。このチャネルは Context に対してキャンセルが通知されたか、タイムアウトが通知されたタイミングで閉じる。キャンセルは後述する WithCancel() WithDeadline() WithTimeout() から返る関数を呼び出すと通知できる。タイムアウトは後述する WithDeadline() WithTimeout() 関数で Context に設定できる。

Context を使う側は、この Done() から返るチャネルを経由してリクエストの状態を知ることになる。

Err()

Context がキャンセルされた理由を含む error 値が返る。
キャンセルされない間、 Err() は nil を返す。

Deadline()

Context がタイムアウトになるまでの時間と、Deadline が設定されているかどうかを返す。

Value(key interface{})

Context が保持する、 keyに紐づいた値を返す。
Value() が返すべき値の管理はそれぞれの具象 Context 構造体にて定義される。


Context 単体だけではタイムアウトやキャンセルの通知はできない。Context は上記インタフェースの具象値だけでなく、後述する context パッケージ内関数と組み合わせて使う。

Context のルール

  • 保持する変数はリクエストスコープである Value() で習得できる値は、リクエストスコープ内で完結する必要がある。すなわち、複数リクエストから参照可能な値であってはならない。

  • リクエストスコープはゴルーチン安全である リクエストスコープの値は、複数ゴルーチンから同時に参照されても安全、すなわちゴルーチンセーフ(?)である必要がある。

Context の仕組み

Context Tree

Context を使う場合、単一の Context だけをずっと使い回すわけではない。Context を使うアプリ内では、最初に生成した Context から次々と子 Context を派生させ、一つの Context Tree を築いていく。

context パッケージには、 Context から子コンテキストを派生する関数が用意されている。

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

func WithValue(parent Context, key, val interface{}) Context

これらの関数は引数の Context parent から子 Context を派生させる。また同時に、リクエストがキャンセルされたことを通知するための関数も返す。このキャンセル関数を呼び出すと、生成された Context とその 子 Context にキャンセルが通知される。

キャンセルが Context に通知されると、Context が持つ Done() チャネルが閉じ、 Err() が error 値を返すようになる。返す error 値は関数生成元の関数による。

「子コンテキストの派生」「キャンセルの通知関数の生成」、そして子コンテキストへの性質の付与を同時に行う理由は、アプリケーションの境界によって渡す値や伝播するイベントを制御しやすくするため。例えば、データアクセス層へ渡す Context を WithTimeout() で派生した子 Context にすることで、キャンセルイベントの伝播をデータアクセス層に閉じることができる。親 Context へはイベントは伝播しないため、上位層の Context について考えなくても良くなる。

Context を仕組みを助ける関数たち

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

引数に取る Context から新しい子 Context を生成・返戻し、加えてキャンセル関数を返す。 キャンセル関数は呼びだすと ctx とその子 Context すべてにキャンセルが通知され、各 Context の Done() チャネルが閉じ、 Err() で error 値が得られるようになる。

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

基本的には WithCancel と同じ。異なる点は、指定した d の時刻を経過したかどうかが Deadline() から得られるようになるところ。

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

基本的には WithDeadline と同じ。異なる点は、Deadline を現在時刻からの経過時間で指定する点( WithDeadline(parent, time.Now().Add(timeout)) )。

func WithValue(parent Context, key, val interface{}) Context

生成する子 Context に値を設定する関数。子 Context は新たなリクエストスコープ値を持つことになる。

Context のつかいかた

実際に Context を使ってみた。下記コードの全体はGithub 上に置いてある

Context の生成

下記コードでは、 Product 構造体配列を永続化層から取得して、標準出力へ書き出す処理を行っている。

func main() {
	r := repository.ProductRepositoryImpl{}
	s := service.ProductService{Repo: &r}

	ctx := context.Background()

	prods, err := s.GetProducts(ctx)
	if err != nil {
		panic(err)
	}

	fmt.Println(prods)
}
type Product struct {
	Name  string
	Price int
}

Context の生成は context.Background() で行う。生成した Context をアプリ内で呼び出す関数に渡していく。必要であれば、 context パッケージ上の関数で子 Context に派生したり、タイムアウトを設けたり、新たなリクエストスコープ値を作ったりする。

type ProductRepositoryImpl struct{}

// ProductService::GetProducts()が呼び出す、
// ProductRepository::GetProducts()関数の実装
func (r *ProductRepositoryImpl) GetProducts(ctx context.Context) ([]entity.Product, error) {

	c := make(chan []entity.Product, 1)

	childCtx, cancel := context.WithTimeout(ctx, time.Second*5)
	defer cancel()

	go func(ctx context.Context) { c <- r.FindProducts(ctx) }(childCtx)

	select {
	case <-childCtx.Done():
		return nil, errors.New("query canceled")
	case prods := <-c:
		return prods, nil
	}

}

上記関数では、引数で受け取った Context からタイムアウトつき子 Context を派生して使用している。この Context から得られる Done() チャネルを監視することで、キャンセルあるいはタイムアウトしたかどうかを検知することができる。

Context を運用する上で気をつけること

1. フレームワーク独自の Context との共用を避ける

フレームワークが独自に実装している Context でも標準パッケージ context とほぼ同様のことができるが、そのような Context で Wrap したりすると、フレームワークに強く依存した実装になってしまう。

2. Value は不変とする

Context が保持するリクエストスコープ値は、複数ゴルーチンから参照されても安全でなければならない

3.Context を他の構造体フィールドにしない

2. より Context の保持する値はリクエストスコープで完結させたほうがよい。Context を別の構造体のフィールドとすると、その構造体の扱い次第ではアプリケーションスコープになりえるため、特に深刻な理由がなければ構造体フィールド化を避ける。

RootContext はひとつ

リクエストスコープ変数・シグナルを管理する機構が 2 つになるため、Context Tree が 2 つ以上になる状況は厳しい。

Context は nil で渡さない

関数で渡される Context は非 nil とする。どうしても渡す Context がない時のために、そのことを明示する Context を生成する context.Todo() があるので、それを利用する。

参考文献