brezgalov/yii2-domain-model

此软件包已被放弃且不再维护。没有建议的替代软件包。

此软件包包含类和接口,可以帮助您使代码更具体和面向领域。

dev-master 2022-03-05 13:54 UTC

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,因此您可以使用内置的 loadvalidate 机制。

在这里,我已经通过 延迟事件 实现了用户的保存。原因是我在计划在 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;
}

服务中执行动作的顺序如下

  1. 通过存储库获取模型(也可以直接传递模型)
  2. 由于模型必须保持不变,因此需要验证它
  3. 将模型连接到 UnitOfWork 以收集事件并确保数据完整性
  4. 调用模型方法
  5. 根据结果
    • 重置更改
    • 应用更改
  6. 如果需要,将方法结果通过格式化处理

将服务连接到控制器

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 模型的代码示例。