Java反應式事件溯源之第 2 部分:Actor 模型

banq 發表於 2022-01-23
Java

本節我們將解決併發訪問的問題。. 我們的域程式碼非常優雅,但即使我們使用記錄和一些不可變集合,它在多執行緒環境中也不是完全安全的。比如我們要實現在同時預定同一個座位的情況下,一個請求成功一個失敗的保證。如何實施?在大多數情況下,您將在資料庫級別引入某種樂觀(或悲觀)鎖定。在這種情況下,您本身並沒有處理併發。您寧願將此責任轉移到資料庫。這對於中等負載的標準系統來說很好,但我們正在構建一個應該易於擴充套件的反應式解決方案。此外,您僅限於支援此類鎖定的資料庫,但情況並非總是如此。幸運的是,還有其他選項可以處理併發。它'實現Actor Model 的[url=https://doc.akka.io/docs/akka/current/typed/actors.html]Akka 堆疊[/url]。

Actor

Actor是一個非常簡單但聰明的抽象,將幫助您輕鬆構建非常複雜的併發系統。您可以將其視為您所在州的保護者。如果您想從多個執行緒訪問您的狀態,以一種安全的方式,將其包裝在 actor 中並只與 actor 對話。只有通過傳送訊息才能與參與者進行通訊。每個參與者都有自己的訊息框,它一次會消費一條訊息。因此,通過正確的實現,2 個或更多併發執行緒無法對您的狀態執行某些操作。

此外,來自 Akka的永續性參與者(也稱為事件源參與者)能夠以事件的形式持久化其狀態。使用新的型別化 Actor API,您可能還會遇到entity. 它與sharding一起使用,當你想將你的Actor分佈在不同的節點上時。這可能會讓人感到困惑,所以在這一點上,每當您看到 Akka Entity 時,請記住,這實際上是一個Actor。當然,我現在跳過了很多細節,但是為了這一部分,我認為應該足以瞭解發生了什麼。

將所需的依賴項新增到pom.xml後,您就可以建立您的第一個 actor。我們將只關注型別化 API。使用它更安全,建議將其作為預設選擇。請注意,您仍然可以使用舊的經典 API。在功能方面,基本相同,但如果這是您第一次使用 Akka,請堅持使用型別化 API。犯一些愚蠢的錯誤會更難,編譯器會為你檢查很多東西。

要建立一個參與者或實體,我們需要定義它的行為。行為是封裝參與者契約的抽象。如果您向我傳送此訊息,我會回覆,或者根本不回覆,這取決於您想要實現的目標。讓我們檢查一下並實現一個AdderActor.

record Add(int a, int b, ActorRef<Integer> replyTo) {
}

public class AdderActor extends AbstractBehavior<Add> {

    public AdderActor(ActorContext<Add> context) {
        super(context);
    }

    @Override
    public Receive<Add> createReceive() {
        return newReceiveBuilder().onMessage(Add.class, add -> {
            int result = add.a() + add.b();
            add.replyTo().tell(result);
            return Behaviors.same();
        }).build();
    }
}

我們需要擴充套件AbstractBehavior,並將其與我們的輸入訊息Add一起輸入。輸入的訊息是一條記錄,其中包含ints a和b,以及對需要回復結果的詢問行為體的引用。處理這樣一個訊息是非常簡單的。在訊息的新增上,首先,用結果進行回覆(告訴方法),然後返回相同的行為。所有的東西都是型別化的,所以沒有辦法用例如String來回復,編譯器會為你檢查合同。

 

Event sourced行為

使用來自 Akka 庫的更高階別的抽象,對於事件溯源,有一種特殊的行為,稱為EventSourcedBehaviour。因為我們想為每條訊息傳送一個回覆,我們可以強制編譯器使用EventSourcedBehaviorWithEnforcedReplies. 事件源行為使用 3 種其他型別引數化:Command、Event、State。看起來很眼熟,你不覺得嗎?但是,這裡有一個小的變化。我們不使用我們的域命令,而是使用一個包含域命令和回覆所需的Actor引用的信封ShowCommandEnvelope。

