Ddd

领域驱动设计[Domain-Driven Design,简称DDD],在网上没有找到合适的定义,在这里,我试着去定义它:

DDD把领域概念(通用语言)贯穿到软件开发过程的始末(分析、设计、开发、测试),从而保证领域、设计、实现的一致性,并以保持领域模型的独立性和坚持面向对象设计方法为原则,基于模型驱动的软件开发方法。

DDD从两个方面为我们提供指导:战术设计模式战略设计模式。战略设计模式指导我们在较高的抽象层次去分析领域问题,而战术设计模式为领域设计细节方面提供一些指导原则。

软件的复杂性并不在技术上,而是来自领域业务,因此,真正决定软件复杂性的是领域的设计方法,当设计得好,意味着软件更简单易懂,迭代成本也就更低。

一个良好的领域模型应能:

  • 与实现紧密关联,模型能反映实现
  • 模型能作为通用语言
  • 深度体现领域知识

DDD能带给我们什么?

  • 领域专家和开发者使用通用语言工作在一起,这样开发出来的软件能够准确地表达业务规则。
  • 领域专家和开发者在DDD的开发过程中,每个人都能学习和提高业务知识,每个人都能成为知识的贡献者,确保知识并不只是掌握在少数人手中。
  • 战略设计能够指导我们辨析领域的核心子域,通用子域,指导人员安排。战术设计能够指导我们设计出DDD中的各个部件。
  • 由于通用语言渗透到代码中,因此代码是可以清晰地表达出领域模型的,并与领域专家的思维模型保持一致,即实现是能与设计保持同步的。
  • 领域模型有着高度的独立性(高内聚低耦合),这意味着它是可重用的。
  • 领域模型踏实地遵循面向对象的设计方法,所以能为我们解决复杂的业务逻辑。

更概括地说明DDD的好处有:1. 通用语言贯穿软件开发的各个阶段,从而保证领域、设计、实现的一致。2. 始终坚持使用面向对象的设计方法来解决领域问题; 3. 由于领域模型的高度独立性,使得领域模型是可重用 ; 4. 使用通用语言,可使每个人都能学习和提高业务知识

在使用DDD时,我们应该采用最简单的方式对复杂领域进行建模,而不是使问题变得更加复杂。即DDD的作用应该是化繁为简

没有应用DDD时,会导致什么问题?

  • 大量的项目使用的都是没有内在行为的getter/setter对象,这些对象称为贫血领域对象。贫血领域对象会导致业务意图不明确,因为在一个事务中你需要调用多个对象的getter/setter方法,调用一组getter/setter方法的意图是不明确的。
  • 代码实现意图不明确,导致代码维护和迭代成本不断提高,最终只会得到一个逻辑关系错综复杂,难以理解的臃肿系统,从而导致失忆症的产生。
  • 使用的是面向对象的语言,却做着面向过程的事情,因为我们使用的对象是没有行为的,这些对象只是数据持有器。因此并没有利用到面向对象分析方法带给我们的好处:化繁为简!

DDD的业务价值

  • DDD的战略设计模型指导我们将精力花在对业务最有价值的东西上,即为软件开发指明了大方向和合理的成本安排。
  • 通用语言的持续发现和更新使业务得到了更准确的定义和理解,从而使人们对业务的理解逐渐加深,一些业务问题和细节将会不断地暴露出来。
  • 使用共同语言去表达领域模型,使更多的人熟悉业务,降低了人员变动造成的影响
  • 领域模型是按领域专家心中的模型来设计的,使用户获得更好的用户体验。效率提高了,培训也减少了。
  • 限界上下文的划分产生更明确的目标,明确的目标产生高效的解决方案
  • 上下文映射图集成不同的限界上下文,有助于我们全面地了解整个企业的架构。

DDD所面临的挑战

  • 我们需要花费大量的时间和精力来思考业务领域,研究概念和术语,并且和领域专家交流,以发现、捕捉和改进通用语言。
  • 持续地将领域专家引入项目
  • 改变开发者对领域的思考方式,从面向技术层面转变为面向业务层面思考问题

何时应该DDD

  • 需要持续迭代,最终可能会演变成一个复杂的业务系统的时候

DDD中使用测试驱动

  1. 编写测试代码以模拟客户代码是如何使用该领域对象的
  2. 实现并持续重构该领域对象使测试代码能够编译通过
  3. 向团队成员展示代码,保证领域对象能够正确地反映通用语言

感悟:DDD产出的是领域模型。领域模型实现了业务目标,体现了业务的核心价值,因此,所有技术的选择应该以能最好地服务于领域模型为依据,包括架构,框架,基础设施等。实现领域驱动的一个重要原则是保持领域模型的高内聚低耦合(在我的理解中就是高度的独立性),即领域模型只依赖其自身,从不依赖外部任何组件,六边型架构能很好地满足这一点。

