1. 程式人生 > >依賴注入[8]: .NET Core DI框架[服務消費]

依賴注入[8]: .NET Core DI框架[服務消費]

包含服務註冊資訊的IServiceCollection物件最終被用來建立作為DI容器的IServiceProvider物件。當需要消費某個服務例項的時候,我們只需要指定服務型別呼叫IServiceProvider的GetService方法,IServiceProvider就會根據對應的服務註冊提供所需的服務例項。

目錄
一、IServiceProvider
二、建構函式的選擇
三、服務範圍
四、三種生命週期模式
五、ASP.NET Core應用下的生命週期
六、服務範圍檢驗

一、IServiceProvider

如下面的程式碼片段所示,IServiceProvider介面定義了唯一的方法GetService方法根據指定的服務型別來提供對應的服務例項。當我們在利用包含服務註冊的IServiceCollection物件建立對作為DI容器的IServiceProvider物件之後,我們只需要將服務註冊的服務型別(對應於ServiceDescriptor的ServiceType屬性)作為引數呼叫GetService方法,後者就能根據服務註冊資訊為我們提供對應的服務例項。

public interface IServiceProvider
{
    object GetService(Type serviceType);
}

public static class ServiceCollectionContainerBuilderExtensions
{
    public static ServiceProvider BuildServiceProvider(this IServiceCollection services);
}

預設情況下呼叫IServiceCollection的BuildServiceProvider方法返回的一個ServiceProvider物件,但是我並不打算詳細介紹這個型別,這是因為實現在該型別中針對服務例項的提供機制一直在不斷的變化,而且這個變化趨勢在未來版本更替過程中還將繼續。除此之外,ServiceProvider涉及到一系列內部型別和介面,所以我們不打算涉及具體的細節,只講總體設計。

除了定義在IServiceProvider的這個GetService方法,DI框架為了該介面定了如下這些擴充套件方法。GetService<T>方法會泛型引數的形式指定了服務型別,返回的服務例項也會作對應的型別轉換。如果指定服務型別的服務註冊不存在,GetService方法會返回Null,如果呼叫GetRequiredService或者GetRequiredService<T>方法則會丟擲一個InvalidOperationException型別的異常。如果所需的服務例項是必需的,我們一般會呼叫者兩個擴充套件方法。

public static class ServiceProviderServiceExtensions
{
    
public static T GetService<T>(this IServiceProvider provider); public static T GetRequiredService<T>(this IServiceProvider provider); public static object GetRequiredService(this IServiceProvider provider, Type serviceType); public static IEnumerable<T> GetServices<T>(this IServiceProvider provider); public static IEnumerable<object> GetServices(this IServiceProvider provider, Type serviceType); }

如果針對某個型別註冊了多個服務,那麼GetService方法總是會採用最新新增的服務註冊來提供服務例項。如果希望利用所有的服務註冊來建立一組服務例項列表,我們可以呼叫GetServices或者GetServices<T>方法。

二、建構函式的選擇

對於通過呼叫IServiceCollection的BuildServiceProvider方法建立的IServiceProvider來說,當我們通過指定服務型別呼叫其GetService方法以獲取對應的服務例項的時候,它總是會根據提供的服務型別從服務註冊列表中找到對應的ServiceDescriptor物件,並根據後者提供所需的服務例項。

ServiceDescriptor具有三個不同的建構函式,分別對應著服務例項最初的三種建立方式,我們可以提供一個Func<IServiceProvider, object>物件作為工廠來建立對應的服務例項,也可以直接提供一個建立好的服務例項。如果我們提供的是服務的實現型別,那麼最終提供的服務例項將通過呼叫該型別的某個建構函式來建立,那麼建構函式時通過怎樣的策略被選擇出來的呢?

如果IServiceProvider物件試圖通過呼叫建構函式的方式來建立服務例項,傳入建構函式的所有引數必須先被初始化,最終被選擇出來的建構函式必須具備一個基本的條件:IServiceProvider能夠提供建構函式的所有引數。為了讓讀者朋友能夠更加真切地理解IServiceProvider在建構函式選擇過程中採用的策略,我們不讓也採用例項演示的方式來進行講解。

我們在一個控制檯應用中定義了四個服務介面(IFoo、IBar、IBaz和IGux)以及實現它們的四個服務類(Foo、Bar、Baz和Gux)。如下面的程式碼片段所示,我們為Gux定義了三個建構函式,引數均為我們定義了服務介面型別。為了確定IServiceProvider最終選擇哪個建構函式來建立目標服務例項,我們在建構函式執行時在控制檯上輸出相應的指示性文字。

public interface IFoo {}
public interface IBar {}
public interface IBaz {}
public interface IGux {}

