Реализация репозитория для доменных сущностей

Итак, продолжим! Мы уже немного научились проектировать сущности на примере Employee в первой части и даже подготовили небольшой прикладной сервис EmployeeService во второй. И договорились, что нам для хранения доменных сущностей в базе нужно сделать некий репозиторий. И даже сделали его тестовый эмулятор и подготовили работающие тесты. Перед изучением каких-либо готовых решений (чтобы понимать их суть) сегодня навелосипедим собственную реализацию репозитория без использования сторонних ORM-систем.

Реализовывать его будем по тому же интерфейсу:

interface EmployeeRepository
{
    /**
     * @param EmployeeId $id
     * @return Employee
     * @throws NotFoundException
     */
    public function get(EmployeeId $id);
 
    /**
     * @param Employee $employee
     */
    public function add(Employee $employee);
 
    /**
     * @param Employee $employee
     */
    public function save(Employee $employee);
 
    /**
     * @param Employee $employee
     */
    public function remove(Employee $employee);
 
    /**
     * @return EmployeeId
     */
    public function nextId();
}

Готовые тесты нам намного упростят задачу, так как не придётся проверять все методы вручную.

Сочинять SQL-запросы многие вполне себе умеют, если стоит задача сохранить какую-то простую строку. Но в реальном мире дело обстоит сложнее, так как порой нужно производить совсем не тривиальное...

Объектно-реляционное преобразование

Суть любой ORM-системы (Object-Relational Mapping, Объектно-Реляционное Преобразование) – обеспечить хранение объектов в реляционной (табличной) базе данных, победив так называемый объектно-реляционный импеданс, обозначающий несоответствие структуры объекта структуре базы и наоборот.

То есть, простыми словами, задача состоит в необходимости построить преобразователь данных (Data Mapper) из объекта в БД и обратно.

У нас импеданс ярко выражен тем, что нужно объект с древовидной структурой как в этом JSON-виде:

{
    id: 25
    name: {
        last: 'Пупкин',
        first: 'Василий',
        middle: 'Петрович',
    },
    address: {
        country: 'Россия',
        state: 'Липецкая обл.',
        city: 'г. Пушкин',
        street: 'ул. Ленина',
        house: 25
    }
    phones: [
        {country: 7, code: 920, number: 0000001},
        {country: 7, code: 910, number: 0000002},
    ],
    createDate: '2016-04-12 12:34:00',
    statuses: [
        {status: 'active', date: '2016-04-12 12:34:07'},
        {status: 'archive', date: '2016-04-13 12:56:23'},
        {status: 'active', date: '2016-04-16 14:02:10'},
    ];
}

отобразить на плоскую базу данных, чаще состоящую из трёх таблиц:

employees:
    id
    name_last
    name_first
    name_middle
    address_country
    address_state
    address_city
    address_street
    address_house
    create_date
    curent_status

phones:
    id
    employee_id
    country
    code
    number

statuses:
    id
    employee_id
    date
    value

Как альтернатива реляционным хранилищам можно использовать нереляционные документо-ориентированные, чтобы структуру сущности один-в-один преобразовать в вышеприведённый JSON-документ с помощью системы ODM (Object-Document Mapping) и сохранить прямо как есть под своим идентификатором:

$mongoDb->put('employees', 25, json_encode([
    'id' => 25,
    'name' => [
        'last' => 'Пупкин',
        'first' => 'Василий',
        'middle' => 'Петрович',
    ],
    ...
]));

Но с NoSQL-базами получаем проблемы с согласованностью (отсутствие транзакций и контроля внешних ключей) при наличии связей вроде поля company_id, ссылающегося на компанию из коллекции companies.

С появлением более-менее индексируемых JSON-полей (если нужен поиск по содержимому) или уже давно в виде текстового поля (если не нужен) можно сделать гибридную схему, где в SQL-базе адрес, телефоны и статусы записывать прямо в таблицу сотрудников в виде сериализованной JSON-строки:

employee:
    id
    name_last
    name_first
    name_middle
    address_json
    create_date
    curent_status
    phones_json
    statuses_json

Это позволит обойтись без JOIN-ов при выборке. В нашем примере мы можем так сделать, но в некоторых базах это вызовет те же проблемы с невозможностью проставить внешние ключи из содержимого сериализованных колонок, если у телефонов будет ещё какое-то поле вроде type_id, ссылающееся на другую таблицу. Так что если нужны внешние ключи, то JSON кое-где не справится.

Сегодня мы попробуем вручную реализовать несколько способов хранения:

  • Хранение данных в трёх таблицах сотрудников, телефонов и статусов;
  • Хранение в трёх таблицах с реализацией «ленивой» загрузки;
  • Сохранение статусов в JSON-поле statuses таблицы сотрудников.

В следующих статьях мы будем следовать тоже этому плану. Приступим!

Реализация репозитория

Итак, со списком методов мы уже определились в прошлой части. Теперь осталось только создать класс:

class SqlEmployeeRepository implements EmployeeRepository
{
    public function get(EmployeeId $id) { ... }
    public function add(Employee $employee) { ... }
    public function save(Employee $employee) { ... }
    public function remove(Employee $employee) { ... }
    public function nextId() { ... }
}

Начнём с метода вставки записи add(). Что он должен делать? Примерно это:

  • принять от нас сохраняемый агрегат $employee,
  • извлечь id, имя, адрес и дату создания сотрудника и сохранить в таблицу employee;
  • извлечь телефонные номера $employee->getPhones() и сохранить в таблицу employee_phones;
  • извлечь историю статусов $employee->getStatuses() и сохранить в таблицу employee_status;
  • произвести все действия в одной транзакции.

Для работы с БД мы будем использовать объект $db и построитель запросов фреймворка, но никто не мешает сочинять голые SQL-запросы с использованием PDO или mysqli.

Первым делом, открываем транзакцию:

namespace app\repositories;
...
use yii\db\Connection;
use yii\db\Query;
 
class SqlEmployeeRepository implements EmployeeRepository
{
    private $db;
 
    public function __construct(Connection $db)
    {
        $this->db = $db;
    }
 
    ...
 
    public function add(Employee $employee)
    {    
        $this->db->transaction(function () use ($employee) {
            ...        
        });
    }
}

Далее извлекаем все скалярные данные из сущности для полей базы данных и делаем INSERT:

public function add(Employee $employee)
{    
    $this->db->transaction(function () use ($employee) {
 
        $this->db->createCommand()->insert('{{%sql_employees}}', [
            'id' => $employee->getId()->getId(),
            'create_date' => $employee->getCreateDate()->format('Y-m-d H:i:s'),
            'name_last' => $employee->getName()->getLast(),
            'name_middle' => $employee->getName()->getMiddle(),
            'name_first' => $employee->getName()->getFirst(),
            'address_country' => $employee->getAddress()->getCountry(),
            'address_region' => $employee->getAddress()->getRegion(),
            'address_city' => $employee->getAddress()->getCity(),
            'address_street' => $employee->getAddress()->getStreet(),
            'address_house' => $employee->getAddress()->getHouse(),
            'current_status' => end($statuses)->getValue(),
        ])->execute();
 
        ...
    });
}

Да-да. Здесь нам приходится вручную обрабатывать каждое поле и перегонять его в нужный формт (как в примере с датой create_date).

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

Далее однократными пакетными запросами вставим строки телефонов и строки статусов:

public function add(Employee $employee)
{    
    $this->db->transaction(function () use ($employee) {
 
        ...
 
        $this->db->createCommand()
            ->batchInsert( '{{%sql_employee_phones}}', ['employee_id', 'country', 'code', 'number'],
                array_map(function (Phone $phone) use ($employee) {
                    return [
                        'employee_id' => $employee->getId()->getId(),
                        'country' => $phone->getCountry(),
                        'code' => $phone->getCode(),
                        'number' => $phone->getNumber(),
                    ];
                }, $employee->getPhones())
            )->execute();
 
        $this->db->createCommand()
            ->batchInsert('{{%sql_employee_statuses}}', ['employee_id', 'value', 'date'],
                array_map(function (Status $status) use ($employee) {
                    return [
                        'employee_id' => $employee->getId()->getId(),
                        'value' => $status->getValue(),
                        'date' => $status->getDate()->format('Y-m-d H:i:s'),
                    ];
                }, $employee->getStatuses())
            )->execute();           
    });
}

