Help us understand the problem. What is going on with this article?

Go1.13からは今までのエラーハンドリングが機能しなくなるかもしれない

More than 1 year has passed since last update.

8月中にリリース予定の Go1.13 から、標準パッケージ errors にエラーの発生元を特定しやすくするための 新しい機能 が導入されます。
Go1.13以前は github.com/pkg/errors や、最近では errors パッケージとほぼ互換性のある golang.org/x/xerrors など、外部パッケージを利用してスタックトレースをしていた(る)かと思います。

errors パッケージに新しい機能が導入されることで、外部パッケージをわざわざ用いなくて良くなり、スタックトレース付きエラーの導入ハードルが下がる一方で、今まで行っていたエラーハンドリングがうまく機能しなくなる場合があります。

本記事では、エラーハンドリングの種類別にどううまくいかないのか述べた後に、Go1.13以降ではどうエラーハンドリングすべきか説明していきます。

※ 本記事執筆時点ではまだ Go1.13 がリリースされていないので、errors パッケージの代わりに、互換性のある golang.org/x/xerrors を用いて説明します。

スタックトレースのつくり方

func g() error {
    err := f()
    return xerrors.Errorf("f: %w", err)
}

xerrors パッケージでは、既存のエラーからスタックトレース用のエラーに変換するためにフォーマットを使用します。そうして変換されたエラーは、スタックトレースに必要な文字列(メッセージ)だけを持たせることもできますが、フォーマット指定子を : %w1にすることで、元のエラーの値も保持する(Wrapする)ことができます。

うまくいかない場合と、その解決方法

: %wを指定して変換したエラーは、元のエラーの値は保持していますが、元のエラーの値とは同値ではありません。そうすると察しがつくかもしれませんが、エラーハンドリングをする際に起こる不都合を回避するために一工夫が必要であることが分かります。

それではエラーハンドリングの方法別に見ていきましょう!

値の比較によるエラーハンドリング

例えば、RDBから取得できるレコードがなかったときのエラーハンドリングはこのように行っていました。2

const id = 123

func g() error {
    var username string
    err := db
        .QueryRowContext(ctx, "SELECT username FROM users WHERE id=?", id)
        .Scan(&username)

    if err != nil {
        return err // そのまま返す
    }

    // ...

    return nil
}

func f() {
    err := g()
    // "==" で判別可能
    if err == sql.ErrNoRows {
        log.Printf("no user with id %d\n", id)
    }
}

取得可能なレコードがなかった時のエラーの値は database/sql.ErrNoRows なので、比較演算子 == で判別できます。

しかし、xerrors を用いて変換したエラーは == ではうまく判別できません。

const id = 123

func g() error {
    var username string
    err := db
        .QueryRowContext(ctx, "SELECT username FROM users WHERE id=?", id)
        .Scan(&username)

    if err != nil {
        return xerrors.Errorf("main.g: %w", err)  // xerrorsでWrap
    }

    // ...

    return nil
}

func f() {
    err := g()
    // err は sql.ErrNoRows ではなく、
    // &xerrors.noWrapError{} or &xerrors.wrapError{} なので判別できない
    if err == sql.ErrNoRows {
        log.Printf("no user with id %d\n", id)
    }
}

そこで、新しく実装された xerrors.Is を用います。xerrors.Is は今までWrapしてきたエラーを再帰的に遡って比較していくため、うまく機能します。

func f() {
    err := g()
    // xerrors.Is で判別可能
    if xerrors.Is(err, sql.ErrNoRows) {
        log.Printf("no user with id %d\n", id)
    }
}

また、元エラーの値が nil の場合でもWrapされている可能性があるので、Go1.13以降では安全をとって xerrors.Is でエラーハンドリングすると良いでしょう。

func g() error {
    return xerrors.Errorf("main.g: %w", nil)
}

func f() {
    err := g()
    // xerrors.Is で判別可能
    if xerrors.Is(err, nil) {
        log.Printf("no user with id %d\n", id)
    }
}

ちなみに xerrors.Is(nil, nil)true です。

型アサーションによるエラーハンドリング

型でエラーハンドリングする場合はどうでしょう?

