Доступна оплата мастер-классов российскими и иностранными картами, ЮMoney, быстрыми платежами СБП и SberPay

Доменные сущности и 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() поведения подписаться смогут.

Ответить

 

antin_z

А 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() отдельно:

$employee = $this->employees->get(1);
$employee->rename('Vasya');
$this->flusher->flush();
Ответить

 

Алексей

Дмитрий, скажите, получается Doctrine нарушает концепцию DDD? Получается мы делаем маппинг сущности (ну или агрегата). Сущность в DDD - это класс, который не должен никак зависеть или знать о внешних вещах, таких как фреймворки или БД.А тут, в конечном итоге, получается сущность - отражение строки в БД или строк если это агргат или солянка из VO.

Ответить

 

Дмитрий Елисеев

В этом плане сам код сущности здесь почти ничего не нарушает. Весь маппинг и код по сохранению и доставанию из БД спрограммирован снаружи сущности, а не внутри. И сам класс сущности до сих пор не знает, куда и как его сохраняют (в отдельные таблицы, в одну через embedded или в JSON-поле).

А так да, самый чистый вариант - это написание вручную своего репозитория. А если берём готовые ORM, то приходится немного подстраивать код под них.

Ответить

 

Дмитрий Елисеев

Добавил в статью альтернативный вариант хранения VO во вспомогательных сущностях.

Ответить

 

Максим

Ошибки в тексте

> И если мы захотим сериализовать это событие через json_encode, то столкнёмся с ощибками сериализации сложного объекта с циклическими зависимостями.

оЩибками -> ошибками

> Так что поле $id для первичного ключа и связь $employee внутри Phone и Status мам иногда могут мешать.

Мам -> нам

Ответить

 

Telkom University – mmpjj.telkomuniversity.ac.id

Какие преимущества предоставляют доменные сущности в контексте Doctrine ORM? regard Telkom University

Ответить

Оставить комментарий

Войти | Завести аккаунт | Войти через


(никто не увидит)





Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>