Как использовать EventDispatcher в Symfony 6 для собственных событий

Опубликовано admin - пн, 07/18/2022 - 16:22

В этой статья я хочу рассказать о создании собственных событий и собственных обработчиков этих событий в Symfony 6 с использованием EventDispatcher. Для начала укажу, что в symfony используются такие понятия как отправитель события (event dispatcher), слушатель события (event listener), событие (event).

Помимо указанных терминов необходим понимать, что для запуска собственных события и их обработки необходим не только новый обработчик, но и класс с событием. При этом на официальном сайте говориться, что он должен быть  подключен как сервис в конструкторе контейнера.

В качестве основы возьмём базовый пример создания собственного события и обработчика с официального сайта в статье "The EventDispatcher Component".

Установка расширения  

composer require symfony/event-dispatcher

События

События представляют собой объекты php или по другому php классы. Когда события отправляются через обработчик, его ожидает слушатель (listeners), при этом, создаваемый обработчик проходит через множество слушателей.

Требования к имени события:

  • нижний регистр, числа, точки (.) и подчеркивание (_);
  • префикс с пространством имен следует за точкой (order.*user.*);

В symfony получить доступ к событию возможно либо "прослушивая"  через event listener или подписавшись через event subcriber (подробная информация на английском указана в разделе "Creating an Event Subscriber"). В обоих случаях делается это через "EventDispatcherInterface". Однако последний (через event subcriber) более часто используется мной, поскольку позволяет неоднократно его использовать в других расширениях (как их принято называть "bundles").

Event subcriber обязательно располагается а директории /app/src/EventListeners/. Класс Event subcriber должен использовать EventSubscriberInterface и иметь метод "getSubscribedEvents", содержащий все методы подписчика, подлежащие вызову при отправке события через "dispatcher", с указанием приоритета выполнения. Если приоритет не указан, функции подписчика будут выполнены в том порядке, в котором они расположены в коде.

В нашем случае, мы указали несколько функций обратного вызова для подписчика, первая из них деактивирует пользователя, остальные приведены в качестве примера.

    public static function getSubscribedEvents(): array
    {
        return [
            UserDeactivateEvent::NAME => [
                ['deactivateUser', 10],
                ['databaseCleanup', 9],
                ['logUserDeactivated', 8],
                ['sendNotification', 7],
            ],
        ];
    }

Отправить события "dispatcher"

Отправитель события - основное связующее звено в системе отправки события к слушателю. Dispatcher уведомляет всех слушателей, через отправку события.

Как я указывал ранее отправка события допускается через слушателя (event listener):

use Symfony\Component\EventDispatcher\EventDispatcher;

$dispatcher = new EventDispatcher();
$listener = new AcmeListener();
$dispatcher->addListener('acme.foo.action', [$listener, 'onFooAction']);

Метод addListener() принимает несколько аргументов:

  • Название события которое слушатель прослушивает;
  • php функция которая подлежит выполнению когда событие отправляется слушателю;
  • Приоритет вызова слушателя. Чем выше значение, тем раньше будет запущен слушатель. Если два слушателя имеют одинаковый приоритет, они будут запущены в порядке в котором были добавлены в отправку чрез dispatcher.

 

В качестве обработчика может быть вызвана функция обратного вызова (PHP callable) как переменная которая может быть обработана функцией call_user_func() и иметь возвращаемое значение "true" если данная функция поддерживает is_callable() функцию. На официальном сайте указано, что в качестве объекта события  может быть использовано как замыкание, так и объект с методом  __invoke()  (что является переменной замыканием по факту), строка определяющая функцию,  или массив функций.

Также допустимо использовать php замыкания (PHP closure):

use Symfony\Contracts\EventDispatcher\Event;

$dispatcher->addListener('acme.foo.action', function (Event $event) {
    // will be executed when the acme.foo.action event is dispatched
});

В случае с event listener вызывается следующим образом:

