Go の context.Context を正しく使う:キャンセル、タイムアウト、および値

Goのコンテキストは、データの格納ではなく制御フローのために使用する。

目次

Goの context.Context は使い方が簡単すぎるため、誤用しがちです。これがまさに問題点です。

多くのGo開発者は、表面的なルールをすぐに学びます。文脈(context)を最初の引数として渡し、ctx.Done() をチェックし、context.WithTimeout を使用し、nil を渡さないことなどです。

func DoSomething(ctx context.Context) error {
	// ...
}

これらのルールは有用ですが、それは簡単な部分しかカバーしていません。本番環境でのサービスにおいて、コンテキストは単なる引数の規約ではありません。それはリクエストのライフタイムに対する制御平面(control plane)です。

Go context: cancellation propagates through the call chain

コンテキストは、処理をいつ停止させるべきか、残り時間がどれだけあるか、どのキャンセル経路が選択されたか、そしてどのリクエストスコープの値をAPI境界を超えて渡す必要があるかを指示します。適切に使用すれば、ゴルーチンのリークを防ぎ、無駄な作業を回避し、デッドラインを伝播させ、サービスのシャットダウンを容易にします。しかし、誤って使用すると、隠れた依存関係の袋、偽のグローバル変数、忘れられたタイムアウト、リークしたタイマー、そして混乱を招くキャンセル動作の原因となります。

少し意見的なバージョンを述べると、コンテキストはキャンセル、デッドライン、およびリクエストスコープのメタデータのために使用し、依存関係のコンテナとしては使用しない、ということです。

コンテキストの用途

context パッケージには3つの主な役割があります。キャンセル、デッドラインとタイムアウト、そしてリクエストスコープの値です。これら3つの役割が、コンテキストが設計されたすべての用途をカバーしています。

コンテキストは次のような質問に答えるべきです:

このリクエストはキャンセルされましたか?
この操作にはあとどのくらいの時間がありますか?
ログに添付すべきリクエストIDは何ですか?
このリクエストに関連付けられた認証済みユーザーは誰ですか?

コンテキストは次のような質問に答えるべきではありません:

データベース接続はどこにありますか?
ロガーはどこにありますか?
設定はどこにありますか?
どのサービス実装を使用すべきですか?

これらは依存関係です。関数のパラメータを通じて明示的に渡してください(クリーンな方法のパターンについては Goでの依存性注入 を参照)。コンテキストはリクエストのライフタイムとリクエストメタデータのためのものであり、アプリケーションの配線(wiring)のためのものではありません。

基本的なコンテキストの形状

コアインターフェースは小さく、以下の通りです:

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}

重要な部分は以下の通りです:

  • Done() は、コンテキストがキャンセルされたり、デッドラインが期限切れになったりするとクローズされます。
  • Err() は、コンテキストが終了した理由を説明します。
  • Deadline() は、コンテキストにデッドラインがあるかどうかを伝えます。
  • Value() は、リクエストスコープのデータを格納します。

ほとんどのコードはこのインターフェースを実装しません。コンテキストを受け取り、それを下流に渡すだけです。

最初のルール:コンテキストを明示的に渡す

リクエストスコープまたはキャンセル可能な作業を行う関数では、コンテキストを最初の引数として渡します。これは標準的なGoの規約であり、エコシステム内のすべてのライブラリやツールが期待するものです:

func GetUser(ctx context.Context, id string) (*User, error) {
	// ...
}

以下の可能性がある関数ではこれを行います:

  • データベースを呼び出す
  • 他のサービスを呼び出す
  • キューを待つ
  • バックグラウンド作業を開始する
  • I/Oでブロックする
  • タイムアウトを使用する
  • リクエストスコープの値が必要
  • キャンセルが必要

コンテキストを必要としない小さな純粋な関数には、コンテキストを追加しないでください。

これは問題ありません:

func NormalizeEmail(email string) string {
	return strings.ToLower(strings.TrimSpace(email))
}

すべての関数がコンテキストを必要とするわけではありません。どこにでもコンテキストを追加すると、コードがノイズだらけになります。

コンテキストを構造体に格納しないでください

構造体にコンテキストを格納することは、Goコードベースで最も一般的なミスの一つであり、明確に指摘する価値があります。以下のようなことはしないでください:

type UserService struct {
	ctx context.Context
	db  *sql.DB
}

代わりに以下のようにします:

type UserService struct {
	db *sql.DB
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	// ...
}