public class Foo : IFoo {}
public class Bar : IBar {}
public class Baz : IBaz {}
public class Gux : IGux
{
    public Gux(IFoo foo) => Console.WriteLine("Selected constructor: Gux(IFoo)");
    public Gux(IFoo foo, IBar bar) => Console.WriteLine("Selected constructor: Gux(IFoo, IBar)");
    public Gux(IFoo foo, IBar bar, IBaz baz) => Console.WriteLine("Selected constructor: Gux(IFoo, IBar, IBaz)");
}

在如下這段演示程式中我們建立了一個ServiceCollection物件並在其中新增針對IFoo、IBar以及IGux這三個服務介面的服務註冊,針對服務介面IBaz的註冊並未被新增。我們利用由它建立的IServiceProvider來提供針對服務介面IGux的例項,究竟能否得到一個Gux物件呢?如果可以,它又是通過執行哪個建構函式建立的呢?

class Program
{
    static void Main(string[] args)
    {       
        new ServiceCollection()
            .AddTransient<IFoo, Foo>()
            .AddTransient<IBar, Bar>()
            .AddTransient<IGux, Gux>()
            .BuildServiceProvider()
            .GetServices<IGux>();
    }
}

對於定義在Gux中的三個建構函式來說,由於建立IServiceProvider提供的IServiceCollection集合包含針對介面IFoo和IBar的服務註冊,所以它能夠提供前面兩個建構函式的所有引數。由於第三個建構函式具有一個型別為IBaz的引數,這無法通過IServiceProvider來提供。根據我們上面介紹的第一個原則(IServiceProvider能夠提供建構函式的所有引數),Gux的前兩個建構函式會成為合法的候選建構函式,那麼IServiceProvider最終會選擇哪一個呢?

在所有合法的候選建構函式列表中,最終被選擇出來的建構函式具有這麼一個特徵:每一個候選建構函式的引數型別集合都是這個建構函式引數型別集合的子集。如果這樣的建構函式並不存在,一個型別為InvalidOperationException的異常會被丟擲來。根據這個原則,Gux的第二個建構函式的引數型別包括IFoo和IBar,而第一個建構函式僅僅具有一個型別為IFoo的引數,最終被選擇出來的會是Gux的第二個建構函式,所有執行我們的例項程式將會在控制檯上產生如圖1所示的輸出結果。

4-5
圖1建構函式的選擇策略

接下來我們對例項程式略加改動。如下面的程式碼片段所示,我們只為Gux定義兩個建構函式,它們都具有兩個引數,引數型別分別為IFoo&IBar和IBar&IBaz。我們將針對IBaz/Baz的服務註冊新增到建立的ServiceCollection物件上。

class Program
{
    static void Main(string[] args)
    {       
        new ServiceCollection()
            .AddTransient<IFoo, Foo>()
            .AddTransient<IBar, Bar>()
            .AddTransient<IBaz, Baz>()
            .AddTransient<IGux, Gux>()
            .BuildServiceProvider()
            .GetServices<IGux>();
    }
}

public class Gux : IGux
{
    public Gux(IFoo foo, IBar bar) {}
    public Gux(IBar bar, IBaz baz) {}
}

對於Gux的兩個建構函式,雖然它們的引數均能夠由IServiceProvider來提供,但是並沒有一個建構函式的引數型別集合能夠成為所有有效建構函式引數型別集合的超集,所以ServiceProvider無法選擇出一個最佳的建構函式。執行該程式後會丟擲如圖2所示的InvalidOperationException異常,並提示無法從兩個候選的建構函式中選擇出一個最優的來建立服務例項。

4-6
圖2 建構函式的選擇策略

接下來我們著重介紹服務生命週期的話題。生命週期決定了IServiceProvider採用怎樣的方式提供和釋放服務例項。雖然不同版本的DI框架在針對服務例項生命週期管理採用了不同的實現,但總的來說,實現原理還是類似的。在我們提供的DI框架Cat中,我們已經模擬了三種生命週期模式的實現原理,接下來我們結合服務範圍的概念來對這個話題做進一步講解。

三、服務範圍

對於DI框架體用的三種生命週期(Singleton、Scoped和Transient)來說,Singleton和Transient都具有明確的語義,但是Scoped代表一種怎樣的生命週期模式,很多初學者往往搞不清楚。這裡所謂的Scope指的是由IServiceScope介面表示的“服務範圍”,該範圍由IServiceScopeFactory介面表示的“服務範圍工廠”來建立。如下面的程式碼片段所示,IServiceProvider的擴充套件方法CreateScope正是利用提供的IServiceScopeFactory服務例項來建立作為服務範圍的IServiceScope物件。

