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

в 20:38, , рубрики: doctrine, Doctrine ORM, php, symfony

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

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

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

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

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

Код Entity заказа

/**
 * Order
 *
 * @ORMTable(name="order")
 */
class Order
{
    /**
     * @ORMColumn(name="id", type="integer")
     * @ORMId
     * @ORMGeneratedValue(strategy="AUTO")
     */
    private $id;

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

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

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

    /**
     * @ORMColumn(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 содержит в себе некие события, которые нужно обработать:

/**
* @ORMHasLifecycleCallbacks()
*/

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

/**
*  @ORMPrePersist
*/
public function setCreateDate()
{
     $this->createDate = new DateTime();
}
/**
*  @ORMPreFlush
*/
public function setLastUpdateDate()
{
     $this->lastUpdateDate = new DateTime();
}

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

Текущий вид Entity заказа

/**
 * Order
 *
 * @ORMTable(name="order")
 * @ORMHasLifecycleCallbacks()
 */
class Order
{
    /**
     * @ORMColumn(name="id", type="integer")
     * @ORMId
     * @ORMGeneratedValue(strategy="AUTO")
     */
    private $id;

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

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

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

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

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

    /**
     *  @ORMPrePersist
     */
    public function setCreateDate()
    {
        $this->createDate = new DateTime();
    }
    /**
     *  @ORMPreFlush
     */
    public function setLastUpdateDate()
    {
        $this->lastUpdateDate = new DateTime();
    }
}

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

OrderHistory: Entity записи в истории по заказу

/**
 * OrderHistory
 *
 * @ORMTable(name="order_status_history")
 * @ORMHasLifecycleCallbacks()
 */
class OrderHistory
{
    /**
     * @ORMColumn(name="id", type="integer")
     * @ORMId
     * @ORMGeneratedValue(strategy="AUTO")
     */
    private $id;

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

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

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

    /**
     * @ORMManyToOne(targetEntity="OrderStatus")
     * @ORMJoinColumn(name="status_id", referencedColumnName="id")
     */
    private $status;

    /**
     * @ORMManyToOne(targetEntity="AppBundleEntityManager")
     * @ORMJoinColumn(name="manager_id", referencedColumnName="id")
     */
    private $manager;

    /**
     * @ORMManyToOne(targetEntity="Order", inversedBy="orderHistory")
     * @ORMJoinColumn(name="order_id", referencedColumnName="id")
     */
    private $order;    

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

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

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

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

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

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

/**
* @ORMEntityListeners({"AppBundleEntityListenersOrderListener"})
*/

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

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

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

Либо,

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

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

services:
  order.history.status.listener:
        class: AppBundleEntityListenersOrderListener
        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: AppBundleEntityListenersOrderListener
        public: false
        arguments: ["@mailer", "@security.token_storage"]
        tags:
            - { name: "doctrine.orm.entity_listener" }

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

services:
  order.history.status.listener:
        class: AppBundleEntityListenersOrderListener
        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
 *
 * @ORMTable(name="order")
 * @ORMEntityListeners({"AppBundleEntityListenersOrderListener"})
 * @ORMHasLifecycleCallbacks()
 */
class Order
{
    /**
     * @ORMColumn(name="id", type="integer")
     * @ORMId
     * @ORMGeneratedValue(strategy="AUTO")
     */
    private $id;

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

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

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

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

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

    /**
     *  @ORMPrePersist
     */
    public function setCreateDate()
    {
        $this->createDate = new DateTime();
    }
    /**
     *  @ORMPreFlush
     */
    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

        }
    }
}

Автор: Дмитрий Л.

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js