Использование событийной модели в Doctrine 2 + Symfony 3

PHPSymfonyDoctrine ORM

Давайте представим ситуацию: у вас есть заказ в интернет магазине (Entity). Заказ имеет некий статус. При смене статуса заказа необходимо провести кучу сопутствующих действий, например:


  • сохранить в заказе дату последнего изменения
  • записать в историю по заказу информацию о смене статуса
  • отослать письмо / sms клиенту
  • вызвать метод API службы доставки / платежной системы / партнера и т.д.

Возникает вопрос как все это правильно организовать с точки зрения программного кода.
Все ниже описанное справедливо для Doctrine 2 и Symfony > 3.1


Если вы не знакомы с событийной моделью Doctrine, то сначала рекомендую ознакомиться с документацией.


Приведу пример простейшего кода для Entity заказа:


Код Entity заказа
/**
 * Order
 *
 * @ORM\Table(name="order")
 */
class Order
{
    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(name="fio", type="string", length=100)
     */
    private $fio;

    /**
     * @ORM\Column(name="last_update_date", type="datetime")
     */
    private $lastUpdateDate;

    /**
     * @ORM\Column(name="create_date", type="datetime")
     */
    private $createDate;

    /**
     * @ORM\Column(name="status_id", type="integer")
     */
    private $statusId;

    // дальше getter/setter методы
}

Начнем с самого простого — нам нужно, чтобы при создании заказа, в поле create_date была записана дата создания, а при любом изменении заказа, в поле last_update_date, дата последнего изменения.


Самое простое — это явно добавить параметры в том месте, где заказ создается и обновляется (в контроллере или специальном сервисе).


$order = new Order();
$order->setCreateDate(new \DateTime());
$order->setLastUpdateDate(new \DateTime());
// ....
$em->persist($order);
$em->flush();

Минусы такого подхода очевидны — если заказ создается, а тем более, обновляется в нескольких местах — нужно будет в каждом месте повторять эту логику. К счастью Doctrine содержит в себе обработку событий (LifecycleEvents).


Добавляем в описание Entity конструкцию, которая говорит Doctrine, что Entity содержит в себе некие события, которые нужно обработать:


/**
* @ORM\HasLifecycleCallbacks()
*/

и создаем методы, которые будут "реагировать" на эти события. В нашем случае будут два метода:


/**
*  @ORM\PrePersist
*/
public function setCreateDate()
{
     $this->createDate = new \DateTime();
}
/**
*  @ORM\PreFlush
*/
public function setLastUpdateDate()
{
     $this->lastUpdateDate = new \DateTime();
}

@ORM\PrePersist и @ORM\PreFlush говорят Doctrine выполнить соответствующие методы соответственно при создании Entity и при каждом ее обновлении. Теперь нет нужды отдельно устанавливать эти даты. Полный список возможных событий можно посмотреть здесь


Текущий вид Entity заказа
/**
 * Order
 *
 * @ORM\Table(name="order")
 * @ORM\HasLifecycleCallbacks()
 */
class Order
{
    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(name="fio", type="string", length=100)
     */
    private $fio;

    /**
     * @ORM\Column(name="last_update_date", type="datetime")
     */
    private $lastUpdateDate;

    /**
     * @ORM\Column(name="create_date", type="datetime")
     */
    private $createDate;

    /**
     * @ORM\Column(name="status_id", type="integer")
     */
    private $statusId;

    // дальше getter/setter методы

    /**
     *  @ORM\PrePersist
     */
    public function setCreateDate()
    {
        $this->createDate = new \DateTime();
    }
    /**
     *  @ORM\PreFlush
     */
    public function setLastUpdateDate()
    {
        $this->lastUpdateDate = new \DateTime();
    }
}

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


OrderHistory: Entity записи в истории по заказу
/**
 * OrderHistory
 *
 * @ORM\Table(name="order_status_history")
 * @ORM\HasLifecycleCallbacks()
 */
class OrderHistory
{
    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(name="order_id", type="integer")
     */
    private $orderId;

    /**
     * @ORM\Column(name="manager_id", type="integer")
     */
    private $managerId;

    /**
     * @ORM\Column(name="status_id", type="integer")
     */
    private $statusId;

    /**
     * @ORM\ManyToOne(targetEntity="OrderStatus")
     * @ORM\JoinColumn(name="status_id", referencedColumnName="id")
     */
    private $status;

    /**
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\Manager")
     * @ORM\JoinColumn(name="manager_id", referencedColumnName="id")
     */
    private $manager;

    /**
     * @ORM\ManyToOne(targetEntity="Order", inversedBy="orderHistory")
     * @ORM\JoinColumn(name="order_id", referencedColumnName="id")
     */
    private $order;    

    // дальше getter/setter методы

    /**
     * @ORM\Column(name="create_date", type="datetime")
     */
    private $createDate;

    /**
     *  @ORM\PrePersist
     */
    public function setCreateDate()
    {
        $this->createDate = new \DateTime();
    }
}

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


Для этого в Doctrine есть EntityListeners — класс, который отслеживает изменения; место, где можно держать всю логику обработки событий.


Есть два варианта: либо мы добавляем обработчик событий на уровне описания Entity:


/**
* @ORM\EntityListeners({"AppBundle\EntityListeners\OrderListener"})
*/

