领域驱动设计(Domain-Driven Design)

领域驱动设计,Domain-Driven Design,简称 DDD。

软件开发的本质

在理解领域驱动设计之前,我们先思考一下软件开发的本质是什么。从本质上来说,软件开发过程就是问题空间到解决方案空间的一个映射转化

在问题空间中,我们主要是找出某个业务面临的挑战及其相关需求场景用例分析;而在解决方案空间中,则通过具体的技术工具手段来进行设计实现。就软件系统来说,“问题空间”就是系统要解决的“领域问题”。因此,也可以简单理解为一个领域就对应一个问题空间,是一个特定范围边界内的业务需求的总和。“领域模型”就是“解决方案空间”,是针对特定领域里的关键事物及其关系的可视化表现,是为了准确定义需要解决问题而构造的抽象模型,是业务功能场景在软件系统里的映射转化,其目标是为软件系统构建统一的认知。例如,请假系统解决的是人力工时的问题,属于人力资源领域,对口的是HR部门;费用报销系统解决的是员工和公司之间的财务问题,属于财务领域,对口的是财务部门;电商平台解决的是网上购物问题,属于电商领域。

可以看出,每个软件系统本质上都解决了特定的问题,属于某一个特定领域

总结一下,领域模型在软件开发中的主要起到如下作用:
(1)帮助分析理解复杂业务领域问题,描述业务中涉及的实体及其相互之间的关系,是需求分析的产物,与问题域相关。
(2)是需求分析人员与用户交流的有力工具,是彼此交流的语言。
(3)分析如何满足系统功能性需求,指导项目后续的系统设计。

什么是 DDD?

DDD 是指通过统一语言、业务抽象、领域划分和领域建模等一系列手段来控制软件复杂度的方法论。

领域驱动设计关心的是业务中的领域划分(战略设计)和领域建模(战术设计),其开发过程不再以数据模型为起点,而是以领域模型为出发点,研发过程如下图所示。领域模型对应的是业务实体,在程序中主要表现为类、聚合根和值对象,它更加关注业务语义的显性化表达,而不是数据的存储和数据之间的关系。

DDD 的优势

统一语言

统一语言(Ubiquitous Language)的主要思想是让应用能和业务相匹配,这是通过在业务与代码中的技术之间采用共同的语言达成的。业务语言起源于公司的业务侧,业务侧拥有需要实现的概念。业务语言中的术语由公司的的业务侧和技术侧通过协商来定义(意味着业务侧也不能总是选到最好的命名),目标是创造可以被业务、技术和代码自身无歧义使用的共同术语,即统一语言。代码、类、方法、属性和模块的命名必须和统一语言相匹配,必要的时候需要对代码进行重构!试想,在PRD文档、设计文档、代码以及团队日常交流中,如果有一套领域术语是统一无歧义的,是否会极大地提升沟通和工作效率?在日常工作中,因为概念理解不一致,或者语言表达上的问题,导致沟通效率低,甚至发生误解的情况实在太多了。所以,明确概念、形成统一语言至关重要。

面向对象

DDD的核心是领域模型,这一方法论可以通俗地理解为先找到业务中的领域模型,以领域模型为中心,驱动项目开发。领域模型的设计精髓在于面向对象分析、对事物的抽象能力,一个领域驱动架构师必然是一个面向对象分析的大师。DDD鼓励我们接触到需求后第一步就是考虑领域模型,而不是将其切割成数据和行为,然后用数据库实现数据,用服务实现行为,最后造成需求的首尾分离。DDD会让你首先考虑业务语言,而不是数据。DDD强调业务抽象和面向对象编程,而不是过程式业务逻辑实现。重点不同,导致编程世界观不同。

业务语义显性化

统一语言也好,面向对象也好,最终的目都是为代码的可读性和可维护性服务。统一语言使得我们的核心领域概念可以无损地在代码中呈现,从而提升代码的可理解性。例如,在银行转账的案例中,按照事务脚本的写法来写“透支策略”的业务概念,其含义完全被淹没在代码逻辑中没有突显出来。但是,如果我们使用策略模式将其抽象出来,让业务语义得到显性化的表达,代码的可读性就会提升很多。面向对象也是让代码尽量体现领域实体和实体之间的关系原貌,所以目的也是业务语义被显性化地表达,显性化的结果是代码更容易被理解和维护,殊途同归,一切都是为了控制复杂度。在软件的世界里,任何的方法论如果最终不能落在“减少代码复杂度”这个焦点上,那么都是有待商榷的

