“最初我给本文起的标题是《领域驱动设计-理论入门篇》,但是文中掺杂了太多的个人理解,入门篇就显得太官方了,为了避免错误的理解把读者带偏,所以改成《领域驱动设计-理论心得篇》,期待你能发现其中的错误,并可以一同探讨。文章发布前邀请了拥有丰富领域驱动设计实战经验的大佬校正,所以我有理由相信,这篇文章一定可以给你带来一些有用的入门指导建议”

01 写在前面

领域驱动设计(Domain-Driven Design,DDD)是一个有关软件开发的方法论,他提出基于领域开发的开发模式,基于DDD理论,我们可以设计出高质量的软件模型。

这是一篇关于DDD理论大于实践的文章,其中会掺杂一些必要的代码用例,但通常都是伪代码。通常来说,理论介绍往往是枯燥的,所以我假象你对DDD是有兴趣的,这样才能趋势你耐心读完本文。我试图用言简意赅的句子解释清楚其中涉及到的各个概念,但是我不确定我理解的意思和我表达的意思是否符合你的口味,所以如果你在阅读过程中发现有什么地方我没有说清楚,我非常欢迎你能够联系到我进行探讨,你可以从公众号下方菜单处看到我的联系方式。

初次接触DDD是20年8月份,因为换工作之后的项目需要,在飞哥(我的职场导师)的引领下慢慢进入了DDD世界。在我看来,业务很单一的项目是没有必要使用DDD的,如果一个纯CRUD(创建:Create,读取:Read,更新:Update,删除:Delete) 就能搞定的项目被要求使用DDD开发,就是在搞事情,这大概率会加重团队的负担,但是这不意味着DDD在简单项目里没有用武之地。DDD是一个方法论,你需要掌握他然后拆解成若干模型,在合适的地方使用合适的模型,就像你把飞机的刹车原理应用在你的小汽车制动上,这有何不可!

02 为什么使用DDD

2004年,Eric Evans 发表《Domain-Driven Design –Tackling Complexity in the Heart of Software》,中文译为《领域驱动设计》,强调业务架构和技术架构的高度映射统一,提出并强化了业务领域、战略建模、限界上下文、防腐层等诸多新兴概念,DDD理念赋予技术架构必要的灵活性使之足以跟上业务的变化节奏,从此,DDD进入群众的视野。但在当时,在单体应用盛行的年代,DDD在单一的业务场景和快速开始快速开发快速上线的需求面前显得十分鸡肋,因此DDD理念带来的“额外工作量”导致其始终未能占领较大市场。

而后,随着业务场景的日渐复杂庞大,开发、部署、维护简单的单体架构带来的迭代难、重构难、合作难等弊端不再被容忍,于是服务化概念被提出并盛行一时,直到2014年,伴随着容器化技术的成熟以及DevOps文化的兴起,由服务化概念进一步升华的微服务概念以及微服务架构被推崇并一度成为软件开发领域不可或缺的最佳实践。微服务架构极力追求业务层面的解耦和重用,以业务为导向进行服务划分,技术架构上要求做到高内聚低耦合,同时解决单体应用上出现的多团队协作难以及牵一发而动全身等诸多问题。微服务的出现和日渐成熟,仿佛成为了复杂业务开发实践上的银弹,似乎不懂微服务的开发已然是一个不合格的开发。可实际上是这样吗,微服务解决了诸多难题,就没有副作用吗?

微服务的思想使复杂业务系统得到合理的拆分,分而治之解决了多团队合作困难问题,RPC(Remote Procedure Call 远程服务调用)拉通了不同语言开发之间的屏障。但是在应对多变的业务或者拆分出来的服务领域比较庞大时上始终提不上劲,甚至病情有所恶化。业务需求的变化带来的是各个服务之间的相互猜忌,相关人员往往要花更多的时间定位哪些服务需要作出响应,这还没完,实践中往往还会发生服务间相互推诿的情况,在已经“稳定”的代码上响应业务的变更,这是对代码动刀子的活,各团队人员在主观臆测谁应该动多一点谁应该动少一点时,往往得不到一个大家都满意的结论。哪怕最终问题被解决了,因为开发同学已经搞不清楚影响范围,在测试同学介入时往往也是抓耳挠腮。而对于最后即将上线的程序,开发人员还不得不小心翼翼的给各个请求加上各种“开关”以应对源自上下游服务的不信任,同时依然要为那些依赖服务不定时的变更而感到惶恐。甚至不幸的是如果你服务拆分之后的领域依然很庞大,那可能系统之间的耦合问题被解决了但是系统内如何应对如此庞大的领域业务?类似这样关于业务需求变更以及拆分后的服务依然庞大带来的问题,微服务思想并没有提供有效的指导建议。

我通常把业务需求的变更分为两类,一类是系统类变更,另一类是非系统类变更,非系统类变更就比如支付功能需要用户的点击操作,这个操作属于业务范畴,但是又不是系统控制的。而系统类变更,最终体现在系统内部功能的变更上,而微服务思想并未在服务功能的拆分和治理上提出有效的建议,换句话说,微服务关注的点是服务级别的,很好的阐述了各个服务各个应用之间的依赖关系以及提出了很好的治理方案,但在服务之下,不同功能之间的协作关系却缺乏较好的指导建议。

