Day 057 — 2026-09-27 | ミドルウェア設計パターン: ルートごとに違うミドルウェアをどう組むか——ServeMux だけの3案と chi を、同じ要件表10行で測って選ぶ

2026-09-27 🟡 上級者 / Phase 3 設計・アーキテクチャ ミドルウェア設計パターン

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

テーマ15の2日目です(期間は 2026-09-25〜2026-10-24)。Day 056 では「1本のチェーンを全部のリクエストに被せる」前提で、順序・return 抜け・ResponseWriter のラップのバグを直しました。実際の API はそれで済みません。プローブにはログを出したくない、管理 API だけ権限を変えたい、Webhook は Bearer ではなく署名で守りたい——ルートによって被せるものが違います。今日はその組み方を設計します。

標準の ServeMux について、今日の設計を左右する3つの事実

Go 1.22 で http.ServeMux はメソッドとワイルドカード(GET /orders/{id})を扱えるようになり、Go 1.23 で Request.Pattern(一致したパターン)が入りました。今日の判断に効くのは次の3つです。いずれも Go 1.27.1 で確かめています。

  1. **ServeMux は、受け取った *Request に Pattern を直接書き込む**(net/http/server.go の ServeMux.ServeHTTP):
    h, r.Pattern, r.pat, r.matches = mux.findHandler(r)
    h.ServeHTTP(w, r)

    外側のミドルウェアが next.ServeHTTP から戻った後に r.Pattern を読めば、一致したパターンが見えます。ただし**途中のミドルウェアが r.WithContext(...) などで Request をコピーすると、mux が書き込むのはコピーの方**なので、外側からは空に見えます(実測は模範解答の §4)

  2. **パスは合うがメソッドが合わないときは 405 と Allow ヘッダを返す**。ただし同じ mux に "/" のような全部に一致するパターンがあると、そちらが一致してしまい 405 が 404 になります(実測は §4)
  3. より具体的なパターンが優先される。"/api/" と "GET /api/v1/catalog" を両方登録すると、カタログは後者に行きます

アクセスログの route にパスではなくパターンを出す理由

/api/v1/orders/42・/api/v1/orders/43 …をそのままラベルにすると、Prometheus の時系列が注文 ID の数だけ増えます。Prometheus のドキュメント("Metric and label naming" の Labels の節)は、ラベルの値の組み合わせごとに時系列が1本できることを挙げ、ユーザー ID のように値の種類が多いものをラベルにしないよう注意しています。パターン(GET /api/v1/orders/{id})ならルートの数で止まります。

chi のしくみ(v5.3.2 のソースで確認)

go-chi/chi(star 22,894、2026-09-27 に gh api repos/go-chi/chi で取得。依存モジュールは0個で、go.mod に require がありません)は、ルートごとのミドルウェアを Group と With で書けます。

  • r.Use(...) はルートより前に呼ぶ必要があります。後に呼ぶと panic("chi: all middlewares must be defined before routes on a mux")(mux.go 102行目)
  • r.Group(func(r chi.Router){ r.Use(...) }) と r.With(...) は「inline mux」を作り、そのミドルウェアはルートが見つかった後、そのエンドポイントにだけ被さります
  • 一致したパターンは chi.RouteContext(r.Context()).RoutePattern() で読めます。context に入っているのはポインタなので、途中で Request がコピーされても外側から読めます
  • chi.Walk で登録済みのルートと、それに被さるミドルウェアの数を列挙できます。標準の ServeMux には、登録済みパターンを列挙する API がありません

Kubernetes のプローブが出すログの量

Kubernetes の liveness/readiness プローブは、既定で10秒ごとに叩かれます(Kubernetes ドキュメント "Configure Liveness, Readiness and Startup Probes" の periodSeconds の既定値 10)。2種類のプローブを1 Pod で受けると、1日に 2 × 86,400 ÷ 10 = 17,280 行、30 Pod で 518,400 行のアクセスログになります(導出。Pod 数30は仮定)。障害調査で読みたい行がこれに埋もれるため、プローブにはログを出さない要件がよく付きます。

📝 問題

あなたは注文 API のルーティングの組み方を決める担当です。部品(Day 056 で直したミドルウェアと、今日足した3つの守り)は揃っています。**server.go の newServer をどう組むか**を設計し、実装してください。

要件(R1〜R7)

#要件
R1GET /healthz・GET /readyz は認証なし。アクセスログを出さない
R2/api/v1/ の下はユーザーの Bearer トークンが要る。ただし GET /api/v1/catalog は公開。トークンが無ければ 401 と WWW-Authenticate ヘッダ(RFC 9110 §15.5.2 は 401 にこのヘッダを必須としています)
R3GET /admin/users は管理者トークンが要る。ユーザーのトークンなら 403(誰かは分かったが権限が無い)、無ければ 401。通ったら監査ログを1行
R4POST /webhooks/payments は Bearer ではなく、生の本文の HMAC-SHA256 を X-Signature ヘッダ(hex)で検証する
R5プローブ以外の全リクエストに、RequestID・アクセスログ(route に一致したパターン)・Recover を付ける。認証で弾いたリクエストにも route を付ける(ルートごとの 401 の急増をダッシュボードで見たい)
R6404 と 405 もアクセスログに残す。405 は 405 のまま(Allow ヘッダ付き)返す
R7新しいルートを足した人が「守り方」を決め忘れたら、起動時かテストで必ず気づける

候補の4案

// 案A: 1つの mux に全ルート。全体に1本のチェーンを被せ、例外はパスの一覧で飛ばす
Chain(mux, RequestID, skipLogFor(noLog), Recover, authByPath(publicPaths, "/admin/", "/webhooks/"))

// 案B: 接頭辞ごとに子 mux を作り、子 mux ごとにチェーンを被せる
root.Handle("GET /api/v1/catalog", Chain(catalogMux, base...))       // 公開の例外は、より具体的なパターンで先取り
root.Handle("/api/",      Chain(apiMux,     append(base, RequireUser)...))
root.Handle("/admin/",    Chain(adminMux,   append(base, RequireAdmin)...))
root.Handle("/webhooks/", Chain(webhookMux, append(base, VerifySignature)...))

