阅读-领域驱动设计七--使用语言:一个扩展的示例

前面三章介绍了一种模式语言,它可以对模型的细节进行精化,并可以严格遵守MODEL-DRIVEN DESIGN。前面的示例基本上一次只应用一种模式,但在实际的项目中,必须将它们结合起来使用。

本章介绍一个比较全面的示例(当然还是远远比实际项目简单)。这个示例将通过一个假想团队处理需求和实现问题,并开发出一个MODEL-DRIVEN DESIGN来一步步介绍模型和设计的精化过程,其间会展示所遇到的阻力,以及如何运用第二部分讨论的模式来解决它们。

一、货物运输系统简介

假设我们正在为一家货运公司开发新软件。最初的需求包括3项基本功能:
(1) 跟踪客户货物的主要处理;
(2) 事先预约货物;
(3) 当货物到达其处理过程中的某个位置时,自动向客户寄送发票。

在实际的项目中,需要花费一些时间,并经过多次迭代才能得到清晰的模型。这里,我们先从一个已包含所需概念并且形式合理的模型开始,我们将通过调整模型的细节来支持设计。这个模型将领域知识组织起来,并为团队提供了一种语言。



图7-1显示的模型中,每个对象都有明确的意义:
Handling Event(处理事件)是对Cargo采取的不同操作,如将它装上船或清关。这个类可以被细化为一个由不同种类的事件(如装货、卸货或由收货人提货)构成的层次结构。
Delivery Specification(运送规格)定义了运送目标,这至少包括目的地和到达日期,但也可能更为复杂。这个类遵循规格模式



Delivery Specification的职责本来可以由Cargo对象承担,但将Delivery Specification抽象出来至少
有以下3个优点。

(1) 如果没有Delivery Specification,Cargo对象就需要负责提供用于指定运送目标的所有属性和关联。这会把Cargo对象搞乱,使它难以理解或修改。

(2) 当将模型作为一个整体来解释时,这个抽象使我们能够轻松且安全地省略掉细节。例如,Delivery Specification中可能还封装了其他标准,但就图7-1所要展示的细节而言,可以不必将其显示出来。这个图告诉读者存在运送规格,但其细节并非思考的重点(事实上,过后修改细节也很容易)。

(3) 这个模型具有更强的表达力。Delivery Specification清楚地表明:运送Cargo的具体方式没有明确规定,但它必须完成Delivery Specification中规定的目标。

Customer在运输中所承担的部分是按照角色(role)来区分的,如shipper(托运人)、receiver(收货人)、payer(付款人)等。由于一个Cargo只能由一个Customer来承担某个给定的角色,因此它们之间的关联是限定的多对一关系,而不是多对多。角色可以被简单地实现为字符串,当需要其他行为的时候,也可以将它实现为类.

Carrier Movement表示由某个Carrier(如一辆卡车或一艘船)执行的从一个Location(地点)到另一个Location的旅程。Cargo被装上Carrier后,通过Carrier的一个或多个Carrier Movement,就
可以在不同地点之间转移。

Delivery History(运送历史)反映了Cargo实际上发生了什么事情,它与Delivery Specification正好相对,后者描述了目标。Delivery History对象可以通过分析最后一次装货和卸货以及对应的Carrier Movement的目的地来计算货物的当前位置。成功的运送会得到一个满足Delivery Specification目标的DeliveryHistory。

用于实现上述需求的所有概念都已包含在这个模型中,并假定已经有适当的机制来保存对象、查找相关对象等。这些实现问题不在模型中处理,但它们必须在设计中加以考虑。

为了建立一个健壮的实现,这个模型需要更清晰和严密一些。记住,一般情况下,模型的精化、设计和实现应该在迭代开发过程中同步进行。但在本章中,为了使解释更加清楚,我们从一个相对成熟的模型开始,并严格限定修改的唯一动机是保证模型与具体实现相关联,在实现时采用构造块模式。

二、隔离领域:引入应用层

为了防止领域的职责与系统的其他部分混杂在一起,我们应用LAYERED ARCHITECTURE把领域层划分出来。

无需深入分析,就可以识别出三个用户级别的应用程序功能,我们可以将这三个功能分配给三个应用层类。

(1) 第一个类是Tracking Query(跟踪查询),它可以访问某个 Cargo过去和现在的处理情况。
(2) 第二个类是Booking Application(预订应用),它允许注册一个新的Cargo,并使系统准备好处理它。
(3) 第三个类是Incident Logging Application(事件日志应用),它记录对Cargo的每次处理(提供通过Tracking Query查找的信息)。

