CQRS, czyli Command Query Responsibility Segregation, jest popularnym wzorcem architektonicznym wymyślonym przez Grega Younga. Polega na rozdzieleniu aplikacji na część zapisującą i odczytującą dane.
Swoje początki wziął od zasady CQS, czyli Command Query Separation, przedstawionej przez Bertranda Meyera, polegającej na tym, iż każda operacja na obiekcie powinna albo coś wykonywać (Command), albo coś zwracać (Query). Nie powinna robić tych dwóch rzeczy jednocześnie. Obiekty powinniśmy jedynie o coś pytać lub wdawać mu polecenie.
W CQRS idziemy o krok dalej - w kontekście CQS mówiliśmy o metodach, w CQRS o obiektach. W standardowym podejściu do pisania aplikacji często korzystamy z jednego modelu, zarówno do zapisu, jak i odczytu danych. W CQRS chodzi o to, aby zaprojektować system tak, aby część klas obsługiwała zapisywanie danych, a część ich odczyt. Model zapisu przyjmuje tylko polecenia i modyfikuje dane, natomiast model odczytu jest odpowiedzialny za to, aby pobrać i wyświetlić te dane użytkownikowi.
W typowych aplikacjach większość operacji to odczyt danych, a reszta to zapis, lub odwrotnie. Stosując architekturę CQRS, możemy bardziej zoptymalizować jedną część, nie wpływając w żaden sposób na drugą.
Wzorzec ten często kojarzy się z wieloma bazami danych, gdzie po zapisaniu danych wysyłamy zdarzenia (eventy), które są propagowane do warstwy odczytu, co może wiązać się ze skomplikowanymi metodami utrzymywania ich spójności. W rzeczywistości istnieje wiele sposobów na implementację tego wzorca, które nie zawsze wymagają osobnej bazy do odczytu, czy też synchronizowania ich pomiędzy dwiema warstwami.
CQRS często łączony jest z Event Sourcingiem, czyli podejściem, w którym stan obiektu w warstwie zapisu budowany jest poprzez aplikowanie na nim kolejnych zdarzeń. To połączenie jest bardzo popularne, jednak można się obejść bez niego.
W tym wpisie skupię się na implementacji CQRS, która nie wymaga stosowania oddzielnych baz danych. W obrębie tego podejścia pokażę, jak wygląda proces od zapisu do odczytu danych.
Wzorzec ten można przedstawić dzięki schematu. W zależności od tego, czy chcemy korzystać z jednego źródła danych, czy z kilku, będzie się on nieznacznie różnił. Na poniższym schemacie CQRS wykorzystuje tylko jedną bazę danych:
Command Part
Po tej stronie aplikacji modyfikujemy jej stan. Implementujemy tu całą logikę aplikacji i reguły, które dbają o spójność danych i o to, czy możemy jakaś operację.
Załóżmy, iż tworzymy sklep i część odpowiedzialną za składanie zamówień chcemy zaprojektować, używając wzorca CQRS.
Jak widzimy na powyższym schemacie, chęć zmiany danego zasobu wyrażamy poprzez Command. Jest to prosty DTO zawierający dane potrzebne do wykonania operacji. W przypadku, gdy chcemy dodać produkt do zamówienia, może on wyglądać tak:
class AddProductCommand implements CommandInterface { public function __construct( public readonly Uuid $orderId, public readonly Uuid $productId, public readonly int $quantity, public readonly \DateTime $when ) {} }Musimy teraz taką komendę obsłużyć. Przyda się do tego CommandHandler. To w nim znajduje się cała logika mówiąca, jak dany produkt ma zostać dodany do zamówienia.
class AddProductHandler { private OrderRepository $orderRepository; private ProductRepository $productRepository; public function __invoke(AddProductCommand $command): void { $order = $this->orderRepository->find($command->orderId); $product = $this->productRepository->find($command->productId); $order->addProduct($product, $command->quentity); //if needed, trigger the event that the product was added to the order } }Gdy mamy już Command i obsługujący go Handler, należy teraz przekazać Command do Handlera. Często robi się to dzięki CommandBus\a. Możemy go napisać sami. Mamy też do tego celu dostępnych wiele bibliotek. Możemy tu użyć, np. Tactician\a lub Symfony Messenger`a. Są one bardzo proste w konfiguracji i umożliwiają obsługę commandów na wiele sposobów - asynchronicznie lub synchronicznie, z użyciem middleware'ów.
W aplikacji dobrze jest stworzyć prosty interfejs, który w swojej implementacji będzie opakowywał wybraną przez nas bibliotekę. Wtedy to jak obsługiwane są commandy, będzie dla nas transparentne.
interface CommandBusInterface { public function dispatch(CommandInterface $command): void; }Przykładowa implementacja CommandBus`a z wykorzystaniem komponentu Symfony Messenger może wyglądać tak:
<?php use Symfony\Component\Messenger\MessageBusInterface; class CommandBus implements CommandBusInterface { private MessageBusInterface $messageBus; public function __construct(MessageBusInterface $messageBus) { $this->messageBus = $messageBus; } public function dispatch(Command $command): void { $this->messageBus->dispatch($command); } }Event Bus
Analogicznie do CommandBusa możemy stworzyć EventBus`a, na który będą wrzucane zdarzenia po modyfikacji stanu naszego obiektu. Event Bus i Event Handler przedstawione są na schemacie linią przerywaną, co oznacza, iż są opcjonalne, ponieważ możemy używać read modelu, odwołując się do istniejącej struktury bazy danych.
Powyższe elementy przydadzą się natomiast w sytuacjach, gdy:
- będziemy chcieli przebudować zmaterializowany widok,
- będziemy chcieli przechowywać dane w bardziej zoptymalizowanej lub zdenormalizowanej strukturze tabel,
- dane do odczytu będą znajdowały się w innej bazie,
Wtedy, aby przebudować te dane, można wysłać zdarzenie o tym, iż coś się w systemie zmieniło, aby system mógł na to zareagować i wykonać operacje odświeżające dane, które odczytujemy.
Query Part
Ta część aplikacji odpowiada za odczyt danych. Oznacza to, iż nie należy zmieniać w tym miejscu stanu aplikacji.
Tak jak wspomniałem wyżej, dane do odczytu nie muszą być wcale przechowywane w oddzielnej bazie danych. Pierwszym sposobem może być po prostu korzystanie z aktualnej struktury danych dzięki zapytań SQL. Takie podejście ma wiele plusów. Przede wszystkim możemy tu wykorzystać czysty język SQL, co będzie wydajniejsze niż np. pobieranie danych w formie encji. Nie wspominając o braku konieczności serializowania tych danych, np. do JSONa podczas zwracania odpowiedzi. Pobieranie danych czystym SQL'em może być dobrym rozwiązaniem, ponieważ możemy wybrać tylko te informacje, które nas interesują i w niektórych przypadkach nie konwertować ich niepotrzebnie na obiekty.
Kolejnym dobrym sposobem może okazać się tworzenie widoków zmaterializowanych. Taką funkcjonalność oferuje np. PostgreSQL. Taki widok może być zbudowany w sposób, który sprawi, iż odczyt danych będzie szybszy niż dzięki tradycyjnych zapytań wykorzystujących joiny do innych tabel. W przypadku encji zamówienia, wiersz może zawierać informacje o tymże zamówieniu oraz jego pozycje w formie, np. json`a. Do wyciągnięcia tych danych wystarczy prosty select. Odświeżanie takiego widoku może się odbywać poprzez nasłuchiwanie na event mówiący o modyfikacji zamówienia.
Dane do odczytu możemy też zapisywać w innych tabelach, np. w postaci bardziej zdenormalizowanej. Możemy też wykorzystać do tego celu inną, bardziej odpowiednią pod dany przypadek bazę danych, np. MongoDB. Jeszcze innym razem możemy potrzebować narzędzia do wyszukiwania pełnotekstowego, takiego jak, np. ElasticSearch, aby wyszukiwanie danych na naszej stronie było bardzo szybkie.
W tych przypadkach będziemy potrzebować sposobu, aby skutecznie wypełnić nowe źródło danymi. W CQRS taki mechanizm nazywa się Projekcją, a element, który jest odpowiedzialny za tworzenie projekcji to Projector. Reaguje on na zdarzenie i na podstawie dostępnych w nim danych aplikuje zmiany w bazie.
class OrderProjector { public function apply(Event $event): void { match (get_class($event)) { OrderCreatedEvent::class => $this->createOrder($event), default => throw new \Exception('Event not supported') }; } public function createOrder(OrderCreatedEvent $event): void { // insert data to database // or refresh matrialized view } }Warto wspomnieć, iż jeżeli zdecydujemy się na przechowywanie read modelu w osobnej bazie danych, należy mieć świadomość, iż wiąże się to z pewnymi konsekwencjami. Nie będziemy mogli, m.in. wykorzystać transakcji bazodanowych pomiędzy zapisem danych w warstwie zapisu i warstwie odczytu.
Będziemy mieć również do czynienia ze zjawiskiem zwanym Eventual Consistency, czyli ostateczną spójnością. Komunikacja po sieci zajmuje czas. Niekiedy połączenie może się zerwać. Oznacza to, iż rezultaty wykonania commanda nie będą dostępne od razu, ale trzeba będzie na nie poczekać.
Jeśli chodzi o pobieranie danych, wystarczy nam do tego prosty serwis, który na podstawie danych wejściowych zwróci nam szukane informacje.
class Orders { public function findOrders(Criteria $criteria): array { //return data } }Innym podejściem na pobieranie danych jest używanie QueryBusa. Wysyłamy wówczas wiadomość (Query, prosty DTO), która jest obsługiwana przez QueryHandler.
Ten zaś zwraca nam rezultaty. Takie podejście ma jednak swoje wady. Jedną z nich jest to, iż nie do końca możemy określić, jaki będzie typ wiadomości zwracanej przez QueryBus`a.
Kiedy używać, a kiedy nie używać CQRS
CQRS sprawdza się w systemach, gdzie liczba zapisów i odczytów bardzo się różni. Wówczas możemy niezależnie skalować poszczególne części w zależności od obciążenia.
Dobrym miejscem na użycie CQRS są aplikacje zawierające dużo złożonej logiki biznesowej. Rozdzielenie warstwy zapisu i odczytu sprawi, iż obie warstwy będą od siebie niezależne, co zmniejszy ich złożoność i zwiększy przejrzystość.
Jeśli używamy lub chcemy zastosować w naszej aplikacji Event Sourcing, CQRS także będzie dobrym rozwiązaniem. O ile zapisywanie stanu w postaci sekwencji zdarzeń nie powinno być problematyczne, tak wyszukiwanie tych danych w ten sposób w celu ich prezentacji na pewno nie będzie wydajnym podejściem.
Nie powinno się zaś stosować tego wzorca w prostych aplikacjach typu CRUD lub takich, w których nie ma zbyt wiele logiki biznesowej. Wówczas użycie tego wzorca może narzucić na aplikację dodatkową złożoność i niepotrzebnie ją skomplikować, bez wyraźnych korzyści, jakie płyną z jego zastosowania.
Należy pamiętać, iż CQRS`a nie musimy stosować zero-jedynkowo. Możemy go zastosować w części aplikacji, w której będzie to dawało wymierne korzyściami.
Podsumowanie
CQRS jest popularnym wzorcem projektowym, który umożliwia oddzielenie warstwy zapisu od warstwy odczytu. Powoduje to, iż obie części są niezależne i mogą być skalowane i rozwijane osobno. Obie warstwy mogą posiadać wspólną bazę danych, jednak nic nie stoi na przeszkodzie, aby każda z nich korzystała z oddzielnej. Wzorzec ten nadaje się do aplikacji z rozbudowaną logiką biznesową. Powinniśmy unikać go w prostych aplikacjach.