iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Software Development

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

Kotlin 手刻 Ktor 從零開始 Day 08 Route 與匹配規則,讓路由行為可預期

  • 分享至 

  • xImage
  •  

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

上一篇我們用 Map 做出了「能用但不夠用」的路由,Map 路由有兩個明顯的缺陷,沒辦法區分 404 (path 不存在) 和 405 (path 存在但 method 不對),也沒有統一的匹配規則,這篇要把路由升級成 Route + Router,重點是把規則集中起來,讓行為可預期、可測、可演進

TDD 先定義 Router 的行為

Router 的價值在「規則」。我們先把規則寫成測試,因為這些測試就是框架行為的規格書

import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs

class RouterTest {

    @Test
    fun `exact path match returns Matched`() {
        val router = Router()
        router.add(Route("GET", "/hello") { ok("Hello!") })

        val result = router.match("GET", "/hello")

        assertIs<MatchResult.Matched>(result)
    }

    @Test
    fun `unregistered path returns NotFound`() {
        val router = Router()
        router.add(Route("GET", "/hello") { ok("Hello!") })

        val result = router.match("GET", "/missing")

        assertIs<MatchResult.NotFound>(result)
    }

    @Test
    fun `wrong method returns MethodNotAllowed with allowed methods`() {
        val router = Router()
        router.add(Route("GET", "/items") { ok("list") })
        router.add(Route("POST", "/items") { created("new") })

        val result = router.match("DELETE", "/items")

        assertIs<MatchResult.MethodNotAllowed>(result)
        // 有 GET 就等於也支援 HEAD(後面「HEAD fallback 沿用 GET,但不回 body」會說明),所以 allowed 會多一個 HEAD
        assertEquals(setOf("GET", "HEAD", "POST"), result.allowedMethods)
    }

    @Test
    fun `trailing slash is normalized - hello and hello slash match same route`() {
        val router = Router()
        router.add(Route("GET", "/hello") { ok("Hello!") })

        val result = router.match("GET", "/hello/")

        assertIs<MatchResult.Matched>(result)
    }

    @Test
    fun `HEAD falls back to GET handler when no HEAD registered`() {
        val router = Router()
        router.add(Route("GET", "/hello") { ok("Hello!") })

        val result = router.match("HEAD", "/hello")

        assertIs<MatchResult.Matched>(result)
    }

    @Test
    fun `HEAD uses explicit HEAD handler when registered`() {
        val router = Router()
        val headHandler: RelixHandler = { ok("head-specific") }
        router.add(Route("HEAD", "/hello", headHandler))
        router.add(Route("GET", "/hello") { ok("get-handler") })

        val result = router.match("HEAD", "/hello")

        assertIs<MatchResult.Matched>(result)
        // 應該匹配到 HEAD 自己的 handler,不是 GET 的
        assertEquals(headHandler, result.route.handler)
    }

    @Test
    fun `HEAD on path with no GET and no HEAD returns MethodNotAllowed`() {
        val router = Router()
        router.add(Route("POST", "/items") { created("new") })

        val result = router.match("HEAD", "/items")

        assertIs<MatchResult.MethodNotAllowed>(result)
    }

    @Test
    fun `empty router returns NotFound`() {
        val router = Router()

        val result = router.match("GET", "/anything")

        assertIs<MatchResult.NotFound>(result)
    }
}

Route,method + path + handler

先把路由的資訊包成型別

data class Route(
    val method: String,
    val path: String,
    val handler: RelixHandler,
)

Route 本身不做匹配,只描述「我是一條路由」。method 是 HTTP method 字串 (GET、POST 等),path 是精確的路徑 (這篇還不支援 /users/{id} 這種樣板,下一篇才加)

為什麼不用 enum 來表示 method ? 因為 HTTP 的 method 其實是開放的,你可以發 PATCHOPTIONS、甚至自訂的 method,用字串比較彈性,而且匹配的時候只是字串比對,不需要多一層轉換

用 sealed class 表達匹配結果

路由匹配不是「找到 / 找不到」這麼單純,上一篇我們就提過,POST /hello (path 存在但 method 不對) 和 GET /missing (path 不存在) 應該回不同的 status code,現在有了 Router,我們可以用 sealed class 精確表達三種結果

