![领域驱动设计:业务建模与架构实践](https://wfqqreader-1252317822.image.myqcloud.com/cover/164/48894164/b_48894164.jpg)
1.4 关于DDD原则的案例
下面采用一个简单的案例来说明DDD的语言、模型、代码三合一特性,以及如何保持领域模型的内聚与独立性。
该案例基于一个真实的项目,是作者几年前基于SalesForce公司的PaaS云平台Froce.com开发的一个敏捷项目管理软件Agile Vision(敏捷视野)。基于PaaS云平台的开发比较适合作为DDD的案例,因为底层基础设施的功能(如安全性、可靠性、存储等)都已经由云平台封装好了,项目团队可以专注于实现业务逻辑,构建业务模型。
该项目的需求是管理Scrum敏捷项目。选这个项目,也是因为读者多多少少对敏捷过程有一定的了解,其中的术语和领域逻辑不难理解。如果是完全陌生的复杂领域,难免要花费相当大的脑力去理解领域逻辑。而我们的目的只是让大家初步感受一下DDD的本质特性,没必要给读者增加过多的脑力负担。至于复杂的业务逻辑建模技巧,后续章节有大量案例(见第5、6、9章),本节只是一个简单的开胃菜。
1.语言、模型、代码三合一
(1)语言沟通
领域专家对领域的逻辑描述:
领域专家:Sprint是一个固定时长的迭代,时间一般是2~4周。
(2)设计模型
类图如图1-11所示。
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/35_01.jpg?sign=1739279507-0a9rxpZvtosbO8BpmvmLG5FZNOMbnPDV-0-33644d4c1e7dcae8627bd2077ada5320)
图1-11 Sprint类图
(3)实现代码
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/35_02.jpg?sign=1739279507-IO9NfAz9mO6UXMuaHBuwuKZbUmAr9kzr-0-b0d23a4176df2db5154d9cae67fe6bb8)
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/36_01.jpg?sign=1739279507-TMHyHfv5MeK4vF8g0txQreBGVIBu9NKB-0-06b494f10606aca9d822be7a79e33cb7)
例子很简单,但有以下几个值得注意的地方:
1)类名用的是Sprint而非“迭代(iteration)”或“里程碑(milestone)”之类,这是因为领域专家和用户之间沟通的自然语言就是Sprint,如果换作其他概念,交流时就要去翻译和解释。建模时,不要引入沟通中没出现的新词汇。不要认为开发人员不会做这种翻译成近义词的事情,事实上他们会依据自己的理解换成他们熟悉的术语,给沟通带来问题。
2)Sprint的持续时间用的是类的属性SpanWeeks(持续周数),这是基于Sprint的时长都是以周为单位,充分尊重了通用语言。如果按照开发人员的习惯,可能会把它改为以天为单位,因为更小的单位在计算方面要更灵活,但设计人员没有这样做。设计之初,控制住了提前设计的冲动,避免增加多余的解释与领域专家的沟通成本。
3)业务逻辑“固定时长”和“2~4周”在设计上有明确的注释。模型和代码没有丢掉任何业务逻辑。
4)业务逻辑放在Sprint类的内部,没有放在外部来判断。
以上展示了语言、模型、代码的统一。开发团队与领域专家沟通需求时,要把设计模型和代码在会议中展示出来,让他们开始通过模型来重新理解领域,进而检查开发团队对需求的理解是否正确及是否有遗漏。
我们再来体验一下语言、模型、代码三者的同步变化。
2.语言、模型、代码实时同步
(1)第一版设计
1)沟通。开发团队与领域专家的沟通如下。
领域专家:我们先确定Release的启动时间,然后开始进入Sprint,Sprint是一个固定时长的迭代,时间一般是2~4周。在Sprint开始前,我们会确定Sprint的Sprint Backlog,它是Release Backlog的子集。在经历若干个Sprint,当我们完成了Release Backlog之后,产品会进入发布流程。
开发人员:那是不是说一个Release里有很多的Sprint?
领域专家:是的,可以这么理解。但Sprint之间是一个时间连续的概念,也就是在完成一个Sprint之后才能进入下一个Sprint。
开发人员:Sprint的时长可以改变吗?
领域专家:这是不允许的,至少在一个Release内时长是固定的。这是Scrum的核心实践之一。
开发人员:一个Release中Sprint数量是固定的吗?
领域专家:这很难说,尤其是对于新组建的团队来说,他们的Velocity(团队生产率)还不稳定,要完成Release Backlog里的所有任务需要的时间也不确定,可能会增加Sprint来消化所有的Backlog。当缺陷过多时,我们也会增加Sprint来修复缺陷,之后才能考虑结束Release的问题。
开发人员:Release内至少要有一个Sprint吗?
领域专家:对于已经启动的Release是这样的,规划中的Release是没有的。
开发人员:Release启动时,Sprint就启动了吗?两个Sprint之间的时间是连续的吗?
领域专家:是的。
……
开发团队与产品经理的沟通如下。
……
产品经理:我需要一个功能,在用户创建Sprint时,他必须指定一个Release,当他指定后,能自动计算Sprint的开始时间和结束时间。并且有一个默认名,即第X个Sprint的名字是Sprint X。
开发人员(想了想之前和领域专家的交流):好的,没问题。
……
以上对话做了高度简化,甚至把几次的谈话内容浓缩到了一次,只留下了需要说明主题的关键部分。
技术团队结合这两次讨论,做出了下面的模型。
2)设计模型。类图如图1-12所示。
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/37_01.jpg?sign=1739279507-nh4WYGS1zsuxOVXokHko7D1hwZSQl395-0-945f6473008c15a02e11b5dbf9dfb1c7)
图1-12 Release和Sprint领域模型
3)代码模型。
Release类代码如下:
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/37_02.jpg?sign=1739279507-6gfZxn37pj5BHUoGi3KAkK7o6LR6I4EE-0-bc69e3c28dc6a491c58383e7f5ee6bae)
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/38_01.jpg?sign=1739279507-dOiHZiEf6MjpeUxt8Qnfy7ZlM2rcq7nO-0-e2660905301745895fde9f5a3a7bdf35)
Sprint类代码如下:
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/38_02.jpg?sign=1739279507-jB0jMUijs6tBSz3hwsua8ADRNAuB4Jzq-0-b3e63c45a546d6413282cf33659b5c98)
还有不可缺少的单元测试来验证业务逻辑,单元测试如下:
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/39_01.jpg?sign=1739279507-Pc4gRA2IOEpCvjLlbxq9BKf7053hk2MW-0-13b03e34ee618fa1e7a4e4ecb43398e9)
测试用例通过,可以把模型拿出来与领域专家和产品经理讨论了。
(2)第二版设计
第一版的设计和算法在会议上讨论时,领域专家立刻发现了设计中缺失的东西。
1)沟通。开发团队与领域专家的沟通如下。
领域专家:我可以看到设计中Release与Sprint的一对多关系,这是正确的。Sprint开始时间的算法是Release的开始时间加上该Sprint之前的所有Sprint的数量乘以固定时长的天数,这可能不对。因为对于Scrum项目有一个特殊惯例,在Release开始后,我们还有一个特殊阶段,叫Sprint 0。
(显然,对模型的检查唤醒了领域专家之前没有提及的一个深层的业务逻辑。)
开发人员:Sprint 0?(难道说的是Sprint数组的索引?)
领域专家:Sprint 0是这样一个阶段,即所有的利益相关方会创建一个待开发功能、用例、系统改进和缺陷修复的列表,同时会指派一个产品经理,所有的请求都要通过他。在这个过程中,我们会在Product Backlog的基础上先明确Release Backlog,作为一个可发布版本的规划。
开发人员:对于计算后续Sprint开始时间,这个Sprint 0有什么影响吗?
(显然,开发人员并没有听进去Sprint 0所做的任务,而急于给出解决方案。)
领域专家:Sprint 0的时长与后续开发Sprint的时长不一定是一致的,一般不会超过两周。
开发人员:好,我明白了。
2)代码模型。第二版的代码模型很快就出来了,只修改了Sprint类,如下所示。
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/39_02.jpg?sign=1739279507-5uud9G5MFOrhSFIl6Vf71ZkNFYS6FWfr-0-0650144a6e53c2d9d75a331b6c1544dd)
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/40_01.jpg?sign=1739279507-i24gI5rYOAZm73hdo9PNup9nuRyUHBNM-0-ad338d23181e79f9a0c261b7f74041b9)
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/41_01.jpg?sign=1739279507-Vu0JG5qfCsgJzc4KZoFmMCpD3Of8YoD1-0-bf12cb161d9be23eee8853a8923946db)
将Sprint开始时间计算的逻辑从“Release开始时间+Sprint数量×固定时长”变成了“Release开始时间+(Sprint数量-1)×固定时长+Sprint 0的时长”。
另外,为了满足Sprint 0的时长和其他Sprint不一样,对时长的赋值做了特殊处理。
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/41_02.jpg?sign=1739279507-0ofYc18I3AupQZKvV8yfZ8UmbK9pMYkY-0-aa510a3f8998fee73e51bce79e05fa94)
测试全部通过。
在下一次开会时,开发人员拿出了这个代码模型,沟通结果却出乎意料。
开发人员与领域专家的沟通如下。
领域专家:(看完代码后,皱了皱眉)this.BelongedRelease.Sprints[0].Equals(this)这句代码是什么意思?两个Sprint相等是什么意思?
开发人员:这是判断所添加的Sprint是不是第一个Sprint 0,因为它的时间周期可以赋值,而其他的Sprint是固定的。
领域专家:那为什么不是IsSprint0而是这么一句呢?
开发人员:那是因为……(一堆技术术语)
领域专家:(平静了一下)所以你的实现用了一个集合,那this._belongedRelease.StartDate.AddDays((lastSprintIndex-1) * SpanWeeks * 7+_belongedRelease.Sprints[0].SpanWeeks*7);代码中Index减1是什么意思?
开发人员:这是因为……(继续解释集合的技术特性)
领域专家:(终于听完了解释)好吧,至少你的测试用例通过了,这个我还能看懂。技术实现你们自行决定吧,毕竟我也不是太懂……
显然,对于实践DDD的团队来说,这个沟通是失败的。问题主要出在什么地方呢?
首先,代码模型中丢失了重要的领域概念Sprint0。我们都听得出来,虽然它叫Sprint 0,但是它是有特殊的业务含义的,在这个Sprint内,我们并不是完成开发工作,而是Release的准备和计划。开发人员把这个重要的领域概念丢失了,进而使用技术手段通过了测试用例,虽然测试用例提供了防火墙,但模型实际上是与领域逻辑脱离了。直接的后果就是,之后的沟通都需要开发人员来解释和翻译,双方已经无法达成对模型的理解的共识来直接沟通。
进一步来讲,模型与领域逻辑失配后,为后续模型的进化造成了阻碍。我们马上就会看到这样做带来的弊端,因为没有Sprint0的显式概念,后续定义Sprint的其他成员时,我们会发现都不适用于Sprint0,在各种场合都需要在Sprint类中做特殊处理,代码维护也变成了噩梦。
(3)第三版设计
参会的开发组长显然听出了问题,赶紧和领域专家做了如下确认,完善了第三版设计。
1)沟通。开发组长与领域专家的沟通如下。
开发组长:模型与业务似乎有些脱离。专家,我们想确认一下,这个Sprint0叫Sprint究竟有什么特殊含义呢?
领域专家:正如我前面所说,它是所有开发Sprint前的一个特殊阶段,它的主要任务是……(略)。
开发组长:那么它有自己的Sprint Backlog和各种会议之类的吗?
领域专家:没有,它有自己专门的任务。再重复一遍,它的时长并不受开发Sprint时长的约束。
开发组长:是否也是以周为单位?
领域专家:这个不一定。
开发组长(松了一口气):好的,我们理解了,重构后我们再和你讨论。
这时,开发团队也已经意识到Sprint0其实是一个特殊的领域概念,虽然叫Sprint,但它特指在开发Sprint前需求规划和团队组织的起始阶段。基于此,他们很快更改了设计和代码实现。
2)设计模型。模型如图1-13所示。
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/43_01.jpg?sign=1739279507-KRclq5z0CmmOmJa7PgvTUA0uDmJG3CsF-0-7597cd0a17e310db0e4156220dfc0f50)
图1-13 Sprint0领域模型
设计把Sprint0独立出去,并且根据已有业务,不再需要开始时间StartDate、结束时间EndDate这两个属性,将SpanWeeks变成了Span-Days。它与Release的关系也得到了体现——一对一,且在计算开发Sprint的开始时间时,需要保证Sprint0已经结束。
3)代码模型。Release类代码如下:
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/43_02.jpg?sign=1739279507-6GXcuBEDWivp9w4xKz7TyEdGRhKVNnQm-0-5de1a5f6fddd433e287bef8c65d60751)
Sprint类的BelongedRelease属性做了如下修改:
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/43_03.jpg?sign=1739279507-tJL4i7pdbvHh6Kf0c8BtOXYaVifvW0oj-0-3fc778ca4d3924c74a08ffe6177124cf)
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/44_01.jpg?sign=1739279507-pq4mKvfB8vTOSrhkl7jBWQWFYpZdvJd3-0-4129f7230e6c4c8b5cf0f676f2012894)
新增Sprint0类,代码如下:
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/44_02.jpg?sign=1739279507-MbpFOQ99MLh8oP5sauivRMcFFLUdFuBj-0-a626e2d6fb927dc1400f2dd74f8dfc15)
单元测试如下:
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/44_03.jpg?sign=1739279507-xQ1v5vAXtPHnqW647bMPzPo65Cj0pffI-0-eba60261f0cc62784d335e114f00bc68)
这一版模型显然吻合了业务逻辑,少了技术转译,领域专家又能理解模型的表达了。
我们基于这个案例演示了DDD的语言、模型、代码三合一以及实时同步的特性。要知道模型始终处于动态演化的过程中。开发团队在与领域专家沟通时,一定要坚持使用模型作为沟通工具和媒介。一旦发现背离的地方,要迅速让模型回到正确的轨道上来。当然,何时背离也很容易分辨,就是一方(无论是领域专家还是开发人员)无法理解模型并拒绝使用它作为沟通工具时。
3.依赖倒置保证独立性
仍以Sprint类为例。Sprint领域模型有一个重要的概念——燃尽图,用于展示Sprint工作的进度,直线是理想工作线,曲线为剩余的故事点数,如图1-14所示。
现在有一个需求,每天Sprint更新后,要显式地通知绘制界面更新燃尽图。然而,对于Sprint燃尽图的绘制是一个不确定的任务。比例尺、绘制平台都可能在不同的场景下有不同的要求。我们如何保证领域模型的独立性,而不与界面绘制的逻辑产生任何耦合呢?
这里我们采用了依赖倒置架构(详见第10章),如图1-15所示。
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/45_01.jpg?sign=1739279507-edQ8tPkE9yr4MPb30gOTbkFsLJi591wi-0-698a5ff6979dfb6bc50cee56ac7bf9dd)
图1-14 Sprint燃尽图示例
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/45_02.jpg?sign=1739279507-52gRN4GwCeuy12Rm9646XicSBLIB8WYq-0-2ce15d4104cdc1c09116669ca682e55f)
图1-15 依赖倒置架构
Sprint类代码如下:
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/45_03.jpg?sign=1739279507-6mEqSL5UiCkc7P9U2FpWVx140Gw0X4ui-0-e868553e36d86dee49f73077257ac3ce)
绘图接口如下:
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/45_04.jpg?sign=1739279507-xTqTfrxn2sPKvAi7ublgHbSd8e83k3FP-0-384c81a57c89e50bac85bcd540ea5ee9)
![](https://epubservercos.yuewen.com/E5E0B3/28606954407532806/epubprivate/OEBPS/Images/46_01.jpg?sign=1739279507-cAz5zgk9ZSlhJxilQoHEoEsmvbc61phk-0-c5d75b3e9a31d348b77f0761da3bc572)
Sprint类并没有任何比例尺或绘制平台的概念。它只是定义了一个接口类,由实现接口类的具体类根据需要完成绘制。需要说明的是,依赖倒置架构并不仅限于领域层和基础设施层之间,实际上可以用于任何高层模块和低层模块之间。按照六边形架构,与基础设施打交道的任务应该交给应用服务层,领域逻辑应该更加纯粹。这里仅作为展示,展示如何利用以接口为基础的编程思想,解耦领域层和其他组件。当我们以接口为基础进行编程并采用依赖倒置架构时,实际上不存在分层的概念。无论是高层还是低层,它们都只依赖于抽象。整个分层架构都被扁平化了。通过抽象为领域模型提供了解耦,保护了领域模型的独立性。我们通常会使用资源库(Repository)来解耦领域模型和持久化机制,以保证领域模型的独立性。这个案例进行了大量简化和抽象,仅用于演示和启发。代码并不是完整的,而且有些逻辑与现实并不完全一致,读者能够理解其中的要点是最重要的。在接下来的章节中,我们将会有更深入的案例和讨论。