Spring BootのWeb APIの例外ハンドリング 1回目

2022/11/17に公開

Spring BootのWebAPIで発生した例外のハンドリング方法について調べたことの備忘録的なメモです。
例外ハンドリングの実装方法はわかったのですが、内部の振る舞いが全然分からなかったので、ステップ実行しながら調べた個人的なメモです。
一応、例外ハンドリングの実装方法も備忘的に記述しています。

DispatcherServletの振る舞いなどを深掘りできたら、そのうち記事としてまとめたいと思っていますが、今回は備忘メモとなります。

【備忘メモ】Spring Boot(MVC)配下の処理で例外が発生した場合のハンドリングの流れ

  • DispatcherServletクラスで制御。

  • doDispatch()メソッドの中で例外をcatchして、processDispatchResult()メソッドを呼び出して例外をハンドリング。

  • 登録されている全てのHandlerExceptionResolverクラスのresolveException()メソッドを呼び出して例外をハンドリングする。

  • 上記の処理の中でExceptionHandlerExceptionResolverクラスのgetExceptionHandlerMethod()メソッドで今回throwされたExceptionのクラスに関連付けられたExceptionHanlderがないか確認し、あれば、ハンドラーメソッドが呼び出される。

ソースコード

今回作成するソースコードの全量は以下を参照ください。

https://github.com/ryotsuka7/spring-boot-demo/tree/v.1.0.6

Spring Boot配下の例外のハンドリング

実装するハンドリング処理の概要

以前の記事で、DBにCRUDするAPIを作成しました。
Spring BootからMyBatisを使ったDB接続(2回目 CRUDを行うAPI)

APIの仕様は以下の通りです。

処理 メソッド パス リクエストボディ レスポンスボディ レスポンスステータスコード 説明
詳細検索 GET /items/{id} null {id: integer, item_name: string} 200(OK) idを指定して1件取得
一覧検索 GET /items null [{id: integer, item_name: string}] 200(OK) 全件取得
登録 POST /items {item_name: string} {id: integer, item_name: string} 201(Created) id以外の項目をリクエストボディデータとして渡して、登録。idは自動採番。登録したデータをレスポンスボディデータとして返却。
更新 PUT /items/{id} {item_name: string} {id: integer, item_name: string} 200(OK) idをURLで指定して、id以外の項目をリクエストボディデータとして渡して、更新。更新したデータをレスポンスボディデータとして返却。
削除 DELETE /items/{id} null null 204(No Content) idをURLで指定して、削除。

例外処理を実装していない状態で、上記の詳細検索で存在しないidを指定して検索すると、NullPointerExceptionが発生して、ステータスコードは500でレスポンスボディとして以下が返却されます。
具体的にはlocalhost:8080/items/3に対してGETリクエストを発行したレスポンスが以下となります。

{
    "timestamp": "2022-11-13T09:08:23.374+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "trace": "java.lang.IllegalArgumentException: Source must not be null\n\tat org.springframework.util.Assert.notNull(Assert.java:201)\n\tat org.springframework.beans.BeanUtils.copyProperties(BeanUtils.java:782)\n\tat org.springframework.beans.BeanUtils.copyProperties(BeanUtils.java:719)\n\tat com.example.springbootdemo.controller.ItemController.findById(ItemController.java:43)\n\tat com.example.springbootdemo.controller.ItemController$$FastClassBySpringCGLIB$$79242a86.invoke(<generated>)\n\tat org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218)\n\tat org.springframework.aop.framework.CglibAopProxy.invokeMethod(CglibAopProxy.java:386)\n\tat org.springframework.aop.framework.CglibAopProxy.access$000(CglibAopProxy.java:85)\n\tat org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:704)\n\tat com.example.springbootdemo.controller.ItemController$$EnhancerBySpringCGLIB$$ed475005.findById(<generated>)\n\tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)\n\tat java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)\n\tat java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)\n\tat java.base/java.lang.reflect.Method.invoke(Method.java:568)\n\tat org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:205)\n\tat org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:150)\n\tat org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:117)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:808)\n\tat org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)\n\tat org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1071)\n\tat org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:964)\n\tat org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)\n\tat org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898)\n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:655)\n\tat org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)\n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:764)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:227)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)\n\tat org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:197)\n\tat org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97)\n\tat org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:541)\n\tat org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:135)\n\tat org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92)\n\tat org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78)\n\tat org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:360)\n\tat org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:399)\n\tat org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65)\n\tat org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:890)\n\tat org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1789)\n\tat org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)\n\tat org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191)\n\tat org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)\n\tat org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)\n\tat java.base/java.lang.Thread.run(Thread.java:833)\n",
    "message": "Source must not be null",
    "path": "/items/3"
}

