CQRS架构-《复杂软件设计之道:领域驱动设计全面解析与实战》笔记 - 5

Scroll Down

1. DDD的实现架构

DDD的实现架构有很多种,这些架构都是一种关注点分离模式的实现,也是SOLID单一职责原则的体现,将人们关注的一个职责与其他职责分离,不要试图混合在一起。传统的SOA架构在这方面有很大缺陷,造成了一种单体耦合的架构,虽然这样的大型服务能够实现一定程度的复用和重用,但是在重用和解耦之间需要有一个取舍,在这两者之间如果非选择一个,那么首先选择解耦。通过解耦可以实现更小粒度的重用,虽然这种重用粒度太细小会使得提高生产效率方面的效果不是很明显,但是随着系统的扩展和复杂性的提高,其优点将逐步体现

1.1. 三层架构

常见的三层架构:表现层、应用层和数据层

Untitled

  • 表现层

表现层是与外界进行联系,处理所有传入请求和放回响应的地方,这是MVC模式实现的地方。表现层依赖应用层来执行系统提供的所有功能。表现层仅仅依赖应用层,在React.JS或Vue.JS等浏览器富客户端流行的情况下,表现层已经在浏览器客户端实现,而传统SpringMVC则是在后端服务器实现MVC。

  • 应用层

应用层是开发应用程序提供所有功能的地方,这也是业务逻辑所在,业务领域核心所在,也就是进行所有业务规则验证的地方。应用层仅仅依赖数据层,保存所有的数据供以后使用,或获取某些先前保存的数据,应用层将其计算结果返回表现层。

  • 数据层

数据层,保存所有的数据供以后使用。

在这种分层下,应用层既负责业务决策,也负责业务协调,在复杂在业务变得复杂后,应用层会越来越臃肿。

优点

  • 层是隔离的,每一层可以独立修改。
  • 关注点分离,每个层处理应用程序一个方面。
  • 开发人员熟悉这种简单架构。

缺点

  • 对于简单CURD来说,也需要走整个3层
  • 应用层依赖数据层,所有的业务逻辑都会依赖到数据层的数据库,数据库负载大,而且如果数据库技术进行变更,应用层也需要进行改造。
  • 读写都在一起无法分离读写关注点,无法单独针对查询进行独立优化。

1.2. 传统DDD分层架构

传统DDD架构在传统三层架构基础上增加一个领域层,将数据库层或仓储层作为基础设施层,而应用层则用来协调领域层和基础设施层,因为最终一个功能是需要业务领域决策加上仓储来实现的

Untitled

  • 表现层传入失血模型DTO对象传输数据
  • 应用层委派领域层执行业务决策(可能需要DTO→领域模型类)
  • 应用层委派基础设置层(仓储)进行数据保存

1.3. 清洁(Clean)架构

清洁(Clean)架构是著名软件工程大师RobertC.Martin提出的一种架构整洁清晰之道,也是当前各种语言开发的目标架构。

Untitled

同心圆代表各种不同领域的软件。一般来说,越深入代表软件层次越高。外圆是战术实现机制,内圆是战略核心策略。

此架构能够工作的关键是依赖规则。这条规则规定源代码只能向内依赖,在最里面的部分对外面一点都不知道,也就是内部不依赖外部,而外部则依赖内部。这种依赖包含代码名称、类的函数、变量或任何其他命名软件实体。

同样,在外圆中使用的数据格式不应被内圆中使用,特别是如果这些数据格式由外面一圈的框架生成时。清洁架构不希望任何外圆的东西影响内圆的业务核心

  • 实体:类似DDD中的实体模型,封装业务规则,带有业务方法。
  • 用例(Use Case):类似DDD中的应用服务模型,组合来自实体的数据流程,指导实体使用企业规则来完成用例的功能和目标。
  • 接口适配器(Interface Adapter):本层主要用于将用例和实体中数据转换为外部系统(比如Web)使用的数据。一些传统的Controller层都属于这一层。

使用清洁架构的领域模型有以下特点:

  • 使用纯粹统一的业务语言。如果在一个个有界上下文中到处使用相同的语言、概念和含义,那么业务专家,产品经历,开发就容易达成共识。
  • 需要使用封装方法,只向外界公开有关模型的最少信息,防止业务逻辑泄漏到应用程序逻辑,应用层或用例层与领域层的分离是实战中的难点。

