iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Software Development

Kotlin Ktor 實戰 101系列 第 17 篇

Kotlin Ktor 實戰 101 Day 17 設定管理與多環境

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260909/20121948LfQo3aVCtG.jpg

day 16 結尾列了 3 個寫死在程式碼裡的東西,port 8080、log level、call id 的長度,還有 day 15 欠的那個「開發模式才回傳例外訊息」,這篇把它們一次處理掉

application.yaml 加上 EngineMain,設定怎麼被找到、環境變數怎麼覆蓋、整份設定怎麼讀進一個 data class,這些是這篇的骨幹,中間會停在 developmentMode 上問清楚它到底改變了什麼,也會一條一條決定哪些常數該搬進設定檔、哪些不該,最後測試整個換過一輪

這篇要完成什麼

  • 加 application.yaml,main() 從 embeddedServer 換成 EngineMain
  • 講清楚 YAML 跟 HOCON 在 3.5.2 是怎麼被找到的,以及 2 份都放的時候誰贏
  • 環境變數的 4 種語法、3 條覆蓋路徑,以及它們的優先順序,全部實測
  • developmentMode 到底改變了什麼,3 件事裡有一件是拿不到的
  • 兌現 day 15 的承諾,開發模式才把例外訊息放進 details
  • 用 @Serializable 的 data class 一次讀完一整個設定節點
  • 哪些常數該搬進設定檔、哪些不該,逐一交代理由
  • 既有測試整個換一輪

application.yaml 與 EngineMain

先加相依套件。build.gradle.kts 的 dependencies 區塊,接在 callId 後面

implementation(ktorLibs.server.config.yaml)

ktorLibs 這個名字是 day 02 在 settings.gradle.kts 寫的 create("ktorLibs") { from("io.ktor:ktor-version-catalog:3.5.2") },它是 Ktor 官方發佈的一份 toml,Gradle 讀進去之後,替裡面每一個 alias 產生一個 accessor 給 implementation() 用,規則是把 alias 的 dash 換成一層層的點,所以 ktorLibs. 後面那串怎麼寫,是由 toml 裡那個手寫的 alias 決定的,不是由 artifact 名稱決定

day 02 當時說的規則是「把 artifact 名去掉 ktor- 前綴,dash 換成一層層的點」,這句話對一半,翻 ktor-version-catalog-3.5.2.toml 就知道差在哪

server-callId = {group = "io.ktor", name = "ktor-server-call-id", version.ref = "ktor" }
server-config-yaml = {group = "io.ktor", name = "ktor-server-config-yaml", version.ref = "ktor" }

ktor-server-config-yaml 的 alias 跟 artifact 同形,照 day 02 的規則推剛好對,寫成 server.config.yaml。ktor-server-call-id 的 alias 卻被手寫成 camelCase 的 server-callId,照那個規則推會得到 3 層的 server.call.id,實際上只有 2 層的 server.callId 編譯得過,同一份 catalog 裡 2 種風格並存,推不出來的時候去翻那份 toml 最快,它躺在 Gradle 的 cache 裡

find ~/.gradle/caches/modules-2 -name "ktor-version-catalog-3.5.2.toml"

接著開新檔案 src/main/resources/application.yaml

ktor:
  application:
    modules:
      - com.cashwu.todo.ApplicationKt.module
  deployment:
    host: '$HOST:0.0.0.0'
    port: '$PORT:8080'
  development: '$DEV_MODE:false'

todo:
  requestIdHeader: '$REQUEST_ID_HEADER:X-Request-Id'
  responseTimeHeader: '$RESPONSE_TIME_HEADER:X-Response-Time'

ktor 底下是框架自己認得的 key,todo 底下是我們的,等一下會讀進 data class

modules 那一行是 module 函式的完整名稱,Ktor 啟動時用它把 Application.module() 找出來執行

然後改 Application.kt 的 main()

fun main(args: Array<String>) {
    EngineMain.main(args)
}

2 行 import 換成一行,io.ktor.server.engine.embeddedServer 跟 io.ktor.server.netty.Netty 拿掉,改成 io.ktor.server.netty.EngineMain

day 04 拆過 embeddedServer(Netty, port = 8080, module = Application::module) 那行裡的 3 個角色,現在 3 個全部搬到設定檔,engine 由 EngineMain 這個物件決定 (它住在 ktor-server-netty 裡,換 engine 也要換 import),port 跟 module 由 application.yaml 決定

./gradlew installDist 會把 jar 跟所有相依套件複製到 build/install/todo-api/,bin/todo-api 就是它產生的啟動腳本,直接執行

./gradlew installDist
build/install/todo-api/bin/todo-api

server 佔住這個 terminal,另開一個打一次 /todos/1

curl -s localhost:8080/todos/1

server 那邊會有 4 行 log

20:30:33.133 INFO  [no-call-id] Application -- Autoreload is disabled because the development mode is off.
20:30:33.213 INFO  [no-call-id] Application -- Application started in 0.152 seconds.
20:30:33.289 INFO  [no-call-id] Application -- Responding at http://0.0.0.0:8080
20:30:36.945 INFO  [bn6dk4n9ufga] Application -- 200 GET /todos/1 15ms

跟 day 16 比,logger 的名字從 i.k.s.Application 變成了 Application,embeddedServer 用的是預設的 io.ktor.server.Application,EngineMain 走的 CommandLineConfig 則是讀 ktor.application.id 建一個 logger,沒設就叫 Application,%logger{20} 印出來的就是這個名字,要改回去就在 yaml 補一行 ktor.application.id

這篇一律用 installDist 產生出來的啟動腳本,不用 ./gradlew run,run 也跑得起來,環境變數也吃得到,但後面幾段要驗的東西它做不到,gradlew 這個 shell script 拿到的 JAVA_OPTS 是給 Gradle 自己那個 JVM 的,run 是另外 fork 一個行程跑程式,-Dktor.deployment.port=7072 到不了那個行程的 System.getProperty(),而且是安靜失效,實測 JAVA_OPTS="-Dktor.deployment.port=7072" PORT=9090 ./gradlew run 起在 9090,那行 JAVA_OPTS 像不存在一樣,照這個結果去推優先順序會推錯,命令列參數也要改寫成 --args="-port=7070" 才進得去,再來下一節要看啟動腳本裡 jar 的排列順序,那個檔案只有 installDist 才會產生,installDist 出來的東西跟部署到機器上跑的是同一份,驗設定的行為少墊一層 Gradle

還有一件事要注意,application.yaml 跟 logback.xml 是被打包進 build/install/todo-api/lib/todo-api.jar 裡的

unzip -l build/install/todo-api/lib/todo-api.jar | grep -E "application.yaml|logback.xml"
      296  02-01-1980 00:00   application.yaml
      381  02-01-1980 00:00   logback.xml

