谈谈DDD设计落地的一些经验

领域驱动设计(Domain-Driven Design,简称DDD)作为一种设计和开发方法论,旨在解决复杂业务系统的开发难题,提高软件的质量和可维护性,当前已经得到了越来越多的关注与应用。但DDD的设计思想和我们日常广泛使用的经典三层结构有较大的区别,落地时很容易走偏,本文就结合笔者多个项目经验来谈谈对DDD思想落地的一些思考。

什么项目适合DDD

我们直接来看一张DDD与其它一些常见开发模式的成本比较:

在这里插入图片描述

DDD前期需要花费较多的时间进行领域建模,所以前期需要额外投入不少设计成本,但当业务复杂度逐步增加时,DDD的后续维护成本一直增长的比较平滑,而其它两种开发模式可能很快就会因为复杂度过高而难以维护了。所以答案就很明显的了,如果是简单的,只有是一些增删改查的模块;或者是一些临时性的项目,后续几乎不会再迭代了,那么就不要去折腾DDD了,这个属于杀鸡用牛刀。需要注意的是,如果进度非常紧迫的项目,也不建议一开始就直接采用DDD进行设计,领导的小皮鞭一直守在旁边,大家也没心情静下心来好好设计吧,此时可以先参考DDD的分层架构把模块层次先划分好,具体设计上可以适当妥协一下,等待后续再逐步的重构优化。这样虽然总的投入成本肯定要更大一些,但好在成本周期可以拉长一些,而且重构时领域相关的知识应该都理解的比较充分了,也更有利于设计出更加合理的领域模型。

DDD项目的分层

常规的DDD主要分为四层:

在这里插入图片描述

但笔者接触的项目大多都是基于HTTP对外暴露RESTFUL形式接口的web项目,几乎不存在其它形式接口的封装需求,这样导致用户接口层的实现比较单薄。因此我在实践中是将用户接口层和应用层合并到一起的,用户接口仅仅以包的形式进行隔离(比如在应用层中放置一个api包或者controller包),这样可以稍微降低交互的复杂度。但如果你的项目对于外部接口存在多种形式,比如既有restful接口,还有webservice接口,或者是移动端接口和PC端接口有不同的定义方式,那么还是建议保留独立的用户接口层。其实我们在项目实现过程中不要教条式的遵循DDD的规范,而是应该根据项目的实际情况进行必要的变通,对于任何设计模式都应如此

以下主要以我在项目中常用的三层结构来说明各层的组成结构:

应用层

负责用户接口的定义,参数的封装,转换和校验。并对领域层的业务逻辑进行简单编排,实现跨领域的业务流程。应用层主要是由三类对象构成接口,应用服务和DTO。注意:应用层的应用服务应该仅仅只是对逻辑的编排,具体的逻辑实现应放到领域层进行处理的。

如何理解这里所谓的对逻辑的编排呢?简单的说就是应用层的服务(service)仅仅作为下层组件的粘合剂,它只是单纯的组装领域层或者基础层的组件方法,来实现一个完整的业务流程,本身并不包含任何业务逻辑。举个例子:如果一个电子商务网站要实现一个在线下单的功能,那么实际的业务步骤可能有以下这些:

  1. 检查库存。
  2. 处理付款。
  3. 更新库存。
  4. 生成订单信息。
  5. 发送通知。

这里面每个步骤可能都是由不同的领域对象或者基础层的组件完成的,但到了应用层,那么就会整合成一个createOrder的方法,它的伪代码大概是这样的:

@Transactional
public void createOrder(Order order)  {
    //调用库存的领域服务检查库存
    //调用支付的领域服务处理付款
    //调用库存的领域服务更新库存
    //调用订单的领域服务创建订单
    //调用基础层的通知组件发送订单创建通知
}

需要注意的是,定义事务也通常是放到应用层的service中的,基础层和领域层并不关心事务。另外还有一些比较极端的规则,说是应用层的service不应该出现if-else,因为判断也意味着某种逻辑。但这个我觉得倒不一定非得遵守。

领域层

该层是DDD的核心,包含业务逻辑和业务规则。它包括实体(Entities)、值对象(Value Objects)、领域服务(Domain Services)、仓储接口(Repository Interfaces)等。该层的目标主要是 表达领域概念,捕获业务逻辑,确保业务规则的一致性。

