Warstwa Abstrakcji - stanowi izolację pomiędzy kodem programisty a zewnętrznymi modułami, pozwala odseparować system od jego zależności przez co czyni go podatnym na zmiany i aktualizacje. Przywiązując system do innej usługi czynimy go słabym.

CQRS - Query, read model post

2017-07-01

Zanim przystąpisz do czytania rzuć okiem na wprowadzenie do CQRS. W poprzednim wpisie wyjaśniłem po krótce czym jest CQRS oraz jakie jest jego zastosowanie. Tym razem skupie się jedynie na read modelu, czyli części Query.
Generowanie read modelu może okazać się bardzo skomplikowane, szczególnie kiedy model domeny nie do końca przekłada się na interfejs użytkownika. Chyba każdemu zdarzyło się dołożyć coś do encji tylko dlatego, że później w UI będzie to potrzebne, pomimo iż ta wartość nie ma żadnego znaczenia biznesowego. CQRS oraz rozdzielny read i write model świetnie rozwiązuje ten problem, pozwala dane mało istotne trzymać z daleka od tych krytycznych, ograniczając przez to ryzyko wprowadzenia systemu w nieoczekiwany lub niepoprawny stan. Dla mnie osobiście największą zaletą posiadania niezależnego read modelu, który może być przechowywany w zasadzie gdziekolwiek (nawet w pamięci) jest możliwość jego odtworzenia w dowolnym czasie, w dowolny sposób, nawet zmieniając zupełnie jego strukturę.

Query

Poniższy obrazek chyba najlepiej przedstawi czym dla systemu jest query.

Klient pyta system o jakiś zasób, system wykonuje query i zwraca rezultat. Osobiście preferuje definicje query jako prosty interfejs z ewentualnie zdefiniowanym modelem jeżeli typy proste okażą się niewystarczające.

<?php

declare (strict_types=1);

namespace Example\Application\Query;

use Example\Application\Exception\NotFoundException;
use Example\Application\Query\Model\User;

interface UserQuery
{
    public function usersCount() : int;

    /**
     * @throws NotFoundException
     */
    public function user(string $email) : User;

    /**
     * @return User[]
     */
    public function find(UserFilter $filter) : array;
}

W powyższym przykładzie możemy dobrać się do liczby użytkowników w systemie, konkretnego użytkownika czy konkretnej kolekcji użytkowników. Gdyby tylko w PHP istniały generyki nie musiałbym w ogóle pisać tych paskudnych adnotacji...

public function user(string $email) : User; ta metoda może być w sumie pierwszym zaskoczeniem. Skoro w systemie istnieją użytkownicy, wiemy że posiadają maile to łatwo wydedukować, że musi również istnieć value object Example\Domain\Email. Czy nie wypadałoby wobec tego wykorzystać owy VO?
Przyznam szczerze, że w swoich pierwszych implementacjach Query właśnie w ten sposób próbowałem to rozwiązać. Finalnie okazało się to nietrafionym podejściem, model domeny zaczął pałętać się po całej aplikacji, bardzo szybko okazało się, że czasami model potrzebuje czegoś więcej co generalnie prowadziło do sytuacji, w której interfejs użytkownika wymuszał zmiany na modelu domeny. Bardzo szybko zrezygnowałem z tego pomysłu. Uniemożliwiało to też jakiekolwiek zmiany na modelu domeny. Jedyny argument za korzystaniem z VO był taki, że nie dało się do takiego query przekazać czegoś co emailem nie jest. Co zresztą było nieuzasadnioną obawą, niezależnie od tego czy ktoś przekazałby tam poprawny email, przypadkowy ciąg znaków, czy też specjalnie spreparowany ciąg znaków implementacja powinna zwrócić model jeżeli taka wartość została by znaleziona lub rzucić wyjątek.

UserFilter to w zasadzie prosty obiekt implementujący Fluent Interface (czytaj więcej).

<?php

declare (strict_types=1);

namespace Example\Application\Query;

final class UserFilter
{
    private $offset;

    private $limit;

    public function __construct()
    {
        $this->offset = 0;
        $this->limit = 10;
    }

    public function changeOffset(int $newOffset) : UserFilter
    {
        $this->offset = $newOffset;

        return $this;
    }

    public function changeLimit(int $newLimit) : UserFilter
    {
        $this->limit = $newLimit;

        return $this;
    }
}

Teraz najciekawsze, read model User

<?php

declare (strict_types=1);

namespace Example\Application\Query\Model;

final class User
{
    private $id;

    private $email;

    public function __construct(string $id, string $email)
    {
        $this->id = $id;
        $this->email = $email;
    }

    public function id(): string
    {
        return $this->id;
    }

    public function email(): string
    {
        return $this->email;
    }
}

