Goでのエラー判別という記事で、Goのエラーの判別方法にはだいたい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.DoSomething
はErrorWithCode
のインスタンスを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
メソッドはエラーかそうでない型かを判別するタグのようなものでしか無いと考えてもいいと思います。
というわけで、皆さんも恐れずにどんどん独自エラー型を定義していきましょう。
ただし、それは安全なエラーハンドリングのための道具であることだけは忘れないでください。