私がWebアプリケーションを実装するうえのエラー設計をどのようにアーキテクチャの中で実現しているかを紹介します。
以下の方針で設計しています。
- アプリケーション層にレベルを付与したエラーを定義する
- アプリケーション層では、定義したエラーに変換する
- ミドルウェアは全アプリケーション共通にする
以下、サンプルコードです。
サンプルのため省略していますが、実際にはパッケージ分割やファイル分割をしています。
package main
import (
"fmt"
"os"
"golang.org/x/xerrors"
)
var ErrUnknown = &ApplicationError{
code: 100,
level: "Error",
msg: "unknown",
}
var ErrUserNotFound = &ApplicationError{
code: 101,
level: "Error",
msg: "not found",
}
type ApplicationError struct {
level string
code int
msg string
}
func (e *ApplicationError) Error() string {
return fmt.Sprintf("%s: code=%d, msg=%s", e.level, e.code, e.msg)
}
func main() {
middleware()
}
func application() error {
user, err := userSearch()
if err != nil {
return ErrUserNotFound
}
fmt.Println(user)
return nil
}
func middleware() {
err := application()
if err != nil {
var appErr *ApplicationError
if xerrors.As(err, &appErr) {
switch appErr.level {
case "Fatal":
fmt.Fprintf(os.Stderr, "Fatal! %v\n", appErr)
case "Error":
fmt.Fprintf(os.Stderr, "Error! %v\n", appErr)
case "Warning":
fmt.Printf("Warning! %v\n", appErr)
}
} else {
// 定義したエラー以外は想定外のエラーなので、Unknownエラーにして返す
err = ErrUnknown
}
// 本来はここでエラーレスポンスを返す
fmt.Printf("%+v\n", err)
return
}
fmt.Printf("エラーなし\n")
}
func userSearch(uID string) (string, error) {
user, err := userFromDB(uID)
if err != nil {
return "", xerrors.Errorf("userSearch error user id %v not found: %w", uID, err)
}
return user, nil
}
func userFromDB(uID string) (string, error) {
return "", xerrors.Errorf("userFromDB error")
}
アプリケーション層にレベルを付与したエラーを定義する
Warning
、 Error
、 Fatal
の3種類を定義しています。
Warning
は、想定できるエラーです。例えば、パスワードを間違えた場合などユーザの入力によって起こりえるエラーです。
Error
は、本来は起きないが外部の影響によっては起こりえるエラーを返します。例えば、クライアントがAPIの実行方法を間違えた場合や外部との通信に失敗した場合に返します。
Fatal
は、バックエンドの実装バグのときのエラーです。例えば到達したらエラーのときには返します。
switch 1 + 1 {
case 2:
return 1
}
// 上記の実装を間違わない限りここには到達しない
return FatalError
このようにレベルを分けて定義しておくことで、ログをみたときの対応方針が明確になります。
Warningは、想定内のエラーなので見る必要がありません。
Errorは、外部との通信なので頻発しなければ問題はない。大量に発生している場合はクライアントの実装の問題か、インフラで何かが起きている可能性が高いです。
Fatalは、バグなので即座に原因を解決する必要があります。
アプリケーション層でエラーを変換する
アプリケーション層では、ドメイン層やインフラ層で定義したエラーを受けて、そこから先ほど説明したアプリケーション層で定義したレベルを付与したエラーに変換して返します。
このとき標準ライブラリなどから返ってくるエラーをすべてアプリケーション層に再定義するようなことはしません。
基本的にはクライアントがハンドリングをするべきエラーに留めます。そうすることで本当に必要なエラーが大量のエラーの定義の中に埋もれメンテナンスが困難にならないようにします。
例えば、クライアントはDBとの接続が失敗したというエラー情報を貰ってもどうしようもありませんが、パスワード認証に失敗しという情報はユーザに伝えるべきなのでエラーとして定義します。
ミドルウェアは全アプリケーション共通にする
上記のようなエラーを受け取ってログ出力する、レスポンスを作成するなどのハンドリングは共通のミドルウェアで行います。
またミドルウェアでは、アプリケーションが定義した以外のエラーを取得したらUnknownエラーに変換してレスポンスを返します。
なぜならアプリケーションが定義した以外のエラーというのは、外部ライブラリから返ってきたエラーであることが多く、そのエラーの詳細な内容をクライアントに漏らすとセキュリティの問題に繋がるためです。
先ほど、「Errorは、外部との通信なので、頻発しなければ問題はない」と書きましたが、実際には標準ライブラリへの入力を間違えている場合など実装バグであることはよくあるので確認が必要になります。
さいごに
しかしこの実装では、ミドルウェアでエラー出力した際にアプリケーション層より下位のエラーの情報が失われてしまいます。
Error! Error: code=101, msg=not found
application: userSearch error user id 12345 not found: userFromDB:
xerrors/sample1.application
/Users/sonatard/tmp/xerrors/sample1/application.go:41
- Error: code=101, msg=not found
本来は以下のようになってほしいところです。
Error: code=101, msg=not found:
xerrors/sample2.application
/Users/sonatard/tmp/xerrors/sample2/application.go:54
- userSearch error user id 12345 not found:
xerrors/sample2.userSearch
/Users/sonatard/tmp/xerrors/sample2/infra.go:10
- userFromDB:
xerrors/sample2.userFromDB
/Users/sonatard/tmp/xerrors/sample2/infra.go:16
なぜ下位のエラーの情報が失われてしまうのかというと、エラーを変換する際に下位の情報をラップすることが xerrors.Errorf
ではできないためです。
func application() error {
user, err := userSearch()
if err != nil {
// return ErrUserNotFound ではなく以下のようにしたいが、複数のエラーのラップには対応していない
return xerrors.Errorf(": %w: %w", ErrUserNotFound, err)
}
fmt.Println(user)
return nil
}
それを解決するための方法がxerrorsパッケージには提供されているため、xerrors パッケージで途中に独自定義したエラー型をラップする方法にまとめました。