⛑️
GoでContextに値を格納するときに潜むinterfaceとnilの落とし穴
📝 はじめに
Web サービスでは、リクエストスコープの情報(認証ユーザー、テナント、権限、トレーシング情報など)を context.Context に積んで各レイヤに渡すのが一般的です。
便利な一方で、interface と nil が絡む「typed-nil」問題が潜んでおり、存在判定を誤るとバグにつながります。
ここでは サービスでよく使われる「認証ユーザー(Principal)」 を例に、
- Context に格納する値の設計
-
interface × nilの落とし穴 - 実装 A/B の比較と安全な代替案
- 実務向けのコード規約
をまとめます。
✅ まずインターフェース定義を明示する(Principal)
実例として 認証ユーザー(Principal) を用います。まず、インターフェース定義を明示します。
// package auth は認証関連の境界(型とコンテキスト操作)を提供する。
package auth
// Principal は「リクエストを実行している主体(認証ユーザー)」を表すドメイン境界。
// 呼び出し側はこのインターフェースだけに依存し、実装に依存しない。
type Principal interface {
UserID() string
TenantID() string
Roles() []string
IsActive() bool
}
具体型(非公開)
package auth
type principal struct {
userID string
tenantID string
roles []string
active bool
}
// NewPrincipal は Principal の具象を生成するファクトリ。
func NewPrincipal(userID, tenantID string, roles []string, active bool) Principal {
return &principal{
userID: userID,
tenantID: tenantID,
roles: append([]string(nil), roles...),
active: active,
}
}
func (p *principal) UserID() string { return p.userID }
func (p *principal) TenantID() string { return p.tenantID }
func (p *principal) Roles() []string { return append([]string(nil), p.roles...) }
func (p *principal) IsActive() bool { return p.active }
🔑 Context key は「未公開の専用型」を使う(Go の定石)
context.WithValue のキーには、衝突防止のため未公開の専用型を使います。
package auth
import "context"
// Context key は未公開のゼロサイズ型を用いると、衝突回避と意図の明確化ができます。
// メモリ面でも追加のペイロードを持たず、実務上の差はごく小さいものの分かりやすさの利点があります。
type principalKeyType struct{}
var principalKey principalKeyType
// WithPrincipal は Principal を context に格納して返す。
// 規約として nil を格納しない。ただし interface の性質上ここでは typed-nil を検知できないため、呼び出し側で
// NewPrincipal 等により non-nil を渡すポリシーを徹底する。
func WithPrincipal(ctx context.Context, p Principal) context.Context {
if p == nil {
return ctx
}
return context.WithValue(ctx, principalKey, p)
}
⚠️ interface × nil(typed-nil)の落とし穴(超重要)
Go のインターフェース値は 「型 + 値」 のペアです。
var impl *principal = nil
var i Principal = impl
// i は「型= *principal, 値=nil」のペアになる
// よって i == nil は false(インターフェース自体は非 nil)
_ = (i == nil) // => false
つまり 「アサーションに成功したから存在する」とは限らない ことが落とし穴です。
(x.(Principal)) が成功しても、中身が (*principal)(nil) の typed-nil であれば i == nil は false になります。
📦 取り出し実装 A/B とその比較(interface を返す版)
実装 A(シンプルに戻す)
// PrincipalFromContext は Principal と「アサーション成否」を返します。
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(principalKey).(Principal)
return p, ok
}
- ✅ メリット
- シンプルで読みやすいです。
- 値型でも流用可能です。
- ⚠️ デメリット
-
ok == trueでも typed-nil の可能性が残ります。 - 呼び側が
p == nil判定に頼ると誤判定の原因になります(必ずokを見る規約が必要です)。
-
実装 B(未設定時は nil,false を返す)
// PrincipalFromContext は「未設定時に nil,false」を返します。
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(principalKey).(Principal)
if !ok {
return nil, false
}
return p, true
}
- ✅ メリット
- 未設定(キー不一致)を
nil,falseで明確に伝えられます。 - 存在判定を
okに一本化しやすくなります(p == nilに依存しません)。
- 未設定(キー不一致)を
- ⚠️ デメリット
-
typed-nil 問題は依然として残ります(
ok==trueかつ型付き nil)。 - やはり「
okを唯一の存在判定にする」規約が必要です。
-
typed-nil 問題は依然として残ります(
結論:A/B どちらでも「必ず
okを見る」 を徹底する(p == nilに依存しない)。
さらに WithPrincipal で nil を入れない ことが重要。ただし interface の性質上、WithPrincipal 単体では typed-nil を検知・防止できない点に注意。
🛡️ 実務で最も安全な代替:具体型ポインタを返す
interface ではなく 具体型ポインタ を返すと、typed-nil を完全に扱えます。
// 具体型を公開(または別パッケージで非公開でも可)
type PrincipalData struct {
UserID string
TenantID string
Roles []string
Active bool
}
func (p *PrincipalData) Clone() *PrincipalData {
if p == nil { return nil }
cp := *p
cp.Roles = append([]string(nil), p.Roles...)
return &cp
}
type contextKey2 int
const principalDataKey contextKey2 = iota
func WithPrincipalData(ctx context.Context, p *PrincipalData) context.Context {
if p == nil {
return ctx // 規約として nil は格納しない
}
return context.WithValue(ctx, principalDataKey, p)
}
func GetPrincipalData(ctx context.Context) (*PrincipalData, bool) {
p, ok := ctx.Value(principalDataKey).(*PrincipalData)
return p, ok && p != nil // 「型一致」かつ「実体が non-nil」を保証
}
- ✅ 利点
-
ok && p != nilで 未設定を確実に弾けます。 - 呼び側は
if !ok { ... }でもif p == nil { ... }でも安全に判定可能です。 - メソッド呼び出し時の nil レシーバ・パニックも避けやすくなります。
-
🧩 ミドルウェア/ハンドラでの利用例
package httpx
import (
"net/http"
"github.com/yourorg/yourapp/auth"
)
// 認証ミドルウェアの例(擬似コード)
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 例: ヘッダやトークンから主体を解決
p := auth.NewPrincipal("u_123", "t_001", []string{"admin"}, true)
// 規約: nil は入れない
r = r.WithContext(auth.WithPrincipal(r.Context(), p))
next.ServeHTTP(w, r)
})
}
// ハンドラ側
func HandleMe(w http.ResponseWriter, r *http.Request) {
p, ok := auth.PrincipalFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
_ = p.UserID() // 利用前提: ミドルウェアで non-nil を格納(typed-nil を許容しない設計/レビューを徹底)
w.WriteHeader(http.StatusOK)
}
具体型ポインタ版を採用するなら
auth.WithPrincipalData / auth.GetPrincipalDataを同様に使ってください。
📐 チーム規約(サンプル)
-
[must]
XxxFromContext(ctx)の戻り値は、okを唯一の存在判定として使用してください。== nilに頼らないでください。 -
[must]
WithXxx(ctx, v)で nil を格納しないでください。未ログイン等は「格納しない」ことで表現します。 -
[should] 返り値は 具体型ポインタ を優先してください。やむを得ず interface を返す場合は、typed-nil の危険性を理解し、レビューで
okチェックを確認してください。 - [should] Context key は 未公開の専用型 を使ってください。
-
[should] 「存在しない」はエラーではなく
(nil,false)または(_,false)で返す設計を検討してください(ログ汚染防止)。
✅ まとめ
- Context は便利ですが、interface × nil の「typed-nil」問題が潜んでいます。
-
GetXxxではokを見ること、nil を格納しないことが基本方針です。 - 最も安全なのは 具体型ポインタで返す設計です(
ok && p != nil)。 - ミドルウェアでの格納/ハンドラでの取得パターンを整備し、規約としてチームに共有しましょう。
付録:A/B 実装の比較表(interface を返す場合)
| 観点 | A: シンプルに返す | B: 未設定は nil,false |
|---|---|---|
| 読みやすさ | ◎ | ○ |
| 未設定の表現 | (_,false) |
(nil,false) |
| typed-nil回避 | ×(残る) | ×(残る) |
| 呼び側の判定 | ok 必須 |
ok 必須 |
| 汎用性(値型流用) | ○ | ○ |
| 推奨度 | △ | △ |
→ 根本的には「具体型ポインタ」に寄せるのが無難です。
Discussion