Goのエラーハンドリングの基本的な考え方

はじめに

アプリケーションの開発においてエラーハンドリングの設計は重要です。エラーハンドリングの設計を軽視すると、エラー発生時に原因が明確にわからないことや、エンドユーザーに対して不親切なエラーメッセージを返すことになってしまってもおかしくはありません。

本記事ではGoのエラーハンドリングの基本的な考え方について紹介します。

エラーについて

エラーはプログラムが異常な処理をした場合に発生するもので、Ben Johnson氏の記事によるとエラーは以下の2つに分けることができます。

  • well-defined error(明確なエラー)
    • ハンドリングできているエラーで、アプリケーション内で予測できているエラー
  • undefined error(未定義のエラー)
    • panicなどアプリケーションで予測できていないエラー

エラーはただ返せば良いわけではない

エラーは処理に失敗したときにerrをただ表示するだけではエラーハンドリングを十分に行えているとは言えません。エラーを扱うシーンによって、エラーに含める情報やエラーハンドリングを工夫する必要があります。エラーの利用シーンは主に以下の3つに分けられ、それぞれのシーンでエラーの扱い方は異なります。

  • アプリケーション内でエラーを扱うとき
    • エラーにアプリケーション固有のエラーコードを加えることで、エラーを識別しエラーハンドリング可能な状態にする
  • エンドユーザーがエラーをみるとき
    • エラーに人間が読んでわかるメッセージを含めて、ユーザーがエラーメッセージを見て行動を取れる状態にする
  • オペレーターがエラーをみて障害対応などをするとき
    • エラーにオペレーションの情報を含めてスタックしておくことで、システムの運用者がエラーの発生原因を特定しやすくする

サンプル

実際にどのようにエラーを実装していけばよいのかを擬似コードをもとに説明していきます。

エラーの定義例

先ほどのエラーの利用シーンに対応するためには、エラーに以下の情報を含める必要があります。

  • アプリケーション固有のエラーコード
  • エンドユーザー向けに表示するメッセージ
  • 発生源のエラーやラップされたエラーを格納するエラー

コードにすると以下のようになります。ApplicationErrorはエラーコードの他に、Errorメソッドを持っておりerrorインターフェースを満たすようになっています。

type ApplicationError struct {
  // Code is ApplicationErrorCode
  Code string
  
  // Message is to show enduser
  Message string
  
  // Err is nested error
  Err error
}

// Error is implementation for error interface
func (e *ApplicationError) Error() string {
  errStr := ""
  if e.Err != nil {
    errStr := fmt.Sprintf("%s", e.Err.Error())
  }
  return fmt.Sprintf("code=%s, err=%s, msg=%s", e.Code, errStr, e.Message)
}

UserService.FindByIDのエラー変換の実装例

今回は仮の実装としてUserService.FindByIDから返されるエラーを内部のエラーに変換してみます。まずは、以下のようなアプリケーション固有の内部のエラーコードを定義します。内部のエラーコードは、外部サービスやライブラリのエラーコードを、アプリケーション固有のエラーコードとして扱うために利用されます。アプリケーションでは内部のエラーコードを利用してエラーハンドリングを行います。

// ApplicationErrorCode
const(
  InternalServerErrorCode  = "InternalServerError"
  NotFoundErrorCode        = "NotFoundError"
  InvalidArgumentErrorCode = "InvalidArgumentError"
)

以下のコードではUserService.client.Getのエラーを場合に応じて内部のエラーコードに変換しています。エラーが有る場合はgRPCのステータスコードがあるかないかをチェックして、内部のエラーコードであるInternalServerErrorCodeNotFoundErrorCodeに変換しています。NotFoundErrorCodeの場合はエンドユーザー向けに人間が理解可能なメッセージを付与しています。InternalServerErrorの場合はエラーをpkg/errorsなどでラップして詳細な情報を加えることで、システムの運用者がエラーの原因を追いやすくなるようにしています。

func (s *UserService) FindByID(id string) (*User, error) {
  res, err := s.client.Get(id)
  if err != nil {
    // err to gRPC status
    status, ok := status.FromError(err)
    if !ok {
      return nil, &apperr.ApplicationError{Code: apperr.InternalServerErrorCode, Err: errors.Wrapf(err, "grpc connection is failed")}
    }

    // external error to internal error 
    switch status.Code() {
    case codes.NotFound:
      // respond err message to enduser
      return nil, &apperr.ApplicationError{Code: apperr.NotFoundErrorCode, Message: "ユーザーが見つかりませんでした", Err: err}
    case default:
      // wrap original error and add detail information
      return nil, &apperr.ApplicationError{Code: apperr.InternalServerErrorCode, Err: errors.Wrapf(err, "UserService.Get() responds error: id=%s", id)}
    }
  }
  
  return &User{
    ID: res.userID,
    Name: res.name,
  }, nil
}

固有エラーからgRPCステータスコードへのエラーハンドリング例

この実装例では内部のエラーコードをgRPCのステータスーコードに変換しています。err.(apperr.ApplicationError)により、定義したアプリケーション内部のエラーコードのみでエラーハンドリングを行います。外部のエラーを内部のエラーに変換しておくことで、最終的にエラーハンドリングする際に懸念事項をぐっと減らすことができます。

func grpcStatus(err error) codes.Code {
    if e, ok := err.(apperr.ApplicationError); ok {
        switch e.Code {
        case domainerr.InternalServerErrCode:
            return codes.Internal
        case apperr.NotFoundErrorCode:
            return codes.NotFound
        case apperr.InvalidArgumentErrorCode:
            return codes.InvalidArgument
        }
    }
    return codes.Internal
}

まとめ

今回はエラーハンドリングについて例を踏まえてまとめました。これは基本的なエラーの設計方針にとどまっているため、実際の実装はシステムによって様々です。またエラーについて記事を書くことがあれば、実際のアプリケーションでエラーハンドリング設計から実装までを紹介できればと思います。

参考文献

middlemost.com

golang.org