iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Software Development

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

Kotlin 手刻 Ktor 從零開始 Day 11 路由 DSL (下),路由群組與巢狀路由

  • 分享至 

  • xImage
  •  

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

第 10 篇我們做出了基本的 routing { get("/path") { ... } },但寫 REST API 時,你會想要把相關的路由放在一起

routing {
    route("/api/v1") {
        route("/users") {
            get { ok("list users") }
            get("/{id}") { ok("user ${pathParam("id")}") }
            post { created("user created") }
        }
    }
}

這篇要做兩件事,路由群組的前綴拼接,以及讓 TestKit 也支援 routing { } DSL

TDD 確認路徑合併規則

巢狀路由的關鍵不在 DSL 語法,而在「路徑合併規則」,先寫測試把規則確認清楚,檔案放 PathNormalizeTest.kt

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

class PathNormalizeTest {

    @Test
    fun `normal prefix and path`() {
        assertEquals("/api/users", normalizePath("/api", "/users"))
    }

    @Test
    fun `prefix with trailing slash`() {
        assertEquals("/api/users", normalizePath("/api/", "/users"))
    }

    @Test
    fun `path without leading slash`() {
        assertEquals("/api/users", normalizePath("/api", "users"))
    }

    @Test
    fun `prefix with multiple trailing slashes`() {
        assertEquals("/api/users", normalizePath("/api//", "/users"))
    }

    @Test
    fun `empty path means use prefix as-is`() {
        assertEquals("/api", normalizePath("/api", ""))
    }

    @Test
    fun `empty prefix with path`() {
        assertEquals("/users", normalizePath("", "/users"))
    }

    @Test
    fun `both empty means root`() {
        assertEquals("/", normalizePath("", ""))
    }

    @Test
    fun `deeply nested prefixes`() {
        val step1 = normalizePath("/api", "/v1")
        val step2 = normalizePath(step1, "/users")
        val step3 = normalizePath(step2, "/{id}")
        assertEquals("/api/v1/users/{id}", step3)
    }
}

normalizePath 把規則集中在一個函式

有了測試,實作就很直接

normalizePath 不屬於 RoutingBuilder 也不屬於 RouteGroup,這篇後面兩個 class 都會呼叫它,所以寫成 top-level function,開一個新檔案 PathNormalize.kt 放它

fun normalizePath(prefix: String, path: String): String {
    if (prefix.isEmpty() && path.isEmpty()) {
        return "/"
    }

    if (prefix.isEmpty()) {
        return if (path.startsWith("/")) path else "/$path"
    }

    if (path.isEmpty()) {
        return prefix
    }

    val cleanPrefix = prefix.trimEnd('/')
    val cleanPath = if (path.startsWith("/")) path else "/$path"
    return cleanPrefix + cleanPath
}

邏輯很簡單,把 prefix 尾巴的 / 去掉,確保 path 開頭有 /,然後拼起來,三個 early return 處理邊界情況

為什麼不用 URI.resolve() 或其他標準函式 ? 因為我們的路徑合併是「prefix 疊加」,不是 URL 的相對路徑解析,/api + /users 在 URI 規範裡會變成 /users (因為 /users 是絕對路徑),這不是我們要的行為

TDD 確認 RouteGroup 的疊加行為

normalizePath 只管兩個字串怎麼拼,接下來要做的 RouteGroup 才是把這個規則套到每一條路由上的人,它不需要 Router,也不需要 TestKit,給它一個 MutableList<Route>,看它往裡面加了什麼就好,檔案放 RouteGroupTest.kt

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame

class RouteGroupTest {

    @Test
    fun `group prepends its prefix to every path`() {
        val routes = mutableListOf<Route>()
        val group = RouteGroup("/api", routes)

        group.get("/users") { ok("get") }
        group.post("/users") { created("post") }
        group.put("/users/{id}") { ok("put") }
        group.delete("/users/{id}") { ok("delete") }

        assertEquals(
            listOf(
                "GET" to "/api/users",
                "POST" to "/api/users",
                "PUT" to "/api/users/{id}",
                "DELETE" to "/api/users/{id}",
            ),
            routes.map { it.method to it.path },
        )
    }

