领域驱动设计(DDD)实践探索

复杂业务系统经过长期迭代,难免逐渐腐化,如何治理腐化,并设计出能够延缓腐化,保持长期搞笑的方案是开发人员难免要遇到的问题,本文旨在分析系统腐化的原因以及DDD的

复杂业务系统经过长期迭代,难免逐渐腐化,如何治理腐化,并设计出能够延缓腐化,保持长期搞笑的方案是开发人员难免要遇到的问题,本文旨在分析系统腐化的原因以及DDD的一些落地实践

概述

系统经过长时间的迭代,人员的更替,如果没有能够长期维持的规范以及行之有效的架构设计,业务逻辑的演进会愈加复杂,需求交付效率愈发不可控。

总结之前遇到的一些问题,主要是以下几点:

业务复杂,边界不清

任何业务系统,都是经过不断迭代出来的,系统初期的野蛮生长,快速扩张,导致很多模块边界不清,功能耦合度较高。

业务上经过不断的迭代,一直在做加法,导致功能臃肿不堪,其中相当一部分不再使用的逻辑,不再存在的场景、过于复杂的流转过程等等。

架构混乱,功能分散

整个系统没有统一的业务架构设计,业务上的分层也是聊胜于无,各种相互依赖关系,逻辑功能散落在各个类中,职责不清晰的类设计,如果认真去看,可以找到各种坏味道的代码,比如循环依赖、分散的功能点,过长的方法和类,分不清层次的Service,模糊的模块边界等等。

image-20230502234008526

需求交付率不可控

伴随着上述问题的形成,就是越来越不可控的需求交付,需求评估工时越来越不准确,需求的交付效率和交付质量都面临很大风险。

补充一点,虽然有些系统代码比较乱,但是系统相对来说还是比较稳定,技术评审、code review以及测试验收等手段还是能够保证需求的正常交付。

系统设计方案

腐化原因分析

  1. 系统开发前期,边界划分不是太清晰,模块之间依赖比较随意,随着版本的迭代,如果进行重构影响面比较广,收益小,导致重构的动力不足。
  2. 缺少行之有效的业务架构以及规范,模块之间如何依赖,行为职责如何拆分,通用的功能如何下沉,代码如何分包等,任由开发人员自行发挥。
  3. 面向过程的开发方式,加重了功能分散和代码耦合的问题

设计目标

基于现状分析,业务方案的整体诉求可以归纳为:业务边界清晰,架构合理,功能内聚,长期迭代能更好的避免腐化

上面的诉求如果细化的代码层面,会有更详细的要求

  1. 核心模块之间,核心系统之间的依赖关系需要有防腐,不能直接依赖细节
  2. 合理的分包层次,一般建议先按照领域功能分包,再按照技术细节分包
  3. 需要遵循面向对象的设计原则SOLID (单一职责原则,开闭原则原则,里氏替换原则,接口隔离原则,依赖倒置原则)
  4. 需要对常用的设计模式对业务功能的处理进行抽象,提供公共的能力供业务代码使用,避免业务代码面向过程的迭代

开发方案

库表为核心的开发

以数据库表为核心的开发是大多数程序员最熟悉的开发方式,逻辑层进行合理的模块划分和分层设计,也能够实现我们对设计目标的诉求,但是以库表为核心的开发方式最大的问题是面向过程的开发,容易导致功能点分散到各个Service中,而且模块和模块之间容易产生依赖,业务边界不容易维持,稍不注意,代码容易迅速的腐化。

领域驱动设计开发

领域驱动设计,以领域模型为核心,解耦业务逻辑与数据库表结构的关系,领域驱动设计和以库表结构为核心的开发过程有很大的不同

  1. 系统的参与角色,产品、开发、测试等人员需要形成一套通用语言
  2. 方案设计不再把DB设计放在核心问题去解决,更加专注业务模型本身,进行领域、业务聚合的设计,领域层的聚合以及实体才是整个系统的核心
  3. 真正的面向对象编程,由过去的过程式的事务脚本方式,转变成真正的面向对象编程

方案比较

image-20230502235110832

第一种方案优点很明确,将架构划分清楚,采用一些优秀的设计模式,重写一遍代码,确实能够获得不少的收益,但是缺点也很明显,面向过程的编程方式,功能逻辑分散,随着需求的不断迭代,代码迅速的腐化,回到原来的状态。

相比较而言,第二种方式前期开发成本比较高,但优势也很明确,业务导向,领域模型优先,边界范围容易维持,核心业务逻辑内聚在领域中,低耦合高内聚,易于长期维护。

基于对于方案的整体诉求,以及长期迭代过程中遇到的一些问题,选择领域驱动设计才能满足我们的目的。

落地实践

第一步建模:基于业务场景,产品、开发、测试等协作,充分沟通,达成共识,形成通用语言,构建领域模型。对于重构的项目,用例法更为合适,将主要的业务逻辑作为用力输入,聚焦事件->命令->提取实体,最终划分界限上下文。

