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.Is や errors.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 を返す独自のハンドラ型を定義する。
func(w http.ResponseWriter, r *http.Request) という署名で、戻り値がない。エラーはハンドラ内で直接レスポンスに書く必要がある
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)とヘルパー関数はそのまま流用し、ミドルウェアのフレームワーク固有部分だけを差し替えているのがわかる。設計の核となる部分をフレームワーク非依存に保っておくと、移行や併用も容易になる。