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

2,111 阅读1小时+

前言:

我个人在学习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对象封装多对象行为

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

image.png

//前端交互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){
         // 校验逻辑
        if (name == null || name.length() == 0) {
            throw new ValidationException("name");
        }
        if (workId == null || !isValidWorkId(workId)) {
            throw new ValidationException("workId");
        }
        //拼接employ对象,并进行注册
        String pre = workId.subString(0,1) //取出工号第一位
        String departMent = departRepo.findByNum(pre);
        Employee employ = new Employee();
        employ.name = name;
        employ.workId = workId;
        employ.address = address; 
        if(departMent!=null){
            employ.departMent = departMent;
        }
        departRepo.save(employ);
    }
    
    
    private boolean isValidWorkId(String workId) {
       String pattern ="[0-9]\d*"
       //此处省略其他校验规则....
       //校验是否符合
       return workId.matches(pattern)
    }
}

上面这段代码,我们依然从以下几个纬度去分析

1、接口的清晰度

public void register(String name,String workId,String address);

这个接口就是上面代码实现的接口,乍一看没什么毛病,但是如果调用方一时眼花/手滑,调用代码这样写的话

service.register("100001","第四工区701","张三");

这段代码在代码编写和编译时是不会报任何错误的,只有在程序运行起来的时候,才会报错。我们都知道代码的Bug发现的阶段越早越好,那么有没有什么办法能在代码编写阶段就发现这样的问题呢?

2、业务代码的清晰度

上面的代码我们依然发现,接口处理的逻辑包含了校验逻辑,从入参抽取所需参数的逻辑,从Repository查询数据的逻辑以及持久化逻辑糅杂在了一起,试想一下,如果这段逻辑中的任何一处发生了变化,我们需要在读懂整个接口的逻辑的前提下,去修改代码,那么当这段代码变得越来越复杂的时候,维护成本是很高的。

3、数据校验和错误处理

基本上和DEMO1的问题一致,这里不再过多赘述。

4、单元测试的可测试性

基本上和DEMO1的问题一致,这里不再过多赘述。

那么如果用DP对象去优化这段代码,效果又会怎么样呢?首先我们发现工号作为一个参数传入,但实际上工号的前缀第一个字符是业务逻辑需要关心的,所以工号的前缀这个概念在不经意在被隐藏掉了,而我们要将其显性化出来,而工号的前缀首字符属于工号,那么我们可以构建出下面这个DP对象

public class WorkId{
    
    private String id;
    public String getId(){
        return id;
    }
    //将校验方法放入到构造方法中
    public WorkId(String id){
        if(id==null){
            throw new ValidationException("工号id不能为空");
        }else if(isValid(id)){
            throw new ValidationException("工号id校验不通过")
        }
    this.id = id;
    }
    
    public String getPreCode(){
        return id.subString(0,1);
    }
    
    public Boolean isValid(String id){
      String pattern ="[0-9]\d*"
       //此处省略其他校验规则....
       //校验是否符合
      return id.matches(pattern)
    }
}

上述代码中,一个DP对象对应代表了一个workId的对象;同时我们将校验逻辑收敛到了DP对象里,这样如果新增校验逻辑在多个地方使用的情况,仅修改这一个对象里面的校验逻辑即可;另外我们将校验逻辑放在了构造方法中,如果参数不正确则直接无法构造,那么就保证了该DP对象被创建出来即一定校验通过了;还有自身携带了getPreCode()方法,即说明其是WorkId对象自身特有的逻辑。

那么在同时将Employee中的属性全部用DP对象进行替换后(实际情况中,不一定需要全部替换,需要根据实际情况而定,这里我们仅是为了演示而这么做),我们看看现在代码会变成什么样。

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

public class RegistrationServiceImpl implements RegistrationService{

    private EmployeeRepository employRepo;
    
    private DepartmentRepository departRepo;
    
    //注册接口实现
    public void register(
    @NotNull Name name,
    @NotNull WorkId workId,
    @NotNull Address address)
    {
        //拼接employ对象,并进行注册
        String pre = workId.getPreCode() //取出工号第一位
        DepartMent departMent = departRepo.findByNum(pre);
        Employee employ = new Employee();
        employ.name = name;
        employ.workId = workId;
        employ.address = address; 
        if(departMent!=null){
            employ.departMent = departMent;
        }
        departRepo.save(employ);
    }
 
}

可以看到接口的参数变了,实现方法之前的校验逻辑和一些非业务流程也没有了,剩下的只有核心的业务逻辑,我们再来看看上面提到的四个问题。 1、接口的清晰度

public void register(Name name,WorkId workId,Address address);

这样的接口,就不会再出现上面提到的错误,因为一旦写错,IDE会提示,且编译就不会通过。 2、业务代码清晰度

关于接口的实现逻辑,上面的代码仅剩下最核心的业务逻辑,校验逻辑和非业务流程getPreCode()都收敛消失了,代码逻辑的清晰度大大提升。并且其他任何使用到工号WorkId的DP对象的地方,校验逻辑和getPreCode()都不用再写一遍了,复用性大大提高。

3、数据校验和错误处理

DP对象自身的校验逻辑收敛到了自己的对象里,意味着只要对象能够构建出来,那么一定是符合校验规则的,另外接口逻辑中我们只需要使用@NotNull进行判空即可,同样没有了多余的校验逻辑。如果DP对象的校验规则有朝一日发生了变化,那么仅需要修改DP校验即可,所有用到的地方都会生效。

4、单元测试可测试性

基本上同DEMO1一致,这里不再赘述

可以看到,我们通过简单的DP对象的使用,让我们的代码更加健壮。

DEMO3:用DP对象将隐性的上下文显性化

假如我们要实现一个转账的功能,从A的账户转账X元给账户B

实现大致如下

public void pay(BigDecimal money, Long recipientId) {
    BankService.transfer(money, "CNY", recipientId);
}

如果该转账功能仅在境内,该实现没有什么问题。但是如果出现了我们需要跨境转账,该实现就出问题了。

在这个例子中,转账除了金额数字,还需要关注的就是货币类型,有可能我们需要转账CYN到USD,那么就需要进行汇率的计算和转换,这个时候,货币类型这个上下文概念就在无意中被我们隐藏掉了。所以这个时候,我们构建Money这个DP对象,不仅需要金额数字BigDecimal这个属性,还需要货币属性Currency,代码如下

public class Money{
    private BigDecimal amount;
    private Currency currency;
    public Money(BigDecimal amount,Currency currency){
        this.amount=amount;
        this.currency=currency;
    }
}

而我们做转账的时候,考虑到跨境的场景的话,就需要获取from Currency和target Currency的汇率,并进行转换,而转换逻辑同样可以使用一个DP对象封装

public class ExchangeRate {
    private BigDecimal rate;
    private Currency from;
    private Currency to;

    public ExchangeRate(BigDecimal rate, Currency from, Currency to) {
        this.rate = rate;
        this.from = from;
        this.to = to;
    }

    public Money exchange(Money fromMoney) {
        notNull(fromMoney);
        isTrue(this.from.equals(fromMoney.getCurrency()));
        BigDecimal targetAmount = fromMoney.getAmount().multiply(rate);
        return new Money(targetAmount, to);
    }
}

