pkg/errorsの導入時につまづいたところのメモ

最近、既存のプロジェクトにpkg/errorsを導入する機会がありました。使い方のガイドライン開発者のブログ記事の後半部分にあったので、参考にさせて頂きました。大体問題なく導入できましたが、悩んだ・つまづいたところもあったのでメモしておきます。

そもそもpkg/errorsとは、という説明は省略しますので、必要であればgodocを参照して下さい。

1.関数の引数に渡す関数

関数への引数として関数を渡す時、渡す関数の返すエラーがpkg/errorsで包まれていると、期待通りに動かないことがありました。

例えば、ioutil.ReadAllです。この関数は、引数で与えられたio.ReaderのReadメソッドを、Readメソッドがio.EOFを返すまで呼び続けます。io.EOFではないエラーが返ってきた場合は、エラーを返して終了します。もしpkg/errorsで包まれたio.EOFが返ってくると、ioutil.ReadAllio.EOFではないエラーが返ってきたとみなして終了してしまいます。

従って、以下のコードはioutil.ReadAllがエラーを返して終了します。

package main

import (
    "fmt"
    "io"
    "io/ioutil"
    "log"

    "github.com/pkg/errors"
)

type OneTimeReader struct {
    called bool
}

func (r *OneTimeReader) Read(p []byte) (int, error) {
    if r.called {
        return 0, errors.Wrap(io.EOF, "eof")
    }
    r.called = true
    return copy(p, "Hello pkg/errors!"), nil
}

func main() {
    r := &OneTimeReader{}
    b, err := ioutil.ReadAll(r)
    if err != nil {
        log.Fatal(err)  // ここでエラー終了
    }
    fmt.Printf("%s\n", b)
}

対処としては、関数の引数に渡す関数がio.EOFのような定義済みのエラーを返す必要がある場合はpkg/errorsで包まないようにしました。間違いに気づけば対処は簡単ですが、ちょっと見逃しやすいなーと思いました。僕はテストがFailして気づきました。

2.関数の引数に渡すエラー

1と類似した問題として、関数の引数にエラーを渡す際、エラーがpkg/errorsで包まれていると、期待通りに動かないことがありました。

例えば、os.IsNotExist()のようなエラーを判別する関数にpkg/errorsで包んだエラーを渡すと、trueを返すべき場合でもfalseを返してしまいます。

対処はerr == os.ErrNotExistのような比較をする場合と同様に、errors.Cause()でオリジナルのエラーを取り出すだけです。こちらもちょっと気づきにくいケースでした。

3.定義済みのエラーがもつスタックトレース情報

Goの場合、エラーの種別をos.ErrNotExistのような定義済みのエラーとの比較で判定しているのをよく見かけます。自分たちでも同様の方法でエラー判定をすることがありますが、これをそのままpkg/errorsと組み合わせると、スタックトレース情報があまり意味のないものになってしまいました。

例えば以下のようなエラーを自分で定義しておいたとすると、スタックトレースにはエラーを定義した時点のスタックが残ってしまいます。これだと、実際のエラー発生箇所がスタックトレースから分かりません。

var ErrNotExist = errors.New("file does not exist")

そこで今回は、関数内でエラーを返すときに定義済みのエラーをerrors.Wrap()で更に包むことにしました。スタックトレースが二重に出てしまいますが。。

エラー判別用の関数を使ってみる

まだ実戦で試せてませんが、より良さそうなのは、こちらの記事で紹介されているエラー判別用の関数を使う方法です。例えば以下のようなコードでは、IsNotExistがそれにあたります。

type myError struct {
    msg      string
    notExist bool
}

func newMyError(msg string, notExist bool) error {
    return errors.WithStack(&myError{msg, notExist})
}

func (e *myError) Error() string {
    return e.msg
}

func (e *myError) NotExist() bool {
    return e.notExist
}

type notExist interface {
    NotExist() bool
}

func IsNotExist(err error) bool {
    notExistErr, ok := errors.Cause(err).(notExist)
    return ok && notExistErr.NotExist()
}

ポイントとしては以下かと思います。

  • エラーを受け取る側はIsNotExist()だけ知っていればいい(myError構造体のことや、エラー判別時のerrors.Cause()のことも知らなくていい)
  • IsNotExist()はmyError構造体以外も利用できる
  • 問題が起きた箇所でエラーを生成するので、意味のあるスタックトレース情報を残せる

4.独自のerror構造体

pkg/errorsを利用することでエラーにスタックトレース情報を含めることができますが、場合によっては他の情報も含めたいことがあります。そういう場合は独自のerror構造体を定義すると思いますが、スタックトレース情報はどうやって残すと良いでしょうか。

今回は独自のerror構造体を、生成時にpkg/errorsで包むようにしました。例えば以下のような感じです。

type MyError struct {
    msg  string
    info string
}

func NewMyError(msg, info string) error {
    return errors.WithStack(&MyError{msg, info})
}

func (e *MyError) Error() string {
    return e.msg
}

func (e *MyError) Info() string {
    return e.info
}

最初は逆に、独自のerror構造体の中にpkg/errorsのerrorを入れようとしていたのですが、fmt.Printf("%+v", err)で出力する時にスタックトレースが出なくなるので、今の形にしました。

ちなみにこのようなerror構造体についても、上述したエラー判別用の関数のような仕組みにしておけば、より綺麗にInfo等の付随情報を引き出せるかと思います。

感想

既存のプロジェクトへpkg/errorsを導入する際につまづいたところのメモでした。間違いにテストの失敗で気付くことが多く、既存のプロジェクトに入れる場合はテストがちゃんと書かれているのが大前提だなーと思いました。

ここに書いた対処は、これがベストという自信は全くないので、良さそうなやり方がありましたらぜひ教えてください。