Spring Modulith 通过异步领域事件帮助我们保持两个模块的最终一致性。因此,每个模块使用单独的数据库表,其中包含针对其特定职责进行优化的模型。
在本文中,我们将重新审视CQRS 模式,探讨其在模块化 Spring Boot 应用中的优缺点。我们将使用Spring Modulith将代码构建为清晰分离的模块,并在它们之间实现异步、事件驱动的通信。
演示如何使用 Spring Modulith 实现 CQRS。在这里,我们将同样的思路应用到电影票预订系统中,并通过领域事件保持两端同步。
Spring Modulith
Spring Modulith 帮助我们将 Spring Boot 应用程序构建成清晰且松散连接的模块。它鼓励围绕特定业务领域而非技术 问题对每个模块进行建模,类似于垂直切片架构。此外,Spring Modulith 还包含用于验证和测试模块之间边界的工具,我们将在代码示例中使用这些工具。
让我们首先将spring-modulith-core依赖项添加到我们的pom.xml 文件中:
org.springframework.modulith
spring-modulith-core
1.4.2
在本文中,我们将构建一个电影票预订系统的后端。我们将域名拆分为两个子域名:“电影”和“票”。电影模块负责电影搜索、放映室和座位信息。“票”模块负责预订和取消票务。
Spring Modulith 会验证我们的项目结构,并假定应用程序中的逻辑模块是作为根级别的包创建的。让我们遵循这一理念,将“movie”和“ticket”包直接放在包结构的根目录下:
spring.modulith.cqrs
|-- movie
| |-- MovieController
| |-- Movie
| |-- MovieRepository
| -- ...
-- ticket
|-- BookingTicketsController
|-- BookedTicket
|-- BookedTicketRepository
-- ...
通过此设置,Spring Modulith 可以帮助我们验证模块之间是否存在循环依赖。让我们编写一个测试,扫描基础包,检测应用程序模块,并验证它们之间的交互:
@Test
void whenWeVerifyModuleStructure_thenThereAreNoUnwantedDependencies() {
ApplicationModules.of("com.baeldung.spring.modulith.cqrs")
.verify();
}
此时,我们的模块之间没有任何依赖关系。“movie”包中的任何类都不依赖于“ticket”包中的任何类,反之亦然。因此,测试应该可以顺利通过。
CQRS
CQRS 代表命令查询职责分离。它是一种将应用程序中的写入操作(命令)与读取操作(查询)分离的模式。我们不会使用相同的模型来读取和写入数据,而是使用针对特定任务进行优化的不同模型。
在 CQRS 中,命令由写入端处理,写入端将数据保存到写入优化的存储中。之后,使用领域事件、变更数据捕获 (CDC) 或其他同步方法更新读取模型。读取端使用单独的、查询优化的结构来高效地处理查询:
命令和查询之间的另一个主要区别在于它们的复杂性。查询通常很简单,可以直接访问读取存储以返回数据的特定投影。相比之下,命令通常涉及复杂的验证和业务规则,因此它们依赖于域模型来强制执行正确的行为。
实现CQRS
在我们的应用中,命令处理票务预订和取消。具体来说,我们接受 POST 和 DELETE 请求,用于预订指定电影和座位号的票,或取消现有预订。查询端由电影模块处理,该模块公开了用于搜索电影、查看放映室和查看座位空余情况的 GET 端点。
为了使读取模型最终与写入端保持一致,我们将使用 Spring Modulith 对发布和处理领域事件的支持。
1. 命令端
首先,我们将订票和退票的命令定义为 Java 记录。虽然我们可以将它们放在一个专门的包中,但这样做违背了 Spring Modulith 按业务功能组织代码的理念。但是,如果我们仍然想清楚地表明这些记录代表的是 CQRS 设置中的命令,我们可以使用注解。
jMolecules 库提供了一组注解,有助于突出组件的架构角色。Spring Modulith 也使用了它的一些模块。虽然我们的用例并非严格要求,但让我们继续导入jmolecules-cqrs-architecture模块:
org.jmolecules
jmolecules-cqrs-architecture
1.10.0
现在,让我们创建BookTicket和CancelTicket Java 记录并用@Command注释它们:
@Command
record BookTicket(Long movieId, String seat) {}
@Command
record CancelTicket(Long bookingId) {}
最后,让我们创建一个TicketBookingCommandHandler 类来处理机票预订和取消。在这里,我们将执行必要的验证,并将每个BookedTicket(无论是已预订还是已取消)保存为数据库中的单独行:
@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
}
}
2. 发布领域事件
现在我们已经更新了写入存储,我们还需要确保查询端最终反映相同的状态。由于我们已经在使用 Spring Modulith,我们还可以利用其内置的支持异步发布领域事件,并使用事务发件箱模式处理它们。
首先,我们需要定义BookingCreated和BookingCancelled领域事件。虽然它们看起来与我们上一节中定义的命令类似,但领域事件本质上是不同的。命令是请求执行某件事,而领域事件则表示某件事已经发生。
为了突出这种差异,让我们用 jMolecule 的@DomainEvent注释我们的域事件:
@DomainEvent
record BookingCreated(Long movieId, String seatNumber) {
}
@DomainEvent
record BookingCancelled(Long movieId, String seatNumber) {
}
提醒一下,如果我们希望其他模块可以访问这些事件,它们就需要属于该模块的 API,因此我们应该将它们直接放在“ticket”包中。
最后,我们需要实例化领域事件,并将其从保存已预订和已取消机票的同一事务中发布到数据库中。我们将方法标记为 @Trasactional,并使用ApplicationEventPublisher将这些更新通知给其他模块:
@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
}
}
3. 查询端
读取端可以使用不同的表、模式,甚至完全独立的数据存储。为了简单起见,我们的演示对两个模块使用相同的数据库,但使用不同的表。但在处理查询之前,我们需要确保“电影”模块监听“票务”模块发布的事件并更新其数据。
如果我们使用简单的@EventListener,更新操作将与写入操作在同一个事务中运行。虽然这确保了原子性,但它使更新和写入操作紧密耦合,限制了可扩展性。
相反,我们可以使用 Spring Modulith 的@ApplicationModuleListener,它可以异步监听事件。这允许读取端独立更新,并使用事务发件箱模式来确保事件不会丢失,从而保持系统最终一致性:
@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对其进行注释:
@QueryModel
record UpcomingMovies(Long id, String title, Instant startTime) {
}
如果我们的投影字段名称与实体字段名称匹配,JPA 可以自动将结果集映射到我们的查询模型。这使得返回自定义视图变得非常容易,而无需编写手动映射代码:
@Repository
interface MovieRepository extends JpaRepository {
List findUpcomingMoviesByStartTimeBetween(Instant start, Instant end);
// ...
}
最后,让我们实现 REST 控制器。由于查询很简单,不涉及命令操作的复杂性,我们可以跳过访问领域服务和领域模型的步骤,直接从控制器调用存储库。
此外,我们通过返回专用查询模型来避免暴露电影实体:
@RestController
@RequestMapping("/api/movies")
class MovieController {
private final MovieRepository movieScreens;
// constructor
@GetMapping
List moviesToday(@RequestParam String range) {
return movieScreens.findUpcomingMoviesByStartTimeBetween(now(), endTime(range));
}
@GetMapping("/{movieId}/seats")
ResponseEntity movieSeating(@PathVariable Long movieId) {
return ResponseEntity.of(
movieScreens.findAvailableSeatsByMovieId(movieId));
}
private static Instant endTime(String range) { /* ... */ }
}
CQRS 带来了关注点分离和更好的可扩展性等好处,但也增加了复杂性。维护单独的读写模型意味着需要更多的代码和协调。CQRS的一个关键挑战是最终一致性。由于读取端异步更新,用户可能会短暂地看到过时的数据。
另一方面,通过领域事件进行异步通信使我们的应用程序更具可扩展性。如果其他模块需要对已预订或已取消的票务做出反应,它们只需监听这些事件即可,而无需更改现有逻辑。
最后,Spring Modulith 还可以使用事件外部化功能轻松地将域事件转发到外部消息代理,只需很少的代码更改。