实体
- 《领域驱动设计之PHP实现》全书翻译 - DDD入门
- 《领域驱动设计之PHP实现》全书翻译 - 架构风格
- 《领域驱动设计之PHP实现》全书翻译 - 值对象
- 《领域驱动设计之PHP实现》全书翻译 - 实体
- 《领域驱动设计之PHP实现》全书翻译 - 服务
- 《领域驱动设计之PHP实现》全书翻译 - 领域事件
- 《领域驱动设计之PHP实现》全书翻译 - 模块
- 《领域驱动设计之PHP实现》全书翻译 - 聚合
- 《领域驱动设计之PHP实现》全书翻译 - 工厂
- 《领域驱动设计之PHP实现》全书翻译 - 仓储
- 《领域驱动设计之PHP实现》全书翻译 - 应用服务
- 《领域驱动设计之PHP实现》全书翻译 - 集成上下文
- 《用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
值对象必须满足四个先决条件,它们是:
- 必须不为空
- 至少 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 作为一种持久化机制,我们已经研究了使用活动记录模式的缺点,最后,我们讨论了不同级别的实体验证:
- 属性验证:通过先决条件,后置条件及不变性来检查对象状态内部的细节。
- 对象整体验证:检查整个对象的一致性。将验证提取到外部服务是一个好的实践。
- 对象组合:复杂对象组合可以通过领域服务验证。与其它应用间通信的一个好方法就是领域事件。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。