KtorのRouting定義のベストプラクティスを考えてみる

KtorのRouting定義のベストプラクティスを考えてみる

HRMOS事業部プロダクト開発部の清水(@kaonash_)です。

現在サーバーサイドKotlinを使って新規事業のプロダクト開発を行っています。

業務ではSpring Bootで開発をしていますが、個人的に活動をする中でJetBrains製のWebフレームワークであるKtorに触る機会があったため、その知見を少し共有させてもらおうと思います。

Ktorとは?

そもそもKtorをご存知でない方はこのページにアクセスされないような気もしますが笑、上述のとおり、JetBrains社のWebフレームワークです。

2018年11月に1.0がリリースされ、軽量・非同期を特徴としています。

KtorでのRoutingの定義方法

まず、前提としてRouting定義の基礎知識です。

Ktorでは、Routingを定義する方法が大きく2つあります。標準のRoutingのみを使う方法と、それにexperimentalな機能であるLocationを組み合わせる方法です。

Routing単体で定義する場合

Routing単体で定義する場合はこのような形になります。

routing {
    get("/users/{userId}") {
        // パスパラメータはcall.parametersから取得する(戻り値はString)
        val userId: Long? = call.parameters["userId"]?.toLong
    }
    post("/users/{userId}") { ... }

    // ネストも可能
    route("/teams") {
        get("") { ... }
        get("/{teamId}") { ... }
    }
}

Location + Routing で定義する場合

RoutingLocationを組み合わせて使うと、パスパラメータやクエリパラメータをタイプセーフに扱うことが可能になります。

// パスパラメータの型を指定することができる
@Location("/users/{userId}")
data class UserParam(val userId: Long) {
    // Locationもネストできる
    @Location("/items/{itemId}")
    data class ItemParam(val userParam: UserParam, val itemId: Long)
}

routing {
    // パラメータをUserParam型で取得できるので、型変換が不要
    get<UserParam> { param ->
        call.respondText(param.userId.toString())
    }

    get<UserParam.ItemParam> { param ->
        call.respondText("${param.userParam.userId}, ${param.itemId}")
    }
}

