不为有趣之事,何遣有涯之生
不失其所者久,死而不亡者寿

DDD领域驱动战略篇(4) 架构与代码模型

DDD领域驱动战略篇(4)

架构与代码模型

认识分层架构

分层架构是运用最为广泛的架构模式,几乎每个软件系统都需要通过层来隔离不同的关注点,以此应对不同需求的变化,使得这种变化可以独立进行

传统经典三层架构

DDD经典分层结构

  • 用户界面/展现层:负责向用户展现信息以及解释用户命令
  • 应用层:很薄的一层,用来协调应用的活动,它不包含业务逻辑,它不保留业务对象的状态,但它保有应用任务的进度状态
  • 领域层:本层包含关于领域的信息,这是业务软件的核心所在。在这里保留业务对象的状态,对业务对象和它们状态的持久化被委托给了基础设施层
  • 基础设施层 :本层作为其他层的支撑库存在。它提供了层间的通信,实现对业务对象的持久化,包含对用户界面层的支撑库等作用
分层的依据和原则
  • 基于关注点为不同的调用目的划分层次:以领域驱动设计的四层架构为例,之所以引入应用层就是为了给调用者提供完整的业务用例
  • 面对变化:针对不同的变化原因确定层次的边界,严禁层次之间互相干扰
  • 保证同一层的组件处于同一个抽象层次
层与层之间协作
  • 依赖倒置原则:我们要依赖不变或稳定的元素(类、模块或层),抽象不应该依赖于细节,细节应该依赖于抽象
  • 低层模块的细节实现可以独立变化,避免变化对高层模块产生污染
  • 在编译时,高层模块可以独立于低层模块单独存在
  • 对于高层模块而言,低层模块的实现是可替换的
  • 为了更好地解除高层对低层的依赖,我们往往需要将依赖倒置原则与依赖注入结合起来

分层架构的演化

整洁架构


该架构思想提出的模型并非传统的分层架构,而是类似于一个内核模式的内外层架构,由内及外分为四层

  • 层次越靠内的组件依赖的内容越少,处于核心的 Entities 没有任何依赖
  • 层次越靠内的组件与业务的关系越紧密,因而越不可能形成通用的框架
  • Entities 层封装了企业业务规则,准确地讲,它应该是一个面向业务的领域模型
  • Use Cases 层是打通内部业务与外部资源的一个通道,因而提供了输出端口(Output Port)与输入端口(Input Port),但它对外的接口展现的其实是应用逻辑,或者说是一个用例
  • Gateways、Controllers 与 Presenters 其本质都是适配器(Adapter),用于打通应用业务逻辑与外层的框架和驱动器,实现逻辑的适配以访问外部资源
  • 系统最外层包括框架和驱动器,负责对接外部资源,不属于系统(仅指限界上下文而言)开发的范畴,但选择这些框架和驱动器,是属于设计决策要考虑的内容。这一层的一些组件甚至与要设计的系统不处于同一个进程边界
六边型架构

六边形架构在满足整洁架构思想的同时,更关注于内层与外层以及与外部资源之间通信的本质:六边形架构通过内外两个六边形为系统建立了不同层次的边界。核心的内部六边形对应于领域驱动设计的应用层与领域层,外部六边形之外则为系统的外部资源,至于两个六边形之间的区域,均被 Cockburn 视为适配器(Adapter),并通过端口(Port)完成内外区域之间的通信与协作,故而六边形架构又被称为端口-适配器模式

下面是对六边型架构的一个落地实践示例:

六边形架构中,Gateways、Controllers 与 Presenters 统一看做是适配器
不同的命名代表的是对其职责认识上的不同,如果认为是“网关”,则将该组件的实现视为一种门面,内部负责多个对象之间的协作以及职责的委派;如果认为是“适配器”,则是为了解决内外协议(数据协议与服务接口)之间的不一致而进行的适配。

微服务架构

下图的逻辑边界代表了一个微服务