分离业务逻辑和技术细节

代码复杂度是由业务复杂度和技术复杂度共同组成的。实践DDD还有一个好处,是让我们有机会分离核心业务逻辑和技术细节,让两个维度的复杂度有机会被解开和分治。核心业务逻辑是整个应用的核心,最好只是简单Java类(Plan Old Java Object,POJO)。也就是说,核心业务逻辑对技术细节没有任何依赖,依赖都是由外向内的,即使有由内向外的依赖,也应该通过依赖倒置来反转依赖的方向。通过这样的划分,Entities 只要安安心心地处理业务逻辑就好,业务逻辑越复杂,这样划分带来的好处越明显。

业务逻辑和技术细节分离的架构为什么说数据库、UI和框架都是技术细节呢?

(1)数据库:业务逻辑不应该受限于存储方式,也就是不论你是使用关系型数据库还是NoSQL,都不应该影响业务逻辑的实现。数据本身很重要,但数据库技术仅仅是一个实现细节。
(2)UI:UI只是一种I/O设备的呈现,Web、WAP和Wireless都是不同的I/O,我们的核心业务逻辑应该与如何呈现解耦,以及针对不同的端可以使用不同的适配器(Adaptor)去做适配。
(3)框架:不要让框架侵入我们的核心业务代码,以Spring为例,最好不要在业务对象中到处写@autowired注解。业务对象不应该依赖框架。

这么说来,这些技术细节是不重要了吗?不是的,技术细节是一个系统的必要组成部分,也非常重要。技术细节和核心业务逻辑是两个维度的重要性,如果把软件比喻成一个人,那么核心业务逻辑是大脑,技术细节是身体,二者都很重要,分开处理主要是为了降低复杂度。

DDD核心概念

领域实体

现实世界中的事物在软件世界中可以被模拟成一个对象:该事物在现实世界中被赋予什么职责,在软件世界中就被赋予什么职责;在现实世界中拥有什么特性,在软件世界中就拥有什么属性;在现实世界中拥有什么行为,在软件世界中就拥有什么函数;在现实世界中与哪些事物存在怎样的关系,在软件世界中就应当与它们发生怎样的关联。这正是面向对象编程的核心思想,也是DDD中寻找领域实体的核心思想。

假如现在你需要设计一个中介系统,一个典型的User Story是“小明去找工作,中介让他留个电话,有工作机会就会通知他”。我们要如何寻找该业务中的关键领域实体呢?一个简单的方式就是“找名词”,分析这些名词,不难得到以下可能成为实体的候选项。

(1)小明:一个求职者。
(2)电话:求职者的相关信息,可以是一个属性。
(3)中介:可以拆解为中介公司和中介公司的员工两个概念。
(4)工作机会:对于中介系统来说,工作机会应该是最关键的实体之一。
(5)通知:作为名词是一个实体,但是作为一个动词是在暗示我们可以使用Notify。

是的,对于这个简单的User Story,这样分析就可以了。当然,随着更多的Story被加入,我们会补充更多的实体,比如增加了“中介费是按照小明第一个月工资的30%收取”,那么就可能要引入“订单”和“支付”等实体。

以上就是我在实际工作中寻找领域实体的大致过程。从方法论的角度来说,也叫作“用例分析法”。

聚合根

聚合根(Aggregate Root)是DDD中的一个概念,是一种更大范围的封装,会把一组有相同生命周期、在业务上不可分割的实体和值对象放在一起,只有根实体可以对外暴露引用,这也是一种内聚性的表现。确定聚合边界要满足固定规则(Invariant),是指在数据变化时必须保持的一致性规则,具体规则如下:
(1)根实体具有全局标识,最终负责检查规定规则。
(2)聚合内的实体具有本地标识,这些标识在Aggregate内部才是唯一的。
(3)外部对象不能引用除根Entity之外的任何内部对象。
(4)只有Aggregate的根Entity才能直接通过数据库查询获取,其他对象必须通过遍历关联来发现。
(5)Aggegate内部的对象可以保持对其他Aggregate根的引用。
(6)Aggregate边界内的任何对象在修改时,整个Aggregate的所有固定规则都必须满足。仍以银行转账的例子来说明,如图5所示,账号(Account)是客户信息(CustomerInfo)Entity和值对象(Address)的聚合根,交易(Tansaction)是流水(Journal)的聚合根,流水是因为交易才产生的,具有相同的生命周期。

领域服务

