Go のエラーハンドリングとミドルウェア

Go の標準 net/http でハンドラを書いていると、エラー処理のコードが各ハンドラに散らばりがちになる。ステータスコードの設定、JSON レスポンスの構築、ログ出力――これらを毎回書くのは冗長だし、書き忘れによるバグの温床にもなる。ミドルウェアとエラー型の設計を組み合わせることで、この問題を構造的に解決できる。

典型的なハンドラの問題

まず、ミドルウェアを使わない素朴なハンドラを見てみよう。

func handleGetUser(w http.ResponseWriter, r *http.Request) {
	id := r.URL.Query().Get("id")
	if id == "" {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusBadRequest)
		json.NewEncoder(w).Encode(map[string]string{
			"error": "id is required",
		})
		return
	}

	user, err := findUser(id)
	if err != nil {
		log.Printf("failed to find user: %v", err)
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusInternalServerError)
		json.NewEncoder(w).Encode(map[string]string{
			"error": "internal server error",
		})
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(user)
}

Content-Type の設定、ステータスコードの書き込み、JSON エンコードという同じパターンが何度も繰り返されている。ハンドラが 10 個、20 個と増えていけば、このボイラープレートは膨大になる。

アプリケーションエラー型を定義する

まず、HTTP レスポンスに必要な情報を持つエラー型を定義する。

type AppError struct {
	Code    int    `json:"-"`
	Message string `json:"error"`
	Err     error  `json:"-"`
}

func (e *AppError) Error() string {
	if e.Err != nil {
		return e.Err.Error()
	}
	return e.Message
}

func (e *AppError) Unwrap() error {
	return e.Err
}

Code は HTTP ステータスコード、Message はクライアントに見せるメッセージ、Err は内部的な元エラーを保持するフィールドだ。json:“-” タグを付けることで、JSON エンコード時にステータスコードや内部エラーがレスポンスに含まれないようにしている。Unwrap() を実装しておけば、errors.Iserrors.As によるエラーチェーンの走査にも対応できる。

便利にエラーを生成するためのヘルパー関数も用意しておく。

func NewBadRequest(msg string) *AppError {
	return &AppError{Code: http.StatusBadRequest, Message: msg}
}

func NewNotFound(msg string) *AppError {
	return &AppError{Code: http.StatusNotFound, Message: msg}
}

func NewInternalError(err error) *AppError {
	return &AppError{
		Code:    http.StatusInternalServerError,
		Message: "internal server error",
		Err:     err,
	}
}

NewInternalError だけ元エラーを受け取る設計にしているのは、内部エラーの詳細はログに残したいがクライアントには見せたくないからだ。400 系のエラーはユーザー起因なので、メッセージをそのまま返しても問題ない。

error を返すハンドラ型を定義する

標準の http.HandlerFunc は戻り値がないため、エラーを返り値で表現できない。そこで、error を返す独自のハンドラ型を定義する。

標準の http.HandlerFunc

func(w http.ResponseWriter, r *http.Request) という署名で、戻り値がない。エラーはハンドラ内で直接レスポンスに書く必要がある

error を返すハンドラ型

func(w http.ResponseWriter, r *http.Request) error という署名で、エラーをミドルウェアに委譲できる

type AppHandler func(w http.ResponseWriter, r *http.Request) error

この型を http.Handler に変換するアダプタを書けば、エラー処理を一箇所に集約できる。

func (fn AppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if err := fn(w, r); err != nil {
		handleError(w, err)
	}
}

func handleError(w http.ResponseWriter, err error) {
	var appErr *AppError
	if !errors.As(err, &appErr) {
		appErr = NewInternalError(err)
	}

	if appErr.Err != nil {
		log.Printf("internal error: %v", appErr.Err)
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(appErr.Code)
	json.NewEncoder(w).Encode(appErr)
}

ServeHTTP メソッドを実装しているため、AppHandler は http.Handler インターフェースを満たす。ハンドラが error を返せば handleError が呼ばれ、AppError 型なら Code と Message をそのまま使い、それ以外の error なら 500 Internal Server Error として処理する。ログ出力も内部エラーがある場合だけ行われる仕組みだ。

ハンドラをシンプルに書く

この設計により、ハンドラはビジネスロジックだけに集中できるようになる。

func handleGetUser(w http.ResponseWriter, r *http.Request) error {
	id := r.URL.Query().Get("id")
	if id == "" {
		return NewBadRequest("id is required")
	}

	user, err := findUser(id)
	if err != nil {
		return NewInternalError(err)
	}

	w.Header().Set("Content-Type", "application/json")
	return json.NewEncoder(w).Encode(user)
}

冒頭の素朴な実装と比べると、エラー処理のボイラープレートがほぼ消えている。エラーが発生したら適切な AppError を return するだけでよく、ステータスコードの設定や JSON レスポンスの構築はミドルウェア側が担当する。

ルーティングの登録は以下のように行う。

mux := http.NewServeMux()
mux.Handle("GET /users", AppHandler(handleGetUser))

AppHandler は http.Handler を満たすので、標準の ServeMux にそのまま登録できる。

ログミドルウェアとの組み合わせ

エラーハンドリングのミドルウェアとは別に、リクエストのログを記録するミドルウェアもよく使われる。これらを組み合わせることで、横断的関心事をハンドラの外側に追い出せる。

func loggingMiddleware(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))
	})
}