コンテキストはリクエスト、操作、またはタスクに属しますが、サービス構造体は通常、単一のリクエストよりもずっと長く存続します。これらのライフタイムを混同すると、キャンセルが不明確になり、コンテキストがどの操作に属しているかを推論するのが難しくなります。

単一の操作ライフタイムを真に表現する型には稀な例外がありますが、それらは十分に稀なので、デフォルトのルールはシンプルであるべきです:

コンテキストは渡します。格納しないでください。

nilコンテキストを渡さない

コンテキストとして nil を渡すことは決してありません。

悪い例:

err := svc.DoWork(nil)

既存のコンテキストがない場合は context.Background() を使用します:

err := svc.DoWork(context.Background())

テストでは、可能な限りテストコンテキストを使用します:

func TestDoWork(t *testing.T) {
	err := svc.DoWork(t.Context())
	if err != nil {
		t.Fatal(err)
	}
}

nilコンテキストは、コードがそのメソッドを呼び出す際にパニックを引き起こす可能性があります。バックグラウンドコンテキストは明示的で安全です。

バックグラウンド、TODO、およびリクエストコンテキスト

一般的な開始点には3つあります。

context.Background

親コンテキストが存在しないプログラムのトップレベルで context.Background() を使用します。これは、すべての子コンテキストが派生されるルートコンテキストです:

func main() {
	ctx := context.Background()
	_ = run(ctx)
}

または:

func TestSomething(t *testing.T) {
	ctx := context.Background()
	_ = ctx
}

context.TODO

コンテキストを使用すべきことが分かっているが、まだどのコンテキストを使用するか決定していない場合に context.TODO() を使用します。

ctx := context.TODO()

これは移行中に有用ですが、実際のコンテキストが存在する場合は永久的なものにすべきではありません。

リクエストコンテキスト

HTTPサーバーでは、リクエストコンテキストを使用します:

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	_ = ctx
}

リクエストコンテキストは、クライアント接続がクローズされたり、リクエストがキャンセルされたり、サーバーがリクエストの処理を終了したりするとキャンセルされます。

Webサービスの場合、通常はアプリケーションコードに下流に渡すべきコンテキストはこれです。

context.WithCancelによるキャンセル

作業を明示的に停止したい場合は context.WithCancel を使用します。

ctx, cancel := context.WithCancel(parent)
defer cancel()

返された cancel 関数は、子コンテキストをキャンセルし、それに関連するリソースを解放します。終了時には常にこれを呼び出してください。コンテキストが最終的にタイムアウトする場合でも、早期にcancelを呼び出すことで、リソースが不要なほど長く存続することを回避できます。

例:

func RunWorker(parent context.Context) error {
	ctx, cancel := context.WithCancel(parent)
	defer cancel()

	done := make(chan error, 1)

	go func() {
		done <- doBackgroundWork(ctx)
	}()

	select {
	case <-ctx.Done():
		return ctx.Err()
	case err := <-done:
		return err
	}
}

パターンはシンプルです:

  • 子コンテキストを派生させる。
  • cancelをdeferする。
  • 一緒に停止すべき作業に子コンテキストを渡す。
  • ctx.Done() を監視する。

context.WithTimeoutによるタイムアウト

操作に最大継続時間がある場合は context.WithTimeout を使用します。

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

HTTPクライアントとの例:

func FetchUser(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}

	return client.Do(req)
}

これにより、タイムアウトは操作の一部となり、隠れたグローバル設定ではなくなります。

cancelを常に呼び出す

WithCancelWithTimeout、または WithDeadline を呼び出す際には、常に返されたcancel関数を呼び出してください。これは正しさを確保するために重要です。

良い例:

ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()

悪い例:

ctx, _ := context.WithTimeout(parent, 5*time.Second)

cancelを呼び出し忘れると、タイマーと子コンテキストが不必要に長く存続し続ける可能性があります。

デッドラインとタイムアウトの違い

タイムアウトは相対的なものです:

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

デッドラインは絶対的なものです:

deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()

ほとんどのアプリケーションコードはタイムアウトを使用します。デッドラインは、リクエストに複数の操作で共有すべき固定された終了時間がある場合に有用です。例えば、リクエストに900ミリ秒しか残っていない場合、各下流呼び出しに新しい1秒のタイムアウトを与えるのではなく、残りの予算を伝播させるべきです。

サービスレイヤー間のタイムアウト予算

盲信的にタイムアウトを積み重ねるのは一般的なミスです。

func Handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	_ = service.DoWork(ctx)
}

func (s *Service) DoWork(ctx context.Context) error {
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	return s.repo.Query(ctx)
}

