Golangのエラー処理とpkg/errors

GoConでは毎回エラー処理について面白い知見が得られる.Go Conference 2014 autumn においては(実際のトークではないが)居酒屋にて@JxckさんがRob Pike氏から以下のようなテクニックを紹介してもらっていた.

これはWrite(やRead)のエラー処理が複数続く場合にerrWriter を定義して複数のエラー処理を一箇所にまとめてコードをすっきりとさせるテクニックであった.

そして今回の Go Conference 2016 spring のkeynoteにおいてもDave Cheney氏から(僕にとっては)新たなエラー処理テクニックが紹介された.

実際に使ってみて/コードを読んでみて(飲み会でもコードとともにいろいろ教えてもらった)自分の抱えている問題を解決できそうで使ってみたいと思えた.

本記事では現在のエラー処理の問題と発表で紹介されたpkg/errorsについてまとめる.なお上記のスライドにはトークノートも書かれているので具体的な内容はそちらを見るのが良い.

問題

@Jxckさんのケースは1つの関数において複数のエラーハンドリングが煩雑になる,言わば縦方向のエラー処理の問題であった.Dave氏のトークで語られているのは深さ方向のエラー処理の問題である.大きく分けて2つの問題がある.

  • 最終的に表示されるエラーメッセージ
  • 特定のエラーに対する分岐処理

以下ではそれらを具体的に説明する.

エラーメッセージ

まずはエラーメッセージについて.以下は基本的なGoのエラー処理である.

func Foo() error {
    conf, err := ReadConf()
    if err != nil {
        return err
    }
    ...
    return nil
}

Foo()ReadConf()を呼び,ReadConf()がエラーを返せばそれをerrとして返し,そうでなければconfをつかった処理を続行し問題がなければnilを返す.

大きなパッケージやツールになるとこの定型的な処理はどんどん連なり深くなる.例えばこの例の場合はReadConf()がさらにWrite()といった標準パッケージの関数を呼びそのエラーを返すかもしれないし,Foo()は別の関数から呼ばれその中でエラーが処理されるかもしれない.

これらの一連のエラーは最終的にどうなるか? コマンドラインツールやWebサーバーのmain()に戻り以下のようにfmt.Printf()(やlog)を使って適切なエラーメッセージとしてユーザに表示する(”べき”である).

fmt.Printf("Failed Foo: %s",err)

この単純なreturn errの連鎖は問題を起こす.最終的にユーザに表示されるエラーメッセージにはその後のデバッグに対してなんの情報も提示できないことがある.つまりどの関数のどこでエラーが発生したのか追えなくなる.例えば発表でも触れられていたようにno such file or directoryのみ表示されるケースに出会った人は多いと思う.他にもGoのツールだとTLSに関わるエラーなどで困ったひとは多いと思う(これはググるとDockerのGithub Issueが最初に現れるw).

この問題に対してできることはfmt.Errorf()を使って具体的なエラーの状況を付加することである.

func Foo() error {
    conf, err := ReadConf()
    if err != nil {
        return fmt.Errorf("failed to read configuration file: %s")
    }
    ...
    return nil
}

これはよくPR reviewで指摘することだと思う.これで最終的に提示されるエラーはよりデバッグのしやすいものになる.

しかしfmt.Errorf()errorを別のstringに結合して別のerrorをつくり出す.原因となったエラーが特定の型を持っていた場合にそれを隠蔽してしまう.これによりfmt.Errorf()は次に説明する呼び出し元での分岐処理を難しくする.

分岐処理

次に特定のエラーに対する呼び出し元での分岐処理の問題について説明する.関数の呼び元において特定のエラーが返ってきたときに単純にそれを返す,もしくはユーザに表示するのではなく,別の処理をしたいという場合がある.例えばリトライ処理を行うなど.

これは様々な方法がある.が,以下は避けるべきである.

  • error.Error()して中身をみてstringとして使う
  • Sentinel error(io.EOFなど)を使う
  • 自分でError typeを定義してType assertionする

1つ目は初心者がやりがちだが最も避けるべき方法である.他の2つを避ける理由は呼び出しに別の依存をもたらすことになるのが大きな理由(スライドにはもっと詳しい例があるのでそちらを見るとよい).無駄なCouplingは避ける.基本的には単純なerrorを返すというパッケージ間のContractを破るべきではない.

ではどうするのが良いか? 型ではなくインターフェースを考える(Assert errors for behaviour, not type).以下のようにする.

type temporary interface {
    Temporary() bool

}

func IsTemporary(err error) bool {

   te, ok := err.(temporary)

   return ok && te.Temporary()

}

errtemporaryインターフェースを実装していればTemporary()関数で特別な分岐処理をするべきか(例えばリトライするべきか)どうかを判別して返す.していなければ無関係なエラーとして単純にfalseを返し特別な処理分岐をスキップする.これは無駄な依存やCouplingを避けることができる.

しかしこの方法と上述したfmt.Errorf()によるエラーメッセージ問題を同時に解決することはできない.fmt.Errorf()がエラーを作り直してしまうからである(元のエラーがインターフェースを持っていたか追えなくなる).

errorsを使う

errors

errorsは上記で説明したエラーメッセージ問題を良い感じに解決しつつ処理分岐にも対応する.errors パッケージは以下のような関数を持つ.

func Wrap(cause error, message string) error
func Cause(err error) error

Wrap()

まずWrap()はオリジナルのerrorを具体的なエラーの状況(message)とともにラップした新たなerrorを返す.

conf, err := ReadConf()
if err != nil {
    return errors.Wrap(err, "failed to read configuration file")
}

このError()の結果は以下のerrorと同じである.

fmt.Errorf("failed to read configuration file: %s")

関数のインターフェースとしてそのエラーのコンテキスト(annotation)を要求するのがよい.最終的に出力されるエラーメッセージはfmt.Errorf()と同じ結果になりエラー処理の方法も変わらない.

これだけではなくWrap()は呼ばれたファイルとその行数も同時に内部に記録する.同パッケージのPrintFprint)を使うと以下のような詳細なエラーメッセージを表示することができる.

err := fn()
errors.Fprint(os.Stderr, err)
read.go:3: A.conf is not exist
conf.go:35: failed to read configuration
main.go:100: Failed to run fn()

これは単純にfmt.Errorfを使うより便利なので移行の理由になる.

Cause()

次にCause()は元となったエラーをそのまま取り出す.fmt.Errorfはコンテキストを付与できる一方で新しいエラーを返してしまうために呼び出し元での処理の分岐がやりにくくなってしまった.Cause()は以下のインターフェースをerrorに持たせることで元となったerrorを取り出す関数である.

type Causer interface {
    Cause() error
}

これを使うと上記のtemporaryインターフェースの例は以下のように書ける.

func IsTemporary(err error) bool {

    te, ok := errors.Cause(err).(temporary)
    return ok && te.Temporary()

}    

もちろんWrap()はデフォルトで元のerrorを保持し,かつCauserインターフェースを満たすのでCause()をそのまま使える.

まとめ

errorsを使うとエラーにコンテキストを付与しつつ,オリジナルのエラーを保持し呼び出し元において処理の分岐を行うことができる.かつerrorを介すので標準的なContractから外れることもない.

Canonicalのいくつかのツールでも使われている(jujuも似たパッケージjuju/errorsを使っている)らしい.そしてerrorsはそれらのシンプル版とのこと.

こういう標準パッケージに則した薄いライブラリはとても好きなので使っていきたい.