brezgalov / yii2-domain-model
This package contains classes and interfaces helping you make your code more specific and domain-oriented
Requires
- php: >=7.2.0
- yiisoft/yii2: >=2.0.44
This package is auto-updated.
Last update: 2023-02-17 16:10:49 UTC
README
Лирика
Этот репозиторий содержит классы и интерфейсы, каркас (framework) для структурирования кода приложения. Он вдохновлен подходом Domain Driven Development.
Зачем вообще стремиться к доменно-ориентированности?
Можно начать проект вообще без всякой архитектуры, быстро поднять его и если он взлетит - переписывать после, но такое переписывание потребует много сил.
Можно сразу начать проект на каноничном DDD, что увеличит сложность старта и порог входа, а дальше - как пойдет, если проект не взлетит - мы старались зря.
Я предлагаю этот пакет, как что-то среднее. Он позволяет иметь не такой высокий порог входа и меньший уровень сложности на старте, чем каноничный DDD. Но при этом переписать на каноничный DDD после работы с таким проектом - будет значительно проще.
К сути!
Как это работает?
Модель
Чтобы было проще начнем знакомство на примере. Допустим мы хотим реализовать сохранение отметки о подтверждении телефона пользователя.
Следуя DDD мы должны были бы создать модель Пользователь такого вида:
class UserDM extends BasicDomainModel
{
/**
* @var integer
*/
public $id;
/**
* @var string
*/
public $phone_confirmed_mark;
}
На практике, прописывать перенос полей из БД в модель и обратно - довольно затратная по человеко-часам операция. Следование DDD в этом аспекте увеличит срок разработки и бизнес будет недоволен. Я предлагаю пойти на компромисс и оборачивать один или несколько DAO в модели, если это возможно.
Поля модели оставляю публичными, опять же для упрощения. Я полагаю, что программисты не будут использовать эти поля "не правильно", потому что такой код будет отсеиваться на код-ревью, а сами сотрудники - обучаться.
class UserProfileDM extends BasicDomainModel
{
/**
* @var UsersDao
*/
public $user;
}
Модель-Инвариант
Инвариант в объектно-ориентированном программировании — выражение, определяющее непротиворечивое внутреннее состояние объекта. Иными словами, - инвариант всегда сохраняет свое состояние валидным. Именно такой, должна быть модель, по DDD.
Наша модель, при получении ее из репозитория, должна всегда быть валидна. Для того чтобы в этом убедиться я добавил в интерфейс доменных моделей метод isValid().
Пример реализации метода:
/**
* @return bool
*/
public function isValid()
{
return $this->user && $this->user->validate();
}
Этот метод вызывается в базовом сервисе после загрузки модели. При получении отрицательного ответа от метода - выбрасывается исключение
class ActionAdapterService extends Action
{
...
public function run()
{
...
try {
$resultFormatter = $this->getFormatter();
$model = $this->getDomainModel();
if (!$model->isValid()) {
throw new InvalidConfigException("Model loaded in failed state");
}
...
}
}
Репозиторий (чтение)
Мы будем подтверждать телефон уже существующего пользователя. Нам понадобится способ наполнить модель данными. Создадим для этого класс-репозиторий.
class UserProfileDMRepository extends BasicRepository
{
/**
* @var integer
*/
public $id;
/**
* @var UsersDaoRepository
*/
public $usersDaoRepo;
/**
* UserProfileDMRepository constructor.
* @param array $config
*/
public function __construct($config = [])
{
parent::__construct($config);
if (empty($this->usersDaoRepo)) {
$this->usersDaoRepo = new UsersDaoRepository();
}
}
/**
* Входные параметры запроса попадут в load через метод registerInput базового класса
* @return array[]
*/
public function rules()
{
return [
[['id'], 'required', 'message' => 'Укажите ID пользователя для отображения профиля'],
];
}
/**
* @return UserProfileDM
* @throws ErrorException
*/
public function loadDomainModel()
{
$model = new UserProfileDM();
$daoRepo = clone $this->usersDaoRepo;
$daoRepo->id = $this->id;
$userDao = $daoRepo->getQuery()->one();
if (empty($userDao)) {
ErrorException::throwAsModelError('id', 'Не удается найти профиль пользователя');
}
$model->user = $userDao;
return $model;
}
}
DAO - Data Access Object. Как правило, в Yii в роли объекта доступа к данным выступает ActiveRecord
UsersDaoRepository реализует IDaoRepository и служит для поиска DAO по каким-либо параметрам, в нашем случае - по id
Логика
Теперь мы хотим реализовать сохранение отметки о подтверждении телефона. Можно просто:
class UserProfileDM extends BasicDomainModel
{
/**
* @var UsersDao
*/
public $user;
/**
* @return UsersDao
*/
public function setPhoneConfirmed()
{
$this->user->phone_confirmed_mark = true;
if (!$this->user->save()) {
// обработка ошибки
}
return $this->user;
}
}
В нарушение DDD я сохранил изменения в DAO непосредственно в методе run. Это сократит сложность кода и понизит порог вхождения. Целостность данных мы сохраним с помощью миграции. См. подробнее в секции UnitOfWork
Но теперь наша модель Профиль будет становится крупнее с каждым новым действием, это моет привести к тому, что она со временем будет перегружена кодом, а сами методы модели начнут проникать в друг друга через прямые вызовы или protected методы.
Давайте попробуем сделать процесс подтверждения телефона более самостоятельным и изолированным.
Для этого опишем его отдельно:
class SetPhoneConfirmedDAM extends BasicDomainActionModel
{
public function run()
{
// ...
return $this->user;
}
}
Теперь подключим процесс в модель
class UserProfileDM extends BasicDomainModel
{
const METHOD_SET_PHONE_CONFIRMED = 'setPhoneConfirmed';
/**
* @var UsersDao
*/
public $user;
/**
* @return array
*/
public function actions()
{
return [
/**
* Тут мы задокументируем все особенности метода и
* коротко опишем что делаем
*/
static::METHOD_SET_PHONE_CONFIRMED => SetPhoneConfirmedDAM::class,
];
}
}
Попробуем немного углубить логику, чтобы лучше понять возможности BasicDomainActionModel и предполагаемый способ взаимодействия.
class SetPhoneConfirmedDAM extends BasicDomainActionModel
{
/**
* @var UserProfileDM
*/
protected $model;
/**
* @return bool
*/
public function run()
{
if (empty($this->model->user->phone)) {
$this->model->addError('phone', 'Необходимо указать телефон в профиле, прежде чем его подтверждать');
return false;
}
if ($this->model->user->phone_confirmed_mark) {
$this->model->addError('phone', 'Ваш номер телефона уже подтвержден');
return false;
}
$this->model->user->phone_confirmed_mark = date('Y-m-d H:i:s');
$this->model->delayEventByKey(new StoreModelEvent($this->model), UserProfileDM::EVENT_STORE_MODEL);
return true;
}
}
BasicDomainActionModel может обращаться к DomainModel через защищенное поле $model
BasicDomainActionModel это \yii\base\Model, поэтому вы можете использовать встроенные механизмы load и validate
Здесь я уже реализовал сохранение пользователя через отложенное событие. Дело в том, что я планирую использовать этот метод внутри других методов модели UserProfileDM. Поэтому я не хочу чтобы модель юзера сохранялась несколько раз подряд. Событие с ключем позволит обновить модель до актуального состояния за один вызов save().
Сервис
Задача сервисов по DDD - передача входных данных в модели и ответа модели обратно. Если представить, что один запрос к модели = одно действие (метод), то сервис можно стандартизировать и привести к общему виду.
Для этого я реализовал интерфейс IService и трейт ServiceTrait. Используя это сочетание можно создавать любые сервисы.
Я попробовал реализовать максимально общий в классе BaseService
/**
* @return \Exception|false|mixed|void
*/
public function handleAction()
{
$unitOfWork = null;
$model = null;
$resultFormatter = null;
try {
$resultFormatter = $this->getFormatter();
$model = $this->getDomainModel();
if (!$model->isValid()) {
throw new InvalidConfigException("Model " . get_class($model) . " loaded in failed state");
}
$unitOfWork = $this->getUnitOfWork();
$model->linkUnitOfWork($unitOfWork);
$result = $model->call($this->getActionName());
if (!$model->isValid()) {
throw new InvalidCallException('Action lead to invalid state');
}
if ($result === false) {
$model->getUnitOfWork()->die();
} else {
$model->getUnitOfWork()->flush();
}
} catch (\Exception $ex) {
$result = $ex;
if ($unitOfWork) {
$model->getUnitOfWork()->die();
}
}
return $resultFormatter ? $resultFormatter->format($model, $result) : $result;
}
Порядок выполнения действий в сервисе следующий:
- Получение модели через репозиторий (можно так же передать модель напрямую)
- Модель должна оставаться инвариативной, поэтому валидируем ее
- Подключаем к модели UnitOfWork для сбора событий и обеспечения целостности данных
- Вызываем метод модели
- В зависимости от результата:
- Сбрасываем изменения
- Применяем изменения
- Пропускаем результат метода через форматирование, если требуется
Подключение сервиса к контроллеру
ActionAdapterService - это попытка использовать единый сервис для подключения всех моделей.
class UserProfileController extends Controller
{
public function actions()
{
return [
/**
* Описываем для чего этот метод и как он должен
* интегрироваться с клиентской частью приложения
*
* Добавляем так же ссылку на метод, чтобы потом было проще его найти
* @see SubmitPhoneDAM::run()
*/
'submit-profile' => [
'class' => ActionAdapterService::class,
'repository' => UserProfileDMRepository::class,
'modelActionName' => UserProfileDM::METHOD_SUBMIT_PHONE,
]
];
}
}
ActionAdapterService по умолчанию использует ActionAdapterMutexBehavior.
Это поведение заворачивает Action в Mutex для каждого отдельного клиента. Это необходимо на случай, если клиент отправит одновременно несколько запросов, которые начнут обрабатываться параллельно. Хранилище данных не всегда будет актуальным при выполнении таких запросов, поэтому я решил упорядочить их выполнение. Такое поведение можно отключить, передав 'behaviors' => [] в конфигурации сервиса.
Работа с View
Для преобразования ответа метода модели в html необходимо использовать DisplayViewFormatter.
Укажите 2 обязательных параметра:
- view - назавние вашего шаблона
- viewContext - класс реализующий ViewContextInterface. Это может быть контроллер, модуль или кастомный класс
class Controller extends \yii\web\Controller
{
public function actionIndex()
{
$service = \Yii::$container->get(BaseService::class, [], [
'actionName' => MyDomainModel::METHOD_DO_STUFF,
'model' => new MyDomainModel(),
'formatter' => [
'class' => DisplayViewFormatter::class,
'view' => 'test/index',
'viewContext' => $this,
],
]);
// Вернет только html шаблона
return $service->handleAction();
// Вернет html шаблона обернутый в layout
return $this->renderContent(
$service->handleAction();
);
}
}
Было бы неудобно создавать новые экшены только для того, чтобы обернуть ответ модели в layout. Чтобы избежать рутинной работы я добавил RenderActionAdapterService.
class TestController extends Controller
{
public function actions()
{
return [
'index' => [
'class' => RenderActionAdapterService::class,
'model' => RolesManagerDM::class,
'actionName' => RolesManagerDM::METHOD_GET_ROLES,
'formatter' => [
'class' => DisplayViewFormatter::class,
'view' => 'test/index',
'viewContext' => $this,
],
],
];
}
}
Результат метода преобразуется с помощью форматтера, а после этого RenderActionAdapterService вызывает метод renderContent у своего родительского контроллера.
Unit of Work
UnitOfWork должен отвечать за сохранение изменений. В моем варианте реализации этого класса используются миграции. Благодаря им мы можем использовать ActiveRecord::save() прямо в методе модели, при этом имея возможность откатить изменения в случае ошибки.
Вот пример кода из ActionAdapterService, который работает с UnitOfWork:
$unitOfWork = $this->getUnitOfWork();
$model->linkUnitOfWork($unitOfWork);
try {
$result = call_user_func([$model, $this->modelActionName]);
$model->getUnitOfWork()->flush();
} catch (\Exception $ex) {
$result = $ex;
$model->getUnitOfWork()->die();
}
$model::linkUnitOfWork() дает доступ модели к UnitOfWork. Это позволяет передавать его ниже по архитектуре, если это требуется, а так же использовать его для регистрации отложенных событий.
Вариации UnitOfWork
По умолчанию UnitOfWork поднимает миграции и хранилище событий.
Чтобы оптимизировать скорость работы приложения на запросах в которых происходит только отдача данных - можно использовать различные варианты этого класса.
UnitOfWorkDummy - заглушка, не делает ничего. Совсем.
UnitOfWorkEventsOnly - не работает с транзакцией, обладает только хранилищем событий
Форматирование ответа
Формат ответа вещь довольно индивидуальная для каждого проекта. Где-то будет необходимо возвращать результат View::render, где-то будет использоваться API и формат ответа будет совершенно другим.
Я не хочу навязывать конкретный способ форматирования ответа, поэтому конечную реализацию оставляю пользователям пакета. Возможно со временем я добавлю несколько стандартных классов для форматирования.
Вы можете не пользоваться форматированием вообще, написать общий форматировщик на весь проект, отдельные форматировщики для отдельных кейсов. Используйте поле ActionAdapterService::resultFormatter и интерфейс IResultFormatter для реализации конкретных классов.
Продвинутые практики
Передача модели в сервис напрямую
Если вдруг по какой-то причине наша модель не имеет в себе данных или мы осознанно превращаем модель в фасад группирующий методы - использование репозитория будет не оправданным усложнением. В таких случаях модель можно передать напрямую через поле ActionAdapterService::model
Сервис проверяет, может ли ваша модель быть передана напрямую
public function getDomainModel()
{
$input = $this->getInput();
if ($this->model) {
$model = $this->model instanceof IDomainModel ? $this->model : \Yii::createObject($this->model);
if (!$model->canInitWithoutRepo()) {
throw new InvalidCallException('Model ' . get_class($model) . ' can not be loaded without Repo');
}
}
...
}
По-умолчанию такое запрещено в модели BaseDomainModel. Для того чтобы открыть эту функцию необходимо определить метод IDomainModel::canInitWithoutRepo и вернуть true
Очень редко нужно обратиться к своим методам в модели которая уже получена, через репозиторий. Используем вот такой "хак":
$callResult = $this->model->crossDomainCall(
$this->model->getNoRepoClone(),
MyDomainModel::MY_METHOD,
[]
);
Отложенные события
Часто возникает ситуация, что необходимо в ходе работы приложения выполнить необратимое действие. Например, при подтверждении регистрации, - отправить письмо или смс. Что делать, если мы уже произвели такое действие и уже после получили ошибку в доменном процессе?
Для решения этой проблемы используются отложенные события. Я предлагаю регистрировать их с помощью UnitOfWork::delayEvent. Этот метод будет доступен как внутри доменной модели, так и внутри доменных процессов, через обращение к ней.
Чуть выше мы уже использовали вызов регистрации отложенного события. Вот так выглядит реализация события сохранения пользователя:
class StoreModelEvent extends Model implements IEvent
{
/**
* @var UserProfileDM
*/
protected $model;
/**
* StoreModelEvent constructor.
* @param UserProfileDM $model
* @param array $config
*/
public function __construct(UserProfileDM $model, $config = [])
{
$this->model = $model;
parent::__construct($config);
}
/**
* @return bool|void
* @throws Exception
*/
public function run()
{
if (!$this->model->user->save()) {
throw new Exception('Не удается сохранить модель пользователя');
}
}
}
События реализуют метод run(), через интерфейс IEvent.
Событие будет вызвано в пределах основной миграции UnitOfWork, поэтому в случае чего исключение отменит все изменения других событий меняющих бд.
Здесь мы можем вызывать не только сохранение, но и отправку смс, почты и т.п.
Кросс-доменное взаимодействие
Мы реализовали сохранение отметки о том, что телефон пользователя подтвержден. Теперь нас просят реализовать простановку этой отметки в случае получения смс-кода подтверждения.
Попробуем реализовать такой процесс
class SubmitConfirmPhoneDAM extends BaseDomainActionModel
{
/**
* @var UserProfileDM
*/
protected $model;
/**
* @var string
*/
public $phone;
/**
* @var string
*/
public $code;
/**
* @var SmsCodesDaoRepository
*/
public $smsCodesRepo;
/**
* SubmitPhoneDAM constructor.
* @param IDomainModel $model
* @param array $config
*/
public function __construct(IDomainModel $model, $config = [])
{
parent::__construct($model, $config);
if (empty($this->smsCodesRepo)) {
$this->smsCodesRepo = new SmsCodesDaoRepository();
}
}
/**
* @return array[]
*/
public function rules()
{
return [
[['code', 'phone'], 'required'],
];
}
/**
* @return $this|mixed
* @throws ErrorException
*/
public function run()
{
if (!$this->validate()) {
$this->model->addErrors($this->getErrors());
return false;
}
$phone = PhoneHelper::clearPhone($this->phone);
if ($this->model->user->phone !== $phone) {
$this->model->addError('phone', "Телефон \"{$this->phone}\" не привязан к вашему профилю");
return false;
}
$smsCodesRepo = clone $this->smsCodesRepo;
$smsCodesRepo->code = $this->code;
$smsCodesRepo->for_phone = $phone;
$isValidCode = $smsCodesRepo->getQuery()->exists();
if (!$isValidCode || !$phone) {
$this->model->addError('code', 'Неверный код');
return false;
}
$callResult = $this->model->crossDomainCall(
$this->model,
UserProfileDM::METHOD_SET_PHONE_CONFIRMED
);
if (!$callResult->result) {
/** @var UserProfileDM $calledModel */
$calledModel = $callResult->model;
$this->model->addErrors($calledModel->getErrors());
return false;
}
return true;
}
}
На входе процесс получает от контроллера номер телефона и код смс
После выполнения всех проверок мы проставляем отметку о подтверждении через $this->model->crossDomainCall()
Минуточку. У нас ведь реализован класс для процесса простановки отметки. Почему бы нам инстанцировать его, передав внутрь текущую модель профиля и не вызвать напрямую? Зачем нужен странный и не понятный нам метод для вызова этого процесса?
Если бы начнем инстанцировать процессы внутри процессов напрямую, то спустя некоторое время вернувшись в код мы не сможем точно сказать по нашей модели, какие процессы в ней внутренние, какие наружные. Единственный способ разобраться в таком случае - читать непосредственно все реализации процессов.
Более того, процесс из одной модели может быть использован в другой, тогда найти все связи будет гораздо труднее.
Я предлагаю договориться между собой и запретить такие действия. Вместо это, я предлагаю использовать функцию BasicDomainModel::crossDomainCall. Она принимает модель (или репозиторий), название метода который необходимо использовать и входные параметры.
Для того, чтобы метод стал доступен для вызова через эту функцию, необходимо явно указать его в списке, получаемом через BasicDomainModel::crossDomainActionsAllowed.
При вызове этого метода в модели, которая совершает кросс-доменное действие, в конец массива BasicDomainModel::$crossDomainOrigin проставляется отметка о модели совершившей вызов. см registerCrossDomainOrigin Это позволяет вам проверить в методе BasicDomainModel::crossDomainActionsAllowed(), из какой модели вызывается процесс и в какая была последовательность обращений к моделям.
/**
* @return array|mixed
* @throws \Exception
*/
public function crossDomainActionsAllowed()
{
$domainOrigins = $this->crossDomainOrigin;
$lastParent = array_pop($domainOrigins);
return ArrayHelper::getValue([
// Методы доступные только внутри другой модели
SomeOtherDM::class => [
UserProfileDM::METHOD_SUBMIT_PHONE_CONFIRM,
],
// Методы доступные себе внутри себя
UserProfileDM::class => [
UserProfileDM::METHOD_SET_PHONE_CONFIRMED,
],
], $lastParent, []);
}
Таким образом в crossDomainActionsAllowed мы увидим подробное описание того какие методы и откуда можно вызывать
Так же модели получают общий UnitOfWork. Это позволяет иметь единое хранилище для отложенных событий между всеми моделями. см linkUnitOfWork
Метод возвращает не только результат процесса, но и модель, которая его совершала. см CrossDomainCallDto
public function crossDomainCall($modelConfig, string $methodName, array $input = [])
{
$model = null;
if (is_array($modelConfig) || is_string($modelConfig)) {
$modelConfig = \Yii::createObject($modelConfig);
}
if ($modelConfig instanceof IDomainModelRepository) {
/**
* Если репозиторий передан на прямую - кросс-доменный вызов не должна вносить в него артефакты
* Если нет - проще сделать лишний clone, чем плодить if'ы
*/
$modelConfig = clone $modelConfig;
$modelConfig->registerInput($input);
$modelConfig = $modelConfig->getDomainModel();
}
if (!($modelConfig instanceof IDomainModel)) {
CrossDomainException::throwException(static::class, null, "Only Models and Repos can be accessed in cross-domain way");
}
/**
* Если модель передана на прямую - кросс-доменный вызов не должна вносить в нее артефакты
* Если нет - проще сделать лишний clone, чем плодить if'ы
*/
$modelConfig = clone $modelConfig;
$modelConfig->registerCrossDomainOrigin(static::class);
if (!in_array($methodName, $modelConfig->crossDomainActionsAllowed())) {
CrossDomainException::throwException(static::class, get_class($modelConfig), "Method {$methodName} is not allowed for cross-domain access");
}
/**
* pass UnitOfWork by ref, so events storage and transaction stays "singltoned"
*/
if ($this->unitOfWork) {
$modelConfig->linkUnitOfWork($this->unitOfWork);
}
$modelConfig->registerInput($input);
$result = call_user_func([$modelConfig, $methodName]);
return new CrossDomainCallDto([
'model' => $modelConfig,
'result' => $result,
]);
}
Я приложил часть проекта, на котором внедрялся такой подход, в папке example. Там вы сможете более подробно рассмотреть примеры кода модели UserProfileDM