サーバサイドKotlinといえばSpring Bootを採用することが多いと思います。
個人的にはSpring Bootは、Auto Configurationのブラックボックス感が辛くて、もっとシンプルなフレームワークに乗り換えたいという思いが常々ありました。

JetBrains社製のWebフレームワークであるKtorが、2018年11月にv1.0.0がリリースされ、2020年4月現在v1.3.2まで順調にアップデートされ続けており、そろそろ本格的に利用しても良さそうな気配を感じています。

この記事では、Kotlin+KtorでREST APIの作成に必要な技術要素をStep By Stepで検証してみながら、クリーンアーキテクチャなAPIサーバを構築してみたいと思います。

最初の画面を表示

Ktor Pluginを使う方法だと良く設定内容を理解せずに使ってしまうことになりそうですし、Dockerを使う方法だとHotReloadができなさそうなので、今回はGradleプロジェクトを作る方法を試して行こうと思います。

IntelliJでGradleプロジェクト作成

まず、Gradleプロジェクトでkotlinを選択します。

Gradleプロジェクトのオプションを好みで設定します。

  • Use auto-importはパッケージの自動同期をしてくれるオプションです
  • Create separate module per source setはsrcとtestを別moduleとして出力して、依存ライブラリも別管理にするオプションです
  • JVMのバージョンはJava >=9 support ISSUEがOPENなので、java8にしておくのが無難そうです。

作成を進めFinishするとプロジェクトが作成されます。

$ tree
.
├── build.gradle
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
└── settings.gradle

2 directories, 7 files

build.gradleにKtorのGradleセットアップ用の内容で置き換え

デフォルトで生成されたbuild.gradleの内容をこちらの内容で書き換えます。これによりktor-server-nettyなどの必要なライブラリがインストールされます。

group 'rinoguchi'
version '1.0-SNAPSHOT'

