iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
Software Development

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

Kotlin 手刻 Ktor 從零開始 Day 03 Hello, Relix,用 JDK HttpServer 跑起第一個伺服器

  • 分享至 

  • xImage
  •  

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

這篇的重點是讓框架「能跑」,我們會用 JDK 內建的 HttpServer 啟動一個最小 HTTP Server,用整合測試驗證它能回應 "Hello, Relix!",然後把這些邏輯包進一個叫 RelixApplication 的類別裡

先快速複習 HTTP Request / Response

你不需要把 HTTP 規格背起來,但框架設計會反覆用到下面這個心智模型

Client                          Server
  │                               │
  │  ── Request ──────────────►   │
  │     Method: GET               │
  │     URI: /hello?name=Relix    │
  │     Headers: Accept: text/... │
  │     Body: (empty for GET)     │
  │                               │
  │  ◄── Response ────────────    │
  │     Status: 200 OK            │
  │     Headers: Content-Type:... │
  │     Body: Hello, Relix!       │
  │                               │

Request 由四個東西組成,Method、URI (path + query)、Headers、Body,Response 也是四個,Status code、Headers、Body,再加上一個 reason phrase (不過現在沒什麼人在意它了)

常見的 Content-Type 你會在系列中反覆遇到,text/plainapplication/jsonapplication/x-www-form-urlencoded。我們接下來的封裝 (RelixRequest / RelixResponse) 就是把這些概念整理成好用、可測、可演進的型別

用 HttpServer 啟動最簡單的 HTTP Server

JDK 的 HttpServer API 很小,基本流程只有三步,建 server、註冊 handler、呼叫 start()

import com.sun.net.httpserver.HttpServer
import java.net.InetSocketAddress

fun main() {
    val server = HttpServer.create(InetSocketAddress(8080), 0)

    server.createContext("/hello") { exchange ->
        val body = "Hello, Relix!"
        val bytes = body.toByteArray(Charsets.UTF_8)
        exchange.responseHeaders.add("Content-Type", "text/plain; charset=utf-8")
        exchange.sendResponseHeaders(200, bytes.size.toLong())
        exchange.responseBody.use { it.write(bytes) }
    }

    server.start()
    println("Server started on port 8080")
}

這裡先說明一下,createContext("/hello") { exchange -> ... } 大括號裡那一段就是 handler,後面文章提到 handler 都是指這種「收到請求之後要跑的那段程式」

JDK 的簽章是 createContext(path: String, handler: HttpHandler)HttpHandler 是只有一個方法 handle(exchange: HttpExchange) 的介面,因為只有一個方法,Kotlin 的 SAM conversion 會自動把 lambda 轉成 HttpHandler,所以你不用寫成下面這種囉唆的版本

server.createContext("/hello", object : HttpHandler {
    override fun handle(exchange: HttpExchange) {
        // 跟上面 lambda 裡一模一樣的內容
    }
})

至於 exchange 這個參數,它同時是 request 的入口 (requestMethodrequestURIrequestHeadersrequestBody) 和 response 的出口 (responseHeaderssendResponseHeaders()responseBody),一個物件包了兩件事

這段程式碼可以直接貼到 main.kt 裡試試,但它有幾個問題

HttpExchange 很「低階」,你必須自己處理 byte 轉換、自己加 header、自己管 response body 的 output stream,寫一兩個 handler 還好,寫到十幾個就會想翻桌,測試也麻煩,要驗證這段行為,你得真的開 port、送 HTTP request、再關掉 server,這樣一輪跑下來不只慢,還很容易變成 flaky test (同一份程式碼,這次過下次可能會掛)

所以我們會把這段包起來,逐步變成 Relix 的核心

TDD 先寫整合測試

這裡先用「真的啟動 server」的方式做最小整合測試,第 06 篇我們會做 MVP TestKit 來降低測試成本,但現在先求能驗證行為

完整的測試類別長這樣

import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.AfterTest
import kotlin.test.BeforeTest

class HelloRelixTest {

    private lateinit var app: RelixApplication

    @BeforeTest
    fun setUp() {
        app = RelixApplication()
        app.start(port = 0) // port 0 = 讓 OS 分配隨機 port
    }

    @AfterTest
    fun tearDown() {
        app.stop()
    }

    @Test
    fun `GET hello returns 200 with greeting`() {
        val client = HttpClient.newHttpClient()
        val request = HttpRequest.newBuilder()
            .uri(URI("http://localhost:${app.port}/hello"))
            .GET()
            .build()

        val response = client.send(request, HttpResponse.BodyHandlers.ofString())

        assertEquals(200, response.statusCode())
        assertEquals("Hello, Relix!", response.body())
    }

    @Test
    fun `response has correct Content-Type header`() {
        val client = HttpClient.newHttpClient()
        val request = HttpRequest.newBuilder()
            .uri(URI("http://localhost:${app.port}/hello"))
            .GET()
            .build()

        val response = client.send(request, HttpResponse.BodyHandlers.ofString())

        val contentType = response.headers().firstValue("Content-Type").orElse("")
        assertEquals("text/plain; charset=utf-8", contentType)
    }

    @Test
    fun `unregistered path returns 404`() {
        val client = HttpClient.newHttpClient()
        val request = HttpRequest.newBuilder()
            .uri(URI("http://localhost:${app.port}/nonexistent"))
            .GET()
            .build()

        val response = client.send(request, HttpResponse.BodyHandlers.ofString())

        assertEquals(404, response.statusCode())
    }
}

幾個重點

port = 0 讓作業系統自動分配一個可用的 port,這樣測試之間不會撞到。@BeforeTest 在每個測試方法之前啟動 server,@AfterTest 在之後關掉,確保每個測試跑在乾淨的環境。第三個測試驗證沒有註冊的 path 會回 404,這是 JDK HttpServer 的預設行為

這種「每個測試都開關一次 server」的方式其實偏慢,等第 06 篇做了 TestKit 之後,就會改善了

建立 RelixApplication

測試寫好了,現在來實作讓它通過,RelixApplication 目前只需要三件事,啟動、關閉、告訴外面 port 是多少

import com.sun.net.httpserver.HttpServer
import java.net.InetSocketAddress

class RelixApplication {

    private lateinit var server: HttpServer
    var port: Int = 0
        private set

    fun start(port: Int = 8080) {
        server = HttpServer.create(InetSocketAddress(port), 0)

        server.createContext("/hello") { exchange ->
            val body = "Hello, Relix!"
            val bytes = body.toByteArray(Charsets.UTF_8)
            exchange.responseHeaders.add("Content-Type", "text/plain; charset=utf-8")
            exchange.sendResponseHeaders(200, bytes.size.toLong())
            exchange.responseBody.use { it.write(bytes) }
        }

        server.start()

        // 如果傳入 port = 0,OS 會分配一個隨機 port
        // 這裡拿到實際分配的 port,測試才知道要連哪裡
        this.port = server.address.port
    }

    fun stop() {
        server.stop(0) // 0 = 不等待,直接關
    }
}

server.address.port 這個 API 是關鍵,當你傳入 port = 0 時,OS 會隨機挑一個可用的 port 綁定,然後你可以透過 address.port 拿到實際的值,這在測試中非常好用,因為你不用擔心 port 被佔走

stop(0) 的參數是「等待多少秒讓進行中的請求完成」,目前測試場景不需要等,所以傳 0,第 25 篇做 graceful shutdown 時我們會回來處理這個

另外先講一個後面會一直出現的詞,createContext 裡那個 lambda,就是 JDK 跟我們的框架交界的地方,exchange 從那裡進來,回應也從那裡出去,這一層在框架設計裡通常叫 adapter,職責是把底層 server 給的型別翻譯成框架自己的型別,現在它還跟 RelixApplication 混在一起,而且直接操作 exchange,第 04 篇開始就會把這一層拆出來

Port 已佔用的處理

如果你指定了一個固定 port (例如 8080),而那個 port 正好被其他程式佔用,HttpServer.create() 會丟 java.net.BindException,在開發環境下這是很常見的問題,這個行為也值得用測試確認,畢竟錯誤訊息也是 API 的一部分

簡單的處理方式

fun start(port: Int = 8080) {
    try {
        server = HttpServer.create(InetSocketAddress(port), 0)
    } catch (e: java.net.BindException) {
        throw IllegalStateException(
            "Port $port is already in use. Try a different port or use port 0 for auto-assign.",
            e
        )
    }
    // ...
}

BindException 包成一個訊息比較清楚的 IllegalStateException,開發者看到錯誤訊息就知道怎麼解決。這不是什麼高深的技巧,但「好的錯誤訊息」是框架品質的一部分

測試長這樣

@Test
fun `occupied port throws IllegalStateException`() {
    // setUp 裡的 app 已經佔住 app.port
    val another = RelixApplication()

    val error = assertFailsWith<IllegalStateException> {
        another.start(port = app.port)
    }

    assertTrue(error.message!!.contains("already in use"))
    assertIs<java.net.BindException>(error.cause)
}

這裡沒有寫死 8080,而是跟 setUp 借已經啟動的 app.port。這個 port 一定被佔著,測試也就不會受開發機上跑了什麼服務影響,another 因為根本沒啟動成功,不需要在 tearDown 裡關掉它

為什麼先選 JDK 內建,而不是 Netty ?

原因很簡單,這裡的重點主要是學習框架設計,而不是先把一套高效能 I/O 堆疊搞定,JDK HttpServer 的 API 很小,比較容易把注意力放在「我們的抽象」上面

等 Relix 的抽象邊界清楚後 (Application / Engine / Router 各有各的介面),替換成 Netty 或 Undertow 才有意義,如果一開始就用 Netty,我們還要先搞懂它的 EventLoopGroupChannelPipelineByteBuf 相關的概念,根本沒心力去想「要怎麼設計」的問題

這也是很多工程專案的常見策略,先用簡單可控的底層把上層 API 定型,再談最佳化,第 31 篇我們會回來分析 JDK HttpServer 的效能瓶頸,並討論 Netty 替換的可行性

JDK HttpServer 的能力也很精簡,執行模型取決於你設定的 Executor,HTTP/2 與流量控制等能力則不在這個教學 adapter 的範圍內,這不等於它一律不能上正式環境,但你必須根據流量、延遲、TLS 與維運需求實測後再決定,第 31 篇會說明怎麼做可重現的量測,以及何時值得替換底層 engine

常見陷阱與設計取捨

HttpExchange 的 response body 一定要 close

如果你忘了關 exchange.responseBody,client 端可能會一直 hang 在那裡等資料,用 .use { } 是最安全的做法,少了這一行,你的測試會 timeout 但不會報明確的錯誤,debug 起來很痛苦

sendResponseHeaders 的第二個參數

sendResponseHeaders(statusCode, responseLength) 的第二個參數是 response body 的長度,如果傳 0,代表不知道長度 (chunked transfer)。如果傳 -1,代表沒有 body,傳錯了不會立即出現錯誤,但 client 端的行為會不一致,最安全的做法是先把 body 轉成 ByteArray,再傳 bytes.size.toLong(),傳入 body 真實的大小

handler 裡拋例外會怎樣 ?

如果 handler 拋出未捕捉的例外,exchange 不一定會得到格式穩定的錯誤回應,client 端也可能只看到連線中斷,因此 adapter 必須先接住例外,第 16 篇再用 ErrorHandling middleware 統一處理框架內的錯誤


小結

這篇完成了三件事,用 JDK HttpServer 跑起第一個 server、用整合測試驗證行為、把邏輯包進 RelixApplication

目前 handler 還是直接寫死在 RelixApplication 裡面,路由也只有 /hello 一條,接下來幾篇我們會把 Request / Response 封裝成自己的型別,再把 handler 抽出來讓使用者可以自由註冊


下一篇

下一篇我們會把 HttpExchange 包裝成更好用的 RelixRequest,method、path、headers、query parameters 都要能乾淨地取得,並用測試把解析行為定義清楚


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin 手刻 Ktor 從零開始 Day 02 專案建置與開發環境,用 Kotlin Toolchain CLI 建立專案
下一篇
Kotlin 手刻 Ktor 從零開始 Day 04 Request 的封裝,把 HttpExchange 變成好用的 RelixRequest
系列文
Kotlin 手刻 Ktor 從零開始18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言