Overview
- Go 1.20 から errors パッケージに導入された
errors.Join
は複数のエラーを詰め込んでも詰め込んだエラーそれぞれを Unwrap して取り出せる。 - fmt.Errorf でも複数のエラーを wrap できるようになっている。
- 複数のエラーを wrap してもそれぞれ unwrap して取り出せるので Is 判定を使うことができる。
errors.Join について
Go1.20 から導入された機能です。
以下のフューチャーさんのブログで丁寧に解説されてますが、error のラップの方法がちょっと便利になる関数になります。
もともと hashcorp が go-multierror というマルチエラーを生成するライブラリを出していましたが、これを公式が実装したような感じかなと理解しました。
具体的には以下のようなコードにおいて、error を1つのエラーにまとめつつ、その先で特定のエラーを unwrap で取り出して検査することが可能になります。
package main import ( "context" "errors" "fmt" ) func main() { err1 := errors.New("err1") joinedErr := errors.Join(err1, context.Canceled) fmt.Println(errors.Is(joinedErr, context.Canceled)) }
ref: https://go.dev/play/p/XC0MXYbZg0a
これでわかるように Join と言ってるので連結してるのか、配列に追加してるのか、といった印象を若干持ってしまいますが、実態としては wrap をしています。
これは実際の実装を見るとわかり易なと思いました。
現時点における errors.Join の実装は以下なのですが、これを見ると Join といいつつ、参照を入れ子にしていっているのがわかります。
https://cs.opensource.google/go/go/+/refs/tags/go1.20:src/errors/join.go;l=13;bpv=1;bpt=0
ひたすら joinError
型の持つ errors の配列につ生かしていってる感じですね。
例えば今までだと loop の中で連続した処理をする(外部API を叩いたり)場合に、error の slice を作成したりして、loop の中で逐一エラーをハンドリングしたりせず、発生したエラーをその配列に貯めておいて、後でまとめてエラーをハンドリングする、といった処理があったかと思いますが、 errors.Join
を使うと追加するだけでなく、追加したエラーの中で Unwrap してみて特定のエラー型のエラーが有った場合のみ例外処理を入れる、みたいなちょいとリッチなハンドリングを入れることも可能です(なお、errors.Join
の繰り返しは単純な error slice への append よりも多少コストの掛かる処理になります。後述)
また errors.Join
を利用したケースでは以下のようにエラー文言の出力に改行が入ることになるので、wrap する方向を決めておけば、Layered Architecture を採用してるコードベースなどで下層からエラーを伝播させてくるときに、どこでどんなエラーが発生したのかを追跡するのに多少寄与することもあるかもしれません。とはいえ、こういったことは実装方法に依存するので、こうすればいい感じになる、といったプラクティスは自分でも今現在持ち合わせてはいないんですが...。
package main import ( "errors" "fmt" ) func main() { var errs error var errlist []error e1 := errors.New("1") e2 := errors.New("2") e3 := errors.New("3") for _, e := range []error{e1, e2, e3} { errs = errors.Join(errs, e) errlist = append(errlist, e) } fmt.Printf("%v\n", errs) fmt.Printf("%v\n", errlist) }
ref: https://go.dev/play/p/-35UMpxZ9B3
パフォーマンスについて
実業務で errors.Join
を実際に使用するときに追加(wrap) していくときのコストが気になったので合わせて調べてみました。
というのも、上記のユースケースで提示したような loop の処理の中で発生した error をJoin していく場合、loop の回数が多くなるような実装だと、error の追加(wrap) そのものにコストが掛かるのではないか、という仮説があったので実際に計測してみた、という流れになります。
比較したサンプルコードは以下の2つです。
- loop の中で
errors.Join
を連続して行うパターン。 - loop の中で errors の配列に append していくパターン。
package main import ( "context" "errors" "fmt" "testing" ) func Benchmark(b *testing.B) { b.ReportAllocs() b.Run("slice", func(b *testing.B) { b.ResetTimer() var errs []error for i := 0; i < b.N; i++ { err := errors.New(fmt.Sprint(i)) errs = append(errs, err) } _ = errors.Join(errs...) }) b.Run("join", func(b *testing.B) { b.ResetTimer() var errs error for i := 0; i < b.N; i++ { err := errors.New(fmt.Sprint(i)) errs = errors.Join(errs, err) } }) b.Run("slice unwrap", func(b *testing.B) { var errs []error for i := 0; i < 1000; i++ { err := errors.New(fmt.Sprint(i)) errs = append(errs, err) } errs = append(errs, context.Canceled) err := errors.Join(errs...) b.ResetTimer() for i := 0; i < b.N; i++ { _ = errors.Is(err, context.Canceled) } }) b.Run("join unwrap", func(b *testing.B) { var errs error for i := 0; i < 1000; i++ { err := errors.New(fmt.Sprint(i)) errs = errors.Join(errs, err) } errs = errors.Join(errs, context.Canceled) b.ResetTimer() for i := 0; i < b.N; i++ { _ = errors.Is(errs, context.Canceled) } }) }
このベンチの結果は以下でした。
goos: darwin goarch: amd64 pkg: sample.com/go-sandbox cpu: VirtualApple @ 2.50GHz Benchmark Benchmark/slice Benchmark/slice-10 3439363 332.0 ns/op 146 B/op 2 allocs/op Benchmark/join Benchmark/join-10 5206392 219.2 ns/op 88 B/op 4 allocs/op Benchmark/slice_unwrap Benchmark/slice_unwrap-10 53870 22331 ns/op 0 B/op 0 allocs/op Benchmark/join_unwrap Benchmark/join_unwrap-10 21345 58111 ns/op 0 B/op 0 allocs/op PASS ok sample.com/go-sandbox 7.394s
これだけ見ると errors.Join
を利用するほうが良さそうに見えるんですが、これ M1 の結果なんですよね....
別で x8664 でやってもらったときは errors.Join
より slice への append の方が圧倒的に早かったので、大量の loop を通常の production 環境で利用する場合は slice の方がコストが安い、ということは覚えておいて良いかもしれません。
大体の production で動いてる実行環境というのは x8664 環境であると思いますので....。
Practice
この Go1.20 から追加された errors の新機能を実際にどう使っていくのがいいのか、ということなんですが、以下のエントリが非常に参考になりました。
特にこの In Practice に記載されているユースケースは実際のプロダクト開発をするときにエラーハンドリングとしても参考になることが多いのではないかなと思いました。
HTTP ステータスのエラーコードごとに表示するエラー文言を変更したいユースケースなんてのは自分もプロダクト開発していてもぶつかることが多いですし、サンプルにあるような実装にできるのであればエラーの表現力にも寄与しそうだなと思いました。
それより何より fmt package の errror 生成で複数エラーを wrap できる、というのはそれ自体でとても便利だなと思いました。
まとめ
Go1.20 については実はそこまで追いかけてはいなかったのですが、調べると結構今までめんどくさかったなと思っていた error ハンドリングに対してまた1つ改善が追加されていました。
errors.Join
は使えるユースケースが広そうなので、活用していきたいなと思います。