网络知识 娱乐 [ASP.NET Core 3框架揭秘] 依赖注入:依赖注入模式

[ASP.NET Core 3框架揭秘] 依赖注入:依赖注入模式

IoC主要体现了这样一种设计思想:通过将一组通用流程的控制权从应用转移到框架之中以实现对流程的复用,并按照“好莱坞法则”实现应用程序的代码与框架之间的交互。我们可以采用若干设计模式以不同的方式实现IoC,比如我们在前面介绍的模板方法、工厂方法和抽象工厂,接下来我们介绍一种更有价值的IoC模式:依赖注入(DI:Dependency Injection)。

一、由容器提供对象

和前面介绍的工厂方法和抽象工厂模式一样,依赖注入是一种“对象提供型”的设计模式,在这里我们将提供的对象统称为“服务”、“服务对象”或者“服务实例”。在一个采用依赖注入的应用中,我们定义某个类型的时候,只需要直接将它依赖的服务采用相应的方式注入进来就可以了。

在应用启动的时候,我们会对所需的服务进行全局注册。一般来说,服务大都是针对实现的接口或者继承的抽象类进行注册的,服务注册信息的帮助我们在后续消费过程中提供对应的服务实例。按照“好莱坞法则”,应用只需要定义并注册好所需的服务,服务实例的提供则完全交给框架来完成,框架则会利用一个独立的“容器(Container)”来提供所需的每一个服务实例。

我们将这个被框架用来提供服务的容器称为“依赖注入容器”,也有很多人将其称为“IoC容器”,根据前面针对IoC的介绍,我不认为后者是一个合理的称谓。依赖注入容器之所以能够按照我们希望的方式来提供所需的服务是因为该容器是根据服务注册信息来创建的,服务注册了包含提供所需服务实例的所有信息。

举个简单的例子,我们创建一个名为Cat的依赖注入容器类型,那么我们可以调用如下这个扩展方法GetService<T>从某个Cat对象中获取指定类型的服务对象。我之所以将其命名为Cat,源于我们大家都非常熟悉的一个卡通形象“机器猫(哆啦A梦)”。机器猫的那个四次元口袋就是一个理想的依赖注入容器,大熊只需要告诉哆啦A梦相应的需求,它就能从这个口袋中得到相应的法宝。依赖注入容器亦是如此,服务消费者只需要告诉容器所需服务的类型(一般是一个服务接口或者抽象服务类),就能得到与之匹配的服务实例。

public static class CatExtensions
{  
    public static T GetService<T>(this Cat cat);
}

对于我们演示的MVC框架来说,我们在前面分别采用不同的设计模式对框架的核心类型MvcEngine进行了“改造”,现在我们采用依赖注入的方式,并利用上述的这个Cat容器按照如下的方式对其进行重新实现,我们会发现MvcEngine变得异常简洁而清晰。

public class MvcEngine
{
    public Cat Cat { get; }
    public MvcEngine(Cat cat) => Cat = cat;
        
    public async Task StartAsync(Uri address)
    {
        var listener = Cat.GetService<IWebListener>();
        var activator = Cat.GetService<IControllerActivator>();
        var executor = Cat.GetService<IControllerExecutor>();
        var renderer = Cat.GetService<IViewRenderer>();

        await listener.ListenAsync(address);
        while (true)
        {
            var httpContext = await listener.ReceiveAsync();
            var controller = await activator.CreateControllerAsync(httpContext);
            try
            {
                var view = await executor.ExecuteAsync(controller, httpContext);
                await renderer.RenderAsync(view, httpContext);
            }
            finally
            {
                await activator.ReleaseAsync(controller);
            }
        }
    }        
}

依赖注入体现了一种最为直接的服务消费方式,消费者只需要告诉提供者(依赖注入容器)所需服务的类型,后者就能根据预先注册的规则提供一个匹配的服务实例。由于服务注册最终决定了依赖注入容器根据指定的服务类型会提供一个怎样的服务实例,所以我们可以通过修改服务注册的方式来实现对框架的定制。如果应用程序需要采用前面定义的SingletonControllerActivator以单例的模式来激活目标Controller,那么它可以在启动MvcEngine之前按照如下的形式将SingletonControllerActivator注册到依赖注入容器上就可以了。

