[ASP.NET Core 3框架揭秘] 依賴注入[5]: 利用容器提供服務

  • 2019 年 11 月 11 日
  • 筆記

毫不誇張地說,整個ASP.NET Core框架是建立在依賴注入框架之上的。ASP.NET Core應用在啟動時構建管道以及利用該管道處理每個請求過程中使用到的服務對象均來源於依賴注入容器。該依賴注入容器不僅為ASP.NET Core框架自身提供必要的服務,同時也是應用程式的服務提供者,依賴注入已經成為了ASP.NET Core應用的基本編程模式。

一、服務的註冊與消費

為了讓讀者朋友們能夠更加容易地認識.NET Core提供的依賴注入框架,我在“《一個迷你版DI框架》”中特意創建了一個名為Cat的迷你版依賴注入框架。不論是編程模式還是實現原理,Cat與我們即將介紹的依賴注入框架都非常相似。這個依賴注入框架主要涉及兩個NuGet包,我們在編程過程中頻繁使用的一些介面和基礎數據類型都定義在NuGet包“Microsoft.Extensions.DependencyInjection.Abstractions”中,而依賴注入的具體實現則由“Microsoft.Extensions.DependencyInjection”這個NuGet包來承載。

我在設計Cat的時候,既將它作為提供服務實例的依賴注入容器,也將它作為存放服務註冊的集合,但是.NET Core依賴注入框架則將這兩者分離開來。我們添加的服務註冊被保存到通過IServiceCollection介面表示的集合之中,由這個集合創建的依賴注入容器體現為一個IServiceProvider對象。

作為依賴注入容器的IServiceProvider對象不僅具有類似於Cat的層次結構,兩者對提供的服務實例也採用一致的生命周期管理方式。依賴注入框架利用如下這個枚舉ServiceLifetime來表示Singleton、Scoped和Transient三種生命周期模式,我在Cat中則將其命名為Root、Self和Transient,前者命名關注於現象,後者則關注於內部實現。

public enum ServiceLifetime  {      Singleton,      Scoped,      Transient  }

應用程式初始化過程中添加的服務註冊是依賴注入容器用來提供所需服務實例的依據。由於IServiceProvider對象總是利用指定的服務類型來提供對應服務實例,所以服務總是基於類型進行註冊。我們傾向於利用介面來對服務進行抽象,所以這裡的服務類型一般為介面,但是依賴注入框架對服務註冊的類型並沒有任何限制。具體的服務註冊主要體現為如下三種形式,除了直接提供一個服務實例的註冊形式之外(這種形式默認採用Singleton模式),我們在註冊服務的時候必須指定一個具體的生命周期模式。

  • 指定具體的服務實現類型。
  • 提供一個現成的服務實例。
  • 指定一個創建服務實例的工廠。

我們的演示實例時一個普通的控制台應用。由於“Microsoft.Extensions.DependencyInjection”這個NuGet包承載了整個依賴注入框架的實現,所以我們應該添加該NuGet包的依賴。由於是ASP.NET Core框架的基礎NuGet包之一,所以我們可以通過修改項目文件並按照如下的方式添加針對“Microsoft.AspNetCore.App”的框架引用(FrameworkReference)來引入該NuGet包。對於後續部分中採用 “Microsoft.NET.Sdk”作為SDK的演示實例,如果未作說明,在默認採用這種方式添加所需NuGet包的依賴。

<Project Sdk="Microsoft.NET.Sdk">    <PropertyGroup>      <OutputType>Exe</OutputType>      <TargetFramework>netcoreapp3.0</TargetFramework>    </PropertyGroup>    <ItemGroup>      <FrameworkReference Include="Microsoft.AspNetCore.App" />    </ItemGroup>  </Project>

在添加了針對“Microsoft.Extensions.DependencyInjection”這個NuGet包的依賴之後,我們定義了如下這些介面和實現類型來表示相應的服務。如下面的程式碼片段所示,Foo、Bar和Baz分別實現了對應的介面IFoo、IBar和IBaz。為了反映DI框架對服務實例生命周期的控制,我們讓它們派生於同一個基類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創建出代表依賴注入容器的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這樣普通的服務實例之外,IServiceProvider對象同樣也能提供泛型服務實例。如下面的程式碼片段所示,在為創建的ServiceCollection對象添加了針對IFoo和IBar介面的服務註冊之後,我們調用AddTransient方法註冊了針對泛型定義IFoobar<,>的服務註冊(實現的類型為Foobar<,>)。當我們利用ServiceCollection創建出代表依賴注入容器的IServiceProvider對象並由它提供一個類型為IFoobar<IFoo, IBar>的服務實例的時候,它會創建並返回一個Foobar<Foo, Bar>對象。

