25

值对象

  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实现六边形架构》

通过使用 self 关键字,我们不会将值对象作为领域驱动设计的基本构建块,在代码中它们用于对通用语言概念进行建模。值对象不仅仅是领域中衡量,量化或者描述事物的东西。值对象可以被视为小而简单的对象 - 例如金钱或者日期范围 - 它们的相等性不是通过其标识,而是基于其持有的内容体现的。

例如,产品价格可以用值对象建模。在这种情况下,它不代表一个东西,而是一个值,可以用于衡量产品的价值。这些对象的内存占用是微不足道的,无法确定(通过它们的组成部分计算)且开销极小。因此,即使表示相同的值 ,创建新实例也优于引用重用,然后再根据两个实例的字段可比性来检查是否相等。

定义

Ward Cunningham 定义值对象如下:

可衡量或者可描述的事物。值对象的例子如数值,日期,货币和字符串。通常,它们是一些使用相当广泛的小对象。它们的标识 是基于他们的状态而不是他们的对象标识。这样,你可以拥有同一概念值对象的多个副本。每个5美元的钞票都有自身的标识(多亏了它的序列号),但现金经济依赖于每5美元与其它5美元有相同的价值。

Martin Fowler 定义值对象如下:

一个小对象,例如货币或者日期范围对象。它们的关键属性是遵从值语义而不是引用语义。你通常可以说它们概念的等同不是基于它们的标识,而是两个值对象它们所有字段是否相等。尽管所有字段相等,但如果子集是唯一的,你也不需要比较所有字段 - 例如币种代码对于货币对象来说足以说明它的相等性。通常的准则的值对象应该是完全不可变的。如果你想改变一个值对象,应该用一个新的对象来替换它而不允许用值对象本身来更新值 - 值对象的更新会引起混淆问题。

值对象的例子有数值,文本字符,日期,时间,一个人的全名(由姓,中间名,名字,爵位等组成),货币,颜色,电话号码,邮箱地址。

值对象 VS. 实体

考虑下面来自维基百科的例子,以便更好的理解值对象与实体的不同点:

  • 值对象:当人们兑换美金,他们一般不区分每张唯一的钞票;他们只关心美金的字面值。在这个上下文中,美金就是值对象。然而,美联储可能需要关心每张唯一钞票;在这个上下文中,每张钞票就是实体。
  • 实体:大部分航空公司都区分航班上每个座位的唯一性。在这个上下里每个座位就是一个实体。然而,西南航空,易捷航空和瑞安航空并不区分每个座位,所有座位都是相同的,在这里,一个座位实际上就是值对象。

货币与金钱的例子

货币和金钱值对象可能是解释值对象最有用的例子了,多亏了金钱模式。这种设计模式提供了一种模型化问题的方法,来避免浮点舍入问题,这又过来又允许执行确定性运算。

在现实世界中,货币用米和码的描述距离单位相同的方式来描述货币单位。每种货币用三个大写字母的 ISO 代码来表示:

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;
    }
}

现在你知道了值对象的正式定义了,让我们更深入地了解它们提供的最大功能吧。

特征

当用代码模型化一个通用语言概念时,你应该总是倾向于在实体上使用值对象。值对象问题更容易创建,测试,使用和管理。

  • 它检验,量化或者描述领域中的一个事物。
  • 它可以保持不变
  • 它通过将相关属性整合成一个统一单元来建模一个概念整体
  • 它可以值相等来与其它值对象比较
  • 它是完全可替换的,当测量或描述改变时
  • 它给合作者提供无副作用的行为

检验,量化或者描述

正如之前讨论的,一个值对象不应该被视你领域中的一个事物。作为值,它可以衡量,量化,描述领域内的概念。

在我们的例子里,货币对象描述了金钱是什么类型。金钱对象衡量或者量化了一个给定的货币单位。

永久性

这是需要要掌握的最重要的方面之一。值对象不应该在他们的生命周内改变。由于这种永久性,值对象易于推导和测试以及没有不受欢迎/意外的副作用。因此,值对象应该由他们的构造器创建。为了生成一个值对象,你通常通过构造函数传递必要原生类型或者其它值对象。

值对象总是处于有效状态;这就是为什么我们在一个原子步骤中创建它们。具有多个 gettersetter 方法的空构造函数将创建责任转移到客户端,从而导致了贫血模型,这被认为是一种反模式。

还需要指出的是,我们不建议在值对象中保留对实体的引用。实体是可变的,并且保留对它们的引用可能导致在值对象中发生一些不可取的副作用。

