Go言語でstack trace付きerrorを自作してみる

に公開

※この記事は、KNOWLEDGE WORK Blog Sprint第15日目の記事になります。

ことのあらまし

Go 言語の標準ライブラリで定義されるエラー、すなわち errors.New() で作成されるエラーは、エラー文のみを保持しており stack trace を含むそのほかの有益な情報を保持していません。

弊社ではエラーライブラリを独自実装しており、様々な調査に有益な情報が付与されています。 stack trace も情報として含まれており、エラーの調査で大きく困ることはありませんでした。

しかしstack traceを取る仕組みについて何も知らないままだと後で困ることがあります。仕組みがあるので仕事するうえでは問題ないのですが、その仕組みではうまくいかなかったときコミットすることができないのはあまりよい状態ではありません。

そこで今回はstack traceをどうやってエラーに取り込んでいくのか勉強した過程をまとめてみました。

Go言語でのerrorの挙動

まずGo言語のerrorsライブラリを普通に使ったときの挙動を見てみます。

newError := errors.New("new error")
fmt.Println(newError)
$ go run main.go
new error

errors.New の引数に入れたエラー文だけが表示されます。物凄くシンプルですね。ライブラリの実装自体もものすごく短いです。

https://go.dev/src/errors/errors.go

ではエラーが関数の実行により深いところで発生した場合はどう対処するのでしょうか。

よくある実装だとエラーが起こり得る関数の2つ目の返り値でerrorを返し、メッセージを添えてラップして更に返すようにすると思います。

ビルトインのライブラリだけを使ってどうやるかというと、これは fmt パッケージを使うことになります。

newError := errors.New("new error")
fmt.Println(newError)
+
+ wrappedError := fmt.Errorf("wrapped: %w", newError)
+ fmt.Println(wrappedError)
$ go run main.go
new error
wrapped: new error

このように fmt.Errorf を使用するとエラーをラップした新たなエラーを作ることができます。

関数の実行が繰り返されて奥の方でエラーが発生した場合でも、ラップされて追加された情報を見ることでどこで発生したかわかります。

特定のエラーの場合の挙動を変えたい要求があったとき、標準で用意された仕組みではどう解決するのがよいでしょうか。errorsパッケージには errors.Isが存在するのでそれを利用します。

newError := errors.New("new error")
fmt.Println(newError)

wrappedError := fmt.Errorf("wrapped: %w", newError)
fmt.Println(wrappedError)
+ 
+ if errors.Is(wrappedError, newError) {
+     fmt.Println("new error is wrapped")
+ }

このようにラップしても同じエラーを含んでいれば errors.Is でチェックできます。

ざっと errors パッケージでできることを確認しましたが、ビルトインのパッケージだけでもできることは多いことがわかりました。

しかし、エラー文字列からだけでは、エラーが起こった場所を特定したり、エラーがどのような呼び出しを経て起こったのかを知るのには非常に手間がかかります。そこで、stack trace がエラーに付与されているとエラーを起こった場所や経路を特定でき、エラーの原因理解に非常に役立ちます。

現在のstack traceを取得するためのruntime.Callers

stack traceを取るにはどうすればよいでしょうか。stack traceを取るには runtime.Callers を使用します。

まずはstack traceだけを取るコードは次のようになります。

package main

import (
	"fmt"
	"runtime"
)

func main() {
	f1() // 9行目
}

func f1() { // 13行目
	f2()
}

func f2() { // 17行目
	f3()
}

func f3() { // 21行目
	stack := getStackTrace()
	fmt.Println(stack)
}

func getStackTrace() string {
	stack := make([]uintptr, 10)
	length := runtime.Callers(0, stack)
	frames := runtime.CallersFrames(stack[:length])

	result := ""
	for {
		frame, more := frames.Next()
		result += fmt.Sprintf("%s:%d\n", frame.Function, frame.Line)
		if !more {
			break
		}
	}

	return result
}

getStackTracef3 という関数で実行しています。

このコードを実行すると以下のような出力が得られます。

$ go run main.go
runtime.Callers:345
main.getStackTrace:27
main.f3:21
main.f2:17
main.f1:13
main.main:9
runtime.main:285
runtime.goexit:1268

