Ponieważ temat Event Sourcing stał się w ostatnich miesiącach bardzo popularny (głównie za sprawą Grega Younga) chciałbym także i ja coś o nim napisać. Coś w rodzaju, powiedzmy, wprowadzenia w zagadnienie w formie odpowiedzi na kilka prostych pytań.

Co to?

Event Sourcing to wzorzec projektowy (dokładny opis znajdziecie u Fowlera), który polega na przechowywaniu stanu obiektów w formie serii zdarzeń reprezentujących modyfikacje. Oznacza to, że nigdzie (poza pewnymi wyjątkami, ale o tym później) nie jest przechowywany aktualny stan obiektu. Stan ten, w razie potrzeby, jest odtwarzany poprzez naniesienie na “czysty” obiekt wszystkich modyfikacji, począwszy od początku świata.

Po co to?

Najważniejszym zyskiem z Event Sourcingu to wbudowanie w system mechanizmu audytowego, który gwarantuje spójność danych transakcyjnych i audytowych… ponieważ są to te same dane. Reprezentacja zdarzeniowa pozwala odtworzyć stan dowolnego obiektu w dowolnej chwili czasu, co w niektórych systemach może być bardzo przydatne. Aby nie być gołosłownym powiem tylko, że sam implementowałem (2 razy…) różnicowy mechanizm zapisu zmian danych w formie dokumentów XML, aby umożliwić podgląd stan obiektu w dowolnej chwili jego życia.

Drugą zaletą jest możliwość szybkiej i bezproblemowej modyfikacji modelu danych wdrożonego i utrzymywanego systemu. Event Sourcing bywa zwykle wykorzystywany w połączeniu z CQRS. W takim wypadku mamy część transakcyjną oraz część raportową (dla celów zapytań). ES wykorzystujemy oczywiście w części transakcyjnej. Powszechnym sposobem przechowywania zdarzeń jest zwykła serializacja. Gdy rozbudujemy zdarzenie o nowe pole, w zależności od zastosowanego mechanizmu serializacji możemy:

  • tak zmodyfikować obiekt, aby obsługiwane były zarówno zdarzenia z, jak i bez, nowego pola
  • stworzyć konwerter, który w runtime podczas podczas odtwarzania obiektu będzie transformował zdarzenia zapisane w starszych wersjach na wersję najnowszą.W takim wypadku obiekt musi umieć radzić sobie jedynie z najnowszą wersją.

Tak czy inaczej, jeśli wykorzystujemy bazę danych do przechowywania zdarzeń (jest to całkiem niezły pomysł), modyfikacja modelu nie wiąże się z modyfikacją schematu bazy transakcyjnej. A co z bazą raportową? Możemy ją po prostu wyrzucić i wygenerować od podstaw przetwarzając cały zapisany od początku świata strumień zdarzeń. Greg twierdzi, że taka operacja nawet dla dużych systemów może być zrealizowana w nocnym oknie serwisowym.

Jak to zrobić?

Można na przykład tak lub tak. Pierwsza implementacja to dzieło Marka Nijhofa, bodaj pierwszy publicznie dostępny duży kawał kodu demonstrujący koncepcje Event Sourcing oraz CQRS. Druga, to moja własna, wykonana w ramach rozbudowy kodu DDDSample.Net. Generalnie, podstawowa implementacja Event Sourcing jest banalnie prosta. Udowodnię to twierdzenie na przykładzie.

Po pierwszy potrzebujemy klasy bazowej dla obiektów przechowywanych w ten sposób. To najprostsza metoda. Można oczywiście próbować stworzyć mechanizm Event Sourcing dla PONO, ale w tym wypadku chyba gra nie jest warta świeczki. Moja klasa bazowa (AggregateRoot) ma trzy ważne metody. Pierwsza z nich to Publish.

protected void Publish<TAggregate, TEvent>(TAggregate @this, TEvent @event)
   where TAggregate : AggregateRoot
   where TEvent : Event<TAggregate>
{
   Apply(@event);
   ((IAggregateRoot)this).Events.Add(@event);
   Bus.Publish(@this, @event);
}

Modyfikuje ona stan obiektu za pomocą przekazanego zdarzenia i jednocześnie publikuje to zdarzenie za pomocą jakiejś szyny — udostępnia je na zewnątrz. Widzimy tutaj wywołanie Apply. Co robi Apply?

