1. 程式人生 > >依賴注入[3]: 依賴注入模式

依賴注入[3]: 依賴注入模式

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來提取服務。