Jak widać jest to bardzo prosty obiekt, w zasadzie bardziej struktura danych (nie posiada i nie powinien posiadać żadnego zachowania). Struktura ta nie posiada nawet walidacji danych, z których jest tworzona. W sumie można by użyć zwykłej tablicy jednak istnieją przypadki przekazania gdzieś konkretnego read modelu co z kolei wymusza typowanie.

No dobra, ale dlaczego tak?

Najważniejsze założenie jakie musimy cały czas mieć w głowie to stan naszego systemu zawsze musi być poprawny. Jedynie solidny model domeny może ułatwić przestawienie się na taki sposób rozumowania. Jeżeli jesteś pewny że model domeny skonstruowany jest tak żeby przyjmował tylko poprawne dane, masz na to dowód w postaci testów jednostkowych nie powinno być trudno. W przeciwnym wypadku masz o wiele większy problem niż to czy read model zbuduje się poprawnie i powinieneś się nim jak najszybciej zająć. Kiedy już oswoisz się z myślą, że stan systemu musi być bezwzględnie poprawny musisz jeszcze uświadomić sobie, że read model nie ma tak naprawdę większego znaczenia dla systemu (oczywiście w zależności od domeny można z tym polemizować). Nawet jeżeli jakimś cudem dane z których budowany jest read model będą zawierały jakiś błąd, powinna istnieć możliwość odbudowania tych danych od nowa.

Przykładowo użytkownik zarejestrował się używając adresu [email protected] a wyświetla mu się samo user@ w związku z jakimś błędem w kodzie który generuje read model. Użytkownik zgłasza błąd, programista znajduje przyczynę, odpowiedni mechanizm odbudowuje read model, problem rozwiązany. Odbudowa jest w ogóle możliwa ponieważ w źródłowych danych, w write modelu (który mapowany jest do bazy danych bezpośrednio lub jest przechowywany w postaci eventów) tego błędu nie ma. Dlaczego go tam nie ma? A no właśnie dlatego, że mamy value object Email, który za nic nie pozwoli aby adres email nie był poprawny a on sam z kolei jest bezpośrednio mapowany do odpowiedniego magazynu danych.

View Object - w poprzednim wpisie posługiwałem się tym pojęciem. Klasa Example\Application\Query\Model\User reprezentuje właśnie View Object czyli read model.

W tym miejscu chciałbym wtrącić jedną rzecz. Każdy pewnie słyszał narzekania na ORM'y jakie to strasznie powolne bestie? Jak to ORM potrafi zajechać nawet najlepsze maszyny gdy trzeba kilka setek lub tysięcy encji wyświetlić? CQRS oraz osobny read model w zasadzie rozwiązuje ten problem automatycznie. Nietrudno sobie wyobrazić, że łatwiej jest zbudować i przechować w pamięci 1000 prostych struktur danych niż 1000 pełnych encji (często z asocjacjami), które w dodatku muszą być wrzucone w Unit of Work, który w zasadzie musi sklonować każdą z tych encji żeby móc pod koniec wygenerować porównanie i pokazać co się zmieniło. UoW sklonuje obiekty a w zasadzie ich stan niezależnie czy tego chcemy czy nie. Czy kogokolwiek w ogóle dziwią problemy wydajnościowe ORM'ów w takich przypadkach? ORMy powstały na potrzeby write modelu, nie read modelu. Młotkiem też się ciężko kopie rowy.

View implements Query

Czy w ogóle read model zawsze musi być budowany i trzymany gdzieś w osobnej tabeli, bazie albo nawet innym systemie? Ortodoksyjni wyznawcy CQRS pewnie powiedzą, że tak. Chciałbym jednak pokazać o wiele prostsze podejście, które na pewno znacznie przyśpieszy rozwój systemu, co zwłaszcza na początku ma kluczowe znaczenie. Jakiś czas temu projektowałem system, który docelowo miał być długo rozwijany jednak pomysł musiał być jak najszybciej zweryfikowany. Jednym podejściem był rapid development i CRUD a potem przepisywanie wszystkiego. Nasz biznes pewnie jak każdy inny nie zgodziłby się na przepisanie (niezależnie od tego co by na początku obiecał) systemu kiedy nie byliby przyparci do muru. Nikt nie lubi wydawać pieniędzy kiedy nie musi, inna sprawa że biznes i programiści inaczej postrzegają "musi". CURD nie wchodził w grę, mieliśmy nad tym pracować kilka lat i przy okazji nie popaść w alkoholizm i depresję z powodu pracy z gównokodem, który sami byśmy sobie wygenerowali.

