DI 容器實務建議
文章目錄
整理了一些有關使用 DI 容器的一些建議事項,主要的參考資料來源是 Jimmy Board 的文章:Container Usage Guidelines。
考慮使用物件工廠或
1. 容器設定
避免對同一個組件(DLL)重複掃描兩次或更多次
掃描組件的目的是為了自動註冊型別對應關係,故其過程涉及了探索組件內含之型別資訊。依應用程式所包含的組件與型別數量而定,掃描組件與探索型別的動作可能在毫秒內完成,亦可能需要花費數十秒。因此,當你在應用程式中使用 DI 容器的自動掃描功能來註冊型別時,應注意避免對同一個組件重複掃描兩次以上,以免拖慢應用程式的執行效能(通常是影響應用程式啟動的時間,因為註冊型別的動作大多集中寫在 Composition Root)。
為了能夠自動找到型別對應關係,型別的命名通常會遵循特定慣例,比如說,在掃描組件過程中,自動把
Foo
註冊成為 IFoo
的實作類別。D>本書第 7 章的〈自動註冊〉一節有介紹自動註冊的基本用法。
使用不同類別來註冊不同用途的元件
例如,你可能有一個
WebApiConfig
類別負責註冊 ASP.NET Web API 相關型別,以及用一個 DalConfig
類別來註冊資料存取層(Data Access Layer)的相關型別。然後,在應用程式的組合根(Composition Root)呼叫這些類別的註冊型別方法。一種常見的寫法是把組合根放在一個叫做Bootstrapper
的類別裡,像這樣:public static class Bootstrapper
{
private static readonly IUnityContainer _container = new UnityContainer();
public static void RegisterDependencies()
{
WebApiConfig.RegisterTypes(_container);
DalConfig.RegisterTypes(_container);
BllConfig.RegisterTypes(_container);
}
}
此範例使用了一個靜態(
static
)類別 Bootstrapper
來總管容器物件的建立與型別註冊的工作,而此作法對於某些需要更大彈性的場合來說可能不適用,此時可參考下一個建議。使用非靜態類別來建立與設定 DI 容器
有時候,應用程式可能需要在不同時機建立多個 DI 容器,而這些 DI 容器的生命週期可能不完全相同。若應用程式只有一個全域共享的靜態 DI 容器,便無法滿足上述需求。提供非靜態的 API,例如提供 instance 層級的方法和屬性(而不是 class 層級的 static 方法和屬性),可提供用戶端程式更多彈性,同時也更方便測試時抽換特定元件。
不要另外建立一個 DLL 專案來集中處理相依關係的解析
DI 容器的設定應該寫在需要解析那些相依型別的應用程式裡面,而不要把它們集中放在一個單獨的 DLL 專案(例如 DependencyResolver.csproj)。
為個別組件加入一個初始化類別來設定相依關係
如果你的 DLL 組件會給多個專案共用,而且它依賴某些外部型別,此時最好為該組件提供一個初始化類別(如前面提過的
Bootstrapper
)來設定相依關係。若該組件需要掃描其他組件來尋找相依型別,此動作也是寫在初始化類別裡。掃描組件時,盡量避免指定組件名稱
組件名稱可能會變,所以在掃描組件時,最好避免指定特定名稱的組件。
2. 生命週期管理
多數 DI 容器都有提供物件生命週期管理的功能。一般而言,應用程式比較常用的是 Transient(每次要求解析時都建立新的物件)、Singleton(全都共享同一個物件)、以及 Per-HTTP request(每一個 HTTP 請求範圍內共享同一個物件),而只在某些少數場合才需要用到特殊的自訂生命週期。
優先使用 DI 容器來管理物件的生命週期
若無特殊原因,最好使用 DI 容器來管理物件的生命週期,而不要自行撰寫物件工廠來管理相依物件的生命週期。這是因為,目前大家喊得出名號的 DI 容器都經過多年的實務考驗與多方測試,不僅在設計上考量得更全面,發生 bug 的機率也比自己寫的元件來得低。有時候,物件生滅的時機比較特殊,或者需要更細緻地管理物件的生命週期,這些情況則不妨自行撰寫物件工廠來管理。
考慮使用子容器來管理 Per-Request 類型的物件
對於 Per HTTP Request 或類似的場合,一種常見的作法是把物件保存在當前的
HttpContext
物件裡,例如在 HTTP Request 建立之初便一併建立相依物件並將它們保存於 HttpContext.Current.Items
集合裡,然後在 Request 結束前一併釋放這些相依物件。DI 容器提供了另一個選擇來處理這類需求:子容器(child container)。所有透過子容器解析(建立)的物件,其壽命不會超過子容器。也就是說,子容器一旦消失,它所管理的物件亦隨之消失。基於此特性,我們可以利用子容器來管理如 Per HTTP Request 或其他類似性質的物件生命週期。
在適當時機呼叫容器的 Dispose 方法
DI 容器通常有實作
IDisposable
介面,亦即提供了 Dispose
方法。當你呼叫某容器物件的 Dispose
方法來釋放該容器時,它會找出內部管理的所有物件,只要是支援 IDisposable
的物件,就呼叫它的 Dispose
方法,以確保相依物件的資源得以釋放。3. 元件設計相關建議
避免建立深層的巢狀物件
雖然我們偏好物件組合而不是類別繼承,但如果相依物件的巢狀關係太多且深,這樣的程式架構仍然不好維護。DI 容器的一個方便卻也危險之處,是它具備自動解析巢狀相依物件的能力。於是,我們甚至只要寫一行程式碼就能解析所有深層的相依物件。這的確減輕了開發人員的負擔,但同時也隱藏了背後的複雜性,因而導致開發人員更容易忽略設計的缺陷:太多零碎的小介面,組合出非常複雜的物件圖。對此設計面的問題,DI 容器沒法幫忙,唯有靠開發人員自己多加注意。
考慮使用泛型來封裝抽象概念
當你發覺整個程式架構太過笨重,可試著從現有的元件中找出同性質的功能,並為它們定義基礎的泛型介面,例如
ICommand
、IValidator
、IRequestHandler
等等。考慮使用 Adapter 或 Facade 來封裝 3rd-party 元件
開發應用程式時,難免會使用一些現成的外來(third-party)元件。有時候,外來元件可能會因為版本更新而造成既有應用程式無法運行或產生錯誤的結果。為了避免這種狀況,我們可以利用 Adapter、Facade、若 Proxy 等模式來為自己的應用程式建立一個反腐敗層(anti-corruption layer)。
不要一律為每個元件定義一個介面
對 S.O.L.I.D. 設計原則的一個常見誤解是:每個元件都應該要有一個相應的介面。當元件本身確實具有某個抽象概念的意涵,那麼為它定義一個抽象介面是合理的;但如果它不具備抽象概念,就不要硬為它生出一個介面。此外,你也不應該因為在使用 DI 容器時想要一致透過介面來解析,而為特定元件定義介面。DI 容器可以直接解析具象型別(concrete type),故從技術上而言,直接使用具象型別並不是問題。
D>看一下應用程式中的元件,如果許多元件的類別名稱正好就是它所實作的介面名稱去掉 'I'(例如
Foo
與 IFoo
),這可能是一個訊號:有些介面可能只是單純因為「每個元件都要有個介面」的想法而產生的。對於同一層(layer)的元件,可依賴其具象型別
如果某元件與其所依賴的其他元件都位在應用程式的同一層(layer),而且沒有抽換元件實作的需要(例如單元測試),這種情況,可直接依賴具象型別無妨。這就好像在同一間辦公室裡面工作的同事,通常是直接當面溝通;除非有特殊原因,否則沒必要透過中間人傳話,或透過即時訊息軟體的方式溝通。
4. 動態解析
雖然元件或服務的類型大多能夠在應用程式初始化的時候決定,但有時候還是需要依執行時期的特定條件來動態決定使用哪個類別。比如說,在 ASP.NET MVC 應用程式中,可能會需要根據每一次前端網頁發出請求的動作參數來決定該繫結哪一個 model 類別,而無法在建立 controller 時預先得知欲繫結的 model 類別。
盡量避免把 DI 容器直接當成 Service Locator 來使用
如需動態解析元件類型,比較偷懶的辦法是把 DI 容器當成全域共享的 Service Locator 來使用。像這樣:
MyApp.Container.Resolve();
這種用法很方便,只要在程式的某處向 DI 容器預先註冊元件類型,之後在程式中的任何地方都可以透過 DI 容器來解析元件。正因為它方便,所以容易濫用,使得元件之間的相依關係變得複雜紊亂,增加程式碼閱讀與維護的困難。
因此,若有其他選項,例如 Constructor Injection、Factory 模式 等,應優先考慮。若都沒有合適的辦法,最後才使用 Service Locator。
此外,如果應用程式框架本身已經有提供元件解析的機制,也應該優先採用。例如 ASP.NET MVC 和 Web API 提供的
IDependencyResolver
及其相關機制。本書第 4 章與第 5 章都有介紹 IDependencyResolver
的用法。
考慮使用物件工廠或 Func
來處理晚期繫結
有時候,必須等到程式執行時,依當下的某些變數值來動態決定該建立何種物件。例如:
class NotificationService
{
private IMessageService _emailService; // 郵件服務
private IMessageService _smsService; // 簡訊服務
public NotificationService(IMessageService emailService, IMessage smsService)
{
_emailService = emailService;
_smsService = smsService;
}
public void Notify(string to, string msg)
{
if (當前某些變數值符合特定條件)
{
_emailService.SendMessage(to, msg);
}
else
{
_smsService.SendMessage(to, msg);
}
}
}
其中「當前某些變數值符合特定條件」所涉及的相關資訊如果不存在
NotificationService
類別裡面,則可以考慮將「建立物件」的工作委外,亦即由呼叫端或其他特定類別來負責建立所需之物件。一種常見的委外方式是撰寫特定用途的物件工廠,把建立物件的邏輯包在工廠類別裡,例如:class MessageServiceFactory : IMessageServiceFactory
{
public IMessageService GetService()
{
if (當前某些變數值符合特定條件)
{
return new EmailService();
}
else
{
return new SmsService();
}
}
}
class NotificationService
{
public NotificationService(IMessageServiceFactory msgServiceFactory)
{
_msgServiceFactory = msgServiceFactory;
}
public void Notify(string to, msg)
{
using (var msgService = _msgServiceFactory.GetService())
{
msgService.SendMessage(to, msg);
}
}
}
於是用戶端可以這麼寫:
var notySvc = new NotificationService(new MessageServiceFactory());
notySvc.Notify("Michael", "DI Example");
另一種可行的作法,是利用
Func
來讓外界提供建立物件的函式。像這樣:class NotificationService
{
private Func _msgServiceFactory;
public NotificationService(Func svcFactory)
{
_msgServiceFactory = svcFactory;
}
public void SendMessage(string to, string msg)
{
using (var msgService = _msgServiceFactory())
{
msgService.Send(to, msg);
}
}
}