这些应用层类是协调者,它们只是负责提问,而不负责回答,回答是领域层的工作。

三、3 将ENTITY和VALUE OBJECT区别开

依次考虑每个对象,看看这个对象是必须被跟踪的实体还是仅表示一个基本值。首先,我们来看一些比较明显的情况,然后考虑更含糊的情况。

Customer

我们从一个简单的对象开始。Customer对象表示一个人或一家公司,从一般意义上来讲它是一个实体。Customer对象显然有对用户来说很重要的标识,因此它在模型中是一个ENTITY。那么如
何跟踪它呢?

我们与运输公司的业务人员讨论这个问题,发现公司已经建立了客户数据库,其中每个Customer在第一次联系销售时被分配了一个ID号。这种ID已经在整个公司中使用,因此在我们的软件中使用这种ID号就可以与那些系统保持标识的连贯性。ID号最初是手工录入的。

Cargo

两个完全相同的货箱必须要区分开,因此Cargo对象是ENTITY。在实际情况中,所有运输公司会为每件货物分配一个跟踪ID。这个ID是自动生成的、对用户可见,而且在本例中,在预订时
可能还要发送给客户。

Handling Event和Carrier Movement

我们关心这些独立事件是因为通过它们可以跟踪正在发生的事情。它们反映了真实世界的事件,而这些事件一般是不能互换的,因此它们是ENTITY。每个Carrier Movement都将通过一个代码来识别,这个代码是从运输调度表得到的。

在与领域专家的另一次讨论中,我们发现Handling Event有一种唯一的识别方法,那就是使用Cargo ID、完成时间和类型的组合。例如,同一个Cargo不会在同一时间既装货又卸货。

Location

名称相同的两个地点并不是同一个位臵。经纬度可以作为唯一键,但这并不是一个非常可行的方案,因为系统的大部分功能并不关心经纬度是多少,而且经纬度的使用相当复杂。Location
更可能是某种地理模型的一部分,这个模型根据运输航线和其他特定于领域的关注点将地点关联起来。因此,使用自动生成的内部任意标识符就足够了。

Delivery History

这是一个比较复杂的对象。Delivery History是不可互换的,因此它是ENTITY。但Delivery History与Cargo是一对一关系,因此它实际上并没有自己的标识。它的标识来自于拥有它的Cargo。
当对AGGREGATE进行建模时这个问题会变得更清楚。

Delivery Specification(运送规格)

尽管它表示了Cargo的目标,但这种抽象并不依赖于Cargo(货物)。它实际上表示某些Delivery History的假定状态。运送货物实际上就是让Cargo的Delivery History最后满足该Cargo的Delivery
Specification。如果有两个Cargo去往同一地点,那么它们可以用同一个Delivery Specification,但它们不会共用同一个Delivery History,尽管运送历史都是从同一个状态(空)开始。因此,Delivery Specification是VALUE OBJECT。

Role和其他属性

Role表示了有关它所限定的关联的一些信息,但它没有历史或连续性。因此它是一个VALUE OBJECT,可以在不同的Cargo/Customer关联中共享它。其他属性(如时间戳或名称)都是VALUE OBJECT。

四、设计运输领域中的关联

图7-1中的所有关联都没有指定遍历方向,但双向关联在设计中容易产生问题。此外,遍历方向还常常反映出对领域的洞悉,使模型得以深化。

如果Customer对它所运送的每个Cargo都有直接引用,那么这对长期、频繁托运货物的客户将会非常不便。此外,Customer这一概念并非只与Cargo相关。在大型系统中,Customer可能具
有多种角色,以便与许多对象交互,因此最好不要将它限定为这种具体的职责。如果需要按照Customer来查找Cargo,那么可以通过数据库查询来完成。

如果我们的应用程序要对一系列货船进行跟踪,那么从Carrier Movement遍历到Handling Event将是很重要的。但我们的业务只需跟踪Cargo,因此只需从Handling Event遍历到Carrier
Movement就能满足我们的业务需求。由于舍弃了具有多重性的遍历方向,实现简化为简单的对象引用。

图7-2解释了其他设计决策背后的原因



