使用 .NET Core 實現依賴關係注入

發表於2016-06-14

為什麼使用依賴關係注入?

使用 .NET,通過 new 運算子(即,new MyService 或任何想要例項化的物件型別)呼叫建構函式即可輕鬆實現物件例項化。遺憾的是,此類呼叫會強制實施客戶端(或應用程式)程式碼到已例項化物件的緊密耦合的連線(硬編碼的引用),此外還會引用其程式集/NuGet 包。

對於常見的 .NET 型別而言,這不是問題。然而,對於提供“服務”(如日誌記錄、配置、支付、通知或事件 DI)的型別,如果你想切換所用服務的實現,則可能不需要依賴關係。例如,一種方案是,客戶端可能將 NLog 用於日誌記錄,而另一種方案是,客戶端可能選擇 Log4Net 或 Serilog。而且,使用 NLog 的客戶端不喜歡使用 Serilog 打亂其專案,因此,同時引用兩種日誌記錄服務不會令人滿意。

為了解決對服務實現的引用進行硬編碼的問題,DI 提供了一個間接層,這樣與其直接使用 new 運算子例項化服務,倒不如客戶端(或應用程式)請求例項的服務集或“工廠”。此外,與其請求特定型別的服務集(例如建立一個緊密耦合的引用),倒不如請求一個介面(如 ILoggerFactory),並期待服務提供程式(本例中為 NLog、Log4Net 或 Serilog)實現該介面。

結果是,當客戶端直接引用抽象程式集 (Logging.Abstractions) 時,會同時定義服務介面­,將不需要引用直接實現。

我們將解耦返回到客戶端的實際例項的模式稱為控制反轉。這是因為,與其客戶端確定要例項化的物件,就像使用 new 運算子顯式呼叫建構函式時一樣,倒不如 DI 確定將返回的內容。DI 註冊了由客戶端請求的型別(一般為介面)和將返回的型別之間的關聯。此外,DI 通常會確定已返回型別的生存期,具體取決於該型別的所有請求之間將有單個共享的例項、每個請求將各有一個新例項,還是介於兩者之間。

對 DI 的一個尤為常見的需求體現在單元測試中。考慮相應地取決於付款服務的購物車服務。假設編寫利用付款服務的購物車服務,並嘗試對購物車服務進行單元測試,而不實際呼叫真實的付款服務。相反,你想呼叫的是模擬付款服務。為了使用 DI 實現此目的,你的程式碼會從 DI 框架請求付款服務介面的例項而不是呼叫,例如,new PaymentService。然後,只需為單元測試“配置”DI 框架,以返回一個模擬付款服務。

相比之下,生產主機可以配置購物車,以使用(可能很多)付款服務選項之一。也許最重要的是,引用將僅針對付款抽象,而不是針對每個具體的實現。

提供“服務”的例項而不是使客戶端直接將其例項化是 DI 的基本原則。事實上,一些 DI 框架允許通過支援基於配置和反射的繫結機制(而不是編譯時繫結)從引用實現中對主機進行解耦。這種解耦稱為服務定位器模式。

.NET Core Microsoft.Extensions.DependencyInjection

若要利用 .NET Core DI 框架,你只需引用 Microsoft.Extnesions.DependencyInjection.Abstractions NuGet 包。此包提供了 IServiceCollection 介面的入口,從而公開你可以從中呼叫 GetService 的 System.IService­Provider。型別引數 TService 標識要檢索的服務的型別(一般為介面),如下應用程式程式碼獲得了一個例項:

有一些相應的非泛型 GetService 方法將 Type 作為引數(而不是泛型引數)。泛型方法允許直接分配給特定型別的變數,而非泛型版本需要一個顯式轉換,因為返回型別為 Object。此外,當新增該服務型別時,會有泛型約束,因此使用該型別引數時可以完全避免轉換。

如果在呼叫 GetService 時沒有使用收集服務註冊任何型別,它將返回 null。這在與 null 傳播運算子結合以將可選行為新增到應用時非常有用。類似的 GetRequiredService 方法在沒有註冊服務型別時會丟擲異常。

如你所見,程式碼非常簡單。然而,現在缺少的是如何獲得在其上呼叫 GetService 的服務提供程式的例項。解決方案是首先例項化 ServiceCollection 的預設建構函式,然後再註冊你想要服務提供的型別。圖 1 中顯示了一個示例,你可以假設其中的每個類(Host、Application 和 PaymentService)已在單獨的程式集中實現。