领域模型

领域模型是关于某个特定业务领域的软件模型。通常,领域模型通过对象模型来实现,这些对象同时包含了数据和行为,并且表达了准确的业务含义

从广义上讲,领域(Domain)即是一个组织所做的事情以及其中所包含的一切。

通用语言

通用语言是一种团队协作模式,用于捕捉特定业务领域中的概念和术语,反映了领域专家对于软件系统的思维模型。软件开发最大问题之一便是业务人员和技术人员需要某种翻译才能交流,通用语言则是为业务人员和技术人员搭起了沟通的桥梁。

一套通用语言只在一个特定的限界上下文内适用的,脱离了特定的限界上下文,这些术语表达的将是其他的含义。如果一个概念术语在两个不同的限界上下文中表达了相同的含义,那么就要考虑一下是否限界上下文的边界存在重叠了。

通用语言有以下的特点:

  • 有边界的

    这套语言只有在一个特定的限界上下文中才是有意义的。所以每个限界上下文都应该有一套独立的通用语言

  • 团队共享的
  • 清晰性、简洁性
  • 会发展的

    随着对领域认识的不断加深,而不断演变的。

我们应该在整个限界上下文的分析/设计/开发/实现/测试过程中,都保持使用同一套通用语言,随着不同概念的发现或变更,对其持续维护和更新。

通用语言需要记录到模型、代码、文档中去。

如何生成通用语言

通用语言其中一个重要来源是领域专家对领域的概念理解,需要开发人员聆听和理解领域专家的想法。另一方面,开发人员需要向领域专家提出疑问,有些疑问可能是领域专家也不知道的,这时候就要通过讨论来发现领域概念,并提取有业务价值的概念,开发人员的软件建模的抽象能力也能为通用语言作出贡献,通用语言就是在这样的过程中逐步形成完善。

通用语言是在讨论、聆听、理解、发现、提取中形成的

限界上下文

限界上下文是一个概念性的显式边界,领域模型便工作于其中。同时,限界上下文为通用语言提供了一套环境,项目成员便通过通用语言来表达软件模型。限界上下文指定了通用语言的适用范围。

限界上下文是一个显式边界,领域模型便存在于边界之内。在边界内,通用语言中的所有术语和词组都有特定的含义,而模型需要准确地反映通用语言。

一个特定的限界上下文是一套通用语言、一个领域模型的边界,一套通用语言只能表达一个单一的领域模型。

在一个好的限界上下文中,每一个术语应该仅表示一种领域概念。——这是判断限界上下文划分正确与否的一个依据。

限界上下文的粒度问题?概念的粒度选择问题?抽象角度与抽象层次?

限界上下文划分方法

领域中同时存在着问题空间(problem space)和解决方案空间(solution space)。在问题空间中,我们思考的是业务所面临的挑战,在解决方案空间中,我们思考如何实现软件以解决这些业务挑战。

公式

问题空间 = 核心域 + \(\sum_0^n\)支撑子域/通用子域

每个问题空间都是由一个核心域和多个支撑子域/通用子域组成。因为每个问题空间必然会有一个最关键的、最重要的子域去实现的,这个就是核心域了。如果在一个问题空间中找不到核心域,那么问题空间就没有解决的意义了。核心域不是孤立存在的,它往往需要一些支撑子域或通用子域的支持。

解决方案空间 = \(\sum_1^n\)限界上下文

由一个或多个限界上下文组成解决方案空间。有些限界上下文可能是已经存在的,而有些则需要创建。另外,还需要考虑如何将这些限界上下文集成到一起。

理清问题空间的一般过程

  1. 问题空间所面临的最关键、最重要的问题是什么?如何用一个名字来表达它?这个过程就确定了核心域的名字和目标了
  2. 核心域还需要哪些支撑子域来解决问题空间?
  3. 核心域中有哪些概念?
  4. 如何分配人手?

理清了问题空间,相应的解决方案空间就基本上可以确定下来,一般情况下,一个子域对应着一个限界上下文,但不排除出现一个子域包含了多个限界上下文,如库存子域需要库存限界上下文和地图处理上下文(外部)。

我们应该给予核心域最高的优先级、最资深的领域专家和最优秀的开发团队。

限界上下文的大小

限界上下文应该足够大,以能够表达它所对应的整套通用语言。核心领域之外的概念不应该包含在限界上下文中。

我们应该避免从技术层面去思考问题,而应该更多地从业务层面去思考问题。

上下文映射图

[Brandolini]:上下文映射图 [Hohpe & Woolf]:消息集成限界上下文

