Go
golang
エラー
error
Go4Day 20

Goで独自エラー型を定義する

Goでのエラー判別という記事で、Goのエラーの判別方法にはだいたい3種類あるという話をしました。

  1. どの関数から返されたかで判別
  2. 定義済みの変数と比較して判別
  3. 独自エラー型を定義してキャストして判別

今回の記事では3の独自エラー型を定義する際のノウハウについて扱います。

※ Go2アドベントカレンダーのGoのカスタムエラーとその自動生成についてという記事とかなり内容が被っている気がしますが、ネタかぶりはご容赦ください。
generrはすごく良いツールだと思います!この記事のコードを自動生成したい場合にご利用ください。

それではいくつかの実装例を紹介します。

例1: エラーコードを持たせる

例えばエラーコードを持たせたい場合を考えます。
この場合、エラーコード型にそのままerrorインターフェースを持たせてしまえばいいでしょう。
GoのエラーはインターフェースなのでErrorメソッドさえ持たせればどんな型もエラーにすることができます。

type ErrorCode uint
const (
    ErrCode1 ErrorCode = 1
    ErrCode2 ErrorCode = 2
    // ...
)

func (e ErrorCode) Error() string {
    // あるいはパッケージ変数にErrorCode => エラーメッセージのMapを定義して引くなどしてもよい。
    switch e {
    case ErrCode1:
        return "error code 1"
    case ErrCode2:
        return "error code 2"
    default:
        return "unknown error code"
    }
}

ErrorCode型はerrorインターフェースを持っているのでerrorを返す関数の戻り値にそのまま使うことができます。

func doSomething() error { return ErrCode1 }

エラーコードを取り出すときはキャストします。

    err := doSomething()
    if err != nil {
        code, ok := err.(ErrorCode)
        if ok {
            // この例ではerrがエラーコードでなければデフォルト値をセットしている
            // あるいはErrorCodeでないエラーの場合の処理フローを用意するのもよい
            code = defaultErrorCode
        }
        // ErrorCodeを元になにかのエラーハンドリングを行う
        handleErrorCode(code)
        return
    }

例2: エラーに追加情報を加える

例1ではリポジトリの内部パッケージで定義した関数がErrorCode型のエラーを返すことを期待していました。
しかし、リポジトリ外のパッケージの関数がエラーを返してきたときに、例えばエラーコードを追加したい場合はどうすればいいでしょうか?

そのような場合は追加情報を保持する構造体を定義してその中に元のエラーを埋め込んでしまえばよいのです。

type ErrorWithCode struct {
    error // オリジナルのエラー
    Code ErrorCode // 追加情報(エラーコード)
}

他のエラーが返ってきた場合にErrorWithCode型でラップします。

ちなみにErrorWithCode型はerror型を埋め込んでいるのでそのままerrorインターフェースを持っている型として扱えます。

func doAnotherThing() error {
    // externalpackage.DoSomething は error を返す関数
    err := externalpackage.DoSomething()
    if err != nil {
        // 元のエラーを埋め込んで、エラーコードを追加する
        return ErrorWithCode{ error: err, Code: ErrCode2 }
    }
    return nil
}

エラーコードを取り出すときはキャストします。

    err := doAnotherThing()
    if err != nil {
        errWithCode, ok := err.(ErrorWithCode)
        if !ok {
            // この例ではErrorWithCode型でなければデフォルト値を使う
            handleErrorCode(defaultErrorCode)
            return
        }
        // ErrorCodeを元になにかのエラーハンドリングを行う
        handleErrorCode(errWithCode.Code)
        return
    }

例3: パッケージ間の依存性をなくす

例2ではエラーコードを取り出すたびにErrorWithCode型にキャストしていました。
このやり方だと、別パッケージでエラーコードを取り出したいときは、ErrorWithCode型を定義してあるパッケージを毎回インポートしなければなりません。

また、複数のパッケージでそれぞれ独立にエラーコードをもたせたエラー型を定義した場合、それらのエラーをハンドリングするには、それぞれのパッケージをインポートしなければなりません。

このような場合、Goではインターフェースを用いて解決します。

例えばfooパッケージでErrorWithCodeという独自エラー型を定義しているとします。

foo.DoSomethingErrorWithCodeのインスタンスをerror型で返す関数です。

package foo

type ErrorWithCode struct {
    error // オリジナルのエラー
    Code uint // エラーコード(分かりやすくするため、この例ではuint型とします)
}

func (e ErrorWithCode) GetErrorCode() uint { return e.Code }

func DoSomething() error { return ErrorWithCode{/* ... */} }

別のbarパッケージでは中でfoo.DoSomethingを呼んでエラーを返すような関数を定義します。

package bar

import "foo"

func DoAnotherThing() error { return foo.DoSomething() }

例えばmainパッケージでbar.DoAnotherThingを呼んだ場合、
もし例2のやり方で、エラーコードを取り出そうとするならばfooをインポートしてfoo.ErrorWithCode型にキャストしなければなりません。

しかしインターフェースを使うと以下のようにfooへの依存性をなくすことが出来ます。

package main

// "foo"のimportは不要
import "bar"

// GetErrorCodeを持つインターフェースを定義
type errorCodeHolder interface {
    GetErrorCode() uint
}

func main() {
    err := bar.DoSomething() // 中でfoo.ErrorWithCodeを返している
    if err != nil {
        errCodeHolder, ok := err.(errorCodeHolder)
        if !ok {
            // この例ではerrorCodeHolder型でなければデフォルト値を使う
            handleErrorCode(defaultErrorCode)
            return
        }
        // ErrorCodeを元になにかのエラーハンドリングを行う
        handleErrorCode(errorCodeHolder.GetErrorCode())
        return
    }
}

