Day 063 — 2026-10-03 | ミドルウェア設計パターン: ServeMux の「外」に置くか「内」に置くか——r.Pattern・404・405・307 の見え方を予想してから 9 本のテストで確かめる

2026-10-03 🟡 上級者 / Phase 3 概念理解 ミドルウェア設計パターン
フェーズ: 上級者 (Phase 3) | 問題タイプ: 概念理解(予想→実測。小さな実装つき) | テーマ: ミドルウェア設計パターン 番号は state の「次の番号」(Day 062 の次)。Day 057 は「ルートごとに違う守りを宣言表で選ぶ」、Day 059〜062 は回数・時間・大きさの上限だった。今日はその前提になっている「ミドルウェアを ServeMux のどちら側に置くか」そのものを扱う。直近2日が実装だったので、今日は概念理解に振った。

📚 背景知識

Go のミドルウェアは func(http.Handler) http.Handler というただの関数です。フレームワークの機能ではないので、「どこに置くか」は自分で決めます。置き場所は 2 つあります。

置き場所書き方何が起きるか
外側(ServeMux ごと包む)Chain(mux, A, B)すべてのリクエストが通る。ルーティングの前に走る
内側(ルートごとに包む)mux.Handle("GET /x", Chain(h, A))そのパターンに一致したときだけ通る。ルーティングの後に走る

この差は「ログを足す」程度では気になりません。メトリクスのラベルに「どのルートか」を載せたいときに初めて効きます。ルートは Request.Pattern(Go 1.23 で追加。$GOROOT/api/go1.23.txt 74 行目に Pattern string #66405)に入りますが、これを入れるのは ServeMux 自身で、入れる瞬間は ServeMux.ServeHTTP の中です(Go 1.27.1 の net/http/server.go 2899 行目: h, r.Pattern, r.pat, r.matches = mux.findHandler(r))。つまり:

  • 外側のミドルウェアは、next.ServeHTTP を呼ぶ前には r.Pattern が空。呼んだ後に読めば入っている(r は同じポインタで、ServeMux がその場で書き換えるため)
  • ただし途中でリクエストを複製するミドルウェア(r.WithContext(...)。Day 058 の RequestID など)が間に挟まると、ServeMux が書くのは複製の方。外側が持っている元の r は空のまま
  • 404・405・末尾スラッシュのリダイレクトは、ルートに一致しなかった(または別のパターンに回された)結果なので、内側に置いたミドルウェアには見えない

今日の設計判断はこれです。「リクエストの数・ステータスを、ルート単位で数えるミドルウェアを、どこに置いてどう書くか」。

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

案採らなかった理由
ルートごとに内側で数える(Day 057 の宣言表に列を足す)404・405・リダイレクトが数えられない(R8 で実測)。攻撃や typo は「一致しない」リクエストに集中するので、一番知りたい所が欠ける
ラベルに生のパス(r.URL.Path)を使う/users/1〜/users/1000 で系列が 1000 個になる(R2 で実測)。{id} を含むパターンなら 1 個
Observe の中でリクエストを複製して Pattern を取り出す取り出せない。複製側に書かれるので(R7)。置く順序で解くのが唯一の手段
前提が変わったら自前の mux を使わず別ルータ(chi 等)を使うなら、Pattern ではなくルータ固有の API(chi は RouteContext)を読む。今日の結論は net/http の ServeMux 専用(chi は手元で動かしていない)

参照日 2026-10-03。一次資料は手元の Go 1.27.1 のソースと go doc(go doc net/http.Request.Pattern: "Pattern is the ServeMux pattern that matched the request. It is empty if the request was not matched against a pattern.")。

世界の実物との位置づけ

  • Prometheus の Go クライアント(promhttp.InstrumentHandler*)や OpenTelemetry の otelhttp も「ハンドラを包む」形で、ルート名の取り方は各自。ここの記述は手元知識で、今回ソースは読んでいない(実装前に各リポジトリで確認すること)
  • 今日の自作が扱う中核概念は 6 つ(外/内の置き場所・Pattern を入れる瞬間・複製の罠・404/405/307 の通り方・系列数・ResponseWriter の包み方)。扱わないものは末尾の「扱わなかった範囲と理由」に書いた

📝 問題

先に予想を書く(自己評価欄へ。実行前に)

mux に GET /users/{id}(内側にだけ CountInner を付ける)、GET /tree/、POST /orders を登録し、外側に Observe を付けたアプリがあります。次をコードを動かす前に答えてください。

  • 問A: Chain(h, A, B, C) で、入るとき・出るときの順序は?
  • 問B: 外側のミドルウェアは、GET /nope(404)と DELETE /users/7(405)で走るか? 内側の CountInner("users") は?
  • 問C: 外側で next.ServeHTTP を呼ぶ前と後で、r.Pattern と r.PathValue("id") は何になる?
  • 問D: 外側と ServeMux の間に r.WithContext(...) で複製するミドルウェアがあると、外側が呼んだ後に読む r.Pattern は?
  • 問E: GET /tree(末尾スラッシュなし。/tree/ だけ登録済み)の応答は? そのとき外側が読む Pattern は?
  • 問F: ハンドラが WriteHeader(201) の後に WriteHeader(418) を呼ぶと、ステータスは何として数えるべきか?

課題

下の 3 ファイルのうち、observe.go を実装して、go test を全部通してください。main.go・main_test.go は変えません。

  1. statusWriter: 最初に確定したステータスを覚える。何も書かずに終わったハンドラは 200(net/http の挙動と同じ)。Unwrap を持ち、http.NewResponseController(w).Flush() が下まで届くようにする
  2. (*Stats).Observe: ServeMux の外側に置いて、(ServeMux が選んだパターン, 実際のステータス) を数える。一致しなければルートは "unmatched"
  3. go test -race -count=1 ./... が通る

準備

mkdir d63 && cd d63                       # 作業ディレクトリを作って入る
printf 'module d63\n\ngo 1.23\n' > go.mod   # モジュールを作る(Request.Pattern は Go 1.23 以上が必要)
# 下の main.go・observe.go(スタブ)・main_test.go を保存する
go test -count=1 ./... 2>&1 | grep -c '^--- FAIL'   # スタブの状態で失敗するテスト数を数える(7 のはず)

ファイル 1: main.go(変えない)

package main

import (
	"context"
	"net/http"
	"sync"
)

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
}

