本節主要主題是將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() { <font>//given<i> 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<i> showEntityRef.tell(toEnvelope(reserveSeat, commandResponseProbe.ref()));
//then<i> commandResponseProbe.expectMessageClass(CommandProcessed.class);
//when<i> showEntityRef.tell(new ShowEntityCommand.GetShow(showResponseProbe.ref()));
//then<i> 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 = <font>"cluster" }
cluster { seed-nodes = ["akka://es-workshop@127.0.0.1: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); <font>// this will also work<i> // CompletionStage<Show> result = getShowEntityRef(showId).<Show>ask(replyTo -> new ShowEntityCommand.GetShow(replyTo), askTimeout);<i> return result; }
|
服務測試
測試ShowService應該是一項相當容易的任務。這一次,我們將手動建立ActorSystem(帶有記憶體事件儲存),只是為了檢查在沒有測試工具的情況下如何完成這項工作。
private static Config config = PersistenceTestKitPlugin.config().withFallback(ConfigFactory.load()); private static ActorSystem system = ActorSystem.create(<font>"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<i> var showId = ShowId.of(); var seatNumber = randomSeatNumber();
//when<i> var result = showService.reserveSeat(showId, seatNumber).toCompletableFuture().get();
//then<i> assertThat(result).isInstanceOf(ShowEntityResponse.CommandProcessed.class); }
@Test public void shouldCancelReservation() throws ExecutionException, InterruptedException { //given<i> var showId = ShowId.of(); var seatNumber = randomSeatNumber();
//when<i> var reservationResult = showService.reserveSeat(showId, seatNumber).toCompletableFuture().get();
//then<i> assertThat(reservationResult).isInstanceOf(ShowEntityResponse.CommandProcessed.class);
//when<i> var cancellationResult = showService.cancelReservation(showId, seatNumber).toCompletableFuture().get();
//then<i> assertThat(cancellationResult).isInstanceOf(ShowEntityResponse.CommandProcessed.class); }
|
如果你分析一下日誌,你可以看到單節點叢集是正確形成:
Cluster Node [akka:<font>//es-workshop@127.0.0.1:2551] - Starting up, Akka version [2.6.16] ...<i> Cluster Node [akka://es-workshop@127.0.0.1:2551] - Registered cluster JMX MBean [akka:type=Cluster]<i> Cluster Node [akka://es-workshop@127.0.0.1:2551] - Started up successfully<i> Cluster Node [akka://es-workshop@127.0.0.1:2551] - No downing-provider-class configured, manual cluster downing required, see https://doc.akka.io/docs/akka/current/typed/cluster.html#downing<i> Cluster Node [akka://es-workshop@127.0.0.1:2551] - Node [akka://es-workshop@127.0.0.1:2551] is JOINING itself (with roles [dc-default], version [0.0.0]) and forming new cluster<i> Cluster Node [akka://es-workshop@127.0.0.1:2551] - is the new leader among reachable nodes (more leaders may exist)<i> Cluster Node [akka://es-workshop@127.0.0.1:2551] - Leader is moving node [akka://es-workshop@127.0.0.1:2551] to [Up]<i>
|
總結
ShowService是我們業務功能的主要埠。實際的服務合同當然可以根據您的需要和標準進行調整。在實體周圍使用服務包裝器將使程式碼更具可重用性和容錯性。
請從part_3標籤中檢視完整的原始碼,執行所有測試並分析日誌