Spring Web MVC のエラーハンドリングのメモ

Spring Web MVC のエラーハンドリング方法について調べたのでメモ

調べたバージョンは
- Spring Boot 3.2.1
- Spring Web MVC 6.1.2

例外をハンドリングできるところはいろいろあるかもしれないですが
- `@ExceptionHandler` (Controller, ControllerAdviceに付与)
- `HandlerExceptionResolver`
- `ErrorController`

についてメモを残します。

## HandlerExceptionResolver
説明の都合上先にこれから説明します。

`HandlerExceptionResolver` インターフェースを実装したクラスをBean定義をしておくことで例外のハンドリングを行うことができます。

`resolveException` を実装し、解決できた場合は`ModelAndView`を返し、未解決の場合は`null`を返す実装を行います。

定義したBeanは [DispatcherServletのhandlerExceptionResolversフィールド](https://github.com/spring-projects/spring-framework/blob/v6.1.2/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java#L336C41-L336C66) で `List<HandlerExceptionResolver>` として保持されます。

[initHandlerExceptionResolvers](https://github.com/spring-projects/spring-framework/blob/v6.1.2/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java#L686)メソッドで取得が行われます。
この時 `AnnotationAwareOrderComparator` によりソートが行われるので`Ordered`インターフェースもしくは`@Order`アノテーションを付与することで優先順位をつけることができます。
何も優先順位をつけていない場合は 数値が最大(つまり最後)となります。

Spring Bootで動かした場合デフォルトでは `DefaultErrorAttributes` と `HandlerExceptionResolverComposite` が存在しています。

`DefaultErrorAttributes` は例外のハンドリングは行わず、後述の`ErrorController` で利用できるように例外のインスタンスを退避します。

`HandlerExceptionResolverComposite` は内部に`HandlerExceptionResolver`の一覧を持っており後述の`@ExceptionHandler` をハンドリングしている `ExceptionHandlerExceptionResolver` などが存在します。

例外のハンドリングが行われるのは[DispatcherServletのprocessHandlerException](https://github.com/spring-projects/spring-framework/blob/v6.1.2/spring-webmvc/src/main/java/org/springframework/web/servlet/DispatcherServlet.java#L1356)メソッドでハンドリングが行われます。

### 意識しておいたほうがいいこと
なるべくOrderedをつけておいた方がいいかなと思います。

`HandlerExceptionResolverComposite` の [DefaultHandlerExceptionResolver](https://github.com/spring-projects/spring-framework/blob/v6.1.2/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/support/DefaultHandlerExceptionResolver.java#L178) が一部の例外を解決するため、自作の`HandlerExceptionResolver`がこれよりも後の優先順位だと、すでに解決済みであるためresolveExceptionが呼ばれない場合があります。

他の注意点としてはハンドリング箇所を見れば明らかですが、サーブレットフィルターなどで例外が発生した場合はハンドリングが行われません。

## ExceptionHandlerアノテーション

`Controller` や `@ControllerAdvice` のクラスに`@ExceptionHandler` にハンドリング対象の例外を指定し、以下のような実装をすることで例外のハンドリングを行うことができます。
また未解決の場合はnullを返すようにします。

```java
@ExceptionHandler({ MyException.class })
public Map<String, Object> errorHandling(Exception e) {
  return Map.of(
      
      "message", "例外です",

      "exceptionClass", e.getClass().getName(),

      "exceptionMessage", e.getMessage());
}
```

例外が起きた場合は[ExceptionHandlerExceptionResolverのgetExceptionHandlerMethod](https://github.com/spring-projects/spring-framework/blob/v6.1.2/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ExceptionHandlerExceptionResolver.java#L476)メソッドで対象のメソッドの特定が行われます。前半部分でControllerに付与したものからの特定、後半部分でControllerAdviceに付与したものからの特定が行われます。
その後対象メソッドの実行が行われます。

ExceptionHandlerExceptionResolverはHandlerExceptionResolverの実装クラスなので 同様にDispatcherServletのprocessHandlerExceptionから呼び出されます。

こちらも同様にHandlerExceptionResolverの仕組みを使っているためサーブレットフィルターなどで発生した例外はハンドリングできません。

## ErrorController
Spring Bootで追加された仕組みです。

例外や`sendError`が発生したときに
warで起動しているときはErrorPageFilter、jarでTomcatで起動している時はStandardHostValveによって`/error` へリクエストがフォワードされます。

デフォルトだと[BasicErrorController](https://github.com/spring-projects/spring-boot/blob/v3.2.1/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/BasicErrorController.java)が使用されますが `ErrorController`の実装クラスをBean定義することで上書きが可能です。

実装方法の詳細は `BasicErrorController` を参考で `ErrorAttributes`などから情報を取得しレスポンスを返します。

### 意識しておいたほうがいいこと
`ErrorPageFilter`や `StandardHostValve` は一連の処理の手前の方で適用されるためサーブレットフィルターで発生した例外などもハンドリングすることが可能です。

`/error` への折り返しの際、`ErrorController`へ到達するまでに サーブレットフィルターや`HandlerInterceptor` を通過するため、場合によっては 以下のような適応対象外の対応が必要になるかもしれません。

(例えば`HandlerInterceptor`のなかで 何かのチェックを行っており、例外をスローしている場合などは `/error` でも同様のチェックが実行されて 再度例外がスローされてしまう可能性があります。)

```java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		//↓/errorの時は 適用しない
		registry.addInterceptor(new MyHandlerInterceptor()).excludePathPatterns("/error/**");
	}
}
```

また `/error` に折り返されているため `HttpServletRequest` などからパスを取得した際も元々のリクエストではなく `/error` となっているため注意が必要です。

元々のリクエストの情報を退避しておきたい場合は `DefaultErrorAttributes` を継承したクラスを作って退避しておくとよいかもしれないですね

## まとめ
上記のポイントがよく使う箇所になるかなと思います。

それぞれ注意すべきポイントがあるので、それらを意識しながら使うとよいかなと思います。