1. 程式人生 > >[ASP.NET Core 3框架揭祕] 依賴注入:依賴注入模式

[ASP.NET Core 3框架揭祕] 依賴注入:依賴注入模式

IoC主要體現了這樣一種設計思想:通過將一組通用流程的控制權從應用轉移到框架之中以實現對流程的複用,並按照“好萊塢法則”實現應用程式的程式碼與框架之間的互動。我們可以採用若干設計模式以不同的方式實現IoC,比如我們在前面介紹的模板方法、工廠方法和抽象工廠,接下來我們介紹一種更有價值的IoC模式:依賴注入(DI:Dependency Injection)。

一、由容器提供物件

和前面介紹的工廠方法和抽象工廠模式一樣,依賴注入是一種“物件提供型”的設計模式,在這裡我們將提供的物件統稱為“服務”、“服務物件”或者“服務例項”。在一個採用依賴注入的應用中,我們定義某個型別的時候,只需要直接將它依賴的服務採用相應的方式注入進來就可以了。

在應用啟動的時候,我們會對所需的服務進行全域性註冊。一般來說,服務大都是針對實現的介面或者繼承的抽象類進行註冊的,服務註冊資訊的幫助我們在後續消費過程中提供對應的服務例項。按照“好萊塢法則”,應用只需要定義並註冊好所需的服務,服務例項的提供則完全交給框架來完成,框架則會利用一個獨立的“容器(Container)”來提供所需的每一個服務例項。

我們將這個被框架用來提供服務的容器稱為“依賴注入容器”,也有很多人將其稱為“IoC容器”,根據前面針對IoC的介紹,我不認為後者是一個合理的稱謂。依賴注入容器之所以能夠按照我們希望的方式來提供所需的服務是因為該容器是根據服務註冊資訊來建立的,服務註冊了包含提供所需服務例項的所有資訊。

舉個簡單的例子,我們建立一個名為Cat的依賴注入容器型別,那麼我們可以呼叫如下這個擴充套件方法GetService<T>從某個Cat物件中獲取指定型別的服務物件。我之所以將其命名為Cat,源於我們大家都非常熟悉的一個卡通形象“機器貓(哆啦A夢)”。機器貓的那個四次元口袋就是一個理想的依賴注入容器,大熊只需要告訴哆啦A夢相應的需求,它就能從這個口袋中得到相應的法寶。依賴注入容器亦是如此,服務消費者只需要告訴容器所需服務的型別(一般是一個服務介面或者抽象服務類),就能得到與之匹配的服務例項。

public static class CatExtensions
{  
    public static T GetService<T>(this Cat cat);
}

對於我們演示的MVC框架來說,我們在前面分別採用不同的設計模式對框架的核心型別MvcEngine進行了“改造”,現在我們採用依賴注入的方式,並利用上述的這個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<IWebListener>();
        var activator = Cat.GetService<IControllerActivator>();
        var executor = Cat.GetService<IControllerExecutor>();
        var renderer = Cat.GetService<IViewRenderer>();

        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 renderer.RenderAsync(view, httpContext);
            }
            finally
            {
                await activator.ReleaseAsync(controller);
            }
        }
    }        
}

依賴注入體現了一種最為直接的服務消費方式,消費者只需要告訴提供者(依賴注入容器)所需服務的型別,後者就能根據預先註冊的規則提供一個匹配的服務例項。由於服務註冊最終決定了依賴注入容器根據指定的服務型別會提供一個怎樣的服務例項,所以我們可以通過修改服務註冊的方式來實現對框架的定製。如果應用程式需要採用前面定義的SingletonControllerActivator以單例的模式來啟用目標Controller,那麼它可以在啟動MvcEngine之前按照如下的形式將SingletonControllerActivator註冊到依賴注入容器上就可以了。

public class App
{
    static void Main(string[] args)
    {
        var cat = new Cat() .Register<ControllerActivator, SingletonControllerActivator>();
        var engine     = new MvcEngine(cat);
        var address     = new Uri("http://localhost/mvcapp");
        engine.StartAsync(address);
    }
}

二、三種依賴注入方式

一項任務往往需要多個物件相互協作才能完成,或者說某個物件在完成某項任務的時候需要直接或者間接地依賴其他的物件來完成某些必要的步驟,所以執行時物件之間的依賴關係是由目標任務來決定的,是“恆定不變的”,自然也無所謂“解耦”的說法。但是執行時物件通過對應的類來定義,類與類之間耦合則可以通過對依賴進行抽象的方式來降低或者解除。

