Day 066 — 2026-10-06 | ミドルウェア設計パターン: ルートごとに守りを外す・足す——積む順は 1 本のまま「ポリシー」を ctx に置いて守りに読ませ、書き忘れが「開く」側に倒れないことを 10 通りの変異と 5,040 通りの並びで確かめる

2026-10-06 🟡 上級者 / Phase 3 実装 ミドルウェア設計パターン
フェーズ: 上級者 (Phase 3) | 問題タイプ: 実装 | テーマ: ミドルウェア設計パターン
Day 065 で決めた積む順 RequestID → Observe → Recover → Auth → RateLimit → BodyLimit を壊さずに、ルートごとの違い(/healthz は認証なし・制限なし、/upload だけ上限 10 MiB、/admin/users は admin だけ)を足す。Day 065 の次回予告どおり「実装」。

📚 背景知識(読んでから問題へ)

Day 065 では、守りを全ルート共通の 1 本として積み、順序を 5 本の対(RequestID>Recover・Observe>Recover・Recover>Auth・Auth>RateLimit・RateLimit>BodyLimit)で縛りました。ところが本物の API は全ルートが同じではありません。

ルート共通の 1 本との違い運用上の理由
GET /healthz認証しない・レート制限しないロードバランサや Kubernetes の probe はトークンを持たず、数秒おきに叩く
POST /upload本文の上限が 10 MiB(他は 1 MiB)画像を受ける。他のルートを 10 MiB にすると、全ルートが大きな本文を受け入れてしまう
GET /admin/usersadmin ロールだけ(足す)管理画面

ここで「外す」と「足す」は性質が違います。足すのは簡単です——ルートの内側に守りを 1 つ挟めばよい(chi の With はまさにこれで、mux.go 242〜261 行は親の守りの列に append するだけ。外す手段は無い)。難しいのは外すほうで、共通の 1 本の中にある Auth を /healthz のときだけ飛ばす方法が要ります。

外し方は 3 つある

方式どう書くか今日の実測で見えたこと
(A) 守りの中でパスを見て飛ばすif strings.HasPrefix(r.URL.Path, "/healthz") { next... }GET /healthz/orders が GET /{tenant}/orders に一致し、認証なしで orders of healthz が返る(E1)
(B) 共通部分だけ mux の外に置き、Auth 以降はルートごとに mux の内側で包む(Day 057 の案C)mux.Handle("GET /ok", Chain(h, Auth, RateLimit, BodyLimit))認証なしで叩くと、存在しないルートは 404、メソッド違いは 405、存在するルートは 401——どのルートがあるかを外から数えられる(E2)
(C) ルートの宣言表にポリシーを書き、共通の 1 本の守りがそれを ctx から読む(今日の採用)a.Handle("GET /healthz", h, Policy{Public: true, NoRateLimit: true})積む順は 1 本のまま。照合は mux.Handler(r) で 1 回だけ(Day 065 の先引きと同じ)

(C) は ASP.NET Core の「エンドポイントのメタデータ」(UseRouting でルートを決め、[AllowAnonymous] などの印を後段の認可が読む)と同じ考え方です(手元知識・今回はドキュメント未確認)。

今日の核心: 書き忘れはどちらに倒れるか

ポリシーは Go の構造体で書きます。ここでゼロ値の意味が安全性を決めます。

  • RequireAuth bool と書くと、ゼロ値(書き忘れ)は「認証しない」=開く
  • Public bool と書くと、ゼロ値は「認証する」=閉じる

Go の「ゼロ値を役に立つ値にせよ」という慣習を、ゼロ値を最も厳しい値にせよと読み替えるのが今日の設計です。ただし、これだけでは足りない場面があることを P6 で実測します(「締める向き」のフィールド Role はゼロ値が「開く」になってしまう)。

参照日 2026-10-06。一次資料: 手元の Go 1.27.1 のソース(net/http/server.go 2537 行「GET のパターンは GET と HEAD の両方に一致する」、2688〜2690 行 ServeMux.Handler の doc、net/http/request.go 373 行 WithContext、1209 行 MaxBytesReader)、go-chi/chi v5.3.2(mux.go 95〜105 行 Use「all middlewares must be defined before routes」、242〜261 行 With)。いずれも手元の実物。

世界の実物との位置づけ

  • chi は「足す」(With・Group)を持つが「外す」を持たない。外したいときは Group で共通の 1 本を分けるのが chi の流儀=(B) に近い。今日の (C) は chi に無い形を自作している
  • 今日扱う中核概念は 5 つ: 外し方 3 方式の比較、ゼロ値を閉じる側に倒す、ポリシー不在時の既定(fail-closed)、照合を 1 回にして ctx に置く、変異で要件テストの穴を探す。扱わないものは末尾

📝 問題

Day 065 の guards.go に、ルートの宣言表(Handle(pattern, handler, Policy))と /healthz・/upload(10 MiB)・/admin/users・/{tenant}/orders・/echo を足した出発点を用意しました。守りはまだポリシーを読みません。

やること

  1. policy.go の Route(mux と同じ照合を 1 回して、パターンとポリシーを ctx に置く)と resolve(ctx から読む)を実装する
  2. guards.go の Auth・RateLimit・BodyLimit の TODO を埋め、ポリシーを読ませる
  3. order.go の Order に Route を足す。Day 065 の 6 つの相対順は変えない。足す前に「Route は誰より外でなければならないか」を対で書き、7! = 5,040 通りのうち何通りが全要件を通るかを予想する
  4. 設計判断を 2 つ:
    • 未登録のパス・405 のとき、Route はどのポリシーを置くか
    • Route を積み忘れたとき(誰かが Chain を手で組んだ等)、resolve は何を返すか。ゼロ値の Policy{} で足りるか

要件(テストは下に全部ある)

