Сервисный слой и контроллеры
Продолжаем погружение в проектирование и разработку. В прошлой статье про проектирование доменных сущностей мы сочинили полноценную сущность-агрегат предметной области Employee со своей собственной бизнес-логикой для описания объектов сотрудников. Теперь нужно как-то работать с ней из контроллера, сохранять в базу данных и доставать обратно. Но наш Employee не содержит ни одной строки по работе с базой данных, поэтому сам сохраняться не умеет. Что же с этим делать?
Обычно в данном случае создают внешний объект хранилища (Repository), который будет управлять сохранением сущностей.
И внутри себя он уже будет содержать SQL-код, сохраняющий весь агрегат с его статусами, телефонами и прочими внутренностями.
В оригинальном определении паттерна Репозиторий описывает объект, обеспечивающий работу с каким-либо хранилищем доменных сущностей, предоставляя над ними интерфейс коллекции.
То есть вещь, работая с которой мы можем добавлять, доставать удалять элементы как будто работаем с объектом Collection:
$employee = $employees->get($id); $employees->add($employee); $employees->remove($employee);
Это Collection-Like Repository. Здесь есть добавление, но нет обновления, так как предполагается, что за этим должен следить другой код и актуализовать данные, время от времени посылая запросы в БД.
Но в нашем случае мы можем добавить метод save:
$employee = $employees->get($id); $employees->add($employee); $employees->save($employee); $employees->remove($employee);
в котором будем выполнять UPDATE-запросы. Это уже будет Storage-Like Repository.
И такой 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\Id; 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( Id::next(), new \DateTimeImmutable(), 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(static 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(Id $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(Id $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(Id $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(Id $id, $index): void { $employee = $this->employees->get($id); $employee->removePhone($index); $this->employees->save($employee); $this->dispatcher->dispatch($employee->releaseEvents()); } public function archive(Id $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(Id $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(Id $id): void { $employee = $this->employees->get($id); $employee->remove(); $this->employees->remove($employee); $this->dispatcher->dispatch($employee->releaseEvents()); } }
Мы можем передавать большое число параметров каждому методу:
public function changeAddress(Id $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(Id $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(Id $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.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\Id; interface EmployeeRepository { /** * @param Id $id * @return Employee * @throws NotFoundException */ public function get(Id $id): Employee; public function add(Employee $employee): void; public function save(Employee $employee): void; public function remove(Employee $employee): void; }
В этих методах мы уже можем производить необходимые SQL-запросы и заполнять объекты.
Методы у нас стандартные: get, add, save и 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->users->add($user); } public function changeEmail($userId, $email): void { $user = $this->users->get($userId); $this->guardEmailIsUnique($email, $user->getId()); $user->changeEmail($email); $this->users->save($user); } public function confirmSignup($token): void { $user = $this->users->getByEmailConfirmToken($token); $user->confirmSignup(); $this->users->save($user); } ... private function guardUsernameIsUnique($username, $exceptId = null): void { if ($this->users->existsByUsername($username, $exceptId)) { throw new \DomainException('Username already exists'); } } private function guardEmailIsUnique($email, $exceptId = null): void { if ($this->users->existsByEmail($email, $exceptId)) { throw new \DomainException('Email already exists'); } } }
Помимо использования вспомогательных доменных сервисов PasswordHasher и AuthTokenizer этот прикладной сервис UserService вызывает у репозитория методы existsByUsername и existsByEmail, чтобы проверить, нет ли там уже других пользователей (исключая текущего) с такими же данными. И может потребовать ещё и реализацию методов getByEmail, getByEmailConfirmToken и подобных. Поэтому классы репозиториев могут быть более обширными.
Пока остановимся на нужном нам базовом наборе методов:
namespace app\repositories; ... interface EmployeeRepository { /** * @param Id $id * @return Employee * @throws NotFoundException */ public function get(Id $id): Employee; public function add(Employee $employee): void; public function save(Employee $employee): void; public function remove(Employee $employee): void; }
а любые другие можно добавить при необходимости.
Метод get нам должен либо возвращать запрошенный из базы данных объект, либо кидать исключение, если не нашёл. Создадим сразу класс этого исключения:
namespace app\repositories; class NotFoundException extends \LogicException { }
и так и оставим его пустым.
Сейчас сделаем примитивный MemoryEmployeeRepository, который будет сохранять записи в приватный массив $items. В следующих частях рассмотрим несколько реализаций репозиториев.
В коде проекта тестировать репозитории почти не нужно, но так как мы будем делать несколько реализаций, то для чистоты эксперимента подготовим для них максимально подробные тесты.
Репозитории будут разными. Кто-то будет хранить всё в MySQL скалярными полями или в JSON, кто-то – в другой БД. Можно было бы написать тест, который вызывает метод add() и проверяет все поля в базе и действительно ли там всё записалось в JSON, TIMESTAMP или DATETIME. В итоге на каждый репозиторий будут сотни строк тестов и потом получим проблемы с переписыванием тестов при каждом переименовании поля в базе.
Но не всё ли нам равно, как мы там храним? Мы придумали репозиторий для того, чтобы сохранять и извлекать объекты. Поэтому для нас главное – это записать объект и проверить, что он вернулся оттуда таким же, каким был.
Поэтому вместо создания отдельных низкоуровневых тестовых наборов создадим один высокоуровневый и оформим его абстрактным базовым классом:
namespace tests\unit\repositories; use app\entities\Employee\Id; 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 = (new EmployeeBuilder())->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 Id(uniqid())); } public function testAdd(): void { $employee = (new EmployeeBuilder()) ->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 = (new EmployeeBuilder()) ->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 { $id = new Id(uniqid()); $employee = (new EmployeeBuilder())->withId($id)->build(); $this->repository->add($employee); $this->repository->remove($employee); $this->expectException(NotFoundException::class); $this->repository->get($id); } private function checkPhones(array $expected, array $actual): void { $phoneData = static 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 = static 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(); } }
Тесты готовы. Пора приступать к написанию репозиториев:
namespace app\repositories; use app\entities\Employee\Employee; use app\entities\Employee\Id; use Ramsey\Uuid\Uuid; class MemoryEmployeeRepository implements EmployeeRepository { private $items = []; public function get(Id $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(): Id { return new Id(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:
SqlEmployeeRepositoryдля работы с SQL-запросами вручную;DoctrineEmployeeRepositoryдля сохранения с использованием Doctrine ORM;AREmployeeRepositoryдля интеграции сущностей с ActiveRecord.
и будем их проверять этими же универсальными тестами.
ВладимирА что делать если на такие вещи
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 кинуть.
RomanДмитрий, а как понять где мне нужно писать проверку бизнес требований, в сущности или в сервисе?
Дмитрий ЕлисеевЕсли все нужные для проверки данные находятся в сущности или VO, то и проверяем как можно глубже в сущности или VO. Если же внутри что-то проверить не удаётся, то придётся выносить наружу где все данные есть.
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()? Или создавать отдельный сервис, который бы возвращал нужную форм-модель?
Отдельно спасибо за отредактированные ссылки:)
Дмитрий Елисеев
Евгений – catine.ruОтличная статья! Как раз для любителей тонких контроллеров и тонких методов.
anton_zА почему не стали использовать синглтон для диспетчера событий и не публикуете их прямо из агрегата?
Я знаю, что синглтон многие не любят за неявность, мне интересно, почему именно такая реализация была выбрана.
Дмитрий ЕлисеевИз-за проблем с транзакциями, про которые говорил в прошлой статье.
anton_zЯ в коде к книге DDD in PHP такое решение нашел: там целиком прикладные сервисы оборачиваются в транзакции. Внутри сервис о транзакции ничего не знает, однако, все что происходит в сервисе, должно быть транзакционно. События, которые влияют на внешний нетранзакционный мир обозначаются специальным интерфейсом PublisableEvent, обработчик этих событий сохраняет их в базу под той же транзакцией. Потом другой процесс обрабатывает события.
Дмитрий ЕлисеевДа, такое оборачивание легко реализуется с CommandBus. А синглтоны не люблю в тестах.
anton_zА шина может/должна ответ возвращать (лучше типизированный)? У меня по вариантам использования разделение на команды/запросы не везде получается - нужны возвраты.
Дмитрий ЕлисеевВ асинхронной шине не должна.
СержСкажите, а как же быть со связями в репозиториях? Как мне получить у user допустим его profile ? Хотелось бы увидеть в статье эту тему.
Дмитрий ЕлисеевВнутри user будет $this->profile. В следующих статьях есть реализация ленивой загрузки.
IvanЗдравствуйте! Спасибо за статью.
Можете подсказать пример диспетчера, который будет обрабатывать такое событие: замена адреса, но при условии, что в новом адресе изменился город
Дмитрий ЕлисеевВ событие передаём старый адрес и новый:
и в обработчике сравниваем, изменился город или нет.
Denis PugachevДмитрий, у меня возник следующий вопрос.
Допустим ситуация: я реализую API метод на создание объекта, который должен вернуть ответ на запрос с полями объекта.
Я вызываю сервис создания объекта, передавая ему параметры.
Какой из следующих вариант с точки зрения DDD будет наиболее верным?
1. В ответ сервис возвращает мне созданный объект. В контроллере я из этого объекта извлекаю поля и возвращаю в виде JSON
2. В ответ сервис возвращает мне ID созданного объекта. Из другого сервиса в контроллере я получаю объект по ID и возвращаю его в виде JSON
3. В ответ сервис мне отдает ViewModel или DTO, в котором реализован например метод toArray, и я прямиком его отдаю в json_encode
Если у вас есть другой вариант, пожалуйста, опишите его.
Спасибо!
Дмитрий ЕлисеевУниверсальнее вариант 2. Но можно и 1, если нет отдельной ReadModel.
СергейВсе предельно ясно, спасибо. Один вопрос, хотелось бы увидеть как именно билдится $employeeService с его зависимостями и как происходит injection в контроллер.
Ведь обычно в приложении не делается new SomeController - фреймворк инстанциирует его за нас. Или я что-то не понимаю)
Дмитрий ЕлисеевФреймворк делает Yii::createObject('app\controllers\SomeController', [$id, $module]) через контейнер, а уже он рекурсивно парсит конструкторы классов, подставляя недостающий Yii::createObject('app\services\EmployeeService') и так далее.
Антон Пресняков – github.comЗдравствуйте! Спасибо за статью! Очень интересно. У меня вопрос про uuid. Несколько непривычно его использовать, скажите есть ли недостатки такого подхода?
Дмитрий ЕлисеевНедостаток случайного UUID - не получится по привычке делать ORDER BY id. Проблема решается через ORDER BY create_date.
Сергей ДоровскийДмитрий, как бы ты реализовал аутентификацию и авторизацию для Employee в рамках yii?
Дмитрий Елисеевclass AuthController extends Controller { ... public function actionLogin() { if (!Yii::$app->user->isGuest) { return $this->goHome(); } $form = new LoginForm(); if ($form->load(Yii::$app->request->post()) && $form->validate()) { try { $user = $this->authService->auth($form); Yii::$app->user->login(new Identity($user), $form->rememberMe ? $this->rememberMeDuration : 0); return $this->goBack(); } catch (\DomainException $e) { Yii::$app->errorHandler->logException($e); Yii::$app->session->setFlash('error', $e->getMessage()); } } return $this->render('login', [ 'model' => $form, ]); } }class Identity implements IdentityInterface { private $user; public function __construct(User $user) { $this->user = $user; } public static function findIdentity($id): ?self { $user = self::getRepository()->findActiveById($id); return $user ? new self($user) : null; } public function getId(): int { return $this->user->id; } public function getAuthKey(): string { return $this->user->auth_key; } public function validateAuthKey($authKey): bool { return $this->getAuthKey() === $authKey; } private static function getRepository(): UserReadRepository { return \Yii::$container->get(UserReadRepository::class); } }
СергейЗдравствуйте. В контроллере вы проверяете исключения DomainException, а в репозитории выбрасываете RuntimeException, которые получается нигде не обрабатываются? Их тоже следует обрабатывать в контроллере получается?
Сергей ДоровскийБыл похожий вопрос. RuntimeException это ни что иное, как 500 или 503 ошибка.
СергейМне кажется это неправильным, выдать 500 ошибку при неудачном сохранении допустим записи.
Сергей ДоровскийПри неудачном сохранении не должно быть RuntimeException. Необработанный RuntimeException сигнализирует о том, что приложение нужно завершить, т.к. дальнейшее выполнение невозможно. Если тебе кажется, что ты можешь его обработать и продолжить работу, значит неправильный Exception бросаешь. Имхо, конечно.
ЕвгенийСпасибо за хорошую статью. У меня есть два вопроса:
1. В примере достаточно простая задача реализуется, поэтому сервисный уровень не большой умещенный в один класс. Он по сути повторяет интерфейс модели. В реальности модель сложней, а значит и сервисный уровень так же. Допустимо ли разделение сервиса обслуживающего определенный объект (группу объектов) на части? Например вынос создания нового сложного объекта в отдельный класс? Иначе сервисный класс становится слишком большим и имеет слишком много ответственности.
2. Мне не понятно где нужно выполнять проверку значений объекта модели. В вопросах я видел что Вы рекомендуете типизацию проверять в объекте, а требования бизнеса уже в формах. Но в документации по DDD много где встречается, что объекты модели должны соблюдать свои инварианты, а это в моем понимании намного больше чем проверить что значение действительно является числом. В качестве примера: номер телефона абонента обязательно должен быть из штата Техас для одного тарифного плана услуги, а для другого допускается вся страна.
а. мы должны проверить что значение должно быть телефоном
б. мы должны проверить номер телефона в соответствии с тарифным планом.
По идее проверка 2 не является основной для предоставления услуги, а значит не является частью модели, но с другой стороны ее отсутствие в модели нарушает инварианты.
3. Независимо в формах или в самом объекте, при проверке возникают ошибки. Предлагается на каждую ошибку создавать свой тип exception, чтоб далее на уровне вида можно было вывести корректные ошибки, или есть другие, более красивые решения чем множество разных exception?
Спасибо за ответы!
Дмитрий Елисеев1. Всё допустимо. Если чего-то стало много, то выносим. Для создания сложных объектов как раз и придуманы фабрики и построители.
2. > В вопросах я видел что Вы рекомендуете типизацию проверять в объекте, а требования бизнеса уже в формах.
Наборот. В формах ставим примитивные правила валидации на формат: число, строку, email, телефон и для красоты required. А в объектах уже проверяем на Техас и другие сложные бизнес-требования.
3. Формы сами умеют выводить свои ошибки валидации. А из модели уже вылетают DomainException, которые выводим во flash-сообщении.
ЕвгенийСпасибо за ответ.
А формы относятся к какому уровню приложения? И каким образом они кидают ошибки. Поясню суть проблемы:
Приложение имеет несколько языков. Во время инициализации создается объект с нужными языковыми константами.
Когда форма выполняет проверку на каждую ошибку она может формировать свой exception. В таком случае, в большем приложении может получиться ну очень много различных exception. Что, мне кажется, не лучший вариант.
С другой стороны, они все одинаковые, и различия между ними нет, их задача вернуть описание ошибки. Т.е. Достаточно сделать throw new UserException($this->constants->msg1)
А вид в таком случае просто отобразит $e->getMessage()
Но тогда в форму нужно сетить константы, а значит код становится тяжелей использовать в другом месте, поскольку он зависит от конкретной реализации констант.
Что скажете?
Дмитрий ЕлисеевФормы создаются в контроллере и рендерятся в HTML-коде. Значит вместе с контроллерами относятся к слою UI. Они выводят ошибки валидации.
RomanДмитрий, человек ниже задал хороший вопрос - но он так и остался без ответа:
что, если ошибки нужно привязать к конкретному атрибуту формы, а не выводить во flash сообщении? Как быть в этом случае?
Можете ли привести хотя бы минимальный пример решения ?
Дмитрий ЕлисеевВ форму добавить кастомный валидатор, который будет делать эту же проверку.
Eugeniy UrvantsevПриветствую
>3. Формы сами умеют выводить свои ошибки валидации. А из модели уже вылетают DomainException, которые выводим во flash-сообщении.
что, если такие ошибки нужно привязать к конкретному атрибуту формы, а не выводить во flash сообщении?
как это делают стандартные валидаторы
Дмитрий ЕлисеевВ форму добавить кастомный валидатор, который будет делать эту же проверку.
Eugeniy UrvantsevЭто я знаю
Но вы пишите domain exception для проверки уникальности данных, который возникает в сервисе. К тому же, я считаю, что использование валидаторов в форме, которые ходят в базу - плохой практикой.
Дмитрий ЕлисеевТогда сделайте отдельный класс UserExistsException extends DomainException, поймайте через catch и привяжите ошибку к полю уже в контроллере или шаблоне.
ОмЗдравствуйте. Подскажите пожалуйста, где инициализируются Сервисы. Я вижу, что они передаются контроллеру в конструтор, а где создается контроллер не могу найти.
Дмитрий ЕлисеевОтветил выше на комментарий Сергея от 29 сентября.
ЕвгенийТут, кажется, опечатка. Вместо AddressDto должно быть EmployeeReinstateDto.
class ReinstateForm extends Model { public $year; public $mounth; public $day; public function rules(): array { ... } public function getDto(): <b>AddressDto</b> { $dto = new <b>AddressDto();</b> $dto->date = DateTimeImmutable::createFromFormat('Y-m-d', $this->year . '-' . $this->mounth . '-' . $this->day); return $dto; } }
Egor UshakovА куда принято "складывать" классы своих сервисов в Yii2 Advanced Template? Я понимаю, что можно в любую директорию и добавить autoload в composer.json. Но может уже какая устоявшаяся best practice по этому поводу есть? Просто первый раз сталкиваюсь с Yii2. Сходу в документации не нашел. А в проекте, как обычно, уже все вчера должно было быть.
Дмитрий ЕлисеевНикакой устоявшейся нет.
Alexey VerkhovtsevЗдравствуйте! Спасибо за статью!
Несколько вопросов
1) Может ли 1 сервис инжектить другой?
2) Если 1 верно, то как избежать циклических ссылок, когда оба сервиса могут ссылаться друг на друга? Видел 3 варианта (создать еще один общий сервис и туда оба сервиса или же сделать ленивую загрузку, или оба сервиса в контроллер переместить) Какой вариант лучше или может вы знаете иной?
3) На самом деле переплетается с первыми двумя. Есть 2 сервиса и каждый имеет по методу для создания, 1 создает шары, другой людей. Задача после создания человека создавать шар и ему передавать человека. Это реализовывать в методе create человека сразу после его создания или же вернуть созданный объект и в контроллере вызвать 2 метод из другого сервиса и передать туда его?
Заранее спасибо
Дмитрий Елисеев1) Да, может.
2) Циклические зависимости нужны только в крайнем случае. Обычно можно обойтись без них, перепроектировав систему по предложенным вариантам. Но если без них никак не получается, то жадная загрузка спасает.
3) Из контроллера вызвать один сервис, который вызовет по очереди два других. Или скопипастить в него создание обоих, если оно простое.
Alexey Verkhovtsevспасибо! По поводу 3, получается не желательно работать с разными сервисами в контроллере в пределах 1 метода?
Zhukov SergeiДмитрий, а где мы создаем заполняем AddressDto? И вообще DTO в целом? Спасибо за статью.
Дмитрий ЕлисеевВ контроллере.
СергейОчень полезно, спасибо. Только мне кажется, что если добавлять телефон (дополнительный), то не происходит проверки, что он уже есть. В Сущности Employee добавляется сразу add(Phone $phone)
Дмитрий ЕлисеевЭта проверка у нас находится внутри add.
Dmitrii ShitikovЗдравствуйте,
В статье DTO делается с открытыми свойствами:
class AddressDto { public $country; public $region; public $city; public $street; public $house; }Нет опасности, что свойства могут перезаписать по пути? Что если сделать их закрытыми и добавить геттеры?
Дмитрий ЕлисеевЕсли нужна гарантия неизменяемости, то да, можно сделать геттеры.
Но если используются статическиие анализаторы вроде Psalm, то можно пометить иммутабельным:
/** * @psalm-immutable */ class AddressDto { public $country; }Либо скоро дождаться PHP 8.1, где поля можно будет помечать как readonly.
Овчинников АртёмОтличный материал спасибо. Я видел подобное в NestJs entity services dto теперь немного начал понимать вроде После полного прохождения недели ООП я вернусь снова к этой статье пока рано(
МаксОх и школота начальная.