领域驱动设计漫谈

DDD简介 领域驱动设计(Domain Driven Design),软件核心复杂性应对之道。 Fred Brooks 在经典著作《人月神话》中对于软件复杂

DDD简介

领域驱动设计(Domain Driven Design),软件核心复杂性应对之道。

 

Fred Brooks 在经典著作《人月神话》中对于软件复杂度有着精彩的论述,他将软件复杂度分为本质复杂度(Essential Complexity)和偶然复杂度(Accidental Complexity)。本质复杂度是一个软件系统必然拥有的复杂度,也可以理解为是业务复杂度(核心复杂度),而偶然复杂度是一个软件系统可能会有的复杂度(也可能没有,取决于你的技术实现路径)。

 

举个例子:一个电商软件必然会包含商品展示、资金交易、库存扣减等业务复杂度,因此我们称它们为本质复杂度;而同一个电商软件,可以是基于 Java 编写的,也可以基于C语言,但是选择C语言的话会面临内存回收的复杂度,这里我们称由于选择C语言而引入的内存回收的复杂度,为偶然复杂度。(当然了,对于JVM的开发团队来说,内存回收这个场景的复杂度就是他们需要面临的本质复杂度)

 

而领域驱动设计,就是为了应对软件本质复杂度的。注意是“应对”而不是“解决”,这个用词很重要,因为本质复杂度是解决不了的,是绕不开的(如果能绕开那就不是本质复杂度了)。我们能做的,只有通过良好的建模方法以及合理的代码组织方法,来把这个本质复杂度收敛在一个较小的范围之内,而不至于使本质复杂度外泄到你的系统的其他地方,从而使后续的维护和迭代更加的安全和高效。这就是DDD要做的事情,也是我们学习DDD的目的。

 

本文将首先介绍一下DDD里面的几个核心概念,然后介绍一下分层架构、六边形架构和CQRS架构。

领域、子域和限界上下文

领域:一个组织所做的事情以及其中所包含的一切。每个组织都有它自己的业务范围和做事情的方式,这个业务范围以及在其中进行的活动便是领域。

子域:业务范围(领域)的某个方面。

限界上下文:整个应用程序的一个概念性边界,在这个边界之内的每种领域术语、词组或句子(通用语言),都有明确的上下文含义。在这个边界之外,这些术语可能表示不同的意思。限界上下文和通用语言存在一对一的关系。

 

由于“领域模型”包含了“领域”这个词,我们可能会认为应该对某个领域创建一个单一的、内聚的、全功能式的模型,但事实恰恰相反,试图创建一个全功能的领域模型是非常困难的,并且很容易失败,在DDD中,一个领域被划分为若干子域,领域模型在限界上下文中完成开发。

 

那领域、子域和限界上下文应该怎样划分呢?来看个具体的例子,这个例子用电子商务的通用语言可以描述如下:我们要开发一个电子商务系统供零售商在线销售产品,在该系统内,零售商必须向买家展示不同类别的产品,允许买家下单和付款,需要给买家开发票,并安排物流。所以在这个在线零售的领域中,可以分为4个主要的子域:产品目录,订单,发票,物流,这里的限界上下文是电子商务上下文(限界上下文的命名一般是:模型名+上下文)。

 

好像很简单?其实不然。首先通用语言的描述里面有一些隐性的概念,比如这个电子商务系统里面有用户和角色的概念(买家和卖家),既然有角色,那可能会牵扯到权限的概念;既然有产品,那可能又会有库存的概念...... 所以这些概念是否需要划分成不同的子域?其次因为在线零售相对来说已经是一个比较成熟的领域了,里面的一些概念或者是通用语言早已在业界广为流传,所以大概率最终大家都会讨论出一个一致的子域划分,大家都不会有什么分歧。但是如果是我们面对的是一个不是那么熟悉的领域,我们应该怎么做?

 

DDD中其实也给出了解法,那就是找领域专家去沟通了解。领域专家不一定是要懂技术的人,但一定是要懂业务的人(通常来说,一个业务的业务负责人就是领域专家,因为他一定是你们团队内最了解业务的人)。领域专家和开发者使用一套通用语言进行沟通,沟通的过程中逐步完善和丰富通用语言。开发者会基于这个通用语言去构建领域模型。

实体和值对象

实体

