Uzabase Tech Blog

SPEEDA, NewsPicks, FORCASなどを開発するユーザベースの技術チームブログです。

Ktor で小さな API を作る

こんにちは。SPEEDA 開発チームの緒方です。

システムをマイクロサービスで構成するメリットのひとつに、採用する技術にバリエーションを持たせることができるという点が挙げられると思います。

実際、SPEEDA でも様々な言語・フレームワークを利用してマイクロサービスを開発しています。 その中でも Kotlin はかなり多くのプロジェクトで採用されている言語です。

Kotlin で利用できるフレームワークと言えば Spring Boot など Spring 系のものが真っ先に思い付くと思うのですが、本当に小さな API を作りたい場合には少し大袈裟すぎる気もします。

今回はそういう場合に手軽に使える Ktor という小さなフレームワークを使った API について、簡単に紹介していきたいと思います。

ベースとなるプロジェクトの作成

まずはベースとなる Ktor プロジェクトを作成します。(Maven を使ったバージョン。)

公式の Maven - Quick Start - Ktor あたりが参考になると思います。

pom.xml の dependency に Ktor の依存関係を追加します。 サーバエンジンには Netty を利用します。

<dependency>
    <groupId>io.ktor</groupId>
    <artifactId>ktor-server-netty</artifactId>
    <version>${ktor.version}</version>
</dependency>

これで準備は完了です。最初のアプリケーションを作ります。

適当なパッケージに main 関数を作成します。

package sample

import io.ktor.application.call
import io.ktor.response.respondText
import io.ktor.routing.get
import io.ktor.routing.routing
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty

fun main() {
    embeddedServer(Netty, 8080) {
        routing {
            get("/") {
                call.respondText("Hello, world!")
            }
        }
    }.start(true)
}

実行してみます。

$ mvn exec:java -Dexec.mainClass=sample.MainKt

エラーなく実行できたら、curl コマンドを使って動作を確認します。

$ curl -v localhost:8080
*   Trying ::1:8080...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.68.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Content-Length: 13
< Content-Type: text/plain; charset=UTF-8
<
* Connection #0 to host localhost left intact
Hello, world!

無事に実行できました!これでベースとなるプロジェクトは完成です。

簡単な説明

まず embeddedServer 関数を使って、このプロセスで起動するサーバを生成しています。第一引数の Netty はサーバエンジンとして Netty を利用することを明示しています。

ポイントはこの関数に渡しているブロックです。このブロック内でサーバの各種設定を行っています。 このブロックのレシーバは io.ktor.application.Application のインスタンスで、このインスタンスを操作することでサーバの設定をすることができます。

例えば、routing 関数は名前の通り API のルーティングを表す io.ktor.routing.Routing を登録するものです。 io.ktor.application.Application の拡張関数として定義されているので設定変更を行う関数としてこのブロック内で呼び出すことができます。

get は Routing を構成する Route を登録する関数で、パス "/" の HTTP GET メソッドに対応する Route を登録しています。

Route は Routing に対して複数設定することができます。試しに Route を増やしてみます。

package sample

import io.ktor.application.call
import io.ktor.response.respondText
import io.ktor.routing.get
import io.ktor.routing.routing
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty

fun main() {
    embeddedServer(Netty, 8080) {
        routing {
            get("/") {
                call.respondText("Hello, world!")
            }
            get("/ping") {
                call.respondText("Pong")
            }
        }
    }.start(true)
}
$ curl -v localhost:8080/ping
*   Trying ::1:8080...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /ping HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.68.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Content-Length: 5
< Content-Type: text/plain; charset=UTF-8
<
* Connection #0 to host localhost left intact
Pong

Route を増やすことができました。

このように、Ktor では拡張関数とブロックを使った DSL (的な記法) で様々な設定を行っていきます。 慣れるまでは理解しにくいかもしれませんが、Kotlin の言語仕様をうまく使って直感的に定義できるようにした上手い記法だと思います。

レスポンスを JSON にする

さて、ここまではテキストでレスポンスを返していましたが、もちろん JSON 形式でレスポンスを返すこともできます。

Ktor には Feature と呼ばれる様々な機能が予め用意されています。

例えば認証を行ったりレスポンスヘッダを付与したりなど、一般的な用途であれば大抵のものに対しては自前で実装することなく利用が可能です。

レスポンスを JSON に変換する Content Negotiation と呼ばれる Feature もそのひとつです。

まず、pom.xml に依存を追加します。(Jackson を使ったバージョンを使うことにします。)

<dependency>
    <groupId>io.ktor</groupId>
    <artifactId>ktor-jackson</artifactId>
    <version>${ktor.version}</version>
</dependency>

次に、embeddedServer に対して Feature を install し、テスト用のルーティングを追加します。

package sample

import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.ContentNegotiation
import io.ktor.jackson.jackson
import io.ktor.response.respond
import io.ktor.response.respondText
import io.ktor.routing.get
import io.ktor.routing.routing
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty

fun main() {
    embeddedServer(Netty, 8080) {
        install(ContentNegotiation) {
            jackson {
                enable(SerializationFeature.INDENT_OUTPUT)
            }
        }
        routing {
            get("/") {
                call.respondText("Hello, world!")
            }
            get("/ping") {
                call.respondText("Pong")
            }
            get("/json") {
                call.respond(ResponseJson("JSON response"))
            }
        }
    }.start(true)
}

data class ResponseJson(val message: String)

リクエストしてみます。

$ curl -v localhost:8080/json
*   Trying ::1:8080...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /json HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.68.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Content-Length: 27
< Content-Type: application/json; charset=UTF-8
<
* Connection #0 to host localhost left intact
{
  "message":"JSON response"
}

これだけです。めっちゃ簡単ですね。

jackson 関数に渡されるブロックのレシーバは Jackson の ObjectMapper となりますので、JSON 変換のオプションを指定したい場合はそのブロック内で指定します。

まとめと問題点

Ktor について簡単にまとめてみます。

  • サーバの設定は embeddedServer 生成時のブロック内で行うことがきる。(ルーティングなど。)
  • Feature と呼ばれる予め用意された機能を利用できる。使う場合は install 関数を呼び出して登録する。

ここまでのコード例で (本当にミニマムな) API サーバであれば作成することができると思います。 ですが同時に、改善の余地はかなり残っています。

例えば、

  • ルーティングが増えたら embeddedServer の初期化ブロックが肥大化するのでは?
  • リクエストハンドラは routing 内に記述する?
  • 設定ファイルの読み込みは?
  • DI コンテナは併用できない?

などです。

このあたりについては、改善案を紹介していければと思います。