第6章 领域对象的生命周期

每个对象都有生命周期,如图6-1所示。对象自创建后,可能会经历各种不同的状态,直至最终消亡——要么存档,要么删除。当然,很多对象是简单的临时对象,仅通过调用构造函数来创建,用来做一些计算,而后由垃圾收集器回收。这类对象没必要搞得那么复杂。但有些对象具有更长的生命周期,其中一部分时间不是在活动内存中度过的。它们与其他对象具有复杂的相互依赖性。它们会经历一些状态变化,在变化时要遵守一些固定规则。管理这些对象时面临诸多挑战,稍有不慎就会偏离MODEL-DRIVEN DESIGN的轨道。

第6章 领域对象的生命周期 - 图1 图6-1 领域对象的生命周期

主要的挑战有以下两类。

(1) 在整个生命周期中维护完整性。

(2) 防止模型陷入管理生命周期复杂性造成的困境当中。

本章将通过3种模式解决这些问题。首先是AGGREGATE(聚合),它通过定义清晰的所属关系和边界,并避免混乱、错综复杂的对象关系网来实现模型的内聚。聚合模式对于维护生命周期各个阶段的完整性具有至关重要的作用。

接下来,我们将注意力转移到生命周期的开始阶段,使用FACTORY(工厂)来创建和重建复杂对象和AGGREGATE(聚合),从而封装它们的内部结构。最后,在生命周期的中间和末尾使用REPOSITORY(存储库)来提供查找和检索持久化对象并封装庞大基础设施的手段。

尽管REPOSITORY和FACTORY本身并不是来源于领域,但它们在领域设计中扮演着重要的角色。这些结构提供了易于掌握的模型对象处理方式,使MODEL-DRIVEN DESIGN更完备。

使用AGGREGATE进行建模,并且在设计中结合使用FACTORY和REPOSITORY,这样我们就能够在模型对象的整个生命周期中,以有意义的单元、系统地操纵它们。AGGREGATE可以划分出一个范围,这个范围内的模型元素在生命周期各个阶段都应该维护其固定规则。FACTORY和REPOSITORY在AGGREGATE基础上进行操作,将特定生命周期转换的复杂性封装起来。

6.1 模式:AGGREGATE

第6章 领域对象的生命周期 - 图2

减少设计中的关联有助于简化对象之间的遍历,并在某种程度上限制关系的急剧增多。但大多数业务领域中的对象都具有十分复杂的联系,以至于最终会形成很长、很深的对象引用路径,我们不得不在这个路径上追踪对象。在某种程度上,这种混乱状态反映了现实世界,因为现实世界中就很少有清晰的边界。但这却是软件设计中的一个重要问题。

假设我们从数据库中删除一个Person对象。这个人的姓名、出生日期和工作描述要一起被删除,但要如何处理地址呢?可能还有其他人住在同一地址。如果删除了地址,那些Person对象将会引用一个被删除的对象。如果保留地址,那么垃圾地址在数据库中会累积起来。虽然自动垃圾收集机制可以清除垃圾地址,但这也只是一种技术上的修复;就算数据库系统存在这种处理机制,一个基本的建模问题依然被忽略了。

即便是在考虑孤立的事务时,典型对象模型中的关系网也使我们难以断定一个修改会产生哪些潜在的影响。仅仅因为存在依赖就更新系统中的每个对象,这样做是不现实的。

在多个客户对相同对象进行并发访问的系统中,这个问题更加突出。当很多用户对系统中的对象进行查询和更新时,必须防止他们同时修改互相依赖的对象。范围错误将导致严重的后果。

在具有复杂关联的模型中,要想保证对象更改的一致性是很困难的。不仅互不关联的对象需要遵守一些固定规则,而且紧密关联的各组对象也要遵守一些固定规则。然而,过于谨慎的锁定机制又会导致多个用户之间毫无意义地互相干扰,从而使系统不可用。

换句话说,我们如何知道一个由其他对象组成的对象从哪里开始,又到何处结束呢?在任何具有持久化数据存储的系统中,对数据进行修改的事务必须要有范围,而且要有保持数据一致性的方式(也就是说,保持数据遵守固定规则)。数据库支持各种锁机制,而且可以编写一些测试来验证。但这些特殊的解决方案分散了人们对模型的注意力,很快人们就会回到“走一步,看一步”的老路上来。

实际上,要想找到一种兼顾各种问题的解决方案,要求对领域有深刻的理解,例如,要了解特定类实例之间的更改频率这样的深层次因素。我们需要找到一个使对象间冲突较少而固定规则联系更紧密的模型。

尽管从表面上看这个问题是数据库事务方面的一个技术难题,但它的根源却在模型,归根结底是由于模型中缺乏明确定义的边界。从模型得到的解决方案将使模型更易于理解,并且使设计更易于沟通。当模型被修改时,它将引导我们对实现做出修改。

人们已经开发出很多模式(scheme)来定义模型中的所属关系。下面这个简单但严格的系统就提炼自这些概念,其包括一组用于实现事务(这些事务用来修改对象及其所有者)的规则[7]

首先,我们需要用一个抽象来封装模型中的引用。AGGREGATE就是一组相关对象的集合,我们把它作为数据修改的单元。每个AGGREGATE都有一个根(root)和一个边界(boundary)。边界定义了AGGREGATE的内部都有什么。根则是AGGREGATE所包含的一个特定ENTITY。对AGGREGATE而言,外部对象只可以引用根,而边界内部的对象之间则可以互相引用。除根以外的其他ENTITY都有本地标识,但这些标识只在AGGREGATE内部才需要加以区别,因为外部对象除了根ENTITY之外看不到其他对象。

汽车修配厂的软件可能会使用汽车模型。如图6-2所示。汽车是一个具有全局标识的ENTITY:我们需要将这部汽车与世界上所有其他汽车区分开(即使是一些非常相似的汽车)。我们可以使用车辆识别号来进行区分,车辆识别号是为每辆新汽车分配的唯一标识符。我们可能想通过4个轮子的位臵跟踪轮胎的转动历史。我们可能想知道每个轮胎的里程数和磨损度。要想知道哪个轮胎在哪儿,必须将轮胎标识为ENTITY。当脱离这辆车的上下文后,我们很可能就不再关心这些轮胎的标识了。如果更换了轮胎并将旧轮胎送到回收厂,那么软件将不再需要跟踪它们,它们会成为一堆废旧轮胎中的一部分。没有人会关心它们的转动历史。更重要的是,即使轮胎被安在汽车上,也不会有人通过系统查询特定的轮胎,然后看看这个轮胎在哪辆汽车上。人们只会在数据库中查找汽车,然后临时查看一下这部汽车的轮胎情况。因此,汽车是AGGREGATE的根ENTITY,而轮胎处于这个AGGREGATE的边界之内。另一方面,发动机组上面都刻有序列号,而且有时是独立于汽车被跟踪的。在一些应用程序中,发动机可以是自己的AGGREGATE的根。

第6章 领域对象的生命周期 - 图3 图6-2 本地标识与全局标识及对象引用