public class App
{
    static void Main(string[] args)
    {
        var cat = new Cat() .Register<ControllerActivator, SingletonControllerActivator>();
        var engine     = new MvcEngine(cat);
        var address     = new Uri("http://localhost/mvcapp");
        engine.StartAsync(address);
    }
}

二、三种依赖注入方式

一项任务往往需要多个对象相互协作才能完成,或者说某个对象在完成某项任务的时候需要直接或者间接地依赖其他的对象来完成某些必要的步骤,所以运行时对象之间的依赖关系是由目标任务来决定的,是“恒定不变的”,自然也无所谓“解耦”的说法。但是运行时对象通过对应的类来定义,类与类之间耦合则可以通过对依赖进行抽象的方式来降低或者解除。

从服务消费的角度来讲,我们借助于一个接口对消费的服务进行抽象,那么服务消费程序针对具体服务类型的依赖可以转移到对服务接口的依赖上面,但是在运行时提供给消费者的总是一个针对某个具体服务类型的对象。不仅如此,要完成定义在服务接口的操作,这个对象可能需要其他相关对象的参与,换句话说,提供的这个依赖服务对象可能具有对其他服务对象的依赖。作为服务对象提供者的依赖注入容器,它会根据这一依赖链提供所有的依赖服务实例。

如下图所示,应用框架调用GetService<IFoo>方法向依赖注入容器索取一个实现了IFoo接口的服务对象,后者会根据预先注册的类型映射关系创建一个类型为Foo的对象。由于Foo对象需要Bar和Gux对象的参与才能完成目标操作,所以Foo具有了针对Bar和Gux的直接依赖。至于服务对象Bar,它又依赖Baz,那么Baz成为了Foo的间接依赖。对于依赖注入容器最终提供的Foo对象,它所直接或者间接依赖的对象Bar、Baz和Qux都会预先被初始化并自动注入到该对象之中。

从面向对象编程的角度来讲,类型中的字段或者属性是依赖的一种主要体现形式。如果类型A中具有一个B类型的字段或者属性,那么A就对B产生了依赖,所以我们可以将依赖注入简单地理解为一种针对依赖字段或者属性的自动化初始化方式。我们可以通过三种主要的方式达到这个目的,这就是接下来着重介绍的三种依赖注入方式。

构造器注入

构造器注入就是在构造函数中借助参数将依赖的对象注入到由它创建的对象之中。如下面的代码片段所示,Foo针对Bar的依赖体现在只读属性Bar上,针对该属性的初始化实现在构造函数中,具体的属性值由构造函数传入的参数提供。

public class Foo
{
    public IBar Bar{get;}
    public Foo(IBar bar) =>Bar = bar;
}

除此之外,构造器注入还体现在对构造函数的选择上。如下面的代码片段所示,Foo类定义了两个构造函数,依赖注入容器在创建Foo对象之前首先需要选择一个适合的构造函数。至于目标构造函数如何选择,不同的依赖注入容器可能有不同的策略,比如可以选择参数最多或者最少的构造函数,或者可以按照如下所示的方式在目标构造函数上标注一个InjectionAttribute特性。

public class Foo
{
    public IBar Bar{get;}
    public IBaz Baz {get;}

    [Injection]
    public Foo(IBar bar) =>Bar = bar;
    public Foo(IBar bar, IBaz):this(bar)=>Baz = baz;
}

属性注入

如果依赖直接体现为类的某个属性,并且该属性不是只读的,我们可以让依赖注入容器在对象创建之后自动对其进行赋值进而达到依赖注入的目的。一般来说,我们在定义这种类型的时候,需要显式将这样的属性标识为需要自动注入的依赖属性以区别于其他普通的属性。如下面的代码片段所示,Foo类中定义了两个可读写的公共属性Bar和Baz,我们通过标注InjectionAttribute特性的方式将属性Baz设置为自动注入的依赖属性。对于由依赖注入容器提供的Foo对象,它的Baz属性将会自动被初始化。