模型中存在一个循环引用:Cargo知道它的Delivery History,Delivery History中保存了一系列的Handling Event,而Handling Event又反过来指向Cargo。很多领域在逻辑上都存在循环引用,
而且循环引用在设计中有时是必要的,但它们维护起来很复杂。

在选择实现时,应该避免把必须同步的信息保存在两个不同的地方,这样对我们的工作很有帮助。对于这个例子,我们可以
在初期原型中使用一个简单但不太健壮的实现(用Java语言)——在Delivery History中提供一个List对象,并把Handling Event都放到这个List对象中。

但在某些时候,我们可能不想使用集合,以便能够用Cargo作为键来执行数据库查询。在选择存储库时,我们还会讨论到这一点。如果查询历史的操作相对来说不是很多,那么这种方法可以提供很好的性能、简化维护并减少添加Handling Event的开销。如果这种查询很频繁,那么最好还是直接引用。这种设计上的折中其实就是在实现的简单性和性能之间达成一个平衡。模型还是同一个模型,它包含了循环关联和双向关联。

五、AGGREGATE边界

Customer、Location和Carrier Movement都有自己的标识,而且被许多Cargo共享,因此,它们在各自的AGGREGATE中必须是根,这些聚合除了包含各自的属性之外,可能还包含其他比这里
讨论的细节级别更低层的对象。Cargo也是明显的AGGREGATE根,但把它的边界画在哪里还需要仔细思考一下。

如图7-3所示,Cargo AGGREGATE可以把一切因Cargo而存在的事物包含进来,这当中包括Delivery History、Delivery Specification和Handling Event。这很适合Delivery History,因为没人会在不知道Cargo的情况下直接去查询Delivery History。因为Delivery History不需要直接的全局访问,而且它的标识实际上只是由Cargo 派生出的,因此很适合将Delivery History放在Cargo的边界之内,并且它也无需是一个AGGREGATE根。Delivery Specification是一个VALUE OBJECT,因此将它包含在Cargo AGGREGATE中也不复杂。

Handling Event就是另外一回事了。前面已经考虑了两种与其有关的数据库查询,一种是当不想使用集合时,用查找某个Delivery History的Handling Event作为一种可行的替代方法,这种查询是位于Cargo AGGREGATE内部的本地查询;另一种查询是查找装货和准备某次Carrier Movement时所进行的所有操作。在第二种情况中,处理Cargo的活动看起来是有意义的(即使是与Cargo本
身分开来考虑时也是如此),因此Handling Event应该是它自己的AGGREGATE的根。

六、选择REPOSITORY

在我们的设计中,有5个ENTITY是AGGREGATE的根,因此选择存储库时只需考虑这5个实体,其他对象都不能REPOSITORY。


模型中的AGGREGATE边界

为了确定这5个实体当中哪些确实需要REPOSITORY,必须回头看一下应用程序的需求。要想通过Booking Application进行预订,用户需要选择承担不同角色(托运人、收货人等)的Customer。因此需要一个Customer Repository。在指定货物的目的地时还需要一个Location,因此还需要创建一个Location Repository。

为了确定这5个实体当中哪些确实需要REPOSITORY,必须回头看一下应用程序的需求。要想通过Booking Application进行预订,用户需要选择承担不同角色(托运人、收货人等)的Customer。因此需要一个Customer Repository。在指定货物的目的地时还需要一个Location,因此还需要创建一个Location Repository。

用户需要通过Activity Logging Application来查找装货的Carrier Movement,因此需要一个Carrier Movement Repository。用户还必须告诉系统哪个Cargo已经完成了装货,因此还需要一个
Cargo Repository,如图7-4所示。

我们没有创建Handling Event Repository,因为我们决定在第一次迭代中将它与Delivery History的关联实现为一个集合,而且应用程序并不需要查找在一次Carrier Movement中都装载了什么货物。这两个原因都有可能发生变化,如果确实改变了,可以增加一个REPOSITORY。

图7-4 REPOSITORY提供了对所选AGGREGATE根的访问

七、 场景走查

为了复核这些决策,我们需要经常走查场景,以确保能够有效地解决应用问题。

七、 1. 应用程序特性举例:更改Cargo的目的地

我们需要自己思考一些特殊场景的发生,确保改设计能有效的解决问题。

有时Customer会打电话说:“糟了!我们原来说把货物运到Hackensack,但实际上应该运往Hoboken。”既然我们提供运输服务,就一定要让系统能够进行这样的修改。Delivery Specification是一个VALUE OBJECT,因此最简单的方法是抛弃它,再创建一个新的,然后使用Cargo上的setter方法把旧值替换成新值。

