
前面幾篇我們把 HTTP 引擎、Request、Response、RelixCall、TestKit 都準備好了,但目前的 RelixApplication 只能註冊一條 handler,不管什麼 path 都走同一個,這篇要做的事情是用 Map 把不同的 path 對到不同的 handler
這是路由系統的最小雛形,它的目的是讓你清楚知道「路由的本質」是什麼,然後再一步步演進成可預期、可擴展的 Router
用第 06 篇做的 TestKit 來寫測試,我們期望的行為很單純
import kotlin.test.Test
import kotlin.test.assertEquals
class MapRoutingTest {
@Test
fun `GET hello returns greeting`() {
val app = RelixApplication()
app.get("/hello") { ok("Hello!") }
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/hello")
assertEquals(200, response.statusCode)
assertEquals("Hello!", response.bodyAsText())
}
@Test
fun `GET health returns OK`() {
val app = RelixApplication()
app.get("/health") { ok("OK") }
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/health")
assertEquals(200, response.statusCode)
assertEquals("OK", response.bodyAsText())
}
@Test
fun `unregistered path returns 404`() {
val app = RelixApplication()
app.get("/hello") { ok("Hello!") }
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/missing")
assertEquals(404, response.statusCode)
}
@Test
fun `POST to registered path works`() {
val app = RelixApplication()
app.post("/users") { created("user created") }
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("POST", "/users")
assertEquals(201, response.statusCode)
}
@Test
fun `PUT to registered path works`() {
val app = RelixApplication()
app.put("/users/1") { ok("user updated") }
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("PUT", "/users/1")
assertEquals(200, response.statusCode)
assertEquals("user updated", response.bodyAsText())
}
@Test
fun `DELETE to registered path returns 204`() {
val app = RelixApplication()
app.delete("/users/1") { noContent() }
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("DELETE", "/users/1")
assertEquals(204, response.statusCode)
}
@Test
fun `same path different methods go to different handlers`() {
val app = RelixApplication()
app.get("/items") { ok("list of items") }
app.post("/items") { created("item created") }
val testKit = RelixTestKit(app)
val getResponse = testKit.handleRequest("GET", "/items")
assertEquals(200, getResponse.statusCode)
assertEquals("list of items", getResponse.bodyAsText())
val postResponse = testKit.handleRequest("POST", "/items")
assertEquals(201, postResponse.statusCode)
assertEquals("item created", postResponse.bodyAsText())
}
@Test
fun `wrong method for registered path returns 404`() {
val app = RelixApplication()
app.get("/hello") { ok("Hello!") }
val testKit = RelixTestKit(app)
// POST /hello 沒有註冊
val response = testKit.handleRequest("POST", "/hello")
assertEquals(404, response.statusCode)
}
}
PUT 和 DELETE 那兩個看起來有點像湊數的,四個 method 最後都會走進同一段註冊邏輯 (下面實作裡的 addRoute),補它們只是要確認 get 和 post 以外的入口真的有接上,不是在驗新邏輯
POST 那個沒有傳 JSON body,這是刻意的,帶 body 進去對「用 method 加 path 找到 handler」沒有驗證力,只會讓焦點模糊
但這不是框架還做不到,傳 body 現在就可以,第 04 篇的 RelixRequest 有 body: ByteArray 欄位和 bodyAsText(),第 06 篇的 RelixTestKit.handleRequest 也留了 body: ByteArray = ByteArray(0) 這個參數,所以這樣寫現在就會動
val response = testKit.handleRequest(
"POST",
"/users",
body = """{"name":"Cash"}""".toByteArray(),
)
handler 裡面用 request.bodyAsText() 也讀得到那串字,bodyAsText() 就只是把 bytes 用 UTF-8 轉成字串而已,還做不到的是把 JSON 反序列化成物件,這件事第 04 篇做 RelixRequest 的時候就先講明了
真正的解析要等第 19 篇的 request body 讀取,第 20 篇 Content Negotiation 接上 kotlinx.serialization,第 21 篇才有型別安全的 receive<T>()
最後一個測試 wrong method for registered path returns 404 其實暴露了一個問題,POST /hello 應該回 404 (路徑不存在) 還是 405 (路徑存在但方法不對) ? 用 Map 做路由的話,我們只能回 404,405 要等下一篇做了 Router 之後才能正確區分
最直覺的路由表是一層 Map,也就是 Map<String, RelixHandler>,key 是 path,但這樣沒辦法區分 GET 和 POST,我們有兩個選擇
方案 A,把 method 拼進 key
val routes: Map<String, RelixHandler> = mapOf(
"GET /hello" to { ok("Hello!") },
"POST /users" to { created("user created") },
)
// 查詢
val key = "${call.request.method} ${call.request.path}"
val handler = routes[key]
簡單直接,但 key 的格式是自己發明的字串,沒有型別保護,如果有人寫成 "GET/hello" (漏了空格),compiler 不會幫你抓
Map<Pair<String, String>, RelixHandler> 看起來是更乾淨的中間解,用 Pair("GET", "/hello") 當 key,避開字串拼裝,但它不方便回答「這個 path 有哪些 method ?」,要產生 405 的 allowed methods set,就得遍歷整個 Map
第 08 篇會用到這個查詢,所以這裡選兩層 Map,理由是查詢方式,而不是沒有量測過的效能猜測
方案 B,兩層 Map (我們的選擇)
// 外層 key = path,內層 key = method
val routes = mutableMapOf<String, MutableMap<String, RelixHandler>>()
查詢的邏輯變成先用 path 找到那個 path 的所有 handlers,再用 method 找到對應的 handler
fun findHandler(method: String, path: String): RelixHandler? {
val methodMap = routes[path] ?: return null
return methodMap[method]
}
兩層 Map 的好處是你可以知道「path 存在但 method 不對」 (routes[path] 不是 null,但 methodMap[method] 是 null),雖然這篇還不處理 405,但下一篇升級成 Router 時,這個資訊會很有用
把路由邏輯整合進 RelixApplication
class RelixApplication {
// path → (method → handler)
private val routes = mutableMapOf<String, MutableMap<String, RelixHandler>>()
fun get(path: String, handler: RelixHandler) = addRoute("GET", path, handler)
fun post(path: String, handler: RelixHandler) = addRoute("POST", path, handler)
fun put(path: String, handler: RelixHandler) = addRoute("PUT", path, handler)
fun delete(path: String, handler: RelixHandler) = addRoute("DELETE", path, handler)
private fun addRoute(method: String, path: String, handler: RelixHandler) {
routes.getOrPut(path) { mutableMapOf() }[method] = handler
}
fun handle(call: RelixCall): RelixResponse {
val method = call.request.method
val path = call.request.path
val handler = routes[path]?.get(method)
return if (handler != null) {
handler(call) // 等同於 call.handler()
} else {
notFound()
}
}
}
addRoute 用 getOrPut 來確保每個 path 都有對應的 method map,handle() 先查 path,再查 method,找到就執行 handler,找不到就回 404
注意 handler(call) 這行,因為 handler 的型別是 RelixCall.() -> RelixResponse (receiver lambda),所以 handler(call) 就是在 call 上面呼叫 handler,等同於 call.handler(),這是 Kotlin 的語法糖
handle(method, path, handler) 不見了private var handler 這個欄位和 handle(method, path, handler) 是一起刪掉的,註冊 API 從 app.handle("GET", "/hello") { ... } 換成 app.get("/hello") { ... }
除了 Map 需要一個能存多筆的入口之外,handle 這個名字本來就同時當註冊和執行兩件事用,兩個 overload 只靠參數列區分,讀的時候很容易看成另一個,拆成 get 和 post 之後,意圖直接寫在名字上
留下來的只有 handle(call): RelixResponse,也就是第 06 篇說「TestKit 和 Adapter 都要靠它」的那個,所以 RelixTestKit 和 JdkHttpServerAdapter 這篇一行都不用改
第 06 篇留下來的測試就沒這麼好運,它們會編譯不過
RelixApplicationTest.kt 裡有註冊 handler 的那三個最單純,app.handle("GET", "/hello")、("GET", "/greet")、("GET", "/status") 換成 app.get("/hello")、app.get("/greet")、app.get("/status") 就好,path 本來就寫對了,只是換個 API 名稱,第 06 篇最後那個 main() 裡的 app.handle("GET", "/hello") 也一樣 (沒註冊 handler 的第四個回 404,換成 Map 之後照樣是 404,不用動)
JdkHttpServerAdapterTest.kt 裡有三個就麻煩一點,不只是換名字,path 也要跟著改 (另外兩個,真實 HTTP 的 404 和 port 被佔用,都不受影響)
每個測試只有註冊那一行要動,val response = send(...) 和後面的斷言原封不動,get 和 post 是拿來註冊的,回傳型別是 Unit,不會給你 response
@Test
fun `request reaches the handler and response comes back`() {
// 原本 handle("GET", "/")
application.get("/hello") { ok("Hello, Relix!") }
// 不動
val response = send("/hello")
assertEquals(200, response.statusCode())
assertEquals("Hello, Relix!", response.body())
}
@Test
fun `handler sees the parsed request`() {
// 原本 handle("POST", "/")
application.post("/echo") {
ok("${request.method} ${request.path} ${request.bodyAsText()}")
}
// 不動
val response = send("/echo?x=1", body = "payload")
assertEquals("POST /echo payload", response.body())
}
@Test
fun `throwing handler returns 500 instead of hanging`() {
// 原本 handle("GET", "/"),這條 path 本來就對
application.get("/") { error("boom") }
// 不動
val response = send("/")
assertEquals(500, response.statusCode())
assertEquals("Internal Server Error", response.body())
}
前兩個註冊的是 /,打進去的卻是 /hello 和 /echo,在第 06 篇居然拿得到回應,因為那時候 RelixApplication 只存最後一個 handler,path 這個參數從頭到尾沒被看過一眼,如果只換 API 名字、先不動 path,這兩個就會變成 404
先別急著把 path 一起改掉,讓它們失敗再說,第 06 篇的常見陷阱只能用一句話說明那個怪現象,現在是測試自己把它展現出來,改完 path 重新通過,那個 200 才是真的打在 /hello 上面
下面用一個具體例子走過完整流程
1. 測試發出:testKit.handleRequest("GET", "/hello")
2. TestKit 建構 RelixRequest(method="GET", path="/hello", ...)
3. TestKit 建構 RelixCall(application, request)
4. TestKit 呼叫 application.handle(call)
5. Application 查詢 routes["/hello"] → 找到 {"GET" → handler}
6. Application 查詢 methodMap["GET"] → 找到 handler
7. Application 執行 handler(call) → call 裡面的 this = RelixCall
8. handler 回傳 ok("Hello!") → RelixResponse(200, ...)
9. TestKit 拿到 response,測試驗證 statusCode 和 body
用 Map 做路由表夠用於教學,但限制很明顯
沒有 405 Method Not Allowed
目前 POST /hello (path 存在但 method 沒註冊) 和 GET /missing (path 不存在) 都回 404,HTTP 規格說前者應該回 405,並且帶上 Allow: GET header 告訴 client 哪些 method 可以用,要做到這個,我們需要在查詢時區分「path 不存在」和「path 存在但 method 不匹配」
沒有匹配規則
/hello 和 /hello/ 是兩個不同的 key,URL decode 的時機也沒有統一,這些都需要一個「匹配規則」來集中管理
無法支援路徑樣板
/users/{id} 沒辦法用 Map 的 key 直接匹配,要支援路徑參數,需要把 path 拆成 segments 逐段比對
無法做巢狀路由
route("/api") { route("/users") { get { ... } } } 這種 DSL 需要 prefix 拼接的概念,Map 做不到
| 功能 | Map 路由 | Router (下一篇) |
|---|---|---|
| 精確 path 匹配 | ✓ | ✓ |
| Method 過濾 | ✓ | ✓ |
| 404 Not Found | ✓ | ✓ |
| 405 Method Not Allowed | ✗ | ✓ |
| Trailing slash | ✗ | ✓ |
路徑參數 /users/{id} |
✗ | 第 09 篇 |
| DSL 語法 | ✗ | 第 10-11 篇 |
所以下一篇我們會把路由升級成兩個核心型別,Route (method + path + handler) 和 Router (集中管理路由、統一匹配規則)
path 比較是 case-sensitive 的
/Hello 和 /hello 在 Map 裡是兩個不同的 key,HTTP 規格的 path 是 case-sensitive 的 (跟 header name 不同),所以這個行為是正確的,但你要確保 client 端和 server 端用的大小寫一致
handler 的覆蓋
如果你對同一個 path + method 註冊了兩次 handler,後面的會蓋掉前面的 (因為 Map 的 [key] = value 會覆蓋),這目前沒有警告,是不是要在重複註冊時拋例外 ? 見仁見智,我們目前先不做,但如果你在自己的框架裡做,這是個值得考慮的 feature
為什麼不直接做 Router ?
有可能會覺得「Map 路由這麼簡陋,為什麼不直接做 Router ?」因為先看到最簡單的版本,理解路由的核心概念只有一件事,「根據 request 的某些資訊找到對應的 handler」,等有了這個想法,後面 Router 的各種規則 (405、trailing slash、priority) 才不會覺得抽象
這篇用最少的程式碼做出了路由的核心概念,根據 path 和 method 找到 handler、找不到就回 404,Map 路由的限制很明顯 (沒有 405、沒有路徑樣板、沒有匹配規則),也因此能看出下一步該補哪些能力
下一篇我們把路由升級成 Route + Router,用 sealed class 表達匹配結果 (Matched / NotFound / MethodNotAllowed),做出 405 + Allow header,並處理 trailing slash 和 HEAD fallback
同步刊登於 Blog
圖片來源:AI 產生