Introduction
Kotlin勉強シリーズ。今回はktorの続編。
前回までのKotlinシリーズはこちら。
- KotlinとGradleとSpekとIntelliJIdeaで環境構築
- KotlinでReactorのハンズオン書いてみた
- KotlinでProtocol Buffersを使ってみた
- Kotlinでlettuceを使ってみた
- Kotlinでktorをちょろっと使ってみた
今回は、簡単なTo-DoリストのRESTな感じのアプリケーションを作成しました。
内容は、黒ベコ本にあるものを参考にしました。
バージョン
本記事作成に用いたOS、言語、ミドルウェア等のバージョンは以下の通りです。
なお、JUnitを5.3.1にするとSpekがうまく動作しませんでした。
種類 | 名前 | バージョン |
---|---|---|
OS | Ubuntu | 18.04(Bionic Beaver) |
開発環境 | Intellij IDEA | ULTIMATE 2018.2 |
開発言語 | Kotlin | 1.2.71 |
JVM | OpenJDK | 10.0.2 |
ビルド | Gradle | 4.10.2 |
テスト環境1 | JUnit | 5.2 |
テスト環境2 | Spek | 1.2.1 |
依存ライブラリ | Ktor | 0.9.5 |
依存ライブラリ | jackson | 2.9.6 |
依存ライブラリ | logback | 1.2.3 |
環境設定
Gradle各種設定
ビルドに使用したGradle用の設定です。
gradle.properties
環境構築の記事の際に作成したものと同じです。
org.gradle.jvmargs=-Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStorePassword=changeit -Dorg.gradle.daemon=false
settings.gradle
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
// Kotlin
maven {
url { 'https://dl.bintray.com/kotlin/kotlin-dev' }
}
}
}
rootProject.name = 'todoserver1'
build.gradle
Ktorを使用するためのgradleです。
gradle runでアプリを実行するためのApplicationプラグインと、その際のクラス名としてio.ktor.server.netty.DevelopmentEngineを指定しています。
また、ktorでは、Kotlinのexperimentalなcoroutine機能を使用しているので、それを有効にしています(dependencyとkotlin.experimental.coroutines設定)。
buildscript {
ext.versions = [
'kotlin': '1.2.71',
'kotlin_coroutines': '0.26.1',
'junit': '5.2.0',
'junit_platform': '1.2.0',
'spek': '1.2.1',
'ktor': '0.9.5',
'jackson': '2.9.6',
'logback': '1.2.3'
]
}
plugins {
id 'org.jetbrains.kotlin.jvm' version '1.2.71'
// Application
id 'application'
}
group 'jp.spock.grandala'
version '1.0-SNAPSHOT'
repositories {
mavenCentral()
// Spek
maven {
url { 'https://dl.bintray.com/spekframework/spek' }
}
// Ktor
maven {
url { 'https://dl.bintray.com/kotlin/ktor' }
}
maven {
url { 'https://repo.spring.io/plugins-release' }
}
}
dependencies {
// Kotlin
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
// JUnit5
testImplementation "org.jetbrains.kotlin:kotlin-test-junit5:${versions.kotlin}"
testImplementation "org.junit.jupiter:junit-jupiter-api:${versions.junit}"
testRuntimeOnly "org.junit.platform:junit-platform-launcher:${versions.junit_platform}"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${versions.junit}"
// Spek
testImplementation "org.jetbrains.spek:spek-api:${versions.spek}"
implementation "org.jetbrains.kotlin:kotlin-reflect:${versions.kotlin}"
testRuntimeOnly "org.jetbrains.spek:spek-junit-platform-engine:${versions.spek}"
// Ktor
implementation "io.ktor:ktor-server-netty:${versions.ktor}"
implementation "ch.qos.logback:logback-classic:${versions.logback}"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:${versions.kotlin_coroutines}"
implementation "io.ktor:ktor-jackson:${versions.ktor}"
testImplementation "io.ktor:ktor-server-test-host:${versions.ktor}"
// Jackson
implementation "com.fasterxml.jackson.core:jackson-annotations:${versions.jackson}"
implementation "com.fasterxml.jackson.module:jackson-module-kotlin:${versions.jackson}"
}
compileKotlin {
kotlinOptions.jvmTarget = "1.8"
}
compileTestKotlin {
kotlinOptions.jvmTarget = "1.8"
}
kotlin {
experimental { coroutines 'enable' }
}
test {
useJUnitPlatform()
}
mainClassName = 'io.ktor.server.netty.DevelopmentEngine'
ソース
ツリー
今回作成したソースは以下の通りです。
src/
main/
kotlin/
todoserver/
controller/
TaskController.kt
model/
Task.kt
Application.kt
resources/
application.conf
test/
kotlin/
todoserver/
controller/
TaskControllerSpec.kt
ApplicationSpec.kt
リソース
アプリケーションのエントリ、Ktorサーバのポートなどの設定ファイルを作成します。
エントリポイントのクラス名は、Kotlinクラスの真名である"Kt"が最後についているものを指定します。
ktor.deployment.port = 8080
ktor.application.modules = [todoserver.ApplicationKt.main]
実行ソース
エントリ
起動時のエントリポイントは、io.ktor.application.Application
クラスにメソッドを追加する形で行いました。
エントリ内では、To-Doタスクのコントローラの作成、json入出力機能の導入、ルーティングの設定を行っています。
package todoserver
import com.fasterxml.jackson.databind.SerializationFeature
import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.ContentNegotiation
import io.ktor.http.HttpStatusCode
import io.ktor.jackson.jackson
import io.ktor.request.receive
import io.ktor.response.respond
import io.ktor.routing.*
import todoserver.controller.TaskController
import todoserver.model.PostTask
import todoserver.model.Task
fun Application.main() {
// TO-DOタスクのコントローラ
val taskController = TaskController()
install(ContentNegotiation) {
// Json用変換器の導入
jackson {
enable(SerializationFeature.INDENT_OUTPUT) // インデント出力機能付きで。
}
}
// ルーティング
routing {
route("/tasks") {
get("{id}") { // tasks/(数値)
val t: Task? = call.parameters["id"]?.let {id ->
taskController.get(id.toLong())
}
call.respond(t ?: HttpStatusCode.NotFound)
}
post {
val post = call.receive<PostTask>()
taskController.add(post.content, post.done)
call.respond(HttpStatusCode.OK)
}
put("{id}") {
val put = call.receive<PostTask>()
val id = call.parameters["id"]?.toLong()
if (id != null) {
taskController.update(id, put.content, put.done)
}
call.respond(HttpStatusCode.OK)
}
delete("{id}") {
call.parameters["id"]?.let {id ->
taskController.remove(id.toLong())
}
call.respond(HttpStatusCode.OK)
}
get("list") {
call.respond(taskController.list())
}
}
}
}
モデル
To-Doリストのタスクのデータモデルと、通信用のモデルです。
通信用モデルは、複数のTaskを収納できるようにして、同時に複数のデータ登録などができるようにするべきなんでしょうが、今回はテスト用ということで単発にしています。
package todoserver.model
data class Task(val id: Long, val content: String, val done: Boolean)
data class PostTask(val content: String, val done: Boolean)
コントローラ
データのCRUDを行うためのクラスです。黒ベコ本の例を参考にしています。
package todoserver.controller
import todoserver.model.Task
class TaskController {
var taskList = mutableListOf(
Task(1, "宣教師を派遣する", false),
Task(2, "同盟都市を増やす", true)
)
private fun nextId(): Long =
taskList.fold(0L) {
maxTaskId, task ->
when {
maxTaskId < task.id -> task.id
else -> maxTaskId
}
} + 1L
fun list() = taskList
fun add(content: String, done: Boolean) {
val task = Task(nextId(), content, done)
taskList.add(task)
}
fun update(id: Long, content: String, done: Boolean) {
remove(id)
taskList.add(Task(id, content, done))
}
fun remove(id: Long) =
taskList.removeIf {
it.id == id
}
fun get(id: Long): Task? =
taskList.find {
it.id == id
}
}
テストソース
TaskControllerのテスト
TaskControllerのテスト用クラスです。
だいぶ手を抜いています(カバレージ? なんのことやら)。
package todoserver.controller
import org.jetbrains.spek.api.Spek
import org.jetbrains.spek.api.dsl.describe
import org.jetbrains.spek.api.dsl.it
import org.jetbrains.spek.api.dsl.on
import org.junit.jupiter.api.Assertions.assertEquals
import todoserver.model.Task
object TaskControllerSpec: Spek({
describe("Task controller") {
on("CRUD") {
it("has default values") {
val tc = TaskController()
assertEquals(tc.list().size, 2)
}
it("can be added") {
val tc = TaskController()
tc.add("hogehoge", false)
assertEquals(Task(3L, "hogehoge", false), tc.get(3L))
assertEquals(tc.list().size, 3)
}
it("can be updated") {
val tc = TaskController()
tc.update(2L, "fugafuga", false)
assertEquals(Task(2L, "fugafuga", false), tc.get(2L))
}
it("can be removed") {
val tc = TaskController()
tc.remove(1L)
tc.remove(2L)
assertEquals(tc.list().size, 0)
}
}
}
})
Applicationのテスト
Applicationクラスのテストクラスです。
実際にサーバを立ち上げなくても、テストモジュールを使えばテストできます。便利ですね。
なお、こちらもテストパターンがまったく網羅していませんが、動作確認用なのでこの程度でご勘弁を。
package todoserver
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import io.ktor.application.Application
import io.ktor.application.ApplicationCallPipeline
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.handleRequest
import io.ktor.server.testing.setBody
import io.ktor.server.testing.withTestApplication
import org.apache.http.entity.ContentType
import org.jetbrains.spek.api.Spek
import org.jetbrains.spek.api.dsl.describe
import org.jetbrains.spek.api.dsl.it
import org.jetbrains.spek.api.dsl.on
import org.junit.jupiter.api.Assertions.assertEquals
import todoserver.controller.TaskController
import todoserver.model.PostTask
import todoserver.model.Task
fun Application.testableModule() {
main()
intercept(ApplicationCallPipeline.Call) {
// intercept something
}
}
object ApplicationSpec: Spek({
val mapper = jacksonObjectMapper()
describe("Application") {
on("routines and responses") {
withTestApplication({testableModule()}) {
it("returns default list for GET list") {
with(handleRequest(HttpMethod.Get, "/tasks/list")) {
assertEquals(HttpStatusCode.OK, response.status())
val content = response.content ?: ""
val task = mapper.readValue<Array<Task>>(content)
assertEquals(task.size, 2)
assertEquals(task[0].id, 1)
assertEquals(task[1].content, "同盟都市を増やす")
}
}
it("is ok for POST") {
with(handleRequest(HttpMethod.Post, "/tasks") {
addHeader(HttpHeaders.ContentType, ContentType.APPLICATION_JSON.toString())
val post = PostTask("hoghog", false) // id=3
val content = mapper.writeValueAsString(post)
setBody(content)
}) {
assertEquals(HttpStatusCode.OK, response.status())
}
}
it("is ok for PUT") {
with(handleRequest(HttpMethod.Put, "/tasks/3") {
addHeader(HttpHeaders.ContentType, ContentType.APPLICATION_JSON.toString())
val post = PostTask("hoghog", false) // id=3
val content = mapper.writeValueAsString(post)
setBody(content)
}) {
assertEquals(HttpStatusCode.OK, response.status())
}
}
it("returns previous task for GET") {
with(handleRequest(HttpMethod.Get, "/tasks/3")) {
assertEquals(HttpStatusCode.OK, response.status())
val content = response.content ?: ""
val task = mapper.readValue<Task>(content)
assertEquals(task.id, 3L)
assertEquals(task.content, "hoghog")
}
}
it("delete previous task for DELETE") {
with(handleRequest(HttpMethod.Delete, "/tasks/3")) {
assertEquals(HttpStatusCode.OK, response.status())
}
}
it("returns no tasks after DELETE action") {
with(handleRequest(HttpMethod.Get, "/tasks/3")) {
assertEquals(HttpStatusCode.NotFound, response.status())
}
}
}
}
}
})
実行
Run
IntelliJ IDEAのRun/Debug Configurationsで、Gradle設定を追加し、Gradle projectにこのプロジェクト、Tasksにrunを指定します。
この設定を実行した後、ブラウザで"http://localhost:8080/tasks/list"などとアクセスすると、デフォルトタスク一覧が表示されます。
Test
今度はテスト用の設定を追加します。Runと同様にGradle設定を追加した後、Tasksにtestを指定します。
この設定を実行すると、Spekテストが実行されます。(上記のRunを実行したままだとポート使用中エラーが出るので注意)