Loading

第十二章-DI容器和DI容器介绍

DI容器

本书的先前部分是关于一起定义DI的各种原理和模式的。 如第3章所述,DI容器是一个可选工具,可用于实现许多通用基础结构,如果使用Pure DI,则必须实现这些基础结构。

在整本书中,我们始终将讨论容器保持不可知状态,这意味着我们只教了您Pure DI。 请勿将其解释为Pure DI本身的建议; 相反,我们希望您以最纯粹的形式看到DI,不受任何特定容器的API的污染。

.NET平台提供了许多出色的DI容器。 在第12章中,我们将讨论何时应使用这些容器之一以及何时应使用Pure DI。 第4部分的其余各章涵盖了三个免费和开源的DI容器的选择。 在每一章中,我们都会详细介绍该特定容器的API,因为它与第3部分中涉及的尺寸有关,以及传统上会引起初学者悲伤的其他各种问题。 涵盖的容器是Autofac(第13章),Simple Injector(第14章)和Microsoft.Extensions.DependencyInjection(第15章)。

由于空间和时间不受限制,我们希望包含所有容器,但是,这不可能。 我们排除了第一版中涵盖的所有容器,但其中一个除外。 被排除在外的包括Castle Windsor,StructureMap,String.NET,Unity和MEF。 有关这些内容的更多信息,请获取第一版的副本(此版本免费提供)。 另外,我们考虑了但不包括Ninject,它是最受欢迎的DI容器之一。 在撰写本文时,尚无与.NET Core兼容的版本,这是包括在内的标准。

所描述的所有容器都是具有快速发布周期的开源项目。 在此部分中讨论容器之前,第12章将更详细地介绍什么是容器,它可以为您提供什么帮助以及如何决定何时使用DI容器或坚持使用Pure DI。

由于它的市场份额,即使我们在第一版中介绍了Autofac,我们也不能排除它。 Autofac是.NET最受欢迎的DI容器。 第13章专门讨论它。 尽管我们包含了Microsoft.Extensions .DependencyInjection(MS.DI),但我们对此表示怀疑,因为它的功能有限。 但是,我们觉得有必要对此进行介绍,因为许多开发人员倾向于先使用内置工具,然后再切换到第三方工具。 第15章将解释MS.DI可以做什么和不能做什么。

每章都遵循一个通用模板。 当您第三次阅读相同的句子时,这可能会给您某种déjàvu的感觉。 我们认为这是一个优势,因为如果您想比较跨容器处理特定功能的方式,它可以使您轻松地轻松找到不同章节中的相似部分。

这些章节是作为启发。 如果您还没有选择最喜欢的DI容器,则可以通读所有三章进行比较,但也可以只阅读特别感兴趣的一章。 在撰写本文时,第4部分中提供的信息是准确的,但始终请确保也参考最新的信息。

DI容器(DI Container)介绍

当我(马克)还是个孩子时,我和妈妈偶尔会做冰激凌。 这种情况很少发生,因为它需要工作,而且很难正确解决。 真正的冰淇淋基于法式薄脆饼干,是由糖,蛋黄,牛奶或奶油制成的淡蛋c。 如果加热太多,该混合物会凝结。 即使您设法避免这种情况,下一阶段也会出现更多问题。 单独放置在冰箱中的奶油混合物会结晶,因此您必须定期搅拌,直到变得僵硬以致不再可行。 只有这样,您才能拥有优质的自制冰淇淋。 尽管这是一个缓慢且劳动密集的过程,但如果您想要-并且拥有必要的原料和设备-,您可以使用此技术来制作冰淇淋。

大约35年后的今天,我的岳母制造冰淇淋的频率是我和母亲在年轻时无法比拟的-不是因为她喜欢制造冰淇淋,而是因为她使用技术来帮助她。 技术仍然是相同的,但是她没有定期从冰箱取出冰淇淋并搅拌,而是使用电动冰淇淋机为她完成工作(见图12.1)。

DI首先是一种技术,但是您可以使用技术使事情变得更轻松。 在第3部分中,我们将DI描述为一种技术。 在第4部分中,我们将介绍可用于支持DI技术的技术。 我们称此技术为DI容器(DI Container)。

定义 DI容器(DI Container)是一个软件库,提供DI功能并自动执行与对象组成,拦截和生命周期管理有关的许多任务。 它是解析和管理对象图的引擎。

图12.1 一家意大利冰淇淋机。 与制作冰淇淋一样,采用更好的技术,您可以更轻松,更快速地完成编程任务。
image

在本章中,我们将把DI容器(DI Container)作为一个概念-它们如何适合DI的整个主题-以及有关其用法的一些模式和实践。 我们还将研究过程中的一些示例。

本章以对DI容器(DI Container)的一般介绍开始,包括对称为自动装配的概念的描述,然后是有关各种配置选项的部分。 您可以单独阅读每个配置选项,但我们认为至少在阅读有关自动注册的知识之前,最好先阅读“以代码配置”的知识。

最后一部分是不同的。 它着重介绍使用DI容器(DI Container)的优缺点,并帮助您确定使用DI容器(DI Container)是否对您和您的应用程序都有利。 我们认为这是每个人都应该阅读的重要部分,无论他们使用DI和DI容器(DI Container)的经验如何。 尽管可以先阅读有关“配置为代码和自动注册”部分,但可以单独阅读本节。

本章的目的是使您对DI容器(DI Container)是什么以及它如何适合本书的其余模式和原理有很好的了解。 从某种意义上讲,您可以将本章作为本书第4部分的介绍。 在这里,我们将通常讨论DI容器(DI Container),而在接下来的章节中,我们将讨论特定的容器及其API。

介绍DI容器(DI Container)

DI容器(DI Container)是一个软件库,可以自动执行对象组成,生命周期管理和拦截中涉及的许多任务。 尽管可以用Pure DI编写所有必需的基础结构代码,但这并不能为应用程序增加多少价值。 另一方面,组成对象的任务是一般性的,可以一劳永逸地解决。 这就是所谓的通用子域。鉴于此,使用通用库很有意义。 与实现日志记录或数据访问没有太大不同; 记录应用程序数据是一种通用日志记录库可以解决的问题。 组成对象图也是如此。

在本节中,我们将讨论DI容器(DI Container)如何构成对象图。 我们还将向您展示一些示例,以使您大致了解使用容器和实现的外观。

探索容器的Resolve API

DI容器(DI Container)是与任何其他软件库一样的软件库。 它公开了可用于组成对象的API,并且组成对象图是单个方法调用。 DI容器(DI Container)还要求您在组成对象之前对其进行配置。 我们将在第12.2节中重新讨论这一点。

在这里,我们将向您展示DI容器(DI Container)如何解析对象图的一些示例。 作为本节中的示例,我们将同时使用应用于ASP.NET Core MVC应用程序的AutofacSimple Injector。 有关如何组成ASP.NET Core MVC应用程序的更多详细信息,请参见7.3节。

您可以使用DI容器(DI Container)解析控制器实例。 可以在以下各章中介绍的所有三个DI容器(DI Container)中实现此功能,但是在这里我们仅显示几个示例。

使用各种DI容器(DI Container)解析控制器

Autofac是一个DI容器(DI Container),具有相当符合模式的API。 假设您已经有一个Autofac容器实例,则可以通过提供请求的类型来解析控制器:

var controller = (HomeController)container.Resolve(typeof(HomeController));

您需要将typeof(HomeController)传递给Resolve方法,并获取一个请求类型的实例,该实例完全填充了所有适当的依赖项。 Resolve方法是弱类型的,并返回System.Object的实例。 这意味着您需要将其转换为更具体的内容,如示例所示。

许多DI容器(DI Container)都具有与Autofac相似的API。 即使实例是使用SimpleInjector.Container类解析的,Simple Injector的相应代码看起来也几乎与Autofac的相同。 使用Simple Injector,先前的代码将如下所示:

controller = (HomeController)container.GetInstance(typeof(HomeController));

唯一真正的区别是Resolve方法称为GetInstance。 您可以从这些示例中提取DI容器(DI Container)的一般形状。

使用DI容器(DI Container)解析对象图

DI容器(DI Container)是解析和管理对象图的引擎。 尽管DI容器(DI Container)不仅具有解决对象的功能,但这还是任何容器API的核心部分。 前面的示例显示了为此目的,容器具有弱类型方法。 随着名称和签名的变化,该方法如下所示:

object Resolve(Type serviceType);

如前面的示例所示,由于返回的实例的类型为System.Object,因此在使用返回值之前,通常需要将其转换为期望的类型。 对于那些在编译时知道要请求哪种类型的情况,许多DI容器(DI Container)也提供了通用版本。 它们通常如下所示:

