f10@t's blog

领域驱动设计学习(一)

字数统计: 5.1k阅读时长: 17 min
2024/04/23

"Domain-Driven Design is an approach to software development that centers the development on programming a domain model that has a rich understanding of the processes and rules of a domain. ....... The approach is particularly suited to complex domains, where a lot of often-messy logic needs to be organized."——Martin Fowler

最近阅读了郑天民的《DDD工程实战:从零构建企业级DDD应用》,相较于一些直译本,个人感觉更加易懂。了解了DDD方法的思想、概念和优势,打破了我一直以来对于Java后端开发,特别是微服务开发时的一些固念。感兴趣的伙伴可以阅读Martin Fowler的相关博客或者这本书。(喜欢好友家乡的甏肉干饭白)

Before DDD

在我个人的理解中,DDD是一种编写代码时的思维方式,一种对代码进行组织的方法。大约在大二的时候听到了这个概念,随手也借来了一本Evans原著的译本,了解到了一些关于领域对象建模的概念。但当时我更多的精力在于学习已有的编码相关技术,对这一类上层思想类内容不感兴趣或不够重视。也不了解DDD诞生要解决的问题是什么、他的优点是什么、他的缺点是什么等。

随着着手写了一些单体结构的代码、微服务风格的代码后,无事回头再来看DDD,才了解了他的因为所以然。以我个人代码习惯来说,在早期,我的代码大多是这样的结构:

弄出来不同的类,各自包含不同的方法,比如Utils类。然后在一个main中把他们依次创建、调用,结束。在这个过程中,通常也都是在一个文件夹下编写,不存在什么分包的意思,也无所谓什么测试,大不了print出来看白(滑稽)。问题也随之而来,更改起来有诸多麻烦,一个函数包含的功能也较多,有时候还要找半天功能代码在哪里,并不规范。

后来大三实习了,接触到了微服务的开发,也基本学习了一些代码规范,才知道有maven管理、分包这些概念。我的代码才变成了下面这样常见的结构:

由于转向了Java后端开发的学习,也就接触了上面的分层结构。在这个过程中,controller的代码都很简单,仅仅包含接口的暴露以及服务的注入和调用(以我写过的代码作为例子说明):

配合entity中定义的、要持久化的实体(左图),repo层编写如Spring Data JPA的接口代码(右图):

而大量的repo层调用代码、业务功能代码,都充斥在Service层中。这样的代码从功能复杂度或者花费的功夫上来说,很像一个菱形,上、下都是尖尖的,因为要么是简单的接口暴露、要么是自动化的持久化,中间则臃肿成了一个胖子,充斥着大量的代码。

从对象的角度来说,这样的编码方式是一种典型的贫血模型编写方法。也即核心的对象中(如上左图中的Block类),仅仅包含属性、Getter、Setter方法、构造器,没有任何与业务相关的代码。 仅仅是数据本身。

......实体对象具有状态可变性和完整生命周期...有时候,我们把基于实体对象的建模方式称为充血模型,以便与基于数据对象的贫血模型相区别...数据对象只包含了一组数据属性,没有提供任何对业务逻辑的处理过程,开发人员更关注数据而不是领域。这种开发模式非常常见,但却不是DDD更推荐的做法。在DDD中,我们关注的是领域模型对象而非数据本身[3]。...

上述的代码方式当然有他的优点,分包清晰、职责划分清楚,便于思考时快速组织代码框架,这也是我很长一段时间编写代码的方式。

Why DDD?

那么DDD的必要性在哪里呢?换言之,上述通用的开发习惯会带来怎么的问题?recall关于数据对象的开发方式,谈一谈我个人理解和感受。

在实际开发中,一旦问题背景较为复杂,涉及的业务方面较广,其下也包含各种不同的小业务,那么就会出现半天理不清类关系、业务流程、东一榔头西一棒槌的感觉。今天觉得这样好就这样写,明天又觉得得拆分开那就拆分开。我总感觉似乎缺少一个能够指导我快速将业务划分清楚,并且将其对应到代码中角色的一个方法论。当项目没有那么复杂的时候,这个问题似乎看不见,越复杂我感觉越明显。这也是我的长期以来的一个瓶颈。

