Jak zwrócić rezultat wykonania komendy w CQRS?

18

W tekście “CQRS+DI w C# i Autofac” pokazałem, że CommandHandler nie zwraca żadnego rezultatu wykonania komendy. Natomiast w “Esencja CQRS” pisałem, że jest to jedna z zasad, co do której można się spierać. Więc… jak to faktycznie jest?

Akcja: BLOGvember! Post nr 23.
W listopadzie w każdy roboczy poranek na devstyle.pl znajdziesz nowy, świeżutki tekst. W sam raz do porannej kawy na dobry początek dnia.
Miłej lektury i do przeczytania jutro! :)

Wrzucanie komendy w system i “pójście dalej” bez żadnej informacji o rezultacie bywa możliwe, lecz zwykle ciężko byłoby to zaimplementować. No bo jak to: kazałem coś zrobić mojej aplikacji i nie wiem czy się udało, więc… co dalej? Gdzie pokierować użytkownika?

Moje systemy są wyjątkowe!

I to w ilu znaczeniach! Nawet nie chodzi o to, że są super. Albo, że nigdzie na świecie nikt nie wymyślił takich. Bo przecież jest dokładnie odwrotnie.

Chodzi o to, że moje komendy – jak zresztą KAŻDY kawałek kodu – MOŻE zwracać rezultat. Nawet, jeśli sygnatura mówi: void. Bo niekoniecznie musi to być int, bool, enum czy pełnoprawna klasa. W ogromnej – naprawdę OGROMNEJ – większości przypadków nie potrzebuję konkretnych danych będących wynikiem wykonania operacji. Potrzebuję tylko jednej informacji: udało się, czy nie?

A jak można taką informację osiągnąć z kontraktu zwracającego void? Hmm, no zastanówmy się… Czyż nie będzie logiczne takie założenie: “nie wywaliło się, czyli się powiodło”? Tak, będzie! Rozwiązaniem są wyjątki.

O sposobie implementacji oraz motywacjach kryjących się za tym podejście rozpisałem się już kiedyś w tekście “Custom Exceptions“, więc nie będę się tutaj powtarzał. Odsyłam na chwilę do tamtego posta. Idź, idź. Jak Mietek Fogg – to był gość – “ja mam czas, ja poczekam“.

Już? W ilu głowach w tym momencie pojawiło się takie wykrzyknienie:

Przecież nie można “sterować programem za pomocą wyjątków“!

Zgadza się, jak najbardziej. Ale kiedy w takim razie MOŻNA zastosować wyjątek? Czyż sama nazwa nie wskazuje na to, że… w sytuacji wyjątkowej?

Pozostaje kwestia definicji takiej sytuacji. Najpierw to ustalmy, a najwyżej potem będziemy się kłócić. Przedwczesna kłótnia jest gorsza niż “prematuralne” wiadomo co.

W moim pięknym świecie – a przypomnę ponownie, że teorię tę weryfikowałem przez lata stosowania jej w praktyce – obowiązuje takie coś:

Sytuacja wyjątkowa (rzecz.) – każda ścieżka różna od legendarnej “happy path”

Użytkownik chce zarezerwować na koncert więcej biletów, niż jest dostępnych w systemie? Wyjątek – jakaś walidacja powinna to wcześniej wyłapać! Ktoś chce zadzwonić do klienta, ale w numerze telefonu mamy literki zamiast cyferek? Wyjątek – niepoprawne dane w bazie! Przechodzimy z koszyka do kasy, a w międzyczasie sesja wygasła? Wyjątek!

Można by takie scenariusze mnożyć. To NIE JEST sterowanie wyjątkami. To jest zasygnalizowanie sytuacji wyjątkowej. Z tą różnicą, że ja – jako programista – przewidziałem możliwość jej wystąpienia. Co wcale nie czyni jej… mniej wyjątkową!

Wyjątek od reguły

Jest jedna – słownie: jedna – sytuacja, w której naprawdę przydałoby się zwrócenie z komendy konkretnej wartości. Jest to scenariusz, w którym użytkownik dodaje do systemu nowy obiekt i od razu chcemy przekierować go do ekranu szczegółów/edycji tegoż. Żeby to zrobić: musimy znać ID obiektu.

Czy dla jednego scenariusza warto komplikować całą infrastrukturę, dodając wsparcie dla zwracania wartości przez komendy? Moim zdaniem: zdecydowanie nie.

Więc jak sobie poradzić? Poniżej dwa przykładowe rozwiązania. Jest ich pewnie więcej.

Sposób 1 (skomplikowany). Wysyłamy do systemu komendę “utwórz obiekt”. System ją przetwarza, obiekt zostaje utworzony. Następnie system komunikuje się w drugą stronę, aktywnie wysyłając wiadomość do klienta: powstał nowy obiekt z {id}. Klient przekierowuje użytkownika. Wymaga to sporo zachodu, bo na kliencie musimy zaimplementować kanał przyjmowania wiadomości od serwera.

