DDD 中关于应用架构的那些事


DDD 中关于应用架构的那些事

对领域驱动设计中关键的一些概念,大家有了更为深入的认识是不够的,在具体实践中我们还会面临诸如代码如何分层、不同上下文之间如何集成,以及某些时候还会用到CQRS。本文就来补齐领域驱动设计中剩余的一些内容,希望能够助你更游刃有余地应对开发中遇到的各种问题。

作者 | 于振

责编 | 韩楠

你好,今天我想与你聊聊DDD中的应用架构。在过往我分享的几篇文章中,我们介绍了领域驱动设计中的一些基本概念,这里,再做一个简单的回顾。

·《基础问题不简单|怎么合理使用值对象,让你的代码更清晰、更安全?》

·《不想只做Cruder?实体、聚合根,还不快去了解下》

·《如何通过仓储,对实体进行持久化处理?》

·《实体表达力不够?那你应该试试领域服务》

·《如何使用工厂,进一步解耦领域对象的职责》

·《领域模型细节太多不便使用?那就加个应用服务吧》

·《DDD在Go中如何落地|如何在业务中使用领域事件?》

使用值对象和实体帮助我们构建了具有丰富行为的领域模型,实体创建出来后需要通过仓储进行持久化,如果领域模型跟数据模型存在差异,就还需要通过 Converter 进行转换,以及通过 Snapshot 对实体进行追踪。

DDD 中关于应用架构的那些事

如果某些行为不适合放到某个实体上,就需要使用领域服务,同时,为了一定程度地防止领域服务的滥用,我们规定领域服务在命名上必须有一个动词。

为了解耦领域对象的创建过程和其自身行为,我们又介绍了工厂方法。

对于外部用户来说,领域之内的各个对象描述的,都是细粒度的领域概念,为了方便外部调用,同时屏蔽领域对象的具体细节,就又有了应用服务。

最后,通过领域事件,进一步解耦了不同上下文之间的依赖,即使在同一边界之内的不同的聚合根,也可以实现数据的最终一致性。

至此,大家应该对领域驱动设计中关键的一些概念,有了更为深入的认识。但仅仅是这些应该是还不够的,在具体实践中,我们还面临着诸如代码如何分层、不同上下文之间如何集成,以及某些时候还会用到CQRS。

在这篇文章中,我们就来补齐领域驱动设计中剩余的一些内容。

首先,我们从代码的分层开始说起。

01 DDD的分层架构

分层架构作为一种历史悠久的架构模式,在很多的场景中都得到了应用。

大家比较熟悉的应该就是 MVC 对应用三层架构的拆分。MVC 这种分层是自上而下的。

随着业务越来越复杂,人们逐渐发现, MVC 架构在应对复杂的业务问题时会显得力不从心。

于是,后面逐渐演化出了六边形架构、洋葱架构、整洁架构等架构模式。这几种架构也是一种分层架构,但这种分层不是由上而下的,而是由内而外的。

我们以洋葱架构为例:

DDD 中关于应用架构的那些事

可以看到,最关键的是中心的领域模型,它包括了所有的应用逻辑与规则。在这一层中不会直接引用技术实现,这样就能够确保在技术层面的改动不会影响到领域核心。

在领域层之外又包裹了领域服务层、应用服务层,而具体的技术实现则是被置于最外层的。

这种架构的好处就在于,它屏蔽掉了应用程序在UI层、DB层,以及各种中间件层的本质区别,所有的这些外部资源都被抽象成了对系统的输入输出,然后我们就能够以一致的方式来处理不同的请求类型,并且,在与实际运行的设备和数据库相隔离的情况下,也可以先行开发和测试。

在 DDD 的技术实现中,就用到了这种分层方式。

下图是 Eric Evans 在其经典著作《领域驱动设计》中给出的一个典型的 DDD 系统所采用的分层架构:

DDD 中关于应用架构的那些事

在上图中可以看到,整个架构划分成了四个层,各层所表示的含义及其职责描述如下:

1、用户接口层

这一层主要负责直接面向外部用户或者系统,接收外部输入,并返回结果。

