今goのエラーハンドリングを無難にしておく方法(2021.09現在)
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.New
orpkg/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
}
この例では、dbHandler.Query
がErrRecordNotFound
エラーを返す場合、userServiceでユーザーが存在しなかった場合のエラーハンドリングを行います。dbHandler.Query
がErrUnknown
エラーを返した場合は、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.Query
がErrRecordNotFound
エラーを返す場合、userServiceでユーザーが存在しなかった場合のエラーハンドリングを行います。dbHandler.Query
がErrUnknown
エラーを返した場合は、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.Query
がErrRecordNotFound
エラーを返す場合、userServiceでユーザーが存在しなかった場合のエラーハンドリングを行います。dbHandler.Query
がErrUnknown
エラーを返した場合は、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をラップする機能と判定する機能が標準errors
とfmt
に取り込まれることになりました。
pkg/errors
はその後標準errors
とfmt
に合わせて、pkg/errors.Errorf
などのメソッドを追加しました。
xerrorsを使わなかった理由
xerrors
はgo v1.13に取り込まれたことで役目を終えたようです。
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
は標準errors
とfmt
に倣って追加されたメソッドです。しかし、pkg/errors.Errorf
はエラーメッセージをラップし、stacktraceの情報を付与してくれますが、エラーそのもののラップはしてくません。なので、pkg/errors.Errorf
をしたエラーはpkg/errors.Cause
などで取り出すことができないのです。
ということで、新しいエラーを作るときにはよいですが、エラーをラップする用途では使いないです。
標準errors
にstacktraceの機能は取り込まれないの?
結局おそらくv2で取り込まれると思います。
の 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
標準 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() 関数と組み合わせてハンドリングするのがいいと思います。
ご指摘いただきありがとうございます!
その通りですmm
最後の項目ではerrorのラップの話ではなくstacktraceの話をしているつもりでした
なので、最後の項目を以下のように変更しました。
結局標準errorsはラップする機能は取り込まれないの?-> 結局標準errorsにstacktraceの機能は取り込まれないの?
僕も同じ認識です!
そうですね、僕の表現の仕方が放置してるっぽくなってしまっていたので修正しました
pkg/errors.Casue()とpkg/errors.Unwrap() のコードは異なるようにみえます。
pkg/errors
を使ってエラーを発生させた場合のエラー原因の特定のために、pkg/errors.Cause()
、pkg/errors.Is()
、標準errors.Is()
のどれを使っても問題なくハンドリングできるので、そこは好みで使い分けて良いと思います!1点注意が必要なのが、
pkg/errors
を使って発生させたエラーをpkg/errors.Unwrap()
か標準errors/Unwrap()
する際の場合の挙動についてです。
詳細はこちらになりますが、pkg/errors
を使って発生させたエラーを2回Unwrap()
しないと1回分のUnwrapができません。なので、
pkg/errors
を使って発生させたエラーの原因を特定する場合には、pkg/errors.Cause()
、pkg/errors.Is()
、標準errors.Is()
を使うのがよさそうです。おー,なるほど。情報をありがとうございます。
pkg/errors の errors.Cause() と errors.(*withStack).Cause() および errors.Unwrap() と errors.(*withStack).Unwrap() がごっちゃの説明になってましたね。すみません。
プロポーザルを読み直しましたが、スタックトレースについてGo2でリリースされるとは書いてなさそうです。
そもそも、Go2というのは、後方互換が崩れるような変更を入れざる得ない時に生まれるバージョンなので、今の所予定はないと思います。
スタックトレースはGo1.13の開発時にxerrorsから輸入され、その後リリース前に消されたという経緯があります。
ご指摘、詳細な経緯をいただきありがとうございます!本文を修正させていただきました🙏