フェーズ: 上級者 (Phase 3) | 問題タイプ: デバッグ・コードレビュー | テーマ: ミドルウェア設計パターン > 9/29 のバックフィル分(実際の作成日は 2026-10-02。番号は state の「次の番号」を使った)。Day 056 の「5 つのバグ」とは別の題材です(Day 056 は順序・return 抜け・本文の消費。今日は ResponseWriter を包む側の約束事 と gzip という「書き換える」ミドルウェア が中心)。
📚 背景知識
アクセスログ・panic 回復・gzip。どれも 20 行で書けて、単体で試すと動きます。壊れるのは「積んだとき」と「実サーバーに載せたとき」です。今日は次の 4 つの約束事を、バグ付きのコードに出会って覚えます。
defer f(x)のxは defer を書いた時点で評価される。ミドルウェアでは「終わった後の状態」を見たいので、ほぼ必ずdefer func() { ... }()にする。WriteHeaderは最初の 1 回だけが効く(net/http は 2 回目をsuperfluous response.WriteHeader callとログに出して捨てる。go1.27.1 net/http/server.go:1210)。包む側が「最後の値」を覚えると、クライアントが見たものとログがずれる。panic(http.ErrAbortHandler)は「接続を切れ」という合図。go doc net/http.ErrAbortHandler(Go 1.27.1): 「any panic from ServeHTTP aborts the response to the client, panicking with ErrAbortHandler also suppresses logging of a stack trace」。サーバー側はserver.go:1936でerr != ErrAbortHandlerのときだけスタックを出す。回復ミドルウェアがこれを握りつぶすと、切るべき接続が 500 つきで正常終了する。- 書き換えるミドルウェア(gzip)は、下流が付けるヘッダと、
Flush/Unwrapという「能力」に責任を持つ。http.ResponseController.FlushはFlushError() error→http.Flusher→Unwrap()の順に探す(responsecontroller.go:47-62)。包んだ側がどれも持たなければErrNotSupported。
世界の実物との位置づけ(参照日 2026-10-02。star 数は gh api repos/<repo> の実測)
| 実物 | star | 今日との関係 |
|---|---|---|
| felixge/httpsnoop | 1,174 | ResponseWriter を「壊さずに」包む専用ライブラリ。Flusher/Hijacker/ReaderFrom などの組み合わせを壊さず通すのが売り(組み合わせの網羅方法は手元知識。README は未確認) |
klauspost/compress(gzhttp) | 5,654 | 本番向け gzip ミドルウェア。最小サイズ・Content-Type 判定・Content-Length の扱い・プールを持つ |
| go-chi/chi | 22,914 | middleware.Recoverer は rvr == http.ErrAbortHandler なら回復しない(recoverer.go:26)、middleware.Compress は Del("Content-Length")(compress.go:334)。どちらも今日の B3・B5 と同じ処理(2026-10-02 に GitHub の main を grep して確認) |
今日の自作は、この 3 つの「中核概念」のうち、約束事 4 つ(状態の記録・Abort の通過・Content-Length・Flush)だけを扱う。圧縮レベル・最小サイズ・Content-Type による除外・sync.Pool・Hijacker・ReaderFrom は扱わない(「扱わなかった範囲」参照)。
📝 問題
課題
下の 4 ファイルを置くと、go test が 6 本落ちます(実測。バグ付きの mw.go で 7 本中 6 本が FAIL、1 本は守る側のテストで PASS)。mw.go だけを直して、7 本すべて・-race でも通してください。main.go(積む順 AccessLog → Recover → Gzip)と main_test.go は変えません。
| # | 症状(実サーバーで観測) | 落ちるテスト |
|---|---|---|
| B1 | ログの status がいつも 0 | TestAccessLogRecordsStatus |
| B2 | 本文を書いた後に http.Error を呼ぶ道で、ログは 500、クライアントは 200 | TestFirstWriteHeaderWins |
| B3 | panic(http.ErrAbortHandler) が 200 の空応答になる(切れない) | TestAbortHandlerIsNotRecovered |
| B4 | 普通の panic("boom") が 500 でなく 200・本文なし になる | TestPanicStillBecomes500 |
| B5 | Content-Length を自分で付けるハンドラの応答が unexpected EOF | TestGzipWithContentLength |
| B6 | SSE の 1 件目が届かない(ヘッダも来ない) | TestGzipFlushReachesClient |
(TestPlainClientKeepsContentLength は「gzip を要求しないクライアントには従来どおり」を守るテスト。直しすぎの検出用で、最初から PASS)
先に予想を書く(自己評価欄へ)
- 問A: B3 と B4 は、直す場所が同じか。別か。
- 問B: B1 を直すと、
TestFirstWriteHeaderWinsはそのまま通るか。 - 問C:
go vetは B1〜B6 のうちいくつ見つけるか。
準備
mkdir day060 && cd day060
# go.mod・main.go・mw.go・main_test.go を置く
go test -count=1 ./... # 6 本落ちる
go test -race -count=1 ./... # 直した後は -race でも通ること
go.mod:
module day060
go 1.24
package main
import (
"fmt"
"log/slog"
"net/http"
"os"
"strings"
)
// newHandler は 4 本の「本体」と、守りの積み方を決める。
// 積む順(外→内)は AccessLog → Recover → Gzip。mw.go の中身を直しても、この行は変えない。
// release は /events が「次のイベントを送る」のを待つための栓(テストが閉じる)。
func newHandler(l *slog.Logger, release <-chan struct{}) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /created", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, "made")
})
// 先に 200 で本文を書いたあと、失敗に気づいて http.Error を呼ぶ(実務でよくある順序)。
mux.HandleFunc("GET /late", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
http.Error(w, "late failure", http.StatusInternalServerError)
})
// Content-Length を自分で付ける(静的ファイルや事前計算した JSON と同じ形)。
mux.HandleFunc("GET /big", func(w http.ResponseWriter, r *http.Request) {
body := strings.Repeat("a", 2000)
w.Header().Set("Content-Length", fmt.Sprint(len(body)))
fmt.Fprint(w, body)
})
// SSE。1 件送って Flush し、release が閉じるまで待ってから 2 件目を送る。
mux.HandleFunc("GET /events", func(w http.ResponseWriter, r *http.Request) {
rc := http.NewResponseController(w)
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "data: 1\n\n")
rc.Flush()
select {
case <-release:
case <-r.Context().Done():
return
}
fmt.Fprint(w, "data: 2\n\n")
})
mux.HandleFunc("GET /boom", func(w http.ResponseWriter, r *http.Request) { panic("boom") })
mux.HandleFunc("GET /abort", func(w http.ResponseWriter, r *http.Request) { panic(http.ErrAbortHandler) })
return Chain(mux, AccessLog(l), Recover(l), Gzip)
}
func main() {
l := slog.New(slog.NewJSONHandler(os.Stdout, nil))
l.Info("listening", "addr", ":8080")
if err := http.ListenAndServe(":8080", newHandler(l, make(chan struct{}))); err != nil {
l.Error("serve", "err", err)
os.Exit(1)
}
}package main
import (
"compress/gzip"
"log/slog"
"net/http"
"strings"
"time"
)
type Middleware func(http.Handler) http.Handler
// Chain は先に書いたものが外側になるように積む。
func Chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
// ---- 状態コードを覚える ResponseWriter ----
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
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 }
// ---- アクセスログ ----
func AccessLog(l *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w}
start := time.Now()
defer l.Info("request", "method", r.Method, "path", r.URL.Path,
"status", rec.status, "dur_ms", time.Since(start).Milliseconds())
next.ServeHTTP(rec, r)
})
}
}
// ---- panic を 500 にする ----
func Recover(l *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 {
l.Error("panic", "value", v, "path", r.URL.Path)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
// ---- gzip ----
type gzipWriter struct {
http.ResponseWriter
gz *gzip.Writer
}
func (g *gzipWriter) Write(b []byte) (int, error) { return g.gz.Write(b) }
func Gzip(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "Accept-Encoding")
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(w)
defer gz.Close()
next.ServeHTTP(&gzipWriter{ResponseWriter: w, gz: gz}, r)
})
}package main
import (
"bufio"
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
type syncBuf struct {
mu sync.Mutex
b bytes.Buffer
}
func (s *syncBuf) Write(p []byte) (int, error) { s.mu.Lock(); defer s.mu.Unlock(); return s.b.Write(p) }
func (s *syncBuf) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() }
func setup(t *testing.T) (*httptest.Server, *syncBuf, chan struct{}) {
t.Helper()
buf := &syncBuf{}
release := make(chan struct{})
srv := httptest.NewServer(newHandler(slog.New(slog.NewJSONHandler(buf, nil)), release))
t.Cleanup(srv.Close)
return srv, buf, release
}
// logStatus は msg=="request" の行のうち path が一致するものの status を返す。
func logStatus(t *testing.T, buf *syncBuf, path string) int {
t.Helper()
deadline := time.Now().Add(2 * time.Second) // ログはレスポンス送出後に書かれるので少し待つ
for time.Now().Before(deadline) {
for _, line := range strings.Split(buf.String(), "\n") {
var m map[string]any
if json.Unmarshal([]byte(line), &m) == nil && m["msg"] == "request" && m["path"] == path {
return int(m["status"].(float64))
}
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("path=%s のアクセスログが出ていない:\n%s", path, buf.String())
return 0
}
// B1: defer の引数は defer を書いた時点で評価される
func TestAccessLogRecordsStatus(t *testing.T) {
srv, buf, _ := setup(t)
resp, err := http.Get(srv.URL + "/created")
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if got := logStatus(t, buf, "/created"); got != 201 {
t.Fatalf("ログの status = %d, want 201", got)
}
}
// B2: 本文を書いた後の http.Error は無視される。ログも最初の状態コードのまま
func TestFirstWriteHeaderWins(t *testing.T) {
srv, buf, _ := setup(t)
resp, err := http.Get(srv.URL + "/late")
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("クライアントが見た status = %d, want 200", resp.StatusCode)
}
if got := logStatus(t, buf, "/late"); got != 200 {
t.Fatalf("ログの status = %d, want 200(クライアントが見た値と一致させる)", got)
}
}
// B3: ErrAbortHandler は「接続を切れ」という合図。500 に変えてはいけない
func TestAbortHandlerIsNotRecovered(t *testing.T) {
srv, _, _ := setup(t)
resp, err := http.Get(srv.URL + "/abort")
if err == nil {
resp.Body.Close()
t.Fatalf("接続が切れるはずが status=%d で応答した", resp.StatusCode)
}
}
// 守る側: 普通の panic は今までどおり 500
func TestPanicStillBecomes500(t *testing.T) {
srv, _, _ := setup(t)
resp, err := http.Get(srv.URL + "/boom")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 500 {
t.Fatalf("status = %d, want 500", resp.StatusCode)
}
}
// B4: 自分で付けた Content-Length は gzip 後の長さと合わない
func TestGzipWithContentLength(t *testing.T) {
srv, _, _ := setup(t)
resp, err := http.Get(srv.URL + "/big") // Go の Transport は Accept-Encoding: gzip を自動で付けて自動で伸長する
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("本文の読み取りに失敗: %v", err)
}
if len(body) != 2000 {
t.Fatalf("len(body) = %d, want 2000", len(body))
}
}
// 守る側: gzip を受け付けないクライアントには、そのまま・Content-Length 付きで返す
func TestPlainClientKeepsContentLength(t *testing.T) {
srv, _, _ := setup(t)
c := &http.Client{Transport: &http.Transport{DisableCompression: true}}
resp, err := c.Get(srv.URL + "/big")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if len(body) != 2000 || resp.Header.Get("Content-Encoding") != "" || resp.ContentLength != 2000 {
t.Fatalf("len=%d enc=%q CL=%d", len(body), resp.Header.Get("Content-Encoding"), resp.ContentLength)
}
if !strings.Contains(resp.Header.Get("Vary"), "Accept-Encoding") {
t.Fatalf("Vary に Accept-Encoding が無い: %q", resp.Header.Get("Vary"))
}
}
// B5: gzip の内側の Flush は、gzip のバッファも吐き出して初めてクライアントに届く
func TestGzipFlushReachesClient(t *testing.T) {
srv, _, release := setup(t)
defer close(release) // 失敗しても /events のハンドラを解放して srv.Close が詰まらないようにする
type result struct {
line string
err error
}
got := make(chan result, 1)
go func() {
resp, err := http.Get(srv.URL + "/events") // ヘッダ自体が Flush されないとここで止まる
if err != nil {
got <- result{err: err}
return
}
defer resp.Body.Close()
line, err := bufio.NewReader(resp.Body).ReadString('\n')
got <- result{line, err}
}()
select {
case r := <-got:
if r.err != nil || r.line != "data: 1\n" {
t.Fatalf("1 件目 = %q, err = %v", r.line, r.err)
}
case <-time.After(2 * time.Second):
t.Fatal("Flush したのに 2 秒たっても 1 件目が届かない")
}
}💡 ヒント
ヒント1(方向性)
6 つの症状は 6 つの原因ではありません。B3 と B4 は同じ場所(gzip の末尾の書き方)が一部の原因で、B1 は B2 の原因を隠しています。1 つ直したら必ず全テストを回し直して、症状が「入れ替わる」のを見てください。
ヒント2(アプローチ)
- B1:
deferの後ろを関数リテラルに包む - B2: 状態コードは「まだ 0 のときだけ」覚える。1xx は何度でも呼べるので
code >= 200を条件に - B3:
recover()の値がhttp.ErrAbortHandlerならpanic(v)で返す - B4:
defer gz.Close()は panic のときも走る。gzip の末尾が書かれた瞬間に 200 が確定する。Close はnext.ServeHTTPが正常に戻った後に置く - B5:
Content-Lengthを消す場所は、Gzipの入口ではない(ハンドラが後から付ける)。最初のWriteHeader/Writeの瞬間に消す - B6:
Flushの持ち主はgzipWriter。FlushError() errorを実装し、gz.Flush()→ 下流の Flush の順
ヒント3(コード骨格)
type gzipWriter struct {
http.ResponseWriter
gz *gzip.Writer
started bool
}
func (g *gzipWriter) start() { /* 1 回だけ: Content-Length を消し Content-Encoding を付ける */ }
func (g *gzipWriter) WriteHeader(code int) { g.start(); g.ResponseWriter.WriteHeader(code) }
func (g *gzipWriter) Write(b []byte) (int, error) { g.start(); return g.gz.Write(b) }
func (g *gzipWriter) FlushError() error { /* start → gz.Flush → NewResponseController(下流).Flush */ }
func (g *gzipWriter) Unwrap() http.ResponseWriter { return g.ResponseWriter }✅ 模範解答
模範解答を表示する(まず問A〜Cの予想を書いてから)
mw.go の差分は 4 か所です(AccessLog の defer・statusRecorder.WriteHeader・Recover の再 panic・gzipWriter 一式)。完全版:
package main
import (
"compress/gzip"
"log/slog"
"net/http"
"strings"
"time"
)
type Middleware func(http.Handler) http.Handler
// Chain は先に書いたものが外側になるように積む。
func Chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
// ---- 状態コードを覚える ResponseWriter ----
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
if s.status == 0 && code >= 200 { // 最初の最終ステータスだけ覚える(1xx は何度でも呼べる)
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 }
// ---- アクセスログ ----
func AccessLog(l *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w}
start := time.Now()
defer func() { // 引数は「いま」ではなく、閉じるときに評価したい
l.Info("request", "method", r.Method, "path", r.URL.Path,
"status", rec.status, "dur_ms", time.Since(start).Milliseconds())
}()
next.ServeHTTP(rec, r)
})
}
}
// ---- panic を 500 にする ----
func Recover(l *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) // 「接続を切れ」の合図は net/http に返す
}
l.Error("panic", "value", v, "path", r.URL.Path)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
// ---- gzip ----
type gzipWriter struct {
http.ResponseWriter
gz *gzip.Writer
started bool
}
// start は「最初に WriteHeader か Write が来た時」に1回だけ、gzip にするとヘッダに書く。
// ハンドラが付けた Content-Length は圧縮後の長さと合わないので、このとき消す。
func (g *gzipWriter) start() {
if g.started {
return
}
g.started = true
h := g.Header()
h.Del("Content-Length")
h.Set("Content-Encoding", "gzip")
}
func (g *gzipWriter) WriteHeader(code int) { g.start(); g.ResponseWriter.WriteHeader(code) }
func (g *gzipWriter) Write(b []byte) (int, error) { g.start(); return g.gz.Write(b) }
// FlushError は ResponseController.Flush が最初に探すメソッド。gzip の溜めを吐いてから下流を Flush する。
func (g *gzipWriter) FlushError() error {
g.start()
if err := g.gz.Flush(); err != nil {
return err
}
return http.NewResponseController(g.ResponseWriter).Flush()
}
func (g *gzipWriter) Unwrap() http.ResponseWriter { return g.ResponseWriter }
func Gzip(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "Accept-Encoding")
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
gw := &gzipWriter{ResponseWriter: w, gz: gzip.NewWriter(w)}
next.ServeHTTP(gw, r)
// defer にしない: panic の最中に Close すると、gzip の末尾が 200 として確定してしまい、
// 外側の Recover が 500 を返せなくなる。何も書かなかった応答には空の gzip も付けない。
if gw.started {
gw.gz.Close()
}
})
}
実行結果(Go 1.27.1 darwin/arm64、go test -race -count=3 ./...): ok day060。
🪜 Step-by-Step 解説
Step 1: B1 — defer の引数は「今」評価される
defer l.Info(..., "status", rec.status, ...) は、この行を通った瞬間の rec.status(=0)をコピーして積みます。time.Since(start) も同じ理由で 0 ms になります。go vet が出すのは後者だけ(call to time.Since is not deferred。実測)で、rec.status の方は黙ります。ツールが見つけてくれるのは時間の計算だけで、「状態を後で読む」という意図は人が書く必要があります。
Step 2: B2 — 最後ではなく最初の WriteHeader
/late は Write("ok")(暗黙の 200)の後に http.Error(..., 500) を呼びます。net/http は 2 回目を捨てて superfluous をログに出すだけで、クライアントは 200 を見ます。包む側が s.status = code と上書きすると、ログだけ 500 になり監視のグラフとクライアントの体験が食い違います。B1 が残っている間は status が 0 のままなので、このバグは見えません(テスト結果の「0」の裏に隠れている)。
Step 3: B4 — gzip の defer gz.Close() が panic を 200 にする
最も気づきにくいバグです。panic("boom") が起きると、スタックを巻き戻す途中で Gzip の defer gz.Close() が走り、gzip のヘッダ 10 バイトと末尾 8 バイトを下流に書きます。これが暗黙の WriteHeader(200) になります。その後で外側の Recover が http.Error(500) を呼んでも superfluous。クライアントには 200 と壊れた gzip が届きます(実測: curl が http=200 size=0 exit=23)。
直し方は「panic のときは Close しない」。defer をやめて、next.ServeHTTP の後ろに置きます(正常に戻ったときだけ実行される)。何も書かなかった応答に空の gzip ストリームを付けないよう、started も見ます。
Step 4: B3 — ErrAbortHandler は握りつぶさない
Step 3 を直しても B3 はまだ落ちます。Recover が ErrAbortHandler を 500 に変えるためです。v == http.ErrAbortHandler なら panic(v) で net/http に返す。net/http はこれを受けて接続を切り、スタックのログも出しません(server.go:1936)。「回復」は panic の種類を選ぶ、というのが chi の Recoverer も同じ処理を持つ理由です。
Step 5: B5 — Content-Length は圧縮後の長さではない
ハンドラは 2000 バイトに Content-Length: 2000 を付けます。gzip 後は 36 バイト。ヘッダが 2000 のままだとクライアントは 36 バイトで EOF を受け取り unexpected EOF。直しは Del("Content-Length")。タイミングが肝で、入口で消してもハンドラが後から付け直すので、最初の WriteHeader/Write で(start())。同じ start() で Content-Encoding: gzip も付けます。入口で付けると、Step 3 の 500(平文)に Content-Encoding: gzip が付いて、クライアントが伸長に失敗します。
Step 6: B6 — Flush は 3 段ある
/events の rc.Flush() は gzipWriter に当たります。(1) FlushError/Flusher が無く Unwrap も無いので ErrNotSupported(ハンドラは戻り値を捨てている)→ ヘッダすら出ない。(2) Unwrap だけ足しても、データは gzip の内部バッファに残っているので届かない。gz.Flush() で圧縮ブロックを確定してから下流を Flush する、の 2 段が要ります(変異 M6・M7 で、どちらを欠いてもこのテストが落ちることを確認)。
🧭 設計思想・なぜこう書くのか
- 標準ライブラリは「約束」で動いている:
ResponseWriterはインターフェース 3 メソッドだけで、FlusherやHijackerは「実体が持っていれば使える」任意の能力。包むとその能力が消える、という設計上の代償を、Go 1.20 のResponseController(Unwrapを辿る)が埋めた。包む側はUnwrapを書く、が現在の作法。 - 小さなインターフェースの副作用:
http.HandlerがServeHTTP1 本なので、ミドルウェアは何でも包める。その自由さと引き換えに、包んだ側が何を落としたかを型は教えてくれない。だからテストが唯一の網。 - 明示的エラー処理の延長としての panic: Go は例外で制御しない。しかし
ErrAbortHandlerだけは「panic を制御フローに使う」公式の例外。回復ミドルウェアはこれを知っている必要がある。
⚖️ 迷った他案と採らなかった理由
| 論点 | 採った案 | 採らなかった案と理由 | 前提が変わったら |
|---|---|---|---|
| ResponseWriter を包む | 自作(3 メソッド + Unwrap) | httpsnoop(1,174★): Hijacker・ReaderFrom まで壊さず包めるが、今日の論点(何が起きるか)が見えなくなる | WebSocket・io.Copy の最適化(ReaderFrom)を通す API なら httpsnoop を使う |
| gzip の実装 | 自作 | klauspost/compress/gzhttp(5,654★): 本番ならこちら。最小サイズ・Content-Type 判定・プールを持つ。今日は「壊れ方」を学ぶのが目的 | 本番に載せるなら即置換。契約テストはそのまま使える(newHandler の積み替えだけで同じテストを当てる) |
| Content-Length | 最初の書き込みで削除 | 全体をバッファして圧縮後の長さを付ける: SSE・大きな応答で壊れる/メモリを食う | 小さな固定サイズの応答だけを扱う内部 API ならバッファでよい |
| panic 中の gzip | Close しない(defer をやめる) | 常に defer Close して Recover を gzip の内側に置く: 積む順の知識を main.go に強いる。ミドルウェア単体で安全な方が良い | 積む順を 1 か所で強制する仕組み(宣言表)があるなら成立する |
| ErrAbortHandler | 再 panic | 回復しない・ログだけ出す: 通常 panic の 500 化まで失う | — |
| 状態コード | 最初の 200 以上 | 常に上書き(バグ版)/ wroteHeader フラグで全て無視: 1xx(Early Hints)の扱いを間違える | Hijack(101)を通すなら別扱い |
🌐 他言語との比較
※ 下表は手元知識で、一次資料は未確認。
| 言語・FW | 同じ問題の扱い |
|---|---|
| Java Servlet | HttpServletResponseWrapper で包む。isCommitted() で「ヘッダ確定済み」を問える(Go の ResponseWriter には無い)。gzip フィルタで Content-Length を消すのは定番の作法 |
| Express (Node) | compression ミドルウェアは res.write を差し替え、Content-Length を removeHeader する。res.flush() を自前で足す必要があり、Go の Flush 問題と同じ |
| Python (WSGI) | レスポンスがイテレータなので、gzip ミドルウェアは close() の呼び出しに責任を持つ。panic の代わりに例外 |
🛠️ 実務での使いどころ
- 本番の「500 率」ダッシュボードが、gzip を足した日から 0 になる(panic が 200 で記録される)。B4 は監視が静かになる種類の障害で、気づくのは遅れる。
- SSE・長いレスポンスの前段に CDN/gzip を入れた途端、「イベントが最後にまとめて届く」。B6。
- 静的ファイル・事前計算 JSON に
Content-Lengthを付ける実装に、後から gzip を足すと一部のクライアントだけunexpected EOF(B5)。 - 公開されている同じ作法(確認済み): chi の
Recoverer(recoverer.go:26)とCompress(compress.go:334)、標準のhttputil.ReverseProxyは転送中の失敗でpanic(http.ErrAbortHandler)を使う(go1.27.1 net/http/httputil/reverseproxy.go:600、Issue 23643 への言及つき)。
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| 「ミドルウェアは単体テストが通れば安全」 | 各自は正しい。破綻は積み方と実サーバーの挙動 | httptest.NewServer の実接続で、積んだ形を試す |
「httptest.NewRecorder で十分」 | 速いので使いたくなる | Recorder は接続を切れない・Flush を実際には送らない。B3・B5・B6 は実サーバーでしか見えない |
「go vet が通れば defer は安全」 | vet は time.Since の即評価だけ指摘する | defer f(x) の x 全般は人が見る |
| 「recover は全部 500 にすれば親切」 | 例外処理の習慣 | ErrAbortHandler は意図的な切断 |
| 「Content-Encoding は先に付けてよい」 | 入口で付けるのが自然に見える | 下流が 500 を平文で返す道がある。付けるのは圧縮を始める瞬間 |
🚧 扱わなかった範囲
- 圧縮レベル・最小サイズ・Content-Type による除外・
sync.Pool(gzhttp は持つ)。性能は測っていない Hijacker・ReaderFrom・Pusher(httpsnoop は網羅)。UnwrapでResponseController経由のHijackは届くが、テストしていない- HEAD・204・304・Range・既に
Content-Encodingが付いた応答(二重圧縮) WriteHeader(101)・103 Early Hintsの細部。1xx はcode >= 200で無視するだけ- HTTP/2 での挙動(テストは HTTP/1.1 の
httptest.NewServer) - gzip bomb 等のセキュリティ(今日は応答側のみ)
🚀 次のステップ
- 発展:
Gzipをklauspost/compress/gzhttpに差し替えて、同じmain_test.goを当てる(契約テスト。どれが落ちるかを見る)。AccessLogを httpsnoop 版に差し替えて 3 ファイルの差を測る - 次回予告: 同テーマ(ミドルウェア設計パターン)の続き。state の記載に従う