用户接口层是比较轻的一层,不含业务逻辑。可以做一些简单的入参校验,也可以记录一下访问日志,对异常进行统一的处理。同时,对返回值的封装也应当在这层完成。

2、应用层

应用层,通常是用户接口层的直接使用者。

但是在应用层中并不实现真正的业务规则,而是根据实际的 use case 来协调领域层提供的能力,也可以说,应用层主要做的是编排工作。

另外,应用层还负责了事务这个比较重要的功能。

3、领域层

领域层是整个业务的核心层。我们一般会使用充血模型来建模实际的领域对象。

同时,由于业务的核心价值在于其运作模式,而不是具体的技术手段或实现方式。因此,领域层的编码原则上不允许依赖其他外部对象。

4、基础设施层

基础设施层,是在技术上具体的实现细节,它为上面各层提供通用的技术能力。

比如我们使用了哪种数据库,数据是怎么存储的,有没有用到缓存、消息队列等,都是在这一层要实现的。

对于这四个层次的划分,大家通常都没有太多的异议。但是在层与层之间的依赖关系上,后续又衍生出了很多的改良版本。比如在 IDDD 一书中,就给出了下图所示的分层架构:

DDD 中关于应用架构的那些事

这里最大的不同,就是将领域层放到了整个架构的最下面,也即领域层之下就不再有任何的其他依赖。这么做是没有问题的,但是最上面的基础设施层看起来却怪怪的。

在实际开发中,领域层的领域服务往往需要访问持久化组件,以及基础设施层中的其他组件,而对于持久化组件来说,不可避免地需要依赖领域层的实体对象。如此一来,领域层和基础设施层,就产生了双向依赖关系。

实际的解决方式,就是让领域层和基础设施层 都依赖一个统一的抽象,比如对于模型的持久化有 Repository 接口,对其他外部资源的访问也可以通过接口的形式来解耦合。但是 Repository 接口跟其他接口 又有些不太一样,Repository 因为需要参与到实体的整个生命周期中,所以在很多时候 Repository 都被看作是领域层中的一员。而对基础设施层中其他组件的抽象,是不适合定义到领域层的。

▶︎ DDD代码模型

结合上面的描述,这个时候再来看代码的组织形式,就比较清晰了。默认情况下,一个上下文对应了一个服务,我们这里以包含单个上下文的情况为例,给出如下的代码目录结构:

DDD 中关于应用架构的那些事

对上面的代码结构做一个简短的说明:

• Application,对应到架构里的应用层,其内可能包含一些 assembler 和 DTO,assembler 主要用于将领域对象转换成返回需要的数据格式,这些数据格式以DTO的形式进行定义,这些DTO没有任何的业务逻辑,就是单纯的数据对象。

• domAIn,对应的是领域层,仓储的接口也是放在这一层的。

• handler,对应的是架构里的用户接口层,但其本质上还是属于基础设施层的一部分,这里单独提出来也仅仅是为了凸显它的重要性。在这一层,只可以直接访问应用层。

• infra,对应的是基础设施层,根据对不同资源的继承需求,可以在 infra 下继续分包。

• interfaces,是对基础设施层中除持久化以外的中间件的抽象,也即我们在这里定义访问中间件的接口,具体的实现还是放在基础设施层。这里将接口单独放到一个包中,为的是避免在领域层与应用层对基础设施层的直接依赖,如此就通过依赖反转解耦了具体的技术细节。

至此,我们就明确了代码的分层组织结构,以及彼此之间的依赖关系。

我们在文章开头提到的第二个问题是上下文的集成,在实际工作中,相信大家都会使用到微服务,这样一来,如何集成就成为我们必须要考虑的问题。

02 与其他上下文集成

上下文的集成无外乎两种方式, 一种是通过RPC进行集成,另一种是通过领域事件进行集成。

通过领域事件集成,也就是领域事件的发送和消费,这个我们在前面的文章中已经做了比较详细的介绍,这里不再赘述。

接下来主要说说通过 RPC 进行集成。