sealed class MatchResult {
    data class Matched(val route: Route) : MatchResult()
    data object NotFound : MatchResult()
    data class MethodNotAllowed(val allowedMethods: Set<String>) : MatchResult()
}

sealed class 的好處是 compiler 會幫你檢查,如果你在 when 裡漏掉某個分支,compiler 會顯示錯誤。這比回傳 nullable 的 Route? 然後到處判斷 null 好用很多

也許你會問,為什麼不用 enum ? enum 的每個 case 都是同形狀 (沒有 payload),但這裡 Matched 要帶 route、MethodNotAllowed 要帶 allowed methods set、NotFound 不帶任何東西,三個 case 形狀完全不同,sealed class 允許每個子類有自己的欄位,這在型別語意上更精準

bytecode 層面 sealed class 編譯成一個抽象 class + 一群限定的子類,compiler 知道「這個型別在編譯期是封閉的」,於是 when 才能做 exhaustiveness check,Kotlin 1.5 之後還有 sealed interface,可以做出比抽象 class 更彈性的階層

MethodNotAllowed 帶了 allowedMethods: Set<String>,因為 HTTP 規格要求 405 的 response 要帶 Allow header 告訴 client 這個 path 接受哪些 method

Router 的完整實作

class Router {
    private val routes = mutableListOf<Route>()

    fun add(route: Route) {
        routes += route
    }

    fun match(method: String, path: String): MatchResult {
        val normalizedPath = normalizePath(path)

        // 先找同 path 的所有 routes
        val pathMatches = routes.filter { normalizePath(it.path) == normalizedPath }

        if (pathMatches.isEmpty()) {
            // HEAD fallback:如果找不到 path,但如果是 HEAD 請求,
            // 這裡不需要特別處理,因為 path 本身就不存在
            return MatchResult.NotFound
        }

        // path 存在,找 method 完全匹配的
        val exactMatch = pathMatches.find { it.method == method }
        if (exactMatch != null) {
            return MatchResult.Matched(exactMatch)
        }

        // HEAD fallback:沒有 HEAD handler,但有 GET handler
        if (method == "HEAD") {
            val getMatch = pathMatches.find { it.method == "GET" }
            if (getMatch != null) {
                return MatchResult.Matched(getMatch)
            }
        }

        // path 存在但 method 不對
        val allowedMethods = pathMatches.map { it.method }.toMutableSet().apply {
            if ("GET" in this) add("HEAD")
        }
        return MatchResult.MethodNotAllowed(allowedMethods)
    }

    private fun normalizePath(path: String): String {
        if (path == "/") {
            return "/"
        }
        return path.trimEnd('/')
    }
}

match() 的邏輯分三層,先用 normalizePath 統一 trailing slash,然後依序檢查,path 存不存在 → method 有沒有完全匹配 → HEAD 有沒有 GET 可以 fallback → 都不行就回 MethodNotAllowed

normalizePath 目前只做一件事,把結尾的 / 砍掉。所以 /hello/hello/ 會被視為同一個 path,root path / 是特例,不能砍

在 Application 層使用 MatchResult

前面那組測試定義的是 Router 層的規則,只證明 match() 回傳正確的 MatchResult。至於 Application 有沒有把這三種結果翻成對應的 response,是另一回事,所以一樣先寫測試

import kotlin.test.Test
import kotlin.test.assertEquals

class RelixApplicationRoutingTest {

    @Test
    fun `wrong method returns 405 with Allow header`() {
        val app = RelixApplication()
        app.get("/items") { ok("list") }
        app.post("/items") { created("new") }

        val testKit = RelixTestKit(app)
        val response = testKit.handleRequest("DELETE", "/items")

        assertEquals(405, response.statusCode)
        assertEquals(listOf("GET, HEAD, POST"), response.headers["Allow"])
    }

    @Test
    fun `HEAD request returns GET response without body`() {
        val app = RelixApplication()
        app.get("/hello") { ok("Hello!") }

        val testKit = RelixTestKit(app)
        val response = testKit.handleRequest("HEAD", "/hello")

        assertEquals(200, response.statusCode)
        assertEquals(0, response.body.size) // body 被清掉了
    }

