DDD的奇幻世界:从小积木到艺术品的设计之旅

2023年 9月 5日 50.2k 0

1. 背景

DDD 是一个门槛很高的设计方法,里面涉及众多概念,各概念间相互关联相互制约,大大增加了落地的难度。但,当真正落地之后,你会发现还是有很多技巧能大幅降低学习成本,实现快速上手。

学习 DDD 最关键的一点便是:使用面向对象思维去思考问题。

这个说起来很抽象,面向对象其实很简单,就像孩子们玩的乐高积木:

  • 【组件】每个小积木都有自己的形状(对象自身功能),也都有自己的凸起或插槽(对象暴露的接口或能力);
  • 【关系】多个小积木可以组装成一个大积木,多个大积木可以组装成更大的积木(通过对象间的组合实现更强大的功能);
  • 【功能】多个大积木最终组成“艺术品”(通过对象间的协作实现某个功能);
  • 在 DDD 中也是一样的:
  • 【组件】聚合根是DDD中最核心的组件,对内维护高内聚的对象集合,对外提供原子业务能力;
  • 【关系】应用服务、领域服务、领域事件 对多个组件进行编排,实现业务流程;
  • 【功能】领域内部能力 通过 应用服务 暴露给外部调用者,从而满足业务需求;

2. 原子能力

这里的原子能力主要指的是聚合根所提供的业务方法,从业务或技术视角都是不可拆分的最小操作单元。

2.1. 聚合根

在 DDD 中,聚合根是一个重要的概念,它是一组具有内在一致性的相关对象的根,用来限制对象的边界,可以保证聚合内部的对象间关联关系和业务规则得到统一的管理和维护。

聚合根是聚合的一个实体,作为整个聚合的唯一入口点,通过它才能访问整个聚合。在DDD中,聚合被定义为一组相关对象的集合,这些对象在业务上有着紧密的联系,需要被当作一个整体来对待。聚合根是这个整体的根节点,它负责维护整个聚合的完整性和一致性。

很抽象,让我们看个示例:

电商订单系统主要由以下几个实体组成:

  • 订单(Order)。记录用户的一次生单,主要保存用户、支付金额、订单状态等;
  • 订单项(OrderItem)。购买的单个商品,主要保存商品单价、售价、应付金额等;
  • 支付记录(Pay)。用户的支付信息,包括支付渠道、支付金额、支付时间等;
  • 收货地址(Address)。用户的收货地址;

这几个实体存在非常强的一致性保障,特别是在金额方面:

  • 订单的支付金额等于所有订单项金额总和;
  • 支付记录的待支付金额必须与订单支付金额一致;

如何保障订单、订单项、支付记录上的金额是强一致的呢?小心编码+谨慎测试?

那加入更多的应用场景又该怎么处理呢?比如 优惠券、优惠活动、手工改价、调整快递费用等,这将变成一个烫手山芋。

解决方案便是将这几个实体作为一个整体来思考,也就是聚合的概念。

聚合是DDD的一种设计模式,它的本质是建立比对象粒度更大的边界,聚合了那些紧密联系的对象,形成了一个业务上的整体。

图片图片

如上图所示:

  • Order、OrderItem、Pay 和 Address 不在单独处理,而是组成了一个更大的对象,也就是聚合;
  • Order 是这个聚合的聚合根,对内协调各个对象,对外提供唯一的访问入口;
  • OrderItem、Pay、Address 作为非聚合根的内部实体,不可直接对外提供服务,仅接受 Order 的调用;

这样的调整,能否保障 Order、OrderItem、Pay 三者间的强一致关系呢?让我们从代码层面进行细致分析:

生单:

// 静态工厂,封装复杂的 Order 创建逻辑,并保障创建的 Order 对象是有效的
public static Order create(CreateOrderCommand createOrderCommand) {
    Order order = new Order(createOrderCommand.getUserId());
    order.setAddress(Address.create(createOrderCommand));
    order.setPay(new Pay());
    order.addItems(createOrderCommand.getItems());
    order.init();
    return order;
}

// 添加 OrderItem,并计算总金额
private void addItems(List items) {
    if (!CollectionUtils.isEmpty(items)){
        items.forEach(item ->{
            // orderItem.setPrice(orderItem.getPrice() * orderItem.getQuantity());
            OrderItem orderItem = OrderItem.create(item);
            this.orderItems.add(orderItem);
            this.totalSellingPrice += item.getPrice();
        });
    }
    this.totalPrice = totalSellingPrice;
    this.pay.updatePrice(this.totalPrice);
}

// 设置状态完成对象的初始化
private void init() {
    this.status = OrderStatus.CREATED;
}

所有流程全部封装在 Order 的静态方法 create 上,包括:

  • 构建内部实体。根据输入信息创建 Pay、Address 等关联实体;
  • 金额计算。创建 OrderItem 实体并添加到集合中,在添加流程完成金额计算:

根据单价和购买数量计算 OrderItem 需付金额;

对 OrderItem 需付金额进行累计,更新 Order 的需支付金额;