▶︎ 开放主机与发布语言

我们先来看一个在 DDD 中,经常用来表示集成方式的示例图:

DDD 中关于应用架构的那些事

其中,被集成方(A上下文,U 是 Upstream 的缩写)采用了开放主机和发布语言的方式,而集成方(B上下文,D 是 Downstream 的缩写)则使用了防腐层。几个缩写的含义如下:

• OHS(Open Host Service):开放主机服务,即定义一种协议,子系统可以通过该协议来访问你的服务。

• PL(Published Language):发布语言,通常跟 OHS 一起使用,用于定义开放主机的协议。

• ACL(Anticorruption Layer):防腐层,一个上下文通过一些适配和转换,来跟另一上下文交互。

我们平时大多数时候的开发工作,都是跟 Grpc/Kitex 等 RPC 框架打交道的,不同的框架在设计之初都会定义一份协议,只有符合协议要求的请求 才能被正确地识别和处理。比如 Grpc 使用 HTTP2 作为传输协议,而 Kitex 则主要使用自定义的 TTHeader 协议。

这些框架在使用上,一个共同特点就是需要通过 IDL(Interface description language) 来定义服务可以提供的能力。IDL 中可以定义多个接口,每个接口都有一个方法名,同时需要指定传递什么参数,返回什么数据。这样的一份 IDL 就可以认为是我们为系统定义的发布语言。

还是以前面多次提到的商品服务为例,商品服务作为上下文集成中的被集成方,通过 thrift 定义了其可以提供的服务,比如下面是对 GetProductDetail 接口的定义:

DDD 中关于应用架构的那些事

所以,如果我们是一个服务的提供方,只要我们使用 Grpc/Kitex,那么就可以认为我们是使用 OSH 和 PL 方式来进行集成的。

▶︎ 防腐层

防腐层一般用在下游上下文中,可以用来隔绝上游上下文中可能发生的变化。

在上面的例子中,商品服务提供了一个 GetProductDetail 接口,用以返回关于 Product 的全量信息。但是对于其他集成方来说,可能只是想拿到产品的很少一部分信息,比如在订单服务中要展示订单的详情,而详情只需要产品的图片和名称即可。

可以看到,作为服务的提供方,其具有追求普适性和灵活性的特点,而服务的调用方,在使用时却想要能够集中满足特定需求的接口。

这种张力是导致在边界上出现问题的主要原因,是无法避免的,但是却是可以解决的,应对的方法就是使用防腐层。

DDD 中关于应用架构的那些事

从图中可以看出,Subsystem A 和 Subsystem B 的调用关系并不是直接产生的,都要通过中间的一个ACL,ACL 除了负责执行具体的技术性调用,还将 A 和 B 的领域模型隔离开来,并承担了彼此模型之间的翻译转换功能。

除此以外,还可以在 ACL 做缓存、兜底、开关等功能。

对于集成方来说,一般采用独立接口的形式,接口的定义放在 interfaces 中,上面这个例子就可以这样定义:

DDD 中关于应用架构的那些事

因为实现是跟具体的技术相关的,所以实现需要放到基础设施层。整体的目录层级如下:

DDD 中关于应用架构的那些事

具体的实现可以参考下面的代码,简单来说就是将通过 RPC 获取到的上游模型,转换为自己领域内的模型:

DDD 中关于应用架构的那些事

在传统意义的防腐层实现中,会有一个适配器和一个对应的翻译器,其中适配器的作用是适配对其他上下文的调用,而翻译器就是将调用的结果转换成本地上下文中的元素。

在这里,我们为了保持代码的简单,没有特意声明这样两个对象,rpc的方法在这里起到了适配器的作用,至于翻译器,我们只是简单的提出了一个方法,在方法名上做了特殊的前缀修饰。

最后,ProductRpcClient 会作为 ProductClient 的实现类,最终被注入到服务中。

03 CQRS 简单实现

我们在看一些资料时,可能会看到有的地方叫CQS有的又叫CQRS。CQS 和 CQRS 都表示命令与查询的分离,本质上没有太大的区别。