Эти конструкции сформируют обычный пакетный запрос с телефонами:

INSERT INTO employee_phones (employee_id, country, code, number) VALUES
(25, 7, '920', '0000001'),
(25, 7, '921', '0000002'),
(25, 7, '915', '0000004'),
(25, 7, '920', '0000003'),
(25, 7, '910', '0000005');

и аналогичный со статусами.

Практически аналогичный код у нас будет в методе save(), но он будет выполнять не INSERT, а UPDATE-запрос. Поэтому удобно будет повторяющийся код вынести с общие методы.

В связи с сохранением вложенных объектов часто возникает вопрос, как можно в агрегате отслеживать изменения внутренних элементов и как их при этом сохранять. Например, что делать, если в агрегате появился новый телефон или удалился один из старых? Здесь возможны два варианта:

  • Если это полноценные сущности (с идентификатором), на которые по какой-то причине могут ссылаться внешними ключами записи других таблиц в БД, то в момент запроса из базы в методе get() можно запомнить копию массива строк в приватном поле репозитория $this->items[$employeeId]['phones'], а потом (при сохранении в методе save()) сравнить новые телефоны с массивом старых функцией array_udiff и добавить/удалить/обновить только отличающиеся.
  • Если это просто массив элементов, никому снаружи не нужных, то можно просто очистить все старые строки телефонов по employee_id и вставить заново.

Телефоны и статусы сотрудника можно спокойно удалять, так как они никому больше не нужны. Поэтому при объединении мы пойдём вторым путём:

class SqlEmployeeRepository implements EmployeeRepository
{
    ...
 
    public function add(Employee $employee)
    {
        $this->db->transaction(function () use ($employee) {
            $this->db->createCommand()
                ->insert('{{%sql_employees}}', self::extractEmployeeData($employee))
                ->execute();
            $this->updatePhones($employee);
            $this->updateStatuses($employee);
        });
    }
 
    public function save(Employee $employee)
    {
        $this->db->transaction(function () use ($employee) {
            $this->db->createCommand()
                ->update(
                    '{{%sql_employees}}',
                    self::extractEmployeeData($employee),
                    ['id' => $employee->getId()->getId()]
                )->execute();
            $this->updatePhones($employee);
            $this->updateStatuses($employee);
        });
    }
 
    public function remove(Employee $employee)
    {
        $this->db->createCommand()
            ->delete('{{%sql_employees}}', ['id' => $employee->getId()->getId()])
            ->execute();
    }
 
    private static function extractEmployeeData(Employee $employee)
    {
        $statuses = $employee->getStatuses();
 
        return [
            'id' => $employee->getId()->getId(),
            'create_date' => $employee->getCreateDate()->format('Y-m-d H:i:s'),
            'name_last' => $employee->getName()->getLast(),
            'name_middle' => $employee->getName()->getMiddle(),
            'name_first' => $employee->getName()->getFirst(),
            'address_country' => $employee->getAddress()->getCountry(),
            'address_region' => $employee->getAddress()->getRegion(),
            'address_city' => $employee->getAddress()->getCity(),
            'address_street' => $employee->getAddress()->getStreet(),
            'address_house' => $employee->getAddress()->getHouse(),
            'current_status' => end($statuses)->getValue(),
        ];
    }
 
    private function updatePhones(Employee $employee)
    {
        $this->db->createCommand()
            ->delete('{{%sql_employee_phones}}', ['employee_id' => $employee->getId()->getId()])
            ->execute();
 
        if ($employee->getPhones()) {
            $this->db->createCommand()
                ->batchInsert('{{%sql_employee_phones}}', ['employee_id', 'country', 'code', 'number'],
                    array_map(function (Phone $phone) use ($employee) {
                        return [
                            'employee_id' => $employee->getId()->getId(),
                            'country' => $phone->getCountry(),
                            'code' => $phone->getCode(),
                            'number' => $phone->getNumber(),
                        ];
                    }, $employee->getPhones()))
                ->execute();
        }
    }
 
    private function updateStatuses(Employee $employee)
    {
        $this->db->createCommand()
            ->delete('{{%sql_employee_statuses}}', ['employee_id' => $employee->getId()->getId()])
            ->execute();
 
        if ($employee->getStatuses()) {
            $this->db->createCommand()
                ->batchInsert('{{%sql_employee_statuses}}', ['employee_id', 'value', 'date'],
                    array_map(function (Status $status) use ($employee) {
                        return [
                            'employee_id' => $employee->getId()->getId(),
                            'value' => $status->getValue(),
                            'date' => $status->getDate()->format('Y-m-d H:i:s'),
                        ];
                    }, $employee->getStatuses()))
                ->execute();
        }
    }
}

В общих методах updatePhones и updateStatuses мы просто «дропаем» все старые строки и вставим новые.

И заодно добавили метод remove для удаления сотрудника. При создании таблиц мы потом проставим внешние ограничения с каскадным удалением, чтобы связанные телефоны и статусы удалялись автоматически.

Восстановление объекта из БД

Осталось реализовать только метод get($id), в котором необходимо восстановить объект класса Employee, заполненный данными из базы. Сделать SQL-запросы у нас не составит труда:

public function get(EmployeeId $id)
{
    $employee = (new Query())->select('*')
        ->from('{{%sql_employees}}')
        ->andWhere(['id' => $id->getId()])
        ->one($this->db);
 
    if (!$employee) {
        throw new NotFoundException('Employee not found.');
    }
 
    $phones = (new Query())->select('*')
        ->from('{{%sql_employee_phones}}')
        ->andWhere(['employee_id' => $id->getId()])
        ->orderBy('id')
        ->all($this->db);
 
    $statuses = (new Query())->select('*')
        ->from('{{%sql_employee_statuses}}')
        ->andWhere(['employee_id' => $id->getId()])
        ->orderBy('id')
        ->all($this->db);
 
    return ...;
}

Гораздо интереснее понять, как теперь эти данные в объект поместить.

Действительно, у нас есть два нюанса:

  • Все поля объекта Employee приватные, у них нет сеттеров для записи значений;
  • Конструктор содержит особую логику, которая при извлечении нам не нужна.

Действительно, создавать объект через new Employee(...) мы здесь не можем, так как конструктор устанавливает начальные значения и генерирует событие создания:

class Employee implements AggregateRoot
{
    ...
 
    public function __construct(EmployeeId $id, Name $name, Address $address, array $phones)
    {
        $this->id = $id;
        $this->name = $name;
        $this->address = $address;
        $this->phones = new Phones($phones);
        $this->createDate = new \DateTimeImmutable();
        $this->addStatus(Status::ACTIVE, $this->createDate);
        $this->recordEvent(new Events\EmployeeCreated($this->id));
    }
 
    ...
}

Для решения таких проблем во многих языках (включая PHP) имеется инструменты рефлексии, которыми можно работать с классами и объектами на более «продвинутом» уровне. А именно, можно создавать новые объекты без использования конструктора:

$reflection = new \ReflectionClass(Employee::class);
$employee = $reflection->newInstanceWithoutConstructor();

И напрямую работать с приватными полями, предварительно сделав их доступными на изменение через рефлексию:

$reflection = new \ReflectionClass(Employee::class);
$property = $reflection->getProperty('id');
$property->setAccessible(true);
$property->setValue($employee, new EmployeeId(25));

Удобная вещь. Она понадобится нам для всех сущностей. Чтобы не копировать один и тот же код во все репозитории, можно вынести его в отдельный класс Hydrator:

namespace app\repositories;
 
