Chapter 10

パッケージへのcontext導入について

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

この章について

さて、ここまでcontextで何ができるのか・どう便利なのかというところを見てきました。
そこで、「自分のパッケージにもcontextを入れたい」と思う方もいるかもしれません。

ここからは、「パッケージにcontextを導入する」にはどのようにしたらいいか、について考えていきたいと思います。

既存パッケージへのcontext導入

状況設定

例えば、すでにmypkgパッケージのv1としてMyFunc関数があり、それをmain関数内で呼び出しているとしましょう。

// mypkg pkg

type MyType sometype

func MyFunc(arg MyType) {
	// doSomething
}
// main pkg

func main() {
	// argを準備
	mypkg.MyFunc(arg)
}

この状況で、新たに「MyFunc関数にcontextを渡すようにしたい」という改修を考えます。

mypkg内の改修

NG例: contextを構造体の中に入れる

よくいわれるNG例は、「MyTypeの型定義を改修して、contextを内部に持つ構造体型にする」というものです。

-type MyType sometype
+type MyType struct {
+	sometype
+	ctx context.Context
+}

func MyFunc(arg MyType) {
	// doSomething
}

これがどうしてダメなのか、ということについて考えてみます。

contextのスコープが分かりにくい

例えばもしも、MyFunc関数の中でまた新たに別の関数AnotherFuncを呼んでいたらどうなるでしょうか。

func MyFunc(arg MyType) {
	// doSomething
	AnotherFunc(arg) // 別の関数を呼ぶ
}

よく見るとAnotherFuncの引数にargが使われています。
このarg構造体の中にはcontextが埋め込まれていました。そのため、AnotherFunc関数の中でもcontextが使える状態になります。
ですが「AnotherFunc関数の中でもcontextが使える」というのが、一目見ただけではわかりませんよね。

このように、contextを構造体の中に埋め込んで隠蔽してしまうと、「あるcontextがどこからどこまで使われているのか?」ということが特定しにくくなるのです。

contextの切り替えが難しい

また、MyType型にメソッドがあった場合には別のデメリットが発生します。

type MyType struct {
	sometype
	ctx context.Context
}

// メソッド1
func (*m MyType)MyMethod1() {
	// doSomething
}

// メソッド2
func (*m MyType)MyMethod2() {
	// doSomething
}

この場合に「メソッド1とメソッド2で違うcontextを渡したい」というときには、レシーバーであるMyType型を別に作り直す必要が出てきます。
それはちょっと面倒ですよね。

OK例: MyFuncの第一引数にcontextを追加

これらの不便さを解消するには、contextは関数・メソッドの引数として明示的に渡す方法を取るべきです。

type MyType sometype

-func MyFunc(arg MyType) {
+func MyFunc(ctx context.Context, arg MyType)
	// doSomething
}

実際contextを関数の第一引数にする形では、contextのスコープ・切り替えの面でどうなるのかについてみてみましょう。

contextのスコープ

まずは、「MyFunc関数の中で別の関数AnotherFuncを呼んでいる」というパターンです。

func MyFunc(ctx context.Context, arg MyType) {
	AnotherFunc(arg)
	// or
	AnotherFunc(ctx, arg)
}

前者の呼び出し方なら「AnotherFunc内ではcontextは使っていない」、後者ならば「AnotherFuncでもcontextの内容が使われる」ということが簡単にわかります。

このような明示的なcontextの受け渡しは、contextのスコープをわかりやすくする効果があるのです。

contextの切り分け

また、MyTypeにメソッドが複数あった場合についてはどうでしょうか。

type MyType sometype

// メソッド1
func (*m MyType)MyMethod1(ctx context.Context) {
	// doSomething
}

// メソッド2
func (*m MyType)MyMethod2(ctx context.Context) {
	// doSomething
}

このように、contextをメソッドの引数として渡すようにすれば、「メソッド1とメソッド2で別のcontextを使わせたい」という場合では、引数に別のcontextを渡せばいいだけなので簡単です。
レシーバーであるMyTypeを作り直すという手間は発生しません。

まとめ

Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it.
The Context should be the first parameter, typically named ctx.

(訳)contextは構造体のフィールド内に持たせるのではなく、それを必要としている関数の引数として明示的に渡すべきです。
その場合、contextはctxという名前の第一引数にするべきです。

出典:pkg.go.dev - context

mainパッケージ内の改修

さて、MyFunc関数の第一引数がcontextになったことで、main関数側でのMyFunc呼び出し方も変更する必要があります。
mypkgパッケージ内でのcontext対応が終わっており、問題なく使える状態になっているなら、以下のように普通にcontext.Backgroundで大元のcontextを作ればOKです。

