ddd的战术篇: CQRS

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

关于aggregate与repository的回顾

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

aggregate之间的引用

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

aggregate的缺陷

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

public List viewDeliveryList(UserId userId){
        List deliveries=deliveryRepository.find(new DeliverySpecByUserId(userId));
        List orderIds=deliveries.toStream().map(delivery->delivery.getOrderId()).collect(Collectors.toList());

        List orders=orderRepository.find(new OrderSpecByIds(orderIds));
        List carIds=orders.toStream().flatMap(order->order.getCarIds()).collect(Collectors.toList());
        List cars=carRepository.find(new CarSpecByIds(carIds));

        return List orderDTOs=buildDTO(deliveries, orders, cars);
}

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

public class DeliveryDTO {
    private Long deliveryId;
    private String deliveryStatus;
    private String deliveryAddress;
}

public class OrderDTO {
    private Long orderId;
    private List cars;
    private DeliveryDTO;
}

public class CarDTO {
    private Long carId;
    private Long carModelId;
    private String imageUrl;
}

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

public class Delivery {
  private DeliveryId deliveryId;
}

public class Order {
  private OrderId orderId;
  private Delivery delivery;
  private List cars;
}

public class Car{
  private CarId carId;
  private List tyres;
}

但结果是,这样的实现并不符合我们设计的domain object。而我们在entity中引用了entity(Order中直接引用了Delivery),等于我们设计了一个很大的aggregate,包含了很多entity。当然这又与aggregate的设计策略有关,我们假设这不是我们想要的策略。
而更主要的原因是我们的模型并不契合我们的需求。
我们的需求是什么?就后端要实现的内容来说,我们需要返回画面需要的内容。
而我们的模型–domain model并不是以满足画面显示需求而设计的模型。domain model描述的是业务上的逻辑,所以模型会有很多的行为(类方法)。另外也多次强调过数据完整性这个概念,这也是domain model所关注的。从结论上说,我们倾向比较小的模型(小型aggregate)。
然而要实现画面显示功能时,domain model具体有什么行为,需要保证那些数据完整性显然不是我们关心的。而且模型的大小一般也不是考虑的因素,画面需要的信息我们都必须返回。
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层
ddd的战术篇: CQRS_第9张图片
在application层中,我们将本来的application service分成两种service,command service与query service。

Command处理

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

public OrderCommandService {
  private OrderRepository orderRepository;

  public orderId createOrder(UserId userId, Long carModelId){
    Order order = Order.createOrder(userId, carModelId);
    orderRepository.save(order);
    return order.getOrderId();
  }

  public void cancelOrder(User userId, OrderId orderId){
    Order order = orderRepository.findOrderById(new OrderSpecificationById(orderId));
    if(order.getUserId() != userId) {
      throw new IllegalAccessException();
    }
    order.cancel();
    orderRepository.save(order);
  }
}

Query处理

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

@Component
public class OrderQueryService {
  private final DSLContext jooq;
  public List getOrderList(Long userId){
    return jooq.select()
      .from(ORDER)
      .join(DELIVERY).on(DELIVERY.ORDER_ID.eq(ORDER.ORDER_ID))
      .join(CAR).on(CAR.CAR_ID.eq(ORDER.CAR_ID))
      .where(ORDER.ORDER_ID.eq(userId))
      .fetch()
      .map(record -> 
        OrderDTO.builder()
          .orderId(record.get(ORDER.ORDER_ID))
          .deliveryId(record.get(DELIVERY.ORDER_ID))
          .deliveryAddress(record.get(DELIVERY.ADDRESS))
          .deliveryStatus(record.get(DELIVERY.STATUS))
          .carId(record.get(CAR.CAR_ID))
          .carModelId(record.get(CAR.MODEL_ID))
          .carImageUrl(record.get(CAR.IMAGE_URL))
          .build()
        );
  }
}

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

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

注意,我们这里直接把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的,比如下面的写法是不正确的。

public OrderCommandService {
  private OrderQueryService orderQueryService;
  private OrderRepository orderRepository;

  public void cancelOrder(User userId, OrderId orderId){
    Order order = orderQueryService.findOrderById(orderId); // queryService返回了order这个entity !!!
    if(order.getUserId() != userId) {
      throw new IllegalAccessException();
    }
    order.cancel();
    orderRepository.save(order);
  }
}

这个写法中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》

你可能感兴趣的:(ddd的战术篇: CQRS)