所以 src/ 底下改了任何東西,設定檔也算,都要再跑一次 ./gradlew installDist,不然啟動腳本讀到的還是舊的那份,下面有幾個故意讓它失敗的實驗,忘了重跑就會看不到預期的錯誤,純粹只換環境變數不用重跑,那些值是啟動的當下才讀的

day 02 當時說「跟 start.ktor.io 產出的專案相比少了很多東西,沒有 application.yaml、沒有一排預裝的 plugin,這是刻意的,這些東西後面每一篇會在需要的時候自己加進來」

YAML 跟 HOCON 誰先被找到

Ktor 讀設定檔的格式有 YAML 跟 HOCON 2 種,這個系列用 YAML,先講清楚兩邊在 3.5.2 的實際狀況

HOCON 是內建的,ktor-server-core 本身就相依 com.typesafe:config,HoconConfigLoader 也註冊在 core 的 META-INF/services/io.ktor.server.config.ConfigLoader 裡,什麼都不加就能讀 application.conf,YAML 要多一個 ktor-server-config-yaml,它會再帶進 kaml、snakeyaml-engine-kmp、yamlkt 3 個 jar

2 個 loader 都是靠 ServiceLoader 找出來的

public actual val configLoaders: List<ConfigLoader> = loadServices<ConfigLoader>()

ConfigLoader.load(null) 拿到這份清單之後從頭問到尾,誰先回傳非 null 就用誰,HoconConfigLoader.load(null) 找 application.conf,YamlConfigLoader.load(null) 找 application.yaml

如果 2 個檔案都要放進 src/main/resources,誰會先被讀取 ? 結果是 YAML,原因不是 Ktor 有規定優先順序,而是 classpath 順序

grep -o "CLASSPATH=.*" build/install/todo-api/bin/todo-api | tr ':' '\n' | grep -nE "config-yaml|server-core"
10:$APP_HOME/lib/ktor-server-config-yaml-jvm-3.5.2.jar
11:$APP_HOME/lib/ktor-server-core-jvm-3.5.2.jar

config-yaml 那個 jar 剛好排在 core 前面,ServiceLoader 就先找到 YamlConfigLoader,這個順序由 Gradle 的相依解析決定,換一版可能就換邊,所以結論很單純,直接選一個就好

選 YAML 的理由有 2 個,一個是環境變數,YAML 這邊有 $VAR:default 這套語法,HOCON 要用 ${?ENV} 加 typesafe config 自己的一套規則,兩邊行為不一樣,另一個是 start.ktor.io 現在產出的專案預設就是 application.yaml,跟著官方走,之後查文件或看別人的專案比較不會對不上

環境變數怎麼覆蓋

YamlConfig 在讀進檔案的當下就把所有 $ 開頭的值換掉了,換的邏輯在 resolveReference 這個私有函式裡,4 種寫法

  • $PORT 直接讀,讀不到就丟例外
  • ${PORT} 是 $PORT 的另一種寫法,2 個完全等價,要注意大括號不能拿來跟其他文字相接,整個值必須就是 ${VAR},寫成 "${PORT}-suffix" 會把 {PORT}-suffix 整串當成變數名字然後找不到
  • $PORT:8080 冒號後面是預設值,讀不到就用它
  • $?PORT 問號表示可有可無,讀不到就變成 null

還有一個容易忽略的細節,它讀的東西不只是環境變數

internal actual fun getSystemPropertyOrEnvironmentVariable(key: String): String? {
    return System.getProperty(key) ?: System.getenv(key)
}

system property 排在環境變數前面。實測 -DPORT=6060 跟 PORT=6060 效果一樣,2 個都給的話 system property 贏,這對測試很有用,JVM 沒有官方的 setenv,但 System.setProperty 隨時可以呼叫,等一下寫測試會用到這條路

先看正常的覆蓋

PORT=9090 build/install/todo-api/bin/todo-api

會改成用 9090 port 啟動

20:37:41.853 INFO  [no-call-id] Application -- Responding at http://0.0.0.0:9090
curl -i -s localhost:9090/
HTTP/1.1 200 OK
X-Request-Id: 6mdds-l9p96u
X-Response-Time: 4ms
Content-Length: 12
Content-Type: text/plain; charset=UTF-8

Hello, Ktor!

這裡要停一下,PORT 生效了,但同一行再加上 REQUEST_ID_HEADER=X-Correlation-Id RESPONSE_TIME_HEADER=X-Elapsed,回應的 2 個 header 名字一個字都不會變,還是 X-Request-Id 跟 X-Response-Time

不是環境變數沒生效,是沒有人在讀那 2 個值,ktor.deployment.port 是框架自己認得的 key,EngineMain 啟動時會去讀,todo 這個節點是我們自己加的,此刻整個專案沒有一行程式碼碰它,header 的名字還是 Logging.kt 裡那個 const val REQUEST_ID_HEADER,跟 RequestTiming 自己的預設值,要到下面〈型別化設定讀進 data class〉 把 todo 節點讀出來接到 2 個 plugin 上,這 2 個變數才會有作用,那時候再回來驗一次

再看少一個沒有預設值的變數會怎樣。這次要改設定檔,application.yaml 的 requestIdHeader 那行把冒號後面的預設值拿掉

todo:
  requestIdHeader: '$REQUEST_ID_HEADER'
  responseTimeHeader: '$RESPONSE_TIME_HEADER:X-Response-Time'

yaml 在 jar 裡,所以要重跑 installDist,然後不設那個環境變數直接啟動

./gradlew installDist
build/install/todo-api/bin/todo-api
Exception in thread "main" io.ktor.server.config.ApplicationConfigurationException: Required environment variable "REQUEST_ID_HEADER" not found and no default value is present
	at io.ktor.server.config.yaml.YamlConfigKt.resolveReference(YamlConfig.kt:233)
	at io.ktor.server.config.yaml.YamlConfigKt.resolveReferences(YamlConfig.kt:196)
	at io.ktor.server.config.yaml.YamlConfigKt.swapEnvironmentVariables(YamlConfig.kt:182)
	at io.ktor.server.config.yaml.YamlConfig$Companion.from$ktor_server_config_yaml(YamlConfig.kt:52)
	at io.ktor.server.config.yaml.YamlConfigLoader.load(YamlConfig.kt:29)
	at io.ktor.server.engine.CommandLineKt.buildApplicationConfig(CommandLine.kt:121)
	at io.ktor.server.engine.CommandLineKt.CommandLineConfig(CommandLine.kt:46)
	at io.ktor.server.netty.EngineMain.createServer(EngineMain.kt:39)
	at io.ktor.server.netty.EngineMain.main(EngineMain.kt:24)
	at com.cashwu.todo.ApplicationKt.main(Application.kt:25)

驗完把預設值加回去,後面的段落都是照原來那份 yaml 跑的

