作者:faryrong,腾讯 CSIG 后台开发工程师
最近看了一本书《解构-领域驱动设计》,书中提出了领域驱动设计统一过程(DDDRUP),它指明了实践 DDD 的具体步骤,并很好地串联了各种概念、模式和思想。因此,我对书本内容做了梳理、简化,融入自己的理解,并结合之前阅读的书籍以及实践经验,最终形成这篇文章。希望可以帮助大伙理顺 DDD 的各种概念、模式和思想,降低上手 DDD 的门槛。
领域驱动设计(DDD)由 Eric Evans 提出,并一经《领域驱动设计:软件核心复杂性应对之道》的发布,在软件行业中引起了不少的轰动。DDD 提供的一种新颖的,甚至有点“另类”的思维方式,它在告诉软件开发者“我们要用业务方案来解决业务问题,而不是技术方案解决业务问题”,有点魔法打败魔法的意思。DDD 虽然让人眼前一亮,但是所提倡的理念有点“违背直觉”(对开发人员而言),因此,在当时并没有流行开来。
后来,微服务架构的兴起,大伙惊奇地发现 DDD 是作为划分“微服务边界”的一把利器,并且 DDD 提及的很多设计理念与微服务架构十分契合,因此 DDD 逐渐被开发者们接受并流行起来。毫不夸张地说,了解和学习 DDD 可以算得上是如今软件行业从业者的一门必修课了。
但是!DDD 的学习曲线较为陡峭。作为一个小白,翻阅过很多相关的书籍、KM 文章和分享,但始终觉得未得要领、一知半解。原因有二:a) DDD 涉及的概念繁多,且不同概念的抽象层次不一样,如果我们直白地去理解,往往会感到疑惑,比如:子域和限界上下文都是用于将问题进行归类和收敛,他们的区别是什么?b)缺少过程指导,难以将概念有序的串联起来。作为方法论,DDD 给出了设计思想,核心原则以及常用工具,但是却缺少细致有序的方法步骤,导致难以上手实践。
幸运的是,最近看了一本书《解构-领域驱动设计》。这本书提出了领域驱动设计统一过程(DDDRUP),它指明了实践 DDD 的具体步骤,并很好地串联了各种概念、模式和思想。因此,我对书本内容做了梳理、简化,融入自己的理解,并结合之前阅读的书籍以及实践经验,最终形成这篇文章。希望可以帮助大伙理顺 DDD 的各种概念、模式和思想,降低上手 DDD 的门槛。
经典必读书籍《领域驱动设计:软件核心复杂性应对之道》的书名包含了两个关键词:领域驱动和复杂性,分别代表了 DDD 的核心原则以及解决的问题。
系统的复杂性往往并不在技术上,而是来自领域本身、用户的活动或业务服务。当这种领域复杂性在设计中没有得到解决时,基础技术的构思再好也是无济于事。而系统的复杂度体现在三个方面:规模、结构和变化。
规模:指的是系统所支持的功能点,以及功能点与功能点之间的的关系。DDD 通过子领域,限界上下文,聚合等模式对问题进行拆分和归类,不断收窄问题域,保证聚合边界内所解决的问题集合足够收敛和可控。
结构:指的是系统架构。系统架构是否分层;若分层,每层划分的职责边界是否清晰;架构的基本管理单元是什么,它决定了架构演进时的复杂度。DDD 通过分层架构,独立出领域层,且架构中的每层都有清晰的职责。整体架构的基本管理单元是聚合,它是一个完整的、自治的管理单元,当需要进行服务拆分时,可以直接以聚合作为基本单元进行拆分。
变化:指的是系统响应需求变化的能力。快速响应变化的有效手段是分离不易变逻辑和易变逻辑,"以不变应万变"。而通过分层架构独立的领域层正是不易变的逻辑。领域层是对领域知识的封装,其提供的领域服务具有经验性和前瞻性,是对领域内稳定的领域规则的表达。而领域层以外的应用层和基础设施层则是易变逻辑的封装。保证核心的独立和稳定,通过在调整应用层和基础设施层来实现快速响应需求变化。
领域驱动指的是以领域作为解决问题切入点,面对业务需求,先提炼出领域概念,并构建领域模型来表达业务问题,而构建过程中我们应该尽可能避免牵扯技术方案或技术细节。而编码实现更像是对领域模型的代码翻译,代码(变量名、方法名、类名等)中要求能够表达领域概念,让人见码明义。
结合实践经验,以下是本人对“领域驱动”的一些见解:
思维模式转变
实践 DDD 以前,我最常使用的是数据驱动设计。它的核心思路针对业务需求进行数据建模:根据业务需求提炼出类,然后通过 ORM 把类映射为表结构,并根据读写性能要求使用范式优化表与表之间的关联关系。数据驱动是从技术的维度解决业务问题,得出的数据模型是对业务需求的直接翻译,并没有蕴含稳定的领域知识/规则。一旦需求发生变化,数据模型就得发生变化,对应的库表的设计也需要进行调整。这种设计思维导致变化从需求穿透到了数据层,中间并没有稳定的,不易变的层级进行阻隔,最终导致系统响应变化的能力很差。
协同方式转变
过去由产品同学提出业务需求,研发同学根据业务需求的 tapd 进行技术方案设计,并编程实现。
这种协同方式的弊端在于:无法形成能够消除认知差异的模型。产品同学从业务角度提出用户需求,这些需求可能是易变的、定制化的,而研发同学在缺少行业经验的情况下,往往会选择直译,即根据需求直接转换为数据模型。而研发同学从技术实现角度设计技术方案,其中涉及很多的技术细节,产品同学无法从中判断是否与自己提出的业务诉求和产品规划相一致,最终形成认知差异。且认知差异会随着迭代不断被放大,最后系统变成一个大泥球。
DDD 通过解锁新角色”领域专家"以及模型驱动设计,有效地降低产品和研发的认知差异。领域专家是具有丰富行业经验和领域知识储备的人,他们能够在易变的、定制化的需求中提炼出清晰的边界,稳定的、可复用的领域概念和业务规则,并携手产品和研发共同构建出领域模型。领域模型是对业务需求的知识表达形式,它不涉及具体的技术细节(但能够指导研发同学进行编程实现),因此消除了产品和研发在需求认知上的鸿沟。而模型驱动设计则要求领域模型能够关联业务需求和编码实现,模型的变更意味着需求变更和代码变更,协作围绕模型为中心。
精炼循环
精炼循环指的是在统一语言,提炼领域概念,明确边界,构建模型,绑定实现过程中,这些环节相互影响和反馈,在不断的迭代试错-调整以最终沉淀出稳定的、深层次的模型的过程。比如,我们在提炼领域概念的时候会觉得统一语言定义不合理/有歧义,此时我们就会调整统一语言的定义,并重新进行提炼领域概念。通过精炼循环,我们逐步形成稳定的领域模型。在 DDD 中,让领域专家来主导概念提炼、边界划分等宏观设计,原因就在于领域专家的经验和行业洞见来源于过去已经迭代的无数个精炼循环,因此由这些宏观设计推导出来的领域模型,往往都是非常稳定的。
精炼循环的核心是循环,它避免知识只朝单一方向流动,最终因各环节上的认知差异,最终导致模型无法在产品、领域专家和研发中达成一致、模型与实现割裂。
我早期实践 DDD 的时候,认为代码分层遵循四层架构就是 DDD,抑或分离接口和实现,实现下沉至基础设施层就是 DDD,实则不然。结合上述内容,目前个人认为只要满足以下条件即为实践 DDD:
问题空间和解空间并非 DDD 特有的概念,而是人们为了区分真实世界和理念世界而提出的概念。问题空间表示的是真实世界,是具体的问题、用户的诉求,而解空间则是针对问题空间求解后构建的理念世界,其中包括了解决方案、模型等。
DDD 提出的战略设计覆盖了问题空间和解空间,而战术设计则聚焦在解空间上。明确 DDD 中的概念是作用于问题空间还是解空间,更有助于我们理解它们。
学生管理系统(Student Management System,下文简称 SMS)作为 DDDRUP 的讲解示例,以下为其问题空间的描述。
学校需要构建一个学生管理系统(Student Management System, SMS)。
通过这个管理系统,学生可以进行选课,查询成绩,查询绩点。
而老师则可以通过这个系统录入授课课程的成绩。录入的分数会由系统自动换算为绩点,规则如下:若分数>= 90,绩点为4.0;90>= 分数> 80,绩点为3.0;80 >= 分数 > 70,绩点为2.0;70 >= 分数 >= 60,绩点为1.0;成绩< 60,则没有绩点,并邮件通知教务员,由教务员联系学生商榷重修事宜。
成绩录入后的一周内,若出现录入成绩错误的情况,老师可提交修改申请,由教务员审核后即可完成修改。审核完成后系统会通过邮件告知老师审核结果。一周后成绩将锁定,不予修改。成绩锁定后,次日系统会自动计算各年级、各班的学生的总绩点(总绩点由各门课程的学分与其绩点进行加权平均后所得)。
而教务员则可以通过该系统发布可以选修的课程。同时,教务员能够查看到各年级,各班的学生的总绩点排名。
虽然领域驱动设计划分了战略设计和战术设计,也提供了诸多模式和工具,但却没有一个统一过程去规范这两个阶段需要执行的活动、交付的工件以及阶段里程碑,甚至没有清晰定义这两个阶段如何衔接、它们之间执行的工作流到底是怎么样的。
而《解构-领域驱动设计》提出的 DDDRUP 给出了更细致的步骤、步骤与步骤之间的衔接,以及明确的阶段里程碑,最重要的是 DDDRUP 可以串联 DDD 的所有概念和模式,非常便于初学者做知识梳理和上手实践。下文我会依照 DDDRUP 的步骤流程进行讲述,而非战略设计+战术设计的思路。(DDDRUP 各步骤与战略&战术设计的关系见下表)。
全局分析阶段对问题空间进行的梳理和分析,形成统一语言(ubiquitous language), 获取问题空间的价值需求以及业务需求。
统一语言:蕴含领域知识的、团队内统一的领域术语。产品、领域专家以及开发人员掌握的领域知识存在差异,往往导致对同一个事物使用不同的术语。比如,商品的价格(Price)和商品的金额(Amount),它们本质是同一个东西,但是却有不同的术语表示。
统一语言会参与 DDDRUP 的全流程,且会在精炼循环过程中不断进行调整,以反映出更合适、更深层次的领域知识。
根据业务需求形成统一语言,有助于团队对事物的认知达成一致。统一语言可以通过词汇表的形式展示,其中词汇表最好还要包含术语对应的英文描述,便于研发同学在代码层面表达统一语言。示例-SMS 的统一语言词汇表如下。
价值需求分析主要做的三个工作是:
并非任何系统都 DDD,DDD 的核心是解决领域复杂性,若系统逻辑简单,功能不多,引入 DDD 则会得不偿失。而在进行价值需求分析后,我们便能判断是否需要通过 DDD 驱动系统的设计。
使用业务流程、业务场景、业务服务和业务规则来表示业务需求。
业务流程:表示的是一个完整的、端对端的服务过程。
业务场景:按阶段性的业务目标划分业务流程,就可以获得业务场景。在示例-SMS 中,老师修改成绩就分为了老师“提交申请单”,以及教务员“同意申请单”两个场景。
业务服务:角色主动向目标系统发起服务请求完成一次完整的功能交互,以实现业务目标。角色可以用户、策略(定时任务)或者其他系统,完整则强调的是业务服务的执行序列的所有步骤都应该是连续且不可中断的。业务服务是业务需求分析最核心,也是最基础的单元,而业务流程和业务场景是为了更好地分析出业务服务。在示例-SMS 中的“同意申请单”场景中包含了两个业务服务:教务员“同意申请单”和系统“邮件通知”教务员。
业务规则:指对业务服务约束的描述,用于控制业务服务的对外行为。业务规则是业务服务正确性的基础。常见的业务规则有:a) 意如“若… , 就….” 的需求描述,比如示例-SMS 中可提炼出“若成绩录入时间间隔超过一周,不予修改”;b) 具有事务性的操作。
通过业务流程、业务场景和业务服务的梳理,基本可以分析出业务需求所需要的业务服务。然而,业务服务粒度太细,而问题空间又太大,我们需要找一个更粗粒度的业务单元,来帮助我们对业务服务进行聚类,一方面可以降低管理过多细粒度业务服务导致的额外复杂度,另一方面可以帮助领域专家和开发团队分析问题和设计方案时不至于陷入到业务细节中。而这个更粗粒度的业务单元就是子领域。
子领域的作用:
子领域的分类:
子领域的功能分类策略:问题空间应该分为哪些子领域,需要团队对目标系统整体进行探索,并根据功能分类策略进行分解。
划分子领域的过程存在很多经验因素,一个对该行业领域知识了如指掌的领域专家,可以在完成价值需求分析后,结合自身的领域经验,能够选择合适的聚类策略并给出稳定的子领域列表。但,没有领域经验也没有关系!因为根据知识消化循环思路,再经历多个迭代后收敛出来的子领域划分也会逐渐合理,逼急领域专家凭经验得出的子领域划分,只是可能需要的时间要长一些。
在架构映射阶段,我们需要识别限界上下文,并通过上下文映射表示限界上下文之间的协作关系。
限界上下文是语义和语境的边界。在问题空间,统一语言形成了团队对领域概念的统一表达,子领域形成了领域概念之间的边界。而在解空间,限界上下文可以看做是统一语言+子领域的融合体,统一语言需要在限界上下文内才具有明确的业务含义。
以电商购物场景为例。在进行商品下单后,系统会生成一个订单;在用户付款完成后,系统也会生成一个订单;到了物流派送流程,系统还会生成一个订单。虽然这三个步骤中的领域概念都叫订单,但是他们的关注点/职责却不同:商品订单关注的是商品详情,支付订单关注的是支付金额和分润情况,物流订单关注的是收货地址。也就是说,商品、支付和物流分别为三个限界上下文,而订单作为统一语言需要在特定的限界上下文内,我们才能够明确其关注点/负责的职责。
最小完备:限界上下文在履行属于自己的业务能力时,拥有的领域知识是完整的,无须针对自己的信息去求助别的限界上下文。
自我履行:限界上下文能够根据自己拥有的知识来完成业务能力。自我履行体现了限界上下文纵向切分业务能力的特征。
这里需要强调一下业务模块(横向切分)和限界上下文(纵向切分)的区别。业务模块不具备完整、独立的业务能力,它没有按照同一个业务变化的方向进行。而限界上下文是对目标系统架构的纵向切分,切分的依据是从业务进行考虑的领域维度。为了提供完整的业务能力,在根据领域维度进行划分时,还需要考虑支撑业务能力的基础设施实现,如与该业务相关的数据访问逻辑,以及将领域知识持久化的数据库模型,形成纵向的逻辑边界,即限界上下文边界。
稳定空间:限界上下文必须防止和减少外部变化带来的影响。
独立进化:指减少限界上下文内部变化对外界产生的影响。
上述的四个特征可以帮助我们验证识别出来的限界上下文。限界上下文划分是否合理、职责分配是否合理(最小完备 & 自我履行),是否合理运用上下文映射的手段隔离外部变化的影响(稳定空间)、是否有合理的封装,对外提供的接口是否稳定(独立进化)?
1. 归类
按照业务相关性对业务服务进行归类,业务相关性体现为:
2. 归纳
归纳是对归类后的限界上下文进行命名。给限界上下文命名的过程,实际上也是对归类是否合理的再一次复查。限界上下文的命名同样需要遵循单一职责原则,它只能代表唯一的最能体现其特征的领域概念。倘若归类不合理,命名就会变得困难,这时候我们就需要反思(遵循知识消化循环)归类是否合理,并重新设计归类。
3. 边界梳理
归类和归纳之后,限界上下文的边界基本已经确定,边界梳理则是根据限界上下文特征(最小完备、自我履行、稳定空间和独立进化)以及子领域进行微调(当然也不排除大调)。
为什么需要根据子领域进行限界上下文边界的调整?限界上下文和子领域的关系是什么?
理想的限界上下文与子领域的关系是一一对应的。上文提到,子领域是领域专家根据领域经验选择合适的功能分类策略进行划分,这个过程不会牵扯对业务服务的分析,体现的是领域专家对行业的洞见和深刻认识,可见获取子领域是一个自顶向下的过程。而限界上下文则是对业务服务进行归类、归纳、梳理和调整,最终形成一个个的边界,这是一个自下而上的过程。理想情况下,两者应该是双向奔赴的,自顶向下得到的子领域和自下而上得到的限界上下文能够完美契合!但是,现实哪有这么理想呢!所以一般情况下都需要我们进行调整,力求这两者能够一一对应。
这里就再cue一下知识消化循环。优秀的领域专家划分出来的子领域,往往能够实现与限界上下文的一一对应。这就是经验的力量!那经验是怎么来的呢?我认为是领域专家经历了无数个知识消化循环之后沉淀下来的。领域专家一开始也是小白,划分出来的子领域在映射为限界上下文之后发现不同限界之间可能存在语义重叠,角色在不同限界上下文之中履行的职责可能很相似,于是他们通过知识消化循环,不断调整限界上下文的边界,然后又通过限界上下文调整子领域。慢慢地,稳定、可复用的子领域就被沉淀下来了。因此,识别限界上下文不是一个单向的过程,而是一个根据子领域调整限界上下文,然后又根据限界上下文调整子领域的循环的过程。
正交原则
正交性:如果两个或更多事物中的一个发生变化,不会影响其他事物,这些事物就是正交的。要破坏变化的传递性,就要保证每个限界上下文对外提供的业务服务不能出现雷同。
奥卡姆剃刀原理
“如无必要,勿增实体”。这是避免过度设计的良方,同样也是我们识别限界上下文的原则。如果对识别出来的限界上下文的准确性依然心存疑虑,比较务实的做法是保证限界上下文具备一定的粗粒度。遵循该原则,意味着当我们没有寻找到必须切分限界上下文的必要证据时,就不要增加新的限界上下文。
限界上下文封装了分离的业务能力,上下文映射则建立了限界上下文之间的关系。上下文映射提供了各种模式(防腐层、开放主机服务、发布语言、共享内核、合作者、客户方/供应方、分离方式、遵奉者、大泥球),本质是在控制变化在限界上下文之间传递所产生的影响。
下文将提供服务的限界上下文称为“上游”上下文(U 表示),消费服务的限界上下文称为“下游”上下文(D 表示)。
引入防腐层的目的是为了隔离耦合。防腐层往往位于下游,通过它隔离上游上下文发生的变化。
开放主机服务定义公开服务的协议(亦称为“服务契约”),包括通信方式、传递消息的格式(协议),让限界上下文可以被当做一组服务访问。开放主机服务也可以视为一种承诺,保证开放的服务不会轻易做出变化。
对于进程内的开放主机服务,称为本地服务(对应 DDD 中的应用服务)。
对于进程间的开放主机服务,成为远程服务。根据选择的分布式通信技术的不同,又可以定义出类型不同的远程服务:
发布语言是一种公共语言,用于两个限界上下文之间的模型转换。防腐层和开放主机服务都是访问领域模型时建立的一层包装,前者针对发起调用的下游(通过基础设施层体现),后者针对响应请求的上游(通过应用层+远程服务),以避免上下游之间的通信集成将各自的领域模型引入进来,造成彼此之间的强耦合。因此,防腐层和开放主机服务操作的对象都不应该是各自的领域模型,这正是引入发布语言的原因。(对于熟悉云 API 的小伙伴就会发现,其实云 API 根据我们定义的接口生成对应的 Request 对象和 Response 对象,并集成在云 API 的 SDK 中,这些对象就是发布语言)。
一般情况下,发布语言根据开放主机服务的服务契约进行定义。
说到这里,我们惊讶地发现防腐层,开放主机服务和发布语言可以完美联动!
共享内核指将限界上下文中的领域模型直接暴露给其他限界上下文使用。注意,这会削弱了限界上下文边界的控制力。上面我们讲述的防腐层、开放主机服务以及发布语言无不传达一种思想,限界上下文不能直接暴露自己的领域模型或直接访问其他限界上下文的领域模型,一定要有隔离层!
但是,在特定的场景下,共享内核不见得不是一种合理的方式。任何软件设计决策都要考量成本与收益,只有收益高于成本,决策才是合理的。一般对于一些领域通用的值对象是相对稳定的,这些类型通常属于通用子领域,会被系统中几乎所有的限界上下文复用,那么这些领域模型就适合使用共享内核的方式。共享内核的收益不言而喻,而面临的风险则是共享的领域模型可能产生的变化。
合作关系指的是协作的限界上下文由不同的团队负责,且这些团队之间具有要么一起成功,要么一起失败的强耦合关系。合作者模式要求参与的团队一起做计划、一起提交代码、一起开发和部署,采用持续集成的方式保证两个限界上下文的集成度与一致性,避免因为其中一个团队的修改影响集成点的失败。
当一个限界上下文单向地为另一个限界上下文提供服务时,它们对应的团队就形成了客户方/供应方模式。这是最为常见的团队协作模式,客户方作为下游团队,供应方作为上游团队,二者协作的主要内容包括:
分离方式的团队协作模式是指两个限界上下文之间没有一丁点关系。如果此时双方使用到了相似/相同的领域模型,则可以通过拷贝的方式解决,保证限界上下文之间的物理隔离!
当上游的限界上下文处于强势地位,且上游团队响应不积极时,我们可以采用遵奉者模式。即下游严格遵从上游团队的模型,以消除复杂的转换逻辑。
当下游团队选择“遵奉”于上游团队设计的模型时,意味着:
一定要避免制造大泥球!大泥球的特点:
示例-SMS 的限界上下文可划分为:
上下文映射图如下所示。
领域建模阶段由领域分析建模,领域设计建模和领域实现建模组成。在正式讲解建模活动前,先了解一下什么是模型驱动设计。
模型是一种知识形式,它对知识进行了选择性的简化和有意的结构化,从而解决信息超载的问题。模型便于人们理解信息的意义,并专注核心问题。
建模过程一般由分析活动、设计活动和实现活动组成。每一次建模活动都是一次对知识的提炼和转换,并产生相应的模型,即分析模型、设计模型和实现模型。
建模过程并非是分析、设计和实现单向的前后串行过程,而是相互影响,不断切换和递进的关系。模型驱动设计的建模过程是:分析中蕴含了设计,设计中夹带了实现,甚至实现后还要回溯到设计和分析的一种迭代的、螺旋上升的演进过程。
根据分解问题的视角不同,我们日常建立的模型可以大致分为以下三类:
一个优秀的领域模型应该具备以下的特征(我们也可以说具备这些特征的模型就是领域模型):
领域建模阶段目的便是建立领域模型。领域模型由领域分析模型、领域设计模型以及领域实现模型共同组成,它们也分别是领域分析建模、领域设计建模和领域实现建模三个建模活动的产物。
值得注意的是,领域模型并非由开发团队单方面输出的产物,而是由产品、领域专家和开发团队共同协作的结果。领域专家通过领域模型能够判断系统所支持的领域能力,以及由此编排出来的上层业务能力;开发团队通过领域模型能够形成基本的代码框架(包括架构分层,每层需要定义的接口,接口的命名等)。同理,领域模型的调整,也意味着领域知识或业务规则的变化,也预示着系统所支持的业务能力和代码实现同样需要作出改变。
领域分析建模:在限界上下文内,以“领域”为中心,提炼业务服务中的领域概念,确定领域概念之间的关系,最终形成领域分析模型。领域分析模型描述了各个限界上下文中的领域概念,以及领域概念之间的关系。
下面讲述如何通过“快速建模法”来构建领域分析模型。
找到业务服务中的名词,在统一语言指导下将其映射为领域概念。
识别动词并不是为领域模型对象分配职责、定义方法,而是将识别出来的动词当做一个领域行为,然后看它是否产生了影响管理、法律或财务的过程数据。若存在,则将这些过程数据作为领域概念放到领域分析模型中。注意,这里的过程数据是要求会对企业运营和管理产生影响的数据,比如示例-SMS 系统中老师提交修改申请,就会产生申请单这个过程数据,而请求流水记录、任务执行记录都不属于过程数据。动词建模通过分析领域行为是否产生过程数据来找到隐藏的领域概念,弥补了名词建模的不足。
特别地,对于会产生领域事件的动词,一般可以抽象出一个已完成该动作的状态。
除了“名词”和“动词”,概念中其他重要的类别也可以在模型中显式地表现出来,主要包括:约束和规格。
约束
约束一般是对领域概念的限制,我们可以将约束条件提取到自己的方法中,并通过方法名显式地表达约束的含义。比如示例-SMS 中关于 GPA 运算的约束。
有些时候,约束条件无法用单独一个方法来轻松表达,抑或约束条件中会使用到与对象职责无关的信息,那么我们就可以将其提取到一个显式的对象中。
规格(SPECIFICATION)
很多时候业务规则并不适合作为实体或值对象的职责,而且规则的变化和组合也会掩盖领域对象的含义。但是,将规则移出领域层则导致领域代码无法表达模型。此时,我们可以定义规格(谓词形式的显式值对象),它用于确定对象是否满足指定的标准。规格将规则保留在领域层,由于规格是一个完备的对象,所以这种设计也能更加清晰地反映模型。
规格一般有如下三种用法:
规格由“谓词”概念演变而来,因此我们可以使用“AND”,“OR”和“NOT”等运算对规格进行组合和修改。比如在 SMS 中,教务员需要查询流程完结的申请单,我们就可以通过“AND”组合不同的规格进行实现。
对于有定语修饰的名词,要注意分辨它们是类型的差异,还是值的差异。如配送地址和家庭地址,订单状态和商品状态。如果是值的差异,类型相同,应归并为一个领域概念(如,配送地址和家庭地址);而类型不同,则不能合并(如,订单状态和商品状态)。
特别地,当定语修饰的名词中,定语表示的是不同的限界上下文,且名词相同时(即名称相同、含义不同的领域概念),我们应该尽可能调整命名,确保含义不同的领域概念的名称不同,以避免不必要的歧义和沟通上的误解。比如:商品的订单和库存的订单在特定限界上下文内都可以命名为 order,但是如果把库存的订单改为库存的配送单 delivery 效果会更好。
根据业务需求和领域知识,判断领域概念之间是否存在关联。且对于 1:N, N:1, M:N 的关联关系,我们需要判断是否可以为这些关联关系定义一个新的类型,比如作品与读者存在 1:N 的关系,我们可以定义“订阅”这个概念来描述这种关系。
注意,我们需要尽量避免对象中的双向关系,即对象 A 关联对象 B,而对象 B 关联对象 A。当两个对象存在双向关系时,会为管理他们的生命周期带来额外的复杂度。我们应该规定一个遍历方向,来表明一个方向的关联比另一个方向的关联更有意义且更重要,比如示例 SMS 中,成绩会关联课程(成绩实例中包含课程 ID),而课程不会关联成绩。当然,当双向关系是领域的一个概念时,我们还是应该保留它。
通过名词建模,动词建模和归纳抽象后,可提炼出以下领域对象:成绩(Result)、绩点(gpa)、总成绩(total result)、总绩点(total gpa)、学年(school year)、学期(semester)、课程(course)、学分(credit)、申请单(application receipt),邮件(mail),排名(rank),申请单状态(application receipt status)
这些领域对象之间的关系如下图所示。
领域设计建模的核心工作就是设计聚合和设计服务,在这之前我们需要先了解一下设计要素(实体、值对象、聚合、工厂、资源库、领域服务、领域事件)。
领域驱动设计强调以“领域”为核心驱动力。设计领域模型时应该尽量避免陷入到技术实现的细节约束中。但很多时候我们又不得不去思考一些非领域相关的问题:
为了解答上述的四个问题,DDD 提供了很多的设计要素,它们能够帮助我们在不陷入到具体技术细节的情况下进行领域模型的设计。
实体的核心三要素:身份标识、属性和领域行为。
身份标识:身份标识的主要目的是管理实体的生命周期。身份标识可分为:通用类型和领域类型。通用类型 ID 没有业务含义;而领域类型 ID 则组装了业务逻辑,建议使用值对象作为领域类型 ID。
属性:实体的属性用来说明主体的静态特征,并持有数据与状态。属性分为:原子属性和组合属性。组合属性可以是实体,也可以是值对象,取决于该属性是否需要身份标识。我们应该尽可能将实体的属性定义为组合属性,以便于在实体内部形成各自的抽象层次。
领域行为:体现了实体的动态特征。实体具有的领域行为一般可以分为:
7.3.1.2 值对象
一个领域概念到底该用值对象还是实体类型,判断依据:
值对象具有不变性。值对象完成创建后,其属性和状态就不应该再进行变更了,如果需要更新值对象,则通过创建新的值对象进行替换。
由于值对象的属性是在其创建的时候就完成传入的,那么值对象所具有的领域行为大部分情况下都是“自给自足的领域行为”,即入参为空。这些领域行为一般提供以下的能力。
在进行领域设计建模时,要善于运用值对象而非内建类型去表达细粒度的领域概念。相比于内建类型,值对象的优势有:
7.3.1.3 聚合
聚合的基本特征:
7.3.1.4 工厂
聚合中的工厂:一个类或方法只要封装了聚合对象的创建逻辑,都可以认为是工厂。表现形式如下:
注意!这里工厂创建的基本单元是聚合,而非实体,注意与实体中的创建行为区分。
7.3.1.5 资源库
资源库是对数据访问的一种业务抽象,用于解耦领域层与外部环境,使领域层变得更为纯粹。资源库可以代表任何可以获取资源的仓库,例如网络或其他硬件环境,而不局限于数据库。
一个聚合对应一个资源库。领域驱动设计引入资源库,主要目的是管理聚合的生命周期。资源库负责聚合记录的查询与状态变更,即“增删改查”操作。资源库分离了聚合的领域行为和持久化行为,保证了领域模型对象的业务纯粹性。
值得注意的是,资源库的操作单元是聚合。当我们定义资源库的接口时,接口的入参应该为聚合的根实体。如果要访问聚合内的非根实体,也只能通过资源库获得整个聚合后,将根实体作为入口,在内存中访问封装在聚合边界内的非根实体对象。
资源库与数据访问对象(DAO)的区别:
根本区别在于,数据访问对象在访问数据时,并无聚合的概念,也就是没有定义聚合的边界约束领域模型对象,使得数据访问对象的操作粒度可以针对领域层的任何模型对象。数据访问对象(DAO)可以自由地操作实体和值对象。没有聚合边界控制的数据访问,会在不经意间破坏领域概念的完整性,突破聚合不变量的约束,也无法保证聚合对象的独立访问与内部数据的一致性。
其次,资源库是基于领域模型对存储系统进行的抽象,因此资源库中的方法命名可以表达领域概念;而数据访问对象(DAO)是存储系统对外暴露的抽象,其方法命名更贴合数据库本身的操作。
**7.3.1.6 领域服务 **
聚合通过聚合根的领域行为对外提供服务,而领域服务则是对聚合根的领域行为的补充。因此,我们应该尽量优先通过聚合根的领域行为来满足业务服务。
那什么场景下我们会需要用到领域服务呢?有如下两个:
7.3.1.7 领域事件
领域事件属于领域层的领域模型对象,由限界上下文中的聚合发布,感兴趣的聚合(同一限界上下文/不同限界上下文)可以进行消费。而当一个事件由应用层发布,则该事件为应用事件。
引入领域事件首要目的是更好地跟踪实体状态的变更,并在状态变更时,通过事件消息的通知完成领域模型对象之间的协作。
领域事件的特征:
领域事件的用途:
领域事件应该包含:
在领域设计模型中,聚合是最小的设计单元。
7.3.2.1 设计的经验法则
这里有四条经验法则:
下面展开讲述法则 1 和法则 3。
法则 1 在聚合边界内保护业务规则不变性。
法则 1 包含了两个关键点:a) 参与维护业务规则不变性的领域概念应该置于同一个聚合内;b) 在任何情况下都要保护业务规则不变性。比如,在 sms 系统中分数和绩点具有转换关系,这是业务规则的不变性,因此这两个概念被放在了同一个聚合边界内;当出现老师修改分数的场景时,需要保证绩点的换算同时被执行。由于这里绩点对象是值对象,不需要关心其生命周期管理的问题。当业务规则涉及到多个实体时,就需要通过本地事务来保证规则不变性(即实体间基于业务规则的数据一致性)。
法则 3 通过身份标识符关联其他聚合。
注意这里强调了关联关系,关联关系会涉及聚合 A 对聚合 B 的生命周期管理的问题,对于这种聚合间的关联关系,我们通过身份标识建立关联。而当聚合 A 引用聚合 B,但不需要对聚合 B 进行生命周期管理时,我们认为这是一种依赖关系(比如方法中的入参,而非类中的属性),对于聚合间的依赖关系,我们可以通过对象引用(聚合根实体的引用)的方式建立依赖。(PS:假设设计之初难以判断聚合之间到底是关联关系,还是依赖关系,我们就统一使用身份标识符作为关系引用即可)
聚合间的依赖关系通常分为两种方式
7.3.2.2 设计步骤
1. 理顺对象图
分析对象是实体还是值对象。
2. 分解关系薄弱处
聚合本质是一个高内聚的边界,因此我们可以根据领域对象之间关系的强弱来定义出聚合的边界。对象间的关系由强到弱可以分为:泛化关系,关联关系和依赖关系。其中关联关系和依赖关系在 7.3.2.1 小节已讲述,而泛化关系可以理解为是继承关系(即父子关系)。
泛化关系
虽然泛化关系是强耦合关系,但是根据对业务理解的视角不同,会产生不同的设计:
关联关系
上述提到过,聚合间的关联关系会涉及聚合 A 对聚合 B 的生命周期管理,这其实是一个比较宽松的约束。那聚合内实体的关联关系应该是怎么样的呢?生命周期一致的、共存亡的,当主实体被销毁时,从实体也随之会被销毁。比如商品实体和商品明细实体。而在示例-SMS 中,成绩和总成绩会被定义为两个聚合,原因是总成绩在成绩锁定后被统计,随后将不再发生改变,可见两者不存在上述的共存亡的关联关系。
PS: 实际上根据关联关系来区分边界的方法同样适用于限界上下文的边界划分。比如示例-SMS 中的课程和成绩生命周期不同,先有课程,后有成绩;而且成绩锁定后,课程被撤销也不会对成绩有影响,因此就可以定义出课程上下文和成绩上下问。
依赖关系
依赖关系主要体现的是实体间的职责委派和创建行为,可以分到不同的聚合边界。
3. 调整聚合边界
根据业务规则调整聚合边界。为了维护业务规则的不变性,相关的实体应该至于同一个聚合边界内。
这里的服务是对应用服务、领域服务、领域行为(实体提供的方法)和端口(资源库接口、防腐层接口)的统称。
业务服务包含若干个组合服务,组合服务包含若干个原子服务。领域行为和端口都可以认为是原子服务。
应用服务:匹配业务服务,提供满足业务需求的服务接口。应用服务自身并不包含任何领域逻辑,仅负责协调领域模型对象,通过它们的领域能力组合完整一个完整的应用目标。
领域服务:匹配组合服务,执行业务功能,若原子任务为无状态行为或独立变化的行为,也可以匹配领域服务。控制多个聚合与端口之间的协作,由它来承担组合任务的执行。
领域行为:匹配原子服务,提供业务功能的业务实现。强调无状态和独立变化,由实体提供。
端口:匹配原子服务,抽象对外资源的访问,主要的端口包括资源库接口和防腐层接口。
虽然上述给出了应用服务、领域服务、领域行为和端口与业务服务、组合服务和原子服务的匹配关系,但是对于应用服务、领域服务、领域行为和端口之间的关联关系却还不清晰,这里结合书中内容和个人实践给出一个参考。
应用服务:核心职责是编排聚合间的领域服务。
- 领域服务
- 防腐层接口:当多聚合间领域服务进行协作后需要访问外部资源,此时相关的防腐层逻辑应该至于应用层。(防腐层是上下文映射的方式,并非领域模型特有)
- 工厂:特指服务契约对象或装配器担任工厂,即将DTO转换为实体的工厂。
- 领域行为:在上述工厂创建实体后,若只需要调用实体的领域行为,而不需要涉及生命周期管理,可直接在应用服务中进行调用。
领域服务:细粒度的领域对象可能会把领域层的知识泄露到应用层中。这产生的结果是应用层不得不处理复杂的、细致的交互,从而使得领域知识蔓延到应用层或用户界面代码当中,而领域层会丢失这些知识。明智地引入领域层服务有助于在应用层和领域层之间保持一条明确的界限,因此应用层多数情况下也不会直接引用聚合的领域行为。
- 工厂
- 领域行为
- 防腐层接口:聚合内需要依赖外部资源,则将防腐逻辑收拢在领域服务中。
- 资源库接口
领域行为:不要关联资源库和防腐层接口。
聚合设计:
服务设计:
下面只罗列非查询类的服务设计。
领域实现建模关注的并非是如何进行代码实现,而是如何验证代码实现的正确性,保证实现的高质量。
领域模型中的服务包括了应用服务、领域服务、领域行为和端口。其中通过 Provider(面向服务行为)、Resource(面向服务资源)、Subscriber(面向事件)、Controller(面向视图模型)对外进行暴露的,我们称为远程服务。
领域模型中的服务与测试金字塔的关系如下图所示。
领域实现建模提倡的是测试驱动开发的编程思想,即要求开发者在进行逻辑实现前,优先进行测试用例的编写,站在调用者角度而非实现者角度去思考接口。
在上述测试金字塔中,开发者需要关注的是单元测试(不依赖任何外部资源的测试就是单元测试)。在领域设计建模阶段,我们对业务服务/应用服务进行分解,定义出了领域行为和领域服务。对于领域行为,由于其不依赖外部资源,因此我们可以直接编写单元测试;而对于领域服务,其可能会通过端口访问外部资源,此时我们需要对端口进行 mock,以隔离外部资源对领域逻辑验证的干扰。特别地,单元测试一定要覆盖所有对业务规则的验证,这是保证领域行为和领域服务正确性的基础。
单元测试编码规范:
代码架构分层是经典 DDD 四层:用户接口层,应用层,领域层和基础设施层。
需要注意的的地方是:
用户接口层的核心职能:协议转换和适配、鉴权、参数校验和异常处理。
├── controller //面向视图模型&资源
│ ├── ResultController.java
│ ├── assembler // 装配器,将VO转换为DTO
│ │ └── ResultAssembler.java
│ └── vo // VO(View Object)对象
│ ├── EnterResultRequest.java
│ └── ResponseVO.java
├── provider // 面向服务行为
├── subscriber // 面向事件
└── task // 面向策略
└── TotalResultTask.java
应用层的核心职能:编排领域服务、事务管理、发布应用事件。
├── assembler // 装配器,将DTO转换为DO
│ ├── ResultAssembler.java
│ └── TotalResultAssembler.java
├── dto // DTO(Data Transfer Object)对象
│ ├── cmd // 命令相关的DTO对象
│ │ ├── ComputeTotalResultCmd.java
│ │ ├── EnterResultCmd.java
│ │ └── ModifyResultCmd.java
│ ├── event // 应用事件相关的DTO对象, subscriber负责接收
│ └── qry // 查询相关的DTO对象
└── service // 应用服务
├── ResultApplicationService.java
├── event // 应用事件,用于发布
└── adapter // 防腐层适配器接口
代码组织以聚合为基本单元。
├── result // 成绩聚合
│ ├── entity // 成绩聚合内的实体
│ │ └── Result.java
│ ├── service // 领域服务
│ │ ├── ResultDomainService.java
│ │ ├── event // 领域事件
│ │ ├── adapter // 防腐层适配器接口
│ │ ├── factory // 工厂
│ │ └── repository // 资源库
│ │ └── ResultRepository.java
│ └── valueobject // 成绩聚合的值对象
│ ├── GPA.java
│ ├── ResultUK.java
│ ├── SchoolYear.java
│ └── Semester.java
└── totalresult // 总成绩聚合
├── ... 这段有点长,其代码结构与成绩聚合一致,因此省略 ...
该层主要提供领域层接口(资源库、防腐层接口)和应用层接口(防腐层接口)的实现。
代码组织基本以聚合为基本单元。对于应用层的防腐层接口,则直接以 application 作为包名组织。
├── application // 应用层相关实现
│ └── adapter // 防腐层适配器接口实现
│ ├── facade // 外观接口
│ └── translator // 转换器,DO -> DTO
├── result // 成绩聚合相关实现
│ ├── adapter
│ │ ├── facade
│ │ └── translator
│ └── repository // 成绩聚合资源库接口实现
│ └── ResultRepositoryImpl.java
└── totalresult // 总成绩聚合相关实现
├── adapter
│ ├── CourseAdapterImpl.java
│ ├── facade
│ └── translator
└── repository
└── TotalResultRepositoryImpl.java
微服务拆解指的是把一个单体服务拆分为粒度“足够小”的多个服务,而这里的“足够小”是一个主观的,没有任何标准的定义。尽管如此,我们对“微”这个词还是有一些基本要求的:足够内聚,足够独立,足够完备,这才使得拆分出来的微服务收益大于投入,试想如果一个微服务提供的业务功能会牵扯到与其他众多微服务的协作,那岂不是芭比 Q 了。
而上述我们对微服务的基本要求,实际上与限界上下文的特征(最小完备,自我履行,稳定空间,独立进化)不谋而合,因此,我们可以把限界上下文映射为微服务。我在日常实践中,都是将限界上下文和微服务的关系进行一一对应的,但这不是绝对的!限界上下文是站在领域角度给出的逻辑边界,而微服务的设计往往还要考虑物理边界,以及实际的质量需求(性能,可用性,安全性等),比如当我们采用的是 CQRS 架构,领域模型会被分为命令模型和查询模型,虽然它们同属一个限界上下文,但是它们往往是物理隔离的。因此,限界上下文只能作为微服务拆分的指导,而拆分过程中需要考虑质量需求,架构设计等技术因素。
上文在提及限界上下文识别和聚合设计的时候其实都提到需要考虑事务属性,即需要通过本地事务来保证业务规则的不变性/一致性。这里我们会疑惑的是:谁来承担管理事务的职责?事务管理的边界是什么?
应用层承担管理事务的职责
事务本质是一种技术手段,而领域模型本身与技术无关,因此事务应该由应用层负责管理。
事务管理的边界是聚合,有时限界上下文也可以
资源库操作的基本单元是聚合,因此事务管理的边界是聚合便是自然而然得出的结论。这里需要考虑的是当需要保证事务属性的不仅仅只有资源库操作,还包括发布领域事件时(即保证聚合落库和事件发布的原子性),我们可能需要采用可靠事件模式,即通过把领域事件落库事件表来表示事件的发布。此时应用层在管理事务时就没什么心智负担了。当然,采用可靠事件模式实际是限制了领域模型的实现,也算是技术对领域模型的一种入侵吧,但相比于解放应用层而言,应该是利大于弊。
我们也知道,应用层的核心职责是负责编排和协调不同聚合的领域服务,而应用层又负责事务管理,自然我们能推到出事务管理的边界是多个聚合(即限界上下文)。但这里有两个关注点:
a)一般是出于质量需求(性能会好一些,时效性更高一些);
b)同一个限界上下文内的多个聚合共享一个 DB。
为了避免耦合,DDD 主张通过柔性事务来保证跨聚合、跨限界上下文的最终一致性。而目前业界比较主流的应用是 Saga 模式:通过使用异步消息来协调一系列本地事务,从而维度多个服务之间的数据一致性。而另一个非常著名的柔性事务方案 TCC 为啥没有 Saga 契合呢?
TCC 共分为三个阶段:
可以看到 TCC 实际对领域模型的侵入是比较大的:
a)TCC 要求领域模型设计时,定义相关的属性以支持资源锁定/预留的问题;
b)TCC 对服务接口定义做出了要求,领域模型需要提供 Try,Confirm 和 Cancel 相应的领域服务。
Saga 模式并不要求其对资源进行锁定/预留,而其补偿操作也是通过执行操作的逆操作来完成(比如支付的逆操作是退款)。而大部分情况下,完整的领域模型都会对外提供操作及其逆操作。
最近其他好文: