我们已经讨论过优先将领域中所有内容作为值对象进行建模的好处。但对领域建模时,可能会有一些场景,你会发现在通用语言里的某些概念需要一根标识线。
介绍
对象需要标识符的一些简单例子:
一个人。 人总是有一个标签,并且依照他们的名字或者身份证来说总是相同的。
电子商务系统订单。在这个上下文里,随便时间的推移每个新创建的订单都有它自己的标识。
这些有标识的概念有长期存在的特性。无论概念中的数据发生多少次变化,它们的标识总是相同。这些使得它们成为实体而不是值对象。就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 值对象必须满足四个先决条件,它们是:
必须不为空
至少 5 个字符
少于 10 个字符
必须符合字符加数字或者下划线格式
如果满足了所有先决条件,属性就会正确设置,对象也会创建成功。否则,会产生一个 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 对象,包含验证 Country, City,以及 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 作为一种持久化机制,我们已经研究了使用活动记录模式的缺点,最后,我们讨论了不同级别的实体验证:
- 属性验证:通过先决条件,后置条件及不变性来检查对象状态内部的细节。
- 对象整体验证:检查整个对象的一致性。将验证提取到外部服务是一个好的实践。
- 对象组合:复杂对象组合可以通过领域服务验证。与其它应用间通信的一个好方法就是领域事件。