    @Test
    fun `trailing slash normalized - same handler for both`() {
        val app = RelixApplication()
        app.get("/hello") { ok("Hello!") }

        val testKit = RelixTestKit(app)

        val response1 = testKit.handleRequest("GET", "/hello")
        val response2 = testKit.handleRequest("GET", "/hello/")

        assertEquals(200, response1.statusCode)
        assertEquals(200, response2.statusCode)
        assertEquals("Hello!", response1.bodyAsText())
        assertEquals("Hello!", response2.bodyAsText())
    }
}

405 需要的工廠方法先補上,HTTP 規格 (RFC 9110) 要求 405 的 response 帶 Allow header,告訴 client 這個 path 接受哪些 method

fun methodNotAllowed(allowedMethods: Set<String>): RelixResponse = RelixResponse(
    statusCode = 405,
    headers = mapOf(
        "Content-Type" to listOf("text/plain; charset=utf-8"),
        "Allow" to listOf(allowedMethods.sorted().joinToString(", ")),
    ),
    body = "Method Not Allowed".toByteArray(Charsets.UTF_8),
)

有了 sealed class,handle() 就能很乾淨地用 when 處理每種情況

class RelixApplication {
    private val router = Router()

    fun get(path: String, handler: RelixHandler) =
        router.add(Route("GET", path, handler))
    fun post(path: String, handler: RelixHandler) =
        router.add(Route("POST", path, handler))
    fun put(path: String, handler: RelixHandler) =
        router.add(Route("PUT", path, handler))
    fun delete(path: String, handler: RelixHandler) =
        router.add(Route("DELETE", path, handler))

    fun handle(call: RelixCall): RelixResponse {
        val result = router.match(call.request.method, call.request.path)

        val response = when (result) {
            is MatchResult.Matched -> result.route.handler(call)
            is MatchResult.NotFound -> notFound()
            is MatchResult.MethodNotAllowed -> methodNotAllowed(result.allowedMethods)
        }

        // 這裡先清掉 body;正式 adapter 還要保留原回應的 Content-Length 語意
        return if (call.request.method == "HEAD") {
            response.copy(body = ByteArray(0))
        } else {
            response
        }
    }
}

注意 when 不需要 else 分支,因為 MatchResult 是 sealed class,compiler 知道只有這三種可能,如果未來加了新的 MatchResult 子類別,所有沒處理到的 when 都會報編譯錯誤,逼你去處理

HEAD 的 body 清除放在 Application 層而不是 Router 層,Router 只負責找到 handler,response 則由 Application 與 adapter 處理,這裡直接清空 body 是簡化版本,若要完整遵守 HTTP 語意,adapter 必須避免送出 body,同時保留 GET 回應原本會有的內容長度與 headers

跑一次測試,新寫的三個都會過,但上一篇留下的一個測試會失敗

上一篇那個 404 測試現在會失敗

上一篇的測試裡有這麼一個

@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)
}

/hello 有註冊,只是沒有 POST,這正是 405 的情境,上一篇用兩層 Map 做路由,查不到 handler 就是查不到,分不出「path 不存在」和「path 存在但 method 不對」,所以只能回 404,那個測試當時記錄的是這個限制,不是我們想要的行為

現在 Router 分得出來了,同一個請求會回 405,測試自然會失敗,這種失敗要改測試而不是改實作,因為規格本來就變了,測試只是還停在舊規格

@Test
fun `wrong method for registered path returns 405`() {
    val app = RelixApplication()
    app.get("/hello") { ok("Hello!") }

    val testKit = RelixTestKit(app)
    val response = testKit.handleRequest("POST", "/hello")

    assertEquals(405, response.statusCode)
    assertEquals(listOf("GET, HEAD"), response.headers["Allow"])
}

測試名稱要一起改,returns 404 留著會變成錯誤的文件。Allow 是 GET, HEAD 而不是只有 GET,因為註冊了 GET 就等於也支援 HEAD

順帶一提,「測試失敗就改測試」是很危險的習慣,多數時候測試失敗代表實作有問題,改測試只是在遷就 bug,能動測試的前提是你說得出「哪一條規格改了、為什麼改」,這裡的理由是 404 跟 405 的區分能力本來就是這篇要做的事,上一篇也預告過了

接下來會分別說明這三條規則背後的取捨

405 的 Allow header 為什麼要排序

methodNotAllowed() 裡的 allowedMethods.sorted() 讓 Allow header 的 method 順序固定 (字母排序),順序不影響語意,但固定順序讓測試好寫,也讓 response 看起來整齊。如果直接 joinToString() 而不排序,順序會跟著 route 註冊的先後跑,測試就得改成比對 set 而不是字串