最終的に frame に入っている情報を使用することで、どの関数のどの行数が実行されていったのかを出力することができました。

getStackTrace では runtime.Callers を使っています。 runtime.Callers は現在実行中のgorutineのstack frame(関数の呼出履歴)をプログラムカウンタの配列として出力するAPIです。

プログラムカウンタはコードの stack を見ると分かる通り uintptr 型のsliceのため、人が読むことができません。それを人が読むことができる形に変換してくれるのが runtime.CallersFrames です。

runtime.CallersFrames で得られる runtime.Frame 型は以下のような定義になっています。

https://go.dev/src/runtime/symtab.go

// symtab.goから
type Frame struct {
	// このフレームの命令位置
	PC uintptr

	// このフレームに対応する関数情報
	Func *Func

	// パッケージパス付きの関数名
	Function string

	// このフレームのソースコードファイル名とソースコードの行番号
	File string
	Line int

	// 関数開始位置の行番号
	startLine int

	// 関数のエントリポイントの命令位置
	Entry uintptr

	// ランタイム内部の関数情報
	funcInfo funcInfo
}

Frame には実行された関数の情報が含まれており、このstructを利用すればエラーが起こった場所を指し示すことができることがわかります。

stack traceをもった独自実装のerrorの実装

stack traceの取り方はわかったのでこれを利用して独自のerrorを実装してみます。

要件としては

  • error型として扱えるようにする
  • stack traceを表示できる
  • Wrapして深いところのstack traceを閲覧できる
    を満たしていきたいと思います。

実装は以下になります。

package myerror

import (
	"fmt"
	"path/filepath"
	"runtime"
	"strings"
)

type MyError struct {
	Msg   string
	Cause error
	Stack string
}

func New(msg string) error {
	return &MyError{
		Msg:   msg,
		Stack: capture(2),
	}
}

func Wrap(err error, msg string) error {
	if err == nil {
		return nil
	}

	if se, ok := err.(*MyError); ok {
		return &MyError{
			Msg:   msg,
			Cause: err,
			Stack: fmt.Sprintf("%s\n%s", capture(2), se.Stack),
		}
	}

	return &MyError{
		Msg:   msg,
		Cause: err,
		Stack: capture(2),
	}
}

func (e *MyError) Error() string {
	if e.Cause != nil {
		return e.Msg + ": " + e.Cause.Error()
	}
	return e.Msg
}

func (e *MyError) Unwrap() error { return e.Cause }

func (e *MyError) StackTrace() string {
	var b strings.Builder
	b.WriteString("Error: ")
	b.WriteString(e.Msg)
	b.WriteString("\n\nStack trace:\n")
	b.WriteString(e.Stack)
	return b.String()
}

func capture(skip int) string {
	pc, file, line, ok := runtime.Caller(skip)
	if !ok {
		return "<unknown>"
	}
	fn := runtime.FuncForPC(pc)
	name := "unknown"
	if fn != nil {
		name = fn.Name()
	}
	return fmt.Sprintf("%s\n\t%s:%d", name, filepath.ToSlash(file), line)
}

実装を解説していきます。

MyError 型の定義

type MyError struct {
	Msg   string
	Cause error
	Stack string
}

ここではエラーメッセージだけでなく、元のエラーを保持するためのCause、そして呼び出し元の位置を文字列で持つStackを定義しています。Causeを持たせることで errors.Iserrors.As にも対応できるようになります。

エラー生成とラップ

func New(msg string) error {
	return &MyError{
		Msg:   msg,
		Stack: capture(2),
	}
}

Newは新しいエラーを作るときに使います。capture(2)は後述するのですが実行するとstack traceを取ることができます。つまりNewを実行した瞬間のstack traceがStackに入ります。

func Wrap(err error, msg string) error {
	if err == nil {
		return nil
	}

	if se, ok := err.(*MyError); ok {
		return &MyError{
			Msg:   msg,
			Cause: err,
			Stack: fmt.Sprintf("%s\n%s", capture(2), se.Stack),
		}
	}

	return &MyError{
		Msg:   msg,
		Cause: err,
		Stack: capture(2),
	}
}