上下文映射图用于表示不同的限界上下文之间的集成关系,甚至可能加入翻译、模块、聚合等信息。上下文映射图的示例:

上下文映射图并不是一种企业架构,也不是系统拓扑图,但它可以用于高层次的架构分析,指出诸如集成瓶颈之类的架构不足。

限界上下文之间的协调关系有以下这些:

  • 合作关系(Partnership)——紧密的合作关系,要么一起成功,要么一起失败。
  • 共享内核(Shared Kernel)——共享一部分的模型或代码,这部分代码的修改需要与相关团队协商。
  • 客户方-供应方开发(Customer-Supplier Development):下游团队依赖于上游团队才能完成开发,上游团队会把下游团队的需求列入计划
  • 遵奉者(Conformist):与客户方-供应方开发方式类似,但上游团队并不能保证满足下游团队需求。下游团队可能面临孤军无助的状况。

限界上下文集成方式

在我看来,限界上下文的集成方式只有两种:同步调用和异步调用。这两种方式都需要防腐层(Anticorruption Layer)作为限界上下文之间的翻译层。防腐层可以是一个领域服务(Domain Service),也可以是一个资源库接口。

资源库通常是用来持久化和重建聚合的,如果远程调用创建的就是聚合对象,那么资源库就是一种自然的选择了。

对于同步调用,可以使用开放主机服务(Open Host Service)发布语言(Published Language)公开一种协议契约,使外部限界上下文可以通过公开协议访问到本地限界上下文。开放主机服务+发布语言=接口契约。如Http+XML/JSON的接口契约。

对于异步调用,可以使用领域事件(Domain Event)发布语言(Published Language)的方式进行集成。

服务自治性的实施:

在本地创建一些由外部模型翻译而成的领域对象,这些对象保留着本地模型所需的最小状态集。要与远程模型保持同步,最好的方式是在远程系统中采用面向消息的通知(notification)机制。

自治服务并不是意味着完全独立于上游模型,而是我们的设计应该尽可能地限制实时依赖性。

远程调用可能面临的问题

  • 调用的失败而导致雪崩
  • 网络是不可靠的
  • 总会存在时间延迟,有时甚至非常严重
  • 带宽是有限的
  • 不要假设网络是安全的
  • 网络传输是有成本的
  • 网络是异构的

基于远程调用可能面临的问题,远程调用需要考虑:

  • 网络传输是有成本的,因此需要考虑序列化的选择方案。可以使用JSON/XML等数据传输格式,并在服务提供方和消费方共享数据类型,并注意版本升级时的兼容性问题。另外还可以使用XPATH方式按契约解析传输数据。
  • 事务最终一致性,由于RPC的调用失败,可能会导致事务的不一致。

关于限界上下文集成方式中事务最终一致问题的解决办法,请参考微服务最终一致性实现

架构

领域模型应该是架构中立的(即领域模型应该适用于任何架构), 通过战略和战术设计而成的领域模型应该是架构中立的。当然,在模型周围和模型之间则是存在架构的。

六边形架构

最适合领域模型的架构是六边形架构,架构图如下:

六边形架构提倡用一种新的视角来看待整个系统,该架构下只存在两个区域:“外部区域”和“内部区域”。内部区域是指应用程序内部,外部区域是指不同的客户可以通过不同的适配器转化客户输入为程序内部API所能理解的输入,或应用程序通过不同的适配器输出到不同的地方。

应用程序边界,即内部六边形,也是用例(或用户故事)边界。当应用程序通过API接收到请求时,它将使用领域模型来处理请求。

面向服务架构

SOA的设计原则:

REST——Web架构风格

REST在服务器的关键方面

  • 每个URI指向某个资源,资源是具有展现和状态的(即属性)
  • 资源是具有行为的,如GET/PUT/POST/DELETE
  • 资源并不独立存在,即资源之间存在关联,这种关联通过超媒体(Hypermedia)实现,客户端可以沿着某种路径发现应用程序可能的状态变化。

命令和查询职责分离(CQRS)

CQRS的基本指导原则是一个方法要么执行某种动作的命令(声明为Void),要么是返回数据的查询(返回数据),而不应该在查询时执行命令操作。

CQRS旨在解决数据显示复杂性问题,而不是什么绚丽的新风格。

CQRS将使聚合不再有查询方法,而只有命令方法。资源库也将变成只有add()或save()方法,同时只有一个按标识的查询方法。将所有查询方法移除之后,我们将此时的模型称为命令模型(Command Model),而使用称为查询模型(Query Model)来优化查询。

