【Go言語】APIサーバにおけるエラー設計
Go言語でAPIサーバを実装する際、エラーの処理について調べてみました。
が、「コレがスタンダード!」という方法はなさそうなので、設計をどうするか1から検討してみました。
エラー時のレスポンス関数を定義
はじめに何も考えずに正常系と異常系の関数を作成すると、以下のようになりました。
// 正常系
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()}))
}
こうすることによって、エラー時にユーザ表示するメッセージとエラー詳細の両方を返すことができます。
{
"error_message": "指定されたデータが存在しません。",
"dev_error_message": "not found user."
}
しかし、エラーが発生した際に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 = ?"
row := r.DB.QueryRow(q, id)
if erros.Is(row.err(),sql.ErrNoRows) {
return nil, nil
}
var u model.User
if err := row.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 = ?"
row := r.DB.QueryRow(q, id)
if erros.Is(row.err(),sql.ErrNoRows) {
return nil, nil, nil
}
var u model.User
if err := row.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
を一緒に返してしまうと、冗長的な処理を伝播させてしまうことになります。
また、途中で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 = ?"
row := r.DB.QueryRow(q, id)
if erros.Is(row.err(),sql.ErrNoRows) {
return nil, nil
}
var u model.User
if err := row.Scan(&u.Id, &u.LastName, &u.FirstName, &u.Email); err != nil {
// エラー発生(独自エラーを返す)
return nil, myerror.New(myerror.ErrorCodeInternalServerError, err)
}
return &u, nil
}
使い方はエラーを検知した際に myerror
でerror
をwrapさせるのみです。
その際、エラーコードごとにメッセージを予め定義しているので、該当するエラーコードを設定します。
ビジネスロジック的にエラーとする場合
また、プログラム的にエラーではないが、ビジネスロジック(usecase
)的にエラーとしたい場合は下記のようになります。
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
下記のコードについて質問があります
間違ったていたら申し訳ございませんトランザクションを貼った後の処理がかかれてないような気がします
ロールバック,コミットなど
SELCT文なのでトランザクションを貼る意味は特にないと思いますがどうでしょうか
こちらはrows == nilで判定するのではなく下記で判断するのが良いと思いました
下記は誤字だと思いましたが
×ref
◯row
ご指摘ありがとうございます!
アプリケーションに組み込まれているコードを移植していた関係で、そのままになっていた箇所がありました。仰るとおりのご指摘なので修正しました。