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