例えば http.Client.Do メソッドがリクエストの Timeout によって返すエラーは *url.Error 型なのですが、この型は Timeout 以外のエラーにも用いられるため、これだけでは Timeout によるエラーなのか断言できません。*url.Error に生えている (*url.Error).Timeout メソッドの戻り値が true であればようやく Timeout によるエラーだったと断言することができます。
しかし、インターフェースであるエラーからは直接 (*url.Error).Timeout メソッドは呼べないので、型アサーションを使って具体的な値を取り出してからこのメソッドを呼びます。

func g() error {
    resp, err := client.Do(req) // リクエストがタイムアウトしたと仮定する
    if err != nil {
        return err // そのまま返す
    }
    // ...
    return nil
}

func f() {
    err := g()
    if e, ok := err.(*url.Error); ok { // 型アサーションでエラーの値を取り出す
        if e.Timeout() { // (*url.Error).Timeout() でタイムアウトによるエラーか調べる
            // ...
            return
        }
        // ...
    }
}

値の比較によるエラーハンドリングと同様に xerrors で元のエラーを変換すると、こちらもそのままではうまくエラーハンドリングできません。これは xerrors で変換されたエラーの型が *xerrors.noWrapError*xerrors.wrapError であるためです。

比較によるエラーハンドリングの場合は、比較演算子 == の代わりに xerrors.Is メソッドを用いてましたが、型アサーションによるエラーハンドリングの場合は xerrors.As メソッドを用います。

xerrors.As メソッドの使い方としては、まずは encoding/json.Unmarshal メソッドや github.com/labstack/echo.Context.Bind メソッドのように注目する型の変数を用意し、次にエラーの値をバインドします。

このときバインドに成功する、つまりWrapしてきたエラーのスタック中に注目する型のエラーが含まれている場合、用意した変数にそのエラーの値が格納され、 xerrors.As メソッドの戻り値は true になります。逆にエラーのスタック中に注目する型の値が含まれていない場合はバインドに失敗し、 xerrors.As メソッドの戻り値は false になります。

そしてその後は、それらの真偽に従い処理を行っていきます。

func g() error {
    resp, err := client.Do(req) // リクエストがタイムアウトしたと仮定する
    if err != nil {
        return xerrors.Errorf("main.g: %w", err) // xerrors で Wrap する
    }
    // ...
    return nil
}

func f() {
    err := g()
    var e *url.Error
    // xerrors.As でエラーの値を取り出す。
    // バインドに成功すると e に元のエラーが格納され、 xerrors.As 戻り値が true になるので、
    // if 文の中が実行される
    if xerrors.As(err, &e) {
        if e.Timeout() { // (*url.Error).Timeout() でタイムアウトによるエラーか調べる
            // ...
            return
        }
        // ...
    }
}

型スイッチによるエラーハンドリング

型スイッチによるエラーハンドリング型アサーションによるエラーハンドリング の応用編で、つまりは次のようなエラーハンドリングを指します。(ぱっと型スイッチに適したエラーが思いつかなかったので、hogefugaで代用しています)

err := f()
switch err.(type) {
case HogeError:
    // ..
case FugaError:
    // ...
default:
    // ...
}

これは型アサーションによるエラーハンドリングと同様に、xerrors によって変換されていないエラーであればうまく機能しますが、変換されている場合は機能しないことがお分かりいただけるでしょう。

その解決策はあまりスマートではありませんが、 switch 文を if-else-if 分に書き換え、条件分岐に入る前に必要な型の変数を用意して、 xerrors.As でバインドしていきます。3

err := f()
var hogeErr HogeError
var fugaErr FugaError
if xerrors.As(err, &hogeError) {
    // ..
} else if xerrors.As(err, &fugaError) {
    // ...
}

もし別のやり方を思いついた方がいらっしゃいましたら、気軽にコメント欄までお願いします!

結論

Go1.13以降でエラーハンドリングするときはちょっと面倒ですが安全をとって、値でチェックする場合は == ではなく errors.Isを、型でチェックする場合は errors.As を使ったほうが良いのかなー?と思います。

Appendix

@sachaos さんが lint ツールを作ってくださっています:pray:

参考


  1. %wではありません 

  2. https://golang.org/pkg/database/sql/#DB.QueryRowContext 

  3. 公式wikiで推奨されているやり方です > Rewrite a type switch as a sequence of if-elses. < 

cia_rana
Go/Ruby/渋谷の某3DCGプロダクションで働くそふとうぇあえんじにゃーん/Creator Support Engineer
https://medium.com/@cia_rana/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away