Ktor Routing ディープダイブ
Ktor v2.2.2 の実装に基づいて書いています。
ルーティングツリー
KtorのRoutingプラグインは、Route
オブジェクトをノードとするツリーを構築し、それを用いてHTTPリクエストとマッチングを行うことで、各リクエストに対してどういう処理が呼び出されるべきかを決定します。 Route
オブジェクトは親ルートを通常ひとつ(parent
)と、子ルートを0個以上(children
)もちます。
基本的には、ひとつの Route
オブジェクトは、パスをスラッシュで区切った一つひとつ(以下これをパスセグメントと呼びます)を表していて、たとえば /a/b/c
というパスは、下記のような親子関係をもった Route
のツリーで表されます(ルート[root]は /
だとします)。
ROOT
+-- aを表すRoute
+-- bを表すRoute
+-- cを表すRoute
パスにパラメータが含まれている場合も構造は同じで、たとえば /users/{userId}
であれば、次のようになります。
ROOT
+-- usersを表すRoute
+-- {userId}を表すRoute
Routeは必ずしもパスだけに対応するものではなく、たとえばHTTPメソッドの指定もRouteで表されます。 POST /users
を表すルーティングツリーであれば下記のようになります。他にもリクエストヘッダの有無に対応するRouteなんかが組み込みで用意されているし、パスへのアクセスが認証を要求していることのマーカーとしてもRouteが利用されたりします。
ROOT
+-- usersを表すRoute
+-- POSTメソッドを表すRoute
Routingプラグインのインストール
Routingプラグインを使うときに実行する install(Routing) { ... }
または routing { ... }
におけるラムダのレシーバ this
は Routing
クラスのインスタンスです。
routing {
// class io.ktor.server.routing.Routing
println(this::class)
}
Routing
クラスは Route
クラスを継承しており、ここでアクセスできる Routing
オブジェクトは、Routingプラグインとしての役割と、前項で説明したルーティングツリーのルートノード(routeでなくroot)を表す Route
オブジェクトとしての役割を兼任しているような存在です。
とはいえ、実際上はほぼ後者とみなして利用することになります。たとえば下記のようなコード、敢えて this
を省略せずに書いていますが、この get
メソッド呼出は this
をルーティングツリーのルートノードを表す Route
オブジェクトとして取り扱い、その子ノードとなる Route
オブジェクトをくっつける処理をしています。
routing {
this.get("/hello") { call.respondText("Hello World!") }
}
ルートノードの生成
ルートノードである Routing
オブジェクトは、Routingプラグインのインストール時に内部的に生成されます。そのときにルートノードのパスセグメントとして、設定項目 rootPath
が用いられます。 rootPath
は次のように設定することができます。
val env = applicationEngineEnvironment {
connector {
host = "0.0.0.0"
port = 8080
}
rootPath = "/root"
module {
configureRouting()
}
}
embeddedServer(Netty, env).start(true)
ktor {
deployment {
port = 8080
rootPath = "/root"
}
# ...
}
ルートノード生成に関する挙動はハードコーディングされており、Routingプラグインを普通に使っている限り、コードを書いてコントロールすることはできません。
とはいえ、プログラマブルな設計の一歩手前のような状態にはなっているので、すこし工夫をすれば、ルートパスをコードで記述し、ルーティングツリーを2つKtorにインストールするようなこともできそうな気もします。そんなこと誰かやりたいかは知りませんが。
リクエストとのマッチング
Routeの主要な役割はリクエストとマッチするかどうかを判定することです。Routingプラグインは、リクエストを受け取ると、ルーティングツリーの中からリクエストにマッチする Route
オブジェクトを探します。複数見つかった場合は一定のルールに基づいて最適なものをひとつ選択します。
RouteSelector
HTTPメソッドやヘッダに対応するRouteがあることからもわかるとおり、マッチ判定にはパスだけではなくリクエストがもつあらゆる情報が使われます。オブジェクト指向設計についてそれなりの勘があれば、固定文字列のパスセグメントを表すRoute・パラメータを表すRoute・HTTPメソッドを表すRouteなどが、それぞれ別の実装クラスとして表現され固有のマッチ判定ロジックをもっているのだろうと推測できると思います。それは概ね正しいのですが、 Route
クラスは、フィールドとして保持している RouteSelector
オブジェクトにマッチ判定のロジックを委譲しており、マッチ判定のタイプ別に実装クラスが用意されているのは RouteSelector
のほうになります。
RouteSelector
の定義は、非推奨となっている部分を除外すると、次の通りのシンプルなものです。
public abstract class RouteSelector {
public abstract fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation
}
ただ、 evaluate
メソッドをどのような契約にもとづいて実装すればよいかは、ドキュメントされておらず、わかりにくいところではあります。ざっくり理解できるよう、いくつか実装例を挙げてみましょう。もっとも、パスを対象としてマッチを行うタイプの RouteSelector
を自分で実装することはまずないと思います。パス以外を対象とするものは時々必要になるかもしれません。
固定文字列のパスセグメントとマッチするRouteSelector
class PathSegmentConstantRouteSelector(private val value: String) : RouteSelector() {
override fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation =
when {
segmentIndex < context.segments.size && context.segments[segmentIndex] == value ->
RouteSelectorEvaluation.Success(quality = 1.0, segmentIncrement = 1)
else ->
RouteSelectorEvaluation.Failure(quality = 0.0, failureStatusCode = HttpStatusCode.NotFound)
}
}
RoutingResolveContext
は、リクエスト毎にひとつ生成されるオブジェクトで、マッチ処理で必要な情報がすべて収めされています。リクエストオブジェクト自体も入っているし、マッチ処理の結果もここに格納されます。 context.seguments
は文字通りパスセグメントのリスト、つまりリクエストURIをスラッシュで分割した文字列のリストです。
segmentIndex
は、context.segments
のどこまでが上位のRouteとマッチ済で、次に(通常であれば)何番目のセグメントがマッチ処理対象となるのかを表します。たとえば次のようなルーティングツリーがあり、 /a/b/c
へのリクエストがどのRouteで処理されるのかマッチを行っているとすると、
Root
+-- Route(selector = PathSegmentConstantRouteSelector("a"))
+-- Route(selector = PathSegmentConstantRouteSelector("b"))
+-- Route(selector = PathSegmentConstantRouteSelector("c"))
PathSegmentConstantRouteSelector("c")
の evaluate
では、上位のRouteが /a
と /b
とのマッチをすでに行っていることから、segmentIndex
は 2
になります。
ちなみに、 context.segments
自体は上位Routeと一部マッチ済だからといって変化したりはしないので、 PathSegmentConstantRouteSelector("c")
は、その気になれば /a/b/c
というパス全体にアクセスし、判定に利用することができます。たとえば、ひとつめのパスセグメントが a
でなければマッチしない RouteSelector
を作って下位のほうに配置することもできるわけです。やらないと思いますが。
戻り値は RouteSelectorEvaluation.Success
か RouteSelectorEvaluation.Failure
です。どちらにも quality
というパラメータがありますが、これは、複数のRouteがリクエストにマッチしたときに採用される優先度です。たとえば、 /users/{id}
と /users/import
というパスどちらにもマッチするルーティングツリーがあったとき、 /users/import
へのリクエストは両方にマッチするにも関わらず正しく後者のRouteへとルーティングされますが、これはパラメータマッチよりも固定文字列マッチの返す quality
値が大きいからです。ちなみに実際の実装では前者は 0.8
、後者は 1.0
となっています。パスとマッチする RouteSelector
を自作する場合は、他の組み込み RouteSelector
実装がどのような quality
を返すのかを確認して、自作クラスの quality
を決定するしかありません。やらないと思いますが(しつこい)。
特定のクエリパラメータが存在するときにマッチするRouteSelector
class QueryParameterRouteSelector(private val name: String) : RouteSelector() {
override fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation =
context.call.request.queryParameters[name]
?.let { RouteSelectorEvaluation.Success(quality = 1.0) }
?: RouteSelectorEvaluation.Failure(quality = 0.01, failureStatusCode = HttpStatusCode.BadRequest)
}
この RouteSelector
はパスセグメントとのマッチを行わないから、戻り値の segmentIncrement
がさっきの 1
に対してこちらは 0
です(デフォルト値なので省略されています)。 1
にすると、下位のRouteが evaluate
を呼ばれたときの segmentIndex
が増えてしまいます。
失敗時の quality
を 0.01
としているのは、マッチ失敗時に返すレスポンスコードの優先度に影響します。マッチ判定において、判定対象となるRouteすら存在しなかった、というケースが 404 Not Found になるよう、Routingプラグインはデフォルトのマッチ失敗を quality=0
の Not Found として内部に持っています。なので、それ以外の失敗レスポンスを返したい場合は、それより大きい quality
を設定する必要があるのです。これにより、たとえば次のようなRouteツリーがあった場合、クエリパラメータのない /search
へのリクエストは、Not Found ではなく Bad Request になります。
Root
+-- Route(selector = PathSegmentConstantRouteSelector("search"))
+-- Route(selector = QueryParameterRouteSelector("keyword"))
RoutingBuilder
を使わずにルーティングツリーを組んでみる
やる意味は特にないですが、理解を深めるためにということで。例として次のような定義を考えます( routing { ... }
で囲まれているものとします)。
route("/users") {
post { call.respond("POST /users") }
get { call.respond("GET /users") }
route("/{userId}") {
get { call.respond("GET /users/${call.parameters["userId"]}") }
put { call.respond("GET /users/${call.parameters["userId"]}") }
}
}
これを、すこし低レベルに書くと、次のようになります。 Route
オブジェクトの生成は createChild
に任せています。 Route
オブジェクト生成をコンストラクタ直接呼出で行うことは一応できるのですが、公開APIでやるかぎり、 Route
オブジェクト間の親子関係を構築できないので、残念ながらここまでが限界です。
createChild(PathSegmentConstantRouteSelector("users")).apply {
createChild(HttpMethodRouteSelector(HttpMethod.Post)).handle { call.respond("POST /users") }
createChild(HttpMethodRouteSelector(HttpMethod.Get)).handle { call.respond("GET /users") }
createChild(PathSegmentParameterRouteSelector("userId")).apply {
createChild(HttpMethodRouteSelector(HttpMethod.Get)).handle { call.respond("GET /users/${call.parameters["userId"]}") }
createChild(HttpMethodRouteSelector(HttpMethod.Put)).handle { call.respond("PUT /users/${call.parameters["userId"]}") }
}
}
まとめ
以上、必要になるケースのあまりなさそうな誰得情報でしたが……。
実際の用途としては、一例として[1]、特定のパスへの独自の認可機構の組み込みなどがあります。Web検索するといくつか見つかると思いますが、大体どのサンプルを見ても、次のような簡単な RouteSelector
独自実装と、それを用いた Route
オブジェクトの構築処理が行われているはずです[2]。
class AuthorizationRouteSelector : RouteSelector() {
override fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation =
RouteSelectorEvaluation.Transparent
override fun toString(): String = "(authorization)"
}
この記事を読めば、それらの意味がチョットワカル……くらいですかね。多少の役に立てば幸いです。
Discussion