CQRS w praktyce
Dużo piszę ostatnio o CQRS (Command Query Responsibility Segregation), ale nie pokazałem ani razu jak to podejście wygląda w praktyce. Postaram się dziś naprawić to niedopatrzenie. Posłużę się w tym celu projektem DDDSample w najnowszej wersji CQRS.
Układ solution
Tak wygląda układ solution Visual Studio:
Kod podzielony jest na cztery główne obszary:
- Domain — tutaj znajduje się logika biznesowa aplikacji, której zadaniem jest przetwarzanie transakcji (komend). Oprócz centralnego projektu “Domain”, w obszarze tym znajdują się dwa wspomagające. Idąc od góry, pierwszy z nich zawiera obsługę zdarzeń domenowych a’la Udi Dahan, drugi zaś kod dostępu do danych (w oparciu o NHiberate)
- Reporting — ten obszar ma za zadanie obsługiwać zapytania o dane do wyświetlenia na GUI. W razie potrzeby może on także służyć do generowania raportów. Główny projekt (“Reporting”) zawiera definicję struktur danych. Pozostałe to dostęp do danych oraz obsługa komunikatów reprezentujących zdarzenia aktualizacji modelu.
- Infrastructure — tutaj znajduje się wszelki kod nie związany bezpośrednio z problemem, ale w jakiś sposób wspierający jego rozwiązanie, np. projekt “Messages” zawierający komunikaty przesyłane między podsystemem obsługi komend, a podsystemem obsługi zapytań.
- W najwyższym poziomie hierarchii, bezpośrednio pod solution znajdują się dwa projekty scalające: “Application” oraz “UI”. Ten pierwszy stanowi fasadę ukrywającą złożoność całego rozwiązania przed tym drugim. “UI” to zwykły interfejs użytkownika wykorzystujący ASP.NET MVC.
Workflow
Skoro już wiecie, jak z grubsza wygląda rozwiązanie, chciałbym teraz omówić zachowanie systemu podczas standardowej interakcji, która przedstawia się tak:
Na powyższym diagramie MDK oznacza Model Domeny dla obsługi komend (projekt “Domain”), a MDZ to model dla raportowania (projekt “Reporting”).
W pierwszym żądaniu użytkownik systemu chce zobaczyć dane związane z obiektem biznesowym. System ładuje je z prosto z relacyjnej bazy danych MDZ. Drugie żądanie to już modyfikacja danych. Jest ona wykonywana na obiektach Modelu Domeny, które następnie są zapisywane w formie danych relacyjnych. Dokonane zmiany są także (w jakiejś formie) publikowane do MDZ, gdzie służą do aktualizacji struktur danych.
Zobaczmy, jak wygląda realizacja tego schematu w aplikacji DDDSample. Posłużę się przykładem komendy “Zmień docelową lokalizację dla towaru”.
Pobranie danych
Operacja pobrania danych zaczyna się od odpowiedniej akcji kontrolera:
[AcceptVerbs(HttpVerbs.Get)]
public ActionResult ChangeDestination(string trackingId)
{
Reporting.Cargo cargo = _bookingFacade.LoadCargoForRouting(trackingId);
//...
która odwołuje się do fasady:
public Reporting.Cargo LoadCargoForRouting(string trackingId)
{
Reporting.Cargo c = _cargoDataAccess.Find(trackingId);
if (c == null)
{
throw new ArgumentException("Cargo with specified tracking id not found.");
}
return c;
}
Fasada z kolei wykorzystuje bezpośrednio obiekt dostępu do danych:
public Cargo Find(string trackingId)
{
const string query = @"from DDDSample.Reporting.Cargo c where c.TrackingId = :trackingId";
return _sessionFactory.GetCurrentSession().CreateQuery(query).SetString("trackingId", trackingId)
.UniqueResult<Cargo>();
}
Jak widzicie, nie ma tu żadnego przekształcenia danych po ich zwróceniu przez NHibernate. Żadnego DTO. Ponieważ kod podsystemu obsługi zapytań nie jest szczególnie wartościowy, mogę pozwolić sobie na nieco luźniejszą politykę zarządzania zależnościami. Na przykład obiekt dostępu do danych (CargoDataAccess) wykorzystywany jest jako konkretna klasa — nie mam jego abstrakcyjnej definicji. Nie muszę dbać o długowieczność tej części systemu. Kiedy pojawi się lepsza technologia, po prostu wyrzucę całą implementację, począwszy od fasady, aż do samego dołu.
Tak naprawdę użycie NHibernate tutaj może być nawet traktowane jako nadmierne skomplikowanie. Linq2SQL byłoby pewnie lepsze. Ale to temat na osobną notkę…
Wykonanie komendy
Wykonanie komendy także zaczyna się od akcji na kontrolerze. Zwróćcie uwagę, że tym razem akcja jest odpowiedzią na żądanie POST.
[AcceptVerbs(HttpVerbs.Post)]
public ActionResult ChangeDestination(string trackingId, string destination)
{
_bookingFacade.ChangeDestination(trackingId, destination);
return RedirectToDetails(trackingId);
}
Co kryje się za metodą fasady?
public void ChangeDestination(string trackingId, string destination)
{
_bookingService.ChangeDestination(new TrackingId(trackingId),
new UnLocode(destination));
}
Przekazujemy sterowanie do warstwy Application (obiekt _bookingService). Zaczyna się właściwe wykonanie komendy. W tym momencie włącza się lekki moduł programowania aspektowego (AOP) w moim kontenerze Unity, który zawija całe wywołanie ChangeDestination w transakcję.
Właściwa implementacja komendy zmiany punktu docelowego wygląda tak:
public void ChangeDestination(TrackingId trackingId, UnLocode destinationUnLocode)
{
Location destination = _locationRepository.Find(destinationUnLocode);
Cargo cargo = _cargoRepository.Find(trackingId);
cargo.SpecifyNewRoute(destination);
}
Pierwszym krokiem utworzenie obiektu reprezentującego nową lokalizację docelową towaru. W tym celu wykorzystujemy (abstrakcyjne) repozytorium lokalizacji. Następnie pobieramy z bazy obiekt towaru, którego docelowa lokalizacja ma być zmieniona. Ostatecznie wywołujemy odpowiednią metodę na obiekcie reprezentującym towar. Wygląda ona tak:
public virtual void SpecifyNewRoute(Location.Location destination)
{
if (destination == null)
{
throw new ArgumentNullException("destination");
}
RouteSpecification routeSpecification = new RouteSpecification(_routeSpecification.Origin, destination, _routeSpecification.ArrivalDeadline);
Delivery delivery = Delivery.DerivedFrom(routeSpecification, _itinerary, _lastHandlingEvent);
CargoDestinationChangedEvent @event = new CargoDestinationChangedEvent(this, routeSpecification, _routeSpecification, delivery);
_routeSpecification = routeSpecification;
DomainEvents.Raise(@event);
}
Pomińmy walidację argumentów. Zaczynamy od stworzenia obiektu reprezentującego nową specyfikację trasy tego towaru. RouteSpecification jest tzw. value object-em, więc nie modyfikujemy istniejącej instancji, ale tworzymy nową. Następnie wykorzystujemy obiekt Delivery do określenia nowych danych dotyczących dostawy towaru. Dane te w kolejne linii są wykorzystywane do utworzenia zdarzenia informującego o zmianie lokalizacji docelowej dla towaru. Na koniec ustawiamy nową specyfikację (a wraz z nią lokalizację docelową) i publikujemy zdarzenie.
Asynchroniczna synchronizacja danych
Fajnie mi się napisało. W klasycznym systemie przetwarzanie byłoby już zakończone, ale nie w CQRS. Teraz ma miejsce ostatni etap, jakim jest uwzględnienie dokonanych zmian do w bazie obsługującej zapytania. Pierwsza faza właściwie już się wykonała — była to publikacja zdarzenia. Kolejna to jego obsługa. Zajmuje się nią następująca klasa (zlokalizowana w projekcie “Domain.EventHandlers”):
public class CargoDestinationChangedEventHandler : IEventHandler<CargoDestinationChangedEvent>
{
private readonly IBus _bus;
public CargoDestinationChangedEventHandler(IBus bus)
{
_bus = bus;
}
public void Handle(CargoDestinationChangedEvent @event)
{
_bus.Publish(new CargoDestinationChangedMessage
{
TrackingId = @event.Cargo.TrackingId.IdString,
Origin = @event.NewSpecification.Origin.Name,
Destination = @event.NewSpecification.Destination.Name,
ArrivalDeadline = @event.NewSpecification.ArrivalDeadline
});
}
}
Jej zadaniem jest zamiana zdarzenia domenowego na komunikat NServiceBus, który jest następnie publikowany “na szynie”. Dalej dzieje się magia NServiceBus i MSMQ, która prowadzi do tego, że wiadomość zostanie obsłużona przez przeznaczony do tego obiekt znajdujący się w projekcie “Reporting.MessageHandlers”:
public class CargoDestinationChangedMessageHandler : AbstractMessageHandler<CargoDestinationChangedMessage>
{
private readonly CargoDataAccess _cargoDataAccess;
public CargoDestinationChangedMessageHandler(CargoDataAccess cargoDataAccess, ISessionFactory sessionFactory)
: base(sessionFactory)
{
_cargoDataAccess = cargoDataAccess;
}
protected override void DoHandle(CargoDestinationChangedMessage message)
{
Cargo cargo = _cargoDataAccess.Find(message.TrackingId);
cargo.UpdateRouteSpecification(message.Origin, message.Destination, message.ArrivalDeadline);
}
}
Obsługa komunikatu polega na pobraniu z bazy danych struktury reprezentującej towar, a następnie aktualizacji danych dotyczących specyfikacji trasy.
Podsumowanie
Mam nadzieję, udało mi się nie wystraszyć Was nadmierną ilością kodu. Chciałem pokazać, jak naprawdę wygląda CQRS oraz, że wbrew obiegowej opinii, nie jest to wcale coś strasznego. Nie wymaga wcale większych, niż wykorzystanie wzorca DTO, nakładów pracy, a pozwala na uzyskanie znacznie większej swobody polegającej na względnie niezależnym rozwoju podsystemów obsługi komend i zapytań.





about 5 months ago
Fajnie opisane krok po kroku. Brakuje tylko ze dwa słowa o transakcji oraz co w przypadku wystąpienia wyjątku przy zapisie, ale jak sądzę pewnie będzie o tym w kolejnym poście.
about 5 months ago
Bardzo fajny post, mam nadzieję na więcej.
about 5 months ago
Dzięki za pozytywne komentarze:-) O transakcjach będzie zatem w poniedziałek.
about 5 months ago
Dzieki za wytlumaczenie krok po kroku. Nie za bardzo sie interesowalem CQRS zanim nie zaczalem czytac twoich postow na ten temat. Ale prawde mowiac niedokladnie widze “wartosci” jaka by mi przynioslo zastosowanie CQRS w aktualniej architekturze. Osobiscie stosuje klasyczna DDD z wykorzystaniem IoC. Czyli obiekty domeny sa zgrupowane w agregaty ktore sa manipulowane przez repozytoria. Uzywam tez AutoMappera aby polaczyc model prezentacji z modelem domeny. Pewnie, niedostrzeglem czegos waznego w twoich postach, ale jakbys mogl mnie nakierowac to bylbym wdzieczny.
about 5 months ago
Główną zaletą jest rozdzielenie całego procesu modelowania dziedziny od optymalizowania – ponieważ scenariusz Command jest wykonywany znacznie rzadziej, a za to bazę “raportową” możemy dowolnie optymalizować, przygotowując np. spłaszczone tabele z większą redundancją. To ostatecznie obala argumenty z kategorii “niewydajne ORM-y”, a zarazem jest poprawne politycznie, bo uwzględnia ew. stanowisko dla DBA, dzięki czemu w wielu firmach to podejście ma szanse na akceptację
Osobiście widzę pewną dodatkową zaletę – bezpieczeństwo. Dzięki takiemu oddzieleniu teoretycznie możemy sobie pozwolić na udostępnienie większych możliwości zapytań dla warstw zewnętrznych bez obawy, że nasze główne źródło danych zostanie uszkodzone. Ale oczywiście ta koncepcja wymagałaby rozwinięcia i poeksperymentowania…
about 5 months ago
@dario-g
Od strony “command” zapis danych i wysyłanie komunikatów jest w pełni transakcyjne — korzystam z DTC. W przypadku SQLServer-a jest to dosyć proste. Oracle podobno też nieźle sobie radzi. Problemy mogą być w wypadku innych, mniej “enterprajsowych” baz danych, takich jak MySQL. Zakładam jednak, że wtedy nie wykorzystywalibyśmy NSB, tylko jakieś lekkie rozwiązanie (AtomPub?)
Sposób, w jaki osiągnąłem transakcyjność opisałem tutaj: http://simon-says-architecture.com/2010/01/13/nhibernate-nservicebus-i-transakcje. Nie jestem z niego bardzo zadowolony. W kodzie NServiceBus wykorzystywana jest (i działa!) natywna integracja NHibernate i System.Transactions. Póki co nie wiem, skąd wynikają moje problemy z zastosowaniem tego mechanizmu:\
about 5 months ago
@dario-g
Od strony “query” jedyną czynnością, która może się nie udać jest aktualizacja danych (w przypadku niezgodności schemy lub innego buga). W takim wypadku jestem skłonny zastosować tradycyjne metody i naprawić problem odpowiednim SQLem.
Warto zauważyć, że systemy CQRS oparte na event sourcing są o tyle lepsze, że tam istnieje możliwość odtworzenia “od początku świata” wszystkich zdarzeń aktualizujących bazę query. Dzięki temu po naprawie bug-a można po prostu “wygenerować” od początku bazę zamiast łatać SQLami.
about 5 months ago
@Thomas
rafalb już sporo powiedział. Ja ze swojej strony podkreślę jeszcze kwestię optymalizacji. Miałem przypadek systemu, który zarządzał rachunkami. Rozdzielenie modeli do komend i do zapytań (mimo iż oba pracowały na wspólnej bazie danych) pozwoliło mi w modelu komend zrealizować różne typy rachunków przez dziedziczenie, podczas gdy w modelu zapytań mam enum “Typ”.
Dzięki możliwości wprowadzenia dziedziczenia mogłem znaczenie zmodyfikować zachowanie “rachunku technicznego” (w porównaniu do klienckiego). Usunąłem konieczność zapisu salda, co pozwoliło mi wykorzystywać jeden rachunek techniczny naraz w dowolnej ilości transakcji (jedyna operacja na nim wykonywana to “insert” do tabeli z listą operacji — brak blokady na poziomie SQL).
Nie muszę chyba mówić, jak bardzo zwiększyło to przepustowość mojego modelu:-)
about 5 months ago
Dziekuje Wam za wytlumaczenia. Jest to naprawde interesujace podejscie i musze sie tym bardziej zainteresowac. Sciagnolem juz kod Szymona ale jeszcze nie mialem czasu popatrzec jak to wszystko jest zrobione.
Jedyne pytanie jakie bym mial, to : W jaki sposob dane sa synchronizowane miedzy modelem dla zapytan i dla komend ?
Z tego co rozumiem, logicznie moglibysmy kozystac z dwuch baz danych lub majac dwa oddzielne schematy.
about 5 months ago
W moim przykładzie do synchronizacji wykorzystuje moją ulubioną bibliotekę do komunikacji — NServiceBus. NSB korzysta z kolejek MSMQ, aby transakcyjne przesyłać wiadomości: wiadomość jest wysyłana w tej samej transakcji rozproszonej, co zapis danych po stronie transakcyjnej, natomiast odczyt — w tej samej, co aktuaizacja modelu raportowego.
W przykładzie wykorzystuje jedną bazę danych (w sensie serwera) i dwa schematy, ale równie dobrze mogłyby to być zarówno dwie bazy danych, jak i dwa zupełnie różne serwery. Jestem w trakcie przygotowywania przykładu z modelem raportowym zrobionym na innej bazie i mapowaniem w oparciu o Linq 2 SQL.