固定规则(invariant)是指在数据变化时必须保持的一致性规则,其涉及AGGREGATE成员之间的内部关系。而任何跨越AGGREGATE的规则将不要求每时每刻都保持最新状态。通过事件处理、批处理或其他更新机制,这些依赖会在一定的时间内得以解决。但在每个事务完成时,AGGREGATE内部所应用的固定规则必须得到满足,如图6-3所示。

现在,为了实现这个概念上的AGGREGATE,需要对所有事务应用一组规则。

根ENTITY具有全局标识,它最终负责检查固定规则。

根ENTITY具有全局标识。边界内的ENTITY具有本地标识,这些标识只在AGGREGATE内部才是唯一的。

AGGREGATE外部的对象不能引用除根ENTITY之外的任何内部对象。根ENTITY可以把对内部ENTITY的引用传递给它们,但这些对象只能临时使用这些引用,而不能保持引用。根可以把一个VALUE OBJECT的副本传递给另一个对象,而不必关心它发生什么变化,因为它只是一个VALUE,不再与AGGREGATE有任何关联。

作为上一条规则的推论,只有AGGREGATE的根才能直接通过数据库查询获取。所有其他对象必须通过遍历关联来发现。

AGGREGATE内部的对象可以保持对其他AGGREGATE根的引用。

删除操作必须一次删除AGGREGATE边界之内的所有对象。(利用垃圾收集机制,这很容易做到。由于除根以外的其他对象都没有外部引用,因此删除了根以后,其他对象均会被回收。)

当提交对AGGREGATE边界内部的任何对象的修改时,整个AGGREGATE的所有固定规则都必须被满足。

第6章 领域对象的生命周期 - 图4 图6-3 AGGREGATE的固定规则

我们应该将ENTITY和VALUE OBJECT分门别类地聚集到AGGREGATE中,并定义每个AGGREGATE的边界。在每个AGGREGATE中,选择一个ENTITY作为根,并通过根来控制对边界内其他对象的所有访问。只允许外部对象保持对根的引用。对内部成员的临时引用可以被传递出去,但仅在一次操作中有效。由于根控制访问,因此不能绕过它来修改内部对象。这种设计有利于确保AGGREGATE中的对象满足所有固定规则,也可以确保在任何状态变化时AGGREGATE作为一个整体满足固定规则。

有一个能够声明AGGREGATE的技术框架是很有帮助的,这样就可以自动实施锁机制和其他一些功能。如果没有这样的技术框架,团队就必须靠自我约束来使用事先商定的AGGREGATE,并按照这些AGGREGATE来编写代码。

示例 采购订单的完整性

考虑一个简化的采购订单系统(见图6-4)可能具有的复杂性。

第6章 领域对象的生命周期 - 图5 图6-4 一个采购订单系统的模型

图6-4展示了一个典型的采购订单(Purchase Order,PO)视图,它被分解为采购项(Line Item),一条固定规则是采购项的总量不能超过PO总额的限制。当前实现存在以下3个互相关联的问题。

(1) 固定规则的实施。当添加新采购项时,PO检查总额,如果新增的采购项使总额超出限制,则将PO标记为无效。正如我们将要看到的那样,这种保护机制并不充分。

(2) 变更管理。当PO被删除或存档时,各个采购项也将被一块处理,但模型并没有给出关系应该在何处停止。在不同时间更改部件(Part)价格所产生的影响也不明确。

(3) 数据库共享。数据库会出现由于多个用户竞争使用而带来的问题。

多个用户将并发地输入和更新各个PO,因此必须防止他们互相干扰。让我们从一个非常简单的策略开始,当一个用户开始编辑任何一个对象时,锁定该对象,直到用户提交事务。这样,当George编辑采购项001时,Amanda就无法访问该项。Amanda可以编辑其他PO上的任何采购项(包括George正在编辑的PO上的其他采购项),如图6-5所示。

第6章 领域对象的生命周期 - 图6 图6-5 数据库中存储的PO的初始情形

每个用户都将从数据库读取对象,并在自己的内存空间中实例化对象,而后在那里查看和编辑对象。只有当开始编辑时,才会请求进行数据库锁定。因此,George和Amanda可以同时工作,只要他们不同时编辑相同的采购项即可。一切正常,直到George和Amanda开始编辑同一个PO上的不同采购项,如图6-6所示。

第6章 领域对象的生命周期 - 图7 图6-6 在不同事务中同时进行的编辑

从这两个用户和他们各自软件的角度来看,他们的操作都没有问题,因为他们忽略了事务期间数据库其他部分所发生的变化,而且每个用户都没有修改被对方锁定的采购项。

当这两个用户保存了修改之后,数据库中就存储了一个违反领域模型固定规则的PO。一条重要的业务规则被破坏了,但并没有人知道,如图6-7所示。

第6章 领域对象的生命周期 - 图8 图6-7 最后的PO超过了批准限额(破坏了固定规则)

显然,锁定单个行并不是一种充分的保护机制。如果一次锁定一个PO,可以防止这样的问题发生,如图6-8所示。

第6章 领域对象的生命周期 - 图9 图6-8 锁定整个PO可以确保满足固定规则

直到Amanda解决这个问题之前,程序将不允许保存这个事务,Amanda可以通过提高限额或减少一把吉他来解决此问题。这种机制防止了问题,如果大部分工作分布在多个PO上,那么这可能是个不错的解决方案。但如果是很多人同时对一个大PO的不同项进行操作时,这种锁定机制就显得很笨拙了。

即便是很多小PO,也存在其他方法破坏这条固定规则。让我们看看“Part”。如果在Amanda将长号加入订单时,有人更改了长号的价格,这不也会破坏固定规则吗?

那么,我们试着除了锁定整个PO之外,也锁定Part。图6-9展示了当George、Amanda和Sam在不同PO上工作时将会发生的情况。

第6章 领域对象的生命周期 - 图10 图6-9 过于谨慎的锁定会妨碍人们的工作

工作变得越来越麻烦,因为在Part上出现了很多争用的情况。这样就会发生图6-10中的结果:3个人都需要等待。

现在我们可以开始改进模型,在模型中加入以下业务知识。

(1) Part在很多PO中使用(会产生高竞争)。

(2) 对Part的修改少于对PO的修改。

(3) 对Price(价格)的修改不一定要传播到现有PO,它取决于修改价格时PO处于什么状态。

第6章 领域对象的生命周期 - 图11 图6-10 死锁

当考虑已经交货并存档的PO时,第三点尤为明显。它们显示的当然是填写时的价格,而不是当前价格。

第6章 领域对象的生命周期 - 图12 图6-11 price被复制到Line Item中,现在可以确保满足聚合的固定规则了

按照图6-11,这个模型得到的实现可以确保满足PO和采购项相关的固定规则,同时,修改部件的价格将不会立即影响引用部件的采购项。涉及面更广的规则可以通过其他方式来满足。例如,系统可以每天为用户列出价格过期的采购项,这样用户就可以决定是更新还是去掉采购项。但这并不是必须一直保持的固定规则。通过减少采购项对Part的依赖,可以避免争用,并且能够更好地反映出业务的现实情况。同时,加强PO与采购项之间的关系可以确保遵守这条重要的业务规则。