class Hydrator
{
    public function hydrate($class, array $data)
    {
        $reflection = new \ReflectionClass($class);
        $target = $reflection->newInstanceWithoutConstructor();
        foreach ($data as $name => $value) {
            $property = $reflection->getProperty($name);
            $property->setAccessible(true);
            $property->setValue($target, $value);
        }
        return $target;
    }
}

и пользоваться им в репозитории, передавая имя класса и массив значений для заполнения:

$employee = $this->hydrator->hydrate(Employee::class, [
    'id' => new EmployeeId(25),
    'name' => new Name(...),
    'address' => new Address(...),
    ...
]);
return $employee;

Так рефлексия нам поможет воссоздать объект в методе get($id). Но есть небольшое неудобство в том, что она работает сравнительно медленно при вызове new \ReflectionClass($class). Это будет заметно при создании тысяч объектов. Чтобы повысить производительность можно по примеру SamDark/Hydrator создать объект рефлексии всего один раз и поместить в приватное поле. И можно лишний раз не вызывать $property->setAccessible(true) для публичных свойств, так как они и так доступны.

В итоге оптимизированный класс гидратора окажется таким:

namespace app\repositories;
 
class Hydrator
{
    private $reflectionClassMap;
 
    public function hydrate($class, array $data)
    {
        $reflection = $this->getReflectionClass($class);
        $target = $reflection->newInstanceWithoutConstructor();
        foreach ($data as $name => $value) {
            $property = $reflection->getProperty($name);
            if ($property->isPrivate() || $property->isProtected()) {
                $property->setAccessible(true);
            }
            $property->setValue($target, $value);
        }
        return $target;
    }
 
    private function getReflectionClass($className)
    {
        if (!isset($this->reflectionClassMap[$className])) {
            $this->reflectionClassMap[$className] = new \ReflectionClass($className);
        }
        return $this->reflectionClassMap[$className];
    }
}

Теперь передадим гидратор в репозиторий и с его помощью заполним наш объект:

class SqlEmployeeRepository implements EmployeeRepository
{
    private $db;
    private $hydrator;
 
    public function __construct(Connection $db, Hydrator $hydrator)
    {
        $this->db = $db;
        $this->hydrator = $hydrator;
    }
 
    public function get(EmployeeId $id)
    {
        $employee = (new Query())->select('*')
            ->from('{{%sql_employees}}')
            ->andWhere(['id' => $id->getId()])
            ->one($this->db);
 
        if (!$employee) {
            throw new NotFoundException('Employee not found.');
        }
 
        $phones = (new Query())->select('*')
            ->from('{{%sql_employee_phones}}')
            ->andWhere(['employee_id' => $id->getId()])
            ->orderBy('id')
            ->all($this->db);
 
        $statuses = (new Query())->select('*')
            ->from('{{%sql_employee_statuses}}')
            ->andWhere(['employee_id' => $id->getId()])
            ->orderBy('id')
            ->all($this->db);
 
        return $this->hydrator->hydrate(Employee::class, [
            'id' => new EmployeeId($employee['id']),
            'name' => new Name(
                $employee['name_last'],
                $employee['name_first'],
                $employee['name_middle']
            ),
            'address' => new Address(
                $employee['address_country'],
                $employee['address_region'],
                $employee['address_city'],
                $employee['address_street'],
                $employee['address_house']
            ),
            'createDate' => new \DateTimeImmutable($employee['create_date']),
            'phones' => new Phones(array_map(function ($phone) {
                return new Phone(
                    $phone['country'],
                    $phone['code'],
                    $phone['number']
                );
            }, $phones)),
            'statuses' => array_map(function ($status) {
                return new Status(
                    $status['value'],
                    new \DateTimeImmutable($status['date'])
                );
            }, $statuses),
        ]);
    }
 
    ...
}

Пока мы напрямую вызываем new у наших объектов-значений, чтобы не пользоваться медленной рефлексией. Но в какой-то момент времени у нас может получиться так, что конструктор класса Address изменится и начнёт требовать обязательного заполнения номера дома, и new Address вдруг начнёт ругаться с InvalidArgumentException на пустые старые значения из базы данных. Или станет необходимо заполнение двух телефонов вместо одного, и вызов new Phones будет бросать исключение класса DomainException.

Чтобы полностью игнорировать такие опасные или слишком долгие проверки в конструкторах можно и все внутренние объекты вместо new Phones(...) создавать через рефлексию:

'phones' => $this->hydrator->hydrate(Phones::class, [
    'phones' => (array_map(function ($phone) {
        return $this->hydrator->hydrate(Phone::class, (
            $phone['country'],
            $phone['code'],
            $phone['number']
        );
    }, $phones)),
]),

Это, возможно, и замедлит работу на несколько микросекунд, но на объёмах до тысяч объектов это не заметно.

Проверка работы

Попробуем написанный репозиторий в действии. Напишем миграцию для создания нужных нам таблиц:

use yii\db\Migration;
 
class m170401_060956_create_sql_tables extends Migration
{
    public function up()
    {
        $this->createTable('{{%sql_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(16)->notNull(),
        ]);
 
        $this->addPrimaryKey('pk-sql_employees', '{{%sql_employees}}', 'id');
 
        $this->createTable('{{%sql_employee_phones}}', [
            'id' => $this->primaryKey(),
            'employee_id' => $this->char(36)->notNull(),
            'country' => $this->integer()->notNull(),
            'code' => $this->string()->notNull(),
            'number' => $this->string()->notNull(),
        ]);
 
        $this->createIndex('idx-sql_employee_phones-employee_id', '{{%sql_employee_phones}}', 'employee_id');
        $this->addForeignKey('fk-sql_employee_phones-employee', '{{%sql_employee_phones}}', 'employee_id', '{{%sql_employees}}', 'id', 'CASCADE', 'RESTRICT');
 
        $this->createTable('{{%sql_employee_statuses}}', [
            'id' => $this->primaryKey(),
            'employee_id' => $this->char(36)->notNull(),
            'value' => $this->string(32)->notNull(),
            'date' => $this->dateTime()->notNull(),
        ]);
 
        $this->createIndex('idx-sql_employee_statuses-employee_id', '{{%sql_employee_statuses}}', 'employee_id');
        $this->addForeignKey('fk-sql_employee_statuses-employee', '{{%sql_employee_statuses}}', 'employee_id', '{{%sql_employees}}', 'id', 'CASCADE', 'RESTRICT');
    }
 
    public function down()
    {
        $this->dropTable('{{%sql_employee_statuses}}');
        $this->dropTable('{{%sql_employee_phones}}');
        $this->dropTable('{{%sql_employees}}');
    }
}

Здесь для первичного UUID-ключа мы указали тип CHAR(36), но если объёмы большие и очень хочется скорости, то можете поковыряться с трансформацией UUID-строки в BINARY(16).

Применим миграцию к тестовой базе данных:

php tests/bin/yii migrate

Для автоматичекой очистки тестовых таблиц от предыдущего мусора создадим классы фикстур:

namespace tests\_fixtures;
 
use yii\test\ActiveFixture;
 
class EmployeeFixture extends ActiveFixture
{
    public $tableName = '{{%sql_employees}}';
    public $dataFile = '@tests/_fixtures/data/employees.php';
}
class EmployeePhoneFixture extends ActiveFixture
{
    public $tableName = '{{%sql_employee_phones}}';
    public $dataFile = '@tests/_fixtures/data/employee_phones.php';
}
use yii\test\ActiveFixture;
 
class EmployeeStatusFixture extends ActiveFixture
{
    public $tableName = '{{%sql_employee_statuses}}';
    public $dataFile = '@tests/_fixtures/data/employee_statuses.php';
}

с пустыми данными:

return [
 
];

в файлах employees.php, employee_phones.php и employee_statuses.php в папке tests/_fixtures/data.

Теперь напишем тест, создающий наш репозиторий для придуманного в прошлый раз общего тестового базового класса:

namespace tests\unit\repositories;
 