public interface IServiceScope : IDisposable
{
    IServiceProvider ServiceProvider { get; }
}

public interface IServiceScopeFactory
{
    IServiceScope CreateScope();
}

public static class ServiceProviderServiceExtensions
{
   public static IServiceScope CreateScope(this IServiceProvider provider) => provider.GetRequiredService<IServiceScopeFactory>().CreateScope();
}

任何一個IServiceProvider物件都可以利用其註冊的IServiceScopeFactory服務建立一個代表服務範圍的IServiceScope物件,後者代表的“範圍”內具有一個新建立的IServiceProvider物件(對應著介面IServiceScope的ServiceProvider屬性),後者同樣具有提供服務例項的能力,它與當前IServiceProvider具在邏輯上具有如圖3所示的“父子關係”。

4-7

圖3 IServiceScope與IServiceProvider(邏輯結構)

如圖3所示的樹形層次結構只是一種邏輯結構,從物件引用層面來開,通過某個IServiceScope包裹的IServiceProvider物件不需要知道自己的“父親”是誰,它只關心作為根節點的IServiceProvider在哪裡就可以了。圖4從物理層面揭示了IServiceScope/IServiceProvider物件之間的關係,任何一個IServiceProvider物件都具有針對根容器的引用。

4-8

圖4 IServiceScope與IServiceProvider(物理結構)

四、三種生命週期模式

只有在充分了解IServiceScope的建立過程以及它與IServiceProvider之間的關係之後,我們才會對三種生命週期管理模式(Singleton、Scope和Transient)具有深刻的認識。就服務例項的提供方式來說,它們之間具有如下的差異:

  • Singleton:IServiceProvider建立的服務例項儲存在作為根容器的IServiceProvider上,所有多個同根的IServiceProvider物件提供的針對同一型別的服務例項都是同一個物件。

  • Scoped:IServiceProvider建立的服務例項由自己儲存,所以同一個IServiceProvider物件提供的針對同一型別的服務例項均是同一個物件。

  • Transient:針對每一次服務提供請求,IServiceProvider總是建立一個新的服務例項。

IServiceProvider除了為我們提供所需的服務例項之外,對於由它提供的服務例項,它還肩負起回收釋放之責。這裡所說的回收釋放與.NET Core自身的垃圾回收機制無關,僅僅針對於自身型別實現了IDisposable介面的服務例項(下面簡稱為Disposable服務例項),針對服務例項的釋放體現為呼叫它們的Dispose方法。IServiceProvider針對服務例項採用的回收釋放策略取決於對應服務註冊的生命週期模式,具體服務回收策略主要體現為如下兩點:

  • Singleton:提供Disposable服務例項儲存在作為根容器的IServiceProvider物件上,只有後者被釋放的時候這些Disposable服務例項才能被釋放。

  • Scoped和Transient:IServiceProvider物件會儲存由它提供的Disposable服務例項,當自己被釋放的時候,這些Disposable會被釋放。

綜上所述,每個作為DI容器的IServiceProvider物件都具有如圖5所示兩個列表來存放服務例項,我們將它們分別命名為“Realized Services”和“Disposable Services”,對於一個作為非根容器的IServiceProvider物件來說,由它提供的Scoped服務儲存在自身的Realized Services列表中,Singleton服務例項則會儲存在根容器的Realized Services列表。如果服務實現型別實現了IDisposable介面,Scoped和Singleton服務例項會被儲存到自身的Disposable Services列表中,而Singleton服務例項則會儲存到根容器的Disposable Services列表。

4-9

圖5 生命週期管理

對於作為容器的IServiceProvider物件來說,Singleton和Scope模式對它來說是兩種等效的生命週期模式,由它提供的Singleton和Scoped服務例項會被被存放到自身的Realized Services列表,而所有需要被釋放的服務例項則被存放到Disposable Services列表。

當某個IServiceProvider被用於提供針對指定型別的服務例項時,它會根據服務型別提取出表示服務註冊的ServiceDescriptor物件並根據後者得到對應的生命週期模式。如果生命週期模式為Singleton,並且作為根容器的Realized Services列表中包含對應的服務例項,後者將作為最終提供的服務例項。如果這樣的服務例項尚未建立,那麼新的服務將會被創建出來並作為提供的服務例項。在返回之後該物件會被新增到根容器的Realized Services列表中,如果例項型別實現了IDisposable介面,建立的服務例項會被新增到根容器的Disposable Services列表中。

如果生命週期為Scoped,那麼IServiceProvider會先確定自身的Realized Services列表中是否存在對應的服務例項,存在的服務例項將作為最終返回的服務例項。如果Realized Services列表不存在對應的服務例項,那麼新的服務例項會被創建出來。在作為最終的服務例項被返回之前,建立的服務例項會被新增的自身的Realized Services列表中,如果例項型別實現了IDisposable介面,建立的服務例項會被新增到自身的Disposable Services列表中。

