пятница, 29 марта 2019 г.

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




Итак, продолжим! Мы уже немного научились проектировать сущности на примере 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.phpemployee_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.
Инструмент получился удобный, но с ним нам придётся делать отдельный прокси-класс для каждого класса, который мы хотим обернуть. Но можно развить тему дальше.
Что будет общего у DbProxyRestProxy и других подобных классов? У них будет приватное поле для хранения анонимной функции и поле для хранения оригинального объекта. И будет набор методов, построенных на основе оригинальных методов и вызывающих 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.

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

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

JavaScript learn

Чтобы вставить элемент после какого-то элемента, нужно создать прототип. Element.prototype.appendAfter = function (element) { element.paren...