こんにちは。カミナシ ソフトウェアエンジニアの @aoman です。
つい先日、Goで有名な@tenntennさんがConnpassで募集していたGopher塾#2に参加させていただきました。
大変勉強になりおすすめです!筆者が参加したのは第一回目ですが、二回目三回目と予定されているようなので、有料講義ではありますが気になる方はぜひ参加してみてください!学生さんであれば無料の抽選枠もあります。
その際に紹介されていたコードで、エラーのラップ関数があったのですが、これが「メッチャアタマイイ!!スタイリッシュ!!」と感動しました。そのコードは、Goの公式ページである https://go.dev/ のWebサイトを実装しているリポジトリ pkgsite 内の internal/derrors
パッケージで実装されています(GitHubリポジトリはミラーで本体は go.googlesource.com/pkgsite です)。まだご存知ない方へ向けて、そのコード+エラーパッケージの設計がどのようにされているか記事にしてみようと思います!OSSではよく使われている手法のようですので、普段からGoを使っている方にはあまり大した内容ではないかもしれません。
Goのエラーを1から設計して作る場合、どのように作るのが良いのだろう?と悩むことがあったので、今回学んだことを参考にエラー設計してみたいなーと思っています。
「メッチャアタマイイ!!スタイリッシュ!!」となったコード
さっそく結論なのですが、実際に「メッチャアタマイイ!!」となった関数と使用例はこちらです(コードリンク)。
Wrap関数はたったの3行!とてもスタイリッシュですね。
func Wrap(errp *error, format string, args ...any) { if *errp != nil { *errp = fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), *errp) } } // ----- 以下は使用例 var ErrNotFound = errors.New("not found") func f(input int) (err error) { // defer文で関数から抜ける際に実行される defer Wrap(&err, "f(%d)", input) if input == 0 { return ErrNotFound } return nil } func main() { if err := f(0); err != nil { fmt.Println("err:", err)j // -> err: f(0): not found } }
Wrap関数を使う側の関数では、defer文を用いて関数からerrを返した時にそのerrを、Wrap関数に渡した第二引数以降の文字列でラップして返してくれます。
このようにすることで、関数内で複数箇所エラーを返す場合にその都度ラップしなくて済みますね。
var ( ErrInvalidParam = errors.New("invalid param") ErrNotFound = errors.New("not found") ) // ❌ func bad(input int) error { if input == 0 { return fmt.Errorf("bad(%d): %w", input, ErrInvalid) } if input == 1 { return fmt.Errorf("bad(%d): %w", input, ErrNotFound) } return nil } // ⭕ func good(input int) (err error) { defer Wrap(&err, "good(%d)", input) if input == 0 { return ErrInvalid } if input == 1 { return ErrNotFound } return nil }
一つ使用時の注意としては、Wrap関数を使用する場合、 func f(input int) (err error)
のように名前付き変数の戻り値を利用する必要がありますが、return文を書く際には return xxx, yyy
のように明示的に値を返した方がよさそうです。pkgsiteのコード内でもそのような書き方になっており、 return
だけよりも可読性や保守性が上がるかと思います。
実はこのWrap関数には、 Wrap(&err)
のようにerrのダブルポインタを渡しています。ポインタのポインタを渡すことで、ポインタ自体を書き換えることを可能にするためです。
(以前「Goでダブルポインタを知った話」という記事も書いたので、ぜひ見てみてください!)
ちなみに、errors.New(”xxx”)で作成したエラーインスタンスは構造体のポインタとなっています。独自のエラー構造体を作るときも、ポインタで利用することが多いかと思います。
package errors // errors.New("hoge")を呼ぶと、構造体errorStringのポインタが返ってくる func New(text string) error { return &errorString{text} } type errorString struct { s string } func (e *errorString) Error() string { return e.s }
ここまでで、本記事で伝えたいことの9割は終わりました!笑
こんな素敵なコードを使っていい感じにエラーをハンドリングしていきたいですね!
せっかくなので、もう少しだけ derrors パッケージの実装を見ていきます。
スタックトレースを付与するWrapStack関数
Goでは標準のerrorsパッケージだとスタックトレースを付与することができないため、独自で実装するかサードパッケージのライブラリを使用する必要があります。
derrrorsパッケージでは WrapStack
という関数を用意して、スタックトレース付与できるようにしていました。この WrapStack 関数も前述のWrap同様、defer文で使用する想定で作られています。
WrapStack 関数内では、スタックトレースを保持する新たな StackError構造体
を作成しています。
まずは、StackError構造体のコードを見てみましょう。
標準パッケージの runtime.Stack を呼び出している箇所が肝ですね。
// エラーをラップしてStackを付与する構造体 type StackError struct { Stack []byte err error } func NewStackError(err error) *StackError { // Limit the stack trace to 16K. Same value used in the errorreporting client, // cloud.google.com/go@v0.66.0/errorreporting/errors.go. var buf [16 * 1024]byte // ここでruntime.StackでStackを取得 n := runtime.Stack(buf[:], false) return &StackError{ err: err, Stack: buf[:n], } } func (e *StackError) Error() string { return e.err.Error() // ignore the stack } func (e *StackError) Unwrap() error { return e.err }
続いて、WrapStack()
関数を見てみます。Wrap() と処理内容は似ていますね。
渡されたエラーが…
- 既に
StackError
構造体であればWrap()を呼び出し StackError
構造体でなければ、StackError
構造体にする
という処理が追加されています。
(errors.Asについてわからない場合は 公式ドキュメント をご確認ください🙇♂️)
func WrapStack(errp *error, format string, args ...any) { if *errp != nil { if se := (*StackError)(nil); !errors.As(*errp, &se) { *errp = NewStackError(*errp) } Wrap(errp, format, args...) } }
ここでも一つ学びとなったのは、errors.Asを使用する時にワンスコープで書く方法です。筆者は今まで下記のようなコードを書いてしまっていました。細かいところですが、こういうところもしっかり気をつけて、自信を持ってGopherと名乗れるようになりたいですね!
// ❌ func bad(errp error) { // if文の外で変数を定義しているためスコープが広くなる var stackErr *StackError if ok := errors.As(errp, &stackErr); ok { ... } }
WrapStack関数
されたerrからStackを確認する場合は、下記のようなコードになります。
一番下の呼び出し関数でWrapStackをすれば、途中ラップせずともすべてのStackを確認出来ます。
func main() { if err := f(); err != nil { if se := (*StackError)(nil); errors.As(err, &se) { fmt.Println("stack:", string(se.Stack)) } } } func f() (err error) { if err := g(); err != nil { return err } return nil } func g() (err error) { defer WrapStack(&err, "g()") return errors.New("error g") } // stack: goroutine 1 [running]: // main.NewStackError({0x10ccca8?, 0xc000014230}) // /Users/xx/main.go:82 +0x47 // main.WrapStack(0xc00006eed8, {0x10ab3c6, 0x3}, {0x0, 0x0, 0x0}) // /Users/xxx/main.go:65 +0x90 // main.g() // /Users/xxx/main.go:27 +0x98 // main.f(...) // /Users/xxx/main.go:18 // main.main() // /Users/xxx/main.go:10 +0x1d
ログ出力をする場合はloggerのErrorfメソッドなどの実装でStackを出力出来るようにすると良さそうですね。
以上のように、https://github.com/golang/pkgsite ではdeferを上手く使ってエラーのラップを行っています。エラーのログ出力についても、deferを使っている箇所があったりします(コードリンク)。deferはクローズやクリーンアップ、panicのキャッチくらいしか使ったことがなかったので、こんな使い方もあるのかと学びになりました。
func FetchAndUpdateState(...) (_ int, err error) { defer func() { if err != nil { log.Infof(ctx, "FetchAndUpdateState(%q, %q) completed with err: %v. ", modulePath, requestedVersion, err) } else { log.Infof(ctx, "FetchAndUpdateState(%q, %q) succeeded", modulePath, requestedVersion) } derrors.Wrap(&err, "FetchAndUpdateState(%q, %q)", modulePath, requestedVersion) }() ... }
まとめ
ここまで読んでいただきありがとうございました!
今回のコードで、deferを使ったエラーラップやログの出力など、スマートなGoの書き方がとても学びになりました。まだderrorsパッケージとその周辺コードしか確認出来ていないので、もっと pkgsite のコードを読み込んで学びを深めていきたいと思っています。
最後に宣伝です 📣
カミナシでは絶賛採用中です! 一緒に強いチームを作っていく仲間を募集しています! careers.kaminashi.jp