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

Продолжаем погружение в проектирование и разработку. В прошлой статье про проектирование доменных сущностей мы сочинили полноценную сущность-агрегат предметной области 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;
 
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->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

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

Ответить

 

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);
Ответить

 

Евгений

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

Ответить

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

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


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



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