Featured image of post WebFluxのFunctional Enpointに対する小考察

WebFluxのFunctional Enpointに対する小考察

前回、WebFluxではFunctional Endpointを使うべきかというポストを書いたことがありますが、今回はController/ServiceRouter/Handlerのパターン間の比較ではなく、Functional Endpointを使う場合に、どんな形で実装をしていくべきかについて少し考えたことを述べようと思います。

実際の業務でWebFluxを使っているわけではないので、さまざまなパターンがあるかなとは思いますが、このFunctional Endpointを使う場合に考慮すべきものが、Router Function(以下Router)とHandler Function(以下Handler)をどう分けるかについての問題かと思います。RouterHandlerは概念的には別のものではありますが、実装としては一つのクラスにまとめでもアプリは問題なく動くので、フレームワークの仕様や思想というよりかは、アプリのアーキテクチャに関する内容に近いますね。

なので、今回はRouterHandlerを分けた場合と分けない場合について、いくつかの観点から考えてみたいと思います。

RouterとHandlerは分離するべきか

Spring MVCの場合、ControllerServiceを明確に分けるのが常識のようになっています。アーキテクチャとしてもそうですが、フレームワークの思想(デザインの観点)としてもそうですね。

こういう前例があるので、同じくSpring Frameworkに属するWebFluxの場合でも、Functional Endpointという新しい概念を導入するとしても、RouterHandlerを分ける必要があると思いがちかなと思います。一見、Controller ≒ Router, Service ≒ Handlerという対応関係が成立するようにも見えて、ネットで検索できるサンプルコードも多くがそのような構造で書かれています。

しかし、実際のアプリをFunctional Endpointを持って書くとしたら、いくつか考えなければならないことがあると思います。例えば、そもそもRouterHandlerはそれぞれControllerServiceに一対一の対応関係であるという前提は確かであるか?もしそうでなければ、あえてMVCのパターンに合わせる必要があるのか?実装においてはどう影響するのか?などがあるかと思います。なので、今回はこれらの観点からFunctional Endpointについて述べていきます。

対応関係について

Springの公式ドキュメントでは、WebFluxのFunctional Endpointの紹介において以下のようなサンプルコードを提示しています。

val repository: PersonRepository = ...
val handler = PersonHandler(repository)

val route = coRouter { 
    accept(APPLICATION_JSON).nest {
        GET("/person/{id}", handler::getPerson)
        GET("/person", handler::listPeople)
    }
    POST("/person", handler::createPerson)
}

class PersonHandler(private val repository: PersonRepository) {

    // ...

    suspend fun listPeople(request: ServerRequest): ServerResponse {
        // ...
    }

    suspend fun createPerson(request: ServerRequest): ServerResponse {
        // ...
    }

    suspend fun getPerson(request: ServerRequest): ServerResponse {
        // ...
    }
}

公式のサンプルとしてHandlerが別のクラスになっているのを見ると、やはりController ≒ Router, Service ≒ Handlerという対応関係が成立するようにも見えます。@RestController@Serviceと違って、@Router@Handlerというアノテーションは存在しないことに注目する必要があります。これはつまり、Springというフレームワークの思想としてはRouterHandlerを必ず分ける必要はない、ということを意味しているのではないでしょうか。

なので、少なくともアプリケーションのアーキテクチャという観点からしてController ≒ Router, Service ≒ Handlerという対応関係が成立する、という結論を出すのは難しいのではないかと思います。

では、実際RouterHandlerをあえてアノテーションを使ってDIをするとしたら、どうなるのでしょうか。サンプルとしては、以下のような形が一般的かなと思います。

@Configuration
class PersonRouter(private val handler: PersonHandler) {

    @Bean
    fun route(): RouterFunction<ServerResponse> =
        coRouter {
            accept(APPLICATION_JSON).nest {
                GET("/person/{id}", handler::getPerson)
                GET("/person", handler::listPeople)
            }
            POST("/person", handler::createPerson)
        }
}    

@Component
class PersonHandler(private val repository: PersonRepository) {

    // ...

    suspend fun listPeople(request: ServerRequest): ServerResponse {
        // ...
    }

    suspend fun createPerson(request: ServerRequest): ServerResponse {
        // ...
    }

    suspend fun getPerson(request: ServerRequest): ServerResponse {
        // ...
    }
}

クラスそのものを@Componentとして登録する必要があるContollerに対して、RouterFunctionFunctional Interfaceなのでそれを実装したメソッドを@Beanとして登録する必要があります。そしてSpringで@Beanをアプリケーションに登録するのは一般的に@Congifurationが担当するので自然にRouterのアノテーションもそうなります。Handlerは普通に@Componentとして登録することになりますね。

