0
0

【Spring Boot】Spring Bootにおける例外ハンドリング

Last updated at Posted at 2024-09-03

これまでの記事紹介

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」やら難しい話が出てきます。
個人的には理解したほうがベターであり、ベストではありません。
本記事では理解重視で説明します。

image.png

①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("ファイルが存在しません。");
  }
}

実行結果
image.png

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

実行結果
image.png

それぞれの用途

対処法 使う場面 備考
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の組み合わせによる例外ハンドリングはアプリケーション全体をカバーできるとても強力な機能です。例外ハンドリングに悩んでる方への一助になれば幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0