server 起不來,錯誤訊息把變數名字印出來了,這是好事,Relix day 25 為了同一個原則寫過一個測試,那邊擋的是格式錯誤的值,「環境變數的值不是數字時,不能默默 fallback 到預設值,那會讓部署問題很難 debug」,這邊擋的是根本沒設的變數,2 個要避免的結果一樣,Ktor 甚至更早一步,在載入設定檔的當下就爆炸,main 的第 1 行都還沒開始跑

除了 yaml 裡的 $VAR,還有 2 條覆蓋路徑,buildApplicationConfig 那幾行把 3 份設定疊起來

return fileConfig.mergeWith(environmentConfig).mergeWith(commandLineConfig)

mergeWith 的語義是「後面那個贏」,所以順序是設定檔在最底、其次是 environmentConfig、最上面是 commandLineConfig。environmentConfig 這個名字有點誤導,餵給它的 getKtorEnvironmentProperties() 在 JVM 上跑的是 System.getProperties(),而且只挑 ktor. 開頭的 key,跟環境變數沒有關係,原始碼裡還特別留了註解講這件事

3 條路徑各跑一次確認

PORT=9090 build/install/todo-api/bin/todo-api
# 9090
JAVA_OPTS="-Dktor.deployment.port=7072" PORT=9090 build/install/todo-api/bin/todo-api
# 7072
PORT=9090 build/install/todo-api/bin/todo-api -port=7070
# 7070

註解裡的數字是實際起在哪個 port,-P:ktor.deployment.port=7071 這種寫法也可以,它跟 -port= 一樣走 commandLineConfig,差別是 -P: 可以指定任何 key,-port 只是幾個常用參數的捷徑

還有一條路這篇沒走,buildApplicationConfig 裡也認 -config= 這個參數,給多份的話會依序 mergeWith 疊起來、後面的贏 (跟測試那邊 ConfigLoader.loadAll 是同一套邏輯),所以「一個環境一份 yaml」是 Ktor 內建支援的做法,-config=application-prod.yaml 就能換掉整份設定

這個系列選的是單一 yaml 加環境變數,理由是部署環境之間真正會變的只有那幾個值,多一份檔案就多一份會跟主檔案漂移的東西,而且環境變數本來就是容器平台餵設定的原生方式,設定項一多、環境之間的差異變成結構性的差異時,-config= 那條路會比較好管

developmentMode 到底改變了什麼

day 04 埋過這個伏筆,「Ktor 有一個 development mode,開了之後程式碼變動可以自動重新載入,目前沒開所以它提了一句,設定相關的主題 day 17 會展開」,開關就是 yaml 那行 ktor.development

CommandLineConfig 讀它的方式是這樣

developmentMode = configuration.tryGetString(ConfigKeys.developmentModeKey)
    ?.let { it.toBoolean() } ?: PlatformUtils.IS_DEVELOPMENT_MODE

PlatformUtils.IS_DEVELOPMENT_MODE 在 JVM 上讀的是 system property io.ktor.development,但那是設定檔沒寫這個 key 時才輪得到的 fallback,我們的 yaml 寫了 development: "$DEV_MODE:false",所以 -Dio.ktor.development=true 完全沒作用,實測起來那行 Autoreload is disabled 照樣出現,要開就用 DEV_MODE=true
開關在環境變數,程式碼跟設定檔都不用動

DEV_MODE=true build/install/todo-api/bin/todo-api

打開之後,實際觀察得到的變化有 2 件

啟動訊息少一行

20:46:30.248 INFO  [no-call-id] Application -- Application started in 0.178 seconds.
20:46:30.320 INFO  [no-call-id] Application -- Responding at http://0.0.0.0:8080

前面每一次啟動都有的那行 Autoreload is disabled because the development mode is off. 不見了,createClassLoader() 第 1 件事就是看 development mode,關著的話印那行然後直接回傳原本的 classloader,開了才會往下走去看 ktor.deployment.watch 有沒有東西可以監看

stack trace 換了一套執行器,這個要有例外才看得到,在 module() 的 routing 裡臨時加一條會炸的路由,跟 day 15、day 16 用的是同一條,測完拿掉

get("/boom") { throw RuntimeException("資料庫密碼是 hunter2") }

./gradlew installDist 之後起 2 次,一次不設 DEV_MODE、一次 DEV_MODE=true,各打一次 /boom

curl -s -o /dev/null localhost:8080/boom

回應長什麼樣這裡不管,要看的是 server 那邊 todoStatusPages() 裡那行 log.error 印出來的 stack trace,看下面的最後 2 行

java.lang.RuntimeException: 資料庫密碼是 hunter2
	at com.cashwu.todo.ApplicationKt$module$7$2.invokeSuspend(Application.kt:69)
	at com.cashwu.todo.ApplicationKt$module$7$2.invoke(Application.kt)
	at com.cashwu.todo.ApplicationKt$module$7$2.invoke(Application.kt)
	at io.ktor.server.routing.RoutingNode$buildPipeline$1$1.invokeSuspend(RoutingNode.kt:127)
	at io.ktor.server.routing.RoutingNode$buildPipeline$1$1.invoke(RoutingNode.kt)
	at io.ktor.server.routing.RoutingNode$buildPipeline$1$1.invoke(RoutingNode.kt)
	at io.ktor.util.pipeline.PipelineJvmKt.pipelineStartCoroutineUninterceptedOrReturn(PipelineJvm.kt:15)
	at io.ktor.util.pipeline.SuspendFunctionGun.loop(SuspendFunctionGun.kt:168)
	at io.ktor.util.pipeline.SuspendFunctionGun.proceed(SuspendFunctionGun.kt:120)
	...

開了之後同一個位置變成

java.lang.RuntimeException: 資料庫密碼是 hunter2
	at com.cashwu.todo.ApplicationKt$module$7$2.invokeSuspend(Application.kt:69)
	at com.cashwu.todo.ApplicationKt$module$7$2.invoke(Application.kt)
	at com.cashwu.todo.ApplicationKt$module$7$2.invoke(Application.kt)
	at io.ktor.server.routing.RoutingNode$buildPipeline$1$1.invokeSuspend(RoutingNode.kt:127)
	at io.ktor.server.routing.RoutingNode$buildPipeline$1$1.invoke(RoutingNode.kt)
	at io.ktor.server.routing.RoutingNode$buildPipeline$1$1.invoke(RoutingNode.kt)
	at io.ktor.util.pipeline.DebugPipelineContext.proceedLoop(DebugPipelineContext.kt:79)
	at io.ktor.util.pipeline.DebugPipelineContext.proceed(DebugPipelineContext.kt:57)
	...

來源是 Pipeline 建 context 那一行,developmentMode 為真就用 DebugPipelineContext,否則用 SuspendFunctionGun,前者是給人看的,後者是為了效能做的狀態機,這條會影響每一個請求,正式環境不要開