これをDBからidで検索して、該当データが存在しない場合に、HTTPステータス404(Not Found)を返却し、レスポンスボディは以下の感じで返却されるようにします。

{
    "code": 0,
    "message": "No record found for id.",
    "path": "/items/3",
    "timestamp": "2022-11-15 21:02:07"
}

改修する内容

今回、改修する内容は以下の4点となります。

  • エラー情報を格納するJava Beanの新規作成
  • データが見つからないときに使用する例外クラスの新規作成
  • 例外ハンドリングを追加するためのクラスの新規作成
  • ControllerでDB処理を呼び出しているが、該当データがない場合に例外をthrowするように改修

エラー情報を格納するJava Bean

ApiError.java
package com.example.springbootdemo.advice;

import com.fasterxml.jackson.annotation.JsonFormat;
import java.time.LocalDateTime;
import lombok.Data;

@Data
public class ApiError {

    private Integer code;
    private String message;
    private String path;
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime timestamp;

    public ApiError(Integer code, String message, String path) {
        this.code = code;
        this.message = message;
        this.path = path;
        this.timestamp = LocalDateTime.now();
    }
}

ここで定義した内容がJSON形式でレスポンスとして返却されます。

pathtimestampを持たせるか迷いましたが、一旦はということで持たせています。
また、timestamp@JsonFormatでJacksonでJSONに変換される際の書式を指定しています。

あとは、エラーコードとメッセージを返却させています。
APIを設計する際にエラーコードでエラーを管理したいためにエラーコードを持たせています。

独自例外クラス

DBアクセスして、1件もヒットしない場合の例外をハンドリングするために、それ用の例外クラスを追加します。
エラーコードとエラーメッセージを保持できるようにしています。
クラス名はMyBatisに同名の例外クラスがあったので、迷いましたが、とりあえずということでこの名称にしています。
正式に実装する場合は変えると思います。

NotFoundException.java
package com.example.springbootdemo.advice;

public class NotFoundException extends RuntimeException {

    private static final long serialVersionUID = 1L;

    private Integer code;

    public NotFoundException(Integer code, String message) {
        super(message);
        this.code = code;

    }

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

}

例外ハンドリングを追加するためのクラスの新規作成

冒頭で説明したようにDispatchServletが例外を処理する際に検索するハンドラーを追加するためのクラスを追加します。

  • クラスに@RestControllerAdviceを付与するのはお約束です。

  • @ExceptionHandler({NotFoundException.class})でハンドリングする例外クラスを指定して、当該の例外が発生した際に定義したハンドラーメソッドが呼び出されます。

  • handleExceptionInternalメソッドは親クラスのResponseEntityExceptionHandlerのソースを読むとわかりますが、例外をcatchして処理した後に呼び出されるメソッドです。
    こちらをオーバーライドして、ApiErrorクラスで定義したJSONをレスポンスボディとして返却するようにしています。

ApiExceptionHandler.java
package com.example.springbootdemo.advice;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body,
        HttpHeaders headers, HttpStatus status, WebRequest request) {
        // リクエストのBodyのセット
        String path = ((ServletWebRequest) request).getRequest().getRequestURI();
        ApiError apiError = new ApiError(0, ex.getMessage(), path);
        
        return super.handleExceptionInternal(ex, apiError, headers, status, request);
    }


    @ExceptionHandler({NotFoundException.class})
    public ResponseEntity<Object> handleNotFoundException(NotFoundException ex,
        WebRequest request) {
        // リクエストのBodyのセット
        String path = ((ServletWebRequest) request).getRequest().getRequestURI();
        ApiError apiError = new ApiError(ex.getCode(), ex.getMessage(), path);

        // リクエストヘッダーの作成
        HttpHeaders httpHeaders = new HttpHeaders();

        return handleExceptionInternal(ex, apiError, httpHeaders, HttpStatus.NOT_FOUND, request);
    }

}

