3.3 依赖注入
IoC 主要体现了这样一种设计思想:通过将一组通用流程的控制权从应用转移到框架之中以实现对流程的复用,并按照好莱坞法则实现应用程序的代码与框架之间的交互。我们可以采用若干设计模式以不同的方式实现 IoC,如模板方法、工厂方法和抽象工厂,下面介绍一种更有价值的IoC模式:依赖注入(Dependency Injection,DI)。
3.3.1 由容器提供对象
与前面介绍的工厂方法和抽象工厂模式一样,依赖注入是一种“对象提供型”的设计模式,可以将提供的对象统称为“服务”“服务对象”“服务实例”。在一个采用依赖注入的应用中,我们定义某个类型时,只需要直接将它依赖的服务采用相应的方式注入进来即可。
在应用启动时,我们会对所需的服务进行全局注册。一般来说,服务大都是针对实现的接口或者继承的抽象类进行注册的,服务注册信息会在后续消费过程中帮助我们提供对应的服务实例。按照好莱坞法则,应用只需要定义并注册好所需的服务,服务实例的提供则完全交给框架来完成,框架利用一个独立的容器(Container)来提供所需的每个服务实例。
我们将这个被框架用来提供服务的容器称为依赖注入容器,也有很多人将其称为 IoC 容器,根据前面针对 IoC 的介绍,笔者认为后者不是一个合理的称谓。依赖注入容器之所以能够按照我们希望的方式来提供所需的服务是因为该容器是根据服务注册信息创建的,服务注册包含提供所需服务实例的所有信息。
例如,如果创建一个名为 Cat的依赖注入容器类型,那么可以调用 GetService<T>扩展方法从某个 Cat 对象中获取指定类型的服务对象。笔者之所以将其命名为 Cat,主要源于卡通形象“机器猫”(哆啦 A 梦)。机器猫的四次元口袋就是一个理想的依赖注入容器,大熊只需要告诉机器猫相应的需求,它就能从这个口袋中得到相应的法宝。依赖注入容器亦是如此,服务消费者只需要告诉容器所需服务的类型(一般是一个服务接口或者抽象服务类),就能得到与之匹配的服务实例。
对于 MVC 框架来说,我们在前面分别采用不同的设计模式对框架的核心类型 MvcEngine进行了“改造”,而采用依赖注入的方式,并利用上述 Cat容器按照如下方式对其重新实现,我们会发现MvcEngine变得异常简洁而清晰。
依赖注入体现了一种最直接的服务消费方式,消费者只需要告诉提供者(依赖注入容器)所需服务的类型,后者就能根据预先注册的规则提供一个匹配的服务实例。由于服务注册最终决定了依赖注入容器根据指定的服务类型会提供一个什么样的服务实例,所以我们可以通过修改服务注册的方式来实现对框架的定制。如果应用程序需要采用 SingletonControllerActivator 以单例的模式来激活目标 Controller,那么它可以在启动 MvcEngine 之前按照如下形式将SingletonControllerActivator注册到依赖注入容器中。
3.3.2 3种依赖注入方式
一项任务往往需要多个对象相互协作才能完成,或者说某个对象在完成某项任务的时候需要直接或者间接地依赖其他的对象来完成某些必要的步骤,所以运行时对象之间的依赖关系是由目标任务决定的,是“恒定不变”的,自然也无所谓“解耦”的说法。但是运行时对象通过对应的类来定义,类与类之间的耦合可以通过对依赖进行抽象的方式来降低或者解除。
从服务消费的角度来讲,如果借助一个接口对消费的服务进行抽象,那么服务消费程序针对具体服务类型的依赖可以转移到对服务接口的依赖上面,但是在运行时提供给消费者的总是一个针对某个具体服务类型的对象。不仅如此,要完成定义在服务接口的操作,这个对象可能需要其他相关对象的参与,换句话说,提供的这个依赖服务对象可能具有对其他服务对象的依赖。作为服务对象提供者的依赖注入容器,它会根据依赖链提供所有的依赖服务实例。
如图 3-5 所示,当应用框架调用 GetService<IFoo>方法向依赖注入容器索取一个实现了IFoo 接口的服务对象时,该方法会根据预先注册的类型映射关系创建一个类型为 Foo 的对象。由于 Foo对象需要 Bar对象和 Qux对象的参与才能完成目标操作,所以 Foo具有针对 Bar和Qux的直接依赖。而服务对象Bar又依赖Baz,所以Baz成了Foo的间接依赖。对于依赖注入容器最终提供的Foo对象,它所直接或者间接依赖的对象Bar、Baz和Qux都会预先被初始化并自动注入该对象之中。
图3-5 依赖注入容器对依赖的自动注入
从面向对象编程的角度来讲,类型中的字段或者属性是依赖的一种主要体现形式。如果类型A中具有一个类型B的字段或者属性,那么类型A就对类型B产生了依赖,所以可以将依赖注入简单地理解为一种针对依赖字段或者属性的自动化初始化方式,我们可以通过 3 种方式达到这个目的。下面着重介绍3种依赖注入方式。
构造器注入
构造器注入就是在构造函数中借助参数将依赖的对象注入由它创建的对象之中。如下面的代码片段所示,Foo针对Bar的依赖体现在只读属性Bar上,针对该属性的初始化是在构造函数中实现的,具体的属性值由构造函数传入的参数提供。
除此之外,构造器注入还体现在对构造函数的选择上。如下面的代码片段所示,Foo 类定义了两个构造函数,依赖注入容器在创建 Foo 对象之前需要先选择一个合适的构造函数。至于目标构造函数如何选择,不同的依赖注入容器可能有不同的策略,如可以选择参数最多或者最少的构造函数,也可以按照如下方式在目标构造函数上标注一个InjectionAttribute特性。
属性注入
如果依赖直接体现为类的某个属性,并且该属性不是只读的,就可以让依赖注入容器在对象创建之后自动对其进行赋值,进而达到依赖注入的目的。一般来说,在定义这种类型的时候,需要显式地将这样的属性标识为需要自动注入的依赖属性,从而与其他普通属性进行区分。如下面的代码片段所示,Foo类中定义了两个可读写的公共属性(Bar和Baz),通过标注InjectionAttribute特性的方式可以将属性Baz设置为自动注入的依赖属性。由依赖注入容器提供的Foo对象的Baz属性将会自动被初始化。
方法注入
体现依赖关系的字段或者属性可以通过方法的形式初始化。如下面的代码片段所示,Foo对 Bar 的依赖体现在只读属性上,针对该属性的初始化实现在 Initialize 方法中,具体的属性值由该方法传入的参数提供。同样,通过标注特性(InjectionAttribute)的方式可以将该方法标识为注入方法。依赖注入容器在调用构造函数创建一个 Foo对象之后,它会自动调用 Initialize方法对只读属性Bar进行赋值。
除了通过依赖注入容器在初始化服务过程中自动调用的实现,我们还可以利用它实现另一种更加自由的方法注入,这种注入方式在 ASP.NET Core 应用中具有广泛应用。ASP.NET Core在启动的时候会调用注册的 Startup 对象来完成中间件的注册,而定义 Startup 类型的时候不需要让它实现某个接口,所以用于注册中间件的 Configure方法没有一个固定的声明,但可以按照如下方式将任意依赖的服务实例直接注入这个方法之中。
类似的注入方式同样可以应用到中间件类型的定义上。与用来注册中间件的 Startup类型一样,ASP.NET Core 框架下的中间件类型同样不需要实现某个预定义的接口,用于处理请求的InvokeAsync方法或者Invoke方法同样可以按照如下方式注入任意的依赖服务。
上面这种方式的方法注入促成了一种“面向约定”的编程方式。由于不再需要实现某个预定义的接口或者继承某个预定义的基类,所以需要实现或者重写方法的声明也就少了对应的限制,这样就可以采用最直接的方式将依赖的服务注入方法中。对于前面介绍的这几种注入方式,构造器注入是最理想的形式,笔者不建议使用属性注入和方法注入(前面介绍的这种基于约定的方法注入除外)。
3.3.3 Service Locator模式
假设我们需要定义一个服务类型Foo,它依赖于服务Bar和Baz,后者对应的服务接口分别为IBar和IBaz。如果当前应用中具有一个依赖注入容器(假设类似于前面定义的Cat),那么我们就可以采用如下两种方式定义服务类型Foo。
从表面上看,上面提供的这两种服务类型的定义方式都可以解决针对依赖服务的耦合问题,并将针对服务实现的依赖转变成针对接口的依赖。很多人会选择第二种定义方式,因为这种定义方式不仅代码量更少,针对服务的提供也更加直接。我们直接在构造函数中“注入”了代表依赖注入容器的 Cat 对象,在任何使用到依赖服务的地方,只需要利用它来提供对应的服务实例即可。
但第二种定义方式采用的设计模式不是依赖注入,而是一种被称为 Service Locator 的设计模式。Service Locator模式同样具有一个通过服务注册创建的全局的容器来提供所需的服务实例,该容器被称为Service Locator。依赖注入容器和Service Locator实际上是同一事物在不同设计模式中的不同称谓。那么,依赖注入和Service Locator之间的差异主要体现在哪些方面?
笔者认为可以从依赖注入容器或者 Service Locator 被谁使用的角度来区分这两种设计模式的差别。在一个采用依赖注入的应用中,我们只需要采用标准的注入形式定义服务类型,并在应用启动之前完成相应的服务注册即可,框架自身的引擎在运行过程中会利用依赖注入容器来提供当前所需的服务实例。换句话说,依赖注入容器的使用者应该是框架而不是应用程序。Service Locator模式显然不是这样,而是应用程序在利用它来提供所需的服务实例,所以它的使用者是应用程序。
我们也可以从另外一个角度区分两者之间的差别。由于依赖服务是以“注入”的方式来提供的,所以采用依赖注入模式的应用可以看作将服务推送到依赖注入容器,Service Locator模式下的应用则是利用 Service Locator 拉取所需的服务,这一“推”一“拉”也准确地体现了两者之间的差异。那么既然两者之间有差异,究竟孰优孰劣?
2010年,Mark Seemann就已经将Service Locator视为一种反模式(Anti-Pattern),虽然也有人对此提出不同的意见,但笔者不推荐使用这种设计模式。笔者反对使用 Service Locator模式与前面提到的反对使用属性注入和方法注入具有类似的缘由。
本着“松耦合、高内聚”的设计原则,我们既然将一组相关的操作定义在一个能够复用的服务中,就应该尽量要求服务自身不但具有独立和自治的特性,而且要求服务之间应该具有明确的界定,服务之间的依赖关系应该是明确的而不是模糊的。不论采用属性注入或者方法注入,还是使用 Service Locator 提供当前依赖的服务,都可以为当前的服务增添一个新的依赖,即针对依赖注入容器或者Service Locator的依赖。
当前服务针对另一个服务的依赖与针对依赖注入容器或者 Service Locator 的依赖具有本质上的不同。前者是一种基于类型的依赖,不论是基于服务的接口还是实现类型,这是一种基于“契约”的依赖。这种依赖不仅是明确的,也是有保障的。但是依赖注入容器或者 Service Locator 本质上是一个黑盒,它能够提供所需服务的前提是相应的服务注册已经预先添加到容器之中,但是这种依赖不仅是模糊的也是不可靠的。
正因为如此,ASP.NET Core 框架使用的依赖注入框架只支持构造器注入,而不支持属性注入和方法注入(类似于 Startup 和中间件基于约定的方法注入除外),但是我们可能会不知不觉地按照 Service Locator 模式编写代码。从某种意义上讲,当我们在程序中使用 IServiceProvider (表示依赖注入容器)提取某个服务实例时,就意味着我们已经在使用 Service Locator 模式了,所以遇到这种情况时应该思考是否一定需要这么做。