// 案C: ルートを宣言表(パターン・守り方・ハンドラ)にし、守りはルートごとに被せる。土台(base)は外側に1回
app.Handle(rt.Pattern, guardFor(rt.Access)(rt.Handler))
root.Handle("/", Chain(app, base...))

// 案D: chi の Group / With
r.Group(func(r chi.Router) { r.Use(base...); r.With(RequireAdmin).Get("/admin/users", ...) })

課題

  1. 予測: 下の main_test.go の要件表10行を、案A〜D のそれぞれが何行満たすかを、実行する前に予測してください。特に次の3つが各案でどうなるかを書きます
    • トークン無しの GET /api/v1/orders/42(401)のログの route
    • POST /webhooks/payments のログの route
    • 404・405 がログに残るか
  2. 選択と記録: 1つの案を選び、「採った案/採らなかった案と理由/前提が変わったら判断がどう変わるか」を表にしてください。比べる軸は少なくとも「R7(決め忘れに気づけるか)」「R5・R6(観測)」「無いパスを匿名で叩いたとき 401 か 404 か」「依存の数」の4つ
  3. 実装: 選んだ案で server.go を書き、main_test.go を全部通してください
  4. R7 の仕組み: 標準の ServeMux は登録済みのルートを列挙できません。それでも「決め忘れ」を機械的に捕まえる方法を1つ設計し、テストを1本書いてください

準備

mkdir day057 && cd day057
# 下の go.mod・main.go・server.go・main_test.go を置く
go test -count=1 -v ./...     # 最初は server.go の panic で落ちる

go.mod:

module day057

go 1.24

main.go(部品。そのまま使う)

// 注文 API の部品(ミドルウェア・守り・本体ハンドラ)。組み立ては server.go。
package main

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"os"
	"time"
)

type Middleware func(http.Handler) http.Handler

// Chain は mws を「先に書いたものが外側」になるように h に被せる(Day 056 の修正版)。
func Chain(h http.Handler, mws ...Middleware) http.Handler {
	for i := len(mws) - 1; i >= 0; i-- {
		h = mws[i](h)
	}
	return h
}

// ---- 土台(全ルート共通。プローブ以外) ----

type ctxKey struct{}

func RequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-Id")
		if id == "" {
			b := make([]byte, 8)
			_, _ = rand.Read(b)
			id = hex.EncodeToString(b)
		}
		w.Header().Set("X-Request-Id", id)
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, id)))
	})
}

func requestIDFrom(ctx context.Context) string {
	id, _ := ctx.Value(ctxKey{}).(string)
	return id
}

type statusRecorder struct {
	http.ResponseWriter
	status int
}

func (s *statusRecorder) WriteHeader(code int) {
	if s.status == 0 {
		s.status = code
	}
	s.ResponseWriter.WriteHeader(code)
}

func (s *statusRecorder) Write(b []byte) (int, error) {
	if s.status == 0 {
		s.status = http.StatusOK
	}
	return s.ResponseWriter.Write(b)
}

func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }

// Logging はアクセスログを1行書く。route にはパスではなく一致したパターンを出す。
// r2 は自分で作った浅いコピーで、内側の ServeMux はこの r2 に Pattern を書き込む。
// Logging と mux の間に Request をコピーするミドルウェアを挟まないこと(挟むと route が空になる)。
func Logging(logger *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			rec := &statusRecorder{ResponseWriter: w}
			r2 := r.WithContext(r.Context())
			defer func() {
				logger.Info("request",
					"id", requestIDFrom(r.Context()),
					"method", r.Method,
					"route", r2.Pattern,
					"status", rec.status,
					"dur_ms", time.Since(start).Milliseconds(),
				)
			}()
			next.ServeHTTP(rec, r2)
		})
	}
}

func Recover(logger *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if v := recover(); v != nil {
					if v == http.ErrAbortHandler {
						panic(v)
					}
					logger.Error("panic", "id", requestIDFrom(r.Context()), "value", fmt.Sprint(v))
					http.Error(w, "internal error", http.StatusInternalServerError)
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}

// ---- 守り(ルートごとに1つ選ぶ) ----

type Config struct {
	UserToken, AdminToken, WebhookSecret string
}

func bearer(r *http.Request) string {
	const p = "Bearer "
	h := r.Header.Get("Authorization")
	if len(h) > len(p) && h[:len(p)] == p {
		return h[len(p):]
	}
	return ""
}

func eq(a, b string) bool { return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 }

func unauthorized(w http.ResponseWriter) {
	w.Header().Set("WWW-Authenticate", `Bearer realm="orders"`) // RFC 9110 §15.5.2 / RFC 6750 §3
	http.Error(w, "unauthorized", http.StatusUnauthorized)
}

func RequireUser(cfg Config) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			t := bearer(r)
			if !eq(t, cfg.UserToken) && !eq(t, cfg.AdminToken) {
				unauthorized(w)
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

func RequireAdmin(cfg Config, logger *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			switch t := bearer(r); {
			case eq(t, cfg.AdminToken):
				logger.Info("audit", "id", requestIDFrom(r.Context()), "actor", "admin", "route", r.Pattern)
				next.ServeHTTP(w, r)
			case eq(t, cfg.UserToken):
				http.Error(w, "forbidden", http.StatusForbidden) // 誰かは分かったが権限が無い
			default:
				unauthorized(w)
			}
		})
	}
}

// VerifySignature は生の本文の HMAC-SHA256 を X-Signature(hex)と比べる。
// 署名は生のバイト列に対して取るので、本文を読み切る。上限は 1 MiB。
func VerifySignature(secret string) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
			if err != nil {
				http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
				return
			}
			mac := hmac.New(sha256.New, []byte(secret))
			mac.Write(body)
			got, _ := hex.DecodeString(r.Header.Get("X-Signature"))
			if !hmac.Equal(got, mac.Sum(nil)) {
				http.Error(w, "bad signature", http.StatusUnauthorized)
				return
			}
			r2 := r.WithContext(r.Context())
			r2.Body = io.NopCloser(bytes.NewReader(body))
			next.ServeHTTP(w, r2)
		})
	}
}

