Springの代わりを求めた先に~Ktor + Exposed~
一応これの続き
今回の成果物
やりたかったこと
kotlin + Spring bootの組み合わせで普段開発をしていてSpringでない軽めのフレームワークで開発したくなった。その候補としてquarkus, micronaut, Ktorを順番に触ってみてその比較、最終章。
ktorとは
ピュアkotlinの軽量フレームワーク。quarkus, micronautと違って純正kotlin。kotlin好きのためのフレームワーク。coroutineによる非同期クライアントおよびサーバー処理をかける。安心のJetBrainsが開発をしている。起動も早いためサーバーレス、コンテナ環境でも活用できる。quarkus, micronautがクラウドネイティブ時代のマイクロフレームワークとして誕生したように感じるが、ktorは「軽量で非同期でkotlinらしく」みたいな雰囲気を感じる。純正kotlinなので当然対応言語はkotlin。なのでktorをquarkusとmicronautと比べるのは若干ジャンルが違う気がしなくもないけどkotlin使いがSpringに代わる軽量フレームワークを使うなら見たいなジャンルとして比較してます。
セットアップ
IntelliJからプロジェクト作成できると書いてあったのでどんだけ探してもなくてなんでーってなったらUltimate版だけだった。無課金のためGeneratorサイトからプロジェクト作成。一旦、プラグインはなしでそのまま作成。
kotestとORMにはExposedを今回は使用するので以下の依存関係をとりあえず追加。
//kotest
val kotest_version: String by project
val kotest_assertions_ktor_version: String by project
testImplementation("io.kotest:kotest-runner-junit5-jvm:$kotest_version")
testImplementation("io.kotest.extensions:kotest-assertions-ktor:$kotest_assertions_ktor_version")
//Exposed
val exposedVersion: String by project
implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-jodatime:$exposedVersion")
implementation("mysql:mysql-connector-java:8.0.30")
//json
implementation("io.ktor:ktor-server-content-negotiation:$ktor_version")
implementation("io.ktor:ktor-serialization-jackson:$ktor_version")
Application.kt
エントリーポイントを修正してプラグインの読み込みやルーティングの設定、後述するDB接続などを実行できるようにしておく。
package com.example
import com.example.data.Migration
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import com.example.routes.configureRouting
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.SerializationFeature
import io.ktor.serialization.jackson.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import java.text.DateFormat
fun Application.module() {
//APIのリクエスト、レスポンスのJSONをクラスにマッピングするために
install(ContentNegotiation) {
jackson {
enable(SerializationFeature.INDENT_OUTPUT)
disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL)
dateFormat = DateFormat.getDateInstance()
}
}
//ルーティング
configureRouting()
//DB接続
Migration()
}
fun main(args: Array<String>) {
embeddedServer(Netty, commandLineEnvironment(args)).start(wait = true)
}
main/resources配下にapplication.confを作成する。
ktor {
deployment {
port = 8080
port = ${?PORT}
}
application {
modules = [ com.example.ApplicationKt.module ]
}
}
データベース(Exposed)
Ktorと一緒に使われるものとしてExposedがよく使われているようなので今回はこちらを使用。DomaやJOOQといったものを使ってみることもできたがせっかくKtor使うのでとことんkotlinがいいかなと思いExposedにしました。ちょっと気になったのがKtorはcoroutineが多用されてると思うのでなるべくブロッキングの処理は書かない方がいいのかと思ってノンブロッキングなORMを使用した方がいいのかと思い軽く調べたけどあんまりいい情報が出てこなかった。Exposedに関してはR2DBC対応できないのかなーみたいなisuueはあったがまだ対応中っぽい。
JOOQがR2DBC対応してそうだったのと、komapperというORMなどもノンブロッキング対応してそうな気配があったけど本題とずれそうだったので一旦Exposedを使用することにした。
マイグレーションに関してはflywayを導入しようと思ったのだけどExposedがDBの作成やテーブルの作成みたいなDDLが実行できるようなのでコードとして書いてみた。
package com.example.data
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.Schema
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
class Migration {
companion object {
val database = Database.connect("jdbc:mysql://localhost:3306/demo", driver = "com.mysql.cj.jdbc.Driver",
user = "root", password = "root")
val schema = Schema("demo").also {
transaction {
SchemaUtils.createSchema(it)
}
}
val tables = arrayOf(
UserTable
).also {
transaction {
SchemaUtils.create(*it)
}
}
}
}
これをApplication.module内で読み込むことで実行時にテーブルの作成とかしてくれる。
object UserTable : IntIdTable("user") {
val name: Column<String> = varchar("name", 50)
}
object UserRepository {
fun create(name: String) = transaction {
UserTable.insertAndGetId { it[this.name] = name }.value
}
fun find(id: Int) = transaction {
UserTable.select { UserTable.id eq id }.singleOrNull()?.let {
User(it[UserTable.id].value, it[UserTable.name])
}
}
}
UserテーブルのEntityとRepository定義。KtorはデフォルトではDIの機能がないのでテーブル定義はobjectにIntIdTableを継承させている。(companion以外で初めてobject使った...)テーブルはSQLのように書けるので明確だなと感じた。Spring DataとかだとSQLとEntityでうまくマッピングできないみたいなのよくあるからミス減りそう。
Repositoryもクラスにする意味が思いつかなかったのでobjectにした。transactionブロック内に書く。SQLライクで何してるかわかりやすい。
ルーティング(Controller)
Routeの拡張関数として機能ごとに切り分けて管理すると管理しやすそう。routeブロック内にエンドポイントを書いていくだけ。ServiceもRepositoryと同じようにobjectで定義している。Jacsonのプラグインをインストールしているのでクラスやmapを指定するだけでいい感じにJSONに変換される。
package com.example.routes
import com.example.domain.service.UserService
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Route.userRoute() {
route("/user") {
post {
val request = call.receive<CreateUserRequest>()
val id = UserService.create(request.name)
call.respond(mapOf("id" to id))
}
get("/{id}") {
val id = call.parameters["id"]?.let { it.toInt() } ?: run {
return@get call.respond(HttpStatusCode.BadRequest, "IDが指定されていません")
}
val user = UserService.find(id) ?: run {
return@get call.respond(HttpStatusCode.NotFound, "ユーザーが存在しません id: $id")
}
call.respond(user)
}
}
}
data class CreateUserRequest(val name: String)
各ルートを束ねるメインルート。上記のuserRoteをroutingブロック内に宣言するだけ。
package com.example.routes
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Application.configureRouting() {
// Starting point for a Ktor app:
routing {
get("/") {
call.respondText("Hello World!")
}
}
routing {
userRoute()
}
}
このconfigurationRoutingはApplication.module()内で宣言してあるので、これで一応一通り動くはず。
テスト
RepositoryのテストでSpringなどでは@Transactionalをつけてテスト後にロールバックできたので、同じようにしたかったのだけど、いいやり方が調べても出てこずとりあえずtransactionブロック内でrollback()を呼び出すことでできることはわかったので以下のようなヘルパー関数を作成した。
package com.example
import org.jetbrains.exposed.sql.transactions.transaction
fun testRollbackScope(test: () -> Unit) {
transaction {
test()
rollback()
}
}
使い方はこんな感じ
package com.example.data
import com.example.domain.model.User
import com.example.testRollbackScope
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
internal class UserTest : StringSpec({
beforeSpec { Migration() }
"test" {
testRollbackScope {
val id = UserRepository.create("test")
val user = UserRepository.find(id)
user shouldNotBe null
user shouldBe User(id, "test")
}
}
})
- メイン処理同様、テスト開始前にMigrationを宣言しておく。
- 先ほど作ったtestRollBackScopeブロック内にテストを書く。
サービスのテストも同様で書ける。
Routingのテストは一応以下のように書いて見たのだけどうまくいかない。
package com.example.routes
import com.example.data.Migration
import io.kotest.core.spec.style.StringSpec
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.jackson.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.testing.*
internal class UserRouteTest : StringSpec({
beforeSpec { Migration() }
"test" {
testApplication {
install(ContentNegotiation) {
jackson { }
}
val created = client.post("/user") {
install(ContentNegotiation) {
jackson { }
}
contentType(ContentType.Application.Json)
setBody(CreateUserRequest("user"))
}
}
}
})
とりあえず、最新のKtor(2.0.3)だとwithTestApplicationが非推奨になっているのでtestApplicationを使う必要がある。
これで実行すると
If you expect serialized body, please check that you have installed the corresponding plugin(like `ContentNegotiation`) and set `Content-Type` header.
のようなエラーが出るので多分jacsonプラグインのインストールができていない気がするのだけど何回やってもダメだったので今回は諦めます、できたらまた記事書きます。(Ktor有識者の方でわかる方いましたらコメントください!)
依存関係の追加
testImplementation("io.ktor:ktor-client-content-negotiation:$ktor_version")
package com.example.routes
import com.example.data.Migration
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.jackson.*
import io.ktor.server.testing.*
internal class UserRouteTest : StringSpec({
beforeSpec { Migration() }
"test" {
testApplication {
val client = createClient {
install(ContentNegotiation) {
jackson()
}
}
val created = client.post("/user") {
contentType(ContentType.Application.Json)
setBody(CreateUserRequest("user"))
}
created.status shouldBe HttpStatusCode.OK
val body = created.bodyAsText()
println(body)
}
}
})
おまけ(nativeビルド)
quarkus, micronautで一応native imageビルドを試したのでKtorでもできるか検証してみた。一応Ktor1.6でGraalVMはサポートされているよう。ただ、上記で作成したものをそのままnativeCompileしたところエラーが発生し、コンパイルできなかったのでサンプルコードで試してみた。(依存関係などによってそのままコンパイルできないのかもしれないが検証はまたの機会で)
- GraalVMをインストールし、$GRAALVM_HOMEにパスを設定してあること。
- build.gradle.ktsに下記追記。
plugins {
application
kotlin("jvm") version "1.7.10"
+ id("org.graalvm.buildtools.native") version "0.9.11"
}
dependencies {
implementation("ch.qos.logback:logback-classic:1.2.11")
implementation("io.ktor:ktor-server-core-jvm:2.1.0")
//NettyがサポートされていないのでCIOにする必要がある
implementation("io.ktor:ktor-server-cio-jvm:2.1.0")
}
+graalvmNative {
+ binaries {
+ named("main") {
+ fallback.set(false)
+ verbose.set(true)
+
+ buildArgs.add("--initialize-at-build-time=io.ktor,kotlin")
+
+ buildArgs.add("-H:+InstallExitHandlers")
+ buildArgs.add("-H:+ReportUnsupportedElementsAtRuntime")
+ buildArgs.add("-H:+ReportExceptionStackTraces")
+
+ imageName.set("graal-server")
+ }
+ }
+}
- Application.ktのembeddedServerはNettyでなくCIOで起動する。
fun main() {
embeddedServer(CIO, port = 8080, host = "0.0.0.0") {
configureRouting()
}.start(wait = true)
}
- src/main/resouces/META-INF/native-image配下にreflect-config.jsonを作成し、下記を追記。
[
{
"name": "kotlin.reflect.jvm.internal.ReflectionFactoryImpl",
"allDeclaredConstructors":true
},
{
"name": "kotlin.KotlinVersion",
"allPublicMethods": true,
"allDeclaredFields":true,
"allDeclaredMethods":true,
"allDeclaredConstructors":true
},
{
"name": "kotlin.KotlinVersion[]"
},
{
"name": "kotlin.KotlinVersion$Companion"
},
{
"name": "kotlin.KotlinVersion$Companion[]"
},
{
"name": "kotlin.internal.jdk8.JDK8PlatformImplementations",
"allPublicMethods": true,
"allDeclaredFields":true,
"allDeclaredMethods":true,
"allDeclaredConstructors":true
}
]
ビルド
./gradlew nativeCompile
実行(一瞬で起動)
build/native/nativeCompile/graal-server
09:33:03.969 [DefaultDispatcher-worker-1] INFO ktor.application - Autoreload is disabled because the development mode is off.
09:33:03.970 [DefaultDispatcher-worker-1] INFO ktor.application - Application started in 0.001 seconds.
09:33:03.970 [DefaultDispatcher-worker-5] INFO ktor.application - Responding at http://0.0.0.0:8080
まとめ
- やっぱりKtorはquarkusとmicronautと比べるとだいぶ雰囲気変わる
- 意識した訳ではないけどなんとなくKtor使ってるとkotlinっぽい(kotlinっぽいって結局何)書き方になってくる気がする
- Ktor + Exposedの組み合わせにするともうだいぶ違う、言語変わるレベルで変わった気がする
- native対応が目的ならquarkus,micronautの方が安全な気がする
最終的な比較と感想
(あくまで個人的な感想です。)
quarkus | micronaut | Ktor | |
---|---|---|---|
CLI | ◯ | ◎(initがまじでいい) | × |
学習コスト | 普通 | 普通 | 少し高そう |
native対応 | k8sでの使用を推してる | サーバーレス、コンテナ環境での利用を想定 | できはするけど他の2つのがいい |
kotlinサポート | あまり積極的なサポートはない | 問題なく使える | 純正kotlinなので間違いない |
対応ORM | panache, 他サードパーティー製のORMたち | micronaut data, 他サードパーティー製のORMたち | Exposed, 他サードパーティー製のORMたち |
開発体験 | panacheをactive recordパターンで使えば楽しいかも | あんまりSpringと変わらない | 楽しい(kotlin書いてる感) |
micronautのCLIからプロジェクト作成が本当によくできてたし、kotlinも普通に対応してるし、kotestもSpockも使えるのがまじで予想外に良かった。ただ、あとはSpringと雰囲気はあんまり変わらないのでやっぱりKtorの方がkotlin書いてる感あって楽しいというか、普段書かなそうなコード書けそうで勉強になる。
プロジェクトでの採用もしているところはあるようだし、もうSpringを使い続けなくてもいいんじゃないかなーという気はしている。Javaで大規模開発してるっていうのでもコンテナ化やマイクロサービスみたいな話は進んでいくだろうし、機能的にも十分充実しているしmicronautかquarkusとか導入進んでもいい気がする。Springはやっぱり十分に枯れていて導入するには安心感がやばいけどあんまり開発体験が向上していかない気がする。
web系でkotlin使ってるなら、なおさらKtorとかmicronautの導入進んでもいいんじゃないだろうか。今までのエコシステムとか資産があるならあれだけど、特になんとなくSpring使ってるなら他のフレームワークも候補に入れてもいいんじゃないかと思う
とりあえず、quarkusもmicronautもKtorも触っていて楽しかった
個人的に使っていくならこれからはKtorを触っていこうかと思う
さっくりなんか作るならmicronautでもいいかもしれない(課金すれば話は変わるけどJetBrainsさんすみません、まだ無課金で行きます)
以上、優勝はKtorでした!!
参考
基本的なKtorを使用したAPI開発の流れ
application.conf読み込む
公式
testApplication
Discussion
おそらくですが、サーバー側の plugin を import されているようです。
テスト時には client を生成して、その client からリクエストをするので、
以下の依存関係を追加し、
client を import すれば良い と思います。
※公式ドキュメントとそこで紹介されているサンプルプロジェクトのコードを参考にしました
依存関係をclientのパッケージにすることでちゃんと動きました!
コメントありがとうございます😆