
框架開始有各種功能 (middleware、content negotiation、body limit、thread pool) 之後,你需要一個一致而且方便的方式來調整它們,這篇做 Relix 相關的設定系統,把設定和順序定義清楚
設定的優先順序 (後者覆蓋前者)
application.properties)RELIX_PORT=9090)程式碼 port=8080 → 設定檔 port=9000 → 環境變數 RELIX_PORT=9090
最終生效:9090
為什麼環境變數優先級最高 ? 因為容器化部署時,改環境變數最方便,你不會想為了改 port 重新打包程式。簡單來說,就是越外層的優先度越高
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.assertFailsWith
class RelixConfigTest {
@Test
fun `default values`() {
val config = RelixConfig()
assertEquals(8080, config.port)
assertEquals("0.0.0.0", config.host)
assertEquals(false, config.development)
assertEquals(1_048_576, config.bodyLimitBytes) // 1 MB
assertEquals(30_000L, config.requestTimeoutMs)
assertEquals(LogLevel.INFO, config.logLevel)
}
@Test
fun `dsl overrides defaults`() {
val config = RelixConfig().apply {
port = 3000
host = "127.0.0.1"
development = true
}
assertEquals(3000, config.port)
assertEquals("127.0.0.1", config.host)
assertTrue(config.development)
}
@Test
fun `env overrides dsl`() {
val env = mapOf("RELIX_PORT" to "9090", "RELIX_HOST" to "localhost")
val config = RelixConfig().apply {
port = 8080
host = "0.0.0.0"
}
config.applyEnv(env)
assertEquals(9090, config.port)
assertEquals("localhost", config.host)
}
@Test
fun `invalid env value throws`() {
val env = mapOf("RELIX_PORT" to "not-a-number")
val config = RelixConfig()
assertFailsWith<IllegalArgumentException> {
config.applyEnv(env)
}
}
@Test
fun `properties file overrides dsl`() {
val props = mapOf("port" to "9000", "development" to "true")
val config = RelixConfig().apply {
port = 8080
}
config.applyProperties(props)
assertEquals(9000, config.port)
assertTrue(config.development)
}
@Test
fun `logLevel parses case-insensitively`() {
val config = RelixConfig()
config.applyProperties(mapOf("logLevel" to "warn"))
assertEquals(LogLevel.WARN, config.logLevel)
}
@Test
fun `invalid logLevel throws`() {
val config = RelixConfig()
assertFailsWith<IllegalArgumentException> {
config.applyEnv(mapOf("RELIX_LOG_LEVEL" to "VERBOSE"))
}
}
@Test
fun `full chain dsl then properties then env`() {
val config = RelixConfig().apply {
port = 8080
host = "0.0.0.0"
}
config.applyProperties(mapOf("port" to "9000"))
config.applyEnv(mapOf("RELIX_PORT" to "9090"))
assertEquals(9090, config.port)
assertEquals("0.0.0.0", config.host)
}
}
第四個測試很重要,環境變數的值不是數字時,不能默默 fallback 到預設值,那會讓部署問題很難 debug,你以為設了 RELIX_PORT,結果根本沒生效
logLevel 那兩個測試也是同一件事的延伸,一個確認大小寫不敏感 (設定檔寫 warn 還是 WARN 都要吃得下),一個確認打錯字會直接有問題,而不是默默用回預設的 INFO
class RelixConfig {
var port: Int = 8080
var host: String = "0.0.0.0"
var development: Boolean = false
var bodyLimitBytes: Int = 1_048_576 // 1 MB
var requestTimeoutMs: Long = 30_000 // 30 秒
var logLevel: LogLevel = LogLevel.INFO
fun applyProperties(props: Map<String, String>) {
props["port"]?.let {
port = it.toIntOrNull()
?: throw IllegalArgumentException("Invalid port in properties: '$it'")
}
props["host"]?.let { host = it }
props["development"]?.let {
development = it.toBooleanStrictOrNull()
?: throw IllegalArgumentException("Invalid development in properties: '$it'")
}
props["bodyLimitBytes"]?.let {
bodyLimitBytes = it.toIntOrNull()
?: throw IllegalArgumentException("Invalid bodyLimitBytes in properties: '$it'")
}
props["requestTimeoutMs"]?.let {
requestTimeoutMs = it.toLongOrNull()
?: throw IllegalArgumentException("Invalid requestTimeoutMs in properties: '$it'")
}
props["logLevel"]?.let { raw ->
logLevel = LogLevel.entries.find { it.name == raw.uppercase() }
?: throw IllegalArgumentException("Invalid logLevel in properties: '$raw'")
}
}
fun applyEnv(env: Map<String, String> = System.getenv()) {
env["RELIX_PORT"]?.let {
port = it.toIntOrNull()
?: throw IllegalArgumentException("Invalid RELIX_PORT: '$it'")
}
env["RELIX_HOST"]?.let { host = it }
env["RELIX_DEVELOPMENT"]?.let {
development = it.toBooleanStrictOrNull()
?: throw IllegalArgumentException("Invalid RELIX_DEVELOPMENT: '$it'")
}
env["RELIX_BODY_LIMIT"]?.let {
bodyLimitBytes = it.toIntOrNull()
?: throw IllegalArgumentException("Invalid RELIX_BODY_LIMIT: '$it'")
}
env["RELIX_REQUEST_TIMEOUT"]?.let {
requestTimeoutMs = it.toLongOrNull()
?: throw IllegalArgumentException("Invalid RELIX_REQUEST_TIMEOUT: '$it'")
}
env["RELIX_LOG_LEVEL"]?.let { raw ->
logLevel = LogLevel.entries.find { it.name == raw.uppercase() }
?: throw IllegalArgumentException("Invalid RELIX_LOG_LEVEL: '$raw'")
}
}
}
RelixConfig 是一個 mutable class (不是 data class),建構期可以修改,設定完成後交給 RelixApplication 持有
applyProperties() 和 applyEnv() 分開呼叫,順序由 caller 決定,每個欄位用 let 做 null check,環境變數沒設就不覆蓋
轉型失敗丟 IllegalArgumentException 加清楚的訊息。"Invalid RELIX_PORT: 'not-a-number'" 比 NumberFormatException 對 ops 人員更友善
logLevel 是唯一的 enum 欄位,處理方式跟前面幾個不太一樣,Int 和 Boolean 有 toIntOrNull、toBooleanStrictOrNull 可以用,enum 沒有現成的 toXxxOrNull,只好用 LogLevel.entries.find 自己查,entries 是 Kotlin 1.9 之後取代 values() 的寫法,回傳的 EnumEntries 本身就是 List 的子型別,所以 find 這些 collection 的 extension 直接能用,而且不會每次呼叫都複製一份陣列,這裡刻意不用 LogLevel.valueOf(),因為它找不到時丟的是自己的 IllegalArgumentException,訊息裡沒有「這是哪個設定欄位」的資訊,跟其他欄位的錯誤訊息風格對不上
loadProperties() 收一個路徑、回一個 map,不用起 server 也不用 TestKit,但需要真的有檔案,所以測試自己開一個暫存目錄,測試檔案放 LoadPropertiesTest.kt
import java.nio.file.Files
import java.nio.file.Path
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class LoadPropertiesTest {
private val tempDir: Path = Files.createTempDirectory("relix-config")
private fun write(name: String, content: String): String =
tempDir.resolve(name).toFile().apply { writeText(content) }.absolutePath
@Test
fun `reads key value pairs`() {
val path = write("app.properties", "port=9000\nlogLevel=WARN\n")
val props = loadProperties(path)
assertEquals("9000", props["port"])
assertEquals("WARN", props["logLevel"])
}
@Test
fun `a missing file gives an empty map`() {
val path = tempDir.resolve("nope.properties").toString()
assertTrue(loadProperties(path).isEmpty())
}
@Test
fun `a utf-8 value is not mangled`() {
val path = write("utf8.properties", "host=主機\n")
assertEquals("主機", loadProperties(path)["host"])
}
}
第二個測試對應的是「設定檔是選用的」這個決定,檔案不在不該算錯誤,第三個測試驗證的是編碼,writeText() 預設用 UTF-8 寫檔,讀的那一端如果換成 load(InputStream),主機 會變成亂碼,這個測試就會失敗
實作是一個 top-level function,接在 RelixConfig.kt 的 class 後面,它只有設定這條路徑會用到,不像第 19 篇的 parseCharset() 還要給 RelixRequest 用,所以不用自己開一個檔案
import java.io.File
import java.nio.charset.StandardCharsets.UTF_8
import java.nio.file.Files
import java.util.Properties
fun loadProperties(path: String): Map<String, String> {
val file = File(path)
if (!file.exists()) return emptyMap()
val props = Properties()
Files.newBufferedReader(file.toPath(), UTF_8).use { props.load(it) }
return props.entries.associate { (k, v) -> k.toString() to v.toString() }
}
用 Java 的 Properties 讀 .properties 格式,這裡明確用 UTF-8 Reader,若直接呼叫 load(InputStream),會套用 ISO-8859-1 規則,中文容易出現非預期結果,檔案不存在回空 map,因為設定檔是選用的
# application.properties
port=9000
development=true
bodyLimitBytes=2097152
logLevel=WARN
Relix { } 是整個框架的進入點,依序是 DSL 設定 → 讀設定檔 → 套用環境變數,每一步都只覆蓋有設定的欄位,沒設的保持前一步的值
要驗的是整合,不是覆蓋邏輯,RelixConfigTest 的 full chain 驗的是「照這個順序手動呼叫會得到什麼」,這裡驗的是「Relix { } 有沒有照那個順序去呼叫」,測試檔案放 RelixDslTest.kt,下面這段跟第 18 篇一樣,寫的當下是編不過的
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class RelixDslTest {
private val propertiesPath: String = Files.createTempDirectory("relix-dsl")
.resolve("application.properties")
.toFile()
.apply { writeText("port=9000\ndevelopment=true\n") }
.absolutePath
private val missingPath = "no-such-file.properties"
@Test
fun `dsl alone decides everything`() {
val app = Relix(propertiesPath = missingPath, env = emptyMap()) {
port = 8080
}
assertEquals(8080, app.config.port)
assertFalse(app.config.development)
}
@Test
fun `the properties file overrides the dsl`() {
val app = Relix(propertiesPath = propertiesPath, env = emptyMap()) {
port = 8080
}
assertEquals(9000, app.config.port)
assertTrue(app.config.development)
}
@Test
fun `env overrides the properties file`() {
val app = Relix(
propertiesPath = propertiesPath,
env = mapOf("RELIX_PORT" to "9090"),
) {
port = 8080
}
assertEquals(9090, app.config.port)
assertTrue(app.config.development)
}
}
第一個測試特意指一個不存在的檔案,確認沒有設定檔的時候 DSL 的值留得住,這條路徑跟 loadProperties() 回空 map 是同一件事的兩端,第三個測試裡 development 仍然是 true,因為 env 沒有給 RELIX_DEVELOPMENT,設定檔那層的值就不會被動到,三層覆蓋是逐欄位的,不是整份換掉
到第 24 篇為止 RelixApplication 的建構子都是空的,現在它需要傳入一個設定檔
class RelixApplication(val config: RelixConfig = RelixConfig()) {
// ...
}
只加一個建構子參數,而且給預設值,沒有預設值的話,前面所有一整批 RelixApplication() 的呼叫端跟測試會一起編譯不過。宣告成 val 是因為 app.config 等一下 start() 要讀、main 裡建 logger 也要讀
接著是 Relix() 本身,一樣是 top-level function,放在 RelixConfig.kt 最後面
fun Relix(
propertiesPath: String = "application.properties",
env: Map<String, String> = System.getenv(),
block: RelixConfig.() -> Unit,
): RelixApplication {
val config = RelixConfig()
config.block()
config.applyProperties(loadProperties(propertiesPath))
config.applyEnv(env)
return RelixApplication(config)
}
設定檔路徑跟 env 都做成參數、都給預設值,所以呼叫端還是 Relix { },一個字都不用改,但測試可以塞一個暫存的設定檔跟一個假的 env map 進去,這不是為了測試才多開的洞,JVM 沒有官方的 setenv,System.getenv() 回的是唯讀 map,不從外面傳進來就真的沒辦法在測試裡控制環境變數
fun Relix 這個名字 IDE 會警告 Function name 'Relix' should start with a lowercase letter,這是刻意的。Kotlin 的命名慣例對工廠函式開了例外,「用來建立某個 class 實體的函式可以跟那個 class 同名」,標準函式庫的 MutableList()、Job()、CoroutineScope() 都是這樣,第一個字母大寫是在告訴讀者「這裡在生一個東西出來」,而不是在做一件事
嚴格講這裡比慣例又多走一步,回傳的型別叫 RelixApplication,函式卻叫 Relix,兩個名字沒有完全對上,取這個名字是想讓進入點直接就是框架的名字,Ktor 站在同一個位置的是 embeddedServer { }
這條只是 IDE 的 inspection,kotlinc 編譯不會有任何一句話,所以不處理它也不會怎麼樣,不想看到就在函式上加 @Suppress("FunctionName")
設定只有在被執行路徑讀取時才有效,若只把欄位放進 RelixConfig 卻沒有接到實際位置,設定看似存在,行為不會改變,所以這幾個欄位要各自接上去
host 和 port 在 start() 建 server 時用掉了 (後面「Graceful Shutdown」會說明),剩下三個各自有落點,一個一個看
bodyLimitBytes 接到 adapter 讀 body 的地方
第 19 篇的 toRelixRequest() 已經有 maxBodyBytes 這個參數了,當時給它 1 MB 的預設值,也留了話說第 25 篇會把它接上設定,所以這裡要改的是呼叫端,第 06 篇建立的 JdkHttpServerAdapter.kt
server.createContext("/") { exchange ->
// 原本是 exchange.toRelixRequest(),吃參數的預設值 1 MB
val request = exchange.toRelixRequest(application.config.bodyLimitBytes)
// ...
}
adapter 從第 14 篇開始就拿著 application,config 現在掛在它上面,所以不用再多傳一份設定進去
requestTimeoutMs 接到 pipeline 執行的地方
改的是 RelixApplication.kt 裡第 14 篇那個 suspend 版的 handle(),只動執行 pipeline 那一行
suspend fun handle(call: RelixCall): RelixResponse {
// ...
val pipeline = buildPipeline(terminal, middlewares)
// 原本是 val response = pipeline(call)
val response = withTimeout(config.requestTimeoutMs.milliseconds) { pipeline(call) }
return if (call.request.method == "HEAD") {
response.copy(body = ByteArray(0))
} else {
response
}
}
包住的只有 pipeline(call),router 匹配跟 HEAD 那段都留在計時之外,前者還沒開始跑使用者的程式,後者是回應送出前的收尾,handle() 是 RelixApplication 的成員,config 就是自己的欄位,直接讀
withTimeout 有兩個多載,一個收 Long 毫秒,一個收 kotlin.time.Duration。直接把 requestTimeoutMs 傳進去走的是 Long 那個,編得過也跑得動,但 IDE 會提示 Legacy Long overload can be converted to Duration,因為官方文件把 Long 版定義成 Duration 版的簡寫,所以這裡用 .milliseconds 轉一手,要 import 的是這兩行
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.withTimeout
RelixConfig 的欄位維持 Long,設定檔跟環境變數給的本來就是一個數字,欄位名 requestTimeoutMs 也把單位講清楚了,型別轉換留在用它的那一行就好
還有一個地方要看清楚,IDE 匯入 withTimeout 時會列兩個候選,另一個在 kotlinx.coroutines.time 底下,收的是 java.time.Duration,不是這裡要的那個
withTimeout 超時會丟 TimeoutCancellationException,它是 CancellationException 的子類別,第 16 篇的 ErrorHandling 和第 27 篇的 StatusPages 都特別把 CancellationException 重新丟出去,所以它不會被誤當成 500 吃掉,而是往外傳到 adapter 那層決定怎麼回應
logLevel 接到建立 logger 的地方
這個不在框架內部,是使用者的 main
val logger = ConsoleLogger(app.config.logLevel)
app.use(loggingMiddleware(logger))
第三個跟前面兩個不一樣的地方在於,它沒有接到框架內部的執行路徑,只在「建立 logger 那一刻」讀一次,設定檔改成 logLevel=WARN,ConsoleLogger 就不再印 info 那行 request log,第 26 篇會把這個 logger 註冊進 DI 容器,讓 handler 也拿得到同一個實例
三個都接好之後,使用者那邊要寫的就只有這樣
val app = Relix {
port = 8080
host = "0.0.0.0"
development = true
}
三個欄位都處理好了,但「有沒有接上」這件事本身也要有測試,不然哪天改壞了沒有人會知道,RelixDslTest 驗的是設定值有沒有正確落進 app.config,這裡要驗的是下一步,那些值有沒有真的改變行為
requestTimeoutMs 走 TestKit 就驗得到,因為它接在 handle() 裡面,bodyLimitBytes 不行,它接在 adapter 呼叫 toRelixRequest() 的那一行,而 TestKit 是自己建一個 RelixRequest 直接餵給 handle(),根本不經過 adapter,所以那個測試得開真的 server,用第 06 篇的 JdkHttpServerAdapter 起就好
兩個測試驗的是同一件事的兩端,所以放在同一個檔案,ConfigWiringTest.kt
import java.net.HttpURLConnection
import java.net.URI
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
class ConfigWiringTest {
private fun app(block: RelixConfig.() -> Unit) =
Relix(propertiesPath = "no-such-file.properties", env = emptyMap(), block = block)
private fun post(url: String, body: String): Int {
val conn = (URI(url).toURL().openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
connectTimeout = 2_000
readTimeout = 2_000
}
conn.outputStream.use { it.write(body.toByteArray()) }
return conn.responseCode
}
@Test
fun `requestTimeoutMs stops a slow handler`() {
val app = app { requestTimeoutMs = 50 }
app.routing {
get("/slow") {
delay(5.seconds)
ok("never")
}
}
assertFailsWith<TimeoutCancellationException> {
RelixTestKit(app).handleRequest(path = "/slow")
}
}
@Test
fun `a handler within the timeout is untouched`() {
val app = app { requestTimeoutMs = 50 }
app.routing {
get("/fast") { ok("done") }
}
val response = RelixTestKit(app).handleRequest(path = "/fast")
assertEquals(200, response.statusCode)
assertEquals("done", response.bodyAsText())
}
@Test
fun `bodyLimitBytes is enforced by the adapter`() {
val app = app { bodyLimitBytes = 10 }
app.routing {
post("/echo") { ok(request.bodyAsText()) }
}
val adapter = JdkHttpServerAdapter(app)
adapter.start(0) // 0 = 讓系統挑一個沒被佔用的 port
try {
val url = "http://localhost:${adapter.port}/echo"
assertEquals(200, post(url, "0123456789"))
assertEquals(500, post(url, "01234567890"))
} finally {
adapter.stop()
}
}
}
adapter.start(0) 那個 0 是交給系統挑一個沒被佔用的 port,起完再從 adapter.port 問它實際綁到哪,這是第 14 篇就寫在 adapter 裡的東西,測試不該寫死 port,同時跑兩個測試或本機剛好有人佔著,就會變成莫名其妙的失敗
bodyLimitBytes 那個測試裡,超過上限拿到的是 500 不是 413,這點要注意,第 19 篇的 bodySizeLimitMiddleware 丟出來的 PayloadTooLargeException 會被 ErrorHandling 接成 413,但 adapter 這一層的 readAtMost() 是在 pipeline 開始之前就丟出來的,ErrorHandling 那時候還沒上場,例外一路穿到 adapter 自己的 catch (e: Exception),於是變成 500,兩層擋的時機不同,回應也不同,要 413 就得靠 middleware 那一層,adapter 這層擋的是「別讓記憶體先爆掉」
logLevel 沒有出現在這個檔案裡,因為它沒有接到框架的執行路徑,接的是使用者 main 裡建 logger 的那一行,框架這邊沒有東西可以驗,留給後面「在 main 裡組起來跑一次」那節實際跑一次
Kotlin 的 property delegation 可以把 env 讀取封裝起來
class EnvInt(private val envKey: String, private val default: Int) {
operator fun getValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int {
val envValue = System.getenv(envKey) ?: return default
return envValue.toIntOrNull()
?: throw IllegalArgumentException("Invalid $envKey: '$envValue'")
}
}
fun envInt(envKey: String, default: Int) = EnvInt(envKey, default)
用起來像這樣
val port: Int by envInt("RELIX_PORT", default = 8080)
上面這兩段是示範,不用建檔案,這裡沒有在 RelixConfig 裡面用 delegation,因為 RelixConfig 要支援三層覆蓋 (DSL → 設定檔 → env),delegation 只處理 env 一層,但這個機制可以先知道,在應用層的 config class 裡很好用
這裡要先講一個結構上的調整,第 06 篇把 server 的生命週期放在 JdkHttpServerAdapter,RelixApplication 只管路由與 pipeline,有了 RelixConfig 之後,host、port、bodyLimitBytes 這些設定都要傳給 adapter,如果還讓使用者自己 new adapter 再手動接設定,Relix { } 就名不副實了
所以從這篇開始,RelixApplication 多一個 start() / stop(),這兩個不是把 adapter 的同名方法搬走,adapter 的 start() / stop() 原樣留著 (前面 ConfigWiringTest 就還在用),RelixApplication 這兩個是薄薄的一層,只做「讀 config 去驅動 adapter、印啟動訊息、觸發 hook、註冊 shutdown hook」這幾件事,責任邊界沒有改變,server 怎麼建、HttpExchange 怎麼轉,還是 adapter 的事
改的是第 06 篇建立的 RelixApplication.kt,routing、use、install、handle 這些既有成員都要保留,不是另外開一個同名類別
// 合併到既有的 RelixApplication,既有成員都保留
// 建構子那行是前面接 Relix { } 時就加好的
class RelixApplication(val config: RelixConfig = RelixConfig()) {
private var adapter: JdkHttpServerAdapter? = null
fun start() {
val adapter = JdkHttpServerAdapter(this)
this.adapter = adapter
adapter.start(config.port, config.host)
println("Relix started on ${config.host}:${config.port}")
fireStarted()
Runtime.getRuntime().addShutdownHook(Thread {
stop()
})
}
fun stop() {
println("Relix shutting down...")
fireStopping()
adapter?.stop(5) // 等待最多 5 秒讓進行中的 request 完成
println("Relix stopped")
}
}
這裡沒有 HttpServer.create()、沒有 createContext,也沒有 try/catch 去擋 BindException,因為那些第 06 篇跟第 14 篇早就在 adapter 裡寫過了,自己再 create 一次 server 等於把那段邏輯抄第二份,port 被佔用時的錯誤訊息、handler 的接線、executor 的設定,之後每次改都要記得改兩邊
adapter 那邊要配合的只有兩個參數,改 JdkHttpServerAdapter.kt
class JdkHttpServerAdapter(private val application: RelixApplication) {
// 原本是 start(port: Int = 8080)
fun start(port: Int = 8080, host: String = "0.0.0.0") {
try {
// 這裡要加上 host
server = HttpServer.create(InetSocketAddress(host, port), 0)
} catch (e: java.net.BindException) {
// ...
}
// ...其餘同第 14 篇,只是 application.fireStarted() 那行要拿掉
}
// 原本是 stop(),裡面寫死 server.stop(0)
fun stop(delaySeconds: Int = 0) {
server.stop(delaySeconds)
}
}
host 以前是寫死的,第 14 篇用的 InetSocketAddress(port) 這個建構子等於綁 0.0.0.0,要讓 host = "127.0.0.1" 這種設定有意義,就得把它變成參數
stop() 的等待秒數同理,adapter 原本寫死 stop(0),現在改成呼叫端決定,預設值留 0,第 06 篇到第 24 篇那些 adapter.stop() 的呼叫端行為完全不變
fireStarted() 和 fireStopping() 是第 18 篇留下來的觸發點,plugin 用 onStarted / onStopping 註冊的那些 block 要靠它們才會跑,原本這兩行寫在 JdkHttpServerAdapter.kt 的 start() 和 stop() 裡,現在生命週期搬到 RelixApplication,觸發點也得一起搬過來,不然 plugin 註冊的連線池永遠不會被建立,關閉時當然也不會被釋放,是搬不是複製,第 18 篇 adapter 裡那兩行要對應拿掉,兩邊都留就變成觸發兩次
這件事漏掉了不容易察覺,第 18 篇那四個 hook 測試沒有一個會走到 start(),它們是直接操作 fireStarted() / fireStopping(),第一個連叫都不叫,驗的就是安裝當下不能執行,所以 start() 裡少了那一行,測試照樣全部通過,要真的把 server 跑起來才看得出 plugin 的啟動邏輯根本沒動
擺放位置也跟第 18 篇一樣,fireStarted() 放在 adapter.start() 之後,這時候 server 真的在聽了,plugin 拿到的才是可用的狀態,fireStopping() 放在 adapter?.stop() 之前,plugin 才來得及在 server 開始關閉前收到通知,不過這裡有個取捨要注意,stop(5) 還會等進行中的 request 最多 5 秒,onStopping 裡如果直接把連線池關掉,那些還沒跑完的 request 就會踩空,這裡的 onStopping 適合做「標記下線、停掉背景工作」這類事,真正的資源釋放排在等待結束之後才安全,這個時機我們沒有給 hook 的介面,真要做就得自己在 adapter?.stop(5) 之後補一段
addShutdownHook 在 JVM 收到 SIGTERM (docker stop、Ctrl+C) 時觸發,adapter?.stop(5) 的參數是等待秒數,給進行中的 request 5 秒鐘完成,超時就強制關閉
為什麼不用 stop(0) ? 因為如果有 request 正在處理到一半,直接砍掉會讓 client 收到 connection reset,給個緩衝時間比較友善
加了 fireStopping() 之後有個坑要先知道,stop() 現在有兩個呼叫來源,一個是 shutdown hook,一個是使用者自己在程式裡呼叫,兩邊都跑到的話,plugin 的關閉邏輯就會執行兩次,連線池會被關第二次,真要處理不難,加一個 AtomicBoolean 讓 stop() 只生效一次就好,注意 shutdown hook 跑在另一條 thread 上,普通的 var 擋不住
第 18 篇那四個 hook 測試驗的是「fireStarted() 有沒有跑到 hook」,至於觸發點擺對了沒有,當時就講了要開真的 server 才驗得到,生命週期搬進 RelixApplication 之後,這個測試就可以寫得出來,檔案放 LifecycleTest.kt
import java.net.HttpURLConnection
import java.net.URI
import kotlin.test.Test
import kotlin.test.assertEquals
class LifecycleTest {
// 逾時要設,觸發點擺錯時才會失敗,不是一直等下去
private fun fetch(url: String): String {
val conn = (URI(url).toURL().openConnection() as HttpURLConnection).apply {
connectTimeout = 2_000
readTimeout = 2_000
}
return conn.inputStream.use { it.readBytes().decodeToString() }
}
@Test
fun `started hooks fire after the server is listening`() {
val trace = mutableListOf<String>()
val app = Relix(propertiesPath = "no-such-file.properties", env = emptyMap()) {
port = 18080
}
app.routing {
get("/ping") { ok("pong") }
}
app.onStarted { trace += "started: ${fetch("http://localhost:18080/ping")}" }
app.onStopping { trace += "stopping" }
app.start()
app.stop()
assertEquals(listOf("started: pong", "stopping"), trace)
}
}
關鍵是 onStarted 裡面那個 request,它打回自己剛起的 server,fireStarted() 如果被擺到 adapter.start() 前面,這裡就拿不到 pong
逾時那兩行不是保險,是必要的,HttpServer.create() 在建立當下就把 port 綁好了,只是還沒有 thread 在收,所以觸發點擺錯的時候 TCP 連得上、HTTP 卻等不到回應,沒有 readTimeout 這個測試不會失敗,會一直掛在那裡,設了兩秒之後,把 fireStarted() 移到 adapter.start() 前面重跑,兩秒就得到 java.net.SocketTimeoutException: Read timed out
port 寫死 18080 是個妥協,start() 沒有把實際綁到的 port 回寫,寫 port = 0 讓系統挑一個的話,測試這邊反而問不到號碼,只好指定一個不太會撞到的
跑完還有一個東西會出現在輸出裡,stop() 明明只呼叫一次,Relix shutting down... 跟 Relix stopped 卻各印兩次,第二次是 JVM 結束時 shutdown hook 補上的,這就是上面說的兩個呼叫來源,這裡沒有特別擋,它就老實地跑第二次,斷言在那之前就檢查完了,所以測試還是通過的
設定這種東西,測試裡驗的是「給這個 map 會得到這個值」,真的跑起來才看得出三層來源疊上去是什麼結果
fun main() {
val app = Relix {
port = 8080
development = true
}
val logger = ConsoleLogger(app.config.logLevel)
app.use(loggingMiddleware(logger))
app.routing {
get("/hello") { ok("Hello!") }
}
app.start()
}
只有 DSL
沒有 application.properties,也沒設環境變數
Relix started on 0.0.0.0:8080
curl localhost:8080/hello
[Relix] GET /hello -> 200 (0ms)
加上設定檔
專案根目錄放一個 application.properties
port=9090
logLevel=WARN
重跑
Relix started on 0.0.0.0:9090
DSL 裡明明寫著 port = 8080,實際卻跑在 9090,設定檔蓋掉了 DSL,這時候打 localhost:9090/hello 一樣拿得到 Hello!,但 terminal 上不會再出現 [Relix] GET /hello ... 那行,因為 logLevel 被調到 WARN,ConsoleLogger.info() 直接 return 了
有一件事順便看清楚了,Relix started on 0.0.0.0:9090 這行還在,它是 start() 裡的 println,不經過 RelixLogger,所以 logLevel 管不到它,框架自己的啟動訊息要不要也走 logger,是個可以再想的問題,走了才能被統一過濾,但那又表示 logger 必須在 server 啟動之前就準備好
再加上環境變數
RELIX_PORT=7070 RELIX_LOG_LEVEL=INFO <你的啟動指令>
Relix started on 0.0.0.0:7070
port 又被蓋成 7070,request log 也回來了,三層的優先順序在這裡看得很清楚,環境變數贏過設定檔,設定檔贏過 DSL,這個順序就是 Relix { } 裡那三行呼叫的先後,後套用的蓋掉先套用的
設一個不存在的值
RELIX_LOG_LEVEL=VERBOSE <你的啟動指令>
Exception in thread "main" java.lang.IllegalArgumentException: Invalid RELIX_LOG_LEVEL: 'VERBOSE'
at RelixConfig.applyEnv(RelixConfig.kt:66)
at RelixConfigKt.Relix(RelixConfig.kt:89)
at RelixConfigKt.Relix$default(RelixConfig.kt:81)
at MainKt.main(main.kt:15)
stack trace 第二行就把事情講完了,它死在 Relix { } 裡面的 applyEnv(),main 的下一行 app.start() 根本沒機會執行
server 根本起不來,這是刻意的,設定錯誤在啟動當下就炸掉,比起默默用預設值繼續跑,然後在讓人猜為什麼 log level 不對,前者好處理得多,錯誤訊息把欄位名跟拿到的值都印出來了,這就是前面不用 LogLevel.valueOf() 的原因
關掉它
在 server 的 terminal 按 Ctrl+C,或從 IDE 的停止鈕停掉
Relix started on 0.0.0.0:7070
Relix shutting down...
Relix stopped
後面兩行都是 shutdown hook 印的 (在 terminal 按 Ctrl+C 的話,Relix shutting down... 前面還會多一個 shell echo 出來的 ^C),中間那 5 秒的等待這樣看不出來,因為根本沒有 request 在跑
關掉它,但有 request 還在跑
在 main 的 routing 加一條慢的路由
get("/slow") {
delay(3.seconds)
ok("slow done")
}
delay 跟 withTimeout 一樣有 Long 跟 Duration 兩個多載,要 import 的是 kotlinx.coroutines.delay 加上 kotlin.time.Duration.Companion.seconds,IDE 列的另一個 kotlinx.coroutines.time 收的是 java.time.Duration,一樣不是這裡要的
打 /slow,趁它還沒回來的時候把 server 關掉
Relix started on 0.0.0.0:8080
Relix shutting down...
[Relix] GET /slow -> 200 (3010ms)
Relix stopped
curl 那邊照樣拿到 slow done,順序是重點,Relix shutting down... 先印,接著才是這筆 request 的 log,最後才輪到 Relix stopped,中間那段就是 stop(5) 在等進行中的 request 收尾,這時候新的連線已經進不來了 (listening socket 先關掉),但手上這一筆會被做完
把 delay 改成 8 秒再跑一次,就看得到超過等待時間會怎樣,這次把時間也標出來
17:05:15 Relix started on 0.0.0.0:8080
17:05:18 Relix shutting down... <- 收到 SIGTERM
17:05:26 [Relix] GET /too-slow -> 200 (8016ms)
17:05:26 Relix stopped
client 在 17:05:23 就被切斷了,剛好是 SIGTERM 之後 5 秒,curl 回 exit 52 (Empty reply from server),但 server 這邊的 handler 完全沒被中斷,它照樣跑滿 8 秒才印出那行 log
這就是 HttpServer.stop(delay) 的語意,它先關掉 listening socket,最多等 delay 秒讓進行中的 exchange 完成,時間到就把連線關掉,但它不會去中斷正在執行的 handler,所以那 5 秒保護的是 client 的體驗,不是「5 秒後一定收得乾淨」,handler 自己要跑多久還是跑多久
要連 handler 一起停下來,靠的是前面接好的 requestTimeoutMs,把它設得比 shutdown 的等待時間短,withTimeout 會取消那個 coroutine,delay() 這種可取消的暫停點才會真的中斷
看不到後面兩行是正常的
上面那兩段輸出是把專案打包成 jar 之後,直接用 java -cp ... MainKt 跑出來的,如果你是用 kotlin run 起 server,Ctrl+C 之後很可能只看到
Relix started on 0.0.0.0:8080
Relix shutting down...
就沒有下文了,[Relix] GET /slow -> ... 跟 Relix stopped 都不會出現,這不是 shutdown hook 沒跑,kotlin run 實際上是 Amper 的 CLI 跑在一個 JVM 裡 (process 名稱是 org.jetbrains.amper.cli.MainKt),我們的 server 是它底下的另一個 JVM,terminal 的 Ctrl+C 送給整個 foreground process group,Amper 那個先結束,負責轉印子行程輸出的管線就沒人接了,我們的 JVM 在那之後才印的東西自然落不到畫面上
server 其實還活著,從 client 那一側看得更清楚,3 秒的 request 照樣拿到 slow done,8 秒的拿到 curl: (52) Empty reply from server,這兩個結果跟前面那兩段輸出是一致的
要看到完整的四行,要用 ./kotlin build 之後直接跑打包好的 jar,讓 server 自己就是那個收訊號的 process,中間不要隔一層
為什麼不用 YAML 或 TOML ?
.properties 是 Java 生態內建的格式,不需要額外的 parser dependency,這裡的設定項目都是平坦的 key-value,不需要巢狀結構,真實專案如果設定項很複雜,可以換 YAML (用 SnakeYAML) 或 HOCON (用 Typesafe Config)
拿 Spring Boot 來對照會更具體,Spring Boot 預設同時支援 application.properties 和 application.yml,並用 application-{profile}.properties 做多環境設定 (dev / staging / prod)。server.port=8080 (properties) 等同於 server: { port: 8080 } (yaml),兩者底層都映射到同一份 Environment 抽象,Relix 的設計方向也一樣,先選最簡單的格式,把優先級鏈 (DSL → file → env) 做對,當設定樹真的長到三層以上、或團隊想要 relix-prod.yml 這種 profile 機制,再換 parser,前面的多層覆蓋邏輯都還能用
env 格式錯應該丟 exception 還是 log warning ?
丟 exception,如果 ops 設了 RELIX_PORT=abc,他預期 port 會被覆蓋,默默 fallback 到預設值會讓問題藏起來,app 跑在 8080,ops 以為跑在 abc,debug 半天
RelixConfig 為什麼是 mutable class 而不是 data class ?
因為建構過程是分步驟的,先 DSL 設定、再設定檔覆蓋、再 env 覆蓋,如果用 data class 的 copy(),每一步都要產生新物件,mutable class 在建構期修改,建構完成後交給 RelixApplication 持有就不再改了
設定系統用三層覆蓋,程式碼 DSL → 設定檔 → 環境變數,RelixConfig 是 mutable class,建構期分步驟套用各層設定,環境變數轉型失敗丟 exception 而不是默默 fallback
Graceful Shutdown 用 addShutdownHook + adapter.stop(delay) 實作,給進行中的 request 時間完成
下一篇做極簡 DI,建立 ServiceContainer,提供 services { singleton { ... } } 和 resolve<T>(),讓 handler 不需要手動組裝依賴
同步刊登於 Blog
圖片來源:AI 產生