至於第 3 件事,官方文件說 development mode 會在 5xx 的時候給一個帶除錯資訊的回應頁,這個東西確實存在,ExceptionPageContent 會把 request 資訊跟整份 stack trace 印成藍色的 HTML,掛的位置在 setupFallbackResponse

internal fun setupFallbackResponse(application: EnginePipeline, logger: Logger) {
    val inDevMode = application.developmentMode
    application.intercept(EnginePipeline.Before) {
        try {
            proceed()
        } catch (cause: Throwable) {
            // ... inDevMode -> ExceptionPageContent(call, cause)
        }
    }
}

要看它得先讓例外沒有人接,把 module() 裡的 install(StatusPages) 整段註解掉,重新 installDist,然後開著 development mode 打 /boom

DEV_MODE=true build/install/todo-api/bin/todo-api
curl -i -s localhost:8080/boom

拿到的是這個

HTTP/1.1 500 Internal Server Error
X-Request-Id: t-=nsp2i05d5
X-Response-Time: 6ms
Content-Length: 26
Content-Type: text/plain; charset=UTF-8

資料庫密碼是 hunter2

不是 HTML,原因是那個 catch 掛在 EnginePipeline.Before,而 defaultEnginePipeline 在 EnginePipeline.Call 這一段自己就有一個 try/catch

pipeline.intercept(EnginePipeline.Call) {
    try {
        call.application.execute(call)
    } catch (error: ChannelIOException) {
        // ...
    } catch (error: Throwable) {
        // ... handleFailure(call, error)
    }
}

Call 比 Before 內層,這次由 application pipeline 丟出的例外先被 handleFailure 接走,沒有繼續往外傳到 Before 的 catch,handleFailure 做的事是 tryRespondError(call, statusCode, error.message),把例外的 message 原封不動當成 body 回出去,這就是上面那個的來源,外層 fallback 仍可能處理 engine pipeline 其他位置拋出的例外,只是一般 route handler 的錯誤不會走到那裡,順帶又替 day 15 那個論點多了一個佐證,沒有 StatusPages 的時候 500 會外流例外訊息,而且開不開 development mode 都一樣

所以對 todo-api 來說,developmentMode 開了之後回應內容一個字都不會變,要讓開發跟正式的錯誤訊息不同,得自己動手

兌現 day 15 那個開發模式的例外訊息

day 15 的原話是「Relix day 27 給了 development 模式一條例外,開發時把 message 回出去方便 debug,那套做法在 Ktor 這邊也成立,application.developmentMode 讀得到,這裡不做是因為 todo-api 還沒有環境的概念,day 17 講設定管理的時候會補」

把前面註解的 StatusPages 記得裝回去,改 src/main/kotlin/com/cashwu/todo/ErrorHandling.kt 裡 todoStatusPages() 的 exception<Throwable>,最後那 2 行改成

call.application.log.error("Unhandled exception on ${call.request.path()}", cause)
val details = if (call.application.developmentMode) {
    listOf("${cause::class.simpleName}: ${cause.message}")
} else {
    emptyList()
}
call.respondError(HttpStatusCode.InternalServerError, "伺服器發生未預期的錯誤", details)

details 這個欄位是 day 15 就定好的 ErrorResponse 的一部分,驗證失敗時裝的是每一條規則的說明,這裡裝的是例外,回應的形狀沒有變,只是多了內容

developmentMode 是 Pipeline 上的屬性,Application 繼承自它,不用另外 import

實測用的還是上面那條 /boom,./gradlew installDist 打包後再測試

build/install/todo-api/bin/todo-api
curl -i -s localhost:8080/boom
HTTP/1.1 500 Internal Server Error
X-Request-Id: 1nqt2xuny3wc
X-Response-Time: 21ms
Content-Length: 73
Content-Type: application/json

{"status":500,"message":"伺服器發生未預期的錯誤","details":[]}

用 DEV_MODE=true 再測試一次

DEV_MODE=true build/install/todo-api/bin/todo-api
HTTP/1.1 500 Internal Server Error
X-Request-Id: djau+=9elpnl
X-Response-Time: 14ms
Content-Length: 119
Content-Type: application/json

{"status":500,"message":"伺服器發生未預期的錯誤","details":["RuntimeException: 資料庫密碼是 hunter2"]}

這裡有一個取捨,用 developmentMode 當開關,好處是不用多一個設定 key,而且它跟 auto-reload、DebugPipelineContext 是同一個開關,語義上一致,「這台機器是給人開發用的」,壞處是全都綁在一起,想在 staging 拿到錯誤細節但不想吃 DebugPipelineContext 的效能,就得另外開一個 todo.exposeErrorDetail 之類的 key,todo-api 的規模用前者就夠,真的要分再拆

還有一件事要注意,cause.message 是誰寫的沒人管得到,正式環境一定不能外流,所以這個 if 的方向不能寫反,下面有個測試就是專門驗證這件事的

不過測試驗證的是程式碼裡那個 if,沒有任何東西在驗部署出去的那個值,DEV_MODE=true 不小心跟著正式環境的設定上線,每一個未處理的 500 都會把原始例外訊息送給任何一個呼叫端,SQL 片段、檔案路徑、連線字串都在 cause.message 裡,測試裡那句 資料庫密碼是 hunter2 演的就是這件事

day 16 講的是 log 洩漏,那至少還留在自己的機器上,這個是直接送出門,所以這個開關的值該由部署平台管,而且要能一眼看出正式環境現在是開還是關

型別化設定讀進 data class

ApplicationConfig 有 tryGetString(key) 可以一個一個讀,但 key 打錯字會回 null 而不是失敗,而且每一個欄位都要自己轉型。3.x 有更好的做法,整個節點一次餵給 kotlinx.serialization

新增一個檔案 src/main/kotlin/com/cashwu/todo/TodoConfig.kt

package com.cashwu.todo

import kotlinx.serialization.Serializable

@Serializable
data class TodoConfig(
    val requestIdHeader: String,
    val responseTimeHeader: String,
)

在 Application.kt 的 module() 開頭讀一次,然後接到 2 個 plugin 上

fun Application.module() {
    val config = property<TodoConfig>("todo")

    install(CallId) {
        todoCallId(config.requestIdHeader)
    }
    install(CallLogging) {
        todoCallLogging()
    }
    install(RequestTiming) {
        headerName = config.responseTimeHeader
    }
    // ... 其餘三個 plugin 跟 routing 不變
}

property 是 io.ktor.server.config 底下的 extension function,實作只有一行

public inline fun <reified E> Application.property(key: String): E =
    environment.config.property(key).getAs()

getAs() 走到 YamlConfig 這一端是 format.decodeFromYamlNode(type.serializer(), node),也就是 kaml 直接把那個 YAML 節點反序列化成 data class,走 HOCON 的話會轉一手 MapConfigDecoder,結果一樣

