Wprowadzenie do CQRS, część I  ![Udostępnij na: Facebook](images/gg670867.udostepnij_fb(pl-pl,MSDN.10).png \"Udostępnij na: Facebook\")

Autor: Piotr Zieliński

Opublikowano: 2014-05-21

Wstęp

CQRS to skrót od Command Query Responsibility Segregation. W internecie opublikowano wiele artykułów na ten temat, ale zagadnienie wciąż nie jest dobrze znane wśród programistów. Moim zdaniem wynika to z wielu niejasności – wprowadzonych już na samym początku.

Nie zamierzam tutaj wnikać w to, czy CQRS to architektura systemu, wzorzec projektowy, czy też paradygmat. W praktyce nie ma to najmniejszego znaczenia. Moim celem jest wyjaśnienie CQRS i pokazanie, kiedy warto z niego korzystać. Już na wstępnie chcę zaznaczyć, że CQRS nie nadaje się do każdej aplikacji i każdego systemu. Zanim więc podejmie się decyzję o implementacji CQRS, zawsze należy dokonać dokładnej analizy.

CQS – command query seperation

CQRS opiera się na dobrze znanej zasadzie CQS.

CQS dotyczy konstrukcji metod: każda z nich powinna być komendą (command) lub zapytaniem (query), ale nigdy jednocześnie i komendą, i zapytaniem.

W tym miejscu należy wyjaśnić, czym się różnią te dwie konstrukcje. Komenda to polecenie zrobienia czegoś – np. zapisu danych do bazy danych. Zapytanie to natomiast zwrócenie danych. Innymi słowy, nie powinno się tworzyć metod, które zarówno wykonują jakąś logikę (a tym samym zmieniają stan obiektu, bazy danych), jak i zwracają dane. Według CQS lepszym podejściem jest rozdzielenie tych czynności na dwie metody.

Warto od razu przestrzec przed pewnymi pułapkami. Pierwszy przykład to programowanie współbieżne, w którym należy unikać częstych locków. Z tego względu lepiej skorzystać z jednej – bardziej rozbudowanej – metody. Drugi przykład to warstwa usług, eksponująca warstwę biznesową. Fasada z definicji gromadzi szeroką funkcjonalność w jednej metodzie. W kontekście usługi sieciowej lepiej, aby jedno zapytanie zrobiło tyle, co trzeba – zamiast wysyłać kilka pojedynczych zapytań. Interfejs warstwy usług nie powinien być „chatty”. Operacja na stosie POP (zdjęcie obiektu) to klasyczny przykład łamania reguł CQS, ale mimo wszystko jest to dobre podejście.

CQS daje przede wszystkim przejrzyste API – wiadomo, które metody zmieniają stan obiektu, a kilkakrotne ich wywołanie może spowodować jakieś efekty uboczne.

CQR to tak naprawdę esencja CQRS. CQRS bardzo często wykorzystuje się z innym wzorcem, Event-Sourcing – ale nie jest to koniecznością. Wystarczy po prostu rozdzielić metody na dwie podgrupy (komendy i zapytania):

interface ReadService
{
    IEnumerable<Employee> GetAllEmployees();
    IEnumerable<Employee> FindEmployeeByName(string name);
}

interface WriteService
{
    void AddEmployee(Employee employee);
    void RemoveEmployee(int id);
}

Odizolowanie metod wykonujących operacje tylko do odczytu i te modyfikujące stan przynosi wiele dodatkowych korzyści, które są fundamentami CQRS.

Po pierwsze, bardzo łatwo rozdzielić pracę między programistów. Dwie powyższe usługi mogą być rozwijane całkowicie niezależnie od siebie. Programiści odpowiedzialni za tworzenie zapytań nie muszą analizować logiki warstwy zapisu, która jest zwykle dużo bardziej skomplikowana.

Standardowy, trójwarstwowy model aplikacji wygląda (w uproszczeniu) następująco:

Klasyczna architektura aplikacji

Rys. 1. Klasyczna architektura aplikacji.

Warstwa prezentacji wymienia dane z warstwą domeny za pomocą DTO. Logikę biznesową wykonują osobne klasy (model domeny, aktywny rekord, skrypt transakcji itp.). Niezależnie od tego, z jakich wzorców korzysta się w warstwie biznesowej, zawsze istnieje potrzeba konwersji obiektu biznesowego do DTO, tak aby móc go potem przesłać między warstwą usług a np. prezentacji. Czasami ta konwersja okazuje się czasochłonna i monotonna. W przypadku CQRS możemy wykorzystać to, że część metod nie modyfikuje stanu, a wyłącznie zwraca czyste dane. Dlaczego więc nie uprościć zapytań do poniższej formy?

CQRS znacząco upraszcza sposób odczytu danych

Rys. 2.  CQRS znacząco upraszcza sposób odczytu danych.

Rysunek 2 oznacza, że nie trzeba dokonywać żadnej konwersji. Jeśli DAL jest oparty na Entity Framework, da się bezpośrednio wykorzystać wygenerowane encje. Zapytania mogą być optymalizowane pod konkretne zadanie.

Wykonywanie poleceń również uprości klasyczną, trójwarstwową architekturę. Ze względu na to, że polecenia nie zwracają teraz danych, nie ma również potrzeby konwersji obiektów biznesowych do DTO. Klient wysyła pakiet, serwer wykonuje logikę – i nie trzeba zwracać skomplikowanych DTO. Wykonywanie komend jest zdecydowanie większym wyzwaniem architektonicznym niż proste zapytania, dlatego warto poświęcić temu trochę więcej czasu.

Wykonywanie komend

Modyfikacji stanu dokonuje się poprzez wysłanie komendy do tzw. Command Bus. Następnie z każdą komendą kojarzony jest handler, czyli klasa wykonująca dane polecenie. Sama komenda w zależności od wykorzystywanego frameworka może mieć różną postać: od prostego identyfikatora po interfejsy bardziej przyjazne programiście, np.:

public interface ICommand
{
  Guid Id { get; }
}

Załóżmy, że tworzymy aplikację odpowiedzialną za zarządzanie kontaktami (książka adresowa). Przykład komendy dodającej nowy kontakt może wyglądać następująco:

public class AddPersonCommand : ICommand
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public Guid Id { get; set; }

    public AddPersonCommand(string firstName, string lastName, Guid id)
    {
        FirstName = firstName;
        LastName = lastName;
        Id = id;
    }
}

