iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Software Development

Kotlin Ktor 實戰 101系列 第 19 篇

Kotlin Ktor 實戰 101 Day 19 DI 進階,把 Repository 抽出來

  • 分享至 

  • xImage
  •  

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

day 18 結尾寫的是「provide<MutableList<Todo>> 這個宣告本身就是問題的所在,型別只說了資料的形狀,沒說誰負責維護它」,這篇要把它換掉,用 TodoRepository 介面加上 InMemoryTodoRepository 實作,todoRoutes 從收 2 個參數變成收一個 repository

介面怎麼定才撐得過後面接資料庫那幾篇,換掉那一行之後容器登記的 key 變成什麼樣子,還有 day 18 那組 60 個併發 POST,這篇分 3 段實測改到底

這篇要完成什麼

  • TodoRepository 介面與 InMemoryTodoRepository,handler 不再自己算 id
  • 換掉 provide<MutableList<Todo>>,用 TRACE log 看容器登記的 key 差在哪
  • 併發實測 3 段,從掉資料修到 63 筆不少
  • synchronized 跟 Mutex 的取捨,為什麼這篇選前者
  • 介面要不要 suspend,加上去實際編譯一次看誰會壞
  • 測試裡換成假的 repository,day 18 那個 module 順序的坑照樣要繞
  • 相依變成一條鏈之後,啟動驗證跟不跟得住
  • 這篇沒有兌現的那一筆債
  • 跟 Relix day 29 的 repository 逐條對照
  • 這樣的 repository 能不能上線

介面先定,它要撐過接資料庫那幾篇

新開一個檔案 src/main/kotlin/com/cashwu/todo/TodoRepository.kt,介面放最上面

interface TodoRepository {
    fun findAll(limit: Int?): List<Todo>
    fun findById(id: Int): Todo?
    fun create(title: String, done: Boolean): Todo
}

3 個方法對應現在的 3 個端點,有 2 個決定會影響到 day 20 換成 Exposed,day 22 正式換掉 repository 時要不要改簽名,先講清楚

findAll 收 limit,沒有讓 route 拿到整包之後自己 take,in-memory 的時候 2 種寫法跑起來沒差別,換成資料庫就差很多,LIMIT 要進 SQL,不然就是把整張表撈回記憶體再丟掉大部分,null 表示不限制,query string 合不合法這種判斷留在 route 那邊做

create 收 title 跟 done 2 個欄位,回傳一個完整的 Todo,id 跟 createdAt 由 repository 決定,呼叫端沒辦法事先知道 id 是幾號,這也是 Clock 要從 route 搬進 repository 的原因,時間戳記是儲存這一層的事

參數會隨欄位變多而變長,改成收一個 CreateTodoRequest 也行,代價是 repository 反過來依賴 HTTP 那一層的 DTO,不然就是中間還要多轉一個 DTO,3 個欄位還撐得住,等 day 22 真的長出更新端點再看

實作,先只修 id

實作寫在同一個檔案,接在介面後面,這是第 1 版,等一下會再改一次

class InMemoryTodoRepository(
    private val clock: Clock,
    initial: List<Todo> = defaultTodos,
) : TodoRepository {
    private val todos = initial.toMutableList()
    private val nextId = AtomicInteger((initial.maxOfOrNull { it.id } ?: 0) + 1)

    override fun findAll(limit: Int?): List<Todo> =
        if (limit == null) todos.toList() else todos.take(limit)

    override fun findById(id: Int): Todo? =
        todos.find { it.id == id }

    override fun create(title: String, done: Boolean): Todo {
        val todo = Todo(
            id = nextId.getAndIncrement(),
            title = title,
            done = done,
            createdAt = Instant.now(clock),
        )
        todos.add(todo)
        return todo
    }
}

檔案頂端 3 個 import,java.time.Clock、java.time.Instant、java.util.concurrent.atomic.AtomicInteger

initial 預設是 defaultTodos,就是 day 12 那 3 筆,它從 day 12 起是 top-level 那個 todos 的來源,day 18 進了容器變成 provide<MutableList<Todo>> 的內容物,現在整個專案只剩 InMemoryTodoRepository 這一個使用者,測試要一份空的 repository 就傳 initial = emptyList(),種子資料變成建構子參數,這是抽出這一層順手撿到的

day 18 那個 (todos.maxOfOrNull { it.id } ?: 0) + 1 每次 POST 都要掃一次清單,換成 AtomicInteger 之後只在建構的時候掃一次,之後就是一個 counter,這一步先只動 id,寫入還是原來的 ArrayList.add

換掉 provide 那一行

Application.kt 的 dependencies { } 區塊改一行,delegate 的宣告少一個