Dlatego zastosowaliśmy podejście, które pozwalało nam zminimalizować nakład pracy a jednocześnie zachować otwartą drogę do przyszłych modyfikacji. Przyjęliśmy podejście, które zakładało że system w pierwszej fazie życia będzie całkowicie synchroniczny i nie będzie posiadał odrębnego magazynu na dane read modelu (bazy danych w naszym wypadku). Read model czerpał bezpośrednio z danych, które powstawały z mapowania write modelu. W tym celu utworzyliśmy byt o nazwie View (łatwo doszukać się analogii do pojęcia widoku z baz danych).

Załóżmy, że posiadamy model domenowy użytkownika

<?php

declare (strict_types=1);

namespace Example\Domain;

final class User
{
    private $id;

    private $email;

    public function __construct(UUID $id, Email $email)
    {
        $this->email = $email;
        $this->id = $id;
    }

    public function doSomething()
    {
        // domain logic
    }
}

niech ORM mapuję go w bazie na następująca tabelę

example_user

| id [uuid] | email [string] |
| ...       | ...            |

Przy takich założeniach View mógłby wyglądać następująco

<?php

declare (strict_types=1);

namespace Example\Infrastructure\Doctrine\DBAL\Query;

use Doctrine\DBAL\Connection;
use Example\Application\Exception\NotFoundException;
use Example\Application\Query\Model\User;
use Example\Application\Query\UserFilter;
use Example\Application\Query\UserQuery;

final class DbalUserView implements UserQuery
{
    private $connection;

    public function __construct(Connection $connection)
    {
        $this->connection = $connection;
    }

    public function usersCount(): int
    {
        return $this->connection->fetchColumn('SELECT COUNT(u.id) FROM example_user');
    }

    public function user(string $email): User
    {
        $result = $this->connection->fetchAssoc('
            SELECT u.id, u.email FROM example_user u
            WHERE u.email = :email',
            [
                ':email' => $email,
            ]
        );

        if (!$result) {
            throw new NotFoundException();
        }

        return new User($result['id'], $result['email']);
    }

    public function find(UserFilter $filter): array
    {
        // TODO: Implement find() method.
    }
}

Powinno być już jasne do czego zmierzam. View implementuje Query, zwraca read model i jedynie źródło danych jest współdzielone z write modelem. Trzeba jednak przestrzegać jednej bardzo ważnej zasady. View nigdy nie zmienia żadnych danych Złamanie tej reguły powinno grozić natychmiastowym wydaleniem z projektu. Tylko pod takim warunkiem wykorzystanie View jest w ogóle możliwe. Niezastosowanie się do tej zasady wyklucza się z zasadami CQRS.

Dzięki takiemu podejściu udało się zbudować pierwszą wersję systemu zaskakująco szybko, po miesiącu mieliśmy już coś co można było pokazać biznesowi, zaprezentować działanie i przy okazji nie zamknęliśmy się na dalszy rozwój. Pomysł wypalił i w niedalekiej przyszłości doszliśmy do momentu w którym czerpanie z write modelu stało się zbyt skomplikowane.

Ponieważ bardzo namiętnie korzystaliśmy z pól typu jsonb, które są dla mnie największym dobrodziejstwem PostgreSQL'a. Pozwalają one mapować praktycznie dowolne agregaty bezpośrednio do bazy nie idąc przy tym na żadne kompromisy, jedyne do czego czasami trzeba się ucieć to napisanie własnego, specyficznego typu dla Doctrine DBAL, który w najgorszym wypadku musiał wykorzystać refleksję. Nie ma w tym jednak nic złego, w końcu cay Doctirne ORM oparty jest o refleksję. Zaczęliśmy powoli uderzać głową w sufit. Zapytania stawały się coraz bardziej skomplikowane i coraz mniej wydajne. Nie było wyjścia, musieliśmy zacząć budować model. W tym momencie nie tyle ilość danych była problemem co ich struktura, system cały czas był synchroniczny.

View w przeciwieństwie do budowanego read modelu nie wymagają odbudowywania. Czerpanie z wspólnego źródła danych gwarantuje ich poprawność jednak wprowadza szereg innych ograniczeń, które do pewnego etapu można spokojnie zaakceptować.

Projection implements Query

Projekcja to byt, który powstał w celu budowania read modelu w taki sposób aby struktura danych była prosta i pozwalała się bardzo wydajnie przeszukać. Przyjęliśmy, że poszczególne projekcje tworzy Projector.

Przykład bardzo prostego projektora poniżej.

<?php

declare (strict_types=1);

namespace Example\Application\Projector;

interface UserProjector
{
    public function initialize(string $id, string $email, int $points);

    public function addPoints(string $userId, int $points);
}

Załóżmy, że użytkownicy w systemie zdobywają punkty, zadaniem projektora jest więc utworzenie projekcji użytkownika oraz dodawanie nowych punktów użytkownikom.