DDD正好可以弥补这个短板!当然,DDD与微服务的关系绝对不只是弥补短板这么简单,DDD理念使业务架构和技术架构达到高程度的契合,倡导结合各类优秀合适的“模式”(比如设计模式)进行开发,同时将设计重心放在复杂领域的划分上,这使得一向冰冷的技术架构能够灵活地应对业务的变化,特别是当今时代想找到一个不怎么变化的业务实在太难。

需要解释的是,我并没有说DDD本身能够解决业务多变导致的所有问题,DDD只是方法而不是手段,他提出的适配器结合防腐层概念可以一定程度降低外部系统业务变更导致的代码变更而带来的影响,但模式才是真正能够解决本身业务多变的功臣。合适的模式与领域相结合,这便是DDD对于应对多变业务的指导建议。当然,不要指望存在一个系统可以设计之初就兼容未来所有的业务变化,业务变化一定会带来系统的变更甚至架构的变更,我们能做的只是降低变更带来的风险以及重构需要的成本。

03 什么是DDD

DDD作为一个有关软件开发的方法论,他提出基于领域开发的开发模式,基于DDD理论,我们可以设计出高质量的软件模型。所有的DDD实践都需要围绕着其方法论而展开,这意味着DDD不像是某些技术框架即使不懂原理也能根据经验把碗端稳,了解其理论知识(观点)是步入DDD殿堂的第一步。

同DDD所倡导的建立通用语言一样,我现在试图和各位对某些“术语”的理解上达成一致,注意,要完全理解和掌握这些术语可能不是一件容易的事情,甚至会很枯燥。但是这没关系,术语只是基于DDD开发的开胃菜,你大可以只是先掌握他们的基本概念,在真正决定动手的时候再详细了解。从知,到知其然,再到知其所以然!

04 通用语言

通用语言是DDD最基础概念之一,可以从字面意思上解释。通用语言的目的是在业务语言和技术语言之间建立一个良好的映射关系,一个项目的开展通常包含多中利益相关方:业务、产品、项管、项目leader、开发leader、开发、测试等,通用语言的建立就是为了在项目的开展过程中,项目所包含的所有相关人员在针对同一个术语进行理解的时候保持统一,这个工作必须在项目开始前就做好(当然,项目开展过程中如果发现某个通用语言设计不合理也务必修改并同步所有相关人员),这将在项目行进过程中为我们降低相当大的沟通成本,以及在代码开发过程中开发人员可以花更少的时间去思考某个领域的概念或者方法字段的命名。

比如:业务语言是“删除某模板的时候校验该模板是否有效,且删除成功之后,需要发短信给相关负责人”;从技术的角度来看,删除、校验、发送短信是动作,模板和相关负责人是资源,删除成功是事件,技术语言就是某动作操作了某资源然后发布某事件。如此看来业务语言和技术语言是不一样的,正因为如此,通用语言才显得至关重要。

再比如:“素材”这个术语,在知识库领域里,素材通常表示一系列图片或者富文本,但是在PS领域,素材则表示其他的可以帮助构成整个PS作品的某个单一的元素。这需要在通用语言约定时说清楚,以防止某个精通PS的同学听到我说素材的时候不自觉地联想到笔刷或者矢量楼房等别的领域的东西。

05 领域专家

在后面的内容中,你可能会偶尔看到“领域专家”这个字眼,领域专家并不是一个职位,他表示的是任何一个精通业务的人,相比于软件设计者和开发者,他可能了解更多有关业务领域的背景和知识,他可能是项目经理可能是产品经理可能是业务经理甚至有可能是一个保安大叔。

06 贫血症和失忆症

软件的贫血症是指某个领域对象中包含大量的get和set方法(可能包含极少的业务代码)而缺乏内在行为(也称之为:贫血领域对象),此时的领域对象仅仅是用来存储属性值的容器。而真正的事件行为被放在所谓的service层,service层会暴露我们所需要的各种业务方法,并直接访问repository层完成数据的查询或者持久化,这看起来似乎没什么不好。我们熟悉的MVC不就是这么干的吗?但是想想,当我们的业务操作变得很复杂,每个领域对象可能被频繁地在不同的业务方法中使用,不断的被初始化,然后set或者get,到最后你就会发现,这个领域对象就是个完全没有生命力的类,哪里需要就new一下。这样使用领域对象的开发方式被称为面向过程开发,这导致的结果就是没有人来告诉开发人员到底这个领域对象到底是什么,在哪里出现,又被用来干了什么?这就导致了所谓的“失忆症”。

下面是一个典型的贫血领域对象,其中仅包含属性set和get方法,没有业务逻辑:

07 领域、子域和限界上下文

领域(Domain)是一个比较广泛的概念,广义上来讲,领域就是一个组织所做的事情以及其中所包含的一切。对应到软件里来讲,领域就是软件所实现的业务需求所规定的业务范围以及我们可以通过软件赋予这个业务需求的一系列行为活动。比如你所在的公司现在要开辟一条客服管理的业务线,当你的团队接受了这个需求,那你们即将面对的就是一个在线客服领域,其中包含所有的业务需求以及业务活动,比如:IM聊天、知识库、机器人语义识别、工单、质检等等。

由于领域的概念过于灵活,在软件工程中,领域又被划分为问题域和解系统。DDD传承这样的方案,将领域划分为问题空间(problem space)和解决方案空间(solution space),在问题空间中,我们思考业务所面临的挑战,而在解决方案空间中我们思考软件如何解决问题空间中的挑战。