该架构图并未严格按照分层架构模式来约定各个组件的位置与职责,这是完全合理的设计
整幅图的架构其实蕴含了两个方向:自顶向下和由内至外

  • Resource(REST服务中资源层的意思) 组件就是我们通常定义的 Controller,对应于上下文映射中的开放主机服务
  • 当微服务需要调用外部服务,且外部服务由 HTTP 协议通信,就需要提供一个 HTTP Client 组件完成对外部服务的调用
  • 为了避免当前微服务对外部服务的强依赖,又或者对客户端的强依赖,需要引入 Gateways 来隔离,充当防腐层

领域驱动架构的演进

改良的DDD经典分层

将基础设施层奇怪地放在了整个架构的最上面

整个架构模型清晰地表达了领域层别无依赖的特质,但整个架构却容易给人以一种错乱感
这个架构模型仍然没有解决人们对分层架构的认知错误,例如它并没有很好地表达依赖倒置原则与依赖注入
这个架构模型将基础设施层放在了整个分层架构的最顶端,导致它依赖了用户展现层
那如何继续演进呢?避免领域模型出现贫血模型,保证领域模型的纯粹性

避免贫血,保证纯粹

经典分层架构图:

将数据访问层中的Java Beans这种没有任何业务行为的对象称之为“贫血对象”,基于这样的贫血对象进行领域建模,得到的模型则被称之为“贫血模型”
贫血模型开发起来比较简单,但不具备丰富表达能力,无法有效应对重用与变化,无法展现业务特性

改进后的架构图:

目前,前后端分离已经是流行趋势,逐渐的用户表现层将彻底分离出去,形成一个完全松耦合的前端层
不管前端的展现方式如何,它的设计思想是面向调用者,而非面向领域。因此,我们在讨论领域驱动设计时,通常不会将前端设计纳入到领域驱动设计的范围

前端演化后架构:

这个分层架构展现了“离经叛道”的一面,因为基础设施层在这里出现了两次,但同时也充分说明了基础设施层的命名存在不足。当我们提及基础设施(Infrastructure)时,总还是会想当然地将其视为最基础的层。同时,这个架构也凸显了分层架构在表现力方面的缺陷。

引入应用层架构:

领域驱动分层架构中的应用层其实是一个外观,不包含业务逻辑,但对外它却提供了一个一致的体现业务用例的接口。一般它和用户故事一一对应。

基础设施层本质

出现的二个基础设施层是不是有点奇怪?从依赖关系看,处于领域层下端的基础设施层是通过实现抽象 Repository 接口导致的。二个基础设施层其实体现出的是南北网关的概念,我们结合整洁架构与六边型架构,可以得到下面的表达公式:

Controllers + Gateways + Presenters = Adapters = Infrastructure Layer

分层架构是一种架构模式,遵循了“关注点分离”原则。因此,在针对不同限界上下文进行分层架构设计时,还需要结合当前限界上下文的特点进行设计,合理分层,保证结构的清晰和简单。

层次职责与协作案例

案例业务描述

电商系统的下订单场景,在买家提交订单时,除了与订单直接有关的业务之外,还需要执行以下操作:

  • 订单数据的持久化:OrderRepository 提供插入订单功能。它属于支撑订单提交业务的基础功能,但将订单持久化到数据库的实现 OrderMapper 并不属于该业务范畴
  • 发送通知邮件:NotificationService 提供通知服务。它属于支撑通知业务的基础功能,但邮件发送的实现 EmailSender 却不属于该业务范畴
  • 异步发送消息给仓储系统:提交订单成功后需要异步发送消息 OrderConfirmed 给仓储系统,这一通信模式是通过消息队列来完成的。EventBus 发送 OrderConfirmed 事件属于支撑订单提交成功的基础功能,但发送该事件到 RabbitMQ 消息队列的 RabbitEventBus 则不属于该业务范畴

