3.1 控制反转
整个ASP.NET Core框架建立在一个底层的依赖注入框架之上,它使用依赖注入容器来提供所需的服务对象。
要了解这个依赖注入容器以及它的服务提供机制,我们得先知道什么是“依赖注入(DI:Dependence Injection)”。一旦我们提到依赖注入,又不得不说说“控制反转(IoC:Inverse of Control)”。
1、IOC概念
IoC的全名Inverse of Control,翻译成中文就是“控制反转”或者“控制倒置”。它体现的意思是控制权的转移,即控制权原来在A手中,现在需要B来接管。
那么对于软件设计来说,IoC所谓的控制权转移具有怎样的体现呢?要回答这个问题,就需要先了解IoC的C(Control)究竟指的是怎样一种控制。对于我们所在的任何一项任务,不论其大小,基本上都可以分解成相应的步骤,所以任何一项任务的实施都有其固有的流程,而IoC涉及的控制可以理解为“针对流程的控制”。
我们设计的类库(MvcLib)仅仅通过API的形式提供各种单一功能的实现,作为类库消费者的应用程序(App)则需要自行编排整个工作流程。
如果从代码重用的角度来讲,这里被重用的仅限于实现某个环节单一功能的代码,编排整个工作流程的代码并没有得到重用。
2、真实需求
但是在真实开发场景下,我们需要的不仅仅是一个能够提供单一API的类库,而是能够直接在上面构建应用的框架。
类库(Library)和框架(Framework)的不同之处在于:前者往往只是提供实现某种单一功能的API,而后者则针对一个目标任务对这些单一功能进行编排形成一个完整的流程,并利用一个引擎驱动这个流程自动执行。
现在我们将MvcLib从类库改造成一个框架,姑且将其称为MvcFrame。如下图所示,MvcFrame的核心是一个被称为MvcEngine的执行引擎,它驱动一个编排好的工作流对HTTP请求进行一致性处理。如果我们利用MvcFrame构建一个具体的MVC应用,除了根据我们的业务需求定义相应的Controller和View之外,我们只需要初始化这个引擎并直接启动它即可。如果你曾经开发过ASP.NET MVC应用,你会发现ASP.NET MVC就是这么一个框架。
总的来说,IoC是我们设计框架所采用的一种基本思想,所谓的控制反转就是将应用对流程的控制转移到框架中。【即采用IOC 可以实现流程控制 从应用程序向框架的转移】
拿前面这个例子来说,在传统面向类库编程的时代,针对HTTP请求处理的流程牢牢控制在应用程序手中。在引入框架之后,请求处理的控制权转移到了框架手中。
3、框架Call应用
以熟悉的ASP.NET MVC应用开发来说,我们只需要按照约定的规则(比如约定的目录结构和文件与类型命名方式等)定义相应的Controller类型和View文件就可以了。当ASP.NET MVC框架在处理请求的过程中,它会根据路由解析生成参数得到目标Controller的类型,然后自动创建Controller对象并执行它。如果目标Action方法需要呈现一个View,框架会根据预定义的目录约定找到对应的View文件(.cshtml文件),并对它实施动态编译生成对应的类型。当目标View对象创建出来之后,它执行之后生成的HTML会作为响应回复给客户端。可以看出,整个请求流程处处体现了“框架Call应用”的好莱坞法则。
一句话:我们创建应用程序,只需按照框架制定的规则注册这些组件即可,因为框架会在适当的时机自动加载并执行注册的组件。
IoC几乎是所有框架均具有的一个固有属性,从这个意义上讲,“IoC框架”的说法其实是错误的,世界上并没有什么IoC框架,或者说所有的框架都是IoC框架。
4、流程定制
IoC将对流程的控制从应用程序转移到框架之中,框架利用一个引擎驱动整个流程的自动化执行。应用程序无需关心工作流程的细节,它只需要启动这个引擎即可。这个引擎一旦被启动,框架就会完全按照预先编排好的流程进行工作,如果应用程序希望整个流程按照自己希望的方式被执行,需要在启动之前对流程进行定制。一般来说,框架会以相应的形式提供一系列的扩展点,应用程序通过注册扩展的方式实现对流程某个环节的定制。在引擎被启动之前,应用程序将所需的扩展注册到框架之中。一旦引擎被正常启动,这些注册的扩展会自动参与到整个流程的执行过程中。
3.2 IOC模式
设计模式?
一般来讲,设计模式提供了一种解决某种具体问题的方案,但是IoC既没有一个针对性的问题领域,其自身也没有提供一种可操作性的解决方案,所以我们更加倾向于将IoC视为一种设计原则。很多我们熟悉的设计模式背后都采用了IoC原则,接下来我们就来介绍几种典型的“设计模式”。
1、模板方法
模板方法模式与IoC的意图可以说不谋而合,该模式主张将一个可复用的工作流程或者由多个步骤组成的算法定义成模板方法,组成这个流程或者算法的单一步骤则实现在相应的虚方法之中,模板方法根据预先编排的流程去调用这些虚方法。这些方法均定义在一个类中,我们可以通过派生该类并重写相应的虚方法的方式达到对流程定制【扩展】的目的。
示例:mvc中 http处理
将整个请求处理流程实现在一个MvcEngine类中。如下面的代码片段所示,我们将请求的监听与接收、目标Controller的激活与执行以及View的呈现分别定义在5个受保护的虚方法中,模板方法StartAsync根据预定义的请求处理流程先后调用这5个方法。
public class MvcEngine { public async Task StartAsync(Uri address) { await ListenAsync(address); while (true) { var request = await ReceiveAsync(); var controller = await CreateControllerAsync(request); var view = await ExecuteControllerAsync(controller); await RenderViewAsync(view); } } protected virtual Task ListenAsync(Uri address); protected virtual Task<Request> ReceiveAsync(); protected virtual Task<Controller> CreateControllerAsync(Request request); protected virtual Task<View> ExecuteControllerAsync(Controller controller); protected virtual Task RenderViewAsync(View view); }
对于具体的应用程序来说,如果定义在MvcEngine中针对请求的处理方式完全符合要求,它只需要创建一个MvcEngine对象,然后指定一个监听地址调用模板方法StartAsync开启这个MVC引擎即可。
如果该MVC引擎处理请求的某个环节不能满足要求,我们可以创建MvcEngine的派生类,并重写实现该环节的相应虚方法即可。
比如说定义在某个应用程序中的Controller都是无状态的,它希望采用单例(Singleton)的方式重用已经激活的Controller对象以提高性能,那么它就可以按照如下的方式创建一个自定义的FoobarMvcEngine并按照自己的方式重写CreateControllerAsync方法即可。
public class FoobarMvcEngine : MvcEngine { protected override Task<View> CreateControllerAsync (Request request) { <<省略实现>> } }
2、工厂方法
涉及的对象:抽象工厂和抽象产品类,具体工厂和具体产品类。
把具体产品的创建推迟到子类中,就可以允许系统不修改工厂类逻辑的情况下来添加新产品。
好处:每个具体工厂类只完成单个实例的创建,所以它具有很好的可扩展性。
不足:在现实生活中,一个工厂只创建单个产品这样的例子很少,因为现在的工厂都多元化了,一个工厂创建一系列的产品。
3、抽象工厂
抽象工厂模式:提供一个创建产品的接口来负责创建相关或依赖的对象,而不具体明确指定具体类
好处:工厂接口中提供了一系列产品的操作集合
不足:抽象工厂接口中已经确定了可以被创建的产品集合,如果需要添加新产品,此时就必须去修改抽象工厂的接口,这样就涉及到抽象工厂类的以及所有子类的改变,这样也就违背了“开发——封闭”原则。
3.3 依赖注入
我们可以采用若干设计模式以不同的方式实现IoC,比如我们在前面介绍的模板方法、工厂方法和抽象工厂,接下来我们介绍一种更有价值的IoC模式:依赖注入(DI:Dependency Injection)。
1、由容器提供对象
依赖注入是一种“对象提供型”的设计模式,在这里我们将提供的对象统称为“服务”、“服务对象”或者“服务实例”。在一个采用依赖注入的应用中,我们定义某个类型的时候,只需要直接将它依赖的服务采用相应的方式注入进来就可以了。
按照“好莱坞法则”,应用只需要定义并注册好所需的服务,服务实例的提供则完全交给框架来完成,框架则会利用一个独立的“容器(Container)”来提供所需的每一个服务实例。
我们将这个被框架用来提供服务的容器称为“依赖注入容器”。依赖注入容器之所以能够按照我们希望的方式来提供所需的服务是 因为该容器是根据服务注册信息来创建的,服务注册了包含提供所需服务实例的所有信息。
服务消费者只需要告诉容器所需服务的类型(一般是一个服务接口或者抽象服务类),就能得到与之匹配的服务实例【取决于 服务注册】。所以我们可以通过修改服务注册的方式来实现对框架的定制。
2、三种依赖注入方式
从面向对象编程的角度来讲,类型中的字段或者属性是依赖的一种主要体现形式。如果类型A中具有一个B类型的字段或者属性,那么A就对B产生了依赖,所以我们可以将依赖注入简单地理解为一种针对依赖字段或者属性的自动化初始化方式。我们可以通过三种主要的方式达到这个目的,这就是接下来着重介绍的三种依赖注入方式。
- 构造器注入
构造器注入就是在构造函数中借助参数将依赖的对象注入到由它创建的对象之中。如下面的代码片段所示,Foo针对Bar的依赖体现在只读属性Bar上,针对该属性的初始化实现在构造函数中,具体的属性值由构造函数传入的参数提供。
public class Foo { public IBar Bar{get;} public Foo(IBar bar) =>Bar = bar; }
- 属性注入
如果依赖直接体现为类的某个属性,并且该属性不是只读的,我们可以让依赖注入容器在对象创建之后自动对其进行赋值进而达到依赖注入的目的。一般来说,我们在定义这种类型的时候,需要显式将这样的属性标识为需要自动注入的依赖属性以区别于其他普通的属性。
public class Foo { public IBar Bar{get; set;} [Injection] public IBaz Baz {get; set;} }
我们通过标注InjectionAttribute特性的方式将属性Baz设置为自动注入的依赖属性。对于由依赖注入容器提供的Foo对象,它的Baz属性将会自动被初始化
- 方法注入
体现依赖关系的字段或者属性可以通过方法的形式初始化。如下面的代码片段所示,Foo针对Bar的依赖体现在只读属性上,针对该属性的初始化实现在Initialize方法中,具体的属性值由该方法的传入的参数提供。
我们同样通过标注特性(InjectionAttribute)的方式将该方法标识为注入方法。依赖注入容器在调用构造函数创建一个Foo对象之后,它会自动调用这个Initialize方法对只读属性Bar进行赋值。
public class Foo { public IBar Bar{get;} [Injection] public Initialize(IBar bar)=> Bar = bar; }
综上:构造器注入是最为理想的形式,我个人不建议使用属性注入和方法注入(基于约定的方法注入除外)。
3.4 一个mini版的依赖注入框架
依赖注入容器的设计原理和具体实现:由于作为依赖注入容器的Cat对象总是利用预先添加的服务注册来提供对应的服务实例,所以服务注册至关重要。
示例:名为Cat的依赖注入框架。
地址:https://github.com/jiangjinnan/InsideAspNetCore3/tree/master/01
Cat对象 不仅可以作为服务实例的提供者,还需要维护 服务实例的生命周期。Cat提供了三种生命周期模式:其中
- Transient代表容器针对每次服务请求都会创建一个新的服务实例,
- Self则是将提供服务实例保存在当前容器中,它代表针对某个容器范围内的单例模式,
- Root则是将每个容器提供的服务实例统一存放到根容器中,所以该模式能够在多个“同根”容器范围内确保提供的服务是单例的。
4.1 利用容器提供服务
毫不夸张地说,整个ASP.NET Core框架是建立在依赖注入框架之上的。ASP.NET Core应用在启动时构建管道以及利用该管道处理每个请求过程中使用到的服务对象均来源于依赖注入容器。该依赖注入容器不仅为ASP.NET Core框架自身提供必要的服务,同时也是应用程序的服务提供者,依赖注入已经成为了ASP.NET Core应用的基本编程模式。
1、服务的注册与消费
在设计Cat的时候,既将它作为提供服务实例的依赖注入容器,也将它作为存放服务注册的集合,但是.NET Core依赖注入框架则将这两者分离开来。我们添加的服务注册被保存到通过IServiceCollection接口表示的集合之中,由这个集合创建的依赖注入容器体现为一个IServiceProvider对象。
依赖注入框架利用如下这个枚举ServiceLifetime来表示Singleton、Scoped和Transient三种生命周期模式,
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>,它将利用返回根据所有服务注册提供的服务实例【返回一个列表】。
2、生命周期
- 由于Singleton服务实例保存在作为根容器的IServiceProvider对象上,所以它能够在多个同根IServiceProvider对象之间提供真正的单例保证。
- Scoped服务实例被保存在当前IServiceProvider对象上,所以它只能在当前范围内保证提供的实例是单例的。
- 没有实现IDisposable接口的Transient服务则采用“即用即建,用后即弃”的策略。
4.2 服务注册
1、ServiceDescriptor
ServiceDescriptor是对某个服务注册项的描述,作为依赖注入容器的IServiceProvider对象正是利用该对象提供的描述信息才得以提供我们需要的服务实例。服务描述总是注册到通过ServiceType属性表示的服务类型上,ServiceDescriptor的Lifetime表示采用的生命周期模式。
2、IServiceCollection
依赖注入框架将服务注册存储在一个通过IServiceCollection接口表示的集合之中。
一个IServiceCollection对象本质上就是一个元素类型为ServiceDescriptor的列表。在默认情况下我们使用的是实现该接口的ServiceCollection类型。
public interface IServiceCollection : IList<ServiceDescriptor> {} public class ServiceCollection : IServiceCollection {}
针对服务的注册本质上就是创建相应的ServiceDescriptor对象并将其添加到指定IServiceCollection对象中的过程。
4.3 服务消费
1、IServiceProvider
包含服务注册信息的IServiceCollection集合最终被用来创建作为依赖注入容器的IServiceProvider对象。
当需要消费某个服务实例的时候,我们只需要指定服务类型调用IServiceProvider的GetService方法即可,IServiceProvider对象就会根据对应的服务注册提供所需的服务实例。
public interface IServiceProvider { object GetService(Type serviceType); }
2、服务实例的创建
3、生命周期
服务范围:
对于依赖注入框架采用的三种生命周期模式(Singleton、Scoped和Transient)来说,Singleton和Transient都具有明确的语义,但是Scoped代表一种怎样的生命周期模式,很多初学者往往搞不清楚。
这里所谓的Scope指的是由IServiceScope接口表示的“服务范围”,该范围由IServiceScopeFactory接口表示的“服务范围工厂”来创建。
3种生命周期模式:
只有在充分了解IServiceScope对象的创建过程以及它与IServiceProvider对象之间的关系之后,我们才会对三种生命周期管理模式(Singleton、Scoped和Transient)具有深刻的认识。就服务实例的提供方式来说,它们之间具有如下的差异:
- Singleton:IServiceProvider对象创建的服务实例保存在作为根容器的IServiceProvider对象上,所以多个同根的IServiceProvider对象提供的针对同一类型的服务实例都是同一个对象。
- Scoped:IServiceProvider对象创建的服务实例由自己保存,所以同一个IServiceProvider对象提供的针对同一类型的服务实例均是同一个对象。
- Transient:针对每一次服务提供请求,IServiceProvider对象总是创建一个新的服务实例。
释放:
IServiceProvider除了为我们提供所需的服务实例之外,对于由它提供的服务实例,它还肩负起回收释放的责任。这里所说的回收释放与.NET Core自身的垃圾回收机制无关,仅仅针对于自身类型实现了IDisposable或者IAsyncDisposable接口的服务实例(下面简称为Disposable服务实例),针对服务实例的释放体现为调用它们的Dispose或者DisposeAsync方法。IServiceProvider对象针对服务实例采用的回收释放策略取决于采用的生命周期模式,具体策略主要体现为如下两点:
- Singleton:提供Disposable服务实例保存在作为根容器的IServiceProvider对象上,只有在这个IServiceProvider对象被释放的时候这些Disposable服务实例才能被释放。
- Scoped和Transient:当前IServiceProvider对象会保存由它提供的Disposable服务实例,当自己被释放的时候,这些Disposable服务实例就会被释放。
综上所述,每个作为依赖注入容器的IServiceProvider对象都具有如下图所示的两个列表来存放服务实例,我们将它们分别命名为“Realized Services”和“Disposable Services”,对于一个作为非根容器的IServiceProvider对象来说,由它提供的Scoped服务保存在自身的Realized Services列表中,Singleton服务实例则会保存在根容器的Realized Services列表中。如果服务实现类型实现了IDisposable或者IAsyncDisposable接口,Scoped和Transient服务实例会被保存到自身的Disposable Services列表中,而Singleton服务实例则会保存到根容器的Disposable Services列表中。
ASP.NET Core应用:
依赖注入框架所谓的服务范围在ASP.NET Core应用中具有明确的边界,指的是针对每个HTTP请求的上下文,也就是服务范围的生命周期与每个请求上下文绑定在一起。如下图所示,ASP.NET Core应用中用于提供服务实例的IServiceProvider对象分为两种类型,一种是作为根容器并与应用具有相同生命周期的IServiceProvider对象,另一个类则是根据请求及时创建和释放的IServiceProvider对象,我们一般将它们分别称为ApplicationServices和RequestServices。
在ASP.NET Core应用初始化过程(即请求管道构建过程)中使用的服务实例都是由ApplicationServices提供的。在具体处理每个请求时,ASP.NET Core框架会利用注册的一个中间件来针对当前请求创建一个代表服务范围的IServiceScope对象,该服务范围提供的RequestServices用来提供当前请求处理过程中所需的服务实例。一旦服务请求处理完成,IServiceScoped对象代表的服务范围被终结,在当前请求处理过程中的Scoped服务会变成垃圾对象并最终被GC回收。对于实现了IDisposable或者IAsyncDisposable接口的Scoped或者Transient服务实例来说,在变成垃圾对象之前,它们的Dispose或者DisposeAsync方法会被调用。