サーバサイドKotlinをはじめよう。Ktor Tips集をまとめた。


この記事はCyberAgent Developers Advent Calendar 2018の22日目の記事です。

Ktor

KtorはKotlin純正のWebフレームワークです。APIはラムダ式を使った関数呼び出しでKotlin DSLによりルーティングを宣言的なコードで保守できます。KtorアプリケーションはTomcatなどのServlet 3.0またはスタンドアローンなJetty、Nettyなどのサーブレットコンテナでホスティングできます。KotlinConf 2018(2018/10)で1.0.0のベータが紹介され11月にはKtor 1.0.0の正式リリースを発表しました。2018/12時点では1.0.1が利用できます。

Ktor - asynchronous Web framework for Kotlin

モチベーション

これまで私はサーバサイド Kotlinを開発するフレームワークにはSpring Bootを選択してきましたがマイクロサービスを俯瞰してみると軽量なマイクロフレームワークを採用したいという気持ちがありました。例えばDBアクセスが必要ないマイクロサービスであればAPIルーティングをストレスなく開発できるフレームワークのほうが向いているかもしれません。

Spring BootもRouter Functional DSLやSpring Fuが登場するなどニーズにあった選択ができるようになってきています。しかしKotlin Nativeの登場によってiOS/Android/Serverのリソースが共有できる環境になると純正Kotlinを採用しているKtorを採用したほうが今後の潮流に順応できそうです。またマルチプラットフォームにKtorのライブラリが採用されているドキュメントなどが今後更に増えていくことを考慮するとKtorを採用することは理にかなっていると言えるでしょう。

このエントリではWebサーバのAPI開発のユースケースがKtorではどのように解決されているのかをまとめていきます。サーバサイド Kotlinをはじめようとする皆さんにとって、これから更に注目されるKtorを利用する手助けになれば幸いです。

ユースケースをまとめる

新しいフレームワークでWebサーバのAPI開発を行うときには次のようなユースケースがどのようにサポートされているか気になります。

Ktorではどのように実装するのでしょうか。順を追って見ていきましょう。

APIルーティング

簡単なEchoサーバを作っていきましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fun main(args: Array<String>) {
    embeddedServer(Netty, 8008) {
        routing {
            get("echo") {
                call.apply {
                    respond(request.queryParameters["q"] ?: "none")
                }
            }
        }
    }.start(wait = true)
}

/echo?q=helloにGETリクエストを送るとレスポンスにhelloと返すAPIが出来上がりました。

APIをモジュール化する

上記のコードではmain関数にroutingを宣言していて今後APIが増えていくことを考慮すると見通し良いコードに改善すべきです。
KtorではAppicationクラスに拡張関数を生やすことでAPIをモジュール化できます。

1
2
3
4
5
6
7
8
9
fun Application.echoModule() {
    routing {
        get("/echo") {
            call.apply {
                respond(request.queryParameters["q"] ?: "none")
            }
        }
    }
}

routingもApplicationクラスの拡張関数のためechoModuleの拡張関数の中でroutingが宣言できます。 このechoModuleをresources/application.confに追加しましょう。

1
2
3
4
5
6
7
8
ktor {
  deployment {
    port = 8080
  }
  application {
    modules = [ com.github.soushin.ktorsample.ApplicationKt.echoModule ]
  }
}

application.confドリブンでKtorを動くように定義したのでmain関数は次のように変化します。

1
2
3
fun main(args: Array<String>) {
    embeddedServer(Netty, commandLineEnvironment(args)).start(wait = true)
}

このようにAPIをモジュール化することでマイクロサービス内のAPIルーティングをエンドクライアントごとに分けることができます。InternalModuleやExternalModuleなど内部向け、外部向けのAPIをモジュールで切り出すことでコードの保守性が高まります。

Routingを見通しよくする

echoModule()ではroutingのDSLを直接記述していましたがRouteクラスに拡張関数を生やすことでroutingをパスごとに切り出せます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Location("/echo")
class Echo(val q: String? = null)

