SpringBoot

Spring Boot における例外処理

Spring Bootにおける例外処理のプラクティスを色々調べたのでまとめる.

基本的な考え方

  1. Javaプログラムにおいて何らかのエラーを検出する際には,必ず 例外を発生させること
    • 返り値でエラーを表現してはいけない.
  2. 例外は必ず 非検査例外 (extends RuntimeException)とすること
    • 検査例外(extends Exception)をすると,throws宣言やtry-catchが必要になりコードが汚くなる
    • 呼び出し先の検査例外をcatchする必要があれば,非検査例外にラップして投げ直す
      try {
         //検査例外を投げる呼び出し
      } catch(検査例外 e) {
         throw new 非検査例外("メッセージ", e);  //非検査例外で投げる
      }
  3. 例外のハンドリングは,専用の 例外ハンドラ で横断的に行う
  4. 例外が発生した場合,Webアプリ・Web-APIの呼び出し元であるクライアントにエラーを伝えること
    • サーバ側で復旧・解決できるエラーについてはこの限りではない
  5. エラーを伝える際には,適切なHTTPステータスをつけられればなお良い
    • デフォルトではすべて500(Internal Server Error)になってしまう

アーキテクチャ

exception-handling.png

拾うべき例外とその層

拾うべきエラーはそれぞれの層で種類が異なる.

Springに関する記事,マニュアルをいろいろと調べた中で, 大体下記のような感じか.

Springの層拾うべき例外の種類
コントローラ層サービス利用の前提に違反した例外フォームの入力間違い,パラメータの型違い,認証・認可の失敗など
サービス層ビジネスルールに違反した例外ユーザIDは他の人と同じものを使えない,他人の口座から引き出せない,残高以上の額を引き出せない,口座は1つしか作れないなど
レポジトリ層インフラやシステムの不具合による例外入出力例外,通信例外,データベース例外など

カスタム例外

Spring Frameworkが発生させる例外は一般的なものなので, アプリケーション固有の例外 (カスタム例外)でラップしてやるのが良い.

Spring例外例外発生場所ラップするカスタム例外
NoSuchElementException@ServiceでRepository内の口座を参照しようとしたがなかったAccountNotFoundException
MethodArgumentNotValidException@RestController でフォーム検査に引っかかったPitCoinValidationException

例外の種類だけでなく,起こった場所や文脈に応じて,例外に適切なエラーコードを埋め込んであげると,呼び出し側で対処しやすい

例外タイプ検査エラーの内容エラーコード
不正な操作フォーム検査にひっかかったPirCoinValidationException.INVALID_FORM
不正な操作新規ユーザIDが既に存在したPirCoinValidationException.USER_ALREADY_EXISTS
不正な操作残高以上のお金を出勤しようとしたPirCoinValidationException.INSUFFICIENT_BALANCE

例外サンプル

/**
 * アカウントに対する不正な操作に関する例外
 */
public class AccountValidationException extends RuntimeException {
    /** 定義済みエラーコード */
    public static final int ACCOUNT_ALREADY_EXISTS = 11;
    public static final int INVALID_UID = 12;
    public static final int INVALID_AMOUNT = 13;
    public static final int INSUFFICIENT_BALANCE = 14; 

    private static final long serialVersionUID = 1L;
    /** エラーコード */
    public final int code;

    public AccountValidationException(int code, String str, Throwable cause) {
        super(str, cause);
        this.code = code;
    }
    public AccountValidationException(int code, String str) {
        super(str);
        this.code = code;
    }
}

使用例

	/**
	 * 与えられたユーザIDの口座からコインを出金する.
	 * 
	 * @param uid    口座のユーザID
	 * @param amount 出金する枚数
	 * @return 出金後の口座情報
	 */
	public Account useCoin(String uid, int amount) {
		if (amount <= 0) {
			throw new AccountValidationException(AccountValidationException.INVALID_AMOUNT,
					"amount must be a positive number");
		} else {
			int coin = getCoin(uid);
			if (coin - amount < 0) { // 残高が0未満にならないかチェック
				throw new AccountValidationException(AccountValidationException.INSUFFICIENT_BALANCE,
						"Cannot afford " + amount + " coins");
			} else {
				return setCoin(uid, coin - amount);
			}
		}
	}

例外のタイプと対応するHTTPステータスコード