其实原著中Evans也是因为在为电气工程师开发软件时,由于对方不了解开发概念,Evans自己也不了解电气知识。这就是一个上述新的陌生场景,而通过DDD方法就可以清晰的抓住线头,按部就班地整理出代码结构。

因此,与代码架构有所区别,如单体、微服务、中台,DDD我认为是一种方法论,可以应用于上述任一代码架构中。当然,事物都有两面性,产品、所在团队不一定每个人都会使用这样的方法论,也不一定有那么多的时间精力或者人力条件去细致的讨论业务需求。但是我觉得了解这个方法还是一些好处的,包括:

  • 提供一种新的系统建模模式,能够帮助你快速抓住项目中核心的部分。也就是我总觉得缺少的那个指导我的"方法论"。
  • 提供一种能够和需求方沟通的通用语言

DDD基本概念

首先介绍一下大流程,在DDD中,我们的思考会有两个维度:战术设计(tactical design)和战略设计(strategic design),其中:

  • 战术:目的在于如何实现和开发系统,层次较低。更关注如何实施DDD、如何在领域模型基础上采用技术工具来开发。
  • 战略:目的在于清楚地界定不同的系统与业务关注点,层次较高。更关注如何设计领域模型、如何对领域模型进行划分。

战略显然更为抽象,因此聚焦业务架构和技术架构怎么结合;而战术则更为具体,聚焦如何设计和实现技术架构。下图是业务模型与战略、战术设计之间的对应关系。

一个大概的流程如下:

首先,进行战略设计:

  1. 我们根据需求方的业务描述首先整理出业务场景的通用语言(Ubiquitous Language),即一句话描述清楚这个业务场景是干什么的?什么流程?

  2. 接下来我们需要进行业务拆分,即那些功能是需要分开的?根据业务和通用语言拆分处不同的子域,从而得到不同的限界上下文。具体来说,子域会分为:

    • 核心子域:系统核心业务
    • 支撑子域:专注与某一方面业务,为了核心子域而开发
    • 通用子域:公共能力或基础设施,也可以用于如数据分析等其他领域

另外,也可以使用非面向领域的拆分方法,如根据团队只能进行拆分:前端、后端、数据库等。然后,进行战术设计:

  1. 针对每一个限界上下文,提取其聚合对象、值对象和实体对象。
  2. 根据业务需求,我们将不同限界上下文中的聚合对象联系起来,从而构建起领域服务
  3. 针对存在强耦合的实体,我们可以提取领域事件以解耦,他可能是限界上下文内部的或跨上下文的。
  4. 构建数据访问的资源库,实现对象的持久化,如MyBatis、Hibernate。
  5. 最后,面向用户界面,构建应用服务。特别地,它是命令查询分离(Command Query Responsibility Segregation,CQRS)风格的。

可以看出,DDD字如其意,核心就是Domain,就是限界上下文,不同的上下文就构建起了整个应用,如同一个个小细胞。因此下面将聚焦单一的一个界限上下文,阐述不同的核心概念。后续文章将进一步总结关于不同上下文集成的内容和技术框架。

限界上下文(Bounded Context)

以Java为例,首先给出一个典型的限界上下文的代码包结构:

大的来说,可以分为:领域模型对象(Domain)、领域应用服务(application)、基础设施(infrastructure)、上下文集成(intergration)、接口(interfaces)。每一个包的含义及其下属包的含义如下图,后面会针对每一个领域概念、对应着代码包结构进行总结:

DDD代码架构图

领域模型对象(domain)

这里我们主要讨论架构图中的model部分:

从类别上,可以将对象分为:聚合(Aggregate)、实体(Entity)、值对象(Value Object);从包含关系来说,包含后两者。

实体(Entity)

区别于数据对象,实体对象包含了业务状态以及围绕这些状态的是生命周期,称为充血模型。实体中包含一个唯一标识以及一些改变状态的业务方法。如下代码所示:

值对象(Value Object)

值对象自身没有状态,且不可变。如地址、城市、国家,这些都是不可变的内容,比如我们常用的Enum类。即实际代码中不存在Setter方法,仅包含构造函数,一旦创建这不可修改。如下代码所示:

值对象的作用包括:

  • 表示业务数据,如国家
  • 充当响应的数据传输媒介

聚合(Aggregate)

