Projekt TheGame #5 – Podróżujemy statkami z komponentem FleetJourney

gildia-developerow.pl 5 miesięcy temu

Minęły dwa tygodnie i mamy nowy komponent – FleetJourney, który pozwala na podróżowanie statkami w przestrzeni kosmicznej O tym oraz o kilku innych domenowych drobiazgach przeczytacie w dzisiejszym wpisie.

Pamiętacie, iż tworzymy grę?

Dla wszystkich tych, którzy pojawili się tutaj po raz pierwszy: od kilku tygodni, na łamach bloga Gildii Developerów, tworzę strategiczną grę MMO o tematyce kosmicznej. Bardzo mocno bazuję na mechanizmach podobnych do tych zastosowanych w OGame, XWars oraz Space Pioneers. Całość jest tworzona w zgodzie z konceptami znanymi z Domain Driven Design. Gra powstaje w otwartoźródłowym repozytorium na GitHubie.

Link do repozytorium: https://github.com/senghe/TheGame. Zachęcam Was do śledzenia, kliknięcia gwiazdki oraz ogólnie – udzielania się. Niedawno dotarły do mnie informacje, iż są pośród Was tacy, którzy chcieliby dołożyć swoją cegiełkę do projektu =)

Główne założenia procesu podróżowania

W ostatnim okresie prac działałem nad podróżowaniem statków. Głównie skupiłem się na procesie stacjonowania floty z planety na planetę oraz transporcie surowców. Cały mechanizm powstał w taki sposób, aby bardzo łatwo można było dokładać kolejne rodzaje misji, takie jak atak wrogiej planety czy kolonizacja niezajętej jeszcze pozycji galaktyki.

Łapcie link do pull requestu, który dotyczy prac związanych z tworzeniem wspomnianego komponentu: https://github.com/senghe/TheGame/pull/4.

Analiza kodu, czyli zacznijcie od komend

Każdy moduł ma tzw. miejsce entrypointu, które posiada wejście do procesów, które zachodzą w grze. Takimi entrypointami u nas są komendy. W komponencie FleetJourney mamy cztery nowe komendy:

  • StartJourneyCommand – najbardziej zaawansowany proces polegający na wysłaniu floty statków w galaktyczną misję
  • TargetJourneysCommand – proces polegający na śledzeniu wszystkich podróży użytkownika, weryfikujący, czy flota dotarła do celu i może zawrócić do punktu powrotu (lub stacjonować w punkcie docelowym)
  • ReturnJourneysCommand – proces podobny do poprzedniego, polegający na śledzeniu wszystkich podróży powrotnych. Weryfikujemy, czy przypadkiem którakolwiek z flot nie wróciła z powrotem na planetę, z której wystartowała.
  • CancelJourneyCommand – proces związany z zawróceniem floty bez osiągnięcia punktu docelowego.

Piszę o procesach, ponieważ do każdej komendy w projekcie istnieje dedykowana klasa handlera, który adekwatnie posiada całą logikę konkretnego procesu. Sama komenda jest prostą, niemutowalną klasą, która posiada informacje w postaci wartości o typach prostych.

No to startujemy!

Najgłówniejszy i najbardziej zaawansowanym procesem jest start podróży. Zanim wyruszymy w podróż, musimy sprawdzić kilka rzeczy:

  • czy miejsce docelowe znajduje się w ramach galaktyki,
  • czy możemy wyruszyć w docelowe miejsce z wybraną przez nas misją (stacjonowanie, transport, atak),
  • czy na planecie, z której startujemy, znajduje się pożądana przez nas ilość statków,
  • czy na planecie, z której startujemy, znajduje się odpowiednia ilość paliwa oraz surowców, które statki wezmą ze sobą w podróż
  • czy flota posiada odpowiednią ładowność, aby zmieścić paliwo oraz surowce.