将 Order 需付金额同步到 Pay 实体;

  • 设置订单状态。调用 init 方法,将订单状态设置为 CREATED;

然后看下改价流程:

public void changePrice(Long newPrice) {
    if (newPrice  Order.create(command))
            // 配置执行额外操作
            .update(order -> order.changePrice(command.getNewPrice()))
            // 执行操作
            .call();
}

支付成功流程编排:

@Transactional
public void paySuccess(Long orderId){
    // 流程编排
    // 设置存储仓库
    updaterFor(this.orderRepository)
            // 设置 id
            .id(orderId)
            // 配置事件发布器
            .publishBy(this.eventPublisher)
            // 未找到时抛出异常
            .onNotExist(id -> new AggregateNotFountException(id))
            // 配置业务动作
            .update(order -> order.paySuccess())
            // 执行操作
            .call();

}

代码的可读性得到很大的提升。

3.2. 领域服务

领域服务通常是无状态操作,当一些职责不适合放在任何一个领域对象上时,我们可以考虑将其放在一个领域服务中。

整个流程是一个标准的业务概念,并且流程中涉及多个领域对象,当这个操作放在哪个领域对象上都不合适时,可以将其放在一个单独的服务中,这个服务就是领域服务。

领域服务只做流程编排,不直接处理业务逻辑,业务逻辑直接调用领域模型中的其他对象。

一个例子就是转账,“转账”作为一个标准的业务概念,需要提供一个转账服务来承载这个领域概念。转账服务需要协调两个领域对象:源账户和目标账户,源账号做转出,目标账号做转入,从而实现转账逻辑。

简单的单机场景可以基于数据库事务进行保障,具体如下:

图片图片

image

从严格意义上讲,在一个事务中只能对一个聚合进行修改,这条原则更适合于分布式系统。在单机系统中,直接使用数据库本地事务对一致性进行保障,是一种投入产出比极高的事情。

在复杂的分布式场景需要引入“协调器”来对流程进行总控,以实现系统的最终一致性,具体如下:

图片图片

image

【注】领域服务只做流程编排,不处理业务逻辑!!!!

领域服务不会直接暴露给业务方使用,而是由应用服务负责协作。切记应用服务是领域模型的门面(Facade)。

领域服务是基于面向过程编程范式构建的,目的是降低领域模型之间的耦合关系。切记不要被滥用,将太多的逻辑放在领域服务中,这会导致领域服务变得臃肿和难以维护。

3.3. 领域事件编排

领域事件是一种轻量级的通信机制,聚合根可以发布领域事件来通知其他聚合根或外部系统发生了某个事件,而其他聚合根或外部系统则可以订阅这些事件,进行相应的处理,从而推动流程向下发展。

在系统中,基于领域事件的流程编排极为重要,他是系统间解耦的利器,也是分布式环境下最终一致性的保障。

首先,看一个简单场景:

图片图片

订单支付成功后,向外发布领域事件,下游接受到领域事件后,做如下动作:

  • 为用户发送购买成功的短信通知;
  • 为用户增加积分;
  • 通知仓库进行发货;

然后,看一个更复杂的外卖场景:

图片图片

在分布式系统中极为常见,将事件串联起来从而完成一个复杂的业务操作:

  • 用户完成支付,订单服务向外发布“支付成功”事件;
  • 餐厅服务接收到“支付成功”事件后,执行下单动作,将菜品增加到大厨的制菜清单上;
  • 餐厅做完菜后对外发送“饭菜准备好”事件;
  • 物流系统接收到“饭菜准备好”事件后,通知外卖小哥上门取餐(这点不太合适,应该是支付成功后,快递小哥便收到通知。为了更好的描述流程,先忽略现实操作)
  • 快递小哥完成取餐、送餐后,物流系统发出“外卖已送达”事件;
  • 订单服务接收到“外卖已送到”事件后,更新订单为“已完成”状态;

不管简单场景还是复杂场景,事件玩的就是一个连线游戏:

  • 各个系统已经提供了丰富的业务操作,也就是游戏中的“节点”;
  • MQ中间件是一个标准的桥梁,用于连接消息发送方和订阅方,也就是游戏中的“线”;
  • 根据业务流程,将业务操作与消息发送端&消费端链接起来,并完成了整个业务操作;

4. 小节

在一个系统中,“元素”的数量是有限的,“元素”间的“关系”是无限的。我们需要用好流程编排这把利器,在有限“元素”基础上,构建无限的“关系”,从而应对多变的业务场景。

  • 原子能力。主要以聚合根为中心,对外暴露的各种 原子业务操作;
  • 流程编排。通过多种手段,将原子能力和基础设施编排起来,最终实现业务需求:

应用服务。对领域模型中的组件进行组装,以实现不同的业务诉求;

领域服务。对于有明确的业务概念,但找不到合适的领域对象作承载的操作,可以封装成领域服务;

领域事件编排。主要解决聚合之间、服务之间耦合问题,对于有明确的 “因果” 关系的场景最为实用;

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论