🛹

今goのエラーハンドリングを無難にしておく方法(2021.09現在)

2021/07/11に公開
8

2021年7月現在、goでエラーハンドリングってどうやって扱うのが無難なのかについて、いろいろと調べました。
昔から人気な pkg/errors、Go公式がメンテナンスしていたxerrors、 go v1.13から追加されたfmt.Errorfなどエラーハンドリングの選択肢がいろいろとあるので、stacktrace欲しい場合とそうでない場合で個人的にベストだと思った方法をまとめました。

エラーハンドリングに求める要件

  • エラーが発生した箇所を追える
  • エラーの原因によって処理を分岐する
  • (場合による)stacktraceがみれる

結論

  • stacktraceが不要な場合
    • fmt.Errorfでラップし、errors.Isで判定する
  • stacktraceが必要な場合
    • pkg/errors.Wrapでラップし、pkg/errors.Causeで判定する
    • log.Printf("%+v", err)でstacktraceを出力する
    • 新しいエラーを返す時はpkg/errors.Neworpkg/errors.Errorfを使う

実装例

stacktraceが不要な場合

fmt.Errorfを使ってエラーがラップするのが良さそうです。エラーをラップし続けることでメッセージはどんどん長くなりますが、スタックトレースがなくてもエラーが発生したこと箇所を追えるようになります。また、fmt.Errorfでラップした場合、errors.Isを使って、発生元となったエラーの種類により処理を分岐することもできます。

レイアードアーキテクチャによくあるservice、repositoryを使った構成でのエラーハンドリングの例です。
userRepositoryはユーザーを取得し、userServiceはそれを利用したロジックが書かれている想定です。

package main

import (
	"errors"
	"fmt"
	"log"
)

var (
	ErrRecordNotFound = errors.New("record not found")
	ErrUnknown        = errors.New("unknown error")
)

func main() {
	userService := userService{}
	if err := userService.Authenticate(); err != nil {
		log.Fatal(err)
	}
}

type userService struct {
	userRepo userRepository
}
func (s *userService) Authenticate() error {
	if err := s.userRepo.FindUser("hoge"); err != nil {
		if errors.Is(err, ErrRecordNotFound) {
			log.Print("ユーザーが存在しなかった場合のハンドリングをする")
		} else {
			return fmt.Errorf("failed to userRepo.FindUser: %w", err)
		}
	}
	return nil
}

type userRepository struct {
	db dbHandler
}
func (r *userRepository) FindUser(id string) error {
	if err := r.db.Query("SELECT * FROM users WHERE id = ?", id); err != nil {
		return fmt.Errorf("failed to find user: %w", err)
	}
	return nil
}

type dbHandler struct{}
func (h *dbHandler) Query(sql string, args ...interface{}) error {
	return ErrRecordNotFound
	// return ErrUnknown
}

(plauground)

この例では、dbHandler.QueryErrRecordNotFoundエラーを返す場合、userServiceでユーザーが存在しなかった場合のエラーハンドリングを行います。dbHandler.QueryErrUnknownエラーを返した場合は、log.Fatalにより処理が中断され、ログに

failed to userRepo.FindUser: failed to find user: unknown error

と出力されます。スタックトレースには劣りますが、エラーメッセージをラップし続けることでエラーが発生した箇所をなんとか特定することができそうです。

スタックトレースが必要な場合

現状の標準errorsではスタックトレースを出力することはできないので、外部ライブラリを使います。おそらくgoのエラーハンドリングライブラリで最もGitHubのスター数が多いpkg/errorsを使うことにしました。

pkg/errorsを使う場合、2パターンの実装方法がありました。

  • pkg/errors.WithStackをつかうパターン
    • エレガントな実装だけど、stacktraceつけ忘れがち
  • pkg/errors.Wrapをしまくるパターン
    • ちょっと強引な実装だけど、stacktraceつけ忘れにくい

pkg/errors.WithStackをつかうパターン

このパターンでは、stacktraceを付与したいタイミングでpkg/errors.WithStackを使います。その関数を呼び出した関数では、エラーをラップしないでそのまま返します。エラーの原因によって処理を分岐するには pkg/errors.Causeを使います。

package main

import (
	"log"

	"github.com/pkg/errors"
)

var (
	ErrRecordNotFound = errors.New("record not found")
	ErrUnknown        = errors.New("unknown error")
)