// ---- 本体のハンドラ(中身は今日の論点ではないので最小) ----

func catalog(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `[{"sku":"A-1"}]`) }

func getOrder(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, `{"id":%q,"customer":"ACME"}`, r.PathValue("id"))
}

func createOrder(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) }

func listUsers(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `[{"name":"alice"}]`) }

func paymentWebhook(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }

func probe(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	cfg := Config{UserToken: os.Getenv("USER_TOKEN"), AdminToken: os.Getenv("ADMIN_TOKEN"), WebhookSecret: os.Getenv("WEBHOOK_SECRET")}
	if cfg.UserToken == "" || cfg.AdminToken == "" || cfg.WebhookSecret == "" {
		logger.Error("USER_TOKEN / ADMIN_TOKEN / WEBHOOK_SECRET を設定してください")
		os.Exit(1)
	}
	logger.Info("listening", "addr", ":8080")
	if err := http.ListenAndServe(":8080", newServer(logger, cfg)); err != nil {
		logger.Error("server", "err", err)
	}
}

server.go(ここを設計する)

// server.go(ここを設計する)
package main

import (
	"log/slog"
	"net/http"
)

// newServer は要件 R1〜R7 を満たす http.Handler を返す。
// 部品(RequestID・Logging・Recover・RequireUser・RequireAdmin・VerifySignature)と
// 本体のハンドラ(catalog・getOrder・createOrder・listUsers・paymentWebhook・probe)は main.go にある。
func newServer(logger *slog.Logger, cfg Config) http.Handler {
	panic("TODO: 設計して組み立てる")
}

main_test.go(これが全部通れば完了)

package main

import (
	"bytes"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"log/slog"
	"net/http/httptest"
	"strings"
	"testing"
)

var cfg = Config{UserToken: "user-token", AdminToken: "admin-token", WebhookSecret: "whsec-test"}

func sign(body string) string {
	m := hmac.New(sha256.New, []byte(cfg.WebhookSecret))
	m.Write([]byte(body))
	return hex.EncodeToString(m.Sum(nil))
}

// logsOf は JSON ログのうち msg が一致する行を返す。
func logsOf(t *testing.T, buf *bytes.Buffer, msg string) []map[string]any {
	t.Helper()
	var out []map[string]any
	for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
		if line == "" {
			continue
		}
		var m map[string]any
		if err := json.Unmarshal([]byte(line), &m); err != nil {
			t.Fatalf("log line is not JSON: %q", line)
		}
		if m["msg"] == msg {
			out = append(out, m)
		}
	}
	return out
}

type tc struct {
	name, method, path, token, body, sig string
	wantCode                             int
	wantRoute                            string // アクセスログの route。"-" はログが出ないこと
}

var cases = []tc{
	{"R1 プローブは認証なし・ログなし", "GET", "/healthz", "", "", "", 200, "-"},
	{"R2 カタログは公開", "GET", "/api/v1/catalog", "", "", "", 200, "GET /api/v1/catalog"},
	{"R2 注文はトークン必須", "GET", "/api/v1/orders/42", "", "", "", 401, "GET /api/v1/orders/{id}"},
	{"R2 注文をユーザーで取得", "GET", "/api/v1/orders/42", "user-token", "", "", 200, "GET /api/v1/orders/{id}"},
	{"R3 管理はユーザーでは 403", "GET", "/admin/users", "user-token", "", "", 403, "GET /admin/users"},
	{"R3 管理は管理者で 200", "GET", "/admin/users", "admin-token", "", "", 200, "GET /admin/users"},
	{"R4 Webhook は署名で通る", "POST", "/webhooks/payments", "", `{"paid":1}`, sign(`{"paid":1}`), 204, "POST /webhooks/payments"},
	{"R4 Webhook の署名違い", "POST", "/webhooks/payments", "", `{"paid":1}`, sign(`{"paid":2}`), 401, "POST /webhooks/payments"},
	{"R6 メソッド違いは 405 のまま", "DELETE", "/api/v1/orders/42", "user-token", "", "", 405, ""},
	{"R6 無いパスは 404 でログに残る", "GET", "/api/v1/nope", "user-token", "", "", 404, ""},
}

func TestRequirements(t *testing.T) {
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			var buf bytes.Buffer
			h := newServer(slog.New(slog.NewJSONHandler(&buf, nil)), cfg)
			req := httptest.NewRequest(c.method, c.path, strings.NewReader(c.body))
			if c.token != "" {
				req.Header.Set("Authorization", "Bearer "+c.token)
			}
			if c.sig != "" {
				req.Header.Set("X-Signature", c.sig)
			}
			rec := httptest.NewRecorder()
			h.ServeHTTP(rec, req)

			if rec.Code != c.wantCode {
				t.Errorf("code = %d, want %d (body %q)", rec.Code, c.wantCode, rec.Body.String())
			}
			if c.wantCode == 401 && c.path != "/webhooks/payments" && rec.Header().Get("WWW-Authenticate") == "" {
				t.Errorf("401 without WWW-Authenticate")
			}
			if c.wantCode == 405 && rec.Header().Get("Allow") == "" {
				t.Errorf("405 without Allow")
			}
			if strings.Contains(rec.Body.String(), "ACME") && c.wantCode != 200 {
				t.Errorf("order data leaked: %q", rec.Body.String())
			}
			logs := logsOf(t, &buf, "request")
			switch {
			case c.wantRoute == "-" && len(logs) != 0:
				t.Errorf("probe was logged: %v", logs)
			case c.wantRoute != "-" && len(logs) != 1:
				t.Fatalf("want 1 access log, got %d; logs=%s", len(logs), buf.String())
			case c.wantRoute != "-":
				if logs[0]["route"] != c.wantRoute {
					t.Errorf("logged route = %q, want %q", logs[0]["route"], c.wantRoute)
				}
				if logs[0]["status"] != float64(c.wantCode) {
					t.Errorf("logged status = %v, want %d", logs[0]["status"], c.wantCode)
				}
			}
		})
	}
}

