コンテンツにスキップ

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も持っているような気もがします。今後も気が向いたらブログで発信していこうと思います。


最終更新日: 2022/04/16 01:46