
前面四個部分我們一直在處理「路由與流程」,request 進來、走 pipeline、找到 handler、回 response
但 API 最常使用的 request body,我們一直還沒處理
POST /users 帶 JSONPOST /login 帶 form-urlencodedtext/plain
這篇要做三件事,讓 body 可讀取、可快取、可限制大小,再加一個 parseFormUrlEncoded() 處理表單資料
第 04 篇定義的 RelixRequest 已經有 body: ByteArray 欄位,JDK adapter 在建立 request 時把 InputStream 讀成 ByteArray 塞進去,所以 body 本身已經快取了 (ByteArray 可以重複讀取,不像 InputStream)
但使用者要的不只是 raw bytes
a=1&b=2
目前 RelixRequest 身上只有一個把 charset 寫死成 UTF-8 的 bodyAsText(),這三件事一件都還沒做到
在寫 bodyAsText() 之前,有個前置問題要先解決
body 是 ByteArray,要轉成字串就得知道 charset
Content-Type: application/json; charset=utf-8 → UTF-8Content-Type: text/plain → 沒指定 charset,預設 UTF-8這三條規則交給一個獨立的 parseCharset(),它是純函式,收一個字串、回一個 Charset,不用起 server 也不用 TestKit,測試直接呼叫就好,檔案放 ParseCharsetTest.kt
import java.nio.charset.Charset
import kotlin.test.Test
import kotlin.test.assertEquals
class ParseCharsetTest {
@Test
fun `no Content-Type defaults to UTF-8`() {
assertEquals(Charsets.UTF_8, parseCharset(null))
}
@Test
fun `parses charset parameter from Content-Type`() {
assertEquals(Charsets.UTF_8, parseCharset("application/json; charset=utf-8"))
}
@Test
fun `Content-Type without charset defaults to UTF-8`() {
assertEquals(Charsets.UTF_8, parseCharset("text/plain"))
}
@Test
fun `charset parameter ignores case and spacing`() {
assertEquals(Charset.forName("Big5"), parseCharset("text/plain; Charset=Big5 "))
}
@Test
fun `unknown charset falls back to UTF-8`() {
assertEquals(Charsets.UTF_8, parseCharset("text/plain; charset=no-such-charset"))
}
}
前三個測試就是上面那三條規則,另外兩個處理現實裡會遇到的髒資料,Charset=Big5 這種大小寫跟前後空白不一致的寫法要照樣解析得出來,charset 名稱根本不存在的時候也不能讓 Charset.forName() 把例外丟到 handler 去
實作照著測試補就行,parseCharset() 不碰 HttpExchange 也不碰 RelixRequest,跟第 04 篇的 parseQuery 是同一種東西,所以比照辦理,開一個新檔案 CharsetParser.kt 放它,不要塞進 RelixRequest.kt
import java.nio.charset.Charset
fun parseCharset(contentType: String?): Charset {
if (contentType == null) {
return Charsets.UTF_8
}
val charsetParam = contentType.split(";")
.map { it.trim() }
.firstOrNull { it.startsWith("charset=", ignoreCase = true) }
return if (charsetParam != null) {
val charsetName = charsetParam.substringAfter("=").trim()
try {
Charset.forName(charsetName)
} catch (_: Exception) {
Charsets.UTF_8
}
} else {
Charsets.UTF_8
}
}
把 Content-Type header 用 ; 切開,找到 charset= 那一段,用 Charset.forName() 轉換,找不到或格式不對就 fallback 到 UTF-8
歷史上的小坑,HTTP/1.1 的 RFC 2616 規定 text/* 系列在沒指定 charset 時預設是 ISO-8859-1 (Latin-1),純粹是因為當時西歐語系是主流,RFC 7231 (2014) 廢掉了這個預設值,改成「沒指定就由 server 自由決定」,現代 web 幾乎都該回 UTF-8,它向下相容 ASCII、能表示所有 Unicode 字元、bytecount 對拉丁字母也只多一點點,這裡直接 fallback 到 UTF-8 是比較務實的選擇,如果你真的要服務舊 client (例如某些 legacy POS 系統),才需要把 ISO-8859-1 改回 default 然後讓使用者明確 opt-in
application/x-www-form-urlencoded 是 HTML 表單的預設格式,格式很簡單,key=value 用 & 連接,特殊字元用 percent-encoding
name=Relix&version=1&tag=web&tag=framework
一樣是純函式,收字串回 Map,測試直接呼叫就好,檔案放 ParseFormUrlEncodedTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ParseFormUrlEncodedTest {
@Test
fun `parseFormUrlEncoded parses key-value pairs`() {
val result = parseFormUrlEncoded("name=Relix&version=1")
assertEquals(listOf("Relix"), result["name"])
assertEquals(listOf("1"), result["version"])
}
@Test
fun `parseFormUrlEncoded handles URL-encoded values`() {
val result = parseFormUrlEncoded("msg=hello+world&path=%2Fapi%2Fv1")
assertEquals(listOf("hello world"), result["msg"])
assertEquals(listOf("/api/v1"), result["path"])
}
@Test
fun `parseFormUrlEncoded keeps equals sign inside value`() {
val result = parseFormUrlEncoded("data=a=b")
assertEquals(listOf("a=b"), result["data"])
}
@Test
fun `parseFormUrlEncoded handles same key multiple values`() {
val result = parseFormUrlEncoded("tag=kotlin&tag=web&tag=framework")
assertEquals(listOf("kotlin", "web", "framework"), result["tag"])
}
@Test
fun `parseFormUrlEncoded handles empty string`() {
val result = parseFormUrlEncoded("")
assertTrue(result.isEmpty())
}
}
實作照著測試補,跟 parseCharset() 一樣自己一個檔案,放 FormUrlEncoded.kt
import java.net.URLDecoder
fun parseFormUrlEncoded(body: String): Map<String, List<String>> {
if (body.isBlank()) return emptyMap()
val result = mutableMapOf<String, MutableList<String>>()
body.split("&").forEach { pair ->
val parts = pair.split("=", limit = 2)
if (parts.size == 2) {
val key = URLDecoder.decode(parts[0], "UTF-8")
val value = URLDecoder.decode(parts[1], "UTF-8")
result.getOrPut(key) { mutableListOf() } += value
}
}
return result
}
幾個細節
split("=", limit = 2) 確保 value 裡如果有 = 不會被切斷 (例如 data=a=b,value 應該是 a=b),這就是 keeps equals sign inside value 那個測試在驗證的行為,拿掉 limit = 2 它就會失敗URLDecoder.decode() 處理 %2F → /、+ → 空格這類轉換Map<String, List<String>> 跟 query parameter 的型別一致,因為同一個 key 可以出現多次兩個純函式解決之後,剩下的測試要走 TestKit,放在 RequestBodyTest.kt,這個 class 只管一件事,body 讀出來對不對,涵蓋一般讀取跟重複讀取,中間再加一個 Big5 的案例,確認 parseCharset() 真的有串進 bodyAsText()
import java.nio.charset.Charset
import kotlin.test.Test
import kotlin.test.assertEquals
class RequestBodyTest {
@Test
fun `bodyAsText returns body as UTF-8 string`() {
val app = RelixApplication()
app.routing {
post("/echo") { ok(request.bodyAsText()) }
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest(
method = "POST",
path = "/echo",
body = "Hello, Relix!".toByteArray(),
)
assertEquals(200, response.statusCode)
assertEquals("Hello, Relix!", response.bodyAsText())
}
@Test
fun `bodyAsText can be called multiple times`() {
val app = RelixApplication()
app.routing {
post("/echo") {
val first = request.bodyAsText()
val second = request.bodyAsText()
ok("$first|$second")
}
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest(
method = "POST",
path = "/echo",
body = "test".toByteArray(),
)
assertEquals("test|test", response.bodyAsText())
}
@Test
fun `bodyAsText honors charset from Content-Type`() {
val app = RelixApplication()
app.routing {
post("/echo") { ok(request.bodyAsText()) }
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest(
method = "POST",
path = "/echo",
headers = mapOf("Content-Type" to listOf("text/plain; charset=big5")),
body = "手刻框架".toByteArray(Charset.forName("Big5")),
)
assertEquals("手刻框架", response.bodyAsText())
}
}
bodyAsText() 在第 04 篇新增 RelixRequest 的時候它就在了,只是當時只有一行
// 第 04 篇的版本,charset 寫死
fun bodyAsText(): String = body.toString(Charsets.UTF_8)
charset 直接 hardcode 成 UTF-8,第 05 篇講 response 編碼時也留過話,說 request 這邊的 charset 要等第 19 篇處理,這裡不是新增一個方法,是把那個寫死的 Charsets.UTF_8 換成 parseCharset() 的結果,改的是第 04 篇建立的 RelixRequest.kt
第 17 篇做 CORS 時已經加過 header() 這個不分大小寫的查詢入口,bodyAsText() 要拿的 Content-Type 同樣經過它,才不會被 JDK 正規化過的 key 卡住
data class RelixRequest(
val method: String,
val path: String,
val headers: Map<String, List<String>> = emptyMap(),
val queryParameters: Map<String, List<String>> = emptyMap(),
val body: ByteArray = ByteArray(0),
) {
fun header(name: String): String? = headers.entries
.firstOrNull { (key) -> key.equals(name, ignoreCase = true) }
?.value
?.firstOrNull()
fun bodyAsText(): String {
val contentType = header("Content-Type")
val charset = parseCharset(contentType)
return body.toString(charset)
}
}
改完之後,第 04 篇那兩個 bodyAsText() 的測試一個字都不用動,它們建的 RelixRequest 都沒有帶 Content-Type,走到 parseCharset(null) 回的還是 UTF-8,舊行為原封不動保留下來,這也是為什麼 parseCharset() 的第一個測試要測 null 這個 case
bodyAsText() 每次呼叫都會重新解析 charset,不過因為底層的 body 是 ByteArray (不是 InputStream),重複呼叫不會有問題,基本上字串轉換很快,不需要額外快取
你可能聽過「request body 只能讀一次」,這在 Servlet 的 InputStream 時代是真的,stream 讀完就沒了。但我們在第 04 篇的 JDK adapter 裡已經把 InputStream 轉成 ByteArray 了
// JdkHttpServerAdapter 裡的轉接(第 04 篇)
val body = exchange.requestBody.readAllBytes()
val request = RelixRequest(method, path, headers, queryParams, body)
readAllBytes() 把整個 stream 讀進記憶體,之後 body 是一個普通的 ByteArray,ByteArray 可以讀任意多次,所以 middleware 讀一次、handler 再讀一次完全沒問題
如果你用的是其他 HTTP server (例如 Netty),底層可能不會一次讀完,那就需要在框架層做快取,但 JDK HttpServer 的 readAllBytes() 已經幫我們做了
readAllBytes() 有個風險,如果 client 送了一個 2GB 的 body,server 會嘗試配置相同量級的記憶體,middleware 可以檢查政策,但此時配置已經發生
擋大 body 這件事分兩層做,路由層的政策交給 BodySizeLimit.kt 的 middleware,全域的上限交給 HttpExchangeExt.kt 的 readAtMost()
兩支程式在不同的檔案、不同的階段執行,但擋的是同一件事,不讓超大的 body 吃掉記憶體,所以測試不跟著程式碼拆,一起放在 BodySizeLimitTest.kt
import java.io.ByteArrayInputStream
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class BodySizeLimitTest {
@Test
fun `body size limit returns 413`() {
val app = RelixApplication()
// errorHandling 要裝在外層,才接得到 bodySizeLimit 丟出來的例外
app.use(errorHandlingMiddleware())
app.use(bodySizeLimitMiddleware(maxBytes = 10))
app.routing {
post("/upload") { ok(request.bodyAsText()) }
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest(
method = "POST",
path = "/upload",
body = "a".repeat(100).toByteArray(),
)
assertEquals(413, response.statusCode)
}
@Test
fun `body within limit passes through`() {
val app = RelixApplication()
app.use(bodySizeLimitMiddleware(maxBytes = 1024))
app.routing {
post("/upload") { ok(request.bodyAsText()) }
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest(
method = "POST",
path = "/upload",
body = "small body".toByteArray(),
)
assertEquals(200, response.statusCode)
assertEquals("small body", response.bodyAsText())
}
@Test
fun `readAtMost returns all bytes when within limit`() {
val stream = ByteArrayInputStream("small body".toByteArray())
assertEquals("small body", stream.readAtMost(1024).toString(Charsets.UTF_8))
}
@Test
fun `readAtMost throws before allocating beyond limit`() {
val stream = ByteArrayInputStream("a".repeat(100).toByteArray())
assertFailsWith<PayloadTooLargeException> { stream.readAtMost(10) }
}
}
前兩個測試走 TestKit,驗的是 middleware 有沒有把超過 maxBytes 的 request 擋下來、沒超過的有沒有正常放行
後兩個不用起 server,readAtMost() 收的是 InputStream,拿 ByteArrayInputStream 餵它就測得到,它們驗的也是另一件事,超過上限時要在配置超量記憶體之前就中止,不是整包讀進記憶體之後才發現太大
例外和 middleware 一起放在新檔案 BodySizeLimit.kt
class PayloadTooLargeException(maxBytes: Long) :
RelixHttpException(413, "Payload too large (limit: $maxBytes bytes)")
fun bodySizeLimitMiddleware(maxBytes: Long = 1_048_576): RelixMiddleware = { next ->
if (request.body.size > maxBytes) {
throw PayloadTooLargeException(maxBytes)
}
next()
}
預設上限 1MB (1,048,576 bytes)。超過就丟 PayloadTooLargeException,由第 16 篇的 ErrorHandlingMiddleware 轉成 413 response
安裝順序要注意,ErrorHandlingMiddleware 必須在 bodySizeLimitMiddleware 外層,bodySizeLimit 是在呼叫 next() 之前就丟例外的,如果它裝在外層,例外會直接往外穿出 pipeline,內層的 error handling 根本沒機會執行
為什麼用 middleware 而不是在 RelixRequest 裡檢查 ? 因為 limit 是「政策」,不是 request 的屬性,不同路由可能有不同的限制 (上傳檔案的 endpoint 可能需要更大的 limit),放在 middleware,哪條路由要裝、限多大,各自決定就好
不過要注意一個限制,我們的 JDK adapter 是用 readAllBytes() 一次讀完的,所以這個 middleware 檢查的是「已經讀進記憶體的大小」,不是「邊讀邊擋」,如果真正要防止記憶體爆掉,需要在 adapter 層就限制讀取量,改的是第 04 篇放 toRelixRequest() 的 HttpExchangeExt.kt
// 真正的讀取上限要放在 adapter 層
fun InputStream.readAtMost(maxBytes: Int): ByteArray {
require(maxBytes < Int.MAX_VALUE)
val bytes = readNBytes(maxBytes + 1)
if (bytes.size > maxBytes) {
throw PayloadTooLargeException(maxBytes.toLong())
}
return bytes
}
關鍵是那個 maxBytes + 1,多讀一個 byte 就足以判斷「有沒有超過」,而且不管 client 送多大都只會配置 maxBytes + 1 的記憶體。require(maxBytes < Int.MAX_VALUE) 則是擋這個加一溢位,傳進 Int.MAX_VALUE 的話它會變成負數,readNBytes() 直接丟例外
然後才是呼叫端,第 04 篇的 toRelixRequest() 原本是 this.requestBody.readAllBytes() 一次讀完,現在換成 readAtMost()
fun HttpExchange.toRelixRequest(maxBodyBytes: Int = 1_048_576): RelixRequest {
return RelixRequest(
// ...其餘欄位同第 04 篇
body = this.requestBody.use { it.readAtMost(maxBodyBytes) },
)
}
toRelixRequest() 目前有三個呼叫端,第 04 篇的測試、第 06 篇的 JdkHttpServerAdapter、第 14 篇改成 suspend 之後的版本,全部都是 exchange.toRelixRequest() 原本的無參數呼叫,直接加一個必填參數會讓三個地方一起編譯不過,有了預設值,這一篇就只要動 HttpExchangeExt.kt 一個檔案
預設值挑 1MB,跟 bodySizeLimitMiddleware 的 maxBytes 一致,兩層擋的門檻預設一樣,差別只在擋的時機,真的要調的時候,第 25 篇做 RelixConfig 會把這個參數接到 bodyLimitBytes,變成整個框架的全域設定
全域硬上限要由 adapter 負責,才能在配置超量記憶體前中止,不過 middleware 仍有價值,適合做更小的路由層政策檢查,兩者處理的是不同階段
有了 parseFormUrlEncoded(),可以在 RelixCall 加一個便利方法讓 handler 更好用,改的是第 06 篇建立的 RelixCall.kt
parseFormUrlEncoded() 自己的解析行為前面測過了,這裡要測的是另一件事,接線有沒有接對,body 讀出來、丟給 parser、結果送到 handler 手上這條路,所以測試走 TestKit,檔案放 FormParamsTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
class FormParamsTest {
@Test
fun `formParam reads a value from the body`() {
val app = RelixApplication()
app.routing {
post("/login") { ok("Welcome, ${formParam("username")}") }
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest(
method = "POST",
path = "/login",
body = "username=cash&password=secret".toByteArray(),
)
assertEquals(200, response.statusCode)
assertEquals("Welcome, cash", response.bodyAsText())
}
@Test
fun `formParams keeps every value of a repeated key`() {
val app = RelixApplication()
app.routing {
post("/tags") { ok(formParams()["tag"].orEmpty().joinToString(",")) }
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest(
method = "POST",
path = "/tags",
body = "tag=kotlin&tag=web".toByteArray(),
)
assertEquals("kotlin,web", response.bodyAsText())
}
@Test
fun `formParam returns 400 when the parameter is missing`() {
val app = RelixApplication()
app.use(errorHandlingMiddleware())
app.routing {
post("/login") { ok(formParam("username")) }
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest(
method = "POST",
path = "/login",
body = "password=secret".toByteArray(),
)
assertEquals(400, response.statusCode)
}
}
第三個測試主要是例外的驗證,formParam() 丟例外、pipeline 往外傳、error handling 接住、轉成 response 的 status code,任何一環出問題都會立刻被抓到
class RelixCall(
val application: RelixApplication,
val request: RelixRequest,
internal var pathParams: Map<String, String> = emptyMap(),
internal var matchedRoute: Route? = null,
) {
fun formParams(): Map<String, List<String>> {
return parseFormUrlEncoded(request.bodyAsText())
}
fun formParam(name: String): String {
return formParams()[name]?.firstOrNull()
?: throw BadRequestException("Missing form parameter: $name")
}
}
formParam() 找不到參數就丟 BadRequestException,跟第 09 篇的 pathParam() 一樣的模式,這種設計讓 handler 的 happy path 很乾淨,錯誤處理交給 middleware
這篇支援三種 Content-Type
| Content-Type | 處理方式 |
|---|---|
text/plain |
bodyAsText() 直接讀 |
application/json |
bodyAsText() 拿到 raw JSON 字串 (解析在第 20 篇) |
application/x-www-form-urlencoded |
formParams() / formParam() |
multipart/form-data |
在這個系列不會支援 |
為什麼 JSON 先不解析 ? 因為「讀取」和「解析」是兩個不同的責任,這篇處理讀取,第 20 篇處理 JSON 序列化/反序列化,第 21 篇的 receive<T>() 把兩者串起來,分層做讓每一步都可以獨立測試
測試都在 TestKit 裡跑,現在裝進 main 用真的 server 打一次
大小上限故意設成 32 bytes,才容易用 curl 觸發,正常的預設值是 1MB
fun main() {
val app = RelixApplication()
app.install(Logging) {
logger = ConsoleLogger()
}
app.install(ErrorHandling)
app.use(bodySizeLimitMiddleware(maxBytes = 32))
app.routing {
post("/echo") { ok(request.bodyAsText()) }
post("/login") { ok("Welcome, ${formParam("username")}") }
}
JdkHttpServerAdapter(app).start(8080)
}
Logging 和 Error Handling 用第 18 篇的 install() 裝,body size limit 還是 use() middleware,兩種寫法混用沒問題,因為 install() 做的事就是幫你呼叫 use(),ErrorHandling 的 install() 裡面就是 application.use(errorHandlingMiddleware(...)),最後都進同一個 queue
順序規則因此沒有變,寫的順序就是包裹的順序,ErrorHandling 在 bodySizeLimitMiddleware 前面就會在它外層,正是前面說的那個安裝順序,至於 body size limit 為什麼不做成 plugin,後面「常見陷阱與設計取捨」會講
前面幾組測試裡直接寫 app.use(errorHandlingMiddleware()) 也不是漏改,plugin 那層是給使用者組裝用的,測試要的是最短路徑
讀 body
curl -i -X POST -H "Content-Type: text/plain" -d "Hello, Relix!" localhost:8080/echo
HTTP/1.1 200 OK
Content-type: text/plain; charset=utf-8
Content-length: 13
Hello, Relix!
表單登入
curl -i -X POST -d "username=cash&password=secret" localhost:8080/login
HTTP/1.1 200 OK
Content-type: text/plain; charset=utf-8
Content-length: 13
Welcome, cash
curl -d 不指定 Content-Type 的話預設就是 application/x-www-form-urlencoded,剛好是 formParam() 要的格式,不用自己帶 header
少給一個參數
curl -i -X POST -d "password=secret" localhost:8080/login
HTTP/1.1 400 Bad Request
Content-type: text/plain; charset=utf-8
Content-length: 32
Missing form parameter: username
handler 裡沒有任何 try-catch,formParam() 丟出的 BadRequestException 一路穿到第 16 篇的 error handling 才被接住,轉成 400
超過大小上限
curl -i -X POST -d "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" localhost:8080/echo
HTTP/1.1 413 Request Entity Too Large
Content-type: text/plain; charset=utf-8
Content-length: 35
Payload too large (limit: 32 bytes)
40 bytes 超過設定的 32,bodySizeLimitMiddleware 在 next() 之前就丟例外,handler 根本沒被呼叫到
這個限制是連表單一起算的,上面登入那筆的 body 是 username=cash&password=secret,29 bytes,只要上限設成 20 就會連正常登入都被擋掉,而且回的是 413,不是任何跟表單有關的錯誤,光看訊息完全猜不到是自己把門檻設太低。真的在調這個值的時候,要抓的是「最大的合法 request」而不是「想擋掉的攻擊」
reason phrase 印出來是 Request Entity Too Large,不是 Content Too Large,413 在 RFC 9110 已經改名成後者,MDN 查到的也是新名字,但 JDK HttpServer 內建的對照表還停在 RFC 2616
這個狀態碼一路改過三次名,Request Entity Too Large (RFC 2616)、Payload Too Large (RFC 7231)、Content Too Large (RFC 9110)。上面那段輸出裡就同時出現兩代,狀態列是第一代,我們自己在 PayloadTooLargeException 寫的訊息是第二代。數字本身沒有錯,client 判斷的也是數字,reason phrase 只是給人看的
server 那邊的 terminal 記錄
[Relix] POST /echo -> 200 (2ms)
[Relix] POST /login -> 200 (0ms)
[Relix] POST /login -> 400 (1ms)
[Relix] POST /echo -> 413 (0ms)
後面兩筆一個 400 一個 413,logging 一樣記得到,因為它裝在最外層
bodyAsText() 要不要快取結果 ?
目前沒有,每次呼叫 bodyAsText() 都會重新做 body.toString(charset) 轉換,對大多數 API 來說不是問題,body 通常幾 KB,字串轉換基本上是微秒等級,如果你真的在乎,可以用 lazy 快取
// 快取版
data class RelixRequest(...) {
private val textCache: String by lazy {
val charset = parseCharset(headers["Content-Type"]?.firstOrNull())
body.toString(charset)
}
fun bodyAsText(): String = textCache
}
但 lazy 加上 data class 會有陷阱,lazy 屬性不參與 equals()/hashCode()/copy()
為什麼 body size limit 用 middleware 而不是 Plugin ?
可以做成 Plugin,但 body size limit 本質就是一個簡單的前置檢查,用 middleware 反而更直覺,不是所有東西都要包成 Plugin,Plugin 適合有設定、有生命週期的功能,一個檢查數字大小的邏輯用工廠函式就夠了
formParam() 找不到參數為什麼丟 exception 而不是回傳 null ?
兩種 API 都有道理,formParam() 丟 exception 適合「這個參數一定要有」的場景 (跟 pathParam() 一樣),如果你想要 nullable 版本,可以加一個 formParamOrNull()
fun formParamOrNull(name: String): String? {
return formParams()[name]?.firstOrNull()
}
第 21 篇的 queryParam<T>() / queryParamOrNull<T>() 也是同樣的二元設計
parseCharset() 和 parseFormUrlEncoded() 這兩個 parse 的函式先解決掉,不依賴框架,測試直接呼叫就好,bodyAsText() 再把 parseCharset() 接上去,charset 從 Content-Type 來、沒指定就是 UTF-8,第 04 篇寫死 UTF-8 的舊行為完整保留,因為 JDK adapter 已經用 readAllBytes() 把 body 存成 ByteArray,重複讀取不是問題
擋大 body 分成兩層,bodySizeLimitMiddleware 做路由層的政策、丟 413 讓 Error Handling 處理,adapter 層的 readAtMost() 才是能在配置超量記憶體之前就中止的那一道,到這裡,request 的讀取基礎建設完成了
下一篇開始處理 JSON,接上 kotlinx.serialization,抽出 converter 介面做 Content Negotiation,讓 handler 寫 ok(user) 就自動序列化
同步刊登於 Blog
圖片來源:AI 產生