// R3: 管理 API は監査ログを1行残す。
func TestAdminIsAudited(t *testing.T) {
	var buf bytes.Buffer
	h := newServer(slog.New(slog.NewJSONHandler(&buf, nil)), cfg)
	req := httptest.NewRequest("GET", "/admin/users", nil)
	req.Header.Set("Authorization", "Bearer admin-token")
	h.ServeHTTP(httptest.NewRecorder(), req)
	a := logsOf(t, &buf, "audit")
	if len(a) != 1 || a[0]["route"] != "GET /admin/users" {
		t.Errorf("audit logs = %v", a)
	}
}

💡 ヒント(クリックで開く)

ヒント1 — 方向性

課題1は、ログを書く Logging と、パターンを決める mux の間に何が挟まっているかを案ごとに書き出すと解けます。「mux にたどり着く前に 401 で返った」なら、mux はパターンを書き込む機会がありません。「間で Request がコピーされた」なら、書き込まれるのはコピーの方です。

ヒント2 — アプローチ
  • 案A・案B は「認証 → mux」の順です。案C・案D は「mux(ルーティング)→ 認証」の順です。どちらの順かで、R5 と「無いパスへの匿名アクセス」の答えが決まります
  • VerifySignature は署名を検証するために本文を読み切り、r.WithContext でコピーした Request に本文を付け直してから次へ渡します。これが Logging と mux の間にあると何が起きるでしょうか
  • chi の Group の Use は、エンドポイントが見つかったときだけ被さります。見つからなかった(404・405)リクエストはどこへ行くでしょうか
  • R7: ServeMux が列挙できないなら、登録する前のデータを自分で持てばよい。守り方の型のゼロ値を「未決定」にすると、書き忘れを登録時に検出できます
ヒント3 — コード骨格
type Access int
const (
	accessUnset Access = iota // ゼロ値 = 決めていない
	Public; User; Admin; Webhook
)
type Route struct { Pattern string; Access Access; Handler http.HandlerFunc }

app := http.NewServeMux()
for _, rt := range routeTable() {
	guard, ok := guards[rt.Access]   // accessUnset は map に無い
	if !ok { panic(...) }
	app.Handle(rt.Pattern, guard(rt.Handler))
}
root := http.NewServeMux()
root.HandleFunc("GET /healthz", probe)          // 土台を通さない
root.Handle("/", Chain(app, RequestID, Logging(logger), Recover(logger)))
// "/" は root に置く。app に置くと 405 が 404 になる

✅ 模範解答

模範解答を表示する(まず課題1の予測を書いてから)
案A 全体+スキップ一覧
6/10 案B 接頭辞ごとの子 mux
6/10 案C 宣言表(採用)
10/10 案D chi Group/With
8/10

同じ要件表10行を各案に流して満たした行数(Go 1.27.1 で実測、§4)。

1. 採った案: 案C(宣言表+ルートごとの守り)+土台は外側に1回

root mux ─┬─ GET /healthz, GET /readyz ──────────────────────────→ probe(ログなし)
          └─ "/" → RequestID → Logging → Recover → app mux ─┬─ Public  → catalog
                                     (r2 を作る)   (r2 に Pattern を書く)├─ User    → RequireUser → getOrder …
                                                                  ├─ Admin   → RequireAdmin → listUsers
                                                                  └─ Webhook → VerifySignature → paymentWebhook

ポイントは3つです。

  • ルーティングを認証より先にする。app mux がパターンを決めてから守りが走るので、401・403 にも route が付きます(R5)。無いパスは守りに届く前に 404 になります
  • Logging と app mux の間で Request をコピーしない。Logging 自身が作った r2 を mux に渡し、mux が r2.Pattern に書き込んだ値を defer で読みます。VerifySignature のようにコピーするものは mux の内側(ルートごと)に置きます
  • **"/" は root 側に置く**。app mux に置くと、メソッド違いが "/" に一致して 405 が 404 になります

2. server.go(模範解答)

// server.go(模範解答): 案C「ルートの宣言表+ルートごとの守り」+共通の土台は外側に1回だけ被せる。
package main

import (
	"fmt"
	"log/slog"
	"net/http"
)

// Access はルートの守り方。ゼロ値は「決めていない」で、登録時に panic する。
type Access int

const (
	accessUnset Access = iota
	Public
	User
	Admin
	Webhook
)

// ---- ルートの宣言表 ----

type Route struct {
	Pattern string
	Access  Access
	Handler http.HandlerFunc
}

func routeTable() []Route {
	return []Route{
		{"GET /api/v1/catalog", Public, catalog},
		{"GET /api/v1/orders/{id}", User, getOrder},
		{"POST /api/v1/orders", User, createOrder},
		{"GET /admin/users", Admin, listUsers},
		{"POST /webhooks/payments", Webhook, paymentWebhook},
	}
}

func newServer(logger *slog.Logger, cfg Config) http.Handler {
	return newServerFrom(logger, cfg, routeTable())
}

func newServerFrom(logger *slog.Logger, cfg Config, routes []Route) http.Handler {
	guards := map[Access]Middleware{
		Public:  func(h http.Handler) http.Handler { return h },
		User:    RequireUser(cfg),
		Admin:   RequireAdmin(cfg, logger),
		Webhook: VerifySignature(cfg.WebhookSecret),
	}
	app := http.NewServeMux()
	for _, rt := range routes {
		guard, ok := guards[rt.Access]
		if !ok {
			panic(fmt.Sprintf("route %q: Access が決まっていない", rt.Pattern))
		}
		app.Handle(rt.Pattern, guard(rt.Handler))
	}

	root := http.NewServeMux()
	root.HandleFunc("GET /healthz", probe) // 土台を通さない: ログも ID も付けない
	root.HandleFunc("GET /readyz", probe)
	root.Handle("/", Chain(app, RequestID, Logging(logger), Recover(logger)))
	return root
}

3. R7 のテスト(r7_test.go)

package main

import (
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

// R7: 宣言表の全ルートについて、Public 以外は資格情報なしで 401 になること。
// ルートを足したら自動でこのテストの対象になる。
func TestEveryNonPublicRouteRejectsAnonymous(t *testing.T) {
	h := newServer(slog.New(slog.DiscardHandler), cfg)
	for _, rt := range routeTable() {
		if rt.Access == Public {
			continue
		}
		method, path, _ := strings.Cut(rt.Pattern, " ")
		path = strings.ReplaceAll(path, "{id}", "1")
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest(method, path, strings.NewReader("{}")))
		if rec.Code != http.StatusUnauthorized {
			t.Errorf("%s: anonymous got %d, want 401", rt.Pattern, rec.Code)
		}
	}
}

