0%

DDD yyds

image-20210816002928294

“这业务逻辑真奇怪,先上线再说” 两个月后:“这垃圾代码谁写的,谁看的懂?”

为什么要在标题上面标明是干货呢?因为要特别强调下,本文不是广告,就是一篇从入门到上手的DDD教程,让你一看就懂,一学就会。

这篇文章已经写了有一段时间了,一直没发,原因是在尝试落地方案,看看这个DDD是不是真的那么好用,毕竟理论与实际落地之间可能会存在很大的鸿沟的。直到我最近用上瘾了,于是我毫不犹豫的分享给大家了。

你是否会遇到这样的困境:

  • 接手过来的项目跑起来了,但是研究了几天,仍旧不知道它是如何工作的;
  • 项目用了合理的技术框架,也用到了很多设计模式, 并且是使用敏捷流程开发的,但是引入新功能是在太困难了;
  • 跟领域专家、产品们沟通用的是一套专业术语,但是打开项目代码翻看,发现又是另一套术语,甚至我们一个命名为“支付”的方法里面,竟然发现了用优惠券、上架新商品的奇怪逻辑;
  • 项目里面的Service代码几千行,一大把重复代码,每修复一个bug,都可能因为漏修改好几个地方而导致出现新的问题...

DDD就是用于解决此类问题的一种软件设计思想。通过使用DDD,添加新功能将变得更加容易,DDD强制设计人员、开发人员和领域专家一起工作来理解领域概念、策略和逻辑,让开发人员更深入地了解问题领域,好让生产出来的的软件更容易适应未来的发展。而不是通过巧合编程把业务逻辑堆叠在一起,导致出现各种奇奇怪怪的bug...

image-20210816001427087

经常有人会问:DDD会干掉微服务吗?请大家不要想太多了,微服务就是在DDD思潮背景下而发展而来的,新的技术是不可能突然就凭空冒出来干掉旧技术的,况且DDD在微服务出现之前就已经诞生了。

这两年,DDD这个概念极其火爆,本文我就带大家一探究竟。

1. DDD起源

DDD,为 Domain-driven design 领域驱动设计的简写,一句话概括,就是:软件代码的结构应该要和业务领域相匹配。

我们先来看看在软件架构演变历史中,DDD是怎么发展起来的,如下图:

image-20210808114418694

(图片来源于我的博客IT宅(itzhai.com,公众号:Java架构杂谈)的另一篇文章:架构演变之路:为何要搞微服务架构?)

  • 早在60年代就有了OOP,这个时候,人们就开始意识到面向过程的编程模式对于构建大型软件不太友好,于是开始探索以现实世界的对象的方式去思考编程;

  • 直到了1982年,出现了OOAD(面向对象分析和设计),为OOP的落地提供了指导思想,同时作为解决复杂的业务场景的利器;

  • 2000年之后,随着计算机渗透到越来越多的行业,以及互联网的兴起,软件的复杂性增加了,如何应对日趋复杂的软件呢?这个时候,Eric Evans写了一本书:“Domain-driven Design: Tackling Complexity in the Heart of Software” (领域驱动设计-软件核心复杂性应对之道),促使人们开始以业务模型去思考软件的设计。但是,人们并没有立刻认识到领域模型的重要性,反而觉得这种软件思想太过于复杂了,于是DDD继续沉睡;

  • 在2013年,Vaughn Vernon写了一本关于DDD的书籍:“Implementing Domain-Driven Design”(实现领域驱动设计),提供了一个DDD落地的指导思想,让人们对DDD更加触手可及了;

  • 诸如DDDEIP之类的软件设计自2003年左右就已经开始实践起来了,此时一些团队将应用程序开发为模块化服务,并促使了微服务架构的诞生。

    • 微服务架构的产生,需要相关技术的支撑,随后的几年,大家把玩各种微服务技术,玩的不亦乐乎:RPC、服务网关、负载均衡、配置中心、消息中间件、分布式任务调度、服务容错、日志监控、DevOps...似乎掌握了这些技术,就离技术的天花板更进一步了。随着微服务技术的不断发展,人们发现每次升级微服务技术,都要对系统进行大量改造,有什么方法可以把业务代码和技术代码更加彻底的隔离开来呢?

    • 与此同时,随着业务的演变,人们不断痛苦的拆分微服务。一不小心,拆分过细,导致业务开发更加复杂了。这个时候,人们才开始思考:怎么才能更加合理的拆分微服务呢?

终于,大家把目光投向了今天的主角:DDD。

DDD的设计目标:

  • 基于领域模型的复杂设计;
  • 将项目主要重点放在领域和领域逻辑上;
  • 启动技术专家和领域专家之间的创造性合作,不断的迭代改进特定领域问题的概念模型。

基于DDD的设计思想,我们开始把目光回归到软件的核心:业务,开始思考如何让技术更好的为业务服务。

某种程度上来说,面向对象、DDD的思潮,掀起了微服务的狂潮,同时人们在微服务中踩到的技术和服务拆分的坑,促使人们重新意识到DDD的价值。

DDD是万能的吗?

DDD不是银弹,建议将其用于复杂业务,使得可以更好的制定域的通用术语,降低业务的理解难度。

对于简单的业务,DDD不仅不能体现DDD的优势,反而会让开发工作变得更加复杂。

另外,对于探索性的新业务,由于业务模型不能明确下来,也不建议使用DDD,否则会面临不断大力度重构业务领域的问题,导致新业务进展缓慢。在领域知识体系尚未建立的情况下,单体架构可以实现快速开发和迭代。

