Ohhnews

分类导航

$ cd ..
Baeldung原文

使用 Spring Modulith 实现 CQRS 模式

#cqrs#spring modulith#spring boot#领域事件#模块化架构

[LOADING...]

1. 概述

在本文中,我们将重新审视 CQRS 模式,探讨其在模块化 Spring Boot 应用程序中的优势和权衡。我们将使用 Spring Modulith 将代码组织成清晰分离的模块,并实现它们之间的异步、事件驱动通信。

此方法受到我们同事 Gaetano Piazzolla 文章的启发,他在文章中演示了如何使用 Spring Modulith 在产品目录中实现 CQRS。在这里,我们将相同的思想应用于电影票预订系统,并通过领域事件使两端保持同步。

2. Spring Modulith

Spring Modulith 帮助我们将 Spring Boot 应用程序构建成清晰且松散耦合的模块。它鼓励围绕特定业务领域而非技术关注点来建模每个模块,这与 垂直切片架构 类似。此外,Spring Modulith 还包含用于验证和测试模块之间边界的工具,我们将在代码示例中使用这些工具。

首先,让我们将 spring-modulith-core 依赖项添加到我们的 pom.xml 文件中:

$ xml
<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-core</artifactId>
    <version>1.4.2</version>
</dependency>

在本文中,我们正在构建一个电影票预订系统的后端。我们将领域划分为两个子领域:“电影”和“票务”。电影模块管理电影搜索、放映厅和座位可用性。“票务”模块负责票务预订和取消。

Spring Modulith 验证我们的项目结构,并假定应用程序中的逻辑模块是在根级别创建的包。 让我们遵循这一理念,将“movie”和“ticket”包直接放在包结构的根目录下:

$ plaintext
spring.modulith.cqrs
|-- movie
|   |-- MovieController
|   |-- Movie
|   |-- MovieRepository
|   `-- ...
`-- ticket
    |-- BookingTicketsController
    |-- BookedTicket
    |-- BookedTicketRepository
    `-- ...

通过这种设置,Spring Modulith 可以帮助我们验证模块之间没有循环依赖。 让我们编写一个测试,扫描基本包,检测应用程序模块,并验证它们之间的交互:

$ java
@Test
void whenWeVerifyModuleStructure_thenThereAreNoUnwantedDependencies() {
    ApplicationModules.of("com.baeldung.spring.modulith.cqrs")
      .verify();
}

此时,我们的模块之间没有依赖关系。“movie”包中的任何类都不依赖于“ticket”包中的任何类,反之亦然。因此,测试应该顺利通过。

3. CQRS

CQRS 代表命令查询职责分离(Command Query Responsibility Segregation)。它是一种在应用程序中分离写入操作(命令)和读取操作(查询)的模式。我们不为读取和写入数据使用相同的模型,而是使用针对其特定任务优化的不同模型。

在 CQRS 中,命令由写入端处理,写入端将数据保存到写入优化存储中。之后,使用领域事件、变更数据捕获(CDC)或其他同步方法更新读取模型。读取端使用单独的、查询优化结构来高效地服务查询:

[LOADING...]

命令和查询之间的另一个关键区别在于它们的复杂性。查询通常很简单,可以直接访问读取存储以返回数据的特定投影。相比之下,命令通常涉及复杂的验证和业务规则,因此它们依赖领域模型来强制执行正确的行为。

4. 实现 CQRS

在我们的应用程序中,命令处理票务预订和取消。具体来说,我们接受 POST 和 DELETE 请求,用于预订给定电影和座位号的票,或取消现有预订。查询端由电影模块处理,该模块公开 GET 端点用于搜索电影、查看放映厅和检查座位可用性。

为了使读取模型最终与写入端保持一致,我们将利用 Spring Modulith 对异步发布和处理领域事件的支持。

4.1. 命令端

首先,让我们将预订和取消票的命令定义为 Java 记录。虽然我们可以将它们放在专用包中,但这样做与 Spring Modulith 按业务能力组织代码的理念相悖。但是,如果仍想明确这些记录在 CQRS 设置中代表命令,我们可以使用注解。

jMolecules 库 提供了一组注解,有助于突出我们组件的架构角色。Spring Modulith 也使用它的一些模块。虽然这对于我们的用例并非严格要求,但我们还是导入 jmolecules-cqrs-architecture 模块:

$ xml
<dependency>
    <groupId>org.jmolecules</groupId>
    <artifactId>jmolecules-cqrs-architecture</artifactId>
    <version>1.10.0</version>
</dependency>

现在,让我们创建 BookTicketCancelTicket Java 记录,并用 @Command 注解它们:

$ java
@Command
record BookTicket(Long movieId, String seat) {}
@Command
record CancelTicket(Long bookingId) {}

最后,让我们创建一个 TicketBookingCommandHandler 类来处理票务预订和取消。在这里,我们将执行必要的验证,并将每个 BookedTicket——无论是已预订还是已取消——作为单独的行保存到数据库中:

$ java
@Service
class TicketBookingCommandHandler {
    private final BookedTicketRepository bookedTickets;
    // logger, constructor
    public Long bookTicket(BookTicket booking) {
        // validate payload
        // validate seat availability
        // ...
        BookedTicket bookedTicket = new BookedTicket(booking.movieId(), booking.seat());
        bookedTicket = bookedTickets.save(bookedTicket);
        return bookedTicket.getId();
    }
    public Long cancelTicket(CancelTicket cancellation) {
         // validate payload 
         // verify if the ticket can be cancelled 
         // save the cancelled ticket to DB
    }
}

4.2. 发布领域事件

现在我们已经更新了写入存储,还需要确保查询端最终反映相同的状态。由于我们已经在使用 Spring Modulith,我们还可以利用其内置的异步发布领域事件的支持,并使用事务性发件箱模式来处理它们。

首先,我们需要定义 BookingCreatedBookingCancelled 领域事件。虽然它们可能看起来与我们在上一节中定义的命令相似,但领域事件本质上是不同的。命令是发起某事发生的请求,而领域事件则表示某事已经发生。

为了突出这种差异,让我们用 jMolecule 的 @DomainEvent 注解我们的领域事件:

$ java
@DomainEvent
record BookingCreated(Long movieId, String seatNumber) {
}
@DomainEvent
record BookingCancelled(Long movieId, String seatNumber) {
}

提醒一下,如果希望这些事件可供其他模块访问,它们需要属于模块的 API,因此我们应该将它们直接放在“ticket”包中。

最后,我们需要实例化领域事件,并在我们将已预订和已取消的票保存到数据库的同一事务中发布它们。让我们将这些方法标记为 @Transactional,并使用 ApplicationEventPublisher 来通知其他模块这些更新:

$ java
@Service
class TicketBookingCommandHandler {
    private final BookedTicketRepository bookedTickets;
    private final ApplicationEventPublisher eventPublisher;
    // logger, constructor
    @Transactional
    public Long bookTicket(BookTicket booking) {
        // validate payload
        // validate seat availability
        // ...
        BookedTicket bookedTicket = new BookedTicket(booking.movieId(), booking.seat());
        bookedTicket = bookedTickets.save(bookedTicket);
    
        eventPublisher.publishEvent(
          new BookingCreated(bookedTicket.getMovieId(), bookedTicket.getSeatNumber()));
        return bookedTicket.getId();
    }
    @Transactional
    public Long cancelTicket(CancelTicket cancellation) {
        // validate payload
        // verify if the ticket can be cancelled
        // save the cancelled ticket to DB
        // publish BookingCancelled domain event
    }
}

4.3. 查询端

读取端可以使用不同的表、schema,甚至完全独立的数据存储。为简单起见,我们的演示使用相同的数据库,但对两个模块使用不同的表。但在处理查询之前,我们需要确保“movie”模块监听“ticket”模块发布的事件并更新其数据。

如果使用简单的 @EventListener,更新将在与写入端相同的事务中运行。虽然这确保了原子性,但它紧密耦合了两端并限制了可伸缩性。

相反,我们可以使用 Spring Modulith 的 @ApplicationModuleListener,它异步监听事件。 这允许读取端独立更新,并使用事务性发件箱模式来确保事件不会丢失,从而使系统最终保持一致:

$ java
@Component
class TicketBookingEventHandler {
    private final MovieRepository screenRooms;
    // constructor
    @ApplicationModuleListener
    void handleTicketBooked(BookingCreated booking) {
        Movie room = screenRooms.findById(booking.movieId())
          .orElseThrow();
        room.occupySeat(booking.seatNumber());
        screenRooms.save(room);
    }
    @ApplicationModuleListener
    void handleTicketCancelled(BookingCancelled cancellation) {
        Movie room = screenRooms.findById(cancellation.movieId())
          .orElseThrow();
        room.freeSeat(cancellation.seatNumber());
        screenRooms.save(room);
    }
}

通过这样做,我们引入了两个模块之间的依赖关系。以前,它们是独立的,但现在“movie”模块监听“ticket”模块发布的领域事件。这完全没问题,只要依赖不是循环的,我们的 Spring Modulith 测试仍然会通过。

我们还为我们想要支持的一个查询定义一个投影,并用 jMolecule 的 @QueryModel 注解它:

$ java
@QueryModel
record UpcomingMovies(Long id, String title, Instant startTime) {
}

如果我们的投影字段名称与实体字段名称匹配,JPA 可以自动将结果集映射到我们的查询模型。这使得返回自定义视图变得容易,而无需编写手动映射代码:

$ java
@Repository
interface MovieRepository extends JpaRepository<Movie, Long> {
    List<UpcomingMovies> findUpcomingMoviesByStartTimeBetween(Instant start, Instant end);
    // ...
}

最后,让我们实现 REST 控制器。由于查询很简单,不涉及命令操作的复杂性,我们可以跳过访问领域服务和领域模型,直接从控制器调用存储库。

此外,我们通过返回专用的查询模型来避免暴露 Movie 实体:

$ java
@RestController
@RequestMapping("/api/movies")
class MovieController {
    private final MovieRepository movieScreens;
    // constructor
   
    @GetMapping
    List<UpcomingMovies> moviesToday(@RequestParam String range) {
        return movieScreens.findUpcomingMoviesByStartTimeBetween(now(), endTime(range));
    }
    @GetMapping("/{movieId}/seats")
    ResponseEntity<AvailableMovieSeats> movieSeating(@PathVariable Long movieId) {
        return ResponseEntity.of(
          movieScreens.findAvailableSeatsByMovieId(movieId));
    }
    private static Instant endTime(String range) { /* ... */ }
}
## 5. 权衡

CQRS 带来了关注点分离和更好的可伸缩性等好处,但它也增加了复杂性。为读取和写入维护单独的模型意味着更多的代码和协调。**CQRS 中的一个关键挑战是最终一致性。由于读取端异步更新,用户可能会短暂地看到过时的数据。**

另一方面,通过领域事件进行的异步通信使我们的应用程序更具可扩展性。如果其他模块需要对已预订或已取消的票做出反应,它们只需监听这些事件——而无需更改现有逻辑。

最后,**Spring Modulith 还通过[事件外部化](https://www.baeldung.com/spring-modulith-event-externalization)功能,以最少的代码更改,轻松地将领域事件转发到外部消息代理。**

## 6. 结论

在本教程中,我们回顾了 CQRS 模式背后的主要思想,并探讨了如何使用逻辑模块(通过 Spring Modulith 强制执行)清晰地解耦应用程序领域。我们还使用 jMolecules 库中的注解突出显示了架构角色,而不是依赖包结构。

Spring Modulith 帮助我们通过异步领域事件使两个模块最终保持一致。因此,**每个模块都使用独立的数据库表,其中包含针对其特定职责优化的模型。**

一如既往,本文中提供的代码可在 [GitHub](https://github.com/eugenp/tutorials/tree/master/spring-boot-modules/spring-boot-libraries-3) 上获取。