初探领域驱动设计(Domain Driven Design)

前言:

我个人在学习DDD的过程中,早期翻找各种资料的时候,看到了很多名词:战略设计、战术设计、聚合根、实体、值对象、界限上下文...这些繁多的名词定义配合上几乎少的可怜的实战例子,让我在翻阅了大量资料之后依然感觉无从下手。在盲人摸象式的探索和一些实践经历后,我发现还是从开发人员最熟悉的代码层面做突破切入,才能迅速让初次接触的人员能够快速理解并上手DDD,所以这篇文章我决定直接从一些DDD的代码层面战术设计入手,一步一步窥探DDD带给我们的启发

你是否有这样的困惑

1、某个系统在发展的初期,能够迅速响应业务的快速功能迭代的需求,获得业务方的一致称赞。但是直到业务发展到一定复杂度的时候,系统的维护和迭代周期指数级增长,bug越来越多,之前神勇表现的开发人员,似乎不灵了?

2、庞大复杂的业务系统越来越难以维护,“能跑就不要乱动”这句程序员“圣经”一遍又一遍得到印证,直到某一天问题多到无法忍受,不得不花大力气对系统进行推倒重构。

3、业务方和产品人员跟开发人员沟通不畅,各自说着对方听不太懂的术语,最后开发出来的系统总是让使用的业务方感觉不满意,直到最后信任关系破裂。

4、不断变化的业务需求给系统的设计提出了越来越多的挑战,也许今天我们接到的是一个“养鸡场”的需求,或许明天就变成了做一个“肯德基”。在需求多变的场景下最终我们的业务系统走向了混乱的终点,变动臃肿且难以维护,又不得不推到重构。

开发人员如何跟业务专家进行合作建立起稳定、健壮、易维护的系统是长期以来一直讨论的话题,下面我们一起了解一下早在2004年由Eric Evans提出的 领域驱动设计(Domain Driver Design),看看能否解答我们遇到的困惑

一、从OOP面向对象说起

面向对象程序设计方法是尽可能模拟人类的思维方式,使得软件的开发方法与过程尽可能接近人类认识世界、解决现实问题的方法和过程,也即使得描述问题的问题空间与问题的解决方案空间在结构上尽可能一致,把客观世界中的实体抽象为问题域中的对象。 ——百度百科

我们比较熟悉的Java的语言的世界里,万物即对象,例如一些最基础的数据类型例如Integer、String这些对象一样,他们就像一砖一瓦一样构建起了整个程序世界。那么同样的,针对于我们关注一个复杂业务世界,是不是也可以抽象出一些最基础的对象,我们称之为**DP** (Domain Primary)对象,让这些DP对象成为我们构筑业务模型的一砖一瓦。

下面我来举一个构建DP对象并给代码带来具体优化的例子

DEMO1:用DP对象封装多对象行为

在我之前做质量中心系统的时候,涉及到自动化用例集的执行成功率情况的展示,如下图:

//前端交互VO类
public ResultRecordVO{
    String name;   //用例集信息
    String type;   //用例集类型
    String creator; //创建人
    String executeType; //执行方式
    Date startTime; //开始时间
    Boolean retry; //是否重试
    String firstProductDomain; //一级产品域
    String result; //执行情况
}

public AldSceneAssembleServiceImpl implements AldSceneAssembleService{

private RecordRepository recordRepository;

//根据用例集Id获取运行记录列表,并返回ResultRecordVO对象给
public ResultRecordVO getRecord(Long recordId) throws ValidationException{

    //逻辑校验
    if(recordId == null){
        throw new ValidationException("recordId");
    }

    //查询数据库获取DO的list
    RecordDO record = recordRepository.findRecordByAssembleId(assembleId);

    ResultRecordVO resultRecordVO = new ResultRecordVO();

    //此处省略部分DO到VO的转化逻辑
    ......  

    //转化执行情况字段逻辑
    Integer successNum = record.getSuccessNum();
    Integer totalNum = record.getTotalNum();

    //在拼装成功率前进行异常数据的校验
    if (totalNum == null || successNum == null || totalNum.equals(0)) {
      throw new ValidationException("totalNum or successNum can not be null or total can not be 0");
    }
      
     //计算成功率逻辑
     BigDecimal bigDecimal1 = new BigDecimal(successNum);
     BigDecimal bigDecimal2 = new BigDecimal(totalNum);
     //成功率为成功数除以总数,保留两位小数
     BigDecimal rate = bigDecimal1.divide(bigDecimal2, 2, BigDecimal.ROUND_HALF_EVEN);
    
     //拼接展示数据
     StringBuilder str = new StringBuilder();
     str.append(successNum);
     str.append("/");
     str.append(totalNum);
     str.append(String.format(rate));
     resultRecordVO.setResult(str.toString());
    
     return record;
  }  
}
复制代码

