Pytanie na dziś: co lepiej zrobić, wysłać komendę, czy wygenerować nowe zdarzenie? Jakie konsekwencje będzie miało pierwsza, a jakie druga opcja? Dzisiaj odpowiemy sobie na to pytanie.

Komendy i zdarzenia w Modularnym Monolicie

Kiedy tworzymy Modularny Monolit (dla tych, co nie wiedzą, co to jest – łapcie link do wpisu wyjaśniającego ten temat), to musimy w jakiś sposób skomunikować ze sobą moduły, które są od siebie zależne. Jednym z podejść jest wysłanie w obrębie modułu zdarzenia, na które będzie nasłuchiwane przez inne moduły. Moduły, które opierają się na tych zdarzeniach, najczęściej będą wysyłały na szynę komendę, która zostanie wykonana przez moduł ją wysyłający.

W tym miejscu jako komendę odbieramy żądanie odnoszące się do czasu przyszłego w formie „zrób to”. Komendą będzie stwierdzenie „Utwórz produkt”, „Wylicz trasę”, „Zaktualizuj statystyki”. Sama komenda znajduje się w granicacj modułu, który ją wykonuje. I jest zobowiązany wykonać ją (musi istnieć w systemie handler, który ją przetworzy). Jako zdarzenie rozumiemy stwierdzenie faktu, który zaistniał w przeszłości, używając przy tym czasu przeszłego. Zdarzeniem zatem będzie „Produkt został utworzony”, „Trasa została wyliczona”, „Statystyki zostały zaktualizowane”. Zdarzenia są elementem należącym do domeny modułu, którego domena (jej stan) uległa zmianie. Pozostałe moduły mogą, ale nie muszą być nią zainteresowane. Może nawet okazać się, że w obecnej chwili nie ma żadnego modułu, który byłby tym zdarzeniem zainteresowany. I to jest całkiem w porządku.

Pracując nad modularnym monolitem, jednym z częstszych scenariuszy jest praca w obrębie jednego zespołu, gdzie każdy developer odpowiedzialny jest za jakieś części systemu (moduł, bądź kilka). Problemy natury „ja stworzę zdarzenie, a Ty je sobie odbierz” rozwiązywane są na bieżąco, w obrębie codziennych spotkań daily, bądź podczas planowania sprintu. W tym miejscu mamy dużą swobodę działania, nie mamy zbyt wielu konsekwencji związanych z tym, czy utworzymy jakąś komendę, czy nie. Bo jeżeli będzie potrzebna – ktoś w zespole ją utworzy.

Komendy i zdarzenia w pracy z mikroserwisami

Jedną z zalet pracy nad mikroserwisami jest autonomia serwisu w obrębie wyznaczonych granic. Powoduje to, że często wyznacza się osobne osoby, bądź nawet zespoły, które będą pracowały w odseparowaniu od innych zespołów, które opiekują się innymi serwisami. Czasami wręcz są to zespoły porozrzucane między różne firmy oraz strefy czasowe. Komunikacja między zespołami staje się wtedy ograniczona.

Jedną z częstszych form komunikacji mikroserwisów jest komunikacja przez systemy kolejkowe. Polega to na tym, że zespoły, których praca jest od siebie zależna, komunikują swoje aplikacje poprzez wysyłkę na tzw. szynę wiadomości, które podobnie jak w modularnym monolicie, mają formę zdarzeń oraz komend. Jeżeli chcecie zobaczyć przykładową implementację takiej komunikacji, to znajdziecie ją w innym moim wpisie: Komunikacja dwóch mikroserwisów z Symfony Messengerem. No ale wracając do tematu – z perspektywy dwóch mikroserwisów to, w jaki sposób integrujemy się między sobą – używamy komend, czy zdarzeń – potrafi mieć duże znaczenie w kontekście tego, ile pracy będzie miał jeden zespół w swoim serwisie, jeżeli ten drugi zespół coś u siebie zmieni.

Słuchajcie, głupia sprawa, bo zmieniłem komendę…

Na start na tapet weźmy komendy. Bo lubimy tworzyć komendy. Zrzucamy wtedy odpowiedzialność integracji na innych. Rozgłaszamy tylko, że taka i taka komenda powstała i voilà. Mija czas. My cieszymy się, bo nie musimy nic robić – w końcu inni się martwią. Następnie po pół roku przychodzi biznes i prosi o zmianę części funkcjonalności, które wymuszają na nas zmianę komend. No i… okazuje się, że nie możemy tego zrobić, bo w tzw. międzyczasie zintegrowało się z nami kilkanaście serwisów, którym zniszczylibyśmy życie, robiąc zmianę tak po prostu.

