janos.szurovecz.hu

A régi blogom, ahova lehet írok majd még...

DDD és CQRS a gyakorlatban

2013-12-27 11 perc olvasási idő

Korábbi írásaimban a teljesség igénye nélkül próbáltam ismertetni néhány általam készített nyílt forráskódú libraryt. Most viszont azt tűztem ki célul, hogy megmutatom a “big picture”-t, azaz egy konkrét példán keresztül bemutatom, hogy is pakolgatom össze ezeket a kis építőkockákat és csinálok egy rugalmas, könnyen karbantartható architektúrát. Fontosnak tartom megjegyezni, hogy a továbbiakban vázolt architekturális megfontolások teljes mértékben nyelvfüggetlenek. Pusztán azért mutatom be PHP-n keresztül, mert ebben a nyelvben volt/van szerencsém használni a DDD-t, illetve CQRS-t.

Hogy darabolom rétegekre a szoftvert?

Először is próbáljuk minél inkább elkülöníteni az interfészt a rendszer többi komponenseitől. Jelen esetben interfész alatt felhasználói interfészt értek, tehát egy webes projekt esetén a webes interfészt. Az, hogy weben keresztül akarjuk használni az alkalmazásunkat, fontos dolog, de nem szabad, hogy ez minden rétegben megjelenjen. A presentation réteg az, ami a ezt a tudást hordozza, vagyis ez van a legmagasabb szinten. Ebben a rétegben kezeljük a requestet és responset, viszont itt nem érdekes számunkra az alkalmazás magja, a domain réteg. Itt nincs adatbázis, nincs email küldés, csak és kizárólag az, ami a HTML, JSON, XML, stb. előállításhoz és feldolgozáshoz szükséges, úgy mint, controllerek, formok, template fájlok, statikus fájlok, stb.

Az MVC patternt sokan hibásan projekt szinten alkalmazzák, átszőve azzal a teljes kódbázist, holott ez a presentation rétegbe való. Ebből is következik, hogy az MVC frameworknek nincs keresnivalója alacsonyabb szinten. Érdemes akár külön modulba rakni a presentation réteget, teljesen leválasztva azt a többitől. Ez olyan előnyökkel jár, mint például az, hogy bármikor lecserélhetjük azt (mondjuk másik MVC frameworköt felhasználva), vagy könnyen készíthetünk egy másik interfészt (SOAP, REST, stb.).

Függőségek kezelése

Szerencsére már vannak PHP-ban is eszközök a függőségek kezelésére (composer), így elég könnyen felszabdalhatjuk a projektünket. Tegyük fel, hogy kétfelé vágjuk: frontend és backend modul, előbbi a presentation réteget, utóbbi a többit tartalmazza. Adja magát az, hogy a frontend modulba függőségként felvesszük a backendet, ezzel viszont akad egy probléma: két modul kommunikációja egy nagyon pontosan meghatározott interfészen (class/interface halmaz) keresztül kell történjen és a kérdés az, hogy ez hova kerüljön? Ha a backend projektbe, akkor azt nem tudjuk könnyen kicserélni, ami viszont hasznos tud lenni pl. unit tesztek futtatásakor, vagy akkor, ha a backend egy third party cucc és első körben szeretnénk valami dummy megvalósítást használni. Ha viszont a frontendbe rakjuk, akkor még rosszabb a helyzet: a backendben nem tudjuk használni ezt az interfészt, mivelhogy a frontend függ a backendtől, nem pedig fordítva.

A megoldás az, hogy a két modul közötti interfész réteget (legyen backend-api) külön modulba szervezzük, és ezt függőségként húzza be mind a frontend, mind a backend projekt. Eszközfüggő, de pl. composerrel megoldható az, hogy a backend-api-ban függőségként definiáljuk a nem létező backend-implementation-t, amit a backend modulban provide-ként felveszünk. Ezzel elérjük azt, hogy bármikor kicserélhetjük bármelyik modult és a hiányzó függőségekről is egyből értesülünk.

Szóval van egy frontend projektünk controllerekkel, formokkal, statikus fájlokkal, ennek elkészítésére nem térnék ki. Nyilvánvalóan valahogy kommunikálni akarunk a backenddel, ez a kommunikáció alapvetően két féle lehet: olvasó, valamint író/módosító. A továbbiakban a CQRS elveit figyelembe véve fogom ismertetni a dolgokat a predaddy és kapcsolódó PHP libraryk felhasználásával.

