Sytuacja kobiet w IT w 2024 roku
28.01.20196 min
Michał Kanak

Michał KanakTeam Leader / Technical Leaderpixers.pl

Event Sourcing - czy to zawsze właściwa droga?

Sprawdź, jakie problemy może sprawiać połączenie CQRS z Event Sourcing oraz poznaj kilka rozwiązań.

Event Sourcing - czy to zawsze właściwa droga?

W tym artykule poruszę kwestie dotyczące różnic między zwykłym CQRS a połączeniem go z ES w architekturze asynchronicznej, a także powiem o niebezpieczeństwach związanych z tym drugim.

Jak wiemy z wielu publikacji - Event Sourcing daje nam do dyspozycji kilka przydatnych założeń oraz ma wiele zalet takich jak:

- łatwe skalowanie aplikacji, przez podział na Command i Query (w połączeniu z CQRS) oraz procesy asynchroniczne (Prooph)
- pełna „historia” zmian w agregatach, tzw. audyt
- odtworzenie stanu aplikacji w danym momencie do określonego stanu

Gdzie jest haczyk?

Jak zapewne wiecie z doświadczenia lub dowiecie się tego w przyszłości - nie każde podejście jest zawsze właściwe i nie za każdym razem warto sięgać po to samo. Bo nic nie jest uniwersalne. W tym miejscu chcę Was zachęcić do rozmyślań, wzbudzić wątpliwości, wywołać dyskusję, która prowadzi do powstania nowych pytań, hipotez i wniosków. Chcę się z Wami tym podzielić, ponieważ osobiście miałem okazję spotkać się z wieloma pytaniami i sytuacjami w kontekście opisanym w tym artykule.

Warto pomyśleć o tym, jakie zagrożenia są związane z takim podejściem oraz na jakie kompromisy musimy pójść, by zachować spójność danych - zanim napiszemy pierwsze linijki kodu wg tych założeń. Za przykład do tego tekstu posłuży mi coraz częściej używany Prooph + Symfony4. Jeśli znacie implementację tej biblioteki, to będzie Wam łatwiej zrozumieć resztę tekstu, ale wystarczy też sam diagram działania, który przedstawiam na rys. 2. Proces synchroniczny, który wywołuje klient serwisu, dodaje eventy do Event Store’a. A drugi (lub kilka na raz) asynchroniczny odpowiada za tworzenie projekcji ze zgromadzonych eventów. Zastosowanie tego mechanizmu na pierwszy rzut oka może wydać się idealne, jednak jest sporo wątpliwości, które chcę poruszyć.

Zasadnicze różnice między CQRS a CQRS z ES

Schematy zawsze przemawiają do wyobraźni, więc zacznijmy od nich:

(rys.1) CQRS

(rys.2) CQRS/ES Prooph (celowo nie napisałem CQRS+ES)

Jak widzicie, w klasycznym CQRS walidacja jest przeprowadzana zawsze na podstawie aktualnego stanu aplikacji, który jest zmieniany wraz z wykonaniem komendy. W ES ten proces rozumiany jest jako odtworzenie stanu z zebranych eventów, a także wykonanie projekcji (w tym przypadku asynchronicznych) na odpowiednie read modele. Przypuszczam, że w tym momencie już część z Was wie, co może pójść nie tak.

Przedstawię potencjalne zagrożenia i wątpliwości 

Problem 1: Walidacja unikalności

Wyobraźmy sobie taką sytuację - posiadamy model User, który ma pola id, e-mail, password. Pole e-mail ma nałożony unikalny indeks na tabeli projekcji w bazie danych, ponieważ nie chcemy pozwolić na rejestrację użytkownika o takim samym e-mailu. Do tej pory wszystko wydaje się być ok. Następnie user rejestruje się poprzez API, podając e-mail i hasło, dostaje odpowiedź pozytywną (200), lecz z jakiegoś powodu ponawia request.

