DDD 對決:事務腳本 vs. 領域模型,哪個才是業務優化的終極方案?
在 CQRS 架構篇提到,由于 Command 和 Query 內部驅動力完全不同,需要在架構層就進行分離,但其中有個一個原則極為重要:
- “讀”再復雜也是簡單;
- “寫”再簡單也是復雜;
可見 Command 遠比 Query 棘手的多,其中最關鍵的便是使用哪種模式來承載業務?
最常見的業務承載模式有:
- 事務腳本。
- 領域模型。
1. 事務腳本 與 領域模型
事務腳本 和 領域模型 都是承載業務的不同模型,都有其適合的場景,沒有絕對的對和錯。核心的決策依據只有一個:選擇最合適的業務場景即可。
簡單且直觀的對兩者進行區分:
- 事務腳本,門檻低上手快,適合簡單的業務場景,比如資訊、博客等;
- 領域模型,門檻很高,適合處理復雜的業務場景,比如電商、銀行、電信等;
大家最常聽說也是最反感的便是:被別人稱為 CRUD boy,更多時候說的便是 事務腳本。
1.1. 事務腳本
事務腳本(Transaction Script)是一種應用程序架構模式,主要用于處理簡單的業務場景。它將業務邏輯和數據庫訪問緊密耦合在一起,以便實現對數據的操作。
事務腳本,將整個業務邏輯封裝在一個事務中,借助數據庫事務來滿足業務操作的 ACID 特性。通過將邏輯和事務封裝在一起,從而簡化應用程序的處理和開發。
下圖是基于事務腳本的生單流程:
圖片
簡單描述就是:將“腳本”(SQL)進行打包,然后放在一個“事務”中運行。這也就是“事務腳本”命名的由來。
接下來,看一個訂單改價流程:
圖片
和生單流程基本一致,在此不做過多介紹。
1.2. 領域模型
領域驅動設計(Domain-Driven Design,DDD)是應對復雜業務場景的利器,它是對業務領域中的關鍵概念和業務規則的抽象。領域模型是一個對象模型,它主要描述各領域對象之間的關系和行為。
和事務腳本不同,領域模型使用對象來承載業務邏輯,領域模型的設計基于業務領域知識,強調領域專家的參與,以提高軟件系統的質量和開發效率。
下圖是基于領域模型的生單流程:
圖片
簡單描述就是:核心業務邏輯全部由對象實現(addItems方法),數據庫僅做數據存儲。
接下來,看下基于DDD的訂單改價流程:
圖片
和生單流程基本一致,核心邏輯由 Order 的 modify price 實現。
相比之下,領域模型就復雜太多,它由多個實體 (Entity)、值對象 (Value Object)、聚合 (Aggregate)、領域服務 (Domain Service)、工廠 (Factory) 等組成,它們共同構成了領域對象模型。在模型中,實體和值對象表示業務中的實際對象,聚合是由多個高內聚實體和值對象形成的組合提,領域服務表示不屬于任何一個實體或值對象的操作,工廠則用于創建復雜的對象,比如實體和值對象等。
1.3. 區別
兩者都是承載業務邏輯的架構,但區別巨大:
- 事務腳本是以流程為中心的設計方法,在數據庫層面執行指令,簡化數據處理的過程;DDD 是以領域對象為中心的設計方法,旨在更好地理解和解決業務問題。
- 事務腳本以技術和流程為重點,以技術為中心,以代碼實現為核心,關注數據處理問題;DDD 則強調模型驅動開發,以業務為中心,以領域模型為核心,關注業務邏輯,并以此為基礎進行技術實現。
- 事務腳本很容易造成代碼的累積,難以維護;DDD 能夠幫助開發人員找到領域的本質(深層模型),并以此為核心,從而形成統一的、易于維護的架構。
除此之外,DDD 還有很多的特點,比如:
- 標準化。DDD 由一組嚴謹的規范組成,有完整的理論基礎,可以實現落地過程的標準化;
- 設計模型。大家在日常工作中很少使用設計模型的根因在于:缺乏應用場景。當你處于“過程式”的開發模式下,只能產出面條代碼;只有面對“面對對象”場景,才能落地設計模式,提升抽象能力;
- 降維打擊。DDD 是從業務需求出發,將業務概念轉化為對象模型,最后通過技術進行落地。這本身就是一種自上而下的設計方式,聚焦于業務,解決真實問題;
2. 實戰體驗
對于程序員來說,文字顯得不夠直觀,在此我們通過代碼來體驗下兩者的不同。
為了更好的體現兩者的區別,將會從兩個場景進行對比:
- 創建場景。圍繞電商下單流程進行說明。
- 更新場景。以電商訂單改價流程為基礎進行說明。
在日常開發中,物理刪除場景用的非常少,甚至很多公司都明令禁止使用“delete”語句。通常使用 “邏輯刪除” 替代,它可歸屬為標準的更新場景,在此暫不對比 物理刪除場景。
2.1. 創建場景:下單
在電商中,一個標準的下單需求主要包括:
- 對商品庫存進行校驗,避免出現超賣的情況;
- 對商品庫存進行鎖定,如果支付成功則直接扣減鎖定的庫存;如果支付失敗,則對鎖定的庫存進行歸還;
- 為每一種購買的商品生成一個訂單項(OrderItem),記錄商品單價、購買數量、需付總價、應付金額等;
- 為每一筆下單生成一個訂單(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;
}
}
和事務腳本相比,由以下幾點不同:
- 應用服務中的 createOrder 方法內容非常簡單,可以看做是模版代碼,變化的可能性非常小,可以對其進行進一步的封裝;
- 核心邏輯全部在 Order 聚合根中,通過靜態方法 create 完成 Order 對象的創建,業務邏輯非常集中,形成了擁有屬性和行為的“富對象”;
- 數據操作與邏輯解耦,最后一步操作 orderRepository#save 方法 完成內存對象向DB數據的同步,其他部分均不涉及基礎設施;
2.2. 更新場景:訂單改價
在電商中,訂單改價主要包括:
- 修改訂單項價格(OrderItem),根據商品要支付金額對新價格按比例進行均攤;
- 修改訂單價格(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);
}
和生單流程一樣:
- 應用服務中的 changeOrderPrice 方法內容非常簡單,標準的模版代碼,變化可能性非常小,需要對其進行封裝;
- 核心邏輯全部在 Order 聚合根中,通過 changePrice 方法 完成改價邏輯,業務邏輯非常集中,形成擁有屬性和行為的“富對象”;
- 數據操作與邏輯解耦,最后一步操作 orderRepository#save 方法 完成內存對象向DB數據的同步,其他部分均不涉及基礎設施;
2.3. 對比
看過這兩種風格代碼有什么感覺?你可能會說代碼也沒少些什么,只是組織方式發生了變化。
確實是,只是組織方式發生變化,代碼一行都沒少。這是這點變化,帶來了革命的創新。
來看個新的場景:業務改價過于隨意,產品想增加一個環節:填入改價金額后,先把每個訂單項的均攤價格展示出來,確認無誤后在提交改價請求。
在不同的模式下,又該怎么解呢?
- 事務腳本模型下,大概率會 copy 一個新的 changePrice,并在其基礎上進行修改。這將產生代碼的冗余,比如原來 changePrice 方法存在bug,在修復時你需要修改多處,但往往只會想起一處;
- DDD模型下,你只需獲取 Order 聚合,然后調用 changePrice 方法,把均攤結果進行返回,便可實現想要的結果;
3. 小節
事務腳本 和 領域模型 是承載業務的不同模式,都有各自適用的場景,需要根據自己的需求進行選擇。
事務腳本:流程 + 數據,在操作流程中對數據進行操作;
領域模型:編排 + 模型 + 數據,基于模型能力進行編排,以完成業務操作;操作結果暫存于對象中,最后將其同步到數據庫;
DDD 靈活性還體現在:
- 流程組合,添加一個新功能,一次性完成生單和改價操作;
- 封裝不變,創建和更新主流程基本一致,可以對其進行封裝,以統一操作;
- 應用模式,邏輯由聚合對象承接,各種模式都可以拿來使用,比如設計模式、架構模式、領域模式等;
- 局部標準化,基于 DDD 戰術體系,構建標準的編程模型;
想了解 DDD 的精髓,讓我們進入下一篇:初識極簡DDD。