iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Software Development

Kotlin 手刻 Ktor 從零開始系列 第 9

Kotlin 手刻 Ktor 從零開始 Day 09 路徑參數,讓 /users/{id} 活起來

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260822/20121948NIqCtgaIUJ.png

上一篇我們做了 Router,能處理精確匹配、405、trailing slash。但精確匹配只能做到 /users/health 這種固定路徑,真實的 REST API 需要 /users/123/posts/abc-def 這種動態路徑,這篇要讓 Router 支援路徑樣板,/users/{id} 匹配 /users/123,把 id=123 提取出來給 handler 用

第一階段,TDD 先定義 Router 的匹配行為

這篇分兩個階段。第一階段只處理 Router 這一層,路徑樣板怎麼跟 request path 比對、參數怎麼提取,測試也只斷言 match() 的回傳值,等這層跑通了,第二階段再組合到 RelixCall,讓 handler 真的能拿到參數

先寫第一階段的測試

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs

class PathParamTest {

    @Test
    fun `single path parameter is extracted`() {
        val router = Router()
        router.add(Route("GET", "/users/{id}") { ok("user") })

        val result = router.match("GET", "/users/123")

        assertIs<MatchResult.Matched>(result)
        assertEquals(mapOf("id" to "123"), result.pathParams)
    }

    @Test
    fun `multiple path parameters are extracted`() {
        val router = Router()
        router.add(Route("GET", "/users/{userId}/posts/{postId}") { ok("post") })

        val result = router.match("GET", "/users/u1/posts/p9")

        assertIs<MatchResult.Matched>(result)
        assertEquals("u1", result.pathParams["userId"])
        assertEquals("p9", result.pathParams["postId"])
    }

    @Test
    fun `static route takes priority over parameterized route`() {
        val router = Router()
        val staticHandler: RelixHandler = { ok("static") }
        val paramHandler: RelixHandler = { ok("param") }
        router.add(Route("GET", "/users/{id}", paramHandler))
        router.add(Route("GET", "/users/me", staticHandler))

        val result = router.match("GET", "/users/me")

        assertIs<MatchResult.Matched>(result)
        assertEquals(staticHandler, result.route.handler)
    }

    @Test
    fun `segment count mismatch returns NotFound`() {
        val router = Router()
        router.add(Route("GET", "/users/{id}") { ok("user") })

        // template 有 2 段,request 有 3 段
        val result = router.match("GET", "/users/123/posts")

        assertIs<MatchResult.NotFound>(result)
    }

    @Test
    fun `trailing slash without id does not match parameter`() {
        val router = Router()
        router.add(Route("GET", "/users/{id}") { ok("user") })

        // normalizePath 會先把結尾的 / 砍掉,所以只剩一段 ["users"],段數對不上
        val result = router.match("GET", "/users/")

        assertIs<MatchResult.NotFound>(result)
    }

    @Test
    fun `router keeps the decoded path parameter`() {
        val router = Router()
        router.add(Route("GET", "/search/{query}") { ok("search") })

        // Adapter 已從 URI.path 取得解碼後的路徑
        val result = router.match("GET", "/search/hello world")

        assertIs<MatchResult.Matched>(result)
        assertEquals("hello world", result.pathParams["query"])
    }

    @Test
    fun `parameterized route still supports 405`() {
        val router = Router()
        router.add(Route("GET", "/users/{id}") { ok("user") })

        val result = router.match("POST", "/users/123")

        assertIs<MatchResult.MethodNotAllowed>(result)
    }
}

這七個測試現在連編譯都過不了,因為 MatchResult.Matched 還沒有 pathParams 這個欄位。接下來就是要讓它們通過測試,先補型別,再寫比對邏輯,最後接進 Router.match()

更新 MatchResult,加入 pathParams

sealed class MatchResult {
    data class Matched(
        val route: Route,
        val pathParams: Map<String, String> = emptyMap(),
    ) : MatchResult()
    data object NotFound : MatchResult()
    data class MethodNotAllowed(val allowedMethods: Set<String>) : MatchResult()
}

pathParams 預設是空 map,這樣精確匹配的路由不用特別傳參數,保持向後相容

分段匹配,把路徑切成 segments 逐段比對

路徑樣板的匹配有兩種常見做法,正則表達式和分段匹配,正則的好處是彈性高 (可以做 /users/{id:\\d+} 這種帶 pattern 的參數),但對系列文來說太 magic,分段匹配更直覺,一看就知道在幹嘛

概念很簡單,把 template 和 request path 都用 / 切成 segments,逐段比對

template: /users/{id}     → ["users", "{id}"]
path:     /users/123      → ["users", "123"]

比對過程
  "users" == "users"     → 靜態段,完全匹配 ✓
  "{id}"  vs "123"       → 參數段,提取 id=123 ✓

先把 segment 切割和判斷邏輯抽成工具函式,這幾個都是 Router 的 private 成員,只是為了方便解釋才單獨列出來,下面整合時會一起放進 class 裡