これは無害に見えますが、実際の予算を隠しています。サービスレイヤーは、タイマーを同じ値にリセットするのではなく、通常は呼び出し側のデッドラインを尊重すべきです。

より良いパターンは以下の通りです:

func Handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	if err := service.DoWork(ctx); err != nil {
		// handle error
		return
	}
}

そしてサービス内部では:

func (s *Service) DoWork(ctx context.Context) error {
	return s.repo.Query(ctx)
}

サブ操作がより小さな予算を必要とする場合にのみ、子タイムアウトを追加します:

func (s *Service) DoWork(ctx context.Context) error {
	queryCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
	defer cancel()

	return s.repo.Query(queryCtx)
}

正しいメンタルモデルは単純です:リクエスト全体には1つの外側の予算があり、特定のサブ操作はその予算から切り出されたより小さな予算を持つことができ、どのレイヤーも呼び出し者が意図した範囲を超えてリクエストを静かに拡張してはいけません。

ctx.Err()をチェックしてキャンセルとタイムアウトを区別する

コンテキストが終了すると、ctx.Err() は理由を返します。

通常は次のうちの1つです:

context.Canceled
context.DeadlineExceeded

例:

select {
case <-ctx.Done():
	return ctx.Err()
case result := <-resultCh:
	return handle(result)
}

これにより、呼び出し側はキャンセルとタイムアウトを区別できます。この区別は実務上で重要です。キャンセルされたリクエストは多くの場合、クライアントが切断したことを意味し、デッドライン超過エラーは通常、サービスが遅すぎたことを意味します。これらを常に同じようにログに記録したり、リトライしたり、報告したりするべきではありません。

より良いキャンセル理由のために context.Cause を使用する

現代のGoは、原因認識型(cause-aware)のキャンセルもサポートしています。

有用な関数には以下が含まれます:

  • context.WithCancelCause
  • context.WithTimeoutCause
  • context.WithDeadlineCause
  • context.Cause

通常の ctx.Err() は、広範な理由(キャンセルまたはデッドライン超過)を伝えます。

context.Cause(ctx) は、より具体的な原因を伝えることができます。

例:

var ErrShutdown = errors.New("server shutting down")

func Run(ctx context.Context) error {
	ctx, cancel := context.WithCancelCause(ctx)
	defer cancel(nil)

	go func() {
		// Some shutdown signal arrived.
		cancel(ErrShutdown)
	}()

	<-ctx.Done()

	return context.Cause(ctx)
}

理由が呼び出し側、ログ、またはクリーンアップ動作にとって重要である場合に原因認識型キャンセルを使用し、通常の ctx.Err() で十分な場合はそれを回避してください。追加の詳細は、診断が実際にそれを必要とする場合にのみ価値があります。

HTTPサーバーの例

通常のHTTPハンドラは r.Context() から始めるべきです。Go HTTPサービス構造化の完全なガイドについては、GoでのREST APIの構築 を参照してください。

func GetUserHandler(svc *UserService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		id := r.PathValue("id")

		user, err := svc.GetUser(ctx, id)
		if err != nil {
			writeError(w, err)
			return
		}

		writeJSON(w, http.StatusOK, user)
	}
}

サービスはコンテキストを受け入れ、伝播させるべきです:

type UserService struct {
	repo *UserRepository
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(ctx, id)
}

リポジトリはコンテキスト対応のデータベースメソッドを使用すべきです:

type UserRepository struct {
	db *sql.DB
}

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
	)
	if err != nil {
		return nil, err
	}

	return &user, nil
}

重要なのはチェーンです。各レイヤーが同じコンテキストを次のレイヤーに渡します:

flowchart TD A[HTTP request context] --> B[Handler] B --> C[Service] C --> D[Repository] D --> E[Database query]

context.Background() を中途で作成してチェーンを壊さないでください。

context.Background() のミス:キャンセルチェーンの破壊

これは一般的なバグです:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(context.Background(), id)
}

これにより、呼び出し側からのすべてのキャンセルとデッドライン情報が破棄されます。クライアントが切断しても、データベースクエリは実行され続けます。リクエストがタイムアウトしても、下流の作業は進行中かもしれません。サーバーがシャットダウン中でも、このコードはそれを完全に無視します。ビジネスロジック内で受信したコンテキストを context.Background() で置き換えることは、ほぼ常に誤りです。

与えられたコンテキストを使用してください:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(ctx, id)
}

context.Background() は、親コンテキストが存在しないエッジでのみ使用してください。

