Event Sourcing - wprowadzenie PHP post
2017-11-06
Capture all changes to an application state as a sequence of events.
Ciężko o lepszą definicję Event Sourcingu. Niezależnie od technologii i implementacji Event Sourcing to po prostu sposób na tworzenie stanu systemu poprzez zapisywanie i odtwarzanie zdarzeń. Utarło się twierdzenie, że Event Sourcing należy implementować w parze z CQRS. Nie jest to prawda, CQRS bardzo dobrze współgra z Event Sourcingiem jednak mogą istnieć całkowicie od siebie niezależnie. Ten wpis nie poruszy jeszcze tematu implementacji Event Sourcingu za pomocą jakiegokolwiek konkretnego frameworka. Chodzi w nim bardziej o zaprezentowanie i wyjaśnienie samego konceptu niż implementacji.
Najprościej idee stojącą za Event Sourcingiem można przedstawić za pomocą kodu, potrzeba jedynie odpowiedniego przykładu ponieważ Event Sourcing jak każda architektura pasuje do określonego rodzaju problemów. Nie jest to żaden złoty środek ani odpowiedź na pytanie "jak robić żeby było dobrze". Kto nie widział formularza kontaktowego opartego o CQRS i Event Sourcing ten pewnie nie wie o czym mówię dlatego proponuję zaufać na słowo. Śrubki lepiej wkręcać śrubokrętem niż młotkiem.
Fundamentalną zasadą ES jest tworzenie stanu za pomocą zdarzeń. Jeżeli mamy do rozwiązania problem, który wymaga zapisywania i przechowywania dokładnie każdego zdarzenia istnieje bardzo duże prawdopodobieństwo, że trafiliśmy na coś gdzie Event Sourcing może okazać się przydatny. Na potrzeby tego wpisu posłużymy się modelem portfela wirtualnego. Czegoś w rodzaju miejsca do którego użytkownik może wpłacać/wypłacać pieniądze. Tutaj każda operacja jest istotna. Sam fakt, że posiadamy w portfelu $100 nie jest aż tak istotny jak ciąg zdarzeń, który doprowadził do tego stanu.
Zacznijmy może od zdefiniowania Value Objectu reprezentującego pieniądze:
<?php
declare (strict_types = 1);
namespace Example\Domain;
use Example\Domain\Exception\InvalidArgumentException;
final class Money
{
private $validCurrencies = [
'PLN', 'EUR', 'USD'
];
private $amount;
private $currency;
public function __construct(int $amount, string $currency)
{
if ($amount <= 0) {
throw new InvalidArgumentException("Money amount needs to be greater than zero.");
}
if (!in_array(mb_strtoupper($currency), $this->validCurrencies)) {
throw new InvalidArgumentException(sprintf("Illegal currency \"%s\".", $currency));
}
$this->amount = $amount;
$this->currency = $currency;
}
public static function USD(int $amount) : Money
{
return new Money($amount, 'USD');
}
public function isNegative() : bool {}
public function add(Money $money) : Money {}
public function subtract(Money $money) : Money {}
}
Mamy pieniądze, możemy przejść do portfela. Jak wszystkie encje (obiekty które można zidentyfikować i które posiadają cykl życia) portfel trzeb jakoś zainicjalizować, założyć.
<?php
declare (strict_types=1);
namespace Example\Domain\User;
use Example\Domain\UUID;
final class Wallet
{
public function __construct(UUID $ownerId)
{
//...
}
}
Portfel powinien oczywiście do kogoś należeć, załóżmy że w tym systemie użytkowników identyfikuje się za pomocą UUID.
Klasycznym podejściem do problemu byłoby przypisanie identyfikatora właściciela portfela do prywatnego pola w klasie Wallet
o nazwie
$ownerId
. Event Sourcing mówi jednak, że zamiast zmieniać stan powinniśmy rejestrować zdarzenia, które to dopiero tworzą stan.
Stwórzmy więc zdarzenie.
Event
Niezmienne obiekty reprezentujące coś co miało miejsce w przeszłości i na co już nie mamy wpływu.
<?php
declare (strict_types=1);
namespace Example\Domain\User\Wallet;
use Example\Domain\UUID;
final class WalletCreated
{
private $walletId;
private $ownerId;
public function __construct(UUID $walletId, UUID $ownerId)
{
$this->walletId = $walletId;
$this->ownerId = $ownerId;
}
public function walletId(): UUID
{
return $this->walletId;
}
public function ownerId(): UUID
{
return $this->ownerId;
}
}
Patrząc nawet na nazwę tego obiektu widać, że opisuje on jakieś zdarzenie w przeszłości, jakąś zmianę w systemie, która się dokonała. Zdarzenia powinny być proste, nie powinny zawierać logiki, jeżeli do zdarzenia doszło to znaczy, że dane w nim zawarte są poprawne. Nie ma większej potrzeby przeprowadzać walidacji argumentów zdarzenia, gdyby były niepoprawne zdarzenie nie powinno mieć miejsca.
Aggregate Root
Zadaniem Agregatu jest kontrola i hermetyzacja dostępu do jego zasobów. Powinien on robić to w taki sposób aby uniemożliwiać
jakiekolwiek zmiany bez jego udziału. To pojęcie bardzo ogólne, nie zdefiniowane przez sam Event Sourcing jako tako, jednak
mocno wykorzystywane. W naszym wypadku Wallet
jest właśnie Aggregate Rootem.
Idąc krok dalej można powiedzieć, że w zasadzie dowolna encja może być Aggregate Rootem, czy to encja budowana w sposób
klasyczny poprzez zmianę stanu czy tak jak w naszym wypadku poprzez przechowywanie zdarzeń. Posiłkując się jakimkolwiek
frameworkiem w celu implementacji Event Sourcingu pojęcie to będzie wykorzystane do nazwania interfejsu określającego
encję, nie oznacza to jednak że nie implementując Event Sourcingu nie korzysta się z agregatów.
Zobaczmy więc jak mógłby wyglądać nasz portfel
<?php
declare (strict_types=1);
namespace Example\Domain\User;
use Example\Domain\Money;
use Example\Domain\User\Wallet\WalletCreated;
use Example\Domain\UUID;
final class Wallet
{
private $id;
private $ownerId;
private $balance;
private $events = [];
public function __construct(UUID $ownerId)
{
$this->record(new WalletCreated(UUID::generate(), $ownerId));
}
private function record($event) : void
{
$this->events[] = $event;
$this->apply($event);
}
private function apply($event) : void
{
switch (get_class($event)) {
case WalletCreated::class:
$this->id = $event->walletId();
$this->ownerId = $event->ownerId();
$this->balance = Money::USD(0);
break;
default:
throw new \RuntimeException(sprintf('Unknown event type %s', get_class($event)));
break;
}
}
}
Przeanalizujmy więc kod krok po kroku.
private function record($event) : void
- metoda record jest prywatna nie bez powodu, nie chcemy żeby coś z zewnątrz
portfela powiedziało nam, że coś się z nim stało. Jeżeli stan portfela się zmienia to tylko za sprawą portfela.
$this->events[] = $event;
- zdarzenie zostało odnotowane, stało się częścią encji, jakimś krokiem w jej życiu.
Dlaczego nie ma tu żadnej walidacji? Ponieważ zdarzenia mogą pochodzić tylko od tego agregatu i tylko w momencie
kiedy coś już doszło do skutku. Nie można odnotować zdarzenia a potem powiedzieć "to jednak było bez sensu". Takie
stwierdzenia należy stawiać zanim odnotujemy zdarzenie (o tym później).
$this->id = $event->walletId();
$this->ownerId = $event->ownerId();
$this->balance = Money::USD(0);
Następstwa zdarzenia, czyli modyfikacja stanu. Jeżeli coś się dokonało to znaczy, że stan systemu został zmieniony. W naszym wypadku przypisaliśmy identyfikator portfela, właściciela oraz zerowy stan. W przypadku klasycznych encji budowanych za pomocą zmiany stanu te operacje wykonane byłyby pewnie w konstruktorze, w przypadku Event Sourcingu dzieje się to dopiero po odnotowaniu zdarzenia.
Zmiana Stanu
Ok, załóżmy że chcielibyśmy zaimplementować dwie najprostsze operacje związane z wirtualnym portfelem, wpłaty oraz wypłaty. Zaczniemy więc od zdarzenia wpłaty pieniędzy:
<?php
declare (strict_types=1);
namespace Example\Domain\User\Wallet;
use Example\Domain\Money;
use Example\Domain\UUID;
final class MoneyPaidIn
{
private $walletId;
private $money;
public function __construct(UUID $walletId, Money $money)
{
$this->walletId = $walletId;
$this->money = $money;
}
public function walletId(): UUID
{
return $this->walletId;
}
public function money(): Money
{
return $this->money;
}
}
Tak samo jak w poprzednim przypadku, nazwa wskazująca na czas przeszły, niemożliwość zmiany obiektu po jego utworzeniu. Przejdźmy więc do portfela:
<?php
final class Wallet
{
private $balance;
public function payIn(Money $money) : void
{
if ($money->isNegative()) {
throw new \RuntimeException("Can't pay in negative amount.");
}
$this->record(new MoneyPaidIn($this->id, $money));
}
private function apply($event) : void
{
switch (get_class($event)) {
case MoneyPaidIn::class:
$this->balance = $this->balance->add($event->money());
break;
}
}
}
(dla uproszczenia pominąłem kawałki kodu napisane wcześniej)
Jak widać zanim w ogóle odnotujemy wpłatę powinniśmy przeprowadzić walidację. To jest moment w którym należy upewnić się czy zdarzenie powinno się odbyć. W naszym wypadku upewniamy się po prostu czy włacana kwota nie jest ujemna. W dalszej kolejności nagrywamy zdarzenie oraz zmieniamy stan agregatu. Proste, nie?
Analogicznie można zapisać wypłatę pieniędzy.
<?php
final class Wallet
{
public function payout(Money $money) : Money
{
if ($money->isNegative()) {
throw new \RuntimeException("Can't payout negative amount.");
}
if ($this->balance->subtract($money)->isNegative()) {
throw new \RuntimeException('There is not enough funds for this payout.');
}
$this->record(new MoneyPaidOut($this->id, $money));
return $money;
}
private function apply($event) : void
{
switch (get_class($event)) {
case MoneyPaidOut::class:
$this->balance = $this->balance->subtract($event->money());
break;
}
}
}
(dla uproszczenia pominąłem kawałki kodu napisane wcześniej)
W tym przypadku dodatkowo sprawdzamy czy można w ogóle wypłacić podaną kwotę. Przykład jest oczywiście
trochę naciągany, chodzi w nim jednak o pokazanie gdzie powinna następować walidacja operacji.
Patrząc na wszystko powyżej chciałbym zwrócić uwagę na jeszcze jedną istotną rzecz. Z punktu widzenia naszego systemu nie ma większego znaczenia czy encja jest budowana za pomocą zdarzeń czy zmiana stanu. Publiczne API naszej encji pozostaje bez zmian. Publicznym API tej klasy stałyby się metody:
public function __construct(UUID $ownerId)
public function payout(Money $money) : Money
public function payIn(Money $money) : void
Implementując tą samą encję bez ES te metody wyglądałyby identycznie a encja dalej byłaby Aggregate Rootem ponieważ sama decydowałaby o zmianie stanu i o dostępnie do swoich poszczególnych składowych.
Oczywiście bardzo istotną kwestią jest też sposób testowania tego typu encji. Na poziomie testów jednostkowych nie różni się od praktycznie niczym od testowania klasycznych encji.
Event Store
Kolejnym ważnym pojęciem związanym z Event Sourcingiem jest Event Store, czasami też Event Log. Jest to miejsce, które służy do przechowywania zdarzeń w systemie. Zdarzenia występują w konkretnych agregatach jednak trzymane są w specjalnie do tego przygotowanym miejscu. Zdarzenia służą później do odbudowywania encji poprzez ich odtwarzanie w określonej kolejności. Tym głównie zajmują się frameworki ułatwiąjace wdrażanie ES. Definiują podstawowe struktury, mechanizmy które pozwalają w łatwy sposób pracować z eventami, przechowywać je i korzystać z nich do odbudowywania encji.
Najprościej Event Store można wyobrazić sobie mniej więcej za pomocą takiej struktury:
| aggregate_id (uuid) | event (json) | version (int) |
aggregate_id (uuid)
- identyfikator agregatuevent (json)
- zdarzenie w zserializowanej formieversion (int)
- wersja zdarzenia (w ramach konkretnego agregatu)
Cykl Życia Encji
Wiedząc już te wszystkie rzeczy możemy przejść do opisu cyklu życia encji opartej o zdarzenia. Dla ułatwienia opiszę go posiłkując się dodatkowymi pojęciami takimi jak Repozytorium czy Transakcja.
Utworzenie nowej encji
- Utworzenie encji i nadanie jej unikalnego identyfikatora.
- Nagranie odpowiednich zdarzeń.
- Wrzucenie encji do repozytorium, którego implementacja zapisze zdarzenia w Event Store zgodnie z kolejnością występowania.
- Opcjonalnie zapisany zostanie też snapshot (o tym zaraz).
Odtworzenie encji
- Odpytanie repozytorium o encje za pomocą jej unikalnego identyfikatora.
- Odczytanie przez repozytorium wszystkich zdarzeń dotyczących danej encji.
- Odbudowanie encji poprzez przepuszczenie przez nią wszystkich zdarzeń w kolejności ich powstawania (metoda
apply($event)
). - Dorzucenie encji do transakcji (tak aby można było w czasie jej zamykania zapisać nowe zdarzenia).
Snapshoty
Już pewnie zaczęliście zdawać sobie pytania o wydajność. To naturalne, Event Sourcing polega na odnotowywaniu wszystkich zdarzeń w systemie, w związku z tym ich ilość nawet w obrębie jednego agregatu będzie rosnąć razem z czasem jego życia. Nie trudno wyobrazić sobie portfel który po 2 latach korzystania posiada setki tysięcy wykonanych operacji. Zgodnie z tym co napisałem wcześniej każdorazowe dostanie się do takiego portfela w celu wykonania nowej operacji (odnotowania zdarzenia) będzie się wiązać z odczytaniem wszystkich poprzednich zdarzeń i wykonaniem ich jednego po drugim. Byłoby to wysoce nieefektywne i niewydajne. Właśnie z tego powodu korzysta się z Snapshotów. Snapshot reprezentuje stan w którym znajduje się dana encja po nagraniu określonej ilości zdarzeń.
Można więc przyjąć (oczywiście jest to zależne od specyfiki encji), że np. co 5 zdarzeń powinien być generowany nowy snapshot. Dzięki temu repozytorium, które normalnie musiałoby odbudować encję zdarzenie po zdarzeniu może po prostu odczytać stan encji z snapshotu, ustawić go i przyjąć, że poprzednie zdarzenia po prostu się wykonały bez konieczności ich ponownego ładowania. Dzięki takiemu podejściu agregaty mogą być budowane latami a system dalej będzie działać tak jakby był postawiony wczoraj.
Podsumowanie
Event Sourcing świetnie sprawdza się w sytuacjach w których liczy się nie tyle stan systemu co sposób w jaki do niego doszło. Bardzo dobrym przykładem jest zarządzanie jakimikolwiek pieniędzmi. Przykładowo konto w banku. Wyobraźmy sobie co by się stało gdyby nagle na naszym rachunku bankowym ktoś wykonał metodę "setBalance($0)", nie byłoby fajnie? Innym przykładem systemu idealnie nadającego się do pracy z Event Sourcingiem jest system kontroli wersji (plików, dokumentów, czegokolwiek). W zasadzie za każdym razem kiedy w grę wchodzą pieniądze ES powinien się nadać.
Jest to pierwszy wpis z serii Event Sourcing. W kolejnych postaram się poruszyć kilka istotnych problemów wynikających z tego podejścia oraz sposób radzenia sobie z nimi. Przedstawię też przykładową implementację opartą o framework prooph.
Klasa Money której używam jest oczywiście bardzo "naiwną" implementacją bardzo złożonego problemu jakim są pieniądze. Osobiście korzystam i polecam moneyphp/money.