
上一篇我們用 HttpExchange 直接處理 request 和 response,雖然可以動,不過這種程式碼寫起來真的很痛苦
這篇要做的事很單純,把 JDK 的 HttpExchange 封裝成乾淨、可測、可演進的 RelixRequest
HttpExchange 很接近底層,直接拿來寫應用會遇到幾個問題
第一個也是最主要的,是資訊分散的問題,method、URI、headers、body 各自用不同的 API 取得,沒有一個統一的「request 物件」,query string 要自己從 URI 裡拆出來再 parse,headers 是 Headers 型別 (本質上是 Map<String, List<String>>),key 的大小寫處理也不一致
另外一個問題是測試,你沒辦法輕鬆地「建構一個 HttpExchange 物件」來測解析邏輯,因為它是 JDK 內部的 abstract class,你必須透過真的 HTTP connection 才能拿到它,這表示每次想測一個 query string 解析,你都得啟動一整個 server
所以我們要做一個自己的資料結構,把需要的資訊整理好,測試時可以直接 new 出來
在動手寫 data class 之前,先把這一層負責什麼、不負責什麼講清楚,不然很容易一路加欄位或是修修改改,最後變成什麼都往裡面塞的萬用物件
說穿了只有三件
搬資料,把 HttpExchange 上散在各處的 method、URI、headers、body 讀出來,放到同一個物件上
解析,把 raw query string 拆成 Map<String, List<String>>,順便做 URL decode,這是這篇唯一有邏輯、值得寫測試的部分
定型,給每個欄位一個確定的型別跟確定的語意,後面的 Router、Middleware、TestKit 都對著這份定義寫,不用各自猜 headers 到底是什麼形狀
RelixRequest 不知道系統註冊了哪些 route,也不知道自己等一下會被哪個 handler 接走bodyAsText() 只是把 bytes 用 UTF-8 轉成字串,JSON 轉物件要等第 20 篇的 Content Negotiation 接上 kotlinx.serialization,第 21 篇的 receive<T>() 才把讀取和解析串起來有一個很好用的判斷方式,問自己一句話,這個欄位的值,光看這條 request 的 bytes 能不能決定 ?
method、path、headers、query、body 都可以,client 送出去那一刻它們就定案了,你換一套框架、換一組路由表,它們還是同樣的值
path parameters 就不行,GET /users/42 這條 request 本身並沒有「id 等於 42」這件事,是因為有人註冊了 /users/{id},router 比對完才生出這個結果,同樣一條 request,如果註冊的樣板寫成 /users/{userId},key 就變成 userId,如果什麼 route 都沒註冊,那就連 path params 都不存在
所以 path params 是框架的產出,不是 HTTP 的輸入,middleware 之間傳值用的 attributes 也是一樣的道理,它是處理過程中長出來的東西
假設我們現在偷懶,把 pathParameters 直接加進 RelixRequest,三個問題會馬上冒出來
第一個是 建構時序對不上,toRelixRequest() 執行的時候還沒經過 router,根本不知道要填什麼,只能先填 emptyMap(),等比對完再回頭塞值,這表示欄位得改成 var,或者每次都 copy() 出一個新的,前者讓 immutable 的好處消失,後者讓「現在手上這個 request 到底是哪一份」變得難追
第二個是 測試變彆扭,你只想測 query string 有沒有解析對,卻得先決定 pathParameters 要填什麼,反過來測 handler 的時候,你關心的只有 path params,卻得補一堆跟這個測試無關的 HTTP 欄位
第三個是 equals 的語意糊掉,兩個 HTTP 內容一模一樣的 request,只因為跑過不同的路由表就變成不相等,這個物件也就不再是單純的值
| 資訊 | 放哪裡 | 判斷依據 |
|---|---|---|
| method / path / headers / queryParameters / body | RelixRequest |
client 送出時就定案的原始資料 |
| path parameters | RelixCall (第 06 篇) |
router 比對路由樣板之後才產生 |
| attributes | RelixCall |
middleware 執行過程中才寫入 |
| response | RelixCall |
處理過程中逐步組出來的 |
RelixCall 是「一次請求的生命週期容器」,它會持有一個 RelixRequest,再加上處理過程中長出來的資料,這樣分之後,RelixRequest 可以是完全 immutable 的值物件,建好就不再改,RelixCall 則是可變的,因為它本來就要隨著 middleware chain 累積狀態
Ktor 其實也是這個分法,ApplicationCall 裡面包著 ApplicationRequest 跟 ApplicationResponse,path params 掛在 call 上而不是 request 上
當然這條線之後不是完全不能調整,但原則會維持一個,衍生資訊不進 RelixRequest,只要那個值需要框架的設定或執行過程才算得出來,它就屬於框架層
這篇會生出三個檔案,QueryParser.kt 放 query string 的解析、RelixRequest.kt 放資料結構、HttpExchangeExt.kt 放轉換用的 extension function,測試則對應到 test/ 底下的三個檔案
先從 query string 開始,它是這篇邏輯最多的部分,而且可以抽成一個不依賴任何 HTTP 物件的純函式,測起來最單純
import kotlin.test.Test
import kotlin.test.assertEquals
class ParseQueryTest {
@Test
fun `null query returns empty map`() {
assertEquals(emptyMap(), parseQuery(null))
}
@Test
fun `empty string returns empty map`() {
assertEquals(emptyMap(), parseQuery(""))
}
@Test
fun `single key-value pair`() {
val result = parseQuery("a=1")
assertEquals(listOf("1"), result["a"])
}
@Test
fun `multiple key-value pairs`() {
val result = parseQuery("a=1&b=2")
assertEquals(listOf("1"), result["a"])
assertEquals(listOf("2"), result["b"])
}
@Test
fun `same key with multiple values`() {
val result = parseQuery("tag=kotlin&tag=web")
assertEquals(listOf("kotlin", "web"), result["tag"])
}
@Test
fun `URL encoded values are decoded`() {
val result = parseQuery("q=hello%20relix&name=%E9%A2%A8%E7%AE%8F")
assertEquals(listOf("hello relix"), result["q"])
assertEquals(listOf("風箏"), result["name"])
}
@Test
fun `key without value`() {
val result = parseQuery("debug&verbose")
assertEquals(listOf(""), result["debug"])
assertEquals(listOf(""), result["verbose"])
}
@Test
fun `empty value`() {
val result = parseQuery("name=")
assertEquals(listOf(""), result["name"])
}
}
注意第六個測試,hello%20relix 要被 decode 成 hello relix,中文 %E9%A2%A8%E7%AE%8F 要被 decode 成 風箏。URL decode 在 query string 這一層就要做完,不要留給上層處理
trailing slash 的規則、path 的 URL decode 時機,這篇先不處理。這些會在第 08-09 篇路由系統中集中管理
測試寫好了,來實作,程式碼放在 QueryParser.kt
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
fun parseQuery(query: String?): Map<String, List<String>> {
if (query.isNullOrEmpty()) return emptyMap()
val result = mutableMapOf<String, MutableList<String>>()
query.split("&").forEach { pair ->
val eqIndex = pair.indexOf('=')
val key: String
val value: String
if (eqIndex == -1) {
// "debug" 這種沒有等號的 key
key = decode(pair)
value = ""
} else {
key = decode(pair.substring(0, eqIndex))
value = decode(pair.substring(eqIndex + 1))
}
result.getOrPut(key) { mutableListOf() }.add(value)
}
return result
}
private fun decode(s: String): String =
URLDecoder.decode(s, StandardCharsets.UTF_8)
幾個實作細節要注意
split("&") 會把 a=1&b=2 拆成 ["a=1", "b=2"],接著用 indexOf('=') 找等號的位置,而不是用 split("="),為什麼 ? 因為 value 裡面可能包含 = (例如 base64 編碼的值),用 split 會切到錯的地方
getOrPut 是 Kotlin 標準函式庫裡很好用的 API,如果 key 不存在就建一個新的 list,如果已經存在就拿現有的。這樣同一個 key 的多個值會自然地被收集在同一個 list 裡
parseQuery 有了,接下來是這篇的主角
RelixRequest 看起來只是一個裝資料的 data class,好像沒什麼好測的,但它有兩個行為一定要測
一個是 bodyAsText() 的解碼,body 存的是 ByteArray,轉字串時用什麼編碼會直接決定中文會不會變亂碼
另一個是相等性。這點更重要,因為 RelixRequest 之後會大量出現在測試的斷言裡,第 06 篇的 TestKit 也會直接 new 它來比對,如果兩個內容一模一樣的 request 被判定為不相等,後面每一篇的測試都會很難寫
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
class RelixRequestTest {
// 大部分測試只關心其中一兩個欄位,用 helper 補掉其他的
private fun request(
method: String = "GET",
path: String = "/",
body: ByteArray = ByteArray(0),
) = RelixRequest(
method = method,
path = path,
headers = emptyMap(),
queryParameters = emptyMap(),
body = body,
)
@Test
fun `body defaults to empty`() {
val request = RelixRequest(
method = "GET",
path = "/hello",
headers = emptyMap(),
queryParameters = emptyMap(),
)
assertEquals(0, request.body.size)
assertEquals("", request.bodyAsText())
}
@Test
fun `bodyAsText decodes UTF-8`() {
val request = request(body = "你好,Relix".toByteArray(Charsets.UTF_8))
assertEquals("你好,Relix", request.bodyAsText())
}
@Test
fun `requests with same content are equal`() {
val a = request(method = "POST", path = "/echo", body = "hi".toByteArray())
val b = request(method = "POST", path = "/echo", body = "hi".toByteArray())
assertEquals(a, b)
}
@Test
fun `equal requests have same hashCode`() {
val a = request(body = "hi".toByteArray())
val b = request(body = "hi".toByteArray())
assertEquals(a.hashCode(), b.hashCode())
}
@Test
fun `equal requests collapse into one Set entry`() {
val a = request(body = "hi".toByteArray())
val b = request(body = "hi".toByteArray())
assertEquals(1, setOf(a, b).size)
}
@Test
fun `different body means not equal`() {
val a = request(body = "hi".toByteArray())
val b = request(body = "bye".toByteArray())
assertNotEquals(a, b)
}
}
這幾個測試在 RelixRequest 還沒寫出來之前連編譯都過不了,這是正常的,紅燈本來就是 TDD 的第一步
照著測試的需求,最直覺的寫法是這樣,檔案放 RelixRequest.kt
data class RelixRequest(
val method: String,
val path: String,
val headers: Map<String, List<String>>,
val queryParameters: Map<String, List<String>>,
val body: ByteArray = ByteArray(0),
) {
fun bodyAsText(): String = body.toString(Charsets.UTF_8)
}
這段貼進 IntelliJ IDEA,測試都還沒跑,body: ByteArray = ByteArray(0) 那一行就會被標起來,游標移上去會看到
Property with 'Array' type in a 'data' class:
it is recommended to override 'equals()' and 'hashCode()'
旁邊還附了一個 quick fix「Generate equals() and hashCode()」,按下去 IDE 就會幫你把兩個方法補完
不過先不要按,把測試跑起來看看失敗長什麼樣子,因為 IDE 只說了「建議覆寫」,沒說不覆寫會壞成什麼樣子,而那個壞法才是這篇要講的東西
六個測試裡有兩個失敗,requests with same content are equal 和 equal requests collapse into one Set entry
錯誤訊息長這樣
// requests with same content are equal
org.opentest4j.AssertionFailedError:
expected: RelixRequest@49a64d82<RelixRequest(method=POST, path=/echo, headers={}, queryParameters={}, body=[104, 105])>
but was: RelixRequest@344561e0<RelixRequest(method=POST, path=/echo, headers={}, queryParameters={}, body=[104, 105])>
// equal requests collapse into one Set entry
org.opentest4j.AssertionFailedError: expected: <1> but was: <2>
第一條訊息本身就是線索。兩個物件的 toString 印出來一字不差,連 body 都是 [104, 105] (hi 這兩個字元的 byte),JUnit 發現印出來分不出差別,只好額外補上 @49a64d82 和 @344561e0 這兩個 identity hash 來區分。內容完全一樣卻判定為不相等,比的顯然不是內容
第二條則是同一件事在 Set 上的後果,兩個內容相同的 request 丟進 setOf(),應該只留下一筆,實際卻留了兩筆
兇手就是 IDE 標出來的那一欄,body,另外四個欄位是 String 和 Map,== 比的都是內容,只有 ByteArray 例外,比的是「這兩個 array 是不是同一個物件」,測試裡 "hi".toByteArray() 呼叫了兩次,拿到的是兩個內容相同但各自獨立的物件,於是整個 request 就被判定為不相等,至於為什麼偏偏是它例外,下面有一節專門講
所以 equals 要自己寫,body 那一欄改用 contentEquals 比內容,hashCode 也要一起覆寫,因為這兩個方法必須成對一致,兩個相等的物件一定要給出相同的 hash code,HashMap 和 HashSet 都建立在這個前提上
data class RelixRequest(
val method: String,
val path: String,
val headers: Map<String, List<String>>,
val queryParameters: Map<String, List<String>>,
val body: ByteArray = ByteArray(0),
) {
fun bodyAsText(): String = body.toString(Charsets.UTF_8)
// data class 產生的 equals 對 ByteArray 比的是 reference,手動覆寫
override fun equals(other: Any?): Boolean {
if (this === other) {
return true
}
if (other !is RelixRequest) {
return false
}
return method == other.method
&& path == other.path
&& headers == other.headers
&& queryParameters == other.queryParameters
&& body.contentEquals(other.body)
}
override fun hashCode(): Int {
var result = method.hashCode()
result = 31 * result + path.hashCode()
result = 31 * result + headers.hashCode()
result = 31 * result + queryParameters.hashCode()
result = 31 * result + body.contentHashCode()
return result
}
}
Map<String, List<String>> ?HTTP 規格允許同一個 header 出現多次 (例如 Set-Cookie),query parameter 也可以同 key 多值 (例如 tag=a&tag=b)。如果你用 Map<String, String> 就會有問題
有些框架 (例如 Ktor) 用自訂的 StringValues 型別來處理這件事,在這裡我們先用標準的 Map<String, List<String>>,夠用而且不需要額外抽象,如果之後覺得太囉嗦,可以加幾個 extension function 來簡化存取
改成 contentEquals 之後測試就通過了,不過這次失敗的成因值得多探討,因為它不只影響 RelixRequest
這個坑主要來自 JVM 的歷史包袱,ByteArray 在 JVM 上就是 byte[],而 Java 的陣列不覆寫 equals(),繼承自 Object 的版本永遠用 reference 比較,Kotlin compiler 為 data class 產生 equals 時,對每個欄位呼叫 ==,而 == 對 ByteArray 委派給 JVM 的 Object.equals(),於是就回到 reference 比較,換句話說,這不是 Kotlin 故意這樣設計,而是 compiler 不能擅自把 == 改寫成 contentEquals (其他 Array 型別也一樣,所以 IntArray、Array<String> 都有相同問題)
更討厭的是它很安靜 (不容易發現),equals 一旦判定兩個內容相同的 request 不同,HashSet 就把它們存成兩筆,不會丟例外,也不會有任何警告,要一直到後面某個地方數量對不上才會發現,這就是 equal requests collapse into one Set entry 想要驗證的
前面說 hashCode 要跟著一起覆寫,這裡可以再補一個理由,compiler 為 array 欄位產生的 hashCode 是實作細節,不必去賭它,自己寫出來就不用管那一層,而且 equals 和 hashCode 放在一起,下次有人加欄位比較不會只改一邊,IDE 那句提示同時點名兩個方法,也是同一個道理
另外那個 different body means not equal,在還沒覆寫之前也是通過的,但它通過得沒有意義,因為還沒覆寫的那一版比的是 reference,兩個分別建出來的 request 一定不相等,body 一不一樣根本不影響結果,它是碰巧通過的,這種測試從結果上看不出問題,只有搭配前面幾個一起看才知道它有沒有真的驗到東西
我們需要一個從 HttpExchange 轉成 RelixRequest 的函式,比較簡單的方式是把它寫成 extension function
第 03 篇提過 adapter 這個詞,指的是銜接底層 server 跟框架的那一層,當時它還跟 RelixApplication 混在一起,toRelixRequest() 就是把它拆出來的第一步,職責很單純,把 JDK HttpServer 給的東西翻譯成框架自己的型別。目前只有 request 這半邊,response 那半邊下一篇補上,之後如果要換掉底層的 engine,要改的也只有這一層
parseQuery 和 RelixRequest 都可以用純粹的單元測試驗證,但 toRelixRequest() 沒辦法,因為前面說過,HttpExchange 你建不出來,只能透過真的 HTTP connection 拿到
所以這些測試要開一個 server,做法跟第 03 篇一樣,只是這次 handler 裡不回傳內容,而是把轉換後的 RelixRequest 存起來讓測試檢查
import com.sun.net.httpserver.HttpServer
import java.net.InetSocketAddress
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ToRelixRequestTest {
private lateinit var server: HttpServer
private var port = 0
private var captured: RelixRequest? = null
@BeforeTest
fun setUp() {
server = HttpServer.create(InetSocketAddress(0), 0)
// 註冊在 "/" 上,任何 path 都會進來
server.createContext("/") { exchange ->
captured = exchange.toRelixRequest()
exchange.sendResponseHeaders(204, -1) // -1 = 沒有 body
exchange.close()
}
server.start()
port = server.address.port
}
@AfterTest
fun tearDown() {
server.stop(0)
}
private fun send(request: HttpRequest) {
HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.discarding())
}
@Test
fun `converts method path query and body`() {
send(
HttpRequest.newBuilder()
.uri(URI("http://localhost:$port/users/42?tag=kotlin&tag=web"))
.POST(HttpRequest.BodyPublishers.ofString("hello relix"))
.build()
)
val request = captured!!
assertEquals("POST", request.method)
assertEquals("/users/42", request.path)
assertEquals(listOf("kotlin", "web"), request.queryParameters["tag"])
assertEquals("hello relix", request.bodyAsText())
}
@Test
fun `header keys are normalized by JDK`() {
send(
HttpRequest.newBuilder()
.uri(URI("http://localhost:$port/hello"))
.header("X-Trace-Id", "abc-123")
.GET()
.build()
)
val headers = captured!!.headers
assertEquals(listOf("abc-123"), headers["X-trace-id"])
assertNull(headers["X-Trace-Id"])
}
}
第一個測試沒什麼懸念,method、path、query、body 都要照著送出去的值還原回來,這個在實作之前就寫得出來
第二個就不是了,它是實作跑起來之後才補的,因為 JDK 的 Headers 會把 key 正規化成「首字大寫、其餘全小寫」的形式,這件事我事先並不知道,所以你送出去的 X-Trace-Id 進到 exchange 之後是 X-trace-id,Content-Length 是 Content-length,User-Agent 是 User-agent
更麻煩的是,Headers 本身查詢是不分大小寫的,headers.get("X-Trace-Id") 拿得到值,但是一旦經過 toMap() 變成普通的 Map,這個能力就消失了,剩下正規化過的 key,用原本的大小寫去查就是 null
這個測試跟前面那種先定義行為,再讓實作去滿足它的測試目的不一樣,它不是用來驅動設計的,而是把 JDK 既有的行為記錄下來,留著是因為它剛好是那種你不寫下來,三個月後一定會忘記,然後再踩一次雷的東西,第 17 篇補 header(name) helper 的時候,這個測試也會變成那個 helper 的規格來源
我們把這個檔案放 HttpExchangeExt.kt
import com.sun.net.httpserver.HttpExchange
fun HttpExchange.toRelixRequest(): RelixRequest {
val uri = this.requestURI
val query = uri.rawQuery // 還沒 decode 的 query string
return RelixRequest(
method = this.requestMethod,
path = uri.path,
headers = this.requestHeaders.toMap(),
queryParameters = parseQuery(query),
body = this.requestBody.readAllBytes(),
)
}
uri.rawQuery 拿到的是還沒 decode 的 query string (例如 q=hello%20relix),decode 的工作交給 parseQuery() 內部處理,uri.path 拿到的是已經 decode 過的 path (這是 URI 類別的行為)
requestHeaders.toMap() 會把 JDK 的 Headers 型別轉成標準的 Map<String, List<String>>,也就是上面那個測試記錄下來的行為,key 已經被正規化過,原本不分大小寫的查詢能力也不見了,所以後續查詢不能直接依賴 Map 的 key 大小寫,在第 17 篇做 CORS 時會補上一個集中處理的 header(name) helper,第 19 篇的 request body 和第 20 篇的 Content Negotiation 都會沿用它
body 什麼時候讀 ?
上面的實作在 toRelixRequest() 時就呼叫 readAllBytes(),會先把 body 全部配置到記憶體,第 19 篇會清楚區分兩件事,middleware 能做的是讀取完成後的政策檢查,真正能避免超大 body 配置記憶體的上限,必須放在 adapter 讀取 body 的那一步,也就是這篇 toRelixRequest() 呼叫 readAllBytes() 的地方
path 要不要做 URL decode ?
URI.path 已經 decode 過了,所以 RelixRequest.path 存的是 decode 後的值。但路由匹配時你需要注意,使用者可能傳入 /users/hello%20world,decode 後變成 /users/hello world,這個帶空格的 path 能不能匹配到 /users/{id} ? 答案是可以,但你得在 Router 那邊處理好,這個問題第 08-09 篇會碰到
為什麼不做 case-insensitive headers ?
HTTP 規格說 header name 不分大小寫,但 Map<String, List<String>> 的 key 比較會區分大小寫,兩邊對不上,而且前面 header keys are normalized by JDK 那個測試已經測到,key 進 exchange 就被正規化過了,連用原本的大小寫去查都拿不到值
不過大小寫容錯是查詢端的事,不是資料結構的責任,RelixRequest 這一層先原封不動地保留 JDK 給的資料,第 17 篇會加入一個 helper function,讓所有 header 查詢都走同一個入口,到那時候再統一處理
這篇做的是「把底層 API 整理成好用的資料結構」,看起來很樸素,但 RelixRequest 會直接影響後面每一篇的開發體驗,Router 要靠 method / path 做匹配,Middleware 要讀 headers 和 query,TestKit 要能直接 new 一個 RelixRequest 來測,先把相關的物件整理起來,後面就會比較方便了
在第 03 篇 RelixApplication 裡那段寫死的 handler 目前還是原封不動,這篇做出來的 toRelixRequest() 還沒有真的被跑到,只有測試在用它,下一篇的 RelixResponse 也一樣,兩篇都是在準備零件,要到第 06 篇定義完 RelixCall 和 RelixHandler,把 adapter 抽成獨立的類別之後,才會把 RelixApplication 那段換掉
下一篇我們會做 RelixResponse,把 statusCode / headers / body 封裝起來,並提供 ok()、notFound()、json() 這類便利方法,讓寫 handler 時可以很直覺
同步刊登於 Blog
圖片來源:AI 產生