DBアクセスして結果が0件だった場合に上記で定義した例外をthrow

Controllerの当該のGETリクエストを処理するメソッドでDBからidをキーにして検索した結果をチェックして、nullだった場合に上記で追加したNotFoundExceptionをthrowするように追記しています。
エラーコードとメッセージはここでは一旦仮な感じでセットしています。
次回の記事で、エラー定義を外部に外出しする予定でいます。

ItemController.java
    @GetMapping("/{id}")
    @ResponseStatus(HttpStatus.OK)
    public ItemResponse findById(@PathVariable int id) {
        // DBからidをキーにデータを取得
        Item item = itemMapper.findById(id);

        // 0件の場合は例外とする
        if (Objects.isNull(item)) {
            throw new NotFoundException(0, "No record found for id.");
        }
        // Responseにデータをコピーしてreturn
        ItemResponse itemResponse = new ItemResponse();
        BeanUtils.copyProperties(item, itemResponse);
        return itemResponse;
    }

業務的なエラーについて、レスポンスを制御する場合の実装方法は以上となります。
ここまで理解できれば、業務エラーと例外クラス、エラー時のAPIレスポンスの設計を行い、それに基づいた実装ができるかと思います。

対応する処理が存在しないパスへアクセスした場合の例外ハンドリング

上記で説明した方法ではハンドリング出来ない例外がありますので、そちらについて説明します。

対応する処理が存在しないパスへアクセスした場合、Spring Bootは静的ファイルへのアクセスとみなして、静的ファイルを検索する仕様になっています。

試しに、localhost:8080/aaaにGETリクエストを発行すると、以下の通りのレスポンスが返ってきます。
サーバーサイドには何もエラーは出力されません。

response body
{
    "timestamp": "2022-11-15T11:19:54.516+00:00",
    "status": 404,
    "error": "Not Found",
    "message": "No message available",
    "path": "/aaa"
}

対応する処理が存在しないパスへアクセスされたときの例外をハンドリングするために、以下の三つの修正を行います。

  • 対応する処理が存在しないパスへアクセスした場合にExceptionを発生するように設定変更
  • 静的ファイルへのマッピングをしないように設定変更
  • handleExceptionInternal()メソッドのオーバーライド

最初の二つを対応するために、application.propertiesに以下を追記します。

application.properties
spring.mvc.throw-exception-if-no-handler-found=true
spring.web.resources.add-mappings=false

三つ目の対応をするために、ResponseEntityExceptionHandlerクラスを継承したクラスに以下の通りオーバーライドします。

    @Override
    protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body,
        HttpHeaders headers, HttpStatus status, WebRequest request) {
        String path = ((ServletWebRequest) request).getRequest().getRequestURI();
        ApiError apiError = new ApiError(0, ex.getMessage(), path);
        return super.handleExceptionInternal(ex, apiError, headers, status, request);
    }

上記の対応を入れて、再度、localhost:8080/aaaにGETリクエストを発行すると、以下の通りのレスポンスが返ってきます。

{
    "code": 0,
    "message": "No handler found for GET /aaa",
    "path": "/aaa",
    "timestamp": "2022-11-15 20:29:16"
}

サーバー側には以下のログが出力されます。

2022-11-15 20:29:16.846  WARN 45152 --- [nio-8080-exec-1] o.s.web.servlet.PageNotFound             : No mapping for GET /aaa
2022-11-15 20:29:16.880  WARN 45152 --- [nio-8080-exec-1] .m.m.a.ExceptionHandlerExceptionResolver : Resolved [org.springframework.web.servlet.NoHandlerFoundException: No handler found for GET /aaa]

今回の記事は以上となります。

Discussion