
routing 區塊已經有 3 條路由,全部擠在 module() 裡,Todo API 之後還要長出新增、修改、刪除,再全部往同一個函式裡塞,Application.kt 很快就會變成一個什麼都有的大檔案,這篇把路由組織起來,route() 分組、把路由抽成 extension function,讓 Todo 的路由住進自己的檔案
這篇不加任何端點,也不寫任何新測試,從頭到尾行為都不會變,變的只有程式碼放在哪裡、長什麼形狀,這件事有個正式的名字,重構,那驗收標準是什麼 ? 就是到 day 06 為止累積的 12 個測試,改完之後應該要全部通過
route("/todos") { } 把 2 條 Todo 路由的共同前綴抽到外層Route 的 extension function,搬進獨立的 TodoRoutes.kt
module() 回到只負責組裝get { } 和 get("/") { } 差在哪,讓測試親自示範route() 疊成 2 層看前綴怎麼接,順便決定這個系列現在要不要疊重構最怕的事只有一件,改了結構,順便不小心改了行為,而前幾篇累積下來的 12 個測試,正好把行為看得緊緊的,它們不只測功能,連非法 limit 回 400、trailing slash 回 404、POST 沒註冊回 405 都固定下來了,當時說那是「用測試確認預設行為」,這篇它們多了第 2 個身分,重構的安全網
所以這篇的流程很單純,動手搬程式碼,搬完跑測試,所有的測試一個都不改,全部通過就代表重構成功,任何一個失敗就代表搬的過程弄壞了什麼
開一個新檔案 src/main/kotlin/com/cashwu/todo/TodoRoutes.kt,把 2 條 Todo 路由整組搬過來
package com.cashwu.todo
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
fun Route.todoRoutes() {
route("/todos") {
get {
val limitText = call.request.queryParameters["limit"]
val limit = if (limitText == null) todos.size else limitText.toIntOrNull()?.takeIf { it >= 0 }
if (limit == null) {
call.respondText("limit 要是 0 以上的整數", status = HttpStatusCode.BadRequest)
return@get
}
call.respondText(todos.take(limit).joinToString("\n"))
}
get("{id}") {
val id = call.parameters["id"]?.toIntOrNull()
if (id == null) {
call.respondText("id 要是數字", status = HttpStatusCode.BadRequest)
return@get
}
val todo = todos.getOrNull(id - 1)
if (todo == null) {
call.respondText("找不到 id $id 的待辦", status = HttpStatusCode.NotFound)
return@get
}
call.respondText(todo)
}
}
}
再把 src/main/kotlin/com/cashwu/todo/Application.kt 的 module() 改成下面這樣,todos 清單和 main 都不動,順手把已經搬走的路由用到的 import 清掉
fun Application.module() {
routing {
get("/") {
call.respondText("Hello, Ktor!")
}
todoRoutes()
}
}
改動就這 2 個檔案,handler 裡面的程式碼一行都沒變,變的是它們外面包的東西
重構前,2 條路由各自寫了完整路徑,get("/todos") 和 get("/todos/{id}"),/todos 這個前綴出現 2 次,route("/todos") { } 做的事是開一個帶路徑的節點,裡面註冊的東西,路徑從外層一路接下來
規則只有一條,一個 handler 的路徑,是從根往下走到它,把沿途每一層 route() 的 path 依序接起來,get、post 這些 method builder 本身不建節點,它們是掛在某個節點上的 handler,決定這個節點對哪些 HTTP method 有反應,所以現在這份程式碼註冊出來的是這 2 條
| 程式碼位置 | 實際註冊的路徑 |
|---|---|
route("/todos") 底下的 get { } |
/todos |
同一層的 get("{id}") { } |
/todos/{id} |
層與層之間的斜線 Ktor 自己補,所以裡面寫 get("{id}") 不用寫成 get("/{id}"),2 種都對到 /todos/{id}
裡面那個不給 path 的 get { } 要特別說一下,它註冊的就是所在節點本身,也就是 /todos 這條,要 /todos 就寫 get { },不是 get("/"),後者是另一條路,這裡容易誤會成「斜線隨便加都沒差」,並不是,"/{id}" 的斜線後面還有東西,是層與層之間的分隔,而 "/" 的斜線後面什麼都沒有,那會多出一個空的 segment,等一下的陷阱實驗就是在示範這件事
day 05 拆 routing DSL 時說過,routing { } 底下是一棵路由樹,不是扁平的表,route("/todos") 是樹上的一個節點,2 個 get 掛在它底下,程式碼的縮排結構跟樹的結構直接對應,路由一多,用看的就知道誰住在誰底下
fun Route.todoRoutes() 的寫法應該很眼熟,day 04 拆 fun Application.module() 時講過,這是 extension function,把一個函式掛到既有型別上,這裡只是掛的對象從 Application 換成 Route,也就是路由樹的節點
所以在 routing { } 裡面可以直接呼叫 todoRoutes(),因為那個區塊是 lambda with receiver,this 是路由樹的根,而 todoRoutes() 正是定義在 Route 上的函式,呼叫它,Todo 的整組路由就長在根上面,沒有註冊表、沒有設定檔,把路由掛上去就只是一次函式呼叫
拆完之後 2 個檔案各管各的事,Application.kt 的 module() 回到只負責組裝,一條 hello 路由加一句 todoRoutes(),一眼就能看出這個應用程式由哪幾塊組成,Todo 相關的路由全部住在 TodoRoutes.kt,之後 Todo 路由再長,新增、修改、刪除,都只動這個檔案,Application.kt 不用再碰
不過,todos 清單還是 day 05 那個 top-level 的 mutableList,因為 TodoRoutes.kt 跟它同一個 package,直接看得到,連 import 都不用,方便歸方便,這也代表 2 個檔案耦合在一個共享狀態上,誰都摸得到它,day 19 把它抽成 repository 交給 DI 管的時候一起改
./gradlew test
實測的結果應該是全部通過,從非法 limit 還是 400、trailing slash 還是 404、POST 還是 405,分組寫法沒有偷偷改掉任何 day 06 確認過的行為,路由從平鋪改成掛在 route("/todos") 底下,又從 Application.kt 搬到 TodoRoutes.kt,對外的行為一模一樣,這就是重構要的結果
get("/") 不等於 get { }前面說要 /todos 本身就寫 get { },不是 get("/"),很多人的直覺是反過來的,「路徑就是這個前綴自己,那就給個 /」,這個直覺錯在哪,讓測試來示範。把 TodoRoutes.kt 裡的 get { } 改成 get("/") { },跑 ./gradlew test,6 個測試同時失敗
> Task :test FAILED
ApplicationTest > root path responds hello() PASSED
ApplicationTest > unknown path responds not found() PASSED
EnvironmentTest > Ktor EmbeddedServer class is available() PASSED
TodoRoutesTest > todos path responds all todos() FAILED
org.opentest4j.AssertionFailedError: expected: <200 OK> but was: <404 Not Found>
TodoRoutesTest > todos with limit query responds limited todos() FAILED
org.opentest4j.AssertionFailedError: expected: <200 OK> but was: <404 Not Found>
TodoRoutesTest > todo by id responds single todo() PASSED
TodoRoutesTest > todos with non numeric limit responds bad request() FAILED
org.opentest4j.AssertionFailedError: expected: <400 Bad Request> but was: <404 Not Found>
TodoRoutesTest > todos with negative limit responds bad request() FAILED
org.opentest4j.AssertionFailedError: expected: <400 Bad Request> but was: <404 Not Found>
TodoRoutesTest > todo by unknown id responds not found() PASSED
TodoRoutesTest > todos path with trailing slash responds not found() FAILED
org.opentest4j.AssertionFailedError: expected: <404 Not Found> but was: <200 OK>
TodoRoutesTest > post todos responds method not allowed() FAILED
org.opentest4j.AssertionFailedError: expected: <405 Method Not Allowed> but was: <404 Not Found>
TodoRoutesTest > todo by non numeric id responds bad request() PASSED
12 tests completed, 6 failed
每個 FAILED 底下那句期望與實際,是 day 02 在 testLogging 裡設 exceptionFormat 換來的,少了那行,命令列只會給你一個例外類別名,得自己去開 build/reports/tests/test/index.html 才看得到
get("/") 在 route("/todos") 底下註冊的路徑是 /todos/,帶尾斜線的那條,所以打 /todos 的成功案例與 3 個 limit 測試都變成 404,因為 /todos 這條路沒人註冊了,尾斜線測試期望 404 卻拿到 200,因為 /todos/ 現在反而有人接了,2 條路徑整個對調,連 POST 那個也遭殃,/todos 這個 path 底下已經沒有任何 method,POST 打過去從「path 對到但 method 沒對到」的 405 變成「path 根本不存在」的 404
一個小小的 "/",讓 6 個測試同時失敗,而安全網一次把它們全抓出來,如果沒有這 12 個測試,這個改動看起來完全無害,編譯過、server 也跑得起來,要等到有人打 /todos 拿到 404 才會發現,實驗完把 get("/") { } 改回 get { },再跑一次測試,12 個回到全部通過
前面說路徑是沿途每一層 route() 依序接起來的,可是這篇的程式碼只有一層,「一層一層」到底長什麼樣看不出來,把 {id} 那條改成 2 層就看得到了,get("{id}") 外面包一個 route("{id}"),裡面的 get 不給 path
route("/todos") {
get {
// 內容不動
}
route("{id}") {
get {
// 內容不動
}
}
}
跑 ./gradlew test,12 個測試全部通過,這就是要看的結果,路徑一個字都沒變,外層的 /todos 接上內層的 {id} 還是 /todos/{id},而內層那個不給 path 的 get { },註冊的就是 route("{id}") 這個節點本身,跟外層 get { } 註冊 /todos 是同一條規則,只是換了一層。層數沒有上限,route() 裡面還能再包 route(),路徑就這樣一層層往下接
{id} 先維持一層實驗做完,多包的那一層不留,{id} 維持 1 層的 get("{id}") { }
理由很單純,/todos/{id} 目前只有一個 GET,多包一層換到的只有 2 行縮排,多一層 route() 真正划算是在 2 種情況,一是同一個路徑上掛了好幾個 method,路徑只寫一次,改路徑也只改一個地方,二是有東西要套在整個節點上,而不是套在單一 method 上
巢狀路由這件事,Relix 在 day 11 手刻過一輪,那篇的核心是一個 normalizePath 函式加一個帶前綴的 RouteGroup,route("/api") 建一個 group,group 上註冊的每條路由都用 normalizePath 把前綴疊上去,巢狀就是 group 裡再建 group,前綴一層層疊,routes 全部進同一份 list,Ktor 的 route() 開箱就有這個能力,而且它不是把路徑拼成字串塞進扁平列表,route() 建的是路由樹上真正的節點,巢狀結構和匹配用的是同一棵樹
對照起來最有感的是前綴拼接的細節,手刻時「前綴怎麼拼」是要自己想清楚的實作細節,Relix 的 normalizePath 用 8 個測試把規則一條一條定下來,/api/ 加 /users 要去掉重複的斜線、path 是空字串就用前綴本身,Ktor 也有一模一樣的眉角,只是它藏在 get { } 和 get("/") { } 的差異裡,前者是前綴本身、後者是前綴加尾斜線,上面的陷阱實驗就是證據,框架把細節做掉了,但沒把決定做掉,"/" 到底代表什麼,Ktor 選了一個答案,你不知道它選哪邊的話,還是會踩進去
還有一個地方兩邊剛好走到同一個設計,Relix 的 RouteGroup 也有不帶 path 的 get { } 多載,註冊的就是 group 前綴本身,跟 Ktor 這篇用的寫法一模一樣,手刻時為了「要 /users 本身該怎麼寫」做的設計,回頭在 Ktor 看到同一個形狀,就知道它為什麼長這樣
Todo 路由搬進 TodoRoutes.kt,module() 回到只負責組裝,既有的測試一字不改會全部通過,反過來把 get { } 誤寫成 get("/") { },6 個測試會同時失敗,路由結構雖然變了,驗收標準仍然是外部行為,route() 疊成 2 層也實測過,路徑照樣接得起來、測試照樣通過
路由的路徑到目前為止都是字串,"/todos"、"{id}",打錯字要等到執行期才會發現,下一篇講 Resources plugin,把路徑定義成型別,讓 compiler 幫忙看住路由,也讓「組出某條路由的 URL」這件事不用再手拼字串
同步刊登於 Blog
圖片來源:AI 產生