func main() {
	userService := userService{}
	if err := userService.Authenticate(); err != nil {
		log.Fatalf("%+v", err)
	}
}

type userService struct {
	userRepo userRepository
}
func (s *userService) Authenticate() error {
	if err := s.userRepo.FindUser(); err != nil {
		switch errors.Cause(err) {
		case ErrRecordNotFound:
			log.Print("ユーザーが存在しなかった場合のハンドリングをする")
		default:
			return err
		}
	}
	return nil
}

type userRepository struct {
	db dbHandler
}
func (r *userRepository) FindUser() error {
	if err := r.db.Query(); err != nil {
		return err
	}
	return nil
}

type dbHandler struct{}
func (h *dbHandler) Query() error {
	return errors.WithStack(ErrRecordNotFound)
	// return errors.WithStack(ErrUnknown)
}

(playground)
この例でも、dbHandler.QueryErrRecordNotFoundエラーを返す場合、userServiceでユーザーが存在しなかった場合のエラーハンドリングを行います。dbHandler.QueryErrUnknownエラーを返した場合は、log.Fatalにより処理が中断され、以下のようにstacktraceが出力されます。

2009/11/10 23:00:00 unknown error
main.init
	/tmp/sandbox455547551/prog.go:11
runtime.doInit
	/usr/local/go-faketime/src/runtime/proc.go:6309
runtime.main
	/usr/local/go-faketime/src/runtime/proc.go:208
runtime.goexit
	/usr/local/go-faketime/src/runtime/asm_amd64.s:1371
main.(*dbHandler).Query
	/tmp/sandbox455547551/prog.go:52
main.(*userRepository).FindUser
	/tmp/sandbox455547551/prog.go:42
main.(*userService).Authenticate
	/tmp/sandbox455547551/prog.go:26
main.main
	/tmp/sandbox455547551/prog.go:16
runtime.main
	/usr/local/go-faketime/src/runtime/proc.go:225
runtime.goexit
	/usr/local/go-faketime/src/runtime/asm_amd64.s:1371

この実装のデメリットは、エラーの発生箇所でpkg/errors.WithStackをし忘れるとstacktraceが付与されないということです。外部ライブラリから返ってきたエラーに対してはpkg/errors.WithStackを絶対にしなければいけないです。チーム開発においてそれをチームメンバー全員に周知し実践する必要がありますし、基本はreturn errでハンドリングすることになるのでプルリクのレビューでも見落としがちになってしまうと思います。

pkg/errors.Wrapをしまくるパターン

このパターンでは、どんなエラーを返すときにも、絶対にpkg/errors.Wrapを使います。


import (
	"log"

	"github.com/pkg/errors"
)

var (
	ErrRecordNotFound = errors.New("record not found")
	ErrUnknown        = errors.New("unknown error")
)

func main() {
	userService := userService{}
	if err := userService.Authenticate(); err != nil {
		log.Fatalf("%+v", err)
	}
}

type userService struct {
	userRepo userRepository
}
func (s *userService) Authenticate() error {
	if err := s.userRepo.FindUser(); err != nil {
		switch errors.Cause(err) {
		case ErrRecordNotFound:
			log.Print("ユーザーが存在しなかった場合のハンドリングをする")
		default:
			return errors.Wrap(err, "failed to userRepo.FindUser")
		}
	}
	return nil
}

type userRepository struct {
	db dbHandler
}
func (r *userRepository) FindUser() error {
	if err := r.db.Query(); err != nil {
		return errors.Wrap(err, "failed to dbHandler.Query")
	}
	return nil
}

type dbHandler struct{}
func (h *dbHandler) Query() error {
	return errors.Wrap(ErrRecordNotFound, "failed to query")
	// return errors.Wrap(ErrUnknown, "failed to query")
}

(playground)
この例でも、dbHandler.QueryErrRecordNotFoundエラーを返す場合、userServiceでユーザーが存在しなかった場合のエラーハンドリングを行います。dbHandler.QueryErrUnknownエラーを返した場合は、log.Fatalにより処理が中断され、以下のようにstacktraceが出力されるのですが、その出力のされ方が微妙になってしまいます。