Teoretycznie aplikacja klienta powinna zadbać o ograniczenia, ale nie żyjemy w świecie idealnym. Gdy chcemy, by taka aplikacja była używana przez zewnętrzne systemy, w nich również może wystąpić niespodziewany problem. Gdy w tym właśnie czasie asynchroniczny proces tworzenia projekcji zostanie przerwany - eventy bez sprawnej walidacji będą odkładane w Event Store - ponieważ aplikacja nie będzie posiadała aktualnego stanu - pozwoli na dodawanie kolejnych eventów. W takiej sytuacji po ponownym uruchomieniu procesu projekcji dostaniemy błąd SQL bezpośrednio z bazy danych. Proces projekcji bez obsługi wyjątków zostanie przerwany (trwale, bo nigdy nie przejdzie przez dodanie duplikatu do tabeli) - a sama obsługa wyjątków nie jest wpisana w to miejsce systemu w żaden sposób. Jest to problem, który nie występuje w architekturze synchronicznej.   

Proponuje kilka rozwiązań takiego problemu. Nie wszystkie są idealne, ale sprawdziły się w podobnych do opisanej sytuacji.

Utworzenie aggregateID na podstawie e-maila

W ten sposób podczas dodawania kolejnego eventu do event store’a dostaniemy błąd w trakcie wykonywania komendy użytkownika. Będzie to błąd dotyczący duplikatu aggregateID, a mechanizm nie pozwoli na utworzenie nowego wpisu, a co za tym idzie - narażania się na kolejne błędne kroki systemu. Zapobiega przerwaniu procesu i awarii systemu. To rozwiązanie może być rozbudowane o kolejne unikalne pola, które chcemy walidować. Należy wtedy odpowiednio przebudować tworzenie aggregateID.

Implementacja obsługi błędów zapytań do bazy danych

Z jednej strony nie jest to ideał, ale z drugiej dodając także logowanie błędów - nie tracimy informacji, że coś poszło nie tak, a sam system działa sprawnie i nieprzerwanie. Analiza logów powstałych podczas obsługi wyjątków pozwoli na kolejne usprawnienia systemu.

Implementacja wzorca Sagi

Saga jest, najprościej mówiąc, zbiorem zasad, ustalonych dla zarządzania danym procesem. Umożliwia jej elementom wybranie dodatkowej lub naprawczej akcji w przypadku błędnego przejścia podstawowego procesu. Sagi reagują na wcześniej zdefiniowane dla niej eventy. Za jej pomocą jesteśmy w stanie naprawić „uszkodzenie” systemu - wywołując event naprawczy. Świadomie zgadzamy się na błędne przejście, by je obsłużyć, jednocześnie nie odstępując od wzorca.

Walidacja i kontrola przepływu po stronie klienta

Pozwala to na zapewnienie względnego bezpieczeństwa. Wszystko zależy od stopnia zabezpieczenia. Możemy przed wysłaniem konkretnego requestu rejestracji klienta, odpytać nasz nowy serwis o to, czy asynchroniczny proces tworzący projekcje aktualnie działa. Taka informacja oczywiście musi być wcześniej udostępniona, jednak mamy tutaj namiastkę gwarancji, że serwis, do którego zaraz wyślemy prośbę o rejestrację, poradzi sobie z jej interpretacją, jak należy. Oczywiście to rozwiązanie nie może być stosowane w ogólnodostępnych serwisach, ze względu na możliwość różnej implementacji klientów.

Problem 2: Autoryzacja użytkownika

Użytkownik loguje się za pomocą swojego loginu i wcześniej ustalonego hasła, lecz by nie musiał robić tego przy każdej akcji, powinien dostać wygenerowany token np. JWT. Za jego pomocą w nagłówku zapytania będzie mógł autoryzować kolejne żądania. Sama akcja jest komendą, więc nie powinna zwracać odpowiedzi poza kodem http. Co można zrobić w takiej sytuacji?

