后端开发实践系列之四——简单可用的CQRS编码实践
Every method should either be a command that performs an action, or a query that returns data to the caller, but never both. (一个方法要么作为一个“命令”执行一个操作,要么作为一次“查询”向调用方返回数据,但两者不能共存。)
一个例子
public interface OrderRepository {
void save(Order order);
Order byId(String id);
}
查询某个Order详情,详情中不用包含Order的某些字段;
查询Order列表,列表中所展示的数据比Order详情更少;
根据时间、类别和金额等多种筛选条件查询Order列表;
展示Order中的产品(Product)概要信息,而Product属于另一个业务实体;
展示Order下单人的昵称,下单人信息属于另一个单独的账户系统,用户修改昵称之后,Order下单人昵称也需要相应更新;
......
CQRS实现模式概览
常见误解
查询模型的数据来源
所读数据来源于同一个进程空间的单个实体(后文简称“单进程单实体”),这里的进程空间指某个单体应用或者单个微服务;
所读数据来源于同一个进程空间中的多个实体(后文简称“单进程跨实体”);
所读数据来源于不同进程空间中的多个实体(后文简称“跨进程跨实体”)。
读写模型的分离形式
共享存储/共享模型:读写模型共享数据存储(即同一个数据库),同时也共享代码模型,数查询据通过模型转换后返回给调用方,事实上这不能算CQRS,但是对于很多中小型项目而言已经足够;
共享存储/分离模型:共享数据存储,代码中分别建立写模型和读模型,读模型通过最适合于查询的方式进行建模;
分离存储/分离模型:数据存储和代码模型都是分离的,这种方式通常用于需要聚合查询多个子系统的情况,比如微服务系统。
单进程单实体 + 共享存储/共享模型
单进程单实体 + 共享存储/分离模型
单进程跨实体 + 共享存储/分离模型
单进程跨实体 + 分离存储/分离模型
跨进程跨实体 + 分离存储/分离模型
CQRS编码实践
请注意,本文的示例电商项目只是一个虚构出来的简单项目,仅仅用于演示CQRS的各种编码模式,并不具备实际参考价值。
针对以上各种CQRS模式组合,本文将使用电商系统中的以下业务用例进行演示:
调用方所需的数据模型与领域模型可能不一致;
有些敏感信息是不能返回给调用方的,需要屏蔽;
从设计上讲,领域模型不能直接返回给调用方,否则会产生领域模型的泄露
将领域模型直接返回给调用方会在领域模型与对外接口间产生强耦合,不利于领域模型自身的演进。
Inventory
领域模型定义如下:public class Inventory{
private String id;
private String productId;
private String productName;
private int remains;
private Instant createdAt;
}
productId
和createdAt
字段,于是在Inventory
中创建相应的转换方法如下:public InventoryRepresentation toRepresentation() {
return new InventoryRepresentation(this.id,
this.productName,
this.remains);
}
InventoryRepresentation
即表示读模型,后缀Representation
取自REST中的“R”,表示读模型是一种数据展现,下文将沿用这种命名形式。在InventoryApplicationService
服务中返回InventoryRepresentation
:public InventoryRepresentation byId(String inventoryId) {
return repository
.byId(inventoryId)
.toRepresentation();
}
InventoryApplicationService
,此时的InventoryApplicationService
同时承担了读操作和写操作的业务入口,在实践中也可以将此二者分离开来,即让InventoryApplicationService
只负责写操作,而另行创建InventoryRepresentationService
专门用于读操作。Representation
并对外暴露查询接口。因此每一个聚合根中都会有一个toRepresentation()
方法,该方法仅仅返回当前聚合根的状态,而不会关联其他实体对象(比如下文提到的“单进程跨实体”)。2. 单进程单实体 + 共享存储/分离模型
ProductRepresentationService
,直接从数据库读取数据构建ProductSummaryRepresentation
。@Transactional(readOnly = true)
public PagedResource<ProductSummaryRepresentation> listProducts(int pageIndex, int pageSize) {
MapSqlParameterSource parameters = new MapSqlParameterSource();
parameters.addValue("limit", pageSize);
parameters.addValue("offset", (pageIndex - 1) * pageSize);
List<ProductSummaryRepresentation> products = jdbcTemplate.query(SELECT_SQL, parameters,
(rs, rowNum) -> new ProductSummaryRepresentation(rs.getString("ID"),
rs.getString("NAME"),
rs.getBigDecimal("PRICE")));
int total = jdbcTemplate.queryForObject(COUNT_SQL, newHashMap(), Integer.class);
return PagedResource.of(total, pageIndex, products);
}
Product
,也绕过了其对应的ProductRepository
,以最快速的方式从数据库中直接获取数据。3. 单进程跨实体 + 共享存储/分离模型
Product
和Category
两个聚合根对象, 在查询Product
时,我们希望一并带上Category
的信息,为此创建ProductWithCategoryRepresentation如下:@Value
public class ProductWithCategoryRepresentation {
private String id;
private String name;
private String categoryId;
private String categoryName;
}
ProductRepresentationService
中,直接从数据库获取Product
和Category
数据,此时需要对PRODUCT
和CATEGORY
两张表做join操作:@Transactional(readOnly = true)
public ProductWithCategoryRepresentation productWithCategory(String id) {
String sql = "SELECT PRODUCT.ID, PRODUCT.NAME, CATEGORY.ID AS CATEGORY_ID, CATEGORY.NAME AS CATEGORY_NAME FROM PRODUCT JOIN CATEGORY ON PRODUCT.CATEGORY_ID=CATEGORY.ID WHERE PRODUCT.ID=:productId;";
return jdbcTemplate.queryForObject(sql, of("productId", id),
(rs, rowNum) -> new ProductWithCategoryRepresentation(rs.getString("ID"),
rs.getString("NAME"),
rs.getString("CATEGORY_ID"),
rs.getString("CATEGORY_NAME")));
}
4. 单进程跨实体 + 分离存储/分离模型
ProductWithCategoryRepresentation
为例,假设我们认为先前的join操作太复杂或者太低效了,需要采用专门的数据库来简化查询提升效率。PRODUCT_WITH_CATEGORY
:CREATE TABLE PRODUCT_WITH_CATEGORY
(
PRODUCT_ID VARCHAR(32) NOT NULL,
PRODUCT_NAME VARCHAR(100) NOT NULL,
CATEGORY_ID VARCHAR(32) NOT NULL,
CATEGORY_NAME VARCHAR(100) NOT NULL,
PRIMARY KEY (PRODUCT_ID)
) CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
使用进程内事件机制(比如Guava的EventBus),在与写操作相同的事务中同步,这种方式的好处是可以保证写操作与同步操作的原子性进而确保读写间的数据一致性,缺点是在写操作过程中存在额外的数据库同步开销进而增加了写操作的延迟时间;
使用进程内事件机制,独立事务同步(比如Guava的AsyncEventBus),这种方式的好处是写操作和同步操作彼此独立互不影响,缺点是无法保证二者的原子性进而可能使系统产生脏数据;
使用独立的消息机制(比如RabbitMQ/Kafka等),独立事务同步,可以将查询功能分离为单独的子系统,事实上这种方式已经与“跨进程跨实体 + 分离存储/分离模型”相似,因此请参考“5. 跨进程跨实体 + 分离存储/分离模型”小节。
5. 跨进程跨实体 + 分离存储/分离模型
ecommerce-order-query-service
查询服务,该服务负责接收Order和Product服务发布的领域事件以同步其自身的读模型OrderWithProductRepresentation
。ecommerce-order-query-service
服务中,在接收到OrderEvent
事件后,OrderQueryRepresentationService
负责分别调用Order和Product的接口完成数据同步:public void cqrsSync(OrderEvent event) {
String orderUrl = "http://localhost:8080/orders/{id}";
String productUrl = "http://localhost:8082/products/{id}";
OrderRepresentation orderRepresentation = restTemplate.getForObject(orderUrl, OrderRepresentation.class, event.getOrderId());
List<Product> products = orderRepresentation.getItems().stream().map(orderItem -> {
ProductRepresentation productRepresentation = restTemplate.getForObject(productUrl,
ProductRepresentation.class,
orderItem.getProductId());
return new Product(productRepresentation.getId(),
productRepresentation.getName(),
productRepresentation.getDescription());
}).collect(Collectors.toList());
OrderWithProductRepresentation order = new OrderWithProductRepresentation(
orderRepresentation.getId(),
orderRepresentation.getTotalPrice(),
orderRepresentation.getStatus(),
orderRepresentation.getCreatedAt(),
orderRepresentation.getAddress(),
products
);
dao.save(order);
log.info("CQRS synced order {}.",orderId);
}
ecommerce-order-query-service
查询服务使用了关系型数据库,但在实际应用中应该根据项目所需选择适当的数据存储机制。例如,对于海量数据的查询,可以选择诸如MongoDB或者Cassandra之类的NoSQL数据库;而对于需要进行全文搜索的场景,可以采用Elasticsearch等。读模型和写模型之间不再是强事务一致性,而是最终一致性。
从用户体验上讲,用户发起操作之后将不再立即返回结果数据,此时要么需要调用方(比如前端)进行轮询查询,要么需要在用户体验上做些权衡,比如使用确认页面延迟用户对查询数据的获取。
关于Representation对象的命名
Representation
对象,以Order为例:OrderRepresentation
:仅仅包含聚合根实体自身状态详情,一种常见的形式是通过Order.toRepresentation()
方法获得OrderSummaryRepresentation
:用于返回聚合根的列表,仅仅包含Order本身的状态OrderWithProductRepresentation
:用于返回带有Product数据的Order详情OrderWithProductSummaryRepresentation
:用于返回带有Product数据的Order列表
什么时候该采用CQRS
总结
- 相关阅读 -
点击【阅读原文】可至洞见网站查看原文&绿色字体部分的相关链接。
本文版权属ThoughtWorks公司所有,如需转载请在后台留言联系。