Java
Go
golang
error
Try-Catch

【Golang Error handling】エラーの種類によって処理を分けるBESTな方法

はじめに

Golangでエラーの種類によって処理を変えたい際にどういった方法があるか調べてみました。

と言うのもGolangにはtry-catchがサポートされていません。
なのでGolangでJavaのtry-catchの様な機能をどう実現できるか調べようとした事がこの記事を書いた発端です。

記事の全体概要についてです。
・まず最初に、errorパッケージのよく使う4つの機能について説明します。
・次に、エラーの種類によって処理を変えるために考えられる3つの方法を順に試していきます。
・そしてエラーの種類によって処理を分けるBESTな方法の結論。
といった順序で進めていきます。
※結論だけ知りたい人は最後の方法3をみてください。

errors packageのよく使う4つの機能

標準のエラーパッケージだと以下の機能がありません。

  • 最初に起きたエラーの種類の判別
  • スタックトレースの情報を取得

なので基本的にerrorsパッケージを利用します。
それではerrorsの機能をみていきます。

機能1 func New(message string) error

以下の様にエラーメッセージ文字列で指定して単純にエラーを生成する時に使います。

err := errors.New("エラ~だよ~ん")
fmt.Println("output: ",err)
// output:エラ~だよ~ん

機能2 func Errorf(format string, args ...interface{}) error

以下の様にフォーマット形式と、エラーメッセージ文字列を指定してエラーを生成する時に使います。

err := errors.Errorf("output: %s", "エラ~だよ~ん")
fmt.Printf("%+v", err)
// output: エラ~だよ~ん

機能3 func Wrap(err error, message string) error

以下の様に元のerrorをラップする時に使います。

err := errors.New("repository err")
err = errors.Wrap(err, "service err")
err = errors.Wrap(err, "usecase err")
fmt.Println(err)
// usecase err: service err: repository err

重要な機能なのでもう少し詳しく説明します。
例えば、usecase層→service層→repository層と階層が深くあっても、
最初のエラーをwrapしていくことで、上位に下位のエラー情報を持っていくことが出来ます。
結果として、エラーが起きた原因の特定がしやすくなります。

また今回は気にしていませんが、原因を早く特定するためにエラーメッセージにfunction名を含める事が一般的らしいです。
メッセージ同士が繋がるので、自然なエラーメッセージになるよう組み立てる必要もあります。

機能4 func Cause(err error) error

wrapしたエラーから最初のエラーメッセージを引き出す時に使用します。
一番最初に起きたエラーの原因を特定する際に非常に有効です。

err := errors.New("repository err")
err = errors.Wrap(err, "service err")
err = errors.Wrap(err, "usecase err")
fmt.Println(errors.Cause(err))
// repository err

BESTなエラーハンドリングを求めて

これから3つのエラーハンドリング方法について説明します。
1つ1つ何が問題か、どうその問題を解決できるかをみていきます。

  • 方法1 エラー値による判定
  • 方法2 エラー型による判定
  • 方法3 インターフェイスを使った判定

結論としては、方法3のインターフェイスを使った判定がベストな方法かと思います。
(もっとこんな方法あるぜ!といったのがあればコメントで指摘してもらえるとありがたいです。)
※もちろん、ケースに合わせて柔軟に使い分ける事が重要ではあります。

方法1 エラー値による判定

コード

var (
    // 起こり得るエラーを定義
    ErrHoge = errors.New("this is error hoge")
    ErrFuga = errors.New("this is error fuga")
)

func Function(str string) error {
    // 処理によって異なるエラーを返却
    if str == "hoge" {
        return ErrHoge
    }else if str == "fuga" {
        return ErrFuga
    }
    return nil
}


func main()  {
    err := Function("hoge")

    switch err {
    case ErrHoge:
        fmt.Println("hoge")
    case ErrFuga:
        fmt.Println("fuga")
    }
}

まとめ

関数(Function)の戻り値がErrHogeかErrFugaなのかをswitch文で判定し、処理を振り分けています。
これはあまり良くない方法だと思います。
問題は、以下の3点です。

  • Functionで返却するエラーメッセージを固定にする必要がある
  • パッケージ間の依存関係が強い
  • errを外部に公開する必要がある