七、 2 、 应用程序特性举例:重复业务

用户指出,相同Customer的重复预订往往是类似的,因此他们想要将旧Cargo作为新Cargo的原型。应用程序应该允许用户在存储库中查找一个Cargo,然后选择一条命令来基于选中的Cargo
创建一个新Cargo。我们将利用PROTOTYPE(原型)模式来设计这一功能。

Cargo是一个ENTITY,而且是AGGREGATE的根。因此在复制它时要非常小心,其AGGREGATE边界内的每个对象或属性的处理都需要仔细考虑,下面逐个来看一下。

 Delivery History:应创建一个新的、空的Delivery History,因为原有Delivery History的历史并不适用。这是AGGREGATE内部的实体的常见情况。

 Customer Roles:应该复制存有Customer引用的Map(或其他集合)——这些引用通过键来标识,键也要一起复制,这些Customer在新的运输业务中可能担负相同的角色。但必须注意不要复制Customer对象本身。在复制之后,应该保证和原来的Cargo引用相同的Customer对象,因为它们是AGGREGATE边界之外的ENTITY。

 Tracking ID:我们必须提供一个新的Tracking ID,它应该来自创建新Cargo时的同一个来源。

注意,我们复制了Cargo AGGREGATE边界内部的所有对象,并对副本进行了一些修改,但这并没有AGGREGATE边界之外的对象产生任何影响。

八、 对象的创建

八、1. Cargo的FACTORY和构造函数

即使为Cargo创建了复杂而精致的FACTORY,或像“重复业务”一节那样使用另一个Cargo作 为FACTORY,我们仍然需要有一个基本的构造函数。我们希望用构造函数来生成一个满足固定规则的对象,或者,就ENTITY而言,至少保持其标识不变。考虑到这些因素,我们可以在Cargo上创建一个FACTORY方法,如下所示:

或者可以为一个独立的FACTORY添加以下方法:

独立FACTORY还可以把为新Cargo获取新(自动生成的)ID的过程封装起来,这样它就只需要一个参数:

这些FACTORY返回的结果是完全相同的,都是一个Cargo,其Delivery History为空,且Delivery Specification为null。

Cargo与Delivery History之间的双向关联意味着它们必须要互相指向对方才算是完整的,因此它们必须被一起创建出来。记住,Cargo是AGGREGATE的根,而这个AGGREGATE包含Delivery
History。因此,我们可以用Cargo的构造函数或FACTORY来创建Delivery History。Delivery History的构造函数将Cargo作为参数。这样就可以编写以下代码:



结果得到一个新的Cargo,它带有一个指向它自己的新的Delivery History。由于Delivery History的构造函数只供其AGGREGATE根(即Cargo)使用,这样Cargo的组成就被封装起来了。

八、2 添加Handling Event

货物在真实世界中的每次处理,都会有人使用Incident Logging Application来输入一条Handling Event记录。

每个类都必须有一个基本的构造函数。由于Handling Event是一个ENTITY,所以必须把定义了其标识的所有属性传递给构造函数。如前所述,Handling Event是通过Cargo的ID、完成时间和

事件类型的组合来唯一标识的。Handling Event唯一剩下的属性是与Carrier Movement的关联,而有些类型的Handling Event甚至没有这个属性。综上,创建一个有效的Handling Event的基本构造函数是:

就ENTITY而言,那些非标识作用的属性通常可以过后再添加。在本例中,Handling Event的所有属性都是在初始事务中设臵的,而且过后不再改变(纠正数据录入错误除外),因此针对每
种事件类型,为Handling Event添加一个简单的FACTORY METHOD(并带有所有必要的参数)是很方便的做法,这还使得客户代码具有更强的表达力。例如,loading event(装货事件)确实涉及一个Carrier Movement。



模型中的Handling Event是一个抽象,它可以把各种具体的Handling Event类封装起来,包括装货、卸货、密封、存放以及其他与Carrier无关的活动。它们可以被实现为多个子类,或者通过复杂的初始化过程来实现,也可以将这两种方法结合起来使用。通过在基类(Handling Event)中为每个类型添加FACTORY METHOD,可以将实例创建的工作抽象出来,这样客户就不必了解实现的知识。FACTORY会知道哪个类需要被实例化,以及应该如何对它初始化。

