現場で即使える Go開発実践テクニック集 3

独自エラー構造体を自作して学ぶGoの「エラー」と「スタックトレース」

第3回の今回は、Goの実装Tipsとして、独自エラーを自作しながらスタックトレースの仕組みやエラーチェーン・出力制御の手法について紹介します。

唐木 稜生

6:30

はじめに

株式会社サイバーエージェントでソフトウェアエンジニアをしている唐木稜生(@karamaru_alpha)です。

前回まではGoバイナリやそのdockerイメージを小さく・高速にビルドするための方法について解説しました。

今回からは視点をビルドからコードへ移して、Goでアプリケーションコードを書く際に役立つ実装Tipsを紹介していきたいと思います。

1章: error型と自作エラー構造体

Goのエラーは特別な仕組みではなく、ただのインターフェースです。標準パッケージの定義を見てみましょう。

go type error interface { Error() string } 

cf. https://github.com/golang/go/blame/go1.26.4/src/builtin/builtin.go#L317-L319

つまり Error() string を実装した型はすべてエラーとして扱えます。普段よく使う errors.New も、内部では文字列を1つ持つだけの小さな構造体を返しているに過ぎません。

// errors/errors.go(標準パッケージ)
func New(text string) error {
	return &errorString{text}
}

type errorString struct {
	s string
}

func (e *errorString) Error() string {
	return e.s
}

 cf. https://github.com/golang/go/blame/go1.26.4/src/errors/errors.go#L64-L75

裏を返せば、構造体にフィールドを足せば「メッセージ以外の情報を持つエラー」を自由に作れるということです。

さっそく最小の独自エラーを作ってみましょう。まずはメッセージに加えて、HTTPステータスコードなどの分類を表す code だけを持たせます。

type appError struct {
	msg  string // 内部向けメッセージ
	code int    // HTTPステータスコードなどの分類
}

func New(code int, msg string) *appError {
	return &appError{msg: msg, code: code}
}

func (e *appError) Error() string {
	return fmt.Sprintf("[%d] %s", e.code, e.msg)
} 

errorString と大差ない、たった2フィールドの構造体です。 しかし、これだけでも「ハンドラはエラーから code を取り出してレスポンスを組み立てる」という設計ができるようになりました。

とはいえ、実際のアプリケーション開発で使うにはまだ足りないものだらけです。

  • 下層で発生した元のエラーはどこへいったのか → 第2章
  • どこで発生したのか(スタックトレース)→ 第3章
  • ラップし忘れたエラーが素通りしてしまう → 第4章
  • ログには詳細を、レスポンスには簡潔に、と出し分けたい → 第5章

ここから章を追うごとに、この小さな appError を一人前のエラー型に育てていきます。

2章: エラーを繋げる — Unwrapとエラーチェーン

最初の問題は「元のエラー」の扱いです。

例えば、リポジトリ層でDBアクセスが sql.ErrNoRows を返したとき、そのまま呼び出し元へ返すと文脈(何を探していて見つからなかったのか)が伝わりません。 かといって New で独自エラーを作り直すと、今度は元のエラーが捨てられてしまい、呼び出し側で「見つからなかっただけなのか、DB障害なのか」を区別できなくなります。

文脈を足しつつ、元のエラーも保持する。 これを実現するのがエラーの「ラップ」です。appError に元エラーを保持するフィールドを足します。

type appError struct {
	msg  string
	code int
	err  error // ← 追加: ラップ元のエラー
}

// Wrap 既存のエラーに文脈を足して包む
func Wrap(err error, code int, msg string) *appError {
	return &appError{msg: msg, code: code, err: err}
}

そして、重要なのが Unwrap メソッドの実装です。

func (e *appError) Unwrap() error {
	return e.err
} 

Go 1.13以降、Unwrap() error を実装した型はエラーをチェーン状に繋げられます。errors.Is / errors.As がこのチェーンを内側へ辿って判定してくれるため、独自エラーで何重にラップしても、呼び出し側では errors.Is(err, sql.ErrNoRows) でチェーンの奥にある元のエラーを検出できます。

user, err := repo.SelectUser(id)
if err != nil {
	return nil, Wrap(err, http.StatusNotFound, "user not found")
}

