フェーズ: 上級者 (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/users | admin ロールだけ(足す) | 管理画面 |
ここで「外す」と「足す」は性質が違います。足すのは簡単です——ルートの内側に守りを 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 を足した出発点を用意しました。守りはまだポリシーを読みません。
やること
policy.goのRoute(mux と同じ照合を 1 回して、パターンとポリシーを ctx に置く)とresolve(ctx から読む)を実装するguards.goのAuth・RateLimit・BodyLimitの TODO を埋め、ポリシーを読ませるorder.goのOrderにRouteを足す。Day 065 の 6 つの相対順は変えない。足す前に「Route は誰より外でなければならないか」を対で書き、7! = 5,040 通りのうち何通りが全要件を通るかを予想する- 設計判断を 2 つ:
- 未登録のパス・405 のとき、
Routeはどのポリシーを置くか Routeを積み忘れたとき(誰かがChainを手で組んだ等)、resolveは何を返すか。ゼロ値のPolicy{}で足りるか
- 未登録のパス・405 のとき、
要件(テストは下に全部ある)
| 要件 | 内容 |
|---|---|
| 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 |
| P6 | Route を積み忘れた並びでは誰も通さない(/healthz 認証なし 401、alice の /upload・/admin/users・/ok は 403) |
| Q1〜Q7 | Day 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 > Auth | P1(/healthz を認証なしで通す)・P3・P4 | 複製(Auth が ctx のポリシーを読む)。Route が内側だと Auth は「Route 不在」=誰も通さないで動く |
| Route > RateLimit | P2 | 同上 |
| Route > BodyLimit | P3 | 同上 |
| Route > Observe | P1・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 /healthz | GET /healthz | ある | 200 |
HEAD /healthz | GET /healthz(GET は HEAD にも一致。server.go 2537 行) | ある | 200 |
GET //healthz | GET /healthz | ある | 307(mux の正規化リダイレクト。中身は返さない) |
GET /a/../healthz | GET /healthz | ある | 307 |
GET /healthz/ | "" | ない → 最も厳しい | 401 |
GET /HEALTHZ | "" | ない | 401 |
GET /healthz/orders | GET /{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 ⇄ Observe | P1・P4・Q3・Q4(集計のルート名が空になる) |
| Observe ⇄ Recover | Q4 |
| Recover ⇄ Auth | Q5 |
| Auth ⇄ RateLimit | Q6 |
| RateLimit ⇄ BodyLimit | Q7 |
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 の扱い・ポリシーの共有ポインタを書き換えた等)を直す