Poprawna praca ze zdarzeniami w Doctrine ORM

gildia-developerow.pl 10 miesięcy temu

System zdarzeń jest jednym z fajniejszych wzorców projektowych. Nie dość, iż jest on zgodny z SOLID (bo rozszerzamy), to dodatkowo jest on łatwo testowalny. Niestety, czasami możemy przeszarżować, zwłaszcza, o ile chodzi o nasłuchiwanie zdarzeń, które oferuje nam Doctrine 2. Poznajcie kilka wskazówek, dzięki którym (mam nadzieję) pozbędziecie się potencjalnych problemów w przyszłości.

Malutki disclaimer

Podczas czytania tego wpisu, możecie myśleć, iż piszę o zdarzeniach domenowych w Domain Driven Design. Pojęcie domeny aplikacji wychodzi jednak poza ramy DDD. Zatem czytając ten wpis – nie myślmy o tym, iż warstwa domeny w DDD gryzie się ze zdarzeniami Doctrine (bo fakt, gryzie się).

Podstawowe przypadki użycia eventów Doctrine

Zdarzeniem nazwiemy sytuację, w której stan naszego systemu zmienił się. Coś w aplikacji zostało dodane, usunięte albo zaktualizowane. Najczęściej tym czymś jest element modelu, czy jak kto woli w nomenklaturze Domen Driven Design, jest to element domeny. Powstałe wskutek tej sytuacji zdarzenia bardzo często nazywamy właśnie zdarzeniami domenowymi (1).

Przypadków użycia zdarzeń domenowych jest mnóstwo. Ich ilość zależy od dziedziny zagadnienia oraz wymagań biznesowych. Na szczęście, da radę podzielić je na kilka podstawowych grup, które zapisałem poniżej.

Dokładanie logiki dla zainteresowanych

Pierwszą, podstawową kategorią zdarzeń domenowych (a dokładniej, to nasłuchujących na nie listenerów) są te, które będą służyły do rozszerzania logiki biznesowej w obrębie aplikacji. Jest to implementacja zasady Otwarty-Zamknięty z SOLIDu.

Przykładowe przypadki przypięcia logiki biznesowej do zdarzeń domenowych:

  • nadawanie UUID wszystkim encjom implementującym interfejs UuidInterface,
  • zarządzanie polami createdAt oraz updatedAt dla wszystkich encji implementujących interfejs TimestampableInterface
  • śledzenie zmian w encjach, powodu tych zmian, autora oraz kontekstu (HTTP / CLI). Przykładem narzędzia tego typu jest AuditorBundle
  • aktualizacja statystyk sprzedawalności produktów
  • przesyłanie statystyk oglądalności video

Tym, co je wszystkie charakteryzuje, to praca w obrębie tej samej aplikacji. Co z tym często idzie w parze, to również praca w obrębie jednej bazy danych. Wykonanie wszystkich listenerów z tej grupy powinno zamknąć proces biznesowy.

Dogenerowanie elementów niebazodanowych

Drugim typem listenerów na zdarzenia domenowe jest potrzeba dogenerowania czegoś, co fizycznie nie znajduje się w obrębie modelu. Istnienie tych elementów jest natomiast najważniejsze dla naszych procesów biznesowych, które niejednokrotnie mogą być kontynuowane manualnie przez fizyczne osoby. Takimi elementami mogą być:

  • miniaturki uploadowanych zdjęć
  • dokumenty oraz etykiety przewozowe
  • maile oraz inne formy notyfikacji użytkownika

Wspólną cechą tych wszystkich rzeczy jest to, iż nie muszą być one generowane w trybie natychmiastowym. Pomimo ich kluczowości, mogą one zostać wygenerowane choćby parę chwil po fizycznym wystąpieniu zdarzenia.

Informowanie systemów zewnętrznych o zmianie