// 呼び出し側(何重にラップされていても辿れる)
if errors.Is(err, sql.ErrNoRows) { ... }

なお、Go 1.26からはジェネリクス版の errors.AsType が追加され、これまでの「ポインタを渡して書き込んでもらう」スタイルよりも簡潔にチェーンから目的の型を取り出せるようになりました。

// Go 1.25まで
var appErr *appError
if errors.As(err, &appErr) { ... }

// Go 1.26以降
if appErr, ok := errors.AsType[*appError](err); ok { ... }

cf. https://github.com/golang/go/blame/go1.26.4/src/errors/wrap.go#L167 

これで appError は「分類・元エラー」を運べるようになりました。残る大物は、エラー調査の主役であるスタックトレースです。

3章: スタックトレースを持たせる

エラーが起きたとき、最初に知りたいのは「どこで発生したのか」です。しかしエラーは呼び出し元へどんどん伝播していくため、受け取った側では発生箇所の情報はもう失われています。つまり、発生した瞬間(New / Wrap の呼び出し時点)に記録するしかありません。

appError にスタックトレース用のフィールドを足しましょう。これで構造体は完成形になります。

type appError struct {
	msg   string
	code  int
	err   error
	stack *stack // ← 追加: 生成時点のスタックトレース
}

この stack の中身を理解するために、Goでスタックトレースを取得する仕組みを見ていきます。入り口は runtime.Callers です。

func Callers(skip int, pc []uintptr) int 

cf. https://github.com/golang/go/blame/go1.26.4/src/runtime/extern.go#L332

この関数は、現在のゴルーチンのコールスタックを「プログラムカウンタ(PC)」の列として pc に書き込みます。プログラムカウンタとは、実行中の機械語命令のアドレスを指す値です。この時点では単なる uintptr の列であり、人間が読める情報(関数名・ファイル名・行番号)はまだ含まれていません。

これを使って、スタックを取得する callers() を実装します。

type stack []uintptr

func callers() *stack {
	const depth = 32
	var pcs [depth]uintptr
	n := runtime.Callers(3, pcs[:])
	var st stack = pcs[0:n]
	return &st
}

注目すべきは第1引数の skip = 3 です。これは「スタックの先頭から何フレームを読み飛ばすか」を指定します。

  • skip 0: runtime.Callers 自身
  • skip 1: callers()
  • skip 2: New() / Wrap()
  • skip 3: New/Wrapを呼んだ場所 ← ここから記録したい

エラーを調査する人が知りたいのは「エラー生成ライブラリの内部」ではなく「アプリケーションコードのどこでNewが呼ばれたか」です。ライブラリ内部のフレームをskipで削ぎ落とすことで、ノイズのないスタックトレースになります。

PCの列を人間が読める形に変換するのは runtime.CallersFrames の仕事です。

func (s *stack) StackTrace() []string {
	if s == nil {
		return nil
	}
	frames := runtime.CallersFrames(*s)
	stackTrace := make([]string, 0)
	for {
		frame, more := frames.Next()
		stackTrace = append(stackTrace, fmt.Sprintf("%s\n\t%s:%d", frame.Function, frame.File, frame.Line))
		if !more {
			break
		}
	}
	return stackTrace
}

cf. https://github.com/golang/go/blame/go1.26.4/src/runtime/symtab.go#L80

CallersFrames はPCをシンボル情報(関数名・ファイル名・行番号)に解決するイテレータを返します。なぜ取得時に解決せず、わざわざPCのまま保持するのでしょうか。それは、シンボル解決には相応のコストがかかるからです。エラーは生成されても、リトライで握り潰されるなどして最終的にログ出力されないことも多くあります。「取得(uintptrのコピー)は軽量に、解決(文字列化)は実際に出力するときだけ」という遅延評価の設計になっているのです。

仕組みが分かったところで、コンストラクタにスタック取得を組み込みます。

func New(code int, msg string) *appError {
	return &appError{msg: msg, code: code, stack: callers()}
}

New はこれで完成ですが、Wrap にも同じように callers() を仕込むと問題が起きます。エラーは層をまたぐたびにラップされるため、Wrap のたびにフルスタックを取得すると、ラップ元と大部分が重複したスタックトレースを何重にも保持することになるのです。

