タケハタのブログ

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

Spring BootとKtorの実装比較(初級編)

Kotlin Advent Calendar 2020 1日目の記事です。

サーバーサイドKotlinで使われるフレームワークとして、Spring BootとKtorはよく挙げられます。
Spring BootがJavaの流れを組んで主流で、新鋭の軽量なフレームワークとしてKtorがあるというイメージだと思います。

今回は本当に基本的な部分ですが、この2つのフレームワークの実装方法を並べて比較してみたいと思います。

導入

まずはプロジェクトの作成、起動から。

プロジェクトの作成

Spring Boot

Spring BootはSpring Initializrで作成します。
IntelliJ IDEAでSpring Bootプロジェクトとして作成することも可能ですが、有償版のみの機能になるのでここでは使いません。

f:id:take7010:20201201172655p:plain

GENERATEボタンでzipがダウンロードされるので、任意の場所に解凍しIntelliJ IDEAでインポートします。

Ktor

こちらはIntelliJ IDEAのプロジェクトとして作成します。

f:id:take7010:20201201172717p:plain

Ktorプラグインがないとプロジェクト種別として選択できないため、入ってない場合はインストールしてください。

build.gradle

作成されたプロジェクトのbuild.gradleの比較です。
Spring Initializrの方はKotlinを選択するとktsで作成されるため、Ktorプロジェクトの方も合わせてktsで作成しています。

Spring Boot

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "2.4.0"
    id("io.spring.dependency-management") version "1.0.10.RELEASE"
    kotlin("jvm") version "1.4.10"
    kotlin("plugin.spring") version "1.4.10"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.withType<Test> {
    useJUnitPlatform()
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "11"
    }
}

Ktor

import org.jetbrains.kotlin.gradle.dsl.Coroutines
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

val ktor_version: String by project
val kotlin_version: String by project
val logback_version: String by project

plugins {
    application
    kotlin("jvm") version "1.4.10"
}

group = "com.example"
version = "0.0.1"

application {
    mainClassName = "io.ktor.server.netty.EngineMain"
}

repositories {
    mavenLocal()
    jcenter()
}

dependencies {
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version")
    implementation("io.ktor:ktor-server-netty:$ktor_version")
    implementation("ch.qos.logback:logback-classic:$logback_version")
    testImplementation("io.ktor:ktor-server-tests:$ktor_version")
}

kotlin.sourceSets["main"].kotlin.srcDirs("src")
kotlin.sourceSets["test"].kotlin.srcDirs("test")

sourceSets["main"].resources.srcDirs("resources")
sourceSets["test"].resources.srcDirs("testresources")

アプリケーションの起動

Spring Boot

以下のmain関数を実行します。

@SpringBootApplication
class DemoSpringApplication

fun main(args: Array<String>) {
    runApplication<DemoSpringApplication>(*args)
}

もしくは

  • IntelliJ IDEAのGradleビューから Tasks -> application -> bootRun を実行
  • ターミナルからプロジェクトのルートで ./gradlew bootRun を実行

でも起動できます。

Ktor

以下のmain関数を実行します。

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

もしくは

  • IntelliJ IDEAのGradleビューから Tasks -> application -> run を実行
  • ターミナルからプロジェクトのルートで ./gradlew run を実行

でも実行できます。

いずれもアプリケーションのmain関数が用意されていて、それを実行するとサーバーとアプリケーションが起動してListenになり、似たような形になっています。

基本的な機能

ルーティング

基本的なルーティングの定義です。
まずはGETメソッドで固定の文字列を返す、シンプルな処理を記述してみます。

Spring Boot

Spring Bootでは @RestController というアノテーションを付けたクラスを作成し、その中で @GetMapping アノテーションで定義したパスがルーティングの対象として認識されます(HTTPメソッドごとに PostMapping PutMapping などのアノテーションがある)。

@RestController
class HelloController {
    @GetMapping("/hello")
    fun hello(): String {
        return "hello"
    }
}

Ktor

Ktorでは前述のmain関数のあったファイルに記述されている、Application.module の中に routing というブロックで定義します。
GETメソッドの場合は get にパスを記述し(HTTPメソッドごとに post put などの関数がある)、そのブロック内にレスポンスを返す処理を記述します。

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

パスパラメータ

パスパラメータを渡す場合の記述です。

Spring Boot

