
day 18 結尾寫的是「provide<MutableList<Todo>> 這個宣告本身就是問題的所在,型別只說了資料的形狀,沒說誰負責維護它」,這篇要把它換掉,用 TodoRepository 介面加上 InMemoryTodoRepository 實作,todoRoutes 從收 2 個參數變成收一個 repository
介面怎麼定才撐得過後面接資料庫那幾篇,換掉那一行之後容器登記的 key 變成什麼樣子,還有 day 18 那組 60 個併發 POST,這篇分 3 段實測改到底
TodoRepository 介面與 InMemoryTodoRepository,handler 不再自己算 idprovide<MutableList<Todo>>,用 TRACE log 看容器登記的 key 差在哪synchronized 跟 Mutex 的取捨,為什麼這篇選前者suspend,加上去實際編譯一次看誰會壞新開一個檔案 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 真的長出更新端點再看
實作寫在同一個檔案,接在介面後面,這是第 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
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,下一節要改寫它
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 帶出來的測試問題就都處理完了,有問題的那個改寫成介面版,再補一個把註冊的型別綁住
先用 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 同時寫入還是可能壞掉」,那時候只是推論,這次是實際量出來的數字
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 擋的是 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,其實是同一個問題
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,簽名怎麼定要連著那個決定一起做
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 day 29 做過同一層抽象,介面 5 個方法對應 5 個端點,InMemoryTodoRepository 用 LinkedHashMap 存資料、AtomicLong 發號,DI 那邊寫 singleton<TodoRepository> { InMemoryTodoRepository() }
骨架一樣,4 個地方不同
singleton<TodoRepository>,Ktor 是 provide<TodoRepository>,兩邊都是明確寫介面。差別在 Ktor 的 covariance 會多登記幾個 key,寫 provide { InMemoryTodoRepository(...) } 的話介面那個 key 也會通,Relix 的 KClass key 沒有這回事InMemoryTodoRepository() 沒有參數,時間直接 Instant.now()。這篇的 Clock 是從容器 resolve() 進去的,所以測試裡的 created_at 是可以固定的initial 參數決定,併發實測的預期值因此是 63,不是 60AtomicLong 修 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 的單元測試或測試用的假實作,就會有編譯錯誤等在那裡
介面這一層可以,實作不行
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 產生