// R7: Access を書き忘れたルートは起動時に止まること。
func TestUnsetAccessPanics(t *testing.T) {
	defer func() {
		if recover() == nil {
			t.Errorf("route without Access was registered silently")
		}
	}()
	newServerFrom(slog.New(slog.DiscardHandler), cfg, []Route{{Pattern: "GET /api/v1/new", Handler: func(http.ResponseWriter, *http.Request) {}}})
}

routeTable() という登録前のデータを持つので、ServeMux が列挙できなくてもテストがルートを1つずつ叩けます。ルートを足すと自動でこのテストの対象になります。Access を書かなければゼロ値 accessUnset になり、guards に無いので起動時に panic します。

4. 実測(Go 1.27.1 / darwin arm64 / Apple M1、2026-09-27)

4案に同じ要件表10行を流しました(matrix_test.go・measure_test.go・chi 版は別モジュールの chi_test.go。コードは §6)。

案A 全体チェーン+スキップ一覧: 6/10
    R2 注文はトークン必須      got 401 route="" / want 401 route="GET /api/v1/orders/{id}"
    R3 管理はユーザーでは 403  got 403 route="" / want 403 route="GET /admin/users"
    R4 Webhook は署名で通る    got 204 route="" / want 204 route="POST /webhooks/payments"
    R4 Webhook の署名違い      got 401 route="" / want 401 route="POST /webhooks/payments"
案B 接頭辞ごとの子 mux: 6/10
    R2 注文はトークン必須      got 401 route="/api/" / want 401 route="GET /api/v1/orders/{id}"
    R3 管理はユーザーでは 403  got 403 route="/admin/" / want 403 route="GET /admin/users"
    R4 Webhook は署名で通る    got 204 route="/webhooks/" / want 204 route="POST /webhooks/payments"
    R4 Webhook の署名違い      got 401 route="/webhooks/" / want 401 route="POST /webhooks/payments"
案C 宣言表+ルートごとの守り(模範): 10/10
案D chi Group/With: 8/10
    R6 メソッド違いは 405 のまま     got 405 route="-" / want 405 route=""   ("-" = ログが出ていない)
    R6 無いパスは 404 でログに残る   got 404 route="-" / want 404 route=""
匿名 GET /api/v1/nope       -> A: 401  B: 401  C: 404  D: 404
匿名 GET /api/v1/orders/42  -> A: 401  B: 401  C: 401  D: 401
Request をコピーするミドルウェアの内と外で r.Pattern を読む: [コピーの内=GET /orders/{id} コピーの外=]
"/" を同じ mux に登録=false: DELETE /orders/1 -> 405 Allow="GET, HEAD"
"/" を同じ mux に登録=true:  DELETE /orders/1 -> 404 Allow=""
chi.Walk: GET /admin/users (mw=4) / GET /api/v1/catalog (mw=3) / POST /api/v1/orders (mw=4)
          GET /api/v1/orders/{id} (mw=4) / GET /healthz (mw=0) / GET /readyz (mw=0) / POST /webhooks/payments (mw=4)

読み方:

  • **案A の route=""**: 認証が mux より外にあるので、401・403 は mux に届かず、パターンが決まりません。Webhook は通っても空です。VerifySignature が Logging と mux の間で Request をコピーするためです(上の「コピーの外=」が同じ現象を単独で再現しています)
  • **案B の route="/api/"**: root mux が書いた接頭辞パターンのまま。子 mux に届く前に弾かれると、細かいパターンに上書きされません
  • 案D が落とす2行: Group の Use はエンドポイントにしか被さらないので、404・405 は Logging を通りません。直すにはトップレベルの r.Use に Logging を移しますが、そうするとプローブにもログが付くので、R1 のためにパスの一覧で飛ばす処理が要ります(案A と同じ形に戻る)
  • 速さは判断材料にならない: GET /api/v1/orders/42 を1回処理する時間は、3回測って A 2,981〜4,775 ns、B 3,052〜3,597 ns、C 2,417〜4,017 ns、D 2,219〜2,586 ns(go test -bench、実測)。測るたびの揺れ(同じ案で最大1.7倍)が案の差より大きく、どれもネットワーク往復の 1/100 未満です(データセンター内の往復を 0.5 ms と仮定)

5. 採った案と採らなかった案

軸案A 全体+スキップ一覧案B 接頭辞ごとの子 mux案C 宣言表(採用)案D chi
要件表(実測)6/106/1010/108/10
R7 決め忘れ一覧に無いパスは User 扱い(閉じる側に倒れるが、管理用の新しい接頭辞も User になる)接頭辞の下に置けば自動で守られる。接頭辞の外に置くと守られないゼロ値で起動時に panic + 表を歩くテストchi.Walk で列挙できるが、見えるのはミドルウェアの数だけで、どれが認証かは分からない
認証で弾いた行の route空接頭辞だけパターンパターン
無いパスへの匿名アクセス401401404404
依存000chi 1つ(chi 自体の依存は0)
守りの見え方パスの文字列比較に散らばるディレクトリ構造のように接頭辞で読めるルート1行に守り方が書いてあるルートの入れ子で読める

採らなかった理由:

  • 案A: 例外(公開・ログなし)が増えるたびに一覧を直す必要があり、一覧と mux の2か所にルートが現れます。認証で弾いた行にパターンが付かない(R5)のは構造上の限界で、直せません
  • 案B: 接頭辞でまとまる API(/admin/ の下は全部管理者)では読みやすく、無いパスにも 401 を返すので、匿名の相手にルートの有無を見せないという利点があります。ただ R5 を満たせず、公開の例外(カタログ)が root と子 mux の2か所に分かれます
  • 案D: 書き味は最も良く、RoutePattern がコピーに強いのは設計として優れています。ただ今日の要件では R1 と R6 が衝突します(404・405 をログに残すにはトップレベルの Use が要り、そうするとプローブにもログが付く)。chi では NotFound・MethodNotAllowed のハンドラを自分で包んで Logging を通す方法もありますが、今日は測っていません

