Доменные сущности и Doctrine ORM
В прошлый раз мы вручную изготовили работающий репозиторий для сохранения доменных сущностей, чтобы более-менее узнать, как работают изнутри различные ORM. В этот раз на основе прошлого опыта рассмотрим готовую библиотеку Doctrine ORM и попробуем интегрировать её в наш проект на Yii2.
Типы репозиториев
Назначение любого репозитория (хранилища) – поддержать сохранность сущности на протяжении всей жизни приложения (или от сеанса к сеансу). Если пользователь регистрируется на сайте в понедельник, изменяет свой профиль во вторник, пополняет баланс в среду и так проводит на сайте целый год, то
UserRepository
как раз и обязан целый год обеспечить надёжную сохранность и доступность этого прользователя коду сервисов и контроллеров приложения. А как он будет это делать (хранить в MySQL, в Redis или ещё как) – это уже его личное дело.
И хранилища бывают разные.
В простейшем случае мы можем его сделать в виде коллекции с приватным массивом без сохранения в какую-либо базу данных:
class UserCollection { private $items = []; public function get($id): User { if (!isset($this->items[$id])) { throw new NotFoundException('User not found.'); } return $this->items[$id]; } public function add(User $user): void { $this->items[$user->getId()] = $user; } public function remove(User $user): void { if (isset($this->items[$user->getId()])) { unset($this->items[$user->getId()]); } } }
И теперь мы работаем с этой коллекцией:
$collection = new UserCollection(); $user1 = new User(5, 'Вася'); $collection->add($user1); ... $user2 = $collection->get(5); $user2->rename('Петя'); $user2->getName(); // Петя ... $user3 = $collection->get(5); $user3->getName(); // Петя $user1->getName(); // Петя
Почему при переименовывании $user2
у нас вместе с ним переименовываются $user3
и $user1
?
Вспомним, как примитивы и объекты хранятся в памяти. В случае со строками в переменных хранятся сами значения. И если мы присвоим одну переменную другой, то скопируется значение, а сами переменные останутся независимыми:
$name1 = 'Вася'; $name2 = $name1; $name2 = 'Петя'; echo $name1; // Вася echo $name2; // Петя
В случае с объектами в переменной хранится не сам объект, а указатель на него. И когда мы в переменную $user2
присваиваем значение $user1
, то копируется указатель. В итоге после присваивания обе переменные ведут на один и тот же объект:
$user1 = new User(5, 'Вася'); $user2 = $user1; $user2->rename('Петя'); echo $user1->getName(); // Петя echo $user2->getName(); // Петя var_dump($user1 === $user2); // bool(true)
Объекты создаются всего один раз, и при присваивании из переменной в переменную не копируются. Чтобы именно создать копию объекта нужно явно использовать клонирование $user2 = clone $user1
.
В итоге в нашей системе с UserCollection
во всех переменных $user1
, $user2
, $user3
и в приватном массиве $items
оказывается один и тот же объект.
В Collection-like репозиториях (похожих на коллекцию) для управления имеются методы
add
иremove
. Методupdate
отсутствует, так как репозиторий хранит ссылки на объекты внутри себя (например, в приватной переменной) и может сам следить за их изменениями.
Например, в UserCollection
можно добавить метод flush()
и вызывать его в конце работы скрипта, чтобы он прошёл циклом по массиву $items
, просчитал изменения и сохранил всё в БД.
Но вместо UserCollection
мы можем создать UserPersister
:
class UserPersister { private $db; public function get($id): User { if (!$row = $this->db->select('users', ['id' => $user->getId()])) { throw new NotFoundException('User not found.'); } return $this->hydrator->hydrate(User::class, [ 'id' => $row['id'], 'name' => $row['name'], ]); } public function add(User $user): void { $this->db->insert('users', [ 'id' => $user->getId(), 'name' => $user->getName(), ]); } public function save(User $user): void { $this->db->update('users', [ 'name' => $user->getName(), ], ['id' => $user->getId()]); } public function remove(User $user): void { $this->db->delete('users', [ 'id' => $user->getId(), ]); } }
Примерно таким мы и сделали наш класс EmployeeRepository
на прошлом уроке.
Этот класс немного другой. И здесь уже есть метод save
.
В Persistence-like репозиториях (похожих на БД-хранилище) помимо методов
add
иremove
имеется и методsave
, который нужно вызывать вручную для сохранения изменений.
Всё это крайние случаи. Возможны и гибридные варианты. Например, в репозитории можно сделать только метод save
, который делает INSERT
или UPDATE
запрос в зависимости от того, была ли до этого извлечена эта запись или нет. Или просто сделать кеширование в приватный массив в методе get
, чтобы не лазить два раза в БД за одной и той же сущностью, но изменения записывать вручную через save
.
Поэтому если видите, что у какого-то репозитория есть только методы
add
иremove
, и если он ведёт себя так, как будто сохраняет всё в приватный массив (изget
возвращает один и тот же объект), то это Collection-like репозиторий (реализованный «по типу коллекции») с автоматическим отслеживанием изменений.Если же есть полный набор методов
add
,update
иremove
, и он снаружи ведёт себя так, как будто действительно лезет в БД, то это Persistence-like репозиторий (реализованный «по типу хранилища»).
Разобравшись с этими двумя типами перейдём к ORM.
Суть Doctrine ORM
Doctrine ORM – это Collection-like система, эмулирующая коллекции при работе с сущностями. Рассмотрим, за счёт чего она это делает.
Карта соответствия (Identity Map)
Identity Map – это вещь, которая сохраняет внутри себя все найденные сущности под своими идентификаторами для однократной загрузки объекта. Некий кеш. По сути это тот же приватный массив $items
, просто переименованный в identityMap.
Мы бы и в наш прошлый репозиторий EmployeeRepository
могли добавить такой массив, и при поиске методом get($id)
сохранять туда найденные сущности. И аналогично добавлять туда элементы в методе add($employee)
и удалять в remove($employee)
:
private $identityMap = []; public function get(Id $id): Employee { $key = $id->getId(); if (isset($this->identityMap[$key]) && $existing = $this->identityMap[$key]) { return $existing; } $row = $this->db->createCommand('SELECT ...')->...->queryOne(); ... $employee = $this->hydrator->hydrate(Employee::class, [...]); $this->identityMap[$key] = $employee; return $employee; } public function add($employee): void { ... $this->identityMap[$key] = $employee; } public function remove($employee): void { ... unset($this->identityMap[$key]); }
Но чтобы не повторять код во всех репозиториях можно вынести эту вещь в отдельный объект:
class IdentityMap { private $entities = []; function add($entity): void { $class = get_class($entity); $key = $this->generateKey($entity->getId()); $this->entities[$class][$key] = $entity; } function get($class, Id $id) { $key = $this->generateKey($entity->getId()); return $this->entities[$class][$key] ?? null; } function remove($entity): void { $class = get_class($entity); $key = $this->generateKey($entity->getId()); if (isset($this->entities[$class][$key]) { unset($this->entities[$class][$key]); } } public function clear(): void { $this->entities = []; } }
и внедрять его во все репозитории:
public function get(Id $id): Employee { if ($existing = $this->identityMap->get(Employee::class, $id)) { return $existing; } $row = $this->db->createCommand('SELECT ...')->...->queryOne(); ... $employee = $this->hydrator->hydrate(Employee::class, [...]); $this->identityMap->add($employee); return $employee; }
Или (для большего удобства работы в репозитории) добавили бы метод ensure
:
class IdentityMap { private $entities = []; public function ensure($class, Id $id, callable $callback) { $key = $this->generateKey($id); if (!isset($this->entities[$class][$key])) { $this->entities[$class][$key] = $callback(); } return $this->entities[$class][$key]; } ... }
который бы либо возвращал готовый объект, либо дёргал переданную функцию. И вместо императивного подхода вызывали бы его функционально:
public function get(Id $id): Employee { return $this->identityMap->ensure(Employee::class, $id, function () use ($id) { $row = $this->db->createCommand('SELECT ...')->...->queryOne(); ... return $this->hydrator->hydrate(Employee::class, [...]); }; }
Теперь если запросим сущность в первый раз:
$employee = $repo->get(5);
то репозиторий сделает запрос в БД и создаст сущность гидратором. А при всех последующих запросах:
$employee1 = $repo->get(5); $employee2 = $repo->get(5); $employee3 = $repo->get(5);
будет возвращать эту закешированную сущность из массива, больше не обращаясь в БД.
Как уже сказали, это работает как простой кеш в приватном массиве. Но это может быть и частью другой глобальной идеи.
Единица работы (Unit of Work)
В простейшем случае у нас в EmployeeRepository
работа производится так, что сразу выполняются SQL-запросы:
$entity1 = $repo->get(5); // SELECT $entity1->archive($date); $repo->save($entity1); // UPDATE $repo->add($entity2); // INSERT $repo->add($entity3); // INSERT $repo->remove($entity4); // DELETE
Каждое обращение к репозиторию напрямую транслируется в БД.
Но есть альтернативный вариант с использованием паттерна «Единица работы» (Unit of Work) в совокупности с Identity Map:
$entity1 = $repo->get(5); // if (isset($this->identityMap[$id]) return $this->identityMap[$id]; // SELECT // $this->identityMap[$id] = $entity; // $this->originalEntityData[$id] = $data; $entity1->archive($date); $uow->persist($entity2); // $this->identityMap[$id] = $entity2; $uow->persist($entity3); // $this->identityMap[$id] = $entity3; $uow->remove($entity4); // $this->entityStates[$id] = self::STATE_REMOVED; $uow->commit(); // $this->computeChangeSets() // foreach ($this->entityInsertions as $entity) { INSERT } // foreach ($this->entityUpdates as $entity) { UPDATE } // foreach ($this->entityDeletions as $entity) { DELETE }
В реальности же вместо идентификатора $id
используется функция spl_object_hash
:
$oid = spl_object_hash($entity)
так как в случае использования автоинкрементного ключа поле $id
для добавляемых сущностей изначально может оказаться пустым.
Во втором варианте можно заметить несколько отличий от первого:
- При поиске сущности она дополнительно регистрируется в массиве
identityMap
, а вoriginalEntityData
помещается массив оригинальных значений её полей. - При вызове
persist
(аналог методаadd
) вместо запросов к БД сущность добавляется в массивidentityMap
. - При вызове
remove
вместо запросов к БД сущность просто помечается удаляемой. - При вызове
commit
происходит обход и перерасчёт всех сущностей изidentityMap
и их связей внутри методаcomputeChangeSets
. Все новые объекты складываются в массивentityInsertions
, старые с изменениями относительноoriginalEntityData
помещаются вentityUpdates
, а все удаляемые – вentityDeletions
. И только теперь производятся все запросы в БД. - Отсутствует метод
update
для обновления сущности, так как с такой глобальной системой слежения за изменениями он не нужен.
Стоит отметить, что очень редко мы работаем напрямую с UoW и другими компонентами. Для доступа ко всем составным частям в Doctrine ORM мы используем высокоуровневый объект класса EntityManager
:
$em->persist($entity1); $em->remove($entity2); $em->flush();
И уже он в своих методах перекидывает запросы к $this->unitOfWork
.
В итоге при работе с продвинутым компонентом UnitOfWork нам не нужно вручную беспокоиться о сохранении сущностей и связей. Просто создавайте новые объекты, изменяйте старые и как угодно связывайте их друг с другом. При этом только добавляйте новые сущности в систему контроля UoW. И потом попросите UoW оценить изменения и одним махом (единицей работы) записать их в БД.
Встроенные репозитории
Для выборок сущностей из БД в Doctrine предусмотрен встроенный класс репозитория EntityRepository
с базовыми методами для запросов. Мы можем получить репозиторий для любой нашей сущности из того самого великого и могучего EntityManager
и работать уже с ним:
$repository = $em->getRepository('app\entities\Entity'); $entity1 = $repository->find($id); $entity2 = $repository->findOneBy(['email' => $email]);
При желании можно отнаследоваться от базового класса EntityRepository
и добавить свои методы. Например, для получения последних опубликованных постов для виджета:
class PostRepository extends EntityRepository { public function getLatestPosts($limit): array { return $this->findBy(['published' => true], ['createDate' => 'DESC'], $limit); } }
После этого достаточно в настройках сущности Post
указать использование данного репозитория PostRepository
и работать уже с ним:
$posts = $em->getRepository('App\Entity\Post')->getLatestPosts(10);
А есть ли в нём методы сохранения?
В Doctrine за сохранение и удаление сущностей целиком отвечает UnitOfWork, поэтому её репозитории содержат только поисковые методы.
Внутри для восстановления сущностей из базы и заполнения приватных полей используется собственный гидратор. Он сложнее того, который мы сами изобретали в прошлой части.
С сутью более-менее разобрались. Теперь переходим к использованию.
Установка
Doctrine ORM по умолчанию используется во фреймворке Symfony. Но Symfony не является монолитным фреймворком вроде Yii, и для использования его частей не нужно скачивать и устанавливать весь фреймворк.
Философия Symfony подразумевает независимый компонентный подход. Весь фреймворк на самом деле представляет из себя набор независимых компонентов. Так и сама Doctrine – это не часть Symfony, а отдельная независимая библиотека, которую можно использовать в любом PHP-проекте.
Поэтому спокойно можем доустановить её к нашему фреймворку через Composer:
composer require doctrine/orm
Далее её необходимо сконфигурировать.
Конфигурирование
Во время работы Doctrine иногда создаёт вспомогательные классы-обёртки для сущностей. Это нужно учесть. Создадим объект конфигурации и установим ему параметры сохранения прокси-объектов и системные кеши:
use Doctrine\ORM\Configuration; use Doctrine\Common\Cache\ArrayCache; use Doctrine\Common\Cache\FilesystemCache; $config = new Configuration(); $config->setProxyDir(Yii::getAlias('@runtime/doctrine/proxy')); $config->setProxyNamespace('Proxies'); $config->setAutoGenerateProxyClasses(!YII_ENV_PROD); if (YII_ENV_PROD) { $cache = new FilesystemCache(Yii::getAlias('@runtime/doctrine/cache')); } else { $cache = new ArrayCache(); } $config->setMetadataCacheImpl($cache); $config->setQueryCacheImpl($cache); ...
В production-окружении мы указали использование файлового кеширования и отключили перегенерацию прокси-классов.
Вместо этого мы могли бы использовать статические фабрики из класса Setup
как в некоторых примерах из документации.
Далее нам нужно указать, где будут хранится настройки маппинга для наших сущностей. Их можно указывать в аннотациях самих классов, а также в отдельных файлах XML или YAML. Мы будем использовать упрощённый вариант SimplifiedYamlDriver
и для поддержки чтения YAML-файлов доустановим ещё один пакет:
composer require symfony/yaml
Теперь можем указать использование SimplifiedYamlDriver
, которому нужно будет вдальнейшем передать список путей для загрузки конфигураций:
use Doctrine\ORM\Mapping\Driver\SimplifiedYamlDriver; ... $driver = new SimplifiedYamlDriver([ Yii::getAlias('@app/repositories/doctrine/mapping/Employee') => 'app\entities\Employee' ]); $config->setMetadataDriverImpl($driver);
Далее нам нужно указать настройки подключения и создать сам результирующий объект класса EntityManager
:
$config->setMetadataDriverImpl(new SimplifiedYamlDriver([ ... ])); $dbParams = [ 'driver' => 'pdo_mysql', 'user' => 'root', 'password' => '', 'host' => 'localhost', 'dbname' => 'aggregates', 'driverOptions' => [ PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8', ] ]; $em = EntityManager::create($dbParams, $config);
Параметры подключения $dbParams
можно, например, сохранить в конфигурации приложения и подставлять оттуда:
$em = EntityManager::create(Yii::$app->params['doctrine'], $config);
и Doctrine создаст новое PDO-подключение по этим настройкам.
Но в нашем фреймворке нет смысла создавать ещё одно PDO-подключение, так как у нас уже есть встроенный инстанс Yii::$app->db->pdo
. Можно ли использовать его? Да, пакет Doctrine DBAL (Database Access Layer), используемый пакетом Doctrine ORM, вместо передачи параметров позволяет передать ему уже готовый объект PDO
в поле pdo
. поэтому мы можем использовать следующий код:
$dbParams = [ 'pdo' => Yii::$app->db->pdo, ]; $em = EntityManager::create($dbParams, $config);
На этом первоначальная конфигурация завершена. Осталось реализовать одну специфическую вещь.
Префиксы таблиц
Yii Framework позволяет в настройках подключения указать параметр tablePrefix
:
return [ 'class' => 'yii\db\Connection', 'dsn' => 'mysql:host=localhost;dbname=aggregates', 'username' => 'root', 'password' => 'root', 'tablePrefix' => 'app_', 'charset' => 'utf8', ];
С ним мы можем указывать имя таблицы с символом %
:
$db->createCommand('SELECT * FROM {{%posts}} LIMIT 5')->queryAll();
и фреймворк будет подменять этот символ на указанный префикс в результирующих SQL-запросах:
SELECT * FROM `app_posts` LIMIT 5
Использование префиксов удобно в случае, если вы интегрируете на сайт какой-либо сторонний форум. С помощью разных префиксов становится возможно в одну БД поместить и свою таблицу app_users
, и таблицу форума forum_users
(хотя в целях безопасности лучше эти базы разделять).
Также как компромиссный вариант префиксы помогут, если ваш хостинг на текущем тарифе разрешает создать только одну базу данных.
Это редко кому нужно, поэтому в ядре Doctrine встроенной поддержки этого нет. Если же собираемся использовать префиксы, то можем воспользоваться этим рецептом и реализовать свой подписчик на событие Events::loadClassMetadata
, который будет налету подменять имена таблиц при загрузке метаинформации:
namespace app\doctrine\listeners; use Doctrine\Common\EventSubscriber; use Doctrine\ORM\Event\LoadClassMetadataEventArgs; use Doctrine\ORM\Events; use Doctrine\ORM\Mapping\ClassMetadata; class TablePrefixSubscriber implements EventSubscriber { protected $prefix = ''; public function __construct($prefix) { $this->prefix = (string) $prefix; } public function getSubscribedEvents(): array { return array( Events::loadClassMetadata, ); } public function loadClassMetadata(LoadClassMetadataEventArgs $eventArgs): void { $classMetadata = $eventArgs->getClassMetadata(); if (!$classMetadata->isInheritanceTypeSingleTable() || $classMetadata->getName() === $classMetadata->rootEntityName) { $classMetadata->setPrimaryTable([ 'name' => $this->prefix . $classMetadata->getTableName(), ]); } foreach ($classMetadata->getAssociationMappings() as $fieldName => $mapping) { if ($mapping['type'] === ClassMetadata::MANY_TO_MANY && $mapping['isOwningSide']) { $mappedTableName = $mapping['joinTable']['name']; $classMetadata->associationMappings[$fieldName]['joinTable']['name'] = $this->prefix . $mappedTableName; } } } }
И теперь создаём экземпляр подписчика с указанием префикса и регистрируем его внутри EventManager
:
use Doctrine\Common\EventManager; ... $evm = new EventManager(); $evm->addEventSubscriber(new TablePrefixSubscriber(Yii::$app->db->tablePrefix)); $em = EntityManager::create(['pdo' => Yii::$app->db->pdo], $config, $evm);
Интеграция в DIC
Для использования этого кода во фреймворке вставляем его в конфигурацию контейнера внедрения зависимостей:
namespace app\bootstrap; use app\dispatchers\EventDispatcher; use app\dispatchers\DummyEventDispatcher; use app\doctrine\listeners\TablePrefixSubscriber; use Doctrine\Common\Cache\ArrayCache; use Doctrine\Common\Cache\FilesystemCache; use Doctrine\Common\EventManager; use Doctrine\ORM\Configuration; use Doctrine\ORM\EntityManager; use Doctrine\ORM\Mapping\Driver\SimplifiedYamlDriver; use Yii; use yii\base\BootstrapInterface; class ContainerBootstrap implements BootstrapInterface { public function bootstrap($app) { $container = Yii::$container; $container->setSingleton(EventDispatcher::class, DummyEventDispatcher::class); $container->setSingleton(EntityManager::class, function () use ($app) { $config = new Configuration(); $config->setProxyDir(Yii::getAlias('@runtime/doctrine/proxy')); $config->setProxyNamespace('Proxies'); $config->setAutoGenerateProxyClasses(!YII_ENV_PROD); $cache = YII_ENV_PROD ? new FilesystemCache(Yii::getAlias('@runtime/doctrine/cache')) : new ArrayCache(); $config->setMetadataCacheImpl($cache); $config->setQueryCacheImpl($cache); $config->setMetadataDriverImpl(new SimplifiedYamlDriver([ Yii::getAlias('@app/repositories/doctrine/mapping/Employee') => 'app\entities\Employee' ])); $evm = new EventManager(); $evm->addEventSubscriber(new TablePrefixSubscriber($app->db->tablePrefix)); return EntityManager::create(['pdo' => $app->db->pdo], $config, $evm); }); } }
А что делать, если нужно будет логировать SQL-запросы? Для этого можно сделать логер вроде этого и зарегистрировать через вызов:
$config->setSQLLogger(new SQLLogger(Yii::getLogger()));
Можно оставить этот процедурно-императивный код прямо в анонимной функции. Но со временем у нас появятся новые подписчики (и настройки) и код разрастётся. Для удобства можно вынести его в отдельный построитель:
namespace app\doctrine; use Doctrine\Common\Cache\CacheProvider; use Doctrine\Common\EventManager; use Doctrine\Common\Persistence\Mapping\Driver\MappingDriver; use Doctrine\DBAL\Types\Type; use Doctrine\ORM\Configuration; use Doctrine\ORM\EntityManager; class EntityManagerBuilder { private $proxyNamespace; private $proxyDir; private $proxyAutoGenerate; private $cacheProvider; private $mappingDriver; private $subscribers = []; private $listeners = []; private $types = []; public function withProxyDir($dir, $namespace, $autoGenerate): self { $this->proxyDir = $dir; $this->proxyNamespace = $namespace; $this->proxyAutoGenerate = $autoGenerate; return $this; } public function withCache(CacheProvider $cache): self { $this->cacheProvider = $cache; return $this; } public function withMapping(MappingDriver $driver): self { $this->mappingDriver = $driver; return $this; } public function withSubscribers(array $subscribers): self { $this->subscribers = $subscribers; return $this; } public function withListeners(array $listeners): self { $this->listeners = $listeners; return $this; } public function withTypes(array $types): self { $this->types = $types; return $this; } public function build($params): EntityManager { $this->checkParameters(); $config = new Configuration(); $config->setProxyDir($this->proxyDir); $config->setProxyNamespace($this->proxyNamespace); $config->setAutoGenerateProxyClasses($this->proxyAutoGenerate); $config->setMetadataDriverImpl($this->mappingDriver); if (!$this->cacheProvider) { $config->setMetadataCacheImpl($this->cacheProvider); $config->setQueryCacheImpl($this->cacheProvider); } $evm = new EventManager(); foreach ($this->subscribers as $subscriber) { $evm->addEventSubscriber($subscriber); } foreach ($this->listeners as $name => $listener) { $evm->addEventListener($name, $listener); } foreach ($this->types as $name => $type) { if (Type::hasType($name)) { Type::overrideType($name, $type); } else { Type::addType($name, $type); } } return EntityManager::create($params, $config, $evm); } private function checkParameters(): void { if (empty($this->proxyDir) || empty($this->proxyNamespace)) { throw new \InvalidArgumentException('Specify proxy settings.'); } if (!$this->mappingDriver) { throw new \InvalidArgumentException('Specify mapping driver.'); } } }
и этим разгрузить код настройки контейнера:
class ContainerBootstrap implements BootstrapInterface { public function bootstrap($app) { $container = Yii::$container; $container->setSingleton(EventDispatcher::class, DummyEventDispatcher::class); $container->setSingleton(EntityManager::class, function () use ($app) { return (new EntityManagerBuilder()) ->withProxyDir(Yii::getAlias('@runtime/doctrine/proxy'), 'Proxies', !YII_ENV_PROD) ->withCache(YII_ENV_PROD ? new FilesystemCache(Yii::getAlias('@runtime/doctrine/cache')) : new ArrayCache()) ->withMapping(new SimplifiedYamlDriver([ Yii::getAlias('@app/repositories/doctrine/mapping/Employee') => 'app\entities\Employee' ])) ->withSubscribers([ new TablePrefixSubscriber($app->db->tablePrefix), ]) ->build(['pdo' => $app->db->pdo]); }); } }
На этом подключение завершено.
Интеграция сущностей
С чего начинается настройка маппинга сущностей?
Создадим ту самую папку repositories/doctrine/mapping/Employee
для хранения YAML-файлов.
Создадим в этой папке файл Employee.orm.yml
. Если бы у нас была простая сущность Employee
с автоинкрементным первичным ключом и с датой формата DateTimeImmutable
, то файл изначально мог быть таким:
app\entities\Employee\Employee: type: entity table: doctrine_employees id: id: type: integer generator: strategy: AUTO fields: createDate: column: create_date type: datetime_immutable nullable: false currentStatus: column: current_status type: string length: 255 nullable: false
Мы здесь указали, как поля нашего объекта должны соотноситься с колонками в таблице и в каком формате они должны поступать из базы в сущность.
Также мы здесь указали дополнительные данные вроде length
для полей типа VARCHAR
и nullable
для простановки NOT NULL
. Doctrine внутри себя имеет набор команд для генерации таблиц. Например, в Symfony мы могли бы запустить команду:
php bin/console doctrine:schema:update --force
и она бы автоматически сгенерировала таблицу doctrine_employees
с указанными полями. В нашем случае автогенерация не нужна, так как мы будем создавать все таблицы с помощью своих миграций. Поэтому лишнюю информацию можно убрать, оставив только параметр column
(хотя он нужен только если имя поля в объекте не совпадает с именем поля в базе, как у нас) и type
:
app\entities\Employee\Employee: type: entity table: doctrine_employees id: id: type: integer generator: strategy: AUTO fields: createDate: column: create_date type: datetime_immutable currentStatus: column: current_status type: string
Продолжим модифицировать наш пример.
Идентификатор-объект
Как мы договорились в первой статье про сущности, мы не будем использовать автоинкрементный ключ. И, помимо этого, его значение будем хранить не просто строкой или числом, а используя объект-значение Id
namespace app\entities\Employee; class Id { }
Компонент UnitOfWork в своих методах tryGetById
и addToIdentityMap
расчитывает строковый хеш по первичному ключу:
$idHash = implode(' ', (array) $id);
Если сюда в качестве $id
сюда попадёт объект, то PHP ругнётся с ошибкой «Object of class Id could not be converted to string». Чтобы иметь возможность использовать объект как первичный ключ в этот объект нужно добавить магический метод __toString()
. Добавим его базовому классу Id
:
class Id { ... public function __toString(): string { return $this->id; } }
Далее нужно как-то поменять этот автоинкрементный числовой тип integer
:
app\entities\Employee\Employee: type: entity table: doctrine_employees id: id: type: integer generator: strategy: AUTO fields: ...
на свой собственный.
Мощь маппинга Doctrine проявляется в том, что эти самые типы integer
, string
, datetime
и подобные – это ни что иное как классы типов пакета Doctrine DBAL. Это классы, которые наследуются от базового класса Type
и содержат методы конвертации. Например, тип boolean
представлен классом BooleanType
:
namespace Doctrine\DBAL\Types; use Doctrine\DBAL\Platforms\AbstractPlatform; class BooleanType extends Type { ... public function convertToDatabaseValue($value, AbstractPlatform $platform) { return ...; } public function convertToPHPValue($value, AbstractPlatform $platform) { return ...; } ... public function getName() { return Type::BOOLEAN; } }
Да-да, в Doctrine все типы полей сделаны классами и можно перепрограммировать каждую колонку.
Если нам нужно сделать собственное преобразование значений из объекта в базу данных и из базы в объект, то мы по аналогии создаём свой тип. В случае Id
можно унаследоваться от готового GuidType
(который уже генерирует поле CHAR(36)
) и переопределить его имя и методы конвертации convertToDatabaseValue
и convertToPHPValue
, чтобы они значение из БД оборачивали в объект класса id
:
namespace app\repositories\doctrine\types\Employee; use app\entities\Employee\Id; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Types\GuidType; class IdType extends GuidType { const NAME = 'employee_id'; public function convertToDatabaseValue($value, AbstractPlatform $platform) { return $value->getId(); } public function convertToPHPValue($value, AbstractPlatform $platform) { return new Id($value); } public function getName() { return self::NAME; } }
Теперь нужно зарегистрировать этот тип в Doctrine:
use Doctrine\DBAL\Types\Type; ... $container->setSingleton(EntityManager::class, function () use ($app) { $config = new Configuration(); ... Type::addType(IdType::NAME, IdType::class); ... return EntityManager::create(['pdo' => $app->db->pdo], $config, $evm); });
Увы, но здесь мы поймали статическую глобальную переменную
Type::$_typesMap
, в которую записывает элементы статический методType::addType
. Список типов в этом массиве становится глобальным на всю систему. И если запустим тесты, которые снова и снова в каждом методе будут создавать новыйEntityManager
, то поймаем ошибку «Type already exists», так как добавленный тип из предыдущего теста будет мешать добавлению этого же типа в следующем. Поэтому вместо строки:Type::addType(IdType::NAME, IdType::class);
придётся всегда производить проверку на существование элемента в статическом поле:
if (Type::hasType(IdType::NAME)) { Type::overrideType(IdType::NAME, IdType::class); } else { Type::addType(IdType::NAME, IdType::class); }
И если вдруг сами захотите создать одновременно два менеджера с разными наборами, то придётся костылить. Синглтоны – зло.
В коде с нашим EntityManagerBuilder
просто используем его метод withTypes
:
$container->setSingleton(EntityManager::class, function (Container $container) use ($app) { return (new EntityManagerBuilder()) ->withProxyDir(Yii::getAlias('@runtime/doctrine/proxy'), 'Proxies', !YII_ENV_PROD) ->withCache(YII_ENV_PROD ? new FilesystemCache(Yii::getAlias('@runtime/doctrine/cache')) : new ArrayCache()) ->withMapping(new SimplifiedYamlDriver([ Yii::getAlias('@app/repositories/doctrine/mapping/Employee') => 'app\entities\Employee' ])) ->withSubscribers([ new TablePrefixSubscriber($app->db->tablePrefix), ]) ->withTypes([ IdType::NAME => IdType::class, ]) ->build(['pdo' => $app->db->pdo]); });
И после регистрации свой новый тип можем использовать для поля id
по его имени вместо integer
:
app\entities\Employee\Employee: type: entity table: doctrine_employees id: id: type: employee_id fields: createDate: column: create_date type: datetime_immutable currentStatus: column: current_status type: string
Пойдём дальше. Разберёмся теперь с Name
и Address
. В примитивных ORM мы могли бы записать их в отдельные таблицы вроде employee_addresses
и привязать по связи OneToOne. Это было бы неудобно в плане сохранения и производительности. Но Doctrine с текущей версии 2.5 поддерживает концепцию встроенных объектов-значений. Достаточно помимо fields
просто указать в сущности вложенные объекты в секции embedded
:
app\entities\Employee\Employee: type: entity table: doctrine_employees id: id: type: employee_id fields: createDate: column: create_date type: datetime_immutable currentStatus: column: current_status type: string embedded: name: class: Name address: class: Address
и обозначить соответствующие классы типом embeddable
в Name.orm.yml
:
app\entities\Employee\Name: type: embeddable fields: last: type: string first: type: string middle: type: string
и в Address.orm.yml
:
app\entities\Employee\Address: type: embeddable fields: country: type: string region: type: string city: type: string street: type: string house: type: string
Теперь Doctrine будет по умолчанию сохранять их значения в полях name_*
и address_*
таблицы employees
. При желании можно указать собственный prefix
для каждого элемента в секции embedded
.
Переходим к связям.
Статусы по OneToMany
Чтобы сохранять строки статусов в отдельной таблице doctrine_employee_statuses
мы должны сконфигурировать наш класс Status
как сущность. Для этого создадим файл Status.orm.yml
:
app\entities\Employee\Status: type: entity table: doctrine_employee_statuses fields: value: type: string date: type: datetime_immutable
Но есть нюанс в том, что Doctrine каждую извлечённую из БД сущность сохраняет и ищет в IdentityMap под своим идентификатором. Это накладывает на нас требование того, что у каждой сохраняемой в БД сущности обязательно должен быть первичный ключ. Соответственно, в нашем случае помимо секции fields
для полей данных мы должны добавить секцию id
для указания автоинкрементного первичного ключа по полю id
:
app\entities\Employee\Status: type: entity table: doctrine_employee_statuses id: id: type: integer generator: strategy: AUTO fields: value: type: string date: type: datetime_immutable
Как мы помним из первой статьи про сущности, у нас класс Status
сделан иммутабельным (доступным только для чтения). Его объект заполняется через конструктор и не содержит сеттеров для изменения значений. Соответственно, через опцию readOnly
можно попросить UnitOfWork не следить за изменением его полей (не сохранять снапшоты старых значений):
app\entities\Employee\Status: type: entity table: doctrine_employee_statuses readOnly: true id: ... fields: ...
Теперь нам нужно настроить OneToMany связь сотрудника со статусами.
В Doctrine для связей используются отдельные секции. В нашем случае мы можем напрямую обозначить связь statuses
в секции oneToMany
:
app\entities\Employee\Employee: type: entity table: doctrine_employees id: ... fields: ... embedded: ... oneToMany: statuses: targetEntity: Status mappedBy: employee orderBy: { "id": "ASC" } cascade: ["persist", "merge"] orphanRemoval: true
Увы, но нельзя проставить связь OneToMany только в одну сторону. Обязательно нужно указать обратную ManyToOne-связь employee
'
app\entities\Employee\Status: type: entity table: doctrine_employee_statuses readOnly: true id: ... fields: ... manyToOne: employee: targetEntity: Employee joinColumn: name: employee_id referencedColumnName: id
Для связи statuses
мы указали каскадное сохранение всех объектов статусов при сохранении сотрудника параметром cascade
и реальное удаление orphanRemoval
статусов при их исключении из коллекции. Иначе бы без этого параметра статусы оставались в таблице, но у них поле employee_id
просто выставлялось в NULL
.
Теперь нужно немного дополнить код наших сущностей.
Во-первых, в Status
добавим требуемые поля $id
для первичного ключа и $employee
для связи:
namespace app\entities\Employee; use Assert\Assertion; class Status { const ACTIVE = 'active'; const ARCHIVED = 'archived'; private $id; private $employee; private $value; private $date; public function __construct(Employee $employee, string $value, \DateTimeImmutable $date) { Assertion::inArray($value, [ self::ACTIVE, self::ARCHIVED ]); $this->employee = $employee; $this->value = $value; $this->date = $date; } public function isActive(): bool { return $this->value === self::ACTIVE; } public function isArchived(): bool { return $this->value === self::ARCHIVED; } public function getValue(): string { return $this->value; } public function getDate(): \DateTimeImmutable { return $this->date; } }
Конструкция new Status($value, $date)
у нас используется только внутри Employee
:
class Employee implements AggregateRoot { ... private function addStatus($value, \DateTimeImmutable $date): void { $this->statuses[] = new Status($value, $date); } }
поэтому мы можем безболезненно поменять её на new Status($this, $value, $date)
для присваивания связи $employee
без переделки остальных классов.
Помимо этого нужно сделать несколько дополнений в Employee
.
Множественные OneToMany и ManyToMany связи в Doctrine осуществляются в виде коллекций. В прошлой статье мы аналогично переходили от массивов к объектам класса \ArrayObject
, чтобы можно было их проксировать. Здесь же вместо класса \ArrayObject
по умолчанию требуется использовать объект класса Doctrine\Common\Collections\ArrayCollection
. При извлечении сущности из базы данных он будет подменяться системой на прокси-класс Doctrine\ORM\PersistentCollection
, осуществляющий ленивую загрузку. Поэтому переделаем конструктор на создание пустой коллекции в $this->statuses
и переделаем остальные методы с использования массива на работу с объектом-коллекцией:
use Doctrine\Common\Collections\ArrayCollection; class Employee implements AggregateRoot { ... /** * @var ArrayCollection|Status[] */ private $statuses; private $currentStatus; public function __construct(Id $id, \DateTimeImmutable $date, Name $name, Address $address, array $phones) { ... $this->createDate = $date; $this->statuses = new ArrayCollection(); $this->addStatus(Status::ACTIVE, $date); $this->recordEvent(new Events\EmployeeCreated($this->id)); } private function getCurrentStatus(): Status { return $this->statuses->last(); } private function addStatus($value, \DateTimeImmutable $date): void { $this->statuses->add(new Status($this, $value, $date)); $this->currentStatus = $value; } public function getStatuses(): array { return $this->statuses->toArray(); } }
Также на прошлом уроке мы договорились сохранять текущий статус в отдельное поле в таблице сотрудников, чтобы было удобнее фильтровать записи. В Doctrine такой «магии» с виртуальными вычисляемыми полями нет, поэтому мы явно создали поле $currentStatus
и добавили присваивание ему значения прямо в методе addStatus
.
Если у вас имеются сущности с обычными HAS-MANY и HAS-ONE связями, то спокойно прописываем сущности и связи в конфигурационных файлах и пользуемся ими «из коробки».
Но если есть нюансы, то придётся немного попотеть. Как в примере с телефонами, который сейчас рассмотрим.
Телефоны с коллекцией Phones
Привязать статусы по связи OneToMany не было проблемой: поменяли массив $statuses
на объект коллекции, прописали связи в YAML-файлах настроек для Data Mapper... и всё заработало. Другое же дело – привязка телефонных номеров.
Проблема в том, что в Doctrine связи работают только со встроенными объектами коллекций. Поэтому напрямую сделать связь через наш класс Phones
не получится. Для решения этой проблемы мы можем немного схитрить. А именно, рядом с полем $phones
добавить вспомогательное поле-коллекцию $relatedPhones
:
namespace app\entities\Employee; class Employee implements AggregateRoot { ... /** * @var Phones */ private $phones; /** * @var ArrayCollection */ private $relatedPhones; }
и настроить связь на него:
app\entities\Employee\Employee: type: entity table: doctrine_employees id: ... fields: ... embedded: ... oneToMany: relatedPhones: targetEntity: Phone mappedBy: employee orderBy: { "id": "ASC" } cascade: ["persist", "merge"] orphanRemoval: true statuses: targetEntity: Status mappedBy: employee orderBy: { "id": "ASC" } cascade: ["persist", "merge"] orphanRemoval: true
с обратной связью из Phone
:
app\entities\Employee\Phone: type: entity table: doctrine_employee_phones readOnly: true id: ... fields: ... manyToOne: employee: targetEntity: Employee joinColumn: name: employee_id referencedColumnName: id
Аналогично классу Status
мы должны внутрь Phone
добавить поля $id
(для первичного ключа) и $employee
(для связи). Но мы помним, что телефоны new Phone(...)
мы должны создать ещё до создания самого $employee
:
$phones = [ new Phone(7, '920', '00000001'), new Phone(7, '910', '00000002'), ] $employee = new Employee($id, $name, $address, $phones);
поэтому конструктор Phone
изменить мы не можем. Вместо этого для присваивания связи $employee
создадим вспомогательный сеттер setEmployee
:
namespace app\entities\Employee; class Phone { private $id; private $employee; private $country; private $code; private $number; public function __construct($country, $code, $number) { Assertion::notEmpty($country); Assertion::notEmpty($code); Assertion::notEmpty($number); $this->country = $country; $this->code = $code; $this->number = $number; } ... public function setEmployee(Employee $employee): void { $this->employee = $employee; } }
чтобы потом при добавлении каждого телефона вызывать:
$phone->setEmployee($employee);
Теперь нам нужно взять нашу коллекцию, работающую сейчас с массивом:
namespace app\entities\Employee; class Phones { /** * @var Phone[] */ private $phones = []; public function __construct(array $phones) { if (!$phones) { throw new \DomainException('Employee must contain at least one phone.'); } foreach ($phones as $phone) { $this->add($phone); } } public function add(Phone $phone): void { foreach ($this->phones as $item) { if ($item->isEqualTo($phone)) { throw new \DomainException('Phone already exists.'); } } $this->phones[] = $phone; } public function remove($index): Phone { if (!isset($this->phones[$index])) { throw new \DomainException('Phone not found.'); } if (count($this->phones) === 1) { throw new \DomainException('Cannot remove the last phone.'); } $phone = $this->phones[$index]; unset($this->phones[$index]); return $phone; } public function getAll(): array { return $this->phones; } }
и переделать её на работу с доктриновской коллекцией, заодно добавив вызов $phone->setEmployee($this->employee)
при добавлении номеров:
namespace app\entities\Employee; use Doctrine\Common\Collections\Collection; class Phones { private $employee; /** * @var Collection|Phone[] */ private $phones = []; public function __construct(Employee $employee, &$relatedPhones, array $phones) { if (!$phones) { throw new \DomainException('Employee must contain at least one phone.'); } $this->employee = $employee; $this->phones = $relatedPhones = new ArrayCollection(); foreach ($phones as $phone) { $this->add($phone); } } public function add(Phone $phone): void { foreach ($this->phones as $item) { if ($item->isEqualTo($phone)) { throw new \DomainException('Phone already exists.'); } } $this->phones->add($phone); $phone->setEmployee($this->employee); } public function remove($index): Phone { if (!isset($this->phones[$index])) { throw new \DomainException('Phone not found.'); } if ($this->phones->count() === 1) { throw new \DomainException('Cannot remove the last phone.'); } return $this->phones->remove($index); } public function getAll(): array { return $this->phones->toArray(); } }
После этого нужно переделать конструктор сущности Employee
так, чтобы он пробрасывал себя и своё поле relatedPhones
в конструктор Phones
:
namespace app\entities\Employee; class Employee implements AggregateRoot { ... public function __construct(Id $id, \DateTimeImmutable $date, Name $name, Address $address, array $phones) { $this->id = $id; $this->name = $name; $this->address = $address; $this->phones = new Phones($this, $this->relatedPhones, $phones); $this->createDate = $date; $this->addStatus(Status::ACTIVE, $date); $this->recordEvent(new Events\EmployeeCreated($this->id)); } }
Теперь $this->relatedPhones
в Employee
и $this->phones
в Phones
будут ссылаться на один и тот же объект коллекции.
Можно создать сотрудника вызовом new Employee(...)
и сохранить в БД. Система UnitOfWork просчитает все связи и заполнит все таблицы.
Осталась лишь проблема с извлечением из БД. А именно, при заполнении связи гидратором у нас вернётся из базы заполненной только коллекция $this->relatedPhones
, а оригинальная $this->phones
останется пустой, так как гидратор о ней ничего не знает.
Что же делать? Нам нужно как-то влезть в процесс извлечения сущности из БД и там произвести заполнение phones
из relatedPhones
.
Как мы помним из примера с префиксом таблиц, у Doctrine имеется менеджер EventManager
, позволяющий навешиваться на внутренние события. И по аналогии с ActiveRecord там имеются события postLoad
, beforePersist
, afterPersist
и подобные, генерируемые для каждой сущности. Нам будет интересно именно postLoad
, вызывающееся после загрузки сущности из БД.
Напишем подписчик, навешивающийся на это событие и с использованием нашего гидратора заполняющий коллекцию $phones
из $relatedPhones
:
namespace app\doctrine\listeners; use app\entities\Employee\Employee; use app\entities\Employee\Phones; use app\hydrator\Hydrator; use Doctrine\Common\EventSubscriber; use Doctrine\Common\Persistence\Event\LifecycleEventArgs; use Doctrine\ORM\Events; class EmployeeSubscriber implements EventSubscriber { private $hydrator; public function __construct(Hydrator $hydrator) { $this->hydrator = $hydrator; } public function getSubscribedEvents(): array { return array( Events::postLoad, ); } public function postLoad(LifecycleEventArgs $eventArgs): void { $entity = $eventArgs->getObject(); if ($entity instanceof Employee) { $data = $this->hydrator->extract($entity, ['relatedPhones']); $this->hydrator->hydrate($entity, [ 'phones' => $this->hydrator->hydrate(Phones::class, [ 'employee' => $entity, 'phones' => $data['relatedPhones'], ]) ]); } } }
Теперь зарегистрируем его в EventManager
:
$evm->addEventSubscriber(new TablePrefixSubscriber($app->db->tablePrefix)); $evm->addEventSubscriber($container->get(EmployeeSubscriber::class));
Или в случае с нашим построителем добавим в массив для withSubscribers
:
$container->setSingleton(EntityManager::class, function (Container $container) use ($app) { return (new EntityManagerBuilder()) ... ->withSubscribers([ new TablePrefixSubscriber($app->db->tablePrefix), $container->get(EmployeeSubscriber::class), ]) ... ->build(['pdo' => $app->db->pdo]); });
Вот и всё. Сущности доработали. Мы пока сделали такой вариант с использованием связей. В скором времени мы рассмотрим альтернативные варианты, в которых нам не понадобится передавать $employee
напрямую в Phone
и Status
.
А пока чтобы проверить работоспособность сохранения сущностей сделаем репозиторий.
Написание репозитория
Как мы уже разобрались, за поиск сущностей отвечает базовый репозиторий EntityRepository
, а за сохранение – класс UnitOfWork
, находящийся внутри EntityManager
. Если мы перекладываем всю работу на библиотеку, то нам теперь достаточно реализовать интерфейс EmployeeRepository
в новом классе DoctrineEmployeeRepository
:
namespace app\repositories; use app\entities\Employee\Employee; use app\entities\Employee\Id; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityRepository; use Ramsey\Uuid\Uuid; class DoctrineEmployeeRepository implements EmployeeRepository { private $em; private $entityRepository; public function __construct(EntityManager $em, EntityRepository $entityRepository) { $this->em = $em; $this->entityRepository = $entityRepository; } public function get(Id $id): Employee { /** @var Employee $employee */ if (!$employee = $this->entityRepository->find($id)) { throw new NotFoundException('Employee not found.'); } return $employee; } public function add(Employee $employee): void { $this->em->persist($employee); $this->em->flush($employee); } public function save(Employee $employee): void { $this->em->flush($employee); } public function remove(Employee $employee): void { $this->em->remove($employee); $this->em->flush($employee); } }
Попробуем его в работе. Сначала создадим таблицы в базе.
Кстати, в Symfony можно легко подключить Doctrine/MigrationsBundle и автоматически генерировать и применять миграции консольными командами:
php bin/console doctrine:migrations:diff php bin/console doctrine:migrations:migrate
Или непосредственно можно подключить пакет Doctrine/Migrations к проекту не на Symfony Framework и делать то же самое.
Команда diff
сравнит текущую структуру базы данных с информацией из YAML-файлов (если мы опишем подробно все типы полей) и сама сгенерирует миграции для создания или изменения таблиц и внешних ключей.
Но мы напишем миграцию средствами нашего фреймворка:
use yii\db\Migration; class m170402_111825_create_doctrine_tables extends Migration { public function up() { $tableOptions = 'CHARACTER SET utf8 COLLATE utf8_general_ci ENGINE=InnoDB'; $this->createTable('{{%doctrine_employees}}', [ 'id' => $this->char(36)->notNull(), 'create_date' => $this->dateTime(), 'name_last' => $this->string(), 'name_first' => $this->string(), 'name_middle' => $this->string(), 'address_country' => $this->string(), 'address_region' => $this->string(), 'address_city' => $this->string(), 'address_street' => $this->string(), 'address_house' => $this->string(), 'current_status' => $this->string(32)->notNull(), ], $tableOptions); $this->addPrimaryKey('pk-doctrine_employees', '{{%doctrine_employees}}', 'id'); $this->createTable('{{%doctrine_employee_phones}}', [ 'id' => $this->primaryKey(), 'employee_id' => $this->char(36)->notNull(), 'country' => $this->integer()->notNull(), 'code' => $this->string()->notNull(), 'number' => $this->string()->notNull(), ], $tableOptions); $this->createIndex('idx-doctrine_employee_phones-employee_id', '{{%doctrine_employee_phones}}', 'employee_id'); $this->addForeignKey('fk-doctrine_employee_phones-employee', '{{%doctrine_employee_phones}}', 'employee_id', '{{%doctrine_employees}}', 'id', 'CASCADE', 'RESTRICT'); $this->createTable('{{%doctrine_employee_statuses}}', [ 'id' => $this->primaryKey(), 'employee_id' => $this->char(36)->notNull(), 'value' => $this->string(32)->notNull(), 'date' => $this->dateTime()->notNull(), ], $tableOptions); $this->createIndex('idx-doctrine_employee_statuses-employee_id', '{{%doctrine_employee_statuses}}', 'employee_id'); $this->addForeignKey('fk-doctrine_employee_statuses-employee', '{{%doctrine_employee_statuses}}', 'employee_id', '{{%doctrine_employees}}', 'id', 'CASCADE', 'RESTRICT'); } public function down() { $this->dropTable('{{%doctrine_employee_statuses}}'); $this->dropTable('{{%doctrine_employee_phones}}'); $this->dropTable('{{%doctrine_employees}}'); } }
и применим:
php yii migrate php tests/bin/yii migrate
Как и раньше, создадим пустые фикстуры для очистки базы и напишем тест:
namespace tests\unit\repositories; use app\entities\Employee\Employee; use app\repositories\DoctrineEmployeeRepository; use Doctrine\ORM\EntityManager; use tests\_fixtures\EmployeeFixture; use tests\_fixtures\EmployeePhoneFixture; use tests\_fixtures\EmployeeStatusFixture; use ProxyManager\Factory\AccessInterceptorValueHolderFactory; class DoctrineEmployeeRepositoryTest extends BaseRepositoryTest { /** * @var \UnitTester */ public $tester; public function _before() { $this->tester->haveFixtures([ 'employee' => EmployeeFixture::className(), 'employee_phone' => EmployeePhoneFixture::className(), 'employee_status' => EmployeeStatusFixture::className(), ]); $em = \Yii::$container->get(EntityManager::class); $repository = new DoctrineEmployeeRepository($em, $em->getRepository(Employee::class)); $this->repository = (new AccessInterceptorValueHolderFactory())->createProxy($repository, [ 'get' => function () use ($em) { $em->clear(); }, ]); } }
Здесь мы могли бы просто создать объект репозитория таким образом:
$em = \Yii::$container->get(EntityManager::class); $doctrineRepository = $em->getRepository(Employee::class) $this->repository = new DoctrineEmployeeRepository($em, $doctrineRepository);
Но есть небольшой нюанс. В каждом тестовом методе мы добавляем запись в репозиторий через add
и извлекаем назад через get
:
abstract class BaseRepositoryTest extends Unit { protected $repository; public function testGet() { $this->repository->add($employee = EmployeeBuilder::instance()->build()); $found = $this->repository->get($employee->getId()); $this->assertNotNull($found); $this->assertEquals($employee->getId(), $found->getId()); } ... }
Но, как мы помним, UnitOfWork каждую сущность добавляет в приватный массив $identityMap
и при последующих запросах извлекает из него. Если поэксперименировать с очисткой базы между вставкой и запросом:
abstract class BaseRepositoryTest extends Unit { protected $repository; public function testGet() { $this->repository->add($employee = EmployeeBuilder::instance()->build()); // Очищаем таблицу \Yii::$app->db->createCommand()->delete('{{%doctrine_employees}}')->execute(); $found = $this->repository->get($employee->getId()); $this->assertNotNull($found); $this->assertEquals($employee->getId(), $found->getId()); } ... }
то метод get
вместо выполнения SELECT-запроса в $found
сразу вернёт эту же сущность $employee
из приватного массива-кеша, даже если запись в базе уже удалена. И все тесты удачно пройдут, даже если в базу ничего не записалось.
Для чистоты эксперимента нужно перед каждым вызовом метода get
очищать закешированный массив $identityMap
вызовом $em->clear()
. Чтобы не изменять исходники уже написанных универсальных тестов просто спроксируем репозиторий через ещё одну фабрику AccessInterceptorValueHolderFactory
используемого нами ранее пакета:
composer require ocramius/proxy-manager
примерно так:
use ProxyManager\Factory\AccessInterceptorValueHolderFactory; $em = $this->getEntityManager(); $repository = new DoctrineEmployeeRepository($em, $em->getRepository(Employee::class)); $this->repository = (new AccessInterceptorValueHolderFactory())->createProxy($repository, [ 'get' => function () use ($em) { $em->clear(); }, ]);
Этому прокси-объекту мы указали перед каждым вызовом оригинального метода get
дёргать нашу анонимную функцию, очищающую $identityMap
.
Теперь запускаем:
vendor/bin/codecept run unit repositories/DoctrineEmployeeRepositoryTest
И видим что-то такое:
Unit Tests (5) --------------------------------------------- ✔ DoctrineEmployeeRepositoryTest: Get (0.14s) ✔ DoctrineEmployeeRepositoryTest: Get not found (0.03s) ✔ DoctrineEmployeeRepositoryTest: Add (0.04s) ✔ DoctrineEmployeeRepositoryTest: Save (0.05s) ✔ DoctrineEmployeeRepositoryTest: Remove (0.05s) ------------------------------------------------------------ Time: 442 ms, Memory: 8.00MB OK (5 tests, 14 assertions)
...пока противники написания тестов... ещё проверяют вручную репозиторий из прошлой статьи.
А теперь рассмотрим альтарнативные варианты работы с объектами-значениями.
Коллекции объектов-значений как Embedded
Сейчас мы объекты-значения Phone
и Status
оформили в виде неизменяемых (read only) доктриновских сущностей.
Для этого нам пришлось эти классы немного загрязнить. А именно, добавить в них поле $id
для первичного ключа и $employee
для обратной manyToOne
связи. И эту связь теперь надо не забывать присваивать либо через вспомогательный сеттер если мы передаём готовый объект $phone
снаружи:
/** * @var Collection|Phone[] */ private $phones; ... $this->phones->add($phone); $phone->setEmployee($employee);
либо через конструктор, если мы формируем его по принятым данным:
/** * @var Collection|Status[] */ private $statuses; ... $this->statuses->add(new Status($this, $value, $date));
Наличие такой обратной свази в $this->employee
будет для нас оверхэдом в случае использования событий вроде:
$this->recordEvent(new Events\EmployeePhoneAdded($this->id, $phone));
В этом случае в объект события будет передаваться доктриновская сущность $phone
, у которой в $this->employee
окажется присвоен прокси-объект, содержащий подключение к базе данных и экземпляр $employee
. И если мы захотим сериализовать это событие через json_encode
, то столкнёмся с ощибками сериализации сложного объекта с циклическими зависимостями.
Так что поле $id
для первичного ключа и связь $employee
внутри Phone
и Status
мам иногда могут мешать.
Как нам это лишнее убрать?
У нас сейчас объекты-значения Name
и Address
оформлены в виде вложенных объектов как embedded
и с ними у нас проблем нет:
Employee: type: entity ... embedded: name: class: Name address: class: Address
Нам бы хотелось аналогично иметь возможность создавать коллекцию телефонов просто с кодом и номером:
Employee(id, phones) -> Phone(code, number)
Но, как мы уже сказали, для связей в Doctrine нам нужно добавить вспомогательные поля:
Employee(id, phones) -> Phone(id, employee, code, number)
Но вместо такого подхода мы можем поступить хитрее. Например, так:
Employee(id, phones) -> EmployeePhone(id, employee, phone) -> Phone(code, number)
Мы можем добавить вспомогательную доктриновскую сущность-обёртку EmployeePhone
и c нужными ей полями id
и employee
. И в ней сделать embedded-поле phone
для присваивания объекта-значения Phone
.
Если мы сделаем такую вспомогательную сущность-носитель для объекта-значения, то из Employee
сделаем связь на неё.
Пишем такую сущность для хранения телефона:
class EmployeePhone { private $id; private $employee; private $phone; public function __construct(Employee $employee, Phone $phone) { $this->employee = $employee; $this->phone = $phone; } public function getPhone(): Phone { return $this->phone; } }
И такую же для хранения статуса:
class EmployeeStatus { private $id; private $employee; private $status; public function __construct(Employee $employee, Status $status) { $this->employee = $employee; $this->status = $status; } public function getStatus(): Status { return $this->status; } }
Очищаем Phone
от лишних полей $id
с $employee
и сеттера setEmployee()
:
class Phone { private $country; private $code; private $number; ... public function getCountry(): int { return $this->country; } public function getCode(): string { return $this->code; } public function getNumber(): string { return $this->number; } }
Аналогично возвращаем оригинальный класс Status
без $employee
class Status { ... private $value; private $date; public function __construct(string $value, \DateTimeImmutable $date) { Assertion::inArray($value, [ self::ACTIVE, self::ARCHIVED ]); $this->value = $value; $this->date = $date; } ... }
В сущности Employee
от кода, работавшего с коллекцией из элементов типа Status
:
class Employee implements AggregateRoot { /** * @var ArrayCollection|Status[] */ private $statuses; private function getCurrentStatus(): Status { return $this->statuses->last(); } private function addStatus($value, \DateTimeImmutable $date): void { $this->statuses->add(new Status($this, $value, $date)); $this->currentStatus = $value; } public function getStatuses(): array { return $this->statuses->toArray(); } }
переходим на работу с коллекцией обёрток EmployeeStatus
с их геттером getStatus()
:
class Employee implements AggregateRoot { /** * @var ArrayCollection|EmployeeStatus[] */ private $statuses; private function getCurrentStatus(): Status { return $this->statuses->last()->getStatus(); } private function addStatus($value, \DateTimeImmutable $date): void { $this->statuses->add(new EmployeeStatus($this, new Status($value, $date))); $this->currentStatus = $value; } public function getStatuses(): array { return $this->statuses->map(function (EmployeeStatus $row): Status { return $row->getStatus(); })->toArray(); } }
То есть вместо простого присваивания Status
:
$this->statuses->add(new Status($this, $value, $date));
теперь оборачиваем Status
в EmployeeStatus
:
$this->statuses->add(new EmployeeStatus($this, new Status($value, $date)));
И вместо возврата самой обёртки:
private function getCurrentStatus(): Status { return $this->statuses->last(); }
возвращаем хранящийся в ней объект:
private function getCurrentStatus(): Status { return $this->statuses->last()->getStatus(); }
И также в методе вместо прошлого возврата массива:
public function getStatuses(): array { return $this->statuses->toArray(); }
проходим по элементам коллекциий через map
, доставая именно массив вложенных объектов:
public function getStatuses(): array { return $this->statuses->map(function (EmployeeStatus $row): Status { return $row->getStatus(); })->toArray(); }
И то же самое можем сделать с телефонами. В Phones
от прошлого кода:
class Phones { /** * @var Collection|Phone[] */ private $phones; ... public function add(Phone $phone): void { foreach ($this->phones as $item) { if ($item->isEqualTo($phone)) { ... } } $this->phones->add($phone); $phone->setEmployee($this->employee); } public function remove($index): Phone { ... return $this->phones->remove($index); } public function getAll(): array { return $this->phones->toArray(); } }
аналогично переходим на использование обёртки EmployeePhone
с её геттером getPhone()
:
class Phones { /** * @var Collection|EmployeePhone[] */ private $phones; ... public function add(Phone $phone): void { foreach ($this->phones as $item) { if ($item->getPhone()->isEqualTo($phone)) { ... } } $this->phones->add(new EmployeePhone($this->employee, $phone)); } public function remove($index): Phone { ... return $this->phones->remove($index)->getPhone(); } public function getAll(): array { return $this->phones->map(function (EmployeePhone $row): Phone { return $row->getPhone(); })->toArray(); } }
Теперь когда у нас готовы все классы меняем маппинг. В агрегате меняем классы связей:
app\entities\Employee\Employee: type: entity ... oneToMany: relatedPhones: targetEntity: EmployeePhone ... statuses: targetEntity: EmployeeStatus ...
Прописываем маппинг для вспомогательных сущностей EmployeePhone
:
app\entities\Employee\EmployeePhone: type: entity table: doctrine_employee_phones readOnly: true id: id: type: integer generator: strategy: AUTO embedded: phone: class: Phone columnPrefix: false manyToOne: employee: targetEntity: Employee joinColumn: name: employee_id referencedColumnName: id
и аналогично EmployeeStatus
:
app\entities\Employee\EmployeeStatus: type: entity table: doctrine_employee_statuses readOnly: true id: id: type: integer generator: strategy: AUTO embedded: status: class: Status columnPrefix: false manyToOne: employee: targetEntity: Employee joinColumn: name: employee_id referencedColumnName: id
Здесь при описании такого embedded
можно при желании отключить columnPrefix
значением false
, чтобы в таблице в базе данных использовалось прошлое поле value
вместо поля status_value
с префиксм status_
.
И теперь по аналогии с Name
и Address
пишем маппинги для объекта-значения Phone
:
app\entities\Employee\Phone: type: embeddable fields: country: type: integer code: type: string number: type: string
и для Status
:
app\entities\Employee\Status: type: embeddable fields: value: type: string date: type: datetime_immutable
И после этого можем запустить все наши тесты и всё должно работать как и раньше.
Да, мы немного усложнили код агрегата добавлением вспомогательных сущностей-носителей, но при этом мы избавили от мусора классы Phone
и Status`. Теперь наши события вроде:
$this->recordEvent(new Events\EmployeePhoneAdded($this->id, $phone));
мы сможем легко сериализовать, так как нам в объекте телефона больше не будут мешать лишние поля id
и employee
.
Это альтернативный вариант, применимый если вам нужны чистые классы объектов-значений.
Но вместо использования отдельных таблиц и связей можно рассмотреть вариант хранения коллекции статусов в виде JSON.
Поддержка JSON
Для экономии ресурсов в прошлой статье мы сделали сохранение массива статусов в поле statuses
самой таблицы employee
. Сделаем тоже самое и сейчас.
Переделаем базу под хранение статусов в поле JSON. Добавим поле statuses
в БД:
use yii\db\Migration; class m170417_192518_add_doctrine_json_statuses_field extends Migration { public function up() { $this->addColumn('{{%doctrine_employees}}', 'statuses', 'JSON'); } public function down() { $this->dropColumn('{{%doctrine_employees}}', 'statuses'); } }
и применим:
php tests/bin/yii migrate
Очистим Status
обратно от полей $id
и $employee
:
namespace app\entities\Employee; class Status { const ACTIVE = 'active'; const ARCHIVED = 'archived'; private $value; private $date; public function __construct(string $value, \DateTimeImmutable $date) { Assertion::inArray($value, [ self::ACTIVE, self::ARCHIVED ]); $this->value = $value; $this->date = $date; } public function isActive(): bool { return $this->value === self::ACTIVE; } public function isArchived(): bool { return $this->value === self::ARCHIVED; } public function getValue(): string { return $this->value; } public function getDate(): \DateTimeImmutable { return $this->date; } }
и вернём $statuses
обратно на массив вместо ArrayCollection
:
namespace app\entities\Employee; class Employee implements AggregateRoot { ... /** * @var Status[] */ private $statuses = []; private $currentStatus; public function __construct(Id $id, \DateTimeImmutable $date, Name $name, Address $address, array $phones) { $this->id = $id; $this->name = $name; $this->address = $address; $this->phones = new Phones($this, $this->relatedPhones, $phones); $this->createDate = $date; $this->addStatus(Status::ACTIVE, $date); $this->recordEvent(new Events\EmployeeCreated($this->id)); } private function getCurrentStatus(): Status { return end($this->statuses); } private function addStatus($value, \DateTimeImmutable $date): void { $this->statuses[] = new Status($value, $date); $this->currentStatus = $value; } public function getStatuses(): array { return $this->statuses; } }
Удалим файл Status.orm.yml
с настройками маппинга.
Из Employee.orm.yml
удалим связь statuses
, оставив только relatedPhones
:
app\entities\Employee\Employee: type: entity table: doctrine_employees id: ... fields: ... oneToMany: relatedPhones: targetEntity: Phone mappedBy: employee orderBy: { "id": "ASC" } cascade: ["persist", "merge"] orphanRemoval: true
Создадим новый тип для конвертации массива статусов в JSON и обратно на основе методов похожего класса JsonType
:
namespace app\repositories\doctrine\types\Employee; use app\entities\Employee\Status; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Types\JsonType; class StatusesType extends JsonType { const NAME = 'employee_statuses'; public function convertToDatabaseValue($value, AbstractPlatform $platform) { if (null === $value) { return null; } return json_encode(array_map(function (Status $status) { return [ 'value' => $status->getValue(), 'date' => $status->getDate()->format(DATE_RFC3339), ]; }, $value)); } public function convertToPHPValue($value, AbstractPlatform $platform) { if ($value === null || $value === '') { return array(); } $value = (is_resource($value)) ? stream_get_contents($value) : $value; return array_map(function ($row) { return new Status( $row['value'], new \DateTimeImmutable($row['date']) ); }, json_decode($value, true)); } public function getName() { return self::NAME; } }
Зарегистрируем тип в системе:
$container->setSingleton(EntityManager::class, function (Container $container) use ($app) { return (new EntityManagerBuilder()) ... ->withTypes([ IdType::NAME => IdType::class, StatusesType::NAME => StatusesType::class, ]) ->build(['pdo' => $app->db->pdo]); });
И добавим поле statuses
этого типа в Employee
app\entities\Employee\Employee: type: entity table: doctrine_employees id: id: type: employee_id fields: createDate: column: create_date type: datetime_immutable currentStatus: column: current_status type: string statuses: type: employee_statuses embedded: name: class: Name address: class: Address oneToMany: relatedPhones: targetEntity: Phone mappedBy: employee orderBy: { "id": "ASC" } cascade: ["persist", "merge"] orphanRemoval: true
В тестах удалим StatusFixture
и запустим снова:
Unit Tests (5) --------------------------------------------- ✔ DoctrineEmployeeRepositoryTest: Get (0.13s) ✔ DoctrineEmployeeRepositoryTest: Get not found (0.03s) ✔ DoctrineEmployeeRepositoryTest: Add (0.03s) ✔ DoctrineEmployeeRepositoryTest: Save (0.03s) ✔ DoctrineEmployeeRepositoryTest: Remove (0.03s) ------------------------------------------------------------ Time: 402 ms, Memory: 8.00MB OK (5 tests, 14 assertions)
Это всё. Чтобы сохранить какой угодно фрагмент данных в JSON-поле достаточно лишь сделать собственный тип для этого поля, конвертирующий массив в JSON и восстанавливающий обратно.
Регистрация в контейнере
После всего пройденного наш класс ContainerBootstrap
станет примерно таким:
namespace app\bootstrap; ... class ContainerBootstrap implements BootstrapInterface { public function bootstrap($app) { $container = Yii::$container; $container->setSingleton(EventDispatcher::class, DummyEventDispatcher::class); $container->setSingleton(Hydrator::class); $container->setSingleton(EntityManager::class, function (Container $container) use ($app) { return (new EntityManagerBuilder()) ->withProxyDir(Yii::getAlias('@runtime/doctrine/proxy'), 'Proxies', !YII_ENV_PROD) ->withCache(YII_ENV_PROD ? new FilesystemCache(Yii::getAlias('@runtime/doctrine/cache')) : new ArrayCache()) ->withMapping(new SimplifiedYamlDriver([ Yii::getAlias('@app/repositories/doctrine/mapping/Employee') => 'app\entities\Employee', ])) ->withSubscribers([ new TablePrefixSubscriber($app->db->tablePrefix), $container->get(EmployeeSubscriber::class), ]) ->withTypes([ IdType::NAME => IdType::class, StatusesType::NAME => StatusesType::class, ]) ->build(['pdo' => $app->db->pdo]); }); $container->setSingleton(EmployeeRepository::class, function (Container $container) { $em = $container->get(EntityManager::class); return new DoctrineEmployeeRepository($em, $em->getRepository(Employee::class)); }); } }
Здесь мы для EntityManager
прописали все пути, зарегистрировали подписчиков на события, собственные типы полей. Также сделали секцию сборки EmployeeRepository
.
Что получилось
После интеграции мы получили следующую структуру:
├── bootstrap │ └── ContainerBootstrap.php ├── doctrine │ ├── listeners │ │ └── TablePrefixSubscriber.php │ └── EntityManagerBuilder.php ├── entities │ └── ... └── repositories ├── doctrine │ │ listeners │ │ └── EmployeeSubscriber.php │ ├── mapping │ │ └── Employee │ │ ├── Employee.orm.yml │ │ ├── Name.orm.yml │ │ ├── Address.orm.yml │ │ └── Phone.orm.yml │ └── types │ └── Employee │ ├── IdType.php │ └── StatusesType.php ├── EmployeeRepository.php ├── DoctrineEmployeeRepository.php └── NotFoundException.php
Полный исходный код урока с историей изменений доступен на GitHub в ветке doctrine
.
В итоге для интеграции Doctrine ORM в проект мы:
- Установили библиотеку через Composer.
- Сконфигурировали компонент
EntityManager
в контейнере. - Навесили обработчики для служебных целей.
Если для вашего фреймворка есть готовый пакет интеграции, то эти шаги можно пропустить.
И потом для подключения готового кода сущностей к ORM:
- Добавляем метод
__toString()
вId
, если для идентификатора используем объект. - Пишем свои типы для нестандартного преобразования полей в объекты, JSON и т.п.
- Перестраиваем массивы для связей на использование
ArrayCollection
. - Создаём файлы настроек маппинга. Сущности помечаем как
entity
, а вложенные объекты-значения – какembeddable
. - Прописываем связи сущностей.
- При необходимости добавляем вспомогательные поля для первичных ключей и связей.
- Для сложных случаев навешиваемся на событие
postLoad
и инициализируем сущность вручную в обработчике.
После этого сразу получаем готовую работающую систему.
Если бы мы делали проект изначально на Symfony с Doctrine ORM, то сразу делали бы сущности по такому манеру «из коробки». И спокойно использовали бы её репозитории.
Более того, если захочется использовать удобный класс ArrayCollection
(с полезными методами filter
, map
, contains
и т.п.) без Doctrine ORM, то можно установить только микропакет с коллекциями:
composer require doctrine/collections
и работать с ним в своих классах.
Вот мы и научились использовать Doctrine ORM даже для нестандартных вещей.
Возможно, за последнее время описанная работа с Doctrine немного устарела. Свежий вариант можно найти в коде проекта аукциона, который мы пишем на микрофреймворке с использованием этой ORM.
Так что строенная поддержка полноценных классов с конструкторами и приватными полями, вложенными объектами-значениями и связями, возможность создания любых собственных типов полей и отсутствие необходимости ручного слежения за изменениями делают Doctrine удобным готовым инструментом для разработки сложных доменных сущностей.
А в следующей статье этого цикла мы бросим себе вызов и узнаем, можно ли построить такие же доменные сущности с использованием ActiveRecord:
Доменные сущности и ActiveRecord
Не забудьте подписаться на рассылку и до встречи в следующей статье!
Отличная статья!)
Большое дело делаешь Дима! Спасибо за такие статьи, очень и очень помогают новичкам!
Вот после таких статей еще больше доверять начинаешь автору, его опыту так сказать, и люди охотнее будут подписываться на платные учебные курсы. ИМХО, но по моему это действительно так. В итоге и мы, новички, получаем полезную информацию. И, надеюсь, тебе больше подписчиков на курсы приходит.))
Я понял, что ничего не понял. Вот так с наскока взять doctrine не вышло. Нужно уже хорошо разбираться с ней, что бы внедрять ее в проект. А так статья хороша как справочник, делайте так и этак, по шагам и у вас все получится.
Хорошая статья, стало понятнее что за зверь doctrine. Однако смущает, что в итоге внедрение doctrine оказало влияние на доменный слой, что имхо не есть хорошо.
$phone->setEmployee($employee); - получается, что не у сотрудника есть номер телефона, а у номера телефона есть сотрудник.
вызов setEmployee($employee) после "$employee = new Employee($id, $name, $address, $phones);" выглядит костылем.
Очень жду вариант с ActiveRecord и мастер класс по разработке интернет-магазина
Да, для OneToMany нужно всегда добавлять встречный $item->setEmployee($this), чтобы у Item проставлялся employee_id.
Дмитрий, можно. Через линковочную таблицу. One-To-Many, Unidirectional with Join Table Но тут на чаше весов - качество доменной модели и производительность БД.
Да, замена на ManyToMany - это уже костыль со стороны БД.
Я все еще не пойму почему репозиторий может знать о сущности. Может, немного уделите время на объяснение взаимодействия доменного слоя со слоем инфраструктуры?
Спасибо за статью!
Уже Вам отвечал здесь, что репозиторий тоже находится в доменом слое с сущностями.
Я видел тот комментарий. И прочитал то, что по ссылке. Видать я тугодум.
Абстрактный Domain\Repository\EmployeeRepository лежит в домене рядом с сущностью Domain\Entity\Employee. И они спокойно взаимодействуют в одном слое Domain.
А в папке так называемой инфраструктуры уже лежат все конкретные наследники вроде Infrastructure\Repository\SqlEmployeeRepository, которых мы подсовываем через $container->set(...) вместо абстрактных оригиналов.
Спасибо
Привет, не подскажешь, какие есть методы и в каких местах yii2 можно вклиниться, чтобы посмотреть отладочную информацию.
Например есть поведение, как посмотреть где оно используется.
Или есть Модель, как посмотреть, кто подписался на её событие AfterSave.
Обычным поиском по файлам или по Find usages для класса поведения в IDE.
А так если не используете глобальный Event::on(...), то только перечисленные в behaviors() поведения подписаться смогут.
А flush в репозитории вызывать? В application service нельзя? Заинжектить туда em, пригодятся транзакции и flush всегда будет один раз.
Здесь в самом DoctrineEmployeeRepository вызывается. Можно и переделать, если работаете только с Doctrine.
Здравствуйте.
Спасибо за очень полезные и хорошие статьи.
Подскажите пожалуйста когда выйдет следующая статья этого цикла (о ActiveRecord)?
Уже пишу. Так что завтра-послезавтра.
Дмитрий, спасибо за статью!
Немного не корректно написано:
- если мы присвоим одну переменную другой, то создастся ссылка на значение присваеваемой переменной. Т.е. 2 переменные будут ссылаться на одно и тоже значение. И если изменить значение любой переменной, то тогда выделится память под новое значение. Сopy-on-write - PHP создаёт новое значение только в тот момент, когда это действительно необходимо
Все бы хорошо, но!
Как так вышло, что мы отвязались от фреймворка и тут-же привязались к доктрине?
Doctrine - это библиотека, не привязанная к фреймворку.
И по твоему это что-то меняет?
Что если для хранения сущностей будет использован mongodb, например?
На кой нам тогда доктрина сдалась?
Тянуть всю доктрину ради какого-то ArrayCollection?
https://github.com/ElisDN/yii2-demo-aggregates/blob/doctrine/entities/Employee/Employee.php#L8
Делать жесткую связь между сущностью и доктриной, имхо, не лучшее решение.
> И по твоему это что-то меняет?
Да. Получаем самодостаточный фреймворконезависимый код.
> Что если для хранения сущностей будет использован mongodb, например? На кой нам тогда доктрина сдалась?
Поменяем Doctrine ORM на Doctrine ODM.
> Тянуть всю доктрину ради какого-то ArrayCollection?
Не всю doctrine/orm, а только микропакет doctrine/collections.
> Делать жесткую связь между сущностью и доктриной, имхо, не лучшее решение.
Явная связь проявляется только в использовании класса ArrayCollection.
Как и сказал в выводе к следующей статье, Doctrine - это единственная популярная ORM, дающая вменяемое ООП минимальными усилиями сразу из коробки.
Отличная статья. Использовали её как пример для своих репозиториев. Однако недавно возник дополнительный вопрос:
Допустим у аггрегата "Сотрудник" есть вложенная сущность "Кофейная кружка". Это именно сущность, но от сотрудника неотделимая и не достаточно самостоятельная, чтобы быть аггрегатом.
Вот "Вася" наклеивает на кружку стикер с надписью "Не забуду мать родную"... И сразу возникает проблема с доктриновским репозиторием.
Поскольку мы вызываем $this->em->flush($employee), то в базу попадут только изменения конкретного сотрудника и cascade действия (persist, orphanRemoval). То есть добавление и удаление у Васи кружки таким образом сохранится, а вот изменение кружки нет.
Более того в новых версиях доктрины ->flush($entity) будет изменено на просто ->flush(). А это значит что при выполнении:
$employee1 = $this->employeeRepository->get(1);
$employee2 = $this->employeeRepository->get(2);
$employee1->rename('Vasya');
$employee2->rename('Petya');
$this->employeeRepository->save($employee2);
может произойти конфуз.
Есть пара вариантов вроде: создавать сущность каждый раз заново как valueObject, что далеко не всегда удобно т.к. ид регенерится, отслеживать все изменения аггрегата вручную где-нибудь в update.
Хотелось бы узнать ваше мнение по этой проблеме.
Тогда лучше убрать метод save() и вызывать flush() отдельно:
Дмитрий, скажите, получается Doctrine нарушает концепцию DDD? Получается мы делаем маппинг сущности (ну или агрегата). Сущность в DDD - это класс, который не должен никак зависеть или знать о внешних вещах, таких как фреймворки или БД.А тут, в конечном итоге, получается сущность - отражение строки в БД или строк если это агргат или солянка из VO.
В этом плане сам код сущности здесь почти ничего не нарушает. Весь маппинг и код по сохранению и доставанию из БД спрограммирован снаружи сущности, а не внутри. И сам класс сущности до сих пор не знает, куда и как его сохраняют (в отдельные таблицы, в одну через embedded или в JSON-поле).
А так да, самый чистый вариант - это написание вручную своего репозитория. А если берём готовые ORM, то приходится немного подстраивать код под них.
Добавил в статью альтернативный вариант хранения VO во вспомогательных сущностях.
Ошибки в тексте
> И если мы захотим сериализовать это событие через json_encode, то столкнёмся с ощибками сериализации сложного объекта с циклическими зависимостями.
оЩибками -> ошибками
> Так что поле $id для первичного ключа и связь $employee внутри Phone и Status мам иногда могут мешать.
Мам -> нам
Какие преимущества предоставляют доменные сущности в контексте Doctrine ORM? regard Telkom University
Приветствую.
Есть агрегат, в нем есть коллекция каких-то связей (hasMany). Допустим это Диалог и Сообщения. Ну или к примеру Пост и комментарии к нему.
Конечно представить себе тысячи комментов сложно, но вот тысячи сообщений в чате легко. Ну или еще какой любой пример. Мы сейчас не касаемся каких-то моментов, бизнес логики и т.д.. Просто есть корень агрегат - Диалог и сущность Сообщения в нем. Код - ПРИМЕР :
class Dialog {
//ArrayCollection
private $messages;
public function add(Message $message) {
$this->messages->add($message);
}
public function get(int $id) {
$this->messages->get($id);
}
...тут может быть и дальше еще удаление, редактирование и т.д.
}
На сколько я знаю, сам агрегат выбирается из базы с уже заполненными связями. И как это будет выглядеть если сообщений там тысячи непонятно. Конечно, можно сказать что можно использовать ленивую загрузку и дергать сообщения только когда происходит какая-то логика с сообщениями ($this->messages->....)
Но такой подход все равно к примеру, при добавлении или поиске сообщения - загрузит все сообщения в коллекцию $message из базы. И получается все равно ты выгружаешь всю пачку. Что скажется на производительности.
Как обычно такие вещи решаются?