Java反應式事件溯源之第3部分:服務

banq 發表於 2022-01-23
Java

本節主要主題是將ShowEntity隱藏在一個不錯的服務包裝下。否則,與Actor互動所需的邏輯將在許多地方重複,這總是一個壞主意。六邊形角度的角度來看,ShowService這將是我們的埠,將來可供任何介面卡使用。

 

查詢實體

在此之前,我們將從非常簡單的事情開始。有時我們想向實體詢問一些資料以供閱讀。為了使它成為可能,我們需要新增另一個命令GetShow。現在,我知道你在想什麼。這不是一個命令,它更像是一個查詢。是的,這是真的。因此,所有內容都是型別化的,我們需要遵循通用合同。合同是我們的事件源實體將接受任何擴充套件的訊息ShowEntityCommad。閉上眼睛,把GetShow它當作一個命令來獲取一些資料。

public sealed interface ShowEntityCommand extends Serializable {

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

    record GetShow(ActorRef<Show> replyTo) implements ShowEntityCommand {
    }
}

處理這樣的命令非常簡單。

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

private ReplyEffect<ShowEvent, Show> returnState(Show show, ShowEntityCommand.GetShow getShow) {
    return Effect().reply(getShow.replyTo(), show);
}

警告:只有當狀態是不可變的時,這種實現才可以,就像我們的例子一樣。狀態可能是可變的,但是在我們響應它之前,我們需要建立一個完整的副本。否則,我們可能會破壞參與者/實體封裝,我們將不再是執行緒安全的。

多虧了這個GetShow命令,我們可以用更多的邏輯來擴充套件我們的黑盒測試,這些邏輯也會在處理完預留座位命令後檢查狀態。

@Test
public void shouldReserveSeat_WithProbe() {
    //given
    var showId = ShowId.of();
    var showEntityRef = testKit.spawn(ShowEntity.create(showId, clock));
    var commandResponseProbe = testKit.<ShowEntityResponse>createTestProbe();
    var showResponseProbe = testKit.<Show>createTestProbe();

    var reserveSeat = randomReserveSeat(showId);

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

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

    //when
    showEntityRef.tell(new ShowEntityCommand.GetShow(showResponseProbe.ref()));

    //then
    Show returnedShow = showResponseProbe.receiveMessage();
    assertThat(returnedShow.seats().get(reserveSeat.seatNumber()).get().isReserved()).isTrue();
}

一些純粹主義者可能會說我們應該為此建立一個讀取模型,而命令處理方不應該負責查詢。當然,我們將在另一篇文章中討論 CQRS,但我更喜歡更實用的方法。從實體讀取資料(通常)是一項非常便宜的操作,所以為什麼不利用這一點。

 

服務

這只是做一些更有趣的事情所需的開端。您可能已經注意到,根據上面的測試,與Actor互動需要一些儀式。我的建議是將它封裝在一個漂亮、乾淨的服務中。我們希望有一個ShowService只公開 3 種方法的方法:

public class ShowService {

    public CompletionStage<Show> findShowBy(ShowId showId) {    }

    public CompletionStage<ShowEntityResponse> reserveSeat(ShowId showId, SeatNumber seatNumber) {}

    public CompletionStage<ShowEntityResponse> cancelReservation(ShowId showId, SeatNumber seatNumber) {  }
}

由於我們正在構建一個反應式的解決方案,返回型別被CompletitionStage包裹,準備與其他階段的處理相結合。返回ShowEntityResponse,它是ShowEntity契約的一部分,可能看起來像一個抽象的洩漏。這取決於慣例,如果這對你來說是有問題的,那就返回一些更具體的服務合同。在這裡,這樣做將是非常人為的,但在某些情況下,這將是更自然的。

 

分片Sharding

該服務的主要職責是隱藏或簡化與ShowEntity的所有通訊。我們將使用Akka Sharding,而不是像我們的測試中那樣手動生成Acto:testKit.spoon(ShowEntity.create(showId, clock))。長話短說,分片讓你有能力在Akka叢集的不同節點上建立(並與Actor對話)。這樣一來,我們就可以非常容易地分散負載,實現分散式單寫原則

