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