使用 Spring Modulith 實現 CQRS
1.概述
在本文中,我們將重新審視 CQRS 模式,探討其在模組化 Spring Boot 應用程式中的優缺點。我們將使用 Spring Modulith 將程式碼建構成清晰分離的模組,並在它們之間實現非同步、事件驅動的通訊。
這種方法的靈感來自於我們同事Gaetano Piazzolla 的文章,他在產品目錄中示範如何使用 Spring Modulith 實現 CQRS。在這裡,我們將同樣的想法應用到電影票預訂系統中,並透過領域事件保持兩端同步。
2. 彈簧模量
Spring Modulith 幫助我們將 Spring Boot 應用程式建構成清晰且鬆散連接的模組。它鼓勵圍繞特定業務領域而非技術**問題**對每個模組進行建模,類似於垂直切片架構。此外,Spring Modulith 還包含用於驗證和測試模組之間邊界的工具,我們將在程式碼範例中使用這些工具。
讓我們先將[spring-modulith-core](https://mvnrepository.com/artifact/org.springframework.modulith/spring-modulith-core)
依賴項加入我們的pom.xml
檔中:
<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 函式庫提供了一組註解,有助於突顯元件的架構角色。 Spring Modulith 也使用了它的一些模組。雖然我們的用例並非嚴格要求,但讓我們繼續導入[jmolecules-cqrs-architecture](https://mvnrepository.com/artifact/org.jmolecules/jmolecules-cqrs-architecture)
模組:
<dependency>
<groupId>org.jmolecules</groupId>
<artifactId>jmolecules-cqrs-architecture</artifactId>
<version>1.10.0</version>
</dependency>
現在,讓我們建立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
}
}
4.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
}
}
4.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<Movie, Long> {
List<UpcomingMovies> findUpcomingMoviesByStartTimeBetween(Instant start, Instant end);
// ...
}
最後,讓我們實作 REST 控制器。由於查詢很簡單,不涉及命令操作的複雜性,我們可以跳過存取領域服務和領域模型的步驟,直接從控制器呼叫儲存庫。
此外,我們透過返回專用查詢模型來避免暴露Movie
實體:
@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 還可以使用事件外部化功能輕鬆地將網域事件轉送到外部訊息代理,只需很少的程式碼變更。
6. 結論
在本教程中,我們回顧了 CQRS 模式背後的核心思想,並探索如何使用邏輯模組(由 Spring Modulith 強制執行)清晰地解耦應用程式領域。我們也使用 jMolecules 函式庫中的註解來強調架構角色,而不是依賴套件結構。
Spring Modulith 透過非同步領域事件幫助我們保持兩個模組的最終一致性。因此,每個模組使用單獨的資料庫表,其中包含針對其特定職責進行最佳化的模型。
與往常一樣,本文中提供的程式碼可在 GitHub 上找到。