要件内容
P1/healthz は認証なしで 200、X-Request-Id 付き、GET /healthz として数える。HEAD でも 200
P2/healthz を同じ IP から 20 回叩いても 429 にならない(上限 5 回の設定で)
P3/upload は 5 MiB を受け、11 MiB は 413。/echo は 2 MiB で 413。Content-Length を伏せた(chunked)送信でも同じ
P4/admin/users: 認証なし 401・alice 403・root(admin)200。403 も GET /admin/users として数える
P5/healthz/orders(/{tenant}/orders に一致)・/nope・DELETE /healthz は認証なしで 401
P6Route を積み忘れた並びでは誰も通さない(/healthz 認証なし 401、alice の /upload・/admin/users・/ok は 403)
Q1〜Q7Day 065 の 7 要件(順序を壊していないか)

準備

mkdir d66 && cd d66                          # 作業ディレクトリを作って入る
printf 'module d66\n\ngo 1.24\n' > go.mod      # モジュールを作る(b.Loop に Go 1.24 以上が必要)
# 下の guards.go・policy.go・order.go・policy_test.go を保存する
go test -count=1 -short ./...                # 出発点では P1・P2・P3・P4・P6 の 5 本が落ちる(実測)

ファイル 1: guards.go(出発点。TODO の 3 か所を埋める)

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
	routeKey
)

const StatusAborted = 0

type Key struct {
	Route  string
	Status int
}

type App struct {
	Mux      *http.ServeMux
	Log      *log.Logger
	Limit    int
	Roles    map[string]string // 利用者 → ロール
	policies map[string]Policy // 登録パターン → ポリシー
	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, Limit: 5,
		Roles:    map[string]string{"root": "admin"},
		policies: map[string]Policy{},
		stats:    map[Key]int{}, used: map[string]int{}}
	ok := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") }
	readAll := 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)
	}
	// ルートの宣言表(Day 057 と同じ考え)。守り方の違いはポリシーの値だけで書く。
	a.Handle("GET /healthz", ok, Policy{Public: true, NoRateLimit: true})
	a.Handle("GET /ok", ok, Policy{})
	a.Handle("POST /echo", readAll, Policy{})
	a.Handle("POST /upload", readAll, Policy{MaxBody: 10 << 20})
	a.Handle("GET /admin/users", ok, Policy{Role: "admin"})
	a.Handle("GET /{tenant}/orders", func(w http.ResponseWriter, r *http.Request) {
		io.WriteString(w, "orders of "+r.PathValue("tenant"))
	}, Policy{})
	a.Handle("GET /boom", func(w http.ResponseWriter, r *http.Request) { panic("db down") }, Policy{})
	return a
}

func (a *App) Handle(pattern string, h http.HandlerFunc, p Policy) {
	a.Mux.Handle(pattern, h)
	a.policies[pattern] = p
}

func (a *App) Build(order []string) http.Handler {
	byName := map[string]Middleware{
		"RequestID": a.RequestID, "Route": a.Route, "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 "-"
}

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: (ルート, ステータス) を数える。
func (a *App) Observe(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, route := a.Mux.Handler(r) // TODO(任意): Route を作ったら ctx から読む(照合を 1 回にする)
		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
	})
}

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)
	})
}

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
}

func (a *App) Auth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// TODO: Public なら認証しない。Role が指定されていれば一致しない利用者に 403
		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)))
	})
}

func (a *App) RateLimit(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// TODO: NoRateLimit なら数えない
		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)
	})
}

func (a *App) BodyLimit(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		max := int64(defaultMaxBody) // TODO: ルートのポリシーの上限を使う
		if r.ContentLength > max {
			http.Error(w, http.StatusText(413), 413)
			return
		}
		r.Body = http.MaxBytesReader(w, r.Body, max)
		next.ServeHTTP(w, r)
	})
}

func main() {}

ファイル 2: policy.go(出発点。Route と resolve を実装する)

package main

import "net/http"

const defaultMaxBody = 1 << 20

// Policy はルートごとの守り方。ゼロ値がいちばん厳しい(認証あり・制限あり・上限 1 MiB・ロール不問)。
// 書き忘れたフィールドは「守る」側に倒れるよう、緩める向きの名前(Public・NoRateLimit)にしてある。
type Policy struct {
	Public      bool   // true なら認証しない
	NoRateLimit bool   // true ならレート制限しない
	MaxBody     int64  // 0 なら defaultMaxBody
	Role        string // "" ならロール不問
}

func (p Policy) maxBody() int64 {
	if p.MaxBody > 0 {
		return p.MaxBody
	}
	return defaultMaxBody
}

type routeInfo struct {
	Pattern string // 一致した登録パターン。一致しなければ ""
	Policy  Policy
}

// Route は mux と同じ照合を 1 回だけ行い、パターンとポリシーを ctx に置く。
// TODO: 実装する。未登録・405 のときに何を置くかも決める
func (a *App) Route(next http.Handler) http.Handler {
	return next
}

// resolve は Route が置いた値を返す。
// TODO: Route が積まれていないときに何を返すかを決める(P6)
func (a *App) resolve(r *http.Request) routeInfo {
	ri, _ := r.Context().Value(routeKey).(routeInfo)
	return ri
}

ファイル 3: order.go

package main

// Order は外→内の積む順。Day 065 の 6 つ。Route をどこに足すかを決める。
var Order = []string{"RequestID", "Observe", "Recover", "Auth", "RateLimit", "BodyLimit"}

ファイル 4: policy_test.go(受け入れテスト。変えない)

policy_test.go 全体(P1〜P6・Q1〜Q7・5,040 通りの列挙・隣の入れ替え)
package main

import (
	"bytes"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"sort"
	"strings"
	"testing"
)

const (
	alice = "Bearer u.alice"
	root  = "Bearer u.root"
	MiB   = 1 << 20
)

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}
}

type zeros struct{}

func (zeros) Read(p []byte) (int, error) { clear(p); return len(p), nil }

// do は 1 リクエストを流す。body>0 なら body バイトのゼロを送る。
// chunked=true なら Content-Length を伏せる(-1)。守りから漏れた panic も受け止める。
func (e *env) do(method, path, auth string, body int64, chunked bool) (rec *httptest.ResponseRecorder, escaped any) {
	req := httptest.NewRequest(method, path, nil)
	if body > 0 {
		req.Body = io.NopCloser(io.LimitReader(zeros{}, body))
		req.ContentLength = body
		if chunked {
			req.ContentLength = -1
		}
	}
	req.RemoteAddr = "203.0.113.7:4000"
	if auth != "" {
		req.Header.Set("Authorization", auth)
	}
	rec = httptest.NewRecorder()
	defer func() { escaped = recover() }()
	e.h.ServeHTTP(rec, req)
	return rec, nil
}

