為什么需要CQRS,它能解決什么問題?
為什么需要CQRS?
圖片
在領域驅動設計(DDD)中,業務邏輯的基本處理流程通常如下:接口層接收業務請求,進行參數校驗后,調用應用服務執行業務編排。在應用服務中,加載聚合根,接著由領域對象處理業務邏輯,最后通過基礎設施層更新領域對象。
然而,在實際開發中,我們經常遇到一些復雜的查詢需求,比如分頁查詢、非業務標識符的條件查詢以及多表關聯查詢。這些需求往往涉及到多個聚合根,并且在查詢時不一定需要加載完整的聚合根。
例如,在之前的章節中,我通過擴展倉儲接口來支持條件查詢,如訂單服務的倉儲接口定義:
public interface OrderRepository extends Repository<TradeOrder, OrderId> {
/**
* 根據訂單編號查詢訂單
* @param orderSn 訂單號
* @return 訂單聚合
*/
TradeOrder findOrderByTransaction(String customerId);
/**
* 修改訂單狀態
* @author jam
* @date 2023/12/19 9:07
* @param orderSn 訂單編號
* @param status 訂單狀態
*/
void changeStatus(String orderSn, OrderStatusEnum status);
/**
* 分頁查詢
* @param customerId 用戶ID
* @param pageRequest 分頁請求體
*/
PageResponse<TradeOrder> pageQuery(Long customerId, PageRequest pageRequest);
}
這個設計存在一定問題:倉儲接口的職責變得不再單一。根據DDD的設計理念,Repository主要負責維護聚合根的生命周期,然而在這里,它同時承擔了分頁查詢職能,這與其單一職責原則相悖。每當我們需要新增查詢功能時,都需要在領域層的倉儲接口中增加新方法,導致接口變得越來越復雜。
為了保持倉儲接口的單一職責,我們需要將查詢操作與聚合根的生命周期管理分離。CQRS(命令查詢職責分離) 就是為了解決這個問題。
查詢與聚合根的關系
聚合根代表了事務的一致性邊界,倉儲接口需要確保在加載時獲取聚合根的完整狀態以保證數據的準確性。然而,許多查詢操作,如分頁查詢和條件查詢,往往只需要讀取聚合根的一部分數據,而不需要修改它的狀態。在這種情況下,加載整個聚合根的狀態不僅會導致不必要的性能開銷,還可能使查詢變得更復雜和低效。
因此,在只查詢而不修改的場景下,其實沒必要完整的加載聚合根。接下來,我們將引入CQRS來解決這個問題。
什么是CQRS?
CQRS(Command Query Responsibility Segregation,命令查詢職責分離)是一種架構模式,它通過將修改操作(命令,Command)與查詢操作(查詢,Query)分開,使用不同的模型來分別處理這兩類操作,從而實現命令與查詢的分離。
在領域驅動設計(DDD)中引入CQRS后,應用層的職責被明確分為兩個部分:
- 命令應用服務(Command Application Service):負責處理寫操作,如創建、更新和刪除。
- 查詢應用服務(Query Application Service):負責處理讀操作,包括數據查詢和展示。
引入CQRS后,命令和查詢操作在應用層使用不同的模型進行處理:
- 在命令應用服務中,依舊使用領域模型來執行業務操作。通過倉儲(Repository)加載完整的聚合根,并由聚合根修改其內部狀態來實現業務邏輯。
- 在查詢應用服務中,使用專門的數據模型來處理查詢操作。這些數據模型直接從數據庫讀取數據,并將結果展示給用戶。查詢操作不涉及領域邏輯,只關注高效的數據檢索和展示,可以直接使用基礎設施層的額數據模型和ORM接口來完成操作。
實際上,CQRS并非DDD獨有的概念,無論是否使用領域驅動設計,都會推薦采用CQRS架構。具體而言,在三層架構中,可以將Service層拆分為不同職責的模型;在DDD的四層架構中,則將應用服務(Application Service)拆分為命令服務和查詢服務。
CQRS的實現
CQRS的實現通常分為相同數據源模式和異構數據源模式,兩者適用于不同的業務場景。
相同數據源的CQRS
在這種模式下,命令服務和查詢服務共用同一套數據源。命令操作通過領域模型完成,查詢操作則通過數據模型實現。由于數據源相同,CQRS的實現相對簡單,且能夠滿足大部分業務場景需求。
如下圖所示:
圖片
異構數據源的CQRS
雖然相同數據源模式可以滿足大多數業務需求,但在某些場景下,為了優化性能、解決特定問題,可能會引入其他數據存儲中間件,將業務數據的副本存儲在新的數據源中,從而形成異構數據源。這時,命令操作和查詢操作將分別由不同的數據源承接。
示例:
以訂單查詢為例,為了提高查詢性能,訂單領域在創建訂單后,可以通過 binlog 將 MySQL 數據同步到 Elasticsearch,查詢操作則直接從 Elasticsearch 獲取數據。這就是典型的異構數據源 CQRS 模式。
圖片
注意:異構數據源不一定是兩種不同的數據中間件。例如,即使兩個數據源都是 MySQL,只要表結構不同,也可以被視為異構數據源。
部署方式
在實際應用中,CQRS 架構可以根據項目需求靈活部署:
- 同一應用內實現:命令服務和查詢服務共存于同一個應用中,適用于簡單場景。
- 物理隔離部署:將命令服務和查詢服務拆分為不同的應用,獨立部署,適用于高并發、大規模業務場景。
在Dailymart改造CQRS模式
以訂單模塊為例,看看如何實踐CQRS模式,以下為實踐步驟:
1、拆分應用服務
將原應用服務接口OrderService拆成兩個服務,分別是OrderCommandService 和 OrderQueryService,將分頁接口定義遷移到OrderQueryService中,OrderCommandService 中只包含聚合的加載和更新操作。
public interface OrderQueryService {
/**
* 分頁查詢
* @author jam
* @date 2024/12/17 14:56
*/
PageResponse<OrderRespDTO> findListByUserId(OrderPageQueryDTO pageRequest);
}
public interface OrderCommandService {
/**
* 創建訂單
* @param orderCreateRequest 創建訂單參數
*/
String createOrder(OrderCreateRequest orderCreateRequest);
/**
* 訂單發貨
*/
String ship(String orderSn);
/**
* 加載訂單詳情
*/
OrderRespDTO getOrderBySn(String orderSn);
}
2、實現查詢服務: 使用 MyBatis-Plus 進行分頁查詢并轉換 DO 到 DTO
在查詢服務中,我們直接使用 MyBatis-Plus 提供的 selectPage 方法進行分頁查詢,并通過 OrikaUtils.convertList 方法將數據庫對象DO轉換為DTO。
@Service
@Slf4j
public class OrderQueryServiceImpl implements OrderQueryService {
@Resource
private OrderMapper orderMapper;
@Override
public PageResponse<OrderRespDTO> findListByUserId(OrderPageQueryDTO pageRequest) {
Page<OrderDO> page = new Page<>(pageRequest.getCurrent(), pageRequest.getSize());
LambdaQueryWrapper<OrderDO> queryWrapper = Wrappers.lambdaQuery(OrderDO.class).eq(OrderDO::getCustomerId, pageRequest.getCustomerId());
Page<OrderDO> selectedPage = orderMapper.selectPage(page, queryWrapper);
List<OrderDO> records = selectedPage.getRecords();
Map<String,String> refMap = new HashMap<>(1);
//map key 放置 源屬性,value 放置 目標屬性
refMap.put("orderId","id");
//Do -> Dto
List<OrderRespDTO> pageList = OrikaUtils.convertList(records, OrderRespDTO.class,refMap);
return new PageResponse<>(pageRequest.getCurrent(), pageRequest.getSize(), selectedPage.getTotal(), pageList);
}
}
3、刪除倉儲接口中關于分頁查詢的接口
Repository的職責應集中在持久化和聚合的加載上。分頁查詢不應包含在倉儲接口中。通過移除分頁查詢方法來簡化倉儲接口的設計,使其專注于聚合根的生命周期管理。
public interface OrderRepository extends Repository<TradeOrder, OrderId> {
/**
* 根據訂單編號查詢訂單
* @param orderSn 訂單號
* @return 訂單聚合
*/
TradeOrder load(String orderSn);
}
4、修改訂單接口層的調用方式,分別使用不同的應用服務完成業務操作。
@RestController
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
@Tag(name = "OrderController", description = "C端訂單模塊")
public class OrderController {
private final OrderCommandService orderCommandService;
private final OrderQueryService orderQueryService;
...
}
小結
本文詳細介紹了CQRS模式的基本概念及其實現方式,重點分析了在DailyMart項目中如何通過實踐CQRS架構對訂單模塊進行改造。希望通過本文的講解,能夠幫助你更好地理解CQRS模式的應用場景、優勢及實施細節,提升系統架構的可維護性和擴展性。