Spring WebFluxのWebFilterでHandlerInterceptorっぽいことをできるようにする
問題
Spring MVCに慣れている人は、あるアノテーションが付与されているコントローラメソッドに対して共通の処理をするという機能を HandlerInterceptor を使って実装したことがあると思います。
加えて、request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE)
といったようにハンドラ(コントローラメソッド)の情報をAttributeから参照してナニカをしたこともあるかもしれません。(簡単な例が思いつかなかったのでボカしました)
@Target(AnnotationTarget.TYPE, AnnotationTarget.FUNCTION)
private annotation class DemoAnnotation
@RestController
class DemoController {
@GetMapping("/user/{userId}")
@DemoAnnotation
fun getUser(@PathVariable userId: String): User {
// ...
}
}
@Configuration(proxyBeanMethods = false)
class DemoConfiguration : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(DemoHandlerInterceptor())
}
}
class DemoHandlerInterceptor : HandlerInterceptor {
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
if (handler !is HandlerMethod) return true
val method = handler.method
AnnotationUtils.findAnnotation(method, DemoAnnotation::class.java) ?: return true
log.info("なにかの処理")
log.info("pathVariables: {}", request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE))
return true
}
}
ところがWebFluxにはHandlerInterceptorに相当する処理はないため、こういったことをスッと真似して実装することはできません。
代替品としてWebFluxには WebFilter というものがあります。しかしこれは、どちらかというとServletFilterに近い機能のため、上述したようなパターンを実現することはできません。
なぜ実現できないのか
Spring MVCにてControllerとServletFilterとHandlerInterceptorを使用する場合、以下のような順番で処理されていきます。 URLをもとにハンドラ(コントローラメソッド)が決定したあとにHandlerInterceptorが実行されるため、ハンドラの情報(アノテーションの有無)を使った共通処理をすることができるわけです。まさにHandlerInterceptorという名前の通りですね。
stateDiagram-v2
[*] --> ServletFilterの実行
ServletFilterの実行 --> URLをもとにハンドラ(コントローラメソッド)が決定
URLをもとにハンドラ(コントローラメソッド)が決定 --> HandlerInterceptor(preHandle)の実行
HandlerInterceptor(preHandle)の実行 --> ハンドラ(コントローラメソッド)の実行
一方、Spring WebFluxにてControllerとWebFilterを使用する場合、以下のような順番で処理されていきます。 WebFilterはURLをもとにハンドラが決定する前に実行されてしまうため、当然ハンドラの情報を参照することはできません。
stateDiagram-v2
[*] --> WebFilterの実行
WebFilterの実行 --> URLをもとにハンドラ(コントローラメソッド)が決定
URLをもとにハンドラ(コントローラメソッド)が決定 --> ハンドラ(コントローラメソッド)の実行
先程WebFilterがServletFilterに近い機能と書きましたが、こういう理由からです。
解決方法: HandlerAwareWebFilter
この問題を解決するために、次のようなHandlerAwareWebFilterという抽象クラスを用意します。
abstract class HandlerAwareWebFilter(
private val handlerMapping: HandlerMapping
) : WebFilter {
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
return Mono
// attrにあるならそれを使う
.justOrEmpty(exchange.attributes[ATTR_KEY])
// ない場合は、handlerMappingから探す
.switchIfEmpty {
handlerMapping.getHandler(exchange)
// ない場合は、NO_HANDLERにfallback
.switchIfEmpty(Mono.just(NO_HANDLER))
// attrに保存 (= cache)
.map {
exchange.attributes[ATTR_KEY] = it
it
}
}
.flatMap {
if (it == NO_HANDLER) {
filter(exchange, chain, null)
} else {
filter(exchange, chain, it)
}
}
}
abstract fun filter(exchange: ServerWebExchange, chain: WebFilterChain, handler: Any?): Mono<Void>
companion object {
private val ATTR_KEY = HandlerAwareWebFilter::class.java.name
/**
* 空オブジェクト
*/
private val NO_HANDLER = object {}
}
}
簡単にいうと、WebFilter内でHandlerMappingからハンドラを取得するだけです。HandlerAwareWebFilterを複数回使ったときに毎回ハンドラを探索するのは無駄なので、attributeに保存しキャッシュも行っています。
これを使うと冒頭の例と同じようなコードを実現することができます。
@Target(AnnotationTarget.TYPE, AnnotationTarget.FUNCTION)
private annotation class DemoAnnotation
// コルーチンスタイルの例
@RestController
class DemoController {
@GetMapping("/user/{userId}")
@DemoAnnotation
suspend fun getUser(@PathVariable userId: String): String {
return "Hello $userId"
}
}
@Configuration(proxyBeanMethods = false)
class DemoConfiguration {
@Bean
fun demoWebFilter(
requestMappingHandlerMapping: RequestMappingHandlerMapping,
): DemoWebFilter {
return DemoWebFilter(requestMappingHandlerMapping)
}
}
class DemoWebFilter(
handlerMapping: HandlerMapping
) : HandlerAwareWebFilter(handlerMapping) {
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain, handler: Any?): Mono<Void> {
if (handler !is HandlerMethod) return chain.filter(exchange)
val method = handler.method
AnnotationUtils.findAnnotation(method, DemoAnnotation::class.java) ?: return chain.filter(exchange)
log.info("なにかの処理")
log.info(
"pathVariables: {}",
exchange.getAttribute<Map<String, String>>(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE)
)
return chain.filter(exchange)
}
}
おわり
WebFluxを登場してからもう4,5年たちますが、まだまだ国内では情報が少なく、少々寂しい状況です。
僕は登場してからプロダクション環境で使いつづけているため、ちょっとだけTipsも持っているような気もがします。今後も気が向いたらブログで発信していこうと思います。