由于CQRS需要将原来的模型分成查询模型和命令模型,存在两个分布式存储,自然引起一致性问题,而解决CQRS最终一致性的方法如下:

  • 让用户界面临时性地显示先前提交给命令模型的参数。
  • 显式地在用户界面上显示出当前查询模型的日期和时间。
  • 使用静默更新的方式,如Comet,观察者或分布式缓存/网格。

事件驱动架构

在六边形架构中,事件总是来源于一个限界上下文,而经处理后以新事件的形式发往另一个限界上下文,直到这个事件流结束。事件流的处理类似于shell命令中的管道和过滤器,在六边形架构中,事件处理器是过滤器,而端口连接着事件的输入输出管道。

长时处理过程(Saga)

长时处理过程是指需要等待多个事件流执行完成之后,才能进入下一步操作的过程。

设计长时处理过程的核心是设计一个跟踪器,以跟踪多个事件流的处理结果。一般的设计方法有两种:

  • 执行器与跟踪器分离,即分别作为实体处理。
  • 执行器与跟踪器聚合成一个聚合对象。

长时处理过程面临的最大挑战是事件流的中间环节出现异常而导致无法知道事件流的执行结果,此时跟踪器可加入超时机制。

事件源

事件源其实就是对聚合的每个命令操作都需要发布一个事件,并把这些事件存储起来,通过事件重放来重做聚合。事件源一般与CQRS一起使用,因为事件源不利于查询。

事件源有以下优点:

  • 聚合持久化的另一种方案
  • 通过修改事件历史来修改聚合,以修复bug或撤消对聚合的修改。
  • 有了完整的事件历史数据,有利于数据分析和向智能化方向发展
  • 用作消息队列,通过消息中间件发布这些事件
  • 用于基于REST的消息通知

数据网织和基于网络的分布式计算

GemFire和Coherence

实体

开发者趋向于将关注点放在数据上,而不是领域上,因为在软件开发中,数据库依然占据着主导地位。我们首先考虑的是数据的属性(对应数据库的列)和关联关系(外键关联),而不是富有行为的领域概念。这样最终只会导致贫血对象的出现。

实体具有身份标识(identity),拥有相同身份标识的实体是同一个实体,一个实体可以在相当长的一段时间内持续地变化。

唯一的身份标识和可变性(mutability)特征将实体对象和值对象区分开来。

唯一标识

唯一标识的生成策略:

  • 由用户提供的一个或多个唯一值作为唯一标识。如手机号码
  • 程序内部通过某种算法生成唯一标识。如UUID
  • 依赖持久化存储生成唯一标识,如数据库。
  • 由另一个限界上下文提供唯一标识。

值对象可以用作唯一标识

标识的生成时间有两种,一种是持久化实体之前,另一种则是持久化实体之后。标识的生成时间会影响程序的运行,需要谨慎注意。

委派标识(Surrogate Identity)是指有些ORM工具标识的生成方式单一,当我们需要为实体使用其他生成方式时,需要在实体中使用两种标识,一种为领域所使用,另一种为ORM使用,为ORM使用的标识即为委派标识

实体分析方法

要从需求中找出实体,需要先对需求做细致的分析,包括用例场景。将需求中描述不具体,存在歧义的地方进行修正后,再去找出实体对象。

区分一个对象是否实体的原则:

  • 是否有查找该对象的需要(这就要求对象含有标识)
  • 该对象在生命周期内是否会进行修改(对象生命周期的连续性)

之后,为实体确定最必不可少的属性和行为。

实体验证

验证的主要目的在于检查模型的正确性,检查的对象可以是某个属性,也可以是整个对象,甚至是多个对象的组合。

验证属性可以使用自封装(Self-Encapsulation)来验证属性。

整个对象的验证可以使用规格模式(Specification)来进行验证,即创建一个单独的验证类。

对象组合的验证可以放在领域服务中进行。

规格模式(Specification)

规格表达了一个规则,或者某个事物的要求。使用规格模式可以很好地在代码中表达领域模型中的规则术语。

规格模式有三种基本用途:

  • 验证
public interface Specification{
	boolean isSatisfiedBy(Object object);
}
  • 选择或查询
Set filter(Sepcification);

或者用于sql查询

public interface Specification{
	String asSQL();
}

public class Repository{
	Set select(Specification);
}
  • 按要求创建或生成

由于规格表达了某个事物的内在要求。比如说我们要买一个电视柜,但我们的墙面只有2米长,因此电视柜的规格应该在2米内,所以2米内这个规格则可以作为电视柜的一个属性。

由于一个规格表达的是一个谓语,谓语之间可以用逻辑表达式关联起来,于是,规格模式可扩展为以下形式:

public interface Specification{
	boolean isSatisfiedBy(Object object);
	Specification and(Specification other);
	Specification or(Specification other);
	Specification not();
}