fun Application.module() {
    dependencies {
        provide<TodoConfig> { this@module.property("todo") }
        provide<Clock> { Clock.systemUTC() }
        provide<TodoRepository> { InMemoryTodoRepository(resolve()) }
    }

    val config: TodoConfig by dependencies
    val repository: TodoRepository by dependencies

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

provide<MutableList<Todo>> 換成 provide<TodoRepository>,val clock: Clock by dependencies 這個 delegate 不用了,routing { } 裡面那行從 todoRoutes(clock, todos) 變成 todoRoutes(repository),java.time.Clock 的 import 還留著,因為 Clock.systemUTC() 還在這裡

檔案頂端那個 val todos = defaultTodos.toMutableList() 也要一起刪掉,它是 day 12 留下來的,day 18 把清單搬進容器之後就沒有人用了,那時候還提過它「從頭到尾都是 3 筆」,留著它等一下會出事

InMemoryTodoRepository(resolve()) 那個 resolve() 沒有寫型別參數,靠建構子參數的位置推出來要的是 Clock,它能出現在 provider 裡面,是因為 dependencies { } 裡的 provide 收的 lambda 是 suspend 的

// DependencyRegistry.kt,dependencies { } 裡面拿到的就是這一個
public inline fun <reified T> provide(
    name: String? = null,
    noinline provide: suspend DependencyResolver.() -> T?
): KeyContext<T>

// DependencyProvider.kt,同名,但 lambda 沒有 suspend
public inline fun <reified T> DependencyProvider.provide(
    name: String? = null,
    noinline provide: DependencyResolver.() -> T?
)

而 resolve 是 suspend 函式,所以 provider 裡面能不能跟容器要別的相依,看你手上的靜態型別是哪一個,dependencies 本身是 DependencyRegistry,它的 provide 是成員函式、lambda 帶 suspend,而 dependencies { } 只是把同一個 registry 當 receiver (Application.dependencies(action) 的實作就是 dependencies.action()),所以 2 種寫法都 resolve() 得到,真正不行的是拿到裸的 DependencyProvider,例如 dependencies.provider,那時候成員函式不在,套用的是上面那個非 suspend 的擴充,寫 resolve() 會編譯失敗

e: Suspension functions can only be called within coroutine body.

這一點跟 Relix day 26 手刻那個容器剛好對得上,那篇的 provider 簽名是 ServiceContainer.() -> T,receiver 是容器本身,所以裡面也可以 resolve()

TodoRoutes.kt 的簽名跟著改,java.time.Clock 跟 java.time.Instant 2 個 import 一起拿掉

fun Route.todoRoutes(repository: TodoRepository) {
    route("/todos") {
        get {
            val limitText = call.request.queryParameters["limit"]
            val limit = limitText?.let { text ->
                text.toIntOrNull()?.takeIf { it >= 0 }
                    ?: throw ApiException(HttpStatusCode.BadRequest, "limit 要是 0 以上的整數")
            }
            call.respond(repository.findAll(limit))
        }
        get("{id}") {
            val id = call.parameters["id"]?.toIntOrNull()
                ?: throw ApiException(HttpStatusCode.BadRequest, "id 要是數字")
            val todo = repository.findById(id)
                ?: throw ApiException(HttpStatusCode.NotFound, "找不到 id $id 的待辦")
            call.respond(todo)
        }
        post {
            val request = call.receive<CreateTodoRequest>()
            val todo = repository.create(request.title, request.done)
            call.respond(HttpStatusCode.Created, todo)
        }
    }
}

POST 的 handler 從 9 行變成 3 行,id 怎麼算、時間從哪來、資料放哪裡,這 3 件事全部搬到介面後面,route 只剩下「解析請求、呼叫、回應」

GET 那邊算 limit 的寫法也換了,day 18 是 val limit = if (limitText == null) todos.size else ...,沒帶 limit 就拿清單長度當上限,現在 null 直接往下傳給 findAll,由儲存層自己決定不限制,take 不再發生在 route 這一層,這就是前面說的「limit 要下推」

最容易漏掉的是 val limit 那一行,call.respond(todos.take(limit)) 換成 repository.findAll(limit) 之後,val limit 那行留著原樣也編譯得過,因為裡面的 todos 會靜靜地指到剛剛那個頂層的 val todos,而頂層那份永遠是 3 筆,GET 就變成 findAll(3)

如果 Application.kt 檔案頂端那個 val todos = defaultTodos.toMutableList() 沒有刪掉的話,這個時候跑測試會有 4 個測試有問題,而且是它造成的

DependencyInjectionTest > every application gets its own todo list() FAILED
    org.opentest4j.AssertionFailedError: expected: <4> but was: <3>
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>
TodoRoutesTest > post todos creates todo and responds created() FAILED
    org.opentest4j.AssertionFailedError: expected: <4> but was: <3>

4 個測試都在 POST 完之後數清單,數回來還是 3 筆,這也是頂層那個 val todos 要刪掉的理由,刪了之後同樣的漏改在編譯階段就擋下來,不用等測試跑

e: TodoRoutes.kt:13:48 Unresolved reference 'todos'.

一個不再有人維護的全域可變狀態,留在那裡的唯一功能就是讓漏改的程式碼編譯得過

先跑一次測試

頂層那個 val todos 刪掉、limit 那行也改好之後,跑一次 ./gradlew test,還會有一個測試有問題

DependencyInjectionTest > a provided subtype resolves through its supertype() FAILED
    io.ktor.server.plugins.di.MissingDependencyException: Could not resolve dependency for `kotlin.collections.MutableList<com.cashwu.todo.Todo>`

就是 day 18 那個驗 covariant key 的測試,它直接跟容器要 MutableList<Todo>,這個 key 隨著 provide<MutableList<Todo>> 一起不見了

其他的一個都沒事,包括 RequestValidationTest 那 6 個數 todoCount() 的、TodoRoutesTest 整批、CallLoggingTest 打 /todos 的那幾個,它們走的是 HTTP,module() 裡面把哪個相依換掉它們看不到,API 的行為也確實沒變,todoRoutes() 的簽名雖然改了,測試那邊一行都不用改,day 18 提過那幾個自己組 application 的測試都是自己寫 post("/todos") { ... },沒有人呼叫 todoRoutes()

有問題的那一個不是刪掉就好,它要驗的事情還在,只是主角從 MutableList<Todo> 換成 repository,下一節要改寫它

容器登記的 key 少了一大串

day 18 開過 io.ktor.server.plugins.di 的 TRACE log,看容器註冊一個型別的時候順便登記了哪些衍生的 key,同樣的做法再開一次,logback.xml 暫時加一行

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

啟動之後 3 筆註冊長這樣,中間交錯的 Conflicting keys 幾行跟這次的重點無關,省略了

10:28:41.364 DEBUG [no-call-id] i.k.s.p.d.MapDependencyProvider -- Provided com.cashwu.todo.TodoConfig at com.cashwu.todo.ApplicationKt.main(Application.kt:21)
10:28:41.367 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
10:28:41.373 DEBUG [no-call-id] i.k.s.p.d.MapDependencyProvider -- Provided java.time.Clock at com.cashwu.todo.ApplicationKt.main(Application.kt:21)
10:28:41.375 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
10:28:41.376 DEBUG [no-call-id] i.k.s.p.d.MapDependencyProvider -- Provided com.cashwu.todo.TodoRepository at com.cashwu.todo.ApplicationKt.main(Application.kt:21)
10:28:41.376 TRACE [no-call-id] i.k.s.p.d.MapDependencyProvider -- Covariant keys: com.cashwu.todo.TodoRepository, class com.cashwu.todo.TodoRepository, com.cashwu.todo.TodoRepository?, class com.cashwu.todo.TodoRepository

TodoRepository 那行只有 4 個 key,自己跟自己的 class、可 null 的自己跟它的 class,沒有別的,對照 day 18 那筆 MutableList<Todo>,登記出來的是 List<Todo>、Collection<Todo> 一路往上,多到被 COVARIANT_LOG_LIMIT = 8 截斷

差別來自 TodoRepository 沒有父介面,這不只是 log 好不好看的問題,多一個 covariant key 就多一個別人可以拿來 resolve 的入口,宣告成 MutableList<Todo> 的時候,容器同時開放了 List<Todo> 跟 Collection<Todo> 這些 key,它們看到的是同一個可變物件,只是唯讀介面本身沒有 add,原本的 MutableList<Todo> key 仍然存在,拿到唯讀介面的人也可能透過轉型繞回可變型別,宣告成 TodoRepository,能拿到的就只有介面上那 3 個方法

反過來的情況是把型別參數省掉,寫成 provide { InMemoryTodoRepository(resolve()) },T 被推成實作類別,covariance 這次往上長,TodoRepository 這個介面 key 跟著登記進去,2 個 key 都 resolve 得到

這一段就不看 log 了,log 只列出容器登記了哪些 key,真正要確認的是那 2 個 key 背後是同一個物件還是 2 份

寫成測試比較直接,剛剛有問題的那個測試就改寫成這樣,位置一樣在 DependencyInjectionTest.kt,骨架照舊,註冊一個具體型別、從它的父型別再 resolve 一次、assertSame 比對,MutableList<Todo> 跟 List<Todo> 換成 InMemoryTodoRepository 跟 TodoRepository

不一樣的是註冊那一步,day 18 那個測試靠的是 module() 裡的 provide<MutableList<Todo>>,所以它只要一句 todoApplication() 就有東西可以 resolve,現在 module() 明確寫 provide<TodoRepository>,蓋不到「型別參數被推成實作類別」這個情境,測試得自己 provide 一次,day 18 還多一行 assertEquals(4, asReadOnly!!.size),證明唯讀那份不是複本而是同一份資料,repository 沒有筆數可以數,這一行沒有對應的寫法

@Test
fun `a provided implementation resolves through its interface`() = testApplication {
    var asImplementation: InMemoryTodoRepository? = null
    var asInterface: TodoRepository? = null
    application {
        dependencies.provide { InMemoryTodoRepository(fixedClock(FIXED_NOW)) }
        asImplementation = dependencies.resolve<InMemoryTodoRepository>()
        asInterface = dependencies.resolve<TodoRepository>()
    }

    client.get("/")

    assertSame(asImplementation, asInterface)
}

fixedClock(FIXED_NOW) 是 day 18 放進 TestApp.kt 的假時鐘,不呼叫 todoApplication() 還有一個順帶的好處,設定是空的 MapApplicationConfig(),application.yaml 裡的 module 不會載入,容器裡只有 application { } 自己註冊的那一筆,剛好只剩 covariance 這件事可以看

中間那個 client.get("/") 不是要它的回應,testApplication 是等第 1 個 client 請求進來才真的把 server 建起來,application { } 裡的程式碼在那之前一行都不會跑,少了這個請求,2 個變數會一直是 null,這條懶啟動的線 day 24 會整個拆開來看,測試沒有載入 module,/ 也沒有路由,回 404 無所謂

所以斷言只有 assertSame 一行,重點不是 2 個 key 都 resolve 得到,是它們拿到同一個 instance,不是各自建一份,跟 day 18 那組 MutableList<Todo> 與 List<Todo> 的關係一樣

方便歸方便,代價是實作類別也變成一個公開的相依,別人可以直接跟容器要 InMemoryTodoRepository,在 day 20 試換資料庫、day 22 正式替換時就改掉,專案裡的寫法是明確寫死 provide<TodoRepository>,另一個測試把這件事綁住,同樣放 DependencyInjectionTest.kt

@Test
fun `the module registers the repository under the interface key only`() = testApplication {
    var failure: Throwable? = null
    todoApplication()
    application {
        failure = runCatching { dependencies.resolve<InMemoryTodoRepository>() }.exceptionOrNull()
    }

    assertEquals(HttpStatusCode.OK, client.get("/todos").status)
    assertIs<MissingDependencyException>(failure)
}

這個測試要走真的 module,所以先呼叫 todoApplication(),application { } 裡再跟容器要實作類別,runCatching 把丟出來的例外接下來看型別,不然它會直接讓測試失敗。client.get("/todos") 一樣負責觸發啟動,順便確認 API 照常運作,跟容器要 InMemoryTodoRepository 則丟 MissingDependencyException,相依的單位就是你在 provide<T> 裡寫的那個 T

到這裡,換掉 repository 帶出來的測試問題就都處理完了,有問題的那個改寫成介面版,再補一個把註冊的型別綁住

只修 id 修不好清單

先用 day 18 那組指令量一次改動前的樣子。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))
"