ここで役立つのが、1フレームだけを取得する runtime.Caller(単数形)です。

func caller() *stack {
	pc, _, _, _ := runtime.Caller(2)
	return &stack{pc}
}

cf. https://github.com/golang/go/blame/go1.26.4/src/runtime/extern.go#L309

「ラップ元がすでに appError ならフルスタックは取らず、現在のフレームだけ記録する」という分岐を Wrap に入れます。

func Wrap(err error, code int, msg string) *appError {
	var s *stack
	if _, ok := errors.AsType[*appError](err); ok {
		// ラップ元がスタックを持っているなら、現在のフレームだけ積む
		s = caller()
	} else {
		s = callers()
	}
	return &appError{msg: msg, code: code, err: err, stack: s}
}

これで、エラーチェーン全体としては「最深部のフルスタック + 各ラップ地点の1フレーム」というメモリ効率も可読性も良い形になります。

実際に動かして、何が記録されるのか確かめてみましょう。「リポジトリ層で New、ハンドラ層で Wrap」という典型的な伝播を再現します。

func findUser() error {
	return New(500, "db query failed") // 最深部: フルスタックを取得
}

func handler() error {
	if err := findUser(); err != nil {
		return Wrap(err, 404, "user not found") // 通過点: 1フレームだけ積む
	}
	return nil
}

func main() {
	err := handler()

	outer, _ := errors.AsType[*appError](err)
	inner, _ := errors.AsType[*appError](outer.Unwrap())

	fmt.Println("=== 最深部(New)のフルスタック ===")
	fmt.Println(strings.Join(inner.stack.StackTrace(), "\n"))
	fmt.Println()
	fmt.Println("=== ラップ地点(Wrap)の1フレーム ===")
	fmt.Println(strings.Join(outer.stack.StackTrace(), "\n"))
}

実行結果は以下の通りです。

=== 最深部(New)のフルスタック ===
main.findUser
	/tmp/sandbox746589173/prog.go:72
main.handler
	/tmp/sandbox746589173/prog.go:76
main.main
	/tmp/sandbox746589173/prog.go:83
runtime.main
	/usr/local/go-faketime/src/runtime/proc.go:290
runtime.goexit
	/usr/local/go-faketime/src/runtime/asm_amd64.s:1771

=== ラップ地点(Wrap)の1フレーム ===
main.handler
	/tmp/sandbox746589173/prog.go:77

最深部の New では findUser → handler → main → runtime.main とフルスタックが記録されています。skip = 3 の効果で runtime.Callerscallers()New といったライブラリ内部のフレームが出力から消えていることに注目してください。

一方、ラップ地点の Wrap に積まれたのは handler の1フレームだけ。それも findUser を呼んだ76行目ではなく、Wrap を呼んだ77行目を正確に指しています。

このコードはこちらのGo Playgroundでそのまま実行・改変できます。callers()skip の値を小さくしてみると隠れていたライブラリ内部のフレームが現れる様子も観察できるので、ぜひ試してみてください。

別解: 都度フレームを積んでいく方式(xerrorsスタイル) 

ここまでは「エラー生成時にフルスタックを一括取得する」方式を見てきましたが、実は正反対のアプローチも存在します。golang.org/x/xerrors(Go 2のエラー提案の実験実装)が採用している、「各エラーは1フレームだけを持ち、エラーハンドリングのたびに明示的にフレームを積んでいく」方式です。

xerrorsでは xerrors.Frame という型が1フレーム分の位置情報を表します。中身は今回実装したものと同じくプログラムカウンタで、xerrors.Caller(skip) で取得します。

cf. https://github.com/golang/xerrors/blame/7835f813f4da/frame.go#L12-L26

type myError struct {
	msg   string
	err   error
	frame xerrors.Frame // フルスタックではなく1フレームだけ
}

func New(msg string) error {
	return &myError{msg: msg, frame: xerrors.Caller(1)}
}

// Stack エラーハンドリング箇所でフレームを明示的に積む
func Stack(err error) error {
	return &myError{err: err, frame: xerrors.Caller(1)}
}

この方式では、エラーを伝播させる各層で Stack(またはメッセージ付きの Wrap)を呼びます。

