コンテキストと構造体
Contexts and structs by Jean Barkhuysen, Matt T. Proud
はじめに
多くのGoのAPI、特に現代的なAPIでは、関数やメソッドの最初の引数として
context.Context を取ることがよくあります。コンテキストは、
デッドラインや呼び出し元によるキャンセル、その他リクエストスコープの値をAPIの境界を越えて、
またプロセス間で伝達する手段を提供します。これはライブラリがデータベースやAPIなど、
直接的あるいは間接的にリモートサーバーとやり取りする場合によく使われます。
contextパッケージのドキュメント には次のように書かれています。
Contexts should not be stored inside a struct type, but instead passed to each function that needs it.
(コンテキストは構造体型の中に格納すべきではなく、それを必要とする各関数に渡すべきです。)
この記事では、なぜコンテキストを他の型に格納するのではなく引数として渡すことが重要なのか、 その理由と例を挙げてこのアドバイスを詳しく説明します。またコンテキストを構造体型に格納することが 理にかなっている稀なケースについても触れ、その安全な実現方法を紹介します。
引数として渡すコンテキストを優先する
コンテキストを構造体に格納すべきではないというアドバイスを理解するために、まずは好ましいとされる 「コンテキストを引数として渡す」アプローチを見てみましょう。
// Worker はリモートの作業オーケストレーションサーバーに対して作業を取得し、追加します。
type Worker struct { /* … */ }
type Work struct { /* … */ }
func New() *Worker {
return &Worker{}
}
func (w *Worker) Fetch(ctx context.Context) (*Work, error) {
_ = ctx // 呼び出しごとのctxはキャンセル、デッドライン、メタデータのために使われます。
}
func (w *Worker) Process(ctx context.Context, work *Work) error {
_ = ctx // 呼び出しごとのctxはキャンセル、デッドライン、メタデータのために使われます。
}
ここでは (*Worker).Fetch と (*Worker).Process の両方のメソッドが、コンテキストを直接引数として受け取っています。
この「引数として渡す」設計により、利用者は呼び出しごとにデッドラインやキャンセル、メタデータを設定できます。
また、各メソッドに渡された context.Context がどう使われるかも明確です。あるメソッドに渡した
context.Context が他のメソッドで使われるという期待は一切ありません。これはコンテキストが必要最小限の
操作の範囲にスコープされているためであり、このパッケージにおける context の有用性と明確さを大きく高めています。
コンテキストの構造体格納が招く混乱
先ほどの Worker の例を、今度は好ましくない「コンテキストを構造体に格納する」アプローチで見直してみましょう。
この方法の問題点は、コンテキストを構造体に格納すると、そのライフタイムが呼び出し元から見えにくくなり、
さらに悪いことに2つのスコープが予測できない形で入り混じってしまうことです。
type Worker struct {
ctx context.Context
}
func New(ctx context.Context) *Worker {
return &Worker{ctx: ctx}
}
func (w *Worker) Fetch() (*Work, error) {
_ = w.ctx // 共有されたw.ctxがキャンセル、デッドライン、メタデータのために使われます。
}
func (w *Worker) Process(work *Work) error {
_ = w.ctx // 共有されたw.ctxがキャンセル、デッドライン、メタデータのために使われます。
}
(*Worker).Fetch と (*Worker).Process の両方のメソッドは、Workerに格納されたコンテキストを使っています。
これでは、FetchとProcessの呼び出し元(それぞれ異なるコンテキストを持っているかもしれません)が、
呼び出しごとにデッドラインを指定したり、キャンセルを要求したり、メタデータを付与したりできません。
たとえば、利用者は (*Worker).Fetch だけにデッドラインを設定したり、 (*Worker).Process の呼び出しだけを
キャンセルしたりできません。呼び出し元のライフタイムは共有されたコンテキストと混ざり合ってしまい、
コンテキストのスコープは Worker が生成された時点のライフタイムに固定されてしまいます。
このAPIは、引数として渡すアプローチと比べても、利用者にとってはるかにわかりにくいものになります。 利用者は次のような疑問を持つかもしれません。
Newがcontext.Contextを受け取るということは、コンストラクタがキャンセルやデッドラインを必要とする 処理を行っているということなのだろうか?Newに渡されたcontext.Contextは(*Worker).Fetchや(*Worker).Processの処理に適用されるのだろうか? どちらにも適用されない?それとも片方だけ?
このAPIでは、 context.Context が正確に何のために使われるのかを利用者に明示的に伝えるために、
かなりの量のドキュメントが必要になるでしょう。また利用者は、APIの構造が伝えてくれる情報に頼るのではなく、
コードを読まなければならなくなるかもしれません。
そして最後に、リクエストのそれぞれがコンテキストを持たずキャンセルを適切に扱えないような本番グレードの サーバーを設計することは危険です。呼び出しごとのデッドラインを設定できなければ、 プロセスに処理が滞留し、 メモリなどのリソースを枯渇させてしまう可能性があります。
後方互換性を維持するための例外
Go 1.7(context.Contextを導入したバージョン)がリリースされたとき、
多くのAPIが後方互換性を保ったままコンテキストのサポートを追加しなければなりませんでした。たとえば
net/http の Client のメソッドである Get や Do は、コンテキストを導入する
絶好の候補でした。これらのメソッドで送信される外部へのリクエストはそれぞれ、 context.Context が
もたらすデッドライン、キャンセル、メタデータのサポートの恩恵を受けられるはずでした。
後方互換性を保ちながら context.Context のサポートを追加する方法は2つあります。一つはこれから見ていく、
構造体にコンテキストを含める方法、もう一つは関数を複製し、複製したほうに context.Context を受け取らせて
関数名の末尾に Context を付ける方法です。後者の複製する方法のほうが、構造体にコンテキストを含める方法よりも
好ましく、これについては Keeping your modules compatible でさらに詳しく
議論されています。しかし、場合によってはこれが現実的でないこともあります。たとえば、あなたのAPIが
多くの関数を公開している場合、それらすべてを複製するのは実行不可能かもしれません。
net/http パッケージは構造体にコンテキストを含めるアプローチを選択しており、これは有用な事例研究となります。
net/http の Do を見てみましょう。 context.Context が導入される前、 Do は次のように定義されていました。
// Do は HTTP リクエストを送信し、HTTP レスポンスを返します […]
func (c *Client) Do(req *Request) (*Response, error)
Go 1.7以降、もし後方互換性を壊すことにならないのであれば、 Do は次のようになっていたかもしれません。
// Do は HTTP リクエストを送信し、HTTP レスポンスを返します […]
func (c *Client) Do(ctx context.Context, req *Request) (*Response, error)
しかし、標準ライブラリにとっては後方互換性を維持し、
Go 1における互換性の約束 を守ることが重要です。そこでメンテナーたちは代わりに、
後方互換性を壊すことなく context.Context のサポートを可能にするために、 http.Request 構造体に
context.Context を追加するという選択をしました。
// Request はサーバーが受信する、あるいはクライアントが送信するHTTPリクエストを表します。
// ...
type Request struct {
ctx context.Context
// ...
}
// NewRequestWithContext はメソッド、URL、任意のボディを受け取り、新しいRequestを返します。
// [...]
// 与えられたctxはRequestのライフタイムを通して使われます。
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
// この記事のために簡略化しています。
return &Request{
ctx: ctx,
// ...
}
}
// Do は HTTP リクエストを送信し、HTTP レスポンスを返します […]
func (c *Client) Do(req *Request) (*Response, error)
自分のAPIに後付けでコンテキストのサポートを組み込む際には、上記のように構造体に context.Context を
追加することが理にかなっている場合もあります。しかしその前に、まず関数を複製する方法を検討することを
忘れないでください。この方法であれば、有用性や理解しやすさを犠牲にすることなく、後方互換性を保ったまま
context.Context を後付けできます。たとえば次のようになります。
// Call は内部でcontext.Backgroundを使います。コンテキストを指定したい場合は
// CallContextを使ってください。
func (c *Client) Call() error {
return c.CallContext(context.Background())
}
func (c *Client) CallContext(ctx context.Context) error {
// ...
}
結論
コンテキストを使えば、ライブラリやAPIをまたいだ重要な情報を呼び出しスタックの下流へと伝播させるのが 簡単になります。しかし理解しやすく、デバッグしやすく、効果的であり続けるためには、一貫して明確に 使わなければなりません。
構造体に格納するのではなくメソッドの最初の引数として渡すことで、利用者はコンテキストの拡張性を最大限に 活用し、呼び出しスタックを通じてキャンセルやデッドライン、メタデータの情報からなる強力な木構造を 構築できます。そして何より、引数として渡された場合はそのスコープが明確に理解できるため、 スタックの上流でも下流でも明快な理解とデバッグのしやすさにつながります。
コンテキストを使ったAPIを設計するときは、次のアドバイスを忘れないでください。 context.Context は
引数として渡すこと。構造体に格納しないこと。
参考文献
By Jean Barkhuysen, Matt T. Proud