use app\repositories\SqlEmployeeRepository;
use app\repositories\Hydrator;
use app\tests\_fixtures\EmployeeFixture;
use app\tests\_fixtures\EmployeePhoneFixture;
use app\tests\_fixtures\EmployeeStatusFixture;
 
class SqlEmployeeRepositoryTest extends BaseRepositoryTest
{
    /**
     * @var \UnitTester
     */
    public $tester;
 
    public function _before()
    {
        $this->tester->haveFixtures([
            'employee' => EmployeeFixture::className(),
            'phone' => EmployeePhoneFixture::className(),
            'status' => EmployeeStatusFixture::className(),
        ]);
 
        $this->repository = new SqlEmployeeRepository(\Yii::$app->db, new Hydrator());
    }
}

Указание этих фикстур с пустыми данными будет очищать базу перед каждым тестом.

И запустим его:

vendor/bin/codecept run unit repositories/SqlEmployeeRepositoryTest

Unit Tests (5) ---------------------------------------------
 SqlEmployeeRepositoryTest: Get (0.06s)
 SqlEmployeeRepositoryTest: Get not found (0.02s)
 SqlEmployeeRepositoryTest: Add (0.02s)
 SqlEmployeeRepositoryTest: Save (0.02s)
 SqlEmployeeRepositoryTest: Remove (0.02s)
------------------------------------------------------------

Time: 286 ms, Memory: 6.00MB

OK (5 tests, 14 assertions)

Как видим, всё получилось. Мы пойдём дальше, а противники написания тестов могут по привычке проверить это всё вручную.

Ленивая загрузка

Сейчас у нас Employee небольшой. Но в нём могут быть большие груды телефонов, фотографий, атрибутов, адресов, идентификаторов друзей и прочих объектов. Делать десятки запросов в базу каждый раз и загружать лишнюю информацию из всех таблиц весьма затратно, если нам требуется всего лишь вызвать метод changeAddress сотрудника.

Как поступать в этой ситуации? Для решения проблем с производительностью мы можем сделать так, чтобы дополнительные данные подгружались из базы не сразу (жадно), а только по просьбе, когда они будут нужны (лениво). Значит нам нужно научить phones и statuses производить отложенную загрузку.

Поищем для этого полезные паттерны. Представим, что у нас есть некий класс по работе с какой-нибудь жуткой БД:

interface DbInterface
{
    public function queryAll($sql);
    public function queryOne($sql);
}
 
class Db implements DbInterface
{
    public function __construct($params) { ... }
    public function queryAll($sql) { ... }
    public function queryOne($sql) { ... }
}

и этот класс подключается к ней в конструкторе, когда мы создаём объект:

$db = new Db($params);
 
if (...) {
    $result = $db->queryAll(...);
}

Но вдруг эта БД такая медленная, что этим подключением жутко тормозит наш процесс, даже когда if не срабатывает и методы queryAll дёргать не надо. Тогда что мы можем с этим сделать?

Давайте рядом с оригинальным классом Db сделаем похожую на него обёртку DbProxy, которая будет иметь те же самые методы из интерфейса DbInterface, и которой мы будем передавать функцию создания оригинального объекта класса Db:

class DbProxy implements DbInterface
{
    private $original;
    private $factory;
 
    public function __construct(callable $factory)
    {
        $this->factory = $factory;
    }
 
    public function queryAll($sql)
    {
        return $this->getOriginal()->queryAll($sql);
    }
 
    public function queryOne($sql)
    {
        return $this->getOriginal()->queryOne($sql);    
    }
 
    private function getOriginal()
    {
        if ($this->original === null) {
            $this->original = call_user_func($this->factory);
        }
        return $this->original;
    }
}

И теперь вместо создания оригинального экземпляра Db будем использовать эту замену:

$db = new DbProxy(function () use ($params) {
    return new Db($params);
});

Как видно в коде DbProxy, он сохранит эту функцию у себя и выполнит её запуском $this->getOriginal() только когда мы дёрнем любой из методов queryAll и queryOne.

При наличии интерфейса DbConnection такой прокси-объект сделать достаточно легко. Если же интерфейса нет, то для совместимости типов придётся наследовать DbProxy от самого класса Db.

Инструмент получился удобный, но с ним нам придётся делать отдельный прокси-класс для каждого класса, который мы хотим обернуть. Но можно развить тему дальше.

Что будет общего у DbProxy, RestProxy и других подобных классов? У них будет приватное поле для хранения анонимной функции и поле для хранения оригинального объекта. И будет набор методов, построенных на основе оригинальных методов и вызывающих getOriginal. А что если это автоматизировать? Что если сделать такую функцию createProxy, которая при передаче ей имени класса Db сама бы через рефлексию получала список его методов и генерировала на лету новый Proxy-класс, наследующийся от оригинального? И потом мы бы вызывали её так:

$proxyFactory = new ProxyFactory();
 
$db = $proxyFactory->createProxy(Db::class, function () use ($params) {
    return new Db($params);
});

Было бы полезно. Это бы сразу решило проблему проксирования любых классов.

Дабы не сочинять такую вещь самим можем взять готовый компонент Ocramius/ProxyManager, работающий по такому же принципу. Ему нужен PHP 7, но сейчас он почти везде, так что это не проблема. Установим:

composer require ocramius/proxy-manager

С ним мы теперь можем почти также подменять оригинальные объекты на объекты-прокси, только вызов будет немного другой:

$proxyFactory = new LazyLoadingValueHolderFactory();
 
$db = $proxyFactory->createProxy(Db::class, function (&$target, LazyLoadingInterface $proxy) use ($params) {
    $target = new Db($params);
    $proxy->setProxyInitializer(null);
});

Здесь мы должны не забыть изнутри самостоятельно удалить нашу анонимку вызовом $proxy->setProxyInitializer(null), чтобы наша функция не запускалась снова и снова.

Теперь приступим к проксированию наших связей. Начнём с телефонов.

Во-первых, получим эту фабрику в конструктор:

use ProxyManager\Factory\LazyLoadingValueHolderFactory;
 
class SqlEmployeeRepository implements EmployeeRepository
{
    private $db;
    private $hydrator;
    private $lazyFactory;
 
    public function __construct(
        Connection $db,
        Hydrator $hydrator,
        LazyLoadingValueHolderFactory $lazyFactory
    )
    {
        $this->db = $db;
        $this->hydrator = $hydrator;
        $this->lazyFactory = $lazyFactory;
    }
 
    ...
}

Во-вторых, в методе get при извлечении из базы поменяем new Phones(...) на создание прокси-объекта для Phones::class и поместим запрос на загрузку телефонов внутрь в анонимную функцию:

class SqlEmployeeRepository implements EmployeeRepository
{
    ...
 
    public function get(EmployeeId $id)
    {
        $employee = (new Query())->select('*')
            ->from('{{%sql_employees}}')
            ->andWhere(['id' => $id->getId()])
            ->one($this->db);
 
        if (!$employee) {
            throw new NotFoundException('Employee not found.');
        }
 
        return $this->hydrator->hydrate(Employee::class, [
            'id' => new EmployeeId($employee['id']),
            ...
            'createDate' => new \DateTimeImmutable($employee['create_date']),
            'phones' => $this->lazyFactory->createProxy(
                Phones::class,
                function (&$target, LazyLoadingInterface $proxy) use ($id) {
                    $phones = (new Query())->select('*')
                        ->from('{{%sql_employee_phones}}')
                        ->andWhere(['employee_id' => $id->getId()])
                        ->orderBy('id')
                        ->all($this->db);
                    $target = new Phones(array_map(function ($phone) {
                        return new Phone(
                            $phone['country'],
                            $phone['code'],
                            $phone['number']
                        );
                    }, $phones));
                    $proxy->setProxyInitializer(null);
                }
            ),
            'statuses' => ...,
        ]);
    }
 
    ...
}

