Chapter 08

Valueメソッドを有効に使うtips

さき(H.Saki)
さき(H.Saki)
2021.08.29に更新

この章について

前章でも説明した通り、contextへの値付加というのは

  • keyとvalueはcontextを介した時点で全てinterface{}型になる
  • 見方を変えると「引数となりうる値を、contextで隠蔽している」という捉え方にもなる

という点で、扱い方が難しい概念です。

この章では、「contextのvalueを、危うさなしに使うにはどういう設計にしたらいいか」ということについて考察していきたいと思います。

contextに与えるkeyの設定

keyに設定できる値

The provided key must be comparable.
(訳) keyに使用する値は比較可能なものでなくてはなりません。

出典: pkg.go.dev - context.WithValue

これはよくよく考えてもらえば当たり前のことをいってるな、ということがわかると思います。
contextのValue(key)メソッドにて「引数に与えたkeyを内部に持つvalueがないかな」という作業をすることを想像すると、「引数とcontextが持っているkeyは等しいかどうか(=比較可能かどうか)」ということが決定できないといけないのです。

比較可能(comparable)な値の定義については、Goの言語仕様書に明確に定義されています。

  • bool値は比較可能であり、true同士とfalse同士が等しいと判定される
  • 整数値(int, int64など), 浮動小数点値(float32, float64)は比較可能
  • 複素数値は比較可能であり、2つの複素数の実部と虚部が共に等しい場合に等しいと判定される
  • 文字列値は比較可能
  • ポインタ値は比較可能であり、「どちらも同じ変数を指している場合」と「どちらもnilである場合」に等しいと判定される
  • チャネル値は比較可能であり、「どちらも同様のmake文から作られている場合」と「どちらもnilである場合」に等しいと判定される
  • インターフェース値は比較可能であり、「どちらも同じdynamic type・等しいdynamic valueを持つ場合」と「どちらもnilである場合」に等しいと判定される
  • 非インターフェース型の型Xの値xと、インターフェース型Tの値tは、「型Xが比較可能でありかつインターフェースTを実装している場合」に比較可能であり、「tのdynamic typeとdynamic valueがそれぞれXxであった場合」に等しいと判定される
  • 構造体型はすべてのフィールドが比較可能である場合にそれ自身も比較可能となり、それぞれの対応するnon-blankなフィールドの値が等しい場合に2つの構造体値が等しいと判定される
  • 配列型は、その配列の基底型が比較可能である場合にそれ自身も比較可能となり、全ての配列要素が等しい場合に2つの配列値は等しいと判定される

逆に、スライス、マップ、関数値などは比較可能ではない(not comparable)ため、contextのkeyとして使うことはできません。

keyの衝突

contextに与えるkeyについて、注意深く設計していないと「keyの衝突」が起こる可能性があります。

悪い例

状況設定

hogefuga2つのパッケージにて、同じkeyでcontextに値を付加する関数SetValueを用意しました。

// hoge
func SetValue(ctx context.Context) context.Context {
	return context.WithValue(ctx, "a", "b") // hoge pkgの中で("a", "b")というkey-valueを追加
}

// fuga
func SetValue(ctx context.Context) context.Context {
	return context.WithValue(ctx, "a", "c") // fuga pkgの中で("a", "c")というkey-valueを追加
}

そして、main関数内で作ったcontextに、hoge.SetValuefuga.SetValueの順番で値を付加していきます。

import (
	"bad/fuga"
	"bad/hoge"
	"context"
)

func main() {
	ctx := context.Background()

	ctx = hoge.SetValue(ctx)
	ctx = fuga.SetValue(ctx)

	hoge.GetValueFromHoge(ctx) // hoge.SetValueでセットしたkey"a"に対するValue(="b")を見たい
	fuga.GetValueFromFuga(ctx) // fuga.SetValueでセットしたkey"a"に対するValue(="c")を見たい
}

値を付加した後に、それぞれのGetValueFromXXX関数で実際にどんなvalueが格納されているのか確認しています。

func GetValueFromHoge(ctx context.Context) {
	val, ok := ctx.Value("a").(string)
	fmt.Println(val, ok)
}