HTTPクライアントの例

外向きのHTTPリクエストの場合、コンテキストをリクエストにアタッチします。

func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, err
	}

	return client.Do(req)
}

以下のようにしないでください:

req, err := http.NewRequest(http.MethodGet, endpoint, nil)

これにより、操作コンテキストなしのリクエストが作成されます。

また、http.Client.Timeout のみに依存するのを避けてください。それは安全制限として有用ですが、リクエストコンテキストは呼び出しチェーン全体でより良い伝播を提供します。

一般的なパターンは以下の通りです:

func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, err
	}

	return client.Do(req)
}

下流のAPI呼び出しが大きなリクエスト内で特定の予算を持つ場合にこれを使用します。

データベースの例

ほとんどのGoデータベースAPIにはコンテキスト対応のメソッドがあります。GORM、Ent、Bun、sqlcを含むGoデータアクセスクライブラリがコンテキストをどのように処理するかについて広範囲な視点については、PostgreSQL用Go ORMの比較 を参照してください。

それらを使用してください。

良い例:

rows, err := db.QueryContext(ctx, query, args...)

良い例:

err := db.QueryRowContext(ctx, query, id).Scan(&name)

良い例:

result, err := db.ExecContext(ctx, query, args...)

悪い例:

rows, err := db.Query(query, args...)

コンテキスト対応の形式は、リクエストがキャンセルされたりタイムアウトしたりしたときにデータベース操作を停止させることを可能にします。これは、遅いクエリ、過負荷のデータベース、およびレイテンシが直接ユーザー体験に影響するユーザー向けAPIにおいて特に重要です。

トランザクションとコンテキスト

トランザクションは注意深いコンテキスト処理が必要です。

トランザクションは通常、操作コンテキストで開始されるべきです:

tx, err := db.BeginTx(ctx, nil)
if err != nil {
	return err
}
defer tx.Rollback()

そしてトランザクション操作に同じコンテキストを使用します:

if _, err := tx.ExecContext(ctx, query, args...); err != nil {
	return err
}

if err := tx.Commit(); err != nil {
	return err
}

トランザクション周りのタイムアウトには注意してください。コンテキストが Commit の前にキャンセルされると、トランザクションはロールバックされる可能性があります。それが望んでいることかもしれませんが、意図的であるべきです。

長いトランザクションの場合、より良い回答は通常、より長いタイムアウトではなく、単位あたりにより少ない作業を行うより短いトランザクションです。

バックグラウンドワーカーとコンテキスト

バックグラウンドワーカーは、そのライフタイムを表すコンテキストを受け取るべきです。

例:

type Worker struct {
	logger *slog.Logger
}

func (w *Worker) Run(ctx context.Context) error {
	ticker := time.NewTicker(10 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()

		case <-ticker.C:
			if err := w.doOnce(ctx); err != nil {
				w.logger.Error("worker iteration failed", "err", err)
			}
		}
	}
}

このワーカーは、コンテキストがキャンセルされるとクリーンに停止し、そのタイマーは defer ticker.Stop() によって適切にクリーンアップされます。main では、OSシグナルに紐付けられたルートコンテキストを作成します:

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	worker := &Worker{logger: slog.Default()}

	if err := worker.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
		slog.Error("worker stopped", "err", err)
	}
}

これはコンテキストの正しい使用法です:それはプロセス作業のライフタイムを記述し、OSがシグナルを送信すると、このコンテキストを共有するゴルーチンのツリー全体が一緒に停止します。

コンテキストキャンセルによるゴルーチンリークの防止

ゴルーチンリークは、ゴルーチンがもはや有用でない後も永久にブロックされたままになる場合に発生します。

コンテキストはこれを防止するのに役立ちます。

悪い例:

func StartWorker() {
	go func() {
		for {
			doWork()
			time.Sleep(time.Second)
		}
	}()
}

このゴルーチンにはシャットダウンパスがありません。

より良い例:

func StartWorker(ctx context.Context) {
	go func() {
		ticker := time.NewTicker(time.Second)
		defer ticker.Stop()

		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				doWork()
			}
		}
	}()
}

ループするゴルーチンは、ほぼ常にキャンセルパスを持つべきです。

これは必ずしもすべてのゴルーチンが直接コンテキストを受け取る必要があるという意味ではありませんが、システムはそれを停止する明確な方法を持つべきです。

context.AfterFunc

context.AfterFunc は、コンテキストがキャンセルされた後に関数を実行します。

