DDD 对决:事务脚本 vs 领域模型,哪个才是业务优化的终极方案?

👉 这是一个或许对你有用的社群

🐱 一对一交流/面试小册/简历优化/求职解惑,欢迎加入「芋道快速开发平台」知识星球。下面是星球提供的部分资料: 

695464192c1014b0a5dfbb4438c52bb9.gif

👉这是一个或许对你有用的开源项目

国产 Star 破 10w+ 的开源项目,前端包括管理后台 + 微信小程序,后端支持单体和微服务架构。

功能涵盖 RBAC 权限、SaaS 多租户、数据权限、商城、支付、工作流、大屏报表、微信公众号等等功能:

  • Boot 地址:https://gitee.com/zhijiantianya/ruoyi-vue-pro

  • Cloud 地址:https://gitee.com/zhijiantianya/yudao-cloud

  • 视频教程:https://doc.iocoder.cn

来源:geekhalo


在 CQRS 架构篇提到,由于 Command 和 Query 内部驱动力完全不同,需要在架构层就进行分离,但其中有个一个原则极为重要:

  1. “读”再复杂也是简单;

  2. “写”再简单也是复杂;

可见 Command 远比 Query 棘手的多,其中最关键的便是使用哪种模式来承载业务?

最常见的业务承载模式有:

  1. 事务脚本。

  2. 领域模型。

1. 事务脚本 与 领域模型

事务脚本 和 领域模型 都是承载业务的不同模型,都有其适合的场景,没有绝对的对和错。核心的决策依据只有一个:选择最合适的业务场景即可。

简单且直观的对两者进行区分:

  1. 事务脚本,门槛低上手快,适合简单的业务场景,比如资讯、博客等;

  2. 领域模型,门槛很高,适合处理复杂的业务场景,比如电商、银行、电信等;

大家最常听说也是最反感的便是:被别人称为 CRUD boy,更多时候说的便是 事务脚本。

1.1. 事务脚本

事务脚本(Transaction Script)是一种应用程序架构模式,主要用于处理简单的业务场景。它将业务逻辑和数据库访问紧密耦合在一起,以便实现对数据的操作。

事务脚本,将整个业务逻辑封装在一个事务中,借助数据库事务来满足业务操作的 ACID 特性。通过将逻辑和事务封装在一起,从而简化应用程序的处理和开发。

下图是基于事务脚本的生单流程:

d66b9b0c71b64b213329ddeee1e4d918.png

简单描述就是:将“脚本”(SQL)进行打包,然后放在一个“事务”中运行。这也就是“事务脚本”命名的由来。

接下来,看一个订单改价流程:

3d6b0ec9733661c98e8d070d7964b0bb.png

和生单流程基本一致,在此不做过多介绍。

1.2. 领域模型

领域驱动设计(Domain-Driven Design,DDD)是应对复杂业务场景的利器,它是对业务领域中的关键概念和业务规则的抽象。领域模型是一个对象模型,它主要描述各领域对象之间的关系和行为。

和事务脚本不同,领域模型使用对象来承载业务逻辑,领域模型的设计基于业务领域知识,强调领域专家的参与,以提高软件系统的质量和开发效率。

下图是基于领域模型的生单流程:

89d3ff4d119b527fba3cb10f5c9de72c.png

简单描述就是:核心业务逻辑全部由对象实现(addItems方法),数据库仅做数据存储。

接下来,看下基于DDD的订单改价流程:

b77ba201a82a563aabaaa55338665d1b.png

和生单流程基本一致,核心逻辑由 Order 的 modify price 实现。

相比之下,领域模型就复杂太多,它由多个实体 (Entity)、值对象 (Value Object)、聚合 (Aggregate)、领域服务 (Domain Service)、工厂 (Factory) 等组成,它们共同构成了领域对象模型。在模型中,实体和值对象表示业务中的实际对象,聚合是由多个高内聚实体和值对象形成的组合提,领域服务表示不属于任何一个实体或值对象的操作,工厂则用于创建复杂的对象,比如实体和值对象等。

