CQRS v Symfony Messengeru: dva modely bez druhé DB
CQRS v Symfony 8 bez mýtů: čím se liší od CQS, proč nepotřebuje Event Sourcing, druhou databázi ani frontu, kdy se vyplatí a kdy ne (Dahan, Fowler). Command, query a event bus v Messengeru s různým middlewarem, #[AsMessageHandler(bus: …)], doctrine_transaction vs. jeden agregát na transakci, command se záměrem, query handler nad read repozitářem, ViewModel a HandleTrait.
Přehrávač YouTube se načte až po kliknutí · otevřít na YouTube (nové okno) ↗
Přepis dílu komentář tak, jak zazní ve videu
Profil přes doménový model
Modelová situace. Profil uživatele má ukázat počet objednávek a členskou úroveň. Přes doménový model to znamená načíst uživatele a všechny jeho objednávky. Úroveň se pak dopočítá.
Je to pomalé a sahá to přes hranice agregátů. Kvůli jednomu číslu na obrazovce se načte celá historie nákupů.
CQRS na to má druhý model, připravený přesně pro tuhle obrazovku. A nepotřebuje k tomu frontu ani druhou databázi.
Co je CQRS a kde končí CQS
CQRS vychází z prostého pozorování. Model, který data zapisuje, nemusí být tentýž jako model, který je čte.
Tradiční aplikace má na obojí jednu entitu. Objednávku přes ni zakládá i vypisuje v seznamu. CQRS tuhle odpovědnost rozdělí do dvou modelů a každý má vlastní úkol.
Vzor popsal Greg Young. Jeho CQRS Documents pocházejí z roku dva tisíce deset.
Kořeny sahají ke CQS Bertranda Meyera. To je pravidlo pro jednotlivou metodu. Buď mění stav, nebo vrací hodnotu, nikdy obojí.
CQRS stejnou myšlenku posouvá z metody na model. Vzniknou dva, každý s vlastní sadou tříd. Databáze přitom může zůstat jedna.
Young sám píše, že se o CQRS zpočátku mluvilo jako o CQS na vyšší úrovni. Nakonec se ustálil jako samostatný vzor. A podle Younga to není architektura celé aplikace, popisuje něco uvnitř jednoho systému.
Kolem CQRS kolují tři mýty. Podle prvního vyžaduje Event Sourcing. Nevyžaduje. Oba vzory jsou nezávislé a CQRS stojí i na obyčejné Doctrine.
Druhý mýtus tvrdí, že potřebujete dvě databáze. Young přitom popisuje read stranu jako tenkou vrstvu, která čte přímo z databáze a plní DTO. Oddělené úložiště je jen jedna z možností.
A fronta? Azure Architecture Center píše rovnou, že posílání zpráv pro CQRS podmínkou není. Messenger je v tomhle dílu zvolený nástroj, ne předpoklad vzoru.
Kapitola proto rozlišuje čtyři úrovně. Na první jsou oddělené handlery nad sdílenou databází a ORM entitami.
Na druhé dostane read strana vlastní DTO a přímé SQL přes DBAL. Třetí odděluje úložiště a čtvrtá přidává Event Sourcing.
Rozumná cesta vede přes první nebo druhou úroveň. A rozhoduje se pro každý Bounded Context zvlášť. Administraci klidně stačí obyčejný CRUD.
Kdy se CQRS vyplatí a kdy ne
Hlavní přínos je oddělení odpovědností. Write model nese invarianty a konzistenci dat. Read straně zbývá jediný úkol, dodat data ve tvaru obrazovky.
Každá strana se pak ladí zvlášť. Read model navíc jde kdykoli přestavět a write model se ho nedotkne.
Young k tomu přidává zkušenost z praxe. Ve webových systémech podle něj dotazy často převyšují zápisy o dva řády, někdy i o víc. Škálovat se proto vyplatí hlavně čtení.
Cena je vidět hned. Každý command a query má vlastní třídu a handler. Kde CRUD vystačí s jednou třídou, vznikne jich několik.
Oddělená úložiště přidají synchronizaci a když se změna do read modelu nepropíše, modely se rozejdou. K tomu eventual consistency a tým, který se musí naučit nové pojmy.
Technika ale není jediná osa. Udi Dahan se dívá na to, jak moc v doméně spolupracují lidé. CQRS se podle něj vyplatí tam, kde víc aktérů mění tatáž data.
Krátký odhad. Nákupní košík. Kandidát na CQRS?
Podle Dahana ne. Nikdo neupravuje košík někoho jiného, takže doména není kolaborativní. Dahan k tomu dodává, že většina týmů, které CQRS nasadily, to dělat neměla.
Martin Fowler radí stejnou opatrnost a o vzoru rozhoduje pro konkrétní Bounded Context. Kompromis kapitoly je proto střízlivý. Začít úrovní jedna a přidávat, až to bude potřeba.
Symfony Messenger jako základ CQRS
Ani sběrnice není podmínka. Command a query třídy volané přímo z kontroleru jsou plnohodnotná úroveň jedna.
Bus se vyplatí, až kolem zpracování přibude společná infrastruktura, třeba transakce, validace nebo logování. V Symfony tuhle roli hraje Messenger.
Pro CQRS je na něm podstatné, že umí víc sběrnic. Dokumentace Symfony ale klade podmínku. Jeden bus je dobrý výchozí stav a další se přidává, až potřebuje jiný middleware.
Kapitola ji splňuje. Command bus obalí handler validací a transakcí. Query bus data jen čte, takže mu transakce nic nepřinese.
Třetí je event bus pro doménové události. Posluchačů tam může být nula i víc.
V konfiguraci to jsou tři bloky. Command bus má validation a doctrine transaction, query bus jen validation.
Event bus se liší v podstatném bodě. Příkaz bez handleru je chyba, událost bez posluchače běžný stav. Proto allow no handlers.
Se třemi sběrnicemi přestane stačit holý atribut AsMessageHandler. Handler se s ním zaregistruje na všechny busy najednou.
Vazbu na jednu sběrnici vynutí až parametr bus. GetUserProfileHandler pak zná jen query bus.
Middleware doctrine transaction má jeden háček. Obaluje celý handler. Když handler uloží dva agregáty, zapíšou se atomicky. A to odporuje pravidlu z dílu sedm á, že jeden command mění jeden agregát.
Kniha middleware nechává zapnutý a pravidlo hlídá code review. Druhá cesta je middleware vypnout a transakci řídit v repozitáři přes wrapInTransaction. Víc agregátů pak atomicky uložit nejde.
Krátký odhad. Relay outboxu pošle doménovou událost omylem na command bus. Projektor je registrovaný jen na event busu. Co se stane?
Command bus pro událost žádný handler nenajde. Zpráva vyčerpá opakování a k projektoru se nedostane. Bez failure transportu ji Messenger zahodí a událost se ztratí. S ním skončí v dead letter queue a čeká na ruční zásah. Přesně tomu měl outbox bránit.
Command, query, handler, ViewModel
Teď k samotným zprávám. Command je neměnné DTO a jménem vyjadřuje záměr. RegisterUser nebo PlaceOrder, ne SaveUser nebo UpdateOrder.
Záměr se nebere odnikud. U Younga stojí před CQRS task-based UI. Z formuláře nad entitou s tlačítkem Uložit vznikne jediný UpdateOrder.
Co uživatel vlastně chtěl, se ztratí dřív, než to doména uvidí. Rozhraní složené z úloh, třeba změnit doručovací adresu nebo stornovat objednávku, dá každé úloze vlastní command.
RegisterUser z dílu deset bé nese validační atributy. Middleware validation ho zkontroluje dřív, než dorazí k handleru.
Dahan tu rozlišuje dvě věci. Validace na kontextu nezávisí. Je e-mail e-mailem a má heslo aspoň dvanáct znaků?
Byznys pravidla na kontextu závisí. Jestli adresu už někdo nepoužil, rozhodne až doména. Formálně validní command tak může doménově selhat.
Mají commandy něco vracet? Striktní CQS říká ne. I Fowler ale přiznává, že kvůli použitelnému pop pravidlo poruší.
Pro ID nového záznamu nabízí kapitola dvě cesty. Klient ho vygeneruje předem a command nic nevrací, což funguje i asynchronně.
Nebo ho handler vrátí přes HandledStamp. To ale selže, jakmile command poputuje přes asynchronní transport. Handler běží v jiném procesu a výsledek se k požadavku nedostane.
Mezi stranami se nejvíc liší handler. Rozdíl je vidět přímo v závislostech. RegisterUserHandler pracuje s doménou, s repozitářem, entitou User a hodnotovými objekty.
GetUserProfileHandler doménu záměrně obchází. Sáhne do úzkého read repozitáře a ten vrátí rovnou ViewModel.
Je to UserProfileViewModel z úvodu, i s počtem objednávek a členskou úrovní. Read repozitář ho naplní jedním dotazem přes DBAL. Hydratace entit by tu byla jen režie.
Zbývá výsledek vyzvednout. Kontroler z kapitoly vezme poslední HandledStamp a zavolá na něm getResult. Když handler chybí, dostane null a spadne.
Messenger na to má HandleTrait. Jeho metoda handle výsledek vrátí a zároveň ověří, že zprávu obsloužil právě jeden handler. Nula i dva skončí výjimkou se srozumitelnou hláškou.
Kontroler pak jen zavolá ask a se stampy vůbec nepracuje.
Krátký odhad. Někdo přesměruje GetUserProfile na asynchronní transport. Co udělá ask?
Vyhodí výjimku. Zpráva odejde do fronty a v tomhle procesu ji nikdo neobslouží. Dotazy proto zůstávají synchronní.
Otázky a shrnutí
Otázka na rozmyšlenou. Query handler má holý atribut AsMessageHandler. Dotaz GetUserProfile omylem odejde na command bus. Co se stane?
Doputuje ke svému handleru, protože holý atribut ho registruje na všech busech. A poběží v transakci. S parametrem bus nastaveným na query bus by command bus handler nenašel a vyhodil výjimku.
A jedna z dílu deset. Proč RegisterUserHandler nekontroluje duplicitní e-mail přes findByEmail?
Dvě souběžné registrace by kontrolou prošly obě. Unikátnost drží unique constraint v databázi a handler jeho výjimku přeloží na DuplicateEmailException.
Takže v kostce. CQRS dělí model na zápis a čtení. Fronta, druhá databáze ani Event Sourcing k tomu potřeba nejsou.
Druhý bus má smysl jen s jiným middlewarem. A handler bez parametru bus visí na všech.
Command nese záměr. Query handler obchází doménu a vrací ViewModel.
Příští díl read model naplní a ukáže, jak žít s tím, že chvíli zaostává za zápisem. Celou kapitolu o CQRS najdete v knize na ddd-v-symfony.katuscak.cz.