day 18 那份程式碼,每一輪之間重新啟動一次 server

--- run 1
清單長度 61 (預期 63)
不同 id 數 58
重複的 id [4, 8]
--- run 2
清單長度 59 (預期 63)
不同 id 數 57
重複的 id [4]

跟 day 18 同一個症狀,掉幾筆、哪個 id 撞號每次都不一樣,換上剛剛那版 AtomicInteger 的 repository,同樣的指令再跑兩輪

--- run 1
清單長度 62 (預期 63)
不同 id 數 62
重複的 id []
--- run 2
清單長度 61 (預期 63)
不同 id 數 61
重複的 id []

id 的問題乾淨解決了,兩輪都沒有重複,getAndIncrement() 是原子的 read-modify-write 操作,60 個 thread 同時進來也只會各拿到一個號碼,但清單長度還是 62 跟 61,離 63 還差一點,而且「不同 id 數」剛好等於清單長度,掉的那幾筆是連著它們的 id 一起消失的

原因在沒有被保護的那一行

todos.add(todo)

ArrayList.add 內部是「把值寫進 elementData[size],然後 size++」,2 個 thread 讀到同一個 size,就會把值寫進同一格,後到的蓋掉先到的,size 卻只加了 2 次其中的一次,id 沒撞號是因為號碼在別的地方發,資料還是掉了

