Spring Boot で 404 Not Found などのエラーが発生した際の表示をカスタマイズする

## 概要
- Web アプリケーション全体で発生する 404 Not Found などのエラーについて、Spring Boot での表示内容をカスタマイズする

## 今回の環境
- Spring Boot 2.2.0
- Spring Boot Thymeleaf Starter 2.2.0
- Thymeleaf 3.0.11

## デフォルトでは Whitelabel Error Page や JSON が返される

Web ブラウザでアクセスした際にエラーが発生すると、デフォルトでは以下のような Whitelabel Error Page というのが表示されるようになっている。

```
Whitelabel Error Page

This application has no explicit mapping for /error, so you are seeing this as a fallback.

Wed Nov 06 18:38:41 JST 2019
There was an unexpected error (type=Internal Server Error, status=500).
This is a sample error.
```

[Spring Boot Reference Documentation](https://docs.spring.io/spring-boot/docs/2.2.0.RELEASE/reference/htmlsingle/#boot-features-error-handling)

> For browser clients, there is a “whitelabel” error view that renders the same data in HTML format (to customize it, add a View that resolves to error).

また、curl コマンドなどの通常の Web ブラウザでない機械的なクライアントに対してはエラー、HTTP ステータス、および例外メッセージの詳細を含む JSON レスポンスが生成される。

```json
{"timestamp":"2019-11-06T09:30:58.493+0000","status":500,"error":"Internal Server Error","message":"This is a sample error.","path":"/sample/"}
```

[Spring Boot Reference Documentation](https://docs.spring.io/spring-boot/docs/2.2.0.RELEASE/reference/htmlsingle/#boot-features-error-handling)

> For machine clients, it produces a JSON response with details of the error, the HTTP status, and the exception message.

参考: [Spring Boot の Whitelabel Error Page と JSON レスポンス \- Qiita](https://qiita.com/niwasawa/items/0ea9901b31099b08c631)

## デフォルトで用意されている /error マッピング (error.html)

特に設定をしなくても、Spring Boot はすべてのエラーを適切な方法で処理する /error マッピングを提供している。
これはサーブレットコンテナの「グローバル」エラーページとして登録されている。

[Spring Boot Reference Documentation](https://docs.spring.io/spring-boot/docs/2.2.0.RELEASE/reference/htmlsingle/#boot-features-error-handling)

> By default, Spring Boot provides an /error mapping that handles all errors in a sensible way, and it is registered as a “global” error page in the servlet container.

ビュー名が /error ということになるので、Thymeleaf の場合は src/main/resources/templates/error.html に設置したファイルをエラーページとして扱うことができる。

### error.html

以下のような Thymeleaf 用の HTML テンプレートファイルを用意すれば良い。

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>/error</title>
</head>
<body>
  <div th:text="'timestamp: ' + ${timestamp}"></div>
  <div th:text="'status: ' + ${status}"></div>
  <div th:text="'error: ' + ${error}"></div>
  <div th:text="'exception: ' + ${exception}"></div>
  <div th:text="'message: ' + ${message}"></div>
  <div th:text="'errors: ' + ${errors}"></div>
  <div th:text="'trace: ' + ${trace}"></div>
  <div th:text="'path: ' + ${path}"></div>
</body>
</html>
```

### error.html で出力できる属性

エラーページの中ではいくつかの情報を出力することが可能。
/error マッピングは Spring Boot の DefaultErrorAttributes クラスで実装されていて、API リファレンスには出力できる項目が載っている。

[DefaultErrorAttributes \(Spring Boot Docs 2\.2\.0\.RELEASE API\)](https://docs.spring.io/spring-boot/docs/2.2.0.RELEASE/api/org/springframework/boot/web/servlet/error/DefaultErrorAttributes.html)

> Default implementation of ErrorAttributes. Provides the following attributes when possible:
> 
> ・timestamp - The time that the errors were extracted
> ・status - The status code
> ・error - The error reason
> ・exception - The class name of the root exception (if configured)
> ・message - The exception message
> ・errors - Any ObjectErrors from a BindingResult exception
> ・trace - The exception stack trace
> ・path - The URL path when the exception was raised

### error.html の出力例

500 Internal Server Error の場合。

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>/error</title>
</head>
<body>
  <div>timestamp: Wed Nov 06 18:26:31 JST 2019</div>
  <div>status: 500</div>
  <div>error: Internal Server Error</div>
  <div>exception: null</div>
  <div>message: This is a sample error.</div>
  <div>errors: null</div>
  <div>trace: null</div>
  <div>path: /sample/</div>
</body>
</html>
```

404 Not Found の場合。

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>/error</title>
</head>
<body>
  <div>timestamp: Wed Nov 06 18:25:40 JST 2019</div>
  <div>status: 404</div>
  <div>error: Not Found</div>
  <div>exception: null</div>
  <div>message: No message available</div>
  <div>errors: null</div>
  <div>trace: null</div>
  <div>path: /aaa</div>
</body>
</html>
```

/error にアクセスした場合はステータスコードが 999 になってしまう。

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>/error</title>
</head>
<body>
  <div>timestamp: Wed Nov 06 18:24:30 JST 2019</div>
  <div>status: 999</div>
  <div>error: None</div>
  <div>exception: null</div>
  <div>message: No message available</div>
  <div>errors: null</div>
  <div>trace: null</div>
  <div>path: null</div>
</body>
</html>
```

### error.html の代わりに /error/400.html や /error/4xx.html も使える

Spring Boot の DefaultErrorViewResolver クラスがこのあたりの処理をしている。

[spring\-boot/DefaultErrorViewResolver\.java at v2\.2\.0\.RELEASE · spring\-projects/spring\-boot · GitHub](https://github.com/spring-projects/spring-boot/blob/v2.2.0.RELEASE/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/DefaultErrorViewResolver.java)

```java
  static {
    Map<Series, String> views = new EnumMap<>(Series.class);
    views.put(Series.CLIENT_ERROR, "4xx");
    views.put(Series.SERVER_ERROR, "5xx");
    SERIES_VIEWS = Collections.unmodifiableMap(views);
  }
```

```java
  @Override
  public ModelAndView resolveErrorView(HttpServletRequest request, HttpStatus status, Map<String, Object> model) {
    ModelAndView modelAndView = resolve(String.valueOf(status.value()), model);
    if (modelAndView == null && SERIES_VIEWS.containsKey(status.series())) {
      modelAndView = resolve(SERIES_VIEWS.get(status.series()), model);
    }
    return modelAndView;
  }
```

```java
  private ModelAndView resolve(String viewName, Map<String, Object> model) {
    String errorViewName = "error/" + viewName;
    TemplateAvailabilityProvider provider = this.templateAvailabilityProviders.getProvider(errorViewName,
        this.applicationContext);
    if (provider != null) {
      return new ModelAndView(errorViewName, model);
    }
    return resolveResource(errorViewName, model);
  }
```

[DefaultErrorViewResolver \(Spring Boot Docs 2\.2\.0\.RELEASE API\)](https://docs.spring.io/spring-boot/docs/2.2.0.RELEASE/api/org/springframework/boot/autoconfigure/web/servlet/error/DefaultErrorViewResolver.html)

> Default ErrorViewResolver implementation that attempts to resolve error views using well known conventions. Will search for templates and static assets under '/error' using the status code and the status series.
> For example, an HTTP 404 will search (in the specific order):
> 
> ・'/\<templates\>/error/404.\<ext\>'
> ・'/\<static\>/error/404.html'
> ・'/\<templates\>/error/4xx.\<ext\>'
> ・'/\<static\>/error/4xx.html'

### /error を server.error.path で変更することが可能

/error のエラーページ表示は Spring Boot の BasicErrorController が処理している。

[spring\-boot/BasicErrorController\.java at v2\.2\.0\.RELEASE · spring\-projects/spring\-boot · GitHub](https://github.com/spring-projects/spring-boot/blob/v2.2.0.RELEASE/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/BasicErrorController.java)

```java
@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {
```

設定値 server.error.path=/error となっているが、これを application.properties などで設定することによって変更することができる。
参考: [Common Application properties](https://docs.spring.io/spring-boot/docs/2.2.0.RELEASE/reference/html/appendix-application-properties.html)

## デフォルトの /error マッピングの問題点

/error マッピングに対応する error.html を設置しても /error で 999 エラーになってしまったり、 curl など機械的なクライアントに対しては意図しない JSON レスポンスが返る問題は残ってしまう。

以下では、それらの問題を解決するための方法を方法を示す。

## どのようにエラー処理をカスタマイズするか

デフォルトのエラー処理を置き換える方法としては、ErrorController を実装する方法か、あるいは既存の BasicErrorController などの既存の処理を使用したままで ErrorAttributes を実装して内容を置き換える方法がある。

[Spring Boot Reference Documentation](https://docs.spring.io/spring-boot/docs/2.2.0.RELEASE/reference/htmlsingle/#boot-features-error-handling)

> To replace the default behavior completely, you can implement ErrorController and register a bean definition of that type or add a bean of type ErrorAttributes to use the existing mechanism but replace the contents.

## ErrorController でエラー処理をカスタマイズする

ここでは ErrorController を実装することでエラー処理をカスタマイズする方法を示す。

### ErrorController インターフェース実装クラスを用意

エラーページのビューを決定し、表示する情報をセットするための ErrorController インターフェース実装クラスを用意する。

```java
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.RequestDispatcher;
import javax.servlet.http.HttpServletRequest;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

/**
 * Web アプリケーション全体のエラーコントローラー。
 * エラー情報を HTML や JSON で出力する。
 * ErrorController インターフェースの実装クラス。
 */
@Controller
@RequestMapping("${server.error.path:${error.path:/error}}") // エラーページへのマッピング
public class MyErrorController implements ErrorController {

  /**
   * エラーページのパス。
   */
  @Value("${server.error.path:${error.path:/error}}")
  private String errorPath;

  /**
   * エラーページのパスを返す。
   *
   * @return エラーページのパス
   */
  @Override
  public String getErrorPath() {
    return errorPath;
  }

  /**
   * HTML レスポンス用の ModelAndView オブジェクトを返す。
   *
   * @param request リクエスト情報
   * @return HTML レスポンス用の ModelAndView オブジェクト
   */
  @RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
  public ModelAndView myErrorHtml(HttpServletRequest request) {

    // HTTP ステータスを決める
    // ここでは 404 以外は全部 500 にする
    Object statusCode = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
    HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
    if (statusCode != null && statusCode.toString().equals("404")) {
      status = HttpStatus.NOT_FOUND;
    }

    // 出力したい情報をセットする
    ModelAndView mav = new ModelAndView();
    mav.setStatus(status); // HTTP ステータスをセットする
    mav.setViewName("error"); // error.html
    mav.addObject("timestamp", new Date());
    mav.addObject("status", status.value());
    mav.addObject("path", request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI));

    return mav;
  }

  /**
   * JSON レスポンス用の ResponseEntity オブジェクトを返す。
   *
   * @param request リクエスト情報
   * @return JSON レスポンス用の ResponseEntity オブジェクト
   */
  @RequestMapping
  public ResponseEntity<Map<String, Object>> myErrorJson(HttpServletRequest request) {

    // HTTP ステータスを決める
    // ここでは 404 以外は全部 500 にする
    Object statusCode = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
    HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
    if (statusCode != null && statusCode.toString().equals("404")) {
      status = HttpStatus.NOT_FOUND;
    }

    // 出力したい情報をセットする
    Map<String, Object> body = new HashMap<String, Object>();
    body.put("timestamp", new Date());
    body.put("status", status.value());
    body.put("path", request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI));

    return new ResponseEntity<>(body, status);
  }
}
```

参考:

- [ErrorController \(Spring Boot Docs 2\.2\.0\.RELEASE API\)](https://docs.spring.io/spring-boot/docs/2.2.0.RELEASE/api/org/springframework/boot/web/servlet/error/ErrorController.html)
- [spring\-boot/BasicErrorController\.java at v2\.2\.0\.RELEASE · spring\-projects/spring\-boot · GitHub](https://github.com/spring-projects/spring-boot/blob/v2.2.0.RELEASE/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/BasicErrorController.java)
- [Spring Boot エラーページの最低限のカスタマイズ \(ErrorController インターフェースの実装\) \- Qiita](https://qiita.com/niwasawa/items/52722528c9755f8159a1)

### ビューとなる HTML テンプレートを用意

ビュー名に合わせた HTML テンプレートファイル error.html を用意する。

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>/error</title>
</head>
<body>
  <div th:text="'timestamp: ' + ${timestamp}"></div>
  <div th:text="'status: ' + ${status}"></div>
  <div th:text="'path: ' + ${path}"></div>
</body>
</html>
```

これで、404 Not Found などのエラーが発生した際にはこれらの実装クラスが処理をしてくれるようになる。

## ErrorAttributes と ErrorViewResolver でエラー処理をカスタマイズする

ここでは ErrorAttributes と ErrorViewResolver を実装することでエラー処理をカスタマイズする方法を示す。

### ErrorAttributes インターフェース実装クラスを用意

まずは、レスポンス JSON や ModelAndView にセットする情報を返すための ErrorAttributes インターフェース実装クラスを用意する。

```java
import org.springframework.boot.web.servlet.error.DefaultErrorAttributes;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.WebRequest;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

/**
 * ログに記録したりユーザーに提示するエラー情報へのアクセスを提供する。
 * ErrorAttributes インターフェースを実装するため、DefaultErrorAttributes クラスを継承している。
 */
@Component // DIコンテナに登録する
public class MyErrorAttributes extends DefaultErrorAttributes {

  /**
   * エラー情報を返す。
   * エラー情報は、エラーページ ModelAndView のモデルとして使用するか
   * または @ResponseBody の JSON データとして使用する。
   * @param webRequest リクエスト情報
   * @param includeStackTrace スタックトレースを含める必要がある場合は true
   * @return エラー情報
   */
  @Override
  public Map<String, Object> getErrorAttributes(WebRequest webRequest, boolean includeStackTrace) {

    // エラー情報の Map オブジェクト
    // ここにレスポンス JSON やエラーページに必要な情報を追加していく
    Map<String, Object> attr = new HashMap<String, Object>();

    // timestamp
    attr.put("timestamp", new Date());

    // status
    Object status = webRequest.getAttribute("javax.servlet.error.status_code", RequestAttributes.SCOPE_REQUEST);
    if (status == null) {
      status = 999; // ここでは 999 にしておく
    }
    attr.put("status", status);

    // path
    Object path = webRequest.getAttribute("javax.servlet.error.request_uri", RequestAttributes.SCOPE_REQUEST);
    if (path != null) {
      attr.put("path", path);
    }

    // 独自のエラーメッセージを追加する
    attr.put("myErrorMessage", status);

    return attr;
  }
}
```

参考:

- [ErrorAttributes \(Spring Boot Docs 2\.2\.0\.RELEASE API\)](https://docs.spring.io/spring-boot/docs/2.2.0.RELEASE/api/org/springframework/boot/web/servlet/error/ErrorAttributes.html)
- [spring\-boot/spring\-boot\-project/spring\-boot/src/main/java/org/springframework/boot/web/servlet/error at v2\.2\.0\.RELEASE · spring\-projects/spring\-boot · GitHub](https://github.com/spring-projects/spring-boot/tree/v2.2.0.RELEASE/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/servlet/error)

### ErrorViewResolver インターフェース実装クラスを用意

次に、エラーページのビューを決定し、表示する情報をセットするための ErrorViewResolver インターフェース実装クラスを用意する。

```java
import org.springframework.boot.autoconfigure.web.servlet.error.ErrorViewResolver;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import java.util.Map;

/**
 * エラービューを決める bean クラス。
 */
@Component // DIコンテナに登録する
public class MyErrorViewResolver implements ErrorViewResolver {

  public MyErrorViewResolver() {
    System.out.println("MyErrorViewResolver created");
  }

  /**
   * エラービューを決定する。
   *
   * @param request リクエスト情報
   * @param status  エラーの HTTP ステータス
   * @param model   ビューで使うために提示されたモデル
   * @return 決定した ModelAndView または null
   */
  @Override
  public ModelAndView resolveErrorView(
    HttpServletRequest request,
    HttpStatus status,
    Map<String, Object> model) {

    // モデルとビューの情報を構築
    ModelAndView mav = new ModelAndView();

    // MyErrorAttributes#getErrorAttributes の戻り値をセット
    mav.addAllObjects(model);

    // ビュー名を指定
    mav.setViewName("myerror"); // resources/templates/myerror.html

    // ステータスに応じて処理
    if (status == HttpStatus.NOT_FOUND) {
      // 404 Not Found
      mav.setStatus(HttpStatus.NOT_FOUND);
      mav.addObject("myErrorMessage", "404 Not Found");
    } else {
      // 404 以外は全部 500 にする
      mav.setStatus(HttpStatus.INTERNAL_SERVER_ERROR);
      mav.addObject("myErrorMessage", "500 Internal Server Error");
    }

    return mav;
  }
}
```

参考:

- [ErrorViewResolver \(Spring Boot Docs 2\.2\.0\.RELEASE API\)](https://docs.spring.io/spring-boot/docs/2.2.0.RELEASE/api/org/springframework/boot/autoconfigure/web/servlet/error/ErrorViewResolver.html)
- [spring\-boot/spring\-boot\-project/spring\-boot\-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error at v2\.2\.0\.RELEASE · spring\-projects/spring\-boot · GitHub](https://github.com/spring-projects/spring-boot/tree/v2.2.0.RELEASE/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error)

### ビューとなる HTML テンプレートを用意

ビュー名に合わせた HTML テンプレートファイル myerror.html を用意する。

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>/myerror</title>
</head>
<body>
  <div th:text="'myErrorMessage: ' + ${myErrorMessage}"></div>
  <div th:text="'timestamp: ' + ${timestamp}"></div>
  <div th:text="'status: ' + ${status}"></div>
  <div th:text="'path: ' + ${path}"></div>
</body>
</html>
```

これで、404 Not Found などのエラーが発生した際にはこれらの実装クラスが処理をしてくれるようになる。

## 参考資料

- [Spring Boot Reference Documentation - The “Spring Web MVC Framework” - Error Handling](https://docs.spring.io/spring-boot/docs/2.2.0.RELEASE/reference/htmlsingle/#boot-features-error-handling)
- [SpringBootを使うときに最低限やっておきたいセキュリティ対策 \- Qiita](https://qiita.com/tksmaru/items/8b387196ad312f913a2a)
- [Spring Boot で Boot した後に作る Web アプリケーション基盤/spring\-boot\-application\-infrastructure \- Speaker Deck](https://speakerdeck.com/sinsengumi/spring-boot-application-infrastructure)