$user =$this->security->getUser();
$event = new UserDeactivateEvent($user);
$listener = new UserDeactivateSubscriber($this->entityManager);
$eventDispatcher->addListener('user.delete', [$listener, 'deactivateUser']);
$eventDispatcher->dispatch($event, UserDeactivateEvent::NAME);

Указанный код может располагаться, в том числе, внутри класса контроллера. В результате будет создан слушатель user.delete а также запущены все иные слушатели методом dispatch.

Сейчас о самом сложном (Второй способ, о котором далее в основном будет говориться)

Давайте создадим подписчика на событие. Аргумент $event это объект передаваемый в момент отправки события.

Подписчики должны располагаться в директориях, которые указаны в файле service.yaml, тогда подписчики подключаются автоматически. В иных случаях необходимо подключать их вручную (об этом написано по адресу https://symfony.com/doc/current/components/event_dispatcher.html).

В моём случае подписчик имеет следующий код:

<?php

namespace App\EventListener;

use App\Event\UserDeactivateEvent;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class UserDeactivateSubscriber implements EventSubscriberInterface
{
    private EntityManagerInterface $entityManager;

    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    public static function getSubscribedEvents(): array
    {
        return [
            UserDeactivateEvent::NAME => [
                ['deactivateUser', 10],
                ['databaseCleanup', 9],
                ['logUserDeactivated', 8],
                ['sendNotification', 7],
            ],
        ];
    }

    public function deactivateUser(UserDeactivateEvent $event): void
    {
        $user = $event->getUser();

        if (!$fp = fopen($_SERVER['DOCUMENT_ROOT'].'/test.txt','a')) {
            echo "Не могу открыть файл";
            exit;
        }

        if($user->isActive()) {
            fwrite($fp,0);
            $user->setActive(false);
        } else {
            fwrite($fp,1);

            $user->setActive(true);

        }
        fclose($fp);
        $this->entityManager->flush();
    }

    public function databaseCleanup(UserDeactivateEvent $event): void
    {
        $user = $event->getUser();
        
        //do database cleanup stu
    }

    public function logUserDeactivated(UserDeactivateEvent $event): void
    {
        $user = $event->getUser();
        
        //log stu
    }

    public function sendNotification(UserDeactivateEvent $event): void
    {
        $user = $event->getUser();
        
        //do notification stu
    }
}

Код в нем носит справочный характер, методы fopen, fwrite приведены для того чтобы понять как работает система событий в symfony.  Пока необходимо разместить указанный класс в директории /app/src/EventListener/UserDeactivateSubscriber.php

Пример его вызова через контролер (метод __invoke):

<?php
namespace App\Controller;

use App\Entity\User;
use App\Event\UserDeactivateEvent;
use App\EventListener\UserDeactivateSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\SerializerInterface;

class UserDeactivateController extends AbstractController
{
    private SerializerInterface $serializer;
    private EventDispatcherInterface $eventDispatcher;

    public function __construct(SerializerInterface $serializer, EventDispatcherInterface $eventDispatcher, EntityManagerInterface $entityManager)
    {
        $this->serializer = $serializer;
        $this->eventDispatcher = $eventDispatcher;
        $this->entityManager = $entityManager;
    }

    /**
     * @Route("/user/deactivate/{user}", methods={"POST"}, name="user_deactivate")
     */
    public function __invoke(User $user): JsonResponse
    {
        $event = new UserDeactivateEvent($user);
        //dd($this->eventDispatcher->getListeners());
        //$this->eventDispatcher->addSubscriber(new UserDeactivateSubscriber($this->entityManager));

        $this->eventDispatcher->dispatch($event, UserDeactivateEvent::NAME);

        $json = $this->serializer->serialize($user, 'json', ['groups' => ['user:get']]);

        return new JsonResponse($json, JsonResponse::HTTP_OK, [], true);
    }
}

Данный подписчик может быть размещен так же и в директориях вашего собственного расширения, а не только в директории приложения по умолчанию. Как создаются собственные (кастомные) приложения можно прочитать в предыдущих статьях - Установка Symfony 5. Часть 1. Установка своего бандла. и Создание бандла на symfony 5. Часть 2. При этом, symfony самостоятельно подключить Ваши подписчики из кастомных приложений. 

Приведу пример как может выглядеть ваш кастомный подписчик (в моём случае он располагается по пути "Моё Расширение"/src/EventSubscriber/TwigEventSubscriber.php):

<?php


namespace eap1985\ProductBundle\EventSubscriber;

use App\Event\UserDeactivateEvent;
use App\Repository\UserRepository;
use eap1985\ProductBundle\Repository\ProductRepository;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\Security\Core\Security;
use Twig\Environment;

class TwigEventSubscriber implements EventSubscriberInterface
{
    private $twig;
    private $productRepository;
    private $security;


    public function __construct(Container $container, Environment $twig, ProductRepository $productRepository, $mediaManager, $provider,$em, Security $security, UserRepository $user,  )
    {
        $this->twig = $twig;
        $this->productRepository = $productRepository;
        $this->em = $em;
        $this->sonata = $mediaManager;
        $this->provider = $provider;
        $this->security = $security;
        $this->rep = $user;
        $this->container = $container;
    }

    public function onKernelController(ControllerEvent $event)
    {
        if (!$event->isMainRequest()) {

        }
        


    }

    public static function getSubscribedEvents()
    {

        return [
            'kernel.controller' => 'onKernelController',
            UserDeactivateEvent::NAME => [
                ['deactivateUser', 8],
            ],
        ];
    }

    public function deactivateUser(UserDeactivateEvent $event): void
    {
        $user = $event->getUser();

        if (!$fp = fopen($_SERVER['DOCUMENT_ROOT'].'/test.txt','a')) {
            echo "Не могу открыть файл";
            exit;
        }
        fwrite($fp,$user->getEmail());
        fclose($fp);

    }

}

 Класс User в моем случае выглядит следующим образом:

<?php

namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use FOS\MessageBundle\Model\ParticipantInterface;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity(repositoryClass=UserRepository::class)
 * @UniqueEntity(fields={"email"}, message="There is already an account with this email")
 */
class User implements ParticipantInterface, UserInterface, PasswordAuthenticatedUserInterface
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=180, unique=true)
     */
    private $email;

    /**
     * @ORM\Column(type="json")
     */
    private $roles = [];


    /**
     * @var string The hashed password
     * @ORM\Column(type="string")
     */
    private $password;

    /**
     * @ORM\Column(name="active", type="boolean", nullable=false, options={"default":1})
     * @Groups({"user:get", "user:create"})
     */
    private bool $active = true;

    /**
     *
     * @ORM\Column(type="datetime", options={"default": "CURRENT_TIMESTAMP"})
     */
    private $birthday;

    public function setBirthday(?\DateTime $birthday): void
    {
        // WILL be saved in the database
        $this->birthday = $birthday;
    }
    public function getBirthday(): ?\DateTime
    {
        return $this->birthday;
    }


    /**
     * @var SonataMediaMedia
     *
     * @ORM\OneToOne(targetEntity="App\Entity\SonataMediaMedia",cascade={"persist"} )
     * @ORM\JoinColumns( { @ORM\JoinColumn( referencedColumnName="id", onDelete="CASCADE" ) } )
     * @Assert\NotNull()
     */
    private $profilePicture;

