フェーズ: 上級者 (Phase 3) | 問題タイプ: デバッグ・コードレビュー | テーマ: ミドルウェア設計パターン
Day 066 の「宣言表のポリシー+ Route が ctx に置く」設計に、チームメイトが 4 つの新機能(グループ・管理者だけ大きな上限・上限なしの取り込み口・管理画面だけ厳しいレート制限)を足しました。テストは Day 066 の回帰(/healthz)しか無く、全部通っていました。Day 066 の次回予告どおり「デバッグ」。
📚 背景知識(読んでから問題へ)
Day 066 で決めたことを 3 行で振り返ります。
- 積む順は 1 本(
RequestID → Route → Observe → Recover → Auth → RateLimit → BodyLimit)。ルートごとの違いは値(Policy)で表す - Policy のフィールドは緩める向きの名前(
Public・NoRateLimit)にして、ゼロ値=最も厳しい Roleのような締める向きのフィールドはゼロ値で開くので、Route が無いときは誰も持たないロール(denyAll)で閉じる
この設計は「1 本のルートを 1 行で宣言する」ことしか想定していませんでした。現場では次の要求が必ず来ます。
| 要求 | 世界の実物での書き方 | 今日のコードでの書き方(チームメイト) |
|---|---|---|
接頭辞ごとにまとめる(/admin/* は全部 admin) | chi の r.Route("/admin", ...)・r.Group(...)(v5.3.2 mux.go 268〜286 行。Group は With() で親の守りを複製して足す) | a.Group("/admin", Policy{Role: "admin"}, mws, fn)——既定ポリシーを子に merge |
| グループだけ守りを足す | chi の Group 内で r.Use(mw)(mux の内側で包む) | []Middleware{a.RateLimitN(2)} をmux の内側で包む |
| 上限なしの口 | nginx client_max_body_size 0("Setting size to 0 disables checking of client request body size."、既定は 1m) | Policy{MaxBody: -1} |
| 一部の利用者だけ上限を上げる | — | Day 066 で「採らなかった案」に記録したポインタ化(*Policy)を採用し、BodyLimit の中で p.MaxBody = p.AdminMaxBody |
どれも「その場では正しく見える」書き方です。今日はこれらが組み合わさったときに何が開くかを探します。
今日の核心: ゼロ値を最も厳しくした設計は「継承」と相性が悪い
Day 066 の Policy{} は「何も書いていない」と「最も厳しく書いた」を区別できません。だからグループの既定 Public: true を子ルートが Public: false で打ち消そうとしても、false は「書いていない」と同じ値になります。merge が def.Public || p.Public になるのは自然で、その結果 /internal/secrets は誰でも読めます(B1)。
この衝突の直し方は 3 つあり、今日は「型で継承できるものを締める向きだけに絞る」を選びます(GroupPolicy{Role, Limit})。他の 2 案(*bool の 3 値・merge の規則を文書で決める)を採らなかった理由は末尾の表に。
参照日 2026-10-07。一次資料: 手元の Go 1.27.1 net/http/request.go 1209〜1214 行(MaxBytesReader: "Treat negative limits as equivalent to 0.")、go-chi/chi v5.3.2 mux.go 242〜286 行(With・Group・Route)と 350 行(Routes()=chi はルート一覧を返せる。net/http.ServeMux には一覧の API が無い)、nginx ngx_http_core_module の client_max_body_size(https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size 、2026-10-07 取得)。
📝 問題
下のコード(guards.go・policy.go・order.go)には 5 つのバグがあります。受け入れテスト policy_test.go(9 本)を通るように直してください。
やること
- テストを流す前に、コードを読んで 5 つのバグを予想する(どのリクエストで何が起きるか、開く側か閉じる側か)
go test -race -count=1 ./...を流し、予想と照らす(バグ入りでは 9 本中 8 本が落ちる。T1 だけ通る)- 1 つずつ直す。直す順番によって、別のバグが表に出てくることがある。どの組がそうか書く
- 直し方を 2 つ判断する:
- グループの既定を子ルートへどう受け継がせるか(merge の規則/型/3 値)
- 「mux にはあるのにポリシー表に無いルート」に Route は何を置くか
要件(テスト)
| テスト | 内容 |
|---|---|
| T1 | 回帰: /healthz は認証なし・20 回叩いても 429 にならない |
| T2 | /internal/version は認証なし 200、/internal/secrets は認証なし 401・alice 403・root 200 |
| T3 | /admin/users は認証なし 401・alice 403・root 200。403 も GET /admin/users で数える |
| T4 | 管理画面は 1 利用者 2 回まで(3 回目 429)。全体の予算(5 回)とは別に数える |
| T5 | /upload: 一般 10 MiB・admin 40 MiB。admin が使った後も一般は 10 MiB のまま |
| T6 | /stream は 20 MiB を受ける(CL 付き・chunked とも)。ただし天井はある(65 MiB は 413) |
| T7 | mux に直接登録されポリシー表に無い /rogue は誰も通さない。本当に無い /nope は認証済みに 404 |
| T8 | root と alice が同時に 30 MiB を送っても alice は全部 413(-race で流す) |
| T9 | 負の上限(MaxBody: -1)は登録時に止まる |
準備
mkdir d67 && cd d67 # 作業ディレクトリを作って入る
printf 'module d67\n\ngo 1.24\n' > go.mod # モジュールを作る
# 下の guards.go・policy.go・order.go・policy_test.go を保存する
go test -race -count=1 ./... # バグ入りでは T2〜T9 の 8 本が落ちる(実測)
ファイル 1: guards.go(バグ入り)
guards.go 全体(307 行)
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 // 登録パターン → ポリシー(Day 066 の値型から、割り当て削減のためポインタへ)
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)
}
a.Handle("GET /healthz", ok, Policy{Public: true, NoRateLimit: true})
a.Handle("GET /ok", ok, Policy{})
a.Handle("POST /echo", readAll, Policy{})
// 管理者だけ 40 MiB まで(新機能)
a.Handle("POST /upload", readAll, Policy{MaxBody: 10 << 20, AdminMaxBody: 40 << 20})
// 計測データの取り込み。nginx の client_max_body_size 0 と同じく「上限なし」にしたい(新機能)
a.Handle("POST /stream", readAll, Policy{MaxBody: -1})
// 管理画面: admin だけ、しかも 1 利用者 2 回までに絞る(新機能)
a.Group("/admin", Policy{Role: "admin"}, []Middleware{a.RateLimitN(2)}, func(g *Group) {
g.Handle("GET /users", ok, Policy{})
g.Handle("POST /import", readAll, Policy{MaxBody: 10 << 20})
})
// 社内向け: 既定は公開。secrets だけ admin に締める(新機能)
a.Group("/internal", Policy{Public: true, NoRateLimit: true}, nil, func(g *Group) {
g.Handle("GET /version", ok, Policy{})
g.Handle("GET /secrets", ok, Policy{Role: "admin"})
})
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 userOf(r *http.Request) string {
u, _ := r.Context().Value(userKey).(string)
return u
}
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 はルート名を Route が ctx に置いた値から読む。
func (a *App) Observe(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
route := a.resolve(r).Pattern
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 len(parts) != 2 || 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) {
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) clientKey(r *http.Request) string {
if u := userOf(r); u != "" {
return "user:" + u
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return "ip:" + host
}
return "ip:" + r.RemoteAddr
}
// take は key の予算を 1 つ使い、limit を超えたら false を返す。
func (a *App) take(key string, limit int) bool {
a.mu.Lock()
defer a.mu.Unlock()
a.used[key]++
return a.used[key] <= limit
}
func tooMany(w http.ResponseWriter) {
w.Header().Set("Retry-After", "1")
http.Error(w, http.StatusText(429), 429)
}
func (a *App) RateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if a.resolve(r).Policy.NoRateLimit {
next.ServeHTTP(w, r)
return
}
if !a.take(a.clientKey(r), a.Limit) {
tooMany(w)
return
}
next.ServeHTTP(w, r)
})
}
// RateLimitN はグループ専用の、より厳しい制限(Group の内側に挟む)。
func (a *App) RateLimitN(n int) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !a.take(a.clientKey(r), n) {
tooMany(w)
return
}
next.ServeHTTP(w, r)
})
}
}
func (a *App) BodyLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := a.resolve(r).Policy
if p.AdminMaxBody > 0 && a.Roles[userOf(r)] == "admin" {
p.MaxBody = p.AdminMaxBody // 管理者は上限を引き上げる
}
max := p.maxBody()
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(バグ入り)
package main
import (
"context"
"net/http"
"strings"
)
const defaultMaxBody = 1 << 20
// Policy はルートごとの守り方。ゼロ値がいちばん厳しい(Day 066)。
type Policy struct {
Public bool // true なら認証しない
NoRateLimit bool // true ならレート制限しない
MaxBody int64 // 0 なら defaultMaxBody。-1 なら上限なし
AdminMaxBody int64 // admin ロールのときの上限。0 なら MaxBody と同じ
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 に置く。
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})))
})
}
const denyAll = "\x00route-missing"
// resolve は Route が置いた値を返す。Route が積まれていなければ誰も通さない。
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}}
}
// Group は接頭辞と既定のポリシーを共有するルートのまとまり(chi の Group/Route に倣った)。
type Group struct {
a *App
prefix string
def Policy
mws []Middleware
}
func (a *App) Group(prefix string, def Policy, mws []Middleware, fn func(g *Group)) {
fn(&Group{a: a, prefix: prefix, def: def, mws: mws})
}
// Handle は "GET /users" を "GET /admin/users" として登録する。
func (g *Group) Handle(pattern string, h http.HandlerFunc, p Policy) {
method, path, _ := strings.Cut(pattern, " ")
full := method + " " + g.prefix + path
g.a.Mux.Handle(full, Chain(h, g.mws...))
merged := merge(g.def, p)
g.a.policies[g.prefix+pattern] = &merged
}
// merge はグループの既定にルートの指定を重ねる。
func merge(def, p Policy) Policy {
out := p
out.Public = def.Public || p.Public
out.NoRateLimit = def.NoRateLimit || p.NoRateLimit
if out.Role == "" {
out.Role = def.Role
}
if out.MaxBody == 0 {
out.MaxBody = def.MaxBody
}
return out
}
ファイル 3: order.go
package main
// Order は外→内の積む順(Day 066 と同じ)。
var Order = []string{"RequestID", "Route", "Observe", "Recover", "Auth", "RateLimit", "BodyLimit"}
ファイル 4: policy_test.go(受け入れテスト。変えない)
policy_test.go 全体(T1〜T9)
package main
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"sync"
"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() *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 なら Content-Length を伏せる。
func (e *env) do(method, path, auth string, body int64, chunked bool) *httptest.ResponseRecorder {
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()
e.h.ServeHTTP(rec, req)
return rec
}
type req struct {
m, p, a string
body int64
chunked bool
want int
}
func expect(t *testing.T, e *env, reqs ...req) {
t.Helper()
for i, c := range reqs {
if got := e.do(c.m, c.p, c.a, c.body, c.chunked).Code; got != c.want {
t.Fatalf("#%d %s %s auth=%q body=%d chunked=%v: code=%d want %d",
i+1, c.m, c.p, c.a, c.body, c.chunked, got, c.want)
}
}
}
// T1 回帰: /healthz は認証なし・制限なし(Day 066 の P1・P2)
func TestT1_HealthzStaysOpen(t *testing.T) {
e := newEnv()
for i := 0; i < 20; i++ {
expect(t, e, req{"GET", "/healthz", "", 0, false, 200})
}
}
// T2 /internal/version は公開、/internal/secrets は admin だけ
func TestT2_InternalGroup(t *testing.T) {
e := newEnv()
expect(t, e,
req{"GET", "/internal/version", "", 0, false, 200},
req{"GET", "/internal/secrets", "", 0, false, 401},
req{"GET", "/internal/secrets", alice, 0, false, 403},
req{"GET", "/internal/secrets", root, 0, false, 200},
)
}
// T3 /admin/users は admin だけ。403 もルート名で数える
func TestT3_AdminGroup(t *testing.T) {
e := newEnv()
expect(t, e,
req{"GET", "/admin/users", "", 0, false, 401},
req{"GET", "/admin/users", alice, 0, false, 403},
req{"GET", "/admin/users", root, 0, false, 200},
)
if s := e.app.Snapshot(); s[Key{"GET /admin/users", 403}] != 1 {
t.Fatalf("stats=%v", s)
}
}
// T4 管理画面は 1 利用者 2 回まで。全体の予算(5 回)とは別に数える
func TestT4_AdminBudget(t *testing.T) {
e := newEnv()
expect(t, e,
req{"GET", "/admin/users", root, 0, false, 200},
req{"GET", "/admin/users", root, 0, false, 200},
req{"GET", "/admin/users", root, 0, false, 429},
)
for i := 0; i < 5; i++ {
expect(t, e, req{"GET", "/ok", root, 0, false, 200})
}
expect(t, e, req{"GET", "/ok", root, 0, false, 429})
}
// T5 /upload: 一般 10 MiB・admin 40 MiB。admin が使った後も一般の上限は 10 MiB のまま
func TestT5_UploadAdminBonusDoesNotLeak(t *testing.T) {
e := newEnv()
e.app.Limit = 1000
expect(t, e,
req{"POST", "/upload", alice, 5 * MiB, false, 200},
req{"POST", "/upload", alice, 11 * MiB, false, 413},
req{"POST", "/upload", root, 30 * MiB, false, 200},
req{"POST", "/upload", alice, 30 * MiB, false, 413},
req{"POST", "/upload", alice, 11 * MiB, true, 413},
)
}
// T6 /stream は 20 MiB を受ける(Content-Length 付き・chunked とも)。ただし天井はある(65 MiB は 413)
func TestT6_StreamCeiling(t *testing.T) {
e := newEnv()
e.app.Limit = 1000
expect(t, e,
req{"POST", "/stream", alice, 20 * MiB, false, 200},
req{"POST", "/stream", alice, 20 * MiB, true, 200},
req{"POST", "/stream", alice, 65 * MiB, true, 413},
req{"POST", "/echo", alice, 2 * MiB, true, 413},
)
}
// T7 mux に登録されているのにポリシー表に無いルートは、誰も通さない
func TestT7_RouteWithoutPolicyIsClosed(t *testing.T) {
e := newEnv()
e.app.Mux.HandleFunc("GET /rogue", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "secret") })
expect(t, e,
req{"GET", "/rogue", "", 0, false, 401},
req{"GET", "/rogue", alice, 0, false, 403},
req{"GET", "/rogue", root, 0, false, 403},
req{"GET", "/nope", alice, 0, false, 404}, // 本当に無いルートは、認証済みの利用者には 404 で打ち間違いを知らせる
)
}
// T8 admin と一般利用者が同時にアップロードしても、一般の上限は 10 MiB のまま(-race で流す)
func TestT8_ConcurrentUploads(t *testing.T) {
e := newEnv()
e.app.Limit = 1000
var wg sync.WaitGroup
var mu sync.Mutex
var leaked []string
for i := 0; i < 8; i++ {
wg.Add(2)
go func() { defer wg.Done(); e.do("POST", "/upload", root, 30*MiB, false) }()
go func(i int) {
defer wg.Done()
if c := e.do("POST", "/upload", alice, 30*MiB, true).Code; c != 413 {
mu.Lock()
leaked = append(leaked, fmt.Sprintf("alice#%d=%d", i, c))
mu.Unlock()
}
}(i)
}
wg.Wait()
if len(leaked) > 0 {
t.Fatalf("一般利用者の 30 MiB が通った: %v", leaked)
}
}
// T9 負の上限(「上限なし」のつもりの -1 など)は登録時に止まる
func TestT9_NegativeLimitRejectedAtStartup(t *testing.T) {
e := newEnv()
defer func() {
if recover() == nil {
t.Fatal("MaxBody: -1 が登録できてしまった")
}
}()
e.app.Handle("POST /x", func(http.ResponseWriter, *http.Request) {}, Policy{MaxBody: -1})
}
受け入れ条件
go test -race -count=1 ./...が 5 回連続で通る- 5 つのバグそれぞれについて「どのテストで見つかるか」「開く側か閉じる側か」を 1 行で書く
- 手順 4 の 2 つの判断について、採らなかった案と理由を 1 行ずつ書く
💡 ヒント
ヒント1(方向性)
新機能 4 つは、それぞれ Day 066 の約束を 1 つずつ破っています。「積む順は 1 本」「照合は 1 回、mux と同じ文字列で」「ゼロ値=最も厳しい」「ctx から読んだ値は読むだけ」。どの新機能がどれを破っているかを対応づけると 5 つ見つかります(1 つの新機能に 2 つ入っているものがある)。
ヒント2(アプローチ)
Group.Handleが mux に書く文字列と、a.policiesに書く文字列を並べて比べる。fmt.Printlnで表のキーを全部出すのが一番早いMaxBody: -1はr.ContentLength > maxとhttp.MaxBytesReader(w, body, -1)の 2 か所でどう振る舞うか。GET(ContentLength 0)・CL 付き・chunked(ContentLength -1)の 3 つで考えるRateLimitNの中のa.take(a.clientKey(r), n)と外側のRateLimitのa.take(...)は、同じ財布を使っていないかp := a.resolve(r).Policyの型は何か。p.MaxBody = ...は何を書き換えているか
ヒント3(コード骨格)
// 1. mux と表に「同じ文字列」で書く入口を 1 つにする
func (a *App) register(pattern string, h http.Handler, p Policy) {
if err := p.validate(); err != nil { panic(...) } // 負の値を起動時に止める
a.Mux.Handle(pattern, h)
a.policies[pattern] = p // 値で持つ
}
// 2. グループが受け継がせてよいのは締める向きだけ(型で制限)
type GroupPolicy struct { Role string; Limit int }
// 3. Route: mux にあるのに表に無い → 閉じる
switch {
case ok:
case pattern == "": p = Policy{} // 本当に無い: 認証を先に、通れば mux が 404/405
default: p = closed // 食い違い
}
ルート専用の予算は Policy.Limit に移し、外側の RateLimit が「利用者+パターン」の別の財布で 1 回だけ数える。RateLimitN は消す。
✅ 模範解答
5 つのバグ(実測した症状)
| # | バグ | 破った約束 | 症状(バグ入りで実測) | 倒れる側 | 見つけるテスト |
|---|---|---|---|---|---|
| B1 | グループの既定を def.Public || p.Public で merge。子は Public: false を表せない | ゼロ値=最も厳しい(「書いていない」と「締めた」を区別できない) | /internal/secrets が認証なしで 200(B5 を直した後に表に出る) | 開く | T2 |
| B2 | *Policy を共有し、BodyLimit が p.MaxBody = p.AdminMaxBody と書き換える | ctx から読んだ値は読むだけ | root が 1 回 30 MiB を送った後、alice の 30 MiB が 200。同時実行では alice 160 本中 134 本が 200(20 回中 20 回で落ちる)。-race は guards.go:295 の書き込み同士の競合を報告 | 開く(しかも順序次第で揺れる) | T5・T8 |
| B3 | MaxBody: -1 を「上限なし」の意味で使った(nginx の 0 の感覚) | 表せない値は登録時に止める | /stream は1 バイトでも 413: CL 付きは 1 > -1、chunked は MaxBytesReader が負を 0 として扱う(request.go 1210 行) | 閉じる(大きな音) | T6・T9 |
| B4 | 管理画面の制限を mux の内側の RateLimitN(2) で足した。外側の RateLimit と同じ鍵で数える | 積む順は 1 本/1 リクエストは 1 回だけ数える | root の 1 回目で used=2、2 回目で 429(used=4 > 2)。全体の予算も 2 倍の速さで減る | 閉じる(早すぎる 429) | T4 |
| B5 | Group.Handle が mux には "GET /admin/users"、表には "/adminGET /users" と書く。Route は表に無いと Policy{} を置く | 照合は mux と同じ文字列で/締める向きのゼロ値は開く | **alice が /admin/users を 200 で取れる**(Role が空になる)。/internal/version は 401、/admin/import の 10 MiB 指定も消えて 5 MiB が 413 | 開く(admin)+閉じる(version) | T2・T3・T4・T7 |
B5 が B1 を隠していた(手順 3 の答え): バグ入りでは /internal/secrets の表のキーも食い違っているので、Route は Policy{}=認証ありを置き、認証なしは 401 になります。B1 があるのに T2 の secrets の行は通ってしまう。B5 だけ直すと、merge 済みの Public: true が正しく引かれ、secrets が認証なしで 200 に変わります(変異 M1・M6 で実測)。「1 つ直したら別の穴が開いた」は、直し方が悪いのではなく、隠れていた穴が見えるようになっただけです。
policy.go(模範解答)
package main
import (
"context"
"fmt"
"net/http"
"strings"
)
const defaultMaxBody = 1 << 20
// Policy はルートごとの守り方。ゼロ値がいちばん厳しい(Day 066)。
// 値型で持ち回す: ctx から取り出した時点で複製なので、守りの中で書き換えても表は変わらない(B2)。
type Policy struct {
Public bool // true なら認証しない
NoRateLimit bool // true ならレート制限しない
MaxBody int64 // 0 なら defaultMaxBody。負の値は登録時に panic(B3)
AdminMaxBody int64 // admin ロールのときの上限。0 なら MaxBody と同じ
Role string // "" ならロール不問
Limit int // このルート専用の予算(利用者×ルートで数える)。0 なら App.Limit を全体で共有(B4)
}
func (p Policy) maxBody() int64 {
if p.MaxBody > 0 {
return p.MaxBody
}
return defaultMaxBody
}
// maxBodyFor は利用者のロールを見た上限。p を書き換えずに返す(B2)。
func (p Policy) maxBodyFor(role string) int64 {
if role == "admin" && p.AdminMaxBody > 0 {
return p.AdminMaxBody
}
return p.maxBody()
}
func (p Policy) validate() error {
if p.MaxBody < 0 || p.AdminMaxBody < 0 || p.Limit < 0 {
return fmt.Errorf("負の値は意味を持たない(上限なしは表せない): %+v", p)
}
return nil
}
type routeInfo struct {
Pattern string
Policy Policy
}
const denyAll = "\x00route-missing"
// closed はどの利用者も持たないロールを要求する、誰も通さないポリシー。
var closed = Policy{Role: denyAll}
// Route は mux と同じ照合を 1 回だけ行い、パターンとポリシーを ctx に置く。
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]
switch {
case ok:
case pattern == "":
p = Policy{} // 未登録・405: 認証を先に求め、通れば mux が 404/405 を返す
default:
p = closed // mux にはあるのに表に無い=登録の食い違い。開かずに閉じる(B5)
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), routeKey, routeInfo{pattern, p})))
})
}
// resolve は Route が置いた値を返す。Route が積まれていなければ誰も通さない。
func (a *App) resolve(r *http.Request) routeInfo {
if ri, ok := r.Context().Value(routeKey).(routeInfo); ok {
return ri
}
return routeInfo{Policy: closed}
}
// register は mux とポリシー表に「同じ文字列」で書く唯一の入口(B5)。
func (a *App) register(pattern string, h http.Handler, p Policy) {
if err := p.validate(); err != nil {
panic(pattern + ": " + err.Error())
}
a.Mux.Handle(pattern, h)
a.policies[pattern] = p
}
// GroupPolicy はグループが子ルートへ受け継がせてよいものだけを持つ。
// 締める向き(Role・Limit)だけ。緩める向き(Public・NoRateLimit・MaxBody)は型に無いので継承できない(B1)。
type GroupPolicy struct {
Role string
Limit int
}
type Group struct {
a *App
prefix string
def GroupPolicy
}
// Group は接頭辞と既定を共有するルートのまとまり。守りは足さない(積む順は Order の 1 本だけ。B4)。
func (a *App) Group(prefix string, def GroupPolicy, fn func(g *Group)) {
fn(&Group{a: a, prefix: prefix, def: def})
}
// Handle は "GET /users" を "GET /admin/users" として登録する。
func (g *Group) Handle(pattern string, h http.HandlerFunc, p Policy) {
method, path, _ := strings.Cut(pattern, " ")
if p.Role == "" {
p.Role = g.def.Role
}
if p.Limit == 0 {
p.Limit = g.def.Limit
}
g.a.register(method+" "+g.prefix+path, h, p)
}
guards.go の変更(バグ入りとの差分)
--- bug/guards.go 2026-10-07 06:00:06
+++ fix/guards.go 2026-10-07 06:02:10
@@ -43,8 +43,8 @@
Mux *http.ServeMux
Log *log.Logger
Limit int
- Roles map[string]string // 利用者 → ロール
- policies map[string]*Policy // 登録パターン → ポリシー(Day 066 の値型から、割り当て削減のためポインタへ)
+ Roles map[string]string // 利用者 → ロール
+ policies map[string]Policy // 登録パターン → ポリシー(値型に戻す。B2)
seq atomic.Int64
mu sync.Mutex
stats map[Key]int
@@ -54,7 +54,7 @@
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{},
+ 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) {
@@ -71,25 +71,24 @@
a.Handle("POST /echo", readAll, Policy{})
// 管理者だけ 40 MiB まで(新機能)
a.Handle("POST /upload", readAll, Policy{MaxBody: 10 << 20, AdminMaxBody: 40 << 20})
- // 計測データの取り込み。nginx の client_max_body_size 0 と同じく「上限なし」にしたい(新機能)
- a.Handle("POST /stream", readAll, Policy{MaxBody: -1})
+ // 計測データの取り込み。「上限なし」は表せないので、運用上の天井を数字で書く(B3)
+ a.Handle("POST /stream", readAll, Policy{MaxBody: 64 << 20})
// 管理画面: admin だけ、しかも 1 利用者 2 回までに絞る(新機能)
- a.Group("/admin", Policy{Role: "admin"}, []Middleware{a.RateLimitN(2)}, func(g *Group) {
+ a.Group("/admin", GroupPolicy{Role: "admin", Limit: 2}, func(g *Group) {
g.Handle("GET /users", ok, Policy{})
g.Handle("POST /import", readAll, Policy{MaxBody: 10 << 20})
})
- // 社内向け: 既定は公開。secrets だけ admin に締める(新機能)
- a.Group("/internal", Policy{Public: true, NoRateLimit: true}, nil, func(g *Group) {
- g.Handle("GET /version", ok, Policy{})
+ // 社内向け: 公開はルートごとに書く(グループからは継承できない。B1)
+ a.Group("/internal", GroupPolicy{}, func(g *Group) {
+ g.Handle("GET /version", ok, Policy{Public: true, NoRateLimit: true})
g.Handle("GET /secrets", ok, Policy{Role: "admin"})
})
return a
}
func (a *App) Handle(pattern string, h http.HandlerFunc, p Policy) {
- a.Mux.Handle(pattern, h)
- a.policies[pattern] = &p
+ a.register(pattern, h, p)
}
func (a *App) Build(order []string) http.Handler {
@@ -263,11 +262,16 @@
func (a *App) RateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if a.resolve(r).Policy.NoRateLimit {
+ ri := a.resolve(r)
+ if ri.Policy.NoRateLimit {
next.ServeHTTP(w, r)
return
}
- if !a.take(a.clientKey(r), a.Limit) {
+ key, limit := a.clientKey(r), a.Limit
+ if ri.Policy.Limit > 0 { // ルート専用の予算は別の財布で数える(1 リクエスト=1 回だけ数える。B4)
+ key, limit = key+" "+ri.Pattern, ri.Policy.Limit
+ }
+ if !a.take(key, limit) {
tooMany(w)
return
}
@@ -275,26 +279,9 @@
})
}
-// RateLimitN はグループ専用の、より厳しい制限(Group の内側に挟む)。
-func (a *App) RateLimitN(n int) Middleware {
- return func(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if !a.take(a.clientKey(r), n) {
- tooMany(w)
- return
- }
- next.ServeHTTP(w, r)
- })
- }
-}
-
func (a *App) BodyLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- p := a.resolve(r).Policy
- if p.AdminMaxBody > 0 && a.Roles[userOf(r)] == "admin" {
- p.MaxBody = p.AdminMaxBody // 管理者は上限を引き上げる
- }
- max := p.maxBody()
+ max := a.resolve(r).Policy.maxBodyFor(a.Roles[userOf(r)])
if r.ContentLength > max {
http.Error(w, http.StatusText(413), 413)
return
実測の数字(Go 1.27.1、Apple M1 8 GB)
| 項目 | 値 | 出所 |
|---|---|---|
| バグ入りで落ちるテスト | 9 本中 8 本(T1 だけ通る) | 実測(go test -count=1) |
| 模範解答 | 9 本すべて PASS。-race -count=1 を 5 回連続で PASS(1 回 1.3〜1.7 秒) | 実測 |
| B2 の揺れ | T8(root 8 本+alice 8 本を同時に)を 20 回: 20 回とも落ち、alice 160 本中 134 本が 200(84%)。-race では競合を報告 | 実測(-count=20) |
| 変異 11 通り | 11 通りすべて検出(下の表)。最初の版では M7 が生き残った | 実測(mut.py で 1 か所ずつ戻して -race で流す) |
| 割り当て(1 リクエスト) | /ok 7 段: ポインタ版 1,768 B・20 allocs → 値版 1,808 B・20 allocs。Route 単体: 392 B → 432 B(どちらも 3 allocs) | 実測(-benchmem、3 回とも同じ値)。値に戻す費用は 40 B・0 allocs |
| 時間 | 比較に使わない(計測中のロードアベレージ 7.5〜17.5。Route 単体が 384〜600 ns と揺れた) | 実測(uptime) |
Day 066 で「*routeInfo にすれば 48 B・1 alloc 減る」と書いたポインタ化を、チームメイトは *Policy(routeInfo の中のポインタ)で採りました。実測では allocs は減らず 40 B だけ——routeInfo が context.WithValue で interface に詰められるとき 1 回割り当てられるのは同じだからです。得はほぼ無く、書き換えの穴だけが残った。
変異(1 か所ずつ戻す)
| 変異 | 結果 | 落ちたテスト |
|---|---|---|
| M1 B1 だけ戻す(GroupPolicy に Public を足して OR で継承) | 検出 | T2(secrets 認証なし 200) |
M2 B2 だけ戻す(*Policy で共有+書き換え) | 検出 | T5・T8+-race の競合報告 |
M3 B3 だけ戻す(-1・!= 0・検査なし) | 検出 | T6・T9 |
M3b -1 を書くが登録時の検査は残す | 検出 | 起動時に panic(全テストが止まる=本番なら起動しない) |
| M3c 登録時の検査だけ外す | 検出 | T9 |
| M4 ルート専用の予算を全体と同じ財布で数える | 検出 | T4(root の /ok が 3 回目で 429) |
| M5 B5 だけ戻す(鍵の食い違い+既定ゼロ値) | 検出 | T2・T3・T4・T7 |
| M5a 鍵の食い違いだけ(既定は closed のまま) | 検出 | T2・T3・T4(root も 403=閉じる側に倒れた) |
| M5b 既定をゼロ値に(鍵は一致) | 検出 | T7(/rogue を alice が 200) |
| M6 B5 と B1 を同時に戻す | 検出 | T2・T3・T4・T7(T2 は version の 401 で落ち、secrets の穴は隠れる) |
| M7 未登録(pattern が空)にも closed を置く | 検出 | T7(alice の /nope が 404 でなく 403) |
M7 は最初の版で生き残りました(T7 に /nope の行が無かった)。「本当に無いルート」と「表から漏れたルート」を同じに扱っても安全側には倒れますが、認証済みの利用者は打ち間違いでも 403 を受け、原因が分かりません。pattern == ""(mux も知らない)と pattern != ""(mux は知っている)を分ける理由を、テストの 1 行で固定しました。M5a と M5b の 2 つが別々に検出されることも大事です——鍵を揃える(register)と揃わなかったときに閉じる(closed)は別の守りで、片方だけでは、もう片方が壊れたときに気づけません。
🪜 Step-by-Step 解説
Step 1: 表のキーを全部出す(B5)
最初にやるのは推理ではなく表の中身を見ることです。バグ入りで a.policies のキーを出した実測:
"GET /healthz" "GET /ok" "POST /echo" "POST /upload" "POST /stream"
"/adminGET /users" "/adminPOST /import" "/internalGET /version" "/internalGET /secrets"
グループの 4 本が mux と違う文字列で入っています。Route は mux.Handler で "GET /admin/users" を得て表を引き、無いので Policy{} を置く。Day 066 の Step 3 と同じ穴——**Role は締める向きなので、ゼロ値では開く**——が、今度は「Route を積み忘れた」ではなく「表に書き忘れた」経路で出ました。
直し方は 2 段です。
- 書く入口を 1 つにする(
register): mux と表に同じ変数で書くので、食い違いようがない - 食い違っても閉じる(
closed):registerを通らずにa.Mux.Handleを直接呼ぶ人は必ず出る(T7)。mux が知っているのに表が知らないなら、それは誤りなので誰も通さない
net/http.ServeMux は登録済みパターンの一覧を返す API を持たないので、「起動時に mux と表を突き合わせる」検査はできません。chi は Routes()(mux.go 350 行)で一覧を返せるので、chi なら起動時検査が書けます。標準の mux では 2 の実行時の既定が最後の砦です。
Step 2: 継承は締める向きだけ(B1)
B5 を直すと T2 の落ち方が変わります(version の 401 → secrets の 200)。merge の out.Public = def.Public || p.Public は、Policy のゼロ値が「何も書いていない」と「認証あり」を兼ねている以上、他に書きようがありません。
GroupPolicy を別の型にし、Role と Limit だけを持たせました。緩める指定は型に無いので継承できない。公開したいルートは 1 本ずつ Public: true と書くので、Day 066 の「grep 'Public: true' で緩めている箇所の一覧が出る」が保たれます。
Step 3: 読んだ値は読むだけ(B2)
p := a.resolve(r).Policy は、型が *Policy なら共有の表そのものです。p.MaxBody = p.AdminMaxBody は表を書き換え、以後すべての利用者の /upload が 40 MiB になります。同時に動く 2 つのリクエストが同じ場所に書くのでデータ競合でもあります。
直し方は 2 つを重ねました。
- 値型に戻す: ctx から取り出した時点で複製なので、書き換えても表は変わらない(費用は実測 40 B・0 allocs)
- 書き換えずに計算する:
maxBodyFor(role)が上限を返す。値型でも「ポリシーを書き換えて意味を変える」書き方そのものをやめる
Step 4: 「上限なし」を数字で書かない(B3)
nginx では client_max_body_size 0 が「検査しない」です。Go の MaxBytesReader は負の上限を 0 として扱います(request.go 1210 行)。今日のコードは 0 を「既定の 1 MiB」に使っていました。同じ数字が、道具ごとに逆の意味を持つ——これが B3 の根です。-1 は r.ContentLength > -1 で CL 付きをすべて 413 にし、chunked は MaxBytesReader(-1)=0 バイトで 413 にします。今回は閉じる側に倒れたので被害は「取り込みが全部止まる」でしたが、もし maxBody() が -1 を math.MaxInt64 に変換していたら、本当に上限なしになっていました。
直し方は「上限なし」を表せなくすること: 負の値は登録時に panic(T9)、/stream には運用上の天井 64 MiB を数字で書く。
Step 5: 守りは 1 本、財布は分ける(B4)
chi の Group + Use に倣った「グループだけ mux の内側で守りを足す」は、Day 065 で縛った積む順の外にもう 1 本の列を作ります。さらに RateLimitN は外側の RateLimit と同じ a.used の同じ鍵で数えるので、管理画面のリクエストは 1 回で 2 つ減ります(実測 used=map[user:root:2])。
ルート専用の予算を Policy.Limit に移し、外側の RateLimit 1 つが「利用者+パターン」という別の財布で数えるようにしました。グループは守りを足す手段を持たない(Group の引数から []Middleware を消した)ので、積む順は Order の 1 本に戻ります。
⚖️ 迷った他案と採らなかった理由
| 論点 | 採った案 | 採らなかった案と理由 | 前提が変わったら |
|---|---|---|---|
| グループの継承 | **GroupPolicy{Role, Limit}——締める向きだけ型で許す** | ***bool の 3 値(nil=継承)**: Public: ptr(false) で打ち消せるが、宣言表が ptr(true) だらけになり、nil 参照の扱いを守り全部に書く。merge 規則を文書で決める: 規則は書けても、false と「未指定」を区別できない型のままでは守れない | グループごとに公開・非公開を切り替える要件が多い(社内ツールで数十ルートが全部公開等)なら、3 値にしてもよい |
| 表に無いルート | **pattern != "" なら closed、"" なら Policy{}** | 両方 closed(M7): 安全だが認証済みの打ち間違いに 403 で、原因が分からない。起動時に突き合わせる: 標準の ServeMux は一覧を返さないのでできない | chi に移るなら Routes() で起動時検査を足し、実行時の closed は保険に |
| 管理者の上限 | **maxBodyFor(role) で計算(値型)** | ポインタのまま複製して書き換える(q := *p): 直るが、次に誰かが複製を忘れれば同じ穴。ロールごとの別ルート(/admin/upload): 上限の違いをパスで表すとクライアントの分岐が増える | ロール×ルートの組み合わせが増えたら、ポリシーエンジン(OPA・Cedar)へ |
| 上限なし | 天井を数字で書く(64 MiB)+負は起動時に拒む | **NoBodyLimit bool**: 名前の向きは Day 066 の流儀に合うが、上限なしの口はディスクとメモリを枯らす入口になる。nginx の既定も 1m と有限 | 本当にストリームで GB 級を受けるなら、本文上限ではなく読み取り速度・時間の上限(Day 061 のタイムアウト)で守る |
| ルート専用の予算 | 外側の RateLimit 1 つ・別の財布(利用者+パターン) | 同じ財布で上限だけ変える(M4): 管理画面の利用で一般 API の予算が減る。内側に 2 つ目の RateLimit(B4): 二重に数え、積む順が 2 本になる | 管理画面の利用も全体の予算に含めたい(総量で守りたい)なら、全体の財布にも 1 回数える=2 つの財布に 1 回ずつ |
🧭 設計思想・なぜこう書くのか
- 型で「できないこと」を作る: Go には継承もアノテーションも無いので、「グループから緩める指定を受け継げない」をコードレビューで守るのは難しい。
GroupPolicyという小さな別の型を作るだけで、書けないことになります。Go の「小さい型を組み合わせる」流儀の使いどころです - 値型は「読むだけ」を言語が保証する: Go のポインタと値の選択は、性能より先に共有するかどうかの宣言です。実測で得が 40 B しか無いなら、値を選ぶ
- ゼロ値の意味を 1 つにする:
0が「既定」、-1が「上限なし」のように同じフィールドに複数の番兵を持たせると、道具ごとの慣習(nginx の 0、MaxBytesReader の負=0)と衝突します - 入口を 1 つに: mux と表の 2 か所に書く処理を
register1 つにまとめる。2 か所に書く限り、いつか食い違う
🌐 他言語との比較
| FW | グループの既定を子が打ち消す方法 | 今日との違い |
|---|---|---|
| Go net/http(今日) | 打ち消せない(緩める指定は継承しない) | 型で制限 |
| chi(Go) | Group は親の守りの列を複製して足すだけ(With、mux.go 249〜255 行)。外すには別の Group に分ける | 守りを足す(関数の列)。今日は値を足す |
| ASP.NET Core | コントローラの [Authorize] を、アクションの [AllowAnonymous] が打ち消す(手元知識・未確認) | 属性に「打ち消し」専用の型があるので、ゼロ値問題が起きない。代わりに打ち消しが grep で探しにくい場所に散らばる |
| Spring Security(Java) | requestMatchers を上から順に評価し、最初に一致した規則を使う(手元知識・未確認) | 継承ではなく順序。/internal/** の permitAll を /internal/secrets より先に書くと開く——B1 と同じ形の穴が「行の順番」で起きる |
| nginx | location ブロックごとに client_max_body_size、0 で検査しない(一次資料確認済み) | 0=無制限。今日のコードの 0=既定と逆 |
🏭 実務での使いどころ
- コードレビューの観点: ポリシー・設定の構造体がポインタで持ち回されていたら、守りの中に代入文が無いかを grep する(
\.Policy\.\w+ =、p\.\w+ =) - 「上限なし」を頼まれたとき: 数字の番兵(-1・0)で書かず、天井の値を相談する。nginx・Go・ALB などで 0 や負の意味が違うので、設定を移植したときに逆転する
- グループ機能を足すとき: 何を継承させるかを、締める向き/緩める向きで分けて決める。緩める向きを継承させるなら、子が打ち消せる型(3 値)にする
- 障害調査: 「特定の利用者が操作した後から、他の利用者の挙動が変わった」は共有状態の書き換え(B2)を疑う。T8 のように同時実行で揺れるなら、
-raceを付けて再現する
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| 「ゼロ値を最も厳しくしたから、継承しても安全」 | 単独のルートではうまくいったから | ゼロ値は「書いていない」と区別できないので、子が親の緩めを打ち消せない(B1) |
| 「ctx から取り出した値はコピー」 | Day 066 では値型だったから | 型が *Policy なら共有の表そのもの。代入すると全員に効く(B2) |
| 「-1 は上限なしの慣習」 | 設定ファイルでよく見る | nginx は 0、Go の MaxBytesReader は負を 0 として扱う。道具ごとに違う(B3) |
| 「グループだけ守りを足すのは chi の流儀」 | Group+Use の例が多い | 積む順が 2 本になり、同じ状態を 2 回数える(B4)。値で表せるなら値で |
| 「表に無いルートは Policy{}(最も厳しい)で十分」 | Day 066 の未登録パスの扱い | Role は締める向きなので、Policy{} では admin ルートが開く。mux が知っているのに表に無いなら closed(B5) |
| 「1 つ直したら別のテストが落ちた=直し方が悪い」 | 落ちる場所が変わると不安になる | 隠れていた穴が見えるようになっただけのことがある(B5 が B1 を隠していた) |
🚧 扱わなかった範囲と理由
- **実サーバー(
httptest.NewServer)での確認**: 今日のバグはすべて守りの中の値の扱いで、通信路に依存しない。T8 の同時実行は in-process で確かめた - ルート専用の予算の継承で子が親より緩める場合(グループ
Limit: 2の中で子がLimit: 100): 今日のGroup.Handleは子の値を優先するので緩められる。Limitを「小さいほうを取る」にするかは要件次第で、今日は決めていない - **管理グループ内で子が
Public: trueを書いた場合**: Auth はPublicをRoleより先に見るので公開になる。宣言表にPublic: trueと書かれる=grep で見つかるので、今日は「明示的な緩め」として許した - 起動時の検査(mux と表の突き合わせ): 標準の ServeMux には一覧の API が無い。chi の
Routes()を使う版は書いていない - 時間の比較: 計測中の負荷が高く使っていない。割り当てだけを比べた
- ASP.NET Core・Spring Security の実際の挙動: 手元知識のまま、ドキュメントは読んでいない
🚀 次のステップ
- 発展:
Groupの入れ子(/admin/billingが/adminの中にある)を足し、Roleが入れ子でどう重なるべきか(内側が上書き/両方を要求)を決めてテストにする - 発展: chi に移植し、
Routes()で起動時に「全ルートに表の行があるか」を検査する。実行時の closed と起動時の検査で、どちらが何を捕まえるかを変異で比べる - 次回予告: 同テーマ(10/25 まで据え置き)。次は「概念理解」軸で、ミドルウェアの合成をデコレータ・関数合成・パイプラインの 3 つの見方で比べ、Go の
func(http.Handler) http.Handlerが他の形(next()を呼ぶ Express 型・(ctx, next)の Koa 型)と比べて何を表せて何を表せないかを実測する