🐡

Goa のカスタムエラーを理解する

2022/12/15に公開

はじめに

これは Goa の HTTP トランスポートにおけるカスタムエラーについてまとめてみたものです。

フラー株式会社 Advent Calendar 2022 アドベントカレンダーで『Goa v3の気をつけポイント』という Goa の記事で、カスタムエラーについての話題がでていたのですが、Goa のカスタムエラー、やっぱわかりにくいよね・・・という感想はみんなが思ってるところではないかと思います。自分も使おうと思ったら毎回調べちゃうんで、ここいらでまとめてみてもいいかなと思った次第。

カスタムエラーとは何か?

Goa において、エラーとは、エラーレスポンスのことです。エラーが発生するタイミングは2つあり、それぞれについてカスタマイズが可能です。

  1. サービスメソッド(ビジネスロジック)内で返すエラー
  2. サービスメソッド(ビジネスロジック)に到達する前にバリデーションで返されるエラー

サービスメソッド内で返すエラー

サービスメソッド内で返すエラーは、基本的にあらかじめデザインで定義しておきます。サービスメソッドとは、いわゆるビジネスロジックのコードで、goa example したときにリポジトリ直下に生成される .go ファイル内の、ユーザーがビジネスロジックを実装する関数のことです。

デザインで定義されたエラーについては、エラー生成用の関数が生成されるので、それを呼んでやることでエラーを返すことが出来ます。

Error DSL で定義して、それを実際のレスポンスに関連付けて利用します(この辺の詳細は公式ドキュメントのエラーハンドリングに解説されています)。

var _ = Service("divider", func() {
    // "div_by_zero" をエラーとして宣言します
    Error("div_by_zero", func(){
        Description("ゼロ除算エラー")
    })

    Method("divide", func() {
        // ... snip ...
        HTTP(func() {
            POST("/")
            Response("div_by_zero", StatusBadRequest, func() {  // ← ゼロ除算は bad_request として "div_by_zero" エラーを返す
                Description("Response used for division by zero errors")
            })
        })
    })
})

このエラーは、サービスごとに専用の生成関数が用意されるので、それを利用してサービスメソッド内でエラーを生成して返します。

func (s *calcsrvc) Divide(ctx context.Context, p *calc.DividePayload) (res *calc.DivideResult, err error) {
    if p.Divisor == 0 {
        return nil, calc.MakeDivByZero(fmt.Errorf("cannot divide by zero")) // ← MakeDibByZero という生成用の関数が用意されます
    }
    // ...
}

このエラーはデザインで、bad_request として定義されているので、ステータスコード 400 でレスポンスされます。この時のレスポンスボディは Goa の標準のエラー形式で返されます(ちなみに、デザインで定義されてないエラーを返すと自動的に 500 InternalServerError としてレスポスされます)。

上の例で、実際に返されるのは下記のようなレスポンスボディになります。

{
  "name": "DivByZero",
  "id": "jrCxFDGJ",
  "message": "cannot divide by zero",
  "temporary": false,
  "timeout": false,
  "fault": false
}

ここで、namemessage 以外の項目は Goa が用意したものになっています(これらの項目の値をセットする方法についてはここでは説明しませんが、気になる方は公式のドキュメントを参照してください)。エラーについて、何か情報が欲しいとき、また、何か決まった形式を返す必要があるとき、この形式では困ることがあります。

その場合、エラーをデザインで定義することが出来ます。1つだけ約束があって、ErrorName でアトリビュートを指定して、それを必須要素にする必要があります。このアトリビュートは、Goa がエラーがデザインで指定されたどのエラーであるかを判別するのに使われます(つまり以下で定義するのはエラーの形式だけで、色々なエラーに流用してもよいということです)。

// 自分でカスタマイズしたエラーを設計できます。ここでは Type で定義していますが、Content-Type も指定したければ ResultType を利用するとよいでしょう
var CustomError = Type("CustomError", func() {
    ErrorName("name")
	Attribute("arg1", Int)
	Attribute("arg2", Int)
	Attribute("description",String)
	Required("name", "arg1", "arg2","description")
})

