编者按:

软件工程师所做的工作便是把实际中的工作搬到计算机上,通过信息化进步生产力。在这个过程中有一个点是不能被忽视的,那便是**[体系的内建质量]**。

规划杰出的体系: 概念明晰,结构合理,即便代码库巨大,仍然可了解、可保护;

规划糟糕的体系: “屎上雕花”。

其中,范畴概念和范畴模型的缺失是形成这种差异的元凶巨恶。

01、概念解读


范畴驱动规划 – DDD(Domain-Driven Design)是一种基于范畴常识来解决杂乱事务问题的软件开发办法论,其本质是将事务上要做的一件大事,通过推演和笼统,拆分成多个内聚的范畴。

它有以下三个重点:

跟范畴专家(Domain Expert)密切合作来定义出 Domain 的规模及解决方案

切分范畴出数个子范畴,并专心在中心子范畴

透过一系列规划形式,将范畴常识转换成对应的程序模型(Model)

范畴可大可小,对应着巨细事务问题的鸿沟,对鸿沟的区分与控制是范畴驱动规划着重的中心思维。

02、DDD 带来的改动


向 Anemic Model 说 “No!”

跟大家介绍一个有名的反形式:贫血模型(Anemic Model)。此形式泛指那些只要 getter 与 setter 的 model。这些 model 缺少行为表述,导致使用者每次都要自己组合出想要的功能。

“贫血模型使用起来像在教小孩子相同,一个指令一个动作还很简略忘掉;具有行为表述能力的模型则像跟大人沟通相同,一次举动就能完结许多指令。”

举个栗子:以数据为中心的办法是要求客户代码必须知道如何正确地将一个待定项提交到冲刺中。此刻,错误地修正 sprintId 或有另外一个特点需求设值,都要求开发人员仔细剖析客户代码来完结从客户数据到 BacklogItem 特点的映射。这样的模型不是范畴模型。


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;
    }
    ...
}
// 客户端通过设置sprintId和status将一个BacklogItem提交到Sprint中
backlogItem.setSprintId(sprintId);
backlogItem.setStatus(BacklogItemStatusType.COMMITTED);

通过事务言语封装程序行为

DDD 注重将事务言语引入程序模型之中,对重点事务行为进行封装。与其随意封装代码,将程序模型与事务逻辑绑定在一同的行为可以确保代码紧随事务变化做出调整。在建模时,范畴专家讨论了以下几个需求:

  • 允许将每一个待定项提交到冲刺中且只要在一个待定项坐落发布方案(Release)中时才能进行提交

  • 假如一个待定项已提交到了另外一个冲刺中,先将其回收

  • 提交完结时,告诉相关客户方

客户代码并不需求知道提交 BacklogItem 的完成细节,由于完成代码的逻辑刚好可以描述事务行为。

public class BacklogItem extends Entity {   private SprintId sprintId;   private BacklogItemStatusType status;   ...   public void commitTo(Sprint sprint) {       if (!this.isScheduledForRelease()) {           throw new IllegalStateException("Must be scheduled for release to commit to sprint.");       }       if (this.isComittedToSprint()) {           if (!sprint.sprintId().equals(this.sprintId())) {               this.uncommitFromSprint();           }       }       this.elevateStatusWith(BacklogItemStatus.COMMITTED);       this.setSprintId(sprint.sprintId());       DomainEventPublisher.instance()           .publish(new BacklogItemCommitted(               this.tenantId(),               this.backlogItemId(),               this.sprintId()           ));   }}// 客户端通过设置特定于范畴的行为将BacklogItem提交到Sprint中backlogItem.commitTo(sprint);

03、DDD 详解


举例说明 DDD:

假设我们现在在做一个简略的数据计算体系,其运算逻辑是这样的:地推员输入客户的姓名和手机号,体系根据客户手机号的归属地和所属运营商,将客户群体分组,分配给相应的出售组,由出售组跟进后续的事务。

领域驱动设计入门与实践[上]

代码如上,大部分人都是这么写的,看起来也没什么问题,对一个小工程或短期下线的体系来说,这样写可以称得上是又快又好;但把其放在一同迭代频频的大工程内,还留有一些危险:

危险 1:接口语义不明确

Register 办法的 bug 在于它支持一种类型、两组参数(用户名、手机号)。当用户注册体系的参数改变时,比如改用身份证注册,Register 办法就要被改造为 RegisterByPhone 和 RegisterByIdCard。

由于内部校验只会保留参数类型不会保留参数名,因此改变参数意味着新的接口和再来一遍的校验,这不是我们预期的方针。我们希望的是:语义接口足够明确无歧义、可扩展性强且带有必定的自检性,这才是最优解。

接口语义修正方针:语义明确无歧义、扩展性强、带有必定的自检性

危险 2:参数校验逻辑杂乱

假如存在多个相似的办法,每个办法都要在开头校验,必定会存在许多重复代码。一旦某个类型的参数校验逻辑需求修正,那么每个当地都要一一修正,这明显不符合“开闭原则”。即便将其封装进某个东西进行复用,还存留两个 bug:1、在事务办法中把参数反常和事务逻辑反常混合起来,不太合理: 事务办法内还需求自动调用东西类来进行校验,假如校验失利,需求抛出反常;2、随着参数类型越来越多,东西类中的校验逻辑会随之不断胀大,后续保护起来是不小的工作量。

参数校验修正方针:进步校验逻辑复用性参数校验反常与事务逻辑反常解耦

危险 3:中心事务逻辑明晰度不行

通过改造后的代码,虽然多了些高雅但不“朴实”。RegistrationService 是用于对用户进行注册的服务,它的职责应仅限制为「注册」。而注册最本质的行为便是「拿到用户的信息并存储起来」。在这段代码中存在的两个行为「获取手机号的归属地编码」、「获取运营商编码」明显并不适用于「注册」这个事务逻辑。

问题来了:那我们为什么要在 Register 办法里边写这些逻辑?为了适配 findRep 这个接口来对原始的参数进行处理拼接,就像拿胶水来进行缝缝补补的“胶水逻辑”?

如何改造这些“胶水逻辑” 才合理?

两个思路:

1、改造 findRep 这个接口的入参

这在笼统上便是合理的,不必在 register 办法内进行胶水操作了

2、把「获取手机号的归属地编码」&「获取运营商编码」内聚到手机号这个类型中

这两个行为都是获取手机号相关的特点,内聚在手机号这个类型中在笼统上也是合理的。

由此看来,选用内聚、搭建中心范畴编辑的办法能使注册办法逻辑最为明晰。

什么逻辑应该归属于哪个事务域,这是对“范畴”的了解,就像如何对微服务进行鸿沟限制相同,不同的了解视点会产生不同的范畴模型区分。

需求说明一点:许多同学对写单元测试感到头疼:写的话,要做到高掩盖很费事;不写的话,不只跑不过 CI,心里还有点慌……不怕!通过对 PhoneNumber 逻辑的内聚、事务逻辑的简化,童鞋们写单元测试的功率可以得到极大的进步。PhoneNumber 这类型的改动频率比较小,一旦写了完善的测试用例,复用程度会很高~ 这样,后边的事务逻辑尽管会变杂乱,但单元测试逻辑的保护成本也不会进步。


文章来自:LigaAI- 智能研制协作平台|智能项目协作 (ligai.cn)

文章作者:nerd4me