Jak robić projekcje zdarzeń dla rozbudowanych struktur obiektów?
Cześć!
Jak tam Twój okres świąteczny? Urlopik, czy może odrabianie drugiego dnia Świąt dzisiaj? Ja jestem B2B, więc nie ma urlopu dla mnie, plus nowa robota. Niby średnio, ale niektórzy się cieszą, że mogą pracować, bo rozbiegane dzieciaki w domu. Ja mam mały przedsmak jak to jest, bo moja córka ząbkuje.
O niezły ból (chociaż raczej głowy niż zęba) potrafią przyprawić też bardziej zaawansowane scenariusze i edge case’y zanim je dobrze poznamy. Dajmy na to projekcje. Dla przypomnienia - projekcja to interpretacja pewnego zestawu zdarzeń. Służy ona zwykle do budowy read modelu. Przykładowo - mając zdarzenia związane z zakupem biletu do kina: UtworzonoTymczasowąRezerwację, ZmienionoMiejsceRezerwacji, PotwierdzonoRezerwację i aplikując je po kolei wiemy, że rezerwacja została potwierdzona i jaki był ostateczny numer siedzenia. Taki wynik możemy zapisać po prostu jako wpis w bazie relacyjnej. Możemy też wyprodukować zdarzenie i zapisać je w osobnym strumieniu. Wtedy ostatnie zdarzenie w tym strumieniu reprezentuje aktualny stan read modelu. W dużym uproszczeniu - w pierwszy sposób działa Marten (https://martendb.io/documentation/events/projections/custom/), w drugi - Event Store (https://developers.eventstore.com/server/20.6/server/projections/).
Przykład jak można zdefiniować projekcję w Marten z mojego repo: https://github.com/oskardudycz/EventSourcing.NetCore/blob/main/Sample/Tickets/Tickets/Reservations/Projections/ReservationDetails.cs.
W ogólności nie wydaje się to specjalnie trudne i faktycznie nie musi takie być. Jedna jak to w życiu, gdy wejdziemy w niego głębiej może się okazać, że nie zawsze jest tak prosto. Co w sytuacji gdy mamy relacje między różnymi projekcjami i obiektami w naszym systemie? Na przykład gdy mamy Szkołę, a w niej Uczniów, którzy mogą mieć Rodziców? W jaki sposób zrobić projekcję, w której mamy szkołę i wszystkie dzieci oraz nazwiska ich rodziców? Czyli ogólnie jak obsługiwać zagnieżdżone relacje?
Moim zdaniem są 3 główne opcje na podejście do tego tematu, plus szara strefa pomiędzy nimi:
- Opublikować cięższe zdarzenie (“fat event”), który posiada wszystkie dane, które potrzebne są do zbudowania read modelu przez projekcję. Czyli np. UczeńDodanyDoSzkoły z IdUcznia, IdSzkoły, ale też dane ucznia z zagnieżdżonymi danymi rodzica.
- Opublikować zdarzenie tylko z identyfikatorami zależnych obiektów. Czyli np. UczeńDodanyDoSzkoły z IdUcznia, IdSzkoły, ale też dane ucznia z identyfikatorem rodzica. W takim przypadku jeśli chcemy mieć zdenormalizowany model odczytu to możemy dociągnąć obiekty zależne w momencie aplikowania projekcji.
- Opublikować takie zdarzenie jak w punkcie 2, ale nie doczytywać danych zależnych. Wtedy trzymamy dane znormalizowane. Czyli klasycznie jak w bazach relacyjnych. Wtedy powinniśmy zrobić join (lub “lookup” jeśli korzystamy z bazy dokumentowej) w momencie odczytu.
Każda z tych opcji ma wady i zalety:
- Pierwsza opcja jest najszybsza w procesowaniu, bo wszystkie dane będą już dostępne w zdarzeniach. Jednakże nadmiarowość danych w zdarzeniach czyni je cięższymi w przechowywaniu i transporcie. Dodatkowo takie zdarzenia są trudniejsze w utrzymaniu ze względu na to, że są bardziej narażone na zmianę schematu. Im więcej posiadają tym większa szansa, że te ich struktura się zmieni.
- Drugi scenariusz czyni zdarzenia mniejszymi, ale samo aplikowanie projekcji będzie trwało dłużej trwało, ze względu na konieczność doczytania dodatkowych danych. Powinno się tutaj rozważyć procesowanie asynchroniczne (a co za tym idzie eventual consistency). Podobnie jak rozwązanie pierwsze zaletą tego rozwiązania jest to, że odpytywanie będzie szybkie bo dane są zdenormalizowane. Jednakże tutaj też jest większa szansa na trafienie na sytuacje gdy np. nazwisko rodzica się zmieni i będziemy musieli przy zdarzeniu NazwiskoRodzicaZmienione zaktualizować kilka rekordów. Tutaj zawsze musimy przeanalizować strukturę naszych danych. W przypadku szkoły i rodziców, nie będzie to raczej bardzo kosztowne, bo rzadko jeden rodzic ma więcej niż 1-3 dzieci w jednej szkole. Jednakże w scenariuszu np. danych słownikowych, które są często używane może to miec znaczenie.
- Trzecie rozwiązanie jest najszybsze jeśli chodzi o procesowanie, przesył danych, ale najwolniejsze jeśli chodzi o odczyt. Zarówno zdarzenia jak i same projekcje nie będą miały nadmiarowych danych (tylko minimalny potrzebny zakres). Z tego też powodu będziemy musieli “sklejać” dane w momencie odczytu. Nie zawsze to jest wielkim problemem, ale użycie joinów zawsze niesie za sobą pewien negatywny wpływ na wydajność. Jednak jeśli mamy dobry rozkład danych i dobrze je poindeksujemy to również to powinno być dobre rozwiązanie.
No i pomiędzy tymi wszystkimi przypadkami “szara strefa”. Niestety nie uciekniemy od klasycznej odpowiedzi “to zależy”. Każdy przypadek jest inny i chcą mieć dobre, wydajne ale i utrzymywalne rozwiązanie musimy wziąć pod uwagę ostateczne scenariusze użycia. Powyższe punkty to heurystyki i rady, które liczę, że mogą Ci pomóc.
Masz jakieś pytania? Chcesz zobaczyć kod do tych przykładów? Daj znać!
Pozdrawiam i już korzystając z okazji życzę Ci wszystkiego najlepszego w Nowym Roku. Niech ten 2021 będzie bardziej jak dobry los, trafione oczko niż ten parszywy rok 2020.
Oskar
p.s. zachęcam do lektury mojego wpisu na blogu “How to (not) do event versioning”, który myślę jest dobrym uzupełnieniem tego maila: https://event-driven.io/en/how_to_do_event_versioning/. p.s.2 dzisiaj też wypuściłem już trzecie “Architecture Weekly” https://github.com/oskardudycz/ArchitectureWeekly#28th-december-2020. Jest tam ciekawy atrykuł jak Twitter używa Kafki do przechowywania zdarzeń. Jest to ciekawe case study, ale może się okazać mylące dla początkującego. Jeśli chcesz to daj znać, to zrobię za tydzień analizę tego wpisu.