CQRS - System post
2018-04-25
Każdy poprzedni wpis dotyczący CQRS'a mniej lub bardziej wspominał o systemie, czyli o takim bycie
do którego trafiają wszystkie command oraz queries. Za pomocą komend, stan systemu może być zmieniony, query
natomiast służy do jego odczytania. Przykładowo budując system do zarządzania pracownikami, komendą
CreateUser()
moglibyśmy dodać użytkownika do systemu a następnie korzystając z UserQuery::totalCount() : int
moglibyśmy pobrać całkowitą liczbę użytkowników, którymi system zarządza. Czym jednak jest system?
Popatrzmy na poniższy przykład.
<?php
namespace System\Userinterface\Web\Controller;
use System\Application\CommandBus;
use System\Application\Query\UserQuery;
class UserController
{
private $commandBus;
private $query;
public function __construct(CommandBus $commandBus, UserQuery $query)
{
$this->commandBus = $commandBus;
$this->query = $query;
}
public function createAction(Request $request) : Response
{
$command = new CreateNewUser(
(string) $request->post->get("email"),
(string) $request->post->get("username"),
);
$this->commandBus->handle($command);
return new JsonResponse([
'userId' => $this->query->getByEmail((string) $request->post->get("email"))->id()
]);
}
}
Mamy komendę, komenda przekazywana jest do command busa, przechodzi przez handler, gdzieś tam pod spodem
tworzona jest encja reprezentująca użytkownika w systemie. Ta encja staje się write modelem, następnie
być może budowana jest jakaś projekcja z której na końcu dane może czerpać UserQuery
. To właśnie jest
nasz system. Widzicie jak bardzo jest on wkomponowany w UI?
Wszystko wydaje się być poprawne i poukładane, jednak brakuje tu jasno zdefiniowanych granic. W jaki sposób Controler
istniejący gdzieś na poziomie warstwy UI dostaję CommandBus, oraz UserQuery? W jaki sposób te dwa byty
są tworzone i przekazywane dalej? Na czym tak naprawdę polega warstwa UI? Na czymkolwiek co znajduje się w warstwie App?
A może tylko na Command Busie i Queries? W jaki sposób najlepiej przetestować Tworzenie nowego użytkownika
w systemie?
Dlaczego moje testy jak i aplikacją stają się coraz bardziej zależne od frameworka i warstwy UI?
Jakiś czas temu zacząłem zadawać sobie właśnie takie pytania. Pierwsza rzecz jaka skłoniła mnie
do przemyśleń na temat systemu były testy. Za każdym razem gdy chciałem przetestować jakąś funkcjonalność systemu
przykładowo takie dodawanie użytkownika musiałem wysłać komendę do command busa, pobrać dane za pomocą Query i dokonać
asercji. Typowy Arrange/Act/Assert
. Ponieważ akurat wykorzystywałem framework dostarczający service container,
to właśnie tam inicjalizowałem command busa oraz queries, żeby nie bootstrapować tych bytów za każdym
razem na potrzeby testów uzależniłem TestCase od frameworka. To był pierwszy błąd, aplikacja teoretycznie
była niezależna od frameworka, w praktyce jednak ta zależność istniała i uniemożliwiała w zasadzie zmianę frameworka.
Mniej więcej wtedy pomyślałem, no dobra a co gdyby skrystalizować, umieścić go w warstwie Application
i za jego
pomocą mówić aplikacji co chciałbym zmienić oraz czego chciałbym się dowiedzieć?
Popatrzmy na poniższy przykład
<?php
declare (strict_types=1);
namespace Example\Application;
use Example\Application\Command\Command;
use Example\Application\Query\Query;
final class System
{
public function handle(Command $command) : void
{
}
public function query(string $queryClass) : Query
{
}
}
Taki byt można potraktować jako jednolity punkt wejścia/wyjścia, możemy nazwać go System
, Kernel
, Core
.
Jak więc wyglądałby powyższy przykład gdyby zamiast korzysta z command busa i query, skorzystać z systemu?
<?php
namespace System\Userinterface\Web\Controller;
use System\Application\Query\UserQuery;
class UserController
{
private $system;
public function __construct(System $system)
{
$this->system = $system;
}
public function createAction(Request $request) : Response
{
$command = new CreateNewUser(
(string) $request->post->get("email"),
(string) $request->post->get("username"),
);
$this->system->handle($command);
return new JsonResponse([
'userId' => $this->system->query(UserQuery::class)->getByEmail((string) $request->post->get("email"))->id()
]);
}
}
Niby nie zmieniło się nic, naprawdę jednak zmieniło się wszystko. Po pierwsze mamy jasno zdefiniowaną i czytelną
zależność, Controler
zależy od System
'u. Chcemy przetestować kontroler? Nie ma problemu, tworzymy FakeSystem i
możemy śmiało pisać unit test na kontroler. Wcześniej niby też mogliśmy tylko, trzeba było stworzyć 2 test doubles,
jeden dla command busa, drugi dla query, niby nic wielkiego? Niby nie ale z czasem dojdzie więcej Queries, potworzą się
dodatkowe zależności i będziemy mockować nie 2 a 5 bytów.
Chcąc przetestować integracyjnie tworzenie użytkownika w systemie wystarczy wysłać do systemu Command i pobrać jego stan za pomocą odpowiedniego Query. Aby jednak taki test był możliwy do napisania trzeba zbootstrapować System. Dalej można to zrobić za pomocą service containera dostarczanego przez framework (tak jest szybciej), jednak w samym teście nie musimy już odwoływać się do service containera. Wystarczy, że odwołamy się do systemu. Oto jak mógłby wyglądać abstrakcyjny test case.
<?php
declare (strict_types=1);
namespace Example\Application\Test;
abstract class SystemTestCase extends PHPUnitTestCase;
{
public abstract function system() : System;
}
Implementacja bazująca na service containerze frameworka, może pobierać system z containera.
Implementacja totalnie niezależna może sobie budować system samodzielnie używając wszędzie implementacji
InMemory. Piszecie testy akceptacyjne? Na pewno. Używacie Behata? To bardzo dobrze, zbudujcie sobie Context
wewnątrz niego zbootstrapujcie System
oparty o InMemory
i wysyłajcie do niego Command oraz czytajcie z niego
za pomocą Queries.
Testy to jednak nie wszystkie korzyści jakie daje System
. Powiedzmy, że pracujemy sobie z ORM'em, którego
zadaniem jest śledzenie zmian w write modelu. Gdzie najlepiej rozpocząć i zamknąć transakcję zapisu tych zmian?
<?php
declare (strict_types=1);
namespace Example\Application;
use Example\Application\Command\Command;
use Example\Application\Query\Query;
final class System
{
public function handle(Command $command) : void
{
$this->transactionManager->open();
try {
$this->commandBus->handle($command);
$this->transactionManager->close();
} catch (\Exception $e) {
$this->transactionManager->rollback();
throw $e;
}
}
public function query(string $queryClass) : Query
{
}
}
Od teraz każda komenda obsługiwana przez system musi być atomowa i zawierać się w dokładnie jednej transakcji.
Tak, ktoś powie Ale przecież można napisać sobie middle ware do command busa!
. No można, tylko:
a) Nie każda implementacja command busa posiada taki mechanizm b) Ukrywamy bardzo istotne pojęcie gdzieś na poziomie infrastruktury c) Czy wymiana implementacji command busa powinna mieć wpływ na transakcję?
Pójdźmy krok dalej. Każdy dobrze zaprojektowany system loguje to co się w nim dzieje. Nie mając logów jesteśmy ślepi.
Czy może istnieć lepsze miejsce na logowanie tego co się dzieje niż System
?
<?php
declare (strict_types=1);
namespace Example\Application;
use Example\Application\Command\Command;
use Example\Application\Query\Query;
final class System
{
public function handle(Command $command) : void
{
$this->logger->debug('...');
$this->transactionManager->open();
try {
$this->commandBus->handle($command);
$this->transactionManager->close();
$this->logger->debug('...');
} catch (\Exception $e) {
$this->transactionManager->rollback();
$this->logger->error('...');
throw $e;
}
}
public function query(string $queryClass) : Query
{
}
}
Wystarczy podnieść verbosity logów do poziomu DEBUG
i zobaczymy dokładnie w jakiej kolejności komendy
trafiały do systemu i były przez niego obsługiwane, które zostały obsłużone a które nie.
Niektóre systemu muszą zapisywać wszystkie operacje/zmiany. Posiadając System
, dodanie mechanizmu zapisywania komend
w metodzie System::handle(Command $command)
można zrobić praktycznie w dowolnym momencie życia projektu.
Co więcej, komendy których z jakiejś przyczyny nie udało się przetworzyć (połączenie z bazką nam akurat padło albo programista zrobił buga, którego testy nie wyłapały),
można wrzucić łatwo na jakiś storage i spróbować ponownie wysłać ją do systemu po pewnym czasie.
W wpisie CQRS - Read Model poruszałem temat budowania projekcji na podstawie komend,
które trafiają do systemu. Czy może być lepsze miejsce dla projektora niż System::handle(Command $command)
?
Projektor oczywiście dobrze jakby był asynchroniczny i najlepiej tak zabezpieczony aby nie mógł przerwać wykonywania
metody handle
ale śmiało można zacząć od synchronicznego i rozbudowywać po czasie.
Chciałbyś aby UI Twojej aplikacji bardzo szybko reagował na żądania użytkownika i zastanawiasz się nad
asynchronicznym przetwarzaniem żądań? Żaden problem, stwórz Command
w Controlerze, zserializuj (w końcu to DTO, nie?)
wrzuć na kolejkę i daj działać consumerowi. Consumer zależny jedynie od systemu zdeserializuje komendę, przekaże ją do
systemu a ten zrobi resztę.
No dobra, ale przecież to wszystko da się też osiągnąć przekazując wszędzie CommandBus bezpośrednio? No tak,
da się tylko że z czasem ta liczba miejsc rośnie i bardzo łatwo jest stworzyć niepoprawne i bardzo złożone zależności.
Controlery, CLICommands, Consumery - to wszystko musiałoby dostać Command Busa, a jeżeli nagle zajdzie potrzeba żeby
zrobić coś po każdej poprawnie obsłużonej komendzie?
Posiadając system można też bardzo łatwo wprowadzić jedna prostą zasadę, Warstwa UI nie powinna być zależna od niczego
co leży w warstwie Application, co nie jest System
'em. Dzięki temu, redukujemy zależności pomiędzy warstwami.
Gdyby więc próbować narysować aplikację złożoną z takich warstw jak UI, Application i Domain mogłaby ona przypominać klepsydrę. Wszystko co z UI trafia do Systemu, który poniżej operuje na bardzo rozbudowanym i skomplikowanym modelu Domeny.
Podsumowując CQRS w jednym pliku:
interface System
{
public function handle(Command $command) : void
public function query(string $queryClass) : Query;
}
Jeżeli CQRS został poprawnie zaimplementowany, wprowadzenie System
'u nie powinno być większym problemem.