当我们需要考虑一个对象的个体特征,或者需要区分不同的对象时,我们需要引入实体这个领域概念。一个实体是一个唯一的东西(拥有身份标识),并且可以在相当长的一段时间内持续地变化(可变性)。实体拥有自己的属性和关键行为。我们应当基于通用语言来挖掘实体的属性和关键行为。

值对象

值对象用于度量和描述事务,唯一身份标识和可变性特征将实体和值对象区分开来。我们可以非常容易地对其进行创建、测试和使用,我们应该尽量使用值对象来建模而不是实体。

当你不确定一个领域的概念是否可以建模为值对象时,你可以考虑它是否拥有下面的特征:

  • 它度量或者描述了领域中的一件东西。
  • 它可以作为不可变量。
  • 它将不同的相关属性组合成一个整体的概念。
  • 当度量和描述改变时,可以用另一个值对象直接替换。
  • 它可以和其他的值对象进行相等性比较
  • 它不会对协作对象造成副作用(即不会修改它的协作对象)

使用上面的特征进行判断时,我们可能会发现很多的领域概念可以设计成值对象,而不是我们先前认为的实体对象。

 

这里说下为什么我们应该尽量使用值对象而不是实体来建模。在传统的数据建模的方式中,我们由于受到关系型数据库的影响,认为所有的东西都需要范式化,并且通过外键进行关联引用,由于每个对象都有一个数据库主键,各个对象被组织在了一个庞大且复杂的对象网络中,这种全然面向实体的思维方式是不可取的。还有一个原因是值对象方便创建和测试。所以我们应当尽量使用值对象建模。

 

贫血模型和充血模型

说到实体,这里不得不提一下贫血模型和充血模型。受Spring框架和ORM框架的影响(这些框架常常暗示我们把getter和setter方法暴露出来,这样便破坏了对象的封装性,使我们更容易把对象建模成贫血的),贫血模型在我们的代码里大行其道,这会导致我们花了很大的成本来开发领域对象,但从中获益甚少。开发者将很多时间花在对象和数据库之间的映射上,或者说这里的领域对象根本不是领域对象,而只是将关系型数据库的模型映射到了对象上而已,这样的领域对象更像是活动记录,因为领域对象的关键行为散落到了各处,系统的本质复杂度没有收敛。

 

但是这里并不是说Spring框架与ORM框架和充血模型之间是顾此失彼的,只是说我们在进行领域对象建模的时候,其实可以不受这些框架的影响,DDD的过程也并不依赖某个具体的框架,在使用了Spring框架和ORM框架的情况下,我们依然可以把我们的领域对象建成充血模型。

领域服务

领域服务表示一个无状态的操作,它用于实现特定于某个领域的任务(业务逻辑),并且能够明确地表达限界上下文中的通用语言。

 

当某个操作不适合放在聚合和值对象上时,我们便要考虑放在领域服务里面了。那什么样的操作不适合放在聚合和值对象上呢?下面是几种情况:

  • 执行一个显著的业务操作过程。
  • 对领域对象进行转换。
  • 以多个领域对象作为输入进行计算,结果产生一个值对象。

凡是出现上面提到的几种情况,那就应该考虑使用领域服务了。

 

有一点需要注意,不要过度倾向于将一个领域概念建模成领域服务,因为一不小心,就可能会导致贫血模型的产生。即所有逻辑都位于领域服务中,而不是实体和值对象里面。

领域事件

领域专家所关心的发生在领域中的一些事件,就是领域事件。领域事件既可以由本地限界上下文消费,也可以由外部的限界上下文消费。

 

那问题来了,我们如何确定哪些事件是领域专家关心的?这就需要我们在与领域专家讨论时,找到领域事件的线索,考虑以下的领域专家所说的关键词汇:

  • “当.....”
  • “如果发生......”
  • “当......的时候,请通知......”
  • “发生......时”

并不是说符合上面的情况就都要建模成领域事件,只是说当你听到通用语言里面类似的表述的时候,你要注意了,这很有可能是一个领域事件。

 

有的时候领域专家起初可能意识不到所有的领域事件,但是通过和团队成员的充分讨论之后,我们应该是可以找出绝大部分的领域事件的。至于那些隐藏在通用语言很深的领域事件,就需要我们慢慢挖掘了。当我们对领域事件达成一致之后,领域事件便成为通用语言的组成部分了。