Implementacja zależy w dużej mierze od fantazji programisty. My akurat korzystaliśmy z PostgreSQL'a. Nic jednak nie stoi na przeszkodzie żeby read model trzymać w innej bazie, w pamięci czy nawet zupełnie gdzieś w zewnętrznej usłudze. My akurat trzymaliśmy dane w jednej bazie, dodanie nowej nic by nam nie dało poza koniecznością utrzymywania dwóch baz danych. Tabele projekcji miały jednak ustalony prefix.

<?php

declare (strict_types=1);

namespace Example\Infrastructure\Doctrine\DBAL\Query;

use Doctrine\DBAL\Connection;
use Example\Application\Exception\NotFoundException;
use Example\Application\Query\Model\User;
use Example\Application\Query\UserFilter;
use Example\Application\Query\UserQuery;

final class DbalUserProjection implements UserQuery
{
    private $connection;

    public function __construct(Connection $connection)
    {
        $this->connection = $connection;
    }

    public function usersCount(): int
    {
        return $this->connection->fetchColumn('SELECT COUNT(u.id) FROM example_proj_user');
    }

    public function user(string $email): User
    {
        $result = $this->connection->fetchAssoc('
            SELECT u.id, u.email FROM example_proj_user u
            WHERE u.email = :email',
            [
                ':email' => $email,
            ]
        );

        if (!$result) {
            throw new NotFoundException();
        }

        return new User($result['id'], $result['email']);
    }

    public function find(UserFilter $filter): array
    {
        // TODO: Implement find() method.
    }
}

Jak widać Projection oraz View implementują interfejs Query więc ich podmiana dla systemu jest praktycznie niezauważalna. W przypadku projekcji trzeba jednak umieścić gdzieś Projector, który będzie dane dla projekcji budował.

W naszym wypadku stworzyliśmy mechanizm rozszerzeń dla całego systemu, które pozwalały nasłuchiwać poprawnego wykonania komendy oraz eventu domenowego (tak, tego z event sourcingu). Mechanizm extension na początku jak cały system był stworzony w sposób synchroniczny. Został jednak tak zaprojektowany aby jakiekolwiek niepowodzenie w czasie wykonywania się rozszerzenia nie mogło mieć wpływu na pracę systemu.

Ponieważ rozszerzenia wykonywały się tylko po poprawnym przetworzeniu komendy przez handler nie było ryzyka, że utworzymy projekcję użytkownika w sytuacji gdy komenda nie została poprawnie obsłużona. Każdy błąd handlera kończył się w naszym wypadku exception, które nie dopuszczało do wywołania się extension. My z drugiej strony nie dopuszczaliśmy nigdy do występowania tych exceptions poprzez walidację na poziomie warstwy UI, która w zasadzie nie pozwalała przekazać komendy do Command Busa kiedy nie było szans na powodzenie. Przykładowo kiedy użytkownik o podanym adresie email już istniał w systemie.

W jaki sposób odbudować projekcje w przypadku błędu czy konieczności zmiany struktury danych? W zasadzie wystarczy odczytać dane bezpośrednio w źródle i manualnie przekazać je do projektora w odpowiedni sposób.

Event sourcing w sumie ułatwia sprawę ponieważ wystarczy reagować na konkretne zdarzenia i budować projekcje, w przypadku odtwarzania read modelu również wystarczy iterować po zdarzeniach.

Podsumowanie

Na koniec chciałbym zwrócić uwagę, że wszystko co opisałem powyżej działało wewnątrz synchronicznego systemu będącego sporym monolitem. CQRS nie oznacza, że trzeba się rzucać na asynchroniczne procesowanie komend, asynchroniczne budowanie read modelu co w efekcie prowadzi do problemów z spójnością danych wynikających z równoległego przetwarzania. Nie ma potrzeby na dzień dobry straszyć inwestorów koniecznością integracji systemów kolejkowych.

W naszym wypadku finalnie doszliśmy do momentu w którym synchroniczne procesowanie komend stało się zbyt mało wydajne i skalowanie sprzętowe stawało się powoli mało opłacalne. To pchnęło nas do wprowadzenia asynchronicznego command busa, asynchronicznego mechanizmu rozszerzeń dla całego systemu i wprowadziło zupełnie inny rodzaj problemów, o których do tej pory nie myśleliśmy jak na przykład kolejność procesowania komend ale o tym kiedy indziej.

Najważniejsze jednak jest, że stosując się do podstawowych wytycznych CQRS ten rozwój w ogóle był możliwy i był stosunkowo prosty. Wszystko dzięki przestrzeganiu dwóch prostych zasad:

  • command tylko zmienia stan systemu
  • query tylko odczytuje stan systemu

Masz pytanie?
Napisz: [email protected]
Akceptuję

Ten serwis używa plików cookies. Więcej o plikach cookies.