
上一篇結尾停在一個問題上,路由的路徑到目前為止都是字串,"/todos"、"{id}",打錯一個字編譯一樣會過,要等到某個請求莫名 404 才會發現問題,Ktor 對這個問題的答案是 Resources plugin,把路徑定義成型別,讓 compiler 幫忙檢查路由
這篇跟 day 04 換 CIO engine、day 06 裝 IgnoreTrailingSlash 一樣是實驗篇,把 Todo 的路由整組換成 Resources 寫法實測,看它給了什麼、也看它拿走了什麼,不過這次不只是換上去跑一輪就決定去留,測試會抓出 2 個退化,其中 1 個的成因藏得很深,我們會把 routing trace 打開,進 Ktor 原始碼把它挖出來,再給 2 種修法,最後才做決定
build.gradle.kts 要動 2 個地方href 反向產生 URLbuild.gradle.kts 要動 2 個地方
第 1 個是 plugins 區塊加一行
kotlin("plugin.serialization") version "2.2.20"
裝一個路由的 plugin 為什麼要動到編譯器外掛 ? 因為 Resources 把路徑定義成 class 之後,「class 的屬性」和「URL 裡的字串」之間的雙向轉換是靠 kotlinx.serialization 做的,而 kotlinx.serialization 需要編譯器外掛在編譯期替 class 產生序列化用的程式碼,版本跟 kotlin("jvm") 一致,都是 2.2.20,這個外掛也不是為了這篇才裝的一次性東西,day 12 做 JSON 的 content negotiation 時它還會用到
第 2 個是 dependencies 加一行
implementation(ktorLibs.server.resources)
開一個新檔案 src/main/kotlin/com/cashwu/todo/TodoResources.kt,把 Todo 的 2 條路徑寫成 class
package com.cashwu.todo
import io.ktor.resources.Resource
@Resource("/todos")
class TodosPath(val limit: Int? = null) {
@Resource("{id}")
class ById(val parent: TodosPath = TodosPath(), val id: Int)
}
這幾行不長,但每一行都在講一件不同的事,一行一行拆開來看
@Resource("/todos") 的參數是路徑樣板,跟之前寫在 route("/todos") 裡的字串是同一個東西,只是現在它黏在型別上limit 之前是從 queryParameters["limit"] 拿出來的 String?,現在直接宣告成 Int?,字串轉型別的工作交給框架parent 屬性表達路徑階層,ById 的完整路徑是外層的 /todos 接上自己的 {id},也就是 /todos/{id}
id 宣告成 Int,day 06 說過參數拿到手永遠是 String,型別轉換與驗證得自己來,這裡的型別宣告本身就是驗證,abc 這種值連 handler 都進不來先在 src/main/kotlin/com/cashwu/todo/Application.kt 的 module() 裡、routing 區塊前面加一行
install(Resources)
要搭配的 2 行 import 是 io.ktor.server.application.install 和 io.ktor.server.resources.Resources
再把 src/main/kotlin/com/cashwu/todo/TodoRoutes.kt 整個改寫
package com.cashwu.todo
import io.ktor.http.HttpStatusCode
import io.ktor.server.resources.get
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
fun Route.todoRoutes() {
get<TodosPath> { path ->
val limit = path.limit ?: todos.size
call.respondText(todos.take(limit).joinToString("\n"))
}
get<TodosPath.ById> { path ->
val todo = todos.getOrNull(path.id - 1)
if (todo == null) {
call.respondText("找不到 id ${path.id} 的待辦", status = HttpStatusCode.NotFound)
return@get
}
call.respondText(todo)
}
}
跟 day 07 的版本對照,有幾件事不一樣
get 的 import 從 io.ktor.server.routing.get 換成 io.ktor.server.resources.get,這個版本不吃字串路徑,吃型別參數,get<TodosPath> 的意思是「這條路由的路徑,去 TodosPath 的 annotation 上找」route("/todos") 的包裝不見了,前綴住在 class 上,路徑階層由 class 的巢狀關係表達path 參數,框架把 URL 解析成 TodosPath 或 TodosPath.ById 的物件傳進來,path.limit 是 Int?、path.id 是 Int
toIntOrNull() 加 400 的檢查整段不見了,字串轉型別的工作被框架接走,handler 只剩查資料和回應./gradlew test
實測的結果是,12 個測試 10 個通過、2 個失敗,先看通過名單裡跟這次改寫直接相關的 4 個
TodoRoutesTest > todo by non numeric id responds bad request() PASSED
TodoRoutesTest > todos with limit query responds limited todos() PASSED
TodoRoutesTest > todos with non numeric limit responds bad request() PASSED
TodoRoutesTest > todos path with trailing slash responds not found() PASSED
/todos/abc 還是 400,但這次的 400 是框架回的,不是我們寫的,那段檢查已經刪掉了,typed 的 query 參數運作正常,?limit=2 照樣只回前 2 筆,?limit=abc 也維持 400,尾斜線行為則沒變,/todos/ 還是 404
失敗的是這 2 個
TodoRoutesTest > todos with negative limit responds bad request() FAILED
org.opentest4j.AssertionFailedError: expected: <400 Bad Request> but was: <500 Internal Server Error>
TodoRoutesTest > post todos responds method not allowed() FAILED
org.opentest4j.AssertionFailedError: expected: <405 Method Not Allowed> but was: <404 Not Found>
負數那個比較好理解,Resources 會把 -1 正確轉成 Int,型別轉換成功,handler 接著執行 todos.take(-1) 才丟出例外,等一下講修法時一起處理
405 那個問題就沒那麼直覺了,day 06 花了一整節講「path 對到但 method 沒對到」為什麼該回 405,還用一個測試把它固定下來,現在對 /todos 發 POST 拿到的是 404,表面上的說法是「Resources 註冊路由的方式不一樣」,但這句話什麼也沒解釋,下一節來處理這個問題
要看路由解析到底發生了什麼,Ktor 有內建的 trace,在 src/main/kotlin/com/cashwu/todo/Application.kt 的 routing 區塊裡加一行就好
routing {
trace { println(it.buildText()) }
// 其餘路由不動
}
它會把每個請求走過的路由樹逐節點印出來,包含每一段對到還是沒對到、最後選了哪條,這是暫時的除錯工具,查完就拿掉
跑起 server 打一個 POST /todos,印出來的是
Trace for [todos]
/, segment:0 -> SUCCESS @ /
/ [<slash>], segment:0 -> SUCCESS @ / [<slash>]
/ [<slash>, (method:GET)], segment:0 -> FAILURE "Selector didn't match" @ / [<slash>, (method:GET)]
/todos, segment:1 -> SUCCESS @ /todos
/todos [[limit?]], segment:1 -> SUCCESS @ /todos [[limit?]]
/todos [[limit?], (method:GET)], segment:1 -> FAILURE "Selector didn't match" @ /todos [[limit?], (method:GET)]
/todos/{id}, segment:1 -> FAILURE "Selector didn't match" @ /todos/{id}
Matched routes:
No results
Routing resolve result:
FAILURE "No matched subtrees found" @ /
看第 5 到第 7 行,/todos 這個路徑節點對到了,method selector 沒對到,這完全就是 405 該出現的情況,可是最後的結果是 404
關鍵在 /todos 和 (method:GET) 中間多出來的那個 [limit?],字串路由的樹上,/todos 底下直接掛 method,Resources 因為 TodosPath 有一個 limit 屬性,替它多開了一層 query 參數的節點,把 TodosPath 的 limit 屬性拿掉再打同一個請求,同一段 trace 變成
/todos, segment:1 -> SUCCESS @ /todos
/todos [(method:GET)], segment:1 -> FAILURE "Selector didn't match" @ /todos [(method:GET)]
回應也跟著變回 405 Method Not Allowed,多一層節點就從 405 變 404,接下來要問的是,為什麼一個 query 參數節點有這麼大的影響
Ktor 的路由解析不是找到就停,而是把整棵樹走過一遍,替每個節點算一個 quality 分數,再從所有對得上的候選裡挑最好的一條,怎麼算最好等一下會用到,解析失敗時要回什麼狀態碼,記在一個叫 failedEvaluation 的欄位裡,一條路由都沒對到時,findBestRoute() 這樣決定狀態碼
failedEvaluation?.failureStatusCode ?: HttpStatusCode.NotFound
failedEvaluation 的初值是 RouteSelectorEvaluation.FailedPath,它的狀態碼就是 404,所以 405 要出現,得有人在解析過程中把那筆 405 的 failure 寫進 failedEvaluation,而寫入的入口只有一個,RoutingResolveContext.updateFailedEvaluation
private fun updateFailedEvaluation(
new: RouteSelectorEvaluation.Failure,
trait: ArrayList<RoutingResolveResult.Success>
) {
val current = failedEvaluation ?: return
if ((current.quality < new.quality || failedEvaluationDepth < trait.size) &&
trait.all {
it.quality == RouteSelectorEvaluation.qualityTransparent ||
it.quality == RouteSelectorEvaluation.qualityConstant
}
) {
failedEvaluation = new
failedEvaluationDepth = trait.size
}
}
決定要怎麼記的是 trait.all { ... } 這一段,trait 是走到這個失敗節點為止,沿路成功對到的那些 selector,這個條件要求它們每一個的 quality 都必須是 qualityTransparent 或 qualityConstant,只要有一個不是,這筆 failure 就不記,failedEvaluation 維持初值,最後回落到 404
quality 是一組常數,定義在 RouteSelector.kt
qualityConstant = 1.0,固定字串的 segment,像 /todos
qualityQueryParameter = 1.0,query 參數有帶到qualityMissing = 0.2,選填的東西沒帶到qualityTransparent = -1.0,不影響路徑的透明節點[limit?] 那個節點背後是 OptionalParameterRouteSelector,它的 evaluate() 分 2 種結果,query 有帶就回 Success,quality 是 qualityQueryParameter 的 1.0,沒帶就回 RouteSelectorEvaluation.Missing,這裡有個容易看錯的地方,Missing 的名字聽起來像失敗,本體卻是 Success(qualityMissing),也就是 0.2,它算成功,請求會順利往下走,那個 0.2 也會留在 trait 裡
到這裡就對得上了,POST /todos 沒有帶 limit,[limit?] 這個節點算成功,請求也順利往下走到 method selector,但它在 trait 裡留下的分數是 0.2,trait.all { ... } 看到 0.2 既不是 1.0 也不是 -1.0,條件不成立,那筆 405 的 failure 沒被記下來,最後照初值回 404
順帶一提,條件裡只寫了 qualityConstant,沒寫 qualityQueryParameter,但 2 個常數的值剛好都是 1.0,== 比的是數值,所以「query 有帶到」這種情況是過得了這一關的
同一份程式、同一條路由、同一個 HTTP 動詞,只差在網址後面有沒有掛一個 ?limit=2,實測出來是 2 個狀態碼
POST /todos 回 404 Not FoundPOST /todos?limit=2 回 405 Method Not Allowed帶了 limit,OptionalParameterRouteSelector 回的是 1.0,trait.all { ... } 通過,405 被記下來,沒帶則回 0.2,同一個請求變成 404
一個 query 參數帶不帶,決定了 client 看到的是「資源不存在」還是「動詞用錯」,這件事光看 API 表面完全看不出來,它也不是 Resources 的 bug,而是「把 query 參數變成路由樹上的一個節點」這個設計必然的副作用
day 06 對照 Relix 時說過,手刻版是自己在 sealed class 裡把 NotFound 和 MethodNotAllowed 分成 2 個結果,判斷點就那一個地方,Ktor 把它做成一套 quality 評分,能力強很多,代價是判定條件散在解析流程裡,行為變了不容易一眼看出原因
原因清楚了,修法就有方向
既然多出來的 [limit?] 節點是問題來源,最直接的修法就是不要有這個節點,path 參數繼續交給 Resources,query 參數自己讀
src/main/kotlin/com/cashwu/todo/TodoResources.kt 拿掉 limit
package com.cashwu.todo
import io.ktor.resources.Resource
@Resource("/todos")
class TodosPath {
@Resource("{id}")
class ById(val parent: TodosPath = TodosPath(), val id: Int)
}
src/main/kotlin/com/cashwu/todo/TodoRoutes.kt 把 day 06 那段 limit 的處理搬回來
package com.cashwu.todo
import io.ktor.http.HttpStatusCode
import io.ktor.server.resources.get
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
fun Route.todoRoutes() {
get<TodosPath> {
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<TodosPath.ById> { path ->
val todo = todos.getOrNull(path.id - 1)
if (todo == null) {
call.respondText("找不到 id ${path.id} 的待辦", status = HttpStatusCode.NotFound)
return@get
}
call.respondText(todo)
}
}
./gradlew test 實測,測試全部通過
2 個失敗一起修掉了,405 回來,是因為 /todos 底下不再有 [limit?],trait 裡只剩常數 selector 的 1.0,那筆 405 的 failure 記得下來,負數 limit 回到 400,是因為 takeIf { it >= 0 } 這行檢查自己寫回來了
負數那個要注意,limit = -1 是一個合法的 Int,型別轉換從頭到尾沒有失敗,型別安全擋得住「不是整數」,擋不住「是整數但超出業務規則」,這條界線 2 種修法都跨不過去,業務規則的檢查一定得自己補,day 06 補的那個負數測試剛好把這件事測出來
get<TodosPath.ById> 那條沒動,path 參數仍然是型別安全的,path.id 拿到手就是 Int,少掉的只有 typed query,換回來的是乾淨的路由樹,還有 405 與錯誤訊息都回到自己手上
如果 typed query 是你要的,TodoResources.kt 就維持初版那個帶 limit 的寫法,改的是路由怎麼註冊。src/main/kotlin/com/cashwu/todo/TodoRoutes.kt 改成
package com.cashwu.todo
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.server.resources.get
import io.ktor.server.resources.handle
import io.ktor.server.resources.resource
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.method
fun Route.todoRoutes() {
resource<TodosPath> {
method(HttpMethod.Get) {
handle<TodosPath> { path ->
val limit = path.limit ?: todos.size
if (limit < 0) {
call.respondText("limit 要是 0 以上的整數", status = HttpStatusCode.BadRequest)
return@handle
}
call.respondText(todos.take(limit).joinToString("\n"))
}
}
handle<TodosPath> {
call.respondText("", status = HttpStatusCode.MethodNotAllowed)
}
}
get<TodosPath.ById> { path ->
val todo = todos.getOrNull(path.id - 1)
if (todo == null) {
call.respondText("找不到 id ${path.id} 的待辦", status = HttpStatusCode.NotFound)
return@get
}
call.respondText(todo)
}
}
./gradlew test 實測,測試也全部通過
原本一行 get<TodosPath> 的地方變成 3 層,其實前 2 層不是新東西,io.ktor.server.resources.get 的原始碼骨架就是 resource<T> { method(HttpMethod.Get) { handle(body) } },這裡只是把它展開,真正加上去的只有最後那個 handle<TodosPath>,3 層各自負責一件事
resource<TodosPath> { } 只開路徑節點,不掛任何 method,/todos 加上 [limit?] 這 2 層都是它建的method(HttpMethod.Get) { handle<TodosPath> { ... } } 才是 GET 的 handler,method() 掛上 method selector,handle<T> 負責把 URL 解析成 TodosPath 物件method() 裡的 handle<TodosPath> { },它身上沒有 method selector,任何動詞都對得上,GET 以外的動詞就落在這裡,自己回 405GET 進來時 2 條都對得上,Ktor 挑的規則是逐節點比分數,一樣就比誰對到的節點多,2 條的前 3 層完全相同,多掛一個 method selector 的那條多對到一個節點,所以 GET 仍然走上面那個 handler,其他動詞只剩下面那條可以走
負數 limit 的檢查則是 if (limit < 0) 那 3 行,跟修法一是同一件事,只是輸入已經是 Int 了
這 2 個都是實際試過才知道不行的,它們比正解更能說明 Ktor 的解析規則
第 1 個,用字串路由補一條 route("/todos") { handle { ... } } 回 405,這條會把 GET /todos 一起搶走,todos path responds all todos() 直接失敗,把 trace 打開就看得到為什麼
/todos, segment:1 -> SUCCESS @ /todos
/todos [[limit?]], segment:1 -> FAILURE "Better match was already found" @ /todos [[limit?]]
route("/todos") 跟 resource<TodosPath> 用的是樹上同一個 /todos 節點,selector 一樣的子節點會被重用,不會長出第 2 個
差別在 handle { } 直接掛在 /todos 這個節點身上,走到這裡就先成了一個候選,分數是 /todos 自己的 1.0。接著往下看 [limit?] 時,0.2 比已經拿到的 1.0 低,整個子樹被 handleRoute 剪掉,理由就是 trace 印的 Better match was already found。跟 2 條的註冊順序無關,GET /todos 沒帶 query 時字串那條一定贏
第 2 個,在 todoRoutes() 頂層直接寫 handle<TodosPath> { ... }。這個寫法編譯得過、也不會出現錯誤,但對 POST /todos 沒有任何幫助,還是 404
原因是 Route.handle<T> 把 handler 裝在「當下這個 Route」上,它不會自己建出 /todos 的子路由,resource<TodosPath> { } 才是開路徑節點的那個函式,所以 handle<T> 一定要放在 resource<T> 的區塊裡面
修法一放棄 typed query,換回乾淨的路由樹、框架預設的 405,以及自己控的錯誤訊息,代價是 limit 的字串轉型別要自己寫
修法二保住 typed query,讓框架繼續做那次轉換,代價是路由的寫法從一行 get<TodosPath> 變成 3 層巢狀,而且這個囉嗦是每一個帶 query 參數的 resource 都要重來一次的
要用 Resources 的話,我會選修法一
day 06 花了一節建立 404 和 405 的語意差別,還用測試固定下來,limit 要是 0 以上的整數 這種對 client 友善的訊息也是那時候寫的,2 樣都想留著
而 limit 說到底只是一個 query 參數,一行 toIntOrNull()?.takeIf { it >= 0 } 就處理完了,用它去換整組路由變成 3 層巢狀,不划算
狀態碼已經有測試,錯誤訊息還沒固定成規格,用 ./gradlew run 把修法一的程式跑起來,再用 curl 逐一確認
先看 path 參數轉換失敗
curl -w "\nstatus=%{http_code}" http://localhost:8080/todos/abc
Can't transform call to resource
status=400
再把 limit 給成非數字,還有負數
curl "http://localhost:8080/todos?limit=abc" -w "\nstatus=%{http_code}"
curl "http://localhost:8080/todos?limit=-1" -w "\nstatus=%{http_code}"
limit 要是 0 以上的整數
status=400
limit 要是 0 以上的整數
status=400
最後對 /todos 發一個 POST
curl -i -X POST http://localhost:8080/todos
HTTP/1.1 405 Method Not Allowed
Content-Length: 0
Content-Length: 0,body 是空的,狀態碼是 405,跟 day 06 字串路由的行為一模一樣
4 個實際測試裡面有 3 個沒問題,limit 的 2 個錯誤訊息是自己寫的文案,POST 的 405 是框架的預設,2 種都是我們要的,只剩第 1 個,/todos/abc 的 body 是框架的 Can't transform call to resource,這是站在框架內部視角的措辭,對 API 的使用者來說不算友善,他只想知道自己哪裡打錯了
這一個問題不在這篇處理,它跟 /todos/999 的 404,和所有端點的錯誤格式是同一類問題,散在各個 handler 裡各寫各的沒有意義,後面的 StatusPages 會把錯誤回應集中到一個地方統一格式,那時再一起解決
Resources 還有一個字串路由給不了的能力,從型別反向產生 URL,把 2 個斷言放進一個臨時測試實測,href 要在 application 的 scope 裡呼叫,所以斷言放在 testApplication 的 application { } 區塊裡,搭配的 import 是 io.ktor.server.resources.href
assertEquals("/todos", href(TodosPath()))
assertEquals("/todos/2", href(TodosPath.ById(id = 2)))
2 個斷言都通過,建構一個 TodosPath.ById(id = 2),href 就組出 /todos/2,這件事的價值要到改路由的時候才會完全顯現,而且分 2 種情況
/todos 要改名,改的是 @Resource 那一行,所有用 href 產生的 URL 跟著變id 從 Int 換成 String、建構子多一個必填參數,這時 compiler 會失敗,把每個建構該型別的地方標出來,一個都跑不掉手拼字串 2 種保證都沒有,散落各處的 "/todos/$id" 只能靠 IDE 全文搜尋慢慢挑
實驗做完,跟 day 04、day 06 一樣要做決定,這次的決定是換回字串路由,TodoRoutes.kt 和 Application.kt 用 git 還原,TodoResources.kt 和臨時測試刪掉,build.gradle.kts 的 2 行也拿掉,再跑一次 ./gradlew test 確認 12 個測試回到全部通過
要說清楚的是,這跟前面那 2 個實驗篇不一樣,day 04 換回 Netty、day 06 拿掉 IgnoreTrailingSlash,都是因為那個東西真的不合用,Resources 不是,它的 2 個代價這篇都追到底也都修掉了,換回字串路由純粹是為了這個系列後面幾十篇的示範
字串路由少一層轉譯,路由長什麼樣,route("/todos") 跟 get("{id}") 直接寫在那裡,一眼就看得到,後面每一篇都還要在這個路由上加東西,驗證、錯誤格式、認證、授權,每次都讓讀者先繞過 @Resource 那層再看到重點划不來,示範框架機制的時候,路由本身越簡單越好
換句話說,這是寫文章的取捨,不是工程上的取捨,真正在做的專案我會用 Resources,get<TodosPath.ById> 打錯型別名稱是編譯錯誤,get("/todso/{id}") 打錯字串卻要等到有人回報 404 才會發現,這正是 day 07 結尾提出來的那個問題,而 href 反向產生 URL 更是手拼字串怎麼樣都給不了的,路由一多、內部互相引用 URL 的地方一多,這 2 個編譯期保證的價值會跟著放大
反過來說,路由本來就沒幾條、內部沒有需要反向產生 URL 的地方,那 Resources 帶來的保證換不到什麼,多裝一個編譯器外掛跟一個 plugin 反而是純成本,這時候留在字串路由就不只是文章的取捨了
前幾篇的對照都是「Relix 手刻過、Ktor 內建」,這篇不一樣,type-safe routing 是 Relix 完全沒做的層次,把 class 屬性對應到路徑樣板,背後需要 serialization 編譯器外掛等級的支援,編譯期替每個 class 產生序列化用的程式碼,執行期框架拿著它做雙向轉換,這種投入跟「理解框架機制」的目標完全不成比例
這正是框架生態的價值,Ktor 也沒有自己發明序列化,kotlinx.serialization 是 Kotlin 官方生態的積木,Resources 站在它上面就好。這種「只有生態做得起」的能力,系列後面還會遇到更多,serialization、資料庫、認證都是同一類,Relix 系列 主要手刻的是框架的骨架,骨架之外的血肉是整個生態一起長出來的
不過這篇也示範了另一面,能力越厚,行為變化的成因就藏得越深,Relix 在 day 08 手刻路由匹配時,405 是 sealed class 裡明確的一個分支,看得到、改得動,Ktor 這邊同一件事變成 updateFailedEvaluation 裡一個 quality 的相等判斷,要靠 trace 加原始碼才追得出來,手刻那一輪的價值就在這裡,知道「這個判斷本來就存在、只是被做進去了」,才會想到往那個方向找
Resources 讓 path 參數直接變成型別,也能用 href 反向產生 URL,代價是把 query 參數宣告成屬性時,路由樹上會多一個 quality 0.2 的節點,讓「path 對到但 method 沒對到」的 405 掉回 404,實測到帶不帶 ?limit 會改變同一個請求的狀態碼,修法一是 query 參數自己讀、修法二是用 resource 加 handle 把 405 補回來
這個系列換回字串路由,為的是讓後面幾十篇的路由簡單一點,不是因為 Resources 不好用
這篇的 install(Resources)、day 06 的 install(IgnoreTrailingSlash),install 這個動作已經出現好幾次了,但它到底做了什麼、plugin 是在請求處理流程的哪個位置介入、多個 plugin 之間的順序誰說了算,下一篇進 plugin 與 pipeline,這也是 day 01 提過的、Relix 系列結尾留下的那個排序伏筆要開始還的地方
同步刊登於 Blog
圖片來源:AI 產生