Sposób 2 (prostszy). Musimy tylko ZNAĆ ID obiektu PRZED jego utworzeniem, rajt? Da się to zrealizować nawet bez rezygnowania z “autoincremented primary key identity integer column” w bazie, która dla wielu jest świętością. Wystarczy dodać… kolejną kolumnę! UniqueId, typu GUID. I generować to ID po stronie klienta, przesyłając je wraz z pozostałymi danymi potrzebnymi do utworzenia obiektu. Po zakończeniu przetwarzania komendy (czyli: jeśli nic się nie wywaliło) możemy spokojnie założyć, że obiekt o takim ID znajduje się w systemie i jest właśnie tym nowym, o który nam chodzi.

Zrobione? Zrobione! A system nadal jest prosty? Nadal.

Jak zakończyłby swój wywód matematyk-taksówkarz: “co należało dowieść“.

Fajrant.

Share.

About Author

Programista, trener, prelegent, pasjonat, blogger. Autor podcasta programistycznego: DevTalk.pl. Jeden z liderów Białostockiej Grupy .NET i współorganizator konferencji Programistok. Od 2008 Microsoft MVP w kategorii .NET. Więcej informacji znajdziesz na stronie O autorze. Napisz do mnie ze strony Kontakt. Dodatkowo: Twitter, Facebook, YouTube.

