6 дек. 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:
На этом пока все. Спасибо за внимание!