4.1 利用容器提供服务
由于依赖注入对ASP.NET Core框架本身和基于该框架的应用开发都具有举足轻重的作用,所以本书的多个章节都会涉及这一主题。本章只是单纯地介绍这个独立的基础框架,不会涉及它在ASP.NET Core中的应用。本书在接下来讲述每个主题时都会采用“先简单体验,后深入剖析”的模式,所以我们先从编程的层面体验如何利用依赖注入容器来提供所需的服务实例。
4.1.1 服务的注册与消费
为了使读者更容易理解.NET Core提供的依赖注入框架,笔者在第3章创建了一个名为Cat的Mini版依赖注入框架。不论是编程模式还是实现原理,Cat与我们即将介绍的依赖注入框架都非常相似。这个依赖注入框架主要涉及两个NuGet包,我们在编程过程中频繁使用的一些接口和基础数据类型都定义在NuGet包“Microsoft.Extensions.DependencyInjection.Abstractions”中[1],而依赖注入的具体实现则由NuGet包“Microsoft.Extensions.DependencyInjection”来承载。
在设计 Cat 框架时,既可以将 Cat 对象作为提供服务实例的依赖注入容器,也可以将它作为存放服务注册的集合,但是.NET Core 依赖注入框架则将这两者分离开来。我们添加的服务注册被保存在通过 IServiceCollection 接口表示的集合之中,由这个集合创建的依赖注入容器体现为一个IServiceProvider对象。
作为依赖注入容器的 IServiceProvider对象不仅具有类似于 Cat的层次结构,两者对提供的服务实例也采用一致的生命周期管理方式。依赖注入框架利用 ServiceLifetime 来表示 Singleton、Scoped和Transient这3种生命周期模式,笔者在Cat中则将其命名为Root、Self和Transient,前者关注的是现象,后者关注的是内部实现。
应用程序初始化过程中添加的服务注册是依赖注入容器用来提供所需服务实例的依据。由于 IServiceProvider 对象总是利用指定的服务类型来提供对应的服务实例,所以服务总是基于类型进行注册。我们倾向于利用接口来对服务进行抽象,所以这里的服务类型一般为接口,但是依赖注入框架对服务注册的类型并没有任何限制。具体的服务注册主要体现为如下 3 种形式,除了直接提供一个服务实例的注册形式(这种形式默认采用 Singleton 模式),在注册服务的时候还必须指定一个具体的生命周期模式。
● 指定具体的服务实现类型。
● 提供一个现成的服务实例。
● 指定一个创建服务实例的工厂。
我们的演示实例是一个普通的控制台应用。由于 NuGet 包“Microsoft.Extensions.DependencyInjection”承载了整个依赖注入框架的实现,所以应该添加该NuGet包的依赖。由于这是ASP.NET Core框架的基础NuGet包之一,所以可以通过修改项目文件并按照如下方式添加针对“Microsoft.AspNetCore.App”的框架引用(FrameworkReference)来引入该 NuGet 包。本书后续章节中采用“Microsoft.NET.Sdk”[2]作为 SDK 的演示实例,如果没有具体说明,则默认采用这种方式添加所需NuGet包的依赖。
在添加了针对“Microsoft.Extensions.DependencyInjection”这个 NuGet包的依赖之后,我们定义了如下接口和实现类型来表示相应的服务。如下面的代码片段所示,Foo、Bar和 Baz分别实现了对应的接口 IFoo、IBar 与 IBaz。为了反映 DI 框架对服务实例生命周期的控制,我们让它们派生于同一个基类 Base。Base 实现了 IDisposable 接口,我们在其构造函数和实现的Dispose 方法中打印出相应的文字以确定对应的实例何时被创建与释放。我们还定义了一个泛型的接口IFoobar<T1,T2>和对应的实现类Foobar<T1,T2>来演示针对泛型服务实例的提供。
如下所示的代码片段创建了一个ServiceCollection对象(它是对IServiceCollection接口的默认实现),并调用相应的方法(AddTransient、AddScoped 和 AddSingleton)针对接口 IFoo、IBar 和 IBaz 注册了对应的服务,从方法命名可以看出注册的服务采用的生命周期模式分别为Transient、Scoped 和 Singleton。完成服务注册之后,我们调用 IServiceCollection 接口的BuildServiceProvider 扩展方法创建出代表依赖注入容器的 IServiceProvider 对象,并调用该对象的GetService<T>方法来提供相应的服务实例。调试断言表明IServiceProvider对象提供的服务实例与预先添加的服务注册是一致的。(S401)
除了提供类似于IFoo、IBar和IBaz的服务实例,IServiceProvider对象还能提供泛型服务实例。如下面的代码片段所示,为创建的ServiceCollection对象添加了针对 IFoo接口和 IBar接口的服务注册之后,我们调用 AddTransient 方法注册了针对泛型定义 IFoobar<,>的服务注册(实现的类型为 Foobar<,>)。当我们利用 ServiceCollection 对象创建出代表依赖注入容器的IServiceProvider 对象并由它提供一个类型为 IFoobar<IFoo,IBar>的服务实例的时候,它会创建并返回一个Foobar<Foo,Bar>对象。(S402)
进行服务注册时可以为同一个类型添加多个服务注册。虽然添加的所有服务注册均是有效的,但是 GetService<T>扩展方法总是返回一个服务实例。依赖注入框架对该方法采用了“后来居上”的策略,也就是说,依赖注入容器总是采用最近添加的服务注册来创建服务实例。如果调用 GetServices<TService>扩展方法,该方法将利用指定服务类型的所有服务注册来提供一组服务实例。
下面的代码片段为创建的ServiceCollection对象添加了3个针对Base类型的服务注册,对应的实现类型分别为Foo、Bar和Baz。我们将Base作为泛型参数调用了GetServices<Base>方法,该方法会返回包含3个Base对象的集合,集合元素的类型分别为Foo、Bar和Baz。(S403)
对于 IServiceProvider对象针对服务实例的提供还有一个细节:如果在调用 GetService方法或者GetService<T>方法时将服务类型设置为IServiceProvider接口,提供的服务实例实际上就是当前的IServiceProvider对象。这说明可以将代表依赖注入容器的IServiceProvider对象作为服务进行注入,这一特性体现在如下所示的调试断言中。第 3 章提及,一旦在应用中利用注入的IServiceProvider 来获取其他依赖的服务实例,就意味着使用了 Service Locator 模式。这是一种反模式,当应用程序中出现了这样的代码时,应认真思考是否真的需要这么做。
4.1.2 生命周期
代表依赖注入容器的 IServiceProvider对象之间的层次结构创建了 3种不同的生命周期模式。由于 Singleton 服务实例保存在作为根容器的 IServiceProvider 对象上,所以它能够在多个同根IServiceProvider对象之间提供真正的单例保证。Scoped服务实例被保存在当前 IServiceProvider对象上,所以它只能在当前范围内保证提供的实例是单例的。没有实现 IDisposable 接口的Transient服务则采用“即用即建,用后即弃”的策略。
下面通过对前面演示的实例稍做修改来演示 3 种不同生命周期模式的差异。如下所示的代码片段创建了一个 ServiceCollection对象,并针对接口 IFoo、IBar和 IBaz注册了对应的服务,它们采用的生命周期模式分别为 Transient、Scoped和 Singleton。利用 ServiceCollection对象创建出代表依赖注入容器的 IServiceProvider 对象之后,我们调用其 CreateScope 方法创建了两个代表“服务范围”的 IServiceScope 对象,该对象的 ServiceProvider 属性返回一个新的IServiceProvider对象,它实际上是当前 IServiceProvider对象的子容器。最后利用作为子容器的IServiceProvider对象来提供相应的服务实例。
运行上面的程序在控制台上输出的结果如图4-1所示。由于服务IFoo被注册为Transient服务,所以 IServiceProvider对象针对该接口类型的 4次调用都会创建一个全新的 Foo对象。IBar服务的生命周期模式为Scoped,如果利用同一个IServiceProvider对象提供对应的服务实例,它只会创建一个 Bar 对象,所以整个程序在执行过程中会创建两个 Bar 对象。IBaz 服务采用Singleton生命周期,所以具有同根的两个 IServiceProvider对象提供的总是同一个 Baz对象,后者只会被创建一次。(S404)
图4-1 IServiceProvider对象按照服务注册对应的生命周期模式提供服务实例
作为依赖注入容器的 IServiceProvider 对象不仅可以提供所需的服务实例,还可以管理这些服务实例的生命周期。如果某个服务类型实现了 IDisposable 接口,就意味着当生命周期完结的时候需要通过调用 Dispose 方法执行一些资源释放操作,这些操作同样由提供该服务实例的IServiceProvider 对象来驱动执行。依赖注入框架针对提供服务实例的释放策略取决于对应的服务注册所采用的生命周期模式,具体的策略如下。
● Transient和 Scoped:所有实现了 IDisposable接口的服务实例会被当前 IServiceProvider对象保存起来,当 IServiceProvider对象的 Dispose方法被调用的时候,这些服务实例的Dispose方法会随之被调用。
● Singleton:由于服务实例保存在作为根容器的 IServiceProvider对象上,所以只有当后者的Dispose方法被调用的时候,这些服务实例的Dispose方法才会随之被调用。
对于一个 ASP.NET Core 应用来说,它具有一个与当前应用绑定代表全局根容器的IServiceProvider对象。对于处理的每一次请求,ASP.NET Core框架都会利用这个根容器来创建基于当前请求的服务范围,并利用后者提供的 IServiceProvider 对象来提供请求处理所需的服务实例。请求处理完成之后,创建的服务范围被终结,对应的 IServiceProvider 对象也随之被释放,此时由该 IServiceProvider对象提供的 Scoped服务实例以及实现了 IDisposable接口的 Transient服务实例得以及时释放。
上述释放策略可以通过如下演示实例进行印证。如下代码片段创建了一个ServiceCollection对象,并针对不同的生命周期模式添加了针对 IFoo、IBar 和 IBaz 的服务注册。利用ServiceCollection 集合创建出作为根容器的 IServiceProvider 对象之后,可以调用它的CreateScope 方法创建出对应的服务范围。然后我们利用创建的服务范围得到代表子容器的IServiceProvider对象,并用它提供了3个注册服务对应的实例。
由于代表根容器的 IServiceProvider 对象和服务范围的创建都是在 using 块中进行的,所以所有针对它们的 Dispose方法都会在 using块结束的地方被调用。为了确定方法被调用的时机,可以在控制台上打印相应的文字。该程序运行之后在控制台上输出的结果如图 4-2 所示,可以看到,当作为子容器的 IServiceProvider 对象被释放的时候,由它提供的两个生命周期模式分别为 Transient 和 Scoped 的两个服务实例(Foo 和 Bar)被正常释放。而对于生命周期模式为Singleton的服务实例 Baz,它的 Dispose方法会延迟到作为根容器的 IServiceProvider对象被释放的时候才释放。(S405)
图4-2 服务实例的释放
4.1.3 针对服务注册的验证
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对象来提供这两种类型的服务实例。
上面的演示实例启动之后在控制台上输出的结果如图4-3所示。从输出结果可以看出,4个服务解析只有1次(使用代表子容器的IServiceProvider提供IBar服务实例)是成功的。这个实例充分说明:一旦开启了针对服务范围的验证,IServiceProvider 对象不可能提供以单例形式存在的Scoped服务。(S406)
图4-3 IServiceProvider针对服务范围的检验
针对服务范围的检验体现在配置选项类型ServiceProviderOptions的ValidateScopes属性上。如下面的代码片段所示,ServiceProviderOptions 还具有另一个名为 ValidateOnBuild 的属性,如果将该属性设置为 True,就意味着 IServiceProvider 对象被构建的时候会检验提供的每个ServiceDescriptor 的有效性,即确保它们最终都具有提供对应服务实例的能力。在默认情况下,ValidateOnBuild的属性值为False,意味着只有利用IServiceProvider对象来提供我们所需的服务实例,相应的异常采用才会被抛出来。
我们照例来做一个在构建 IServiceProvider 对象时检验服务注册有效性的实例。如下代码片段定义了一个接口 IFoobar和对应的实现类型 Foobar,若采用单例的形式来使用 Foobar对象,可以定义唯一的私有构造函数。
如下所示的演示实例定义了一个内嵌的 BuildServiceProvider 方法,从而完成针对IFoobar/Foobar 的服务注册和最终对 IServiceProvider 对象的创建。在调用 BuildServiceProvider扩展方法创建对应 IServiceProvider对象时指定了一个 ServiceProviderOptions对象,而该对象的ValidateOnBuild属性来源于内嵌方法的同名参数。
由于Foobar具有唯一的私有构造函数,而内嵌方法BuildServiceProvider提供的服务注册并不能提供我们所需的服务实例,所以这个服务注册是无效的。由于在默认情况下构建IServiceProvider 对象的时候并不会对服务注册做有效性检验,所以此时无效的服务注册并不会及时被探测到。一旦将 ValidateOnBuild选项设置为 True,IServiceProvider对象在被构建的时候就会抛出异常,图4-4所示的输出结果就体现了这一点。(S407)
图4-4 构建IServiceProvider对象针对服务注册有效性的检验