前提が変わったら:

前提の変化判断
ルートの存在を匿名の相手に知られたくない(社内向け管理 API を外に出している等)案B に寄せる。または案C のまま、/admin/ だけ接頭辞で先に 401 を返す守りを root に足す
ルートが100本を超え、入れ子のグループが3段以上になる案D(chi)。宣言表が平らすぎて読みにくくなる。chi でも r.Use の Logging を最外に置き、プローブは別の http.ServeMux に分けて前段で振り分ければ R1 と R6 を両立できる
アクセスログに route が要らない(L7 のロードバランサーがルート別の指標を出している等)案A でも足りる。部品が最少
守り方が「誰が・何に」の組み合わせ(ロール×リソース)になるAccess の列挙では足りない。ポリシーエンジン(OPA・Cedar など)に「このパターンにこのロール」を問い合わせる形にする。宣言表はその入力になる

6. 測定用コード(任意)

matrix_test.go・measure_test.go は模範解答と同じディレクトリに置いて go test -v -run Measure、ベンチマークは go test -run XXX -bench . -count=3 です。

package main

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

// runMatrix は同じ要件表(cases)を別の設計に流し、満たした数と外れた行を出す。
func runMatrix(t *testing.T, name string, build func(*slog.Logger) http.Handler) {
	ok := 0
	var miss []string
	for _, c := range cases {
		var buf bytes.Buffer
		h := build(slog.New(slog.NewJSONHandler(&buf, nil)))
		req := httptest.NewRequest(c.method, c.path, strings.NewReader(c.body))
		if c.token != "" {
			req.Header.Set("Authorization", "Bearer "+c.token)
		}
		if c.sig != "" {
			req.Header.Set("X-Signature", c.sig)
		}
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)
		logs := logsOf(t, &buf, "request")
		route := "-"
		if len(logs) > 0 {
			route = fmt.Sprint(logs[0]["route"])
		}
		if rec.Code == c.wantCode && route == c.wantRoute {
			ok++
		} else {
			miss = append(miss, fmt.Sprintf("    %-28s got %d route=%q / want %d route=%q", c.name, rec.Code, route, c.wantCode, c.wantRoute))
		}
	}
	t.Logf("%s: %d/%d\n%s", name, ok, len(cases), strings.Join(miss, "\n"))
}
package main

import (
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

var noLog = map[string]bool{"/healthz": true, "/readyz": true}
var publicPaths = map[string]bool{"/healthz": true, "/readyz": true, "/api/v1/catalog": true}

// 案A: 1つの mux + 全体に1本のチェーン。例外はパスの一覧で飛ばす。
func newServerA(logger *slog.Logger) http.Handler {
	mux := http.NewServeMux()
	for _, rt := range routeTable() {
		mux.Handle(rt.Pattern, rt.Handler)
	}
	mux.HandleFunc("GET /healthz", probe)
	mux.HandleFunc("GET /readyz", probe)
	logging := Logging(logger)
	skipLog := func(next http.Handler) http.Handler {
		withLog := logging(next)
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if noLog[r.URL.Path] {
				next.ServeHTTP(w, r)
				return
			}
			withLog.ServeHTTP(w, r)
		})
	}
	user, admin, wh := RequireUser(cfg), RequireAdmin(cfg, logger), VerifySignature(cfg.WebhookSecret)
	auth := func(next http.Handler) http.Handler {
		u, a, v := user(next), admin(next), wh(next)
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			switch p := r.URL.Path; {
			case publicPaths[p]:
				next.ServeHTTP(w, r)
			case strings.HasPrefix(p, "/admin/"):
				a.ServeHTTP(w, r)
			case strings.HasPrefix(p, "/webhooks/"):
				v.ServeHTTP(w, r)
			default:
				u.ServeHTTP(w, r)
			}
		})
	}
	return Chain(mux, RequestID, skipLog, Recover(logger), auth)
}

// 案B: 接頭辞ごとに子 mux を作り、子 mux ごとにチェーンを被せる。
func newServerB(logger *slog.Logger) http.Handler {
	base := []Middleware{RequestID, Logging(logger), Recover(logger)}
	sub := func(prefix string) *http.ServeMux {
		m := http.NewServeMux()
		for _, rt := range routeTable() {
			if strings.Contains(rt.Pattern, " "+prefix) && rt.Access != Public {
				m.Handle(rt.Pattern, rt.Handler)
			}
		}
		return m
	}
	root := http.NewServeMux()
	root.HandleFunc("GET /healthz", probe)
	root.HandleFunc("GET /readyz", probe)
	cat := http.NewServeMux()
	cat.HandleFunc("GET /api/v1/catalog", catalog)
	root.Handle("GET /api/v1/catalog", Chain(cat, base...)) // 公開の例外は、より具体的なパターンで先取り
	root.Handle("/api/", Chain(sub("/api/"), append(base, RequireUser(cfg))...))
	root.Handle("/admin/", Chain(sub("/admin/"), append(base, RequireAdmin(cfg, logger))...))
	root.Handle("/webhooks/", Chain(sub("/webhooks/"), append(base, VerifySignature(cfg.WebhookSecret))...))
	return root
}

func TestMeasureDesigns(t *testing.T) {
	runMatrix(t, "案A 全体チェーン+スキップ一覧", newServerA)
	runMatrix(t, "案B 接頭辞ごとの子 mux", newServerB)
	runMatrix(t, "案C 宣言表+ルートごとの守り(模範)", func(l *slog.Logger) http.Handler { return newServer(l, cfg) })
}

// 無いパスを匿名で叩いたとき、各案は何を返すか(ルートの存在が外から分かるか)
func TestMeasureUnknownAnonymous(t *testing.T) {
	for name, h := range map[string]http.Handler{
		"A": newServerA(slog.New(slog.DiscardHandler)),
		"B": newServerB(slog.New(slog.DiscardHandler)),
		"C": newServer(slog.New(slog.DiscardHandler), cfg),
	} {
		for _, p := range []string{"/api/v1/nope", "/api/v1/orders/42"} {
			rec := httptest.NewRecorder()
			h.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
			t.Logf("案%s 匿名 GET %-18s -> %d", name, p, rec.Code)
		}
	}
}