1.3. 区别

两者都是承载业务逻辑的架构,但区别巨大:

  1. 事务脚本是以流程为中心的设计方法,在数据库层面执行指令,简化数据处理的过程;DDD 是以领域对象为中心的设计方法,旨在更好地理解和解决业务问题。

  2. 事务脚本以技术和流程为重点,以技术为中心,以代码实现为核心,关注数据处理问题;DDD 则强调模型驱动开发,以业务为中心,以领域模型为核心,关注业务逻辑,并以此为基础进行技术实现。

  3. 事务脚本很容易造成代码的累积,难以维护;DDD 能够帮助开发人员找到领域的本质(深层模型),并以此为核心,从而形成统一的、易于维护的架构。

除此之外,DDD 还有很多的特点,比如:

  1. 标准化。DDD 由一组严谨的规范组成,有完整的理论基础,可以实现落地过程的标准化;

  2. 设计模型。大家在日常工作中很少使用设计模型的根因在于:缺乏应用场景。当你处于“过程式”的开发模式下,只能产出面条代码;只有面对“面对对象”场景,才能落地设计模式,提升抽象能力;

  3. 降维打击。DDD 是从业务需求出发,将业务概念转化为对象模型,最后通过技术进行落地。这本身就是一种自上而下的设计方式,聚焦于业务,解决真实问题;

基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能

  • 项目地址:https://github.com/YunaiV/ruoyi-vue-pro

  • 视频教程:https://doc.iocoder.cn/video/

2. 实战体验

对于程序员来说,文字显得不够直观,在此我们通过代码来体验下两者的不同。

为了更好的体现两者的区别,将会从两个场景进行对比:

  1. 创建场景。围绕电商下单流程进行说明。

  2. 更新场景。以电商订单改价流程为基础进行说明。

在日常开发中,物理删除场景用的非常少,甚至很多公司都明令禁止使用“delete”语句。通常使用 “逻辑删除” 替代,它可归属为标准的更新场景,在此暂不对比 物理删除场景。

2.1. 创建场景:下单

在电商中,一个标准的下单需求主要包括:

  1. 对商品库存进行校验,避免出现超卖的情况;

  2. 对商品库存进行锁定,如果支付成功则直接扣减锁定的库存;如果支付失败,则对锁定的库存进行归还;

  3. 为每一种购买的商品生成一个订单项(OrderItem),记录商品单价、购买数量、需付总价、应付金额等;

  4. 为每一笔下单生成一个订单(Order),记录用户、地址、支付金额、订单状态等;

2.1.1. 基于事务脚本的下单

核心代码如下:

@Transactional  
public void createOrder(CreateOrderCommand createOrderCommand) {  
    // 1. 库存校验  
    for (OrderItemDTO itemDTO : createOrderCommand.getItems()) {  
        Integer stock = inventoryMapper.getStock(itemDTO.getProductId());  
        if (stock < itemDTO.getQuantity()) {  
            throw new IllegalStateException("库存不足");  
        }  
    }  
    // 2. 锁定库存  
    for (OrderItemDTO itemDTO : createOrderCommand.getItems()) {  
        inventoryMapper.lockStock(itemDTO.getProductId(), itemDTO.getQuantity());  
    }  
  
    // 3. 生成订单项  
    List<OrderItem> items = createOrderCommand.getItems().stream()  
            .map(OrderItem::create)  
            .collect(Collectors.toList());  
    orderItemMapper.createOrderItems(items);  
  
    // 4. 生成订单  
    Long totalPrice = items.stream()  
            .mapToLong(OrderItem::getPrice)  
            .sum();  
  
    Order order = new Order(createOrderCommand.getUserId(),  totalPrice, OrderStatus.CREATED);  
    orderMapper.createOrder(order);  
  
}

事务脚本与需求所需操作流程完全一致,简单来说就是使用“编程语言”对需求进行了翻译。

2.1.2. 基于 DDD 的下单

核心代码如下:

public void createOrder(CreateOrderCommand createOrderCommand) {  
    // 1. 检查库存,如果足够则进行锁定;如果不够,则抛出异常  
    this.inventoryService.checkIsEnoughAndLock(createOrderCommand.getItems());  
  
    // 2. 创建 Order 聚合,此处使用静态工厂创建复杂的 Order 对象  
    Order order = Order.create(createOrderCommand);  
  
    // 3. 保存 Order 聚合, @Transactional 在OrderRepository上  
    this.orderRepository.save(order);  
}  
  
public class Order {  
    private Long id;  
    private Long userId;  
    private Long totalSellingPrice = 0L;  
    private Long totalPrice = 0L;  
    private OrderStatus status;  
    private List<OrderItem> orderItems = new ArrayList<>();  
  
    // 避免外部调用  
    private Order(Long userId) {  
        this.userId = userId;  
    }  
  
    // 静态工厂,封装复杂的 Order 创建逻辑,并保障创建的 Order 对象是有效的  
    public static Order create(CreateOrderCommand createOrderCommand) {  
        Order order = new Order(createOrderCommand.getUserId());  
        order.addItems(createOrderCommand.getItems());  
        order.init();  
        return order;  
    }  
  
    // 添加 OrderItem,并计算总金额  
    private void addItems(List<OrderItemDTO> items) {  
        if (!CollectionUtils.isEmpty(items)){  
            items.forEach(item ->{  
                OrderItem orderItem = OrderItem.create(item);  
                this.orderItems.add(orderItem);  
                this.totalPrice += item.getPrice();  
            });  
        }  
        this.totalPrice = totalSellingPrice;  
    }  
  
    // 设置状态完成对象的初始化  
    private void init() {  
        this.status = OrderStatus.CREATED;  
    }  
}

和事务脚本相比,由以下几点不同:

  1. 应用服务中的 createOrder 方法内容非常简单,可以看做是模版代码,变化的可能性非常小,可以对其进行进一步的封装;

  2. 核心逻辑全部在 Order 聚合根中,通过静态方法 create 完成 Order 对象的创建,业务逻辑非常集中,形成了拥有属性和行为的“富对象”;

  3. 数据操作与逻辑解耦,最后一步操作 orderRepository#save 方法 完成内存对象向DB数据的同步,其他部分均不涉及基础设施;

2.2. 更新场景:订单改价

在电商中,订单改价主要包括:

  1. 修改订单项价格(OrderItem),根据商品要支付金额对新价格按比例进行均摊;

  2. 修改订单价格(Order),修改订单的支付金额;

2.2.1. 基于事务脚本的订单改价

核心代码如下:

@Transactional  
public void changeOrderPrice(Long orderId, Long newPrice) {  
    // 1. 校验金额  
    if (newPrice <= 0) {  
        throw new IllegalArgumentException("金额必须大于0");  
    }  
  
    // 校验订单有效性  
    Order order = orderMapper.getOrderById(orderId);  
    if (order == null) {  
        throw new IllegalArgumentException("订单不存在");  
    }  
  
    // 2. 对订单项价格进行均摊  
    allocateDiscount(order, order.getTotalPrice() - newPrice);  
  
    // 3. 修改订单价格  
    order.setTotalPrice(newPrice);  
    orderMapper.updateOrder(order);  
}  
  
public void allocateDiscount(Order order, Long discount) {  
    if (discount == 0){  
        return;  
    }  
  
    List<OrderItem> items = this.orderItemMapper.getByOrderId(order.getId());  
    Long totalAmount = order.getTotalPrice();  
    Long allocatedDiscount = 0L;  
  
    for (int i = 0; i < items.size(); i++) {  
        OrderItem item = items.get(i);  
        Long itemAmount = item.getSellingPrice();  
        if (i != items.size() - 1) {  
            // 按比例进行均摊  
            Long itemDiscount = itemAmount / totalAmount * discount;  
            // 重新设置金额  
            item.setPrice(item.getPrice() - itemDiscount);  
            // 记录累加金额  
            allocatedDiscount += itemDiscount;  
        }else {  
            // 分摊余下的优惠金额到最后一个订单  
            Long lastItemDiscount = discount - allocatedDiscount;  
            item.setPrice(item.getPrice() - lastItemDiscount);  
        }  
        // 更新数据库  
        this.orderItemMapper.update(item);  
    }  
}