Goのインターフェースは、構造体やそれを返す関数が定義されたパッケージとは、完全に独立して定義することが出来ます。
ですので、このように実体を定義したパッケージと、そこから情報を取り出すパッケージを分離できるのです。

例4: 元のエラーを取り出す

追加情報でエラーをラップした場合でも、元のエラーを取り出して処理したいケースが存在します。
例えば、io.EOFエラーかどうかを判別する場合、元のエラーでなければ同一判定が出来ません。

そのような場合にはgithub.com/pkg/errorsを使いましょう。

github.com/pkg/errorsはエラーに追加メッセージやスタックトレースを追加するライブラリですが、
元のエラーメッセージを取り出したいときのためにCause関数を用意しています。

github.com/pkg/errorsでラップされたエラーは内部的にCauseメソッドを持っており、このメソッドでラップ元のエラーにアクセスすることが出来ます。

このCauseメソッドを持ったインターフェースをcauserと定義しています。

パッケージ関数の方のCauseは引数のエラーがcauserにキャスト出来たらラップ元のエラーを取り出す処理を再帰的に回していて、最終的には元のエラーを取り出せるという仕組みになっています。

もちろん、独自に定義されたエラー型でもCauseメソッドを定義すれば、causerインターフェースを持ったオブジェクトとして、Cause関数にその型のエラーオブジェクトを渡すことが出来ます。

Goのインターフェースはシグネチャーさえマッチしていれば、定義されたパッケージを問わないからです。
ですから、外のパッケージから渡されたオブジェクトでも、中のプライベートなインターフェースにキャストできるわけです。
とても便利ですね!

// ErrorWithCodeの定義は割愛。
// Causeメソッドを定義して元のエラーを取り出せるようにする。
func (e ErrorWithCode) Cause() error { e.error }
// EOFにエラーコードを付与したオブジェクトを返す
func GetEOFWithCode() error {
    return ErrorWithCode{error: io.EOF, code: /* エラーコード */}
}
err := GetEOFWithCode()

// Cause関数は元のエラーを取り出す
if errors.Cause(err) == io.EOF {
    // ...
}

例5: 複数回ラップしたとき、目当ての情報を取り出す

複数の追加情報を別々の場所で追加するケース、例えば、ある関数の中でエラーコード付きエラーを返して、エラー伝搬した呼び出し元の関数でさらにリクエストIDも追加して…、というようなケースの場合、それぞれの追加情報を取り出すにはどのようにすればいいでしょうか。

github.com/pkg/errors.Causeだと、元のエラーが取り出されてしまい、追加情報にアクセスすることが出来ません。

そのような場合はCauseを参考に独自のヘルパー関数を定義しましょう。

以下の例では、GetErrorCodeメソッドを持つエラーの場合はエラーコードを取り出し、
それ以外でcauserにキャストできる場合は、再帰的に元のエラーを取り出して、型チェックを行うことを繰り返しています。

type errorCodeHolder interface { GetErrorCode() uint }
type causer interface { Cause() error }

func GetErrorCode(err error) uint {
EXT: // for文を抜けるため、外側にラベルを定義している
    for err != nil {
        switch cause := err.(type) {
        case errorCodeHolder:
            return cause.GetErrorCode()
        case causer:
            // Cause持ちの場合は元のエラーを取り出してループ継続
            err = cause.Cause()
        default:
            break EXT // forループの外まで抜ける
        }
    }
    // エラーコードを保持したエラーが見つからなかったり、
    // エラーがnilの場合にはデフォルト値を返す
    return defaultErrorCode
}

このやり方であれば、さらにGetRequestIDなどと、他の追加情報を取り出すヘルパーを併用しても、
それぞれの追加情報を矛盾なく取り出すことが出来ます。

さらに、github.com/pkg/errorsとも併用できます。

例6: エラー発生時の状態を元にハンドリングする

ときにはエラー発生時の状態を元に、呼び出し元で何らかのハンドリングを行いたいときがあります。
そのような場合は関数オブジェクトをラップしましょう。

type ErrorFunc func()
func (e ErrorFunc) Error() string { return /*エラー情報*/ }

func doSomething() error {
    /* 何らかの処理 */
    return ErrorFunc(func() {
        // エンクロージャ(ErrorFuncの外のスコープ)の情報を参照する処理
    })
}
err := doSomething()
if callback, ok := err.(ErrorFunc); ok {
    // ErrorFunc型として定義された関数をコールバックとして受け取るようなエラーハンドラ
    handleErrorWithCallback(callback)
}

例ではシンプルに関数型にerrorインターフェースを持たせていますが、
独自エラーの構造体を定義して、元のエラーとそれを扱うコールバック関数をそれぞれ持たせるなどしてもよいでしょう。

おわりに

ここまで様々な独自エラー型の作り方を紹介してきました。
他にも様々なエラー型が必要に応じて定義できると思います。

僕が強く主張したいのは、Goのerror型というのはただのインターフェース型でしか無いということです。
なので内部実装はいかようにでも定義できるのです。

個人的にはerror型はinterface{}型と同様の扱いをしていいと思っています。
好きなオブジェクトをどんどん放り込みたいような変数や引数の型にinterface{}を使うように、error型ももっと柔軟に活用できるはずです。
ただしそれだとエラーとそうでないオブジェクトの判別が難しくなるため、Errorメソッドを要求しているだけなのです。
つまりErrorメソッドはエラーかそうでない型かを判別するタグのようなものでしか無いと考えてもいいと思います。

というわけで、皆さんも恐れずにどんどん独自エラー型を定義していきましょう。
ただし、それは安全なエラーハンドリングのための道具であることだけは忘れないでください。