カミナシ エンジニアブログ

株式会社カミナシのエンジニアが色々書くブログです

Goでスタイリッシュにエラーをラップする方法を学んだ

こんにちは。カミナシ ソフトウェアエンジニアの @aoman です。

つい先日、Goで有名な@tenntennさんがConnpassで募集していたGopher塾#2に参加させていただきました。

tenntenn.connpass.com

大変勉強になりおすすめです!筆者が参加したのは第一回目ですが、二回目三回目と予定されているようなので、有料講義ではありますが気になる方はぜひ参加してみてください!学生さんであれば無料の抽選枠もあります。

その際に紹介されていたコードで、エラーのラップ関数があったのですが、これが「メッチャアタマイイ!!スタイリッシュ!!」と感動しました。そのコードは、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