等等!這是否意味著我現在需要執行Akka叢集?這並不像你想象的那麼難,但答案是否定的(或者說不完全是)。

我們可以建立一個只有一個節點的單例項叢集。它的行為會像一個標準的應用程式。在未來,如果我們決定要擴充套件並處理更多的流量,那麼我們就可以通過許多節點、滾動更新、動態擴充套件等方式來實現Akka叢集的全部潛力。最重要的是,我們只需要改變配置檔案。負責與Actor對話的程式碼將是完全一樣的。位置透明性實際上是Akka架構的主要驅動力之一。一切都被設計為預設在分散式設定中工作。

要啟用分片,我們需要在application.conf檔案中進行以下設定。

akka {

  actor {
    provider = "cluster"
  }

  cluster {
    seed-nodes = ["akka://[email protected]:2551"]
  }

  remote {
    artery {
      canonical.hostname = 127.0.0.1
      canonical.port = 2551
    }
  }
}

Akka配置使用的是HOCON格式。我個人認為,這比YAML要好得多。在上面的片段中,我們把akka.actor.provider從本地改為叢集。形成叢集所需的種子節點與應用程式的地址相同。遠端協議是artery(一個預設選項)。我們將在後面討論dockerization和擴充套件時詳細介紹這些設定。

為了獲得EntityRef,我們需要用適當的Entity工廠方法來初始化sharding。這可以在服務建構函式中完成。

public ShowService(ClusterSharding sharding, Clock clock) {
    this.sharding = sharding;
    sharding.init(Entity.of(SHOW_ENTITY_TYPE_KEY, entityContext -> {
        ShowId showId = new ShowId(UUID.fromString(entityContext.getEntityId()));
        return ShowEntity.create(showId, clock);
    }));
}

多虧了entityRefFor方法,我們終於可以獲得一個給定的showId的引用。

private EntityRef<ShowEntityCommand> getShowEntityRef(ShowId showId) {
    return sharding.entityRefFor(SHOW_ENTITY_TYPE_KEY, showId.id().toString());
}

同樣的showId將在entityContext.getEntityId()方法中可用。

當我們通過EntityRef傳送訊息時,分片邏輯將檢查ActorSystem中是否已經存在這樣一個實體。

如果沒有,那麼它將獲得如何建立實體的指令(在SHOW_ENTITY_TYPE_KEY下)。使用lambda表示式(上文)來建立它,並將訊息路由到一個新的ShowEntity。

另一方面,如果實體已經存在於ActorSystem中,那麼就不需要額外的步驟了。訊息可以立即被路由。您不必為每個訊息重新建立狀態(這可能需要一些時間)。

這就是為什麼基於Actor的事件源(用Akka實現)如此之快,因為一旦你載入狀態,它就會等待處理下一個命令。

這就是為什麼從實體中讀取資訊很便宜,因為在很多情況下,它不需要任何事件儲存的訪問。

有了Single Writer 單寫原則(在第2部分中提到),我們就可以利用一致的writ-through cache的力量。與Akka Sharding和Akka Cluster一起,我們可以擁抱ActorSystem的全部潛力,玩轉分散式單寫者原則,但這是另一篇文章的主題。

從互動的角度來看,EntityRef的工作方式與ActorRef完全相同,但它是一個獨立的抽象,以強調我們在使用分片時,實體的生命週期比普通Actor的情況下更復雜。

與Actor的互動有很多選擇。在我們的服務層中,我們將採用Request-Response並使用ask方法。

public CompletionStage<Show> findShowBy(ShowId showId) {
    return getShowEntityRef(showId).ask(replyTo -> new ShowEntityCommand.GetShow(replyTo), askTimeout);
}

ask的方法可能會讓人感到困惑,因為第一個引數是一個lambda函式,它將建立一個訊息給Actor,並將replyTo引用到提問的Actor。