Spring Bootでは @GetMapping などのアノテーションで定義されたパスの、パラメータを含めたい箇所に {} で括ったパラメータ名を定義します。
そしてルーティングされた関数の引数に @PathVariable アノテーションを使用し、渡されるパラメータ名(アノテーションのパスで定義した{}内の名前)を指定します。

@GetMapping("/hello/{name}")
fun helloName(@PathVariable("name") name: String): String {
    return "hello $name"
}

Ktor

Ktorもパスのパラメータを含めたい箇所に、 {} で括ったパラメータ名を定義します。
そして call.parameters[パラメータ名] で取得できます。

get("/hello/{name}") {
    val name = call.parameters["name"]
    call.respondText("hello $name")
}

ただし、Ktorの方はこのやり方だとパラメータを問答無用にString型で受け取ってしまうため、他の型で扱う場合はキャストや下記のように変換が必要になります。

val id = call.parameters["id"]?.toInt()

このリクエストをタイプセーフに扱うためには Locationsが必要になります。

Locations を使うにはいくつか手順が必要で、まずはbuild.gradleに依存関係を追加します。

implementation("io.ktor:ktor-locations:$ktor_version")

そして Application.module 内でフィーチャーをinstallします。

install(Locations)

これで Locations の機能は使えるようになります。
使い方としては、まず以下のようなデータクラスを定義します。

@Location("/hello/{id}")
data class HelloLocation(val id: Int)

@Location アノテーションでパスを定義し、その中でパスパラメータのパラメータ名も指定します。
また、パラメータ名と同じ名前のプロパティをコンストラクタで定義します。

そしてルーティング側の定義は下記のように、型パラメータとして @Location を付与したデータクラスの型を渡します。

get<HelloLocation> { request ->
    call.respondText("hello ${request.id}")
}

ただし、ルーティングの定義とそれに紐づく処理の定義がバラけてしまうため、視認性は落ちます。
以下で紹介されているような工夫は必要かもしれません。

engineering.visional.inc taro.hatenablog.jp

クエリパラメータ

次はクエリパラメータの定義です。

Spring Boot

Spring Bootでは、ルーティングされた関数の引数に @RequestParam アノテーションを付与し、パラメータ名を指定します。

@GetMapping("/hello/query")
fun helloNameByQuery(@RequestParam("name") name: String): String {
    return "hello $name"
}

Ktor

Ktorではパスパラメータと同様に call.parameters を使用して取得します。
指定した名前がパスパラメータとして定義されていない場合、クエリパラメータとして扱います。

get("/hello/query") {
    val name = call.parameters["name"]
    call.respondText("hello $name")
}

こちらもタイプセーフに扱いたい場合は、Locations を使用します。
以下のようにパスの定義にパラメータを書かず、プロパティを定義すればクエリパラメータとして扱われます。

@Location("/hello/query")
data class HelloQueryLocation(val name: String)
get<HelloQueryLocation> {
    val name = call.parameters["name"]
    call.respondText("hello $name")
}

JSONのリクエスト、レスポンス

REST APIで使用するJSONのリクエスト、レスポンスです。
以下をリクエスト、レスポンスのクラスとして使います(Spring Boot、Ktor共通)。

data class HelloRequest(val name: String)
data class HelloResponse(val message: String)

Spring Boot

Spring Bootではルーティングされた関数の引数を前述のデータクラスの型で定義し、 @RequestBody のアノテーションを付与すると、データクラスと同じ構造のJSONでのリクエストを受け付けます。

@PostMapping("/hello/json")
fun helloJson(@RequestBody request: HelloRequest): HelloResponse {
    return HelloResponse("hello ${request.name}")
}

レスポンスはルーティングの項で書いた @RestController のアノテーションが付与されたクラスであれば、データクラスの構造でJSON化した結果を返却されます。

Ktor

Ktorではまず、Jacksonのフィーチャーを追加する必要があります。
下記の依存関係をbuild.gradleに追加します。

implementation("io.ktor:ktor-jackson:$ktor_version")

そして Application.module に下記を追加します。

install(ContentNegotiation) {
    jackson()
}

これでリクエスト、レスポンスのシリアライズ、デシリアライズにJacksonが使用されます。

ルーティング側では、call.receive に型パラメータとしてデータクラスの型を渡すと、JSONのリクエストを受け付けます。
そしてレスポンスは call.respond にデータクラスの型のオブジェクトを渡すと、同じ構造のJSONを返却します。

post("/hello/json") {
    val request = call.receive<HelloRequest>()
    call.respond(HelloResponse("hello ${request.name}"))
}

