Java反應式事件溯源:領域

banq發表於2022-01-23

這篇博文開始了一系列文章,這些文章將從許多不同的角度非常深入地展示事件溯源模式的實現。我即將釋出的帖子背後的主要目標是:
  1. 讓您相信事件溯源並不難實現,
  2. 提供正確的工具來幫助您快速完成這項工作,
  3. 展示如何在沒有任何框架依賴項的情況下對域程式碼進行建模
  4. 解釋如何在沒有樂觀(或悲觀)鎖定的情況下建立一致且可擴充套件的應用程式
  5. 並展示如何使用您最喜歡的堆疊(如 Spring)在 Java 中執行此操作。

 

領域
許多人認為事件溯源模式實現起來非常複雜。我可以對此提出異議,但我知道這可能具有挑戰性,尤其是第一次。對我來說有問題的是,當您根本沒有選擇並且您需要實現非常接近事件溯源或完全事件溯源適應的東西時,但您仍然拒絕遵循這條道路,因為這是新事物,具有挑戰性,而您對此沒有任何經驗。根據我的觀察,這種恐懼驅使您採用一種經典的方法來實現狀態永續性,並帶有一些附加功能。隨著時間的推移,與基於事件溯源的實現相比,這些模仿事件溯源的附加功能變得非常複雜,難以理解和維護,令人驚訝的是,事件溯源可以簡化很多。
讓我們從一個基本理論開始。我建議您跟蹤這個Github 儲存庫。我不想重複所有這些資訊,因為我更喜歡透過實踐學習的方法。理論部分將被最小化。
從領域的角度來看,事件溯源是一種非常簡單的模式。有3個主要構建塊:

  • 命令——定義我們想要在系統中發生的事情,
  • 狀態——它通常是 DDD 方法的聚合,負責保持系統的某些部分一致和有效(聚合不變數)
  • 事件——捕捉系統中發生的事情。

狀態/聚合通常需要提供2 個入口點方法:
  • 列表 程式(命令命令),
  • 狀態應用(事件事件)。

這個系列最難的部分是找到一個好的領域示例,不像大多數教程那樣簡單,但也不太複雜,更多地關注事件溯源部分。想象一下,我們正在建立一個用於銷售電影演出門票的應用程式。在我們簡單的實現中,狀態被建模為 Show 聚合,它代表一個電影節目,您可以在其中預訂座位。顯示記錄包含一個id、title和 的集合seats,在我們的例子中,是一個方便使用的Map,但它可以是任何東西。其他欄位,如節目Show的實際時間,可能還有一些其他欄位被省略。

record Show(ShowId id, String title, Map<SeatNumber, Seat> seats) {

    public static final BigDecimal INITIAL_PRICE = new BigDecimal("100");

    public static Show create(ShowId showId) {
        return new Show(showId, "Show title " + showId.id(), SeatsCreator.createSeats(INITIAL_PRICE));
    }
}


Seat 封裝瞭如下資訊:座位號、狀態和價格:

record Seat(SeatNumber number, SeatStatus status, BigDecimal price)

enum SeatStatus {
    AVAILABLE, RESERVED
}


到目前為止,Show的構建被簡化為只有一種工廠方法,它將使用另一種輔助方法以初始價格填充 10 個可用席位。

現在,讓我們預訂一個座位。要實現這個簡單的用例,首先,我們需要一個命令。

sealed interface ShowCommand extends Serializable {
    ShowId showId();

    record ReserveSeat(ShowId showId, SeatNumber seatNumber) implements ShowCommand {
    }
}


ReserveSeat命令是一條記錄,因為我們不想修改它。它還擴充套件了通用介面ShowCommand。這個介面是密封的,所以它的所有實現都在裡面。我們不允許將其擴充套件到其他任何地方,因為它沒有任何意義。該命令將被process前面提到的方法使用。

public Either<ShowCommandError, List<ShowEvent>> process(ShowCommand command, Clock clock) {
    return switch (command) {
        case ReserveSeat reserveSeat -> handleReservation(reserveSeat, clock);
    };
}


這個方法的簽名對你來說可能很有趣。我們使用Either來自Vavr 庫的型別,因此我們可以在可能失敗的情況下返回ShowCommandError,
或者在命令成功處理的情況下返回事件列表。如果這對您來說是新事物,並且您在業務失敗的情況下丟擲異常,我真的建議您熟悉 Vavr 庫並更廣泛地使用型別系統。
Show聚合也是使用 Vavr 集合(Map 和 List),因此整個狀態是不可變的(雖然不是強制性的,我們將在下一篇文章中討論)。此外,我們將時鐘作為方法引數傳遞。測試這樣的程式碼更容易,因為我們總是可以模擬時鐘。
處理預訂非常簡單。

private Either<ShowCommandError, List<ShowEvent>> handleReservation(ReserveSeat reserveSeat, Clock clock) {
    SeatNumber seatNumber = reserveSeat.seatNumber();
    return seats.get(seatNumber).<Either<ShowCommandError, List<ShowEvent>>>map(seat -> {
        if (seat.isAvailable()) {
            return right(List.of(new SeatReserved(id, clock.now(), seatNumber)));
        } else {
            return left(SEAT_NOT_AVAILABLE);
        }
    }).getOrElse(left(SEAT_NOT_EXISTS));
}


