手把手落地DDD
作者:钟敬
追风赶月莫停留,平芜尽处是春山。
如果想看改造旧系统相关可以直接查看【34|落地经验:怎样在实际项目中推广DDD?】
02|迭代一概述:怎样开启一个麻雀虽小五脏俱全的项目?
领域驱动设计主要的开发流程:
03|事件风暴(上):怎样和业务愉快地聊需求?
事件风暴是怎么一回事?
事件风暴的主要过程:
实际上,领域事件表示的是,业务流程中每个步骤引发的结果。
在 DDD 中的各种命名,一般都优先使用约定俗成的业务术语。
关于领域事件,我们还要注意下面这两点。
- 第一,不要把技术事件当成领域事件。领域事件一定要是领域专家所关注的,用的是业务术语。像数据库事务已回滚、缓存已命中之类的技术术语,不是领域事件,不在这个阶段讨论。
- 第二,查询功能不算领域事件。领域事件应该是对某样事物产生了影响,并被记录的事情。一般是某个事物的创建、修改和删除。还有一种情况是向其他人或者系统发消息,例如”通知邮件已发送”也算领域事件,因为接收方可能会通过进一步处理来影响某些事物。
所谓统一语言,英文是 Ubiquitous Language,是 DDD 中的一个核心模式。指的是业务人员和开发人员使用的语言要一致。语言是知识的载体。语言一致就意味着背后对领域知识的理解一致。统一语言贯穿了 DDD 的全过程。
04|事件风暴(下):事件风暴还有哪些诀窍?
事件风暴第二步:识别命令
所谓命令(command),就是引发领域事件的操作,我们可以通过分析领域事件得到。除了识别出命令本身以外,我们通常还要识别出谁执行的命令,以及为了执行命令我们要查询出什么数据。
事件风暴第三步:识别领域名词
这里说的领域名词,是从命令、领域事件、执行者、查询数据里找到的名词性概念。例如,对于签订合同这个命令而言,受到影响的名词性概念是”合同”;类似地,对于合同已签订这个领域事件,是由于”合同”这个名词性概念的状态变化所导致的。
再谈事件风暴的作用
首先我们看看领域事件的作用。从代码实现的角度来看,领域事件一般会对应一段代码逻辑,这段逻辑可能会最终改变数据库中的数据。另外,在事件驱动的架构中,一个领域事件可能会表现为一个向外部发送的异步消息。
那命令的作用体现在哪儿呢?领域建模时,我们可以通过对命令的走查(walkthrough),细化和验证领域模型。在实现层面,一个命令可能对应前端的一个操作,例如按下按钮;对于后端而言,一个命令可能对应一个 API。
再来说命令的执行者。在领域建模时,执行者可能本身就是一个领域对象,也可能是领域对象充当的角色,或者是权限管理中的一个角色。
其实识别领域名词的最终目的是要找到领域模型中的对象。
事件风暴的常见问题
第一个问题是,在事件风暴里是否要列出所有的领域事件和命令?
在事件风暴里只列出主要的、足以用于表达和交流领域知识的步骤,例如签订合同、生效合同等等。而像修改合同和删除合同这样的步骤是显而易见的,在讨论过程中可以提一下,但不必真的列出来,这样是为了保持简洁
第二个问题是,各个领域事件需要体现严格的时间顺序吗?
只需要按照大致的顺序,贴出领域事件就可以了。这是因为,如果要体现严格的时间顺序,需要用到更复杂的符号,例如条件判断,还有要画更多的连线,这会使事件风暴变得非常繁琐。
因此,我们应该关注点分离。如果要体现严格的时间顺序,我们可以用流程图、用顺序图等方法,但事件风暴不必关注这一点。
第三个问题是,每个步骤的颗粒度应该有多大?
这里说的步骤,指的是一对领域事件和命令。
比如说,”签订合同”这个命令,在具体操作的时候,可能分成录入合同基本信息、录入合同明细、上传附件等等更小的步骤。那么,我们需要为每一个小步骤都识别出领域事件和命令吗?
这就要考虑从业务的角度,我们是把每个小步骤都当作独立的一个事务来看待,还是把它们合起来作为同一个事务。
另外,可以设想,如果每个小步骤都向外界发出一个领域事件,对系统后续的功能是不是有意义。那么在目前的需求里,合同作为一个整体来提交就可以了,分成小的领域事件,并没有意义,所以不再分成更小的步骤了。
在实践里,有时仍然会有模棱两可的情况,这时,原则上宜粗不宜细。可以先采用比较大的颗粒度。后面必要的时候,再拆细,就可以了。
第四个问题是,事件风暴适用于所有项目吗?
事件风暴主要应用在需求不清晰,或者理解不统一的情况下,通过协作的方式理清业务、达成一致,所以通常对于新项目比较适用。
至于遗留系统改造的情况,如果这个系统的知识已经流失得很严重,那么事件风暴仍然是有意义的。但如果大家对这个系统的业务知识很清楚,只是要进行架构改造,那么事件风暴的意义就不大了。
总结
事件风暴是一种通过协作的方式捕获行为需求的方法,在这个过程里,业务人员和技术人员一起消化领域知识、形成统一语言、并为领域建模奠定基础。
事件风暴分为识别领域事件、识别命令、识别领域名词三个步骤。这一节课讲的是后面两个步骤。
“命令”是引发领域事件的操作,可以从领域事件”反推”出来。此外,还可以识别命令的两个附加信息,一个是发出命令的”执行者”,另一个是为了完成命令要查询出的数据。
“领域名词”是隐含在命令和领域事件中的名词性概念。这些名词是领域建模的素材, 而对于这些素材的深入分析可以留到领域建模进行
05|领域建模实践(上):怎样既准确又深刻地理解业务知识?
领域建模中的一些基本概念
领域建模主要有两个目的:
- 将知识可视化,准确、深刻地反映领域知识,并且在业务和技术人员之间达成一致;
- 指导系统的设计和编码,也就是说,领域模型应该能够比较容易地转化成数据库模式和代码实现。
而我们建立领域模型,主要是要识别领域对象(domain object),领域对象之间的关系,以及领域对象的关键属性,必要的时候还要将领域对象组织成模块。
那么,什么是领域对象呢?我们系统中要处理的各种”事物”就是领域对象。比如说项目、员工、账户等等。这些对象都反映了名词性的概念。
其中,有些名词化了的动词也是领域对象。比如说我们进行了一笔支付操作,并且想把这笔操作记录下来。这时,”支付”也是领域对象。支付本来是动词,但这里实际上是要把一笔支付的信息记录下来,在这里就把”支付”当名词用了。
领域模型是用领域模型图来表达的,通常用 UML 来画。
在领域建模过程中,我们说领域对象时,有时指类,有时指实例,一般可以通过上下文来区分。
此外,DDD 中将领域对象又分成实体(entity)和值对象(value object)。
在领域建模阶段,我们主要关注的是实体和它们之间的关系。如果实体的名字已经能清晰说明实体的含义,那我们就不需要加属性了。如果名字还不足以充分表达含义,我们可以写几个关键属性,来辅助说明。
在 UML 中,用大括号括起来的内容称为”约束”(constraint)。和一般性的注释不同,凡是约束,必须在程序中的某个地方进行实现。
“0..*” 我们也可以这样理解:一个组织最少有 0 个员工,最多可以有很多员工。
“1..1” 表示,一个员工最少要属于一个组织,最多也只能属于一个组织。
06|领域建模实践(下):领域建模还有什么其他技巧?
划分模块
操作(operation)在 UML 里也叫方法(method)。对象的属性是静态的值,而操作是动态的逻辑。在 UML 中,操作用”操作名+ 括号”的方式表示,括号中可以写参数。
人的认知能力是有限的,面对这样一张复杂的对象网络,就产生了认知过载(cognitive overload)。
解决这一问题的方法就是”模块化”。也就是说,把模型中的业务概念组织成若干高内聚的模块(module),而模块之间尽量低耦合。
在 UML 中,可以用包来表示模块。包的符号是下面这样:
建立词汇表
接着我们来建立词汇表,也就是把事件风暴和领域模型中重要的词汇列成表。为什么要建立词汇表呢?主要是有两个作用。
首先,我们需要通过词汇表来规范领域模型中的词汇。同一个词,可能会在领域模型中出现多次,时间久了,就可能不一致,因此需要进一步规范。
第二,是可以用于后续编程中的命名。按照 DDD 的要求,程序中的各种命名也需要统一,并且需要与领域模型中保持一致。我们会在词汇表中列出英文全称和缩写,以达到这个目的。
词汇表是保证统一语言的重要手段。
07|领域建模原理:DDD领域建模和传统方法有什么区别?
什么是领域模型?
在讨论什么是领域模型之前,咱们先说说什么是模型。
首先,模型是以解决特定问题为目的的。例如沙盘模型是为了卖房,而建筑图纸是为了盖楼。没有目的就谈不上模型。
第二,模型都是对现实世界或人们思维中的事物进行的模拟。例如沙盘模型和建筑图纸都是对建筑物的模拟,而玩具车是对真车的模拟。
第三,模型总是提取了被模拟事物中的部分信息,而忽略掉了其他大部分信息。例如,沙盘模型提取了楼盘的外观信息,但是忽略了内部结构和建筑材料信息。而建筑图纸反映了内部结构信息,但忽略了外观信息。到底提取哪些信息,忽略哪些信息,取决于模型的目的。
第四,模型可以有多种表现形式,例如图纸、影像、公式以及电脑中的文件等等。具体采用哪种形式,取决于要解决的问题和当前的技术水平。
最后,模型是一种人造物,大自然本身是不存在模型的。
DDD软件研发过程
DDD 的核心模式之一:模型驱动设计就是围绕领域模型展开的。它有两个要点:领域模型和业务需求要保持一致;系统实现和领域模型也要保持一致。最终的结果就是系统实现和业务需求保持一致。
除此之外,DDD 还有另一个核心模式:统一语言。要用好统一语言,一方面要建立好领域模型、词汇表等”物质基础”,另一方面要在沟通协作的过程中,不断保持模型、语言和系统实现的一致性。
08|数据库设计:怎样按领域模型设计数据库?
模型中的一个一对多关联,可以映射成一个外键字段,以及一个外键约束。但基于云的应用一般不会真的建立外键约束,而外键的逻辑关系还是存在的。我们用虚线箭头表示这种逻辑上的外键关系,称为虚拟外键。对于多对多关联,我们必须增加一个关联表,其中包括了两个实体表各自的主键。另外,关联上的多重性决定了外键字段的非空约束。
与”ER 图法”的区别
首先,采用 UML 类图描述的领域模型图是 ER 图的超集。也就是说,ER 图能表达的,领域模型图都能表达;而领域模型图能表达的,ER 图未必能表达。因此,使用领域模型图以后,我们就不必再使用 ER 图了。
其实我们前几节课进行的领域建模,大体上相当于传统意义上的”概念设计”。如果把领域模型中的属性都补全,就相当于传统意义的”逻辑设计”了。而我们今天做的,其实就是传统上的”物理设计”,所以产物叫做”物理数据模型”。
第二个区别是,ER 图只能表达静态的数据关系,只用于数据库设计,而领域模型图则可以将静态数据和动态行为绑定,不仅可以用于数据库设计,还可以用于程序设计,这一点我们在后面的课程会看到。也就是说,基于 DDD 的方法能够保证程序设计和数据库设计的高度统一。
第三个区别是,领域模型对应的主要是传统软件工程的分析模型,而 ER 图在传统软件工程里则处于设计阶段,所以两者的层次和使用场合也是不一样的。
DDD 方法是 ER 图法的”超集”,并且能够将静态数据和动态逻辑整合在一起,达到业务、数据库和代码三者的统一。
09|分层架构:怎样逃离”大泥球”?
代码中不稳定的部分,应该依赖稳定的部分。
10|代码实现(上):要”贫血”还是要”充血”?
“面向对象”还是”面向过程”?
贫血模型指的是领域对象中只有数据,没有行为,这种风格违背了面向对象的原则。
“富领域模型”,也就是领域对象里既包含数据,也包含行为。
DDD 强调,在代码编写阶段,如果发现模型的问题,要及时修改模型,始终保持代码和模型的一致。
领域模型图中一定不能存在只有技术人员才懂的内容。
11|代码实现(中):怎样创建领域对象、实现领域逻辑?
“表意接口”(Intention-Revealing Interfaces)模式
DDD 强调,每个类和方法的命名都应该尽量直观地反映领域知识,与统一语言保持一致。这种做法也是 DDD 的一个模式,叫做 “Intention-Revealing Interfaces”,可以译作表意接口。
“领域服务”(Domain Service)模式
如果一个逻辑需要和领域专家讨论才能确认的,就是领域逻辑;如果领域专家根本不感兴趣的,多半就是应用逻辑。
“工厂”(Factory)模式
DDD 认为,领域对象的创建逻辑也是领域层的一部分。如果创建领域对象的逻辑比较简单,可以直接用对象的构造器来实现。但是如果比较复杂,就应该把创建逻辑放到一个专门的机制里,来保证领域对象的简洁和聚焦。
这里说的专门机制可以是一个方法或者一个类,可以有很多种实现方式。不论具体方式是什么,在 DDD 里统称为工厂(Factory)模式。准确地说,工厂其实是用来创建聚合的。工厂和前面说的仓库这两个模式,其实是一种隐喻(metaphor):用工厂来创造产品,然后存到仓库。
所谓创建逻辑复杂,包括两方面:一是规则复杂,二是结构复杂。DDD 认为用于校验的领域逻辑也属于创建过程的一部分。
模块划分的”打横”与”打竖”
尽管《重构》一书中没有明确说,但是一个包下有太多的类,也是一种常见的坏味道。那么多少算”多”呢,也没有明确的标准,不过根据心理学的认知负载理论,我建议,一个包里的类和子包的数量加在一起,最好不要超过 9 个。
事实上,一个应用服务和调用它的控制器以及被它调用的领域对象之间才具有耦合性。分层架构把本来耦合的几个对象拆到了不同的包。之所以我们在分层架构中采用按性质分包的方式,是因为,将领域逻辑与其非领域逻辑分离,以及将技术相关和无关两部分分离,这两个”关注点分离”的好处实在太大,大过了破坏”松耦合高内聚”原则的代价。
但是另一方面,在每个层次内部,我比较建议尽量按照耦合性分包。这是由于排除上述两个”关注点分离”以后,”松耦合高内聚”的好处就体现出来了。如果把按层次划分叫做”打横”分,把按耦合性划分叫做”打竖”分,咱们目前采取的是就是”先横后竖”的分法。
12|代码实现(下):怎样更加”面向对象”?
通过”表意接口”提高封装性
在面向对象设计中一个常见的陷阱就是滥用继承。要防止这一倾向,要记住一个原则,不要仅仅为了复用而使用继承。你还要问自己一个问题,父类和子类的关系,在语义上,是否有分类关系,或者概念的普遍和特殊关系。只有符合这种关系的,才能采用继承,否则应该用”组合”来实现复用。
13|迭代二概述:怎样更深刻地理解领域知识?
模型的建立
模型的实现
14|聚合的概念:怎样保护业务规则?
聚合的概念
第一,具有整体与部分的关系。也就是说,逻辑上,员工信息是整体,而技能信息是员工信息的一部分。
第二,具有不变规则,而且这种不变规则在并发的时候可能被破坏。要防止规则的破坏,仅仅锁住一条技能记录是不够的,必须把员工和所有技能作为一个整体锁住才能解决。或者说,员工和他的所有技能确定了一个事务边界。
具有这样特征的一组领域对象,在 DDD 里就叫做一个聚合(Aggregate)。
聚合的表示法
让我们看看这个图是怎么表达的。
首先,在员工实体名字上方,我们加了一个 <<aggregate root>> 的标识,中文是聚合根的意思。在一个聚合里,像员工这样代表整体的实体就是聚合根。一个聚合只有一个聚合根。
<<aggregate root>> 外面这个像书名号一样的符号,其实不是书名号,而是两个小于号和两个大于号。这个符号在 UML 里叫做 stereotype ,中文译作 “衍型“ 。这是 UML里用来扩充符号意思的一种机制。
比如说,表示员工实体的方框在 UML 中本来用来表示”类”。加上 <<aggregate root>>以后,就衍生出了”表示聚合根的类”的符号。所谓”衍型”就是”衍生出来的符号类型”。这种机制我们后面还会用到。
再看表示员工和技能一对多关联的那条实线。在员工一端,变成了一个空心棱形。这种符号专门表示整体部分关系,有菱形的一端是代表”整体”的对象,另一端是代表”部分”的对象。整体部分关系是关联关系的一种特例。
另外,原来这一端的 “1..1” 被删掉了。因为,对于这种整体部分关系而言,这一端必然是”1..1”。你可以思考一下为什么。虽然写上 “1..1” 也对,但由于必然是,所以出于简洁的原因,就可以不写了。
最后,我们用一个包把这个聚合中的类包起来,从而可以一眼看出这个聚合的边界。一般我们约定,聚合包的名字和聚合根的名字是一样的。
识别更多的聚合
不过,一般来说,业务人员最关心的就是当前客户经理,历史变更信息只在少数情况下才用到。所以,领域专家希望强调当前客户经理这个概念。因此,我们保留了这个关联。
这个关联是可以由客户经理推导出来的,称为”派生关联“(derived association)。仔细看一下,在”/ 当前客户经理”前面有一个斜杠。在 UML 中,凡是前面有斜杠的,就表示是派生出来的内容。
识别出聚合以后,模型图变成下面的样子。
进一步理解聚合概念
首先,作为部分的实体,只能属于一个聚合根,不可能属于多个聚合根。比如说,一条技能信息,只能属于一个员工,不能属于多个员工。又比如说,我的手只能是我一个人的手,不能同时又是其他人的手。
其次,我的手是不能”跳槽”的。不能今天是我的手,明天就变成了别人的手。也就是说,一个聚合的一部分,不能再变成其他聚合根的一部分。
再次,由前两条自然可以推出,聚合根被删除,那么聚合中的所有对象都会被删除。
最后,还有一个”标识”的问题。在业务上,为了识别每个实体,实体必然要有一个标识。例如,人的标识,可以是身份证号。如果这个人是学生,那么他的标识也可以是学号。注意,这里说的标识是一个业务概念,而不是技术概念,和数据库表中常见的没有业务概念的 ID 是不同的。
对于聚合而言,聚合根要有全局的唯一标识,而从属于聚合根的实体只需要有局部于聚合的标识。例如,员工是聚合根,员工号是全局标识。而工作经验没有必要进行全局编号,只需要在聚合内部编个号就可以了。例如,001 号员工的第 1 份工作经验、第 2 份工作经验等等。
我们再来考虑一下聚合的作用。聚合最基本的作用,是为一组具有整体部分关系的对象维护不变规则。而当我们掌握了这种建模技术以后,还可以发现其他一些层面的作用。
首先,聚合不仅是”被动地”实现不变规则,它还为我们提供了一个新的视角,可以更细致地和业务人员讨论业务规则。从这个视角去思考过去做过的系统,我们很可能会发现一些遗漏的业务规则。
其次,开发人员过去一般认为事务只是一个技术概念。现在我们可以看到,事务其实是来源于业务规则的,本质上是个业务问题。也就是说,聚合在业务规则和事务之间建立了起联系。
再次,我们在模型上为每个聚合建了一个包,可以认为,聚合是一种特殊的模块。这样,模型的层次就变得更清晰了。同时,我们也可以把聚合当作一个粗粒度的概念单位进行思考,降低了认知负载。
最后,不少开发人员编程时觉得事务范围的大小不好把握。聚合作为一个事务边界,给出了事务范围的下限,为开发时确定事务范围提供了参考。
总结
聚合是 DDD 里的一个重要模式,主要作用是维护不变规则。如果一组对象具有整体部分关系,并且需要维护整体上的不变规则,那么就可以识别为一个聚合。其中表示整体的那个实体叫做聚合根。
为了在模型中表示聚合,我们使用了叫做 <<aggregate root>> 的衍型来表示聚合根;在关联上用空心菱形符号表示整体部分关系;并用一个包把聚合包起来,包的名字一般和聚合根的名字相同。另外,在识别客户经理等聚合的时候,我们还介绍了派生关联。
通过整体部分这一特征,我们还可以推出其他几个特征,包括:表示部分的实体只能属于一个聚合,并且不能再变成其他聚合的一部分;聚合根被删除的话,整个聚合的实体都要被删除;聚合根有全局标识,非聚合根实体只有局部标识。
聚合的作用,除了确保不变规则以外,还为我们增加了一个分析业务规则的视角,将业务规则和事务联系起来,增加了模型的清晰度,并且使开发人员更容易确定事务的范围。
18|值对象(上):到底什么是值对象?
值对象的概念
在 DDD 里,像员工这样有单独的标识,理论上可以改变的对象,就叫做实体(Entiy);像员工状态和时间段这样没有单独的标识,并且不可改变的对象,就叫值对象(Value Object)。
从直观上看,实体是一个”东西”,而值对象是一个”值”,往往用来描述一个实体的属性,这也是值对象名字的由来。
多种多样的值对象
原子值对象 vs 复合值对象
首先,我们可以把值对象分成原子的和复合的。
所谓原子值对象,是在概念上不能再拆分的值对象。比如说,整数、布尔值,日期、颜色以及状态等等,一般都建模成值对象。他们只有一个属性,不能再分了。
而复合值对象是其他对象组合起来的值对象。
举个例子,”长度”对象是由”数值”和”长度单位”两个属性组成的,比如”5 米”,”3毫米”等等。”姓名”一般也认为是值对象,由”姓”和”名”两个属性组成,如果考虑国际化,还要加上”中间名”。”地址”常常也认为是值对象,属性包括”国家”、”省”、”市”、”区”、”街道”、”门牌号”等。还有,”字符串”也是复合值对象,它是由一系列的字符组成的,这种组合方式和前面几种不太一样。
现在你知道为什么 Java 里面 String 对象是不可变的了吧?因为它是值对象。但是你可能又发现一个问题,Java 里 Date(日期)是可变的,而我们上面说日期是值对象,不可变。这是为什么呢?
其实呀,把 Date 实现成可变的,是早期 JDK 设计的一个错误,这带来了很多问题。直到JDK8 引入了新的日期和时间库,也就是 LocalDate、 LocalDatetime 这些类型,才完美地解决了这个问题。而这些新的类型都是不可变的。
你看,哪怕是发明 Java 的牛人,有时候也没搞清楚什么是值对象。
**最后还有一种常见的复合值对象,就是所谓”快照”**。
比如修改员工的时候,可能需要把修改历史留下来,也就是我们可以看到员工信息的各个版本。一种做法就是建一个员工历史表,里面的字段和员工表差不多。每次修改,都把修改前的员工数据存一份到历史表。这些信息,就是员工在某个时刻的”快照”。快照是不可变的,因为它是历史信息,历史是不可改变的。多数值对象都比较小,但快照有时会很大,但仍然是值对象。
独立的值对象 vs 依附于实体的值对象
另外,值对象还可以分成独立的和依附于实体的。比如说,”时间段”、”整数”都是独立的,它们可以用来描述任何实体的属性,所以可以不依附于任何实体而单独存在。但是,员工状态就是依附于实体的,它只能表达员工这个实体的状态,脱离了员工,员工状态也就没有单独存在的意义了。
可数值对象 vs 连续值对象
值对象也可以分成可数的和连续的。可数值对象是离散的,可以一个一个列出来。比如说整数和日期、员工状态都是可数的。而实数则是连续的值对象。像颜色这样的值对象,在自然界里本来是连续的,但由于技术的限制,在计算机里一般实现为可数的,比如说,一些老式的系统只支持 256 种颜色。
预定义值对象 vs 非预定义值对象
最后,值对象还可以分成预定义的和非预定义的。
所谓预定义的,就是需要以某种方式在系统里,把这种对象的值定义出来,常见的方式有程序里的枚举类型、数据库定义表,配置文件等。比如说,员工状态的三个对象”试用期” “正式工” “终止”,就是用枚举的方式定义在程序里的。而用于构造地址的”省” “市”则常常定义在数据库表里。
非预定义的值对象就不必预先定义在系统里了,比如说”整数”,由于是无限的,根本就没有办法预定义。我们不可能用一个数据库表把所有整数都定义进去,当然,也没这个必要。
20|值对象(下):值对象和实体的本质区别是什么?
实体是靠独立于其他属性的标识来确定同一性的,而值对象以本身的值来确定同一性,没有独立于其他属性的标识;理论上,实体是可变的,而值对象是不可变的。
值对象和实体的本质区别
现实中的事物,也就是实体,总有一个产生和消亡的过程,在这个过程里,各种属性也可能发生变化,因此是可变的。
而值对象则是纯粹的概念产物,唯一的目的就是方便人的思考和沟通。所以,这样的概念本身并没有自然的产生和消亡过程,也不需要改变。5 就是 5 , 5 如果变成 6 ,那么就已经是另一个值对象了,原来的 5 还在那里,并没有改变。换句话说,值对象并没有实体意义上的”生命周期”。因此,谈论值对象的改变,本身是没有意义的,这就是值对象不变性的本质原
因。
21|用”限定”建模:怎样简化一对多关联?
之前,员工和工作经验之间有一个一对多关联。现在,在员工那一端加了一个小方框,里面写了”: 时间段”,而另一端的多重性,由原来的”0..*”神奇地变成了”0..1”。
这种方式所表达的意思是说,对于一个员工而言,任何一个时间段,要么没有工作经验,要么有一条工作经验,但不能有多条工作经验。换句话说,总体上看,一个员工可以有多条工作经验,但限定在一个时间段的话,那么最多就只能有一条工作经验了。
所以,这种机制就叫作”限定”(qualification)。而上面那个标有”: 时间段”的小方框,叫做”限定符”(qualifier)。
我们不难发现,限定机制起到了两个作用:第一,表达了更丰富的语义,把原来用注解说明的约束变成了更严格的符号;第二,简化了关联关系的多重性,把原来的一对多,在形式上,变成了一对一。
27|迭代三概述:怎样处理规模更大的系统?
聚合
聚合(aggregate)是一组有整体部分关系,并且要满足一定不变规则的领域对象,其中只有一个实体表示整体,这个实体叫做聚合根。
聚合的整体与部分是强关联的,也就是一旦聚合根被删除,其他部分必然也被删除。由于这种强关系,所以外界只能通过聚合根来访问非根对象,因此,只有聚合根有业务意义上的全局标识,非聚合根实体只有局部标识。
对于那些单独存在,而不属于其他聚合的实体,可以认为是只有聚合根的、退化的聚合。
在模型图里,我们可以用 <<aggregarte root>> 衍型结合菱形符号表示聚合根,并且把聚合相关的实体以及专属于聚合的值对象放在一个包里。
不变规则,指的是每时每刻都不能打破的规则。如果可以暂时打破,后面再补救,就不算不变规则了。对于聚合整体上的不变规则,需要在聚合根或者和聚合配合的领域服务中维护。
此外,我们还要考虑不变规则在并发的情况下被破坏的情况,这就要用事务把聚合的操作保护起来。所以,聚合决定了事务的最小边界。这种事务常常要用乐观锁或悲观锁来实现。
在编程上,非根实体的增、删、改,一般要由聚合根或者和聚合配合使用的工厂或领域服务来负责,外部不能直接修改非聚合根。为了实现这一点,我们可以将非聚合根的构造器和 setter设成包级私有权限。此外,聚合根返回非根实体的列表时,应该转换成不可变列表。
《领域驱动设计》原书的第 6.1 节介绍了聚合,你可以去看看。
值对象
值对象(value object)通常用来表示实体的属性值。由于值对象是纯粹的概念产物,因此并不存在从创建到消亡的生命周期,在概念上也是不可变的。而另一方面,实体则是现实中的概念,存在从产生到消亡的生命周期,理论上是可变的。
由于实体的属性变了,仍然是这个实体,所以必须具有独立于其他属性的标识,通过这个标识来判断实体的同一性。也就是只要标识一样,哪怕属性变了,这个实体还是这个实体。而值对象是不变的,所以不需要独立于其他属性的标识,而是以所有的属性值作为一个整体来判断同一性。
在模型图上,可以用 <<value>> 衍型来表示值对象。我们还要注意一点,值对象也是要封装领域逻辑的,因此不是 DTO(数据传输对象)。
值对象的主要优点是在内存和数据库布局上的灵活性,既可以采用共享的方式,也可以采用不共享的方式,这是实体所不具备的。同时,不变性也可以避免程序错误,有利于并发程序的编写和函数式编程。
《领域驱动设计》原书第 5.3 节介绍了值对象。
限定
限定(qualification)可以起到简化关联的多重性,丰富模型语义的作用。如果两个实体之间本来是一对多的关系,而某个属性固定后,就可以变成一对一的关系,那么就可以使用限定。
限定在数据库里可以表现为主键和限定属性组成的唯一索引;而在程序里可以用 Map 来表示。
《领域驱动设计》原书第 5.1 节介绍关联的时候,也同时讲了限定。
泛化
泛化(generalization)表示的是分类关系,是领域建模中强大的抽象机制。当我们发现一些对象既有共性又有个性的时候,就可以考虑使用泛化。另一方面,泛化又有可能使模型复杂化,因此,是否使用泛化要经过权衡,可以从简洁性、可理解性、可维护性等方面,想一想使用泛化是否合适。
在模型中,泛化用空三角箭头来表示。
在数据库表的设计上,有三种策略:每个类一个表、每个子类一个表、整个泛化体系一个表。这三种策略的选择要考虑多种因素进行权衡。另外,还有共享主键和不共享主键两种关于主键的策略。
在编程上,通常用类的继承或接口的实现来表示泛化。由仓库进行数据库数据到内存数据的转换。仓库屏蔽了不同数据库设计策略的差别。
《领域驱动设计》原书中不少章节(例如第 3.2 节、8.1 节、8.4 节、9.1 节、9.2 节 、10.4节、10.8 节 、11 章、12.1 节、 12.2 节、14.12 节、 16.4 节、16.5 节)的例子都使用了泛化, 但书里并没有章节专门介绍泛化,算是一个小小的缺憾吧。
28|限界上下文(上):怎样为更大的需求建模?
限界上下文的含义
限界上下文确实和划分模块、划分子系统一样,是一种分而治之的手段,可以起到分离关注点的作用。但限界上下文增加了一个要点,就是,它的目的还在于维护概念一致性。正是这一点,造成限界上下文和传统方法的本质不同。
DDD 认为,面对大规模的系统,全局概念一致性从根本上是不可能的。这是因为,人的认知能力是有限的。由于大型软件是团队协作开发的,因此这里其实是团队的认知能力。当系统规模增大时,团队规模也会相应增大,沟通难度会呈非线性增长。当系统达到一定规模,就超过了一个团队的认知能力,无法保证概念的一致性了。
这时候,就要把大系统分解成若干子系统,每个子系统对应一个领域模型。每个模型的规模都不超过一个开发小组的认知负载。在每个子系统的内部实现概念的严格一致性,而不同系统内部之间则没有必要一致。也就是说,不再追求全局一致性,而是退而求其次,只需追求局部的一致性,使概念不一致的问题得到合理管控,从而实现业务目标,这样就足够了。
概念的一致性是通过语义上的一致性来表达的,所以 Evans 引用了语言学上”上下文”(context)的术语,来表示一个子系统、子模型或者维护这个子系统的团队。
语言学认为,一个词汇或者语句,只有在一个上下文里才有确切的含义。
划分限界上下文
通常一个敏捷的团队的大小是 7 ± 2,也就是 5 ~ 9 个人。也有人说所谓 2 pizza team,也就是两个披萨够吃的规模,大概 10 个人上下。据说这个团队规模是有心理学的实验依据的。
敏捷软件开发里常常提到的”康威定理”。也就是说,系统的架构总是与组织的沟通结构趋于一致。通俗地说,就是怎么划分子系统,相应就会怎么划分开发小组。
总结
DDD 的限界上下文,是一种解决大系统或者大模型概念不一致的手段。我们把一个大模型分成几个小模型,保持每个小模型内部概念的一致性,而不同模型之间的概念不必一致,这种小模型就叫做限界上下文。
放弃对全局一致性不切实际的追求,退而求其次,代之以局部一致性,从而使概念一致性问题得到足够的管理,达到业务目标,这种思维就是限界上下文与传统划分模块或子系统思路的本质区别。
之所以限界上文不追求全局一致性,实际上是由于全局一致性已经超过了团队的认知负载。所以限界上下文的划分在理论上应该和团队的划分保持一致。这也印证了康威定律。
另外,统一语言只有在一个限界上下文中才有意义。或者说,一个限界上下文对应一套统一语言。
31|CQRS(上):实现查询功能有什么诀窍?
事实上,前人已经意识到了查询和其他功能的不同之处,主张采用不同的方式来处理查询逻辑,并提出了所谓 CQRS 架构。
最早提出这个说法的是 Greg Young。他把增、删、改功能称为 Command(命令),把查询称为 Query,这两种功能的职责不同,应该采用不同的方式来处理,因此叫做”命令查询职责分离”(Command Query Responsibility Segregation ),简称 CQRS。我们可以先粗放一点来理解,一共是两条规则。
第一,命令要走领域模型。
第二,查询不走领域模型,直接用 SQL 和 DTO。
总结
今天我们学习了 CQRS (Command Query Responsibility Segregation),也就是”命令和查询职责分离”模式。
尽管通过 DDD 的领域模型完成增、删、改等功能是很适合的,但是通过领域模型来实现查询功能,常常是比较繁琐的,而且性能也不高。因此, CQRS 就成了 DDD 的有力补充。
根据 CQRS ,命令(也就是增、删、改功能)和查询功能的实现逻辑应该是不一样的。
34|落地经验:怎样在实际项目中推广DDD?
改造现有系统的步骤
第一步是反推领域模型。新建系统的时候是从需求到模型,可以叫做正推。而由于现有系统已经存在了,所以我们做的第一步,反而是从系统现状中”反推”出当前的领域模型,目的是客观地反映出系统当前的领域知识和逻辑。这时候的模型往往有不少问题,比如不能正确反映领域知识、存在矛盾、冗余等。
反推领域模型,我们可以从数据库、用户界面、代码等方面入手。一般是先看数据库,因为数据库里常常已经凝聚了 80% 的业务知识。如果数据库看不明白,再看界面,如果还不明白,就只能翻代码了。之所以把代码放在最后,是因为阅读代码比较耗时,尤其是现有系统的代码往往很难理解。
虽然课程是按照”正推”来讲的,但是我们也重点强调了需求、模型和实现的一致性。如果你把这个原理吃透,那么反推领域模型也就不在话下了。
反推出的领域模型可以为进一步的改进建立”基线”。在反推的过程中所发现的问题,也可以作为下一步建立目标领域模型的输入。
第二步是建立目标领域模型。根据当前系统的痛点、问题以及业务需求,就可以建立目标领域模型,作为改进的方向。建立目标领域模型,一定要有明确的”时间点”。也就是说,这个目标是 1 年的、3 个月的、还是 2 周的。脱离时间谈目标是没有意义的。越远的目标应该越宏观,越粗粒度;越近的目标应该越具体,越细致。过于长远的目标模型往往难以落地,所以要合理地设置目标时间。
第三步是设计演进路线。有了当前模型和目标模型,就可以分析两者之间的差距。跨越这个差距的过程就是改进的过程。设计演进路线最大的问题就是怎么保证可行性。一般要把改进过程化整为零,迭代实施,并且还要兼顾日常的业务需求,后面我们还会提到这个问题。
第四步是迭代实施。最好基于敏捷软件开发方法,小步快跑地实施。在这个过程中,必然会对之前建立的目标领域模型进行反馈,不断改进。同时还要不断评估开发现状,保证不偏离目标。
选择精益切片
上面这 4 步是改造现有系统的总体思路,但真正实施的时候,不能一开始就针对整个系统进行。
就拿反推领域模型来说,由于现有系统往往规模庞大逻辑复杂,如果针对整个系统反推模型,必然旷日持久。再加上建立目标领域模型的时间,可能半年就过去了。这段时间只有模型,没有任何落地的代码,也就看不到任何实际效果。于是人们往往失去耐心, DDD 无疾而终。
另一方面,在开始引入 DDD 的时候,架构师、领域专家、开发人员其实还没有真的掌握相关技能。只有落地到代码,形成闭环,才会有真切的感受,真正学懂 DDD。所以,就算我们愿意花半年时间来建模,由于没有掌握领域建模的精髓,也很难保证模型的正确性,更加无法落地到代码了。
所以,建议的做法是,首先选择系统中一个相对独立的小模块,然后按照前面的 4 步,尽快落地到代码并上线,建立最小闭环。通过这个过程,初步掌握 DDD 落地技能并取得实际效果。同时,这么做也能培养人才,积累经验,建立必要的开发流程。完成之后,再选择下一个切片,逐步扩大范围,并深化 DDD 的技能。
这个相对独立的模块往往称为”精益切片”。精益切片的难度、范围、风险要适中,最好在 3 个月内形成最小闭环。
“低配版”的 DDD
再谈一个有意思的问题——怎样在推广过程中降低 DDD 的难度。
DDD 的知识点还是比较多的,而且其中有一些理解起来有一定难度。如果在推广过程中,一下子就让所有人掌握所有知识点,往往会造成很多误解,导致动作走形,影响推广效果。
所以,一开始可以聚焦在 DDD 最核心的问题上,暂时省略其他要点,推行一个”低配版”的 DDD。等到大家掌握了基本技能,需要更深层次的运用时,再引入其他知识点。
那么在开始的时候,哪些可以省略,哪些不能省略呢?我梳理了一张表,供你参考。