之前的文章介绍了ddd在战术层面的要素,包括entity,value object,aggregate和一些设计模式如repository。在ddd中,repository几乎成为了等同于entity一样的基本要素。

关于aggregate与repository的回顾

aggregate是entity和value object的聚合,它定义了一个边界,在此边界中,数据必须保证数据完整性。
aggregate有一个根root entity,从这个root entity可以获取aggregate内部的其他entity和value object
【20201009】DDD的战术篇: CQRS - 图1
如图所示,entity 1是aggregate的root entity。
repository是存放和获取aggregate的地方。一种aggregate对应一种repository。
【20201009】DDD的战术篇: CQRS - 图2
如图,Car与Bicycle都是aggregate,它们各自对应CarRepository与BicycleRepository。
然后Car有4个轮子,所以Car这个aggregate必须保证它有4个轮子的数据完整性。
【20201009】DDD的战术篇: CQRS - 图3
把这些都关联起来,我们可以话成这样的图
【20201009】DDD的战术篇: CQRS - 图4

aggregate之间的引用

aggregate之间是通过entity的ID来引用的。Car这个entity,有一个CarId的value object作为Car的识别符。
现在我们想象要做一个购物平台,其中处理订单是我们的一个业务。那我们自然而然会想到需要一个表示订单的domain object。那我们就创建一个Order的entity。Order通过CarId来引用Car这个aggregate
【20201009】DDD的战术篇: CQRS - 图5
如果还有关于运输的需求。我们为此创建一个叫Delivery的domain object。(当然这个例子有点搞大了,很难想象一般消费者能像买纸巾一样买车子…)
【20201009】DDD的战术篇: CQRS - 图6

aggregate的缺陷

如果按照ddd提倡的建模方式,我们会比较自然地得出上面的模型。当然Aggregate的范围是可以有讨论余地的。比如把所有entity都扔进一个aggregate中。这方面的讨论可以参考下面的文章。
aggregate的设计策略
我们现在以上面的模型为前提来说说我们可能会遇到的问题。
如果我们需要实现一个订单列表的功能。
【20201009】DDD的战术篇: CQRS - 图7
简而言之,我们需要获取Delivery,Order和Car的信息。
下面是代码

  1. public List<OrderDTO> viewDeliveryList(UserId userId){
  2. List<Delivery> deliveries=deliveryRepository.find(new DeliverySpecByUserId(userId));
  3. List<OrderId> orderIds=deliveries.toStream().map(delivery->delivery.getOrderId()).collect(Collectors.toList());
  4. List<Order> orders=orderRepository.find(new OrderSpecByIds(orderIds));
  5. List<CarId> carIds=orders.toStream().flatMap(order->order.getCarIds()).collect(Collectors.toList());
  6. List<Car> cars=carRepository.find(new CarSpecByIds(carIds));
  7. return List<OrderDTO> orderDTOs=buildDTO(deliveries, orders, cars);
  8. }

用于画面显示的DTO类,constructors, getter就省略了。

  1. public class DeliveryDTO {
  2. private Long deliveryId;
  3. private String deliveryStatus;
  4. private String deliveryAddress;
  5. }
  6. public class OrderDTO {
  7. private Long orderId;
  8. private List<CarDTO> cars;
  9. private DeliveryDTO;
  10. }
  11. public class CarDTO {
  12. private Long carId;
  13. private Long carModelId;
  14. private String imageUrl;
  15. }

大致的处理是从各个repository中获取entity(aggregate)。最后把取得的3种entity拼装成画面需要的DeliveryDTO。
那有什么问题吗?直观的感觉是对数据库进行了3次查询,而实际上如果使用关系型数据库的话,明明可以用一个集联查询来实现的东西,用了3次查询显得有些笨拙。
表面上看可能是我们选择ORM和模型设计的问题。比如如果是jpa的话,下面的模型设计是可以减少查询数量的。

  1. public class Delivery {
  2. private DeliveryId deliveryId;
  3. }
  4. public class Order {
  5. private OrderId orderId;
  6. private Delivery delivery;
  7. private List<Car> cars;
  8. }
  9. public class Car{
  10. private CarId carId;
  11. private List<Tyre> tyres;
  12. }