那么这时,我们的转账逻辑代码

public void pay(Money money, Currency targetCurrency, Long recipientId) {
    //先从外部获取当前货币和目标货币的汇率信息
    ExchangeRate rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
    //根据汇率信息,对当前货币值进行转换
    Money targetMoney = rate.exchange(money);
    //转换完成之后,进行转账
    BankService.transfer(targetMoney, recipientId);
}

DP小结:一种值对象

这里我给出DP对象的定义:Domain Primitive是一个在特定领域里,无状态的、拥有精准定义的、可自我验证的、拥有行为的值对象Value Object。

那么什么情况下可以使用DP对象呢?常见的

1、格式限制的String: Address, Name, Number, ZipCode

2、有限制的Integer、Long : 一定长度的OrderId, 百分比 Percentage 等

3、大多数BigDecimal涉及的:例如Money, Rate 等

4、复杂数据结构 Map<List<>>等 DP和众多基础的对象一样, 是构成DDD代码世界的最基础的砖瓦,它所体现出的面向对象与DDD的建模思想如出一辙。在这里我们通过DP对象的构建再次重温了面向对象的好处,为我们接下来更加深入DDD打下坚实的基础。

二、DDD的架构与约束

前言

架构其实就是将代码按照一定的规则进行置放,好的架构能让系统安全、稳定、快速迭代,在一个团队内通过规范架构设计,可以让团队内能力参差不齐的同学们都能有一个统一的开发规范,降低沟通成本,提升效率和代码质量。

在学习DDD的过程中,我个人最大的体会就是DDD作为一种设计思想,它缺少一些比较成熟的框架来进行规范的约束,比如SpringMVC,导致很多新手落地实践比较困难。所以在这一章节中,我们先来聊一聊DDD在架构方面的思考带给我们的启发。

DEMO

我之前在阿里做质量侧工具平台开发的时候,发现大部分web server的工程采取的是三层MVC结构,即java经典的starter、service、dal、client,四个module,starter里面主要是view层的逻辑,一般都是写的各种controller接口,dal里面一般都是数据库持久层的model模型和mapping以及Mapper接口,client则是对外提供的rpc接口和参数模型DTO,而service里面则包含了主要的业务逻辑、rpc接口的实现逻辑、外部依赖逻辑、访问数据持久层逻辑、组装返回对外DTO的逻辑等等,可以说最重的逻辑都在service这一层中,整体四个module的依赖关系也是starter依赖service,service依赖dal和client。这里还是举个例子来说明一下

在做发布流水线卡点持续集成的时候,我们作为质量中心需要根据流水线的不同卡点,提供不同的测试用例并运行,流水线根据这个结果采取是否通过以及其他策略

这里我们暂时不考虑技术实现的合理性,仅从例子说明的角度来看这个需求经过技术选型可以简单拆解如下:

1、质量中心通过RPC接口查询流水线系统当前卡点的信息,例如现在是什么阶段的卡点,提测、预发布、发布,以及现在是哪个系统的流水线行为。

2、质量中心通过上述信息,从Mysql中查找出对应的用例信息

3、内部进行用例执行运行

4、触发运行行为之后,通过分布式调度中间件DTS,登记调度任务,用于异步查询运行进度以及运行结果,提供给流水线根据结果进行决策

一个简单的代码实现如下

public class TestCaseAssembleService{
    
    @Resource
    AoneFlowService aoneFlowService;  //流水线系统RPC接口服务
    
    @Resource
    DtsService dtsService; //dts服务
    
    @Resource
    TestCaseDAO testCaseDao;  //测试用例的DAO接口
    
    @Resource
    TestCaseExecuteService testCaseExecuteService; //内部测试用例运行服务
    
    
    public Result flowTestCase(Long aoneflowId){
        //1、参数校验
        if(aoneflowId == null){
            throw new ValidParamExecption("参数异常");
        }
        //2、通过RPC获取卡点信息
        Result<AoneFlowInfo> rpcResult = aoneFlowService.queryFlow(aoneflowId);
        //判断RPC的调用结果
        if(rpcResult==null||!rpcResult.success()){
            throw new RpcCallException("调用流水线系统rpc接口异常");
        }
        
        //3、获取流水线系统卡点信息并拼装查询参数,进行Mysql查询
        AoneFlowInfo aoneFlowInfo = rpcResult.getModel()
        QueryParam queryParam = new QueryParam();
        queryParam.createCritier.andProjectEquals(aoneFlowInfo.getProject()).andStageEquals(aoneFlowInfo.getStage());
        List<TestCaseDO> dbResult = testCaseDao.selectByParam(queryParam)
        
        //判断db的查询结果
        if(CollectionsUtils.isEmpty(dbResult)){
            throw new ValidConfigException("db未查出任何数据,请检查配置");
        }
       
        //运行并登记分布式调度任务,用于异步查询运行进度和运行结果
        //运行用例
        Result<ExecuteInfo> executeResult = testCaseExecuteService.execute(dbResult.get(0));
        //判断运行触发成功
        if(executeResult.success()){
            //注册dts异步任务,扫描运行情况,这里省略各种参数拼接逻辑
            dtsService.registe();
        }
        return Result.success(executeResult.success());
    }
   
}

上面这段代码属于经典的脚本代码, 面向过程的编程,也是众多程序员平时最直接的思维方式,这样的代码尤其容易出现在一些需求倒排资源紧张,工期短的场景下。这样的代码在快速支持需求的情况下,功能实现没有任何问题,从长久的迭代发展和维护的方面看,会有以下几个问题。

1、可维护性差

在整个系统的生命周期中,从需求确认到开发测试再到上线维护,整个生命周期中公认的开发活动一般不会是耗时最长的,其中大部分时间在于对系统的维护。而维护的重点在于保障代码功能的稳定,如果有任何逻辑变更或者外部依赖的变更,我们都希望以最小的代码修改代价来cover这些变更。所以对于脚本代码来说,我们可以具体分析一下各个变更带来的维护成本。

a、首先上面代码依赖了外部RPC接口,我们作为单个系统的owner,很难说外部的接口什么时候会不会发生变化,例如入参的变化,返回值的变化,甚至整个接口被下线掉。这个时候我们可能要对接口的变化进行适配,或者迁移,同时外部依赖的限流和熔断策略都要重新考虑并随之改变。

b、我们看到接下来我们会去查询Mysql获取数据库的信息,而我们在代码中直接使用了TestCaseDO这个专门用于和数据库表字段进行映射的结构来参与下面代码的逻辑。那么试想一下,数据库同样作为一个外部依赖,如果有一天要做sharding或者表字段发生了变化,那么这里的逻辑就需要重新进行修改了。假如整段代码中有多处使用到TestCaseDO这个对象的话,那么涉及到的地方全部需要进行修改。

c、我们在上面的代码中使用的ORM框架是Mybatis,假如有一天我们要更换ORM框架,我们一定期望以最小的影响范围进行迁移,例如最好不要对业务逻辑有影响,因为业务逻辑跟数据持久化层的耦合,会让我们的迁移成本巨大。

d、在代码的最后,我们使用了dts中间件对异步任务进行注册,那么还是有可能会面临c的问题,假如我们更换了其他任务调度中间件的话,因为业务逻辑跟中间件对耦合,迁移成本同样巨大。