如果提供服務的生命週期為Transient,那麼IServiceProvider會直接建立一個新的服務例項。在作為最終的服務例項被返回之前,建立的服務例項會被新增的自身的Realized Services列表中,如果例項型別實現了IDisposable介面,建立的服務例項會被新增到自身的Disposable Services列表中。

對於非根容器的IServiceProvider物件來說,它的生命週期是由“包裹”著它的IServiceScope物件控制的。從上面給出的定義可以看出IServiceScope實現了IDisposable介面,Dispose方法的執行不僅標誌著當前服務範圍的終結,也意味著對應IServiceProvider物件生命週期的結束。

當代表服務範圍的IServiceScope物件的Dispose方法被呼叫的時候,它會呼叫對應IServiceProvider的Dispose方法。一旦IServiceProvider因自身Dispose方法的呼叫而被釋放的時候,它會從自身的Disposable Services列表中提取出所有需要被釋放的服務例項,並呼叫它們的Dispose方法。在這之後,Disposable Services和Realized Services列表會被清空,列表中的服務例項和IServiceProvider物件自身會成為垃圾物件被GC回收。

五、ASP.NET Core應用下的生命週期

DI框架所謂的服務範圍在ASP.NET Core應用中具有明確的邊界,指的是針對每個HTTP請求的上下文,也就是服務範圍的生命週期與每個請求上下文繫結在一起。如圖6所示,ASP.NET Core應用中用於提供服務例項的IServiceProvider物件分為兩種型別,一種是作為根容器並與應用具有相同生命週期的IServiceProvider,另一個類則是根據請求及時建立和釋放的IServiceProvider,我們可以將它們分別稱為Application ServiceProviderRequest ServiceProvider

4-10

圖6 生命週期管理

在ASP.NET Core應用初始化過程中,即請求管道構建過程中使用的服務例項都是由Application ServiceProvider提供的。在具體處理每個請求時,ASP.NET Core框架會利用註冊的一箇中間件來針對當前請求建立一個服務範圍,該服務範圍提供的Request ServiceProvider用來提供當前請求處理過程中所需的服務例項。一旦服務請求處理完成,上述的這個中介軟體會主動釋放掉由它建立的服務範圍。

六、服務範圍檢驗

如果我們在一個ASP.NET Core應用中將一個服務的生命週期註冊為Scoped,實際上是希望服務例項採用基於請求的生命週期。舉個簡單的例子,如果我們在一個ASP.NET Core應用中採用Entity Framework Core來訪問資料庫,我們一般會將對應的DbContext型別(姑且命名為FoobarDbContext)註冊為一個Scoped服務,這樣既可以保證在FoobarDbContext能夠自同一個請求上下文中被重用,也可以確保FoobarDbContext在請求結束之後能夠及時將資料庫連結釋放掉。

但是如果我們使用作為根容器的Application ServiceProvider來提供這個DbContext物件,意味著提供的DbContext將被儲存在Application ServiceProvider的Realized Services列表中,知道應用關閉時才能被釋放。即使提供該FoobarDbContext是針對請求的Request ServiceProvider,如果另一個Singleton服務(姑且命名為Foobar)具有針對它的依賴,意味著提供服務例項Foobar將會具有針對FoobarDbContext物件的引用。由於Foobar是一個Singleton服務例項,所以被它引用的FoobarDbContext也只能在應用關閉的時候才能被釋放。

為了解決這個問題,我們可以讓IServiceProvider在提供Scoped服務例項的時候進行鍼對性的檢驗。針對服務範圍驗證的開關由ServiceProviderOptions的ValidateScopes屬性來控制,預設情況下是關閉的。如果希望開啟針對服務範圍的驗證,我們可以在呼叫IServiceCollect介面的BuildServiceProvider方法的時候指定一個ServiceProviderOptions物件作為引數,或者直接呼叫另一個擴充套件方法並將傳入的引數validateScopes設定為True。

public class ServiceProviderOptions
{
    public bool ValidateScopes { get; set; }
}

public static class ServiceCollectionContainerBuilderExtensions
{
    public static ServiceProvider BuildServiceProvider(this IServiceCollection services, ServiceProviderOptions options);
    public static ServiceProvider BuildServiceProvider(this IServiceCollection services, bool validateScopes);
}

針對服務範圍的驗證對於IServiceProvider來說是一項額外附加的操作,會對效能帶來或多或少的影響,所以一般情況下這個開關只會在開發(Development)環境被開啟,對於產品(Production)或者預發(Staging)環境下最好將其關閉。