これまでの記事紹介
URL | |
---|---|
第1回 | 【Spring Boot】個人的に推奨するディレクトリ/ファイル構成 |
第2回 | 【Spring Boot】【Java】定数宣言はfinal staticが良いのか、enumが良いのか |
第3回 | 【Spring Boot】Spring Bootにおける例外ハンドリング |
はじめに
第3回の続きとしてRESTful APIにおける例外ハンドリングを紹介します。
RESTful APIにおける例外ハンドリングはHTTPステータスコードと、処理中に何が発生したかをメッセージなどで返却することが一般的です。
本記事ではECサイトを利用するユーザが商品を購入するケースをもとに、どのタイミングで何が発生するかを解説していきます。
参考文献
- mdn web docs様「HTTP レスポンスステータスコード」
https://developer.mozilla.org/ja/docs/Web/HTTP/Status - NTT データグループ様 「TERASOLUNA Server Framework for Java (5.x) Development Guideline」
https://terasolunaorg.github.io/guideline/current/ja/
前提
SpringBootを使ったRESTful APIアプリケーションに焦点を当てています。
読者ターゲット
- Java の Web/API アプリケーション開発経験がある方
- RESTful API がわかる方
RESTful APIで考慮するHTTPステータスコード
HTTPステータスコード | 意味 | 説明 | 例 | 正常/異常 |
---|---|---|---|---|
200 | OK | リクエストが成功したことを示します。成功が意味することは、 HTTP メソッドにより異なります。 GET: リソースが読み込まれ、メッセージ本文で転送された。 DELETE/PATCH/PUT/POST: 操作の結果を表すリソースがメッセージ本文で送信される。 |
- | 正常 |
201 | Created | リクエストは成功し、その結果新たなリソースが作成されたことを示します。これは一般的に、 POST リクエストや、一部の PUT リクエストを送信した後のレスポンスになります。 | - | 正常 |
400 | Bad Request | クライアントのエラーとみなされるもの(例えば、不正なリクエスト構文、不正なリクエストメッセージフレーム、不正なリクエストルーティング)のために、 サーバーがリクエストを処理できない、あるいは処理しようとしない場合を示します。 | リクエストに必須項目が存在しなかった。日付の書式が正しくなかった | 異常 |
401 | Unauthorized | HTTP 標準では "unauthorized" (不許可) と定義されていますが、意味的にはこのレスポンスは "unauthenticated" (未認証) です。 つまり、クライアントはリクエストされたレスポンスを得るためには認証を受けなければなりません。 | アクセストークンの有効期限が切れていた | 異常 |
403 | Forbidden | 認証されていないなどの理由でクライアントにコンテンツのアクセス権がなく、サーバーが適切なレスポンスの返信を拒否していることを示します。 401 Unauthorized とは異なり、クライアントの ID がサーバーに知られています。 | 許可されていないエンドポイントにアクセスした | 異常 |
404 | Not Found | サーバーがリクエストされたリソースを発見できないことを示します。 ブラウザーでは、これは URL が解釈できなかったことを意味します。 API では、これは通信先が有効であるものの、リソース自体が存在しないことを意味することがあります。 サーバーは認証されていないクライアントからリソースの存在を隠すために、 403 の代わりにこのレスポンスを返すことがあります。 | 存在しないエンドポイントを指定した。もしくは要求したリソースが存在しなかった | 異常 |
405 | Method Not Allowed | サーバーがリクエストメソッドを理解しているものの、無効にされており使用することができません。例えば、 API がリソースを DELETE することを禁止できます。 GET および HEAD の 2 つは必須で、無効にすることができず、このエラーコードを返してはいけません。 | POSTメソッドのエンドポイントに対してGETメソッドでリクエストした | 異常 |
409 | Conflict | このレスポンスは、リクエストがサーバーの現在の状態と矛盾する場合に送られるでしょう。 | 楽観/悲観ロックによって情報を更新できなかった | 異常 |
500 | Internal Server Error | サーバー側で処理方法がわからない事態が発生したことを示します。 | データベースやキーバリューストアなどミドルウェアに接続できなかった、null値(定義されていない値)を参照しようとしてNullPointerExceptionが発生した | 異常 |
※説明はmdn web docs様より引用
番外編:上記以外に考慮するHTTPステータスコード
HTTPステータスコード | 意味 | 説明 | 例 | 正常/異常 |
---|---|---|---|---|
429 | Too Many Requests | ユーザーは一定の時間内に大量のリクエストを送信しました ("レート制限") | 同一IPから一定期間中に大量のリクエストが送信された(DDoS攻撃) | 異常 |
503 | Service Unavailable | サーバーはリクエストを処理する準備ができていないことを示します。 一般的な原因は、サーバーがメンテナンスや過負荷でダウンしていることです。 | 人気アーティストのチケット購入予約に殺到してアクセスが集中している | 異常 |
504 | Gateway Timeout | このエラーレスポンスは、ゲートウェイとして動作するサーバーが時間内にレスポンスを得られない場合に送られます。 | 過負荷やデータベース接続タイムアウトなどが原因でリクエストに対するレスポンスを返却する前にリクエストタイムアウト時間を経過してしまった | 異常 |
RESTfulAPIリクエスト~レスポンスまでのシーケンス
ここまでの説明で登場したHTTPステータスコードがどのタイミングで発生するのかRESTful APIリクエスト~レスポンスの一連の流れをシーケンスで見てみましょう。
※WAF:Webアプリケーションファイアウォールの略でWebアプリケーションの脆弱性を悪用した攻撃から保護する目的で導入されるセキュリティ対策ツール
皆さんが普段使いしているECサイトでの購入処理を例にしてみました。利用ユーザに正しく購入できたことを伝えるため、また、購入できなかった場合には何が原因で、利用ユーザは次に何をするべきかを伝える必要があります。
RESTful API(アプリケーション全般ですが)では、これら発生し得る事象をケア(ハンドリング)する必要があります。
本記事で紹介するのは赤枠内の部分となります。それ以外はWAFやSpringBootフレームワーク内で制御されるので割愛します。
JSONからオブジェクトへパース
下記が発生し得ます。
HTTPステータスコード | 発生例 |
---|---|
400 | ・購入数が数字以外(例:abc ・配達日時の値がyyyyMMddHH形式以外(例:202412T3(2024年1月2日 3時) |
購入リクエストのリクエストボディ
{
"productId": "pid1",
"orderNumber": "abc",
"deliveryTime": "202412T3"
}
RESTful APIのオブジェクト
public class OrderRequest {
private String productId; // 値が"pid"はStringなのでパースできる:OK
private int orderNumber; // 値が"abc"はintにパースできない:NG
private ZonedDateTime deliveryTime; // 値が"202412T3"はZonedDateTimeにパースできない:NG
}
HttpMessageNotReadableExceptionが発生するのでExceptionHandler(※)で例外の捕捉しましょう。
※前回の記事を参照
https://qiita.com/rpc_tk/items/cec5c322cecf8b48f78c#exceptionhandler%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%9F%E4%BE%8B%E5%A4%96%E3%81%AE%E6%8D%95%E6%8D%89
例外の捕捉
@RestControllerAdvice
public class RestApiControllerAdvice extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleHttpMessageNotReadable(HttpMessageNotReadableException ex,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.APPLICATION_PROBLEM_JSON);
ErrorResponse response = ErrorResponse.builder().errorCode("ERROR_CODE_JSON_TO_OBJECT_PARSE")
.errorMessage("JSONからオブジェクトへパースに失敗しました").build();
// HTTPステータスコード400(HttpStatus.BAD_REQUEST)でレスポンス返却
return super.handleExceptionInternal(ex, response, httpHeaders, HttpStatus.BAD_REQUEST,
request);
}
}
補足
SpringBootフレームワークにてJSON/オブジェクト間をマッピングしてくれます。
入力チェック
下記が発生し得ます。
HTTPステータスコード | 発生例 |
---|---|
400 | ・商品IDが指定されていない ・購入数が0 |
購入リクエストのリクエストボディ
{
"productId": "",
"orderNumber": 0,
}
RESTful APIのオブジェクト
public class OrderRequest {
@NotBlank // 値が""なので未設定扱い:NG
private String productId;
@NotNull // 値が0なので設定扱い:OK
@Range(min = 1, max = 99) // 値が0なのでminの指定値以上になっていない:NG
private int orderNumber;
private ZonedDateTime deliveryTime;
}
MethodArgumentNotValidExceptionが発生するのでExceptionHandlerで例外の捕捉しましょう。
例外の捕捉
@RestControllerAdvice
public class RestApiControllerAdvice extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.APPLICATION_PROBLEM_JSON);
List<String> errorMessages = ex.getBindingResult().getFieldErrors().stream().map(error -> {
return String.format("%s:%s", error.getField(), error.getDefaultMessage());
}).toList();
RestApiErrorResponse response = RestApiErrorResponse.builder().errorCode("ERROR_CODE_VALID")
.errorMessages(errorMessages).build();
// HTTPステータスコード400(HttpStatus.BAD_REQUEST)でレスポンス返却
return super.handleExceptionInternal(ex, response, httpHeaders, HttpStatus.BAD_REQUEST,
request);
}
補足
入力チェックはController層にて@Validated
で実行します。
@PostMapping("/order")
public void order(@RequestBody @Validated OrderRequest request)
アクセストークンチェック
下記が発生し得ます。
HTTPステータスコード | 発生例 |
---|---|
401 | ・JWTの発行者が不適切 ・JWTの受信者が不適切 ・JWTの有効期限が切れている |
RESTful APIでは主にアクセストークン(JSON Web Token)を用いて認証されたユーザからのリクエストであるかを判定するかと思います。
その際、アクセストークンに含まれる各クレームの値が適切かのチェックを行います。
次のエンドポイントアクセス権限チェックを含めてほぼすべてのエンドポイントでチェックすることとなるのでSpringBootの機能であるFilterやInterceptorに実装するのがベターでしょう。
try {
// アクセストークンチェック(独自メソッドです。チェックNGの場合は何かしらの例外が発生する前提とします。)
validAccessToken(accessToken);
} catch (Exception e) {
throw new UnauthorizedException("アクセストークンチェックに失敗しました");
}
例外の捕捉
@RestControllerAdvice
public class RestApiControllerAdvice extends ResponseEntityExceptionHandler {
@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<ErrorResponse> handleUnauthorizedException(UnauthorizedException ex) {
ErrorResponse error = ErrorResponse.builder().errorCode("ERROR_CODE_UNAUTHORIZED")
.errorMessage(ex.getMessage()).build();
// HTTPステータスコード401(HttpStatus.UNAUTHORIZED)でレスポンス返却
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
}
}
try~catch(※)を用いて発生し得る例外を捕捉し、別の例外クラスにて再度例外を発生させます。
UnauthorizedExceptionといった独自例外を発生させてExceptionHandlerで例外の捕捉しましょう。
エンドポイントアクセス権限チェック
下記が発生し得ます。
HTTPステータスコード | 発生例 |
---|---|
403 | ・エンドポイントにアクセスする権限が存在しない |
アクセストークンに含まれるパーミッションのクレーム内に、エンドポイントへアクセスできる権限を持っているかのチェックを行います。
チェックの実装方法も様々です。本記事ではエンドポイントのメソッドに独自アノテーションである@Permission
を付与し、その値がアクセストークンに含まれるパーミッションのクレームに存在するかといった実装を紹介します。
@PostMapping("/order")
@Permission("write:order")
public void order(@RequestBody @Validated OrderRequest request,
@RequestHeader(name = "X-ACCESS-TOKEN", required = true) String accessToken) {
try {
Class<?> c = this.getClass();
Method m = c.getMethod("order", new Class[] {});
Permission p = (Permission) m.getAnnotation(Permission.class);
// エンドポイントアクセス権限チェック(独自メソッドです)
validPermission(accessToken, p.value());
} catch (Exception e) {
throw new PermissionException("エンドポイントアクセス権限チェックに失敗しました");
}
}
例外の捕捉
@RestControllerAdvice
public class RestApiControllerAdvice extends ResponseEntityExceptionHandler {
@ExceptionHandler(PermissionException.class)
public ResponseEntity<ErrorResponse> handlePermissionException(PermissionException ex) {
ErrorResponse error = ErrorResponse.builder().errorCode("ERROR_CODE_PERMISSION")
.errorMessage(ex.getMessage()).build();
// HTTPステータスコード403(HttpStatus.FORBIDDEN)でレスポンス返却
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
}
}
PermissionExceptionといった独自例外を発生させてExceptionHandlerで例外の捕捉しましょう。
在庫数チェック
下記が発生し得ます。
HTTPステータスコード | 発生例 |
---|---|
404 | ・購入数分の在庫が確保できない |
409 | ・悲観ロックが失敗した |
500 | ・データベースエラーが発生した |
購入にあたって在庫数チェックを行い、購入数分を確保できるのかをデータベースに問い合わせします。悲観ロックをする場合はSELECT FOR UPDATEを用いましょう。同時アクセスが発生してもクリティカルな問題にならない場合は楽観ロックで良いでしょう。
※悲観ロック、楽観ロックについては下記が参考になります。
NTT データグループ様 「TERASOLUNA Server Framework for Java (5.x) Development Guideline」
https://terasolunaorg.github.io/guideline/public_review/ArchitectureInDetail/ExclusionControl.html#id7
// 在庫数チェック(独自メソッドです。悲観ロックにて更新行をロックして不整合を防ぎます。)
int stock = getStockByProductId(productId);
if (stock < orderNumber) {
throw new StockNotFoundException("在庫がありません");
}
例外の捕捉
@RestControllerAdvice
public class RestApiControllerAdvice extends ResponseEntityExceptionHandler {
@ExceptionHandler(StockNotFoundException.class)
public ResponseEntity<ErrorResponse> handleStockNotFoundException(StockNotFoundException ex) {
ErrorResponse error = ErrorResponse.builder().errorCode("ERROR_CODE_STOCK_NOT_FOUND")
.errorMessage(ex.getMessage()).build();
// HTTPステータスコード404(HttpStatus.NOT_FOUND)でレスポンス返却
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(LockingFailureException.class)
public ResponseEntity<ErrorResponse> handleLockingFailureException(LockingFailureException ex) {
ErrorResponse error = ErrorResponse.builder().errorCode("ERROR_CODE_LOCK")
.errorMessage(ex.getMessage()).build();
// HTTPステータスコード409(HttpStatus.CONFLICT)でレスポンス返却
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAll(Exception ex) {
ErrorResponse error = ErrorResponse.builder().errorCode("ERROR_CODE_UNEXPECTED")
.errorMessage(ex.getMessage()).build();
// HTTPステータスコード500(HttpStatus.INTERNAL_SERVER_ERROR)でレスポンス返却
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
購入数分の在庫が確保できない場合
StockNotFoundExceptionといった独自例外を発生させてExceptionHandlerで例外の捕捉しましょう。
※在庫が足りないケースでNotFoundと表現するのはどうなの?といった疑問があると思いますがそこは目をつぶっていただけると!
存在しないエンドポイントにリクエストしたケースと、今回のケースをどう区別するの?といった疑問があると思いますが、後者の場合はレスポンスにエラーコード、エラーメッセージを詰めているのでこの値があるかどうかで区別します。
悲観ロックが失敗した場合
他のユーザに購入処理を先乗りされて悲観ロックによる行ロックが失敗する可能性があります。
用いるデータベースによって異なりますがPessimisticLockingFailureExceptionとはじめとする悲観ロック例外が発生するので、他のケースと同様にExceptionHandlerで例外の捕捉しましょう。
データベースエラーが発生した場合
データベースに問い合わせするため、データベースへの接続エラー、タイムアウトなど予期せぬエラーが発生する可能性があります。予期せぬエラーに備えてExceptionクラスをExceptionHandlerで捕捉しましょう。
登録
下記が発生し得ます。
HTTPステータスコード | 発生例 |
---|---|
500 | ・データベースエラーが発生した |
ようやく購入処理です。データベースに購入情報の登録や在庫数の更新を行います。在庫数チェックと同様でデータベースに問い合わせするため予期せぬエラーが発生する可能性があります。
// 購入処理(独自メソッドです)
registerOrder(productId, orderNumber, deliveryTime);
例外の捕捉
※在庫数チェックの「データベースエラーが発生した場合」と同一なので割愛します。
最後に
改めてまとめてみると思った以上に例外ハンドリングするケースが多いなと感じました。他にもこういったケースがあるのでは?ここはこうした方がいいのでは?などコメントをいただけると幸いです。