Entity : 用于表示某一个领域对象,它具有如下的一些特征:

  1. 有状态,每个领域对象都有一个标识其唯一性的ID,因为其有状态,在多个线程中进行操作需要额外确保线程安全。
  2. 使用充血模型,即对象同时具备行为和属性
  3. 属性必须使用有意义的业务方法进行修改,原则上禁止暴露setter方法

初学者很难理解entity所谓的有状态的充血模型究竟是怎么回事,我们还是以订单(Order)为例子,如果是按照传统的java bean的方式来定义Order对象,大致应该是这个样子:

public class Order {

    private int status;

    private List<OrderItem> items;
    
    ......

	//获取订单的状态
    public int getStatus() {
        return status;
    }

   //设置订单的状态
    public void setStatus(int status) {
        this.status = status;
    }
    
}

以这种方式定义的Order,其所有属性都是直接通过Getter,Setter暴露给外部的,本身并没有什么业务逻辑,也就没有有意义的行为。它的主要用处还是充当不同的服务和方法间传递数据的介质(DTO).

而如果我们以DDD的设计思想,将Order看作是一个领域对象,那么它的定义方式就会完全不同:

public class Order {

	private String id;

    private int status;

    private List<OrderItem> items;
    
    ......

    /**
     * 订单是否已支付
     *
     * @return
     */
    public boolean isPayed() {
        return status == PAYED;
    }

    /**
     * 支付订单
     */
    public void payOrder() {
        checkItems();
        this.status = PAYED;
        this.payTime = LocalDateTime.now();
        ......
    }

}

这样定义以后,Order就不只是一个简单的“数据容器”了,它拥有自己的行为,而这些行为都关联了某一些业务逻辑,这样就成功的把一个领域中的数据和行为统一起来了,也符合软件设计中 “高内聚" 的概念。

Domain Service:领域服务和应用服务的区别在于,领域服务专注于处理单个领域的跨多个领域对象的操作,而应用服务是对实体类和领域服务提供的业务方法进行编排和组合,形成跨领域的业务流程。举个例子,我们在创建订单时可能需要检测一下当前用户有没有重复下单,而某个单独的Order领域对象由于无法感知到其它的Order对象,它们是没有办法做这样的判断的,而重复下单的判断属于某一种业务逻辑,也应该放到领域层当中,所以这个操作,就很适合放到OrderDomainService当中,作为一个独立的方法。

Value Object: 领域层中的VO主要用于表示一个没有唯一标识的对象。值对象主要用于描述领域中的某些属性,并且通常是不可变的。与实体(Entity)不同,值对象关注的是它们的属性而不是它们的身份,比如用户的电话号码,很多同学可能会直接用一个string来表示,但如果我们需要验证号码的有效性呢?那么还需要一个单独的util方法,如果需要获取号码的地区归属,还得再加一个,其实这些都是业务逻辑的一部分,我们可以将电话号码封装成一个值对象的形式,比如:

public class PhoneNumber {
  
    private final String number;
    public String getNumber() {
        return number;
    }

    public PhoneNumber(String number) {
        if (number == null) {
            throw new ValidationException("number不能为空");
        } else if (isValid(number)) {
            throw new ValidationException("number格式错误");
        }
        this.number = number;
    }

    public String getAreaCode() {
        for (int i = 0; i < number.length(); i++) {
            String prefix = number.substring(0, i);
            if (isAreaCode(prefix)) {
                return prefix;
            }
        }
        return null;
    }

    private static boolean isAreaCode(String prefix) {
        String[] areas = new String[]{"0571", "021", "010"};
        return Arrays.asList(areas).contains(prefix);
    }

    public static boolean isValid(String number) {
        String pattern = "^0?[1-9]{2,3}-?\\d{8}$";
        return number.matches(pattern);
    }

}

这样看起来是不是清晰多了。值对象有很多使用场景:

  • 格式限制的String:比如Name,PhoneNumber,OrderNumber,ZipCode,Address等
  • 限制的Integer:比如OrderId(>0),Percentage(0-100%),Quantity(>=0)等
  • 枚举的 int :比如 Status(一般不用Enum因为反序列化问题)
  • Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 Temperature、Money、Amount、ExchangeRate、Rating 等

