🛣️

Ktor Routing ディープダイブ

2023/01/26に公開

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 { ... } におけるラムダのレシーバ thisRouting クラスのインスタンスです。

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)
application.confの場合
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 とのマッチをすでに行っていることから、segmentIndex2 になります。

ちなみに、 context.segments 自体は上位Routeと一部マッチ済だからといって変化したりはしないので、 PathSegmentConstantRouteSelector("c") は、その気になれば /a/b/c というパス全体にアクセスし、判定に利用することができます。たとえば、ひとつめのパスセグメントが a でなければマッチしない RouteSelector を作って下位のほうに配置することもできるわけです。やらないと思いますが。

戻り値は RouteSelectorEvaluation.SuccessRouteSelectorEvaluation.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 が増えてしまいます。

失敗時の quality0.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)"
}

この記事を読めば、それらの意味がチョットワカル……くらいですかね。多少の役に立てば幸いです。

脚注
  1. 他の例はほぼ思いつかないですが ↩︎

  2. Transparent のところが Constant になっている例もありますが、どちらかというと Transparent のほうが適切そうです ↩︎

Discussion

ログインするとコメントできます