    @Test
    fun `overload without path registers the prefix itself`() {
        val routes = mutableListOf<Route>()
        val group = RouteGroup("/users", routes)

        group.get { ok("list") }
        group.post { created("create") }
        group.put { ok("replace") }
        group.delete { ok("remove") }

        assertEquals(
            listOf(
                "GET" to "/users",
                "POST" to "/users",
                "PUT" to "/users",
                "DELETE" to "/users",
            ),
            routes.map { it.method to it.path },
        )
    }

    @Test
    fun `nested group stacks prefixes into the same list`() {
        val routes = mutableListOf<Route>()
        val group = RouteGroup("/api", routes)

        group.route("/v1") {
            get("/users") { ok("list") }
            route("/users") {
                get("/{id}") { ok("one") }
            }
        }

        assertEquals(
            listOf("/api/v1/users", "/api/v1/users/{id}"),
            routes.map { it.path },
        )
    }

    @Test
    fun `group keeps the handler it was given`() {
        val routes = mutableListOf<Route>()
        val handler: RelixHandler = { ok("Hello!") }

        RouteGroup("/api", routes).get("/hello", handler)

        assertSame(handler, routes.single().handler)
    }
}

第一個把四個 method 都確認一次,驗 prefix 有沒有正確疊到每一條路由上,順便確認 method 字串沒寫錯

第二個驗不帶 path 的那組多載,get { } 註冊的路徑就是 group 的 prefix 本身,put { }delete { } 後面的整合測試不會碰到,哪個多載委派錯了 (例如 delete(handler) 手滑寫成 get("", handler)),只有這個測試看得出來

第三個是設計重點的測試版本,測試自己拿著那份 routes,巢狀兩層之後兩條路由都出現在同一份 list 裡,代表 RouteGroup 沒有自己收集一份再往上交

第四個跟第 10 篇的 RoutingBuilderTest 一樣用 assertSame,group 只加 prefix,不會偷偷包一層 handler

RouteGroup,帶前綴的路由作用域

RouteGroup 是巢狀路由的核心,它持有一個 currentPrefix,所有在它上面註冊的路由都會自動加上這個前綴,檔案放 RouteGroup.kt

@RelixDsl
class RouteGroup(
    private val prefix: String,
    private val routes: MutableList<Route>,
) {
    fun get(path: String, handler: RelixHandler) {
        routes += Route("GET", normalizePath(prefix, path), handler)
    }

    fun get(handler: RelixHandler) = get("", handler)

    fun post(path: String, handler: RelixHandler) {
        routes += Route("POST", normalizePath(prefix, path), handler)
    }

    fun post(handler: RelixHandler) = post("", handler)

    fun put(path: String, handler: RelixHandler) {
        routes += Route("PUT", normalizePath(prefix, path), handler)
    }

    fun put(handler: RelixHandler) = put("", handler)

    fun delete(path: String, handler: RelixHandler) {
        routes += Route("DELETE", normalizePath(prefix, path), handler)
    }

    fun delete(handler: RelixHandler) = delete("", handler)

    fun route(subPrefix: String, block: RouteGroup.() -> Unit) {
        val group = RouteGroup(normalizePath(prefix, subPrefix), routes)
        group.block()
    }
}

幾個設計重點

RouteGroupRoutingBuilder 共用同一個 routes list,RouteGroup 不會自己收集 routes 再交出去,而是直接往共享的 list 裡面加,巢狀的 route() 建一個新的 RouteGroup,prefix 疊加,但 routes list 是同一個,這樣不管巢狀幾層,最後 RoutingBuilder.build() 拿到的就是所有 routes 的完整列表

