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:

solution layout

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:

Diagram przepływu CQRSNa 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ń.

VN:F [1.8.7_1070]
Rating: 3.0/5 (2 votes cast)
CQRS w praktyce3.052