2、可扩展性差

上面脚本式代码的写法虽然比较快,但是代码的可扩展性是比较差的,比如如果我要加一个非流水线触发而以系统维度的用例定时运行的功能,那么基本上新功能要重新写了,基本上得不到什么复用。原因要是整个接口的各种逻辑为一体,每种逻辑均为原子服务的调用,而无原子服务以上的封装。所以在相关新功能的加入的时候,几乎需要重新开一个接口了。并且在某些极端的情况下,对原有功能的修改,可能还需要将整个接口的逻辑推倒重构。

3、可测试性差

上面这段代码中,对于一些对外部的依赖我们可以进行一些细粒度的单元测试以外,如果要进行整个接口功能串联的验证的话,则会发现我们对于外部的依赖的环境要全部搭建起来,并且需要准备好对应的测试数据,才能够完成测试。在测试模型中,全链路端到端的测试成本最高,这类测试应该是少量的,而细粒度且成本比较低的单元测试是我们需要多去执行的。另外我们发现针对于业务逻辑的测试,我们要构建一些边界条件的测试,需要通过造数据和造场景的方式去达成,这样做的成本仍然较高。总体来讲,上面这段代码的逻辑可测试性是比较差的。

为了解决上面的问题,降低逻辑耦合度,我们主要思路是对每个依赖节点进行抽象和整理。

a、抽象数据存储层,降低对数据存储的耦合度

为了不直接使用数据库字段映射的模型类TestCaseDO耦合在代码中,这里我们引入实体Entity的概念,简单说,实体是面向业务的,可以有唯一标识的,同时也可以拥有自身行为的对象。和DP对象相比较来说,DP对象是无状态的,而实体对象是有唯一标识的有状态的。关于实体的概念和具体的规则使用在后面的章节中我会详细介绍,这里先做个铺垫。大家可以理解为在包含业务逻辑的接口中,我们使用实体模型替代TestCaseDO进行解耦,同时不再直接使用DAO接口在业务逻辑层中,取而代之的是Repository层,Entity和TestCaseDO的链接和转换逻辑统一封装在Repository层中。具体的代码如下

//测试用例实体类
@Data
public class TestCaseEntity{
    privare Long caseId;
    private String name;
    private TypeEnum caseType;
    .... 
}

public class TestCaseRepository{

    @Resource
    TestCaseDAO testCaseDao;  //测试用例集的DAO接口
    
    @Resource
    TestCaseEntityBuilder tsetCaseEntityBuilder;//实体工厂转换器
    
    //根据流水线系统卡点查询
    public TestCaseEntity findByAoneFlowParam(String project,String stage){
        QueryParam queryParam = new QueryParam();
        queryParam.createCritier.andProjectEquals(project).andStageEquals(stage);
        List<TestCaseDO> dbResult = testCaseDao.selectByParam(queryParam);
        List<TestCaseEntity> result = tsetCaseEntityBuilder.build(dbResult);//里面包含了非空判断
        return result.get(0);
    }
    //这里省略其他涉及DB操作的封装
    ...
}

上面的代码通过repository层将所有数据库操作的DAO进行封装,并通过Entity承接数据库字段映射DO类,这样做的好处,避免了业务代码与数据库的之间耦合,避免了数据库变化业务逻辑跟着变的问题;业务代码面向的不再是DO和DAO,而是Entity,从面向数据库转变为领域实体对象;其中TestCaseRepository的类仅作为数据库与实体的映射,职责单一出来,而实体作为纯内存对象,更容易进行测试。

b、对外部的 RPC 依赖添加 ACL 防腐层

为了解耦业务接口逻辑对外部RPC接口的直接依赖,比较常见的方式就是增加ACL防腐层,这样如果外部RPC接口发生了任何变化,我们的业务逻辑基本上不会受到太大的干扰,而我们只需要修改的就是防腐层的逻辑。防腐层可能看起来只是又包了一层,其除了做逻辑解耦的功能以外,还能在该层做适配器、限流、功能开关,缓存等功能,可以说好处很多。同时因为新添加了内部方法,也更加便于做一些测试,具体代码如下

public class FacadeService{

    @Resource
    AoneFlowService aoneFlowService;  //流水线系统RPC接口服务
    
    @Resource
    WrapperService wrapper;
    
    public Result<AoneFlowInfoWrap> queryFlowInfo(Long aoneflowId){
        Result<AoneFlowInfo> result;
        //在这里对外部异常进行处理
        try{
            result = aoneFlowService.queryFlow(aoneflowId);
        }catch(Throwable e){
             throw new AoneServiceException("aone外部依赖异常:"+e.stackTrace());
        }
        return wrapper.wrap(result);
    }
}

c、通过领域服务封装实体逻辑

领域服务的概念也是DDD中一个非常重要的概念,这里首次提出。领域服务引入的好处和具体的用法、规则会在后面详细介绍。这里先做一个简单的使用展示。主要思路是作为用例实体,应该自身拥有“运行”这个动作,但是该动作看似是该实体自身的行为,但是同时涉及到了另外一个实体 用例运行记录,以及外部分布式异步任务调度中间件DTS的依赖,所以这里采用领域服务来进行封装,这里进一步体现了面向业务领域的编程。具体代码如下:

//用例运行领域服务
public class ExecuteService{

    @Resource
    TestCaseExecuteRepository executeRepo;//用例运行记录存储服务 
    
    @Resource
    CaseExecuteEntityBuilder executeEntityBuilder;//实体工厂转换器
    
    @Resource
    DTSservice dtsService; //分布式任务调度服务,用于异步查询运行进度和结果
    
    public Result<Long> TestCaseRun(TestCaseEntity testCaseEntity,Long executeId){
        //如果该id为空,说明是新运行
        if(executeId == null){
            CaseExecuteEntity executeEntity = executeEntityBuilder.buildNew(testCaseEntity.getId);
            executeRepo.save(executeEntity);//先持久化
            dtsService.regist(executeEntity);//再注册任务
            //此处省略注册异常回滚处理逻辑 
            ...
            return Result.success(executeEntity.getId);
        }else{
         //如果不为空,则说明运行的是已存在的记录,暂停运行或者是重试运行,此处逻辑先省略
         //....   
        }   
    }
}
//测试用例实体类
@Data
public class TestCaseEntity{
    privare Long caseId;
    private String name;
    private TypeEnum caseType;
    
    private List<Long> executeIds; //执行记录的id集合
    
    //用例执行动作,实体方法中不直接依赖外部服务,领域服务作为参数传入
    public Result newRun(ExecuteService executeService){
       Result<Long> result = executeService.TestCaseRun(this,null);
       executeIds.add(result.getModel());
       return result.success();
    }
    
    //运行重试
    public Result retryRun(ExecuteService executeService,Long executeId){
        executeService.TestCaseRun(this,executeId);
        //省略...
    }
}

现在我们看一下,经过上面的抽象和重构,我们整个脚本式代码会变成什么样子?

public class TestCaseAssembleService{
    
    @Resource
    FacadeService facadeService;  //RPC接口门面服务
    
    @Resource
    TestCaseRepository testCaseRepo; //用例的存储服务
    
