Command Handler v Symfony 8: registrace bez race condition
Registrace uživatele v Symfony 8 od HTTP požadavku po doménovou událost: command, handler a command bus, proč unikátnost e-mailu hlídá jen databáze, kdo řídí transakci, kudy posílat doménové události, proč catch kolem dispatch() nic nechytí a jak v kontejneru propojit repozitář s rozhraním.
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
Dva uživatelé se stejným e-mailem
Modelová situace. Handler registrace se před uložením zeptá repozitáře, jestli e-mail už někdo nemá. Nemá, takže uživatele uloží.
Jenže ve stejnou chvíli přijde druhá registrace se stejnou adresou. Obě se zeptají dřív, než kterákoli z nich zapíše, a obě slyší, že je adresa volná.
V tabulce users pak máte dva uživatele se stejným e-mailem. Registraci od HTTP požadavku až po doménovou událost jde postavit tak, aby se tohle stát nemohlo.
Command, handler a bus
Registraci spouští command RegisterUser. Je to neměnný objekt, který popisuje záměr a nese jen data. Jméno, e-mail a heslo, žádné chování.
Vykoná ho handler. Agregát založí nebo načte, zavolá doménovou metodu a výsledek uloží. Pravidla přitom nechává agregátu.
Mezi nimi stojí command bus ze Symfony Messengeru. Kontroler handler vůbec nezná, busu předá jen command. Bus cestou spustí middleware validation, který command zvaliduje, a doctrine transaction, který handler obalí transakcí.
Teď k úvodu. Naivní handler hlídá unikátnost dotazem findByEmail. Mezi dotazem a zápisem je ale okno a do něj se vejde druhý požadavek. Mezi kontrolou a zápisem se data můžou změnit.
Unikátnost proto garantuje jen databáze. Na sloupci email leží unique constraint a druhý INSERT skončí výjimkou UniqueConstraintViolationException. Handler ji chytí a přeloží na doménovou DuplicateEmailException.
Dotaz findByEmail smí zůstat navíc, kvůli hezčí hlášce v běžném případě. Jako jediná ochrana ale nestačí nikdy.
Všimněte si flush uvnitř bloku try. Krátký odhad. Proč ho handler volá sám, když flush i commit obstará middleware?
Constraint se vyhodnotí až při flushi. Middleware flushuje po návratu handleru a výjimka by pak vybublala mimo tenhle catch. Commit dál řídí middleware, handler jen zapíše SQL o chvíli dřív.
Repozitář a jedna transakce
Repozitář tvoří dvojice. Rozhraní UserRepository leží v doméně a implementace DoctrineUserRepository v infrastruktuře.
Metoda save přitom jen volá persist. Neflushuje a transakci neotevírá. To obstará middleware kolem celého handleru, takže jeden use case odpovídá jedné transakci.
Lákavá pojistka je obalit tělo save ještě vlastní transakcí přes wrapInTransaction. Pod middlewarem tím ale vzniknou dvě vrstvy. Vnější otevře middleware a vnitřní Doctrine vytvoří jako savepoint.
Krátký odhad. Co potvrdí vnitřní commit v repozitáři?
Do databáze nic. Skutečná transakce je pořád jen jedna a vnitřní commit jen uvolní savepoint. Potvrzené je všechno až po commitu middlewaru.
Rollback vnitřní vrstvy se zase vrátí jen k savepointu a zápisy vnější vrstvy zůstanou. Z kódu pak nepoznáte, kde se doopravdy commituje. Transakci proto řídí jediná vrstva a kniha volí middleware na command busu.
Doménové události: kdy a kudy
Po flushi handler vyzvedne události přes releaseEvents a pošle je na event bus. Pořadí a jeho důvody znáte z dílu šest bé.
Kapitola přidává varování. Bez tohohle kroku zůstane UserRegistered ležet v agregátu. Posluchač, který zakládá přihlašovací záznam, se nespustí a uživatel se nepřihlásí. A přitom nic nespadne.
Kudy událost poslat, rozhoduje příjemce. Posluchači uvnitř kontextu, třeba read model nebo audit log, dostanou synchronní event bus v Messengeru. Běží v témže požadavku i v téže transakci.
Jiný kontext dostane událost přes asynchronní transport a spolehlivé doručení mu v produkci zajistí outbox. EventDispatcher zůstává pro technické háčky frameworku. Doménové události jím neprocházejí.
Proč jedna sběrnice pro oba případy? Přesun posluchače na asynchronní zpracování je pak změna routingu v konfiguraci a kód se nepřepisuje.
Třetí cestu potkáte v Symfony projektech často. Doctrine listener na onFlush nebo postFlush si sám vybere agregáty a rozešle jejich události. Aplikační vrstva o publikaci nemusí vědět.
V onFlush jsou ale změny už spočítané a obyčejný persist se do probíhajícího flushe nepromítne. A v postFlush nejde bezpečně zavolat flush. Publikace by skončila v kódu nejtěsněji svázaném s ORM, který se nejhůř testuje.
Ještě samotná událost. UserRegistered v konstruktoru přijme UserId a Email, uloží si ale obyčejné řetězce. Zkuste odhadnout proč, když události objednávky nesou hodnotové objekty.
Odebírá ji i kontext Identity, takže má tvar integrační události. Řetězce se serializují bez závislosti na třídách hodnotových objektů. A příjemce z nich poskládá reakci, aniž by sahal zpátky do repozitáře.
Výjimky a tenký kontroler
Výjimky kapitola třídí podle vrstvy. Doménová hlásí porušené pravidlo, třeba duplicitní e-mail. Aplikační patří use casu, jako ValidationFailedException z middlewaru validation. Infrastrukturní jsou technické, třeba výpadek databáze.
Kontroler je adaptér mezi HTTP a aplikační vrstvou. Atribut MapRequestPayload převede JSON na command a zvaliduje ho. Když validace neprojde, vrátí Symfony status čtyři sta dvacet dva.
Pak kontroler command pošle na bus a duplicitní e-mail přeloží na konflikt. Vypadá to rozumně. Jenže tenhle catch nechytí nic.
Synchronní Messenger zabalí každou výjimku z handleru do HandlerFailedException. Původní typ se ke catch bloku nedostane a klient místo konfliktu dostane chybu serveru.
Kontroler proto chytá obálku a projde zabalené výjimky. Je jich pole, protože jedna zpráva může mít víc handlerů. Na DuplicateEmailException odpoví stavem čtyři sta devět.
Všechno ostatní pošle dál. Kdyby neznámou chybu spolkl, zamaskoval by skutečné selhání.
Krátký odhad. Klient pošle e-mail s mezerou na začátku a adresa bez mezery už je obsazená. Čtyři sta dvacet dva, nebo čtyři sta devět?
Čtyři sta dvacet dva. Validátor běží nad surovým vstupem, ještě před Email fromUserInput. Striktní režim mezeru odmítne a k normalizaci ani k databázi se požadavek nedostane.
U jména to command řeší naopak. Délka se měří až po oříznutí, protože UserName si vstup ořízne sám. Jinak by jedno písmeno obalené mezerami prošlo validací a spadlo až v hodnotovém objektu jako chyba serveru.
A HTML formulář? Váže se na command, nikdy na entitu, protože Form by obešel továrny i invarianty agregátu. U readonly commandu vrací formulář pole a command z něj skládá kontroler.
DI a autowiring po kontextech
Zbývá kontejner. Handler typuje na rozhraní UserRepository a Symfony musí vědět, co za něj dosadit.
Dva zápisy v services.yaml vypadají skoro stejně. Klíč class založí pod jménem rozhraní novou službu. Vzniknou tak dvě instance DoctrineUserRepository.
Obě dostanou týž EntityManager, stav repozitáře ale existuje dvakrát. A dekorátor nebo tag na jedné instanci se druhé netýká.
Zápis se zavináčem je alias. Jedna instance, dvě jména. Idiomatičtější je atribut AsAlias přímo na implementaci, ten znáte z dílu devět a.
Ve větším projektu dostane každý kontext vlastní blok resource místo souhrnného bloku App. Hranice kontextu je pak vidět i v kontejneru.
Má to háček. Souhrnný blok zaregistruje i to, na co se zapomnělo. Třída z adresáře, který ve výčtu chybí, službou prostě není. A když na ní nic nezávisí, jako na handleru, ukáže se chyba až za běhu.
Otázky a shrnutí
Otázka na rozmyšlenou. Synchronní posluchač UserRegistered vyhodí výjimku. Co zůstane z registrace?
Nic. Výjimka vybublá z event busu přes handler a middleware odvolá celou transakci, uživatele i zápisy posluchače. Kontroler v obálce DuplicateEmailException nenajde, pošle ji dál a klient dostane chybu serveru.
A jedna z dílu o doménových službách. Handler registrace volá repozitář i flush. Proč to není doménová služba?
Koordinuje use case a sahá na perzistenci. Doménová služba drží pravidlo, které nemá vlastníka, a perzistenci nevolá.
Takže v kostce. Handler koordinuje a pravidla nechává agregátu. Unikátnost drží databáze, handler ji jen přeloží do jazyka domény.
Transakci řídí jediná vrstva, middleware na command busu. Události se vyzvedávají po flushi a kudy jdou, rozhoduje příjemce.
Kontroler je tenký adaptér. Zvaliduje vstup, rozbalí HandlerFailedException a vrátí správný status.
Příští díl otevírá autorizaci. Pravidlo, kdo smí objednávku zrušit, potřebuje místo, na které nezapomene žádný vstupní bod. Celou kapitolu o implementaci v Symfony najdete v knize na ddd-v-symfony.katuscak.cz.