Író/módosító műveletek - commandok

A commandok lényegében szerializált metódushívások. Egyszerű osztályok, az osztálynevek valamilyen utasítást fejeznek ki: CreateUser, SendEmail, stb. Ezekben az osztályokban alap, beépített típusokat érdemes használni. A commandokat a command busnak kell elküldeni, amely aztán továbbítja azt a megfelelő command handlernek. A commandokat érdemes egy backend-write-api (nem feltétlenül ilyen nevű) modulba rakni, amelyet függőségként húz be mind a front-, mind a backend modul.

Hogy kezeljem a commandokat?

A command busra command handlereket aggatunk, amelyekben a megfelelő commandot mindig a megfelelő handler metódus fogja megkapni. Lehetőleg tartózkodjuk attól, hogy több handler, illetve több metódus is megkaphassa ugyanazt a command objektumot. A command handlerek az application rétegben vannak a backend modulban. A handlerek domain objektumokkal dolgoznak, tipikusan repositorykkal és modellekkel. A handler fogja elkészíteni, majd perzisztálni a repositoryn keresztül az objektumokat, illetve ő fogja az aggregate rootokon hívni a megfelelő metódusokat. Mindebből következik, hogy egy command handler metódus elég rövid, az esetek többségében kevesebb, mint 10 sor. A DDD-ben az aggregatek tranzakciós határt definiálnak, vagyis érdemes ezen a szinten kezelni a tranzakciókat.

A predaddy az 1.2-es verzió óta direkt módon ad command bus megvalósítást és egyben natív megoldást nyújt az automatikus tranzakció kezelésre: minden handler metódust tranzakcióba zár. Ha pedig Doctrinet használunk perzisztencia rétegként, akkor elérhetjük azt is, hogy minden tranzakcióban új EntityManager példányt kapjunk, ezáltal több - akár rollbackelt - tranzakció is könnyen kezelhető egy requesten belül.

<?php

namespace hu\szjani\sample\application\user;

use Assert\Assertion;
use hu\szjani\sample\command\user\CreateUser;
use hu\szjani\sample\command\user\ModifyEmail;
use hu\szjani\sample\domain\model\user\User;
use hu\szjani\sample\domain\model\user\UserRepository;
use precore\lang\Object;
use predaddy\messagehandling\annotation\Subscribe;

class UserCommandHandler extends Object
{
    private $userRepository;

    public function __construct(UserRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    /**
     * @Subscribe
     * @param CreateUser $command
     */
    public function handleCreateUser(CreateUser $command)
    {
        $user = new User($command->getEmail(), $command->getRawPassword());
        $this->userRepository->store($user);
        self::getLogger()->info("User [{}] has been created", array($command->getEmail()));
    }

    /**
     * @Subscribe
     * @param ModifyEmail $command
     */
    public function handleModifyEmail(ModifyEmail $command)
    {
        $userId = $command->getUserId();
        $newEmail = $command->getEmail();
        $user = $this->userRepository->findOneById($userId);
        Assertion::notNull($user, "Invalid user ID [{$userId}]");
        $user->updateEmail($newEmail, $command->getRawPassword());
        $this->userRepository->store($user, $command->getVersion());
        self::getLogger()->info("User's [{}] email has been modified to [{}]", array($userId, $newEmail));
    }
}

Nézzük, mit csinálunk a domain rétegben lévő User aggregate rootban

<?php

namespace hu\szjani\sample\domain\model\user;

use Assert\Assertion;
use precore\util\UUID;
use predaddy\domain\AggregateRoot;
use predaddy\messagehandling\annotation\Subscribe;

class User extends AggregateRoot
{
    private $userId;
    private $email;
    private $passwordHash;

    public function __construct($email, $rawPassword)
    {
        Assertion::email($email, "Invalid email address [$email]");
        $this->raise(new UserCreated(UUID::randomUUID()->toString(), $email, $rawPassword));
    }