两个规格之间可能存在这样的一种关系,规格A会比规则B更严格,这种关系称为包容(subsumes)。例如有一个规格C由规格A and 规格B逻辑组成,则规格C一定比规格A更严格,即规格C包容规格A。规格模式进一步扩展为以下形式:

public interface Specification{
	boolean isSatisfiedBy(Object object);
	Specification and(Specification other);
	Specification or(Specification other);
	Specification not();
	boolean subsumes(Specification other);
}

实体与聚合的区别是,聚合具有一致性边界,组成不变条件的属性集合。

值对象

值类型用于度量和描述事物,我们可以非常容易地对值对象进行创建、测试、使用、优化和维护。

值对象的特征:

  • 它是一个不变量
  • 它将不同的相关属性组合成一个概念整体,能度量和描述领域中的一个概念。
  • 当度量和描述改变时,可以用另一个值对象替换。
  • 它可以和其他值对象进行相等性比较。
  • 它不会对协作对象造成副作用

如果你试图将多个属性加在一个实体上,但这样却弱化了各个属性之间的关系,那么此时你便应该考虑将这些相互关联的属性组合在一个值对象中了。每个值对象都是一个内聚的概念整体,它表达了通用语言中的一个概念。如果其中一个属性表达了一种描述性概念,那么我们应该把与该概念相关的所有属性集中起来。如果其中一个或多个属性发生了改变,那么可以考虑对整体值对象进行替换。

无副作用函数(Side-Effect-Free Funtion)

无副作用函数是指一个对象方法,它只产生输出,而不会修改对象的状态。对于不变的值对象而言,所有的方法都必须是无副作用函数,因为它们不能破坏值对象的不变性。

要增加一个值对象的健壮性,我们传给值对象方法的参数依然应该是值对象。这样我们可以获得更高层次的无副作用行为。

领域服务

当领域中的某个操作过程或转换过程不是实体或值对象的职责时,我们便应该将该操作放在一个单独的接口中,即领域服务。请确保该领域服务和通用语言是一致的,并且保证它是无状态的。

领域事件

将领域中所发生的活动建模成一系列的离散事件。每个事件都用领域对象来表示,领域事件是领域模型的组成部分,表示领域中所发生的事情。

领域事件的作用:

  • 维护事务的最终一致性,消除两阶段提交。
  • 支持聚合原则实施——在单个事务中,只允许对一个聚合实例进行修改,由此产生的其他改变必须在另外单独的事务中完成。

领域事件的流转:

事件订阅方(订阅处理与事件发布在同一个线程中的情况)不应该在另一个聚合上执行命令方法,因为这样将破坏“在单个事务中只修改单个聚合实例”的原则,聚合实例之间的最终一致性必须通过异步的方式予以处理。

转发存储事件的架构风格:

  1. 以REST资源的方式发布事件通知
  2. 通过消息中间件发布事件通知

模块

模块的划分原则:

  • 模块应能体现领域概念,并用通用语言来命名
  • 不要机械式地根据通用的组件类型和模式来创建模块,如将所有领域服务或工厂都放在一个模块
  • 模块之前的依赖关系应该保持单向依赖,避免双向依赖
  • 保持模块高内聚低耦合

命名规范:

  • 界面层—— 域名倒序.上下文限界名.resources.领域概念名
  • 应用服务—— 域名倒序.上下文限界名.application.领域概念名
  • 领域层—— 域名倒序.上下文限界名.domain.model.领域概念名
  • 基础设施层—— 域名倒序.上下文限界名.infrastructure

聚合

聚合是由实体和值对象在一致性边界之内组合而成的。聚合模式一方面表达了对象组合和信息隐藏,另一方面表达了聚合的边界就是事务一致性边界。

聚合建模原则

在一致性边界之内建模真正的不变条件

不变条件决定了聚合的边界,也就决定了事务一致性的边界。不变条件表示一个业务规则,该规则应该总是保持一致的。聚合边界之内的所有内容组成一套不变的业务规则,任何操作都不能违背这些规则。对于一个设计良好的聚合来说,无论由于何种业务需求而发生改变,在单个事务中,聚合中的所有不变条件都是一致的。而对于一个设计良好的限界上下文来说,无论在哪种情况下,它都能保证在一个事务中只修改一个聚合实例。此外,在设计聚合时,我们必须将事务分析也考虑在内。

该原则也表达了在同一个事务内只能对一个聚合实例进行修改

设计小聚合

大聚合的问题:

  • 大聚合也要保证事务一致性边界,由于“大”,使得在并发条件下保证大聚合的一致性成为困难,最终限制系统的性能和可伸缩性。
  • 大聚合会导致内存消耗过大