fun Route.echo() {
    get<Echo> {
        call.apply {
            respond(request.queryParameters["q"] ?: "none")
        }
    }
}

パスをget(“/echo”)のようにStringで定義していましたが@Locationを使うことでタイプセーブにパスを定義できます。 echo()拡張関数を次のようにechoModule()に定義します。

1
2
3
4
5
6
fun Application.echoModule() {
    install(Locations)
    routing {
        echo()
    }
}

タイプセーフにLocationsクラスを利用したことでKtorの機能をモジュールにインストールする必要があります。install(Locations)でLocations機能をインストールしています。

Application Features

Ktorは多くの機能(ApplicationFeature)をラインナップしています。後述するユースケースではApplicationFeatureとともに紹介していきます。

https://ktor.io/servers/features/index.html

環境変数マッピング

コンテナでアプリケーションを動かす場合には開発環境や本番環境などのDB設定値を環境変数に入れてアプリケーション側から環境変数を参照することで環境を切り分けます。Ktorでは環境変数をアプリケーションでどのように参照するのかまとめていきます。

application.confに変数を定義する

1
2
3
4
5
6
7
app {
    github {
      baseUrl = "https://api.github.com"
      token = "token"
      token = ${?KTOR_APP_GITHUB_TOKEN}
    }
}

上記のようにアプリケーションで参照する変数をapplication.confに定義しました。app.github.tokenはデフォルト値としてtokenが定義され環境変数のKTOR_APP_GITHUB_TOKENが参照できればその値で上書きされます。

Applicationクラスで環境変数を参照する

APIルーティングで作成したechoModuleはApplicationクラスの拡張関数なのでenviromentプロパティのApplicationConfigを参照することができます。

1
2
3
fun Application.echoModule() {
    val token = environment.config.property("app.github.token").getString()
}

上記のようにenvironmentを参照できますがenvironmentはApplicationクラスのプロパティなので次のような拡張プロパティを定義できます。

1
2
val Application.gitHubToken
        get() = environment.config.property("app.github.token").getString()

拡張プロパティを定義することで冗長なコードを防ぎコードの短縮化の恩恵も受けれました。

1
2
3
4
5
import com.github.soushin.ktorsample.config.Config.gitHubToken

fun Application.echoModule() {
    val token = gitHubToken
}

インターセプター

APIにリクエストが来て本処理をする前や本処理をした後などに処理を挟むインターセプターの実装方法を見ていきましょう。このエントリではリクエストログを出力することを目的にリクエスト内容に含まれる情報をインターセプターで取得してみます。

ApplicationのEchoモジュールにinterceptを追加しましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fun Application.echoModule() {
    val logger = KotlinLogging.logger("RequestLogger")
    install(Locations)
    intercept(ApplicationCallPipeline.Monitoring) {
        call.attributes.put(
            loggerAttributeKey,
            RequestLogBuilder()
                .name(call.request.uri)
                .userAgent(call.request.userAgent())
                .remoteAddr(call.request.header("X-Forwarded-For") ?: "-")
        )
        try {
            proceed()
        } catch (e: Exception) {
            logger.error { call.logBuilder.userId(call.user?.id).build() }
            throw e
        }
        logger.info { call.logBuilder.success(true).userId(call.user?.id).build() }
    }
    routing {
        echo()
    }
}

interceptの引数にはPipelinePhaseオブジェクトを渡します。ApplicationCallPipeline.Monitoringはロギングやメトリクス向きのフェーズ定義です。
KtorのPipelineは適切な場所で機能を追加することができる拡張メカニズムです。KtorアプリケーションにはSetup, Monitoring, Feature, Call, Fallbackの5つの主要なフェーズが定義されています。

リクエスト本処理前のインターセプト処理