    /**
     * @throws \InvalidArgumentException if a parameter is invalid
     */
    public function updateEmail($newEmail, $currentRawPassword)
    {
        // validate parameters and the state of the AR, throw exception if necessary
        Assertion::email($newEmail, "Invalid email address [$newEmail]");
        Assertion::eq($this->passwordHash, md5($currentRawPassword), "Invalid password");

        // send a DomainEvent
        $this->raise(new UserEmailUpdated($this->userId, $newEmail));
    }

    /**
     * @return mixed
     */
    public function getUserId()
    {
        return $this->userId;
    }

    /**
     * @return mixed
     */
    public function getEmail()
    {
        return $this->email;
    }

    /**
     * @Subscribe
     * @param UserCreated $event
     */
    private function handleUserCreated(UserCreated $event)
    {
        $this->userId = $event->getAggregateIdentifier();
        $this->email = $event->getEmail();
        $this->passwordHash = md5($event->getRawPassword());
    }

    /**
     * @Subscribe
     * @param UserEmailUpdated $event
     */
    private function handleUserEmailUpdated(UserEmailUpdated $event)
    {
        $this->email = $event->getEmail();
    }
}

Minden publikus, módosító metódus az aggregate rootokban szükségszerűen csak író/módosító, nincs visszatérési értékük. Alapvetően két dolgot csinálnak: validálják a bejövő paramétereket az aggregate állapotától függően, valamint ha minden stimmel, elsütnek egy domain eventet. Nem módosítanak semmit. A domain eventet ugyanis azonnal megkapja az AR-en belüli private/protected handler metódus, ami az eventben lévő értékek segítségével módosítja az aggregatet. Itt már nem történhet semmilyen hiba. De miért nem? Mert az eventek már megtörtént eseményeket reprezentálnak, ami pedig már megtörtént, az megtörtént, nem lehet visszacsinálni. Miután a belső handler metódus megkapta az eventet, jönnek a külső event handlerek, és mindegyik, ami kezeli az adott eventet, megkapja azt.

<?php

namespace hu\szjani\sample\infrastructure\eventhandler;

use hu\szjani\sample\domain\model\user\UserEmailUpdated;
use predaddy\messagehandling\annotation\Subscribe;

class UserEmailNotificationNecessary
{
    /**
     * @Subscribe
     * @param UserEmailUpdated $event
     */
    public function sendNewEmail(UserEmailUpdated $event)
    {
        // post new command, or send email, etc.
    }
}

A predaddyben lévő AggregateRoot osztály két szinten kezeli az eseményeket: automatikusan, minden AR-hez létrehoz egy belső message bust, amin keresztül az AR-en belüli handler metódusokat hívja, valamint kívülről be kell állítanunk egy domain event bust, amire csak azután küldi ki az eventeket, hogy azokat az AR már lekezelte. Az utóbbi, explicit message bus esetén érdemes EventBus példányt használni, az ugyanis tranzakcióhoz kötött és csakis sikeres commit után küldi ki az addig pufferelt domain eventeket.

Vannak hosszan tartó folyamatok, azokat hogy érdemes kezelni?

Aszinkron módon. Bár működik, mégse javaslom, hogy a domain event busunk aszinkron legyen. Ha aszinkron működést szeretnénk elérni, akkor azt javaslom, hogy kapjuk el az eventet és továbbítsuk azt egy aszinkron busra.

A predaddyvel konfigurálható olyan MessageBus (az EventBus és a CommandBus is a MessageBus-ból származik), amivel aszinkron eventeket küldhetünk. Ez az mf4php libraryvel oldható meg, amihez egyelőre beanstalk implementáció készült. Bár nem biztos, hogy a legjobb megoldás, de elárulok egy ügyes trükköt: készítsünk egy üres AsyncEvent interfészt, hozzá az imént említett handlert és minden eventben, amit aszinkron (is) szeretnénk feldolgozni, implementáljuk ezt az interfészt. Így a handler minden ilyen eventet megkap és forwardolja azt a másik busra.

<?php

namespace hu\szjani\sample\infrastructure\eventhandler;

use hu\szjani\sample\domain\shared\AsyncEvent;
use predaddy\messagehandling\MessageBus;
use predaddy\messagehandling\annotation\Subscribe;

class AsyncEventHandler
{
    private $asyncBus;

    public function __construct(MessageBus $asyncBus)
    {
        $this->asyncBus = $asyncBus;
    }

