🚨

【Go言語】APIサーバにおけるエラー設計

2023/03/10に公開
2

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()}))
}

こうすることによって、エラー時にユーザフレンドリーなメッセージを返すことができる。

しかし、エラーが発生した際にerrMessageerrの整合性を保つために冗長的な処理が伝播されてしまう。

例えば、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を一緒に返してしまうと、冗長的な処理を伝播させてしまうことになる。

独自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
}

エラーを検知した際に myerrorerrorを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を使用する。

脚注
  1. https://pkg.go.dev/builtin#error ↩︎

Discussion

ponzuponzu

下記のコードについて質問があります
https://zenn.dev/ysk1to/articles/dc76ad691606b1#:~:text=例えば、controllerからusecase、repositoryと呼び出す設計で、下記のrepositoryでエラーが発生したとする。
間違ったていたら申し訳ございません

tx := transaction.GetTransaction(ctx)

トランザクションを貼った後の処理がかかれてないような気がします
ロールバック,コミットなど
SELCT文なのでトランザクションを貼る意味は特にないと思いますがどうでしょうか

if row == nil {
		return nil, nil
	}

こちらはrows == nilで判定するのではなく下記で判断するのが良いと思いました

if erros.Is(row.err(),sql.ErrNoRows){
   return nil,row.err()
}

下記は誤字だと思いましたが

if err := ref.Scan(&u.Id, &u.LastName, &u.FirstName, &u.Email); err != nil {
		// エラー発生
		return nil, err
	}

×ref
◯row

Yusuke ItoYusuke Ito

ご指摘ありがとうございます!

アプリケーションに組み込まれているコードを移植していた関係で、そのままになっていた箇所がありました。仰るとおりのご指摘なので修正しました。