サーバサイド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.kt
をinfrastructure
ディレクトリに移動します。
設定(application.conf)を読み込んで /gradlew run 実行
設定ファイルでサーバーを構成できるようにしたいと思います。
Ktorでは、こちらを参照すると、mainClassName
で特定のWebサーバエンジンのEngineMain
クラスを指定する必要があるようです。embeddedServerを利用する場合は、lambdaとDSLで記述するようです。
今回は Nettyエンジンを使うようにしたいと思います。
./gradlew runで起動するメインクラスをNettyエンジンのメインクラスに変更
これまでは、対象のメインクラスを右クリックして直接プログラムを起動していましたが、Nettyエンジンのメインクラスを起動する際にはその方法は使えません。
build.gradle
のmainClassName
を変更します。
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には二つの方法が提供されています。RoutingとLocationsです。
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.module
のrouting
ブロックで、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だとKoinとKodeinの二つのDIフレームワークがあるようですが、Koinの方がgithubのstarが多いのでKoinを採用することにします。
依存ライブラリを追加
build.gradle
のrepositories
にjcenter()
を追加した上で、dependencies
にkoin-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.module
でMemoController
インスタンスを毎回生成していた部分を修正します。
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.gradle
にFlywayプラグインを追加し、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を持つテーブルを扱うためのベースクラスです。
IntEntityClass
がfindById
などの便利関数を提供してくれるのですが、足りなければ、MemoEntity
にfun 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.gradle
でlogback
ライブラリを追加します。
dependencies {
implementation "ch.qos.logback:logback-classic:1.2.3"
}
logback.xmlを作成
src/main/resources
にlogback.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.module
でStatusPagesをインストールして、例外クラス毎に適切な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))
}
}
}
NotFoundException
やBadRequestException
はKtorが提供してくれている例外クラスで、この例外は実装でthrowするイメージです。
ParameterConversionException
やDataConversionException
は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の設定も一応変更しておきます。MissingRequestParameterException
はgetOrFail()
でパラメータが見つからなかった時に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/usecase
にMemoService
クラスを作成します。
とりあえず、メモ本文をトークン化して、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+クリーンアーキテクチャーを実運用で採用する選択肢も十分ありえると思いました。