// Key は集計の単位。Route は ServeMux が選んだパターン(なければ "unmatched")。
type Key struct {
	Route  string
	Status int
}

type Stats struct {
	mu sync.Mutex
	m  map[Key]int
}

func NewStats() *Stats { return &Stats{m: map[Key]int{}} }

func (s *Stats) add(k Key) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.m[k]++
}

func (s *Stats) Snapshot() map[Key]int {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := make(map[Key]int, len(s.m))
	for k, v := range s.m {
		out[k] = v
	}
	return out
}

// CountInner は「ルートごとに包む」側の計数。登録した名前で数える。
func (s *Stats) CountInner(name string) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			sw := &statusWriter{ResponseWriter: w}
			next.ServeHTTP(sw, r)
			s.add(Key{name, sw.statusOr200()})
		})
	}
}

type ctxKey struct{}

// RequestID はリクエストの複製を作って次へ渡す(Day 058 と同じ型のミドルウェア)。
func RequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := context.WithValue(r.Context(), ctxKey{}, "req-1")
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// NewApp は ServeMux を組み、outer で外側のミドルウェアを巻く。inner は /users/{id} だけに付く計数。
func NewApp(inner *Stats, outer func(mux http.Handler) http.Handler) http.Handler {
	mux := http.NewServeMux()
	mux.Handle("GET /users/{id}", Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("user " + r.PathValue("id")))
	}), inner.CountInner("users")))
	mux.HandleFunc("GET /tree/", func(w http.ResponseWriter, r *http.Request) {})
	mux.HandleFunc("POST /orders", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusCreated)
		w.WriteHeader(http.StatusTeapot) // 2回目は無視される
	})
	return outer(mux)
}