public class Foo
{
    public IBar Bar{get; set;}

    [Injection]
    public IBaz Baz {get; set;}
}

方法注入

体现依赖关系的字段或者属性可以通过方法的形式初始化。如下面的代码片段所示,Foo针对Bar的依赖体现在只读属性上,针对该属性的初始化实现在Initialize方法中,具体的属性值由该方法的传入的参数提供。我们同样通过标注特性(InjectionAttribute)的方式将该方法标识为注入方法。依赖注入容器在调用构造函数创建一个Foo对象之后,它会自动调用这个Initialize方法对只读属性Bar进行赋值。

public class Foo
{
    public IBar Bar{get;}

    [Injection]
    public Initialize(IBar bar)=> Bar = bar;
}

除了上述这种通过依赖注入容器在初始化服务过程中自动调用的实现之外,我们还可以利用它实现另一种更加自由的方法注入,这种注入方式在ASP.NET Core应用中具有广泛的应用。ASP.NET Core在启动的时候会调用注册的Startup对象来完成中间件的注册,我们定义这个Startup类型的时候不需要让它实现某个接口,所以用于注册中间件的Configure方法没有一个固定的声明,我们可以按照如下的方式将任意依赖的服务实例直接注入到这个方法中。

public class Startup
{
    public void Configure(IApplicationBuilder app, IFoo foo, IBar bar, IBaz baz);
}

类似的注入方式同样应用到中间件类型的定义上。与用来注册中间件的Startup类型一样,ASP.NET Core框架下的中间件类型同样不需要实现某个预定义的接口,用于处理请求的InvokeAsync或者Invoke方法同样可以按照如下的方式注入任意的依赖服务。

public class FoobarMiddleware
{
    private readonly RequestDelegate _next; 
    public FoobarMiddleware(RequestDelegate next)=> _next = next;

    public Task InvokeAsync(HttpContext httpContext, IFoo foo, IBar bar, IBaz baz);
}

上面这种方式的方法注入促成了一种“面向约定”的编程方式。由于不再需要实现某个预定义的接口或者继承某一个预定义的基类,需要实现或者重写方法的声明也就少了对应的限制,这样就可以采用最直接的方式将依赖的服务注入到方法中。对于前面介绍的这几种注入方式,构造器注入是最为理想的形式,我个人不建议使用属性注入和方法注入(前面介绍的这种基于约定的方法注入除外)。

三、Service Locator模式

假设我们需要定义一个服务类型Foo,它依赖于另外两个服务Bar和Baz,后者对应的服务接口分别为IBar和IBaz。如果当前应用中具有一个依赖注入容器(假设类似于我们在前面定义的Cat),那么我们可以采用如下两种方式来定义这个服务类型Foo。

public class Foo : IFoo
{
    public IBar Bar { get; }
    public IBaz Baz { get; }
    public Foo(IBar bar, IBaz baz)
    {
        Bar = bar;
        Baz = baz;
    }  
    public async Task InvokeAsync()
    {
        await Bar.InvokeAsync();
        await Baz.InvokeAsync();
    }
}

public class Foo : IFoo
{
    public Cat Cat { get; }
    public Foo(Cat cat) => Cat = cat; 
    public async Task InvokeAsync()
    {
        await Cat.GetService<IBar>().InvokeAsync();
        await Cat.GetService<IBaz>().InvokeAsync();
    }
}

从表面上看,上面提供的这两种服务类型的定义方式貌似都不错,至少它们都解决针对依赖服务的耦合问题,并将针对服务实现的依赖转变成针对接口的依赖。那么哪一种更好呢?我想有人会选择第二种定义方式,因为这种定义方式不仅仅代码量更少,针对服务的提供也更加直接。我们直接在构造函数中“注入”了代表“依赖注入容器”的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模式了,所以当我们遇到这种情况下的时候应该多想一想是否一定需要这么做。