    /**
     * @Subscribe
     * @param AsyncEvent $event
     */
    public function handleAsyncEvent(AsyncEvent $event)
    {
        $this->asyncBus->post($event);
    }
}

A command bus nem ad vissza semmit, hogy értesüljek az eredményről?

Alapvetően sehogy, ez a lényege a CQRS-nek, legalábbis command részről. Viszont bizonyos esetekben mégiscsak jó dolog ez és megoldható, hogy visszatérési értékek, illetve kivételek visszavándoroljanak a hívóhoz.

A preaddys MessageBus post() metódusának átadhatunk egy callback objektumot, ami értesül minden exceptionről (a bus egyébként minden exceptiont elnyel), valamint megkapja az esetleges visszatérési értéket, amennyiben a command handler mégis ad vissza valamit. De akkor honnan értesülünk a sikeres végrehajtásról, ha a handler nem ad vissza semmit? Egyszerű: event handlerrel. A felhasználóknak szóló üzenetekhez beregisztrálok egy handlert, amivel elkapok minden domain eventet.

Mi a különbség egy finder és egy repository között?

A finder teljes mértékben a frontend igényeit szolgálja, független a domain rétegtől, tehát nem tud az AR-ekről semmit. A finderben nincs írási művelet, kizárólag finder metódusok találhatók benne, amelyek egy, vagy több DTO-val térnek vissza. Ez a helye a szűrésnek, lapozásnak, rendezésnek. Ezzel szemben a repositoryban kizárólag a domain és application réteg számára szükséges metódusok vannak, domain modellekkel dolgoznak. Természetesen itt is szükség van olvasó metódusokra, pl. egy findOneById() metódus elengedhetetlen ahhoz, hogy egy meglévő AR-t módosítsunk a command handlerben, de ezek egyike sincs felhasználva közvetlenül, vagy közvetetten a UI-on. Mind a repository, mind a finder implementációk a backend modul, azon belül is az infrastructure réteg részei.

Tehát akkor mit is használhatok a presentation layerben?

Mindent, ami ott van, valamint az api modulokban. Vagyis küldhetünk commandokat a command busnak, valamint használhatunk findereket és az általuk visszaadott DTO objektumokat.

Vannak interfészek a domain rétegben, de hol vannak az implementációk?

Az infrastructure rétegben. Ez az a réteg, ami átsző mindent, ami alapvetően nem egy jól elkülőníthető réteg. Itt vannak azok a konkrét implementációk, amelyek kapcsolódnak az adatbázishoz, levelet küldenek, külső serviceket hívnak, stb. Akkor jó az architektúránk, ha egy meglévő library lecseréléséhez elegendő ebben a rétegben elkészíteni egy új implementációt és módosítani a konfiguráción. Az annotációkra sokan fújnak, mondván hozzákötik a kódhoz a konkrét libraryt: ilyen pl. az entitykben lévő Doctrine annotációk (vagy mondjuk Java esetén a JPA annotációk). Ez kód szinten igaz, de vegyük észre: az annotációk inline konfigurációk. Adott esetben kényelmes, adott esetben problémát okoz, de nem más, mint konfiguráció. Így azt mondom, ez nem számít. Ha nagyon bántja a szemünket, használjunk config fájlt. A predaddy bár csak annotációval tud dolgozni, de úgy lett kialakítva, hogy könnyen készíthető hozzá konfigurációs fájlt feldolgozó implementáció.

Hány rétegből áll tehát az alkalmazás?

Jelen esetben négy rétegből: presentation, application, domain és infrastructure. Modulokat tekintve szintén négy van: frontend a presentationnek, backend a többinek, valamint egy backend-write-api (commandok) és backend-read-api (DTO és finder interfészek) a kommunikáció során felhasznált elemeknek.

Az application rétegben vannak a command handlerek. A domain rétegben az összes entity, és value object. Ezeken kívül az összes domain service, factory és repository interfész; valamint ezeknek azon implementációi, amelyeknek nincs külső függőségük, tehát minden függőségük a “domain” rétegben van.

Hogy teszteljem az események/commandok kiváltását?

Az entitykben nem feltétlenül van getter definiálva minden memberhez, így adott esetben nem lehet kiolvasni az új értéket, így nem lehet azt ellenőrizni sem. Tegyük fel, hogy az AR megfelelően lekezeli az eventet, azt akarjuk ellenőrizni, hogy az eventben minden megfelelő-e. Ebben az esetben be kell regisztrálnunk egy event handlert a busra, mielőtt meghívjuk az AR-en a metódust. Tesztelésnél erősen ajánlom az anonym függvényeket a következő módon:

<?php

namespace hu\szjani\sample\domain\model\user;

use PHPUnit_Framework_TestCase;
use predaddy\domain\AggregateRoot;
use predaddy\messagehandling\annotation\AnnotatedMessageHandlerDescriptorFactory;
use predaddy\messagehandling\DefaultFunctionDescriptorFactory;
use predaddy\messagehandling\SimpleMessageBus;

class UserTest extends PHPUnit_Framework_TestCase
{
    /**
     * @var SimpleMessageBus
     */
    private $bus;