call.attributes.putではApplicationCallのattributeプロパティに自作のRequestLogBuilderをセットしています。ここまででリクエスト本処理の直前のインターセプト処理を挟んでいます。インターセプト処理はRequestLogBuilderにリクエストURIやユーザエージェントなどをセットアップしています。 loggerAttributeKeyは次のようにどこからでも参照できる形で変数として定義しています。

1
val loggerAttributeKey = AttributeKey<RequestLogBuilder>("loggerAttributeKey")

このloggerAttributeKeyをルーティングのほうで参照することでインターセプト処理でセットしたRequestLogBuilderを取得できるようにしています。

1
2
3
4
5
6
7
8
fun Route.echo() {
    get<Echo> {
        call.apply {
            attributes.getOrNull(loggerAttributeKey)?.elem("q" to request.queryParameters["q"])
            respond(request.queryParameters["q"] ?: "none")
        }
    }
}

echoルーティングでApplicationCallのattributeプロパティからRequestLogBuilderを取得してクエリストリングをセットしています。

call.attributes.getOrNull〜は冗長なコードになりそうなのでApplicationCallに拡張プロパティを生やしましょう。

1
2
val ApplicationCall.logBuilder
    get() = this.attributes[loggerAttributeKey]
1
2
3
4
5
6
7
8
fun Route.echo() {
    get<Echo> {
        call.apply {
            logBuilder.elem("q" to request.queryParameters["q"]) // 拡張プロパティ logBuilderを参照する
            respond(request.queryParameters["q"] ?: "none")
        }
    }
}

リクエスト本処理後のインターセプト処理

次にリクエスト本処理後のインターセプト処理を見ていきましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
fun Application.echoModule() {
    // -- 省略 --
    intercept(ApplicationCallPipeline.Monitoring) {
        // -- 省略 --
        try {
            proceed()
        } catch (e: Exception) {
            logger.error { call.logBuilder.userId(call.user?.id).build() }
            throw e
        }
        logger.info { call.logBuilder.success(true).userId(call.user?.id).build() }
    }
    // -- 省略 --
}

proceed()がリクエストの本処理になります。proceedをtry-catchで囲みエラーが発生したらErrorレベルでログを出力してExceptionをスローしています。エラーにならなければInfoレベルでログを出力しています。

エラーハンドリング

アプリケーション開発ではアーキテクチャに沿ってレイヤーを用意することが一般的です。各レイヤーでエラーが発生した時にはその場で例外クラスをスローしてアプリケーションがよしなにクライアントにエラーレスポンスを返すことができればコードの見通しがよくなります。
次のコードは極端な例外クラスをスローしているルーティングコードです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Location("/errors")
class Errors() {
    @Location("/404")
    class NotFound

    @Location("/400")
    class BadRequest

    @Location("/500")
    class InternalServerError
}

fun Route.error() {
    get<Errors.NotFound> { throw NotFoundException("not found") }
    get<Errors.BadRequest> { throw BadRequestException("bad request") }
    get<Errors.InternalServerError> { throw InternalServerErrorException("internal server error") }
}

/errors/404へGETリクエストを送るとアプリケーションはNotFoundExceptionの例外クラスをスローします。このような例外クラスをアプリケーションの各レイヤーでスローして例外クラスに応じたエラーレスポンスを返す処理を集約させることができます。

StatusPagesをつかう

errorModuleにStatusPagesの機能をインストールします。exceptionの型パラメーターに例外クラスを渡してラムダで例外クラスを処理しています。ラムダ内ではApplicationCallクラスを参照できるのでレスポンスを組み立てることができます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
fun Application.errorModule() {
    install(Locations)
    install(ContentNegotiation) {
        jackson {
            configure(SerializationFeature.INDENT_OUTPUT, true)
        }
    }
    install(StatusPages) {
        exception<SystemException> { cause ->
            call.response.status(cause.status)
            call.respond(cause.response())
        }
    }
    routing {
        error()
    }
}

SystemExceptionクラスは次のようにNotFoundExceptionクラスの基底クラスとしています。