AGGREGATE强制了PO与采购项之间符合业务实际的所属关系。PO和采购项的创建及删除很自然地被联系在一起,而Part的创建和删除却是独立的。

AGGREGATE划分出一个范围,在这个范围内,生命周期的每个阶段都必须满足一些固定规则。接下来要讨论的两种模式FACTORY和REPOSITORY都是在AGGREGATE上执行操作,它们将特定生命周期转换的复杂性封装起来……

6.2 模式:FACTORY

第6章 领域对象的生命周期 - 图13

当创建一个对象或创建整个AGGREGATE时,如果创建工作很复杂,或者暴露了过多的内部结构,则可以使用FACTORY进行封装。

对象的功能主要体现在其复杂的内部配臵以及关联方面。我们应该一直对对象进行提炼,直到所有与其意义或在交互中的角色无关的内容被完全剔除为止。一个对象在它的生命周期中要承担大量职责。如果再让复杂对象负责自身的创建,那么职责过载将会导致问题。

汽车发动机是一种复杂的机械装臵,它由数十个零件共同协作来履行发动机的职责——使轴转动。我们可以试着设计一种发动机组,让它自己抓取一组活塞并塞到汽缸中,火花塞也可以自己找到插孔并把自己拧进去。但这样组装的复杂机器可能没有我们常见的发动机那样可靠或高效。相反,我们用其他东西来装配发动机。或许是机械师,或者是工业机器人。无论是机器人还是人,实际上都比二者要装配的发动机复杂。装配零件的工作与使轴旋转的工作完全无关。只是在生产汽车时才需要装配工,我们驾驶时并不需要机器人或机械师。由于汽车的装配和驾驶永远不会同时发生,因此将这两种功能合并到同一个机制中是毫无价值的。同理,装配复杂的复合对象的工作也最好与对象要执行的工作分开。

但将职责转交给另一个相关方——应用程序中的客户(client)对象——会产生更严重的问题。客户知道需要完成什么工作,并依靠领域对象来执行必要的计算。如果指望客户来装配它需要的领域对象,那么它必须要了解一些对象的内部结构。为了确保所有应用于领域对象各部分关系的固定规则得到满足,客户必须知道对象的一些规则。甚至调用构造函数也会使客户与所要构建的对象的具体类产生耦合。结果是,对领域对象实现所做的任何修改都要求客户做出相应修改,这使得重构变得更加困难。

当客户负责创建对象时,它会牵涉不必要的复杂性,并将其职责搞得模糊不清。这违背了领域对象及所创建的AGGREGATE的封装要求。更严重的是,如果客户是应用层的一部分,那么职责就会从领域层泄漏到应用层中。应用层与实现细节之间的这种耦合使得领域层抽象的大部分优势荡然无存,而且导致后续更改的代价变得更加高昂。

对象的创建本身可以是一个主要操作,但被创建的对象并不适合承担复杂的装配操作。将这些职责混在一起可能产生难以理解的拙劣设计。让客户直接负责创建对象又会使客户的设计陷入混乱,并且破坏被装配对象或AGGREGATE的封装,而且导致客户与被创建对象的实现之间产生过于紧密的耦合。

复杂的对象创建是领域层的职责,然而这项任务并不属于那些用于表示模型的对象。在有些情况下,对象的创建和装配对应于领域中的重要事件,如“开立银行账户”。但一般情况下,对象的创建和装配在领域中并没有什么意义,它们只不过是实现的一种需要。为了解决这一问题,我们必须在领域设计中增加一种新的构造,它不是ENTITY、VALUE OBJECT,也不是SERVICE。这与前一章的论述相违背,因此把它解释清楚很重要。我们正在向设计中添加一些新元素,但它们不对应于模型中的任何事物,而确实又承担领域层的部分职责。

每种面向对象的语言都提供了一种创建对象的机制(例如,Java和C++中的构造函数,Smalltalk中创建实例的类方法),但我们仍然需要一种更加抽象且不与其他对象发生耦合的构造机制。这就是FACTORY,它是一种负责创建其他对象的程序元素。如图6-12所示。

第6章 领域对象的生命周期 - 图14 图6-12 与FACTORY的基本交互

正如对象的接口应该封装对象的实现一样(从而使客户无需知道对象的工作机理就可以使用对象的功能),FACTORY封装了创建复杂对象或AGGREGATE所需的知识。它提供了反映客户目标的接口,以及被创建对象的抽象视图。

因此:

应该将创建复杂对象的实例和AGGREGATE的职责转移给单独的对象,这个对象本身可能没有承担领域模型中的职责,但它仍是领域设计的一部分。提供一个封装所有复杂装配操作的接口,而且这个接口不需要客户引用要被实例化的对象的具体类。在创建AGGREGATE时要把它作为一个整体,并确保它满足固定规则。

FACTORY有很多种设计方式。[Gamma et al.1995]中详尽论述了几种特定目的的创建模式,包括FACTORY METHOD(工厂方法)、ABSTRACT FACTORY(抽象工厂)和BUILDER(构建器)。该书主要研究了适用于最复杂的对象构造问题的模式。本书的重点并不是深入讨论FACTORY的设计问题,而是要表明FACTORY的重要地位——它是领域设计的重要组件。正确使用FACTORY有助于保证MODEL-DRIVEN DESIGN沿正确的轨道前进。

任何好的工厂都需满足以下两个基本需求。

(1) 每个创建方法都是原子的,而且要保证被创建对象或AGGREGATE的所有固定规则。FACTORY生成的对象要处于一致的状态。在生成ENTITY时,这意味着创建满足所有固定规则的整个AGGREGATE,但在创建完成后可以向聚合添加可选元素。在创建不变的VALUE OBJECT时,这意味着所有属性必须被初始化为正确的最终状态。如果FACTORY通过其接口收到了一个创建对象的请求,而它又无法正确地创建出这个对象,那么它应该抛出一个异常,或者采用其他机制,以确保不会返回错误的值。

(2) FACTORY应该被抽象为所需的类型,而不是所要创建的具体类。[Gamma et al.1995]中的高级FACTORY模式介绍了这一话题。

6.2.1 选择FACTORY及其应用位置

一般来说,FACTORY的作用是隐藏创建对象的细节,而且我们把FACTORY用在那些需要隐藏细节的地方。这些决定通常与AGGREGATE有关。

例如,如果需要向一个已存在的AGGREGATE添加元素,可以在AGGREGATE的根上创建一个FACTORY METHOD。这样就可以把AGGREGATE的内部实现细节隐藏起来,使任何外部客户看不到这些细节,同时使根负责确保AGGREGATE在添加元素时的完整性,如图6-13所示。

另一个示例是在一个对象上使用FACTORY METHOD,这个对象与生成另一个对象密切相关,但它并不拥有所生成的对象。当一个对象的创建主要使用另一个对象的数据(或许还有规则)时,则可以在后者的对象上创建一个FACTORY METHOD,这样就不必将后者的信息提取到其他地方来创建前者。这样做还有利于表达前者与后者之间的关系。