T Resolve<T>();

除了提供类型方法参数之外,这种重载还使用指示所请求类型的类型参数(T)。 该方法返回T的实例。如果大多数容器无法解析请求的类型,则它们会引发异常。

警告 Resolve方法的签名功能强大且用途广泛。 您可以请求任何类型的实例,并且您的代码仍然可以编译。 实际上,Resolve方法适合服务定位器的签名。 如5.2中所述,您需要格外小心,不要通过在组合根(Composition Root)之外调用Resolve来将您的DI容器(DI Container)用作服务定位器。

如果我们单独查看Resolve方法,它几乎就像魔术一样。 从编译器的角度来看,可以要求它解析任意类型的实例。 容器如何知道如何组成请求的类型,包括所有依赖项? 它没有; 您必须先告诉它。 您可以使用将抽象映射到具体类型的配置来执行此操作。 我们将在第12.2节中返回该主题。

如果容器的配置不足以完全构成请求的类型,则通常会抛出描述性异常。 例如,考虑下面我们在清单3.4中首先讨论的HomeController。 您可能还记得,它包含IProductService类型的依赖项:

public class HomeController : Controller
{
    private readonly IProductService productService;
    public HomeController(IProductService productService)
    {
        this.productService = productService;
    }
    ...
}

对于不完整的配置,Simple Injector具有示例性的异常消息,如下所示:

​ 类型HomeController的构造函数包含名称为productService的参数和类型为IProductService的参数,该参数尚未注册。 请确保已注册IProductService或更改HomeController的构造函数。

在上一个示例中,您可以看到Simple Injector无法解析HomeController,因为它包含IProductService类型的构造函数参数,但是没有告知Simple Injector在请求IProductService时返回哪个实现。 如果容器配置正确,它甚至可以根据请求的类型解析复杂的对象图。 如果配置中缺少某些内容,则容器可以提供有关丢失内容的详细信息。 在下一节中,我们将详细介绍如何完成此操作。

自动接线(Auto-Wiring)

DI容器(DI Container)在编译到所有类中的静态信息上蓬勃发展。 使用反射,他们可以分析所请求的类并找出需要哪些依赖项。

如第4.2节所述,构造器注入是应用DI的首选方式,因此,所有DI容器(DI Container)本质上都理解构造器注入。 具体来说,他们通过将自己的配置与从类的类型信息中提取的信息相结合来构成对象图。 这称为自动接线。

定义 自动装配是通过利用编译器和公共语言运行时(CLR)提供的类型信息,根据抽象和具体类型之间的映射自动组成对象图的功能。

大多数DI容器(DI Container)也了解属性注入,尽管有些容器要求您显式启用它。 考虑到属性注入的缺点(如第4.4节所述),这是一件好事。 图12.2描述了大多数DI容器(DI Container)自动连线对象图所遵循的一般算法。

图12.2 简化的自动装配工作流程。 DI容器(DI Container)使用其配置来查找与请求的类型匹配的适当的具体类。 然后,它使用反射来检查类的构造函数。
image

如图所示,DI容器(DI Container)找到请求的抽象的具体类型。 如果具体类型的构造函数需要参数,则递归过程开始,在该过程中,DI容器(DI Container)针对每种参数类型重复该过程,直到满足所有构造函数参数为止。 完成此操作后,容器将构造具体类型,同时注入递归解析的依赖项。

大多数DI容器(DI Container)都实现了优化,以使连续的请求能够更快地执行。 这些优化的执行方式因容器而异。 正如我们在4.2.2节中提到的那样,DI容器(DI Container)通常不会给您的应用带来明显的性能开销。 对于一般应用程序而言,I/O是最重要的瓶颈,与优化对象组成相比,优化 I/O 通常会带来更多收益。

在第12.2节中,我们将详细介绍如何配置容器。 目前,最重要的是要理解的是,配置的核心是抽象和它们所表示的具体类之间的映射列表。 这听起来有点理论性,所以我们认为一个示例会有所帮助。

示例:实现一个支持自动装配的简单DI容器(DI Container)(Example: Implementing a simplistic DI Container that supports Auto-Wiring)

为了演示自动装配的工作原理,并展示DI容器(DI Container)没有什么神奇之处,让我们看一下一个简单的DI容器(DI Container)实现,该实现能够使用自动装配来构建复杂的对象图。

警告 清单12.1被标记为错误代码,在这种情况下,这意味着尽管您可以使用此代码来尝试该概念,但绝对不要在实际的应用程序中使用它。 正如我们将在第12.3节中详细说明的那样,您应该使用Pure DI或使用现有的常用且经过良好测试的DI容器(DI Container)之一。 此清单仅用于教育目的-请勿在工作中使用此清单!

清单12.1展示了这种简单的DI容器(DI Container)实现。 它不支持生命周期管理,拦截或其他许多重要功能。 唯一受支持的功能是自动接线。

清单12.1 一个支持自动装配的简单的DI容器(DI Container) (坏代码)

public class AutoWireContainer
{
    Dictionary<Type, Func<object>> registrations =
        new Dictionary<Type, Func<object>>(); <--包含一组映射
    public void Register(
        Type serviceType, Type componentType)
    {
        this.registrations[serviceType] =
            () => this.CreateNew(componentType);
    }  <-- 创建一个新注册,并将服务类型的映射添加到注册字典中
    public void Register(
        Type serviceType, Func<object> factory)
    {
        this.registrations[serviceType] = factory;
    } <--您可以自己为容器提供Func <T>委托,可以选择绕过自动装配。
    public object Resolve(Type type)  <--解析完整的对象图
    {
        if(this.registrations.ContainsKey(type))
        {
            return this.registrations[type]();
        }
        throw new InvalidOperationException(
            "No registration for " + type);
    }
    private object CreateNew(Type componentType)  <--创建组件的新实例
    {
        var ctor =
            componentType.GetConstructors()[0];
        var dependencies =
            from p in ctor.GetParameters()
            select this.Resolve(p.ParameterType);
        return Activator.CreateInstance(
            componentType, dependencies.ToArray());
    }
}

AutoWireContainer包含一组注册。 Register是抽象(服务类型)和组件类型之间的映射。 抽象类被表示为字典的键,而它的值是Func <object>委托,该委托允许构造实现抽象的组件的新实例。 Register方法通过告诉容器应为给定服务类型创建哪个组件来注册新的注册。 您仅指定要创建的组件,而不能指定如何创建。

Register方法将服务类型的映射添加到注册字典中。 可选地,Register方法可以直接向容器提供Func <T>委托。 这会绕过其自动装配功能。 它将改为调用提供的委托。

Resolve方法允许解析完整的对象图。 它从注册字典中获取所请求的serviceTypeFunc <T>,对其进行调用并返回其值。 如果没有针对所请求类型的注册,则Resolve会引发异常。 最后,CreateNew通过遍历组件的构造函数参数并递归地调用容器来创建组件的新实例。 为此,您可以在提供参数类型的同时为每个参数调用解析。 以这种方式解决所有类型的依赖关系后,它会通过使用反射(使用System.Activator类)来构造类型本身。

AutoWireContainerCreateNew方法包含清单12.1中示例的内容。 它使用反射来分析类型信息,以递归方式调用容器以获取类型的依赖项,并再次创建类型本身。 CreateNew实现自动装配。

可以将AutoWireContainer实例配置为组成任意对象图。 回到清单3.13的第3章,您使用Pure DI创建了HomeController。 下一个清单重复了第3章中的清单。我们以该清单为例来演示以前在AutoWireContainer中定义的自动装配功能。

清单12.2 使用Pure DI组成HomeController的对象图

new HomeController(
	new ProductService(
		new SqlProductRepository(
			new CommerceContext(connectionString)),
		new AspNetUserContextAdapter()));

您可以使用AutoWireContainer注册五个必需的组件,而不必像上一个清单中那样手工构成此对象图。 为此,您必须将这五个组件映射到其相应的抽象。 表12.1列出了这些映射。

表12.1支持HomeController自动装配的映射类型

抽象对象 具体类型
HomeController HomeController
IProductService ProductService
IProductRepository SqlProductRepository
CommerceContext CommerceContext
IUserContext AspNetUserContextAdapter

清单12.3显示了如何使用AutoWireContainerRegister方法添加表12.1中指定的必需映射。 请注意,此清单使用配置作为代码。 我们将在第12.2.2节中讨论配置作为代码。

清单12.3 使用AutoWireContainer注册HomeController

