
到第 09 篇為止,我們的 Router 已經能匹配 method/path、回 404/405、支援路徑參數,功能看起來應該是夠了,但使用者體驗不行,目前註冊路由要寫 app.get("/hello") { ... },路由一多就會變成一堆 app.xxx 散落在各處,這篇要做出一個看起來像框架的路由 DSL
routing {
get("/hello") { ok("Hello!") }
post("/users") { created("created") }
}
我們從第 06 篇就在用 RelixCall.() -> RelixResponse 這個型別,但當時只說「先體驗,後面再解釋」,現在是把它講清楚的時候了
這個語法在上一個系列介紹過,想先把基礎補起來的話可以參考
RelixCall.() 這個 receiver 語法的近親let、run、with、apply、also,把「帶 Receiver 的 Lambda」整個拆開來看下面還是會從頭講一次,沒看過也不影響
先看一個普通的 lambda
val greet: (String) -> String = { name -> "Hello, $name!" }
greet("Relix") // "Hello, Relix!"
greet 接收一個 String 參數,回傳 String。很直覺
再看 lambda with receiver
val greet: String.() -> String = { "Hello, $this!" }
"Relix".greet() // "Hello, Relix!"
差異在哪 ? String.() 把第一個參數變成了 this,在 lambda 裡面,this 就是那個 String,你可以直接用 this 或省略它來存取 String 的屬性和方法
這跟 extension function 的概念一樣,String.() -> String 就是「一個在 String 上面執行的函式」
現在回頭看 handler 的型別
typealias RelixHandler = RelixCall.() -> RelixResponse
翻譯成白話,「一個在 RelixCall 上面執行的函式,回傳 RelixResponse」,所以在 handler 裡,this 是 RelixCall,你可以直接寫 request (因為 RelixCall 有 request 屬性)、pathParam("id") (因為 RelixCall 有這個方法)、ok("Hello!") (如果 ok() 是 top-level function 的話不需要 receiver,但概念是通的)
handler 能省略 call.,是 receiver lambda 的型別規則,不是額外的 runtime 機制
// 沒有 receiver — 要寫 call.xxx
val handler: (RelixCall) -> RelixResponse = { call ->
val id = call.pathParam("id")
ok("User: $id")
}
// 有 receiver — 直接寫 xxx
val handler: RelixCall.() -> RelixResponse = {
val id = pathParam("id")
ok("User: $id")
}
兩者功能完全一樣,差別只在手感,receiver lambda 讓 handler 讀起來像在描述「這個請求要做什麼」,而不是在寫「拿到一個 call 物件然後從裡面撈東西」
DSL 的設計要從使用者的角度出發。先寫測試,確認「我希望使用者怎麼寫」
import kotlin.test.Test
import kotlin.test.assertEquals
class RoutingDslTest {
@Test
fun `routing DSL registers GET route`() {
val app = RelixApplication()
app.routing {
get("/hello") { ok("Hello!") }
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/hello")
assertEquals(200, response.statusCode)
assertEquals("Hello!", response.bodyAsText())
}
@Test
fun `routing DSL registers multiple routes`() {
val app = RelixApplication()
app.routing {
get("/hello") { ok("Hello!") }
post("/users") { created("user created") }
put("/users/{id}") { ok("updated ${pathParam("id")}") }
delete("/users/{id}") { ok("deleted") }
}
val testKit = RelixTestKit(app)
assertEquals(200, testKit.handleRequest("GET", "/hello").statusCode)
assertEquals(201, testKit.handleRequest("POST", "/users").statusCode)
assertEquals(200, testKit.handleRequest("PUT", "/users/1").statusCode)
assertEquals(200, testKit.handleRequest("DELETE", "/users/1").statusCode)
}
@Test
fun `routing DSL supports path parameters`() {
val app = RelixApplication()
app.routing {
get("/users/{id}") { ok("User: ${pathParam("id")}") }
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/users/42")
assertEquals("User: 42", response.bodyAsText())
}
@Test
fun `unregistered route returns 404`() {
val app = RelixApplication()
app.routing {
get("/hello") { ok("Hello!") }
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/missing")
assertEquals(404, response.statusCode)
}
}
有了這些測試,不管內部怎麼實作 builder 都無所謂,只要測試通過就好
RoutingBuilder 的工作很單純,提供 get/post/put/delete/patch 方法讓使用者註冊路由,內部把它們整理成 Route 物件
上面那組 RoutingDslTest 是從使用者的角度測的,一路走過 DSL、Router 和 TestKit,測試會過代表整條路都通了,但也代表任何一段壞掉,看到的都是同一種紅字,所以 builder 自己也值得有一組測試,它不需要 Router、不需要 TestKit,直接檢查 build() 出來的 Route 清單就好
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame
class RoutingBuilderTest {
@Test
fun `builder registers every supported method in order`() {
val builder = RoutingBuilder()
builder.get("/r") { ok("get") }
builder.post("/r") { created("post") }
builder.put("/r") { ok("put") }
builder.delete("/r") { ok("delete") }
builder.patch("/r") { ok("patch") }
val methods = builder.build().map { it.method }
assertEquals(listOf("GET", "POST", "PUT", "DELETE", "PATCH"), methods)
}
@Test
fun `builder keeps the path and handler it was given`() {
val builder = RoutingBuilder()
val handler: RelixHandler = { ok("Hello!") }
builder.get("/hello", handler)
val route = builder.build().single()
assertEquals("/hello", route.path)
assertSame(handler, route.handler)
}
@Test
fun `build returns a snapshot not a live view`() {
val builder = RoutingBuilder()
builder.get("/hello") { ok("Hello!") }
val routes = builder.build()
builder.get("/late") { ok("late") }
assertEquals(1, routes.size)
}
}
三個測試各自驗證一件事
第一個把五個方法一次掃過,驗的是「method 字串有沒有對」和「順序有沒有保住」,不用寫五個長得一樣的測試,這個也補了一個外層測不到的角落,RoutingDslTest 只用了 get/post/put/delete,patch 從頭到尾沒人碰過,如果 patch() 裡面手滑寫成 Route("PUT", ...),那組 DSL 測試會全部通過
第二個用 assertSame 確認 builder 是原封不動把 handler 放進 Route,沒有偷偷包一層,builder 的職責是收集不是執行,所以這裡不需要真的呼叫 handler,也就不需要生一個 RelixCall 出來
第三個在驗 build() 回的是當下的快照,不是內部那份 mutableListOf 的別名,之後如果有人手滑把 routes.toList() 改成 routes,這個測試會馬上失敗給你看,而外層那組 DSL 測試不會,因為使用者根本碰不到 builder
class RoutingBuilder {
private val routes = mutableListOf<Route>()
fun get(path: String, handler: RelixHandler) {
routes += Route("GET", path, handler)
}
fun post(path: String, handler: RelixHandler) {
routes += Route("POST", path, handler)
}
fun put(path: String, handler: RelixHandler) {
routes += Route("PUT", path, handler)
}
fun delete(path: String, handler: RelixHandler) {
routes += Route("DELETE", path, handler)
}
fun patch(path: String, handler: RelixHandler) {
routes += Route("PATCH", path, handler)
}
fun build(): List<Route> = routes.toList()
}
然後在 RelixApplication 加一個 routing 方法
class RelixApplication {
private val router = Router()
fun routing(block: RoutingBuilder.() -> Unit) {
val builder = RoutingBuilder()
builder.block()
builder.build().forEach { router.add(it) }
}
// get()/post() 那組和 handle() 都跟之前一樣,沒有被拿掉
}
routing(block: RoutingBuilder.() -> Unit) 這行是整個 DSL 的關鍵
block 的型別是 RoutingBuilder.() -> Unit,代表 block 裡面的 this 是 RoutingBuilder,所以使用者在 routing { } 裡面可以直接呼叫 get(...)、post(...) 等方法,不用寫 builder.get(...)
這跟 handler 的 RelixCall.() -> RelixResponse 是同一個概念,只是 receiver 換成了 RoutingBuilder,回傳型別是 Unit (因為 builder 不需要回傳什麼)
DSL 和 Router 的責任切得很清楚,DSL 只負責「描述路由長什麼樣」,Router 負責「怎麼匹配」,你可以改 DSL 語法而不動 Router,也可以改 Router 內部實作而不動 DSL
下一篇會做巢狀路由 route("/api") { ... },先講一個會遇到的問題
假設沒有 @DslMarker,巢狀 DSL 裡可以呼叫外層 receiver 的方法
routing {
route("/api") {
// 這裡的 this 是內層的 RouteGroup
// 但沒有 @DslMarker 的話,也能呼叫外層 RoutingBuilder 的方法
get("/hello") { ok("Hello!") } // OK — 呼叫內層的 get
// build() // 糟糕 — 不小心呼叫外層 RoutingBuilder.build()
}
}
Kotlin 的 @DslMarker 可以避免這種誤用,定義一個標記
@DslMarker
annotation class RelixDsl
然後回頭把它標在所有 DSL 的 receiver 類別上,前面寫的 RoutingBuilder 就是第一個
@RelixDsl
class RoutingBuilder { ... } // 前面那個,補上標記
加上 @RelixDsl 之後,compiler 會禁止你在內層 lambda 裡隱式呼叫外層 receiver 的方法,傳給 routing() 的 lambda 已有 routing 隱式標籤,確實需要存取外層時可以明確寫 this@routing,這讓 DSL 的作用域更清楚,也能避免意外操作外層 builder
前面每加一個東西都補了測試,@RelixDsl 卻沒有,這不是漏掉
@DslMarker 的效果整個發生在編譯期,違規的寫法不會變成執行期會爆炸的程式碼,它是根本編譯不出來,所以你想寫一個「在內層呼叫外層的 build() 應該要失敗」的測試,那個測試檔自己就先編不過,整個 test source set 會一起陪葬,測試連跑都跑不起來
真要驗「這段程式碼不該編譯」,得用 kotlin-compile-testing 這類函式庫,在測試裡把原始碼當字串丟給 compiler,再斷言它吐出哪個錯誤訊息,這是驗 compiler plugin 或 annotation processor 的做法,為了一個 @DslMarker 拉一整套進來並不划算,這系列不會走這條路
反過來看,「加了標記之後正常寫法還是能編譯」,用目前的那些測試就好,RoutingDslTest 只要編得起來、跑得過,就代表 marker 沒有誤擋掉該過的用法,編譯期的規則,交給編譯本身去驗就夠了
寫到這裡,你已經做出了 Ktor 路由 DSL 的最小版本,先看真正跑起來的 main 前後差在哪
// 第 09 篇為止,一條路由一行 app.xxx
fun main() {
val app = RelixApplication()
app.get("/hello") { ok("Hello!") }
app.post("/users") { created("done") }
JdkHttpServerAdapter(app).start(8080)
}
// 這篇之後,路由集中在一個區塊裡
fun main() {
val app = RelixApplication()
app.routing {
get("/hello") { ok("Hello!") }
post("/users") { created("done") }
}
JdkHttpServerAdapter(app).start(8080)
}
只有兩條路由的時候差別不大,等到十幾條、又要分成 /api/v1 和 /admin 兩組的時候,前者會變成一整片 app.get 的牆,後者至少有一個框把它們框起來,下一篇的 route() 群組就是接著解決這件事
app.get() 這組舊 API 並沒有被刪掉,routing { } 是疊在它上面的一層,前面幾篇的測試也還是用 app.get() 註冊,兩種寫法會一路共存到系列結束
接著把 DSL 的部分跟 Ktor 放在一起看
// Relix
app.routing {
get("/hello") { ok("Hello!") }
post("/users") { created("done") }
}
// Ktor
fun Application.module() {
routing {
get("/hello") { call.respondText("Hello!") }
post("/users") { call.respond(HttpStatusCode.Created, "done") }
}
}
相似的地方,都用 routing { } 當進入點,都用 get/post 等方法註冊路由,handler 都是 lambda
差異在於,Ktor 的 handler 裡用 call.respondText() (call 是 ApplicationCall 的隱式變數),我們用 ok() (因為 this 就是 RelixCall,回傳值就是 response)
Ktor 是 suspend 的,我們目前還是同步 (第 14 篇才會實作),Ktor 的 routing engine 底下有 phase-based pipeline,我們的 Router 比較直接
至於啟動的那一段,Ktor 那邊是 embeddedServer(...) 把 module 掛上去,位置就是我們 main 裡那行 JdkHttpServerAdapter(app).start(8080),都是「先描述好路由,再把它插上一個真的 server」
如果你能自己做出這個 DSL,再回頭看 Ktor 的原始碼,你會更容易理解它為什麼那樣分層
拿其他語言的類似機制來對照,Groovy 的 with 和 Scala 的 apply block 也都能讓你「在某個物件的作用域裡寫程式」,差別在於 Groovy 偏動態,名稱解析可能一路往外找,Scala 依賴 implicit conversion,語法糖比較硬,Kotlin 的 receiver lambda 則是靜態型別,編譯期就知道 this 是誰、能呼叫哪些方法,所以 IDE 補全、重構和型別檢查都跟一般 Kotlin 程式碼一樣穩定
routing { } 可以呼叫多次嗎 ?
可以。每次呼叫 routing { } 都會建一個新的 RoutingBuilder,收集到的 routes 會追加到 Router 裡,這跟 Ktor 的行為一致,好處是你可以把路由分散在不同的設定函式裡 (例如 setupUserRoutes(app)、setupAdminRoutes(app)),壞處是不小心呼叫兩次可能會重複註冊
為什麼 RoutingBuilder 不直接持有 Router 的引用 ?
因為 builder 的職責是「收集」,不是「註冊」,讓 builder 持有 Router 引用會把兩個責任混在一起,分開之後,你可以在 build() 完之後做額外處理 (例如檢查有沒有重複路由),或者像前面 RoutingBuilderTest 那樣,直接測 builder 而不需要真的 Router
handler 裡的 this 到底是什麼 ?
在 routing { get("/hello") { ok("Hello!") } } 這段裡有兩層 lambda。外層 routing { } 的 this 是 RoutingBuilder,內層 { ok("Hello!") } 的 this 是 RelixCall (因為 handler 的型別是 RelixCall.() -> RelixResponse)。兩層的 receiver 不同,但你不太會搞混,因為 get 是 builder 的方法,ok 跟 pathParam 是在 handler 裡用的
Lambda with Receiver 是 Kotlin DSL 的基石,我們用它做了兩件事,handler 裡的 this 是 RelixCall (讓 handler 寫起來像 DSL),routing { } 裡的 this 是 RoutingBuilder (讓路由註冊寫起來像 DSL)
@DslMarker 確保巢狀 DSL 不會作用域混亂,這些都不需要 annotation processing 或 code generation,純粹是 Kotlin 型別系統的能力
下一篇做路由 DSL 的下半部,路由群組 route("/api/v1") { ... } 和巢狀路由,重點是路徑的前綴拼接規則 (怎麼避免拼出 // 或漏掉 /),還有讓 TestKit 也支援 routing { } 語法
同步刊登於 Blog
圖片來源:AI 產生