第6章 领域对象的生命周期 - 图15 图6-13 一个FACTORY METHOD封装了AGGREGATE的扩展

在图6-14中,Trade Order不属于Brokerage Account所在的AGGREGATE,因为它从一开始就与交易执行应用程序进行交互,所以把它放在Brokerage Account中只会碍事。尽管如此,让Brokerage Account负责控制Trade Order的创建却是很自然的事情。Brokerage Account含有会被嵌入到Trade Order中的信息(从自己的标识开始),而且它还包含与交易相关的规则——这些规则控制了哪些交易是允许的。隐藏Trade Order的实现细节还会带来一些其他好处。例如,我们可以将它重构为一个层次结构,分别为Buy Order和Sell Order创建一些子类。FACTORY可以避免客户与具体类之间产生耦合。

第6章 领域对象的生命周期 - 图16 图6-14 FACTORY METHOD生成一个ENTITY,但这个ENTITY并不属于FACTORY所在的AGGREGATE

FACTORY与被构建对象之间是紧密耦合的,因此FACTORY应该只被关联到与被构建对象有着密切联系的对象上。当有些细节需要隐藏(无论要隐藏的是具体实现还是构造的复杂性)而又找不到合适的地方来隐藏它们时,必须创建一个专用的FACTORY对象或SERVICE。整个AGGREGATE通常由一个独立的FACTORY来创建,FACTORY负责把对根的引用传递出去,并确保创建出的AGGREGATE满足固定规则。如果AGGREGATE内部的某个对象需要一个FACTORY,而这个FACTORY又不适合在AGGREGATE根上创建,那么应该构建一个独立的FACTORY。但仍应遵守规则——把访问限制在AGGREGATE内部,并确保从AGGREGATE外部只能对被构建对象进行临时引用,如图6-15所示。

第6章 领域对象的生命周期 - 图17 图6-15 由一个独立的FACTORY来构建AGGREGATE

6.2.2 有些情况下只需使用构造函数

我曾经在很多代码中看到所有实例都是通过直接调用类构造函数来创建的,或者是使用编程语言的最基本的实例创建方式。FACTORY的引入提供了巨大的优势,而这种优势往往并未得到充分利用。但是,在有些情况下直接使用构造函数确实是最佳选择。FACTORY实际上会使那些不具有多态性的简单对象复杂化。

在以下情况下最好使用简单的、公共的构造函数。

类(class)是一种类型(type)。它不是任何相关层次结构的一部分,而且也没有通过接口实现多态性。

客户关心的是实现,可能是将其作为选择STRATEGY的一种方式。

客户可以访问对象的所有属性,因此向客户公开的构造函数中没有嵌套的对象创建。

构造并不复杂。

公共构造函数必须遵守与FACTORY相同的规则:它必须是原子操作,而且要满足被创建对象的所有固定规则。

不要在构造函数中调用其他类的构造函数。构造函数应该保持绝对简单。复杂的装配,特别是AGGREGATE,需要使用FACTORY。使用FACTORY METHOD的门槛并不高。

Java类库提供了一些有趣的例子。所有集合都实现了接口,接口使得客户与具体实现之间不产生耦合。然而,它们都是通过直接调用构造函数创建的。但是,集合类本来是可以使用FACTORY来封装集合的层次结构的。而且,客户也可以使用FACTORY的方法来请求所需的特性,然后由FACTORY来选择适当的类来实例化。这样一来,创建集合的代码就会有更强的表达力,而且新增集合类时不会破坏现有的Java程序。

但在某些场合下使用具体的构造函数更为合适。首先,在很多应用程序中,实现方式的选择对性能的影响是非常敏感的,因此应用程序需要控制选择哪种实现(尽管如此,真正智能的FACTORY仍然可以满足这些因素的要求)。不管怎样,集合类的数量并不多,因此选择并不复杂。

虽然没有使用FACTORY,但抽象集合类型仍然具有一定价值,原因就在于它们的使用模式。集合通常都是在一个地方创建,而在其他地方使用。这意味着最终使用集合(添加、删除和检索其内容)的客户仍可以与接口进行对话,从而不与实现发生耦合。集合类的选择通常由拥有该集合的对象来决定,或是由该对象的FACTORY来决定。

6.2.3 接口的设计

当设计FACTORY的方法签名时,无论是独立的FACTORY还是FACTORY METHOD,都要记住以下两点。

每个操作都必须是原子的。我们必须在与FACTORY的一次交互中把创建对象所需的所有信息传递给FACTORY。同时必须确定当创建失败时将执行什么操作,比如某些固定规则没有被满足。可以抛出一个异常或仅仅返回null。为了保持一致,可以考虑采用编码标准来处理所有FACTORY的失败。

Factory将与其参数发生耦合。如果在选择输入参数时不小心,可能会产生错综复杂的依赖关系。耦合程度取决于对参数(argument)的处理。如果只是简单地将参数插入到要构建的对象中,则依赖度是适中的。如果从参数中选出一部分在构造对象时使用,耦合将更紧密。

最安全的参数是那些来自较低设计层的参数。即使在同一层中,也有一种自然的分层倾向,其中更基本的对象被更高层的对象使用(第10章将从不同方面讨论这样的分层,第16章也会论述这个问题)。

另一个好的参数选择是模型中与被构建对象密切相关的对象,这样不会增加新的依赖。在前面的Purchase Order Item示例中,FACTORY METHOD将Catalog Part作为一个参数,它是Item的一个重要的关联。这在Purchase Order类和Part之间增加了直接依赖。但这3个对象组成了一个关系密切的概念小组。不管怎样,Purchase Order的AGGREGATE已经引用了Part。因此将控制权交给AGGREGATE根,并封装AGGREGATE的内部结构是一个不错的折中选择。

使用抽象类型的参数,而不是它们的具体类。FACTORY与被构建对象的具体类发生耦合,而无需与具体的参数发生耦合。

6.2.4 固定规则的相关逻辑应放置在哪里

FACTORY负责确保它所创建的对象或AGGREGATE满足所有固定规则,然而在把应用于一个对象的规则移到该对象外部之前应三思。FACTORY可以将固定规则的检查工作委派给被创建对象,而且这通常是最佳选择。

但FACTORY与被创建对象之间存在一种特殊关系。FACTORY已经知道被创建对象的内部结构,而且创建FACTORY的目的与被创建对象的实现有着密切的联系。在某些情况下,把固定规则的相关逻辑放到FACTORY中是有好处的,这样可以让被创建对象的职责更明晰。对于AGGREGATE规则来说尤其如此(这些规则会约束很多对象)。但固定规则的相关逻辑却特别不适合放到那些与其他领域对象关联的FACTORY METHOD中。