2. 什么是DDD

DDD (Domain-driven design),领域驱动设计,核心在领域。

什么是领域呢?

我们都知道写代码之前,首先需要梳理业务,然后再做系统设计,不可能让产品经理站在你的身后,一边告诉你业务需求,一边写代码,说一句话,响应写一行代码,我们可以把它称为“响应式编程”。

image-20210808130432857

一般的,我们在写代码之前,会进行需求分析和方案设计。这一步最重要的就是把需求转换为能够落实的技术方案。而设计方案的时候,先要把需求涉及的业务边界搞清楚。

如果我们采用传统的三层架构,基于单体架构快速开发一个电商系统,可能很快就写完了,系统分层架构可能是这样:

image-20210815174414792

然后,我们发现需求越来越多,于是加人呀,一大堆人进来开发同一个系统,由于只有水平的三层结构,导致模块间是耦合在一起的,很可能调整一个底层的dao或者service方法,就导致其他各个模块收到影响;另外,大家一起在一个项目里面写代码,似乎每天都在解决冲突,技术债务开始越来越多...

image-20210815175225800

尝试引入微服务

于是,我们会想起拆分服务,搞成多个微服务,不同的同事在不同的服务上面开发,问题就解决了。

而拆分微服务的过程中,就需要区分业务边界,定义好业务领域的限界上下文。

领域就是一个范围,圈定了一个业务的边界。领域往往是业务的某一个部分,可以称为模块,例如电商系统的,商品模块,订单模块,用户模块,这些模块就是一个一个的领域,领域的关键是领域边界,能不能很好的区分边界,决定了领域模型构建的合理性,领域就是用于解决这个边界范围内的问题域的。

于是我们找到业务同事、产品同事,技术同事一起讨论了好几天,把系统的业务都梳理了一遍,按照高内聚、低耦合的原则,最终把大的系统划分成了几个领域,重新按照新的微服务架构梳理了整个系统的业务流程。然后就开始把单体系统重构为了微服务系统...

这一切看起来很顺利...

拆分完成之后,每个微服务仍旧是三层架构,经过了若干次需求迭代之后,我们惊奇的发现,拆分出来的微服务系统又开始变得越来越复杂了,另外,由于同事们把需求都按照事务脚本的形式填充到了Service层中,导致系统中出现了不少重复或者逻辑相似的代码,每次优化,都要修补很多地方,随着重复的代码越多,bug好像也越顽固,新来的那个同事表示,快要hold不住了...

问题:

  • service不断膨胀,难以维护,找不到业务入口,只能通过rpc方法来查找入口
  • service存在大量重复代码
image-20210808224649482

项目负责人意识到,似乎,又得重新做一次拆分了...

聪明的团队,也许会在接收到新的需求之后,开始讨论会不会有新的领域,需不需要创建新的微服务来搞。但并不是所有的需求都会经过这样的思考过程,不知不觉中,微服务内部逻辑就开始变得复杂起来了。

最终,拆分后的微服务又变成了新的微单体,又得面临拆分的困境。

我们开始思考,微服务的问题出现在哪里了?

最终,我们发现,虽然微服务做了拆分,保证了微服务之间的解耦,但是微服务内部依然是典型的三层架构,而在互联网小步快跑,迭代试错的大环境下,开发同事接到需求,按照业务流程从头到尾就把代码填充到了三层架构中,最终导致微服务快速腐化。而目前互联网项目逐渐深入实体经济,业务日趋复杂,导致服务腐化的越来越快。

其实,我们在构建微服务的过程中,就已经用上了DDD的思想,只不过,并没有把DDD贯彻到整个项目的生命周期中。

除了拆分微服务的过程中需要考虑领域模型,我们在迭代需求的过程中,也需要不断的考虑,并且需要在系统中提供特定的DDD的项目结构,方便开发同事按照项目结构进行填充代码,避免走回三层架构的贫血模型的老路。

接下来,我们先来看看DDD的架构思想,如何让DDD在我们的项目中落地。

3. DDD,再次聚焦业务

我们先来简单概括一下DDD最主要要做的事情:

  • 将一个大的问题域提炼成一系列更小的子域;
    • 最关键的是确定核心子域的功能和职责;
    • 核心领域是那些对业务价值更大的领域,需要花费更多精力到上面;
  • 与专家合作制定一个分析模型,该模型将提供解决问题的解决方案(领域模型提炼);
  • 使用相同的通用语言将分析模型绑定到代码模型中;
  • 将模型封闭在边界内(有界上下文)以保护其完整性;
  • 维护一个上下文地图以了解所有模型之间的关系。

3.1 DDD相关概念

为了后续的交流,我们先把DDD相关概念说明下。如果觉得这小节的内容枯燥,可以先大致看看概念的名称,然后跳过该小节。下文涉及到对应概念的时候,再回到这节来查看解释。

领域:你系统做什么业务的,这个业务就是领域。领域就是一个业务范围,比如购物系统,就是一个领域,领域的核心思想是将问题逐级细分来降低业务和系统的复杂度,这个是DDD讨论的核心;

子域:把你的服务拆分为多个微服务,每个微服务就是一个子域。领域可以按照业务进一步划分为子领域,把相关性比较高的相关业务概念划分到一个子领域中,降低每个子领域的复杂度;

核心域:核心服务,在划分子域的过程中,按照子域的重要程度,划分为三类:核心域、通用域、支撑域。决定产品核心竞争力的子域就是核心域。比如手机主打的是性能,那么芯片就是核心域,京东电商是自营模式,那么仓储,供应链,wms就是核心域;