1
2
3
4
5
6
7
8
9
abstract class SystemException(message: String, ex: Exception?) : RuntimeException(message, ex) {
    abstract val status: HttpStatusCode
    fun response() = HttpError(code = status.value, message = message ?: "error")
}

class NotFoundException : SystemException {
    constructor(message: String) : super(message, null)
    override val status: HttpStatusCode = HttpStatusCode.NotFound
}

SystemExceptionクラスを基底クラスにすることで例外クラスの分岐が1つだけになりエラーハンドリングがシンプルになりました。

認証処理

KtorではAPIリクエストの認証処理を簡単に行えるApplicationFeatureが用意されています。今回は認証にJWTを使ったコードをまとめます。
JWTのトークンにはユーザIDが含まれている想定です。

Authenticationを使う

authModuleにAuthenticationします。Authentication.Configurationの拡張関数であるjwtを引数に認証処理を定義しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fun Application.authModule() {
    install(Authentication) {
        jwt {
            verifier(JWT.require(Algorithm.HMAC256(jwtSecret)).build())
            validate { credential ->
                User.toUser(JWTPrincipal(credential.payload))
            }
        }
    }
    routing {
        auth()
    }
}

verifierでJWTのVerificationをビルドします。jwtSecretはApplicationクラスの拡張プロパティで環境変数で定義したJWTのsecretを参照しています。
validateは認証処理が成功した後で複合された認証情報から独自のUserクラスを生成しています。

1
2
3
4
5
6
7
data class User(val id: String): Principal {
    companion object {
        fun toUser(principal: JWTPrincipal) = User(
            principal.payload.getClaim("id")?.asString() ?: throw IllegalStateException("unauthorized")
        )
    }
}

独自のUserクラスにPrincipalのインターフェースをマークすることでJWTのvalidateを通してApplicationCall#principal()にUserクラスがセットされます。

リクエストに認証処理を追加する

authModuleのroutingに定義したauthルーティングのコードです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Auth() {
    @Location("/who")
    class Who
}

fun Route.auth() {
    authenticate {
        get<Auth.Who> { call.respond("Success, userId=${call.user?.id}") }
    }
}

authenticateはRouteクラスの拡張関数です。authenticate { }で括ることで認証処理が追加できます。
call.userはApplicationCallの拡張プロパティで先述したprincipalにセットされたUserクラスを参照しています。

1
2
val ApplicationCall.user
    get() = this.authentication.principal<User>()

DI

DIにはKoinを使います。KoinではなくてもDIできますがKtorにはApplicationクラスにinstallKoinの拡張関数が用意されているので導入が簡単にできます。

次のようなUseCaseクラスにDIする手順を確認していきましょう。

1
2
3
4
interface PullRequestRepository {
    fun getPullRequests(owner: String, repo: String, state: String, perPage: Int):
            List<PullRequest>
}

PullRequestRepositoryインターフェースクラスを用意します。このPullRequestRepositoryを実装したクラスが次のPullRequestRepositoryImplクラスです。

1
2
3
4
class PullRequestRepositoryImpl(private val client: GitHubClient) : PullRequestRepository {
    override fun getPullRequests(owner: String, repo: String, state: String, perPage: Int) =
        client.get(GetPullRequests(owner, repo, state, perPage))
}

PullRequestRepositoryImplのコンストラクタにはGitHubClientのインスタンスが必要になります。GitHubClientは次のようなに定義しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class GitHubClient(
    override val baseUrl: String,
    override val token: String,
    override val client: FuelManager = FuelManager.instance
) : Client() {
    override fun error(response: Response) {
        when {
            response.isClientError -> throw BadRequestException("GitHubClient error, ${response.responseMessage}")
            else -> throw InternalServerErrorException("GitHubClient error, ${response.responseMessage}")
        }
    }
}

PullRequestRepositoryがGitHubClientに依存しています。この依存関係をKoinで定義しましょう。