W tym miejscu nie ma wygranych: my potrzebujemy zmiany, a pozostali potrzebują co najmniej czasu, aby się dostosować. W tej sytuacji najlepszym rozwiązaniem może okazać się utworzenie nowych komend, które będą obsługiwały te nowe funkcjonalności, równocześnie robiąc deprecjację na aktualnych. Niestety, dotrzymanie kompatybilności wstecznej może utrudnić nam pracę nad nowymi rzeczami. No i dobrze by było w sumie poinformować wszystkie zależne od nas zespoły, dając im równocześnie trochę czasu na dostosowanie się do nowego formatu wiadomości.

Zdarzenia domenowe vs generyczne

Wyobraźmy sobie, że mamy dwa mikroserwisy – pierwszy, dotyczący profilu użytkownika ProfileManager, oraz drugi, który dotyczy logów (Logger). Zakładamy, że Logger nasłuchuje na zdarzenia ProfileManagera.

Jako zmianę profilu, możemy potraktować kilka różnych (osobnych) funkcji, które generują osobne zdarzenia domenowe:

  • PersonalInformationHasBeenChanged – zdarzenie prezentujące zmianę danych osobowych użytkownika
  • MainPhotoHasBeenChanged – zdarzenie dotyczące zmiany głównego zdjęcia profilowego
  • PasswordHasBeenChanged – informacja, że hasło zostało zaktualizowane.

Na te zdarzenia nasłuchuje Logger. Jego implementacja będzie stosunkowo prosta. Ba – wręcz generyczna. Każde zdarzenie, na które nasłuchuje, będzie obsługiwane wewnętrznie przez ten sam mechanizm. Ponieważ zbyt wiele tutaj logiki nie trzeba, to zespół pracujący nad tym mikroserwisem prawie się nim nie zajmuje – wręcz walczy z bardzo ważnymi deadlinami związanymi z innym serwisem.

Okazuje się. że aplikacja ProfileManagera przybrała rozpędu biznesowego i dokładana jest tam duża ilość nowych funkcjonalności: mamy informacje o naszej karierze oraz rekomendacje od innych użytkowników. ProfileManager implementuje nowe zdarzenia:

  • NewWorkPositionHasBeenAdded oraz WorkPositionHasBeenRemoved
  • NewRecommendationHasBeenAdded, RecommendationHasBeenUpdated, RecommendationHasBeenApproved oraz RecommendationHasBeenRemoved

Aby biznesowo wszystko było spójne, koniecznie trzeba obsłużyć te zdarzenia w Loggerze. Bo biznes będzie chciał analizować nowe zachowania użytkowników, aby móc na nie odpowiednio reagować. Niestety – zespół, który opiekuje się Loggerem walczy teraz z innym wyzwaniem, co powoduje, że nie może zająć się tematem w czasie, kiedy to jest potrzebne.

Rozwiązaniem tego typu problemu (najlepiej przed jego wystąpieniem) jest komunikacja między tymi dwoma serwisami za pomocą jednego, generycznego zdarzenia: UserProfileHasBeenUpdated. Kiedy cokolwiek nowego zdarzy się po stronie ProfileManagera, to Logger zawsze będzie na to przygotowany. Oczywiście pod warunkiem, że kontrakt zdarzenia został dotrzymany – jego format się nie zmienił.

Lekcja, jaką w tym miejscu możemy wyciągnąć jest taka, że musimy umieć przewidzieć, który mikroserwis będzie miał większy potencjał na zmiany kontraktowe. Dobrze jest pogadać z drugim zespołem na ten temat przed integracją. W określeniu tej kwestii może pomóc nam stwierdzenie, który serwis prezentuje bardziej generyczną domenę. W tym wypadku bardziej generyczny był Logger.

Zdarzenia publiczne i prywatne

Wygląda na to, że naraziłem się wszystkim, którzy kochają DDD 😉 Bo jak to tak może być, że mamy jedno generyczne zdarzenie, kiedy my właśnie chcemy dbać o naszą domenę i ją rozwijać. Zatem pytanie: co jest lepsze: zdarzenia domenowe, czy generyczne?

Odpowiedź brzmi: oba. Zależnie od tego, do czego ich potrzebujemy. Nigdy nie powinniśmy traktować żadnego rozwiązania jako remedium na wszystkie problemy. Jeżeli potrzebujemy zdarzeń domenowych – robimy domenowe. Jeżeli korzyść przyniesie nam zdarzenie generyczne (w powyższym przypadku tak jest), to używamy generycznego, bardziej ogólnego zdarzenia. A jeżeli potrzebujemy obydwu – nic nie stoi na przeszkodzie, abyśmy je stosowali razem.

