The Go Blog

コンテキストと構造体

Jean Barkhuysen、Matt T. Proud
2021年2月24日

はじめに

多くのGo API、特に現代のAPIでは、関数やメソッドの最初の引数はcontext.Contextであることがよくあります。コンテキストは、デッドライン、呼び出し元のキャンセル、その他のリクエストスコープの値をAPI境界を越えてプロセス間で伝達する手段を提供します。ライブラリがデータベース、APIなどのリモートサーバーと直接的または間接的に対話する場合によく使用されます。

contextのドキュメントには次のように記載されています。

コンテキストは構造体型の中に格納されるべきではなく、それを必要とする各関数に渡されるべきです。

この記事では、このアドバイスをさらに掘り下げ、コンテキストを他の型に格納するのではなく、渡すことが重要である理由と例を説明します。また、コンテキストを構造体型に格納することが理にかなっている稀なケースと、それを安全に行う方法についても説明します。

引数として渡されるコンテキストを優先する

コンテキストを構造体に格納しないというアドバイスを理解するために、推奨される引数としてのコンテキストのアプローチを考えてみましょう。

// Worker fetches and adds works to a remote work orchestration server.
type Worker struct { /* … */ }

type Work struct { /* … */ }

func New() *Worker {
  return &Worker{}
}

func (w *Worker) Fetch(ctx context.Context) (*Work, error) {
  _ = ctx // A per-call ctx is used for cancellation, deadlines, and metadata.
}

func (w *Worker) Process(ctx context.Context, work *Work) error {
  _ = ctx // A per-call ctx is used for cancellation, deadlines, and metadata.
}

ここでは、(*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 // A shared w.ctx is used for cancellation, deadlines, and metadata.
}

func (w *Worker) Process(work *Work) error {
  _ = w.ctx // A shared w.ctx is used for cancellation, deadlines, and metadata.
}

(*Worker).Fetchメソッドと(*Worker).Processメソッドの両方がWorkerに格納されたコンテキストを使用しています。これにより、FetchとProcessの呼び出し元(それら自体が異なるコンテキストを持つ可能性がある)が、呼び出しごとにデッドラインを指定したり、キャンセルを要求したり、メタデータを添付したりすることができません。たとえば、ユーザーは(*Worker).Fetchのみにデッドラインを提供したり、(*Worker).Process呼び出しのみをキャンセルしたりすることはできません。呼び出し元のライフタイムは共有コンテキストと混ざり合い、コンテキストはWorkerが作成されるライフタイムにスコープされます。

また、このAPIは、引数として渡すアプローチと比較して、ユーザーにとってずっと混乱しやすくなります。ユーザーは次のように自問するかもしれません。

  • Newcontext.Contextを受け取るので、コンストラクタはキャンセルやデッドラインを必要とする作業を行っているのでしょうか?
  • Newに渡されたcontext.Contextは、(*Worker).Fetch(*Worker).Processの作業に適用されますか?どちらにも適用されませんか?一方には適用されますが、もう一方には適用されませんか?

APIは、context.Contextが何に使用されるかをユーザーに明示的に伝えるために、多くのドキュメントを必要とするでしょう。ユーザーは、APIの構造に頼るのではなく、コードを読まなければならないかもしれません。

そして最後に、リクエストごとにコンテキストを持たないため、キャンセルを適切に処理できない本番グレードのサーバーを設計することは非常に危険です。呼び出しごとのデッドラインを設定する機能がなければ、プロセスがバックログされ、リソース(メモリなど)を使い果たしてしまう可能性があります。

ルールの例外:下位互換性の維持

context.Contextを導入したGo 1.7がリリースされたとき、多数のAPIが下位互換性のある方法でコンテキストのサポートを追加する必要がありました。たとえば、GetDoのようなnet/httpClientメソッドは、コンテキストにとって優れた候補でした。これらのメソッドで送信される各外部リクエストは、context.Contextがもたらすデッドライン、キャンセル、およびメタデータのサポートの恩恵を受けるでしょう。

下位互換性のある方法でcontext.Contextのサポートを追加するには、2つのアプローチがあります。1つは、後で説明するように構造体内にコンテキストを含めること、もう1つは、context.Contextを受け入れ、関数名にContextサフィックスを持つ関数を重複させることです。重複アプローチは構造体内のコンテキストよりも優先されるべきであり、モジュールの互換性を維持するでさらに議論されています。しかし、場合によっては非現実的です。たとえば、APIが多数の関数を公開している場合、それらすべてを重複させるのは実現不可能かもしれません。

net/httpパッケージは構造体内のコンテキストのアプローチを選択し、これは有用なケーススタディを提供します。net/httpDoを見てみましょう。context.Contextの導入前、Doは次のように定義されていました。

// Do sends an HTTP request and returns an HTTP response [...]
func (c *Client) Do(req *Request) (*Response, error)

Go 1.7以降、下位互換性を破らなければ、Doは次のように見えたかもしれません。

// Do sends an HTTP request and returns an HTTP response [...]
func (c *Client) Do(ctx context.Context, req *Request) (*Response, error)

しかし、下位互換性を維持し、Go 1の互換性の約束を遵守することは、標準ライブラリにとって非常に重要です。そこで、メンテナは、下位互換性を破らずにcontext.Contextをサポートするために、http.Request構造体にcontext.Contextを追加することを選択しました。

// A Request represents an HTTP request received by a server or to be sent by a client.
// ...
type Request struct {
  ctx context.Context

  // ...
}

// NewRequestWithContext returns a new Request given a method, URL, and optional
// body.
// [...]
// The given ctx is used for the lifetime of the Request.
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
  // Simplified for brevity of this article.
  return &Request{
    ctx: ctx,
    // ...
  }
}

// Do sends an HTTP request and returns an HTTP response [...]
func (c *Client) Do(req *Request) (*Response, error)

APIをコンテキストをサポートするように改修する場合、上記のようにcontext.Contextを構造体に追加するのが理にかなっている場合があります。ただし、まず関数を重複させることを検討してください。これにより、ユーティリティと理解を犠牲にすることなく、下位互換性を保ちながらcontext.Contextを改修できます。たとえば、

// Call uses context.Background internally; to specify the context, use
// CallContext.
func (c *Client) Call() error {
  return c.CallContext(context.Background())
}

func (c *Client) CallContext(ctx context.Context) error {
  // ...
}

まとめ

コンテキストは、重要なクロスライブラリおよびクロスAPI情報を呼び出しスタックに伝播することを容易にします。しかし、理解可能で、デバッグしやすく、効果的であるためには、一貫して明確に使用される必要があります。

構造体型に格納されるのではなく、メソッドの最初の引数として渡される場合、ユーザーはその拡張性を最大限に活用して、呼び出しスタック全体にわたる強力なキャンセル、デッドライン、およびメタデータ情報のツリーを構築できます。そして、何よりも、引数として渡されるときにそのスコープが明確に理解され、スタック全体で明確な理解とデバッグ可能性につながります。

コンテキストを使用してAPIを設計する際は、アドバイスを覚えておいてください。context.Contextを引数として渡し、構造体に格納しないでください。

さらに読む

次の記事:Go開発者調査2020結果
前の記事:Go 1.16での新しいモジュール変更
ブログインデックス