《领域驱动设计之PHP实现》- 聚合

聚合

聚合多是领域驱动设计中最难的构建块了。它们难以理解,而且难以正确设计。但不用担忧,咱们会帮助你。不过,在进入聚合以前,咱们首先须要深刻了解一些概念:事务和并发策略。php

介绍

若是你使用过电子商务应用,则可能已经遇到过与数据库中数据不一致有关的错误。例如,考虑一个总额为 99.99 美圆的购物订单,该订单与订单中每行总金额的总和 89.99 美圆不匹配。那这笔额外的 10 美圆来自哪里?html

或者,考虑一个为影院售卖电影票的网站。有一个剧院有 100 个可用座位,而且在电影成功上映以后,每一个人都在网站上等待购票。一旦你开售,一切都会快速进行,最终你会以某种方式卖出了 102 张门票。你可能已经指定只有 100 个座位,可是因为某种缘由你超过了该阈值。web

你可能有使用像 JIRA 或者 Redmine 之类的追踪系统的经验。考虑一个开发,QA,以及一个产品经理的小组。若是每一个人都在计划会议中,围绕用户故事分类和移动它们而后保存,这会发生什么问题?最终的待办项或者
冲突优先级可能会是团队中最后保存它的人。sql

通常来讲,当咱们用一种非原子方式处理持久化机制时,会发生数据不一致。一个例子就是,当你发送三个查询请求到数据库,它们中的一些正常一些不正常。数据库的最终状态就会不一致。有时,你但愿这三个查询请求所有成功或者失败,那么能够用事务。不过要注意,正如你将在这章看到的,并非全部非一致性问题都用事务解决。事实上,有时一些数据不一致状况须要锁或者并发策略来解决。这些工具可能会影响你的应用性能,因此请注意权衡。数据库

你可能认为这些数据不一致状况仅仅发生在数据库,但实际不是这样。例如,若是咱们使用一个面向文档数据库(诸如 Elasticsearch),两个文档间数据可能会不一致。此外,大多数 NoSQL 持久化存储系统都不支持 ACID 事务。这意味着你不能在单个操做上持久化或更新多个文档。所以,若是咱们对 Elasticsearch 做出不一样请求,则可能会失败,从而使保存在 Elasticsearch 中的数据不一致。编程

保证数据一致性是一个挑战。避免将基础设施问题泄漏到领域中是一个更大的挑战。聚合能够帮助你处理这些问题。json

关键概念

持久化引擎,特别是数据库,具备一些解决数据不一致的功能:ACID,约束,引用完整性,锁,并发控制和事务。在使用聚合以前,让咱们回一下这些概念。设计模式

这些概念的大多数在互联网上都是对公开开放的。咱们想感谢在 Oracle,PostgreSQL,以及 Doctrine 的人,用他们的文档作出了使人惊叹的工做。他们细致地定义和解释了这些重要内容,而且不是重复造轮子,咱们整理了一些官方解释以分享给你。数组

ACID (事务管理)

正如在上一节中讨论的,ACID 表明原子性 (atomicity),一致性 (consistency),隔离性 (isolation),以及持久性 (durability)。根据 MySQL 词汇表:浏览器

这些属性都是数据库系统所须要的,而且都与事务概念紧密相关。例如,MySQL InnoDB 引擎的事务功能遵循 ACID 原则。

事务是原子工做单元,它能够被提交和回滚。当一个事务都数据库作出多种改变,要么当事务提交时全部改变都成功,要么当事务回滚时全部改变都失败。

在每一个提交或回滚以后,以及在事务的进程中,数据库老是保持一个一致状态。若是要跨越多个表更新了相关数据,那么查询将看到全部旧值或全部新值,而不是新旧值的混合。

事务进程时,受彼此隔离保护。他们互相之间不能干扰,也不能看到彼此未提交的数据。这种隔离是经过锁定机制实现的。有经验的用户在肯定事务不会相互干扰时,能够调整隔离级别,下降保护措施以提升性能和并发。

事务执行结果是持久的:一旦操做提交成功,不论断电,系统崩溃,竞争条件,或者其它许多非数据库应用程序容易受到的潜在危险,事务所做出的改变都是安全的。持久性一般涉及到写入磁盘存储,并具备必定数量的冗余以防止写做操做期间出现电源故障或软件崩溃。

事务 (Transactions)

依据 PostgreSQL 8.2.23 文档:

事务是全部数据库系统的基础概念。事务的精髓就是,它将多个步骤捆绑成单个“全有或全无”的操做。步骤之间的中间状态对于其余并发事务是不可见的,而且若是发生某些故障致使该事务没法完成,则全部步骤都不会影响数据库。

例如,考虑一个银行数据库,其包含各类客户帐户余额以及分行的总存储余额。假设咱们记录从 Alice 的帐户到 Bob 的帐户之间的 100 美圆转帐。简单来讲,SQL 命令看起来就像这样:

UPDATE accounts SET balance = balance - 100.00 WHERE name = 'Alice';

UPDATE branches SET balance = balance - 100.00 WHERE name = (SELECT branch_name FROM accounts WHERE name ='Alice');

UPDATE accounts SET balance = balance + 100.00 WHERE name = 'Bob';

UPDATE branches SET balance = balance + 100.00 WHERE name = (SELECT branch_name FROM accounts WHERE name ='Bob');

这些命令的详细信息在这里并不重要,重要的是,要完成这个简单的操做,须要涉及到几个独立的更新。咱们银行的管理人员但愿确保全部的这些更新都发生了,或者什么都没发生。系统故障固然不会致使 Bob 收到不是从 Alice 那里扣除的 100 美圆。若是在没有 Bob 信用的状况下借出,Alice 也永远不会是一个满意的客户。咱们须要保证,若是在操做过程当中出现问题,到目前为止执行的任何操做都不会生效。将更新编排到一个事务中为咱们提供了保证。事务是原子的:从其它事务的角度来看,它要么彻底发生,要么根本不发生。

咱们也想确保,一旦事务完成而且由数据库系统确认,该事务将肯定被永久记录,而且以后不久系统发生崩溃也不会丢失。例如,咱们正在记录 Bob 提取的现金,咱们不但愿他帐户的借方在他走出银行大门后的崩溃中消失的任何可能性。事务数据库保证在报告事务完成以前,其所作的全部更新都记录在永久性的存储中(即在磁盘上)。

事务型数据库另外一个重要属性与原子更新的概念密切相关:当多个事务同时运行时,每一个都不该该看到其它事务未完成的改变。例如,若是一项事务正忙于汇总全部分行的余额,那么它将不会包括 Alice 所在分行的这笔借款,也不会包括 Bob 所在分行的这笔贷款,反之亦然。因此事务不只必须是对数据库的永久影响,还必须视它们发生时的可见性而定。迄今为止,一个打开的事务进行更新时对其它事务是不可见的,直到该事务完成为止,随后全部更新同时可见。

在 PostgreSQL中,例如,一个事务由 BEGIN 和 COMMIT 包裹的 SQL 命令组成。因此咱们的银行事务实际看起来像这样:

BEGIN;
UPDATE accounts SET balance = balance - 100.00 WHERE name = 'Alice';
-- etc etc
COMMIT;

若是在事务中途,咱们决定不提交(也许咱们刚刚发现 Alice 的余额为负),则能够发出 ROLLBACK 命令代替 COMMIT,而且到目前为止全部更新都将被取消。

PostgreSQL 实际上将每一个 SQL 语句都看成事务执行。若是你没有声明一个 BEGIN 命令,以后每一个单独的语句都会被 BEGIN 和 COMMIT 包裹(若是成功的话)。一组用 BEGIN 和 COMMIT 包裹的语句有时候被称为事务块。

全部这些都发生在一个事务块内,因此任何一个事务对其它数据库会话都不可见。当你提交事务块时,提交动做做为一个单元对其它会话可见,而回滚动做则不可见。

隔离级别 (Isolation Levels)

依据 MySQL Glossary,事务隔离是:

数据库处理过程的基本功能之一。隔离是缩写 ACID 中的 "I"。隔离级别是一种设置,用于在多个事务同时进行更新和执行查询时微调性能与结果可靠性,一致性和可重复性之间的平衡。

从最高级别的一致性和最低程度的保护,InnoDB 支持的隔离级别是:SERIALIZABLE(序列化),REPEATABLE READ(重复读),READ COMMITTED(读提交),和 READ UNCOMMITTED(读未提交)。