var container = new AutoWireContainer();  <--创建一个新的容器实例
container.Register(
    typeof(IUserContext),
    typeof(AspNetUserContextAdapter));
container.Register(
    typeof(IProductRepository),
    typeof(SqlProductRepository));
<---注册抽象和具体类型之间的映射,而无需指定类型的依赖关系。 请注意,由于容器使用内部字典,因此进行注册的顺序无关紧要。

container.Register(
    typeof(IProductService),
    typeof(ProductService));
container.Register(
    typeof(HomeController),
    typeof(HomeController));
<--对于HomeController,抽象和具体类型是相同的类型。 这意味着,每当您请求HomeController时,您都会获得一个HomeController。
container.Register(
    typeof(CommerceContext),
    () => new CommerceContext(connectionString)); <-当请求CommerceContext时,Resolve方法调用此委托。

当请求CommerceContext时,您可以手动创建上下文,而不是使用自动装配。 需要手动接线,因为CommerceContext包含connectionString参数,它是原始类型字符串。

您可能会在表12.1和清单12.3中发现HomeController的映射令人困惑,因为它映射到自身而不是映射到抽象类。 但是,这是一种常见的做法,尤其是在处理对象图顶部的类型(例如MVC控制器)时。

您在清单4.4、7.8和8.3中看到了类似的内容,其中在请求HomeController类型时创建了一个新的HomeController实例。 这些清单和清单12.3之间的主要区别在于,后者使用DI容器(DI Container)而不是Pure DI。

您可以尝试自动装配CommerceContext,同时为connectionString添加额外的注册,而不是手动连接CommerceContext。 但是,由于connectionString的类型为String,所以这会引起歧义。 请记住,DI容器(DI Container)根据其类型来解析依赖关系,但是应用程序组件可能需要许多类型为String的配置值。 在这种情况下,容器将无法确定要使用哪个值,因为它们的类型都相同。 手工接线CommerceContext解决了此问题。

清单12.3有效注册了组成HomeController对象图所需的所有组件。 现在,您可以使用配置的AutoWireContainer创建一个新的HomeController

清单12.4 使用AutoWireContainer解析HomeController

object controller = container.Resolve(typeof(HomeController));

当调用AutoWireContainerResolve方法来请求新的HomeController类型时,容器将递归调用自身,直到它解决了所有必需的依赖项。 此后,将创建一个新的HomeController实例,同时将解析的依赖项提供给其构造函数。 图12.3显示了递归过程,它使用某种非常规的表示来可视化递归调用。 容器实例分布在四个单独的垂直时间线上。 因为递归调用有多个级别,所以将它们折叠成一行(就像UML序列图的规范一样)会很混乱。

当DI容器(DI Container)收到对HomeController的请求时,它要做的第一件事就是在其配置中查找类型。 HomeController是一个具体的类,您已将其映射到自身。 然后,容器使用反射来检查具有以下签名的HomeController的唯一构造函数:

public HomeController(IProductService productService)

因为此构造函数不是无参数的构造函数,所以在遵循图12.2的一般流程图时,需要对IProductService构造函数参数重复该过程。 容器在其配置中查找IProductService,并发现它映射到具体的ProductService类。 ProductService的单个公共构造函数具有以下签名:

public ProductService( IProductRepository repository, IUserContext userContext)
图12.3 组合根从容器请求一个HomeController,该容器递归地回调自身以请求HomeController的依赖项。
image

那仍然不是一个无参数的构造函数,现在有两个构造函数参数需要处理。 容器按顺序处理每个容器,因此它从IProductRepository接口开始,该接口根据配置映射到SqlProductRepository。 该SqlProductRepository具有带有此签名的公共构造函数:

public SqlProductRepository(CommerceContext context)

但这又不是无参数的构造函数,因此容器需要解析CommerceContext才能满足SqlProductRepository的构造函数。 但是,使用以下委托在清单12.3中注册了CommerceContext

() => new CommerceContext(connectionString) <------------用于定义匿名函数的这种语法也称为lambda表达式。

容器调用该委托,这将导致一个新的CommerceContext实例。 这次不使用自动接线。

重要 当您开始使用DI容器(DI Container)时,不需要完全放弃手工接线对象图。

现在,容器具有CommerceContext的适当值,它可以调用SqlProductRepository构造函数。 现在,它已经成功处理了ProductService构造函数的Repository参数,但是需要将该值保留一段时间。 它还需要注意ProductServiceuserContext构造函数参数。 根据配置,IUserContext映射到具体的AspNetUserContextAdapter类,该类具有以下公共构造函数:

public AspNetUserContextAdapter() <--用于定义匿名函数的这种语法也称为lambda表达式。

您可能从清单3.12回忆起,AspNetUserContextAdapter根本没有指定构造函数。 如果您没有在类上指定任何构造函数,则C#编译器会使用无参数的公共构造函数来编译该类。

由于AspNetUserContextAdapter包含无参数构造函数,因此无需解析任何依赖关系就可以创建它。 现在,它可以将新的AspNetUserContextAdapter实例传递给ProductService构造函数。 现在,它与以前的SqlProductRepository一起实现了ProductService构造函数,并通过反射调用了它。 最后,它将新创建的ProductService实例传递给HomeController构造函数,并返回HomeController实例。 图12.4显示了图12.2中显示的一般工作流程如何从清单12.1映射到AutoWireContainer

使用清单12.3所示的DI容器(DI Container)的自动装配功能而不是清单12.2所示的使用Pure DI的优势在于,使用Pure DI时,对组件构造函数的任何更改都必须反映在组合根(Composition Root)中。 另一方面,自动装配使组合根(Composition Root)对此类更改更具弹性。

例如,假设您需要将一个CommerceContext依赖项添加到AspNetUserContextAdapter,以便它查询数据库。 以下清单显示了应用Pure DI时需要对组合根(Composition Root)进行的更改。

清单12.5 更改后的AspNetUserContextAdapter的组合根(Composition Root)

new HomeController(
    new ProductService(
        new SqlProductRepository(
            new CommerceContext(connectionString)),
        new AspNetUserContextAdapter(
            new CommerceContext(connectionString)))); <----现在将CommerceContext注入到AspNetUserContextAdapter中。
图12.4 映射到清单12.1中的代码的自动装配的简化工作流程。 查询注册字典中的具体类型,解析其构造函数参数,并使用其解析的Dependencies创建具体类型。
image

另一方面,使用自动装配时,无需更改组合根(Composition Root)。 AspNetUserContextAdapter是自动连接的,并且由于已经注册了其新的CommerceContext依赖关系,因此该容器将能够满足新的构造函数参数,并愉快地构造一个新的AspNetUserContextAdapter

重要 尽管自动布线可以减少对成分根的必要维护,但这仍然并不意味着您应该始终首选DI容器(DI Container)而不是Pure DI。 如前所述,第12.3节详细介绍了何时使用Pure DI更好。

这就是自动装配的工作方式,尽管DI容器(DI Container)还需要注意生命周期管理,并且可能要解决属性注入以及其他更专业的创建要求。

警告 清单12.1中的AutoWireContainer会在对象图中有一个依赖关系循环时引发StackOverflowException。StackOverflowExceptions有问题,因为它们会使应用程序崩溃,从而很难找出到底出了什么问题。 这是许多原因之一,相比于本地实现,您始终应该首选可用的DI容器(DI Container)之一。 大多数现代流行的DI容器(DI Container)都可以检测周期而不会导致过程终止。

重点在于,构造方法注入静态地通告类的依赖关系要求,并且DI容器(DI Container)使用该信息来自动装配复杂对象图。 必须先配置容器,然后才能组成对象图。 组件的注册可以通过多种方式完成。

配置DI容器(DI Container)

尽管大多数操作都在执行Resolve方法,但您应该期望将大部分时间花费在DI容器(DI Container)的配置API上。 毕竟,解析对象图是单个方法调用。

DI容器(DI Container)倾向于支持图12.5中所示的两个或三个通用配置选项。 有些不支持配置文件,而另一些还不支持自动注册,而无所不包的作为代码配置。 大多数允许您在同一应用程序中混合使用几种方法。 第12.2.4节讨论了为什么要使用混合方法。

图12.5 针对显式维度和绑定度显示了配置DI内容的最常用方法
image

这三个配置选项具有不同的特性,这些特性使它们在不同情况下很有用。 配置文件和配置作为代码都倾向于是明确的,因为它们要求您分别注册每个组件。 另一方面,自动注册更为隐含,因为它使用约定通过一条规则来注册一组组件。

