asterisc

技術ネタ中心にその他雑多なこと

Goのカスタムエラーとその自動生成について

はじめに

この記事はGo2アドベントカレンダー14日目の記事です。

GoのErrorハンドリングについては、これまでにも様々なパターンが発表されてきました。 今回は独自定義のエラーについて、これまでのパターンをまとめた上で、その実現をeasyにするgenerrというツールを作ったので、その紹介をします。

github.com

Goのカスタムエラー

Goのエラーハンドリングの中で、カスタムなerror型を用いるパターンは比較的広く知られているかと思います。 その中からいくつかのパターンを紹介します。

sentinel errorsパターン

Goのerrorinterfaceとして定義されており、Error() stringのメソッドを実装さえしていれば、errorとして扱うことができます。 標準パッケージで提供されているerrorsfmt.Errorfを用いて簡単にエラーを表現することができます。 しかし、errorを場合によってハンドリングしたい場合、stringの中身をパースして、何が起きたかを判断せねばならず、人間にとっては易しいのですが、プログラミングでは扱いにくい形式だと思います。

その問題に対応するため、標準パッケージのsqlなどで採用されている方式として、予め固定された値を提供するパターンが知られています(Dave Cheney先生により、sentinel errors命名されています)。

前述のsqlパッケージで提供されているerrorのうちの一つ、ErrNoRowsは以下のように定義されています。

var ErrNoRows = errors.New("sql: no rows in result set")

実際にErrNoRowsをハンドリングしたい場合、 返されたerror==で比較することができます。 以下はQueryRowのExampleからの引用です。

id := 123
var username string
var created time.Time
err := db.QueryRowContext(ctx, "SELECT username, created_at FROM users WHERE id=?", id).Scan(&username, &created)
switch {
case err == sql.ErrNoRows: // ここでerrの種類をチェック
    log.Printf("No user with id %d", id)
case err != nil:
    log.Fatal(err)
default:
    fmt.Printf("Username is %s, account created on %s\n", username, created)
}

このようにsentinel errorsを用いることで、エラーハンドリングのソースコードの見通しを大きく改善することができます。

error typesパターン

上記のパターンは見通しを改善するという意味では成功していますが、エラーに発生時の情報を付与することができず、エラーの情報量としてはやや不十分となる場合があります。 前述のExampleからの引用でもsql.ErrNoRows時のlogで出力されていますが、sql.ErrNowRowsが発生したとき、どういったuserのidが渡されたのかをerrの中に格納したい場合があります。 そういった場合は自分でError() stringを実装した型を定義し、カスタムなerrorを作ります。上記のケースだと、例えば以下のようにエラーを定義できます。

type UserNotFound struct {
    Id int
}

func (e *UserNotFound) Error() string {
    return fmt.Sprintf("userid: %d is not found", e.Id)
}

このUserNotFound型を用いて、上記のExampleを書き換えてみます。

func GetUesr(id int) (User, error) {
    var username string
    var created time.Time
    err := db.QueryRowContext(ctx, "SELECT username, created_at FROM users WHERE id=?", id).Scan(&username, &created)
    switch {
    case err == sql.ErrNoRows:
        return nil, &UserNotFound{Id: id} // Idの情報を渡せる
    case err != nil:
        return nil, err
    default:
        return &User{username, created}, nil
    }
}

先程のExampleをGetUserという関数に書き換え、sql.ErrNoRowsのときに、よりエラーの文脈を表すUserNotFoundというエラーに翻訳し、呼び出し元にreturnするというコードに修正しました。UserNotFoundを作る際にIdを渡すことができるようになり、エラーの情報量を豊かにすることができました。

呼び出し側を想定し、error handlingしてみます。type assertionを行うことにより、handlingを行います。

func GetUserCaller() {
    id := ....
    // 中略
    u, err := GetUser(id)
    switch type.(err) {
    case nil:
        // 正常系の処理を続ける
    case *UserNotFound:
        log.Printf("user id %d is not exists", err.Id)
        // NotFoundのときの処理を行う
    default:
        // unknown error
    }
}

上記の関数ではhandlingに意味はないですが、REST APIなどでは、errが*UserNotFoundだった場合は、404を返し、defaultに入ってきた場合は50x系を返すというようなユースケースをイメージするとわかりやすいと思います。

Assert for behaviourパターン

error typesを用いることにより、errorをより文脈に沿った形に翻訳し、エラー時の情報をより豊かにすることができるようになりました。 ですが、上記のパターンでも問題となるケースがあります。 「GetUserUserNotFoundという型のエラーを返す」という知識を呼び出し元のGetUserCallerが持っていなければならず、呼び出し元と呼び出され側の依存関係が強くなってしまうという点です。 この問題点は前述のBanzai CloudのError Handling Practiceのブログなどで触れられています。

解決策として、Errorの具体的な実装は公開せず、振る舞いのみを公開するというパターンがあります。以下のコードはBanzai CloudのError Handling Practiceからの引用(および一部改変)です。 上記のUserNotFoundの場合は、以下のようにinterfaceを定義し、併せてerrが定義したinterfaceを満たしているかどうかをcheckする関数を定義します。

type userNotFound interface {
    UserNotFound() int64 //返り値はidを想定 
}

func IsUserNotFound(err error) (bool, int64) {
    if e, ok := err.(userNotFound); ok {
        return true, e.UserNotFound()
    }
    return false, 0 // 0はint64のゼロ値
}

ポイントとしては、checkする関数のみをpublicなAPIとして定義する点です。

呼び出され側(GetUser)では、以下のようにすべてprivateなAPIとしてerrorを定義します。

type userNotFound struct {
    id int64
}

func (e *userNotFound) Error() string {
    return fmt.Sprintf("user with ID %d not found", e.id)
}

