Сервисный слой и контроллеры
Продолжаем погружение в проектирование и разработку. В прошлой статье про проектирование доменных сущностей мы сочинили полноценную сущность-агрегат предметной области 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.
и будем их проверять этими же универсальными тестами.
А что делать если на такие вещи
не хватает памяти, т.к телефонов несколько тысяч. Логично что не нужно загружать их все? А как тогда логика проверки на уникальность и т.д. Или можно будет придумать ленивую загрузку?
По несколько тысяч телефонов у каждого сотрудника?
Ну да, скажем - сотрудник это клиент, клиент-оптовик занимается регистрацией сим карт, у него тысячи телефонов или номеров сим. Иметь много можно, нельзя только иметь дубли.
Тогда сим-карты будут сами по себе отдельными сущностями с id и client_id. В отдельной таблице и со своим репозиторием и сервисом. А не внутри клиента.
То есть будете делать по репозиторию на таблицу, верно?
Полагаю, по репозиторию на сущность. Но с телефонами пример хороший, хотел спросит другое: кто\что должен врзвращать, например, количество телефонов у сотрудника.
Если нужно для логики домена, то:
Если же нужно выводить в листинге на сайте, то сделаете 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.
Круто.
А куда можно добавить транзакцию? Может в контроллере что то подобное?
В сервисе и только в сервисе
Может стоит в класс Id добавить метод
а-то записи вида $employee->getId()->getId() выглядят не очень.
Можно.
Чем отличаются методы у репозитория add и save?
Метод add производит INSERT-запрос, а save выполняет UPDATE.
У меня в репозиториях только один метод-save, отписался тут на данную тему.
Спасибо за статью)
можно сделать так чтобы DTO-шки наследовались от одной - чтобы руками не сетить свойства
И по поводу именования методов репозитория, хорошо бы иметь некий стандарт, - если знаете такие делитесь)
Spring - Table 4. Supported keywords inside method names
Надёжнее засеттить руками, через гидратор:
или аналогично через Yii::configure($dto, [...]).
Иначе в десяти свойствах поменяете что-то местами и весь проект накроется.
и в итоге лучше сетить руками, через свойства объекта :D
спасибо)
А по именованию add/persist, remove/delete... Кому как повезёт :)
Наверное стоит вынести логику валидации из сервисов в отдельный какой-то модуль, а лучше для этого использовать готовую библиотеку, например https://github.com/Respect/Validation
Простая валидация ввода производится в формах. А в сервисах и в сущностях производим проверку бизнес-требований.
Вот у вас в UserService два метода, начинающиеся на guard. Это бизнес валидация: сервисов может быть много, валидаций еще больше, предлагаете описывать вручную все эти методы?
Почему, например, слово guard? Другой разработчик новый метод назовет по-другому, в итоге получим беспорядок в конкретном сервисе, не говоря о том, что эти методы могут дублироваться из сервиса в сервис.
Не нравится слово guard - назовите с assert или check.
А как Вы будете "не вручную" для проверки уникальности свою библиотеку использовать? Приведите пример кода, если не сложно.
Про конкретное слово я упомянул, чтобы указать на некий общий паттерн, проблему которого решает тот же Yii2, например или библиотека выше(и даже не одна) Почему бы не сделать также? Задавать в бизнес-моделях только правила валидации, а вызывать валидацию в сервисе. Вот тут можно посмотреть на пример того, что я предлагаю http://validatejs.org/ (раздел Constraints, библиотека под js).
И да, речь идет только о бизнес валидации, не касаясь валидации конкретных форм.
Напишите пример своего UserService со своими проверками вместо моих guard-ов. Сравним.
Зачем? Вам непонятна моя идея? Я постарался объяснить, привел пример, вы не знаете js?
> Зачем?
Так принято вести конструктивную аргументированную дискуссию.
> привел пример
Нет, пример Вы так и не привели.
Я считаю по-другому и, так как, текущее обсуждение отошло от изначальной проблематики я не вижу смысла его продолжать. Спасибо за уделенное время!
Дмитрий, подскажите, что означает "Простая валидация ввода"? Как приблизительно понять границу, когда валидация уже перейдет в бизнес-требование?
Например, "поле должно быть числом больше 0" - это бизнес-правило или простая валидация формы?
Представьте, что валидации в форме у Вас нет. Тогда сразу становится видно, что на правило "поле должно быть числом" - это просто формат ввода, на который можно не обращать внимания и положиться на типизацию. А вот "сумма платежа должно быть больше 0" и "не больше баланса на счету" - это уже опасные бизнес-правила, которые желательно в ядре проверить и Exception кинуть.
Дмитрий, а как понять где мне нужно писать проверку бизнес требований, в сущности или в сервисе?
Если все нужные для проверки данные находятся в сущности или VO, то и проверяем как можно глубже в сущности или VO. Если же внутри что-то проверить не удаётся, то придётся выносить наружу где все данные есть.
Проверку прав, ролей в сервисе лучше реализовывать?
Обычно проверяем в контроллере:
Потому как в обычном контроллере такая проверка нужна, а в консольном - нет.
А если система доступа весьма продвинутая, то делаем её отдельным сервисом и из класса-правила EditOwnPostRule дёргаем его allowToEditPost($userId, ...).
Если у EmployeeCreateDto EmployeeCreateForm не отличаются поля, можно использовать EmployeeCreateForm как DTO? А если надо преобразование какое, то сделать это в сервисе.
Т.е. не вместо. Можно отказаться от DTO и передавать в сервис сами поля из формы? Для чего DTO необходим вообще?
Про это говорил в статье.
Если мы откажемся от DTO в пользу форм фреймворка, то у нас уже будет не совсем DDD. А использовать DTO мы будем только для промежуточного хранения данных? Получится что то на пободобие того, о чем тут говорят.
Нет. DDD - это философия программирования терминами реальной жизни. Если перейдём на формы, то потеряем только фреймворконезависимость.
Спасибо. А если нам нужно будет изменять уже созданного Employee целиком в одной форме, то кто должен создавать EmployeeUpdateForm. В сущности Employee делать метод getEmployeeUpdateForm()? Или создавать отдельный сервис, который бы возвращал нужную форм-модель?
Отдельно спасибо за отредактированные ссылки:)
Отличная статья! Как раз для любителей тонких контроллеров и тонких методов.
А почему не стали использовать синглтон для диспетчера событий и не публикуете их прямо из агрегата?
Я знаю, что синглтон многие не любят за неявность, мне интересно, почему именно такая реализация была выбрана.
Из-за проблем с транзакциями, про которые говорил в прошлой статье.
Я в коде к книге DDD in PHP такое решение нашел: там целиком прикладные сервисы оборачиваются в транзакции. Внутри сервис о транзакции ничего не знает, однако, все что происходит в сервисе, должно быть транзакционно. События, которые влияют на внешний нетранзакционный мир обозначаются специальным интерфейсом PublisableEvent, обработчик этих событий сохраняет их в базу под той же транзакцией. Потом другой процесс обрабатывает события.
Да, такое оборачивание легко реализуется с CommandBus. А синглтоны не люблю в тестах.
А шина может/должна ответ возвращать (лучше типизированный)? У меня по вариантам использования разделение на команды/запросы не везде получается - нужны возвраты.
В асинхронной шине не должна.
Скажите, а как же быть со связями в репозиториях? Как мне получить у user допустим его profile ? Хотелось бы увидеть в статье эту тему.
Внутри user будет $this->profile. В следующих статьях есть реализация ленивой загрузки.
Здравствуйте! Спасибо за статью.
Можете подсказать пример диспетчера, который будет обрабатывать такое событие: замена адреса, но при условии, что в новом адресе изменился город
В событие передаём старый адрес и новый:
и в обработчике сравниваем, изменился город или нет.
Дмитрий, у меня возник следующий вопрос.
Допустим ситуация: я реализую 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') и так далее.
Здравствуйте! Спасибо за статью! Очень интересно. У меня вопрос про uuid. Несколько непривычно его использовать, скажите есть ли недостатки такого подхода?
Недостаток случайного UUID - не получится по привычке делать ORDER BY id. Проблема решается через ORDER BY create_date.
Дмитрий, как бы ты реализовал аутентификацию и авторизацию для Employee в рамках yii?
Здравствуйте. В контроллере вы проверяете исключения 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. Они выводят ошибки валидации.
Дмитрий, человек ниже задал хороший вопрос - но он так и остался без ответа:
что, если ошибки нужно привязать к конкретному атрибуту формы, а не выводить во flash сообщении? Как быть в этом случае?
Можете ли привести хотя бы минимальный пример решения ?
В форму добавить кастомный валидатор, который будет делать эту же проверку.
Приветствую
>3. Формы сами умеют выводить свои ошибки валидации. А из модели уже вылетают DomainException, которые выводим во flash-сообщении.
что, если такие ошибки нужно привязать к конкретному атрибуту формы, а не выводить во flash сообщении?
как это делают стандартные валидаторы
В форму добавить кастомный валидатор, который будет делать эту же проверку.
Это я знаю
Но вы пишите domain exception для проверки уникальности данных, который возникает в сервисе. К тому же, я считаю, что использование валидаторов в форме, которые ходят в базу - плохой практикой.
Тогда сделайте отдельный класс UserExistsException extends DomainException, поймайте через catch и привяжите ошибку к полю уже в контроллере или шаблоне.
Здравствуйте. Подскажите пожалуйста, где инициализируются Сервисы. Я вижу, что они передаются контроллеру в конструтор, а где создается контроллер не могу найти.
Ответил выше на комментарий Сергея от 29 сентября.
Тут, кажется, опечатка. Вместо AddressDto должно быть EmployeeReinstateDto.
А куда принято "складывать" классы своих сервисов в Yii2 Advanced Template? Я понимаю, что можно в любую директорию и добавить autoload в composer.json. Но может уже какая устоявшаяся best practice по этому поводу есть? Просто первый раз сталкиваюсь с Yii2. Сходу в документации не нашел. А в проекте, как обычно, уже все вчера должно было быть.
Никакой устоявшейся нет.
Здравствуйте! Спасибо за статью!
Несколько вопросов
1) Может ли 1 сервис инжектить другой?
2) Если 1 верно, то как избежать циклических ссылок, когда оба сервиса могут ссылаться друг на друга? Видел 3 варианта (создать еще один общий сервис и туда оба сервиса или же сделать ленивую загрузку, или оба сервиса в контроллер переместить) Какой вариант лучше или может вы знаете иной?
3) На самом деле переплетается с первыми двумя. Есть 2 сервиса и каждый имеет по методу для создания, 1 создает шары, другой людей. Задача после создания человека создавать шар и ему передавать человека. Это реализовывать в методе create человека сразу после его создания или же вернуть созданный объект и в контроллере вызвать 2 метод из другого сервиса и передать туда его?
Заранее спасибо
1) Да, может.
2) Циклические зависимости нужны только в крайнем случае. Обычно можно обойтись без них, перепроектировав систему по предложенным вариантам. Но если без них никак не получается, то жадная загрузка спасает.
3) Из контроллера вызвать один сервис, который вызовет по очереди два других. Или скопипастить в него создание обоих, если оно простое.
спасибо! По поводу 3, получается не желательно работать с разными сервисами в контроллере в пределах 1 метода?
Дмитрий, а где мы создаем заполняем AddressDto? И вообще DTO в целом? Спасибо за статью.
В контроллере.
Очень полезно, спасибо. Только мне кажется, что если добавлять телефон (дополнительный), то не происходит проверки, что он уже есть. В Сущности Employee добавляется сразу add(Phone $phone)
Эта проверка у нас находится внутри add.
Здравствуйте,
В статье DTO делается с открытыми свойствами:
Нет опасности, что свойства могут перезаписать по пути? Что если сделать их закрытыми и добавить геттеры?
Если нужна гарантия неизменяемости, то да, можно сделать геттеры.
Но если используются статическиие анализаторы вроде Psalm, то можно пометить иммутабельным:
Либо скоро дождаться PHP 8.1, где поля можно будет помечать как readonly.
Отличный материал спасибо. Я видел подобное в NestJs entity services dto теперь немного начал понимать вроде После полного прохождения недели ООП я вернусь снова к этой статье пока рано(
Ох и школота начальная.