遗憾的是,事情并不是这么简单。
Cargo→Delivery History→History Event→Cargo这个引用
循环使实例创建变得很复杂。Delivery History保存了与其Cargo有关的Handling Event集合,而且新对象必须作为事务的一部分来添加到这个集合中(见图7-5)。如果没有创建这个反向指针,那么对象间将发生不一致。



我们可以把反向指针的创建封装到FACTORY中(并将其放在领域层中——它属于领域层),但现在我们来看另一种设计,它完全消除了这种别扭的交互。

九、停一下,重构:Cargo AGGREGATE的另一种设计

建模和设计并不总是一个不断向前的过程,如果不经常进行重构,以便利用新的知识来改进模型和设计,那么建模和设计将会停滞不前。

到目前为止,我们的设计中有几个蹩脚的地方,虽然这并不影响设计发挥作用,而且设计也确实反映了模型。但设计之初看上去不太重要的问题正渐渐变得棘手。让我们借助事后的认识来解决其中一个问题,以便为以后的设计做好铺垫。

由于添加Handling Event时需要更新Delivery History,而更新Delivery History会在事务中牵涉Cargo AGGREGATE。因此,如果同一时间其他用户正在修改Cargo,那么Handling Event事务将会失败或延迟。输入Handling Event是需要迅速完成的简单操作,因此能够在不发生争用的情况下输入Handling Event是一项重要的应用程序需求。这促使我们考虑另一种不同的设计。

我们在Delivery History中可以不使用Handling Event的集合,而是用一个查询来代替它,这样在添加Handling Event时就不会在其自己的AGGREGATE之外引起任何完整性问题。如此修改之后,
这些事务就不再受到干扰。如果有很多Handling Event同时被录入,而相对只有很少的查询,那么这种设计更加高效。实际上,如果使用关系数据库作为底层技术,那么我们可以设法在底层使
用查询来模拟集合。使用查询来代替集合还可以减小维护Cargo和Handling Event之间循环引用一致性的难度。

为了使用查询,我们为Handling Event增加一个REPOSITORY。Handling Event Repository将用来查询与特定Cargo有关的Event。此外,REPOSITORY还可以提供优化的查询,以便更高效地回答
特定的问题。例如,为了推断Cargo的当前状态,常常需要在Delivery History中查找最后一次报告的装货或卸货操作,如果这个查找操作被频繁地使用,那么就可以设计一个查询直接返回相关
的Handling Event。而且,如果需要通过查询找到某次Carrier Movement装载的所有Cargo,那么很容易就可以增加这个查询。

循环引用的创建和维护也不再是问题。Cargo Factory将被简化,不再需要为新的Cargo实例创建一个空的Delivery History。数据库空间会略微减少,而且持久化对象的实际数量可能减少很多(在某些对象数据库中,能容纳的持久化对象的数量是有限的)。如果常见的使用模式是:用户在货物到达之前很少查询它的状态,那么这种设计可以避免很多不必要的工作。

另一方面,如果我们正在使用对象数据库,则通过遍历关联或显式的集合来查找对象可能会比通过REPOSITORY查询快得多。如果用户在使用系统时需要频繁地列出货物处理的全部历史,而不是偶尔查询最后一次处理,那么出于性能上的考虑,使用显式的集合比较有利。此外要记住,现在并不需要查询“这次Carrier Movement上都装载了什么”,而且这个要求可能永远也不会被提出来,因此暂时不必过多地注意该选项。

这些类型的修改和设计折中随处可见,仅仅在这个简化的小系统中,我就可以举出许多示例。但重要的一点是,这些修改和折中仅限于同一个模型内部。通过对VALUE、ENTITY以及它们的AGGREGATE进行建模(正如我们已经做的那样),已经大大减小了这些设计修改的影响。例如,在这个示例中,所有的修改都被封装在Cargo的AGGREGATE边界之内。它还需要增加一个Handling

Event Repository,但并不需要重新设计Handling Event本身(虽然根据不同的REPOSITORY框架细节,可能需要对实现进行一些修改)。

十、 运输模型中的MODULE

到目前为止,我们只看到了很少的几个对象,因此MODULE化还不是问题。现在,我们来看看大一点的运输模型(当然,这仍然是简化的),从而了解一下MODULE的组织怎样影响模型。
contact
我们应该寻找紧密关联的概念,并弄清楚我们打算向项目中的其他人员传递什么信息。如同应对规模较小的建模决策时,总是会有多种方法可以达成目的。图7-8显示了一种直观的划分方法。