Logging.kt 那邊配合改一行,const val REQUEST_ID_HEADER 拿掉,函式改成收參數

fun CallIdConfig.todoCallId(headerName: String) {
    header(headerName)
    generate(length = 12)
    verify { callId ->
        callId.length in 1..CALL_ID_MAX_LENGTH && callId.all { it in CALL_ID_DEFAULT_DICTIONARY }
    }
}

RequestTiming 那邊什麼都不用改,day 10 寫它的時候 RequestTimingConfig 就有 headerName 這個欄位了,當時示範換成 X-Elapsed 用的是寫死的字串,現在換成設定檔的值

接上去之後,前面那 2 個沒作用的環境變數就有作用了

./gradlew installDist 重跑一次,3 個一起測試看看

PORT=9090 REQUEST_ID_HEADER=X-Correlation-Id RESPONSE_TIME_HEADER=X-Elapsed \
  build/install/todo-api/bin/todo-api
curl -i -s localhost:9090/
HTTP/1.1 200 OK
X-Correlation-Id: 5zp2dogradwn
X-Elapsed: 5ms
Content-Length: 12
Content-Type: text/plain; charset=UTF-8

Hello, Ktor!

可以看到除了 port 之外,相關的 header 的名稱也都換掉了

yaml 的 $REQUEST_ID_HEADER:X-Request-Id 在載入設定的當下就被換成 X-Correlation-Id,property<TodoConfig>("todo") 把它讀成 data class 的欄位,todoCallId(config.requestIdHeader) 再把它交給 plugin,程式碼裡沒有任何一個地方認得 REQUEST_ID_HEADER 這個變數名字,認得它的是 yaml

型別化最實際的好處是打錯字會在啟動當下失敗,把 application.yaml 的 responseTimeHeader 那行整行刪掉,./gradlew installDist 重跑,再啟動一次

20:47:41.776 INFO  [no-call-id] Application -- Autoreload is disabled because the development mode is off.
Exception in thread "main" MissingRequiredPropertyException at todo on line 11, column 3: Property 'responseTimeHeader' is required but it is missing.
	at com.charleskorn.kaml.YamlInput.throwIfMissingRequiredPropertyException(YamlInput.kt:155)
	at com.charleskorn.kaml.YamlInput.decodeSerializableValue(YamlInput.kt:146)
	at com.charleskorn.kaml.YamlMapLikeInputBase.decodeSerializableValue(YamlMapLikeInputBase.kt:51)
	at com.charleskorn.kaml.Yaml.decodeFromYamlNode(Yaml.kt:52)

跟前面少一個必要環境變數那次不一樣,這次第 1 行 log 有印出來,因為它不是在載入設定檔的時候炸的,是 module() 第 1 行 property<TodoConfig>("todo") 反序列化的時候才發現欄位不見了

測試完記得把那行加回去

欄位名字直接印出來,而且連是哪個節點、第幾行第幾欄都指給你了,相對地,tryGetString("responseTimeHeder") 少一個 a 只會安靜回 null,然後你會拿到一個叫 null 的 header,或是某個地方莫名其妙用了預設值

要留退路的話,data class 的欄位給 Kotlin 預設值就好,val responseTimeHeader: String = "X-Response-Time",這樣 yaml 沒寫也活得下去,這裡 2 個欄位都不給預設值,因為它們在 yaml 裡本來就寫了預設值,兩邊都留一份反而不知道以哪邊為準

讀出來之後,別的地方怎麼拿

property 是 Application 的 extension,route handler 裡有 call.application,所以這樣寫是合法的

get("/probe") {
    val config = call.application.property<TodoConfig>("todo")
    call.respondText(config.requestIdHeader)
}

打下去回的是 X-Request-Id,能動,但不要真的這樣用,property 沒有任何快取

public inline fun <reified E> Application.property(key: String): E =
    environment.config.property(key).getAs()

public fun ApplicationConfig.getAs(type: TypeInfo): Any? =
    when (type) {
        typeInfo<Map<String, Any?>>() -> toMap()
        else -> type.serializer().deserialize(MapConfigDecoder(toMap().flatten().toMap()))
    }

每呼叫一次就把整份設定 toMap() 攤平再反序列化一次,放在 module() 開頭讀一次沒問題,放在每個請求都會經過的路徑上就是白做工

所以正確的問題不是「別的地方怎麼讀」,是「讀出來的那個物件怎麼交給別的地方」,不靠 DI 的話有 3 條路

傳參數,fun Route.todoRoutes(config: TodoConfig),module() 裡呼叫的時候把 config 給它,最直白,2、3 層還好,層數一多每一層都要多掛一個用不到的參數

放進 Application.attributes,自己開一把 key

val TodoConfigKey = AttributeKey<TodoConfig>("TodoConfig")

module() 裡讀完就 attributes.put(TodoConfigKey, config),handler 那邊

val config = call.application.attributes[TodoConfigKey]

簽章都不用改,代價是型別安全靠自己顧,忘了 put 就是執行時才炸

存成頂層 val,這就是 todos 現在的樣子,別走這條,理由下一篇整篇都在講

3 條路都是在解同一件事,東西建出來之後要怎麼交出去,而且真正麻煩的不是設定本身,是設定要餵給別的物件的時候,repository 需要連線字串、HTTP client 需要 base URL,沒有容器就得把 TodoConfig 從 module() 一路傳到最深的那個建構子,這是下一篇 DI 的題目,module() 開頭用 property 讀一次仍然是對的,設定的來源保持只有一個地方

哪些常數該搬,哪些不該

day 16 結尾點名了 3 個東西,port 8080、log level、call id 的長度,實際做下來 3 個各去了不同的地方,只有 1 個進 application.yaml,判斷的標準只有一條,這個值換一台機器跑會不會不一樣

port 跟 host 搬,同一份程式在本機、CI、容器裡要開不同的 port,這是設定的定義本身

2 個 header 的名字搬,X-Request-Id 跟 X-Response-Time 是給外面看的,Cloudflare 用 CF-Ray、AWS ALB 用 X-Amzn-Trace-Id、公司內部可能統一叫 X-Correlation-Id,同一份程式部署到不同環境就是會不一樣

log level 搬,但不是搬進 application.yaml,logback 在 Ktor 的設定系統之外,它自己啟動、自己找 logback.xml,讀不到 ApplicationConfig 裡的任何東西,好消息是 logback 本身就支援變數,改 src/main/resources/logback.xml 一個地方,<root> 那行寫死的 INFO 換掉

<root level="${LOG_LEVEL:-INFO}">
    <appender-ref ref="STDOUT"/>
</root>

${VAR:-default} 是 logback 的語法,冒號減號跟 Ktor 的單冒號不一樣,兩套系統各有各的寫法,logback.xml 也在 jar 裡,一樣要 ./gradlew installDist 重跑

