はじめに
Goのcontext.Context
はリクエストスコープにおいてキャンセルの情報の伝播や値の受け渡しに利用するためのものですが、使う上でいくつかの注意が必要です。
Go Doc - Package contextに以下のような一文が存在します。
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として第1引数に渡すべきです。
func DoSomething(ctx context.Context, arg Arg) error {
// ... use ctx ...
}
ここには何故Contextを構造体に含めてはいけないかの理由が書いてありません。
その理由をGoogle社員の方の回答をもとに説明したいと思います。
Contextを構造体に含めてはいけない理由
構造体にContextを含めるということは、その構造体をリクエストスコープと紐付け、状態として管理することを意味します。
しかし状態を持つということは、別のリクエストスコープを持つContextからその構造体を利用する可能性がでてきてしまいます。
以下のようなコードを書いてしまうと想定していない挙動になります。
type S struct {
ctx Context
}
func (s *S) Foo() {
bar(s.ctx)
}
func bar(ctx context.Context) {
go something()
select {
case <-ctx.Done():
stopSomething()
}
}
func Baz(ctx context.Context, s *S) {
// ここでは引数に渡されたctxのリクエストスコープではなく、S構造体を作成したときのctxのリクエストスコープとして扱われてしまう。
// Bazのctxがキャンセルされたとき、このs.Fooの呼び出しはキャンセルされないため、bar()の中のsomething()が動き続けてしまう。
s.Foo()
}
もし正しくキャンセルの情報を伝播させるために以下のようにするべきです。
func (s *S) Foo(ctx context.Context) {
bar(ctx)
}
func Baz(ctx context.Context, s *S) {
// ctxを渡すことで、正しくsomething()を停止することができる。
s.Foo(ctx)
}
func bar(ctx context.Context) {
go something()
select {
case <-ctx.Done():
stopSomething()
}
}
Contextを構造体に含めることが許される状況
上記のように関数の第1引数にcontextを渡すことが理想的ですが、それができない状況が存在します。
それはio.Readerやio.Writerのようなインターフェースを満たさなければいけないようなケースです。このようなときにContextを渡してしまうとインターフェースを満たせなくなってしまいます。
しかし構造体にContextを含めた場合においても、先程説明した問題は回避しなければなりません。
Googleでは以下のようにして対応しているようです。
ctx := context.WithTimeout(context.Background(), 1*time.Second)
f, err := fs.Open(ctx, "file.go")
if err != nil {
log.Fatal(err)
}
b, err := ioutil.ReadAll(f.IO(ctx))
package fs
type IO interface {
io.ReadWriteSeeker
io.ReaderAt
io.Closer
}
type File struct {
f *os.File
}
type fio struct {
f *os.File
ctx context.Context
}
func Open(ctx context.Context, name string) (file *File, err error) {
return OpenFile(ctx, name, os.O_RDONLY, 0)
}
func OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (file *File, err error) {
defer interrupt(ctx)()
f, err := os.OpenFile(name, flag, perm)
if err != nil {
return nil, err
}
return newFile(f), nil
}
func (f *File) IO(ctx context.Context) IO {
return fio{f.f, ctx}
}
func (fio fio) Write(p []byte) (int, error) {
defer interrupt(fio.ctx)()
n := 0
for len(p) > 0 {
wn, err := fio.f.Write(p)
n += wn
p = p[wn:]
err = errAgain(fio.ctx, err)
if err != nil {
return n, err
}
select {
case <-fio.ctx.Done():
return n, &os.PathError{
Op: "write",
Path: fio.f.Name(),
Err: context.Canceled,
}
default:
}
}
return n, nil
}
os.File
ではなくfs.File
という独自の構造体を定義しています。これはio.ReadWriter
を満たしていない構造体なので、ctx
を利用せずにWrite()
などを実行できないような設計になっています。Write()
などを実行するためには必ずf.IO(ctx)
としてfio構造体に返すことで利用することが可能になります。fio構造体はio.ReadWriter
を実装しctx
を格納します。
以下は、上記の方式でキャンセル可能なFileSystemパッケージを実装したサンプルです。
File system package that supports cancellation. - fs.go
個人的にContextを構造体に含めてもいいのではと思う状況
上記の問題点を理解することで、以下の2点を守れば構造体にContextを含めても問題が発生しないことがわかります。
- contextを含めた構造体はリクエストスコープ内だけで生存することを保証する。
- contextを含めた構造体のメソッドにはContextを渡さない。
問題がないと思う例
func handler(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
foo := &Foo{ctx:ctx}
foo.GoodMorning()
foo.GoodEvening()
foo.GoodNight()
}
問題がある例
var foo *Foo
func handler(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
foo = &Foo{ctx:ctx}
foo.GoodMorning()
foo.GoodEvening()
foo.GoodNight()
}
正しい例
var foo = &Foo{}
func handler(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
foo.GoodMorning(ctx)
foo.GoodEvening(ctx)
foo.GoodNight(ctx)
}
ただし問題がないと思う例では、ctxを渡して構造体を生成するためリクエストごとに構造体を作ることが必須になりパフォーマンスの点ではよくありません。
そのため問題がないと思う例を実施する動機が「メソッドの第1引数にcontextを渡すのが面倒」以外に存在しないので、積極的に実施する理由にはなりません。
おわり
## 参考