// Logging と mux の間で Request をコピーすると route が空になるか
func TestMeasurePatternCopy(t *testing.T) {
	var got []string
	peek := func(label string) Middleware {
		return func(next http.Handler) http.Handler {
			return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				next.ServeHTTP(w, r)
				got = append(got, label+"="+r.Pattern)
			})
		}
	}
	copyReq := func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			next.ServeHTTP(w, r.WithContext(r.Context()))
		})
	}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /orders/{id}", func(http.ResponseWriter, *http.Request) {})
	Chain(mux, peek("コピーの外"), copyReq, peek("コピーの内")).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/orders/1", nil))
	t.Logf("%v", got)
}

// 405 を保つか: 同じ mux に "/" を足すと 405 が 404 に化けるか
func TestMeasureCatchAll405(t *testing.T) {
	for _, withRoot := range []bool{false, true} {
		m := http.NewServeMux()
		m.HandleFunc("GET /orders/{id}", func(http.ResponseWriter, *http.Request) {})
		if withRoot {
			m.HandleFunc("/", http.NotFound)
		}
		rec := httptest.NewRecorder()
		m.ServeHTTP(rec, httptest.NewRequest("DELETE", "/orders/1", nil))
		t.Logf(`"/" を同じ mux に登録=%v: DELETE /orders/1 -> %d Allow=%q`, withRoot, rec.Code, rec.Header().Get("Allow"))
	}
}

func benchServe(b *testing.B, h http.Handler) {
	req := httptest.NewRequest("GET", "/api/v1/orders/42", nil)
	req.Header.Set("Authorization", "Bearer user-token")
	b.ReportAllocs()
	for b.Loop() {
		h.ServeHTTP(httptest.NewRecorder(), req)
	}
}

func BenchmarkA(b *testing.B) { benchServe(b, newServerA(slog.New(slog.DiscardHandler))) }
func BenchmarkB(b *testing.B) { benchServe(b, newServerB(slog.New(slog.DiscardHandler))) }
func BenchmarkC(b *testing.B) { benchServe(b, newServer(slog.New(slog.DiscardHandler), cfg)) }

chi 版は依存を分けるため別のディレクトリで測りました。

mkdir day057chi && cd day057chi
go mod init day057chi                      # 新しいモジュールを作る
go get github.com/go-chi/chi/v5@v5.3.2     # chi を取得する(2026-09-27 時点の最新)
# day057 の main.go・server.go・main_test.go・matrix_test.go と、下の chi_test.go を置く
go test -v -run Chi ./...                  # 要件表を chi 版に流す
package main

import (
	"fmt"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/go-chi/chi/v5"
)

// chi 版の Logging: route は chi の RouteContext から取る(context にポインタで入っているので、コピーを挟んでも読める)
func chiLogging(logger *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			rec := &statusRecorder{ResponseWriter: w}
			defer func() {
				route := ""
				if rc := chi.RouteContext(r.Context()); rc != nil && rc.RoutePattern() != "" {
					route = r.Method + " " + rc.RoutePattern()
				}
				logger.Info("request", "id", requestIDFrom(r.Context()), "method", r.Method,
					"route", route, "status", rec.status, "dur_ms", time.Since(start).Milliseconds())
			}()
			next.ServeHTTP(rec, r)
		})
	}
}

// 案D: chi の Group / With
func newServerD(logger *slog.Logger) http.Handler {
	r := chi.NewRouter()
	r.Get("/healthz", probe)
	r.Get("/readyz", probe)
	r.Group(func(r chi.Router) {
		r.Use(RequestID, chiLogging(logger), Recover(logger))
		r.Get("/api/v1/catalog", catalog)
		r.Group(func(r chi.Router) {
			r.Use(RequireUser(cfg))
			r.Get("/api/v1/orders/{id}", getOrder)
			r.Post("/api/v1/orders", createOrder)
		})
		r.With(RequireAdmin(cfg, logger)).Get("/admin/users", listUsers)
		r.With(VerifySignature(cfg.WebhookSecret)).Post("/webhooks/payments", paymentWebhook)
	})
	return r
}

func TestMeasureChi(t *testing.T) {
	runMatrix(t, "案D chi Group/With", newServerD)
	// chi.Walk で登録済みルートを列挙できる
	var n []string
	chi.Walk(newServerD(slog.New(slog.DiscardHandler)).(chi.Routes), func(method, route string, _ http.Handler, mws ...func(http.Handler) http.Handler) error {
		n = append(n, fmt.Sprintf("%s %s (mw=%d)", method, route, len(mws)))
		return nil
	})
	t.Logf("chi.Walk:\n    %s", strings.Join(n, "\n    "))
	rec := httptest.NewRecorder()
	newServerD(slog.New(slog.DiscardHandler)).ServeHTTP(rec, httptest.NewRequest("GET", "/api/v1/nope", nil))
	t.Logf("案D 匿名 GET /api/v1/nope -> %d", rec.Code)
}

func BenchmarkD(b *testing.B) {
	hd := newServerD(slog.New(slog.DiscardHandler))
	req := httptest.NewRequest("GET", "/api/v1/orders/42", nil)
	req.Header.Set("Authorization", "Bearer user-token")
	b.ReportAllocs()
	for b.Loop() {
		hd.ServeHTTP(httptest.NewRecorder(), req)
	}
}

🪜 Step-by-Step 解説

Step 1: 要件を「ルート × 被せるもの」の表にする

R1〜R4 を書き出すと、被せるものは「土台(RequestID・Logging・Recover)」と「守り(なし/User/Admin/署名)」の2層に分かれます。土台はプローブ以外の全部に、守りはルートごとに1つ。この2層を別々の仕組みで被せるのが案C の骨格です。

Step 2: 「ログを書く人」と「パターンを決める人」の位置関係を決める

