Ktor

KtorはKotlinを使用して非同期サーバまたは非同期Webクライアントを構築できるフレームワーク。

Ktor | 公式

サーバー

概要

Ktorは様々なアプリケーションを相互接続することを目的として作られたKotlinフレームワーク。基本的にはHTTPによる相互接続になっておりサーバーとクライアントの機能を提供している。KotlinのCoroutineを使っているので非同期で処理を実行することができるのが特徴。Kotlinのマルチプラットフォーム対応にも対応しているのでJavaScriptやiOS,Android環境でも動作することができるらしい。

使用したバージョン

サーバー

helloworld

KtorlはIntelliJのプラグインを使用することで簡単にHello,Worldプロジェクトを作成できる。ちなみに、作成されたプロジェクトはデフォルトでGradleプロジェクトになっている。

デフォルトで以下のファイルが作成される。 src/Application.ktはエントリーポイントになる。

HelloWorld
├── 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

Application.kt

サーバーのエントリーポイントに当たるファイル。プロジェクトを作成した段階では以下のような内容になる。

// Application.kt
package com.example.ktor

import io.ktor.application.Application

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

main関数が定義されているので、この関数を呼び出すことでサーバーを起動することができるが、resource/application.confを使用してアプリケーションを起動する方法もある。confファイルを使用して起動した方が、アプリケーションに関する設定値をソースファイルから分離することができる。デフォルトでは以下の内容が生成される。

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

IntelliJでconfファイルを使用するにはbuild.gradleファイルに以下の行を追加し(IntelliJ プラグインでプロジェクトを作成した場合はデフォルトで出力されていた)Run -> Edit Configurations でKotlinのMainClassをio.ktor.server.netty.EngineMainにして実行する。

apply plugin: 'application'

...

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

ただ、デフォルトの内容でサーバーを起動してもエンドポイントが作成されていないのでアクセスできない。エンドポイントを以下のように追加することで、"Hello, World"を返すサーバーになる。

// application.conf
package com.example.ktor

import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.http.ContentType
import io.ktor.response.respondText
import io.ktor.routing.get
import io.ktor.routing.routing

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) {
    routing {
        get("/") {
            call.respondText("Hello World!", ContentType.Text.Plain)
        }
    }
}

Gradleに関するファイルは以下の通りで生成されている。

gradle.properties

ktor_version=1.1.2
kotlin.code.style=official
kotlin_version=1.3.20
logback_version=1.2.1

build.gradle

buildscript {
    repositories {
        jcenter()
    }

    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
    }
}

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

group 'helloworld'
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はKotlinのDSLを使って直感的にルーティングを行うことができる。ルーティングは木構造で定義することができ、リクエストがルーティングの条件と一致するまで再帰的に処理が遷移する。

例えば、以下のように書くことでルーティングを定義できる。基本的にはrouteでパスを組み立て、param, header, methodでリクエストの条件を指定し、get, post, delete, putを使用してエンドポイントの定義かつハンドラーの実装を行う感じ。

package com.example.ktor

import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.response.respondText
import io.ktor.routing.*

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) {
    // ルーティング
    routing {
        // routeでパスを組み立てる
        route("a") {
            // get, post, delete, putでエンドポイントを定義しハンドラーを実装できる
            get {
                call.respondText("GET a")
            }
            // headerでヘッダーに対する条件を指定できる
            header("name", "bookstore") {
                get {
                    call.respondText("GET a/b : has query parameter [name] value [bookstore]")
                }
            }
            // routeは入れ子にすることで階層構造にすることができる
            route("b") {
                // paramでリクエストパラメータに対する条件を指定できる
                param("name") {
                    // get, post, delete, putにパスを指定することも可能
                    get("c") {
                        call.respondText("GET a/b/c : has query parameter [name]")
                    }
                }
            }
            // { ... } を使うことでリクエストパスに含まれるパラメータを取得できる
            get("e/{username}") {
                call.respondText("GET a/b/c/d/e : has path parameter ${call.parameters["username"]}")
            }
        }
    }
}

パスパラメータ

リクエストパスにパラメータを含めることができる。パラメーターはStringとしてハンドラの中で参照することができる。

// パスの一部を { ... } で区切ることでパスにパラメータを含めることができる。