上面的代码基本符合了我们大部分人的思维习惯,看起来是没有问题的,但是我们从以下几个维度分析以下

1、业务代码的清晰度

我们可以看到上述代码中接口public ResultRecordVOgetRecord (Long recordld);的实现逻辑中,包含了参数校验逻辑、数据库的操作逻辑、异常数据校验逻辑、字段模型的转换逻辑、成功率计算逻辑等逻辑。可以试想,如果我们的 ResultRecordVO 字段更加的复杂,我们这一个接口中的逻辑会变得越来越冗余,难以维护。有的同学可能会想到将实现逻辑的抽分成多个方法的方式,公用的方法也可以抽取一个静态工具类Utis,这种方式固然可行,但是这是否是最好的实现方式呢?当你抽取的方法过多,静态工具类也到处散落,业务的核心逻辑是否依然清晰呢?

2、数据验证和错误处理

上述代码中有存在异常数据的校验逻辑,一般为了保障能够fail-fast这些代码都会写到整体逻辑的前端。但是这里有个问题,如果有多个地方使用到了success Num,total Num是不是每个地方都要有这些校验逻辑;假如有一天我要新增失败的个数的时候,是不是还要再每个校验逻辑的地方增加一个failNum的校验?同时我们在上面的代码逻辑中,抛出了 ValidationException ,那么外部调用的时候则需要进行try catch,而业务逻辑异常和数据校验异常处理混合在了一起。那么有同学可能会想到可以通过catch不同的异常进行不同异常类型逻辑处理,那么还是那个问题,如果该方法的外部调用方比较多,则每个调用方的try catch均需要进行分类处理。

3、单元测试的可测试性

