《领域驱动设计之PHP实现》全书翻译 - 实体

 阅读约 60 分钟

实体

  1. 《领域驱动设计之PHP实现》全书翻译 - DDD入门
  2. 《领域驱动设计之PHP实现》全书翻译 - 架构风格
  3. 《领域驱动设计之PHP实现》全书翻译 - 值对象
  4. 《领域驱动设计之PHP实现》全书翻译 - 实体
  5. 《领域驱动设计之PHP实现》全书翻译 - 服务
  6. 《领域驱动设计之PHP实现》全书翻译 - 领域事件
  7. 《领域驱动设计之PHP实现》全书翻译 - 模块
  8. 《领域驱动设计之PHP实现》全书翻译 - 聚合
  9. 《领域驱动设计之PHP实现》全书翻译 - 工厂
  10. 《领域驱动设计之PHP实现》全书翻译 - 仓储
  11. 《领域驱动设计之PHP实现》全书翻译 - 应用服务
  12. 《领域驱动设计之PHP实现》全书翻译 - 集成上下文
  13. 《用PHP实现六边形架构》

我们已经讨论过优先将领域中所有内容作为值对象进行建模的好处。但对领域建模时,可能会有一些场景,你会发现在通用语言里的某些概念需要一根标识线。

介绍

对象需要标识符的一些简单例子:

  • 一个人。 人总是有一个标签,并且依照他们的名字或者身份证来说总是相同的。
  • 电子商务系统订单。在这个上下文里,随便时间的推移每个新创建的订单都有它自己的标识。

这些有标识的概念有长期存在的特性。无论概念中的数据发生多少次变化,它们的标识总是相同。这些使得它们成为实体而不是值对象。就PHP实现而言,它们就是简单的类。例如,考虑下面情况下的一个 Person 类:

namespace Ddd\Identity\Domain\Model;
class Person
{
    private $identificationNumber;
    private $firstName;
    private $lastName;

    public function __construct(
        $anIdentificationNumber, $aFirstName, $aLastName
    )
    {
        $this->identificationNumber = $anIdentificationNumber;
        $this->firstName = $aFirstName;
        $this->lastName = $aLastName;
    }

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

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

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

或者,考虑下面情况的 Order 类:

namespace Ddd\Billing\Domain\Model\Order;
class Order
{
    private $id;
    private $amount;
    private $firstName;
    private $lastName;

    public function __construct(
        $anId, Amount $amount, $aFirstName, $aLastName
    )
    {
        $this->id = $anId;
        $this->amount = $amount;
        $this->firstName = $aFirstName;
        $this->lastName = $aLastName;
    }

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

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

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

对象 VS. 基本类型

大多数时候,一个实体的标识表示为一个基本类型 - 通常是一个字符串或者整型。但使用值对象来表示它有更多优势:

  • 值对象是不变的,因此它们不能被修改。
  • 值对象是有自定义行为的复杂类型,这些是基本类型没有的。举个例子,相等操作。在值对象中,相等操作可被模型化或者封装在它们自身类中,使概念从含蓄到明确。

让我们看看一个 OrderId 的可能实现,这里 Order 标识进化成一个值对象:

namespace Ddd\Billing\Domain\Model;
class OrderId
{
    private $id;

    public function __construct($anId)
    {
        $this->id = $anId;
    }

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

    public function equalsTo(OrderId $anOrderId)
    {
        return $anOrderId->id === $this->id;
    }
}

你可以考虑一些不同的方式来实现 OrderId。上面展示的例子相当简单。正如第三章,值对象所述,你可以把 __constructor 设为私有同时使用一个静态工厂方法来创建新的实例。与你的小组进行讨论,体验,并达成一致。因为实体标识不是复杂的值对象,我们建议你对此不要有太多顾虑。

回到 Order 类,是时候更新引用到 OrderId 了:

class Order
{
    private $id;
    private $amount;
    private $firstName;
    private $lastName;