基本的架构如下:

  • 北向网关:OrderController提供RESTAPI服务,属于基础设施层
  • 南向网关:OrderMapper、EmailSender 和 RabbitEventBus分别提供了订单持久化,邮件发送,事件总线具体实现
  • 持久化的抽象方法属于领域逻辑,放在领域层;事件和消息通知抽象不属于领域逻辑,放在应用层
  • 领域层:包含 PlaceOrderService、Order、Notification、OrderConfirmed 与抽象的 OrderRepository,封装了纯粹的业务逻辑,不掺杂任何与业务无关的技术实现
  • 应用层:包含 OrderAppService 以及抽象的 EventBus 与 NotificationService,提供对外体现业务价值的统一接口,同时还包含了基础设施功能的抽象接口
  • 基础设施层:包含 OrderMapper、RabbitEventBus 与 EmailSender,为业务实现提供对应的技术功能支撑,但真正的基础设施访问则委派给系统边界之外的外部框架或驱动器

问题:在实现过程,我们会碰到组装邮件和组装订单确认事件的内容,假设这两个功能在该业务场景下是属于领域层的,那应用层怎么获取领域层生成的对象呢?

方案一

应用层直接调用领域层的方法获取
类目录结构:

类图关系:

方案二

将“上层对下层的调用”改为“下层对上层的通知”,领域层通过事件通知的方式告知应用层,相当于应用层是订阅者,领域层是发布者
事件发布接口定义在领域层,具体实现在应用层(这里的前提是:发送邮件与异步发送通知属于应用逻辑的一部分)
领域服务在下订单完成后,需要分别发布 NotificationComposed 与 OrderConfirmed 事件,应用层在调用业务逻辑时要先注册订阅事件

类目录结构:

类图关系:

方案三

重新分配 NotificationService 与 EventBus,将这两个抽象接口放到单独的一个名为 interfaces 的包中,这个 interfaces 包既不属于应用层,又不属于领域层
通过这样的职责分配后,业务逻辑发生了转移,发送邮件与异步发送通知的调用不再放到应用服务 OrderAppService 中,而是封装到了 PlaceOrderService 领域服务。
这里的前提是:发送邮件与异步发送通知属于业务逻辑的一部分

类目录结构:

类图关系:

限界上下文与架构

  • 架构范围:限界上下文体现的是一个垂直的架构边界,主要针对后端架构层次的垂直切分;不仅仅作用于领域层和应用层,它是架构设计而非仅仅是领域设计的关键因素
限界上下文的通信边界

划分时,限界上下文之间是否为进程边界隔离,直接影响架构设计,进程内与进程间在如下方面存在迥然不同的处理方式:通信、消息的序列化、资源管理、事务与一致性处理、部署。除此之外,通信边界的不同还影响了系统对各个组件(服务)的重用方式与共享方式

  • 进程内通信边界:通过模块和命名空间来划分设计,即使处于同一个进程的边界,我们仍需重视代码模型的边界划分

越容易重用,越容易耦合,进程间通信我们依然可以适当采取防腐层来进行设计,如下图所示:

项目上下文中定义通知服务的接口 NotificationService,并由 NotificationClient 去实现这个接口,它们扮演的就是防腐层的作用
倘若在未来需要将通知上下文分离为进程间的通信边界,这种变动将只会影响到防腐层的实现,作为 NotificationService 服务的调用者,并不会受到这一变化的影响

  • 进程间通信:一个限界上下文通过分布式的通信方式调用另一个限界上下文的方法,有两种风格:1 数据共享架构;2 零共享架构

数据库共享架构:即多个限界上下文共享同一个数据库
数据库共享架构也可能是一种“反模式”。当两个分处不同限界上下文的服务需要操作同一张数据表(这张表被称之为“共享表”)时,就传递了一个信号,即我们的设计可能出现了错误

零共享架构:将两个限界上下文共享的外部资源彻底斩断
彻底分离的限界上下文变得小而专,使得我们可以很好地安排遵循 2PTs 规则的小团队去治理它
然而,这种架构的复杂度也不可低估,数据一致性是棘手的问题,运维与监控的复杂度也随之而剧增

