5.3 被约束的构造器(Constrained Construction)
正确实施DI的最大挑战是让所有带有依赖的类移到一个组合根。当我们完成这个任务时,我们已经走在了前面。
即便如此,仍然有一些陷阱需要注意。一个常见的错误是要求所有的依赖都有一个具有特定签名的构造函数。这通常是由于希望达到后期绑定的目的,这样依赖就可以在外部的配置文件中定义,从而在不重新编译应用程序的情况下进行修改。
注意:ASP.NET中使用的所谓Provider模式是构造器注入的一个例子,因为Provider必须有默认的构造函数。这通常会因为Provider的构造函数试图从应用程序的配置文件中读取而加剧这种情况。 通常,如果所需的配置部分不可用,构造器会抛出一个异常。
注意: 本节仅适用于需要后期绑定的情况。在我们直接引用应用程序根部的所有依赖的情况下,我们没有这个问题,但话说回来,我们也没有能力在不重新编译的情况下替换依赖。
在第三章中,我们简要地谈到了这个问题。本节将对其进行更仔细的研究。
5.3.1 实例:后期绑定 ProductRepository
在示例的商业应用程序中,一些类依赖于抽象的ProductRepository类。 这意味着为了创建这些类,我们首先需要创建ProductRepository的一个实例。在这一点上,你已经知道组合根是做这件事的正确位置。在ASP.NET应用程序中,这意味着Global.asax;下面的列表显示了创建ProductRepository实例的相关部分。
Listing 5.3 隐式的约束了ProductRepository构造函数
string connectionString = ConfigurationManager.ConnectionStrings["CommerceObjectContext"].ConnectionString;
string productRepositoryTypeName = ConfigurationManager.AppSettings["ProductRepositoryType"];
var productRepositoryType = Type.GetType(productRepositoryTypeName, true);
//创建一个具体类的实例
var repository = (ProductRepository) Activator.CreateInstance(productRepositoryType, connectionString);
首先应该引起怀疑的是,从web.config文件中读取了一个连接字符串。 如果你打算把产品仓库当作一个抽象,为什么还需要一个连接字符串?虽然这不太可能,但你可以选择用内存数据库或 XML 文件来实现 ProductRepository。 基于REST的存储服务,如Windows Azure表存储服务,提供了一个更现实的选择,但今年,最流行的选择似乎是关系型数据库。 数据库的普遍性使得人们很容易忘记,连接字符串隐含着一种实现选择。
要后期绑定一个 ProductRepository,你还需要确定选择哪种类型作为实现。 这可以通过从 web.config 中读取一个符合汇编条件的类型名称,并从该名称中创建一个 Type 实例来完成。这本身并没有问题,只有当你需要创建该类型的实例时才会出现困难。
给定一个类型,你可以使用激活器(Activator)类创建一个实例。创建实例方法会调用该类型的构造函数,所以你必须提供正确的构造函数参数以防止抛出异常。 在这种情况下,你提供了一个连接字符串。
如果你除了知道ListIng 5.3中的代码外,对这个应用程序一无所知,那么你现在应该想知道为什么一个连接字符串会作为构造参数传递给一个未知的类型。 如果这个实现是基于一个基于REST的网络服务或一个XML文件,那就没有什么意义了。
事实上,这确实没有意义,因为这代表了对依赖构造函数的一个意外约束。 在这种情况下,你有一个隐含的要求,即任何 ProductRepository 的实现都应该有一个接受单一的字符串作为输入的构造函数。这是对显式约束的补充,即该类必须从 ProductRepository 派生。
注意:构造函数应该接受一个单一的字符串的隐含约束仍然给我们留下了很大的灵活性,因为我们可以在字符串中编码很多不同的信息,以便以后解码。想象一下,如果这个约束是一个需要一个TimeSpan和一个数字的构造函数,你就可以开始想象这有多大的限制。
你可以说,基于 XML 文件的 ProductRepository 也需要一个字符串作为构造参数,尽管这个字符串是一个文件名,而不是一个连接字符串。 然而,从概念上讲,这仍然很奇怪,因为你必须在 web.config 的 connectionStrings 元素中定义文件名(无论如何,我认为这样一个假设的 XmlProductRepository 应该使用 XmlReader 作为构造函数参数,而不是文件名)。
完全在显式约束上建立依赖结构模型(接口或base类)是一个更好、更灵活的选择。
5.3.2 分析
在前面的例子中,隐式约束要求实现者必须有一个带有单个字符串参数的构造函数。一个更常见的约束是,所有的实现者都应该有一个默认的构造函数,所以最简单的Activator.CreateInstance的形式就可以了。
var dep = (ISomeDependency)Activator.CreateInstance(type);
虽然这可以说是最低的共同标准,但在灵活性方面的成本太高。
影响
无论我们如何约束对象的构造,我们都会失去灵活性。声明所有的依赖实现都应该有一个默认的构造器可能是很诱人的,毕竟它们可以在内部执行初始化,比如直接从.config文件中读取配置数据,如配置字符串。 然而,这将在其他方面限制我们,因为我们可能希望能够组成一个由封装其他实例的多层实例组成的应用程序。例如,在某些情况下,我们可能希望在不同的消费者之间共享一个实例,如图5.4中所示。
当我们有多个需要相同依赖的类时,我们可能想在所有这些类*享一个实例。 只有当我们可以从外部注入该实例时,这才有可能。 尽管我们可以在每个类中编写代码,从配置文件中读取类型信息,并使用Activator.CreateInstance来创建正确的实例类型,但我们永远无法以这种方式共享一个实例–相反,我们会有同一个类的多个实例,占用更多的内存。
注意:仅仅因为DI允许我们在许多成员之间共享一个实例,并不意味着我们应该总是这样做。共享一个实例可以节省内存,但可能会带来与交互有关的问题,比如线程问题。我们是否希望共享一个实例与对象生命周期的概念密切相关,这将在第8章讨论。
我们不应该对对象的构造方式施加隐性约束,而应该实现我们的组合根,使其能够处理我们可能扔给它的任何类型的构造函数或工厂方法。
朝DI的方向重构
当我们需要后期绑定时,我们如何处理对组件的构造器没有约束的问题呢? 引入一个可以用来创建所需抽象实例的抽象工厂,然后要求这些抽象工厂的实现有默认的构造函数,这也许很有诱惑力,但这样做很可能是在没解决根本问题的情况下将其问题转移。
警告: 尽管我们可以使用抽象工厂来成功地实现绑定,但这样做需要遵守一些规则。 一般来说,我们最好使用正确的DI 容器;但我还是要描绘出如何用艰难的方式来做。
让我们简单地研究一下这样的方法。想象一下,你有一个服务抽象imaginatively叫做ISomeService。抽象工厂方案决定了你还需要一个 ISomeServiceFactory 接口。图5.5说明了这种结构。
现在让我们假设你希望使用ISomeService的一个实现,它需要ISomeRepository的一个实例来工作,如下面的列表所示。
Listing5.4 需要ISomeRepository的某些服务
public class SomeService: ISomeService {
public SomeService(ISomeRepository repository) {}
}
SomeService类实现了ISomeService接口,但需要一个ISomeRepository的实例。因为唯一的构造函数不是默认的构造函数,ISomeServiceFactory将派上用场。
目前,你想使用一个基于实体框架的ISomeRepository的实现。 你称这个实现为SomeEntityRepository,它被定义在一个与SomeService不同的程序集中。
因为你不想把对EntityDataAccess库的引用和SomeService拖在一起,唯一的解决办法是在一个与SomeService不同的程序集中实现SomeServiceFactory,如图5.6所示。
尽管ISomeService和ISomeServiceFactory看起来是一对有凝聚力的组合,但在两个不同的程序集中实现它们是很重要的,因为工厂必须有对所有依赖的引用才能正确地将它们连在一起。
按照惯例,ISomeServiceFactory的实现有一个默认的构造函数,所以你可以在.config文件中写上符合汇编条件的类型名称,并使用Activator.CreateInstance来创建一个实例。 每当你需要连接新的依赖关系组合时,你必须实现一个新的ISomeServiceFactory,准确地连接该组合,然后将应用程序配置为使用该工厂而不是之前的工厂。 这意味着你不能在不编写和编译代码的情况下定义任意的依赖组合,但你可以不重新编译应用程序本身。
从本质上讲,这样的抽象工厂成为一个抽象的组合ROOT,它被定义在一个独立于核心应用程序的程序集中。尽管这当然是一个可行的方法,但一般来说,利用一个通用的DI 容器要容易得多,它可以根据配置文件为我们做所有这些事情。
被约束的构造器(Constrained Construction)反模式只有在我们采用后期绑定时才真正适用,因为当我们利用早期绑定时,编译器会确保我们不会对组件的构造方式引入隐性约束。
最后一种模式的适用范围更广,有些人甚至认为它是一种适当的模式,而不是一种反模式。