LoginSignup
8
6

More than 3 years have passed since last update.

【Go】errorsパッケージを使ってエラーハンドリングをうまくやる

Last updated at Posted at 2019-12-28

最近は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型がラップされているか確認し、
されているようであればエラーレベル毎に任意の処理を実行します。
されていないようであれば、appErrErrUnknownを代入することで想定外のエラーとして処理します。
このような実装から期待するようなエラーハンドリングを行わせる場合、利用者側にいくつか要求される点があります。

  • エラーが発生した際は、必ずどこかのタイミングでアプリケーション独自のエラーインスタンスでラップしたエラーを返す
  • 独自のエラーインスタンスにはどのようなものがあるかを知り、アプリケーションエラーに応じて適切なエラーインスタンスを選択する

独自のエラーインスタンスを返すタイミングはそのエラーの種類を分類できる箇所で統一するのが良いでしょう。
例えばレイヤードアーキテクチャで実装されているのであればアプリケーション層のロジックがそれにあたります。
本記事のコードでは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関数の実装がこちらです。
errUnwrapメソッドを実装しておけばそれを呼んでくれるようになっています。

// 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でエラーの解析が行えるよう、独自のエラー型にはErrorUnwrap関数を実装しました。
また、発生したエラーをラップできるようにWrapメソッドも実装しました。
エラー解析後はエラーレベル毎に分類して、処理を分岐できるようにしました。
新規にアプリケーションエラーを追加する際はエラーレベルを適切に設定する必要があります。
そしてアプリケーションの責務を分けるために上記のエラーハンドリングを行う処理を関数化しました。

参考文献

GoのWebアプリケーションのエラー設計
Revelからechoへの移行メモ
Go 1.13時代のエラー実装者のお作法

8
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
6