iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
Software Development

Kotlin 手刻 Ktor 從零開始系列 第 7

Kotlin 手刻 Ktor 從零開始 Day 07 最簡單的路由,用 Map 做 URL 對應

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260822/20121948NIqCtgaIUJ.png

前面幾篇我們把 HTTP 引擎、Request、Response、RelixCall、TestKit 都準備好了,但目前的 RelixApplication 只能註冊一條 handler,不管什麼 path 都走同一個,這篇要做的事情是用 Map 把不同的 path 對到不同的 handler

這是路由系統的最小雛形,它的目的是讓你清楚知道「路由的本質」是什麼,然後再一步步演進成可預期、可擴展的 Router

TDD 先定義要達成的行為

用第 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),補它們只是要確認 getpost 以外的入口真的有接上,不是在驗新邏輯

POST 那個沒有傳 JSON body,這是刻意的,帶 body 進去對「用 method 加 path 找到 handler」沒有驗證力,只會讓焦點模糊

但這不是框架還做不到,傳 body 現在就可以,第 04 篇的 RelixRequestbody: 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,也就是 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 的 Map-based 路由實作

把路由邏輯整合進 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()
        }
    }
}

addRoutegetOrPut 來確保每個 path 都有對應的 method map,handle() 先查 path,再查 method,找到就執行 handler,找不到就回 404

注意 handler(call) 這行,因為 handler 的型別是 RelixCall.() -> RelixResponse (receiver lambda),所以 handler(call) 就是在 call 上面呼叫 handler,等同於 call.handler(),這是 Kotlin 的語法糖

第 06 篇的 handle(method, path, handler) 不見了

private var handler 這個欄位和 handle(method, path, handler) 是一起刪掉的,註冊 API 從 app.handle("GET", "/hello") { ... } 換成 app.get("/hello") { ... }

除了 Map 需要一個能存多筆的入口之外,handle 這個名字本來就同時當註冊和執行兩件事用,兩個 overload 只靠參數列區分,讀的時候很容易看成另一個,拆成 getpost 之後,意圖直接寫在名字上

留下來的只有 handle(call): RelixResponse,也就是第 06 篇說「TestKit 和 Adapter 都要靠它」的那個,所以 RelixTestKitJdkHttpServerAdapter 這篇一行都不用改

第 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(...) 和後面的斷言原封不動,getpost 是拿來註冊的,回傳型別是 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 上面

handle 的完整流程

下面用一個具體例子走過完整流程

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 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 06 RelixCall 與可測核心,Application / Engine / Handler
下一篇
Kotlin 手刻 Ktor 從零開始 Day 08 Route 與匹配規則,讓路由行為可預期
系列文
Kotlin 手刻 Ktor 從零開始18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言