在本教程中,我想演示 CQRS模式与SpringBoot这是一个微服务的设计模式,以独立地扩展读取和写入的应用程序的工作负载和有很好的优化数据架构。
CQRS模式:
1.读写模型:
本质上,大多数应用程序都是CRUD。设计这些应用程序时,我们为CRUD操作创建实体类和相应的存储库类。我们对所有CRUD操作使用相同的模型类。但是,这些应用程序可能具有完全不同的读取和写入要求!
例如:让我们考虑一个应用程序,其中有3个表,如下所示。
所有这些表均已标准化。创建新用户或产品或订单可快速直接地转到相应的表。但是,如果考虑到READ要求,我们将不只是希望所有用户,产品,而是所有订单。相反,我们将有兴趣了解用户的所有订单详细信息,按州分类的总销售额,按州分类和按产品分类的销售额等。涉及多个表联接的大量汇总信息。所有这些连接READ操作也可能需要相应的DTO映射。
我们所做的标准化程度更高,更容易编写,但更难阅读,进而影响整体读取性能。
尽管我们说WRITE很容易,但是在将记录插入数据库之前,我们可能会进行业务验证。所有这些逻辑都可能存在于模型类中。因此,我们可能最终会创建一个非常复杂的模型,该模型试图同时支持READ和WRITE。
应用程序可能具有完全不同的读取和写入要求。因此,为WRITE创建的模型可能不适用于READ。为了解决这个问题,我们可以为读取和写入建立单独的模型。
2.读写流量:
大多数基于Web的应用程序阅读量很大。让我们以Facebook / Twitter为例。无论我们是否发布任何新更新,我们都每天都会非常频繁地检查这些应用程序。当然,由于数据库中的某些插入,我们不断在那些应用程序中获取更新。但是它比WRITE读得多。另外,考虑预订机票的应用程序。可能不到5%的用户可以预订机票,而大多数应用程序用户将继续搜索满足其需求的最佳航班。
与WRITE操作相比,应用程序对READ操作的请求更多。为了解决这个问题,我们甚至可以为READ和WRITE提供单独的微服务。因此,可以根据需要独立地放大/缩小它们。 这就是CQRS模式所代表的含义,它代表命令查询责任隔离模式。
- 命令:修改数据且不返回任何内容(写)
- 查询:不修改,但返回数据(读取)
那就是将应用程序的命令(写)模型和查询(读)模型分开,以独立地扩展应用程序的读写操作。我们可以使用CQRS模式解决上述2个问题。让我们看看如何实现它。
案例应用
让我们考虑一个简单的应用程序,其中有3个服务,如下所示。(理想情况下,所有这些服务都应具有不同的数据库。在本文中,我使用的是同一数据库)。目前,我们仅对本文中与订单服务相关的功能感兴趣。
我们为此应用提供了3个数据表,如下所示。
CREATE TABLE users( id serial PRIMARY KEY, firstname VARCHAR (50), lastname VARCHAR (50), state VARCHAR(10) );
CREATE TABLE product( id serial PRIMARY KEY, description VARCHAR (500), price numeric (10,2) NOT NULL );
CREATE TABLE purchase_order( id serial PRIMARY KEY, user_id integer references users (id), product_id integer references product (id), order_date date );
|
假设我们有一个用于订单服务的接口,用于读取和写入操作,如下所示。
public interface OrderService { void placeOrder(int userIndex, int productIndex); void cancelOrder(long orderId); List<PurchaseOrderSummaryDto> getSaleSummaryGroupByState(); PurchaseOrderSummaryDto getSaleSummaryByState(String state); double getTotalSale(); }
|
- 它具有多项职责,例如下订单,取消订单和查询产生不同类型结果的表。
- 取消订单可能涉及其他业务逻辑,例如订单日期应在30天之内以及部分退款计算等。
CQRS模式–读写接口
让我们将它们分成两个不同的接口,而不是只有一个接口负责所有的READ和WRITE操作,如下所示。
- 查询服务处理所有READ要求
- 命令服务处理修改数据的所有其他要求
查询服务:
public interface OrderQueryService { List<PurchaseOrderSummaryDto> getSaleSummaryGroupByState(); PurchaseOrderSummaryDto getSaleSummaryByState(String state); double getTotalSale(); }
|
命令服务:
public interface OrderCommandService { void createOrder(int userIndex, int productIndex); void cancelOrder(long orderId); }
|
CQRS模式–读写实现:
分离命令和查询接口后,让我们执行操作。
@Service public class OrderQueryServiceImpl implements OrderQueryService {
@Autowired private PurchaseOrderSummaryRepository purchaseOrderSummaryRepository;
@Override public List<PurchaseOrderSummaryDto> getSaleSummaryGroupByState() { return this.purchaseOrderSummaryRepository.findAll() .stream() .map(this::entityToDto) .collect(Collectors.toList()); }
@Override public PurchaseOrderSummaryDto getSaleSummaryByState(String state) { return this.purchaseOrderSummaryRepository.findByState(state) .map(this::entityToDto) .orElseGet(() -> new PurchaseOrderSummaryDto(state, 0)); }
@Override public double getTotalSale() { return this.purchaseOrderSummaryRepository.findAll() .stream() .mapToDouble(PurchaseOrderSummary::getTotalSale) .sum(); }
private PurchaseOrderSummaryDto entityToDto(PurchaseOrderSummary purchaseOrderSummary){ PurchaseOrderSummaryDto dto = new PurchaseOrderSummaryDto(); dto.setState(purchaseOrderSummary.getState()); dto.setTotalSale(purchaseOrderSummary.getTotalSale()); return dto; } }
|
订单命令服务
- 这是简单的插入到PURCHASE_ORDER它的业务逻辑表,修改数据-它不返回任何东西。
命令与查询–控制器:
总体而言,项目结构如下所示。如果您仔细看一下,您可能会注意到我有2个不同的Order Service控制器。
我们有用于查询(READ)和命令(WRITE)的专用控制器。
我们甚至可以根据属性值控制应用程序应在读取模式还是写入模式下工作。
订单查询控制器:
我的订单查询控制器的实现如下所示,其中只有GET请求。它不会做任何修改数据的事情。
@RestController @RequestMapping("po") @ConditionalOnProperty(name = "app.write.enabled", havingValue = "false") public class OrderQueryController {
@Autowired private OrderQueryService orderQueryService;
@GetMapping("/summary") public List<PurchaseOrderSummaryDto> getSummary(){ return this.orderQueryService.getSaleSummaryGroupByState(); }
@GetMapping("/summary/{state}") public PurchaseOrderSummaryDto getStateSummary(@PathVariable String state){ return this.orderQueryService.getSaleSummaryByState(state); }
@GetMapping("/total-sale") public Double getTotalSale(){ return this.orderQueryService.getTotalSale(); }
}
|
订单命令控制器:@RestController @RequestMapping("po") @ConditionalOnProperty(name = "app.write.enabled", havingValue = "true") public class OrderCommandController {
@Autowired private OrderCommandService orderCommandService;
@PostMapping("/sale") public void placeOrder(@RequestBody OrderCommandDto dto){ this.orderCommandService.createOrder(dto.getUserIndex(), dto.getProductIndex()); }
@PutMapping("/cancel-order/{orderId}") public void cancelOrder(@PathVariable long orderId){ this.orderCommandService.cancelOrder(orderId); } }
|
application.yaml看起来像这样以READ模式运行该应用程序。
spring: datasource: url: jdbc:postgresql://localhost:5432/productdb username: vinsguru password: admin app: write: enabled: false
|
当然,我们可以更改以下属性以在WRITE模式下运行该应用程序。
CQRS模式–缩放比例:
现在,我们已经成功地拆分了READ和WRITE模型。现在,我们需要能够独立扩展系统的能力。让我们看看如何实现这一目标。
在运行时创建Spring Bean:
在查询和命令控制器上,我为Spring Boot添加了是否创建此控制器的条件。也就是说,仅当app.write.enabled设置为true时,以下注释才有助于创建控制器。否则,它将不会创建此控制器bean。
//for WRITE controller @ConditionalOnProperty(name = "app.write.enabled", havingValue = "true")
|
如果该值设置为false,它将创建此控制器。
//for READ controller @ConditionalOnProperty(name = "app.write.enabled", havingValue = "false")
|
因此,根据属性,我们可以更改应用程序的行为,使其类似于只读节点或只写节点。它使我们能够以不同的模式运行一个应用程序的多个实例。我可以有1个我的应用程序实例来进行编写,而我可以有多个应用程序实例仅用于满足读取请求。它们可以独立缩放。我们可以将它们放置在像nginx这样的负载平衡器/代理的后面,以便可以使用基于路径的路由或其他机制将READ / WRITE请求转发到适当的实例。
命令与查询数据库:
在上面的示例中,我们使用了相同的数据库。我们甚至可以通过将数据库分开进行读取和写入来进一步上一层,如下所示。也就是说,任何写入操作都将通过事件源将更改推送到读取数据库。
CQRS模式带来了其他好处,例如针对具有优化的数据模式的同一应用程序使用不同的数据库。但是请注意,这种方法最终将使数据保持一致!由于我们可以使用其自己的数据库运行读取节点的多个实例,因此我们甚至可以将它们放在不同的区域中,从而以最小的延迟提供用户体验。
总结:
除了易于维护和扩展的好处外,CQRS模式还提供了并行开发等其他好处。也就是说,两个不同的开发人员/团队可以在微服务上一起工作。当一个人可以在查询端工作而另一人可以在命令端工作时。
源代码在这里