通用域:中间件服务或者第三方服务,通用域就是你需要用到的通用系统,比如权限系统,认证系统。比如买手机的时候,商家给了你一个袋子装手机,这个袋子就是通用域,因为这个袋子不一定只是用来装手机的;

支撑域:对于功能来讲是必须存在的域,但是不对产品核心竞争力构成影响,比如一步手机,可以把手机壳、钢化膜作为支撑域来看待。支撑域具有企业特性,但不具有通用性;

统一语言:处理软件核心中的复杂性”中使用的术语,用于构建由团队,开发人员,领域专家和其他参与者共享的语言。在其中标识表达了业务领域的术语和概念,并且不应该有歧义。如果要建立统一语言,必须了解更多的业务;

限界上下文:子域职责划分的边界,也就划分微服务的边界,边界划分的合不合理非常重要,否则拆分了微服务的系统可能比不拆分微服务还要混乱;

聚合:如果某些类实体紧密关联,每次操作其中一个的时候,另外的相关实体也会出现或者受影响,那么就应该把他们放到一起,作为一个聚合,这样也比较容易保证数据的完整性;

聚合根:每个聚合对象组,都需要选一个与外界交互的实体,这个实体就称为根实体,即聚合跟,一个聚合只有一个聚合根,这样就保证了聚合的内聚性,避免暴露过多细节;

实体:如果一个对象具在系统中有唯一标识,并且是具有连续性的,不是瞬时对象,那么就可以作为一个实体;

值对象:如果一个对象在系统中不需要唯一标识,他们通常只是用来作为参数传递,或者承载一组属性,那么就称为值对象。

3.2 如何更好的划分微服务?

一般的,我们在划分微服务的过程中,就需要用到DDD的思想去提炼领域模型,确定模块边界,最终划分成一个一个的微服务,交由不同的团队进行开发。

而我们的问题就出现在划分微服务之后,如何在接下来的微服务开发过程中,持续的保持领域模型的清晰明了,避免因为新的需求而让项目引入更多复杂度,大部分的微服务项目都在这里遇到了滑铁卢。究其本质原因,还是因为DDD并没有贯彻落实到整个研发流程中。

在网上看到好多次类似这样的文章标题:微服务过时了,赶快抛弃掉吧,来拥抱DDD...

image-20210815175601517

这明显是一种错误的表达方式。

正是DDD的思潮,促进了微服务的发展。但是由于人们没有太过于重视DDD的思想,所以在开发微服务过程中导致项目走偏了。用这句话形容这种问题,会更恰当:没有DDD的微服务,就失去了灵魂

所以,我们在微服务中需要时刻贯彻落实DDD的思想,才可以让微服务持续的保持它的活力。除此之外,基于DDD的思想制定一套项目的架构规范,系统开发规范也是很重要的,接下来,我们就来讲述这些内容。

3.3 DDD落地思想

3.3.1 六边形架构

六边形架构[1]Alistair Cockburn发明的,旨在规避面向对象软件设计中的结构缺陷,如:

  • 层之间避免依赖性;
  • 避免用户界面和业务代码耦合。

六边形的思想是将组件的输入和输出都放在设计的边缘部分,让我们能将组件的核心逻辑和外部关注点隔离开来。核心逻辑隔离之后,意味着我们可以很轻松的修改外部对接细节,而不会造成重大影响或者是进行大量的重构。

image-20210808163022487

如上图,六边形架构将一个系统划分为几个松散耦合的可互换组件,如:应用程序核心(领域层)、数据库、用户界面、测试脚本以及其他系统接口...

端口的粒度及其数量不受限制:

  • 在某些情况下,如一个简单的服务消费者,单个端口就够了;
  • 在极端情况下,每个用例可能有不同的端口。

每个组件都通过许多暴露的端口连接到其他组件。

适配器是组件与外部世界之间的粘合剂,适配器指定了外部世界和应用程序组件内部端口信息转换的规则。一个端口可以实现多个适配器,如持久化端口,可以实现DB存储适配器和文件存储适配器。

六边形架构优点

  • 可以方便的开发出用于测试的适配器,我们在验证业务逻辑时,输入输出适配器很容易进行mock;
  • 应用程序和领域模型可以独立开发,不受外部组件的影响(如存储机制、接入方开发进度)
  • 应用核心围绕着领域进行持续开发,不需要考虑支撑性的技术组件,如采用何种消息队列,采用何种存储技术等。

六边形架构设计思想

  • 关注点分离:由于把外部业务,外部依赖技术与核心的领域层隔离开来了,使得领域业务逻辑相对更加稳定;外部依赖技术的调整不影响领域层,开发过程中不受接入方进度的影响;
  • 可测试性:外部任何依赖都可以通过适配器进行模拟,极大的方便了领域内部的测试;

关于“六边形”

因为约定用六边形单元格来表示应用程序组件,所以称为六边形架构。

通过六边形来表示组件,可以有足够的空间来表示组件和外部世界之间接口,并不是指组件有固定6个边界/端口。

Jeffrey Palermo 在 2008 年提出了洋葱架构,类似于六边形架构:它还通过适当的接口将基础设施外部化,以确保应用程序和数据库之间的松散耦合。通过控制反转,将应用核心进一步分解为几个同心环。

Robert C. Martin在 2012 年提出了整洁架构,结合了六边形架构、洋葱架构和其他几个变体的原则。下面我们重点来看看整洁架构,这也是我们落地DDD的重要基础。

3.3.2 整洁架构