func main() {}

ファイル 2: observe.go(ここを実装する。まずスタブ)

package main

import "net/http"

// statusWriter: 最初に確定したステータスを覚える ResponseWriter にする(TODO)
type statusWriter struct {
	http.ResponseWriter
	status int
}

// TODO: WriteHeader / Write / Unwrap を足す

func (w *statusWriter) statusOr200() int { return 0 } // TODO

// TODO: ServeMux の外側で (r.Pattern, ステータス) を数える
func (s *Stats) Observe(next http.Handler) http.Handler { return next }

ファイル 3: main_test.go(変えない)

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"testing"
)

func do(h http.Handler, method, path string) *httptest.ResponseRecorder {
	w := httptest.NewRecorder()
	h.ServeHTTP(w, httptest.NewRequest(method, path, nil))
	return w
}

// 正しい並び: Observe を ServeMux の直前に置く(RequestID はその外)。
func goodApp(s *Stats) http.Handler {
	h, _ := goodAppInner(s)
	return h
}

func goodAppInner(s *Stats) (http.Handler, *Stats) {
	in := NewStats()
	return NewApp(in, func(mux http.Handler) http.Handler {
		return Chain(mux, RequestID, s.Observe)
	}), in
}

// 誤った並び: 複製する RequestID が Observe の内側にある。
func badApp(s *Stats) http.Handler {
	return NewApp(NewStats(), func(mux http.Handler) http.Handler {
		return Chain(mux, s.Observe, RequestID)
	})
}

func want(t *testing.T, got map[Key]int, w map[Key]int) {
	t.Helper()
	if fmt.Sprint(got) != fmt.Sprint(w) {
		t.Fatalf("got %v\nwant %v", got, w)
	}
}

func TestR1_PatternNotPath(t *testing.T) {
	s := NewStats()
	do(goodApp(s), "GET", "/users/7")
	want(t, s.Snapshot(), map[Key]int{{"GET /users/{id}", 200}: 1})
}

func TestR2_Cardinality(t *testing.T) {
	s, naive := NewStats(), map[string]bool{}
	app := goodApp(s)
	for i := 0; i < 1000; i++ {
		p := fmt.Sprintf("/users/%d", i)
		do(app, "GET", p)
		naive[p] = true // 生のパスをラベルにした場合の系列数
	}
	if n := len(s.Snapshot()); n != 1 {
		t.Fatalf("series = %d, want 1 (naive would be %d)", n, len(naive))
	}
}

func TestR3_UnmatchedAnd405(t *testing.T) {
	s := NewStats()
	app := goodApp(s)
	do(app, "GET", "/nope")
	do(app, "DELETE", "/users/7")
	want(t, s.Snapshot(), map[Key]int{{"unmatched", 404}: 1, {"unmatched", 405}: 1})
}

func TestR4_RedirectCountedOnTargetPattern(t *testing.T) {
	s := NewStats()
	w := do(goodApp(s), "GET", "/tree")
	if w.Code != 307 || w.Header().Get("Location") != "/tree/" {
		t.Fatalf("code=%d loc=%q", w.Code, w.Header().Get("Location"))
	}
	want(t, s.Snapshot(), map[Key]int{{"GET /tree/", 307}: 1})
}

func TestR5_StatusDefaultsAndFirstWins(t *testing.T) {
	s := NewStats()
	app := goodApp(s)
	do(app, "GET", "/tree/x")  // 本文もヘッダも書かない → 200
	do(app, "POST", "/orders") // 201 のあと 418 を書く → 201
	want(t, s.Snapshot(), map[Key]int{{"GET /tree/", 200}: 1, {"POST /orders", 201}: 1})
}

func TestR6_FlushReachesRecorder(t *testing.T) {
	s := NewStats()
	var err error
	h := s.Observe(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		err = http.NewResponseController(w).Flush()
	}))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
	if err != nil || !rec.Flushed {
		t.Fatalf("err=%v flushed=%v", err, rec.Flushed)
	}
}