從服務消費的角度來講,我們藉助於一個介面對消費的服務進行抽象,那麼服務消費程式針對具體服務型別的依賴可以轉移到對服務介面的依賴上面,但是在執行時提供給消費者的總是一個針對某個具體服務型別的物件。不僅如此,要完成定義在服務介面的操作,這個物件可能需要其他相關物件的參與,換句話說,提供的這個依賴服務物件可能具有對其他服務物件的依賴。作為服務物件提供者的依賴注入容器,它會根據這一依賴鏈提供所有的依賴服務例項。

如下圖所示,應用框架呼叫GetService<IFoo>方法向依賴注入容器索取一個實現了IFoo介面的服務物件,後者會根據預先註冊的型別對映關係建立一個型別為Foo的物件。由於Foo物件需要Bar和Gux物件的參與才能完成目標操作,所以Foo具有了針對Bar和Gux的直接依賴。至於服務物件Bar,它又依賴Baz,那麼Baz成為了Foo的間接依賴。對於依賴注入容器最終提供的Foo物件,它所直接或者間接依賴的物件Bar、Baz和Qux都會預先被初始化並自動注入到該物件之中。

從面向物件程式設計的角度來講,型別中的欄位或者屬性是依賴的一種主要體現形式。如果型別A中具有一個B型別的欄位或者屬性,那麼A就對B產生了依賴,所以我們可以將依賴注入簡單地理解為一種針對依賴欄位或者屬性的自動化初始化方式。我們可以通過三種主要的方式達到這個目的,這就是接下來著重介紹的三種依賴注入方式。

構造器注入

構造器注入就是在建構函式中藉助引數將依賴的物件注入到由它建立的物件之中。如下面的程式碼片段所示,Foo針對Bar的依賴體現在只讀屬性Bar上,針對該屬性的初始化實現在建構函式中,具體的屬性值由建構函式傳入的引數提供。

public class Foo
{
    public IBar Bar{get;}
    public Foo(IBar bar) =>Bar = bar;
}

除此之外,構造器注入還體現在對建構函式的選擇上。如下面的程式碼片段所示,Foo類定義了兩個建構函式,依賴注入容器在建立Foo物件之前首先需要選擇一個適合的建構函式。至於目標建構函式如何選擇,不同的依賴注入容器可能有不同的策略,比如可以選擇引數最多或者最少的建構函式,或者可以按照如下所示的方式在目標建構函式上標註一個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;
}

屬性注入

如果依賴直接體現為類的某個屬性,並且該屬性不是隻讀的,我們可以讓依賴注入容器在物件建立之後自動對其進行賦值進而達到依賴注入的目的。一般來說,我們在定義這種型別的時候,需要顯式將這樣的屬性標識為需要自動注入的依賴屬性以區別於其他普通的屬性。如下面的程式碼片段所示,Foo類中定義了兩個可讀寫的公共屬性Bar和Baz,我們通過標註InjectionAttribute特性的方式將屬性Baz設定為自動注入的依賴屬性。對於由依賴注入容器提供的Foo物件,它的Baz屬性將會自動被初始化。

public class Foo
{
    public IBar Bar{get; set;}

    [Injection]
    public IBaz Baz {get; set;}
}

方法注入

體現依賴關係的欄位或者屬性可以通過方法的形式初始化。如下面的程式碼片段所示,Foo針對Bar的依賴體現在只讀屬性上,針對該屬性的初始化實現在Initialize方法中,具體的屬性值由該方法的傳入的引數提供。我們同樣通過標註特性(InjectionAttribute)的方式將該方法標識為注入方法。依賴注入容器在呼叫建構函式建立一個Foo物件之後,它會自動呼叫這個Initialize方法對只讀屬性Bar進行賦值。

public class Foo
{
    public IBar Bar{get;}

    [Injection]
    public Initialize(IBar bar)=> Bar = bar;
}

除了上述這種通過依賴注入容器在初始化服務過程中自動呼叫的實現之外,我們還可以利用它實現另一種更加自由的方法注入,這種注入方式在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);
}

上面這種方式的方法注入促成了一種“面向約定”的程式設計方式。由於不再需要實現某個預定義的介面或者繼承某一個預定義的基類,需要實現或者重寫方法的宣告也就少了對應的限制,這樣就可以採用最直接的方式將依賴的服務注入到方法中。對於前面介紹的這幾種注入方式,構造器注入是最為理想的形式,我個人不建議使用屬性注入和方法注入(前面介紹的這種基於約定的方法注入除外)。

