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 - System post

2018-04-25

Każdy poprzedni wpis dotyczący CQRS'a mniej lub bardziej wspominał o systemie, czyli o takim bycie do którego trafiają wszystkie command oraz queries. Za pomocą komend, stan systemu może być zmieniony, query natomiast służy do jego odczytania. Przykładowo budując system do zarządzania pracownikami, komendą CreateUser() moglibyśmy dodać użytkownika do systemu a następnie korzystając z UserQuery::totalCount() : int moglibyśmy pobrać całkowitą liczbę użytkowników, którymi system zarządza. Czym jednak jest system?

Popatrzmy na poniższy przykład.

<?php

namespace System\Userinterface\Web\Controller;

use System\Application\CommandBus;
use System\Application\Query\UserQuery;

class UserController
{
    private $commandBus; 
    private $query; 

    public function __construct(CommandBus $commandBus, UserQuery $query)
    {
        $this->commandBus = $commandBus;
        $this->query = $query;
    }

    public function createAction(Request $request) : Response
    {
        $command = new CreateNewUser(
            (string) $request->post->get("email"),
            (string) $request->post->get("username"),
        );

        $this->commandBus->handle($command);

        return new JsonResponse([
            'userId' => $this->query->getByEmail((string) $request->post->get("email"))->id()
        ]); 
    }
}

Mamy komendę, komenda przekazywana jest do command busa, przechodzi przez handler, gdzieś tam pod spodem tworzona jest encja reprezentująca użytkownika w systemie. Ta encja staje się write modelem, następnie być może budowana jest jakaś projekcja z której na końcu dane może czerpać UserQuery. To właśnie jest nasz system. Widzicie jak bardzo jest on wkomponowany w UI?

Wszystko wydaje się być poprawne i poukładane, jednak brakuje tu jasno zdefiniowanych granic. W jaki sposób Controler istniejący gdzieś na poziomie warstwy UI dostaję CommandBus, oraz UserQuery? W jaki sposób te dwa byty są tworzone i przekazywane dalej? Na czym tak naprawdę polega warstwa UI? Na czymkolwiek co znajduje się w warstwie App? A może tylko na Command Busie i Queries? W jaki sposób najlepiej przetestować Tworzenie nowego użytkownika w systemie? Dlaczego moje testy jak i aplikacją stają się coraz bardziej zależne od frameworka i warstwy UI?

Jakiś czas temu zacząłem zadawać sobie właśnie takie pytania. Pierwsza rzecz jaka skłoniła mnie do przemyśleń na temat systemu były testy. Za każdym razem gdy chciałem przetestować jakąś funkcjonalność systemu przykładowo takie dodawanie użytkownika musiałem wysłać komendę do command busa, pobrać dane za pomocą Query i dokonać asercji. Typowy Arrange/Act/Assert. Ponieważ akurat wykorzystywałem framework dostarczający service container, to właśnie tam inicjalizowałem command busa oraz queries, żeby nie bootstrapować tych bytów za każdym razem na potrzeby testów uzależniłem TestCase od frameworka. To był pierwszy błąd, aplikacja teoretycznie była niezależna od frameworka, w praktyce jednak ta zależność istniała i uniemożliwiała w zasadzie zmianę frameworka.

Mniej więcej wtedy pomyślałem, no dobra a co gdyby skrystalizować, umieścić go w warstwie Application i za jego pomocą mówić aplikacji co chciałbym zmienić oraz czego chciałbym się dowiedzieć? Popatrzmy na poniższy przykład

<?php

declare (strict_types=1);

namespace Example\Application;

use Example\Application\Command\Command;
use Example\Application\Query\Query; 

final class System
{
    public function handle(Command $command) : void
    {

    }

    public function query(string $queryClass) : Query
    {

    }
}

Taki byt można potraktować jako jednolity punkt wejścia/wyjścia, możemy nazwać go System, Kernel, Core.

Jak więc wyglądałby powyższy przykład gdyby zamiast korzysta z command busa i query, skorzystać z systemu?

<?php

namespace System\Userinterface\Web\Controller;

use System\Application\Query\UserQuery;

class UserController
{
    private $system; 

    public function __construct(System $system)
    {
        $this->system = $system;
    }

    public function createAction(Request $request) : Response
    {
        $command = new CreateNewUser(
            (string) $request->post->get("email"),
            (string) $request->post->get("username"),
        );

        $this->system->handle($command);

        return new JsonResponse([
            'userId' => $this->system->query(UserQuery::class)->getByEmail((string) $request->post->get("email"))->id()
        ]); 
    }
}

Niby nie zmieniło się nic, naprawdę jednak zmieniło się wszystko. Po pierwsze mamy jasno zdefiniowaną i czytelną zależność, Controler zależy od System'u. Chcemy przetestować kontroler? Nie ma problemu, tworzymy FakeSystem i możemy śmiało pisać unit test na kontroler. Wcześniej niby też mogliśmy tylko, trzeba było stworzyć 2 test doubles, jeden dla command busa, drugi dla query, niby nic wielkiego? Niby nie ale z czasem dojdzie więcej Queries, potworzą się dodatkowe zależności i będziemy mockować nie 2 a 5 bytów.

Chcąc przetestować integracyjnie tworzenie użytkownika w systemie wystarczy wysłać do systemu Command i pobrać jego stan za pomocą odpowiedniego Query. Aby jednak taki test był możliwy do napisania trzeba zbootstrapować System. Dalej można to zrobić za pomocą service containera dostarczanego przez framework (tak jest szybciej), jednak w samym teście nie musimy już odwoływać się do service containera. Wystarczy, że odwołamy się do systemu. Oto jak mógłby wyglądać abstrakcyjny test case.