图7-8 基于宽泛的领域概念的模块

图7-8中的MODULE名称成为团队语言的一部分。我们的公司为客户(Customer)运输货物(Shipping),因此向他们收取费用(Bill),公司的销售和营销人员与Customer磋商并签署协议,操作人员负责将货物Shipping到指定目的地,后勤办公人员负责Billing(处理账单)并根据Customer协议开具发票。这是可以通过这组MODULE描述的业务。

当然,这种直观的分解可以通过后续迭代来完善,甚至可以被完全取代,但它现在对MODEL-DRIVEN DESIGN大有帮助,并且使UBIQUITOUS LANGUAGE更加丰富。

十一、引入新特性:配额检查

到目前为止,我们已经实现了最初的需求和模型。现在要添加第一批重要的新功能。

在这个假想的运输公司中,销售部门使用其他软件来管理客户关系、销售计划等。其中有一项功能是效益管理(yield management),利用此功能,公司可以根据货物类型、出发地和目的地或者任何可作为分类名输入的其他因素来制定不同类型货物的运输配额。这些配额构成了各类货物的运输量目标,这样利润较低的货物就不会占满货舱而导致无法运输利润较高的货物,同时避免预订量不足(没有充分利用运输能力)或过量预订(导致频繁地发生货物碰撞,最终损害客户关系)。

现在,他们希望把这个功能集成到预订系统中。这样,当客户进行预订时,可以根据这些配额来检查是否应该接受预订。配额检查所需的信息保存在两个地方,Booking Application必须通过查询这些信息才能确定接受或拒绝预订。图7-9给出了一个大体的信息流草图。

十一、1.连接两个系统

销售管理系统(Sales Management System)并不是根据这里所使用的模型编写的。如果Booking Application与它直接交互,那么我们的应用程序就必须适应另一个系统的设计,这将很难保持一个清晰的MODEL-DRIVEN DESIGN,而且会混淆UBIQUITOUS LANGUAGE。相反,我们创建一个类,让它充当我们的模型和销售管理系统的语言之间的翻译。它并不是一种通用的翻译机制,而只是对我们的应用程序所需的特性进行翻译,并根据我们的领域模型重新对这些特性进行抽象。这个类将作为一个ANTICORRUPTION LAYER(防腐层)。

这是连接销售管理系统的一个接口,因此首先就会想到将它叫做Sales Management Interface(销售管理接口)。但这样一来就失去了用对我们更有价值的语言来重新描述问题的机会。相反,让我们为每个需要从其他系统获得的配额功能定义一个SERVICE。我们用一个名为Allocation Checker(配额检查器)的类来实现这些SERVICE,这个类名反映了它在系统中的职责。

如果还需要进行其他集成(例如,使用销售管理系统的客户数据库,而不是我们自己的Customer REPOSITORY),则可以创建另一个翻译类来实现用于履行该职责的SERVICE。用一个更低层的类(如Sales Management System Interface)作为与其他程序进行对话的机制仍然是一种很有用的方法,但它并不负责翻译。此外,它将隐藏在Allocation Checker后面,因此领域设计中并不展示出来。

十一、2、进一步完善模型:划分业务

我们已经大致描述了两个系统的交互,那么提供什么样的接口才能回答“这种类型的货物可以接受多少预订”这个问题呢?问题的复杂之处在于定义Cargo的“类型”是什么,因为我们的领域模型尚未对Cargo进行分类。在销售管理系统中,Cargo类型只是一组类别关键词,我们的类型只需与该列表一致即可。我们可以把一个字符串集合作为参数传入,但这样会错过另一个机会——重新抽象那个系统的领域。我们需要在领域模型中增加货物类别的知识,以便使模型更丰富;而且需要与领域专家一起进行头脑风暴活动,以便抽象出新的概念。

有时,分析模式可以为建模方案提供思路。《分析模式》[Fowler 1996]一书介绍了一种用于解决这类问题的模式:ENTERPRISE SEGMENT(企业部门单元)。ENTERPRISE SEGMENT是一组维度,它们定义了一种对业务进行划分的方式。这些维度可能包括我们在运输业务中已经提到的所有划分方法,也包括时间维度,如月初至今(month to date)。在我们的配额模型中使用这个概念,可以增强模型的表达力,并简化接口。这样,我们的领域模型和设计中就增加了一个名为Enterprise Segment的类,它是一个VALUE OBJECT,每个Cargo都必须获得一个Enterprise Segment类。