這正是 Relix day 29 那篇自己點出來的狀況,當時寫的是「AtomicLong 本身是 thread-safe,所以 id 不會撞號,但 LinkedHashMap 不是,多個 request 同時寫入還是可能壞掉」,那時候只是推論,這次是實際量出來的數字

把整段放進同一個 lock

id 的產生跟資料的寫入必須是同一個不可分割的動作,這是 day 18 就寫下的結論。InMemoryTodoRepository 改成這樣

class InMemoryTodoRepository(
    private val clock: Clock,
    initial: List<Todo> = defaultTodos,
) : TodoRepository {
    private val lock = Any()
    private val todos = initial.toMutableList()
    private var nextId = (initial.maxOfOrNull { it.id } ?: 0) + 1

    override fun findAll(limit: Int?): List<Todo> = synchronized(lock) {
        if (limit == null) todos.toList() else todos.take(limit)
    }

    override fun findById(id: Int): Todo? = synchronized(lock) {
        todos.find { it.id == id }
    }

    override fun create(title: String, done: Boolean): Todo = synchronized(lock) {
        val todo = Todo(
            id = nextId++,
            title = title,
            done = done,
            createdAt = Instant.now(clock),
        )
        todos.add(todo)
        todo
    }
}

AtomicInteger 拿掉了,換回一個普通的 var nextId,現在整段已經在 critical section 裡面,再包一層原子操作沒有意義,java.util.concurrent.atomic.AtomicInteger 那個 import 也跟著移除

3 個方法都上鎖,讀的那 2 個也是,findAll 做 take 的同時要是有人在 add,ArrayList 的 iterator 會丟 ConcurrentModificationException,這不是理論上的風險,GET 跟 POST 本來就會同時打進來,findAll(null) 回的是 todos.toList() 而不是 todos 本身,交出去的是一份快照,離開鎖的範圍之後序列化才安全

