知識庫 / Spring RSS 訂閱

使用 Spring Modulith 實現 CQRS

Architecture,Spring
HongKong
4
10:43 AM · Dec 06 ,2025

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>

現在,讓我們創建 BookTicketCancelTicket 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 增加了跟蹤事件交付成功或失敗的基礎設施,從而確保跨模塊的最終一致性

首先,我們需要定義 BookingCreatedBookingCancelled 領域事件。雖然它們可能看起來與我們在上一部分中定義的命令相似,但領域事件本質上與命令不同。命令是要求某事發生,而領域事件則表明某事已經發生

為了突出這種差異,我們使用 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 幫助我們保持兩個模塊最終的一致性,通過異步領域事件實現。因此,每個模塊都使用單獨的數據庫表,並針對其特定的職責進行了模型優化。

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.