func zeroBudget(o []string) *env { e := newEnv(o); e.app.Limit = 0; return e }

type req struct {
	m, p, a string
	body    int64
	chunked bool
	want    int
}

// expect は reqs を順に流し、ステータスが違えば理由を返す。
func expect(e *env, reqs ...req) string {
	for _, c := range reqs {
		rec, esc := e.do(c.m, c.p, c.a, c.body, c.chunked)
		if esc != nil || rec.Code != c.want {
			return fmt.Sprintf("%s %s auth=%q body=%d chunked=%v: code=%d want %d escaped=%v",
				c.m, c.p, c.a, c.body, c.chunked, rec.Code, c.want, esc)
		}
	}
	return ""
}

var requirements = []struct {
	name string
	ok   func(o []string) string
}{
	// ---- 今日の要件(ルートごとの違い) ----
	{"P1 /healthz は認証なしで 200・ID 付き・ルート名で数える", func(o []string) string {
		e := newEnv(o)
		rec, _ := e.do("GET", "/healthz", "", 0, false)
		if rec.Code != 200 || rec.Header().Get("X-Request-Id") == "" {
			return fmt.Sprintf("code=%d id=%q", rec.Code, rec.Header().Get("X-Request-Id"))
		}
		if s := e.app.Snapshot(); s[Key{"GET /healthz", 200}] != 1 {
			return fmt.Sprint(s)
		}
		// HEAD も GET のパターンに一致する(ServeMux の仕様)。LB が HEAD で叩く設定でも通ること
		return expect(e, req{"HEAD", "/healthz", "", 0, false, 200})
	}},
	{"P2 /healthz はレート制限しない(LB が毎秒叩く)", func(o []string) string {
		e := newEnv(o)
		for i := 0; i < 20; i++ {
			if why := expect(e, req{"GET", "/healthz", "", 0, false, 200}); why != "" {
				return fmt.Sprintf("probe #%d: %s", i+1, why)
			}
		}
		return ""
	}},
	{"P3 /upload だけ 10 MiB(他は 1 MiB)。Content-Length を伏せても同じ", func(o []string) string {
		e := newEnv(o)
		e.app.Limit = 1000
		return expect(e,
			req{"POST", "/upload", alice, 5 * MiB, false, 200},
			req{"POST", "/upload", alice, 11 * MiB, false, 413},
			req{"POST", "/upload", alice, 5 * MiB, true, 200},
			req{"POST", "/upload", alice, 11 * MiB, true, 413},
			req{"POST", "/echo", alice, 2 * MiB, false, 413},
			req{"POST", "/echo", alice, 2 * MiB, true, 413},
		)
	}},
	{"P4 /admin/users は admin だけ。403 もルート名で数える", func(o []string) string {
		e := newEnv(o)
		e.app.Limit = 1000
		if why := expect(e,
			req{"GET", "/admin/users", "", 0, false, 401},
			req{"GET", "/admin/users", alice, 0, false, 403},
			req{"GET", "/admin/users", root, 0, false, 200},
		); why != "" {
			return why
		}
		if s := e.app.Snapshot(); s[Key{"GET /admin/users", 403}] != 1 {
			return fmt.Sprint(s)
		}
		return ""
	}},
	{"P5 公開ルートに似たパス・未登録パスで認証を抜けない", func(o []string) string {
		e := newEnv(o)
		e.app.Limit = 1000
		return expect(e,
			req{"GET", "/healthz/orders", "", 0, false, 401}, // GET /{tenant}/orders に一致する
			req{"GET", "/nope", "", 0, false, 401},           // 未登録は最も厳しいポリシー
			req{"DELETE", "/healthz", "", 0, false, 401},     // 405 も同じ
		)
	}},
	{"P6 Route を積み忘れたら誰も通さない(閉じる側に倒れる)", func(o []string) string {
		var without []string
		for _, n := range o {
			if n != "Route" {
				without = append(without, n)
			}
		}
		e := newEnv(without)
		e.app.Limit = 1000
		return expect(e,
			req{"GET", "/healthz", "", 0, false, 401},
			req{"POST", "/upload", alice, 5 * MiB, false, 403}, // 認証済みでも通さない(上限 10 MiB のルートでも 413 より先に止まる)
			req{"GET", "/admin/users", alice, 0, false, 403},   // ゼロ値ポリシーだとここが 200 で開いた
			req{"GET", "/ok", alice, 0, false, 403},            // 誰も通さない
		)
	}},
	// ---- Day 065 の要件(順序を壊していないか) ----
	{"Q1 すべての応答に X-Request-Id", func(o []string) string {
		e := newEnv(o)
		e.app.Limit = 1000
		for _, c := range []struct {
			e *env
			r req
		}{
			{e, req{"GET", "/ok", alice, 0, false, 200}},
			{e, req{"GET", "/ok", "", 0, false, 401}},
			{e, req{"POST", "/echo", alice, 2 * MiB, false, 413}},
			{e, req{"GET", "/boom", alice, 0, false, 500}},
			{zeroBudget(o), req{"GET", "/ok", alice, 0, false, 429}},
		} {
			rec, _ := c.e.do(c.r.m, c.r.p, c.r.a, c.r.body, false)
			if rec.Code != c.r.want || rec.Header().Get("X-Request-Id") == "" {
				return fmt.Sprintf("%s: code=%d want %d id=%q", c.r.p, rec.Code, c.r.want, rec.Header().Get("X-Request-Id"))
			}
		}
		return ""
	}},
	{"Q2 panic のログに ID", func(o []string) string {
		e := newEnv(o)
		e.do("GET", "/boom", alice, 0, false)
		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, false)
		e.do("POST", "/echo", alice, 2*MiB, false)
		z := zeroBudget(o)
		z.do("GET", "/ok", alice, 0, false)
		s, sz := e.app.Snapshot(), z.app.Snapshot()
		if s[Key{"GET /ok", 401}] != 1 || s[Key{"POST /echo", 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, false)
		if s := e.app.Snapshot(); s[Key{"GET /boom", 500}] != 1 {
			return fmt.Sprint(s)
		}
		return ""
	}},
	{"Q5 認証の panic も 500", func(o []string) string {
		return expect(newEnv(o), req{"GET", "/ok", "Bearer u", 0, false, 500})
	}},
	{"Q6 偽トークンは利用者の予算を減らさない", func(o []string) string {
		e := newEnv(o)
		for i := 0; i < 5; i++ {
			e.do("GET", "/ok", "Bearer x.forged", 0, false)
		}
		return expect(e, req{"GET", "/ok", alice, 0, false, 200})
	}},
	{"Q7 上限超えの本文を送り続けても予算を使う", func(o []string) string {
		e := newEnv(o)
		var codes []int
		for i := 0; i < 6; i++ {
			rec, _ := e.do("POST", "/echo", alice, 2*MiB, false)
			codes = append(codes, rec.Code)
		}
		if codes[5] != 429 {
			return fmt.Sprintf("codes=%v", codes)
		}
		return ""
	}},
}

