brezgalov / yii2-domain-model
此软件包包含类和接口,可以帮助您使代码更具体和面向领域。
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
Лирика
此仓库包含类和接口,用于构建应用程序代码的结构框架。它受到领域驱动开发(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 - 数据访问对象。通常,在Yii中,数据访问对象由 ActiveRecord 扮演
UsersDaoRepository 实现了 IDaoRepository,用于根据某些参数(在我们的情况下是id)搜索DAO
逻辑
现在,我们想要实现保存电话验证标记。可以简单地
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,我在 run 方法中直接保存了DAO中的更改。这将简化代码并降低门槛。我们通过迁移来保持数据完整性。请参阅 UnitOfWork 部分,以了解更多信息。
但现在我们的模型 Profile 将会随着每一步操作而变大,这可能会导致随着时间的推移,代码变得过于繁重,而模型的自身方法也可能通过直接调用或 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 可以通过受保护的字段 $model 访问 DomainModel。
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。
此行为将每个单独客户端的操作封装在 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();
);
}
}
创建新操作仅为了将模型响应包装在布局中会非常麻烦。为了避免这种例行工作,我添加了 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 方法。
工作单元
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('Не удается сохранить модель пользователя');
}
}
}
事件通过 IEvent 接口实现 run() 方法。
事件将在主迁移 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 模型的代码示例。