iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Claude AI

用 AI Agent 重構一套無框架的 legacy PHP 系統系列 第 10

Day 10:外部 API 呼叫的介面化——從裸 curl 到 PSR-17/PSR-18

  • 分享至 

  • xImage
  •  

前言:這段程式碼「沒有改行為」,你怎麼證明?

「AI 說它只是把這段呼叫外部 API 的程式碼重構了一下,邏輯完全沒變,可以直接合併嗎?」

如果這段程式碼是裸寫的 curl_init()curl_exec(),我的答案是:你沒辦法證明,AI 自己也沒辦法證明。不是因為 AI 說謊,而是因為這種寫法從一開始就沒有留給任何人「驗證」的空間——要驗證「呼叫外部 API 的邏輯行為沒變」,唯一的辦法就是真的打一次那支外部 API,看回應對不對。而外部 API 通常不穩定、有副作用(可能真的觸發一筆交易)、甚至要花錢,沒有人會為了跑一次單元測試就真的打一次正式環境的第三方服務。

結果就是:這段程式碼永遠活在「沒有安全網」的狀態裡。這正好呼應 Day 01 立下的主題句——AI 給出的「已確認」結論,永遠只在它實際查過的範圍內成立。裸 curl 呼叫這種寫法,連「查證」這件事本身都做不到,AI 說「行為沒變」的時候,那句話背後根本沒有任何驗證支撐。

今日目標

  • 理解裸寫 curl_* 呼叫外部 API 為什麼讓「驗證程式碼行為沒變」變成不可能的任務
  • 認識 PSR-17(RequestFactoryInterfaceStreamFactoryInterface)跟 PSR-18(ClientInterface)分別解決了什麼問題
  • 看一組「裸 curl vs 介面化 + mock client」的具體對照
  • 建立「外部依賴要能被替換掉,才談得上測試」這個語言無關的判斷原則
  • 知道換成其他語言時,等效的做法大概長什麼樣

裸 curl 的根本問題:邏輯跟傳輸方式黏在一起

裸寫 curl_init()curl_setopt()curl_exec() 的呼叫程式碼,通常長得像這樣:組 URL、設定 header、決定要不要驗證 SSL、決定 timeout,全部寫在同一個函式裡,中間直接呼叫真正會發出網路請求的 curl 函式。這裡的根本問題不是「curl 不好用」,而是業務邏輯(要打哪支 API、帶什麼參數、怎麼解析回應)跟傳輸方式(真的透過網路送出 HTTP 請求)被焊死在一起,沒有任何介面可以插進去替換

對 AI 重構這種程式碼來說,這意味著什麼?意味著 AI 沒辦法寫一個測試去驗證「這段程式碼組出的 request 內容對不對」,因為唯一能執行這段程式碼的方式,就是真的發送一次網路請求。AI 要嘛跳過驗證、直接相信自己改對了,要嘛在測試裡真的打一次外部服務——這兩個選項都不是「安全動手」該有的樣子。

PSR-17/PSR-18:把「組什麼」跟「怎麼送」分開

PSR-17 定義了 RequestFactoryInterfaceStreamFactoryInterface 這類工廠介面,負責「組出一個 request 物件」;PSR-18 定義了 ClientInterface,負責「把這個 request 物件送出去、拿回一個 response 物件」。這兩個標準合起來的效果,是把「業務邏輯要組出什麼樣的請求」跟「這個請求實際上怎麼被送出去」徹底切開成兩個獨立的介面。

這個切開帶來的關鍵好處是:測試的時候,可以換一個不會真的發送網路請求的 ClientInterface 實作(例如 php-http/mock-client 這類套件),讓「驗證業務邏輯組出的 request 對不對」這件事,完全不需要依賴真的網路連線、不需要外部服務真的存在、不需要擔心副作用或費用。

用一組對照來看這個差異:

❌ 裸 curl:邏輯跟傳輸黏死,沒辦法測試
class PaymentGatewayClient
{
    public function charge(string $orderId, int $amount): array
    {
        $ch = curl_init('https://api.example-gateway.com/charge');
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
            'order_id' => $orderId,
            'amount'   => $amount,
        ]));
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        $response = curl_exec($ch);
        curl_close($ch);

        return json_decode($response, true);
    }
}
// 要驗證「組出來的 request body 對不對」,
// 唯一的辦法是真的打一次 api.example-gateway.com