代码结构:

  • Domain 领域层,原来的领域实体
  • usecase 用例层,就是原来的应用层代码
  • interface 接口适配器层 和外界接口适配 service 和 持久层的接口定义在这里 各种数据的转换接口也可以写在这里
  • adapter 适配器层,Controller类 + 持久层实现
  • configuration 放一些配置

1.4. 六边形架构

清洁架构实际总结了六边形架构的特点,其用例和接口适配器类似与六边形架构的适配器。它们的核心都是将业务逻辑和基础设施相分离

Untitled

六边形架构的特点:

  • 只有两个世界,六边形里面是所有的业务模式/逻辑,外面是基础设施,二者通过端口/适配器组件联系。端口根据调用请求方向又分为API和SPI两种。适配器通过软件技术组件来实现业务领域端口和具体技术之间的适配转换。

    API(应用程序编程接口)和SPI(服务提供者接口)的主要区别:API是被调用者、被驱动者,是供外界调用和使用的接口;SPI是主动调用者、主驱动者,驱动调用基础设施或第三方API。SPI可以收集所有由业务领域检索的信息或从第三方获得某些服务所需的接口。

  • 依赖关系始终从外部进入内部,确保了业务域的隔离,如果以后更改基础架构,业务逻辑将可以重写。

  • 六边形内的一切一定不能依赖任何技术框架,包括诸如Jackson或JPA之类的外部注释。

代码结构:

  • Domain 领域层 放领域实体 充血模型

  • application

    这个包下有两个子包

    1. port 六边形的端口接口 放纯接口(各种service,根据输入输出方向不同分为SPI和API)
    2. service 放接口的实现 各种service实现
  • adapter 适配器层

    1. 各种数据类型转换的类型
    2. 子包web 放controller~ dto之类的包也放在这
    3. 子包持久层 放持久层相关的类

异步应用的六边形架构代码结构:

  • domain 领域层 充血模型
  • api 接受调用的api服务类
  • spi 调用其他三方服务或基础设施的代码
  • presentation 与UI有关的WEB类,包括controller等
  • infrastructure 数据库仓储 mysql,kafka等

调用顺序 presentation → api → domain → spi → infrastructure

1.5. 垂直切片架构

垂直切片架构是来自JimmyBogard的CQRS实践总结,它是对清洁架构以及六边形架构的否定。根据这些架构的分层方法,一个业务功能会跨越这些分层执行,当需要增加或修改一个功能时会涉及在这些层内实现多次修改,JimmyBogard的想法是:如果根据功能进行分“层”(称为“片”)则会大大提高开发效率,这种“片”是垂直于分层的:

Untitled

这个思路与微服务的想法不谋而合,微服务按业务进行垂直切分,每个小组单独分担几个微服务

因为一个微服务代表一个有界上下文,垂直切片方式也被用来进行有界上下文的分类,一个垂直切片就可能是一个有界上下文。当然,垂直切片强调的功能粒度非常细腻,而且是根据不同的技术实现进行切片的,并不是根据业务能力进行切片。这种架构的总体原则也是根据SOLID原则中的单一职责,既可以按业务单一职责切分,也可以按技术实现的单一职责切分。

垂直分片架构提出了从请求的功能职责角度进行分片分层的思路,对于不同的请求功能应用不同的策略分层,如果是简单查询,可以直接使用SQL,无须跨越多个抽象层。

1.6. CQRS架构的特点

CQRS(CommandQueryResponsibilitySegregation,命令和查询职责分离)是由GregYoung提出的模式,本质上是一种读写分离的架构

分层设计虽然初衷是为了分离领域和技术,但是过于武断和粗粒度,有时一个简单的查询如果也遵循这种分层,那么维护拓展起来未免过于复杂。

从读写职责角度对请求的功能进行分类。当界面的表单数据提交到后端时,就会有写入表单数据的命令,命令送达聚合模型,将命令中的DTO提取出来,进行业务逻辑检查或计算,聚合中的状态发生改变,发出领域事件,这条路线称为Command模型路线。而另外一种请求则没有这么复杂,搜索只是从搜索库中获取搜索的结果,并没有任何复杂的业务逻辑计算,这时候如果也是使用聚合模型,可能会使得聚合模型的设计需要为搜索方面的功能需求进行添加和修改,这使得领域模型的职责变得复杂了

