📚 背景知識(読んでから問題へ)
テーマ15の3日目です(期間は 2026-09-25〜2026-10-24)。Day 056・057 のミドルウェアは、どれも r.WithContext(context.WithValue(...)) で下流へ値を渡していました。Day 017 では WithValue の基本(独自型のキー・型アサーションで取り出す)を学んでいます。今日はその先で、ミドルウェアを設計する人が迷う3つの問いを扱います。
- キーはどう作れば衝突しないのか。「独自型なら安全」はどこまで本当か
- 下流から上流へ値を返したい(ログにユーザー名を載せたい)とき、context をどう使うか。何が壊れるか
- 結局、何を context に入れて、何を入れないのか
公式の記述(go doc context.WithValue、Go 1.27.1)
原文の要点は次の3つです(ここでは訳して載せます)。
- context の値は「プロセスや API の境界を越えて運ばれる、リクエストに紐づくデータ」だけに使う。関数の省略可能な引数を渡すためには使わない
- キーは比較できる型でなければならない。パッケージ間の衝突を避けるため、
stringなどの組み込み型にしない。使う側が自分の型を定義する - interface に入れるときの割り当てを避けるため、キーは
struct{}型にすることが多い。公開するキー変数は、静的な型をポインタか interface にする
標準ライブラリは実際にどう書いているか(Go 1.27.1 のソースを数えた)
$(go env GOROOT)/src の中で、cmd/・テスト・vendor を除いた context.WithValue( の呼び出しは 9か所でした(grep で数えた。うち1か所は context.go のコメント内の例)。
| 場所 | キー | 値 |
|---|---|---|
net/http/server.go(3か所) | ServerContextKey = &contextKey{"http-server"} など、公開のポインタ | *http.Server、net.Addr |
net/http/httptrace/trace.go(2か所) | clientEventContextKey{} など、非公開の struct{} | *ClientTrace など |
runtime/trace・runtime/pprof・net/http/fcgi・net | すべて非公開の struct{} 型 | ポインタや map |
string や int をキーにしている箇所は0か所です。また log/slog には、ロガーを context に入れる関数(NewContext・FromContext)がありません(go doc log/slog の一覧で確認)。代わりに InfoContext(ctx, ...) のように context をログ関数へ渡す形になっています。これは今日の問7に効きます。
Value はどう探すか(context.go 776行目 func value)
WithValue は親を指す valueCtx{parent, key, val} を1つ作るだけです。Value(key) は自分から親へ向かって1つずつ key == ctx.key を比べ、見つかったら返します。連結リストを根元へたどるので、かかる時間は「探す値がどれだけ根元にあるか」に比例します。== は interface 同士の比較なので、型と値の両方が一致したときだけ当たります。
実際の net/http サーバーで、ハンドラが受け取る r.Context() を fmt.Sprint すると、ミドルウェアを1つも挟まない状態で次の4段でした(Go 1.27.1 で実測)。
context.Background.WithValue(net/http context value http-server, *http.Server).WithValue(net/http context value local-addr, 127.0.0.1:51438).WithCancel.WithCancel
fmt.Sprint(ctx) で段の並びが見えるのは、調べ物のときに便利です。
📝 問題
今日は予測してから実行する問題です。下の3つのファイルと go.mod を1つのディレクトリに置き、実行する前に問1〜問6の答えを自己評価欄に書いてください。そのあと実行して答え合わせをし、問7に答えます。
mkdir day058 && cd day058
# 下の go.mod・main.go・race_test.go・bench_test.go を置く
go run . # 実験1〜4・6 を表示する
go test -race -count=1 -run Race -v . # 実験5 をデータ競合の検出つきで動かす
go test -count=1 -run Race -v . # 実験5 を検出なしで動かす
go test -run XXX -bench . -count=3 . # 実験7 の速さと割り当てを3回測る
go.mod:
module day058
go 1.24
実験コード(3ファイル)
// Day 058: context.WithValue の挙動を6つの実験で確かめる。go run . で全部出る。
package main
import (
"context"
"fmt"
"log/slog"
"os"
"time"
)
// ---- 実験1: キーの同一性 ----
type ctxKey string // Day 017 で使った「独自の string 型」
// 2つの別パッケージが、それぞれ非公開の key 型を持っている状況を1ファイルで再現する
type pkgAKey int
type pkgBKey int
type contextKey struct{ name string } // net/http と同じ形(http.go 120行目)
func exp1() {
fmt.Println("== 実験1: どの取り出しが当たるか")
// 認証パッケージがユーザー名を、課金パッケージがユーザーの数値 ID を、どちらも "user" で入れる
ctx := context.WithValue(context.Background(), "user", "alice")
ctx = context.WithValue(ctx, "user", 42)
name, ok := ctx.Value("user").(string)
fmt.Printf(" (a) 認証側が \"user\" を string で取り出す : %q ok=%v\n", name, ok)
fmt.Printf(" (b) \"user\" で入れて ctxKey(\"user\") で取る : %v\n", ctx.Value(ctxKey("user")))
ctx = context.WithValue(context.Background(), pkgAKey(0), "A の値")
ctx = context.WithValue(ctx, pkgBKey(0), "B の値")
fmt.Printf(" (c) pkgAKey(0) で取る : %v / pkgBKey(0) で取る : %v\n", ctx.Value(pkgAKey(0)), ctx.Value(pkgBKey(0)))
k1 := &contextKey{"http-server"}
ctx = context.WithValue(context.Background(), k1, "srv")
fmt.Printf(" (d) 中身が同じ別のポインタ &contextKey{\"http-server\"} で取る : %v\n", ctx.Value(&contextKey{"http-server"}))
ctx = context.WithValue(context.Background(), contextKey{"http-server"}, "srv")
fmt.Printf(" (e) 値の contextKey{\"http-server\"} で入れて、別に作った同じ値で取る : %v\n", ctx.Value(contextKey{"http-server"}))
}
// ---- 実験2: 上書きと順序 ----
type userKey struct{}
func exp2() {
fmt.Println("== 実験2: 上書き・兄弟・順序")
parent := context.WithValue(context.Background(), userKey{}, "alice")
child := context.WithValue(parent, userKey{}, "bob")
sibling := context.WithValue(parent, ctxKey("other"), 1)
fmt.Printf(" child=%v parent=%v sibling=%v\n", child.Value(userKey{}), parent.Value(userKey{}), sibling.Value(userKey{}))
// next を呼んだ「後」で値を足すミドルウェア
var seen any
handler := func(ctx context.Context) { seen = ctx.Value(userKey{}) }
mw := func(ctx context.Context) {
handler(ctx)
ctx = context.WithValue(ctx, userKey{}, "carol")
_ = ctx
}
mw(context.Background())
fmt.Printf(" next の後で WithValue したとき、ハンドラが見た値=%v\n", seen)
}
// ---- 実験3: 比較できないキー ----
func try(label string, f func()) {
defer func() {
if v := recover(); v != nil {
fmt.Printf(" %-34s panic: %v\n", label, v)
}
}()
f()
fmt.Printf(" %-34s ok\n", label)
}
func exp3() {
fmt.Println("== 実験3: キーに使える型")
bg := context.Background()
try("[]string{\"a\"}", func() { _ = context.WithValue(bg, []string{"a"}, 1) })
try("struct{ tags []string }{}", func() { _ = context.WithValue(bg, struct{ tags []string }{}, 1) })
try("map[string]int{}", func() { _ = context.WithValue(bg, map[string]int{}, 1) })
try("[2]string{\"a\", \"b\"}", func() { _ = context.WithValue(bg, [2]string{"a", "b"}, 1) })
try("nil", func() { _ = context.WithValue(bg, nil, 1) })
}
// ---- 実験4: WithoutCancel ----
type reqIDKey struct{}
func exp4() {
fmt.Println("== 実験4: レスポンス後に走る処理(監査ログの書き込み等)")
reqCtx, cancel := context.WithCancel(context.WithValue(context.Background(), reqIDKey{}, "req-42"))
detached := context.WithoutCancel(reqCtx)
cancel() // ハンドラが返ると net/http がリクエストの context をキャンセルする
fmt.Printf(" reqCtx: Err=%v id=%v\n", reqCtx.Err(), reqCtx.Value(reqIDKey{}))
fmt.Printf(" detached: Err=%v id=%v\n", detached.Err(), detached.Value(reqIDKey{}))
bounded, cancel2 := context.WithTimeout(detached, 2*time.Second)
defer cancel2()
dl, _ := bounded.Deadline()
fmt.Printf(" WithTimeout(detached, 2s): Err=%v id=%v 期限あり=%v\n", bounded.Err(), bounded.Value(reqIDKey{}), !dl.IsZero())
}
// ---- 実験6: ロガーを context に入れず、Handler が context から値を読む ----
type ctxHandler struct{ slog.Handler }
func (h ctxHandler) Handle(ctx context.Context, r slog.Record) error {
if id, ok := ctx.Value(reqIDKey{}).(string); ok {
r.AddAttrs(slog.String("req_id", id))
}
return h.Handler.Handle(ctx, r)
}
func (h ctxHandler) WithAttrs(as []slog.Attr) slog.Handler {
return ctxHandler{h.Handler.WithAttrs(as)}
}
func (h ctxHandler) WithGroup(n string) slog.Handler { return ctxHandler{h.Handler.WithGroup(n)} }
func exp6() {
fmt.Println("== 実験6: slog.Handler が context から req_id を読む")
opts := &slog.HandlerOptions{ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
}}
logger := slog.New(ctxHandler{slog.NewTextHandler(os.Stdout, opts)})
ctx := context.WithValue(context.Background(), reqIDKey{}, "req-42")
logger.InfoContext(ctx, "order created", "order", 7)
logger.Info("order created", "order", 7) // ctx を渡さない版
}
func main() {
exp1()
exp2()
exp3()
exp4()
fmt.Println("== 実験5 は go test -race -run Race、実験7 は go test -bench . で")
exp6()
}package main
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
// 実験5: 「上向きの袋」— 外側のミドルウェアが可変のポインタを context に入れ、
// 内側のハンドラが書き込み、外側が next の後で読む(chi の RouteContext と同じ形)。
type fields struct{ kv map[string]string }
type fieldsKey struct{}
func addField(ctx context.Context, k, v string) {
if f, ok := ctx.Value(fieldsKey{}).(*fields); ok {
f.kv[k] = v
}
}
func withFields(next http.Handler, got *map[string]string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f := &fields{kv: map[string]string{}}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), fieldsKey{}, f)))
*got = f.kv // アクセスログを書くつもりで読む
for k := range f.kv { // ログの行を組み立てるつもりで全部なめる
_ = k
}
})
}
// 同じ goroutine の中で書くだけなら安全
func TestRaceSameGoroutine(t *testing.T) {
var got map[string]string
h := withFields(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
addField(r.Context(), "user", "alice")
}), &got)
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
t.Logf("fields=%v", got)
}
// ハンドラが起こした goroutine が、レスポンスの後に書く
func TestRaceDetachedWriter(t *testing.T) {
var got map[string]string
var wg sync.WaitGroup
h := withFields(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
addField(r.Context(), "user", "alice")
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(time.Millisecond)
addField(r.Context(), "audit", "written") // 外側がもう読んでいるかもしれない
}()
}), &got)
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
for range 3 {
for k := range got { // ログの出力中に map を読み続ける想定
_ = k
}
time.Sleep(time.Millisecond)
}
wg.Wait()
t.Logf("fields=%v", got)
}
// 直し方: 袋への書き込みはハンドラが返る前だけにする。後で走る処理には、
// 必要な値を引数で渡し、context は WithoutCancel で切り離す(袋には触らない)
func TestRaceDetachedFixed(t *testing.T) {
var got map[string]string
var wg sync.WaitGroup
var audited string
h := withFields(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
addField(r.Context(), "user", "alice")
addField(r.Context(), "audit", "scheduled") // 返る前に書く
ctx := context.WithoutCancel(r.Context())
wg.Add(1)
go func(ctx context.Context, user string) {
defer wg.Done()
time.Sleep(time.Millisecond)
audited = user // 監査ログを書く想定。袋には書かない
_ = ctx
}(ctx, "alice")
}), &got)
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
for range 3 {
for k := range got {
_ = k
}
time.Sleep(time.Millisecond)
}
wg.Wait()
t.Logf("fields=%v audited=%s", got, audited)
}package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
type benchKey int
// 実験7a: 取り出しの速さは、目的の値がどれだけ深いところにあるかで決まる
func BenchmarkValueDepth(b *testing.B) {
for _, depth := range []int{1, 8, 32, 128} {
ctx := context.WithValue(context.Background(), reqIDKey{}, "req-42") // 一番根元に置く
for i := range depth - 1 {
ctx = context.WithValue(ctx, benchKey(i), i)
}
b.Run(fmt.Sprintf("depth=%d", depth), func(b *testing.B) {
for b.Loop() {
_ = ctx.Value(reqIDKey{})
}
})
}
}
// 実験7b: 1回の WithValue の割り当て(キーと値の型で変わるか)
var sink context.Context
var sinkReq *http.Request
func BenchmarkWithValueAlloc(b *testing.B) {
bg := context.Background()
id := fmt.Sprint("req-", 42) // 実行時に作った文字列(定数ではない)
n := 4242
cases := []struct {
name string
f func() context.Context
}{
{"key=struct{}/val=定数string", func() context.Context { return context.WithValue(bg, reqIDKey{}, "req-42") }},
{"key=struct{}/val=実行時string", func() context.Context { return context.WithValue(bg, reqIDKey{}, id) }},
{"key=struct{}/val=*T", func() context.Context { return context.WithValue(bg, reqIDKey{}, &n) }},
{"key=struct{}/val=int(4242)", func() context.Context { return context.WithValue(bg, reqIDKey{}, n) }},
{"key=ctxKey(string定数)/val=*T", func() context.Context { return context.WithValue(bg, ctxKey("user"), &n) }},
}
for _, c := range cases {
b.Run(c.name, func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
sink = c.f()
}
})
}
}
// 実験7c: ミドルウェアが値を1つ足すたびの r.WithContext(Request の浅いコピー)
func BenchmarkRequestWithContext(b *testing.B) {
r := httptest.NewRequest("GET", "/api/v1/orders/42", nil)
b.ReportAllocs()
for b.Loop() {
sinkReq = r.WithContext(context.WithValue(r.Context(), reqIDKey{}, "req-42"))
}
}問1(実験1: キーの同一性)
(a)〜(e) の5つの取り出しは、それぞれ何を返しますか。(a) は ok の値も答えてください。
問2(実験2: 上書きと順序)
child・parent・sibling がそれぞれ userKey{} で返す値と、「next の後で WithValue したとき」にハンドラが見た値を答えてください。
問3(実験3: キーに使える型)
5つのキーのうち、panic するものはどれですか。panic の文言も予想してください。コンパイルエラーになるものはありますか。
問4(実験4: WithoutCancel)
reqCtx をキャンセルした後の、reqCtx・detached・bounded それぞれの Err() と req_id を答えてください。
問5(実験5: 上向きの袋)
withFields は「外側のミドルウェアがポインタを context に入れ、内側が書き込み、外側が next の後で読む」形です。3つのテストのうち、
-race付きで FAIL するのはどれか-race無しでも FAIL するか
を答えてください。
問6(実験7: 速さと割り当て)
Apple M1 での値を、桁で予想してください。
- 根元の値を探すとき、段数 1・8・32・128 でそれぞれ何 ns か
WithValue1回の割り当ての回数とバイト数。5つの組み合わせで違いはあるかr.WithContext(context.WithValue(...))1回の割り当ての回数とバイト数
問7(判断: 何を context に入れるか)
次の8つの候補を「入れる/入れない/入れるが境界で取り出す」に仕分け、それぞれ理由を1行で書いてください。入れないものには、代わりにどう渡すかも書きます。
| # | 候補 | 想定する使い方 |
|---|---|---|
| ① | リクエスト ID | ログの全行に載せる |
| ② | トレースの span(OpenTelemetry) | 下流の HTTP・DB 呼び出しを同じトレースにつなぐ |
| ③ | 認証済みの主体(ユーザー ID・ロール) | 認可・監査ログ・「自分の注文だけ返す」の絞り込み |
| ④ | *sql.DB | リポジトリ層が使う |
| ⑤ | *sql.Tx | 1リクエストの複数の書き込みを1トランザクションにする |
| ⑥ | *slog.Logger | ハンドラの奥でログを書く |
| ⑦ | ページサイズ・並び順 | 一覧 API の取得条件 |
| ⑧ | 機能フラグの評価結果 | 新しい価格計算を一部のユーザーだけに出す |
💡 ヒント
ヒント1(方向性)
問1〜問3は、value 関数の key == ctx.key が interface 同士の == であることだけで解けます。Go の仕様で interface 同士は「動的な型が同じで、値が等しい」ときに等しくなります。ポインタは「指している先が同じ」ときだけ等しくなります。
ヒント2(アプローチ)
- 問2:
WithValueは親を書き換えず、新しい子を作るだけです。子は親をたどれますが、親は子を知りません - 問4:
WithoutCancelの説明(go doc context.WithoutCancel)には「親を指す(points to the parent)新しい context で、親がキャンセルされてもキャンセルされない。Deadline も Err も返さない」とあります。値については何も書いていません。「親を指す」から何が言えるでしょうか - 問5: context の値は書き換えられないが、値がポインタなら指す先は書き換えられる。そのポインタに2つの goroutine が触ると何が起きるか。
-raceを付けないときに Go のランタイムが map の同時書き込みを見つけるのは、たまたま同じ瞬間に重なったときだけです - 問6:
valueCtxは interface 2つ(キーと値)と親の interface 1つを持つ構造体です。interface 1つは 16 バイト(64bit 環境)。定数や、ポインタ1つ分に収まる値は、interface に入れても新しい割り当てが要りません
ヒント3(コード骨格)
問7の考え方の骨組みです。候補ごとに次の2つを問います。
Q1. その値は「このリクエストが何者か」を表すか、それとも「処理のやり方」を変えるか
→ 前者なら context の候補。後者(ページサイズ・DB 接続・フラグ)なら引数か構造体のフィールド
Q2. その値が無いとき、関数は正しく動けるか
→ 動けない(必須)なら、関数のシグネチャに出す。context に隠すと、呼び出し側が入れ忘れても
コンパイルは通り、実行時に初めて壊れる
③ 認証済みの主体 は、この2問で答えが割れる候補です。「ミドルウェアからハンドラまで」と「ハンドラからドメイン層まで」を分けて考えてください。
✅ 模範解答
模範解答を表示する(まず問1〜問6の予測を書いてから)
ひと目でわかる実測(Go 1.27.1 / Apple M1)
根元の値を探す時間(3回の中央値)— 段数にほぼ比例する
1回あたりの割り当て — コストの大半は Request のコピー
go test -bench . -count=3 の実測。数値の全体は下の表。
実行結果(Go 1.27.1 / darwin arm64 / Apple M1、2026-09-28 実測)
go run . の出力:
== 実験1: どの取り出しが当たるか
(a) 認証側が "user" を string で取り出す : "" ok=false
(b) "user" で入れて ctxKey("user") で取る : <nil>
(c) pkgAKey(0) で取る : A の値 / pkgBKey(0) で取る : B の値
(d) 中身が同じ別のポインタ &contextKey{"http-server"} で取る : <nil>
(e) 値の contextKey{"http-server"} で入れて、別に作った同じ値で取る : srv
== 実験2: 上書き・兄弟・順序
child=bob parent=alice sibling=alice
next の後で WithValue したとき、ハンドラが見た値=<nil>
== 実験3: キーに使える型
[]string{"a"} panic: key is not comparable
struct{ tags []string }{} panic: key is not comparable
map[string]int{} panic: key is not comparable
[2]string{"a", "b"} ok
nil panic: nil key
== 実験4: レスポンス後に走る処理(監査ログの書き込み等)
reqCtx: Err=context canceled id=req-42
detached: Err=<nil> id=req-42
WithTimeout(detached, 2s): Err=<nil> id=req-42 期限あり=true
== 実験5 は go test -race -run Race、実験7 は go test -bench . で
== 実験6: slog.Handler が context から req_id を読む
level=INFO msg="order created" order=7 req_id=req-42
level=INFO msg="order created" order=7
go test -race -count=1 -run Race -v . の出力(要点):
=== RUN TestRaceSameGoroutine
race_test.go:41: fields=map[user:alice]
--- PASS: TestRaceSameGoroutine (0.00s)
=== RUN TestRaceDetachedWriter
WARNING: DATA RACE
race_test.go:65: fields=map[audit:written user:alice]
--- FAIL: TestRaceDetachedWriter (0.00s)
=== RUN TestRaceDetachedFixed
race_test.go:94: fields=map[audit:scheduled user:alice] audited=alice
--- PASS: TestRaceDetachedFixed (0.00s)
FAIL
-race 無しでは3つとも PASS でした。-race 付きの TestRaceDetachedWriter は3回流して3回とも WARNING: DATA RACE を1件出しました。
go test -run XXX -bench . -count=3 . の出力(3回の範囲。ns/op は1回あたり):
| 実験 | 3回の範囲 | 割り当て |
|---|---|---|
| 根元の値を探す: 段数 1 | 3.21〜3.28 ns | 0 |
| 段数 8 | 11.00〜11.13 ns | 0 |
| 段数 32 | 39.65〜39.79 ns | 0 |
| 段数 128 | 191.9〜197.4 ns | 0 |
WithValue: キー struct{}・値は定数の string | 15.58〜15.69 ns | 1回・48 B |
キー struct{}・値は実行時に作った string | 24.89〜26.20 ns | 2回・64 B |
キー struct{}・値はポインタ | 15.00〜15.20 ns | 1回・48 B |
キー struct{}・値は int(4242) | 21.26〜21.44 ns | 2回・56 B |
キー ctxKey("user")(string 型の定数)・値はポインタ | 18.55〜19.75 ns | 1回・48 B |
r.WithContext(context.WithValue(...)) | 87.80〜95.25 ns | 2回・368 B |
go vet と staticcheck(golangci-lint 2.13.2 に入っているもの。単体の staticcheck 2025.1.1 は Go 1.27 のエクスポートデータを読めず動きませんでした)に、下の lintdemo.go を読ませた結果:
// Package lintdemo はキーの型をリンターが見るかを確かめるためだけのもの。
package lintdemo
import "context"
type ctxKey string
type userKey struct{}
func WithStringKey(ctx context.Context) context.Context {
return context.WithValue(ctx, "user", "alice")
}
func WithIntKey(ctx context.Context) context.Context { return context.WithValue(ctx, 1, "alice") }
func WithNamedString(ctx context.Context) context.Context {
return context.WithValue(ctx, ctxKey("user"), "alice")
}
func WithStructKey(ctx context.Context) context.Context {
return context.WithValue(ctx, userKey{}, "alice")
}
[go vet] 指摘0件
[staticcheck] lintdemo.go:10:89: SA1029: should not use built-in type string as key for value; define your own type to avoid collisions
lintdemo.go:11:89: SA1029: should not use built-in type int as key for value; define your own type to avoid collisions
ctxKey("user") と userKey{} は指摘されません。
問1〜問6の答え
| 問 | 答え | なぜ |
|---|---|---|
| 1(a) | ""・ok=false | 課金側が同じ "user" で 42 を入れたので、近い方の 42(int)が返り、string への型アサーションが失敗する。エラーにもならず、黙って空になる |
| 1(b) | nil | string と ctxKey は動的な型が違うので等しくない |
| 1(c) | A の値 / B の値 | 型が違えば、値がどちらも 0 でも別のキー。公式ドキュメントの type key int の例はこれに頼っている |
| 1(d) | nil | ポインタは指す先が同じときだけ等しい。net/http が公開のキーをポインタにしているのは、外のパッケージが同じ中身の値を作っても偽造できないようにするため |
| 1(e) | srv | 比較できる構造体は、フィールドが全部等しければ等しい |
| 2 | child=bob parent=alice sibling=alice、ハンドラは nil | 子を作っても親は変わらない。next を呼んだ後に作った context は、もう誰にも渡らない |
| 3 | panic は4つ([]string・スライスを含む構造体・map は key is not comparable、nil は nil key)。配列 [2]string は通る。コンパイルエラーは0 | 引数が any なので、比較できるかはコンパイル時に分からない。WithValue が reflectlite.TypeOf(key).Comparable() で実行時に調べる(context.go 735行目)。配列は要素が比較できれば比較できる |
| 4 | reqCtx: context canceled・req-42。detached: nil・req-42。bounded: nil・req-42・期限あり | WithoutCancel はキャンセルと期限だけを切り離し、値は親から引き継ぐ。切り離したままだと止める手段が無いので、WithTimeout で上限を付け直す |
| 5 | TestRaceDetachedWriter だけが -race 付きで FAIL。-race 無しでは3つとも PASS | context の値はポインタで、指す先の map は誰でも書ける。外側が読み終わった後に goroutine が書き込むのはデータ競合。-race 無しで通るのは「壊れていない」ではなく「今回は重ならなかった」だけ |
| 6 | 下の3点 | 下の「問6の読み方」 |
問6の読み方:
- 探す時間は段数にほぼ比例します(1段あたり約 1.2〜1.5 ns。例: 39.7 ÷ 32 ≈ 1.2 ns、191.9 ÷ 128 ≈ 1.5 ns、導出)。実際のサーバーは net/http の4段にミドルウェア数段を足した10段前後なので、1回の取り出しは十数 ns です。ネットワーク往復(データセンター内で 0.5 ms と仮定)の 1/30,000 程度で、速さは context を避ける理由になりません
WithValueの土台は1回・48 B(valueCtxの親・キー・値の interface 3つ × 16 B、導出)。値が interface に収まらないと、もう1回割り当てが起きます(実行時に作った string は +16 B、int の 4242 は +8 B)。定数の string やポインタは追加の割り当てがありません。キーはstruct{}でもctxKey("user")のような定数でも割り当てはありませんr.WithContextのコストの大半は Request のコピーです(368 B − 48 B = 320 B、導出)。ミドルウェアが値を1つ足すたびに Request 丸ごとの浅いコピーが1回起きます。1リクエストあたりのコストとしては小さいですが、Day 057 で見たとおり、コピーはr.Patternの書き込み先を分けるという別の副作用を持ちます
問7の答え
| # | 候補 | 仕分け | 理由 | 入れない場合の渡し方 |
|---|---|---|---|---|
| ① | リクエスト ID | 入れる | リクエストが何者かを表し、無くても処理は正しく動く(ログの質が落ちるだけ)。全層を貫くので、引数にすると全関数のシグネチャが汚れる | — |
| ② | トレースの span | 入れる | ①と同じ性質で、しかもプロセスを越えて運ばれる(HTTP ヘッダ traceparent に載る)。OpenTelemetry の Go API も trace.SpanFromContext(ctx) で context から取り出す形(API 名は公式ドキュメントによる。今日は実行していない) | — |
| ③ | 認証済みの主体 | 入れるが、境界で取り出す | 認証ミドルウェア→ハンドラの間は context で渡すしかない(http.Handler のシグネチャは変えられない)。しかし「自分の注文だけ返す」の絞り込みでは無いと正しく動けない必須の値。ハンドラで1回取り出し、ドメイン層へは svc.ListOrders(ctx, principal, filter) のように引数で渡す | ドメイン層へは引数 |
| ④ | *sql.DB | 入れない | 処理のやり方(どこへつなぐか)で、リクエストごとに変わらない。context に入れると、テストで差し替えるときに「どの context に入れ忘れたか」を探すことになる | 構造体のフィールド(type OrderRepo struct{ db *sql.DB })。起動時に1回だけ組み立てる |
| ⑤ | *sql.Tx | 入れない(下の「迷った他案」参照) | その関数がトランザクションの中で動くかどうかがシグネチャから見えなくなる。入れ忘れると、黙ってトランザクションの外で書き込む | DB と Tx の両方が満たす小さな interface(ExecContext・QueryContext などを持つもの)を引数で受ける。sqlc が生成するコードの DBTX と同じ形(sqlc の生成コードの形として知られているもの。今日は生成して確かめていない) |
| ⑥ | *slog.Logger | 入れない | 標準の slog は context にロガーを入れる関数を持たず、InfoContext(ctx, ...) で context を渡し、Handler が値を取り出す設計(実験6: req_id=req-42 が付き、context を渡さない行には付かない) | ロガーは構造体のフィールド。リクエストごとの値(①②③)は context に入れ、Handler が読む |
| ⑦ | ページサイズ・並び順 | 入れない | 公式ドキュメントが名指しで禁じている「省略可能な引数」そのもの | 引数か、ListOptions 構造体 |
| ⑧ | 機能フラグの評価結果 | 入れない(条件付き) | 価格という業務の結果を変える値。context に隠すと、同じ入力で結果が変わる理由がシグネチャから追えない | 評価済みの値(pricing.Version)を引数で渡す。ただし「どのフラグの組み合わせでこのリクエストを処理したか」をログやトレースに載せたいだけなら、その記録用の写しは①と同じ扱いで context に入れてよい |
迷った他案と、前提が変わったときの判断
⑤ *sql.Tx を context に入れる案を採らなかった理由を残します。この案には実務で使われる利点があります。リポジトリの関数を (ctx, …) のまま変えずに、呼び出し側が「この3つの書き込みを1トランザクションに」と後から決められることです(Unit of Work パターンを context で実現する形)。
| 観点 | 引数で渡す(採用) | context に入れる(不採用) |
|---|---|---|
| トランザクションの中かどうかがコードから分かるか | 分かる(引数が Tx か DB か) | 分からない |
| 入れ忘れたとき | コンパイルエラー | 黙ってトランザクションの外で書く |
| 既存のリポジトリの関数を変えずに済むか | 済まない(引数を足す) | 済む |
| 後で走る goroutine(問5の形)に context が渡ったとき | Tx を渡さなければ触れない | Commit 後の Tx を触って sql: transaction has already been committed or rolled back になる経路ができる(database/sql の ErrTxDone。今日は再現していない) |
前提が変わったら:
| 前提の変化 | 判断 |
|---|---|
| リポジトリの関数が100を超え、全部の引数を変える改修費が大きい | context に入れる案を、取り出し関数を1つに限り(txFrom(ctx) が無ければ *sql.DB を返す)、Commit 後の Tx を返さないガードを付けて採る |
| 主体(③)が「誰か」だけでなく「何をしてよいか」の判定結果まで持つようになる | 判定はドメイン層に引数で渡したポリシーで行い、context には判定の入力(主体)だけを置く。判定結果を context に入れると、問5と同じく「誰がいつ書いたか」が追えなくなる |
| gRPC のサーバーを書く | gRPC-go はメタデータや接続相手を metadata.FromIncomingContext(ctx)・peer.FromContext(ctx) で context から渡す(API 名は公式ドキュメントによる。今日は実行していない)。①②③の扱いは同じで、境界で取り出す |
🪜 Step-by-Step 解説
Step 1: キーを「型」で守る仕組みを理解する
value 関数の比較は key == ctx.key の1行です。interface の == は型と値の両方を見るので、自分のパッケージにしか無い型をキーにすれば、他のパッケージは同じキーを作れません。これが公式の「組み込み型にしない」の理由です(問1(a)(b)(c))。
Day 017 で使った type ctxKey string も、この点では安全です。ただし同じパッケージの中で ctxKey("user") を2か所で別の意味に使えば衝突します。struct{} 型を値ごとに1つ作る(type userKey struct{}・type reqIDKey struct{})と、同じパッケージ内でも型でしか作れないので、打ち間違いで衝突することがありません。割り当ては問6のとおり、どちらも0です。
Step 2: 公開するならポインタ、使わせるなら関数
net/http はキーを公開しています(http.ServerContextKey)。ここで値の構造体にすると、外のパッケージが contextKey{"http-server"} を作れれば偽造できてしまいます(contextKey は非公開の型なので実際には作れませんが、公開の型だったら作れる)。ポインタなら、同じ中身でも別のポインタは別のキーです(問1(d))。
ただし多くの場合、キーそのものは公開せず、公式ドキュメントの例のように NewContext(ctx, u)・FromContext(ctx) (u, ok) の2つの関数だけを公開します。型アサーションを1か所に閉じ込めることで、問1(a)の「黙って空になる」を防げます。
Step 3: context は下にしか流れない。上に返すならポインタ、ただし期限つき
問2のとおり、ハンドラが WithValue しても外側のミドルウェアには見えません。それでもアクセスログに「ハンドラが決めた値」を載せたいとき、実務ではポインタを先に入れておき、内側がその指す先に書く「上向きの袋」を使います。chi の RouteContext も同じ形で、Day 057 で見たとおり Request のコピーに強いという利点があります。
この形は 「書き込みはハンドラが返るまで」 という暗黙の約束の上に成り立っています。問5の TestRaceDetachedWriter は、この約束を goroutine が破った例です。-race 無しでは通るので、CI で go test -race を回していなければ見つかりません。
Step 4: レスポンスの後に走る処理には WithoutCancel + 上限
ハンドラが返ると、net/http はリクエストの context をキャンセルします。その後に監査ログを DB に書く goroutine が r.Context() をそのまま使うと、書き込みは context canceled で失敗します。Go 1.21 で入った context.WithoutCancel は、値(リクエスト ID など)を残したままキャンセルだけを切り離します(問4)。切り離すと止める手段が無くなるので、WithTimeout で上限を付け直します。そして、その goroutine は袋に触らない(TestRaceDetachedFixed)。
Step 5: 「何者か」と「どうやるか」で仕分ける
問7の仕分けは、ヒント3の2問に集約されます。context に入れてよいのは「このリクエストが何者か」を表し、無くても処理の正しさが変わらないもの(①②)。必須の値(③)は境界まで context で運び、そこで取り出して引数にします。「どうやるか」を変えるもの(④⑤⑦⑧)は、引数か構造体のフィールドです。
🧭 設計思想・なぜこう書くのか
Go の関数は、何を必要としているかをシグネチャで示すことを大事にします。context.Context の値は、この原則にわざと穴を開ける仕組みです。穴が必要なのは、http.Handler のようにシグネチャを変えられない境界を越えて、リクエストの素性(誰か・どのトレースか)を運ぶためです。
だから公式は値の用途を「境界を越えて運ばれるリクエストのデータ」に絞り、キーに型を要求し、取り出しは any を返すというあえて使いにくい APIにしています。型安全なジェネリクス版(context.Value[T] のようなもの)が標準に入っていないのも、使いやすくすると穴が広がるからだと読めます(これは筆者の解釈で、Go チームの公式の説明ではありません)。
もう1つは「不変」です。WithValue は親を書き換えないので、ある context を複数の goroutine に渡しても、context 自体は安全です(問2)。危ないのは、不変の箱に可変のポインタを入れたときだけです(問5)。
🌐 他言語との比較
| 言語・仕組み | リクエストの値の運び方 | Go の context との違い |
|---|---|---|
Java(ThreadLocal、Spring の SecurityContextHolder) | スレッドに暗黙に紐づく | 引数に現れない。スレッドプールで別のリクエストに値が残る事故がある。Java 25 で正式になった Scoped Values(JEP 506)は、スコープを抜けると消える形に改めたもの |
Python(contextvars.ContextVar) | タスク(asyncio)ごとに暗黙に引き継ぐ | 引数に現れない。キーは ContextVar のオブジェクトそのもので、Go の「非公開の型」と同じく、持っていないと取り出せない |
Node.js(AsyncLocalStorage) | 非同期の呼び出しの連鎖に暗黙に紐づく | 引数に現れない。OpenTelemetry の JS 版はこれで span を運ぶ |
Go(context.Context) | 第1引数で明示的に渡す | 渡し忘れはコンパイルで分からないが、渡しているかどうかはコードで見える。値の出どころを grep で追える |
他の言語が「暗黙に引き継ぐ」仕組みを選んだのに対し、Go は ctx を全関数の第1引数に書かせる手間と引き換えに、流れを目に見えるようにしました。上の表の各仕組みの説明は、各言語の公式ドキュメントの記述によるもので、今日は実行して確かめていません。
🏭 実務での使いどころ
- ミドルウェアのパッケージを作るとき: キーは
type xxxKey struct{}で非公開にし、WithXxx(ctx, v)・XxxFrom(ctx) (v, ok)の2関数だけを公開する。型アサーションはこの2関数の中だけ - CI に入れるもの: staticcheck(golangci-lint 経由でも可)の SA1029 で組み込み型のキーを止める。
go vetは見ない(実測)。go test -raceで問5の形を止める - ログの
req_idが一部の行だけ空になっていたら:slog.Info(context 無し)を呼んでいる行か、Request をコピーする前の context を使っている行。実験6の2行目がその形 - 非同期の監査ログ・メール送信:
context.WithoutCancel(r.Context())+WithTimeout。値を引数でも渡し、袋には触らない - コードレビューで
ctx.Value(を見たら: その値が無いときに関数が正しく動くかを聞く。動かないなら引数にする
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| キーが衝突すると panic かエラーになる | 衝突は異常だと思う | 近い方が黙って勝つ。型アサーションの ok が false になるだけ(問1(a)、実測) |
| 独自型なら中身が同じでも別パッケージとは衝突しない、はポインタにも当てはまる | 「型が同じなら値で比べる」と思う | ポインタは指す先で比べる。中身が同じ別のポインタでは取れない(問1(d)、実測) |
| 比較できないキーはコンパイルで弾かれる | Go は静的型付けだから | 引数が any なので実行時の panic(問3、実測) |
| context の値は不変だから、何を入れてもデータ競合は起きない | 「WithValue は親を変えない」を拡大解釈 | 箱は不変でも、中のポインタが指す先は可変。goroutine が触ると競合する(問5、-race で実測) |
WithoutCancel にすると値も消える | 「親から切り離す」と読む | 切り離すのはキャンセルと期限だけ。値は残る(問4、実測) |
| context の値の取り出しは遅いので避けるべき | 連結リストをたどると聞く | 10段で十数 ns(問6、実測)。避ける理由は速さではなく、依存が見えなくなること |
go vet が string のキーを指摘してくれる | vet は標準の検査だから | vet は指摘しない。staticcheck の SA1029 が指摘する(実測) |
🚧 扱わなかった範囲
*sql.Txを context に入れる案の実装と実測: 表の「Commit 後の Tx を触る経路」は database/sql のErrTxDoneの説明からの推論で、今日は再現していません- OpenTelemetry・gRPC-go の実物: API 名を挙げただけで、依存を足して動かしてはいません。span が context の何段目に入るか、取り出しに何 ns かかるかは測っていません
context.AfterFunc(Go 1.21): キャンセル時に関数を走らせる仕組み。今日の「値」の論点から外れるので扱っていません- context を構造体のフィールドに持つことの是非: Go ブログ "Contexts and structs"(2021)の話題。今日は「値」に絞りました
- 他言語の比較表: 各言語の公式ドキュメントの記述によるもので、動かして比べてはいません
🚀 次のステップ
- 発展: 実験6の
ctxHandlerを、①リクエスト ID だけでなく③主体の ID も載せるように広げ、slog.Info(context 無し)で呼ばれた行を数えるテストを書いてみましょう。「context を渡し忘れたログ」を CI で見つける仕組みになります - 次回予告: 同じテーマ15のまま、切り口を「実装」に替えます。ルートごとに違う上限を持つレート制限ミドルウェア(トークンバケット、キーは今日の③主体、超えたら 429 と
Retry-After)を、Day 057 の宣言表に1列足す形で実装します