Możemy w końcu skonfigurować dwie szyny w obrębie ProfileManagera – jedną do zdarzeń prywatnych, czyli tych rozpatrywanych wyłącznie w granicach ProfileManagera, oraz drugą – służącą do komunikacji z Loggerem. I tutaj mamy dwie możliwości:

  • Wraz z wystąpieniem zdarzenia prywatnego (np. PasswordHasBeenChanged), w tym samym czasie puszczamy na szynę publiczną zdarzenie ProfileHasBeenUpdated.
  • W momencie wystąpienia zdarzenia prywatnego, wysyłamy je na szynę prywatną. Na tej szynie będziemy nasłuchiwać subscriberem na wszystkie zdarzenia, których wystąpienie ma w efekcie utworzyć zdarzenie ProfileHasBeenUpdated. I właśnie to publiczne zdarzenie wysyłamy na publiczną szynę, wewnątrz subscribera.

Tego typu mechanizmów możemy mieć wiele – wymieniłem jedynie dwa, które prawdopodobnie rozważałbym, gdybym potrzebował tego typu problem rozwiązać. Istotne tutaj jest to, aby zdarzenia prywatne były „zamknięte” wewnątrz serwisu, a zdarzenia publiczne mogły lądować na szynie, do której podłączyć mogą się inne systemy.

Zdarzenia snapshotowe i ważność zdarzeń

Kolejną kwestią, z którą musimy się zmierzyć, kiedy komunikujemy ze sobą mikroserwisy jest synchronizacja. Ponieważ jesteśmy tylko ludźmi, to piszemy aplikacje zbugowane, wysypujące się, rzucające 500ki. Wiadomości czasami nie są wysyłane kiedy powinny, a czasami są problemy z ich odbiorem. W wyniku tego powstają przekłamania danych, które w dalszej konsekwencji tworzą trudne do analizy problemy. Dodatkowo, przez kolejki wszystko odbywa się asynchronicznie. Zazwyczaj mamy kilka procesów konsumujących na szynie, które mogą w sposób konkurencyjny przetwarzać dwie wiadomości, które powinny zostać przetworzone jedna po drugiej.

Wyobraźmy sobie, że gubimy wiadomości. Czasem nie wpadnie zdarzenie dotyczące zmiany ceny produktu, czasami nie wpadnie zdarzenie dotyczące aktualizacji atrybutów produktu. Generalnie jest słabo – rozjazd danych mamy duży. O ile nie uchronimy się przed odtworzeniem i poprawieniem buga, to istnieje sposób na zmniejszenie skali rozjazdu: wdrożenie zdarzeń snapshotowych. Polega to na tym, że wraz ze zdarzeniem wysyłamy nie tyle informację o tym, co się zmieniło, ale informację o obecnym, pełnym stanie obiektu, którego to dotyczy. Czyli w zdarzeniu ProductNameHasBeenChanged nie będziemy zwracali wyłącznie nowej nazwy produktu, ale cały produkt. Dzięki tym informacjom druga strona może w momencie odbioru tego zdarzenia zaktualizować cały produkt. W takiej sytuacji, jeżeli gdzieś zgubimy jedno zdarzenie dotyczące produktu, to drugie zdarzenie dotyczące tego samego produktu spowoduje nadpisanie wartości, która została „zgubiona”.

Oprócz zalet, zdarzenia snapshotowe mają również wady. Jedną z nich jest problem związany z przetworzeniem dwóch wiadomości, które nastąpiły po sobie, w odwrotnej kolejności. W sytuacji, kiedy mamy dwa procesy konsumujące podpięte pod jedną szynę – jest to całkiem prawdopodobny przypadek. Efektem tego scenariusza będzie nadpisanie informacji aktualnej tą nieaktualną. Jest bardzo prosty mechanizm, który może zapobiec tej sytuacji: do zdarzeń dołączamy timestamp utworzenia zdarzenia, który następnie zapisujemy w aktualizowanej encji po drugiej stronie. W momencie, kiedy przychodzi do nas zdarzenie, to porównujemy obydwa timestampy, jednocześnie odrzucając wiadomość, jeżeli dla tego kontekstu została przetworzona wiadomość z nowszym timestampem.

Odpowiadam na pytanie

Jak zwykle – to zależy. Jeżeli bawimy się w mikroserwisy, to najlepiej byłoby mieć kogoś, kto zaplanuje całą architekturę, również w związku z wiadomościami, które będą przepływały między nimi. Bo decyzje związane z komunikacją powinny być podejmowane na podstawie pełnego obrazu całego ekosystemu, a nie dlatego, bo „nam jest łatwiej tak to zaimplementować” 🙂

Profesjonalista, zajmujący się na co dzień aplikacjami biznesowymi w ekosystemie PHP. Jego pasją jest odkrywanie nowych konceptów programistycznych oraz wzorce architektoniczne. Uwielbia również pisać testy, gdyż jak sam uważa, dobry kod to przetestowany kod.

Comments are closed.