RouterFunctionでグローバルにエラーハンドリングして任意のレスポンスを返す

はじめに

@ControllerAdvance@ExceptionHandler,を使ったグローバルなエラーハンドリングはやったことがあったのですが、そういえばRouterFanctionでやったことなかったなと思い試してみたいと思います。

やってみる

ざっくりやること

大きく2つのやることがあります。

  • ハンドルされる ResponseStatusExceptionを継承したExceptionクラスを作成する
  • AbstractErrorWebExceptionHandlerを継承したハンドラークラスを作成し、BeanとしてDIコンテナに登録する

環境

実行環境は以下の通り

$ java --version
openjdk 15 2020-09-15
OpenJDK Runtime Environment (build 15+36-1562)
OpenJDK 64-Bit Server VM (build 15+36-1562, mixed mode, shari

$ mvn --version
Apache Maven 3.6.3
Maven home: /usr/share/maven
Java version: 15, vendor: Oracle Corporation, runtime: /home/somenone/.sdkman/candidates/java/15-open
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-48-generic", arch: "amd64", family: "unix"

プロジェクトはSpring Initializrで作成し、Bootのバージョンは2.3.4.RELEASEです。

ハンドラーを作成してRouterに登録する

まずはExpectionを発生させるハンドラーや諸々を実装します。

@Component
public class ExampleResource {

    public Mono<ServerResponse> throwUnexpectedException(ServerRequest serverRequest) {
        return throwRuntimeException()
                .flatMap(s -> ServerResponse.ok().contentType(MediaType.TEXT_PLAIN).body(s, String.class));
    }

    public Mono<String> throwRuntimeException() {
        return Mono.error(new RuntimeException("something happened"));
    }

}

Routerに登録します。

@SpringBootApplication
public class ErrorHandlingWithRouterFunctionApplication {

    public static void main(String[] args) {
        SpringApplication.run(ErrorHandlingWithRouterFunctionApplication.class, args);
    }

    @Bean
    public RouterFunction<ServerResponse> route(ExampleResource exampleResource) {
        return RouterFunctions
                .route(GET("/unexpected"), exampleResource::throwUnexpectedException);
    }
}

この状態で、/unexpectedに対してリクエストを送ると以下のようなレスポンスが来ます。

$ curl localhost:8080/unexpected
{"timestamp":"2020-10-08T10:39:04.101+00:00","path":"/unexpected","status":500,"error":"Internal Server Error","message":"","requestId":"e06546f4-1"}

これはデフォルトではSpringのDefaultErrorWebExceptionHandlerがいい感じにハンドリングしてくれて値を返してくれているからです。
ちなみにこのハンドラーはAcceptリクエストヘッダーによってはHTMLのホワイトページを返してくれたりもします。
javadocによると以下のディレクトリにステータスコードの名前のHTMLを配置することで任意のエラーページを返すことも可能なようです。
例えば404のステータスコードでは以下の順序で探索が行われるようです。

'/<templates>/error/404.<ext>'
'/<static>/error/404.html'
'/<templates>/error/4xx.<ext>'
'/<static>/error/4xx.html'
'/<templates>/error/error'
'/<static>/error/error.html'

ハンドルされる ResponseStatusExceptionを継承したExceptionクラスを作成する

ハンドラーは作成できたので次にスローする任意のExpeptionクラスを作成します。
このExpectionクラスはResponseStatusExceptionを継承します。これは、特定の HTTP レスポンスステータスコードに関連付けられた例外の基本クラスで、ステータスコード、理由、原因となったException等を持つことができます。
詳細は後述しますが、グローバルでハンドリングする際のエラーの情報を持つErrorAttributesはResponseStatusExceptionで保持する情報をもとに作成されます。
それではInternalServerErrorを表すInternalServerErrorException.javaを作成してみます。

InternalServerErrorExcepiton.java

public class InternalServerErrorException extends ResponseStatusException {
    public InternalServerErrorException(String message) {
        super(HttpStatus.INTERNAL_SERVER_ERROR, message);
    }
}

ここでは、HttpStatus.INTERNAL_SERVER_ERRORは固定値にしておき、messageはExceptionが発生する際に詰め込むようにします。
今回はこのメッセージをクライアント側に返すようにします。
作成したExceptionを投げるように先程のハンドラーの実装を書き換えます。

@Component
public class ExampleResource {

    public Mono<ServerResponse> throwUnexpectedException(ServerRequest serverRequest) {
        return throwRuntimeException()
                .flatMap(s -> ServerResponse.ok().contentType(MediaType.TEXT_PLAIN).body(s, String.class))
                .onErrorResume(RuntimeException.class, e -> Mono.error(new InternalServerErrorException("something happened")));
    }

    public Mono<String> throwRuntimeException() {
        return Mono.error(new RuntimeException("something happened"));
    }
}

throwRuntimeExpception()で投げられる例外を.onErrorResume()でキャッチして作成したInternalServerErrorExceptionに詰め替え再度スローしています。

ここまで書いておいてなんですが、こいつはわざわざ実装しなくてもResponseStatusExceptionを直接使うでも大丈夫だとは思います。

AbstractErrorWebExceptionHandlerを継承したハンドラークラスを作成し、BeanとしてDIコンテナに登録する

メインコンテンツのグローバルなエラーハンドラーを実装していきます。
ハンドラーを実装するためにはAbstractErrorWebExceptionHandler を継承したクラスを作成しgetRoutingFunction(ErrorAttributes errorAttributes)をオーバライドします。

GlobalErrorWebExceptionHandler.java

@Component
@Order(-2)
public class GlobalErrorWebExceptionHandler extends AbstractErrorWebExceptionHandler {

    protected final static Logger logger = LoggerFactory.getLogger(GlobalErrorWebExceptionHandler.class);
    public GlobalErrorWebExceptionHandler(DefaultErrorAttributes g, ApplicationContext applicationContext,
                                          ServerCodecConfigurer serverCodecConfigurer) {
        super(g, new ResourceProperties(), applicationContext);
        super.setMessageWriters(serverCodecConfigurer.getWriters());
        super.setMessageReaders(serverCodecConfigurer.getReaders());
    }


    @Override
    protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) {
        return RouterFunctions.route(RequestPredicates.all(), r -> {
            ErrorAttributeOptions eao = ErrorAttributeOptions.defaults();

            Map<String, Object> ea = getErrorAttributes(r,
                    eao.including(ErrorAttributeOptions.Include.EXCEPTION, ErrorAttributeOptions.Include.MESSAGE)
            );
            logger.warn(ea);
            return renderJsonResponse(ea);
        });
    }

    private int getStatusCode(Map<String, Object> ea) {
        return (int) ea.get("status");
    }

    private Mono<ServerResponse> renderJsonResponse(Map<String, Object> ea) {
        ea.remove("exception");
        return ServerResponse.status(getStatusCode(ea))
                .contentType(MediaType.APPLICATION_JSON)
                .body(BodyInserters.fromValue(ea));
    }
}

