【Go言語】APIサーバにおけるエラー設計
Go言語でAPIサーバを実装する際、エラー設計をどうするか検討してみた。
エラー時のレスポンス関数を定義
はじめに何も考えずに正常系と異常系の関数を作成すると、以下のようになる。
// 正常系
func ResponseJson(w http.ResponseWriter, data interface{}, httpStatus int) {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.WriteHeader(httpStatus)
if err := json.NewEncoder(w).Encode(data); err != nil {
panic(err)
}
}
// 異常系(エラー発生時)
func ResponseErrorJson(w http.ResponseWriter, httpStatus int, err error) {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.WriteHeader(httpStatus)
w.Write(err.Error())
}
ただ、これだけではエラー時にエラー内容をそのまま返すことになり、ユーザフレンドリーな設計とは到底言えない。
エラー時のレスポンスを調整
ユーザに理解しやすいエラーメッセージを返せるようにし、かつフロント側でエラー内容を把握できるように、エラー時のレスポンスを拡張する。
type ErrorResponse struct {
ErrMessage string `json:"error_message"`
DevErrMessage string `json:"dev_error_message"`
}
// 異常系(エラー発生時)
func ResponseErrorJson(w http.ResponseWriter, httpStatus int, errMessage string, err error) {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.WriteHeader(httpStatus)
w.Write(ErrorResponse{errMessage, err.Error()}))
}
こうすることによって、エラー時にユーザフレンドリーなメッセージを返すことができる。
しかし、エラーが発生した際にerrMessage
とerr
の整合性を保つために冗長的な処理が伝播されてしまう。
例えば、controllerからusecase、repositoryと呼び出す設計で、下記のrepositoryでエラーが発生したとする。
func (r *UserRepositoryImpl) Find(ctx context.Context, id string) (*model.User, error) {
q := "SELECT id, last_name, first_name, email FROM users WHERE id = ?"
var row *sql.Row
tx := transaction.GetTransaction(ctx)
if tx != nil {
row = tx.QueryRow(q, id)
} else {
row = r.DB.QueryRow(q, id)
}
if row == nil {
return nil, nil
}
var u model.User
if err := ref.Scan(&u.Id, &u.LastName, &u.FirstName, &u.Email); err != nil {
// エラー発生
return nil, err
}
return &u, nil
}
これにユーザに表示するメッセージを追加してみる。
func (r *UserRepositoryImpl) Find(ctx context.Context, id string) (*model.User, error, *string) {
q := "SELECT id, last_name, first_name, email FROM users WHERE id = ?"
var row *sql.Row
tx := transaction.GetTransaction(ctx)
if tx != nil {
row = tx.QueryRow(q, id)
} else {
row = r.DB.QueryRow(q, id)
}
if row == nil {
return nil, nil, nil
}
var u model.User
if err := ref.Scan(&u.Id, &u.LastName, &u.FirstName, &u.Email); err != nil {
// エラー発生
return nil, err, "予期せぬエラーが発生しました"
}
return &u, nil, nil
}
加えてrepositoryを呼び出すusecaseは次のようになる。
func (i *UserInteractor) GetUser(ctx context.Context, userId string) (*UserOutput, *Status, error, *string) {
user, err, errMessage := i.r.Find(ctx, userId)
if err != nil {
return nil, &Status{Code: InternalServerError}, err, errMessage
}
output := adaptUserOutput(user)
return output, &Status{Code: OK}, nil, nil
}
本来、error
だけ良いところ、何も考えずerrMessage
を一緒に返してしまうと、冗長的な処理を伝播させてしまうことになる。
独自error型を作成しエラーメッセージを持たせる
先程挙げた問題を解決するために独自のerror型を作成する。
Go言語のerror
は、interfaceとして定義[1]されているため、Error() string
メソッドを定義すれば、error
として扱うことができる。
この特性を活かして、エラーメッセージを管理できる独自errorを次のように作成する。
package myerror
import (
"fmt"
)
type ErrorCode int
const (
ErrorCodeInvalidArgument ErrorCode = iota
ErrorCodeNotFound
ErrorCodeServiceUnavailable
)
type MyError struct {
code ErrorCode
message string
stackTrace error
}
func (e *MyError) Error() string {
return e.stackTrace.Error()
}
func (e *MyError) Code() ErrorCode {
return e.code
}
func (e *MyError) Message() string {
return e.message
}
func New(c ErrorCode, e error) error {
return &MyError{
code: c,
stackTrace: e,
}
}
func Errorf(c ErrorCode, e error, format string, a ...interface{}) error {
return &MyError{
code: c,
stackTrace: e,
message: fmt.Sprintf(format, a...),
}
}
func ErrorMessage(err error) *string {
if me, ok := err.(*MyError); ok {
message := me.Message()
if 0 < len(message) {
return &message
}
code := me.Code()
switch code {
case ErrorCodeInvalidArgument:
message = "不正なパラメータが検出されました。"
case ErrorCodeNotFound:
message = "指定されたデータが存在しません。"
default:
message = "予期せぬエラーが発生しました。"
}
return &message
}
return nil
}
また、独自のerrorを処理するレスポンス関数は次のようになる。
func ResponseErrorJson(w http.ResponseWriter, statusCode int, err error) {
fmt.Printf("%+v", errors.WithStack(err))
res, err := json.Marshal(ErrorResponse{*myerror.ErrorMessage(err), err.Error()})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
w.WriteHeader(statusCode)
w.Write(res)
}
エラー発生を検知した場合
この独自エラーを利用することで例に挙げたrepositoryは下記のようになる。
func (r *UserRepositoryImpl) Find(ctx context.Context, id string) (*model.User, error) {
q := "SELECT id, last_name, first_name, email FROM users WHERE id = ?"
var row *sql.Row
tx := transaction.GetTransaction(ctx)
if tx != nil {
row = tx.QueryRow(q, id)
} else {
row = r.DB.QueryRow(q, id)
}
if row == nil {
return nil, nil
}
var u model.User
if err := ref.Scan(&u.Id, &u.LastName, &u.FirstName, &u.Email); err != nil {
// エラー発生(独自エラーを返す)
return nil, myerror.New(myerror.ErrorCodeInternalServerError, err)
}
return &u, nil
}
エラーを検知した際に myerror
でerror
をwrapさせるのみである。
その際、エラーコードごとにメッセージを予め定義しているので、該当するエラーコードを設定する。
ビジネスロジック的にエラーとする場合
また、プログラム的にエラーではないが、ビジネスロジック的にエラーとしたい場合は下記のようにする。
if err != nil {
return nil, &Status{Code: InternalServerError}, err, errMessage
}
// userが見つからない場合はエラーとする
if user == nil {
return nil, &Status{Code: NotFound}, myerror.Errorf(myerror.ErrorCodeNotFound, xerror.Errorf("not found user."), "指定されたユーザが見つかりません。"
}
エラーコードで定義しているメッセージは汎用的なメッセージを想定しているので、usecaseに応じたメッセージをレスポンスしたい場合はmyerror.Errorf
を使用する。
Discussion