Request.Pattern を決めるのは mux です。Logging はそれを next から戻った後に読みます。だから Logging → (コピーしないもの) → mux の並びが必要です。案C では Logging と app mux の間にあるのは Recover だけで、Recover は Request をコピーしません。

Step 3: 認証をルーティングの内側に入れる

守りをルートごとに被せると、「mux がパターンを決める → 守りが走る」の順になります。弾いた行にもパターンが付き、R5 を満たします。代わりに、無いパスは守りに届かず 404 になります(案B との差)。

Step 4: プローブと "/" を root に置く

プローブは root で先に取り、土台を通しません(R1)。"/" を root に置くのは、app mux に 405 を判定させるためです。root の "/" はメソッドを問わず一致するので、root 自体は 405 を返しませんが、app mux が返します。

Step 5: 決め忘れを型のゼロ値で止める

Access のゼロ値を accessUnset にし、guards の map に入れません。Route{Pattern: ..., Handler: ...} と書いて Access を忘れると、登録のループで panic します。起動時に落ちるので、デプロイ前の CI のテストか、遅くともステージングの起動で気づけます。

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

Go の標準ライブラリは「ルートのグループ」を持ちません。http.Handler と関数の合成だけで、グループはデータ(宣言表)とループで表せるからです。フレームワークの DSL(chi の Group、Express の Router)が与えてくれる構造を、Go では自分で表にします。その代わり、表は普通の Go の値なので、テストから歩けます。今日の R7 のテストは、この性質だけで書けています。

もう1つは「明示」です。案A の authByPath は、どのルートがどの守りかをパスの文字列比較の中に隠します。案C はルート1行に User と書きます。レビューで「このルートは誰が叩けるか」を1行で答えられることが、ルートが増えたときの安全性を決めます。

🌐 他言語との比較

言語・FWルートごとのミドルウェアの書き方今日の論点との関係(各 FW の公式ドキュメントの記述に基づく。未実測)
Go(net/http)宣言表+ループ、または子 muxグループの仕組みが無いので、自分で表にする。表はテストから歩ける
Go(chi)r.Group・r.With・r.Routeエンドポイントにだけ被さる。404・405 は別扱い(実測)
Node.js(Express)app.get(path, auth, handler) のようにルートの引数に並べる。express.Router() でグループルート1行に守りが並ぶ点は案C と同じ形
Java(Spring Security)SecurityFilterChain で requestMatchers("/admin/**").hasRole("ADMIN")パスの一致規則で守る点は案A・案B に近い。anyRequest().authenticated() を最後に置くのが定番で、「一覧に無いものは閉じる」
Python(FastAPI)APIRouter(dependencies=[Depends(auth)])、ルートごとの Depends依存性注入の宣言がルートに付くので、案C に近い。ルート一覧は app.routes で列挙できる
Ruby on Railsコントローラーの before_action :authenticate, except: [:index]「除外の一覧」を持つ点で案A に近い

🏭 実務での使いどころ

  • 新しいサービスの雛形を作るとき: ルートが10〜50本の API なら、案C の宣言表から始めると、R7 のテストが最初から付きます
  • **「401 がログで route 空になっている」のを見たら**: 認証がルーティングの外にあるか、間で Request をコピーしているかのどちらかです。今日の測定の案A・案B の形です
  • 既存の chi のサービスで 404 がログに出ないと言われたら: ミドルウェアが Group の中だけにある形です。トップレベルの Use へ移すか、NotFound ハンドラを包みます
  • Webhook(Stripe・GitHub など): 署名は生の本文に対して取るので、本文を JSON にデコードするミドルウェアより前で検証します。GitHub の Webhook は X-Hub-Signature-256 に sha256= を付けた hex の HMAC-SHA256 を送ります(GitHub Docs "Validating webhook deliveries")。今日の X-Signature はそれを簡略にした形です

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
外側のミドルウェアで r.Pattern を読めば必ずパターンが取れるmux が書き込む先を意識しないmux は受け取った *Request に書く。間でコピーされると外からは空(実測)
全部に一致する "/" を同じ mux に足しても害はない404 の置き場所として便利メソッド違いが "/" に一致し、405 が 404 になる(実測)
chi の Group に Logging を置けば全リクエストがログに残るUse という名前から全体に効くと思うエンドポイントにしか被さらない。404・405 は通らない(実測)
認証は一番外に置くのが安全「早く弾くほど安全」弾いた行にパターンが付かず、ルート別の 401 を観測できない(実測)。代わりに無いパスを隠せる利点はある
401 と 403 は同じ意味どちらも「ダメ」401 は資格情報が無い・無効(WWW-Authenticate 必須)、403 は誰かは分かったが権限が無い(RFC 9110 §15.5.2・§15.5.4)
ルートの一覧はフレームワークから取ればよい他の FW では取れる標準の ServeMux は列挙できない。登録前のデータを自分で持つ

🚧 扱わなかった範囲

  • 案D を直した形の実測: chi で NotFound・MethodNotAllowed を包む版、プローブを前段の ServeMux に分ける版は、今日は測っていません。「前提が変わったら」の表に書いた形は、コードを書いていない推測です
  • ホスト名によるルーティング(api.example.com/ と admin.example.com/ を分ける): ServeMux のパターンはホストも取れますが、今日の要件に無いので扱っていません
  • ポリシーエンジンとの接続: Access の列挙を OPA や Cedar の問い合わせに替える設計は、名前を挙げただけです
  • レート制限・CORS: ルートごとに違うミドルウェアの代表例ですが、今日の要件には入れていません
  • トークンの中身: 固定の文字列で比べています。JWT の検証・鍵のローテーションは扱っていません

🚀 次のステップ

  • 発展: 宣言表に Log bool(アクセスログを出すか)の列を足し、プローブも表に入れてみましょう。root mux の特別扱いが無くなる代わりに、Logging をルートごとに被せることになり、404・405 のログ(R6)をどう守るかが課題になります
  • 次回予告: 同じテーマ15のまま、切り口を「概念理解」に替えます。ミドルウェアから下流へ値を渡す context.WithValue の使いどころと禁じ手(キーの型、何を入れてよいか)を、go doc context の記述と標準ライブラリ内の実例から整理します

🎯 自己評価

自分の回答

気づき・メモ