GlobalErrorWebExceptionHandlerではDefualtErrorAttributesに格納されたExceptionの情報をもとにログを出力することと、Mono<ServerResponse>を作成することを行っています。
DefualtErrorAttributesでは以下のような情報を持つことができます。

  • timestamp - エラーが抽出された時間
  • status - HTTPのステータスコード
  • error - エラーの理由
  • exception - ルート例外のクラス名 (設定されている場合)
  • message - 例外メッセージ (設定されている場合)
  • errors BindingResult 例外からの ObjectError (設定されている場合)
  • trace - 例外スタックトレース (設定されている場合)
  • path - 例外が発生したときの URL パス
  • requestId - リクエストのID

このなかで、 (設定されている場合)と書かれる4つの項目に関しては、ErrorAttributeOptions.Includeをオプションとして設定してgetErrorAttributesに渡してやることで取得することができます。 また、このDefualtErrorAttributesはErrorAttributesを実装したクラスをDIコンテナに登録し、エラーハンドラーでコンストラクターインジェクションを行ってセットすることでカスタマイズすることもできます。

getErrorAttributesはMap<String, Object>の形で保持する情報を返します。 この際のキーは上記のリストの英語部分のとおりです。

クラスに付与している@Order(-2)はautoconfigクラスであるErrorWebFluxAutoConfiguration@Order(-1)で設定されているためより優先度を高くする必要があるためです。

最後に、renderJsonResponseではDefualtErrorAttributeの情報をもとにMono<ServerResponse>を作成しています。この際に内部の例外の情報を返すことは好ましくないためexceptionはマップから削除しています。

アプリケーションを再起動してcurlでアクセスしてみます。

$ curl localhost:8080/unexpected
{"timestamp":"2020-10-08T12:18:07.761+00:00","path":"/unexpected","status":500,"error":"Internal Server Error","message":"something happened","requestId":"fe9c31ea-2"}y