Blog
W trakcie ostatniej konferencji PHP w Amsterdamie, Mathias Verraes, w ramach swojej pełnej pasji prezentacji pt.: “Practical Event-Sourcing” przedstawił nieszablonowy sposób utrwalania informacji w aplikacji. Pokazał alternatywę do tradycyjnego zapisu stanu – zapis historii zdarzeń. Postanowiłem bliżej poznać ten wzorzec projektowy. Niestety aktualnie brakuje pozycji książkowych, które wyczerpująco opisywałyby ten wzorzec. Głównym źródłem informacji stanowi dla mnie wspomniana prezentacja i wpisy na blogu Martina Fowlera.
Pierwsze czego się dowiedziałem to, że Mathias Verraes, nie przedstawił “Event Sourcing” w czystej postaci ale swoją adaptacje, w której łączy koncepcje zapisu historii zdarzeń ze wzorcem CQRS. Taki właśnie tandem zamierzam przedstawić.
CQRS – Command Query Segregation
CQRS to wzorzec, który podważa koncepcję tworzenia jednego spójnego modelu odpowiedzialnego zarówno za wprowadzanie zmian stanu aplikacji, jak i jego prezentacje. W zamian promuje podział na odrębne modele do zapisu (Command) i odczytu (Query) stanu aplikacji. CQRS nie określa jak głęboki ma to być podział:
- podział abstrakcji – implementacja modelu może być wspólna, ale odwołujemy się do niej przez oddzielne interfejsy,
- podział implementacji,
- podział bazy danych,
- podział aplikacji,
Sam podział abstrakcji nie jest zalecany – poza wiekszą ekspresyjnością interfejsów nie wnosi dodatkowej wartości. Podział implementacji oferuje, zamiast jednego modelu, stanowiącego często trudny kompromis, wynikający z wielu odpowiedzialności, dwa prostsze i bardziej wyraziste modele. Zyskiem jest też niezależność obu modeli, która sprzyja szybkości ich rozwoju. Przy podziale obejmującym dodatkowo warstwę bazy danych zyskujemy możliwość niezależnego skalowania bazy do zapisu i bazy do odczytu. Jest to szczególnie istotne, gdy w aplikacji jest duża dysproporcja pomiędzy liczbą odczytów a zapisów. Niesie to za sobą konsekwencje, których musimy być świadomi – problemy z utrzymaniem spójności danych pomiędzy bazami zapisu i odczytu.
Historia zdarzeń vs Stan aplikacji
Jak wspomniałem wzorzec “Event Sourcing” opiera się na zapisie historii zdarzeń zamiast stanu aplikacji. Jest to mało intuicyjne podejście, ale po chwili zastanowienia można zauważyć podstawową jego zaletę: zapisujemy wiecej informacji. Stan aplikacji jest tylko wynikiem serii zdarzeń z przeszłości. Dysponując historią zdarzeń mamy możliwość odtworzyć aktualny stan, a także stan z dowolnego momentu z przeszłości. Mathias Verraes żartobiliwie porównał tę zaletę do wehikułu czasu. Tylko czemu mielibyśmy cofać się w czasie? Odpowiedź jest prosta, aby wyciągnąć nowe wnioski i podjąć lepsze decyzje, niż tylko w oparciu o teraźniejszość.
Przykład.
Klient w sklepie wkłada do koszyka produkt A, następnie produkt B i następnie produkt C, po czym wyjmuje produkt B i idzie do kasy. Jakie wnioski można z takiej historii zdażeń wyciągnąć?
- produkty B i C są konkurencyjne i klient uznał C za lepszy?
- produkt B jest za drogi i klient zdecydował się go odłożyć? Może wprowadzenie promocyjnej ceny zwiększy jego sprzedaż?
Patrząc jedynie na końcowy stan koszyka tracimy cenne biznesowo informacje.
Jak zbudować aplikację w oparciu o te wzorce?
Przede wszystkim musimy wprowadzić podział na operacje wpływające na historie zdarzeń (komendy) i prezentację danych (zapytania).
Model komend
Model komend złożony jest z agregatów. Agregaty, w rozumieniu Domain Driven Development to spójne układy zależnych obiektów domenowych, które dla innych obiektów powinny stanowić jeden byt. Cechy agregatów we wzorcu “Event-Sourcing”:
- posiadają identyfikator,
- rejestrują zdarzenia domenowe,
- odbudowują swój stan na podstawie historii zdarzeń domenowych,
- nie udostępniają swojego stanu (poza historią zdarzeń),
- pilnują ograniczeń domenowych,
Nawiązując do przykładu z klientem robiącym zakupy, agregatem będzie obiekt koszyka. Koszyk będzie posiadał identyfikator. W wyniku operacji na nim, będzie rejestrował w swoim stanie zdarzenia domenowe, takie jak:
Operacja na agregacie | Zdarzenie domenowe | Parametry zdarzenia |
cart.pickUp() | CartWasPickedUp | cartId |
cart.addProduct(productId) | ProductWasAddedToCart | cartId, productId |
cart.removeProduct(productId) | ProductWasRemovedFromCart | cartId, productId |
Agregat udostępnia zarejestrowane zdarzenia domenowe, aby możliwe było ich utrwalenie w bazie danych. Agregat potrafi odbudować swój stan, na podstawie kolekcji zdarzeń odczytanych z bazy danych.
Zdarzenie domenowe to zdarzenie istotne dla domeny. Zaleca się, aby zdarzenia były bogate w informacje. Nawet jeśli aktualnie nie widzimy potrzeby zapisu pewnych szczegółów zdarzenia, to w przyszłości, być może informacje te okażą się bardzo cenne.
Co składa się na stan ageragtu poza historią zdarzeń? To zależy od domeny. Załóżmy, że istnieje ograniczenie domenowe – klient może mieć w koszyku maksymalnie 5 produktów. Elementem stanu może być wtedy licznik produktów, dzięki któremu agregat wykryje próbę złamania ograniczenia domenowego.
Skoro nasz obiekt koszyka, w swoim stanie, zawiera tylko historię zdarzeń i licznik produktów, to jak zaprezentować klientowi aktualny stan jego koszyka?
Przypominam, że omawiam teraz model komend aplikacji. Za prezentacje danych odpowiada model zapytań. Przejdźmy zatem do częsci prezentacji danych.
Model odczytu i projekcje stanu
Jedyne informacje, jakie utrwala model komend to historia zdarzeń agregatów. Aby wyświetlić stan koszyka klienta musimy przetworzyć historie zdarzeń z nim powiązanych i wyciągnąć z niej niezbędne do prezentacji dane. Do tego celu służą projektory. Projektory przetwarzają zdarzenia zgodnie z czasem ich zajścia i na ich podstawie tworzą projekcję stanu w modelu odczytu. W przypadku omawianego przykładu, aby wykonać projekcję stanu koszyków możemy utworzyć w relacyjnej bazie danych schemat opisujący strukturę koszyka. Następnie przygotować klasę projektora, który po otrzymaniu zdarzenia domenowego wykona odpowiednie operacje w bazie danych:
Zdarzenie domenowe | Operacja projekcji stanu |
CartWasPickedUp | Utworzenie rekordu koszyka: INSERT INTO cart(cart_id) VALUES(?) |
ProductWasAddedToCart | Utworzenie rekordu pozycji koszyka: INSERT INTO cart_item(cart_id,product_id) VALUES(?,?) |
ProductWasRemovedFromCart | Usunięcie rekordu pozycji koszyka: DELETE FROM cart_item WHERE cart_id=? AND product_id=? |
Pozostaje zasilić projektor zdarzeniami związanymi z koszykami klientów. Tak odtworzone stany koszyków model odczytu może przekazać do interfejsu odpowiednich klientów.
Projektory nie służą jedynie do prezentacji aktualnego stanu aplikacji. Dają dużo wieksze możliwości. To one, w połączeniu z historią zdarzeń, tworzą wspomniany wehikuł czasu. Jeśli zasilimy projektory historią zdarzeń do danego punktu w przeszłości, to odtworzymy stan aplikacji z tego właśnie punktu w czasie. Jeśli przedefiniujemy projektory, to na podstawie tej samej historii zdarzeń, możemy przedstawić inny aspekt stanu aplikacji. Projektory mogą służyć do raportowania. Projektory możemy zasilać najnowszymi wpisami z historii zdarzeń tak, aby aktualizowały bazy odczytu lub też możemy zresetować wybrane tabele/bazy odczytu i wykonać ponowną projekcję na podstawie pełnej historii zdarzeń.
Powstaje pytanie w jaki sposób wyzwalać projekcje danych? To zależy od celu projekcji.
Do celów raportowych, najprawdopodobniej wystarczy wyzwalać projekcje raz dziennie poprzez harmonogram systemowy. Aplikacja na żądanie powinna pobrać wszystkie zdarzenia z ostatniego dnia i zasilić nimi projektory.
W celu odświeżenia interfejsu użytkownika np. gdy chcemy wyświetlić aktualny stan koszyka użytkownikowi, który przed chwilą dodał do niego kolejny produkt musimy to zrobić natychmiastowo. W tym wypadku zalecałbym użycie wzorca “EventDispatcher”, aby nie tworzyć silnych powiązań pomiędzy modelem komend i projektorami.
Wydajność
Ostatnie zagadnienie, które chcę poruszyć to wydajność. Wcześniej jako zaletę przedstawiłem fakt, że zapisujemy większą ilość danych. Jest to bez wątpienia wartość dla biznesu, ale czy aby nie stanowi jednocześnie problemu technicznego, który wyklucza ten wzorzec z zastosowania w dużych aplikacjach? Absolutnie nie.
Historia zdarzeń, to jedna tabela indeksowana wg identyfikatorów agregatów, co więcej nie edytujemy w niej rekordów, a jedynie dodajemy nowe. Daje to duże możliwości skalowania i cache-owania.
Tabele projekcji stanu możemy optymalizować pod kątem konkretnej projekcji danych tj. mogą przechowywać identyczną strukturę danych jaka trafia do interfejsu użytkownika czy wiersza raportu. Jako, że służą tylko do odczytów, nie jest wymagana normalizacja ich schematu. Dzięki temu w zapytaniach możemy zrezygnować z kosztownych JOIN-ów.
Jak wspominałem we wstępie wzorzec CQRS nie określa jak głęboko powinna sięgać separacja modeli komend i zapytań. Jeśli zdecydujemy się rozdzielić warstwe bazy danych zyskamy dalsze pole do optymalizacji (z uwzględnieniem wspomnianych przy opisie wzorca CQRS problemów jakie są z tym powiązane). Możemy niezależnie skalować bazy danych historii zdarzeń a nawet poszczególnych projekcji.
W przypadku, gdy dodatkowo chcemy do implementacji poszczególnych projektorów stosować różne technologie, możemy zastosować kolejkę wiadomości (MessageQueue), która zapewni swobodę doboru technologii.
Na zakończenie, muszę zaznaczyć, że nie miałem jeszcze okazji w praktyce zastosować tego wzorca projektowego. Z niecierpliwością będę wyczekiwał na odpowiedni projekt.