Wrapは既存のエラーをラップし、追加のメッセージとstack情報を付けます。もしラップ対象が MyErrorなら、既存のstackに自分のstackを前に追加して連結します。これにより1本のstack traceとして追跡可能になります。

Error と Unwrap の実装

func (e *MyError) Error() string {
	if e.Cause != nil {
		return e.Msg + ": " + e.Cause.Error()
	}
	return e.Msg
}

func (e *MyError) Unwrap() error { return e.Cause }

Errorメソッドはerrorインターフェースの実装です。ラップしている場合は「自分のメッセージ: 元エラーのメッセージ」という形式で表示されます。Unwrapを実装しているため、errors.Iserrors.As といった標準のエラーチェーン操作も使えます。

stack traceの表示

func (e *MyError) StackTrace() string {
	var b strings.Builder
	b.WriteString("Error: ")
	b.WriteString(e.Msg)
	b.WriteString("\n\nStack trace:\n")
	b.WriteString(e.Stack)
	return b.String()
}

ここではstack traceを整形して見やすく出力しています。最上位のエラーメッセージを先頭に、その下にstack frameを一覧化します。

呼び出し元を取る capture

func capture(skip int) string {
	pc, file, line, ok := runtime.Caller(skip)
	if !ok {
		return "<unknown>"
	}
	fn := runtime.FuncForPC(pc)
	name := "unknown"
	if fn != nil {
		name = fn.Name()
	}
	return fmt.Sprintf("%s\n\t%s:%d", name, filepath.ToSlash(file), line)
}

runtime.Caller を使い呼び出し位置のプログラムカウンタ・ファイル名・行番号を取得しています。skipを調整することで「何階層上の関数を取得するか」をコントロールできます。ここでは capture(2)として、capture自身とNew/Wrapをスキップし、実際にエラーを発生させた関数を取るようにしています。

実行イメージ

実際にコードを実行してみます。

func f1() error {
	return myerror.New("error in f1")
}

func f2() error {
	err := f1()
	return myerror.Wrap(err, "error in f2")
}

func f3() error {
	err := f2()
	return myerror.Wrap(err, "error in f3")
}

func f4() error {
	err := f3()
	return myerror.Wrap(err, "error in f4")
}

func f5() error {
	err := f4()
	return myerror.Wrap(err, "error in f5")
}

func main() {
	err := f5()
	var me *myerror.MyError
	if errors.As(err, &me) {
		fmt.Println(me)
		fmt.Println(me.StackTrace())
	}
}

出力結果は以下になります。

$ go run main.go
error in f5: error in f4: error in f3: error in f2: error in f1
Error: error in f5

Stack trace:
main.f5
        /path/to/project/main.go:33
main.f4
        /path/to/project/main.go:28
main.f3
        /path/to/project/main.go:23
main.f2
        /path/to/project/main.go:18
main.f1
        /path/to/project/main.go:13

stack traceもエラーメッセージも表示できました。簡単なstack trace付きのerrorとしてはこれで十分な実装になったのではないかと思います。

色々errorとして扱うためにコードは増えましたが、要は runtime.Caller をどのタイミングで実行して得られたstack traceをどうつなげていくかというところがポイントかと思います。

まとめ

Go言語のerrorパッケージでできることの確認と、stack traceをどう取得するかを学び簡単な独自のerror型であるMyErrorを実装してみました。

runtime.Caller を使用してstack traceをとることができ、 Frame 内の情報でデバッグに必要な情報を得ることができることを学べたので、今後エラーライブラリの改修にスムーズにとりかかることができそうです。また基礎的な部分を学べたことにより、OSSのエラーライブラリを読むのチャレンジできそうです。

普段触らない runtime パッケージに触れたことにより、Go言語の基礎的な部分に興味が湧いてきたので、今後は runtime パッケージを起点にしてGo言語本体のコードを読んでみても面白そうだなと思いました。

KNOWLEDGE WORK Blog Sprint、明日9/16の執筆者は元気キャラで社内で有名なQAエンジニアのKatoaz さんです。 お楽しみに!

株式会社ナレッジワーク

Discussion