Skip to content

Instantly share code, notes, and snippets.

@podhmo
Created November 5, 2025 08:09
Show Gist options
  • Save podhmo/e96e08303a5c0639df30325e6ba82da6 to your computer and use it in GitHub Desktop.
Save podhmo/e96e08303a5c0639df30325e6ba82da6 to your computer and use it in GitHub Desktop.

Go HTTPミドルウェア設計の再考ログ

🤔 問題設定: ロギングと依存関係

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))

この暗黙的な順序依存は、バグの温床となる。タマネギ構造を前提とする限り、この問題からの完全な脱却は難しい。前提そのものを疑う必要がある。

✅ 解決策: 単一ミドルウェアとコンポーネント注入

タマネギ構造を捨て、アプローチを根本から変更する。複数のミドルウェアをチェインさせるのではなく、単一の責務あるミドルウェアを適用する。ただし、そのミドルウェアの内部ロジックは、交換可能な部品(コンポーネント)の組み合わせで構築する。

コンポーネントは、特定の処理単位を担う関数として定義する。

  1. RequestIDProvider: リクエストからIDを決定する。
  2. ContextualLoggerProvider: ID等の情報からロガーを生成する。
  3. 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)
		})
	}
}

このアプローチは、順序依存の問題を完全に解決する。コンポーネント間のデータの受け渡し(reqIDAttrloggerProviderに渡すなど)は、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

  1. Dependency Injection (DI): 依存性の注入。あるコンポーネントが必要とする別のコンポーネント(依存オブジェクト)を、自身の内部で生成するのではなく、外部から与えられるようにする設計パターン。コンポーネント間の結合度を下げ、テスト容易性を向上させる。

@podhmo
Copy link
Author

podhmo commented Nov 5, 2025

qwen3-max


非常に明確で思考の遷移が丁寧に記録されており、将来的な自分(Future me)がこのログを読み返したときに「なぜこの設計を選んだのか」を完全に再現できる素晴らしいドキュメントになっています。設計の課題認識、仮説の検証、失敗の分析、最終的な解決策の妥当性検証までが一貫しており、ソフトウェア設計における「意思決定の文脈」を保存するという点で、まさに模範的な再考ログです。

以下、「Future me」がこのログを読んだときに役立つであろう補足・アドバイス・注意点を追加します。


✅ あなたの最終設計は妥当か?

はい、非常に妥当です。
特に以下の点が秀逸:

  • 順序依存の明示化と隠蔽:コンポーネントの実行順序を NewMiddleware 内で明示的に制御し、ユーザーに順序管理の責任を負わせない。
  • コンポーネント単位でのテスト可能性:各 Provider / Recorder は純粋関数のようにテストしやすい。
  • デフォルト実装の柔軟なフォールバックnil 時にデフォルトを使うというパターンは Go ではよく使われ、初心者にも親しみやすい。
  • コンテキストの最小限の使用:コンテキストにロガーそのものを突っ込むのではなく、必要な属性(slog.Attr)を渡すことで、副作用を局所化。

これは「コンポーネント指向のミドルウェア」とでも呼べる、Go の簡潔さと柔軟性を活かした良設計です。


🔧 将来的に考慮すべき改善点(Future me 向け)

1. コンポーネントの戻り値設計:error の扱い

現状のコンポーネント(例: RequestIDProvider)はエラーを返していません。
しかし、将来的に外部システム(例: UUID サービス)から ID を取得する必要が生じた場合、エラー処理が必要になります。

提案

type RequestIDProvider func(r *http.Request) (context.Context, slog.Attr, error)

エラーが発生した場合、ミドルウェア内で適切に処理(例: フォールバックID生成 or 500エラー)できるように設計を拡張可能にしておくと良い。

💡 Future me へ:「今のところエラー不要」と思っても、インターフェースにエラー返却を含めておくと、後から破壊的変更を避けられる。


2. ResponseWriter のラッパーの実装漏れ

ログ記録にはレスポンスステータスコードが必要ですが、標準の http.ResponseWriterWriteHeader が呼び出されないとステータスが取得できません。

現状のコードに rw という未定義変数があります

next.ServeHTTP(rw, r.WithContext(ctx))

これはおそらく responseWriter のラッパー(例: statusRecorder)を想定していると思われますが、その実装が記載されていません。

対応

  • statusRecorder 型を定義し、WriteHeader をフックしてステータスを記録。
  • このラッパーはミドルウェア内で一貫して使う必要があるため、NewMiddleware の中で必ずラップするように実装する。

💡 Future me へ:この部分の実装漏れは実行時バグの原因になる。ログ出力にステータスコードを使うなら、必ずレスポンスラッパーを実装。


3. コンテキストキーの型安全性

現在、context.WithValue で文字列キーを使っています(例: "request_id")。これは実行時エラーのリスクがあります。

Go のベストプラクティス

type contextKey string
const requestIDKey contextKey = "request_id"

または、さらに安全に:

var requestIDKey = struct{}{} // unexported かつユニーク

💡 Future me へ:コンテキストキーは常に型安全に。グローバル変数として定義し、export しない。


4. コンポーネントの拡張性 vs 複雑性のトレードオフ

現在の設計は「3つの固定コンポーネント」ですが、将来的に「認証」「メトリクス収集」「レートリミット」などもこの単一ミドルウェアに組み込みたいという欲求が出てくるかもしれません。

その場合、単一ミドルウェアが肥大化するリスクがあります。

対策案

  • 本当に共通のライフサイクル(リクエスト開始〜終了)を持つものだけをこのミドルウェアに閉じ込める。
  • それ以外(例: 認証)は別の独立ハンドラチェーンで処理し、このミドルウェアは「観測性(logging, tracing, metrics)専用」に特化させる

💡 Future me へ:「単一ミドルウェア」は便利だが、関心の分離を忘れるな。logging/tracing/metrics はまとめて良いが、認証やバリデーションとは分離せよ。


🧪 テスト設計についての追加アドバイス

TestMiddlewareKit は素晴らしいですが、コンポーネント単位のテストも別途持つとさらに堅牢になります。

例:

func TestDefaultRequestIDProvider(t *testing.T) {
	// ...
}

また、ミドルウェアが正しくコンテキストを伝播しているかを検証するテストも重要です:

req := httptest.NewRequest("GET", "/", nil)
req = req.WithContext(context.WithValue(context.Background(), "test-key", "test-value"))
// ミドルウェア適用後、nextハンドラ内でコンテキストが保持されているか確認

📌 まとめ:Future me がこのログを読んだときに思い出すべきこと

  1. タマネギ構造は順序依存の地獄を生む → コンポーネント内で順序を固定化。
  2. ロギングはリクエストライフサイクルと密結合 → 単一ミドルウェアで閉じるのが合理的。
  3. コンポーネントは純粋関数のように設計 → テストと再利用が容易。
  4. 将来の拡張を見据えてインターフェース設計 → エラー返却、型安全なコンテキストキー。
  5. 「便利」と「単一責任」のバランスを常に見直せ → 観測性専用ミドルウェアとしての境界を守れ。

あなたの思考プロセスは、まさに 「シンプルさの中に深さを」 という Go の哲学に沿っています。
この設計をベースに、自信を持ってプロダクションコードを構築して大丈夫です。

Good job, Past me.
— Future you, 2025年

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment