Go実践 #5 ミドルウェアパターン

読了 6分

#4 DB連携で内側のデータを扱ったなら — 今度は再び外側。リクエストとレスポンスの間の共通処理

Goのミドルウェアは — 他言語のミドルウェアと本質は同じですが、言語が別途サポートする概念ではなくhttp.Handlerインターフェースだけで表現されるのが特徴です。シンプルなぶん — 強力です。

ミドルウェアの形 — Handler → Handler #

ミドルウェアのシグネチャ
type Middleware func(http.Handler) http.Handler

関数1つが — ハンドラを受けて新しいハンドラを返す。その中で元のハンドラを呼ぶ前後に仕事をします。

最小の例 — ロギング #

ロギングミドルウェア
func logger(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 %s", r.Method, r.URL.Path, time.Since(start))
	})
}

中核:

  • http.HandlerFuncアダプタで関数をHandlerに変換
  • next.ServeHTTP(w, r)が実際のハンドラ呼び出し — その前後にロジック

適用 #

登録
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", helloHandler)

handler := logger(mux)    // mux全体をロギングで包む

http.ListenAndServe(":8080", handler)

mux自体もhttp.Handler — ミドルウェアで囲むのが自然です。

チェーン #

複数のミドルウェアを — 関数合成のように。

チェーン
handler := logger(recoverer(authMiddleware(mux)))

順序: logger → recoverer → auth → mux。内側が先に実行されるのではなく — 外側が先にServeHTTPに入る。呼び出しフローは:

  1. logger.ServeHTTP開始(start記録)
  2. recoverer.ServeHTTP開始(defer recover)
  3. auth.ServeHTTP開始(認証検査)
  4. mux.ServeHTTP(実際のルーティング)
  5. ← レスポンス後に順番に抜ける(ログ記録など)

関数合成の形なので — 順序が重要。通常outerからinner: ログ → 復旧 → 認証 → ルーティング。

チェーンヘルパー #

手でネストすると可読性が落ちます。ヘルパー1つで平たく。

チェーン関数
type Middleware func(http.Handler) http.Handler

func chain(h http.Handler, mw ...Middleware) http.Handler {
	for i := len(mw) - 1; i >= 0; i-- {
		h = mw[i](h)
	}
	return h
}

handler := chain(mux, logger, recoverer, authMiddleware)

リストの前方が — 外側(先に実行)。

標準ミドルウェア集 #

1) Recoverer — パニック復旧 #

Recoverer
func recoverer(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				log.Printf("panic: %v\n%s", err, debug.Stack())
				http.Error(w, "internal error", http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

ハンドラでpanicが起きたら — サーバーが死なないように捕まえて500レスポンス。すべてのハンドラを包む最も外側のミドルウェアの1つ。

2) Request ID #

リクエストID
type ctxKey string

const reqIDKey ctxKey = "reqID"

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.New().String()
		}
		w.Header().Set("X-Request-ID", id)
		ctx := context.WithValue(r.Context(), reqIDKey, id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func RequestIDFrom(ctx context.Context) string {
	if v, ok := ctx.Value(reqIDKey).(string); ok {
		return v
	}
	return ""
}

分散システムで — 1つのリクエストを追跡するための標準。contextに入れておいて — ログに含めます。

中級#5のctx.Valueが似合う場面の中で最も一般的なケース。

3) レスポンスステータスのキャプチャ — accesslog拡張 #

デフォルトのResponseWriterは — レスポンスステータスを記憶しません。ログにステータス/サイズを入れたいなら小さなラッパー。

status capture
type statusRecorder struct {
	http.ResponseWriter
	status int
	bytes  int
}

func (r *statusRecorder) WriteHeader(code int) {
	r.status = code
	r.ResponseWriter.WriteHeader(code)
}

func (r *statusRecorder) Write(b []byte) (int, error) {
	if r.status == 0 {
		r.status = http.StatusOK
	}
	n, err := r.ResponseWriter.Write(b)
	r.bytes += n
	return n, err
}

func accessLog(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		rec := &statusRecorder{ResponseWriter: w}
		start := time.Now()
		next.ServeHTTP(rec, r)
		log.Printf("%s %s %d %dB %s",
			r.Method, r.URL.Path, rec.status, rec.bytes, time.Since(start))
	})
}