聚合设计领域模型最基本和重要的工作就是在限界上下文中识别聚合。聚合由聚合根、实体和值对象组成。为什么要聚合这个概念呢?

原因在于通过将不同实体和值对象“聚合”到一个类中,可以有效地减少不同类之间的交互关系,仅由聚合根完成交互。因此,本文后面的限界上下文集成中,通信也只由不同限界上下文的聚合根进行。如下代码是一个聚合的示例:

从上述代码中可以看出,区别于普通的实体对象,聚合对象不仅是一个充血模型,还包含了同一限界上下文内所有其他实体对象的引用。oderplanscore等对象都不是普通的数据类型。即:聚合对象一定是实体对象,但实体对象不一定是聚合对象(也可以是)。

那么我们如何识别聚合对象?他有哪些特点?识别聚合对象的方法有三条原则:

  • 不变条件。即一个事务修改的内容应该位于同一聚合中,保持强一致性,要修改则都变。
  • 设计小聚合。聚合不宜太大,如上代码所示,如果一个聚合边界过大、包含对象过多,那就会导致复杂的对象管理和深层次的对象遍历,带来较差的性能。此外,小聚合也能确保一定的可扩展性。
  • 不同限界上下文中的聚合通过唯一表示进行引用。

领域服务(Services)

下面首先讨论架构图中的如下部分:

领域服务的存在是为了更好地组织一个限界上下文中各个技术组件之间的交互关系,可以理解为当前限界上下文提供给外部的接口,他的作用类似于设计模式中的外观模式(Facade)。书中有一张图很清晰,我在图中增加了一些内容以增强理解性:

绿色范围内的就是一个限界上下文,向内的箭头来自客户端和其他上下文的集成行为;向外的箭头则为来自本限界上下文的请求或集成。可以发现起到“中心调度”作用的就是应用服务。他对外屏蔽了客户端、其他上下文对本上下文内聚合的直接调用;也屏蔽了聚合与如REST API、中间件、资源库等外部资源之间的直接调用关系。

在开发过程中,领域服务会分为两大类服务:命令(Command)服务查询(Query)服务。DDD提倡使用CQRS模式,因此分为了上述两类服务,而他们最大的区别就是是否会对聚合的状态产生改变。这里提一嘴CQRS模式,他起源于Bertrand Meyer提出的命令查询分离模型(Command and Query Separation Principle,CQS),wiki部分描述如下:

It states that every method should either be a command that performs an action, or a query that returns data to the caller, but not both. In other words, asking a question should not change the answer.

而CQRS则是在架构层面采用CQS原则的版本,即将服务类型分为独立的命令和查询服务。Martin Flower认为有两种情况下适合采用该模式,否则可能会带来复杂性,可能还不如原来普通的CRUD:

  • 极少数清况下,对于较为复杂的领域,使用CQRS可能更加清晰。但如果查询和命令对应的实体对象都是同一个类,那就没有必要分开了。
  • 对于有高性能需求的应用,CQRS可以将读写的负载天然的分开,如果二者的负载差异很大那么可以考虑采用CQRS。当然,也可以使用不同的数据库。

首先我们需要在领域中定义命令和查询实体对象,如对于命令对象:

命名上结尾为Command,作为实体当然具有一个唯一的ID。而对于命令对象则如下图,命名上结尾为Summary,并且在query包下定义不同的transformer类来满足客户端/服务请求的数据内容(类似于视图):

定义了相关命令、查询对象后,我们就可以利用这些对象来定义服务了。下图为一个CQRS的代码示例,上图为命令服务,下图为查询服务,从函数的返回值就可以看出,命令服务返回值都为void,而查询服务返回的则是数据对象。

定义好领域服务后,我们就可以跨领域构建应用服务了。但在这过程中,考虑如下问题:

如果直接将远程调用的代码写入到服务代码中,并对返回的数据进行对应的处理,那么如果以后被请求的接口的参数列表或返回值变动了怎么办?是不是还得反过头来修改请求方的服务代码?如果涉及的调用很多怎么办?

