2020-05-15 中村
Spring Bootにおける例外処理のプラクティスを色々調べたのでまとめる.
try {
//検査例外を投げる呼び出し
} catch(検査例外 e) {
throw new 非検査例外("メッセージ", e); //非検査例外で投げる
}File not found: "exception-handling.png" at page "SpringBoot/関連技術情報"[添付]
拾うべきエラーはそれぞれの層で種類が異なる.
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);
}
}
}
| 一般的なJava例外 | カスタム例外 | 対応するHTTPコード |
| ElementNotFound | そんなリソースないよ例外 | 404 Not found |
| IllegalArgumentException | そんな引数おかしいよ例外 | 400 Bad Request |
| IllegalStateException | アクセスのタイミングがおかしいよ例外 | 503 Service Unavailable |
| AuthenticationException | 認証失敗したよ例外 | 401 Unauthorized |
| AccessDeniedException,SecurityException | アクセスしてはダメだよ例外 | 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 を実行する際
{ "code": "OK", "message": null, "result": {"uid": "masa-n", "coin": 750}}{ "code": "E14", "message": "Cannot afford 250 coins", "result": null}ソース
/**
* 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);
}
}