和所描述的操作流程完全一致,成功使用“编程语言”完成了对需求的翻译。

2.2.2. 基于 DDD 的订单改价

核心代码如下:

@Transactional  
public void changeOrderPrice(Long orderId, Long newPrice) {  
    // 1. 校验金额  
    if (newPrice <= 0) {  
        throw new IllegalArgumentException("金额必须大于0");  
    }  
  
    // 2. 获取订单聚合根  
    Optional<Order> orderOpt = this.orderRepository.getById(orderId);  
  
    Order order = orderOpt.orElseThrow(() -> new IllegalArgumentException("订单不存在"));  
  
    // 3. 修改价格  
    order.changePrice(newPrice);  
  
    // 4. 保存 Order 聚合  
    this.orderRepository.save(order);  
  
}  
  
// Order 聚合根内方法  
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);  
}  
  
// 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);  
}

和生单流程一样:

  1. 应用服务中的 changeOrderPrice 方法内容非常简单,标准的模版代码,变化可能性非常小,需要对其进行封装;

  2. 核心逻辑全部在 Order 聚合根中,通过 changePrice 方法 完成改价逻辑,业务逻辑非常集中,形成拥有属性和行为的“富对象”;

  3. 数据操作与逻辑解耦,最后一步操作 orderRepository#save 方法 完成内存对象向DB数据的同步,其他部分均不涉及基础设施;

2.3. 对比

看过这两种风格代码有什么感觉?你可能会说代码也没少些什么,只是组织方式发生了变化。

确实是,只是组织方式发生变化,代码一行都没少。这是这点变化,带来了革命的创新。

来看个新的场景:业务改价过于随意,产品想增加一个环节:填入改价金额后,先把每个订单项的均摊价格展示出来,确认无误后在提交改价请求。

在不同的模式下,又该怎么解呢?

  1. 事务脚本模型下,大概率会 copy 一个新的 changePrice,并在其基础上进行修改。这将产生代码的冗余,比如原来 changePrice 方法存在bug,在修复时你需要修改多处,但往往只会想起一处;

  2. DDD模型下,你只需获取 Order 聚合,然后调用 changePrice 方法,把均摊结果进行返回,便可实现想要的结果;

基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能

  • 项目地址:https://github.com/YunaiV/yudao-cloud

  • 视频教程:https://doc.iocoder.cn/video/

3. 小节

事务脚本 和 领域模型 是承载业务的不同模式,都有各自适用的场景,需要根据自己的需求进行选择。

事务脚本:流程 + 数据,在操作流程中对数据进行操作;

领域模型:编排 + 模型 + 数据,基于模型能力进行编排,以完成业务操作;操作结果暂存于对象中,最后将其同步到数据库;

DDD 灵活性还体现在:

  1. 流程组合,添加一个新功能,一次性完成生单和改价操作;

  2. 封装不变,创建和更新主流程基本一致,可以对其进行封装,以统一操作;

  3. 应用模式,逻辑由聚合对象承接,各种模式都可以拿来使用,比如设计模式、架构模式、领域模式等;

  4. 局部标准化,基于 DDD 战术体系,构建标准的编程模型;


欢迎加入我的知识星球,全面提升技术能力。

👉 加入方式,长按”或“扫描”下方二维码噢

03dbcab93d35a580cadba3d18ee02085.png

星球的内容包括:项目实战、面试招聘、源码解析、学习路线。

32ece91366e3b53935527d20fde4d28a.png

0038ea4d1bb36379a9c3c5f47b66c6ec.png5ded445c18abe2ccea3cdcff3d5aa0e7.pngbe7653c14aa91600e5149892031e76fa.png9b8f2a307b225b296004bc96ed591a71.png

文章有帮助的话,在看,转发吧。
谢谢支持哟 (*^__^*)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值