子域(Subdomain)是问题空间中概念,在对问题空间的开发过程中将诞生各种子域,如核心子域、支撑子域、通用子域等。

解决方案空间包含一个或多个限界上下文(Context),每个限界上下文通过软件的方式实现并组成最终的解决方案,因此我们也称这样一个或多个限界上下文为一组特定的软件模型。限界上下文是一个显式边界,领域模型便存在于这个边界内并把上下文通用语言表达成软件模型。

你可以在下面这张图里看到他们之间的关系。

08 架构

有很多架构方案可以在实施DDD时使用,这里介绍两种:分层架构和六边形架构。

分层架构将一个系统分成若干个层次,层与层之间的依赖关系清晰,是最广泛也是最受欢迎的架构之一。下图是DDD分层架构:

层级架构是最容易理解的,所以我们不做过多的说明。值得一提的是,在这个架构图中,领域层依赖基础设施层,实际开发中,你经常会遇到领域对象持久化到资源层的案例,如果你在基础设施层实现领域层的持久化接口,那就意味着领域层和基础设施层之间的依赖关系被搞反了,这显然违背了DDD分层架构原则。有一个方案可以实现这样的案例同时保持DDD分层架构不被破坏,那就是将持久化接口的实现放到应用层,再由应用层访问基础设施层完成持久化。如图:

六边形架构(端口/适配器架构)提出的是一种对称架构的思想,应用程序和领域模型被包含在六边形架构的中间,任何一个外部服务或者接口都可以平等地与系统交互。只需要给每个与系统产生交互的外部服务编写属于他的“适配器”就好了,适配器的职责就是将外部系统的输入转换成内部系统API所能理解的参数。同时,内部系统对外的输出,比如持久化、消息队列等都可以在适配器中完成。这意味着层级结构中的“基础设施层”被认为和外部系统是一样的,他也可以通过适配器与系统内部产生依赖关系,从而直接在适配器中实现领域内的持久化接口。这是一个更容易也更适合开发的DDD架构模型,六边形架构如下图所示(出自《实现领域驱动设计》):

六边形架构并不是说领域服务只会暴露六个方向端口供适配,“六边形”只是按照抽象出来的架构图而产生的一个别名,实际上六边形架构和六边形没什么直接的关系,你要是乐意也可以叫他圆形架构,只不过社区可能更加认可六边形架构这个说法。

PS:六边形架构的真实名称实际上是端口与适配器架构。

09 实体

在现实世界中,存在这样一个情况:人,不无论身在何处,身着何物,人还是这个人,这时候我们可以说人是一个实体(Entity);衣服,对于人而言,则只是一个附加属性,允许随意变更,无生命,无灵魂,我们可以说此时对于人而言,衣服就是值对象(Value Object)。注意这里我的措辞,衣服相对于人这个实体而言才是值对象,也就是说值对象的概念并不是绝对的,是不是实体,是不是值对象,什么时候是实体,什么时候是值对象,这需要我们先划分业务领域,站在领域的视角再完成定义。

在DDD中,实体的定义可以这么描述:一个实体是一个唯一的东西,并且可以长时间持续的变化。我们可以对一个实体完成多次修改,这可能会导致这个实体对象和先前的状态不太相同,但是,由于他们拥有相同的身份标识,他们依然还是一个实体。(在刚刚的例子中,我们可以认为“人”具备的身份标识就是身份证号码)

实际开发中,这样的概念往往得不到太多的关注,在我以往接触的代码程序中,很难看到真正的实体,反之无生命无状态无标识的三无对象特别多,比如一个User类,在CRUD的每个方法中都new了一个User对象,然后调用get方法set方法完成数据绑定,最后完成存储或者查询功能,程序的作者则试图通过业务代码将这些User对象捆绑成一个”实体“。当然,这里绝对不是说这样的对象不好,DDD强调合适的地方使用合适的对象,缺乏真正实体而存在大量没有标识的对象的应用可能更适合一个简单的初级的CRUD系统,面向过程的开发模式在前期的开发速度是极快的,对于小型的前期项目再合适不过了。只是随着业务的拓展,原来的CRUD系统需要承担更多更复杂的业务功能以至于最后同一个实体被分散到系统的各个角落,从而产生我们所谓的“失忆症”。是的,不正确应用实体和对象并不会让你的系统崩溃,只是有很大的概率会让你看不懂他们。

10 值对象

值对象和实体不同,值对象没有唯一的身份标识,也没有实体具备的可变性,对于两个值对象而言,有任何一个属性不同,则两个值对象不同,如果所有属性相同,则DDD认为两个值对象相同并允许相互替代。正因为如此,我们可以非常方便快捷地对值对象进行创建、测试、使用、优化以及维护等,这使得值对象在DDD中保持着十分重要的地位。

DDD倡导值对象在实际开发中的不变性,认为值对象一旦赋值,那其在其依附实体的整个生命周期中将不会被改变,在实践中则体现在,我们通常会为值对象暴露一个初始的构造函数,但是不会暴露set方法以表达值对象不能被改变的立场。

