🛒

Goにおける複数エラーのハンドリング

2022/08/23に公開

はじめに

Goにはgoroutineにより並行処理を簡便に記述できるという特徴があります(簡便に正しく動かせるとは限らないですが)。

この時問題になるのがエラーハンドリングで実際のアプリケーション実装では大きく

  • いずれかのgoroutineでエラーが発生したら他のgoroutineを中断したい
  • 各goroutineの処理が終わるまで待機し、発生したエラーは個別に処理したい

という要件がよく発生するかと思います。
これらを充足する処理を自前で実装することも勿論可能ですが、それぞれに適したライブラリがあるため記法のサンプルと共に紹介したいと思います。

errgroupが適したケース・使い方

Go公式の実験パッケージとして提供されているgolang.org/x/sync/errgroupでは主に単一のエラーをハンドリングすることに焦点を当てており標準のsyncパッケージに近い使い方、もしくはエラー時の中断が必要な処理の実装に向いています。

エラーが発生しても最後まで継続する

errgroup.Group 構造体を定義し .Go() メソッドに実処理の関数を渡すことでgoroutineを起動、同様に .Wait() で待ち合わせとエラーの取り出しをすることができます。syncパッケージと違いgoroutine起動数の管理は内部でよろしく処理してくれるため特に意識する必要はありません。この使い方ではちょっと便利なsyncパッケージといった具合です。

下記実装は1つ目のgoroutineは100ms待機後に 1 を表示してエラーを返却、2つ目は200ms待機後に 2 を表示してエラーを返却するという挙動です。

errgroupは最初に発生したエラーしか返却しないという仕様のため2番目以降のエラーは消失しハンドリングすることはできません。

そしてgoroutineの処理は全て終わるまで待機するため処理が終わったシグナルである 1 2 は両方表示されますがエラーについては先に返却された errgroup: err1 のみが表示されます。

func errGroup() {
	var eg errgroup.Group

	eg.Go(func() error {
		time.Sleep(100 * time.Millisecond)
		fmt.Println("1")
		return fmt.Errorf("errgroup: err1")
	})
	eg.Go(func() error {
		time.Sleep(200 * time.Millisecond)
		fmt.Println("2")
		return fmt.Errorf("errgroup: err2")
	})

	if err := eg.Wait(); err != nil {
		fmt.Println(err.Error())
	}
}
$ go run main.go 
1
2
errgroup: err1

他goroutineの中断ハンドリング

先の例では標準のsyncで実装することに近い単純な待ち合わせ+先行エラーの取り出しのみでしたがcontextを使ってエラーが発生していないgoroutineを中断させることもできます。実利用としては例えば「複数のAPIに同時問い合わせをするがどれか1つでもコケたら全て中断する」等といった場合に役立つ機構かと思います。

下記実装は先の処理にcontextのハンドリングを組み込み中断できるようにしたものです。100ms or 200msの待機後に完了のサインである 1 2 を表示することはかわりませんが、加えてctx.Done()も同時に待ち受けているためもしcancel()が発動すればその時点で強制的に中断されます。

errgroup.WithContext() で初期化すると内部で context.WithCancel(ctx) を使いcontextを呼び出し元に返却、更にcancel functionを保持し、エラーが発生した時点でそれを発動させるという振る舞いをしています。

https://cs.opensource.google/go/x/sync/+/7fc1605a:errgroup/errgroup.go;l=45

実行してみると1つ目のgoroutineは100ms待機後に 1 を表示してエラーを返却します。この時一緒にcancel()が実行されるため、2つ目のgoroutineでは200msの終了前に実行が中断されcontext canceledが発生していることがわかります。