func (s *service) FindUser(id string) (*User, error) {
	user, err := s.repo.SelectUser(id) // 最深部で New される
	if err != nil {
		return nil, Stack(err) // ← 通過点でフレームを積む
	}
	return user, nil
}

つまり、フルスタックを一括取得する方式が「カメラで一枚撮影する」イメージだとすると、xerrorsスタイルは「通過した地点でスタンプを押していく」イメージです。最終的なエラーチェーンを %+v で出力すると、各層のフレームが連なって実質的なスタックトレースが浮かび上がります。

それぞれの方式の特性を比較してみましょう。

【フルスタック方式(runtime.Callers)の特徴】

  • New の1発で全フレームが記録されるため、途中の層がラップを忘れてもトレースが欠けない
  • その分、トレースにはフレームワークやミドルウェアなど調査に不要なフレームも含まれがち

【都度スタック方式(xerrors.Caller)の特徴】

  • 記録されるのは「エラーを意図的にハンドリングした地点」だけ。トレースがそのままエラーの伝播経路を表し、ノイズが一切ない
  • 保持するのも1層あたり1フレームだけなので軽量
  • ただし Stack を書き忘れた層はトレースから抜け落ちる。チームの規律やレビュー・リンタで担保する必要がある

もう1つ、都度スタック方式には見逃せない利点があります。runtime.Callers が取得できるのは「現在のゴルーチンの」コールスタックだけです。エラーがchannelを経由して別のゴルーチンに渡るような設計では、生成時のフルスタックは受け取り側の処理経路を何も語ってくれません。都度スタック方式なら、受け取った側がさらにフレームを積むことで、ゴルーチンをまたいだ「論理的な」伝播経路を記録できます。

どちらが優れているという話ではなく、トレードオフです。ラップ規律が徹底できる成熟したチームなら都度スタック方式のクリーンなトレースは魅力的ですし、「書き忘れに強い」ことを優先するならフルスタック方式が安心です。本章の実装のように、フルスタック方式をベースに「ラップ時は1フレームだけ積む」を組み合わせると、両者のいいとこ取りに近づけます。

4章: 境界で保証する — Apply

appError の機能は出揃いましたが、運用上の弱点が1つ残っています。アプリケーションの実装では、サードパーティライブラリが返したエラーを Wrap し忘れて、そのまま return してしまうことが起こり得ます。そうしたエラーはスタックトレースもcodeも持たないまま最上位まで素通りしてしまいます。

そこで、コンストラクタをもう1つ用意します。

// Apply errに独自エラーを適用する。
// すでに独自エラーならそのまま返し、それ以外は内部エラーとしてラップする
func Apply(err error) *appError {
	if appErr, ok := errors.AsType[*appError](err); ok {
		return appErr
	}
	return &appError{
		msg:   err.Error(),
		code:  http.StatusInternalServerError,
		err:   err,
		stack: callers(),
	}
}

New / Wrap が「エラーを作る・包む」ための関数だとすると、Apply は「独自エラーであることを保証する」ための関数です。すでに独自エラーならそのまま返すため、何度適用しても結果が変わらない冪等な操作になっています。

これが活きるのはアプリケーションの境界、つまりミドルウェアやインターセプタです。

func errorMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if err := handle(next, w, r); err != nil {
			appErr := Apply(err) // ここを通るエラーは必ず独自エラーになる
			logError(appErr)     // スタックトレース付きでログ出力(出力方法は第5章)
			http.Error(w, http.StatusText(appErr.code), appErr.code)
		}
	})
}

最外殻で一度 Apply をかけておけば、「ログ出力に到達するエラーは必ずスタックトレースとステータスコードを持っている」とアプリケーション全体で保証できます。ラップ漏れがあった場合のスタックトレースはミドルウェア起点になってしまいますが、何の手がかりもないよりずっとマシです。個別のエラー処理(Wrap)と全体の安全網(Apply)の二段構えにするわけです。

5章: fmt.Formatterを活用したエラー文の出力変更

最後の仕上げは出力の出し分けです。せっかく取得したスタックトレースですが、Error() string の戻り値に含めてしまうのは悪手です。Error() はエラーメッセージの比較やAPIレスポンスへの埋め込みなど、簡潔な文字列が期待される場面でも呼ばれるからです。

