Goのエラーハンドリングについて現時点での方針を書いてみる

April 25, 2022

(追記) こちら の記事でエラーハンドリングについて考え直しました. この記事の内容には現在推奨されない内容も含まれています.

はじめに

Go では関数がエラーを返すたびに,明示的にエラーハンドリングをどうするか示さなければなりません. エラーハンドリングに関して(だけではないと思いますが)かなりスパルタな言語だと言えそうです.

私は Go 言語をそれなりに気に入っているので例にもれずエラーハンドリングについてどうするか迷ったのですが,自分の中で納得できる書き方を確立できたので記しておこうと思います.

要点

  • 必要があれば xerrors.New()を用いてエラーを定義 (sentinel pattern)
  • 上位レイヤに返す場合は xerrors.Errorf(), %w を用いて追加情報を付与する
  • 最上位レイヤでフォーマット文字列 %+v を用いて出力する

独自エラーを定義(必要であれば)

エラーの種類によって処理を分岐させたい場合に,ある程度の粒度まで抽象化されたエラーが欲しくなることがあります. また,特に一般に公開するライブラリなどでは,エラーを変数として公開することで呼び出し元がエラーを区別してハンドリングできるようになります. 具体例としては,「リクエストされたデータが見つからなかった場合には 404 を返したい」というケースが挙げられます.

リクエストされたデータが存在しなかった場合のエラー

var (
  ErrNoSuchData = xerrors.New("Requested data is not found")
)

データストアが 1 種類だけではないこともあります. また,インフラ層とコントローラ層が別れている際にも,コントローラ層にインフラ層の情報を持ち込むのは結合度が高くなってしまうためあまり良いとはいえません. 「該当するデータが見つからなかった」ことを上位層で判断できればよいため,抽象化したエラーを定義し,それを返すようにします.

receiver が nil だった場合のエラー

var (
  ErrNilReceiver = xerrors.New("Receiver is nil")
)

レシーバが nil であることを判別するためのエラーです.

このページでも言及があるとおり,定義した struct が public の場合,別の struct に埋め込むことができます. そのため,struct が 0 値で初期化されるということが起こりえます. したがって,struct が正常に動作するために満たすべき条件を満たしているかの確認は必要です. 自分以外が使うことを想定していない API を作る場合は書き手(自分)の問題になる場合がほとんどですが,他の文脈では必要になる知識だと思っています.

追加情報を付与する

fmt.Errorf() では Error() で取得できる文字列に情報を付与することができますが,スタックトレースを付与することができません. スタックトレースを付与できるのも golang.org/x/xerrors パッケージを使用する理由の 1 つです.

func (r *HogeRepository) GetHoge(hogeID int) (*Hoge, error) {
  hoge, err := r.DB.Get()
  if err != nil {
    if err == sql.ErrNoRows {
      // 「該当するデータが見つからなかった」ことを上位層で判断するため,抽象化したエラーを返す
      return nil, xerrors.Errorf("Hoge with ID(%d) is not found: %w", hogeID, model.ErrNoSuchData)
    }
    // 上位層で判断する必要の無いエラーはそのまま追加情報を付与して返す
    return nil, xerrors.Errorf("Failed to get Hoge with ID(%d): %w", hogeID, err)
  }
}

すべてを抽象化したエラーで返してしまったほうがまとまりのあるコードにはなるかもしれませんね.

ハンドラなどでエラーを判別する際には標準パッケージ(errors)の errors.Is() を使用します.

if errors.Is(err, model.ErrNoSuchData) {
  // ...
}

エラーの種類によって HTTP レスポンスコードなどを使い分けます.

フォーマット文字列を使用して出力する

出力する際にはフォーマット文字列 %+v を用いて出力します. 出力はこのようになります. スタックトレースが出力できています.

Task with ID(3) is not found:
    ratri/handler.(*Task).FetchTaskInfo
        /Users/ushmz/src/github.com/ushmz/ratri/app/handler/task.go:57
  - Failed to get task information:
    ratri/usecase.(*TaskImpl).FetchTaskInfo
        /Users/ushmz/src/github.com/ushmz/ratri/app/usecase/task.go:36
  - Task with given ID(3) is not found:
    ratri/infra/mysql.(*TaskRepositoryImpl).FetchTaskInfo
        /Users/ushmz/src/github.com/ushmz/ratri/app/infra/mysql/task.go:45
  - Requested data is not found:
    ratri/domain/model.init
        /Users/ushmz/src/github.com/ushmz/ratri/app/domain/model/error.go:15

ただ,「本当にスタックトレースの情報は必要なのか?」という問いには自信を持って答えられません. まだまだ実践的な経験が足りていません. また,カスタムエラーをどこに定義するか?も考えどころです.