经过使用 self
关键字,咱们不会将值对象做为领域驱动设计的基本构建块,在代码中它们用于对通用语言概念进行建模。值对象不只仅是领域中衡量,量化或者描述事物的东西。值对象能够被视为小而简单的对象 - 例如金钱或者日期范围 - 它们的相等性不是经过其标识,而是基于其持有的内容体现的。php
例如,产品价格能够用值对象建模。在这种状况下,它不表明一个东西,而是一个值,能够用于衡量产品的价值。这些对象的内存占用是微不足道的,没法肯定(经过它们的组成部分计算)且开销极小。所以,即便表示相同的值 ,建立新实例也优于引用重用,而后再根据两个实例的字段可比性来检查是否相等。mysql
Ward Cunningham 定义值对象以下:git
可衡量或者可描述的事物。值对象的例子如数值,日期,货币和字符串。一般,它们是一些使用至关普遍的小对象。它们的标识 是基于他们的状态而不是他们的对象标识。这样,你能够拥有同一律念值对象的多个副本。每一个5美圆的钞票都有自身的标识(多亏了它的序列号),但现金经济依赖于每5美圆与其它5美圆有相同的价值。
Martin Fowler 定义值对象以下:github
一个小对象,例如货币或者日期范围对象。它们的关键属性是听从值语义而不是引用语义。你一般能够说它们概念的等同不是基于它们的标识,而是两个值对象它们全部字段是否相等。尽管全部字段相等,但若是子集是惟一的,你也不须要比较全部字段 - 例如币种代码对于货币对象来讲足以说明它的相等性。一般的准则的值对象应该是彻底不可变的。若是你想改变一个值对象,应该用一个新的对象来替换它而不容许用值对象自己来更新值 - 值对象的更新会引发混淆问题。
值对象的例子有数值,文本字符,日期,时间,一我的的全名(由姓,中间名,名字,爵位等组成),货币,颜色,电话号码,邮箱地址。sql
考虑下面来自维基百科的例子,以便更好的理解值对象与实体的不一样点:shell
货币和金钱值对象多是解释值对象最有用的例子了,多亏了金钱模式。这种设计模式提供了一种模型化问题的方法,来避免浮点舍入问题,这又过来又容许执行肯定性运算。数据库
在现实世界中,货币用米和码的描述距离单位相同的方式来描述货币单位。每种货币用三个大写字母的 ISO 代码来表示:json
class Currency { private $isoCode; public function __construct($anIsoCode) { $this->setIsoCode($anIsoCode); } private function setIsoCode($anIsoCode) { if (!preg_match('/^[A-Z]{3}$/', $anIsoCode)) { throw new InvalidArgumentException(); } $this->isoCode = $anIsoCode; } public function isoCode() { return $this->isoCode; } }
值对象的主要目标之一也是面向对象设计的圣杯:封装。经过遵循此模式,你将最终获取一个专用位置,以便将全部验证,比较逻辑和行为都放在一块儿。设计模式
货币的扩展验证器
在以前的代码示例中,咱们能够用相似 AAA 的 ISO 代码来构建一个货币类。对于须要编写一个检查是否合法的 ISO 代码的具体规则来讲,这没起什么做用。这里有一个完整的 ISO 货币代码类清单。若是你须要帮助,就查看一下 Money packagist 库。
金钱则用来衡量一个具体的货币数量。它模型由金额和货币构成。在金钱模式的状况下,金额都是由货币最不值钱的分数来表示实现的 - 例如,在美圆,欧元,美分的状况下。数组
另外,你可能注意到咱们使用自封装来设置 ISO 代码,这使值对象自己的更改集中化了。
class Money { private $amount; private $currency; public function __construct($anAmount, Currency $aCurrency) { $this->setAmount($anAmount); $this->setCurrency($aCurrency); } private function setAmount($anAmount) { $this->amount = (int)$anAmount; } private function setCurrency(Currency $aCurrency) { $this->currency = $aCurrency; } public function amount() { return $this->amount; } public function currency() { return $this->currency; } }
如今你知道了值对象的正式定义了,让咱们更深刻地了解它们提供的最大功能吧。
当用代码模型化一个通用语言概念时,你应该老是倾向于在实体上使用值对象。值对象问题更容易建立,测试,使用和管理。
正如以前讨论的,一个值对象不该该被视你领域中的一个事物。做为值,它能够衡量,量化,描述领域内的概念。
在咱们的例子里,货币对象描述了金钱是什么类型。金钱对象衡量或者量化了一个给定的货币单位。
这是须要要掌握的最重要的方面之一。值对象不该该在他们的生命周内改变。因为这种永久性,值对象易于推导和测试以及没有不受欢迎/意外的反作用。所以,值对象应该由他们的构造器建立。为了生成一个值对象,你一般经过构造函数传递必要原生类型或者其它值对象。
值对象老是处于有效状态;这就是为何咱们在一个原子步骤中建立它们。具备多个 getter
和 setter
方法的空构造函数将建立责任转移到客户端,从而致使了贫血模型,这被认为是一种反模式。
还须要指出的是,咱们不建议在值对象中保留对实体的引用。实体是可变的,而且保留对它们的引用可能致使在值对象中发生一些不可取的反作用。
在具备方法重载的语言(例如Java)中,你能够用同一个名称建立多个构造函数 。每一个构造函数都提供不一样的选项来生成相同类型的对象。在 PHP 里,咱们能够经过工厂方法来提供相似的能力。这些 特定的工厂方法也被称为构造语义。fromMoney
的主要目标是提供比普通构造函数更多的上下文意义。更激进的方法建议将 __construct
方法私有化, 并使用语义构造函数构建每一个实例。
在咱们的 Money
对象里,咱们能够添加以下一些有用的工厂方法:
class Money { // ... public static function fromMoney(Money $aMoney) { return new self( $aMoney->amount(), $aMoney->currency() ); } public static function ofCurrency(Currency $aCurrency) { return new self(0, $aCurrency); } }
经过使用 self
关键字,咱们没必要用类名来耦合代码。所以,类名或者命名空间的改变不会影响到工厂方法。这个小细节实如今之后重构代码会起到帮助。
static
vs.self
当一个值对象继承另外一个值对象时,在 self 上 使用 static 将致使不可预期的问题。
因为这种不变性,咱们必须考虑如何在有状态上下文的常见位置处理易变操做。若是咱们须要一个状态变化,则须要用这个变化来返回一个全新的值对象表述。若是咱们要增长金额,例如,一个金钱值对象,则经过所需改动来返回一个新的 Money
实例。
幸运的是,遵循此规则相对简单,以下面的例子所示:
class Money { // ... public function increaseAmountBy($anAmount) { return new self( $this->amount() + $anAmount, $this->currency() ); } }
increaseAmountBy
返回的 Money
对象与接收方法调用的 Money
客户端对象不一样。在下面的示例可比性检查中能够观察到这一点:
$aMoney = new Money(100, new Currency('USD')); $otherMoney = $aMoney->increaseAmountBy(100); var_dump($aMoney === otherMoney); // bool(false) $aMoney = $aMoney->increaseAmountBy(100); var_dump($aMoney === $otherMoney); // bool(false)
那么为何不照下面的例子实现,同时还能够避免实例化一个新的对象呢?
class Product { private $id; private $name; /** * @var int */ private $amount; /** * @var string */ private $currency; // ... }
这种方法有一些明显的缺陷,要说的话,例如你想要验证 ISO. 但对 Product
来讲,验证 ISO 的责任是没有意义的(从而违反了单一职责原则)。若是你想在其它领域重用这一部分代码的话,这一点将会更加突出(遵循 DRY 原则)。
考虑到这些因素,这个用例是一个被抽象成值对象的完美候选项,使用此抽象不只让你有机会将相关属性组合在一块儿,还将同时让你建立更高阶的概念和更具体的通用语言。
练习
与你的小伙伴一块儿讨论,一个邮件是否能够被考虑为一个值对象?它使用的上下文会对此有影响吗?
正如本章开头所讨论的,若是两个值对象的衡量,量化,或描述是相同的,那么它们就是相等的。
例如,想象两个表明 1 美圆的 Money
对象。咱们能够说它们是相等的吗?在真实世界里,两个 1 美圆的硬币价值是相同的吗?固然是。咱们回过头来看代码,问题中的值对象是指不一样的 Money
实例。然而,它们都表示相同的值,这使得它们相等。
在 PHP 里,使用 ==
来比较两个值对象是司空见惯的。查看 PHP Documentation 里这个运算符的定义突出了一个更有趣的行为:
当使用比较运算符
==
时,比较对象的值是一种简单的方式:若是两个对象实例有相同的属性和值,以及是同一个类的实例,那么他们是相等的。
这个行为与咱们对值对象的正式定义一致。然而,做为一个精确的类匹配谓词,在处理子类型的值对象时你应该当心谨慎。
牢记这一点,更严格的 ===
运算符也没有帮到咱们,不幸的是:
当使用运算标识符
===
,两个值对象相等,仅仅在它们是指同一个类的同一个实例的状况时。
下面的例子能够帮助验证这些微妙不一样之处:
$a = new Currency('USD'); $b = new Currency('USD'); var_dump($a == $b); // bool(true) var_dump($a === $b); // bool(false) $c = new Currency('EUR'); var_dump($a == $c); // bool(false) var_dump($a === $c); // bool(false)
一个解决办法是,在每一个值对象里实现一个常规的相等断定方法。这个方法负责检查它们的复合属性的类型和相等性。抽象数据类型的比较,用 PHP 内置的类型提示实现是很是容易的。必要的话你也可使用 get_class()
函数来帮助你检查比较。
然而,语言并不能诠释你的领域概念中相等的真正意义,也意味着你必须提供答案。为了比较 Currency
对象,咱们仅须要确认它们关联的 ISO 代码是相同的。===
运算符在下面的案例中完美体现:
class Currency { // ... public function equals(Currency $currency) { return $currency->isoCode() === $this->isoCode(); } }
由于 Money 对象使用了 Currency
对象,equals
方法须要将连同金额的比较一块儿执行。
class Money { // ... public function equals(Money $money) { return $money->currency()->equals($this->currency()) && $money->amount() === $this->amount(); } }
考虑一个 Product
实体,其包含了一个 Money
值对象用来衡量它们的价值。此外,考虑两个彻底同样的 Product
实体 - 例如100美圆。此方案可使用两个单独的 Money
对象或者两个指向单个值对象的引用来建模。
共享相同的值对象可能会有风险。若是一个改变了,二者都会发生变化。这种行为可视为异常的反作用。例如,若是 Carlos 在 2 月 20 日被录用,并且咱们知道 Christian 在同一天录用,咱们可能将 Christian 的录用日期设置成与 Carlos 的一致。那么只要 Carlos 以后将他的录用改为 5 月,Charistian 的录用日期也会改变。无论对错与否,这都不是人们所期待的。
因为此例中凸显的问题,当持有一个值对象的引用时,建议将其总体替换,而不是改变其值:
$this−>price = new Money(100, new Currency('USD')); //... $this->price = $this->price->increaseAmountBy(200);
这种行为与 PHP 中的基本类型(如字符串)的工做方式相似。考虑函数 strtolower
. 它返回一个新的字符串而不是修改原值。不使用引用,而是返回一个新的值。
咱们若是想在 Money 类中引入一些额外的行为,好比 add
方法,那么检查输入是否符合任何先决条件并保持不变性是很天然的。在咱们的例子中,咱们仅仅但愿用一样的货币增长金钱:
class Money { // ... public function add(Money $money) { if ($money->currency() !== $this->currency()) { throw new InvalidArgumentException(); } $this->amount += $money->amount(); } }
若是两次货币不匹配,就会抛出异常。反之金额就会增长。然而,此段代码仍有一些不可取的缺陷。如今想象一下,在咱们的代码中有一个神秘的方法 otherMethod
:
class Banking { public function doSomething() { $aMoney = new Money(100, new Currency('USD')); $this->otherMethod($aMoney);//mysterious call // ... } }
一切看起来都很好直到某些缘由,当咱们返回或完成 otherMethod
时,咱们开始看到了意想不到的结果。忽然,$aMoney
再也不包含 100 美圆。发生了什么?若是 otherMethod
方法内部使用了咱们以前定义的 add
方法又会怎么样?也许你不明白是添加了变异的 Currency 实例状态。这就是咱们所说的反作用。你必须避免产生反作用。你不能让你的论据变异。若是你这样作,开发者使用你的对象可能会遇到一些奇怪的行为。他们就会抱怨,而且他们会是正确的。那么咱们应该怎样解决这个问题?简单来讲,经过确保值对象保持不变,咱们就能避免此类异常问题。一个简单的办法就是为每一个可变操做返回一个新的实例,正以下面 add
方法:
class Money { // ... public function add(Money $money) { if (!$money->currency()->equals($this->currency())) { throw new \InvalidArgumentException(); } return new self( $money->amount() + $this->amount(), $this->currency() ); } }
有了这种简单的改动,就保证了不变性。每次两个 Money
实例相加时,将会返回一个新的结果实例。其它类能够在不影响原始副本的状况下执行任意数量的改变。无反作用的代码易于理解,便于测试,难以出错。
考虑下面的代码片段:
$a = 10; $b = 10; var_dump($a == $b); // bool(true) var_dump($a === $b); // bool(true) $a = 20; var_dump($a); // integer(20) $a = $a + 30; var_dump($a); // integer(50);
尽管 $a
和 $b
是不一样的变量,存储在不一样内存位置,但在比较它们时,它们是相同的。它们有相同的值,因此咱们认为它们是相等的。你能够在任什么时候刻将 $a
的值从 10 改变成 20,你能够在不考虑上一个值的状况下尽量多地替换整数值,由于你根本没有修改它;你只是在替换它。若是对这些变量应用任何操做(例如 $a + $b
),则能够得到另外一个可分配给另外一个或以前定义的变量的值。当你将 $a
传给另外一个函数,除非经过引用显式传递,不然你传递的就是值。$a
在此函数中的改变与否也无紧要,由于在当前的代码中,你仍然拥有原始副本。值对象的行为就是基本类型。
值对象的测试方式与正常对象相同。然而,不变性及无反作用行为也必须测试。解决方案就是在执行任何改动前建立要测试的值对象副本。断言都是相等的,使用实现的相等性检查。执行要测试的操做并断言结果。最后,断言原始对象和副本仍然相等。
让咱们把这个付诸实践,并在 Money 类中测试咱们实现的无反作用 add
方法:
class MoneyTest extends FrameworkTestCase { /** * @test */ public function copiedMoneyShouldRepresentSameValue() { $aMoney = new Money(100, new Currency('USD')); $copiedMoney = Money::fromMoney($aMoney); $this->assertTrue($aMoney->equals($copiedMoney)); } /** * @test */ public function originalMoneyShouldNotBeModifiedOnAddition() { $aMoney = new Money(100, new Currency('USD')); $aMoney->add(new Money(20, new Currency('USD'))); $this->assertEquals(100, $aMoney->amount()); } /** * @test */ public function moniesShouldBeAdded() { $aMoney = new Money(100, new Currency('USD')); $newMoney = $aMoney->add(new Money(20, new Currency('USD'))); $this->assertEquals(120, $newMoney->amount()); } // ... }
值对象自身并不持久化;它们一般用聚合来持久化。值对象不该做为一条完整记录保存,尽管在某些状况下能够这样作。值对象最好以嵌入值或者序列化的 LOB 模式存储。当你开源的 ORM 例如 Doctrine
或者定制的 ORM 来存储你的对象时,这两种模式均可以使用。因为值对象很小,嵌入值一般是最好的选择,由于它提供一个简单的途径来查询实体,即经过值对象里的任意属性。不过,假如用这些字段来查询对你并不重要,那么用序列化策略来持久化将很是容易实现。
考虑下面的 Product
实体,有字符串 id
, name
和 price
(Money 值对象)这些属性。 咱们有意简单化实现这些例子,因此用一个字符 id
而不是值对象:
class Product { private $productId; private $name; private $price; public function __construct( $aProductId, $aName, Money $aPrice ) { $this->setProductId($aProductId); $this->setName($aName); $this->setPrice($aPrice); } // ... }
假设你已经学过第 10 章,以 仓储 来持久 Product
实体,能够用以下方式来实现建立和持久一个新的 Product
:
$product = new Product( $productRepository->nextIdentity(), 'Domain-Driven Design in PHP', new Money(999, new Currency('USD')) ); $productRepository−>persist(product);
如今咱们看到定制的 ORM 和 Doctrine 的实现均可以用来持久化包含值对象的 Product
实体。咱们将突出嵌入值和序列化 LOB 模式的应用,以及持久化单个值对象和集合之间的不一样。
为何用 Doctrine ?Doctrine 是一个强大的 ORM。它解决 80% 以上 了 PHP 应用要面对的问题。同时它有一个强大的社区。只要正确合适的配置好,它能够产生与定制的 ORM 相同甚至更好的效果(同时不丢失可维护性)。咱们推荐在大多数场景下使用 Doctrine 来处理实体和业务逻辑。它能够帮你节省大量时间和脑细胞。
持久化单个值对象有许多方法,从用序列化 LOB 或者嵌入式值做为映射策略,到使用定制 ORM 或者开源方法,好比 Doctrine。咱们考虑到为了持久化实体到数据库,大家公司能够已经开发了自建的定制 ORM。在咱们的方案中,定制的 ORM 应该用 DBAL
库来实现。根据官方文档,DBAL 即 Doctrine 数据库抽象层以及访问层(The Doctrine Database Abstraction & Access Layer)提供了一个相似 PDO API 的轻量且薄的运行时层次以及许多附加的,水平的功能,例如经过一个面对对象型的 API 来处理数据库架构及一些操做。
若是咱们要用一个实现嵌入值模式的定制 ORM,则须要为每一个值对象里的属性在实体表中建立一个字段。在这种状况下,持久久一个 Product
实体须要两个扩展栏位:一个是值对象的金额,一个是它自身的货币 ISO 代码。
CREATE TABLE `products` ( id INT NOT NULL, name VARCHAR( 255) NOT NULL, price_amount INT NOT NULL, price_currency VARCHAR( 3) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
对于持久化对象到数据库,咱们第 10 章,仓储必须映射实体中的每一个字段以及 Money
值对象中的每一个属性。
若是你使用一个基于 DBAL 定制的 ORM 仓储(让咱们称之为 DbalProductRepository
),你必须当心地建立 INSERT
语句,构建参数,以及执行语句:
class DbalProductRepository extends DbalRepository implements ProductRepository { public function add(Product $aProduct) { $sql = 'INSERT INTO products VALUES (?, ?, ?, ?)'; $stmt = $this->connection()->prepare($sql); $stmt->bindValue(1, $aProduct->id()); $stmt->bindValue(2, $aProduct->name()); $stmt->bindValue(3, $aProduct->price()->amount()); $stmt->bindValue(4, $aProduct ->price()->currency()->isoCode()); $stmt->execute(); // ... } }
在执行这个代码片段后,建立了一个 Product
实体,同时将其保存到数据库,表中的每一列都显示了期待的结果:
mysql> select * from products \G *************************** 1. row *************************** id: 1 name: Domain-Driven Design in PHP price_amount: 999 price_currency: USD 1 row in set (0.00 sec)
正如你所见,你能够经过定制方法映射值对象和查询参数,来持久化值对象。然而,一切并不像看起来那么简单。让咱们尝试用 Product
关联的 Money
值对象来获取它。一般的方法是执行一个 SELECT
语句同时返回一个新实体:
class DbalProductRepository extends DbalRepository implements ProductRepository { public function productOfId($anId) { $sql = 'SELECT * FROM products WHERE id = ?'; $stmt = $this->connection()->prepare($sql); $stmt->bindValue(1, $anId); $res = $stmt->execute(); // ... return new Product( $row['id'], $row['name'], new Money( $row['price_amount'], new Currency($row['price_currency']) ) ); } }
这种方法有几个好处。首先,你能够轻松地,逐步看懂持久化及其后续创做的发生过程。其次,你能够基于值对象里任意的属性来查询。最后,持久化实体所需的空间正如咱们所需的,很少也很多。
然而,使用定制的 ORM 方式也有它的缺点,正如第 6 章,领域事件中所解释的,若是你的领域模型对聚合的建立过程感兴趣,实体(以聚合形式)应在构造函数中触发一个事件。若是你使用 new
运算符,则会在数据库中查出聚合时屡次触发该事件。
这就是为何 Doctrine 使用内部的代理和序列化以及反序列方法在不使用构造函数的状况下用对象属性的特定状态来重建它。一个实体应该仅在其生命周期内使用 new
运算符建立。
构造函数构造函数并不须要为对象的每一个属性声明参数。设想一个博客帖子,构造函数可能须要一个
id
和title
;然而,它内部能够将其状态属性设置为草稿。当发布帖子时,为了将其修改成发布状态,则须要调用一个发布方法。
若是你仍然意图推出你本身的 ORM,则要准备好解决一些基础性问题,例如事件,不一样的构造函数,值对象,惰性加载关系,等等。这就是咱们为何推荐在领域驱动设计应用中给 Doctrine 一个机会。
除此以外,在本实例中,你须要建立一个继承于 Product
的 DbalProduct
实体,为了在不使用 new
运算符的状况下,使用一个静态工厂方法从数据库中重建实体。
最新的 Doctrine 发行版本为 2.5,同时它支持值对象映射。从而消除了你须要在 2.4 版本中手动完成这些工做。从 2015 年 12 月开始,Doctrine也有了对嵌套值的支持。尽管支持度没有 100%,但也很是值得尝试。万一它对你的场景不适用,则查看下一节。对于官方文档,则查看 Doctrine Embeddables reference
一节。若是正确实现了这一项,那绝对是咱们最推荐的。这将是最简单,最优雅的解决方案,同时它经过 DQL
查询语言提供了搜索功能。
由于 Product
, Money
, 以及 Currency
类已经展现过了,惟一剩下的就是展现 Doctrine 映射文件:
<?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="Product" table="product"> <id name="id" column="id" type="string" length="255"> <generator strategy="NONE"> </generator> </id> <field name="name" type="string" length="255" /> <embedded name="price" class="Ddd\Domain\Model\Money" /> </entity> </doctrine-mapping>
在 Product
映射里,咱们定义 Price
为一个持有 Money
对象的实例。同时,Money
被设计为拥有金额和 Currency
的实例:
<?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"> <embeddable name="Ddd\Domain\Model\Money"> <field name="amount" type="integer" /> <embedded name="currency" class="Ddd\Domain\Model\Currency" /> </embeddable> </doctrine-mapping>
最后,是时候展现咱们的 Currency
值对象的 Doctrine 映射了:
<?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"> <embeddable name="Ddd\Domain\Model\Currency"> <field name="iso" type="string" length="3" /> </embeddable> </doctrine-mapping>
正如你所见,上述代码有一个标准的嵌套定义,经过一个持有 ISO 代码的字符串类型字段。这种方法是使用嵌套的最简单的途径,甚至更高效。默认状况下,Doctrine 经过在值对象名字前加前缀的方式来命名你的栏位。你能够经过在 XML 注释中改变栏位前缀属性来改变这些行为,以达到你所需。
若是你还停留在 Doctrine 2.4里,你可能想知道小于 2.5 版本时,使用嵌套值的可接受方案是什么。如今,咱们须要代理 Product
实体中的全部值对象属性,这意味着将建立出拥有值对象信息的新属性。有了这个,咱们能够用 Doctrine 映射全部这些新的属性。让咱们来看看这对 Product
实体有什么影响:
<?php class Product { private $productId; private $name; private $price; private $surrogateCurrencyIsoCode; private $surrogateAmount; public function __construct($aProductId, $aName, Money $aPrice) { $this->setProductId($aProductId); $this->setName($aName); $this->setPrice($aPrice); } private function setPrice(Money $aMoney) { $this->price = $aMoney; $this->surrogateAmount = $aMoney->amount(); $this->surrogateCurrencyIsoCode = $aMoney->currency()->isoCode(); } private function price() { if (null === $this->price) { $this->price = new Money( $this->surrogateAmount, new Currency($this->surrogateCurrency) ); } return $this->price; } // ... }
正如你所见,这有两个新的属性:一个是 amount,一个是 currency 的 IOS 代码。咱们已经更新了 setPrice
方法,以便在设置它时保持属性一致。在上面,咱们咱们更新了 price 的 getter
方法,以便返回重新字段中生成的 Money
值对象。让咱们看看相应的 Doctrince XML 映射文件应该怎样改动:
<?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="Product" table="product"> <id name="id" column="id" type="string" length="255"> <generator strategy="NONE"> </generator> </id> <field name="name" type="string" length="255" /> <field name="surrogateAmount" type="integer" column="price_amount" /> <field name="surrogateCurrencyIsoCode" type="string" column="price_currency" /> </entity> </doctrine-mapping>
代理属性严格来讲这两个新字段不属于此领域模型,由于它们不引用基础设施的具体信息。相反,因为在
Doctrine
中缺乏嵌套值的支持,它们必不可少。还有一些替代方法能够把这两个属性置于领域以外;然而,这种方法是最简单,容易,以及做为一种权衡,是最能接受的。另外此书还有一处使用代理属性的例子;你能够在第 4 章,实体,标识操做的子部分代理标识一节中找到.
若是咱们想将这两个属性放到领域以外,这能够经过使用一个抽象工厂来达到。首先,咱们须要在咱们的基础设施文件夹里建立一个新的实体,DoctrineProduct
。它继承于 Product
实体。全部的代理字段都放在这个新的类中,以及例如 price
或者 setPrice
这些方法须要从新实现。咱们将用 DoctrineProduct
来映射到 Doctrine
而不是 Product
实体。
如今,咱们能够从数据库中检索出实体了,但怎样新建一个 Product
呢?在某些时候,咱们须要调用一个新的 Product
,但因为咱们须要用 DoctrineProduct
来处理,同时又不想对应用服务暴露具体的基础设施,咱们将须要使用工厂来建立 Product
实体。所以,在每一个实体构造新实例的地方,你须要在 ProductFactory
中调用 createProuct
来代替。
这会产生许多附加的类,以免代理属性污染源实体。因此,咱们推荐用同一实体来代理全部值对象,尽管这无疑会致使一个不太纯的解决方案。
若是增长值对象的搜索能力并不重要,则能够考虑另外一种模式:序列化 LOB。这种模式经过将整个值对象序列化为一个字符串格式,以便容易存储和检索。这种方案与嵌套方案最大的区别在于,后一种选项里,持久化足迹要求减小到单列:
CREATE TABLE ` products` ( id INT NOT NULL, name VARCHAR( 255) NOT NULL, price TEXT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
为了使用这种方法来持久化 Product
实体,须要对 DbalProductRepository
作一个改动。在持久化 final
实体前,Money` 值对象须要序列化为一个字符串:
class DbalProductRepository extends DbalRepository implements ProductRepository { public function add(Product $aProduct) { $sql = 'INSERT INTO products VALUES (?, ?, ?)'; $stmt = $this->connection()->prepare(sql); $stmt->bindValue(1, aProduct−> id()); $stmt->bindValue(2, aProduct−> name()); $stmt->bindValue(3, $this−> serialize($aProduct->price())); // ... } private function serialize($object) { return serialize($object); } }
如今让咱们看看 Product
在数据库是如何呈现的。表的 price
列是一个 TEXT 类型的列,它含有一个序列的 Money
对象表明 9.99 USD:
mysql > select * from products \G *************************** 1.row*************************** id : 1 name : Domain-Driven Design in PHP price : O:22:"Ddd\Domain\Model\Money":2:{s:30:"Ddd\Domain\Model\\ Money amount";i : 999;s:32:"Ddd\Domain\Model\Money currency";O : 25:"Ddd\Domain\Model\\ Currency":1:{\ s:34:" Ddd\Domain\Model\Currency isoCode";s:3:"USD";}}1 row in set(\ 0.00 sec)
这就是这种方法所作的。不过,因为重构这些类时发生的一些问题,咱们并不推荐这种作法。你能想象这些问题吗?若是咱们决定重命名 Money
类?当你把 Money
类从一个命名空间移动到另外一个时,须要数据库层面如何呈现?另外一种权衡,前面已经解释过,就是弱化查询能力。它与你是否使用 Doctrine 无关;在使用序列化策略时,编写一个查询语句使 products 更实惠,比方说,200 USD 是几乎不可能的。
这种查询问题只能用嵌套值的方式解决。不过,序列化重构问题能够用特定的处理序列化过程的库来解决。
PHP 原生的序列/反序列化策略有一个问题,就是重构命名空间和类。一个替代方法就是使用你本身的序列化机制 -- 例如,用一个字符分割好比 "|" 来串联金额和货币 ISO 代码。不过,这还有另外一种更受欢迎的方法:使用一个开源的序列化库,例如 JSM Serializer. 让咱们看看一个应用它来序列化 Money
对象的例子:
$myMoney = new Money(999, new Currency('USD')); $serializer = JMS\Serializer\SerializerBuilder::create()->build(); $jsonData = $serializer−>serialize(myMoney, 'json');
反序列化对象,过程也是很简单的:
$serializer = JMS\Serializer\SerializerBuilder::create()->build(); // ... $myMoney = $serializer−>deserialize(jsonData, 'Ddd', 'json');
经过这个例子,你能够在没必要更新数据库的前提下重构你的 Money
类。JMS Serializer 能够在多种不一样场景下使用 -- 例如,当使用 REST API 时。其中一个重要的特性就是能够在序列化过程当中指定对象中应该省略的属性 -- 例如密码。
查阅 Mapping Reference 和 Cookbook 以获取更多资料。JMS Serializer 在任何领域驱动设计项目中是必需的。
在 Doctrine
中,有许多不一样的方法来序列化对象,以便最终持久化。
Doctrine
对象映射类型Doctrine
支持序列化 LOB 模式。这里有大量预约义的映射类型,以便将实体属性匹配到数据库栏位甚至表中。其中的一种映射类型就是对象类型,它能够将 SQL CLOB
映射到 PHP 的 serializer()
和 unserialize()
上。
按照 Doctrince DBAL 2, Documentation中的描述:
null
值,若是没有数据的话。Doctrine
没法正确地使用那些不支持栏目注释的数据库产品来正确地返回值对象类型,而是直接返回文本类型来代替。因为 PostgreSQL
内置的文本类型不支持 null
字节,对象类型将致使反序列化错误。此问题的一个解决办法就是使用 serialize()
/unserialize()
和 base64_encode()
/bease64_decode()
来处理 PHP 对象,手动将它们存储到数据库。
让咱们看一种可能的使用对象类型的 Product
实体的 XML 映射:
<?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="Product" table="products"> <id name="id" column="id" type="string" length="255"> <generator strategy="NONE"> </generator> </id> <field name="name" type="string" length="255" /> <field name="price" type="object" /> </entity> </doctrine-mapping>
增长的关键是 type="object"
,它告诉 Doctrine 咱们将使用一个对象类型映射。接下来看看咱们是怎样使用 Doctrine 来建立和持久化一个 Product
实体:
// ... $em−>persist($product); $em−>flush($product);
如今咱们检查下,假如咱们从数据库中查询 Product
实体,它是否以期待的状态返回:
// ... $repository = $em->getRepository('Ddd\\Domain\\Model\\Product'); $item = $repository->find(1); var_dump($item);
最后但不是最重要的,Doctrine DBAL 2 Documentation
声明:
对象类型经过引用比较,而不是值。若是引用改变,Doctrine 就会更新值,所以这个行为就像这些对象是不可变的值对象同样。
这种方法面临与定制 ORM 同样的重构问题。对象映射类型在内部使用 serialize/unserialize
。那么不如使用咱们本身的序列化方式?
另外一种方式就是使用 Doctrine 自定义类型来处理值对象的持久化。自定义类型增长一个新的映射类型到 Doctrine -- 描述了实体字段与数据库表述间的自定义的转换,以便持久化前者。
正如 Doctrine DBAL 2 文档解释:
仅仅重定义数据字段类型与 doctrine 已存在的类型间的映射是不够用的。你能够经过继承 `DotrineDBALTypes
Type` 来定义你本身的类型。你须要实现 4 个不一样的方法来完成这项工做。
因为使用了对象类型,序列化步骤包含了诸如类的一些信息,使用它很是难以安全地重构咱们的代码。
让咱们试着优化咱们的解决方案。考虑一下自定义的序列化过程来解决这个问题。
好比其中一个方法就是把 Money
值对象做为字符串持久到数据库,编码为 amount|isoCode
格式:
use Ddd\Domain\Model\Currency; use Ddd\Domain\Model\Money; use Doctrine\DBAL\Types\TextType; use Doctrine\DBAL\Platforms\AbstractPlatform; class MoneyType extends TextType { const MONEY = 'money'; public function convertToPHPValue( $value, AbstractPlatform $platform ) { $value = parent::convertToPHPValue($value, $platform); $value = explode('|', $value); return new Money( $value[0], new Currency($value[1]) ); } public function convertToDatabaseValue( $value, AbstractPlatform $platform ) { return implode( '|', [ $value->amount(), $value->currency()->isoCode() ] ); } public function getName() { return self::MONEY; } }
你必须注册全部的自定义类型才能使用 Doctrine 。它一般用一个 EntityMangerFactory
来集中建立 EntityManger
。
或者,你若是经过应用启动执行这一步骤:
use Doctrine\DBAL\Types\Type; use Doctrine\ORM\EntityManager; use Doctrine\ORM\Tools\Setup; class EntityManagerFactory { public function build() { Type::addType( 'money', 'Ddd\Infrastructure\Persistence\Doctrine\Type\MoneyType' ); return EntityManager::create( [ 'driver' => 'pdo_mysql', 'user' => 'root', 'password' => '', 'dbname' => 'ddd', ], Setup::createXMLMetadataConfiguration( [__DIR__ . '/config'], true ) ); } }
接下来咱们须要在映射中指明咱们的自定义类型:
<?xml version = "1.0" encoding = "utf-8"?> <doctrine-mapping> <entity name = "Product" table = "product"> <!-- ... --> <field name = "price" type = "money" /> </entity> </doctrine-mapping>
为何使用 XML 映射?多亏了 XML 映射文件头部中的 XSH 语法验证,许多集成开发环境(IDE)设置提供了自动完成功能,在映射定义中显示全部元素和属性。不过,在本书的其它部分,咱们使用 YAML 来展现不一样的语法。
让咱们检验数据库,看看使用这种方法后价格是如何存储的:
mysql> select * from products \G *************************** 1. row*************************** id: 1 name: Domain-Driven Design in PHP price: 999|USD 1 row in set (0.00 sec)
这种方法在未来的重构方面是一个改进。然而,搜索能力仍然受到列格式的限制。经过 Doctrine 自定义类型,你能够稍微改善状况,但它还不是构建 DQL 查询的最佳选项。能够参考 Doctrine Cumstom Mapping Types
获取更多信息。
讨论时间与伙伴思考和讨论你是怎样用 JMS 建立自定义类型来序列化和反序列化值对象的。
想象一下咱们如今想要添加一个价格集合到 Product
实体中。这些能够表示产品生命周期或者不一样币种状况的价格。这些能够命名为 HistoricalPrice
,以下:
class HistoricalProduct extends Product { /** * @var Money[] */ protected $prices; public function __construct( $aProductId, $aName, Money $aPrice, array $somePrices ) { parent::__construct($aProductId, $aName, $aPrice); $this->setPrices($somePrices); } private function setPrices(array $somePrices) { $this->prices = $somePrices; } public function prices() { return $this->prices; } }
HistoricalProduct
继承自 Product
,因此它继承了相同的行为,以及价格集合的功能。
如前面部分所述,若是你不关心搜索能力,序列化是一个可行的方法。不过,在咱们明确知道要持久化多少价格,嵌入值方式是能够的。可是假如咱们想持久化一个不肯定的历史价格集合又会怎样呢?
持久化值对象集合到一个列看起来是最简单的解决方案。全部以前部分解释过的关于单个值对象的的持久化均可以应用到这种状况。经过 Doctrine 你可使用一个对象或者自定义类型 -- 同时考虑到一些注意事项:值对象应该很小,但若是你想持久化一个大型集合,必须确保数据库引擎每行能支持的最大长度及每行支持的最大容量。
练习想出 Doctrine 对象类型和 Doctrine 自定义类型的实现策略,来持久化一个含有价格集合的
Product
。
若是你想经过一个实体关联的值对象来持久化和查询,你能够选择将值对象以实体形式存储。就领域而言,这些对象仍然是值对象,但咱们须要赋值它们一个主键而且将它们与全部者,一个真正的实体以一对多/一对一的方式关联起来。总而言之,你的 ORM 以实体形式处理值对象集合,而你的领域,仍然将它们视为值对象。
联表策略背后的主要思想是建立一个表来链接实体和它的值对象。让咱们来看数据库中的呈现:
CREATE TABLE ` historical_products` ( `id` char( 36) COLLATE utf8mb4_unicode_ci NOT NULL, `name` varchar( 255) COLLATE utf8mb4_unicode_ci NOT NULL, `price_amount` int( 11 ) NOT NULL, `price_currency` char( 3) COLLATE utf8mb4_unicode_ci NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
historical_products
表看起来与 product
一致。记住一点,HistoricalProduct
继承 Product
实体一是为了更容易的展现如何持久化一个集合。如今须要一个新的 prices
表来持久化全部 product
实体中不一样的 Money
值对象:
CREATE TABLE `prices`( `id` int(11) NOT NULL AUTO_INCREMENT, `amount` int(11) NOT NULL, `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
最后,须要一个关联 products
和 prices
的表:
CREATE TABLE `products_prices` ( `product_id` char( 36) COLLATE utf8mb4_unicode_ci NOT NULL, `price_id` int( 11 ) NOT NULL, PRIMARY KEY (`product_id`, `price_id`), UNIQUE KEY `UNIQ_62F8E673D614C7E7` (`price_id`), KEY `IDX_62F8E6734584665A` (`product_id`), CONSTRAINT `FK_62F8E6734584665A` FOREIGN KEY (`product_id`) REFERENCES `historical_products` (`id`), CONSTRAINT `FK_62F8E673D614C7E7` FOREIGN KEY (`price_id`) REFERENCES `prices`(`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Doctrine
要求全部数据库实体都有一个惟一标识符。由于咱们想持久化 Money
值对象的话,就须要人为地增一个标识,以便 Doctrine
可以处理。这里有两个选项:在 Money
值对象中包含这个代理标识,或者将它放置到一个扩展类中。
第一种方式的问题是,新的标识仅仅是由于数据库持久层的须要。标识并非领域的一部分。
第二种方式的问题是,为了不所谓的边界泄漏,须要作大量的变更。用一个类扩展从任意领域对象中建立一个新的 Money
值对象实例,是不推荐的,由于它会破坏依赖倒置原则。解决方法是,再建立一个 Money
工厂,传递到应用服务和其它任何领域对象中。
在这种状况下,咱们推荐使用第一种选项。让咱们回顾在 Money
值对象中实现它须要作的改动:
class Money { private $amount; private $currency; private $surrogateId; private $surrogateCurrencyIsoCode; public function __construct($amount, Currency $currency) { $this->setAmount($amount); $this->setCurrency($currency); } private function setAmount($amount) { $this->amount = $amount; } private function setCurrency(Currency $currency) { $this->currency = $currency; $this->surrogateCurrencyIsoCode = $currency->isoCode(); } public function currency() { if (null === $this->currency) { $this->currency = new Currency( $this->surrogateCurrencyIsoCode ); } return $this->currency; } public function amount() { return $this->amount; } public function equals(Money $aMoney) { return $this->amount() === $aMoney->amount() && $this->currency()->equals($this->currency()); } }
正如上面看到的,增长了两个属性,第一个是 surrogateId
,它不是咱们领域所使用的,可是基础设施须要它将值对象以实体形式持久化到咱们的数据库中。第二个是 surrogateCurrencyIsoCode
,持有货币的 ISO
代码。使用这些新的属性,真的很是容易将咱们的值对象映射到 doctrine里。
Money
的映射也是直截了当的:
<?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\Domain\Model\Money" table="prices"> <id name="surrogateId" type="integer" column="id"> <generator strategy="AUTO"> </generator> </id> <field name="amount" type="integer" column="amount" /> <field name="surrogateCurrencyIsoCode" type="string" column="currency" /> </entity> </doctrine-mapping>
使用 Doctrine,HistoricalProduct
实体将有如下映射:
<?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\Domain\Model\HistoricalProduct" table="historical_products" repository-class=" Ddd\Infrastructure\Domain\Model\DoctrineHistoricalProductRepository "> <many-to-many field="prices" target-entity="Ddd\Domain\Model\Money"> <cascade> <cascade-all/> </cascade> <join-table name="products_prices"> <join-columns> <join-column name="product_id" referenced-column-name="id" /> </join-columns> <inverse-join-columns> <join-column name="price_id" referenced-column-name="id" unique="true" /> </inverse-join-columns> </join-table> </many-to-many> </entity> </doctrine-mapping>
也能够用定制 ORM 进行相同的处理,这须要用到层叠 INSERTS
和 JION
查询。重要的是,为了避免孤立 Money
值对象,怎样当心地处理值对象的移除工做。
练习为
DbalHisotircalRepository
能够处理持久化方法提出一个解决方案。
数据库实体与联表是同一种方案,仅添加全部者实体管理的值对象。在当前方案中,考虑到 Money
值对象仅被 HistoricalProduct
实体使用,联表将过于复杂。由于一样的结果能够经过一对多的数据库关系来实现。
练习若是使用数据库实体方法,考虑
HistoricalProduct
和Money
之间须要的映射。
若是用相似 Redis
, Mongodb
或者 CouchDB
的 NoSQL
机制会怎样呢?不幸的是,你不能逃避这些问题,为了持久化一个聚合而使用Redis,你须要在设置它的值前,用字符串来序列化。若是你使用 PHP 的 serialize/unserialize 方法,则须要再次面对命名空间或者类名重构问题。若是你选择一条自定义实现道路(JSON, 自定义字符串等等),你将须要再次在 Redis 检索期间重建值对象。
若是咱们的数据库引擎不只容许咱们使用序列化 LOB 策略,同时能够基于它们的值进行搜索,咱们将同时拥有最佳的方法。好消息:如今你能够作到了。由于 PostGreSQL 9.4
版本,已经增长了对 JSONB 的支持。值对象能够用 JSON 序列化来存储,同时能够在 JSON 序列中进行子查询。
MySQL 一样作到了。在 MySQL 5.7.8
版本里,MySQL 支持了一个原生的 JSON 数据类型,可以有效地访问 JSON(JavaScript 对象表示法) 文档中的数据。依照 MySQL 5.7 引用手册
,JSON 数据类型提供了比以字符串形式存储 JSON 格式方法更多的优点:
若是关系数据库为文档和嵌套文档搜索添加了高性能的支持, 而且具备原子性、一致性、隔离性、耐久性 (ACID) 哲学的全部好处, 那么在许多项目中, 它能够减小大量的复杂性。
另外一个有意思的细节是,使用值对象对领域概念进行建模时的安全性好处。考虑一个售卖航班机票应用的上下文。若是你处理国际航空运输协会的机场代码,也称为 IATA 代码,你能够决定使用字符串或者用值对象来建模概念。若是你选择字符串方式,则要考虑到全部你须要检验一个字符串是否为 IATA 代码的地方。若是你可能在某个重要的地方忘记了怎么办?另外一方面,考虑尝试实例化一个 IATA("BCN'; DROP TABLE users;--")
。若是你在构造函数里集中守卫,而后将一个 IATA 值对象传递进去,则避免 SQL 注入或者相似的攻击将更加容易。
若是你想知道领域驱动设计安全性方面的更多细节,你能够关注 Dan Bergh Johnsson
或者读他的博客。
强烈建议使用值对象对领域概念进行建模,如上所述,值对象易于建立,维护和测试。为了在一个领域驱动设计中处理持久化问题,使用 ORM 是必须的。不过,为了持久化值对象而使用 Doctrine,更优的选择是使用 embeddables
。若是你困在 2.4 版本中,有两个选择:直接在你的实体中添加值对象字段并映射好它们(不那么优雅,但更容易),或者扩展你的实体(更优化,但更复杂)。