虽然原则上在每个操作结束时都应该应用固定规则,但通常对象所允许的转换可能永远也不会用到这些规则。可能ENTITY标识属性的赋值需要满足一条固定规则。但该标识在创建后可能一直保持不变。VALUE OBJECT则是完全不变的。如果逻辑在对象的有效生命周期内永远也不被用到,那么对象就没有必要携带这个逻辑。在这种情况下,FACTORY是放臵固定规则的合适地方,这样可以使FACTORY创建出的对象更简单。

6.2.5 ENTITY FACTORY与VALUE OBJECT FACTORY

ENTITY FACTORY与VALUE OBJECT FACTORY有两个方面的不同。由于VALUE OBJECT是不可变的,因此,FACTORY所生成的对象就是最终形式。因此FACTORY操作必须得到被创建对象的完整描述。而ENTITY FACTORY则只需具有构造有效AGGREGATE所需的那些属性。对于固定规则不关心的细节,可以之后再添加。

我们来看一下为ENTITY分配标识时将涉及的问题(VALUE OBJECT不会涉及这些问题)。正如第5章所指出的那样,既可以由程序自动分配一个标识符,也可以通过外部(通常是用户)提供一个标识符。如果客户的标识是通过电话号码跟踪的,那么该电话号码必须作为参数被显式地传递给FACTORY。当由程序分配标识符时,FACTORY是控制它的理想场所。尽管唯一跟踪ID实际上是由数据库“序列”或其他基础设施机制生成的,但FACTORY知道需要什么样的标识,以及将标识放到何处。

6.2.6 重建已存储的对象

到目前为止,FACTORY只是发挥了它在对象生命周期开始时的作用。到了某一时刻,大部分对象都要存储在数据库中或通过网络传输,而在当前的数据库技术中,几乎没有哪种技术能够保持对象的内容特征。大多数传输方法都要将对象转换为平面数据才能传输,这使得对象只能以非常有限的形式出现。因此,检索操作潜在地需要一个复杂的过程将各个部分重新装配成一个可用的对象。

用于重建对象的FACTORY与用于创建对象的FACTORY很类似,主要有以下两点不同。

(1)用于重建对象的ENTITY FACTORY不分配新的跟踪ID。如果重新分配ID,将丢失与先前对象的连续性。因此,在重建对象的FACTORY中,标识属性必须是输入参数的一部分。

(2) 当固定规则未被满足时,重建对象的FACTORY采用不同的方式进行处理。当创建新对象时,如果未满足固定规则,FACTORY应该简单地拒绝创建对象,但在重建对象时则需要更灵活的响应。如果对象已经在系统的某个地方存在(如在数据库中),那么不能忽略这个事实。但是,同样也不能任凭规则被破坏。必须通过某种策略来修复这种不一致的情况,这使得重建对象比创建新对象更困难。

图6-16和图6-17显示了两种重建。当从数据库中重建对象时,对象映射技术就可以提供部分或全部所需服务,这是非常便利的。当从其他介质重建对象时,如果出现复杂情况,FACTORY是个很好的选择。

第6章 领域对象的生命周期 - 图18 图6-16 从关系数据库中检索一个ENTITY并重建它

第6章 领域对象的生命周期 - 图19 图6-17 重建以XML形式传输的ENTITY

总之,必须把创建实例的访问点标识出来,并显式地定义它们的范围。它们可能只是构造函数,但通常需要有一种更抽象或更复杂的实例创建机制。为了满足这种需求,需要在设计中引入新的构造——FACTORY。FACTORY通常不表示模型的任何部分,但它们是领域设计的一部分,能够使对象更明确地表示出模型。

FACTORY封装了对象创建和重建时的生命周期转换。还有一种转换大大增加了领域设计的技术复杂性,这是对象与存储之间的互相转换。这种转换由另一种领域设计构造来处理,它就是REPOSITORY。

6.3 模式:REPOSITORY

第6章 领域对象的生命周期 - 图20

我们可以通过对象之间的关联来找到对象。但当它处于生命周期的中间时,必须要有一个起点,以便从这个起点遍历到一个ENTITY或VALUE。

无论要用对象执行什么操作,都需要保持一个对它的引用。那么如何获得这个引用呢?一种方法是创建对象,因为创建操作将返回对新对象的引用。第二种方法是遍历关联。我们以一个已知对象作为起点,并向它请求一个关联的对象。这样的操作在任何面向对象的程序中都会大量用到,而且对象之间的这些链接使对象模型具有更强的表达能力。但我们必须首先获得作为起点的那个对象。

实际上,我曾经遇到过一个项目,团队成员对MODEL-DRIVEN DESIGN怀有极大的热情,因而试图通过创建对象或遍历对象的方法来访问所有对象。他们的对象存储在对象数据库中,而且他们推断出已有的概念关系将提供所有必要的关联。他们只需完成充分的分析工作,以便使整个领域满足内聚的要求。这种自己强加的限制导致他们创建出的模型错综复杂,而前几章我们一直试图通过仔细地实现ENTITY和应用AGGREGATE来避免这种复杂性。这种策略并没有坚持多长时间,但团队成员也一直没有用一种更有条理的方法来取代它。他们临时拼凑了一些解决方案,并放弃了最初的宏伟抱负。

想到这种方法的人并不多,尝试它的人就更少了,因为人们将大部分对象存储在关系数据库中。这种存储技术使人们自然而然地使用第三种获取引用的方式——基于对象的属性,执行查询来找到对象;或者是找到对象的组成部分,然后重建它。

数据库搜索是全局可访问的,它使我们可以直接访问任何对象。由此,所有对象不需要相互联接起来,整个对象关系网就能够保持在可控的范围内。是提供遍历还是依靠搜索,这成为一个设计决策,需要在搜索的解耦与关联的内聚之间做出权衡。Customer对象应该保持该客户所有已订的Order吗?应该通过Customer ID字段在数据库中查找Order吗?恰当地结合搜索与关联将会得到易于理解的设计。

遗憾的是,开发人员一般不会过多地考虑这种精细的设计,因为他们满脑子都是需要用到的机制,以便很有技巧地利用它们来实现对象的存储、取回和最终删除。

现在,从技术的观点来看,检索已存储对象实际上属于创建对象的范畴,因为从数据库中检索出来的数据要被用来组装新的对象。实际上,由于需要经常编写这样的代码,我们对此形成了根深蒂固的观念。但从概念上讲,对象检索发生在ENTITY生命周期的中间。不能只是因为我们将Customer对象保存在数据库中,而后把它检索出来,这个Customer就代表了一个新客户。为了记住这个区别,我把使用已存储的数据创建实例的过程称为重建。

领域驱动设计的目标是通过关注领域模型(而不是技术)来创建更好的软件。假设开发人员构造了一个SQL查询,并将它传递给基础设施层中的某个查询服务,然后再根据得到的表行数据的结果集提取出所需信息,最后将这些信息传递给构造函数或FACTORY。开发人员执行这一连串操作的时候,早已不再把模型当作重点了。我们很自然地会把对象看作容器来放臵查询出来的数据,这样整个设计就转向了数据处理风格。虽然具体的技术细节有所不同,但问题仍然存在——客户处理的是技术,而不是模型概念。诸如METADATA MAPPING LAYER[Fowler 2002]这样的基础设施可以提供很大帮助,利用它很容易将查询结果转换为对象,但开发人员考虑的仍然是技术机制,而不是领域。更糟的是,当客户代码直接使用数据库时,开发人员会试图绕过模型的功能(如AGGREGATE,甚至是对象封装),而直接获取和操作他们所需的数据。这将导致越来越多的领域规则被嵌入到查询代码中,或者干脆丢失了。虽然对象数据库消除了转换问题,但搜索机制还是很机械的,开发人员仍倾向于要什么就去拿什么。

