[DDD]读书笔记《领域驱动设计:软件核心复杂性应对之道》(2)模型驱动设计的构造块⑥

前文回顾

上一篇介绍了该书的第二部分“模型驱动设计的构造块”,将面向对象领域建模中的一些核心的最佳实践提炼为一组基本的构造块。我们了解了提供查找和对象持久化的REPOSITORY(仓库)。

这一篇我们继续学习该书的第二部分。我们会在一个模拟的场景中,通过处理假象的需求和实现中出现的问题,来实际运用我们前面学到的建模技巧。

实战场景

假设我们正在为一家货运公司开发新软件。最初的需求包括3项基本功能:

  1. 跟踪客户货物的主要处理;
  2. 事先预约货物;
  3. 当货物到达其处理过程中的某个状态时,自动向客户寄送发票。

基本模型

假设我们通过一段时间的分析,得到了以下基本概念模型。

image.png

一个Cargo(货物)涉及多个Customer(客户),每个Customer承担不同的角色。Cargo的运送目标已指定。由一系列满足Specification(规格)的CarrierMovement(运输动作)来完成运送目标。

HandlingEvent(处理事件)是对Cargo采取的不同操作,如将它装上船或清关。这个类可以被细化为一个由不同种类的事件(如装货、卸货或由收货人提货)构成的层次结构。

DeliverySpecification(运送规格)定义了运送目标,至少包括目的地和到达日期。

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

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

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

隔离领域层

我们使用前文提到过的LAYERED ARCHITECTURE,把领域层分离出来。我们可以识别出三个用户级别的应用程序功能,可以将这三个功能分配给三个应用层类。

  1. TrackingQuery(跟踪查询),它可以访问某个Cargo过去和现在的处理情况。
  2. BookingApplication(预订应用),它允许注册一个新的Cargo,并使系统准备好处理它。
  3. IncidentLoggingApplication(事件日志应用),它记录对Cargo的每次处理,供TrackingQuery查找。

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

区分ENTITY和VALUE OBJECT

Cargo

两个完全相同的货箱必须要区分开,因此Cargo对象是ENTITY。

Customer

Customer对象表示一个人或一家公司,从一般意义上来讲它是一个实体。Customer对象显然有对用户来说很重要的标识,因此它在模型中是一个ENTITY。

HandlingEvent和CarrierMovement

我们关心这些独立事件是因为通过它们可以跟踪正在发生的事情。它们反映了真实世界的事件,而这些事件一般是不能互换的,因此它们是ENTITY。

DeliveryHistory

DeliveryHistory是不可互换的,因此它是ENTITY。DeliveryHistory与Cargo是一对一关系,因此它实际上并没有自己的标识。它的标识来自于拥有它的Cargo。

Location

名称相同的两个地点并不是同一个位置,而用经纬度作为唯一标识也不合适,经纬度并不是经常被用到的,使用自动生成的内部任意标识符可以唯一区分,因此它是ENTITY。

DeliverySpecification

它表示了Cargo的目标,但这种抽象并不依赖于Cargo。它实际上表示某些DeliveryHistory的最终状态。运送货物实际上就是让Cargo的DeliveryHistory最后满足该Cargo的DeliverySpecification。如果有两个Cargo去往同一地点,那么它们可以用同一个DeliverySpecification。因此,DeliverySpecification是VALUE OBJECT。

Role和其他属性

Role表示了有关它所限定的关联的一些信息,但它没有历史或连续性。因此它和其他属性(如时间,名称)一样,是一个VALUE OBJECT。

设计对象的关联关系

前文我们提到对象的关联应该是有方向的,而且应当尽量避免双向关联。基于这个精神,我们可以得出如下关联:

image.png

如果我们的应用程序要对一系列货船进行跟踪,那么从CarrierMovement遍历到HandlingEvent将是很重要的。但我们的业务只需跟踪Cargo,因此只需从HandlingEvent遍历到CarrierMovement就能满足我们的业务需求。

模型中存在一个循环引用:Cargo知道它的DeliveryHistory,DeliveryHistory中保存了一系列的HandlingEvent,而HandlingEvent又反过来指向Cargo。很多领域在逻辑上都存在循环引用,而且循环引用在设计中有时是必要的,但它们维护起来很复杂。在选择实现时,应该避免把必须同步的信息保存在两个不同的地方。