Теперь как только в объекте $employee произойдёт любое обращение к методу прокси-объекта вроде $this->phones->add($phone), сразу выполнится SQL-запрос в таблицу телефонов, внутри прокси восстановится оригинальный объект класса Phones и вызовется его метод add($phone).

В третьих, необходимо модифицировать метод updatePhones, чтобы он сам не обновлял телефоны без необходимости.

Банальный вызов $employee->getPhones() вернёт $this->phones->getAll() прокси-объекта, что сразу же запустит весь процесс загрузки из БД. Поэтому напрямую обращаться через геттер getPhones() мы не можем. Вместо этого в гидратор можно добавить ещё метод extract, который будет извлекать значение приватного поля из объекта:

class Hydrator
{
    private $reflectionClassMap;
 
    public function hydrate($class, array $data) { ... }
 
    public function extract($object, array $fields)
    {
        $result = [];
        $reflection = $this->getReflectionClass(get_class($object));
        foreach ($fields as $name) {
            $property = $reflection->getProperty($name);
            if ($property->isPrivate() || $property->isProtected()) {
                $property->setAccessible(true);
            }
            $result[$property->getName()] = $property->getValue($object);
        }
        return $result;
    }
 
    private function getReflectionClass($className) { ... }
}

С ним мы можем уже безболезненно извлечь прокси-объект:

$data = $this->hydrator->extract($employee, ['phones']);
$phones = $data['phones'];

Далее нужно определить, загрузил он уже телефоны из БД или нет. Используемая нами библиотека ProxyManager генерирует прокси-объекты и добавляет к ним реализацию интерфейса LazyLoadingInterface. Поэтому можно легко отличить, что это именно прокси-объект (а не оригинал) и методом isProxyInitialized проверить, сработал он или нет:

if ($phones instanceOf LazyLoadingInterface && !$phones->isProxyInitialized()) {
    // Это прокси-объект. Оригинальные данные не загружены. Ничего не делаем.
} else {
    // Это новый объект new Phones(...) из конструктора или сработавший прокси-объект. Сохраняем.
}

Соответственно, добавляем эти проверки в метод updatePhones:

use ProxyManager\Proxy\LazyLoadingInterface;
 
class SqlEmployeeRepository implements EmployeeRepository
{
    ...
 
    private function updatePhones(Employee $employee)
    {
        $data = $this->hydrator->extract($employee, ['phones']);
        $phones = $data['phones'];
 
        if ($phones instanceOf LazyLoadingInterface && !$phones->isProxyInitialized()) {
            return;
        }
 
        $this->db->createCommand()
            ->delete('{{%sql_employee_phones}}', ['employee_id' => $employee->getId()->getId()])
            ->execute();
 
        if ($employee->getPhones()) {
            $this->db->createCommand()
                ->batchInsert('{{%sql_employee_phones}}', ['employee_id', 'country', 'code', 'number'],
                    array_map(function (Phone $phone) use ($employee) {
                        return [
                            'employee_id' => $employee->getId()->getId(),
                            'country' => $phone->getCountry(),
                            'code' => $phone->getCode(),
                            'number' => $phone->getNumber(),
                        ];
                    }, $employee->getPhones()))
                ->execute();
        }
    }
}

С телефонами разобрались. Теперь переделаем статусы.

Но с ними есть небольшая проблема. Если телефоны хранились в объекте, для которого мы легко сделали прокси-обёртку, то статусы у нас хранятся в простом массиве:

class Employee implements AggregateRoot
{
    ...
 
    private $statuses = [];
 
    ...
 
    private function getCurrentStatus()
    {
        return end($this->statuses);
    }
 
    ...
 
    public function getStatuses() { return $this->statuses; }
}

и подменить его на что-то умное у нас не получится. Чтобы выйти из этой ситуации мы можем заменить массив на объект стандартного PHP-класса ArrayObject, присвоив его в конструкторе и немного переписав геттеры:

use ArrayObject;
 
class Employee implements AggregateRoot
{
    ...
 
    private $statuses;
 
    public function __construct(EmployeeId $id, Name $name, Address $address, array $phones)
    {
        ...
 
        $this->statuses = new ArrayObject();
        $this->addStatus(Status::ACTIVE, $this->createDate);
        $this->recordEvent(new Events\EmployeeCreated($this->id));
    }
 
    ...
 
    private function getCurrentStatus()
    {
        $statuses = $this->statuses->getArrayCopy();
        return end($statuses);
    }
 
    ...
 
    public function getStatuses() { return $this->statuses->getArrayCopy(); }
}

Теперь можем также спокойно проксировать этот ArrayObject класс при поиске:

class SqlEmployeeRepository implements EmployeeRepository
{
    ...
 
    public function get(EmployeeId $id)
    {
        $employee = (new Query())->select('*')
            ->from('{{%sql_employees}}')
            ->andWhere(['id' => $id->getId()])
            ->one($this->db);
 
        if (!$employee) {
            throw new NotFoundException('Employee not found.');
        }
 
        return $this->hydrator->hydrate(Employee::class, [
            'id' => new EmployeeId($employee['id']),
            ...
            'phones' => ...,
            'statuses' => $this->lazyFactory->createProxy(
                ArrayObject::class,
                function (&$target, LazyLoadingInterface $proxy) use ($id) {
                    $statuses = (new Query())->select('*')
                        ->from('{{%sql_employee_statuses}}')
                        ->andWhere(['employee_id' => $id->getId()])
                        ->orderBy('id')
                        ->all($this->db);
                    $target = new ArrayObject(array_map(function ($status) {
                        return new Status(
                            $status['value'],
                            new \DateTimeImmutable($status['date'])
                        );
                    }, $statuses));
                    $proxy->setProxyInitializer(null);
                }
            ),
        ]);
    }
}

и аналогично обрабатывать при сохранении:

private function updateStatuses(Employee $employee)
    {
        $data = $this->hydrator->extract($employee, ['statuses']);
        $statuses = $data['statuses'];
 
        if ($statuses instanceOf LazyLoadingInterface && !$statuses->isProxyInitialized()) {
            return;
        }
 
        $this->db->createCommand()
            ->delete('{{%sql_employee_statuses}}', ['employee_id' => $employee->getId()->getId()])
            ->execute();
 
        ...
    }
}

Теперь в тесте добавим передачу в конструктор экземпляра фабрики:

namespace tests\unit\repositories;
 
use app\repositories\SqlEmployeeRepository;
use app\repositories\Hydrator;
use app\tests\_fixtures\EmployeeFixture;
use app\tests\_fixtures\EmployeePhoneFixture;
use app\tests\_fixtures\EmployeeStatusFixture;
use ProxyManager\Factory\LazyLoadingValueHolderFactory;
 
class SqlEmployeeRepositoryTest extends BaseRepositoryTest
{
    /**
     * @var \UnitTester
     */
    public $tester;
 
    public function _before()
    {
        $this->tester->haveFixtures([
            'employee' => EmployeeFixture::className(),
            'phone' => EmployeePhoneFixture::className(),
            'status' => EmployeeStatusFixture::className(),
        ]);
 
        $this->repository = new SqlEmployeeRepository(
            \Yii::$app->db,
            new Hydrator(),
            new LazyLoadingValueHolderFactory()
        );
    }
}

и запустим его:

vendor/bin/codecept run unit repositories/SqlEmployeeRepositoryTest

Unit Tests (5) ---------------------------------------------
 SqlEmployeeRepositoryTest: Get (0.08s)
 SqlEmployeeRepositoryTest: Get not found (0.01s)
 SqlEmployeeRepositoryTest: Add (0.02s)
 SqlEmployeeRepositoryTest: Save (0.02s)
 SqlEmployeeRepositoryTest: Remove (0.02s)
------------------------------------------------------------

Time: 327 ms, Memory: 6.00MB

OK (5 tests, 14 assertions)

О да! Всё работает :) Повторим ещё раз, что противники написания тестов могут снова по привычке проверить это вручную.

Поддержка JSON

