2-4. ミドルウェア - 合成・順序・context 連携

所要時間: 40-60分(2セッション分) コミット内容: ~/learn/go/level2/day04/ にログ・recover・request ID・タイムアウトの自作ミドルウェア群


このレッスンのゴール

  • func(http.Handler) http.Handler シグネチャを書ける
  • RequestID/Recover/AccessLog/Timeout 4つを自作できる
  • ミドルウェア順序の設計判断ができる
  • context.WithValue でリクエストスコープ値を伝播
  • statusRecorder で実ステータスをログに残せる

なぜ学ぶか

全エンドポイントに認証チェックを入れたい」「全リクエストにIDを振りログを追跡可能にしたい」「panicでサーバー落ちないようにrecoverを仕掛けたい」 - これらを 各ハンドラに書くと地獄。ミドルウェアで「ハンドラの周りに層」を被せるのが解。本章は Level 2 で最も「Go らしさ」を体感できる回http.Handler インターフェースの真価がここで開花する。

前章とのつながり

2-1_net_httphttp.Handler インターフェースを学んだ。本章はそれを 「ハンドラを受け取って別のハンドラを返す関数」 という形で活用する。http.HandlerFunc キャストの存在意義もここで明白になる。2-3_JSONdecodeJSON本質的にミドルウェアの一部 だったと気づく。

これができると何が嬉しいか

  • 障害解析が劇的に楽になる - request_id で grep 一発で1リクエストの全ログ
  • panic でサーバーが落ちない - recover ミドルウェアで境界防御
  • 認証/レート制限/CORS を1行のコードで全エンドポイントに追加できる
  • chi/gin のミドルウェア記法に違和感がない - 全部同じパターン

ストーリー導入: ミドルウェアは「玉ねぎの皮」

ハンドラ本体(玉ねぎの芯)を、ログ→recover→認証→…と 皮で何重にも包む。リクエストは外側の皮から順に入って、芯で処理されて、また外側の皮を逆順に通って出ていく。これが Go の http.Handler の合成パターン。Decorator パターン の教科書的実装。順序を間違えると「recover の外でログを書こうとして自分が panic する」みたいな悲劇が起きるので設計が重要。


大前提: ミドルウェアは「ハンドラを包む層」

すべてのリクエストに共通でやりたい処理がある:

  • アクセスログ
  • panic からの復帰(recover)
  • 認証・認可
  • リクエスト ID 採番
  • レート制限
  • CORS ヘッダ追加
  • リクエストタイムアウト

これらをハンドラごとに書くのは無理。ハンドラの周りに「層」として被せるのが解。それがミドルウェア。

Express でいう app.use((req, res, next) => {...})、Rails でいう Rack middleware、Java でいう Servlet Filter と同じ概念。Go では http.Handler を入れ子にすることで実現する

[クライアント] → [ログ層] → [recover層] → [認証層] → [ハンドラ本体]
                  ↓                                       ↑
                  └──────────── 全リクエスト共通 ────────┘

セッション①: ミドルウェアパターンの本質(30分)

0. 録画スタート&作業ディレクトリ

mkdir -p ~/log ~/learn/go/level2/day04
cd ~/learn/go/level2/day04
script ~/log/go_level2_day04.log
 
# Go モジュール初期化
go mod init example.com/level2/day04

1. 最小のミドルウェア

package main
 
import (
	"log"
	"net/http"
	"time"
)
 
// ミドルウェアの「型」: http.Handler を受け取って http.Handler を返す関数
type Middleware func(http.Handler) http.Handler
 
// アクセスログを取るミドルウェア
func Logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)  // ← 次のハンドラを呼ぶ
		log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
	})
}
 
func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("hello"))
	})
 
	// mux を Logging で包む
	wrapped := Logging(mux)
 
	http.ListenAndServe(":8080", wrapped)
}
go run main.go &
curl http://localhost:8080/hello
# サーバー側ログ: GET /hello 123.4µs

ミドルウェアの本質

ミドルウェアは http.Handler を受け取って http.Handler を返す関数」。シグネチャは:

func MiddlewareName(next http.Handler) http.Handler

