DDD 与 MVC 架构学习笔记

4 篇文章 0 订阅


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

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

但是 DDD 有些时候确实不好用,需要酌情考虑,一些小项目贴点胶水上去无伤大雅

Domain Primitive

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

DP 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object,在某些文件中,DP 又被称为聚合根

为什么会出现 DP

以下列举的四个原因,是大家在 MVC 架构中检查出现的几种问题(严格来说不算是问题,虽然写不好容易埋雷,但是这就是 service 层需要干的事)

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

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

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

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

接口的清晰度:在Java代码中,对于一个方法来说所有的参数名在编译时丢失,留下的仅仅是一个参数类型的列表,那么入参为三个 str 的函数就会变成这样

service.register("殷浩", "浙江省杭州市余杭区文三西路969号", "0571-12345678");

第三个参数需要传入电话,如果格式不对或者传入了姓名什么的,在真实代码中运行时会报错,但这种 bug 是在运行时被发现的,而不是在编译时

普通的 Code Review 也很难发现这种问题,很有可能是代码上线后才会被暴露出来。这里的思考是,有没有办法在编码时就避免这种可能会出现的问题

DP 的使用

DP 最重要的使用,是将隐性的概念显性化

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

@Data
public class UserName {
	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() {
		// 业务校验逻辑
		...
	}
}

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

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

这样做完之后,其实是生成了一个 Type(数据类型)和一个 Class(类):

  • Type 指我们在今后的代码里可以通过 username 去显性的标识电话号这个概念
  • Class 指我们可以把所有跟电话号相关的逻辑完整的收集到一个文件里

这两个概念加起来,构造成了 Domain Primitive(DP)

DP 同样可以封装多对象行为,但是需要封装的属性有足够的关联性,附上一个 DP 与 DTO 比较的图
在这里插入图片描述

优雅的使用 Domain Primitive

常见的 DP 的使用场景包括:

  • 有业务限制的 String 或者 Integer:比如 Name,PhoneNumber,OrderNumber,ZipCode,Address 等。一般来说,在从 DTO 转成 VO 时,在 DP 的构造方法里做校验实现这些限制即可
  • 枚举以及可枚举的属性:比如 Status
  • Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 Temperature、Money、Amount、ExchangeRate、Rating 等
  • 复杂的数据结构:比如 Map,尽量能把 Map 的所有操作包装掉,仅暴露必要行为

注意不要这么写:

  • 不要把 DP 的方法写到 DTO 或者 POJO 里。你见过 5000 行的 Order 类吗,我见过。把 DP 当成一个内部类写到 POJO 里我都能接受

持久化怎么处理:

一般来说,写个转换器将 DP 转成 jsonb 或者 hstore 是不错的选择

贫血模型和充血模型

贫血模型是指领域对象里只有 get 和 set 方法(POJO),所有的业务逻辑都不包含在内而是放在 Service 层,实体缺乏行为和业务逻辑。在这种模型中,实体更像是数据载体,而不包含任何业务规则或复杂逻辑。这通常会导致服务层变得庞大和复杂,难以维护。比如传统的 MVC 架构就是贫血模型

充血模型是指数据和对应的业务逻辑被封装到同一个类中。因此,这种充血模型满足面向对象的封装特性,是典型的面向对象编程风格。在充血模型中,实体不仅持有数据,还持有对数据进行操作的方法,包括验证、计算和其他业务相关的行为。这种方式可以减少服务层的复杂度,提高代码的可读性和可维护性,因为它遵循了高内聚,低耦合(业务逻辑与业务实体强绑定,业务逻辑不与其他逻辑接触)的原则。使用了 DP 就是充血模型了

选择贫血模型还是充血模型,取决于项目的需求、团队的偏好和系统的复杂度。在一些场景下,如简单的 CRUD 操作,贫血模型可能是足够且合适的;而在复杂的业务场景中,采用充血模型,将业务逻辑封装在实体中,往往能更好地反映业务领域,提高代码质量和可维护性

为什么要六边形架构

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

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

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

事务脚本式代码的第二大缺陷是:虽然写单个用例的代码非常高效简单,但是当用例多起来时,其扩展性会变得越来越差。可扩展性减少做新需求或改逻辑时,需要新增/修改多少代码

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

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

