Go 1.22+のnet/httpでアプリケーションを構築するにあたり、ミドルウェアの設計を検討する。特に、リクエストのロギングは必須要件となる。
一般的なアプローチは、複数のミドルウェアをチェインさせる、いわゆるタマネギ構造だ。例えば、リクエストID生成、認証、ロギングなどを個別のミドルウェアとして実装する。この方法は各機能の関心が分離され、再利用性が高い。
しかし、ミドルウェア間で依存関係が生じる場合に複雑化する。例えば、アクセスログを記録するミドルウェアは、リクエストIDを生成するミドルウェアがコンテキストに設定した値に依存する。この依存関係は暗黙的であり、ミドルウェアの適用順序を厳密に管理する必要がある。これが保守性の低下を招くことは経験上明らかだ。
まず、タマネギ構造を前提として、依存性の問題を緩和する策を考える。ミドルウェアがグローバルなロガーを参照するのではなく、外部から注入(Dependency Injection)1する形にしてみる。
ミドルウェアを生成する高階関数が、*slog.Loggerインスタンスを引数として受け取るようにする。
// Middleware は http.Handler を受け取り、http.Handler を返す関数の型
type Middleware func(http.Handler) http.Handler
// NewAccessLogMiddleware はロガーを受け取り、アクセスログを記録するミドルウェアを生成する
func NewAccessLogMiddleware(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// ... response writerのラッパーなど ...
next.ServeHTTP(w, r)
duration := time.Since(start)
logger.Info("request completed",
"method", r.Method,
"path", r.URL.Path,
"duration", duration,
)
})
}
}この設計により、ミドルウェアは特定のロガー実装から切り離され、テスト時にはモックやバッファを向いたロガーを注入できるようになる。テスト容易性は向上する。
しかし、これではリクエストごとに動的な情報(トレースID、ユーザーIDなど)をログに含めることができない。注入されるロガーは静的なインスタンスだからだ。
静的なインスタンスではなく、ロガーを生成するファクトリー関数を注入するアプローチを試す。このファクトリーはリクエスト情報を受け取り、コンテキストに応じたロガーを動的に生成する。
// LoggerFactory はリクエスト情報からコンテキスト付きのロガーを生成する関数の型
type LoggerFactory func(r *http.Request) *slog.Logger
// NewContextualLogMiddleware はロガーファクトリーを受け取り、
// リクエストごとにロガーを生成してコンテキストに格納するミドルウェアを返す
func NewContextualLogMiddleware(factory LoggerFactory) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := factory(r)
ctx := context.WithValue(r.Context(), loggerContextKey, logger)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}利用者は、先行するミドルウェアがコンテキストに設定した値(例: リクエストID)を使ってロガーを生成するファクトリーを定義する。
func requestLoggerFactory(r *http.Request) *slog.Logger {
// 先行するミドルウェアが設定した値を取得
requestID, _ := r.Context().Value(requestIDContextKey).(string)
return baseLogger.With("request_id", requestID)
}このロガーファクトリー案は、当初の課題を解決できていない。むしろ悪化させている。requestLoggerFactoryが呼び出される時点で、コンテキストにrequestIDが存在している必要があるため、ミドルウェアの適用順序への依存がより強固になってしまった。
// 正しい順序
handler := RequestIDMiddleware(ContextualLogMiddleware(mux))
// 誤った順序: これではログに request_id が含まれない
handler := ContextualLogMiddleware(RequestIDMiddleware(mux))この暗黙的な順序依存は、バグの温床となる。タマネギ構造を前提とする限り、この問題からの完全な脱却は難しい。前提そのものを疑う必要がある。
タマネギ構造を捨て、アプローチを根本から変更する。複数のミドルウェアをチェインさせるのではなく、単一の責務あるミドルウェアを適用する。ただし、そのミドルウェアの内部ロジックは、交換可能な部品(コンポーネント)の組み合わせで構築する。
コンポーネントは、特定の処理単位を担う関数として定義する。
RequestIDProvider: リクエストからIDを決定する。ContextualLoggerProvider: ID等の情報からロガーを生成する。AccessLogRecorder: 最終的な情報でログを記録する。
これらのコンポーネントを引数として受け取り、内部で正しい順序で実行するミドルウェア生成関数を作成する。config構造体のような中間層は設けず、関数シグネチャで直接受け取るのが最もシンプルだ。
// --- コンポーネントの型定義 ---
type RequestIDProvider func(r *http.Request) (context.Context, slog.Attr)
type ContextualLoggerProvider func(ctx context.Context, baseLogger *slog.Logger, attrs ...slog.Attr) (context.Context, *slog.Logger)
type AccessLogRecorder func(logger *slog.Logger, r *http.Request, status int, duration time.Duration)
// --- デフォルト実装 ---
func DefaultRequestIDProvider() RequestIDProvider { /* ... */ }
func DefaultContextualLoggerProvider() ContextualLoggerProvider { /* ... */ }
func DefaultAccessLogRecorder() AccessLogRecorder { /* ... */ }そして、これらを組み立てる生成関数。
// NewMiddleware は各種コンポーネントを直接引数に取り、単一のミドルウェアを生成する
func NewMiddleware(
baseLogger *slog.Logger,
reqIDProvider RequestIDProvider,
loggerProvider ContextualLoggerProvider,
logRecorder AccessLogRecorder,
) func(http.Handler) http.Handler {
// 引数がnilの場合はデフォルト実装を適用
if reqIDProvider == nil {
reqIDProvider = DefaultRequestIDProvider()
}
// ... 他のコンポーネントも同様 ...
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 1. RequestIDProvider を使用
ctx, reqIDAttr := reqIDProvider(r)
// 2. ContextualLoggerProvider を使用
ctx, logger := loggerProvider(ctx, baseLogger, reqIDAttr)
// ... response writerのラッパー ...
next.ServeHTTP(rw, r.WithContext(ctx))
// 3. AccessLogRecorder を使用
duration := time.Since(start)
logRecorder(logger, r, rw.statusCode, duration)
})
}
}このアプローチは、順序依存の問題を完全に解決する。コンポーネント間のデータの受け渡し(reqIDAttrをloggerProviderに渡すなど)は、NewMiddleware関数内で明示的に行われる。利用者は、部品を渡すだけで、内部の複雑さを意識する必要はない。
この設計がもたらす副次的な利点も大きい。
第一に、スタックトレースの簡潔さ。タマネギ構造では、パニック時のスタックトレースがミドルウェアの層の数だけ深くなり、デバッグが困難になる。単一ミドルウェアのアプローチでは、スタックフレームは常に一つであり、極めて見通しが良い。
第二に、テスト容易性。ハンドラの単体テストを行う際、テスト用のコンポーネントを注入したミドルウェアを簡単に生成できる。
// TestMiddlewareKit はテストに必要なミドルウェアと関連オブジェクトをまとめたもの
type TestMiddlewareKit struct {
Middleware func(http.Handler) http.Handler
LogBuffer *bytes.Buffer
Logger *slog.Logger
}
// NewTestMiddlewareKit はハンドラの単体テストを容易にするためのヘルパーキットを生成する
func NewTestMiddlewareKit() *TestMiddlewareKit {
logBuffer := new(bytes.Buffer)
testLogger := slog.New(slog.NewJSONHandler(logBuffer, nil))
// テスト用の固定IDを返すProvider
testReqIDProvider := func(r *http.Request) (context.Context, slog.Attr) {
const testID = "test-req-id-12345"
ctx := context.WithValue(r.Context(), "request_id", testID)
return ctx, slog.String("request_id", testID)
}
middleware := NewMiddleware(
testLogger,
testReqIDProvider,
nil, // デフォルト実装を使用
nil, // デフォルト実装を使用
)
return &TestMiddlewareKit{
Middleware: middleware,
LogBuffer: logBuffer,
Logger: testLogger,
}
}このテストキットを使えば、ハンドラが必要とするコンテキスト内の依存オブジェクトを簡単に準備でき、かつミドルウェアが出力するログの検証も容易に行える。
結論として、タマネギ構造の再利用性を諦める代わりに、コンポーネントの交換可能性という形で再利用性を担保し、順序依存とデバッグの複雑さという大きな問題を解決するこのアプローチが、堅牢なアプリケーション基盤として最適であると判断する。
Footnotes
-
Dependency Injection (DI): 依存性の注入。あるコンポーネントが必要とする別のコンポーネント(依存オブジェクト)を、自身の内部で生成するのではなく、外部から与えられるようにする設計パターン。コンポーネント間の結合度を下げ、テスト容易性を向上させる。 ↩
qwen3-max
非常に明確で思考の遷移が丁寧に記録されており、将来的な自分(Future me)がこのログを読み返したときに「なぜこの設計を選んだのか」を完全に再現できる素晴らしいドキュメントになっています。設計の課題認識、仮説の検証、失敗の分析、最終的な解決策の妥当性検証までが一貫しており、ソフトウェア設計における「意思決定の文脈」を保存するという点で、まさに模範的な再考ログです。
以下、「Future me」がこのログを読んだときに役立つであろう補足・アドバイス・注意点を追加します。
✅ あなたの最終設計は妥当か?
はい、非常に妥当です。
特に以下の点が秀逸:
NewMiddleware内で明示的に制御し、ユーザーに順序管理の責任を負わせない。Provider/Recorderは純粋関数のようにテストしやすい。nil時にデフォルトを使うというパターンは Go ではよく使われ、初心者にも親しみやすい。slog.Attr)を渡すことで、副作用を局所化。これは「コンポーネント指向のミドルウェア」とでも呼べる、Go の簡潔さと柔軟性を活かした良設計です。
🔧 将来的に考慮すべき改善点(Future me 向け)
1. コンポーネントの戻り値設計:error の扱い
現状のコンポーネント(例:
RequestIDProvider)はエラーを返していません。しかし、将来的に外部システム(例: UUID サービス)から ID を取得する必要が生じた場合、エラー処理が必要になります。
提案:
エラーが発生した場合、ミドルウェア内で適切に処理(例: フォールバックID生成 or 500エラー)できるように設計を拡張可能にしておくと良い。
2. ResponseWriter のラッパーの実装漏れ
ログ記録にはレスポンスステータスコードが必要ですが、標準の
http.ResponseWriterはWriteHeaderが呼び出されないとステータスが取得できません。現状のコードに
rwという未定義変数があります:これはおそらく
responseWriterのラッパー(例:statusRecorder)を想定していると思われますが、その実装が記載されていません。対応:
statusRecorder型を定義し、WriteHeaderをフックしてステータスを記録。NewMiddlewareの中で必ずラップするように実装する。3. コンテキストキーの型安全性
現在、
context.WithValueで文字列キーを使っています(例:"request_id")。これは実行時エラーのリスクがあります。Go のベストプラクティス:
または、さらに安全に:
4. コンポーネントの拡張性 vs 複雑性のトレードオフ
現在の設計は「3つの固定コンポーネント」ですが、将来的に「認証」「メトリクス収集」「レートリミット」などもこの単一ミドルウェアに組み込みたいという欲求が出てくるかもしれません。
その場合、単一ミドルウェアが肥大化するリスクがあります。
対策案:
🧪 テスト設計についての追加アドバイス
TestMiddlewareKitは素晴らしいですが、コンポーネント単位のテストも別途持つとさらに堅牢になります。例:
また、ミドルウェアが正しくコンテキストを伝播しているかを検証するテストも重要です:
📌 まとめ:Future me がこのログを読んだときに思い出すべきこと
あなたの思考プロセスは、まさに 「シンプルさの中に深さを」 という Go の哲学に沿っています。
この設計をベースに、自信を持ってプロダクションコードを構築して大丈夫です。
Good job, Past me.
— Future you, 2025年