技術メモ

技術メモ

ラフなメモ

APIサーバのおけるGoのエラーハンドリングについて考えてみる

Go のエラーハンドリングについて考えてみます。ここではライブラリの中で扱うようなエラーハンドリングではなく、Web アプリケーション、とりわけ API サーバとして振る舞うようなアプリケーションでのエラーハンドリングについて考えます。前提として Error handling and Go は読んでいるものとします。また標準ライブラリで構成するサーバを想定し、WAF を用いたハンドリング方法は紹介しません。

例として、以下のような 2 つのパスを持つ API サーバを考えます。

  • /user/create
  • /user/delete
package main

import (
    "database/sql"
    "log"
    "net/http"
)

type UserHandle struct {
    db *sql.DB
}

func (u *UserHandle) Create(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Create"))
    // ハンドラの処理
}

func (u *UserHandle) Delete(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Delete"))
    // ハンドラの処理
}

func NewUserHandle(db *sql.DB) *UserHandle {
    return &UserHandle{db: db}
}

func RegisterUserHandler(db *sql.DB, prefix string, mux *http.ServeMux) {
    u := NewUserHandle(db)
    mux.HandleFunc(prefix+"/create", u.Create)
    mux.HandleFunc(prefix+"/delete", u.Delete)
}

func main() {
    mux := http.NewServeMux()
    var db *sql.DB // db のセットアップはしてある想定
    RegisterUserHandler(db, "/user", mux)
    if err := http.ListenAndServe(":8080", mux); err != nil {
        log.Fatal(err)
    }
}

このような場合、エラーのハンドリングが冗長になる傾向があります。Create メソッドの処理を考えます。Create メソッドの中でクライアントに返却するエラーとロギングをする必要があります。ロギングは任意ですが、通常の場合必要です。

func (u *UserHandle) Create(w http.ResponseWriter, r *http.Request) {
    err := Hoge()
    if err != nil {
        writeLog()
        http.Error(w, fmt.Sprintf("...: %w", err) , 400)
    }

    err := Foo()
    if err != nil {
        writeLog()
        http.Error(w, fmt.Sprintf("...: %w", err) , 500)
    }
    
    err := Hoo()
    if err != nil {
        writeLog()
        http.Error(w, fmt.Sprintf("...: %w", err) , 500)
    }
}

あっという間に似たようなエラーハンドリングの実装が並んでしまい、メンテンスが悪くなってしまいます。これを回避するためには以下のようにハンドラをラップする層が必要になります。

Create メソッドを CreateHandler という名前に変更しておきましょう。

実際のビジネスロジックを担う Create メソッドのシグネチャを以下のように変更して、Create メソッドから error を返せるようにします。

-func (u *UserHandle) Create(w http.ResponseWriter, r *http.Request) {
+func (u *UserHandle) Create(w http.ResponseWriter, r *http.Request) error {
    // ...
}

このようにすると実装は以下のようにエラーを呼び出し元に返すことができます。

func (u *UserHandle) Create(w http.ResponseWriter, r *http.Request) error {
    err := Hoge()
    if err != nil {
        return err
    }

    err := Foo()
    if err != nil {
        return err
    }
    
    err := Hoo()
    if err != nil {
        return err
    }
}

このとき CreateHandler は以下のような実装になります。

func (u *UserHandle) CreateHandler(w http.ResponseWriter, r *http.Request) {
    err := u.Create(w, r)
    if err != nil {
        writeLog()
        http.Error(w, fmt.Sprintf("...: %w", err) , 500)
    }
}

さて、CreateHandler では、Create メソッドのエラーの種類に応じて、クライアントに返却するレスポンスコードを変える必要があります。error の値に応じて処理を分岐するイディオムが Go にはあって、errors.As を用いると良いです。(Go1.13 以前の場合は Error handling and Go にもあるように型アサーションをしていました。)そのためにアプリケーション独自のエラーを定義しておきましょう。

error は以下のように定義できるでしょう。struct に保持するフィールドは必要に応じて増えていきます。例えばスタックトレースの情報など。今回は単なる error のフィールドを埋め込んでおくだけにします。error インターフェースを埋め込むことでエラーの構造体を生成できます。AppError インターフェースは error インターフェースを満たしています。

type AppError interface {
    error
    Code() string
}

type XError struct {
    Err error
}

func (e *XError) Error() string {
    msg := "x error"
    if e.Err != nil {
        return msg + ": " + e.Error()
    }
    return msg
}

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

func (e *XError) Code() string {
    return "XXX"
}

type YError struct {
    Err error
}

func (e *YError) Error() string {
    msg := "y error"
    if e.Err != nil {
        return msg + ": " + e.Error()
    }
    return msg
}

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

func (e *YError) Code() string {
    return "YYY"
}

Create メソッドからはアプリケーション独自のエラー型にラップして、呼び出し元にエラーを返却します。

func (u *UserHandle) Create(w http.ResponseWriter, r *http.Request) error {
    err := Hoge()
    if err != nil {
        return &XError{err}
    }

    err := Foo()
    if err != nil {
        return &XError{err}
    }
    
    err := Hoo()
    if err != nil {
        return &YError{err}
    }

    return nil
}

そうすると呼び出し元のハンドラでは err 変数について errors.As でエラーの型を検査し、呼び出し元からのエラー型に応じた処理ができます。

func (u *UserHandle) CreateHandler(w http.ResponseWriter, r *http.Request) {
    err := u.Create(w, r)
    if err != nil {
        writeLog()
        var xe *XError
        if errors.As(err, &xe) {
            http.Error(w, fmt.Sprintf("...: %w", err) , 400)
            return
        }
        var ye *YError
        if errors.As(err, &ye) {
            http.Error(w, fmt.Sprintf("...: %w", err) , 500)
            return
        }

        // application unknown error
        http.Error(w, fmt.Sprintf("...: %w", err) , 500)
        return
    }
}

サマリ

  • エラーを扱うハンドラを用いることで、ハンドラからエラーを返すことができる
  • 呼び出し元となるハンドラではエラーを errors.As でチェックすることで、エラーに応じた処理ができる

このようにラップするハンドラを用意するくらいであれば WAF を用いたほうが良いのでは、という声もあると思います。ここでは紹介しませんでしたが、プロジェクトに応じて WAF を導入していることも多いと思います。その場合は WAF の慣習に従うのが良いでしょう。このようなエラーハンドリング方法も役に立つかもしれません。