六边形架构与 DDD

六边形架构(Hexagonal Architecture)和领域驱动设计(Domain-Driven Design,简称DDD)虽然都是软件架构和设计模式中的一部分,但它们关注的层面有所不同,并不是完全相同的概念

DDD 是一种软件开发方法论,侧重于解决复杂业务领域的软件设计问题。它的核心在于强调提炼出业务概念,并将其转化为软件中的模型,确保软件能够准确地反映现实世界的业务逻辑,而领域模型通常包含实体、值对象、聚合根等概念

六边形架构则是一种具体的架构模式,主要关注如何将应用的核心业务逻辑与外部基础设施和框架隔离开来,以便提高代码的可测试性和可移植性。它通过定义输入端口和输出端口,以及相应的适配器,来实现这一目标,他的重点也是这两个,核心领域(包含业务逻辑,不依赖于任何外部系统)和接口以及适配器(通过端口与外界通信,通过适配器与具体的技术栈或基础设施交互)

虽然六边形架构和 DDD 可以独立存在,但它们也可以很好地结合在一起,就行了说一下他们的好处是什么

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

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

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

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

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

Presentation(Web、Interfaces)模块

Web 模块包含 Controller 等相关代码,同时在该模块中可以为其他的项目提供统一的出入口,比如提供给外部的 dubbo、http、rpc、mq 的 api,就在这一层

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

  • 网络协议的转化:通常这个已经由各种框架给封装掉了,我们需要构建的类要么是被注解的 bean,要么是继承了某个接口的 bean
  • 统一鉴权:比如在一些需要 AppKey+Secret 的场景,需要针对某个租户做鉴权的,包括一些加密串的校验
  • 限流配置:对接口做限流避免大流量打到下游服务
  • 异常处理:通常在接口层要避免将异常直接暴露给调用端,所以需要在接口层做统一的异常捕获,转化为调用端可以理解的数据格式

Application 模块

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

Application Service 是业务流程的封装,不处理业务逻辑。并且,ApplicationService 应该永远返回 DTO 而不是 Entity。该层的出参应该是标准的 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;
    }
}

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());
    }

app 层/service 层:

    @Override
    public OperateResult transfer(String clueId) {
        SalesClue salesClue = salesClueRouteService.selectById(clueId);
        if (salesClue == null) {
            return OperateResult.error("未查询到线索");
        }
        return OperateResult.success();
    }

Application层的几个核心类:

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

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

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

Domain 模块

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

有状态的 Entity 指对应原来 MVC 中的 DO,只不过加入了对 DO 中属性的一些操作(行为方法),被称为聚合根,里面封装了多个 PO 中的属性,里面的属性被称为 Object Value;Types 包的作用就是前文说的作用;Domain Service 则是核心的复合操作。以下是一个分组聚合根例子:

@Getter
@Document(GROUP_COLLECTION)
@TypeAlias(GROUP_COLLECTION)
@NoArgsConstructor(access = PRIVATE)
public class Group extends AggregateRoot {
    private String name;//名称
    private String appId;//所在的app
    private List<String> managers;//管理员
    private List<String> members;//普通成员
    private boolean archived;//是否归档
    private String customId;//自定义编号
    private boolean active;//是否启用
    private String departmentId;//由哪个部门同步而来
    
    //...此处省略了Group的行为方法
}

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

Infrastructure 模块

该层主要为 Domain 提供数据,包含数据库 DAO 的实现,包含外部依赖的接口类包括 Http 调用、dubbo 调用、中间件 redis、mq 等,我们把这些打包成防腐接口(一定要防腐接口),提供给 domain 层的业务使用。此外基础的配置,也需要放在这里

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

// 代码在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;
    }
}

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

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

模型以及模型之间的转换

这里额外强调一下,对于一些简单的业务,使用 DDD 反而不好,因为内部又大量的数据转化逻辑,但是简单业务中,实体往往不需要怎么转化,有可能从 PO 到 VO 都是使用一模一样的字段的,使用 DDD 反而不好了。比如有一个特别简单的业务,他基本上未来十年内,使用的 DB 都是 PG,此时 DDD 在基础架构层提供的 CRUD 防腐接口转换就没有意义了,如果是之前 MVC 架构,写个 mapper 只需要:

  • CrudMapper

