使用通知模式进行域验证

时间:2017-12-08 14:19:50

标签: php validation design-patterns domain-driven-design

从历史上看,我已在其构造函数中对我的对象进行了验证,并在验证失败时抛出异常。例如:

class Name
{
    const MIN_LENGTH = 1;

    const MAX_LENGTH = 120;

    private $value;

    public function __construct(string $name)
    {
        if (!$this->isValidNameLength($name)) {
            throw new InvalidArgumentException(
                sprintf('The name must be between %d and %d characters long', self::MIN_LENGTH, self::MAX_LENGTH)
            );
        }
        $this->value = $name;
    }

    public function changeName(string $name)
    {
        return new self($name);
    }

    private function isValidNameLength(string $name)
    {
        return strlen($name) >= self::MIN_LENGTH && strlen($name) <= self::MAX_LENGTH;
    }
}

虽然我喜欢这种方法,因为我的对象负责强制执行其一致性并确保它始终有效,但我从未过度热衷于使用异常。虽然有人会争论和反对上述异常的使用,但它确实限制了我在多个对象上执行验证时可以返回的验证消息的数量。例如:

class Room
{
    private $name;

    private $description;

    public function __construct(Name $name, Description $description)
    {
        $this->name = $name;
        $this->description = $description;
    }
}

class Name
{
    public function __construct(string $name)
    {
        // do some validation
    }
}

class Description
{
    public function __construct(string $description)
    {
        // do some validation
    }
}

如果NameDescription都未通过验证,我希望能够返回两个对象的失败消息,而不仅仅是首先失败的任何对象的单个异常。

notification pattern上做了一些阅读后,我觉得这对我的情景非常适合。我陷入困境的地方是如何执行验证并防止我的对象在验证失败时进入无效状态。

class Name
{
    const MIN_LENGTH = 1;

    const MAX_LENGTH = 120;

    private $notification;

    private $value;

    public function __construct(string $name, Notification $notification)
    {
        $this->notification = $notification;
        $this->setName($name);
    }

    private function setName(string $name)
    {
        if ($this->isValidNameLength($name)) {
            $this->value = $name;
        }
    }

    private function isValidNameLength(string $name)
    {
        if (strlen($name) < self::MIN_LENGTH || strlen($name) > self::MAX_LENGTH) {
            $this->notification->addError('NAME_LENGTH_INVALID');
            return false;
        }
        return true;
    }

    public function hasError()
    {
        return $this->notification->hasError();
    }

    public function getError()
    {
        return $this->notification->getError();
    }
}

我对上述内容有些担忧:

  1. 如果验证失败,则对象仍然构建,但其$valuenull且不是有效状态。
  2. 创建Name后,我必须记得致电hasError以确定是否发生了验证错误。
  3. 我现在正在使用hasError / getError函数来填充我的域对象,我不确定这是不是很好的做法。
  4. 我缺少这个谜题吗?我如何利用通知模式但确保我的对象无法进入无效状态?

4 个答案:

答案 0 :(得分:0)

如何调用notification.hasError函数并在之后抛出异常?

通过这种方式,您可以使用通知处理来处理任何错误,并且由于例外,可以保证您不会拥有有效的对象。

答案 1 :(得分:0)

嗯,你几乎已经做过了,但是如果你在一个validate()方法中共享所有验证,你可以在继续进行业务逻辑之前调用它。

类似

if (model.validate()) 
{
   // You can safely proceed
}

After this you can for example throw an exception so you will know that you have an object in an invalid state.

答案 2 :(得分:0)

  

我如何利用通知模式但确保我的对象无法进入无效状态?

工厂模式 - 又名&#34;命名构造函数&#34;

你离开构造函数验证了 - 你永远不想创建一个不能保留自己的不变量的值。

但是你从公共API中取出构造函数,而不是安排客户端代码来调用工厂上的方法。该工厂决定如何管理构造对象的失败 - 收集所有notifications然后

  • 抛出包含通知集合的异常
  • 返回包含通知集合的失败类型

取决于您是否更喜欢处理具有例外或歧视联盟的控制流程。

答案 3 :(得分:0)

与立即抛出异常相比,我更喜欢提供错误列表(在您的情况下为通知)。但是我仍然想确保我的域模型不能进入无效状态

我的经验法则是,如果用户可能犯了一个错误,则提供错误(或一系列错误),仅当您作为程序员时才抛出异常显然犯了一个错误或系统中发生了其他不良情况(并非由于用户输入而发生)。

因此,我遵循的模式基于Martin Fowler(Replacing Throwing Exceptions with Notification in Validations)和Vladimir Khorikov(Validation and DDD)的思想

每当操作或构造函数进行必要的验证(可能导致异常)时,请提供相应的方法询问实体是否可以执行该操作。

