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

Сервисный слой и контроллеры




Продолжаем погружение в проектирование и разработку. В прошлой статье про проектирование доменных сущностей мы сочинили полноценную сущность-агрегат предметной области Employee со своей собственной бизнес-логикой для описания объектов сотрудников. Теперь нужно как-то работать с ней из контроллера, сохранять в базу данных и доставать обратно. Но наш Employee не содержит ни одной строки по работе с базой данных, поэтому сам сохраняться не умеет. Что же с этим делать?
Обычно в данном случае создают внешний объект хранилища (Repository), который будет управлять сохранением сущностей примерно такими методами:
$employee = $employeeRepository->get($id);
$employeeRepository->add($employee);
$employeeRepository->save($employee);
$employeeRepository->remove($employee);
И внутри себя он уже будет содержать SQL-код, сохраняющий весь агрегат с его статусами, телефонами и прочими внутренностями.
И именно этот EmployeeRepository сможем использовать в нужных нам местах. Например, в сервисах приложения. Что это за сервисы и что они делают?

Сервис приложения

Весь код записывать в контроллере неудобно. Причины этого озвучим ниже. Вместо этого есть популярная в узких кругах практика код с логикой из контроллера извлекать в отдельные классы, называемые сервисами уровня приложения (или прикладные сервисы) и уже из контроллеров обращаться к ним. Особо не будем вдаваться в терминологию, а просто посмотрим, что это такое.
В нашем случае для взаимодействия с контроллерами браузера или API нам понадобится некий прикладной сервис EmployeeService, который используя хранилище EmployeeRepository и диспетчер событий EventDispatcher осуществлял бы все операции с сущностью сотрудника:
namespace app\services;
 
use app\services\dto\AddressDto;
use app\services\dto\EmployeeArchiveDto;
use app\services\dto\EmployeeCreateDto;
use app\services\dto\EmployeeReinstateDto;
use app\services\dto\NameDto;
use app\services\dto\PhoneDto;
use app\dispatchers\EventDispatcher;
use app\entities\Employee\Address;
use app\entities\Employee\Employee;
use app\entities\Employee\EmployeeId;
use app\entities\Employee\Name;
use app\entities\Employee\Phone;
use app\repositories\EmployeeRepository;
 
class EmployeeService
{
    private $employees;
    private $dispatcher;
 
    public function __construct(EmployeeRepository $employees, EventDispatcher $dispatcher)
    {
        $this->employees = $employees;
        $this->dispatcher = $dispatcher;
    }
 