首先,我們需要找到座位,如果這樣的座位不存在,我們可以返回SEAT_NOT_EXISTS錯誤,或者如果座位不可用SEAT_NOT_AVAILABLE。最後,如果有空位,我們可以預訂。實際上,域驗證會更加複雜,只要記住該process方法是適合它的地方。請注意,我們並沒有改變狀態,只是返回一個事件。

sealed interface ShowEvent extends Serializable {
    ShowId showId();

    Instant createdAt();

    record SeatReserved(ShowId showId, Instant createdAt, SeatNumber seatNumber) implements ShowEvent {
    }
}


這可能看起來與命令非常相似,但它完全不同。該SeatReserved事件是我們系統中的一個事實,將儲存在我們的事件儲存中。這是事件溯源的本質,其中命令只是用於操作封裝的方便 DTO。
正如我所提到的,當我們離開 process 方法時,狀態將完全相同。我們沒有改變它。我們不能在這裡改變它。我們正在實現事件溯源模式,所以要習慣我們資料庫中的狀態不再存在的事實。它是透過重播所有持久事件而建立的。在我們改變狀態之前,我們需要持久化一個(或多個)事件,然後我們才能應用它並獲得一個新版本的狀態。這就是為什麼我們需要一個單獨的方法來應用事件。
理解在 process 方法中改變狀態可能會導致非常討厭的錯誤非常重要,其中處理命令後的狀態與從事件重建時的狀態不同。應用事件方法簽名不返回任何錯誤。這一次,如果我們找不到座位,我們將丟擲異常。沒有辦法優雅地處理這種情況。這不是業務錯誤。很可能是實現失敗或併發問題。通常,這不應該發生。實現的另一個關鍵方面是此方法不會有任何副作用,因為這些也將在狀態恢復期間執行。

public Show apply(ShowEvent event) {
    return switch (event) {
        case SeatReserved seatReserved -> applyReserved(seatReserved);
    };
}

private Show applyReserved(SeatReserved seatReserved) {
    Seat seat = getSeatOrThrow(seatReserved.seatNumber());
    return new Show(id, title, seats.put(seat.number(), seat.reserved()));
}

private Seat getSeatOrThrow(SeatNumber seatNumber) {
    return seats.get(seatNumber).getOrElseThrow(() -> new IllegalStateException("Seat not exists %s".formatted(seatNumber)));
}


在我們的例子中,應用SeatReserved將簡單地覆蓋具有保留狀態的席位,所有這些都以不可變的方式進行。
這並不難,你不覺得嗎?當然,仍然缺少很多重要的部分,如永續性、併發訪問等。別擔心,我們稍後會介紹。實際上,我是故意這樣做的,因為我想強調一個想法。您的域程式碼是原始碼中最重要的部分。您將使用的所有框架、資料庫等也是相關的,但不要讓它們決定您應該如何實現您的域。讓這成為我們的座右銘。
 
測試
使用這種方法,我們不需要啟動 Spring Context 或 Actor System(或任何繁重的東西)來測試域程式碼。簡單的單元測試就可以完成這項工作。

private Clock clock = new FixedClock(Instant.now());

@Test
public void shouldReserveTheSeat() {
    //given
    var show = randomShow();
    var reserveSeat = randomReserveSeat(show.id());

    //when
    var events = show.process(reserveSeat, clock).get();

    //then
    assertThat(events).containsOnly(new SeatReserved(show.id(), clock.now(), reserveSeat.seatNumber()));
}


第一個測試是相當明顯的。我們需要檢查返回的事件是否是我們預期的。如您所見,我們使用固定時鐘進行測試。
有時,我們可以應用它們並斷言狀態本身,而不是斷言事件。

//when
var events = show.process(reserveSeat, clock).get();
var updatedShow = apply(show, events);

//then
var reservedSeat = updatedShow.seats().get(reserveSeat.seatNumber()).get();
assertThat(events).containsOnly(new SeatReserved(show.id(), clock.now(), reserveSeat.seatNumber()));
assertThat(reservedSeat.isAvailable()).isFalse();


我們預計updatedShow變數中選定的座位不再可用。其餘測試將在 Github 上提供。
 

擴充套件程式碼
新增另一個命令怎麼樣?我們也想取消我們的預訂。這和以前的故事一樣。我們需要處理CancelSeatReservation命令,返回一個新SeatReservationCancelled事件,並應用這個事件。

public Either<ShowCommandError, List<ShowEvent>> process(ShowCommand command, Clock clock) {
    return switch (command) {
        case ReserveSeat reserveSeat -> handleReservation(reserveSeat, clock);
        case CancelSeatReservation cancelSeatReservation -> handleCancellation(cancelSeatReservation, clock);
    };
}

public Show apply(ShowEvent event) {
    return switch (event) {
        case SeatReserved seatReserved -> applyReserved(seatReserved);
        case SeatReservationCancelled seatReservationCancelled -> applyReservationCancelled(seatReservationCancelled);
    };
}



您可能會注意到我們在 Java 17 中的switch 語句中使用了模式匹配。它仍然是一個預覽功能,因此您需要明確啟用它。可能在生產環境中,您可以堅持 if 語句中的模式匹配,但我也想在建立這些示例時獲得一些樂趣。使用密封Seal介面,一旦您新增了新的命令或事件,Java 編譯器將突出顯示您需要更新程式碼的所有地方。
 
這就是第一部分。完整的原始碼可在此處獲得,記得檢視part_1標籤

相關文章