クリーンアップ、ブロッキング操作の解除、またはネイティブにコンテキストをサポートしていないAPIのブリッジに有用です。

例:

func waitWithContext(ctx context.Context, ch <-chan struct{}) error {
	stop := context.AfterFunc(ctx, func() {
		// Wake up or clean up if needed.
	})
	defer stop()

	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-ch:
		return nil
	}
}

AfterFunc は慎重に使用してください。それはキャンセルが発生したときにロジックを開始するため、制御フローを追うのが難しくなる可能性があります。ほとんどのアプリケーションコードでは、ctx.Done() 上の通常の select の方が明確で推論しやすいです。AfterFunc は、コンテキストキャンセルを既にコンテキストを受け付けないAPIに適応させる必要がある場合に最も価値があります。

context.WithoutCancel

context.WithoutCancel は、親がキャンセルされてもキャンセルされないコンテキストを作成します。

これは有用ですが、誤用も容易です。

使用例:

func Handler(audit *AuditLog) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		// Handle request...
		_ = ctx

		auditCtx := context.WithoutCancel(ctx)

		go func() {
			ctx, cancel := context.WithTimeout(auditCtx, 2*time.Second)
			defer cancel()

			_ = audit.Write(ctx, "request completed")
		}()
	}
}

アイデアは、監査書き込みはリクエストコンテキストがキャンセルされた後も少し続行する必要があるかもしれない、というものです。これは稀で意図的であるべきです。キャンセルに対処しない方法として WithoutCancel を使用しないでください。子作業が実際に親のキャンセルを超えて存在する必要がある場合にのみ使用し、常に新しいタイムアウトを追加してください。キャンセルを無視しますがデッドラインを持たないコンテキストは、容易にバックグラウンドゴルーチンのリークを作成します。

コンテキスト値の正しい使い方

コンテキスト値は、API境界を越えるリクエストスコープのデータのためのものです。

良い例:

  • リクエストID
  • トレースID
  • 認証済みユーザーID
  • テナントID
  • ロケール
  • セキュリティプリンシパル
  • 相関メタデータ

悪い例:

  • データベース接続
  • 隠れた依存関係としてのロガー
  • 通常の制御フロー用の機能フラグ
  • オプションの関数パラメータ
  • 設定
  • サービスクライアント

有用なルール:値がリクエストのアイデンティティまたは可観測性コンテキストの一部である場合、それはコンテキストに属するかもしれません。コードがその仕事を完了するために必要な依存関係である場合、明示的に渡してください。

コンテキスト値に型付きキーを使用する

コンテキストキーとして平文の文字列を使用しないでください。

悪い例:

ctx = context.WithValue(ctx, "userID", "123")

これは他のパッケージと衝突する可能性があります。

エクスポートされていないカスタムキー型を使用します:

type userIDKey struct{}

func WithUserID(ctx context.Context, userID string) context.Context {
	return context.WithValue(ctx, userIDKey{}, userID)
}

func UserIDFromContext(ctx context.Context) (string, bool) {
	userID, ok := ctx.Value(userIDKey{}).(string)
	return userID, ok
}

このパターンは、パッケージ境界での型安全性を提供し、他のパッケージとのキーの衝突を回避し、型付きアクセサ関数でコンテキストAPIサーフェスをクリーンに保ちます。

オプションパラメータにコンテキスト値を使用しない

これは悪い例です:

ctx = context.WithValue(ctx, "pageSize", 100)
users, err := repo.ListUsers(ctx)

これにより、関数契約が隠されます。

明示的なパラメータを優先してください:

users, err := repo.ListUsers(ctx, ListUsersOptions{
	PageSize: 100,
})

コンテキスト値は関数引数を置き換えるべきではありません。隠れた入力は、コードを理解し、テストし、レビューすることを難しくします。関数シグネチャを読んでいる人は、パラメータが存在することさえ気づきません。

ログとコンテキスト

コンテキストを使用したログ記録には2つの一般的なアプローチがあります。ここでの例はGoの log/slog パッケージを使用しています。本番環境でのslogを使用した構造化ログ記録について深く掘り下げるには、Goでのslogによる構造化ログ記録 を参照してください。

アプローチ1:値を抽出してログに添付する

func LogRequest(ctx context.Context, logger *slog.Logger, msg string) {
	if requestID, ok := RequestIDFromContext(ctx); ok {
		logger = logger.With("request_id", requestID)
	}

	logger.Info(msg)
}

これにより、ロガーは適切な依存関係として明示的に保たれ、コンテキストは正当にAPI境界を越える必要があるリクエストスコープの値にのみ使用されます。