Реляционные таблицы и нормальные формы Бойса-Кодда – это хорошо. Но неудобно. Надо возиться с кучей таблиц... Нет бы просто сериализовать массив в строку через json_encode или serialize и сохранить в поле JSON или TEXT... Это быстро и модно. Давайте сделаем :)

Телефоны оставим в покое. По ним может кто-то кого-то в базе искать. А история статусов для поиска никому не нужна. Её в JSON и поместим.

Напишем ещё одну миграцию для добавления поля statuses:

use yii\db\Migration;
 
class m170402_083539_add_sql_json_statuses_field extends Migration
{
    public function up()
    {
        $this->addColumn('{{%sql_employees}}', 'statuses', 'JSON');
    }
 
    public function down()
    {
        $this->dropColumn('{{%sql_employees}}', 'statuses');
    }
}

и применим:

php tests/bin/yii migrate

Мы делали для статусов объект ArrayObject. Теперь он нам не особо нужен, поэтому вернём массив как было:

use ArrayObject;
 
class Employee implements AggregateRoot
{
    ...
 
    private $statuses = [];
 
    ...
 
    private function getCurrentStatus()
    {
        return end($this->statuses);
    }
 
    ...
 
    public function getStatuses() { return $this->statuses; }
}

Теперь заполним поле сущности массивом статусов на основе раскодированного значения из поля statuses в БД:

public function get(EmployeeId $id)
{
    ...
    return $this->hydrator->hydrate(Employee::class, [
        ...
        'statuses' => array_map(function ($status) {
            return new Status(
                $status['value'],
                new \DateTimeImmutable($status['date'])
            );
        }, Json::decode($employee['statuses'])),
    ]);
}

И при записи закодируем массив обратно в JSON:

private static function extractEmployeeData(Employee $employee)
{
    $statuses = $employee->getStatuses();
 
    return [
        'id' => $employee->getId()->getId(),
        'create_date' => $employee->getCreateDate()->format('Y-m-d H:i:s'),
        ...
        'current_status' => end($statuses)->getValue(),
        'statuses' => Json::encode(array_map(function (Status $status) {
            return [
                'value' => $status->getValue(),
                'date' => $status->getDate()->format(DATE_RFC3339),
            ];
        }, $employee->getStatuses())),
    ];
}

И удалим уже не нужный метод updateStatuses, чтобы весь код оказался как у нас на GitHub в ветке sql.

Из тестов удалим EmployeeStatusFixture, так как нужны нам теперь всего две таблицы:

class SqlEmployeeRepositoryTest extends BaseRepositoryTest
{
    /**
     * @var \UnitTester
     */
    public $tester;
 
    public function _before()
    {
        $this->tester->haveFixtures([
            'employee' => EmployeeFixture::className(),
            'phone' => EmployeePhoneFixture::className(),
        ]);
 
        $this->repository = new SqlEmployeeRepository(
            \Yii::$app->db,
            new Hydrator(),
            new LazyLoadingValueHolderFactory()
        );
    }
}

и ударим автопробегом по бездорожью и разгильдяйству:

Unit Tests (5) ---------------------------------------------
 SqlEmployeeRepositoryTest: Get (0.07s)
 SqlEmployeeRepositoryTest: Get not found (0.01s)
 SqlEmployeeRepositoryTest: Add (0.02s)
 SqlEmployeeRepositoryTest: Save (0.02s)
 SqlEmployeeRepositoryTest: Remove (0.02s)
------------------------------------------------------------

Time: 316 ms, Memory: 6.00MB

OK (5 tests, 14 assertions)

пока противники написания тестов... ну вы поняли... ещё тестируют вручную репозиторий из предыдущего примера.

Регистрация в DI-контейнере

Классы написаны, библиотеки установлены. Пора настроить контейнер внедрения зависимостей, чтобы он сумел корректно иньектить объекты в конструкторы друг друга.

Сначала укажем контейнеру, какой класс в системе должен соответствовать интерфейсу EmployeeRepository:

$container = \Yii::$container;
 
$container->setSingleton(EmployeeRepository::class, SqlEmployeeRepository::class);

Конструктор нашего класса SqlEmployeeRepository должен принять три объекта:

class SqlEmployeeRepository implements EmployeeRepository
{
    private $db;
    private $hydrator;
    private $lazyFactory;
 
    public function __construct(
        Connection $db,
        Hydrator $hydrator,
        LazyLoadingValueHolderFactory $lazyFactory
    )
    {
        $this->db = $db;
        $this->hydrator = $hydrator;
        $this->lazyFactory = $lazyFactory;
    }
}

При этом Hydrator и LazyLoadingValueHolderFactory контейнер может подтянуть автоматически, а с Connection будут проблемы. Контейнер попытается создать новое пустое подключение new Connection() когда будет парсить конструктор. Вместо этого нам надо вручную подсунуть ему первым параметром Yii::$app->db как-нибудь так:

$container->setSingleton(EmployeeRepository::class, SqlEmployeeRepository::class, [
    Yii::$app->db,
    Instance::of(Hydrator::class),
    Instance::of(LazyLoadingValueHolderFactory::class),
]);

Но сразу дёргать подключение Yii::$app->db в момент конфигурации весьма глупо. Вместо этого мы можем объявить вспомогательный элемент db в контейнере и подставлять его через Instance:of:

$container->setSingleton('db', function () use ($app) {
    return $app->db;
});
 
$container->setSingleton(EmployeeRepository::class, SqlEmployeeRepository::class, [
    Instance::of('db'),
    Instance::of(Hydrator::class),
    Instance::of(LazyLoadingValueHolderFactory::class),
]);

Помимо этого нам необязательно указывать все параметры. Мы можем указать только первый:

$container->setSingleton(EmployeeRepository::class, SqlEmployeeRepository::class, [
    Instance::of('db'),
]);

а остальные спарсятся контейнером из конструктора автоматически.

Далее можно попросить создавать в единственном экземпляре гидратор и фабрику прокси-объектов:

$container->setSingleton(Hydrator::class);
$container->setSingleton(LazyLoadingValueHolderFactory::class);

В итоге полную конфигурацию контейнера можно оставить примерно такой:

namespace app\bootstrap;
 
use app\dispatchers\EventDispatcher;
use app\dispatchers\DummyEventDispatcher;
use app\repositories\Hydrator;
use app\repositories\SqlEmployeeRepository;
use app\repositories\EmployeeRepository;
use ProxyManager\Factory\LazyLoadingValueHolderFactory;
use yii\base\BootstrapInterface;
use yii\di\Instance;
 
class Bootstrap implements BootstrapInterface
{
    public function bootstrap($app)
    {
        $container = \Yii::$container;
 
        $container->setSingleton(EventDispatcher::class, DummyEventDispatcher::class);
 
        $container->setSingleton(Hydrator::class);
 
        $container->setSingleton(LazyLoadingValueHolderFactory::class);
 
        $container->setSingleton(EmployeeRepository::class, SqlEmployeeRepository::class, [
            Instance::of('db'),
        ]);
    }
}

На этом можно пока остановиться. В следующих частях реализуем сохранение сущностей с использованием Doctrine и ActiveRecord.

Следующая часть: Подключение и использование Doctrine ORM

А пока задавайте вопросы в комментариях здесь или на форуме.

Комментарии

 

anton_z

У меня работник со 100 фотками. Чтобы добавить 1 фотку придется загрузить все фотки, хотя мне они не нужны.

Ответить

 

anton_z

Можно ли это исправить, расширив функциональность прокси для коллекции?

Ответить

 

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

Тогда не делайте загрузку в методе add(), а просто добавляйте новые в приватный массив. А так не вижу смысла заморачиваться с этими "лишними" десятью килобайтами.

Ответить

 

anton_z

Ну их может быть и не 10 килобайт, а мегабайты) Тут так нельзя подходить. А что если у вас интенсивность обновления агрегата будет довольно высокой? Каждый раз все удалять/заново вставлять - так могут и первичные ключи кончиться. Для маленьких read-intensive сайтов это не имеет значения, но бывает и по-другому.