func check(order []string) []string {
	var failed []string
	for _, q := range requirements {
		if q.ok(order) != "" {
			failed = append(failed, q.name[:2])
		}
	}
	return failed
}

func TestPolicy(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
}

// 7! = 5040 通りを流す。時間がかかるので -short では飛ばす。
func TestEnumerateAllOrders(t *testing.T) {
	if testing.Short() {
		t.Skip("5040 通りの列挙は -short で飛ばす")
	}
	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) != 3 {
		t.Fatalf("want 3 passing orders, got %d", len(pass))
	}
}

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))
	}
}

受け入れ条件

  • go test -race -short -count=1 ./... が 5 回連続で通る
  • go test -count=1 -run TestEnumerateAllOrders ./... が「5,040 通り中 3 通り」を出す(予想と違ったら、どの対を見落としたかを書く)
  • 手順 4 の 2 つの判断について、採らなかった案と理由を 1 行ずつ書く

💡 ヒント

ヒント1(方向性)

Route が置いた値を読む守りは、Route より内側でなければ読めません(r.WithContext は複製なので、内側で置いた値は外側に戻らない——Day 065 の「複製」の仕組み)。Route を読むのは誰か、を数えると対が出ます。

ヒント2(アプローチ)
  • 照合は _, pattern := a.Mux.Handler(r)。pattern で a.policies を引く。r.URL.Path で引かない(HEAD・ワイルドカード・リダイレクトで mux と食い違う)
  • 未登録は「一番厳しいポリシー」。Policy のフィールドは緩める向きの名前(Public・NoRateLimit)なので、Policy{} が一番厳しい
  • ただし Role は締める向き。Route が無いときに Policy{} を返すと、Role が空=誰でも admin ルートに入れる。誰も持たないロールを置く手がある
ヒント3(コード骨格)
func (a *App) Route(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, pattern := a.Mux.Handler(r)
		p, ok := a.policies[pattern]
		if !ok {
			pattern, p = "", Policy{}
		}
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), routeKey, routeInfo{pattern, p})))
	})
}

Order は Route を Observe と Auth より外へ。RequestID との前後は自由。

✅ 模範解答

Route が満たすべき対(手順 3 の答え)

対(外 > 内)どの要件が縛るか仕組み
Route > AuthP1(/healthz を認証なしで通す)・P3・P4複製(Auth が ctx のポリシーを読む)。Route が内側だと Auth は「Route 不在」=誰も通さないで動く
Route > RateLimitP2同上
Route > BodyLimitP3同上
Route > ObserveP1・P4・Q3(ルート名つきで数える)・Q4複製(Observe が ctx のパターンを読む)
Route と RequestID縛られない互いの ctx の値を読まない

Day 065 の 5 本と合わせると、Recover より外にある {RequestID, Route, Observe} の並べ方のうち「Route が Observe より前」のものだけが通る。3! ÷ 2 = 3 通り(予想)→ 実測も 3 通り(下)。

policy.go(模範解答)

package main

import (
	"context"
	"net/http"
)

const defaultMaxBody = 1 << 20

// Policy はルートごとの守り方。ゼロ値がいちばん厳しい(認証あり・制限あり・上限 1 MiB・ロール不問)。
// 書き忘れたフィールドは「守る」側に倒れるよう、緩める向きの名前(Public・NoRateLimit)にしてある。
type Policy struct {
	Public      bool   // true なら認証しない
	NoRateLimit bool   // true ならレート制限しない
	MaxBody     int64  // 0 なら defaultMaxBody
	Role        string // "" ならロール不問
}

func (p Policy) maxBody() int64 {
	if p.MaxBody > 0 {
		return p.MaxBody
	}
	return defaultMaxBody
}

type routeInfo struct {
	Pattern string // 一致した登録パターン。一致しなければ ""
	Policy  Policy
}

// Route は mux と同じ照合を 1 回だけ行い、パターンとポリシーを ctx に置く(r を複製する)。
func (a *App) Route(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, pattern := a.Mux.Handler(r)
		p, ok := a.policies[pattern]
		if !ok {
			pattern, p = "", Policy{} // 未登録・405・リダイレクトは最も厳しいポリシー
		}
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), routeKey, routeInfo{pattern, p})))
	})
}

// denyAll はどの利用者も持たないロール。Route が積まれていないときに使う。
const denyAll = "\x00route-missing"

// resolve は Route が置いた値を返す。Route が積まれていなければ「誰も通さない」ポリシーを返す。
// ゼロ値ではない点に注意: Role は締める向きのフィールドなので、ゼロ値だと admin 限定ルートが開く(P6 で実測)。
func (a *App) resolve(r *http.Request) routeInfo {
	if ri, ok := r.Context().Value(routeKey).(routeInfo); ok {
		return ri
	}
	return routeInfo{Policy: Policy{Role: denyAll}}
}

order.go(模範解答)

package main