こうなった場合、クラスやその実装を見てRouterHandlerを分離しているのはわかりますが、アノテーションだけだと違和感を感じられますね。実装は簡単なのでそれぞれに対応するアノテーションを作るのが難しいわけでもないようですが、なぜこのような構造になっているのでしょうか。公式のドキュメントでは、以下のような説明があります。

The big difference with annotated controllers is that the application is in charge of request handling from start to finish versus declaring intent through annotations and being called back.

つまり、「アノテーションをつけたContoller」と「Functional Endpoint」の違いは、前者が「アノテーションでコールバックと意図を表す」に対して、後者は「リクエストのハンドリングを開始から終了まで担当する」ということです。プログラミングモデルとしてこのような観点の差があるので、アノテーションがないのは当たり前なのかもしれません。そして結果的に、Controller ≒ Router, Service ≒ Handlerという対応関係は、少なくともプログラミングモデルという観点では当てはならないと考えられます。

責任の分散という側面で

アノテーションの実装を見ると、@Controller@Serviceを分けているのがフレームワークのアーキテクチャや思想によるものであることがより明確になります。それぞれのアノテーションの実装は、以下の通りです。

@Target(value=TYPE)
@Retention(value=RUNTIME)
@Documented
@Component
public @interface Controller

@Target(value=TYPE)
@Retention(value=RUNTIME)
@Documented
@Component
public @interface Service

両方とも実装が同じであるので、極端的にいうとController@Serviceをつけても機能的には同一ということになります。そして@Serviceでは、以下のようなコメントでこのアノテーションが存在する理由をあくまで「デザインパターンに基盤を置いている」ことを明示しています。

Indicates that an annotated class is a “Service”, originally defined by Domain-Driven Design (Evans, 2003) as “an operation offered as an interface that stands alone in the model, with no encapsulated state.” May also indicate that a class is a “Business Service Facade” (in the Core J2EE patterns sense), or something similar. This annotation is a general-purpose stereotype and individual teams may narrow their semantics and use as appropriate.

なので、アプリケーションデザインの観点からするとControllerはリクエストを受信、レスポンスを返す、エンドポイントをServiceにつなぐという義務だけを持ち、Serviceはビジネスロジックを処理する義務を持つと考えられます。同じ観点から考えると、アノテーションはないものの、RouterHandlerもまた同じ義務を持つように書くこともできるでしょう。

ただ、問題は「リクエストのハンドリングを開始から終了まで担当する」という定義です。先程のサンプルコードをよく見ると、HandlerのメソッドはどれもServerRequestを引数として、戻り値はServerResponseになっています。これはつまり、RouterHandlerをあえて別のクラスとして分割するとしても、リクエストとレスポンスまでをHandlerで処理することを意味します。

ここで「Controller/Serviceの場合と同じく、Handlerの引数と戻り値だけを変えて良いのでは?」と考えられます。しかし、それこそフレームワークの思想に反することです。ServerRequestServerResponseのJavaDocでは、以下の通り「ServerRequestServerResponseHandlerFunctionでハンドリングする」ことを明示しています。

/**
 * Represents a server-side HTTP request, as handled by a {@code HandlerFunction}.
 *
 * <p>Access to headers and body is offered by {@link Headers} and
 * {@link #body(BodyExtractor)}, respectively.
 *
 * @author Arjen Poutsma
 * @author Sebastien Deleuze
 * @since 5.0
 */
