
傳說中,普羅米修斯把火偷給人類以後,宙斯氣到決定換一種方式報復人類,他命令赫菲斯托斯用泥土造出一名女子,再讓其他神明替她裝扮、賦予能力,最後把她送到厄庇米修斯面前,而她就是潘朵拉。
普羅米修斯明明早就警告弟弟,不要接受宙斯送來的任何東西,偏偏厄庇米修斯還是收下了這份禮物,等到發現不對勁時已經來不及了。
在潘朵拉來到人間以前,人類原本不必承受沉重的勞動、疾病與各種苦難,因為這些災禍全被關在一只大型陶罐裡,結果潘朵拉一掀開蓋子,除了希望仍留在罐中以外,疾病、勞動與各種苦難全部跑了出去,從此散到人間,再也不可能回到原本的樣子。
看到這裡,各位可能會想,厄庇米修斯也太雷了吧?哥哥都已經特地提醒「宙斯送來的東西不要亂收」,結果人家一送到門口,他還是很開心地收了。
寫系統的我們,好像也沒有資格笑他 XD
第三方 API 文件寫得完整、測試環境打得通,我們很自然把它直接依賴進主流程,它哪天 timeout、維護、改版甚至直接消失,我們的系統要怎麼辦?
今天介紹的,就是這麼一個運行很多年以後才發現有詐的範例。
今日範例 是一套購物網站的退貨系統,收到申請後會先呼叫合作物流商的 API 安排到府取件,取得取件編號,最後才把退貨申請寫進資料庫。
這幾天客服突然收到一堆內容幾乎相同的回報:
最近送出退貨申請時,都要等十幾秒,最後只看到「系統發生錯誤」,以前完全沒有這個問題,也沒有動過它,為什麼現在會出事?
於是我們查看 application log 後,發現每一筆 request 都停在物流商 API /api/v1/pickups,大約十五秒後才因為 HttpClient timeout 結束,Controller 接到 Exception 後回傳 500 Internal Server Error,所以使用者才會看到系統錯誤。
各位可能會覺得奇怪,API 都已經壞掉了,為什麼還要等客服收到回報,我們才知道出事?
因為這支 API 上線以來從沒出過問題,團隊一直把它當成相當穩定的既有串接,production 雖然會留下 timeout 的 error log,卻沒有觀測外部 API 與告警,所以才會發生使用者幫我們做 error driven 的狀況....
我們到物流商的官方狀態頁查看,才知道他們遭遇資安攻擊,為了緊急處理問題,已經暫停物流 API v1,官方公告要求串接方先改用 v2。
偏偏 v2 不再使用原本的認證方式,request 與錯誤格式也全部換掉,所以這不是換一段 URL 就能在客服回報當下直接上線的 Hotfix。
不過有個問題我們必須要先釐清:
物流商的 API 現在不能用,網站難道就只能一起停止退貨嗎?
如果這套網站唯一的工作就是轉呼叫物流商,那確實很奇怪,顧客幹嘛不直接去找物流商就好?購物網站至少應該能先接住退貨申請,產生自己的退貨編號,物流商只負責後面的到府取件,而且物流 API 使用的是商家的合約、認證資訊與內部資料,顧客本來就不會直接呼叫它。
換句話說,我們可以理解成,物流服務暫時不能預約,並不代表我們連退貨申請都不能先收下來,現在整個系統會跟著停擺,純粹是 Legacy Code 把兩件事情綁得太緊了。
先來看看原本的 Controller:
[HttpPost]
public async Task<ActionResult<ReturnReceipt>> Create(
CreateReturnRequest input,
CancellationToken ct)
{
try
{
var result = await _service.CreateAsync(input, ct);
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "建立退貨申請失敗");
return Problem(
statusCode: StatusCodes.Status500InternalServerError,
title: "系統發生錯誤");
}
}
Controller 本身看起來沒有什麼特別的地方,再來看看 ReturnService,它會先呼叫物流 v1,拿到取件編號以後才把退貨申請寫進資料庫:
public async Task<ReturnReceipt> CreateAsync(
CreateReturnRequest input,
CancellationToken ct)
{
_httpClient = new HttpClient
{
BaseAddress = new Uri(_configuration["Logistics:BaseAddress"]!),
Timeout = _configuration.GetValue<TimeSpan>("Logistics:Timeout"),
};
_httpClient.DefaultRequestHeaders.Add(
"X-Api-Key",
_configuration["Logistics:ApiKey"]);
var response = await _httpClient.PostAsJsonAsync(
"/api/v1/pickups",
new
{
merchant_no = _configuration["Logistics:MerchantNo"],
consignee_name = input.RecipientName,
consignee_tel = NormalizePhone(input.RecipientPhone),
consignee_addr = input.PickupAddress,
},
ct);
response.EnsureSuccessStatusCode();
var pickup = await response.Content
.ReadFromJsonAsync<PickupResponse>(cancellationToken: ct);
if (pickup!.ResultCode != "0000")
{
throw new InvalidOperationException(
$"物流建立取件失敗:{pickup.ResultMessage}");
}
var pickupNo = pickup.Data.PickupNo;
var id = await _repository.InsertAsync(input, pickupNo, ct);
return new ReturnReceipt() {
Id = id,
PickupStatus= PickupStatus.Scheduled,
PickupNo = pickupNo
};
}
正式環境的 appsettings.json 則保留物流網址與十五秒的 timeout:
{
"Logistics": {
"BaseAddress": "https://logistics.example.com",
"Timeout": "00:00:15",
"ApiKey": "ApiKey",
"MerchantNo": "MerchantNo"
}
}
這種直接在 production Service 裡 new HttpClient() 的場景,在 Legacy Code 裡完全不稀奇,雖然網址、timeout 與認證資料已經放進 configuration,真正的 HTTP 呼叫仍然和退貨流程黏在一起,平常能動的時候大家也不會特別去碰它,直到出問題。
那麼我們第一步應該要怎麼做?當然還是 Characterization Test。
接著就可以來寫 Characterization Test,我們需要先把 Issue 描述的現況固定下來:
物流 v1 遲遲沒有回應,等到 timeout 後回傳系統錯誤。
但是要怎麼模擬 timeout 這件事情呢?
WireMock.Net 可以在測試裡啟動一個由我們控制的 HTTP Server,依照 request 的路徑與 method 回傳安排好的 response,也能透過 WithDelay 模擬第三方遲遲沒有回應,我們讓 /api/v1/pickups 一秒後才回應,再用測試 configuration 把 production ReturnService 讀到的 timeout 縮短成 200 毫秒,不需要每次跑測試都真的坐在那邊等十五秒。
讓我們先透過以下指令先安裝 WireMock.Net:
dotnet add package WireMock.Net
接著來設置我們假的 WireMock Server:
private void SimulateLogisticsV1Timeout()
{
_mockServer
.Given(
Request.Create()
.WithPath("/api/v1/pickups")
.UsingPost())
.RespondWith(
Response.Create()
.WithDelay(TimeSpan.FromSeconds(1))
.WithStatusCode(HttpStatusCode.OK)
.WithBodyAsJson(new
{
resultCode = "0000",
resultMessage = "OK",
data = new { pickupNo = "PICKUP-TEST-001" },
}));
}
這個 Stub 讓 /api/v1/pickups 等待一秒後才回應,比待會我們設定的測試用 200ms timeout 整整長了五倍,確保 HttpClient 一定會先 timeout,而回傳一份合法的成功 body 看起來多此一舉,因為反正 timeout 前連 body 都沒機會送到,不過這份 body 是為了後面驗證測試才放的,稍後會解釋。
接著我們建立測試用 configuration,再與 Mock Repository 組合 ReturnService :
private ReturnsController BuildController(TimeSpan timeout)
{
var values = new Dictionary<string, string?>
{
["Logistics:BaseAddress"] = _mockServer.Url,
["Logistics:Timeout"] = timeout.ToString("c"),
["Logistics:ApiKey"] = "test-api-key",
["Logistics:MerchantNo"] = "TEST-MERCHANT",
};
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
var service = new ReturnService(
configuration,
_repository.Object);
return new ReturnsController(
service,
NullLogger<ReturnsController>.Instance);
}
我們可能會好奇,是不是還需要把 HttpClient 抽出來注入,才有辦法讓測試?答案是不需要,而且這正是這種設計的關鍵,如果改用 Mock 來模擬 timeout,timeout 行為就會由 test double 決定,而不是真實的 HttpClient 機制在跑。
WireMock 提供的是一個真正在 localhost 監聽的 HTTP server,production 程式碼的整條呼叫路徑從頭到尾都是真的,只有對端從物流商換成了我們控制的伺服器。
所以可以看到在上述程式碼中,我們把原本在設定檔的 Logistics:BaseAddress 換成 WireMock 的本機位址,ReturnService 裡面的 new HttpClient 就會如實讀到這個設定,然後對 WireMock 發出真實的 TCP 連線。
神奇吧!
接下來因為 WireMock Server 需要在測試開始前啟動、結束後關閉,所以我們用建構子與 IDisposable 管理生命週期:
public class ReturnControllerTests : IDisposable
{
private readonly WireMockServer _mockServer;
private readonly Mock<IReturnRepository> _repository;
public ReturnControllerTests()
{
_mockServer = WireMockServer.Start();
_repository = new Mock<IReturnRepository>();
}
public void Dispose() => _mockServer.Stop();
// ... 略
}
測試程式碼如下:
[Fact]
public async Task 建立退貨申請_物流V1逾時_目前回傳系統錯誤()
{
SimulateLogisticsV1Timeout();
var sut = BuildController(
timeout: TimeSpan.FromMilliseconds(200));
var response = await sut.Create(
NewReturnRequest(),
CancellationToken.None);
_repository.Verify(
x => x.InsertAsync(
It.IsAny<CreateReturnRequest>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()),
Times.Never);
var result = Assert.IsType<ObjectResult>(response.Result);
Assert.Equal(
StatusCodes.Status500InternalServerError,
result.StatusCode);
var problem = Assert.IsType<ProblemDetails>(result.Value);
}
測試先呼叫 SimulateLogisticsV1Timeout() 安排 stub,然後用 200ms timeout 建立 Controller,發出一筆退貨申請。
Times.Never 的驗證,則是把「資料庫沒被寫入」這件事情明確被檢查。
最後的 result 以及 problem 則都是在驗證 Controller 的 Response。
測試跑起來是綠燈,代表我們成功重現目前的行為:
外部物流服務沒有回應時,退貨申請根本不會建立,使用者等到最後只會收到
500。
不過測試目前是綠燈的,我們就能直接相信它真的抓得到 timeout 嗎?不一定,如果 WireMock.Net 等一秒後只回傳空的 200 OK,就算我們把 timeout 改長,程式也可能因為 response 無法反序列化而再次落進 500,最後測試仍然是綠燈,只是通過的原因就完全不一樣,所以前面的 Stub 不只加入 delay,也刻意回傳一份合法的物流 v1 成功資料的原因就在這
在 Day15 我們學到如何透過突變來檢驗我們的測試,但今天我們不用大費周章,來做個手動突變,把 ReturnService 裡真正套用 timeout 的程式先故意改壞:
- Timeout = _configuration.GetValue<TimeSpan>(
- "Logistics:Timeout"),
+ Timeout = TimeSpan.FromSeconds(2),
測試原本會從設定檔中取得 200ms timeout,手動改成兩秒後,物流 v1 來得及回傳成功結果,ReturnService 會繼續呼叫 Repository,Controller 最後也不再回傳 500。
執行測試後,原本的測試就會亮起紅燈,代表這支測試成功抓到了突變體。
這麼做而不是直接修改測試設定檔的原因,是因為那只是在更換測試條件,我們現在破壞的是真正 ReturnService 使用設定的方式,這才是確保真的有測到 production code。
那麼到這裡 Characterization Test 已經成功還原案發現場了,再來呢?
接下來真正要討論的不是要把 timeout 從十五秒改成三秒,三秒後顯示系統錯誤,那確實比起讓使用者苦苦等待還要來得好,但我想使用者應該不會因此而被我們感動了 XD
經過團隊一番討論後,我們達成一致確定「退貨申請已受理」與「物流取件已預約」是兩件不同的事情,即便是放在使用者那邊也是這樣的,既然使用者是透過我們的系統去向物流預約,那使用者就不應該知道那麼多物流的細節,不然就自己去跟物流申請不就好了。
使用者不需要知道物流 API 是 v1、v2、timeout 還是正在維護,但仍然需要知道自己的取件目前有沒有安排完成,當然我們也不能因為物流那邊出問題,就讓系統的回饋受到影響對吧?
所以最後決定透過狀態來解決這件事情:
| 狀態 | 我們已經能保證的事情 |
|---|---|
| 已受理 | 申請已保存、使用者取得退貨編號 |
| 已預約 | 物流商已接受請求、系統取得取件編號 |
物流商暫時不能用時,我們仍然可以先做到第一列,至於第二列,就讓這筆資料保留在 Pending 的狀態,不論物流 API 狀態如何,在我們系統顯示就是「已受理」,於是我們就可以等 v2 串接完成後再處理這些 Pending 的單。
所以新的 Controller 行為我們預計會是如下:
POST /returns
↓
201 Created
Location: /returns/7318
{
"id": 7318,
"pickupStatus": "Pending",
"pickupNo": null
}
確認新的行為後,我們就來看看我們有什麼方式怎麼來實踐!
我們的目標定義 POST /returns 要先完成自己的工作,保存退貨申請,回傳退貨編號,物流取件則不在此處作業。
但是要怎麼運作?我們真正需要的是一種能讓 Controller 先完成自己的工作、把取件預約的事情傳遞出去、讓另一個角色在背景負責後續呼叫的機制。
這就必須提到,有一種模式叫做 生產者-消費者(Producer–Consumer),我們先來了解這個模式是怎麼運作的,與其長篇大論說明,不如就容許我用一個簡易的範例做說明,在這個模式中我們會提到三種元素:
| 角色 | 餐廳比喻 | 在我們系統的對應 |
|---|---|---|
| Producer(生產者) | 結帳人員收下訂單,把餐單夾到夾單區 | 收下退貨申請後,把「取件預約」事件交出去 |
| Buffer(緩衝區) | 櫃檯與廚房之間夾單子的地方 | 存等待處理的取件事件,讓使用者不需要等待 |
| Consumer(消費者) | 廚房按照餐單備料出菜 | 持續取出或監聽事件、呼叫物流 API |