func GetValueFromFuga(ctx context.Context) {
	val, ok := ctx.Value("a").(string)
	fmt.Println(val, ok)
}

結果

これを実行すると、以下のようになります。

$ go run main.go
c true  // hoge.GetValueFromHoge(ctx)からの出力
c true  // fuga.GetValueFromFuga(ctx)からの出力

hogeパッケージの中でcontextに値"b"を付加していたのに、hoge.GetValueFromHoge関数で確認できたvalueは"c"でした。
これは、hogefugaで同じkey"a"を利用してしまったため、key"a"に対応するvalueは、後からSetしたfugaの方の"c"が使用されてしまうのです。

解決策: パッケージごとに独自の非公開key型を導入

このようなkeyの衝突を避けるために、Goでは「keyとして使用するための独自のkey型」を導入するという手段を公式で推奨しています。
context.WithValue関数の公式ドキュメントにも、以下のような記述があります。

The provided key should not be of type string or any other built-in type to avoid collisions between packages using context.
Users of WithValue should define their own types for keys.

(訳)異なるパッケージ間でcontextを共有したときのkey衝突を避けるために、keyにセットする値にstring型のようなビルトインな型を使うべきではありません。
その代わり、ユーザーはkeyには独自型を定義して使うべきです。

出典: pkg.go.dev - context.WithValue

コード改修

hoge,fugaパッケージの中身を、それぞれ以下のように改修します。

+// hoge

+type ctxKey int
+
+const (
+	a ctxKey = iota
+)

func SetValue(ctx context.Context) context.Context {
-	return context.WithValue(ctx, "a", "b")
+	return context.WithValue(ctx, a, "b")
}

func GetValueFromHoge(ctx context.Context) {
-	val, ok := ctx.Value("a").(string)
+	val, ok := ctx.Value(a).(string)
	fmt.Println(val, ok)
}
+// fuga

+type ctxKey int
+
+const (
+	a ctxKey = iota
+)

func SetValue(ctx context.Context) context.Context {
-	return context.WithValue(ctx, "a", "c")
+	return context.WithValue(ctx, a, "c")
}

func GetValueFromFuga(ctx context.Context) {
-	val, ok := ctx.Value("a").(string)
+	val, ok := ctx.Value(a).(string)
	fmt.Println(val, ok)
}

hoge,fugaパッケージ共にctxKey型という非公開型を導入し、それぞれctxKey型の定数aをkeyとしてcontextに値を付与しています。

この改修を終えた後に、先ほどと同じmain関数を実行したらどうなるでしょうか。

結果

$ go run main.go
b true  // hoge.GetValueFromHoge(ctx)からの出力
c true  // fuga.GetValueFromFuga(ctx)からの出力

無事衝突することなく、hoge.GetValueFromHoge関数からはhogeパッケージで付加されたvalue"b"が、fuga.GetValueFromFuga関数からはfugaパッケージで付加されたvalue"c"が確認できました。

これは、contextに付与された値のkeyがそれぞれ

  • hogeパッケージ内: hoge.ctxKey型の定数a = itoa
  • fugaパッケージ内: fuga.ctxKey型の定数a = itoa

であるからです。
各パッケージ内で独自の型を作ったことにより、hogefugaパッケージ双方iotaで同じ見た目の値になったとしても、型が異なるので違う値扱いになり衝突しなくなるのです。
また、独自型を非公開にすれば、keyの衝突を避けるためには「hogeパッケージ内で同じkeyを使ってないか」「fugaパッケージ内で同じkeyを使っていないか」というところのみ気にすればいいので、contextが断然扱いやすくなります。

また、同じパッケージ内でのkey衝突に関しても、「keyをまとめて非公開型の定数で用意してから、全てiotaで値をセット」という方法をとれば簡単に回避可能です。

(210829追記)

syumaiさん(@__syumai)から以下のようなコメントいただきました!
ありがとうございます!

https://twitter.com/__syumai/status/1431640657311846408
// ある一定の型の定数でkeyを区別する
type ctxKet int
const (
	a ctxKey = iota
	b
)// そもそもkeyが違えば型も変えてしまう
type ctxKeyA struct{}
type ctxKeyB struct{}

