1. 程式人生 > >依賴注入[6]: .NET Core DI框架[程式設計體驗]

依賴注入[6]: .NET Core DI框架[程式設計體驗]

毫不誇張地說,整個ASP.NET Core框架是建立在一個依賴注入框架之上的,它在應用啟動時構建請求處理管道過程中,以及利用該管道處理每個請求過程中使用到的服務物件均來源於DI容器。該DI容器不僅為ASP.NET Core框架提供必要的服務,同時作為了應用的服務提供者,依賴注入已經成為了ASP.NET Core應用基本的程式設計模式。在前面一系列的文章中,我們主要從理論層面講述了依賴注入這種設計模式,補充必要的理論基礎是為了能夠理解與ASP.NET Core框架無縫整合的依賴注入框架的設計原理。我們總是採用“先簡單體驗,後者深入剖析”來講述每一個知識點,所以我們利用一些簡單的例項從程式設計層面來體驗一下服務註冊的新增和服務例項的提取。

一、服務的註冊與消費

為了讓讀者朋友們能夠更加容易地認識依賴注入框架的實現原理和程式設計模式,我在《依賴注入[4]: 建立一個簡易版的DI框架[上篇]》和《依賴注入[5]: 建立一個簡易版的DI框架[下篇]》自行建立了一個名為Cat的依賴注入框架。不論是程式設計模式和實現原理,Cat與我們現在即將介紹的依賴注入框架都非常相似,對於後者提供的每一個特性,我們幾乎都能在Cat中找到對應物。

我在設計Cat的時候即將它作為提供服務例項的DI容器,也作為了存放服務註冊的容器,但是與ASP.NET Core框架整合的這個依賴注入框架則將這兩者分離開來。我們新增的服務註冊被儲存到通過IServiceCollection

介面表示的集合之中,基於這個集合建立的DI容器體現為一個IServiceProvider

由於作為DI框架的IServiceProvider具有類似於Cat的層次結構,所以兩者對提供的服務例項採用一致的生命週期管理方式。DI框架利用如下這個列舉ServiceLifetime提供了SingletonScopedTransient三種生命週期模式是,我在Cat中則將其命名為RootSelfTransient,前者命名關注於現象,而我則關注於內部實現。

public enum ServiceLifetime
{
    Singleton,
    Scoped,
    Transient
}
應用初始化過程中新增的服務註冊是DI容器用於提供所需服務例項的依據。由於IServiceProvider總是利用指定的服務型別來提供對應服務例項,所以服務是基於型別進行註冊的,我們傾向於利用介面來對服務進行抽象,所以這裡的服務型別一般為介面。除了以指定服務例項的形式外(預設採用Singleton模式),我們在註冊服務的時候必須指定一個具體的生命週期模式。
  • 指定註冊非服務型別和實現型別;
  • 指定一個現有的服務例項;
  • 指定一個建立服務例項的委託物件。

我們定義瞭如下的介面和對應的實現型別來演示針對DI框架的服務註冊和提取。其中Foo、Bar和Baz分別實現了對應的介面IFoo、IBar和IBaz,為了反映Cat對服務例項生命週期的控制,我們讓它們派生於同一個基類Base。Base實現了IDisposable介面,我們在其建構函式和實現的Dispose方法中打印出相應的文字以確定對應的例項何時被建立和釋放。我們還定義了一個泛型的介面IFoobar<T1, T2>和對應的實現類Foobar<T1, T2>來演示針對泛型服務例項的提供。

public interface IFoo {}
public interface IBar {}
public interface IBaz {}
public interface IFoobar<T1, T2> {}
public class Base : IDisposable
{
    public Base() => Console.WriteLine($"An instance of {GetType().Name} is created.");
    public void Dispose() => Console.WriteLine($"The instance of {GetType().Name} is disposed.");
}

public class Foo : Base, IFoo, IDisposable { }
public class Bar : Base, IBar, IDisposable { }
public class Baz : Base, IBaz, IDisposable { }
public class Foobar<T1, T2>: IFoobar<T1,T2>
{
    public IFoo Foo { get; }
    public IBar Bar { get; }
    public Foobar(IFoo foo, IBar bar)
    {
        Foo = foo;
        Bar = bar;
    }
}

