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の一般的なエラーハンドリングから