客户需要一种有效的方式来获取对已存在的领域对象的引用。如果基础设施提供了这方面的便利,那么开发人员可能会增加很多可遍历的关联,这会使模型变得非常混乱。另一方面,开发人员可能使用查询从数据库中提取他们所需的数据,或是直接提取具体的对象,而不是通过AGGREGATE的根来得到这些对象。这样就导致领域逻辑进入查询和客户代码中,而ENTITY和VALUE OBJECT则变成单纯的数据容器。采用大多数处理数据库访问的技术复杂性很快就会使客户代码变得混乱,这将导致开发人员简化领域层,最终使模型变得无关紧要。

根据到目前为止所讨论的设计原则,如果我们找到一种访问方法,它能够明确地将模型作为焦点,从而应用这些原则,那么我们就可以在某种程度上缩小对象访问问题的范围,。初学者可以不必关心临时对象。临时对象(通常是VALUE OBJECT)只存在很短的时间,在客户操作中用到它们时才创建它们,用完就删除了。我们也不需要对那些很容易通过遍历来找到的持久对象进行查询访问。例如,地址可以通过Person对象获取。而且最重要的是,除了通过根来遍历查找对象这种方法以外,禁止用其他方法对AGGREGATE内部的任何对象进行访问。

持久化的VALUE OBJECT一般可以通过遍历某个ENTITY来找到,在这里ENTITY就是把对象封装在一起的AGGREGATE的根。事实上,对VALUE的全局搜索访问常常是没有意义的,因为通过属性找到VALUE OBJECT相当于用这些属性创建一个新实例。但也有例外情况。例如,当我在线规划旅行线路时,有时会先保存几个中意的行程,过后再回头从中选择一个来预订。这些行程就是VALUE(如果两个行程由相同的航班构成,那么我不会关心哪个是哪个),但它们已经与我的用户名关联到一起了,而且可以原封不动地将它们检索出来。另一个例子是“枚举”,在枚举中一个类型有一组严格限定的、预定义的可能值。但是,对VALUE OBJECT的全局访问比对ENTITY的全局访问更少见,如果确实需要在数据库中搜索一个已存在的VALUE,那么值得考虑一下,搜索结果可能实际上是一个ENTITY,只是尚未识别它的标识。

从上面的讨论显然可以看出,大多数对象都不应该通过全局搜索来访问。如果很容易就能从设计中看出那些确实需要全局搜索访问的对象,那该有多好!

现在可以更精确地将问题重新表述如下:

在所有持久化对象中,有一小部分必须通过基于对象属性的搜索来全局访问。当很难通过遍历方式来访问某些AGGREGATE根的时候,就需要使用这种访问方式。它们通常是ENTITY,有时是具有复杂内部结构的VALUE OBJECT,还可能是枚举VALUE。而其他对象则不宜使用这种访问方式,因为这会混淆它们之间的重要区别。随意的数据库查询会破坏领域对象的封装和AGGREGATE。技术基础设施和数据库访问机制的暴露会增加客户的复杂度,并妨碍模型驱动的设计。

有大量的技术可以用来解决数据库访问的技术难题,例如,将SQL封装到QUERY OBJECT中,或利用METADATA MAPPING LAYER进行对象和表之间的转换[Fowler 2002]。FACTORY可以帮助重建那些已存储的对象(本章后面将会讨论)。这些技术和很多其他技术有助于控制数据库访问的复杂度。

有得必有失,我们应该注意失去了什么。我们已经不再考虑领域模型中的概念。代码也不再表达业务,而是对数据库检索技术进行操纵。REPOSITORY是一个简单的概念框架,它可用来封装这些解决方案,并将我们的注意力重新拉回到模型上。

REPOSITORY将某种类型的所有对象表示为一个概念集合(通常是模拟的)。它的行为类似于集合(collection),只是具有更复杂的查询功能。在添加或删除相应类型的对象时,REPOSITORY的后台机制负责将对象添加到数据库中,或从数据库中删除对象。这个定义将一组紧密相关的职责集中在一起,这些职责提供了对AGGREGATE根的整个生命周期的全程访问。

客户使用查询方法向REPOSITORY请求对象,这些查询方法根据客户所指定的条件(通常是特定属性的值)来挑选对象。REPOSITORY检索被请求的对象,并封装数据库查询和元数据映射机制。REPOSITORY可以根据客户所要求的各种条件来挑选对象。它们也可以返回汇总信息,如有多少个实例满足查询条件。REPOSITORY甚至能返回汇总计算,如所有匹配对象的某个数值属性的总和,如图6-18所示。

第6章 领域对象的生命周期 - 图21 图6-18 REPOSITORY为客户执行一个搜索

REPOSITORY解除了客户的巨大负担,使客户只需与一个简单的、易于理解的接口进行对话,并根据模型向这个接口提出它的请求。要实现所有这些功能需要大量复杂的技术基础设施,但接口很简单,而且在概念层次上与领域模型紧密联系在一起。

因此:

为每种需要全局访问的对象类型创建一个对象,这个对象相当于该类型的所有对象在内存中的一个集合的“替身”。通过一个众所周知的全局接口来提供访问。提供添加和删除对象的方法,用这些方法来封装在数据存储中实际插入或删除数据的操作。提供根据具体条件来挑选对象的方法,并返回属性值满足查询条件的对象或对象集合(所返回的对象是完全实例化的),从而将实际的存储和查询技术封装起来。只为那些确实需要直接访问的AGGREGATE根提供REPOSITORY。让客户始终聚焦于模型,而将所有对象的存储和访问操作交给REPOSITORY来完成。

REPOSITORY有很多优点,包括:

它们为客户提供了一个简单的模型,可用来获取持久化对象并管理它们的生命周期;

它们使应用程序和领域设计与持久化技术(多种数据库策略甚至是多个数据源)解耦;

它们体现了有关对象访问的设计决策;

可以很容易将它们替换为“哑实现”(dummy implementation),以便在测试中使用(通常使用内存中的集合)。

6.3.1 REPOSITORY的查询

所有REPOSITORY都为客户提供了根据某种条件来查询对象的方法,但如何设计这个接口却有很多选择。

最容易构建的REPOSITORY用硬编码的方式来实现一些具有特定参数的查询。这些查询可以形式各异,例如,通过标识来检索ENTITY(几乎所有REPOSITORY都提供了这种查询)、通过某个特定属性值或复杂的参数组合来请求一个对象集合、根据值域(如日期范围)来选择对象,甚至可以执行某些属于REPOSITORY一般职责范围内的计算(特别是利用那些底层数据库所支持的操作)。如图6-19所示。