    @Resource
    MqProducerService mqProducerService;  //mq中间件服务
    
    @Resource
    ExecuteService executeService; //用例运行领域服务
    
    
    public Result flowTestCase(@Valid Long aoneflowId){
       
        //1、通过防腐层获取rpc信息
        Result<AoneFlowInfoWrap> resultWrap = facadeService.queryFlowInfo(aoneflowId);
       
        //2、获取流水线系统卡点信息并拼装查询参数,进行Mysql查询
        TestCaseEntity testCaseEntities = testCaseRepo.findByAoneFlowParam(resultWrap.getModel().getProject(),resultWrap.getModel().getStage());
         
        //3、用例实体方法执行
        Result result = testCaseEntity.newRun(executeService);
        
        return Result.success(executeResult.success());
    }
    
}

我们可以看到,经过重构之后

a、整个接口的逻辑就剩下了4行代码,除了最后一行返回,其它三行都对应了一种业务行为,原来之前各种参数校验、参数拼接、异常处理等逻辑,全部统统收敛至各自的方法中,剩下的只有业务逻辑。

b、上面提到的数据存储通过repository层、外部依赖通过ACL防腐层跟业务逻辑完全分离。

c、业务逻辑层里面的各种行为,可以进行编排,后面我们会了解到,这一层叫做Application Service应用服务层。是应用层的组成部分,应用层依赖领域层。

d、我们从面向数据库的编程,转变为了面向更具有业务含义的领域模型的编程,这样转变的好处后面会详细讲。而实体、领域服务、DP对象我们统一归纳为领域层。

e、ACL和Repository的具体实现,依赖外部的数据持久化、RPC接口等这些,统称为Infrastructure层。

所以,最终上面的代码重构之后,整体我们的代码会由三层架构形成下面的DDD经典的四层架构

image.png

其实,如果我们整个编码流程不是通过这样先脚本流程再重构的方式进行,而是直接面向领域编程的话,自然会先专注于各个领域实体的业务逻辑,然后对功能实现编排,最后完成外部依赖实现。这也就契合了Domain Driven Design 领域驱动设计的思维方式和实现。

代码划分与约束

前面我们提到DDD的编码架构约束的问题,因为就算我们把一个编程思想讲的再透彻,缺少落地实践总是不太够的,而架构就是帮助我们实际进行落地的规范。同时在团队协作的范围内,架构约束能够帮助我们尽可能的避免因为一些人为的因素而导致的“不讲码德”的事情发生。那么根据我们上面介绍的DDD四层架构的内容,我们又该如何从定义各个module,通过Java工程的各个Module的pom文件去定义各个Module之间的依赖,实现一个简单的架构约束?下面给一个例子。

1、Types模块,里面用于盛放可以对外暴露DP类和对外的DTO以及入参等,因为要对外暴露所以经常会被依赖在对外的API接口中,其本身无任何依赖。

2、Domain模块,最主要的业务核心逻辑,包含Entity实体类,领域服务类,以及外部依赖例如Repository、ACL、中间件等的接口类。其本身依赖Types模块

3、Application模块,主要是服务编排service类。其依赖Domain模块,同时提供对外接口的实现方法,依赖Client模块

4、Infrastructure模块,包含了Repository、ACL、RPC、中间件等具体的实现,同时包含模型转化关系的类。其依赖外部框架例如 Spring-Mybatis,RPC等,同时需要实现Domain中的接口方法,需要依赖Domain模块

5、Starter模块,Springboot启动类,同时包含Controller等相关类,依赖Application模块

6、Client模块,对外提供RPC服务的接口客户端,依赖Types模块

image.png

一个简单的依赖关系如上图所示。基本上可以对DDD经典四层架构描述清楚了。

除了我们可以自行根据项目的需要去定义自己的模块之外,在GitHub上有一个Java的框架叫COLA github.com/alibaba/COL…

image.png

从上图我们可以看到,COLA的架构分层同样也是面向Domain的四层架构

1)适配层(Adapter Layer):负责对前端展示(web,wireless,wap)的路由和适配,对于传统B/S系统而言,adapter就相当于MVC中的controller;

2)应用层(Application Layer):主要负责获取输入,组装上下文,参数校验,调用领域层做业务处理,如果需要的话,发送消息通知等。层次是开放的,应用层也可以绕过领域层,直接访问基础实施层;

3)领域层(Domain Layer):主要是封装了核心业务逻辑,并通过领域服务(Domain Service)和领域对象(Domain Entity)的方法对App层提供业务实体和业务逻辑计算。领域是应用的核心,不依赖任何其他层次;

4)基础实施层(Infrastructure Layer):主要负责技术细节问题的处理,比如数据库的CRUD、搜索引擎、文件系统、分布式服务的RPC等。此外,领域防腐的重任也落在这里,外部依赖需要通过gateway的转义处理,才能被上面的App层和Domain层使用。

上面便是比较经典的分层架构的一些具体实例,相较于之前的三层架构,经过抽象分层之后的架构代码逻辑更加的清晰便于维护和管理。但是分层的另一个极端情况也是需要我们进行避免的,即过度的对代码进行抽象分层,粒度太细,就成为了一个老千层饼,这样其实也没有必要。所以总体来说,架构是适应业务发展而来的产物,没有最好的架构,只有最适合的架构。

三、Entity实体建模与Repository持久层

在大致了解完上面整体架构的介绍之后,这一章节我们会详细介绍关于DDD最重要的实体层和我们常用的Repository层的设计规范,以及这里我们为什么要这样做的一些取舍。

说到了实体,就不得不说一下实体模型(充血模型)和贫血模型了。那么什么是贫血模型呢,基本上可以理解为我们用于映射数据库字段的各种DO类,即只有各种映射数据库的属性字段和getter setter方法。说到这里我们就会发现大部分的开发者使用的都是贫血模型。至于为什么贫血模型会用的这么多,主要原因有以下几个方面:

1、目前我们的ORM框架大部分都是MyBatis,方便的generator生成器能够快速的生成sql和DO几乎让我们不用手写任何的数据库sql和Mapper,就可以方便地在代码中随意操作数据库

2、大部分开发人员都是数据库思维,大部分涉及到数据库的需求一来,对于技术方案的第一反应是数据库字段有哪些

3、贫血模型仅仅是数据库字段映射,非常简单,搭配上流水式开发思维,就非常便于理解,不需要过多的考虑,尤其是在赶工的时候,基本上可以达到快速抄起键盘就干的程度。

总结起来,贫血模型拥有灵活度高,容易控制等特点。我们在设计好数据库表后,从上到下实现功能,实现事务等也符合技术直觉。但是其缺点也是非常明显的,那就是太灵活反而容易被滥用,经过一段时间以后,尤其是熟悉这段代码的人员离职或转岗,就没人知道这段代码是做什么的了。我们对这种情况称之为“贫血症引起的失忆症”。

