Давайте представим ситуацию: у вас есть заказ в интернет магазине (Entity). Заказ имеет некий статус. При смене статуса заказа необходимо провести кучу сопутствующих действий, например:
- сохранить в заказе дату последнего изменения
- записать в историю по заказу информацию о смене статуса
- отослать письмо / sms клиенту
- вызвать метод API службы доставки / платежной системы / партнера и т.д.
Возникает вопрос как все это правильно организовать с точки зрения программного кода.
Все ниже описанное справедливо для Doctrine 2 и Symfony > 3.1
Если вы не знакомы с событийной моделью Doctrine, то сначала рекомендую ознакомиться с документацией.
Приведу пример простейшего кода для 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 и при каждом ее обновлении. Теперь нет нужды отдельно устанавливать эти даты. Полный список возможных событий можно посмотреть здесь
/**
* 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
*
* @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');
}
/**
* 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();
}
}
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
}
}
}
Автор: Дмитрий Л.