1
2
3
4
5
6
val usecaseModule = module {
    // client
    single { GitHubClient(getProperty("github_base_url"), getProperty("github_auth_token")) }
    // repository
    single<PullRequestRepository> { PullRequestRepositoryImpl(get()) }
}

KoinはDI定義をmodule毎に分割できます。moduleに名前をつけて依存解決をする時に名前を参照できます。今回のエントリでは1つのmoduleを定義しています。
singleで単一インスタンスを保証するsingletonを定義できます。getPropertyはKoinの機能でmodule内のプロパティを一意に保ちます。依存注入時にプロパティに変数を定義します。そして最後にget()です。get()はmodule内の依存関係を判断して型推論してくれます。

DiModuleをつくる

ここまではKoinの利用方法を見てきました。ここからはKtorに定義したDI Moduleを使うコードを見ていきましょう。

次のようにApplicationクラスの拡張関数としてdiModuleを作ります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
fun Application.diModule() {
    val usecaseModule = module {
        // client
        single { GitHubClient(getProperty("github_base_url"), getProperty("github_auth_token")) }
        // repository
        single<RepoRepository> { RepoRepositoryImpl(get()) }
        single<PullRequestRepository> { PullRequestRepositoryImpl(get()) }
        // useCase
        single<RepositoryUseCase> { RepositoryUseCaseImpl(get(), get()) }
    }
    installKoin(
        listOf(usecaseModule),
        extraProperties = mapOf(
            "github_base_url" to gitHubBaseUrl,
            "github_auth_token" to gitHubToken
        )
    )
}

先程のコードから少し依存関係が増えましたが新しく利用している機能はありません。
installKoinがKtorで利用できる拡張関数でKoinの利用に必要なstartKoin()を抽象化しています。installKoinの第一引数には定義したモジュールを渡します。名前付き引数のextraPropertiesでgetPropertyで参照する変数を定義しています。

なぜモジュールで切り出すのか?

DI定義をdiModuleに切り出している理由は次の章で紹介するKtorのテストに関連します。モックを用いてテストするためにDI定義をモジュールで切り出してテスト時にはモック化したインスタンスで依存性の注入を行います。

モジュールで切り出したのでapplication.confに追加します。

1
2
3
4
5
6
7
8
ktor {
  deployment {
    port = 8080
  }
  application {
    modules = [ com.github.soushin.ktorsample.ApplicationKt.diModule, com.github.soushin.ktorsample.ApplicationKt.mainModule ]
  }
}

mainModuleは次のセクションで説明します。

Injectする

最後に定義した依存関係を利用するコードを紹介します。application.confのmodulesに追加したmainModuleは次のようになります。

1
2
3
4
5
6
7
8
fun Application.mainModule() {

    val usecase by inject<RepositoryUseCase>()

    routing {
        repository(usecase)
    }
}

diModuleで定義したsingle<RepositoryUseCase> { RepositoryUseCaseImpl(get(), get()) }のsingletonがinject<RepositoryUseCase>()によって参照できます。

APIテスト

APIテストにはTestApplicationEngineを利用します。テストコード内でTestApplicationEngineのインスタンスを生成して拡張関数のhandleRequestを呼び出しレスポンスを検証します。実際のコードをまとめていきます。

Echoサーバをテストする

「APIルーティング」のところで実装したEchoサーバをテストします。

JUnitを利用したテストコードです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class EchoTest {

    private lateinit var engine: TestApplicationEngine

    @Before
    fun before() {
        engine = TestApplicationEngine().apply {
            start(wait = false)
            application.echoModule()
        }
    }

    @Test
    fun testEcho() {
        with(engine) {
            handleRequest(HttpMethod.Get, "/echo?q=hello").response.apply {
                assertEquals("hello", content)
                assertEquals(HttpStatusCode.OK, status())
            }
        }
    }
}