这里回到整篇文章开头介绍的场景,即互联网场景下的“养鸡场”到“肯德基”。这里举一个不太恰当的例子,假如我们最开始的需求是建立一个养鸡场,主要是因为当前贩售活体鸡是一件收益非常高的事情,能给公司带来很多的业务收入。那么我们通过代码搭建好养鸡场,然后加大马力去生产每一个鸡蛋,然后孵化出小鸡,让小鸡满满羽翼丰满,能跑能飞。可是随着业务的发展,外部环境发生了变化,由于前期养鸡场建的太多,市场上的活体鸡远远供大于求,而新打入市场的快餐“肯德基”在市场上非常受欢迎,作为互联网养鸡场的我们自然要快速的拥抱变化,转变业务方向,从养鸡场变为快餐厅“肯德基”。那么我们之前做好的活体鸡怎么办呢?就地改造!我们通过代码把活体鸡通过各种方式做成了炸鸡,但是我们之前某段可以让活体鸡飞起来的代码遗忘在了某个角落,当炸鸡做好了之后,在某个比较极限的场景下,触发了飞起来的代码,这个时候炸鸡竟然要起飞,然后就Boom,出故障了。

网上有个图挺有意思,当模型已经摇摇欲坠,后继的修改人强如谢尔顿也最好带上安全帽

image.png

而实体Entity(充血模型)的视角则完全是站在业务模型的视角出发建立的模型,具不仅拥有属性,仍有行为方法。实体模型的重要作用对于团队协作的开发流程来说,建立技术和产品业务的统一语言,减少开发人员和产经理的沟通成本。对于技术本身而言,是建立在数据持久层之上的业务模型层,如果将数据持久层看作我们程序不易变更的固件,那么实体就是隔离固件和我们上层软件逻辑的一层,具体就是通过 Repository 模式来进行解决的。

DEMO

这里举一个我之前做质量中心实际遇到的例子。质量中心的一部分核心便是自动化脚本的管理模型,这一部分和质量中心的执行能力起支撑起了质量中心上层各种业务能力,均属于底层的核心能力。但是这部分管理模型经过了之前质量中心一定时间的发展,已经具有一定的复杂度了,这里我大概梳理了一下

image.png

上述仅仅是模型,而为了支撑复杂的业务用法,每种用例的执行数据和运行记录也均需要各种的存储。所以综合下来,在不考虑结果校验模块的情况下,仅仅用例模型涉及到的数据库的表一共有8张。这部分在和一位要转岗的同事进行交接的过程中,是一个十分痛苦的过程,因为很明显的体感便是涉及的数据库的表不仅多,而且由于当时发展过程中的种种因素影响,一些字段早已失去了其原本的含义,某些字段的数据混乱,不同类型的数据并存情况也时有出现,最麻烦的就是有些字段连该同事都不再清楚其中的含义。解决方法只能是不停的扒代码,找同事,慢慢寻找那因为“贫血症而失去的记忆”。

为了理清楚这部分核心模型关系,能够造福我自己的同时也能够造福后人,这个时候我采用了 Repository 层和实体模型的方式,对这部分模型进行了重构。首先我们一定要意识到数据库理论上属于我们应用系统所依赖的一个固件,其字段不应该被随意修改,一旦有改动,也必须快速而精准的评估出其对当前业务实体模型产生的影响,进而能推断出对我们业务功能产生的影响。所以本次重构我采用的策略是,数据库层面不再进行任何改动,从业务功能场景入手,构建面向业务的众多实体,再通过 Repository 层一步一步的建立底层8张表众多字段和实体的各个属性的关联关系。简单用

一张图来表示即

image.png 重构前业务逻辑和底层数据库的依赖关系

image.png 重构之后的业务逻辑和底层数据库的依赖关系

可以看到我通过Entity实体对象,避免了业务逻辑直接操作数据库,避免了当数据库字段发生变化直接影响业务逻辑。Entity和DAO的字段关系转换写在了 Repository 层中,这样即使出现“失忆症”的情况,也能够在 Repository 层的转换代码中,在无其他业务逻辑的干扰情况下,快速找到其映射关系和转换逻辑。同时因为Entity实体对象完全是内存中的对象,比较容易实现Mock等测试。

弄清楚了上面的原理,便可以通过从业务规则和场景入手,先将实体对象建模好。当然这些工作相对会容易一点,因经过一定时间的积累迭代,自动化脚本的业务场景已经相对稳定;本身人对业务场景的记忆清晰度远远超过对某个数库字段的记忆清晰度:且仍有一众从这些从质量中心系统的前生今世一直参与的人都可以提供很有价值的信息。经过我的一番努力梳理,业务实体模型便初步建立起来,如下图

image.png

有了实体对象以后,接下来就是最难的工作了,建立 Repository 层,转换实体和底层8张表子段的映朝关系,这个过程非常的痛苦和枯燥,正如前面所提到的,“失忆症”便是这层工作真正要克服的困难,不过最终成功建立起 Repository 层,并重构成功一个自动化脚本查询接口。此时的成就感是非常的强烈的,因为这意味着后面的所有工作,可以从业务场景入手考虑,仅仅通过实体的修改来支持业务逻辑,而数据库层面能固化不动则不动,减少了大量的重复劳动和工作量,让我们的系统更加稳固。重构之前整个接口的service层业务代码差不多有100行,强耦合的数据库操作逻辑,校验逻辑,各种计算逻辑,返回值组装逻辑。重构之后,service层业务代码逻辑总共有10行左右。

经过上面的一次落地尝试和体验,可以感受到Entity实体建模和 Repository 持久层这种编码风格带来的切切实实的好处,但是这种模式还是会有一些弊端,例如所有的业务操作均基于实体对象,如果一个实体对象属于比较大而复杂的聚合根(一个实体对象包含其他多个实体,和DP值对象)的情况下,那么可能一个简单的业务操作,需要先构建这个大实体,势必会对内存和性能是一种消耗;而且我们在做一些实体变更操作的时候,大实体并非每个实体都有变更,那么如果全量进行扫描变更的话,同样会非常的损耗性能。举个例子,就像我们熟知的电商业务的主子订单,如果存在一主多子的订单,而我们的修改操作仅仅是它的运费订单,那么完全不需要将所有主子订单都查询出来然后再仅仅修改运费。这里业界有两种比较主流的变更追踪方案,即基于SnapShot内存Diff的方案,和基于Proxy判断属性是否被更改标记Dirty的方案。在字节基于Go语言我发现是有使用SnapShot对变更进行追踪的方案介绍的,感兴趣的同学可以去tech圈里搜一下。而我之前的实践中,还是采用了比较原始的写多个Repository的临时更新的方法,比较笨,但是简单直接,复用性也不差。同时这里有一篇聚合使用的一些思考的文章,感兴趣的同学可以看一下,mp.weixin.qq.com/s/7SRfVWckq… 。总的来看,新的编码风格OOP起来非常的爽,但是有可能会带来更多的性能损耗,落地起来也会遇到各种各样的问题。但是对于面向业务系统的开发来说,正如byteTech里面有位老师讲的。

image.png

上面4个指标中,按照重要性排列,或许性能指标是最不重要的了,我们更多的还是关注于前三个指标。

即原有功能要稳定、新功能要迅速交付、程序员要早下班。

模型间的关系

经过对上面例子的介绍,相信小伙伴们对Repository和Entity已经有了一定的认识。那么对于新引入的Entity模型,它和数据库字段映射类DO和出参DTO之间是什么关系呢?下面我们来详细介绍一下。首先来明确一下各个模型的定义和含义

