如何使用学说事件构建活动日志
#php #symfony #doctrine

最近,我一直在研究基于Symfony和Doctrine Orm的应用程序中的用户活动的功能。在Symfony中,已经有几种用于此目的的工具,例如sonata-project/entity-audit-bundle,它为存储实体数据的表创建了一个双表。这无疑是简单更改跟踪的便捷解决方案。但是,它不是很容易定制的。
就我而言,我必须记录特定订单中的变化。我只需要对某些领域而不是整个实体进行更改。
我决定根据学说如何管理实体来创建自己的解决方案。该ORM为我们坚持数据时正在触发的听众和订户提供。使用它们非常简单,允许创建更多可自定义的解决方案。

它如何在学说中起作用

实体可以具有各种类型的字段。从原始类型,日期到各种类型的关系。默认情况下,当对实体进行操作并称为$entityManager->flush()时,学说会贯穿当前在工作单位中管理的所有实体,并查看已更改的内容。在此过程中,正在触发各种事件,我们可以听。这些是:

  • Lifecycle callbacks-这些是实体类中的公共方法,当发生特定事件时称为。为了使这些方法被调用,我们必须在类声明之前添加HasLifecycleCallbacks属性。接下来,我们必须用适当的属性标记特定方法,该属性指定了我们要收听的事件。重要的是,我们可以声明几种处理相同事件的方法。
  #[ORM\Entity]
  #[ORM\HasLifecycleCallbacks]
  class Order
  {
      #[ORM\PrePersist]
      public function doSmth(): void
      {
          //...
      }

      #[ORM\PrePersist]
      public function doSmth2(): void
      {
          //...
      }
  }  
  • Lifecycle listenerslifecycle subscribers是单独的类,其方法可以处理特定事件。当所有实体被保存时,它们被称为。这使我们能够在其中保存的对象之间创建交互。此外,毫无疑问的优势比lifecycle callbacks可以在其中调用其他服务。由于它们被称为所有实体,与先前的解决方案相比,它们的性能较低。 生命周期侦听器和订户之间的区别在于,订户必须实现定义我们订阅的事件的方法,而听众必须在服务配置文件中定义这些事件。一个示例事件订户看起来如下:
    use Doctrine\Common\EventSubscriber;
    use Doctrine\ORM\Event\OnFlushEventArgs;
    use Doctrine\ORM\Event\PreUpdateEventArgs;
    use Doctrine\ORM\Events;

    class EntitySubscriber implements EventSubscriber
    {
        public function getSubscribedEvents(): array
        {
            return [
                Events::preUpdate,
                Events::onFlush,
            ];
        }

        public function preUpdate(PreUpdateEventArgs $args): void
        {
            $args->getEntity();
            $args->getEntityChangeSet();
        }

        public function onFlush(OnFlushEventArgs $args): void
        {
            $entityManager = $args->getEntityManager();
            $unitOfWork = $entityManager->getUnitOfWork();

            $scheduledInsertions = $unitOfWork->getScheduledEntityInsertions();
            $scheduledUpdated = $unitOfWork->getScheduledEntityUpdates();
            $scheduledDeletions = $unitOfWork->getScheduledEntityDeletions();

            $scheduledCollectionUpdates = $unitOfWork->getScheduledCollectionUpdates();
            $scheduledCollectionDeletions = $unitOfWork->getScheduledCollectionDeletions();
        }
    }
  • Entity listeners-它们类似于生命周期的听众,唯一的区别是它们被要求用于特定类的实体。与上述解决方案相比,它使它们更有效。一个示例听众可能看起来像这样:
    #[ORM\Entity]
    #[ORM\EntityListeners([OrderListener::class])]
    class Order
    {
        //...   
    }
    use App\Entity\Order;
    use Doctrine\Persistence\Event\PreUpdateEventArgs;

    class OrderListener
    {
        public function preUpdate(Order $order, PreUpdateEventArgs $event): void
        {
            //do smth
        }
    }  

事件

上面,我描述了我们如何聆听特定事件。很高兴知道我们可以听到哪些事件。其中有很多。但是,有些仅在lifecycle listenerslifecycle subscribers中可用。其中包括:

  • 预科者 - 在对数据库的持续更改之前,当persist方法被调用对实体管理器时。
  • 删除实体之前,当remove方法在实体管理器上调用时。
  • preupdate-发生在更新实体之前。
  • preflush-发生在flush操作的开头。
  • Onflush-在这一点上,对所有托管实体进行的更改均已重新计算。我们可以访问将添加,修改或删除的所有实体。我们还可以访问修改后的集合。此事件不属于lifecycle callback
  • flush -postflush-发生变化后发生。此事件不属于lifecycle callback
  • postupdate,poptremove,pertpersist-同样发生在数据库的更改后。
  • 后负载 - 在从数据库中加载实体后发生

让我们代码

在本节中,我将向您展示在收听事件时在每种方法中可以找到的内容。为此,我将使用Xdebug。

准备实体

出于此博客文章的目的,我准备了三个实体:订单,状态和产品。我想跟踪对订单实体的更改。我在其中添加了一些字段,这些字段是:一种原始类型,日期,与另一个实体和实体集合的关系。看起来如下:

#[ORM\Entity]
class Order
{
    #[ORM\Id]
    #[ORM\GeneratedValue(strategy: 'AUTO')]
    #[ORM\Column(type: 'integer')]
    private ?int $id;

    #[ORM\Column(type: 'integer', nullable: false)]
    private int $totalPrice;

    #[ORM\Column(type: 'date', nullable: true)]
    private ?\DateTimeImmutable $deliveryDate;

    #[ORM\ManyToOne(targetEntity: Status::class)]
    #[ORM\JoinColumn(name: 'status_id', referencedColumnName: 'id')]
    private Status $status;

    #[ORM\ManyToMany(targetEntity: Product::class)]
    #[ORM\JoinTable(name: 'order_products')]
    #[ORM\JoinColumn(name: 'order_id', referencedColumnName: 'id')]
    #[ORM\InverseJoinColumn(name: 'product_id', referencedColumnName: 'id')]
    private Collection $products;
}
#[ORM\Entity]
class Product
{
    #[ORM\Id]
    #[ORM\GeneratedValue(strategy: 'AUTO')]
    #[ORM\Column(type: 'integer')]
    private ?int $id;

    #[ORM\Column(type: 'string', nullable: false)]
    private string $name;

    #[ORM\Column(type: 'integer', nullable: false)]
    private int $price;
}
#[ORM\Entity]
class Status
{
    #[ORM\Id]
    #[ORM\GeneratedValue(strategy: 'AUTO')]
    #[ORM\Column(type: 'integer')]
    private ?int $id;

    #[ORM\Column(type: 'string', nullable: false)]
    private string $name;
}

跟踪变化

更改,添加,删除订单或修改其某些字段时,将触发适当的事件。我将使用实体听众聆听他们。为此,我需要在顺序类中添加属性,以便将适当的侦听器分配给该实体。

#[ORM\Entity]
#[ORM\EntityListeners([OrderListener::class])]
class Order
{
    //...
}

听众类看起来像这样:

use App\Entity\Order;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;

class OrderListener
{
    public function postPersist(Order $order, LifecycleEventArgs $args): void
    {
        //...
    }

    public function preUpdate(Order $order, PreUpdateEventArgs $args): void
    {
        $changeSet = $args->getEntityChangeSet();
    }

    public function postUpdate(Order $order, LifecycleEventArgs $args): void
    {
        $em = $args->getEntityManager();
        $uow = $em->getUnitOfWork();

        $changeSet = $uow->getEntityChangeSet($order);
    }

    public function preRemove(Order $order, LifecycleEventArgs $args): void
    {
        //...
    }

    public function postRemove(Order $order, LifecycleEventArgs $args): void
    {
        //...
    }
}

您可以通过订单进行三件事:添加,修改或删除。

  • 添加新订单时,我可以通过收听postPersist事件来找到我感兴趣的数据。添加的订单将没有任何更改,只有其原始状态。在我的跟踪功能中,我可以在创建订单以及使用哪种产品时记录。
  • 在修改订单时,可以在收听preUpdate事件的方法中可用(或onFlush,但这在实体侦听器中不支持此事件,因为此事件不属于lifecycle callback)。重要的是,$args->getEntityChangeSet()不会返回有关修改的收藏的信息 - 要检查它们的变化,我们需要以不同的方式进行操作,但以后需要更多。如果我们想提取postUpdate方法中的更改,我们应该使用UnitOfWork为此,如上示例所示。
  • 如果我们需要删除订单,并且需要其标识符,那么将该实体的标识符与实体本身以preRemove方法中的任何方式关联是一件好事。为什么?在postRemove方法中,实体将不再具有标识符。

聆听实体的更改时,有两种类型的更改需要注意。日期和关系的变化。

  • 修改了date类型的日期字段后,在数据库中,我们仅存储日期,没有时间(或时间设置为00:00:00)。在修改此类字段时,我们经常将DateTime对象设置为时间。然后,即使日期相同,学说仍然会在该字段上检测到一个更改,因为时间是不同的。值得使用,例如format('Y-m-d')检查日期是否已更改的方法。

  • 实体中的集合通过集合接口表示。创建实体时,通常会用ArrayCollection类的对象初始化该集合。这是存储集合的所有元素并允许对它们执行各种操作的类,例如添加/删除元素,过滤等。 ArrayCollection类的实例,但是PersistentCollectionPersistentCollection允许我们跟踪该集合所做的更改。它的初始状态存储在snapshot属性中,而该集合的当前元素位于collection属性中。该课程还具有isDirty方法,它告诉我们集合中的任何内容是否发生了变化。如果没有在没有修改之前,请使用它不必要地加载整个集合。