限界上下文对架构的影响

在进行架构设计时,其中最主要的视图就是逻辑视图和物理视图,这两种视图都需要考虑限界上下文以及它们之间的协作关系
在考虑逻辑视图时,我们会为限界上下文履行的职责所吸引,同时又需得关注它们之间的协作,此时,就该物理视图粉墨登场了

对于跨进程边界进行协作的限界上下文,建议为其绘制上下文映射,并通过六边形架构来确定二者之间的通信端口与通信协议
分布式调用需要考虑各种方案原则选择:CAP 原则的约束,最终一致性(BASE),TCC(Try-Confirm-Cancel)模式等

限界上下文与六边型及微服务
  • 一个限界上下文就是一个六边形,限界上下文之间的通信通过六边形的端口进行
  • 一个微服务就是一个六边形,微服务之间的协作就是限界上下文之间的协作

限界上下文即微服务:我们可以利用领域驱动设计对限界上下文的定义,以及根据前述识别限界上下文的方法来设计微服务
微服务即限界上下文:运用微服务设计原则,可以进一步甄别限界上下文的边界是否合理,对限界上下文进行进一步的演化
微服务即六边形:深刻体会微服务的“零共享架构”,并通过六边形架构来表达微服务
限界上下文即六边形:运用上下文映射来进一步探索六边形架构的端口与适配器角色
六边形即限界上下文:通过六边形架构的端口确定限界上下文之间的集成关系

电商购物示例

用例如下:

根据用例可以会的六个限界上下文:Product Context,Basket Context,Order Context,Inventory Context,Payment Context,Notification Context
如果这六个限界上下文之间采用跨进程通信,实际上就是六个微服务
采用客户方供应商开发模式,得到如下上下文映射图:

命令:是一个动作,是一个要求其他服务完成某些操作的请求,它会改变系统的状态,命令会要求响应
查询:是一个请求,查看是否发生了什么事。重要的是,查询操作没有副作用,它们不会改变系统的状态
事件:既是事实又是触发器,用通知的方式向外部表明发生了某些事

如果采用事件协作机制,协作过程如下:

1 Basket Context 发布 OrderRequested 事件,Order Context 订阅该事件,然后执行提交订单的流程
2 Order Context 验证订单,并发布 InventoryRequested 事件,要求验证订单中购买商品的数量是否满足库存要求
3 Inventory Context 订阅此事件并对商品库存进行检查,倘若检查通过,则发布 AvailabilityValidated 事件
4 Order Context 侦听到 AvailabilityValidated 事件后,验证通过,发布 OrderValidated 事件从而发起支付流程
5 Payment Context 响应 OrderValidated 事件,在支付成功后发布 PaymentProcessed 事件
6 Order Context 订阅 PaymentProcessed 事件,确认支付完成进而发布 OrderConfirmed 事件
7 Basket Context、Notification Context 与 Shipment Context 上下文都将订阅该事件。Basket Context 会清除购物篮,Notification Context 会发起对买家和卖家的通知,而 Shipment Context 会发起配送流程,在交付商品给买家后,发布 ShipmentDelivered 事件并被 Order Context 订阅

事件皆以“过去时态”命名,味着它是过去发生的且不可变更的数据,代表了某种动作的发生,并以事件的形式留下了足迹

DDD的代码模型

理解了限界上下文和分层架构的本质,要确认系统的代码模型自然也就水到渠成,不同项目可以使用不同的代码模型,同一个项目中代码模型应作为架构规范共同遵守