Entity:实体模型,面向业务领域的概念,具有清晰的业务边界,自身的字段和业务概念严格保持一致,不能出现含义模糊或技术性字段或概念,其包含了业务领域自身的行为。另外其自身字段值仅能通过自身的方法或者行为进行修改,不对外暴露getter、setter方法。

DO:纯数据库字段映射的类,属于纯技术上的数据库字段转换。

DTO:API对外返回字段,和前端或者RPC交互的字段封装类,传输时需要序列号,各个字段完全遵照相互之间的约定进行定义。

在了解了上面各个模型的定义之后,我们来梳理一下模型之间的关系。首先是Entity实体模型和DO之间的关系,它们两个对于初次接触DDD的同学来说,最为重要。我们从上面DDD架构与约束那一章可以了解到,业务逻辑层面向的是Entity实体,而实体的持久化是通过Repository层来实现的,所以我们一定会有Entity与DO的一层转换,而这里需要注意的是,Entity与DO不一定是一一对应的,有可能是一对多。并且Entity背后的数据存储也不一定就是Mysql一种持久化框架,也有可能分别对了好几张表,分别是Mysql、mongoDb、甚至是OSS,这个是要根据具体的存储计算方案来确定的,但是只要业务领域实体划分好,它对应的就应该是一个边界清晰的实体。举个例子,电商的主子订单模型,主订单和子订单对应了不同的表存储,而订单模型却是一个边界清晰的实体,它就是典型的1对多的实体与DO的关系模型。

而对于DTO来说,首先它跟DO是没有关系的,因为DO在定义上来说是纯数据库字段映射的类,DO只会与Entity有模型直接的关系。而DTO作为API对外返回或者和前端交互字段封装类,则完全由业务功能决定,它跟Entity有直接的关系。同样的,DTO与Entity的关系也并非一定是1比1,也存在1比N的情况,例如我们熟悉的电商业务中,订单详情页面,同时包含了订单信息和商品信息,那么订单详情的DTO就对应于订单的Entity和商品的Entity。

梳理清楚了模型之间的关系,相比于面向数据的开发模式,我们发现中间多了一层Entity,而在以前我们很可能的做法就是DO字段直接取出来对DTO来进行赋值的。所以现在我们需要两个模型转换器来对模型之间进行适配和转换,视图如下:

image.png

这里就又引出了一个问题,那就是不停地映射工作,代码量会膨胀,同时对于程序员来说,写多了也会比较烦,而且有的字段长得像的,还容易出错。这个时候就要使用一些现成的“轮子”来帮助减轻我们这部分的工作量了,比如Java有一个库叫MapStruct,在编译时会生成静态代码。

Entity与Reposotory的具体实现规范

在我们了解了上面举的实例和模型间关系之后,这里我会开始介绍具体的规范。但是要注意的一点是DDD作为一套设计思想,我们主要关注的点应该是其设计思想体现的取舍与思考,规范在具体的场景实现面前也是需要有取舍的,而不应该生搬硬套。就像我们在学习设计模式的时候,不要去生搬硬套。但是基本的规范能遵守还是要遵守的,否则设计思想无从体现了。

1、Entity的规范

这里要提到DDD中我们最常见的两个概念,实体和聚合根。在一定的上下文概念中,实体和聚合根都是拥有唯一标识的,有状态的。实体自身包含了值对象,属性,和自身的业务行为方法;而聚合根我们可以理解为一个业务范围更大的实体,除了包含实体拥有的内容以外,它自身还包含了其他实体,持有其他实体的引用。实体和聚合根的属性都不直接对外暴露访问,对外暴露的仅有其自身的业务方法,业务方法为仅对自身实体产生影响的方法。聚合根中的实体的访问,一般情况下仅能通过聚合根来进行访问而不单独访问,因为要保持整个聚合根的一致性,且内部不能强依赖其他实体。

2、Repository的实现

Repository作为持久层,主要处于Infrastructure层内,其内部主要封装数据库的最直接的逻辑。一个简单的实现主要分为几个方法find查询,save更改、新增,remove删除。find的返回值需要经过转换直接返回Entity,save一般入参也是Entity。逻辑主要是数据库的sql逻辑和Entity与DO的转换逻辑。

上面提到的聚合根的多实体下的Repository的snapShot变更diff的方式,这里就不再举代码实例了,感兴趣的同学可以内部搜一下bytetech相关的文章,主要是go语言的实现方式。

四、Domain-Service领域服务

试想是否存在着这种情况,在复杂的业务场景下,一个聚合无法完成一个完整的业务场景,而该业务场景可能需要通过多个聚合/实体通过合作的形式才能够完成。例如转账这个场景,从账户A转一定的金额到账户B中,而该业务动作不论是放在A中还是B中,都是不太合适的(感兴趣的同学可以下来自己假设推论一下),当我们需要一种无状态的服务,放在任何领域实体中都不太合适的时候,我们需要将这样的业务行为抽象出来形成领域服务。

从一个游戏场景说起

说起了游戏,我想起了最近非常火的《英雄联盟》这款游戏,无数玩家为S11的中国战队EDG获得全球冠军而骄傲喝彩,相信很多小伙伴也非常熟悉这款游戏。

这款游戏中每位玩家会操控一个英雄角色,与其他的玩家操控的英雄角色进行对战。我们是否考虑过这种场景即英雄攻击行为的场景。

一个英雄的攻击行为大致分为普通攻击类型和技能攻击类型,如果按照面向对象的思想,我们很容易想到,英雄作为一个实体对象,它自身会拥有这两种类型的攻击行为方法。但是我们知道,在英雄联盟这款游戏中英雄实体与英雄实体之间发生攻击行为后的伤害计算是一个非常复杂的计算过程。要考虑英雄当前的自身属性情况,防御值高的话受到的伤害就会减小;要考虑英雄自身携带装备的情况,例如“反伤甲”、“饮血剑”这样的装备,不仅仅会对被攻击者的生命值产生影响还会对攻击者的生命值产生影响;要考虑英雄当前是否处于一些特殊状态之下,例如“重伤”效果会导致被攻击者的治疗效果减半,或者血量低于15%的时候某些技能会触发斩杀效果,或者处于某些无敌的状态不受到任何伤害;还要考虑英雄自身的一些被动机制效果,例如“快乐风男”的暴击伤害并非是200%等等。在发现攻击的行为涉及到大量的计算逻辑之后,我们要考虑一下如果这里仍然按照面向对象OOP的思想,将这些逻辑收敛到具体的英雄实体的攻击行为方法之后,会有什么样的后果呢?首先《英雄联盟》这款游戏目前英雄的个数已经达到了148个,每个英雄如果都收敛一整套这些逻辑,虽然看起来每个英雄拥有一套逻辑非常的清晰,但是仍然会变得非常的冗杂。

我们知道《英雄联盟》这款游戏之所以能够这么多年一直保持着如此强的生命力的原因就在于它能在一个大的MOBA竞技玩法的基础上,不断快速添加新的英雄,新的道具,新的玩法机制,让玩家能够充分发挥自身的创造力,在竞技的基础上探索新的玩法路线。我作为一个游戏的老玩家,对游戏的设计理念虽然是个门外汉,但是有很多优秀的游戏充分体现了玩家的创造力带来的价值。例如《马里奥制造》、《坎巴拉太空计划》等。所以对于游戏的底层架构来说,快速的迭代添加新的设计元素,是一件非常重要的事情。