值对象还倡导概念的整体性,一个值对象可以只处理单个属性,也可以处理一组相关联的属性,每个属性都是整体属性不可或缺的组成部分,如果值对象中所有属性联合起来无法表达一个整体的概念,那这个属性联合的设计通常的不恰当的;又如果一个值对象中如果存在有歧义的属性,那这样的设计也是不合理的。举个例子,描述一枚货币一定是一个数字加上它的单位,单独的数字或者单独的单位都不能完整的表达一枚货币,所以当这枚货币存在于某个对象中时,就应该将数值和单位以一个整体的形式出现:

值对象远不止这里描述的这么简单,但是足够你对值对象树立一个正确且基础的概念,这对你以后深入学习DDD大有裨益。

11 聚合

聚合(Aggregate)是一组相关对象的集合,这些相关对象包含聚合根、实体以及值对象等,一个限界上下文中包含一个或者多个聚合,在实际操作中,一个聚合将是我们操作和查询数据的基本单元。

聚合根(Aggregate Root)是一个新的术语,但是概念同实体一样,有生命周期,有状态,也有唯一标识,如果硬要讨论聚合根和实体的区别的话,那么应该是限定在同一个聚合内的。在实际进行领域模型分析时,我们通常按照自顶向下的顺序,这也意味着在分析时我们将最先识别到的就是聚合根,而后自上而下衍生出对象树。

下面这张表介绍了同一聚合内聚合根、实体和值对象三者的区别。

指标

聚合根

实体

值对象

标识

全局唯一标识

聚合内唯一标识

无标识

只读

唯一标识只读,其余非只读

非只读

只读

生命周期

独立生命周期

同聚合生命周期

无生命周期

在以往的聚合设计经验中,聚合根往往不难找到,设计聚合的难点在于定义聚合的边界,因为聚合被包含在某个限界上下文内,这意味着限界上下文已经给我们定义好了一个大边界,接着解决如何在这个限界上下文内完成聚合的发现就行了。

这是一个在设计领域模型时值得消化的经验:以用户为中心作为出发点去思考问题,不能老是想着用户会对系统做什么,而应该从一个客观的角度,思考我能提供什么样的能力,根据用户需求挖掘出领域内的相关事物,思考这些事物的本质关联及其变化规律作为出发点去思考问题。

根据前人经验,聚合边界的发现和设计有几个原则可以参考:

1. 在一致性边界内建模真正的不变条件

不变条件是指某个业务规则,这个规则应该总是保持一致的。这里的一致通常是指事务一致性,因为聚合作为我们操作和读取数据的基本单元,这就要求我们在提交事务时,该聚合边界内的所有对象都应当保持一致。这可能听起来太抽象了,举个例子:

现在你的公司要建模一台机器人,此时整台机器人的零件、该机器人被赋予的行为以及对外部的依赖等确定了这个项目的领域,你的团队在对这个领域进行分析后,一致认为机器人的脑是最核心的部分,那么脑就是本领域中的核心子域,其他的器官、肢体、毛发等便成了支撑子域,你被分到了肢体子域的开发工作,在建模的过程中会产生肢体对应的限界上下文,其中包含对肢体结构的建模以及肢体的一些相关行为的实现。在进行了一系列准备之后,你开始了开发工作,接着发现肢体的组成比较复杂,按照聚合的思路,如果把整个肢体设计成一个聚合,那任何一处的变更都将影响整个肢体,这意味着你仅仅修改一下脚指甲的颜色就得把所有肢体取出来作为一个单元操作,这显然大可不必,换句话说,脚指甲只是脚的一个组成部分而脚也只是肢体的一个部分,也就是脚指甲具体的形态和肢体并不具备一致性,你换个指甲盖颜色还要把我手指叫出来?干啥?有法律吗?有王法吗!

那不如把肢体设计成:上肢和下肢两个聚合,这挺好!(实际上肢体并不只是上肢+下肢,但咱们不是医学讲堂,不要纠结这么设计是否符合实际),于是你在肢体领域找到了两个聚合边界。找到聚合边界之后,你开始寻找聚合根,你最开始建模的是上肢,我们看看上肢是怎么解释一致性的:不妨先定义上肢=左手+右手,这意味着如果缺少任何一只手,就构不成(完整的)上肢,或者说,如果这个聚合里加了一个第三者,那左手+右手+第三者=上肢?这显然不正确,所以上肢这个聚合内是不应该出现第三者的。之后我们的任何上肢相关都将对这个足够内聚的聚合进行操作,修改右手的指甲颜色有必要把左手也拿出来吗?我认为有必要,你右手改了指甲,我左手也要改有什么问题!(实际上我是想强调左右手之间的关联是非常密切的,把他们设计成一个聚合没什么问题)。接着你开始定义聚合根,前面其实说的很清楚了,也就不难看出,手(Hand)就是你的聚合根,在这个聚合根内有两个实体,分别是左手和右手,左手和右手实体内又包含他们各自的手指、手指数量、皮肤、皮肤颜色、指甲、指甲颜色等实体或者值对象。‍‍‍‍‍‍‍‍‍

2. 设计小聚合

上一个例子举得比较详细了,这个原则其实就像把肢体拆成上肢和下肢两个个聚合一样,在保证一致性的同时尽可能地把聚合设计得更加“聚”。什么?这不够聚,能不能继续拆!当然可以,一定要记得,上到领域下到值对象,都是相对而言的,站在业务的角度去拆分,如果你的业务甚至已经需要精确到细胞层面了,那继续往细胞层面去拆又有何不可呢。