LOG_LEVEL=WARN build/install/todo-api/bin/todo-api

跑起來整個 terminal 一行都沒有,連 Responding at 都不見了,因為那也是 INFO,打一個請求進去也一樣安靜

CALL_ID_MAX_LENGTH 不搬,day 16 結尾說要搬 call id 的長度,這裡改口,那個 64 是一道防線,擋的是有人塞十萬字元的 id 進 log 檔,它跟環境無關,正式環境調寬也沒有任何好處,留在 Logging.kt 當 const val 就好

TITLE_MAX_LENGTH 不搬,day 14 定的 100 個字是 API 合約的一部分,測試裡有一個 title at the max length is accepted 專門在驗邊界,搬進設定檔就表示同一個請求在 staging 通過,在正式環境有可能會失敗,而 client 完全不知道為什麼,這種「換一台機器行為就不一樣」的東西,正是設定檔不該碰的,判斷標準跟前面剛好反過來,因為它面對的是 API 的使用者而不是部署的人

機敏設定一律不進版控,application.yaml 是要 commit 的,資料庫密碼、API token 寫進去就是把它公開了,git 的歷史還刪不乾淨,做法是在 yaml 只寫參照

database:
  password: '$DB_PASSWORD'

刻意不給預設值,這樣忘了設就起不來,總比默默用一個空字串連上去好,真正的值放在部署平台的 secret 機制裡,Kubernetes 的 Secret、AWS 的 Parameter Store、GitHub Actions 的 repository secret 都是同一件事,day 16 講的是 log 洩漏,這裡是設定洩漏,2 個一起看的話規則其實一樣,機敏資料只能待在它該待的地方

環境變數也不是保險箱,ps eww 看得到、/proc/PID/environ 讀得到、docker inspect 印得出來,crash dump 裡也可能整份躺在那裡,它解決的是「不要 commit 進 repo」,不是「沒有人看得到」,同一台機器上的其他行程該擋還是要擋,本機開發的那份值也要有地方放,常見的做法是一個不進版控的 .env 配 direnv,或是 IDE 的 run configuration,重點跟上面一樣,值不在 repo 裡,只有需要的人跟需要的機器拿得到

這件事台灣剛發生過。2026-08-27,部署平台 Zeabur 偵測到一組內部服務憑證遭到未授權存取,攻擊者用那組憑證取得了部分使用者專案的環境變數紀錄,外洩清單裡有 OpenAI、Anthropic、OpenRouter、GitHub 的 API key,AWS 跟 Cloudflare 的憑證,PostgreSQL、MySQL、MongoDB 的密碼,還有 JWT secret 跟 Stripe key,官方確認 Anthropic、OpenAI、OpenRouter 3 家的憑證有被實際盜用,有人一覺醒來帳單多了一截,這些值一個都沒有進版控,前面講的規則全做到了,還是外流了,因為它們交給平台保管,平台被攻破就一起被拿走

測試整個換了一輪

這一節要動的檔案先列出來,src/test/kotlin/com/cashwu/todo/ 底下

  • 新增 TestApp.kt,放測試共用的設定與 helper
  • 新增 ConfigurationTest.kt,這篇的 6 個新測試
  • 新增 src/test/resources/ 底下 2 份故意壞掉的 yaml
  • 改 ApplicationTest.kt、CallLoggingTest.kt、RequestTimingTest.kt、RequestValidationTest.kt、StatusPagesTest.kt、TodoRoutesTest.kt

第一關,編譯過不了

改完 module() 之後跑 ./gradlew test

> Task :compileTestKotlin FAILED
e: file:///Users/cash/Downloads/ktor/src/test/kotlin/com/cashwu/todo/CallLoggingTest.kt:126:40 Unresolved reference 'REQUEST_ID_HEADER'.
e: file:///Users/cash/Downloads/ktor/src/test/kotlin/com/cashwu/todo/CallLoggingTest.kt:195:49 Unresolved reference 'REQUEST_ID_HEADER'.
...
e: file:///Users/cash/Downloads/ktor/src/test/kotlin/com/cashwu/todo/CallLoggingTest.kt:228:46 Unresolved reference 'REQUEST_ID_HEADER'.

測試會失敗,全部在 CallLoggingTest.kt,因為 Logging.kt 裡那個 const val REQUEST_ID_HEADER 被拿掉了,測試檔還在用它,新增 src/test/kotlin/com/cashwu/todo/TestApp.kt 把常數補回來

val todoTestConfig: TodoConfig = ApplicationConfig("application.yaml").property("todo").getAs()

val REQUEST_ID_HEADER: String = todoTestConfig.requestIdHeader

fun ApplicationTestBuilder.todoApplication(developmentMode: Boolean = false) {
    configure()
    serverConfig { this.developmentMode = developmentMode }
}

常數從 main 搬到 test,值直接從設定檔讀,CallLoggingTest.kt 那 9 行一個字都不用動,而且 yaml 改了 header 名字測試會自動跟上,todoApplication() 是下一關要用的,先一起放進來

第二關,module() 全部換掉

編譯過了再跑一次,換成執行時失敗,凡是呼叫 module() 的測試全部倒下,同一個訊息

io.ktor.server.config.ApplicationConfigurationException: Property todo not found.

原因是 testApplication 不會去讀 application.yaml,它的 createTestEnvironment 給的是一份 MapApplicationConfig("ktor.deployment.environment" to "test"),使用者沒有自己設 config 的話還會被換成一個空的 MapApplicationConfig()。以前 module() 不讀設定,這件事沒有影響,現在讀了就直接炸

TestApplicationBuilder 有一個現成的 API 解決這件事,configure(vararg configPaths: String, overrides: ...),不帶參數呼叫 configure() 就是 ConfigLoader.loadAll(),跟正式啟動走同一條路,找到的就是 src/main/resources/application.yaml (main 的資源目錄本來就在測試的 classpath 上,day 16 講 logback.xml 時提過),要在測試裡蓋掉某幾個 key 就用 overrides 那個參數,上面的 todoApplication() 包的就是它

要注意的是 configure() 之後不能再寫 application { module() }

io.ktor.server.application.DuplicatePluginException: Please make sure that you use unique name for the plugin and don't install it twice. Conflicting application plugin is already installed with the same key as `CallId`

因為 yaml 裡的 ktor.application.modules 已經讓 Ktor 載入過一次 module() 了,手動再叫一次就是裝 2 遍,所以是整段換掉,不是在前面補一行

application {
    module()
}

換成

todoApplication()