<?php

declare (strict_types=1);

namespace Example\Application\Test;

abstract class SystemTestCase extends PHPUnitTestCase;
{
    public abstract function system() : System;
}

Implementacja bazująca na service containerze frameworka, może pobierać system z containera. Implementacja totalnie niezależna może sobie budować system samodzielnie używając wszędzie implementacji InMemory. Piszecie testy akceptacyjne? Na pewno. Używacie Behata? To bardzo dobrze, zbudujcie sobie Context wewnątrz niego zbootstrapujcie System oparty o InMemory i wysyłajcie do niego Command oraz czytajcie z niego za pomocą Queries.

Testy to jednak nie wszystkie korzyści jakie daje System. Powiedzmy, że pracujemy sobie z ORM'em, którego zadaniem jest śledzenie zmian w write modelu. Gdzie najlepiej rozpocząć i zamknąć transakcję zapisu tych zmian?

<?php

declare (strict_types=1);

namespace Example\Application;

use Example\Application\Command\Command;
use Example\Application\Query\Query; 

final class System
{
    public function handle(Command $command) : void
    {
        $this->transactionManager->open();

        try {
            $this->commandBus->handle($command);
            $this->transactionManager->close();
        } catch (\Exception $e) {
            $this->transactionManager->rollback();

            throw $e;
        }
    }

    public function query(string $queryClass) : Query
    {

    }
}

Od teraz każda komenda obsługiwana przez system musi być atomowa i zawierać się w dokładnie jednej transakcji. Tak, ktoś powie Ale przecież można napisać sobie middle ware do command busa!. No można, tylko:

a) Nie każda implementacja command busa posiada taki mechanizm b) Ukrywamy bardzo istotne pojęcie gdzieś na poziomie infrastruktury c) Czy wymiana implementacji command busa powinna mieć wpływ na transakcję?

Pójdźmy krok dalej. Każdy dobrze zaprojektowany system loguje to co się w nim dzieje. Nie mając logów jesteśmy ślepi. Czy może istnieć lepsze miejsce na logowanie tego co się dzieje niż System?

<?php

declare (strict_types=1);

namespace Example\Application;

use Example\Application\Command\Command;
use Example\Application\Query\Query; 

final class System
{
    public function handle(Command $command) : void
    {
        $this->logger->debug('...');
        $this->transactionManager->open();

        try {
            $this->commandBus->handle($command);
            $this->transactionManager->close();
            $this->logger->debug('...');
        } catch (\Exception $e) {
            $this->transactionManager->rollback();
            $this->logger->error('...');

            throw $e;
        }
    }

    public function query(string $queryClass) : Query
    {

    }
}

Wystarczy podnieść verbosity logów do poziomu DEBUG i zobaczymy dokładnie w jakiej kolejności komendy trafiały do systemu i były przez niego obsługiwane, które zostały obsłużone a które nie.

Niektóre systemu muszą zapisywać wszystkie operacje/zmiany. Posiadając System, dodanie mechanizmu zapisywania komend w metodzie System::handle(Command $command) można zrobić praktycznie w dowolnym momencie życia projektu. Co więcej, komendy których z jakiejś przyczyny nie udało się przetworzyć (połączenie z bazką nam akurat padło albo programista zrobił buga, którego testy nie wyłapały), można wrzucić łatwo na jakiś storage i spróbować ponownie wysłać ją do systemu po pewnym czasie.

W wpisie CQRS - Read Model poruszałem temat budowania projekcji na podstawie komend, które trafiają do systemu. Czy może być lepsze miejsce dla projektora niż System::handle(Command $command)? Projektor oczywiście dobrze jakby był asynchroniczny i najlepiej tak zabezpieczony aby nie mógł przerwać wykonywania metody handle ale śmiało można zacząć od synchronicznego i rozbudowywać po czasie.

Chciałbyś aby UI Twojej aplikacji bardzo szybko reagował na żądania użytkownika i zastanawiasz się nad asynchronicznym przetwarzaniem żądań? Żaden problem, stwórz Command w Controlerze, zserializuj (w końcu to DTO, nie?) wrzuć na kolejkę i daj działać consumerowi. Consumer zależny jedynie od systemu zdeserializuje komendę, przekaże ją do systemu a ten zrobi resztę. No dobra, ale przecież to wszystko da się też osiągnąć przekazując wszędzie CommandBus bezpośrednio? No tak, da się tylko że z czasem ta liczba miejsc rośnie i bardzo łatwo jest stworzyć niepoprawne i bardzo złożone zależności. Controlery, CLICommands, Consumery - to wszystko musiałoby dostać Command Busa, a jeżeli nagle zajdzie potrzeba żeby zrobić coś po każdej poprawnie obsłużonej komendzie?

Posiadając system można też bardzo łatwo wprowadzić jedna prostą zasadę, Warstwa UI nie powinna być zależna od niczego co leży w warstwie Application, co nie jest System'em. Dzięki temu, redukujemy zależności pomiędzy warstwami.

Gdyby więc próbować narysować aplikację złożoną z takich warstw jak UI, Application i Domain mogłaby ona przypominać klepsydrę. Wszystko co z UI trafia do Systemu, który poniżej operuje na bardzo rozbudowanym i skomplikowanym modelu Domeny.

Podsumowując CQRS w jednym pliku:


interface System { public function handle(Command $command) : void public function query(string $queryClass) : Query; }

Jeżeli CQRS został poprawnie zaimplementowany, wprowadzenie System'u nie powinno być większym problemem.


Masz pytanie?
Napisz: [email protected]
Akceptuję

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