命令是客户端让服务器做事情,是从客户端向服务器后端发出写入操作命令,通常会改变后端模型的状态;而查询是服务器后端向客户端返回结果。这是两种不同的方向,如果这两种方向涉及的职责耦合在一起,使得领域模型的设计需要兼顾这两种方向,就容易耦合成一个大的上帝式的对象。

Untitled

CQRS源于BertrandMayer设计的命令查询分离(CQS)原理,CQS声明一个类只能有两种方法:改变状态并返回void的方法和返回状态但不改变状态的方法。

根据CQS思想,任何功能可以划分为读取/查询和命令/写入两大功能,写后再读也归为写功能。因此如果将功能粗暴简单地分为读写两种功能,开发团队也可以由此划分为两种:DDD业务逻辑实现和数据报表分析。

CQRS的实现可以分为三个步骤:

  • 拆分查询读取和命令写入。读取是从数据源获取数据,写入则是往数据源写入数据,二者方向相反。
  • 使用不同的数据访问。查询模型可以专门使用缓存等优化的查询数据库,而命令模型则使用自己的写数据库
  • 使用领域事件实现写入数据库和查询数据库之间的同步

以上这三步完成任何一步都可以称为CQRS,第二步和第三步是针对大规模系统的

Untitled

1.6.1. 命令和查询分离

一般情况下,一个聚合模型的CRUD操作总是放在一个领域服务中进行传递协调,现在需要将其分为CUD命令模型服务和读取查询服务两种:

Untitled

根据数据进出的方向分为Command命令模型和Query查询模型两种。这里使用Handler而不是服务描述,突出了Handler作为专门处理命令或查询的一种方式,但是真正进行命令或查询处理的并不是Handler本身,而是它委托给领域模型或查询模型。这里的Handler也是一种命令或查询的传递处理方式,它接受来自前端的请求,这种请求被打包成命令方式。Handler以类似MVC控制器的方式来接受命令或查询(也可以是来自异步消息机制),然后递交给领域模型或查询模型进一步处理。查询模型与领域模型是两种完全不同的模型,领域模型是完全基于DDD原则建立的,而查询模型则是根据数据查询要求来建立的,两种模型的设计依据不同当然基于领域模型实现各种查询也是有好处的。领域模型中提供了业务逻辑规则,这种规则可能对输出查询也是有作用的

关于Command对象

CQRS是命令和查询分离的模式或架构,因此Command命令对象是CQRS的关注重点。命令表达用户的意图,是用户希望计算机系统做想让它做的事情,命令通过发出请求的方式到达计算机系统,通过响应让用户知晓他的命令是否被计算机成功执行

传入领域层的数据都是以命令对象的形式封装的,这样领域层所依赖的输入数据不再是DTO,因为DTO属于技术级别,如果领域模型的输入参数依赖DTO,那么就会造成领域层依赖技术层,这就违背了领域驱动设计的宗旨。

作为CQRS架构实现,领域模型接受的应该是命令,也就是用户的意图表达

CQRS的好处之一是将领域驱动设计和数据驱动设计分离,在查询读取模型中可以使用数据驱动设计,而在命令传入执行的模型路线中必须使用领域驱动设计,因此,从请求的起点开始,沿着请求传入方向,逐个检查请求经过的各个环节,以确保请求数据在传入领域层之前已经被转换为命令对象

当然,这样系统还有很多的类,他们服务于不同的分层,有着不一样的单一的职责:Form服务于表现层、DTO负责在技术级别层次之间传输纯数据、命令服务于领域层、领域模型的实体服务于业务领域和需求、仓储实体服务于数据表、数据表服务于长久保存的目的。

如果用户界面希望显示什么字段,可直接在Form对象中增加,是不是需要放入命令对象,那就要考察这个字段是否属于业务领域范畴,如果这个字段是控制显示方式的,那么它属于应用程序的逻辑,不是业务逻辑,当然不需要放入命令对象;如果属于业务逻辑放入命令对象了,也要考察这个字段对领域模型的影响:为什么设计领域模型时没有考虑这个字段,是否意味着流程改变或分支流程的出现,是否会出现新的有界上下文和聚合(这个影响比较大)。