private void Apply(object @event)
{
   if (@event == null)
   {
      throw new ArgumentNullException("event");
   }
   string eventTypeName = @event.GetType().Name;
   int suffixIndex = eventTypeName.LastIndexOf("Event");
   if (suffixIndex <= 0)
   {
      throw new InvalidOperationException("Invalid event name: " + eventTypeName);
   }
   string methodName = "On" + eventTypeName.Substring(0, suffixIndex);
   MethodInfo methodInfo = GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
   methodInfo.Invoke(this, new[] { @event });
}

Metoda ta wykorzystuje konwencję

“On” + nazwa typu zdarzenia – suffix “Event”

do znalezienia w obiekcie metody obsługi zdarzenia. Znaleziona metoda jest wywoływana. Jak wygląda praktyczne wykorzystanie? Oto fragment klasy Cargo:

public virtual void SpecifyNewRoute(UnLocode destination)
{
   if (destination == null)
   {
      throw new ArgumentNullException("destination");
   }
   RouteSpecification routeSpecification = new RouteSpecification(_routeSpecification.Origin, destination,
                                                                  _routeSpecification.ArrivalDeadline);

   Publish(this, new CargoDestinationChangedEvent(routeSpecification,
                                          _deliveryStatus.Derive(routeSpecification, _itinerary)));
}
//...
private void OnCargoDestinationChanged(CargoDestinationChangedEvent @event)
{
   _routeSpecification = @event.NewSpecification;
   _deliveryStatus = @event.Delivery;
}

Metoda biznesowa kończy się modyfikacją stanu obiektu (wywołanie Publish w linii 10). Do zdarzenia dopasowywana jest następnie metoda (OnCargoDestinationChanged), która jest wywoływana celem dokonania modyfikacji stanu. Ostatnim elementem układanki jest odtworzenie stanu. Nic prostszego.

void IAggregateRoot.LoadFromEventStream(IEnumerable<object> events)
{
   foreach (object @event in events)
   {
      Apply(@event);
   }
}

To załatwia nam jednak dopiero obsługę zdarzeń po ich odczycie z trwałego magazynu. Aby móc z tego skorzystać potrzebujemy w ogóle możliwości zapisu i odczytu tych zdarzeń. Jak już pisałem, dobrym wyjściem w niekrytycznych wydajnościowo systemach jest użycie relacyjnej bazy danych. Ja, dla większej łatwości użyłem dodatkowo NHibernate, ale tylko dlatego, że bardzo mi się spieszyło. Moje zdarzenie wygląda tak:

public class Event
{
   public int SequenceNumber { get; set; }
   public int Version { get; set; }
   public Guid EntityId { get; set; }
   public object Data { get; set; }
   public bool IsSnapshot { get; set; }
}

Posiada numer kolejny (do sortowania), numer wersji (do optymistycznej kontroli zmian), ID obiektu, zserializowane dane zdarzenia oraz flagę “snapshot”. O snapshotach wspomnę w ostatniej części tej notki. Jakie operacje musimy wykonać na takim obiekcie? Po pierwsze zapis do bazy nowego zdarzenia. Banalnie proste. Po drugie odczyt pełnej serii zdarzeń dotyczących obiektu o podanym ID, w kolejności od najstarszego. To zapytanie również nie powinno nastręczać zbyt wiele problemów.

To wszystko. Dokładnie tyle potrzeba do realizacji własnego mechanizmu Event Sourcing. Prawda, że łatwe?

Jak to zoptymalizować?

Zapewne uważny czytelnik zwrócił uwagę na kilka potencjalnych problemów wydajnościowych związanych z ES. Po pierwsze, odtwarzanie zdarzeń “od początku świata” za każdym razem gdy potrzebujemy obiektu może być bardzo czasochłonne. Dlatego dopuszcza się istnienie, oprócz normalnych zdarzeń, tzw. snapshotów. Są to zrzuty kompletnego obiektu zserializowanego w całości — wyjątek od reguły “przechowywania tylko modyfikacji”, o którym wspominałem we wstępie. Mogą występować np. co 10 zwykłych zdarzeń. Dzięki temu odtwarzając obiekt sięgamy po zdarzenia nie od początku świata, ale tylko do najnowszego snapshotu. Sprytne, prawda?

Kolejna sprawa to serializacja. Standardowa, wbudowana we framework, nie jest zoptymalizowana pod kątem wydajności. Warto przyjrzeć się temu porównaniu wybierając własny mechanizm serializacji dla ES.

Co dalej?

Zachęcam do eksperymentów z Event Sourcing. Nie twierdzę, że wszyscy powinniście ES od razu stosować w systemach produkcyjnych. Warto jednak znać możliwości (oraz ograniczenia) tego wzorca. Być może pewnego dnia okaże się przydatny?

VN:F [1.9.13_1145]
Rating: 5.0/5 (3 votes cast)