morikuni/failureを使ったエラーハンドリング

2020年12月13日

はじめに

Goにはいくものエラーハンドリングのパターンがあり、それらのパターンを実現するための様々なライブラリが作られています。 私自身も自分のやりたいエラーハンドリングを実現するため、morikuni/failureというライブラリを自作しました。 なぜ標準ライブラリの

errors
や、広く使われているpkg/errorsを使うのではなく独自のライブラリを作ったのか、どのように使っているかを紹介します。

標準的なエラーハンドリング

まず、Goの標準的なエラーの返し方は、

errors.New
fmt.Errorf
などを使うことです。

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

上記のように、その場で同じエラーを作り出すことで処理を分岐させる事ができますが、

Foo
内で作られるエラーメッセージが書き換えられた場合に正しく処理が行われなくなります。 また、
Bar
の用に
fmt.Errorf
を使って、値をエラーに埋め込んでしまっているような場合には特に問題が起きやすくなります。 例えば、エラーメッセージを
something happend: %d
(
with
:
にした)のように書き換える可能性は十分に考えられます。

これを解決するためには、エラーの値を変数に入れてしまうのが一般的です。

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

エラーの値を

ErrSomethingHappened
という変数に入れてしまうことで、同じ変数を参照している限りはエラーメッセージが変わったとしても想定通りの処理が行われます。 また、Go 1.13で取り込まれた xerrors
%w
を使うことで、変数に固定されたエラーをラップし、動的にエラーメッセージを追加することもできます。 このようにエラーをラップすることで、エラーを拡張していくのが、最近のGoでのエラー設計の基本となっています。 ラップされていったエラーの集合体をエラーチェインと呼んだりします。

pkg/errors
を使ったエラーハンドリング

標準の

errors
パッケージだけでも、ライブラリや小さなアプリケーションのエラーハンドリングには十分だと言えるでしょう。 一方で、Webサービスなど、ある程度の規模のアプリケーションでは、困る場面も出てきます。

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つの

http.Handler
があり、それぞれ、エラーが発生した場合にログを吐いています。
b
c
のバリデーションエラーについてはログのメッセージから、
HandlerA
HandlerB
のどちらで発生したかを特定することができます。 一方、
a
のバリデーションエラーについては、ログのメッセージも同一になってしまうため、発生箇所を特定できません。

これを解決するにはエラーにスタックトレースを含めるとよいでしょう。 エラーにスタックトレースを付与するために広く使われているのが 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
を作ったのか?

pkg/errors
で、スタックトレースは表示されるようになりますが、
pkg/errors
が苦手としていることがエラーの変換です。

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

上記の

GetByExistingID
は、
Get
を呼び出し、データが見つからなければ、内部エラーを返しています。 一見問題なさそうに見えますが、このコードで困るのは、
ErrInternal
を返す部分で、オリジナルのエラーを捨ててしまっていることです。
ErrNotFound
が発生したという情報やスタックトレースが失われてしまうため、
Get
内のどこでエラーが発生したのかを知る術がありません。

これを解決するには、独自のエラー型を定義して、エラーをラップするという方法があります。

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

InternalError
は内部にエラー変数を持っているため、オリジナルのエラー情報を内部に残したままエラーの変換ができます。 この方法は標準パッケージの url.Errorなどでも使われています。 エラーをラップするだけでなく、独自の情報(例えば
url.Error
であればpathなど)を追加することもできるためとても便利な方法ですが、欠点もあります。 それは、次のようにハンドリングしたいエラーの種類毎に型を作らなければならないことです。

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

ApplicationError
は内部にエラーコードを持っており、エラーコードによってエラーを識別しています。 そのため、エラーコードを追加するだけで、新しいエラーの識別が可能になりました。 しかし、まだいくつか手間がかかると感じるポイントがあります。

  • 独自のエラー型にスタックトレースをつけるのは面倒なので、結局
    pkg/errors
    などと併用することになる
  • pkg/errors.Wrap
    のメッセージをいちいち書くのが面倒くさいが、スタックトレースだけだとぱっと見でなにが起きているかわからない
  • アプリケーション/プロジェクト毎に同じような
    ApplicationError
    を定義しなくてはいけない