遵循DDD代码模型

  • application:对应了领域驱动设计的应用层,主要内容为该限界上下文中所有的应用服务
  • interfaces:对 gateways 中除 persistence 之外的抽象,包括访问除数据库之外其他外部资源的抽象接口,以及访问第三方服务或其他限界上下文服务的抽象接口。从分层架构的角度讲,interfaces 应该属于应用层,但在实践时,往往会遭遇领域层需要访问这些抽象接口的情形,单独分离 出 interfaces,非常有必要
  • domain:对应了领域驱动设计的领域层,可以将 repositories 单独分出来,目的是为了更好地体现它在基础设施层扮演的与外部资源打交道的网关语义
  • repositories:代表了领域驱动设计中战术设计阶段的资源库,皆为抽象类型。如果该限界上下文的资源库并不复杂,可以将 repositories 合并到 domain 中
  • infrastructure:对应了领域驱动设计的基础设施层,其下可以视外部资源的集成需求划分不同的包。其中,controllers 相对特殊,它属于对客户端提供接口的北向网关,等同于上下文映射中“开放主机服务(OHS)”的概念。如果为了凸显它的重要性,可以将 controllers 提升到与 application、domain、gateways 同等层次。我之所以将其放在 gateways 之下,还是想体现它的网关本质。persistence 对应了 repositories 抽象,至于其余网关,对应的则是 interfaces 下的抽象,包括消息队列以及与其他限界上下文交互的客户端。例如,通过 http 通信的客户端。其中,client 包下的实现类与 interfaces 下的对应接口组合起来,等同于上下文映射中“防腐层(ACL)”的概念

每个模块或包都是单一职责的设计,扮演着不同的角色,有的对应了分层架构的层,有的代表了领域驱动设计的设计要素,有的则是为了保证架构的松散耦合

进程间通讯代码模型

限界上下文的边界是进程间通信,则意味着每个限界上下文就是一个单独的部署单元,此即微服务的意义
架构的设计需要“恰如其分”,在不同的微服务中,各自的领域逻辑复杂程度亦不尽相同,故而不必严格遵循领域驱动设计的规范

有三种不同的领域建模模式,包括事务脚本(Transaction Script)、表模块(Table Module)或领域模型(Domain Model)
不同建模模式代码结构肯定有所不同,DDD主要关注领域模型模式(选择了事务脚本,领域模型就不一定要规避贫血模型,依赖注入也就未必成为必选项)

订单上下文和通知上下文例子:

由于限界上下文之间采用进程间通信,因此在 Notification Context 中,提供开放主机服务是必须的。倘若 NotificationController 以 RESTful 服务实现,则在 Order Context 发起对 RESTful 服务的调用属于基础设施的内容,因而必须定义 NotificationService 接口来隔离这种实现机制,使其符合整洁架构思想

进程内通讯代码模型

注意如何在代码模型中体现限界上下文的边界,更关键的则是要考虑两个处于相同进程中的限界上下文彼此之间该如何协作
下面是一些主要的考量因素:

  • 简单:在下游限界上下文的领域层直接实例化上游限界上下文的领域类
  • 解耦:在下游限界上下文的领域层通过上游限界上下文的接口和依赖注入进行调用
  • 迁移:在下游限界上下文中定义一个防腐层,而非直接调用
  • 清晰:要保证领域层代码的纯粹性,应该避免在当前限界上下文中依赖不属于自己的代码模型

如果确有迁移可能,且架构师需要追求一种纯粹的清晰架构,可以考虑在 interface 中定义自己的服务接口,然后在 gateway/client 中提供一个适配器,在实现该接口的同时,调用上游限界上下文的服务,无论这个服务是领域服务还是应用服务,甚至也可以是领域层的领域对象

订单上下文和通知上下文例子:

与进程间通信的唯一区别在于:NotificationClient 不再通过跨进程调用的方式发起对 RESTful 服务的调用,即使在 Notification Context 中定义了这样的开放主机服务。如上图所示,NotificationClient 直接通过实例化的方式调用了 Notification Context 应用层的 NotificationAppService。这是在 Order Context 中,唯一与 Notification Context 产生了依赖的地方