模块

模块并不是DDD中的专业术语,就是我们平常所说的代码的目录结构。在java中,叫做包;在C++中,叫做命名空间。这个小节主要描述下在DDD中模块是怎么划分和命名的。

在DDD中设计模块有几个原则:

  • 模块应该和领域概念保持一致。通常,对于一个或一组内聚的聚合来说,我们都应该相应地创建一个模块。
  • 根据通用语言来命名模块。
  • 不要机械式的根据通用的组件类型和模式来创建模块。
  • 设计松耦合的模块,同层级的模块耦合时要避免循环依赖,父模块和子模块间也要避免循环依赖。
  • 模块的设计不是一成不变的。如果模型概念随时间变化,概念名和模块名不再匹配时,你应当对模块进行重构。

一般来说顶层的模块的命名方式为:com.公司名.限界上下文名,详细的目录命名结构参考如下:

--com
  --公司名
    --限界上下文名
      --application
        --service : 应用服务
      --domain
        --model : 定义模型中的类,包括接口类和抽象类,实体和值对象。
        --service : 领域服务
        --repository : 资源库

聚合

实体和值对象在一致性边界之内组合成的一个整体,就是聚合。聚合之内保证事务一致性,聚合之间保证最终一致性。

 

聚合设计有以下几个原则:

  • 尽量设计小聚合。一个庞大的聚合不利于保证事务一致性,并且可能限制系统的性能和可伸缩性。
  • 通过唯一标识引用其他聚合。一个聚合可以通过唯一标识引用另一个聚合的根聚合,此时被引用的聚合不应该放在引用聚合的一致性边界之内。如果你试图在一个事务中修改多个聚合,那往往意味着此时一致性边界的划分是错误的,即聚合的建模是错误的,需要重新考虑一些聚合的设计。
  • 在聚合的边界之外使用最终一致性。在一个大规模高吞吐量的系统中,要使所有的聚合实例保证强一致性几乎是不可能的,认识到这一点,我们便知道在较小规模的系统中使用最终一致性也是有必要的。

 

在DDD中,有一个很实用的方法可以用来保证聚合之间的最终一致性,那就是通过领域事件。即一个聚合的命令方法发布的领域事件投递到订阅方,在接收到事件之后,每个订阅方都会获取自己的聚合实例,然后在该聚合上完成操作(修改)。这样,每个订阅方都在单独的事务里操作自己的聚合实例,也满足了“在一次事务中只修改一个聚合实例”的原则。

 

建议:不要在聚合中注入资源库或者是领域服务,这样做可能的原因是希望在聚合内部查找一个所依赖的对象的实例,所依赖的对象实例可能是另一个聚合。对于依赖的对象,我们应该在聚合命令方法执行之前进行查找,然后再将其传入命令方法,应用服务可以完成这个职责。

工厂

DDD中的工厂概念其实和设计模式中的工厂模式差不多,存在抽象工厂、工厂方法、创建者等模式(不在此赘述)。将创建复杂对象和聚合的职责分配给一个单独的对象,该对象本身不承担领域模型中的职责,但依然是领域设计的一部分。

 

大多数情况下,在DDD中,我们都会为聚合根创建一个工厂方法,这样也有助于更好地表达通用语言,这是构造函数所不能达到的。

资源库

在《领域驱动设计》一书中,Evans对于资源库的描述是这样的:对于每种需要进行全局访问的对象,我们都应该创建另一个对象来作为这些对象的提供方,就像是在内存中访问这些对象的集合一样,为这些对象创建一个全局接口以供客户端访问,为这些对象创建添加和删除方法,此外,我们还应该提供能够按照某种指定条件来查询这些对象的方法......我们应该只为聚合创建资源库。

 

每一个聚合都将拥有一个资源库,通常来说,聚合和资源库之间存在一对一的关系,然而有时,当两个或多个聚合位于同一个对象层级中时(继承自同一个基类),他们可以共享一个资源库。下面来看下在DDD中两种设计资源库的方法:

面向集合的资源库

这种资源库模拟了一个集合,或者至少模拟了集合上的标准接口(可以参考下java.util.Collection中的接口)。此时,从资源库的角度看,我们根本看不出其背后存在着持久化机制,也感觉不到我们是在向存储区域中保存数据。

 