小聚合的“小”是指用根实体(Root Entity)来表示聚合,根实体只包含最小属性集合,包括值类型属性。最小属性集合是指那些必须与其他属性保持一致的属性,不多也不少。

疑问:如何设计一个群组限制最多只能10个人加入的场景?

这个问题思考了很久,其实在明白了聚合建模原则之后,这个问题其实很好解决。在这里有两个实体:群组和人。首先,要意识到人不能做为群组内部的实体而存在,因为群组和人的生命周期不相同,群组并不能管理人的生命周期,相反,群组就更不能成为人的成员实体了,人就算没有群组也是客观存在的。再次,可以确认群组和人都应该是聚合根,聚合根之间的引用应该使用ID标识。因此,这里对群组建模时,只能Set形式关联人的ID,限制10人则通过版本乐观锁来实现了。

小聚合的优点:

  • 性能好和可伸缩性强
  • 有助于事务成功执行,减少事务提交冲突。

疑问:当一个用例要求,在一个事务中修改多个聚合,这种情况下应该如何处理?

要么可能是我们的聚合设计错误,缺少了某些聚合不变条件,需要将多个聚合组合成一个新的聚合,要么通过聚合间的最终一致性来实现。

疑问:到底要用大聚合还是小聚合呢?

我认为并不是所有的聚合都要设计成最小聚合,聚合的大小应该要适中,而且要视具体情况而定。如果用例要求必须保证事务一致性的话,那么聚合的大小边界应能保证这个事务一致性,如果用例要求允许最终一致性,那么可将聚合拆分,以获得小聚合所带来的好处。

通过唯一标识引用其他聚合

[Evans]写道,一个聚合可以引用另一个聚合的根聚合,此时被引用的聚合不应该放在引用聚合的一致性边界之内。

一个聚合是可以引用另一个聚合的根聚合的,但此时:

  • 被引用的聚合不应该放在引用聚合的一致性边界之内
  • 不可以在同一个事务中对引用聚合和被引用聚合进行修改

如果希望一个聚合不引用另一个聚合,可以只引用该聚合的唯一标识。通过唯一标识引用其他聚合的好处:

  • 创建的聚合会更小
  • 小的聚合会增强模型的性能
  • 使用分布式存储聚合的时候,可以得到无限伸缩性。

通过标识引用并不意味着我们完全丧失了对象导航性,我们可以在应用服务调用聚合行为之前,使用资源库或领域服务来获取所需要的对象。

不建议在聚合中使用资源库来定位其他聚合,这种技术称为失联领域模型。

通过唯一标识引用其他聚合的缺点:在客户端的用户界面层,要组装多个聚合并予以显示将变得非常困难,我们不得不使用多个资源库,此时,如果对聚合的查询导致了性能问题,那么我们可以考虑theta联合查询或者CQRS

在边界之外使用最终一致性

当在一个聚合上执行命令方法时,如果还需要在其他的聚合上执行额外的业务规则,那么请使用最终一致性,最终一致性通过领域事件达到。如果这时候,事件订阅方由于并发竞争而使修改失败时,订阅方并不向消息中间件发回成功确认信号,由消息机制进行重发,然后开始一个新的事务重新触发更新操作。

使用迪米特法则(Law of Demeter)和“告诉而非询问”原则(Tell,Don’t Ask)

  • 迪米特法则,强调“最小知识”原则。一个客户对象应尽量少地知道服务对象的内部结构,客户对象不应该知道任何关于服务对象属性的信息,而只能调用服务对象上的表层接口。
  • 告诉而非询问原则,客户对象不应该先询问服务对象,然后根据询问结果调用服务对象中的方法,而是应该通过调用服务对象的公共接口的方式来“告诉”服务对象所要执行的操作。

从经验上来说,聚合的建模原则个人总结如下:

  1. 不变性条件决定了聚合的边界,不变性条件的意思就是不管这个聚合怎么变,这个不变性条件都必须能满足的。
  2. 从完整性的角度来看,当聚合缺少了某个实体或值对象,聚合会变得不完整时(无法确定或满足不变性条件时),那么这些实体或值对象就应该作为聚合内的成员。
  3. 某个实体是否能成为聚合的成员的一个重要判断标准是,这个实体的生命周期是否只能由聚合来管理,即该实体的生命周期在聚合生命周期范围内

另外,理解聚合根、实体、值对象之间的区别也有利于明白聚合的建模:

  1. 从标识的角度:聚合根具有全局的唯一标识,而实体只有在聚合内部有唯一的本地标识,值对象没有唯一标识,不存在这个值对象或那个值对象的说法;
  2. 从是否只读的角度:聚合根除了唯一标识外,其他所有状态信息都理论上可变;实体是可变的;值对象是只读的;
  3. 从生命周期的角度:聚合根有独立的生命周期,实体的生命周期从属于其所属的聚合,实体完全由其所属的聚合根负责管理维护;值对象无生命周期可言,因为只是一个值;