此外,儘管 Host 程式集知道要使用哪個記錄器,但是沒有在 Application 或 PaymentService 中引用記錄器。同樣,Host 程式集沒有引用 PaymentServices 程式集。介面也在單獨的“抽象”程式集中實現了。例如,ILogger 介面是在 Microsoft.Extensions.Logging.Abstractions 程式集中定義的。

圖 1 註冊和請求來自依賴關係注入的物件

從概念上講,可以將 ServiceCollection 型別認為是名稱/值對,其中名稱是稍後將要檢索的物件的型別(一般為介面),而值是實現介面的型別或用於檢索該型別的演算法(委託)。因此,在圖 1 的 Host.Configure­Services 方法中呼叫 AddInstance 可註冊 ILoggerFactory 型別的任何請求,該型別返回在 ConfigureServices 方法中建立的相同 LoggerFactory 例項。因此,Application 和 PaymentService 均可以檢索 ILoggerFactory,而無需瞭解實現和配置記錄器的知識(或程式集/NuGet 引用)。同樣,Application 提供 MakePayment 方法,無需瞭解關於要使用的付款服務的知識。

請注意,ServiceCollection 不直接提供 GetService 或 GetRequiredService 方法。而是由 ServiceCollection.BuildServiceProvider 方法返回的 IServiceProvider 提供這些方法。此外,僅由提供程式提供的服務是呼叫 BuildServiceProvider 之前新增的服務。