// generated getter and setter
    public function setProfilePicture(SonataMediaMedia $profilePicture = null): User
    {
        $this->profilePicture = $profilePicture;
        return $this;
    }
    public function getProfilePicture()  {

        return $this->profilePicture;
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getEmail(): ?string
    {
        return $this->email;
    }

    public function setEmail(string $email): self
    {
        $this->email = $email;

        return $this;
    }

    /**
     * A visual identifier that represents this user.
     *
     * @see UserInterface
     */
    public function getUserIdentifier(): string
    {
        return (string) $this->email;
    }

    /**
     * @deprecated since Symfony 5.3, use getUserIdentifier instead
     */
    public function getUsername(): string
    {
        return (string) $this->email;
    }

    /**
     * @see UserInterface
     */
    public function getRoles(): array
    {
        $roles = $this->roles;
        // guarantee every user at least has ROLE_USER
        $roles[] = 'ROLE_USER';
        return array_unique($roles);
    }

    public function setRoles(array $roles): self
    {
        $this->roles = $roles;
        return $this;
    }

    /**
     * @see PasswordAuthenticatedUserInterface
     */
    public function getPassword(): string
    {
        return $this->password;
    }

    public function setPassword(string $password): self
    {
        $this->password = $password;
        return $this;
    }

    /**
     * Returning a salt is only needed, if you are not using a modern
     * hashing algorithm (e.g. bcrypt or sodium) in your security.yaml.
     *
     * @see UserInterface
     */
    public function getSalt(): ?string
    {
        return null;
    }

    /**
     * @see UserInterface
     */
    public function eraseCredentials()
    {
        // If you store any temporary, sensitive data on the user, clear it here
        // $this->plainPassword = null;
    }

    public function isActive(): bool
    {
        return $this->active;
    }

    public function setActive(bool $active): User
    {
        $this->active = $active;

        return $this;
    }

    public function __serialize(): array
    {
        return [
            'id' => $this->id,
            'email' => $this->email,
            'password' => $this->password,
            //......
        ];
    }

    public function __unserialize(array $serialized)
    {
        $this->id = $serialized['id'];
        $this->email = $serialized['email'];
        $this->password = $serialized['password'];
        // .....
        return $this;
    }

    public function __toString() {
        return $this->email;
    }

}

