深入实践DDD:以DSL驱动复杂软件开发
上QQ阅读APP看书,第一时间看更新

1.3.6 聚合分析是“拆分”的基础

分布式系统设计原则的第一条:不要“分布”。确切地说,如果没有足够的理由就不要做分布式设计,开发和运维分布式系统往往需要更高的成本。

为什么要分布?常见的理由是为了解决系统的水平扩展问题。这往往意味着我们需要给系统中的软件元素(构造块)划分出边界,这样我们就可以更有针对性地对每个构造块进行独立的优化。此外,我们可能还需要对同一类型的数据(比如说订单数据)进行横向切分(也就是所谓的Sharding),让每个分布式系统的结点只负责处理其中的一部分数据。

那么,到底什么东西能分、什么东西不能分,它们之间的边界如何表述呢?这就是聚合这个概念发挥作用的地方。有些东西互相关联、密不可分,那么它们可能应该建模成一个“聚合”。如果从一开始,在软件设计中就使用了“聚合”这个概念的话,实际上表明我们已经纵向地给其中的软件元素划分了边界,也给数据的横向切分(Sharding)提供了标准。

比如说,如果我们通过聚合分析已经把OrderHeader和OrderItem划分到一个聚合内,将它们与其他聚合(比如Product、Payment等)区分开来,我们可能会考虑开发一个叫作Order Service的可独立部署的微服务。如果要做订单——OrderHeader以及它关联的OrderItem——数据的横向切分,我们只要根据聚合根的ID(可能我们把这个ID叫作OrderId)来进行SQL数据库的分库分表操作就行了。必要的时候,我们可以直接把某些聚合的数据存储到NoSQL数据库中。

遗憾的是,有一些DDD的框架和工具,仅仅满足于使用“一个聚合只有一个实体”的例子来展示它们的“强大”。我们认为,一个DDD工具如果不支持聚合概念,那它就是一个“有残疾”的DDD工具。虽然据说按照经验估计,使用DDD方法建模,70%的聚合都只有一个实体(即只有聚合根这个实体),但是这并不等于聚合分析不重要。领域中真正复杂的业务逻辑,可能大部分都集中在另外30%的聚合以及需要处理不同聚合之间的数据一致性的领域服务内,而聚合分析尤其能为数据一致性的处理提供帮助。

如果一个DDD工具不能真正地支持聚合概念,那么使用它的产品人员、系统分析师、开发人员在分析阶段很可能也就不会认真去做聚合分析,因为“即便分析了,大家也未必真的按照这个来做”。

如果一个DDD框架不允许聚合内存在多个“实体”,那么实际上就没有了聚合这个概念(当然,若只剩下大部分开发人员都耳熟能详的“实体”这一概念,确实很“方便”大家理解),如此一来我们采用强一致性模型的边界在哪里?如果没有明确的标准,“哪几个实体的数据需要保证强一致”这样的决定应该如何做出?

可能有人觉得可以做二选一的设计决策:

·要么是当要改变状态时,都先开始一个数据库事务,操作完若干实体/表后提交数据库事务,让数据库事务来保证强一致性。这其实是我们常见的传统做法。

·要么是认为每个实体就构成了一个聚合,当要改变系统状态的时候,都自己写代码来实现“最终一致性”。如果系统的最终用户(业务部门)能接受所有实体的状态只要最终一致就可以,那么这么做至少理论上是可行的。

但是现实中这两种(极端)做法在开发大型应用,特别是互联网应用时,往往都不太现实。

前者除了很容易导致代码层面的紧密耦合以外,往往还会造成严重的性能和扩展性问题,为了解决这些问题可能需要大规模地重构代码甚至要重写整个系统。

可能有人会想:那就不要那么极端,我们可以不只是“聚合内强一致”,也“适当”地多用数据库事务来做“聚合外强一致性”。特别是在软件开发的初始阶段,软件往往还是个单体应用,数据库可能还是一个单点,一时半会儿也不会分库分表,可以让开发人员“便宜行事”,自行控制数据库事务来保证在必要范围内的“强一致性”。

现实情况是,“便宜行事”的开发人员很有可能“做过头”,因为根本没有清晰的“边界”。极端的情况就是臭名昭著的“Open Session In View”,也就是服务端收到客户端请求时立即开启一个数据库事务,在将请求的处理结果返回客户端前的那一刻才提交数据库事务,这样所有对系统状态的查询和修改操作都会放到同一个数据库事务中完成——可以说是非常“方便”了。但是,这样做的结果往往是灾难级的系统性能表现,以及剪不断理还乱的面条式代码。

后一种做法,可能有人觉得不是问题。他们可能会说:“我们就是应该坚持原则,一个实体一个聚合,所有实体之间都最终一致。业务部门要的是结果,他们才不管什么强一致还是最终一致,银行转账这么重要的事情,大家还不是普遍接受‘最终一致’?所以,经过精心设计的业务逻辑都是可以绕过大多数对强一致性的‘伪需求’的。”

这里所谓接受“最终一致”,意味着要接受“有时候数据就是不一致的”。比如,我们大多数时候都能接受出现这样的情况:我们执行了一个转账操作,源账户减少了500块钱,目标账户没有马上增加500块钱。

首先,我们认为实体之间的强一致性需求一定是存在的,我们不能寄希望于“最终一致性”能满足所有的业务需要。就算退一万步,业务部门确认“最终一致性”真的能满足他们的所有需求,这种做法也会极大地增加软件开发的工作量与成本。如果大量处理“最终一致性”的代码都需要程序员来“手动”编码实现的话,即使有一些框架和工具能提供一定的帮助,程序员也会不堪重负,产出的可能是Bug满满的低质量代码。

所以软件开发团队几乎总是会混合使用强一致性和最终一致性模型。如果开发团队想就这个问题——什么时候选择强一致性模型、什么时候选择最终一致性模型——确定标准,那么即使不使用“聚合”这个术语,迟早也会发明一个相似的概念。

总之,聚合分析的意义就是让开发人员一开始就在强一致性和最终一致性的选择上进行足够的思考和权衡,而不是没有想清楚就匆忙进入编码阶段。如果先做了聚合分析,即使因为项目工期的要求,没有完全按照“聚合内强一致,聚合外最终一致”的原则来编写代码,至少开发人员也清楚地知道自己在哪些实现代码上是做了妥协的。也许有人会认为既然要妥协,那就没有必要做预先的分析和设计。但是笔者认为,就现状而言,对软件进行预先设计的价值已经被太多人低估了。

顺便说一下,做聚合分析还有其他好处。一个好处是可以根据聚合分析的结果自动生成UI层的代码——最少是可以生成脚手架代码。想一想订单头和订单行项同属一个订单聚合的情况,它们在与分属不同聚合的两个实体各自独立、毫不相干的情况比起来,显然应该是不一样的吧?这个问题后面可以进一步探讨。此外,聚合分析有助于自动生成CQRS的“读模型”(后面会讨论CQRS模式,这里的读模型可以先理解为数据库的视图),因为我们知道聚合内的实体关系密切,需要一起查询的可能性很高。比如,因为订单聚合的存在,我们可以自动生成联接(Join)了订单头和订单行项两个表(实体)的视图(读模型)。