func (e *userNotFound) UserNotFound() int64 {
    return e.id
}

上記のコードを用いて、GetUserおよびGetUserCallerをリファクタします。 GetUserはrepositoryパッケージ、GetUserCallerはmainパッケージに定義されているものとします。

package repository

func GetUesr(id int) (User, error) {
    var username string
    var created time.Time
    err := db.QueryRowContext(ctx, "SELECT username, created_at FROM users WHERE id=?", id).Scan(&username, &created)
    switch {
    case err == sql.ErrNoRows:
        return nil, &userNotFound{Id: id} // 同一packageのstructを使用する
    case err != nil:
        return nil, err
    default:
        return &User{username, created}, nil
    }
}
package main

func GetUserCaller() {
    id := ....
    // 中略
    u, err := repository.GetUser(id)
    
    if ok, id := IsUserNotFound(err); ok { // メソッドがidを返すので、情報量は落ちない
        log.Printf("user id %d is not exists", id)
        // NotFoundのときの処理を行う
    } else if err != nil {
    // unknown error
    }
    // 正常系の処理を続ける
}

error typesを用いたときと同様の情報量を保ちつつ、依存を緩めることに成功しました。 このパターンは特に複数のパッケージにまたがるような規模の大きい開発のときに特に有効だと感じています。

generrについて

ここからがこの記事の本題です。

assert for behaviourのパターンは柔軟かつ有効だと思うのですが、 やや記述量が多い とも感じていました。 errors.New()でエラーを定義していたときから比べると、interfaceを定義し、checkの関数を定義し、errorの実装を定義しというように、大分手数が増えたなあと思います。

そこで、最初のinterfaceさえ定義すれば、あとはgo generateで他のものをすべて自動生成してくれるツール、generrを作りました。

使い方

実際の使い方を見ていきたいと思います。

install

go getしてください。

$ go get github.com/akito0107/generr/cmd/generr

describe interface

任意のパッケージで、interfaceを定義します。

type userNotFound interface {
    UserNotFound() (id int64)
}

ややトリッキーなのですが、値をreturnさせたい場合、必ずnamedな値にしてください。 準備はこれだけです。

generate!

同じレベルのディレクトリで、

$ generr -t userNotFound

と打ってください。-tオプションで渡すのは、interfaceの型名です。

すると、userNotFond_check.goというコードが生成されます。中身は、下記のようになっています。

func IsUserNotFound(err error) (bool, int64) {
    var id int64
    if e, ok := err.(userNotFound); ok {
        id = e.UserNotFound()
        return true, id
    }
    return false, id
}

interfaceをcheckし、情報を取り出すための関数が生成されています。

generate struct!

次に、実装を生成します。

以下のようなディレクトリ構成だと仮定します。

.
├── error.go # userNotFoundのinterface定義ファイル
├── implpkg
│   └── # ここに実装を吐き出す
└── userNotFound_check.go

下記のコマンドを打ってください。

$ generr -t userNotFound -i -it userNotFound -o implpkg

オプションの解説をします。 -i は実装を吐き出すオプションです。 -itは吐き出す実装の型名です。 -oは吐き出すディレクトリです。 -oに前述した例だと、repositoryなどを指定するイメージです。

下記のファイルが userNotFound_impl.goとして生成されます。

// Code generated by "generr"; DO NOT EDIT.
package implpkg

import "fmt"

type userNotFound struct {
    Id int64
}

func (e *userNotFound) UserNotFound() int64 {
    return e.Id
}
func (e *userNotFound) Error() string {
    return fmt.Sprintf("userNotFound Id: %v", e.Id)
}

error + userNotFound interfaceの実装が吐かれました。

ちなみに、-it-oは任意です。指定しなかった場合、interface定義ディレクトリと同じディレクトリに生成され、構造体の型名はUserNotFound (interface名の先頭を大文字にしたもの)になります。 ユースケースとして、パッケージ分離が必要なく、custom error typesをeasyに生成したいときなどはこのケースで十分だと思います。なお、-uオプションで、上記の生成物(チェック関数と実装)を一つのファイルにまとめることもできます。同様に、custom error typesをeasyに使いたいときに有効です。

with go generate

僕がこのツールを使うときにはgo generateと一緒に使うことが多いです。 先程の例だと、

//go:generate generr -t userNotFound -i -it userNotFound -o implpkg 
type userNotFound interface {
    UserNotFound() (id int64)
}

として定義し、

$ go generate

で同様の結果が得られます。

また、通常のerror typesパターンとして使いたいときなどは、

//go:generate generr -t userNotFound -i -u
type userNotFound interface {
    UserNotFound() (id int64)
}

として定義して、

$ go generate

と実行すると、以下のようなファイルがuserNotFound_impl.goとして生成されます。

// Code generated by "generr"; DO NOT EDIT.
package generrdemo

import "fmt"

func IsUserNotFound(err error) (bool, int64) {
    var id int64
    if e, ok := err.(userNotFound); ok {
        id = e.UserNotFound()
        return true, id
    }
    return false, id
}

type UserNotFound struct {
    Id int64
}

func (e *UserNotFound) UserNotFound() int64 {
    return e.Id
}
func (e *UserNotFound) Error() string {
    return fmt.Sprintf("userNotFound Id: %v", e.Id)
}

UserNotFoundIsUserNotFoundが公開されており、assert for behaviourパターンからするとアンバランスですが、ユースケースによってはこれで十分な場合もあるかと思います。

また、-mオプションでError() stringで返すメッセージのカスタマイズが可能となっています。

まとめ

Goにおけるカスタムなerrorを使ったパターンを紹介しました。 その上で、その実現をeasyにするツール、generrの紹介をしました。

バグなどがありましたら教えていただけると幸いです。

ありがとうございました!