Robert C. Martin在对比了很多系统架构的想法(Hexagonal Architecture, Onion Architecture, Screaming Architecture, DCI, BCE...)之后,发现这些架构都非常相似。它们都是通过将软件分层来实现关注点分离

这些架构构建了具有以下特点的系统:

  • 独立于框架,系统不依赖于特定的框架或者软件库,可以方便替换框架;
  • 可测试性,即使在没有UI、数据库、Web服务器等外部元素的情况,也可以进行业务规则的测试;
  • 系统业务不受用户界面影响,不管UI如何调整,核心的业务规则不受影响;
  • 不受限于底层存储技术,也就是说,业务不会绑定到特定的数据库上,可以方便的切换数据库;
  • 业务系统与外部任何元素都是隔离的,您的业务规则不受任何外部元素的影响。

具有这种特特点的系统架构,称为整洁架构,The Clean Architecture[2],其架构组成如下图所示:

image-20210808170303832

(图片来源于: The Clean Architecture[2:1])

依赖规则:如上图所示,只能是外层依赖于内层,内层不能依赖外层,也即:内层不关注任何外层的实现细节,表现为:内层不能使用外层中声明的函数、类、变量或者任何其他命名的软件实体。

3.3.2.1 层次结构说明

一般包含如下层次:实体、用例、接口适配器、框架和驱动程序。

Entities 实体

实体封装了领域范围内的业务规则,实体可以是带有方法的对接,也可以是一组数据结构和函数。

Use Cases 用例

该层中的软件包含特定于应用程序的业务规则,封装了系统的所有用例,用例通过编排实体来实现。

这一层的变化不会影响实体,同时这一层也不受外部变化的影响,如数据库、UI或其他任何框架。

实体层和用例层共同构成了领域层。

Interface Adapters 接口适配器

该层主要是一组适配器,用于:

  • 将内部实体的数据转换为外部需要的格式;
  • 将内部实体的数据转换为对正在使用的任何持久化框架最方便的格式;
  • 将外部传入进来的数据转换为实体内部锁需要的格式。
Frameworks and Drivers 框架和驱动程序

最外层一般由数控,web框架等工具组成,这一层一般不会做太多的代码开发。

通过将软件分成多层,并遵守上面提到的依赖规则,将构建一个可测试的系统,并且在需要时,随时替换掉任何外部部件,如持久层框架,web层框架,数据库,消息中间件等。

3.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]提出的。

image-20210815111412438

如上图,由于读操作和写操作是职责分离的,体现了单一职责原则,所以我们完全可以基于业务特性,分别给读写操作使用不同的存储技术。比如写操作还是基于传统的关系型数据库,而读操作可以使用NoSQL数据库来实现。

遵循这一原则的应用使软件设计更清晰,代码更易于阅读和理解。基于CQRS架构的应用程序非常方便处理性能和调优,您可以将系统的读取端与写入端分开优化。写入端通过领域中的行为去实现命令操作,读取端专门用于报表需求。由于查询和命令分离了,因此领域专注于命令的处理,不需要在对外公开内部状态,除了id。而查询端则可以绕过领域对象,直接从数据存储层中获取DTO,该DTO可以封装查询所需要的所有数据,而不受限于领域对象的格式。

基于What is CQRS[5]这篇文章中的案例,更具体的实现可能是这样子的:

image-20210815160551350

命令端:接收命令,交给Command Handler进行处理,命令处理程序执行以下任务:

  • 验证命令是否有效;
  • 找出聚合aggregate对象群;
  • 基于命令参数调用聚合实例的适当参数进行业务处理;
  • 将聚合对象群新状态持久化到存储中。

注意,这个案例使用到了事件源模式(Event Sourcing),在聚合根做了特定的业务处理之后,生成对应的内部事件,将事件保存到聚合根对象的未提交的事件列表中,最终通过仓储Repository把所有事件保存到数据库中。

同时,通过把内部事件发布到域外,通过Event Handler把事件应用到查询库。基于事件源模式,查询数据库和写入数据库使用可能是不同的数据库,两者之间数据同步通过领域事件实现最终一致性。

查询与写入数据库的分离,可以实现专门为各自查询读取而设计特别的数据表结构,专门为查询进行优化。例如,通过宽表来实现查询,比关系型数据库直接查询效率高多了。

查询端:查询端只包含获取数据的方法,绕开领域对象,直接通过读取层读取数据,把结果封装成DTO。读取层可以直接连接到数据库。与数据库直接连接可以使查询非常容易维护和优化,这对数据库的非规范化处理是有意义的。

延伸阅读:

3.4 DDD架构指南

前面是一些DDD架构的落地及思想,那么,我们在实际的架构工作中,有什么更具体的落地指南,来让我们把六边形架构,洋葱架构,整洁架构应用到我们的系统中,从而构建一个更加规范的的DDD架构的系统呢?

hgraca在他的博客[6]中给出了我们答案。作者将这些架构设计理念融合在一起,形成了一种更加全面的DDD架构,并且给出了更加具体的DDD落地指导方案,推荐大家详细阅读这篇原文:DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together[6:1]

下面我们就来看看(以下部分图片均来自hgraca的博客[6:2])。

3.4.1 应用,外部接口,基础设施

首先,基于EBI架构和六边形架构,我们可以把一个应用系统划分为如下三个模块:

image-20210809222119394

  • 应用核心(Application Core):上图红框内部,系统业务逻辑所在的模块;
  • 用户界面(User Interface):上图左边部分,用户接口模块,用于构建用户界面;
  • 基础设施(Infrastructure):系统依赖的技术框架、工具等,如数据库,搜索引擎或者第三方API等。