上記に点によって問題が生じる場合は、他の方法を検討するべきです。

方法2 エラー型による判定

コード

type Err struct {
    err error
}

func (e *Err) Error() string {
    return fmt.Sprint(e.err)
}

type ErrHoge struct {
    *Err
}
type ErrFuga struct {
    *Err
}

func Function(str string) error {
    // 処理によって異なるエラーを返却
    if str == "hoge" {
        return ErrHoge{&Err{errors.New("this is error hoge")}}
    } else if str == "fuga" {
        return ErrFuga{&Err{errors.New("this is error fuga")}}
    }
    return nil
}

func main() {
    err := Function("hoge")

    switch err.(type) {
    case ErrHoge:
        fmt.Println("hoge")
    case ErrFuga:
        fmt.Println("fuga")
    }
}

まとめ

関数(Function)の戻り値の型がErrHogeかErrFugaなのかをswitch文で判定し、処理を振り分けています。
この方法もあまり良くはありませんが、値判定から型での判定になったため、
エラー値による判定の以下の問題は解決されました。

  • Functionで返却するエラーメッセージを固定にする必要

残る問題は、2点です。

  • パッケージ間の依存関係が強い
  • 構造体を外部に公開する必要がある

方法3 インターフェイスを使った判定

コード

type temporary interface {
    Temporary() bool
}

func IsTemporary(err error) bool {
    te, ok := errors.Cause(err).(temporary)
    return ok && te.Temporary()
}

type Err struct {
    s string
}

func (e *Err) Error() string { return e.s }

func (e *Err) Temporary() bool { return true }

func Function(str string) error {
    // 処理によって異なるエラーを返却
    if str == "hoge" {
        return &Err{"this is error"}
    } else {
        errors.New("予期せぬエラー")
    }
    return nil
}

func main() {
    err := Function("hoge")

    if IsTemporary(err) {
        fmt.Println("期待しているエラー:", err)
    } else {
        fmt.Println(err)
    }
}

コードが少し複雑になっているので解説をしておきます。
Errがtemporaryインタフェースを実装しています。
よって使う側の処理のIsTemporary()の判定で「temporaryインタフェースを実装していて、かつ返り値がtrueのもの」を絞る事が出来ます。

また、以下の様にerrors.causeを使う事で、エラーがwrapされていても最初に起きたエラーがtemporaryインタフェースを実装しているのかを見分けることができます。

// 帰ってきたerrがtemporaryを実装しているかを見る
te, ok := err.(temporary)
// 最初に起きた原因であるエラーがtemporaryを実装しているかを見る(←推奨)
te, ok := errors.Cause(err).(temporary)

よってIsTemporary(err)の結果を見ることで、最初に起きたエラー(根本原因)によって処理を振り分けることができます。

まとめ

この方法によってエラー型による判定で残った2つの問題を解決することができました。

  • パッケージ間の依存関係が強い ⇒ temporaryインタフェースに依存
  • 構造体を外部に公開する必要がある

これでJavaの時のtry-catchがgolangだと以下の様に実装することができそうです。

java
public static void main(String[] args) {
    try {
        // ...
    } catch (ArithmeticException e) {
        // ...
    } catch (RuntimeException e) {
        // ...
    } catch (Exception e) {
        // ...
    }
}
golang
func main() {
    err := Function("//....")

    if IsArithmeticException(err) {
        // ...
    }
    if IsRuntimeException(err) {
        // ...
    }
    if IsException(err) {
        // ...
    }
    // ...
}

最後に

Golangでの3つのエラーハンドリングについて説明しました。
方法3のインターフェイスを使った判定をする事で一番問題が少なくエラーの種類によって処理を分けることができそうです。
※プロジェクトの状況によって柔軟に対応する必要はありそうですが。

まだまだ自分も勉強中なので、他にもより良いエラーハンドリングの方法があればコメントで教えて頂けると嬉しいです。

こちらの記事がとても参考になりました。
https://dave.cheney.net/tag/error-handling

追記

・2018/11/10
「構造体を外部に公開する必要がある」こちらの記述を削除しました。
外部に構造体を公開しないことで、エラー型による判定を防ぐ事ができますが、
privateな構造体にする事で外部からフィールドの値を参照できない問題が生じるためです。