我们现在重新审视一下上面提到的英雄攻击行为的场景,如果我们此时采用数据与行为分离,将具体的攻击行为抽离出来形成一个攻击服务,而英雄实体仍然持有我们上面提到的英雄的属性、状态、机制等数据,通过将实体作为参数传入攻击服务系统中,而最后攻击产生的结果是由具体的实体数据驱动得到的。下面是一个大概可能实现的伪代码。


public class DamageService{
    //属性伤害计算规则(自动注入)
    @Autowired
    private List<AttributePolicy> attributePolicys;
    
    //装备伤害计算规则(自动注入)
    @Autowired
    private List<EquipmentPolicy> equipmentPolicys;
    
    //状态伤害计算规则(自动注入)
    @Autowired
    private List<StatusPolicy> statusPolicys;
   
    public void heroDemageCalute(Hero hero1,Hero hero2){
        for(AttributePolicy policy:attributePolicys){
            policy.calculate(hero1,hero2);
        }
        // 省略其他
        //最后计算出一个hero1和hero2各自的生命值伤害、恢复的结果,将结果返回
    }
}

我们可以看到,上面这种写法当有新的规则玩法、新的装备、新的状态加入以后,我们只需要简单的添加相应的规则,就能获得伤害计算的结果。当然这里其实我并不知道《英雄联盟》的具体代码是怎么写的,我只是站在DDD的角度去观察,这里举了个例子。

DEMO

我们再来看一个比较经典的场景,ERP系统的角色和权限模块。比如我们有一个复杂的ERP系统,操作该系统的人员分为非常多的角色,例如采购部门下分为采购经理、采购审核、采购员;财务部分下分为财务经理、财务审核、财务专员;而在各个部门之上还有一位全局的超级管理员。每个角色都会拥有一些具体的权限功能点,例如采购员角色拥有提交采购单的权限点,而采购审核拥有审核采购点的权限点,采购经理则同时拥有上述两个权限点。同样的财务专员角色拥有提交财务审批单的权限点,而财务审核拥有审核财务审批单的权限点,而财务经理则拥有上述两个权限点。全局超级管理员不仅拥有上述所有权限点,同时还能够对角色赋予权限点和取消权限点,以及添加删除角色和权限点。

上面这个场景就比较有意思了,我们可以看到业务上的几个概念:人员、角色、权限点,一个人员可以同时拥有多个角色,例如他自己可能是超级管理员,也可能是采购经理。一个角色的权限点也并非固定的。而我们最终权限校验的原子校验点也是对权限点的校验。那么问题来了,人员申请角色这个业务行为和权限校验这个业务行为,要怎么实现比较好呢?

下面给出两个从DDD视角比较建议的伪代码

//人员申请角色
public class EmployeeEntity{
    //此处省略各种属性
    
    public void applyRole(Role,ApplyRoleService){
        //省略一些校验逻辑
        ApplyRoleService.applyRole(this,Role);
    }
}

这个是人员申请角色的代码,我们可以看到行为方法是人员的实体方法,具体的依赖服务作为入参传入该实体方法,进行反转调用。因为我们变更的对象是EmployeeEntity人员实体的Role角色参数,可能会从外部读取到一些数据(角色的赋予往往也需要一条流水线式的申请)。

对于权限校验的业务行为,建议的伪代码如下

public class AuthorizationService{
    
    private AuthorizaPointRepository repo;
    
    //对人员实例是否有具体的业务动作进行权限校验
    public Result authorize(Operator,Action){
        //获取当前操作者拥有的角色/权限点集合
        Collection collection = repo.findAllPoint(operator);
        //检查当前的权限点集合是否有足够的权限执行Action
        action.check(collection);
    }
}

上述代码的逻辑,可能会随着权限点的不断复杂而增加,而我们要重点维护的就是权限点的Repo新增修改删除即可。

领域服务的具体规范

通过上面的例子,我们可以发现,领域服务的使用其实是一种数据与行为的分离,这也就导致了如果我们滥用领域服务,那么导致的结果就是我们又从DDD重新回归到了面向数据的开发方式。所以下面是一些实用领域服务的规范建议:

1、仅影响自身属性的实体行为,牵扯到了外部资源和服务的使用,可以考虑使用实体行为入参传入领域服务的方式,在实体行为方法中反转调用。

2、当有行为同时影响两个或多个实体,可以考虑使用领域服务。尤其是该方法放在哪个实体上都不合适的时候且该方法逻辑复杂,使用的实体众多。

3、实体内不能强依赖其他实体,或者外部服务资源。

最后多说一点,我们需要区分的一个概念是领域服务Domain-Service与应用服务Application-Service两者是完全不同的概念。应用服务主要是面向业务逻辑的流程编排,流程编排不一定是同步顺序的,还有可能是异步事件驱动的。还记得我们第二章里面DEMO重构之后得到的代码,那个就是应用服务层,纯业务逻辑,理论上最多只能有一层if else语句,所有具体的实现都在Repository层和ACL防腐层中。

五、DDD的战略设计对研发流程的启示

我们上面介绍的全部是DDD在战术设计也就是代码层面的规范,理论上只需要多加实践与练习,掌握起来并不是很难。而DDD真正难的地方,在于其战略设计,团队成员技术与领域业务专家如何合作,如何牢牢抓住领域核心,如何建立起合理的领域上下文映射,这些东西体现在我们团队的成员对于业务、组织的发展理解上。

这一章我会举一些实际工作中遇到的例子,从一个开发人员的视角,去探讨DDD的战略设计的一些理念对于我们在日常工作中研发流程与团队协作带来的一些思考。

交流失效的例子

我们在工作中是否会遇到这样的场景?

场景1:

在一个产品需求提出了以后,经过技术同学辛苦的开发,最后验收的时候,

产品同学说“这个不是我想要的”。

  • 技术同学说“这个点在前几天不是进行了需求改动吗”。
    •    产品同学说“这个不是需求的改动,目前市面上主流的产品逻辑都这样,我以为你们都了解的”
    •    技术同学说“我们后来不是又评审了一次吗?当时大家都达成共识才修改的吗?”
    •    ....

后面就扯不清了。

场景2:

在一次技术升级迭代中,技术同学辛苦开发,最后却发现上下游流程没有对齐

技术同学A说“为什么下游系统没有针对这次需求做改造开发呢?”

技术同学B说“这个涉及到的改动,我们发现上游其实是可以自己处理完成的,基于改动最小化原则,你们可以自己处理”

技术同学A说“上游处理是有一定问题的,不利于业务和系统的长远发展。”

...

后面就开始无休止的争论了。

场景3:

  • 在产品功能讨论阶段,技术同学和产品同学在讨论一个需求
    •    产品同学“我希望得到一个优惠券活动定时提报的功能”

    •    技术同学“没问题,我来搞定”

    •    经过了几天的开发,系统上线

    •    产品同学“咦,为啥我活动提报前要触发了活动的短暂的暂停?”

    •    技术同学“提报系统的底层能力为了保障操作原子性,导致定时提报暂时不支持直接提报新的活动”

    •    产品同学内心OS:什么鬼......