public class Program  {      public static void Main()      {          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<TService>,它將利用返回根據所有服務註冊提供的服務實例。

如下面的程式碼片段所示,我們為創建的ServiceCollection對象添加了三個針對Base類型的服務註冊,對應的實現類型分別為Foo、Bar和Baz。我們最後將Base作為泛型參數調用了GetServices<Base>方法,該方法會返回包含三個Base對象的集合,集合元素的類型分別為Foo、Bar和Baz。

public class Program  {      public static void Main()      {          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對象。這一特性意味著我們可以將代表依賴注入容器的IServiceProvider作為服務進行注入,這一特性體現在如下所示的調試斷言中。但是在上一章已經提到過,一旦我們在應用中利用注入的IServiceProvider來獲取其他依賴的服務實例,意味著我們在使用“Service Locator”模式。這是一種“反模式(Anti-Pattern)”,當我們的應用程式出現了這樣的程式碼時,最好多想想是否真的需要這麼做。

var provider = new ServiceCollection().BuildServiceProvider();  Debug.Assert(provider.GetService<IServiceProvider>() == provider);

二、生命周期

代表依賴注入容器的IServiceProvider對象之間的層次結構造就了三種不同的生命周期模式。由於Singleton服務實例保存在作為根容器的IServiceProvider對象上,所以它能夠在多個同根IServiceProvider對象之間提供真正的單例保證。Scoped服務實例被保存在當前IServiceProvider對象上,所以它只能在當前範圍內保證提供的實例是單例的。沒有實現IDisposable介面的Transient服務則採用“即用即建,用後即棄”的策略。

接下來我們通過對前面演示的實例略作修改來演示三種不同生命周期模式的差異。在如下所示的程式碼片段中我們創建了一個ServiceCollection對象並針對介面IFoo、IBar和IBaz註冊了對應的服務,它們採用的生命周期模式分別為Transient、Scoped和Singleton。在利用ServiceCollection創建出代表依賴注入容器的IServiceProvider對象之後,我們調用其CreateScope方法創建了兩個代表“服務範圍”的IServiceScope對象,該對象的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;            GetServices<IFoo>(provider1);          GetServices<IBar>(provider1);          GetServices<IBaz>(provider1);          Console.WriteLine();          GetServices<IFoo>(provider2);          GetServices<IBar>(provider2);          GetServices<IBaz>(provider2);            static void GetServices<T>(IServiceProvider provider)          {              provider.GetService<T>();              provider.GetService<T>();          }      }  }

上面的程式運行之後會在控制台上輸出如下圖所示的結果。由於服務IFoo被註冊為Transient服務,所以IServiceProvider針對該介面類型的四次調用都會創建一個全新的Foo對象。IBar服務的生命周期模式為Scoped,如果我們利用同一個IServiceProvider對象來提供對應的服務實例,它只會創建一個Bar對象,所以整個程式執行過程中會創建兩個Bar對象。IBaz服務採用Singleton生命周期,所以具有同根的兩個IServiceProvider對象提供的總是同一個Baz對象,後者只會被創建一次。

4-1

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