このように、Wrapする箇所ごとでstacktraceが表示されるため、表示されるログが見づらく膨大になってしまいます。またエラーオブジェクトがどんどん肥大化していくので、もしかするとパフォーマンスへの悪影響も考慮する必要が出てきます。以上がこの実装のデメリットなのですが、この実装のメリットはとにかく全てのエラーをpkg/errors.Wrapしておけば良いのです。チームメンバーへの周知もしやすく、プルリクのレビューで見落としづらいです。**

pkg/errorsのまとめ

pkg/errors.WithStackは美しいstacktraceにパフォーマンス的な懸念も小さいのは素晴らしいですが、実装ミスが発生しやすいという欠点があります。実際にエラーが発生してから調査する際に、「ここ実装ミスっててstacktrace出てないじゃん!調査これ以上できないじゃん!」という最悪の事態が起きてしまっては目も当てられません。

一方、pkg/errors.Wrapはstacktraceが見づらいことやパフォーマンスへの懸念はありますが、実装ミスをしづらく、エラーの調査ができなくなるという最悪の事態は引き起こしづらいです。

いろいろ調査したメモ

goのerrorパッケージの進化の流れ

goの標準errorsで、stacktraceが出せないことや、エラーの判定がしずらいことなどを補うために pkg/errorsがそれらの機能を備えていました。それらの機能をgoの標準errorsに取り込むためにxerrorsが誕生しました。そして、go v1.13がリリースされたタイミングでxerrorsの一部機能が標準errorsに取り込まれました。この時にstacktraceの機能は標準errorsの取り込まれず、errorをラップする機能と判定する機能が標準errorsfmtに取り込まれることになりました。
https://blog.golang.org/go1.13-errors

pkg/errorsはその後標準errorsfmtに合わせて、pkg/errors.Errorfなどのメソッドを追加しました。

xerrorsを使わなかった理由

https://github.com/golang/xerrors
を見てもらうとわかると思うのですが、もうメンテされないからです。xerrorsはgo v1.13に取り込まれたことで役目を終えたようです。

pkg/errorsってもうメンテさてなくない?

https://github.com/pkg/errors
最近はあまり変更されていないですが、メンテがされていないというわけではないようです!

Because of the Go2 errors changes, this package is not accepting proposals for new functionality. With that said, we welcome pull requests, bug fixes and issue reports.

READMEに上のように書いてあるのですが、go v2でエラーハンドリングにラップなど機能が追加される予定であることから、もう機能追加をする気は全くないようです。Issueやbug fixについては対応すると書いてありますし、実際にIssueでは返事が返ってきています。

pkg/errors.Errorfは使わないの?

pkg/errors.Errorf標準errorsfmtに倣って追加されたメソッドです。しかし、pkg/errors.Errorfはエラーメッセージをラップし、stacktraceの情報を付与してくれますが、エラーそのもののラップはしてくません。なので、pkg/errors.Errorfをしたエラーはpkg/errors.Causeなどで取り出すことができないのです。

ということで、新しいエラーを作るときにはよいですが、エラーをラップする用途では使いないです。

結局標準errorsにstacktraceの機能は取り込まれないの?

おそらくv2で取り込まれると思います。
https://go.googlesource.com/proposal/+/master/design/go2draft.md
draft design for error printing という項目に詳細が載っています。

今のところ予定はないようです。
@tenntennさんにコメントいただきました!ありがとうございます!)

プロポーザルを読み直しましたが、スタックトレースについてGo2でリリースされるとは書いてなさそうです。
そもそも、Go2というのは、後方互換が崩れるような変更を入れざる得ない時に生まれるバージョンなので、今の所予定はないと思います。
スタックトレースはGo1.13の開発時にxerrorsから輸入され、その後リリース前に消されたという経緯があります。
https://github.com/golang/go/issues/30468
https://go-review.googlesource.com/c/go/+/176997

自作すればよくない?

もちろん「俺の思う最強エラーハンドリングパッケージ」的なものを作っても良いと思います。
今回はみんなと一緒のやり方でエラーハンドリングしておくなら、どんな方法が無難なのかなというのを知りたかった次第です。

最後に

個人的に調べてまとめたものなので、他によりよい方法があったり、間違ってる箇所があるかもしれません。
その際はご指摘いただけますと嬉しいです!

Discussion

SpiegelSpiegel

