Composer:統一的組裝者概念#

本章首先引入 Composer 這個統一術語,泛指任何負責組裝 Dependencies 的程式碼。無論是 Composition Root 中的手動組裝、DI Container 的自動解析,還是其他負責建立物件圖的機制,都屬於 Composer 的範疇。

Composer 不僅決定如何組裝物件圖,也決定何時建立何時釋放每個 Dependency——這就是 Object Lifetime 管理的核心。

三種生命週期模式 (Lifestyle)#

Singleton#

一個實例,整個應用程式共用。

  • 在應用程式啟動時建立,結束時銷毀
  • 最適合無狀態 (Stateless)執行緒安全 (Thread-safe) 的服務
  • 效能最佳,因為只需建立一次
  • 需特別注意共用狀態與並行存取問題

Figure 8.3: 將同一個 Dependency 實例注入多個消費者以重複使用

// Singleton: 在 Composition Root 中只建立一次
var repository = new SqlProductRepository(connectionString);
// 所有需要 IProductRepository 的地方都使用這個實例

Transient#

每次請求都建立一個新實例。

  • 最簡單、最安全的選擇
  • 不需要擔心狀態共用或執行緒安全問題
  • 缺點是可能造成較多的記憶體配置與 GC 壓力
  • 當不確定該選哪種 Lifestyle 時,Transient 是最安全的預設選擇

Figure 8.2: 組裝多個獨立的 Dependency 實例

Scoped#

在一個定義好的範圍內共用一個實例。

  • 典型的 Scope 是一次 Web Request
  • 非常適合 Unit of Work 模式(例如同一個 Request 共用同一個 DbContext)
  • Scope 結束時,該範圍內的實例一併釋放

Figure 8.9: Scoped Lifestyle 表示每個指定 Scope 最多建立一個實例

Lifestyle 的選擇原則:預設用 Transient,需要跨請求共用狀態時用 Scoped,確定無狀態且執行緒安全時才考慮 Singleton。

Pure DI 中的生命週期管理#

使用 Pure DI 時,生命週期完全由 Composer 透過程式碼的結構來控制:

  • Singleton:在 Composition Root 的最外層建立實例,傳入所有需要它的地方
  • Transient:每次組裝物件圖時都 new 一個新的
  • Scoped:在 Scope 的進入點(如每個 Web Request 的開始)建立實例,在同一 Scope 內共用
// Pure DI 中的 Lifestyle 控制
var singleton = new CachingRepository(connectionString);  // Singleton

void HandleRequest()
{
    var scoped = new DbContext(connectionString);          // Scoped: 每個 Request 一個
    var transient = new ProductService(scoped, singleton); // Transient: 每次都新建
}

Disposable Dependencies 的管理#

核心原則:消費者絕不負責釋放#

消費者 (Consumer) 永遠不應該 Dispose 被注入的 Dependencies。 這是 Object Lifetime 管理中最重要的原則之一。

原因很明確:

  • 消費者不知道這個 Dependency 是否被其他地方共用
  • 消費者不知道這個 Dependency 的 Lifestyle(可能是 Singleton)
  • Dispose 的責任屬於建立者,也就是 Composer

Figure 8.5: 釋放 Dependencies 的事件順序

Composer 負責釋放#

// Composer 建立,Composer 負責 Dispose
using var repository = new SqlProductRepository(connectionString);
var service = new ProductService(repository);
service.Execute();
// repository 在這裡被正確釋放

錯誤的 Lifestyle 配置#

Captive Dependency(被俘虜的依賴)#

最危險的 Lifestyle 錯誤:Singleton 持有 Scoped 或 Transient 的 Dependency。

// 危險!Singleton 持有了 Scoped 的 DbContext
var context = new DbContext(connectionString);  // 應該是 Scoped
var singleton = new CachingService(context);    // Singleton 持有了它

問題:

  • Scoped/Transient 的 Dependency 被 Singleton 俘虜,無法正常釋放
  • 導致過期資料 (Stale Data)——Scoped 物件的狀態跨越了原本的 Scope
  • 引發並行存取 (Concurrency) 問題——非執行緒安全的物件被多執行緒共用

Captive Dependency 是一種隱性的 Bug,通常不會立即出錯,而是在高負載或長時間運行後才浮現,非常難以除錯。

Leaky Abstraction#

當消費者的設計被 Dependency 的 Lifestyle 影響時,就產生了 Leaky Abstraction

  • 消費者不應該知道或關心 Dependency 的生命週期
  • 如果消費者因為 Dependency 是 Singleton 而省略某些保護措施,就洩漏了實作細節

Thread-based Lifetime#

以 Thread 為基礎的生命週期管理(如 ThreadLocal<T>)看似合理,實則脆弱:

  • 在 async/await 的環境中,程式碼可能在不同 Thread 上恢復執行
  • 導致 Scoped 物件意外跨越 Thread 或在錯誤的 Thread 上存取
  • 應使用 Scope-based(而非 Thread-based)的生命週期管理

Figure 8.12: Thread-specific Dependencies 在非同步環境中可能導致並行錯誤

Lifestyle 選擇決策表
條件建議 Lifestyle
無狀態、執行緒安全Singleton
有狀態但不確定Transient
Unit of Work / 每次請求共用Scoped
建立成本高且無狀態Singleton
實作了 IDisposable通常 Scoped 或 Transient,由 Composer 管理釋放