И создаем класс Listener-а


class OrderHistoryListener
{
    public function postUpdate(Order $order, LifecycleEventArgs $event)
    {
         // some code
    }
}

Первый параметр — ссылка на объект, в котором произошли события. Второй — это объект события (о нем мы поговорим ниже).


Либо,


  • у нас много логики, которая реагирует на события, мы хотим разнести ее по разным классам
  • EntityListener должен реагировать не только на события конкретного класса (например одинаковое письмо отсылаем по событиям нескольких видов Entity)

можно зарегистрировать обработчики через стандартные сервисы Symfony:


services:
  order.history.status.listener:
        class: AppBundle\EntityListeners\OrderListener
        tags:
            - { name: doctrine.event_listener, event: preUpdate, method: preUpdate }
            - { name: doctrine.event_listener, event: prePersist, method: prePersist }

Параметр event определяет событие, на которое будет вызван данный сервис, method — определяет конкретный метод, внутри сервиса. Т.е. сервис может быть один, но обрабатывать разные события для разных Entity.


В этом случае Listener будет реагировать на события вообще любого Entity и внутри класса нужно будет проверять тип объекта.


class OrderHistoryListener
{
    public function preUpdate(PreUpdateEventArgs $event)
    {
        if ($event->getEntity() instanceof Order) {

        }
    }
}

EntityListener может содержать различные методы (handlers), в зависимости от того, на какое событие мы хотим получить реакцию.


Объект $event уже содержит в себе ссылки на EntityManager и на UnitOfWork. Соответственно уже есть все, чтобы работать с объектами Doctrine. Вы можете вытаскивать необходимые объекты, обновлять и удалять их.


Сложности начинаются, когда вы хотите сделать что-то, не связанное с базой, например отправить письмо. Для этого в EntityListener нужно внедрить зависимости на внешние сервисы.


В первом случае мы создаем запись вида, которая внедрит зависимости в EntityListener


services:
    app.doctrine.listener.order:
        class: AppBundle\EntityListeners\OrderListener
        public: false
        arguments: ["@mailer", "@security.token_storage"]
        tags:
            - { name: "doctrine.orm.entity_listener" }

Во втором, просто добавляем строку с зависимостями


services:
  order.history.status.listener:
        class: AppBundle\EntityListeners\OrderListener
        arguments: ["@mailer", "@security.token_storage"]
        tags:
            - { name: doctrine.event_listener, event: preUpdate, method: preUpdate }
            - { name: doctrine.event_listener, event: prePersist, method: prePersist }

Дальше все как с обычным Symfony-сервисом.


Внутри Listener можно получить проверку на то, изменилось ли поле, а также получить текущее и предыдущее значения.


if ($event->hasChangedField('status_id')) {
    $oldValue = $event->getOldValue('status_id');
    $newValue = $event->getNewValue('status_id');
}

Окончательный вид Entity заказа
/**
 * Order
 *
 * @ORM\Table(name="order")
 * @ORM\EntityListeners({"AppBundle\EntityListeners\OrderListener"})
 * @ORM\HasLifecycleCallbacks()
 */
class Order
{
    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(name="fio", type="string", length=100)
     */
    private $fio;

    /**
     * @ORM\Column(name="last_update_date", type="datetime")
     */
    private $lastUpdateDate;

    /**
     * @ORM\Column(name="create_date", type="datetime")
     */
    private $createDate;

    /**
     * @ORM\Column(name="status_id", type="integer")
     */
    private $statusId;

    // дальше getter/setter методы

    /**
     *  @ORM\PrePersist
     */
    public function setCreateDate()
    {
        $this->createDate = new \DateTime();
    }
    /**
     *  @ORM\PreFlush
     */
    public function setLastUpdateDate()
    {
        $this->lastUpdateDate = new \DateTime();
    }
}

Код OrderListener
class OrderListener {

    private
        $_securityContext = null,
        $_mailer = null;

    public function __construct(\SwiftMailer $mailer, TokenStorage $securityContext)
    {
        $this->_mailer = $mailer;
        $this->_securityContext = $securityContext;
    }

    public function postUpdate(Order $order, LifecycleEventArgs $event)
    {
        $em = $event->getEntityManager();

        if ($event->hasChangedField('status_id')) {

            $status = $em->getRepository('AppBundle:OrderStatus')->find($event->getNewValue('status_id'));

            $history = new OrderHistory();
            $history->setManager($this->_securityContext->getToken()->getUser());
            $history->setStatus($status);
            $history->setOrder($order);

            $em->persist($history);
            $em->flush();

            // код для отправки письма с помощью SwiftMailer

        }
    }
}
Теги:doctrinesymfonyphp
Хабы: PHP Symfony Doctrine ORM
+5
15,6k 71
Комментарии 52

Похожие публикации

Комплексное обучение PHP
19 апреля 202120 000 ₽Loftschool
PHP. Уровень 1. Основы создания сайтов
1 февраля 202115 990 ₽Специалист.ру
Digital-маркетолог
21 января 202175 588 ₽GeekBrains
Data Scientist
21 января 2021126 000 ₽Нетология
Графический дизайнер
22 января 202121 600 ₽GeekBrains

Лучшие публикации за сутки