为什么需要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模式的应用场景、优势及实施细节,提升系统架构的可维护性和扩展性。