讀也要上鎖是 in-memory 才有的負擔,換成資料庫就不用自己動手,PostgreSQL 跟 MySQL 的 InnoDB 走的是 MVCC,寫入留下舊版本,讀的人拿的是自己那個時間點的快照,讀不擋寫,寫也不擋讀,SELECT 不需要為了避開寫到一半的資料去搶一把鎖,ArrayList 這種讀到一半被改就丟例外的情況在資料庫那邊不存在

代價換到另一個地方,變成隔離等級的選擇,保證愈強,資料庫要維持的版本跟檢查愈多,吞吐就愈低,能接受讀到還沒 commit 的資料 (dirty read) 就可以再往下調,不過多數資料庫預設不會走到那一步,PostgreSQL 連 Read Uncommitted 都是當成 Read Committed 在跑,in-memory 這一層問的是「要不要鎖」,資料庫那一層問的是「要哪一種一致性」,day 21 會實際去設 transaction() 的隔離等級跟 readOnly

同樣的指令,三輪

--- run 1
清單長度 63 (預期 63)
不同 id 數 63
重複的 id []
--- run 2
清單長度 63 (預期 63)
不同 id 數 63
重複的 id []
--- run 3
清單長度 63 (預期 63)
不同 id 數 63
重複的 id []

63 筆資料一筆不少,id 也沒有重複,day 12 寫下那段警告、day 18 拿實測證明 DI 沒有解決它,到這裡才真的處理掉

curl 打起來的行為跟以前一模一樣

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

{"id":4,"title":"倒垃圾","done":false,"created_at":"2026-08-31T02:27:51.810170Z"}

為什麼選 synchronized

synchronized 擋的是 thread,handler 跑在 Netty 的 worker thread 上,被擋住的那條 thread 在等鎖的期間什麼都不能做,包括去服務別的請求。這在協程的世界裡是要避開的事

這裡選它的理由是 critical section 裡面沒有 I/O,就是幾個記憶體操作跟一次 Instant.now(),微秒等級,等鎖的時間比排程一次 coroutine 的成本還短,換成 kotlinx.coroutines.sync.Mutex 的話,等待的時候是 suspend 而不是 block,thread 可以先去做別的事,但 Mutex.withLock 是 suspend 函式,介面的 3 個方法就得跟著變成 suspend

鎖要換哪一種,跟介面要不要 suspend,其實是同一個問題

介面要不要 suspend,這篇先不加

Relix day 29 也遇過這題,那篇的答案是「因為現在是 in-memory 實作,沒有 I/O 要等,加了也只是多打 5 個字,換成資料庫的時候,介面跟 5 個 override fun 一起加上 suspend,呼叫端本來就在 suspend handler 裡不用動,這幾個測試也不用動,因為它們打的是 HTTP,沒有直接碰 repository」

這次直接實測。把 TodoRepository 3 個方法跟 InMemoryTodoRepository 3 個 override 都加上 suspend,然後編譯

./gradlew clean compileKotlin
> Task :compileKotlin
BUILD SUCCESSFUL in 622ms

(中間那幾行 ErrorHandling.kt 的 serialization opt-in 警告本來就在,跟這次的改動無關,省略了)

正式程式碼一個錯誤都沒有。todoRoutes 的 3 個 handler 本來就在 suspend 的 context 裡,Application.kt 那個 provider 的 lambda 也是 suspend 的,兩邊都不用動。前半句成立

./gradlew compileTestKotlin

19 個錯誤,全部在測試裡,分成 2 種 (下面 2 行把檔案路徑的前綴拿掉了)

e: DependencyInjectionTest.kt:45:18 Non-suspend function 'findAll' cannot override suspend function 'suspend fun findAll(limit: Int?): List<Todo>' defined in 'com.cashwu.todo.TodoRepository'.
e: TodoRepositoryTest.kt:13:32 Suspend function 'suspend fun create(title: String, done: Boolean): Todo' can only be called from a coroutine or another suspend function.

第 1 種是測試裡那 2 個假的 repository,它們實作同一個介面,介面變了就得跟著變,DependencyInjectionTest

第 2 種是直接呼叫 repository 的單元測試,@Test fun 不是 suspend 函式,得改成 runTest { } 或 runBlocking { } 包起來,TodoRepositoryTest

Relix day 29 那句「這幾個測試也不用動」在它自己的專案裡是對的,那篇沒有直接測 repository,所有測試都打 HTTP,這篇多了 TodoRepositoryTest 直接呼叫的測試,還多了測試用的假實作,所以「不用動」變成「main 不用動,test 要改」,同一句話在不同的測試結構下結論不一樣

實驗做完就把那幾個 suspend 撤掉了,這篇維持非 suspend 的介面

現在標上 suspend 只是在騙自己,方法裡面沒有任何一個地方會真的掛起,而且 day 22 要處理的正是「Exposed 的 transaction { } 是 blocking 的,包在 suspend 裡面反而危險」這件事,那時候會需要 withContext(Dispatchers.IO) 或 newSuspendedTransaction,簽名怎麼定要連著那個決定一起做

測試裡換掉 repository

day 18 為了塞假時鐘踩過一個坑,設定檔裡的 module 一定排在程式碼註冊的 module 前面,而測試環境的衝突策略是先來的贏,所以測試的 provide 永遠晚一步

