Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

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』