    public function create(EmployeeCreateDto $dto): void
    {
        $employee = new Employee(
            $this->employees->nextId(),
            new Name(
                $dto->name->last,
                $dto->name->first,
                $dto->name->middle
            ),
            new Address(
                $dto->address->country,
                $dto->address->region,
                $dto->address->city,
                $dto->address->street,
                $dto->address->house
            ),
            array_map(function (PhoneDto $phone) {
                return new Phone(
                    $phone->country,
                    $phone->code,
                    $phone->number
                );
            }, $dto->phones)
        );
        $this->employees->add($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
 
    public function rename(EmployeeId $id, NameDto $dto): void
    {
        $employee = $this->employees->get($id);
        $employee->rename(new Name(
            $dto->last,
            $dto->first,
            $dto->middle
        ));
        $this->employees->save($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
 
    public function changeAddress(EmployeeId $id, AddressDto $dto): void
    {
        $employee = $this->employees->get($id);
        $employee->changeAddress(new Address(
            $dto->country,
            $dto->region,
            $dto->city,
            $dto->street,
            $dto->house
        ));
        $this->employees->save($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
 
    public function addPhone(EmployeeId $id, PhoneDto $dto): void
    {
        $employee = $this->employees->get($id);
        $employee->addPhone(new Phone(
            $dto->country,
            $dto->code,
            $dto->number
        ));
        $this->employees->save($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
 
    public function removePhone(EmployeeId $id, $index): void
    {
        $employee = $this->employees->get($id);
        $employee->removePhone($index);
        $this->employees->save($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
 
    public function archive(EmployeeId $id, EmployeeArchiveDto $dto): void
    {
        $employee = $this->employees->get($id);
        $employee->archive($dto->date);
        $this->employees->save($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
 
    public function reinstate(EmployeeId $id, EmployeeReinstateDto $dto): void
    {
        $employee = $this->employees->get($id);
        $employee->reinstate($dto->date);
        $this->employees->save($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
 
    public function remove(EmployeeId $id): void
    {
        $employee = $this->employees->get($id);
        $employee->remove();
        $this->employees->remove($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
}
Мы можем передавать большое число параметров каждому методу:
public function changeAddress(EmployeeId $id, $country, $region, $city, $street, $house): void
{
    $employee = $this->employees->get($id);
    $employee->changeAddress(new Address(
        $country,
        $region,
        $city,
        $street,
        $house
    ));
    $this->employees->save($employee);
    $this->dispatcher->dispatch($employee->releaseEvents());
}
Но, чтобы не путаться в их порядке и количестве, эти наборы мы собрали в отдельные типизированные структуры передачи данных (Data Transfer Object) вроде AddressDto:
class AddressDto
{
    public $country;
    public $region;
    public $city;
    public $street;
    public $house;
}
и передаём уже их:
public function changeAddress(EmployeeId $id, AddressDto $dto): void
{
    $employee = $this->employees->get($id);
    $employee->changeAddress(new Address(
        $dto->country,
        $dto->region,
        $dto->city,
        $dto->street,
        $dto->house
    ));
    $this->employees->save($employee);
    $this->dispatcher->dispatch($employee->releaseEvents());
}
При желании мы можем и сам $id поместить внутрь DTO в поле $dto->id. Это на любителя...
Здесь сервис не особо большой и его действия совпадают с методами сущности. Но могут быть и сервисы, оперирующие несколькими агрегатами. Например, метод отправки этого сотрудника в командировку может не только модифицировать сущность сотрудника, но и одновременно создавать сущность приказа и заполнять сущность командировочного листа.
Можете посмотреть код нашего сервиса и его DTO на GitHub.
Если ваш проект содержит какой-либо построитель форм поверх объектов (либо если используете компонент Symfony/Forms или подобный), то можете использовать его для генерации форм ввода на основе этих структур. Если же ваш фреймворк не такой умный и не умеет этого делать, то можем в качестве DTO использовать саму модель формы AddressForm вместо AddressDto:
class AddressForm extends \yii\base\Model
{
    public $country;
    public $region;
    public $city;
    public $street;
    public $house;
 
    public function rules(): array { ... }
}
public function changeAddress(EmployeeId $id, AddressForm $form)
{
    ...
    $employee->changeAddress(new Address(
        $form->country,
        ...
    ));
    ...
}
Но этот подход привяжет код проекта к конкретному фреймворку. Это критично, если вы хотите разработать модуль, который бы в совместно с плагинами-адаптерами интегрировался в разные фреймворки и CMS. Но такие модули пишут весьма редко.
Если всё же хотите осуществить полную фреймворконезависимость, то можете оставить AddressDto и производить ручную конвертацию данных из формы в этот объект:
class AddressForm extends Model
{
    public $country;
    public $region;
    public $city;
    public $street;
    public $house;
 
    public function rules(): array { ... }
 
    public function getDto(): AddressDto
    {
        $dto = new AddressDto();
        $dto->country = $this->country;
        ...
        return $dto;
    }
}
Здесь набор и формат полей повторяет поля DTO и создание отдельных классов форм кажется избыточным. Но у некоторых форм могут быть отличия.
Например, если хотите заполнять дату в форме в виде трёх полей, а в DTO записать уже скомбинированный объект класса DateTimeImmutable, то это будет очень кстати:
class ReinstateForm extends Model
{
    public $year;
    public $mounth;
    public $day;
 
    public function rules(): array { ... }
 
    public function getDto(): ReinstateDto
    {
        $dto = new ReinstateDto();
        $dto->date = DateTimeImmutable::createFromFormat('Y-m-d', $this->year . '-' . $this->mounth . '-' . $this->day);
        return $dto;
    }
}
Так мы становимся полностью отвязанными от специфики реализации форм фреймворка.

Диспетчер событий

Далее нашему сервису понадобится некий диспетчер событий:
namespace app\dispatchers;
 
interface EventDispatcher
{
    public function dispatch(array $events): void;
}
Это штука, в которую мы будем передавать все сгенерированные в нашем ядре события:
$this->dispatcher->dispatch($employee->releaseEvents());
а он уже внутри себя будет что-то с ними делать. В простейшем случае можно сделать простую логирующую заглушку:
namespace app\dispatchers;
 
class DummyEventDispatcher implements EventDispatcher
{
    public function dispatch(array $events)
    {
        foreach ($events as $event) {
            \Yii::info('Dispatch event ' . \get_class($event));
        }
    }
}
и впоследствии подменить её на полноценный компонент, через который можно будет на конкретные события навешивать обработчики. А обработчики уже будут отправлять письма, чистить кеши, обновлять поисковые индексы... То есть делать всю техническиую рутину. А потом можем заменить его на QueueEventDispatcher, который складывал бы их в очередь и исполнял где-то в фоне.
Заглушку можно пока зарегистрировать в DI-контейнере вашего фреймворка. В Yii, например, это можно сделать прямо добавив секцию container в конфигурационные файлы web.phpconsole.php и test.php (если работать с yii2-app-basic) или в common/config/main.php (в yii2-app-advanced):
$config = [
    'id' => 'basic',
    'basePath' => dirname(__DIR__),
    'bootstrap' => ['log'],
    'container' => [
        'singletons' => [
            'app\dispatchers\EventDispatcher' => ['app\dispatchers\DummyEventDispatcher'],
        ],
    ]
    'components' => [
        ...
    ],
],
Но дабы это не копипастить, можно сделать отдельный загрузочный класс с настройками контейнера:
namespace app\bootstrap;
 
use app\dispatchers\EventDispatcher;
use app\dispatchers\DummyEventDispatcher;
use yii\base\BootstrapInterface;
 
class Bootstrap implements BootstrapInterface
{
    public function bootstrap($app)
    {
        $container = \Yii::$container;
 
        $container->setSingleton(EventDispatcher::class, DummyEventDispatcher::class);
    }
}
и в вышеуказанных конфигурационных файлах указать его в секции bootstrap:
$config = [
    'id' => 'basic',
    'basePath' => dirname(__DIR__),
    'bootstrap' => [
        'log',
        'app\bootstrap\Bootstrap',
    ],
    'components' => [
        ...
    ],
],
Так этот класс загрузится в момент запуска приложения и сконфигурирует контейнер внедрения зависимостей на внедрение именно объекта заглушки всем сервисам, которые будут требовать интерфейс диспетчера. И с отдельным классом мы не сильно засоряем конфигурационные файлы обилием конструкций use.

Контроллеры

И сейчас, когда разобрались с формами, можно соорудить «браузерный» контроллер:
namespace app\controllers;
 
use app\forms\EmployeeCreateForm;
use app\services\EmployeeService;
use yii\web\Controller;
use Yii
 
class EmployeeController extends Controller
{
    private $employeeService;
 
    public function __construct($id, $module, EmployeeService $employeeService, $config = [])
    {
        $this->employeeService = $employeeService;
        parent::__construct($id, $module, $config);
    }
 
    public function actionCreate()
    {
        $form = new EmployeeCreateForm();
 
        if ($form->load(\Yii::$app->request->post()) && $form->validate()) {
            try {
                $this->employeeService->create($form->getDto());
                Yii::$app->session->setFlash('success', 'Employee is created.');
                return $this->redirect(['index']);
            } catch (\DomainException $e) {
                Yii::$app->errorHandler->logException($e);
                Yii::$app->session->setFlash('error', Yii::t('errors', $e->getMessage()));                
            }
        }
 
        return $this->render('create', [
            'form' => $form,
        ]);
    }
}
На этом шаге часто возникает вопрос вроде «зачем создавать отдельный класс EmployeeService, если его код можно поместить прямо в контроллере«? На него есть несколько ответов.
Во-первых, помимо обычного веб-интерфейса у сайта может быть некое API. И оно будет содержать похожий контроллер:
namespace app\controllers\api;
 
use app\forms\EmployeeCreateForm;
use app\services\EmployeeService;
use yii\rest\Controller;
 
class EmployeeController extends Controller
{
    private $employeeService;
 
    public function __construct($id, $module, EmployeeService $employeeService, $config = [])
    {
        $this->employeeService = $employeeService;
        parent::__construct($id, $module, $config);
    }
 
    public function actionCreate()
    {
        $form = new EmployeeCreateForm();
        $form->load(\Yii::$app->request->getBodyParams(), '');
 
        if (!$form->validate()) {
            return $form;
        }
 
        try {
            $this->employeeService->create($form->getDto());
            Yii::$app->response->setStatusCode(201);
        } catch (\DomainException $e) {
             throw new BadRequestHttpException($e->getMessage(), 0, $e);            
        }
    }
}
И помимо этих двух у нас может появиться консольный контроллер для управления сотрудниками из командной строки (или Ratchet-демон). Если бы у нас не было класса EmployeeService, то код его методов вроде create пришлось бы копировать во все три-четыре контроллера.
Во-вторых, весьма сложно протестировать модульными тестами фрагмент кода внутри контроллера. Пришлось бы имитировать Yii::$app->request и парсить результирующий отрендеренный HTML-ответ. Его бы целиком пришлось проверять только внешними функциональными тестами. А в нашем подходе (когда весь основной код вынесен в отдельные классы) при необходимости можно легко протестировать сам класс EmployeeService также, как мы тестировали Employee.
В-третьих, даже наш класс EmployeeService в варианте с DTO уже нигде не использует классы фреймворка, поэтому его можно спокойно перенести без изменений хоть на WordPress.
Выносом кода в отдельный класс мы избавились от копирования и облегчили тестирование. Контроллеры у нас оказались практически пустыми. Они только заполняют модели форм данными запроса и вызывают методы сервиса. Вся логика из контроллеров перекочевала в доменную модель (где термин «доменная модель» объединяет вместе все сервисы и сущности). Именно это и имеет в виду принцип «тонкий контроллер и толстая модель» в частности и паттерн «Контроллер» из GRASP в общем, а не «пихайте всё в ActiveRecord».
А то у меня постоянно происходит диалог адептами Yii:
— Куда впихнуть эту груду кода? В ActiveRecord или в контроллер?
— А в чём проблема?
— Говорят, что и толстый контроллер – это плохо, и жирная модель тоже плохо.
— Ни туда, ни туда. Напиши отдельный класс.
— В каком смысле? А от чего его наследовать? От Component? От Model?
— Ни от чего не наследуй. Просто класс.
— А что... так можно?
— Да, во фреймворках можно делать просто классы :)
— Ух-ты... А мужики-то не знают... © Какой-то «Толстяк».
С контроллерами примерно разобрались. Подробнее их изучим в следующих статьях. А пока вернёмся к хранилищу.

Хранилище

Судя по исходному коду, нашему EmployeeService нужен некий объект хранилища EmployeeRepositoryс таким интерфейсом:
namespace app\repositories;
 
use app\entities\Employee\Employee;
use app\entities\Employee\EmployeeId;
 
interface EmployeeRepository
{
    /**
     * @param EmployeeId $id
     * @return Employee
     * @throws NotFoundException
     */
    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-запросы и заполнять объекты.
Стоит ещё заметить, что мы придумали некий метод nextId, который бы возвращал следующий идентификатор для нашей сущности. Мы его используем именно при создании сотрудника:
class EmployeeService
{
    private $employees;
    private $dispatcher;
 
    public function __construct(EmployeeRepository $employees, EventDispatcher $dispatcher)
    {
        $this->employees = $employees;
        $this->dispatcher = $dispatcher;
    }
 
    public function create(EmployeeCreateDto $dto): void
    {
        $employee = new Employee(
            $this->employees->nextId(),
            new Name(
                $dto->name->last,
                $dto->name->first,
                $dto->name->middle
            ),
            ...
        );
        $this->employees->add($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
}
А как же с этим быть, если первичные ключи в базе автоинкрементные и мы текущий идентификатор заранее на знаем?
Работать с заранее известным идентификатором вместо автоинкремента удобнее, так как его можно присваивать, передавать в объекты событий вроде new EmployeeCreated($this->id) в конструкторе класса Employee, использовать в путях загрузки файлов или куда-то записывать уже ДО сохранения объекта в базу данных. Если же значение заранее не известно, то так сделать мы не сможем. Да и не все хранилища поддерживают автоинктементную генерацию первичных ключей.
Поэтому вместо обычных автоинкрементов чаще используют генерируемый вручную UUID или заводят в базе данных отдельную таблицу (или секвенцию в PostgreSQL), из которой потом отдельным запростом в nextId() извлекают свежие идентификаторы.
Остальные методы у нас стандартные: getaddsave и remove. Но в некоторых сервисах нужно осуществлять дополнительные проверки. Например, в методе регистрации пользователя и смены его данных нужно проверять бизнес-требование уникальности имени и адреса электронной почты. Такую проверку в сущность User не поместишь, поэтому её добавим в сам сервис:
class UserService
{
    ...
 
    public function requestSignup($username, $email, $password): void
    {
        $this->guardUsernameIsUnique($username);
        $this->guardEmailIsUnique($email);
        $user = User::requestSignup(
            $username,
            $email,
            $this->passwordHasher->hash($password),
            $this->authTokenizer->generate(),
        );
        $this->userRepository->add($user);
    }
 
    public function changeEmail($userId, $email): void
    {
        $user = $this->userRepository->get($userId);
        $this->guardEmailIsUnique($email, $user->getId());
        $user->changeEmail($email);
        $this->userRepository->save($user);
    }
 
    public function confirmSignup($token): void
    {
        $user = $this->userRepository->getByEmailConfirmToken($token);
        $user->confirmSignup();
        $this->userRepository->save($user);
    }
 
    ...
 
    private function guardUsernameIsUnique($username, $exceptId = null): void
    {
        if ($this->userRepository->existsByUsername($username, $exceptId)) {
            throw new \DomainException('Username already exists');
        }
    }
 
    private function guardEmailIsUnique($email, $exceptId = null): void
    {
        if ($this->userRepository->existsByEmail($email, $exceptId)) {
            throw new \DomainException('Email already exists');
        }
    }
}
Помимо использования вспомогательных доменных сервисов PasswordHasher и AuthTokenizer этот прикладной сервис UserService вызывает у репозитория методы existsByUsername и existsByEmail, чтобы проверить, нет ли там уже других пользователей (исключая текущего) с такими же данными. И может потребовать ещё и реализацию методов getByEmailgetByEmailConfirmToken и подобных. Поэтому классы репозиториев могут быть более обширными.
Пока остановимся на нужном нам базовом наборе методов:
namespace app\repositories;
...
interface EmployeeRepository
{
    /**
     * @param EmployeeId $id
     * @return Employee
     * @throws NotFoundException
     */
    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;
}
а любые другие можно добавить при необходимости.
Метод get нам должен либо возвращать запрошенный из базы данных объект, либо кидать исключение, если не нашёл. Создадим сразу класс этого исключения:
namespace app\repositories;
 
class NotFoundException extends \LogicException
{
 
}
и так и оставим его пустым.
Сейчас сделаем примитивный MemoryEmployeeRepository, который будет сохранять записи в приватный массив $items. В следующих частях рассмотрим несколько реализаций репозиториев, поэтому для чистоты эксперимента подготовим для них максимально подробные тесты.
Репозитории будут разными. Кто-то будет хранить всё в MySQL скалярными полями или в JSON, кто-то – в другой БД. Можно было бы написать тест, который вызывает метод add() и проверяет все поля в базе и действительно ли там всё записалось в JSON, TIMESTAMP или DATETIME. В итоге на каждый репозиторий будут сотни строк тестов и потом получим проблемы с переписыванием тестов при каждом переименовании поля в базе.
Но не всё ли нам равно, как мы там храним? Мы придумали репозиторий для того, чтобы сохранять и извлекать объекты. Поэтому для нас главное – это записать объект и проверить, что он вернулся оттуда таким же, каким был.
Поэтому вместо создания отдельных низкоуровневых тестовых наборов создадим один высокоуровневый и оформим его абстрактным базовым классом:
namespace tests\unit\repositories;
 
use app\entities\Employee\EmployeeId;
use app\entities\Employee\Name;
use app\entities\Employee\Phone;
use app\entities\Employee\Status;
use app\repositories\EmployeeRepository;
use app\repositories\NotFoundException;
use tests\unit\entities\Employee\EmployeeBuilder;
use Codeception\Test\Unit;
 
abstract class BaseRepositoryTest extends Unit
{
    /**
     * @var EmployeeRepository
     */
    protected $repository;
 
    public function testGet(): void
    {
        $this->repository->add($employee = EmployeeBuilder::instance()->build());
 
        $found = $this->repository->get($employee->getId());
 
        $this->assertNotNull($found);
        $this->assertEquals($employee->getId(), $found->getId());
    }
 
    public function testGetNotFound(): void
    {
        $this->expectException(NotFoundException::class);
 
        $this->repository->get(new EmployeeId(25));
    }
 
    public function testAdd(): void
    {
        $employee = EmployeeBuilder::instance()
            ->withPhones([
                new Phone(7, '888', '00000001'),
                new Phone(7, '888', '00000002'),
            ])
            ->build();
 
        $this->repository->add($employee);
 
        $found = $this->repository->get($employee->getId());
 
        $this->assertEquals($employee->getId(), $found->getId());
        $this->assertEquals($employee->getName(), $found->getName());
        $this->assertEquals($employee->getAddress(), $found->getAddress());
 
        $this->assertEquals(
            $employee->getCreateDate()->getTimestamp(),
            $found->getCreateDate()->getTimestamp()
        );
 
        $this->checkPhones($employee->getPhones(), $found->getPhones());
        $this->checkStatuses($employee->getStatuses(), $found->getStatuses());
    }
 
    public function testSave(): void
    {
        $employee = EmployeeBuilder::instance()
            ->withPhones([
                new Phone(7, '888', '00000001'),
                new Phone(7, '888', '00000002'),
            ])
            ->build();
 
        $this->repository->add($employee);
 
        $edit = $this->repository->get($employee->getId());
 
        $edit->rename($name = new Name('New', 'Test', 'Name'));
        $edit->addPhone($phone = new Phone(7, '888', '00000003'));
        $edit->archive(new \DateTimeImmutable());
 
        $this->repository->save($edit);
 
        $found = $this->repository->get($employee->getId());
 
        $this->assertTrue($found->isArchived());
        $this->assertEquals($name, $found->getName());
 
        $this->checkPhones($edit->getPhones(), $found->getPhones());
        $this->checkStatuses($edit->getStatuses(), $found->getStatuses());
    }
 
    public function testRemove(): void
    {
        $employee = EmployeeBuilder::instance()->withId(5)->build();
        $this->repository->add($employee);
 
        $this->repository->remove($employee);
 
        $this->expectException(NotFoundException::class);
 
        $this->repository->get(new EmployeeId(5));
    }
 
    private function checkPhones(array $expected, array $actual): void
    {
        $phoneData = function (Phone $phone) {
            return $phone->getFull();
        };
 
        $this->assertEquals(
            array_map($phoneData, $expected),
            array_map($phoneData, $actual)
        );
    }
 
    private function checkStatuses(array $expected, array $actual): void
    {
        $statusData = function (Status $status) {
            return [
                'value' => $status->getValue(),
                'date' => $status->getDate()->getTimestamp(),
            ];
        };
 
        $this->assertEquals(
            array_map($statusData, $expected),
            array_map($statusData, $actual)
        );
    }
}
Здесь мы просто сохраняем записи и считываем их обратно, проверяя, совпадают ли их поля, статусы и телефоны. И такому тесту теперь не важно, с каким объектом он будет работать.
Теперь для нашего тренировочного MemoryEmployeeRepository напишем тест-наследник, который будет подставлять нужный объект в родительское поле $this->repository:
namespace tests\unit\repositories;
 
use app\repositories\MemoryEmployeeRepository;
 
class MemoryEmployeeRepositoryTest extends BaseRepositoryTest
{
    /**
     * @var \UnitTester
     */
    public $tester;
 
    public function _before(): void
    {
        $this->repository = new MemoryEmployeeRepository();
    }
}
Тесты готовы. Пора приступать к написанию репозиториев. В нашем MemoryEmployeeRepository мы можем столкнуться с проблемой генерации уникальных идентификаторов в методе nextId(). Мы могли бы везде пользоваться PHP-функцией uniquid():
namespace app\repositories;
 
class MemoryEmployeeRepository implements EmployeeRepository
{
    ...
 
    public function nextId(): EmployeeId
    {
        return uniquid('', true);
    }
}
но эта функция не всегда выдаёт уникальные значения. И нам бы хотелось генерировать идентификаторы общепринятого UUID-формата. В PHP7 для этих целей можно использовать любой алгоритм вроде этого на основе функции random_bytes, но если не хотите сочинять свои функции и вручную реализовывать совместимость с PHP5, то можете использовать готовую библиотеку Ramsey/Uuid. Её мы себе и установим:
composer require ramsey/uuid
Теперь можем написать класс репозитория с использованием этой библиотеки в nextId:
namespace app\repositories;
 
use app\entities\Employee\Employee;
use app\entities\Employee\EmployeeId;
use Ramsey\Uuid\Uuid;
 
class MemoryEmployeeRepository implements EmployeeRepository
{
    private $items = [];
 
    public function get(EmployeeId $id): Employee
    {
        if (!isset($this->items[$id->getId()])) {
            throw new NotFoundException('Employee not found.');
        }
        return clone $this->items[$id->getId()];
    }
 
    public function add(Employee $employee): void
    {
        $this->items[$employee->getId()->getId()] = $employee;
    }
 
    public function save(Employee $employee): void
    {
        $this->items[$employee->getId()->getId()] = $employee;
    }
 
    public function remove(Employee $employee): void
    {
        if ($this->items[$employee->getId()->getId()]) {
            unset($this->items[$employee->getId()->getId()]);
        }
    }
 
    public function nextId(): EmployeeId
    {
        return new EmployeeId(Uuid::uuid4()->toString());
    }
}
Здесь мы, как и договорились, вместо базы данных сохраняем сущности в приватный массив.
Запустим наши тесты:
vendor/bin/codecept run unit repositories
Unit Tests (5) ---------------------------------------------
 MemoryEmployeeRepositoryTest: Get (0.01s)
 MemoryEmployeeRepositoryTest: Get not found (0.00s)
 MemoryEmployeeRepositoryTest: Add (0.00s)
 MemoryEmployeeRepositoryTest: Save (0.00s)
 MemoryEmployeeRepositoryTest: Remove (0.00s)
------------------------------------------------------------

Time: 146 ms, Memory: 6.00MB

OK (5 tests, 14 assertions)
Объект ведёт себя корректно.
В реальной жизни использовать заглушку MemoryEmployeeRepository мы не будем, но она нам может помочь при необходимости протестировать тот же сервис EmployeeService без использования моков.
В следующих статьях напишем три реальные реализации EmployeeRepository:
и будем их проверять этими же универсальными тестами.

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

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

JavaScript learn

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