Go
error

Goのエラー処理とpkg/errorsの使い方

やっぱり自分なりにまとめないと理解した気になれないので、まとめてみることにする。

https://godoc.org/github.com/pkg/errors

Golangにおけるエラー処理(おさらい)

Goは言語仕様として例外機構(Exception)がない。例外ではなく、複数の戻り値を返せるという特徴を利用し、最後の戻り値をエラーに割り当てるということをする。throwを書きたくなったら代わりにreturnを書くわけだ。

import "fmt"

// helloはnameが空だったらエラー扱いにする
func hello(name string) (string, error) {
    if name == "" {
        return nil, fmt.Errorf("name is invalid")
    }
    return fmt.Sprint("hello %v", name)
}

関数を実行する場合、戻り値のerrorを受け取り、何かが返っていたら適切な後始末を行うようにする。この時、慣例として err という変数名が使われることが多い。

msg, err := hello(v)
if err != nil {
    return err
}

エラーの後始末とは、だいたい以下のようなパターンになるだろう。他の言語で例外をcatchしているのとイメージは同じである。

  • 無視して(デフォルト値を使うなどして)処理を続行する
  • リトライする
  • 中断して、関数の呼び出し元にエラーを伝搬する
    • 別のエラーにまとめた上で伝搬するパターン
    • 何らかの後処理を行った上で、伝搬するパターン

エラーの後始末はアプリケーションによるので、ここでは具体的な内容を論じない。

Go標準の errors.New() / fmt.Errorf()について

error というのは、言語組み込みのインターフェースであり、何もimportしなくてもよく、暗黙的にどこでも使うことができる。

type error interface {
    Error() string
}

Error handling and Go - The Go Blog

errors という標準パッケージも存在し、 error のインターフェースを満たすデフォルトの実装がある。

ただ、あまりにもシンプルなので、明示的に使うケースは少ないと思う。
https://github.com/golang/go/blob/master/src/errors/errors.go

func New(text string) error

fmtパッケージに、errors.New()にSprintの機能を併せ持つ fmt.Errorf() という関数があるので、これを使ってる人が多いのではなかろうか。

// Errorf formats according to a format specifier and returns the string
// as a value that satisfies error.
func Errorf(format string, a ...interface{}) error {
    return errors.New(Sprintf(format, a...))
}

エラーの伝搬

どの経路で関数が実行されたかによって、バグの特定がしやすくなることは多い。

  • A <- B <- C ...
  • A <- Z <- Y ...

Goの標準エラーでは単なる文字列しか記録していないので、こういうスタックトレースのようなものは含まれていない。

擬似的にスタックトレースを実現する手段として、先ほど紹介した fmt.Errorf() を使ってエラーを作り直すという技がある。Sprintなどを使うとerrorインターフェースは文字列化できることを利用する。

resp, err := c.request(req)
if err != nil {
    return fmt.Errorf("client error occurred: %s", err)
}

A <- B <- C の方向で関数を実行していたら、エラーメッセージは
C msg: B msg: A msg
の順番で構成されるため、呼び出しの経路がわかるようになる。

ただし、後述するpkg/errorsのWrap/Wrapfを使うほうが優れているので、そちらを使うほうが良い。
また、スタックトレースだけであれば実現する手段は他にもあるので、このやり方をあまり考えなしに使わないこと。

エラーメッセージの慣例

上記の伝搬のようなことをするため、一般的にエラーメッセージは以下のルールに則っていることが多い。

  • 英語にする
  • 小文字から始める (連結されても変にならないよう、完結した文章にしない)

これは、いくつかのlintツールでも指摘されるようになっている。

標準エラーに欠けているもの

エラーとは、例外とは何だろうか。

まず、後処理に困っていることを表現している。その場で後始末して復帰できるエラーであれば、復帰してしまえば良い。それができず、returnして抜けようとするのは、その場だけでは判断がつかないからだ。

また、後始末が場合によって異なる場合に、あえて未実装にして残しているということもある。CLIのツールとWebAPIサーバーではエラーの表示の仕方も異なるだろう。