Po odebraniu komendy Command Bus ma za zadanie wykonać ją za pomocą skojarzonego handlera. Przykładowy interfejs:

public interface ICommandHandler<in TCommand> where TCommand:ICommand
{
    void Handle(TCommand command);
}

W najprostszej postaci Command Bus to nic innego jak usługa, która wywołuje handlery na podstawie przesłanej komendy.

Event Sourcing

Jak wspomniałem wcześniej, CQRS to tak naprawdę separacja metod do dwóch osobnych grup. Bardzo często jednak razem z CQRS wykorzystuje się inny wzorzec projektowy – Event Sourcing. Na szczęście nie jest on skomplikowany. Polega na nieco innym sposobie przechowywania informacji.

Załóżmy, że mamy klasę Person, która zawiera takie informacje, jak imię, nazwisko, e-mail, numer telefonu, adres zamieszkania itp. Standardowym podejściem do przechowywania takiej encji jest klasa z odpowiednimi polami – rozwiązanie jak najbardziej poprawne, ale czasami niewystarczające.

Event Sourcing, zamiast przechowywać wyłącznie stan ostateczny (czyli aktualne dane adresowe i kontaktowe), przechowuje listę modyfikacji (zdarzeń). Na podstawie zdarzeń można odtworzyć stan aktualny albo dowolny wcześniejszy. Przykłady zdarzeń to: dodanie kontaktu, zmiana adresu zamieszkania, zmiana adresu e-mail itd.

Event Sourcing opiera się na strumieniu zdarzeń, zamiast na przechowywaniu konkretnego stanu.

