我们先来简单概括一下DDD最主要要做的事情:
- 将一个大的问题域提炼成一系列更小的子域;
- 最关键的是确定核心子域的功能和职责;
- 核心领域是那些对业务价值更大的领域,需要花费更多精力到上面;
- 与专家合作制定一个分析模型,该模型将提供解决问题的解决方案(领域模型提炼);
- 使用相同的通用语言将分析模型绑定到代码模型中;
- 将模型封闭在边界内(有界上下文)以保护其完整性;
- 维护一个上下文地图以了解所有模型之间的关系。
1 DDD相关概念
为了后续的交流,我们先把DDD相关概念说明下。如果觉得这小节的内容枯燥,可以先大致看看概念的名称,然后跳过该小节。下文涉及到对应概念的时候,再回到这节来查看解释。
领域
:你系统做什么业务的,这个业务就是领域。领域就是一个业务范围,比如购物系统,就是一个领域,领域的核心思想是将问题逐级细分来降低业务和系统的复杂度,这个是DDD讨论的核心;
子域
:把你的服务拆分为多个微服务,每个微服务就是一个子域。领域可以按照业务进一步划分为子领域,把相关性比较高的相关业务概念划分到一个子领域中,降低每个子领域的复杂度;
核心域
:核心服务,在划分子域的过程中,按照子域的重要程度,划分为三类:核心域、通用域、支撑域。决定产品核心竞争力的子域就是核心域。比如手机主打的是性能,那么芯片就是核心域,京东电商是自营模式,那么仓储,供应链,wms就是核心域;
通用域
:中间件服务或者第三方服务,通用域就是你需要用到的通用系统,比如权限系统,认证系统。比如买手机的时候,商家给了你一个袋子装手机,这个袋子就是通用域,因为这个袋子不一定只是用来装手机的;
支撑域
:对于功能来讲是必须存在的域,但是不对产品核心竞争力构成影响,比如一步手机,可以把手机壳、钢化膜作为支撑域来看待。支撑域具有企业特性,但不具有通用性;
统一语言
:处理软件核心中的复杂性”中使用的术语,用于构建由团队,开发人员,领域专家和其他参与者共享的语言。在其中标识表达了业务领域的术语和概念,并且不应该有歧义。如果要建立统一语言,必须了解更多的业务;
限界上下文
:子域职责划分的边界,也就划分微服务的边界,边界划分的合不合理非常重要,否则拆分了微服务的系统可能比不拆分微服务还要混乱;
聚合
:如果某些类实体紧密关联,每次操作其中一个的时候,另外的相关实体也会出现或者受影响,那么就应该把他们放到一起,作为一个聚合,这样也比较容易保证数据的完整性;
聚合根
:每个聚合对象组,都需要选一个与外界交互的实体,这个实体就称为根实体,即聚合跟,一个聚合只有一个聚合根,这样就保证了聚合的内聚性,避免暴露过多细节;
实体
:如果一个对象具在系统中有唯一标识,并且是具有连续性的,不是瞬时对象,那么就可以作为一个实体;
值对象
:如果一个对象在系统中不需要唯一标识,他们通常只是用来作为参数传递,或者承载一组属性,那么就称为值对象。
2 如何更好的划分微服务?
一般的,我们在划分微服务的过程中,就需要用到DDD的思想去提炼领域模型,确定模块边界,最终划分成一个一个的微服务,交由不同的团队进行开发。
而我们的问题就出现在划分微服务之后,如何在接下来的微服务开发过程中,持续的保持领域模型的清晰明了,避免因为新的需求而让项目引入更多复杂度,大部分的微服务项目都在这里遇到了滑铁卢。究其本质原因,还是因为DDD并没有贯彻落实到整个研发流程中。
在网上看到好多次类似这样的文章标题:微服务过时了,赶快抛弃掉吧,来拥抱DDD…
这明显是一种错误的表达方式。
正是DDD的思潮,促进了微服务的发展。但是由于人们没有太过于重视DDD的思想,所以在开发微服务过程中导致项目走偏了。用这句话形容这种问题,会更恰当:没有DDD的微服务,就失去了灵魂。
所以,我们在微服务中需要时刻贯彻落实DDD的思想,才可以让微服务持续的保持它的活力。除此之外,基于DDD的思想制定一套项目的架构规范,系统开发规范也是很重要的,接下来,我们就来讲述这些内容。
3 DDD落地思想
3.1 六边形架构
六边形架构[1]是Alistair Cockburn
发明的,旨在规避面向对象软件设计中的结构缺陷,如:
- 层之间避免依赖性;
- 避免用户界面和业务代码耦合。
六边形的思想是将组件的输入和输出都放在设计的边缘部分,让我们能将组件的核心逻辑和外部关注点隔离开来。核心逻辑隔离之后,意味着我们可以很轻松的修改外部对接细节,而不会造成重大影响或者是进行大量的重构。
如上图,六边形架构将一个系统划分为几个松散耦合的可互换组件,如:应用程序核心(领域层)、数据库、用户界面、测试脚本以及其他系统接口…
端口的粒度及其数量不受限制:
- 在某些情况下,如一个简单的服务消费者,单个端口就够了;
- 在极端情况下,每个用例可能有不同的端口。
每个组件都通过许多暴露的端口连接到其他组件。
适配器是组件与外部世界之间的粘合剂,适配器指定了外部世界和应用程序组件内部端口信息转换的规则。一个端口可以实现多个适配器,如持久化端口,可以实现DB存储适配器和文件存储适配器。
六边形架构优点
- 可以方便的开发出用于测试的适配器,我们在验证业务逻辑时,输入输出适配器很容易进行mock;
- 应用程序和领域模型可以独立开发,不受外部组件的影响(如存储机制、接入方开发进度)
- 应用核心围绕着领域进行持续开发,不需要考虑支撑性的技术组件,如采用何种消息队列,采用何种存储技术等。
六边形架构设计思想
关注点分离
:由于把外部业务,外部依赖技术与核心的领域层隔离开来了,使得领域业务逻辑相对更加稳定;外部依赖技术的调整不影响领域层,开发过程中不受接入方进度的影响;可测试性
:外部任何依赖都可以通过适配器进行模拟,极大的方便了领域内部的测试;
关于“六边形”
因为约定用六边形单元格来表示应用程序组件,所以称为六边形架构。
通过六边形来表示组件,可以有足够的空间来表示组件和外部世界之间接口,并不是指组件有固定6个边界/端口。
Jeffrey Palermo 在 2008 年提出了洋葱架构
,类似于六边形架构:它还通过适当的接口将基础设施外部化,以确保应用程序和数据库之间的松散耦合。通过控制反转,将应用核心进一步分解为几个同心环。
Robert C. Martin在 2012 年提出了整洁架构
,结合了六边形架构、洋葱架构和其他几个变体的原则。下面我们重点来看看整洁架构,这也是我们落地DDD的重要基础。
3.2 整洁架构
Robert C. Martin在对比了很多系统架构的想法(Hexagonal Architecture, Onion Architecture, Screaming Architecture, DCI, BCE…)之后,发现这些架构都非常相似。它们都是通过将软件分层来实现关注点分离。
这些架构构建了具有以下特点的系统:
- 独立于框架,系统不依赖于特定的框架或者软件库,可以方便替换框架;
- 可测试性,即使在没有UI、数据库、Web服务器等外部元素的情况,也可以进行业务规则的测试;
- 系统业务不受用户界面影响,不管UI如何调整,核心的业务规则不受影响;
- 不受限于底层存储技术,也就是说,业务不会绑定到特定的数据库上,可以方便的切换数据库;
- 业务系统与外部任何元素都是隔离的,您的业务规则不受任何外部元素的影响。
具有这种特特点的系统架构,称为整洁架构
,The Clean Architecture[2],其架构组成如下图所示:
(图片来源于: The Clean Architecture[2:1])
依赖规则:如上图所示,只能是外层依赖于内层,内层不能依赖外层,也即:内层不关注任何外层的实现细节,表现为:内层不能使用外层中声明的函数、类、变量或者任何其他命名的软件实体。
3.2.1 层次结构说明
一般包含如下层次:实体、用例、接口适配器、框架和驱动程序。
Entities 实体
实体封装了领域范围内的业务规则,实体可以是带有方法的对接,也可以是一组数据结构和函数。
Use Cases 用例
该层中的软件包含特定于应用程序的业务规则,封装了系统的所有用例,用例通过编排实体来实现。
这一层的变化不会影响实体,同时这一层也不受外部变化的影响,如数据库、UI或其他任何框架。
实体层和用例层共同构成了领域层。
Interface Adapters 接口适配器
该层主要是一组适配器,用于:
- 将内部实体的数据转换为外部需要的格式;
- 将内部实体的数据转换为对正在使用的任何持久化框架最方便的格式;
- 将外部传入进来的数据转换为实体内部锁需要的格式。
Frameworks and Drivers 框架和驱动程序
最外层一般由数控,web框架等工具组成,这一层一般不会做太多的代码开发。
通过将软件分成多层,并遵守上面提到的依赖规则,将构建一个可测试的系统,并且在需要时,随时替换掉任何外部部件,如持久层框架,web层框架,数据库,消息中间件等。
3.3 CQS, CQRS[3]
CQS
我们先来说说CQS,CQS 代表命令查询分离(Command-query separation)。最初由 Bertrand Meyer 在他的Object Oriented Software Construction一书中提出,作为他 Eiffel 编程语言工作的一部分。这个概念背后的核心思想是:
每个方法要么是执行操作的命令,要么是将数据返回给调用者的查询,但不能同时是两者。
换句话说,就是提出问题不应改变答案,就是不要在getXxx的方法里面取执行某个业务。许多应用程序由读写端通用的模型组成。具有相同的读写端模型会导致模型更加复杂,可能很难维护和优化。
CQRS
而CQRS 代表命令查询职责分离(Command Query Responsibility Segregation),它是一种架构模式。它受 CQS 的启发而获得灵感,因为该模式的主要思想基于命令-查询分离。最初这种模式是由 Greg Young 在他的CQRS 文档中[4]提出的。
如上图,由于读操作和写操作是职责分离的,体现了单一职责原则,所以我们完全可以基于业务特性,分别给读写操作使用不同的存储技术。比如写操作还是基于传统的关系型数据库,而读操作可以使用NoSQL数据库来实现。
遵循这一原则的应用使软件设计更清晰,代码更易于阅读和理解。基于CQRS架构的应用程序非常方便处理性能和调优,您可以将系统的读取端与写入端分开优化。写入端通过领域中的行为去实现命令操作,读取端专门用于报表需求。由于查询和命令分离了,因此领域专注于命令的处理,不需要在对外公开内部状态,除了id。而查询端则可以绕过领域对象,直接从数据存储层中获取DTO,该DTO可以封装查询所需要的所有数据,而不受限于领域对象的格式。
基于What is CQRS[5]这篇文章中的案例,更具体的实现可能是这样子的:
命令端:接收命令,交给Command Handler进行处理,命令处理程序执行以下任务:
- 验证命令是否有效;
- 找出聚合aggregate对象群;
- 基于命令参数调用聚合实例的适当参数进行业务处理;
- 将聚合对象群新状态持久化到存储中。
注意,这个案例使用到了事件源模式(Event Sourcing)
,在聚合根做了特定的业务处理之后,生成对应的内部事件
,将事件保存到聚合根对象的未提交的事件列表中,最终通过仓储Repository把所有事件保存到数据库中。
同时,通过把内部事件发布到域外,通过Event Handler把事件应用到查询库。基于事件源模式,查询数据库和写入数据库使用可能是不同的数据库,两者之间数据同步通过领域事件
实现最终一致性。
查询与写入数据库的分离,可以实现专门为各自查询读取而设计特别的数据表结构,专门为查询进行优化。例如,通过宽表来实现查询,比关系型数据库直接查询效率高多了。
查询端:查询端只包含获取数据的方法,绕开领域对象,直接通过读取层读取数据,把结果封装成DTO。读取层可以直接连接到数据库。与数据库直接连接可以使查询非常容易维护和优化,这对数据库的非规范化处理是有意义的。
延伸阅读:
- CQRS. Retrieved from https://martinfowler.com/bliki/CQRS.html
- CQRS架构. Retrieved from https://www.jdon.com/cqrs.html
4 DDD架构指南
前面是一些DDD架构的落地及思想,那么,我们在实际的架构工作中,有什么更具体的落地指南,来让我们把六边形架构,洋葱架构,整洁架构应用到我们的系统中,从而构建一个更加规范的的DDD架构的系统呢?
hgraca
在他的博客[6]中给出了我们答案。作者将这些架构设计理念融合在一起,形成了一种更加全面的DDD架构,并且给出了更加具体的DDD落地指导方案,推荐大家详细阅读这篇原文:DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together[6:1]。
下面我们就来看看(以下部分图片均来自hgraca的博客[6:2])。
4.1 应用,外部接口,基础设施
首先,基于EBI架构和六边形架构,我们可以把一个应用系统划分为如下三个模块:
应用核心(Application Core)
:上图红框内部,系统业务逻辑所在的模块;用户界面(User Interface)
:上图左边部分,用户接口模块,用于构建用户界面;基础设施(Infrastructure)
:系统依赖的技术框架、工具等,如数据库,搜索引擎或者第三方API等。
我们真正关心的就是红框内部的应用核心,这是我们业务代码所处的位置。
至于用户界面,可以有各种不同的表现方式,如Web应用、移动APP、命令行、接口等,但是他们都是请求应用核心完成具体的业务处理。
而基础设施,是支撑我们系统的技术,用的什么技术,是要与业务隔离的,我们随时可以替换底层技术。如,我们把消息队列从RocketMQ替换为Kafka,把存储技术从MySQL替换为TiDB等。
4.2 业务控制流方向
典型的应用控制流,从用户界面触发,请求到应用核心,然后应用核心继续调用需要的基础设施,处理完业务逻辑之后,由从应用核心响应传达给用界面。
4.3 外部工具,主动适配器和被动适配器
在应用核心外部,是很多需要与应用核心交互的外部工具,如数据库、搜索引擎、web服务器,命令控制台等。
这些工具中,有些是告知应用核心要做什么事情的,这种工具一般是用户界面
中的工具,有些是由应用核心告知外部工具的要做什么的,这种工具一般是基础设施
中的工具:
主动适配器
包装应用核心的端口(接口),将主动请求转换为我们应用核心需要的数据格式,从而实现外部工具请求数据与应用核心的适配;- 与主动适配器同,
被动适配器
包装应用核心的端口(接口),将应用核心请求转换为外部工具需要的数据格式,应用核心通过注入这个被动适配器,从而实现应用核心的请求数据与基础设施的适配。
也就是说,被动适配器是要由应用核心来操作的,见名知意。
此时,我们的架构是这样的:
举个例子,好让大家更容易理解:我们编写的Controller就是一个主动适配器,Controller中用到的Service接口就是一个端口;另外,在CQRS架构中,端口还可以是命令总线接口
或者查询总线接口
。
我们未来如果要决定更换基础设施,比如把MySQL替换为MongoDB,只需要创建一个被动适配器来适配MogoDB的接口,然后在应用核心注入的时候,用新的适配器替代旧的适配器就可以了。
有一点需要注意:端口是按照应用核心的需要而设计的,而不是简单的套用外部工具的API,切记,否则应用核心就与外部工具耦合了。
4.4 应用层,领域层,领域服务
基于洋葱架构,我们可以继续对应用核心进行分层。
4.4.1 应用层
业务的用例定义在应用层中,应用层通常用于处理以下这类事务:
- 使用Repository查找一个或者多个领域实体;
- 让这些领域实体执行一些领域逻辑;
- 再次调用Repository让这些领域实体进行持久化。
同时,应用层同时包括了应用事件
的触发,即发送业务处理完成的消息。
4.4.2 领域层
应用层再往内一层就是领域层了。
领域层只包含了数据以及操作数据的逻辑,它们只和领域本身有关,独立于应用层的业务用例。领域层并不清楚一个完整的业务用例。
领域服务
有时候,一些领域逻辑,并不属于同一个领域实体,我们也许可能会把这种逻辑放到应用层中,但是放到应用层中,也就意味着我们不能够复用这些领域逻辑。为此,我们需要创建一个领域服务,主要用于接收一组实体并对它们执行一些业务。
领域服务属于领域层,所以,它们并不了解应用层中的类,请不要把Repository放到领域服务中。
领域模型
我们可以看到,在架构的最中心就是领域模型,领域模型不依赖于任何外部层次的类、方法等元素。它就是一个特定的领域概念对应的业务对象。其中包括实体、值对象、枚举,以及领域模型中用到的任何对象。
领域模型中同时可以触发领域事件
,也就是,实体发生变化之后,就会触发一个领域事件,领域事件包含了发生变化的属性新值。我们可以通过这些事件进行事件溯源。
4.5 组件
如果只是基于以上分层架构来划分代码,那么很容易造成同一层内部的代码混乱,为此按照子域和限界上下文对代码进行划分同样重要,这就是我们通常说的按特性分包或者按组件分包。
不管是应用层,还是领域层,都需要按组件进行分包。按组件分包之后,系统可能是这样的:
我觉得这是非常关键的一个思想,这样分包之后,也就意味着可以更方便的随时拆分微服务。现在的架构就像一个蛋糕,一个一个的组件是切好的每一块蛋糕,每一块蛋糕都有相同的层次,随时都可以单独拿开。
组件之间解耦
为了让组件之间解耦,我们应该不能让组件直接引用任何来自其他组件细粒度的代码单元,甚至是接口。所以依赖倒置和依赖注入对组件解耦来说是不够的,我们还需要更高层次的结构。我们可以通过事件、共享内核、最终一致性、发现服务等来实现组件之间的通信。
触发其他组件的逻辑
在组件之间解耦的前提下,要触发其他组件的逻辑,可以有如下方式:
- 使用事件派发器,通过事件解耦组件;
- 通过发现服务,让组件能够触发其他组件的逻辑,这样会导致组件和发现服务耦合在一起,但是会让组件之间解耦。
从其他组件获取数据
组件不允许修改不属于它的数据,但是可以查询和使用任何数据。
当一个组件需要使用其他组件的数据时,可以在数据存储中查找该数据,但是查出来的其他组件的数据是只读的,不能去修改其他组件的数据。
4.6 系统控制流
我们知道基础的控制流是这样的:从用户侧触发,请求进入应用核心,然后再达到基础设施工具,在返回应用核心,最终返回给用户。
而在这个过程中,具体用到了哪些类,他们之间是如何配合工作的呢?我们通过CQRS架构来说明。
为了给Java架构杂谈
(IT宅 itzhai.com)的文章增加多点易于理解的图片,我根据DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together[6:3]这篇文章中的内容,重新绘制了一个更加清晰的控制流图,如下:
如上图:
- 总线(Bus),命令(Command),查询()Query以及Handler之间是没有依赖关系的,这是因为实际上他们之间只有互相无感知,才能提供足够的解耦。只有通过配置才能设置总线可以发现哪些命令以及查询,进而交由哪个处理程序进程处理;
- Query对象包含优化过的查询,简单的返回给用户看的原始数据,这些数据将封装在DTO中,并注入到ViewModel中,ViewModel中可能有一些View逻辑,最终将ViewModel填充到View中;
- ApplicationService包含了业务用例逻辑,ApplicationService依赖于Repository返回领域实体,这些实体包含了需要出发的逻辑。也可能依赖领域服务来整合多个实体来完成领域流程,但是这种情况比较少;
- 用例完成之后,AppicationService可能向统治整个系统这个用例完成事件,这个时候需要依赖事件派发器来触发事件。
最终,可以发现,所有跨越应用核心边界的依赖方向,都指向了引用核心内部,这也体现了六边形架构、洋葱架构、以及整洁架构的基本原则:
References
Hexagonal architecture (software). Retrieved from https://en.wikipedia.org/wiki/Hexagonal_architecture_(software) ↩︎
The Clean Architecture. Retrieved from https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html ↩︎ ↩︎
CQS, CQRS, Event Sourcing. Retrieved from https://danylomeister.blog/2020/06/25/cqs-cqrs-event-sourcing-whats-the-difference/ ↩︎
CQRS Documents by Greg Young. Retrieved from https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf ↩︎
What is CQRS. Retrieved from https://www.codeproject.com/Articles/555855/Introduction-to-CQRS ↩︎
DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together. Retrieved from https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together/ ↩︎ ↩︎ ↩︎ ↩︎