但结果是,这样的实现并不符合我们设计的domain object。而我们在entity中引用了entity(Order中直接引用了Delivery),等于我们设计了一个很大的aggregate,包含了很多entity。当然这又与aggregate的设计策略有关,我们假设这不是我们想要的策略。
而更主要的原因是我们的模型并不契合我们的需求。
我们的需求是什么?就后端要实现的内容来说,我们需要返回画面需要的内容。
而我们的模型–domain model并不是以满足画面显示需求而设计的模型。domain model描述的是业务上的逻辑,所以模型会有很多的行为(类方法)。另外也多次强调过数据完整性这个概念,这也是domain model所关注的。从结论上说,我们倾向比较小的模型(小型aggregate)。
然而要实现画面显示功能时,domain model具体有什么行为,需要保证那些数据完整性显然不是我们关心的。而且模型的大小一般也不是考虑的因素,画面需要的信息我们都必须返回。
【20201009】DDD的战术篇: CQRS - 图8
这个图引用了《patterns, principles, and practices of domain-driven design》一书的CQRS部分的插图。
书中的观点是,读与写本来就会有不同的需求,需要各自的模型。它将用于画面查询的模型称作view model,而view model与domain model是处理不同问题的,当我们使用不合适的模型来处理问题时,显然就会觉得比较变扭。

CQRS

既然模型不合适,我们就可以选择合适的模型。其实就订单列表这个功能来说,我们是不是已经有了需要的模型?对,DeliveryDTO!问题在于,我们是用domain object转成DTO的。自然我们可以想到,又没有办法跳过domain object这个步骤呢?这里我们就要说说CQRS这个思想。
CQRS,全称Command Query Responsibility Segregation。直译过来就是命令查询的职责分离。
Command指的是增加/更新数据的处理。
Query指的是查询数据的处理。它不会造成数据的变更。
我们将这两种处理用不同的模型来应对。
这又和ddd有什么关系呢?刚才说过ddd的domain model关注实际的业务行为以及业务上的数据完整性等问题。而这些问题在增加/更新数据时起着较大的作用。也就是说Command和ddd的契合度比较高。
而对复杂的画面提供数据这种功能,一般来说它对业务行为和数据完整性没有很多的要求,所以Query并比一定需要domain object。

如何实现

首先,CQRS已经超过了ddd的范畴,它属于如何使用ddd的一种策略,或者说在Command处理时使用ddd,而在Query处理时则使用更合理的实现方式。
那具体实现层面,介绍一种做法。
之前说过ddd的一种分层方法是分成
presentation层,application层,domain层,infra层
【20201009】DDD的战术篇: CQRS - 图9
在application层中,我们将本来的application service分成两种service,command service与query service。

Command处理

首先是command处理。command service会专门负责command处理。command处理包括创建与更新类型的处理。比如例子中下订单,取消订单的功能就会属于command service。

  1. public OrderCommandService {
  2. private OrderRepository orderRepository;
  3. public orderId createOrder(UserId userId, Long carModelId){
  4. Order order = Order.createOrder(userId, carModelId);
  5. orderRepository.save(order);
  6. return order.getOrderId();
  7. }
  8. public void cancelOrder(User userId, OrderId orderId){
  9. Order order = orderRepository.findOrderById(new OrderSpecificationById(orderId));
  10. if(order.getUserId() != userId) {
  11. throw new IllegalAccessException();
  12. }
  13. order.cancel();
  14. orderRepository.save(order);
  15. }
  16. }

Query处理

query部分的处理采用的方法是不通过domain model,直接获取数据。概念是这样,实际上的实现是多种多样,一个重要的因素是用来与数据库交互的框架。个人感觉一些能直接写sql语句的框架是比较不错的选择。比如一个叫jooq的框架。
我们用jooq来写一个查询某个用户的订单列表的例子

  1. @Component
  2. public class OrderQueryService {
  3. private final DSLContext jooq;
  4. public List<OrderDTO> getOrderList(Long userId){
  5. return jooq.select()
  6. .from(ORDER)
  7. .join(DELIVERY).on(DELIVERY.ORDER_ID.eq(ORDER.ORDER_ID))
  8. .join(CAR).on(CAR.CAR_ID.eq(ORDER.CAR_ID))
  9. .where(ORDER.ORDER_ID.eq(userId))
  10. .fetch()
  11. .map(record ->
  12. OrderDTO.builder()
  13. .orderId(record.get(ORDER.ORDER_ID))
  14. .deliveryId(record.get(DELIVERY.ORDER_ID))
  15. .deliveryAddress(record.get(DELIVERY.ADDRESS))
  16. .deliveryStatus(record.get(DELIVERY.STATUS))
  17. .carId(record.get(CAR.CAR_ID))
  18. .carModelId(record.get(CAR.MODEL_ID))
  19. .carImageUrl(record.get(CAR.IMAGE_URL))
  20. .build()
  21. );
  22. }
  23. }