などなど考えていると、エラーとは以下のような要件を満たすものになるはずだ。

  • 何が起きたのかを教えてくれるもの
    • 発生した関数、ソースコードのファイル名、行番号
    • スタックトレースがあるとより分かりやすい
    • 実行時の引数
  • どうすれば復旧するのか教えてくれるもの
    • 呼び出し側が悪いなら、どう悪いのか/どう直せばいいのか
    • リトライで直るのか / 諦めるしかないのか / 時間を空けたほうがいいのか

Goの標準機能では、もの足りなさを感じてしまう。

pkg/errors

標準errorだけでは物足りない。そこで、準・標準的に使われているパッケージに、 github.com/pkg/errors がある。

https://godoc.org/github.com/pkg/errors

素直にimportするとGo標準のerrorsと名前がかぶってしまうが、APIは上位互換でありerrors.Newも存在しているので、特に困らないと思う。 これを使うところから話は始まる

標準errorに加えて、こんな機能を持っている。pkg/errorsが提供するerror型もあるし、他のerror型に対して機能を追加することもできる。

  • fmt.Errorf()より良い、エラーの再throw機能
  • スタックトレースを収集する機能
  • スタックトレースを表示する機能

順番に見ていこう。

エラーのWrapping/再throw

errors.Wrap()を使うと、fmt.Errorf()で再生成するよりも良い形で情報を付与することができる。

func Wrap(err error, message string) error
func Wrapf(err error, format string, args ...interface{}) error

fmt.Errorf("追加情報: %s", err)では、せっかくのエラーがただの文字列になってしまう。errors.Wrapは純粋にerrを保持するため、情報が消えることがない。そのためこちらを使ったほうが良い。

また、: %sなどを書かなくても慣例に沿ったエラーメッセージを生成してくれるため、便利だ。

resp, err := c.request(req)
if err != nil {
    return errors.Wrap("client error occurred", err)
}

errors.Wrap()したものを更にerrors.Wrap()することもできる。何回やっても大丈夫。

オリジナルのエラーを取り出す

wrappingされまくったエラーから原初のエラーを取り出すために、errors.Cause()を使う。

func Cause(err error) error

どんなに入れ子が深くても、最初の一個まで遡って取り出してくれる。また、エラーが辿れなかったり、nilが渡された場合はその時点で探索を打ち切るので、気軽に errors.Cause(err) と実行してしまって大丈夫。

Cause()があるから何回Wrap()しても大丈夫。

これを理解するのが第一歩である。

スタックトレースを収集する / 表示する

pkg/errorsにも組み込みのエラー型があるが、実はスタックトレースを収集する機能がある。

main.go
package main

import (
    "fmt"
    "github.com/pkg/errors"
)

func main() {
    fmt.Print(errors.New("nyaaa"))
}

これだけだと nyaaa と表示されるだけだけど、以下の風に %+v でフォーマットすると、スタックトレースが見えてくる。これは、fmt.Formatterインターフェースを実装することで実現されているやり方。

main.go
package main

import (
    "fmt"
    "github.com/pkg/errors"
)

func main() {
    fmt.Printf("%+v", errors.New("nyaaaa"))
}

nyaaaa
main.main
        /Users/hiraku/sandbox/golang/errors/main.go:9
runtime.main
        /usr/local/Cellar/go/1.10.2/libexec/src/runtime/proc.go:198
runtime.goexit
        /usr/local/Cellar/go/1.10.2/libexec/src/runtime/asm_amd64.s:2361

独自にerror型を定義している場合も多いと思う。その場合、errors.WithStack(err)でスタックトレース情報だけ追加することも出来る。

独自エラーの作法

error型は独自に定義することもできる。

type myError struct {}

func (e *myError) Error() string {
    return "myError!"
}

例えばHTTPリクエストのエラーだったら、どのURLで、どんなヘッダを付けてリクエストしたらエラーだったのか含められると便利だ。

type requestError struct {
    url string
    headers []string
}

func (e *requestError) Error() string {
    return fmt.Sprint("request error: %s", e.url)
}

func (e *requestError) Headers() []string {
    return e.headers
}

しかし、この場合も *requestError を戻り値として宣言するのは、あまりよろしくない。
request関数自体が返すエラーだけであれば良いかもしれない。ただ、request関数が内部で実行している別の関数も、エラーを返すことがきっとあるはずだ。