アプローチ2:コンテキストにロガーを格納する

一部のコードベースでは、コンテキストにロガーを格納します。

これは便利ですが、デフォルトとして推奨しません。コンテキストを依存関係コンテナに変えてしまいます。

私の好み:

  • ロガー依存関係を明示的に渡す。
  • トレースIDとリクエストIDをコンテキストに格納する。
  • 境界またはミドルウェアでログにそれらの値を追加する。

これにより、依存関係が可視になります。

コンテキストとトレーシング

トレーシングはコンテキスト値の最も強力な使用ケースの一つであり、本当に良い適合です。OpenTelemetryや類似システムは、トレースデータがコンテキストが運ぶために設計されたまさにその種のリクエストスコープメタデータであるため、関数呼び出しとプロセス境界を超えてトレーススパンを伝播するためにコンテキストを使用します。

典型的なパターンは以下のようになります:

func (s *Service) DoWork(ctx context.Context) error {
	ctx, span := s.tracer.Start(ctx, "Service.DoWork")
	defer span.End()

	return s.repo.Query(ctx)
}

コンテキストはアクティブなトレーススパンを持ち、リポジトリはそれから子スパンを作成できます。各レイヤーは、トレーサーオブジェクトの明示的な渡しなしに、自分自身のスパンを追加します。コンテキストは呼び出しツリー全体でその作業を透明に行います。

エラーハンドリングとコンテキスト

コンテキストキャンセルのために操作が停止した場合、その情報を保持します。ここでのパターンは、Goエラーハンドリングアーキテクチャ でカバーされている広範なエラー設計戦略を補完します。

例:

err := svc.DoWork(ctx)
if err != nil {
	if errors.Is(err, context.Canceled) {
		// Client canceled or caller stopped the work.
		return err
	}

	if errors.Is(err, context.DeadlineExceeded) {
		// Timeout.
		return err
	}

	return err
}

コンテキストエラーを隠す方法で盲目的にラップしないでください。

%w でラップすると errors.Is が保持されるため、呼び出し側は依然としてキャンセルまたはタイムアウトを検出できます:

if err != nil {
	return fmt.Errorf("query user: %w", err)
}

エラーを完全に置き換えると、その情報が破棄され、特定のコンテキストエラータイプをチェックする呼び出し側が壊れます:

if err != nil {
	return errors.New("query user failed")
}

HTTPレスポンスへのコンテキストエラーのマッピング

コンテキストエラーはしばしば異なるHTTP結果にマッピングされます。

例:

func writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, context.Canceled):
		// The client likely went away.
		// Some systems log this as a client closed request.
		return

	case errors.Is(err, context.DeadlineExceeded):
		http.Error(w, "request timed out", http.StatusGatewayTimeout)
		return

	default:
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}
}

クライアントのキャンセルをアプリケーションの失敗として扱わないでください。ユーザーがブラウザのタブを閉じた場合、それはあなたのサービスが誤動作しているわけではなく、それをエラーとしてログに記録するのはシグナルなしのノイズを追加するだけです。

ミドルウェアでのコンテキスト

HTTPミドルウェアは、リクエストスコープの値を追加する一般的な場所です。

リクエストIDミドルウェアの例:

type requestIDKey struct{}

func WithRequestID(ctx context.Context, requestID string) context.Context {
	return context.WithValue(ctx, requestIDKey{}, requestID)
}

func RequestIDFromContext(ctx context.Context) (string, bool) {
	requestID, ok := ctx.Value(requestIDKey{}).(string)
	return requestID, ok
}

func RequestIDMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		requestID := r.Header.Get("X-Request-ID")
		if requestID == "" {
			requestID = newRequestID()
		}

		ctx := WithRequestID(r.Context(), requestID)

		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

これはコンテキストの良い使用法です。リクエストIDはリクエストに属し、完全な呼び出しチェーンを通過し、各レイヤーでログとトレースに添付することは、コンテキスト値がサポートするために設計されたまさにその種の横断的な可観測性懸念です。

テストでのコンテキスト

テストでは、盲目的に context.Background() を使用しないでください。

作業がテストのライフタイムに属する場合、t.Context() を優先してください:

func TestService(t *testing.T) {
	ctx := t.Context()

	err := service.DoWork(ctx)
	if err != nil {
		t.Fatal(err)
	}
}

タイムアウト動作の場合、タイムアウトが小さく意味のある場合にのみ、実際のタイムアウトでテストします。

