Spring5.0 + KotlinのRouterFunctionのテストはどうすればよいか? 試してみた
引き続きSpring5.0(Spring Boot 2.0) + KotlinのWebアプリケーションを試している。今回はRouterFunction
をつかってHTTPルーティングを定義したときにテストコードはどう書くのか?気になったのでまとめてみた。
Router Function
Router FunctionはSpring5.0から導入されるものでアノテーションベースのHTTPサービスの定義ではなく RouterFunctionDsl
クラスを実装していくことになる。エンドポイントのURLとHTTP Handlerをマッピングを行うことでルーティングが定義できる。
@Configuration class TaskRoutes(private val taskHandler: TaskHandler, private val exceptionFilter: ExceptionFilter) { @Bean fun taskRouter() = router { (accept(APPLICATION_JSON) and "/api").nest { "/task".nest { POST("/", taskHandler::create) GET("/{id}", taskHandler::fetchByTaskId) PUT("/{id}", taskHandler::updateByTaskId) DELETE("/{id}", taskHandler::deleteByTaskId) PUT("/{id}/finish", taskHandler::finishByTaskId) } "/tasks".nest { GET("/", taskHandler::fetchAll) } } }.filter(exceptionFilter()) }
次からはRouter Functionで定義されたHTTPサービスのテストコードを書いていきたい。
MockMvcではなくWebTestClient をつかう
SpringのコントローラテストコードはMockMvcを用いて書くことが多かったがRouter Functionでは WebTestClient
をつかう。
上記の taskRouter
はタスクエンティティを扱うコントローラでありルーティングも固有のBeanで定義している。また TaskHandler
はDBアクセスやgRPC サーバなどロジックが絡むのでモック化していきたい。そのような場合にはWebTestClientに用意されている bindToRouterFunction
を定義する。
@RunWith(SpringRunner::class) class TaskRoutesTest { lateinit var client : WebTestClient lateinit var taskHandler: TaskHandler lateinit var exceptionFilter: ExceptionFilter val mapper = ObjectMapper().registerModule(KotlinModule()) @Before fun before() { taskHandler = mock(TaskHandler::class) exceptionFilter = ExceptionFilter() val taskRoutes = TaskRoutes(taskHandler, exceptionFilter) client = WebTestClient.bindToRouterFunction(taskRoutes.taskRouter()).build() } ・・・ }
TaskHandler
をモック化する(taskHandler変数を定義)- taskHandler変数とexceptionFilter変数から
TaskRoutes
のコンストラクタを呼び出しRoutesオブジェクトを生成する(taskRoutes変数を定義) - taskRoutes変数を
bindToRouterFunction
に渡しWebTestClientオブジェクトを生成する
以上をbeforeに定義してテスト実行の準備を整える
@Testを定義する
タスクを1件取得するエンドポイントURLのテストコードは次のようになった。
@Test fun `GET Task`() { // mock `when`(taskHandler.fetchByTaskId(any())).thenReturn(ok().json().body(Mono.just(mockModel))) client.get().uri("/api/task/1") .accept(MediaType.APPLICATION_JSON_UTF8) .exchange() .expectStatus().isOk .expectBody() .consumeAsStringWith { val actual = mapper.readValue<TaskModel>(it, TaskModel::class) actual.id shouldBe 1L actual.title shouldBe "task title" actual.createdAt shouldBe "2017-06-13T16:22:52Z" actual.updatedAt shouldBe "2017-06-13T16:22:52Z" } }
taskHandler.fetchByTaskId
をモック化して返すオブジェクトを定義しているexchange()
でResponseSpec
オブジェクトが返ってくるのでHTTPステータスやレスポンスボディを取得しアサーションする
Spring REST Docsは使えるの?
Test DrivenにAPIドキュメントを生成するSpring Rest Docs
はMockMVCベースなので今のところは対応していない。
対応はしていないが対応はする予定である。issueから確認できるがREST Docs 2.0で対応予定のようなので期待したい。
同じくAPIドキュメントをアノテーションベースで生成できる便利なSpring Foxもサポート予定であることをissueから観測した。
Spring 5 support · Issue #1773 · springfox/springfox · GitHub
個人的にはエンドポイント設計時からアノテーションを付与すればAPI仕様書がつくれるSpring Foxのほうが好みである。だがアノテーションベースで定義する手法が、HTTPルーティングをアノテーションベースで定義しないRouter Functionにどのようにマッピングされるのだろうか。サポート結果が楽しみである。
まとめ
- RouterFunctionをつかった場合でもテストコードはモックも交えることができるし問題無さそうで導入に前向きになれた。
- APIドキュメントのライブラリのサポート体制状況も観測できたので、Spring5.0のリリースが待ち遠しい。
コード
コードを書きながら調べたのでgithubと合わせて確認いただけます。
テストコードはこちらです。 spring5-kotlin-application/TaskRoutesTest.kt at master · nsoushi/spring5-kotlin-application · GitHub
参考にしたエントリ
Spring5.0 + KotlinではDoma、Request Interceptorあたりはどうなっているのか調べてみた
Spring5.0のリリースが迫るなか、プロジェクトへ導入に向けて色々と調べている。インタセプターなどのSpring Frameworkにおける作法はどうなっているか、便利に使えていたライブラリとの相性はどうなのか、などをアウトプットしていく。
次の2つのはてな?
を調べてみた。
- DBアクセスにはDomaを使いたい。Spring Transactionalの
@Transactional
も合わせて使える? HandlerInterceptorAdapter
や@ControllerAdvice
などAOPがRouterFunctionで使いたいけど、どうすれば?
それでは1つずつ整理していく。
Domaとの相性
Spring Boot 1.5系とDomaの相性は良くSpring Transactionalの@Transactional
も問題なく使えている。
@Transactional
の使い勝手は良くアノテーションを付けたメソッド無いでRuntimeExceptionが発生すればロールバックしてくれる。
@Transactional override fun invoke(command: CreateTaskCommand): Task { return taskRepository.create(command.title).fold({ task -> task }, { error -> throw handle(error) }) }
com.mysql.jdbc.ReplicationDriver
のドライバを使い @Transactional(readOnly = true)
をつければスレーブに対してDBアクセスが行われる。
DomaのKotlinサポートの対応方法をベースにSpringBoot 2.0で試してみたところ、問題なく使えた!
Domaとの相性は問題ないと結果が得られたのでDBアクセスには引き続きDomaを使っていく。
動いているコードはgithubにあるので合わせて参照ください。
RouterFunctionでリクエストやExceptionを良しなに処理したい
注意!FilterFunctionを用いたエラーハンドリングは正しくありません。Exceptionをエラーハンドリングしたい場合は WebExceptionHandler
を使います。
詳細は次のエントリにまとまっています。
HTTPリクエストをハンドリングするアプリケーションの関心事として次のようなものがある。
- HTTPリクエストをインターセプトして共通の処理を入れたい
- エラーレスポンスの扱いを共通化したい
これらの関心事をSpring5.0から導入されるRouterFunctionでは、どのように解決すれば良いか。
Spring4系であればHTTPリクエストのインターセプトにはHandlerInterceptorAdapter
を実装したクラスを用意すれば良かった。サービス層で発生したエクセプションに応じてエラーレスポンスを定義するクラスを@ControllerAdvice
のアノテーションをつけたクラスを用意すれば良かった。
RouterFunctionでは、FilterFunction
をつかって2つの関心事を解決できる。
コードとしては次のようになる。
@Configuration class TaskRoutes(private val taskHandler: TaskHandler, private val exceptionFilter: ExceptionFilter) { @Bean fun taskRouter() = router { (accept(APPLICATION_JSON) and "/api").nest { "/task".nest { POST("/", taskHandler::create) GET("/{id}", taskHandler::fetchByTaskId) PUT("/{id}", taskHandler::updateByTaskId) DELETE("/{id}", taskHandler::deleteByTaskId) PUT("/{id}/finish", taskHandler::finishByTaskId) } "/tasks".nest { GET("/", taskHandler::fetchAll) } } }.filter(exceptionFilter()) } @Component class ExceptionFilter { private val logger = KotlinLogging.logger {} operator fun invoke(): (request: ServerRequest, next: HandlerFunction<ServerResponse>) -> Mono<ServerResponse> = { request, next -> try { next.handle(request) } catch (e: Exception) { when (e) { is SystemException -> status(e.status).json().body(Mono.just(ErrorItem(e.message ?: "web application error", e.status.value().toString(), null))) else -> { logger.error(e) { "unknown exception: %s".format(e.message ?: "unknown error") } status(HttpStatus.INTERNAL_SERVER_ERROR).json().body(Mono.just(ErrorItem(e.message ?: "internal server error", null, null))) } } } } }
.filter(exceptionFilter())
の箇所でFilter Functionを定義している。
コードの例ではExceptionFilter
クラスでは各APIで発生したExceptionをフィルタリングしている。Exceptionが発生した場合はエラーレスポンスを返すようにしている。これで例外処理をハンドリングできるインタセプターをRouterFunctionに追加できる。
Spring4系で HandlerInterceptorAdapter
と@ControllerAdvice
で分かれていたところがFilterFunction
に集約された格好である。
コード
コードを書きながら調べたのでgithubと合わせて確認いただけます。
まとめ
- Domaとの相性が問題ないことやHTTPハンドリングの関心事もRouterFunctionでも解決できることが分かった。
- 並行してSpringFoxとの相性も調べている。APIドキュメントの快適さもSpring4系を使う恩恵があったのでSpring5.0からも問題なく使えることを期待している。
Spring5.0 + Kotlinで1つのjarにHTTPサーバーとgRPCサーバーを相乗りさせてみた
Spring Boot 2.0.0 M1がリリースされました。以前のエントリで試した当時は 2.0.0.BUILD-SNAPSHOT
でありHTTPサーバーが起動している状態でgRPCクライアントを動かすとエラーになっていた。
2.0.0 M1のリリースに伴いHTTPサーバーとgRPCサーバーが1つのjarに相乗りできるようになっているか確認するのが今回のモチベーション。
次のようなアプリケーション構成を実現したい。
API Server
はHTTP1.1のリクエストのルーティング
とgRPCサーバー
のエンドポイントを提供する- API Serverに届いたリクエストは
Backend Server向け
のgRPCクライアント
からBackend Serverにリクエストする - API ServerとBackend Server間の通信はgRPCで行う
- エンドポイントには
HTTP1.1のリクエストのルーティング
とgRPCサーバー
の2つの通信方式を用意する - すべてのアプリケーションはSpring Boot 2.0.0 M1の上で動く
- Introducing Kotlin support in Spring Framework 5.0のエントリにあるようなSpring5.0で提供される新しい機能をつかう
ここからは試した過程での気づきなどをアウトプットしていく。
CommandLineRunnerでgRPCサーバーを起動する
HTTPルーティングが動いている状態で CommandLineRunner
をつかいgRPCサーバーを次のように起動させた。
@Configuration class GrpcServerRunner(private val appProperties: AppProperties, private val echoServer: EchoServer, private val taskServer: TaskServer) : CommandLineRunner, DisposableBean { private val logger = KotlinLogging.logger {} lateinit var server: Server override fun run(args: Array<String>) { val port = appProperties.grpc.server.port logger.info { "Starting gRPC Server ..." } val serverBuilder = NettyServerBuilder.forPort(port) serverBuilder.addService(echoServer) serverBuilder.addService(taskServer) server = serverBuilder.build().start() logger.info {"gRPC Server started, listening on port $port."} startDaemonAwaitThread() } }
Spring Boot 2.0.0 M1
で試したところ問題なく起動した!(2.0.0.BUILD-SNAPSHOT
ではエラーになっていたところ)
2.0.0 M1では300以上のissueやプルリクエストがマージされたので、その中のどれかで解消されたということだろう。
grpc-javaの 1.3.0
はエラーになる
Spring Bootには直接関係はないところであるが最新のgrpc-javaの1.3.0を使うとエラーになった。
java.lang.NoClassDefFoundError: io/netty/handler/codec/http2/internal/hpack/Decoder at io.grpc.netty.GrpcHttp2HeadersDecoder.<init>(GrpcHttp2HeadersDecoder.java:85) ~[grpc-netty-1.2.0.jar:1.2.0] at io.grpc.netty.GrpcHttp2HeadersDecoder$GrpcHttp2ServerHeadersDecoder.<init>(GrpcHttp2HeadersDecoder.java:135) ~[grpc-netty-1.2.0.jar:1.2.0] at io.grpc.netty.NettyServerHandler.newHandler(NettyServerHandler.java:109) ~[grpc-netty-1.2.0.jar:1.2.0] at io.grpc.netty.NettyServerTransport.createHandler(NettyServerTransport.java:132) ~[grpc-netty-1.2.0.jar:1.2.0] at io.grpc.netty.NettyServerTransport.start(NettyServerTransport.java:77) ~[grpc-netty-1.2.0.jar:1.2.0] at io.grpc.netty.NettyServer$1.initChannel(NettyServer.java:141) ~[grpc-netty-1.2.0.jar:1.2.0] ・・・
Netty 4.1.9
ではio.netty.handler.codec.http2.internal.hpack.Decoder
が削除されているのが原因。
gradleは次のようにgrpc-netty
のみ1.4.0-SNAPSHOT
のバージョンを指定することで解消できる。
buildscript { ext.grpc_version = "1.3.0" ext.grpc_version_snapshot = "1.4.0-SNAPSHOT" repositories { mavenCentral() maven { url "https://repo.spring.io/milestone" } maven { url 'http://repo.spring.io/plugins-release' } maven { url "https://oss.sonatype.org/content/repositories/snapshots" } } } ・・・ dependencies { ・・・ // grpc compile "io.grpc:grpc-netty:${grpc_version_snapshot}" compile "io.grpc:grpc-protobuf:${grpc_version}" compile "io.grpc:grpc-stub:${grpc_version}" compile "io.grpc:grpc-okhttp:${grpc_version}" compile "com.google.api.grpc:googleapis-common-protos:0.0.3" ・・・ }
BackendサーバーにはHTTPサーバーがいらない
BackendサーバーにはHTTPサーバーがいらないので次のように@SpringBootApplication
がついたメインクラスの起動でWebアプリケーションを動かさないようにした。
@SpringBootApplication @EnableConfigurationProperties(AppProperties::class) class Application { companion object { @JvmStatic fun main(args: Array<String>) { SpringApplicationBuilder(Application::class).web(WebApplicationType.NONE).run(*args) } } }
コード
今回試したコードはgithubに置いてあるので参考になれば嬉しい。
まとめ
- 理想の構成どおりにアプリケーションが組めた
- Spring5.0のkotlinサポートは魅力的であり実戦投入を検討したかったがgRPCとの相性が良くない印象であったが検証を経て問題ないことが確認できた
- いよいよSpring5.0の正式リリースに向けて整ってきている印象を受けた
- その他で利用しているSpringエコシステムの各種ライブラリとのSpring5.0/Spring Boot 2.0.0の相性が問題ないことを引き続き確認していき導入を検討していきたい