一共 36 處,分佈在 6 個檔案

  • ApplicationTest.kt 2 處
  • CallLoggingTest.kt 2 處,其中一處是 todoApp() 這個 helper 的本體,改成 private fun ApplicationTestBuilder.todoApp() = todoApplication(),用到它的測試都不用動
  • RequestTimingTest.kt 2 處
  • RequestValidationTest.kt 6 處
  • StatusPagesTest.kt 11 處
  • TodoRoutesTest.kt 13 處

有一處不能直接換,CallLoggingTest.kt 那個 a log written inside the handler shares the call id,它是在 module() 上面再加一條路由

application {
    module()
    routing {
        get("/inside") { ... }
    }
}

module() 現在由 yaml 載入,這裡要拆成 2 段,先 todoApplication() 把設定跟 module 準備好,再用一個 application { } 把那條路由補上去

todoApplication()
application {
    routing {
        get("/inside") { ... }
    }
}

第三關,StatusPagesTest.kt 那個驗訊息不外流的測試

換完再跑一次,還剩一個測試失敗,是 day 15 那個 an unexpected exception responds 500 without the message,它沒有用 module(),前面那 36 處根本沒動到它

StatusPagesTest > an unexpected exception responds 500 without the message() FAILED
    org.opentest4j.AssertionFailedError: Expected value to be false.

斷言是 assertFalse(response.bodyAsText().contains("hunter2")),訊息看不出所以然,但這個測試從 day 15 起就在,一直通過,這次一改就失敗,而且失敗得很有道理,看 TestApplication.kt 就知道為什麼

serverConfig(environment) {
    applicationModules.forEach { module(it) }
    parentCoroutineContext += job
    watchPaths = emptyList()
    developmentMode = true
    this@TestApplicationBuilder.applicationProperties(this)
}

testApplication 預設把 development mode 打開,從 day 03 寫第 1 個測試起就是這樣,只是以前沒有任何一行程式碼在乎這個旗標,所以看不出來。現在 ErrorHandling.kt 開始讀它了,測試環境跟正式環境的行為就分岔了

修法是讓測試自己講清楚要哪一邊,StatusPagesTest.kt 裡有 2 個測試不走 module(),各自在測試裡手動裝 ContentNegotiation 跟 StatusPages,就是上面那個失敗的,跟 a cancellation is not swallowed by the catch all。把那段抽成一個 probeApp,順便讓它收 development mode

private fun ApplicationTestBuilder.probeApp(
    developmentMode: Boolean = false,
    block: Route.() -> Unit,
) {
    serverConfig { this.developmentMode = developmentMode }
    application {
        install(ContentNegotiation) { json() }
        install(StatusPages) { todoStatusPages() }
        routing { block() }
    }
}

那 2 個測試的 application { ... } 換成 probeApp { get("/boom") { ... } },預設值把它們固定在關閉,斷言的 details 是空的,再補一個 development mode puts the exception into the details,用 probeApp(developmentMode = true),斷言 details 裡有那句 RuntimeException: 資料庫密碼是 hunter2。2 個測試把 if 的兩邊都蓋住,方向寫反其中一個就會失敗

第四關,新開 ConfigurationTest.kt

先準備 2 份故意壞掉的設定,放在 src/test/resources/,它們只給測試用,不會進 jar 的正式設定路徑

config-required-env.yaml,裡面那個變數不會有人設

todo:
  requestIdHeader: '$A_HEADER_NOBODY_SETS'
  responseTimeHeader: 'X-Response-Time'

config-missing-field.yaml,少了一個欄位

todo:
  requestIdHeader: 'X-Request-Id'

接著新增 src/test/kotlin/com/cashwu/todo/ConfigurationTest.kt,2 個 helper 先放進來

class ConfigurationTest {

    private inline fun <T> withProperty(key: String, value: String, block: () -> T): T {
        val previous = System.getProperty(key)
        System.setProperty(key, value)
        try {
            return block()
        } finally {
            if (previous == null) System.clearProperty(key) else System.setProperty(key, previous)
        }
    }

    private fun todoConfig(path: String): TodoConfig =
        ApplicationConfig(path).property("todo").getAs()

todoConfig() 把「讀一份設定的 todo 節點、變成 TodoConfig」包成一行,6 個測試都靠它。withProperty 暫時設一個 system property,finally 那段負責還原,沒有還原的話後面的測試會拿到被污染的值

它走的就是前面說的那條路,getSystemPropertyOrEnvironmentVariable 先看 system property 再看環境變數,而 System.getenv() 是唯讀的,測試改不了環境變數

前面 2 個測試不起 server,直接對設定檔本身斷言

    @Test
    fun `the todo node maps onto the data class`() {
        assertEquals(
            TodoConfig("X-Request-Id", "X-Response-Time"),
            todoConfig("application.yaml"),
        )
    }

    @Test
    fun `an environment variable replaces the default value`() {
        withProperty("REQUEST_ID_HEADER", "X-Correlation-Id") {
            val config = todoConfig("application.yaml")
            assertEquals("X-Correlation-Id", config.requestIdHeader)
            assertEquals("X-Response-Time", config.responseTimeHeader)
        }
    }

第 1 個一句話講完「$VAR:default 的預設值有沒有生效」,TodoConfig 是 data class,assertEquals 比的是 2 個欄位的值,第 2 個驗覆蓋,順便斷言另一個欄位不受影響,替換是一個一個值做的,不是整份換掉

接著 2 個驗證失敗

    @Test
    fun `a reference with no default and no variable fails to load`() {
        val failure = assertFailsWith<ApplicationConfigurationException> {
            todoConfig("config-required-env.yaml")
        }
        assertTrue(failure.message!!.contains("A_HEADER_NOBODY_SETS"))
    }

    @Test
    fun `a node that is missing a field fails to decode`() {
        val failure = assertFailsWith<Exception> {
            todoConfig("config-missing-field.yaml")
        }
        assertTrue(failure.message!!.contains("responseTimeHeader"))
    }

2 個都不只斷言「有丟例外」,還斷言訊息裡有那個名字,這才是前面說的「錯誤訊息要把出問題的東西指出來」,前一個斷言的是 ApplicationConfigurationException,那是 Ktor 自己的型別,後一個刻意只寫 Exception,因為丟出來的是 kaml 的 MissingRequiredPropertyException,斷言了具體型別,哪天換掉 YAML backend 這個測試就要跟著改,斷言訊息內容則不用

最後 2 個起 server,驗設定真的有接到行為上

    @Test
    fun `the header name in the config is the one that comes back`() = testApplication {
        todoApplication()

        val response = client.get("/")

        assertNotNull(response.headers["X-Request-Id"])
        assertNotNull(response.headers["X-Response-Time"])
    }

    @Test
    fun `overriding the header names changes the response headers`() = testApplication {
        withProperty("REQUEST_ID_HEADER", "X-Correlation-Id") {
            withProperty("RESPONSE_TIME_HEADER", "X-Elapsed") {
                todoApplication()

                val response = client.get("/")

                assertNotNull(response.headers["X-Correlation-Id"])
                assertNotNull(response.headers["X-Elapsed"])
                assertNull(response.headers["X-Request-Id"])
                assertNull(response.headers["X-Response-Time"])
            }
        }
    }
}

