Goブログ

コンテキストと構造体

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

はじめに

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

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

Contextはstruct型の中に格納するのではなく、必要な各関数に渡すべきです。

この記事では、Contextを別の型に格納するのではなく、渡すことがなぜ重要なのかを理由と例を挙げて説明します。また、Contextをstruct型に格納することが理にかなっているまれなケースと、それを安全に行う方法についても強調します。

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

structにコンテキストを格納しないというアドバイスを理解するために、引数としてコンテキストを渡すことを推奨するアプローチを検討してみましょう。

// 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 の有用性と明瞭さが大幅に向上するためです。

structにコンテキストを格納すると混乱を招く

structにコンテキストを格納するという好ましくないアプローチで、上記の Worker の例をもう一度見てみましょう。問題点は、structにコンテキストを格納すると、呼び出し元に対する有効期間が不明確になるか、さらに悪いことに、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の構造が伝えていることに頼るのではなく、コードを読まなければならない場合もあります。

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

ルールの例外:後方互換性を維持する

Go 1.7(context.Contextを導入)がリリースされたとき、多くのAPIが後方互換性を維持した方法でコンテキストのサポートを追加する必要がありました。たとえば、net/httpClientメソッドGetDoなど)は、コンテキストの優れた候補でした。これらのメソッドで送信された各外部リクエストは、context.Contextに付属しているデッドライン、キャンセル、およびメタデータのサポートからメリットが得られました。

後方互換性を維持した方法でcontext.Contextのサポートを追加する方法は2つあります。1つは、後ほど説明するようにstructにコンテキストを含めること、もう1つは、context.Contextを受け入れる関数を複製し、関数名のサフィックスとしてContextを持つことです。structにコンテキストを入れるよりも、複製アプローチを優先する必要があります。詳細については、モジュールの互換性を維持するで説明しています。ただし、APIが多数の関数を公開している場合など、実際的ではない場合があります。その場合、それらをすべて複製することは実行不可能かもしれません。

net/httpパッケージはstructにコンテキストを入れるアプローチを選択しました。これは、有用なケーススタディを提供します。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 structに 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をコンテキストをサポートするように改造する場合、上記のようにstructに 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をまたがる重要な情報を呼び出しスタックに簡単に伝播できます。ただし、理解しやすく、デバッグしやすく、効果的にするために、一貫して明確に使用する必要があります。

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

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

さらに読む

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