next は「自分の次に呼ばれるハンドラ」。next.ServeHTTP(w, r) を呼ぶことで処理を委譲する。

これは Decorator パターン(GoF デザインパターン)の典型的な実装。元のハンドラに機能を「追加」していく。

http.HandlerFunc(func(w, r) {...}) のキャストが鍵

http.HandlerFunc(func) は「関数を http.Handler に変換」するアダプタ(2-1 で学んだやつ)。これがあるから、無名関数を Handler として返せる

もしこれが無ければ、毎回 struct を定義して ServeHTTP メソッドを生やすことになる。HandlerFunc の存在がミドルウェア記述を簡潔にしている。

2. ミドルウェアを「重ねる」

func Auth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		token := r.Header.Get("Authorization")
		if token == "" {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return  // ← next.ServeHTTP を呼ばない = 後続を実行しない
		}
		next.ServeHTTP(w, r)
	})
}
 
// Logging → Auth → mux の順に通る
wrapped := Logging(Auth(mux))
http.ListenAndServe(":8080", wrapped)

包む順序 = 実行順序の外側から内側

Logging(Auth(mux))

実行の流れ:

  1. Logging の「next.ServeHTTP 前」処理(時刻記録)
  2. Auth の「next.ServeHTTP 前」処理(トークン確認)
  3. mux → ハンドラ本体
  4. Auth の「next.ServeHTTP 後」処理(あれば)
  5. Logging の「next.ServeHTTP 後」処理(ログ出力)

外側のミドルウェアが最初と最後に呼ばれる。サンドイッチ構造。

3. ミドルウェアの順序設計

推奨される標準的な順序(外側→内側)

[リクエストID採番] ← 最初に必ず ID を振る(後段のログで使う)
      ↓
[アクセスログ] ← 全リクエストを必ず記録
      ↓
[recover (panic 回復)] ← 万一のpanicで500を返す
      ↓
[リクエストタイムアウト] ← 過剰に長い処理を止める
      ↓
[CORS] ← プリフライト OPTIONS は早めに返す
      ↓
[レート制限] ← 認証通すコストを節約
      ↓
[認証] ← Bearer Token、Cookie などの検証
      ↓
[認可] ← 認証されたユーザーがこのリソースを触れるか
      ↓
[ハンドラ本体]

順序を間違えると起きる事故

  • ログより先に recover を置くと: panic 時のリクエストがログに残らない(recover で握りつぶされ、ログミドルウェアが panic に気づけない)
  • 認証より先にレート制限を置かないと: 認証コストが先に発生する → DoS 攻撃で CPU を食われる
  • CORS より先に認証を置くと: ブラウザのプリフライト OPTIONS が認証失敗 → CORS エラーで真の原因が見えなくなる
  • タイムアウトを最内側に置くと: ミドルウェアの遅延(DBアクセスする認証など)でタイムアウト超過してから止まる

「全リクエストに走らせたいもの」ほど外側、「特定の処理に近いもの」ほど内側が原則。

4. recover ミドルウェア(重要)

func Recover(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if rec := recover(); rec != nil {
				stack := debug.Stack()
				slog.Error("panic recovered",
					"err", rec,
					"path", r.URL.Path,
					"stack", string(stack),
				)
				// クライアントには汎用エラーだけ返す(内部情報を漏らさない)
				http.Error(w, "internal server error", http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

recover ミドルウェアが無いと、panic でサーバー全体が落ちるか?

落ちない(Go 1.x 系の net/http は標準で recover している)。標準ライブラリ内部で goroutine ごとに recover してくれる。

しかし標準の recover は Connection: close を付けて 500 を返すだけ。ログにも残らない。

自前の Recover ミドルウェアの価値:

  • panic 内容とスタックトレースを構造化ログに残せる → 障害解析の生命線
  • クライアントに返すエラーメッセージを制御できる(標準は空ボディ)
  • panic を計測指標として観測可能にできる(Prometheus に panic_total を出す等)

アンチパターン: panic 情報をそのままクライアントに返す

if rec := recover(); rec != nil {
    http.Error(w, fmt.Sprintf("error: %v\n%s", rec, debug.Stack()), 500)
}

なぜNG: スタックトレースには ファイルパス、関数名、内部の DB クエリ文字列 など、攻撃者にとって有用な情報が含まれる。これを返すと「攻撃の地図を渡す」ことになる。

修正: スタックは サーバー側ログに、クライアントには 汎用エラー だけ返す。

5. リクエスト ID ミドルウェアと context 連携

type ctxKey string
 
const requestIDKey ctxKey = "requestID"
 
func RequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// 既に上流(LB やリバプロ)で付与されていれば尊重
		id := r.Header.Get("X-Request-ID")
		if id == "" {
			id = uuid.NewString()  // github.com/google/uuid
		}
 
		// context に積む
		ctx := context.WithValue(r.Context(), requestIDKey, id)
		r = r.WithContext(ctx)
 
		// レスポンスヘッダにも返す
		w.Header().Set("X-Request-ID", id)
 
		next.ServeHTTP(w, r)
	})
}
 
