はじめましてfreee株式会社でソフトウェアエンジニアをしているbudougumi0617ことしみっちょです。普段はGoによるアカウントアグリゲーション機能(銀行同期基盤)の開発に携わっています。
この記事はfreee Developers Advent Calendar 2018 の23日目の記事です。
12月23日と言えば国民の祝日、Perfumeかしゆかさんの誕生日ですね。おめでとうございます。 今回は私が先日参加したPerfume 7th Tour 2018 「FUTURE POP」の感想を書こうと思ったのですが、ツアーが終わるまでネタバレは禁止なのでGoの話をすることにしました。
この記事では私が携わっているプロダクトで行なっているエラーハンドリングの一部とGoでスタックトレースを構造化に扱う方法について紹介します。
なお、文中で利用しているソースコードや出力結果は以下のサンプルリポジトリに用意したコードを利用しています。
Goのエラーハンドリングについて
Goの標準パッケージから生成されるエラーはそのまま愚直に利用するとエラーメッセージしか持っていない非常なシンプルな情報しか得られません。
プロダクトで利用する上ではスタックトレースやSeverity
の仕組みがほしいですね。各社・各人が多様なアプローチをとっており、2018年12月のアドベントカレンダーや勉強会だけでもエラーの扱い方について以下のような投稿・発表がなされています(漏れていたらごめんなさい)。
私のGoのプロダクトではpkg/errors
をラップした独自errors
パッケージを利用しています。
pkg/errors
を利用すると、エラー情報にスタックトレースをエラー情報に含めることが可能です。
以下はpkg/errors.New
関数から生成したエラーをfmt.Printf("%+v",err)
と出力した結果に含まれているスタックトレースの引用です。
github.com/budougumi0617/errors-example/errors.NewByPkg /Users/budougumi0617/go/src/github.com/budougumi0617/errors-example/errors/errors.go:14 github.com/budougumi0617/errors-example/root/sub.(*Sub).ReturnErrorByPkg /Users/budougumi0617/go/src/github.com/budougumi0617/errors-example/root/sub/sub.go:18 github.com/budougumi0617/errors-example/root.secondFuncWithPkg /Users/budougumi0617/go/src/github.com/budougumi0617/errors-example/root/root.go:24 github.com/budougumi0617/errors-example/root.FirstFuncWithPkg /Users/budougumi0617/go/src/github.com/budougumi0617/errors-example/root/root.go:19 main.main /Users/budougumi0617/go/src/github.com/budougumi0617/errors-example/main.go:14 runtime.main /usr/local/opt/go/libexec/src/runtime/proc.go:201 runtime.goexit /usr/local/opt/go/libexec/src/runtime/asm_amd64.s:1333
呼び出し元の関数名や呼び出された場所のファイル行数などのスタックフレームを出力しているのがわかります。
上記のようにpkg/errors
を使うだけでスタックトレースをエラー情報に含めることができるようになるのですが、私のプロダクトの独自エラー型ではスタックトレースの生成を一部独自に行なっています。
なぜ独自にスタックトレース情報を取り扱うのか?
pkg/errors
で出来る以上ほとんど車輪の再発明なのですが、スタックトレースの取得を独自にすることの利点は、エラートラッキングサービスに加工後のスタックトレース情報を送信できることです。
私が担当しているプロダクトでは、BugSnagを利用してエラー情報のトラッキングをしています。BugSnagはSentryなどと同様のエラートラッキングサービスです。
そして、私が担当しているGoのプロダクトはos/exec.Cmd
経由でRubyのスクリプトに一部の処理を移譲しています。Rubyスクリプトのスタックフレームについては当然Goのランタイムからは取得できません。
そのため、Rubyスクリプトの実行時に例外が発生したとき、Rubyのスタックトレースのフレーム情報ををGoのスタックトレースに追加してからBugSnagに送信しています*1 。ではどのようにすればGoのスタックトレースを操作することが可能なのでしょうか。
Goでスタックトレース(スタックフレーム)を構造化して操作する
まず、pkg/errors
はどのようにスタックトレースを取得しているのでしょうか。pkg/errors
でerror
を生成するerrors.New
などは以下のような実装をしています。
https://github.com/pkg/errors/blob/2233dee583dcf88f3c8b22cb7a33f05a499800d8/errors.go#L100-L107
// New returns an error with the supplied message. // New also records the stack trace at the point it was called. func New(message string) error { return &fundamental{ msg: message, stack: callers(), } }
このstack
フィールドに入っている情報がスタックトレースの情報なのですが、実際に格納するオブジェクトを返しているcallers
関数の実装は以下です。
https://github.com/pkg/errors/blob/059132a15dd08d6704c67711dae0cf35ab991756/stack.go#L133-L139
func callers() *stack { const depth = 32 var pcs [depth]uintptr n := runtime.Callers(3, pcs[:]) var st stack = pcs[0:n] return &st }
この中のruntime.Callers
関数で取得した[]uintptr
がスタックトレースの各フレーム情報になります。pkg/errors
では独自型のpkg/errors.Frame
(type Frame uintptr
)に格納して情報を取得しています。
なお、Go1.7以降ではruntime
パッケージ内にスタックトレース情報を模すruntime.Frames
と、runtime.Callers
関数で取得したuintptr
をからFrames
を取得するruntime.CallersFrames
関数が追加されているのですが、こちらを利用すると加工が少し面倒なため、今回は利用しません。
(runtime.CallersFrames
の利用方法はruntime
パッケージのExampleをご覧ください。)
本稿では以下のような独自型を定義します。
// Frame はスタックフレームを表現する独自構造体 type Frame struct { // File はそのスタックが発生した開始されたファイル File string // LineNumber はそのスタックが開始されたファイル行数 LineNumber int // Name はそのスタックが開始された関数・メソッド名 Name string // ProgramCounter は元データ ProgramCounter uintptr }
そしてこのFrame
構造体にruntime.Func
構造体を介して各スタックフレーム情報を格納してきます。*runtime.Func
オブジェクトはruntime.Callers
で取得したスタックフレーム情報のuintptr
からruntime.FuncForPC
関数を使って取得できます。*runtime.Func
オブジェクトを介してスタックフレーム情報を[]Frame
に格納し直すコードが以下になります。
func callers() []uintptr { const depth = 32 var pcs [depth]uintptr n := runtime.Callers(3, pcs[:]) return pcs[0 : n-2] } // NewFrame はスタックトレースの各スタックフレームをFrameに格納する func NewFrame(pcs []uintptr) []Frame { frames := []Frame{} for _, pc := range pcs { frame := Frame{ProgramCounter: pc} fn := runtime.FuncForPC(pc) if fn == nil { return frames } frame.Name = trimPkgName(fn) frame.File, frame.LineNumber = fn.FileLine(pc - 1) frames = append(frames, frame) } return frames } // package名を取り除く func trimPkgName(fn *runtime.Func) string { name := fn.Name() if ld := strings.LastIndex(name, "."); ld >= 0 { name = name[ld+1:] } return name }
あとはerror
インターフェースを満たす構造体に[]Frame
を保持しておけば、スタックトレース情報を含んだ独自error
の定義の完成です(fmt.Stringer
、fmt.Formatter
インターフェースも実装しておくとよいでしょう)。
次の出力は大雑把にError()
メソッドを実装したMyError
の出力です。
大雑把にスタックトレースを出力する独自error: Cause: custom error Trace: /Users/budougumi0617/go/src/github.com/budougumi0617/errors-example/root/sub/sub.go:15 ReturnError /Users/budougumi0617/go/src/github.com/budougumi0617/errors-example/root/root.go:16 secondFunc /Users/budougumi0617/go/src/github.com/budougumi0617/errors-example/root/root.go:11 FirstFunc /Users/budougumi0617/go/src/github.com/budougumi0617/errors-example/main.go:12 main
私のプロダクトでは[]Frame
に更にRubyから取得したスタックトレース情報を付与したりしています。
今回は独自定義のFrame
型を用意しましたが、例えばBugSnagを利用している場合、bugsnag-go/errors.StackFrame
構造体にフレーム情報を格納し、bugsnag-go/errors.ErrorWithStackFrames
インターフェースを実装すれば「細工したスタックトレース」をBugSnagのWebビューに表示することもできます。
// bugsnag-go/errors.StackFrame の定義 type StackFrame struct { File string LineNumber int Name string Package string ProgramCounter uintptr } // bugsnag-go/errors.ErrorWithStackFrame の定義 type ErrorWithStackFrames interface { Error() string StackFrames() []StackFrame }
終わりに
今回はGoでエラーハンドリングを行なう際に自前でスタックトレースを取り扱う方法を紹介しました。
最後にfreeeでは一緒に働けるGopherを募集中です。 www.wantedly.com
明日は人事労務とスクラムに精通するエンジニア、そーださんです。お楽しみに。
*1:もちろん、Rubyスクリプト側からStderrPipe経由などで情報をGoに送り込む「仕込み」は必要です。