func errGroupCtx() {
	ctx := context.Background()
	eg, ctx := errgroup.WithContext(ctx)

	eg.Go(func() error {
		select {
		case <-time.After(100 * time.Millisecond):
			fmt.Println("1")
			return fmt.Errorf("errgroupctx: err1")
		case <-ctx.Done():
			fmt.Println("errgroupctx: ctx done1", ctx.Err())
			return ctx.Err()
		}
	})
	eg.Go(func() error {
		select {
		case <-time.After(200 * time.Millisecond):
			fmt.Println("2")
			return nil
		case <-ctx.Done():
			fmt.Println("errgroupctx: ctx done2", ctx.Err())
			return ctx.Err()
		}
	})

	if err := eg.Wait(); err != nil {
		fmt.Println(err.Error())
	}
}
$ go run main.go
1
errgroupctx: ctx done2 context canceled
errgroupctx: err1

go-multierrorが適したケース・使い方

github.com/hashicorp/go-multierror はerrgroupと違いcontextによる中断等は行なえません。しかし最初に発生したエラー以外も残しておくことができるため全てチェックし丁寧にハンドリングする必要があるようなユースケースで役立つことでしょう。

goroutineで発生したエラー全てをまとめておきたい

初期化としては go-multierror.Group 型を使用しerrgroupと同様にgoroutineを制御してやります。

下記コードは先のように100ms or 200ms待った後でエラーを返却していますがerrgroupの例と違い2番目のエラーも記録できています。取り出し時は構造体の .Errors メンバーを参照しrangeで取り出してやります。

goroutineの待ち合わせである .Wait() で返却される go-multierror.Error 型はerror interfaceを実装しているためそのまま通常のエラーとして返却可能です。また.Error() メソッドでは多少ヒューマンリーダブルに出力してくれる親切設計です(今回は割愛しますがフォーマットの指定も可能です)。

import (
	multierror "github.com/hashicorp/go-multierror"
)

func goMultierror() {
	var meg multierror.Group

	meg.Go(func() error {
		time.Sleep(100 * time.Millisecond)
		return fmt.Errorf("multierror: err1")
	})
	meg.Go(func() error {
		time.Sleep(200 * time.Millisecond)
		return fmt.Errorf("multierror: err2")
	})

	merr := meg.Wait()
	for _, err := range merr.Errors {
		fmt.Println(err.Error())
	}
	fmt.Println(merr.Error())
}
multierror: err1
multierror: err2
2 errors occurred:
        * multierror: err1
        * multierror: err2

単に複数のエラーをまとめて扱いたい

並行処理から外れますが標準のエラーラップではなくスライスのように単純にAppendし扱いやすい形でエラーを保持しておくことができます。

またAppendするerrorが独自型であってもUnwrapメソッドが実装されていれば標準パッケージの errors.As errors.Is で扱うことも可能です(勿論goroutineからのエラー返却についても同様)。

更に呼び出した関数からgo-multierror.Error型をerror型として返却した場合も型アサーションをすることでスライス的に取り出せます。

type ErrX struct {
	err error
	msg string
}

func (ex *ErrX) Error() string {
	return ex.msg
}

func (ex *ErrX) Unwrap() error {
	return ex.err
}

type ErrY struct {
	err error
	msg string
}

func (ey *ErrY) Error() string {
	return ey.msg
}

func (ey *ErrY) Unwrap() error {
	return ey.err
}

func goMultierrorAppend() {
	var err error

	errX := &ErrX{err: nil, msg: "multierror-append: err1"}
	errY := &ErrY{err: nil, msg: "multierror-append: err2"}
	err = multierror.Append(err, errX)
	err = multierror.Append(err, errY)

	merr, ok := err.(*multierror.Error)
	if !ok {
		fmt.Println("failed to assert multierror")
		return
	}
	for _, err := range merr.Errors {
		fmt.Println(err.Error())
	}
	fmt.Println(merr.Error())

	fmt.Println(errors.As(err, &errX))
	fmt.Println(errors.Is(err, errY))
}
multierror-append: err1
multierror-append: err2
2 errors occurred:
        * multierror-append: err1
        * multierror-append: err2


true
true

まとめ

  • 全てのエラーをハンドリングしなくてもよい場合、エラー時にgoroutineの中断をしたい場合はerrgroupが適している
  • 発生したエラーを全てハンドリングしたい場合はgo-multierrorが適している

Discussion