public interface ServerRequest {

/**
 * Represents a typed server-side HTTP response, as returned
 * by a {@linkplain HandlerFunction handler function} or
 * {@linkplain HandlerFilterFunction filter function}.
 *
 * @author Arjen Poutsma
 * @author Juergen Hoeller
 * @author Sebastien Deleuze
 * @since 5.0
 */
public interface ServerResponse {

以上のことでわかるように、WebFluxではServerRequestServerResponseHandlerFunctionで扱うようにデザインされています。なので、既存のServiceのように、Handlerがビジネスロジック「のみ」を扱うというのはそれが実装として可能かどうか以前の問題になるのです。

ただ、「責任の分散」という観点からして、責任によってクラスを分けるという発想は間違っているわけではないですね。なのでビジネスロジックを担当するクラスをHandlerと分離して運用するケースは考えられますが、必ずしもクラスを分ける基準がRouterHandlerである必要はないのではないかと思われます。

テストの観点で

JavaでJUnitなどを用いてユニットテストを作る場合、テスト自体はユースケース単位で作成しますが、それらのテストはクラス単位でまとめるというケースが多いかなと思います。なので同じ観点でユニットテストを書く場合、RouterHandlerが分けられているとしたら当然ユニットテストもその単位で分けられるでしょう。

ただ、こうする場合の問題は、テスト自体があまり意味を持たなくなる可能性があるということです。まずRouterは単純にエンドポイントとHandlerをつなぐ役割しか持たなくなるので、そのテストも「想定通りのHadlerFunctionを呼び出しているのか」に限るものになります。そしてHandlerの場合、ServerRequestを受信してServerResponseを発するので、テストが非常に難しくなるという問題があります。

なぜServerRequestを受信してServerResponseを発するのが問題になるかというと、ServerRequestのインスタンスを生成するのが難しく、ServerResponseの場合でもレスポンスボディーを抽出するのが難しいからです。なので、WebTestClientで行うことになるかと思いますが、WebTestClientを使う場合はエンドポイントとHTTPメソッドなどを利用して実際のAPIを呼び出すことになるので、結果的にHandlerのテストをするつもりがRouterのテストまでふくむしかないということになります。こうするとクラス単位でテストケースをまとめることが難しいだけでなく、Routerのみのテストも実質的には意味をなくすということになります。

ではどうすればいいか

今まで論じた3つの観点からすると、RouterHandlerは別のクラスにする理由もあまりなく、むしろ別クラスに色々と問題が生じるように見えます。しかし、これが必ずしもエンドポイントに対するルーティングとビジネスロジックを分離する必要はない、ということではないかと思います。先に述べた通り、クラスを分ける基準をRouterHandlerにしないだけで良いかなと思います。例えば、以下のようなコードがあるとします。

@Configuration
class PersonRouter(private val repository: PersonRepository) {

    @Bean
    fun route(): RouterFunction<ServerResponse> =
        coRouter {
            GET("/person/{id}") {
                ServerResponse.ok()
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(
                        repository.findById(it.pathVariable("id").toLong())
                            .map { record ->
                                PersonDto(record.id, record.name)
                            }
                    ).awaitSingle()
                }
        }
}

Handlerで、Bodyを作る箇所以外はビジネスロジックと言えるものがあまりありません。なので、ここではBodyだけを分離して別のクラス(Service)に一任しても良さそうです。例えば以下のようにです。

@Configuration
class PersonRouter(private val service: PersonService) {

    @Bean
    fun route(): RouterFunction<ServerResponse> =
        coRouter {
            GET("/person/{id}") {
                ServerResponse.ok()
                    .contentType(MediaType.APPLICATION_JSON)
                    .body(service.getPersonById(it.pathVariable("id").toLong()))
                    .awaitSingle()
                }
        }
}

@Service
class PersonService(private val repository: PersonRepository) {

    suspend fun getPersonById(id: Long): Mono<PersonDto> =
        repository.findById(id)
                  .map { PersonDto(it.id, it.name) }
}

こうすると、Routerから直接Repositoryにアクセスこともなくなり、今まで挙げていたさまざまな問題も解消できるようになりますね。

最後に

ここで提示した方法でビジネスロジックを分けるのは可能だとして、その方法をとった場合に残る疑問はあります。これは果たしてFunctionalなのか?Functional EndpointLambda-basedと説明されてあるが、Lambdaが使われないので設計意図とは違う形になってないか?そもそもSpring MVCとは違うコンセプトのフレームワークなので既存とは違うアプローチが必要なのでは?などなど。

これらの問題を判断するのはなかなか難しいですが、個人的には新しい技術だからといって常に新しい方法論を適用するということは難しく、既存の良い体系があるのならそれに従うのもそう間違っていることとは思いません。Springの公式ドキュメントでは「すでに問題なく動いているSpring MVCアプリケーションにあえてWebFluxを導入する必要はない(If you have a Spring MVC application that works fine, there is no need to change)」と述べていますが、これと同じく、既存の検証されてあるアーキテクチャがあるのならばそれをそのまま適用するもの悪くないのではと思います。まぁ、そもそもWebFluxを導入するところでMVCパターンを使うとしたらこういうことを気にする理由すら無くなるのですが…むしろこのようなプログラミングモデルが増えていくと今後は新しいアーキテクチャが生まれそうな気もしますね。今回のポストを書きながらはそういういうものを感じました。

では、また!

Built with Hugo
Theme Stack designed by Jimmy