在如下所示的程式碼片段中我們建立了一個ServiceCollection(它是對IServiceCollection介面的預設實現)物件並呼叫相應的方法(AddTransient、AddScoped和AddSingleton)針對介面IFoo、IBar和IBaz註冊了對應的服務,從方法命名可以看出註冊的服務採用的生命週期模式分別為Transient、Scoped和Singleton。在完成服務註冊之後,我們呼叫IServiceCollection介面的擴充套件方法BuildServiceProvider創建出代表DI容器的IServiceProvider物件,並利用它呼叫後者的GetService<T>方法來提供相應的服務例項。除錯斷言表明IServiceProvider提供的服務例項與預先新增的服務註冊是一致的。

class Program
{
    static void Main()
    {
        var provider = new ServiceCollection()
            .AddTransient<IFoo, Foo>()
            .AddScoped<IBar>(_ => new Bar())
            .AddSingleton<IBaz, Baz>()
            .BuildServiceProvider();
        Debug.Assert(provider.GetService<IFoo>() is Foo);
        Debug.Assert(provider.GetService<IBar>() is Bar);
        Debug.Assert(provider.GetService<IBaz>() is Baz); 
    }
}

除了提供類似於IFoo、IBar和IBaz這樣非泛型服務例項之外,如果具有對應的泛型定義(Generic Definition)的服務註冊,IServiceProvider同樣也能提供泛型服務例項。如下面的程式碼片段所示,在為建立的ServiceCollection物件添加了針對IFoo和IBar介面的服務註冊之後,我們呼叫AddTransient方法註冊了針對泛型定義IFoobar<,>的服務註冊,實現的型別為Foobar<,>。當我們利用ServiceCollection創建出代表DI容器的IServiceProvider物件並利用後者提供一個型別為IFoobar<IFoo, IBar>的服務例項的時候,它會建立並返回一個Foobar<Foo, Bar>物件。

var provider = new ServiceCollection()
    .AddTransient<IFoo, Foo>()
    .AddTransient<IBar, Bar>()
    .AddTransient(typeof(IFoobar<,>), typeof(Foobar<,>))
    .BuildServiceProvider();

var foobar = (Foobar<IFoo, IBar>)provider.GetService<IFoobar<IFoo, IBar>>();
Debug.Assert(foobar.Foo is Foo);
Debug.Assert(foobar.Bar is Bar);
當我們在進行服務註冊的時候,可以為同一個型別新增多個服務註冊,實際上新增的所有服務註冊均是有效的。不過由於擴充套件方法GetService<T>總是返回一個唯一的服務例項,我們對該方法採用了“後來居上”的策略,即總是採用最近新增的服務註冊來建立服務例項。如果我們呼叫另一個擴充套件方法GetServices<T>,它將利用返回所有服務註冊提供的服務例項。如下面的程式碼片段所示,我們為建立的ServiceCollection物件添加了三個針對Base型別的服務註冊,對應的實現型別分別為Foo、Bar和Baz。我們最後將Base作為泛型引數呼叫了GetServices<Base>方法,該方法會返回包含三個Base物件的集合,集合元素的型別分別為Foo、Bar和Baz。
var services = new ServiceCollection()
    .AddTransient<Base, Foo>()
    .AddTransient<Base, Bar>()
    .AddTransient<Base, Baz>()
    .BuildServiceProvider()
    .GetServices<Base>();
Debug.Assert(services.OfType<Foo>().Any());
Debug.Assert(services.OfType<Bar>().Any());
Debug.Assert(services.OfType<Baz>().Any());
對於IServiceProvider針對服務例項的提供還具有這麼一個細節:如果我們在呼叫GetService或者GetService<T>方法是將服務型別設定為IServiceProvider介面型別,提供的服務例項實際上就是當前的IServiceProvider物件。這一特性意味著我們可以將代表DI容器的IServiceProvider作為服務進行注入,但是在《依賴注入[3]: 依賴注入模式》已經提到過,一旦我們在應用中利用注入的IServiceProvider來獲取其他依賴的服務例項,意味著我們在使用“Service Locator”模式。這是一種“反模式(Anti-Pattern)”,如果迫不得已最好不要這麼做。IServiceProvider的這一特性體現在如下所示的除錯斷言中。
var provider = new ServiceCollection().BuildServiceProvider();
Debug.Assert(provider.GetService<IServiceProvider>() == provider);

二、生命週期管理

IServiceProvider之間的層次結構造就了三種不同的生命週期模式:由於Singleton服務例項儲存在作為根容器的IServiceProvider物件上,所以它能夠在多個同根IServiceProvider物件之間提供真正的單例保證。Scoped服務例項被儲存在當前IServiceProvider上,所以它只能在當前IServiceProvider物件的“服務範圍”保證的單例的。沒有實現IDisposable介面的Transient服務則採用“即用即取,用後即棄”的策略。