我们真正关心的就是红框内部的应用核心,这是我们业务代码所处的位置。

至于用户界面,可以有各种不同的表现方式,如Web应用、移动APP、命令行、接口等,但是他们都是请求应用核心完成具体的业务处理。

而基础设施,是支撑我们系统的技术,用的什么技术,是要与业务隔离的,我们随时可以替换底层技术。如,我们把消息队列从RocketMQ替换为Kafka,把存储技术从MySQL替换为TiDB等。

3.4.2 业务控制流方向

典型的应用控制流,从用户界面触发,请求到应用核心,然后应用核心继续调用需要的基础设施,处理完业务逻辑之后,由从应用核心响应传达给用界面。

image-20210809222930842

3.4.3 外部工具,主动适配器和被动适配器

在应用核心外部,是很多需要与应用核心交互的外部工具,如数据库、搜索引擎、web服务器,命令控制台等。

这些工具中,有些是告知应用核心要做什么事情的,这种工具一般是用户界面中的工具,有些是由应用核心告知外部工具的要做什么的,这种工具一般是基础设施中的工具:

  • 主动适配器包装应用核心的端口(接口),将主动请求转换为我们应用核心需要的数据格式,从而实现外部工具请求数据与应用核心的适配;
  • 与主动适配器同,被动适配器包装应用核心的端口(接口),将应用核心请求转换为外部工具需要的数据格式,应用核心通过注入这个被动适配器,从而实现应用核心的请求数据与基础设施的适配。

也就是说,被动适配器是要由应用核心来操作的,见名知意。

此时,我们的架构是这样的:

image-20210809224828933

举个例子,好让大家更容易理解:我们编写的Controller就是一个主动适配器,Controller中用到的Service接口就是一个端口;另外,在CQRS架构中,端口还可以是命令总线接口或者查询总线接口

我们未来如果要决定更换基础设施,比如把MySQL替换为MongoDB,只需要创建一个被动适配器来适配MogoDB的接口,然后在应用核心注入的时候,用新的适配器替代旧的适配器就可以了。

有一点需要注意:端口是按照应用核心的需要而设计的,而不是简单的套用外部工具的API,切记,否则应用核心就与外部工具耦合了。

3.4.4 应用层,领域层,领域服务

基于洋葱架构,我们可以继续对应用核心进行分层。

image-20210809232706459

3.4.4.1 应用层

业务的用例定义在应用层中,应用层通常用于处理以下这类事务:

  • 使用Repository查找一个或者多个领域实体;
  • 让这些领域实体执行一些领域逻辑;
  • 再次调用Repository让这些领域实体进行持久化。

同时,应用层同时包括了应用事件的触发,即发送业务处理完成的消息。

3.4.4.2 领域层

应用层再往内一层就是领域层了。

领域层只包含了数据以及操作数据的逻辑,它们只和领域本身有关,独立于应用层的业务用例。领域层并不清楚一个完整的业务用例。

领域服务

有时候,一些领域逻辑,并不属于同一个领域实体,我们也许可能会把这种逻辑放到应用层中,但是放到应用层中,也就意味着我们不能够复用这些领域逻辑。为此,我们需要创建一个领域服务,主要用于接收一组实体并对它们执行一些业务。

领域服务属于领域层,所以,它们并不了解应用层中的类,请不要把Repository放到领域服务中。

领域模型

我们可以看到,在架构的最中心就是领域模型,领域模型不依赖于任何外部层次的类、方法等元素。它就是一个特定的领域概念对应的业务对象。其中包括实体、值对象、枚举,以及领域模型中用到的任何对象。

领域模型中同时可以触发领域事件,也就是,实体发生变化之后,就会触发一个领域事件,领域事件包含了发生变化的属性新值。我们可以通过这些事件进行事件溯源。

3.4.5 组件

如果只是基于以上分层架构来划分代码,那么很容易造成同一层内部的代码混乱,为此按照子域和限界上下文对代码进行划分同样重要,这就是我们通常说的按特性分包或者按组件分包。

不管是应用层,还是领域层,都需要按组件进行分包。按组件分包之后,系统可能是这样的:

image-20210810085049572

我觉得这是非常关键的一个思想,这样分包之后,也就意味着可以更方便的随时拆分微服务。现在的架构就像一个蛋糕,一个一个的组件是切好的每一块蛋糕,每一块蛋糕都有相同的层次,随时都可以单独拿开。

组件之间解耦

为了让组件之间解耦,我们应该不能让组件直接引用任何来自其他组件细粒度的代码单元,甚至是接口。所以依赖倒置和依赖注入对组件解耦来说是不够的,我们还需要更高层次的结构。我们可以通过事件、共享内核、最终一致性、发现服务等来实现组件之间的通信。

触发其他组件的逻辑

在组件之间解耦的前提下,要触发其他组件的逻辑,可以有如下方式:

  • 使用事件派发器,通过事件解耦组件;
  • 通过发现服务,让组件能够触发其他组件的逻辑,这样会导致组件和发现服务耦合在一起,但是会让组件之间解耦。
从其他组件获取数据

组件不允许修改不属于它的数据,但是可以查询和使用任何数据。

当一个组件需要使用其他组件的数据时,可以在数据存储中查找该数据,但是查出来的其他组件的数据是只读的,不能去修改其他组件的数据。

3.4.6 系统控制流

我们知道基础的控制流是这样的:从用户侧触发,请求进入应用核心,然后再达到基础设施工具,在返回应用核心,最终返回给用户。

