iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Software Development

Kotlin Ktor 實戰 101系列 第 18 篇

Kotlin Ktor 實戰 101 Day 18 官方 DI Plugin 入門

  • 分享至 

  • xImage
  •  

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

day 17 結尾說「這 2 件事是同一個問題的兩面,東西建出來之後要交給誰、怎麼交」,這篇會用 Ktor 3.2 起內建的 ktor-server-di 回答它,順便逐筆處理 day 12 到 day 17 欠下的 3 筆債

這篇要完成什麼

  • 裝 ktor-server-di,搞清楚 dependencies、provide、resolve 三者的關係
  • DependencyKey 是什麼、covariant key 會自動註冊哪些東西,開 TRACE log 直接看
  • 兌現 day 13 的承諾,把 Instant.now() 換成注入進來的 Clock
  • day 17 讀出來的 TodoConfig 從區域變數搬進容器
  • day 12 那個 top-level 的 todos 搬進容器,4 個測試 class 的重置程式碼因此消失
  • 少一個相依會怎樣,2 種失敗模式的實際輸出
  • DI 沒有解決什麼,用併發實測講清楚
  • 跟 Relix day 26 手刻的容器逐條對照,順便修正當時的一個說法
  • 7 個新測試

裝 ktor-server-di

build.gradle.kts 的 dependencies 區塊,接在 server.config.yaml 後面

implementation(ktorLibs.server.di)

day 17 在 accessor 上踩過一次坑,這次先翻 ktor-version-catalog-3.5.2.toml

server-di = {group = "io.ktor", name = "ktor-server-di", version.ref = "ktor" }

alias 就是 server-di,所以 accessor 是 server.di,不像 day 17 那次得先猜錯一輪,也沒有 server-callId 那種 camelCase 例外

整個專案沒有寫過 install(DI),plugin 卻確實裝上去了,原因在 DependencyRegistry.kt 這幾行

public var Application.dependencies: DependencyRegistry
    get() {
        if (!attributes.contains(DependencyRegistryKey)) {
            install(DI)
        }
        return attributes[DependencyRegistryKey]
    }

第 1 次碰 application.dependencies 的時候,如果 attributes 裡還沒有 registry,它就自己 install(DI)。dependencies { } 那個 DSL 入口也只是 dependencies.action() 的包裝,所以寫 dependencies { provide<Clock> { ... } } 的當下 plugin 就進去了,這跟前面 17 篇的每一個 plugin 都不一樣,install(DI) { } 只有在要改 conflictPolicy、keyMapping、reflection 這些設定的時候才需要自己寫

provide 跟 resolve

調整後的 Application.kt 的 module() 開頭長這樣

fun Application.module() {
    dependencies {
        provide<TodoConfig> { this@module.property("todo") }
        provide<Clock> { Clock.systemUTC() }
        provide<MutableList<Todo>> { defaultTodos.toMutableList() }
    }

    val config: TodoConfig by dependencies
    val clock: Clock by dependencies
    val todos: MutableList<Todo> by dependencies

    // ... 六個 install 不變
    routing {
        get("/") {
            call.respondText("Hello, Ktor!")
        }
        todoRoutes(clock, todos)
    }
}

Application.kt 需要多一行 java.time.Clock 的 import,io.ktor.server.plugins.di.dependencies 也要

直覺的寫法是 provide<TodoConfig> { property("todo") },基本上跟 day 17 寫的差不多,就只是換個位置,不過這樣子寫的話,編譯會有問題,前面要加上 this@module. 才不會錯

 'fun <reified E> Application.property(key: String): E' cannot be called in this context with an implicit receiver. Use an explicit receiver if necessary.

property 是 Application 的 extension function,而 Application 跟 DependencyRegistry 都掛了 @KtorDsl,那是一個 @DslMarker

@DslMarker
@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPEALIAS, AnnotationTarget.TYPE, AnnotationTarget.FUNCTION)
public annotation class KtorDsl

DslMarker 的規則是同一個 marker 只留最近的那個 receiver,進到 dependencies { } 之後 DependencyRegistry 蓋掉 Application,property 就找不到隱含 receiver 了,這個機制本來是為了擋「在 routing { } 裡不小心呼叫到 install」這類錯誤,所以需要寫明 this@module.

provide<T> { } 註冊的是「怎麼生出這個東西」,取用有 2 種寫法,差別不只是語法

  • val clock: Clock by dependencies 是 property delegation,第 1 次讀屬性的時候才真的解析,而且會順手把這個 key 登記進 registry.requirements,啟動階段會被驗證
  • dependencies.resolve<Clock>() 是 suspend 函式,當場拿到值,不登記 requirement

兩者的差別在下面「少一個相依會怎樣」那會變成 2 種完全不同的失敗

key 是一個 DependencyKey

public data class DependencyKey(
    public val type: TypeInfo,
    public val name: String? = null,
    public val qualifier: Any? = null,
)

TypeInfo 本身帶完整的 Kotlin 型別,List<String> 跟 List<Int> 的精確 key 確實不同,不過預設的 DefaultKeyCovariance 包含 RawTypes,兩者還是會各自衍生出原始的 List key,放進同一個容器仍會衝突,要同時註冊,可以替其中一組加上 name,或自行 install(DI),把 keyMapping 換成不含 RawTypes 的規則

