Day 065 — 2026-10-05 | ミドルウェア設計パターン: 守り 6 つの積む順を 1 本に決める——7 つの要件を「A は B より外」の対に直し、720 通りを全部流して通る順序が 2 通りしかないことを確かめる

2026-10-05 🟡 上級者 / Phase 3 設計・アーキテクチャ ミドルウェア設計パターン
フェーズ: 上級者 (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. 要件を 1 つずつ「A は B より外でなければならない」という対に直す(順序の制約)
  2. 対を全部満たす並びを数える。対が足りなければ複数の並びが残る——残った自由は「どちらでもよい」と記録する
  3. 要件をテストにし、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 つです。

要件内容運用上の理由
Q1200・401・413・429・500・404 のすべての応答に X-Request-Id が付く顧客の問い合わせを ID でログと突き合わせる
Q2panic のログにその リクエストの ID が出る500 の原因をすぐ引ける
Q3401・413・429 もルート名つきで数える「どの API が攻撃されているか」を見る
Q4ハンドラの panic は 500 として数える(切断=StatusAborted ではない)500 の率でアラートを出す
Q5認証の部品が panicしても 500 が返る("Bearer u" で parseToken が範囲外アクセス)認証ライブラリの不具合で接続が切れないように
Q6同じ IP から偽トークンを 5 回送られても、正規の利用者は 200 を受け取れる同じ NAT の後ろの利用者を巻き込まない
Q7上限超えの本文を送り続ける利用者も、6 回目には 429 になる413 を無料で引き出させない

やること

  1. 要件ごとに「A は B より外」の対を書き出す(例: Q4 → Observe は Recover より外)。どの対が他の対から自動的に出てくるか(推移)も書く
  2. 対をすべて満たす並びが何通りあるかを、テストを書く前に予想する。自由に入れ替えられる対はどれか
  3. Order を決め、Q1〜Q7 をテストにする(土台と Q1 は下に用意した)。permutations で 720 通りを流し、予想と一致するか確かめる
  4. 設計判断を 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 の答え)

要件対(外 > 内)縛る仕組み他の対から出てくるか
Q2RequestID > Recover複製(Recover が ctx の ID を読む)—
Q4Observe > Recoverpanic(内だと 500 ではなく StatusAborted と数える)—
Q5Recover > Authpanic(Auth の panic が外へ漏れる)—
Q6Auth > RateLimit複製(RateLimit が ctx の利用者を読む。無ければ IP で数える)—
Q7RateLimit > BodyLimit短絡(BodyLimit の 413 が予算を使わずに返る)—
Q1RequestID > Auth・RateLimit・BodyLimit短絡出てくる(Q2・Q5・Q6・Q7 をつなぐと RequestID > Recover > Auth > RateLimit > BodyLimit)
Q3Observe > 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 ⇄ RecoverQ4panic が StatusAborted と数えられ、500 の率のアラートが鳴らない
Recover ⇄ AuthQ5認証ライブラリの panic で 500 ではなく切断
Auth ⇄ RateLimitQ6同じ NAT の後ろの正規利用者が、他人の偽トークンのせいで 429
RateLimit ⇄ BodyLimitQ7上限超えの本文を何回でも送れる(413 が予算を使わない)

隣の入れ替えごとに落ちる要件がちょうど 1 つ(自由な対は 0)。テストが多すぎも少なすぎもしない、という目安になります。

Step 4: 要件を 1 つ外すと、順序はどこまで緩むか

外す要件通る並びの数読み方
Q12(変わらない)推移で出る。順序を決める力は無い
Q23RequestID が Recover と Auth の間にも入れるようになる——Q1 がそれ以上内側へ行くのを止める
Q32(変わらない)推移で出る
Q43Observe が Recover と Auth の間に入れる
Q58Recover が Auth より内側にも行ける
Q612一番効いている要件。Auth と RateLimit の前後が自由になる
Q78BodyLimit が 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 Coreapp.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 を壊さずに書く方法

🎯 自己評価

手順 2 の予想(何通り)と実測

自分の回答

気づき・メモ