Easy to use, fun and asynchronous.
Ktorの思想です。
簡単に言えば、100%Kotlin製のCoroutinesを駆使した超軽量フレームワークです。
背景
最近Kotlinを触り始めた訳ですが、今までJavaをメインに使っていた自分はJavaのwebアプリフレームワークはSpring Boot
しか使ったことがありませんでした。
Spring Boot
はもちろんKotlinもサポートしているので、Kotlin製Spring Boot
を使ってみようと思ったのですが、
Software Design
という雑誌を読んでいたところ、Ktor
という100%Kotlin製webアプリフレームワークが紹介されていたのを目にし、それをきっかけに勉強してみました。
Ktorというフレームワークについての思想は省略しますので、詳しく知りたい方は、Ktor 公式をご覧下さい。
Ktorを使ってみる
Ktorプロジェクトを始めるには色々方法がありますが、今回はIInteliJ IDEA
を使います。
環境
- macOS Mojava v10.14.1
- InteliJ IDEA ULTIMATE 2018.3
- open JDK 11.0.1
- gradle
InteliJ IDEAでNew Project
IDEAにデフォルトでKtor
が搭載されている訳ではないので、まずはプラグインをインストールします。
プラグインをインストール
インストールしたら再起動。
再びIDEAが起動したら、New Projectを選択します。
Ktorの設定
デフォルトでは写真のようになります。
- ビルドツール...gradle
- サーバエンジン...Netty
- Ktor 1.2.0
使用するFeatureが決まっていればここで選択すればいいですが、後から追加することも可能なのでこのまま次に進みます。
Springプロジェクト作成する時同様に、GroupId,ArtifactIdを入力します。適当に。
また同様に、プロジェクト名を入力します。
FINISHを押下します。
ここは自分好みの設定で。OK押すとプロジェクトが作成されgraldeが必要なライブラリ群をインストールして構築完了です。
ディレクトリ構成
初期の状態ではこんな感じです。
.
├── build.gradle
├── gradle
│ └── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── resources
│ ├── application.conf
│ └── logback.xml
├── settings.gradle
└── src
└── Application.kt
実装
メインプロセス起動
mainとなるのは、Application.kt
です。
なお、クラス名は自由に変えてしまって問題ありません。
package com.example
import io.ktor.application.*
import io.ktor.response.*
import io.ktor.request.*
fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)
@Suppress("unused") // Referenced in application.conf
@kotlin.jvm.JvmOverloads
fun Application.module(testing: Boolean = false) {
}
このままプロセス起動はできそうなのですが、いざ起動してみるとエラーが発生します。
2019-06-11 15:09:13.922 [main] INFO Application - No ktor.deployment.watch patterns specified, automatic reload is not active
Exception in thread "main" java.lang.ClassNotFoundException: Module function cannot be found for the fully qualified name 'com.example.ApplicationKt.module'
at io.ktor.server.engine.ApplicationEngineEnvironmentReloading.executeModuleFunction(ApplicationEngineEnvironmentReloading.kt:367)
at io.ktor.server.engine.ApplicationEngineEnvironmentReloading.access$executeModuleFunction(ApplicationEngineEnvironmentReloading.kt:33)
at io.ktor.server.engine.ApplicationEngineEnvironmentReloading$instantiateAndConfigureApplication$1$$special$$inlined$forEach$lambda$1.invoke(ApplicationEngineEnvironmentReloading.kt:287)
at io.ktor.server.engine.ApplicationEngineEnvironmentReloading$instantiateAndConfigureApplication$1$$special$$inlined$forEach$lambda$1.invoke(ApplicationEngineEnvironmentReloading.kt:33)
at io.ktor.server.engine.ApplicationEngineEnvironmentReloading.avoidingDoubleStartupFor(ApplicationEngineEnvironmentReloading.kt:320)
at io.ktor.server.engine.ApplicationEngineEnvironmentReloading.access$avoidingDoubleStartupFor(ApplicationEngineEnvironmentReloading.kt:33)
at io.ktor.server.engine.ApplicationEngineEnvironmentReloading$instantiateAndConfigureApplication$1.invoke(ApplicationEngineEnvironmentReloading.kt:286)
at io.ktor.server.engine.ApplicationEngineEnvironmentReloading$instantiateAndConfigureApplication$1.invoke(ApplicationEngineEnvironmentReloading.kt:33)
at io.ktor.server.engine.ApplicationEngineEnvironmentReloading.avoidingDoubleStartup(ApplicationEngineEnvironmentReloading.kt:302)
at io.ktor.server.engine.ApplicationEngineEnvironmentReloading.instantiateAndConfigureApplication(ApplicationEngineEnvironmentReloading.kt:284)
at io.ktor.server.engine.ApplicationEngineEnvironmentReloading.createApplication(ApplicationEngineEnvironmentReloading.kt:137)
at io.ktor.server.engine.ApplicationEngineEnvironmentReloading.start(ApplicationEngineEnvironmentReloading.kt:257)
at io.ktor.server.netty.NettyApplicationEngine.start(NettyApplicationEngine.kt:116)
at io.ktor.server.netty.NettyApplicationEngine.start(NettyApplicationEngine.kt:22)
at io.ktor.server.engine.ApplicationEngine$DefaultImpls.start$default(ApplicationEngine.kt:56)
at io.ktor.server.netty.EngineMain.main(EngineMain.kt:21)
at com.example.ApplicationKt.main(Application.kt:7)
Ktor 公式を見れば分かるのですが、サーバの起動の仕方が違います。
IDEAのプラグインがサポートできているversionが古いのでしょうか、、?分かりませんが、公式に合わせます。
package com.example
import io.ktor.application.*
import io.ktor.http.HttpStatusCode
import io.ktor.response.*
import io.ktor.request.*
import io.ktor.routing.get
import io.ktor.routing.routing
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
fun main(args: Array<String>) {
val server = embeddedServer(Netty, 8080) {
routing {
get("/") {
call.respond(HttpStatusCode.OK,"Hello, Kotlin")
}
}
}
server.start()
}
これで無事起動できました。
実際にルーティングを設定したエンドポイントにGETリクエストしてみると
$ curl localhost:8080
Hello, Kotlin%
レスポンスが返ってきます。
embeddedServer
はio.ktor.server.engine.EmbeddedServer.kt
に定義されたメソッドです。
この書き方だと、エンジンやポートをコード内にベタ書きしてしまっていてあまりキレイではありませんが、一旦スルーします。。
Controller
上記のようにルーティングを設定できますが、あそこにAPIの数だけ設定を追加していくとかなり肥大化されてしまいます。
一方、Springでは@Controller
がマッピングの役割を担っていてキレイにコンポーネント間の責務を果たしていました。あんな風にルーティングは分けたいところです。
はい、分けましょう。
src
ディレクトリにcontroller
パッケージを作成し、ここではUserController
を作成してみます。
package com.example.controller
import io.ktor.application.call
import io.ktor.response.respondText
import io.ktor.routing.Route
import io.ktor.routing.get
import io.ktor.routing.route
fun Route.userController() {
route("/user") {
get {
call.respondText { "user routing ok" }
}
get("/detail") {
call.respond(HttpStatusCode.OK, "user detail routing ok")
}
}
}
簡単に言うと、route()の引数でエンドポイントを指定し、そこを基準に細かく各APIのルーティング設定ができます。
Springで言うと、クラスレベルとメソッドレベル両方を用いた@RequestMapping
と似ていますね。
次に、mainメソッドを以下のように書き換えます。
package com.example
import io.ktor.application.*
import io.ktor.http.HttpStatusCode
import io.ktor.response.*
import io.ktor.request.*
import io.ktor.routing.get
import io.ktor.routing.routing
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
fun main(args: Array<String>) {
val server = embeddedServer(Netty, 8080) {
routing {
userController()
}
}
server.start()
}
そうして、再度プロセスを再起動すると、
$ curl localhost:8080/user
user routing ok%
$ curl localhost:8080/user/detail
user detail routing ok%
ちゃんとルーティングされてレスポンスが返ってきます。
この他に、/user
だけでなく/course
のようにエンドポイントを設定したければCourseController
などを作成して分けるといいでしょう。(ただの一例です。)
データベース接続
簡単なREST APIを実装できたところで、DB接続をしたいと思います。
KtorでDBにアクセスするとなると、exposed
と言うライブラリを使用するのが一般的だそうで、ここでもこれを使ってみます。
ちなみにexposed
はInteliJ IDEAの開発元であるJetBrainsが提供している純Kotlin製ORMです。
gradleの設定
少し遅れてしまいましたが、デフォルトではbuild.gradle
は以下のようになっています。
buildscript {
repositories {
jcenter()
}
dependencies {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
apply plugin: 'kotlin'
apply plugin: 'application'
group 'example'
version '0.0.1'
mainClassName = "io.ktor.server.netty.EngineMain"
sourceSets {
main.kotlin.srcDirs = main.java.srcDirs = ['src']
test.kotlin.srcDirs = test.java.srcDirs = ['test']
main.resources.srcDirs = ['resources']
test.resources.srcDirs = ['testresources']
}
repositories {
mavenLocal()
jcenter()
}
dependencies {
compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
compile "io.ktor:ktor-server-netty:$ktor_version"
compile "ch.qos.logback:logback-classic:$logback_version"
testCompile "io.ktor:ktor-server-tests:$ktor_version"
}
ktor_version=1.2.0
kotlin.code.style=official
kotlin_version=1.3.31
logback_version=1.2.1
ここに、expoesedを使えるよう依存関係を追加します。
// 上記省略
dependencies {
compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
compile "io.ktor:ktor-server-netty:$ktor_version"
compile "ch.qos.logback:logback-classic:$logback_version"
testCompile "io.ktor:ktor-server-tests:$ktor_version"
// 以下追加
compile "org.jetbrains.exposed:exposed:$exposed_version"
}
2019/06/11現在、exposed
の最新版は0.14.1
なので、それを利用することとします。
ktor_version=1.2.0
kotlin.code.style=official
kotlin_version=1.3.31
logback_version=1.2.1
# 以下追加
exposed_version=0.14.1
※別にバージョンをプロパティファイルに外だししなくても構いません。
DBの種類
GitHubにあるように、exposed
はMySQLやOracleをはじめ、多様なDBをサポートしています。
ですが、ここではDBの用意を省略したいので、Ktor
で標準で利用可能なインメモリDBのH2
を利用します。
H2に接続するには、単純にJDBCを経由するだけです。
package com.example
import com.example.controller.userController
import io.ktor.application.*
import io.ktor.http.HttpStatusCode
import io.ktor.response.*
import io.ktor.request.*
import io.ktor.routing.get
import io.ktor.routing.routing
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import org.jetbrains.exposed.sql.Database
fun main(args: Array<String>) {
// DBへ接続
Database.connect("jdbc:h2:mem:ktor_db;DB_CLOSE_DELAY=-1", "org.h2.Driver")
val server = embeddedServer(Netty, 8080) {
routing {
userController()
}
}
server.start()
}
サーバを起動する前にDBへのコネクションを確立させれば、以後利用可能になります。
DBアクセス方法
上記でDBへの接続はできました。
肝心のDB操作方法ですが、exposed
にはDBを操作する手段として、以下の2つの方法があります。
- SQLをラップしたDSL
- 軽量なDAO(Data Access Object)
どちらも試してみましたが、どちらがどうと言う訳でも甲乙がある訳でもなく、好きな方を使えばいいと思います。
(詳細な使用方法の違い等お分かりの方は教えていただければ幸いです。)
ここでは、DAOを利用することとします。
DAOの作成
dao
パッケージを作成して、そこにUsers
クラスを作成します。
package com.example.dao
import org.jetbrains.exposed.dao.IntIdTable
object Users : IntIdTable() {
val name = varchar("name",20).uniqueIndex()
val age = integer("age")
}
IntIdTable
クラスを継承することで、auto increment
が適用されたint
型のプライマリーキーを持つTBLを定義できます。
プライマリーキー以外のカラムは1つ1つ上記のように定義します。良いのか悪いのか、単純なauto increment
なPKを記述する手間が省けますね。
DAO
はSpringでいうRepository
のようなもので、これとは別にレコードをKotlinの中で扱うためためのmodelを作成する必要があります。
SpringでいうEntity
のようなものです。
Entityの作成
entityパッケージを作成して、その中にUser
クラスを作成します。
package com.example.entity
import com.example.dao.Users
import org.jetbrains.exposed.dao.EntityID
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
class User(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<User>(Users)
var name by Users.name
var age by Users.age
}
ところで、Javaを書いていた癖というか、データアクセス層とドメイン層はパッケージ別としていますが、Kotlinらしいパッケージ構成はどうなるのでしょうか。
Kotlinの性質上、1クラスに両方定義できますが、、趣味で書いている時ってパッケージ構成とかあまり気にしないから分かりませんね。
CRUD
H2はインメモリなDBのため、初期状態としてデータを永続化させるにはファイルに保存するか、アプリ起動時に初期データをinsertする必要があります。
ここではinsertのやり方も兼ねて後者を採用します。
package com.example
import com.example.controller.userController
import com.example.dao.Users
import com.example.entity.User
import io.ktor.application.*
import io.ktor.http.HttpStatusCode
import io.ktor.response.*
import io.ktor.request.*
import io.ktor.routing.get
import io.ktor.routing.routing
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
fun main(args: Array<String>) {
Database.connect("jdbc:h2:mem:ktor_db;DB_CLOSE_DELAY=-1", "org.h2.Driver")
// DBへレコードinsert
transaction {
SchemaUtils.create(Users)
User.new {
name = "swallowtail"
age = 24
}
}
val server = embeddedServer(Netty, 8080) {
routing {
userController()
}
}
server.start()
}
こうすることで、アプリ起動時に投入できます。
/Library/Java/JavaVirtualMachines/jdk-11.0.1.jdk/Contents/Home/bin/java "-javaagent:/Users/riku/Library/Application Support/JetBrains/Toolbox/apps/IDEA-U/ch-0/183.4886.37/IntelliJ IDEA.app/Contents/lib/idea_rt.jar=56743:/Users/riku/Library/Application Support/JetBrains/Toolbox/apps/IDEA-U/ch-0/183.4886.37/IntelliJ IDEA.app/Contents/bin" -Dfile.encoding=UTF-8 -classpath /Users/riku/qiitasample/out/production/classes:/Users/riku/qiitasample/out/production/resources:/Users/riku/.gradle/caches/modules-2/files-2.1/io.ktor/ktor-server-netty/1.2.0/c30ea7f287343d3007a0794dbfeb3f69aad76ca8/ktor-server-netty-1.2.0.jar/Users/riku/.gradle/caches/modules-2/files-2.1/io.netty/netty-common/4.1.24.Final/7eeecd7906543214c3c1c984d275d3c6de10b99d/netty-common-4.1.24.Final.jar com.example.ApplicationKt
2019-06-11 19:28:12.087 [main] DEBUG Exposed - CREATE TABLE IF NOT EXISTS USERS (ID INT AUTO_INCREMENT PRIMARY KEY, "NAME" VARCHAR(20) NOT NULL, AGE INT NOT NULL)
2019-06-11 19:28:12.094 [main] DEBUG Exposed - ALTER TABLE USERS ADD CONSTRAINT USERS_NAME_UNIQUE UNIQUE ("NAME")
2019-06-11 19:28:12.131 [main] DEBUG Exposed - INSERT INTO USERS (AGE, "NAME") VALUES (24, 'swallowtail')
2019-06-11 19:28:12.299 [main] INFO ktor.application - No ktor.deployment.watch patterns specified, automatic reload is not active
2019-06-11 19:28:12.413 [main] INFO ktor.application - Responding at http://0.0.0.0:8080
プロセス起動時のログを見てみると、ちゃんとTBLが作成され、レコードがinsertされていることを確認できます。
Serviceの作成
それでは、上で作ったUserController
を修正します。
/user
へのGETリクエストには、DBのUSER
全レコードを返すようにします。
この時、Controller
から直接データアクセスしても問題はありませんが、Springを使用してきたことからService
層を挟んだ方がしっくりくるのでここでもそうしたいと思います。
package com.example.service
import com.example.entity.User
import org.jetbrains.exposed.sql.transactions.transaction
class UserService {
fun getAllUsers(): List<User> {
var result: List<User> = listOf()
transaction {
result = User.all().toList()
}
return result
}
fun createUser(name: String, age: Int) {
transaction {
User.new {
this.name = name
this.age = age
}
}
}
}
簡単に、Userの作成・取得のメソッドを定義します。
そして、UserController
を以下のようにします。
package com.example.controller
import com.example.msg.CreateUserReqMsg
import com.example.msg.GetUserResMsg
import com.example.service.UserService
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
import io.ktor.request.receive
import io.ktor.response.respond
import io.ktor.routing.Route
import io.ktor.routing.get
import io.ktor.routing.post
import io.ktor.routing.route
fun Route.userController() {
val userService = UserService() // DIしたい
route("/user") {
get {
call.respond(
HttpStatusCode.OK,
userService.getAllUsers().map { user -> GetUserRespMsg(user.name, user.age) })
}
post {
val result = call.receive<CreateUserReqMsg>()
userService.createUser(result.name, result.age)
call.respond(HttpStatusCode.OK)
}
}
これでUserの取得と作成のAPIが作成できました。
更新・削除はまた別の機会に。。
Jackson
JSON
からKotlinで扱うObjectへシリアライズ/デシリアライズするため、Jackson
を使用します。
他に、GSON
というライブラリがありますが、今回は普段から使用しているJackson
を選びます。
package com.example
import com.example.controller.userController
import com.example.dao.Users
import com.example.entity.User
import io.ktor.application.*
import io.ktor.http.HttpStatusCode
import io.ktor.response.*
import io.ktor.request.*
import io.ktor.routing.get
import io.ktor.routing.routing
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
fun main(args: Array<String>) {
Database.connect("jdbc:h2:mem:ktor_db;DB_CLOSE_DELAY=-1", "org.h2.Driver")
// DBへレコードinsert
transaction {
SchemaUtils.create(Users)
User.new {
name = "swallowtail"
age = 24
}
}
val server = embeddedServer(Netty, 8080) {
// jacksonをinstall
install(ContentNegotiation) {
jackson {
}
}
routing {
userController()
}
}
server.start()
}
注意点
entity.User
のListをそのままJSONにシリアライズすると、JsonMappingException
が発生しました。循環的に起きているようです。
そのため、APIレスポンス用のObjectを作成します。
package com.example.msg
data class GetUserRespMsg(val name: String, val age: Int)
同様に、POSTリクエストのボディをデシリアライズするためのObjectを作成します。
package com.example.msg
data class CreateUserReqMsg(val name: String, val age: Int)
これで、改めてリクエストを送ってみます。
$ curl -X POST -H "Content-Type: application/json" -d '{"name":"riku", "age":12}' localhost:8080/user
$ curl localhost:8080/user/
[{"name":"swallowtail","age":24},{"name":"riku","age":12}]%
POSTで作成したユーザもGETで参照できました。
ディレクトリ構成
最後に、最終的なディレクトリ構成は以下のようになります。
.
├── build.gradle
├── gradle
│ └── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── out
├── resources
│ ├── application.conf
│ └── logback.xml
├── settings.gradle
└── src
├── Application.kt
├── controller
│ └── UserController.kt
├── dao
│ └── Users.kt
├── entity
│ └── User.kt
├── msg
│ ├── CreateUserReqMsg.kt
│ └── GetUserRespMsg.kt
└── service
└── UserService.kt
次試したいこと
- DIコンテナ
- Connection pool
- H2 -> MySQLへ置換
*1
Koin
を利用すればDIできるそうです。時間があるときに試したいです。
*2
Hikari CPを利用してコネクションプールの設定が可能です。これもまた今度試したい。
*3
最初はMySQLで実装するはずだったのですが、
val url = "jdbc:mysql://localhost:3306/some_schema"
val drive = "com.mysql.cj.jdbc.Driver"
val user = "root"
val password = "password"
Database.connect(url, drive, user, password)
どうやら上記の設定ではダメなようで、以下のようなエラーが発生しました。
すぐには解決できなかたので一旦諦めました。笑
Caused by: java.sql.SQLException: No suitable driver found for jdbc:mysql//localhost:3306/some_schema
どなたか分かる方ーー
------------追記 2019/08/15------------
上記のMySQLへのコネクションですが、MySQL Driverをgradleの依存関係に追加するだけで解決できました。
コメントしてくださった @ih6109_at_nagaoka さんありがとうございます。
// 上記省略
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "io.ktor:ktor-server-netty:$ktor_version"
implementation "io.ktor:ktor-jackson:$ktor_version"
implementation "ch.qos.logback:logback-classic:$logback_version"
implementation "org.jetbrains.exposed:exposed:$exposed_version"
implementation "com.h2database:h2:$h2_version"
implementation "mysql:mysql-connector-java:$mysql_version" // ここ追加!
testImplementation "io.ktor:ktor-server-tests:$ktor_version"
}
上記のようにdriverを追加して、
package com.example
import com.example.controller.userController
import com.example.dao.Users
import com.example.entity.User
import io.ktor.application.*
import io.ktor.http.HttpStatusCode
import io.ktor.response.*
import io.ktor.request.*
import io.ktor.routing.get
import io.ktor.routing.routing
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
fun main(args: Array<String>) {
// Database.connect("jdbc:h2:mem:ktor_db;DB_CLOSE_DELAY=-1", "org.h2.Driver") // H2への接続
val url = "jdbc:mysql://localhost:3306/some_schema"
val driver = "com.mysql.cj.jdbc.Driver"
val user = "root"
val password = "password"
Database.connect(url, driver, user, password) // MySQLへの接続
// DBへレコードinsert
transaction {
SchemaUtils.create(Users)
User.new {
name = "swallowtail"
age = 24
}
}
val server = embeddedServer(Netty, 8080) {
// jacksonをinstall
install(ContentNegotiation) {
jackson {
}
}
routing {
userController()
}
}
server.start()
}
アプリケーションからDBへの接続処理を上記のように変更すれば、MySQLへのコネクションが可能になりました。