3. 通过唯一标识引用其他聚合

聚合边界看起来把每个聚合中间加了一道无形的墙,但是我们都知道,这堵墙并不是完全隔离的,聚合之间存在交互是非常正常的现象。DDD可不会因为一个聚合边界的概念就剥夺了他们相互交流的权利。这个原则的意思是指,如果我们在A聚合内需要与B聚合交互,或者两个聚合有依赖关系,那也务必不要把整个聚合引用进来,而是考虑通过全局唯一标识来引用其他聚合。这会带来什么好处呢,之前说过,聚合是我们操作和访问数据的基本单元,如果聚合被直接通过对象引用的方式被另一个聚合引用,那就意味着在这个聚合赋予了其他聚合修改自己的权利,这是违反了聚合的基本概念的。而通过唯一标识不仅仅能实现多个聚合之间的交互,同时也能减小聚合的体积,谁会希望在自己的聚合内还拖着一大坨别的聚合呢。

4. 在边界之外使用最终一致性

在任何一个大规模的企业系统中,要做到所有的聚合实例完全一致这是不可能的,所以设计聚合时除了做到聚合内的一致性以外,还需要有一个认识:多个聚合之间有一致性需求时一定是最终一致而不是完全一致。这需要你和你的领域专家沟通,你在上肢聚合中修改了手指的数量,需要脑领域内的某个负责记忆功能的聚合修改关于手指数量变更的这部分记忆(ps:这里你可以思考下多个聚合之间怎么实现通信才符合DDD思想),数据的最终一致务必有所延迟,领域专家是否能忍受这样的延迟?根据经验,只要理由足够有说服性,通常而言领域专家是能忍受的。

12 领域服务

DDD不提倡贫血领域对象,这意味着在DDD中的实体本身就实现了很多业务操作,考虑这样一个需求:客户端携带一个用户ID和一个名字来验证这个ID和名字是否来自于一个用户。如果这个需求使用充血模型的方式实现,那么思路大体是:User实体提供的名字校验能力,客户端根据用户ID获得一个User实体,再调用User实体的名字校验能力获得校验结果。

这看起来还能应付,让我们把需求升级一下:User可能附属于某个企业(注意:企业和用户并不属于同一个聚合),客户端的需求是,如果用户ID对应的用户所属企业是互联网行业,那么就判断用户ID和名字是否来自同一人,否则直接返回失败。这个需求中增加了一个新的对象,按照之前的做法,代码可能是这样的:

现在我们可以看出来,并不是每个业务都简单到只需要某一个实体就能实现,通常他需要多个聚合中多个实体之间相互协作完成,按照充血模型的思路,这给客户端带来了的太多不必要的负担。从代码可以看出客户端不仅关注企业实例,还关注了用户实例,而这并不是客户端应该关注的,这也不是DDD所期待的,DDD不希望客户端关注太多细节,也不想把领域内的对象过多暴露给客户端。在这时候,领域服务就出现了,领域服务专门处理这些领域内的复杂业务实现,将领域内复杂的实体访问和操作封装到领域服务的方法内,客户端不需要关注内部细节,只需要提供最原始的参数获得结果即可。

PS:领域服务通常和领域内相关的聚合放在同一模块(目录)下。

13 领域事件

领域事件(Domain Event)用于发布和捕捉发生在领域内的一些事情,领域事件通常是由某个聚合发出,在整个领域范围内订阅。某某方法执行完成、某某数据修改成功、某某接口调用结束等等都可以理解为领域内事件,这些事件结束后我们往往需要做一些进一步的操作,比如:订单领域完成下单操作后,物流领域需要开始物流。这是在两个领域之间具备先后关系的事件,问题在于完成下单之后怎么通知物流领域呢?直接调用吗?领域事件认为,在有需要时,当订单创建完成,应当对外发布一个携带了必要对象信息的订单创建完成事件(OrderCreatedEvent)消息,由订阅方订阅到该事件消息,进而完成后续业务。

发布-订阅模式大家并不陌生,大家熟悉的消息中间件或者基于REST资源的发布方式都具备发布-订阅能力。而在领域内,中间件这样的组件是作为基础设施存在的,我们并不会在聚合中使用他们。那聚合内如何发布事件?这里我们可以使用到一个经典的设计模式——“观察者”模式,利用观察者模式我们可以完成一个轻量级的事件组件,事件的订阅方在事件发布组件上完成注册,当事件被发布时,事件组件会轮询所有已经注册的订阅方,当发现订阅了本事件的订阅方,则调用该订阅方方法实现订阅。

有一件事情是值得注意的,领域事件在某聚合中发布后,如果同步地被订阅方订阅,那么订阅方是不推荐操作其他聚合的,根据聚合的原则:在同一个事务中应当只对一个聚合进行操作或者访问。同步的订阅者在其他聚合中执行命令显然违反了这一原则。因此我们的事件发布组件往往设计成异步的,事件发布后异步地被订阅者订阅,此时订阅者便可以在各自单独的事务中完成对其他聚合或者其他限界上下文完成修改。

ps:在领域事件的践行中,需要使用到一个轻量级的发布订阅组件,那是一个可以让你用上瘾的组件(反正我是上瘾了),计划在下一期《实践篇》中分享。

14 资源库