而在这个过程中,具体用到了哪些类,他们之间是如何配合工作的呢?我们通过CQRS架构来说明。

为了给Java架构杂谈(IT宅 itzhai.com)的文章增加多点易于理解的图片,我根据DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together[6:3]这篇文章中的内容,重新绘制了一个更加清晰的控制流图,如下:

image-20210810230619037

如上图:

  • 总线(Bus),命令(Command),查询()Query以及Handler之间是没有依赖关系的,这是因为实际上他们之间只有互相无感知,才能提供足够的解耦。只有通过配置才能设置总线可以发现哪些命令以及查询,进而交由哪个处理程序进程处理;
  • Query对象包含优化过的查询,简单的返回给用户看的原始数据,这些数据将封装在DTO中,并注入到ViewModel中,ViewModel中可能有一些View逻辑,最终将ViewModel填充到View中;
  • ApplicationService包含了业务用例逻辑,ApplicationService依赖于Repository返回领域实体,这些实体包含了需要出发的逻辑。也可能依赖领域服务来整合多个实体来完成领域流程,但是这种情况比较少;
  • 用例完成之后,AppicationService可能向统治整个系统这个用例完成事件,这个时候需要依赖事件派发器来触发事件。

最终,可以发现,所有跨越应用核心边界的依赖方向,都指向了引用核心内部,这也体现了六边形架构、洋葱架构、以及整洁架构的基本原则:

image-20210811062142240

4. 开始构建你的DDD架构

有没有完美的DDD落地方案呢?这取决于业务上下文,取决于实际的问题。

不同的业务系统,面临的业务挑战有所区别,架构也就各有不同,但是跟随着DDD的指导思想去落地实践,那么我们也就可以从中获取到DDD架构的益处,真正的给微服务注入灵魂了。

构建DDD架构,最核心的就是找出领域模型,以及区分领域的边界。为了确定领域模型,我们需要一个探索性的挖掘过程。

为了能够给业务领域建模,我们需要通过一些方法去探索,常见的方法有事件风暴建模,问题空间与解决空间,下面我们进行介绍。

4.1 问题空间与解决空间[7]

我们可以通过引入问题空间,进行设计得出解决空间,得到最终更多领域模型,如下图所示,首先是问题空间:

image-20210811224349037

领域专家和研发团队一起基于要解决的问题和业务愿景进行讨论,并核对相关领域知识,在此过程中与业务专家建立一个统一语言,用于后续的沟通。

为什么要建立统一语言?

如果没有建立统一语言,那么接下来的需求讨论会产生很多歧义,而且极不利于新同事熟悉代码,因为在基于领域专家的语言了解了需求之后,发现代码里面又是另一种表达方式。

举个例子:

  • 可能开发同同事没有在代码里面建立洗碗这个概念,于是每次要触发洗碗的时候,代码里面都是这样过程式的表达:拿起一个碗,加点洗洁精刷呀刷呀刷呀,冲一下水,滤干水...这就是我们所说的重复代码。而如果开发知道洗碗这个概念,就会把这个过程封装到一个方法里面,这样每次看到这个方法调用,就知道是洗碗了,不用阅读过程式的代码,这样也就增加了代码的可读性。另外,有了这个方法,我们也就更好的把这个方法划分到具体的问题子域中进行分析了;
  • 一个香港来的程序员与内地来的领域专家探讨怎么做草莓蛋糕,但是这个香港的程序员每次提到草莓的时候,都称为"士多啤梨"(香港一般把草莓按英文发音称呼为士多啤梨),最终导致他们很难沟通下去。而当他们讨论球星的时候,领域专家根本不认识这个程序员口中的“朗拿度”、“施丹”、“碧咸”、“美斯”了,尽管这些都是赫赫有名的球星。当然要是用英文沟通就不存在这些问题了。

然后基于领域知识,把大的问题域进行细分,然后提取成为一个一个的子域,当细分到一定的程度后,会将问题限定在特定的边界内,这个边界内也就是我们所说的限界上下文,最终在这个边界内建立领域模型。DDD的领域就是这个边界内要解决的业务问题域。

而在这个过程中,有几种方法可以辅助我们建立领域模型,下面一一介绍。

4.2 事件风暴法

事件风暴(EventStorming),首次于2013年11月18日在Ziobrando's Lair的博客中被提出。详细的博文大家可以移步阅读:Introducing Event Storming[8]

事件风暴是一种基于研讨会的形式,用于快速找出软件程序领域中发生的事情,探索复杂的业务领域。结果以宽墙上的便利贴表示。业务流程“一涌而出(stormed out)”作为一系列域事件,这些事件表示为橙色。选择这个名称是为了表明重点应该放在领域事件上,并且该方法的工作方式类似于头脑风暴或敏捷建模的模型风暴。

事件风暴可以使我们能够在数小时而不是数周内提出完整业务流程的综合模型。研讨过程中,将提出问题的人和参与讨论问题的人组织到一起,通过简单的符号进行构建模型,期间不会使用复杂的UML,避免远离问题讨论的核心。

以下是大致的工作步骤:

  • 邀请适合的人参与,提前准备好要讨论的问题;对于事件风暴研讨会来说,让合适的人在场非常重要。这包括知道要问什么问题的人(通常是开发人员)和知道答案的人(领域专家、产品负责人);
  • 提供一个足够大的表述白板空间,避免因为没有足够可用更多空间来查看复杂的问题,而最终导致问题没有被正确分析;
  • 从领域事件开始探索领域
  • 探索领域事件的起源,某些是UI操作的结果,我们可以用蓝色便签表示为命令,某些是外部系统处理事件的结果,我们可以使用紫色的便签进行表示;
  • 寻找聚合,聚合是系统的一部分,他接收命令并且决定是否执行它们,从而产生域事件。