一个面向集合的资源库应该模拟一个Set集合。无论采用什么类型的持久化机制,都不允许多次添加同一个聚合实例,另外,当从资源库中获取到一个对象并对其进行修改时,我们并不需要“重新保存”该对象到资源库中。因为对于一个集合来说,要修改其中的一个对象,我们只需要先从集合中获取该对象的引用,然后在该对象上执行命令方法即可。这样做的好处是不会让持久化机制通过公有接口泄露到客户端中。

 

面向集合的资源库可能需要背后的持久化机制提供一些特殊的功能(隐式地跟踪发生在每个聚合上的变化并将其持久化),所以有些场景下可能不适用,这个时候请考虑后面的面向持久化的资源库。

面向持久化的资源库

这是一种基于保存操作的资源库,每次新建聚合或者修改聚合之后,我们都需要调用资源库中的save()方法进行保存。这种类型的数据存储可以极大地简化对聚合的读写,正因如此,这种数据存储方式称为聚合存储或面向聚合资源库。

 

有时,我们需要在用户界面中显示数据,而这些数据来自多个聚合,此时我们不必先分别获取到每个聚合,然后从中提取数据,而是可以使用用例优化查询(Use Case Optimal Query)的方式,在资源库中直接查询所需要的数据,将查询结果放在一个值对象中予以返回。

资源库VS数据访问对象(DAO)

资源库和 DAO是不一样的,一个DAO主要是从数据库表的角度来看待问题,而且提供CRUD操作,而资源库是从聚合的角度出发的,并且这有助于你将自己的领域当作模型来看待,而不是数据库表的CRUD操作,我们应当尽量避免在领域模型中使用DAO模式。

集成限界上下文

一个项目中通常存在多个限界上下文,并且我们需要在它们直接进行集成。有多种直接的方式可以完成上下文之间的集成:

  • 在一个限界上下文中暴露出应用程序接口(API),然后在另一个限界上下文中通过远程调用(RPC)的方式访问该接口。
  • 使用消息机制。每一个需要交互的限界上下文都使用消息队列或者发布/订阅机制。
  • 通过共享文件或数据库的方式(不常用)。

上下文映射图

上下文映射图是用来表示不同的限界上下文在领域中是如何通过集成相互关联的。它有助于我们直观地看出不同限界上下文之间的关系,帮助我们了解现在所处的位置。类似下面这个简单的框图(图片来自《实现领域驱动设计》):

我们还可以往这个上下文映射图中加入一些其他的内容,比如模块、聚合、或者团队的分布信息等。这些连线和上下文边界的焦点处,就是防腐层(Anti Corruption Layer)的所在。

应用服务

应用服务是领域服务的直接用户,我们将所有的业务领域逻辑放在领域模型中(包括实体、值对象、聚合和领域服务),而将应用服务做成很薄的一层,我们使用应用服务来协调用例任务、管理事务、执行一些必要的安全授权等。

架构

分层架构

分层架构模式被认为是所有架构的始祖,它支持N层架构系统,因此被广泛应用于Web、企业级应用和桌面应用。在这种架构中,我们将一个应用程序或系统分为不同的层次。如图所示为一个典型的DDD系统所采用的传统分层架构(图片来自《实现领域驱动设计》):

其中核心域(领域层)只位于架构中的一层,其上为用户接口层和应用层,其下是基础设施层。每层只能与位于其下方的层发生耦合,较低层是不能直接访问较高层的。

 

用户接口层只用于处理用户请求和展示,不应该包含领域或业务逻辑,如果用户接口层使用了领域模型中的对象,那么此时仅限于数据的渲染展示。

 

应用层用于控制持久化事务和安全认证,或者向其他系统发送基于事件的通知,协调对领域对象的操作,比如聚合。应用层应当尽量做成很薄的一层。

六边形架构(端口与适配器)

在传统的分层架构中,领域层对于基础设施层的依赖还是太重了,比如资源库的实现需要依赖基础设施层提供的持久化机制,有没有一种方法可以减轻领域层对基础设施层的依赖,还真有,那就是依赖倒置原则。依赖倒置原则定义为:

  • 高层模块不应该依赖低层模块,两者都应该依赖于抽象。
  • 抽象不应该依赖于细节,细节应该依赖于抽象。

