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()
    }
}

幾個設計重點

RouteGroup 和 RoutingBuilder 共用同一個 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 裡的 this 是 RouteGroup,使用者可以直接呼叫 get()、post()、route() 等方法

注意 RoutingBuilder 的 get/post 不帶 prefix (因為它是頂層),而 RouteGroup 的 get/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 裡的 this 是 RelixApplication,你可以直接呼叫 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 的每個方法 (routing、install、use...) 都重新轉發一次,每加一個新方法,builder 也得跟著改,兩邊的介面容易逐漸不同,直接用 application 當 receiver,測試與正式 app 使用同一套 DSL

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

常見陷阱與設計取捨

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

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

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

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

prefix 結尾要不要帶 / ?

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


小結

巢狀路由的核心是一個 normalizePath 函式和一個帶 prefix 的 RouteGroup,路徑合併規則集中在一個地方,RouteGroup 和 RoutingBuilder 共用 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 從零開始 共 32 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言