如果两个或多个限界上下文还存在共同代码,只能说明一点:那就是我们之前识别的限界上下文有问题!一旦提炼或发现了这个隐藏的限界上下文,就应该将它单列出来,与其他限界上下文享受相同的待遇,即处于代码模型的相同层次,然后再通过 interfaces 与 gateways/client 下的相关类配合完成限界上下文之间的协作即可

代码模型的架构决策

如果我们将限界上下文视为微服务,则该系统的架构风格就是微服务架构风格;果我们将上下文协作模式抽象为发布/订阅事件,则该系统的架构风格就是事件驱动架构风格;如果在限界上下文层面将查询与命令分为两种不同的实现模式,则该系统的架构风格就是命令查询职责分离(CQRS)架构风格

帮助设计元素的粒度和层次的C4模型:

  • 系统上下文:是最高的抽象层次,代表了能够提供价值的东西。一个系统由多个独立的容器构成。
  • 容器:是指一个在其内部可以执行组件或驻留数据的东西。作为整个系统的一部分,容器通常是可执行文件,但未必是各自独立的进程。从容器的角度理解一个软件系统的关键在于,任何容器间的通信可能都需要一个远程接口。(等同微服务概念)
  • 组件:可以想象成一个或多个类组成的逻辑群组。组件通常由多个类在更高层次的约束下组合而成。
  • 类:在一个面向对象的世界里,类是软件系统的最小结构单元

代码模型中公共组件分为两种:1 具有业务价值的作为独立的限界上下文(如规则引擎、消息验证、分析算法);2 非领域一部分,作为公共的基础设施功能,以独立项目依赖包方式进行设计(文件读写、FTP 传输、Telnet 通信等)

一个典型微服务架构:

优先考虑设计成微服务的前提条件:

  • 实现经常变更,导致功能需要频繁升级
  • 采用了完全不一样的技术栈
  • 对高并发与低延迟敏感,需要单独进行水平扩展
  • 是一种端对端的垂直业务体现(即需要与外部环境或资源协作)

单体架构提供一些与业务无关的 RESTful 服务,如健康检查、监控等时,可以采用一个统一RESTful组个多个限界上下文,如下图所示:

支持多终端展示的用户前端方案:

图中为浏览器 UI 调用提供的 UI Layer,即 BFF,它实则是在服务器与浏览器之间增加了一个 Node.js 中间层。各层的职责如下表所示

BFF(Backends For Frontends,为前端提供的后端) 的设计并不在领域驱动战略设计的考虑范围之内

系统架构层面需要的基础设施层方案思考,例如对 Excel 或 CSV 文件的导入导出,消息的发布与订阅、Telnet 通信等。理想状态下,这些公共组件的调用应由属于限界上下文自己的基础设施实现调用。倘若它们被限界上下文的领域对象或应用服务直接调用(即绕开自身的基础设施层),则应该遵循整洁架构思想,在系统架构层引入 interfaces 包,为这些具体实现定义抽象接口。

一个单体架构代码模型示意图:

如果选择微服务架构风格,通常不需要建立一个大一统的代码模型,而是按照内聚的职责将这些职责分别封装到各自的限界上下文中,又或者定义为公共组件以二进制依赖的方式被微服务调用。这些公共组件应该各自构建为单独的包,保证重用和变化的粒度。

如果选择 CQRS 架构风格,就可以在限界上下文的代码模型中为 command 和 query 分别建立 module(领域驱动设计中的设计要素),使得它们的代码模型可以独自演化,毕竟命令和查询的领域模型是完全不同的。

基于质量因素的考虑,我们甚至可以为同一个领域的 command 和 query 各自建立专有的限界上下文。在 command 上下文中,除了增加了 command 类和 event 类以及对应的 handler 之外,遵循前面讲述的限界上下文代码模型,而 query 上下文的领域模型就可以简化,例如直接运用事务脚本或表模块模式。

未经允许不得转载:菡萏如佳人 » DDD领域驱动战略篇(4)

欢迎加入极客江湖

进入江湖关于作者