IoC主要體現了這樣一種設計思想:通過將一組通用流程的控制權從應用轉移到框架中以實現對流程的復用,並按照“好萊塢法則”實現應用程序的代碼與框架之間的交互。我們可以采用若干設計模式以不同的方式實現IoC,比如我們在《依賴注入[2]: 基於IoC的設計模式》介紹的模板方法、工廠方法和抽象工廠,接下來我們介紹一種更為有價值的IoC模式,即依賴注入(DI:Dependency Injection,以下簡稱DI)。
目錄
一、由容器提供服務實例
二、構造器注入
三、屬性注入
四、方法注入
五、Service Locator
一、由容器提供服務實例
和在《基於IoC的設計模式》中介紹的工廠方法和抽象工廠模式一樣,DI是一種“對象提供型”的設計模式,在這里我們將提供的對象統稱為“服務”、“服務對象”或者“服務實例”。在一個采用DI的應用中,在定義某個服務類型的時候,我們直接將依賴的服務采用相應的方式注入進來。按照“面向接口編程”的原則,被注入的最好是依賴服務的接口而非實現。
在應用啟動的時候,我們會對所需的服務進行全局注冊。服務一般都是針對接口進行注冊的,服務注冊信息的核心目的是為了在后續消費過程中能夠根據接口創建或者提供對應的服務實例。按照“好萊塢法則”,應用只需要定義好所需的服務,服務實例的激活和調用則完全交給框架來完成,而框架則會采用一個獨立的“容器(Container)”來提供所需的每一個服務實例。
我們將這個被框架用來提供服務的容器稱為“DI容器”,也由很多人將其稱為“IoC容器”,根據我們在《控制反轉》針對IoC的介紹,我不認為后者是一個合理的稱謂。DI容器之所以能夠按照我們希望的方式來提供所需的服務是因為該容器是根據服務注冊信息來創建的,服務注冊了包含提供所需服務實例的所有信息。
舉個簡單的例子,我們創建一個名為Cat的DI容器類,那么我們可以通過調用具有如下定義的擴展方法GetService<T>從某個Cat對象獲取指定類型的服務對象。我之所以將其命名為Cat,源於我們大家都非常熟悉的一個卡通形象“機器貓(哆啦A夢)”。機器貓的那個四次元口袋就是一個理想的DI容器,大熊只需要告訴哆啦A夢相應的需求,它就能從這個口袋中得到相應的法寶。DI容器亦是如此,服務消費者只需要告訴容器所需服務的類型(一般是一個服務接口或者抽象服務類),就能得到與之匹配的服務實例。
public static class CatExtensions
{
public static T GetService<T>(this Cat cat);
}
對於演示的MVC框架,我們在《基於IoC的設計模式》中分別采用不同的設計模式對框架的核心類型MvcEngine進行了改造,現在我們采用DI的方式並利用上述的這個Cat容器按照如下的方式對其進行重新實現,我們會發現MvcEngine變得異常簡潔而清晰。
public class MvcEngine
{
public Cat Cat { get; }
public MvcEngine(Cat cat) => Cat = cat;
public async Task StartAsync(Uri address)
{
var listener = Cat.GetService<IWebLister>();
var activator = Cat.GetService<IControllerActivator>();
var executor = Cat.GetService<IControllerExecutor>();
var render = Cat.GetService<IViewRender>();
await listener.ListenAsync(address);
while (true)
{
var httpContext = await listener.ReceiveAsync();
var controller = await activator.CreateControllerAsync(httpContext);
try
{
var view = await executor.ExecuteAsync(controller, httpContext);
await render.RendAsync(view, httpContext);
}
finally
{
await activator.ReleaseAsync(controller);
}
}
}
}
從服務消費的角度來講,我們借助於一個服務接口對消費的服務進行抽象,那么服務消費程序針對具體服務類型的依賴可以轉移到對服務接口的依賴上,但是在運行時提供給消費者總是一個針對某個具體服務類型的對象。不僅如此,要完成定義在服務接口的操作,這個對象可能需要其他相關對象的參與,也就是說提供的這個服務對象可能具有針對其他對象的依賴。作為服務對象提供者的DI容器,在它向消費者提供服務對象之前就會根據服務實現類型和服務注冊信息自動創建依賴的服務實例,並將后者注入到當前對象之中。接下來我們從編程層面介紹三種典型的注入方式。
二、構造器注入
構造器注入就在在構造函數中借助參數將依賴的對象注入到創建的對象之中。如下面的代碼片段所示,Foo針對Bar的依賴體現在只讀屬性Bar上,針對該屬性的初始化實現在構造函數中,具體的屬性值由構造函數的傳入的參數提供。當DI容器通過調用構造函數創建一個Foo對象之前,需要根據當前注冊的類型匹配關系以及其他相關的注入信息創建並初始化參數對象。
public class Foo { public IBar Bar{get;} public Foo(IBar bar) =>Bar = bar; }
除此之外,構造器注入還體現在對構造函數的選擇上面。如下面的代碼片段所示,Foo類上面定義了兩個構造函數,DI容器在創建Foo對象之前首選需要選擇一個適合的構造函數。至於目標構造函數如何選擇,不同的DI容器可能有不同的策略,比如可以選擇參數做多或者最少的,或者可以按照如下所示的方式在目標構造函數上標注一個InjectionAttribute特性。
public class Foo
{
public IBar Bar{get;}
public IBaz Baz {get;}
[Injection]
public Foo(IBar bar) =>Bar = bar;
public Foo(IBar bar, IBaz):this(bar) =>Baz = baz;
}
三、屬性注入
如果依賴直接體現為類的某個屬性,並且該屬性不是只讀的,我們可以讓DI容器在對象創建之后自動對其進行賦值進而達到依賴自動注入的目的。一般來說,我們在定義這種類型的時候,需要顯式將這樣的屬性標識為需要自動注入的依賴屬性以區別於該類型的其他普通的屬性。如下面的代碼片段所示,Foo類中定義了兩個可讀寫的公共屬性Bar和Baz,我們通過標注InjectionAttribute特性的方式將屬性Baz設置為自動注入的依賴屬性。對於由DI容器提供的Foo對象,它的Baz屬性將會自動被初始化。
public class Foo { public IBar Bar{get; set;} [Injection] public IBaz Baz {get; set;} }
四、方法注入
體現依賴關系的字段或者屬性可以通過方法的形式初始化。如下面的代碼片段所示,Foo針對Bar的依賴體現在只讀屬性上,針對該屬性的初始化實現在Initialize方法中,具體的屬性值由構造函數的傳入的參數提供。我們同樣通過標注特性(InjectionAttribute)的方式將該方法標識為注入方法。DI容器在調用構造函數創建一個Foo對象之后,它會自動調用這個Initialize方法對只讀屬性Bar進行賦值。在調用該方法之前,DI容器會根據預先注冊的類型映射和其他相關的注入信息初始化該方法的參數。
public class Foo
{
public IBar Bar{get;}
[Injection]
public Initialize(IBar bar)=> Bar = bar;
}
除了上述這種通過DI容器在初始化服務過程中自動調用的實現在外,我們還可以利用它實現另一個更加自由的方法注入形式,后者在ASP.NET Core應用具有廣泛的應用。ASP.NET Core在啟動的時候會調用我們注冊的Startup對象來完成中間件的注冊,當我們在定義這個Startup類型的時候不需要讓它實現某個接口,所以用於注冊中間件的Configure方法其實沒有一個固定的聲明,我們可以按照如下的方式將任意依賴的服務直接注入到這個方法中。
public class Startup { public void Configure(IApplicationBuilder app, IFoo foo, IBar bar, IBaz baz); }
類似的注入方式同樣可以應用到中間件的定義中。與用於注冊中間件的Startup類型一樣,ASP.NET Core框架下的中間件類型同樣不需要實現某個預定義的接口,用於處理請求的InvokeAsync或者Invoke方法上同樣可以按照如下的方式注入任意的依賴服務。
public class FoobarMiddleware { private readonly RequestDelegate _next; public FoobarMiddleware(RequestDelegate next) =>_next = next; public Task InvokeAsync(HttpContext httpContext, IFoo foo, IBar bar, IBaz baz); }
上面這種方式的方法注入促成了一種“面向約定”的編程方式,由於不再需要實現某個預定義的接口或者繼承某一個預定義的類型,需要實現的方法的聲明也就少了對應的限制,這樣就可用采用最直接的方式將依賴的服務注入到所需的方法中。
對於上面介紹的這幾種注入方式,構造器注入是最為理想的形式,我個人不建議使用屬性注入和方法注入(上面介紹這種基於約定的方法注入除外)。我們定義的服務類型應該是獨立自治的,我們不應該對它運行的環境做過多的假設和限制,也就說同一個服務類型可以使用在框架A中,也可以實現在框架B上;在沒有使用任何DI容器的應用中可以使用這個服務類型,當任何一種DI容器被使用到應用中之后,該服務類型依舊能夠被正常使用。對於上面介紹的這三種注入方式,唯一構造器注入能夠代碼這個目的,而屬性注入和方法注入都依賴於某個具體的DI框架來實現針對依賴屬性的自動復制和依賴方法的自動調用。
五、Service Locator
假設我們需要定義一個服務類型Foo,它依賴於另外兩個服務Bar和Baz,后者對應的服務接口分別為IBar和IBaz。如果當前應用中具有一個DI容器(假設類似於我們在上面定義的Cat),那么我們可以采用如下兩種方式來定義這個服務類型Foo。
public class Foo : IFoo { public IBar Bar { get; } public IBaz Baz { get; } public Foo(IBar bar, IBaz baz) { Bar = bar; Baz = baz; } public async Task InvokeAsync() { await Bar.InvokeAsync(); await Baz.InvokeAsync(); } } public class Foo : IFoo { public Cat Cat { get; } public Foo(Cat cat) => Cat = cat; public async Task InvokeAsync() { await Cat.GetService<IBar>().InvokeAsync(); await Cat.GetService<IBaz>().InvokeAsync(); } }
從表面上看,上面提供的這兩種服務類型的定義方式貌似都不錯,至少它們都解決針對依賴服務的耦合問題,將針對服務實現的依賴轉變成針對接口的依賴。那么哪一種更好呢?我想有人會選擇第二種定義方式,因為這種定義方式不僅僅代碼量更少,針對服務的提供也更加直接。我們直接在構造函數中“注入”了代表“DI容器”的Cat對象,在任何使用到依賴服務的地方,我們只需要利用它來提供對應的服務實例就可以了。
但事實上第二種定義方式采用的設計模式根本就不是“依賴注入”,而是一種被稱為“Service Locator”的設計模式。Service Locator模式同樣具有一個通過服務注冊創建的全局的容器來提供所需的服務實例,該容器被稱為“Service Locator”。“DI容器”和“Service Locator”實際上是同一事物在不同設計模型中的不同稱謂罷了,那么DI和Service Locator之間的差異體現在什么地方呢?
我們覺得可以從“DI容器”和“Service Locator”被誰使用的角度來區分這兩種設計模式的差別。在一個采用依賴注入的應用中,我們只需要采用標准的注入形式將服務類型定義好,並在應用啟動之前完成相應的服務注冊就可以了,框架自身的引擎在運行過程中會利用DI容器來提供當前所需的服務實例。換句話說,DI容器的使用者應該是框架而不是應用程序。Service Locator模式顯然不是這樣,很明顯是應用程序在利用它來提供所需的服務實例,所以它的使用者是應用程序。
我們也可以從另外一個角度區分兩者之間的差別。由於依賴服務是以“注入”的方式來提供的,所以采用依賴注入模式的應用可以看成是將服務“推”給DI容器,Service Locator模式下的應用則是利用Service Locator去“拉”取所需的服務,這一推一拉也准確地體現了兩者之間的差異。那么既然兩者之間有差別,究竟孰優孰劣呢?
早在2010年,Mark Seemann就在它的博客中將Service Locator視為一種“反模式(Anti-Pattern)”,雖然也有人對此提出不同的意見,但我個人是非常不推薦使用這種設計模式的。我反對使用Service Locator與上面提到的反對使用屬性注入和方法注入具有類似的緣由。
我們既然將一組相關的操作定義在一個能夠復用的服務中,不但要求服務自身具有獨立和自治的特性,也要求服務之間的應該具有明確的邊界,服務之間的依賴關系應該是明確的而不是模糊的。不論是采用屬性注入或者構造器注入,還是使用Service Locator來提供當前依賴的服務,這無疑為當前的應用增添了一個新的依賴,即針對DI容器或者Service Locator的依賴。
當前服務針對另一個服務的依賴與針對DI容器或者Service Locator的依賴具有本質的不同,前者是一種基於類型的依賴,不論是基於服務的接口還是實現類型,這是一種基於“契約”的依賴。這種依賴不僅是明確的,也是由保障的。但是DI容器也好,Service Locator也罷,它們本質上都是一個黑盒,它能夠提供所需服務的前提已經預先添加了對應的服務注冊,但是這種依賴不僅是模糊和也是可靠的。
正因為如此,ASP.NET Core框架使用的DI框架只支持構造器注入,而不支持屬性和方法注入(類似於Startup和中間件基於約定的方法注入除外)。但是我們很有可能不知不覺地會按照Service Locator模式來編寫我們的代碼,從某種意義上講,當我們在程序中使用IServiceProvider(表示DI容器)來提取某個服務實例的時候,就意味着我們已經在使用Service Locator模式了,所以當我們遇到這種情況下的時候應該多想一想是否一定需要這么做。雖然我們提倡盡可能避免使用Service Locator模式,但是有的時候(有其是在編寫框架或者組件的時候),我們是無法避免使用IServiceProvider來提取服務。
依賴注入[1]: 控制反轉
依賴注入[2]: 基於IoC的設計模式
依賴注入[3]: 依賴注入模式
依賴注入[4]: 創建一個簡易版的DI框架[上篇]
依賴注入[5]: 創建一個簡易版的DI框架[下篇]
依賴注入[6]: .NET Core DI框架[編程體驗]
依賴注入[7]: .NET Core DI框架[服務注冊]
依賴注入[8]: .NET Core DI框架[服務消費]