第二步实施:通过领域模型指导架构设计和代码实现

QQ图片20230615164443 系统架构整体上参考COLA应用架构(如上图)进行设计,系统架构如下图所示,基本上是一个洋葱架构的底子,加上CQRS,并基于具体实践演化出的一个结构,与洋葱架构的一个显著区别在于:应用层依赖了基础设施层,而不是基础设施层依赖应用层,这样可以带来编码上的很多便利;另一个小的区别是,在此之外增加了一个共享依赖包,放置一些工具类、枚举值、异常类、领域层的入参DTO等。 image-20230503001427791

领域层:处于系统最底层,包含业务聚合、实体、领域能力等;领域层需要的持久化,以及其他能力需要在领域层定义gateWay接口,在基础设施层或者应用层进行实现。

基础设施层:依赖领域层,承接数据持久化等基础服务,实现了领域层定义的持久化gateWay接口,进行DO - PO数据转换,封装持久化细节等。

应用层:依赖领域层和基础设施层,对接外围接口层适配器,提供查询和命令能力,并采用责任链模式进行逻辑编排。

接口层:依赖应用层,提供外部服务的访问入口,包括但不限于HTTP、RPC等。

共享包:工具类、异常类、服务中的DTO对象等,共享包的存在主要是为了解决领域层和应用层共享数据结构的问题。

以一次业务操作命令执行为例,各组件在执行流程中的作用如下:

image-20230612151448841

遇到的问题

能力下沉

在进行领域建模的过程中,我们需要设计出领域实体,领域服务等,但是有个问题一直有困惑,就是哪些能力应该放在Domain层,是按照传统的做法,将所有的业务全部收拢到Domain层,这样做是不是合理?

在实际业务中,有很多功能都是用例特有的,如果全部将所有的用例全部收拢到Domain层,不见得能带来多大的益处,相反这种收拢会导致Domain层过度膨胀,不够纯粹,影响复用性和业务表达能力。

鉴于此,最近的思考是采用小步快走,持续迭代的能力下沉的策略,也就是说我们不强求一次性设计出Domain的能i,也无需将所有的业务功能全部都放到Domain层,而是采用实用主义的态度,只对需要在多个场景中被复用的能力进行抽象下沉,而对于暂时不需要复用的,就暂时放在应用层中。

这种循序渐进的能力下沉策略,应该是一种更符合实际、更敏捷的方法。因为我们承认模型不是一次性设计出来的,而是迭代演化出来的。

下沉的过程如下图所示,假设两个use case中,我们发现use case1的step3和use case2的step1有类似的功能,我们就可以考虑让其下沉到Domain层,从而增加代码的复用性。

https://cdnss.haodaima.top/uploadfile/2024/0306/20240306095623127.png

指导能力下沉有两个关键指标:代码的复用性和内聚性。

复用性是告诉我们When(什么时候该下沉了),即有重复代码的时候。内聚性是告诉我们How(要下沉到哪里),功能有没有内聚到恰当的实体上,有没有放到合适的层次上(因为Domain层的能力也是有两个层次的,一个是Domain Service这是相对比较粗的粒度,另一个是Domain的Model这个是最细粒度的复用)。

号称很薄的应用层,很难薄下来

领域驱动设计的开发方式,让我们将通用的能力下沉到领域层,而应用层负责进行业务逻辑的编排,但是大多数情况下我们的业务并不是直接更新领域聚合那么简单,他还需要做领域能力的聚合操作。在步骤比较少的情况下,应用层直下也没有什么问题,但当应用层需要编排更多的步骤时,这样的代码就会愈来愈臃肿。

image-20230613173937997

逻辑编排基本上还是事务脚本式的过程式编码,这意味着应用层很容易回到原来的状态,逻辑会愈来愈分散,代码越来越臃肿,为此引入责任链的设计模式,来对这些逻辑编排进行结构上的优化

image-20230612155921578

使用责任链模式处理逻辑校验、组装领域对象数据等操作,并将需要的业务对象放到上下文对象中,责任链执行完成后,使用面向插件的编程模型,去执行相应的业务数据,可以实现动态的增加或者减少业务操作,做到开闭原则的实现。

查询操作如何才能简化实现?

查询相对于命令来说简单一些,但是也正是由于应用中的各种查询操作,会严重影响我们的领域对象的业务语义。

洋葱架构的依赖关系图如下所示image-20230612161413655

如果遵从这种依赖,就意味着应用层无法感知到基础设施层的PO,如果需要使用PO数据,那就需要在应用层定义接口,然后基础设施层进行实现是,这就带来一个数据转换的动作放在哪里的问题。

一个方法是在应用层定义一个类似PO的数据结构app-po,在基础设施层将PO转换为应用层的app-po,然后在应用层使用这个app-po,很明显这样的操作臃肿繁杂。另一个方法是将dto的数据封装到基础设施层,那么就更不合理了,基础设施层代理了应用层需要处理的事情。