资源库通常表示一个安全的存储区域,当你从资源库中取出一个物品时,你希望该物品和其先前放进去时的状态是一样的。当然,你也可能从资源库中删除一些物品。DDD的资源库(Repository)也是一样的概念,在DDD的实践时,我们通常把聚合存放在资源库中,之后再从资源库中获取相同的实例。如果你修改了聚合,那这个修改也将被持久化到资源库。如果你从资源库删除了某个实例,那也意味着你不会再从这个资源库获取到该实例。

听起来资源库似乎和DDD没什么直接的关系,这说的不就是我们日常开发涉及到的持久化层?没错!我们继续往后看看DDD是怎么使用资源库的。

1. 面向集合的资源库

面向集合的资源库是指资源库就像一个集合,在向一个Set集合中添加聚合时,如果重复添加是不会成功的,从集合中获取到的实例也可以直接进行修改而不需要写会操作,因为从集合中获取到的对象是引用类型,这意味着任何修改都会直接生效。面向集合的资源库看起来非常便捷,但是实际场景中,使用集合作为资源库通常不是我们常用的,实际开发场景中也不太容易能够创造这种资源库的条件。所以我们把目光转到另一种资源库!

2. 面向持久化的资源库

持久化的资源库可能更符合大部分企业的业务场景,也是开发人员更加熟悉的方式。面向持久化的资源库时,控制聚合不被重复插入可能没有Set集合那样方便,但是别忘了每个聚合中都至少存在一个唯一标识,这个唯一标识可以帮助我们保证持久化存储时不会存在重复的聚合实例。此外,持久化的资源库通常作为第三方服务而存在,比如MongoDB、MySQL等,这意味着在对聚合进行更新时不再像集合操作那样修改即生效,而是需要通过主动的调用触发更新生效。

和我们平时的开发方式不太一样的是,面向持久化的资源库依然建议效仿集合那一套,将细节封装在资源库内部实现而不是交给客户端,例如防止聚合添加重复的校验不再由客户端做,资源库仅仅提供一个save或者saveAll接口,客户端在新增或者修改聚合时,尽管调用就好了,至于是新增还是修改,将由资源库自己判断。让我们想想集合提供的add或者addAll(put或者putAll),其实都是如出一辙的思想。客户端只是想把某个聚合完成持久化,至于重复不重复合不合法不是客户端关心的事情。

同时也因为资源库通常和聚合有直接关联关系,所以资源库中提供的接口不会很复杂,持久化无非新增+修改+删除+查询,删除(软删除)、修改、新增的能力由save或者saveAll提供,就只剩下查询,通常来说一个Repository接口类只和一个聚合相关,所以查询无非就是根据唯一标识查询到一个实例,或者根据某些条件查询多个实例,这意味着还需要两个查询聚合实例接口。集合还有size接口提供给客户端查询实例的个数,资源库也可以做到,这往往也是业务需求的,查询符合条件的实例数量太正常不过了。

下面这个用例可以直观地看到面向持久化的资源库是如何设计的:

15 防腐层

在原著中没有单独使用一个章节对防腐层进行介绍,因为防腐层不算很复杂的概念。但是这里我想为防腐层做一个单独的介绍,因为防腐层的思路不是DDD独享,哪怕你没有DDD,在普通分层架构中也是有用武之地的。

通常而言,领域与领域或者上下文与上下文之间存在数据交互,这意味着一旦上游的接口或者暴露的对象有所变更,就可能会影响到下游服务的正常运行。防腐层的引入就是为了让这样的变更尽可能地不影响到领域内的已有功能代码。对外,防腐层将领域内通用语言翻译成外部系统的语言并发起请求;对内,防腐层把外部系统输入的数据转成领域通用语言响应到领域内。以此保护领域内的代码不受侵害。

通俗来讲:当你的领域和其他领域存在数据交互时,你需要一个防腐层作为两个领域之间的纽带。这会给你带来很多“数据转换”的代码,但是对于业务多变的系统来讲,它能保护你的领域,这是值得的。

16 模块

模块这个词听起来有点“空”,对于很多初学者,不如使用package分包来讲更容易理解。

这里将要介绍的是在DDD的指导下,我们将怎么划分模块?哪些内容应该放在同样的模块下?之前说到的领域、限界上下文、聚合、实体、值对象、领域事件、领域服务、资源库等等他们之间的包关系又如何!

像是厨房的抽屉一样,我想你不会乐意在打开厨房抽屉的时候看到很多修车的扳手或者螺丝刀,虽然这不影响你做菜,如果你乐意看到这种现象,那你一定不是一个专一的厨师,至少你还喜欢修车!

软件的模块设计不喜欢不专一的设计,因此在动手敲代码之前,也许你应该分配更多的经历在模块的设计上。模块设计不是简单的区域划分,给模块取一个好名字同样重要。接下来我们用机器人例子进行示例:

1. 通常而言,模块名总是以因特网顶级域名开头,接着是公司的域名,如:com.xipijiang,当然你们如果在部门层面还有划分可以继续往下分,这部分应当是公司层面统一的,我想这个不用太多介绍,地球人都知道;

2. 接着往下是限界上下文,限界上下文是开发者和领域专家在项目开始之初就应该划分出来的,所以大胆地根据划分好的限界上下文划分模块。在机器人例子中,你分配到的是肢体上下文,肢体翻译出来是limbs,那你可以给你的上下文命名为limbscontext,另一个同事可能负责是脑上下文,那你们的上下文模块可以这么写:

com.xipigongjiang.limbscontext;com.xipigongjiang.braincontext;

3. 限界上下文下面包含领域对象、聚合、领域服务、领域事件、应用服务等,为了隔离领域内外,首先创建domain层用于定位有关领域内某个特定的模块,接着在domain下创建model模块,将模型相关的对象放到model下,比如聚合、领域对象等,领域服务作为服务暴露给客户端,所以创建么model同级的service模块用于放置领域服务(如果你喜欢把领域服务和对应的聚合放在一起,那也可以不需要service层,将领域服务设计在聚合内也是可以的),即:

com.xipigongjiang.limbscontext.domain.model;com.xipigongjiang.limbscontext.domain.service;

4. model是存放聚合和领域对象的地方,同时按照聚合的原则,DDD建议当一个上下文中存在多个聚合时,可以按照聚合含义分出不同模块,场景中已经明确了limbscontext中应该至少包含以下两个聚合:

com.xipigongjiang.limbscontext.domain.model.upperlimbs; // 上肢
com.xipigongjiang.limbscontext.domain.model.lowerlimbs; // 上肢

5. 聚合下包含聚合根、实体和值对象,那么上肢聚合可能有以下内容

com.xipigongjiang.limbscontext.domain.model.upperlimbs.Upperlimbs.java // 上肢聚合根
com.xipigongjiang.limbscontext.domain.model.upperlimbs.LimbsId.java  // 肢体ID 上肢的唯一标识
com.xipigongjiang.limbscontext.domain.model.upperlimbs.Fingernial.java  // 指甲,实体或者聚合根
com.xipigongjiang.limbscontext.domain.model.upperlimbs.Finger.java  // 手指,实体或者聚合根
com.xipigongjiang.limbscontext.domain.model.upperlimbs.Arm.java  // 手臂,实体或者聚合根
com.xipigongjiang.limbscontext.domain.model.upperlimbs.UpperlimbsRepository.java // 上肢持久化前端

正常情况下,上肢的聚合中的实体或者值对象会以某种形式被聚合根引用,聚合根中存在全局唯一的肢体ID,肢体ID便是这个上肢在整个领域内的唯一标识。之后这个聚合就是上肢操作的一个操作单元。

6. 除了本上下文中聚合,通常我们还有与其他上下文中的聚合交互的需求,比如上肢在实现上可能还涉及到使用的材质类型,这是一个来自其他领域的对象,我们也可以把材质作为一个“外部聚合”放在model下,和肢体上下文中的聚合不一样的是,内部聚合的数据来源于资源库,而“外部聚合”的数据来自于其他领域暴露的服务接口,但是除了数据来源不一样,其他的设计我们都应当遵循一个常规的聚合设计原则。重点在于这个来自其他领域的数据是怎么被这个限界上下文使用的,之前介绍的防腐层便在这里发挥作用。

com.xipigongjiang.limbscontext.domain.model.material.Material.java  // 材料聚合根(也可以是实体或者值对象)
com.xipigongjiang.limbscontext.domain.model.material.MaterialService.java // 材料防腐层前端

7. 以上模块涵盖了大部分领域相关的对象,对于同一上下文下的应用服务,在和domain同一级别的目录下创建application层。应用服务便在这里完成对领域服务的调用,用于控制持久化事务和安全认证等,或者是订阅来自聚合内的事件从而完成消息的后续业务处理。

关于命名,我习惯使用XxxxAppService这样的格式来给应用服务命名:

com.xipigongjiang.limbscontext.application.UpperlimbsAppService.java

8. 基础设施主要包含持久层、消息中间件等为整个系统提供基础能力的设施,这些设施只是给系统提供基础能力,绝对不能入侵到领域层。用六边形架构(端口/适配器架构)举例,在上线文中定义port.adapter层,意为端口.适配器,然后我们便可以遵循六边形架构的思想将外部数据的输入或者输出放在这里面实现,同样,基础设施也被认为是其他领域的服务,因此基础设施将被放在这一层。

com.xipigongjiang.limbscontext.port.adapter.mq
com.xipigongjiang.limbscontext.port.adapter.api.impl
com.xipigongjiang.limbscontext.port.adapter.outerservice
com.xipigongjiang.limbscontext.port.adapter.persistence

对于非六边形架构的其他架构在分模块时,只要清晰的认识到各功能区间的划分,那就按照提前划分好的区间完成即可。

9. 以上便是领域内一些核心概念的模块约定,其他的比如常用的util、common、config等模块,思考他们的影响范围,对谁生效,然后放到对应的大模块下即可。下面是一个完整项目的模块设计,供参考:

以上便对于DDD理论学习的心得介绍,在业务高速发展的今天,领域驱动设计就像一盏明灯,你可能不会把他举起来,但一旦举起,他能带给你的一定不只是开发一个优雅的系统那么简单,对我们日常思考问题的方式和角度也是大有裨益。此外,如同微服务盛行一样,领域驱动设计虽然还没有普遍到人尽皆知,但我相信在不久的未来,在更多DDD社区文化以及业务需求的冲击下,DDD必定被更多企业认可并实践!

17 写在最后

本文主要参考Vernon的《实现领域驱动设计》,这是关于领域驱动设计的理论篇,在之后尽可能短的时间里(受工作忙碌程度的影响),我会以文中机器人的案例继续产出《DDD实战篇》,实践篇将更加着重于为什么、怎么做,而不是是什么。