Ответить

 

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

> Ну их может быть и не 10 килобайт, а мегабайты.

Приведите пример, как насчитали мегабайт.

> А что если у вас интенсивность обновления агрегата будет довольно высокой?

Если обновление занимает 0.05 секунд, то это 20 запросов в секунду... или 1 728 000 запросов на запись в сутки. Думаю, что справлюсь.

> Каждый раз все удалять/заново вставлять - так могут и первичные ключи кончиться.

Переделаю по первому способу с array_udiff для детектирования изменений. Или сделаю составной ключ с $i + 1 в id из foreach ($phones as $i => $phone). Тогда не закончатся.

Ответить

 

anton_z

Да пожалуйста.
Система управления рекламой рекламной компании.
Сущность - баннер, у него есть связанные сущности - документы, сертификаты, лицензии. Документы хранятся в виде BLOB. Каждый от 200 кб до 800 кб. Бывает до 10 документов к одному баннеру. Так и насчитали)

Вообще я не понимаю такую постановку вопроса - "приведите пример". По вашему коду у вас количество телефонов ничем не ограничено. В БД тоже может их быть сколько угодно. Так что это не заморочки.

Ответить

 

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

> Документы хранятся в виде BLOB

Это бОльшая дикость в плане производительности, чем ООП.

> Вообще я не понимаю такую постановку вопроса - "приведите пример". По вашему коду у вас количество телефонов ничем не ограничено.

У среднестатистического человека максимум десять телефонов, пять адресов и обычно три-четыре паспорта. Это ограничено жизнью. В программном продукте вы именно реальную жизнь и моделируете. И сущности проектируете по реальным фактам, а не по перлам "а что если у человека будет миллиард паспортов". Поэтому и говорю "приведите пример".

Ответить

 

anton_z

А как же "зашита от дурака"? Если оставить возможность ввести неограниченное количестсво телефонов, то кто-нибудь обязательно ей воспользуется рано или поздно.

Ответить

 

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

Вот сплю и вижу, как Вы себе вбиваете миллион телефонов, чтобы положить чей-то сайт. Защита добавляется за двадцать секунд:

if (count($this->items) >= self::LIMIT + 1) {
    throw new \DomainException('Too many phones.');
}
Ответить

 

anton_z

Что поделаешь. Документы нужны под транзакционной защитой)

Ответить

 

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

> Что поделаешь...

Вынесу blob в таблицу files и у баннера укажу file_id.

Да и с файлами на диске транзакционность есть: транзакция не прошла - поле file_name откатилось на имя старого файла.

Ответить

 

anton_z

> Да и с файлами на диске транзакционность есть: транзакция не прошла - поле file_name откатилось на имя старого файла.

Слышали, слышали, знаем) В моем случае в файл вносятся изменения в приложении в процессе обработки записи + репликация и бэкапы проще делать) Короче, есть нюансы)

> Вынесу blob в таблицу files и у баннера укажу file_id.

Ну вот видите, Все равно не получается при проектировании проектировать чисто в терминах объектов. Все равно реляционка пролезает, никуда от нее не убежишь.

Ответить

 

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

> Ну вот видите, Все равно не получается при проектировании проектировать чисто в терминах объектов.

В объекте сделаю ленивую загрузку file. В репозитории разрулю, например, по file_id. Это только у Вас не получается.

Ответить

 

anton_z

> Это только у Вас не получается.

Вы это зачем написали? Себе на потеху?

> В объекте сделаю ленивую загрузку file. В репозитории разрулю по file_id. Это только у Вас не получается.

Сам факт того, что вы в домене создаете еще одну сущность, чтобы вынеси file в отдельную таблицу говорит о том, что реляционная модель управляет доменом и слова, что систему надо проектировать начиная с объектов, а потом уже переносить ее на БД - это просто слова, до конца от реляционки не отвяжешься. Рассогласование нагрузки не мной и не вчера доказано.

Ответить

 

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

> Сам факт того, что вы в домене создаете еще одну сущность...

Не создаю. Спроксирую у сущности методы, работающие с файлом, чтобы BLOB запрашивался только по требованию.

> Вы это зачем написали? Себе на потеху?
> Слова, что систему надо проектировать начиная с объектов, а потом уже переносить ее на БД - это просто слова, до конца от реляционки не отвяжешься.

Вы уже пятнадцатый комментарий здесь пишете о том, как у Вас "лыжи не едут".

Ответить

 

anton_z

Да все у меня едет.
Пишу комментарии, чтобы лучше понять мысли, изложенные в ваших статьях.

Ответить

 

anton_z

Вы хорошо владете проксированием. Я его как-то не брал в расчет.
Методология действительно рабочая, так что вопросов больше нет.

Ответить

 

anton_z

Вообще я склонен считать lazy load антипаттерном. Не знаю, почему его все так любят. Там используется замыкание, которое обращается к БД. БД и сеть это штуки ненадежные, могут возникать обрывы соединений и прочее. Внутри прокси будут возникать исключения, однако клиентский код про них ничего не знает и не может их обработать. Негде подписать phpdoc @throws DbException. Соответственно, подобные исключения могут происходить в самых неожиданных местах. Конечно, можно в прикладном сервисе или еще выше все обернуть в try/catch, но это будет протекание. Вспомните java и аннотации. Если у вас метод может генерировать исключение, вы обязаны либо обработать его, либо пропустить исключение выше, обозначив метод соответтсвующей аннотацией - иначе не скомпилируется.

Ответить

 

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

> Внутри прокси будут возникать исключения, однако клиентский код про них ничего не знает и не может их обработать.

Вылетит не жадно в $repository->get(...), а на строку ниже лениво в $employee->addPhone(...). Какая клиентскому коду в контроллере разница, откуда ему наверх RuntimeException прилетел?

Ответить

 

anton_z

Ну а если у вас ленивая загрузка только во view произойдет? Все в try/catch обернете?)
Жадная загрузка - тоже не выход, данных может быть очень много.
Еще могут быть случаи, когда из 1000 связанных сущностей для выполнения бизнес-операции могут быть нужны 1-100, выбранных по какому-либо критерию. Ни жадная, ни ленивая загрузка не решат эту задачу. Только отдельный репозиторий на сущность, которая хранится в отдельной таблице. Да, не кошерно, хранение влияет на домен. Но полный persitence-ignorance это сказка, которая работает в достаточно жестко ограниченных условиях.

Ответить

 

anton_z

Этим самым у вас создается тесная связь между вашим прокси и кодом обработки исключения.

Ответить

 

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

У меня нет кода обработки RuntimeException.

Ответить

 

anton_z

А пользователю тогда какое сообщение показываете? Никакого? 500 и все?

Ответить

 

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

Что в жадной загрузке база отвалится с "PDOException: Could not connect to database...", что в ленивой. Фреймворк сгенерирует по умолчанию Server Error. Разница-то в чём? А пользователю я только DomainException показываю.

Ответить

 

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

> Ну а если у вас ленивая загрузка только во view произойдет?

Для view есть ViewModel без ленивой загрузки.

> Все в try/catch обернете?)

А зачем RuntimeException оборачивать? Страница вылетит как обычно с 503 Server Error. Хоть жадно, хоть лениво.

> Еще могут быть случаи, когда из 1000 связанных сущностей...

Ну значит это отдельные сущности с client_id. Не вижу смысла делать вложенность там, где она не нужна.

Ответить

 

Патрик Фельдеш

Хорошая статья, красивая реализация! Но моменты, на которых начинается реальная свистопляска здесь не показаны, а про них знать стоит. Сохранять одну сущность или агрегат с ValueObjects легко и просто, но как только появляются связи уже между отдельными сущностями, агрегаты содержащие их и необходимость одним запросом вытакскивать и сохранять такой агрегат и все связи - вот тут уже все намного сложнее. Второй момент - наследование, реализовать Single/Class table inheritance тоже гораздо сложнее чем примеры в статье. Я это к чему - в познавательных целях знать как все работает очень важно, хотя бы для того, чтобы понимать сильные/слабые стороны той же доктрины, но писать свою ORM для чего-то более менее-сложного, но в то же время недостаточно долгого чтобы оправдать затраты времени на ее создание - не оправданно. Проверено на себе, лучше не пытаться так делать на коммерческом проекте и если нет готовых наработок сделанных за свое время и деньги.