解法是用 configure(overrides = { put("ktor.application.modules.size", "0") }) 把 module 清單清空,再自己叫一次 module()

換 repository 走的是同一條路,TestApp.kt 的 helper 多一個參數

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

2 個假實作都宣告在 DependencyInjectionTest.kt 的檔案層級,跟 day 18 那個 Probe 放在一起

private class RecordingTodoRepository(private val delegate: TodoRepository) : TodoRepository {
    val limits = mutableListOf<Int?>()

    override fun findAll(limit: Int?): List<Todo> {
        limits += limit
        return delegate.findAll(limit)
    }

    override fun findById(id: Int): Todo? = delegate.findById(id)

    override fun create(title: String, done: Boolean): Todo = delegate.create(title, done)
}

private class BrokenTodoRepository : TodoRepository {
    override fun findAll(limit: Int?): List<Todo> = throw IllegalStateException("儲存體壞掉了")

    override fun findById(id: Int): Todo? = throw IllegalStateException("儲存體壞掉了")

    override fun create(title: String, done: Boolean): Todo = throw IllegalStateException("儲存體壞掉了")
}

RecordingTodoRepository 把呼叫轉給真的實作,順便記下每次收到的 limit,前面說「limit 要下推到 repository」,這個測試就是那句話的證據,測試跟假實作放同一個檔案,DependencyInjectionTest.kt

@Test
fun `a fake repository replaces the real one and sees the limit`() = testApplication {
    val recording = RecordingTodoRepository(InMemoryTodoRepository(fixedClock(FIXED_NOW)))
    todoApplication(repository = recording)

    val limited = Json.decodeFromString<List<Todo>>(client.get("/todos?limit=2").bodyAsText())

    assertEquals(2, limited.size)
    assertEquals(3, client.todoCount())
    assertEquals(listOf(2, null), recording.limits)
}

client.todoCount() 是 day 18 放進 TestApp.kt 的 helper,打 GET /todos 再數回來的筆數,2 次請求進去,repository 收到的是 2 跟 null,route 沒有自己截,也沒有把 null 翻譯成清單長度再傳下去

BrokenTodoRepository 換一個角度,它 3 個方法全部丟例外,測試一樣放 DependencyInjectionTest.kt

