iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0
Software Development

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

Kotlin 手刻 Ktor 從零開始 Day 05 Response 的封裝,設計直覺的 RelixResponse

  • 分享至 

  • xImage
  •  

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

上一篇實作了 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())
    }
}

RelixResponse 用 data class 封裝

照著測試的需求寫出來是這樣

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 / hashCodeheadersMap<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」的版本。這篇先用字串版本把路打通

換 header(),一樣先寫測試

工廠方法給的是一個做好的 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() 的 copy + merge

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

為什麼 header() 要回傳新物件 ?

你可能會想,headers 用 MutableMap 不是更方便嗎 ? 直接 response.headers["X-Foo"] = listOf("bar") 就好了

問題是 response 可能在好幾個地方被傳遞。handler 裡建了一個 response,middleware 可能要加 header,adapter 最後把它寫出去。如果 response 是 mutable 的,每一層都可能偷改上一層的資料,debug 起來會很頭痛

所以我們用 immutable 的 data class,需要修改時就 copy() 出一個新的。這在 Kotlin 裡成本很低,而且行為可預測

Builder vs scope function vs factory 三種寫法比較

有需要做一個 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,不是 applyapply 適合的是欄位可變的 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 也不遲

用 writeResponse 寫回 HttpExchange

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-Lengthbyte 數而不是字元數,所以「你好」是 6 而不是 2,這也說明為什麼 RelixResponse.body 從一開始就設計成 ByteArray,長度算在字串上就會錯

開頭那個檔案分法的理由,在這段實作的第一行就看得到了。writeResponse()import com.sun.net.httpserver.HttpExchangeRelixResponse.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 時,RelixRequestbodyAsText() 會從 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 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 04 Request 的封裝,把 HttpExchange 變成好用的 RelixRequest
下一篇
Kotlin 手刻 Ktor 從零開始 Day 06 RelixCall 與可測核心,Application / Engine / Handler
系列文
Kotlin 手刻 Ktor 從零開始18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言