Goのカスタムエラーとその自動生成について
はじめに
この記事はGo2アドベントカレンダー14日目の記事です。
GoのErrorハンドリングについては、これまでにも様々なパターンが発表されてきました。 今回は独自定義のエラーについて、これまでのパターンをまとめた上で、その実現をeasyにするgenerrというツールを作ったので、その紹介をします。
Goのカスタムエラー
Goのエラーハンドリングの中で、カスタムなerror
型を用いるパターンは比較的広く知られているかと思います。
その中からいくつかのパターンを紹介します。
sentinel errorsパターン
Goのerror
はinterfaceとして定義されており、Error() string
のメソッドを実装さえしていれば、error
として扱うことができます。
標準パッケージで提供されているerrorsやfmt.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をより文脈に沿った形に翻訳し、エラー時の情報をより豊かにすることができるようになりました。
ですが、上記のパターンでも問題となるケースがあります。
「GetUser
はUserNotFound
という型のエラーを返す」という知識を呼び出し元の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) }
UserNotFound
とIsUserNotFound
が公開されており、assert for behaviour
パターンからするとアンバランスですが、ユースケースによってはこれで十分な場合もあるかと思います。
また、-m
オプションでError() string
で返すメッセージのカスタマイズが可能となっています。
まとめ
Goにおけるカスタムなerrorを使ったパターンを紹介しました。 その上で、その実現をeasyにするツール、generrの紹介をしました。
バグなどがありましたら教えていただけると幸いです。
ありがとうございました!