// リクエストパス:login/fogefoge レスポンス:fogefoge
get("login/{username}") {
    call.respondText(call.parameters["username"] ?: "error")
}

// リクエストパス:login/bookstore/infomation/10 レスポンス:bookstore , 10
get("login/{username}/infomation/{pagenumber}") {
    call.respondText("${call.parameters["username"]} , ${call.parameters["pagenumber"]}")
}

パスに含めるパラメータは上の例の他にも、以下のように定義することができる。

書き方 内容
{param?} オプションのパラメータ。あってもなくても良い。ある場合は値を指定した名前(例ではparam)で取得できる。
* ワイルドカード。全てのパラメータに一致する。空白不可。
{...} テイルカード。URI全体に一致する。一番最後に書く必要がある。空白可。
{param...} テイルカード。URI全体に一致する。一番最後に書く必要がある。空白可。指定した名前(例ではparam)で取得できる。

実際に書いてみると以下のようになる。注意して欲しいのはワイルドカードとテイルカードの微妙な違い。ワイルドカードの場合、パスパラメータに何もなければ404になり一致しない。一方、テイルカードはパスパラメータに何もなくても一致する。

// リクエストパス:login/optional/fogefoge レスポンス:fogefoge
// リクエストパス:login/optional レスポンス:no name
get("login/optional/{username?}") {
    call.respondText(call.parameters["username"] ?: "no name")
}

// リクエストパス:login/wildcard/fogefoge レスポンス:wildcard
// リクエストパス:login/wildcard レスポンス:(404 not found)
get("login/wildcard/*") {
    call.respondText("wildcard")
}

// リクエストパス:login/tailcard/fogefoge レスポンス:Tailcard
// リクエストパス:login/tailcard/fogefoge/foge レスポンス:Tailcard
// リクエストパス:login/tailcard レスポンス:Tailcard
get("login/tailcard/{...}") {
    call.respondText("Tailcard")
}

// リクエストパス:login/tailcard_capture/fogefoge レスポンス:[fogefoge]
// リクエストパス:login/tailcard_capture/fogefoge/foge レスポンス:[fogefoge, foge]
// リクエストパス:login/tailcard_capture_capture レスポンス:[]
get("login/tailcard_capture/{usernames...}") {
    val all: List<String>? = call.parameters.getAll("usernames")
    call.respondText(all.toString())
}

インターセプター

ハンドラの処理の前に特定の処理を割り込ませることができる。

route("admin/{loginInfo}") {
    // intercept関数でインターセプターを定義できる。
    // 後続のハンドラに処理が遷移する前に必ず実行される。
   intercept(ApplicationCallPipeline.Features) {
       // インターセプター内でパスパラメータを参照できる。
       val loginInfo = call.parameters["loginInfo"]
       // パスパラメータが期待と一致しなければリクエストを弾く
       if (loginInfo != "bookstore") {
           call.respond(HttpStatusCode.NotAcceptable)

           // 後続の処理を実行したくない場合や、インターセプター内でクライアントにレスポンスを送った場合
           // 以下の様にすることで処理を終了できる。
           return@intercept finish()
       }
   }
    get {
        call.respondText("intercept")
    }
}

インターセプター内でレスポンスを送った場合はreturn@intercept finish()を実行しないと、後続のハンドラが実行されレスポンスを2回送ろうとして以下の様に例外が発生する。

2019-02-24 19:41:27.709 [nettyCallPool-4-1] ERROR Application - 406 Not Acceptable: GET - /admin/bookstor io.ktor.server.engine.BaseApplicationResponse$ResponseAlreadySentException: Response has already been sent
    at io.ktor.server.engine.BaseApplicationResponse.commitHeaders(BaseApplicationResponse.kt:40)
    (以下略)

intercept関数は第一引数にPipelinePhaseを、第二引数に割り込み処理のラムダを受け取る。PipelinePhaseはフェーズの名前を表現するクラスでインターセプターを作る場合にはApplicationCallPipeline.Featuresが使われる。

applicationcall

ハンドラやインターセプターにおいてApplicationCallを取得する事が出来る。ApplicationCallはKtorにとってとても重要なクラスで、ほとんどの機能はこのクラスによって実現されているらしい。

get {
    // call によって ApplicationCall のインスタンスを取得することができる。
    call.respondText("Hello, World")
}

ApplicationCall | GitHub