private fun splitSegments(path: String): List<String> {
    if (path == "/") {
        return emptyList()
    }
    return path.trimEnd('/').split("/").drop(1)  // drop(1) 去掉開頭的空字串
}

private fun isParam(segment: String): Boolean =
    segment.startsWith("{") && segment.endsWith("}")

private fun paramName(segment: String): String =
    segment.substring(1, segment.length - 1)

splitSegments("/users/{id}") 會回傳 ["users", "{id}"]drop(1) 是因為 /users/{id}.split("/") 會得到 ["", "users", "{id}"],第一個空字串要丟掉

然後是比對函式,一樣是 Router 的 private 成員,回傳匹配結果和提取的參數

private fun matchPath(
    templateSegments: List<String>,
    pathSegments: List<String>,
): Map<String, String>? {
    if (templateSegments.size != pathSegments.size) return null

    val params = mutableMapOf<String, String>()

    for (i in templateSegments.indices) {
        val template = templateSegments[i]
        val actual = pathSegments[i]

        when {
            isParam(template) -> {
                if (actual.isEmpty()) return null  // 空段不匹配參數
                // RelixRequest.path 來自 URI.path,這裡已經是解碼後的值
                params[paramName(template)] = actual
            }
            template == actual -> { /* 靜態段匹配 */ }
            else -> return null  // 靜態段不匹配
        }
    }

    return params
}

回傳 null 代表不匹配,回傳 Map 代表匹配成功 (map 可能是空的,代表純靜態路由),actual 已經來自解碼後的 RelixRequest.path,這一層不再重複處理

更新 Router.match() 支援 segment-based 匹配

matchPath 整合進 Router。靜態路由優先的策略也在這裡實作

class Router {
    private val routes = mutableListOf<Route>()

    fun add(route: Route) {
        routes += route
    }

    fun match(method: String, path: String): MatchResult {
        val pathSegments = splitSegments(normalizePath(path))

        // 收集所有 path 匹配的 route(不管 method)
        data class PathMatch(
            val route: Route,
            val params: Map<String, String>,
            val isStatic: Boolean,
        )

        val pathMatches = routes.mapNotNull { route ->
            val templateSegments = splitSegments(route.path)
            val params = matchPath(templateSegments, pathSegments)
            if (params != null) {
                PathMatch(route, params, isStatic = params.isEmpty()
                    && templateSegments == pathSegments)
            } else null
        }

        if (pathMatches.isEmpty()) {
            return MatchResult.NotFound
        }

        // 靜態優先:先找完全靜態匹配 + method 正確的
        val staticMatch = pathMatches
            .filter { it.isStatic && it.route.method == method }
            .firstOrNull()
        if (staticMatch != null) {
            return MatchResult.Matched(staticMatch.route, staticMatch.params)
        }

        // 再找參數匹配 + method 正確的
        val paramMatch = pathMatches
            .filter { it.route.method == method }
            .firstOrNull()
        if (paramMatch != null) {
            return MatchResult.Matched(paramMatch.route, paramMatch.params)
        }

        // HEAD fallback
        if (method == "HEAD") {
            val getMatch = pathMatches.firstOrNull { it.route.method == "GET" }
            if (getMatch != null) {
                return MatchResult.Matched(getMatch.route, getMatch.params)
            }
        }

        val allowedMethods = pathMatches.map { it.route.method }.toMutableSet().apply {
            if ("GET" in this) add("HEAD")
        }

        return MatchResult.MethodNotAllowed(allowedMethods)
    }

    private fun normalizePath(path: String): String {
        if (path == "/") {
            return "/"
        }
        return path.trimEnd('/')
    }

    // 前面那四個 private 函式
    // splitSegments、isParam、paramName、matchPath 原封不動放在這裡
}

這裡不能再用 URLDecoder,它的規則是給 query string 與 form data 使用,會把 + 當成空白,但 + 在 path 中是一般字元,第 04 篇已經用 URI.path 取得解碼後的路徑,Router 直接保存 segment 即可,否則 %25 這類內容可能被重複解碼

isStatic 的判斷有點取巧,如果 params 是空的而且 template segments 跟 path segments 完全相同,就是純靜態匹配,這樣不需要額外的旗標就能區分靜態和參數路由

第一階段到這裡結束,跑一次測試,前面那些失敗的測試應該全部通過,Router 已經會匹配路徑樣板、提取參數,也維持了原本的 405 與靜態優先行為

第二階段,讓 handler 拿得到參數

Router 現在把參數放在 MatchResult.Matched.pathParams 裡,但 handler 還碰不到它,第二階段要把這段路接起來

第一階段那組測試驗的是 Router 這一層,這次要驗的是從 request 到 response 的完整流程,屬於 Application 層,所以另開一個測試類別,命名沿用第 08 篇 RelixApplicationRoutingTest 的風格

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith

class RelixApplicationPathParamTest {