並行および時間依存コードの場合、testing/synctest の使用を検討してください。synctestによる並行Goコードのテスト はこのツールを深くカバーしています:

func TestTimeout(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
		defer cancel()

		time.Sleep(30 * time.Second)

		if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
			t.Fatalf("got %v, want deadline exceeded", ctx.Err())
		}
	})
}

これにより、実際の時間を待つことなく、実際のタイムアウト値をテストできます。

コンテキストと errgroup

一緒にキャンセルされるべきゴルーチンのグループの場合、errgroup はしばしば良い適合です。

例:

func FetchAll(ctx context.Context, ids []string, client *Client) error {
	g, ctx := errgroup.WithContext(ctx)

	for _, id := range ids {
		id := id

		g.Go(func() error {
			_, err := client.Fetch(ctx, id)
			return err
		})
	}

	return g.Wait()
}

1つのゴルーチンがエラーを返すと、グループコンテキストはキャンセルされ、ctx.Done() を尊重する他のゴルーチンは早期に停止できます。これは、複数のゴルーチン、チャンネル、およびキャンセルパスを手動で管理するよりもはるかにクリーンです。ここでの重要なフレーズは「コンテキストを尊重する」です。errgroupは ctx.Done() を無視する作業を停止できません。

グレースフルシャットダウン

コンテキストはグレースフルシャットダウンの中心です。

典型的なサーバー設定には以下があります:

  • OSシグナルによってキャンセルされるルートコンテキスト
  • HTTPサーバー
  • バックグラウンドワーカー
  • シャットダウンタイムアウト
  • クリーンアップロジック

例:

func main() {
	root, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	server := &http.Server{
		Addr:    ":8080",
		Handler: routes(),
	}

	go func() {
		<-root.Done()

		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()

		if err := server.Shutdown(shutdownCtx); err != nil {
			slog.Error("server shutdown failed", "err", err)
		}
	}()

	if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		slog.Error("server failed", "err", err)
		os.Exit(1)
	}
}

シャットダウンコンテキストがルートコンテキストと同じではないことに注意してください。OSシグナルが届くと、ルートはすでにキャンセルされています。別のタイムアウトコンテキストは、シャットダウンプロセスに強制終了する前に進行中リクエストをドレインするための限定された時間を与え、これはグレースフルシャットダウンを実際に機能させる微妙だが重要な区別です。

一般的なアンチパターン

アンチパターン1:コンテキストを依存関係コンテナとして使用

悪い例:

ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "logger", logger)
ctx = context.WithValue(ctx, "config", cfg)

依存関係を明示的に渡してください。

アンチパターン2:ビジネスロジック内で context.Background を作成

悪い例:

func (s *Service) DoWork(ctx context.Context) error {
	return s.repo.Save(context.Background())
}

これにより、キャンセル伝播が壊れます。

アンチパターン3:cancelを忘れる

悪い例:

ctx, _ := context.WithTimeout(parent, time.Second)

良い例:

ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()

アンチパターン4:コンテキストにオプションパラメータを配置

悪い例:

ctx = context.WithValue(ctx, "includeDeleted", true)

明示的なオプション構造体を使用してください。

アンチパターン5:純粋なコードに深くコンテキストを渡す

悪い例:

func Add(ctx context.Context, a, b int) int {
	return a + b
}

長時間実行またはキャンセル可能なものでない限り、純粋な計算にはコンテキストは必要ありません。

アンチパターン6:ループ内でキャンセルを無視

悪い例:

for item := range items {
	process(item)
}

より良い例:

for item := range items {
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	if err := process(ctx, item); err != nil {
		return err
	}
}

アンチパターン7:コンテキストエラーを飲み込む

悪い例:

if err != nil {
	return errors.New("operation failed")
}

良い例:

if err != nil {
	return fmt.Errorf("operation failed: %w", err)
}

キャンセルとデッドラインエラーを保持してください。

実用的なコンテキストチェックリスト

Goバックエンドコードにこのチェックリストを使用してください。

関数シグネチャ

  • コンテキストは最初の引数です。
  • コンテキストは長寿命の構造体に格納されません。
  • 必要な場合に限り、コンテキストは純粋なヘルパー関数に渡されません。
  • nilコンテキストは決して使用されません。

キャンセル

  • 長時間実行するループは ctx.Done() をチェックします。
  • ゴルーチンにはシャットダウンパスがあります。
  • ワーカーのライフタイムは親コンテキストに紐付けられます。
  • コンテキストキャンセルは下流呼び出しに伝播されます。