@Test
fun `a repository that throws becomes a 500`() = testApplication {
    todoApplication(repository = BrokenTodoRepository())

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

createTodo 是 day 18 放在 DependencyInjectionTest.kt 檔案層級的 helper,POST 一筆帶指定標題的待辦,day 15 裝的 StatusPages 把不認識的例外轉成 500,這個測試證明那條路在 repository 出事的時候也行得通,等到 day 20 接上真的資料庫,「資料庫連不上會回什麼」就不用真的去拔網路線

repository 自己的測試就不放這裡了,開一個新檔案 src/test/kotlin/com/cashwu/todo/TodoRepositoryTest.kt

分界是走不走 HTTP,剛剛那 2 個測的是「換掉容器裡的相依之後 API 有沒有照劇本走」,屬於 DI 的題目,這一批直接 new 一個 repository 出來呼叫,跟 day 13 那些直接餵 Json 的序列化測試同一類

package com.cashwu.todo

import java.util.concurrent.CyclicBarrier
import kotlin.test.Test
import kotlin.test.assertEquals

class TodoRepositoryTest {
    @Test
    fun `sixty concurrent creates keep every todo and every id unique`() {
        val repository = InMemoryTodoRepository(fixedClock(FIXED_NOW))
        val gate = CyclicBarrier(60)
        val threads = (1..60).map { index ->
            Thread {
                gate.await()
                repository.create("併發 $index", false)
            }
        }

        threads.forEach { it.start() }
        threads.forEach { it.join() }

        val ids = repository.findAll(null).map { it.id }
        assertEquals(63, ids.size)
        assertEquals(63, ids.toSet().size)
    }
}

這是上面那組 curl 實測的 CI 版本,CyclicBarrier 讓 60 個 thread 都到齊後再一起 create,斷言 63 筆而且 id 全部不同,少了 synchronized 它仍不保證每次都會失敗,race condition 本來就不是每次都踩得到,但共同起跑能提高撞上的機率,這個測試跑起來也不到十毫秒

fixedClock 跟 FIXED_NOW 都在 TestApp.kt,同一個 package 不用 import。這個 class 裡還有 4 個測試接在後面,findAll(null) 拿到的快照不受後續寫入影響、create 的 id 接在種子資料後面、空的 repository 從 1 號開始、findById 對不存在的 id 回 null

併發那個測試要小心,這一個靠的是 60 個 thread 剛好擠在一起,過了不代表程式是對的,只代表這一次沒撞到,把 synchronized 拿掉它照樣可能連過好幾次,CI 的機器在忙的時候更明顯,thread 被排程排開,共同起跑那一下就不夠密集

所以它的定位是哨兵不是證明,失敗一定是真的有問題,通過什麼都證明不了,這類測試動手寫的時候有幾件事要注意,不要用 Thread.sleep 湊同步,join() 一定要收回來,不然 CI 會卡在那裡等,thread 數量也不要隨手加碼,一個 suite 裡放 3、5 個這種測試,跑起來的時間跟不穩定的程度都會很有感,真的要證明並行是對的,靠的是 jcstress 那類專門的工具,或是回到程式碼確認 critical section 有沒有涵蓋完整,不是一個 JUnit 測試

相依變成一條鏈之後

day 18 把 Clock 用 val clock: Clock by dependencies 拿出來再傳給 todoRoutes,所以它是 registry.requirements 裡的一個 key,啟動階段會被驗證。現在 module() 不再直接碰 Clock 了,它只出現在 InMemoryTodoRepository 的建構子裡

那少了 provide<Clock> 還會不會在啟動的時候爆 ? 把那一行拿掉試一次

10:28:00.465 WARN  [no-call-id] Application -- Exception during cleanup for com.cashwu.todo.TodoRepository; continuing
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 io.ktor.server.plugins.di.DependencyRegistry.get(DependencyRegistry.kt)
	at com.cashwu.todo.ApplicationKt$module$1$2.invokeSuspend(Application.kt:63)
	at io.ktor.server.plugins.di.DependencyInitializer$Explicit$lazyAsyncInit$1$1.invokeSuspend(DependencyInitializer.kt:55)
	...
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$1$2.invokeSuspend(Application.kt:63)

會爆,而且訊息指的是 java.time.Clock,stack trace 指回 module$1$2 這個 lambda,也就是 provide<TodoRepository> { } 那一段。by dependencies 解析 TodoRepository 的時候會去跑 provider,provider 裡的 resolve<Clock>() 找不到東西就在那裡丟出來

第 1 段那個 WARN 是關閉流程留下的,容器在收尾的時候想把 TodoRepository 這個 key 對應的物件拿出來 close,跑 provider 又踩到同一個缺口,它記一筆 log 然後繼續往下關,這是 day 18 那個 onShutdown 機制的另一面

驗證會不會跟著 provider 往下走一層,這件事寫成測試,還是放 DependencyInjectionTest.kt,它用到的 createTodo 是那個檔案的 private helper,本來也只能放在這裡

@Test
fun `a missing transitive dependency fails before the server starts`() {
    val failure = assertFailsWith<DependencyInjectionException> {
        testApplication {
            todoApplication()
            application {
                dependencies.provide<StringBuilder> { StringBuilder(resolve<ZoneId>().id) }
                @Suppress("UNUSED_VARIABLE")
                val text: StringBuilder by dependencies
            }
            client.createTodo("倒垃圾")
        }
    }

    assertTrue(failure.message!!.contains("check logs for details"))
}

註冊一個 StringBuilder,它的 provider 需要一個沒有人提供的 ZoneId,然後用 by dependencies 宣告一個誰都沒讀的 delegate,丟出來的是 DependencyInjectionException,也就是 day 18 講的第 2 種失敗模式,ApplicationModulesLoaded 事件把 requirements 裡的 key 一個一個解過去那一條路,解 StringBuilder 的時候順著 provider 走進去,缺的 ZoneId 一樣被抓到

所以 day 18 的結論要補一句,啟動驗證涵蓋的是 by dependencies 宣告的 key,以及從那些 key 順著 provider 走得到的所有東西,resolve() 開頭而且沒有人 by 的那條線,還是要等到有人打進來才會知道

這篇沒有兌現的那一筆

day 18 把 TodoConfig 搬進容器的時候寫了「這一步的好處現在還看不出來,TodoConfig 目前只有 module() 一個使用者,把它放進容器純粹是為了下一篇,真正的驗收要等 day 19」,還說「TodoRepository 要用到設定的時候不必再從 module() 傳一路傳進去,宣告一個 TodoConfig 參數就好」

這篇沒有完全兌現,InMemoryTodoRepository 的建構子只有 Clock 跟 initial,一個 in-memory 的清單沒有設定可讀,硬塞一個用不到的參數只是為了證明機制能動,真正會用到的是 day 20,資料庫的連線字串、使用者名稱、連線池大小都得從 application.yaml 進來,那時候設定會從容器一路走到資料庫連線那一層

不過 Clock 這條線已經先示範了同一件事,它從 module() 的一個區域變數變成 provider 裡的一次 resolve(),module() 現在完全不知道 Clock 的存在。設定到時候走的是同一條路

跟 Relix 的對照

Relix day 29 做過同一層抽象,介面 5 個方法對應 5 個端點,InMemoryTodoRepository 用 LinkedHashMap 存資料、AtomicLong 發號,DI 那邊寫 singleton<TodoRepository> { InMemoryTodoRepository() }

骨架一樣,4 個地方不同

  • 註冊的型別參數,Relix 是 singleton<TodoRepository>,Ktor 是 provide<TodoRepository>,兩邊都是明確寫介面。差別在 Ktor 的 covariance 會多登記幾個 key,寫 provide { InMemoryTodoRepository(...) } 的話介面那個 key 也會通,Relix 的 KClass key 沒有這回事
  • 相依怎麼進建構子,Relix 的 InMemoryTodoRepository() 沒有參數,時間直接 Instant.now()。這篇的 Clock 是從容器 resolve() 進去的,所以測試裡的 created_at 是可以固定的
  • 種子資料,Relix 的 repository 起手是空的,這篇帶著 day 12 那 3 筆,靠 initial 參數決定,併發實測的預期值因此是 63,不是 60
  • 並行安全,Relix 那篇誠實寫了「這個 in-memory 實作不建議真的拿去用」,AtomicLong 修 id、LinkedHashMap 沒修寫入。這篇把中間那個狀態當成實驗跑了一次,數字證實了那篇的推論,然後改成整段上鎖

Relix day 29 還提過一個這篇沒碰到的問題,update() 的「讀出來 → copy → 寫回去」是一段 read-modify-write,就算換成 ConcurrentHashMap 也還是有 lost update,這篇的 TodoRepository 現在只有讀跟新增,等更新端點補上去就會遇到,那時候答案會從 synchronized 換成資料庫的 transaction,剛好是 day 21 的題目

還有一句要修正,Relix day 29 說「換成資料庫的時候,介面跟 5 個 override fun 一起加上 suspend,呼叫端本來就在 suspend handler 裡不用動,這幾個測試也不用動」,前半段這次實測是對的,正式程式碼零改動,後半段只在「所有測試都打 HTTP」的前提下成立,一旦有直接呼叫 repository 的單元測試或測試用的假實作,就會有編譯錯誤等在那裡

這樣的 repository 能不能上線

介面這一層可以,實作不行

TodoRepository 3 個方法,實作連同介面整段 synchronized,這個結構本身沒有什麼疑慮,真的專案也是這樣切,可以再往上長的是加一層 service,讓 route 呼叫 service、service 呼叫 repository,商業邏輯有地方放,todo-api 現在沒有商業邏輯,多一層只是多一層轉呼叫

InMemoryTodoRepository 就是另外一回事了,資料在記憶體裡,process 重新啟動就沒了,多開一個 instance 兩邊的資料各走各的,synchronized 保護的也只有同一個 JVM 裡面,它撐得住的場景是測試、demo,還有這個系列走到資料庫之前的過渡

一把鎖鎖住全部 3 個方法這件事本身也是取捨,讀跟讀之間其實不用互斥,資料量大、讀多寫少的時候可以換成 ReentrantReadWriteLock,寫的時候才互斥,倒是不能換成 CopyOnWriteArrayList 這種本身併發安全的結構就了事,它保證的是單一次 add 安全,「讀 nextId、遞增、寫進去」這 3 步還是得自己包成一個不可分割的動作,實際換過去跑兩輪,63 筆確實一筆不少,但第 2 輪只有 62 個不同的 id,41 號發了 2 次,跟前面只換 AtomicInteger 那一版的失敗模式剛好對調過來,一個掉資料不撞號、一個撞號不掉資料,2 個都不算修好,讀寫鎖這裡也沒換,理由是 60 個併發下 synchronized 的成本量不出來,而且再過 1、2 篇這個實作就會被資料庫取代,最佳化它是在最佳化一個要丟掉的東西


小結

TodoRepository 介面 3 個方法,findAll(limit) 把 limit 下推到儲存層,create(title, done) 讓 id 跟 createdAt 由 repository 決定,provide<MutableList<Todo>> 換成 provide<TodoRepository> { InMemoryTodoRepository(resolve()) },provider 裡面能 resolve() 是因為 DependencyRegistry.provide 收的 lambda 是 suspend 的,DependencyProvider 上那個同名函式則不是,TRACE log 看得到差別,TodoRepository 只登記 4 個 covariant key,day 18 那個 MutableList<Todo> 登記到被截斷

併發實測 3 段,day 18 的程式碼 2 輪分別是 61 筆和 59 筆,重複的 id 一輪 2 個號碼,一輪一個,只換 AtomicInteger 之後 id 不再重複,但兩輪還是只有 62 筆跟 61 筆,掉資料的是沒有保護的 ArrayList.add

整段包進 synchronized 之後三輪都是 63 筆、63 個不同 id,選 synchronized 而不是 Mutex 是因為 critical section 裡沒有 I/O,換 Mutex 就得讓介面變 suspend,而把 suspend 加上去實測的結果是正式程式碼零錯誤,測試會有錯誤,所以這篇維持非 suspend,決定留到 day 22 再處理

測試裡換 repository 走的是 day 18 那條 configure(overrides = { put("ktor.application.modules.size", "0") }) 的路,2 個假實作一個記下 limit,一個全部丟例外,啟動驗證會跟著 provider 往下走,少一個 Clock 現在是從 provide<TodoRepository> 的 lambda 裡爆出來,day 18 那筆 TodoConfig 的驗收沒有兌現,往後挪到 day 20


下一篇

TodoRepository 這個介面有了之後,換掉後面那個實作就是一件單純的事,route 那一層一行都不用動,下一篇把 Exposed 裝進來,建立 table 定義,接上資料庫連線,然後處理連線這種 AutoCloseable 資源要怎麼交給 DI 管,day 18 那個 ApplicationStopping 自動 close 的機制終於有真的用途


參考資料


同步刊登於 Blog

圖片來源:AI 產生


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

尚未有邦友留言

立即登入留言