  • Transient和Scoped:所有實現了IDisposable介面的服務實例會被當前IServiceProvider對象保存起來,當IServiceProvider對象的Dispose方法被調用的時候,這些服務實例的Dispose方法會隨之被調用。
  • Singleton:由於服務實例保存在作為根容器的IServiceProvider對象上,只有當後者的Dispose方法被調用的時候,這些服務實例的Dispose方法才會隨之被調用。

對於一個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塊結束的地方被調用。為了確定方法被調用的時機,我們特意在控制台上列印了相應的文字。該程式運行之後會在控制台上輸出如下圖所示的結果,我們可以看到當作為子容器的IServiceProvider對象被釋放的時候,由它提供的兩個生命周期模式分別為Transient和Scoped的兩個服務實例(Foo和Bar)被正常釋放了。至於生命周期模式為Singleton的服務實例Baz,它的Dispose方法會延遲到作為根容器的IServiceProvider對象被釋放的時候。

4-2

三、針對服務註冊的驗證

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

在ASP.NET Core應用中,我們將某個服務註冊的生命周期設置為Scoped的真正意圖是希望依賴注入容器根據每個接收的請求來創建和釋放服務實例,但是一旦出現上述這種情況,意味著Scoped服務實例將變成一個Singleton服務實例,這樣的Scoped服務實例會直到應用關閉的那一刻才會被釋放,這無疑不是我們希望得到的結果。如果某個Scoped服務實例引用的資源(比如資料庫連接)需要被及時釋放,這可能會對應用造成滅頂之災。為了避免這種情況的出現,我們在利用IServiceProvider提供服務過程中可以開啟針對服務範圍的驗證。

如果希望IServiceProvider在提供服務的過程中對服務範圍作有效性檢驗,我們只需要在調用IServiceCollection的BuildServiceProvider擴展方法的時候將一個布爾類型的True值作為參數即可。在如下所示的演示程式中,我們定義了兩個服務介面(IFoo和IBar)和對應的實現類型(Foo和Bar),其中Foo依賴IBar。我們將IFoo和IBar分別註冊為Singleton和Scoped服務,當調用BuildServiceProvider方法創建代表依賴注入容器的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 {}

上面這個演示實例啟動之後將在控制台上輸出如下圖所示的結果。從輸出結果可以看出針對四個服務解析,只有一次(使用代表子容器的IServiceProvider提供IBar服務實例)是成功的。這個實例充分說明了一旦開啟了針對服務範圍的驗證,IServiceProvider對象不可能提供以單例形式存在的Scoped服務。

4-3

針對服務範圍的檢驗體現在配置選項類型ServiceProviderOptions的ValidateScopes屬性上。如下面的程式碼片段所示,ServiceProviderOptions還具有另一個名為ValidateOnBuild的屬性,如果該屬性設置為True,意味著IServiceProvider對象被構建的時候會檢驗提供的每個ServiceDescriptor的有效性,即確保它們最終都具有提供對應服務實例的能力。默認情況下ValidateOnBuild的屬性值為False,意味著只有利用IServiceProvider對象來提供我們所需的服務實例的時候,相應的異常採用才會拋出來。

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

我們照例來作一個在構建IServiceProvider對象時檢驗服務註冊有效性的實例。我們定義了如下一個介面IFoobar和對應的實現類型Foobar,由於我們希望採用單例的形式來使用Foobar對象,所以我們為它定義了唯一的私有構造函數。

public interface IFoobar {}  public class Foobar : IFoobar  {      private Foobar() {}      public static readonly Foobar Instance = new Foobar();  }

在如下的演示實例中,我們定義了一個內嵌的BuildServiceProvider方法來完成針對IFoobar/Foobar的服務註冊和最終對IServiceProvider對象的創建。當我們在調用擴展方法BuildServiceProvider創建對應IServiceProvider對象時指定了一個ServiceProviderOptions對象,而它的ValidateOnBuild屬性來源於內嵌方法的同名參數。

class Program  {      static void Main()      {         BuildServiceProvider(false);          BuildServiceProvider(true);            static void BuildServiceProvider(bool validateOnBuild)          {              try              {                  var options = new ServiceProviderOptions                  {                      ValidateOnBuild = validateOnBuild                  };                  new ServiceCollection()                      .AddSingleton<IFoobar, Foobar>()                      .BuildServiceProvider(options);                  Console.WriteLine(                      $"Status: Success; ValidateOnBuild: {validateOnBuild}");              }              catch (Exception ex)              {                  Console.WriteLine( $"Status: Fail; ValidateOnBuild: {validateOnBuild}");                  Console.WriteLine($"Error: {ex.Message}");              }          }      }  }

由於Foobar只具有一個唯一的私有構造函數,所以內嵌方法BuildServiceProvider提供的服務註冊並不能提供我們所需的服務實例,所以這個服務註冊是無效的。由於默認情況下構建IServiceProvider對象的時候並不會對服務註冊作有效性檢驗,所以此時無效的服務註冊並不會及時被探測到。一旦我們將ValidateOnBuild選項設置為True,IServiceProvider對象在被構建的時候就會拋出異常,如下圖所示的輸出結果體現了這一點。

4-4

[ASP.NET Core 3框架揭秘] 依賴注入[1]:控制反轉
[ASP.NET Core 3框架揭秘] 依賴注入[2]:IoC模式
[ASP.NET Core 3框架揭秘] 依賴注入[3]:依賴注入模式
[ASP.NET Core 3框架揭秘] 依賴注入[4]:一個迷你版DI框架
[ASP.NET Core 3框架揭秘] 依賴注入[5]:利用容器提供服務
[ASP.NET Core 3框架揭秘] 依賴注入[6]:服務註冊
[ASP.NET Core 3框架揭秘] 依賴注入[7]:服務消費
[ASP.NET Core 3框架揭秘] 依賴注入[8]:服務實例的生命周期
[ASP.NET Core 3框架揭秘] 依賴注入[9]:實現概述
[ASP.NET Core 3框架揭秘] 依賴注入[10]:與第三方依賴注入框架的適配