其实我们就像写一条query语句一样

  1. select * from order o
  2. inner join delivery d on o.order_id = d.order_id
  3. inner join car c on order.car_id = c.car_id
  4. where o.user_id = ${userId}• 1

注意,我们这里直接把query的结果转换成了画面需要的dto(用于画面显示的模型,我们也可称作view model)。
我们可以总结成一个原则,query service接受参数返回dto。query service中不允许存在domain object,如entity,value object, repository等。

注意点

query service与command service不要相互调用

query,与command他们服务于不同的目的,使用的模型也不同,所以他们应该没有交集。
例子中command service中引用domain object(entity, value object, repository, specification),而query service使用了view model(例子中我们使用了dto)与query(jooq)。
请注意不是所有查询操作都要使用query service的,比如下面的写法是不正确的。

  1. public OrderCommandService {
  2. private OrderQueryService orderQueryService;
  3. private OrderRepository orderRepository;
  4. public void cancelOrder(User userId, OrderId orderId){
  5. Order order = orderQueryService.findOrderById(orderId); // queryService返回了order这个entity !!!
  6. if(order.getUserId() != userId) {
  7. throw new IllegalAccessException();
  8. }
  9. order.cancel();
  10. orderRepository.save(order);
  11. }
  12. }

这个写法中query service返回了domain object。这是违反了query service的原则。当我们进行的command操作需要检索时,我们还是必须通过repository来对数据库进行检索。

可以将QueryService定义为接口

如果你觉对在queryService中出现了infra的实现不太满意的话,也可以使用控制反转,在application层定义queryService接口,在infra层进行实现。

使用queryService时,不要涉及业务逻辑

使用queryService有一个隐含的前提。之前提到过使用合适的model来处理合适的问题。因为查询这样的业务通常不涉及业务逻辑(domain),所以我们自然不需要domain model。
然而即使画面显示这样的功能有时候也是会涉及业务逻辑的。比如说我们沿用上面购买汽车的例子。我们假设有一个订单确认的画面,画面中需要显示订单价格,其中价格的计算比较复杂,它包含促销打折等要素。
当然这样的实现方法会有很多种,我们说一个不太好的实现方式。那就是在query service中查询是否有促销打折,然后根据情况对订单的价格进行计算。价格计算显然是一种业务逻辑,他应该在domain object中实现。
具体的解决方法,个人觉得也有多种多样,最简单的就是虽然它是用于画面显示的功能,我们也不使用query service而把它写进一个application service中(不是command service)。当然这样会增加application层的复杂度,增加了一个service的种类。
另一种方法是我们在订单确认前就允许order的创建。用一个状态表示它未确认,已确认。order中的金额事先计算好。这样query service就可以查询到order表中的数据。这里可以根据实际的业务需求进行选择。

总结

CQRS的思想史把操作分成query, command两种操作,用各自合适的模型与框架来实现两种处理,query处理时我们可以选择mybatis, jooq这种抽象度较低,能直接写query的框架。
CQRS一方面让query的处理更加的高效,同时他会增加程序设计的复杂度。你必须判断什么样的查询需要使用query service。而当项目有一定规模时,查询可能和业务逻辑不能很轻易的分离,也会阻碍query service的使用。
所以你可以选择在处理效率遇到瓶颈时才引入query service。或者选择在功能设计时尽量避免查询功能涉及业务逻辑,但后者往往不完全取决于工程师的意见,具体的需求也是重要的因素,这不仅考验程序员的程序设计能力,还要要求工程师有沟通能力去与domian expert(具体对业务有了解,或者设计产品的人)交涉。

参考

《patterns, principles, and practices of domain-driven design》