尽管大多数查询都返回一个对象或对象集合,但返回某些类型的汇总计算也符合REPOSITORY的概念,如对象数目,或模型需要对某个数值属性进行求和统计。

第6章 领域对象的生命周期 - 图22 图6-19 在简单REPOSITORY中进行的硬编码查询

在任何基础设施上,都可以构建硬编码式的查询,也不需要很大的投入,因为即使它们不做这些事,有些客户也必须要做。

在一些需要执行大量查询的项目上,可以构建一个支持更灵活查询的REPOSITORY框架。如图6-20所示。这要求开发人员熟悉必要的技术,而且一个支持性的基础设施会提供巨大的帮助。

基于SPECIFICATION(规格)的查询是将REPOSITORY通用化的好办法。客户可以使用规格来描述(也就是指定)它需要什么,而不必关心如何获得结果。在这个过程中,可以创建一个对象来实际执行筛选操作。第9章将深入讨论这种模式。

基于SPECIFICATION的查询是一种优雅且灵活的查询方法。根据所用的基础设施的不同,它可能易于实现,也可能极为复杂。Rob Mee和Edward Hieatt在[Fowler 2002]一书中探讨了设计这样的REPOSITORY时所涉及的更多技术问题。

第6章 领域对象的生命周期 - 图23 图6-20 在一个复杂的REPOSITORY中,用一种灵活的、声明式的SPECIFICATION来表述一个搜索条件

即使一个REPOSITORY的设计采取了灵活的查询方式,也应该允许添加专门的硬编码查询。这些查询作为便捷的方法,可以封装常用查询或不返回对象(如返回的是选中对象的汇总计算)的查询。不支持这些特殊查询方式的框架有可能会扭曲领域设计,或是干脆被开发人员弃之不用。

6.3.2 客户代码可以忽略REPOSITORY的实现,但开发人员不能忽略

持久化技术的封装可以使得客户变得十分简单,并且使客户与REPOSITORY的实现之间完全解耦。但像一般的封装一样,开发人员必须知道在封装背后都发生了什么事情。在使用REPOSITORY时,不同的使用方式或工作方式可能会对性能产生极大的影响。

Kyle Brown曾告诉过我他的一段经历,有一次他被请去解决一个基于WebSphere的制造业应用程序的问题,当时这个程序正向生产环境部署。系统在运行几小时后会莫名其妙地耗尽内存。Kyle在检查代码后发现了原因:在某一时刻,系统需要将工厂中每件产品的信息汇总到一起。开发人员使用了一个名为all objects(所有对象)的查询来进行汇总,这个操作对每个对象进行实例化,然后选择他们所需的数据。这段代码的结果是一次性将整个数据库装入内存中!这个问题在测试中并未发现,原因是测试数据较少。

这是一个明显的禁忌,而一些更不容易注意到的疏忽可能会产生同样严重的问题。开发人员需要理解使用封装行为的隐含问题,但这并不意味着要熟悉实现的每个细节。设计良好的组件是有显著特征的(这是第10章的重点之一)。

正如第5章所讨论的那样,底层技术可能会限制我们的建模选择。例如,关系数据库可能对复合对象结构的深度有实际的限制。同样,开发人员要获得REPOSITORY的使用及其查询实现之间的双向反馈。

6.3.3 REPOSITORY的实现

根据所使用的持久化技术和基础设施不同,REPOSITORY的实现也将有很大的变化。理想的实现是向客户隐藏所有内部工作细节(尽管不向客户的开发人员隐藏这些细节),这样不管数据是存储在对象数据库中,还是存储在关系数据库中,或是简单地保持在内存中,客户代码都相同。REPOSITORY将会委托相应的基础设施服务来完成工作。将存储、检索和查询机制封装起来是REPOSITORY实现的最基本的特性,如图6-21所示。

第6章 领域对象的生命周期 - 图24 图6-21 REPOSITORY将底层数据存储封装起来

REPOSITORY概念在很多情况下都适用。可能的实现方法有很多,这里只能列出如下一些需要谨记的注意事项。

对类型进行抽象。REPOSITORY“含有”特定类型的所有实例,但这并不意味着每个类都需要有一个REPOSITORY。类型可以是一个层次结构中的抽象超类(例如,TradeOrder可以是BuyOrder或SellOrder)。类型可以是一个接口——接口的实现者并没有层次结构上的关联,也可以是一个具体类。记住,由于数据库技术缺乏这样的多态性质,因此我们将面临很多约束。

充分利用与客户解耦的优点。我们可以很容易地更改REPOSITORY的实现,但如果客户直接调用底层机制,我们就很难修改其实现。也可以利用解耦来优化性能,因为这样就可以使用不同的查询技术,或在内存中缓存对象,可以随时自由地切换持久化策略。通过提供一个易于操纵的、内存中的(in-memory)哑实现,还能够方便客户代码和领域对象的测试。

将事务的控制权留给客户。尽管REPOSITORY会执行数据库的插入和删除操作,但它通常不会提交事务。例如,保存数据后紧接着就提交似乎是很自然的事情,但想必只有客户才有上下文,从而能够正确地初始化和提交工作单元。如果REPOSITORY不插手事务控制,那么事务管理就会简单得多。

通常,项目团队会在基础设施层中添加框架,用来支持REPOSITORY的实现。REPOSITORY超类除了与较低层的基础设施组件进行协作以外,还可以实现一些基本查询,特别是要实现的灵活查询时。遗憾的是,对于类似Java这样的类型系统,这种方法会使返回的对象只能是Object类型,而让客户将它们转换为REPOSITORY含有的类型。当然,如果在Java中查询所返回的对象是集合时,客户不管怎样都要执行这样的转换。

有关实现REPOSITORY的更多指导和一些支持性技术模式(如QUERY OBJECT)可以在[Fowler 2002]一书中找到。

6.3.4 在框架内工作

在实现REPOSITORY这样的构造之前,需要认真思考所使用的基础设施,特别是架构框架。这些框架可能提供了一些可用来轻松创建REPOSITORY的服务,但也可能会妨碍创建REPOSITORY的工作。我们可能会发现架构框架已经定义了一种用来获取持久化对象的等效模式,也有可能定义了一种与REPOSITORY完全不同的模式。

例如,你的项目可能会使用J2EE。看看这个框架与MODEL-DRIVEN DESIGN的模式之间有哪些概念上近似的地方(记住,实体bean与ENTITY不是一回事),你可能会把实体bean和AGGREGATE根当作一对类似的概念。在J2EE框架中,负责对这些对象进行访问的构造是EJB Home。但如果把EJB Home装饰成REPOSITORY的样子可能会导致其他问题。

一般来讲,在使用框架时要顺其自然。当框架无法切合时,要想办法在大方向上保持领域驱动设计的基本原理,而一些不符的细节则不必过分苛求。寻求领域驱动设计的概念与框架中的概念之间的相似性。这里的假设是除了使用指定框架之外没有别的选择。很多J2EE项目根本不使用实体bean。如果可以自由选择,那么应该选择与你所使用的设计风格相协调的框架或框架中的一些部分。