划定AGGREGATE的边界

Cargo AGGREGATE可以把一切因Cargo而存在的事物包含进来,这当中包括DeliveryHistory、DeliverySpecification和HandlingEvent。这很适合DeliveryHistory,因为没人会在不知道Cargo的情况下直接去查询DeliveryHistory。DeliverySpecification是一个VALUE OBJECT,因此将它包含在Cargo AGGREGATE中也不复杂。

HandlingEvent就是另外一回事了。如果业务中需要查找装货和准备某次CarrierMovement时所进行的所有操作,那么与Cargo本身分开来考虑也是有意义的,因此HandlingEvent应该是它自己的AGGREGATE的根。

Customer、Location和CarrierMovement都有自己的标识,而且被许多Cargo共享,因此,它们在各自的AGGREGATE中必须是根。

image.png

选择REPOSITORY

上面的设计中,我们划定了5个聚合根,所以设计REPOSITORY时只能从这5个ENTITY里面选择。

为了确定这5个实体当中哪些确实需要REPOSITORY,必须回头看一下应用程序的需求。要想通过BookingApplication进行预订,用户需要选择承担不同角色(托运人、收货人等)的Customer。因此需要一个 CustomerRepository

在指定货物的目的地时还需要一个Location,因此还需要创建一个 LocationRepository。用户需要通过ActivityLoggingApplication来查找装货的CarrierMovement,因此需要一个 CarrierMovementRepository。用户还必须告诉系统哪个Cargo已经完成了装货,因此还需要一个 CargoRepository

在第一次迭代中我们决定将HandlingEvent与DeliveryHistory的关联实现为一个集合,而且应用程序并不需要查找在一次CarrierMovement中都装载了什么货物。基于这两个原因,我们没有创建HandlingEventRepositoty。当然如果情况改变了,可以增加一个REPOSITORY。

image.png

创建对象

即使我们为某些ENTITY设计了FACTORY,但是仍然需要基本的构造函数。作为ENTITY,我们希望它的标识保持不变。可以在FACTORY中添加以下方法:

public Cargo newCargo(Cargo prototype, String newTrackingId)
复制代码

也可以将生成新ID的过程封装在FACTORY中,这样就只需要一个参数:

public Cargo newCargo(Cargo prototype)
复制代码

Cargo与DeliveryHistory之间的双向关联意味着它们必须要互相指向对方才算是完整的,因此它们必须被一起创建出来。Cargo是聚合根,因此,我们可以用Cargo的构造函数或FACTORY来创建DeliveryHistory。

public Cargo(String id) {
    trackingId = id;
    deliveryHistory = new DeliveryHistory(this);
    customerRoles = new HashMap();
}
复制代码

HandlingEvent是通过Cargo的ID、完成时间和事件类型的组合来唯一标识的。HandlingEvent剩下的属性是与CarrierMovement的关联,这个并不是必须的。对ENTITY而言,这些非标识作用的属性通常可以过后再添加。

public HandlingEvent(Cargo cargo, String eventType, Date timeStamp) {
    handled = cargo;
    type = eventType;
    completionTime = timeStamp;
}
复制代码

为每种EventType添加一个工厂方法,并附带必要参数是一种便利的做法,这也让代码更具有表达力。例如,loadingEvent(装货事件)确实涉及一个CarrierMovement。

public static HandlingEvent newLoading(Cargo cargo, CarrierMovement loadedOnto, Date timeStamp) {
    HandlingEvent res = new HandlingEvent(cargo, LOADING_EVENT, timestamp);
    res.setCarrierMovement(loadedOnto);
    return res;
}
复制代码

前面遇到的循环引用:Cargo→DeliveryHistory→HistoryEvent→Cargo使得实例创建变得很复杂。DeliveryHistory保存了与其Cargo有关的HandlingEvent集合,而且新的HandlingEvent对象必须作为事务的一部分来添加到这个集合中。为了避免对象间的不一致,我们可能需要一个反向指针(HandlingEvent→DeliveryHistory),但像下图这样做的话的确很别扭。

image.png

重构

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

我们可以将DeliveryHistory中的HandlingEvent集合实现为一个查询,这样可以使HandlingEvent的插入变得简单,而且不会与Cargo AGGREGATE发生争用。于是我们需要为HandlingEvent增加一个REPOSITORY来实现查询。