var _ = Service("divider", func() {
    // "div_by_zero" をエラーとして宣言します
    Error("div_by_zero", CustomError, func(){ // ← さっきは指定していなかった型を自分で定義したもを指定します
        Description("ゼロ除算エラー")
    })
... snip

こうすると、レスポンスは、

curl localhost:8000/div/2/0|jq .
{
  "name":"div_by_zero",
  "arg1": 2,
  "arg2": 0,
  "description": "ゼロで割るとエラーになります"
}

のようになります ╭( ・ㅂ・)و ̑̑ グッ !

ただし、エラーの形式をカスタマイズした場合には、エラー生成用の関数を Goa が用意してくれないので、サービスメソッドで直接、エラーを指定する必要があります(構造体自体は gen の下に生成されてます)。

func (s *calcsrvc) Divide(ctx context.Context, p *calc.DividePayload) (res *calc.DivideResult, err error) {
    if p.Divisor == 0 {
    	return 0, &calc.CustomError{
            Name:        "div_by_zero", // ← エラーの種別を定義します(デザインで指定したもの)
            Arg1:        2,
            Arg2:        0,
            Description: "ゼロで割るとエラーになります",
	    }
    }
    // ...
}

サービスメソッドに到達する前にバリデーションで返されるエラー

ところで、デザインでエラーをカスタマイズできることは分かりましたが、サービスメソッドに到達する前にバリデーションなどで返されるエラーはどうやったらカスタマイズできるでしょうか?

これは、少々めんどくさいですが、やり方が分かっていればそんなに混乱もないです。それはエラー用のフォーマッタを用意することです(これらはサービスメソッドとは関係ないコードなので、どこかにまとめておくといいかもしれません)。

簡単に、エラーコードとメッセージを返すようにカスタマイズしてみます。

  • まず、エラーの形式を用意します(例では MyErrorResponse
  • 次に、エラーを生成する関数を用意します
  • 最後に、エラーと紐付くレスポンスコードを Goa に通知するために StatusCode() int というインターフェースを実装します
type MyErrorResponse struct {
    Code    int
    Message string
}

func NewMyErrorResponse(err error) goahttp.Statuser {
    if gerr, ok := err.(*goa.ServiceError); ok { // ← Goa で発生するバリデーションのエラーとかはここに入るはず
        return &MyErrorResponse{
            Code:    123,
            Message: gerr.Message,
        }
    }
    return &MyErrorResponse{ // ← Goa が知らないエラーのとき
        Code:    666,
        Message: err.Error(),
    }
}

func (resp *MyErrorResponse) StatusCode() int { // `Code` の値に応じてレスポンスコードをふるい分ける
    if resp.Code == 666 {
        return http.StatusInternalServerError
    }
    return http.StatusBadRequest
}

これを、サーバにセットします。セットする場所は、cmd/<service>/http.go の中の Server を作成してる New の引数です。calc の例で説明すると、以下です。

// Wrap the endpoints with the transport specific layers. The generated
    // server packages contains code generated from the design which maps
    // the service input and output data structures to HTTP requests and
    // responses.
    var (
        calcServer *calcsvr.Server
    )
    {
        eh := errorHandler(logger)
        calcServer = calcsvr.New(calcEndpoints, mux, dec, enc, eh, nil) // ← ★ ここ!
        if debug {
            servers := goahttp.Servers{
                calcServer,
            }
            servers.Use(httpmdlwr.Debug(mux, os.Stdout))
        }
    }

修正後は、次のようになります。

 calcServer = calcsvr.New(calcEndpoints, mux, dec, enc, eh, NewMyErrorResponse) // ← ★ ここ!

動作を検証する

calc で実験してみましょう。

修正前:

$ curl -X GET localhost:8000/multiply/a/b| jq .
{
  "name": "invalid_field_type",
  "id": "Em9NSWp6",
  "message": "invalid value \"a\" for \"a\", must be a integer; invalid value \"b\" for \"b\", must be a integer",
  "temporary": false,
  "timeout": false,
  "fault": false
}

修正後:

$ curl -X GET localhost:8000/multiply/a/b| jq .
{
  "Code": 123,
  "Message": "invalid value \"a\" for \"a\", must be a integer; invalid value \"b\" for \"b\", must be a integer"
}

おわりに

サービスメソッド内で発生するエラーと、サービスメソッドに到達する前に発生するエラーについてカスタマイズする方法を紹介しました。サービスメソッドに到達する前に発生するエラーについては、デフォルトのままでも適当にやれることが多いのでここまでカスタマイズすることは無いかもしれませんが、知っておくといいことがあるかも知れません。

サービスメソッドで発生するエラーのカスタマイズについては、適宜デザインで調整できますでの、用途に応じて柔軟に編集できると思います。

ややこしいですが、ややこしいですね 😇

Happy hacking!

Discussion