    public function setUp()
    {
        parent::setUp();
        $funcDescFactory = new DefaultFunctionDescriptorFactory();
        $handlerDescFactory = new AnnotatedMessageHandlerDescriptorFactory($funcDescFactory);
        $this->bus = new SimpleMessageBus(__CLASS__, $handlerDescFactory, $funcDescFactory);
        AggregateRoot::setEventBus($this->bus);
    }

    public function testEmailUpdate()
    {
        $email = '[email protected]';
        $newEmail = '[email protected]';
        $pass = 'randomPass';
        $user = new User($email, $pass);
        $called = false;
        $this->bus->registerClosure(
            function (UserEmailUpdated $event) use (&$called, $user) {
                UserTest::assertEquals($user->getUserId(), $event->getAggregateIdentifier());
                UserTest::assertEquals($user->getEmail(), $event->getEmail());
                $called = true;
            }
        );
        $user->updateEmail($newEmail, $pass);
        self::assertTrue($called);
    }
}

Ha a User-től nem tudom elkérni az emailt (getEmail()), akkor problémás a helyzet. De miért is kell nekünk az email cím, ha nem tudjuk azt elkérni a user-től? Ha nincs hozzá getter, akkor vagy felesleges az email cím, vagy közvetett módon ugyan, de megkaphatjuk azt. Pl. egy másik metódus hívásra adott esetben bekerül az eventbe az email cím. Egy másik példa talán szemléletesebb: még ha nem is tudjuk elkérni jelszó módosítás után a usertől az új jelszót, de az updateEmail() metódhívásnál van jelszó ellenőrzés. Vagyis jelszó változtatás után csak akkor tudunk email címet is változtatni, ha az új jelszóval hívjuk a metódust. A commandok esetén hasonló a probléma és a megoldás is.

A loggolást hogy érdemes beépíteni?

Az összes említett library lf4php-t használ, ami szintén egy általam készített interface library. Monologhoz és log4php-hez készítettem implementációt, vagyis bármelyiket is használjuk, a libek megfelelően fognak loggolni. Kis munkával tetszőleges logging frameworkhöz készíthető lf4php implementáció.

Miért jó ez az egész?

Az osztályok gyengén csatoltak, rövidek, egyszerűek. Mindennek meg van a helye. Könnyű szeparálni a dolgokat, több csapat is tud párhuzamosan dolgozni a projekten anélkül, hogy akár látnák egymás munkáját. A teljesítmény könnyen skálázható aszinkron műveletekkel, tetszőleges message queue rendszer felhasználásával. Minimális továbbfejlesztéssel elérhető az event sourcing, aminek segítségével kidobhatjuk az ORM-et és még nagyobb teljesítményt érhetünk el. A komplex folyamatok is jól implementálhatók ebben az architektúrában, tetszőlegesen bonyolult és hosszan tartó üzleti processek is jól modellezhetők.

Néhány számadat egy majd’ 20,000 soros alkalmazásból: átlagos ciklomatikus komplexitás: 1.2, átlagos osztály hossz: 36 sor, átlagos metódus hossz: 9.2 sor.

A példa alkalmazás moduljai elérhetők a https://github.com/szjani?tab=repositories alatt, cqrs-sample* kezdettel. A frontend modult nem készítettem el, de azt hiszem a meglévő alapján már nem nehéz elképzelni. Az infrastructure rétegbeli implementációkkal szintén nem foglalkoztam csakúgy, mint egy DIC library behúzásával és konfigurálásával sem.

comments powered by Disqus