目录
1、DDD 背景
领域驱动设计(Domain-Driven Design,简称DDD)是由Eric Evans提出的,并在其著作《领域驱动设计:软件核心复杂性应对之道》发布后,在软件行业内产生了巨大影响。DDD提供了一种独特的思维方式,它倡导开发者应该用业务方案来解决业务问题,而不是单纯依靠技术方案。这种理念仿佛是魔法对抗魔法。尽管DDD的理念在当时看起来有些违反直觉,但并没有立即流行开来。
随着微服务架构的兴起,人们惊讶地发现DDD可以作为划分微服务边界的有力工具,并且DDD的许多设计理念与微服务架构高度契合。因此,DDD逐渐被开发者们接受并流行起来。现在,了解和学习DDD已经成为软件行业从业者的一门必修课。
但是,DDD的学习曲线相对较陡峭。我阅读过许多相关书籍、KM文章和分享,但始终感觉难以掌握其精髓。原因有两点:一是DDD涉及的概念众多,且抽象层次不同,如子域和限界上下文,它们在问题归类和收敛方面的区别是什么?二是缺乏过程指导,难以将概念有序地串联起来。虽然DDD提供了设计思想、核心原则和常用工具,但缺乏具体的方法步骤,使得实践变得困难。
幸运的是,最近看了一本书《解构-领域驱动设计》。这本书提出了领域驱动设计统一过程(DDDRUP),它指明了实践 DDD 的具体步骤,并很好地串联了各种概念、模式和思想。因此,我对书本内容做了梳理、简化,融入自己的理解,并结合之前阅读的书籍以及实践经验,最终形成这篇文章。希望通过这篇文章,能够帮助大家理清DDD的概念、模式和思想,降低学习DDD的门槛。
在未来的软件开发过程中,领域驱动设计将发挥越来越重要的作用。掌握DDD的理念和方法,将有助于我们更好地应对软件行业的挑战。
2、DDD 概要与实践感悟
经典必读书籍《领域驱动设计:软件核心复杂性应对之道》的书名包含了两个关键词:领域驱动和复杂性,分别代表了 DDD 的核心原则以及解决的问题。
2.1 复杂性
系统的复杂性通常源于领域本身、用户行为或业务流程,并非仅仅是技术问题。若在设计阶段未能妥善处理这些领域性难题,即便技术设想再精妙,也难以奏效。而系统的复杂度体现在三个方面:规模、结构和变化。
规模:规模涉及系统支持的功能点及其相互关系。利用领域驱动设计(DDD)中的子领域、界限上下文和聚合等概念,我们能够对问题进行细分和归类,从而缩小问题范围,确保聚合边界内的问题解决更加集中和可控。
结构:结构关注的是系统架构,包括是否分层、各层职责是否分明以及基本管理单元的性质。这些因素影响架构演化的复杂度。DDD推崇分层架构,将领域层独立出来,并为每一层分配明确职责。聚合作为基本管理单元,是独立和自包含的,服务拆分时,可 以聚合为单位进行分割。
变化:变化指的是系统对需求变更的适应能力。分离不变和易变逻辑是有效管理变化的关键。领域层通过分层架构成为不变逻辑的独立部分,它包含了领域知识,提供的领域服务反映了经验和远见,是对稳定领域规则的表达。领域层之外的应用层和基础设施层则包含易变逻辑,确保核心稳定的同时,通过调整这两层来迅速适应需求变化。
实践中,我深刻体会到DDD理念和方法在应对软件复杂性方面的重要性。应用DDD的设计模式和原则,我能更有效地分解和归类问题,构建出更清晰、易于维护和扩展的系统架构。同时,我也意识到领域驱动设计不只是技术手段,更是一种思考模式,它促使我们深入理解业务领域,从业务视角出发思考问题,用业务语言沟通,从而更有效地解决业务挑战。
2.2 领域驱动
领域导向设计(Domain-Driven Design, DDD)是一种以业务领域为核心的软件设计方法。它强调在满足业务需求时,首先应抽象出领域概念,并基于这些概念构建领域模型来刻画业务问题。在这个过程中,应尽量推迟技术细节的介入。编码阶段则是对领域模型的逐行实现,代码应能够清晰地表达领域概念,让人见码明义。
根据实践经验,以下是本人对“领域驱动”的一些见解:
思维模式转变
在实践 DDD 以前,我最常使用的是数据驱动设计。它的核心思路针对业务需求进行数据建模:从业务需求中提炼出类,然后通过 ORM 将类映射为表结构,并根据读写性能要求使用范式优化表与表之间的关联关系。数据驱动设计是从技术角度解决业务问题,得出的数据模型是对业务需求的直接翻译,并没有蕴含稳定的领域知识/规则。一旦业务需求发生变化,数据模型就必须调整,数据库设计也随之改变。这种设计思维导致业务变化直接影响到数据层,缺乏一个稳定且不易变的中层来缓冲变化,从而影响了系统对变化的响应能力。
总结来说,领域导向设计不仅仅是一种技术上的转变,它更是一种思维模式上的革新。它要求我们深入理解业务领域的本质,从业务的角度出发思考问题,用业务的语言进行沟通。通过这种方式,我们能够构建出更加稳定、更能适应业务变化的软件系统。领域导向设计的核心在于建立一套能够反映业务领域知识和规则的模型,这套模型是稳定的,能够在业务需求变化时提供不变的基础,从而使得技术实现能够更加灵活地适应这些变化。
协同方式转变
过去,由产品同学提出业务需求,研发同学根据业务需求的 tapd 进行技术方案设计,并编程实现。
这种协同方式的弊端在于:无法形成能够消除认知差异的模型。产品经理从业务视角出发提出用户需求,这些需求可能频繁变化且高度定制化。与此同时,研发人员在缺乏行业经验的情况下,往往倾向于直接将需求转换为数据模型。研发团队从技术实现的角度出发设计技术方案,涉及众多技术细节,而产品经理难以判断这些方案是否与自己的业务诉求和产品规划一致,从而导致认知差异。随着迭代的进行,这种差异会不断加剧,最终导致系统变得复杂难以维护。
DDD 通过解锁新角色”领域专家"以及模型驱动设计,有效地减少了产品和研发之间的认知差异。领域专家拥有丰富的行业经验和深厚的领域知识,他们能够从变化多端和定制化的需求中提炼出清晰的边界,稳定且可复用的领域概念和业务规则,并与产品和研发团队合作,共同构建领域模型。领域模型是对业务需求的知识表达,它不涉及具体技术细节(但能指导研发人员进行编程实现),从而消除了产品和研发在需求理解上的分歧。模型驱动设计要求领域模型与业务需求和编码实现紧密关联,模型的任何变更都意味着需求变更和代码变更,协作的核心围绕着模型展开。
总结来说,DDD的实施不仅仅是一种技术上的改进,它更是一种协作模式的创新。通过领域专家的介入和模型驱动设计的方法,我们能够构建出一个更加清晰、更能适应业务变化的软件系统。这种模式要求我们深入理解业务领域的本质,从业务的角度出发思考问题,用业务的语言进行沟通。通过这种方式,我们能够建立起一个稳定、能够灵活适应业务变化的软件系统。领域模型成为了协作的中心,它不仅仅是一个技术工具,更是一个沟通平台,确保了产品和研发的紧密合作,共同应对业务挑战。
精炼循环
精炼迭代是指在统一语言、提炼领域概念、界定边界、构建模型、实现绑定这一系列过程中,各环节相互作用与反馈,通过不断的试错、调整,最终形成一个稳定且深层次的领域模型。例如,在领域概念提炼过程中,若发现统一语言定义存在不合理或歧义,将调整统一语言定义,并重新进行领域概念的提炼。通过精炼迭代,我们逐步构建出稳定的领域模型。在DDD中,领域专家主导概念提炼、边界划分等宏观设计,这是因为领域专家的经验和行业洞察力来源于过去无数次的精炼迭代,因此,这些宏观设计推导出的领域模型通常非常稳定。
精炼迭代的关键在于循环,它确保知识在各个方向上流动,防止因环节上的认知差异导致模型在产品、领域专家和研发之间无法达成一致,以及模型与实现之间的割裂。
实践中,我深刻认识到领域驱动设计方法在解决软件复杂性方面的重要性。运用领域驱动设计的原则和模式,我能更有效地拆分和归类问题,设计出更清晰、易于维护和扩展的系统架构。同时,我也意识到领域驱动设计不只是一种技术方法,更是一种思维方式,它要求我们深入理解业务领域,从业务角度思考问题,用业务语言沟通,从而更好地解决业务问题。
总结来说,精炼迭代是一种在不断反馈和调整中逐步完善领域模型的过程。它强调领域专家的参与和主导,以确保模型稳定且具有行业洞察力。在实践中,这种方法帮助我们设计出更符合业务需求、更易于维护和扩展的系统架构。同时,它也促使我们以业务为中心,深入理解业务领域,用业务语言沟通,从而更好地解决业务问题。精炼迭代不仅是一种技术手段,更是一种思维方式,它引导我们以更全面、更深入的视角看待软件复杂性,从而找到更有效的解决方案。
2.3 怎么才算 DDD?
我早期实践 DDD 的时候,认为代码分层遵循四层架构就是 DDD,抑或分离接口和实现,实现下沉至基础设施层就是 DDD,实则不然。结合上述内容,目前个人认为只要满足以下条件即为实践 DDD:
-
构建出产品、领域专家和研发同学认知一致且便于交流的模型,并且模型与实现紧密绑定;
-
模型逐步演进,反复消化和精炼;
-
模型蕴含领域知识,足够稳定。
3、问题空间&解空间
3.1 问题空间&解空间
问题空间和解空间并非 DDD 特有的概念,而是人们为了区分真实世界和理念世界而提出的概念。问题空间表示的是真实世界,是具体的问题和用户的诉求,而解空间则是针对问题空间求解后构建的理念世界,其中包括了解决方案、模型等。
DDD 提出的战略设计覆盖了问题空间和解空间,而战术设计则聚焦在解空间上。明确 DDD 中的概念是作用于问题空间还是解空间,更有助于我们理解它们。
3.2 示例-学生管理系统的问题空间
学生管理系统(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,则没有绩点,并邮件通知教务员,由教务员联系学生商榷重修事宜。
成绩录入后的一周内,若出现录入成绩错误的情况,老师可提交修改申请,由教务员审核后即可完成修改。审核完成后系统会通过邮件告知老师审核结果。一周后成绩将锁定,不予修改。成绩锁定后,次日系统会自动计算各年级、各班的学生的总绩点(总绩点由各门课程的学分与其绩点进行加权平均后所得)。
而教务员则可以通过该系统发布可以选修的课程。同时,教务员能够查看到各年级,各班的学生的总绩点排名。
在我看来,领域驱动设计是一种思维方式,它强调从业务领域出发,以领域知识为核心,构建出稳定、可复用的模型。这种设计方法不仅有助于提高系统的可维护性和可扩展性,还能有效地降低产品、领域专家和研发人员之间的认知差异,促进协作。
4、领域驱动设计统一过程(DDDRUP)
尽管领域驱动设计将设计过程分为战略设计和战术设计,并提供了一系列模式和工具,但一直没有一个统一的过程来规范这两个阶段需要执行的活动、交付的成果以及阶段性的里程碑。此外,这两个阶段之间的衔接以及执行的工作流程也缺乏明确的定义。
《解构-领域驱动设计》一书提出的 DDDRUP 为我们提供了更详细的步骤、各步骤之间的衔接,以及明确的阶段里程碑。最重要的是,DDDRUP 能够将 DDD 的所有概念和模式串联起来,非常便于初学者做知识梳理和上手实践。下文我会依照 DDDRUP 的步骤流程进行讲述,而非战略设计+战术设计的思路。(DDDRUP 各步骤与战略&战术设计的关系见下表)。
总的来说,领域驱动设计统一过程(DDDRUP)为我们提供了一种规范化、结构化的领域驱动设计方法,有助于我们更好地理解和应用领域驱动设计,从而提高软件系统的质量和可维护性。
5、全局分析阶段
全局分析阶段对问题空间进行的梳理和分析,形成统一语言(ubiquitous language), 获取问题空间的价值需求以及业务需求。
5.1 形成统一语言
统一语言:蕴含领域知识的、团队内统一的领域术语。由于产品、领域专家和开发人员掌握的领域知识有所差异,往往会导致对同一事物使用不同的术语。例如,商品的价格(Price)和商品的金额(Amount),它们本质上是相同的,但却有不同的术语表示。
统一语言将贯穿 DDDRUP 的整个流程,并在精炼循环过程中不断进行调整,以更好地反映更合适、更深层次的领域知识。
根据业务需求形成的统一语言有助于团队成员对事物的认知达成一致。统一语言可以通过词汇表的形式展示,其中词汇表最好还要包含术语对应的英文描述,以便研发人员在代码层面表达统一语言。示例-SMS 的统一语言词汇表如下。
术语 | 英文描述 | 术语 | 英文描述 |
---|---|---|---|
学生 | student | 分数 | score |
老师 | teacher | 学分 | credit |
教务员 | senator | 排名 | rank |
课程 | course | 年级 | grade |
成绩 | result | 班级 | class |
绩点 | gpa | 学年 | school year |
总绩点 | total gpa | 学期 | semester |
申请单 | application receipt | 修改成绩 | modify result |
查询成绩 | queny result | 提交申请单 | submit application receipt |
统计总绩点 | compute total gpa | 发布课程 | publish course |
查询总绩点 | query total gpa | 查询课程列表 | query course list |
查询排名 | query rank | 查询课程信息 | query course info |
同意申请单 | agree application receipt | 选择课程 | choose course |
拒绝申请单 | reject application receipt | 录入成绩 | enter result |
邮件通知 | notify by mail |
全局分析阶段是领域驱动设计过程中的重要一环,它有助于我们深入理解业务领域,挖掘业务需求,并为后续的设计和实现奠定基础。通过形成统一语言,我们可以确保团队成员在沟通和协作过程中减少歧义,提高工作效率。
在全局分析阶段,我们还需要对业务领域进行细分,划分出不同的子领域,以便在后续的设计过程中更好地组织和管理领域知识。此外,我们还需要识别出领域中的关键概念和关系,为构建领域模型做好准备。
5.2 价值需求分析
价值需求分析主要做的三个工作是:
-
识别利益相关者。利益相关者是指与目标系统存在利益关系的人、团队或组织,可以简单理解为目标系统的用户,或与目标系统有直接交互的人、团队或组织。
-
明确系统愿景。阐明目标系统要做什么,以及为何要做。
-
确定系统范围。确定系统问题空间的边界,明确系统什么该做,什么不该做。结合目标系统当前状态和未来状态进行判断。当前状态指的是系统的可用资源,包括业务资源、人力资源,资金资源等;而未来的状态则由业务目标、组织的战略规划和产品规划共同构成。
并不是所有系统都适合采用 DDD,DDD 的核心是解决领域复杂性。如果系统逻辑简单,功能有限,引入 DDD 可能会带来不必要的成本。通过进行价值需求分析,我们可以判断是否需要通过 DDD 来驱动系统设计。
在软件开发过程中,价值需求分析是一个非常重要的环节。通过对价值需求的分析,我们可以更好地理解目标系统的功能和目标,从而为后续的系统设计和实现提供指导。同时,价值需求分析也有助于我们识别出系统的关键利益相关者,确保系统的设计和实现能够满足他们的需求和期望。
5.3 业务需求分析
5.3.1 业务流程、业务场景、业务服务和业务规则
使用业务流程、业务场景、业务服务和业务规则来表示业务需求。
业务流程:表示的是一个完整的、端对端的服务过程。
业务场景:将业务流程根据阶段性的业务目标进行划分,就可以得到业务场景。在示例-SMS 中,老师修改成绩可以分为老师“提交申请单”和教务员“同意申请单”两个场景。
业务服务:角色主动向目标系统发起服务请求,完成一次完整的功能交互,以实现业务目标。角色可以用户、策略(定时任务)或者其他系统,完整则强调的是业务服务的执行序列的所有步骤都应该是连续且不可中断的。业务服务是业务需求分析最核心,也是最基础的单元,而业务流程和业务场景是为了更好地分析出业务服务。在示例-SMS 中的“同意申请单”场景中包含了两个业务服务:教务员“同意申请单”和系统“邮件通知”教务员。
业务规则:它是对业务服务约束的描述,用于控制业务服务的对外行为。业务规则是业务服务正确性的基础。常见的业务规则有:
-
a) 意如“若… , 就….” 的需求描述,比如示例-SMS 中可提炼出“若成绩录入时间间隔超过一周,不予修改”;
-
b) 具有事务性的操作。
业务需求分析是软件开发过程中的重要一环,它帮助我们深入理解业务领域,明确业务需求,并为后续的系统设计和实现提供指导。通过分析业务流程、业务场景、业务服务和业务规则,我们可以更好地理解业务领域的运作机制,为构建高质量的软件系统奠定基础。
5.3.2 子领域
通过对业务流程、业务场景和业务服务的整理,我们可以分析出业务需求所需的业务服务。然而,由于业务服务过于细致,而问题空间又较大,我们需要找到一个更粗粒度的业务单元,以便对业务服务进行分类。这样做一方面可以降低管理大量细粒度业务服务所带来的额外复杂度,另一方面可以帮助领域专家和开发团队在分析问题和设计方案时避免陷入业务细节。这个更粗粒度的业务单元就是子领域。
子领域的作用:
-
划分问题空间,作为业务服务分类的边界;
-
用于分辨问题空间的核心问题和次要问题。
子领域的分类:
-
核心子领域:能够体现系统愿景,具有产品差异化和核心竞争力的业务服务;
-
通用子领域:包含的内容缺乏领域个性,具有较强的通用性,例如权限管理和邮件管理;
-
支撑子领域:包含的内容多为“定制开发”,其为核心子领域的功能提供了支撑。
子领域的功能分类策略:问题空间应该分为哪些子领域,需要团队对目标系统整体进行探索,并根据功能分类策略进行分解。
-
业务职能:当目标系统应用于企业的生产和管理时,与目标系统业务相关的职能部门往往会影响目标系统的子领域划分,并形成一种简单的映射关系。这是康威定律的一种应用。
-
业务产品:当目标系统为客户提供多个具有业务价值的产品时,可以按照产品的内容和方向进行子领域划分。
-
业务环节:对贯穿目标系统的核心业务流程进行阶段划分,然后按照划分出来的每个环节确定子领域。(这也是我们最常用的策略)
-
业务概念:捕捉目标系统中一目了然的业务概念,将其作为子领域。
划分子领域的过程存在很多经验因素,一个对该行业领域知识了如指掌的领域专家,可以在完成价值需求分析后,结合自身的领域经验,能够选择合适的聚类策略并给出稳定的子领域列表。但,没有领域经验也没有关系!因为根据知识消化循环思路,再经历多个迭代后收敛出来的子领域划分也会逐渐合理,逼急领域专家凭经验得出的子领域划分,只是可能需要的时间要长一些。
子领域的概念和划分方法是为了帮助我们在面对复杂问题空间时,能够更好地对业务服务进行管理和分析。通过将问题空间划分为不同的子领域,我们可以更有针对性地解决问题,提高系统的质量和可用性。在实际应用中,领域专家和开发团队需要根据目标系统的特点和业务需求,灵活运用子领域的划分方法,以实现高质量的系统设计和实现。
6、架构映射阶段
在架构映射阶段,我们需要识别限界上下文,并通过上下文映射表示限界上下文之间的协作关系。
6.1 限界上下文的定义和特征
6.1.1 限界上下文的定义
限界上下文是语义和语境的边界。在问题空间,统一语言构成了团队对领域概念的统一表达,子领域形成了领域概念之间的边界。而在解空间,限界上下文可以看作是统一语言+子领域的结合体,统一语言在限界上下文内才具有明确的业务含义。
以电商购物场景为例。在进行商品下单后,系统会生成一个订单;在用户付款完成后,系统也会生成一个订单;到了物流派送流程,系统还会生成一个订单。虽然这三个步骤中的领域概念都叫订单,但是他们的关注点/职责却不同:商品订单关注的是商品详情,支付订单关注的是支付金额和分润情况,物流订单关注的是收货地址。也就是说,商品、支付和物流分别为三个限界上下文,而订单作为统一语言需要在特定的限界上下文内,我们才能够明确其关注点/负责的职责。
6.1.2 限界上下文的特征
最小完备:限界上下文在履行属于自己的业务能力时,拥有的领域知识是完整的,无须针对自己的信息去求助别的限界上下文。
自我履行:限界上下文能够根据自己拥有的知识来完成业务能力。自我履行体现了限界上下文纵向切分业务能力的特征。
这里需要强调一下业务模块(横向切分)和限界上下文(纵向切分)的区别。业务模块不具备完整、独立的业务能力,它没有按照同一个业务变化的方向进行。而限界上下文是对目标系统架构的纵向切分,切分的依据是从业务进行考虑的领域维度。为了提供完整的业务能力,在根据领域维度进行划分时,还需要考虑支撑业务能力的基础设施实现,如与该业务相关的数据访问逻辑,以及将领域知识持久化的数据库模型,形成纵向的逻辑边界,即限界上下文边界。
稳定空间:限界上下文必须防止和减少外部变化带来的影响。
独立进化:指减少限界上下文内部变化对外界产生的影响。
上述的四个特征可以帮助我们验证识别出来的限界上下文。限界上下文划分是否合理、职责分配是否合理(最小完备 & 自我履行),是否合理运用上下文映射的手段隔离外部变化的影响(稳定空间)、是否有合理的封装,对外提供的接口是否稳定(独立进化)?
限界上下文是软件架构设计的重要概念,它帮助我们更好地理解业务领域,明确业务边界,并为系统设计提供指导。通过识别和划分限界上下文,我们可以确保系统的高内聚和低耦合,提高系统的可维护性和可扩展性。在实际应用中,开发团队需要根据业务需求和领域特点,灵活运用限界上下文的概念和特征,以实现高质量的系统设计和实现。
6.2 限界上下文的识别
6.2.1 按业务维度识别
1. 归类
按照业务相关性对业务服务进行归类,业务相关性体现为:
-
语义相关性:存在相同或相似的领域概念,对应于业务服务描述的名词,如果不同的业务服务操作了相同或相似的对象,即可认为它们存在语义相关性。
-
功能相关性:体现领域行为的相关性,业务服务是否服务于同一个业务目标。
2. 归纳
归纳是对归类后的限界上下文进行命名。给限界上下文命名的过程,实际上也是对归类是否合理的再一次复查。限界上下文的命名同样需要遵循单一职责原则,它只能代表唯一的最能体现其特征的领域概念。倘若归类不合理,命名就会变得困难,这时候我们就需要反思(遵循知识消化循环)归类是否合理,并重新设计归类。
3. 边界梳理
归类和归纳之后,限界上下文的边界基本已经确定,边界梳理则是根据限界上下文特征(最小完备、自我履行、稳定空间和独立进化)以及子领域进行微调(当然也不排除大调)。
为什么需要根据子领域进行限界上下文边界的调整?限界上下文和子领域的关系是什么?
理想的限界上下文与子领域的关系是一一对应的。上文提到,子领域是领域专家根据领域经验选择合适的功能分类策略进行划分,这个过程不会牵扯对业务服务的分析,体现的是领域专家对行业的洞见和深刻认识,可见获取子领域是一个自顶向下的过程。而限界上下文则是对业务服务进行归类、归纳、梳理和调整,最终形成一个个的边界,这是一个自下而上的过程。理想情况下,两者应该是双向奔赴的,自顶向下得到的子领域和自下而上得到的限界上下文能够完美契合!但是,现实哪有这么理想呢!所以一般情况下都需要我们进行调整,力求这两者能够一一对应。
这里就再cue一下知识消化循环。优秀的领域专家划分出来的子领域,往往能够实现与限界上下文的一一对应。这就是经验的力量!那经验是怎么来的呢?我认为是领域专家经历了无数个知识消化循环之后沉淀下来的。领域专家一开始也是小白,划分出来的子领域在映射为限界上下文之后发现不同限界之间可能存在语义重叠,角色在不同限界上下文之中履行的职责可能很相似,于是他们通过知识消化循环,不断调整限界上下文的边界,然后又通过限界上下文调整子领域。慢慢地,稳定、可复用的子领域就被沉淀下来了。因此,识别限界上下文不是一个单向的过程,而是一个根据子领域调整限界上下文,然后又根据限界上下文调整子领域的循环的过程。
6.2.2 验证
正交原则
正交性:如果两个或更多事物中的一个发生变化,不会影响其他事物,这些事物就是正交的。要破坏变化的传递性,就要保证每个限界上下文对外提供的业务服务不能出现雷同。
奥卡姆剃刀原理
“如无必要,勿增实体”。这是避免过度设计的良方,同样也是我们识别限界上下文的原则。如果对识别出来的限界上下文的准确性依然心存疑虑,比较务实的做法是保证限界上下文具备一定的粗粒度。遵循该原则,意味着当我们没有寻找到必须切分限界上下文的必要证据时,就不要增加新的限界上下文。
总结来说,限界上下文的识别是软件架构设计的重要环节,它有助于我们更好地理解业务领域,明确业务边界,并为系统设计提供指导。通过识别和划分限界上下文,我们可以确保系统的高内聚和低耦合,提高系统的可维护性和可扩展性。在实际应用中,开发团队需要根据业务需求和领域特点,灵活运用限界上下文的概念和特征,以实现高质量的系统设计和实现。
6.3 上下文映射
限界上下文封装了独立的业务能力,上下文映射则建立了限界上下文之间的关系。上下文映射提供了各种模式(防腐层、开放主机服务、发布语言、共享内核、合作者、客户方/供应方、分离方式、遵奉者、大泥球),本质是在控制变化在限界上下文之间传递所产生的影响。
下文将提供服务的限界上下文称为“上游”上下文(U 表示),消费服务的限界上下文称为“下游”上下文(D 表示)。
6.3.1 防腐层
引入防腐层的目的是为了隔离耦合。防腐层往往位于下游,通过它隔离上游上下文发生的变化。
6.3.2 开放主机服务
开放主机服务定义公开服务的协议(亦称为“服务契约”),包括通信方式、传递消息的格式(协议),使得限界上下文能够被视作一系列服务接口来使用。公开主机服务也可看作一种保证,承诺开放的服务将保持稳定,不易做出变化。
对于进程内的开放主机服务,称为本地服务(对应 DDD 中的应用服务)。
对于进程间的开放主机服务,成为远程服务。根据所选择的分布式通信技术的差异,可以进一步定义出不同类型的远程服务:
-
面向服务行为,比如基于 RPC,称为提供者(Provider);
-
面向服务资源,比如基于 REST,称为资源(Resource);
-
面向事件,比如基于消息中间件,称为订阅者(Subscriber);
-
面向视图模型,比如基于 MVC,称为控制器(Controller);
总结来说,公开主机服务是一种服务规范,确保服务的稳定性,无论是进程内部还是进程之间,都可以根据不同的技术选择定义不同类型的远程服务,包括提供者、资源、订阅者和控制器。
6.3.3 发布语言
发布语言是一种公共语言,,用于在两个限界上下文之间进行模型转换。防腐层和开放主机服务都是在访问领域模型时设置的一层封装,前者针对发起调用的下游(通过基础设施层表现),后者针对响应请求的上游(通过应用层+远程服务),以防止上下游之间的通信集成各自的领域模型,导致彼此之间的强耦合。因此,防腐层和开放主机服务操作的对象都不应该是各自的领域模型,这正是引入发布语言的原因。(对于熟悉云 API 的小伙伴就会发现,其实云 API 根据我们定义的接口生成对应的 Request 对象和 Response 对象,并集成在云 API 的 SDK 中,这些对象就是发布语言)。
通常情况下,发布语言根据开放主机服务的服务契约进行定义。
说到这里,我们惊讶地发现防腐层,开放主机服务和发布语言可以完美联动!
总结来说,发布语言是一种在两个限界上下文之间进行模型转换的通用语言。防腐层和开放主机服务都是在访问领域模型时设置的一层封装,以防止上下游之间的通信集成各自的领域模型,导致彼此之间的紧密耦合。因此,引入了颁布语言,用于操作对象而不是领域模型。这些概念可以相互配合,实现更好的模型转换和通信。
6.3.4 共享内核
共享内核指将限界上下文中的领域模型直接暴露给其他限界上下文使用。注意,这会削弱了限界上下文边界的控制力。上面我们讲述的防腐层、开放主机服务以及发布语言无不传达一种思想,限界上下文不能直接暴露自己的领域模型或直接访问其他限界上下文的领域模型,一定要有隔离层!
但是,在特定的场景下,共享内核不见得不是一种合理的方式。任何软件设计决策都要考量成本与收益,只有收益高于成本,决策才是合理的。一般对于一些领域通用的值对象是相对稳定的,这些类型通常属于通用子领域,会被系统中几乎所有的限界上下文复用,那么这些领域模型就适合使用共享内核的方式。共享内核的收益不言而喻,而面临的风险则是共享的领域模型可能产生的变化。
总结来说,共享核心是指将限界上下文中的领域模型直接展示给其他限界上下文使用。虽然这会削弱限界上下文边界的控制力,但在特定情境下,这可能是合理的方法。任何软件设计决策都需要权衡成本和收益,只有在收益大于成本的情况下,决策才是合理的。对于一些领域通用的值对象,它们通常相对稳定,适合使用共享核心的方式。然而,共享核心也面临着共享领域模型可能产生的变化的风险。
6.3.5 合作者
合作关系指的是协作的限界上下文由不同的团队负责,且这些团队之间具有要么一起成功,要么一起失败的强耦合关系。合作者模式要求参与的团队一起做计划、一起提交代码、一起开发和部署,采用持续集成的方式保证两个限界上下文的集成度与一致性,避免因为其中一个团队的修改影响集成点的失败。
6.3.6 客户方/供应方
当一个限界上下文单向地为另一个限界上下文提供服务时,它们对应的团队就形成了客户方/供应方模式。这是最为常见的团队协作模式,客户方作为下游团队,供应方作为上游团队,二者协作的主要内容包括:
-
下游团队对上游团队提出的服务
-
上游团队提供的服务采用什么样的协议与调用方式
-
下游团队针对上游服务的测试策略
-
上游团队给下游团队承诺的交付日期
-
当上游服务的协议或调用方式发生变更时,如何控制变更
6.3.7 分离方式
分离方式的团队协作模式是指两个限界上下文之间没有一丁点关系。如果此时双方使用到了相似/相同的领域模型,则可以通过拷贝的方式解决,保证限界上下文之间的物理隔离!
6.3.8 遵奉者
当上游的限界上下文处于强势地位,且上游团队响应不积极时,我们可以采用遵奉者模式。即下游严格遵从上游团队的模型,以消除复杂的转换逻辑。
当下游团队选择“遵奉”于上游团队设计的模型时,意味着:
-
可以直接复用上游上下文的模型(好的);
-
减少了两个限界上下文之间模型的转换成本(好的);
-
使得下游限界上下文对上游产生了模型上的强依赖(坏的)。
6.3.9 大泥球
一定要避免制造大泥球!大泥球的特点:
-
越来越多的聚合因为不合理的关联和依赖导致交叉污染;
-
对大泥球的维护牵一发而动全身;
-
强调“个人英雄主义”,只有个别“超人”能够理清逻辑。
6.4 示例-SMS 的限界上下文及其映射
示例-SMS 的限界上下文可划分为:
-
成绩上下文
-
课程上下文
-
审批上下文
-
权限上下文
-
邮件上下文
上下文映射图如下所示。
7、领域建模阶段
领域建模阶段由领域分析建模,领域设计建模和领域实现建模组成。在正式讲解建模活动前,先了解一下什么是模型驱动设计。
7.1 模型驱动设计
模型是知识表达的一种方式,它通过筛选和有目的的结构化处理信息,有效应对信息过载的挑战。这种方法使人们能够抓住信息的关键含义,并集中精力于核心问题。
建模过程一般由分析活动、设计活动和实现活动组成。每一次建模活动都是一次对知识的提炼和转换,并产生相应的模型,即分析模型、设计模型和实现模型。
建模过程并非是分析、设计和实现单向的前后串行过程,而是相互影响,不断切换和递进的关系。模型驱动设计的建模过程是:分析中蕴含了设计,设计中夹带了实现,甚至实现后还要回溯到设计和分析的一种迭代的、螺旋上升的演进过程。
根据分解问题的视角不同,我们日常建立的模型可以大致分为以下三类:
-
数据模型:将问题空间抽取出来的概念视为数据信息,在求解过程中关注数据实体的样式和它们之间的关系,由此建立的模型就是数据模型。
-
服务模型:将每个问题视为目标系统为客户端提供的服务,在求解过程就会关注客户端发起的请求以及服务返回的响应,由此建立的模型就是服务模型。
-
领域模型:围绕问题空间的业务需求,在求解过程中力求提炼出表达领域知识的逻辑概念,由此建立的模型就是领域模型。
总结而言,模型驱动的设计是一个复杂的迭代过程,涉及分析、设计和实施的不断循环和深入。通过这种方式,我们可以更好地理解和解决问题,将知识转化为有效的模型,并在实际应用中发挥其价值。
7.1.1 领域模型驱动设计
一个出色的领域模型应该具备以下的特征(或者说,具备这些属性的就是领域模型):
-
运用统一语言来表达领域中的概念;
-
蕴含业务活动和规则等领域知识;
-
对领域知识进行适度的提炼和抽象;
-
由一个迭代的演进过程建立;
-
有助于产品、领域专家和开发同学进行交流。
领域建模阶段目的便是建立领域模型。领域模型由领域分析模型、领域设计模型以及领域实现模型共同组成,它们也分别是领域分析建模、领域设计建模和领域实现建模三个建模活动的产物。
需要强调的是,领域模型不是开发团队独立工作的成果,而是产品经理、领域专家和开发团队共同协作的产物。领域专家利用领域模型来评估系统对领域能力的支持,并根据模型来组织上层的业务能力;开发团队则依据领域模型来构建基础的代码框架(包括确定架构分层,定义每层的接口,接口的命名等)。同样,领域模型的任何调整都意味着领域知识或业务规则的变化,这也预示着系统支持的业务能力和代码实现必须相应地进行更新。
总结来说,领域模型在驱动设计中扮演着至关重要的角色。它不仅作为产品、领域专家和开发团队之间沟通的桥梁,还确保了系统设计和实现与业务需求的一致性。通过领域模型,我们能够确保设计的系统既符合业务的实际需求,又能够在技术层面上实现高效的功能实现。这种模型驱动的 approach 提供了一种结构化和迭代的方法,使得团队能够在面对复杂和不断变化的业务环境时,保持清晰的方向和高效的协作。
7.2 领域分析建模
领域分析建模:在限界上下文内,以“领域”为中心,提炼业务服务中的领域概念,确定领域概念之间的关系,最终形成领域分析模型。领域分析模型描述了各个限界上下文中的领域概念,以及领域概念之间的关系。
下面讲述如何通过“快速建模法”来构建领域分析模型。
7.2.1 名词建模
找到业务服务中的名词,在统一语言指导下将其映射为领域概念。
7.2.2 动词建模
识别动词并不是为领域模型对象分配职责、定义方法,而是将识别出来的动词当做一个领域行为,然后看它是否产生了影响管理、法律或财务的过程数据。如果存在影响,则将这些过程数据纳入领域分析模型作为领域概念。需要注意的是,此处的过程数据是指那些对企业运营和管理产生实质性影响的数据,比如示例-SMS 系统中老师提交修改申请,就会产生申请单这个过程数据,而请求流水记录和任务执行记录并不属于过程数据。动词建模通过分析领域行为是否产生过程数据来揭示潜在的领域概念,从而补充了名词建模的局限。
特别地,对于会产生领域事件的动词,一般可以抽象出一个已完成该动作的状态。
动词建模是领域驱动设计中的一个关键环节,它要求我们超越传统的名词中心视角,转而关注行为和动作。在这个过程中,我们不是简单地为模型对象分配职责或定义方法,而是将动词视为领域行为的表达,探查这些行为是否对企业运营的各个方面产生了实际影响。当这些行为确实引发了诸如申请单这样的过程数据时,我们就将这些数据作为领域概念纳入模型中,从而丰富了我们的领域分析。
7.2.3 提取隐式概念
除了“名词”和“动词”,概念中其他重要的类别也可以在模型中显式地表现出来,主要包括:约束和规格。
约束
约束通常是对领域概念的限定。我们可以将这些约束条件提取到独立的方法中,并通过方法名明确地表达这些约束的含义。比如示例-SMS 中关于 GPA 运算的约束。
有些时候,约束条件无法用单独一个方法来轻松表达,抑或约束条件中会使用到与对象职责无关的信息,那么我们就可以将其提取到一个显式的对象中。
规格(SPECIFICATION)
在很多情况下,业务规则不适合作为实体或值对象的职责,而且这些规则的变化和组合可能会掩盖领域对象的含义。如果将规则从领域层移除,那么领域代码就无法表达这些模型。此时,我们可以定义规格(谓词形式的显式值对象),它用于确定对象是否满足特定的标准。规格将规则保留在领域层,由于规格是一个完整对象,因此这种设计也能更清晰地反映模型。
规格一般有如下三种用法:
-
(验证)用于验证对象,检查它是否满足某些标准,例如在示例-SMS中,成绩实体在修改分数时需要通过规格判断当前是否满足修改的标准;
-
(选择)从集合中选择一个符合要求的对象,可以搭配资源库使用;
-
(根据要求来创建)指定在创建新对象时必须满足某种要求。
规格源自“谓词”概念,因此我们可以使用“AND”,“OR”和“NOT”等运算符对规格进行组合和修改。例如,在SMS系统中,教务员需要查询流程完成的申请单,我们可以通过使用“AND”来组合不同的规格来实现这一目标。
在领域驱动设计中,提取隐式概念是一个关键步骤,它确保了模型能够全面地反映领域的复杂性。通过将约束和规格这些通常隐含在业务逻辑中的概念显式化,我们不仅提高了模型的可读性和可维护性,而且保持了领域规则的一致性和完整性。约束通常是对领域概念的限制,而规格则是用于定义对象是否满足特定标准的规则集。规格的使用可以灵活地应用于验证、选择和创建对象的过程中,而且可以通过逻辑运算符进行组合和修改,以满足复杂的业务需求。通过这种方式,领域模型不仅变得更加精确,而且能够更好地适应业务需求的变化,从而提高了模型的实用性和价值。总之,提取隐式概念是确保领域模型全面性和准确性的重要环节,它使我们能够从更广泛的视角理解和分析企业领域的复杂性。
7.2.4 归纳抽象
对于有定语修饰的名词,要注意分辨它们是类型的差异,还是值的差异。如配送地址和家庭地址,订单状态和商品状态。如果是值的差异,类型相同,应归并为一个领域概念(如,配送地址和家庭地址);而类型不同,则不能合并(如,订单状态和商品状态)。
特别是,当名词的修饰语代表不同的限界上下文,且名词本身相同(即名称相同但含义不同的领域概念)时,我们应尽可能调整命名,以确保不同含义的领域概念有不同的名称,这样可以减少不必要的歧义和沟通误解。例如,在特定的限界上下文中,商品的订单和库存的订单都可以称为“order”,但如果我们把库存的订单改为“库存的配送单”(delivery),则可以更清晰地表达其含义。
归纳抽象是领域驱动设计中的一个重要环节,它要求我们仔细分辨名词修饰语所表示的是类型差异还是值差异。通过这种方式,我们可以准确地归纳和抽象领域概念,避免概念混淆和误解。同时,当修饰语表示不同限界上下文时,我们应调整命名以区分不同含义的领域概念,这有助于提高模型的可理解性和可维护性。总之,归纳抽象是确保领域模型清晰和准确的重要步骤,它有助于我们从更广泛的视角理解和分析企业领域的复杂性。
7.2.5 确认关系
在领域驱动设计中,我们需要根据业务需求和领域知识来判断领域概念之间是否相互关联。特别是对于1:N、N:1、M:N这三种关联关系,我们需要考虑是否可以定义一个新的类型来表征这些关系。例如,如果作品与读者之间存在1:N的关系,我们可以创建“订阅”这一概念来描述这种关联。
在确认关系时,应尽量避免在对象间建立双向关系,即对象A与对象B相关联,同时对象B也与对象A相关联。当两个对象存在双向关系时,会为管理他们的生命周期带来额外的复杂度。我们应该规定一个遍历方向,来表明一个方向的关联比另一个方向的关联更有意义且更重要,例如,在示例SMS系统中,成绩与课程之间存在关联(成绩实例包含课程ID),但课程不会与成绩关联。当然,如果双向关系本身是领域的一个核心概念,那么我们应当保留这种关系。
在领域驱动设计中,确认关系是一个关键步骤,它帮助我们根据业务需求和领域知识来确定领域概念之间的相互关联。对于不同类型的关联关系,我们需要判断是否可以定义新的类型来表征这些关系,以便更准确地描述领域中的交互。同时,我们需要避免在对象间建立双向关系,以减少生命周期管理的复杂性。通过定义明确的遍历方向,我们可以更好地理解和管理对象之间的关联。总之,确认关系是确保领域模型准确和全面的重要环节,它有助于我们从更广泛的视角理解和分析企业领域的复杂性。
7.2.6 示例-SMS 的领域分析模型
通过名词建模,动词建模和归纳抽象后,可提炼出以下领域对象:成绩(Result)、绩点(gpa)、总成绩(total result)、总绩点(total gpa)、学年(school year)、学期(semester)、课程(course)、学分(credit)、申请单(application receipt),邮件(mail),排名(rank),申请单状态(application receipt status)
这些领域对象之间的关系如下图所示。
7.3 领域设计建模
领域设计建模的核心工作就是设计聚合和设计服务,在这之前我们需要先了解一下设计要素(实体、值对象、聚合、工厂、资源库、领域服务、领域事件)。
7.3.1 设计要素
领域驱动设计强调以“领域”为核心驱动力。在构建领域模型时,应尽量规避技术实现的细节限制。然而,在许多情况下,我们仍需思考一些与技术无关的问题:
-
领域模型对象在身份上是否存在明确的差别?
-
领域模型对象的加载以及对象间的关系如何处理?
-
领域模型对象如何实现数据的持久化?
-
领域模型对象彼此之间如何做到弱依赖地完成状态的变更通知?
为了解答上述的四个问题,DDD 提供了很多的设计要素,它们能够帮助我们在不陷入到具体技术细节的情况下进行领域模型的设计。
领域驱动设计(DDD)是一种以领域为中心的设计方法,旨在帮助我们在构建领域模型时,避免陷入技术实现的细节限制。通过理解并运用DDD提供的设计元素,如实体、值对象、聚合、工厂、资源库、领域服务以及领域事件,我们可以在不关注具体技术实现的情况下,更好地设计领域模型。这种方法使我们能够更加专注于领域的本质,从而提高模型的灵活性和可维护性。
7.3.1.1 实体
实体的核心三要素:身份标识、属性和领域行为。
身份标识:身份标识的主要目的是管理实体的生命周期。身份标识可分为:通用类型和领域类型。通用类型 ID 没有业务含义;而领域类型 ID 则组装了业务逻辑,建议使用值对象作为领域类型 ID。
属性:实体的属性描述了主体的静态特征,并持有数据和状态。属性分为:原子属性和组合属性。组合属性可以是实体,也可以是值对象,这取决于该属性是否需要身份标识。我们应尽可能将实体的属性定义为组合属性,以便在实体内部形成各自的抽象层次。
领域行为:展示了实体的动态特征。实体具有的领域行为通常可以分为:
-
变更状态的领域行为:变更状态的领域行为体现的是实体/值对象内部的状态转移,对应的方法入参为期望变更的状态。(有入参,无出参);
-
自给自足的领域行为:自给自足意味着实体对象只操作了自己的属性,不外求于别的对象。(无入参);
-
互为协作的领域行为:需要调用者提供必要的信息。(有入参,有出参);
-
创建行为:这种行为代表了对象在内存中的从无到有。创建行为由构造函数实现,但在创建行为较为复杂或需要表达领域语义时,我们可以在实体中定义简单工厂方法,或使用专门的工厂类进行创建。(有返回值,且返回值为特定实体实例)。
实体是领域模型中的基本构建块,由标识、属性和领域行为三个核心要素组成。标识用于管理实体的生命周期,属性描述实体的静态特征,而领域行为展示实体的动态特征。通过合理地定义实体的属性和领域行为,我们可以更好地描述和处理领域中的问题和场景。这种方法有助于提高领域模型的灵活性和可维护性,从而更好地支持业务的发展
7.3.1.2 值对象
在决定一个领域概念应该使用值对象还是实体类型时,判断依据:
-
业务的参与者对它的相等判断是依据值还是依据身份标识;
-
确定对象的属性值是否会发生变化,若变化了,究竟是产生一个完全不同的对象,还是维持相同的身份标识;
-
生命周期的管理。值对象无需进行生命周期管理。
值对象具有不变性。一旦值对象被创建,其属性和状态就不应该再改变,如果需要更新值对象,就通过创建新的值对象来进行替换。
由于值对象的属性是在其创建的时候就完成传入的,那么值对象所具有的领域行为大部分情况下都是“自给自足的领域行为”,即入参为空。这些领域行为一般提供以下的能力。
-
自我验证:验证传入值对象的外部数据是否正确,一般在创建该值对象时进行验证。
-
自我组合:当值对象涉及到数值运算时,可以定义相同类型值对象的方法,使值对象具有自我组合能力。比如示例-SMS 中,在统计成绩时会涉及学分相加的运算,因此我们可以将相加运算定义为可组合的方法,便于调用者使用。
-
自我运算:根据业务规则对属性值进行运算的行为。
在进行领域设计建模时,应善于使用值对象而非内建类型来表达细粒度的领域概念。与内建类型相比,值对象的优势包括:
-
值对象在类型层面就可以表达领域概念,而不仅仅依赖命名;
-
值对象可以封装领域行为,进行自我验证,自我组合,自我运算。
值对象在领域驱动设计中扮演着重要的角色,它们用于表示那些不应该随着时间改变的状态,并且可以提供自我验证、组合和运算的能力。值对象的不变性确保了领域模型的一致性和可预测性,同时它们可以封装领域逻辑,使得领域模型更加丰富和表达性强。在设计领域模型时,我们应该优先考虑值对象,因为它们能够更好地反映出业务领域的本质特征,并且在类型层面提供了对领域概念的直接表达。
7.3.1.3 聚合
聚合的基本特征:
-
聚合是包含了实体和值对象的一个边界。
-
聚合内部的实体和值对象构成一棵树状结构,其中只有实体可以作为树的根节点。
-
外部对象只允许持有聚合根的引用,以起到边界控制作用。
-
聚合作为一个完整的领域概念整体,其内部会维护这个领域概念的完整性。
-
聚合根负责对外提供执行该领域概念职责的行为方法,并协调内部各个对象之间的行为。
聚合是领域驱动设计中的一个关键概念,它通过实体和值对象的集合来定义一个界限,确保领域概念的完整性和一致性。聚合内的实体作为树状结构的根节点,而外部对象只能通过聚合根来访问,这样维护了聚合内部对象之间的协作和外部对象与聚合之间的清晰界限。
7.3.1.4 工厂
在聚合中,工厂的概念指的是任何封装了聚合对象创建逻辑的类或方法。工厂的具体表现形式有:
-
引入专门的聚合工厂(尤其适合需要通过访问外部资源来完成创建的复杂创建逻辑)
-
聚合自身担任工厂(简单工厂模式)
-
服务契约对象或装配器(assembler)担任工厂(负责将外部请求对象 DTO 转换为实体)
-
使用构建者组装聚合
注意!这里工厂创建的基本单元是聚合,而非实体,注意与实体中的创建行为区分。
工厂在领域驱动设计中扮演着至关重要的角色,它负责创建和组装聚合对象,确保了聚合的实例化和完整性。无论是通过引入专门的工厂类,还是让聚合自身、服务契约对象或装配器来承担工厂的角色,甚至是使用构建者模式,目的都是将外部请求转换为内部的实体和值对象,从而构建出一个符合领域模型要求的聚合。在这个过程中,聚合被视作创建的基本单元,与实体的创建行为有着本质的区别,这有助于维持领域模型的一致性和清晰性。
7.3.1.5 资源库
资源库是对数据访问的一种业务抽象,用于解耦领域层与外部环境,使领域层变得更为纯粹。资源库可以代表任何可以获取资源的仓库,例如网络或其他硬件环境,而不局限于数据库。
一个聚合对应一个资源库。领域驱动设计引入资源库,主要目的是管理聚合的生命周期。资源库负责聚合记录的查询与状态变更,即“增删改查”操作。资源库分离了聚合的领域行为和持久化行为,保证了领域模型对象的业务纯粹性。
需要注意的是,资源库的操作单元是聚合。在定义资源库的接口时,接口的参数应该是聚合的根实体。如果需要访问聚合内的非根实体,也只能通过资源库获取整个聚合,然后以根实体作为入口,在内存中访问聚合边界内的非根实体对象。
资源库与数据访问对象(DAO)的区别:
根本区别在于,数据访问对象在访问数据时,并无聚合的概念,也就是没有定义聚合的边界约束领域模型对象,使得数据访问对象的操作粒度可以针对领域层的任何模型对象。数据访问对象(DAO)可以自由地操作实体和值对象。没有聚合边界控制的数据访问,会在不经意间破坏领域概念的完整性,突破聚合不变量的约束,也无法保证聚合对象的独立访问与内部数据的一致性。
其次,资源库是基于领域模型对存储系统进行的抽象,因此资源库中的方法命名可以表达领域概念;而数据访问对象(DAO)是存储系统对外暴露的抽象,其方法命名更贴合数据库本身的操作。
资源库在领域驱动设计中起到了桥梁的作用,它连接了领域层和外部环境,确保了领域层的独立性和纯粹性。通过将聚合的生命周期管理和持久化操作从领域行为中分离出来,资源库维护了领域模型的一致性和完整性。与之相比,数据访问对象(DAO)则是一个更为底层的数据操作抽象,它不依赖于聚合的概念,操作粒度更细,但这也使得它在保护领域模型完整性方面不如资源库。因此,在选择数据访问策略时,应当根据领域模型的复杂性和保护需求来决定是使用资源库还是数据访问对象。
7.3.1.6 领域服务
聚合通过聚合根的领域行为对外提供服务,而领域服务则是对聚合根的领域行为的补充。因此,我们应该尽量优先通过聚合根的领域行为来满足业务服务。
那什么场景下我们会需要用到领域服务呢?有如下两个:
-
生命周期管理。为了防止领域知识的泄露,应用服务不应直接引用与聚合生命周期相关的服务(如工厂、资源库接口)。由于聚合根实体通常不依赖于资源库接口,在这种情况下,就需要领域服务来组合并对外提供。
-
依赖外部资源。为了保证聚合的稳定性,聚合根实体不会依赖防腐层接口。因此,当聚合需要对外提供的服务涉及外部资源的访问时,就需要通过领域服务来实现。
领域服务在领域驱动设计中扮演着重要的角色,它不仅补充了聚合根的领域行为,而且在特定情况下,如生命周期管理和依赖外部资源时,它还提供了必要的封装和组合功能。这有助于保持领域模型的一致性和稳定性,同时防止领域知识的泄露。通过领域服务,我们能够确保聚合根实体不依赖于资源库接口和防腐层接口,从而使得聚合更加纯粹和专注于业务逻辑。领域服务的使用是在特定场景下的权衡选择,它提供了一种平衡业务需求和技术约束的方法。
7.3.1.7 领域事件
领域事件属于领域层的领域模型对象,由限界上下文中的聚合发布,感兴趣的聚合(同一限界上下文/不同限界上下文)可以进行消费。而当一个事件由应用层发布,则该事件为应用事件。
领域事件的引入主要是为了更有效地追踪实体状态的改变,并且在状态改变时,通过事件消息的传递来实现领域模型对象之间的协同工作。
领域事件的特征:
-
领域事件代表了领域的概念;
-
领域事件是已经发生的事实(表示事件的名称应该是过去时,比如 Committed);
-
领域事件是不可变的领域对象;
-
领域事件会基于某个条件而触发。
领域事件的用途:
-
发布状态变更;
-
发布业务流程中的阶段性成果;
-
异步通信。
领域事件应该包含:
-
身份标识,即事件 ID,为通用类型的身份标识;
-
事件发生的时间戳,便于记录和跟踪;
-
属性需要针对订阅者的需求,在增强事件和反向查询之间进行权衡。增强事件意味着属性中包含订阅者所需的所有数据;而反向查询则是属性包含事件 ID,订阅者可以通过事件 ID 进行数据的反向查询。
领域事件在领域驱动设计中扮演着至关重要的角色,它不仅允许实体状态的变更得到有效追踪,还通过事件驱动的消息传递机制,促进了领域模型对象之间的解耦和协同。领域事件的特征和用途共同确保了业务逻辑的一致性和可追溯性,同时也为异步处理和分布式系统提供了坚实的基础。通过精心设计领域事件,我们可以在不违反封装原则的前提下,实现领域内部状态变更的透明通知和外部系统的响应。这种设计使得领域模型更加健壮,能够适应复杂业务流程的变化和挑战。
7.3.2 设计聚合
在领域设计模型中,聚合是最小的设计单元。
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. 调整聚合边界
根据业务规则调整聚合边界。为了维护业务规则的不变性,相关的实体应该置于同一个聚合边界内。
领域模型的设计需要通过整理对象图,确定聚合边界,并根据业务规则调整聚合边界。在确定聚合边界时,需要考虑对象间的关系强度,包括泛化关系、关联关系和依赖关系。泛化关系可以是整体视角或独立视角,关联关系要求生命周期一致且共存亡,依赖关系主要体现在职责委派和创建行为。通过这些步骤,我们可以设计出合理且有效的领域模型。
7.3.3 设计服务
这里的服务是对应用服务、领域服务、领域行为(实体提供的方法)和端口(资源库接口、防腐层接口)的统称。
7.3.3.1 分解任务
业务服务包含若干个组合服务,组合服务包含若干个原子服务。领域行为和端口都可以认为是原子服务。
7.3.3.2 分配职责
应用服务:匹配业务服务,提供满足业务需求的服务接口。应用服务本身不包含任何领域逻辑,其作用是协调领域模型对象,通过它们的领域能力实现完整的应用目标。
领域服务:匹配组合服务,负责执行业务功能。若原子任务为无状态行为或独立变化的行为,也可由领域服务承担。它控制多个聚合与端口之间的协作,并负责组合任务的执行。
领域行为:匹配原子服务,提供业务功能的业务实现。强调无状态和独立变化,由实体提供。
端口:匹配原子服务,抽象对外资源的访问,主要的端口包括资源库接口和防腐层接口。
虽然上述给出了应用服务、领域服务、领域行为和端口与业务服务、组合服务和原子服务的匹配关系,但是对于应用服务、领域服务、领域行为和端口之间的关联关系却还不清晰,这里结合书中内容和个人实践给出一个参考。
应用服务:核心职责是编排聚合间的领域服务。
领域服务
防腐层接口:当多聚合间领域服务进行协作后需要访问外部资源,此时相关的防腐层逻辑应该至于应用层。(防腐层是上下文映射的方式,并非领域模型特有)
工厂:特指服务契约对象或装配器担任工厂,即将DTO转换为实体的工厂。
领域行为:在上述工厂创建实体后,若只需要调用实体的领域行为,而不需要涉及生命周期管理,可直接在应用服务中进行调用。
领域服务:细粒度的领域对象可能会将领域层的知识泄露到应用层,导致应用层需要处理复杂且细致的交互,从而使领域知识扩散到应用层或用户界面代码中,而领域层则可能丢失这些知识。明智地引入领域层服务有助于在应用层和领域层之间保持明确的界限,因此应用层通常不会直接引用聚合的领域行为。
工厂
领域行为
防腐层接口:聚合内需要依赖外部资源,则将防腐逻辑收拢在领域服务中。
资源库接口
领域行为:不要关联资源库和防腐层接口。
总结而言,服务设计是领域模型中的一个关键环节,涉及应用服务、领域服务、领域行为和端口的概念。应用服务作为协调者,领域服务负责执行,领域行为提供业务实现,而端口则是外部资源的抽象。这些服务的正确分配和关联对于保持领域模型的清晰和高效至关重要。
7.3.4 示例-SMS 的领域设计模型
聚合设计:
服务设计:
下面只罗列非查询类的服务设计。
成绩上下文:
7.4 领域实现建模
领域实现建模关注的并非是如何进行代码实现,而是如何验证代码实现的正确性,保证实现的高质量。
7.4.1 领域模型与测试金字塔
领域模型中的服务包括了应用服务、领域服务、领域行为和端口。其中通过 Provider(面向服务行为)、Resource(面向服务资源)、Subscriber(面向事件)、Controller(面向视图模型)对外进行暴露的,我们称为远程服务。
领域模型中的服务与测试金字塔的关系如下图所示。
7.4.2 测试驱动开发
领域实现建模提倡的是测试驱动开发的编程思想,即要求开发者在进行逻辑实现前,优先进行测试用例的编写,站在调用者角度而非实现者角度去思考接口。
在上述测试金字塔中,开发者需要关注的是单元测试(不依赖任何外部资源的测试就是单元测试)。在领域设计建模阶段,我们对业务服务/应用服务进行分解,定义出了领域行为和领域服务。对于领域行为,由于其不依赖外部资源,因此我们可以直接编写单元测试;而对于领域服务,其可能会通过端口访问外部资源,此时我们需要对端口进行 mock,以隔离外部资源对领域逻辑验证的干扰。特别地,单元测试一定要覆盖所有对业务规则的验证,这是保证领域行为和领域服务正确性的基础。
单元测试编码规范:
-
测试类的命名应与被测试类保持一致,为“被测类名称+Test 后缀”。
-
测试方法表达业务或业务规则为目的。
-
测试方法体遵循 Given-When-Then 模式。Given: 为要测试的方法提供准备,包括创建被测试对象,为调用方法准备输入参数实参等;When: 调用被测试的方法,遵循单一职责原则,在一个测试方法的 When 部分,应该只有一条语句对被测方法进行调用;Then: 对被测方法调用后的结果进行预期验证。
总结来说,测试驱动开发是领域实现建模中的一种重要编程思想,它要求开发者首先编写测试用例,从调用者的角度思考接口,并确保单元测试覆盖所有业务规则的验证。这种开发模式有助于提高代码质量,减少bug,并确保领域行为和服务的正确性。
8、分层架构与代码骨架
8.1 分层架构
代码架构分层是经典 DDD 四层:用户接口层,应用层,领域层和基础设施层。
需要注意的的地方是:
-
用户接口层根据通信方式的不同,区分开了 Provider(面向服务行为)、Subscriber(面向事件)、Controller(面向视图模型&资源) 、Task(面向策略/定时任务)。
-
基础设施层单独划分了 infranstructure-impl 模块。为了保证领域层的纯洁性,DDD 通过依赖倒置把访问外部系统(数据库,第三方系统)的服务的实现都下放到了基础设施层,而 infranstructure-impl 模块 则是对这些实现进行了归集。这样做的好处有两个:第一,依赖关系明确,(infransturcture-impl —> domain,application), (interface、application、domain —> infranstructure);第二,拆分服务更便捷。当我们需要部分领域独立拆分出来的时候,在实现层面就只需要关注 infransturcture-impl 模块 即可。
-
Infranstructure-impl 模块依赖应用层的原因是应用层可能会抽象出防腐层接口,需要 infranstruct-impl 为其提供实现。
总结而言,分层架构是领域驱动设计中的核心概念,它通过将代码分为用户接口层、应用层、领域层和基础设施层,帮助我们清晰地定义各个层次的责任和依赖关系。这种架构风格不仅提高了代码的可维护性和可扩展性,而且通过依赖倒置和基础设施层的独立,保证了领域层的纯净和服务的灵活拆分。这种设计模式有助于团队更好地管理和维护复杂的业务系统。
8.2 代码骨架
8.2.1 用户接口层
用户接口层的核心职能:协议转换和适配、鉴权、参数校验和异常处理。
├── controller //面向视图模型&资源
│ ├── ResultController.java
│ ├── assembler // 装配器,将VO转换为DTO
│ │ └── ResultAssembler.java
│ └── vo // VO(View Object)对象
│ ├── EnterResultRequest.java
│ └── ResponseVO.java
├── provider // 面向服务行为
├── subscriber // 面向事件
└── task // 面向策略
└── TotalResultTask.java
8.2.2 应用层
应用层的核心职能:编排领域服务、事务管理、发布应用事件。
├── 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 // 防腐层适配器接口
8.2.3 领域层
代码组织以聚合为基本单元。
├── result // 成绩聚合
│ ├── entity // 成绩聚合内的实体
│ │ └── Result.java
│ ├── service // 领域服务
│ │ ├── ResultDomainService.java
│ │ ├── event // 领域事件
│ │ ├── adapter // 防腐层适配器接口
│ │ ├── factory // 工厂
│ │ └── repository // 资源库
│ │ └── ResultRepository.java
│ └── valueobject // 成绩聚合的值对象
│ ├── GPA.java
│ ├── ResultUK.java
│ ├── SchoolYear.java
│ └── Semester.java
└── totalresult // 总成绩聚合
├── ... 这段有点长,其代码结构与成绩聚合一致,因此省略 ...
8.2.4 基础设施实现层
该层主要提供领域层接口(资源库、防腐层接口)和应用层接口(防腐层接口)的实现。
代码组织基本以聚合为基本单元。对于应用层的防腐层接口,则直接以 application 作为包名组织。
├── application // 应用层相关实现
│ └── adapter // 防腐层适配器接口实现
│ ├── facade // 外观接口
│ └── translator // 转换器,DO -> DTO
├── result // 成绩聚合相关实现
│ ├── adapter
│ │ ├── facade
│ │ └── translator
│ └── repository // 成绩聚合资源库接口实现
│ └── ResultRepositoryImpl.java
└── totalresult // 总成绩聚合相关实现
├── adapter
│ ├── CourseAdapterImpl.java
│ ├── facade
│ └── translator
└── repository
└── TotalResultRepositoryImpl.java
9、杂谈
9.1 DDD 与微服务
微服务拆解指的是将一个整体服务分割成多个细粒度的服务。这里的“细粒度”并没有一个客观的标准,它依赖于主观判断。尽管如此,我们对“微”这个词还是有一些基本要求的:服务应当足够内聚,足够独立,足够完备,这样的微服务才能使得拆分的收益大于成本。如果一个微服务提供的业务功能需要与其他众多微服务协作,那么这样的微服务就失去了意义。
而上述我们对微服务的基本要求,实际上与限界上下文的特征(最小完备,自我履行,稳定空间,独立进化)不谋而合,因此,我们可以把限界上下文映射为微服务。在日常工作实践中,我通常会将限界上下文和微服务一一对应,但这并不是绝对的规则。限界上下文是从领域角度定义的逻辑边界,而微服务的设计除了考虑逻辑边界外,还需要考虑物理边界以及实际的质量要求(如性能、可用性、安全性等)。例如,在使用CQRS架构时,领域模型会被分为命令模型和查询模型,尽管它们属于同一个限界上下文,但它们通常是物理隔离的。因此,限界上下文可以作为微服务拆分的指导原则,但在拆分过程中还需要考虑质量要求、架构设计等技术因素。
总结来说,微服务拆分是一种将整体服务细分为多个独立、内聚和完备的小服务的架构风格。它与领域驱动设计中的限界上下文概念相吻合,但在实际应用中,微服务的设计还需考虑物理边界和质量要求。微服务的目标是提高系统的可维护性和可扩展性,同时保持服务的独立性和内聚性,以便更好地应对复杂的业务需求。
9.2 事务
9.2.1 本地事务
上文在提及限界上下文识别和聚合设计的时候其实都提到需要考虑事务属性,即需要通过本地事务来保证业务规则的不变性/一致性。这里我们会疑惑的是:谁来承担管理事务的职责?事务管理的边界是什么?
应用层承担管理事务的职责
事务本质是一种技术手段,而领域模型本身与技术无关,因此事务应该由应用层负责管理。
事务管理的边界是聚合,有时限界上下文也可以
资源库操作的基本单元是聚合,因此事务管理的边界是聚合便是自然而然得出的结论。这里需要考虑的是当需要保证事务属性的不仅仅只有资源库操作,还包括发布领域事件时(即保证聚合落库和事件发布的原子性),我们可能需要采用可靠事件模式,即通过将领域事件落库到事件表来表示事件的发布。这样,应用层在管理事务时就无需承担太多的心智负担。当然,采用可靠事件模式实际上是限制了领域模型的实现,这可以看作是技术对领域模型的一种入侵。但相比于解放应用层而言,这种入侵应该是利大于弊。
我们也知道,应用层的核心职责是负责编排和协调不同聚合的领域服务,而应用层又负责事务管理,因此我们可以推断事务管理的范围是多个聚合(即限界上下文)。但这里有两个关注点:
a)一般是出于质量需求(性能会好一些,时效性更高一些);
b)同一个限界上下文内的多个聚合共享一个 DB。
总结来说,本地事务在领域驱动设计中扮演着重要角色,它们用于确保业务规则的一致性和不变性。应用层负责管理事务,而事务管理的范围通常是一个聚合,但在某些情况下,也可以是一个限界上下文。这种设计允许应用层专注于业务逻辑,而无需担心底层技术细节。同时,通过可靠事件模式,我们可以确保领域事件的发布与聚合的落库具有原子性。虽然这种模式可能会对领域模型的实现造成一定的限制,但它为应用层提供了更大的便利,使得事务管理变得更加简单和高效。
9.2.2 Saga 事务
为了避免耦合,DDD 主张通过柔性事务来保证跨聚合、跨限界上下文的最终一致性。而目前业界比较主流的应用是 Saga 模式:通过使用异步消息来协调一系列本地事务,从而维度多个服务之间的数据一致性。而另一个非常著名的柔性事务方案 TCC 为啥没有 Saga 契合呢?
TCC 共分为三个阶段:
-
Try 阶段:准备阶段,对资源进行锁定或预留;
-
Confirm 阶段:提交阶段,执行实际的操作;
-
Cancel 阶段:这是补偿阶段,如果在前面阶段发生错误,需要进行补偿,即释放在 Try 阶段预留的资源。
可以看到 TCC 实际对领域模型的侵入是比较大的:
a)TCC 要求领域模型设计时,定义相关的属性以支持资源锁定/预留的问题;
b)TCC 对服务接口定义做出了要求,领域模型需要提供 Try,Confirm 和 Cancel 相应的领域服务。
Saga 模式并不要求其对资源进行锁定/预留,而其补偿操作也是通过执行操作的逆操作来完成(比如支付的逆操作是退款)。在大多数情况下,完整的领域模型都会提供操作及其逆操作。
总结而言, Saga 事务是一种在领域驱动设计中用于保证跨服务数据一致性的柔性事务模式。它通过异步消息和本地事务的协调,避免了服务的直接耦合,同时也不需要对资源进行复杂的锁定或预留。相比之下,TCC 模式虽然也是一种柔性事务方案,但它对领域模型的设计有较大的影响,要求模型提供特定的服务接口和属性以支持事务的各个阶段。Saga 模式因其对领域模型的较小侵入和操作的直观性,在某些场景下可能更受欢迎。然而,选择哪种事务模式应该基于具体的应用场景和业务需求。
10、参考
-
《解耦-领域驱动设计》
-
《领域驱动设计:软件核心复杂性应对之道》
-
《实现领域驱动设计》
-
《微服务架构设计模式》
-
极客时间《DDD 实战课》
-
极客时间《如何落地业务建模》
-
《领域驱动设计精粹》