如果发现有不准确或者有歧义的地方,欢迎沟通,你可以在公众号“嬉皮工匠”菜单处找到我的联系方式,也可以微信搜索“ErshIsi__”添加我为好友

我是章总不是总,期待结果,相信过程!

校正:life_john(飞哥)

校正:嘻嘻皮

领域驱动设计-原理心得篇相关推荐

  1. 领域驱动设计学习心得

    领域驱动设计(Domain Driven Design,简称 DDD),是设计方法之一,可以针对开发领域,当然也可能用于其他领域. 领域驱动设计的过程,就是建立起通用语言和识别模型的过程. 但我只看懂 ...

  2. ABP入门教程(四)初探领域驱动设计

    ABP项目的分层 .Application 为应用层:构建服务 .Core 为领域层:定义实体,定义实体功能,实现实体功能,定义仓储接口 .EntityFrameworkCore 为数据库处理(EF层 ...

  3. 孙向晖-《领域驱动设计》读书心得交流会-UMLChina讲座-实录

    时间 北京时间2006年3月30日(周四)晚上19:00-21:00 主持人 孙向晖.负责<领域驱动设计>中译本的技术审校工作.他的blog"豆豆他爹的生活随笔". 聊 ...

  4. 【华为云技术分享】如何设计高质量软件-领域驱动设计DDD(Domain-Driven Design)学习心得

    DDD做为软件设计方法于2004年提出,一直不温不火,最近几年突然火起来了,为啥呢?正所谓机会给有准备的人,因为微服务的流行,大家都跃跃欲试把传统单体软件转成微服务架构,但理论很丰满,现实很骨感,光是 ...

  5. 什么是DDD(领域驱动设计)? 这是我见过最容易理解的一篇关于DDD 的文章了

    领域驱动设计之领域模型 加一个导航,关于如何设计聚合的详细思考,见这篇文章. 2004年Eric Evans 发表Domain-Driven Design –Tackling Complexity i ...

  6. 领域驱动设计之设计原则篇

    语言从c的面向过程到java的面向对象,在程序设计.组织的角度来看是在抽象.直观化.便于模块整合上的一次进步.现在的许多通用框架,比如spring.mybatis为应用程序提供了对象的管理以及数据仓库 ...

  7. 领域驱动设计,为何死灰复燃?

    作者简介 张逸,曾先后就职于中兴通讯.惠普 GDCC.中软国际.ThoughtWorks 等大型中外企业,任职角色为高级软件工程师.架构师.技术总监.首席咨询师. 一.领域驱动设计为何又死灰复燃焕发青 ...

  8. 浅谈我对DDD领域驱动设计的理解

    从遇到问题开始 当人们要做一个软件系统时,一般总是因为遇到了什么问题,然后希望通过一个软件系统来解决. 比如,我是一家企业,然后我觉得我现在线下销售自己的产品还不够,我希望能够在线上也能销售自己的产品 ...

  9. 分享我对领域驱动设计(DDD)的学习成果

    本文内容提要: 1. 领域驱动设计之领域模型 2. 为什么建立一个领域模型是重要的 3. 领域通用语言(Ubiquitous Language) 4. 将领域模型转换为代码实现的最佳实践 5. 领域建 ...

最新文章

  1. CSS教你玩转背景background-position(1)
  2. $python爬虫系列(1)——一个简单的爬虫实例
  3. EXT.NET GridPanel展开与收缩
  4. 内部类访问局部变量的时候,为什么变量必须加上final修饰
  5. coco creator编辑动画坑之拖图片
  6. 一体化方案解决大数据处理的两个难题
  7. ScheduledThreadPoolExecutor之scheduleWithFixedDelay和scheduleAtFixedRate的区别
  8. gtp6 linux 启动_glibc.i686安装
  9. STM32入门之电路基础
  10. thinkphp5.0.20 数据库迁移/填充
  11. 2019字节跳动实习面试
  12. 从历史上的错误数据中吸取教训
  13. leetcode 2448
  14. 格式工厂AVI格式视频转MP4画面不清晰变模糊解决办法
  15. 在线JADE转HTML工具
  16. html页面加载有时没有网样式,页面css加载失败的原因有哪些?
  17. java定义长方形类三个构造方法,《JAVA期末考.docx
  18. HTML+JS 画图板
  19. labview2020图文教程LabVIEW2020
  20. android R 修改wifi信号强度

热门文章

  1. 如果用户希望将自己计算机中的照片,信息技术会考模拟题共31套的选择三
  2. python画一片树叶的故事_阿里达摩院 | 小小树叶,藏着哪些技术之道?你一定没想到...
  3. 支持向量机学习总结( 持续更新)
  4. 最棒的一本管理书《卓有成效的管理者》[美] 彼得·德鲁克
  5. golang 下载第三方依赖
  6. LVGL学习之路——基于lv_lib_freetype库的TTF字体文件动态加载中文字体(阿里普惠字体)
  7. 并发经验八年架构师:缓存在高并发场景下该如何问题
  8. Win系统 - 6步加快开机速度,来比比谁更快?
  9. 中国兰炭市场现状分析与投资前景方向研究报告2022-2028年
  10. ***BAT机器学习面试1000题系列