一般的なJava例外カスタム例外対応するHTTPコード
ElementNotFoundそんなリソースないよ例外404 Not found
IllegalArgumentExceptionそんな引数おかしいよ例外400 Bad Request
IllegalStateExceptionアクセスのタイミングがおかしいよ例外503 Service Unavailable
AuthenticationException認証失敗したよ例外401 Unauthorized
AccessDeniedExceptionSecurityExceptionアクセスしてはダメだよ例外403 Forbidden
UnsupportedOperationExceptionそんな操作ないよ例外501 Not Implemented
Throwable (anything else)その他の例外500 Internal Server Error

例外ハンドラ

サンプルソース

@RestControllerAdvice
public class PitCoinErrorHandler {
   /**
    * アカウントが存在しない
    * @param req
    * @param ex
    * @return
    */
   @ExceptionHandler(AccountNotFoundException.class)
   @ResponseStatus(HttpStatus.NOT_FOUND)
   public PitCoinResponse handleNotFoundException(HttpServletRequest req, AccountNotFoundException ex) {
       log.warn(ex.getMessage());
       return PitCoinResponse.createErrorResponse(ex.code, ex);
   }
   /**
    * アカウントに対する操作が不正
    */
   @ExceptionHandler(AccountValidationException.class)
   @ResponseStatus(HttpStatus.BAD_REQUEST)
   public PitCoinResponse handleIlleagalArgumentException(HttpServletRequest req, AccountValidationException ex) {
       log.warn(ex.getMessage());
       return PitCoinResponse.createErrorResponse(ex.code, ex);
   }
    /**
    * 許可されないメソッドの実行
    * @param request
    * @param ex
    * @return
    */
   @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
   @ResponseStatus(HttpStatus.FORBIDDEN)
   public PitCoinResponse handleMethodNotSupportedException(HttpServletRequest request, HttpRequestMethodNotSupportedException ex){
       return PitCoinResponse.createErrorResponse(ex);
   }
   /**
    * 上記で処理していないエラー
    *
    * @param request
    * @param ex
    * @return
    */
   @ExceptionHandler(Exception.class)
   @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
   public PitCoinResponse handleException(HttpServletRequest request, Exception ex){
       log.error(ex.getMessage(),ex);
       return PitCoinResponse.createErrorResponse(ex);
   }
}

レスポンスの設計

APIの呼び出しが,正常に完了した場合もエラーの場合も,同じ型のレスポンス を返すように設計すれば呼び出し側のハンドリングが楽.

レスポンスの設計例

例えば,POST /accounts/masa-n/use?amount=250 を実行する際

ソース

/**
 * PitCoinコントローラーが返すレスポンス
 */
@Data
public class PitCoinResponse {
   public static final String CODE_OK = "OK";
   public static final String ERROR_PREFIX = "E";
   public static final int DEFAULT_ERROR_CODE = 99;
   /** PitCoin-APIのレスポンスコード */
   private String code;
   /** メッセージ */
   private String message;
   /** データ */
   private Object result;

   /**
    * 外部から生成禁止
    */
   private PitCoinResponse() {

   }
   /**
    * エラーコード付きでエラーレスポンスを作成する
    * @param code エラーコード
    * @param ex 例外
    * @return エラーレスポンス
    */
   public static PitCoinResponse createErrorResponse(int code, Exception ex) {
       PitCoinResponse res = new PitCoinResponse();
       res.setCode(ERROR_PREFIX + String.format("%02d", code));
       res.setMessage(ex.getMessage());
       res.setResult(null);
       return res;
   }
   /**
    * デフォルトエラーコードでエラーレスポンスを作成する
    * @param ex 例外
    * @return エラーレスポンス
    */
   public static PitCoinResponse createErrorResponse(Exception ex) {
       return createErrorResponse(DEFAULT_ERROR_CODE, ex);
   }
   /**
    * メッセージ付きで成功レスポンスを作成する
    * @param data データ
    * @param message メッセージ
    * @return レスポンス
    */
   public static PitCoinResponse createSuccessResponse(Object data, String message) {
       PitCoinResponse res = new PitCoinResponse();
       res.setCode(CODE_OK);
       res.setMessage(message);
       res.setResult(data);
       return res;
   }
   /**
    * 成功レスポンスを作成する
    * @param data データ
    * @return レスポンス
    */
   public static PitCoinResponse createSuccessResponse(Object data) {
       return createSuccessResponse(data, null);
   }
}

参考文献


添付ファイル: fileexception-handling.png 56件 [詳細]

トップ   編集 凍結 差分 履歴 添付 複製 名前変更 リロード   新規 一覧 検索 最終更新   ヘルプ   最終更新のRSS
Last-modified: 2023-11-23 (木) 23:14:42