Trochę tych rzeczy do sprawdzenia jest. o ile wszystkie te punkty są spełnione, to możemy ruszać w podróż. o ile wybraliśmy całą flotę, to wybieramy wszystkie statki do lotu. o ile wybraliśmy jedynie część statków, to musimy w tej chwili stacjonującą na planecie flotę podzielić (wydzielić z niej tą, która poleci w podróż).

Chciałbym jeszcze dwa słowa powiedzieć o ładunku, który statki mogą ze sobą wziąć w podróż. Każdy statek ma określoną ładowność (load capacity), co dalej składa się na ładowność całej floty. Wziąć ze sobą możemy dowolne surowce – ważne, aby sumarycznie ilość się zgadzała. O tym, jakie surowce flota bierze ze sobą w podróż decyduje gracz. To, co jest bardzo istotne to fakt, iż oprócz surowców przeznaczonych na transport, na statki musi zmieścić się również paliwo. Im dłuższa podróż, tym więcej paliwa statki muszą ze sobą zabrać, zatem tym mniej pozostałych surowców będzie mogło być załadowanych na pokład.

Osiąganie celu, powrót i anulowanie wyprawy

Kiedy flota jest w podróży, to do jej encji (Fleet) dopisany zostaje obiekt podróży (Journey). W momencie startu (konstruktor encji Journey) wyliczamy planowany czas dotarcia do celu, planowany czas podróży. Posiadamy tam również koordynaty miejsca startu, celu podróży oraz miejsca powrotu floty. Te wszystkie informacje wystarczą, aby opisać naszą podróż oraz stwierdzić, w którym jej miejscu w tej chwili się znajdujemy.

Aby było domenowo, z poziomu encji podróży mamy dużo metod typu:

<?php declare(strict_types=1); namespace TheGame\Application\Component\FleetJourney\Domain\Entity; class Journey { // ... public function doesPlanToStationOnTarget(): bool { return $this->missionType === MissionType::Stationing; } public function doesAttack(): bool { return $this->missionType === MissionType::Attack; } public function doesTransportResources(): bool { return $this->missionType === MissionType::Transport; } public function didReachTargetPoint(): bool { $now = new DateTimeImmutable(); return $now >= $this->reachesTargetAt; } public function didReachReturnPoint(): bool { $now = new DateTimeImmutable(); return $now >= $this->returnsAt; } }

W oparciu o te informacje, możemy przeprowadzić różne operacje, takie jak np. anulowanie podróży:

<?php declare(strict_types=1); namespace TheGame\Application\Component\FleetJourney\Domain\Entity; class Journey { // ... public function cancel(): void { if ($this->doesFlyBack() === true) { throw new CannotCancelFleetJourneyOnFlyBackException($this->fleetId); } if ($this->didReachTargetPoint() === true) { throw new CannotCancelFleetJourneyOnReachingTargetPointException($this->fleetId); } if ($this->didReachReturnPoint() === true) { throw new CannotCancelFleetJourneyOnReachingReturnPointException($this->fleetId); } $this->cancelled = true; $this->turnAround(); } }

Z tego możemy jasno wyczytać, iż nie możemy anulować podróży, kiedy flota wraca z podróży, osiągnęła właśnie punkt docelowy podróży, bądź wróciła właśnie z podróży. A anulowanie podróży to nic innego, jak zawrócenie floty, której czas powrotu będzie równy czasowi, który minął od momentu startu:

<?php declare(strict_types=1); namespace TheGame\Application\Component\FleetJourney\Domain\Entity; class Journey { // ... private function turnAround(): void { $this->doesFlyBack = true; $now = new DateTimeImmutable(); $timeFromStart = $now->getTimestamp() - $this->startedAt->getTimestamp(); $this->reachesTargetAt = $now; $this->returnsAt = new DateTimeImmutable(sprintf('+ %d seconds', $timeFromStart)); } }

Metoda zawracania uruchamiana jest również w momencie, kiedy flota osiąga cel.

Modularny Monolit w praktyce

Technicznie, praca na projekcie zaczyna nabierać konkretnego schematu. Ponieważ mamy bardzo ładnie wydzielone komponenty, to praca nad nowymi funkcjonalnościami bardzo często polega na kilku krokach:

  • Utworzenie nowego komponentu wraz z komendami,
  • Utworzenie encji wchodzących w skład agregatu oraz zamodelowanie nowych procesów,
  • Wrzucenie odpowiednich zdarzeń na szynę,
  • Integracja nowego komponentu z już istniejącymi komponentami (poprzez event listenery),
  • Napisanie specek, dzięki czemu jestem w stanie potwierdzić, czy nie ma jakichś warunków brzegowych, których wcześniej nie wziąłem pod uwagę.

Bardzo fajne jest to, iż każdy komponent jest na tyle samodzielny, iż praca nad nim w niewielkim stopniu wpływa na inne części systemu. Praktycznie rzecz biorąc, reagować musimy wtedy, kiedy API komponentu się zmieni. Mowa tu szczególnie o zdarzeniach oraz serwisach typu Bridge. Pozostałe części każdego komponentu są w nim zamknięte, dlatego każda modyfikacja staje się bardzo prosta.

Event Driven Architecture a pełna wizja procesu biznesowego

W każdym komponencie mamy komendy, które są punktem wejścia do aplikacji. Każda komenda jest przetwarzana przez handler. Pod spodem mamy repozytoria, serwisy pomocnicze (takie jak np. FleetResolver), agregaty itp. Wydawałoby się, iż to wszystko składa się na pełny proces. No ale tak niestety nie jest.

W projekcie mamy architekturę sterowaną zdarzeniami. Polega to na tym, iż w zależności od tego, co stało się z naszą domeną – zostają wygenerowane zdarzenia domenowe. Na te zdarzenia mogą nasłuchiwać inne komponenty, po czym będą wykonywały swoją logikę. Być może będą one generowały nowe zdarzenia, na które będą mogły nasłuchiwać inne komponenty i tak dalej… W końcu może okazać się, iż względnie prosty proces biznesowy zmienia mnóstwo w stanie aplikacji. Tego niestety na pierwszy rzut oka nie widać.

Powyższe powoduje, iż oprócz takiego prostego testowania speckami, w końcu będziemy musieli sięgnąć po nieco większy kaliber – testy funkcjonalne. Z dużą dozą prawdopodobieństwa będą to testy pisane w Behacie.

O wyciekaniu domeny słów kilka…

Każdy komponent aplikacyjny posiada dostęp do swojej części domeny. Wewnątrz domeny mamy conajmniej jeden agregat. o ile chcecie przeczytać coś o agregatach, to napisałem kiedyś o nich wpis: W poszukiwaniu agregatów w Domain Driven Design. Każdy agregat składa się z określonej ilości encji oraz obiektów wartości. Tak na przykład mamy klasę GalaxyPoint, która agreguje nam informacje o miejscu w galaktyce. Łatwiej jest przekazywać do metod jeden obiekt niż trzy skorelowane ze sobą zmienne. Tym bardziej, o ile takich obiektów tej klasy miałoby być dwa lub trzy. Przy okazji nasza domena zyskuje na czytelności.

W niektórych miejscach (serwisach typu Bridge, komendach oraz zdarzeniach) możecie zauważyć, iż jednak korzystamy z typów prostych. Aby zrozumieć tą ideę, posłużę się przykładem. Kiedy ukończona zostaje konstrukcja nowego statu w komponencie Shipyard, wysłane zostaje odpowiednie zdarzenie. Jest ono konstruowane w ten sposób:

<?php declare(strict_types=1); namespace TheGame\Application\Component\Shipyard\Domain\Event\Factory; // ... final class FinishedConstructionEventFactory implements FinishedConstructionEventFactoryInterface { public function createEvent( FinishedJobsSummaryEntryInterface $summaryEntry, PlanetIdInterface $planetId, ): EventInterface { switch ($summaryEntry->getUnit()) { case ConstructibleUnit::Cannon: { return new NewShipsHaveBeenConstructedEvent( $planetId->getUuid(), $summaryEntry->getType(), $summaryEntry->getQuantity(), ); } // ... } // ... } }

Jak zauważycie, do tego zdarzenia wrzucamy dwie wartości z obiektu $summaryEntry. Dlaczego zamiast tego nie wrzucimy całego tego obiektu? Mielibyśmy jeden parametr mniej i w ogóle. No ale nie możemy tak zrobić. Bo klasa (interfejs) FinishedJobsSummaryEntryInterface znajduje się w domenie komponentu Shipyard i nie chcielibyśmy, aby inne komponenty (te nasłuchujące na to zdarzenie) wiedziały w ogóle o istnieniu tego interfejsu. Jest to informacja wewnętrzna, trzymana w granicach komponentu Shipyard.

Wyobraźmy sobie, co by było, gdybyśmy chcieli wydzielić któryś komponent do postaci mikroserwisu i połączyć obydwie aplikacje np. systemem kolejkowym. o ile komponent Shipyard wyśle zserializowane zdarzenie, które posiada odwołanie do klasy, którą zna tylko on, to wtedy to zdarzenie nie będzie mogło być zdeserializowane po drugiej stronie. Bo ta druga strona nie będzie wiedziała, czym w ogóle jest ta klasa.

I jeszcze dwa słowa o identyfikatorach

Gdzieś w środku pull requestu padło pytanie, dlaczego dla każdej encji trzymamy identyfikatory w osobnej klasie. Jest to zabezpieczenie przed tym, aby przypadkowo nie przypisać np. identyfikatora floty jako identyfikatora encji gdzieś w fabryce. Gdybyśmy mieli identyfikatory z typami prostymi (string dla UUID lub int dla identyfikatorów z auto incrementu), to wtedy moglibyśmy „przypadkowo” coś sobie popsuć. A tak – mamy jeden przypadek mniej

Słowo o SharedKernelu

Shared Kernel – błogosławieństwo i przekleństwo zarazem. Jest to miejsce (taki worek) na wszystkie klasy, z których chcielibyśmy korzystać w każdym komponencie. Czyli jest to m.in. miejsce na kontrolowany wyciek domeny. Mamy tam wrzucone np. klasy PlanetId, ResourceAmount oraz Resources. Bo w większości komponentów będziemy korzystali z tych klas, a nie chcemy ich definiować co raz to w nowym miejscu.

Mówi się, iż im większy SharedKernel, tym gorszej jakości mamy domenę. Nie umiemy wtedy wyznaczyć odpowiednio granic modułów – powstaje znane wszystkim Spaghetti, lub jak kto woli – Big Ball of Mud. Dlatego powinniśmy pilnować, aby nie było takiej sytuacji, iż do SharedKernela wrzucamy klasę, która będzie wykorzystywana tylko przez dwa moduły. Nie chcę wytykać palcem, ale klasy BuildingType i w obecnym wydaniu GalaxyPoint chyba nie powinny znajdować się w SharedKernelu. A znajdują się. I to jest sprawa, na którą muszę mieć przynajmniej wzgląd w przyszłości. Bo pomimo tego, iż w projekcie mamy DDD, to nie znaczy, iż wszystko jest w porządku.

Uwaga na flotę bojową!

Kolorowe zostawiam jak zwykle na koniec Dzisiaj poznajcie trzon floty bojowej, czyli obrazki, które prawdopodobnie wykorzystam w fixturkach gry. Poznajcie kolejno: Krążownika, Pancernika, Okręt Wojenny, Niszczyciela oraz Bombowca.

PS. Pytanie podchwytliwe: kto zgadnie, jaki komponent robimy następny?

Idź do oryginalnego materiału