三、Service Locator模式

假設我們需要定義一個服務型別Foo,它依賴於另外兩個服務Bar和Baz,後者對應的服務介面分別為IBar和IBaz。如果當前應用中具有一個依賴注入容器(假設類似於我們在前面定義的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();
    }
}

從表面上看,上面提供的這兩種服務型別的定義方式貌似都不錯,至少它們都解決針對依賴服務的耦合問題,並將針對服務實現的依賴轉變成針對介面的依賴。那麼哪一種更好呢?我想有人會選擇第二種定義方式,因為這種定義方式不僅僅程式碼量更少,針對服務的提供也更加直接。我們直接在建構函式中“注入”了代表“依賴注入容器”的Cat物件,在任何使用到依賴服務的地方,我們只需要利用它來提供對應的服務例項就可以了。

但事實上第二種定義方式採用的設計模式根本就不是“依賴注入”,而是一種被稱為“Service Locator”的設計模式。Service Locator模式同樣具有一個通過服務註冊建立的全域性的容器來提供所需的服務例項,該容器被稱為“Service Locator”。“依賴注入容器”和“Service Locator”實際上是同一事物在不同設計模式中的不同稱謂罷了,那麼依賴注入和Service Locator之間的差異體現在什麼地方呢?

我覺得可以從“依賴注入容器”或者“Service Locator”被誰使用的角度來區分這兩種設計模式的差別。在一個採用依賴注入的應用中,我們只需要採用標準的注入形式將服務型別定義好,並在應用啟動之前完成相應的服務註冊就可以了,框架自身的引擎在執行過程中會利用依賴注入容器來提供當前所需的服務例項。換句話說,依賴注入容器的使用者應該是框架而不是應用程式。Service Locator模式顯然不是這樣,很明顯是應用程式在利用它來提供所需的服務例項,所以它的使用者是應用程式。

我們也可以從另外一個角度區分兩者之間的差別。由於依賴服務是以“注入”的方式來提供的,所以採用依賴注入模式的應用可以看成是將服務“推”給依賴注入容器,Service Locator模式下的應用則是利用Service Locator去“拉”取所需的服務,這一推一拉也準確地體現了兩者之間的差異。那麼既然兩者之間有差別,究竟孰優孰劣呢?

早在2010年,Mark Seemann就在他的部落格中將Service Locator視為一種“反模式(Anti-Pattern)”,雖然也有人對此提出不同的意見,但我個人是非常不推薦使用這種設計模式的。我反對使用Service Locator與前面提到的反對使用屬性注入和方法注入具有類似的緣由。

本著“鬆耦合、高內聚”的設計原則,我們既然將一組相關的操作定義在一個能夠複用的服務中,就應該儘量要求服務自身不但具有獨立和自治的特性,也要求服務之間的應該具有明確的界限,服務之間的依賴關係應該是明確的而不是模糊的。不論是採用屬性注入或者方法注入,還是使用Service Locator來提供當前依賴的服務,這無疑為當前的服務增添了一個新的依賴,即針對依賴注入容器或者Service Locator的依賴。

當前服務針對另一個服務的依賴與針對依賴注入容器或者Service Locator的依賴具有本質的不同,前者是一種基於型別的依賴,不論是基於服務的介面還是實現型別,這是一種基於“契約”的依賴。這種依賴不僅是明確的,也是有保障的。但是依賴注入容器或者Service Locator本質上是一個黑盒,它能夠提供所需服務的前提是相應的服務註冊已經預先添加了容器之中,但是這種依賴不僅是模糊的也是不可靠的。

ASP.NET Core框架使用的依賴注入框架只支援構造器注入,而不支援屬性和方法注入(類似於Startup和中介軟體基於約定的方法注入除外),但是我們很有可能不知不覺地會按照Service Locator模式來編寫我們的程式碼。從某種意義上講,當我們在程式中使用IServiceProvider(表示依賴注入容器)來提取某個服務例項的時候,就意味著我們已經在使用Service Locator模式了,所以當我們遇到這種情況下的時候應該多想一想是否一定需要這麼做。

[ASP.NET Core 3框架揭祕] 依賴注入:控制反轉
[ASP.NET Core 3框架揭祕] 依賴注入:IoC模式
[ASP.NET Core 3框架揭祕] 依賴注入:依賴注入模式
[ASP.NET Core 3框架揭祕] 依賴注入:一個迷你版DI框