「AI 說它只是把這段呼叫外部 API 的程式碼重構了一下,邏輯完全沒變,可以直接合併嗎?」
如果這段程式碼是裸寫的 curl_init()/curl_exec(),我的答案是:你沒辦法證明,AI 自己也沒辦法證明。不是因為 AI 說謊,而是因為這種寫法從一開始就沒有留給任何人「驗證」的空間——要驗證「呼叫外部 API 的邏輯行為沒變」,唯一的辦法就是真的打一次那支外部 API,看回應對不對。而外部 API 通常不穩定、有副作用(可能真的觸發一筆交易)、甚至要花錢,沒有人會為了跑一次單元測試就真的打一次正式環境的第三方服務。
結果就是:這段程式碼永遠活在「沒有安全網」的狀態裡。這正好呼應 Day 01 立下的主題句——AI 給出的「已確認」結論,永遠只在它實際查過的範圍內成立。裸 curl 呼叫這種寫法,連「查證」這件事本身都做不到,AI 說「行為沒變」的時候,那句話背後根本沒有任何驗證支撐。
curl_* 呼叫外部 API 為什麼讓「驗證程式碼行為沒變」變成不可能的任務RequestFactoryInterface/StreamFactoryInterface)跟 PSR-18(ClientInterface)分別解決了什麼問題裸寫 curl_init()/curl_setopt()/curl_exec() 的呼叫程式碼,通常長得像這樣:組 URL、設定 header、決定要不要驗證 SSL、決定 timeout,全部寫在同一個函式裡,中間直接呼叫真正會發出網路請求的 curl 函式。這裡的根本問題不是「curl 不好用」,而是業務邏輯(要打哪支 API、帶什麼參數、怎麼解析回應)跟傳輸方式(真的透過網路送出 HTTP 請求)被焊死在一起,沒有任何介面可以插進去替換。
對 AI 重構這種程式碼來說,這意味著什麼?意味著 AI 沒辦法寫一個測試去驗證「這段程式碼組出的 request 內容對不對」,因為唯一能執行這段程式碼的方式,就是真的發送一次網路請求。AI 要嘛跳過驗證、直接相信自己改對了,要嘛在測試裡真的打一次外部服務——這兩個選項都不是「安全動手」該有的樣子。
PSR-17 定義了 RequestFactoryInterface、StreamFactoryInterface 這類工廠介面,負責「組出一個 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 生態的具體實現方式,換成其他語言,做的是同一件事,只是工具長得不一樣:Java 生態常見做法是透過依賴注入把 HttpClient 抽象成介面,測試時換成 mock 實作;Node.js 生態常用 nock 或 msw 這類工具攔截 HTTP 呼叫、回傳假資料;Python 生態則有 requests-mock、responses 這類套件對 requests 函式庫做同樣的事。工具不同,但背後的判斷邏輯一模一樣:能不能在測試裡把「真的發送網路請求」這件事換掉。
這也是為什麼即使你手上的 legacy 系統不是 PHP,這篇的核心判斷依然用得上——遇到裸寫的 HTTP 呼叫程式碼,第一個要問的問題永遠是:這段程式碼有沒有辦法在不真的連網的情況下被驗證?如果答案是沒有,那就是該介面化的訊號。
回頭看看你手上系統裡呼叫外部 API 的程式碼,有多少段是裸寫的 HTTP 呼叫、完全沒辦法在測試裡替換掉?如果 AI 要重構這些程式碼,它有辦法自己驗證「行為沒變」嗎,還是只能憑感覺說「應該沒問題」?
curl_* 呼叫外部 API,讓「業務邏輯」跟「真的發送網路請求」黏在一起,沒有任何介面可以替換RequestFactoryInterface/StreamFactoryInterface)負責組 request,PSR-18(ClientInterface)負責送出,切開後測試時可以換成 mock client明天要從「介面化的原則」往下走一步,進入具體的遷移實戰:一支既有的 legacy 廠商接線程式碼,怎麼一步步拆成一個獨立的 package,讓「這支程式碼屬於哪個廠商、遵守什麼協定」這件事本身也變得清楚可管理。