当使用配置作为代码时,可以将容器配置编译为程序集,而基于文件的配置使您能够支持后期绑定(Late binding),您可以在其中更改配置而无需重新编译应用程序。 在该维度中,自动注册位于中间位置,因为您可以要求自动注册扫描在编译时已知的单个程序集,或者扫描在编译时未知的预定义文件夹中的所有程序集。 表12.2列出了每个选项的优缺点。

表12.2 配置选项

方式 描述 优点 缺点
配置文件(Configuration files) 映射在配置文件中指定(通常以XML或JSON格式) 支持更换而无需重新编译 没有编译时检查
详细而脆弱
配置为代码(Configuration as Code) 代码明确确定映射 编译时检查
高度控制
不重新编译就不支持替换
自动注册(Auto-Registration) 规则用于使用反射来定位合适的组件并构建映射 支持更换而无需重新编译
所需的精力更少
帮助强制执行约定以使代码库更加一致
没有编译时检查
更少的控制
一开始可能看起来更抽象

从历史上看,DI容器(DI Container)从配置文件开始,这也解释了为什么较旧的库仍然支持该配置文件。 但是,此功能已被轻描淡写,以支持更常规的方法。 这就是为什么最近开发的DI容器(DI Container)(例如Simple Injector和Microsoft.Extensions.DependencyInjection)没有对基于文件的配置的任何内置支持。