✅ PSR-17/18:邏輯跟傳輸分開,可以用 mock client 測試
class PaymentGatewayClient
{
    public function __construct(
        private RequestFactoryInterface $requestFactory,
        private StreamFactoryInterface $streamFactory,
        private ClientInterface $httpClient,
    ) {}

    public function charge(string $orderId, int $amount): array
    {
        $body = $this->streamFactory->createStream(json_encode([
            'order_id' => $orderId,
            'amount'   => $amount,
        ]));

        $request = $this->requestFactory
            ->createRequest('POST', 'https://api.example-gateway.com/charge')
            ->withBody($body)
            ->withHeader('Content-Type', 'application/json');

        $response = $this->httpClient->sendRequest($request);

        return json_decode((string) $response->getBody(), true);
    }
}

// 測試時:換成 mock client,完全不打真正的網路
$mockClient = new MockClient();
$mockClient->addResponse(
    (new Psr17Factory())->createResponse(200)
        ->withBody((new Psr17Factory())->createStream('{"status":"paid"}'))
);

$gateway = new PaymentGatewayClient(
    new Psr17Factory(),
    new Psr17Factory(),
    $mockClient,
);

$result = $gateway->charge('order-123', 1000);

// 可以驗證:mock client 實際收到的 request body 是不是預期的內容
$sentRequest = $mockClient->getRequests()[0];
assert(json_decode((string) $sentRequest->getBody(), true) === [
    'order_id' => 'order-123',
    'amount'   => 1000,
]);

這組對照差別不在「哪個寫法比較潮」,而在右邊的版本讓「AI 說這段程式碼行為沒變」這句話第一次有辦法被驗證——測試可以直接斷言 mock client 收到的 request 內容,不用真的連上外部服務。

AI 重構外部 API 呼叫程式碼時最危險的狀態,不是它改錯了什麼,而是它連「改對了沒有」都沒有辦法自己驗證。 介面化不是為了寫起來優雅,是為了把這個「沒辦法驗證」的狀態,變成「可以驗證」的狀態。

這是語言無關的原則,PSR-17/18 只是 PHP 生態的實現方式

「外部依賴要能被替換掉,才談得上測試」這件事跟程式語言完全無關——PSR-17/18 是 PHP 生態的具體實現方式,換成其他語言,做的是同一件事,只是工具長得不一樣:Java 生態常見做法是透過依賴注入把 HttpClient 抽象成介面,測試時換成 mock 實作;Node.js 生態常用 nockmsw 這類工具攔截 HTTP 呼叫、回傳假資料;Python 生態則有 requests-mockresponses 這類套件對 requests 函式庫做同樣的事。工具不同,但背後的判斷邏輯一模一樣:能不能在測試裡把「真的發送網路請求」這件事換掉。

這也是為什麼即使你手上的 legacy 系統不是 PHP,這篇的核心判斷依然用得上——遇到裸寫的 HTTP 呼叫程式碼,第一個要問的問題永遠是:這段程式碼有沒有辦法在不真的連網的情況下被驗證?如果答案是沒有,那就是該介面化的訊號。

今日思考題

回頭看看你手上系統裡呼叫外部 API 的程式碼,有多少段是裸寫的 HTTP 呼叫、完全沒辦法在測試裡替換掉?如果 AI 要重構這些程式碼,它有辦法自己驗證「行為沒變」嗎,還是只能憑感覺說「應該沒問題」?

今日重點回顧

  • 裸寫 curl_* 呼叫外部 API,讓「業務邏輯」跟「真的發送網路請求」黏在一起,沒有任何介面可以替換
  • 這導致 AI 沒辦法驗證「重構後行為沒變」,只能真的打一次外部服務,或乾脆不驗證
  • PSR-17(RequestFactoryInterface/StreamFactoryInterface)負責組 request,PSR-18(ClientInterface)負責送出,切開後測試時可以換成 mock client
  • 核心原則跟語言無關:外部依賴要能被替換掉才談得上測試,PHP 用 PSR-17/18,其他語言各自有等效工具(Java 的 DI + mock HttpClient、Node.js 的 nock/msw、Python 的 requests-mock)

明日預告

明天要從「介面化的原則」往下走一步,進入具體的遷移實戰:一支既有的 legacy 廠商接線程式碼,怎麼一步步拆成一個獨立的 package,讓「這支程式碼屬於哪個廠商、遵守什麼協定」這件事本身也變得清楚可管理。


上一篇
Day 09:Repository 重構實戰——一個 Controller 直接查資料庫的案例
系列文
用 AI Agent 重構一套無框架的 legacy PHP 系統10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言