领域驱动设计融合_1_领域驱动设计的战略考量
前言
Github:https://github.com/HealerJean
一、限界上下文与微服务
1、进程内的通信边界
若限界上下文之间为进程内的通信方式,意味着它们的代码模型运行在同一个进程中,通过对象实例化的方式即可调用另一个限界上下文内部的对象。限界上下文的代码模型存在两种级别的设计方式
命名空间级别 | 工程模块级别 | |
---|---|---|
说明 | 通过命名空间进行界定,所有的限界上下文位于同一个工程模块( module ),编译后生成一个 JAR 包 |
在命名空间上是逻辑分离的,不同限界上下文属于同一个项目的不同模块,编译后生成各自的 JAR 包 |
区别 | 解耦不彻底 | 解耦更加彻底,可以更好地应对变化对限界上下文的影响。例如,当限界上下文 A 的业务场景发生变更时,我们可以只修改和重编译限界上下文 A 对应的 JAR 包 |
相似 | 到了运行期,这两种方式就没有任何区别了,因为它们都运行在同一个 Java 虚拟机 |
到了运行期,这两种方式就没有任何区别了,因为它们都运行在同一个Java虚拟机 |
2、进程间的通信边界
如果限界上下文的边界就是进程的边界,限界上下文之间的协作就必须采用分布式的通信方式,考虑协作时,因为数据库共享方式的不同,产生两种不同的风格:
1)数据库共享架构
代码的运行是进程分离的,数据库却共享彼此的数据,即多个限界上下文共享同一个数据库。共享数据库可以更加便利地保证数据的一致性,这或许是该方案最有说服力的证据,但也可以视为对一致性约束的妥协
a、绕过另一个上下文直接操作数据表
不管在物理上是否共享数据库,限界上下文之间的逻辑边界仍然需要守护,不能让一个限界上下文越界访问另一个限界上下文的数据库。在针对某手机品牌开发的舆情分析系统中,危机查询服务提供对识别出来的危机进行查询。查询时,需要通过
userId
获得危机处理人、危机汇报人的详细信息。这样的设计就破坏了危机分析上下文的逻辑边界,绕开了用户上下文,直接访问了用户数据表。
b、不同上下文操作同一张表问题
数据库共享架构可能传递 “反模式” 的信号。当两个分处不同限界上下文的服务需要操作同一张数据表(这张表被称为“共享表”)时,意味着设计可能出现了错误”
1、遗漏了一个限界上下文,共享表对应的是一个被复用的服务 :买家在查询商品时,商品服务会查询价格表中的当前价格,而在提交订单时,订单服务也会查询价格表中的价格,计算当前的订单总额。共享价格数据的原因是我们遗漏了价格上下文,引入价格服务就可以解除这种不必要的数据共享
2、职责分配出现了问题,操作共享表的职责应该分配给已有的服务:舆情服务与危机服务都需要从邮件模板表中获取模板数据,然后调用邮件服务组合模板的内容发送邮件。实际上,从邮件模板表获取模板数据的职责应该分配给已有的邮件服务。
3、共享表对应两个限界上下文的不同概念:仓储上下文与订单上下文都需要访问共享的产品表,但实际上这两个限界上下文需要的产品信息并不相同,应该按照领域模型的知识语境分开为各自关心的产品建立数据表
**问题:为什么会出现上面的3种错误的设计 **
答案:一个可能的原因在于我们没有遵循领域建模的要求,而直接对数据库进行了设计,代码没有体现正确的领域模型,导致了数据库的耦合或共享
2)零共享架构
如果限界上下文之间没有共享任何外部资源,整个架构就成为零共享架构
a、样例:
如前面介绍的舆情分析系统,在去掉危机查询对用户表的依赖后,同时将用户数据与危机数据分库存储,就演进为零共享架构
这是一种限界上下文彻底独立的架构风格,保证了边界内的服务、基础设施乃至于存储资源、中间件等其他外部资源的独立性,形成自治的微服务,体现了微服务架构的特征:每个限界上下文都有自己的代码库、数据存储和开发团队,每个限界上下文选择的技术栈和语言平台也可以不同,限界上下文之间仅仅通过限定的通信协议进行通信。
b、优点
独立运行的限界上下文实现了真正的自治,不仅每个限界上下文的内部代码能够做到独立演化,在技术选型上也可以结合自身的业务场景做出“恰如其分”的选择。譬如,危机分析上下文需要存储大规模的非结构化数据,业务上需要支持对危机数据的高性能全文本搜索,故而选择了 Elasticsearch
作为持久化的数据库。
c、缺点
1、限界上下文之间采用进程间通信,必然影响通信的效率与可靠性。数据库是完全分离的,一旦一个服务需要关联跨库之间的数据,就需要跨限界上下文去访问,无法享受数据库自身提供的关联福利
2、每个限界上下文都是分布式的,如何保证数据的一致性也是一件棘手的问
3、当整个系统都被分解成一个个可以独立部署的限界上下文时,运维与监控的复杂度也随之而剧增
3)限界上下文与微服务的关系
在确定限界上下文与微服务之间的关系时,需要考虑团队与代码的边界对它们的影响,包括团队边界和代码模型边界。
a、团对边界
控制交流成本,不能出现一个限界上下文由两个或多个团队共同承担的情况。
微服务也当如此。如果不同微服务选择了不同的技术栈,团队的边界更需要与微服务对应,微服务的粒度要细于或等于限界上下文的粒度。由于技术栈选择,根据业务能力切分的限界上下文,可进一步切分其边界,此时,微服务的边界等同于限界上下文的边界
b、代码模型边界
一个微服务的代码模型不能分别部署在两个不同的进程,如果分别部署了,则应被视为不同的微服务,限界上下文却未必如此。倘若一个限界上下文采用了 CQRS
模式,针对相同的业务,查询模型与命令模型可以部署到不同的进程,可以认为是不同的微服务,但它们在逻辑上仍然属于同一个限界上下文。如此看来,微服务的粒度要细于或等于限界上下文的粒度。
一旦系统被设计为微服务,而微服务的边界又不合理,对它的重构难度就要远远大于单体架构。单体架构优,通过该架构风格逐步探索系统的复杂度,确定限界上下文构成组件的边界,待系统复杂度增加,证明了微服务的必要性时,再考虑将这些限界上下文设计为独立的微服务。
一种审慎的做法是在无法明确微服务边界的合理性时,考虑将微服务的粒度设计得更粗一些,而在服务内部,通过限界上下文的边界对代码模型进行控制。微服务内部存在的多个限界上下文自然采用进程内通信,如此可降低微服务的管理成本,也避免了不必要的分布式通信成本。与数据库共享风格相似,这可以算是一种折中的服务设计模式。整个软件系统仍然由多个微服务组成,但每个微服务的粒度并不均衡,内部的限界上下文边界却又保留了继续拆分的可能性,增强了架构的演进能力。这可认为是混合了单体架构与微服务架构的混合架构风格(面向服务架构),如图
图中架构充分体现了菱形对称架构北向网关的价值。它的远程服务与应用服务分别适应不同的业务场景,松耦合的结构使得整个架构能够较好地响应变化,遵循了演进式设计的要求。
在这样一种糅合单体架构与微服务架构的混合架构风格中,微服务的粒度又粗于限界上下文的粒度了。因此,我们很难为限界上下文和微服务确定一个稳定的映射关系,这正是软件设计棘手之处,却也是它的魅力所在
二、限界上下文之间的分布式通信
1、分布式通信的设计因素
一旦决定采用分布式通信,就需要考虑如下3个因素。
通信协议:用于数据或对象的传输。
数据协议:为满足不同节点之间的统一通信,需确定统一的数据协议。
接口定义:接口要满足一致性与稳定性,它的定义受到通信框架的影响。
2、分布式通信机制
虽然有多种不同的分布式通信机制,但在微服务架构风格下,主要采用的分布式通信机制包括”
1)REST
遵循
REST
架构风格的服务即REST
风格服务,通常采用HTTP
+JSON
序列化实现数据的进程间传输。
2)RPC
RPC
是一种技术思想,即为远程调用提供一种类本地化的编程模式,封装网络通信和寻址,实现一种位置上的透明性。因此,RPC
不限于传输层的网络协议,但为了数据传输的可靠性,通常采用的还是TCP
3)事件消息传递
REST
风格服务在跨平台通信与接口一致性方面存在天然的优势。REST
架构风格业已成熟,可以说是微服务通信的首选。然而,现阶段的REST
风格服务主要采用了HTTP
与JSON
序列化,在数据传输性能方面表现欠佳。RPC
服务解决了这一问题,但在跨平台与服务解耦方面又有着一定的技术约束。通过消息队列进行消息传递作为一种非阻塞跨平台异步通信机制,可以成为
REST
风格服务与RPC
服务之外的有益补充
三、命令查询职责的分离
命令与查询是否需要分离,这一设计决策会对系统架构、限界上下文乃至领域模型产生直接影响。在领域驱动设计中,是否选择引入该模式是一个重要的战略考量
1、查询和命令差异
1、查询操作没有副作用,具有幂等性;命令操作会修改状态,其中新增操作若不加约束则不具有幂等性。
2、查询操作发起同步请求,需要实时返回查询结果,往往为阻塞式的请求/响应操作;命令操作可以发起异步请求,甚至可以不用返回结果,即采用非阻塞式的即发即忘操作。
3、查询结果往往需要面向
UI
表示层,命令操作只是引起状态的变更,无须呈现操作结果4、查询操作的频率要远远高于命令操作,领域复杂度又要低于命令操作。
1)CQRS
既然命令操作与查询操作存在如此多的差异,采用一致的设计方案就无法更好地应对不同的客户端请求。按照领域驱动设计的原则,针对同一领域逻辑,原本应该建立一个统一的领域模型,但它可能无法同时满足具有复杂
UI
呈现与丰富领域逻辑的需求,无法同时满足具有同步实时与异步低延迟的需求。这时,就需要寻求改变,按照操作类型对领域模型进行划分,分别建立命令模型和查询模型,形成命令查询职责分离模式 (
command
query
responsibility
segregation
,CQRS
)。”
命令操作:命令处理器操作的领域模型就是命令模型。如果没有采用事件溯源与事件存储,该领域模型与普通领域模型并无任何区别,仍然包括实体、值对象、领域服务、资源库和工厂,实体与值对象放在聚合边界内。若有必要还可以引入领域事件
查询操作:查询操作面对查询模型,对应消息契约模型中的响应消息对象。响应消息对象并不属于领域模型,因为查询端要求查询操作干净利落、直截了当。为了减少不必要的对象转换,没有定义领域层,而是通过一个薄薄的数据层直接访问数据库。为了提高查询性能,还可以在数据库专门为查询操作建立对应的视图。查询返回的结果无须经过领域模型,直接转换为调用者需要的响应请求对象
CQRS
模式要求分离命令操作和查询操作,相当于砍掉了资源库执行查询操作的职责。去掉查询操作后,命令操作执行的聚合又来自何处呢?难道还需要去求助专门的查询接口吗?其实不然,虽然命令模型的资源库不再提供查询方法,但根据聚合根实体的 ID
执行查询的方法仍然需要保留,否则就无从管理聚合的生命周期了,它的作用实则是加载一个聚合。命令模型的一个典型资源库接口应如下所示
2)查询和命令相同上下文
命令端:除了需要将查询方法从资源库接口中分离出去,与领域驱动设计对领域模型的要求完全保持一致,在架构上,同样遵循菱形对称架构。
查询端则不同:没有领域模型,而是直接通过北向网关的远程查询服务调用南向的
DAO
对象,获得的数据访问对象就是消息契约模型,也就是调用者希望获得的响应消息对象,甚至可以是UI
前端需要的视图模型对象。本质上,查询端的架构遵循了弱化的菱形对称架构,即没有领域模型作为内核命令端与查询端位于同一个限界上下文,但采用了不同的分层架构。关键之处在于查询端无须领域模型,从而减少了不必要的抽象与间接,满足快速查询的业务需求。
3)命令总线的引入
如果命令请求需要执行较长时间,或者服务端需要承受高并发的压力,又无须实时获取执行命令的结果,就可以引入命令总线,将同步的命令请求改为异步方式,以有效利用分布式资源,提高系统的响应能力。
大型软件系统通常会使用消息队列作为命令总线。消息队列引入的异步通信机制,使得发送方和接收方都不用等待对方返回成功消息即可执行后续的代码,提高了数据处理的能力。尤其在访问量和数据流量较大的情况下,可结合消息队列与后台任务,避开高峰期对任务进行批量处理,有效降低数据库处理数据的负荷,同时也减轻了命令端的压力。
为保证命令端与查询端的一致性,可以将命令服务定义为北向网关的远程服务或应用服务。它的调用方式看起来和查询服务完全一样。实现时,命令服务相当于是命令请求的中转站,在接收到调用者的命令请求后,不做任何处理,立刻将命令消息转发给消息队列。命令处理器作为命令消息的订阅者,在收到命令消息后,调用领域模型对象执行对应的领域逻辑。如此一来,限界上下文的架构就会发生变化,接收命令请求的远程服务和命令处理器在逻辑上属于同一个限界上下文
显然,命令总线的引入增加了架构的复杂度,即使在同一个限界上下文内部,也引入了复杂的分布式通信机制,但提高了整个限界上下文的响应能力。
// 此时的应用服务作为命令处理器
public class OrderAppService {
// 为了体现命令请求的含义,消息对象的后缀统一为command
public void placeOrder(PlaceOrderCommand placeOrderCommand) {
}
public void cancelOrder(CancelOrderCommand cancelOrderCommand) {
}
}
四、事物
1、本地事物
2、分布式事务
3、柔性事务
1)可靠事件模式
2)TCC
模式
3)Saga
模式