    @Test
    fun `GET users id returns user id in response`() {
        val app = RelixApplication()
        app.get("/users/{id}") { ok("User: ${pathParam("id")}") }

        val testKit = RelixTestKit(app)
        val response = testKit.handleRequest("GET", "/users/42")

        assertEquals(200, response.statusCode)
        assertEquals("User: 42", response.bodyAsText())
    }

    @Test
    fun `nested path params work end to end`() {
        val app = RelixApplication()
        app.get("/users/{userId}/posts/{postId}") {
            ok("${pathParam("userId")}/${pathParam("postId")}")
        }

        val testKit = RelixTestKit(app)
        val response = testKit.handleRequest("GET", "/users/alice/posts/first")

        assertEquals(200, response.statusCode)
        assertEquals("alice/first", response.bodyAsText())
    }

    @Test
    fun `pathParamOrNull returns null for an unknown name`() {
        val app = RelixApplication()
        app.get("/users/{id}") { ok(pathParamOrNull("name") ?: "none") }

        val testKit = RelixTestKit(app)
        val response = testKit.handleRequest("GET", "/users/42")

        assertEquals("none", response.bodyAsText())
    }

    @Test
    fun `pathParam throws for an unknown name`() {
        val app = RelixApplication()
        app.get("/users/{id}") { ok(pathParam("name")) }

        val testKit = RelixTestKit(app)

        assertFailsWith<IllegalStateException> {
            testKit.handleRequest("GET", "/users/42")
        }
    }
}

更新 RelixCall,加入 pathParams

回到第 04 篇的分層原則,path parameters 是 Router 匹配後「框架產出的資料」,不是 HTTP 原始資訊,所以放在 RelixCall 而不是 RelixRequest,改的是第 06 篇建立的 RelixCall.kt

class RelixCall(
    val application: RelixApplication,
    val request: RelixRequest,
    internal var pathParams: Map<String, String> = emptyMap(),
) {
    fun pathParam(name: String): String =
        pathParams[name] ?: error("Missing path param: $name")

    fun pathParamOrNull(name: String): String? =
        pathParams[name]
}

提供兩個 API,pathParam() 在參數不存在時直接拋例外 (因為如果路由匹配成功了,參數一定存在,不存在代表程式有 bug),pathParamOrNull() 回傳 nullable 給特殊情況用

接著改 RelixApplication.kthandle(),不要為了 path params 建立第二個 RelixCall,否則前面放進 call 的狀態會消失,Router 應把匹配結果寫回同一個生命週期物件

fun handle(call: RelixCall): RelixResponse {
    val result = router.match(call.request.method, call.request.path)

    val response = when (result) {
        is MatchResult.Matched -> {
            call.pathParams = result.pathParams
            result.route.handler(call)
        }
        is MatchResult.NotFound -> notFound()
        is MatchResult.MethodNotAllowed ->
            methodNotAllowed(result.allowedMethods)
    }

    return if (call.request.method == "HEAD") {
        response.copy(body = ByteArray(0))
    } else {
        response
    }
}

再跑一次測試,第二階段那四個也通過了,它們走過 Router 匹配、pathParams 提取、RelixCall 的參數寫入、handler 內的 pathParam() 存取,任何一環出問題都會立刻被抓到

常見陷阱與設計取捨

URL decode 的時機

URL decode 已在 adapter 讀取 URI.path 時完成,Router 與 pathParam() 都不再 decode,避免 %25 被處理兩次,也不會把 path 裡的 + 誤當空白

重複的 param 名稱

/{id}/{id} 這種路徑樣板會讓後面的值覆蓋前面的 (因為放進 Map 時 key 相同),一般的框架通常會在註冊路由時就檢查並拋例外,這裡我們先不做這個檢查,但如果你覺得有必要,在 add() 方法裡加一行 require 就好

靜態優先的實作方式

目前用 params.isEmpty() && template == path 判斷靜態路由,足以處理固定 segment 與必填參數,若未來加入可選參數或 wildcard,應讓 Route 在註冊時解析成明確的 segment 型別,而不是繼續靠空 Map 推論


小結

路徑參數讓 Router 從只能匹配固定路徑,進一步支援 REST 風格的動態路徑,分段匹配的邏輯不複雜,但仍要明確決定解碼時機、靜態優先策略與空段處理,這些行為都用測試固定下來,後續重構 Router 時才有可靠的回歸保護


下一篇

下一篇我們進入路由 DSL,用 Kotlin 的 Lambda with Receiver 寫出 routing { get("/hello") { ok("Hello!") } } 這種語法。這篇會深入講 Lambda with Receiver 的原理,也就是 RelixCall.() -> RelixResponse 這個型別背後真正在發生什麼事


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 08 Route 與匹配規則,讓路由行為可預期
下一篇
Kotlin 手刻 Ktor 從零開始 Day 10 路由 DSL (上),用 Kotlin DSL 寫出漂亮的路由定義
系列文
Kotlin 手刻 Ktor 從零開始18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言