接下來我們通過簡單的例項來演示三種不同生命週期模式的差異。在如下所示的程式碼片段中我們建立了一個ServiceCollection物件並針對介面IFoo、IBar和IBaz註冊了對應的服務,它們採用的生命週期模式分別為Transient、Scoped和Singleton。在利用ServiceCollection創建出代表DI容器的IServiceProvider物件之後,我們呼叫其CreateScope方法建立了兩個所謂的“服務範圍”,後者的ServiceProvider屬性返回一個新的IServiceProvider物件,它實際上是當前IServiceProvider物件的子容器。我們最後利用作為子容器的IServiceProvider物件來提供相應的服務例項。

class Program
{
    static void Main()
    {
        var root = new ServiceCollection()
            .AddTransient<IFoo, Foo>()
            .AddScoped<IBar>(_ => new Bar())
            .AddSingleton<IBaz, Baz>()
            .BuildServiceProvider();
        var provider1 = root.CreateScope().ServiceProvider;
        var provider2 = root.CreateScope().ServiceProvider;

        void GetServices<TService>(IServiceProvider provider)
        {
            provider.GetService<TService>();
            provider.GetService<TService>();
        }

        GetServices<IFoo>(provider1);
        GetServices<IBar>(provider1);
        GetServices<IBaz>(provider1);
        Console.WriteLine();
        GetServices<IFoo>(provider2);
        GetServices<IBar>(provider2);
        GetServices<IBaz>(provider2);
    }
}
上面的程式執行之後會在控制檯上輸出如圖1所示的結果。由於服務IFoo被註冊為Transient服務,所以IServiceProvider針對該介面型別的四次請求都會建立一個全新的Foo物件。IBar服務的生命週期模式為Scoped,如果我們利用同一個IServiceProvider物件來提供對應的服務例項,它只會建立一個Bar物件,所以整個程式執行過程中會建立兩個Bar物件。IBaz服務採用Singleton生命週期,所以具有同根的兩個IServiceProvider物件提供的總是同一個Baz物件,後者只會被建立一次。

4-1
圖1 IServiceProvider按照服務註冊對應的生命週期模式提供服務例項

作為DI容器的IServiceProvider不僅僅為我們提供所需的服務例項,它還幫我們管理者這些服務例項的生命週期。如果某個服務例項實現了IDisposable介面,意味著當生命週期完結的時候需要通過呼叫Dispose方法執行一些資源釋放操作,這些操作同樣由提供服務例項的IServiceProvider物件來驅動執行。DI框架針對提供服務例項的釋放策略取決於對應的服務註冊採用的生命週期模式,具體的策略如下:

  • Transient和Scoped:所有實現了IDisposable介面的服務例項會被作為服務提供者的當前IServiceProvider物件儲存起來,當IServiceProvider物件自身被釋放的時候,這些服務例項的Dispose方法會隨之被呼叫。

  • Singleton:由於服務例項儲存在作為根容器的IServiceProvider物件上,所以後者被釋放的時候呼叫會觸發針對服務例項的釋放。

對於一個ASP.NET Core應用來說,它具有一個與當前應用繫結,代表全域性根容器的IServiceProvider物件。對於處理的每一次請求,ASP.NET Core框架都會利用這個根容器來建立基於當前請求的服務範圍,並利用後者提供的IServiceProvider來提供請求處理所需的服務例項。請求處理完成之後,建立的服務範圍被終結,對應的IServiceProvider物件也隨之被釋放,此時由它提供的Scoped服務例項以及實現了IDisposable介面的Transient服務例項最終得以釋放。

上述的釋放策略可以通過如下的演示例項來印證。我們在如下的程式碼片段中建立了一個ServiceCollection物件,並針對不同的生命週期模式添加了針對IFoo、IBar和IBaz的服務註冊。在利用ServiceCollection創建出作為根容器的IServiceProvider之後,我們呼叫它的CreateScope方法創建出對應的服務範圍。接下來我們利用建立對的服務範圍得到代表子容器的IServiceProvider物件,並用後者提供了三個註冊服務對應的例項。

