Итак, продолжим! Мы уже немного научились проектировать сущности на примере
Employee в первой части и даже подготовили небольшой прикладной сервис EmployeeService во второй. И договорились, что нам для хранения доменных сущностей в базе нужно сделать некий репозиторий. И даже сделали его тестовый эмулятор и подготовили работающие тесты. Перед изучением каких-либо готовых решений (чтобы понимать их суть) сегодня навелосипедим собственную реализацию репозитория без использования сторонних ORM-систем.
Реализовывать его будем по тому же интерфейсу:
interface EmployeeRepository { public function get(EmployeeId $id): Employee; public function add(Employee $employee): void; public function save(Employee $employee): void; public function remove(Employee $employee): void; public function nextId(): EmployeeId; }
Готовые тесты нам намного упростят задачу, так как не придётся проверять все методы вручную.
Сочинять SQL-запросы многие вполне себе умеют, если стоит задача сохранить какую-то простую строку. Но в реальном мире дело обстоит сложнее, так как порой нужно производить совсем не тривиальное...
Объектно-реляционное преобразование
Суть любой ORM-системы (Object-Relational Mapping, Объектно-Реляционное Преобразование) – обеспечить хранение объектов в реляционной (табличной) базе данных, победив так называемый объектно-реляционный импеданс, обозначающий несоответствие структуры объекта структуре базы и наоборот.
То есть, простыми словами, задача состоит в необходимости построить преобразователь данных (Data Mapper) из объекта в БД и обратно.
У нас импеданс ярко выражен тем, что нужно объект с древовидной структурой как в этом JSON-виде:
{ id: 25 name: { last: 'Пупкин', first: 'Василий', middle: 'Петрович', }, address: { country: 'Россия', state: 'Липецкая обл.', city: 'г. Пушкин', street: 'ул. Ленина', house: 25 } phones: [ {country: 7, code: 920, number: 0000001}, {country: 7, code: 910, number: 0000002}, ], createDate: '2016-04-12 12:34:00', statuses: [ {status: 'active', date: '2016-04-12 12:34:07'}, {status: 'archive', date: '2016-04-13 12:56:23'}, {status: 'active', date: '2016-04-16 14:02:10'}, ]; }
отобразить на плоскую базу данных, чаще состоящую из трёх таблиц:
employees:
id
name_last
name_first
name_middle
address_country
address_state
address_city
address_street
address_house
create_date
curent_status
phones:
id
employee_id
country
code
number
statuses:
id
employee_id
date
value
Как альтернатива реляционным хранилищам можно использовать нереляционные документо-ориентированные, чтобы структуру сущности один-в-один преобразовать в вышеприведённый JSON-документ с помощью системы ODM (Object-Document Mapping) и сохранить прямо как есть под своим идентификатором:
$mongoDb->put('employees', 25, json_encode([ 'id' => 25, 'name' => [ 'last' => 'Пупкин', 'first' => 'Василий', 'middle' => 'Петрович', ], ... ]));
Но с NoSQL-базами получаем проблемы с согласованностью (отсутствие транзакций и контроля внешних ключей) при наличии связей вроде поля
company_id, ссылающегося на компанию из коллекции companies.
С появлением более-менее индексируемых JSON-полей (если нужен поиск по содержимому) или уже давно в виде текстового поля (если не нужен) можно сделать гибридную схему, где в SQL-базе адрес, телефоны и статусы записывать прямо в таблицу сотрудников в виде сериализованной JSON-строки:
employee:
id
name_last
name_first
name_middle
address_json
create_date
curent_status
phones_json
statuses_json
Это позволит обойтись без JOIN-ов при выборке. В нашем примере мы можем так сделать, но в некоторых базах это вызовет те же проблемы с невозможностью проставить внешние ключи из содержимого сериализованных колонок, если у телефонов будет ещё какое-то поле вроде
type_id, ссылающееся на другую таблицу. Так что если нужны внешние ключи, то JSON кое-где не справится.
Сегодня мы попробуем вручную реализовать несколько способов хранения:
- Хранение данных в трёх таблицах сотрудников, телефонов и статусов;
- Хранение в трёх таблицах с реализацией «ленивой» загрузки;
- Сохранение статусов в JSON-поле
statusesтаблицы сотрудников.
В следующих статьях мы будем следовать тоже этому плану. Приступим!
Реализация репозитория
Итак, со списком методов мы уже определились в прошлой части. Теперь осталось только создать класс:
class SqlEmployeeRepository implements EmployeeRepository { public function get(EmployeeId $id): Employee { ... } public function add(Employee $employee): void { ... } public function save(Employee $employee): void { ... } public function remove(Employee $employee): void { ... } public function nextId(): EmployeeId { ... } }
Начнём с метода вставки записи
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(EmployeeId $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(EmployeeId $id, Name $name, Address $address, array $phones) { $this->id = $id; $this->name = $name; $this->address = $address; $this->phones = new Phones($phones); $this->createDate = new \DateTimeImmutable(); $this->addStatus(Status::ACTIVE, $this->createDate); $this->recordEvent(new Events\EmployeeCreated($this->id)); } ... }
Для решения таких проблем во многих языках (включая PHP) имеется инструменты рефлексии, которыми можно работать с классами и объектами на более «продвинутом» уровне. А именно, можно создавать новые объекты без использования конструктора:
$reflection = new \ReflectionClass(Employee::class); $employee = $reflection->newInstanceWithoutConstructor();
И напрямую работать с приватными полями, предварительно сделав их доступными на изменение через рефлексию:
$reflection = new \ReflectionClass(Employee::class); $property = $reflection->getProperty('id'); $property->setAccessible(true); $property->setValue($employee, new EmployeeId(25));
Удобная вещь. Она понадобится нам для всех сущностей. Чтобы не копировать один и тот же код во все репозитории, можно вынести его в отдельный класс
Hydrator:namespace app\repositories; class Hydrator { public function hydrate($class, array $data) { $reflection = new \ReflectionClass($class); $target = $reflection->newInstanceWithoutConstructor(); foreach ($data as $name => $value) { $property = $reflection->getProperty($name); $property->setAccessible(true); $property->setValue($target, $value); } return $target; } }
и пользоваться им в репозитории, передавая имя класса и массив значений для заполнения:
$employee = $this->hydrator->hydrate(Employee::class, [ 'id' => new EmployeeId(25), 'name' => new Name(...), 'address' => new Address(...), ... ]); return $employee;
Так рефлексия нам поможет воссоздать объект в методе
get($id). Но есть небольшое неудобство в том, что она работает сравнительно медленно при вызове new \ReflectionClass($class). Это будет заметно при создании тысяч объектов. Чтобы повысить производительность можно по примеру SamDark/Hydrator создать объект рефлексии всего один раз и поместить в приватное поле. И можно лишний раз не вызывать $property->setAccessible(true) для публичных свойств, так как они и так доступны.
В итоге оптимизированный класс гидратора окажется таким:
namespace app\repositories; class Hydrator { private $reflectionClassMap; public function hydrate($class, array $data) { $reflection = $this->getReflectionClass($class); $target = $reflection->newInstanceWithoutConstructor(); foreach ($data as $name => $value) { $property = $reflection->getProperty($name); if ($property->isPrivate() || $property->isProtected()) { $property->setAccessible(true); } $property->setValue($target, $value); } return $target; } private function getReflectionClass($className) { if (!isset($this->reflectionClassMap[$className])) { $this->reflectionClassMap[$className] = new \ReflectionClass($className); } return $this->reflectionClassMap[$className]; } }
Теперь передадим гидратор в репозиторий и с его помощью заполним наш объект:
class SqlEmployeeRepository implements EmployeeRepository { private $db; private $hydrator; public function __construct(Connection $db, Hydrator $hydrator) { $this->db = $db; $this->hydrator = $hydrator; } public function get(EmployeeId $id): Employee { $employee = (new Query())->select('*') ->from('{{%sql_employees}}') ->andWhere(['id' => $id->getId()]) ->one($this->db); if (!$employee) { throw new NotFoundException('Employee not found.'); } $phones = (new Query())->select('*') ->from('{{%sql_employee_phones}}') ->andWhere(['employee_id' => $id->getId()]) ->orderBy('id') ->all($this->db); $statuses = (new Query())->select('*') ->from('{{%sql_employee_statuses}}') ->andWhere(['employee_id' => $id->getId()]) ->orderBy('id') ->all($this->db); return $this->hydrator->hydrate(Employee::class, [ 'id' => new EmployeeId($employee['id']), 'name' => new Name( $employee['name_last'], $employee['name_first'], $employee['name_middle'] ), 'address' => new Address( $employee['address_country'], $employee['address_region'], $employee['address_city'], $employee['address_street'], $employee['address_house'] ), 'createDate' => new \DateTimeImmutable($employee['create_date']), 'phones' => new Phones(array_map(function ($phone) { return new Phone( $phone['country'], $phone['code'], $phone['number'] ); }, $phones)), 'statuses' => array_map(function ($status) { return new Status( $status['value'], new \DateTimeImmutable($status['date']) ); }, $statuses), ]); } ... }
Пока мы напрямую вызываем
new у наших объектов-значений, чтобы не пользоваться медленной рефлексией. Но в какой-то момент времени у нас может получиться так, что конструктор класса Address изменится и начнёт требовать обязательного заполнения номера дома, и new Address вдруг начнёт ругаться с InvalidArgumentException на пустые старые значения из базы данных. Или станет необходимо заполнение двух телефонов вместо одного, и вызов new Phones будет бросать исключение класса DomainException.
Чтобы полностью игнорировать такие опасные или слишком долгие проверки в конструкторах можно и все внутренние объекты вместо
new Phones(...) создавать через рефлексию:'phones' => $this->hydrator->hydrate(Phones::class, [ 'phones' => (array_map(function ($phone) { return $this->hydrator->hydrate(Phone::class, ( $phone['country'], $phone['code'], $phone['number'] ); }, $phones)), ]),
Это, возможно, и замедлит работу на несколько микросекунд, но на объёмах до тысяч объектов это не заметно.
Проверка работы
Попробуем написанный репозиторий в действии. Напишем миграцию для создания нужных нам таблиц:
use yii\db\Migration; class m170401_060956_create_sql_tables extends Migration { public function up() { $this->createTable('{{%sql_employees}}', [ 'id' => $this->char(36)->notNull(), 'create_date' => $this->dateTime(), 'name_last' => $this->string(), 'name_first' => $this->string(), 'name_middle' => $this->string(), 'address_country' => $this->string(), 'address_region' => $this->string(), 'address_city' => $this->string(), 'address_street' => $this->string(), 'address_house' => $this->string(), 'current_status' => $this->string(16)->notNull(), ]); $this->addPrimaryKey('pk-sql_employees', '{{%sql_employees}}', 'id'); $this->createTable('{{%sql_employee_phones}}', [ 'id' => $this->primaryKey(), 'employee_id' => $this->char(36)->notNull(), 'country' => $this->integer()->notNull(), 'code' => $this->string()->notNull(), 'number' => $this->string()->notNull(), ]); $this->createIndex('idx-sql_employee_phones-employee_id', '{{%sql_employee_phones}}', 'employee_id'); $this->addForeignKey('fk-sql_employee_phones-employee', '{{%sql_employee_phones}}', 'employee_id', '{{%sql_employees}}', 'id', 'CASCADE', 'RESTRICT'); $this->createTable('{{%sql_employee_statuses}}', [ 'id' => $this->primaryKey(), 'employee_id' => $this->char(36)->notNull(), 'value' => $this->string(32)->notNull(), 'date' => $this->dateTime()->notNull(), ]); $this->createIndex('idx-sql_employee_statuses-employee_id', '{{%sql_employee_statuses}}', 'employee_id'); $this->addForeignKey('fk-sql_employee_statuses-employee', '{{%sql_employee_statuses}}', 'employee_id', '{{%sql_employees}}', 'id', 'CASCADE', 'RESTRICT'); } public function down() { $this->dropTable('{{%sql_employee_statuses}}'); $this->dropTable('{{%sql_employee_phones}}'); $this->dropTable('{{%sql_employees}}'); } }
Здесь для первичного UUID-ключа мы указали тип CHAR(36), но если объёмы большие и очень хочется скорости, то можете поковыряться с трансформацией UUID-строки в BINARY(16).
Применим миграцию к тестовой базе данных:
php tests/bin/yii migrate
Для автоматичекой очистки тестовых таблиц от предыдущего мусора создадим классы фикстур:
namespace tests\_fixtures; use yii\test\ActiveFixture; class EmployeeFixture extends ActiveFixture { public $tableName = '{{%sql_employees}}'; public $dataFile = '@tests/_fixtures/data/employees.php'; }
class EmployeePhoneFixture extends ActiveFixture { public $tableName = '{{%sql_employee_phones}}'; public $dataFile = '@tests/_fixtures/data/employee_phones.php'; }
use yii\test\ActiveFixture; class EmployeeStatusFixture extends ActiveFixture { public $tableName = '{{%sql_employee_statuses}}'; public $dataFile = '@tests/_fixtures/data/employee_statuses.php'; }
с пустыми данными:
return [ ];
в файлах
employees.php, employee_phones.php и employee_statuses.php в папке tests/_fixtures/data.
Теперь напишем тест, создающий наш репозиторий для придуманного в прошлый раз общего тестового базового класса:
namespace tests\unit\repositories; use app\repositories\SqlEmployeeRepository; use app\repositories\Hydrator; use app\tests\_fixtures\EmployeeFixture; use app\tests\_fixtures\EmployeePhoneFixture; use app\tests\_fixtures\EmployeeStatusFixture; class SqlEmployeeRepositoryTest extends BaseRepositoryTest { /** * @var \UnitTester */ public $tester; public function _before() { $this->tester->haveFixtures([ 'employee' => EmployeeFixture::className(), 'phone' => EmployeePhoneFixture::className(), 'status' => EmployeeStatusFixture::className(), ]); $this->repository = new SqlEmployeeRepository(\Yii::$app->db, new Hydrator()); } }
Указание этих фикстур с пустыми данными будет очищать базу перед каждым тестом.
И запустим его:
vendor/bin/codecept run unit repositories/SqlEmployeeRepositoryTest
Unit Tests (5) --------------------------------------------- ✔ SqlEmployeeRepositoryTest: Get (0.06s) ✔ SqlEmployeeRepositoryTest: Get not found (0.02s) ✔ SqlEmployeeRepositoryTest: Add (0.02s) ✔ SqlEmployeeRepositoryTest: Save (0.02s) ✔ SqlEmployeeRepositoryTest: Remove (0.02s) ------------------------------------------------------------ Time: 286 ms, Memory: 6.00MB OK (5 tests, 14 assertions)
Как видим, всё получилось. Мы пойдём дальше, а противники написания тестов могут по привычке проверить это всё вручную.
Ленивая загрузка
Сейчас у нас
Employee небольшой. Но в нём могут быть большие груды телефонов, фотографий, атрибутов, адресов, идентификаторов друзей и прочих объектов. Делать десятки запросов в базу каждый раз и загружать лишнюю информацию из всех таблиц весьма затратно, если нам требуется всего лишь вызвать метод changeAddress сотрудника.
Как поступать в этой ситуации? Для решения проблем с производительностью мы можем сделать так, чтобы дополнительные данные подгружались из базы не сразу (жадно), а только по просьбе, когда они будут нужны (лениво). Значит нам нужно научить
phones и statuses производить отложенную загрузку.
Поищем для этого полезные паттерны. Представим, что у нас есть некий класс по работе с какой-нибудь жуткой БД:
interface DbInterface { public function queryAll($sql); public function queryOne($sql); } class Db implements DbInterface { public function __construct($params) { ... } public function queryAll($sql) { ... } public function queryOne($sql) { ... } }
и этот класс подключается к ней в конструкторе, когда мы создаём объект:
$db = new Db($params); if (...) { $result = $db->queryAll(...); }
Но вдруг эта БД такая медленная, что этим подключением жутко тормозит наш процесс, даже когда
ifне срабатывает и методы queryAll дёргать не надо. Тогда что мы можем с этим сделать?
Давайте рядом с оригинальным классом
Db сделаем похожую на него обёртку DbProxy, которая будет иметь те же самые методы из интерфейса DbInterface, и которой мы будем передавать функцию создания оригинального объекта класса Db:class DbProxy implements DbInterface { private $original; private $factory; public function __construct(callable $factory) { $this->factory = $factory; } public function queryAll($sql) { return $this->getOriginal()->queryAll($sql); } public function queryOne($sql) { return $this->getOriginal()->queryOne($sql); } private function getOriginal() { if ($this->original === null) { $this->original = call_user_func($this->factory); } return $this->original; } }
И теперь вместо создания оригинального экземпляра
Db будем использовать эту замену:$db = new DbProxy(function () use ($params) { return new Db($params); });
Как видно в коде
DbProxy, он сохранит эту функцию у себя и выполнит её запуском $this->getOriginal() только когда мы дёрнем любой из методов queryAll и queryOne.
При наличии интерфейса
DbConnection такой прокси-объект сделать достаточно легко. Если же интерфейса нет, то для совместимости типов придётся наследовать DbProxy от самого класса Db.
Инструмент получился удобный, но с ним нам придётся делать отдельный прокси-класс для каждого класса, который мы хотим обернуть. Но можно развить тему дальше.
Что будет общего у
DbProxy, RestProxy и других подобных классов? У них будет приватное поле для хранения анонимной функции и поле для хранения оригинального объекта. И будет набор методов, построенных на основе оригинальных методов и вызывающих getOriginal. А что если это автоматизировать? Что если сделать такую функцию createProxy, которая при передаче ей имени класса Db сама бы через рефлексию получала список его методов и генерировала на лету новый Proxy-класс, наследующийся от оригинального? И потом мы бы вызывали её так:$proxyFactory = new ProxyFactory(); $db = $proxyFactory->createProxy(Db::class, function () use ($params) { return new Db($params); });
Было бы полезно. Это бы сразу решило проблему проксирования любых классов.
Дабы не сочинять такую вещь самим можем взять готовый компонент Ocramius/ProxyManager, работающий по такому же принципу. Ему нужен PHP 7, но сейчас он почти везде, так что это не проблема. Установим:
composer require ocramius/proxy-manager
С ним мы теперь можем почти также подменять оригинальные объекты на объекты-прокси, только вызов будет немного другой:
$proxyFactory = new LazyLoadingValueHolderFactory(); $db = $proxyFactory->createProxy(Db::class, function (&$target, LazyLoadingInterface $proxy) use ($params) { $target = new Db($params); $proxy->setProxyInitializer(null); });
Здесь мы должны не забыть изнутри самостоятельно удалить нашу анонимку вызовом
$proxy->setProxyInitializer(null), чтобы наша функция не запускалась снова и снова.
Теперь приступим к проксированию наших связей. Начнём с телефонов.
Во-первых, получим эту фабрику в конструктор:
use ProxyManager\Factory\LazyLoadingValueHolderFactory; class SqlEmployeeRepository implements EmployeeRepository { private $db; private $hydrator; private $lazyFactory; public function __construct( Connection $db, Hydrator $hydrator, LazyLoadingValueHolderFactory $lazyFactory ) { $this->db = $db; $this->hydrator = $hydrator; $this->lazyFactory = $lazyFactory; } ... }
Во-вторых, в методе
get при извлечении из базы поменяем new Phones(...) на создание прокси-объекта для Phones::class и поместим запрос на загрузку телефонов внутрь в анонимную функцию:class SqlEmployeeRepository implements EmployeeRepository { ... public function get(EmployeeId $id): Employee { $employee = (new Query())->select('*') ->from('{{%sql_employees}}') ->andWhere(['id' => $id->getId()]) ->one($this->db); if (!$employee) { throw new NotFoundException('Employee not found.'); } return $this->hydrator->hydrate(Employee::class, [ 'id' => new EmployeeId($employee['id']), ... 'createDate' => new \DateTimeImmutable($employee['create_date']), 'phones' => $this->lazyFactory->createProxy( Phones::class, function (&$target, LazyLoadingInterface $proxy) use ($id) { $phones = (new Query())->select('*') ->from('{{%sql_employee_phones}}') ->andWhere(['employee_id' => $id->getId()]) ->orderBy('id') ->all($this->db); $target = new Phones(array_map(function ($phone) { return new Phone( $phone['country'], $phone['code'], $phone['number'] ); }, $phones)); $proxy->setProxyInitializer(null); } ), 'statuses' => ..., ]); } ... }
Теперь как только в объекте
$employee произойдёт любое обращение к методу прокси-объекта вроде $this->phones->add($phone), сразу выполнится SQL-запрос в таблицу телефонов, внутри прокси восстановится оригинальный объект класса Phones и вызовется его метод add($phone).
В третьих, необходимо модифицировать метод
updatePhones, чтобы он сам не обновлял телефоны без необходимости.
Банальный вызов
$employee->getPhones() вернёт $this->phones->getAll() прокси-объекта, что сразу же запустит весь процесс загрузки из БД. Поэтому напрямую обращаться через геттер getPhones() мы не можем. Вместо этого в гидратор можно добавить ещё метод extract, который будет извлекать значение приватного поля из объекта:class Hydrator { private $reflectionClassMap; public function hydrate($class, array $data) { ... } public function extract($object, array $fields) { $result = []; $reflection = $this->getReflectionClass(\get_class($object)); foreach ($fields as $name) { $property = $reflection->getProperty($name); if ($property->isPrivate() || $property->isProtected()) { $property->setAccessible(true); } $result[$property->getName()] = $property->getValue($object); } return $result; } private function getReflectionClass($className) { ... } }
С ним мы можем уже безболезненно извлечь прокси-объект:
$data = $this->hydrator->extract($employee, ['phones']); $phones = $data['phones'];
И чтобы использовать такой гидратор в других проектах можно опубликовать его как отдельную 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(EmployeeId $id, Name $name, Address $address, array $phones) { ... $this->statuses = new ArrayObject(); $this->addStatus(Status::ACTIVE, $this->createDate); $this->recordEvent(new Events\EmployeeCreated($this->id)); } ... private function getCurrentStatus(): Status { $statuses = $this->statuses->getArrayCopy(); return end($statuses); } ... public function getStatuses(): array { return $this->statuses->getArrayCopy(); } }
Теперь можем также спокойно проксировать этот
ArrayObject класс при поиске:class SqlEmployeeRepository implements EmployeeRepository { ... public function get(EmployeeId $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 EmployeeId($employee['id']), ... 'phones' => ..., 'statuses' => $this->lazyFactory->createProxy( ArrayObject::class, function (&$target, LazyLoadingInterface $proxy) use ($id) { $statuses = (new Query())->select('*') ->from('{{%sql_employee_statuses}}') ->andWhere(['employee_id' => $id->getId()]) ->orderBy('id') ->all($this->db); $target = new ArrayObject(array_map(function ($status) { return new Status( $status['value'], new \DateTimeImmutable($status['date']) ); }, $statuses)); $proxy->setProxyInitializer(null); } ), ]); } }
и аналогично обрабатывать при сохранении:
private function updateStatuses(Employee $employee): 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(EmployeeId $id) { ... return $this->hydrator->hydrate(Employee::class, [ ... 'statuses' => array_map(function ($status) { return new Status( $status['value'], new \DateTimeImmutable($status['date']) ); }, Json::decode($employee['statuses'])), ]); }
И при записи закодируем массив обратно в JSON:
private static function extractEmployeeData(Employee $employee): 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
Комментариев нет:
Отправить комментарий