其实大部分情况,Value Object是用于替代各种XXXUtil的存在

基础层

基础层的目标主要是确保领域逻辑不依赖于基础设施代码,从而使领域模型能够保持纯粹和高内聚。基础层的实现细节对领域层是透明的,领域层只通过接口与之交互。它的职责包括:数据持久化,负责与数据库的交互,将领域对象保存到数据库中;外部服务的集成,处理与外部系统或服务的通信,包括调用外部API、消息队列、邮件服务等,其它还有日志记录、缓存、身份验证和授权等属于基础架构的内容。

相关其它两层,基础层的内容相关还是比较容易理解的,包含的对象主要是各种Repository的实现类(封装了对底层框架或者中间件的使用)以及数据库的DO对象。基础层有效的将业务逻辑和底层架构进行了分离,假设后续项目因为各种原因需要更换缓存中间件或者数据库,只需要替换对应的Repository实现即可,上层的业务逻辑基本可保持不变。

依赖倒置

按照我们一般的想象,DDD中各层级的依赖关系肯定是至底向上的,应用层 —>(依赖于) 领域层 —>(依赖于) 基础层。逻辑上这样肯定是没错的,但在具体的代码实现上,我们却一般会进行依赖倒置,让基础层依赖于领域层,领域层位于依赖链的最下层(不依赖任何一层),应用层同时依赖基础层和领域层:

在这里插入图片描述

这样做的理由主要是为了分离业务逻辑和技术实现,我们之前已经介绍过,领域层定义了各种Repository的接口,表示业务逻辑对底层框架的要求,比如数据持久化,消息发送等等。但具体的实现是放到基础层的,具体的实现应该依赖于接口抽象,所以提供实现的基础层就需要依赖于定义接口的领域层了。而且通过依赖倒置之后,领域层位于整个应用的最下层,不再依赖于其它任何层级,这也有利于突出DDD中领域层作为核心的地位。

交互关系

综合上述的介绍,各个层级的组件之间的交互逻辑如下图:

在这里插入图片描述

转换器的实现

在DDD中,各层表示数据的对象都是不同的概念,比如应用层(用户接口层)中用于表示数据的主要是DTO对象,而领域层的数据则封装在entity中,在基础层中数据对象又变成了DO。这几个概念比较容易让人混淆,以下是对它们三种对象的一个简单比较:

在这里插入图片描述

要维持各层级的独立和分离,肯定是不能跨层去混用数据对象的,那么就一定需要通过数据对象转换器来将一种对象转换为另一种对象. 一般在DDD中存在两种类型的转换器:

  • Assembler: 属于应用层,其主要作用是将一个领域对象或一组领域对象转换成一个 DTO(数据传输对象),或将 DTO 转换回领域对象。

  • Converter: 属于基础层,其主要作用是将一个领域对象或一组领域对象转换成一个DO(数据库对象),或将 DO 转换回领域对象。

在项目中实现Assembler或者Converter时,直接手搓是最灵活的,但领域对象比较多的情况会增加很多额外的编码负担。也可以采用一些BeanUtil的工具类,基于反射进行不同对象间的属性复制,但这种方式一个是性能不高,一些比较复杂的转换也无法完成,而且一旦目标或者源的对象定义发生了变更,没有编译器的支持很难发现相关错误。这里推荐一个非常适合用于转换器实现的框架 — MapStruct . 它只需要开发人员声明相关的转换接口,例如:

@Mapper(unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface AssetAssembler {

    AssetAssembler INSTANCE = Mappers.getMapper(AssetAssembler.class);

    Asset toDomainEntity(AssetCreateDTO dto);

    AssetDetailDTO toDetailDTO(Asset asset);

}

至于具体的实现它能够在编译期自动生成,因为是在编译时生成的,也就无需用到反射,能够保证高效的转换。而且一旦源或者目标的定义发生了变更,它会自动去适配新的字段,一旦无法解析则会直接发生编译错误,不会将错误静默的带来运行时。而且MapStruct支持很多非常灵活的转换规则配置,甚至能够写脚本来定制一些规则,具体的用法大家可以去查询一些MapStruct的官网,这里就不再赘述了。

在这里插入图片描述
欢迎关注我的公号—飞空之羽的技术手札,阅读更多有深度的技术好文~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值