iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Claude AI

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

Day 13:依賴注入容器的陷阱——單例快取了不該快取的物件

  • 分享至 

  • xImage
  •  

前言:這段程式碼在正式環境跑了三年都沒事,怎麼會有 bug?

「這個類別已經在正式環境穩定跑了三年,你確定它有問題?」

這是我請 AI 修一個 DI 容器單例的 bug 時,最先浮現的疑惑——連我自己都差點被這句話說服。事實是:這段程式碼確實有 bug,只是正式環境的執行方式剛好讓這個 bug 永遠不會被觸發。今天要講的,是 DI 容器裡最陰險的一種陷阱:單例(Singleton)快取了一個不該被快取的物件,而且這個錯誤在某種執行環境下完全不會冒出來。

今日目標

  • 理解 DI 容器單例的生命週期,跟它為什麼會悄悄變成一個危險的陷阱
  • 看一個真實案例:單例建構子快取了 Request 物件,導致同一個 process 裡用到舊資料
  • 理解為什麼這個 bug 在測試環境會炸開,在正式環境卻完全不會被踩到
  • 認識這種陷阱背後跟系列主題句一致的模式
  • 建立「單例該存什麼、不該存什麼」的判斷標準

案例:一個把 Request 存進物件屬性的路由單例

系統裡有一個負責路由派送的類別,在 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 生命週期)的類別:有沒有哪一個的建構子接收了某種「理論上每次都可能不一樣」的物件?你能確定它在正式環境的執行方式下,不會踩到今天講的這種陷阱嗎?

今日重點回顧

  • DI 容器單例的生命週期陷阱:建構子快取了請求層級的可變狀態,只有第一次解析時的資料會被保留
  • 真實案例:路由單例的建構子快取 Request,正式環境(獨立 process)完全不會踩到,測試環境(同一 process 連續派送)直接炸開
  • 正確做法:請求層級的資料一律當方法參數傳入,不進建構子、不存成物件屬性
  • 這個模式跟系列主題句一致:AI 觀察到的「沒事」,只反映了它觀察的執行模式,不是這段程式碼真正的行為邊界
  • 判斷標準跟語言無關:任何要註冊成單例的類別,先確認建構子沒有接觸「每次呼叫都可能不一樣」的資料

明日預告

明天要看 DI 容器的另一個陷阱——型別提示具體類別而不是介面,會讓容器 autowire 出一個看起來正常、實際上是空殼的物件,直到真正用到它才會炸開一個令人困惑的錯誤訊息。


上一篇
Day 12:package 隔離規則——為什麼廠商 package 不能依賴外層專案的 vendor
系列文
用 AI Agent 重構一套無框架的 legacy PHP 系統13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言