😎

GoのContextパターンガイド:よくある落とし穴とベストプラクティス

に公開

Contextの基本

そもそもContextって何?

リクエストに関するメタデータの受け渡しや、キャンセル制御などの課題を解決するための仕組みです。

Goにおける並列処理で有名な、ゴルーチンは一意なIDを持っていません。そのため、リクエストをトラッキングできず、個別判定できません。

そこで、contextの出番です。

contextはAPIやプロセスの境界を超えて、次のデータを運搬することができます。

Contextが運搬できるデータと使うタイミング

  • タイムアウト情報 (デッドライン)
    • ある時刻まで、もしくはn秒かかったら処理を止めたいとき。
  • キャンセレーションのシグナル
    • 処理の中断をしたい時。HTTPリクエストがキャンセルされたとき、子処理も止めたい時。
  • 処理に必要な値
    • Cookieに格納されたセッションIDを後続の処理でも参照したいとき

実践 Contextの簡単な例

キャンセレーションの使い方【例あり】(コミット)

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

タイムアウトを設定する【例あり】 (コミット)

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

親のコンテキストTimeout設定で2秒、子のコンテキストTimeout設定で5秒設定した場合、親で2秒Timeoutでcancelが発火し、子のコンテキストもcancelとなる。

select {
		case <-time.After(5 * time.Second):
			log.Println("end")
			return c.String(http.StatusOK, "done")
		case <-ctx.Done():
			end := time.Now()
			log.Println("canceled")
			log.Println(end.Sub(start))
			return ctx.Err()
	}

ctxがキャンセルとなると、 ctx.Done() の処理に入る

コンテキストで値を受け渡す【例あり】 (ファイル)

context.WithValueは、認証情報やトレーシングIDのような横断的関心事(cross-cutting concerns) を運ぶ手段として便利です。しかし、それがドメインロジックの中核に入り込むと問題になります。


// 値を受け取って使う側
func logWithRequestID(ctx context.Context, message string) {
	if reqID, ok := ctx.Value(contextKey("request_id")).(string); ok {
		log.Printf("[reqID=%s] %s", reqID, message)
	} else {
		log.Println(message)
	}
}

// 値を渡す側
func handlerTrace(c echo.Context) error {
	ctx := context.WithValue(c.Request().Context(), contextKey("request_id"), "abc_123")
	logWithRequestID(ctx, "StartLogTraceHandler")
	return c.String(http.StatusOK, "Logged with request ID")
}

Context管理のベストプラクティス

  • ✅ context.WithValue キー設定
    • コンテキストにセットするキーは独自の型を作成するのがBEST
      • contextにセットする値は、同様のキーの型・名称の場合上書きされるためです
  • ✅ context.WithValueのユースケース
    • CookieやセッションIDやリクエストメタデータ、リクエスト追跡用のID共有に使うのがベスト
package main

import (
	"context"
	"github.com/labstack/echo/v4"
	"log"
	"net/http"
)

type contextKey string

func logWithRequestID(ctx context.Context, message string) {
	if reqID, ok := ctx.Value(contextKey("request_id")).(string); ok {
		log.Printf("[reqID=%s] %s", reqID, message)
	} else {
		log.Println(message)
	}
}

func handlerTrace(c echo.Context) error {
	ctx := context.WithValue(c.Request().Context(), contextKey("request_id"), "abc_123")
	logWithRequestID(ctx, "StartLogTraceHandler")
	return c.String(http.StatusOK, "Logged with request ID")
}

func main() {
	e := echo.New()
	e.GET("/", handlerTrace)
	if err := e.Start(":9010"); err != nil {
		log.Fatalf(err.Error())
	}
}

  • ✅ コンテキストの共有
    • コンテキストは1リクエスト1つを共有する
      • キャンセルシグナルが発生した際に共通化でき、不要な処理を中断することで、リソースの効率化につながります
package main

import (
	"context"
	"fmt"
	"github.com/labstack/echo/v4"
	"log"
	"net/http"
	"time"
)

func slowOperation(ctx context.Context) (string, error) {
	select {
	case <-time.After(5 * time.Second):
		return "success", nil
	case <-ctx.Done():
		return "failed", ctx.Err()
	}
}

func handler(c echo.Context) error {
	ctx := c.Request().Context()
	result, err := slowOperation(ctx)
	if err != nil {
		fmt.Println("canceled")
		return c.String(http.StatusRequestTimeout, "request canceled : "+err.Error())
	}
	return c.String(http.StatusOK, result)
}

func main() {
	e := echo.New()
	e.GET("/", handler)
	if err := e.Start(":9010"); err != nil {
		log.Fatalf(err.Error())
	}
}

Context管理のバッドプラクティス

  • ❌ context.WithValue キー設定
    • キー名をstring型で指定している
      • 他の処理でctxに値を格納した際に、キー名が重複した場合、値が上書きされる可能性が高く、バグを生む危険性が高まります
  • ❌ context.WithValueのユースケース
    • ビジネスロジックにctxをそのまま渡し、値を参照している
      • ビジネスロジックがctxに処理を依存してしまうため、テストの実装が難しくなり保守性が下がります
package main

import (
	"context"
	"errors"
	"fmt"
	"github.com/labstack/echo/v4"
	"net/http"
)

func businessLogic(c context.Context) error {
	if userID, ok := c.Value("user_id").(string); ok {
		fmt.Printf("userID={%s}", userID)
		return nil
	} else {
		fmt.Printf("user id none")
		return errors.New("user id none")
	}
}

func handler(c echo.Context) error {
	// key is not correct.
	ctx := context.WithValue(c.Request().Context(), "user_id", "bad_practice_name")
	// ctx send to business logic
	if err := businessLogic(ctx); err != nil {
		return c.String(http.StatusInternalServerError, err.Error())
	}
	return c.String(http.StatusOK, "success")
}

func main() {
	e := echo.New() // with value 値の重複
	e.GET("/", handler)
	e.Start(":9011")
}

参考

Discussion