正在下单

让我们假设我们有一个产品目录,其中包括智能手机和电视。我们想将订单的状态从New更改为Collecting

$productSmartphone = $productRepository->findOneBy(['name' => 'Smartphone']); //1000
$productTv = $productRepository->findOneBy(['name' => 'Tv']); //1500

$statusNew = $statusRepository->findOneBy(['name' => 'New']);
$statusCollecting = $statusRepository->findOneBy(['name' => 'Collecting']);

添加订单

我们使用智能手机在New状态中添加订单:

$order = new Order();
$order
    ->setStatus($statusNew)
    ->setDeliveryDate(new \DateTimeImmutable())
    ->addProduct($productSmartphone)
    ->calculatePrice();

$this->entityManager->persist($order);
$this->entityManager->flush();

在EntityManager上调用persist方法后,调用了事件侦听器的prePersist方法。正如您在下面的屏幕截图中看到的那样,实体尚未具有标识符。

Image description

调用flush方法后,调用了侦听器中的postPersist方法。订单对象将具有标识符,因为它已经存储在数据库中。代表该集合的类已从ArrayCollection变为PersistentCollection

Image description

修改订单

让我们在订单中添加另一个产品,更改其状态,重新计算价格并设置不同的交货日期。如果我们从数据库中检索了这样的对象,它将有一个时间00:00:00的日期。在这里,我们提供了当前时间的日期,这也将导致学说也检测到该领域的变化。

$order
    ->addProduct($productTv)
    ->setStatus($statusCollecting)
    ->setDeliveryDate(new \DateTimeImmutable())
    ->calculatePrice();

$this->entityManager->flush();

preUpdate方法中,$args参数将具有以下值:

Image description

但是,您无法在此处看到对集合的更改。您可以通过在preUpdatepostUpdate方法中浏览集合本身来获取它们。

Image description

删除订单

最后,我们将删除放置订单。为此,您需要调用以下代码:

$this->entityManager->remove($order);
$this->entityManager->flush($order);

preRemove方法中,实体看起来如下:

Image description

和在postRemove方法中,实体没有标识符:

Image description

其它的办法

此外,我将展示我们如何在实体侦听器中而是在订户中阅读这些更改。如上所述,活动订户还处理包括onFlush在内的其他事件。在其中,我们可以访问所有受任何更改影响的实体。我们可以通过调用以下方法来访问它们:

public function onFlush(OnFlushEventArgs $args): void
 {
     $entityManager = $args->getEntityManager();
     $unitOfWork = $entityManager->getUnitOfWork();

     $scheduledInsertions = $unitOfWork->getScheduledEntityInsertions();
     $scheduledUpdated = $unitOfWork->getScheduledEntityUpdates();
     $scheduledDeletions = $unitOfWork->getScheduledEntityDeletions();

     $scheduledCollectionUpdates = $unitOfWork->getScheduledCollectionUpdates();
     $scheduledCollectionDeletions = $unitOfWork->getScheduledCollectionDeletions();
 }

让我们创建三个订单:

$order1 = new Order();
$order1
    ->setStatus($statusNew)
    ->setDeliveryDate(new \DateTimeImmutable())
    ->addProduct($productSmartphone)
    ->calculatePrice();

$this->entityManager->persist($order1);
$this->entityManager->flush();

$order2 = new Order();
$order2
    ->setStatus($statusNew)
    ->setDeliveryDate(new \DateTimeImmutable())
    ->addProduct($productSmartphone)
    ->calculatePrice();

$this->entityManager->persist($order2);
$this->entityManager->flush();

$order3 = new Order();
$order3
    ->setStatus($statusNew)
    ->setDeliveryDate(new \DateTimeImmutable())
    ->addProduct($productSmartphone)
    ->calculatePrice();

$this->entityManager->persist($order3);

$this->entityManager->remove($order1);

$order2
    ->addProduct($productTv)
    ->calculatePrice();

$this->entityManager->flush();

上面的代码看起来很复杂。让我们重点关注上次flush方法调用后将发生的事情 - 我们将删除$order1,在价格重新计算的情况下向$order2添加新产品,而$order3将首次写入数据库。我们可以从UnitOfWork提取有趣的数据:

Image description

此外,如果我们要预览影响订单的更改(在这种情况下为$order2),我们可以重申$scheduledUpdated数组。

foreach ($scheduledUpdated as $entity) {
    $unitOfWork->getEntityChangeSet($entity);
}

结果将如下:

Image description

概括

学说提供了一种简单的方法来访问有关实体发生变化的信息的简单方法。我们的任务是创建适当的听众并倾听适当的事件。我们处理数据取决于我们。我们可以将它们排在队列中,并通过在为此目的创建的某些事件日志中坚持使用它们来处理另一个过程。我要避免的一件事是直接在这些听众中进行冲洗,因为这至少会导致掉入无限的循环或重新计算多次变化,这不是最佳的。