根据该定义,低层服务应该依赖高层提供的接口,那么在依赖倒置原则中,只存在两层,一层位于最上方(实现),一层位于最下方(抽象),实现依赖抽象而不是抽象依赖实现。

 

当我们把这个概念引入上面的分层架构中时,我们可能会发现,事实上已经不存在分层的概念了,无论是高层还是底层,他们都依赖抽象,这好像把整个分层架构推平了一样,如果我们在这个推平的架构上,加入一些对称性会如何?没错,六边形架构诞生了!

在这种架构中,不同的客户通过“平等”的方式与系统交互,需要新的客户吗?不是问题,只需要添加一个新的适配器将客户输入转化成系统所能理解的参数就行了,同时,系统输出、持久化、消息等都可以通过不同的方式实现,并且是可以互换的。事实上,很多声称使用了分层架构的团队,实际上使用的是六边形架构,因为很多项目都使用了某种形式的依赖注入,并不是说使用依赖注入的天生就是六边形架构,而是说使用依赖注入的架构自然的具有了端口/适配器风格。

CQRS架构

CQRS(Command-Query Responsibility Segregation),命令和查询职责分离。

 

有时我们从资源库中查询所有需要显示的数据是困难的,特别是在需要显示来自不同的聚合类型与实例的数据时。领域越复杂,这种困难度越大。

 

我们并不期望于单单使用资源库来解决这个问题。因为我们需要从不同的资源库获取聚合实例,然后再将这些数据组装成一个数据传输对象(Data Transfer Object,DTO),又或者,我们可以在同一个查询中使用特殊的查找方法将不同资源库的数据组合在一起,如果这些办法都不合适,我们可能要在用户体验上做出妥协,即使界面显示(或交互)生硬地服从与模型的聚合边界,这显然是不合适的。那么,有没有另外一种完全不同的方法可以将领域数据映射到显示界面中呢?有,答案是CQRS。

 

CQRS基于一个基本原则:一个方法要么是执行某种动作的命令,要么是返回数据的查询,而不能两者皆是。在对象层面,这意味着,如果一个方法修改了对象的状态,该方法便是一个命令(Command),它不应该返回数据;如果一个方法返回了数据,该方法便是一个查询,此时它不应该通过直接或间接的方式修改对象的状态。

 

现在,在领域模型中,考虑将那些纯粹的查询功能从方法里面分离出来,聚合将不再有查询方法,而只有命令方法,资源库也只有add()或save()方法,同时只有一个查询方法(getById),资源库不再有其他的查询方法。在将所有的查询方法去除之后,我们将此时的模型称为命令模型(Command Model)。但是我们依然需要向用户展示数据,因此我们将创建第二个模型,该模型专门用于优化查询,也就是查询模型(Query Model)。

 

查询模型是一种非规范化的数据模型,它并不反应领域行为,只是用于数据显示,如果查询模型的存储是SQL数据库,那么每张数据库表(视图)便是一种数据展示视图,代表整个数据展示的一个逻辑子集。

 

因此,领域模型将一分为二,命令模型和查询模型分开存储,最终我们得到的系统如图所示:

命令模型中的每个命令方法在执行完成之后都将发布领域事件,事件订阅器接收所有的领域事件,根据命令模型的更改来更新查询模型(前提是每种领域事件都应该包括足够的数据以便正确地更新查询模型)。

 

其中,监听领域事件来更新查询模型,这一步可以是同步的也可以是异步的。同步的话,可以保证强一致性但也可能会因为引入了更多的更新操作导致系统性能下降;异步的话,更新延迟是不确定的,对最终一致性带来了诸多挑战(我个人认为异步更新才是CQRS架构的灵魂所在,因为这样会使命令模型和查询模型最大化解耦,并且大多数的业务场景其实保证最终一致性就可以了)。

 

总结一下,CQRS架构的好处在于命令模型和查询模型解耦,这也使得命名模型的存储选型上,可以考虑的方案更多了,比如可以使用NoSql。而且命令模型设计的时候,也可以完全不受查询模型的影响,能够设计出更加符合通用语言的模型;当然了,其最大的缺点是当使用异步更新的时候,需要业务上接受最终一致性。

 

以上内容,大多来自《实现领域驱动设计》一书,其中也夹杂了一部分自己的思考,如有不对之处,欢迎拍砖~ 欢迎交流~