每個 HTTP method 都有兩個多載,get(path, handler)get(handler),後者直接委派給 get("", handler)normalizePath 的第三個 early return 會回傳 prefix,所以路徑就是 group 的 prefix 本身

TDD 巢狀路由的整合測試

RouteGroup 做好了,但使用者還進不去,頂層的 routing { } 少了 route() 這個入口,先用 TestKit 把期待的寫法寫成整合測試,確認 DSL → Router → 匹配整條路都會通,檔案放 NestedRoutingTest.kt

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

class NestedRoutingTest {

    @Test
    fun `route group adds prefix`() {
        val app = RelixApplication()
        app.routing {
            route("/api") {
                get("/hello") { ok("Hello from API") }
            }
        }

        val testKit = RelixTestKit(app)
        val response = testKit.handleRequest("GET", "/api/hello")

        assertEquals(200, response.statusCode)
        assertEquals("Hello from API", response.bodyAsText())
    }

    @Test
    fun `nested route groups stack prefixes`() {
        val app = RelixApplication()
        app.routing {
            route("/api/v1") {
                route("/users") {
                    get("/{id}") { ok("User: ${pathParam("id")}") }
                }
            }
        }

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

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

    @Test
    fun `get without path uses group prefix`() {
        val app = RelixApplication()
        app.routing {
            route("/users") {
                get { ok("list users") }
            }
        }

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

        assertEquals(200, response.statusCode)
        assertEquals("list users", response.bodyAsText())
    }

    @Test
    fun `multiple methods in same group`() {
        val app = RelixApplication()
        app.routing {
            route("/items") {
                get { ok("list") }
                post { created("created") }
            }
        }

        val testKit = RelixTestKit(app)

        assertEquals(200, testKit.handleRequest("GET", "/items").statusCode)
        assertEquals(201, testKit.handleRequest("POST", "/items").statusCode)
    }

    @Test
    fun `routes outside and inside group coexist`() {
        val app = RelixApplication()
        app.routing {
            get("/health") { ok("OK") }
            route("/api") {
                get("/users") { ok("users") }
            }
        }

        val testKit = RelixTestKit(app)

        assertEquals("OK", testKit.handleRequest("GET", "/health").bodyAsText())
        assertEquals("users", testKit.handleRequest("GET", "/api/users").bodyAsText())
    }

    @Test
    fun `nested route returns 404 for wrong path`() {
        val app = RelixApplication()
        app.routing {
            route("/api") {
                get("/hello") { ok("Hello") }
            }
        }

        val testKit = RelixTestKit(app)
        assertEquals(404, testKit.handleRequest("GET", "/hello").statusCode)
    }
}

升級 RoutingBuilder,加上 route()

RoutingBuilder 需要一個 route() 方法來建立 RouteGroup,改的是第 10 篇建立的 RoutingBuilder.kt

@RelixDsl
class RoutingBuilder {

    fun route(prefix: String, block: RouteGroup.() -> Unit) {
        val group = RouteGroup(prefix, routes)
        group.block()
    }

