emahiro/b.log

日々の勉強の記録とか育児の記録とか。

Go1.20 から入った errors.Join が実は便利そうだった

Overview

  • Go 1.20 から errors パッケージに導入された errors.Join は複数のエラーを詰め込んでも詰め込んだエラーそれぞれを Unwrap して取り出せる。
  • fmt.Errorf でも複数のエラーを wrap できるようになっている。
  • 複数のエラーを wrap してもそれぞれ unwrap して取り出せるので Is 判定を使うことができる。

errors.Join について

Go1.20 から導入された機能です。

pkg.go.dev

以下のフューチャーさんのブログで丁寧に解説されてますが、error のラップの方法がちょっと便利になる関数になります。

future-architect.github.io

もともと 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 に記載されているユースケースは実際のプロダクト開発をするときにエラーハンドリングとしても参考になることが多いのではないかなと思いました。

lukas.zapletalovi.com

HTTP ステータスのエラーコードごとに表示するエラー文言を変更したいユースケースなんてのは自分もプロダクト開発していてもぶつかることが多いですし、サンプルにあるような実装にできるのであればエラーの表現力にも寄与しそうだなと思いました。
それより何より fmt package の errror 生成で複数エラーを wrap できる、というのはそれ自体でとても便利だなと思いました。

まとめ

Go1.20 については実はそこまで追いかけてはいなかったのですが、調べると結構今までめんどくさかったなと思っていた error ハンドリングに対してまた1つ改善が追加されていました。

errors.Join は使えるユースケースが広そうなので、活用していきたいなと思います。