而使用了 DDD,在基础架构层的 mapper 则是:

  • CrudRouteService
  • CrudRouteServiceImpl
  • CrudMapper

此时多出来的两层就完全没有必要了

VO、DTO、BO、PO

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

  • VO(View Object):视图对象,用于展示层,只要是这个东西是让人看到的就叫VO
  • DTO(Data Transfer Object):数据传输对象,泛指用于展示层与服务层之间的数据传输对象,即前后端之间的传输对象;在微服务盛行的现在,服务和服务之间调用的传输对象也可以叫 DTO
  • BO(Business Object):业务对象,就是从现实世界中抽象出来的有形或无形的业务实体。BO 就是 PO 的组合,比如 PO1 是交易记录,PO2 是登录记录,PO3 是商品浏览记录,PO4 是添加购物车记录,PO5 是搜索记录,BO 是个人网站行为对象。BO 是一个业务对象,一类业务就会对应一个 BO,数量上没有限制,而且 BO 会有很多业务操作,也就是说除了 get,set 方法以外,BO 会有很多针对自身数据进行计算的方法,也就是上面我们说的 DP
  • 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

Service 与 DAO 层方法命名规约

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

  • 获取单个对象的方法用 get 做前缀,如 getById
  • 获取多个对象的方法用 list 做前缀,如 listByCondition
  • 获取统计值的方法用 count 做前缀,如 countByCondition
  • 插入的方法用 save(推荐)或 insert 做前缀
  • 删除的方法用 remove(推荐)或 delete 做前缀
  • 修改的方法用 update 做前缀

Dao 层应当只负责接收最终的 sql 语句,具体到某一张表的增删查改,因为列表的效率从经验上来说大大低于多次查询单表的效率

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

业务错误是用返回值来处理还是抛异常来处理

业务层给接口层传递错误信息无外乎几种方式

  • 封装统一返回类,一般构成是 code+msg,复杂点可以再封装一层 data 用于存储数据,然后上层先判断 code==true 才去获取 data 内容
  • 封装业务异常类,在各个判断或边界检查不符合的时候直接抛出自定义的异常类,上层通过捕获异常来获取非成功流程提示信息

1和2效率无疑是较高的,但1方式功能有限,无法提供比较详细的非成功流程信息

2比1提高了很多的扩展性,基本会满足大多数场景,但代码可读性较差,上层需要通过层层判断来获取信息。代码显得不够优雅,我们知道 java 异常效率低下是因为抛出异常会遍历所有涉及堆栈,具体代码在基类 Throwable 的 fillInStackTrace() 方法里。但其实可以通过在自定义异常中重写 fillInStackTrace() 来大幅度提高异常效率。这样业务异常抛出是不会有堆栈信息,上层只能获取到定义的异常 message

具体该使用1还是使用2完全可以看团队要求,保持统一风格即可,调优本来就是一个取舍的过程,没有十全十美的方案,许多细节只能争取做到平衡,然后去关注其他要点,比如一个烂 sql 带来的损耗可能比 1-2 方案之间的损耗要大数百倍

同时,处理业务异常的时候需要考虑监控报警问题,线上就因为没考虑监控会收集 error 日志以及访问接口时返回为500的次数,没有将业务异常与系统异常分离开,导致监控噪音很大,因此,抛出业务异常的时候应当考遵守以下写法:

  • 业务异常特殊处理。在 catch Exception 之前,先 catch ServiceException,业务异常打 info 日志,系统异常打监控也好,打 error 日志也好,无所谓了
  • 如果不想这么写,那么业务异常直接封装统一返回类返回
  • 记得不要将业务异常直接丢给前端,接口会报500,导致前端同学觉得我们的水平很次,接口经常不能用。我们可以写个全局异常处理,因此处理校验异常 MethodArgumentNotValidException、业务异常 ServiceException 等,如果抛出了除了这些以外的异常,我们打个 error 日志,然后给前端返回500
  • 业务码 code 和 httpCode 区分开,我们的 httpCode 应当尽可能保证是200,当然也可以是301、302、用户无权限401、402等,但是尽量不要是500。业务码 code 则可以自己将一些常见的业务问题总结一下给前端了,比如用户没有写入权限、调用 RPC 接口异常、处理逻辑代码超时等等
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值