• DDD 学习笔记



    因为公司的包结构参考借鉴了 DDD 的思想,被迫无奈学习了一下 DDD 相关知识

    每一种架构都是为了解决实际工程中的问题,就像设计模式看起来什么用都没有,但是其实是解决了现实工程中遇到的各种问题,主要是为了降低代码的维护与修改的代价,而这个 DDD 个人认为也是这个作用

    Domain Primitive

    这是 DDD 最与众不同的地方

    对实体类的属性进行显示化,我们一般对有限制的数据类型以及复杂的数据类型使用 DP,但是使用 DP 会增加代码膨胀,除去 PO、DTO 转化为 DP 的成本,在使用到属性的时候还需要进行转换

    为什么会出现 DP

    数据验证和错误处理:每个入参都需要方法校验,就算前端已经校验过了,后端为了程序健壮性以及规范,还是要校验一次。虽然现在可以用注解来简化校验过程,但是还有一些需要业务校验的情况在代码中经常出现,在每个方法里这段校验逻辑还是会被重复

    在需要新增校验规则与维护原来的校验规则时,会比较麻烦,有没有一种方法,能够一劳永逸的解决所有校验的问题以及降低后续的维护成本和异常处理成本呢?

    大量的工具类:问题时从一些入参里抽取一部分数据,然后调用一个外部依赖获取更多的数据,然后通常从新的数据中再抽取部分数据用作其他的作用。这种代码通常被称作“胶水代码”,其本质是由于外部依赖的服务的入参并不符合我们原始的入参导致的。为了解决这个问题,一个常见的办法是将这段代码抽离出来,变成独立的一个或多个方法

    可测试性:假如一个方法有 N 个参数,每个参数有 M 个校验逻辑,至少要有 N * M 个 TC,要如何降低测试成本呢?

    DP 使用以及好处

    将隐性的概念显性化:比如下面例子

    public class TestEntity {
    	private String username;
    	private String email;
    }
    
    • 1
    • 2
    • 3
    • 4

    原来 username 仅仅是TestEntityPo 的一个参数,属于隐形概念,如果此时 username 参与了真正的业务逻辑,为了减少维护成本我们需要将username的概念显性化

    public class UserName {
    	@NotNull
    	String name;
    	public String getName() {
            return name;
        }
        public UserName (String name) {
            if (name == null) {
                throw new ValidationException("number不能为空");
            }
            this.name = name;
        }
    	public isEnglish() {
    	// 业务校验逻辑
    	...
    	}
    	public isUser() {
    	// 业务校验逻辑
    	...
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    我们将之前的 username 写成一个类,此时:

    • 校验逻辑都放在了 UserName 里面,确保只要该类被创建出来后,一定是校验通过的,数据验证和错误处理都在类中处理
    • 只对该属性操作的方法变成了 UserName 类里的方法
    • 刨除了数据验证代码、胶水代码,在业务层剩下的都是核心业务逻辑
    • 对 entity 中会封装多对象行为

    像这种类被称作 TYPE,可以在 entity 中组合 type,它们一起被称作 DP,一般在业务层使用 DP

    public class TestEntity {
    	private UserName username;
    	private Email email;
    }
    
    • 1
    • 2
    • 3
    • 4

    为什么要六边形架构

    如果忽略应用内部的架构设计,很容易导致代码逻辑混乱,很难维护,容易产生bug而且很难发现

    一个应用最大的成本一般都不是来自于开发阶段,而是应用整个生命周期的总维护成本,所以代码的可维护性代表了最终成本,强依赖其他三方组件与基层数据库的脚本式代码通常可维护性能差,它可能出现以下几个问题

    • 数据结构的不稳定性:数据库的表结构和设计是应用的外部依赖,都有可能会改变,如果改了POJO要改流程也要改
    • 第三方服务依赖的不确定性:第三方服务,比如Yahoo的汇率服务未来很有可能会有变化:轻则API签名变化,重则服务不可用需要寻找其他可替代的服务。在这些情况下改造和迁移成本都是巨大的。同时,外部依赖的兜底、限流、熔断等方案都需要随之改变。
    • 中间件或者数据库更换:今天我们用Kafka发消息,明天如果要上阿里云用RocketMQ该怎么办?后天如果消息的序列化方式从String改为Binary该怎么办?

    事务脚本式代码的第二大缺陷是:虽然写单个用例的代码非常高效简单,但是当用例多起来时,其扩展性会变得越来越差。

    可扩展性减少做新需求或改逻辑时,需要新增/修改多少代码

    • 数据来源被固定、数据格式不兼容:原有的AccountDO是从本地获取的,而跨行转账的数据可能需要从一个第三方服务获取,而服务之间数据格式不太可能是兼容的,导致从数据校验、数据读写、到异常处理、金额计算等逻辑都要重写
    • 业务逻辑无法复用:数据格式不兼容的问题会导致核心业务逻辑无法复用。每个用例都是特殊逻辑的后果是最终会造成大量的if-else语句,而这种分支多的逻辑会让分析代码非常困难,容易错过边界情况,造成bug
    • 逻辑和数据存储的相互依赖:当业务逻辑增加变得越来越复杂时,新加入的逻辑很有可能需要对数据库schema或消息格式做变更。而变更了数据格式后会导致原有的其他逻辑需要一起跟着动。在最极端的场景下,一个新功能的增加会导致所有原有功能的重构,成本巨大

    设计模式六大原则给了我们不错的解决思路,依赖与抽象而不依赖与具体。调用每一个三方时都使用接口或者加防腐层,调用每一个底层组件时都使用抽象,同时按逻辑分离代码操作,使代码复用性增加

    六边形架构

    原来的 MVC 架构非常容易理解,从 Controller 层接受前端数据,Service 层做处理,而数据层则对应数据库。一般来说 Controller 依赖 Service 层,而 Service 层依赖的东西过多,有可能是第三方组件与接口,比如消息队列、Dubbo 调用等;又有可能是数据层的东西,我们的惯性思维就是在 Service 层从数据库中取出数据的,此时数据库可能是 MySQL、PG、redis 等等

    无论如何,这些三方与数据库都是有可能变动的,其他公司的烂代码可能会腐蚀我们自己写的代码,而数据库的表也可能会增减字段,此时牵一发而动全身。说了这么多,DDD 到底是如何解决这些问题的呢?

    DDD 最直观的体现就是模块名跟 MVC 不一样,领域驱动设计的四层结构为:

    • 表现层(Presentation)
    • 应用层(Application)
    • 领域层(Domain)
    • 基础设施层(Infrastructure)

    设计人员可以根据实际问题填充不同的模块到这四层中,填充的原则如下:

    Presentation(Web、Interfaces)模块

    Web 模块包含 Controller 等相关代码,同时在该模块中可以加入 VO,Param 等,该层即为协议层

    我们单独会抽取出来 Interface 接口层,作为所有对外的门户,将网络协议和业务逻辑解耦,该层需要做以下这些事情:

    • 网络协议的转化:通常这个已经由各种框架给封装掉了,我们需要构建的类要么是被注解的 bean,要么是继承了某个接口的 bean
    • 统一鉴权:比如在一些需要 AppKey+Secret 的场景,需要针对某个租户做鉴权的,包括一些加密串的校验
    • Session 管理:一般在面向用户的接口或者有登陆态的,通过 Session 或者 RPC 上下文可以拿到当前调用的用户,以便传递给下游服务。
    • 限流配置:对接口做限流避免大流量打到下游服务
    • 前置缓存:针对变更不是很频繁的只读场景,可以前置结果缓存到接口层
    • 异常处理:通常在接口层要避免将异常直接暴露给调用端,所以需要在接口层做统一的异常捕获,转化为调用端可以理解的数据格式
    • 日志:在接口层打调用日志,用来做统计和 debug 等。一般微服务框架可能都直接包含了这些功能

    这些大多都不都是用拦截器做的吗。而且,虽然说 App 层是组合下层的模块,但是一些非常简单的需求只需要查询一次数据库即可,我们也允许在 Web 层直接调用 Mapper

    Application 模块

    主要包含 Application Service,该模块依赖 Domain 模块与基础设施层。Application 层主要职责为组装 domain 层各个组件,完成具体的业务服务。Application 层可以理解为粘合各个组件的胶水,使得零散的组件组合在一起提供完整的业务服务

    Application Service 是业务流程的封装,不处理业务逻辑

    并且,ApplicationService 应该永远返回 DTO 而不是 Entity

    举个例子,查询数据库的方法调用应该在该层做,而数据库接口的定义应该在 Domain 层,基础层则是实现查询的地方

    该层的出参应该是标准的 DTO,并且不应该做任何逻辑处理,而入参则是 CQE 对象,一般入参的校验应该在这一层,这样就保证了非业务代码不会混杂在业务代码之间

    注意此处的校验与上层 web 层的校验不一样,web 层主要用来校验不需要访问 dao 层即可校验的数据,比如权限,入参是否为 null 等,而 app 层的校验则是需要访问数据库来获取数据的情况。但是还有一个问题,就是在 app 层校验时,校验结果如何返回给用户,此处推荐增加一个 OperateResult 来返回操作结果,举个例子:

    OperateResult:

    /**
     * description 操作结果,主要用于单纯操作,记录操作日志
     */
    @Data
    public class OperateResult implements Serializable {
    
        private boolean ret;
    
        private String msg;
    
        public static OperateResult success() {
            OperateResult operateResult = new OperateResult();
            operateResult.setRet(true);
            return operateResult;
        }
    
        public static OperateResult error(String msg) {
            OperateResult operateResult = new OperateResult();
            operateResult.setRet(false);
            operateResult.setMsg(msg);
            return operateResult;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    web 层代码:

        @PostMapping("/transfer.json")
        public JsonResult<String> transfer(String clueId) {
            OperateResult opt = salesClueService.transfer(clueId);
            if (opt.isRet()) {
                return JsonResult.success("成功");
            }
    
            return JsonResult.error(opt.getMsg());
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    app 层/service 层:

        @Override
        public OperateResult transfer(String clueId) {
            SalesClue salesClue = salesClueRouteService.selectById(clueId);
            if (salesClue == null) {
                return OperateResult.error("未查询到线索");
            }
            return OperateResult.success();
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    Application层的几个核心类:

    • ApplicationService应用服务:最核心的类,负责业务流程的编排,但本身不负责任何业务逻辑
    • DTO Assembler:负责将内部领域模型转化为可对外的 DTO
    • Command、Query、Event 对象:作为 ApplicationService 的入参
    • 返回的 DTO:作为 ApplicationService 的出参

    在这里插入图片描述
    判断是否业务流程的几个点:

    • 不要有任何计算,基于对象的计算逻辑应该封装到实体里
    • for 循环一般为业务判断
    • 允许有 if 判断中断条件,一般如果条件不满足抛异常或者返回

    Domain 模块

    业务核心模块,包含有状态的 Entity、领域服务 Domain Service、Types、以及各种外部依赖的接口类,注意,只是接口类

    有状态的 Entity 指对应原来 MVC 中的 DO,只不过加入了对 DO 中属性的一些操作;Types 包的作用就是前文说的作用;Domain Service 则是核心的复合操作

    外部依赖的接口类包括 Repository、ACL、中间件等,我们把这些打包成接口,使用 LSP、策略模式等做防腐,增加扩展性等等。这些接口对应到 MVC 中,就是 Service 层的接口

    领域服务不依赖任何其他功能,只做最纯粹的算法操作,这里面不应该有任何依赖注入的情况(当然因为这个模块不依赖任何模块,也无法注入其他东西)

    Infrastructure 模块

    包含数据库 DAO 的实现,包含 PO、ORM Mapper、Entity 到 PO 的转化类等;包含 Service,不过没有业务代码,都是一些类似日志什么的 Service 操作;包含要具体依赖的 ORM 类库配置,比如 MyBatis

    对于三方或者数据库的具体实现可以使用转换器模式

    // 代码在Infrastructure层
    @Repository // Spring的注解
    public class OrderRepositoryImpl implements OrderRepository {
        private final OrderDAO dao; // 具体的DAO接口
        private final OrderDataConverter converter; // 转化器
    
        public OrderRepositoryImpl(OrderDAO dao) {
            this.dao = dao;
            this.converter = OrderDataConverter.INSTANCE;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    在这里插入图片描述
    可以看到,该模块主要负责提供数据的来源以及储存数据,但是一些配置信息也需要放在这里

    如果觉得 PO 太过麻烦,可以直接忽略它,将 DO 作为该层的传入传出以及层次内部的传输对象,DDD 是一种设计理念,没有规定具体需要如何实现

    综上所述,考虑到最终的依赖关系,我们在写代码的时候可能先写 Domain 层的业务逻辑,然后再写 Application 层的组件编排,最后才写每个外部依赖的具体实现。这种架构思路和代码组织结构就叫做 Domain-Driven Design(领域驱动设计,或DDD)

    模型以及模型之间的转换

    VO、DTO、DO、PO

    模型对象代码规范其实只有3种模型,Entity、Data Object (DO) 和 Data Transfer Object (DTO),不过思路都是类似的,先来看看包括了大众理解的模型
    在这里插入图片描述

    • VO(View Object):视图对象,用于展示层,只要是这个东西是让人看到的就叫VO
    • DTO(Data Transfer Object):数据传输对象,泛指用于展示层与服务层之间的数据传输对象,即前后端之间的传输;在微服务盛行的现在,服务和服务之间调用的传输对象也可以叫 DTO
    • DO(Domain Object):领域对象,就是从现实世界中抽象出来的有形或无形的业务实体。还有一个版本叫 DO(Data Object),等同于下面的 PO
    • PO(Persistent Object):持久化对象,它跟持久层(通常是关系型数据库)的数据结构形成一一对应的映射关系,如果持久层是关系型数据库,那么,数据表中的每个字段(或若干个)就对应PO的一个(或若干个)属性

    DDD 中的3种模型

    • Data Object (DO、数据对象):在DDD的规范里,DO应该仅仅作为数据库物理表格的映射,不能参与到业务逻辑中。DO 的生命周期应该被限制在基础组件层,不能向 domain 层暴露
    • Entity(实体对象):实体对象是我们正常业务应该用的业务模型,它的字段和方法应该和业务语言保持一致,和持久化方式无关。也就是说,Entity 和 DO 很可能有着完全不一样的字段命名和字段类型,甚至嵌套关系。Entity 的生命周期应该仅存在于内存中,不需要可序列化和可持久化。等同于上图中的 BO
    • DTO(传输对象):主要作为 Application 层的入参和出参,在表现层,可以被看做 param 入参以及 VO 出参,应该避免让业务对象变成一个万能大对象

    在实际开发中 DO、Entity 和 DTO 不一定是1:1:1的关系,一个 Entity 应该可以对应多个 DO,应该 DTO 又可以对应多个 Entity
    在这里插入图片描述

    总结

    综上,DDD 的包架构应该是下面这样
    在这里插入图片描述
    但是在 DDD 开发的过程中会有很多障碍,比如 mybtais 几乎不能满足 DDD 架构的需求,如果强制在 domain 层定义接口,代码会变的十分臃肿。同时市面上大多数 ORM 框架也有这个缺点

    其次,type 的想法虽好,但是就算有了 mapper 的帮助也无法很好的转换代码,过程往往变的更加复杂

    但是其思想是值得我们借鉴学习的,领域驱动设计的理论甚至可以用在非模块开发上

    MVC

    mvc 架构我们平时用的就比较多了

    Service/DAO层方法命名规约

    CRUD 是指在做计算处理时的增加(Create)、读取查询(Retrieve)、更新(Update)和删除(Delete)几个单词的首字母简写。主要被用在描述软件系统中 DataBase 或者持久层的基本操作功能。对应这里的 crud 方法的命名,每个人有不同的实践

    个人理解 dao 层是数据库的映射,因此这里的方法应当对应与数据库的关键字或者函数

    • insert
    • batchInsert
    • selectOne
    • selectById
    • count
    • selectByCondition
    • update
    • deleteById

    而 Service 接口命名应当偏向业务,与单纯的查询分离开来

    • add
    • create
    • findById
    • query
    • list
    • modify
    • remove

    而 Dao 层,应当只负责接收最终的 sql 语句,具体到某一张表的增删查改

    Service 层也不是就非有不可,对于极小的项目而言,加了 Service 层,反而增加了代码量,而且 Dao 层中已经预见了可能出现的情况,并进行了相应的扩展。那么,此时就不需要了

  • 相关阅读:
    python对月饼数据进行可视化,看看哪家最划算
    拼多多API接口,百亿补贴商品详情接口系列
    Kafka ETL 之后,我们将如何定义新一代实时数据集成解决方案?
    fltter解决跨域问题
    leetcode刷题笔记——位运算
    基于JSP/SERVLET实现的人脸识别考勤系统
    第五次作业
    最近又考了两个Oracle认证,交一下作业
    [python刷题模板] 数位DP
    2024年高考:计算机相关专业前景分析与选择建议
  • 原文地址:https://blog.csdn.net/sekever/article/details/126511021