這樣一來,Controller 只需要把工作交出去就能立刻回應使用者,不管物流服務那邊狀態如何,退貨申請的受理與取件預約從此各自跑在自己的節奏上,互不干擾。
Producer–Consumer 沒有規定 Buffer 一定得怎麼去實作,所以在選工具之前,團隊需要先做評估,工作事件能不能遺失?流量有多大?消費者有幾組?系統目前已經維護了哪些基礎設施?維護成本又是如何?
帶著這些問題,我們來看幾個可行的方案:
| Buffer | 適合的場景 | 維護成本 |
|---|---|---|
| ConcurrentQueue | 同一個 Process 裡,多條執行緒交換短期工作 | 本身沒有非同步等待與持久化,還要另做通知,服務重啟後工作也會消失 |
| .NET Channel | 同一個 Process 裡的短期工作協調 | 最輕,但重啟後記憶體內容消失,無法單獨承擔這筆退貨申請 |
| SQL Server | 工作與業務資料在同一套系統 | 沿用現有 DB,但 polling、鎖定、重試與清理都得自己實作 |
| Transactional Outbox(Polling / CDC) | 要把資料庫裡的事件可靠地送往多個下游服務 | 要維護 Outbox、CDC 等管線、重試與重複事件處理,整體複雜度較高 |
| RabbitMQ、Kafka | 需要明確的確認、重新排隊與彈性路由、多個系統需要長期保存、重播與各自消費同一串事件 | 自行部署時要維護,叢集、監控..等,當然也可選擇代管服務 |
| Azure Service Bus、Amazon SQS | 系統本來就在相應雲端,希望由平台管理 | 少維護一套 Server,但仍有權限、費用...等問題 |
這張表格裡沒有最好的選擇,.NET Channel 很輕,代價是 Application 一重啟,還沒處理的工作就跟著消失,RabbitMQ、Kafka 與雲端 Queue 把更多功能都準備好了,但我們要面對的會是一連串的維運成本,每一種技術都只是在不同限制下做取捨,沒有哪一個是可以不看場景直接選擇來用的。
我們的習慣是先討論,工作能不能遺失?失敗後要重試多久?同一筆工作重複執行會不會出事?需不需要保證順序、讓多個系統各自消費,或把幾個月前的事件重新播放?團隊半夜收到告警時,真的有能力自動把 Infra 恢復嗎?答案不同,Buffer 的選擇自然就會不同。
更別說生產消費者本身不等同於可靠,也會有種種需要我們去面對的問題,也仍然需要更多知識成本。
不過無論最後技術選型是什麼,Producer–Consumer 想解決的問題都不會改變,Producer 只負責把工作交出去,Consumer 按照自己的節奏處理,不需要知道彼此怎麼實作,實踐「解耦合」。
當然不止表格這些,還有很多為此而生的 Infra 可以為我們所用,各位還記得我們在 Day23 聊過的 Testcontainers 嗎?因為本系列文章並不專業實踐這些設施 XD
如果想自己實作看看的話,可以透過 Testcontainers 在測試開始時啟動一個真正的容器,例如:
dotnet add package Testcontainers.MsSql
dotnet add package Testcontainers.RabbitMq
dotnet add package Testcontainers.Kafka
有了容器技術,我們甚至可以在根本還沒有建置 Infra 之前就開始 TDD 呢,這時代真是太美好了!
再次感恩鯨魚,讚嘆鯨魚!
今天我們先用 WireMock.Net 重現物流 API timeout,寫下 Characterization Test,再故意動手突變一次,確認這個測試的能抓住原本的行為,接著我們把「退貨申請已受理」與「物流取件已預約」拆成兩個狀態,這才發現真正需要調整的不是 timeout,而是退貨流程把自己的工作和第三方的工作綁在同一個 Request 裡了,再延續介紹到生產者-消費者模式。
我自己覺得最重要的,很多時候當我們把職責解耦合後,會發現有一部分的問題都會隨之迎刃而解,第三方服務出問題時,我們未必能立刻讓它恢復,但至少系統還是不能跟著暴斃,待不可控因素解決後仍然持續運作,而使用者毫無感覺。
明天我們繼續看:怎麼讓團隊也一起