18 Comments

  1. Pingback: dotnetomaniak.pl

  2. Czy nie lepiej zamiast wyjątku zastosować prosty message passing i zarejestrować się na odpowiedź? Dostajemy z tym całe dobrodziejstwo kolejkowania :) Warto wspomnieć, że guid. Net’owy trochę różni się od sql’owego i wrzucany do bazy pogarsza wydajność.

    • Oczywiście wspomniałeś o kolejkowaniu :) Umknęło to mojej uwadze.

    • Jakkolwiek by tego rozwiązania nie nazwać, wpasowuje się to w sposób 1. opisany przez Maćka, czyli chwilowe odwrócenie ról w procesie komunikacji klientserwer. Czy będzie to jakaś kolejka, czy SignalR, czy cokolwiek innego. nie ma większego znaczenia, bo i tak musimy się narzeźbić w architekturze w celu ogarnięcia tego jednego przypadku, co ma niewielki sens.

    • Pogarsza jeżeli nie poprzesuwane zostaną byte’y. Jeżeli wygenerujesz Guid w wersji pierwszej a nie czwartej, używając funkcji UuidCreateSequential zamiast Guid.NewGuid(), można tak przesortować byte’y aby były zgodne z porządkiem byte’ów klucza klastrowanego. I problem rozwiązany :)

    • Karol,
      Kolejkowanie jest dobrodziejstwem, jeśli faktycznie go potrzebujemy :).
      A GUID… Dodanie kolumny tego typu, która jest używana tylko w jednym scenariuszu, spowolni inne scenariusze? Możesz podać jakiś link opisujący takie zachowanie?

  3. Ja po prostu stosuję dwa interfejsy. Pierwszy dla większości komend, które nie zwracają rezultatu, a drugi dla tych gdzie rezultat jednak chce otrzymać.

    public interface ICommand
    public interface ICommand : ICommand

    Do tego mam dwie metody do wykonania komend.

    public void Execute(ICommand command)
    public TResult Execute(ICommand command)

    Zbytniego skomplikowania tutaj nie ma, a rozwiązuje to problem zwracania rezultatu w konkretnych przypadkach. Oczywiście można by się przyczepić, że system jest niespójny, bo raz komenda coś zwraca, a raz nie. No ale ja jednak wole tu się kierować pragmatyzmem i tak jest mi zwyczajnie wygodniej :)

    • Bogusz,
      Zależy jak zdefiniujemy “skomplikowanie” :). Dla mnie jest to duża komplikacja, nawet jeśli nie na poziomie samego kodu, to na poziomie koncepcyjnym. Można to oczywiście zrealizować na wiele sposobów (i nawet próbowałem to robić tak jak napisałeś, żeby nie było), ale moim zdaniem jest to rozwiązywanie nie tego problemu co trzeba.

  4. Generyczny rezultat z command handlera zmusza nas do pamiętania jaka komenda ma jaki rezultat. To juz lepiej mieć w komendzie właściwość “Result”. Można nadać każdej komendzie corellation id i odpytywać czy komenda o podanym id została wykonana poprawnie.

    • Daniel,
      Można by to rozwiązać konwencją wymuszającą utworzenie CommandnameResult dla każdej komendy… ale to i tak komplikuje sprawę.
      CorrelationId – jak najbardziej, to się przydaje w więcej niż jednym scenariuszu. Czy akurat w tym: to już zależy jak bardzo komuś przeszkadzają wyjątki, i dlaczego.

  5. Rozumiem, że całe zagadnienie rozpatrujemy z punktu widzenia oprogramowania, które korzysta z bazy z “doskoku”, czyli nie istnieje obiekt, który mógłby przechować uzyskane Id, aby móc je za chwilę odpytać? Bo po wykonaniu komendy wszystko co do niej doprowadziło znika z pamięci (co ma np. miejsce w aplikacjach Webowych, które nie posiadają sesji)?
    Jeśli tak, to ten wygenerowany GUID, jak jest zapamiętany? Przekazany do widoku? I ten na jego podstawie wybiera sobie właściwe Id z bazy?
    Jeśli właśnie tak, to dokładanie kolumny do tabeli tylko po to by pełniła ona rolę tymczasowego identyfikatora jest bez sensu. Lepiej jest mieć oddzielną tabelę buforującą takie pary GUID-identyfikator. I to do niej kierować pytanie ([Q]uery), a po poznaniu właściwego Id usunąć tak zbuforowany wpis stosownym poleceniem ([C]ommand).

    Ale w tej chwili (może nie ma to uzasadnienia) wydaje mi się, że można zbuforować wartość ID pod “spodem” polecenia i odpytać o nie tuż po nim, po czym zakończyć działanie kodu, który dodawał (wykonywał polecenie). Trochę brakuje tutaj przykładowego kodu, wtedy byłyby jakieś fakty, o których można dyskutować.

    • Paskol,
      GUID nie jest do widoku przekazany, on jest w widoku wygenerowany i z widoku przekazany do kontrolera czy innego bytu pełniącego rolę “translatora inputu na komendy”.
      Można to oczywiście rozwiązać na wiele sposobów. Ja jestem zwolennikiem stosowania rozwiązań najprostszych, jeśli nie mają rażących wad.

      • Dąbrowski Daniel on

        Bez przesady. Generowany jest w kontrolerze. A jak mamy spa to można sie pokusić o generowanie pobstronie client side przed postem ajaxowym. Po co operacja read np. generowanie formularza lub jego odswiezenie ma generować nowe id.

    • devamator on

      Paskol,
      Widzę, że opisujesz podobne podejście, do tego jakie zastosowałem przy rozwiązaniu tego problemu. Dobrze wiedzieć, że nie jest to tylko mój dziwny hack.
      Fakt że stosowałem je w niewielu miejscach, tylko tam gdzie nie mam innego identyfikatora (NaturalKey) nadanego przed wywołaniem.
      Tam gdzie po Command muszę coś jeszcze zrobić stosuję następujące kroki:
      Generuję chwilowy klucz (Guid)
      Przekazuję ten klucz do Command
      W Command po zapisie do bazy gdy mam już nadany identyfikator, tworzę wpis w dedykowanym słowniku/cachu (Key:Guid,Value:ID)
      Po zakończeniu Command pobieram z tego słownika wartość identyfikatora potrzebną do dalszych działań.

  6. Ewolucyjnie też doszedłem do podobnych wniosków, ciekawym elementem ewolucji był moment, kiedy zamiast void zwracany był jeszcze status o wartości zawsze wynoszącej OK. Niektórzy nie mogli się długo pozbyć nawyku zwracania czegoś.

  7. Moim rozwiązaniu stosuję się do drugiego sposobu. Id jest po prostu guidem i generowany jest przed utrwaleniem elementu w warstwie persystencji. Mam natomiast taką zagwostkę, która wynika z kolejnych kroków. Załóżmy, że mój CQRS to nie tylko dwa różne modele, ale również dwa różne źródła. Do zapisu baza relacyjna, do oczytu coś zdenormalizowanego bazującego na dokumentach.

    Ogólnie chciałbym zaimplementować takie podejście, że zapisuję coś w write modelu. Zapis się powiódł i wyrzucam event ‘ObjectCreated’. I zwracam do klienta, że operacja sie powiodła.
    Część odpowiedzialna za read model przechwytuje zdarzenie i dodaje objekt do drugiego źródła danych.
    W tym samym czasie klient dowiedział się, że operacja sie udała i próbuje odczytać obiekt z read modelu. Czyli mój read model jest eventually consistent.

    Pytanie teraz jakie są dobre praktyki, żeby to rozwiązać i żeby klient pytał o obiekt do odczytu w momencie, gdy jest pewność, żę obiekt znajduje się już w read modelu?

  8. Zauważyłem że z każdym rokiem coraz mniej boję się wyjątków, zostaje tylko problem co jest sytuacją wyjątkową a co nie. Staram też się dbać żeby wyjątki mówiły co poszło nie tak (np. często na CI każę ludziom zmieniać safe cast na direct cast bo Invalid cast jest lepszy niż null reference).
    Co do Guidów to nie ryzykowałbym generowania ich na kliencie, nie są one aż tak unikalne jak się wydaje.
    Problem pojawia się gdy maszyny klienckie są stawiane z jednego obrazu systemu, co się zdarza w większych firmach.
    Moim zdaniem najlepszym miejscem jest “wierzchnia warstwa serwera” czyli serwis WCF albo kontroler MVC.