Логгирование изменений сущностей в Symfony 4

6 Dec 2019

Всем привет. Сегоня хотел бы поделиться опытом реализации истории изменения сущностей для symfony/doctrine. Основная идея в том, чтобы логгировать какой пользователь и когда создал, изменил или удалил сущность. И если поменял, то какие поля, какие значения были и какие стали после изменения.

Ранее приходилось реализовывать подобное в проекте на Yii2. Воспользовался готовым решением - есть неплохое расширение yii2-activerecord-history. Пришлось немного доработать вывод полей. В частноти, выводить названия полей в формах, вместо навзаний полей в базе данных. Кроме того выводить названия связанных сущностей вместо их ID.

Хотелось сделать нечто подобное в проекте на Symfony 4. Т.к. речь идет об использовании Symfony в legacy проекте, то хотелось гибкое решение, чтобы можно было записывать имеющуюся таблицу, с катомной структурой. Также хотелось иметь возможность менять логику логгирования изменений в случае необходимости.

Для работы с базой данных в Symfony, как правило, используется Doctrine. Для записи сущности в БД используется EntityManager, методы persist и flush. Persist помечает сущность для сохранения, при этом непосредственно запись в БД происходит при вызове flush.

<?php 
$car = new Car();
$car
    ->setVendor('BMW')
    ->setModel('X5')
    ->setYear(2017);
 
$this->entityManager->persist($car);
$this->entityManager->flush();

Для сохранения истории изменений в базу данных будем использовать специальную таблицу и сущность Doctrine. Предлагается использовать таблицу такого вида: https://dbdiagram.io/d/5d88f707ff5115114db48b01

Для создания сущности можно использовать консольную команду.

php bin/console make:entity

Для ее использования необходимо установить maker-bundle. И, естественно, должна быть установлена сама doctrine.

composer require symfony/orm-pack
composer require --dev symfony/maker-bundle

Для реализации истории изменений будем перехватывать вызов flush. Для этого реализуем EntitySubscriber, который будет обрабатывать события, связанные с entity. Зарегистрируем сервис в конфигурационном файле config/services.yaml

services:
    # ...
    app.entity_subscriber:
        class: App\EventSubscriber\EntitySubscriber
        tags:
            - { name: doctrine.event_subscriber, connection: default }
    ...

И создадим класс src/EventSubscriber/EntitySubscriber.php. В нем c помощью метода getSubscribedEvents подпишемся на события onFlush, postFlush. А также реализуем обработчики этих событий.

<?php
 
namespace App\EventSubscriber;
 
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
 
class EntitySubscriber implements EventSubscriber
{
    public function onFlush(OnFlushEventArgs $args)
    {
        //@todo implement onFlush handling logic
    }
 
    public function postFlush(PostFlushEventArgs $args)
    {
        //@todo implement postFlush handling logic
    }
 
    /**
     * @return array
     */
    public function getSubscribedEvents()
    {
        return [
            'onFlush',
            'postFlush'
        ];
    }
}

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

В обоих обработчиках нам понадобится EntityManager. Он нам понадобится для получения дополнительных сервисов и сохранения истории изменений. Получить его можно из аргументов:

<?php
public function postFlush(PostFlushEventArgs $args)
{
    $entityManager = $args->getEntityManager();
}

Для получения списка измененных сущностей а также непосредственно изменений в рамках одной сущности будем использовать Doctrine\ORM\UnitOfWork.

<?php
$entityManager = $args->getEntityManager();
$unitOfWork = $entityManager->getUnitOfWork();
$updatedEntities = $unitOfWork->getScheduledEntityUpdates();
$deletedEntities = $unitOfWork->getScheduledEntityDeletions();
$this->insertedEntities = $unitOfWork->getScheduledEntityInsertions();
 
$changeSet = $unitOfWork->getEntityChangeSet($updatedEntity);

Для получения названий таблиц и полей базы данных будем испошльзовать Doctrine\ORM\Mapping\ClassMetadata.

<?php
$entityManager = $args->getEntityManager();
$unitOfWork = $entityManager->getUnitOfWork();
$entityClassName = get_class($updatedEntity);
$metaData = $entityManager->getClassMetadata($entityClassName);
 
// get table name
$tableName = $metaData->getTableName();
 
$changeSet = $unitOfWork->getEntityChangeSet($updatedEntity);
foreach ($changeSet as $fieldName => $changes) {
    // get column name
    $columnName = $metaData->getFieldMapping($fieldName)['columnName'];
}

Для получения ID сущности реализуем отдельный метод, т.к. поле primary key может иметь название отличное от ID.

<?php
 
private function getEntityId(UnitOfWork $unitOfWork, $entity)
{
    $identifier = $unitOfWork->getEntityIdentifier($entity);
    $idFieldName = array_key_first($identifier);
    $entityId = $identifier[$idFieldName];
 
    return $entityId;
}

Для сохранения записи об изменении в таблицу предлагаю реализоват отдельный сервис и подключать его через Dependency Injection.

В каждом методе создается сущность DbChange, которая возвращается в EntitySubscriber для дальнейшей обработки. При желании, сервис можно переделать для записи изменений в другое хранилище, например в специальный сервис для хранения и обработки логов.

<?php
 
namespace App\Service;
 
use App\Entity\DbChange;
 
class LogChangesService
{
    private const ACTION_UPDATE = 'update';
    private const ACTION_INSERT = 'insert';
    private const ACTION_DELETE = 'delete';
    private const DEFAULT_USER_ID = 1;
 
    public function logEntityUpdate(
        string $tableName,
        string $entityId,
        string $columnName,
        string $oldValue,
        string $newValue
    ): DbChange {
        $dbChange = new DbChange();
        $dbChange
            ->setCreatedAt(new \DateTime())
            ->setUserId($this->getAuthorizedUserId())
            ->setTableName($tableName)
            ->setEntityId($entityId)
            ->setAction(self::ACTION_UPDATE)
            ->setFieldName($columnName)
            ->setOldValue($oldValue)
            ->setNewValue($newValue);
 
        return $dbChange;
    }
 
    public function logEntityInsert(string $tableName, string $entityId): DbChange
    {
        // @TODO implement logic for logEntityInsert
    }
 
    public function logEntityDelete(string $tableName, string $entityId): DbChange
    {
        // @TODO implement logic for logEntityDelete
    }
 
    /**
     * TODO use real user id
     */
    private function getAuthorizedUserId(): string
    {
        return (string) self::DEFAULT_USER_ID;
    }
}

Для корректного сохранения сущности DbChange важно вызвать метод computeChangeSet.

<?php 
 
$logChange = $this->logChangesService->logEntityDelete(
    $tableName,
    $entityId
);
 
$entityManager->persist($logChange);
 
$logMetadata = $entityManager->getClassMetadata(DbChange::class);
$unitOfWork->computeChangeSet($logMetadata, $logChange);

Также важно не отслеживать изменения для самой сущности изменений, иначе можно получить бесконечный цикл при сохранинии данных.

<?php
 
foreach ($this->insertedEntities as $insertedEntity) {
    if ($insertedEntity instanceof DbChange) {
        continue;
    }
}

В итоге, с учетом всей логики, получился такой EntitySubscriber.

В результате имеем относительно простое решение, использующее стандартные средства Symfony/Doctrine. Которое также нетрудно донастроить под свои нужды. Пример доступен на github: https://github.com/antonshell/symfony-entity-history-example.

В процессе разработки использовал некоторые вопросы/ответы на stack overflow и документацию Doctrine:

На этом пока все. Спасибо за внимание!

Теги: Symfony, PHP, Doctrine,