// Order は外→内の積む順。Day 065 の 6 つに Route を足しただけで、6 つの相対順は変えない。
var Order = []string{"RequestID", "Route", "Observe", "Recover", "Auth", "RateLimit", "BodyLimit"}

guards.go の変更(出発点との差分)

--- guards.go(出発点)
+++ guards.go(模範解答)
@@ -146,10 +146,10 @@
 
 func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
 
-// Observe: (ルート, ステータス) を数える。
+// Observe はルート名を Route が ctx に置いた値から読む(Route が積まれていなければ unmatched)。
 func (a *App) Observe(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		_, route := a.Mux.Handler(r) // TODO(任意): Route を作ったら ctx から読む(照合を 1 回にする)
+		route := a.resolve(r).Pattern
 		if route == "" {
 			route = "unmatched"
 		}
@@ -207,19 +207,30 @@
 
 func (a *App) Auth(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		// TODO: Public なら認証しない。Role が指定されていれば一致しない利用者に 403
+		p := a.resolve(r).Policy
+		if p.Public {
+			next.ServeHTTP(w, r)
+			return
+		}
 		user, ok := parseToken(r.Header.Get("Authorization"))
 		if !ok {
 			http.Error(w, http.StatusText(401), 401)
 			return
 		}
+		if p.Role != "" && a.Roles[user] != p.Role {
+			http.Error(w, http.StatusText(403), 403)
+			return
+		}
 		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, user)))
 	})
 }
 
 func (a *App) RateLimit(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		// TODO: NoRateLimit なら数えない
+		if a.resolve(r).Policy.NoRateLimit {
+			next.ServeHTTP(w, r)
+			return
+		}
 		key := "ip:" + r.RemoteAddr
 		if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
 			key = "ip:" + host
@@ -242,7 +253,7 @@
 
 func (a *App) BodyLimit(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		max := int64(defaultMaxBody) // TODO: ルートのポリシーの上限を使う
+		max := a.resolve(r).Policy.maxBody()
 		if r.ContentLength > max {
 			http.Error(w, http.StatusText(413), 413)
 			return
実験用 experiments_test.go(E1 接頭辞で飛ばす・E2 ルートごとに包む・E3 mux.Handler が返すパターン・E4 実サーバー・ベンチマーク)
package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

// E1: 採らなかった案 A —— パスの接頭辞で認証を飛ばす(Day 057 の案A の書き方)。
func TestE1_PrefixSkipBypass(t *testing.T) {
	app := NewApp(log.New(io.Discard, "", 0))
	app.Limit = 1000
	prefixAuth := func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if strings.HasPrefix(r.URL.Path, "/healthz") { // 公開ルートの一覧をパスで持つ
				next.ServeHTTP(w, r)
				return
			}
			user, ok := parseToken(r.Header.Get("Authorization"))
			if !ok {
				http.Error(w, "Unauthorized", 401)
				return
			}
			next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, user)))
		})
	}
	h := Chain(app.Mux, app.RequestID, app.Recover, prefixAuth)
	for _, p := range []string{"/healthz", "/healthz/orders", "/healthzz/orders", "/ok"} {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
		t.Logf("prefix-skip  GET %-17s → %d %q", p, rec.Code, strings.TrimSpace(rec.Body.String()))
	}
}

// E2: 採らなかった案 B —— 共通部分だけ mux の外、Auth 以降はルートごとに mux の内側で包む(Day 057 の案C)。
// 認証なしのクライアントから見て、存在するルートと存在しないルートが区別できるか。
func TestE2_PerRouteInsideMux(t *testing.T) {
	type c struct{ m, p string }
	cases := []c{{"GET", "/admin/users"}, {"GET", "/nope"}, {"DELETE", "/healthz"}, {"GET", "/ok"}}
	// B: ルートごとに包む
	app := NewApp(log.New(io.Discard, "", 0))
	mux := http.NewServeMux()
	ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
	for _, pat := range []string{"GET /admin/users", "GET /ok"} {
		mux.Handle(pat, Chain(ok, app.Auth))
	}
	mux.Handle("GET /healthz", ok)
	// B の Auth は ctx に routeInfo が無いので denyAll で動く。ここでは 401 か 404/405 かだけを見る。
	hb := Chain(mux, app.RequestID, app.Observe, app.Recover)
	ha := newEnv(Order).h
	for _, x := range cases {
		rb, ra := httptest.NewRecorder(), httptest.NewRecorder()
		hb.ServeHTTP(rb, httptest.NewRequest(x.m, x.p, nil))
		ha.ServeHTTP(ra, httptest.NewRequest(x.m, x.p, nil))
		t.Logf("認証なし %-6s %-13s  B(ルートごと)=%d  採用(ポリシー)=%d", x.m, x.p, rb.Code, ra.Code)
	}
}

// E3: mux.Handler が返す pattern。リダイレクトのときは「パターン」ではなく「行き先のパス」が返る(doc どおり)。
func TestE3_HandlerPatternVariants(t *testing.T) {
	app := NewApp(log.New(io.Discard, "", 0))
	h := newEnv(Order).h
	for _, x := range []struct{ m, p string }{
		{"GET", "/healthz"}, {"HEAD", "/healthz"}, {"GET", "//healthz"}, {"GET", "/healthz/"},
		{"GET", "/a/../healthz"}, {"GET", "/HEALTHZ"}, {"GET", "/healthz/orders"}, {"DELETE", "/healthz"},
	} {
		r := httptest.NewRequest(x.m, "http://example.com"+x.p, nil)
		_, pat := app.Mux.Handler(r)
		_, inPolicy := app.policies[pat]
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest(x.m, "http://example.com"+x.p, nil))
		t.Logf("%-6s %-16s pattern=%-22q 表にある=%-5v 認証なしの応答=%d", x.m, x.p, pat, inPolicy, rec.Code)
	}
}

