morikuni/failureを使ったエラーハンドリング
2020年12月13日
はじめに
Goにはいくものエラーハンドリングのパターンがあり、それらのパターンを実現するための様々なライブラリが作られています。 私自身も自分のやりたいエラーハンドリングを実現するため、morikuni/failureというライブラリを自作しました。 なぜ標準ライブラリの
標準的なエラーハンドリング
まず、Goの標準的なエラーの返し方は、
1func Foo() error {
2    return errors.New("something happened") 
3}
4
5func Bar(x int) error {
6    return fmt.Errorf("something happened with %d", x) 
7}
8
このようにその場で動的にエラーを作って返すというのが、最もシンプルなエラーの返し方ですが、これでは困る場面があります。 それは、エラーを受け取った側がエラーの種類によって処理を分岐したい場合です。
1func main() {
2    err := Foo()
3    if err == errors.New("something happened") {
4        // important process
5    }
6}
7
上記のように、その場で同じエラーを作り出すことで処理を分岐させる事ができますが、
これを解決するためには、エラーの値を変数に入れてしまうのが一般的です。
1var ErrSomethingHappened = errors.New("something happened")
2
3func Foo() error {
4    return ErrSomethingHappened
5}
6
7func Bar(x int) error {
8    return fmt.Errorf("value = %d: %w", x, ErrSomethingHappened)
9}
10
11func main() {
12    err := Foo()
13    if err == ErrSomethingHappened {
14        // important process
15    }
16
17    err := Bar(1)
18    if errors.Is(err, ErrSomethingHappened) {
19        // important process
20    }
21}
22
エラーの値を
pkg/errorsを使ったエラーハンドリング
標準の
1var ErrValidation= errors.New("validation error")
2
3func Validate(x string) error {
4    return ErrValidation
5}
6
7func HandlerA(w http.ResponseWriter, r *http.Request) {
8    a := r.FormValue("a")
9    if err := Validate(a); err != nil {
10        log.Println("validation error on a", err)
11        return
12    }
13
14    b := r.FormValue("b")
15    if err := Validate(b); err != nil {
16        log.Println("validation error on b", err)
17        return
18    }
19}
20
21func HandlerB(w http.ResponseWriter, r *http.Request) {
22    a := r.FormValue("a")
23    if err := Validate(a); err != nil {
24        log.Println("validation error on a", err)
25        return
26    }
27
28    b := r.FormValue("c")
29    if err := Validate(b); err != nil {
30        log.Println("validation error on c", err)
31        return
32    }
33}
34
上記のコードでは、2つの
これを解決するにはエラーにスタックトレースを含めるとよいでしょう。 エラーにスタックトレースを付与するために広く使われているのが pkg/errorsです。
1var ErrValidation= errors.New("validation error")
2
3func Validate(x string) error {
4    return errors.Wrapf(ErrValidation, "invalid value %s", x)
5}
6
7func main() {
8    if err := Validate("foo"); err != nil {
9        log.Printf("%+v", err)
10    }
11}
12
これを実行すると次のようにスタックトレースが表示されます。
12009/11/10 23:00:00 validation error
2invalid value foo
3main.Validate
4	/tmp/sandbox144848305/prog.go:13
5main.main
6	/tmp/sandbox144848305/prog.go:17
7runtime.main
8	/usr/local/go-faketime/src/runtime/proc.go:204
9runtime.goexit
10	/usr/local/go-faketime/src/runtime/asm_amd64.s:1374
11
なぜmorikuni/failureを作ったのか?
1var ErrNotFound = errors.New("not found")
2
3func Get(id string) (*Object, error) {
4    obj, err := databaseA.Get(id)
5    if err != nil {
6        return nil, errors.Wrap(ErrNotFound, "get error A") 
7    }
8
9    obj, err = databaseB.Get(id)
10    if err != nil {
11        return nil, errors.Wrap(ErrNotFound, "get error B") 
12    }
13
14    return obj, nil
15}
16
17var ErrInternal = errors.New("internal")
18
19func GetByExistingID(id string) (*Object, error) {
20    obj, err := Get(id)
21    if errors.Cause(err) == ErrNotFound {
22        return nil, errors.Wrap(ErrInternal, "id should exist")
23    }
24    if err != nil {
25        return nil, errors.Wrap(err, "get by existing id error")
26    }
27
28    return obj, nil
29}
30
上記の
これを解決するには、独自のエラー型を定義して、エラーをラップするという方法があります。
1type InternalError struct {
2   Err error 
3}
4
5func (e InternalError) Error() string {
6    return fmt.Sprintf("internal error: %v", e.Err)
7}
8
9func GetByExistingID(id string) (*Object, error) {
10    obj, err := Get(id)
11    if errors.Cause(err) == ErrNotFound {
12        return nil, errors.Wrap(&InternalError{Err: err}, "id should exist")
13    }
14    if err != nil {
15        return nil, errors.Wrap(err, "get by existing id error")
16    }
17
18    return obj, nil
19}
20
21func main() {
22    _, err := GetByExistingID("a")
23    switch errors.Cause(err).(type) {
24    case *InternalError:
25        // 内部エラーの処理
26    default:
27        // 他のエラーの処理
28    }
29}
30
1type NotFoundError struct {
2    Err error
3}
4
5func (e NotFoundError) Error() string {
6    return fmt.Sprintf("not found error: %v", e.Err)
7}
8
9type BadRequestError struct {
10    Err error
11}
12
13func (e BadRequestError) Error() string {
14    return fmt.Sprintf("bad request error: %v", e.Err)
15}
16
17type AlreadyExistsError struct {
18    Err error
19}
20
21func (e AlreadyExistsError) Error() string {
22    return fmt.Sprintf("already exists error: %v", e.Err)
23}
24
ある程度真面目にHTTPのステータスコードなどを設定しようとすると、少なくとも5種類ほどはエラー型を作る事になるのではないでしょうか。 エラー型が増えたからといって、それほど困ることもないと思いますが、同じような中身で型だけが違うエラーを定義するのは微妙だと感じるかもしれません。
そこで、エラー型を共通化しつつ、エラーの識別が可能になる方法として、エラーコードの使用が挙げられます。
1const (
2    NotFound = "NotFound"
3    BadRequest = "BadRequest"
4    AlreadyExists = "AlreadyExists"
5    Internal = "Internal"
6)
7
8type ApplicationError struct {
9    Code string
10    Err error
11}
12
13func (e ApplicationError) Error() string {
14    return fmt.Sprintf("application error (%s): %v", e.Code, e.Err)
15}
16
17func GetByExistingID(id string) (*Object, error) {
18    obj, err := Get(id)
19    if ae, ok := errors.Cause(err).(*ApplicationError); ok && ae.Code == NotFound {
20        return nil, errors.Wrap(&InternalError{Code: Internal, Err: err}, "id should exist")
21    }
22    if err != nil {
23        return nil, errors.Wrap(err)
24    }
25
26    return obj, nil
27}
28
- 独自のエラー型にスタックトレースをつけるのは面倒なので、結局pkg/errorsなどと併用することになる
- pkg/errors.Wrapのメッセージをいちいち書くのが面倒くさいが、スタックトレースだけだとぱっと見でなにが起きているかわからない
- アプリケーション/プロジェクト毎に同じようなApplicationErrorを定義しなくてはいけない
これらの不満を解消しつつ、追加で便利な機能を足して作ったのが
morikuni/failureを使ったエラーハンドリング
1const (
2    NotFound failure.StringCode = "NotFound"
3    BadRequest failure.StringCode  = "BadRequest"
4    AlreadyExists failure.StringCode  = "AlreadyExists"
5    Internal failure.StringCode = "Internal"
6)
7
8func Get(id string) (*Object, error) {
9    obj, err := databaseA.Get(id)
10    if err != nil {
11        return nil, failure.New(NotFound, 
12            failure.Context{
13                "id": id,
14                "database": "A",
15            },
16        )
17    }
18
19    obj, err = databaseB.Get(id)
20    if err != nil {
21        return nil, failure.New(NotFound, 
22            failure.Context{
23                "id": id,
24                "database": "B",
25            },
26        )
27    }
28
29    return obj, nil
30}
31
32func GetByExistingID(id string) (*Object, error) {
33    obj, err := Get(id)
34    if failure.Is(err, NotFound) {
35        return nil, failure.Translate(err, Internal,
36            failure.Message("id should exist"),
37        )
38    }
39    if err != nil {
40        return nil, failure.Wrap(err)
41    }
42
43    return obj, nil
44}
45
まずは、
関数の引数や変数の状態などもエラー内に含めたい場合は、failure.Contextを使ってください。 Key-Value形式でエラーが発生したときの情報を追加できます。
エラーの変換にはfailure.Translateを使用します。 ここでは、
また、
1main.GetByExistingID: id should exist: code(Internal): main.Get: database=B id=aaa: code(NotFound)
2
これは、
スタックトレースなども含めた詳細なエラーを見たい場合には
1[main.GetByExistingID] /tmp/sandbox423731974/prog.go:50
2    message("id should exist")
3    code(Internal)
4[main.Get] /tmp/sandbox423731974/prog.go:36
5    database = B
6    id = aaa
7    code(NotFound)
8[CallStack]
9    [main.Get] /tmp/sandbox423731974/prog.go:36
10    [main.GetByExistingID] /tmp/sandbox423731974/prog.go:48
11    [main.main] /tmp/sandbox423731974/prog.go:11
12    [runtime.main] /usr/local/go-faketime/src/runtime/proc.go:204
13    [runtime.goexit] /usr/local/go-faketime/src/runtime/asm_amd64.s:1374
14
関数単位でセクションが分けられたエラーの詳細を見ることができるため、どこで何が起こったのかを一目で判断でます。
なお、上記のコードはこちらから実行できます。
おわりに
Goの一般的なエラーハンドリングから