最近は1からGoでサービスを開発する機会があり、その際にGo1.13から追加された新機能を導入して使ってみたりしています。
例えばerrorsパッケージのAs
関数やIs
関数などです。
https://golang.org/pkg/errors/
上記のerrorsパッケージはエラーハンドリングを実装する際によく利用しています。
本記事ではerrorsパッケージを使った効果的なエラーハンドリング実装についてまとめます。
記事の構成は最初にエラーハンドリングの設計で目指す要件を簡単に説明したあと、
その要件を満たすようなシンプルなWebアプリケーションの実装を提示します。
実装するエラーハンドリングの設計について
本記事で示すアプリケーションのコードでは、下記の項目を満たすようなエラーハンドリングを実装していきます。
エラーの発生箇所がログから確認できる
発生したエラーの原因を推測するためにコード中のどこで、どのようなエラーが発生したかを確認する必要があります。
そのため、下位レベルの重要なエラーが途中で握りつぶされずにログに出力される仕組みをアプリケーションに持たせる必要があります。
ログ出力処理はアプリケーションロジックとは別の所に記述されている
モジュールが持つ責務を適切に分けるため、ログ出力処理とアプリケーションロジックによる計算処理は別のモジュールに分けていきます。
また、アプリケーションロジックとは関係のないログ出力処理を別の箇所に記述することで、
アプリケーション層のコードが不要に肥大化することを防ぐことができます。
システムエラーの情報を隠蔽する
API利用者に不要な情報やセキュリティ的によろしくない情報を与えることが無いよう、システム特有のエラーメッセージが含まれないようにする必要があります。
具体的には発生したエラーに対してユースケース毎に外部向けに調整されたエラー情報でラッピングします。
ユースケース毎のエラーはerrorインターフェースを持った変数として定義し、各変数は外部向けのメッセージやエラーレベルなどを持たせます。
アプリケーションエラーの定義を容易に拡張できる
アプリケーションエラーを追加する際にエラーハンドリングの実装を修正しなくても済むようにします。
今回のエラーハンドリングの実装ではエラーはエラーレベルを元にハンドリングされるような設計とし、
アプリケーションエラーの種類が増えてもスケールするような実装を目指します。
具体的な実装について
使用した環境
$ go version
go version go1.13.4 darwin/amd64
実装したコード
上記の項目を満たすように実装したコードの全文を提示します。
ソースコードの細かい部分についてはその後に解説を入れていきます。
なお、実装にはWebアプリケーションフレームワークのEchoを使用しており、ログ出力処理やログフォーマットはEchoの標準のミドルウェアとして用意されているロガーに任せています。
また、アプリケーションのエラーハンドリング部分の処理はこちらの記事を参考にさせて頂きました。
GoのWebアプリケーションのエラー設計
package main
import (
"errors"
"fmt"
"github.com/labstack/echo"
"github.com/labstack/echo/middleware"
)
// mapのシンタックスシュガー
type m map[string]interface{}
// handler→usecase→serviceという流れで実装
func handler(c echo.Context) error {
err := usecase("hoge")
if err != nil {
return err
}
return c.JSON(200, m{"message": "success"})
}
func usecase(s string) error {
err := service(s)
if err != nil {
return ErrSomething.Wrap(err)
}
return nil
}
func service(s string) error {
return errors.New("occur error")
}
func main() {
e := echo.New()
e.Use(middleware.Logger())
e.HTTPErrorHandler = ErrorHandler
e.GET("/", handler)
e.Logger.Fatal(e.Start(":1323"))
}
// エラーハンドリングの実装
func ErrorHandler(err error, c echo.Context) {
var appErr *AppErr
if errors.As(err, &appErr) {
switch appErr.Level {
case Fatal:
fmt.Printf("[%s] %d %+v\n", appErr.Level, appErr.Code, appErr.Unwrap())
case Error:
fmt.Printf("[%s] %d %+v\n", appErr.Level, appErr.Code, appErr.Unwrap())
case Warning:
}
} else {
appErr = ErrUnknown
}
c.JSON(appErr.Code, m{"message": appErr.Message})
}
// ここから下はエラー型とエラー変数の定義
type AppErr struct {
Level ErrLevel
Code int
Message string
err error
}
func (e *AppErr) Error() string {
return fmt.Sprintf("[%s] %d: %+v", e.Level, e.Code, e.err)
}
type ErrLevel string
const (
Fatal ErrLevel = "FATAL"
Error ErrLevel = "ERROR"
Warning ErrLevel = "WARNING"
)
var ErrSomething = &AppErr{
Level: Error,
Code: 500,
Message: "something error",
}
var ErrUnknown = &AppErr{
Level: Fatal,
Code: 500,
Message: "unknown error",
}
func (e *AppErr) Wrap(err error) error {
e.err = err
return e
}
func (e *AppErr) Unwrap() error {
return e.err
}
実行結果
curlのレスポンス
$ curl localhost:1323
{"message":"something error"}
アプリケーションのログ出力
[ERROR] 500 occur error
{"time":"2019-12-29T15:53:17.395943+09:00","id":"","remote_ip":"::1","host":"localhost:1323","method":"GET","uri":"/","user_agent":"curl/7.64.1","status":500,"error":"[ERROR] 500: occur error","latency":100100,"latency_human":"100.1µs","bytes_in":0,"bytes_out":30}
curlした時のレスポンス結果は独自のアプリケーションエラーで定義したメッセージが表示されます。
対してアプリケーションログを見てみると、標準出力にエラーの内容とEchoが出力したログのerror
パラメータの値にoccur error
と出力されています。
ソースコードの解説
エラー型の定義
独自のエラー型では、エラーを推測するのに必要な情報やラップされたエラーを保持するための変数を持っています。
// ここから下はエラー型とエラー変数の定義
type AppErr struct {
Level ErrLevel
Code int
Message string
err error
}
func (e *AppErr) Error() string {
return fmt.Sprintf("[%s] %d: %+v", e.Level, e.Code, e.err)
}
type ErrLevel string
const (
Fatal ErrLevel = "FATAL"
Error ErrLevel = "ERROR"
Warning ErrLevel = "WARNING"
)
AppErr.Levelはエラーハンドリングを行う関数で処理の分岐のために使われるので、
定数値をそのまま使わずErrLevelという型を定義しています。
AppErrは当然errorインタフェースを実装する必要があるのでError() string
の関数も定義します。
エラーハンドリングの処理について
エラーハンドリングを行う部分は関数としてアプリケーションとは分離させています。
アプリケーションがエラーを返した時この関数が呼ばれ、第一引数に発生したエラーのインスタンスが渡ります。
// エラーハンドリングの実装
func ErrorHandler(err error, c echo.Context) {
var appErr *AppErr
if errors.As(err, &appErr) {
switch appErr.Level {
case Fatal:
fmt.Printf("[%s] %d %+v\n", appErr.Level, appErr.Code, appErr.Unwrap())
case Error:
fmt.Printf("[%s] %d %+v\n", appErr.Level, appErr.Code, appErr.Unwrap())
case Warning:
}
} else {
appErr = ErrUnknown
}
c.JSON(appErr.Code, m{"message": appErr.Message})
}
上記の挙動としてはまずerrors.As
関数でerr
の中に*AppErr
型がラップされているか確認し、
されているようであればエラーレベル毎に任意の処理を実行します。
されていないようであれば、appErr
にErrUnknown
を代入することで想定外のエラーとして処理します。
このような実装から期待するようなエラーハンドリングを行わせる場合、利用者側にいくつか要求される点があります。
- エラーが発生した際は、必ずどこかのタイミングでアプリケーション独自のエラーインスタンスでラップしたエラーを返す
- 独自のエラーインスタンスにはどのようなものがあるかを知り、アプリケーションエラーに応じて適切なエラーインスタンスを選択する
独自のエラーインスタンスを返すタイミングはそのエラーの種類を分類できる箇所で統一するのが良いでしょう。
例えばレイヤードアーキテクチャで実装されているのであればアプリケーション層のロジックがそれにあたります。
本記事のコードではusecase
関数でエラーインスタンスを返すようにしています。
func usecase(s string) error {
err := service(s)
if err != nil {
return ErrSomething.Wrap(err)
}
return nil
}
また、アプリケーションがエラーを返した時にErrorHandler
関数が呼ばれるようにするため、Echoのエラーハンドラーとしてこの関数を登録しています。
e.HTTPErrorHandler = ErrorHandler
これはEchoを用いた場合になりますが、フレームワークを使わない場合は上記のようなエラーのハンドリングを行う独自のミドルウェアを実装して、
アプリケーションをラップするような実装になります。
ラップされたエラーの比較について
エラーを解析するためにerrors.As
メソッドを利用していますが、上手く機能させるために独自のエラー型にはUnwrap
関数を
実装しています。
func (e *AppErr) Unwrap() error {
return e.err
}
errors.As
関数の実装を見てみると、for文の中で同じerrorsパッケージ内のUnwrap
関数を呼ぶことで
再帰的にエラーをerr
に代入しています。
そしてfor文の中でerrがtarget型に代入可能であれば代入してtrueを返すような動きになっています。
// As finds the first error in err's chain that matches target, and if so, sets
// target to that error value and returns true.
//
// The chain consists of err itself followed by the sequence of errors obtained by
// repeatedly calling Unwrap.
//
// An error matches target if the error's concrete value is assignable to the value
// pointed to by target, or if the error has a method As(interface{}) bool such that
// As(target) returns true. In the latter case, the As method is responsible for
// setting target.
//
// As will panic if target is not a non-nil pointer to either a type that implements
// error, or to any interface type. As returns false if err is nil.
func As(err error, target interface{}) bool {
if target == nil {
panic("errors: target cannot be nil")
}
val := reflectlite.ValueOf(target)
typ := val.Type()
if typ.Kind() != reflectlite.Ptr || val.IsNil() {
panic("errors: target must be a non-nil pointer")
}
if e := typ.Elem(); e.Kind() != reflectlite.Interface && !e.Implements(errorType) {
panic("errors: *target must be interface or implement error")
}
targetType := typ.Elem()
for err != nil {
if reflectlite.TypeOf(err).AssignableTo(targetType) {
val.Elem().Set(reflectlite.ValueOf(err))
return true
}
if x, ok := err.(interface{ As(interface{}) bool }); ok && x.As(target) {
return true
}
err = Unwrap(err)
}
return false
}
そしてerrors.Unwrap
関数の実装がこちらです。
err
にUnwrap
メソッドを実装しておけばそれを呼んでくれるようになっています。
// Unwrap returns the result of calling the Unwrap method on err, if err's
// type contains an Unwrap method returning error.
// Otherwise, Unwrap returns nil.
func Unwrap(err error) error {
u, ok := err.(interface {
Unwrap() error
})
if !ok {
return nil
}
return u.Unwrap()
}
まとめ
下記のような特性を持ったエラーハンドリング処理を設計し、errorsパッケージの新機能を使って実装しました。
- エラーの発生箇所がログから確認できる
- ログ出力処理はアプリケーションロジックとは別の所に記述されている
- システムエラーの情報を隠蔽する
- アプリケーションエラーの定義を容易に拡張できる
errors.As
でエラーの解析が行えるよう、独自のエラー型にはError
とUnwrap
関数を実装しました。
また、発生したエラーをラップできるようにWrap
メソッドも実装しました。
エラー解析後はエラーレベル毎に分類して、処理を分岐できるようにしました。
新規にアプリケーションエラーを追加する際はエラーレベルを適切に設定する必要があります。
そしてアプリケーションの責務を分けるために上記のエラーハンドリングを行う処理を関数化しました。