// ハンドラから取り出す
func GetRequestID(ctx context.Context) string {
	if id, ok := ctx.Value(requestIDKey).(string); ok {
		return id
	}
	return ""
}

context.Context は「リクエストスコープの値の運び屋」

Go 1.7+ で標準導入された context パッケージは、HTTP リクエストの「寿命」と「リクエスト固有の値」をハンドラ間で受け渡しするための仕組み。

使うもの:

  • キャンセル通知: ctx.Done() で「クライアントが切断した」「親 context がタイムアウト」を検知
  • デッドライン: ctx.WithTimeout で「5秒で諦める」設定
  • 値伝播: ctx.WithValue でリクエスト ID、ユーザー ID、ロガーなどを乗せる

ミドルウェアは r.Context() から派生 context を作り、r = r.WithContext(...) で後段に渡す。

context.WithValue の使いすぎは禁止

公式ドキュメントに 「リクエストスコープのデータ以外を入れるな」 と明記されている。

OK: リクエスト ID、認証済みユーザー ID、トレース ID NG: DB コネクション、ロガー(依存注入で渡すべき)、設定値(起動時に渡すべき)

context は 動的な値の運搬路 であって、依存注入の代わりではない。

6. 自前ロガーを context に乗せる

type loggerCtxKey struct{}
 
func InjectLogger(logger *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// リクエスト ID を取り出して、フィールド付きロガーを作る
			requestID := GetRequestID(r.Context())
			rlog := logger.With("request_id", requestID, "path", r.URL.Path)
 
			ctx := context.WithValue(r.Context(), loggerCtxKey{}, rlog)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}
 
// ハンドラから取り出す
func LoggerFromCtx(ctx context.Context) *slog.Logger {
	if l, ok := ctx.Value(loggerCtxKey{}).(*slog.Logger); ok {
		return l
	}
	return slog.Default()
}
 
// 使う側
func myHandler(w http.ResponseWriter, r *http.Request) {
	log := LoggerFromCtx(r.Context())
	log.Info("processing", "user_id", 42)
	// → {"time":..., "msg":"processing", "request_id":"abc-123", "path":"/x", "user_id":42}
}

なぜ「リクエスト ID 付きロガー」を context に積むか

1リクエスト = 1 goroutine + その中で複数の関数呼び出し → ログが時系列で混ざる。

リクエスト ID + ロガーが context に乗っていれば、全てのログ行に自動で request_id が付く → 障害時に「このリクエストで何が起きたか」を grep 一発で追える。

Stripe, Cloudflare などの本番運用ベストプラクティス。


セッション②: ResponseWriter のラップとよくある事故(30分)

7. ResponseWriter をラップして「実際のステータスコード」を記録する

ログには「200 を返した」「404 を返した」を残したい。しかし、標準の ResponseWriter書いたステータスを後から取り出すメソッドが無い。自前でラップする必要がある。

type statusRecorder struct {
	http.ResponseWriter
	status int
	bytes  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  // WriteHeader を呼ばずに Write した場合の暗黙の 200
	}
	n, err := s.ResponseWriter.Write(b)
	s.bytes += n
	return n, err
}
 
func Logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		rec := &statusRecorder{ResponseWriter: w}
		next.ServeHTTP(rec, r)
 
		slog.Info("http",
			"method", r.Method,
			"path", r.URL.Path,
			"status", rec.status,
			"bytes", rec.bytes,
			"duration_ms", time.Since(start).Milliseconds(),
			"request_id", GetRequestID(r.Context()),
		)
	})
}

このラップが必要な理由

アクセスログは「200 か 404 か 500 か」が 最重要メトリクス。これが取れなかったら、エラー率の監視も SLO 計測も成り立たない。

Go の http.ResponseWriter書き込み専用 のインターフェースで、「書いた内容」を読み出す方法は標準では用意されていない。自前ラップが事実上の標準テクニック。

アンチパターン: グローバル変数でステータスを記録する

var lastStatus int
 
func WriteHeader(code int) {
    lastStatus = code  // 競合状態!
}

なぜNG: 全リクエスト goroutine が共有するグローバルだから、同時リクエスト時にステータスが混ざる。

修正: リクエストごとに statusRecorder を new する(上記コード)。

8. ResponseWriter ラップの落とし穴 - http.Hijacker と http.Flusher

// statusRecorder は http.Hijacker を実装していないため WebSocket で壊れる
func (s *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
	if hj, ok := s.ResponseWriter.(http.Hijacker); ok {
		return hj.Hijack()
	}
	return nil, nil, errors.New("not a Hijacker")
}
 
// Flush は SSE / ストリーミングで使う
func (s *statusRecorder) Flush() {
	if f, ok := s.ResponseWriter.(http.Flusher); ok {
		f.Flush()
	}
}

ラップすると失われるインターフェース

Go の http.ResponseWriter は実は 複数のインターフェースを兼ね備えた具体型 が標準ライブラリから渡される:

  • http.Flusher - レスポンスを即時送信(SSE で必要)
  • http.Hijacker - 下層の net.Conn を奪う(WebSocket で必要)
  • http.CloseNotifier - クライアント切断通知(deprecated、今は context 経由)

statusRecorder でラップすると、http.Hijacker への型アサーションが失敗して WebSocket が動かなくなる。実例: chi, echo の古いミドルウェアでも踏んだバグ。

対策: ラッパー側でも Hijack()Flush() を実装して 下層に委譲する。これを徹底しないと、ミドルウェアを通したエンドポイントで突然 WebSocket が壊れる。

9. タイムアウトミドルウェア

func Timeout(d time.Duration) Middleware {
	return func(next http.Handler) http.Handler {
		return http.TimeoutHandler(next, d, `{"error":"request timeout"}`)
	}
}

http.TimeoutHandler は標準ライブラリ提供

標準で http.TimeoutHandler(handler, timeout, message) が用意されている。指定時間内にハンドラが終わらなければ、自動で 503 Service Unavailable を返す。

ただし注意点:

  • 内部のハンドラは止まらない(goroutine は走り続ける)。「クライアントへのレスポンス送信を諦める」だけ
  • 真に止めたければ r.Context() を ハンドラ内で監視 する(context が cancel される)
// ハンドラ側の正しい書き方
func slowHandler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
 
	resultChan := make(chan string, 1)
	go func() {
		// 重い処理
		resultChan <- doExpensiveWork(ctx)
	}()
 
	select {
	case result := <-resultChan:
		w.Write([]byte(result))
	case <-ctx.Done():
		// 親 context が cancel された(タイムアウト or クライアント切断)
		slog.Warn("request cancelled", "err", ctx.Err())
		return
	}
}

10. ミドルウェアチェイン用のヘルパー

// 順番に重ねる(左から内側、右から外側、ではなく「書いた順に外側→内側」)
func Chain(mws ...Middleware) Middleware {
	return func(next http.Handler) http.Handler {
		for i := len(mws) - 1; i >= 0; i-- {
			next = mws[i](next)
		}
		return next
	}
}
 
func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/api", apiHandler)
 
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
 
	// 外側 → 内側の順に書く(直感的)
	stack := Chain(
		RequestID,
		Recover,
		Timeout(10*time.Second),
		InjectLogger(logger),
	)
 
	http.ListenAndServe(":8080", stack(mux))
}

Chain ヘルパーが無いとどうなる