ちなみにここで使っているgetメソッドは上のRouting単体の場合に使っているgetメソッドとは別のものになります。(前者はio.ktor.locations.get、後者はio.ktor.routing.get

正直このあたりの名称が被っていたりするのがKtorのRoutingちょっとイケてないなと思うポイントの一つなのですが、それはまた別の話。(Locationはまだexperimentalなので、しばらくすればこのあたりも含めて解消されるんじゃないかと期待しています)

実際に使ってみた

さて、前置きが長くなりましたが、ここからが本題です。

実際に開発を進めるにあたって、Locationのリクエストパラメータをタイプセーフに扱える機能は魅力的です。

ので、今回はLocationを活用した上でRouting定義を書いてみました。

実際出来上がったコードはこんな感じです。

routing {
    route("/users") {
        get<UserParam> { param ->
            val userId = param.userId
            ...
        }
        put<UserParam> { ... }
        put<UserParam.Email> { param ->
            val userId = param.userParam.userId
            ...
        }
        post<UserParam.Email.Confirm> { ... }
        put<UserParam.PhoneNumber> { ... }
        post<UserParam.PhoneNumber.Confirm> { ... }
        put<UserParam.Password> { ... }
        get<UserParam.ItemParam> { param ->
            val userId = param.userParam.userId
            val itemId = param.itemId
            ...
        }
    }
}

@Location("/{userId}")
data class UserParam(val userId: Long) {
    @Location("/email")
    data class Email(val userParam: UserParam){
        @Location("/confirm")
        data class Confirm(val email: Email)
    }

    @Location("/phone_number")
    data class PhoneNumber(val userParam: UserParam) {
        @Location("/confirm")
        data class Confirm(val userParam: UserParam)
    }

    @Location("/password")
    data class Password(val userParam: UserParam)

    @Location("/items/{itemid}")
    data class ItemParam(val userParam: UserParam, val itemId: Long)
}

・・・なんということでしょう。

なぜだかめちゃくちゃ分かりづらいコードが出来上がってしまいました。(実際はもっとたくさんのRouteが存在していてさらにカオス) 開発開始から1ヶ月にして立派な技術的負債の完成です。

ではこのコード、なにが問題なのか考えてみましょう。

1. どのURLがどの処理を呼ぶのか、直感的に理解できない

これが最大の問題点です。たとえば”/users/1/phone_number”というURLでputリクエストが飛んできたとして、どの処理が呼ばれるのか全然わかりません。

本来Routingをまとめて定義することで一覧性を高められるのがKtorの利点のはずなのに、まったく直感的でなくなってしまってます。

上側に定義されているroutingを見て”/users”の中に入りそうなところまではかろうじてわかるものの、Location側にもroute定義がされており、もはやお手上げ状態。

route定義がroutingLocationに分かれてしまっているが故に、どちらを見ればいいのかよくわからない。

さらには、Locationをネストさせてしまっていることも一層理解しづらさに拍車をかけてしまっています。

2. リクエストパラメータの取得方法に統一性がない

get<UserParam.ItemParam>の箇所を見てください。

userIdもitemIdもどちらも同じパスパラメータなのに、値の取得方法が変わってしまっています。

val userId = param.userParam.userId
val itemId = param.itemId

これ、感覚的にはこう記述したいところ。

val userId = param.userId
val itemId = param.itemId

ですが、Locationをネストさせた場合、必ず内部のLocationは外部のLocationをプロパティに含める必要があるため、こういった書き方しかできません。

改善ポイント

以上の問題点を加味した上で、どのようにRouting定義をするのがよいのか、改めて考えてみました。

結果、現時点で至った結論は、下記の3つ。

  1. URL定義をroutingLocationに分散させず、Locationに統一する
  2. Locationの定義は実際の処理を行う場所に一緒に記述するようにする
  3. ネストは原則使用しない

一時はLocation自体使わないようにしようかとも思いましたが、やはりタイプセーフにパラメータを扱えるメリットは残したく、上記のような結論になりました。

リファクタリングした結果

上記の内容を踏まえて、リファクタリングした結果がこちらです。(Locationのクラス名も一緒に変更しています)

routing {
    @Location("/users/{userId}")
    data class UserLocation(val userId: Long)
    get<UserLocation> { param ->
        val userId = param.userId
        ...
    }
    put<UserLocation> { ... }

    @Location("/users/{userId}/email")
    data class EmailLocation(val userId: Long)
    put<EmailLocation> { param ->
        val userId = param.userId
        ...
    }

    @Location("/users/{userId}/email/confirm")
    data class EmailConfirmLocation(val userId: Long)
    post<EmailConfirmLocation> { ... }

    @Location("/users/{userId}/phone_number")
    data class PhoneNumberLocation(val userId: Long)
    put<PhoneNumberLocation> { ... }

    @Location("/users/{userId}/confirm")
    data class PhoneNumberConfirmLocation(val userId: Long)
    post<PhoneNumberConfirmLocation> { ... }

    @Location("/users/{userId}/password")
    data class PasswordLocation(val userId: Long)
    put<PasswordLocation> { ... }

    @Location("/users/{userid}/items/{itemid}")
    data class UserItemLocation(val userId: Long, val itemId: Long)
    get<UserItemLocation> { param ->
        val userId = param.userId
        val itemId = param.itemId
        ...
    }
}

Locationをネストさせず、あえて冗長にRoute定義を行うようにしました。

加えて、Locationの定義と実際の処理を同じ場所に記述するようにした結果、どのURLがどの処理に紐付いているのか、かなりわかりやすくなったのではないでしょうか。

問題点であげた、リクエストパラメータの取得方法の統一性に関しても、下記のように感覚的な取得が可能になっています。

val userId = param.userId
val itemId = param.itemId

KtorでのRoutingについてのFAQ

最後に、KtorでのRoutingについて多くの方が疑問/不安を持たれるであろうポイントについて自分なりの見解を述べたいと思います。

疑問点1. 規模が大きくなった場合、1つのファイルにRoutingをまとめる書き方ってスケールしなくない?

たしかにRouteが数十個を超えるようになった場合、1つのファイルにまとめて記述することには限界があります。が、これは簡単に解消できます。

routing {
  userRoute()
}

fun Route.userRoute() {
    @Location("/users/{userId}")
    data class UserLocation(val userId: Long)
    get<UserLocation> { param ->
        val userId = param.userId
        ...
    }
    put<UserLocation> { ... }

    ...
}

このように、Routeクラスの拡張関数を作成する形で定義してあげれば、定義を細かく分割していくことができます。

分割した拡張関数ごとに別のファイルを用意するようにすれば、規模が大きくなってもファイルサイズが膨張することなくスケールさせることが可能です。

疑問点2. Locationをネストさせて階層構造にしておいたほうが、機能ごとにRouteをまとめられるし、不用意なRouteの衝突も防ぎやすいのでは?

これはまさにおっしゃるとおりで、だからこそ僕も最初は階層構造で定義したわけなんですが、上述した「リクエストパラメータの取得方法に統一性がなくなってしまう」問題点と天秤にかけた結果、「現時点では」Locationをネストさせないほうが読みやすい/書きやすいコードになるのでは、と判断しました。

疑問点3. ぶっちゃけ、Spring Bootと比べてどうなの?

フレームワークにKtorを採用したことについて現状概ね満足しているのですが、論点をRoutingに絞ると、「どっこいどっこい」もしくは「まだ若干Spring Bootのほうが使いやすいかも」というのが正直な感想です。

僕自身がSpring Bootを使っていて一番困るのは、「このRoute、どの処理を呼び出すの?」を探す時です。

Controllerごとに@RequestMappingアノテーションでpathを指定していますが、正直これがかなり探しづらい。(※個人の感想です)

特に、@RequestMapping/users/{userId}/を指定したControllerと/users/{userId}/items/{itemId}を指定したControllerが別々にあったりするとなおさら迷路に迷い込むことになります。

これに対してKtorでは、Route定義をまとめることができるため、Spring Bootと比べて呼び出す処理が探しやすいと感じます。

一方で先に述べたように、Routeをネストさせつつ、うまくパスパラメータを取得できるという点ではSpring Bootに分があります。

加えて、KtorだとroutingLocationどちらでもRoute定義ができてしまうがゆえに、書き方を事前に意思統一しておかないとコードが汚れやすいという問題点もあります。

ただしこのあたりの問題点はversion upに伴って改善していってくれるのでは、と期待しています。(確証はない)

まとめ

Ktor自体はまだ1.0が出て間もなく、知見もなかなか集めづらい状況ではあります。

ですがKotlin製のWebフレームワークとしてはとても注目度も高く、これから実用事例も出てくるだろうと思いますので、もっといろんな人柱・・・もとい、様々なベストプラクティスが出てきてくれるといいなと思います。

Let’s enjoy Kotlin!!