幸運的是,我們不需要建立這樣一個Actor(就像我們這裡的測試一樣)。一切都被封裝在ask方法中。

這看起來很奇怪,因為在引擎蓋下,與actor通訊的唯一方法是通過告訴方法(fire and forget)。如果你還不清楚,那麼我建議仔細閱讀關於互動模式的官方文件。

ask方法的第二個問題是,有時你必須幫助Java編譯器確定返回型別,例如,這段程式碼將無法編譯。

public CompletionStage<Show> findShowBy(ShowId showId) {
    var result = getShowEntityRef(showId).ask(replyTo -> new ShowEntityCommand.GetShow(replyTo), askTimeout);
    return result;
}

這樣做更好:

public CompletionStage<Show> findShowBy(ShowId showId) {
    var result = getShowEntityRef(showId).<Show>ask(replyTo -> new ShowEntityCommand.GetShow(replyTo), askTimeout);
    // this will also work
    // CompletionStage<Show> result = getShowEntityRef(showId).<Show>ask(replyTo -> new ShowEntityCommand.GetShow(replyTo), askTimeout);
    return result;
}

 

服務測試

測試ShowService應該是一項相當容易的任務。這一次,我們將手動建立ActorSystem(帶有記憶體事件儲存),只是為了檢查在沒有測試工具的情況下如何完成這項工作。

private static Config config = PersistenceTestKitPlugin.config().withFallback(ConfigFactory.load());
    private static ActorSystem system = ActorSystem.create("es-workshop", config);
    private ClusterSharding sharding = ClusterSharding.get(Adapter.toTyped(system));
    private Clock clock = new Clock.UtcClock();
    private ShowService showService = new ShowService(sharding, clock);

    @AfterAll
    public static void cleanUp() {
        TestKit.shutdownActorSystem(system);
    }

    @Test
    public void shouldReserveSeat() throws ExecutionException, InterruptedException {
        //given
        var showId = ShowId.of();
        var seatNumber = randomSeatNumber();

        //when
        var result = showService.reserveSeat(showId, seatNumber).toCompletableFuture().get();

        //then
        assertThat(result).isInstanceOf(ShowEntityResponse.CommandProcessed.class);
    }

    @Test
    public void shouldCancelReservation() throws ExecutionException, InterruptedException {
        //given
        var showId = ShowId.of();
        var seatNumber = randomSeatNumber();

        //when
        var reservationResult = showService.reserveSeat(showId, seatNumber).toCompletableFuture().get();

        //then
        assertThat(reservationResult).isInstanceOf(ShowEntityResponse.CommandProcessed.class);

        //when
        var cancellationResult = showService.cancelReservation(showId, seatNumber).toCompletableFuture().get();

        //then
        assertThat(cancellationResult).isInstanceOf(ShowEntityResponse.CommandProcessed.class);
    }

如果你分析一下日誌,你可以看到單節點叢集是正確形成:

Cluster Node [akka://[email protected]:2551] - Starting up, Akka version [2.6.16] ...
Cluster Node [akka://[email protected]:2551] - Registered cluster JMX MBean [akka:type=Cluster]
Cluster Node [akka://[email protected]:2551] - Started up successfully
Cluster Node [akka://[email protected]:2551] - No downing-provider-class configured, manual cluster downing required, see https://doc.akka.io/docs/akka/current/typed/cluster.htmldowning
Cluster Node [akka://[email protected]:2551] - Node [akka://[email protected]:2551] is JOINING itself (with roles [dc-default], version [0.0.0]) and forming new cluster
Cluster Node [akka://[email protected]:2551] - is the new leader among reachable nodes (more leaders may exist)
Cluster Node [akka://[email protected]:2551] - Leader is moving node [akka://[email protected]:2551] to [Up]

 

總結

ShowService是我們業務功能的主要埠。實際的服務合同當然可以根據您的需要和標準進行調整。在實體周圍使用服務包裝器將使程式碼更具可重用性和容錯性。

請從part_3標籤中檢視完整的原始碼,執行所有測試並分析日誌