对于 InnoDB 表,大多数用户沿用默认隔离级别 REPEATABLE READ 处理全部操做。资深用户可能会选择 READ COMMITTED 级别,这是由于他们在 OLTP 处理中或在数据仓库操做过程突破了可伸缩性的界限,在这种状况下,微小的不一致不会影响大量数据的合计结果。边缘的级别(SERIALIZABLE 和 READ UNCOMMITTED)将处理行为更改成不多使用的程度。

引用完整性 (Referential Integrity)

依据 MySQL Glossary,引用完整性是:

一种维护数据保持一致格式的技术,是 ACID 哲学的一部分。特别是,不一样表的数据经过使用外键约束来保持一致性,这能够阻止事件的改变,或者自动广播这些改变给全部相关的表。相关机制包括一致性约束(防止重复插入错误值)以及 NOT NULL 约束(防止空值被错误插入)。

锁 (Locking)

依据 MySQL Glossary,锁是:

一种保护事务中正在查看或更改的被其它事务查询和更改的数据的系统(The system of protecting a transaction from seeing or changing data that is being queried or changed by other transactions)。锁定策略必须在数据库操做的可靠性和一致性(ACID 哲学原则)与良好并发所需的性能之间取得平衡。调整锁定策略一般涉及选择隔离级别,并确保全部数据库操做对于该隔离级别而言都是可靠的。

并发 (Concurrency)

依据 MySQL Glossary,并发是:

多个操做在相互不干涉的状况下(在数据库术语中指事务)同时运行的能力。并发还与性能有关,由于理想状况下,使用有效的锁机制来保护多个同时进行的事务时,能够在最小的性能开销的状况下工做。

悲观并发控制 (PCC)

Clinton Gormley 和 Zachary Tong 在 《Elasticsearch 权威指南》一书中讨论过 PCC:

其普遍使用于关系型数据库,这种方法假设更新有可能发生冲突,并所以阻止对资源访问以防止冲突。一个典型的例子就是在读取一行数据以前将其锁定,以确保只有设置锁的线程才能够更新这行数据。
使用 Doctrine

依据 Doctrine 2 ORM Documentation 关于锁的支持部分:

Doctrine 2 原生地提供悲观与乐观锁策略的支持。这样能够对应用程序中的实体所需的锁种类进行很是细粒度的控制。

依据 Doctrine 2 ORM Documentation 关于悲观锁的介绍:

Doctrine 2 在数据库层面支持悲观锁。没有尝试在 Doctrine 内部实现悲观锁定,而是使用了 vendor-speicific 和 ANSI-SQL 命令来获取行级锁。每一个 Doctrine 实体均可以是悲观锁的一部分,使用此功能不须要特殊的元数据。

不过,要使悲观锁起做用,你必须禁用数据库的自动提交模式(Auto-Commit Mode),并使用显式数据划分(Explicit Transaction Demarcation)在悲观锁用例周围启动事务。若是你试图获取悲观锁但事务没有运行,则 Doctrine 2 会发生异常。

Doctrine 2 当前支持两种悲观锁模式:

  • 悲观写模式 Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE,锁定底层数据库行以进行并发读写操做。
  • 悲观读模式 Doctrine\DBAL\LockMode::PESSIMISTIC_READ,锁定其它尝试更新或者写模式行锁的并发请求。

你能够在如下三种场景使用悲观锁:

  • 使用 EntityManager#find($className, $id, \Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE) 或者 EntityManager#find($className, $id, \Doctrine\DBAL\LockMode::PESSIMISTIC_READ)
  • 使用 EntityManager#lock($entity,\Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE) 或者 EntityManager#lock($entity,\Doctrine\DBAL\LockMode::PESSIMISTIC_READ)
  • 使用 Query#setLockMode(\Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE) 或者 Query#setLockMode(\Doctrine\DBAL\LockMode::PESSIMISTIC_READ)

乐观并发控制 (OCC)

依据维基百科:

乐观并发控制(OCC)是一种并发控制方法,应用于事务系统,例如关系型数据库以及软件事务内存。OCC 假设多个事务能够频繁完成而不互相干扰。在运行时,事务使用数据资源而不用获取这些资源的锁。在提交前,
每一个事务都会验证没有其余事务修改了已读取的数据。若是检查显示有冲突的修改,提交中的事务将回滚并能够从新启动。OCC 由 H.T.Kung 首次提出。

OCC 一般用于数据抢占较少的环境。当冲突不多发生时,事务能够完成而无需管理锁。也没必要让事务等待其余事务的锁被清除,从而致使吞吐量比其余并发控制方法更高。可是,若是数据资源抢占频繁,那么重复重启事务的成本会严重损害性能。一般认为其余并发控制方法在这些条件下有更好的性能。可是,基于锁的"悲观"方法也会带来较差的性能,由于即便避免了死锁,锁也会极大地限制有效的并发性。

使用 Elasticsearch

依据 Elasticsearch: The Definitive Guide,当 Elasticsearch 使用 OCC 时:

这种方式假设冲突不可能发生,而且不会阻止尝试操做。可是,若是在读写之间修改了基础数据,则更新会失败。而后由应用程序决定如何解决冲突。例如,它可使用新数据从新尝试更新,也能够将状况报告给用户。

Elasticsearch 是分布式的。当文档建立,更新,或者删除时,新版本的文档必须复制到集群中的其它节点。Elasticsearch 同时是异步和并发的,意思就是这些复制请求是并行发送的,而且它们到达目的地是失序的。Elasticsearch 须要一种方法来确保老版本的文档永远不会覆盖更新的版本。

每一个文档都有一个 _version 数字,不管什么时候文档更新,它就会自动增加。Elasticsearch 使用这个 _version 数字来确保按正确的顺序应用更改。若是一个更老的版本先于新版本到达,它能够直接被忽略。

咱们能够利用 _version 数字来确保应用程序产生的更改冲突不会致使数据丢失。咱们经过指定想要更改的文档版本数字来作到。若是版本不是当前的,则咱们的请求失败。

让咱们新建一个 blog post:

PUT /website/blog/1/_create
{
    "title": "My first blog entry",
    "text": "Just trying this out..."
}

回复的 body 告诉咱们新建立的文档有一个 _version 值 1。如今想象咱们须要编辑这个文档:咱们加载数据到一个 web 表单,作出更改,而且保存新版本。

首先咱们从新查询该文档:

GET /website/blog/1

回复的 body 包含相同的 _version 值 1:

{
    "index": "website",
    "type": "blog",
    "id": "1",
    "version": 1,
    "found": true,
    "_source": {
    "title": "My first blog entry",
    "text": "Just trying this out..."
    }
}

如今,当尝试经过从新查询出来的文档保存咱们的更改,咱们要指定这个将被更改的版本。咱们但愿这个更新仅仅在当前文档的 _version 是 1 时才成功:

PUT /website/blog/1?version=1
{
    "title": "My first blog entry",
    "text": "Starting to get the hang of this..."
}

请求成功后,回复的 body 告诉咱们 _version 已经增长到 2:

{
    "index": "website",
    "type": "blog",
    "id": "1",
    "version": 2,
    "created": false
}

可是,若是咱们运行同一个索引请求,仍然指定 version=1,Elasticsearch 会回复一个 409 Conflict HTTP 响应代码,body 以下:

{
    "error": {
        "root_cause": [{
            "type": "version_conflict_engine_exception",
            "reason":
                "[blog][1]: version conflict,current[2],provided[1]",
            "index": "website",
            "shard": "3"
        }],
        "type": "version_conflict_engine_exception" ,
        "reason": "[blog][1]:version conflict,current [2],provided[1]",
        "index": "website",
        "shard": "3"
    },
    "status": 409
}

它告诉咱们当前文档的 _version 值在 Elasticsearch 中是 2,但咱们指定更新的版本是 1。

如今咱们要作什么取决于咱们的应用程序要求。咱们能够告诉用户,其余人已经对文档进行了更改,并在再次尝试保存更改以前先进行检查。另外,就像前面例子的 stock_count widget 同样,咱们能够检索最新的文档并尝试从新应用更改。

全部更新或者删除一个文档的 API 接受一个版本参数,它容许你将 OCC 仅应用于有意义的代码部分。

使用Doctrine

依据 Doctrine 2 ORM Documentation 关于 乐观锁的部分:

数据库事务在单个请求期间的并发控制是没问题的。可是,一个数据库事务不该该跨越请求,即所谓的用户思考时间(user think time)。所以一个跨越多个请求的长时间运行的“业务事务”须要涉及多个数据库事务中。所以,在这样长时间运行的业务事务中,仅数据库事务就没法再控制并发。并发控制成为应用程序自己的部分责任。
Doctrine 经过 version 字段集成了自动乐观锁的支持。这种方法,在长时间运行的业务事务期间,须要被保护的实体在面对并发的改动时会获取一个 version 字段,该字段能够是简单的数字 (映射类型:integer)或时间戳(映射类型:datetime)。若是长时间运行后仍保留对此类实体的更改,则会将该实体的版本与数据库中的版本进行比较,若是不匹配,则会引起 OptimisticLockException,代表该实体已被其余人修改。