これらの不満を解消しつつ、追加で便利な機能を足して作ったのが

morikuni/failure
です。
failure
を作る際には、 Failure is your Domainと、Proposal: Go 2 Error Inspectionを参考にしました。

morikuni/failure
を使ったエラーハンドリング

morikuni/failure
の特徴は、値ではなくエラーコードによってエラーを識別することです。 そのため、開発者は最初に、アプリケーション内で使用するエラーコードを定義する必要があります。 逆に、多くの場合、エラーコードを定義する以外の事をする必要がないため、エラーハンドリングについて深く考えずとも開発を始められます。 実際に、
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

まずは、

Get
関数内を見てみましょう。 failure.Newでエラーコードを含む基本的なエラーが生成されます。 このエラーには自動でスタックトレースが取り込まれているため、エラーの発生箇所を簡単に特定できます。

関数の引数や変数の状態などもエラー内に含めたい場合は、failure.Contextを使ってください。 Key-Value形式でエラーが発生したときの情報を追加できます。

GetByExistingID
の中では、
Get
を呼び出し、failure.Isを使ってエラーの識別をしています。 標準ライブラリの
errors.Is
では、エラーを2つ受け取って等価性をチェックしますが、
failure.Is
ではエラーとエラーコードを受け取って等価性をチェックします。

エラーの変換にはfailure.Translateを使用します。 ここでは、

NotFound
のだった場合に、エラーコードを
Internal
で上書きすることで、エラーの変換をしています。
failure
では、一度エラーコードの変換をすると、
failure.Is(err, NotFound)
false
になります。 標準の
errors
では、何もしないと、エラーの変換後も
errors.Is(err, NotFound)
true
となる場合が多いと思います。 エラーの変換後に元のエラーを識別するかどうかが
failure
errors
の設計方針の違いの1つだと思います。

また、

failure
では、エンドユーザー向けのエラーメッセージを簡単に扱うこともできます。
Internal
を返すところで、
failure.Message
を使ってメッセージを付与していますが、このメッセージは
failure.MessageOf(err)
という関数を使うことでいつでも取り出すことができます。
err.Error()
だと、エンドユーザーに出すには詳細すぎる場合がありますが、
failure.Message
を使えば、内部実装を全く露出させない形で、エンドユーザー向けのメッセージを扱えます。

Get
のレスポンスが
NotFound
ではない場合には、failure.Wrapを使っています。
failure
を導入している場合、エラーを返す際には、基本的に
failure.Translate
failure.Wrap
などでエラーをラップすることを推奨しています。 これは、
failure
のラップ関数が開発者向けのエラーメッセージの自動生成を行うためです。 実際に、上記のコードを実行した際のエラーを見てみましょう。

1main.GetByExistingID: id should exist: code(Internal): main.Get: database=B id=aaa: code(NotFound)
2

これは、

GetByExistingID
を呼び出して、
fmt.Println(err)
をした結果です。 エラーコードや、
failure.Context
のKey-Value値、
failure.Message
のエラーメッセージが含まれています。 注目してもらいたいのは、
main.GetByExistingID
main.Get
の部分です。 コード上では、明示的に関数名を埋め込んでいませんが、
failure
のラップ関数を使っているため、関数名が自動的に
err.Error()
に含まれています。 そのため、スタックトレースを見なくても大まかなエラーの発生箇所が特定できるようになります。

スタックトレースなども含めた詳細なエラーを見たい場合には

fmt.Printf("%+v", err)
などが使用できます。

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

failure
を使ったエラーハンドリングまでを紹介しました。
failure
を使うことで、独自のエラー設計を考えずとも、多くのアプリケーションで効率的なエラーハンドリングができるようになると思います。 一般公開されている利用事例としては、ISUCON9予選のベンチマーカー でも採用していただいています。 私自身も
failure
を使ったAPIサーバーをいくつか立ち上げて、本番運用していますが、エラー設計周りで困ることはほとんどありません。 Goのアプリケーションでエラーハンドリングに困っている方は是非
failure
の採用を検討してみてください。