對比上一篇的 Map 路由,同樣的情境只能回 404,現在 client 可以看到 Allow: GET, HEAD, POST。GET 可被 HEAD fallback,因此 Allow 也要包含 HEAD

HEAD fallback 沿用 GET,但不回 body

HTTP 規格說 HEAD 的語意跟 GET 完全一樣,差別只在 response 不帶 body,很多框架會自動做這件事,如果你沒有註冊 HEAD handler,但有 GET handler,HEAD 就走 GET 的邏輯,回來之後把 body 清掉

Router 負責匹配到 GET handler,Application 負責清掉 body,這個分工就是 handle() 最後那段 if 在做的事,放在 Router 層也不是不行,但 Router 的職責是「找到誰來處理」,動 response 會讓它多背一件事

如果你有特殊需求 (例如 HEAD 的 response header 跟 GET 不同),可以單獨註冊 HEAD handler,Router 會優先匹配它

Trailing slash 策略

/hello/hello/ 是同一個 path 嗎 ? HTTP 規格的答案是「不一定」,有些 API 把它們當作不同的資源,有些框架把它們視為等價,這裡我們選了寬鬆策略,兩者視為等價

在實務上,trailing slash 造成的 bug 遠比它帶來的語意價值多,使用者在瀏覽器手打 URL 時很容易多打或少打一個 /,API client 的 base URL 結尾有沒有 / 也不一定一致,統一 normalize 省去很多麻煩

如果你的框架需要嚴格模式,只要把 normalizePath 改成不做任何處理就好,規則集中在一個方法裡,改起來很容易,而前面 trailing slash normalized 那個測試會立刻失敗,提醒你這個決定影響到哪些行為

延伸,靜態路由 vs path param 的優先順序

第 09 篇我們會加入路徑參數 /users/{id},這時會出現一個問題

GET /users/me     → 應該匹配 /users/me(靜態)
GET /users/123    → 應該匹配 /users/{id}(參數)

如果 Router 不區分優先順序,/users/me 可能被 /users/{id} 吃掉 (id = "me"),下一篇會處理

常見陷阱與設計取捨

sealed class vs enum

你可能想用 enum 來表示匹配結果,差別在於 enum 的每個 variant 不能帶不同的資料,Matched 需要帶 RouteMethodNotAllowed 需要帶 allowedMethodsNotFound 不帶任何東西,而 sealed class 可以讓每個子類別有自己的結構,enum 做不到

method 比較要不要 case-insensitive ?

HTTP 規格說 method 是 case-sensitive 的 (GETget),但實務上,很多 proxy 和 client library 會正規化 method 成大寫,我們現在不做 case-insensitive 比較,因為 RelixRequest 拿到的 method 已經是 JDK HttpServer 提供的,通常是大寫,如果之後遇到問題,在 match() 裡加一個 uppercase() 就好

Router 用 List 還是 Map ?

目前 Router 內部用 List<Route> 存所有路由,match() 每次都要 filter 整個 list,這對幾十條路由來說完全不是問題,如果路由數量到了幾百甚至幾千條,可以考慮改成 Map<String, List<Route>> (path → routes),先用 path 查到 O(1),再在小 list 裡找 method,但這是效能最佳化的事,不是現在要做的


小結

從 Map 到 Router 的升級看起來程式碼沒多很多,但關鍵差異在語意,sealed class 讓匹配結果有明確的型別,when 表達式讓 compiler 幫你檢查有沒有漏掉某種情況

Router 把 trailing slash normalize、HEAD fallback、405 + Allow header 這些規則集中在一個地方,後面再怎麼加功能都不會散落各處


下一篇

下一篇我們讓路由支援路徑參數,把 /users/{id} 裡的 {id} 解析出來,放進 RelixCall 裡讓 handler 可以用 pathParam("id") 取值。這會牽涉到路徑樣板的分段匹配,還有靜態路由與參數路由的優先順序


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 07 最簡單的路由,用 Map 做 URL 對應
下一篇
Kotlin 手刻 Ktor 從零開始 Day 09 路徑參數,讓 /users/{id} 活起來
系列文
Kotlin 手刻 Ktor 從零開始18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言