⛑️
GoでContextに値を格納するときに潜むinterfaceとnilの落とし穴
📝 はじめに
Web サービスでは、リクエストスコープの情報(認証ユーザー、テナント、権限、トレーシング情報など)を context.Context に積んで各レイヤに渡すのが一般的です。
便利な一方で、interface と nil が絡む「typed-nil」問題が潜んでおり、存在判定を誤るとバグにつながります。
ここでは サービスでよく使われる「認証ユーザー(Principal)」 を例に、
- Context に格納する値の設計
-
interface × nilの落とし穴 - 実装 A/B の比較と安全な代替案
- 実務向けのコード規約
をまとめます。
✅ まずインターフェース定義を明示する(Principal)
「急に model.Session が出てくる」問題を避けるため、この記事では 認証ユーザー(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"
type contextKey int
const (
principalKey contextKey = iota
)
// WithPrincipal は Principal を context に格納して返す。
// nil を渡さないこと(規約)。nil の場合は格納せず元の ctx を返すか、明示的にエラーにする。
func WithPrincipal(ctx context.Context, p Principal) context.Context {
if p == nil { // ここで nil を弾くことで "typed-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(シンプルに戻す)
// GetPrincipal は Principal と「アサーション成否」を返す。
func GetPrincipal(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(principalKey).(Principal)
return p, ok
}
- ✅ メリット
- シンプルで読みやすい
- 値型でも流用可能
- ⚠️ デメリット
-
ok == trueでも typed-nil の可能性が残る - 呼び側が
p == nil判定に頼ると事故る(必ずokを見る規約が必要)
-
実装 B(未設定時は nil,false を返す)
// GetPrincipal は「未設定時に nil,false」を返す。
func GetPrincipal(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(principalKey).(Principal)
if !ok {
return nil, false
}
return p, true
}
- ✅ メリット
- 未設定(キー不一致)を
nil,falseで明確に伝えられる - 呼び側は
okでもnilでも判定できる(二重の安全網)
- 未設定(キー不一致)を
- ⚠️ デメリット
-
typed-nil 問題は依然として残る(
ok==trueかつ型付き nil) - やはり「
okを唯一の存在判定にする」規約が必要
-
typed-nil 問題は依然として残る(
結論:A/B どちらでも「必ず
okを見る」 を徹底してください。p == nilに依存しない。
さらに WithPrincipal で 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.GetPrincipal(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
_ = p.UserID() // 安全に利用(ただし p==nil 判定に頼らない)
w.WriteHeader(http.StatusOK)
}
具体型ポインタ版を採用するなら
auth.WithPrincipalData / auth.GetPrincipalDataを同様に使ってください。
📐 チーム規約(サンプル)
-
[must]
GetXxx(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