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

Продолжаем погружение в проектирование и разработку. В прошлой статье про проектирование доменных сущностей мы сочинили полноценную сущность-агрегат предметной области 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)
    {
        $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)
    {
        $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)
    {
        $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)
    {
        $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)
    {
        $employee = $this->employees->get($id);
        $employee->removePhone($index);
        $this->employees->save($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
 
    public function archive(EmployeeId $id, EmployeeArchiveDto $dto)
    {
        $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)
    {
        $employee = $this->employees->get($id);
        $employee->reinstate($dto->date);
        $this->employees->save($employee);
        $this->dispatcher->dispatch($employee->releaseEvents());
    }
 
    public function remove(EmployeeId $id)
    {
        $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)
{
    $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)
{
    $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() { ... }
}
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() { ... }
 
    public function getDto()
    {
        $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() { ... }
 
    public function getDto()
    {
        $dto = new AddressDto();
        $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);
}

Это штука, в которую мы будем передавать все сгенерированные в нашем ядре события:

$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.php, console.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);
 
    /**
     * @param Employee $employee
     */
    public function add(Employee $employee);
 
    /**
     * @param Employee $employee
     */
    public function save(Employee $employee);
 
    /**
     * @param Employee $employee
     */
    public function remove(Employee $employee);
 
    /**
     * @return EmployeeId
     */
    public function nextId();
}