Sam problem dotyczy kwestii, która jest bardzo często implementowana. A rozwiązanie możemy uzyskać poprzez kompromis. Użytkownik rzeczywiście wywołuje odpowiedni Command w systemie, jednak przy poprawnym wykonaniu (gdy użytkownik poprawnie się autoryzuje) system wyzwala Query, które zwraca do użytkownika token. W tym przypadku token musi zostać zapisany przy danym użytkowniku.

Przykład metody:

public function __invoke(Request $request): JsonResponse
{
    	$email = $request->get('email');
    	Assertion::notNull($email, "Email cant't be empty");
   	 
    	$userSignInCommand = new UserSignInCommand(
        	$email,
        	$request->get('pass')
    	);
    	$this->exec($userSignInCommand);
   	 
    	return JsonResponse::create(
        	[
            	'token' => $this->ask(new GetUserTokenQuery($email)),
        	]
    	);
}

Problem 3: Pamiętaj o bazie danych

Przy uruchamianiu zewnętrznych procesów (w tym kontekście - asynchronicznych) działających na DB - należy szczególną uwagę zwrócić na częstotliwość zapytań o potencjalne zmiany, często nikt nie przywiązuje do nich wagi, a nowo postawiony serwis z bardzo małych ruchem i małą częstotliwością Commandów nie potrzebuje zbyt częstej synchronizacji, a przy domyślnych ustawieniach i implementacji może być to operacja niepotrzebnie obciążająca bazę danych. Póki nic się nie dzieje, spowoduje większość jej obciążenia, a przy rozwoju projektu, może stać się poważnym problemem.

Aby zapobiec tak częstym zapytaniom, można zmniejszyć ich częstotliwość, jednak to rozwiązanie może powodować inne anomalie systemu - jakaś zmiana można zbyt późno zostać nałożona na projekcje. Lepszym rozwiązaniem będzie opracowanie mechanizmu blokowania np. na Redisie. Póki żadna komenda nie doda eventu do Event Store, to żadne zapytanie o to, czy były zmiany, nie zostanie wykonane do bazy danych. To zabezpieczenie może ściągać dowolna komenda. W rezultacie uzyskamy bardzo przejrzyste rozwiązanie, które zabezpieczy nasz potencjał operacyjny w przyszłości.

Podsumowanie

Samo podejście ma wiele zalet, ale ma też swoje ograniczenia i kwestie, o których należy pamiętać - zależnie od projektu.

W tym przypadku, pomimo trudności, znaleźliśmy wiele rozwiązań, które pozwalają nam uruchomić serwis w wybranej bibliotece. Jednak nie zawsze wszystko może pójść zgodnie z planem. Za każdym razem zaczynając projekt, musimy jak najszybciej zidentyfikować potencjalne zagrożenia podczas wyboru danej biblioteki czy rozwiązania. Najlepiej jeszcze na poziomie projektowania lub pisania specyfikacji. To wtedy należy podjąć decyzję, czy skorzystanie z tego podejścia da nam korzyści, czy utrudni pracę i może zostać zagrożeniem, które spowoduje opóźnienie lub nawet kompletną porażkę w realizacji projektu. Warto wtedy całkowicie zmienić podejście - póki nie jest za późno.

Jeśli brakuje Wam teorii dot. tego artykułu, zapraszam do publikacji o CQRS i Event Sourcing, w której definicje są wyjaśnione przejrzyście. Także, jeśli uważacie, że pominąłem jakąś ważną informację lub macie inne pytania, na które mógłbym odpowiedzieć w drugiej części artykułu - zapraszam :)

Moją główną motywacją do napisania tego artykułu - było uświadomienie większej grupy programistów na to, by zawsze podczas projektowania nowego systemu, lub chęci implementacji konkretnego wzorca, podejścia - zwracać uwagę na potencjalne zagrożenia. Czy aby na pewno to, co wybierzemy, pozwoli nam na realizację projektu bez zbędnych wybojów lub nawet zderzenia ze ścianą.

<p>Loading...</p>