在白板上,我们一步一步的添加便签[9]

  1. 创建领域事件,用橙色便签表示
  2. 添加引起域事件的命令,用蓝色便签表示
  3. 添加执行命令的actor
  4. 添加相应的聚合,用黄色便签表示

image-20210814105913246

最终得到如下的视图:

image-20210814105948425

(标签图片来源于:Event storming[9:1])

更进一步的,你可以探讨聚合内部的子域,如果发现冲突领域,这个时候可以进一步讨论术语不同解释的意图,最终达成一致,划清领域界限。

更多的参与对象表示方法:

  • 🟠 领域事件:业务流程中发生的时间,用过去时表达;
  • 🟡 用户:通过视图执行命令的人;
  • 🟣 业务流程:根据业务规则和逻辑处理命令,创建一个或者多个领域事件;
  • 🔵 命令:用户通过聚合视图执行的命令,导致创建领域事件;
  • 🟡 聚合:一组域对象共同组成一个单一的业务单元;
  • 🌸 外部系统:第三方服务提供商;
  • 🟢 视图:用户与之交互以在系统中执行任务的视图。

当然,最终,你可以根据讨论结果,输出一个完整的思维导图或者表格,然后进行分工开发。表格可以类似如下格式:

业务域 领域模型 聚合 领域对象 领域类型(聚合跟/命令/应用服务/领域服务实体/值对象/仓储接口...) 命名
... ... ... ... ...

更多实战实例参考:

4.3 四色建模法

四色建模法(Object Modeling in Color)[10],首次由Peter Coad, Eric LefebvreJeff De Luca等人在 The Coad Letter[11] 的一系列文章中提出,后来发表在他们的著作 Java Modeling In Color With UML

在无数的领域模型中,很明显的四种主要类型一次又一次的出现,尽管他们在不同的领域有不同改动名词,最终我们通过四种颜色来代表着以下四种类型的领域对象。

四色建模可以用这句话进行描述:某人(Party, PartyRole角色)在某个地点(Place, PlaceRole角色)用某个东西(Thing, ThingRole角色)做了某件事情(MomentInterval)

4.3.1 🌸 时标型(moment-interval)对象

表示在某个时刻或某一段时间内发生的某个活动。使用粉红色表示,简写为MI。‘这种对象往往表示在某一个时间点,一次外界的请求,产生的一个对象。例如,下单操作产生的订单对象。

这些对象是系统的价值所在,所以也是最重要的一类对象,我们用粉红色表示。

这种对象一般都有一个生命周期的起止时间,以及一个唯一标识

这类对象有两个特点:

  • 事实不可变性,记录过去某个时间发生的事实;
  • 责任可追溯性,记录管理者关注的信息。

比如,某个机构创建了一个户外活动,小马报名参加户外活动,最终产生事实:户外活动报名登记记录,这个户外活动和报名登记记录就是一个时标型对象。

image-20210814193413048

更进一步的,我们可以按照时间发展顺序,列出所有的时标对象。

4.3.2 🟢 PPT(party, place, or thing)对象

表示参与某个活动的参与方(Part)或者参与物(Thing),活动发生的地点(Place),用绿色表示。

这些对象往往是客观存在的,如人,地点,产品等,这些对象往往在Moment-interval中扮演某个角色,比如同一个人,在学校就是一个学生或者老师,在运动场上,就是一个运动员或者教练。

比如,小马报名参加户外活动,这个户外活动客观存在的事物有:

  • 参与方:用户,机构;
  • 地点:户外活动地点,比如 深圳湾公园。

image-20210814195117170

4.3.3 🟡 角色(roles)对象

角色代表在这个moment-interval中涉及的人或者物的身份,用黄色表示,一般一个moment-interval会有多个角色共同参与。

image-20210814195215082

4.3.4 🔵 描述(description)对象

表示对PPT对象以及时标型对象的描述,体现为一组属性集合,使用蓝色表示。

image-20210814195302828

这样,我们就可以设计出相关的UML类图了。

许多人觉得有色物体对大脑的模式识别部分很有吸引力。其他人则主张您可以用一叠四色便签卡或彩色便签开始建模过程。为此,我们把这四种类型的领域对象进行不同的着色。

有些观点认为,领域建模最终不过就是使用UML画类图罢了,直接把关系型数据表换成类图就可以了,类图和数据库表似乎是一样的。其实不然,数据库模型是静态的,而类图是动态的,同样的一个数据库结构,可以通过多种不同的对象模型去表示,通过不同的领域建模方法,就可以找出最合理的对象模型。

更多实战实例参考:

4.4 限界笔纸法

四色建模法,通过事件的发展顺序,把业务核心数据模型建好了。

更进一步我们可以基于限界笔纸法[12],进一步做一下建模工作:

  • 划分限界上下文,划分子域,避免模型发展成大泥球架构;
  • 确定聚合中的聚合根,保证数据完整性,并且统一通过聚合根访问管理内部实体,保证了清晰的职责范围;
  • 降低模型复杂度?

主要做法就是:

  • 给时标型对象分类,描述这些分类的业务价值,确定核心领域;
  • 确定核心领域之间的依赖关系;
  • 列出核心领域的详细数据;
  • 选定一个合理的具有唯一标识的实体作为聚合跟;
  • 如果一些属性总是一起出现,那么尝试提取他们,作为新的实体或者值对象。