// E4: 実サーバーで確かめる(Content-Length 付き・chunked の両方)。
func TestE4_RealServer(t *testing.T) {
	app := NewApp(log.New(io.Discard, "", 0))
	app.Limit = 1000
	srv := httptest.NewServer(app.Build(Order))
	defer srv.Close()
	send := func(m, p, auth string, n int64, chunked bool) string {
		var body io.Reader
		if n > 0 {
			body = io.LimitReader(zeros{}, n)
			if chunked {
				body = struct{ io.Reader }{body} // 長さを隠す → Transfer-Encoding: chunked
			}
		}
		req, _ := http.NewRequest(m, srv.URL+p, body)
		if n > 0 && !chunked {
			req.ContentLength = n
		}
		if auth != "" {
			req.Header.Set("Authorization", auth)
		}
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			return "err: " + err.Error()
		}
		defer resp.Body.Close()
		b, _ := io.ReadAll(resp.Body)
		return fmt.Sprintf("%d %s id=%s", resp.StatusCode, strings.TrimSpace(string(b)), resp.Header.Get("X-Request-Id"))
	}
	for _, c := range []struct {
		m, p, a string
		n       int64
		ch      bool
	}{
		{"GET", "/healthz", "", 0, false},
		{"GET", "/healthz/orders", "", 0, false},
		{"POST", "/upload", alice, 5 * MiB, false},
		{"POST", "/upload", alice, 5 * MiB, true},
		{"POST", "/upload", alice, 11 * MiB, true},
		{"POST", "/echo", alice, 2 * MiB, true},
		{"GET", "/admin/users", alice, 0, false},
		{"GET", "/admin/users", root, 0, false},
	} {
		t.Logf("%-6s %-16s chunked=%-5v → %s", c.m, c.p, c.ch, send(c.m, c.p, c.a, c.n, c.ch))
	}
	t.Logf("stats=%v", app.Snapshot())
}

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)             {}

func benchOrder(b *testing.B, order []string, path, auth string) {
	app := NewApp(log.New(io.Discard, "", 0))
	app.Limit = 1 << 62
	h := app.Build(order)
	req := httptest.NewRequest("GET", path, nil)
	if auth != "" {
		req.Header.Set("Authorization", auth)
	}
	b.ReportAllocs()
	for b.Loop() {
		h.ServeHTTP(nullWriter{}, req)
	}
}

func BenchmarkStack7_ok(b *testing.B)      { benchOrder(b, Order, "/ok", alice) }
func BenchmarkStack7_healthz(b *testing.B) { benchOrder(b, Order, "/healthz", "") }
func BenchmarkRouteOnly(b *testing.B) {
	app := NewApp(log.New(io.Discard, "", 0))
	h := app.Route(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
	req := httptest.NewRequest("GET", "/ok", nil)
	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)
	}
}

実測の数字(Go 1.27.1、Apple M1 8 GB)

項目値出所
出発点で落ちるテスト13 本中 5 本(P1・P2・P3・P4・P6)。P5 と Q1〜Q7 は出発点でも通る実測
模範解答13 本すべて PASS。-race -short を 5 回連続で PASS(1 回 2.4〜2.7 秒)実測
5,040 通りの列挙3 通りが通る(下の表)。1 回 21 秒実測(TestEnumerateAllOrders、-race なし)
要件ごとに落とす並びP1: 3,360/P2: 3,360/P3: 3,360/P4: 4,200/P5: 0/P6: 2,520/Q1: 4,284/Q2: 3,780/Q3: 4,788/Q4: 4,410/Q5: 2,520/Q6: 4,200/Q7: 3,150実測。P1 は「Route が {Route, Observe, Auth} の先頭」=1/3 だけ通るので 5,040×2/3=3,360(導出と一致)。P4 は Route>Observe>Auth の 3 つの並び=1/6 だけ通る → 4,200(一致)
変異 10 通り10 通りすべて検出(下の表)実測(1 か所ずつ書き換えて TestPolicy を流す)
/{tenant}/orders を接頭辞で飛ばした場合(E1)GET /healthz/orders → 200 "orders of healthz"、/healthzz/orders → 200実測
実サーバー 8 リクエスト(E4)healthz 200・healthz/orders 401・upload 5 MiB 200(CL 付き/chunked とも)・upload 11 MiB chunked 413・echo 2 MiB chunked 413・admin alice 403・admin root 200。8 本すべてに X-Request-Id実測(httptest.NewServer)
割り当て(1 リクエスト)7 段で /ok: 1,831 B・21〜22 allocs(Day 065 の 6 段は 1,416 B・19 allocs)。/healthz: 1,359 B・14〜15 allocs。Route 単体: 416 B・3 allocs実測(-benchmem)。増えた 415 B は Route の分とほぼ一致
時間今日は比較に使わない。計測時のロードアベレージが 29〜30 で、守りなしの mux が 611〜879 ns(Day 065 は同じ機械で 85〜130 ns)と 5〜7 倍に膨らんでいた実測(uptime)。静かな時に測り直す

🪜 Step-by-Step 解説

Step 1: 照合は mux に任せ、1 回だけにする

Route は a.Mux.Handler(r) でmux がこれから選ぶのと同じパターンを得ます。自分でパスを解釈しない、が要点です。E3 の実測:

リクエストmux.Handler が返すパターン宣言表にある認証なしの応答
GET /healthzGET /healthzある200
HEAD /healthzGET /healthz(GET は HEAD にも一致。server.go 2537 行)ある200
GET //healthzGET /healthzある307(mux の正規化リダイレクト。中身は返さない)
GET /a/../healthzGET /healthzある307
GET /healthz/""ない → 最も厳しい401
GET /HEALTHZ""ない401
GET /healthz/ordersGET /{tenant}/ordersある(認証あり)401
DELETE /healthz""(405)ない401

リダイレクトのときも「行き先のパターン」が返るので、ポリシーは公開になりますが、実際に動くのはリダイレクトするだけの内部ハンドラなので中身は漏れません。doc(2688〜2690 行)は「行き先の path」と書いていますが、実測では GET /healthz というパターンの形で返りました——doc の言葉と実物の表記が違う点は覚えておく価値があります。

Step 2: ポリシーのゼロ値を「最も厳しい」にする

type Policy struct {
	Public      bool   // true なら認証しない
	NoRateLimit bool   // true ならレート制限しない
	MaxBody     int64  // 0 なら 1 MiB
	Role        string // "" ならロール不問
}

