雑に描いた餅

餅は餅屋、わたしは技術屋、と言いたかった。

Spring Bootで実装したアプリケーションをKtorで置き換えてみる

Ktorについて

KtorはKotlinを利用した軽量なWeb Frameworkです。

https://ktor.io/

現在はJVM上で動作するサーバー、クライアントだけでなく、javascriptiOSAndroid上で動作するクライアントをサポートしています。

今後はNative Environmentでのサポートも行っていくということで、今後が楽しみなフレームワークの一つです。

この記事は

今回は以前Spring Bootで実装したアプリケーションをこのKtorを利用して置き換えてみようというのが記事の主旨です。

置き換える中で、Ktorの特徴やSpring Bootと比べた時の違いなどに触れられたら良いなと思っています。

実際のコードは以下のリポジトリにて公開しています。一つ目がSpring Bootで実装された元のプロジェクト、二つ目がKtorで置き換えた後のプロジェクトです。

github.com

github.com

対象読者

  • Kotlinを使ってweb applicationを書いたことがある、もしくは書いてみたい
  • ktorになんとなく興味がある、spring boot等他のフレームワークと比較してみたい

Ktorのセットアップ

以下のQuickstartに沿って進めます。今回は元のプロジェクトがMavenを利用していたので、同じくMavenを利用して開発を進めます。

https://ktor.io/quickstart/quickstart/maven.html

まずktorを利用してサーバーを起動させるのに必要な依存関係を追加していきます。

ktorではどのようなサーバーエンジンを利用するかを開発者が選択することができます。選択肢としては、Netty、Jetty、Tomcatが代表的なものです。

今回はNettyをエンジンとして利用するため、核となる ktor-server-core に加えて、 ktor-server-netty を依存関係として追加します。

<project>
    <repositories>
        <repository>
            <id>jcenter</id>
            <url>https://jcenter.bintray.com</url>
        </repository>
    </repositories>
    ...
    <dependency>
        <groupId>io.ktor</groupId>
        <artifactId>ktor-server-core</artifactId>
        <version>${ktor.version}</version>
    </dependency>
    <dependency>
        <groupId>io.ktor</groupId>
        <artifactId>ktor-server-netty</artifactId>
        <version>${ktor.version}</version>
    </dependency>
    ...
</project>

なおMaven Repositoryとしてjcenterを追加してやる必要があるので、忘れずに追加してやりましょう。

Ktorを利用してサーバーを起動する

Ktorでは以下のようにmain関数の中にサーバーを起動させるDSLを記載することで、簡単にHTTPサーバーを起動させることができます。

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>) {
    val server = embeddedServer(Netty, 8080) {
        routing {
            get("/") {
                call.respondText("Hello, world!", ContentType.Text.Html)
            }
        }
    }
    server.start(wait = true)
}

少し上記のコードを掘り下げてみます。

embeddedServer 関数には、第一引数にサーバーエンジンのFactory、第二引数にリクエストをリッスンするポート番号を渡してやることができます。 第三引数はデフォルトで 0.0.0.0 が渡されるようになっており、基本的には省略されます(上の例でも省略されています)。 そして第四引数に、アプリケーションコードを書いていくことになります。

上記のように作られたサーバーのインスタンスstart関数で起動させることで、 8080番ポートの/にGETのリクエストを投げると、"Hello, world!"という文字列を投げ返すようなセルフホストなサーバーを作ることができました。簡単ですね。

JSON

初期状態では、リクエストをJSON形式で受け取ったり、レスポンスをJSON形式で返したりすることができません。

Ktorでは、Featuresと呼ばれる機能を追加することで、これらの追加機能を実現しています。

JSONへのシリアライズ、デシリアライズは以下のサンプルのようにContent NegotiationというFeatureを利用することで実現されます。

    embeddedServer(Netty, 8080) {
        install(ContentNegotiation) {
            jackson {
            }
        }
        routing {
            get("/systems/ping") {
                call.respond(mapOf("message" to "pong"))
            }
        }
    }.start(wait = true)

なおJacksonを利用する場合には以下のように依存関係に追加してやる必要があるので忘れないようにします。

<project>
    ...
    <dependencies>
        <dependency>
            <groupId>io.ktor</groupId>
            <artifactId>ktor-jackson</artifactId>
            <version>${ktor.version}</version>
            <scope>compile</scope>
        </dependency>
    </dependencies>