int型のiotaにするよりも、空構造体struct{}を採用することで、メモリアロケーションを抑えることができるというメリットがあります。

valueとして与えてもいいデータ・与えるべきでないデータ

「contextの値として付加するべき値はどのようなものがふさわしいか?」というのは、Goコミュニティの中で盛んに議論されてきたトピックです。
数々の人が様々な使い方をして、その結果経験則として分かったことを一言でいうならば、

Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.

(訳)contextのvalueは、関数のoptionalなパラメータを渡すためにではなく、プロセスやAPI間を渡り歩くリクエストスコープなデータを伝播するために使うべきである。

出典: pkg.go.dev - context

これについて、もっと深く具体例を出しながら論じていきましょう。

valueとして与えるべきではないデータ

関数の引数

関数の引数となるべきものを、contextの値として付加するべきではありません。
「関数の引数とは何か?」ということをはっきりさせておくと、ここでは「その関数の挙動を決定づける因子」としておきましょう。

例えば、以下のようなコードを考えます。

func doSomething(ctx context.Context) {
	isOdd(ctx) // ctxに入っているkey=numに対応する値が、奇数かどうかを判定する関数
}

func main() {
	ctx := context.Background()
	ctx = prepareContext1()
	ctx = prepareContext2()
	ctx = prepareContext3()

	doSomething(ctx)
}

これには問題点があります。

  • コメントがないと「isOdd関数は、contextの『num』というkeyの偶奇を見ているんだな」という情報がわからない
  • doSomething関数の引数として渡されているcontextが、いつどこでkey=numの値を付加されているのかが非常に分かりにくい
  • contextにどのような値が入っているのかがわからないので、isOdd関数の結果がどうなるのか予想が非常につきにくい

簡単にいうと、isOdd関数の挙動を決めるための引数がcontextの中に隠蔽されてしまっているため、非常に見通しがつきにくいコードになってしまっているのです。

それでは、isOdd関数の挙動を決める「判定対象の数値」を、isOdd関数の引数にしたらどうなるでしょうか。

func doSomething(ctx context.Context, num int) {
	isOdd(num) // numが奇数かどうか判定する関数
}

func main() {
	ctx := context.Background()
	ctx = prepareContext1()
	ctx = prepareContext2()
	ctx = prepareContext3()

	num := 1

	doSomething(ctx, num)
}

こうすることで、

  • isOdd関数が見ているのは、引数のnumのみだということが明確
  • doSomething関数内で呼ばれているisOdd関数の挙動を決定するのは、main関数内で定義されている変数numである」ということが明確
  • コードの実行結果が、num=1であるため奇数判定されるだろうという予測が容易に立つ

という点で非常に良くなりました。

繰り返しますが、「関数の挙動を決める変数」というのは、引数の形で渡すべきです。contextの中に埋め込む形で隠蔽するべきではありません。

type-unsafeになったら困るもの

再び先ほどのisOdd関数の例を挙げてみましょう。

contextを使ったisOdd関数の実装は以下のようになっていました。

const num ctxKey = 0

func isOdd(ctx context.Context) {
	num, ok := ctx.Value(num).(int) // 型アサーション
	if ok {
		if num%2 == 1 {
			fmt.Println("odd number")
		} else {
			fmt.Println("not odd number")
		}
	}
}

func doSomething(ctx context.Context) {
	isOdd(ctx) // ctxに入っているkey=numに対応する値が、奇数かどうかを判定する関数
}

isOdd関数の中で、contextから得られるkey=numの値が、int型に本当になるのかどうかを確認するアサーション作業が入っているのがわかるかと思います。
これは、「contextに渡した時点で、keyとvalueはinterface{}型になってしまう」ゆえに起こる現象です。

// WithValueで渡した時点でkeyもvalueもinterface{}型になり、元の型情報は失われてしまう
func WithValue(parent Context, key, val interface{}) Context

// 当然、取り出す時も型情報が失われたinterface{}型となる
type Context interface {
	Value(key interface{}) interface{}
}