6.3.5 REPOSITORY与FACTORY的关系

FACTORY负责处理对象生命周期的开始,而REPOSITORY帮助管理生命周期的中间和结束。当对象驻留在内存中或存储在对象数据库中时,这是很好理解的。但通常至少有一部分对象存储在关系数据库、文件或其他非面向对象的系统中。在这些情况下,检索出来的数据必须被重建为对象形式。

由于在这种情况下REPOSITORY基于数据来创建对象,因此很多人认为REPOSITORY就是FACTORY,而从技术角度来看的确如此。但我们最好还是从模型的角度来看待这一问题,前面讲过,重建一个已存储的对象并不是创建一个新的概念对象。从领域驱动设计的角度来看,FACTORY和REPOSITORY具有完全不同的职责。FACTORY负责制造新对象,而REPOSITORY负责查找已有对象。REPOSITORY应该让客户感觉到那些对象就好像驻留在内存中一样。对象可能必须被重建(的确,可能会创建一个新实例),但它是同一个概念对象,仍旧处于生命周期的中间。

REPOSITORY也可以委托FACTORY来创建一个对象,这种方法(虽然实际很少这样做,但在理论上是可行的)可用于从头开始创建对象,此时就没有必要区分这两种看问题的角度了,如图6-22所示。

第6章 领域对象的生命周期 - 图25 图6-22 REPOSITORY使用FACTORY来重建一个已有对象

这种职责上的明确区分还有助于FACTORY摆脱所有持久化职责。FACTORY的工作是用数据来实例化一个可能很复杂的对象。如果产品是一个新对象,那么客户将知道在创建完成之后应该把它添加到REPOSITORY中,由REPOSITORY来封装对象在数据库中的存储,如图6-23所示。

第6章 领域对象的生命周期 - 图26 图6-23 客户使用REPOSITORY来存储新对象

另一种情况促使人们将FACTORY和REPOSITORY结合起来使用,这就是想要实现一种“查找或创建”功能,即客户描述它所需的对象,如果找不到这样的对象,则为客户新创建一个。我们最好不要追求这种功能,它不会带来多少方便。当将ENTITY和VALUE OBJECT区分开时,很多看上去有用的功能就不复存在了。需要VALUE OBJECT的客户可以直接请求FACTORY来创建一个。通常,在领域中将新对象和原有对象区分开是很重要的,而将它们组合在一起的框架实际上只会使局面变得混乱。

6.4 为关系数据库设计对象

在以面向对象技术为主的软件系统中,最常用的非对象组件就是关系数据库。这种现状产生了混合使用范式的常见问题(参见第5章)。但与大部分其他组件相比,数据库与对象模型的关系要紧密得多。数据库不仅仅与对象进行交互,而且它还把构成对象的数据存储为持久化形式。已经有大量的文献对于如何将对象映射到关系表以及如何有效存储和检索它们这样的技术挑战进行了讨论。最近的一篇讨论可参见[Fowler 2002]一书。有一些相当完善的工具可用来创建和管理它们之间的映射。除了技术上的难点以外,这种不匹配可能对对象模型产生很大的影响。

有3种常见情况:

(1) 数据库是对象的主要存储库;

(2) 数据库是为另一个系统设计的;

(3) 数据库是为这个系统设计的,但它的任务不是用于存储对象。

如果数据库模式(database schema)是专门为对象存储而设计的,那么接受模型的一些限制是值得的,这样可以让映射变得简单一点。如果在数据库模式设计上没有其他的要求,那么可以精心设计数据库结构,以便使得在更新数据时能更安全地保证聚合的完整性,并使数据更新变得更加高效。从技术上来看,关系表的设计不必反映出领域模型。映射工具已经非常完善了,足以消除二者之间的巨大差别。问题在于多个重叠的模型过于复杂了。MODEL-DRIVEN DESIGN的很多关于避免将分析和设计模型分开的观点,也同样适用于这种不匹配问题。这确实会牺牲一些对象模型的丰富性,而且有时必须在数据库设计中做出一些折中(如有些地方不能规范化)。但如果不做这些牺牲就会冒另一种风险,那就是模型与实现之间失去了紧密的耦合。这种方法并不要必须使用一种简单的、一个对象/一个表的映射。依靠映射工具的功能,可以实现一些聚合或对象的组合。但至关重要的是:映射要保持透明,并易于理解——能够通过审查代码或阅读映射工具中的条目就搞明白。

当数据库被视作对象存储时,数据模型与对象模型的差别不应太大(不管映射工具有多么强大的功能)。可以牺牲一些对象关系的丰富性,以保证它与关系模型的紧密关联。如果有助于简化对象映射的话,不妨牺牲某些正式的关系标准(如规范化)。

对象系统外部的过程不应该访问这样的对象存储。它们可能会破坏对象必须满足的固定规则。此外,它们的访问将会锁定数据模型,这样使得在重构对象时很难修改模型。

另一方面,很多情况下数据是来自遗留系统或外部系统的,而这些系统从来没打算被用作对象的存储。在这种情况下,同一个系统中就会有两个领域模型共存。第14章将深入讨论这个问题。或许与另一个系统中隐含的模型保持一致有一定的道理,也可能更好的方法是使这两个模型完全不同。

允许例外情况的另一个原因是性能。为了解决执行速度的问题,有时可能需要对设计做出一些非常规的修改。

但大多数情况下关系数据库是面向对象领域中的持久化存储形式,因此简单的对应关系才是最好的。表中的一行应该包含一个对象,也可能还包含AGGREGATE中的一些附属项。表中的外键应该转换为对另一个ENTITY对象的引用。有时我们不得不违背这种简单的对应关系,但不应该由此就全盘放弃简单映射的原则。

UBIQUITOUS LANGUAGE可能有助于将对象和关系组件联系起来,使之成为单一的模型。对象中的元素的名称和关联应该严格地对应于关系表中相应的项。尽管有些功能强大的映射工具使这看上去有些多此一举,但关系中的微小差别可能引发很多混乱。

对象世界中越来越盛行的重构实际上并没有对关系数据库设计造成多大的影响。此外,一些严重的数据迁移问题也使人们不愿意对数据库进行频繁的修改。这可能会阻碍对象模型的重构,但如果对象模型和数据库模型开始背离,那么很快就会失去透明性。

最后,有些原因使我们不得不使用与对象模型完全不同的数据库模式,即使数据库是专门为我们的系统创建的。数据库也有可能被其他一些不对对象进行实例化的软件使用。即使当对象的行为快速变化或演变的时候,数据库可能并不需要修改。让模型与数据库之间保持松散的关联是很有吸引力的。但这种结果往往是无意为之,原因是团队没有保持数据库与模型之间的同步。如果有意将两个模型分开,那么它可能会产生更整洁的数据库模式,而不是一个为了与早前的对象模型保持一致而到处都是折中处理的拙劣的数据库模式。