標準 errors パッケージでもエラーのラッピングは既に実装されていますよ。 erros.Unwrap(), errors.Is(), errors.As() 関数でハンドリングします。また fmt.Errorf() 関数で %w 書式を使ってラップすることが可能です。 Go 2 に絡めるなら Generics 導入で errors.As() 関数のバリエーションが増える可能性はありますが,今のところは Go 2 も含めて現状が最終形です。

ただし標準パケージではスタックトレースは実装されていないので,この情報が必要なら自前で型を用意するか pkg/errors パッケージを使う,という住み分けになるんだと思います。 pkg/errors パッケージは放置されているわけではなく,設計がシンプルでバグが出る余地が少ないので改修頻度が少ないだけで,あちこちで使われてますよ。

pkg/errors の errors.(*withStack).Cause() 関数は(コードを見れば分かりますが) errors.(*withStack).Unwrap() 関数と同一のコードです。むしろ errors.(*withStack).Cause() 関数は自パッケージの後方互換性確保のために残されてると言った方がいいでしょうか。これから使うのであれば標準の erros.Unwrap(), errors.Is(), errors.As() 関数と組み合わせてハンドリングするのがいいと思います。

Nekoshita YukiNekoshita Yuki

ご指摘いただきありがとうございます!

標準 errors パッケージでもエラーのラッピングは既に実装されていますよ。

その通りですmm
最後の項目ではerrorのラップの話ではなくstacktraceの話をしているつもりでした
なので、最後の項目を以下のように変更しました。

結局標準errorsはラップする機能は取り込まれないの?
-> 結局標準errorsにstacktraceの機能は取り込まれないの?

Nekoshita YukiNekoshita Yuki

標準パケージではスタックトレースは実装されていないので,この情報が必要なら自前で型を用意するか pkg/errors パッケージを使う,という住み分けになるんだと思います

僕も同じ認識です!

pkg/errors パッケージは放置されているわけではなく,設計がシンプルでバグが出る余地が少ないので改修頻度が少ないだけで,あちこちで使われてますよ。

そうですね、僕の表現の仕方が放置してるっぽくなってしまっていたので修正しました

Nekoshita YukiNekoshita Yuki

pkg/errors の errors.(*withStack).Cause() 関数は(コードを見れば分かりますが) errors.(*withStack).Unwrap() 関数と同一のコードです。むしろ errors.(*withStack).Cause() 関数は自パッケージの後方互換性確保のために残されてると言った方がいいでしょうか。これから使うのであれば標準の erros.Unwrap(), errors.Is(), errors.As() 関数と組み合わせてハンドリングするのがいいと思います。

pkg/errors.Casue()pkg/errors.Unwrap() のコードは異なるようにみえます。

pkg/errorsを使ってエラーを発生させた場合のエラー原因の特定のために、pkg/errors.Cause()pkg/errors.Is()標準errors.Is()のどれを使っても問題なくハンドリングできるので、そこは好みで使い分けて良いと思います!

1点注意が必要なのが、pkg/errorsを使って発生させたエラーを pkg/errors.Unwrap()標準errors/Unwrap() する際の場合の挙動についてです。

https://github.com/pkg/errors/issues/223#issuecomment-587372942
詳細はこちらになりますが、pkg/errorsを使って発生させたエラーを2回 Unwrap()しないと1回分のUnwrapができません。
なので、pkg/errorsを使って発生させたエラーの原因を特定する場合には、 pkg/errors.Cause()pkg/errors.Is()標準errors.Is() を使うのがよさそうです。

SpiegelSpiegel

おー,なるほど。情報をありがとうございます。

pkg/errors の errors.Cause() と errors.(*withStack).Cause() および errors.Unwrap() と errors.(*withStack).Unwrap() がごっちゃの説明になってましたね。すみません。

tenntenntenntenn

プロポーザルを読み直しましたが、スタックトレースについてGo2でリリースされるとは書いてなさそうです。
そもそも、Go2というのは、後方互換が崩れるような変更を入れざる得ない時に生まれるバージョンなので、今の所予定はないと思います。

スタックトレースはGo1.13の開発時にxerrorsから輸入され、その後リリース前に消されたという経緯があります。
https://github.com/golang/go/issues/30468
https://go-review.googlesource.com/c/go/+/176997

Nekoshita YukiNekoshita Yuki

ご指摘、詳細な経緯をいただきありがとうございます!本文を修正させていただきました🙏