Реализация репозитория для доменных сущностей
Итак, продолжим! Мы уже немного научились проектировать сущности на примере 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 -> Employee
Спасибо за статью!
Добрый соседА репозиторий разве может знать про сущность? Ведь репозитории находятся уровнем ниже, и вроде бы не должны знать про слои выше него. Вот тут так же возвращают саму сущность из репозитория. И репозиторий, получается, знает про доменный слой.
Или я как то неправильно понимаю? По этой схеме ведь репозиторий не обращается к домену.
Дмитрий ЕлисеевВ этой схеме репозиторий относится к домену, так как его интерфейс лежит в Domain, а реализация - в Infrastructure. Инфраструктура - это не совсем слой.
xfgLazy 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". Как в этом случае проверить уникальность этого свойства в базе?
Если в форме делать проверку так
то ошибка такая
если укажу в правиле валидации существующее поле в базе
то в этом случае ошибка такая
Получается, что в форме делать простейшие проверки, 'required', 'string', 'email', а на уникальность проверку выносить в сервис, как Вы писали в этой статье https://elisdn.ru/blog/105/services-and-controllers?
slo_nikС этим разобрался.
Дмитрий ЕлисеевЕсли нужно такие ошибки валидации выводить именно у полей, то да, можно добавить такую валидацию. Если же достаточно сообщение вывести просто над формой, то тогда можно просто поймать 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; } }Но в этом случае выдаст ошибку
Если вложенные 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