「這個類別已經在正式環境穩定跑了三年,你確定它有問題?」
這是我請 AI 修一個 DI 容器單例的 bug 時,最先浮現的疑惑——連我自己都差點被這句話說服。事實是:這段程式碼確實有 bug,只是正式環境的執行方式剛好讓這個 bug 永遠不會被觸發。今天要講的,是 DI 容器裡最陰險的一種陷阱:單例(Singleton)快取了一個不該被快取的物件,而且這個錯誤在某種執行環境下完全不會冒出來。
系統裡有一個負責路由派送的類別,在 DI 容器裡被註冊成單例——這個決定本身沒有問題,路由邏輯確實只需要一份實例,不需要每次請求都重新建構。問題出在它的建構子:
❌ 單例建構子快取 Request:
class Router
{
private Request $request;
public function __construct(Request $request)
{
// 只有第一次被容器解析時,才會拿到「當下」的 Request
$this->request = $request;
}
public function dispatch(): mixed
{
// 之後每次呼叫,用的都是建構子那次拿到的舊 Request
return $this->resolveHandler($this->request)->handle($this->request);
}
}
這個類別在建構子把 Request 物件存成自己的屬性。因為 Router 是單例,它的建構子只會被容器呼叫一次——也就是說,$this->request 只會在第一次解析時被賦值,之後不管容器裡的 Request 綁定被重新設定幾次,這個單例手上的都還是第一次那份舊資料。
這正是整個案例最耐人尋味的地方。正式環境每一個 HTTP 請求都是獨立的 process:容器在這個 process 裡從頭建構一次,Router 的建構子被呼叫一次、拿到這次請求真正的 Request,處理完這個 process 就結束了。下一個請求是全新的 process、全新的容器、全新的 Router 實例——建構子快取的「舊資料」根本沒有機會變舊,因為它跟這次請求的 Request 本來就是同一份。
但在 BrowserKit 這類整合測試裡,情況完全不同:同一個 process 會連續派送多個不同的請求,模擬使用者連續操作。容器在這個 process 裡只被建構一次,Router 這個單例也只被建構一次——第一次派送請求時,它的建構子拿到第一個 Request 並存了下來;第二次派送不同站台、不同參數的請求時,容器裡重新綁定了新的 Request,但 Router 手上抱著的還是第一次那份。結果就是第二次派送明明帶著新的請求參數,實際處理邏輯卻用著第一次的舊資料——這種「用錯 Request」的症狀,在單元測試裡幾乎不可能被複現,只有在連續派送多個請求的整合測試裡才會現形。
用一組對照來看正確的做法:
✅ 方法參數傳入 Request,不快取:
class Router
{
// 建構子不接觸 Request,單例裡不存放任何請求層級的狀態
public function __construct(private HandlerResolver $resolver) {}
public function dispatch(Request $request): mixed
{
// 每次呼叫都用當下傳進來的 Request,不依賴物件屬性
return $this->resolver->resolve($request)->handle($request);
}
}
差別很簡單:Request 這種「每次請求都不一樣」的物件,不該被存進單例的物件屬性裡,只能當成方法參數,每次呼叫時重新傳入。單例可以快取的是那些真正跨請求不變的東西——設定、已編譯好的路由表、連線池——但絕對不能是任何跟「這次請求」綁定的資料。
這個 bug 的本質,跟 Day 05 講的 PHP 版本落差是同一個模式:正式環境的「一次執行一個請求」剛好把這個設計缺陷完全遮蔽起來,讓 AI(甚至人類工程師)在正式環境的行為紀錄裡完全看不到任何異狀,於是很容易下結論「這段程式碼沒問題,已經穩定跑了很久」。但「沒出過問題」跟「沒有問題」是兩件事——AI 觀察到的『沒事』,只反映了它觀察的那個執行模式,不是這段程式碼真正的行為邊界。只有換一種執行方式(同一個 process 連續處理多個請求),這個一直存在、只是沒被觸發的 bug 才會現形。這正是 Day 01 那句話的又一次具體展現:AI 的自信範圍,永遠只等於它實際觀察過的範圍。
「單例不該持有請求層級的可變狀態」這件事跟語言、跟用哪套 DI 容器都無關——Java 生態裡 Spring 的 singleton bean 如果在建構子存了 HttpServletRequest,會踩到一模一樣的坑(Spring 甚至有專門的 RequestScope 機制來避免這個問題);.NET 的 DI 容器如果把 singleton 服務跟 scoped 的 HttpContext 混在一起,也是同一種錯誤。今天範例裡用的 PHP 容器只是具體實現方式,重點是記住這條判斷標準:任何要註冊成單例的類別,先問一句「這個物件的建構子有沒有接觸到任何『每次呼叫都可能不一樣』的資料」,有的話,那份資料不該進建構子,只能是方法參數。
回想你手上註冊成單例(或 Spring 的 singleton bean、.NET 的 Singleton 生命週期)的類別:有沒有哪一個的建構子接收了某種「理論上每次都可能不一樣」的物件?你能確定它在正式環境的執行方式下,不會踩到今天講的這種陷阱嗎?
Request,正式環境(獨立 process)完全不會踩到,測試環境(同一 process 連續派送)直接炸開明天要看 DI 容器的另一個陷阱——型別提示具體類別而不是介面,會讓容器 autowire 出一個看起來正常、實際上是空殼的物件,直到真正用到它才會炸開一個令人困惑的錯誤訊息。