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