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

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

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

interface EmployeeRepository
{
    public function get(Id $id): Employee;
    public function add(Employee $employee): void;
    public function save(Employee $employee): void;
    public function remove(Employee $employee): void;
}

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

Сочинять 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(Id $id): Employee { ... }
    public function add(Employee $employee): void { ... }
    public function save(Employee $employee): void { ... }
    public function remove(Employee $employee): void { ... }
}

Начнём с метода вставки записи 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): void
    {    
        $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): void
{    
    $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): void
    {
        $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): void
    {
        $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): void
    {
        $this->db->createCommand()
            ->delete('{{%sql_employees}}', ['id' => $employee->getId()->getId()])
            ->execute();
    }
 
    private static function extractEmployeeData(Employee $employee): array
    {
        $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): void
    {
        $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): void
    {
        $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(Id $id): Employee
{
    $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(Id $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 Id(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 Id(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(Id $id): Employee
    {
        $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 Id($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, (
            'country' => $phone['country'],
            'code' => $phone['code'],
            'number' => $phone['number']
        );
    }, $phones)),
]),

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

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

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

use yii\db\Migration;
 
class m170401_060956_create_sql_tables extends Migration
{
    public function up()
    {
        $tableOptions = 'CHARACTER SET utf8 COLLATE utf8_general_ci ENGINE=InnoDB';
 
        $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(),
        ], $tableOptions);
 
        $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(),
        ], $tableOptions);
 
        $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(),
        ], $tableOptions);
 
        $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(Id $id): Employee
    {
        $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 Id($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'];

И чтобы использовать такой гидратор в других проектах можно опубликовать его как отдельную Composer-библиотеку elisdn/hydrator.

Далее нужно определить, загрузил он уже телефоны из БД или нет. Используемая нами библиотека 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): void
    {
        $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(): Status
    {
        return end($this->statuses);
    }
 
    ...
 
    public function getStatuses(): array { return $this->statuses; }
}

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

use ArrayObject;
 
class Employee implements AggregateRoot
{
    ...
 
    private $statuses;
 
    public function __construct(Id $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(): Status
    {
        $statuses = $this->statuses->getArrayCopy();
        return end($statuses);
    }
 
    ...
 
    public function getStatuses(): array { return $this->statuses->getArrayCopy(); }
}

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

class SqlEmployeeRepository implements EmployeeRepository
{
    ...
 
    public function get(Id $id): Employee
    {
        $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 Id($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): void
    {
        $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(): Status
    {
        return end($this->statuses);
    }
 
    ...
 
    public function getStatuses(): array { return $this->statuses; }
}

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

public function get(Id $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): array
{
    $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 и отражение связей между строками в таблицах на связи между объектами (если используется РСУБД, естественно).

Ответить

 

Александр Макаров – rmcreative.ru

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

Ответить

 

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

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

Ответить

 

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

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

Ответить

 

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

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

Ответить

 

Юрий

В месте где

$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.

Ответить

 

Andrew

Здравствуйте!
А как понять "Полноценные сущности используют именно для работы в домене" ?
Просто я новичок)

Ответить

 

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

Начните с первой статьи про доменные сущности.

И для понимания доменной модели посмотрите с эпизода про DDD в плейлисте про проект под ключ.

Ответить

 

Сосед Добрый

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

Ответить

 

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

Спецификации, критерии... Что угодно. Хоть просто DTO с полями $dateFrom, $dateTo и т.д., если это просто форма поиска.

Ответить

 

Сосед Добрый

Спасибо

Ответить

 

Melodic

Дмитрий, а как быть если нужно сохранить сразу два агрегата за одну операцию?

Городить Unit of work? Или можно вынести управление транзакциями из репозитория и сделать примерно так:

class TransferHandler{
    public function handle(TransferRequest $request){
        $from = $this->accountRepository->findById(new AccountID($request->getFromId()));
        $to = $this->accountRepository->findById(new AccountID($request->getToId()));

        $amount = new Amount($request->getAmount());

        $from->transfer($to, $amount);
        $this->transactionService->transaction(function(){
            $this->accountRepository->save($from);
            $this->accountRepository->save($to);
        }
    }
}

На форуме yiiframework.ru вроде обсуждали это, но не могу найти тему

Ответить

 

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

Оставьте так.

Ответить

 

xfg

> с NoSQL-базами получаем проблемы с согласованностью (отсутствие транзакций и контроля внешних ключей)

Можно ли решить отсутствие транзакций с помощью two-phase commit, а вместо контроля внешних ключей использовать domain events. Если вместе с пользователем необходимо удалить его сообщения, то создать событие UserDeleted по которому затем удалить все сообщения пользователя ?

Ответить

 

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

Если это даст Вам хорошую надёжность, то можете попробовать.

Ответить

 

Виталий – vtvz.ru

Разве нельзя сделать Instance::of('db')? Если заглянуть в код, то он работает не только с DI контейнером, но и с Service Locator. А за статью отдельное спасибо. Только вот вопрос. С этими прокси код превращается в спагетти и читается достаточно сложно. Неужели нет более "человеческого" методов решения проблемы

Ответить

 

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

> Разве нельзя сделать Instance::of('db')? Если заглянуть в код, то он работает не только с DI контейнером, но и с Service Locator.

Нельзя, так как с Service Locator он работает только при ручном вызове метода ensure(). Контейнер его так не вызывает.

> Неужели нет более "человеческого" методов решения проблемы

Можно попробовать более человеческий придумать.

Ответить

 

Сергей Доровский

Дмитрий, как можно решить проблему с инкапсуляцией в доменных объектах (VO, Entity) в инфраструктурном слое?
Что бы использовать гидратор нужно знать все внутренности всех VO и сущностей в инфраструктурном слое!
Конечно, можно открыть исходник и посмотреть, какие там поля в классе, но без инкапсуляции это уже не ООП.

Ответить

 

Сергей Доровский

Я вижу такое решение, которое более ООПшное, чем решение с гидратором.
Это приватный конструктор и 2 статических конструктора.

Например, Employee::create(...) для простого создания со всеми событиями итд. и Employee::createFromState(...), который просто устанавливает состояние объекта не дергая события и минуя валидацию.

Конечно, проблема до конца не решается, в клиентском коде можно создать объект по 2 конструктору, и бизнес-логика будет нарушена, но это лучше, чем необходимость в знании о внутреннем состоянии объекта.

Либо можно сделать второй конструктор приватным и в репозитории через рефлексию создавать по нему. По сути это такое же нарушение инкапсуляции, как и в исходном варианте, но тут нужно знать всего об 1 методе, вместо множества полей.

Либо вообще, создавать сущности ТОЛЬКО через фабрику, но тут получается, что на каждый агрегат нужна еще и фабрика. Не критично, конечно, но не хотелось бы создавать еще 1 уровень абстракции.

Ответить

 

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

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

Ответить

 

Сергей Доровский

Чем это грозит... Ну например, это обязывает стороннего разработчика, который захотел интегрировать систему с другим хранилищем, знать внутренности доменных объектов. В то время, как инкапсуляция призвана скрывать эти подробности от пользователей системы. При соблюдении инвариантов не стоит забывать о стержне ООП - инкапсуляции.

Окей, пользователь нашей системы написал маппер и забыл про него. Все прекрасно, все работает. Но в 1 прекрасный момент разработчики системы решили изменить внутренности сущности или VO (переименовали/убрал/добавили поле), причем внешний интерфейс не изменился. Инвариант остался тем-же. Но приложение пользователя перестало работать. Если бы объекты заполнялись через публичный интерфейс или конструктор, ничего плохого бы не произошло.

Имхо, но нарушение инкапсуляции это самое страшное, что может быть в ОО мире.

Ответить

 

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

> Инвариант остался тем-же. Но приложение пользователя перестало работать.

Это весьма «талантливый» разработчиком надо быть, чтобы при изменении сущности забыть переименовать/убрать/добавить поле в БД.

> Если бы объекты заполнялись через публичный интерфейс или конструктор, ничего плохого бы не произошло.

Все конструкторы пришлось бы сделать пустыми приватными и дополнить их статическими Entity::create() вместо вызова new Entity(). И параллельно появилась бы группа сырых конструкторов Entity::fromDb($data) для заполнения данными без инварианта. Так и делают в языках без рефлексии.

Ответить

 

Сергей Доровский

> Это весьма «талантливый» разработчиком надо быть, чтобы при изменении сущности забыть переименовать/убрать/добавить поле в БД.

Я не про БД. Мы поля в сущности поменяли, но внешний интерфейс у сущности не изменился. Внешне изменений нет. А стороннему разработчику нужно знать про внутренности сущности, что бы правильно ее воссоздать. Это бред какой-то. О каком ООП тогда можно говорить?

Ответить

 

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

> Я не про БД.

Мапперы как раз пишут внутри проекта для низкоуровневого хранения сущностей в БД. Зачем нужны мапперы не для БД?

> А стороннему разработчику нужно знать про внутренности сущности...

Мы здесь про разработку проекта под ключ, где мы являемся разработчиками своего кода. А не про написание публичных библиотек, где есть другие "пользователи системы" или "сторонние разработчики".

Ответить

 

Сергей Доровский

А как быть во 2 случае, когда проект опен сорс?

Ответить

 

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

Либо смириться с рефлексией, используя SemVer и ведя понятный CHANGELOG, либо загромоздить сущность кодом для воссоздания.

Ответить

 

Web Master

добрый день!
а как можно организовать работу с репозиториями и сервисами, когда объект разбит на модули? создавать для каждого модуля свои папки с репозиториями и сервисами?
пример проекта на yii2 basic http://prntscr.com/hy5hm0

Ответить

 

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

Да, свои папки. Если нужно делать модули независимыми друг от друга, то организовывать зависимости через интерфейсы и события.

Ответить

 

Web Code

А что передавать в вид? Dto или Entity?
Если есть action для вывода, например поста?
Нужно ли передавать данные поста блога в экшен контроллера через сервис или сразу использовать репозиторий?

Ответить

 

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

Если Entity устаивает, то можете Entity. Если же хотите большей независимости и скорости, то можно DTO или напрямую массивы из результатов SQL-запросов. Можно сделать отдельный ReadRepostory с методами getPosts($offset, $limit), getPopularPosts($limit) и т.п. для различных выборок на сайте.

Ответить

 

xfg

Здесь мы хотим протестировать один метод, но по факту возникает необходимость вызвать сразу два метода. Получается что тест косвенно тестирует и метод add. В итоге testAdd делает фактически тоже самое, что и метод testGet.

Очень часто встречается такой код, где чтобы протестировать какой-нибудь get метод надо сначала вызвать set метод. Я бы хотел почитать какую-нибудь информацию о том как лучше писать тесты на такой код. Можете подсказать источники?

Ответить

 

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

Да, чтобы протестировать get нужно вызывать set и проверить, что вернулось то же самое. Удобно их проверять одним тестом вроде testSetAndGet. Отдельные тесты testSet имеет смысл делать для проверки на кидаемые методом set исключения.

Ответить

 

Яромир

Честно говопять я тогда не понимаю чем репозиторий от datamapper-a тогда отличается. Репозиторий вроде только коллекции доменных объектов должен хранить. Что бы модели (как раз тут хорошо ложатся модели именно active record ) не гуляли по всему приложению. Ок, в данном случае у нас нет моделей active record, и наверное нам излишне еще один слой абстракции. Но во-первых, чисто формально это все равно datamapper, а во-вторых, что куда важнее мы могли бы это и через модели active record реализовать. Правильно ли я понимаю, что тогда бы нам уже точно без еще одного слоя в лице datamapper не обойтись?

Ответить

 

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

Repository - это человеческое название слоя хранилища в общем. А DataMapper, ActiveRecord или Table Guateway - это технические способы, которыми этот репозиторий внутри может быть реализован. Ну и по описанию маппер работает только с одной сущностью.

Ответить

 

xfg

Какой архитектурный подход вы используете для админки ? Я считаю domain model избыточным для такой задачи и склоняюсь к CRUD. Гугл советов не дает. Интересно ваше мнение.

Ответить

 

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

Если это действительно голый CRUD без логики в Yii, то в сервисе хватает $entity->setAttributes($form->getAttributes(), false). Но такой примитивный CRUD в проектах встречается редко. А админка это или фронт - разницы нет.

Ответить

 

xfg

Но гугл ничего не выдает по "admin panel domain model". Админку похоже воспринимают как простейший UI для технического специалиста. Просто набор форм для редактирования полей без каких-либо бизнес-правил. Там же не нужно высчитывать какую-нибудь цену с учетом скидки. Там нужно просто указать значение этой самой скидки. И всё. Для чего там доменная модель?

Я просто не могу себе представить фронт на Java и админку на PHP с полным дублированием всех бизнес-правил. Это не имеет смысла. Также, поскольку все бизнес-правила были реализованы на фронте, то кажется, что в админке просто ничего не может быть кроме CRUD и валидации вводимых данных. Где я не прав?

Ответить

 

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

> Но гугл ничего не выдает по "admin panel domain model".

Все рассматривают сайт целиком. Без деления на админку и не админку.

> Админку похоже воспринимают как простейший UI для технического специалиста. Просто набор форм для редактирования полей без каких-либо бизнес-правил. Там же не нужно высчитывать какую-нибудь цену с учетом скидки. Там нужно просто указать значение этой самой скидки. И всё. Для чего там доменная модель?

Если у Вас это "набор форм для редактирования полей без каких-либо бизнес-правил" вроде списка настроек, то делайте всё на CRUD. У меня же админка тоже с бизнес-правилами.

> Я просто не могу себе представить фронт на Java и админку на PHP с полным дублированием всех бизнес-правил. Это не имеет смысла. Также, поскольку все бизнес-правила были реализованы на фронте, то кажется, что в админке просто ничего не может быть кроме CRUD и валидации вводимых данных. Где я не прав?

Чтобы не дублировать бизнес-правила во фронт их можно частично вычислять прямо в API на бэке.

А если сделаете проверки только на клиенте, в API оставив только голый CRUD без бизнес-правил, то я cмогу хакнуть ваш сайт, послав JSON-запрос с неправильной ценой.

Ответить

 

xfg

Плохо, что вы меняете содержимое сообщений. Теперь я выгляжу идиотом. Я упоминал Java, а не JS. Да, фронт часть может быть на Java. Фронт - это не синоним к SPA на JS. Фронт вполне может генерироваться сервером. Это и подразумевалось в изначальном сообщении.

Ответить

 

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

Поменял обратно.

Ответить

 

xfg

Спасибо. Думал CRUD админки достаточно для разработчика. В Django тоже генерится CRUD админка. Я админку как что-то техническое воспринимаю. Админку на доменной модели я почему-то воспринимаю как просто другой bounded context, например система складского учета к интернет-магазину. Я бы не назвал это админкой, мне кажется это такое же полноценное фронтовое приложение как и сам интернет-магазин, просто в другом контексте и с ограниченным доступом. То есть приложение для конечного потребителя, а за ним вполне может быть CRUD для разработчика. Как-то так я воспринимаю слово "админка".

Почему-то кажется, что в крупных энтерпрайз приложениях как-то так и есть.

Ответить

 

Тимур

Добрый день, Дмитрий. Подскажите, а может ли репозиторий восстанавливать несколько сущностей, восстанавливаемых другим репозитрорием? Псевдокод


/**
 * Репозиторий исследований.
 */
class StudyRepository implements StudyRepositoryInterface {

    /**
     * @return Study
     */
    public function one(StudyUuid $uuid): Study { ... }

    /**
     * @return Patient[]
     */
    public function studyPatient(StudyUuid $uuid): Patient { ... }

    /**
     * @return Series[]
     */
    public function studySeries(StudyUuid $uuid): array {}
}

/**
 * Репозиторий пациентов.
 */
class PatientRepository implements PatientRepositoryInterface {

    /**
     * @return Patient
     */
    public function one(PatientUuid $uuid): Patient { ... }

    /**
     * @return Study[]
     */
    public function studiesOfPatient(PatientUuid $uuid): array { ... }
}

В таком случае восстановление пациента из базы "дублируется" в обеих репозиториях. Второй вариант сделать:


/**
 * Репозиторий исследований.
 */
class StudyRepository implements StudyRepositoryInterface {
   
   /**
     * @return Study
     */
    public function one(StudyUuid $uuid): Study { ... }

    /**
     * @return Study[]
     */
    public function studiesOfPatient(PatientUuid $uuid): array { ... }
}

/**
 * Репозиторий пациентов.
 */
class PatientRepository implements PatientRepositoryInterface {
    /**
     * @return Patient
     */
    public function one(PatientUuid $uuid): Patient { ... }
   
}

Так получается что репозиторий работает с одной сущностью, но в качестве Uuid принимает экземпляры разных объектов

Может есть архитектурно правильный вариант, который поможет решить эту задачу?

Ответить

 

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

Как во втором варианте

interface PostRepository
{
    public function get(PostId $id): Post;

    public function getAllByUser(UserId $id): Post[];

    public function getFollowedByUser(UserId $id): Post[];
}
Ответить

 

ketchup

А можно без рефлексии, восстановить объект из базы, если к примеру логику Employee конструктора перенести в public static function getInstance(...), а конструктор оставить, пустым, на общую архитектуру это повлияет как-нибудь?

Ответить

 

ketchup

Второпях фигню сморозил, а что если так, на подобии вашего EmployerBuilder:

<?php

class Employee
{
	protected $_id;
	protected $_name;
	protected $_phones = [];

	function create($id, $name, $phones)
	{
        $this->_id = $id;
        $this->_name = $name;
        $this->_phones = $phones; // new Phones($phones);

        //$event = new Events\EmployeeCreated($this->_id);
        //$this->pushEvent($event);
		return $this;
	}
}


$employee = (new Employee)->create(2, 'FooBar', ['01', '02', '03']);

var_dump($employee);

Ответить

 

ketchup

Нет это тоже мусор что я привел. А что если сделать доступ к registerEvent из вне и вызывать тогда когда это необходимо удалив при этом этот вызов из конструктора?
Что-то типа такого:

<?php


trait EventTrait
{
	private $__registered_events = [
		'EmployeeCreated' => [Events\EmployeeCreated, $this->_id],
		// ...
	];

    private $__events = [];
 
    function recordEvent($event_name): void
    {
		$event_args = $this->registered_events[$event_name];
		$event = array_shift($event_args);
		if(!class_exists($event)) {
			throw new \DomainException("Event class is not registered.");
		}

        $this->__events[] = new $event(...$event_args);
    }
 
    function releaseEvents(): array
    {
        $events = $this->__events;
        $this->__events = [];
        return $events;
    }
}

class Employee
{
	use EventTrait;

	protected $_id;
	protected $_name;
	protected $_phones = [];

	function __construct($id, $name, $phones)
	{
        $this->_id = $id;
        $this->_name = $name;
        $this->_phones = $phones; // new Phones($phones);
		// ...

	}
}

$employee = new Employee(2, 'FooBar', ['01', '02', '03']);
$employee->recordEvent('EmployeeCreated');

Ответить

 

ketchup

Прошу прощения. Форматированная версия:

<?php

trait EventTrait
{
    private $__registered_events = [
        'EmployeeCreated' => [Events\EmployeeCreated, $this->_id],
        // ...
    ];

    private $__events = [];

    function recordEvent($event_name): void
    {
        $event_args = $this->registered_events[$event_name];
        $event = array_shift($event_args);
        if(!class_exists($event)) {
            throw new \DomainException("Event class is not registered.");
        }

        $this->__events[] = new $event(...$event_args);
    }

    function releaseEvents(): array
    {
        $events = $this->__events;
        $this->__events = [];
        return $events;
    }
}

class Employee
{
    use EventTrait;

    protected $_id;
    protected $_name;
    protected $_phones = [];

    function __construct($id, $name, $phones)
    {
        $this->_id = $id;
        $this->_name = $name;
        $this->_phones = $phones; // new Phones($phones);
        // ...

    }
}

$employee = new Employee(2, 'FooBar', ['01', '02', '03']);
$employee->recordEvent('EmployeeCreated');

Ответить

 

ketchup

Как мне кажется сущность Employee должна оставаться максимально чистой, я к тому что можно попробовать перенести обработку событий из сущности Employee в слой Repository или лучше в Сервисный слой, тем самым можно избавится от необходимости создания, объекта сущности без конструктора.

Ответить

 

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

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

class Employee
{
    private function _construct() {}

    public static function create($id, ...) {
        $employee = new self();
        $employee->id = $id;
        return $employee;
    }

    public static function fromArray($array) {
        ...
        return $employee;
    }
}

но смысла в этом особо не вижу.

Ответить

 

slo_nik

Добрый вечер.

Вот этот код с ошибкой

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

У меня получилось запустить подобное двумя вариантами
Первый:

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

Второй, более правильный, как мне кажется:

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

Или я что-то не понял?

Ответить

 

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

Да, нужны ключи. Исправил на второй.

Ответить

 

slo_nik

Да, и массивом надо сделать, а то будет ошибка синтаксиса.

Ответить

 

slo_nik

Добрый вечер.
А можно сделать проверку на уникальность в форме?
Сейчас у меня есть сущность "Заказчик", для которой есть CreateDto. В dto имена свойств отличаются от имён столбцов в базе данных. Например в dto есть свойство "email", а в базе поле именуется "customer_email". В форме, которая использует dto свойство тоже называется "email". Как в этом случае проверить уникальность этого свойства в базе?
Если в форме делать проверку так

['email', 'unique', 'targetClass' => Customer::class] 

то ошибка такая

Column not found: 1054 Unknown column 'customers.email' in 'where clause'

если укажу в правиле валидации существующее поле в базе

['customer_email', 'unique', 'targetClass' => Customer::class] 

то в этом случае ошибка такая

Getting unknown property: app\Model\Customer\Form\CreateForm::customer_email

Получается, что в форме делать простейшие проверки, 'required', 'string', 'email', а на уникальность проверку выносить в сервис, как Вы писали в этой статье https://elisdn.ru/blog/105/services-and-controllers?

Ответить

 

slo_nik

С этим разобрался.

['email', 'unique', 'targetClass' => Customer::class, 'targetAttribute' => ['email' => 'customer_email'] 
Ответить

 

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

Если нужно такие ошибки валидации выводить именно у полей, то да, можно добавить такую валидацию. Если же достаточно сообщение вывести просто над формой, то тогда можно просто поймать DomainException как в той статье.

Ответить

 

slo_nik

Или писать свои валидаторы в форме?

Ответить

 

slo_nik

Не до конца понял с dto. Как эти все dto собрать в одной форме?
Писал свой код по примерам из этой серии статей, так что отличия только в именах классов и свойств.
Сейчас у меня есть CreateDto для передачи в форму создания заказчика.

class CreateDto
{
    /* @var NameDto */
    public $name;
    /* @var AddressDto */
    public $address
}

В форме CreateForm есть метод getDto()

class CreateForm extends Model
{
    public $first;
    public $middle;
    public $last;

    public $country;
    public $region;
    public $city;

   public function getDto()
   {
       $dto = new CreateDto():
       
       $dto->name->first = $this->first;
       $dto->name->middle = $this->middle;
       $dto->name->last = $this->last;

       $dto->address->country = $this->country;
       $dto->address->region = $this->region;
       $dto->address->city = $this->city;
   }
}

Но в этом случае выдаст ошибку

Creating default object from empty value


Если вложенные dto объявить в конструкторе CreateDto, то всё будет работать.

    public function __construct()
    {
        $this->address = new AddressDto();
        $this->passport = new NameDto();
    }

Но насколько это правильно? Или с использованием конструктора это будет не dto?
Или надо создать под каждое dto свою форму, а в CreateForm делать композитную форму?

Ответить

 

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

Да, создавать все вложенные вручную.

Ответить

 

slo_nik

То есть в конструкторе?

Ответить

 

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

Да.

Ответить

 

Telkom University – mmpjj.telkomuniversity.ac.id

Какие аспекты вашего исследования считаете наиболее значимыми для сообщества в данной области? regard Telkom University

Ответить

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

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


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





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