これまでの記事紹介
URL | |
---|---|
第1回 | 【Spring Boot】個人的に推奨するディレクトリ/ファイル構成 |
第2回 | 【Spring Boot】【Java】定数宣言はfinal staticが良いのか、enumが良いのか |
9/5:記事タイトルをREST APIにおける例外ハンドリング→Spring Bootにおける例外ハンドリングに修正しました。内容的にREST APIについて語り切れていないので本記事、もしくは次回記事に記載します。
はじめに
皆さん例外ハンドリングに悩んだことはありませんか。
アプリケーションに例外はつきものなので、対処する必要があります。
適切に例外を拾って、適切な対処を行っていますか。
発生する可能性のある例外を放置していませんか。
無作為にtry-catchをしてエラーを無かったことにしてませんか。
本記事では例外そのものを知ってもらった上で、どう対処するかをSpringBootの実装で紹介します。
前提
SpringBootを使ったREST APIアプリケーションに焦点を当てています。
Webアプリケーションの場合はErrorControllerなどを使った方法などがありますが本記事では紹介しません。
読者ターゲット
- Java の Web/API アプリケーション開発経験がある方
- RESTful API がわかる方
例外とは
例外とは一般的にプログラム「実行」時に発生するエラーのことを指します。
例外の中でも「予期せぬ」例外と「予期する」例外とで分かれます。
「予期せぬ」例外は更に「検査例外」と「非検査例外」に分かれます。
私は「予期せぬ」例外をシステムエラー、「予期する」例外をビジネスエラーと呼んでいます。
アプリケーションではこれらの例外を適切に対処(ハンドリング)する必要があります。
検査例外とは
コンパイル時に検査される例外で、そのままではコンパイルエラーとなるためアプリケーションが動きません。検査例外が発生する可能性のあるメソッドを呼び出す場合に以下2つのいずれかの対処が必要です。
- try-catchで例外を捕捉する
// try-catchがないとコンパイルエラー
try {
// FileReaderはFileNotFoundExceptionの検査例外が発生する可能性がある
// 該当ファイルが存在しない場合はFileNotFoundExceptionが発生し、catchへ
FileReader in = new FileReader("data.txt");
} catch (FileNotFoundException e) {
// ここではスタックトレースを出力しているが、本来は適切な例外ハンドリングを行う
e.printStackTrace();
}
- throwsで検査例外を上流(呼び出し元)へ伝達する
// throwsがないとコンパイルエラー
private void read() throws FileNotFoundException {
// FileReaderはFileNotFoundExceptionの検査例外が発生する可能性がある
// 該当ファイルが存在しない場合はFileNotFoundExceptionが発生し、上流(呼び出し元)へ伝達
FileReader in = new FileReader("data.txt");
}
// 上流(呼び出し元)はtry-catchもしくは、更に上流へ伝達するthrowsによるハンドリングが必要
// ここではtry-catchによるハンドリングを紹介
private void main() {
try {
this.read();
} catch (FileNotFoundException e) {
// ここではスタックトレースを出力しているが本来は適切な例外ハンドリングを行う
e.printStackTrace();
}
}
非検査例外とは
実行時に発生する例外です。検査例外と違い、コンパイルには影響ありません。
プログラムのバグや「予期せぬ」例外が原因で発生することがあり、通常は防ぐことが難しいです。
代表例は「NullPointerException」でしょうか。
String str = null;
// null値(定義されていない値)を参照しようとしてNullPointerExceptionが発生
// 実行することでプログラムにバグがあることが分かる
System.out.println(str.length());
システムエラーとは
システムは正常稼働していたが、「予期せぬ」事象が発生した
- 存在するはずのファイルが存在しない(FileNotFoundException
- null値の参照型変数を参照しようとした(NullPointerException
- データベースに接続できない(SQLException
ビジネスエラーとは
システムは正常稼働しており、「予期する」事象が発生した
- 入力フォームからリクエストされた内容に必須項目がない(必須入力エラー
- ECサイトでユーザが商品を購入しようとしたが在庫がなかった(在庫なしエラー
// 在庫が存在しなかった場合の独自例外クラス
public class StockNotFoundException extends RuntimeException {
private static final long serialVersionUID = 1L;
public StockNotFoundException(String message) {
super(message);
}
}
// 在庫が存在しなかった場合にStockNotFoundExceptionの独自例外を発生させる
if (stock == 0) {
throw new StockNotFoundException("在庫がありません");
}
なぜ例外をハンドリングするのか
- 例えば必須入力エラーが発生した場合に、何が起きたか(必須入力項目が不足)、次に何をすれば良いのか(必須入力項目に対して入力を促す)といったアプリケーションを利用しているユーザへの通知
- システム保守運用者が全ての例外を1つ1つ見ることは現実的に不可能です。必須入力エラーなど「予期する」例外は監視対象外にして、データベース接続エラーなど「予期せぬ」例外を監視対象にする
といったことが例外をハンドリングする上で重要となります。
どのように例外をハンドリングするのか
- try-catchを使った例外の捕捉
- ExceptionHandlerを使った例外の捕捉
try-catchを使った例外の捕捉
try-catchを使った構文で実際に説明します。
// data.txtファイルを読み込む
FileReader in = new FileReader("data.txt");
FileReaderを用いてdata.txtファイルを読み込むといった処理ですがdata.txtが存在しなかった場合はどうなるでしょうか。
java.io.FileNotFoundException: data.txt (指定されたファイルが見つかりません。)
at java.base/java.io.FileInputStream.open0(Native Method) ~[na:na]
at java.base/java.io.FileInputStream.open(FileInputStream.java:216) ~[na:na]
at java.base/java.io.FileInputStream.<init>(FileInputStream.java:157) ~[na:na]
at java.base/java.io.FileInputStream.<init>(FileInputStream.java:111) ~[na:na]
at java.base/java.io.FileReader.<init>(FileReader.java:60) ~[na:na]
data.txtが存在しなかった場合はFileNotFoundException例外が発生します。
try {
// 該当ファイルが存在しない場合はFileNotFoundExceptionが発生し、catchへ
FileReader in = new FileReader("data.txt");
} catch (FileNotFoundException e) {
// 以降該当ファイルが存在しない場合の例外ハンドリングを記述
}
data.txtが存在しないことを考慮してFileNotFoundExceptionの例外を捕捉できるようにtry-catchを使います。
ExceptionHandlerを使った例外の捕捉
本質的に理解にするには「Servlet Container」「Dispatch Servlet」やら難しい話が出てきます。
個人的には理解したほうがベターであり、ベストではありません。
本記事では理解重視で説明します。
①ECサイトを利用しているユーザから商品を購入するリクエストが発生
②状況に応じて以下のいずれかが発生
1.リクエストにカード番号など購入に必要な情報がなかったので「必須入力エラー」発生
2.在庫がなかったので「在庫なしエラー」発生
3.在庫を確認するためのデータベースに接続できず「データベース接続エラー」発生
③(Rest)ControllerAdvice+ExceptionHandlerにより各種エラーをハンドリング
④エラーレスポンスを返却
といった流れになります。
Controller全体に共通処理を提供する「RestControllerAdvice」において「ExceptionHandler」を組み合わせることで、Controller経由で呼び出された処理において例外を捕捉することができます。とても便利ですね。
「ExceptionHandler」は特定の例外を捕捉することができます。try-catchのcatchに該当すると言えば分かりやすいでしょうか。
RestControllerAdviceとExceptionHandlerを使った例外の捕捉
RestControllerAdviceとExceptionHandlerを使った構文で実際に説明します。
※絵に合わせていずれかの例外の説明が良いかと思いつつ、try-catchとの対比としたいのでFileReaderを使った紹介となります。ご了承ください。
@RestController
@RequestMapping("/hoge")
public class HogeController {
@GetMapping("/file")
public void getFile() throws FileNotFoundException {
// data.txtが存在しないためFileNotFoundExceptionが発生!
FileReader in = new FileReader("data.txt");
}
}
@RestControllerAdvice
public class RestApiControllerAdvice extends ResponseEntityExceptionHandler {
// FileNotFoundException例外を捕捉
@ExceptionHandler(FileNotFoundException.class)
public ResponseEntity<Object> handleFileNotFoundException(FileNotFoundException e) {
// 500エラー返却
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("ファイルが存在しません。");
}
}
FileNotFoundExceptionが発生するとRestApiControllerAdviceクラスの@ExceptionHandler(FileNotFoundException.class)が付与されているメソッド(handleFileNotFoundException)にて捕捉されます。
ここではstatusとbodyにそれぞれ【HTTPステータス500】、【ファイルが存在しません。】を詰めてレスポンスとして返却しています。
bodyは独自オブジェクトも格納できる
bodyに文字列を詰めていますが、独自オブジェクトを指定することも可能です。
// lombokアノテーションを用いてコンストラクタやgetter/setterを生成
@Data
@Builder
@AllArgsConstructor
@RequiredArgsConstructor
// エラーレスポンスオブジェクトクラスを宣言
public class ErrorResponse {
private String errorCode;
private String errorMessage;
}
@ExceptionHandler(FileNotFoundException.class)
public ResponseEntity<ErrorResponse> handleFileNotFoundException(FileNotFoundException e) {
// エラーレスポンスオブジェクトを生成
ErrorResponse error =
ErrorResponse.builder().errorCode("ERROR_001").errorMessage(e.getMessage()).build();
// bodyにエラーレスポンスオブジェクトをセット
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
それぞれの用途
対処法 | 使う場面 | 備考 |
---|---|---|
try-catch | ①例外が発生することを想定しており、処理を継続したい場合。私がよく使う言葉で例えると「例外を握りつぶす」 ②別の例外クラスにて再度例外を発生させたい場合。この場合はExceptionHandlerと組み合わせることも可能 |
個別対処が必要 |
ExceptionHandler | 例外単位でハンドリングしたい場合。FileNotFoundExceptionといった特定のタイミングのみ発生する例外の他に、ExceptionやRuntimeExceptionといった上位クラスを捕捉することも可能。 | 共通対処 |
①例:FileNotFoundExceptionが発生したが、想定しているのでcatchで捕捉するがそのまま処理継続
StringBuilder readData = new StringBuilder();
try {
FileReader in = new FileReader("data.txt");
// ~ readDataに詰める処理 ~
} catch (FileNotFoundException e) {
// 想定している例外なので握りつぶして処理続行
readData.append("data.txtが存在しなかった用の文字列データ");
}
// 以降はreadDataを用いた同一処理
②例:ApplicationExceptionといった独自例外クラスを作成し、try-catchにてFileNotFoundExceptionを捕捉したらApplicationExceptionを発生させる。ApplicationExceptionをExceptionHandlerで捕捉する。
// 独自例外クラス
public class ApplicationException extends RuntimeException {
private static final long serialVersionUID = 1L;
public ApplicationException(Throwable cause) {
super(cause);
}
}
try {
FileReader in = new FileReader("data.txt");
} catch (FileNotFoundException e) {
// ApplicationExceptionで再度例外を発生
throw new ApplicationException(e);
}
// ExceptionHandlerによるApplicationException例外を捕捉
@ExceptionHandler(ApplicationException.class)
public ResponseEntity<ErrorResponse> handleApplicationException(ApplicationException e) {
ErrorResponse error =
ErrorResponse.builder().errorCode("ERROR_002").errorMessage(e.getMessage()).build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
最後に
RestControllerAdviceとExceptionHandlerの組み合わせによる例外ハンドリングはアプリケーション全体をカバーできるとても強力な機能です。例外ハンドリングに悩んでる方への一助になれば幸いです。