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<OrderItemDTO> 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 <= 0) {
throw new IllegalArgumentException("金额必须大于0");
}
long discount = getTotalPrice() - newPrice;
if (discount == 0){
return;
}
// Item 均摊折扣
discountForItem(discount);
// Order 折扣
discountForOrder(discount);
// Pay 折扣
syncForPay();
}
// Item 均摊
private void discountForItem(long discount) {
Long totalAmount = getTotalPrice();
Long allocatedDiscount = 0L;
for (int i = 0; i < getOrderItems().size(); i++) {
OrderItem item = getOrderItems().get(i);
Long itemAmount = item.getSellingPrice();
if (i != getOrderItems().size() - 1) {
// 按比例进行均摊
Long itemDiscount = itemAmount / totalAmount * discount;
// 重新设置金额
item.setPrice(item.getPrice() - itemDiscount);
// 记录累加金额
allocatedDiscount += itemDiscount;
}else {
// 分摊余下的优惠金额到最后一个订单
Long lastItemDiscount = discount - allocatedDiscount;
item.setPrice(item.getPrice() - lastItemDiscount);
}
}
}
// Order 折扣
private void discountForOrder(long discount) {
Long newTotalPrice = getTotalPrice() - discount;
setTotalPrice(newTotalPrice);
}
// 将价格同步到 Pay
private void syncForPay() {
this.pay.updatePrice(getTotalPrice());
}
改价流程由 Order 的实体方法 changePrice 承载,核心流程如下:
- 入参校验。修改后的金额不可为负数;
- Item金额均摊。按OrderItem金额对折扣信息按比例进行均摊,并更新 Item 上的应付金额;
- Order金额更新。重新计算 Order 应付金额并对金额进行更新(也可以使用所有 Item 的金额总和);
- Pay金额同步。将Order上的最新金额同步到 Pay 实体;
如此操作,便可以保证聚合根内各个实体对象间的强一致性关系。
那原子能力又体现在哪呢?
- 不可分割。从业务视角,每个操作都是一个不可被拆分的最小执行单元;从技术视角,操作过程不会被打断;
- 不可破坏规则。必须从一个有效状态转换到另一个有效状态,聚合根内多个实体间的业务规则不允许被破坏;
- 持久化层的原子性保障。整个聚合根的状态要么全部更新到DB,要么全不更新,不允许只更新部分信息;
聚合根上的这些“原子业务”操作形成了聚合根的生命周期,如下图所示:
图片
这是 Order 聚合根的生命周期,每个业务操作均由以下部分组成:
- 业务验证。包括入参校验、前置条件校验;
- 状态或数据更新。对聚合内实体状态或数据进行更新;
- 领域事件发布。对外发布领域事件;
以订单的支付成功为例:
public void paySuccess(){
// 前置校验
if (getStatus() != OrderStatus.CREATED){
throw new RuntimeException("非待支付状态,无法操作");
}
// 更新状态
setStatus(OrderStatus.PAID);
// 如果 OrderItem 需要更新的话进行调用
// paySuccessForItem();
// 发布事件
publishEvent(new OrderPaidEvent(this));
}
2.2. 聚合根设计原则
想设计出好的聚合根非常不容易,需要丰富的经验,更需要对领域概念有很深的认知,但有些原则可以帮助你避免不少坑:
- 尽量简单。聚合根不应该包含太多的业务对象,应该保持简单,以保证可维护性;
- 对内部实体提供保护。非聚合根实体或值对象只能通过聚合根来间接访问,不能被外部直接访问;
- 业务操作具备原子性。内部实体操作必须满足聚合根的业务规则,以保证数据的一致性和完整性;
- 谨慎处理与其他聚合根的关系。聚合根不能包含其它聚合根的引用,聚合根之间可以通过 ID 引用来关联,而不能直接引用其它聚合根,以避免概念的扩大;
- 有效的版本管理。聚合根是状态变化的载体,为了避免并发情况下状态不一致,需要有效的版本管理对其进行保障,比如最常见的乐观锁机制;
- 具有清晰的职责。聚合根只处理自己的领域问题,不应该处理其它聚合根的领域问题;
2.3. 聚合根的优势
聚合根的引入会对系统产生非常大的影响,具体如下:
- 简化系统架构。使用聚合根将整个领域划分为多个聚合,可以使系统架构更加清晰。每个聚合都有自己的聚合根,聚合根之间只有松散的关联,这样可以降低系统的复杂度和耦合度;
- 提高系统性能。聚合根可以作为一组数据的操作单元,因此可以减少数据库的访问次数。例如,在多次更新一个聚合后,最终状态只需要一次数据库操作便能完成;
- 支持并发处理。使用聚合根可以支持更好的并发处理,聚合是一个最小的操作单元,同一聚合可以使用乐观锁等机制来控制并发,而聚合之间不存在共享数据可以进行并行处理;
- 提高可测试性。将整个领域划分为多个聚合,可以将测试场景限定在单个聚合内,从而提高测试的可控性和可重复性;
3. 流程编排
有了小“积木块”(聚合根上的原子业务操作),接下来就是如何将其组装成更大的业务操作。常见的编排方式有:
- 应用服务。对模型中的组件进行编排,为调用者提供完整的业务能力,比如生单、改价等;
- 领域服务。应用服务之下,主要解决跨聚合根操作的业务流程,比如转账等;
- 事件编排。应用服务之上,对多个业务域的操作进行编排,实现聚合间、服务间的解耦,比如支付成功后需要给用户发送短信等;
3.1. 应用服务
应用服务是DDD中重要的一层,它主要根据业务场景对流程进行编排,从而满足不同场景对领域能力的不同诉求。应用服务位于领域层和接口层之间,对内统一协调多个组件,对外满足不同场景下的 User Story。
下图是应用服务所在的位置:
图片
简单一句话形容便是:应用服务在领域模型各个组件的能力之上进行流程编排,以满足上层不同场景下的业务需求。
应用服务使用最多的便是对单个聚合的流程编排,主要包括创建和更新:
创建流程如下:
图片
更新流程如下:
图片
两者最大的区别在于聚合根获取方式:
- 创建流程,使用工厂完成复杂对象的创建,从而开启聚合的生命周期;
- 更新流程,使用仓库从持久化存储中恢复聚合对象,从而推动聚合生命周期的演进;
那这个的价值在哪呢?因为规范所以可以做到非常好的封装,具体见代码:
生单并改价流程编排:
@Transactional
public void createAndChangePrice(CreateOrderAndChangePriceCommand command) {
// 1. 检查库存,如果足够则进行锁定;如果不够,则抛出异常
this.inventoryService.checkIsEnoughAndLock(command.getItems());
// 2. 流程编排
// 设置存储仓库
creatorFor(this.orderRepository)
// 配置事件发布器
.publishBy(eventPublisher)
// 配置聚合根初始化
.instance(() -> 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. 小节
在一个系统中,“元素”的数量是有限的,“元素”间的“关系”是无限的。我们需要用好流程编排这把利器,在有限“元素”基础上,构建无限的“关系”,从而应对多变的业务场景。
- 原子能力。主要以聚合根为中心,对外暴露的各种 原子业务操作;
- 流程编排。通过多种手段,将原子能力和基础设施编排起来,最终实现业务需求:
应用服务。对领域模型中的组件进行组装,以实现不同的业务诉求;
领域服务。对于有明确的业务概念,但找不到合适的领域对象作承载的操作,可以封装成领域服务;
领域事件编排。主要解决聚合之间、服务之间耦合问题,对于有明确的 “因果” 关系的场景最为实用;