
前面文章的 todo-api 一直在做同一件事,收請求、查資料庫、回應,這一篇主動往外面打 POST /todos/import/{externalId},去一個公開的測試 API 抓一筆待辦回來,轉成自己的 Todo 存進資料庫
外部服務會慢、會掛、會回你沒看過的欄位,所以這一篇的東西幾乎都是為了這些狀況存在的,timeout、重試、多出來的欄位、失敗要對應成什麼狀態碼,測試那一側則是 MockEngine,同一份 client 設定,engine 換成假的,這些行為全部在不碰網路的情況下驗證
POST /todos/import/{externalId} 從 JSONPlaceholder 抓一筆,轉成本地的 Todo 存起來todoImportClient 收的是 HttpClientEngine 而不是寫死的 CIO,正式環境給 CIO、測試給 MockEngine
ignoreUnknownKeys 擋下來IMPORT_RETRIES=0 之後同一個失敗 48 毫秒就回來了retryOnServerErrors 沒有只管 server error,IOException 照樣被打 3 次,timeout 反而是唯一不重試的HttpClient 進了 DI 容器就不用自己關,day 18 那條生命週期規則接得上build.gradle.kts 的 dependencies { } 加 2 行,改 1 行
implementation(ktorLibs.server.websockets)
+ implementation(ktorLibs.client.core)
+ implementation(ktorLibs.client.cio)
testImplementation(kotlin("test"))
testImplementation(ktorLibs.server.testHost)
- testImplementation(ktorLibs.client.contentNegotiation)
+ implementation(ktorLibs.client.contentNegotiation)
被改掉的那一行本來是 testImplementation,因為在這之前只有測試裡的 client 需要處理 JSON,這一輪 main 底下真的要用它了,所以換成 implementation
測試那邊多一行 mock engine
testImplementation(ktorLibs.client.websockets)
+ testImplementation(ktorLibs.client.mock)
src/main/resources/application.yaml 的 todo 節點尾巴加 4 行
openapi:
title: "$OPENAPI_TITLE:Todo API"
version: "$OPENAPI_VERSION:1.0.0"
+ importer:
+ baseUrl: "$IMPORT_BASE_URL:https://jsonplaceholder.typicode.com"
+ timeoutMillis: "$IMPORT_TIMEOUT:2000"
+ maxRetries: "$IMPORT_RETRIES:2"
環境變數覆寫的語法是 day 17 那一套,冒號後面是預設值,那個網址不是憑證,寫進版控沒問題,day 27 那條「憑證不給預設值」的規矩管的是 JWT_SECRET 那一種
這個節點叫 importer 而不是 httpclient,理由在 baseUrl 這個 key 上,HttpClient 本身沒有「一個網址」,它是拿來打任意位址的,會有固定網址的是「匯入」這個功能,旁邊的 database、auth、openapi 也都是用途名,沒有一塊是用函式庫命名的
src/main/kotlin/com/cashwu/todo/TodoConfig.kt 的 TodoConfig 多一個 importer 欄位,同一個檔案尾巴多一個 data class
@Serializable
data class ImporterConfig(
val baseUrl: String,
val timeoutMillis: Long,
val maxRetries: Int,
)
新檔案 src/main/kotlin/com/cashwu/todo/TodoImporter.kt,第 1 段是 client 的組法
fun todoImportClient(engine: HttpClientEngine, config: ImporterConfig): HttpClient =
HttpClient(engine) {
expectSuccess = false
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(HttpTimeout) {
requestTimeoutMillis = config.timeoutMillis
connectTimeoutMillis = config.timeoutMillis
}
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = config.maxRetries)
exponentialDelay(base = 1.2, maxDelayMs = config.timeoutMillis)
}
}
第 1 個參數的型別是 HttpClientEngine,不是寫死的 CIO,這是整篇的接縫,正式環境傳 CIO.create(),測試傳 MockEngine { },而中間那層設定兩邊完全一樣,同一個 install(HttpTimeout)、同一個 install(HttpRequestRetry),測試量的是真的要上線的那份設定
CIO 是 Ktor 自己實作的 engine,名字來自 Coroutine-based I/O,client 這一側能選的 engine 不只它一個,那份版本目錄裡還有 client-apache5、client-okhttp、client-java 這些,差別在最底下真的把 bytes 送出去的是誰,上面那層 plugin 設定不會跟著換,CIO 的特色是不包裝任何既有的 HTTP 函式庫,ktor-client-cio 宣告的相依只有 Ktor 自己的模組加上 kotlinx-coroutines-core 跟 slf4j-api,沒有任何第三方的 HTTP 函式庫,這個專案本來就整條在 coroutine 上,選它就不必為了打一個外部 API 多帶一個第三方 client 進來
install 這個字在這裡跟 server 那邊指的是一樣的事,day 12 裝 ContentNegotiation 學到的 plugin 觀念,在 client 這一側原封不動能用,差別只在裝進去的是哪一個 pipeline
client 這一側的 ContentNegotiation 管的是 2 個方向的轉換,送出去的時候把物件轉成 request body,收回來的時候把 response body 轉成物件,這一輪用到的是後者,也就是下一節那個 response.body()
expectSuccess = false 是把預設值寫出來,HttpClientConfig 的宣告是 public var expectSuccess: Boolean = false,旁邊 followRedirects 跟 useDefaultTransformers 都預設 true,只有這個是 false,所以這一行沒有改變任何行為,寫出來是因為下面要自己讀 response.status 決定回什麼,那個前提留在程式碼上比留在腦子裡好
這個檔案的第 2 段,外部回應的形狀
@Serializable
data class ExternalTodo(
val id: Int,
val title: String,
val completed: Boolean,
)
外部服務實際回的東西比這個多一個 userId
curl -s https://jsonplaceholder.typicode.com/todos/1
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
多出來的欄位就是 ignoreUnknownKeys = true 在擋的,這不是可有可無的設定,外部服務哪天多回一個欄位不會先問過你,少了這一行,那次改版就變成你的 502
再來是轉換與失敗的對應
class TodoImporter(
private val client: HttpClient,
private val config: ImporterConfig,
private val repository: TodoRepository,
) {
suspend fun import(externalId: Int): Todo {
val external = fetch(externalId)
return repository.create(external.title, external.completed)
}
private suspend fun fetch(externalId: Int): ExternalTodo {
val response = ask(externalId)
if (response.status == HttpStatusCode.NotFound) {
throw ApiException(HttpStatusCode.NotFound, "外部服務沒有 id $externalId 的待辦")
}
if (!response.status.isSuccess()) {
throw ApiException(HttpStatusCode.BadGateway, "外部服務回 ${response.status.value}")
}
return try {
response.body()
} catch (cause: CancellationException) {
throw cause
} catch (cause: Throwable) {
throw ApiException(HttpStatusCode.BadGateway, "外部服務回的不是預期的 JSON")
}
}
private suspend fun ask(externalId: Int): HttpResponse =
try {
client.get("${config.baseUrl}/todos/$externalId")
} catch (cause: CancellationException) {
throw cause
} catch (cause: Throwable) {
throw ApiException(
HttpStatusCode.BadGateway,
"外部服務連不上 (${cause::class.simpleName})",
)
}
}
外部的 404 變成自己的 404,其他失敗一律 502,這個對應是有意義的,404 是「你要的東西不存在」,使用者換一個 id 再打一次就好,502 是「我這邊往外走的路壞了」,使用者重試也沒有用,2 種都丟 ApiException,剩下的交給 day 15 那個 StatusPages
catch (cause: CancellationException) { throw cause } 出現 2 次,是結構化併發的規矩,CancellationException 是 coroutine 用來傳「你被取消了」的通道,不是失敗,吞掉它會把一次正常的取消寫成一個假的 502
反序列化那一段單獨包起來,是因為它跟連線失敗是 2 件不同的事,200 加上一坨不是 JSON 的東西也是壞掉,但錯的地方不在網路
return response.body() 這一行沒有寫型別,回來的東西卻會變成 ExternalTodo,靠的不是「函式宣告什麼就轉成什麼」這條規則,是 Kotlin 的型別推論從呼叫點的期望型別推出來的,return 的期望型別剛好是 fetch 宣告的 ExternalTodo,所以推成那個,下面 3 種寫法等價
return response.body() // 從返回型別推
val e: ExternalTodo = response.body() // 從變數型別推
val e = response.body<ExternalTodo>() // 明講
body() 的宣告在 ktor-client-core 3.5.2 的 HttpClientCall.kt,逐字是這樣
public suspend inline fun <reified T> HttpResponse.body(): T = call.bodyNullable(typeInfo<T>()) as T
reified T 讓 Ktor 在執行期拿得到真正的型別,這是它能決定要用哪個 serializer 的前提,不過推得出型別不等於真的會反序列化,中間還有 2 個前提,缺的是哪一個,丟出來的例外不一樣,指的毛病也不一樣
第 1 個是 client 要裝 install(ContentNegotiation) { json(...) },而且回應的 Content-Type 要對得上註冊的 converter,這一條不成立的時候丟的是 NoTransformationFoundException,沒裝 ContentNegotiation、body 是合法 JSON 但 header 是 text/html、完全沒有 Content-Type header,這 3 種情況都是同一個例外
第 2 個是 JSON 的內容要對得上那個 data class,Content-Type 是 application/json 但 body 是 <html>maintenance</html>,丟的是 JsonConvertException,欄位缺了也是它,缺 title 跟 completed 的時候訊息逐字是 Fields [title, completed] are required for type with serial name 'com.cashwu.todo.ExternalTodo', but they were missing at path: $
T 是 String 或 ByteArray 這種的就不需要 ContentNegotiation,那是 Ktor 內建的 transformer 處理掉的,沒裝 ContentNegotiation 的 client 用 body<String>() 照樣拿得到原始的 JSON 字串
後面那個 a 200 that is not the expected json becomes a 502 走的就是第 2 條,helper 給的 header 是 application/json,內容卻是 HTML,而 fetch 裡的 catch (cause: Throwable) 把這 2 種例外一起接成 502
同一個檔案的最後一段是路由
@OptIn(ExperimentalKtorApi::class)
fun Route.todoImportRoute(importer: TodoImporter, events: TodoEvents) {
route("/todos") {
post("import/{externalId}") {
val externalId = call.parameters["externalId"]?.toIntOrNull()
?: throw ApiException(HttpStatusCode.BadRequest, "externalId 要是數字")
val todo = importer.import(externalId)
events.publish(TodoEvent.Created(todo))
call.respond(HttpStatusCode.Created, todo)
}.describeJson(
summary = "從外部服務匯入一筆待辦",
successStatus = HttpStatusCode.Created,
responseType = typeOf<Todo>(),
badRequest = true,
notFound = true,
)
}
}
events.publish 那一行讓匯入進來的待辦跟自己建的走同一條事件流,day 30 那個 WebSocket 訂閱者不用改任何東西就會收到
src/main/kotlin/com/cashwu/todo/Application.kt 的 DI 容器多註冊 2 個
dependencies {
provide<TodoEvents> { TodoEvents() }
+ provide<HttpClient> { todoImportClient(CIO.create(), resolve<TodoConfig>().importer) }
+ provide<TodoImporter> { TodoImporter(resolve(), resolve<TodoConfig>().importer, resolve()) }
}
取用的地方多一行,路由那邊也多一行
val events: TodoEvents by dependencies
+ val importer: TodoImporter by dependencies
routing {
// ...
authenticate(TODO_AUTH) {
// ...
todoRoutes(repository, events)
+ todoImportRoute(importer, events)
}
}
todoImportRoute 掛在 authenticate(TODO_AUTH) 裡面,跟其他 todo 路由同一層,匯入會寫資料庫,沒有理由比新增寬鬆
HttpClient 進容器還有一個附帶效果,day 18 那篇量過容器會在 ApplicationStopping 的時候把實作 AutoCloseable 的相依關掉,而 HttpClient 宣告的是 java.io.Closeable,那個介面本身 extends AutoCloseable,所以照樣被關,不用自己在哪裡寫一行 close(),這件事有測試驗證,後面會貼
資料庫還是 day 23 那個 docker compose 起的 PostgreSQL,./gradlew run 的指令跟 day 28 一樣
docker compose up -d
JWT_SECRET=local-dev-secret-32-bytes-minimum ALICE_PASSWORD=alice-secret \
BOB_PASSWORD=bob-secret DB_PASSWORD=todo ./gradlew run
登入拿 access token 的做法跟 day 30 一樣,然後打匯入
curl -s -X POST localhost:8080/login \
-H 'Content-Type: application/json' \
-d '{"name":"alice","password":"alice-secret"}' > /tmp/login.json
AT=$(python3 -c 'import json; print(json.load(open("/tmp/login.json"))["accessToken"])')
curl -i -X POST localhost:8080/todos/import/1 -H "Authorization: Bearer $AT"
回來的東西逐字是這樣,Date 跟 Server 這 2 行省略
HTTP/1.1 201 Created
X-Request-Id: hn9j3r55r4+d
X-Response-Time: 767ms
Content-Length: 93
Content-Type: application/json
{"id":4,"title":"delectus aut autem","done":false,"created_at":"2026-09-04T23:18:53.725674Z"}
id 是 4 不是 1,因為那是自己的資料庫給的號碼,外部那個 1 只是查詢用的,title 原封不動搬過來,外部的 completed: false 變成自己的 done: false,created_at 是匯入的時間而不是外部那筆的建立時間
id 不一定是 4,要看當下資料庫 table 最後一筆資料的 id 決定
外部找不到那一筆的時候
curl -s -X POST localhost:8080/todos/import/9999 -H "Authorization: Bearer $AT"
{"status":404,"message":"外部服務沒有 id 9999 的待辦","details":[]}
把 IMPORT_BASE_URL 指到一個沒有人聽的 port,其他都不動
IMPORT_BASE_URL=http://127.0.0.1:9099 JWT_SECRET=local-dev-secret-32-bytes-minimum \
ALICE_PASSWORD=alice-secret BOB_PASSWORD=bob-secret DB_PASSWORD=todo ./gradlew run
$AT 還是前面那個 token,換一個 shell 打 2 次,順便把狀態碼跟耗時印出來
for i in 1 2; do
curl -s -w ' | %{http_code} %{time_total}s\n' \
-X POST localhost:8080/todos/import/1 -H "Authorization: Bearer $AT"
done
{"status":502,"message":"外部服務連不上 (HttpRequestTimeoutException)","details":[]} | 502 3.346917s
{"status":502,"message":"外部服務連不上 (HttpRequestTimeoutException)","details":[]} | 502 3.608761s
2 件事同時發生了,使用者等了 3.3 秒才拿到失敗,而那個例外的名字是 HttpRequestTimeoutException,看起來像對方接了但太慢
它是錯的,9099 沒有人聽,連線是立刻被拒絕的,從頭到尾沒有任何一次真的 timeout,拆這件事的方法是一次只改一個環境變數,程式碼一行都不動
IMPORT_TIMEOUT |
IMPORT_RETRIES |
浮上來的例外 | 2 次的耗時 |
|---|---|---|---|
| 2000 | 2 | HttpRequestTimeoutException |
3.347s / 3.609s |
| 30000 | 2 | ConnectException |
3.035s / 2.802s |
| 2000 | 0 | ConnectException |
0.048s / 0.004s |
第 1 件事是那 3 秒是誰花的,關掉重試之後同一個失敗 48 毫秒就回來了,連線本身根本沒花時間,3 秒全部是重試之間的退避在等,exponentialDelay(base = 1.2) 加上預設的 baseDelayMs = 1000,2 次重試的固定延遲是 1000 跟 1200 毫秒,後面還各有一段隨機抖動,加起來就是那 3 秒
第 2 件事是 timeout 這個參數在這裡只決定名字,設 2 秒的時候,重試的等待先把那 2000 毫秒吃光,於是 HttpTimeout 先開口,把真正的死因蓋掉,設 30 秒的時候退避跑得完,浮上來的就是真的那一個 ConnectException
正確的因果是「重試的等待撐爆了 request timeout」,不是「timeout 之後又重試」,照前一種讀法會去調 timeout,而 timeout 從來不是問題,對方根本沒開,照後一種讀法才會去看重試次數,那才是 3 秒的來源
HttpTimeout 跟 HttpRequestRetry 這 2 個 plugin 在這裡是疊在一起的,request timeout 罩的是整個帶重試的請求而不是單次嘗試,計時只起跑一次,不會每次重試都重來,所以 timeoutMillis 不能當成「單次連線最多等多久」
但它也不等於「使用者最多等多久」,上面那 3.3 秒就是反例,退避那段 delay 不在它能中斷的範圍裡,時間到了也要等手上這一段跑完才輪得到例外冒出來,要算使用者最多等多久,得把 timeout、重試次數跟退避的總和一起看
上面那些行為都是靠改環境變數、真的打出去才看到的,接下來同一批行為要在不碰網路的情況下重現一次,換掉的只有 engine
新增一個檔案是 src/test/kotlin/com/cashwu/todo/TodoImporterTest.kt,共用的零件放在最上面
private const val BASE_URL = "https://todos.example.com"
private val testImporterConfig = ImporterConfig(
baseUrl = BASE_URL,
timeoutMillis = 2_000,
maxRetries = 2,
)
private const val EXTERNAL_BODY =
"""{"userId":1,"id":7,"title":"delectus aut autem","completed":true}"""
那個網域是 example.com,RFC 2606 保留給文件使用、不會落到任何人手上的,挑它不是隨便挑的,萬一哪天 MockEngine 沒接到而請求真的送出去了,todos.example.com 這個子網域也不解析,打不到任何一個真實的服務
seen 是 class 裡的一個欄位,engine 每收到一個請求就往裡面放
class TodoImporterTest {
private val seen = mutableListOf<HttpRequestData>()
private fun importer(
config: ImporterConfig = testImporterConfig,
respondWith: MutableList<Pair<HttpStatusCode, String>> =
mutableListOf(HttpStatusCode.OK to EXTERNAL_BODY),
): TodoImporter {
val engine = MockEngine { request ->
seen += request
val (status, body) = respondWith.removeFirstOrNull()
?: (HttpStatusCode.OK to EXTERNAL_BODY)
if (status.value >= 500) {
respondError(status)
} else {
respond(
content = body,
status = status,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
}
return TodoImporter(
todoImportClient(engine, config),
config,
FakeTodoRepository(),
)
}
}
MockEngine { } 收到的是一個 HttpRequestData,回的是一個假造的回應,中間沒有 socket、沒有 DNS、沒有 port,respondWith 是一個會被逐次拿掉第 1 筆的清單,所以同一個測試可以安排「第 1 次 500、第 2 次 500、第 3 次 200」這種劇本,seen 記下每一個經過的請求,斷言就對著它做
先確認送出去的東西跟拿回來的東西都對
@Test
fun `an import becomes one get against the configured base url`() = runTest {
val todo = importer().import(7)
assertEquals(1, seen.size)
assertEquals(HttpMethod.Get, seen[0].method)
assertEquals("$BASE_URL/todos/7", seen[0].url.toString())
assertEquals("delectus aut autem", todo.title)
assertTrue(todo.done)
assertEquals(4, todo.id)
}
assertTrue(todo.done) 這一行盯的是欄位改名這件事,外部叫 completed,自己叫 done,中間那個對應斷掉的話這裡會失敗,assertEquals(4, todo.id) 則是說明匯入拿到的是本地的新號碼,不是外部的 7
外部多回欄位也要有測試驗證
@Test
fun `the fields the external service adds are ignored`() = runTest {
val todo = importer(
respondWith = mutableListOf(
HttpStatusCode.OK to
"""{"userId":1,"id":7,"title":"倒垃圾","completed":false,"priority":"high"}""",
),
).import(7)
assertEquals("倒垃圾", todo.title)
assertFalse(todo.done)
}
那個 priority 是憑空多出來的欄位,模擬的就是外部服務某天自己改版,拿掉 ignoreUnknownKeys 這個測試就會失敗
失敗的對應也各有一個
@Test
fun `a missing external todo becomes a 404`() = runTest {
val failure = assertFailsWith<ApiException> {
importer(respondWith = mutableListOf(HttpStatusCode.NotFound to "")).import(404)
}
assertEquals(HttpStatusCode.NotFound, failure.status)
assertEquals(1, seen.size)
assertContains(failure.message, "404")
}
@Test
fun `a 200 that is not the expected json becomes a 502`() = runTest {
val failure = assertFailsWith<ApiException> {
importer(
respondWith = mutableListOf(HttpStatusCode.OK to "<html>maintenance</html>"),
).import(7)
}
assertEquals(HttpStatusCode.BadGateway, failure.status)
assertContains(failure.message, "JSON")
}
第 2 個測試裡那坨 HTML 不是亂寫的,外部服務進維護模式的時候回一頁 HTML 加 200 是很常見的事,這比回 503 更難查,因為狀態碼看起來是好的
retryOnServerErrors 這個名字騙人,HttpRequestRetryConfig 的建構子本身會先跑一次 retryOnExceptionOrServerErrors(3),retryOnServerErrors 換掉的只是「看回應決定要不要重試」那一半,看例外的那一半判斷條件原封不動留著,次數則是共用的,retryOnServerErrors(maxRetries = 2) 會把 init 設的 3 一起蓋掉,所以例外那一側也是最多打 3 次
所以 5xx 會重試是名字說的,IOException 也會被重試是名字沒說的
@Test
fun `an engine that throws is retried too and becomes a 502`() = runTest {
val attempts = AtomicInteger()
val engine = MockEngine {
attempts.incrementAndGet()
throw java.io.IOException("線斷了")
}
val importer = TodoImporter(
todoImportClient(engine, testImporterConfig),
testImporterConfig,
FakeTodoRepository(),
)
val failure = assertFailsWith<ApiException> { importer.import(7) }
assertEquals(HttpStatusCode.BadGateway, failure.status)
assertContains(failure.message, "IOException")
assertEquals(3, attempts.get())
}
@Test
fun `a timeout is the one failure that is not retried`() = runTest {
val config = testImporterConfig.copy(timeoutMillis = 500)
val attempts = AtomicInteger()
val engine = MockEngine {
attempts.incrementAndGet()
delay(3_000)
respond(content = EXTERNAL_BODY, status = HttpStatusCode.OK)
}
val importer = TodoImporter(todoImportClient(engine, config), config, FakeTodoRepository())
val failure = assertFailsWith<ApiException> { importer.import(7) }
assertEquals(HttpStatusCode.BadGateway, failure.status)
assertEquals(1, attempts.get())
}
assertEquals(3, attempts.get()) 跟 assertEquals(1, attempts.get()) 這 2 行,把「retryOnServerErrors 沒有關掉例外重試」跟「timeout 是唯一不重試的」寫成 2 個會被 CI 抓的東西,這是那種只靠讀名字會猜錯的行為,猜錯了不會有人告訴你,所以要寫成測試
timeout 不重試的原因在建構子的預設值,它用的是 retryOnException(maxRetries, retryOnTimeout = false),倒回去看前面那個沒人聽的 port 就通了,連線被拒屬於例外那一半,所以它被重試,而 timeout 不會
MockEngine 在這裡做的 2 件事順便展示了它的範圍,它可以直接丟例外,網路斷掉不用真的拔網路線,也可以 delay 之後才回,timeout 不用真的等一個慢的伺服器,那 0.5 秒是整份測試裡唯一真的在等的時間
回應那一側的重試也有測試
@Test
fun `a server error that clears on the third try still imports`() = runTest {
val todo = importer(
respondWith = mutableListOf(
HttpStatusCode.InternalServerError to "",
HttpStatusCode.InternalServerError to "",
HttpStatusCode.OK to EXTERNAL_BODY,
),
).import(7)
assertEquals("delectus aut autem", todo.title)
assertEquals(3, seen.size)
}
@Test
fun `a client error is not retried`() = runTest {
val failure = assertFailsWith<ApiException> {
importer(respondWith = mutableListOf(HttpStatusCode.BadRequest to "")).import(7)
}
assertEquals(HttpStatusCode.BadGateway, failure.status)
assertEquals(1, seen.size)
}
maxRetries = 2 是「重試 2 次」不是「總共 2 次」,所以最多打 3 次,第 1 個測試安排的 3 個回應剛好用完,第 2 個驗的是 4xx 不重試,把一個格式錯誤的請求再送 2 次不會有不同的結果
前面說 HttpClient 進了容器就不用自己關,這個測試把那句話變成一個破了就會被抓到的斷言
@Test
fun `the client is closed when the application stops`() {
var client: HttpClient? = null
testApplication {
mockedImportApplication { respond(content = EXTERNAL_BODY, status = HttpStatusCode.OK) }
application { client = dependencies.resolve<HttpClient>() }
bearerClient().get("/todos")
assertTrue(client!!.isActive)
}
assertFalse(client!!.isActive)
}
中間那個 bearerClient().get("/todos") 不是在測路由,是為了讓 application 真的起來,容器只關已經建出來的相依
testApplication { } 的 block 結束時 application 才停,所以 block 裡面斷言還活著、block 外面斷言關掉了,寫法跟 day 18 那個 AutoCloseable 的測試同一個路數
isActive 這個名字要看清楚,它不是 HttpClient 自己的屬性,看一下它的宣告
public class HttpClient(
public val engine: HttpClientEngine,
private val userConfig: HttpClientConfig<out HttpClientEngineConfig> = HttpClientConfig()
) : CoroutineScope, Closeable {
HttpClient 實作了 CoroutineScope,測試檔 import 進來的是 kotlinx.coroutines.isActive,那是 CoroutineScope 的擴充屬性,讀的是 coroutineContext[Job],所以這個測試斷的是「那個 client 的 job 還活著沒有」,不是某個 closed 旗標,這樣寫的好處是就算哪天容器換了關閉的實作,只要行為還在,測試就還是對的
最後一個測試把 DI 覆寫、路由跟 day 30 的事件流串成一條
@Test
fun `an import through the api creates a todo and publishes an event`() = testApplication {
val published = CompletableDeferred<TodoEvent>()
mockedImportApplication {
respond(
content = EXTERNAL_BODY,
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
application {
val events = dependencies.resolve<TodoEvents>()
launch(start = CoroutineStart.UNDISPATCHED) {
events.events.collect { published.complete(it) }
}
}
val response = bearerClient().post("/todos/import/7")
assertEquals(HttpStatusCode.Created, response.status)
val event = withTimeout(5_000) { published.await() }
assertEquals("delectus aut autem", (event as TodoEvent.Created).todo.title)
assertTrue(event.todo.done)
}
CoroutineStart.UNDISPATCHED 在這裡的作用是保證 collector 在 module() 繼續往下之前就訂閱好了,不然事件有可能在還沒有人聽的時候就發出去
mockedImportApplication 是這個 class 自己的 helper,做的是 day 24 拆過的那一套,把 modules.size 設成 0,先用一個 application { } 把 HttpClient 換成 MockEngine 的版本,再用第 2 個 application { } 跑 module()
private fun ApplicationTestBuilder.mockedImportApplication(
handler: suspend MockRequestHandleScope.(HttpRequestData) -> HttpResponseData,
) {
configure(overrides = {
h2Database()
put("todo.database.url", "jdbc:h2:mem:importer")
put("ktor.application.modules.size", "0")
})
application {
dependencies.provide<HttpClient> {
todoImportClient(MockEngine(handler), testImporterConfig)
}
}
application { module() }
serverConfig { developmentMode = false }
}
那一行 put("todo.database.url", "jdbc:h2:mem:importer") 是後來補的,其他測試共用 jdbc:h2:mem:todo 這個記憶體資料庫,匯入的測試會往裡面塞資料,換一個名字才不會影響到其他測試對資料列數的斷言
這篇讓 todo-api 主動往外面請求資料,正式環境與測試共用同一份 HttpClient 設定,差別只在 engine,正式環境用 CIO、測試用 MockEngine,所以 timeout、重試、反序列化與失敗的對應都能在不碰網路的情況下驗證
失敗要對應成什麼,是這一篇真正的產品決定,外部的 404 變成自己的 404,其他一律 502,兩者對使用者的意義不一樣,一個是換個 id 再試,一個是等我這邊修
重試的成本不能只看 timeout,同一個連線被拒的失敗,重試開著要 3 秒、關掉只要 48 毫秒,而 timeout 設多少只決定最後浮上來的例外叫什麼名字,HttpTimeout 罩的是整個帶重試的請求,但它擋不住重試之間的退避,所以它不是使用者總等待時間的上界
這篇留下的問題有 5 個,匯入是同步的,外部服務慢的時候使用者就得等,沒有排程或背景匯入這個選項,沒有快取,同一個 externalId 匯入 2 次會打 2 次外部服務,自己的資料庫也會多留一筆,沒有斷路器,外部服務掛掉的時候每一個請求都會自己去撞一輪重試,撞完才回 502,容器裡只有一個 HttpClient,timeoutMillis 跟 maxRetries 是裝在那個 client 身上而不是掛在單一整合上,現在只有匯入在用,兩者剛好重疊看不出差別,哪天多接一個對外呼叫就會一起吃到這一份設定,最後是 MockEngine 測得到請求跟回應,測不到真的網路行為,DNS、TLS、連線池都不在範圍裡
這 31 天長出來的東西,回的一直都是 JSON,服務的一直都是機器,下一篇讓同一批路由多長出一種給人看的形式,用 htmx 接起來,/todos 帶著 HX-Request 就回 HTML fragment,不帶還是回原本那份 JSON
真正的麻煩不在 HTML,瀏覽器點連結、打網址列的時候沒有地方讓你塞 Authorization header,day 26 選 Bearer 的那個決定,到那一篇才會第 1 次遇到它沒有考慮過的客戶端
同步刊登於 Blog
圖片來源:AI 產生