class Program
{
    static void Main()
    {
        using (var root = new ServiceCollection()
            .AddTransient<IFoo, Foo>()
            .AddScoped<IBar, Bar>()
            .AddSingleton<IBaz, Baz>()
            .BuildServiceProvider())
        {
            using (var scope = root.CreateScope())
            {
                var provider = scope.ServiceProvider;
                provider.GetService<IFoo>();
                provider.GetService<IBar>();
                provider.GetService<IBaz>();
                Console.WriteLine("Child container is disposed.");
            }
            Console.WriteLine("Root container is disposed.");
        }
    }
}

由於代表根容器的IServiceProvider物件和服務範圍的建立都是在using塊中進行的,所有針對它們的Dispose方法都會在using塊結束的地方被呼叫,為了確定方法被呼叫的時機,我們特意在控制檯上列印了相應的文字。該程式執行之後會在控制檯上輸出如圖2所示的結果,我們可以看到當作為子容器的IServiceProvider物件被釋放的時候,由它提供的兩個生命週期模式分別為Transient和Scoped的兩個服務例項(Foo和Bar)被正常釋放了。至於生命週期模式為Singleton的服務例項Baz,它的Dispose方法會延遲到作為根容器IServiceProvider物件被釋放的時候。

4-2
圖2 服務例項的釋放

三、服務範圍的檢驗

Singleton和Scoped這兩種不同生命週期是通過將提供的服務例項分別存放到作為根容器的IServiceProvider物件和當前IServiceProvider物件來實現,這意味著作為根容器的IServiceProvider物件提供的Scoped服務例項也是不能被釋放的。如果某個Singleton服務以來另一個Scoped服務,那麼Scoped服務例項將被一個Singleton服務例項所引用,意味著Scoped服務例項也成了一個不會被釋放的服務例項。

在ASP.NET Core應用中,當我們將某個服務註冊的生命週期設定為Scoped的真正意圖是希望DI容器根據請求上下文來建立和釋放服務例項,但是一旦出現上述的情況下,意味著Scoped服務例項將變成一個Singleton服務例項,這樣的Scoped服務例項直到應用關閉的哪一個才會得到釋放。如果某個Scoped服務例項引用的資源(比如資料庫連線)需要被及時釋放,這可能會對應用造成滅頂之災。為了避免這種情況下,我們在利用IServiceProvider提供服務過程開啟針對服務範圍的驗證。

如果希望IServiceProvider在提供服務的過程中對服務範圍作有效性檢驗,我們只需要在呼叫ServiceCollection的BuildServiceProvider方法的時候將一個布林型別的True值作為引數即可。在如下所示的演示程式中,我們定義了兩個服務介面(IFoo和IBar)和對應的實現型別(Foo和Bar),其中Foo依賴IBar。我們將IFoo和IBar分別註冊為Singleton和Scoped服務,當我們在呼叫BuildServiceProvider方法建立代表DI容器的IServiceProvider物件的時候將引數設定為True以開啟針對服務範圍的檢驗。我們最後分別利用代表根容器和子容器的IServiceProvider來分別提供這兩種型別的服務例項。

class Program
{
    static void Main()
    {
        var root = new ServiceCollection()
            .AddSingleton<IFoo, Foo>()
            .AddScoped<IBar, Bar>()
            .BuildServiceProvider(true);    
        var child = root.CreateScope().ServiceProvider;

        void ResolveService<T>(IServiceProvider provider)
        {
            var isRootContainer = root == provider ? "Yes" : "No";
            try
            {
                provider.GetService<T>();
                Console.WriteLine( $"Status: Success; Service Type: {typeof(T).Name}; Root: {isRootContainer}");
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Status: Fail; Service Type: {typeof(T).Name}; Root: {isRootContainer}");
                  Console.WriteLine($"Error: {ex.Message}");
            }
        }

        ResolveService<IFoo>(root);
        ResolveService<IBar>(root);
        ResolveService<IFoo>(child);
        ResolveService<IBar>(child);
    }
}

public interface IFoo {}
public interface IBar {}
public class Foo : IFoo
{
    public IBar Bar { get; }
    public Foo(IBar bar) => Bar = bar;
}
public class Bar : IBar {}
上面這個演示例項啟動之後將在控制檯上輸出如圖3所示的輸出結果。從輸出結果可以看出針對四個服務解析,只有一次(使用代表子容器的IServiceProvider提供IBar服務例項)是成功的。這個例項充分說明了一旦開啟了針對服務範圍的驗證,IServiceProvider物件不可能提供以單例形式存在的Scoped服務。

4-3
圖3 IServiceProvider針對服務範圍的檢驗