Doctrine2 – Lifecycle eventy
- fordo.pytlik
- Člen | 29
Ahoj,
narazil jsem na problém se kterým si už několik dní lámu hlavu. Zkusím to ukázat na konkrétním příkladu – mám články a komentáře, a pokaždé se přidá komentář, tak je potřeba provést X kroků. Jako takový nejjednodušší způsob mi přijde, že bych měl nějakou továrničku:
class CommentFactory
{
public function addComment(User $user, string $text)
{
$comment = new Comment($user, $text); // vytvoříme entitu
$this->em->persist($comment);
$this->em->flush();
$this->fooService->doSomeMagic($comment);
// třeba další logika - volání funkcí tříd apod.
}
}
Tohle řešení je určitě funkční, ale má jeden velký problém – pokud za půl roku přijde někdo (např. já) tak určitě vytvoří komentář bez toho, aby použil CommentFactory. Hledal jsem nějaké robustní řešení, aby na to „nemusel myslet“. Tak jsem začal používat lifecycle eventy.
// na entitě Comment, si zaregisturju listener
#[EntityListeners([CommentListener::class])]
class Comment
{
// ...
}
use Doctrine\ORM\Mapping as ORM;
class CommentListner
{
// ...
#[ORM\PostPersist]
public function postPersist(Comment $comment)
{
$this->fooService->doSomeMagic();
}
}
Problém je v tom, že metada FooService::doSomeMagic() uvnitř volá $this->em->flush(), což (pokud se nepletu) považováno za špatný přístup, protože volám flush() uvnitř již probíhajícího flush() – viz https://www.doctrine-project.org/…/events.html#… (žlutý rámeček).
Samozřejmě můžeme upravit FooService::doSomeMagic, aby se tam flush() nevolal – to je přesně ta věc, na kterou bych zase musel myslet a přesně ta věc, na kterou za půl roku zapomenu. Hledám robustní řešení, tak aby po vytvoření komentáře se stalo X věcí. Máte nějaké nápady? jaké máme možnosti?
- Felix
- Nette Core | 1270
Ahoj,
narazil jsi na klasický problém s oddělením doménové logiky od
persistence. Je super, že jsi na to šel přes továrnu, ale chápu obavy, že
se tomu někdo časem vyhne. Stejně tak je dobře, že jsi zvažoval Doctrine
lifecycle eventy, ale jak správně zmiňuješ – volat flush()
v rámci flush()
je nebezpečné a obecně se jejich používání
na aplikační logiku nedoporučuje.
✅ Doporučené řešení: Event dispatcher
Robustní a dlouhodobě udržovatelné řešení je použít event-driven přístup – např. Symfony EventDispatcher nebo obdobný mechanismus (klidně vlastní jednoduchý). Myšlenka je následující:
1. Továrna vytvoří komentář a vyvolá událost
class CommentFactory
{
public function __construct(
private EntityManagerInterface $em,
private EventDispatcherInterface $dispatcher,
) {}
public function addComment(User $user, string $text): void
{
$comment = new Comment($user, $text);
$this->em->persist($comment);
$this->em->flush();
$this->dispatcher->dispatch(new CommentCreated($comment));
}
}
2. Listener na CommentCreated
class CommentCreatedListener
{
public function __construct(
private FooService $fooService,
) {}
public function __invoke(CommentCreated $event): void
{
$this->fooService->doSomeMagic($event->getComment());
}
}
3. Registrace listeneru
Podle toho, co používáš:
- v Symfony přes
services.yaml
(nebo autokonfiguraci), - v Nette přes
services.neon
, - nebo čistě PHP-style registrací.
Tímto způsobem máš perfektně oddělené:
- doménovou logiku (tvorbu komentáře),
- reakci na události,
- a hlavně jsi mimo Doctrine lifecycle eventy, které jsou „magické“ a hůře testovatelné/udržovatelné.
🛡 Navíc: Kontrola závislostí pomocí Deptrac
Pokud tě trápí, že někdo začne volat
FooService::doSomeMagic()
nebo EntityManager::flush()
napřímo tam, kde by neměl, doporučuju podívat se na Deptrac. Ten ti umožní definovat
pravidla mezi vrstvami aplikace (např. „Controller nesmí volat Repository
přímo“, apod.), a v CI si to můžeš i automaticky hlídat.
Instruovano pres AI.
- ViPEr*CZ*
- Člen | 822
nit: Tady jen … asi spise upozorneni… ze pokud v ramci $this->fooService->doSomeMagic($event->getComment()); zase volas persist, flush tak to je narocnejsi nez persist, persist, flush a take to tudiz nebude pripadne v ramci db od-rolovatelne transakce. Tj. zvazit jestli to je potreba takhle a nebo proste rovnou konkretni byznys v te factory/facade.
- MajklNajt
- Člen | 516
@ViPEr*CZ* v takom prípade by stačilo prehodiť
flush()
úplne nakoniec a v doSomeMagic
vôbec
flush()
nevolať:
class CommentFactory
{
public function __construct(
private EntityManagerInterface $em,
private EventDispatcherInterface $dispatcher,
) {}
public function addComment(User $user, string $text): void
{
$comment = new Comment($user, $text);
$this->em->persist($comment);
$this->dispatcher->dispatch(new CommentCreated($comment));
$this->em->flush();
}
}
- fordo.pytlik
- Člen | 29
Ahoj,
díky moc za odpovědi. Ano, dovedu si představit použít továrničky pro vytvoření entity.
Mám k tomu druhou otázku – jakým způsobem přistoupit k updatu – (tady se mi příklad s napsáním komentáře začíná trochu rozpadat) – ale například pokud chci po updatu komentáře přepočítat nějaké body.
Samozřejmě mohu použít servisní třídu, která vyvolá event:
class CommentUpdater
{
public function updateComment(Comment $comment, string $newText)
{
$comment->update($newText);
$this->em->flush();
$this->dispatcher->dispatch(new CommentUpdated(...));
}
}
Problém je v tom, že nyní všude kde měním komentář musím použít tuto třídu a přijde mi pravděpodobné, že na to zapomenu. Tento návrh mi zavání tím, že zodpovědnost za to, že se má po upravení komentáře je na tom, kdo komentář použije, nikoliv na entitě. A to IMHO je v přímém rozporu s tím, proč používám ORM – chci, aby zodpovědnost za konzistenci dat a jejich správnost byla na entitě, aby tato zodpovědnost byla v modelu a nikoliv v servisní třídě.
Nebo se možná pletu, a o celé záležitosti přemýšlím špatně a třeba by to šlo přepsat úplně bez eventů – ale i když nad tím přemýšlím znovu, tak mi přijde právě jako správný přístup, oddělit události vytvoření/updatu do samostatných listenerů, otázka je, jak vyvolat event – protože když jsem prvně četl dokumentaci eventům, tak mi přišlo, že právě k tomu by se to super hodilo.
Možná by se dalo říct, že je to vlastně jedno – buď to napíšu tady, nebo támhle – ve výsledku se to stane. Ale hledám nějaké robustní řešení, kde pořád nemusím na něco myslet, kde vlastně bussines (/doménová) logika bude zapozdřená a robustní, abych ji omylem při příštím malém updatu nerozbil.
Editoval fordo.pytlik (20. 5. 15:15)
- m.brecher
- Generous Backer | 905
@fordo.pytlik
mám články a komentáře, a pokaždé se přidá komentář, tak je potřeba provést X kroků.
potřebuješ zajistit „orchestraci“ složitějších scénářů v doménové logice, jsou dva základní přístupy:
a ) Event Driven přístup (Domain Events)
– komentář vyvolá doménovou událost CommentCreatedEvent
– event dispatcher rozešle událost listenerům CommentCreatedListener
– listenery provádějí jednotlivé akce prostřednictvím
specializovaných tříd – Mailer, jiné repository, ..
zde je primární business logika zapouzdřena v repository a vedlejší účinky (emaily) se obsluhují událostmi
b ) Servisní třída (Application Service, Domain Service)
všechny související akce doménové logiky („provést X kroků“) se řeší v samostatné třídě, Domain Service je koncept, kdy servisní třída obsahuje primárně doménovou logiku, zatímco Application Service se více zaměřuje na orchestraci různých systémů.
Event Driven koncept se doporučuje pro aplikace podléhající změnám a rozšiřování, zatímco servisní třídy pro méně složité scénáře, kde potřebuješ jasné řízení procesu.
Teď k Tvému kódu, do třídy CommentFactory Jsi zapouzdřil scénář vytvoření komentáře a následující kroky, třída má suffix Factory, což zní divně. Factory je koncept zapouzdření inicializace objektu do samostatné třídy. Třída CommentFactory ale zajišťuje orchestraci scénáře „provést X kroků“ včetně zápisu do databáze. Více by se hodil suffix Service (Facade ?).
Jsou ale i jiné cesty – např. cesta guru OOP Martina Fowlera bohatý doménový model, kdy repository samo rozhoduje vše co souvisí s logikou jeho entity, může i odesílat emaily, a máš to vyřešené. Nebo ty koncepty kombinovat, zapouzdřit „X kroků“ které pracují s entitou do repository a odeslání emailu jako „side effect“ řešit eventem !
Pokud je scénář „X kroků“ komplexní a pracuje s různými entitami, je asi lepší ho umístit do samostatné servisní třídy.
Editoval m.brecher (21. 5. 16:56)
- fordo.pytlik
- Člen | 29
Ahoj,
moc děkuji za odpověď.
- Event Driven přístup (Domain Events)
- komentář vyvolá doménovou událost CommentCreatedEvent
Nerozumím této části – jak může komentář vyvolat event? když entita nemá závislosti (aby mohla použít nějaký EventDispatcher). Můžeš mi prosím ukázat nějaký kód, jak by to mohlo vypadat?
- Servisní třída (Application Service, Domain Service)
rozumím, ta je určitě možnost, ale mám pocit, že to neodpovídá na moji obavu – aby se na to nezapomnělo. Zkusím to ukázat na příkladu. V současné době mám komponentu, která upravuje komentáře:
class UpdateCommentControl extends Control
{
// konstruktor, render, vytvoření formuláře apod...
public function processForm(Form $form)
{
$values = $form->getValues();
$comment = $this->repository->getById($this->commentId); // načtení Orm entity
$comment->updateText($values->text); // zavolání metody na entitě
$this->em->flush(); // entity manager - flush
$this->onSuccess(); // přesměrování, flash message
}
}
A protože do teďka používám lifecycle eventy, tak díky doctrine 2 magii, tak mám:
class CommentListner
{
// ...
#[ORM\PostUpdate]
public function postUpdate(Comment $comment)
{
$this->fooService->doSomeMagic();
}
}
a nyní se mi nikdy nemůže stát, že za půl roku přijde někdo jiný a
upraví komentář a zapomene zavolat
doSomeMagic()
.
Pokud tomu rozumím správně a přepracoval jsem logiku do Servisních tříd, tak by to mohlo vypadat asi takto:
class UpdateCommentControl extends Control
{
// konstruktor, render, vytvoření formuláře apod...
public function processForm(Form $form): void
{
$values = $form->getValues();
$this->service->updateComment($this->commentId, $values->text);
$this->onSuccess(); // přesměrování, flash message
}
}
class CommentUpdaterService
{
public function updateComment(int $commentId, string $text): void
{
$comment = $this->repository->getById($this->commentId); // načtení Orm entity
$comment->updateText($values->text); // zavolání metody na entitě
$this->fooService->doSomeMagic();
$this->em->flush(); // entity manager - flush
}
}
Rozumím tomu správně? Je tohle to řešení tak jak ho navrhuješ?
Já osobně problém vnímám právě v tom, že mi nic nezabrání v tom, abych omylem neupravil komentář přímo v komponentě (tj. nezavolala se funkce doSomeMagic()). To je právě moje otázka, jak to udělat, abych na to nezapomněl.
- m.brecher
- Generous Backer | 905
jak může komentář vyvolat event? když entita nemá závislosti
Entita ne, ale repository ano:
class CommentRepository extends ServiceEntityRepository // doporučeno v Doctrine
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly EventDispatcherInterface $eventDispatcher,
)
{}
public function addComment(Comment $comment): void
{
$this->entityManager->persist($comment);
$this->entityManager->flush();
$this->eventDispatcher->dispatch(new CommentCreatedEvent($comment)); // vyvolání eventu
}
}
Já osobně problém vnímám právě v tom, že mi nic nezabrání v tom, abych omylem neupravil komentář přímo v komponentě (tj. nezavolala se funkce doSomeMagic()). To je právě moje otázka, jak to udělat, abych na to nezapomněl.
Primárně bys měl kód postavit tak, aby byl v souladu s DDD principy, které ale umožňují různé koncepty. A hlavně, kód by měl být čitelný a srozumitelný, což by právě DDD měl zajistit. Mám obavu, že když pověsíš event na ukládání Comment-u do databáze, tak v komponentě nebude patrné, že se něco děje. Sice máš zajištěno, že ať vytvoříš Comment kdekoliv, vždy se vykoná „X kroků“, ale není to ideální.
Domain Service
Mě přijde lepší použít pro kód „X kroků“ CommentService, který by to zapouzdřil a v handleru komponenty (processForm) by Jsi volal CommentService.
Nebo jednoduše kód „X kroků“ umístit v handleru komponenty formuláře. Je to přehledné a nejde pak udělat chyba, když by někdo smazal $foo->doSomeMagic() tak ví co dělá, a nedělá to omylem, ale není to elegantní DDD.
Domain Event
Martin Fowler předložil koncept Repository Pattern, ve kterém říká, že repository je mostem mezi doménovou logikou (Entity, Domain Service) a databází. To je v rozporu s tím co navrhuji – odpálit event v repository. Ano, event v repository je na hraně, čistý event bez jakýchkoliv podmínek nebo dalších kroků je ještě „pattern“, ale jakmile by Jsi podmínil vyvolání eventu nějakými podmínkami (logika), už to bude „antipattern“, protože v repository by neměla doménová logika být.
Takže ano, můžeš v repository vyvolat event a ten zpracovat pomocí dispatcheru a listeneru, ale má to nevýhody v méně čitelném kódu, na hraně mezi patternem a antipatternem. Koho napadne, že v repository je skryto „X kroků“ ?
Martin Fowler totiž v konceptu Domain Event navrhuje jiný koncept eventů, vyvolávat eventy, které jsou součástí doménové logiky v Domain Service, nikoliv v Repository:
– repository se stará o persistenci
– doménová služba po flush() vyvolá event
– event listener na event reaguje
Toto je koncept, který vede na přehledný kód, byť to možná neřeší to „zapomenutí“. Když vyřešíš zapomenutí eventem v repository, bude to trochu na škodu kvality architektury kódu, je to na výběr, co Ti víc vyhovuje.
Editoval m.brecher (24. 5. 20:49)
- m.brecher
- Generous Backer | 905
AI nabízí další alternativy místo odpálení eventu v Repository pro Doctrine ORM a sice odpálit event v Doctrine Event Listener-u. Ukázky kódů a popis koncepce ti dodá https://copilot.microsoft.com/ se kterým mám pozitivní zkušenosti. Zadej dva prompty:
„Doctrine ORM, vyvolat event v Doctrine event listeneru CommentCreatedEvent, při vytvoření entity Comment, kompletní kód“
a potom doplňkový „vyvolat event pouze pro vytvoření nového záznamu“
Nemám s tím žádné praktické zkušenosti, ale nevypadá to špatně. Takže si to vyzkoušet a zvolit vítězný pattern.
Editoval m.brecher (24. 5. 20:50)
- Marek Bartoš
- Nette Blogger | 1309
PHP 8.4 + lazy služby a kolekce služeb implementujících interface v nette/di = přímé volání, podpora v IDE i PHPStanu. Nepotřebuješ nic než foreach a volání metody z interface. Eventy jsou složitý a za mě už přežitý koncept. Přemýšlet o službách jakožto o přímo volaných akcí, které si volají své dílčí akce vede k mnohem jednoduššímu kódu.
- Felix
- Nette Core | 1270
Eventy jsou složitý a za mě už přežitý koncept.
Zkusis to trochu vic rozepsat? Jaka je realna nahrada eventu? Na zaklade ceho to takto hodnotis (nejaky projekty nebo tymy, te k tomu vedou?).
Vidis to i u nejakych velkych firem a nebo frameworku, ze se odkladeji od eventu?
- m.brecher
- Generous Backer | 905
@Felix
U vás ve firmě používáte v PHP aplikacích custom eventy pro vykonání „X kroků“ po nějaké akci ? Pokud ano, tak by mě zajímalo, jak máte vyřešeno odpalování custom eventů – v Domain Service třídě, v Repository, nebo složitějším postupem např. pomocí Doctrine Event Listener ??
U eventů které se odpalují někde mimo business logiku vidím jako problém sníženou čitelnost kódu, protože v hlavní větvi zpracování akce presenteru není v kódu vidět, že se vykoná nějaký další kód. Pokud se ale custom event odpálí uvnitř business logiky (Domain Service) je to koncept Domain Event (Martin Fowler) a kód čitelný je.
Editoval m.brecher (27. 5. 15:14)
- Marek Bartoš
- Nette Blogger | 1309
Nějak takto to může být. Chybové stavy lze řídit přes checked exceptions, přes exceptions se nahradí i mechanismus pro stopnutí propagace eventu. Výhoda je, že tu možnost plně kontroluje odesílatel eventu a nikoli na příjemci který o ostatních příjemcích neví. Stejně tak si můžeš zvolit zda listener předaný objekt může měnit nebo ne. Nebo zda bude jen jeden. Není třeba implementovat event ani listener interface, vše se volá přímo. IDE i statická analýza tomu rozumí bez pluginů či generik. S nastavením služeb na lazy je to i rychlejší.
Když budeme vycházet ze specifikace eventů podle PSR-14, tak je tohle všechno, co eventy dělají.
class StopPropagation extends Exception {}
class User {}
interface PostRegistrationAction {
/**
* @throws StopPropagation
*/
public function postRegistration(User $user): void;
}
class RegistrationAction {
/**
* @param list<PostRegistrationAction> $postRegistrationActions
*/
public function __construct(
private readonly array $postRegistrationActions
) {}
public function register(User $user): void {
// <register user>
foreach ($this->postRegistrationActions as $action) {
try {
$action->postRegistration($user);
} catch (StopPropagation) {
break;
}
}
}
}
di:
lazy: true
services:
- RegistrationAction()
Na testování architekury doporučuju phpat. Vychází z phpstanu, kódu rozumí stejně dobře. Též se konfiguruje v PHP, díky čemuž je flexibilnější než Deptrac.
Editoval Marek Bartoš (27. 5. 16:41)
- Felix
- Nette Core | 1270
m.brecher napsal(a):
@Felix
U vás ve firmě používáte v PHP aplikacích custom eventy pro vykonání „X kroků“ po nějaké akci ? Pokud ano, tak by mě zajímalo, jak máte vyřešeno odpalování custom eventů.
My pouzivame CQRS.
/**
* @extends BaseCommand<Sitemap>
*/
final class CreateSitemapCommand extends BaseCommand
{
public function __construct(
public string $id,
public string $source,
public string $url,
)
{
}
}
#[AsMessageHandler]
readonly class CreateSitemapHandler
{
public function __construct(
private EntityManagerInterface $em,
private EventDispatcherInterface $ed,
)
{
}
public function __invoke(CreateSitemapCommand $command): Sitemap
{
$sitemap = new Sitemap(
$command->id,
$command->source,
$command->url,
);
$this->em->persist($sitemap);
$this->em->flush();
$this->ed->dispatch(new SitemapCreatedEvent($sitemap));
return $sitemap;
}
}
Vypada to zhruba takto. Pouziti je pres command/query bus.
$this->bus->handle(new CreateSitemapCommand(...));
Bez eventu si to nedokazu predstavit v dnesni dobe prave. Bez eventu realne nejde delat, podle moji zkusenosti, moduly/pluginy.
Kdyz vystrelim event, tak mi je realne jedno jestli na nej nekdo posloucha a nebo ne-e. Dokonce to v tu chvili ani nemusim vedet, protoze se to muze registrovat dynamicky podle nastaveni, env, ab-testovani, atd.
- Marek Bartoš
- Nette Blogger | 1309
Kdyz vystrelim event, tak mi je realne jedno jestli na nej nekdo posloucha a nebo ne-e. Dokonce to v tu chvili ani nemusim vedet, protoze se to muze registrovat dynamicky podle nastaveni, env, ab-testovani, atd.
Totéž se dá říct i o kolekci služeb. Prázdné pole v cyklu nevadí, listener a služba se afaik registrují naprosto totožně.
- Felix
- Nette Core | 1270
Marek Bartoš napsal(a):
Kdyz vystrelim event, tak mi je realne jedno jestli na nej nekdo posloucha a nebo ne-e. Dokonce to v tu chvili ani nemusim vedet, protoze se to muze registrovat dynamicky podle nastaveni, env, ab-testovani, atd.
Totéž se dá říct i o kolekci služeb. Prázdné pole v cyklu nevadí, listener a služba se afaik registrují naprosto totožně.
Jop, ja to nikomu nenutim. Zajimalo me jestli se od toho odvracis ty a nebo frameworky a komunity. V ramci firmy bych rad drzel co nejvice mainstream, aby byl hiring co nejvice uspesny.
- m.brecher
- Generous Backer | 905
@Felix
My pouzivame CQRS
Felixi,
pokud ve firmě hrajete ligu na úrovni CQRS potom jsou eventy core technologie (event sourcing, integration events). Ale my, co máme firmu, co dodává menší aplikace, pro nás je DDD klíčový koncept a tam nejsou eventy nutné.
I když z pohledu teorie Domain Driven Design by jednotlivé domény (moduly) měly být maximálně nezávislé, což dobře zajišťují eventy jako prostředek komunikace mezi doménami (Integration Events), kvůli lepší škálovatelnosti často asynchronní. Tak Jsi mě asi přesvědčil, že trend je spíš směrem k eventům než od eventů :).
Editoval m.brecher (28. 5. 1:36)