DDD 是一个门槛很高的设计方法,里面涉及众多概念,各概念间相互关联相互制约,大大增加了落地的难度。但,当真正落地之后,你会发现还是有很多技巧能大幅降低学习成本,实现快速上手。
学习 DDD 最关键的一点便是:使用面向对象思维去思考问题。
这个说起来很抽象,面向对象其实很简单,就像孩子们玩的乐高积木:
这里的原子能力主要指的是聚合根所提供的业务方法,从业务或技术视角都是不可拆分的最小操作单元。
在 DDD 中,聚合根是一个重要的概念,它是一组具有内在一致性的相关对象的根,用来限制对象的边界,可以保证聚合内部的对象间关联关系和业务规则得到统一的管理和维护。
聚合根是聚合的一个实体,作为整个聚合的唯一入口点,通过它才能访问整个聚合。在DDD中,聚合被定义为一组相关对象的集合,这些对象在业务上有着紧密的联系,需要被当作一个整体来对待。聚合根是这个整体的根节点,它负责维护整个聚合的完整性和一致性。
很抽象,让我们看个示例:
电商订单系统主要由以下几个实体组成:
这几个实体存在非常强的一致性保障,特别是在金额方面:
如何保障订单、订单项、支付记录上的金额是强一致的呢?小心编码+谨慎测试?
那加入更多的应用场景又该怎么处理呢?比如 优惠券、优惠活动、手工改价、调整快递费用等,这将变成一个烫手山芋。
解决方案便是将这几个实体作为一个整体来思考,也就是聚合的概念。
聚合是DDD的一种设计模式,它的本质是建立比对象粒度更大的边界,聚合了那些紧密联系的对象,形成了一个业务上的整体。
图片
如上图所示:
这样的调整,能否保障 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 上,包括:
根据单价和购买数量计算 OrderItem 需付金额;
对 OrderItem 需付金额进行累计,更新 Order 的需支付金额;
将 Order 需付金额同步到 Pay 实体;
然后看下改价流程:
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 承载,核心流程如下:
如此操作,便可以保证聚合根内各个实体对象间的强一致性关系。
那原子能力又体现在哪呢?
聚合根上的这些“原子业务”操作形成了聚合根的生命周期,如下图所示:
图片
这是 Order 聚合根的生命周期,每个业务操作均由以下部分组成:
以订单的支付成功为例:
public void paySuccess(){
// 前置校验
if (getStatus() != OrderStatus.CREATED){
throw new RuntimeException("非待支付状态,无法操作");
}
// 更新状态
setStatus(OrderStatus.PAID);
// 如果 OrderItem 需要更新的话进行调用
// paySuccessForItem();
// 发布事件
publishEvent(new OrderPaidEvent(this));
}
想设计出好的聚合根非常不容易,需要丰富的经验,更需要对领域概念有很深的认知,但有些原则可以帮助你避免不少坑:
聚合根的引入会对系统产生非常大的影响,具体如下:
有了小“积木块”(聚合根上的原子业务操作),接下来就是如何将其组装成更大的业务操作。常见的编排方式有:
应用服务是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();
}
代码的可读性得到很大的提升。
领域服务通常是无状态操作,当一些职责不适合放在任何一个领域对象上时,我们可以考虑将其放在一个领域服务中。
整个流程是一个标准的业务概念,并且流程中涉及多个领域对象,当这个操作放在哪个领域对象上都不合适时,可以将其放在一个单独的服务中,这个服务就是领域服务。
领域服务只做流程编排,不直接处理业务逻辑,业务逻辑直接调用领域模型中的其他对象。
一个例子就是转账,“转账”作为一个标准的业务概念,需要提供一个转账服务来承载这个领域概念。转账服务需要协调两个领域对象:源账户和目标账户,源账号做转出,目标账号做转入,从而实现转账逻辑。
简单的单机场景可以基于数据库事务进行保障,具体如下:
图片
image
从严格意义上讲,在一个事务中只能对一个聚合进行修改,这条原则更适合于分布式系统。在单机系统中,直接使用数据库本地事务对一致性进行保障,是一种投入产出比极高的事情。
在复杂的分布式场景需要引入“协调器”来对流程进行总控,以实现系统的最终一致性,具体如下:
图片
image
【注】领域服务只做流程编排,不处理业务逻辑!!!!
领域服务不会直接暴露给业务方使用,而是由应用服务负责协作。切记应用服务是领域模型的门面(Facade)。
领域服务是基于面向过程编程范式构建的,目的是降低领域模型之间的耦合关系。切记不要被滥用,将太多的逻辑放在领域服务中,这会导致领域服务变得臃肿和难以维护。
领域事件是一种轻量级的通信机制,聚合根可以发布领域事件来通知其他聚合根或外部系统发生了某个事件,而其他聚合根或外部系统则可以订阅这些事件,进行相应的处理,从而推动流程向下发展。
在系统中,基于领域事件的流程编排极为重要,他是系统间解耦的利器,也是分布式环境下最终一致性的保障。
首先,看一个简单场景:
图片
订单支付成功后,向外发布领域事件,下游接受到领域事件后,做如下动作:
然后,看一个更复杂的外卖场景:
图片
在分布式系统中极为常见,将事件串联起来从而完成一个复杂的业务操作:
不管简单场景还是复杂场景,事件玩的就是一个连线游戏:
在一个系统中,“元素”的数量是有限的,“元素”间的“关系”是无限的。我们需要用好流程编排这把利器,在有限“元素”基础上,构建无限的“关系”,从而应对多变的业务场景。
应用服务。对领域模型中的组件进行组装,以实现不同的业务诉求;
领域服务。对于有明确的业务概念,但找不到合适的领域对象作承载的操作,可以封装成领域服务;
领域事件编排。主要解决聚合之间、服务之间耦合问题,对于有明确的 “因果” 关系的场景最为实用;