このパターンが — chi、ginのようなフレームワークの中にも同じようにあります。自分で書いてみたなら、そのコードも親しみやすくなります。

4) 認証 #

認証ミドルウェア
type userKey struct{}

func authMiddleware(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
		}

		user, err := verifyToken(token)
		if err != nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}

		ctx := context.WithValue(r.Context(), userKey{}, user)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func UserFrom(ctx context.Context) (User, bool) {
	u, ok := ctx.Value(userKey{}).(User)
	return u, ok
}

ctxキーは — 外部に公開されない非公開型で。他パッケージの同名stringキーと衝突しません。

5) CORS #

シンプルなCORS
func cors(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Access-Control-Allow-Origin", "*")
		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

		if r.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}

		next.ServeHTTP(w, r)
	})
}

プロダクションでは — Origin検証、credentials処理などがさらに入ります。rs/corsパッケージが標準に近く処理。

6) Rate limit #

シンプルなrate limit
import "golang.org/x/time/rate"

func limit(next http.Handler) http.Handler {
	limiter := rate.NewLimiter(10, 30)    // 10 req/sec, burst 30
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !limiter.Allow() {
			http.Error(w, "too many", http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}

サーバー全体単位。ユーザー別に制限するなら — IP/ユーザーID別のlimiter map。大きなトラフィックならRedisベースの分散limiterを検討。

ルート単位ミドルウェア — 標準ServeMuxの限界 #

標準ServeMuxは — ルート単位でミドルウェアを挟むヘルパーがありません。手で包む形。

個別ハンドラに適用
mux.HandleFunc("GET /admin", authMiddleware(http.HandlerFunc(adminHandler)).ServeHTTP)

ルートが多くなるとボイラープレートが気になる — chiのようなルーターのr.Useが自然になる場面。

chiのルート単位
r.Group(func(r chi.Router) {
	r.Use(authMiddleware)
	r.Get("/admin", adminHandler)
	r.Get("/me", meHandler)
})

#2のルーター選択トレードオフと同じ文脈。

標準パターン集 #

典型的なサーバーのミドルウェアスタック:

実際のサーバー構成
handler := chain(mux,
	requestID,           // 1) リクエストIDを付与
	accessLog,           // 2) アクセスログ
	recoverer,           // 3) パニック復旧
	cors,                // 4) CORSヘッダー
	limit,               // 5) Rate limit
	// authはルーター別に
)

順序は — recovererが内側にあったらいいけれど、それだとその上のミドルウェアのpanicが捕まえられません。ログ → 復旧 → その他の順序が普通。

落とし穴 — r.WithContextを渡さない #

よくある落とし穴
func mw(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := context.WithValue(r.Context(), key, val)
		next.ServeHTTP(w, r)        // ✗ 新しいctxを渡さない
	})
}

r.WithContext(ctx)で — 新しいRequestを作って渡す必要があり、コンテキスト値が伝わります。

正しく
next.ServeHTTP(w, r.WithContext(ctx))

落とし穴 — next.ServeHTTPの漏れ #

条件付き認証のような場面で — 認証失敗ならレスポンスを送ってreturn。認証成功ならnext.ServeHTTPを呼ぶ。2つの経路が混乱すると — レスポンスが2回出るかまったく出ないバグ。

ミドルウェアのよいデザイン #

  • 単一責任 — 1つのミドルウェアに1つの仕事
  • 順序の意図を明示 — コメントで書いておく
  • 共有状態を最小化 — 可能ならctxだけを通す
  • エラーはレスポンスで表現 — ミドルウェア自体がpanicしないように
  • ミドルウェアもテストhttptestでシンプルに検証

まとめ #

この記事で整理した内容:

  • ミドルウェア = func(http.Handler) http.Handler
  • ロガー、復旧、リクエストID、認証、CORS、rate limit — 標準ビルディングブロック
  • チェーンヘルパー — 平たい登録
  • 順序 — 外側が先に実行、通常ログ → 復旧 → その他
  • r.WithContextを忘れない
  • status recorderでレスポンスステータス/サイズをキャプチャ
  • 標準muxはルート単位ミドルウェアが弱い — chiが滑らか

次の記事(#6 テストとデプロイ)では — httptestでハンドラテスト、Dockerマルチステージビルド、そして小さなサーバーを運用環境に乗せる標準パターンを整理します。

X