你指定一个版本字段在下面的实体中,在这个例子中咱们使用了整数:

class User
{
    // ...
    /** @Version @Column(type="integer") */
    private $version;
    // ...
}

当在 EntityManager#flush 期间发生版本冲突时,会抛出一个 OptimisticLockException 异常,而且活动的事务会回滚(或者标记为回滚)。这个异常能够被捕获并处理。对一个 OptimisticLockException 可能的回应就是呈现这个冲突给用户或者在一个新的事务中刷新或重载对象,而后重试事务。

因为 PHP 促进了 share-nothing 架构,在最坏的状况下,显示更新表单与实际修改实体之间的时间,可能与您的应用程序会话超时同样长。若是实体在那个时间范围内发生变化,而你想在检索实体时直接知道您将遇到乐观锁异常,则能够在一个请求期间验证明体的版本,也能够调用 EntityManager#find()

use Doctrine\DBAL\LockMode;
use Doctrine\ORM\OptimisticLockException;

$theEntityId = 1;
$expectedVersion = 184;
try {
    $entity = $em->find(
        'User',
        $theEntityId,
        LockMode::OPTIMISTIC,
        $expectedVersion
    );
// do the work
    $em->flush();
} catch (OptimisticLockException $e) {
    echo
        'Sorry, someone has already changed this entity.' .
        'Please apply the changes again!';
}

或者你可使用 EntityManager#lock() 找出:

use DoctrineDBALLockMode;
use DoctrineORMOptimisticLockException;

$theEntityId = 1;
$expectedVersion = 184;
$entity = $em->find('User', $theEntityId);
try {
// assert version em−>lock(entity, LockMode::OPTIMISTIC,
    $expectedVersion);
} catch (OptimisticLockException $e) {
    echo
        'Sorry, someone has already changed this entity.' .
        'Please apply the changes again!';
}

依据 Doctrine 2 ORM Documentation 的重要实现要点:

只要你对比错误的版本,你就会很容易获得乐观锁工做流错误。假设 Alice 和 Bob 编辑一个 blog post:

  • Alice 读取标题为 "Foo" 的博客,乐观锁版本 1 (GET Request)
  • Bob 读取标题为 "Foo" 的博客,乐观锁版本 1 (GET Request)
  • Bob 更新标题为 "Bar",升级乐观锁版本为 2 (POST Request of a Form)
  • Alice 更新标题为 "Baz",... (POST Request of a Form)

如今,该博客的最后一个场景状态须要在 Alice 修改标题以前从数据库中再次读取。这里你确定想检查博客是否仍然是版本 1(它并不在这个场景里)。

正确使用乐观锁,你必须增长版本(version)做为一个额外的字段(或者放到 SESSION 中更安全)。不然你没法验证这个版本是不是当 Alice 执行 GET 请求时从数据库中原始读取的这个版本。若是发生这种状况,你可能会丢失一些更改,你须要乐观锁来解决这些。

看这个示例代码,表单(GET Request):

$post = $em->find('BlogPost', 123456);
echo '<input type="hidden" name="id" value="' .
$post->getId() . '"/>';
echo '<input type="hidden" name="version" value="' .
$post->getCurrentVersion() . '" />';

以及修改标题动做(POST Request):

$postId = (int) $_GET['id'];
$postVersion = (int) $_GET['version'];
$post = $em->find(
'BlogPost',
$postId,
DoctrineDBALLockMode::OPTIMISTIC,
$postVersion
);

嗯,这里有太多信息须要吸取。不过,没必要担忧你是否彻底知道全部事情。你使用聚合领域驱动设计越多,须要在设计应用程序时考虑事务问题的状况就会遇到的更多。

总而言之,若是你想保持数据一致性,就使用事务。可是,当心过分使用事务或者锁策略,由于这些会下降应用程序速度或使它不可用。若是你想有一个真正快的应用,乐观锁能够帮助你。最后但并不是最重要的一点,有些数据能够是最终一致性。这意味着咱们能够容许数据在一些特殊的窗口时间不一致。在这段时间,一些不一致性是可接受的。最终,一个异步进程会执行最后的任务来移除这种不一致性。

什么是聚合

聚合是持有其它实体和值对象的实体,它有助于保持数据一致性。来自 Vaughn Vernon 《实现领域驱动设计》一书中:

聚合精心制做的将实体和值对象聚在一块儿的一致性边界。

另外一本了不得的你应该买来读的书就是 Pramod J.Sadalage 和 Martin Fowler 的《NoSQL精髓:多元语言持久性的新世界简要指南》,该书说道:

在领域驱动设计里,聚合是咱们但愿将之视为一个单元总体对待的相关对象的集合。尤为,它是数据维护以及一致性管理的单元。一般,咱们喜欢用原子操做来更新聚合,并根据聚合与咱们的数据存储进行通讯。

Martin Fowler 怎么说

来自 http://martinfowler.com/bliki...

聚合是一种领域驱动设计模式。一个 DDD 聚合是一个能够视为单元总体的领域对象集合。一个例子就是订单和它的物品项,这些是分离的对象,但将订单(和它的物品项)视为单个聚合是有用的。

一个聚合使用它的组件对象中的一个做为为聚合根。任何外部引用都只能经过聚合根来获取。所以聚合根能够保持聚合的完整性。

聚合是你请求加载或保存整个聚合的数据存储传输的基本元素。事务不能跨越聚合边界。

DDD 聚合有时候与集合类混淆(列表,映射等等)。DDD 聚合是领域概念(订单,门诊,播放列表),而集合是通用的。一个聚合一般包含多种集合和一些简单字段。聚合是一个很常见的术语,而且在各类不一样的上下文(例如:UML)里使用,在这种状况下,它与 DDD 聚合所指的概念不一样。

维基百科 怎么说

来自 https://en.wikipedia.org/wiki...

聚合:一种用根实体(也称为聚合根)绑定的对象集合。聚合根经过禁止外部对象持有其成员的引用来确保聚合中所作的更改的一致性。

例子:当你驾驶一辆汽车,你不须要操心移动轮子前进,用火花和燃油使引擎工做等等。你只是开车而已。在这个上下文里,汽车是一些其余对象的集合,并充当全部其余系统的聚合根。

为何使用聚合

狂热的读者可能会想知道这与聚合和聚合设计有什么关系。实际上,这是一个很好的问题。这有直接关系,所以让咱们对其进行探讨。关系模型使用表来存储数据。这些表由行组成,其中每行一般表明应用程序所关注概念的一个实例。此外,每行均可以指向同一数据库其余表上的其余行,而且能够经过使用引用完整性来保持此关系之间的一致性。这个模型至关好;不过,它缺乏一个很是基本的词:对象。

确实,当咱们讨论关系醋地,咱们是在讨论表,行与行之间的关系,当咱们讨论面向对象模型时,咱们主要是在讨论对象的组成。所以,每次咱们从关系数据库中获取数据(一些行)时,咱们都会运行一个翻译过程,它负责构建咱们可使用的内存表示形式。相反的方向也同样。每当咱们在数据库中存储一个对象时,咱们都应该运行另外一个转换过程,以将该对象转换为给一组行或表。这种从对象到行或表的转换着你能够对数据库运行不一样的查询。这样,在不使用任何特定工具(例如事务)的状况下,不可能保证数据被一致性持久化。这种问题就是所谓的阻抗失配

阻抗失配

对象-关系阻抗失配是一组概念和技术难题,当以面向对象的编程语言或风格编写的程序在使用关系数据库管理系统(RDBMS)时,尤为是在对象或类时,一般会遇到这些问题,以直接的方式映射到数据库表或关系模式。

来自 维基百科

阻抗失配不是一个容易解决的问题,所以咱们强烈建议你自行解决。这将是一项艰巨的任务,这根本不值得付出努力。幸运的是,这里有一些库负责这个翻译过程。它们一般称为对象关系映射器(Object-Relational Mappers),这些咱们在前面的章节中已经讨论过,它们的主要关注点是简化从关系模型到面向对象模型的转换过程,反之亦然。