註冊一個型別的時候,容器同時登記了一票衍生的 key,這件事開 TRACE log 看最直接,logback.xml 暫時加一行

<logger name="io.ktor.server.plugins.di" level="TRACE"/>

啟動之後 (為了看得清楚,我把那幾行重複的 Conflicting keys 拿掉了)

09:45:39.762 DEBUG [no-call-id] i.k.s.p.d.MapDependencyProvider -- Provided com.cashwu.todo.TodoConfig at com.cashwu.todo.ApplicationKt.main(Application.kt:21)
09:45:39.765 TRACE [no-call-id] i.k.s.p.d.MapDependencyProvider -- Covariant keys: com.cashwu.todo.TodoConfig, class com.cashwu.todo.TodoConfig, com.cashwu.todo.TodoConfig?, class com.cashwu.todo.TodoConfig
09:45:39.770 DEBUG [no-call-id] i.k.s.p.d.MapDependencyProvider -- Provided java.time.Clock at com.cashwu.todo.ApplicationKt.main(Application.kt:21)
09:45:39.772 TRACE [no-call-id] i.k.s.p.d.MapDependencyProvider -- Covariant keys: java.time.Clock, class java.time.Clock, java.time.Clock?, class java.time.Clock, java.time.InstantSource, class java.time.InstantSource, java.time.InstantSource?, class java.time.InstantSource
09:45:39.777 DEBUG [no-call-id] i.k.s.p.d.MapDependencyProvider -- Provided kotlin.collections.MutableList<com.cashwu.todo.Todo> at com.cashwu.todo.ApplicationKt.main(Application.kt:21)
09:45:39.781 TRACE [no-call-id] i.k.s.p.d.MapDependencyProvider -- Covariant keys: kotlin.collections.List<com.cashwu.todo.Todo>, class kotlin.collections.List, kotlin.collections.List<com.cashwu.todo.Todo>, class kotlin.collections.List, kotlin.collections.Collection<com.cashwu.todo.Todo>, class kotlin.collections.Collection, kotlin.collections.Collection<com.cashwu.todo.Todo>, class kotlin.collections.Collection, ...

註冊 Clock 的同時多了 java.time.InstantSource,那是 Clock 的介面,註冊 MutableList<Todo> 多了 List<Todo>、Collection<Todo> 這一串,尾巴那個 ... 是 log 自己截的,MapDependencyProvider 有一個 COVARIANT_LOG_LIMIT = 8。規則寫在 DependencyKeyCovariance.kt

public val DefaultKeyCovariance: DependencyKeyCovariance =
    Supertypes * Nullables * OutTypeArgumentsSupertypes * RawTypes

4 個規則相乘,所以每個型別都會生出「自己 / 父型別」乘上「非 null / 可 null」乘上「帶型別參數 / 原始型別」的組合,有一個例外要注意,TodoConfig 那一行沒有出現 Any,因為 Types.jvm.kt 有一張 ignoredSupertypes,把 Any、Serializable、Comparable、AutoCloseable 這些擋掉了,不然每一個 provide 都會在 Any 這個 key 上打架

換掉測試的時鐘

day 13 的原話是「時間來源寫死就沒辦法在測試裡控制它,正式的做法是把時鐘抽成可以從外面注入的相依物件,day 18 做 DI 的時候會一起處理」

JDK 本來就有現成的抽象,java.time.Clock,而且 Instant.now() 有一個收 Clock 的多載,TodoRoutes.kt 需要改 2 個地方,簽名多 2 個參數,Instant.now() 換成 Instant.now(clock),檔案頂端多一行 java.time.Clock 的 import

