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 zwraca int, określający kolejność wykonania tego eventu. W przypadku 2 lub więcej eventów dla danej entity o tym samym order 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 :)