有些领域中的动作是一些动词,看上去并不属于任何对象。它们代表了领域中的一个重要的行为,所以不能忽略它们或者简单地把它们合并到某个实体或者值对象中。当这样的行为从领域中被识别出来时,推荐的实践方式是将它声明成一个服务。这样的对象不再拥有内置的状态,其作用仅仅是为领域提供相应的功能。Service往往是以一个活动来命名,而不是Entity来命名。例如在银行转账的例子中,转账(transfer)这个行为是一个非常重要的领域概念,但是它发生在两个账号之间,归属于账号Entity并不合适,因为一个账号Entity没有必要去关联它需要转账的账号Entity。在这种情况下,使用MoneyTransferDomainService就比较合适了。识别领域服务,主要看它是否满足以下3个特征:
(1)服务执行的操作代表了一个领域概念,这个领域概念无法自然地隶属于一个实体或者值对象。
(2)被执行的操作涉及领域中的其他对象。
(3)操作是无状态的。

领域事件

领域事件(Domain Event)是在一个特定领域由一个用户动作触发的,是发生在过去的行为产生的事件,而这个事件是系统中的其他部分或者关联系统感兴趣的。为什么领域事件如此重要?因为在分布式环境下,很少有业务系统是单体的(Monolithic),消息作为分布式系统间耦合度最低、最健壮、最容易扩展的一种通信机制,是我们实现分布式系统互通的重要手段。关于领域事件,我们需要注意两点,分别是事件命名和事件内容。

事件命名事件是表示发生在过去的事情,所以在命名上推荐使用Domain Name + 动词的过去式 + Event,这样可以更准确地表达业务语义。例如,在银行转账的例子中,对于转账成功和失败我们都需要发出事件通知,可以定义两个领域事件如下。
(1)MoneyTransferedEvent:表示转账成功发出的事件。
(2)MoneyTransferFailedEvent:表示转账失败发出的事件。

事件内容事件内容在计算机术语中叫作payload,有以下两种形式。
(1)自恰(Enrichment):就是在事件的payload中尽量多放数据,这样consumer不需要回查就能处理消息,也就是自恰地处理消息。
(2)回查(Query-Back):这种方式是只在payload放置id属性,然后consumer通过回调的形式获取更多数据。这种形式会加重系统的负载,可能会引起性能问题。

边界上下文

可以理解成微服务架构里的服务

领域实体的意义是有上下文的,比如同样是Apple,在水果店和苹果手机专卖店中表达出的含义就完全不一样。边界上下文(Bounded Context)的作用是限定模型的应用范围,在同一个上下文中,要保证模型在逻辑上统一,而不用考虑它是不是适用于边界之外的情况。那么不同上下文之间的业务实体要如何实现交互呢?就像关系数据库和对象之间需要ORM一样,不同上下文之间的实体也需要映射。在DDD中,这种机制叫作上下文映射(Context Mapping),我们可以使用防腐层(Anti-Corruption)来完成映射的工作。如图6所示,在我们开发的CRM系统中,商家的客户大部分是来自于ICBU网站的会员,虽然二者有很多属性都是一样的,但我们还是有必要引入防腐层来做上下文映射,主要有以下两个原因:
(1)虽然属性大部分一样,但二者的作用和行为在各自上下文中是不一样的。
(2)解耦影响,加入了防腐层之后,网站的会员变化就不会影响到CRM系统了。

为什么DDD难以落地呢?

(1)第一,国内关于DDD的最佳实践还是太少了,除了知名的几个大厂以外很少看到有关于DDD的落地实践。这里附上美团的DDD实践,美团领域驱动设计;最佳实践太少意味着,我们可以参考的资料就少,承担的项目失败的风险就大。

(2)第二,DDD中出现了很多的概念和术语,比如 聚合根,值对象,六边形架构,CQRS(命令和查询职责分离),事件驱动等等概念。很多人看到了这么多概念的时候,心中就开始打退堂鼓了。

(3)第三,DDD需要我们在领域建模花费很多的时间和精力,而且还可能导致付出和收益不成正比的情况。因为在界限上下文的划分上是非常考验架构师的业务水平。如果没有将业务模型很好的识别出来,那么可能很快模型就会在迭代的过程中腐败掉了。


转载请注明来源。 欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。 可以在下面评论区评论,也可以邮件至 sharlot2050@foxmail.com。

文章标题:领域驱动设计(Domain-Driven Design)

字数:4.3k

本文作者:夏来风

发布时间:2021-04-19, 00:24:12

原始链接:http://www.demo1024.com/blog/java-ddd/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。