Kiedy w naszym systemie zostanie utworzony, zaktualizowany bądź usunięty element, o którym wiedza współdzielona jest między kilkoma innymi systemami, może istnieć potrzeba poinformowania tych systemów o całej sprawie. Na przykład:

  • Administrator sklepu śledzi informacje o stanie magazynowym w osobnym systemie. Ten z kolei wysyła do administratora notyfikację, kiedy stan magazynowy jest niski. Aby system magazynowy mógł robić swoje, to aplikacja sklepu musi informować go o każdym zakupie produktu.
  • Administrator sklepu, po opłaceniu zamówienia przez kupującego, loguje się do systemu przewoźnika w celu wygenerowania etykiety oraz wezwania kuriera do odbioru paczki. Aby zaoszczędzić mu żmudnego przepisywania danych z systemu do systemu, system sklepu mógłby wysłać informacje o nowych zamówieniach bezpośrednio do systemu przewoźnika.
  • Pracujemy w systemie-matce, który składa się z wielu mniejszych usług. Każda z tych usług może fizycznie być osobnym mikroserwisem. Informacja o tym, iż uruchamiamy usługę musi być zapisana zarówno w systemie-matce (który zarządza wszystkimi usługami) oraz serwisie usługi. Istnieje w takim razie konieczność poinformowania serwisu usługi przez system-matkę, o utworzenie dostępu dla użytkownika.

Komunikacja między systemami również nie należy do czegoś, co musiałoby być wykonywane w chwili powstania zdarzenia.

Co oferuje nam Doctrine ORM?

Doctrine, jako narzędzie implementujące wzorzec Data Mapper, udostępnia nam zestaw zdarzeń, które możemy nasłuchiwać na dwa różne sposoby(2):

  • Entity listeners – nasuchujemy na zdarzenia w kontekście pojedynczych encji (pojedynczego typu).
  • LifeCycle listeners / subscribers – nasłuchujemy zdarzenia w kontekście pracy klasy UnitOfWork, czyli bez rozróżnienia na typ encji.

Z grubsza, same zdarzenia możemy podzielić na poszczególne pary:

  • {pre|post}Persist – rzucane, kiedy egzemplarz encji pojawia się poraz pierwszy w Doctrine
  • {pre|post}Update – rzucane, kiedy coś zmienia się w polu encji, które jest uwzględnione w plikach mapowań
  • {pre|post}Remove – rzucane, kiedy egzemplarz encji zostaje usunięty z Doctrine
  • postLoad / onFlush – rzucane, kiedy encja jest wczytywana z bazy danych lub zgrywana do bazy danych
  • loadClassMetadata – rzucane, kiedy mapowanie encji zostaje odczytane przez Doctrine.

Eventy są rzucane przez Doctrine niezależnie od tego, czy są nasłuchiwane, czy nie. W Symfony konfiguracja listenerów/subscriberów odbywa się w standardowy sposób, czyli poprzez tagowanie w kontenerze, bądź wrzucanie atrybutów/annotacji w klasie nasłuchującej.

Dużo informacji o zdarzeniach Doctrine możemy znaleźć zarówno w dokumentacji Symfony oraz Doctrine. Dla tych, którzy wolą kodzik – łapcie klasę z wszystkimi eventami. Polecam również prześledzić kod podstawowych metod EntityManagera: persist, remove oraz flush, z których korzystamy na codzień.

Kontynuując główny wątek wpisu, za zdarzenia domenowe będą odpowiadać tylko te dotyczące persystowania, aktualizacji oraz usuwania encji. Pozostałe zdarzenia odbierałbym bardziej jako techniczne, wynikające bezpośrednio z działania samego Doctrine.

Zasady pracy na zdarzeniach domenowych

Każdy wzorzec, używany w odpowiedni sposób, pozwoli wyciągnąć z siebie to, co najlepsze. Podobnie jest z eventami Doctrine. Poniżej przedstawiam kilka zasad, które mogą pomóc Wam w codziennej pracy w tej materii.

Dbaj o niewielki rozmiar event listenerów

Pierwszą zasadą jest to, iż pracujemy w kontekście zupełnie oderwanym od logiki biznesowej naszych funkcjonalności. Oznacza to, iż z marszu nie będziemy pewni, czy event listener został wywołany w kontekście pojedynczej aktualizacji, czy też masowego importu danych. Z tego powodu powinniśmy dbać o to, aby event listenery nie robiły zbyt grubych operacji.

Nie, nie mam tu na myśli rozmiaru logiki biznesowej. Kod PHPa z wersji na wersję wykonuje się coraz szybciej, więc nie mamy się o co martwić. Mowa tutaj o wszelakim generowaniu plików, flushowaniu transakcji bazodanowych oraz komunikacji z systemami zewnętrznymi. W przypadku prostych funkcjonalności większego problemu nie będzie. Jeden przykładowy flush transakcji bazodanowej w tą czy tamtą stronę – na jedno wyjdzie. Problemy mogą pojawić się przy wszelkiej maści importach, operacjach bulkowych (operacje bulkowe w API to bardzo częsty case) oraz procesach wykonywanych w tzw. tle.