ミドルウェアの適用は、ハンドラをラップする形になる。

mux := http.NewServeMux()
mux.Handle("GET /users", AppHandler(handleGetUser))

handler := loggingMiddleware(mux)
http.ListenAndServe(":8080", handler)

リクエストが来ると loggingMiddleware → AppHandler.ServeHTTP → handleGetUser という順で処理が流れる。handleGetUser がエラーを返せば ServeHTTP 内の handleError が処理し、ログミドルウェアはその外側でリクエストの所要時間を記録する。

リクエスト受信

loggingMiddleware(計測開始)

AppHandler.ServeHTTP(エラー処理)

ハンドラ本体(ビジネスロジック)

リカバリーミドルウェア

ハンドラ内で予期しない panic が発生した場合、サーバー全体がクラッシュするのを防ぐためにリカバリーミドルウェアを挟んでおくのが定石だ。

func recoveryMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if rv := recover(); rv != nil {
				log.Printf("panic recovered: %v\n%s",
					rv, debug.Stack())
				appErr := NewInternalError(
					fmt.Errorf("panic: %v", rv),
				)
				handleError(w, appErr)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

recover() で panic を捕捉し、スタックトレースをログに出力してから、クライアントには 500 エラーの JSON レスポンスを返す。先ほど定義した handleError をそのまま再利用できるのがポイントだ。

ミドルウェアの適用順は、リカバリーを最も外側に配置する。

handler := recoveryMiddleware(loggingMiddleware(mux))
http.ListenAndServe(":8080", handler)

最外層のリカバリーミドルウェアが panic を捕捉するため、内側のどの層で panic が起きてもサーバーは稳定して動き続ける。

ミドルウェアの適用順序

ミドルウェアは外側から内側に向かって適用される。処理の流れを意識して順序を決めることが重要になる。

リカバリー(最外層)

panic を捕捉してサーバーのクラッシュを防ぐ。他のすべてのミドルウェアやハンドラで発生した panic に対処するため、最も外側に置く。

ログ(中間層)

リクエストの開始と完了を記録する。エラーハンドリング層の外側に置くことで、エラーレスポンスの返却時間も含めた計測ができる。

エラーハンドリング(最内層)

ハンドラが返した error を HTTP レスポンスに変換する。ハンドラに最も近い位置に置くことで、ビジネスロジックからのエラーを直接受け取れる。

Gin を使う場合

標準ライブラリでの設計を理解しておけば、Gin のようなフレームワークでも同じ考え方を適用できる。Gin では c.Error() でエラーを蓄積し、ミドルウェアでまとめて処理するのが一般的なパターンになる。

func errorMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		c.Next()

		if len(c.Errors) == 0 {
			return
		}

		err := c.Errors.Last().Err
		var appErr *AppError
		if !errors.As(err, &appErr) {
			appErr = NewInternalError(err)
		}

		if appErr.Err != nil {
			log.Printf("internal error: %v", appErr.Err)
		}

		c.JSON(appErr.Code, appErr)
	}
}

Gin の場合、c.Next() を呼んだ後に c.Errors を確認することで、後続のハンドラで蓄積されたエラーをミドルウェアで処理できる。ハンドラ側では c.Error() を呼ぶだけでよい。

func handleGetUser(c *gin.Context) {
	id := c.Query("id")
	if id == "" {
		c.Error(NewBadRequest("id is required"))
		return
	}

	user, err := findUser(id)
	if err != nil {
		c.Error(NewInternalError(err))
		return
	}

	c.JSON(http.StatusOK, user)
}

標準ライブラリ版と Gin 版を比べると、エラー型(AppError)とヘルパー関数はそのまま流用し、ミドルウェアのフレームワーク固有部分だけを差し替えているのがわかる。設計の核となる部分をフレームワーク非依存に保っておくと、移行や併用も容易になる。