Ответить

 

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

> но как только появляются связи уже между отдельными агрегатами и сущностями

Связь между агрегатами делают указав ID, а не вкладывая их друг в друга по "реальной" связи.

> и появляется необходимость одним запросом вытакскивать и сохранять такой агрегат и все связи

Не делайте монструозных агрегатов и так возиться с ними не придётся.

> реализовать Single/Class table inheritance тоже гораздо сложнее чем примеры в статье

Что именно сложнее? Получить get_class($object) для выбора таблицы или вписать switch($row['type']) для выбора класса?

Ответить

 

Патрик Фельдеш

> Связь между агрегатами делают указав ID, а не вкладывая их друг в друга по "реальной" связи.
> Не делайте монструозных агрегатов и так возиться с ними не придётся.

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

> Что именно сложнее? Получить get_class($object) для выбора таблицы или вписать switch($row['type']) для выбора класса?

Выбрать таблицу и класс это пол дела, нужно еще правильно соединить данные из нескольких таблиц, писать мапперы на каждого наследника - все это выливается в большое количество однотипного кода в итоге. В таких случаях нужны уже более универсальные решения, типа metadata mapping - сократит время в разы.

Я только хочу предупредить тех, кто сейчас может прочитав статью воодушевленно кинуться писать свою ORM на рабочем проекте где сроки ограничены - очень быстро начнутся трудности. Есть определенный порог сочетания сложности проекта и доступного времени на его разработку, при котором такой подход не оправдан. Лучше дождитесь следующую статью по доктрине, где это все есть и намного больше.

Ответить

 

anton_z

Извините, хотел бы уточнить. А если допустим не писать полнофункциональную ORM, а использовать какой-нибудь TableGateway? Связи сохранять в сервисе явно в транзакции, от ассоциаций отказаться,
делать все по внешним ключам в сервисах (к слову сказать, в доктрине ассоциации не быстрые, кое где можно с memory-limit вылететь по недосмотру). То есть сдвинуться немного от объектной модели обратно к реляционной, чтобы рассогласование нагрузки не так сильно ощущалось? Это тоже оверхед для средне-крупного проекта?

Ответить

 

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

В программировании всё можно. Лишь бы самому было удобно.

Ответить

 

anton_z

И задача решалась) Просто хочется узнать, как делают опытные разработчики - всегда ли путь более-менее крупного проекта лежит через ORM и отражение связей между строками в таблицах на связи между объектами (если используется РСУБД, естественно).

Ответить

 

Александр Макаров

Хорошая статья. Я бы только заменил «консистентность» на «согласованность». Это устоявшийся термин.

Ответить

 

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

Спасибо! Заменил.

Ответить

 

Руслан Гилязетдинов

Спасибо за отличную серию статей) ждем Doctrine и ActiveRecord

Ответить

 

Сергей Рейтенбах

Спасибо за цикл статей, очень полезные и нужные.

Ответить

 

Денис Танков

При публикации каждой такой статьи уровень энтропии Вселенной падает на 0.0120548 единицы Хаоса . Присоединяюсь)

Ответить

 

Юрий

В месте где

$reflection->getProperty('id');

нужно подправить на

$property = $reflection->getProperty('id');

, если Вам это важно.

Ответить

 

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

Исправил. Спасибо!

Ответить

 

Maxim Suhov

Не большая опечатка в этом блоке текста:

восстановить объект класса Eemployee

Eemployee -> Employee

Спасибо за статью!

Ответить

 

Добрый сосед

А репозиторий разве может знать про сущность? Ведь репозитории находятся уровнем ниже, и вроде бы не должны знать про слои выше него. Вот тут так же возвращают саму сущность из репозитория. И репозиторий, получается, знает про доменный слой.
Или я как то неправильно понимаю? По этой схеме ведь репозиторий не обращается к домену.

Ответить

 

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

В этой схеме репозиторий относится к домену, так как его интерфейс лежит в Domain, а реализация - в Infrastructure. Инфраструктура - это не совсем слой.

Ответить

 

xfg

Lazy load плохой паттерн. Не будет работать в асинхронной среде. Так как вместо результата, будет возвращаться промис. И кажется все забыли, что агрегаты нужны для соблюдения инвариантов, а не потому что кажется логичным вложить Comment в Post или как у нас Phone в Employee. Employee не содержит ни одного инварианта. Не было смысла делать агрегат из такой реализации Employee. Здесь логичнее было бы сделать Employee и Phone отдельными сущностями. Агрегат здесь не нужен. Вон Вернон много про это писал.

Ответить

 

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

> Employee не содержит ни одного инварианта.

Содержит.

> агрегаты нужны для соблюдения инвариантов, а не потому что кажется логичным вложить Comment в Post

Инвариант с агрегатом (с сохранением в одном репозитории):

class Post
{
    public function addComment($id, $name, $text)
    {
        if ($this->isArchived()) {
            throw new \DomainException('Cannot add comment to archived post');
        }
        $this->comments->add(new Comment($id, $name, $text));
    }
}

Инвариант без агрегата (с отдельной сущностью и сохранением в отдельном репозитории):

class Post
{
    public function addComment($id, $name, $text)
    {
        if ($this->isArchived()) {
            throw new \DomainException('Cannot add comment to archived post');
        }
        return new Comment($this->id, $id, $name, $text);
    }
}

Вернон разумному агрегированию/разделению целую главу посвятил.

Телефоны сотрудника отдельным списком в админке выводить и редактировать не надо. Это даже объекты-значения, а не сущности. Поэтому здесь в PhoneRepository не вижу смысла.

Ответить

 

Denis Klimenko

Спасибо За Статью) Много над чем придется "покурить")

Ответить

 

Максим Миронюк

Прочитал с огромным удовольствием. Жду с нетерпением вторую часть статьи. Кажется я только что созрел для DDD ))

Ответить

 

sda

Как-то возможно написать умный репозиторий, который сам может разбирать/собирать агрегат без маппинга? Данные хранятся в JSON.

Ответить

 

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

А как он узнает, где какие классы использовать?

Ответить

 

sda

Также как контейнер зависимостей. Сначала создаст объекты EmployeeId, Name, Address, затем сам Employee.

Ответить

 

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

А имена классов где хранятся?

Ответить

 

sda

В конструкторе тип указан. По типу поймет объект какого класса инстанциировать.

Ответить

 

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

В конструкторе не указан тип Status.

Ответить

 

Дмитрий

Дмитрий, хорошие статьи, хорошая подача. Но есть один вопрос. Я изучал разные публикации на тему DDD и, в частности, репозиториев. Практически у всех один и тот же недостаток: всё хорошо и красиво ровно до тех пор, пока даются примеры работы с одной записью. Очень хотелось бы увидеть варианты реализации пакетного чтения записей, которые имеют взаимосвязи.

Пример с ходу: есть форум, определены модели "Тема", "Комментарий", "Участник". Вывести список тем форума в статусе "active", с информацией о количестве ответов и данными самого свежего ответа (имя участник и дата). ActiveRecord даёт нам удобные методы where(), with() и т.п., с помощью которых мы можем конструировать произвольные выборки и жадно грузить нужные взаимосвязи. Как быть в случае отказа от AR?

Ответить

 

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

Полноценные сущности используют именно для работы в домене. А для вывода обычно делают отдельный набор ViewModel как здесь и там уже делают JOIN-ы и возвращают DTO.

Ответить

 

Сосед Добрый

А если нужны всякие различные фильтры и сортировки надо спецификации делать? Или есть что то более изящное?

Ответить

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

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


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



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