bind 跟 singleton,這個系統怎麼選?「Service Container 的 bind 跟 singleton 差在哪,官方文件講得很清楚,但實際綁一個外部系統的 Client 時,該選哪一個?」
今天開始這個系列的第三部:跟外部系統整合。這個系統要跟一個不受自己控制的外部系統(走 SOAP 協議)交換資料,第一步不是「怎麼呼叫」,是「這個 Client 該怎麼綁進容器」。今天先看容器綁定這一層,明後兩天再深入實際怎麼跟外部系統對接、怎麼補上逾時防護。
bind 跟 singleton 在這個案例裡各自被用在哪裡、為什麼在 AppServiceProvider::register() 裡,這個系統這樣綁定跟外部系統溝通用的物件:
$this->app->bind(Psr18Transport::class, function () {
return Psr18Transport::createForClient(new Client([
RequestOptions::CONNECT_TIMEOUT => 5,
RequestOptions::TIMEOUT => 15,
]));
});
$this->app->singleton(DataGatewayClient::class, function () {
return DataGatewayClientFactory::factory(
app(Psr18Transport::class),
config('services.外部系統.soap.data-gateway')
);
});
這裡有兩個綁定,用了不同的生命週期:Psr18Transport 用 bind(每次要新的實例就重新建立),SOAP Client 本身用 singleton(整個 request 生命週期只建立一次)。這不是隨便選的,兩者各自有清楚的理由。
bind,Client 用 singletonPsr18Transport 是負責實際發送 HTTP 請求的傳輸層物件,bind 代表每次從容器要它,都拿到一個全新的實例——如果這個系統之後需要對不同的外部端點分別建立連線(不同的逾時設定、不同的憑證),bind 讓每次取得的實例互不干擾,不會有共用狀態的疑慮。
SOAP Client(DataGatewayClient)用 singleton,理由是:這個物件建立過程本身有成本(要組出 SOAP engine、WSDL 解析等),而且同一個 request 裡通常只需要跟這個外部系統對話一次,用 singleton 確保整個 request 生命週期只建立一次,重複呼叫時直接複用已經建好的實例,不用每次都重新走一次組裝流程。
判斷依據可以歸納成一句話:物件本身有沒有需要「每次都是全新、互不干擾」的理由——有,用 bind;沒有,且建立成本不低,用 singleton。
留意 Psr18Transport 綁定裡的 Guzzle Client 設定:
new Client([
RequestOptions::CONNECT_TIMEOUT => 5,
RequestOptions::TIMEOUT => 15,
]);
這裡設了兩個逾時:CONNECT_TIMEOUT(建立連線的逾時,5 秒)跟 TIMEOUT(整個請求完成的逾時,15 秒)。這是 HTTP 傳輸層的逾時保護——負責發送請求的 Guzzle Client 這一層先擋一次。
明天會看到,SOAP Client 自己的 Factory 裡,還會再設一次逾時(connection_timeout => 5)。同一條呼叫鏈路,從「發送 HTTP 請求」到「解析 SOAP 回應」,一共經過兩層逾時設定。這不是重複設計,是兩個不同層次各自負責自己的逾時邊界:Guzzle 這一層管的是底層 HTTP 傳輸,SOAP Client 這一層管的是它自己封裝的邏輯要不要提前放棄。哪一層先觸發逾時,取決於實際狀況,但兩層都設,代表這個系統對「跟外部系統對話可能卡住」這件事,做了雙重保護,而不是賭其中一層一定夠用。
DataGatewayClientFactory::factory() 接受的第一個參數是 Psr18Transport——這是一個介面/抽象層,不是寫死某個特定 HTTP client 的實作。這代表:如果哪天要幫這個 Client 換一個底層的 HTTP client(例如從 Guzzle 換成別的 PSR-18 相容實作),只要改容器裡 Psr18Transport 的綁定邏輯,DataGatewayClientFactory 本身完全不用動。這也是為什麼測試環境能夠輕鬆換成假的傳輸層——因為程式碼從一開始就是對著介面寫,不是對著某個具體類別寫。
❌ 在需要用到的地方直接 new 出具體實作
class SomeService
{
public function fetch()
{
$client = new GuzzleHttp\Client(['timeout' => 15]);
// 這裡的呼叫端永遠綁死 Guzzle,測試時無法替換
}
}
✅ 透過容器綁定介面,呼叫端只依賴抽象
$this->app->bind(Psr18Transport::class, fn () => Psr18Transport::createForClient(
new Client([RequestOptions::TIMEOUT => 15])
));
class SomeService
{
public function __construct(private Psr18Transport $transport) {}
// 呼叫端只知道有一個 Transport 可以用,不知道也不需要知道底層是 Guzzle
}
你的專案裡有沒有需要跟外部系統對話的物件?回想一下它是怎麼被建立出來的——是在需要用到的地方直接 new,還是透過容器綁定介面?如果要在測試裡替換掉真實的外部呼叫,現在的寫法容不容易做到?
bind 跟 singleton 選擇要有理由:需要「每次全新」用 bind,建立成本高且可以複用用 singleton
bind,Client 本身用 singleton
Psr18Transport)組裝,而不是寫死具體實作,讓測試時可以輕鬆替換成假的傳輸層明天要具體看這個 SOAP Client 怎麼真的跟外部系統對接——SOAP 這種比較老派的協議,在 2026 年怎麼用 PSR-18 的方式包裝起來用。