フェーズ: 上級者 (Phase 3) | 問題タイプ: 設計・アーキテクチャ | テーマ: ミドルウェア設計パターン
Day 056〜064 で 1 つずつ作った守り(リクエスト ID・集計・回復・認証・レート制限・本文上限)を、今日は1 本の積む順として決める。Day 064 の次回予告どおり「設計」。タイムアウト(Day 061)は今日の 6 つに入れない(理由は末尾)。
📚 背景知識(読んでから問題へ)
ミドルウェアの順序の議論は、ふつう「RequestID は一番外」「Recover は Logger の内側」のような言い伝えで終わります。chi の README(v5.3.2、88〜92 行)も「A good base middleware stack」として RequestID → ClientIP → Logger → Recoverer を並べるだけで、なぜその順か・入れ替えると何が壊れるかは書いていません。
今日はこれを、言い伝えではなく検査できる形にします。手順は 3 つです。
- 要件を 1 つずつ「A は B より外でなければならない」という対に直す(順序の制約)
- 対を全部満たす並びを数える。対が足りなければ複数の並びが残る——残った自由は「どちらでもよい」と記録する
- 要件をテストにし、6 つの守りの並び 6! = 720 通りすべてを流して、手で数えた答えと一致するか確かめる
順序を縛る 3 つの仕組み
守りどうしが順序で影響し合う理由は、Go の net/http では次の 3 つしかありません。どの対もこのどれかで説明できます。
| 仕組み | 何が起きるか | 今日の例 |
|---|---|---|
| 途中で返す(短絡) | 内側の守りもハンドラも動かない | Auth の 401、RateLimit の 429、BodyLimit の 413 |
r を複製する(r.WithContext) | 内側で書いた値(r.Pattern など)が外側の r に戻らない | RequestID・Auth が ctx に値を載せる。WithContext は "a shallow copy of r"(net/http/request.go 364 行) |
| panic が上へ抜ける | defer を持つ守りだけが通り道で気づける | Recover が 500 に変える。Observe は defer で数える(Day 064) |
迷った他案と採らなかった理由
| 論点 | 採った案 | 採らなかった案と理由 | 前提が変わったら |
|---|---|---|---|
| 集計のルート名の取り方 | Observe の入口で mux.Handler(r) を引く(2 回照合する) | **(a) r.Pattern を読む**(Day 063 の方式): 外側に r を複製する守りがあると空になる(E1 で実測)。さらに 401/413/429 は mux まで届かないので、720 通りのどの順でも Q3 を満たせない(実測: Q3 が 720 通りすべてを落とす)。(b) ctx に可変のスロットを置き、内側で書く(chi の RouteContext().RoutePattern() の方式。context.go 111〜123 行に「next の後に読め」とある): 置き場所の制約は消えるが、短絡した応答はやはりルート名が無い | 照合の 2 回目の費用(実測 64〜82 ns・0 allocs)が効くほど速い経路なら (b)。ルートが数千あるなら照合費用を測り直す |
| 認証より先にレート制限するか | 認証の内側(利用者ごとに数える) | 認証の外側(IP ごと): 同じ NAT の後ろの攻撃者が偽トークン 5 回で正規利用者を 429 にできる(Q6 で実測) | 総当たり攻撃そのものを止めたいなら、IP ごとの粗い制限を別に最外側へ足す(今日の 6 つとは別の守り。末尾参照) |
| 本文上限とレート制限 | レート制限の内側(413 も予算を使う) | 外側: 上限超えの本文を送り続ける相手が、予算を使わずに何回でも 413 を引き出せる(Q7) | 本文上限の判定が重く(展開して測る等)なったら、なおさら内側に置く |
| 自由な対(RequestID と Observe) | RequestID を外(chi の README と同じ並び) | Observe を外: 今日の 7 要件ではどちらでも通る(720 通り中この 2 通りだけが通る) | 集計に ID を載せる(exemplar)要件が来たら RequestID が外で確定する。それまでは「自由」と書いておく |
参照日 2026-10-05。一次資料: 手元の Go 1.27.1 のソース(net/http/server.go 2690〜2699 行の ServeMux.Handler の doc: 一致しなければ "an empty pattern"・"does not modify its argument"、net/http/request.go 364 行)、go-chi/chi v5.3.2(README.md 88〜92 行、context.go 111〜123 行。いずれも ~/go/pkg/mod の実物)。
世界の実物との位置づけ
- chi の README は今日の結論のうち **
RequestID → Logger → Recovererの 3 つの並びと一致する。認証・レート制限・本文上限の位置は chi の README には無い**(chi は認証を持たない) - 今日扱う中核概念は 5 つ: 短絡・複製・panic の 3 つの仕組み、順序の制約を対で書くこと、自由な対を記録すること。扱わないものは末尾
📝 問題
あなたは注文 API の守りを 1 本にまとめる担当です。部品は guards.go にすべて揃っています(Day 056〜064 の模範解答を簡略にしたもの。変えない)。決めるのは order.go の Order(外→内の並び)だけです。
満たすべき要件は 7 つです。
| 要件 | 内容 | 運用上の理由 |
|---|---|---|
| Q1 | 200・401・413・429・500・404 のすべての応答に X-Request-Id が付く | 顧客の問い合わせを ID でログと突き合わせる |
| Q2 | panic のログにその リクエストの ID が出る | 500 の原因をすぐ引ける |
| Q3 | 401・413・429 もルート名つきで数える | 「どの API が攻撃されているか」を見る |
| Q4 | ハンドラの panic は 500 として数える(切断=StatusAborted ではない) | 500 の率でアラートを出す |
| Q5 | 認証の部品が panicしても 500 が返る("Bearer u" で parseToken が範囲外アクセス) | 認証ライブラリの不具合で接続が切れないように |
| Q6 | 同じ IP から偽トークンを 5 回送られても、正規の利用者は 200 を受け取れる | 同じ NAT の後ろの利用者を巻き込まない |
| Q7 | 上限超えの本文を送り続ける利用者も、6 回目には 429 になる | 413 を無料で引き出させない |
やること
- 要件ごとに「A は B より外」の対を書き出す(例: Q4 → Observe は Recover より外)。どの対が他の対から自動的に出てくるか(推移)も書く
- 対をすべて満たす並びが何通りあるかを、テストを書く前に予想する。自由に入れ替えられる対はどれか
Orderを決め、Q1〜Q7 をテストにする(土台と Q1 は下に用意した)。permutationsで 720 通りを流し、予想と一致するか確かめる- 設計判断を 1 つ:
Observeがルート名をr.Patternで読む実装だったら、何通りの並びが要件を満たすか。予想してから確かめる
準備
mkdir d65 && cd d65 # 作業ディレクトリを作って入る
printf 'module d65\n\ngo 1.24\n' > go.mod # モジュールを作る(b.Loop に Go 1.24 以上が必要)
# 下の guards.go・order.go・order_test.go を保存する
go test -count=1 -run TestOrder ./... # Order が空なのでまず落ちる
ファイル 1: guards.go(変えない)
package main
import (
"context"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"runtime/debug"
"strings"
"sync"
"sync/atomic"
)
type Middleware func(http.Handler) http.Handler
// Chain は ms[0] が最も外側になるように h を包む。
func Chain(h http.Handler, ms ...Middleware) http.Handler {
for i := len(ms) - 1; i >= 0; i-- {
h = ms[i](h)
}
return h
}
type ctxKey int
const (
idKey ctxKey = iota
userKey
)
const StatusAborted = 0 // 集計用: 応答を完了できずに切った
type Key struct {
Route string
Status int
}
// App は守り 6 つと mux を持つ。守りはどれも「1 つの仕事」だけをする。
type App struct {
Mux *http.ServeMux
Log *log.Logger
MaxBody int64 // 本文の上限(バイト)
Limit int // 1 キーあたりの許可回数(テスト用に補充なし。本物は Day 059 のトークンバケット)
seq atomic.Int64
mu sync.Mutex
stats map[Key]int
used map[string]int
}
func NewApp(logger *log.Logger) *App {
a := &App{Mux: http.NewServeMux(), Log: logger, MaxBody: 1 << 20, Limit: 5,
stats: map[Key]int{}, used: map[string]int{}}
a.Mux.HandleFunc("GET /ok", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
a.Mux.HandleFunc("POST /upload", func(w http.ResponseWriter, r *http.Request) {
n, err := io.Copy(io.Discard, r.Body)
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
http.Error(w, http.StatusText(413), 413)
return
}
fmt.Fprintf(w, "%d", n)
})
a.Mux.HandleFunc("GET /boom", func(w http.ResponseWriter, r *http.Request) { panic("db down") })
return a
}
// Build は名前の並び(外→内)どおりに守りを積む。
func (a *App) Build(order []string) http.Handler {
byName := map[string]Middleware{
"RequestID": a.RequestID, "Observe": a.Observe, "Recover": a.Recover,
"Auth": a.Auth, "RateLimit": a.RateLimit, "BodyLimit": a.BodyLimit,
}
ms := make([]Middleware, 0, len(order))
for _, n := range order {
m, ok := byName[n]
if !ok {
panic("unknown middleware: " + n)
}
ms = append(ms, m)
}
return Chain(a.Mux, ms...)
}
func (a *App) Snapshot() map[Key]int {
a.mu.Lock()
defer a.mu.Unlock()
out := make(map[Key]int, len(a.stats))
for k, v := range a.stats {
out[k] = v
}
return out
}
func idOf(r *http.Request) string {
if v, ok := r.Context().Value(idKey).(string); ok {
return v
}
return "-"
}
// RequestID: 応答ヘッダと ctx に ID を載せる(r を複製する)。
func (a *App) RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := fmt.Sprintf("req-%d", a.seq.Add(1))
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), idKey, id)))
})
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(c int) {
if w.status == 0 {
w.status = c
}
w.ResponseWriter.WriteHeader(c)
}
func (w *statusWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = 200
}
return w.ResponseWriter.Write(b)
}
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
// Observe: (ルート, ステータス) を数える。ルートは r.Pattern ではなく mux.Handler で引く
// (外側で r を複製する守りがあっても、置き場所に縛られないため。E1 で実測)。
func (a *App) Observe(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, route := a.Mux.Handler(r)
if route == "" {
route = "unmatched"
}
sw := &statusWriter{ResponseWriter: w}
completed := false
defer func() {
st := sw.status
if st == 0 {
st = 200
}
if !completed {
st = StatusAborted
}
a.mu.Lock()
a.stats[Key{route, st}]++
a.mu.Unlock()
}()
next.ServeHTTP(sw, r)
completed = true
})
}
// Recover: Day 064 の模範解答と同じ。ログに ctx の ID を入れる。
func (a *App) Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := &statusWriter{ResponseWriter: w}
defer func() {
v := recover()
if v == nil {
return
}
if v == http.ErrAbortHandler {
panic(v)
}
a.Log.Printf("panic: %v id=%s %s %s\n%s", v, idOf(r), r.Method, r.URL.Path, debug.Stack())
if sw.status != 0 {
panic(http.ErrAbortHandler)
}
http.Error(sw, http.StatusText(500), 500)
}()
next.ServeHTTP(sw, r)
})
}
// parseToken は「u.<user>」形式を読む。
// 本物の JWT パーサでも起きる書き損じを 1 つ入れてある: "u" だけのトークンで parts[1] が範囲外になり panic する。
func parseToken(h string) (string, bool) {
t, ok := strings.CutPrefix(h, "Bearer ")
if !ok {
return "", false
}
parts := strings.Split(t, ".")
if parts[0] != "u" {
return "", false
}
return parts[1], true
}
// Auth: 401 か、ctx に利用者を載せて次へ(r を複製する)。
func (a *App) Auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := parseToken(r.Header.Get("Authorization"))
if !ok {
http.Error(w, http.StatusText(401), 401)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, user)))
})
}
// RateLimit: 利用者が分かればそのキー、分からなければ IP で数える。
func (a *App) RateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := "ip:" + r.RemoteAddr
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
key = "ip:" + host
}
if u, ok := r.Context().Value(userKey).(string); ok {
key = "user:" + u
}
a.mu.Lock()
a.used[key]++
over := a.used[key] > a.Limit
a.mu.Unlock()
if over {
w.Header().Set("Retry-After", "1")
http.Error(w, http.StatusText(429), 429)
return
}
next.ServeHTTP(w, r)
})
}
// BodyLimit: Content-Length で先に断り、無い・偽る相手には MaxBytesReader(Day 062)。
func (a *App) BodyLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength > a.MaxBody {
http.Error(w, http.StatusText(413), 413)
return
}
r.Body = http.MaxBytesReader(w, r.Body, a.MaxBody)
next.ServeHTTP(w, r)
})
}
func main() {}
ファイル 2: order.go(ここを決める)
package main
// Order は外→内の積む順。
var Order = []string{ /* RequestID・Observe・Recover・Auth・RateLimit・BodyLimit を並べる */ }
ファイル 3: order_test.go の土台(Q2〜Q7 を書き足す)
package main
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
)
const alice = "Bearer u.alice"
type env struct {
app *App
h http.Handler
log *bytes.Buffer
}
func newEnv(order []string) *env {
buf := &bytes.Buffer{}
app := NewApp(log.New(buf, "", 0))
return &env{app: app, h: app.Build(order), log: buf}
}
// do は 1 リクエストを流し、守りから漏れた panic も受け止める(net/http の代わり)。
func (e *env) do(method, path, auth string, body int) (rec *httptest.ResponseRecorder, escaped any) {
var rd io.Reader
if body > 0 {
rd = bytes.NewReader(make([]byte, body))
}
req := httptest.NewRequest(method, path, rd)
req.RemoteAddr = "203.0.113.7:4000" // 全員同じ NAT の後ろ
if auth != "" {
req.Header.Set("Authorization", auth)
}
rec = httptest.NewRecorder()
defer func() { escaped = recover() }()
e.h.ServeHTTP(rec, req)
return rec, nil
}
// zeroBudget は最初の 1 回から 429 を返す環境(429 の確認を他の要件の回数から切り離す)。
func zeroBudget(order []string) *env {
e := newEnv(order)
e.app.Limit = 0
return e
}
const big = 2 << 20 // 上限 1 MiB を超える本文
// 要件。名前の Qn は本文の表と対応する。どれも「順序」だけで成否が変わるように作ってある。
var requirements = []struct {
name string
ok func(order []string) string // 失敗理由(成功なら "")
}{
{"Q1 すべての応答に X-Request-Id", func(o []string) string {
e := newEnv(o)
e.app.Limit = 1000 // 429 以外の確認では予算を気にしない
cases := []struct {
e *env
m, p, a string
body int
want int
}{
{e, "GET", "/ok", alice, 0, 200},
{e, "GET", "/ok", "", 0, 401},
{e, "POST", "/upload", alice, big, 413},
{e, "GET", "/boom", alice, 0, 500},
{e, "GET", "/nope", alice, 0, 404},
{zeroBudget(o), "GET", "/ok", alice, 0, 429},
}
for _, c := range cases {
rec, _ := c.e.do(c.m, c.p, c.a, c.body)
if rec.Code != c.want {
return fmt.Sprintf("%s %s: code=%d want %d", c.m, c.p, rec.Code, c.want)
}
if rec.Header().Get("X-Request-Id") == "" {
return fmt.Sprintf("%d has no X-Request-Id", c.want)
}
}
return ""
}},
// TODO: Q2〜Q7 を書き足す(各関数は失敗理由を返し、成功なら "")
}
func check(order []string) []string {
var failed []string
for _, q := range requirements {
if why := q.ok(order); why != "" {
failed = append(failed, q.name[:2])
}
}
return failed
}
func TestOrder(t *testing.T) {
for _, q := range requirements {
t.Run(q.name, func(t *testing.T) {
if why := q.ok(Order); why != "" {
t.Fatal(why)
}
})
}
}
func permutations(xs []string) [][]string {
if len(xs) <= 1 {
return [][]string{append([]string(nil), xs...)}
}
var out [][]string
for i := range xs {
rest := append(append([]string(nil), xs[:i]...), xs[i+1:]...)
for _, p := range permutations(rest) {
out = append(out, append([]string{xs[i]}, p...))
}
}
return out
}
// 720 通りすべてを流し、全要件を通る順序と、要件ごとに何通りを落とすかを数える。
func TestEnumerateAllOrders(t *testing.T) {
perms := permutations(Order)
var pass [][]string
rejected := map[string]int{}
for _, p := range perms {
f := check(p)
for _, q := range f {
rejected[q]++
}
if len(f) == 0 {
pass = append(pass, p)
}
}
t.Logf("orders=%d pass=%d", len(perms), len(pass))
for _, p := range pass {
t.Logf(" pass: %s", strings.Join(p, " → "))
}
keys := make([]string, 0, len(rejected))
for k := range rejected {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
t.Logf(" %s rejects %d", k, rejected[k])
}
if len(pass) != 2 {
t.Fatalf("want exactly 2 passing orders (RequestID と Observe の入れ替えだけが自由), got %d", len(pass))
}
}
受け入れ条件
TestOrderの Q1〜Q7 が全部通る(go test -race -count=1 ./...を 5 回連続)TestEnumerateAllOrdersが「720 通り中 何通りが通るか」を出し、その数が手順 2 の予想と一致する(一致しなかったら、どの対を見落としたかを書く)- 決めた並びの隣どうしを 1 か所ずつ入れ替えたとき、どの要件が落ちるかを 1 表にできる
💡 ヒント
ヒント1(方向性)
各要件について「その守りが途中で返す・r を複製する・panic を通すのどれか」を見ると対が出ます。たとえば Q1 は「401 を返す守り(Auth)がヘッダを付ける守り(RequestID)より外にあると、ヘッダが付かない」——短絡です。
ヒント2(アプローチ)
- 短絡: 401・413・429 を返す守りより外に、それを見たい守り(RequestID・Observe)を置く
- 複製: ctx の値(ID・利用者)を読む守りは、それを載せる守りより内
- panic: 500 に変える守りは、panic しうる部品(Auth の
parseToken・ハンドラ)より外。500 を数える守りは Recover より外(内だと panic が通り抜けてStatusAbortedになる) - 対を有向グラフにすると、並びの数は「このグラフを満たす一列の並べ方の数」。頂点 6 個なら手で数えられる
ヒント3(コード骨格)
{"Q4 handler の panic は 500 として数える", func(o []string) string {
e := newEnv(o)
e.do("GET", "/boom", alice, 0)
if s := e.app.Snapshot(); s[Key{"GET /boom", 500}] != 1 {
return fmt.Sprint(s)
}
return ""
}},
Q6 は "Bearer x.forged" を 5 回送ってから alice で 1 回。Q7 は alice で big の本文を 6 回送り、6 回目の 429 を見る。Q1・Q3 の 429 は zeroBudget で取り出すと、他の要件の回数に引きずられない。
✅ 模範解答
対の一覧(手順 1 の答え)
| 要件 | 対(外 > 内) | 縛る仕組み | 他の対から出てくるか |
|---|---|---|---|
| Q2 | RequestID > Recover | 複製(Recover が ctx の ID を読む) | — |
| Q4 | Observe > Recover | panic(内だと 500 ではなく StatusAborted と数える) | — |
| Q5 | Recover > Auth | panic(Auth の panic が外へ漏れる) | — |
| Q6 | Auth > RateLimit | 複製(RateLimit が ctx の利用者を読む。無ければ IP で数える) | — |
| Q7 | RateLimit > BodyLimit | 短絡(BodyLimit の 413 が予算を使わずに返る) | — |
| Q1 | RequestID > Auth・RateLimit・BodyLimit | 短絡 | 出てくる(Q2・Q5・Q6・Q7 をつなぐと RequestID > Recover > Auth > RateLimit > BodyLimit) |
| Q3 | Observe > Auth・RateLimit・BodyLimit | 短絡 | 出てくる(Q4 から同様) |
対は 5 本で一列にほぼ決まり、RequestID と Observe の間だけが縛られていない。だから並びは 2 通り(手順 2 の予想)。Q1・Q3 は推移で出てくるので順序を決める力は無いが、利用者に見える約束をそのまま書いたテストとして残す(Q2 の要件が将来外れたとき、Q1 が RequestID を外側に留める——下の「要件を 1 つ外すと」の表)。
// order.go
package main
// Order は外→内の積む順。これが今日の答え。
var Order = []string{"RequestID", "Observe", "Recover", "Auth", "RateLimit", "BodyLimit"}
order_test.go 全体(Q1〜Q7・720 通りの列挙・隣の入れ替え・要件を 1 つ外す・E1・ベンチマーク)
package main
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
)
const alice = "Bearer u.alice"
type env struct {
app *App
h http.Handler
log *bytes.Buffer
}
func newEnv(order []string) *env {
buf := &bytes.Buffer{}
app := NewApp(log.New(buf, "", 0))
return &env{app: app, h: app.Build(order), log: buf}
}
// do は 1 リクエストを流し、守りから漏れた panic も受け止める(net/http の代わり)。
func (e *env) do(method, path, auth string, body int) (rec *httptest.ResponseRecorder, escaped any) {
var rd io.Reader
if body > 0 {
rd = bytes.NewReader(make([]byte, body))
}
req := httptest.NewRequest(method, path, rd)
req.RemoteAddr = "203.0.113.7:4000" // 全員同じ NAT の後ろ
if auth != "" {
req.Header.Set("Authorization", auth)
}
rec = httptest.NewRecorder()
defer func() { escaped = recover() }()
e.h.ServeHTTP(rec, req)
return rec, nil
}
// zeroBudget は最初の 1 回から 429 を返す環境(429 の確認を他の要件の回数から切り離す)。
func zeroBudget(order []string) *env {
e := newEnv(order)
e.app.Limit = 0
return e
}
const big = 2 << 20 // 上限 1 MiB を超える本文
// 要件。名前の Qn は本文の表と対応する。どれも「順序」だけで成否が変わるように作ってある。
var requirements = []struct {
name string
ok func(order []string) string // 失敗理由(成功なら "")
}{
{"Q1 すべての応答に X-Request-Id", func(o []string) string {
e := newEnv(o)
e.app.Limit = 1000 // 429 以外の確認では予算を気にしない
cases := []struct {
e *env
m, p, a string
body int
want int
}{
{e, "GET", "/ok", alice, 0, 200},
{e, "GET", "/ok", "", 0, 401},
{e, "POST", "/upload", alice, big, 413},
{e, "GET", "/boom", alice, 0, 500},
{e, "GET", "/nope", alice, 0, 404},
{zeroBudget(o), "GET", "/ok", alice, 0, 429},
}
for _, c := range cases {
rec, _ := c.e.do(c.m, c.p, c.a, c.body)
if rec.Code != c.want {
return fmt.Sprintf("%s %s: code=%d want %d", c.m, c.p, rec.Code, c.want)
}
if rec.Header().Get("X-Request-Id") == "" {
return fmt.Sprintf("%d has no X-Request-Id", c.want)
}
}
return ""
}},
{"Q2 panic のログに ID", func(o []string) string {
e := newEnv(o)
e.do("GET", "/boom", alice, 0)
if !strings.Contains(e.log.String(), "id=req-") {
return "log: " + strings.SplitN(e.log.String(), "\n", 2)[0]
}
return ""
}},
{"Q3 401・413・429 もルート付きで数える", func(o []string) string {
e := newEnv(o)
e.app.Limit = 1000
e.do("GET", "/ok", "", 0)
e.do("POST", "/upload", alice, big)
z := zeroBudget(o)
z.do("GET", "/ok", alice, 0)
s, sz := e.app.Snapshot(), z.app.Snapshot()
if s[Key{"GET /ok", 401}] != 1 || s[Key{"POST /upload", 413}] != 1 || sz[Key{"GET /ok", 429}] != 1 {
return fmt.Sprintf("%v / %v", s, sz)
}
return ""
}},
{"Q4 handler の panic は 500 として数える", func(o []string) string {
e := newEnv(o)
e.do("GET", "/boom", alice, 0)
if s := e.app.Snapshot(); s[Key{"GET /boom", 500}] != 1 {
return fmt.Sprint(s)
}
return ""
}},
{"Q5 認証の panic も 500", func(o []string) string {
e := newEnv(o)
rec, esc := e.do("GET", "/ok", "Bearer u", 0)
if esc != nil || rec.Code != 500 {
return fmt.Sprintf("code=%d escaped=%v", rec.Code, esc)
}
return ""
}},
{"Q6 不正トークンは利用者の予算を減らさない", func(o []string) string {
e := newEnv(o)
for i := 0; i < 5; i++ {
e.do("GET", "/ok", "Bearer x.forged", 0)
}
if rec, _ := e.do("GET", "/ok", alice, 0); rec.Code != 200 {
return fmt.Sprintf("alice got %d after 5 forged tokens from the same IP", rec.Code)
}
return ""
}},
{"Q7 上限超えの本文を送り続けても予算を使う", func(o []string) string {
e := newEnv(o)
var codes []int
for i := 0; i < 6; i++ {
rec, _ := e.do("POST", "/upload", alice, big)
codes = append(codes, rec.Code)
}
if codes[5] != 429 {
return fmt.Sprintf("codes=%v, want the 6th to be 429", codes)
}
return ""
}},
}
func check(order []string) []string {
var failed []string
for _, q := range requirements {
if why := q.ok(order); why != "" {
failed = append(failed, q.name[:2])
}
}
return failed
}
func TestOrder(t *testing.T) {
for _, q := range requirements {
t.Run(q.name, func(t *testing.T) {
if why := q.ok(Order); why != "" {
t.Fatal(why)
}
})
}
}
func permutations(xs []string) [][]string {
if len(xs) <= 1 {
return [][]string{append([]string(nil), xs...)}
}
var out [][]string
for i := range xs {
rest := append(append([]string(nil), xs[:i]...), xs[i+1:]...)
for _, p := range permutations(rest) {
out = append(out, append([]string{xs[i]}, p...))
}
}
return out
}
// 720 通りすべてを流し、全要件を通る順序と、要件ごとに何通りを落とすかを数える。
func TestEnumerateAllOrders(t *testing.T) {
perms := permutations(Order)
var pass [][]string
rejected := map[string]int{}
for _, p := range perms {
f := check(p)
for _, q := range f {
rejected[q]++
}
if len(f) == 0 {
pass = append(pass, p)
}
}
t.Logf("orders=%d pass=%d", len(perms), len(pass))
for _, p := range pass {
t.Logf(" pass: %s", strings.Join(p, " → "))
}
keys := make([]string, 0, len(rejected))
for k := range rejected {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
t.Logf(" %s rejects %d", k, rejected[k])
}
if len(pass) != 2 {
t.Fatalf("want exactly 2 passing orders (RequestID と Observe の入れ替えだけが自由), got %d", len(pass))
}
}
// 決めた順の隣どうしを 1 か所ずつ入れ替え、どの要件が落ちるかを出す(本文の表の元)。
func TestAdjacentSwaps(t *testing.T) {
for i := 0; i+1 < len(Order); i++ {
p := append([]string(nil), Order...)
p[i], p[i+1] = p[i+1], p[i]
t.Logf("swap %s<->%s: failed=%v", Order[i], Order[i+1], check(p))
}
}
// E1: Observe が r.Pattern で読むと、外側で r を複製する守りがあるとルートが空になる(Day 063 の続き)。
func TestE1_PatternLostThroughClone(t *testing.T) {
app := NewApp(log.New(io.Discard, "", 0))
var seen string
byPattern := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
seen = r.Pattern
})
}
for _, c := range []struct {
name string
ms []Middleware
}{
{"Observe が RequestID より内", []Middleware{app.RequestID, byPattern}},
{"Observe が RequestID より外", []Middleware{byPattern, app.RequestID}},
{"Observe が Auth より外", []Middleware{byPattern, app.Auth}},
} {
req := httptest.NewRequest("GET", "/ok", nil)
req.Header.Set("Authorization", alice)
Chain(app.Mux, c.ms...).ServeHTTP(httptest.NewRecorder(), req)
t.Logf("%s: r.Pattern=%q", c.name, seen)
}
_, p := app.Mux.Handler(httptest.NewRequest("GET", "/ok", nil))
_, p405 := app.Mux.Handler(httptest.NewRequest("DELETE", "/ok", nil))
_, p404 := app.Mux.Handler(httptest.NewRequest("GET", "/nope", nil))
t.Logf("mux.Handler: /ok=%q DELETE /ok=%q /nope=%q", p, p405, p404)
if p != "GET /ok" {
t.Fatalf("mux.Handler pattern = %q", p)
}
}
func BenchmarkStack200(b *testing.B) {
app := NewApp(log.New(io.Discard, "", 0))
app.Limit = 1 << 62
h := app.Build(Order)
req := httptest.NewRequest("GET", "/ok", nil)
req.Header.Set("Authorization", alice)
b.ReportAllocs()
for b.Loop() {
h.ServeHTTP(nullWriter{}, req)
}
}
func BenchmarkBareMux(b *testing.B) {
app := NewApp(log.New(io.Discard, "", 0))
req := httptest.NewRequest("GET", "/ok", nil)
b.ReportAllocs()
for b.Loop() {
app.Mux.ServeHTTP(nullWriter{}, req)
}
}
func BenchmarkMuxHandlerLookup(b *testing.B) {
app := NewApp(log.New(io.Discard, "", 0))
req := httptest.NewRequest("GET", "/ok", nil)
b.ReportAllocs()
for b.Loop() {
app.Mux.Handler(req)
}
}
type nullWriter struct{}
func (nullWriter) Header() http.Header { return http.Header{} }
func (nullWriter) Write(b []byte) (int, error) { return len(b), nil }
func (nullWriter) WriteHeader(int) {}
// 要件を 1 つ外すと、全要件を通る順序が何通りに増えるか(どの要件が順序を縛っているか)。
func TestDropOneRequirement(t *testing.T) {
perms := permutations(Order)
fails := make([][]string, len(perms))
for i, p := range perms {
fails[i] = check(p)
}
for _, q := range requirements {
id := q.name[:2]
n := 0
for _, f := range fails {
ok := true
for _, x := range f {
if x != id {
ok = false
}
}
if ok {
n++
}
}
t.Logf("without %s: pass=%d", id, n)
}
}
実測の数字(Go 1.27.1、Apple M1 8 コア)
| 項目 | 値 | 出所 |
|---|---|---|
| 720 通りのうち Q1〜Q7 を全部通る並び | 2 通り: RequestID → Observe → Recover → Auth → RateLimit → BodyLimit と、先頭 2 つを入れ替えたもの | 実測(TestEnumerateAllOrders) |
| 要件ごとに落とす並びの数 | Q1: 540/Q2: 360/Q3: 540/Q4: 360/Q5: 360/Q6: 360/Q7: 360 | 実測。Q1 は「RequestID が 4 つの中で先頭」= 1/4 だけ通るので 720×3/4 = 540(導出と一致) |
-race で 5 回連続 | すべて PASS、1 回 4.4〜6.1 秒(大半は 720 通り × 7 要件の列挙) | 実測 |
| 守り 6 つを通した 200 の応答 1 回 | 718〜802 ns、1,416 B、19 allocs(3 回の幅) | 実測(BenchmarkStack200。ヌル Writer の Header() が毎回 map を作るので、alloc は実サーバーより多めに出る) |
| 守りなしの mux だけ | 85〜130 ns、8 B、1 alloc | 実測(BenchmarkBareMux) |
mux.Handler(r) の 2 回目の照合 | 64〜82 ns、0 allocs | 実測(BenchmarkMuxHandlerLookup)。採った案の追加費用 |
| 実サーバーで 8 リクエスト | 200・401・500(Bearer u)・413・500(/boom)・405・200・429。**8 本すべてに X-Request-Id**(req-1〜req-8) | 実測(httptest.NewServer) |
| 同じ実行の集計 | {GET /ok 401}:1 {GET /ok 429}:1 {GET /ok 500}:1 {POST /upload 413}:1 {GET /boom 500}:1 {unmatched 405}:1 … | 実測。**405 は mux.Handler が空のパターンを返すので unmatched になる**(doc どおり) |
🪜 Step-by-Step 解説
Step 1: 要件を「対」に直す——3 つの仕組みのどれかで説明できるまで
Q5 を例にします。"Bearer u" を受けた parseToken は parts[1] を読んで panic します。この panic を 500 に変えられるのは、通り道に defer recover() を持つ守りだけ。Recover が Auth より内にあると、panic は Recover を通らずに外へ抜けます。だから Recover > Auth。
「なんとなく Recover は外側」ではなく、どの部品が panic しうるかを名指しして初めて対が決まる、という点が大事です。Day 064 の Observe → Recover も同じで、「Recover の内側で起きた panic は 500、外側を通り抜けた panic は StatusAborted」という仕組みの帰結でした(Q4)。
Step 2: 対をグラフにして数える
5 本の対を矢印にすると、
RequestID ─┐
├─▶ Recover ─▶ Auth ─▶ RateLimit ─▶ BodyLimit
Observe ───┘
Recover から先は一本道。Recover の前の 2 つは互いに縛られていないので、並べ方は 2 通り。これが手順 2 の予想で、TestEnumerateAllOrders の実測と一致しました。
予想と実測が一致したこと自体が、テストの検査になっている点に注意してください。たとえば Q1 のテストで 429 を「alice の 6 回目」で取り出していた最初の版では、他の要件の回数に引きずられて通る並びが 0 通りになりました(TestOrder の Q1 自体が code=200 want 429 で落ちた)。zeroBudget で 429 を独立に取り出して 2 通りになった。
Step 3: 隣どうしを入れ替えて「1 対 1」を確かめる
| 入れ替え | 落ちる要件 | クライアント/運用に見えること |
|---|---|---|
| RequestID ⇄ Observe | なし | 何も変わらない(自由な対) |
| Observe ⇄ Recover | Q4 | panic が StatusAborted と数えられ、500 の率のアラートが鳴らない |
| Recover ⇄ Auth | Q5 | 認証ライブラリの panic で 500 ではなく切断 |
| Auth ⇄ RateLimit | Q6 | 同じ NAT の後ろの正規利用者が、他人の偽トークンのせいで 429 |
| RateLimit ⇄ BodyLimit | Q7 | 上限超えの本文を何回でも送れる(413 が予算を使わない) |
隣の入れ替えごとに落ちる要件がちょうど 1 つ(自由な対は 0)。テストが多すぎも少なすぎもしない、という目安になります。
Step 4: 要件を 1 つ外すと、順序はどこまで緩むか
| 外す要件 | 通る並びの数 | 読み方 |
|---|---|---|
| Q1 | 2(変わらない) | 推移で出る。順序を決める力は無い |
| Q2 | 3 | RequestID が Recover と Auth の間にも入れるようになる——Q1 がそれ以上内側へ行くのを止める |
| Q3 | 2(変わらない) | 推移で出る |
| Q4 | 3 | Observe が Recover と Auth の間に入れる |
| Q5 | 8 | Recover が Auth より内側にも行ける |
| Q6 | 12 | 一番効いている要件。Auth と RateLimit の前後が自由になる |
| Q7 | 8 | BodyLimit が RateLimit より外にも行ける |
(実測: TestDropOneRequirement)。Q1・Q3 は「単独では効かないが、他の要件が外れたときの歯止め」です。
Step 5: ルート名の取り方が順序を縛るかどうか
Observe を r.Pattern で読む実装に差し替えて同じ列挙を流すと、通る並びは 0 通りでした(Q3 が 720 通りすべてを落とす)。401/413/429 は mux まで届かないので、r.Pattern は空のまま。さらに外側に r を複製する守りがあると、mux まで届いた 200 でも空になります(E1 の実測: Observe が RequestID より外: r.Pattern="")。
つまり「ルート名をどこで知るか」という部品の作り方が、積む順の自由度を 0 にも 2 にもする。順序の設計は部品の設計と切り離せない、というのが今日のいちばん大きな学びです。
🧭 設計思想・なぜこう書くのか
- **Go の
http.Handlerは「1 メソッドの小さいインターフェース」**。だから守りは何個でも積めるが、順序の意味は型に現れない(Chain(mux, a, b)とChain(mux, b, a)はどちらもコンパイルが通る)。型が守ってくれない約束はテストで守る——今日の 720 通りの列挙はその極端な形 - 明示性:
Orderを 1 つの変数に集めたのは、r.Useを何十行にも散らすと順序が「書いた順」という暗黙の約束になるため。1 行にすれば、レビューで差分が見える - 自由を記録する: 「RequestID と Observe はどちらでもよい」は判断の放棄ではなく、今の要件では縛れないという記録。縛る要件が来た日に見直す
🌐 他言語との比較
| FW | 順序の決め方 | 今日との違い |
|---|---|---|
| Go net/http(今日) | 関数で包む順 = 外→内。型は順序を区別しない | 列挙テストで縛る |
| ASP.NET Core | app.Use... を書いた順。公式ドキュメントに推奨順の図があり、UseAuthentication は UseRouting の後と書かれている(手元知識・今回は未確認) | ルーティングを先に済ませるので、401 にもルート名が付く——今日の mux.Handler 先引きと同じ考え |
| Express(Node.js) | app.use を書いた順。エラーハンドラは引数 4 つの関数で、最後に置く | Recover に当たるものが「最後」なのは、Express ではエラーが下(後ろ)へ流れるため。Go の panic は上(外)へ抜けるので逆になる |
| Spring Security(Java) | SecurityFilterChain のフィルタに順序の定数があり、ライブラリが並べる(手元知識・未確認) | 順序をライブラリが持つ。自作の守りを挟む位置は addFilterBefore/After で相手を名指しする——今日の「対」と同じ書き方 |
🏭 実務での使いどころ
- 新しい守りを足すとき: まず「既存のどの守りより外/内か」を対で書き、要件のテストを 1 本足す。列挙テストを残しておけば、並びの数が増えた・0 になったで気づける(6 つで 720 通り・約 2 秒。8 つだと 40,320 通りになるので、そのときは対ごとのテストだけにする)
- レビューの観点:
r.Useの差分を見たら「この守りは短絡するか・r を複製するか・panic を拾うか」を聞く。3 つのどれにも当たらなければ、順序はたぶん自由 - 観測: 405 が
unmatchedになる、短絡した応答のルート名をどう取るか、は集計の設計そのもの。ダッシュボードの「unmatched」系列が増えたら、守りの順序か取り方を疑う
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| 「Recover は一番外」 | 「全部拾いたい」から | 一番外だと集計(Observe)が panic を StatusAborted と数える(Q4)。Recover の外に置くべき守りがある |
| 「レート制限は一番外(安いから先に弾く)」 | 費用だけで並べる | 利用者ごとに数えるなら認証の内側(Q6)。IP の粗い制限が欲しいなら別の守りとして足す |
| 「本文上限は一番内(ハンドラの直前)でよい」 | 本文を読むのはハンドラだから | 413 を予算に数えたいならレート制限の内側(Q7)。「読む場所」と「数える場所」は別 |
「r.Pattern があればルート名は取れる」 | Day 063 で使えたから | 短絡した応答と、外側で複製された r では空(E1・Step 5) |
| 「テストが全部通ったから順序は正しい」 | 決めた並び 1 つしか流していない | 他の並びが落ちることも確かめないと、テストが順序を縛っているか分からない(列挙・入れ替え) |
🚧 扱わなかった範囲と理由
- タイムアウト(Day 061):
http.TimeoutHandlerは別の goroutine でハンドラを動かし、応答を貯める。panic の伝わり方・rの複製・Flush の可否が同時に変わり、6 つの対の話が倍になるので今日は外した。足すなら Recover との前後と、集計が 503 をどう数えるかの 2 対から - IP ごとの粗い制限(総当たり対策): Q6 の「採らなかった案」に書いたとおり別の守り。今日の 6 つには入れていない
- CORS・圧縮(gzip)・トレーシング: 圧縮は Day 060 で「ResponseWriter を包む側の約束」として扱った。積む順の表には未反映
- HTTP/2・実運用の負荷: 費用は 1 リクエストのベンチマークだけ。並列・p99 は測っていない
- ASP.NET Core・Spring Security の推奨順: 手元知識。ドキュメントは今回読んでいない
- ルート単位の守り(Day 057 の宣言表): 今日は全ルート共通の 1 本だけ。ルートごとに Auth を外す(
/healthzなど)設計は別の回で
🚀 次のステップ
- 発展: タイムアウトを 7 つ目として足し、Recover・Observe との対を 2 本書いてから列挙(5,040 通り)で確かめる
- 発展: 集計に ID を載せる要件(exemplar)を Q8 として書き、通る並びが 1 通りに減ることを確かめる
- 次回予告: 同テーマ(10/25 まで据え置き)。次は「実装」軸で、ルートごとに守りを外す・足す(
/healthzは認証なし、/uploadだけ上限 10 MiB)を、今日のOrderを壊さずに書く方法