如果我们打破上面的这种依赖关系,让应用层直接依赖基础设施层,也就是允许应用层直接使用PO,那么应用层操作基础设施层将会变得很简单,但是这样的操作违背了领域驱动的一些理念,数据库如果更换,或者数据库表结构发生大的变更,会牵连到本应该相对独立的用用层,但是对于大多数业务来说,这基本是不可能发生的事情,即使发生,对于我们的应用系统来说也可以接受,既然如此,何必为了留下灵活性而去留下灵活性呢?

数据如何持久化?

在领域聚合设计中,我们将持久化方法与聚合能力区分开来,不再考虑数据持久化的问题,这就带来第一个问题,在业务逻辑处理完,需要保存的数据如何持久化呢?或者如何知道哪些数据是需要持久化的呢?

如果我们在进行领域层代码编写的时候,仍然还是需要考量什么时候存储数据,以什么样的形式进行存储,那么我们领域层的代码很容易又陷入原本过程式编程的方式中去,失去了领域实体对业务含义的表现能力,在开发过程中,我们势必要有某种方法实现实体的持久化,又不影响实体对业务的表达。

领域驱动下的数据持久化有不少的探索思路,这里提供两种相对来说比较简单的解决方案

为领域实体添加标签,根据标签持久化数据

在进行相应的业务操作后,更改实体的状态标签(model: read/update/insert),在最终持久化的过程中,遍历上下文中所有的领域实体,根据标签进行相应的处理

使用这种方法,会带来一个问题,那就是在持久化的过程中,我们需要判断每一个实体的状态,根据状态去做不同的持久化操作,insert or update ?

根据三次原则的理论,遇到这种问题,应该就要想办法解决他,而不是在每个业务操作持久化过程中对每一个实体进行判断然后进行持久化。

这里引入模板方法,使用PoWrapper对实体和状态标签进行包装,PoWrapper同时定义了一套执行模板,执行模板里包含 insert以及update接口,对于每一个实体来说,要做的事情就很明确了,首先进行do -> PoWrapper的转换(包含状态标签),然后PoWrapper根据wrapper的状态标签,以及具体的insert/update的实现方法,进行po的更新或插入,这个持久化模板的最大好处是:代码简洁,同时隐藏了dao的实现细节。

public class PoWrapper<T> {

    private T po;

    private int model;

    public PoWrapper(T po, int model) {
        this.po = po;
        this.model = model;
    }

    public static <T> void save(PoWrapper<T> poWrapper, BaseMapper<T> baseMapper){

        if(poWrapper != null && poWrapper.po != null){

            switch (poWrapper.model){

                case INSERT_MODEL:
                    baseMapper.insert(poWrapper.po);
                    break;
                case UPDATE_MODEL:
                    baseMapper.updateById(poWrapper.po);
                    break;
                default:
                    break;
            }
        }
    }

}
领域事件进行持久化更新

对领域实体的操作不能使用普通的setter方法进行赋值,而应该将操作抽象成业务方法,并将业务方法下沉收敛到领域实体中。对于领域对象的持久化的里一个思路是可以使用Spring事件消息的方式来进行。

/**
 * 取消订单
 */
public void cancel(){
  if(!Objects.equals(OrderState.WAIT_PAY,getOrderState())){
      throw new BusinessException(OrderErrorCode.ORDER_NOT_WAIT_PAY);
  }
  setOrderState(OrderState.ABANDON);

  //发布领域事件
  registerEvent(new OrderEvents.OrderCancelEvent(this));
}
@EventListener
public void handleOrderCancelForDb(OrderCancelEvent orderCancelEvent){
   // 订单取消持久化

}

代码量大大增加,如何降低开发者工作量?

使用领域驱动设计的开发方式,特别是如果想要达到层与层之间防腐解耦的效果,那么各种实体的转换,设计模式的使用必不可少,与之而来的就是代码量的提升,简单来说,实现一个功能,类的创建就需要经过一番功夫。

如何减少开发人员的工作量,提升开发效率,是业务方案架构设计时尤其要考量的因素,如何减少开发工作量,目前从两个层面入手。

  1. 通用模型抽象,提供通用的能力
  2. 基于注解的代码生成器

总结

领域驱动设计,在业务逻辑上,趋向于准确表现业务模型;在技术架构上,有比较稳固的结构关系,高度内聚的聚合边界,比较好的避免了逻辑分散,架构混乱的问题,在边界治理的问题上,也是一道天然的屏障。

但是,领域驱动设计也不是万能灵药,它有比较大的学习成本,开发成本。在实践过程中,也未必能完全遵守领域驱动设计的一些规范,需要根据实际情况进行一些权衡,此外,对于一些比较简单的业务场景,领域驱动设计并不能带来比较大的收益,相反由于代码结构的巨大变化,反而会有一些副作用。