
上一篇實作了 RelixRequest (把 request 讀進來),這篇要做它的另一半 RelixResponse (把 response 寫出去),目標是讓 handler 能用很直覺的方式回應,像 ok("Hello!") 或 json("""{"ok":true}""") 這樣
這篇會異動到兩個檔案,RelixResponse.kt 放資料結構和工廠方法,writeResponse() 則加進第 04 篇就有的 HttpExchangeExt.kt,跟 toRelixRequest() 放在一起
這一組驗的是工廠方法各自要給出什麼,ok() / notFound() / created() / noContent() / json() 的 status code 要對,Content-Type 要對,body 也要能照原樣讀回來,有預設值的參數也要測,不然那個預設值等於沒人看著
import kotlin.test.Test
import kotlin.test.assertEquals
class RelixResponseTest {
@Test
fun `ok returns 200 with text body`() {
val response = ok("Hello, Relix!")
assertEquals(200, response.statusCode)
assertEquals("Hello, Relix!", response.bodyAsText())
}
@Test
fun `ok sets Content-Type to text plain`() {
val response = ok("Hello")
val contentType = response.headers["Content-Type"]
assertEquals(listOf("text/plain; charset=utf-8"), contentType)
}
@Test
fun `notFound returns 404 with default body`() {
val response = notFound()
assertEquals(404, response.statusCode)
assertEquals("Not Found", response.bodyAsText())
}
@Test
fun `notFound accepts custom text`() {
val response = notFound("no such user")
assertEquals(404, response.statusCode)
assertEquals("no such user", response.bodyAsText())
}
@Test
fun `json returns 200 with json content type`() {
val response = json("""{"name":"Relix"}""")
assertEquals(200, response.statusCode)
assertEquals(
listOf("application/json; charset=utf-8"),
response.headers["Content-Type"]
)
assertEquals("""{"name":"Relix"}""", response.bodyAsText())
}
@Test
fun `json with custom status code`() {
val response = json("""{"id":1}""", statusCode = 201)
assertEquals(201, response.statusCode)
}
@Test
fun `created returns 201`() {
val response = created("resource created")
assertEquals(201, response.statusCode)
assertEquals("resource created", response.bodyAsText())
}
@Test
fun `noContent returns 204 with empty body`() {
val response = noContent()
assertEquals(204, response.statusCode)
assertEquals("", response.bodyAsText())
}
}
照著測試的需求寫出來是這樣
data class RelixResponse(
val statusCode: Int,
val headers: Map<String, List<String>> = emptyMap(),
val body: ByteArray = ByteArray(0),
) {
fun bodyAsText(): String = body.toString(Charsets.UTF_8)
override fun equals(other: Any?): Boolean {
if (this === other) {
return true
}
if (other !is RelixResponse) {
return false
}
return statusCode == other.statusCode
&& headers == other.headers
&& body.contentEquals(other.body)
}
override fun hashCode(): Int {
var result = statusCode
result = 31 * result + headers.hashCode()
result = 31 * result + body.contentHashCode()
return result
}
}
跟 RelixRequest 一樣,因為有 ByteArray 所以要手動覆寫 equals / hashCode。headers 用 Map<String, List<String>>,跟 request 端保持一致
handler 最常用的幾個回應方式,我們包成工廠方法,可以把它跟 RelixResponse 放在一起
fun ok(text: String): RelixResponse = RelixResponse(
statusCode = 200,
headers = mapOf("Content-Type" to listOf("text/plain; charset=utf-8")),
body = text.toByteArray(Charsets.UTF_8),
)
fun notFound(text: String = "Not Found"): RelixResponse = RelixResponse(
statusCode = 404,
headers = mapOf("Content-Type" to listOf("text/plain; charset=utf-8")),
body = text.toByteArray(Charsets.UTF_8),
)
fun created(text: String): RelixResponse = RelixResponse(
statusCode = 201,
headers = mapOf("Content-Type" to listOf("text/plain; charset=utf-8")),
body = text.toByteArray(Charsets.UTF_8),
)
fun noContent(): RelixResponse = RelixResponse(
statusCode = 204,
)
fun json(rawJson: String, statusCode: Int = 200): RelixResponse = RelixResponse(
statusCode = statusCode,
headers = mapOf("Content-Type" to listOf("application/json; charset=utf-8")),
body = rawJson.toByteArray(Charsets.UTF_8),
)
目前這些方法都是 top-level function,第 06 篇定義了 RelixHandler (RelixCall.() -> RelixResponse) 之後,handler 裡照樣可以直接寫 ok("Hello!"),top-level function 本來就不需要 receiver,等到第 20 篇要做 ok(user) 這種需要讀 Accept header 的版本時,才會在 RelixCall 上補一個 member 版本,因為那時候它真的需要存取 call 的狀態,這裡的 top-level 版本不會消失,上面那組測試就是直接呼叫它,沒有經過任何 RelixCall
後面第 20 篇我們會做
ok(user)這種「直接丟物件、自動序列化成 JSON」的版本。這篇先用字串版本把路打通
工廠方法給的是一個做好的 response,但實務上常常還要再加東西上去,像是補一個 X-Request-Id。這一組測試要定義的是加 header 時發生什麼事,回傳的是一個新的 RelixResponse,原本那個不能被動到,同一個 key 加兩次,兩個值都要留著
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class RelixResponseHeaderTest {
@Test
fun `header returns new response with added header`() {
val original = ok("Hello")
val withHeader = original.header("X-Request-Id", "abc-123")
// 原始 response 不應該被改動
assertNull(original.headers["X-Request-Id"])
assertEquals(listOf("abc-123"), withHeader.headers["X-Request-Id"])
}
@Test
fun `header preserves existing headers`() {
val response = ok("Hello")
.header("X-Trace-Id", "trace-1")
.header("X-Request-Id", "req-1")
assertEquals(listOf("text/plain; charset=utf-8"), response.headers["Content-Type"])
assertEquals(listOf("trace-1"), response.headers["X-Trace-Id"])
assertEquals(listOf("req-1"), response.headers["X-Request-Id"])
}
@Test
fun `header appends to existing key`() {
val response = ok("Hello")
.header("X-Custom", "first")
.header("X-Custom", "second")
assertEquals(listOf("first", "second"), response.headers["X-Custom"])
}
}
header() 方法要做的是回傳一個新的 RelixResponse,裡面包含原有的 headers 加上新的 header。如果 key 已經存在,新的 value 要被 append 進去 (不是取代)
RelixResponse 是我們自己的型別,想加方法直接寫進 class 裡就好,不需要繞一圈用 extension function,後面 writeResponse 會寫成 extension,是因為 HttpExchange 是 JDK 的類別,我們改不了它
加上 header() 之後,RelixResponse 的完整樣子是這樣
data class RelixResponse(
val statusCode: Int,
val headers: Map<String, List<String>> = emptyMap(),
val body: ByteArray = ByteArray(0),
) {
fun bodyAsText(): String = body.toString(Charsets.UTF_8)
fun header(name: String, value: String): RelixResponse {
val existingValues = headers[name] ?: emptyList()
val newHeaders = headers + (name to existingValues + value)
return copy(headers = newHeaders)
}
override fun equals(other: Any?): Boolean {
if (this === other) {
return true
}
if (other !is RelixResponse) {
return false
}
return statusCode == other.statusCode
&& headers == other.headers
&& body.contentEquals(other.body)
}
override fun hashCode(): Int {
var result = statusCode
result = 31 * result + headers.hashCode()
result = 31 * result + body.contentHashCode()
return result
}
}
這裡用了幾個 Kotlin 的小技巧
headers + (name to existingValues + value),headers Map 的 + operator 會回傳新的 Map,如果 key 已存在就覆蓋,existingValues + value 是 List 的 + operator,把新值 append 到現有 list 後面,整個操作沒有修改原本的 map 或 list
copy() 是 data class 內建的方法,讓你建立一個「只改了部分欄位」的新物件。這就是為什麼我們用 data class 而不是普通 class
你可能會想,headers 用 MutableMap 不是更方便嗎 ? 直接 response.headers["X-Foo"] = listOf("bar") 就好了
問題是 response 可能在好幾個地方被傳遞。handler 裡建了一個 response,middleware 可能要加 header,adapter 最後把它寫出去。如果 response 是 mutable 的,每一層都可能偷改上一層的資料,debug 起來會很頭痛
所以我們用 immutable 的 data class,需要修改時就 copy() 出一個新的。這在 Kotlin 裡成本很低,而且行為可預測
有需要做一個 Builder class 嗎 ? 在 RelixResponse 這個情境下,我們有三種選擇
傳統 Builder Pattern
class ResponseBuilder {
var statusCode: Int = 200
private val headers = mutableMapOf<String, MutableList<String>>()
private var body: ByteArray = ByteArray(0)
fun header(name: String, value: String) = apply {
headers.getOrPut(name) { mutableListOf() }.add(value)
}
fun body(text: String) = apply {
body = text.toByteArray(Charsets.UTF_8)
}
fun build(): RelixResponse = RelixResponse(statusCode, headers, body)
}
// 使用
val response = ResponseBuilder()
.header("Content-Type", "text/plain")
.body("Hello")
.build()
Kotlin scope function 搭配 data class
val response = RelixResponse(statusCode = 200)
.apply { /* 這裡不能改 val 欄位,只能讀 */ }
.header("Content-Type", "text/plain; charset=utf-8")
.let { it.copy(body = "Hello".toByteArray()) }
這個寫法有個限制需要注意,RelixResponse 的欄位都是 val,所以 apply { } 裡面其實改不了任何東西,真正在做事的是 let { it.copy(...) }。換句話說,immutable data class 搭配的是 let + copy,不是 apply,apply 適合的是欄位可變的 builder 物件
順帶一提 apply / let 這類 scope function 都標了 inline,compiler 會把 lambda 直接展開到呼叫處,不會額外產生一個 Function0 物件,所以 chain 起來沒有 runtime 成本,寫起來像物件導向的 builder,跑起來只是普通函式呼叫
Factory function (我們的選擇)
val response = ok("Hello").header("X-Request-Id", "abc")
Factory function 最簡潔。大多數情況下你只需要一個 ok() 或 json() 就搞定了,偶爾加個 header。不需要為了這麼簡單的操作搞一個 Builder class 出來,如果之後真的有很複雜的 response 建構需求,再考慮加 Builder 也不遲
RelixResponse 最終要被寫回到 HttpExchange,把這個邏輯抽成 extension function,加進第 04 篇放 toRelixRequest() 的 HttpExchangeExt.kt,讀進來和寫出去的轉換規則就都在同一個檔案裡
前面兩組測試驗的都是 RelixResponse 這個資料結構本身,writeResponse() 是另一回事,它把資料結構真的變成線路上的 bytes,中間隔著 JDK 的一整套行為
跟第 04 篇的 toRelixRequest() 一樣,HttpExchange 建不出來,所以這個測試只能開真的 server 測,做法是讓 handler 每次都寫出測試指定的那個 response,再從 client 端檢查收到什麼
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.assertTrue
class WriteResponseTest {
private lateinit var server: HttpServer
private var port = 0
private var toWrite: RelixResponse = ok("")
@BeforeTest
fun setUp() {
server = HttpServer.create(InetSocketAddress(0), 0)
server.createContext("/") { exchange ->
exchange.writeResponse(toWrite)
}
server.start()
port = server.address.port
}
@AfterTest
fun tearDown() {
server.stop(0)
}
private fun get(): HttpResponse<String> =
HttpClient.newHttpClient().send(
HttpRequest.newBuilder().uri(URI("http://localhost:$port/")).GET().build(),
HttpResponse.BodyHandlers.ofString(),
)
@Test
fun `writes status code body and headers`() {
toWrite = ok("Hello, Relix!")
val response = get()
assertEquals(200, response.statusCode())
assertEquals("Hello, Relix!", response.body())
assertEquals(
"text/plain; charset=utf-8",
response.headers().firstValue("Content-Type").orElse(""),
)
}
@Test
fun `writes every value of a repeated header`() {
toWrite = ok("x")
.header("X-Custom", "first")
.header("X-Custom", "second")
val response = get()
assertEquals(listOf("first", "second"), response.headers().allValues("X-Custom"))
}
@Test
fun `empty body sends no Content-Length`() {
toWrite = noContent()
val response = get()
assertEquals(204, response.statusCode())
assertEquals("", response.body())
assertTrue(response.headers().firstValue("Content-Length").isEmpty)
}
@Test
fun `body with content sets Content-Length to byte size`() {
toWrite = ok("你好")
val response = get()
// 「你好」是 6 個 UTF-8 byte,不是 2
assertEquals("6", response.headers().firstValue("Content-Length").orElse(""))
assertEquals("你好", response.body())
}
}
import com.sun.net.httpserver.HttpExchange
fun HttpExchange.writeResponse(response: RelixResponse) {
response.headers.forEach { (name, values) ->
values.forEach { value ->
this.responseHeaders.add(name, value)
}
}
if (response.body.isEmpty()) {
// 204 No Content 或空 body 的情況
this.sendResponseHeaders(response.statusCode, -1)
this.close()
} else {
this.sendResponseHeaders(response.statusCode, response.body.size.toLong())
this.responseBody.use { it.write(response.body) }
}
}
注意 sendResponseHeaders 的第二個參數,body 為空時傳 -1 (代表沒有 body),有內容時傳 body.size.toLong(),兩個分支都要關閉 exchange,有內容時由 responseBody.use { } 負責,空 body 則直接呼叫 close()
第二個測試在驗那個 values.forEach 的雙層迴圈,RelixResponse 的 headers 是 Map<String, List<String>>,如果實作時偷懶只寫 responseHeaders.add(name, values.first()),單元測試那邊完全看不出問題,只有真的送出去才會發現第二個值不見了
第三、第四個測試針對的是 sendResponseHeaders 那個容易傳錯的長度參數,走 -1 分支時,client 端收到的 response 連 Content-Length 都不會有,走正常分支時,Content-Length 是 byte 數而不是字元數,所以「你好」是 6 而不是 2,這也說明為什麼 RelixResponse.body 從一開始就設計成 ByteArray,長度算在字串上就會錯
開頭那個檔案分法的理由,在這段實作的第一行就看得到了。writeResponse() 要 import com.sun.net.httpserver.HttpExchange,RelixResponse.kt 裡則從頭到尾沒有這一行,這條 import 就是分界,第 06 篇拆 Application / Engine / Adapter 時,碰得到 HttpExchange 的東西會全部歸到 adapter 那一層
ByteArray 的 charset 問題
我們目前統一用 Charsets.UTF_8。如果你需要支援其他 charset,可以在 Content-Type header 裡指定 (例如 text/plain; charset=iso-8859-1),但 body 的 toByteArray() 也要對應改,這個系列不會碰到 UTF-8 以外的情況,所以先 hardcode,bodyAsText() 這邊也一樣
第 19 篇做 request body 時,RelixRequest 的 bodyAsText() 會從 Content-Type 解析 charset,因為 request 的編碼是 client 決定的,response 這邊不用,header 裡的 charset 本來就是工廠方法自己寫進去的
headers 的 key 要不要統一大小寫 ?
跟第 04 篇一樣的問題,HTTP 規格說 header name 是 case-insensitive,但我們的 Map key 比較是 case-sensitive,目前工廠方法都用 Content-Type 這種標準寫法,所以暫時不會出問題,但如果某個 middleware 用了 content-type (全小寫),它跟工廠方法的 Content-Type 就會變成兩個不同的 key,這個我們留到需要時再處理
到目前為止我們有了兩個核心型別,RelixRequest (讀進來) 和 RelixResponse (寫出去),它們都是 immutable data class,可以自由傳遞、安全比較
下一篇是系列的第一個「工程轉折點」,RelixCall 與可測核心,我們會定義 RelixHandler (RelixCall.() -> RelixResponse)、拆清楚 Application / Engine / Adapter 的責任,並建立 MVP TestKit 讓後面每篇都能在不開 port 的情況下做 TDD (測試)
同步刊登於 Blog
圖片來源:AI 產生