为了解决上述问题,显然我们要将本地的业务代码保护起来,设计一层单独的代码起到隔离的作用。此时就需要引入架构图中的如下部分:

  • 对于入站操作(本小节特指REST请求),我们将请求数据与普通对象的转化以及该普通对象与本领域内命令/查询对象的转化的指责分别定义在dto包和assembler包下。顾名思义,前者将“data”转换为"object",后者将普通的“object”聚集称为领域对象。
  • 对于出站操作,我们将对其他领域借口调用的代码放入到acl包下,其意为防腐层(Anti-Corruption Layer,ACL)。即将发出请求的代码与本领域服务的业务代码分隔开来,从而解决前述问题。

领域事件 (Domain Events)

当通用语言中出现“当......发生......时”、"如果发生......"等诸如此类描述,可以考虑引入领域事件。换言之:

...当一个实体一来与另一个实体,当两者之间并不希望产生强耦合,而我们需要保证二者之间的一致性时,通常可以提取领域事件[3]

那么就首先需要对事件建模,因此我们会在架构图的如下event包中定义领域事件。

如下图就是一个领域事件:

对领域事件对象建模时,通常:

  • 名称采用"xxx+动词过去式+Event"的格式
  • 事件对象类中可以包含唯一ID、产生时间、事件来源等元数据或业务数据
  • 事件对应不应包含Setter类似的方法,因为事件本身产生后就应该是不可变的,代表的是客观存在的瞬时状态。

既然是事件,那就涉及到异步处理,那么必然会存在生产者、消费者、中间件。这里插一句,对事件驱动架构感兴趣的读者可以阅读《Enterprise Integration Patterns - Designing, Building And Deploying Messaging Solutions》。换言之他的生命周期是怎样的呢?如下图,可以分为两个阶段四个步骤

上图中的生产者和消费者不限定所在的上下文,which means二者可以处于同一个上下文,也可以是不同的上下文。区别在于是否涉及到中间件,对于不同上下文的情况,通常需要中间件的参与且要考虑网络因素。

而实现上述功能的代码,则对应架构图中的如下部分:

如下是一个消费事件的入站包代码示例,通常事件的消费类命名为"xxx+EventHandler",利用SpringCloud Stream提供的事件监听注解SteamListener来标记。

相反生产者命名为“xxx+EventPublisher”,如下代码中,当对应事件生成时将事件发送出去。

特别地,对于跨上下文的集成行为,我们会将上述消息发送到中间件如RabbitMQ中去,这就涉及到了架构图的如下部分:

顾名思义,基础设施,包含了我们技术相关配置文件或资源,这里仅介绍事件相关的消息包,下一小节介绍资源库。

messaging包中,以Spring Cloud Stream为例,我们需要定义消息中间件的配置文件并定义对应的路由:

命名方面,消息生产者命名为"xxxSource",对应地,消费者这命名为“xxxSink”:

资源库(Repository)

下面讨论内容对应架构图中的如下部分:

资源库分为定义和实现两个部分,这么做的原因是为了将领域模型对象持久化技术解耦,也称为资源库模式(Repository)[5]

即,在领域模型对象和持久化层代码之间新构建一个代码层,负责从数据请求方处接收操作内容,并向下调用持久化层代码,最终将操作结果返回给数据请求放。(原文如下:):

A Repository mediates between the domain and data mapping layers, acting like an in-memory domain object collection. Client objects construct query specifications declaratively and submit them to Repository for satisfaction.[5]

因此首先,我们需要领域模型domain包下定义数据仓库操作类,并在当前repository包中给出对应的实现:

针对实现,我们需要进一步根据资源库模式分化出三类不同指责的子包:

  • po:即Persistent Object,待被持久化的对象类定义,典型的如Spring Data JPA中:

  • factory:工厂类,负责持久化对象PO和领域聚合对象之间的转换:

  • mapper:数据映射器代码,典型的如Spring Data JPA:

参考学习

CATALOG
  1. 1. Before DDD
  2. 2. Why DDD?
  3. 3. DDD基本概念
    1. 3.1. 限界上下文(Bounded Context)
      1. 3.1.1. 领域模型对象(domain)
        1. 3.1.1.1. 实体(Entity)
        2. 3.1.1.2. 值对象(Value Object)
        3. 3.1.1.3. 聚合(Aggregate)
      2. 3.1.2. 领域服务(Services)
      3. 3.1.3. 领域事件 (Domain Events)
      4. 3.1.4. 资源库(Repository)
  4. 4. 参考学习