fun Route.todoRoutes(clock: Clock, todos: MutableList<Todo>) {
    // ...
        post {
            val request = call.receive<CreateTodoRequest>()
            val todo = Todo(
                id = (todos.maxOfOrNull { it.id } ?: 0) + 1,
                title = request.title,
                done = request.done,
                createdAt = Instant.now(clock),
            )

正式跑的時候拿到的是 Clock.systemUTC(),行為跟以前一模一樣

實際執行程式看看

curl -i -s -X POST -H "Content-Type: application/json" \
  -d '{"title":"倒垃圾"}' localhost:8080/todos
HTTP/1.1 201 Created
X-Request-Id: w0su0xr7uilm
X-Response-Time: 11ms
Content-Length: 84
Content-Type: application/json

{"id":4,"title":"倒垃圾","done":false,"created_at":"2026-08-31T01:39:56.451845Z"}

測試那邊就不一樣了,TestApp.kt 先加 2 行

val FIXED_NOW: Instant = Instant.parse("2026-10-10T12:00:00Z")

fun fixedClock(instant: Instant): Clock = Clock.fixed(instant, ZoneOffset.UTC)

instant 刻意不給預設值。給了的話 fixedClock() 這個沒有參數的寫法會到處出現,測試裡看不出時間是幾號,而 FIXED_NOW 是一個未來的時間點,這件事只寫在 TestApp.kt 那一行。多數測試不在乎時間是幾號,只要每次都一樣就好,這種寫 fixedClock(FIXED_NOW) 一目了然;真正在乎「跟現在的先後」的測試,就一定要自己算一個時間傳進來,不能拿 FIXED_NOW 充數。day 27 那個 JWT 過期的測試會示範不遵守這條的下場,測試通過了,但擋下 token 的根本不是過期

day 17 那個 todoApplication() 也要跟著多收一個 clock,簽章長什麼樣先賣個關子,因為它不是「多一個參數傳下去」這麼單純,下一節整節在講這件事,這裡先當它已經收得到

day 13 為了 Instant.now() 不可預測,把 POST 的測試從「斷言整串 JSON」退成「解析回物件再斷言區間」,現在有了假時鐘就可以改回去,TodoRoutesTest 的 post todos creates todo and responds created 現在是這樣

todoApplication(clock = fixedClock(FIXED_NOW))

val response = client.post("/todos") {
    header(HttpHeaders.ContentType, ContentType.Application.Json)
    setBody("""{"title":"倒垃圾"}""")
}
...

assertEquals(HttpStatusCode.Created, response.status)
assertEquals(
    """{"id":4,"title":"倒垃圾","done":false,"created_at":"2026-10-10T12:00:00Z"}""",
    response.bodyAsText(),
)

val before = Instant.now() 跟 assertTrue(created.createdAt >= before) 都不見了,回應的每一個位元組都寫在測試裡。這一步順便把 java.time.Instant 跟 kotlin.test.assertTrue 2 個 import 也拿掉了

假時鐘第一次塞不進去

上面那個 todoApplication(clock = fixedClock(FIXED_NOW)) 看起來很順,第 1 版寫出來卻沒有生效,回應裡的 created_at 還是當下的系統時間

官方文件的測試範例是這樣寫的

fun test() = testApplication {
  application {
    dependencies.provide<MyService> {
      MockService()
    }
    loadServices()
  }
}

重點在最後那行 loadServices(),文件說它是「the function that bootstraps your application's modules」,而且「equivalent to what is listed under modules in your application.yaml」,也就是說這套做法預設 module 是由測試自己叫的,todo-api 從 day 17 起把 module 名字寫進了 application.yaml,測試用 configure() 讀同一份設定,module 就變成 Ktor 自己去載,測試的 application { } 排在它後面

順序寫在 EmbeddedServerJvm.kt

private val modules: List<DynamicApplicationModule> get() =
    environment.moduleConfigReferences.map(::dynamicModule) +
        rootConfig.modules.map { module -> module.toDynamicModuleOrNull() ?: module.wrapWithDynamicModule() }

設定檔裡的 module 一定排在程式碼裡註冊的 module 前面,這個順序沒有開放調整,而測試環境的衝突策略是「先來的贏」

public val IgnoreConflicts: DependencyConflictPolicy = DependencyConflictPolicy { prev, current ->
    when (val result = DefaultConflictPolicy.resolve(prev, current)) {
        is Conflict, Ambiguous -> KeepPrevious
        else -> result
    }
}

DI plugin 在偵測到 engine 是 TestApplicationEngine 的時候就換成這個策略,註解寫得很白,「During testing, we ignore conflicts by providing dependencies BEFORE loading modules」,2 件事湊在一起,測試的 provide<Clock> 永遠晚一步,永遠是 KeepPrevious

還有第 2 層原因,module() 是用 by dependencies 拿到 clock 再傳進 todoRoutes(clock, todos) 的,值在 module() 執行的當下就固定住了,就算之後把 map 裡的項目換掉也追不回來

解法是讓那一次 module() 由測試自己叫,configure 有一個 overrides 參數可以蓋設定的 key,把 module 清單蓋成空的就行

fun ApplicationTestBuilder.todoApplication(
    developmentMode: Boolean = false,
    clock: Clock? = null,
) {
    if (clock == null) {
        configure()
    } else {
        configure(overrides = { put("ktor.application.modules.size", "0") })
        application { dependencies.provide<Clock> { clock } }
        application { module() }
    }
    serverConfig { this.developmentMode = developmentMode }
}

ktor.application.modules.size 那個 key 是 MapApplicationConfig 存 list 的方式,size 設 0 之後 getList() 回空清單,Ktor 就不會自己載 module,接著 2 個 application { } 依序執行,假的先進去,這樣沒有動到 application.yaml,day 17 那幾個對真實設定檔斷言的測試也不受影響

這條規則要有測試綁住,這篇的新測試都放在同一個新檔案,src/test/kotlin/com/cashwu/todo/DependencyInjectionTest.kt

檔案層級先放一個 helper,後面每一節都會用到

private suspend fun HttpClient.createTodo(title: String) =
    post("/todos") {
        header(HttpHeaders.ContentType, ContentType.Application.Json)
        setBody("""{"title":"$title"}""")
    }

POST 一筆 todo 這件事在這個檔案裡會重複很多次,抽出來之後每個測試只剩下自己要斷言的那幾行

@Test
fun `a clock provided before the module decides created_at`() = testApplication {
    todoApplication(clock = fixedClock(FIXED_NOW))
    // ... 斷言 created_at 是 2026-10-10T12:00:00Z
}

@Test
fun `a clock provided after the module is ignored`() = testApplication {
    todoApplication()
    application {
        dependencies.provide<Clock> { fixedClock(FIXED_NOW) }
    }

    val response = client.createTodo("倒垃圾")

    assertEquals(HttpStatusCode.Created, response.status)
    val created = Json.decodeFromString<Todo>(response.bodyAsText())
    assertNotEquals(FIXED_NOW, created.createdAt)
}

第 2 個測試斷言的是「沒有生效」,看起來很怪,但它記錄的是上面那條規則,測試環境的衝突策略是 IgnoreConflicts,同一個型別被註冊 2 次時保留先註冊的那個,晚來的 provide<Clock> 會被丟掉

這條規則沒有寫在文件裡,只寫在原始碼的註解,哪天 Ktor 把這個策略換掉,改成晚註冊的覆蓋先註冊的,這個測試就會失敗,而那正是我們該知道的時候,不然 todoApplication(clock = ...) 那條路會在某次升級之後安靜地變成多餘的

TodoConfig 設定

day 17 讀出來的 TodoConfig 躺在 module() 的第 1 行,誰要用誰就得從那個區域變數拿,現在它是容器裡的一個項目

provide<TodoConfig> { this@module.property("todo") }

module() 自己用的還是同一個 by dependencies 拿到的物件,從行為上看沒有任何差別,ConfigurationTest 那 6 個測試一個字都沒改就通過了,差別在 day 19,TodoRepository 要用到設定的時候不必再從 module() 傳一路傳進去,宣告一個 TodoConfig 參數就好

這一步的好處現在還看不出來,TodoConfig 目前只有 module() 一個使用者,把它放進容器純粹是為了下一篇,真正的驗收要等 day 19

Ktor 還有一條更漂亮的路沒走,ConfigurationDependencyMap 讓設定本身就是一種相依來源,配合 @Property 這個 annotation 可以直接把設定值注進建構子參數

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.VALUE_PARAMETER)
public annotation class Property(val value: String = "")

代價是要走 ktor.application.dependencies 那套 classpath reference 加 reflection 的機制,整個 wiring 從程式碼搬到 yaml

這個系列選的是程式碼裡寫死的 dependencies { },理由有 3 個

  • 一是接線關係看得到,provide<Clock> { Clock.systemUTC() } 就寫在 module() 裡,誰提供了什麼、順序是什麼,讀一遍 module() 就有答案,不用同時開著 yaml 跟原始碼對照
  • 二是 IDE 幫得上忙,型別、參數、Cmd 點進去看實作、改名字全部有效,寫在 yaml 裡的類別名稱只是一串字,打錯要等啟動才知道
  • 三是這篇整篇都在講的那些規則,註冊順序、衝突策略、resolve() 的時機,全部是程式碼層級的行為,wiring 也留在程式碼裡的話,出事的時候看的是同一個地方

反過來說,如果目標是「換掉一個實作不用重新編譯」,例如同一份 jar 在不同環境接不同的 TodoRepository,那 yaml 那條路才是為此設計的,todo-api 沒有這個需求

全域的 todos

day 12 那行 val todos = defaultTodos.toMutableList() 是 top-level 的,整個 JVM 一份,它換成容器裡的一個項目

provide<MutableList<Todo>> { defaultTodos.toMutableList() }

改完之後,測試那邊有 4 段程式碼可以刪掉

TodoRoutesTest.kt、RequestValidationTest.kt、StatusPagesTest.kt 3 個檔案各有一個一模一樣的 @BeforeTest,整段刪掉

@BeforeTest
fun resetTodos() {
    todos.clear()
    todos.addAll(defaultTodos)
}

CallLoggingTest.kt 是同樣那 2 行,但它們住在負責裝 log appender 的 attachAppender() 裡面,只刪那 2 行,函式的其他部分要留著

 @BeforeTest
 fun attachAppender() {
-    todos.clear()
-    todos.addAll(defaultTodos)
     appender.list.clear()
     appender.start()
     root.addAppender(appender)
 }

刪掉之後 defaultTodos 這個 import 在那 4 個檔案裡就沒人用了,IDE 會標灰,順手清掉

這 4 段是全域可變狀態向測試收的稅,todos 是頂層的 MutableList,整個 JVM 只有一份,POST 的測試新增一筆之後那筆就留在裡面,後面每一個測試都受影響,所以每個 class 都得在每個測試之前自己洗一次。現在每一個 testApplication 是一個新的 Application、新的 DI plugin、新的 map,provide 的 lambda 重跑一次就是一份新的 list,測試之間本來就不共用了,這 4 段沒有存在的理由,刪掉之後測試照樣通過

RequestValidationTest.kt 還有 6 個地方直接讀 todos.size,6 個測試各一行,這條路也要換掉,因為那個頂層的 todos 已經不是 application 在用的那份清單了,provide<MutableList<Todo>> { defaultTodos.toMutableList() } 每建一個 application 就發一份新的,POST 進去的資料寫在那份新的裡面,頂層那個 todos 從頭到尾都是 3 筆

不換的話跑起來會有 2 個失敗

RequestValidationTest > title at the max length is accepted() FAILED
    org.opentest4j.AssertionFailedError: expected: <4> but was: <3>
RequestValidationTest > unknown field is dropped before validation runs() FAILED
    org.opentest4j.AssertionFailedError: expected: <4> but was: <3>

2 個斷言 4 的失敗了,另外 4 個斷言 3 的照樣通過,但通過的理由是「那份清單根本沒人動過」,不是「請求真的被擋下來了」,這種比失敗更麻煩,所以要一起換掉,不是只修失敗的那 2 個

相關的數量改成打 API 問,把 helper 放進 TestApp.kt,跟其他測試工具放在一起

suspend fun HttpClient.todoCount(): Int =
    Json.decodeFromString<List<Todo>>(get("/todos").bodyAsText()).size

這裡跟前面那個 createTodo 的放法不一樣,標準是誰在用。createTodo 從頭到尾只有 DependencyInjectionTest.kt 一個檔案在用,就宣告成該檔案的 private,不會出現在別的檔案的自動完成清單裡;todoCount() 是跨檔案的,RequestValidationTest.kt 這 6 行要用,DependencyInjectionTest.kt 等一下也要用,day 25 還會再用一次,所以放 TestApp.kt 而且不加 private。helper 一律往共用檔案丟的話,那個檔案很快就會變成什麼都有的雜物間

6 個測試把相關的 assertion 換掉,要注意前面的 expected 會有所不同,要記得改

     assertEquals(HttpStatusCode.BadRequest, response.status)
-    assertEquals(3, todos.size)
+    assertEquals(3, client.todoCount())

GET /todos 走的是同一個 application、同一份清單,問到的就是那個 application 眼中的數量,順手撿到的好處是斷言從「內部狀態」變成「外部行為」,測試不再需要知道資料存在哪個變數裡

這一輪不用改的是那些自己組 application 的測試,RequestValidationTest.kt 的 install order does not change when validation runs 跟 receive throws with every reason collected、CallLoggingTest.kt 的 /inside、StatusPagesTest.kt 的 probeApp。它們不走 module(),也就拿不到容器裡的東西,但它們的路由都是自己寫的 post("/todos") { ... },沒有用到 todoRoutes(),也沒有碰 todos,所以 todoRoutes() 改成收 2 個參數這件事影響不到它們

隔離本身補一個測試,放在 DependencyInjectionTest.kt

@Test
fun `every application gets its own todo list`() {
    testApplication {
        todoApplication()
        client.createTodo("倒垃圾")
        assertEquals(4, client.todoCount())
    }
    testApplication {
        todoApplication()
        assertEquals(3, client.todoCount())
    }
}

DI 沒有解決什麼

day 12 當時寫了一段警告,「MutableList 不保證並行安全,maxOfOrNull() + 1 和後面的 add 也不是一個不可分割的動作,2 個 POST 同時進來可能拿到相同 id,甚至互相干擾」

那段話現在一個字都不用改,把一個 MutableList 從 top-level 搬進容器,改變的是誰持有它,不是它安不安全,TodoRoutes.kt 裡那 3 行還是原樣

id = (todos.maxOfOrNull { it.id } ?: 0) + 1,
// ...
todos.add(todo)

實際打一次就知道有多明顯,server 起在 8080,清單裡是那 3 筆預設待辦,一次丟 60 個 POST 進去

seq 1 60 | xargs -P 60 -I{} curl -s -o /dev/null -X POST localhost:8080/todos \
  -H 'Content-Type: application/json' -d '{"title":"併發 {}"}'
curl -s localhost:8080/todos | python3 -c "
import sys,json,collections
d=json.load(sys.stdin)
ids=[t['id'] for t in d]
dup=[k for k,v in collections.Counter(ids).items() if v>1]
print('清單長度', len(d), '(預期 63)')
print('不同 id 數', len(set(ids)))
print('重複的 id', sorted(dup))
"

跑兩輪

--- run 1
清單長度 57 (預期 63)
不同 id 數 50
重複的 id [4]
--- run 2
清單長度 55 (預期 63)
不同 id 數 52
重複的 id [4, 40]

3 加 60 應該是 63,2 輪分別只剩 57 跟 55,沒有同步的 ArrayList 同時被多個 thread add,2 個寫入落在同一個位置就會蓋掉其中一個,id 也真的撞了,第 1 輪 57 筆裡只有 50 個不同的 id,重複的只有 4 這一個號碼,換算下來它被發出去了 8 次,第 2 輪則是 4 跟 40 2 個號碼重複,數字每次跑都不一樣,這就是 race condition 的樣子

要修的話有 2 件事要一起做,id 的產生跟資料的寫入必須是同一個不可分割的動作,而且那個動作要有一個明確的持有者,MutableList<Todo> 這個型別沒有地方可以放這件事,任何人用這個 key resolve 到它都可以直接 add,day 19 的 TodoRepository 就是那個持有者,它有方法可以放 lock,也有介面可以在 day 22 換成真的資料庫

順帶說一句,provide<MutableList<Todo>> 本身也不是好的相依宣告,型別只說了「這是一串可以改的 Todo」,沒說它是什麼角色,這篇這樣寫是過渡,下一篇會把它改掉

少一個相依會怎樣

Ktor 的 DI 在啟動階段會驗證,但驗證的範圍比想像中窄,這件事有 2 種完全不同的失敗模式

第 1 種,宣告了 by dependencies 而且真的讀到它,把 provide<Clock> 那行拿掉再啟動

09:40:03.407 INFO  [no-call-id] Application -- Autoreload is disabled because the development mode is off.
Exception in thread "main" io.ktor.server.plugins.di.MissingDependencyException: Could not resolve dependency for `java.time.Clock`
	at io.ktor.server.plugins.di.MapDependencyResolver.onMissing(DependencyResolution.kt:215)
	...
	at com.cashwu.todo.ApplicationKt$module$$inlined$provideDelegate$2.getValue(DependencyRegistry.kt:100)
	at com.cashwu.todo.ApplicationKt.module$lambda$2(Application.kt:31)
	at com.cashwu.todo.ApplicationKt.module$lambda$10(Application.kt:59)
	at io.ktor.server.routing.RoutingRoot$Plugin.install(RoutingRoot.kt:163)

module() 執行到 todoRoutes(clock, todos) 那一行去讀 delegate,當場丟 MissingDependencyException,server 起不來

第 2 種,宣告了但從來沒讀,塞一個沒有人用的 val zone: java.time.ZoneId by dependencies 進 module()

09:40:17.515 ERROR [no-call-id] Application -- Cannot resolve java.time.ZoneId
	at com.cashwu.todo.ApplicationKt.main(Application.kt:21)

09:40:17.515 ERROR [no-call-id] Application -- Dependency resolution failed:
  - java.time.ZoneId: Missing declaration
Exception in thread "main" io.ktor.server.plugins.di.DependencyInjectionException: Some dependencies could not be resolved; check logs for details

這一次走的是另一條路,DI plugin 訂了 ApplicationModulesLoaded 事件,所有 module 跑完之後把 registry.requirements 裡的 key 一個一個 resolve 過去,失敗的收集起來一次報告,訊息還附上宣告的位置,那個 Cannot resolve 後面的 stack trace 是 DependencyReference 特地留下來的

問題是 requirements 只有一個來源

public inline operator fun <reified T> provideDelegate(
    thisRef: Any?,
    prop: KProperty<*>
): ReadOnlyProperty<Any?, T> {
    val key = DependencyKey<T>()
        .also(::require)

只有 by dependencies 這個寫法會呼叫 require,dependencies.resolve<T>() 不會,所以在 handler 裡面 resolve 一個沒註冊的東西,啟動階段完全看不出來

這件事補一個測試盯著,跟前面幾個一樣加在 DependencyInjectionTest.kt 裡,它用到 2 個這個檔案還沒有的 import,java.time.ZoneId 跟 io.ktor.server.plugins.di.resolve,resolve 是 extension function,沒 import 會是 Unresolved reference

@Test
fun `resolve inside a handler is not checked before the server starts`() = testApplication {
    todoApplication()
    application {
        routing {
            get("/zone") {
                call.respondText(call.application.dependencies.resolve<ZoneId>().id)
            }
        }
    }

    assertEquals(HttpStatusCode.Created, client.createTodo("倒垃圾").status)
    assertEquals(HttpStatusCode.InternalServerError, client.get("/zone").status)
}

ZoneId 沒有人 provide 過,但 server 起得來,POST /todos 也照樣回 201,只有真的走到 /zone 的時候才炸,MissingDependencyException 被 day 15 那個 exception<Throwable> 接住轉成 500。這條路徑上線之後可能 3 天都沒有人走到,所以用 by dependencies 不只是語法偏好,它是唯一會被啟動驗證納入的寫法

生命週期

provide 註冊的 lambda 什麼時候跑 ? DependencyInitializer.Explicit 說得很清楚

private fun DependencyResolver.lazyAsyncInit(): Deferred<Any?> =
    withCycleDetection {
        async(start = CoroutineStart.LAZY) { init() }
    }

CoroutineStart.LAZY,而且結果放在一個 AtomicRef<Deferred<Any?>> 裡用 CAS 存進去,所以是「第 1 次要用才建,之後都是同一個」。一個測試把 2 件事一起蓋住,一樣加在 DependencyInjectionTest.kt

@Test
fun `provide is lazy and runs at most once`() = testApplication {
    var builds = 0
    var buildsBeforeResolve = -1
    var first: StringBuilder? = null
    var second: StringBuilder? = null
    todoApplication()
    application {
        dependencies.provide<StringBuilder> {
            builds++
            StringBuilder()
        }
        buildsBeforeResolve = builds
        first = dependencies.resolve<StringBuilder>()
        second = dependencies.resolve<StringBuilder>()
    }

    client.createTodo("倒垃圾")

    assertEquals(0, buildsBeforeResolve)
    assertEquals(1, builds)
    assertSame(first, second)
}

註冊完還是 0,resolve 2 次之後是 1,2 次拿到同一個物件,covariant key 共用的也是同一份,這件事再補一個測試,同樣在 DependencyInjectionTest.kt

@Test
fun `a provided subtype resolves through its supertype`() = testApplication {
    var asMutable: MutableList<Todo>? = null
    var asReadOnly: List<Todo>? = null
    todoApplication()
    application {
        asMutable = dependencies.resolve<MutableList<Todo>>()
        asReadOnly = dependencies.resolve<List<Todo>>()
    }

    client.createTodo("倒垃圾")

    assertSame(asMutable, asReadOnly)
    assertEquals(4, asReadOnly!!.size)
}

provide<MutableList<Todo>> 註冊下去的時候,covariant key 讓 List<Todo> 這個 key 也指到同一份,所以 2 次 resolve 拿到的是同一個物件,assertSame 過得去。要注意 List<Todo> 不是一份唯讀複本,是同一個物件換一個型別看它,POST 進去的第 4 筆從 asReadOnly 也數得到,最後那行斷言的就是這件事

相依什麼時候建、共用幾份講完了,另一端是什麼時候關,DependencyInjectionConfig 的預設值

public var onShutdown: (DependencyKey, Any?) -> Unit = { _, instance ->
    (instance as? AutoCloseable)?.close()
}

ApplicationStopping 的時候,容器把 map 倒過來走一遍,凡是實作 AutoCloseable 的就 close(),官方文件把這個順序稱為「宣告順序的反向」,容器沒有依照實際相依關係做拓撲排序,若 B 依賴 A,就要先宣告 A、再宣告 B,反向關閉時才會先關 B,而且它只關已經建出來的

val instance = registry.getDeferred<Any?>(key).tryGetCompleted() ?: continue

tryGetCompleted() 拿不到就跳過,從頭到尾沒有人 resolve 過的相依根本不存在,也就沒有東西要關

一個測試同時驗這 2 件事,還是放 DependencyInjectionTest.kt,它需要一個關閉時會舉手的相依,先在檔案層級加一個小 class,跟 createTodo 放在一起

private class Probe(private val onClose: () -> Unit) : AutoCloseable {
    override fun close() = onClose()
}

然後是測試本體

@Test
fun `an AutoCloseable dependency is closed when the application stops`() {
    var closedResolved = false
    var closedUntouched = false
    testApplication {
        todoApplication()
        application {
            dependencies.provide<Probe> { Probe { closedResolved = true } }
            dependencies.provide<Probe>("untouched") { Probe { closedUntouched = true } }
            dependencies.resolve<Probe>()
        }

        client.createTodo("倒垃圾")

        assertFalse(closedResolved)
    }

    assertTrue(closedResolved)
    assertFalse(closedUntouched)
}

Probe 的 close() 把旗標打開,testApplication { } 的 block 結束時 application 才停,所以 block 裡面斷言還沒關、外面斷言關了,那個叫 untouched 的具名相依從來沒被 resolve,到最後也沒被關,day 20 接資料庫的時候這一段會派上用場,連線池就是最典型的 AutoCloseable

最後把一件容易誤會的事講清楚,Ktor 的 DI 只有 1 種生命週期

用過 ASP.NET Core 的話會習慣 3 選 1,AddSingleton 整個 process 一份、AddScoped 每個 request 一份、AddTransient 每次拿都新建,Ktor 這邊沒有這個選單,provide 註冊的東西一律是「每個 Application 一份」,第 1 次 resolve 才建,之後都是同一個,也就是上面那個測試量到的行為,ktor-server-di 這個 jar 裡沒有任何跟 scope 有關的型別,官方文件的 DI 章節也沒有這個概念

少了 scoped 這一層在 Ktor 上不太痛,因為那一層本來就有別的東西在做,ASP.NET Core 的 scoped 通常拿來裝「這個請求專屬的狀態」,例如 request id、登入者、一個 request 內共用的 DbContext,Ktor 這邊 request id 在 CallId、登入者在 day 26 的 Principal、資料庫的 transaction 邊界在 day 21 的 transaction { } 裡,都不是靠容器發物件

真的需要每次都拿新的一份,就註冊一個工廠,容器管的還是那個工廠本身

dependencies.provide<() -> StringBuilder> { { StringBuilder() } }

resolve 2 次拿到的是同一個 lambda,但呼叫 2 次會得到 2 個不同的 StringBuilder,這樣寫的好處是「誰決定要不要共用」變成明確的一行程式碼,不是藏在註冊時選的那個 enum 裡

跟 Relix 的對照

Relix day 26 手刻過一個 30 行的 ServiceContainer,singletons 是 mutableMapOf<KClass<*>, Lazy<Any>>(),factories 是 mutableMapOf<KClass<*>, () -> Any>(),resolve 先找 singleton 再找 factory

骨架完全一樣,Ktor 的 map 是 MutableMap<DependencyKey, DependencyInitializer>,MapDependencyResolver.getInitializer 也是查表,查不到就丟例外,Relix 用 Lazy 做 singleton,Ktor 用 async(start = CoroutineStart.LAZY) 加 AtomicRef,多的是 suspend 能力,少的是 factory 那一種,就是〈生命週期〉那節講的,Ktor 沒有「每次都給新的」這個選項,要的話得自己註冊一個工廠函式

差別集中在 key,Relix 那篇自己點出來了,「singleton<List<String>> 和 singleton<List<Int>> 的 KClass 都是 List::class,會衝突,真要支援泛型需要用 KType」,Ktor 的 DependencyKey 確實保留完整型別,而且多一個 name,但預設的 RawTypes 映射仍會讓兩者在原始 List key 上衝突,完整型別解決了精確 key 的辨識,能不能同時註冊還要看 key mapping,或直接用 name 分開

Ktor 這邊還多了 2 件手刻容器沒做的事。一是循環相依會被抓出來,SafeResolver 用一組 pending key 追蹤,成環就丟 CircularDependencyException,不會變成 stack overflow。二是同一個 key 被 2 個 coroutine 同時 resolve 只會建一次,靠的是前面說的 AtomicRef 加 CAS,手刻的 mutableMapOf 沒有這層保護

Relix 那篇有一句要修正,當時寫的是「它還會在 ApplicationModulesLoaded 的時候把整張圖驗過一遍,少一個就丟 DependencyInjectionException,前面那個『上線 3 天才發現』的情境在它身上不會發生」,前半句對,後半句只在用 by dependencies 的時候成立,上面那個 /zone 的測試就是「上線 3 天才發現」的完整重現,只是它發生在 Ktor 官方的 DI 上

還有一個對照很有意思,Relix day 26 特地解釋過為什麼 logger 要先建好再 singleton<RelixLogger> { logger } 註冊實例,而不是在 provider 裡 new,理由是 middleware 跟 handler 要拿到同一個。Ktor 這邊不會遇到這個問題,Explicit 的快取保證同一個 key 永遠是同一個實例,先註冊後取用的順序也不用自己排。代價是換到別的地方了,就是上面那個假時鐘的順序問題

要不要在正式專案用,十幾個相依以內就夠

ktor-server-di 是 3.2.0 才有的東西,2025 年 6 月釋出,以框架功能來說很新。這篇碰到的 3 個問題都不在文件裡,DslMarker 擋掉 property() 的隱含 receiver、設定檔的 module 一定排在程式碼的 module 前面、resolve() 不進啟動驗證,3 個都是翻原始碼才確定的,官方文件目前是 6 頁的規模,寫得清楚但不深,邊界情況得自己去探索

好處也很具體,不必多一個相依套件、不必多學一套 DSL,而且它跟 Ktor 的生命週期是接在一起的,ApplicationModulesLoaded 驗證、ApplicationStopping 關閉,這些接點第三方套件得自己想辦法接

Koin 是 Kotlin 生態裡最成熟的 runtime DI,文件、社群、範例都比 Ktor 內建的多,而且不綁 Ktor,同一套寫法在 Android 跟純 JVM 專案都能用,兩者也不是二選一,Koin 4.2 起有一個 KoinDependencyMapExtension 實作 Ktor 的 DependencyMapExtension,兩邊的容器可以互相解析

要編譯期保證的話 2 個都不是答案,那要往 Dagger 或 Hilt 走,實際的判斷是這樣,相依關係在 10 幾個以內、專案本來就是 Ktor、團隊沒有既有的 DI 慣例,用內建的就夠了,todo-api 就是這個規模,複雜到需要 module 分組、需要 scope,或是同一套 service 要在 Ktor 以外的地方用,Koin 會比較舒服,而已經在用 Koin 的專案不需要為了 3.2 搬家

有一件事跟成熟度無關,任何 DI 容器都一樣,上面那個 race condition 的實測是最好的提醒,容器只管「誰建的、給誰用、什麼時候關」,它不會讓你的資料結構變安全,也不會幫你想清楚邊界該切在哪裡


小結

ktor-server-di 的 accessor 是 ktorLibs.server.di,plugin 由 Application.dependencies 的 getter 自己 install(DI),程式碼裡不用寫,dependencies { provide<T> { } } 註冊,by dependencies 或 dependencies.resolve<T>() 取用,前者會登記進 requirements 被啟動驗證,後者不會,key 是 DependencyKey,型別加選用的 name,註冊一個型別的同時會依 DefaultKeyCovariance 生出父型別、可 null、原始型別的組合 key,Any 這類太泛的被 ignoredSupertypes 擋掉

初始化是 CoroutineStart.LAZY 加 AtomicRef,第 1 次 resolve 才建、之後都是同一個,關閉時依宣告順序倒過來把 AutoCloseable 關掉,沒建過的不關,property()

在 dependencies { } 裡要寫 this@module.,因為 Application 跟 DependencyRegistry 都掛了 @KtorDsl 這個 DslMarker,測試裡換掉相依要在 module 跑之前,而設定檔的 module 一定排在程式碼的 module 前面,所以得用 configure(overrides = { put("ktor.application.modules.size", "0") }) 把 module 清單清掉再自己叫 module()

3 筆債處理掉 2 筆半,時鐘變成注入的 Clock,設定進了容器,todos 換了持有者但併發問題原樣不動,60 個併發 POST 跑 2 輪分別掉了 6 筆和 8 筆,重複的 id 一輪一個號碼、一輪 2 個


下一篇

provide<MutableList<Todo>> 這個宣告本身就是問題的所在,型別只說了資料的形狀,沒說誰負責維護它,下一篇把 TodoRepository 介面抽出來,使用 InMemoryTodoRepository 實作它,todoRoutes 從收 2 個參數變成收一個 repository,有了那個邊界,id 的產生跟寫入才有地方變成一個不可分割的動作,day 20 會先接上資料庫基礎,day 22 真正換掉 repository 時,路由那一層也不用動


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 17 設定管理與多環境
下一篇
Kotlin Ktor 實戰 101 Day 19 DI 進階,把 Repository 抽出來
系列文
Kotlin Ktor 實戰 101 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言