request

requestはその名前の通り、リクエストに関する情報。ApplicationCallを通じてアクセスすることができる。

ハンドラやインターセプター内で取得することができる。取得するにはcall.requestと呼び出せば良い。例えばこの様にアクセスできる。

get("/") {
    // request からリクエストの情報を取得することができる
    val uri: String = call.request.uri
    val headers: Headers = call.request.headers
    val cookies: RequestCookies = call.request.cookies
    val httpMethod: HttpMethod = call.request.httpMethod

    // 内部のコンテキストも取得することができる
    val pipeline: ApplicationReceivePipeline = call.request.pipeline
}

リクエストパラメーター

request.queryParametersとすることでクエリパラメータにアクセスできる。例えば、?param1=value1&param2=value2というクエリパラメータがあった場合、それぞれコレクションの様に保持され以下の様にアクセスできる。当然ながら、該当するクエリパラメータがない場合もあるので、戻り値はNull許容型になる。

get("/") {
    val value1: String? = call.request.queryParameters["param1"]
}

複数のクエリパラメータをリストとして受け取る場合はgetAllを使う。例えば?param1=a&param1=b&param1=cの様なクエリパラメータの場合、以下の様になる。

get("/") {
    val value1 = call.request.queryParameters.getAll("param1")
    print(value1) // [a, b, c]
}

フォーム

フォーム値はcall.receiveParametersとして受け取ることができる。フォーム値はStringのマップによって表現されているみたいで、クエリパラメータ同様call.receiveParameters[" フォーム値名 "]で取得可能。

例えば以下の様にname=bookstore , age=25とPOSTすると...

POST / HTTP/1.1
Host: localhost:8080
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
cache-control: no-cache
Postman-Token: bc85d43d-5575-4aaf-b1bb-9b707d1ab012

Content-Disposition: form-data; name="name"

bookstore

Content-Disposition: form-data; name="age"

25
------WebKitFormBoundary7MA4YWxkTrZu0gW--

以下の様に取得できる。

post("/") {
    val receiveParameters = call.receiveParameters()
    val userName: String? = receiveParameters["name"]
    val userAge = receiveParameters["age"]
    call.respond("Hey $userName, your age is $userAge")
}

response

responseはレスポンスを表すインスタンス。このインスタンスを通じてクライアントに対してレスポンスを行う。

例えば単純にテキストをクライアントに返すにはこうする。

get("/") {
    call.respondText("Hello, I`am Ktor")
}

以下の様に書くこともできるが、通常は上記の様な便利な関数を使うだろう。ちなみに、コンテンツタイプをセットする機能は見当たらなかった。responseでは自動的にコンテンツタイプが付与される見たい。

get("/") {
    val applicationCall = call.response.call
    call.response.status(HttpStatusCode.OK)
    call.response.pipeline.execute(applicationCall, "Hello, I`am Ktor")
}

ラムダを渡すことで非同期でレスポンスを返すこともできる。

get("/") {
    call.respondText { 

        // なんか長い処理...

        "Hello, I`am Ktor"
    }
}

他にもいろんな便利なメソッドが用意されている。

メソッド 内容
status(HttpStatusCode.OK) HTTPステータスコードを設定
status(HttpStatusCode(999, "Hey!") HTTPステータスコード(カスタム)を設定
status() 現在設定されているHTTPステータスコードを取得
header("X-Custom-Header", "Hey!") ヘッダーを設定
etag("283882kdkks2j2j3h2j...") etagを設定
lastModified(ZonedDateTime.now()) Last-Modifiedヘッダーを設定
contentLength(1024L) Content-Lengthを設定。通常は明示的に設定しなくても自動的に設定される。
cacheControl(CacheControl.NoStore(CacheControl.Visibility.Private)) Cache-Controlヘッダーを設定
expires(LocalDateTime.now()) Expiresヘッダーを設定

リダイレクト

responseを使ってリダイレクト処理を行うことができる。

リダイレクト | MDN

respndRedirectの第一引数はリダイレクト先で、第二引数のpermanentにBooleanで恒久的か一時的かを設定できる。

例えばこの様に実装できる。

routing {
    get("/") {
        call.respondRedirect("/redirect", true)
    }
    get("/redirect") {
        call.respondText {
            "You Redirected"
        }
    }
}