タケハタのブログ

プログラマの生き方、働き方、技術について雑多に書いていくブログです。

Ktorはどう使う?

今朝もちょうどこの記事が話題になってましたが

www.publickey1.jp

先日Kotlin製のフレームワークである、Ktorのバージョン1.0がリリースされました。

blog.jetbrains.com

今のところサーバーサイドKotlinのフレームワークはSpringほぼ一択の状態なのですが、可能性を広げる意味でも期待しています。
前から存在は知っていたのですが、正式版も出たということでちょっと触ってみました。

公式のサンプルを参考に、まずはREST APIを作ってみるところとかをやっています。

導入方法

IntelliJ IDEAにプラグイン導入

IntelliJ IDEAにKtorのプラグインがあるので、導入しましょう。
これがあった方が簡単に始められます。

plugins.jetbrains.com

プロジェクト作成

メニューの File -> New -> Project... でKtorのプロジェクトを作ります。
プラグインが入っていれば下記のようにKtorが選択できるようになっています。
f:id:take7010:20181125230522p:plain

一旦初期状態のまま

  • Project SDK: 1.8
  • Project: Gradle
  • Using Netty
  • Ktor Version: 1.0.0

で作成しましょう。
アプリケーションサーバーはTomcatとかも使えるみたいですが、Nettyがよく使われているようなのでこちらを使います。

f:id:take7010:20181125230933p:plain その他の設定はお好みで。
サンプルのプロジェクトは一旦デフォルトの名前で動かします。

まずは動かしてみる

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"
}

dependenciesio.ktor: と入っているものがKtor関連の依存関係です。
後でいくつか追加していきます。

サンプルアプリケーション

Application.ktを開くと

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) {
}

一旦起動してみましょう。
上記Application.ktのmain関数を実行してもらうだけで大丈夫です。
コンソールに

[main] INFO  Application - Responding at http://0.0.0.0:8080

と表示されていれば8080ポートで起動しています。 しかし、まだなにもpathを定義していないので、アクセスしても404エラーになります。

下記のようにルートパスの処理を追加します。

fun Application.module(testing: Boolean = false) {
    routing {
        get("/") {
            call.respondText("Hello World!")
        }
    }
}

Application.module

Ktorはこの Application.module の中でWebアプリケーションの処理を定義していきます。
moduleという名前は任意で変更できます(後述します)。

ルーティング

そして routing ブロックの中でpathのルーティングを定義します。
今回は get("/") で、GETメソッドでルートパスへのアクセスを定義しています。

レスポンス

call.respondText("Hello World!") で文字列を返却しています。

アクセス

アプリケーションを再起動し、ブラウザで http://localhost:8080 へアクセスすると、画面に「Hello World!」が表示されたと思います。

起動するモジュールの設定

プロジェクトのresources配下に、applicaiton.confというファイルがあります。

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

ここでは起動するポートと、実行するモジュールが定義されています。
先程のApplication.ktのmain関数を起動した時、 port で定義したポートでNettyを起動し、 modules で定義したモジュールを起動します。

Application.module のモジュールという名前は任意と書きましたが、変更する場合はこちらで定義を変更します。
例えばApplication.ktのモジュール名を

