Go3 Advent Calendar 2018 - Qiita
Go2のアドベントカレンダーにも溢れた者たちが集うところ
https://qiita.com/advent-calendar/2018/go3
Wantedly People tribe でエンジニアをやってる泉(@izumin5210)です.今回は Go を書いている人たちにとってはおなじみ Wrap(err) の話をしつつ,Wantedly の本番 Web アプリケーション上でどのようにしてエラーをハンドリングしているかについてです.もし Go を書いてるけど Wrap(err) したことのない人がいれば,この機会にぜひその意義を理解してもらえればと思います.
この記事は Go3 Advent Calendarr 2018 20日目の記事です.
また,内容は Go(Un)Conference(Goあんこ)LT 大会 4kg でのトーク「Wrap(err)
in production」をベースにしています.
一言で言うと「エラー発生箇所のコンテキスト・情報(stacktrace, etc.)をちゃんと残すため」です.
ここは実際の挙動を見るとわかりやすいので,コードで説明します.知ってる人は読み飛ばしてもらって大丈夫です.
まず「スタックトレースとかをいい感じに表示する 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 するのは日常茶飯事なので,毎回スタックトレースを出力してるとログが崩壊します.
ここではほぼデファクトスタンダードと言える 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 氏の 講演資料もしくはブログ記事に詳しいので,そちらも併せて読んでもらうと良いでしょう.
ここからが本題.pkg/errors
などの既存 error wrapper が扱ってくれるエラーコンテキストというのは,おもに「スタックトレース」「メッセージ」の2つだけです.一方で,実 Web アプリケーションにおけるエラーのコンテキストは様々なものが考えられます.
これらの情報にはエラー発生箇所でしか判断できないものもあります.しかし,ここまでの情報を pkg/errors
に乗せることはできません.まじめにエラーオブジェクトを定義すればいいのかもしれませんが,割とキリがないですし,毎回定義してしまうと最後にエラーレポートを投げる箇所の分岐が大変なことになるでしょう.
前述の問題を解決すべく,自分(@izumin5210)と International tribe の岩永(@creasty)で fail
という pkg/errors
と相互運用可能な新しい error wrapper を作りました.@creasty がメインアイディア・実装,@izumin5210 がインタフェースの調整をしています.
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 やエラーレポーティングの可否が決定できるので,より正確かつコンテキストを正しく反映したエラー情報伝播が実現可能になります.
Wantedly の Go のマイクロサービスはすべて grapi を利用して開発されているため,実装者は gRPC サーバを実装することになります.Go による gRPC サーバ開発では Interceptor という仕組みを利用することで,すべての RPC の前後に共通して処理を挟み込むことが可能です.この gRPC の interceptor でのエラーハンドリングを簡単に書くためのパッケージとして,grpc-errors
というものを用意しています.
この grpc-errors
は interceptor の後処理フェーズで fail.Unwrap
し,取り出されたエラーの annotation をもとに予め設定された適切なハンドラ(e.g. StatusCodeMapper
など)を呼び出します.
Wantedly では,社内共通ライブラリに grpc-errors
を利用したエラーハンドリングを記述しており, 漏れのない形で Status Code の設定および honeybadger へのエラーレポート送信を実現しています.
Status Code の決定を永続化レイヤに近いところでやりたい一方で,永続化レイヤではサーバのレスポンスに関すること(要するに presentation レイヤの関心)を扱いたくないという問題があります.この問題に対しては,社内共通の Error code の定義を用意しておき,grpc-errors で 独自 Error code と gRPC Status Code のマッピングを行うことで対処しています.
ここまでで説明したとおり,Wantedly における Go サーバのエラーハンドリングはすべてが error を Wrap することが起点であり絶対条件になっています.Wrap が漏れてしまうとエラーレポートが超貧弱化する上にすべてのエラーが codes.Unknown
(HTTP だと Internal Server Error)で返ってしまいます.一方で,「すべての error が正しく Wrap されているか」を人間がすべてコードレビューするのはかなり面倒です.
このレビューの手間を省略すべく,「Wrap されていない error」を見つけて警告する wraperr
という linter を実装しました.
Wantedly では wraperr を自動レビューツール reviewdog と組み合わせることにより,pull request 上で完全自動の wrap されてない error 検出を実現しています.
長々と書きましたが,伝えたいことは次の3点だけです!