宣言表に Policy{} と書けば「認証あり・制限あり・1 MiB」になります。未登録のパスも Policy{}。緩めるには名前のついたフィールドを書く必要があるので、レビューで Public: true を見れば緩めたことが分かります。

Step 3: ゼロ値では閉じきれない——最初の版の穴(実測)

最初の版は、Route が積まれていないとき resolve が routeInfo{}(ゼロ値)を返していました。P6 の 3 行目を書いた時点で、alice が /admin/users を 200 で取れることが分かりました。Role は締める向きのフィールドで、ゼロ値 "" は「ロール不問」=開くからです。

修正は、Route 不在のときだけ誰も持たないロール("\x00route-missing")を返すこと。結果、認証なしは 401、認証ありは誰でも 403 になります。LB の probe が 401 を受けてすぐ切り離すので、積み忘れは本番に出た瞬間に気づかれる——静かに開いたまま動き続けるより、大きな音で止まるほうを選んでいます。

Step 4: 変異で要件テストの穴を探す

変異(1 か所の書き換え)結果落ちた要件
M1 ロール判定を消す検出P4・P6
M2 公開判定をパスの接頭辞で行う(方式 A)検出P5・P6
M3 上限をルートに関係なく 1 MiB検出P3
M4 MaxBytesReader を外す(Content-Length だけで判定)検出P3(chunked の 11 MiB が 200 になる)
M5 NoRateLimit を見ない検出P2
M6 Route 不在時にゼロ値(最初の版)検出P6
M7 未登録ルートを公開扱い検出P5
M8 ポリシーを r.Method+" "+r.URL.Path で引く検出P1(HEAD)
M9 Route を Auth の内側へ検出P1〜P4・Q1〜Q4・Q6・Q7
M10 Observe を Route より外へ検出P1・P4・Q3・Q4

M8 は、P1 に HEAD の行を足す前は生き残りました(実測: HEAD の行を外すと TestPolicy が全部通る)。パスで引いても GET /healthz・POST /upload は宣言表の文字列と偶然一致するからです。HEAD・ワイルドカード(/{tenant}/orders)のようにmux の照合とパスの文字列がずれる入力を要件に入れて初めて、「mux と同じ照合をする」という設計がテストで守られます。

Step 5: 隣どうしの入れ替え

入れ替え落ちる要件
RequestID ⇄ Routeなし(自由な対)
Route ⇄ ObserveP1・P4・Q3・Q4(集計のルート名が空になる)
Observe ⇄ RecoverQ4
Recover ⇄ AuthQ5
Auth ⇄ RateLimitQ6
RateLimit ⇄ BodyLimitQ7

Day 065 の 5 か所は Day 065 とまったく同じ要件で落ちます。ルートごとの違いを足しても、積む順の約束は変わっていない——これが「Order を壊さずに」の検査です。通る 3 通りは:

RequestID → Route → Observe → Recover → Auth → RateLimit → BodyLimit   ← 採用
Route → RequestID → Observe → Recover → Auth → RateLimit → BodyLimit
Route → Observe → RequestID → Recover → Auth → RateLimit → BodyLimit

P5 が 5,040 通りのどれも落とさないことにも注意してください。P5 は順序ではなく照合の方法(M2・M7)を縛る要件です。要件ごとに「何を縛っているか」が違う。

⚖️ 迷った他案と採らなかった理由

論点採った案採らなかった案と理由前提が変わったら
ルートごとの違いをどこに書くか(C) 宣言表のポリシー+共通の守りが ctx から読む(A) 守りの中でパスを見る: ワイルドカードのルートで認証を抜けられる(E1 実測)。(B) ルートごとに mux の内側で包む: 認証なしでも 404/405/401 の違いでルートの有無を数えられる(E2 実測)。またルートごとに積む順を持つので、順序の検査がルートの数だけ要る社内向けでルートの有無を隠す必要が無く、ルートが 10 本程度なら (B) のほうが単純(ctx も Route も要らない)
Route 不在時の既定誰も持たないロール(401/403)ゼロ値: admin ルートが開く(Step 3 実測)。500 を返す: アラートは早く鳴るが、Observe がハンドラの障害と区別できず、調査が「DB か?」から始まる起動時に Build が Route の有無を検査して panic する仕組みを足すなら、実行時の既定は保険になる(両方あってよい)
照合の回数Route で 1 回、ctx に置く(416 B・3 allocs)守りごとに mux.Handler を呼ぶ: 積む順の対が 4 本減るが、照合が 4 回(Day 065 の実測で 1 回 64〜82 ns)。Observe だけ自前で照合(Day 065 のまま): Route>Observe の対は消えるが照合が 2 回ルートが数千で照合が重くなったら、なおさら 1 回に。逆に守りを別パッケージで配る(他チームが Route を積まない)なら、守りごとの照合で自己完結させる
ctx に置く値値型 routeInfo(読みやすさ優先)登録時に作ったポインタ *routeInfo: 1 alloc・48 B 減る(実測 416→368 B、3→2 allocs)が、差は小さい毎秒数万リクエストで割り当てが GC の負担として見えたら切り替える
/healthz のレート制限外す(P2)外さない: probe が数秒おき×LB の台数だと正規の probe が 429 で落ち、サービスが切り離される/healthz を外から叩き放題にされるのが嫌なら、probe 専用のポートか別の mux に分け、外部公開の経路から外す(Day 057 の採らなかった案)
未登録パスに 401認証より前に 404 を返さない(最も厳しいポリシー)404 を先に返す: 認証なしで存在するルートを数えられる公開 API でドキュメントが全部公開済みなら、隠す意味は薄い。利用者の打ち間違いに 404 を返したいなら (B) 寄りにしてよい