</project>

上記の結果、/systems/pingに対してGETのリクエストを投げると、以下のようにJSON形式でレスポンスを得ることができるようになりました。

$ curl localhost:8080/systems/ping
{"message":"pong"}

LocalDateTime型を任意の形にフォーマットする

Spring Bootで実装していた際にはよしなにされていたもののKtorではうまく行かなかった部分として、JSON内の日付型のやりとりがあります。

例えばLocalDateTime型の値をサーバーから受け取る際、以下のように出力されてしまうという問題が発生していました。

"createdAt":{"dayOfWeek":"WEDNESDAY","dayOfYear":1,"month":"JANUARY","nano":0,"year":2020,"monthValue":1,"dayOfMonth":1,"hour":12,"minute":0,"second":0,"chronology":{"id":"ISO","calendarType":"iso8601"}}

LocalDateTimeの情報としては十分以上に受け取ることができていますが、そこまで必要ではなかったり、任意のフォーマットに変換してやりたくなりますね。

Ktorでは先ほどのjacksonブロック内に、JacksonのObjectMapperを設定するようなコードを書くことができます。

より正確にいえば、内部にObjectMapperをレシーバーとした関数を書くことができるので、以下のようにして日付を任意の形式にフォーマットするようなコードを書くことができます。

        install(ContentNegotiation) {
            val javaTimeModule = JavaTimeModule()
            javaTimeModule.addSerializer(LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")))
            jackson {
                registerModule(javaTimeModule)
            }

        }

(なおここで利用しているJavaTimeModuleはjackson-datatype-jsr310というライブラリに入っているので、依存関係を追加してやる必要があります。)

上記のようなmoduleを追加してやることで、以下のような出力を得ることができるようになりました。

"createdAt":"2020-01-01T12:00:00.000Z"

ルーティング

Ktorでは上記の例でも少し登場したroutingブロック内にルーティング関数を追加していくことで、ルーティングの定義を追加することができます。

今回は実際のコード例を利用しながら、Ktorを利用したルーティングについて解説してきます。

概要

上記のroutingブロックの中では、Routingクラスというクラスをレシーバーにした関数を書くことができます。

RoutingクラスはRouteクラスの拡張先の一つであり、Routeクラスには以下のようなルーティング関数が用意されています。

route関数

Routeに対してルーティングを定義します。

例えば以下のような実装であった場合、"/systems/ping"というルートに対して、ブロック内部の処理をバインディングしています。

        routing {
            route("/systems/ping") {
                call.respond(mapOf("message" to "pong"))
            }
        }

またroute関数はネストさせることができるため、以下のように共通するパスに対する処理を一つにまとめることができます。

        routing {
            route("/api/articles") {
                get("/") {
                    // 記事の一覧を取得する処理
                }
                post("/") {
                    // 新しく記事を登録する処理
                }
            }
        }

get/post/put/delete等各種HTTPメソッドに対応した関数

Ktorでは上記の他に、GET/POST/PUT/DELETE等各種HTTPメソッドに対応したルーティング関数が用意されています。

例えば以下の関数では"/systems/ping"に対するGETのリクエストに対して、ブロック内部の処理をバインドしています。

        routing {
            get("/systems/ping") {
                call.respond(mapOf("message" to "pong"))
            }
        }

Path Parameter

リクエストのパス内部にある変数を取得したい場合、以下のように{}で囲まれた変数と、callという変数の中にあるパラメータからパスパラメータを取得することができます

    route("/api/articles") {
            route("/{slug}") {
                get("/") {
                    val slug = call.parameters["slug"]!!
                    call.respond(articleUsecase.getArticle(slug).convertToArticleResponse())
                }
    }

上記の例では、/api/articles/{slug}というリクエストについて、slug部分に入る値をパラメータとして取得することができます。

Request Header

リクエストヘッダーの値を取得したい場合、パスパラメータの場合と似ているのですが、routeブロック内のcall変数から取得することができます。

        delete("/") {
            val slug = call.parameters["slug"]!!
            val authorizationHeader = call.request.headers["Authorization"]!!
            val userId = userService.getUserId(authorizationHeader)
            articleUsecase.delete(slug, userId)
        }

上記はリクエストヘッダのうちAuthorizationというキーの値を取得しようとしています。

Request/Response Body

リクエストボディやレスポンスボディは以下のように、リクエストやレスポンスに対応させたいClassを指定することができます。

これに加えて上記のJacksonのFeatureを有効にしていると、Classに対応した型からJSONへのシリアライズ、デシリアライズを行うことができるようになります。

        post("/") {
            val request = call.receive<ArticleRequest>()
            val authorizationHeader = call.request.headers["Authorization"]!!
            val userId = userService.getUserId(authorizationHeader)
            call.respond(
                articleUsecase.createNewArticle(userId, request.title, request.body).convertToArticleResponse()
            )
        }

少しわかりにくいかもしれませんが、call.receive<T>()の呼び出しでリクエストに対応させたいT型を指定し、レスポンスの型としてcall.respond(T)としてT型のインスタンスを渡してやっています。


個人的にはSpring BootやSpring Frameworkのようにアノテーションベースで記載をする方が見慣れているので、上記のようなDSLの記載には初めは若干戸惑いました。

慣れてくるとシンプルに見えてくるので、そこまで大きな問題はなくスムーズに開発ができたように思います。

Dependency Injection

Ktorにはデフォルトでは依存性の注入(Dependency Injection, 以下DI)をサポートする機能がありません。

そこで今回はKotlin製のDIコンテナであるKodeinを用いてDIを行うことにします。

(因みにKodeinはKOtlin Dependency INjectionのacronymみたいです。)

https://kodein.org/Kodein-DI/

val articleDependency = Kodein {
    bind<ArticleDriver>() with singleton { InMemoryArticleDriver() }
    bind<IArticleRepository>() with singleton { ArticleRepository(instance()) }
    bind<ArticleUsecase>() with singleton { ArticleUsecase(instance()) }
}

上記のコードの一行目では、ArticleDriverという型、インターフェースについて、InMemoryArticleDriverをシングルトンで注入する、というバインディングをおこなっています。

詳細は別の記事でもまとめようかと思いますが、Kodeinを利用することで、これまで@Component@Autowiredを駆使しておこなっていたDIを上記のようにすっきりと行うことができるようになりました。

Exception Handling

Springでは@ExceptionHandlerアノテーションを利用することで、特定の例外が発生した場合にどのようなレスポンスを返すか実装を決めることができました。

また@ControllerAdviceアノテーションによって、複数のControllerにまたがる例外処理を共通化することもできました。

Ktorでこのような実装を行いたい場合、先ほどJacksonをContentNegotiationとして追加したように、StatusPagesというFeatureを追加してやる必要があります。

fun main(args: Array<String>) {
    embeddedServer(Netty, 8080) {
        ...
        install(StatusPages) {
            exception<ArticleNotFoundException> { call.respond(HttpStatusCode.NotFound) }
        }
        routing {
            get("/systems/ping") {
                call.respond(mapOf("message" to "pong"))
            }
        }
    }.start(wait = true)
}

上記の例では、処理中にArticleNotFoundExceptionという例外が発生した場合に、レスポンスとしてステータスコード404(Not Found)を返すように実装したものです。

感想

Ktorはドキュメントが充実しています。そのため、ドキュメント通りに進めるだけでそれなりにスムーズに進められたのがよかったです。

(この記事に記載されている内容も、ほぼドキュメントに記載されているものからの抜粋だったりします。。)

一方気になった点として、デフォルトではログがほとんど出力されないようになっている点があります。

例えばアプリケーションの処理の中で内部エラーが発生しても、ログが全く出力されず、開発時に若干戸惑いを覚えました。

とはいえ現状でも機能がかなり充実しており、必要に応じてそれらの機能を組み合わせることで、不要な依存を持たなくて良いようになっている点が自分にとっては良いなと思いました。

今回は小さなアプリケーションで試してみたので、今後はもう少し規模の大きなアプリケーションで試すとどうなるか検討してみたいです。

Spring Bootとの比較という点で言えば、ある程度まとまった機能が最初から利用できるのがSpring Bootの良さであり、Ktorは必要に応じて機能をFeaturesや外部のライブラリを駆使して追加しなければならない分、面倒に感じてしまうこともあるかもしれないなと感じています。

個人的にはミニマルなフレームワークは好きなので、必要に応じて使い分けられるようになりたいです。