Deleguj serwerowi wszystko to, co nie jest krytyczne

Generowanie plików (np. PDF) oraz wysyłka żądań do zewnętrznych API ma cechę wspólną – potrafi trwać. Zwłaszcza, kiedy usługa nie odpowiada. Robimy request, który trwa standardowe 30 sekund, przez co cierpi nasza aplikacja (a na końcu oczywiście zniesmaczony użytkownik).

Rozwiązaniem tego problemu jest delegowanie tych większych zadań do backgroundu. Nie jest istotne, czy zrobimy skrypt, który będzie cyklicznie uruchamiany przez CRONa, czy oprzemy to o kolejki. Najważniejsze jest to, aby maksymalnie odciążyć ten element aplikacji, który działa po stronie użytkownika.

Powyższe może wiązać się również ze zmianami UX aplikacji, ponieważ będziemy musieli przyzwyczaić użytkowników, iż np. faktura przyjdzie za chwilę, albo zamówienie jest w trakcie przetwarzania. W kwestii większych wyzwań, jakimi jest np. import cenników, pomocne może okazać się wyświetlanie odpowiedniego statusu wykonywanego procesu. Pomocne może być również wyświetlenie paska postępu z np. 10-procentowym krokiem.

Wykorzystajmy Messengera!

Wszyscy znamy zasadę YAGNI. Na początku developmentu raczej nie myśli się o potencjalnych problemach. Raczej zbieramy wymagania, które następnie kodujemy w obrębie sprintu. Sprinty są stosunkowo krótkie, a niedobrze to wygląda, kiedy względnie prostą funkcję robimy przez trzy sprinty, bo chcemy zrobić to idealnie. Jak to mi ktoś powiedział, „dobrze zrobione jest lepsze, niż doskonale niezrobione”. Ale za to można przygotować się już na starcie na potencjalne, przyszłe wyzwania.

Wykorzystanie Messengera jest jedną z tych rzeczy, która może nam trochę pomóc. Wewnątrz doctrine event listenera możemy utworzyć komendę, wysłać ją na szynę (synchronicznie), a następnie wykonać logikę biznesową w command handlerze. Z pozoru wygląda to na architektoniczny bubel, bo pomnoży nam się liczba klas, które nie wnoszą logiki biznesowej. Z drugiej strony – dostajemy potencjalną możliwość na bezproblemowe przeniesienie wybranej części logiki do serwera.

Po ostatnie, progresujmy

Jeżeli przyjdzie potrzeba oddelegowania logiki do backgroundu, to nie znaczy, iż musimy od razu kopać się z systemami kolejkowymi. Pamiętajmy – te, podobnie jak inne serwisy – trzeba utrzymywać. Zamiast wejść od razu na kolejki, możemy poeksperymentować z transportem Doctrine. Myślę, iż jest to fajna opcja, która może uratować projekt w momencie, kiedy przyjdzie pierwszy stres.

Dla tych, co lubią szperać – łapcie kod transportu Doctrine. Znalazłem również SymfonyCast o tej tematyce: https://symfonycasts.com/screencast/messenger/async-transport.

Jeżeli aplikacja rośnie, to prędzej czy później przyjdzie potrzeba przejścia na kolejki. Na szczęście, zmiana transportu na system kolejkowy nie wiąże się z wielką reimplementacją. Jest tak, ponieważ Symfony Messenger posiada bardzo rozbudowaną bibliotekę transportów bardzo odizolowanych, od logiki biznesowej, dobrą abstrakcją. Nawet, o ile chcielibyśmy zbudować własny transport, to nie będzie to wiązało się ze zmianami logiki zawartej w komendach i handlerach.

(1) – Właśnie tutaj trochę uprościłem i tutaj jest mała kontrowersja. Zdarzeniem w domenie aplikacji jest to, iż stan aplikacji się zmienił – coś zostało dodane, coś usunięte itp. Nie jest to bezpośrednio zdarzenie w kontekście zdarzeń domenowych Domain Driven Design, bo te powinniśmy budować sami wewnątrz warstwy domeny, a nie liczyć na automaty Doctrine, które nie pochodzą z warstwy domeny. Jako wspomnianą domenę, traktujmy więc zagadnienie, wokół którego się poruszamy, nie łącząc tego z kontekstem DDD.

(2) – Istnieje trzecia opcja, czyli LifeCycle callbacks. Ze względu na różnice w konstrukcji tej mechaniki, nie biorę jej pod rozwagę w tym wpisie.

Idź do oryginalnego materiału