「普段は1行のメッセージ、デバッグ時だけスタックトレース付き」のような出し分けをしたい。ここで活躍するのが fmt.Formatter インターフェースです。

type Formatter interface {
	Format(f State, verb rune)
}

cf. https://github.com/golang/go/blame/go1.26.4/src/fmt/print.go#L54-L56

fmt.Printf 系の関数は、渡された値が Formatter を実装している場合、デフォルトの整形処理の代わりに Format メソッドを呼び出します。verb には書式指定子(vs など)が、State には + などのフラグ情報が渡ってくるため、書式に応じて出力を切り替えられます。

func (e *appError) Format(s fmt.State, v rune) {
	switch v {
	case 'v':
		if s.Flag('+') {
			// %+v: メッセージ + スタックトレース
			fmt.Fprintf(s, "%s\n%s", e.Error(), strings.Join(e.stack.StackTrace(), "\n"))
			if e.Unwrap() != nil {
				// ラップ元も %+v で再帰的に出力する
				fmt.Fprintf(s, "\n- %+v", e.Unwrap())
			}
			return
		}
		fallthrough
	case 's':
		// %v, %s: メッセージのみ
		io.WriteString(s, e.Error())
	case 'q':
		// %q: クォート付きメッセージ
		fmt.Fprintf(s, "%q", e.Error())
	}
}

これにより、同じエラー値でも書式指定子によって出力を変えられます。

err := Wrap(findUser(), http.StatusNotFound, "user not found")

fmt.Printf("%s\n", err)
// [404] user not found

fmt.Printf("%+v\n", err)
// [404] user not found
// main.handler
//         /app/main.go:42
// main.main
//         /app/main.go:15
// - [500] db query failed
// main.findUser
//         /app/repository.go:23
// ...

%+v のケースで e.Unwrap() を再帰的に %+v 出力している点にも注目してください。ラップ元も Formatter を実装していれば連鎖的に展開されるため、エラーチェーン全体のスタックトレースが「外側→内側」の順で一望できます。第3章の「ラップ時は1フレームだけ積む」最適化と組み合わせると、各層のメッセージとラップ地点、そして最深部のフルスタックが重複なく出力される、調査しやすいログが完成します。

通常のログ出力では %s、エラーレポートやデバッグログでは %+v と使い分けることで、用途に応じた情報量をコントロールできるわけです。

ここまでの全章の実装を組み込んだ appError の完成形は、こちらのGo Playgroundでそのまま実行・改変できます。

まとめ

メッセージと分類コードしか持たない2フィールドの構造体から始めて、章ごとに appError を育ててきました。

  • error はただのインターフェース。Error() string さえ実装すれば、フィールドは自由に足せる
  • Unwrap を実装すれば errors.Is / errors.As のチェーン判定に乗れる。文脈を足しつつ元のエラーを保持できる
  • スタックトレースの正体はプログラムカウンタの列。runtime.Callers で軽量に取得し、出力時に runtime.CallersFrames でシンボル解決する遅延評価が定石。ラップ時は runtime.Caller で1フレームだけ積むと重複を防げる
  • xerrorsのようにハンドリング箇所で都度1フレームずつ積んでいく流儀もあり、トレースの網羅性とクリーンさのトレードオフになる
  • 境界で Apply をかければ「すべてのエラーが独自エラーである」ことを保証できる
  • fmt.Formatter を実装すると書式指定子で出力を切り替えられる。%s は簡潔に、%+v はスタックトレース付きで、と用途に応じた出し分けが可能

もちろん拡張はここで終わりではありません。

例えばエラー発生時のパラメータ(userIdやリクエスト値)をキーバリューのフィールドで持たせれば、ログ基盤での集計や検索に強いエラーにもできます。皆さんのアプリケーションの要件に合わせて育ててみてください。

エラーハンドリングはアプリケーションの「調査のしやすさ」を大きく左右します。既存ライブラリをただ使うだけでなく、その内部で何が起きているかを知っておくと、いざという時のデバッグやチーム固有の要件への対応がぐっと楽になるはずです。

次回もGoアプリケーション開発に役立つTipsを紹介していきます。お楽しみに!

人気記事トップ10

人気記事ランキングをもっと見る

企画広告も役立つ情報バッチリ! Sponsored