beforeでTestApplicationEngineのインスタンスを生成しengine変数に格納しています。apply内で定義済みのEchoモジュールであるapplication.echoModule()を呼び出しています。これによりテストコード内のTestApplicationEngineにEchoモジュールが有効となります。

testEcho() は/echo/q=helloのエンドポイントをテストしているコードです。with(engine)を用いることでTestApplicationEngineをレシーバとしてメソッドを呼び出すことができます。拡張関数のhandleRequestを呼び出しレスポンスを検証しています。

KtorのAPIテストの大枠はテストコード内でTestApplicationEngineの変数を定義してTestApplicationEngineのインスタンス生成時にAPIモジュールを呼び出すことでテストを行いたいAPIを有効にしたengineを用いてエンドポイントの検証を行います。

DIが必要なAPIのテスト

先述のようなEchoサーバはシンプルなAPIですが一般的なAPIはDIを用いてビジネスロジックは別のクラスで行いルーティング層のコードはビジネスロジックのクラスを呼び出すような形になります。このようなDIが必要なAPIのテストはどのように行うのかまとめていきます。

「DI」のところで実装したコードをテスト対象にします。また外部APIをコールすることになりますモックが必要です。モックにはmockkを利用します。

「DI」のところではdiModuleを切り出した理由がここにあります。次のコードのようにDIが絡むAPIモジュールにはテスト用のDIモジュールをテストコード内で定義します。そして先述したとおりTestApplicationEngineのインスタンス生成時にテスト用のdiModuleを呼び出します。コードは次のようになります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
class RepositoryTest {

    private lateinit var engine: TestApplicationEngine

    // ①
    fun Application.diModuleForTest() {

        val gitHubClientForRepository = mockk&lt;Client>().apply {
            every { executeRequest(any()).statusCode } returns 200
            every { executeRequest(any()).data } returns REPOSITORIES_RESPONSE.toByteArray()
        }.let {
            FuelManager().apply { client = it } // ②
        }.let {
            GitHubClient(GITHUB_BASE_URL, GITHUB_TOKEN, it)
        }

        val gitHubClientForPullRequest = mockk&lt;Client>().apply {
            every { executeRequest(any()).statusCode } returns 200
            every { executeRequest(any()).data } returns PULL_REQUESTS_RESPONSE.toByteArray()
        }.let {
            FuelManager().apply { client = it } // ②
        }.let {
            GitHubClient(GITHUB_BASE_URL, GITHUB_TOKEN, it)
        }

        // ③
        val usecaseModule = module {
            // useCase
            single&lt;RepositoryUseCase> {
                RepositoryUseCaseImpl(
                    RepoRepositoryImpl(gitHubClientForRepository),
                    PullRequestRepositoryImpl(gitHubClientForPullRequest)
                )
            }
        }

        // ④
        installKoin(
            listOf(usecaseModule),
            extraProperties = mapOf(
                "github_base_url" to gitHubBaseUrl,
                "github_auth_token" to gitHubToken
            )
        )
    }

    @Before
    fun before() {
        engine = TestApplicationEngine().apply {
            // ⑤
            (environment.config as MapApplicationConfig).apply {
                // Set here the properties
                put("app.github.baseUrl", GITHUB_BASE_URL)
                put("app.github.token", GITHUB_TOKEN)
            }
            start(wait = false)
            application.diModuleForTest() // ⑥
            application.mainModule()
        }
    }

    // -- 省略 --

}

上記のコードによりHTTPクライアントのリクエストがモック化されたRepositoryクラスをDIしたmainModule()がテストコード内で有効になりました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class RepositoryTest {

    // -- 省略 --

    @Test
    fun testRepository() {
        with(engine) {
            handleRequest(HttpMethod.Get, "/repository").response.apply {
                assertEquals(HttpStatusCode.OK, status())
            }
        }
    }

    // -- 省略 --
}

with(engine)を用いてmainModule()のエンドポイントを検証しています。

まとめ

コード

このエントリで紹介したコードはこちらのレポジトリから参照できます。