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所示。

图1-11 Sprint类图

(3)实现代码

例子很简单,但有以下几个值得注意的地方:

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所示。

图1-12 Release和Sprint领域模型

3)代码模型。

Release类代码如下:

Sprint类代码如下:

还有不可缺少的单元测试来验证业务逻辑,单元测试如下:

测试用例通过,可以把模型拿出来与领域专家和产品经理讨论了。

(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类,如下所示。

将Sprint开始时间计算的逻辑从“Release开始时间+Sprint数量×固定时长”变成了“Release开始时间+(Sprint数量-1)×固定时长+Sprint 0的时长”。

另外,为了满足Sprint 0的时长和其他Sprint不一样,对时长的赋值做了特殊处理。

测试全部通过。

在下一次开会时,开发人员拿出了这个代码模型,沟通结果却出乎意料。

开发人员与领域专家的沟通如下。

领域专家:(看完代码后,皱了皱眉)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所示。

图1-13 Sprint0领域模型

设计把Sprint0独立出去,并且根据已有业务,不再需要开始时间StartDate、结束时间EndDate这两个属性,将SpanWeeks变成了Span-Days。它与Release的关系也得到了体现——一对一,且在计算开发Sprint的开始时间时,需要保证Sprint0已经结束。

3)代码模型。Release类代码如下:

Sprint类的BelongedRelease属性做了如下修改:

新增Sprint0类,代码如下:

单元测试如下:

这一版模型显然吻合了业务逻辑,少了技术转译,领域专家又能理解模型的表达了。

我们基于这个案例演示了DDD的语言、模型、代码三合一以及实时同步的特性。要知道模型始终处于动态演化的过程中。开发团队在与领域专家沟通时,一定要坚持使用模型作为沟通工具和媒介。一旦发现背离的地方,要迅速让模型回到正确的轨道上来。当然,何时背离也很容易分辨,就是一方(无论是领域专家还是开发人员)无法理解模型并拒绝使用它作为沟通工具时。

3.依赖倒置保证独立性

仍以Sprint类为例。Sprint领域模型有一个重要的概念——燃尽图,用于展示Sprint工作的进度,直线是理想工作线,曲线为剩余的故事点数,如图1-14所示。

现在有一个需求,每天Sprint更新后,要显式地通知绘制界面更新燃尽图。然而,对于Sprint燃尽图的绘制是一个不确定的任务。比例尺、绘制平台都可能在不同的场景下有不同的要求。我们如何保证领域模型的独立性,而不与界面绘制的逻辑产生任何耦合呢?

这里我们采用了依赖倒置架构(详见第10章),如图1-15所示。

图1-14 Sprint燃尽图示例

图1-15 依赖倒置架构

Sprint类代码如下:

绘图接口如下:

Sprint类并没有任何比例尺或绘制平台的概念。它只是定义了一个接口类,由实现接口类的具体类根据需要完成绘制。需要说明的是,依赖倒置架构并不仅限于领域层和基础设施层之间,实际上可以用于任何高层模块和低层模块之间。按照六边形架构,与基础设施打交道的任务应该交给应用服务层,领域逻辑应该更加纯粹。这里仅作为展示,展示如何利用以接口为基础的编程思想,解耦领域层和其他组件。当我们以接口为基础进行编程并采用依赖倒置架构时,实际上不存在分层的概念。无论是高层还是低层,它们都只依赖于抽象。整个分层架构都被扁平化了。通过抽象为领域模型提供了解耦,保护了领域模型的独立性。我们通常会使用资源库(Repository)来解耦领域模型和持久化机制,以保证领域模型的独立性。这个案例进行了大量简化和抽象,仅用于演示和启发。代码并不是完整的,而且有些逻辑与现实并不完全一致,读者能够理解其中的要点是最重要的。在接下来的章节中,我们将会有更深入的案例和讨论。