Doctrine'owe entity event'y w ładniejszym opakowaniu
Listenery
danych Entity
- zwłaszcza na dużych projektach - potrafią przypominać wyborne spaghetti na talerzu, które dla programisty często jest dość trudne do strawienia.. Jednak jest na to prosty sposób...
Sam w swojej kilkuletniej pracy zawodowej spotykałem się z takimi przypadkami - gdzie listenery
wyglądały jak najgorszy śmietnik - i zazwyczaj nurkowanie w tym nie było dość przyjemnym doświadczeniem.
Przy okazji mojego najnowszego (małego) projekciku udało mi się w dość elegancki sposób rozwiązać ten problem. Jak? Sprytnie ;) Oczywiście wszystko opakowałem w skromną paczkę, bo coś tak czuję, że jeszcze mi się to może kiedyś przydać - chętnie się podzielę ;)
Zastosowanie
Wpierw przestawię sposób użycia - w pierwszej kolejności musimy utworzyć klasę Notifier
dla naszej danej wybranej Entity
. Na przykładzie Page
z mojego projektu:
<?php
namespace App\EventListener\Entity;
use App\Entity\Page;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use KosmCODE\EntityEventBundle\Enum\Event\EntityEnum;
use KosmCODE\EntityEventBundle\EventListener\Entity\AbstractNotifier;
#[AsEntityListener(event: EntityEnum::prePersist->value, method: EntityEnum::prePersist->name, entity: Page::class)]
#[AsEntityListener(event: EntityEnum::preUpdate->value, method: EntityEnum::preUpdate->name, entity: Page::class)]
#[AsEntityListener(event: EntityEnum::postUpdate->value, method: EntityEnum::postUpdate->name, entity: Page::class)]
#[AsEntityListener(event: EntityEnum::postRemove->value, method: EntityEnum::postRemove->name, entity: Page::class)]
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
final class PageNotifier extends AbstractNotifier
{
public function getEntityClassName(): string
{
return Page::class;
}
}
Jedyne co musimy prócz stworzenia klasy to określenie dziedziczenia po klasie abstrakcyjnejAbstractNotifier
z paczki (KosmCODE\EntityEventBundle\EventListener\Entity\AbstractNotifier
), która wymusza implementacje metody getEntityClassName
, która to powinna zwracać Entity className
.
Reszta odbywa się w sposób standardowy - określamy najlepiej Annotations
z używanymi event'ami:
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use KosmCODE\EntityEventBundle\Enum\Event\EntityEnum;
#[AsEntityListener(event: EntityEnum::prePersist->value, method: EntityEnum::prePersist->name, entity: Page::class)]
EntityEnum
z paczki zawiera wszystkie obsługiwane eventy.
Teraz przyszedł czas na klasę samego eventu - tutaj dla ciekawszego przypadku damy na warsztat klasę SlugCheckEvent
<?php
namespace App\EventListener\Entity\Event\Page\Pre;
use Doctrine\Common\EventArgs;
use App\Entity\Page;
use KosmCODE\EntityEventBundle\EventListener\Entity\Event\PreUpdateInterface;
use KosmCODE\EntityEventBundle\EventListener\Entity\Event\PrePersistInterface;
use Symfony\Component\String\Slugger\SluggerInterface;
final class SlugCheckEvent implements PrePersistInterface, PreUpdateInterface
{
public function __construct(
private readonly SluggerInterface $slugger,
)
{
}
/** {@inheritDoc} */
public function getSupportedEntityClassName(): string
{
return Page::class;
}
/** {@inheritDoc} */
public function getOrder(): int
{
return 1;
}
/**
* {@inheritDoc}
*
* @param Page $entity
*/
public function do(object $entity, EventArgs $args, array $entityChangeSet = []): void // @phpstan-ignore-line
{
if ($entity->getSlug()) {
$entity->setSlug(
$this->slugger->slug(
$entity->getSlug()
)
);
return;
}
$entity->setSlug(
$this->slugger->slug(
$entity->getTitle()
)
);
}
}
Zadaniem tego eventu jest przygotowanie pola slug
modeluPage
- nic wyrafinowanego. Klasa implementuje dwa interfejsy PrePersistInterface oraz PreUpdateInterface
i to one stanowią, w których entity eventach
ten event ma być brany pod uwagę. Oczywiście można zastosować tylko jeden interfejs - albo i więcej niż dwa - nic nie stoi na przeszkodzie.
Implementacja tego/tych interfejsu/ów wymusza implementacje trzech metod:
getSupportedEntityClassName
- która musi zwracaćclassName Entity
getOrder
- która zwracaint
, określający kolejność wykonania tegoeventu
. W przypadku 2 lub więcejeventów
dla danejentity
o tym samymorder
wykonają się one po sobie.do
- metoda, w której dzieje się logika tego eventu.
I to w sumie tyle - sama przyjemność :) Co ponadto daje nam takie rozwiązanie? Czystszy kod. Przejrzystszy. Lepiej wydzielony na odpowiedzialności. Elegancko poukładany w przestrzeni plików, powydzielany na logicznie określone katalogi.
Ktoś pewnie pomyśli jak z wydajnością takiego rozwiązania - nie robiłem jeszcze większych testów, ale myślę, że przy takich technologiach jak JIT
w PHP 8, mogą to być minimalne, niezauważalne wręcz różnice.
Zasada działania
Co tutaj rzecz - prosta jak budowa.. fabryki ;) Otóż cała magia odbywa się w AbstractNotifier
(KosmCODE\EntityEventBundle\EventListener\Entity\AbstractNotifier
) w paczce Bundla.
<?php
namespace KosmCODE\EntityEventBundle\EventListener\Entity;
use Doctrine\ORM\Event\PostUpdateEventArgs;
use Doctrine\ORM\Event\PreRemoveEventArgs;
use Doctrine\ORM\Event\PostRemoveEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Event\PreFlushEventArgs;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Event\OnClearEventArgs;
use Doctrine\ORM\Event\PostLoadEventArgs;
use Doctrine\ORM\Event\LoadClassMetadataEventArgs;
use Doctrine\ORM\Event\OnClassMetadataNotFoundEventArgs;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
use KosmCODE\EntityEventBundle\EventListener\Entity\Event\EventInterface;
use Doctrine\Common\EventArgs;
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
abstract class AbstractNotifier
{
/**
* @param EventInterface[] $entityPrePersistEvents
* @param EventInterface[] $entityPostPersistEvents
* @param EventInterface[] $entityPreUpdateEvents
* @param EventInterface[] $entityPostUpdateEvents
* @param EventInterface[] $entityPreRemoveEvents
* @param EventInterface[] $entityPostRemoveEvents
* @param EventInterface[] $entityPreFlushEvents
* @param EventInterface[] $entityOnFlushEvents
* @param EventInterface[] $entityPostFlushEvents
* @param EventInterface[] $entityOnClearEvents
* @param EventInterface[] $entityPostLoadEvents
* @param EventInterface[] $entityLoadClassMetadataEvents
*/
public function __construct(
#[AutowireIterator('app.entity.event.prePersist')]
private iterable $entityPrePersistEvents,
#[AutowireIterator('app.entity.event.postPersist')]
private iterable $entityPostPersistEvents,
#[AutowireIterator('app.entity.event.preUpdate')]
private iterable $entityPreUpdateEvents,
#[AutowireIterator('app.entity.event.postUpdate')]
private iterable $entityPostUpdateEvents,
#[AutowireIterator('app.entity.event.preRemove')]
private iterable $entityPreRemoveEvents,
#[AutowireIterator('app.entity.event.postRemove')]
private iterable $entityPostRemoveEvents,
#[AutowireIterator('app.entity.event.preFlush')]
private iterable $entityPreFlushEvents,
#[AutowireIterator('app.entity.event.onFlush')]
private iterable $entityOnFlushEvents,
#[AutowireIterator('app.entity.event.postFlush')]
private iterable $entityPostFlushEvents,
#[AutowireIterator('app.entity.event.onClear')]
private iterable $entityOnClearEvents,
#[AutowireIterator('app.entity.event.postLoad')]
private iterable $entityPostLoadEvents,
#[AutowireIterator('app.entity.event.loadClassMetadata')]
private iterable $entityLoadClassMetadataEvents,
#[AutowireIterator('app.entity.event.onClassMetadataNotFound')]
private iterable $entityOnClassMetadataNotFoundEvents,
) {
}
/**
* @var array<mixed>
*/
protected array $entityChangeSet = [];
public abstract function getEntityClassName(): string;
public function prePersist(object $entity, PrePersistEventArgs $args): void
{
$this->doSupportedEvents(
$this->getSupportedEvents($this->entityPrePersistEvents),
$entity,
$args,
$this->entityChangeSet
);
}
public function postPersist(object $entity, PostPersistEventArgs $args): void
{
$this->doSupportedEvents(
$this->getSupportedEvents($this->entityPostPersistEvents),
$entity,
$args,
$this->entityChangeSet
);
}
public function preUpdate(object $entity, PreUpdateEventArgs $args): void
{
$this->doSupportedEvents(
$this->getSupportedEvents($this->entityPreUpdateEvents),
$entity,
$args,
$this->entityChangeSet
);
$this->entityChangeSet = $args->getEntityChangeSet();
}
public function postUpdate(object $entity, PostUpdateEventArgs $args): void
{
$this->doSupportedEvents(
$this->getSupportedEvents($this->entityPostUpdateEvents),
$entity,
$args,
$this->entityChangeSet
);
}
public function preRemove(object $entity, PreRemoveEventArgs $args): void
{
$this->doSupportedEvents(
$this->getSupportedEvents($this->entityPreRemoveEvents),
$entity,
$args,
$this->entityChangeSet
);
}
public function postRemove(object $entity, PostRemoveEventArgs $args): void
{
$this->doSupportedEvents(
$this->getSupportedEvents($this->entityPostRemoveEvents),
$entity,
$args,
$this->entityChangeSet
);
}
public function preFlush(object $entity, PreFlushEventArgs $args): void
{
$this->doSupportedEvents(
$this->getSupportedEvents($this->entityPreFlushEvents),
$entity,
$args,
$this->entityChangeSet
);
}
public function onFlush(object $entity, OnFlushEventArgs $args): void
{
$this->doSupportedEvents(
$this->getSupportedEvents($this->entityOnFlushEvents),
$entity,
$args,
$this->entityChangeSet
);
}
public function postFlush(object $entity, PostFlushEventArgs $args): void
{
$this->doSupportedEvents(
$this->getSupportedEvents($this->entityPostFlushEvents),
$entity,
$args,
$this->entityChangeSet
);
}
public function onClear(object $entity, OnClearEventArgs $args): void
{
$this->doSupportedEvents(
$this->getSupportedEvents($this->entityOnClearEvents),
$entity,
$args,
$this->entityChangeSet
);
}
public function postLoad(object $entity, PostLoadEventArgs $args): void
{
$this->doSupportedEvents(
$this->getSupportedEvents($this->entityPostLoadEvents),
$entity,
$args,
$this->entityChangeSet
);
}
public function loadClassMetadata(object $entity, LoadClassMetadataEventArgs $args): void
{
$this->doSupportedEvents(
$this->getSupportedEvents($this->entityLoadClassMetadataEvents),
$entity,
$args,
$this->entityChangeSet
);
}
public function onClassMetadataNotFound(object $entity, OnClassMetadataNotFoundEventArgs $args): void
{
$this->doSupportedEvents(
$this->getSupportedEvents($this->entityOnClassMetadataNotFoundEvents),
$entity,
$args,
$this->entityChangeSet
);
}
/**
* @param EventInterface[] $entityEvents
*
* @return array<int, array<int,EventInterface>>
*/
private function getSupportedEvents(iterable $entityEvents): array
{
$supportedEvents = [];
foreach ($entityEvents as $entityEvent) {
if ($entityEvent->getSupportedEntityClassName() !== $this->getEntityClassName()) {
continue;
}
if (isset($supportedEvents[$entityEvent->getOrder()])) {
$supportedEvents[$entityEvent->getOrder()][] = $entityEvent;
continue;
}
$supportedEvents[$entityEvent->getOrder()] = [$entityEvent];
}
return $supportedEvents;
}
/**
* @param array<int, array<int,EventInterface>> $supportedEvents
* @param object $entity
* @param EventArgs $args
* @param array<mixed> $entityChangeSet
*
* @return void
*/
private function doSupportedEvents( // @phpstan-ignore-line
array $supportedEvents,
object $entity,
EventArgs $args,
array $entityChangeSet = []
): void {
foreach ($supportedEvents as $supportedEventsArray) {
/** @var EventInterface $supportedEvent */
foreach ($supportedEventsArray as $supportedEvent) {
$supportedEvent->do($entity, $args, $this->entityChangeSet);
}
}
}
}
Tak oto prezentuje się w całej okazałości - dla danych Entity eventów
, są zbierane instancje klas danych eventów
, selekcjonowane po klasie entity
obsługiwanej i order
danego eventu
, a następnie wykonywane. Można się zgubić - przyjrzenie się jednemu przypadkowi w klasie powyżej, rozjaśni wszystko ;)
Jeżeli chcemy zmienić logikę metody któregoś eventu
dla danej Entity
to wystarczy przeciążyć daną metodę na klasie Notify
danej Entity
:) Ewentualnie całą powyższą klasę abstrakcyjną i utworzyć własną bazową i od niej wychodzić dalej. Kto jak woli, komu jak potrzeba :)
To by było na tyle :)