应用逻辑和业务逻辑的区别需要一定的敏感性,学习领域驱动设计的一个好处就是培养业务逻辑的识别,有了业务逻辑识别,就自然对应用逻辑变得敏感了,这样就能逐渐走上业务与技术分离的架构路线,保证业务逻辑在领域模型中得到不断重构和发展,成为系统的核心资产。

1.6.2. 不同的数据访问方式

  1. 应用CQRS的第一步是将大量服务重构为单独的查询和命令

  2. 实现CQRS的第二步是为查询模型和命令模型使用不同的存储引擎。例如,ElasticSearch用于查询端,JPA/MySQL/Oracle用于命令端,使用MongoDB等NoSQL或Redis缓存用于查询,在命令端的RDBMS中将聚合存储为JSON。当然,不同的存储引擎之间也需要互相同步

    Untitled

  • 命令模型和查询模型之间的同步协作

    同步方式可以考虑以下策略:

    1. 使用Spring中的应用程序事件或使用领域事件在同一事务中同步。
    2. 在命令处理程序中的同一事务中同步。
    3. 异步使用某种内存事件总线,实现最终的一致性,
    4. 异步使用像Kafka/RabbitMQ这样的某种队列中间件,实现最终一致性。

    同步方式的一些最佳实践如下:

    1. 应该为每个界面的屏幕/窗口小部件构建一个表/视图。
    2. 表之间的关系应该是屏幕元素之间关系的模型。
    3. “查看表格”包含屏幕上显示的每个字段的列。
    4. 读取模型不应该进行任何计算,而是在命令模型中计算数据并更新读取模型(除非使用EventSourcing)。
    5. 读模型应存储预先计算的数据。
    6. 不要害怕重复。

    拥有单独的查询数据库表是将CQRS解决方案提升到新水平的一个很好的步骤

  • 规格模式

    如果业务规则的变化和组合很多,包括各种算法或者条件判断,那么这些业务规则就不适合放入实体和值对象,因为这些繁多的变化和组合会掩盖领域对象本身的基本含义,主次不分。可以将它们放入专门的规格(Specification)对象中

    **规格模式(Specification Pattern)可以认为是组合模式的一种扩展。**很多时候程序中的某些条件决定了业务逻辑,这些条件就可以抽离出来以某种关系(与、或、非)进行组合,从而灵活地对业务逻辑进行定制。另外,在查询、过滤等应用场合中,通过预定义多个条件,然后使用这些条件的组合来处理查询或过滤,而不是使用逻辑判断语句来处理,可以简化整个实现逻辑,是一种结构型设计模式

    规格模式是满足某种条件的指定对象:

    Untitled

    规格模式有三种形式:

    1. 用于验证:验证一个对象,看它是否满足某些业务要求,或者是否已经准备就绪,检查状态是否符合要求。
    2. 用于筛选过滤:从一个集合中筛选出符合指定要求的对象。例如,带有指定条件的SQL查询语句。
    3. 按需创建:创建一个对象时指定该对象必须满足某种要求。例如,下订单时,要求厂家按照自己指定的规格生产产品。

    规格模式是由“谓语”升华而来的。谓语可以用AND、OR、NOT来组合和修改,这些逻辑运算对于谓语是封闭的,因此,规格的组合也表现为一种操作封闭性

    UML类图:

    Untitled

1.6.3. 领域事件实现数据同步

基于领域事件实现数据同步也是基于不同的存储数据库,将聚合中发生的事件通过消息发送给查询端,在查询端订阅该领域事件,一旦更改事件送达,就执行这个事件,将其转为查询端的视图结构,这个过程称为投影(Projection)。投影是将事件流转换为结构表示的过程,结构表示有许多其他名称:持久性读取模型、查询模型或视图:

Untitled

这种方式的特点在于命令模型:这里使用EventStore作为持久存储,而不是RDBMS和ORM;不保存实际的对象状态,而是保存事件流。这种模式被命名为事件溯源(EventSourcing),后文讨论。

2. 各种架构总结

从清洁架构、六边形架构到CQRS架构,这些架构都从不同方面关注系统的职责功能。清洁架构和六边形架构是将业务与技术相分离,这是一个大的分离解耦方向,而CQRS则是将业务领域分离为查询和命令两种方式。通过这两种不同方式的切分,DDD领域模型将切实得到隔离和保护,这些领域模型能够不受各自具体技术发展的影响,成为组织内真正的核心资产

3. 参考资料