func TestR7_OrderTrap(t *testing.T) {
	bad, good := NewStats(), NewStats()
	do(badApp(bad), "GET", "/users/7")
	do(goodApp(good), "GET", "/users/7")
	want(t, bad.Snapshot(), map[Key]int{{"unmatched", 200}: 1}) // 200 なのに unmatched。警告なしで誤る
	want(t, good.Snapshot(), map[Key]int{{"GET /users/{id}", 200}: 1})
}

func TestR8_InnerCannotSee404_405_307(t *testing.T) {
	s := NewStats()
	app, in := goodAppInner(s)
	do(app, "GET", "/nope")
	do(app, "DELETE", "/users/7")
	do(app, "GET", "/tree")
	do(app, "GET", "/users/1")
	want(t, in.Snapshot(), map[Key]int{{"users", 200}: 1}) // 内側が見たのは 4 件中 1 件
	n := 0
	for _, c := range s.Snapshot() {
		n += c
	}
	if n != 4 { // 外側は 4 件すべて
		t.Fatalf("outer total = %d, want 4", n)
	}
}

func TestR9_OrderOfChain(t *testing.T) {
	var log []string
	tag := func(n string) Middleware {
		return func(next http.Handler) http.Handler {
			return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				log = append(log, n+">")
				next.ServeHTTP(w, r)
				log = append(log, "<"+n)
			})
		}
	}
	do(Chain(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), tag("A"), tag("B"), tag("C")), "GET", "/")
	if fmt.Sprint(log) != "[A> B> C> <C <B <A]" {
		t.Fatal(log)
	}
}

受け入れ条件

  • スタブで 9 本中 7 本が落ちる(R6 と R9 はスタブでも通る。R6 は Observe が何も包まないので Flush が届いてしまうため)
  • 実装後 go test -race -count=1 ./... が全部通る
  • 変異(わざと壊した実装)を 8 通り入れて、すべて落ちること(下の「変異テスト」)

💡 ヒント

ヒント1(方向性)

r.Pattern を読むのは next.ServeHTTP(w, r) を呼んだ後です。呼ぶ前は空です。どちらを呼んでも r は同じポインタなので、後で読めば ServeMux が書いた値が入っています。

ヒント2(アプローチ)
  • ステータスは WriteHeader と Write の両方で確定する。最初の1回だけ覚える(2回目の WriteHeader は net/http も無視する)
  • Unwrap() http.ResponseWriter を持たせると ResponseController が下の Flusher を探せる
  • 外側でパターンが空のとき、ラベルを "" のままにしない(404 と 405 と「ミドルウェアの順序ミス」が区別できなくなる)
ヒント3(コード骨格)
next.ServeHTTP(sw, r)        // sw は statusWriter
route := r.Pattern           // 呼んだ後に読む
if route == "" { route = "unmatched" }
s.add(Key{route, sw.statusOr200()})

✅ 模範解答

observe.go(まず問A〜Fの予想を書いてから開く)
package main

import "net/http"

// statusWriter は「最初に確定したステータス」を覚える ResponseWriter。
type statusWriter struct {
	http.ResponseWriter
	status int
}

func (w *statusWriter) WriteHeader(code int) {
	if w.status == 0 {
		w.status = code
	}
	w.ResponseWriter.WriteHeader(code)
}

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

// Unwrap があるので http.NewResponseController(w).Flush() が下まで届く。
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }

func (w *statusWriter) statusOr200() int {
	if w.status == 0 {
		return http.StatusOK
	}
	return w.status
}

// Observe は ServeMux の外側に置き、(ServeMux が選んだパターン, 実際のステータス) を数える。
// パターンは next が返った「あと」に読む(呼ぶ前は空)。
func (s *Stats) Observe(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		sw := &statusWriter{ResponseWriter: w}
		next.ServeHTTP(sw, r)
		route := r.Pattern
		if route == "" {
			route = "unmatched"
		}
		s.add(Key{route, sw.statusOr200()})
	})
}

問A〜F の答え(Go 1.27.1 での実測。go run と上のテストで確認)

