毫不夸张地说,整个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提供了Singleton、Scoped和Transient三种生命周期模式是,我在Cat中则将其命名为Root、Self和Transient,前者命名关注于现象,而我则关注于内部实现。
代码语言:javascript复制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>来演示针对泛型服务实例的提供。
代码语言:javascript复制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提供的服务实例与预先添加的服务注册是一致的。
代码语言:javascript复制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>对象。
代码语言:javascript复制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>,它将利用返回所有服务注册提供的服务实例。
如下面的代码片段所示,我们为创建的Cat对象添加了三个针对Base类型的服务注册,对应的实现类型分别为Foo、Bar和Baz。我们最后将Base作为泛型参数调用了GetServices<Base>方法,该方法会返回包含三个Base对象的集合,集合元素的类型分别为Foo、Bar和Baz。
代码语言:javascript复制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的这一特性体现在如下所示的调试断言中。
代码语言:javascript复制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对象来提供相应的服务实例。
代码语言:javascript复制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对象,后者只会被创建一次。
图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对象,并用后者提供了三个注册服务对应的实例。
代码语言:javascript复制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对象被释放的时候。
图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来分别提供这两种类型的服务实例。
代码语言:javascript复制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服务。
图3 IServiceProvider针对服务范围的检验