// ネスト地獄
wrapped := RequestID(Recover(Timeout(10*time.Second)(InjectLogger(logger)(mux))))

↑これは読めない。Chain を作るか、chi / echo のようなライブラリの r.Use(mw) 構文を使う。

11. chi のミドルウェア記法との比較

// chi の場合
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(10 * time.Second))
r.Use(middleware.Logger)
 
r.Get("/api", apiHandler)

chi / echo / gin の Use との対比

方式書き方利点
標準 + Chain ヘルパーChain(MW1, MW2)(handler)依存ゼロ
chi の User.Use(mw) を順に呼ぶ直感的、サブルーターごとに切り替え可
gin の User.Use(mw)gin.HandlerFuncgin 専用シグネチャ
echo の Usee.Use(mw)echo.MiddlewareFuncecho 専用シグネチャ

標準と chi は http.Handler 互換 なので、ミドルウェア資産が共通化できる。gin / echo は独自シグネチャなので相互運用不可。

12. 実装: 全部入りミドルウェアスタック

// middleware.go
package main
 
import (
	"context"
	"log/slog"
	"net/http"
	"runtime/debug"
	"time"
 
	"github.com/google/uuid"
)
 
type Middleware func(http.Handler) http.Handler
 
type ctxKey string
 
const (
	requestIDKey ctxKey = "request_id"
)
 
// 1. リクエスト ID
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 == "" {
			id = uuid.NewString()
		}
		ctx := context.WithValue(r.Context(), requestIDKey, id)
		w.Header().Set("X-Request-ID", id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}
 
// 2. recover
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 rec := recover(); rec != nil {
					logger.Error("panic recovered",
						"err", rec,
						"stack", string(debug.Stack()),
						"path", r.URL.Path,
					)
					http.Error(w, "internal server error", 500)
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}
 
// 3. アクセスログ + ステータス記録
type statusRecorder struct {
	http.ResponseWriter
	status int
}
 
func (s *statusRecorder) WriteHeader(c int) {
	s.status = c
	s.ResponseWriter.WriteHeader(c)
}
 
func AccessLog(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, status: 200}
			next.ServeHTTP(rec, r)
 
			rid, _ := r.Context().Value(requestIDKey).(string)
			logger.Info("http",
				"request_id", rid,
				"method", r.Method,
				"path", r.URL.Path,
				"status", rec.status,
				"duration_ms", time.Since(start).Milliseconds(),
				"remote", r.RemoteAddr,
			)
		})
	}
}
 
// 4. Chain ヘルパー
func Chain(mws ...Middleware) Middleware {
	return func(next http.Handler) http.Handler {
		for i := len(mws) - 1; i >= 0; i-- {
			next = mws[i](next)
		}
		return next
	}
}

練習課題

  1. レート制限ミドルウェアを golang.org/x/time/raterate.Limiter で実装
  2. 認証ミドルウェアを書き、Authorization: Bearer XXX を検証して context に user_id を積む
  3. slog を context から取り出すパターンを実装し、ハンドラ内で log := LoggerFromCtx(r.Context()) で使えるようにする
  4. http.TimeoutHandler を使って /slow を3秒でタイムアウトさせる
  5. WebSocket(gorilla/websocket)を含むサーバーで statusRecorder ラップして Hijack が動かなくなることを再現 → Hijack() メソッド追加で修正
  6. ボーナス: chi の middleware.Logger のソースを読む → 業界水準の実装に触れる

締め: git で証跡を残す

cd ~/learn/go/level2/day04
git add .
git commit -m "feat(go-middleware): RequestID/Recover/AccessLogの合成可能ミドルウェア"
exit

アンチパターン集 - やらかし事例

ミドルウェア設計の事故

1. 順序を間違えてログにステータスが残らない

Chain(AccessLog, Recover)  // ← Recover が AccessLog より内側

panic が AccessLog の next.ServeHTTP 内で起きると、AccessLog の後処理(ログ出力)に到達しない。

2. context にロガー・DB を入れる

ctx = context.WithValue(ctx, dbKey, db)  // ← NG

公式ドキュメント明言: 「リクエストスコープのデータ以外を入れるな」。DB/Logger は依存注入で構造体フィールドに。