buildscript {
    ext.kotlin_version = '1.3.70'
    ext.ktor_version = '1.3.1' // mavenCentralに存在するversionを指定する

    repositories {
        mavenCentral()
    }
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

apply plugin: 'java'
apply plugin: 'kotlin'
apply plugin: 'application'

mainClassName = 'MainKt'

sourceCompatibility = 1.8
compileKotlin { kotlinOptions.jvmTarget = "1.8" }
compileTestKotlin { kotlinOptions.jvmTarget = "1.8" }

repositories {
    mavenCentral()
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
    implementation "io.ktor:ktor-server-netty:$ktor_version"
    implementation "ch.qos.logback:logback-classic:1.2.3"
    testImplementation group: 'junit', name: 'junit', version: '4.12'
}

main関数を持つApplication.ktを作成

package sample

import io.ktor.application.*
import io.ktor.http.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*

fun main(args: Array<String>) {
    embeddedServer(Netty, 8080) {
        routing {
            get("/") {
                call.respondText("First Sample", ContentType.Text.Html)
            }
        }
    }.start(wait = true)
}

アプリケーションを実行してブラウザで確認

Application.ktファイルを右クリックしてRun Application.ktを実行します。

その後ブラウザで http://localhost:8080 にアクセスします。

無事に画面が表示されました。

これから作るサンプルアプリの説明

作りたいもの

メモを編集するアプリケーションを作りたいと思います。
フロントエンドは別サーバにする前提で、メモの追加/取得/変更/削除/一覧を行うREST APIだけ提供するシンプルなアプリケーションです。
URLは以下の想定です。

機能 URL
追加 POST /memos/new
取得 GET /memos/{id}
変更 PUT /memos/{id}
削除 DELETE /memos/{id}
一覧 GET /memos

クリーンアーキテクチャー

実はクリーンアーキテクチャをちゃんと実装したことがないので、このシンプルなアプリケーションで試してみたいと思います。

ディレクトリ構成

クリーンアーキテクチャーの各層でディレクトリを分けています。

└── src
    └── main
        └── kotlin
            ├── domain  // Enterprise Business Rules(Entities)
            ├── usecase  // Application Business Rules(Use Cases)
            ├── interfaces  // Interface Adapters(Controllers, Presenters, Gateways)
            │   ├── controller
            │   └── repository
            └── infrastructure  // Frameworks & Drivers(UI, DB, Web, Devices, External Interfaces)
                ├── framework
                ├── dao
                ├── repositoryimpl
                └── Application.kt

以下のような役割分担にしようかと思っています。

ディレクトリ 役割分担
domain Domainモデルを管理する。基本的にはロジックは含めない
usecase アプリケーションのユースケースに応じて、Domainモデルを使って処理・計算を行い、結果を返す
interfaces.controller リクエストに応じて、必要なデータ(Domainモデル)をかき集めて、usecaseに渡して処理をやってもらい、結果を返す。いわゆるオーケストレーションを行う
interfaces.repository データの格納先やORMによらないRepositoryインターフェース。これはcontrollerから呼び出される
infrastructure.framework Ktorに依存するソースコードは原則ここに格納する
infrastructure.dao Exposedを使ってDBにアクセスするDaoクラス
infrastructure.repositoryimpl Repositoryインターフェースの実装クラス。Daoクラスや外部APIなどを呼び出してDomainモデルに詰め替えて返却する

まずは各ディレクトリを作成して、先ほど作ったApplication.ktinfrastructureディレクトリに移動します。

設定(application.conf)を読み込んで /gradlew run 実行

設定ファイルでサーバーを構成できるようにしたいと思います。
Ktorでは、こちらを参照すると、mainClassNameで特定のWebサーバエンジンのEngineMainクラスを指定する必要があるようです。embeddedServerを利用する場合は、lambdaとDSLで記述するようです。

今回は Nettyエンジンを使うようにしたいと思います。

./gradlew runで起動するメインクラスをNettyエンジンのメインクラスに変更

これまでは、対象のメインクラスを右クリックして直接プログラムを起動していましたが、Nettyエンジンのメインクラスを起動する際にはその方法は使えません。

build.gradlemainClassNameを変更します。

mainClassName = "io.ktor.server.netty.EngineMain"

自作のmain関数の中身を拡張関数として再定義

Application.ktを変更し、自作のmain関数は削除して、処理の中身はio.ktor.application.Applicationクラスにmoduleという拡張関数を定義して記載していきます。(拡張関数の名前はなんでもOK)

package infrastructure

import io.ktor.application.*
import io.ktor.http.*
import io.ktor.response.*
import io.ktor.routing.*

@Suppress("unused")
fun Application.module() {
    routing {
        get("/") {
            call.respondText("First Sample", ContentType.Text.Html)
        }
    }
}

拡張関数が利用されるようapplication.confを記載

src/main/resourcesの下にapplication.confファイルを作成します。HOCON仕様に従って記載します。
modulesに先ほど作成したmodule関数を指定しています。

ktor {
    deployment {
        port = 8080
    }
    application {
        modules = [ infrastructure.Application.module ]
    }
}

アプリケーションを起動

./gradlew run

画面キャプチャは載せませんが、問題なく動いてくれました。

ちなみに、出力される以下の警告は、ISSUEに書いてあるように気にしなくていいようです。

16:20:18.863 [main] DEBUG io.netty.util.internal.NativeLibraryLoader - netty_transport_native_kqueue_x86_64 cannot be loaded from java.library.path, now trying export to -Dio.netty.native.workdir: /var/folders/bw/jpoisdfqwe8fb8g3/T
java.lang.UnsatisfiedLinkError: no netty_transport_native_kqueue_x86_64 in java.library.path: [/Users/user_name/Library/Java/Extensions, /Library/Java/Extensions, /Network/Library/Java/Extensions, /System/Library/Java/Extensions, /usr/lib/java, .]

環境変数を利用

application.confを変更

こちらに書いてありますが、application.confで環境変数の値を読み込むように記載できます。

ktor {
    deployment {
        port = 8080
        port = ${?PORT}
    }
}

こちらの記載では、環境変数PORTが設定されていればその値が利用され、設定されていなければ、デフォルト値の8080が利用されます。

環境変数を設定してアプリを起動

export PORT=8081
./gradlew run

無事に8081ポートでアプリが起動されました。

Routinngを定義

Locationsを有効化

KtorのRoutingには二つの方法が提供されています。RoutingLocationsです。

Routingはとてもシンプルですが、パラメータの型を指定することができません。そのため受け取ったパラメータを利用する場合は、個別に目的の型にcastする必要があります。

一方でLocationは2020年4月現在、experimental featureなのですが、型を指定することができます(パラメータをType Safeに扱うことができます)。普通に考えてLocationsの方が便利そうなのでこちらを利用してみようと思います。

まず、依存ライブラリをbuild.gradleに追加します。

dependencies {
    implementation "io.ktor:ktor-locations:$ktor_version"
}

次に、Application.moduleでLocationsを有効化します。

import io.ktor.locations.Locations

fun Application.module() {
    install(Locations)
}

Controllerクラスを追加

まずはメモを取得するMemoControllerクラスを実装します。
Locationsはリソースに対して、URLを割り当てるというような考え方になっています。ここでは、MemoInputというリソースに対して/memos/{id}というURLを割り当ててます。get関数はこのMemoInputを受け取ってメモ情報を文字列で返す関数です。MemoInputの真下に実装してありますが、この時点ではなんの関連もありません。

package interfaces.controller

import io.ktor.locations.Location

@Location("/memos")
class MemoController {
    @Location("/{id}")
    data class MemoInput(val id: Int)

    fun get(input: MemoInput): String {
        return "id: ${input.id}, body: sample" // TODO: DBからメモ内容取得
    }
}

リソースと処理を紐づけ

Application.moduleroutingブロックで、HTTPメソッドに対してリソースを紐づけて、その結果呼び出される処理をlambda式で定義します。今回は、GETメソッドでMemoInputリソース(つまり、/memos/{id})にアクセスした際に、memoController.get()関数が呼び出されるという作りになっています。

fun Application.module() {
    routing {
        val memoController = MemoController()
        get<MemoInput> { input ->
            call.respond(memoController.get(input))
        }
    }
}

動作確認

ここまで実装したら、./gradlew runして
http://localhost:8081/memos/1
にアクセスしてみます。無事、サンプルのメモが文字列で表示されていますね。

[f:id:rinoguchi:20200419175340p:plain:w500]

Routing定義の切り出し

Routing定義はどう見ても太ってくるので、別ファイルに切り出そうと思います。

Application.module内で定義していたRoutingの定義を、Routing.ktに切り出します。
内部で利用するgetなどは、Routingクラスの関数なのでRoutingクラスに拡張関数として定義します。

package infrastructure.framework

import interfaces.controller.MemoController
import interfaces.controller.MemoController.MemoInput
import io.ktor.application.call
import io.ktor.locations.get
import io.ktor.response.respond
import io.ktor.routing.Routing

fun Routing.root() {
    val memoController = MemoController()
    get<MemoInput> { input ->
        call.respond(memoController.get(input))
    }
}

あわせてApplication.moduleで定義した拡張関数を呼び出すようにします。

import infrastructure.framework.root

fun Application.module() {
    routing {
        root()
    }
}

アプリを再起動して画面にアクセスすると問題なく結果が表示されました。

レスポンスをJSONで返却

上記の実装ではレスポンスを文字列で返していましたが、JSONで返すようにしたいと思います。こちらを参考に進めます。

依存ライブラリ追加

まず、Jacksonライブラリをbuild.gradleに追加します。

dependencies {
    implementation "io.ktor:ktor-jackson:$ktor_version"
}

レスポンスをJSONで返してくれるよう設定

ContentNegotiation機能を利用します。これは、Content-TypeおよびAcceptヘッダーに応じてコンテンツを自動的に変換してくれる機能です。
Application.moduleに以下のように記載します。

fun Application.module() {
    install(ContentNegotiation) {
        jackson {
            enable(SerializationFeature.INDENT_OUTPUT)
        }
    }
}

Controllerでオブジェクトを返すよう変更

MemoControllerクラスでget()MemoOutputオブジェクトを返すように変更します。

class MemoController {

    @Location("/{id}")
    data class MemoInput(val id: Int)

    data class MemoOutput(val id: Int, val body: String)
    fun get(input: MemoInput): MemoOutput {
        return MemoOutput(input.id, "sample")
    }
}

動作確認

./gradlew runして
http://localhost:8081/memos/1
にアクセスしてみると、無事レスポンスがJSONで返却されていますね。

DI(依存性の注入)の導入

クリーンアーキテクチャをやろうとすると、制御の方向と依存の方向が逆転するケースが発生します。それを解決するために依存性逆転の原則が重要ですが、それを実現するためにDIを導入しようと思います。

kotlinだとKoinKodeinの二つのDIフレームワークがあるようですが、Koinの方がgithubのstarが多いのでKoinを採用することにします。

依存ライブラリを追加

build.gradlerepositoriesjcenter()を追加した上で、dependencieskoin-ktorライブラリを追加します。

repositories {
    jcenter()
}
dependencies {
    implementation "org.koin:koin-ktor:2.1.5"
}

KoinModule.ktを作成しDI対象のモジュールを定義

infrastructure層にframeworkというディレクトリを作りその中に、KoinModule.ktを作成することにします。
MemoControllerをシングルトンオブジェクトのモジュールとして定義しています。

package infrastructure.framework

import org.koin.dsl.module
import interfaces.controller.MemoController

val koinModules = module {
    single { MemoController() }
}

KoinによるDI機能をインストール

Application.moduleで、KoinによるDI機能をインストールします。

import org.koin.ktor.ext.Koin
import infrastructure.framework.koinModule

fun Application.module() {
    install(Koin) {
        modules(koinModules)
    }
}

依存性を注入

Application.moduleMemoControllerインスタンスを毎回生成していた部分を修正します。

import org.koin.ktor.ext.inject

fun Application.module() {
    routing {
        val memoController: MemoController by inject()
        get<MemoInput> {input ->
            call.respond(memoController.get(input))
        }
    }
}

アプリを再起動して画面にアクセスすると問題なく結果が表示されました。

AutoReload

都度再起動するのが嫌になってきたので、AutoReloadを有効にして、ソースコードの変更を検知して自動的にアプリケーション再起動されるようにしたいと思います。

application.confの設定

application.confに以下のような設定を加えるだけです。

ktor {
    deployment {
        watch = [ /build/classes/kotlin/main/ ]
    }
}

ktor.deployment.watchに監視対象のクラスパスに部分一致する文字列を指定すれば良いらしいです。アプリの起動ログにClass Loaderのログが出力されているので、そこからクラスパスを確認するのが良いと思います。

19:30:08.810 [nioEventLoopGroup-3-1] DEBUG Application - Class Loader: jdk.internal.loader.ClassLoaders$AppClassLoader@9v78dsfa: [(省略), /Users/user_name/workspace/ktor_sample/build/classes/kotlin/main/, /Users/user_name/workspace/ktor_sample/build/resources/main/]

これで、IdeaでソースコードをBuildすると、次にリクエストが発生した時に、アプリケーションが自動再起動されるようになりました。

ファイル変更を検知して、IdeaのソースコードBuildを自動化するには、現状以下の-tオプション(変更を検知して再実行)をつけて、installDistコマンドを実行する必要があります。

./gradlew -t installDist

この一手間を避けたくて、こちらの記事を参考にIdeaの機能でAutoBuildが動くように設定してみたのですが、うまくいきませんでした(涙)。どなたか良い方法があれば教えてください。

DBを構築(Ktor関係ないので適当に読み飛ばしてください)

docker-composeでPostgresqlコンテナを立ち上げ、Flywayでマイグレーションを行う方針とします。

docker-composeでPostgresqlコンテナを立ち上げ

まずはこちらを参考に Docker For Macをインストールします。

次に、docker-compose.ymlを作成します。

version: "3"
services:
  postgres:
    image: postgres:latest
    ports:
      - "5432:5432"
    environment:
      - POSTGRES_USER=root
      - POSTGRES_PASSWORD=root
      - POSTGRES_DB=root

Dockerコンテナを立ち上げます。

# foregroundで実行 ※Ctrl+C で停止する
docker-compose up

# backgroundで実行
docker-compose up -d

# 停止
docker-compose stop

psqlでアクセスできるか確認します。passwordはrootです。無事アクセスできました。

psql -h localhost -U root -d root
> Password for user root: 
> psql (11.3)
> Type "help" for help.

Flywayでマイグレーション

まずは、build.gradleFlywayプラグインを追加し、postgresqlライブラリも追加します。

buildscript {
    repositories {
        maven { url "https://plugins.gradle.org/m2/" }
    }
    dependencies {
        classpath "gradle.plugin.org.flywaydb:gradle-plugin-publishing:6.3.3"
    }
}

apply plugin: "org.flywaydb.flyway"
dependencies {
    implementation "org.postgresql:postgresql:42.2.12"
}

// FlaywayからのDB接続定義
flyway {
    url = "jdbc:postgresql://localhost:5432/root"
    user = "root"
    password = "root"
}

/src/main/resources/db/migration/ディレクトリの下にマイグレーションSQLファイルを作成します。V1__Initial.sqlとしておきます。ネーミングルールはこちらを参照。

drop table if exists memo;

create table memo (
  id serial
  , body text
  , primary key (id)
);

insert into memo (body)
values
  ('sample1')
  , ('sample2')
  , ('sample3')
;

Flywayを実行します。

./gradlew flywayMigrate

これでやっとDBが準備できました。

DBにアクセスする

ORMにはKtorと同じJetBrains社製の Exposed を利用してみようと思います。

設定追加

まずは、build.gradleにExposedライブラリを追加します。

dependencies {
    implementation "org.jetbrains.exposed:exposed:0.17.7"
}

次に、application.confにDB接続定義を記載します。環境変数が指定されていたらそちらの値を読み込むような感じです。

app {
    database {
        url = "jdbc:postgresql://localhost:5432/root"
        url = ${?DATABASE_URL}
        user = "root"
        user = ${?DATABASE_USER}
        password = "root"
        password = ${?DATABASE_PASSWORD}
    }
}

アプリケーションからDBに接続する処理を追加

アプリケーション起動時にDBに接続するように、Application.moduleに記載を追加します。先ほどapplication.confに追加したDB接続パラメータをここで使用しています。

import org.jetbrains.exposed.sql.Database

fun Application.module() {
    Database.connect(
        url = environment.config.property("app.database.url").getString(),
        user = environment.config.property("app.database.user").getString(),
        password = environment.config.property("app.database.password").getString(),
        driver = "org.postgresql.Driver"
    )
}

DAOクラスを作成

/infrastructure/daoディレクトリの下にMenoDao.ktを作成します。
IntIdTableはInt型のidというPrimary Keyを持つテーブルを扱うためのベースクラスです。
IntEntityClassfindByIdなどの便利関数を提供してくれるのですが、足りなければ、MemoEntityfun findByHoge()のように関数を生やしていけばいいと思います。

package infrastructure.dao

import org.jetbrains.exposed.dao.EntityID
import org.jetbrains.exposed.dao.IntEntity
import org.jetbrains.exposed.dao.IntEntityClass
import org.jetbrains.exposed.dao.IntIdTable

object MemoTable : IntIdTable("memo") {
    val body = text("body")
}

class MemoEntity(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<MemoEntity>(MemoTable)
    var body by MemoTable.body
}

Controllerをトランザクション境界に

トランザクション境界をどこにするかは悩みポイントだと思いますが、ここではControllerをトランザクション境界にします。Rooting.ktでcontrollerの処理全体をtransactionでかこみます。

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

fun Routing.root() {
    val memoController: MemoController by inject()
    get<MemoInput> { input ->
        call.respond(transaction{ memoController.get(input) })
    }
}

ControllerからDAO呼出し

クリーンアーキテクチャー的にはintarface層からinfrastracture層を呼び出すのはNGなのですが、一旦動作確認のために実装します。
MemoController.ktでDAOを呼び出して結果を返しています。

import infrastructure.dao.MemoEntity
import java.lang.RuntimeException

@Location("/memos")
class MemoController {
    fun get(input: MemoInput): MemoOutput {
        val memoEntity: MemoEntity? = MemoEntity.findById(input.id)
        memoEntity ?: throw RuntimeException() // TODO: Not Found対応
        return MemoOutput(memoEntity.id.value, memoEntity.body)
    }
}

動作確認

以下のURLそれぞれで、異なる結果が返るようになっていれば想定通りです。

http://localhost:8081/memos1/1

http://localhost:8081/memos2/2

Repository層を導入

これまでの実装では、内側(interface)が外側(infrastructure)に依存していました。これを解決すべくデータアクセスを抽象化したRepository層を導入したいと思います。

Domainモデルを作成

まず、データの入れ物として、Domainモデルを定義しようと思います。RepositoryはDBから取得したデータをこのDomainモデルに詰め替える想定です。
/domainディレクトリの下にMemo.ktを作成します。

package domain

data class Memo(val id: Int, val body: String)

Repositoryインターフェースを作成

/interface/repositoryディレクトリの下にMemoRepositoryインターフェースを作成します。

package interfaces.repository

import domain.Memo

interface MemoRepository {
    fun findById(id: Int) : Memo?
}

Repository実装クラス作成

/infrastructure/repositoryimplディレクトリの下にMemoRepositoryImplクラスを作成します。ここではDAOを呼び出してDBからデータを取得して、Domainモデルへの詰め替えを行うことにしました。

package infrastructure.repositoryimpl

import domain.Memo
import infrastructure.dao.MemoEntity
import interfaces.repository.MemoRepository

class MemoRepositoryImpl : MemoRepository {
    override fun findById(id: Int): Memo? {
        val memoEntity: MemoEntity? = MemoEntity.findById(id)
        memoEntity ?: return null
        return convertToMemo(memoEntity)
    }

    // Domainモデルへの詰め替え
    private fun convertToMemo(memoEntity: MemoEntity) : Memo {
        return Memo(memoEntity.id.value, memoEntity.body)
    }
}

KoinModuleの追加

KoinModule.ktに先ほど作成したRepositoryのモジュールを追記します。

val koinModules = module {
    single { MemoController() }
    factory<MemoRepository> { MemoRepositoryImpl() }
}

ControllerでRepositoryを呼び出し

MemoControllerクラスでRepositoryクラスをinjectして、Repositoryからデータを取得して、レスポンスを返すようにしました。
Applicationクラス以外の場所でinjectするためには、KoinComponentインターフェースを実装する必要がありますのでご注意ください。

import domain.Memo
import interfaces.repository.MemoRepository
import org.koin.core.KoinComponent
import org.koin.core.inject

@Location("/memos")
class MemoController : KoinComponent {
    private val memoRepository: MemoRepository by inject()

    fun get(input: MemoInput): MemoOutput {
        val memo: Memo? = memoRepository.findById(input.id)
        memo ?: throw RuntimeException() // TODO: Not Found対応
        return MemoOutput(memo.id, memo.body)
    }
}

ここまで実装して、画面にアクセスすると結果が表示されます。

ロギング

依存ライブラリを追加

build.gradlelogbackライブラリを追加します。

dependencies {
    implementation "ch.qos.logback:logback-classic:1.2.3"
}

logback.xmlを作成

src/main/resourceslogback.xmlを作成します。

<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="DEBUG">
        <appender-ref ref="STDOUT"/>
    </root>

    <logger name="io.netty" level="DEBUG"/>
</configuration>

ログ出力処理を追加

MemoControllerクラスで、ログ出力処理を追加します。

import org.slf4j.Logger
import org.slf4j.LoggerFactory

class MemoController : KoinComponent {

    private val logger: Logger = LoggerFactory.getLogger("MemoController")    

    fun get(input: MemoInput): MemoOutput {
        logger.info("MemoController.get called.")
    }
}

ログ確認

画面を表示してみると、以下のようにちゃんとログが出力されました。

2020-04-21 18:40:44.937 [nioEventLoopGroup-4-4] INFO MemoController - MemoController.get called.

エラーハンドリング

エラーが発生した時は例外をthrowし、FWがその例外をcatchして適切にメッセージを返すような作りにしたいと思います。

例外クラス毎にHTTPステータス、レスポンスを定義

Application.moduleStatusPagesをインストールして、例外クラス毎に適切なHTTPステータスコードとメッセージをJSON形式で返すようにします。

import io.ktor.features.NotFoundException
import io.ktor.features.ParameterConversionException
import io.ktor.features.StatusPages
import io.ktor.http.HttpStatusCode
import io.ktor.response.respond
import io.ktor.util.DataConversionException

fun Application.module() {
    install(StatusPages) {
        data class ErrorResponse(val message: String?)

        exception<NotFoundException> {
            call.respond(HttpStatusCode.NotFound, ErrorResponse(it.message))
        }
        exception<BadRequestException> {
            call.respond(HttpStatusCode.BadRequest, ErrorResponse(it.message))
        }
        exception<ParameterConversionException> {
            call.respond(HttpStatusCode.BadRequest, ErrorResponse(it.message))
        }
        exception<DataConversionException> {
            call.respond(HttpStatusCode.BadRequest, ErrorResponse(it.message))
        }
        exception<Throwable> {
            call.respond(HttpStatusCode.InternalServerError, ErrorResponse(it.message))
        }
    }
}

NotFoundExceptionBadRequestExceptionはKtorが提供してくれている例外クラスで、この例外は実装でthrowするイメージです。
ParameterConversionExceptionDataConversionExceptionはLocations機能のパラメータバインディング時に発生するエラーです。
また、想定外のエラーが発生した時はThrowableで拾って、InternalServerErrorを返します。

Controllerでエラーを発生させる

MemoControllerクラスでMemoデータが見つからなければ、NotFoundExceptionをthrowするようにしてみたいと思います。

import io.ktor.features.NotFoundException

class MemoController : KoinComponent {
    fun get(input: MemoInput): MemoOutput {
        val memo: Memo? = memoRepository.findById(input.id)
        memo ?: throw NotFoundException("memo is not found.")
        return MemoOutput(memo.id, memo.body)
    }
}

動作確認

http://localhost:8081/memos/99

対象のデータは存在しないので、404 Not Foundが返っています。エラーメッセージも期待通りですね。

http://localhost:8081/memos/xx

Locationsの機能でインプットパラメータ"xx"をInt型にキャストしようとしてエラーが発生し、400 BadRequestが返っています。これも期待通りの動きです。

更新処理&ロールバック

POST /memos/newでメモを作成する処理を作ってみようと思います。

Repository -> RepositoryImpl -> Controller -> Routingの順番に実装していこうと思います。

Repositoryを実装

MemoRepositoryインターフェースに新規作成用の関数を定義します。

interface MemoRepository {
    fun create(body: String) : Memo
}

MemoRepositoryImplクラスで上記をoverrideして実装します。

class MemoRepositoryImpl : MemoRepository {
    override fun create(body: String): Memo {
        val memoEntity: MemoEntity = MemoEntity.new {
            this.body = body
        }
        return convertToMemo(memoEntity)
    }
}

Controller&Routingを実装

MemoControllerクラスでMemoRepositoryを呼び出します。

@Location("/memos")
class MemoController : KoinComponent {
    @Location("/new")
    data class MemoPostInput(val body: String)

    fun post(input: MemoPostInput): MemoOutput {
        logger.info("MemoController.post called.")
        val memo: Memo = memoRepository.create(input.body)
        return MemoOutput(memo.id, memo.body)
    }    
}

最後に、Routing.ktにルーティング定義を追加します。

fun Routing.root() {
    post<MemoPostInput> { input ->
        call.respond(transaction{ memoController.post(input) })
    }
}

さあ、これで動作確認してみます。

curl -v -X POST http://localhost:8081/memos/new -d "body=xxxxxxxxxxx"
* Rebuilt URL to: POST/
* Could not resolve host: POST
* Closing connection 0
curl: (6) Could not resolve host: POST
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#1)
> POST /memos/new HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Length: 16
> Content-Type: application/x-www-form-urlencoded
> 
* upload completely sent off: 16 out of 16 bytes
< HTTP/1.1 404 Not Found
< Content-Length: 0
< 
* Connection #1 to host localhost left intact

残念なことに404 Not Foundが発生します!そう、Locationsではリクエストボディのパラメータはうまく受け取ってくれないみたいなのです。これ1年近く前に試した時のダメで、そろそろなおってるかと思って試したのですが、やはりダメでした。確認するとISSUEがばっちりOPENのままです。

ちなみに、ISSUEにも書いてありますが、URL埋め込みパラメータで受け取る形にするとちゃんと動きます。

@Location("/memos")
class MemoController : KoinComponent {
    @Location("/new/{body}")
    data class MemoPostInput(val body: String)
}
curl -v -X POST http://localhost:8081/memos/new/xxxxxxxxxxx
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> POST /memos/new/xxxxxxxxxxx HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Content-Length: 40
< Content-Type: application/json; charset=UTF-8
< 
{
  "id" : 6,
  "body" : "xxxxxxxxxxx"
* Connection #0 to host localhost left intact
}

動きました。でもパラメータが複数ある時などこの方法はとれないですし、URLの違和感が大きいです。#
結局、Locationsはまだ exporimental なので、未整備な機能があるということですね。

RoutingをLocationsからRoutingに書き換え

しょうがないので、Locationsは諦めて、Routingで全部書き直してみます。

まず、ControllerのLocationsのアノテーションを全て削除します。この層にframeworkに依存するコードが存在することに違和感があったので、逆に良かったかもしれません。

class MemoController : KoinComponent {
    data class MemoInput(val id: Int)
    data class MemoPostInput(val body: String)
}

次に、Routing.ktこちらをみながらRoutingの方法を変更します。
TypeSafeではないですが、とりあえず変更できました。

fun Routing.root() {
    route("/memos") {
        val memoController: MemoController by inject()
        get("/{id}") {
            val input = MemoInput(call.parameters.getOrFail<Int>("id"))
            call.respond(transaction{ memoController.get(input) })
        }
        post("/new") {
            val input = MemoPostInput(call.receiveParameters().getOrFail("body"))
            call.respond(transaction{ memoController.post(input) })
        }
    }
}

Application.moduleのStatusPagesの設定も一応変更しておきます。MissingRequestParameterExceptiongetOrFail()でパラメータが見つからなかった時にthrowされる例外です。

fun Application.module() {
    install(StatusPages) {
        exception<MissingRequestParameterException> {
            call.respond(HttpStatusCode.BadRequest, ErrorResponse(it.message))
        }
        exception<ParameterConversionException> {
            call.respond(HttpStatusCode.BadRequest, ErrorResponse(it.message))
        }
    }
}

動作確認します。大分遠回りしましたが、無事id=7で新しいデータが作成されました。

curl -v -X POST http://localhost:8081/memos/new -d "body=xxxxx"
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> POST /memos/new HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Length: 10
> Content-Type: application/x-www-form-urlencoded
> 
* upload completely sent off: 10 out of 10 bytes
< HTTP/1.1 200 OK
< Content-Length: 34
< Content-Type: application/json; charset=UTF-8
< 
{
  "id" : 7,
  "body" : "xxxxx"
* Connection #0 to host localhost left intact
}

ロールバック

ロールバックも念のため、確認しておこうと思います。

まずは、/infrastructure/frameworkディレクトリにExposed.ktというファイルを作成して、Exposedのtransaction関数をラップして、例外を受け取ったらrollbackする関数を作成します。この関数は、最終的にExceptionをrethrowします。

package infrastructure.framework

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

fun <T> transactionWrapper(statement: Transaction.() -> T): T {
    return transaction {
        try {
            statement()
        }
        catch (ex: Exception) {
            rollback()
            throw ex 
        }
    }
}

次に、Routing.ktでもともとExposedのtransactionを使っていた部分をtransactionWrapperに置き換えます。

fun Routing.root() {
    route("/memos") {
        val memoController: MemoController by inject()
        get("/{id}") {
            val input = MemoInput(call.parameters.getOrFail<Int>("id"))
            call.respond(transactionWrapper { memoController.get(input) })
        }
        post("/new") {
            val input = MemoPostInput(call.receiveParameters().getOrFail("body"))
            call.respond(transactionWrapper { memoController.post(input) })
        }
    }
}

最後に、試しにMemoControllerクラスでMemoを追加した後にRuntimeExceptionを発生させてみます。

class MemoController : KoinComponent {
    fun post(input: MemoPostInput): MemoOutput {
        memoRepository.create(input.body) // ここでDBにレコード追加
        throw RuntimeException("transaction rollback finished.") // ここでロールバック
    }
}

現在の最大のmemoテーブルの最大のidをpsqlで確認すると19です。

# select max(id) from memo;
 max 
-----
  19
(1 row)

POSTリクエストを投げてみると、"message" : "transaction rollback finished."というメッセージが返ってきました。ちゃんとロールバックのロジックを通っているようです。

curl -v -X POST http://localhost:8081/memos/new -d "body=xxxxxxxxxxxxxxxxxxxx"
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> POST /memos/new HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Length: 25
> Content-Type: application/x-www-form-urlencoded
> 
* upload completely sent off: 25 out of 25 bytes
< HTTP/1.1 500 Internal Server Error
< Content-Length: 50
< Content-Type: application/json; charset=UTF-8
< 
{
  "message" : "transaction rollback finished."
* Connection #0 to host localhost left intact
}

改めて、現在の最大のmemoテーブルの最大のidをpsqlで確認すると19のままです。

# select max(id) from memo;
 max 
-----
  19
(1 row)

ちゃんとロールバックされているようです。実際にはRollbackExceptionクラスを作ったりして、想定内のロールバックと想定外のロールバックをきちんと切り分ける必要がありそうですが、現状はこれで十分な気はします。

UseCaseを追加

今回のアプリケーションでは、UseCase層で実装したいような要件がありませんが、無理やり「メモ本文をからキーワードを返す」というユースケースを追加しました。DBからメモデータを取り出してきた際に、キーワードを抽出します。
正直、DomainモデルのMemoクラスに実装したほうがいいのでは?そしてUseCaseには複数のDomainモデルを使って行う処理だけを実装するほうがいいのでは?という悩みはありましたが、今回は一旦UseCaseに上記処理を実装していきます。

まずDomainモデルのMemoクラスにkeywordsというプロパティを追加します。

data class Memo(val id: Int, val body: String, val keywords: List<String> = listOf())

次に、/interface/usecaseMemoServiceクラスを作成します。
とりあえず、メモ本文をトークン化して、5文字以上のトークンをキーワードとします。

package usecase

import domain.Memo
import java.util.*

class MemoService {
    fun attachKeywords(memo: Memo): Memo {
        val keywords = StringTokenizer(memo.body).toList().map { v -> v.toString() }.filter { v -> v.length > 5 }
        return memo.copy(keywords = keywords)
    }
}

さらに、この処理をKoinModule.ktのDIするモジュールとして登録します。

val koinModules = module {
    single { MemoService() }
}

次に、MemoRepositoryImplで上記のキーワードを抽出する処理を呼び出します。

    private fun convertToMemo(memoEntity: MemoEntity) : Memo {
        val memo = Memo(memoEntity.id.value, memoEntity.body)
        return memoService.attachKeywords(memo)
    }

最後に、MemoControllerクラスで、キーワードを返すようにします。

    data class MemoOutput(val id: Int, val body: String, val keywords: List<String>)

    private fun memoToMemoOutput(memo: Memo): MemoOutput {
        return MemoOutput(memo.id, memo.body, memo.keywords!!)
    }

    fun get(input: MemoInput): MemoOutput {
        logger.info("MemoController.get called.")
        val memo: Memo? = memoRepository.findById(input.id)
        memo ?: throw NotFoundException("memo is not found.")
        return memoToMemoOutput(memo)
    }

これで、抽出したキーワードがレスポンスとして返るようになりました。kotlin is programming languageというメモ本文から、[ "kotlin", "programming", "language" ]がキーワードとして抽出されていますね。

curl -v -X POST http://localhost:8081/memos/new -d "body=kotlin is programming language"
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> POST /memos/new HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Length: 35
> Content-Type: application/x-www-form-urlencoded
> 
* upload completely sent off: 35 out of 35 bytes
< HTTP/1.1 200 OK
< Content-Length: 115
< Content-Type: application/json; charset=UTF-8
< 
{
  "id" : 5,
  "body" : "kotlin is programming language",
  "keywords" : [ "kotlin", "programming", "language" ]
* Connection #0 to host localhost left intact
}

Validation

SpringBootなどと異なり、KtorではValidation機能は提供されていないようですので、自前で実装する必要があります。とはいえ、ゼロから実装するのはきついので、Bean Validation APIを使って、Controllerの引数(data class)に対してRouting時にチェックをかけるような形で実装しようと思います。チェックNGの場合はValidationExceptionをthrowして、先ほどのエラーハンドリング(StatusPages)を使って実現しています。

ライブラリを追加

build.gradleにライブラリを追加します。validation-apiだけでなく、validation-providerのhibernate-validatorやEL式を解釈するためのjavax.elなどが必要です。

dependencies {
    implementation "javax.validation:validation-api:2.0.1.Final"
    runtimeOnly 'org.hibernate.validator:hibernate-validator:6.0.17.Final'
    runtimeOnly 'org.glassfish:javax.el:3.0.1-b11'
}

アノテーションでValidation内容を設定

MemoControllerクラスの各アクションの引数はdata classになっていますが、このデータクラスに対してアノテーションでValidation内容を指定します。今回はメモ内容bodyが20文字以内というチェックです。

@field:Sizeのようにfield:をつけて、ターゲットを明確にする必要があるのでご注意ください。

class MemoController : KoinComponent {
    data class MemoPostInput(@field:Size(max = 20) val body: String)
}

ValidatorのDI定義追加

KoinModule.ktにValidatorのモジュールを追加します。

val koinModules = module {
    factory<Validator> { Validation.buildDefaultValidatorFactory().validator }
}

Routing時にValidation実行

Routing時にValidationを実行します。validationを実行してエラーがあったら、ValidationExceptionをthrowするようなラッパー関数を準備してそれを使うようにしました。

fun <T> Validator.validateAndThrow(obj: T, vararg groups: Class<*>?) {
    val errors = this.validate(obj, *groups)
    if (errors.size > 0) throw ValidationException(errors.joinToString { e -> "${e.propertyPath}: ${e.message}" })
}

fun Routing.root() {
    val validator: Validator by inject()

    route("/memos") {
        val memoController: MemoController by inject()
        post("/new") {
            val input = MemoPostInput(call.receiveParameters().getOrFail("body"))
            validator.validateAndThrow(input)
            call.respond(transactionWrapper { memoController.post(input) })
        }

StatusPagesでValidationExceptionをハンドリング

Application.moduleのStatusPagesでValidationExceptionをハンドリングします。

fun Application.module() {
    install(StatusPages) {
        exception<ValidationException> {
            call.respond(HttpStatusCode.BadRequest, ErrorResponse(it.message))
        }
    }
}

動作確認

"body=body length is over 20. xxxxxxxxxxxxxxxxxxxx"というパラメータでメモの追加APIを呼び出し、"message" : "body: size must be between 0 and 20" というエラーメッセージが返ってきました。

curl -v -X POST http://localhost:8081/memos/new -d "body=body length is over 20. xxxxxxxxxxxxxxxxxxxx"
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8081 (#0)
> POST /memos/new HTTP/1.1
> Host: localhost:8081
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Length: 49
> Content-Type: application/x-www-form-urlencoded
> 
* upload completely sent off: 49 out of 49 bytes
< HTTP/1.1 400 Bad Request
< Content-Length: 55
< Content-Type: application/json; charset=UTF-8
< 
{
  "message" : "body: size must be between 0 and 20"
* Connection #0 to host localhost left intact
}I

Nullチェックや型変換チェック

Nullチェックは@NotNullでチェックしたくなりますが、チェック対象プロパティはもともとNonNullなのでこの方法ではチェックできません。また、Int型のパラメータに数値以外を指定した場合の型変換チェックもdata classにバインドできている時点で型変換できているということなので同様にチェックできません。
これらは、Routingのcall.receiveParameters().getOrFail()を使ってチェックする形にしようと思います。

fun Routing.root() {
    route("/memos") {
        get("/{id}") {
            val input = MemoInput(call.parameters.getOrFail<Int>("id"))
            call.respond(transactionWrapper{ memoController.get(input) })
        }
}

このgetOrFailは以下のように、値がない時やコンバージョン失敗した時にExceptionをthrowします。

  • @throws MissingRequestParameterException if no values associated with this [name]
    • @throws ParameterConversionException when conversion from String to [R] fails

なので、StatusPagesに以下の定義を追加すれば問題なく動作します。

fun Application.module() {
    install(StatusPages) {
        exception<MissingRequestParameterException> {
            call.respond(HttpStatusCode.BadRequest, ErrorResponse(it.message))
        }
        exception<ParameterConversionException> {
            call.respond(HttpStatusCode.BadRequest, ErrorResponse(it.message))
        }
    }
}

ソースコード

最終的なソースコードはGitHubで公開してありますので、必要があればこちらから参照ください。

感想

  • SpringBootに比べると提供している機能も少なく、Documentもスッキリしているので、基本的にほとんどはまりませんでしたし、APIサーバとして使う分には今回の検証内容でもだいたいOKな感じがします。

  • Ktorは使いたい機能を一つずつ足していくスタンスなので、利用者の好みでどれぐらいFWに依存するか(薄くするか、厚くするか)を選べます。今回の範囲であればほとんどFW依存のコードもないですし、あとで別のFWに乗り換えることも難しくなさそうです。

  • クリーンアーキテクチャーも(これが正しいかはいまいち自信がないですが)、内側の層が外側の層に依存しない状態は作り出せましたし、よくあるモデルの詰め替え作業みたいなソースも最低限に抑えることができました。今回ぐらいの内容であれば実装の手間が増えた感じはほとんどなく、これで内側の層の安定性が増すのであればかなり良さそうです。

以上のことから、自分としてはKtor+クリーンアーキテクチャーを実運用で採用する選択肢も十分ありえると思いました。