在具有方法重载的语言(例如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, nameprice (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 嵌入值对象

如果我们要用一个实现嵌入值模式的定制 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 一个机会。

除此之外,在本实例中,你需要创建一个继承于 ProductDbalProduct 实体,为了在不使用 new 运算符的情况下,使用一个静态工厂方法从数据库中重建实体。

用 Doctrine >= 2.5.* 嵌入值对象

最新的 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.* 嵌入值对象

如果你还停留在 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 和 定制 ORM

如果增加值对象的搜索能力并不重要,则可以考虑另一种模式:序列化 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 是几乎不可能的。

这种查询问题只能用嵌套值的方式解决。不过,序列化重构问题可以用特定的处理序列化过程的库来解决。

用 JMS Serializer 改进序列化

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 序列化 LOB

Doctrine 中,有许多不同的方法来序列化对象,以便最终持久化。

Doctrine 对象映射类型

Doctrine 支持序列化 LOB 模式。这里有大量预定义的映射类型,以便将实体属性匹配到数据库栏位甚至表中。其中的一种映射类型就是对象类型,它可以将 SQL CLOB 映射到 PHP 的 serializer()unserialize()上。

按照 Doctrince DBAL 2, Documentation中的描述:

  • 值对象类型基于 PHP 序列化映射和转换对象数据。如果你需要存储对象数据的准确表述,可以考虑使用这种类型,因为它使用序列来呈现对象的一个准确的副本,而对象以字符串保存在数据库中。从数据库中检索出来的值总是通过反序列化转换成 PHP 的对象类型,或者 null 值,如果没有数据的话。
  • 值对象类型总是被映射成数据库提供的文本类型,因为没有其它方式可以把 PHP 对象表述原生地存进数据库。此外,此类型需要 SQL 栏位注释提示,以便从数据库中逆向出来。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 -- 描述了实体字段与数据库表述间的自定义的转换,以便持久化前者。
正如 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;

最后,需要一个关联 productsprices 的表:

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 联表返回集合

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 联表返回集合

也可以用定制 ORM 进行相同的处理,这需要用到层叠 INSERTSJION 查询。重要的是,为了不孤立 Money 值对象,怎样小心地处理值对象的移除工作。

练习

DbalHisotircalRepository 可以处理持久化方法提出一个解决方案。

用数据库实体返回集合

数据库实体与联表是同一种方案,仅添加所有者实体管理的值对象。在当前方案中,考虑到 Money 值对象仅被 HistoricalProduct 实体使用,联表将过于复杂。因为同样的结果可以通过一对多的数据库关系来实现。

练习

如果使用数据库实体方法,考虑 HistoricalProductMoney 之间需要的映射。

非关系型数据库

如果用类似 Redis, Mongodb 或者 CouchDBNoSQL 机制会怎样呢?不幸的是,你不能逃避这些问题,为了持久化一个聚合而使用Redis,你需要在设置它的值前,用字符串来序列化。如果你使用 PHP 的 serialize/unserialize 方法,则需要再次面对命名空间或者类名重构问题。如果你选择一条自定义实现道路(JSON, 自定义字符串等等),你将需要再次在 Redis 检索期间重建值对象。

PostgreSQL JSONB 和 MySQL JSON Type

如果我们的数据库引擎不仅允许我们使用序列化 LOB 策略,同时可以基于它们的值进行搜索,我们将同时拥有最佳的方法。好消息:现在你可以做到了。因为 PostGreSQL 9.4 版本,已经增加了对 JSONB 的支持。值对象可以用 JSON 序列化来存储,同时可以在 JSON 序列中进行子查询。

MySQL 同样做到了。在 MySQL 5.7.8 版本里,MySQL 支持了一个原生的 JSON 数据类型,能够有效地访问 JSON(JavaScript 对象表示法) 文档中的数据。依照 MySQL 5.7 引用手册,JSON 数据类型提供了比以字符串形式存储 JSON 格式方法更多的优势:

  • 在 JSON 列中,含有 JSON 文档自动验证器。不正确的 JSON 文档会产生一个错误。
  • 优化存储格式。JSON 列中存储的 JSON 文档被转换为一种内部格式,允许对其进行快速读取访问。当服务器以后必须读这些以二进制格式存储的 JSON 值时,将不再需要从文本形式里分析。二进制格式的结构是为了使服务器能够通过键或数组索引直接查找子对象或嵌套的值, 而不读取文档中前后的所有值。

如果关系数据库为文档和嵌套文档搜索添加了高性能的支持, 并且具有原子性、一致性、隔离性、耐久性 (ACID) 哲学的所有好处, 那么在许多项目中, 它可以减少大量的复杂性。

安全性

另一个有意思的细节是,使用值对象对领域概念进行建模时的安全性好处。考虑一个售卖航班机票应用的上下文。如果你处理国际航空运输协会的机场代码,也称为 IATA 代码,你可以决定使用字符串或者用值对象来建模概念。如果你选择字符串方式,则要考虑到所有你需要检验一个字符串是否为 IATA 代码的地方。如果你可能在某个重要的地方忘记了怎么办?另一方面,考虑尝试实例化一个 IATA("BCN'; DROP TABLE users;--")。如果你在构造函数里集中守卫,然后将一个 IATA 值对象传递进去,则避免 SQL 注入或者类似的攻击将更加容易。

如果你想知道领域驱动设计安全性方面的更多细节,你可以关注 Dan Bergh Johnsson 或者读他的博客。

小结

强烈建议使用值对象对领域概念进行建模,如上所述,值对象易于创建,维护和测试。为了在一个领域驱动设计中处理持久化问题,使用 ORM 是必须的。不过,为了持久化值对象而使用 Doctrine,更优的选择是使用 embeddables。如果你困在 2.4 版本中,有两个选择:直接在你的实体中添加值对象字段并映射好它们(不那么优雅,但更容易),或者扩展你的实体(更优化,但更复杂)。


捷叔叔
1.1k 声望1.8k 粉丝