あぼぼーぼ・ぼーぼぼ

のんびり生きたい

Goでスタックトレースを出力するには

スタックトレース周りについて調べたので放流。

概要

  • Goでエラーを扱う標準パッケージ errors はGo1.18現在スタックトレースに対応していません。Go2では標準パッケージerrorsにスタックトレースがサポートされる予定です(proposal)。それまでの間、スタックトレースを出力するためには別のパッケージを使う必要があります。
  • その場合の有力な選択肢として pkg/errorsxerrors があります。基本このどちらかを使えば良いですが、どちらを使えば良いかは色々な情報があり迷いどころです。これらの特徴を整理して、スタックトレースを扱いたい人が読めばなんとなく何をどう選べばいいかがわかります。

結論

  • フォーマットミスを意識したくない場合はpkg/errors、標準のerrorsパッケージに似たAPIで使いたい場合はxerrorsがおすすめ。
  • errors.New()による生成や、fmt.Errorf()によるラップは、それまでのスタックトレース情報が無くなるためスタックトレースを出力したいなら基本使わない。

スタックトレースを使いたい場合の主な選択肢

準標準パッケージであるxerrorsか、pkg/errorsの2つが一般的な模様。いくつか出典を紹介します。

実用Go言語

Go 1.16では標準ライブラリであるerrors.New()やfmt.Errorf()を使ってエラーを生成した場合、スタックトレースを出力する方法はありません。スタックトレースを出力させたい場合pkg/errorsやgolang.org/x/xerrorsといったライブラリを使う必要があります。

書籍『実用Go言語』p106

プログラミング言語Go完全入門

スタックトレースを付加する - pkg/errors.WithStackすると付加される - pkg/errors.Wrapでも可 - xerrorsでも付加されるがerrorsでは付加されない(Go 1.13)

プログラミング言語Go完全入門 7. エラー処理 @tenten

Gophers Slack

Steve Coffman: So I know that both pkg/errors and x/xerrors have stacktraces, but in the post-Go 1.13 world that didn't bring that along, is there a package that only provides errors with stacktraces? Tim Heckman: I'm not sure I understand the question, because my initial answer are the two packages you identified. Gophers Slackの#newbiesにて

どれを使えばいい?

プロジェクト内で統一されていればどちらでも良いと思います。ただいくつか論点があります。

更新頻度

pkg/errorsは、かつてよりメンテナンスモードに移行することがアナウンスされており、2022年5月現在リポジトリはアーカイブされています。

xerrorsは、エラーのラップに関する機能がGo 1.13で標準パッケージのerrorsやfmt.Errorfに組み込まれたことにより一部機能がdeprecatedになっています。ですが標準パッケージのerrors自体はスタックトレース非対応のままなので、deprecatedな機能も引き続き使うことになります。

pkg/errorsがアーカイブされているならxerrorsを使った方がいいか?というと、そうではないと思います。エラーハンドリング周りは現在特に機能追加の必要性もなくAPIが安定しているため、両方とも安心して使うことができると思っています。

標準パッケージerrorsへの移行コスト

Go 2では標準パッケージであるerrorsにスタックトレースがサポートされる予定です。標準パッケージでやりたいことができそうなら、「繋ぎ」として使うものはGo 2におけるerrorsパッケージへ移行しやすい形だと嬉しいです。

これについては、pkg/errorsがWrap()、WithMessage()、WithStack()、Cause()と割と独自のAPIを提供しているのに対して、xerrorsはerrors同様フォーマットを用いたAPIが基本なのでどちらかといえばxerrorsの方が移行コストが低そうです。ただpkg/errorsもシンプルなAPIなのでそこまで大きな差はないと思います。

使いやすさ・運用しやすさ

Goはエラーハンドリングをたくさん書くので、使いやすさは大事なポイントです。

両者の特徴に関しては Golang Error Handling — Best Practice in 2020 による整理が分かりやすいです。この記事ではpkg/errorsと比較するとxerrorsには次の2つの問題があると言っています。

  1. 基本的な使い方であるフォーマットによるエラーのラップで、フォーマットを間違うとエラーメッセージが変になるし、その間違いがコンパイルエラーにならないので気づきにくい。
  2. エラーのnilチェックが必要

1について、エラーをラップするフォーマットは : %wであり、コロンを忘れたり foo %w 、スペースを忘れて foo:%w と書くと、エラーメッセージに重複が発生します。また、このフォーマットの違いはコンパイルエラーにならないため気づくことが難しく、Linterを自作するなどしないとレビュアーの負担にもなります。

例えばxerrorsを使った以下のコードをGo Playgroundで動かすと、正常にスタックトレースが出力されます。

package main

import (
    "database/sql"
    "fmt"

    "golang.org/x/xerrors"
)

func foo() error {
    return xerrors.Errorf("foo failed: %w", bar()) //ココ
}

func bar() error {
    return xerrors.Errorf("bar failed: %w", sql.ErrNoRows)
}

func main() {
    fmt.Printf("%+v\n", foo())
}

foo failed:
    main.foo
        /tmp/sandbox3601724689/prog.go:11
  - bar failed:
    main.bar
        /tmp/sandbox3601724689/prog.go:15
  - sql: no rows in result set

このコードで ココ とコメントした箇所のフォーマットを "foo failed: %w" から "foo failed:%w" に変更して動かしてみると、以下のようにスタックトレース中のメッセージが変になってしまいます。

foo failed:bar failed: sql: no rows in result set:
    main.foo
        /tmp/sandbox2690898140/prog.go:11
  - bar failed:
    main.bar
        /tmp/sandbox2690898140/prog.go:15
  - sql: no rows in result set

2のエラーのnilチェックが必要について、xerrorsではnilをラップするとエラーを生成しますが、pkg/errorsではnilをラップしようとしてもエラーを生成せずnilを返します。なのでpkg/errorsのほうが場合によってはnilチェックを省略することができます。

// xerrorsを使ったfoo()はラップしようとしたerrorがnilでも新しいerrorを返す
func foo() error {
    return xerrors.Errorf("foo failed: %w", nil)
}

foo failed: %!w(<nil>):
    main.foo
        /tmp/sandbox1009969294/prog.go:10

// pkg/errorsを使ったfoo()はラップしようとしたerrorがnilならnilを返す
func foo() error {
    return errors.WithMessage(nil, "foo failed")
}

<nil>

これらの違いから、僕は冒頭の結論に至りました。

スタックトレースを使いたいなら気を付けること

errors.New()でエラーを生成したり、fmt.Errorf()でエラーをラップすると、それまでのスタックトレース情報が無くなります。

前述したGo Playgroundのコードに出てくるfoo()の中でfmt.Errorf()を使ってラップすると、以下のようにbar()の位置情報が記録されません(fmt.Errorf()を使ったサンプルコード)。

foo failed:bar failed: sql: no rows in result set:
    main.foo
        /tmp/sandbox526836328/prog.go:11
  - bar failed: sql: no rows in result set

また、同様に今度はbar()の中でerrors.New()を使ってエラーを生成した場合のスタックトレースも以下のようにbar()の位置情報が記録されません(errors.New()を使ったサンプルコード)。

foo failed:bar failed:
    main.foo
        /tmp/sandbox3786666462/prog.go:11
  - bar failed