func main() {
	ctx := context.Background()
	// argを準備
	mypkg.MyFunc(ctx, arg)
}

しかし、「MyFuncの第一引数がcontextにはなっているけれども、context対応が本当に終わっているか分からないなあ」というときにはどうしたらいいでしょうか。

NG例: nilを渡す

やってはいけないのは、「使われるかわからないcontextのところにはnilを入れておこう」というものです。

func main() {
	// argを準備
	mypkg.MyFunc(nil, arg)
}

これは中身がnilであるcontextのメソッドが万が一呼ばれてしまった場合、ランタイムパニックが起こってしまうからです。

var ctx context.Context

func main() {
	ctx = nil
	fmt.Println(ctx.Deadline())
}
$ go run main.go
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x488fe9]

goroutine 1 [running]:
main.main()
	/tmp/sandbox74431567/prog.go:12 +0x49

OK例: TODOを渡す

MyFuncの第一引数がcontextにはなっているけれども、context対応が本当に終わっているか分からない」という場合に使うべきものが、contextパッケージ内には用意されています。
それがcontext.TODOです。

func main() {
+	ctx := context.TODO()
	// argを準備
-	mypkg.MyFunc(nil, arg)
+	mypkg.MyFunc(ctx, arg)
}

TODOBackgroundのように空のcontextを返す関数です。

func TODO() Context

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

TODO returns a non-nil, empty Context.
Code should use context.TODO when it's unclear which Context to use or it is not yet available (because the surrounding function has not yet been extended to accept a Context parameter).

(訳)TODOはnilではない空contextを返します。
どのcontextを渡していいか定かではない場合や、その周辺の関数がcontext引数を受け付ける拡張が済んでおらず、まだcontextを渡せないという場合にはこのTODOを使うべきです。

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

標準パッケージにおけるcontext導入状況

さて、これで既存パッケージにcontextを導入する際には「contextを構造体フィールドに入れるのではなく、関数の第一引数として明示的に渡すべき」という原則を知りました。

contextパッケージがGoに導入されたのはバージョン1.7からです。
そのため、それ以前からあった標準パッケージはcontext対応を何かしらの形で行っています。

ここからは、二つの標準パッケージがどうcontextに対応させたのか、という具体例を見ていきましょう。

database/sqlの場合

database/sqlパッケージは、まさに「contextを関数の第一引数の形で明示的に渡す」という方法を使ってcontext対応を行いました。

type DB
	func (db *DB) Exec(query string, args ...interface{}) (Result, error)
	func (db *DB) ExecContext(ctx context.Context, query string, args ...interface{}) (Result, error)

	func (db *DB) Ping() error
	func (db *DB) PingContext(ctx context.Context) error

	func (db *DB) Prepare(query string) (*Stmt, error)
	func (db *DB) PrepareContext(ctx context.Context, query string) (*Stmt, error)

	func (db *DB) Query(query string, args ...interface{}) (*Rows, error)
	func (db *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*Rows, error)

	func (db *DB) QueryRow(query string, args ...interface{}) *Row
	func (db *DB) QueryRowContext(ctx context.Context, query string, args ...interface{}) *Row

出典:pkg.go.dev - database/sql

context導入以前に書かれたコードの後方互換性を保つために古いcontextなしの関数Xxxxも残しつつも、context対応したXxxxContext関数を新たに作ったのです。

net/httpの場合

net/httpパッケージは、あえて「構造体の中にcontextを持たせる」というアンチパターンを採用しました。

例えばhttp.Request型の中には、非公開ではありますがctxフィールドが確認できます。

type Request struct {
	ctx context.Context
	// (以下略)
}

出典:net/http/request.go

なぜそのようなことをしたのでしょうか。実はこれも後方互換性の担保のためなのです。

net/httpの中に、引数・返り値何らかの形でRequest型が含まれている関数・メソッドの数は、公開されているものだけでも数十にのぼります。httpパッケージ内部のみで使われている非公開関数・メソッドまで含めるとその数はかなりのものになるのは想像に難くないでしょう。

そのため、それらをすべて「contextを第一引数に持つように」改修するのは非現実的でした。
database/sqlのように「後方互換性のために古い関数Xxxを残した上で、新しくXxxContextを作る」というのをやるのなら、それはもう新しくhttpcontextというパッケージを作るようなものでしょう。並大抵の労力ではできません。

「非公開フィールドとしてcontextを追加する」という方法ならば、後方互換性を保ったcontext対応が比較的簡単に行えます。
そのため、net/httpパッケージではあえてこのアンチパターンが採用されたのです。

Go公式ブログ - Contexts and structsではnet/httpの例を取り上げて、「これが構造体の中にcontextを入れて許される唯一の例外パターンである」と述べています。