フェーズ: 上級者 (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 は変えません。
statusWriter: 最初に確定したステータスを覚える。何も書かずに終わったハンドラは 200(net/http の挙動と同じ)。Unwrapを持ち、http.NewResponseController(w).Flush()が下まで届くようにする(*Stats).Observe: ServeMux の外側に置いて、(ServeMux が選んだパターン, 実際のステータス)を数える。一致しなければルートは"unmatched"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) |
| E | 307、Location: /tree/。外側が読む Pattern は GET /tree/(リダイレクト先のパターン)。/tree 側のルートは数えない | 実測(R4)。「Pattern が付く」は想定外だった人が多いはず |
| F | 201。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 MVC | HandlerMapping.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 回収と、集計への反映」