这意味着,如果存在要检查的仅特定于实体的特定于域的验证逻辑,则程序员在执行操作或构造域实体之前应使用询问方法(例如 canPurchaseProduct())知道。

如果 asking 方法失败,则可以从聚合根收集所有错误,并告知用户他们所犯的错误。

实体(或构造函数)进行实际工作的相应操作也将调用Asking方法,并且如果调用Asking方法导致任何错误,它将引发异常。例如,包含所有错误的摘要。

每个实体当然都必须遵循这种模式,以便每个实体在需要执行自己的操作时都可以调用其子实体的Asking方法。

我在这里使用了很好的 Order OrderItem 示例。

我正在使用一个抽象基类,该基类为所有域实体类提供错误处理功能。

abstract class AbstractDomainEntity
{
    public function addError(string $message)
    {
        //...
    }
    public function mergeErrors(array $errors)
    {
        //...
    }
    public function getErrors(): array
    {
        //...
    }
    public function getErrorSummary(): array
    {
        //...
    }
    public function hasErrors(): bool
    {
        //...
    }
}

Order类扩展了抽象域类以使用其错误处理功能。

class Order extends AbstractDomainEntity
{
    public function __construct(OrderId $id, CustomerId $customerId, ShopId $shopId, $orderItems)
    {
        $this->canCreate($id, $customerId, $shopId, $orderItems);
        if ($this->hasErrors()) {
            throw new DomainException($this->getErrorSummary);
        }

        $this->setId($id);
        $this->setCustomerId($customerId);
        $this->setShopId($shopId);
        $this->setOrderItems($orderItems);
    }

    /**
     * @return ErrorNotification[] 
     */
    public static function canCreate(OrderId $id, CustomerId $customerId, ShopId $shopId, $orderItems): array
    {
        // Perform validation
        // add errors if any...
        return $this->getErrors();
    }

    public function acceptGeneralTermsAndConditions()
    {
        //...
    }

    public function ship(ShipmentInformation $shipmentInfo)
    {
        $this->canShip(ShipmentInformation $shipmentInfo);
        if ($this->hasErrors()) {
            throw new DomainException($this->getErrorSummary);
        }

        foreach ($this->orderItems as $orderItem) {
            $orderItem->shipToCountry($shipmentInfo->country);
        }

        $this->recordShipmentInformation($shipmentInfo);
        $this->setOrderState(self::SHIPPED);
    }

    public function canShip(ShipmentInformation $shipmentInfo)
    {
        // Validate order item operations
        foreach ($this->orderItems as $orderItem) {
            $orderItem->canShipToCountry($shipmentInfo->country);
            $this->mergeErrors($orderItem->getErrors());
        }

        if (!$this->generalTermsAndConditionsAccepted()) {
            $this->addError('GTC needs to be agreed on prio to shipment');
        }

        return $this->getErrors();
    }
}

此应用程序服务说明了如何应用此方法:

class OrderApplicationService
{
    public function startNewOrder(NewOrderCommand $newOrderCommand): Result
    {
        $orderItems = $newOrderCommand->getOrderItemDtos->toOrderItemEntities();

        $errors = this->canCreate(
            $this->orderRepository->getNextId(),
            $this->newOrderCommand->getCustomerId(),
            $this->newOrderCommand->shopId(),
            $orderItems);

        if ($errors > 0) {
            return Result.NOK($errors)
        }

        $order = new Order(
            $this->orderRepository->getNextId(),
            $this->newOrderCommand->getCustomerId(),
            $this->newOrderCommand->shopId(),
            $orderItems);

        $this->orderRepository->save($order);
    }

    public function shipOrder(ShipOrderCommand $shipOrderCommand): Result
    {
        $order = $this->orderRepository->getById($shipOrderCommand->getOrderId());

        $shipmentInformation = $shipOrderCommand
            ->getShipmentInformationDto()
            ->toShipmentInformationEntity();

        if (!$order->canShip($shipmentInformation)) {
            return Result::NOK($order->getErrors());
        }

        $order->ship($shipmentInformation);
    }
}

收集实体本身中的错误可让您很方便地收集和接收所有错误。如果要在聚合根目录上执行多个域操作,则可以立即收集所有可能的错误。

工厂方法必须对此进行不同处理,并且当然直接返回错误列表。但是,如果构造一个实体会失败,那么对于这种实体的进一步操作将无法执行。

此模式使我可以灵活地收集所有与用户业务逻辑有关的错误信息,而不会在第一个错误上引发异常。

此外,它使我可以确保至少在我忘记提前调用相应的询问方法(例如 canShip())的情况下抛出异常。

DomainException 可以作为最后手段在最高堆栈(例如API控制器层)中捕获,并且来自异常的信息可以轻松用于日志记录或类似操作。