这个问题也影响 NoSQL 持久化引擎,而不只仅是数据库。大多数 NoSQL 引擎使用文档(document),例如 JSON, XML, 二进制文件等。而后持久化。与 RDBMS 数据库不一样的是,若是主实体(例如订单 Order)具备其余相关实体(例如 OrderLines),则能够更轻松地设计一个包含全部信息的 JSON 文档。经过这种方法,只须要向 NoSQL 引擎发送一个请求,而不须要事务。

可是,若是你使用 NoSQL 或 RDBMS 来获取和持久化实体,则须要一个或多个查询。为了确保数据一致性,这些查询或请求须要做为单个操做执行。这能够保证数据的一致性。

一致性意味着什么?它意味着全部持久化到数据库中的数据必须符合全部业务规则,即所说的不变性。一个不变性的业务实例就是 GitHub 上,一个用户能够有无限个公开的仓库但没有私有仓库。可是,若是用户每月付 12 美圆,那么他们就能够有上限 10 个私有仓库。

关系型数据库提供三个主要工具来帮助咱们处理数据一致性:引用完整性:外键,非空检查等等;事务:将多个查询做为单个操做。事务的问题与你代码仓库的分支及合并同样。持有分支会有性能代价(内存,CPU,存储,索引等等)。若是太多人(并发地)修改相同的数据,冲突就会发生同时提交事务就会失败;:锁定行或者表。围绕相同的表或行的其余查询必须等等锁移除。锁对你的应用程序有反作用。

假设咱们有一个电子商务应用程序,咱们能够扩大到其余国家和地区,而且假设发行良好,销售也增加了。一个明显的反作用就是数据库须要处理额外增加的负担。如前所述,这有两种扩展方法:向上或向外。

向上是指提高咱们的硬件设施(例如:更好的 CPU,更多内存,更好的硬盘)。向外是指增长更多机器,这些机器将做为一个集群来处理一些特殊的做业。在这种状况下,咱们能够有一个数据库集群。

可是关系型数据库并非设计为水平扩展的,由于咱们不能配置成保存一些数据集到给定的机器,另外一些数据集到另外一台。关系型数据库很容易向上(垂直)扩展,但关系模型不能水平扩展。

在 NoSQL 的世界里,数据一致性有一点困难:事务和引用完整性不被广泛支持,而锁是支持的但通常不鼓励使用。

NoSQL 数据库不会受到阻抗失配太大的影响。他们与聚合设计彻底匹配,由于它容许咱们轻松地自动保存和检索单个单元。例如,当使用像 Redis 这样的键-值储存时,一个聚合能够被序列化而后用一个指定的键(key)保存。在 Elasticsearch 这样的面向文档存储器,一个聚合会被序列化到一个 JSON 并持久化为一个文档。正如以前提到的,问题是来自于多个文档须要立马更新时。

由于这些缘由,当用单个表示(一个文档,由于不须要多个查询)持久化任何对象时,是很容易将这些单个单元分布到不一样的机器(所谓的节点),而这些机器能够组成一个 NoSQL 数据库集群。这里的共识就是,这样的数据库是容易分布式,也就是说这种类型的数据库是容易水平扩展的。

一点历史

在 21 世纪初,像 Amazon 和 Google 这样的公司迅速发展。为了巩固基的增加,他们使用集群技术:不只有更好的服务器,并且还依赖于更多的服务器协同工做。

在这样的一个场景下,决定怎样储存你的数据便很关键。若是你有一个实体并将其信息分布在集群的多个节点上的多个服务器里,则控制事务所需的工做量很大。获取一个实体也同样。所以,若是你能够以持久化在集群节点中的方式设计实体,那么事情就变得容易不少。这就是聚合设计如此重要的缘由之一。

若是你想了解更多关于领域驱动设计以外的聚合设计的历史,能够看看《NoSQL精髓:多元语言持久化的新世界简要指南》

聚合剖析

聚合是能够持有其余实体和值对象的实体。父实体即为根实体。

没有子实体或者值对象的单个实体自身也是一个聚合。这就是为何在一些书籍中,聚合这个术语用来代替实体术语。当咱们在这里这样使用它们,实体和聚合就表示同一个事物。

聚合的主要目标就是保持领域模型的一致性。聚合使大多数业务规则集中化。聚合在你的持久化机制里自动持久化。不管多少个子实体和值对象在根实体中,它们都会做为单个单元自动持久化。让咱们看一个例子。

考虑一个电子商务应用程序,网站等等。用户能够下单,其中有多行来定义所购买的产品,价格,数量和每行总金额。订单也有一个总金额,这是全部行金额的总和。

若是你更新一行的数量而不是总的订单数量会发生什么?数据不一致。为了修正这个问题,对聚合中任何实体和值对象的全部更改都是经过聚合根来执行的。大多数 PHP 开发者更喜欢构建对象而且用客户端代码来处理它们的关系,而不是就业务逻辑放到实体里:

$order = ...
$orderLine = new OrderLine(
'Domain-Driven Design in PHP', 24.99
);
$order->addOrderLine($orderLine);

正如上面的代码所示,新手或者中级开发者通常会首先构建子对象而后用一个 setter 方法与其父对象关联。考虑以下方式:

$order = ...
$orderLine = $order->addOrderLine(
'Domain-Driven Design in PHP', 24.99
);

这些方法颇有意思,由于他们遵循了两种软件设计原则:命令-不要询问(Tell, Don't Ask)原则和迪米特原则(Law of Demeter)。

按照 Martin Fowler 阐述:

命令-不要询问原则帮助人们记住,面向对象是将数据与对该数据进行操做的功能绑定在一块儿。它提醒咱们,与其向对象询问数据并对该数据执行操做,不如告诉对象该怎么作。这鼓励将行为转移到与数据一块儿使用的对象当中。

根据维基百科解释:

迪米特法则(LoD)或者说最少知识原则,是开发软件的一种设计指导,尤为是面向对象程序。在它的普通形式里,LoD 是一种特殊的松耦合例子,并能够简要总结为下面的每一个方式:

1 每一个单元应该仅保有其余单元的最少知识:即与当前单元相关联的“最近的”单元。
2 每一个单元应该仅与它的友元通讯,而不是其它单元。
3 仅与你当即要通讯友元进行通讯。

基本概念是,根据“信息隐藏”的原理,给定的对象应尽量少地考虑其余任何事物(包括其子组件)的结构和属性。

让咱们继续用订单例子。你已经学过了怎样经过实体根来运行操做。如今让咱们更新订单中的一行产品的数量。这个操做会增长数量,这一行的总数量,以及订单总数量。很好!如今是时候用这些更改来持久化订单了。

若是你使用 MySQL,你能够想象咱们须要两个 UPDATE 语句:一个是给订单表,一个是给 order_line 表。若是这两个查询不在同一个事务里会发生什么?

让咱们假设更新订单行的 UPDATE 语句工做正确。可是,因为网络链接缘由更新订单总数量的 UPDATE 失败了。在这个场景下,你会在你的领域模型里获得一个数据不一致的结果。事务能够帮你保持一致性。

若是你使用 Elasticsearch,这种场景有点不同。你能够用一个 JSON 文档映射这个订单,它内部持有订单行。所以只须要单个请求。可是,若是你用一个 JSON 映射订单用另外一个 JSON 映射订单行,你就会陷入麻烦,由于 Elasticsearch 不支持事务!

一个聚合用它本身的仓储(Repositories, 第 10 章)来获取和持久化。若是两个实体不属于同一个聚合,那么它们都有本身的仓储。若是一个真正不变的业务存在而且两个实体属于同一个聚合,你就只有一个仓储。它就是根实体的仓储。

聚合的缺点是什么?问题就是当处理事务时可能的性能问题和操做错误。咱们会很快深刻这些问题。

聚合设计原则

当设计一个聚合时,为了得到最大的收益和最小化反作用,有一些要遵循的原则和考虑。不要担忧太多,若是你如今还不知道什么。做为一个例子,咱们会展现一个小应用程序,在该应用程序中咱们将会引用向您介绍的规则。

基于业务真正不变条件设计聚合

首先,什么是不变性?不变性是在代码执行期间必须为真且一致的规则。例如,栈(stack)是一种 LIFO(后进先出)的数据结构,咱们能够将元素压入和弹出。咱们也能够询问栈中有多少元素;这就是所谓的堆栈大小。考虑一个不用任何特定的 PHP 数组函数(例如 array_pop)的纯 PHP 实现:

class Stack
{
    private $data;

    public function __construct()
    {
        $this->data = [];
    }

    public function push($value)
    {
        $this->data[] = $value;
    }

    public function size()
    {
        $size = 0;
        for ($i = 0; $i < count($this->data); $i++) {
            $size++;
        }
        return $size;
    }

    /**
     * @return mixed
     */
    public function pop()
    {
        $topIndex = $this->size() - 1;
        $top = $this->data[$topIndex];
        unset($this->data[$topIndex]);
        return $top;
    }
}

考虑上面的 size 方法的实现。它离完美还差很远,但它能工做。可是,由于用上面的代码实现,它是 CPU 密集且高昂的调用。幸运的是,这有一个选择来优化这个方法,经过引入一个私有属性来跟踪数组内部元素的数量:

class Stack
{
    private $data;
    private $size;

    public function __construct()
    {
        $this->data = [];
        $this->size = 0;
    }

    public function push($value)
    {
        $this->data[] = $value;
        $this->size++;
    }

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

    /**
     * @return mixed
     */
    public function pop()
    {
        $topIndex = $this->size--;
        $top = $this->data[$topIndex];
        unset($this->data[$topIndex]);
        return $top;
    }
}

经过这些修改,size 方法如今更快,所以它仅仅返回 size 字段的值。为了达到这个目标,咱们引入一个新的整型属性叫作 size。当一个新的栈(Stack)建立时,size 的值为0,而且没有栈内没有任何元素。当咱们用 push 方法添加一个新的元素到栈中,同时会增长 size 字段的值。类似的,用 pop 方法从栈内弹出元素时咱们会减小 size 的值。

经过增长和减小 size 的值,咱们保证了栈了元素真实数量的一致性。size 值的在调用栈全部公开方法以前和以后都是一致的。结果是,size 的值老是等于栈内元素的数量。这就是不变性!咱们能够这样写: $this->size === count($this->data)

真正的业务不变性是一个业务规则,它必须老是为真而且在一个聚合里是事务一致性。由于事务一致性,咱们更新聚合时必须为一个原子操做。全部包含在聚合内的数据必须是原子持久化。若是不遵循这个规则,咱们可能会持久一个不合法的聚合表示。

根据 Vaughn Vernon 所述:

一个设计正确的聚合能够用任何业务所需的方式进行修改,其不变性在单个事务中彻底一致。在全部状况下,通过适当设计的限界上下文在每一个事务中仅修改一个聚合实例。并且,若是不该用事务分析,咱们就没法正确地推断聚合设计。

正如介绍中讨论的,在一个电子商务应用里,订单的总数量必须匹配每一个订单行的数量总和。这就是不变性,或业务规则。咱们必须在同一个事务里持久化 Order 和 OrderLines 到数据库里。它约束咱们把 Order 和 OrderLine 做为同一聚合的一部分。而 Order 是聚合根。由于 Order 是根,全部与 OrderLines 有关的操做必须经过 Order 执行。所以再也不须要在 Order 外部实例化 OrderLine 对象,而后使用 setter 方法将 OrderLines 添加到 Order。相反,咱们必须在订单上使用工厂方法(Factory Method)。

经过这种方法,咱们在聚合上有单点入口来执行操做:订单(Order)。它意味着这里没有机会调用一个方法来破坏规则。每次你经过 Order 添加或者更新 OrderLine,Order 的总数量会在内部从新计算。使全部操做必须通过根,有助于咱们保持聚合一致性。在这种方式中,它很难打破任何不变性。

小聚合 Vs. 大聚合

对于咱们经历过的大多数网站和项目,几乎 95% 的聚合由单个根实体和一些值对象组成。在同一个聚合里没有其它所必需的。所以在大多数案例中,是没有真正不变业务规则来保持一致性的。

须要当心处理 has-a/has-many 关系,即有没有必要将两个实体变成一个聚合,其中一个做为根。关系,正如咱们所见,能够经过引用实体标识处理。

正如介绍里所解释,一个聚合就是一个事务边界。边界越小,在提交多个并发事务时发生冲突的机会就越小。在设计聚合时,你应该努力把设计得越小。若是项目里没有真正不变性,这意味着全部单个实体自身就是聚合。这就很棒,由于这是获取最好的性能的最好场景。为何?由于锁问题和事务失败问题最小化了。

若是你决定设计大聚合,保持数据一致性将很是容易但这可能不切实际。当大聚合应用运行在生产中,当大量用户执行操做时就会开始遇到问题。当使用乐观锁时,主要的问题就是事务失败。只要使用锁,就会有变慢和超时的问题。

让咱们考虑一些基本例子。当使用乐观并发时,想象整个领域是版本化的,而且任何实体上的每一个操做为整个领域建立一个新版本。在这种场景下,若是两个用户在两个绝不相干的实体上执行不一样的操做,第二个请求就会遇到因为不一样版本引发的事务失败。换句话说,当使用悲观并发时,想象一个咱们在每一个操做上加锁的场景。这会阻塞全部用户直到锁释放。这意味着许多请求会处于等待中,而且在某个时间点,可能会超时。二者均可以保持数据一致性,但应用不能被超过一个用户来使用。

最后但并不是最不重要的一点,当设计大聚合时,因为它们可能持有实体集合,考虑加载如此大的集合到内存中的性能意义就很重要。甚至使用像 Doctrine 这样有懒加载(在须要时加载数据)的 ORM 来加载集合,若是集合过大,就不能放到内存里。

经过标识引用其它实体

当两个实体不造成一个聚合但它们相关联,最好的选择就是经过标识来相互引用。标识(Identity)已经在第 4 章,实体中阐述。

考虑一个 User 和它们的 Orders,而且假设咱们没有发现真正不变性。 User 和 Order 不会是同一聚合的一部分。若是你想知道哪一个 User 拥有一个指定的 Order,你可能须要询问 Order 的 UserId 是什么。UserId 是一个持有 User 标识的值对象。咱们能够经过它的仓储(UserRepository)得到整个 User。这些代码在应用服务(Application Service)里有呈现。

正如通常的解释,每一个聚合都有本身的仓储。若是你查询一个指定的聚合而且你须要查询另外一个相关联的聚合,你会在应用服务或者领域服务里操做。应用服务依赖华联仓储来查询所需聚合。

从一个聚合跳到另外一个就是所谓的领域遍历或者领域导航。使用 ORM,很容易经过在实体间映射全部关系来作到。可是,这样真的很危险,你能够轻松地在特定功能中运行无数查询。一般,你不该该这样作。不要映射全部实体之间的关系,仅仅是由于你能。相反,仅当两个实体造成一个聚合时,才映射 ORM 中一个聚合内的实体之间的关系。若是不是这种状况,则使用仓储来获取引用的聚合。

每一个事务和请求只更新一个聚合

考虑以下场景:你作出一个请求,它进入你的控制器(controller),而且它意图更新两个不一样的聚合。每一个聚合都在那个聚合内保持数据一致性。然而,若是第一个聚合上的更新请求忽然中止(服务器重启,从新加载,内存溢出等等)而且第二个没有更新会发生什么后果?这是否是数据一致性问题?多是,让咱们考虑一些解决方案。

来自于 Vaughn Vernon 的 《实现领域驱动设计》:

在一个设计正确的限界上下文里,全部状况下,每一个事务仅修改一个聚合实例。并且,若是不该用事务分析,咱们就没法推断聚合的设计。限制每一个事务修改一个聚合实例可能听起来过于严格。可是,这是经验法则,在大多数状况下应该是目标。它点破了使用聚合的根本缘由。

若是在单个请求里,你须要更新两个聚合,也许这两个聚合就应该是一个,而且须要在同一事务更新二者。若是不是,你能够在一个事务里包裹整个请求,但咱们不推荐这种方式做为主要选择,由于涉及到性能问题和事务错误。

若是聚合上更新都不须要包裹到事务中,这意味着咱们能够假定更新之间有一些延迟。在这种状况下,须要使用另外一个领域驱动设计方法,就是领域事件。当这样作时,第一个聚合更新会触发一个领域事件。这个事件会持久化到同一事务做为一个聚合更新事件,而后发布到消息队列。以后,一个订阅者会从队列取出并执行第二个聚合更新。这样的方式就是最终一致性(Eventual Consistency),减少了事务边界大小,提升了性能,而且下降了事务错误。

应用服务示例:用户(User)与愿望(Wish)

如今你知道了聚合设计的一些基本规则。

学习聚合最好的方式就是看代码。所以让咱们考虑一个 web 应用场景,用户能够在发生某种事情(相似于遗嘱)时实现愿望。例如,我但愿发送一封电子邮件给个人妻子说明如何处理个人 GitHub 帐号,若是我在一场可怕的意外中丧生的话,或者我想发一封邮件告诉她我有多爱她。确认我是否还活着的方法就是回复平台发送给的邮件。(若是你想了解关于这个应用更多信息,你能够访问咱们的 GitHub 帐号)因此咱们有了用户以及他们的愿望。让咱们仅考虑一个用例:“做为一个用户(User),我想许愿(Wish)”。咱们能够怎样对此建模?当设计聚合时使用好的实践,让咱们试着设计一些小聚合。在这种状况下,这意味着使用两个不一样聚合,User 和 Wish。对于它们之间的关系,咱们应该使用一个标识,例如 UserId。

非不变性,两个聚合

咱们会在后面的章节讨论应用服务,但如今,让咱们检查许愿(making a wish)的不一样方法。第一种,特别是对于新手,可能与此相似:

class MakeWishService
{
    private $wishRepository;

    public function __construct(WishRepository $wishRepository)
    {
        $this->wishRepository = $wishRepository;
    }

    public function execute(MakeWishRequest $request)
    {
        $userId = $request->userId();
        $address = $request->address();
        $content = $request->content();
        $wish = new Wish(
            $this->wishRepository->nextIdentity(),
            new UserId($userId),
            $address,
            $content
        );
        $this->wishRepository->add($wish);
    }
}

这段代码可能会有最好的性能。你几乎能够看到这个场景后的 INSERT 语句;这种用例的最小操做数是 1,这和奶好,经过当前的实现,咱们能够根据业务需求建立任意数量的愿意,这也很好。

可是,这可能有个潜在的问题:在这个领域内咱们能够为一个不存在的用户建立愿望。无论咱们持久化聚合使用的是何种技术,这都是个问题。即便在内存中实现,也能够建立没有对应用户的愿望。

这是一个破坏性的业务逻辑。固然,这能够在数据库中用一个外键来修正,从 wish (user_id) 到 user (id),可是若是秩不使用数据库外键会发生什么?以及是 NoSQL 数据库时会发生什么?例如 Redis 或者 Elasticsearch?

若是咱们想解决这个问题,使相同代码在不一样的基础设施中工做正确,咱们须要检查用户是否存在。在同一个应用服务里这彷佛是最简单的方法:

class MakeWishService
{
// ...
    public function execute(MakeWishRequest $request)
    {
        $userId = $request->userId();
        $address = $request->address();
        $content = $request->content();
        $user = $this->userRepository->ofId(new UserId($userId));
        if (null === $user) {
            throw new UserDoesNotExistException();
        }
        $wish = new Wish(
            $this->wishRepository->nextIdentity(),
            $user->id(),
            $address,
            $content
        );
        $this->wishRepository->add($wish);
    }
}

上面代码能够工做,但在应用服务里执行检查会有一个问题:这个检查在委托链中很高。若是不是该应用服务的任何其余代码段(例如领域服务或其余实体)想建立一个无用户的愿望,则能够执行此操做。看下面的代码:

// Somewhere in a Domain Service or Entity
$nonExistingUserId = new UserId('non-existing-user-id');
$wish = new Wish(
    $this->wishRepository->nextIdentity(),
    $nonExistingUserId,
    $address,
    $content
);

若是你已经读了第 9 章,工厂,那么你已经有了解决方案。工厂(Factories)帮助咱们保持业务不变性,而这正是咱们此处所需的。

这里有一个明显的不变性,咱们不容许一个不存在的用户许愿。让咱们看看工厂是如何帮助咱们的:

abstract class WishService
{
    protected $userRepository;
    protected $wishRepository;

    public function __construct(
        UserRepository $userRepository,
        WishRepository $wishRepository
    )
    {
        $this->userRepository = $userRepository;
        $this->wishRepository = $wishRepository;
    }

    protected function findUserOrFail($userId)
    {
        $user = $this->userRepository->ofId(new UserId($userId));
        if (null === $user) {
            throw new UserDoesNotExistException();
        }
        return $user;
    }

    protected function findWishOrFail($wishId)
    {
        $wish = $this->wishRepository->ofId(new WishId($wishId));
        if (!$wish) {
            throw new WishDoesNotExistException();
        }
        return $wish;
    }

    protected function checkIfUserOwnsWish(User $user, Wish $wish)
    {
        if (!$wish->userId()->equals($user->id())) {
            throw new \InvalidArgumentException(
                'User is not authorized to update this wish'
            );
        }
    }
}

class MakeWishService extends WishService
{
    public function execute(MakeWishRequest $request)
    {
        $userId = $request->userId();
        $address = $request->address();
        $content = $request->content();
        $user = $this->findUserOrFail($userId);
        $wish = $user->makeWish(
            $this->wishRepository->nextIdentity(),
            $address,
            $content
        );
        $this->wishRepository->add($wish);
    }
}

正如你所见,用户许愿(Users make Wishes),而且代码也是这样。 makeWish 是一个用来构建愿望的工厂方法(Factory Method)。这个方法返回一个新的愿望,来自于咱们自身 UserId 所构建:

class User
{
// ...
    /**
     * @return Wish
     */
    public function makeWish(WishId $wishId, $address, $content)
    {
        return new Wish(
            $wishId,
            $this->id(),
            $address,
            $content
        );
    }
// ...
}

为何咱们要返回愿望,而不是像对 Doctrine 那样将新的愿望添加到内部集合中?总而言之,在这种状况下,User 和 Wish 不属于一个聚合,由于没有真正的业务不变性能够保护。用户能够根据须要添加和删除任意数量的愿望。若是须要,能够在数据库中以不一样的事务方式独立更新愿望及其用户。

遵循前面阐述的有关聚合设计的原则,咱们能够针对小聚合,这就是这里的结果。每一个实体都有其本身的仓储。愿望(Wish)使用标识(在这种状况下为 UserId)引用拥有它的用户。能够经过 WishRepository 中的 finder 获取它们,而且能够轻松分页,而不会出现任何性能问题:

interface WishRepository
{
    /**
     * @param WishId $wishId
     *
     * @return Wish
     */
    public function ofId(WishId $wishId);

    /**
     * @param UserId $userId
     *
     * @return Wish[]
     */
    public function ofUserId(UserId $userId);

    /**
     * @param Wish $wish
     */
    public function add(Wish $wish);

    /**
     * @param Wish $wish
     */
    public function remove(Wish $wish);

    /**
     * @return WishId
     */
    public function nextIdentity();
}

这种方法有趣的一面是,咱们没必要在咱们最喜欢的 ORM 中映射用户(User)和愿望(Wish)之间的关系。由于咱们使用 UserId 从愿望中引用了用户,因此咱们只须要仓储。让咱们考虑如何使用 Doctrine 映射此类实体:

Lw\Domain\Model\User\User:
  type: entity
  id:
    userId:
      column: id
      type: UserId
  table: user
  repositoryClass: Lw\Infrastructure\Domain\Model\User\DoctrineUser\Repository
  fields:
  email:
    type: string
  password:
    type: string
Lw\Domain\Model\Wish\Wish:
  type: entity
  table: wish
  repositoryClass: Lw\Infrastructure\Domain\Model\Wish\DoctrineWish\Repository
  id:
    wishId:
      column: id
      type: WishId
  fields:
    address:
      type: string
    content:
      type: text
    userId:
      type: UserId
    column: user_id

没有关系定义。在许愿以后,让咱们写一些更新一个存在的愿望的代码:

class UpdateWishService extends WishService
{
    public function execute(UpdateWishRequest $request)
    {
        $userId = $request->userId();
        $wishId = $request->wishId();
        $email = $request->email();
        $content = $request->content();
        $user = $this->findUserOrFail($userId);
        $wish = $this->findWishOrFail($wishId);
        $this->checkIfUserOwnsWish($user, $wish);
        $wish->changeContent($content);
        $wish->changeAddress($email);
    }
}

由于 User 和 Wish 并不造成一个聚合,为了更新 Wish,咱们首先须要从 WishRepository 中查询它。一些额外的检查就是只有本身能更新愿望。正如你可能看到的,$wish 是已经咱们中存在的实体,所以不须要再用仓储把它加回来。可是,为了使更改持久,咱们的 ORM 必须在更新和冲刷任何到数据库中残余的改变以后显示这些信息。不用担忧;咱们看一下第 11 章,应用。为了完成这个例子,让咱们看看怎样删除一个愿望:

class RemoveWishService extends WishService
{
    public function execute(RemoveWishRequest $request)
    {
        $userId = $request->userId();
        $wishId = $request->wishId();
        $user = $this->findUserOrFail($userId);
        $wish = $this->findWishOrFail($wishId);
        $this->checkIfUserOwnsWish($user, $wish);
        $this->wishRepository->remove($wish);
    }
}

如你所见,你能够重构代码的某些部分,例如构造函数和全部权检查,以便在这两个应用服务中重用。随时考虑你将如何作。最后但并不是最不重要的一点是,咱们如何得到指定用户的全部愿望:

class ViewWishesService extends WishService
{
    /**
     * @return Wish[]
     */
    public function execute(ViewWishesRequest $request)
    {
        $userId = $request->userId();
        $wishId = $request->wishId();
        $user = $this->findUserOrFail($userId);
        $wish = $this->findWishOrFail($wishId);
        $this->checkIfUserOwnsWish($user, $wish);
        return $this->wishRepository->ofUserId($user->id());
    }
}

这很简单。可是,在相应的章节中,咱们将更深刻地介绍如何从应用服务呈现和返回信息。就目前而言,返回愿望集合就能够了。

让咱们总结一下这种非聚合方法。咱们找不到任何真正的业务不变性能够将用户(User)和愿望(Wish)视为一个聚合,这就是为何它们每一个都是聚合的缘由。用户具备本身的 UserRepository,愿望也有本身的 WishRepository。每一个愿望都拥有对全部者的 UserId 引用。即便这样,咱们也不须要事务。就性能和可伸缩性而言,这是最佳方案。然而,生活并不老是那么美好。考虑真正的业务不变性会发生什么?

每一个用户不超过三个愿望

咱们的应用程序取得了巨大的成功,如今是时候从中得到一些收益了。咱们但愿新用户最多拥有三个愿望。做为用户,若是你但愿有更多的愿望,未来须要为高级帐户付费。让咱们看看怎样更改代码以符合关于最大但愿数量的新业务规则(在这种状况下,不考虑高级用户)。

考虑下面的代码。除了上一节中有关将逻辑放入咱们的实体中所解释的内容以外,如下代码还能够工做:

class MakeWishService
{
// ...
    public function execute(MakeWishRequest $request)
    {
        $userId = $request->userId();
        $address = $request->email();
        $content = $request->content();
        $count = $this->wishRepository->numberOfWishesByUserId(
            new UserId($userId)
        );
        if ($count >= 3) {
            throw new MaxNumberOfWishesExceededException();
        }
        $wish = new Wish(
            $this->wishRepository->nextIdentity(),
            new UserId($userId),
            $address,
            $content
        );
        $this->wishRepository->add($wish);
    }
}

看起来能够。那很容易(可能太容易了)。在这里,咱们遇到了不一样的问题。首先是应用服务必须协调,但不该包含业务逻辑。相反,更好的方法是将最多三个愿望的检查放到用户中,在这里咱们能够更好地控制用户和愿望之间的关系。可是,对于此处展现的方法,该代码看起来彷佛有效。

第二个问题是它在竞争条件下不起做用。暂时忘了领域驱动设计。在通信繁忙的状况下,这代码有什么问题?思考一分钟。是否有可能违反用户规则从而拥有三个以上的愿望?为何进行一些压力测试后你的 QA 会如此开心?

你的 QA 会尝试两次建立一个愿望功能,最终致使一个有两个愿望的用户。没错你的 QA 正在进行功能测试。想象一下,他们在浏览器中打开了两个选项卡,填写了每一个选项卡的每一个表彰,并高潮同时提交了两个按钮。忽然,在再次请求以后,用户最终在数据库中获得了四个愿望。错了!发生了什么?

以调试的角度考虑两个不一样的请求同时获取 if ($count > 3) { 这一行。由于用户只有两个愿望,因此两个请求都将返回 false。所以,两个请求都将建立愿望(Wish),而且两个请求都将其添加到数据库中。结果一个用户拥有四个愿望。太矛盾了!

咱们知道你在想什么。这里由于咱们没有将全部东西都放到事务中。好吧,假设ID 为 1 的用户已经两个愿望,所以还有一个愿望。建立两个不一样愿望的两个 HTTP 请求同时到达。咱们为每一个请求启动一个数据库事务(咱们将在第 11 章,应用中介绍如何处理事务和请求)。考虑一下以前的 PHP 代码将对咱们的数据库运行的全部查询。请记住,若是使用任何数据库可视化工具(Visual Database Tool),则须要禁用任何自动提交标志:
图1

图2

ID 为 1 的用户有多少个愿望?是的,四个。这怎么发生的?若是您使用此 SQL 块并在两个不一样的链接中逐行执行它,你将看到愿望(wishes)表在两个执行结束时将如何具备四行。所以,这彷佛与事务保护无关。咱们如何解决这个问题?如简介中所述,并发控制可能会有所帮助。

对于那些在数据技术方面更高级的开发人员,能够调整隔离级别。可是,咱们认为该选项太复杂了,由于能够经过其余方法解决该问题,并且咱们并不老是处理数据库。

悲观并发控制

设置锁时,有一个重要的考虑因素:试图更新或查询相同数据的任何其余链接都将挂起,直到锁释放为止。锁很容易产生大多数性能问题。例如,在 MySQL 中,有多种不一样的方式设置锁:显示锁表 UN/LOCK tables, 以及读锁 SELECT ... FOR UPDATE 和 SELECT ... LOCK IN SHARE MODE。

正如咱们在开始时分享的那样,根据 Clinton 和 Zachary Tong 撰写的《Elasticsearch 权威指南》一书所述:

关系数据库普遍使用此方法,它假定可能发生冲突的更改,所以阻止对资源的访问以防止冲突。一个典型的示例是在读取一行数据以前将其锁定,以确保只有设置了锁的线程才能够更改该行数据。

图3

如你所见,在第一个请求 COMMIT 以后,第二个请求的愿望数为 3。这是一致的,可是第二个请求正在等待,而锁没有释放。这意味着在有大量请求的环境中,它可能会产生性能问题。若是第一个请求花费太多时间来释放锁,则第二个请求可能因为超时而失败:

ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

上面的代码看起来是一个有效的选项,但咱们须要注意可能的性能问题。还有其余选择吗?

乐观并发控制

还有另外一种方法:根本不使用锁。考虑将版本属性添加到咱们的聚合中。当咱们持久化它们时,持久化引擎将 1 设置为被持久化的聚合的版本。稍后,咱们检索相同的聚合并对其进行一些更改。咱们持久化聚合。持久化引擎检查咱们拥有的版本是否与当前持久化的版本 1 相同。持久化引擎用新的状态持久化聚合并更新版本为 2。若是多个请求查询相同的聚合,请作一些改变,而后持久化它,第一个请求会起做用,第二个则会出错。最后一个请求只是更改了一个过期的版本,所以持久化引擎将引起错误。可是,第二个请求能够尝试再次检查聚合,合并新状态,尝试执行更改,而后持久化聚合。

根据 《Elasticsearch 权威指南》所述:

该方法假定冲突不太可能发生,而且不会阻止尝试操做。可是,若是在读写之间修改了基础数据,则更新将失败。而后由应用程序决定如何解决冲突。例如,它可使用新数据从新尝试更新,也能够将状况报告给用户。

这个方法以前有说起,可是有必要再啰嗦一下。若是你尝试将乐观并发应用于这种状况,在这种状况下咱们正在检查应用服务中最大的愿望(Wish)值,那么它将没法工做。为何?咱们正在生成一个新的愿望,因此两个请求将建立两个不一样的愿望。咱们如何使其工做?好吧,咱们须要一个对象来集中添加愿望。咱们能够在该对象上应用乐观并发技巧,所以看起来咱们须要一个能够保存愿望的父对象。有任何想法吗?

总而言之,在检查并发控制以后,有一个悲观的选择正在起做用,可是对性能影响存在一些担心。有一个乐观的选择,可是咱们须要找到一个父对象。让咱们考虑最终的 MakeWishService,但须要一些修改:

class WishAggregateService
{
    protected $userRepository;

    public function __construct(UserRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    protected function findUserOrFail($userId)
    {
        $user = $this->userRepository->ofId(new UserId($userId));
        if (null === $user) {
            throw new UserDoesNotExistException();
        }
        return $user;
    }
}

class MakeWishService extends WishAggregateService
{
    public function execute(MakeWishRequest $request)
    {
        $userId = $request->userId();
        $address = $request->address();
        $content = $request->content();
        $user = $this->findUserOrFail($userId);
        $user->makeWish($address, $content);
// Uncomment if your ORM can not flush
// the changes at the end of the request
// $this->userRepository->add($user);
    }
}

咱们不传递 WishId,由于它应该是用户内部的东西。makeWish 也不返回愿望;它在内部存储新的愿望。执行完应用服务以后,咱们的 ORM 会将在 $user 上执行的更改刷新到数据库。根据咱们的 ORM 的好坏,咱们可能须要使用仓储再次显式添加用户实体。须要对 User 类进行哪些更改?首先,应该有一个能够容纳用户内部全部愿望的集合:

class User
{
// ...
    /**
     * @var ArrayCollection
     */
    protected $wishes;

    public function __construct(UserId $userId, $email, $password)
    {
// ...
        $this->wishes = new ArrayCollection();
// ...
    }
// ...
}

愿望(Wish)属性必须在 User 构造函数中初始化。咱们可使用普通的 PHP 数组,可是咱们选择使用 ArrayCollection。ArrayCollection 是一个 PHP 数组,具备 Doctrine Common Library 提供的一些其余功能,能够与 ORM 分开使用。咱们知道大家中的某些人可能认为这多是边界泄漏,而且这里没有任何基础设施的引用,但咱们确实认为并不是如此。实际上,相同的代码可使用纯 PHP 数组工做。让咱们看看 makeWish 实现如何受到影响:

class User
{
// ...
    /**
     * @return void
     */
    public
    function makeWish($address, $content)
    {
        if (count($this->wishes) >= 3) {
            throw new MaxNumberOfWishesExceededException();
        }
        $this->wishes[] = new Wish(
            new WishId,
            $this->id(),
            $address,
            $content
        );
    }
// ...
}

到目前为止还挺好。如今,该回顾一下其他操做的实现方式了。

追求最终一致性
业务彷佛不但愿用户拥有三个以上愿望。这将使咱们把 User 视为内部包含 Wish 的根聚合。这会影响咱们的设计,性能,可伸缩性问题等等。考虑一下,若是咱们只容许用户添加想要的愿望,而超出了限制,将会发生什么。咱们能够检查谁超出了该限制,并让他们知道他们须要购买高级帐户。容许用户超过限制并随后经过电话警告他们将是一个很是不错的商业策略。这甚至可能使你的团队中的开发人员避免将 User 和 Wish 设计为同一聚合(以 User 为根)的一部分。你已经看到了不设计单个聚合的好处:最高性能。
class UpdateWishService extends WishAggregateService
{
    public function execute(UpdateWishRequest $request)
    {
        $userId = $request->userId();
        $wishId = $request->wishId();
        $email = $request->email();
        $content = $request->content();
        $user = $this->findUserOrFail($userId);
        $user->updateWish(new WishId($wishId), $email, $content);
    }
}

因为 User 和 Wish 如今是一个聚合,所以再也不使用 WishRepository 查询要更新的愿望。咱们使用 UserRepository 获取用户。更新愿望的操做是经过根实体(在这种状况下为用户)执行的。WishId 是必需的,以便标识咱们要更新的愿望:

class User
{
// ...
    public function updateWish(WishId $wishId, $email, $content)
    {
        foreach ($this->wishes as $wish) {
            if ($wish->id()->equals($wishId)) {
                $wish->changeContent($content);
                $wish->changeAddress($address);
                break;
            }
        }
    }
}

根据你框架的功能,执行此任务可能便宜也可能不会便宜。遍历全部的愿望可能意味着进行过多的查询,甚至更糟的是,获取太多的行,这将对内存产生巨大影响。事实上,这是大聚合的主要问题之一。所以,让咱们考虑如何删除愿望(Wish):

class RemoveWishService extends WishAggregateService
{
    public function execute(RemoveWishRequest $request)
    {
        $userId = $request->userId();
        $wishId = $request->wishId();
        $user = $this->findUserOrFail($userId);
        $user->removeWish($wishId);
    }
}

正如前面看到的,WishRepository 再也不须要。咱们使用 User 仓储来获取它,并执行删除愿望(Wish)的操做。为了删除一个 Wish。咱们须要从内部集合中删除它。一种选择是遍历全部元素,并将其与相同的 WishId 匹配:

class User
{
// ...
    public function removeWish(WishId $wishId)
    {
        foreach ($this->wishes as $k => $wish) {
            if ($wish->id()->equals($wishId)) {
                unset($this->wishes[$k]);
                break;
            }
        }
    }
// ...
}

那多是最不了解 ORM 的代码。可是,在场景背后,Doctrine 正在获取全部 Wish 并遍历全部。仅获取不是 ORM 不可知的所需实体的一种更具体的方法以下:Doctrine 映射也必须更新,以使全部魔术工做都按预期进行。虽然 Wish 映射保持不变,但 User 映射具备新的 oneToMany 单向关系:

Lw\Domain\Model\Wish\Wish:
  type: entity
  table: lw_wish
  repositoryClass: Lw\Infrastructure\Domain\Model\Wish\DoctrineWish\Repository
  id:
    wishId:
      column: id
      type: WishId
  fields:
    address:
      type: string
    content:
      type: text
    userId:
      type: UserId
    column: user_id

Lw\Domain\Model\User\User:
  type: entity
  id:
    userId:
      column: id
      type: UserId
  table: user
  repositoryClass: Lw\Infrastructure\Domain\Model\User\DoctrineUser\Repository
  fields:
    email:
      type: string
    password:
      type: string
  manyToMany:
    wishes:
      orphanRemoval: true
      cascade: ["all"]
      targetEntity: Lw\Domain\Model\Wish\Wish
    joinTable:
      name: user_wishes
      joinColumns:
        user_id:
          referencedColumnName: id
      inverseJoinColumns:
        wish_id:
          referencedColumnName: id
          unique: true

在上述代码中,有两个重要的配置:orphanRemoval 和 cascade。依据 Doctrine 2 ORM Documentation 关于 orphan removal 和 transitive persistence / cascade operations:

若是类型 A 实体包含对私有实体 B 的引用,则若是从 A 到 B 的引用被删除,则实体 B 也应被删除,由于再也不使用它。 OrphanRemoval 与一对一,一对多以及多对多关系一块儿使用。当使用 orphanRemoval=true 选项时,Doctrine 会假设实体是私有的,不会被其余实体重用。若是你忽略这一假设,则即便你将孤立的实体分配给另外一个实体,你的实体也会被 Doctrine 删除。持久化,删除,分离,刷新和合并单个实体可能变得很是麻烦,尤为是在涉及到高度交织的对象图时,所以,Doctrine 2 经过级联这些操做提供了传递传递持久化的机制。与另外一个实体或实体集合的每一个关联均可以配置为自动级联某些操做。默认状况下,没有级联操做。

有关更多信息,请仔细阅读有关使用 Doctrine 2 ORM 2 Documentation 关于使用关联的部分。

最后,让咱们看看怎样从 User 获取 Wish 的:

class ViewWishesService extends WishService
{
    /**
     * @return Wish[]
     */
    public function execute(ViewWishesRequest $request)
    {
        return $this
            ->findUserOrFail($request->userId())
            ->wishes();
    }
}

正如前面提到的,尤为是在使用聚合的状况下,返回 Wish 集合不是最佳解决方案。你永远不要返回领域实体,由于这阻止应用服务以外的代码(例如控制器或 UI)意外修改它们。使用聚合,这更有意义。不属于根的实体(属于集合但不属于根的实体)应对外部的其余人显示为私有。

咱们将在第 11 章,应用 对此进行更深刻的研究。总结一下,如今你有不一样的选择:

  • 应用服务返回访问聚合信息的 DTO 构建块。
  • 应用服务返回聚合返回的 DTO。
  • 应用服务使用一个写入聚合的输出依赖,这样的输出依赖将处理 DTO 或其余格式的转换。
渲染 Wish 的数量做为练习,请考虑咱们要渲染用户在其帐户页面上的 Wish 的数量。考虑到 User 和 Wish 不会造成聚合,你将如何实现这一目标?若是 User 和 Wish 确实造成了聚合,你将如何实施?考虑最终一致性如何为你的解决方案提供帮助。

事务

在任何示例中,咱们都没有展现 beginTransaction,commit 或者 rollback。这是由于事务是在应用服务级别处理的。如今不用担忧;你将在第 11 章,应用中找到有关此内容的更多详细信息。

小结

聚合都是关于持久化和事务的。事实上,你必须在不考虑如何持久化的状况下设计聚合。设计合适的聚合的基本规则是:使它们变小,找到真正的业务不变量,使用 Domain Event 推进最终一致性,按标识引用其余聚合,以及每一个请求只修改一个聚合。审查两个实体造成单个聚合时代码会变成怎样。使用工厂来丰富你的实体。最后,放松一下。在咱们看到的大多数 PHP 应用程序中,只有 5% 的实体是由两个或更多实体组成的集合。在设计和实现聚合时与你的同事讨论。

相关文章
相关标签/搜索