3. statusRecorder ラップで Hijack が動かなくなる WebSocket 用途で http.Hijacker 型アサーションが失敗。ラッパーには Hijack() Flush() も委譲実装が必要

4. panic 情報をクライアントに返す

http.Error(w, debug.Stack(), 500)  // 攻撃者に地図を渡す

スタックは ログに、クライアントには汎用エラーだけ。

5. グローバル変数で状態管理

var lastStatus int

全 goroutine 共有でレース。リクエスト単位の statusRecorder で。

対比表で違いを明確化

標準 vs chi vs gin/echo のミドルウェア記法

観点標準 + Chainchi r.Usegin/echo Use
シグネチャfunc(http.Handler) http.Handler同左独自型
net/http 互換×
サブパス適用自前r.Groupあり
移植性

ミドルウェア推奨順序

役割外側/内側
RequestIDID 採番(後段ログ用)最外
Recoverpanic 捕捉 + 構造化ログ外側
AccessLogリクエスト/レスポンス記録外側寄り
Timeout長すぎる処理を中断中間
CORSプリフライト即返却中間
RateLimitDoS 防御(認証前で節約)中間
AuthBearer Token 検証内側
Authz権限チェック最内

自己評価チェックリスト

手を動かせた

  • Logging ミドルウェアを書いた
  • Recover で panic を握りつぶし、構造化ログに残した
  • RequestID ミドルウェアで context に積み、ヘッダにも返した
  • statusRecorder でラップして実ステータスをログ
  • Chain(MW1, MW2, ...) ヘルパーを自作

説明できる

  • ミドルウェアの型 func(http.Handler) http.Handler を空で書ける
  • 推奨順序の理由(ID→ログ→recover→…)
  • context.WithValue の許容範囲(リクエストスコープのみ)
  • Hijacker Flusher インターフェース継承の必要性

やらかし回避

  • panic 情報をクライアントに漏らさない
  • context にロガー/DBを入れない
  • グローバル変数で状態管理しない
  • ラッパーでインターフェース継承を欠かさない

詰まった時のチートシート

やりたいことコード
ミドルウェアの型func(http.Handler) http.Handler
次を呼ぶnext.ServeHTTP(w, r)
早期 return(後続を呼ばない)next.ServeHTTP を呼ばずに return
値を context に積むr = r.WithContext(context.WithValue(r.Context(), key, val))
値を取り出すr.Context().Value(key)
タイムアウトhttp.TimeoutHandler(next, d, msg)
パニック復帰defer func() { if rec := recover(); rec != nil {...} }()
ステータス記録statusRecorderResponseWriter をラップ
chi で連鎖r.Use(mw1); r.Use(mw2)

「実務OK」基準

  • 3-5個のミドルウェアを合成して動くサーバーが書ける
  • recover で panic を握りつぶしつつ、内部情報を漏らさずクライアントに 500 を返せる
  • context にリクエスト ID を積み、ログから一連の処理を grep できる
  • statusRecorder で実際のレスポンスコードをログに残せる
  • Hijacker / Flusher のインターフェース継承を意識した正しいラップが書ける

さらに深掘りするなら

  • chi のミドルウェア集: github.com/go-chi/chi/tree/master/middleware - 業界標準実装の宝庫
  • 公式: The Go Blog - Context and Cancellation
  • Justinas Stankevičius: “Making and Using HTTP Middleware” - ミドルウェアパターンの古典解説
  • 標準ライブラリ: src/net/http/server.goserverHandler.ServeHTTP - 標準内部の recover を読む
  • 書籍: 『Let’s Go』(Alex Edwards) - 中盤がミドルウェア構築の実践

次のレッスン

2-5 エラーハンドリング で Go 流のエラー設計(値としての error、%w ラップ、errors.Is/As、panic と recover の境界)を本気で扱う。エラーは設計の半分。

つながりの予告

  • 本章の Recover ミドルウェアは 2-5_エラー処理 の panic vs error 境界設計に直結
  • context.WithValue で積んだリクエスト ID は 3-6_context でキャンセル・タイムアウトと組み合わせる
  • Auth ミドルウェアは 3-5_認証 で本格実装