Wantedly People tribe でエンジニアをやってる泉(@izumin5210)です.今回は Go を書いている人たちにとってはおなじみ Wrap(err) の話をしつつ,Wantedly の本番 Web アプリケーション上でどのようにしてエラーをハンドリングしているかについてです.もし Go を書いてるけど Wrap(err) したことのない人がいれば,この機会にぜひその意義を理解してもらえればと思います.
この記事は Go3 Advent Calendarr 2018 20日目の記事です.

Go3 Advent Calendar 2018 - Qiita
また,内容は Go(Un)Conference(Goあんこ)LT 大会 4kg でのトーク「Wrap(err) in production」をベースにしています.
Go(Un)Conference(Goあんこ)LT大会 4kg (2018/10/19 19:30〜)
復習: なぜ `Wrap(err)` が必要なのか
一言で言うと「エラー発生箇所のコンテキスト・情報(stacktrace, etc.)をちゃんと残すため」です.
ここは実際の挙動を見るとわかりやすいので,コードで説明します.知ってる人は読み飛ばしてもらって大丈夫です.
`Wrap(err)` がないとき
まず「スタックトレースとかをいい感じに表示する helper」と「エラーを返す関数」 を用意します.この「エラーを返す関数」はアプリケーションコードではなく標準 or 外部パッケージのものと考えてください.
// HTTP request や DB アクセスのような,エラーが返る関数のつもり
func occurError() error {
return stderrors.New("error!")
}
func printError(t *testing.T, err error) {
printTitle(t, "%#v")
t.Logf("%#v\n\n", err)
printTitle(t, "%+v")
t.Logf("%+v\n\n", err)
printStacktrace(t)
}
func printStacktrace(t *testing.T) {
t.Helper()
printTitle(t, "stacktrace")
for i := 2; ; i++ {
pc, src, line, ok := runtime.Caller(i)
if !ok {
break
}
t.Logf("%s:%d: %s\n", src, line, runtime.FuncForPC(pc).Name())
}
}
func printTitle(t *testing.T, title string) {
t.Helper()
t.Logf("*** %s %s\n", title, strings.Repeat("*", 64-3-1-len(title)-1))
}ここで,アプリケーションコードから『「エラーを返す関数」を利用する関数』を呼び出します.
package main
import (
"testing"
)
func TestErrors_NoWrap(t *testing.T) {
err := process1()
if err != nil {
printError(t, err)
}
}
func process1() error {
err := occurError()
return err
}実際のアプリケーション開発を想定したとき,予期せぬエラーが出た場合はスタックトレースを表示させたいと考えるでしょう.ここで,上記のコードで表示されるスタックトレースを見ると次のようになります.
=== RUN TestErrors_NoWrap
--- PASS: TestErrors_NoWrap (0.00s)
main.go:43: *** %#v ********************************************************
main.go:44: &errors.errorString{s:"error!"}
main.go:45: *** %+v ********************************************************
main.go:46: error!
main.go:47: *** stacktrace *************************************************
main.go:47: /go/src/sandbox/main.go:16: main.TestErrors_NoWrap
main.go:47: /usr/local/go/src/testing/testing.go:777: testing.tRunner
main.go:47: /usr/local/go/src/runtime/asm_amd64p32.s:968: runtime.goexit最初に定義した helper により,スタックトレースが表示されています.スタックの一番上, main.go:16 はどこかというと, TestErrors_NoWrap 関数で printError を呼び出している行です.
func TestErrors_NoWrap(t *testing.T) {
err := process1()
if err != nil {
printError(t, err) // main.go:16
}
}一方,本来表示されてほしい「エラーの原因行」がどこかというと,おそらく occurError() か process1() でしょう.
func process1() error {
err := occurError() // ここか
return err // ここが出てほしい
}Go のエラーはスタックトレースを保持しないため,エラー発生箇所を特定したければエラーが起きた時点でのスタックトレースを取得しないといけません,そうでないと,今回のように最後にエラーハンドリングをする場所のスタックトレースしか取得できません.
今回のように短いコードであれば問題のあるコードはエラーの原因(occurError がエラーを返した)が自明ですが,現実世界のアプリケーション開発においてはコードベースももっと大きく,エラーが起きうる箇所は無数に存在します.そんななかでエラーが起きたとき,これだけの情報で原因を探せるでしょうか.もちろん,すべてのエラー発生箇所でスタックトレースを表示するわけにもいきません.Go で error を return するのは日常茶飯事なので,毎回スタックトレースを出力してるとログが崩壊します.
`Wrap(err)` があるとき
ここではほぼデファクトスタンダードと言える pkg/errors を利用します.
pkg/errors
先ほどと似たようなテストコードを用意しました.違いは「process2() 関数がエラーを返す直前に Wrap(err) をしている」だけです.
package main
import (
"testing"
"github.com/pkg/errors"
)
func TestErrors_Wrap(t *testing.T) {
err := process2()
if err != nil {
printError(t, err)
}
}
func process2() error {
err := occurError()
return errors.Wrap(err, "wrapped")
}実行結果を見てみると,次のようになります.
=== RUN TestErrors_Wrap
--- PASS: TestErrors_Wrap (0.00s)
main.go:43: *** %#v ********************************************************
main.go:44: wrapped: error!
main.go:45: *** %+v ********************************************************
main.go:46: error!
wrapped
main.process2
/go/src/sandbox/main.go:35
main.TestErrors_Wrap
/go/src/sandbox/main.go:21
testing.tRunner
/usr/local/go/src/testing/testing.go:777
runtime.goexit
/usr/local/go/src/runtime/asm_amd64p32.s:968
main.go:47: *** stacktrace *************************************************
main.go:47: /go/src/sandbox/main.go:24: main.TestErrors_Wrap
main.go:47: /usr/local/go/src/testing/testing.go:777: testing.tRunner
main.go:47: /usr/local/go/src/runtime/asm_amd64p32.s:968: runtime.goexit一番下のスタックトレースは相変わらず変化はありません.が, %+v で出力した結果にはまた別のスタックトレースのようなものが含まれていることがわかります.先頭が main.process2 関数 main.go:24 となっており,これは process2 関数の return 行を指しています.
func process2() error {
err := occurError()
return errors.Wrap(err, "wrapped") // main.go:24
}これは pkg/errors が内部に記録している独自のスタックトレースです.アプリケーションコードで error を return するときに必ず errors.Wrap(err, "...") もしくは errors.WithStack(err) をしておくことで,pkg/errors.StackTrace をみれば「アプリケーションコード内で最初にエラーが起きたのはどこか」を調べることができるようになります.
pkg/errors のように error を Wrap するツール群を利用することで,「エラーが起きたときのコンテキスト・情報」をエラーオブジェクトに記録しておくことができ,その情報をもと予期せぬエラーが発生したときの原因究明に役立てることができます.
Web アプリケーションであれば,ユーザにレスポンスを返す直前で honeybadger や sentry といったエラー収集サービスに pkg/errors.StackTrace を整形して送る…といったユースケースがメジャーになるでしょう.
pkg/errors に至るまでの以前のエラーハンドリングなどに関しては,Go Conference 2016 Spring でのDave Cheney 氏の 講演資料もしくはブログ記事に詳しいので,そちらも併せて読んでもらうと良いでしょう.
Don't just check errors, handle them gracefully
本番で `Wrap(err)` するために
ここからが本題.pkg/errors などの既存 error wrapper が扱ってくれるエラーコンテキストというのは,おもに「スタックトレース」「メッセージ」の2つだけです.一方で,実 Web アプリケーションにおけるエラーのコンテキストは様々なものが考えられます.
- 「どういう種類のエラーなのか」
- そもそもリクエストがおかしい, not found, 依存サービスのタイムアウト...
- HTTP や gRPC のレスポンスの Status Code をどうするか にもつながってくる
- 「そのエラーの発生は開発者が知るべきなのか」
- honeybadger や sentry に通知するかどうか
これらの情報にはエラー発生箇所でしか判断できないものもあります.しかし,ここまでの情報を pkg/errors に乗せることはできません.まじめにエラーオブジェクトを定義すればいいのかもしれませんが,割とキリがないですし,毎回定義してしまうと最後にエラーレポートを投げる箇所の分岐が大変なことになるでしょう.
`srvc/fail` によるちょっとリッチな `Wrap(err)`
前述の問題を解決すべく,自分(@izumin5210)と International tribe の岩永(@creasty)で fail という pkg/errors と相互運用可能な新しい error wrapper を作りました.@creasty がメインアイディア・実装,@izumin5210 がインタフェースの調整をしています.
srvc/fail
fail を利用するときに覚えておくインタフェースは2つ,Wrap と With... です.コード例を見てもらうほうが早いでしょう.
_, err := ioutil.ReadAll(r)
if err != nil {
return fail.Wrap(
err,
fail.WithMessage("read failed"),
fail.WithCode(http.StatusBadRequest),
fail.WithIgnorable(),
)
}基本は fail.Wrap(err) ですが,functional option の形でいくつかのメタデータ(fail のドキュメント上では Annotator と読んでいます)を付与できます.上記の例ではメッセージ,ステータスコード,ignore していいか(エラーレポートしなくていいフラグ)が渡されています.Wrap 時に付与した annotation は fail.Unwrap() することで *fail.Error として取り出すことができます.
type Error struct {
// Err is the original error (you might call it the root cause)
Err error
// Messages is an annotated description of the error
Messages []string
// Code is a status code that is desired to be contained in responses, such as HTTP Status code.
Code interface{}
// Ignorable represents whether the error should be reported to administrators
Ignorable bool
// Tags represents tags of the error which is classified errors.
Tags []string
// Params is an annotated parameters of the error.
Params H
// StackTrace is a stack trace of the original error
// from the point where it was created
StackTrace StackTrace
}レスポンスを返す直前に fail.Unwrap して適切に Status Code をセットしたり,エラーレポートを送ったりするのが想定されているユースケースです.
Wrap のタイミング == 任意のタイミングで Status Code やエラーレポーティングの可否が決定できるので,より正確かつコンテキストを正しく反映したエラー情報伝播が実現可能になります.
interceptor でのエラーハンドリング
Wantedly の Go のマイクロサービスはすべて grapi を利用して開発されているため,実装者は gRPC サーバを実装することになります.Go による gRPC サーバ開発では Interceptor という仕組みを利用することで,すべての RPC の前後に共通して処理を挟み込むことが可能です.この gRPC の interceptor でのエラーハンドリングを簡単に書くためのパッケージとして,grpc-errors というものを用意しています.
srvc/grpc-errors
この grpc-errors は interceptor の後処理フェーズで fail.Unwrap し,取り出されたエラーの annotation をもとに予め設定された適切なハンドラ(e.g. StatusCodeMapper など)を呼び出します.
Wantedly では,社内共通ライブラリに grpc-errors を利用したエラーハンドリングを記述しており, 漏れのない形で Status Code の設定および honeybadger へのエラーレポート送信を実現しています.
独自 Error code の定義
Status Code の決定を永続化レイヤに近いところでやりたい一方で,永続化レイヤではサーバのレスポンスに関すること(要するに presentation レイヤの関心)を扱いたくないという問題があります.この問題に対しては,社内共通の Error code の定義を用意しておき,grpc-errors で 独自 Error code と gRPC Status Code のマッピングを行うことで対処しています.
`srvc/wraperr` で `Wrap(err)` 忘れを防ぐ
ここまでで説明したとおり,Wantedly における Go サーバのエラーハンドリングはすべてが error を Wrap することが起点であり絶対条件になっています.Wrap が漏れてしまうとエラーレポートが超貧弱化する上にすべてのエラーが codes.Unknown (HTTP だと Internal Server Error)で返ってしまいます.一方で,「すべての error が正しく Wrap されているか」を人間がすべてコードレビューするのはかなり面倒です.
このレビューの手間を省略すべく,「Wrap されていない error」を見つけて警告する wraperr という linter を実装しました.
srvc/wraperr
Wantedly では wraperr を自動レビューツール reviewdog と組み合わせることにより,pull request 上で完全自動の wrap されてない error 検出を実現しています.
まとめ
長々と書きましたが,伝えたいことは次の3点だけです!
- とりあえず,エラーを返すときは pkg/errors などで必ず Wrap しよう!
- エラーによりリッチな情報をのせたいときは srvc/fail で Wrap & Annotate しよう!
- Wrap わすれを人間がチェックするのは不毛なので,wraperr で自動化しよう!