👌

Springの代わりを求めた先に~Ktor + Exposed~

2022/08/26に公開
2

一応これの続き
https://zenn.dev/jy8752/articles/a4bc17f14e1b70

今回の成果物
https://github.com/JY8752/ktor-demo

やりたかったこと

kotlin + Spring bootの組み合わせで普段開発をしていてSpringでない軽めのフレームワークで開発したくなった。その候補としてquarkus, micronaut, Ktorを順番に触ってみてその比較、最終章。

ktorとは

https://ktor.io/

ピュアkotlinの軽量フレームワーク。quarkus, micronautと違って純正kotlin。kotlin好きのためのフレームワーク。coroutineによる非同期クライアントおよびサーバー処理をかける。安心のJetBrainsが開発をしている。起動も早いためサーバーレス、コンテナ環境でも活用できる。quarkus, micronautがクラウドネイティブ時代のマイクロフレームワークとして誕生したように感じるが、ktorは「軽量で非同期でkotlinらしく」みたいな雰囲気を感じる。純正kotlinなので当然対応言語はkotlin。なのでktorをquarkusとmicronautと比べるのは若干ジャンルが違う気がしなくもないけどkotlin使いがSpringに代わる軽量フレームワークを使うなら見たいなジャンルとして比較してます。

セットアップ

IntelliJからプロジェクト作成できると書いてあったのでどんだけ探してもなくてなんでーってなったらUltimate版だけだった。無課金のためGeneratorサイトからプロジェクト作成。一旦、プラグインはなしでそのまま作成。

kotestとORMにはExposedを今回は使用するので以下の依存関係をとりあえず追加。

build.gradle.kts
    //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接続などを実行できるようにしておく。

Application.kt
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を作成する。

application.conf
ktor {
    deployment {
        port = 8080
        port = ${?PORT}
    }
    application {
        modules = [ com.example.ApplicationKt.module ]
    }
}

データベース(Exposed)

https://github.com/JetBrains/Exposed/wiki
Ktorと一緒に使われるものとしてExposedがよく使われているようなので今回はこちらを使用。DomaやJOOQといったものを使ってみることもできたがせっかくKtor使うのでとことんkotlinがいいかなと思いExposedにしました。ちょっと気になったのがKtorはcoroutineが多用されてると思うのでなるべくブロッキングの処理は書かない方がいいのかと思ってノンブロッキングなORMを使用した方がいいのかと思い軽く調べたけどあんまりいい情報が出てこなかった。Exposedに関してはR2DBC対応できないのかなーみたいなisuueはあったがまだ対応中っぽい。

https://github.com/JetBrains/Exposed/issues/456

JOOQがR2DBC対応してそうだったのと、komapperというORMなどもノンブロッキング対応してそうな気配があったけど本題とずれそうだったので一旦Exposedを使用することにした。

https://blog.jooq.org/reactive-sql-with-jooq-3-15-and-r2dbc/

https://www.komapper.org/ja/

マイグレーションに関してはflywayを導入しようと思ったのだけどExposedがDBの作成やテーブルの作成みたいなDDLが実行できるようなのでコードとして書いてみた。

Migration.kt
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内で読み込むことで実行時にテーブルの作成とかしてくれる。

User.kt
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に変換される。

UserRoute.kt
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ブロック内に宣言するだけ。

Routeing.kt
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()を呼び出すことでできることはわかったので以下のようなヘルパー関数を作成した。

TestHelper.kt
package com.example

import org.jetbrains.exposed.sql.transactions.transaction

fun testRollbackScope(test: () -> Unit) {
   transaction {
       test()
       rollback()
   }
}

使い方はこんな感じ

UserTest.kt
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のテストは一応以下のように書いて見たのだけどうまくいかない。

UserRouteTest
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を使う必要がある。
https://kotest.io/docs/extensions/ktor.html

これで実行すると

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")
UserRouteTest
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にパスを設定してあること。

https://zenn.dev/jy8752/articles/593dc0dbeffced#native-imageビルド

https://graalvm.github.io/native-build-tools/0.9.6/graalvm-setup.html

  • build.gradle.ktsに下記追記。
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で起動する。
Application.kt
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を作成し、下記を追記。
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開発の流れ
https://toranoana-lab.hatenablog.com/entry/2021/05/21/180000
https://retheviper.github.io/posts/ktor-first-impression/

application.conf読み込む
https://zenn.dev/someone7140/articles/218f1aeec3acde

公式
https://ktor.io/docs/welcome.html
https://github.com/JetBrains/Exposed/wiki/DataBase-and-DataSource

testApplication
https://zenn.dev/ikatechx/articles/9e5ced9d09d1db

GitHubで編集を提案

Discussion

ponkan1219ponkan1219

これで実行すると
If you expect serialized body, please check that you have installed the corresponding plugin(like ContentNegotiation) and set Content-Type header.
のようなエラーが出るので多分jacsonプラグインのインストールができていない気がする

おそらくですが、サーバー側の plugin を import されているようです。

import io.ktor.server.plugins.contentnegotiation.*

テスト時には client を生成して、その client からリクエストをするので、

以下の依存関係を追加し、

    implementation("io.ktor:ktor-client-content-negotiation:$ktor_version")

client を import すれば良い と思います。

※公式ドキュメントとそこで紹介されているサンプルプロジェクトのコードを参考にしました

https://ktor.io/docs/testing.html#make-request

ぱんだぱんだ

依存関係をclientのパッケージにすることでちゃんと動きました!
コメントありがとうございます😆