public sealed interface ShowEntityCommand extends Serializable {

    record ShowCommandEnvelope(ShowCommand command, ActorRef<ShowEntityResponse> replyTo) implements ShowEntityCommand {
    }
}

為什麼它有用呢?首先,我們的領域沒有被Akka庫觸及,其次,我們以後會新增一些與我們的領域無關的命令。響應也是用ShowEntityResponse來型別化的。

public sealed interface ShowEntityResponse extends Serializable {

    final class CommandProcessed implements ShowEntityResponse {
    }

    record CommandRejected(ShowCommandError error) implements ShowEntityResponse {
    }
}

我們可以回覆該命令已被處理或該命令因某些錯誤被拒絕。這就是我們的第一個事件源行為契約。

擴充套件EventSourcedBehavior*類需要實現3個方法。

@Override
public Show emptyState() {...}

@Override
public CommandHandlerWithReply<ShowEntityCommand, ShowEvent, Show> commandHandler() {...}

@Override
public EventHandler<Show, ShowEvent> eventHandler() {...}

 

ReplyEffect

因為我們只有一個命令,所以commandHandler的實現非常簡單。

public CommandHandlerWithReply<ShowEntityCommand, ShowEvent, Show> commandHandler() {
    return newCommandHandlerWithReplyBuilder().forStateType(Show.class)
        .onCommand(ShowEntityCommand.ShowCommandEnvelope.class, this::handleShowCommand)
        .build();
}

有趣的部分是從handleShowCommand方法開始的。

private ReplyEffect<ShowEvent, Show> handleShowCommand(Show show, ShowEntityCommand.ShowCommandEnvelope envelope) {
    ShowCommand command = envelope.command();
    return show.process(command, clock).fold(
        error -> {
            return Effect().reply(envelope.replyTo(), new CommandRejected(error));
        },
        events -> {
            return Effect().persist(events.toJavaList())
                .thenReply(envelope.replyTo(), s -> new CommandProcessed());
        }
    );
}

這是我們將我們的事件源庫與事件源域合併的地方。

要做到這一點,我們需要處理來自信封的域命令。有兩種可能的結果。在出錯的情況下,效果只是回覆發件人說命令被拒絕。如果命令處理成功,我們將得到一個事件列表。

如前文所述,在我們用CommandProcessed訊息回覆之前,我們需要儲存這些事件。

程式碼中沒有明確說明的是(雖然有很好的記錄),對於所有的持久化事件,將呼叫eventHandler方法,然後Akka Persistence將執行thenReply部分。由於EventSourcedBehaviorWithEnforcedReplies,編譯器會檢查我們是否返回ReplyEffect而不是簡單的Effect。

為什麼要在引擎蓋下呼叫eventHandler?因為這樣,你就不需要記住在所有的命令處理程式中使用它。另外,在恢復實體的過程中,當你需要載入給定聚合的所有事件並逐一應用它們時,這個方法會被呼叫。

public EventHandler<Show, ShowEvent> eventHandler() {
    return newEventHandlerBuilder()
        .forStateType(Show.class)
        .onAnyEvent(Show::apply);
}

在這種情況下,實現更加直接。我們只是將所有事件重定向到Show::apply域方法。

擴充套件EventSourcedBehavior*不僅為我們提供了處理命令、持久化和應用事件的良好抽象。它還以被動的方式保護狀態免受併發訪問。參與者執行緒不會被與資料庫的通訊阻塞。它將在底層事件儲存響應後立即恢復工作。從那時起,它將使用更復雜的Stash機制版本緩衝所有命令。這基本上是一個的實現單個寫入器的原理,建立真正的高效能一致的系統。

 

實體測試