聚合根、实体、值对象对象之间如何建立关联?

  • 聚合根到聚合根:通过ID关联;
  • 聚合根到其内部的实体,直接对象引用;
  • 聚合根到值对象,直接对象引用;
  • 实体对其他对象的引用规则:1)能引用其所属聚合内的聚合根、实体、值对象;2)能引用外部聚合根,但推荐以ID的方式关联,另外也可以关联某个外部聚合内的实体,但必须是ID关联,否则就出现同一个实体的引用被两个聚合根持有,这是不允许的,一个实体的引用只能被其所属的聚合根持有;
  • 值对象对其他对象的引用规则:只需确保值对象是只读的即可,推荐值对象的所有属性都尽量是值对象;

乐观并发问题

使用Hiberate的ORM持久化方案时,可以使用版本号自增的乐观锁方案。每个根聚合的修改都会导致版本号的增加,当发现表中的版本号比根聚合的版本号大时,即发生并发冲突。但并不是根聚合的修改都必然导致版本号的增加,比如根聚合下的值对象列表发生修改的情况,这时,可以将值对象设计成实体,每个实体都维护着一个版本号,或者手动调整根聚合的版本号。

当使用NoSQL持久化方案时,如MongoDB、Riak、Coherence分布式网格和GemFire,都提供了乐观锁的支持,所以不会出现Hiberate中修改了根聚合,而版本号没有增加的问题。

工厂

将创建复杂对象和聚合的职责分配给一个单独的对象,该对象本身并不承担领域模型中的职责,但是依然是领域设计的一部分。工厂应该提供一个创建对象的接口,该接口封装了所有创建对象的复杂操作过程,同时,它并不需要客户去引用那个实际被创建的对象。对于聚合来说,我们应该一次性地创建整个聚合,并且确保它的不变条件得到满足。[Evans]

在使用工厂方法时,聚合的构造函数对客户端来说是隐藏的。

工厂方法可以应用在根聚合、领域服务或单独一个工厂类。工厂方法应用在根聚合下,是为了创建与根聚合相关联的聚合对象。而应用在领域服务中,是为了创建由外部限界上下文翻译过来的要地限界上下文的领域对象。

资源库

对于每种需要进行全局访问的对象,我们都应该创建另一个对象来作为这些对象的提供方,就像是在内存中访问这些对象的集合一样。为这些对象创建一个全局接口以供客户端访问。为这些对象创建添加和删除方法,此外,我们还应该提供能够按照某种指定条件来查询这些对象的方法。[Evans]

只为聚合创建资源库,通常来说,聚合类型和资源库存在一对一的关系。如果你只是随机地,直接地获取和使用实体,而不用考虑聚合的事务边界,那么你可以不考虑使用资源库。

存在两种类型的资源库设计,面向集合(collection-oriented)和面向持久化(persistence-oriented)

面向集合资源库

这种设计方式把资源库模拟成了一个集合,或者至少模拟了集合的标准接口。面向集成的资源库可以看成是一个Set接口,或者资源库可以通过Set集合来模拟实现。面向集合的资源库应该真正地模拟一个集合。

当使用面向集合资源库设计方式时,无论采用什么类型的持久化机制,多次添加同一个聚合实例,只会在第一次添加时有效,其余添加行为都应忽略。另外,当从资源库中获取到一个对象并对其进行修改时,我们并不需要“重新保存”该对象到资源库中。

由于面向集合的资源库要真实地模拟一个集合,这就需要持久化机制必须能够隐式地跟踪发生在每个持久化对象上的改变,有两种方法实现这样的目的:

  • 隐式读时复制:从数据存储中读取对象后,复制一个备份对象,当对象提交到数据存储时,与备份对象比较,以确认要更新的部分。
  • 隐式写时复制:使用委派对象来管理所有被加载的持久化对象,委派对象会调用真实对象中的行为方法,当委派对象首次接收到方法调用时,它将创建一份对真实对象的备份。委派对象将跟踪发生在真实对象上的改变,并将其标记为“肮脏的”(dirty)。当事务提交时,该事务检查所有的“肮脏”对象并将对它们的修改更新到数据存储。

由此可见,面向集合资源库的缺点是需要消耗大量内存,而且不适合用于性能要求非常高的场景。支持实现面向集合资源库的ORM框架有Hiberate和Oracle的TopLink。

面向集合资源库的一般接口定义:

public interface Repository{
	
	//唯一标识生成方法
	public Id nextId();
	
	//添加到集合中
	public void add(Object aggregation);
	
