领域驱动架构篇—菱形对称架构
领域驱动设计中,对于架构风格有一个指导思想:不同的限界上下文,根据其领域模型和业务特征,可以选用不同的架构风格。
在传统的分层架构与领域驱动理念相结合的过程中,产生了多种架构风格:六边型架构、整洁架构、微服务架构等。本文是根据对对IT文艺工作者张逸老师的相关Chat阅读和思考整理而得
前言
菱形对称架构(Rhombic Symmetric Architecture) 主要针对领域层次的架构,借鉴了六边形架构、分层架构、整洁架构的知识,并结合了领域驱动设计的元模型,使其能够更好地运用到限界上下文的架构设计中。
架构演进之路
六边型架构
六边型架构的特点就是:在满足整洁架构思想的同时,更加关注内层与外层自己与外部资源之间的通信本质
对于外界每种类型都有一个适配器与之对应:消息适配器,REST适配器,SOAP适配器,持久化适配器
通过内外两个六边形的不同边界清晰地展现了了领域与技术的边界(蓝色是领域,灰色是技术)
- 端口:我们可以把端口看成是具体的协议
- 适配器:看成是具体的通信垂直技术,如:Servlet、REST、JPA
- 应用:用例级别的业务逻辑,体现了一个业务场景
- 领域模型:系统级别业务逻辑,多个服务和聚合构成一个用例
六边型架构中,端口是解耦的关键:入口隔离了外部请求和技术实现细节;出口隔离了数据持久化和外部访问设备
六边型架构的业务(预定机票)示例:
- ReservationResource:订票请求发送给以 RESTful 契约定义的资源服务,它作为入口适配器,介于应用六边形与领域六边形的边界之内,在接收到以 JSON 格式传递的前端请求后,将其转换(反序列化)为入口端口ReservationAppService需要的请求对象
- ReservationAppService:入口端口为应用服务,位于领域六边形的边界之上。当它在接收到入口适配器转换后的请求对象后,调用位于领域六边形边界内的领域服务TicketReservation
- TicketReservation:订票的领域逻辑
- ReservationRepository:出口端口为资源库,位于领域六边形的边界之上,定义为接口
- ReservationRepositoryAdapter:真正访问数据库的逻辑则由介于应用六边形与领域六边形边界内的出口适配器,该实现访问了数据库,将端口发送过来的插入订票记录的请求转换为数据库能够接收的消息,执行插入操作
整洁架构
整洁架构的模型是一个类似内核模式的内外层结构,它具有以下特点:
- 1 越靠近中心,层次越高
- 2 由外到内:框架与驱动程序 --》接口适配器 --》应用级业务逻辑--》系统级业务逻辑
- 3 源码中依赖关系必须指向同心圆内层,从外到内
我们可以在整洁架构上得到很多值得回味的设计思想:
- 不同层次的组件其变化频率也不相同,引起变化的原因也不相同(符合高内聚低耦合设计思想)
- 层次越靠内组件依赖越少,处于核心业务实体没有任何依赖(领域模型和技术完美解耦)
- 层次越靠内组件与业务关系越密切,专属于特定领域的内容,很难形成通用的框架(领域构建出了壁垒)
在这个架构中,外层圆代表的是机制,内层圆代表的是策略;机制和具体的技术实现有关,容易受到外部环境变化;策略与业务有关,封装了最核心领域模型,最不容易受到外界环境变化的影响
六边形架构仅仅区分了内外边界,提炼了端口与适配器角色,并没有规划限界上下文内部各个层次与各个对象之间的关系;而整洁架构又过于通用,提炼的是企业系统架构设计的基本规则与主题。因此,当我们将六边形架构与整洁架构思想引入到领域驱动设计的限界上下文时,还需要引入分层架构给出更为细致的设计指导,即确定层、模块与角色构造型之间的关系。
经典分层架构
分层架构是运用最为广泛的架构模式,几乎每个软件系统都需要通过层(Layer)来隔离不同的关注点(Concern Point),以此应对不同需求的变化,使得这种变化可以独立进行。
- 用户界面层:负责向用户展现信息和解释用户命令。包含web端UI界面、移动端UI界面、第三方服务等。
- 应用层:很薄的一层,用来协调应用的活动,它不包含业务逻辑,它不保留业务对象的状态。在领域设计中,它其实是一个外观(Facade),一般是供其他界限上下文基础设置层中controller调用。
- 领域层:本层包含领域的信息,是业务软件的核心所在。在这里保留业务对象的状态,对业务对象状态的持久化被委托给基础设置层。它包含领域设计中的:聚合、值对象、实体、服务、资源接口等。
- 基础设置层:不要简单的理解为物理上的一层,它作为其他层的支撑库而存在。它提供了层间的通讯,实现对业务对象的持久化。一般包含:网络通讯、资源实现(数据库持久化实现)、异步消息服务、网关、controller控制等。
菱形对称架构
菱形对称架构融合了分层架构和六边型架构的思想
对六边型进行分层映射
- 入口适配器:响应边界外客户端的请求,需要实现进程间通信以及消息的序列化和反序列化,这些功能皆与具体的通信技术有关,故而映射到基础设施层
- 入口端口:负责协调外部客户端请求与内部应用程序之间的交互,恰好与应用层的协调能力相配,故而映射到应用层
- 应用程序:承担了整个限界上下文的领域逻辑,包含了当前限界上下文的领域模型,毫无疑问,应该映射到领域层
- 出口端口:作为一个抽象的接口,封装了对外部设备和数据库的访问,由于它会被应用程序调用,遵循整洁架构思想,也应该映射到领域层
- 出口适配器:访问外部设备和数据库的真正实现,与具体的技术实现有关,映射到基础设施层
突破分层架构
分层架构仅仅是对限界上下文的逻辑划分,在编码实现时,逻辑层或许会以模块的形式表现,但是也可能将整个限界上下文作为一个模块,每个层不过是命名空间的差异,定义为模块内的一个包。不管是物理分离的模块,还是逻辑分离的包,只要能保证限界上下文在六边形边界的保护下能够维持内部结构的清晰,就能降低架构腐蚀的风险。
依据整洁架构遵循的“稳定依赖原则”,领域层不能依赖于外层。因此,出口端口只能放在领域层。事实上,领域驱动设计也是如此要求的,它在领域模型中定义了资源库(Repository),用于管理聚合的生命周期,同时,它也将作为抽象的访问外部数据库的出口端口。
将资源库放在领域层确有论据佐证,毕竟,在抹掉数据库技术的实现细节后,资源库的接口方法就是对聚合领域模型对象的管理,包括查询、修改、增加与删除行为,这些行为也可视为领域逻辑的一部分。
然而,限界上下文可能不仅限于访问数据库,还可能访问同样属于外部设备的文件、网络与消息队列。为了隔离领域模型与外部设备,同样需要为它们定义抽象的出口端口,这些出口端口该放在哪里呢?如果依然放在领域层,就很难自圆其说。例如,出口端口EventPublisher支持将事件消息发布到消息队列,要将这样的接口放在领域层,就显得不伦不类了。倘若不放在位于内部核心的领域层,就只能放在领域层外部,这又违背了整洁架构思想。
既然出口端口的位置如此尴尬,而且很明显出和入不太对称,所以我们干脆就把出和入对称下,将端口和适配器统一掉,组合成“网关”
上面的对称架构虽脱胎于六边形架构与领域驱动设计分层架构,却又有别于二者。
对称架构北向网关定义的远程网关与本地网关同时承担了端口与适配器的职责,这实际上改变了六边形架构端口-适配器的风格;领域层与南北网关层的内外分层结构,以及南向网关规定的端口与适配器的分离,又与领域驱动设计的分层架构渐行渐远。
既然已经改变,就根据思想,重新抽象下架构图
就得到了菱形对称架构,主要体现了南北网关的对称关系
菱形对称架构的组成
菱形架构其上下文的组成:
- 北向网关的远程网关:进程间通信
- 北向网关的本地网关:进程内通信
- 领域层的领域模型:领域逻辑
- 南向网关的端口抽象:各种资源库操作的抽象借接口,可以被领域层依赖,
- 南向网关的适配器实现:网关端口的实现,运行时通过依赖注入将适配器实现注入到领域层
以六边型中的业务示例为例,改成菱形架构的话,其架构如下:
引入上下文映射
北向网关演变
北向网关和开放式主机服务很类似,但是职能更多,相当于开放式主机层,包含了远程服务和本地服务
- 远程服务:跨进程通信;包含资源(Resource)服务、供应者(Provider)服务、控制器(Controller)服务与事件订阅者(Event Subscriber)服务
- 本地服务:进程内通信;对应于应用层的应用服务
当外部请求从远程服务进入时,如果需要调用领域层的领域逻辑,则必须经由本地服务发起对领域层的请求。此时的本地服务又扮演了端口的作用,可认为远程服务是本地服务的客户端。
南向网关演变
南向网关引入了抽象的端口来隔离内部领域模型对外部环境的访问,这一价值很等同于上下文映射中的防腐层,同样它也扩大了防腐层的功能
南向网关的端口分为:
- 资源库(repository)端口:隔离对外部数据库的访问,对应的适配器提供聚合的持久化能力
- 客户端(client)端口:隔离对上游限界上下文或第三方服务的访问,对应的适配器提供对服务的调用能力
- 事件发布者(event publisher)端口:隔离对外部消息队列的访问,对应的适配器提供发布事件消息的能力
改进后的菱形对称架构
菱形对称架构去掉了应用层和基础设施层的概念,以统一的网关层进行概括,并以北向与南向分别体现了来自不同方向的请求。如此形成的对称结构突出了领域模型的核心作用,更加清晰地体现了业务逻辑、技术功能与外部环境之间的边界。
资源库视为防腐层的端口与适配器,作为领域建模时的角色构造型,与场景驱动设计更好地结合,增强了领域模型的稳定性。应用层被去掉之后,被弱化为开放主机服务层的本地服务,相当于从设计层面回归到服务外观的本质,也有助于解决应用服务与领域服务之间的概念之争。
代码模型示例:
ohs 为开放主机服务模式的缩写,acl 是防腐层模式的缩写,pl 代表了发布语言;也可以使用北向(northbound)与南向(sourthbound)取代 ohs 与 acl 作为包名,使用消息(messages)契约取代 pl 的包名。
菱形对称架构价值体现
当我们为限界上下文引入菱形对称架构之后,一方面可以更加清晰地展现上下文映射模式之间的差异,并凸显了防腐层与开放主机服务的重要性;另一方面,遵循菱形对称架构的领域驱动架构亦具有更好的响应变化的能力。
展现上下文映射
以“查询订单”为例,若需求要求查询返回的订单需要包含商品的信息,这时可能产生订单上下文的订单与商品上下文的商品之间的“模型依赖”。
遵奉者或共享内核模式
一种方式是让订单直接重用商品上下文的领域模型,即采用遵奉者(Conformist) 模式或共享内核(Shared Kernel) 模式
重用领域模型的方式会突破菱形对称架构北向网关修筑的堡垒,让商品上下文的领域模型直接暴露在外
防腐层和开放式主机结合
如果订单上下文与商品上下文处于同一进程,根据菱形对称架构的定义,位于下游的订单上下文可以通过其南向网关发起对商品上下文北向网关中本地服务的调用。
为了保护领域模型,商品上下文在北向网关中还定义了消息契约模型
此时的菱形对称架构体现了防腐层模式与开放主机服务模式共同协作,位于商品上下文北向网关的本地服务还定义了消息契约,从而产生了消息契约模型之间的依赖。
如果订单上下文与商品上下文位于不同的进程,两个限界上下文之间的模型依赖就不存在了,我们需要各自为其定义属于自己的模型对象。
上图所示的订单上下文与商品上下文仅存在上下游调用关系,通过南向网关的防腐层与北向网关的开放主机服务亦降低了彼此的耦合;然而,在各自边界内定义的ProductResponse之间,却隐隐包含了逻辑概念的映射关系,它会带来变化的级联反应(一边改动后另一边也要改动)。
分离方式
若真正体会了限界上下文作为知识语境的业务边界特征,就可以将订单包含的商品信息视为订单上下文的领域模型,隐含的统一语言为“已购商品(Purchased Product)”,它与商品上下文的商品属于不同的领域概念,处于不同的业务边界,然后共享同一个productId
当用户只看订单及商品基本信息时,无需求助商品上下文,若要获取更多的商品信息时,可以通过productId向商品上下文发起请求,此时也和订单上下文无关,做到很好的隔离
这种方式真正展现了限界上下文的价值,即对领域模型的控制力。当限界上下文针对业务关注点进行垂直切割时,不仅要从语义相关性划分领域概念,还要考虑这些概念之间存在的功能相关性。
例如,商品与商品上下文是语义强相关的,但在查询订单这一领域场景中,获得订单时随之返回对应的商品信息,却是功能相关性发挥着作用。它也充分体现了“最小完备”的自治特性,因为在查询订单时,如果不为订单项提供对应的商品信息,该限界上下文就是不完备的。
当我们在定义领域模型时,如果一些领域概念出现矛盾或冲突时,就是引入限界上下文维护概念一致性的时机,也是统一语言发挥作用的时候。
响应变化的能力
限界上下文之间产生协作时,通过菱形对称架构可以更好地响应协作关系的变化。
客户方-供应方模式
通常说来,这种协作模式就是典型的客户方-供应方(Customer-Supplier) 模式,参与协作的角色包括
- 下游限界上下文:防腐层的客户端端口(acl.ports.Client)作为适配上游服务的接口,客户端适配器(acl.adapters.ClientAdapter)封装对上游服务的调用逻辑
- 上游限界上下文:开放主机服务的远程服务(ohs.remote.Resource)与本地服务(ohs.local.AppService)为下游限界上下文提供具有业务价值的服务
客户端适配器到底该调用上游限界上下文的远程服务还是本地服务,取决于这两个限界上下文的通信边界
如果上下游的限界上下文位于同一个进程边界内,客户端适配器可以直接调用本地服务。
如果上下游的限界上下文处于不同的进程边界,就由远程服务来响应下游客户端适配器发起的调用。
发布者和订阅者模式
采用这种模式时,限界上下文之间的耦合主要来自对事件的定义。作为事件发布者的限界上下文可以不用知道有哪些事件的订阅者,反之亦然,彼此之间的解耦往往通过引入事件总线(可以是本地的事件总线,也可以是单独进程的事件总线)来保证
在限界上下文内部,同样需要隔离领域模型与事件通信机制,这一工作由菱形对称架构网关层中的设计元素来完成。事件的发布者位于防腐层,发布者端口(acl.ports.EventPublisher)提供抽象定义,发布者适配器(acl.adapters.EventPublisherAdapter)负责将事件发布给事件总线;事件的订阅者(ohs.remote.EventSubscriber)属于开放主机服务层的远程服务,在订阅到事件之后,交由本地服务(ohs.local.ApplicationService)来处理事件。
应用事件:
领域事件:
一个订单提交的示例
业务场景如下:
- 客户提交订单,向订单上下文发送提交订单的客户端请求
- 订单上下文向库存上下文发送检查库存量的客户端请求
- 库存上下文查询库存数据库,返回库存信息
- 若库存量符合订单需求,则订单上下文访问订单数据库,插入订单数据
- 订单上下文调用库存上下文的锁定库存量服务,对库存量进行锁定
- 提交订单成功后,发布应用事件,并发布到事件总线
- 通知上下文订阅应用事件,调用客户上下文获得该订单的客户信息,组装通知内容
- 通知上下文调用短信服务,发送短信通知客户
识别的上下文如下:
菱形调用关系如下:
订单上下文内部
客户提交订单通过前端 UI 向订单上下文远程服务OrderController提交请求,然后将请求委派给本地服务OrderAppService
远程服务与本地服务使用的消息契约模型定义在ohs.local.pl包中,如此即可同时支持两种开放主机服务,这些消息契约模型都定义了如to()和from之类的转换方法,用于消息契约模型与领域模型之间的互相转换。
订单上下文与库存上下文协作
本地服务OrderAppService收到PlacingOrderRequest请求后,会将该请求对象转换为Order领域对象,然后通过领域服务OrderService提交订单。
提交订单时,首先需要验证订单有效,然后再检查库存量。验证订单的有效性由领域模型对象Order聚合根承担,库存量的检查则需要通过端口InventoryClient,并由注入的适配器InventoryClientAdapter发起向库存上下文远程服务InventoryResource的调用
位于南向网关的客户端端口InventoryClient及其适配器实现需要调用与之对应的消息契约模型,如CheckingInventoryRequest和InventoryReviewResponse。
根据前面的分析,如果上下游的限界上下文都处于同一个进程中,则下游上下文的南向网关可以重用上游上下文的消息契约。既然这里假定各个限界上下文都处于不同进程,就需要自行定义消息契约了
库存上下文协作
InventoryResource又会通过库存上下文的本地服务InventoryAppService调用领域服务InventoryService,然后经由端口InventoryRepository与适配器InventoryRepositoryAdapter访问库存数据库,获得库存量的检查结果
订单上下文发布应用事件
领域服务OrderService在确认了库存量满足订单需求后,通过端口OrderRepository以及适配器OrderRepositoryAdapter访问订单数据库,插入订单数据。
一旦订单插入成功,订单上下文的领域服务OrderService还要调用库存上下文的远程服务InventoryResource锁定库存量。订单上下文的本地服务OrderAppService会在OrderService成功提交订单之后,组装OrderPlacedEvent应用事件,并调用端口EventPublisher,由适配器EventPulbisherAdapter将该事件发布到事件总线
通知上下文订阅事件
通知上下文的远程服务EventSubsriber订阅了OrderPlacedEvent事件。一旦接收到该事件,就会通过本地服务NotificationAppService调用其领域层中的NotificationService领域服务
通知上下文和客户上下文协作
NotificationService领域服务会调用端口CustomerClient,经由适配器CustomerClientAdapter向客户上下文的远程服务CustomerResource发送调用请求。
在客户上下文内部,由北向南,依次通过远程服务CustomerResource、本地服务CustomerAppService、领域服务CustomerService和南向网关的端口CustomerRepository与适配器CustomerRepositoryClient完成对客户信息的查询,返回调用者需要的信息。
通知上下文的领域服务NotificationService在收到该响应消息后,组装领域对象Notification,再通过本地的端口SmsClient与适配器SmsClientAdapter,调用短信服务发送通知短信
小结
显然,若每个限界上下文都采用菱形对称架构,则代码结构是非常清晰的,各个层各个模块各个类都有着各自的职责,泾渭分明,共同协作。同时,网关层对领域层的保护是不遗余力的,没有让任何领域模型对象泄露在限界上下文边界之外,唯一带来的成本就是可能存在重复定义的消息契约对象,以及对应的转换逻辑实现。