Goのcontext.Contextに入れる値をリクエストスコープに限る理由

こんにちは!
京都開発本部テクニカルアーキテクトグループの櫻(@ysakura_)です。
クラウド会計Plusの性能問題の解決を担当しています。

先日Goの開発をしていて、 context.Contextに入れる値をリクエストスコープに限るべき理由をパッと説明できない事がありました。
そこで、自分なりの意見を纏めてみました。

 

はじめに

contextパッケージのコメントによると、Contextに入れる値はリクエストスコープなものに限るべきとされています。

// Use context Values only for request-scoped data that transits processes and
// APIs, not for passing optional parameters to functions.

contextの値は、プロセスやAPIを通過するリクエストスコープなデータに限って使ってください。
関数にオプショナルなパラメータを渡す目的で使うべきではありません。

 

Contextに入れる値を限定するべき背景

大きな理由として、ContextにおけるValueの扱いが難しい事が挙げられます。
Contextを使う必要がなければ、グローバル変数や構造体で値を持つ方が良いです。
扱いが難しい点に関して、以下の2点を取り上げます。

  • Valueの型がinterface{}である事
  • 推奨されるValueへのアクセス方法

 

Valueの型がinterface{}

ContextにはKey-Valueの形式で値を入れる必要があります。
その際の型は、空のインターフェース(interface{})になります。

func WithValue(parent Context, key interface{}, val interface{}) Context
Value(key interface{}) interface{}

その為、具体的な型の情報にアクセスするには、interfaceを型に変換するType Assertionが必要になります。
以下の2つの観点で、扱いが難しいです。

  • Type Assertionでは二つめの戻り値を取らない場合に変換が失敗するとpanicする
  • Type Assertionのbool checkが入る分、コードが複雑になる

 

推奨されるValueへのアクセス方法

ここでもGoのコメントを元に話します。

// A key identifies a specific value in a Context. Functions that wish
// to store values in Context typically allocate a key in a global
// variable then use that key as the argument to context.WithValue and
// Context.Value. A key can be any type that supports equality;
// packages should define keys as an unexported type to avoid
// collisions.

// Packages that define a Context key should provide type-safe accessors
// for the values stored using that key:

KeyはContextの特定のValueを識別します。
ContextにValueを格納したい関数は、通常グローバル変数にKeyを割り当て、context.WithValueContext.Valueの引数としてそのKeyを使います。
KeyにはEqualityをサポートする任意の型が使えます。
各パッケージで、衝突を避ける為にKeyを非公開な型として定義するべきです。

ContextのKeyを定義するパッケージでは、格納されたValueに対するtype-safeなアクセサーを、そのKeyを使って提供すべきです。

contextパッケージのコメントでは以下の例が挙げられています。
Keyの衝突を避ける為に、unexporeted(非公開)な形で型を定義しています。
NewContext, FromContextの様に、Contextへの値の格納・取得を関数経由にする事が推奨されます。
この点で扱いが複雑になります。

// Package user defines a User type that's stored in Contexts.
package user

import "context"

// User is the type of value stored in the Contexts.
type User struct {...}

// key is an unexported type for keys defined in this package.
// This prevents collisions with keys defined in other packages.
type key int

// userKey is the key for user.User values in Contexts. It is
// unexported; clients use user.NewContext and user.FromContext
// instead of using this key directly.
var userKey key

// NewContext returns a new Context that carries value u.
func NewContext(ctx context.Context, u *User) context.Context {
    return context.WithValue(ctx, userKey, u)
}

// FromContext returns the User value stored in ctx, if any.
func FromContext(ctx context.Context) (*User, bool) {
    u, ok := ctx.Value(userKey).(*User)
    return u, ok
}

 

リクエストスコープなデータがContextに向いている理由

Contextに入れる値は限定すべきという話をしたので、次はリクエストスコープなデータが向いている理由についてです。
これはContextがそうなる様に設計された、と自分は思います。
Goの標準パッケージを元にそれを紹介します。

 

Contextのライフサイクルがリクエストと同じ事

Goのnet/httpRequest構造体では以下の通り、context.Contextをフィールドに持ちます。

type Request struct {
    (中略)
    // ctx is either the client or server context. It should only
    // be modified via copying the whole Request using WithContext.
    // It is unexported to prevent people from using Context wrong
    // and mutating the contexts held by callers of the same request.
    ctx context.Context
}

net/httpのサーバーでは、リクエストは個別のGoroutineで処理されます。このGoroutine内部で新規のContextがRequest構造体に付与される為、ライフサイクルがリクエストと同じになります。
その為、リクエストスコープな値の管理には向いています。
一方で、ライフサイクルがリクエストと同じでないloggerなどはContextに入れるには適していません。

Contextの利用例

実際にリクエストスコープでContextのValueが使われている例を紹介します。

MiddlewareでContextに値を入れる

HTTPサーバーのmiddleware chainの例として、middleware内で何かしらの処理を行いその情報をContextに入れる事があります。

例) ユーザーの認証認可を行い、ユーザー情報をContextに詰めるMiddleware

package user

import (
    "context"
    "github.com/pkg/errors"
)

type contextKey string

const userKey contextKey = "user"

// ContextWithUser  ユーザー情報をコンテキストにセット
func ContextWithUser(parent context.Context, user *User) context.Context {
    return context.WithValue(parent, userKey, user)
}

// UserFromContext ユーザー情報をコンテキストから取り出す
func UserFromContext(ctx context.Context) (*User, error) {
    v := ctx.Value(userKey)
    user, ok := v.(*User)
    if !ok {
        return nil, errors.New("user not found")
    }
    return user, nil
}

package middleware

import (
    "fmt"
    "net/http"
    "mymodule/user"
)

// Auth 認可ミドルウェア
func Auth(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        authKey := r.Header.Get("Authorization")
        ctx := r.Context()
        u, err := user.Authorize(authKey)
        if err != nil {
            w.WriteHeader(http.StatusUnauthorized)
            fmt.Fprint(w, "UnAuthorized")
            return
        }
        ctx = user.ContextWithUser(ctx, u)
        next.ServeHTTP(w, r.WithContext(ctx))
    }
}

 

grpc/grpc-goのmetadata

gRPCのmetadataがリクエストのContextに含まれます。(詳細)
Contextからmetadataを取得する関数も提供されています。

func (s *server) SomeRPC(ctx context.Context, in *pb.SomeRequest) (*pb.SomeResponse, err) {
    md, ok := metadata.FromIncomingContext(ctx)
    // do something with metadata
}

 

まとめ

context.Contextにはリクエストスコープな値のみを入れましょう。
リクエストスコープではない値は、グローバル変数や構造体に値を持たせるほうが良いでしょう。

 

マネーフォワードでは、エンジニアを募集しています。
ご応募お待ちしています。

【サイトのご案内】
マネーフォワード採用サイト
Wantedly
京都開発拠点

【プロダクトのご紹介】
お金の見える化サービス 『マネーフォワード ME』 iPhone,iPad Android

ビジネス向けバックオフィス向け業務効率化ソリューション 『マネーフォワード クラウド』

おつり貯金アプリ 『しらたま』

お金の悩みを無料で相談 『マネーフォワード お金の相談』

だれでも貯まって増える お金の体質改善サービス 『マネーフォワード おかねせんせい』

金融商品の比較・申し込みサイト 『Money Forward Mall』

くらしの経済メディア 『MONEY PLUS』

Pocket