    //...
}

route() 建一個 RouteGroup,把 routes list 傳進去,然後在 group 上面執行 block,因為 block 的型別是 RouteGroup.() -> Unit,所以 block 裡的 thisRouteGroup,使用者可以直接呼叫 get()post()route() 等方法

注意 RoutingBuilderget/post 不帶 prefix (因為它是頂層),而 RouteGroupget/post 會用 normalizePath 加上 prefix。這是兩者的差異

TestKit 升級 testApplication

到這裡你會發現,每次測試都要寫 val app = RelixApplication() + app.routing { } + val testKit = RelixTestKit(app),三行樣板可以包成一個函式,檔案就用第 06 篇的 RelixTestKit.kt,但要注意它是 top-level function,寫在 class 外面

class RelixTestKit(private val application: RelixApplication) {
    // ...第 06 篇的 handleRequest 沒有變
}

fun testApplication(
    setup: RelixApplication.() -> Unit,
): RelixTestKit {
    val app = RelixApplication()
    app.setup()
    return RelixTestKit(app)
}

它不能寫成 RelixTestKit 的方法,因為這個函式的工作就是生一個 RelixTestKit 出來,如果它是 member,你得先有一個 RelixTestKit 才能呼叫它,測試裡的 testApplication { } 會找不到人

setup 的型別是 RelixApplication.() -> Unit,所以 lambda 裡的 thisRelixApplication,你可以直接呼叫 routing { }

用起來長這樣,這個加在 NestedRoutingTest.kt

@Test
fun `testApplication helper works`() {
    val testKit = testApplication {
        routing {
            route("/api") {
                get("/hello") { ok("Hello!") }
            }
        }
    }

    val response = testKit.handleRequest("GET", "/api/hello")
    assertEquals(200, response.statusCode)
}

少了三行樣板,測試的意圖更清楚,而且因為 setup 裡寫的就是使用者真正會寫的 DSL,如果 DSL 設計有問題 (例如 routing { } 不能在某個 scope 裡呼叫),測試會先幫你抓到

前面那六個也可以順手換成這個寫法,往後的篇數都會這樣寫測試

順帶解釋一下為什麼是 RelixApplication.() -> Unit,而不是新做一個 TestApplicationBuilder class 包一層,後者得在 builder 上把 RelixApplication 的每個方法 (routinginstalluse...) 都重新轉發一次,每加一個新方法,builder 也得跟著改,兩邊的介面容易逐漸不同,直接用 application 當 receiver,測試與正式 app 使用同一套 DSL

第 28 篇會把 testApplication 升級成完整的 TestRelixApplication,支援 plugin install 和 Content Negotiation

常見陷阱與設計取捨

RouteGroup 和 RoutingBuilder 為什麼不共用一個基底類別 ?

看起來 RouteGroupRoutingBuilder 有很多重複的 get/post 方法,你可能會想抽一個 RouteScope 介面,可以這樣子做,但目前先不做,這兩者的行為有一點差異,RoutingBuilderget(path) 不加 prefix,RouteGroupget(path) 要用 normalizePath 加 prefix,等共同規則穩定後再抽取,會比現在先藏住差異更容易維護,這也是一般人在設計架構上很容易會犯的錯誤,提早優化

route() 裡面可以直接呼叫外層的 routing { } 嗎 ?

不行,第 10 篇加的 @RelixDsl annotation 會擋住這件事,在 RouteGroup 的 lambda 裡,compiler 不讓你隱式呼叫外層 RelixApplicationrouting(),如果你真的需要 (通常代表設計有問題),要用 this@routing 明確指定

prefix 結尾要不要帶 / ?

我們的 normalizePath 會把 prefix 尾巴的 / 去掉,所以 route("/api/")route("/api") 效果一樣,這是刻意的寬容設計,避免使用者因為多一個 / 就拼出錯誤路徑,前面的文章也有說明過了


小結

巢狀路由的核心是一個 normalizePath 函式和一個帶 prefix 的 RouteGroup,路徑合併規則集中在一個地方,RouteGroupRoutingBuilder 共用 routes list,所以不管巢狀幾層,routes 最後都會交給 Router,testApplication { } 讓測試跟正式用法用同一套 DSL,減少樣板,也不會讓兩邊的寫法愈走愈遠


下一篇

下一篇會講 Middleware 和 Pipeline 概念,我們會畫出洋蔥模型的流程,定義 RelixMiddleware 的型別簽名,並用具體例子說明 before/after 處理和短路行為


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 10 路由 DSL (上),用 Kotlin DSL 寫出漂亮的路由定義
下一篇
Kotlin 手刻 Ktor 從零開始 Day 12 Pipeline 的概念,請求處理的洋蔥模型
系列文
Kotlin 手刻 Ktor 從零開始18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言