上一篇:《IDDD 实现领域驱动设计-由贫血导致的失忆症》
这篇博文是对《实现领域驱动设计》第一章后半部分内容的理解。
Domain Experts-领域专家
这节点内容是昨天的一个讨论引发的思考。
什么是领域专家?简单来说,就是对某一业务领域精通的人,这个人可以是医生、学者、作家、艺术家等等,不管是什么职业,什么身份,只要对某一业务领域精通,都可以称之为领域专家。这样说可能会让你感到茫然,我举一个例子,比如你们软件公司要开发一套快递行业的业务系统,然后你需要到实际企业去了解业务流程等等,暂时把这个实际企业想象成很小(非三通一达),那么你到这个企业第一时间找的是谁呢?准确来说,应该是这个公司的 CEO,因为只有他最最了解他们公司的业务,毕竟是他创办的公司,CEO 不了解,还有谁还了解呢,那么,这个公司的 CEO 就可以看作是领域专家。CEO 一般是蛮忙的,有很多的琐事需要处理,所以,在你和他聊天了解业务的时候,最好是先准备一杯咖啡!
当我们开发人员自己开发一套系统的时候,在开发团队之间,领域专家的概念就慢慢淡化了,为什么?因为领域专家变成了我们开发人员自己,自己给自己布置业务,然后自己再去完成,这样虽然很高效,因为没有非技术人员的参与沟通,但是这样就会造成一些问题,比如,开发人员在思考业务流程的时候,会按照开发人员的思路去理解,比如,一个简单的业务操作描述,开发人员会首先想到的什么呢?一个表单和一个 Button,然后就是对这个表单和 Button 操作的具体实现了,等项目开发完成后,需要交付真正的客户去检验,客户让你演示这个业务操作,然后你就开始对表单和 Button 进行操作了,说这就是业务操作,但是,客户突然来一句:我们不要表单和 Button 操作,UI 需要重新搞,这时候,你就傻眼了,因为你所有的内容代码实现都是围绕着表单和 Button。说了这么多,到底是什么意思呢?在这个过程中,你并不了解这个业务操作背后所蕴含的业务含义,首先,业务不是 UI,UI 只不过是业务的一部分体现,有时候,业务仅仅只是领域专家的一段描述,开发人员需要对这个业务描述,进行一点一点的抽离,把术语和操作分离开,然后再和领域专家进行深入的探讨,这个过程可能会花很多的时间,但是是非常重要的,做完这些前期工作,你再去实现业务操作,你会发现,不管 UI 如何变化,这个业务操作的本质是没有发生变化的,也就是说你的内部代码不需要进行修改,UI 修改那就交给前端工程师就可以了,和你没太大关系。总的来说,就是不要让 UI 驱动你开发,而是让业务驱动你开发。
对上面的内容,我还需要补充一点,就是开发人员需要领域专家,开发人员和领域专家的身份最好不要重叠,要不然会造成一系列的问题,还有就是,在整个领域驱动设计的过程中,开发人员和领域专家的地位是相同的,不要有任何的轻视心态,要用平等的心态去沟通交流。领域专家的概念,让我想到一个很相似的事,就是苹果在开发一个产品的时候,会请很多的非技术人员参与,这些人遍布各行各业,医生、学者、作家、艺术家等等,苹果为什么要请他们,就是想让他们参与产品的设计,因为他们就是产品的使用者,他们提出的想法就是实实在在的用户建议,这个产品开发过程,其实就可以看作是领域(产品)驱动设计,这些参与产品设计的非技术人员,就可以看作是领域(产品)专家。
一个简单业务用例的回顾和理解
这个简单业务用例描述是这样的:一个 Scrum 模型,我们需要将一个待定项(Backlog Item)提交到冲刺(Sprint)中去。
这是最简答的描述,没有经过和领域专家进行深入沟通的,Scrum 是敏捷开发中的概念,这个就不说明了,因为我也不懂,你只需要知道上面的操作就可以了,一般的实现方式(属性访问):
public class BacklogItem extends Entity {
private SprintId sprintId;
private BacklogItemStatusType status;
...
public void setSprintId(SprintId sprintId) {
this.sprintId = sprintId;
}
public void setStatus(BacklogItemStatusType status) {
this.status = status;
}
...
}
客户端调用:
// client commits the backlog item to a sprint
// by setting its sprintId and status
backlogItem.setSprintId(sprintId);
backlogItem.setStatus(BacklogItemStatusType.COMMITTED);
上面的实现过程,完全和上一篇 saveCustomer 的实现方式一样,这样做没什么不可以,因为我也这样干过,只是你会总感觉有哪些不对劲的地方,首先,在实现待定项提交到冲刺这个操作的时候,你首先查看的是 BacklogItem 中的属性,然后就是对这个属性进行设置,在这个过程中,你忘记了你实现的是一个行为操作,而不是一个属性赋值操作,这样说来,是不是有点脚本模式开发,还有就是如果客户端第二个属性赋值 setStatus 出现了错误,因为第一个 setSprintId 已经成功完成,这个该怎么进行处理,即使有处理,这个操作也完全放在了客户端去完成,像 saveCustomer 一样,如果再增加一个属性赋值操作,你的实现将越改越乱,最重要的是,再客户端暴露了 BacklogItem 模型的具体结构,这个应该是要避免的。
我们再来看另一种实现方式:
public class BacklogItem extends Entity {
private SprintId sprintId;
private BacklogItemStatusType status;
...
public void commitTo(Sprint aSprint) {
if (!this.isScheduledForRelease()) {
throw new IllegalStateException(
"Must be scheduled for release to commit to sprint.");
}
if (this.isCommittedToSprint()) {
if (!aSprint.sprintId().equals(this.sprintId())) {
this.uncommitFromSprint();
}
}
this.elevateStatusWith(BacklogItemStatusType.COMMITTED);
this.setSprintId(aSprint.sprintId());
DomainEventPublisher
.instance()
.publish(new BacklogItemCommitted(
this.tenant(),
this.backlogItemId(),
this.sprintId()));
}
...
}
客户端调用:
// client commits the backlog item to a sprint
// by using a domain-specific behavior
backlogItem.commitTo(sprint);
将第一种是实现方式出现的问题,再和第二种方式进行比较,你会发现,第二种实现方式完全避免掉了,在开始的时候,我们说了,这是一个最简单的业务操作描述,没有和领域专家进行深入探讨和交流,如果进行探讨和交流的话,最后详细、准确的业务操作描述,应该是这样:
- 允许将每一个待定项提交到冲刺中,只有在一个待定项位于发布计划(Release)中时才能进行提交,如果一个待定项已经提交到了另外一个冲刺中,那么需要先将其回收,提交完成时,通知相关客户方。
对于一个详细、准确的业务操作描述,如何进行确定下来,作者进行了如下总结:
- 对于你目前正在工作的业务领域,思考一下模型中的通用术语和业务操作。
- 将术语写在白板上。
- 然后,将项目中所用到的短语也写下来。
- 与真正的领域专家交流一下,看看哪些词汇是可以改善的(记得带上咖啡哦)。
我们再来分析一下上面第二种实现方式,希望可以抽离出一些对自己有所帮助的理解,首先,读上面的业务操作描述,然后再和实现代码进行对比,你会发现,它们之间的关系是完全契合的,在上一篇中,我们说过,设计就是代码,代码就是设计,这种设计就是一种通用语言,开发人员和领域专家都能懂的通用语言。
在第二种实现的方式中,有两个关键词:commitTo 和 DomainEventPublisher,DomainEventPublisher 是领域事件(Domain Event),这个不要和领域服务(Domain Service)混淆,领域事件我没有使用过,后面再进行学习,你暂时可以把它看作是操作完成后的消息推送者。commitTo 是 BacklogItem 模型中的一个行为,意为提交,你可能会这样想:待定项怎么会有行为呢?它又不是人,我觉得这个很有意思,记得在之前做消息模型设计的时候,一直不确定的一点是发消息这个操作该如何设计?是消息实体的一个行为操作,还是发件人的一个行为操作,又或者是独立出来的一个领域服务(最后结果),在这个设计确定的过程中,我们会进行多次讨论,但有一点需要进行明确的是,不只是具有“生命”的实体,才具有行为操作,就像消息模型中的操作人,你自然会联想到现实生活中的发件人、收件人等等,认为只有人才会有一些行为操作,但是实际上,在软件系统中,一切的模型都有可能是行为操作,你要摒弃现实生活对你的影响,就像上面待定项的提交操作,如果是我设计的话,我会创建一个领域服务进行行为操作,因为,在我的认知中,待定项不具有行为操作,但显然并不是这样,为什么要这样设计?现在还说不出个所以然,以后再慢慢体会。
DDD 并不笨重(测试驱动)
DDD(领域驱动设计)和 TDD(测试驱动开发),这两者有什么关系?我记得在之前的博文中有提到这一点,我的观点是,DDD 和 TDD 可以之间可以产生一些微妙的化学反应,并不一定要强制的去区分它们之间的关系,比如,如果你的 DDD 项目中,使用了 TDD,并不能说明你的项目就不是 DDD 模式了,其实,TDD 可以对 DDD 进行一些补充,或者可以让你的项目,在使用 DDD 的时候,变得如鱼得水。关于它们两者的关系,作者简单说明了一下观点:DDD 也倾向于“测试先行,逐步改进”的设计思路,他们可能有细微的区别,但是基本思路是一样的,DDD 采用的是一种“敏捷的”方式进行软件开发的。
可以采取的步骤:
- 编写测试代码以模拟客户代码是如何使用该领域对象的。
- 创建该领域对象以使测试代码能够编译通过。
- 同时对测试和领域对象进行重构,直到测试代码能够正确地模拟客户代码,同时领域对象拥有能够表明业务行为的方法签名。
- 实现领域对象的行为,直到测试通过为止,再对实现代码进行重构。
- 向你的团队成员展示代码,包括领域专家,以保证领域对象能够正确地反映通用语言。
具体再说明一下,像上面的待定项提交业务操作,可以完全先写一个测试代码,如下:
[test]
public void backlogItemCommit() {
...
}
这个测试代码,其实就是领域专家想要的,他不管你是如何具体实现的,他关心的是有没有这个业务操作,以及这个业务操作完成的结果,也就是说,测试代码可以很好的反应领域专家所描述的业务操作,那有人可能就会说了:你这就不是 DDD 了,而是 TDD,表明看上去,好像确实如此,但是不能说写个测试代码就是 TDD 开发,而去测试代码并不能反映领域模型,他只是一种辅助方式,你可以把它看作是通用语言的一种,可以帮助你和领域专家进行沟通,也可以加快你的开发速度,又或者可以帮助你完善你的领域模型设计。对应某一业务操作的测试代码,也不是一成不变的,它需要开发人员和领域专家的持续沟通和改进,测试代码就是他们进行通用语言的一种表现形势,使用测试代码的好处就是,它可以很好的表现业务需求,当然你也可以使用 UI,这些都不过是通用语言的一种罢了。
在读《DDD 并不笨重》这一小节点内容的时候,我是很有感触和共鸣的,因为我在之前短消息开发的时候,就曾这样搞过,比如,新建一个与 Domain 对应的 Domain.Tests 项目,这个 Domain.Tests 就是你和领域专家进行沟通的一个桥梁。
对于这个节点内容,可能每个人都有自己的理解,如果大家有不同的想法,欢迎探讨交流,就记录到这!