問答え出所
A[A> B> C> <C <B <A]。Chain の最初が最も外側(玉ねぎ)実測(R9)
B外側は走る(404 も 405 も通る)。内側の CountInner は走らない(404・405 は一致なし)実測(R3・R8)
C呼ぶ前: Pattern=""、PathValue("id")=""。呼んだ後: Pattern="GET /users/{id}"実測(スクラッチ実験)
D空のまま。ServeMux が書くのは複製の方で、外側が持つ元の r には書かれない実測(R7。unmatched なのに 200)
E307、Location: /tree/。外側が読む Pattern は GET /tree/(リダイレクト先のパターン)。/tree 側のルートは数えない実測(R4)。「Pattern が付く」は想定外だった人が多いはず
F201。2回目の WriteHeader は無視される実測(R5)。net/http は "superfluous" としてログを出す(今回ログ内容は未確認)

実測の数字

項目値出所
1000 個の異なる /users/{id} を数えたときの系列数パターンラベル 1 / 生パスラベル 1000実測(R2)
Observe 1 回あたりの追加コスト約 20〜100 ns、alloc が 3→4 回(48→72 B)(素の ServeMux 経由 274〜352 ns → 355〜371 ns。3 回の幅が重なるので差は粗い)実測(go test -bench、Apple M 系 8 コア、3 回の幅。ResponseWriter はヌル実装なので実サーバーの値ではない)
テスト9 本、-race で通る実測

🪜 Step-by-Step 解説

Step 1: 置き場所は 2 つ。外側は「ルーティングの前」に走る

Chain(mux, A) の A は、パスが何であっても走ります。mux.Handle(p, Chain(h, A)) の A は、p に一致した後でしか走りません。404・405・リダイレクトは一致しない/別に回された結果なので、内側には届きません(R8: 4 件のうち内側が見たのは 1 件、外側は 4 件)。

Step 2: Pattern は ServeMux が入れる。読むのは呼んだ「後」(R1)

ServeMux.ServeHTTP が r.Pattern を書き換え、それからハンドラを呼びます。外側の中間件は呼ぶ前には何も知りません(PathValue も同じ)。後で読むと入っています。

Step 3: 複製は静かに壊す(R7)

RequestID のような中間件が r.WithContext(ctx) で複製して次へ渡すと、ServeMux が書くのは複製です。外側の Observe が元の r を読むと空になり、ラベルは unmatched。200 を返しているのに unmatched になるので、エラーもパニックも出ません。ダッシュボードで「全部 unmatched」になって初めて気づきます。解は順序: 複製する中間件を外側に、Observe を ServeMux の直前に置く(Chain(mux, RequestID, s.Observe))。

Step 4: ステータスは包んで取る(R5・R6)

WriteHeader しかなければ 200 ではなく、書かないまま終わるハンドラも 200 です。最初の WriteHeader を覚え、2 回目は無視します(net/http と同じ)。Unwrap を持たせないと NewResponseController(w).Flush() が ErrNotSupported になります(Day 060 の再発防止)。

Step 5: 系列数を抑える(R2)

ラベルは r.Pattern(パターン)にする。r.URL.Path だとユーザー数だけ系列が増えます。404 の生パスをラベルにするのも同じ理由で避け、unmatched に畳みます(R3)。

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

  • Go は「ミドルウェア」という概念を言語にも標準ライブラリにも持たず、http.Handler の合成だけで済ませます。だから置き場所の責任は書く人にある。フレームワークは「ここに置け」と教えてくれない
  • Request.Pattern が書き込み可能なフィールドなのは(以下は設計意図の推測で、提案文書は読んでいない)、ハンドラの合成(Handler は *Request しか受け取らない)の中で「どのルートに当たったか」を伝える手段が、リクエストに載せる以外に無いからです。代わりに「複製すると届かない」という罠を作った
  • 採らなかった設計: 外側でパターンを自前で解決する(mux.Handler(r) を呼んで返り値を使う)。使える(ServeMux.Handler が (Handler, pattern) を返す)が、ルーティングが二重になり、PathValue 等の状態が重複する。未計測

🌐 他言語との比較