認証、認可

認証、認可の実装も簡単に紹介します。

ベーシック認証

まずはシンプルなベーシック認証です。

Spring Boot

Spring Bootでの認証、認可の実装は、Spring Securityを使用します。
build.gradleに以下の依存関係を追加します。

implementation("org.springframework.boot:spring-boot-starter-security")

Spring Securityはデフォルトでベーシック認証が有効になるため、これだけでアクセス時に認証を求められるようになります。
ID、パスワードはデフォルトで

  • ID: user
  • パスワード: アプリケーションの起動時に表示される文字列(下記のような形式で表示される)
Using generated security password: xxxxxxxx

となります。

Ktor

KtorではAuthのフィーチャーを追加します。
build.gradleに以下を追加します。

implementation("io.ktor:ktor-auth:$ktor_version")

そして下記のinstallブロックを Application.module に追加します。

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

こちらはID、パスワードのデフォルト値などはありません。
validate で使用している credentials に入力値が入っていて、if文でチェックをしています(ここでは固定値を使用)。

正しい値が入力された場合(ここではuser、passwordという値が入力された場合)、セッション情報を返却しています。
UserIdPrincipal がセッション情報を格納しているオブジェクトで、コンストラクタに入力されたIDを渡します。

そしてKtorは認証の対象となるルーティング定義を以下のように authenticate のブロックで囲う必要があります。

authenticate {
    get("/hello") {
        call.respondText("hello")
    }

    // ・・・
}

他の認証方式の追加(例: Form認証)

他の認証方式の追加です。
ここでは例としてForm認証を使用します。

Spring Boot

下記のように、 WebSecurityConfigurerAdapter を継承し、@EnableWebSecurity アノテーションを付与したクラスを作成します。

@EnableWebSecurity
class WebSecurityConfig : WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity) {
        http.authorizeRequests()
            .mvcMatchers("/login").permitAll()
            .anyRequest().authenticated()
            .and()
            .formLogin()
    }
}

ここで認証、認可の挙動の様々な設定を入れるのですが、.formLogin() を使用することでForm認証が有効になります。
Spring Bootはこれだけでデフォルトのログインページまで用意してくれるため、ブラウザで任意のURLにアクセスするとログインページにリダイレクトされます。
デフォルトのID、パスワードはベーシック認証と同じです。

.mvcMatchers("/login").permitAll() で指定しているのは、ログインページを認証の対象外(全リクエストを受け付ける)にするための記述です。

Ktor

Ktorは正直Form認証を実装した情報がかなり少なかったのですが・・・
下記のように form ブロックで定義します。

form {
    challenge {
        call.respondRedirect("/login")
    }
    validate { credentials ->
        userParamName = "user"
        passwordParamName = "password"
        if (credentials.name == "user" && credentials.password == "password") UserIdPrincipal(credentials.name) else null
    }
}

challenge で実装しているのが認証失敗した際の処理で、ここでは /login のパスにリダイレクトしています。
ただし、Spring Bootのようにログインページが用意されているわけではないので、自前で作る必要があります。

validate で実装しているのは認証時の処理です。
ベーシック認証のところと同様、固定値を入力値と比較して一致した場合はセッションに乗せる情報を返却しています。

userParamName passwordParamName で指定しているのは、認証で送られてくるID、パスワードのパラメータ名です。

Ktorではこのように、認証方式ごとに処理の定義ができるようになっています。
他の認証方式を使う場合も、下記のようにブロックを追加すればできます(build.gradleへの依存関係追加が必要なものもあり)。

jwt {
    // ・・・
}

oauth { 
    // ・・・
}

最後に

Spring BootとKtorの基本的な機能の比較をしました。

基本的にSpring Bootはフルスタックなフレームワークな上、過去に色々と追加されてきた経緯もあるので、ちょっと記述を追加するだけでデフォルトとしてはしっかりした機能を提供してくれます。
歴史が長いしJavaのデファクトスタンダードみたいなフレームワークなので、完成度も高いです。

対してKtorはベースの状態でできる機能は限られていて、必要に応じてフィーチャーを追加していく形。
紹介している中でも少し見えたかもしれませんが、機能追加する時に面倒な部分があったり、未成熟な箇所もあります。
ただ、それゆえ軽量であることと、やはりKotlin製ということで期待は高いです。

今回は初級編ということで、またもっと踏み込んだ機能の比較なども(多分)書きます。