上面三个场景,分别代表了 知识信息不同、思维方式不同、沟通语言不同 三种不同而导致的沟通失效。不可否认的是当前一个大的项目产品的成功交付离不开多人的沟通协作,每个人都有不同的背景,经历了不同的事情,有着不一样的知识。这似乎就说明了为什么我们在整个产品研发周期中,从MRDPRD到技术评审到测试最后到上线验收,为什么我们要不停的拉会进行一轮又一轮的反复对焦。那么在DDD中有什么好的方法能够让每位成员统一语言从业务视角出发去进行讨论呢?这里推荐一种战略设计的方法《事件风暴》法。

事件风暴的具体形式是通过重点的领域专家和业务方,以及部分重点干系人含架构师或者研发leader在一起通过彩色卡片,一步一步将整个业务全景描绘出来。其中技术人员在这个过程中尤其需要注意,DDD的重点在于解决业务的复杂度问题,所以技术人员要摒弃一些技术洁癖的思想,并且专注于业务的复杂性而非技术的复杂性。

一次事件风暴的大致流程,首先我们要用橙色卡片梳理事件,事件的定义是已发送的切业务需要关注的重点事件,梳理的时候要注意Unhappy Path同时也需要梳理。通过事件的梳理,我们便有了大致业务流程的框架。接下来要用粉色卡片梳理业务规则,业务规则主要是事件发生的前置条件以及事件发生以后会产生的反应,例如订单创建以后会发消息通知买家。当业务规则找的差不多了,就可以填充上涉及到的人群(黄色卡片)和执行动作(蓝色卡片)以及决策辅助(绿色卡片)。即谁依据什么执行了什么动作触发了事件。最后我们需要使用红色卡片记录我们在讨论过程中的一些模糊的点,业务痛点,需要进一步讨论的点。以上流程结束以后,我们可以邀请不同的人来担任志愿者,尝试对整个流程进行用户故事描述,在整个过程中,每个人需要注意不一致的地方,提出问题,一起讨论。这里我不再举具体的事件风暴的例子,感兴趣的同学可以看最后的事件风暴的一个视频。

这里我贴一个之前梳理容灾演练相关的流程,使用事件风暴得到的一张图

image.png 个人感觉图中的信息是比较全的,包括了人、动作、决策依据、业务逻辑和事件,能够从全局视角看到整个用户故事和业务痛点。但是有一点不太好的就是对于If else这种分支逻辑,事件风暴图展示不太友好。

过度设计的例子

我们在工作中是否遇到过这样的场景

假如我们团队要开发一款Scrum敏捷开发管理系统。因此核心概念是产品,代表了将要被开发的软件,并且在未来一段时间内会被持续的改进。产品由待办项、发布、冲刺组成。每个待办项都包含一些任务,每个任务都拥有一个估算记录条目集合。发布中包含了计划好的待办项,冲刺中包含了已提交的待办项。

团队成员说,我们需要产品用户,我们希望促进产品团队内协作讨论。租户来表示订购了产品的组织,在每个租户中,我们允许任意数量用户注册,同时他们拥有一些权限。

又有团队成员说,说起协作工具,讨论应该属于论坛,而且还应该包括发帖。

又有人员补充到,我们还需要让租户完成支付,我们还需要支持销售计划套餐,还需要一种跟踪支持事件,这些都应该在账户下进行管理。

随后涌出越来越多的概念,比如每个SCRUM运作产品都有一个特定的团队,团队由一位产品负责人和一些团队成员组成,为了解决团队成员的人力资源利用率,我们还要建立日程,利用率,可用性的模型....

我们发现随着讨论越来越深入,概念越来越多,讨论朝着越来越失控的方向发展了,而我们的系统从一开始似乎就朝着大泥球的方向去了。

image.png

面对已然失控并无限扩展的业务模型而言,我们要对它提出质疑并将其统一。一个非常简单的质疑便是,每个大模型的概念是否都符合Scrum通用语言的要求?例如租户、权限都与SCRUM无关,这些概念都应该剥离。

为什么需要剥离概念获取核心?因为核心是最小完备实现自治的基本条件,它拥有稳定的空间并独立进化,是划分领域的基础。

image.png

经过领域专家和参加讨论的人员反复推敲撕扯,最终我们留下了一个小巧却实际的多的核心域。当然核心域会持续扩展。至少目前看来,我们在划分核心业务领域迈出了坚实的一步,为后续做各个领域的上下文映射打下了坚实的基础。

小结:

这里我们其实可以发现,不论是事件风暴法还是在质疑中统一核心划分领域,对于整个团队人员尤其是业务领域专家和架构师的素质要求都是非常高的。在整个DDD的研发生命周期中,前期的战略设计也是会花费大量时间的。这也体现出了DDD的核心思想:围绕业务概念来构建业务模型,控制业务的复杂性,解决软件难以理解和演进的问题

六、最后总结

总结一下本篇文章的思路,我们先从DDD最基础的OOP面向对象入手,然后介绍了DDD的四层经典架构,接着阐述了DDD里面核心的领域实体和Repositoy还有领域服务这样的战术设计规范和方法,最后结合实际工作中遇到的场景来引出DDD战略设计的一些方法和思路。受制于笔者实践DDD的经验有限,虽然还有很多相关的内容本篇文章没有介绍,但是仍然想在这里抛出一个问题:什么时候?什么项目使用DDD比较合适?我相信在认真读了本篇文章并有自己一定实践经验的同学,能够给出自己的答案。对于我自己目前的情况来说,如果没有产品规划,需要快速交付的,功能简单且相互之间的数据模型复杂度不高的项目,完全没有必要使用DDD。

image.png

从上图我们可以看出来,面向数据的编程在复杂度没有达到一定程度的时候,迭代速度是远高于DDD的。而且DDD在战略设计上的难度决定了,如果没有丰富领域专家经验的人做出合理的设计,那么很可能会加速代码腐化的速度。DDD作为一种编程思想,我们不一定全篇照搬,如果能结合着自身的实际项目在某个时刻对我们的选择产生了指导意义,那么就足够了。

说回写这篇文章的目的,就像开头前言那样,目前一些DDD公开的资料大部分还是以介绍概念为主,而我作为一位DDD的学习者,更希望从实践的一些例子视角去了解DDD,并且抛出DDD的理念带给我们日常工作中的一些思考,同时也能够让更多对DDD和慢腐架构感兴趣的同学能够快速的了解它的思想,而不被那些抽象而复杂的概念所困扰。由于笔者水平有限,本篇中不免会有不够严谨、不太恰当、甚至错误的例子,如有误导还请海涵,另外如果有高人,也请不吝赐教,不胜感激。

附录:参考资料

1、阿里云开发者社区 《殷浩详解DDD系列》developer.aliyun.com/article/713…

2、阿里云开发者社区 《事件风暴在阿里的落地实践》developer.aliyun.com/live/2877

3、bytech社区 《DDD在Golang》中的落地 tech.bytedance.net/articles/71…

4、lark王世明个人空间 《领域驱动设计DDD》领域驱动设计(DDD)

5、去哪儿技术学院 《领域驱动设计》相关资料

6、《领域驱动设计精粹》美 Vaughn Vernon著 覃宇 译