// こういうのは良くない
func request(url string) (string, *requestError) {
    resp, err := foo.request(url)
    if err != nil {
        return "", err // これができなくなっちゃう
    }
    // ...
}

戻り値の型宣言を*requestErrorにしてしまうと、こういう素通しのエラーが返せなくなってしまう。

なので、独自エラーを定義したとしても、基本的にエラーの型宣言は error に統一するべきである。

func request(url string) (string, error) {
    // ...
}

この点はJavaなどの @throws の感覚で戻り値型を使ってはいけないということなので、注意が必要だ。

では、独自に拡張したメソッドはどうやって使うか。
型アサーションを行い、成功したらその独自エラー型だとみなしてエラー処理をするようにする。

resp, err := request("https://packagist.jp/packages.json")
if err != nil {
    if rerr, ok := err.(RequestError); ok {
        // RequestError型のメソッドを使った処理を書く
        // ...
    } else {
        return nil, err
    }
}

もちろん、複数の型アサーションを重ねたい場合はswitch文を使うときれいに書ける。

resp, err := request("https://packagist.jp/packages.json")
if err != nil {
    switch err.(type) {
    case RequestError:
        // RequestError型のメソッドを使った処理を書く
    case ConnectError:
        // ConnectError型のメソッドを使った処理を書く
    // ...
    }
    return nil, err
}

独自エラーと型アサーションの流儀

型アサーションはいいのだけど、では先程のコード例で見たRequestErrorやConnectErrorは具体的にどういう型が望ましいか? という点に関しては議論があると思う。

構造体を公開しちゃうパターン

素朴にやると、独自エラーの実体である構造体を公開するのではないだろうか。

type RequestError struct {
    url string
    headers []string
}

func (e *RequestError) URL() { /* ... */ }

ただ、これだと無駄なものまで公開してしまいがちだし、基本的に継承を持たないGo言語としては拡張性が乏しくなってしまう。

公開用インターフェースだけ公開するパターン

あえてインターフェースのみを公開すると、より拡張しやすい形になる。

type RequestError interface {
    Error() string
    URL() string
    Headers() []string
}

// エラーの実体は公開しない
type requestError struct {
    url string
    headers []string
}

// *requestErrorに対する実装色々

インターフェースを使って後処理を書くことになるので、URL()やHeaders()などの必要な情報を取得するためのメソッドが必要になる。 (構造体のメンバを直接publicにするなどの横着をしてはいけない)

コピペで依存関係を完全に切り離すパターン

多くの場合、エラーを発生させる関数と、そのエラーを受け取って後始末をする関数は、別のパッケージに所属しているだろう。

こういう場合、インターフェースを後始末側で定義してしまえば、完全に依存を切り離すことができる。(少なくともエラーに関しては、だが。)

requestパッケージ
type requestError struct {
    url string
    headers []string
}

// *requestErrorに対する実装色々
mainパッケージ
type requestError interface {
    error
    URL() string
    Headers() []string
}

// ...
    resp, err := request.request("https://packagist.org/packages.json")
    if err != nil {
        if rerr, ok := err.(requestError); ok {
            // ...
        }
    }

型アサーションに使っているrequestErrorが、mainパッケージ側で定義されていることに注目。ただ、個人的にはrequestパッケージが読みにくくなるし、ここまでしなくてもいいんじゃないかと思ってる。

pkg/errorsと独自エラーの型アサーションの組み合わせ

ちなみに、pkg/errorsでWrappingが重なっている場合は、 errors.Cause(err) でオリジナルのエラーが取り出せるのだった。

なので、通常はこう書くんじゃないかな。

mainパッケージ
resp, err := request.request("https://packagist.org/packages.json")
if err != nil {
    if rerr, ok := errors.Cause(err).(requestError); ok {
        // rerrに生えているメソッドを使ってエラー処理を書く
        // ロギングや素通しする際はerrを使う
    }
}

小並感

Go言語は例外を使わないという選択をしたけど、なんだかんだで例外の知識は結構使いまわしができるので、勉強しててよかった。

参考