前一個驗證的是預設值那條路,後一個把 2 個變數同時蓋掉,除了斷言新名字在,也斷言舊名字是 null,不然「2 個都送」也會通過

第 2 個有一個坑,withProperty 一開始寫成普通函式,設完 property 就呼叫 todoApplication() 然後在外面發請求,測試失敗,回來的還是舊的 header 名字,因為 testApplication 是延遲建立的,todoApplication() 只是把設定登記起來,真正讀 yaml 的時機是第 1 次碰 client 的時候,那時 property 早就被 finally 還原了,所以 client.get("/") 跟斷言都要在 withProperty 的 block 裡面,helper 本身也要加 inline,不然 block 裡呼叫不了 client.get() 這種 suspend 函式

跑測試

./gradlew test

如果原始碼跟測試都沒改過,Gradle 會判定 up-to-date 直接跳過,畫面上只會有 BUILD SUCCESSFUL 跟 6 actionable tasks: 6 up-to-date,一個測試名字都看不到,day 02 設的那個 testLogging { events("passed", "failed") } 只有在 task 真的執行時才印東西。要強制重跑

./gradlew test --rerun-tasks

或是只把測試結果清掉重跑

./gradlew cleanTest test

跟 Relix 的對照

Relix day 25 手刻過同一件事,3 層覆蓋,「程式碼預設值 / DSL 設定」→「設定檔 (application.properties)」→「環境變數 (RELIX_PORT=9090)」,RelixConfig 是一個 mutable class,applyProperties() 跟 applyEnv() 分開呼叫,順序由 Relix { } 決定

Ktor 這邊也是 3 層,但每一層裝的東西不一樣,Relix 最底下那層是程式碼裡的 DSL,Ktor 這邊是設定檔、system property、command line,DSL 那一層在 EngineMain 的路線上根本不存在,embeddedServer(Netty, port = 8080) 這種寫死在程式裡的預設值一換成 EngineMain 就沒有了,少一層反而單純,程式碼裡不會有第 2 個 8080 跟設定檔打架

環境變數的位置也不一樣,Relix 是設定套用完之後再跑一次 applyEnv() 整批覆蓋,Ktor 是在 YAML 解析的當下逐個值替換,替換完那個值就是最終值,後面沒有第 2 次機會,所以 Relix 的環境變數名字是另外一套 (RELIX_PORT 對應 port),得在 applyEnv() 裡一個一個對照,Ktor 則是設定檔自己決定要讀哪個變數,port: "$PORT:8080" 寫成 port: "$MY_WEIRD_PORT:8080" 也可以,對照表就是設定檔本身,代價是設定檔沒寫 $VAR 的欄位,環境變數就完全蓋不到

錯誤處理兩邊的結論一樣,Relix 那篇的原話是「丟 exception,如果 ops 設了 RELIX_PORT=abc,他預期 port 會被覆蓋,默默 fallback 到預設值會讓問題藏起來」,Ktor 的 resolveReference 找不到必要變數時丟 ApplicationConfigurationException,getAs<T>() 少欄位時丟 kaml 的例外,2 個都在啟動當下就結束,main 的下一行不會執行

差最多的是型別轉換,Relix 的 applyProperties() 是 5 個欄位各寫一段轉型加 throw,toIntOrNull()、toLongOrNull()、toBooleanStrictOrNull() 各配一個 ?: throw IllegalArgumentException(...),只有 host 是字串直接指派,加上 applyEnv() 那份幾乎一樣的,2 個函式 40 幾行,加一個欄位就再抄 2 段,enum 那個還得自己用 LogLevel.entries.find 查

Ktor 這邊是一個 @Serializable 的 data class 加一行 property<TodoConfig>("todo"),型別轉換、缺欄位、錯誤訊息全部由 kotlinx.serialization 處理,day 12 裝 ContentNegotiation 時加的那個 plugin.serialization 編譯器外掛,在這裡又被用了一次,同一套機制不只能拿來處理 HTTP body

Relix 那篇最後還做了 graceful shutdown,把 addShutdownHook 跟 adapter.stop(5) 接起來,Ktor 的 EmbeddedServer.start() 第 1 行就是 addShutdownHook { stop() },等待秒數則是 application.yaml 的 ktor.deployment.shutdownGracePeriod 跟 shutdownTimeout 2 個 key,由 loadCommonConfiguration 讀進 engine 的設定,同一件事,一邊是自己寫的 start() / stop() 加上 adapter 多開的 2 個參數,一邊是設定檔的 2 行


小結

application.yaml 加上 EngineMain.main(args),port、host、module 名稱、development mode 全部離開程式碼,YAML 跟 HOCON 都是 ServiceLoader 找出來的 ConfigLoader,兩份都放的話誰贏由 classpath 順序決定,環境變數靠 YAML 的 $VAR:default 語法在解析當下替換,System.getProperty 排在 System.getenv 前面,少一個沒有預設值的變數會讓 server 起不來,另外 2 條覆蓋路徑是 -Dktor.xxx 的 system property 跟 -port= 的命令列參數,後者贏過前者、前者贏過設定檔

developmentMode 實際改變的是啟動訊息跟 pipeline 的執行器,一般 route handler 丟出的例外會先被 defaultEnginePipeline 的 Call phase 接住,不會到外層的官方 HTML 例外頁,錯誤訊息是自己接的,call.application.developmentMode 決定 details 裝不裝例外,設定用 @Serializable 的 data class 一次讀完,欄位少一個就在啟動當下失敗,log level 留在 logback.xml 用 ${LOG_LEVEL:-INFO},TITLE_MAX_LENGTH 跟 CALL_ID_MAX_LENGTH 留在程式碼裡

測試那邊 application { module() } 全部換成 configure(),順便發現 testApplication 預設把 development mode 打開,day 15 那個驗訊息不外流的測試因此失敗了一次


下一篇

設定讀出來之後是一個 TodoConfig 物件,現在它躺在 module() 的第 1 行,誰要用誰就得從那裡拿,todos 這個 top-level 的 MutableList 也一樣,TodoRoutes.kt 直接抓全域變數在用,這 2 件事是同一個問題的兩面,東西建出來之後要交給誰、怎麼交

下一篇做 Dependency Injection,Ktor 3.2 之後有官方的 DI,dependencies { provide { ... } } 跟 resolve<T>(),把設定跟資料的來源都從全域變數挪進 DI 容器裡


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 16 CallLogging、CallId 與 MDC
下一篇
Kotlin Ktor 實戰 101 Day 18 官方 DI Plugin 入門
系列文
Kotlin Ktor 實戰 101 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言