什么是DDD
DDD(领域驱动设计)是一种处理高度复杂领域的设计思想,是一种架构设计方法论,是一种设计模式。以高内聚低耦合为目的,把一个复杂的软件应用系统中各个部分进行一个很好的拆解和封装,对软件系统进行模块化的一种思想。DDD不仅可以用于微服务设计,还可以很好地应用于企业中台的设计,也适用于传统的单体应用。
领域模型是什么?
领域模型是关于某个特定业务领域的软件模型。通常,领域模型通过对象模型来实现,这些对象同时包含了数据和行为,并且表达了准确的业务含义。
领域分为问题空间(problem space)和解决方案空间(solution space)。
问题空间是领域的一部分,对问题空间的开发将产生一个新的核心域。对问题空间的评估应该同时考虑已有子域和额外所需子域。因此,问题空间是核心域和其他子域的组合。
解决方案空间包括一个或多个限界上下文,即一组特定的软件模型。这是因为限界上下文即是一个特定的解决方案,它通过软件的方式来实现解决方案。
我们为什么需要DDD
第一:使领域专家和开发者在一起工作,这样开发出来的软件能够准确地传达业务规则;
第二:准确传达业务规则;
第三:可以帮助业务人员自我提高,在DDD中,每个人都在学习,同时每个人又是知识的贡献者;
第四:在DDD中,每个人都在学习,同时每个人又是知识的贡献者;
第五:减少沟通成本,当大家都使用相同的语言进行交流时,每人都能听懂他人所说;
第六:设计就是代码,代码就是设计;
第七: DDD同时提供了战略设计和战术设计两种方式。战略设计帮助我们理解哪些投入是最重要的;哪些既有软件资产是可以重新拿来使用的;哪些人应该被加到团队中?战术设计则帮助我们创建DDD模型中各个部件。
DDD 的基础概念
领域与子域
在研究和解决业务问题时,DDD会按照一定的规则将业务领域进行细分,当领域细分到一定的程度后,DDD会将问题范围限定在特定的边界内,在这个边界内建立领域模型,进而用代码实现该领域模型,解决相应的业务问题。简言之,DDD的领域就是这个边界内要解决的业务问题域。领域是用来确定范围的,范围即边界,在DDD中一直在强调边界,就是这个原因。
领域可以进一步划分为子领域。我们把划分出来的多个子领域称为子域,每个子域对应一个更小的问题域或更小的业务范围。
领域的核心思想就是将问题域逐级细分,来降低业务理解和系统实现的复杂度。通过领域细分,逐步缩小服务需要解决的问题域,构建合适的领域模型。
通用语言
通用语言就是能够简单、清晰、准确描述业务涵义和规则的语言。
通用语言是团队统一的语言,不管你在团队中承担什么角色,在同一个领域的软件生命周期里都使用统一的语言进行交流。那么,通用语言的价值也就很明了,它可以解决交流障碍这个问题,使领域专家和开发人员能够协同合作,从而确保业务需求的正确表达。
这个通用语言到场景落地,大家可能还很模糊,其实就是把领域对象、属性、代码模型对象等,通过代码和文字建立映射关系,可以通过Excel记录这个关系,这样研发可以通过代码知道这个含义,产品或者业务方可以通过文字知道这个含义,沟通起来就不会有歧义,说的简单一点,其实就是统一产品和研发的话术。
界限上下文
限界上下文是用来封装通用语言和领域对象,提供上下文环境,保证在领域之内的一些术语、业务相关对象等(通用语言)有一个确切的含义,没有二义性。 限界上下文是一个显式的边界,领域模型便存在于这个边界之内;在边界内,通用语言中的所有术语和词组都有特定的含义,而模型需要准确地反映通用语言。
限界上下文并不只局限于容纳模型,它通常标定了一个系统、一个应用程序或者一种业务服务。
命名方式:“模型名+上下文”;
上下文映射图
上下文映射图的两种表示方式:
- 画一个简单的框图来表示两个或多个限界上下文之间的映射关系;该框图表示了不同的限界上下文在解决方案空间中是如何通过集成相互关联的。
- 更详细的方式是通过限界上下文集成的源代码实现来表示,
实体
实体 = 唯一身份标识 + 可变性【状态 + 行为】
DDD中要求实体是唯一的且可持续变化的。意思是说在实体的生命周期内,无论其如何变化,其仍旧是同一个实体。唯一性由唯一的身份标识来决定的。可变性也正反映了实体本身的状态和行为。
在设计实体时,我们首先需要考虑实体的本质特征,特别是实体的唯一标识和对实体的查找。只有在对实体的本质特征有用的情况下,才加入相应的属性和行为。
实体以 DO(领域对象)的形式存在,每个实体对象都有唯一的 ID。我们可以对一个实体对象进行多次修改,修改后的数据和原来的数据可能会大不相同。但是,由于它们拥有相同的 ID,它们依然是同一个实体。比如商品是商品上下文的一个实体,通过唯一的ID来标识,不管这个商品的数据如何变化,商品的 ID 一直保持不变,它始终是同一个商品。
值对象
值对象 = 将一个值用对象的方式进行表述,来表达一个具体的固定不变的概念。
值对象可以用于存放实体的唯一标识。值对象是不变(immutable)的,这可以保证实体身份的稳定性,并且与身份标识相关的行为也可以得到集中处理。
当你只关心某个对象的属性时,该对象便可作为一个值对象。 我们需要将值对象看成不变对象,不要给它任何身份标识,还应该尽量避免像实体对象一样的复杂性。
聚合
我们把一些关联性极强、生命周期一致的实体、值对象放到一个聚合里。聚合是领域对象的显式分组,旨在支持领域模型的行为和不变性,同时充当一致性和事务性边界。
聚合有一个聚合根和上下文边界,这个边界根据业务单一职责和高内聚原则,定义了聚合内部应该包含哪些实体和值对象,而聚合之间的边界是松耦合的。按照这种方式设计出来的服务很自然就是“高内聚、低耦合”的。
聚合在 DDD 分层架构里属于领域层,领域层包含了多个聚合,共同实现核心业务逻辑。跨多个实体的业务逻辑通过领域服务来实现,跨多个聚合的业务逻辑通过应用服务来实现。
聚合有以下5个通用的设计原则: (1)在一致性边界之内建模真正的不变条件 聚合用来封装真正的不变性,而不是简单地将对象组合在一起。聚合内有一套不变的业务规则,各实体和值对象按照统一的业务规则运行,实现对象数据的一致性,边界之外的任何东西都与该聚合无关,这就是聚合能实现业务高内聚的原因。 (2) 设计小聚合 如果聚合设计得过大,聚合会因为包含过多的实体,导致实体之间的管理过于复杂,高频操作时会出现并发冲突或者数据库锁,最终导致系统可用性变差。而小聚合设计则可以降低由于业务过大导致聚合重构的可能性,让领域模型更能适应业务的变化。
(3) 通过唯一标识引用其他聚合 聚合之间是通过关联外部聚合根ID的方式引用,而不是直接对象引用的方式。外部聚合的对象放在聚合边界内管理,容易导致聚合的边界不清晰,也会增加聚合之间的耦合度。
(4) 在边界之外使用最终一致性
聚合内数据强一致性,而聚合之间数据最终一致性。在一次事务中,最多只能更改一个聚合的状态。如果一次业务操作涉及多个聚合状态的更改,应采用领域事件的方式异步修改相关的聚合,实现聚合之间的解耦。
(5)通过应用层实现跨聚合的服务调用 为实现微服务内聚合之间的解耦,以及未来以聚合为单位的微服务组合和拆分,应避免跨聚合的领域服务调用和跨聚合的数据库表关联。
从长远看来,遵循聚合原则对整个项目是有益的。我们将尽可能地保证一致性,并且致力于创建高性能的、高可伸缩性的系统。
聚合实现步骤如下: (1) 创建具有唯一标识的根实体 将实体建模成聚合根(Aggregat Root);每个聚合根必须拥有一个全局的唯一标识。 (2)优先使用值对象 我们应该尽量地将根实体所包含的其他聚合建模成值对象,而不是实体。在不至于对模型或基础设施造成明显影响的情况下,采用值对象全部替换的方式是最好的选择。 (3)使用迪米特法则和“告诉而非询问”原则 我们需要在迪米特法则和“告诉而非询问”原则之间进行权衡。前者的限制性更强,它只允许客户端通过聚合根进行访问。另一方面,“告诉而非询问”原则则允许客户端访问聚合根的内部,但是它也要求对聚合状态的修改应该属于聚合本身,而不是客户端。因此,在多数情况下,“告诉而非询问”原则将更加适用。 (4)乐观并发 在我们定义聚合时,最安全的方法便是只为根实体创建版本号。每次在聚合内部执行状态修改命令时,根实体的版本号都会随之增加。 (5)避免依赖注入
聚合根
如果把聚合比作组织,那聚合根就是这个组织的负责人。聚合根也称为根实体,它不仅是实体,还是聚合的管理者。 首先它作为实体本身,拥有实体的属性和业务行为,实现自身的业务逻辑。其次它作为聚合的管理者,在聚合内部负责协调实体和值对象按照固定的业务规则协同完成共同的业务逻辑。最后在聚合之间,它还是聚合对外的接口人,以聚合根ID关联的方式接受外部任务和请求,在上下文内实现聚合之间的业务协同。也就是说,聚合之间通过聚合根ID关联引用,如果需要访问其它聚合的实体,就要先访问聚合根,再导航到聚合内部实体,外部对象不能直接访问聚合内实体。
聚合根的主要目的是为了避免由于复杂数据模型缺少统一的业务规则控制,而导致聚合、实体之间数据不一致性的问题。
贫血模型
所谓的贫血模型是在定义对象时,指定以对象的属性信息,却没有对象的行为信息,最后再通过添加一些对象属性的get/set方法来赋值取值操作。
这些贫血对象在设计之初就被定义为只能包含数据,不能加入领域逻辑;所有的业务逻辑是放在所谓的业务层,需要使用这些模型来传递数据。
战略设计
战略设计强调的是业务战略上的重点,如何按重要性分配工作,以及如何进行最佳整合。
战略设计主要从业务视角出发,建立业务领域模型,划分领域边界,建立通用语言的限界上下文,限界上下文可以作为微服务设计的参考边界。
战略设计会建立领域模型,领域模型可以用于指导微服务的设计和拆分。事件风暴是建立领域模型的主要方法,它是一个从发散到收敛的过程。它通常采用用例分析、场景分析和用户旅程分析,尽可能全面不遗漏地分解业务领域,并梳理领域对象之间的关系,这是一个发散的过程。事件风暴过程会产生很多的实体、命令、事件等领域对象,我们将这些领域对象从不同的维度进行聚类,形成如聚合、限界上下文等边界,建立领域模型,这就是一个收敛的过程。
战术设计
战术设计犹如使用一把精小的画笔在领域模型上描绘着每个细枝末节。其中一个比较重要的工具被用来将若干实体和值对象以恰当的大小聚集在一起。这就是聚合(Aggregate)模式。
战术设计则从技术视角出发,侧重于领域模型的技术实现完成软件开发和落地,包括:聚合根、实体、值对象、领域服务、应用服务和资源库等代码逻辑的设计和实现。
DDD就是以最明确而又可行的方式对领域进行建模。使用领域事件(Domain Events)既可以让你明确地建立模型,也可把模型内部发生的事情分享给需要知道这一切的系统
领域服务
领域中的服务表示一个无状态的操作,它用于实现特定于某个领域的任务。当某个操作不适合放在聚合和值对象上时,最好的方式便是使用领域服务了
可以使用领域服务的情况:
- 执行一个显著的业务操作
- 对领域对象进行转换
- 以多个领域对象作为输入参数进行计算,结果产生一个值对象
应用服务
应用层作为展现层与领域层的桥梁,是用来表达用例和用户故事的主要手段。
应用层通过应用服务接口来暴露系统的全部功能。在应用服务的实现中,它负责编排和转发,它将要实现的功能委托给一个或多个领域对象来实现,它本身只负责处理业务用例的执行顺序以及结果的拼装。通过这样一种方式,它隐藏了领域层的复杂性及其内部实现机制。
应用层相对来说是较“薄”的一层,除了定义应用服务之外,在该层我们可以进行安全认证,权限校验,持久化事务控制,或者向其他系统发生基于事件的消息通知,另外还可以用于创建邮件以发送给客户等。
领域事件
将领域中所发生的活动建模成一系列的离散事件。每个事件都用领域对象来表示……领域事件是领域模型的组成部分,表示领域中所发生的事情。
领域事件 = 事件发布 + 事件存储 + 事件分发 + 事件处理。
领域事件是一个领域模型中极其重要的部分,用来表示领域中发生的事件。忽略不相关的领域活动,同时明确领域专家要跟踪或希望被通知的事情,或与其他模型对象中的状态更改相关联,领域事件包括以下几种: (1)事件发布:构建一个事件,需要唯一标识,然后发布; (2)事件存储:发布事件前需要存储,因为接收后的事建也会存储,可用于重试或对账等; (3)事件分发:服务内直接发布给订阅者,服务外需要借助消息中间件,比如Kafka,RabbitMQ等; (4)事件处理:先将事件存储,然后再处理。
在做用户旅程或者场景分析时,我们要捕捉业务、需求人员或领域专家口中的关键词:“如果发生……,则……”“当做完……的时候,请通知……”“发生……时,则……”等。在这些场景中,如果发生某种事件后,会触发进一步的操作,那么这个事件很可能就是领域事件。
领域事件驱动设计可以切断领域模型之间的强依赖关系,事件发布完成后,发布方不必关心后续订阅方事件处理是否成功,这样可以实现领域模型的解耦,维护领域模型的独立性和数据的一致性。在领域模型映射到微服务系统架构时,领域事件可以解耦微服务,微服务之间的数据不必要求强一致性,而是基于事件的最终一致性。
领域事件的执行需要一系列的组件和技术来支撑;领域事件处理包括:事件构建和发布、事件数据持久化、事件总线、消息中间件、事件接收和处理等,如下图所示:
资源库(仓储)
仓储介于领域模型和数据模型之间,主要用于聚合的持久化和检索。它隔离了领域模型和数据模型,以便我们关注于领域模型而不需要考虑如何进行持久化。
我们将暂时不使用的领域对象从内存中持久化存储到磁盘中。当日后需要再次使用这个领域对象时,根据 key 值到数据库查找到这条记录,然后将其恢复成领域对象,应用程序就可以继续使用它了,这就是领域对象持久化存储的设计思想。
DDD 架构
分层架构
在分层架构中,我们将领域模型和业务逻辑分离出来,并减少对基础设施、用户界面甚至应用层逻辑的依赖,因为它们不属于业务逻辑。将一个复杂的系统分为不同的层,每层都应该具有良好的内聚性,并且只依赖于比其自身更低的层。
典型的DDD系统所采用的传统分层架构,其中核心域只位于架构中的其中一层,其上为用户界面层(User Interface)和应用层(Application Layer),其下是基础设施层(Infrastructure Layer) DDD经典的分层架构如下图所示:
分层架构的一个重要原则是:每层只能与位于其下方的层发生耦合。分层架构也分为几种:在严格分层架构(Strict Layers Architecture)中,某层只能与直接位于其下方的层发生耦合;而松散分层架构(Relaxed Layers Architecture)则允许任意上方层与任意下方层发生耦合。
较低层也是可以和较高层发生耦合的,但这只局限于采用观察者(Observer)模式或者调停者(Mediator)模式[Gamma et al.]的情况。
如果用户界面使用了领域模型中的对象,那么此时的领域对象仅限于数据的渲染展现。在采用这种方式时,可以使用展现模型(Presentation Model)对用户界面与领域对象进行解耦。
用户接口层是应用层的直接客户。
应用服务(Application Services)位于应用层中;应用服务可以用于控制持久化事务和安全认证,或者向其他系统发送基于事件的消息通知,另外还可以用于创建邮件以发送给用户。应用服务本身并不处理业务逻辑,但它却是领域模型的直接客户。应用服务是很轻量的,它主要用于协调对领域对象的操作;应用服务是表达用例和用户故事(user story)的主要手段。因此,应用服务的通常用途是:接收来自用户界面的输入参数,再通过资源库获取到聚合实例,然后执行相应的命令操作。当需要创建新的聚合时,应用服务应该使用工厂(Factory,11)或聚合的构造函数来实例化对象,然后采用资源库对其进行持久化。应用服务还可以调用领域服务来完成和领域相关的任务操作,但此时的操作应该是无状态的。当领域模型用于发布领域事件(Domain Events)时,应用层可以将订阅方注册到任意数量的事件上。
SaaSOvation的开发团队发现,将基础设施层放在最底层是存在缺点的。比如,此时领域层中的一些技术实现是令人头疼的,因为他们违背了分层架构的基本原则。针对这个情况,可以根据依赖倒置原则(Dependency Inversion Principle,DIP) 改进分层架构,根据定义,低层服务(比如基础设施层)应该依赖于高层组件(比如用户界面层、应用层和领域层)所提供的接口。改进分层架构图如下:
采用依赖倒置原则,使领域层和基础设施层都只依赖于由领域模型所定义的抽象接口。由于应用层是领域层的直接客户,它将依赖于领域层接口,并且间接地访问资源库和由基础设施层提供的实现类。应用层可以采用不同的方式来获取这些实现,包括依赖注入(Dependency Injection)、服务工厂(Service Factory)和插件(Plug In)。
六边形架构(端口与适配器)-首选架构
六边形架构也称为端口与适配器。对于每种外界类型,都有一个适配器与之相对应。外界通过应用层API与内部进行交互。六边形架构图如下:
我们可以将端口想成是HTTP,而将适配器想成是Java的Servlet或JAX-RS的REST请求处理类;在这种情况下,端口是消息机制,而适配器则是消息监听器,因为消息监听器将负责从消息中提取数据,并将数据转化为应用层API(领域模型的客户)所需的参数。
在使用六边形架构时,我们应该根据用例来设计应用程序;任何客户都可能向不同的端口发出请求,但是所有的适配器都将使用相同的API。应用程序边界,即内部六边形,也是用例边界。
六边形架构的好处是我们可以轻易地开发用于测试的适配器。
面向服务架构(SOA)
面向服务设计的有八大原则
服务设计原则 | 描述 |
---|---|
服务契约 | 通过契约文档,服务阐述自身的目的与功能 |
松耦合 | 服务将依赖关系最小化 |
服务抽象 | 服务只发布契约,而向客户隐藏内部逻辑 |
服务重用性 | 一种服务可以被其他服务所重用 |
服务自治性 | 服务自行控制环境与资源以保持独立性,这有助于保持服务的一致性和可靠性 |
服务无状态性 | 服务负责消费放的状态管理,这不能与服务的自治性发生冲突 |
服务可发现性 | 客户可以通过服务元数据来查找服务和理解服务 |
服务组合型 | 一种服务可以由其他的服务组合而成,而不管其他服务的大小和复杂性如何。 |
将服务的8大原则与六边形架构结合起来,此时服务边界位于最左侧,而领域模型位于中心位置,如下图所示:
消费方可以通过REST、SOAP和消息机制获取服务。技术服务可以是REST资源、SOAP接口或者消息类型。业务服务强调业务战略,即如何对业务和技术进行整合。
命令和查询职责分离(CQRS )
CQRS是将紧缩(Stringent)对象(或者组件)设计原则和命令-查询分离(CQS)应用在架构模式中的结果。CQRS旨在解决数据显示复杂性问题。
查询模型也被称为读模型,命令模型也被称为写模型。领域模型将被一分为二,命令模型和查询模型分开进行存储。最终,我们得到的组件系统如图:
在CQRS中,来自客户端的命令通过单独的路径抵达命令模型,而查询操作则采用不同的数据源,这样的好处在于可以优化对查询数据的获取,比如用于展现、用于接口或报告的数据。
###事件驱动架构 事件驱动架构(Event-Driven Architecture,EDA)是一种用于处理事件的生成、发现和处理等任务的软件架构。
在一个事件驱动架构中融入了六边形架构风格。该事件驱动架构通过消息机制完成对所有系统的解耦。
一个系统的输出端口所发出的领域事件将被发送到另一个系统的输入端口,此后输入端口的事件订阅方将对事件进行处理。对于不同的限界上下文来说,不同的领域事件具有不同含义,也有可能没有任何含义。在一个限界上下文处理某个事件时,应用程序API将采用该事件中的属性值来执行相应的操作。应用程序API所执行的命令操作将反映到命令模型中。
有时,我们的业务可能需要对发生在领域对象上的修改进行跟踪。跟踪每个源文件的修改应用在单个实体上,然后用在单个聚合上,再用于模型中的每个聚合,那么我们便能体会到在对象层面上跟踪变化的好处,进而体会到变化跟踪对于整个系统的好处。而事件源的核心便是变化跟踪,事件源是对于某个聚合上的每次命令操作,都有至少一个领域事件发布出去,该领域事件描述了操作的执行结果。事件源模式如下:
从高层次看事件源,由聚合发布的事件被保存到事件存储中,同时这些事件被用于跟踪模型的状态变化。资源库从事件存储中读取事件,并将这些事件应用于对聚合状态的重建。
事件源有助于获得高吞吐量的领域模型,从而极大地提高事务处理效率。事件源还有助于提高CQRS查询模型的伸缩性,因为此时查询模型的数据源可以在事件存储更新之后得到静默更新。
数据网织和基于网格的分布式计算
数据网织(Data Fabric),有时也称为网格计算,可以解决大数据计算。数据网织的一个好处是它对领域模型提供了自然的支持,几乎消除了所有的阻抗失配。事实上,分布式缓存可以非常容易地对领域模型进行持久化,此时可以将它看成是一种聚合存储(Aggregate Store)。简单地说,在数据网织中,聚合即是基于图的缓存中的值部分,而聚合的唯一标识则是标识键。这里的键即是聚合的唯一标识。聚合的状态将被持久化为二进制数据或文本数据。
数据网织可以很好地支持事件驱动架构风格,因为它能确保对事件的投递。大多数数据网织都有内建的事件支持,即可以对缓存层面和入口层面上所发生的操作自动地发出事件通知。
数据网织是支持开放架构的,因此应该有种方法可以从聚合中直接发布领域事件。此时,领域事件可能需要继承框架中的某种事件类型。
有些数据网织支持一种名为持续查询(Continuous Query)的事件通知。客户端可以向数据网织注册一个查询,当对缓存的修改可能影响到查询结果时,客户端将自动接收到事件通知。
数据网织的另一个功能是,它可以在所有复制缓存范围内完成分布式处理,然后将处理结果聚合到一起发给客户端。这使得数据网织可以用于事件驱动的、分布式的并行处理过程中。
微服务设计与拆分的困境
微服务可以解决原来采用集中式架构的单体应用的很多问题,但是如何设计微服务和拆分业务一直是件让人头疼:微服务颗粒度多大?微服务的边界应该在哪里?而且微服务架构模式的提出者Martin Fowler在提出微服务的时候也没有告诉我们究竟如何拆分微服务。微服务设计与拆分困难的根本原因是不知道业务或者微服务的边界到底在哪里。若确定了业务边界和应用边界,微服务设计与拆分的困境也就迎刃而解了。
DDD 解决微服务困境
在战略设计中我们建立了领域模型,划定了业务领域的边界,建立了通用语言和限界上下 文,确定了领域模型中各个领域对象的关系。在此过程中除了完成业务端领域模型的设计工作,也确定了应用端的微服务边界。 这个过程可以分为三步:
第一步:在事件风暴中梳理业务过程中的用户操作、事件以及外部依赖关系等,根据这些要素梳理出领域实体等领域对象。
第二步:根据领域实体之间的业务关联性,将业务紧密相关的实体进行组合形成聚合,同时 确定聚合中的聚合根、值对象和实体。
第三步:根据业务及语义边界等因素,将一个或者多个聚合划定在一个限界上下文内,形成 领域模型
DDD 和微服务的关系
DDD 是一种架构设计方法,微服务是一种架构风格,两者从本质上都是为了追求高响应力,而从业务视角去分离应用系统建设复杂度的手段。两者都强调从业务出发,其核心要义是强调根据业务发展,合理划分领域边界,持续调整现有架构,优化现有代码,以保持架构和代码的生命力,也就是我们常说的演进式架构。
DDD 主要关注:从业务领域视角划分领域边界,构建通用语言进行高效沟通,通过业务抽象,建立领域模型,维持业务和代码的逻辑一致性。
微服务主要关注:运行时的进程间通信、容错和故障隔离,实现去中心化数据管理和去中心化服务治理,关注微服务的独立开发、测试、构建和部署。