fun Application.mainModule(testing: Boolean = false) {

とし、application.confの設定を

modules = [ com.example.ApplicationKt.mainModule ]

と変えればmainModuleという名前で起動します。

pathの共通化

例えば、 /main/1 /main/2 という2つのpathを作りたい時、下記のようにまとめることができます。

route("/main") {
    get("/1") {
        call.respondText("main1")
    }
    get("/2") {
        call.respondText("main2")
    }
}

route でベースとなる main というpathを設定し、そのブロックの中で後ろのpathを設定します。
これで
http://localhost:8080/main/1
http://localhost:8080/main/2
がそれぞれアクセスできるようになります。

複数モジュールの起動

モジュールを複数起動することもできます。
例えば、別ファイルで下記2つのモジュールを用意したとします。

HogeApplication.kt

@Suppress("unused") // Referenced in application.conf
@kotlin.jvm.JvmOverloads
fun Application.hogeModule(testing: Boolean = false) {
    routing {
        get("/hoge") {
            call.respondText("Hello Hoge!")
        }
    }
}

FugaApplication.kt

@Suppress("unused") // Referenced in application.conf
@kotlin.jvm.JvmOverloads
fun Application.fugaModule(testing: Boolean = false) {
    routing {
        get("/fuga") {
            call.respondText("Hello Fuga!")
        }
    }
}

そしてapplication.confの modules にもモジュール名を書き加えます。

modules = [ com.example.ApplicationKt.mainModule, com.example.HogeApplicationKt.hogeModule, com.example.FugaApplicationKt.fugaModule ]

配列なので、カンマ区切りで複数設定できます。
そしてApplication.ktのmain関数を起動すると、

http://localhost:8080/hoge
http://localhost:8080/fuga

にアクセスできるようになります。 実際に開発をする時は、サーバー(この場合はnetty)を起動するmain関数を持ったApplication.ktを用意し、後は機能ごとでファイル、モジュールを分けるというSpringBootに近いイメージになるのかなと思っています(開発規模にもよりますが)。

REST APIを実装してみる

JSONを使う

JSONを扱うのには、Jacksonを使用します。
まず、build.gradleの dependencies に下記の1行を追加します。

compile "io.ktor:ktor-jackson:$ktor_version"

そして、Application.ktの Application.mainModule の中に

install(ContentNegotiation) {
    jackson {
        // ここに設定を書ける
    }
}

を追加します。
Ktorはフレームワーク内の機能を使いたい時にこの install で読み込むことで各モジュールで使えるようになるみたいです。
この ContentNegotiation を使うことでjacksonの設定を書けるようになります。

ContentNegotiation はktor-coreに入っているクラスで、 jacksonContentNegotiation の拡張関数として定義されてるようなんですが・・・みたいなところまでは読んだんですが、仕組み的な部分は一旦省略します。

routing ブロックに次のpathを追加し、実行してみてください。

get("/json") {
    call.respond(mapOf("status" to "OK"))
}

ブラウザで http://localhost:8080/json を叩くと、

{
    status: "OK"
}

というJSONが返って来ると思います。

POSTでJSONのリクエスト

もうちょっと現実的な使い方を書きます。
まず、リクエスト、レスポンスに紐づくデータを持った下記2つのデータクラスを作ってください。

data class SampleRequest(val id: Int)
data class SampleResponse(val id: Int, val name: String)

そして、 routing に下記のpathを追加してください。

post("/json") {
    val request = call.receive<SampleRequest>()
    val response = SampleResponse(request.id, "ktor")
    call.respond(response)
}

今回は post で定義しているので、POSTメソッドの /json というpathになります。
そして call.receive<リクエストの型> と書くことで、リクエストのJSONを定義したデータクラスにparseして受け取れます。 この処理では、リクエストで受け取ったidと「ktor」というnameを SampleResponse にsetしています。

最後に、 call.responsed にレスポンスのデータクラスを渡すことで、JSONにformatしてレスポンスを返します。
ターミナルで次のcurlコマンドを実行して確認しましょう。

curl \
  --request POST \
  --header "Content-Type: application/json" \
  --data '{"id" : 1}' \
  http://127.0.0.1:8080/json

下記のJSONが返ってくると思います。

{"id":1,"name":"ktor"}

これで簡単なREST APIが作れました。 実際に開発で使う時は、リクエスト、レスポンスのデータクラスはSwaggerとかでも作れるもので対応できますね。

認証

Basic認証を有効化する

Ktorの機能で認証機構も実装することができます。
SpringでいうSpring Securityみたいなイメージですね。

まず、Gradleのdependencyに下記を追加します。

compile "io.ktor:ktor-auth:$ktor_version"

そしてまたApplication.ktの Application.mainModule に、下記の install を追加します。

install(Authentication) {
    basic {
        validate { if (it.name == "user" && it.password == "password") UserIdPrincipal("user") else null }
    }
}

Basic認証を走らせる設定になります。
validate の中で入力されたユーザー名とパスワードをチェックしています。
今回はユーザー名が「user」、パスワードが「password」という文字列と一致すれば認証が通ります。
ここで出てくる itUserPasswordCredential というクラスの値を参照しています。

そして UserIdPrincipal("user") を返却することで認証成功となります。
この UserIdPrincipal がユーザー情報としてセッションに保持されます(後述します)。

そして、 routing に下記のpathを追加します。

authenticate {
    get("/auth") {
        call.respondText("Authenticated!!")
    }
}

authenticate ブロックで括られているpathが認証の対象となります。
この場合 http://localhost:8080/auth へアクセスすると、Basic認証が求められます。
そして先程のユーザー名、パスワードを入力して認証が成功すれば、「Authenticated!!」が表示されます。

今回はBasic認証での処理を紹介していますが、OAuth等にも対応しているようです。

セッションのユーザー情報の取得

前述した認証時に登録されたセッションのユーザー情報を呼び出す方法です。
先程の /auth の処理を下記のように書き換えます。

get("/auth") {
    val user = call.authentication.principal<UserIdPrincipal>()
    call.respondText("${user!!.name}:Authenticated!!")
}

call.authentication.principal<UserIdPrincipal> でセッションの情報を取得しています。
(Null可で取れてしまうので、今回は一旦!!で回避しています)
ユーザー名「user」で登録していたので、 user:Authenticated!! と表示されます。

ちなみに UserIdPrincipal は、 io.ktor.auth.Principal インターフェースを実装したクラスであればなんでも使えます。
なので例えば

data class User(val id: Int, val name: String): Principal

というデータクラスを作って、

// 認証チェック(Userに設定している値は仮)
validate { if (it.name == "user" && it.password == "password") User(1, "hoge") else null }
// 取得処理
val user = call.authentication.principal<User>()

みたいにすることもできます。
実際はこういう使い方になるのかな?と思います。
(本当に認証にだけ使いたい場合は UserIdPrincipal でも充分ですが)

エラーハンドリング

例外に応じて返却するステータスコードを変更する

例外がthrowされた時、その種類に応じてレスポンスで返却するステータスコードを変更できます。
動作確認用に下記のpathを追加します。

get("/exception") {
    throw IllegalArgumentException("exception example.")
}

IllegalArgumentException を投げるだけの処理です。
そして下記の install を追加します。

install(StatusPages) {
    exception<IllegalArgumentException> { cause ->
        call.respond(HttpStatusCode.BadRequest)
    }
}

これで http://localhost:8080/exception にアクセスすると、400エラーが返ってくると思います。
ちなみに上記のエラーハンドリングの install を削除すると、問答無用に500エラーが返ります。

ステータスコードに応じてレスポンスを変更する

さらに、ステータスコードに応じてエラーを返すだけでなく、レスポンスの内容を変更することもできます。
先程の install(StatusPage) のブロックに、下記を追加してみてください。

status(HttpStatusCode.BadRequest) {
    call.respond(TextContent("${it.value} ${it.description}", ContentType.Text.Plain.withCharset(Charsets.UTF_8), it))
}

これでもう一度 http://localhost:8080/exception にアクセスすると、

400 Bad Request

という文字列が返って来るようになります。

微妙だったポイント

設定を知らないだけかもしれませんが、微妙だと感じたポイントを2つ。
(解決方法あったら誰か教えてください)

同じpathを2個設定すると先に読み込まれた方が有効になる

例えば、下記のように2つの同じpathを設定していたとします。

get("/sample") {
    call.respondText("sample1")
}

get("/sample") {
    call.respondText("sample2")
}

これは普通に起動できます。
そして http://localhost:8080/sample にアクセスすると、 sample1 の文字列が返ってきます。
なので多分上に書いてある方の処理が有効になってるっぽいです。

同様に、他のモジュール間で同じpathが定義されていた場合は、 application.confの modules の配列で先に書いてあるモジュールの方が優先されているようでした。
これは起動の時点でエラーにして欲しい・・・

application.confのmodulesの記述に正規表現が使えない

application.confの modules に起動するモジュールを設定する時、

modules = [ com.example.*ApplicationKt.module ]

みたいに特定のファイル名とか、特定のパッケージ配下のモジュールを全部登録する感じにしたいけど、ちょっと触った感じできなさそうでした。
モジュール追加する度に都度書き換えるのは面倒なので、ここはなんとかしたいですね。

まとめ

とりあえず今回はここまで。
今のところ感触としては

  • 基本的なREST APIを作るには問題なさそう
  • Springは重厚(Web関連以外の要素も色々あるので)なので、ちょっとしたもの作るならKtorでも良さそう
  • Kotlin製フレームワーク触るの楽しい(気持ちの問題)

という感じですかね。
まだInterceptor的な使い方とか、認証のセッション情報の保存方法の変更とか、gRPCやGraphQLへの対応とか調べきれてない部分があります。

あと

Coroutines - Quick Start - Ktor

にも書いてありますが、KtorはCoroutinesを重用しているみたいです。
たしかにちょっとフレームワークの中覗いてみただけでも結構出てきました。

今回は使い方の話だけ書いたので、この辺の仕組みとかも追々調べていければと思います。