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