🧭 設計思想・なぜこう書くのか

  • ゼロ値を最も厳しい値に: Go の構造体リテラルは書かなかったフィールドをゼロ値にします。これは便利ですが、セキュリティ上の設定では「書き忘れ=ゼロ値」です。フィールドの名前の向き(Public/RequireAuth)を選ぶだけで、書き忘れの倒れる側を決められる。型の機能を使わずに名前で守る、Go らしい小さな工夫です
  • 締める向きのフィールドには既定を別に持つ: Role のように「書けば締まる」フィールドは、ゼロ値では閉じません。resolve の既定(denyAll)が、その例外を 1 か所で受け止めます
  • 照合は 1 か所に: 「このリクエストはどのルートか」を守りごとに解釈すると、いつか mux と食い違います(M8)。Go 1.22 で ServeMux がメソッドとワイルドカードを持ったので、mux の答えを借りる(mux.Handler)のが一番ずれない
  • 積む順は 1 本のまま: ルートごとの違いを「積む順」ではなく「値」で表すので、Day 065 の順序の検査(列挙・入れ替え)がそのまま使える

🌐 他言語との比較

FWルートごとに守りを外す・足す方法今日との違い
Go net/http(今日)宣言表のポリシー値+共通の守りが ctx から読む自作。ゼロ値の向きを自分で決める
chi(Go)r.With(mw)・r.Group で足す。外すには Group で共通の 1 本を分ける(mux.go 242〜261 行、確認済み)外す手段が無いので、分けた後の各グループで積む順を守る必要がある=方式 (B)
ASP.NET Core[AllowAnonymous]・[Authorize(Roles = "admin")] 属性。UseRouting が選んだエンドポイントのメタデータを認可が読む(手元知識・未確認)今日の (C) とほぼ同じ形。既定を「全部認証」にする FallbackPolicy もある(未確認)
Spring Security(Java)authorizeHttpRequests で requestMatchers("/healthz").permitAll()、最後に anyRequest().authenticated()(手元知識・未確認)パスのパターンで書く=(A) に近いが、照合器は MVC のルーティングと揃える仕組みを持つ。最後の anyRequest() が今日の「未登録は最も厳しい」に当たる
Express(Node.js)ルートの登録時に app.get("/admin", requireAdmin, handler) と並べる守りをルートに直接並べる=(B)。共通の守りは app.use で、外すには登録順を工夫する

🏭 実務での使いどころ

  • ヘルスチェックの扱い: LB・Kubernetes の probe はトークンを持たないので、/healthz だけ認証を外す要求はほぼ必ず来る。そのとき「パスで飛ばす」を書きそうになったら E1 を思い出す(ワイルドカードのルートが 1 本あれば抜けられる)
  • アップロード系だけ上限を上げる: 全ルートの上限を上げると、JSON API にも大きな本文を送れるようになる。ポリシーで 1 ルートだけ上げる
  • レビューの観点: 宣言表の差分で Public: true・NoRateLimit: true・MaxBody: が出てきたら、それは緩める変更。名前の向きを揃えておくと、grep 1 つで「緩めている箇所の一覧」が出る
  • 積み忘れ対策: 実行時の既定(今日の denyAll)に加え、起動時の検査(Build が Route の有無を確かめる)と、宣言表の全ルートを歩くテスト(Day 057 の R7)を併用する

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「/healthz だけ飛ばすなら HasPrefix で十分」パスは文字列で、ルートも文字列に見えるワイルドカードのルートがあると別のルートに一致する(E1: /healthz/orders が 200)
「ポリシーは r.URL.Path で引けばよい」宣言表のキーと同じ文字列に見えるHEAD・ワイルドカード・リダイレクトで mux と食い違う(M8・E3)。mux.Handler の答えで引く
「ゼロ値が一番厳しいように名前を付ければ安全」Public・NoRateLimit ではうまくいくからRole のような締める向きのフィールドはゼロ値で開く(Step 3)。既定を別に持つ
「chi の With で /healthz から Auth を外せる」With がルートごとの守りだからWith は親の守りに足すだけ(append)。外すには Group で分ける
「未登録パスは 404 を返すのが親切」利用者の打ち間違いを想定認証の前に 404 を返すと、認証なしでルートの有無を数えられる(E2)。誰に親切かを決めてから

🚧 扱わなかった範囲と理由

  • 起動時の検査(Build が Route の有無・宣言表と mux の一致を確かめる): 書けば 10 行程度だが、今日は「実行時に閉じる側に倒れる」ことの検査に絞った。上の表に「両方あってよい」と記録
  • ルート単位のレート制限の値(Day 059 の宣言表の Limit 列): 今日は「制限する/しない」だけ。ルートごとの値は Day 059 で扱った
  • ルート単位のタイムアウト(/upload だけ長く): Day 061 の TimeoutHandler は別の goroutine で動くので、ポリシーの ctx が届くか・Recover との対がどうなるかを別に調べる必要がある
  • IP ごとの粗い制限・/healthz の外部公開: 別の守り・別の経路の話(上の表に記録)
  • ポリシーエンジン(OPA・Cedar): ロール×リソースの組み合わせが増えたら。Day 057 の「前提が変わったら」に記録済み
  • 時間の比較: 計測時の負荷が高く(ロードアベレージ 29〜30)、Day 065 との時間比較はしていない。割り当て(B・allocs)だけを比べた
  • ASP.NET Core・Spring Security の実際の書き方: 手元知識。ドキュメントは今回読んでいない

🚀 次のステップ

  • 発展: Build に起動時の検査を足す——Route が Auth・RateLimit・BodyLimit・Observe より外にあること、宣言表の全パターンが a.Mux に登録されていることを、起動時に panic で確かめる
  • 発展: /upload だけ TimeoutHandler を長くするポリシー(Timeout time.Duration)を足し、TimeoutHandler の内側でも ctx のポリシーが読めるか、Recover との対がどうなるかを確かめる
  • 次回予告: 同テーマ(10/25 まで据え置き)。次は「デバッグ」軸で、ルートごとのポリシーにまつわるバグ(Group を分けたら順序が崩れた・MaxBody の 0 の扱い・ポリシーの共有ポインタを書き換えた等)を直す

🎯 自己評価

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

手順 4 の 2 つの判断(未登録・Route 不在)と採らなかった案

自分の回答(Route・resolve・3 つの守りの TODO)

気づき・メモ(Step 3 の穴を、テストを書く前に予想できたか)