	//从集合中删除,一般业务场景不允许删除聚合,可去掉该接口
	public void remove(Object aggregation);
	
	//按ID查询
	public Object ofId(Id id);
	
	//按其他条件查询
	public Collection ofOtherCondition(Object otherCondition);
		
	public int size();
	
	...
	
}

面向持久化资源库

如果持久化机制不支持对对象变化的跟踪,无论是显式的还是隐式的,那么采用面向集合资源库便不再适用了。此时,我们可以考虑使用面向持久化资源库,这是一种基于保存操作的资源库。

在向数据存储中添加新建对象或修改既有对象时,我们都必须显式地调用put()方法,该方法将以新的值来替换先前关联在某个键上的原值。这种类型的数据存储可以极大地简化对聚合的读写。正因如此,这种数据存储也称为聚合存储(Aggregate Store)或面向聚合数据库(Aggregate-Oriented Database)。

使用面向持久化资源库,需要考虑使用一种优秀的序列化方案,从序列化/反序列化的性能,序列化后的体积,兼容性去考虑。

面向持久化资源库的一般接口定义:

public interface Repository{
	
	//唯一标识生成方法
	public Id nextId();
	
	//保存聚合
	public void save(Object aggregation);
	
	//删除聚合
	public void remove(Object aggregation);
	
	//按ID查询
	public Object ofId(Id id);
	
	//按其他条件查询
	public Collection ofOtherCondition(Object otherCondition);
		
	public int size();
	
	...
	
}

额外的行为

当需要在应用中执行存储过程或数据网格的条目处理器等将业务逻辑转移至数据存储时,这部分的代码调用应该放在领域服务中。

另外,我们可以在资源库中创建一些特殊的查找方法,比如,用户界面显示的数据来自多个聚合,此时,我们可以使用用例优化查询(Use Case Optimal Query)的方法直接查询所需要的数据,即直接在持久化机制上执行查询,然后将查询结果放在一个值对象中予以返回。

类型层级

假如一些聚合都扩展自一个特定于领域的超类,而这些聚合所组成的类型层级具有可互换性和多态性的特征,此时,我们可以使用单个资源库来保存和获取层级中的不同聚合类型,而客户端无须知道他们所使用的实际类形。这也体现了Liskov替换原则。

这类问题其实可以使用在聚合中维护一个类型属性来彻底解决。有了类型属性,就不需要维护多个子类聚合了,类型属性就可以区分不同的子类,然后利用状态模式来处理分发逻辑。

用户界面

用户界面描述的核心问题是如何将领域对象渲染到用户界面以及如何将用户操作反映到领域模型上。

渲染领域对象的方式

往往需要渲染的领域对象不是一个,而是多个的,要渲染领域对象有这几种方式:数据传输对象(DTO-Data Tranfer Object),调停者模式,领域负载对象(DPO-Domain Payload Object)。

DTO

DTO将包含需要显示的所有属性值,这些属性值来自于多个聚合实例。由于聚合具有隐藏内部细节的特点,而DTO方式又要求访问聚合内部,这样就会破坏聚合的封装性。

调停者模式

调停者模式,即双分派,用于解决DTO会破坏聚合封装性的缺点。这种方式使用调停者接口来发布聚合的内部状态。调停者模式又将会使聚合的职责不再单一。

DPO

DPO方式是将多个聚合实例汇聚到一个DPO对象中,由DPO对象负责暴露需要显示的属性。这种方式的缺点是,当使用延迟加载时,DPO对象访问延迟加载的对象时将成为困难,而且同样存在DTO相同的问题。

个人认为最好的渲染方式是DTO+调停者模式两种方式共用。

用例优化查询

当查询多个聚合实例的成本较高时,可直接查询资源库并返回值对象的方式来提高性能。

支持不同类型的客户端

将应用服务的输出适配到不同类型的客户端,有两种方式达到这个目的:转换器方式和端口方式。

转换器方式即在应用服务接口方法中添加一个转换器入参,该转换器用于将应用服务的输出转换到客户端所需的数据格式。

端口方式即应用服务接口方法的返回类型变更为void,应用服务的输出端口将把数据输出到不同客户端的输入端口中,客户端的输入端口将使用自己的转换器来转换输入数据,从而适配不同的客户端输出。该方式最不好的地方在于应用服务接口声明将难以理解,因为它的输出隐藏在输出端口中,我们不能从接口声明中看到其输出内容。

应用服务

应用服务可以用于控制持久化事务和安全认证,或者向其他系统发送基于事件的消息通知,应用服务是很轻量的,它主要用于协调对领域对象的操作,另外,应用服务是表达用例和用户故事(user story)的主要手段。

应用服务的方法签名中将只出现原始类型(int,long等),但更好的方式是使用命令模式。