图7-10

Allocation Checker将充当Enterprise Segment与外部系统的类别名称之间的翻译。Cargo Repository还必须提供一种基于Enterprise Segment的查询。在这两种情况下,我们可以利用与Enterprise Segment对象之间的协作来执行操作,而不会破坏Segment的封装,也不会导致它们自身实现的复杂化(注意,Cargo Repository的查询结果是一个数字,而不是实例的集合)。

但这种设计还存在几个问题:
(1) 我们给Booking Application分配了一个不该由它来执行的工作,那就是对如下规则的应用:“如果Enterprise Segment的配额大于已预订的数量与新 Cargo数量的和,则接受该Cargo。”执行业务规则是领域层的职责,而不应在应用层中执行。
(2) 没有清楚地表明Booking Application是如何得出Enterprise Segment的。

这两个职责看起来都属于Allocation Checker。通过修改接口就可以将这两个服务分离出来,这样交互就更整洁和明显了。


这种集成只有一条严格的约束,那就是有些维度是不能被Sales Management System使用的,具体来说就是那些无法用Allocation Checker转换 为Enterprise Segment的维度(在不使用ENTERPRISE SEGMENT的情况下,这条约束的作用是使销售系统只能使用那些可以在Cargo Repository查询中使用的维度。虽然这种方法也行得通,但销售系统将会溢出而进入领域的其他部分中。

在我们这个设计中,Cargo Repository只需处理Enterprise Segment,而且销售系统中的更改只影响到Allocation Checker(配额检查器),而Allocation Checker可以被看作是一个FACADE)。

十一、3 性能优化

虽然与领域设计的其他方面有利害关系的只是Allocation Checker的接口,但当出现性能问题时,Allocation Checker的内部实现可能为解决这些问题提供机会。例如,如果Sales Management System运行在另一台服务器上(或许在另一个位臵上),那么通信开销可能会很大,而且每个配额检查都需要进行两次消息交换。

第二条消息需要调用Sales Management System来回答是否应该接受货物,因此并没有其他的替代方法可用来处理这条消息。但第一条消息是得出货物的Enterprise Segment(企业部门单元),这条消息所基于的数据和行为与配额决策本身相比是静态的。这样,一种设计选择就是缓存这些信息,以便Allocation Checker在需要的时候能够在自己的服务器上找到它们,从而将消息传递的开销降低一半。但这种灵活性也是有代价的。设计会更复杂,而且被缓存的数据必须保持最新。但如果性能在分布式系统中是至关重要的因素的话,这种灵活部署可能成为一个重要的设计目标。

总结:

这种集成原本可能把我们这个简单且在概念上一致的设计弄得乱七八糟,但现在,在使用了ANTICORRUPTION LAYER、SERVICE和ENTERPRISE SEGMENT之后,我们已经干净利落地把Sales Management System的功能集成到我们的预订系统中了,从而使领域更加丰富。

为什么不把获取Enterprise Segment的职责交给Cargo呢?

如果
Enterprise Segment的所有数据都是从Cargo中获取的,那么乍看上去把它变成Cargo的一个派生属性是一种不错的选择。遗憾的是,事情并不是这么简单。为了用有利于业务策略的维度进行划分,我们需要任意定义Enterprise Segment。出于不同的目的,可能需要对相同的ENTITY进行不同的划分。

出于预订配额的目的,我们需要根据特定的Cargo进行划分;但如果是出于税务会计的目的时,可能会采取一种完全不同的Enterprise Segment划分方式。甚至当执行新的销售策略而对Sales Management System进行重新配臵时,配额的Enterprise Segment划分也可能会发生变化。如此,Cargo就必须了解Allocation Checker,而这完全不在其概念职责范围之内。而且得出特定类型Enterprise Segment所需使用的方法会加重Cargo的负担。

因此,正确的做法是让那些知道划分规则的对象来承担获取这个值的职责,而不是把这个职责施加给包含具体数据(那些规则就作用于
这些数据上)的对象。另一方面,这些规则可以被分离到一个独立的Strategy对象中,然后将这个对象传递给Cargo,以便它能够得出一个Enterprise Segment。这种解决方案似乎超出了这里的需求,但它可能是之后设计的一个选择,而且应该不会对设计造成很大的破坏。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容