В этих методах мы уже можем производить необходимые 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)
    {
        $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() извлекают свежие идентификаторы.

Остальные методы у нас стандартные: get, add, save и remove. Но в некоторых сервисах нужно осуществлять дополнительные проверки. Например, в методе регистрации пользователя и смены его данных нужно проверять бизнес-требование уникальности имени и адреса электронной почты. Такую проверку в сущность User не поместишь, поэтому её добавим в сам сервис:

class UserService
{
    ...
 
    public function requestSignup($username, $email, $password)
    {
        $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)
    {
        $user = $this->userRepository->get($userId);
        $this->guardEmailIsUnique($email, $user->getId());
        $user->changeEmail($email);
        $this->userRepository->save($user);
    }
 
    public function confirmSignup($token)
    {
        $user = $this->userRepository->getByEmailConfirmToken($token);
        $user->confirmSignup();
        $this->userRepository->save($user);
    }
 
    ...
 
    private function guardUsernameIsUnique($username, $exceptId = null)
    {
        if ($this->userRepository->existsByUsername($username, $exceptId)) {
            throw new \DomainException('Username already exists');
        }
    }
 
    private function guardEmailIsUnique($email, $exceptId = null)
    {
        if ($this->userRepository->existsByEmail($email, $exceptId)) {
            throw new \DomainException('Email already exists');
        }
    }
}

Помимо использования вспомогательных доменных сервисов PasswordHasher и AuthTokenizer этот прикладной сервис UserService вызывает у репозитория методы existsByUsername и existsByEmail, чтобы проверить, нет ли там уже других пользователей (исключая текущего) с такими же данными. И может потребовать ещё и реализацию методов getByEmail, getByEmailConfirmToken и подобных. Поэтому классы репозиториев могут быть более обширными.

Пока остановимся на нужном нам базовом наборе методов:

namespace app\repositories;
...
interface EmployeeRepository
{
    /**
     * @param EmployeeId $id
     * @return Employee
     * @throws NotFoundException
     */
    public function get(EmployeeId $id);
 
    /**
     * @param Employee $employee
     */
    public function add(Employee $employee);
 
    /**
     * @param Employee $employee
     */
    public function save(Employee $employee);
 
    /**
     * @param Employee $employee
     */
    public function remove(Employee $employee);
 
    /**
     * @return EmployeeId
     */
    public function nextId();
}

а любые другие можно добавить при необходимости.

Метод 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()
    {
        $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()
    {
        $this->expectException(NotFoundException::class);
 
        $this->repository->get(new EmployeeId(25));
    }
 
    public function testAdd()
    {
        $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()
    {
        $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()
    {
        $employee = EmployeeBuilder::instance()->withId(5)->build();
        $this->repository->add($employee);
 
        $this->repository->remove($employee);
 
        $this->expectException(NotFoundException::class);
 
        $this->repository->get(new EmployeeId(25));
    }
 
    private function checkPhones(array $expected, array $actual)
    {
        $phoneData = function (Phone $phone) {
            return $phone->getFull();
        };
 
        $this->assertEquals(
            array_map($phoneData, $expected),
            array_map($phoneData, $actual)
        );
    }
 
    private function checkStatuses(array $expected, array $actual)
    {
        $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()
    {
        $this->repository = new MemoryEmployeeRepository();
    }
}

Тесты готовы. Пора приступать к написанию репозиториев. В нашем MemoryEmployeeRepository мы можем столкнуться с проблемой генерации уникальных идентификаторов в методе nextId(). Мы могли бы везде пользоваться PHP-функцией uniquid():

namespace app\repositories;
 
class MemoryEmployeeRepository implements EmployeeRepository
{
    ...
 
    public function nextId()
    {
        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)
    {
        if (!isset($this->items[$id->getId()])) {
            throw new NotFoundException('Employee not found.');
        }
        return clone $this->items[$id->getId()];
    }
 
    public function add(Employee $employee)
    {
        $this->items[$employee->getId()->getId()] = $employee;
    }
 
    public function save(Employee $employee)
    {
        $this->items[$employee->getId()->getId()] = $employee;
    }
 
    public function remove(Employee $employee)
    {
        if ($this->items[$employee->getId()->getId()]) {
            unset($this->items[$employee->getId()->getId()]);
        }
    }
 
    public function nextId()
    {
        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:

и будем их проверять этими же универсальными тестами.

Комментарии

 

Владимир

А что делать если на такие вещи

array_map(function (PhoneDto $phone) {
    return new Phone(
        $phone->country,
        $phone->code,
        $phone->number
    );
}, $dto->phones)

не хватает памяти, т.к телефонов несколько тысяч. Логично что не нужно загружать их все? А как тогда логика проверки на уникальность и т.д. Или можно будет придумать ленивую загрузку?

Ответить

 

Дмитрий Елисеев

По несколько тысяч телефонов у каждого сотрудника?

Ответить

 

Владимир

Ну да, скажем - сотрудник это клиент, клиент-оптовик занимается регистрацией сим карт, у него тысячи телефонов или номеров сим. Иметь много можно, нельзя только иметь дубли.

Ответить

 

Дмитрий Елисеев

Тогда сим-карты будут сами по себе отдельными сущностями с id и client_id. В отдельной таблице и со своим репозиторием и сервисом. А не внутри клиента.

Ответить

 

antin_z

То есть будете делать по репозиторию на таблицу, верно?

Ответить

 

Виктор

Полагаю, по репозиторию на сущность. Но с телефонами пример хороший, хотел спросит другое: кто\что должен врзвращать, например, количество телефонов у сотрудника.

Ответить

 

Дмитрий Елисеев

Если нужно для логики домена, то:

$count = $phoneRepository->countByClient($clientId);
if ($count > 100) {
    throw new \DomainException('Too many phones.);
}

Если же нужно выводить в листинге на сайте, то сделаете JOIN или подзапрос с SELECT COUNT(*) и вернёте в поле во вью-модели ClientView.

Ответить

 

Андрей

Получается если мне надо выбрать , например, всех пользователей у которых количество телефонов скажем больше пяти, то надо создать новый метод в EmployeeRepositoryMysql в котором будет JOIN к таблице с телефонами? Т.е. можно в рамках одного репозитория работать с разными таблицами?

И если пользователи и телефоны хранятся в разных таблицах, как и где осуществлять сборку полноценного пользователя с телефонами?

Ответить

 

Дмитрий Елисеев

Да, обращаться можно. Так и в магазине для товаров будет метод вроде getAllByCategory($id, $offset, $limit).

Если собирать нужно для вывода в представлении и оригинальные сущности там использовать неудобно, то для выборок на сайте сделаете отдельный ClientReadRepository с методом getAllWithPhones, который будет возвращать собранный ClientView.

Ответить

 

Патрик Фельдеш
Комментарий удалён
Ответить

 

Дмитрий Елисеев

Controller -> EmployeeService -> Employee

Контроллеры дёргают только EmployeeService. Он у нас и является набором юз-кейсов, которые уже оперируют сущностями. К сущностям напрямую контроллер никакого доступа не имеет.

Ответить

 

Семенов Максим

Спасибо. С удовольствием провел пол часа своей жизни за чтением вашей статьи.
С нетерпением жду продолжения!

Ответить

 

Семенов Максим

Так же пара вопросов:

1. В Dto объектах разрешается делать поля публичными? Не является ли это нарушением инкапсуляции?

2. Если у сервиса есть несколько методов, например, описанный вами UserService. В каких-то методах нужен passwordHasher, в каких-то нет. Получается что для того, что вызвать метод changeEmail нам все равно нужно передать в конструктор объект passwordHasher, который в данном случае нам не нужен. Что обычно делается в таких случаях? Сервис разделяется на несколько частей? Или используется как есть?

Ответить

 

Дмитрий Елисеев

1. DTO - это структура данных (замена ассоциативного массива), а не полноценный объект.

2. В подходе CQRS с Command Bus на каждую команду (DTO) пишется по одному классу-обработчику. Например, для команды ProductCreateCommand делается ProductCreateHandler и из контроллера все команды закидываются в шину как здесь и здесь. Получается больше кода, но лишние объекты не иньектятся. Поэтому либо разделяют на несколько частей, либо не беспокоятся за лишние полкилобайта памяти для PasswordHasher.

Ответить

 

Руслан Самолетов

Круто.

А куда можно добавить транзакцию? Может в контроллере что то подобное?

try {
    $this->conn->beginTransaction();
    $this->service->create($data);
    $this->conn->commit();
} catch (\Exception $e) {
    $this->conn->rollback();
}
Ответить

 

antin_z

В сервисе и только в сервисе

Ответить

 

Александр

Может стоит в класс Id добавить метод

public function __toString()
{
    return (string) $this->id;
}

а-то записи вида $employee->getId()->getId() выглядят не очень.

Ответить

 

Дмитрий Елисеев

Можно.

Ответить

 

Добрый сосед

Чем отличаются методы у репозитория add и save?

Ответить

 

Дмитрий Елисеев

Метод add производит INSERT-запрос, а save выполняет UPDATE.

Ответить

 

Glagola

У меня в репозиториях только один метод-save, отписался тут на данную тему.

Ответить

 

Denis Klimenko

Спасибо за статью)
можно сделать так чтобы DTO-шки наследовались от одной - чтобы руками не сетить свойства

class BaseDTO
{
    public function __construct(...$params)
    {
        $reflect = new ReflectionClass($this);
        $props  = $reflect->getProperties(ReflectionProperty::IS_PUBLIC);

        foreach ($props as $i => $prop) {
            $property = $prop->getName();
            $this->$property = $params[$i];
        }
    }
}

class UserExampleDTO extends BaseDTO
{
    public $firstname;
    public $lastname;
    public $middlename;
}


$dto = new UserExampleDTO('Bob', 'Lavrov', 'Oleksandrovich');

echo $dto->firstname . PHP_EOL;
echo $dto->lastname . PHP_EOL;
echo $dto->middlename . PHP_EOL;

И по поводу именования методов репозитория, хорошо бы иметь некий стандарт, - если знаете такие делитесь)

Spring - Table 4. Supported keywords inside method names

Ответить

 

Дмитрий Елисеев

Надёжнее засеттить руками, через гидратор:

$dto = $this->hydrator->hydrate(new UserExampleDTO(), [
    'firstname' => 'Bob',
    'lastname' => 'Lavrov',
    'middlename' => 'Oleksandrovich'
]);

или аналогично через Yii::configure($dto, [...]).

Иначе в десяти свойствах поменяете что-то местами и весь проект накроется.

Ответить

 

Denis Klimenko

и в итоге лучше сетить руками, через свойства объекта :D
спасибо)

Ответить

 

Дмитрий Елисеев

А по именованию add/persist, remove/delete... Кому как повезёт :)

Ответить

 

Alex

Наверное стоит вынести логику валидации из сервисов в отдельный какой-то модуль, а лучше для этого использовать готовую библиотеку, например https://github.com/Respect/Validation

Ответить

 

Дмитрий Елисеев

Простая валидация ввода производится в формах. А в сервисах и в сущностях производим проверку бизнес-требований.

Ответить

 

Alex

Вот у вас в UserService два метода, начинающиеся на guard. Это бизнес валидация: сервисов может быть много, валидаций еще больше, предлагаете описывать вручную все эти методы?

Почему, например, слово guard? Другой разработчик новый метод назовет по-другому, в итоге получим беспорядок в конкретном сервисе, не говоря о том, что эти методы могут дублироваться из сервиса в сервис.

Ответить

 

Дмитрий Елисеев

Не нравится слово guard - назовите с assert или check.

А как Вы будете "не вручную" для проверки уникальности свою библиотеку использовать? Приведите пример кода, если не сложно.

Ответить

 

Alex

Про конкретное слово я упомянул, чтобы указать на некий общий паттерн, проблему которого решает тот же Yii2, например или библиотека выше(и даже не одна) Почему бы не сделать также? Задавать в бизнес-моделях только правила валидации, а вызывать валидацию в сервисе. Вот тут можно посмотреть на пример того, что я предлагаю http://validatejs.org/ (раздел Constraints, библиотека под js).

И да, речь идет только о бизнес валидации, не касаясь валидации конкретных форм.

Ответить

 

Дмитрий Елисеев

Напишите пример своего UserService со своими проверками вместо моих guard-ов. Сравним.

Ответить

 

Alex

Зачем? Вам непонятна моя идея? Я постарался объяснить, привел пример, вы не знаете js?

Ответить

 

Дмитрий Елисеев

> Зачем?

Так принято вести конструктивную аргументированную дискуссию.

> привел пример

Нет, пример Вы так и не привели.

Ответить

 

Alex

Я считаю по-другому и, так как, текущее обсуждение отошло от изначальной проблематики я не вижу смысла его продолжать. Спасибо за уделенное время!

Ответить

 

Denis

Дмитрий, подскажите, что означает "Простая валидация ввода"? Как приблизительно понять границу, когда валидация уже перейдет в бизнес-требование?

Например, "поле должно быть числом больше 0" - это бизнес-правило или простая валидация формы?

Ответить

 

Дмитрий Елисеев

Представьте, что валидации в форме у Вас нет. Тогда сразу становится видно, что на правило "поле должно быть числом" - это просто формат ввода, на который можно не обращать внимания и положиться на типизацию. А вот "сумма платежа должно быть больше 0" и "не больше баланса на счету" - это уже опасные бизнес-правила, которые желательно в ядре проверить и Exception кинуть.

Ответить

 

Vladimir

Проверку прав, ролей в сервисе лучше реализовывать?

Ответить

 

Дмитрий Елисеев

Обычно проверяем в контроллере:

if (!Yii::$app->user->can('editPost', ['post' => $post]) {
    throw new ForbiddenHttpException();
}

Потому как в обычном контроллере такая проверка нужна, а в консольном - нет.

А если система доступа весьма продвинутая, то делаем её отдельным сервисом и из класса-правила EditOwnPostRule дёргаем его allowToEditPost($userId, ...).

Ответить

 

Добрый сосед

Если у EmployeeCreateDto EmployeeCreateForm не отличаются поля, можно использовать EmployeeCreateForm как DTO? А если надо преобразование какое, то сделать это в сервисе.

Ответить

 

Добрый сосед

Т.е. не вместо. Можно отказаться от DTO и передавать в сервис сами поля из формы? Для чего DTO необходим вообще?

Ответить

 

Дмитрий Елисеев

Про это говорил в статье.

Ответить

 

Добрый сосед

Если мы откажемся от DTO в пользу форм фреймворка, то у нас уже будет не совсем DDD. А использовать DTO мы будем только для промежуточного хранения данных? Получится что то на пободобие того, о чем тут говорят.

Ответить

 

Дмитрий Елисеев

Нет. DDD - это философия программирования терминами реальной жизни. Если перейдём на формы, то потеряем только фреймворконезависимость.

Ответить

 

Добрый сосед

Спасибо. А если нам нужно будет изменять уже созданного Employee целиком в одной форме, то кто должен создавать EmployeeUpdateForm. В сущности Employee делать метод getEmployeeUpdateForm()? Или создавать отдельный сервис, который бы возвращал нужную форм-модель?

Отдельно спасибо за отредактированные ссылки:)

Ответить

 

Дмитрий Елисеев
$form = new EmployeeUpdateForm($employee);
Ответить

 

Евгений

Отличная статья! Как раз для любителей тонких контроллеров и тонких методов.

Ответить

 

anton_z

А почему не стали использовать синглтон для диспетчера событий и не публикуете их прямо из агрегата?
Я знаю, что синглтон многие не любят за неявность, мне интересно, почему именно такая реализация была выбрана.

Ответить

 

Дмитрий Елисеев

Из-за проблем с транзакциями, про которые говорил в прошлой статье.

Ответить

 

anton_z

Я в коде к книге DDD in PHP такое решение нашел: там целиком прикладные сервисы оборачиваются в транзакции. Внутри сервис о транзакции ничего не знает, однако, все что происходит в сервисе, должно быть транзакционно. События, которые влияют на внешний нетранзакционный мир обозначаются специальным интерфейсом PublisableEvent, обработчик этих событий сохраняет их в базу под той же транзакцией. Потом другой процесс обрабатывает события.

Ответить

 

Дмитрий Елисеев

Да, такое оборачивание легко реализуется с CommandBus. А синглтоны не люблю в тестах.

Ответить

 

anton_z

А шина может/должна ответ возвращать (лучше типизированный)? У меня по вариантам использования разделение на команды/запросы не везде получается - нужны возвраты.

Ответить

 

Дмитрий Елисеев

В асинхронной шине не должна.

Ответить

 

Серж

Скажите, а как же быть со связями в репозиториях? Как мне получить у user допустим его profile ? Хотелось бы увидеть в статье эту тему.

Ответить

 

Дмитрий Елисеев

Внутри user будет $this->profile. В следующих статьях есть реализация ленивой загрузки.

Ответить

 

Ivan

Здравствуйте! Спасибо за статью.
Можете подсказать пример диспетчера, который будет обрабатывать такое событие: замена адреса, но при условии, что в новом адресе изменился город

Ответить

 

Дмитрий Елисеев

В событие передаём старый адрес и новый:

new AddressChanged($oldAddress, $newAddress);

и в обработчике сравниваем, изменился город или нет.

Ответить

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

Войти | Завести аккаунт


(никто не увидит)



Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>