对上述代码逻辑进行单元测试时,我们会遇到单元测试仅能够对当前的接口public ResultRecordVOgetRecord (Long recordal/进行单元测试,而这就需要我们自己造一些测试数据在数据库中,来对各种逻辑分支进行测试和覆盖。假如此时我们仅关注 ResultRecordVO 的执行情况字段进行测试,那么根据逻辑我们知道该字段的输入主要依赖于 successNum 和totalNum,那么仅异常用例的情况下,我们要考虑字段为Null和字符值不符合要求的情况,共计4个TC,而假如计算逻辑今天在用例集的运行成功率计算处使用了,而明天我需要在研发流程卡点规则的运行成功率计算也要再使用一遍,那么对于这两个不同的业务场景,都要把这4个TC再来一遍。对于质量中心来说,需求量大开发任务紧的情况下,大部分情况下不会做十分充分的单元测试,那么隐藏的问题在某天就会很容易暴露出来。

通过上面三个维度的分析,长时间大量这样的代码会让我们的系统变得越来越冗杂难以维护。那么解决方案是什么呢?

我们先仅考虑成功率计算这些逻辑,该部分逻辑涉及到了多个对象Integer totalNum, Integer successNum, BigDecimal rate,String percent等多个对象的相互计算逻辑,那么我们是否可以将计算成功率这部分功能,封装到一个叫做CaseNumRateDp的对象里。代码如下

public class CaseNumRateDp {

    private Integer totalNum;

    private Integer successNum;

    private BigDecimal rate;

    private String percent;

    public CaseNumRateDp(Integer totalNum, Integer successNum)throws ValidationException {
    if (totalNum == null || successNum == null || totalNum.equals(0)) {
            throw new ValidationException("totalNum or successNum can not be null or total can not be 0");
        }        
    this.totalNum = totalNum;
        this.successNum = successNum;
        NumberFormat percent = NumberFormat.getPercentInstance();
        percent.setMaximumFractionDigits(2);
        BigDecimal bigDecimal1 = new BigDecimal(successNum);
        BigDecimal bigDecimal2 = new BigDecimal(totalNum);
        this.rate = bigDecimal1.divide(bigDecimal2, 2, BigDecimal.ROUND_HALF_EVEN);
        this.percent = percent.format(rate);
    }

    public BigDecimal getRate() {
        return rate;
    }

    public String getPercent() {
        return percent;
    }

    public Integer getTotalNum() {
        return totalNum;
    }

    public Integer getSuccessNum() {
        return successNum;
    }

}
复制代码

上面的代码我们可以看到

1、我们把校验逻辑放到了构造方法中,则保障只要CaseNumRateDp能够被成功构造出来,那么校验一定通过

2、计算逻辑也放在了构造方法中,则意味着我们在有了CaseNumRateDp对象之后,可以随意去拿它拥有的各种属性值

而我们使用了CaseNumRateDp之后,再来看下之前的代码会变成什么样

//前端交互VO类
public ResultRecordVO{
  String name;
  String type;
  String creator;
  String executeType;
  Date startTime;
  Boolean retry;
  String firstProductDomain;
  String result;
}

public AldSceneAssembleServiceImpl implements AldSceneAssembleService{

  private RecordRepository recordRepository;
  
  //根据用例集Id获取运行记录列表
  public ResultRecordVO getRecord(Long recordId) throws ValidationException{
    
      //逻辑校验
      if(recordId == null){
        throw new ValidationException("recordId");
      }

      //查询数据库获取DO的list
      RecordDO record = recordRepository.findRecordByAssembleId(assembleId);

      ResultRecordVO resultRecordVO = new ResultRecordVO();

      //此处省略部分DO到VO的转化逻辑
      ......  
      
      //校验逻辑和计算逻辑收敛在了caseNumRateDp中      
      CaseNumRateDp caseNumRateDp = new CaseNumRateDp(record.getSuccessNum(),record.getTotalNum());
     
      //拼接展示数据
      StringBuilder str = new StringBuilder();
      str.append(caseNumRateDp.getSuccessNum());
      str.append("/");
      str.append(caseNumRateDp.getTotalNum());
      str.append(String.format(caseNumRateDp.getRate());
      resultRecordVO.setResult(str.toString());
    
      return record;
  }  
}
复制代码

1、代码的清晰度

经过上面的简单重构之后,执行结果字段的校验逻辑和计算逻辑均收拢到了Case Num Rate Dp中,变成了可复用且可测试的代码块,整体上public ResultRecordVO get Record(Long recordld);接口逻辑变得更加的清晰

(上述代码仍有较大的重构空间,这里仅对DP的作用进行关注和探讨)

2、数据验证和错误处理

我们可以看到字段的校验逻辑收拢到了Case Num Rate Dp的构造方法中去了,这样如果出现多个地方使用,但是不用担心漏校验的问题,且即使某天计算逻辑发生了变化,需要计算失败率的时候,只需要修改Dp对象中的校验逻辑和计算逻辑即可。

3、可测试性

我们的测试逻辑收敛之后,可以针对 CaseNumRateDp 自行做单元测试,且不必再通过数据库造数据这种成本较高的方法。对单个Case Num Rate Dp单测通过之后,无论其他地方哪里再使用它,不必再对成功率的计算逻辑再次进行测试。

上面便是DP对象优化代码的一种非常常见且便于理解的场景:封装多对象行为。 而在其他资料中,还有两种比较常见的场景,下面我会根据场景选择相似的例子,在这里加以说明

DEMO2:用DP对象将隐性的概念显性化

我们先看一个简单的例子

某公司的晚会需要一个抽奖系统,该抽奖系统会将员工的基本信息(姓名,工号,工位地址信息)登记录入到该系统中,在抽奖过程中会根据工号进行抽奖,然后奖品按照工位地址信息进行发放。假设该公司的工号组成第一位标识了不同部门,例如 1表示 销售部 2 商务部 3 技术部 4 售后部

我们先看下这个抽奖系统的员工信息注册接口的代码实现逻辑

public class Employee{
  String workId; //工号
  String name; //姓名
  String address; //工位地址
  String department;//部门
}

public class RegistrationServiceImpl implements RegistrationService{

    private EmployeeRepository employRepo;
    
    private DepartmentRepository departRepo;
    
    //注册接口实现
    public void register(String name,String workId,String address){
         //
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值