这样做的话,DeliveryHistory就不再有持久状态了,因此可以在需要用DeliveryHistory回答某个问题时,再创建它。CargoFactory将被简化,不再需要为新的Cargo实例创建一个空的DeliveryHistory。

通过对VALUE、ENTITY以及它们的AGGREGATE进行建模,已经大大减小了这些设计修改的影响。例如我们这次只需要增加一个HandlingEventRepository,但并不需要重新设计HandlingEvent本身。

image.png

划分模块

前面的模型很简单,模块话还不是问题,我们来看一个完整一点的模型,看它是如何划分模块的。

下图是按照模式(PATTERN)来划分的,很明显这种划分并没有传递领域知识。

image.png

我们的业务可以概括为:公司为客户(Customer)运输货物(Shipping),因此向他们收取费用(Bill),公司的销售和营销人员与Customer磋商并签署协议,操作人员负责将货物Shipping到指定目的地,后勤办公人员负责Billing(处理账单),并根据Customer协议开具发票。按照这种领域概念,可以划分为如下模块。

image.png

新需求:配额检查

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

新的需求是要把这个配额检查集成到预订系统中,以确定是否可以接受客户的预订。BookingApplication所使用的信息一方面来自SalesManagementSystem(销售管理系统),一方面来自我们自己的领域REPOSITORY。

image.png

如果让BookingApplication和销售管理系统直接交互,我们就必须适应另外一个系统的设计,这样很难保持一个清楚的模型。相反,我们可以创建一个类,让它充当我们的模型和销售管理系统的语言之间的翻译,而且它只是对我们所需的特性进行翻译。

我们为每个需要从其他系统获得的配额功能定义一个SERVICE。我们用一个名为AllocationChecker(配额检查器)的类来实现这些SERVICE。

那么问题来了,配额是按照Cargo类型来管理的,而我们系统中并没有定义Cargo类型。简单的做法是用一个与销售管理系统一致的字符串列表来表示类型。但这样做我们没有对Cargo类型建模,领域模型中就缺少了货物类别的知识。为了让模型更丰富,我们可以借鉴Fowler在《分析模式》一书中提出的ENTERPRISE SEGMENT PATTERN(企业部门单元)。ENTERPRISESEGMENT是一组维度,它们定义了一种对业务进行划分的方式。这样,我们的领域模型和设计中就增加了一个名为EnterpriseSegment的类,它是一个VALUE OBJECT。

image.png

接下来,关于这条业务规则:“如果EnterpriseSegment的配额大于已预订的数量与新Cargo数量的和,则接受该Cargo。”我们需要考虑在应该哪里实现。而且我们需要搞清楚BookingApplication调用的allocation方法中的EnterpriseSegment参数是如何得来的。

这些事情应该是在领域层中实现,应该交给AllocationChecker来做。所以可以优化一下接口,将两个功能分离开,让交互过程更加清晰易懂。

image.png

在我们这个设计中,CargoRepository只需处理EnterpriseSegment,而且销售系统中的更改只影响到AllocationChecker,而AllocationChecker可以被看作是一个FACADE(外观模式)。

最后一个问题:为什么不把获取EnterpriseSegment的职责交给Cargo呢?如果EnterpriseSegment的所有数据都是从Cargo中获取的,那么乍看上去把它变成Cargo的一个派生属性是一种不错的选择。

遗憾的是,事情并不是这么简单。为了用有利于业务策略的维度进行划分,我们需要任意定义EnterpriseSegment。出于不同的目的,可能需要对相同的ENTITY进行不同的划分。出于预订配额的目的,我们需要根据特定的Cargo进行划分;但如果是出于税务会计的目的时,可能会采取一种完全不同的EnterpriseSegment划分方式。

如果让Cargo来做,Cargo就必须了解AllocationChecker的逻辑,而这完全不在其概念职责范围之内。而且得出特定类型EnterpriseSegment所需使用的方法会加重Cargo的负担。因此,正确的做法是让那些知道划分规则的对象来承担获取这个值的职责,而不是把这个职责施加给包含具体数据(那些规则就作用于这些数据上)的对象。

猜你喜欢

转载自juejin.im/post/7016278079295356941