タイムアウト

  • 外側のリクエストタイムアウトは境界で設定されます。
  • サブ操作のタイムアウトは外側の予算よりも小さいです。
  • cancel関数は常に呼び出されます。
  • タイムアウトは各レイヤーで盲目的に積み重ねられません。

  • コンテキスト値はリクエストスコープです。
  • キーはカスタム型を使用し、平文の文字列は使用しません。
  • 依存関係はコンテキストに格納されません。
  • オプションパラメータはコンテキストに格納されません。

エラー

  • context.Canceledcontext.DeadlineExceeded が保持されます。
  • コンテキストエラーはAPI境界で正しくマッピングされます。
  • 原因認識型キャンセルは、理由が重要な場合にのみ使用されます。

テスト

  • テストは適切な場所で t.Context() を使用します。
  • タイムアウトテストは遅い実時間のスリープを回避します。
  • 並行タイムアウト動作は、有用な場合に testing/synctest でテストされます。
  • シャットダウンパスが存在することを確認して、ゴルーチンリークがチェックされます。

Goコードベースでのコンテキスト使用の監査方法

これらのパターンを検索してください:

grep -R "context.Background()" .
grep -R "context.TODO()" .
grep -R "WithTimeout" .
grep -R "WithCancel" .
grep -R "WithValue" .
grep -R "type .* struct" .

そして問いかけてください:

  • context.Background() はトップレベルの境界でのみ使用されていますか?
  • cancel関数は常に呼び出されていますか?
  • タイムアウトは適切な境界に配置されていますか?
  • コンテキスト値は本当にリクエストスコープですか?
  • 依存関係はコンテキスト値に隠されていますか?
  • ゴルーチンは停止可能ですか?
  • コンテキストエラーは保持されていますか?

これは良いコードレビュー習慣です。なぜなら、多くのコンテキストバグは構文バグではなく、キャンセル、負荷、またはシャットダウン条件下でのみ表面化するライフタイムバグだからです。

私の意見的なルール

これらのルールは退屈ですが、効果的です。

ルール1:コンテキストは制御フローです

コンテキストを使用して、キャンセル、デッドライン、およびリクエストメタデータを制御します。

依存関係を密かに運ぶために使用しないでください。

ルール2:呼び出し側が予算を所有します

関数は、通常、受け取ったコンテキストを尊重するべきです。

サブ操作が特定の小さな予算を必要とする場合にのみ、より短い子タイムアウトを作成してください。

ルール3:Backgroundはエッジに属します

main、テスト、およびトップレベル設定で context.Background() を使用します。

キャンセルを逃れるために、サービスおよびリポジトリメソッド内で使用しないでください。

ルール4:値は退屈であるべきです

リクエストID、トレースID、ユーザーID、およびテナントIDはコンテキストに属します。データベース接続、ロガー、設定構造体、およびサービスクライアントは属しません。それらは依存関係であり、明示的に渡されるべきです。

ルール5:すべてのゴルーチンはライフタイムを必要とします

ゴルーチンが開始されると、それがどのように停止するかを正確に知るべきです。コンテキストが多くの場合正しい回答ですが、コンテキストでない場合でも、チャンネル、同期プリミティブ、または明示的なシグナルのような他の明確なメカニズムがあるべきです。

最終的な考察

context.Context はAPIが大きいから複雑なのではありません。APIは小さいのです。それはライフタイムを表すから複雑です。そしてライフタイムはアーキテクチャです。コンテキストがどこで流れ、どこで派生し、どこで停止するかに関するすべての決定は、サービスが失敗、負荷、およびシャットダウンをどのように処理するかに関する決定です。

適切に使用されたコンテキストは、Goサービスをキャンセルしやすくし、シャットダウンしやすくし、可観測しやすくし、ゴルーチンのリークを少なくします。誤って使用されたコンテキストは、依存関係を隠し、デッドラインを破棄し、圧力下でのコードの推論を難しくします。

実用的な要点は単純です:

コンテキストを下に渡します。
それを格納しないでください。
値で明示的なパラメータを置き換えないでください。
キャンセルを尊重します。
境界でタイムアウトを使用します。
常にcancelを呼び出します。

これがGoコンテキストの正しい使い方です。

この記事は、本番環境でのアプリケーションアーキテクチャ クラスタの一部であり、本番環境GoおよびPythonシステムのコード構造、データアクセス、統合パターン、およびテストアーキテクチャをカバーしています。

出典

購読する

システム、インフラ、AIエンジニアリングの新記事をお届けします。