好的,是時候執行它並測試它了。要建立一個Actor,您需要一個Actor系統。Actor系統就像Actor的“家”。必須管理所有參與者、它們的生命週期、排程策略等。您可以將其視為管理 Spring Bean 的 Spring Context。

對於測試,您不必手動建立 Actor System。您可以使用非常方便的實用程式,例如ActorTestKit某些單元測試配置覆蓋的某些預設配置。不要忘記在所有測試後將其關閉。

private static final ActorTestKit testKit =        ActorTestKit.create(EventSourcedBehaviorTestKit.config().withFallback(UNIT_TEST_AKKA_CONFIGURATION));

@AfterAll
public static void cleanUp() {
    testKit.shutdownTestKit();
}

 

為了測試我們的事件源行為,至少有 2 種策略。

白盒測試

使用EventSourcedBehaviorTestKit的白盒測試非常好,因為您可以斷言任何您想要的東西。不僅是響應,還有持久化的事件,以及actor內部的狀態。

@Test
public void shouldReserveSeat() {
    //given
    var showId = ShowId.of();
    EventSourcedBehaviorTestKit<ShowEntityCommand, ShowEvent, Show> showEntityKit = EventSourcedBehaviorTestKit.create(testKit.system(), ShowEntity.create(showId, clock));
    var reserveSeat = randomReserveSeat(showId);

    //when
    var result = showEntityKit.<ShowEntityResponse>runCommand(replyTo -> toEnvelope(reserveSeat, replyTo));

    //then
    assertThat(result.reply()).isInstanceOf(CommandProcessed.class);
    assertThat(result.event()).isInstanceOf(SeatReserved.class);
    var reservedSeat = result.state().seats().get(reserveSeat.seatNumber()).get();
    assertThat(reservedSeat.isReserved()).isTrue();
}

 

黑盒測試

第二種策略是黑盒方法,您可以在其中與參與者互相呼叫並僅檢查響應。Actor內部發生的事情對你是隱藏的。這種策略更接近Actor的生產使用。要從參與者那裡獲得響應,您需要使用稱為 testing 的probe,它將模擬傳送者參與者。

//given
var showId = ShowId.of();
var showEntityRef = testKit.spawn(ShowEntity.create(showId, clock));
var commandResponseProbe = testKit.<ShowEntityResponse>createTestProbe();

var reserveSeat = randomReserveSeat(showId);

//when
showEntityRef.tell(toEnvelope(reserveSeat, commandResponseProbe.ref()));

//then
commandResponseProbe.expectMessageClass(CommandProcessed.class);

 

行為設定

到目前為止,我沒有涉及的一件事是ShowEntity.create方法。

public static Behavior<ShowEntityCommand> create(ShowId showId,
                                                 Clock clock) {
    return Behaviors.setup(context -> {
        PersistenceId persistenceId = PersistenceId.of("Show", showId.id().toString());
        return new ShowEntity(persistenceId, showId, clock, context);
    });
}

要建立一個Actor或實體,無論是通過 testKit.spoon 還是 EventSourcedBehaviorTestKit.create 方法,我們都需要傳遞 Behavior[T]。為此,我們使用了一個靜態工廠方法。這個方法正在呼叫我們的私有建構函式,並用Behaviors.setup對其進行包裝,我們可以返回Behavior[ShowEntityCommand]。PersistenceId(傳遞給建構函式)是一個鍵,我們將在這個鍵下儲存所有的事件,對於一個給定的集合,在資料庫中(這是下一課的主題)。

如果這讓你感到困惑——別擔心。花點時間,分析程式碼,執行測試,閱讀日誌。

 

與大多數教程相比,領域與技術(Actor)的東西是分開的。這種分離使您能夠在將來從 Akka Persistence 切換到其他東西。域將保持不變。這很可能永遠不會發生,但最好在程式碼庫中分離關注點。通過這種方式新增新功能和改進應用程式將更加愉快。

請檢視part_2標記中的完整原始碼。