iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Software Development

諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道系列 第 26

Day 26 -「潘朵拉的陶罐」從第三方掛了一路到 Producer–Consumer

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260826/20182564lZGNrf7plu.png

傳說中,普羅米修斯把火偷給人類以後,宙斯氣到決定換一種方式報復人類,他命令赫菲斯托斯用泥土造出一名女子,再讓其他神明替她裝扮、賦予能力,最後把她送到厄庇米修斯面前,而她就是潘朵拉。

普羅米修斯明明早就警告弟弟,不要接受宙斯送來的任何東西,偏偏厄庇米修斯還是收下了這份禮物,等到發現不對勁時已經來不及了。

在潘朵拉來到人間以前,人類原本不必承受沉重的勞動、疾病與各種苦難,因為這些災禍全被關在一只大型陶罐裡,結果潘朵拉一掀開蓋子,除了希望仍留在罐中以外,疾病、勞動與各種苦難全部跑了出去,從此散到人間,再也不可能回到原本的樣子。

看到這裡,各位可能會想,厄庇米修斯也太雷了吧?哥哥都已經特地提醒「宙斯送來的東西不要亂收」,結果人家一送到門口,他還是很開心地收了。

寫系統的我們,好像也沒有資格笑他 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
}

確認新的行為後,我們就來看看我們有什麼方式怎麼來實踐!


Producer–Consumer Pattern

我們的目標定義 POST /returns 要先完成自己的工作,保存退貨申請,回傳退貨編號,物流取件則不在此處作業。

但是要怎麼運作?我們真正需要的是一種能讓 Controller 先完成自己的工作、把取件預約的事情傳遞出去、讓另一個角色在背景負責後續呼叫的機制。

這就必須提到,有一種模式叫做 生產者-消費者(Producer–Consumer),我們先來了解這個模式是怎麼運作的,與其長篇大論說明,不如就容許我用一個簡易的範例做說明,在這個模式中我們會提到三種元素:

角色 餐廳比喻 在我們系統的對應
Producer(生產者) 結帳人員收下訂單,把餐單夾到夾單區 收下退貨申請後,把「取件預約」事件交出去
Buffer(緩衝區) 櫃檯與廚房之間夾單子的地方 存等待處理的取件事件,讓使用者不需要等待
Consumer(消費者) 廚房按照餐單備料出菜 持續取出或監聽事件、呼叫物流 API

https://ithelp.ithome.com.tw/upload/images/20260826/20182564G6eUtuHHN5.png

這樣一來,Controller 只需要把工作交出去就能立刻回應使用者,不管物流服務那邊狀態如何,退貨申請的受理與取件預約從此各自跑在自己的節奏上,互不干擾。

生產者消費者可以理解,但 Buffer 要怎麼做?

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 裡了,再延續介紹到生產者-消費者模式。

我自己覺得最重要的,很多時候當我們把職責解耦合後,會發現有一部分的問題都會隨之迎刃而解,第三方服務出問題時,我們未必能立刻讓它恢復,但至少系統還是不能跟著暴斃,待不可控因素解決後仍然持續運作,而使用者毫無感覺。

明天我們繼續看:怎麼讓團隊也一起

Reference


上一篇
Day 25 -「佩涅洛佩的婚床」製作 Golden Master
下一篇
Day 27 -「柏修斯的神器」對付梅杜莎不能只有一把劍,來聊聊團隊的漸進演化
系列文
諸神也搖頭的 Legacy Code: 30天 .NET 工程師生存之道30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言