isOdd関数の引数に判定対象numを入れてしまう形ならば、型アサーションを排除することができます。
これは、関数の引数としてなら、変数numの元の型であるintを保全することができるからです。

func isOdd(ctx context.Context, num int) {
	// 型アサーションなし
	if num%2 == 1 {
		fmt.Println("odd number")
	} else {
		fmt.Println("not odd number")
	}
}

func doSomething(ctx context.Context) {
	isOdd(ctx, 1) // 第二引数を、奇数かどうかを判定する関数
}

contextに渡した値は、interface{}型となって型情報が失われるということを意識するべきです。
そのため、type-unsafeになったら困る値をcontextに渡すべきではありません。

可変な値

今度は先ほどのisOdd関数を、以下のように使ってみましょう。

func doSomethingSpecial(ctx context.Context) context.Context {
	return context.WithValue(ctx, num, 2)
}

func main() {
	ctx := context.Background()
	ctx = context.WithValue(ctx, num, 1)

	isOdd(ctx) // odd

	ctx = doSomethingSpecial(ctx)

	isOdd(ctx) // ???
}

main関数内で与えたcontextの値は当初1だったので、isOdd関数の結果は「奇数」判定されるでしょう。
しかし、その後にdoSomethingSpecialという全然スペシャルではない関数の実行が挟まれています。
そのため、isOdd(ctx)という呼び出しの字面は同じでも、2回目のisOdd関数の結果が1回目のそれと同じになるかどうか、というのが一目ではわからなくなってしまいました。

これも先ほど述べた内容ではあるのですが、contextの中に値を付与するというのは下手したら「context中に変数を隠蔽する」ということにもなりかねます。
そのため、「contextの中には何が入っているのか?」の見通しを良くするために、contextに渡す値というのは不変値が望ましいでしょう。

ゴールーチンセーフではない値

そもそも、contextは「複数のゴールーチン間で情報伝達をするための仕組み」でした。
そのため、contextに渡すvalueというのも、異なる並行関数で扱われることを想定して、ゴールーチンセーフなものにする必要があります。

The same Context may be passed to functions running in different goroutines

(訳)同一のcontextは、異なるゴールーチン上で動いている関数に渡される可能性があります。

出典: pkg.go.dev - context

ゴールーチンセーフでない値の例として、スライスが挙げられます。
例えば以下のようにゴールーチンを10個立てて、それらの中で個別にあるスライスsliceに要素を一つずつ追加していったとしても、最終的なlen(slice)の値が10になるとは限りません。
これは、スライスがゴールーチンセーフではなく、appendの際の排他処理が取れていないからです。

func main() {
	var wg sync.WaitGroup
	wg.Add(10)

	slice := make([]int, 0)
	for i := 0; i < 10; i++ {
		go func(i int) {
			defer wg.Done()
			slice = append(slice, i)
		}(i)
	}

	wg.Wait()
	fmt.Println(len(slice)) // 10になるとは限らない
}

繰り返しますが、contextにゴールーチンセーフでない値を渡すべきではありません。
その部分を担保するのは、Goの言語仕様ではなくGoを利用するプログラマ側の責任です。

valueに与えるのがふさわしい値

それでは逆に、「contextに渡してやった方がいい値」というのはなんでしょうか。

渡すべきではない値の条件を全て避けようとすると、条件は以下のようになります。

  1. 関数の挙動を変えうる引数となり得ない
  2. type-unsafeを許容できる
  3. 不変値
  4. ゴールーチンセーフ

そして、contextというのは本来「異なるゴールーチン上で情報伝達するための機構」なのです。
これらの条件を鑑みると、自ずと使用用途は限られます。
それは「リクエストスコープな値」であることです。

リクエストスコープとは?

リクエストスコープとは、「一つのリクエストが処理されている間に共有される」という性質のことです。
例を挙げると、

  • ヘッダから抜き出したユーザーID
  • 認証トークン
  • トレースのためにサーバー側でつける処理ID
  • etc...

です。これらの値は、一つのリクエストの間に変わることがなく、リクエストを捌くために使われる複数のゴールーチン間で共有されるべき値です。