Указанный класс User передается через событие функции-обработчики (deactivateUser, databaseCleanup, logUserDeactivated, sendNotification):

    public static function getSubscribedEvents(): array
    {
        return [
            UserDeactivateEvent::NAME => [
                ['deactivateUser', 10],
                ['databaseCleanup', 9],
                ['logUserDeactivated', 8],
                ['sendNotification', 7],
            ],
        ];
    }

Как видите, работать с событиями в symfony не так и сложно, как кажется на первый взгляд. 

Если у Вас будут какие-либо предложения или замечания всегда рад ответить в комментариях или по почте.

Взаимосвязанные материалы

# 1. Как использовать EventDispatcher в Symfony 6 для собственных событий (понедельник, июля 18, 2022 - 16:22 ),

В этой статья я хочу рассказать о создании собственных событий и собственных обработчиков этих событий в Symfony 6 с использованием EventDispatcher. читать...

# 2. Создание собственного шаблона для полей формы с select (четверг, июля 14, 2022 - 20:50 ),
Не могу не рассказать о создании собственного шаблона для полей формы с select, поскольку потратил на это пол дня. читать...
# 3. Symfony. Администраторский раздел Sonata Admin. Команды для работы с ORM в своём расширении (bundles). (среда, марта 16, 2022 - 14:04 ),
Symfony. Администраторский раздел Sonata Admin. А также об основных командах для работы с ORM в своём расширении (bundles). читать...
# 4. Создание бандла на symfony 5. Часть 2. (пятница, мая 29, 2020 - 21:26 ),
Мы напишем бандл под названием NewsTop, который будет выводить новости с обновлением данных через ajax. NewsTop – приложение для вывода новостей на сайте. читать...
# 5. Установка Symfony 5. Часть 1. Установка своего бандла. (пятница, мая 29, 2020 - 19:06 ),

1. Основная установка.

читать...

На разработку сайта! Скидки до 20%!