聚合
- 《领域驱动设计之PHP实现》全书翻译 - DDD入门
- 《领域驱动设计之PHP实现》全书翻译 - 架构风格
- 《领域驱动设计之PHP实现》全书翻译 - 值对象
- 《领域驱动设计之PHP实现》全书翻译 - 实体
- 《领域驱动设计之PHP实现》全书翻译 - 服务
- 《领域驱动设计之PHP实现》全书翻译 - 领域事件
- 《领域驱动设计之PHP实现》全书翻译 - 模块
- 《领域驱动设计之PHP实现》全书翻译 - 聚合
- 《领域驱动设计之PHP实现》全书翻译 - 工厂
- 《领域驱动设计之PHP实现》全书翻译 - 仓储
- 《领域驱动设计之PHP实现》全书翻译 - 应用服务
- 《领域驱动设计之PHP实现》全书翻译 - 集成上下文
- 《用PHP实现六边形架构》
聚合可能是领域驱动设计中最难的构建块了。它们难以理解,并且难以正确设计。但不用担心,我们会帮助你。不过,在进入聚合之前,我们首先需要深入了解一些概念:事务和并发策略。
介绍
如果你使用过电子商务应用,则可能已经遇到过与数据库中数据不一致有关的错误。例如,考虑一个总额为 99.99 美元的购物订单,该订单与订单中每行总金额的总和 89.99 美元不匹配。那这笔额外的 10 美元来自哪里?
或者,考虑一个为影院售卖电影票的网站。有一个剧院有 100 个可用座位,并且在电影成功上映之后,每个人都在网站上等待购票。一旦你开售,一切都会快速进行,最终你会以某种方式卖出了 102 张门票。你可能已经指定只有 100 个座位,但是由于某种原因你超过了该阈值。
你可能有使用像 JIRA 或者 Redmine 之类的追踪系统的经验。考虑一个开发,QA,以及一个产品经理的小组。如果每个人都在计划会议中,围绕用户故事分类和移动它们然后保存,这会发生什么问题?最终的待办项或者
冲突优先级可能会是团队中最后保存它的人。
一般来说,当我们用一种非原子方式处理持久化机制时,会发生数据不一致。一个例子就是,当你发送三个查询请求到数据库,它们中的一些正常一些不正常。数据库的最终状态就会不一致。有时,你希望这三个查询请求全部成功或者失败,那么可以用事务。不过要注意,正如你将在这章看到的,并不是所有非一致性问题都用事务解决。事实上,有时一些数据不一致情况需要锁或者并发策略来解决。这些工具可能会影响你的应用性能,所以请注意权衡。
你可能认为这些数据不一致情况仅仅发生在数据库,但实际不是这样。例如,如果我们使用一个面向文档数据库(诸如 Elasticsearch),两个文档间数据可能会不一致。此外,大多数 NoSQL 持久化存储系统都不支持 ACID 事务。这意味着你不能在单个操作上持久化或更新多个文档。因此,如果我们对 Elasticsearch 作出不同请求,则可能会失败,从而使保存在 Elasticsearch 中的数据不一致。
保证数据一致性是一个挑战。避免将基础设施问题泄漏到领域中是一个更大的挑战。聚合可以帮助你处理这些问题。
关键概念
持久化引擎,特别是数据库,具有一些解决数据不一致的功能: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),则需要禁用任何自动提交标志:
ID 为 1 的用户有多少个愿望?是的,四个。这怎么发生的?如果您使用此 SQL 块并在两个不同的连接中逐行执行它,你将看到愿望(wishes)表在两个执行结束时将如何具有四行。因此,这似乎与事务保护无关。我们如何解决这个问题?如简介中所述,并发控制可能会有所帮助。
对于那些在数据技术方面更高级的开发人员,可以调整隔离级别。但是,我们认为该选项太复杂了,因为可以通过其他方法解决该问题,而且我们并不总是处理数据库。
悲观并发控制
设置锁时,有一个重要的考虑因素:试图更新或查询相同数据的任何其他连接都将挂起,直到锁释放为止。锁很容易产生大多数性能问题。例如,在 MySQL 中,有多种不同的方式设置锁:显示锁表 UN/LOCK tables, 以及读锁 SELECT ... FOR UPDATE 和 SELECT ... LOCK IN SHARE MODE。
正如我们在开始时分享的那样,根据 Clinton 和 Zachary Tong 撰写的《Elasticsearch 权威指南》一书所述:
关系数据库广泛使用此方法,它假定可能发生冲突的更改,因此阻止对资源的访问以防止冲突。一个典型的示例是在读取一行数据之前将其锁定,以确保只有设置了锁的线程才可以更改该行数据。
如你所见,在第一个请求 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% 的实体是由两个或更多实体组成的集合。在设计和实现聚合时与你的同事讨论。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。