Microsoft.Framework.DependencyInjection.Abstractions 還包括稱為 ActivatorUtilities 的靜態幫助程式類,該類提供了一些有用的方法,用於處理未使用 IServiceProvider(自定義的 ObjectFactory 委託)註冊的建構函式引數,或者在想要建立預設例項的情況下,呼叫 GetService 時返回 null(請參閱 bit.ly/1WIt4Ka#ActivatorUtilities)。

服務生存期

圖 1 中,我呼叫了 IServiceCollection AddInstance(TService implementationInstance) 擴充套件方法。Instance 是 .NET Core DI 附帶的四個不同的 TService 生存期選項之一。它規定不僅 GetService 的呼叫將返回 TService 型別的物件,而且將返回使用 AddInstance 註冊的特定 implementationInstance 例項。換句話說,使用 AddInstance 進行註冊可以儲存特定的 implementationInstance 例項,因此每次使用 AddInstance 方法的 TService 型別引數呼叫 GetService(或 GetRequiredService)時均可以返回該例項。

相反,IServiceCollection AddSingleton 擴充套件方法沒有例項引數,而是依賴於通過建構函式進行例項化的 TService。預設的建構函式有效,Microsoft.Extensions.Dependency­Injection 也支援註冊了引數的非預設建構函式。例如,你可以呼叫:

而且,在例項化需要其建構函式中的 ILoggingFactory 的 PaymentService 類時,DI 將負責檢索具體的 ILoggingFactory 例項並利用該例項。

如果 TService 型別中沒有此類方法可用,則可以過載 AddSingleton 擴充套件方法,該方法採用了 Func implementationFactory(用於例項化 TService 的工廠方法)型別的委託。無論你是否提供工廠方法,服務收集實現都會確保將僅建立一個 TService 型別的例項,從而確儲存在單一例項。在第一次呼叫觸發 TService 例項的 GetService 後,在服務收集的生存期內將始終返回同一例項。

IServiceCollection 還包括 AddTransient(Type serviceType, Type implementationType) 和 AddTransient(Type serviceType, Func implementationFactory) 擴充套件方法。這些方法類似於 AddSingleton,不同的是每次呼叫這些方法時都會返回一個新例項,從而確保你始終擁有 TService 型別的新例項。

最後,有幾個 AddScoped 型別的擴充套件方法。這些方法設計為在給定的上下文中返回同一例項,並且每當上下文(也稱為作用域)更改時都會建立新例項。從概念上講,ASP.NET Core 的行為對映到作用域生存期。從本質上講,新例項是針對每個 HttpContext 例項建立的,而且每當在相同的 HttpContext 內呼叫 GetService 時,都會返回完全相同的 TService 例項。

總之,有四個生存期選項,用於從服務收集實現返回的物件: Instance、Singleton、Transient 和 Scoped。最後三個是在 ServiceLifetime 列舉中定義的 (bit.ly/1SFtcaG)。但是,缺少 Instance,因為它是 Scoped(在其中無法更改上下文)的特殊用例。

之前我提到過 ServiceCollection 在概念上就像一個名稱/值對,它將 TService 型別用於查詢。ServiceCollection 型別的實際實現在 ServiceDescription 類中完成(請參閱 bit.ly/1SFoDgu)。該類為例項化 TService(即,ServiceType (TService))、Implementation­Type 或 ImplementationFactory 委託以及 ServiceLifetime 所需的資訊提供了一個容器。除了 ServiceDescriptor 建構函式,ServiceDescriptor 上還有許多靜態工廠方法,可幫助例項化 ServiceDescriptor 本身。

無論使用哪種生存期註冊 TService,TService 本身必須是一個引用型別,而不是值型別。每當你將型別引數用於 TService(而不是作為引數傳遞 Type)時,編譯器都會使用泛型類約束進行驗證。然而,編譯器不會驗證是否使用的是物件型別 TService。你一定要避免這種情況,以及任何其他非獨特的介面(或許如 IComparable)。原因是,如果你註冊了物件型別的內容,無論你在 GetService 呼叫中指定哪種型別的 TService,將始終返回註冊為 TService 型別的物件。

DI 實現的依賴關係注入

ASP.NET 利用 DI 的程度之深,事實上,你可以在 DI 框架本身內實現 DI。換句話說,你不限於使用在 Microsoft.Extensions.DependencyInjection 中發現的 DI 機制的 ServiceCollection 實現。相反,只要你有實現 IServiceCollection(在 Microsoft.Extensions.DependencyInjection.Abstractions 中定義,請參閱 bit.ly/1SKdm1z)或IServiceProvider(在 .NET Core lib 框架的 System 名稱空間內定義)的類,你就可以替代自己的 DI 框架或利用另外一個完善的 DI 框架,其中包括 Ninject(ninject.org,經過數年的努力維護 @IanfDavis 呼之欲出)和 Autofac (autofac.org)。

淺談 ActivatorUtilities

Microsoft.Framework.DependencyInjection.Abstractions 還包括靜態幫助程式類,該類提供了一些有用的方法,用於處理未使用 IServiceProvider(自定義的 ObjectFactory 委託)註冊的建構函式引數,或者在想要建立預設例項的情況下,呼叫 GetService 時返回 null。你可以找到一些在 MVC 框架和 SignalR 庫中使用此實用工具類的示例。在第一種情況下,存在一個帶有 CreateInstance(IServiceProvider provider, params object[] parameters) 簽名的方法,允許你針對未註冊的引數使用 DI 框架將建構函式引數傳入到註冊的型別中。

你可能還會有效能需求,lambda 函式需要生成已編譯的 lambda 型別。返回 ObjectFactory 的 CreateFactory(Type instanceType, Type[] argumentTypes) 方法在這種情況下可能有用。第一個引數是使用者尋求的型別,而第二個引數是所有的建構函式型別,以匹配你希望使用的第一個型別的建構函式。在其實現中,這些片段都精簡到已編譯的 lambda,多次呼叫後,效能會相當高。

最後,GetServiceOrCreateInstance(IServiceProvider provider) 方法提供了一個簡單方式,用於提供可能已選擇在其他地方註冊的型別的預設例項。這在呼叫之前允許 DI 的情況下尤為有用,但是,如果未發生這種情況,你會獲得一個回退實現。

總結

與 .NET Core 日誌記錄和配置一樣,.NET Core DI 機制提供了一個相對簡單的功能實現。雖然你不可能找到其他一些框架的更高階的 DI 功能,但 .NET Core 版本是輕量級的,並且是一個很好的入門方式。此外(再如日誌記錄和配置),.NET Core 實現可以被一個更成熟的實現替代。

因此,你可能會考慮利用 .NET Core DI 框架作為一個“包裝器”,通過它,將來你可以根據需要插入其他 DI 框架。通過這種方式,你不必定義自己的“自定義”DI 包裝器,但可以利用 .NET Core 的包裝器作為標準,任何客戶端/應用程式都可以為標準的包裝器插入自定義的實現。

關於 ASP.NET Core 需要注意的是,它自始至終都在利用 DI。這無疑是一個重大實踐,在單元測試中嘗試替代庫的模擬實現時,如果你需要它,它會尤為重要。缺點是,並非簡單的呼叫帶有 new 運算子的建構函式,DI 註冊和 GetService 呼叫的複雜性是必要的。我不禁想知道,C# 語言是否可以簡化這種複雜性,但是,基於目前的 C# 7.0 設計,要實現這一點並不容易。

相關文章