1. 概述
本文將對 CQRS 模式進行深入探討,並分析其在模塊化 Spring Boot 應用中的優勢和權衡。我們將使用 Spring Modulith 對代碼進行結構化,將其劃分為明確分離的模塊,並啓用它們之間異步、事件驅動的通信。
這種方法受到了我們同事 Gaetano Piazzolla 的文章的啓發,他在其中展示了使用 Spring Modulith 在產品目錄中實現 CQRS。在這裏,我們將採用相同的思路,應用於電影票預訂系統,並通過領域事件保持兩端的數據同步。
2. Spring Modulith
Spring Modulith 幫助我們構建清晰、鬆耦合的 Spring Boot 應用,將應用分解為明確的模塊。它鼓勵我們圍繞具體的業務領域來建模模塊,而不是關注技術細節,類似於垂直切片架構。此外,Spring Modulith 還包含工具來驗證和測試模塊之間的邊界,我們將在此代碼示例中使用這些工具。
讓我們先在我們的 pom.xml 文件中添加 spring-modulith-core 依賴:
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-core</artifactId>
<version>1.4.2</version>
</dependency>本文檔將構建一個電影票預訂系統的後端。我們將領域劃分為兩個子領域:“電影”和“票務”。“電影”模塊負責電影搜索、影院和座位預訂。 “票務”模塊負責預訂和取消電影票。
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” 包中的任何類,反之亦然。 因此,測試應該順利通過。
3. CQRS
CQRS 代表命令查詢職責分離。 這是一個模式,它在應用程序中將寫入操作(命令)與讀取操作(查詢)分開。 與其使用同一模型進行讀取和寫入數據,不如使用針對其特定任務優化的不同模型。
在 CQRS 中,命令由寫入側處理,該寫入側將數據保存到寫入優化的存儲中。 然後,通過使用領域事件、變更數據捕獲 (CDC) 或其他同步方法來更新讀取模型。 讀取側使用單獨的、查詢優化的結構,以高效地服務查詢:
命令和查詢之間的另一個關鍵區別在於它們的複雜性。 查詢通常很簡單,可以直接訪問讀取存儲以返回數據的特定投影。 相反,命令通常涉及複雜的驗證和業務規則,因此依賴於領域模型來強制執行正確的行為。
4. 實現 CQRS
我們的應用程序中,命令處理包括訂票和取消預訂。具體來説,我們接受 POST 和 DELETE 請求來為特定電影和座位號預訂票,或取消現有預訂。查詢端由電影模塊處理,它通過 GET 端點提供電影搜索、查看影院和檢查座位可用性。
為了保持讀模型與寫側最終一致,我們將使用 Spring Modulith 的事件發佈和處理功能。
4.1. 命令側
首先,讓我們以 Java 記錄的方式定義預訂和取消機票的命令。雖然我們可以將它們放在一個單獨的包中,但這與 Spring Modulith 對按業務能力組織代碼的理念相悖。但是,如果仍然希望明確這些記錄在 CQRS 設置中代表命令,我們可以使用註解。
jMolecules 庫提供了一組註解,允許我們以描述性和技術中立的方式表達架構角色,例如 <em @Command</em>、<em @QueryModel</em> 或 <em @DomainEvent</em>。 在我們的案例中,這些註解純粹是描述性的,並且不會影響應用程序是否正常工作。
然而,它們有助於使架構意圖更加明確,並且我們可以稍後使用它們來編寫架構測試,以強制執行約束。 讓我們將 <em><a href="https://mvnrepository.com/artifact/org.jmolecules/jmolecules-cqrs-architecture">jmolecules-cqrs-architecture</a></em> 和 <em><a href="https://mvnrepository.com/artifact/org.jmolecules/jmolecules-events">jmolecules-events</a></em> 模塊導入到我們的 <em>pom.xml</em> 中:
<dependency>
<groupId>org.jmolecules</groupId>
<artifactId>jmolecules-cqrs-architecture</artifactId>
<version>1.10.0</version>
</dependency>
<dependency>
<groupId>org.jmolecules</groupId>
<artifactId>jmolecules-events</artifactId>
<version>1.10.0</version>
</dependency>現在,讓我們創建 BookTicket 和 CancelTicket Java 記錄,並使用 @Command 註解進行註釋:
@Command
record BookTicket(Long movieId, String seat) {}
@Command
record CancelTicket(Long bookingId) {}最後,讓我們創建一個 <em>TicketBookingCommandHandler</em> 類來處理車票預訂和取消操作。在這裏,我們將執行必要的驗證,並將每個 <em>BookedTicket</em> 對象——無論是在預訂還是取消的情況下——都作為數據庫中的一個單獨的行保存:
@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,可以利用其對處理領域事件的異步支持,以及在原始業務事務之外的支持。雖然事件發佈本身由 Spring 提供,Modulith 增加了跟蹤事件交付成功或失敗的基礎設施,從而確保跨模塊的最終一致性。
首先,我們需要定義 BookingCreated 和 BookingCancelled 領域事件。雖然它們可能看起來與我們在上一部分中定義的命令相似,但領域事件本質上與命令不同。命令是要求某事發生,而領域事件則表明某事已經發生。
為了突出這種差異,我們使用 jMolecule 的 @DomainEvent 註解對我們的領域事件進行標註:
@DomainEvent
record BookingCreated(Long movieId, String seatNumber) {
}
@DomainEvent
record BookingCancelled(Long movieId, String seatNumber) {
}現在,我們需要實例化領域事件並在同一事務中發佈它們,該事務同時將預訂和取消的門票保存到數據庫中。 讓我們使方法具有 <em @Transactional/> 屬性,並使用 通知其他模塊關於這些更新的信息:
@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
}
}“movie”模塊可以使用不同的表、模式,甚至完全獨立的存儲庫。為了簡化演示,我們使用相同的數據庫,但為兩個模塊分別使用不同的表。然而,在處理查詢之前,我們需要確保“movie”模塊監聽並更新由“ticket”模塊發佈事件的數據。
如果使用簡單的 @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 測試仍然會通過——只要依賴關係不是循環的。
4.3. 查詢端
我們還將為我們想要支持的其中一個查詢定義一個投影,並使用 jMolecule 的 標註它:
@QueryModel
record UpcomingMovie(Long id, String title, Instant startTime) {
}如果我們的投影字段名稱與實體字段名稱匹配,Spring Data JPA 可以自動將結果集映射到我們的查詢模型。這使得返回自定義視圖變得容易,而無需編寫手動映射代碼:
@Repository
interface MovieRepository extends CrudRepository<Movie, Long> {
List<UpcomingMovie> findUpcomingMoviesByStartTimeBetween(Instant start, Instant end);
// ...
}最後,讓我們實現 REST 控制器。由於查詢簡單,不涉及命令操作的複雜性,我們可以直接從控制器中調用存儲庫,而無需訪問領域服務和領域模型。
此外,我們通過返回專用查詢模型來避免暴露 Movie 實體。
@RestController
@RequestMapping("/api/movies")
class MovieController {
private final MovieRepository movieScreens;
// constructor
@GetMapping
List<UpcomingMovie> 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 在這裏提供基礎設施來跟蹤事件交付,從而簡化了構建最終一致性系統。然而,重要的是要注意,事件發佈註冊表 (EPR) 不保證事件的排序或可靠交付。它僅跟蹤事件交付是否成功或失敗,並使用 Spring 的標準事件廣播機制。
最後,Spring Modulith 還通過事件外部化功能,使將領域事件轉發到外部消息代理變得容易,並僅需少量代碼修改。
6. 結論
在本教程中,我們回顧了 CQRS 模式的主要思想,並探討了如何使用邏輯模塊清晰地分離應用程序領域,並通過 Spring Modulith 進行強制。我們還利用 jMolecules 庫中的註解來突出架構角色,而不是依賴於包結構。
Spring Modulith 幫助我們保持兩個模塊最終的一致性,通過異步領域事件實現。因此,每個模塊都使用單獨的數據庫表,並針對其特定的職責進行了模型優化。