Rys. 3. Event Sourcing opiera się na strumieniu zdarzeń, zamiast na przechowywaniu konkretnego stanu.

Przykładowa implementacja zdarzeń może wyglądać tak:

public interface IEvent
{
    DateTime Timestamp { get; }
    Guid AggregateId { get; }       
}

public class NewContactAdded : IEvent
{
    public Guid Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public DateTime Timestamp { get; set; }

    public Guid AggregateId
    {
        get { return Id; }
    }

    public NewContactAdded(Guid id, string firstName, string lastName, DateTime timestamp)
    {
        Id = id;
        FirstName = firstName;
        LastName = lastName;
        Timestamp = timestamp;
    }
}

public class EmailUpdated : IEvent
{
    public Guid Id { get; set; }
    public string Email { get; set; }
    public DateTime Timestamp { get; set; }

    public Guid AggregateId
    {
        get { return Id; }
    }

    public EmailUpdated(Guid id, string newEmail, DateTime timestamp)
    {
        Id = id;
        Email = newEmail;
        Timestamp = timestamp;
    }
}

To, jakie dodatkowe informacje należy przechowywać wraz ze zdarzeniem, zależy już od konkretnej implementacji.

Następnie należy zdefiniować encję i handlery dla zdarzeń:

public class PersonInfo
{
    public PersonInfo(string firstName, string lastName)
    {
        Apply(new NewContactAdded(Guid.NewGuid(),firstName,lastName,DateTime.Now));
    }

    public string FirstName { get; private set; }
    public string LastName { get; private set; }
    public string Email { get; private set; }

    public void Apply(EmailUpdated emailUpdated)
    {
        Email = emailUpdated.Email;
    }
    public void Apply(NewContactAdded newContactAdded)
    {
        FirstName = newContactAdded.FirstName;
        LastName = newContactAdded.LastName;
    }
}

W kodzie produkcyjnym zwykle przechowuje się również serie zmian, tzn. zdarzeń. Należy pamiętać, że w bazie danych zostanie przechowana seria zdarzeń, a nie końcowy stan – dlatego zdarzenia można zapisywać np. w zwyklej kolekcji:

public class PersonInfo
{
    private readonly List<IEvent> _events=new List<IEvent>();

    public PersonInfo(string firstName, string lastName)
    {
        Apply(new NewContactAdded(Guid.NewGuid(),firstName,lastName,DateTime.Now));
    }

    public string FirstName { get; private set; }
    public string LastName { get; private set; }
    public string Email { get; private set; }

    public void Apply(EmailUpdated emailUpdated)
    {
        Email = emailUpdated.Email;
        _events.Add(emailUpdated);
    }
    public void Apply(NewContactAdded newContactAdded)
    {
        FirstName = newContactAdded.FirstName;
        LastName = newContactAdded.LastName;
        _events.Add(newContactAdded);
    }

    public IEnumerable<IEvent> GetChanges()
    {
        return _events;
    }
}

Jeśli myślimy o implementacji własnego frameworka, informacje z powyższej klasy powinny oczywiście być wyodrębnione do osobnego obiektu, ponieważ stanowią tak naprawdę AggregateRoot.

Podsumowując, architektura wygląda następująco:

Elementy architektury CQRS

Rys. 4. Elementy architektury CQRS.

Wspomniane zdarzenia służą do synchronizacji modelu ReadModel z rzeczywistym stanem. Zwykle każda komenda powoduje wysłanie również kilku zdarzeń, np.:

Komenda Zdarzenie
AddNewPersonCommand NewPersonAddedEvent
ChangeEmail EmailChangedEvent
UpdateAddress AdressChangedEvent

Powyższy diagram można przedstawić następująco:

Przykładowy scenariusz użycia CQRS

Rys. 5. Przykładowy scenariusz użycia CQRS.

Z technicznego punktu widzenia publikacja zdarzenia ma miejsce przez tzw. Event Bus. Następnie specjalne handlery modyfikują stan bazy danych, który może być potem odczytywany przez zapytania.

W pseudokodzie prosty Event Bus mógłby wyglądać tak:

public class EventBus
{
    private IEventHandler _handlers;

    public void Publish<T>(T @event)
    {
        _handlers.Handle(@event);
    }
}

W handlerach z kolei powinna znajdować się logika, która modyfikuje ReadModel, czyli np. bazę danych. Zadaniem zdarzeń domenowych jest zmodyfikowanie stanu aplikacji w taki sposób, aby zapytania mogły potem zwrócić dane wyłącznie za pomocą prostego odczytu (a nie generowania danych).

Dla kogo CQRS?

Częstym błędem jest wykorzystywanie CQRS w przypadku każdego projektu. Pełna implementacja CQRS wraz z Event Sourcing nie zawsze ma jednak sens. Za każdym razem należy przeanalizować problem i wybrać najkorzystniejsze rozwiązanie. Jeśli interesuje nas śledzenie zmian, jest to pierwszy sygnał, że Event Sourcing okaże się przydatny. Jeśli warstwa odczytu jest skomplikowana i konwersja obiektów biznesowych do DTO staje się problemem, również warto zastanowić się nad CQRS. Dla prostych rozwiązań (np. CRUD) nie ma to znaczenia, ale im bardziej skomplikowana logika, tym częściej dostarczenie zoptymalizowanych zapytań staje się problemem. Domeny z dużą liczbą użytkowników operujących na tych samych danych są prawdopodobnie doskonałym przykładem CQRS (tzw. collaborative domain).

Przechowywanie zdarzeń w bazie danych

Większość użytkowników ma doświadczenie z relacyjnymi bazami danych. W praktyce korzysta się z frameworków, które już zajmą się zapisem i odczytem zdarzeń. Moim zdaniem warto jednak przyjrzeć się przykładowemu projektowi bazy tylko po to, aby w pełni zrozumieć istotę Event Sourcing. Najprostszy schemat może składać się wyłącznie z pojedynczej tabeli:

Tabela 1 Definicja tabeli Events.

Kolumna Typ
AggregateId (FK) Guid
Data Blob
Version int

Każde zdarzenie musi mieć oczywiście identyfikator, który reprezentuje encje. Wszystkie zdarzenia odnoszące się do tej samej encji (np. tej samej osoby w przypadku zaprezentowanej PersonInfo) powinny mieć również ten sam AggregateId. Zwykle należy także przechowywać informacje o wersji, co ma szczególne znaczenie w przypadku współbieżności. Dzięki wersji można wykryć, czy inny użytkownik nie zmienił w międzyczasie stanu obiektu. Jak już wspomniałem w poprzedniej sekcji, skomplikowane domeny z wieloma użytkownikami operującymi na tych samych danych są najczęstszym scenariuszem zastosowania CQRS.

Informacje o zdarzeniu przechowuje się zwykle w generycznym polu, np. BLOB. Innymi słowy, klasa przechowująca informacje o zdarzeniu (np. NewContactAdded) jest serializowana i zapisywana w polu BLOB.

W praktyce AggregateId jest kluczem obcym i wskazuje na tabelę przechowującą informacje o typie agregacji, tzn.:

Tabela 2 Definicja tabeli AggregateTypes

Kolumna Typ
AggregateId (PK) Guid
Type Varchar(...)

Powyższe tabele wystarczają do implementacji prostego EventStore. Aby odczytać wszystkie zdarzenia skojarzone z konkretną encją, wystarczy:

Select * from Events where AggregateId=@Id ORDER BY Version

Podsumowanie

Po złożeniu wszystkich omawianych elementów w całość osiągnie się następującą architekturę:

Składowe architektury CQRS

Rys. 6. Składowe architektury CQRS.

W praktyce istnieje jeszcze kilka innych elementów, o których nie wspomniałem w artykule – np. implementacja repozytorium, Commands Bus czy Events Bus. Nie wpływają one jednak na CQRS i są osobnymi wzorcami projektowym, z którymi styczność ma się nie tylko w przypadku CQRS.

Kolejne teksty będą już dotyczyć przykładowej implementacji CQRS.