Реализация репозитория для доменных сущностей
Итак, продолжим! Мы уже немного научились проектировать сущности на примере 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
А пока задавайте вопросы в комментариях здесь или на форуме.
У меня работник со 100 фотками. Чтобы добавить 1 фотку придется загрузить все фотки, хотя мне они не нужны.
Можно ли это исправить, расширив функциональность прокси для коллекции?
Тогда не делайте загрузку в методе add(), а просто добавляйте новые в приватный массив. А так не вижу смысла заморачиваться с этими "лишними" десятью килобайтами.
Ну их может быть и не 10 килобайт, а мегабайты) Тут так нельзя подходить. А что если у вас интенсивность обновления агрегата будет довольно высокой? Каждый раз все удалять/заново вставлять - так могут и первичные ключи кончиться. Для маленьких read-intensive сайтов это не имеет значения, но бывает и по-другому.
> Ну их может быть и не 10 килобайт, а мегабайты.
Приведите пример, как насчитали мегабайт.
> А что если у вас интенсивность обновления агрегата будет довольно высокой?
Если обновление занимает 0.05 секунд, то это 20 запросов в секунду... или 1 728 000 запросов на запись в сутки. Думаю, что справлюсь.
> Каждый раз все удалять/заново вставлять - так могут и первичные ключи кончиться.
Переделаю по первому способу с array_udiff для детектирования изменений. Или сделаю составной ключ с $i + 1 в id из foreach ($phones as $i => $phone). Тогда не закончатся.
Да пожалуйста.
Система управления рекламой рекламной компании.
Сущность - баннер, у него есть связанные сущности - документы, сертификаты, лицензии. Документы хранятся в виде BLOB. Каждый от 200 кб до 800 кб. Бывает до 10 документов к одному баннеру. Так и насчитали)
Вообще я не понимаю такую постановку вопроса - "приведите пример". По вашему коду у вас количество телефонов ничем не ограничено. В БД тоже может их быть сколько угодно. Так что это не заморочки.
> Документы хранятся в виде BLOB
Это бОльшая дикость в плане производительности, чем ООП.
> Вообще я не понимаю такую постановку вопроса - "приведите пример". По вашему коду у вас количество телефонов ничем не ограничено.
У среднестатистического человека максимум десять телефонов, пять адресов и обычно три-четыре паспорта. Это ограничено жизнью. В программном продукте вы именно реальную жизнь и моделируете. И сущности проектируете по реальным фактам, а не по перлам "а что если у человека будет миллиард паспортов". Поэтому и говорю "приведите пример".
А как же "зашита от дурака"? Если оставить возможность ввести неограниченное количестсво телефонов, то кто-нибудь обязательно ей воспользуется рано или поздно.
Вот сплю и вижу, как Вы себе вбиваете миллион телефонов, чтобы положить чей-то сайт. Защита добавляется за двадцать секунд:
Что поделаешь. Документы нужны под транзакционной защитой)
> Что поделаешь...
Вынесу blob в таблицу files и у баннера укажу file_id.
Да и с файлами на диске транзакционность есть: транзакция не прошла - поле file_name откатилось на имя старого файла.
> Да и с файлами на диске транзакционность есть: транзакция не прошла - поле file_name откатилось на имя старого файла.
Слышали, слышали, знаем) В моем случае в файл вносятся изменения в приложении в процессе обработки записи + репликация и бэкапы проще делать) Короче, есть нюансы)
> Вынесу blob в таблицу files и у баннера укажу file_id.
Ну вот видите, Все равно не получается при проектировании проектировать чисто в терминах объектов. Все равно реляционка пролезает, никуда от нее не убежишь.
> Ну вот видите, Все равно не получается при проектировании проектировать чисто в терминах объектов.
В объекте сделаю ленивую загрузку file. В репозитории разрулю, например, по file_id. Это только у Вас не получается.
> Это только у Вас не получается.
Вы это зачем написали? Себе на потеху?
> В объекте сделаю ленивую загрузку file. В репозитории разрулю по file_id. Это только у Вас не получается.
Сам факт того, что вы в домене создаете еще одну сущность, чтобы вынеси file в отдельную таблицу говорит о том, что реляционная модель управляет доменом и слова, что систему надо проектировать начиная с объектов, а потом уже переносить ее на БД - это просто слова, до конца от реляционки не отвяжешься. Рассогласование нагрузки не мной и не вчера доказано.
> Сам факт того, что вы в домене создаете еще одну сущность...
Не создаю. Спроксирую у сущности методы, работающие с файлом, чтобы BLOB запрашивался только по требованию.
> Вы это зачем написали? Себе на потеху?
> Слова, что систему надо проектировать начиная с объектов, а потом уже переносить ее на БД - это просто слова, до конца от реляционки не отвяжешься.
Вы уже пятнадцатый комментарий здесь пишете о том, как у Вас "лыжи не едут".
Да все у меня едет.
Пишу комментарии, чтобы лучше понять мысли, изложенные в ваших статьях.
Вы хорошо владете проксированием. Я его как-то не брал в расчет.
Методология действительно рабочая, так что вопросов больше нет.
Вообще я склонен считать lazy load антипаттерном. Не знаю, почему его все так любят. Там используется замыкание, которое обращается к БД. БД и сеть это штуки ненадежные, могут возникать обрывы соединений и прочее. Внутри прокси будут возникать исключения, однако клиентский код про них ничего не знает и не может их обработать. Негде подписать phpdoc @throws DbException. Соответственно, подобные исключения могут происходить в самых неожиданных местах. Конечно, можно в прикладном сервисе или еще выше все обернуть в try/catch, но это будет протекание. Вспомните java и аннотации. Если у вас метод может генерировать исключение, вы обязаны либо обработать его, либо пропустить исключение выше, обозначив метод соответтсвующей аннотацией - иначе не скомпилируется.
> Внутри прокси будут возникать исключения, однако клиентский код про них ничего не знает и не может их обработать.
Вылетит не жадно в $repository->get(...), а на строку ниже лениво в $employee->addPhone(...). Какая клиентскому коду в контроллере разница, откуда ему наверх RuntimeException прилетел?
Ну а если у вас ленивая загрузка только во view произойдет? Все в try/catch обернете?)
Жадная загрузка - тоже не выход, данных может быть очень много.
Еще могут быть случаи, когда из 1000 связанных сущностей для выполнения бизнес-операции могут быть нужны 1-100, выбранных по какому-либо критерию. Ни жадная, ни ленивая загрузка не решат эту задачу. Только отдельный репозиторий на сущность, которая хранится в отдельной таблице. Да, не кошерно, хранение влияет на домен. Но полный persitence-ignorance это сказка, которая работает в достаточно жестко ограниченных условиях.
Этим самым у вас создается тесная связь между вашим прокси и кодом обработки исключения.
У меня нет кода обработки RuntimeException.
А пользователю тогда какое сообщение показываете? Никакого? 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 на рабочем проекте где сроки ограничены - очень быстро начнутся трудности. Есть определенный порог сочетания сложности проекта и доступного времени на его разработку, при котором такой подход не оправдан. Лучше дождитесь следующую статью по доктрине, где это все есть и намного больше.
Извините, хотел бы уточнить. А если допустим не писать полнофункциональную ORM, а использовать какой-нибудь TableGateway? Связи сохранять в сервисе явно в транзакции, от ассоциаций отказаться,
делать все по внешним ключам в сервисах (к слову сказать, в доктрине ассоциации не быстрые, кое где можно с memory-limit вылететь по недосмотру). То есть сдвинуться немного от объектной модели обратно к реляционной, чтобы рассогласование нагрузки не так сильно ощущалось? Это тоже оверхед для средне-крупного проекта?
В программировании всё можно. Лишь бы самому было удобно.
И задача решалась) Просто хочется узнать, как делают опытные разработчики - всегда ли путь более-менее крупного проекта лежит через ORM и отражение связей между строками в таблицах на связи между объектами (если используется РСУБД, естественно).
Хорошая статья. Я бы только заменил «консистентность» на «согласованность». Это устоявшийся термин.
Спасибо! Заменил.
Спасибо за отличную серию статей) ждем Doctrine и ActiveRecord
Спасибо за цикл статей, очень полезные и нужные.
В месте где
нужно подправить на
, если Вам это важно.
Исправил. Спасибо!
Не большая опечатка в этом блоке текста:
Eemployee -> Employee
Спасибо за статью!
А репозиторий разве может знать про сущность? Ведь репозитории находятся уровнем ниже, и вроде бы не должны знать про слои выше него. Вот тут так же возвращают саму сущность из репозитория. И репозиторий, получается, знает про доменный слой.
Или я как то неправильно понимаю? По этой схеме ведь репозиторий не обращается к домену.
В этой схеме репозиторий относится к домену, так как его интерфейс лежит в Domain, а реализация - в Infrastructure. Инфраструктура - это не совсем слой.
Lazy load плохой паттерн. Не будет работать в асинхронной среде. Так как вместо результата, будет возвращаться промис. И кажется все забыли, что агрегаты нужны для соблюдения инвариантов, а не потому что кажется логичным вложить Comment в Post или как у нас Phone в Employee. Employee не содержит ни одного инварианта. Не было смысла делать агрегат из такой реализации Employee. Здесь логичнее было бы сделать Employee и Phone отдельными сущностями. Агрегат здесь не нужен. Вон Вернон много про это писал.
> Employee не содержит ни одного инварианта.
Содержит.
> агрегаты нужны для соблюдения инвариантов, а не потому что кажется логичным вложить Comment в Post
Инвариант с агрегатом (с сохранением в одном репозитории):
Инвариант без агрегата (с отдельной сущностью и сохранением в отдельном репозитории):
Вернон разумному агрегированию/разделению целую главу посвятил.
Телефоны сотрудника отдельным списком в админке выводить и редактировать не надо. Это даже объекты-значения, а не сущности. Поэтому здесь в PhoneRepository не вижу смысла.
Спасибо За Статью) Много над чем придется "покурить")
Прочитал с огромным удовольствием. Жду с нетерпением вторую часть статьи. Кажется я только что созрел для DDD ))
Как-то возможно написать умный репозиторий, который сам может разбирать/собирать агрегат без маппинга? Данные хранятся в JSON.
А как он узнает, где какие классы использовать?
Также как контейнер зависимостей. Сначала создаст объекты EmployeeId, Name, Address, затем сам Employee.
А имена классов где хранятся?
В конструкторе тип указан. По типу поймет объект какого класса инстанциировать.
В конструкторе не указан тип Status.
Дмитрий, хорошие статьи, хорошая подача. Но есть один вопрос. Я изучал разные публикации на тему DDD и, в частности, репозиториев. Практически у всех один и тот же недостаток: всё хорошо и красиво ровно до тех пор, пока даются примеры работы с одной записью. Очень хотелось бы увидеть варианты реализации пакетного чтения записей, которые имеют взаимосвязи.
Пример с ходу: есть форум, определены модели "Тема", "Комментарий", "Участник". Вывести список тем форума в статусе "active", с информацией о количестве ответов и данными самого свежего ответа (имя участник и дата). ActiveRecord даёт нам удобные методы where(), with() и т.п., с помощью которых мы можем конструировать произвольные выборки и жадно грузить нужные взаимосвязи. Как быть в случае отказа от AR?
Полноценные сущности используют именно для работы в домене. А для вывода обычно делают отдельный набор ViewModel как здесь и там уже делают JOIN-ы и возвращают DTO.
Здравствуйте!
А как понять "Полноценные сущности используют именно для работы в домене" ?
Просто я новичок)
Начните с первой статьи про доменные сущности.
И для понимания доменной модели посмотрите с эпизода про DDD в плейлисте про проект под ключ.
А если нужны всякие различные фильтры и сортировки надо спецификации делать? Или есть что то более изящное?
Спецификации, критерии... Что угодно. Хоть просто DTO с полями $dateFrom, $dateTo и т.д., если это просто форма поиска.
Спасибо
Дмитрий, а как быть если нужно сохранить сразу два агрегата за одну операцию?
Городить Unit of work? Или можно вынести управление транзакциями из репозитория и сделать примерно так:
На форуме yiiframework.ru вроде обсуждали это, но не могу найти тему
Оставьте так.
> с NoSQL-базами получаем проблемы с согласованностью (отсутствие транзакций и контроля внешних ключей)
Можно ли решить отсутствие транзакций с помощью two-phase commit, а вместо контроля внешних ключей использовать domain events. Если вместе с пользователем необходимо удалить его сообщения, то создать событие UserDeleted по которому затем удалить все сообщения пользователя ?
Если это даст Вам хорошую надёжность, то можете попробовать.
Разве нельзя сделать 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, либо загромоздить сущность кодом для воссоздания.
добрый день!
а как можно организовать работу с репозиториями и сервисами, когда объект разбит на модули? создавать для каждого модуля свои папки с репозиториями и сервисами?
пример проекта на yii2 basic http://prntscr.com/hy5hm0
Да, свои папки. Если нужно делать модули независимыми друг от друга, то организовывать зависимости через интерфейсы и события.
А что передавать в вид? Dto или Entity?
Если есть action для вывода, например поста?
Нужно ли передавать данные поста блога в экшен контроллера через сервис или сразу использовать репозиторий?
Если Entity устаивает, то можете Entity. Если же хотите большей независимости и скорости, то можно DTO или напрямую массивы из результатов SQL-запросов. Можно сделать отдельный ReadRepostory с методами getPosts($offset, $limit), getPopularPosts($limit) и т.п. для различных выборок на сайте.
Здесь мы хотим протестировать один метод, но по факту возникает необходимость вызвать сразу два метода. Получается что тест косвенно тестирует и метод 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 - это технические способы, которыми этот репозиторий внутри может быть реализован. Ну и по описанию маппер работает только с одной сущностью.
Какой архитектурный подход вы используете для админки ? Я считаю domain model избыточным для такой задачи и склоняюсь к CRUD. Гугл советов не дает. Интересно ваше мнение.
Если это действительно голый CRUD без логики в Yii, то в сервисе хватает $entity->setAttributes($form->getAttributes(), false). Но такой примитивный CRUD в проектах встречается редко. А админка это или фронт - разницы нет.
Но гугл ничего не выдает по "admin panel domain model". Админку похоже воспринимают как простейший UI для технического специалиста. Просто набор форм для редактирования полей без каких-либо бизнес-правил. Там же не нужно высчитывать какую-нибудь цену с учетом скидки. Там нужно просто указать значение этой самой скидки. И всё. Для чего там доменная модель?
Я просто не могу себе представить фронт на Java и админку на PHP с полным дублированием всех бизнес-правил. Это не имеет смысла. Также, поскольку все бизнес-правила были реализованы на фронте, то кажется, что в админке просто ничего не может быть кроме CRUD и валидации вводимых данных. Где я не прав?
> Но гугл ничего не выдает по "admin panel domain model".
Все рассматривают сайт целиком. Без деления на админку и не админку.
> Админку похоже воспринимают как простейший UI для технического специалиста. Просто набор форм для редактирования полей без каких-либо бизнес-правил. Там же не нужно высчитывать какую-нибудь цену с учетом скидки. Там нужно просто указать значение этой самой скидки. И всё. Для чего там доменная модель?
Если у Вас это "набор форм для редактирования полей без каких-либо бизнес-правил" вроде списка настроек, то делайте всё на CRUD. У меня же админка тоже с бизнес-правилами.
> Я просто не могу себе представить фронт на Java и админку на PHP с полным дублированием всех бизнес-правил. Это не имеет смысла. Также, поскольку все бизнес-правила были реализованы на фронте, то кажется, что в админке просто ничего не может быть кроме CRUD и валидации вводимых данных. Где я не прав?
Чтобы не дублировать бизнес-правила во фронт их можно частично вычислять прямо в API на бэке.
А если сделаете проверки только на клиенте, в API оставив только голый CRUD без бизнес-правил, то я cмогу хакнуть ваш сайт, послав JSON-запрос с неправильной ценой.
Плохо, что вы меняете содержимое сообщений. Теперь я выгляжу идиотом. Я упоминал Java, а не JS. Да, фронт часть может быть на Java. Фронт - это не синоним к SPA на JS. Фронт вполне может генерироваться сервером. Это и подразумевалось в изначальном сообщении.
Поменял обратно.
Спасибо. Думал CRUD админки достаточно для разработчика. В Django тоже генерится CRUD админка. Я админку как что-то техническое воспринимаю. Админку на доменной модели я почему-то воспринимаю как просто другой bounded context, например система складского учета к интернет-магазину. Я бы не назвал это админкой, мне кажется это такое же полноценное фронтовое приложение как и сам интернет-магазин, просто в другом контексте и с ограниченным доступом. То есть приложение для конечного потребителя, а за ним вполне может быть CRUD для разработчика. Как-то так я воспринимаю слово "админка".
Почему-то кажется, что в крупных энтерпрайз приложениях как-то так и есть.
Добрый день, Дмитрий. Подскажите, а может ли репозиторий восстанавливать несколько сущностей, восстанавливаемых другим репозитрорием? Псевдокод
В таком случае восстановление пациента из базы "дублируется" в обеих репозиториях. Второй вариант сделать:
Так получается что репозиторий работает с одной сущностью, но в качестве Uuid принимает экземпляры разных объектов
Может есть архитектурно правильный вариант, который поможет решить эту задачу?
Как во втором варианте
А можно без рефлексии, восстановить объект из базы, если к примеру логику Employee конструктора перенести в public static function getInstance(...), а конструктор оставить, пустым, на общую архитектуру это повлияет как-нибудь?
Второпях фигню сморозил, а что если так, на подобии вашего EmployerBuilder:
Нет это тоже мусор что я привел. А что если сделать доступ к registerEvent из вне и вызывать тогда когда это необходимо удалив при этом этот вызов из конструктора?
Что-то типа такого:
Прошу прощения. Форматированная версия:
Как мне кажется сущность Employee должна оставаться максимально чистой, я к тому что можно попробовать перенести обработку событий из сущности Employee в слой Repository или лучше в Сервисный слой, тем самым можно избавится от необходимости создания, объекта сущности без конструктора.
В конструкторе не только событие генерируется, но и первоначальные статусы и прочие вещи выставляются. Если не нравится рефлексия, то да, по аналогии с языками без рефлексии можно добавить служебный конструктор для восстановления из БД:
но смысла в этом особо не вижу.
Добрый вечер.
Вот этот код с ошибкой
У меня получилось запустить подобное двумя вариантами
Первый:
Второй, более правильный, как мне кажется:
Или я что-то не понял?
Да, нужны ключи. Исправил на второй.
Да, и массивом надо сделать, а то будет ошибка синтаксиса.
Добрый вечер.
А можно сделать проверку на уникальность в форме?
Сейчас у меня есть сущность "Заказчик", для которой есть CreateDto. В dto имена свойств отличаются от имён столбцов в базе данных. Например в dto есть свойство "email", а в базе поле именуется "customer_email". В форме, которая использует dto свойство тоже называется "email". Как в этом случае проверить уникальность этого свойства в базе?
Если в форме делать проверку так
то ошибка такая
если укажу в правиле валидации существующее поле в базе
то в этом случае ошибка такая
Получается, что в форме делать простейшие проверки, 'required', 'string', 'email', а на уникальность проверку выносить в сервис, как Вы писали в этой статье https://elisdn.ru/blog/105/services-and-controllers?
С этим разобрался.
Если нужно такие ошибки валидации выводить именно у полей, то да, можно добавить такую валидацию. Если же достаточно сообщение вывести просто над формой, то тогда можно просто поймать DomainException как в той статье.
Или писать свои валидаторы в форме?
Не до конца понял с dto. Как эти все dto собрать в одной форме?
Писал свой код по примерам из этой серии статей, так что отличия только в именах классов и свойств.
Сейчас у меня есть CreateDto для передачи в форму создания заказчика.
В форме CreateForm есть метод getDto()
Но в этом случае выдаст ошибку
Если вложенные dto объявить в конструкторе CreateDto, то всё будет работать.
Но насколько это правильно? Или с использованием конструктора это будет не dto?
Или надо создать под каждое dto свою форму, а в CreateForm делать композитную форму?
Да, создавать все вложенные вручную.
То есть в конструкторе?
Да.
Какие аспекты вашего исследования считаете наиболее значимыми для сообщества в данной области? regard Telkom University