言語・FWルート名の取り方今日との違い
Go net/http(1.23+)r.Pattern(呼んだ後に読む)リクエストの複製で消える
Express(Node)req.route.path(ルートハンドラ内)/req.baseUrlミドルウェアは登録順=実行順。ルート確定前は req.route が無い、という点は似る(手元知識・未確認)
Spring MVCHandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE を request 属性から読む属性は複製されない(参照が同じ)ので、今日の罠は起きにくい(手元知識・未確認)

🛠️ 実務での使いどころ

  • RED メトリクス(Rate・Errors・Duration)のルート別集計。系列数の爆発(高カーディナリティ)を避ける
  • 認可・レート制限など「一致しないリクエストにも掛けたい守り」は外側。Day 057 の宣言表(ルートごとの守り)は内側で、404 には掛からないと理解した上で使い分ける
  • アクセスログに Pattern を載せると、パスの個別値を含まずに集計できる(個別値は別フィールド)

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
外側の Observe が next を呼ぶ前に r.Pattern を読む「リクエストが来た時点で分かっているはず」と思うルーティングは ServeMux.ServeHTTP の中。呼んだ後に読む(M1 で落ちる)
Observe の中で r.WithContext する他の中間件の書き方を真似る複製すると元の r に Pattern が書かれない(M7)
内側(ルートごと)で 404 率を出そうとする宣言表に列を足せば何でも出せると考える一致しないものは内側に届かない(R8)
ラベルにパスを使う一番手軽系列が個別値の数だけ増える(R2: 1000 個)
ResponseWriter を包んで Unwrap を忘れるFlush が型アサーションで動いていた頃の癖ResponseController は Unwrap を辿る(M5)

🧬 変異テスト

observe.go の正解実装を 1 か所ずつ壊し、どのテストが落ちるかを数えた(go test -count=1)。8 通りすべて検出。

壊し方落ちたテスト
M1 Pattern を next の前に読むR1・R4・R5・R7
M2 ラベルを r.URL.Path にするR1〜R5・R7
M3 空ラベルのまま(unmatched にしない)R3・R7
M4 何も書かないハンドラのステータスを 0 のままR5
M5 Unwrap を消すR6
M6 WriteHeader を後勝ちにするR5
M7 next へ複製した r を渡すR1・R4・R5・R7
M8 statusWriter を渡さず元の w を渡すR3・R4・R5

スタブ(何も包まない Observe)では 9 本中 7 本が落ちる(R6・R9 は通る)。R9(Chain の順序)は observe.go を触っても変わらないので、変異検出の対象外(この表で唯一、実装を縛らないテスト)。

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

  • panic: Observe は next が panic すると集計しない(defer していない)。net/http は panic を回収して接続を閉じるので、500 として数えるかどうかは別の設計判断。今日の範囲外
  • 実サーバー: httptest.NewRecorder と ServeHTTP 直接呼びで確かめた。http.ListenAndServe 上で curl は今回やっていない(Pattern の入れ方は同じコードだが、HTTP/2・Expect: 100-continue 等の経路は未確認)
  • サブ mux と http.StripPrefix: mux を入れ子にしたときの Pattern(外側の値になるか内側か)は測っていない
  • Prometheus・OpenTelemetry のソース: 手元知識のみ。上の表の「未確認」は実物で確かめていない
  • Observe のコスト: ヌル ResponseWriter での計測。実サーバーのスループットへの影響は未計測

🚀 次のステップ

  • 発展: サブ mux(mux.Handle("/api/", http.StripPrefix("/api", sub)))で Pattern が何になるかを測り、外側の Observe のラベルを直す
  • 発展: 複製しても届くよう、Observe が context に書き込み先(*string)を置き、内側の薄い中間件がそこに r.Pattern を書く設計を作り、本日の順序ルールと比べる
  • 次回予告: 同テーマ。state の予告はデバッグ(Day 060 で実施済み)。次は残る軸として「中間件の panic 回収と、集計への反映」

🎯 自己評価

問A〜F の予想と結果

自分の回答

気づき・メモ