    public function __construct(
        OrderId $anOrderId, Amount $amount, $aFirstName, $aLastName
    )
    {
        $this->id = $anOrderId;
        $this->amount = $amount;
        $this->firstName = $aFirstName;
        $this->lastName = $aLastName;
    }

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

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

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

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

我们的实体已经有一个使用值对象模型化的标识。让我们使用不同的方式来创建一个 OrderId

标识操作

正如先前声明,标识定义了实体。因此,处理它是实体重要的一方面。这里通常有四种途径来定义实体标识:持久化机制生成标识,客户端生成标识,应用程序自身生成标识,或者另一个界限上下文提供标识

持久化机制生成标识

通常,生成标识最简单的方式就是将其委托给持久化机制,因为绝大多数持久化机制都支持某种标识生成 -- 像 MySQL 的自动增长属性或者 Postgres 和 Oracle 序列。这一点,虽然简单,但有一个主要缺点:在持久化之前,我们是不能获取到实体的标识的。因此,在某种程度上,如果我们要使用持久化机制生成标识,我们将把标识操作与基础性持久化存储耦合。

CREATE TABLE `orders` (
`id` int(11) NOT NULL auto_increment,
`amount` decimal (10,5) NOT NULL,
`first_name` varchar(100) NOT NULL,
`last_name` varchar(100) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

接着我们可能考虑如下代码:

namespace Ddd\Identity\Domain\Model;
class Person
{
    private $identificationNumber;
    private $firstName;
    private $lastName;

    public function __construct(
        $anIdentificationNumber, $aFirstName, $aLastName
    )
    {
        $this->identificationNumber = $anIdentificationNumber;
        $this->firstName = $aFirstName;
        $this->lastName = $aLastName;
    }

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

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

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

如果你曾尝试自己写过 ORM,那么你已经经历过这种情况。创建一个新的 Person 类的方法又是什么?如果数据库要创建标识,我们要把它传给构造函数吗?何处何时我们用来更新 Person 标识的魔法又是什么?如果我们最终不持久化实体,又会发生什么?

代理标识

有时当使用 ORM 映射实体到持久存储区时,会加强一些约束 -- 例如 Doctrine,如果使用了标识生成策略的话, Doctrine 会依赖于一个整型字段。假设领域模型需要另外一种标识的话,这会产生冲突。

处理这种情况最简单的方法就是使用一个 Layer Supertype,其中为持久存储区创建的标识将被放置在:

namespace Ddd\Common\Domain\Model;
abstract class IdentifiableDomainObject
{
    private $id;

    protected function id()
    {
        return $this->id;
    }

    protected function setId($anId)
    {
        $this->id = $anId;
    }
}

namespace Acme\Billing\Domain;

use Acme\Common\Domain\IdentifiableDomainObject;

class Order extends IdentifiableDomainObject
{
    private $orderId;

    public function orderId()
    {
        if (null === $this->orderId) {
            $this->orderId = new OrderId($this->id());
        }
        return $this->orderId;
    }
}

活动记录 VS 富领域模型数据映射

每个项目都会面临应该选择哪个 ORM 。PHP 有许多很好的 ORM:Doctrine, Propel, Eloquent, Paris 等等。

他们中的大多数都是活动记录实现方式。活动记录实现方式对大多数 CRUD 应用刚刚好,但对富领域模型来说并不是一个好的选择,有如下原因:

  • 活动记录模式在实体和数据表间假设了一个一对一的关系。因此它耦合了数据表的设计与对象系统的设计。而在富领域模型里,一些实体的构建信息可能来自不同的数据源。
  • 诸如集合和继承等高级内容很难实现。
  • 它们大多数的实现,强制使用了一些约定的,继承的构造方式。由于 ORM 与领域模型的耦合,可能会导致持久层泄漏到领域模型中。我们唯一见过的不强制从基类继承的活动记录实现是 Castle,来自于 Castle 项目,一个 .NET 框架。尽管这导致了上在生成实体时,领域模型和持久层某些程度上的分离,但它并不会从高层次领域设计解耦低层次的持久化细节。

正如前一章提到过,当前最好的 PHP ORM 就是 Doctrine,它的实现方式就是数据映射模式。数据映射从领域关注点分享了持久化关注点,从而得到无持久性实体。对某些人希望构建富领域模型这点来说使得该工具成为最好的选择。

客户端生成标识

有时,当处理确定的领域,在客户端消费领域模型过程中自然而然产生了标识。这可能是理想的情况,因为很容易建模标识。现在我们来看一个图书售卖系统:

namespace Ddd\Catalog\Domain\Model\Book;
class ISBN
{
    private $isbn;

    private function __construct($anIsbn)
    {
        $this->setIsbn($anIsbn);
    }

    private function setIsbn($anIsbn)
    {
        $this->assertIsbnIsValid($anIsbn, 'The ISBN is invalid.');
        $this->isbn = $anIsbn;
    }

    public static function create($anIsbn)
    {
        return new static($anIsbn);
    }

    private function assertIsbnIsValid($anIsbn, $string)
    {
// ... Validates an ISBN code
    }
}

根据维基百科定义:国际标准书号(IBSN)是唯一的数字商业书籍标识。IBSN 分配给每本书的每个版本和改动(重印除外)。例如,一个电子书,平装版和精装版会有各自不同的 IBSN。2007 年 1 月后出版的图书 IBSN 长度为 13 位数字, 2007 年 1 月之前为 10 位数字。分配 IBSN 的方法是以国家为基础的,往往取决于一个国家出版行业的规模。

IBSN 已经在领域中定义好了,它是一个有效的标识,因为它是唯一的,这一点也很容易验证。下面是一个客户端提供标识的好例子:

class Book
{
    private $isbn;
    private $title;

    public function __construct(ISBN $anIsbn, $aTitle)
    {
        $this->isbn = $anIsbn;
        $this->title = $aTitle;
    }
}

现在,是关于怎样使用它:

$book = new Book(
    ISBN::create('...'),
    'Domain-Driven Design in PHP'
);
练习

思考一下其它内置标识并且实现一个模型。

应用程序生成标识

如果客户端不能很好的提供标识的话,则更好的办法是通过应用程序生成标识的方式来处理标识操作问题,通常是通过一个 UUID。这是我们在这种情况下建议的方法,如果在之前展示的案例中没有你的场景的话。

根据维基百科:UUID 的目的是无须在中心调度的情况下,使分布式系统能够识别唯一标识信息。在这个上下文中,唯一的含义是实际的唯一而不是保证唯一。由于标识具有有限大小,这有可能让两个不同的项共享同一个标识。这便是哈希冲突的一种形式。必须先选好标识大小及其生成过程使得这种方式在生产中极不现实。任何人都可以创建一个 UUID,并使用它来识别合理的内容,相同的标识决不会被任何人用来识别其它内容。因此, 标记为 UUIDs 的信息可以以后合并到单个数据库中, 而无需解析标识 (ID) 冲突。

PHP 中有一些生成 UUID 的库,可以在 Packaglist 上找到: https://packagist.org/search/?q=uuid。最好的推荐便是 Ben Ramsey 开发的,可以通过链接:https://github.com/ramsey/uuid 找到,因为在 github 上已经有上千关注,在 packaglist 也有高达数百万的安装。

其它界限上下文生成标识

这似乎是最复杂的标识生成策略,因为它不仅强制一个本地实体依赖于本地界限上下文事件,同时依赖于外部界限上下文事件。所以就维护而言,这代价有点高。

而其它上下文提供一个接口,来从本地实体中选择标识。这可以将一些暴露的属性作为自身一部分。

当需要在界限上下文的实体中进行同步时,当原始实体发生改变时,在每个需要被通知到的界限上下文中,通常可以用一个事件驱动架构来实现。

持久化实体

目前,在本章先前讨论过,保存实体状态至数据库中最好的工具就是 Doctrine ORM。Doctrine 有几种方式来指定实体元数据:通过实体代码中的注解,通过 XML,通过 YAML,或者普通的 PHP 代码。在这一章,我们将深入讨论为什么注解不是映射实体最好的方式。

设置 Doctrine

首先,我们需要从 Composer 拉取 Doctrine。在项目的根目录下,执行以下命令:

> php composer.phar require "doctrine/orm=^2.5"

接着,用下面的代码可以调起 doctrine:

require_once '/path/to/vendor/autoload.php';

use Doctrine\ORM\Tools\Setup;
use Doctrine\ORM\EntityManager;

$paths = ['/path/to/entity-files'];
$isDevMode = false;
// the connection configuration
$dbParams = [
    'driver' => 'pdo_mysql',
    'user' => 'the_database_username',
    'password' => 'the_database_password',
    'dbname' => 'the_database_name',
];
$config = Setup::createAnnotationMetadataConfiguration($paths, $isDevMode);
$entityManager = EntityManager::create($dbParams, $config);

映射实体

默认情况下,Doctrine 文档使用注解来呈现示例代码。所以我们也用注解开始我们的代码例子,并且讨论为什么应该尽可能地避免它。

为此,我们拿出前面章节中的 Order 类来讨论:

用注解码映射实体

当 Doctrine 发布时,一个引人注目的方式就是使用注解的例子来展示如何映射对象。

什么是注解?

注解是元数据的一种特殊形式,在 PHP 里,它被置在源代码注释里,例如,PHPDocuemnt 使用注解来构建 API 信息,PHPUnit 使用一些注解来指定数据提供者,或者提供期望的掷出异常的一小段代码:

class SumTest extends PHPUnit_Framework_TestCase
{
    /** @dataProvider aMethodName */
    public function testAddition() {
        //...
    }
}

用 XML 映射实体

为了映射 Order 实体到持久存储层,Order 的源代码需要修改添加 Doctrine 注解:

use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Column;

/** @Entity */
class Order

{
    /** @Id @GeneratedValue(strategy="AUTO") */
    private $id;
    /** @Column(type="decimal", precision="10", scale="5") */
    private $amount;
    /** @Column(type="string") */
    private $firstName;
    /** @Column(type="string") */
    private $lastName;

    public function __construct(
        Amount $anAmount,
        $aFirstName,
        $aLastName
    )
    {
        $this->amount = $anAmount;
        $this->firstName = $aFirstName;
        $this->lastName = $aLastName;
    }

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

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

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

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

接着,要保存实体到持久存储层,仅需要执行以下操作即可:

$order = new Order(
    new Amount(15, Currency::EUR()),
    'AFirstName',
    'ALastName'
);
$entityManager->persist($order);
$entityManager->flush();

粗看来,这段代码十分简洁,并且这也是一种指定映射信息的简单方法。但它也带来了代价。最后的代码有什么奇怪之处呢?

首先,领域关注点与基础设施关注点混合了。Order 是领域概念,而表,列等等是基础设施关注点。

因此,这里的代码,实体与注解指定的映射信息紧耦合了。如果实体需要用一个实体管理器和其它不同映射元数据来持久化,这将不可能做到。

注解往往会导致副作用和紧耦合,所以最好不要使用它们。

那么什么是指定映射信息最好的方式?能让你从实体自体分享映射信息的就是最好的方式。这可以用 XML 映射,YAML 映射,或者 PHP 映射来实现。在本书中,我们将使用 XML 映射。

映射实体标识

我们的标识,OrderId,是一个值对象。在前一章已经介绍过,Doctrine 有好几种方法来映射值对象,嵌套值,以及自定义类型。当值对象被当作标识使用时,最好的选择就是自定义类型。

Doctrine 2.5 里一个有意思的新特性就是实体现在可以使用值对象作为标识,只需要实现 __toString() 方法。因此我们可能添加 _toString 到我们的值对象标识中并在映射里使用。

namespace Ddd\Billing\Domain\Model\Order;
use Ramsey\Uuid\Uuid;
class OrderId
{
// ...
    public function __toString()
    {
        return $this->id;
    }
}

通过查阅 Doctrine 自定义类型的实现,发现它们继承于 GuidType,所以它们内部表现为一个 UUID。我们需要指明数据库的原生标识的转换。然后在使用自定义类型前需要先注册它们。如果你需要这些步骤的帮助,Custom Mapping Type 是一个好的参考。

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\GuidType;

class DoctrineOrderId extends GuidType
{
    public function getName()
    {
        return 'OrderId';
    }

    public function convertToDatabaseValue(
        $value, AbstractPlatform $platform
    )
    {
        return $value->id();
    }

    public function convertToPHPValue(
        $value, AbstractPlatform $platform
    )
    {
        return new OrderId($value);
    }
}

最后,我们要设置好自定义类型的注册。同时,我们不得不再一次更新启动过程:

require_once '/path/to/vendor/autoload.php';
// ...
\Doctrine\DBAL\Types\Type::addType(
    'OrderId',
    'Ddd\Billing\Infrastructure\Domain\Model\DoctrineOrderId'
);
$config = Setup::createXMLMetadataConfiguration($paths, $isDevMode);
$entityManager = EntityManager::create($dbParams, $config);

最终的映射文件

随着所有的变化尘埃落定,我们终于准备好了。所以让我们来看看我们最终的映射文件。最有趣的细节就是检查如何用我们 OrderId 的自定义类型来获取映射的 id:

<?xml version="1.0" encoding="UTF-8"?>
<doctrine-mapping
        xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="
http://doctrine-project.org/schemas/orm/doctrine-mapping
https://raw.github.com/doctrine/doctrine2/master/doctrine-mapping.xsd">
    <entity
            name="Ddd\Billing\Domain\Model\Order"
            table="orders">
        <id name="id" column="id" type="OrderId"/>
        <field
                name="amount"
                type="decimal"
                nullable="false"
                scale="10"
                precision="5"
        />
        <field
                name="firstName"
                type="string"
                nullable="false"
        />
        <field
                name="lastName"
                type="string"
                nullable="false"
        />
    </entity>
</doctrine-mapping>

测试实体

测试体相对来说比较容易,因为它们是普通的 PHP 类,它们派生自它们所代表的领域概念。测试的焦点应该是实体所保护的不变量,因为实体的行为可能会围绕这些不变量进行建模。

例如,为了简单起见,假设需要一个 Post 领域模型,一个可能的实现如下:

class Post
{
    private $title;
    private $content;
    private $status;
    private $createdAt;
    private $publishedAt;

    public function __construct($aContent, $title)
    {
        $this->setContent($aContent);
        $this->setTitle($title);
        $this->unpublish();
        $this->createdAt(new DateTimeImmutable());
    }

    private function setContent($aContent)
    {
        $this->assertNotEmpty($aContent);
        $this->content = $aContent;
    }

    private function setTitle($aPostTitle)
    {
        $this->assertNotEmpty($aPostTitle);
        $this->title = $aPostTitle;
    }

    private function setStatus(Status $aPostStatus)
    {
        $this->assertIsAValidPostStatus($aPostStatus);
        $this->status = $aPostStatus;
    }

    private function createdAt(DateTimeImmutable $aDate)
    {
        $this->assertIsAValidDate($aDate);
        $this->createdAt = $aDate;
    }

    private function publishedAt(DateTimeImmutable $aDate)
    {
        $this->assertIsAValidDate($aDate);
        $this->publishedAt = $aDate;
    }

    public function publish()
    {
        $this->setStatus(Status::published());
        $this->publishedAt(new DateTimeImmutable());
    }

    public function unpublish()
    {
        $this->setStatus(Status::draft());
        $this->publishedAt = null;
    }

    public function isPublished()
    {
        return $this->status->equalsTo(Status::published());
    }

    public function publicationDate()
    {
        return $this->publishedAt;
    }
}
class Status
{
    const PUBLISHED = 10;
    const DRAFT = 20;
    private $status;

    public static function published()
    {
        return new self(self::PUBLISHED);
    }

    public static function draft()
    {
        return new self(self::DRAFT);
    }

    private function __construct($aStatus)
    {
        $this->status = $aStatus;
    }

    public function equalsTo(self $aStatus)
    {
        return $this->status === $aStatus->status;
    }
}

为了测试这个领域模型,我们必须确保测试覆盖到所有的 Post 不变量:

class PostTest extends PHPUnit_Framework_TestCase
{
    /** @test */
    public function aNewPostIsNotPublishedByDefault()
    {
        $aPost = new Post(
            'A Post Content',
            'A Post Title'
        );
        $this->assertFalse(
            $aPost->isPublished()
        );
        $this->assertNull(
            $aPost->publicationDate()
        );
    }

    /** @test */
    public function aPostCanBePublishedWithAPublicationDate()
    {
        $aPost = new Post(
            'A Post Content',
            'A Post Title'
        );
        $aPost->publish();
        $this->assertTrue(
            $aPost->isPublished()
        );
        $this->assertInstanceOf(
            'DateTimeImmutable',
            $aPost->publicationDate()
        );
    }
}

DateTimes

由于 DateTimes 在实体中广泛使用,我们认为在单元测试实体上指出具有日期类型的特定方法非常重要。例如一个 Post 在 15 天内创建,就认为它是最新发布的。

class Post
{
    const NEW_TIME_INTERVAL_DAYS = 15;
// ...
    private $createdAt;

    public function __construct($aContent, $title)
    {
// ...
        $this->createdAt(new DateTimeImmutable());
    }

// ...
    public function isNew()
    {
        return
            (new DateTimeImmutable())
                ->diff($this->createdAt)
                ->days <= self::NEW_TIME_INTERVAL_DAYS;
    }
}

isNew() 方法需要比较两个 DateTimes;即比较 Post 发布当天的日期及当前日期。我们计算之间的差异并检查少于指定天数。我们怎么对 isNew() 方法做单元测试呢?正如我们在实现中证明的,在我们的测试套件里重新产生指定的流程是很难的。那我们还有别的选项吗?

用参数传递所有日期

一种可行的方式就是在需要的时候用参数的形式传递所有日期:

class Post
{
// ...
    public function __construct($aContent, $title, $createdAt = null)
    {
// ...
        $this->createdAt($createdAt ?: new DateTimeImmutable());
    }

// ...
    public function isNew($today = null)
    {
        return
            ($today ?: new DateTimeImmutable())
                ->diff($this->createdAt)
                ->days <= self::NEW_TIME_INTERVAL_DAYS;
    }
}

测试类

另一种选择是使用测试类模式。我们的想法是将 Post 类扩展为一个新类, 可以通过操作来强制特定的场景。这个新类将仅用于单元测试目的。坏消息是, 我们必须修改原始的 Post 类, 提取一些方法, 并将一些字段和方法从私有更改为保护。一些开发人员可能会担心由于测试原因而增加类属性的可见性。然而, 我们认为在大多数情况下, 这是值得的:

class Post
{
    protected $createdAt;

    public function isNew()
    {
        return
            ($this->today())
                ->diff($this->createdAt)
                ->days <= self::NEW_TIME_INTERVAL_DAYS;
    }

    protected function today()
    {
        return new DateTimeImmutable();
    }

    protected function createdAt(DateTimeImmutable $aDate)
    {
        $this->assertIsAValidDate($aDate);
        $this->createdAt = $aDate;
    }
}

正如你所见,我们把获取日期的逻辑提取为一个 today() 方法。这种方式,通过应用模板方法模式,我们可以从派生类改变它的行为。有时候类似的情况也发生在 createdAt 方法和字段。现在它们是受保护类型的,因此它们可以在派生类中使用和重写:

class PostTestClass extends Post
{
    private $today;

    protected function today()
    {
        return $this->today;
    }

    public function setToday($today)
    {
        $this->today = $today;
    }
}

通过这种更改,我们现在可以通过 PostTestClass 测试原来的 Post 类:

class PostTest extends PHPUnit_Framework_TestCase
{
// ...
    /** @test */
    public function aPostIsNewIfIts15DaysOrLess()
    {
        $aPost = new PostTestClass(
            'A Post Content',
            'A Post Title'
        );
        $format = 'Y-m-d';
        $dateString = '2016-01-01';
        $createdAt = DateTimeImmutable::createFromFormat(
            $format,
            $dateString
        );
        $aPost->createdAt($createdAt);
        $aPost->setToday(
            $createdAt->add(
                new DateInterval('P15D')
            )
        );
        $this->assertTrue(
            $aPost->isNew()
        );
        $aPost->setToday(
            $createdAt->add(
                new DateInterval('P16D')
            )
        );
        $this->assertFalse(
            $aPost->isNew()
        );
    }
}

最后一个仅有的小细节:使用这种方法,是可以百分百覆盖 Post 类的,因为 today() 方法永远不会执行。然而,它可能会被其它测试覆盖。

外部伪造

另一种选择是把调用包装到 DateTimeImmutable 构造函数或者使用新类及一些静态方法来命名构造函数。在这样做的时候, 我们可以静态地更改这些方法的结果, 以便根据特定的测试方案进行不同的行为:

class Post
{
// ...
    private $createdAt;

    public function __construct($aContent, $title)
    {
// ...
        $this->createdAt(MyCustomDateTimeBuilder::today());
    }

// ...
    public function isNew()
    {
        return
            (MyCustomDateTimeBuilder::today())
                ->diff($this->createdAt)
                ->days <= self::NEW_TIME_INTERVAL_DAYS;
    }
}

对于获取今天的 DateTime,我们现在使用一个静态调用 MyCustomDateTimeBuilder::today。这个类也有一些 setter 方法来伪造结果,以便在下一次调用中返回:

class PostTest extends PHPUnit_Framework_TestCase
{
// ...
    /** @test */
    public function aPostIsNewIfIts15DaysOrLess()
    {
        $createdAt = DateTimeImmutable::createFromFormat(
            'Y-m-d',
            '2016-01-01'
        );
        MyCustomDateTimeBuilder::setReturnDates(
            [
                $createdAt,
                $createdAt->add(
                    new DateInterval('P15D')
                ),
                $createdAt->add(
                    new DateInterval('P16D')
                )
            ]
        );
        $aPost = new Post(
            'A Post Content',
            'A Post Title'
        );
        $this->assertTrue(
            $aPost->isNew()
        );
        $this->assertFalse(
            $aPost->isNew()
        );
    }
}

这种方法的主要问题是会与一个对象静态耦合,根据你的用例,创建一个灵活的伪造对象也很棘手。

反射

你同样只可以使用反射技术来构建一个新的具有自定义日期的 Post 类。可以使用 Mimic,一个简单的对象原型,数据水合,数据展示函数库:

namespace Domain;

use mimic as m;

class ComputerScientist
{
    private $name;
    private $surname;

    public function __construct($name, $surname)
    {
        $this->name = $name;
        $this->surname = $surname;
    }

    public function rocks()
    {
        return $this->name . ' ' . $this->surname . ' rocks!';
    }
}

assert(m\prototype('Domain\ComputerScientist')
    instanceof Domain\ComputerScientist);
m\hydrate('Domain\ComputerScientist', [
    'name' => 'John',
    'surname' => 'McCarthy'
])->rocks(); //John McCarthy rocks!
assert(m\expose(
        new Domain\ComputerScientist('Grace', 'Hopper')) ==
    [
        'name' => 'Grace',
        'surname' => 'Hopper'
    ]
);
分享与讨论

与你的同事讨论如何正确地对具体固定日期的实体进行单元测试,以及提出替代方法。

如果你想知道更多测试模式和方法,去看一看 Gerard Meszaros 的书 xUnit Test Patterns: Refactoring Test

验证

验证是我们领域模型里一个非常重要的过程。它不仅是检查属性的正确性,同时也对整个对象及组合对象进行验证。为了保证模型是可用状态,不同层次的验证是必要的。仅仅因为对象由可用属性(在每个基础上)组成,并不意味着对象(作为一个整体)是可用的。相反的是:对象可用并不等于组合可用。

属性验证

有些人理解的验证是通过一个服务验证给定对象的状态的过程。在这种情况下,验证符合契约式设计方法,它由先决条件,后置条件和不变量组成。保护单一属性的一种方法就是使用第三章提到的值对象。为了使我们的设计能够灵活应对变化,我们只关注断言必须满足领域的先决条件。这里,我们将使用 guards 作为验证先决条件的一种简单方式:

class Username
{
    const MIN_LENGTH = 5;
    const MAX_LENGTH = 10;
    const FORMAT = '/^[a-zA-Z0-9_]+$/';
    private $username;

    public function __construct($username)
    {
        $this->setUsername($username);
    }

    private function setUsername($username)
    {
        $this->assertNotEmpty($username);
        $this->assertNotTooShort($username);
        $this->assertNotTooLong($username);
        $this->assertValidFormat($username);
        $this->username = $username;
    }

    private function assertNotEmpty($username)
    {
        if (empty($username)) {
            throw new InvalidArgumentException('Empty username');
        }
    }

    private function assertNotTooShort($username)
    {
        if (strlen($username) < self::MIN_LENGTH) {
            throw new InvalidArgumentException(sprintf(
                'Username must be %d characters or more',
                self::MIN_LENGTH
            ));
        }
    }

    private function assertNotTooLong($username)
    {
        if (strlen($username) > self::MAX_LENGTH) {
            throw new InvalidArgumentException(sprintf(
                'Username must be %d characters or less',
                self::MAX_LENGTH
            ));
        }
    }

    private function assertValidFormat($username)
    {
        if (preg_match(self:: FORMAT, $username) !== 1) {
            throw new InvalidArgumentException(
                'Invalid username format'
            );
        }
    }
}

正如你在上面例子中看到的,为了构建一个 Username 值对象必须满足四个先决条件,它们是:

  1. 必须不为空
  2. 至少 5 个字符
  3. 少于 10 个字符
  4. 必须符合字符加数字或者下划线格式

如果满足了所有先决条件,属性就会正确设置,对象也会创建成功。否则,会产生一个 InvalidArgumentException 异常,程序会中止,客户端会显示一个错误。

有些程序员可能认为这种验证是一种防御性编程。然而,我们不是检查输入是字符串或者空值是不被允许的。我们无法避免人们错误地使用我们的代码,但我们可以控制领域状态的正确性。正如第三章,值对象所示,验证也可以具有安全性。

防御性编程并不是坏事。一般来说,当开发组件或者在其它项目中使用的第三方库时,这会更有意义。不过,在开发你自己的界限上下文时,可以避免这些额外且偏执的检查(空值,基本类型,类型提示等等),从而通过依赖单元测试套件的覆盖来提高开发速度。

对象整体验证

有时,一个由可用属性组成的对象,作为一个整体,仍然可能视为无效。添加这种验证到对象本身可能很诱人,但这一般是一种反模式。高层次验证的改变节奏不同于对象逻辑本身。另外,职责分离也是一种好的实践。

验证会通知客户端已找到的任何错误或收集要稍后复查的结果, 因为有时我们不想在第一次出现故障时停止执行。

一个 abstract 和可重用的 Validator 有时可以像这样:

abstract class Validator
{
    private $validationHandler;

    public function __construct(ValidationHandler $validationHandler)
    {
        $this->validationHandler = $validationHandler;
    }

    protected function handleError($error)
    {
        $this->validationHandler->handleError($error);
    }

    abstract public function validate();
}

作为一个具体的例子,我们想验证整个 Location 对象,包含验证 CountryCity,以及 Postcode 值对象。然而,在验证时这些单个值可能是不可用的。city 可能不是来自 country 的一部分,邮编可能不符合这个 city 的格式:

class Location
{
    private $country;
    private $city;
    private $postcode;

    public function __construct(
        Country $country, City $city, Postcode $postcode
    )
    {
        $this->country = $country;
        $this->city = $city;
        $this->postcode = $postcode;
    }

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

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

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

验证器将检查整个 Location 对象的状态,分析属性之间关系的含义:

class LocationValidator extends Validator
{
    private $location;

    public function __construct(
        Location $location, ValidationHandler $validationHandler
    )
    {
        parent:: __construct($validationHandler);
        $this->location = $location;
    }

    public function validate()
    {
        if (!$this->location->country()->hasCity(
            $this->location->city()
        )) {
            $this->handleError('City not found');
        }
        if (!$this->location->city()->isPostcodeValid(
            $this->location->postcode()
        )) {
            $this->handleError('Invalid postcode');
        }
    }
}

一旦所有属性设置好,我们就可以验证实体,最有可能是在一些描述性过程之后。表面上,看起来像是 Location 在验证自己。然而并不是这样,Location 类把验证代理给一个具体的验证实例 ,清晰地分离这些职责:

class Location
{
// ...
    public function validate(ValidationHandler $validationHandler)
    {
        $validator = new LocationValidator($this, $validationHandler);
        $validator->validate();
    }
}
解耦验证消息

通过对现有实现进行一些细微的更改, 我们可以将验证消息与验证程序解耦:

class LocationValidationHandler implements ValidationHandler
{
    public function handleCityNotFoundInCountry() {}
    public function handleInvalidPostcodeForCity() {}
}
class LocationValidator
{
    private $location;
    private $validationHandler;

    public function __construct(
        Location $location,
        LocationValidationHandler $validationHandler
    )
    {
        $this->location = $location;
        $this->validationHandler = $validationHandler;
    }

    public function validate()
    {
        if (!$this->location->country()->hasCity(
            $this->location->city()
        )) {
            $this->validationHandler->handleCityNotFoundInCountry();
        }
        if (!$this->location->city()->isPostcodeValid(
            $this->location->postcode()
        )) {
            $this->validationHandler->handleInvalidPostcodeForCity();
        }
    }
}

我们还需要将验证方法的签名更改为以下内容:

class Location
{
// ...
    public function validate(
        LocationValidationHandler $validationHandler
    ) {
        $validator = new LocationValidator($this, $validationHandler);
        $validator->validate();
    }
}

验证对象组合

验证对象组合可能很复杂。因此,实现此目标的首选方法就是通过领域服务。然后,该服务与仓储通信以检索可用的聚合。由于可能会创建复杂的对象,一个聚合可能处于中间状态,需要先验证其它聚合的状态。我们可以使用领域事件来通知系统其它部分,一个特殊的元素已经验证了。

实体和领域事件

我们将在以后的章节(第 6 章,领域事件)探讨;但重要的是要突出实体上执行的操作可能触发领域事件。这种方法用于领域变化与应用其它部分,或者甚至其它应用间的通信,正如你将在第 12 章,集成限界上下文中所看到的:

class Post
{
// ...
    public function publish()
    {
        $this->setStatus(
            Status::published()
        );
        $this->publishedAt(new DateTimeImmutable());
        DomainEventPublisher::instance()->publish(
            new PostPublished($this->id)
        );
    }

    public function unpublish()
    {
        $this->setStatus(
            Status::draft()
        );
        $this->publishedAt = null;
        DomainEventPublisher::instance()->publish(
            new PostUnpublished($this->id)
        );
    }
// ...
}

当一个新的实体实例创建时,甚至可能触发领域事件:

class User
{
// ...
    public function __construct(UserId $userId, $email, $password)
    {
        $this->setUserId($userId);
        $this->setEmail($email);
        $this->setPassword($password);
        DomainEventPublisher::instance()->publish(
            new UserRegistered($this->userId)
        );
    }
}

小结

领域中的一些概念依赖标识 -- 这就是,改变它们的状态并不会改变它们自身的唯一标识。我们已经看到除了操作标识自身的逻辑之外,将标识建模为值对象还可以带来像永久性这样的好处。我们也展示几种生成标识的做法,并在以下列表中复述:

  • 持久化机制:容易实现,但在持久化对象前你不会得到一个标识,它会延迟并使事件传播复杂化。
  • 代理 ID:一些 ORM 需要一个额外的字段,用持久化机制在实体中映射标识。
  • 客户端生成:有时标识符合领域概念,你可以在领域内对其建模。
  • 通过应用程序生成:你可以使用一个库来生成标识。
  • 通过限界上下文生成:可能是最复杂的策略。其它限界上下文提供一个接口来生成标识。

我们已经看到并讨论过使用 Doctrine 作为一种持久化机制,我们已经研究了使用活动记录模式的缺点,最后,我们讨论了不同级别的实体验证:

  • 属性验证:通过先决条件,后置条件及不变性来检查对象状态内部的细节。
  • 对象整体验证:检查整个对象的一致性。将验证提取到外部服务是一个好的实践。
  • 对象组合:复杂对象组合可以通过领域服务验证。与其它应用间通信的一个好方法就是领域事件。
阅读 2.1k更新于 1月14日

推荐阅读
目录