重要 开始解析对象图后,您不应该回头重新配置容器,这只会让您感到悲伤。 这与注册解析版本(Register Resolve Release pattern)模式(https://mng.bz/D8Ew)有关。

尽管自动注册是最现代的选择,但它并不是最明显的起点。 由于其隐含性,它似乎比更显式的选项更抽象,因此,我们将以历史顺序介绍每个选项,从配置文件开始。

使用配置文件配置容器(Configuring containers with configuration files)

当DI容器(DI Container)于2000年代初首次出现时,它们都使用XML作为配置机制-那时,大多数事情都是这样做的。 使用XML作为配置机制的经验后来显示,这很少是最好的选择。

XML往往是冗长而脆弱的。 使用XML配置DI容器(DI Container)时,可以识别各种类和接口,但是如果错误拼写,则没有编译器支持来警告您。 即使类名正确,也不能保证所需的程序集将在应用程序的探测路径中。

近年来,JSON也已成为表达配置的流行方式。 与XML相比,该格式更干净,更易于阅读,但它仍具有相同的特征:与XML一样脆弱且冗长。

为了增加伤害,与普通代码相比,XML的表达能力受到限制。 有时,这使得很难或不可能在配置文件中表达某些配置,而这些配置否则很难用代码来表达。 例如,在清单12.3中,您使用了lambda表达式注册了CommerceContext。 这样的lambda表达式不能用XML或JSON表示。

另一方面,配置文件的优点是您可以更改应用程序的行为而无需重新编译。 如果您开发可交付给数千个客户的软件,这将非常有价值,因为它为客户提供了一种自定义应用程序的方式。 但是,如果您编写内部应用程序或网站来控制部署环境,那么当您需要更改行为时,通常更容易重新编译和重新部署该应用程序。

重要 配置文件和“代码和自动注册”一样,也是组成根的一部分。 因此,使用配置文件不会使组合根(Composition Root)变小,它只会移动它。 仅对DI配置中需要后期绑定(Late binding)的部分使用配置文件。 在配置的所有其他部分中,将配置作为代码(Configuration as Code)或自动注册(Auto-Registration)。

DI容器(DI Container)通常通过将其指向特定的配置文件来配置文件。 下面的示例以Autofac为例。

因为Autofac是本书中涵盖的唯一具有对配置文件的内置支持的DI容器(DI Container),所以使用它作为示例是有意义的。

在此示例中,您将配置与第12.1.3节中相同的类。 任务的大部分是应用表12.1中概述的配置,但是您还必须提供类似的配置以支持HomeController类的组成。 以下清单显示了启动和运行应用程序所需的配置。

清单12.6 使用JSON配置文件配置Autofac

{
    "defaultAssembly": "Commerce.Web",
    "components": [
        {
            "services": [{
                "type":
                "Commerce.Domain.IUserContext, Commerce.Domain"
            }],
            "type":
            "Commerce.Web.AspNetUserContextAdapter"
        },
        {
            "services": [{
                "type": "Commerce.Domain.IProductRepository, Commerce.Domain"
            }],
            "type": "Commerce.SqlDataAccess.SqlProductRepository, Commerce.SqlDataAccess"
        },
        {
            "services": [{
                "type": "Commerce.Domain.IProductService, Commerce.Domain"
            }],
            "type":
            "Commerce.Domain.ProductService, Commerce.Domain"
        },
        {
            "type": "Commerce.Web.Controllers.HomeController"
        },
        {
            "type": "Commerce.SqlDataAccess.CommerceContext,Commerce.SqlDataAccess",
            "parameters": {
                "connectionString":
                "Server=.;Database=MaryCommerce;Trusted_Connection=True;"
            }
        }]
}

在此示例中,如果您未在类型或接口引用中指定程序集限定的类型名称,则将defaultAssembly假定为默认程序集。 对于简单的映射,必须使用完整的类型名称,包括名称空间和程序集名称。 因为AspNetUserContextAdapter排除了程序集的名称,所以AutofacCommerce.Web程序集中查找它,您将其定义为defaultAssembly

从这个简单的代码清单中您可以看到,JSON配置往往非常冗长。 简单的映射,例如从IUserContext接口到AspNetUserContextAdapter类的映射,需要使用方括号和完全限定的类型名形式的大量文本

您可能还记得,CommerceContext将连接字符串作为输入,因此您需要指定如何找到此字符串的值。 通过将参数添加到映射,您可以通过其参数名称(在这种情况下为connectionString)指定值。 使用以下代码将配置加载到容器中。

清单12.7 使用Autofac读取配置文件

var builder = new Autofac.ContainerBuilder();  <--允许在抽象和具体类型之间添加映射
IConfigurationRoot configuration =
    new ConfigurationBuilder()
    .AddJsonFile("autofac.json") 
    .Build();  <--使用.NET Core的配置系统加载清单12.6中的autofac.json配置文件
builder.RegisterModule(
    new Autofac.Configuration.ConfigurationModule(  
        configuration));<--将创建的配置包装在Autofac模块中,该模块处理配置文件并将组件映射到Autofac中的注册

Autofac是本书中包含的唯一支持配置文件的DI容器(DI Container),但此处未涵盖的其他DI容器(DI Container)仍在继续支持配置文件。 每个容器的确切架构都不同,但是总体结构趋于相似,因为您需要将抽象映射到实现。

警告 随着应用程序的大小和复杂性的增长,您的配置文件也将随之增长。 它可以成长为真正的绊脚石。 这是因为它对诸如类,参数等之类的编码概念进行了建模,但是没有编译器,调试选项等的好处。 配置文件容易变脆并且对错误不透明,因此仅在需要后期绑定(Late binding)时才使用此方法。

配置文件无法缩放

我(Steven)曾经为一个大客户工作,该客户维护的产品包含一百多年的代码。 DI无处不在,这是绝对的优势。 但是,为了支持对象组合,他们使用Spring.NET作为其DI容器(DI Container),当时它仅支持XML配置文件。 更糟糕的是,他们使用的Spring.NET版本不支持自动装配。 这不仅需要在大型XML文件中显式定义每个映射,而且还需要指定每个构造函数依赖项。 这些Spring.NET XML配置文件由十几个团队在该代码基础上工作,它们不仅冗长,脆弱且维护繁重,而且还定期引起合并冲突。

由于冗长,脆弱,缺少编译器支持以及这些XML配置文件的性能低下,每天浪费了许多开发时间,这是开发人员都意识到的。 如果他们从一开始就决定实践Pure DI,他们的状况会更好。3在这种情况下,Pure DI本身并不能解决合并冲突,但至少编译器将帮助捕获大多数错误 较早。

提示 尽管配置文件可以在小型应用程序中使用,也可以在应用程序的一小部分使用时使用,但它们无法扩展。 避免将配置文件用作DI配置的默认方法。 正如我们将在12.3节中讨论的那样,请使用Pure DI或自动注册(Auto-Registration)。

不要让缺少处理配置文件的支持对您选择DI容器(DI Container)的影响太大。 如前所述,仅应在配置文件中定义真正的后期绑定(Late binding)组件,这将不多了。 即使容器不提供支持,也可以通过一些简单的语句从配置文件中加载类型,如清单1.2所示。

由于冗长和易碎的缺点,您应该首选其他替代方法来配置容器。 配置作为代码(Configuration as Code)配置在粒度和概念上类似于配置文件,但是显然使用代码而不是配置文件。

使用配置作为代码配置容器(Configuring containers using Configuration as Code)

组成应用程序的最简单方法也许就是对对象图的构造进行硬编码。 这似乎与DI的整体精神背道而驰,因为它确定了在编译时应用于所有抽象的具体实现。 但是,如果在组合根(Composition Root)中完成,则只会违反表1.1中列出的一项好处,即延迟绑定。

如果对依赖项进行了硬编码,则失去后期绑定(Late binding)的好处,但是,正如我们在第1章中提到的那样,这可能不适用于所有类型的应用程序。 如果在受控环境中将应用程序部署在有限数量的实例中,则在需要替换模块时,可以更轻松地重新编译和重新部署应用程序:

​ 我经常认为人们过于渴望定义配置文件。 通常,编程语言会提供一种简单而强大的配置机制

​ --- MARTIN FOWLER

使用“配置作为代码”时,您将明确声明与使用配置文件时相同的离散映射-仅使用代码而不是XML或JSON。

定义 在DI容器(DI Container)的上下文中,配置作为代码(Configuration as Code)允许将容器的配置存储为源代码。 抽象类和特定实现之间的每个映射都直接在代码中明确表示。

所有现代的DI容器(DI Container)都完全支持配置作为代码(Configuration as Code)作为配置文件的后继产品。 实际上,它们中的大多数将其作为默认机制,而配置文件则作为可选功能。 如前所述,有些甚至根本不提供对配置文件的支持。 支持配置作为代码(Configuration as Code)的API在DI容器(DI Container)与DI容器(DI Container)之间有所不同,但是总体目标仍然是定义抽象类和具体类型之间的离散映射。

提示 除非需要后期绑定(Late binding),否则将配置作为代码(Configuration as Code)胜于配置文件。 编译器可能会有所帮助,Visual Studio生成系统会自动将所有必需的程序集复制到输出文件夹中。 而且,如果您确实需要后期绑定(Late binding),则仅对配置中需要后期绑定(Late binding)的部分使用配置文件,该文件通常只是整个应用程序中类型的一小部分。

让我们看看如何使用Microsoft.Extensions.DependencyInjection的配置作为代码(Configuration as Code)配置电子商务应用程序。 为此,我们将使用一个示例,该示例使用代码配置示例电子商务应用程序。

在12.2.1节中,您了解了如何使用Autofac使用配置文件配置示例电子商务应用程序。 我们也可以使用Autofac演示配置作为代码(Configuration as Code),但是,为了使本章更有趣,我们在此示例中使用Microsoft.Extensions.DependencyInjection。 使用Microsoft的配置API,您可以更紧凑地表示清单12.6中的配置,如下所示。

清单12.8 使用代码配置Microsoft.Extensions.DependencyInjection

var services = new ServiceCollection(); <--定义抽象和实现之间的映射

services.AddSingleton<
    IUserContext,
AspNetUserContextAdapter>();  <--在抽象和具体类型之间添加自动连线映射
    
services.AddTransient<
    IProductRepository,
SqlProductRepository>();
services.AddTransient<
    IProductService,
ProductService>();
services.AddTransient<HomeController>(); <--将具体类型作为泛型类型参数的重载

services.AddScoped<CommerceContext>(
    p => new CommerceContext(connectionString)); <--允许将抽象映射到Func<T>委托的重载

Microsoft的ServiceCollection等同于AutofacContainerBuilder,它定义了抽象和实现之间的映射。 AddTransient,AddScopedAddSingleton方法用于在抽象类和特定类型的特定生活方式之间添加自动有线映射。 这些方法是通用的,这将导致代码更加紧凑,并具有获得一些额外的编译时检查的额外好处。 万一具体类型映射到自身,而不是抽象映射到具体类型,则有一个方便的重载,只需将具体类型作为泛型类型参数即可。 并且,就像清单12.1的AutoWireContainer示例一样,此DI容器(DI Container)的API包含一个重载,该重载允许将抽象映射到Func <T>委托。

如果看起来很熟悉,那也就不足为奇了:从概念上讲,它与清单12.3中的示例代码几乎相同。 在那里,我们建立了如何实现自动布线的概念证明。

在清单12.8中,我们自由地使用以下三种常见的生活方式来演示组件的注册:Singleton,Transient和Scoped。 以下各章详细说明了如何为每个容器配置生活方式。

将此代码与清单12.6进行比较,并注意它的紧凑程度-即使它做的完全相同。 一个简单的映射(如从IProductServiceProductService的映射)通过单个方法调用来表示。

配置作为代码(Configuration as Code)不仅比配置文件中表示的配置紧凑得多,而且还具有编译器支持。 清单12.8中使用的类型参数表示编译器检查的实型。 泛型甚至可以走得更远,因为使用诸如Microsoft API之类的泛型类型约束可以使编译器检查所提供的具体类型是否与抽象类相匹配。 如果无法进行转换,则代码将无法编译。

尽管配置作为代码(Configuration as Code)是安全且易于使用的,但仍需要比您想要的更多的维护。 每次将新类型添加到应用程序时,还必须记住注册它-许多注册最终都是相似的。 自动注册解决了这个问题。

使用自动注册按照约定配置容器(Configuring containers by convention using Auto-Registration)

考虑清单12.8的注册,在您的项目中包含以下几行代码可能是完全可以的。 但是,当项目发展时,设置DI容器(DI Container)所需的注册数量也会增加。 随着时间的流逝,您可能会看到许多类似的注册。 他们通常会遵循一种常见的模式。 以下清单显示了这些注册如何开始看起来有些重复。

清单12.9 使用配置作为代码时注册中的重复

services.AddTransient<IProductRepository, SqlProductRepository>();
services.AddTransient<ICustomerRepository, SqlCustomerRepository>();
services.AddTransient<IOrderRepository, SqlOrderRepository>();
services.AddTransient<IShipmentRepository, SqlShipmentRepository>();
services.AddTransient<IImageRepository, SqlImageRepository>();

services.AddTransient<IProductService, ProductService>();
services.AddTransient<ICustomerService, CustomerService>();
services.AddTransient<IOrderService, OrderService>();
services.AddTransient<IShipmentService, ShipmentService>();
services.AddTransient<IImageService, ImageService>();

反复写这样的注册码违反了DRY原则。 看来,基础架构代码的生产效率低下,并没有为应用程序增加太多价值。 如果可以自动注册组件,则可以节省时间并减少错误,并假设这些组件遵循某种约定。 许多DI容器(DI Container)提供自动注册功能,可让您介绍自己的约定并在配置上应用约定。

定义 自动注册是一种功能,可以根据某种约定,通过扫描一个或多个程序集以实现所需抽象的实现,来在容器中自动注册组件。 自动注册有时也称为批量注册或程序集扫描。

约定优于配置

约定越来越多的体系结构模型是约定而不是配置的概念。 您可以同意会影响代码库的约定,而不必编写和维护大量的配置代码。 ASP.NET Core MVC根据控制器名称查找控制器的方式是一个简单约定的好例子:

  • 要求一个名为Home的控制器。
  • 默认控制器工厂在著名名称空间列表中搜索名为HomeController的类。 如果找到这样的班级,那就是一场比赛。
  • 默认的控制器工厂将类的类型转发给控制器激活器,后者构造控制器的实例。

这里的约定是,控制器必须命名为[ControllerName] Controller

约定不仅可以应用于ASP.NET Core MVC控制器。 添加的约定越多,容器配置的各个部分的自动化程度就越高。

提示 约定优于配置比仅支持DI配置具有更多优势。 只要您遵守约定,它就可以自动工作,因此可以使代码更加一致。

实际上,您可能需要将自动注册与配置作为代码或配置文件结合使用,因为您可能无法使每个组件都符合有意义的约定。 但是,您可以将代码库向约定的方向转移的越多,它的可维护性就越高。

Autofac支持自动注册,但是我们认为使用另一个DI容器(DI Container)使用约定来配置示例电子商务应用程序会更有趣。 因为我们希望将示例限制在本书中讨论的DI容器(DI Container)中,并且因为Microsoft.Extensions.DependencyInjection没有任何自动注册功能,所以我们将使用Simple Injector来说明这一概念。

回顾清单12.9,您可能会同意各种数据访问组件的注册是重复的。 我们可以围绕它们表达某种约定吗? 清单12.9的所有五种具体存储库类型都具有一些特征:

  • 它们都定义在同一程序集中。
  • 每个具体的类都有一个以存储库结尾的名称。
  • 每个实现一个接口。

似乎合适的约定可以通过扫描所讨论的程序集并注册与该约定相匹配的所有类来表达这些相似性。 即使Simple Injector确实支持自动注册,它的自动注册API仍侧重于共享同一接口的类型组的注册。 它的API本身不允许您表达此约定,因为没有单个接口来描述该组存储库。

最初,这种疏忽可能看起来很尴尬,但是在.NET的反射API之上定义自定义LINQ查询通常很容易编写,提供更多的灵活性,并防止您不得不学习另一个API(假设您熟悉LINQ) 和.NET的反射API。 下面的清单显示了使用LINQ查询的这种约定。

清单12.10 使用Simple Injector扫描存储库的约定

var assembly =
    typeof(SqlProductRepository).Assembly;  <-- 选择约定的程序集
    
var repositoryTypes =
    from type in assembly.GetTypes()
    where !type.Abstract
    where type.Name.EndsWith("Repository")
    select type;  <--定义一个LINQ查询,以查找程序集中符合具体且以Repository结尾的所有类型的所有类型

foreach (Type type in repositoryTypes)
{
    container.Register(
        type.GetInterfaces().Single(), type);
}  <--遍历LINQ查询以注册每种类型

每个在迭代过程中通过where过滤器的类都应针对其接口进行注册。 例如,由于SqlProductRepository的接口是IProductRepository,因此最终将成为从IProductRepositorySqlProductRepository的映射。

此特定约定将扫描包含数据访问组件的程序集。 您可以通过多种方式获得对该程序集的引用,但最简单的方法是选择一个代表性类型,例如SqlProductRepository,然后从中获取程序集,如清单12.10所示。 您也可以选择其他类或通过名称找到程序集。

使用Microsoft.Extensions.DependencyInjection,清单12.10的约定的代码几乎相同。 只有foreach循环的主体会有所不同,因为这是DI容器(DI Container)的API唯一被调用的地方。

将此约定与清单12.9中的四个注册进行比较,您可能会认为此约定的好处似乎微不足道。 确实,由于当前示例中只有四个数据访问组件,因此代码语句的数量随着约定的增加而增加。 但是,这种约定的伸缩性要好得多。 编写后,它可以处理数百个组件,而无需任何额外的工作。

您也可以使用约定处理清单12.6和12.8中的其他映射,但是这样做没有太大价值。 例如,您可以使用以下约定注册所有服务:

var assembly = typeof(ProductService).Assembly;
var serviceTypes =
    from type in assembly.GetTypes()
    where !type.Abstract
    where type.Name.EndsWith("Service")
    select type;
foreach (Type type in serviceTypes)
{
    container.Register(type.GetInterfaces().Single(), type);
}

该约定将扫描标识的程序集以查找名称以Service结尾的所有具体类,并根据其实现的接口注册每种类型。 这样可以有效地在IProductService接口上注册ProductService,但是由于您目前没有与此约定匹配的其他任何内容,因此无法获得任何收益。 如清单12.9所示,只有添加了更多服务后,制定约定才有意义。

对于所有从它们自己的接口派生的类型,使用LINQ手动定义约定可能是有意义的,就像您之前在存储库中看到的那样。 但是,当您开始注册基于通用接口的类型时(如我们在10.3.3节中广泛讨论的那样),该策略就会开始迅速瓦解-通过反射查询通用类型通常不是一件令人愉快的事情。

因此,Simple Injector的自动注册API围绕基于通用抽象的类型注册而构建,例如清单10.12中的ICommandService <TCommand>接口。 Simple Injector允许在单行代码中完成所有ICommandService <TCommand>实现的注册。

清单12.11 基于通用抽象的自动注册实现

Assembly assembly = typeof(AdjustInventoryService).Assembly;
container.Register(typeof(ICommandService<>), assembly);

ICommandService <>是用于指定开放通用版本的C#语法,可通过省略TCommand通用类型参数来实现。

通过向其重载之一提供程序集列表,Simple Injector遍历这些程序集以查找实现ICommandService <TCommand>的任何非泛型,具体类型,同时通过其特定的ICommandService <TCommand>接口注册每种类型。 这具有使用实际类型填充的通用类型参数TCommand

定义 填充了泛型类型参数的泛型类型(例如ICommandService <AdjustInventory>)被称为封闭泛型。 同样,当您仅具有通用类型定义本身(例如ICommandService <TCommand>)时,此类类型称为开放通用。

在具有四个ICommandService <TCommand>实现的应用程序中,先前的API调用将等效于以下的 配置作为代码 列表。

清单12.12使用配置作为代码(Configuration as Code)注册实现

container.Register(typeof(ICommandService<AdjustInventory>),
                   typeof(AdjustInventoryService));
container.Register(typeof(ICommandService<UpdateProductReviewTotals>),
                   typeof(UpdateProductReviewTotalsService));
container.Register(typeof(ICommandService<UpdateHasDiscountsApplied>),
                   typeof(UpdateHasDiscountsAppliedService));
container.Register(typeof(ICommandService<UpdateHasTierPricesProperty>),
                   typeof(UpdateHasTierPricesPropertyService));

但是,迭代程序集列表以查找适当的类型并不是您可以通过Simple Injector的自动注册API唯一实现的。 另一个强大的功能是通用装饰器的注册,就像您在清单10.15、10.16和10.19中看到的那样。 与清单10.21一样,无需手动构成装饰器的层次结构,Simple Injector允许使用其Register 装饰器方法重载来应用装饰。

清单12.13 使用自动注册来注册通用装饰器

container.RegisterDecorator(
    typeof(ICommandService<>),
    typeof(AuditingCommandServiceDecorator<>));

container.RegisterDecorator(
    typeof(ICommandService<>),
    typeof(TransactionCommandServiceDecorator<>));

container.RegisterDecorator(
    typeof(ICommandService<>),
    typeof(SecureCommandServiceDecorator<>));

<---------RegisterDecorator提供了开放通用的ICommandService <TCommand>服务类型和Decorator的opengeneric实现。 使用此信息,Simple Injector会将使用解析器解析的每个ICommandService <TCommand>包装起来。

Simple Injector按注册顺序应用装饰器,这意味着对于清单12.13,审核装饰器使用事务装饰器进行包装,而事务装饰器则使用安全装饰器进行包装,从而得到与所示的对象图相同的对象图。 在清单10.21。

开放通用类型的注册可以看作是自动注册的一种形式,因为对RegisterDecorator的单个方法调用会导致将装饰器应用于许多注册。7如果没有针对通用Decorator类的这种自动注册形式,您将 强制为每个封闭的ICommandService <TCommand>实现分别注册每个装饰器的每个封闭版本,如下面的清单所示。

清单12.14 使用配置作为代码注册通用装饰器 (坏代码)

container.RegisterDecorator(
    typeof(ICommandService<AdjustInventory>),
    typeof(AuditingCommandServiceDecorator<AdjustInventory>));
container.RegisterDecorator(
    typeof(ICommandService<AdjustInventory>),
    typeof(TransactionCommandServiceDecorator<AdjustInventory>));
container.RegisterDecorator(
    typeof(ICommandService<AdjustInventory>),
    typeof(SecureCommandServiceDecorator<AdjustInventory>));
container.RegisterDecorator(
    typeof(ICommandService<UpdateProductReviewTotals>),
    typeof(AuditingCommandServiceDecorator<UpdateProductReviewTotals>));
container.RegisterDecorator(
    typeof(ICommandService<UpdateProductReviewTotals>),
    typeof(TransactionCommandServiceDecorator<UpdateProductReviewTotals>));
container.RegisterDecorator(
    typeof(ICommandService<UpdateProductReviewTotals>),
    typeof(SecureCommandServiceDecorator<UpdateProductReviewTotals>));
container.RegisterDecorator(
    typeof(ICommandService<UpdateHasDiscountsApplied>),
    typeof(AuditingCommandServiceDecorator<UpdateHasDiscountsApplied>));
container.RegisterDecorator(
    typeof(ICommandService<UpdateHasDiscountsApplied>),
    typeof(TransactionCommandServiceDecorator<UpdateHasDiscountsApplied>));
container.RegisterDecorator(
    typeof(ICommandService<UpdateHasDiscountsApplied>),
    typeof(SecureCommandServiceDecorator<UpdateHasDiscountsApplied>));
...  <--为了简洁起见,省略了其他注册。

此清单中的代码繁琐且容易出错。 此外,它将导致组合根(Composition Root)指数增长。

提示 自动注册最突出的缺点是您失去了一些控制权。 必须能够自动连线由自动注册工具拾取的每个组件。 如果有需要手动接线的特定组件,则应将其排除在自动注册之外,以防止出错。

在遵循SOLID原则的系统中,您创建了许多小型且重点突出的类,但是现有的类更改的可能性较小,从而提高了可维护性。 自动注册可防止组合根(Composition Root)不断更新。 这是一项强大的技术,有可能使DI容器(DI Container)不可见。 一旦适当的约定到位,您可能仅在极少数情况下才需要修改容器配置。

混合和匹配配置方法(Mixing and matching configuration approaches)

到目前为止,您已经看到了三种不同的配置DI容器(DI Container)的方法:

  • 配置文件
  • 配置为代码
  • 自动注册

这些都不是互斥的。 您可以选择将“自动注册”与特定的抽象到具体类型的映射混合使用,甚至可以混合使用所有三种方法以具有某些“自动注册”,“一些配置为代码”以及某些配置文件以用于后期绑定(Late binding)。

根据经验,您应该首选自动注册作为起点,并以配置为代码的形式进行补充以处理更多特殊情况。 您应该保留配置文件,以用于需要在不重新编译应用程序的情况下更改实现的情况,这种情况比您想像的要少。

何时使用DI容器(When to use a DI Container)

在本书的前面各部分中,我们仅将Pure DI用作对象组合的方法。 这不仅是出于教育目的。 单独使用Pure DI即可构建完整的应用程序。

在12.2节中,我们讨论了DI容器(DI Container)的不同配置方法,以及如何使用自动注册来增加您的成分根的可维护性。 但是,与Pure DI相比,使用DI容器(DI Container)会带来额外的成本和缺点。 大部分(如果不是全部)DI容器(DI Container)都是开源的,因此从金钱的角度来说它们是免费的。 但是,由于开发人员的时间通常是软件开发中最昂贵的部分,因此增加开发和维护软件时间的任何事情都是成本,这就是我们在这里要谈到的。

在本节中,我们将比较优点和缺点,以便您可以就何时使用DI容器(DI Container)以及何时坚持使用Pure DI做出明智的决定。 让我们从使用诸如DI容器(DI Container)之类的库时经常被忽视的方面开始,这会带来成本和风险。

使用第三方库涉及成本和风险(Using third-party libraries involves costs and risks)

如果从金钱的角度来看图书馆是免费的,那么我们的开发人员往往会忽略使用它所涉及的其他成本。 DI容器(DI Container)可能被视为稳定依赖项(第1.3.1节),因此从DI角度来看,使用容器不是问题。 但是还有其他需要考虑的问题。 与任何第三方库一样,使用DI容器(DI Container)会带来成本和风险。

在任何图书馆中,最明显的成本就是它的学习曲线-学习使用新图书馆需要花费时间。 您必须学习其API,其行为,其怪癖及其局限性。 当您与一组开发人员在一起时,他们中的大多数人将必须了解如何以一种或另一种方式使用该库。 仅由一个知道如何使用该工具的开发人员可以在短期内节省成本,但是这种做法本身就是对项目连续性的责任。

图书馆的行为,怪癖和局限性可能并不完全符合您的需求。 图书馆可能会选择与您的软件所基于的模型不同的模型。9这通常是在您学习使用它时才发现的。 将其应用于代码库时,您可能会发现需要实现各种变通办法。 这可能导致much牛剃须。

因此,由于学习成本通常难以实际估算,因此很难估计使用新图书馆将为项目节省多少资金。 学习第三方库的API所花费的累积时间不是构建应用程序本身所花费的时间,因此代表了实际成本。

除了学习与图书馆合作的直接费用外,依赖此类图书馆还涉及一些风险。 一个风险是开发人员将停止维护和支持您正在使用的库。10发生此类事件时,这会给项目带来额外的成本,因为它可能会迫使您切换库。 在这种情况下,您将再次支付先前讨论的学习费用,以及再次迁移和测试应用程序的额外费用。

提示 由于存在这些成本和风险,因此应谨慎选择适合您项目的库。 因此,在开始新项目时,为减轻风险,建议限制团队需要熟悉的外部库的数量。

这听起来像是反对使用外部库的论点,但事实并非如此。 如果没有外部库,您将无能为力,因为您必须重新发明轮子。 如果不使用外部库意味着自己建立一个这样的库,那么情况通常会更糟。 (而且我们的开发人员往往低估了编写,测试和维护这样的软件所需的时间。)

但是,使用DI容器(DI Container)时,情况会有所不同。 这是因为使用外部DI容器(DI Container)库的替代方法不是构建自己的DI容器(DI Container)库,而是应用Pure DI。

不要建立自己的DI容器(DI Container)

乍一看,清单12.1似乎暗示可以用几行代码编写DI容器(DI Container)。 尽管清单12.1概述了编写DI容器(DI Container)的第一步,但是有一个明显的原因将其标记为错误代码。

如我们前面所述,清单12.1中的代码是一个幼稚的实现,它缺少许多关键功能。 一个功能齐全的DI容器(DI Container)应支持生命周期管理,拦截,自动注册和依赖周期检测。 有效传达配置错误; 具有适当设计的可扩展性点; 依靠出色的文档; 还有更多。 这将是您在几周内无法做到的事情。

根据经验,我(史蒂文)可以告诉您,这样的库要稳定和成熟需要花费数年的时间。 尽管这对您作为开发人员而言可能是一次很棒的学习经历,但这对您的项目或公司没有帮助,因为您的重点应该放在创造业务价值上。

这并不意味着您永远不应创建新的开源库,例如DI容器(DI Container)。 创新是我们行业的重要方面,新图书馆的创建对此有所帮助。 有时我们需要激进的新想法,这有时意味着我们需要基于这些想法构建新的库和框架。 但是,您应该谨慎使用雇主的钱,因为这会使雇主付出的费用比您最初设想的要多。

正如您在4.1节中所了解的那样,与DI容器(DI Container)的交互应仅限于组合根(Composition Root)。 这已经降低了必须更换时的风险。 但是即使在这种情况下,替换DI容器(DI Container)并熟悉新的API和设计原理也可能是一项耗时的工作。

Pure DI的主要优点是易于学习。 您无需学习任何DI容器(DI Container)的API,尽管各个类仍在使用DI,但一旦找到了组合根(Composition Root),就可以清楚地了解正在发生的事情以及如何构造对象图。 尽管较新的IDE可以解决这个问题,但是使用DI容器(DI Container)时,团队中的新开发人员可能很难理解构造的对象图并难以找到类的依赖关系的实现。

使用Pure DI时,这几乎没有问题,因为对象图构造在“组合根(Composition Root)”中进行了硬编码。 除了易于学习之外,Pure DI还为您提供了更短的反馈周期,以防万一您的对象组成出现错误。 接下来让我们看看。

Pure DI可缩短反馈周期

DI容器(DI Container)技术(例如自动布线和自动注册)取决于反射的使用。 这意味着,在运行时,DI容器(DI Container)将使用反射来分析构造函数参数,甚至通过完整的程序集查询以基于约定查找类型以构成完整的对象图。 因此,仅在解决对象图时在运行时检测到配置错误。 与Pure DI相比,DI Container承担了编译器的代码验证角色。

重要 Pure DI具有一个经常被忽视的巨大优势:它的类型很严格。 这使编译器可以提供有关正确性的反馈,这是您可以获得的最快的反馈。

当组成根结构良好,以使创建单例对象和带作用域的实例分开时(例如,参见清单8.10和8.13),它允许编译器检测强制依赖性,如第8.4.1节中所述。

正如我们在3.2.2节中讨论的那样,由于具有强类型,Pure DI还具有使您更清晰地了解应用程序对象图结构的优点。 当您开始使用DI容器(DI Container)时,这会立即丢失。

但是强类型化可以同时实现这两种方式,因为正如我们在12.1.3节中讨论的那样,这也意味着每次重构构造函数时,都将破坏组合根(Composition Root)。 如果要在应用程序之间共享库(域模型,实用程序,数据访问组件等),则可能需要维护多个组合根(Composition Root)。 这有多大的负担取决于您重构构造函数的频率,但是我们已经看到一些项目,这种情况每天发生几次。 在多个开发人员在一个项目上工作的情况下,这很容易导致合并冲突,这需要花费时间来解决。

尽管编译器在使用Pure DI时会提供快速反馈,但是它可以进行的验证数量是有限的。 由于构造函数的更改以及某种程度上的强制性依赖关系,它可以报告缺少的依赖关系,但是,除其他外,它将无法检测到以下情况:

  • 由于从构造函数主体内引发的异常而导致构造函数调用失败(例如,失败的防御性语句(Guard Clause))
  • 一次性部件超出范围时是否进行处置
  • 当再次(偶然地)在组合根(Composition Root)的不同部分中再次创建了应该是Singleton或Scoped的类时,可能会采用不同的生活方式

使用Pure DI时,组合根(Composition Root)的大小随应用程序的大小线性增长。 当应用程序较小时,其组合根(Composition Root)也将较小。 这使其组合根(Composition Root)清洁且易于管理,并且先前列出的缺陷将很容易发现。 但是,当组合根(Composition Root)增大时,更容易错过此类缺陷。

使用DI容器(DI Container)可以减轻这种情况。 大多数DI容器(DI Container)会自动代表您检测一次性组件,并可能检测常见陷阱,例如专属依赖关系

结论:何时使用DI容器(DI Container)

如果您使用DI容器(DI Container)的“配置作为代码”功能(如第12.2.2节中所述),并使用容器的API显式注册每个组件,则会因强类型输入而失去快速反馈。 另一方面,由于自动布线,维护负担也有可能降低。 尽管如此,您在引入每个新类时仍需要注册,这是一个线性增长,并且您和您的团队必须学习该容器的特定API。 但是,即使您已经熟悉它的API,仍然存在有一天必须替换它的风险。 您可能会损失得更多。

最终,如果您可以以足够复杂的方式使用DI容器(DI Container),则可以使用它通过自动注册来定义一组约定(如第12.2.3节中所述)。 这些约定定义了您的代码应遵循的规则集,只要您遵守这些规则,事情就会奏效。 容器会掉到背景上,您几乎不需要触摸它。

重要 使用使用自动注册的约定优于配置可以将组合根(Composition Root)的维护量降到几乎为零。

自动注册需要花费一些时间来学习,并且输入的类型很弱,但是,如果操作正确,它可以使您专注于可增加价值的代码,而不是基础架构。 另一个优点是,它创建了积极的反馈机制,迫使团队生成与约定一致的代码。 图12.6可视化了Pure DI和使用DI容器(DI Container)之间的权衡。

正如我们在第12.2.4节中所述,没有可用的方法是互斥的。 尽管您可能会发现一个组合根(Composition Root)目录包含所有配置样式的混合,但是组合根(Composition Root)目录要么应该针对具有几种后期绑定(Late binding)类型的Pure DI,要么针对有限数量的自动注册。 配置为代码,Pure DI和配置文件。 围绕作为代码的配置的组合根是没有意义的,因此应避免使用。

图12.6 Pure DI可能很有价值,因为它很简单,尽管DI容器(DI Container)可能是有价值的,也可能是毫无意义的(取决于使用方式)。 如果以足够复杂的方式(通过自动注册)使用它,我们认为DI容器(DI Container)可提供最佳的性价比。
image

问题就变成了:您什么时候应该选择Pure DI,什么时候应该使用自动注册? 不幸的是,我们对此无法给出确切的数字。 这取决于项目的规模,您和您的团队对DI容器(DI Container)的经验量以及风险的计算。

但是,一般而言,您应将Pure DI用于较小的组合根(Composition Root),并在维护此类组合根(Composition Root)成为问题时切换到自动注册。 可以通过几种约定捕获的具有许多类的大型应用程序可以从使用自动注册中受益。

自动的(Automagical)

我(Mark)曾经为一个客户工作过,在该客户中我在代码库中应用了基于约定的自动注册。 其他开发人员对此不太满意,因为他们发现它太神奇了。 他们完全接受了DI并使用了TDD,但并不热衷于使用DI容器(DI Container),因为他们对它的API并不熟悉。

在许多情况下,这些约定按广告宣传进行工作。 当开发人员引入新的类或接口时,DI容器(DI Container)会发现新类型并正确配置它们。 但是,开发人员(包括我自己)有时会以惯例所无法预期的方式实现功能。 发生这种情况时,有必要调整约定。

其他开发人员不了解-并且对学习不感兴趣-如何使用DI容器(DI Container)的API,因此,每当需要更改时,我都必须实现它。 我成为了至关重要的资源,偶尔也成为了瓶颈。 当我离开该项目时,我希望其余的团队能够淘汰DI容器(DI Container)并将其替换为Pure DI。 一年后回来时,得知这正是他们所做的事情,我并不感到惊讶。 我不能说我怪他们。

我们不会告诉您的另一件事是选择哪个DI容器(DI Container)。 选择DI容器(DI Container)不仅仅涉及技术评估。 您还必须评估许可模型是否可以接受,您是否信任开发和维护DI容器(DI Container)的人员或组织,它是否适合组织的IT战略,等等。 您搜索正确的DI容器(DI Container)的范围也不应仅限于本书中列出的容器。 例如,可以选择许多用于.NET平台的出色DI容器(DI Container)。

如果正确使用DI容器(DI Container),则它可能是有用的工具。 要了解的最重要的一点是,DI的使用绝不依赖于DI容器(DI Container)的使用。 应用程序可以由许多松散耦合(Loose Coupling)的类和模块组成,而这些模块都不了解容器。 确保应用程序代码不识别任何DI容器(DI Container)的最有效方法是将其使用范围限制为组合根(Composition Root)。 这可以防止您无意中应用服务定位器反模式(Service Locator anti-pattern),因为它会将容器限制在较小的代码隔离区域中。

以这种方式使用的DI容器(DI Container)将成为负责处理应用程序部分基础结构的引擎。 它根据其配置组成对象图。 如果您采用约定优于配置,则这将特别有益。 如果实施得当,它可以用来组成对象图,并且您可以将精力集中在实现新功能上。 容器将自动发现遵循已建立约定的新类,并将其提供给消费者。 本书的最后三章介绍了Autofac(第13章),Simple Injector(第14章)和Microsoft.Extensions.DependencyInjection(第15章)。

总结

  • DI容器(DI Container)是提供DI功能的库。 它是解析和管理对象图的引擎。
  • DI绝不取决于DI容器(DI Container)的使用。 DI容器(DI Container)是有用但可选的工具。
  • 自动装配是通过利用编译器和公共语言运行时(CLR)提供的类型信息,根据抽象和具体类型之间的映射自动组成对象图的功能。
  • 构造函数注入(Constructor Injection)以静态方式通告类的依赖项要求,并且DI容器(DI Container)使用该信息将Auto-Wire复杂对象图自动关联。
  • 自动装配使组合根(Composition Root)更灵活地进行更改。
  • 当您开始使用DI容器(DI Container)时,不需要完全放弃手工接线的对象图。 当更方便时,您可以在配置的某些部分中使用手工接线。
  • 使用DI容器(DI Container)时,三种配置样式是配置文件(Configuration files),配置作为代码(Configuration as Code)和自动注册(Auto-Registration)。
  • 配置文件与代码和自动注册一样,是配置根目录的一部分。 因此,使用配置文件不会使组合根(Composition Root)目录变小,它只会移动它。
  • 随着应用程序的大小和复杂性的增长,您的配置文件也将随之增长。 配置文件容易变脆并且对错误不透明,因此仅在需要后期绑定(Late binding)时才使用此方法。
  • 不要因为缺少处理配置文件的支持而影响选择DI容器(DI Container)的选择。 可以通过一些简单的语句从配置文件中加载类型。
  • 配置为代码允许将容器的配置存储为源代码。 抽象和特定实现之间的每个映射都直接在代码中明确表示。 除非需要后期绑定(Late binding),否则此方法优于配置文件。
  • 约定优于配置是将约定应用到您的代码中,以简化注册过程。
  • 自动注册(Auto-Registration)是通过扫描一个或多个程序集以实现所需抽象的实现而自动在容器中注册组件的能力,这是约定优于配置的一种形式。
  • 自动注册(Auto-Registration)有助于避免不断更新组合根(Composition Root),因此,它比配置为代码更可取。
  • 使用诸如DI容器(DI Container)之类的外部库会产生成本和风险; 例如,学习新API的成本以及库被遗弃的风险。
  • 避免构建自己的DI容器(DI Container)。 您可以使用现有的,经过良好测试且免费提供的DI容器(DI Container)之一,也可以使用Pure DI。 创建和维护这样的库需要大量的精力,而这并不是为创造业务价值而花费的精力。
  • Pure DI的最大优势在于它的类型很强。 这使编译器可以提供有关正确性的反馈,这是您可以获得的最快的反馈。
  • 您应该将Pure DI用于较小的组合根(Composition Root),并在维护此类组合根(Composition Root)成为问题时切换到自动注册。 可以通过几种约定捕获的具有许多类的大型应用程序可以从使用自动注册中受益匪浅。
posted @ 2022-09-04 10:20  F(x)_King  阅读(629)  评论(0编辑  收藏  举报