详细的使用案例,参考:

4.5 DDD系统架构

4.5.1 DDD项目结构

基于以上DDD架构的思想,我们可以把我们的系统划分为如下层次结构,具体代码结构可以如下,仅供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
itzhai-service
|-- itzhai-code 公共模块代码
| |
| |-- java
| |-- domain 领域通用类
| |-- utils 工具类
|
|-- itzhai-boot 启动模块
| |
| |- java
| | |- Application 系统启动类
| | |- config 配置类
| |- resources
| |- application.yml 配置文件
| |- ...
|
|- itzhai-module1 系统子域模块
| |
| |- java
| |- application 应用层
| | |- command 命令
| | |- handler 命令处理类
| | |- query 查询处理类
| |- domain 领域层
| | |- factory 领域工厂
| | |- model 领域模型
| | |- service 领域服务
| | |- specification 规格校验类
| | |- repository 仓储接口
| | |- external 外部接口(防腐层)
| |- infrastructure 基础设施层
| | |-repository 仓储实现类
| | |-po 持久层对象
| | |-mapper 持久层对象与领域对象的转换工具类
| | |- dao 数据访问接口
| | |- external.impl 被动适配器,适配外部接口
| |- activeadapter 主动适配器,提供rpc,rest等接口
|
|- itzhai-module2 系统子域模块
|
...
|- pom.xml

一旦确定项目结构之后,就需要作为规范,让其他同事遵守。这些目录就像是一个一个的收纳盒,可以把代码放到合理的位置,同时,由于该目录遵循整洁架构思想,保证了核心领域代码与外部接口或者基础设施的解耦。

4.5.2 DDD设计要素

在DDD系统设计与开发过程中,关键的要点:

  • 像《领域驱动设计》一书中讲的那样,跟系统所有的参与者(产品、业务领域专家、运营、开发、测试等)形成一套通用语言,这套语言既是在你的代码的类名、方法名等的命名来源;
  • 你应该首先在需求中分析领域模型,设计领域模型的边间与核心逻辑,以及领域的聚合设计,而不是直接上来就写MySQL建表脚本,创建表结构;
  • 尝试在流程图中加上泳道,按照领域模型划分泳道,而不是基于已有的微服务,这样可以避免写出事务过程式的脚本代码;也许序列图更能够帮助到你理清交互关系中的技术细节。

4.5.3 DDD不是银弹[7:1]

同时,你需要认清这个事实,避免让DDD在项目中遭遇失败:

  • 设计模式不是DDD的关键:DDD 与其说是软件设计模式,不如说是通过协作解决问题;
  • DDD不是以代码为中心的:DDD不会帮你写出优雅的代码,代码只是DDD的产物;
  • DDD不是一个框架:DDD没有固定的框架,它只是一种设计思想,对于同一个DDD项目的不同限界上下文,你完全可以使用不同的架构风格;
  • 并不是所有的系统适用DDD:那些具有很少问题域的系统,也就是业务很简单的系统,不适合使用DDD,因为使用了之后反而会拖慢系统进程。

一般我们看到的比较成功的DDD项目的的代码都是是经过几次迭代之后才产生的,所以在探索业务阶段不适合DDD...

如果你看到了一个DDD的Demo程序之后,就匆忙的想立刻依葫芦画瓢把自己的业务系统改造成DDD,那和可能会面临很多没有考虑到的问题,因为一般Demo程序都很简单,并不能对业务开发过程中的所有问题都提出解决方案。

有没有完美的DDD框架?据我所知,并没有,DDD最重要的是理解业务,达成共识,然后基于业务通过各种方法构建领域模型。业务之外,你可以做的就是提供一个规范,让大家按照规范填代码,好让代码朝着整洁架构方向迭代。

朋友们,都学会了吗?现在,让我们开始动手,实现我们自己的DDD系统吧。

References


  1. Hexagonal architecture (software). Retrieved from https://en.wikipedia.org/wiki/Hexagonal_architecture_(software) ↩︎

  2. The Clean Architecture. Retrieved from https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html ↩︎ ↩︎

  3. CQS, CQRS, Event Sourcing. Retrieved from https://danylomeister.blog/2020/06/25/cqs-cqrs-event-sourcing-whats-the-difference/ ↩︎

  4. CQRS Documents by Greg Young. Retrieved from https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf ↩︎

  5. What is CQRS. Retrieved from https://www.codeproject.com/Articles/555855/Introduction-to-CQRS ↩︎

  6. 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/ ↩︎ ↩︎ ↩︎ ↩︎

  7. DOMAIN-DRIVEN DESIGN. Retrieved from https://danysk.github.io/Course-Laboratory-of-Software-Systems/21-ddd-intro/#/ ↩︎ ↩︎

  8. Introducing Event Storming. Retrieved from http://ziobrando.blogspot.it/2013/11/introducing-event-storming.html ↩︎

  9. Event storming. Retrieved from https://en.wikipedia.org/wiki/Event_storming ↩︎ ↩︎

  10. Object Modeling in Color. Retrieved from https://en.wikipedia.org/wiki/Object_Modeling_in_Color ↩︎

  11. The Coad Letter. Retrieved from https://web.archive.org/web/20061006012428/http://www.coadletter.com/coadletter/ ↩︎

  12. 从“四色建模法”到“限界纸笔建模法”. Retrieved from https://insights.thoughtworks.cn/paper-pen-modeling/ ↩︎

欢迎关注我的其它发布渠道