🛹

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

9 min read 5

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 という項目に詳細が載っています。

また、go v1.13の時に取り込まなかった理由は、下位互換性を保つためだそうです。

If we add this functionality to the standard library in Go 1.13, code that needs to keep building with previous versions of Go will not be able to depend on the new standard library. While every such package could use build tags and multiple source files, that seems like too much work for a smooth transition.

https://go.googlesource.com/proposal/+/master/design/29934-error-values.md

自作すればよくない?

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

最後に

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