🚨

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

2023/03/10に公開
2

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."
}

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

例えば、controllerからusecaserepositoryと呼び出す設計で、下記の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
}

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

脚注
  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

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

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