CQS 是在《面向对象软件架构》一书中提出来的概念,作者 Bertrand Meyer 认为,一个方法原则上不应该既修改数据又返回数据,所以就有了两类方法:

1、查询:返回数据,但不修改数据,不会产生副作用;

2、命令:修改数据,但不返回数据,存在副作用。

CQRS 是对 CQS 概念的升华,因为查询端只返回数据,完全不修改数据,所以我们所有的查询不需要走领域实体,甚至没必要使用 ORM 框架,总之,我们可以通过各种手段来提升查询的效率。

关于CQS与CQRS的更多信息,可以参考这篇文章,和这篇。

下图是在各种技术文章中你会经常看到的一个非常典型的 CQRS 架构示例:

DDD 中关于应用架构的那些事

图中左侧部分代表的是对 Command 的处理,右侧是对 Query 的执行。很明显的一个区别是,在 Query 中不再强制必须走领域模型,而是在应用层可以直接访问基础设施层。

在实际开发中,对 Query 的处理其实是比较灵活的,其目的无外乎是提高查询的效率,另一方面也可以保证领域模型职责的单一。通常在查询相对简单的时候会复用领域模型,在稍微复杂时,会直接访问底层的数据模型,如果查询变得更加复杂,会将数据的存储也独立出来。

下面我们就依次说说这几种情况要如何处理。

▶︎ 复用领域模型

这种是最简单的情况,对应的读模型就是领域模型,要查询的数据基本上都是模型里的属性。

比如,我们有一个库存的聚合根:

DDD 中关于应用架构的那些事

展示的数据如下:

DDD 中关于应用架构的那些事

这个时候,就可以通过 assembler 直接转成对应的 view:

DDD 中关于应用架构的那些事

因为聚合根和仓储是一一对应的,所以,在应用服务中直接通过 Repository 获取领域模型即可:

DDD 中关于应用架构的那些事

▶︎ 使用数据模型

在分页查询,或者是需要多个实体聚合查询的场景,如果直接通过 Repository 获取领域模型再组装,可能会产生很多无关查询,影响效率。

这个时候,可以根据要展示的数据直接使用数据模型,或者通过 sql 只获取指定的某几个字段。

比如,我们有 Product 和 Category 两个聚合根,它们都包含了大量的属性和业务逻辑,但是我们要展示的数据比较简单:

DDD 中关于应用架构的那些事

这个时候就可以通过直接 sql 的形式来绕过领域模型:

DDD 中关于应用架构的那些事

▶︎ 使用独立的读模型

这种情况下,一般对应的查询场景都比较丰富,通常都会有一个独立的查询服务,各种数据在聚合处理之后统一放到查询服务中。

如下所示,订单在创建后,会使用 EventPublisher 来发布相应的事件:

DDD 中关于应用架构的那些事

在订单查询服务中,会对订单创建这个事件进行监听,当收到对应的消息时,会将订单信息存储到ES里。

DDD 中关于应用架构的那些事

如此一来,订单数据就同时存在于 MySQL 以及 ES 中。而在查询的时候会只通过 ES。

04 结语

在这篇文章中,我们介绍了实践领域驱动设计的时候应该如何组织代码结构、如何进行上下文的集成,以及在复杂查询场景中使用CQRS。这些内容我同样是用脑图的形式为你总结:

DDD 中关于应用架构的那些事

希望通过今天的讲解,你能够更游刃有余地应对开发中遇到的各种问题。但总地来说,DDD只是一种思想,所谓的分层架构也并不是事实上的标准,在实际应用时,还要结合自身的理解,可以适当地去创新或进行改进。

到目前为止,关于领域驱动设计的所有内容就都已经介绍完了。在下一篇文章中,我们会结合一个虚构的商城系统,带你实战领域驱动设计。

DDD 中关于应用架构的那些事

【技术专家】

于振

现于某大型互联网公司,负责架构工作

曾就职于美团、快手等一线互联网公司

本文收集自互联网,如果发现有涉嫌侵权或违法违规的内容,请联系6532516@qq.com以便进行及时清除
分享到