面向流的架构——使用战略性领域驱动设计设计解决方案空间

0 阅读47分钟

发现子域属于战略设计的问题空间的一部分。本章将转入战略设计的解决方案空间。正是在这里,会做出软件设计决策,将软件系统分解并设计为模块化组件——也就是限界上下文(bounded contexts)。正如 Evans 所强调的那样:“在理想情况下,一个子域与一个限界上下文是重合的”[3.1]。然而在实践中,这种对齐往往难以完全实现,尤其是在那些随着时间演化而来的系统中,例如遗留系统。

什么是领域模型与限界上下文?

限界上下文将与某个特定子域相关的业务行为聚合在一起。它定义了单一领域模型可以适用的边界,并构成一个关于目标、精通与自主性的单元。下面的各节会先讨论领域模型,再进入限界上下文的细节。

领域模型

领域模型位于领域驱动设计的中心,它表达的是系统某一部分中与该领域相关的领域逻辑业务规则(图 3.1)。领域模型并不是对现实世界的 1:1 映射;相反,它是一组为解决相关子域问题而做出的抽象。

image.png

图 3.1 领域模型

可以使用多种建模技术(另见本章后文“建模技术概览”一节)来协作式地探索复杂领域。领域模型会被持续地设计与打磨,并且可以被直接实现为代码(见第 4 章“使用战术性领域驱动设计实现领域模型”)。领域模型是用一个限界上下文内所有团队成员共同使用的统一语言来表达的。

统一语言

领域模型以统一语言来表达,并且不带任何技术术语,它反映的是领域专家软件开发者在某一个限界上下文内的共享理解。之所以称为“统一”的语言,是因为它会广泛出现在该限界上下文内的对话、文档、用户故事、测试、代码等一切地方。正如 Evans 所说:“有了统一语言,模型就不再只是一个设计产物。它会融入开发者与领域专家共同做的一切事情之中”[3.3]。

不同类型的边界

没有边界,领域模型就无法存在——这正是限界上下文发挥作用的地方。限界上下文为领域模型形成了一层边界。它可以为领域模型提供不同类型的边界,例如物理边界所有权边界(图 3.2)。

image.png

图 3.2 限界上下文及其不同类型的边界

在其报告《What Is DDD? 》[3.4] 以及著作《Learning Domain-Driven Design》[3.5] 中,Vladik Khononov 描述了限界上下文可能具备的边界类型。根据 Khononov 的说法,限界上下文可以形成如下几类边界:

  • 限界上下文形成一种语言与语义边界,从而使领域模型中的术语和含义在其限界上下文内部保持一致且清晰。
  • 它也是一种所有权边界。一个限界上下文应当只由一个团队来实现、演进和维护;不过,一个团队可以拥有多个限界上下文。
  • 限界上下文也是一种物理边界,可以被实现为独立的解决方案,并拥有自己的工件。它也可以位于自己独立的代码仓库中,并拥有自己的生产交付路径。
  • 并不是所有限界上下文都必须共享相同的架构风格。它们完全可以在不同限界上下文之间各不相同。
  • 此外,业务逻辑的实现方式也可以因上下文而异。

当然,“bounded context”这个名称中的“context”一词本身就很关键。它提醒我们去关注这样一个特定业务语境:在这个语境中,领域模型的抽象、表达、逻辑和规则才是适用的,并且具有明确的意义与目的。

设计领域模型与限界上下文

如前所述,DDD 的一个关键部分是领域专家软件开发者之间的协作。围绕“当用户需求被满足时,预期结果应当是什么”展开讨论,可以启动领域专家与软件开发者之间关于领域模型与限界上下文的对话。

这些模型之间彼此是如何关联的?模型中的术语与含义是否保持一致且清晰,还是在建模过程中产生了歧义,从而需要调整统一语言?有多种技术可以用来推导与设计限界上下文和领域模型,例如 EventStorming领域故事讲述(domain storytelling)、示例映射(example mapping)以及用户故事映射(user story mapping);这些技术会在本章后文中展开讨论。

此前识别出的子域类型(见第 2 章“使用战略性领域驱动设计与 Wardley Mapping 探索问题空间”中“发现子域并映射其演进阶段”一节)可以帮助确定应优先处理哪些用户需求。从核心域开始,可能是一个合乎逻辑的做法,因为它们代表了问题领域中最值得进行战略投资的部分,原因在于它们在业务关键型竞争差异化方面发挥着核心作用。

设计领域模型与限界上下文并不是一项简单的任务;第一次没有做对,完全没关系。这个过程本身就涉及在开发者与领域专家的协作过程中不断获取领域知识。当然,正如一句常见的格言所说:“所有模型都是错的,但有些模型是有用的”[3.2]。所有模型——包括领域模型及其限界上下文——都无法精确呈现现实,但它们在自己的抽象语境下仍然可能非常有价值。

事实上,除非一个软件模型所建模的概念本身就起源于软件,否则这个模型不可能在现实意义上“完全正确”。例如,软件只能对物理事物进行一种粗略近似的建模。理解这一点,有助于我们为模型的目标建立正确预期,也能排除那些不切实际的期待。

让我们为第 1 章中介绍的会议活动策划解决方案推导其领域模型与限界上下文。开始时,先考虑来自核心域的用户需求。领域模型在通过满足用户需求来实现预期结果方面起着重要作用。用于管理征文征稿的底层领域模型,可能是一个 Call for Paper(CfP)领域模型,它由多种属性(例如开始日期、结束日期、活动名称、描述)以及被强制执行的业务规则——也就是不变式(invariants)——构成。不变式体现了一种事务上一致的状态,并维护领域模型的完整性(图 3.3)。

image.png

图 3.3 领域模型含义的歧义性

在 CfP 期间提交议题提案,会引出一个 Session 领域模型,它会关联到 SpeakerCfP 领域模型。在为其他用户需求(评审日程消息账户)推导领域模型时,很快就会发现,Session 和 Speaker 这两个领域模型会与多个领域模型相关联。然而,讲者在 CfP 期间提交的议题提案,与会议组织者排入议程中的那个议题,在属性和业务规则上其实是不同的。时间槽房间对于议程或日程是相关的,并且一个议题不能被重复排期。房间和时间槽信息——以及“一个议题只能被安排一次”这一规则——在构建日程时很重要,但在议题提交与评审阶段则并不相关。

为了保持领域模型的一致性与完整性,就需要调整统一语言。这会导致把原本含义模糊的 Session 领域模型,分别重命名为 Submitted SessionScheduled SessionEvaluated Session 领域模型。对统一语言进行打磨,本身就是一个强有力的提醒:必须为这些领域模型划定边界——也就是建立限界上下文——以保持这些模型的含义一致且清晰(图 3.4)。

image.png

图 3.4 作为语言与语义边界而围绕领域模型建立的限界上下文

这些限界上下文为领域模型形成了语言与语义边界,并保护它们的完整性。在限界上下文内部,领域模型的含义是明确的,因为上下文本身已经被显式给出。例如,在“Session Evaluation”这个限界上下文内部,其封闭的领域模型就不再需要额外加上“Evaluated Session”这个前缀,而可以重新命名回“Session”,因为它所属的限界上下文已经把含义说清楚了——它处理的就是被评审过的议题。

限界上下文把更大的问题领域拆分成更小的模块化组件,并落实了 Wardley 教义中的“从小处着手(例如合约) ”这一原则。请记住,在问题空间协作中发现的子域,至少会给出非常强的信号,提示我们哪些地方是合适的上下文边界。

建模技术概览

有多种建模技术可用于帮助探索和建模业务领域。它们并不是彼此排斥的,而是可以根据需要任意组合使用。本节会简要介绍其中一些可用的建模技术,但并不打算给出一个穷尽性的列表。1

  1. 若要获取更完整的协作式建模技术、模板与引导指南集合,也可参见社区驱动的 DDD Crew 仓库:https://github.com/ddd-crew

EventStorming

EventStorming 由 Alberto Brandolini 发明,是一种轻量级、协作式的业务领域探索技术,它基于一系列用不同颜色编码的便签来开展 [3.6]。了解该领域和/或希望解答某些问题的技术人员与非技术人员会聚在一起,彼此学习。EventStorming 提供三种形式:Big PictureProcess ModelingSoftware Design

Big Picture EventStorming

Big Picture EventStorming 关注的是探索业务领域:它首先会沿着一条时间线识别出业务领域中已经发生的一系列领域事件。图 3.5 展示了这些领域事件。

在图 3.5 中,使用了抽象的领域事件以及其他建模元素,以传达这一协作建模工具的基本思想。领域事件被表达为那些在过去已经发生的、值得关注的事实——例如 OrderPlaced。参与者把领域事件写在橙色便签上,并按时间顺序贴到墙上。那些许多人都关心的重要领域事件,会被突出显示为关键领域事件(pivotal domain events)。

image.png

图 3.5 Big Picture EventStorming

除了领域事件之外,还会把参与某一组领域事件的参与者放在浅黄色便签上,并贴在对应那组领域事件的旁边。参与者可以是某个具体的人、一群人、某种用户画像、某种角色等等。如果领域事件是由外部系统产生的——例如邮件系统、SAP 系统,或另一个子域——则会用浅粉色便签记录下来。

如果参与者在 EventStorming 过程中遇到了问题、疑问、冲突、风险或分歧,他们可以把这些内容写在深粉色便签上贴到墙上,以将这些热点可视化。之后可以再专门处理这些热点。

正如 Brandolini 所建议的,在被分配给某些参与者的一系列领域事件下方画出横向的泳道,有助于为整个流程提供结构。泳道与关键事件结合起来,会形成一种结构,用于可视化潜在的系统边界,后面的 Big Picture EventStorming 示例就展示了这一点。用户旅程、流程或子流程的起点与终点;参与者之间的交接;或者实体名称的变化,也都可能表明系统边界的存在。

图 3.6 展示了如何以全景方式对会议活动策划器进行 storming。领域事件的时间顺序展示了从会议组织者和讲者两个视角出发,会议活动策划领域中正在发生什么。

image.png

图 3.6 会议活动策划器示例的 Big Picture EventStorming

横向泳道(图 3.6 中的红色水平线)和关键事件,在领域事件流之上形成了一种涌现式结构。这种涌现出来的结构,可以帮助识别限界上下文的潜在候选项,如图 3.7 所示。

image.png

图 3.7 从 EventStorming 中得到的限界上下文候选项

接下来可以继续进入 Process Modeling EventStormingSoftware Design EventStorming

Process Modeling EventStorming

流程建模会聚焦到业务领域中的某一个具体部分,并专注于协作式地设计一个从开始到结束的流程(图 3.8)。在流程建模中,会引入命令(蓝色、锯齿边便签)、读模型(绿色、条纹便签)以及策略(紫色、螺旋便签)。

image.png

图 3.8 Process Modeling EventStorming

命令表达的是某件事情应当在未来发生的意图,但它也可能被拒绝。命令会导致领域事件发生,并且可以由参与者或策略发出。策略描述的是系统应当如何响应,例如:“每当 X 已经发生(事件),就执行 Y(命令) 。”读模型则记录参与者做决策时所需要的信息。读模型代表了系统在某一时刻的状态。领域事件可以触发策略,也可以形成读模型。

Software Design EventStorming

Software Design EventStorming 支持协作式地设计一个事件驱动的软件系统。这种软件设计形式引入了聚合(普通的浅黄色便签),它代表一组一致性的业务规则。正如图 3.9 所示,聚合接收命令,并发出相应的领域事件。

image.png

图 3.9 包含通用 EventStorming 规则的 Software Design EventStorming

总结一下这些规则:策略或从读模型中获取信息的参与者,可以发出命令。命令可以被调用在聚合或外部系统之上。聚合和外部系统反过来又会生成领域事件,而领域事件可以进一步转化为读模型,或者触发策略。

第 9 章“为流动而设计遗留系统”会基于一个示例,演示不同形式的 EventStorming。

Domain Storytelling

和 EventStorming 一样,领域故事讲述(domain storytelling)也是一种协作式的知识提炼技术,用于探索领域中发生了什么 [3.7]。它聚焦于从某个参与者视角来讲述一个领域故事,如图 3.10 所示。参与者可以是某个具体的人、一群人,或一个系统。领域故事讲述有助于识别:哪些参与者需要对哪些工作对象执行怎样的活动序列,并且沿着一条时间线,可能会产生或消费哪些领域事件。工作对象可以是文档、对话、消息等等。

image.png

图 3.10 一个高层领域故事示例:描述一个观众去电影院观影的过程(图片由 Henning Schwentner 和 Stefan Hofer 提供)

Example Mapping

另一种用于探索问题领域的轻量级协作技术是 示例映射(example mapping)[3.8]。示例映射为细化描述某一行为规格的用户故事的验收标准,提供了一种结构化方式。它首先通过协作,把用户故事写在黄色索引卡上,如图 3.11 所示。接着,把每个用户故事的验收标准或规则记录在蓝色(锯齿边)卡片上。然后再用一张或多张绿色(条纹)卡片,为每条规则补充并说明具体示例。比如,对于“密码必须至少包含一个特殊字符”这样的规则,一个示例可以写成:“给定密码是 myPassword%,则该密码有效。”如果在示例映射过程中出现问题,则会记录在红色或粉色(波浪边)卡片上。

image.png

图 3.11 示例映射卡片

User Story Mapping

创建用户故事地图(user story map)是一种协作活动,它沿着一条反映用户旅程的横轴来捕捉一个故事,如图 3.12 所示 [3.9]。用户故事地图是一种视觉化表示,用来展示用户如何按时间顺序与某个产品或服务发生交互。

image.png

图 3.12 用户故事映射示例

这一技术从描述一整段用户旅程或流程开始,从起点一直描述到终点。用户旅程由用户在使用产品过程中会完成的一系列高层活动构成;这些活动构成了用户故事地图的骨架。在用户故事地图中的每一个活动下方,再加入用户故事,并按对用户的价值进行排序。接下来的一步,是沿水平方向把用户故事地图切分成多个发布版本。用户故事地图有助于澄清满足用户需求所必需内容的全貌,并让团队保持对交付的聚焦。

限界上下文与架构风格

限界上下文并不强制要求某一种特定的软件架构风格。所谓架构风格(或架构模式),指的是子系统的结构,也就是定义该子系统内各组件之间关系的方式。在《Fundamentals of Software Architecture》[3.10] 一书中,Mark Richards 与 Neil Ford 依据部署单元,将架构风格分为单体架构分布式架构

单体架构——例如分层架构、管道架构、微内核架构——是以单一部署单元进行交付的。分布式架构——例如面向服务架构、事件驱动架构、空间型架构以及微服务架构——则允许存在多个通过网络通信的部署单元。每一种架构风格都会支撑不同的架构特性,这些特性也被称作质量属性非功能性需求,或者系统的各种“-ility”属性(例如可扩展性、可用性、可测试性、可部署性)。架构风格既可以单独使用,也可以组合使用。例如,事件驱动的微服务架构,就被视为两种不同风格的组合。

限界上下文为沿着良好的“缝”将系统拆分为更小的、领域特定的部分提供了天然切口,因此它们常被视为微服务的良好粗粒度边界候选项。但这并不意味着限界上下文一定要被实现为微服务。也可以采用其他架构风格,例如模块化单体面向服务的领域服务,以及其他方案。

在选择架构风格时,Richards 和 Ford [3.10] 建议做出下面几类决策。

单体还是分布式

整个系统是否只需要覆盖一组统一的架构特性,还是系统中的某些特定部分需要与其他部分不同的架构特性?如果全系统只需要一组统一的架构特性,那么更适合单体架构风格;而如果不同部分对架构特性的要求不同,则更适合使用分布式架构。例如,当系统中的某一个特定部分需要比其他部分更高水平的可扩展性与弹性时,就属于后者。

数据应该放在哪里?

数据应当存储在一个数据库中,还是多个数据库中?当把数据分散到多个数据库中时,还需要进一步回答一些问题,例如:谁拥有哪些数据、如何设计分布式数据访问、如何管理分布式事务与工作流,等等。

选择通信风格

当系统被拆分之后,服务之间应当如何通信,也是一个需要回答的问题。它们应当采用同步通信还是异步通信?在同步通信中,请求发送方会阻塞自己的操作,并等待接收方返回响应。这个请求可能是一个获取信息的查询,也可能是一个执行动作的命令,例如改变状态。在异步通信中,发送方会继续推进自己的操作(非阻塞),并在稍后的某个时间点收到响应——这种方式允许并行处理。异步通信是事件驱动架构中的典型做法,在这种架构下,服务之间通过交换事件来通信。

做架构决策并不是一次性完成的动作。随着系统不断增长、需求不断变化,架构也必须持续演进。《Continuous Architecture in Practice》[3.11] 的作者强调,为了加速软件交付,架构必须被持续地开发与改进,而不是被当作一次性的工作。他们提出了一组组织可以采用的持续架构原则,以实现这一目标:

  • Architect products:从短期项目转向长期产品。
  • Focus on quality attributes:质量属性需求(例如性能、可扩展性、安全性)驱动架构决策。
  • Delay design decisions until they are absolutely necessary:把设计决策延后到绝对必要时再做,避免过早优化和过度设计。
  • Architect for change — leverage the “power of small” :为变化而架构——利用“小”的力量。尽量减少紧耦合组件,因为它们很难变更;相反,应偏向小而松耦合的组件。
  • Architect for build, test, deploy, and operate:为构建、测试、部署与运行而架构。与其做庞大的前置架构,不如聚焦于灵活、可适应的架构,并通过早期实现和来自生产环境的快速反馈回路来驱动演进。
  • Model the organization of your teams after the design of the system you are working on:让团队的组织方式与其所构建系统的设计保持一致。

不存在所谓“唯一最佳”的软件架构;相反,软件架构中的一切都伴随着各种权衡。为了评估与分析软件架构中的这些权衡,《Software Architecture: The Hard Parts》[3.12] 一书的作者建议:识别彼此纠缠的部分,分析这些纠缠部分是如何耦合的,并评估在特定情境下的各种权衡。例如,团队可能会分析使用同步通信还是异步通信的权衡,或者使用共享服务还是共享库的权衡。

最终形成的架构决策,以及其中的权衡,可以通过 Architecture Decision Records(ADR) 来进行有效记录。

什么是 Architecture Decision Records?

Michael Nygard 提出了 Architecture Decision Records(ADR) [3.13]。ADR 用于记录团队计划实施的某个软件架构方面的决策。它帮助相关利益相关者理解某个具体决策背后的动机与推理过程。ADR 会描述决策本身、其上下文,以及由此带来的后果,包括曾经考虑过的权衡。一个 ADR 的后果,也可能引出后续的 ADR。

限界上下文与演进阶段

对问题领域进行分析、发现子域类型并设计限界上下文之后,还可以进一步把这些限界上下文映射到它们在 Wardley Map 中对应的演进阶段上(图 3.13)。对于会议活动策划解决方案而言,与核心域相关的限界上下文——例如投稿处理CfP 管理议题评审日程管理——都是对业务至关重要的部分,它们为组织提供竞争优势。因此,组织应当把大部分开发投入聚焦在这些领域上,而这些领域通常也是在内部构建的强候选项。由于这些差异化能力仍然需要探索,因此这些子域可以被放入 custom-built 演进阶段。不过需要注意的是,与核心域相关的限界上下文本身也可能继续演进,这一点在第 2 章“发现子域并映射其演进阶段”一节中已有讨论。

image.png

图 3.13 映射到演进阶段的限界上下文

支撑型子域相关的限界上下文,例如消息处理通知处理,所对应的是那些专业化但不具差异化的子域解决方案。对于这种非差异化部分,组织应当评估是否可以利用市场上已有的解决方案。在本例中,对现有解决方案的评估结论是:所需的专业化程度不容易被产品化。因此,这些支撑型子域仍然需要定制构建的解决方案,但它们的投入水平应当是合理的——也就是尽可能低。消息处理和通知处理这两个限界上下文,往往包含较少复杂的业务行为(相较于核心域相关的限界上下文而言),因此在适用时可以采用更简单的实现方式(例如 CRUD)。由于它们需要一定程度的专业化,而市场上又缺少现成产品化方案,因此消息处理与通知处理限界上下文可以被放入 custom-built 演进阶段。

身份与访问管理限界上下文属于一个通用型子域,而市场上已经存在多个解决方案。在这个例子中,团队决定使用一个开源软件方案,因此它被放在 product(+ rental) 演进阶段。

战略设计与 Wardley Mapping 的教义

将 DDD 与 Wardley Mapping 结合起来,不仅有助于以一种自上而下、易于跟随的方式来可视化地进入 DDD,也有助于把 DDD 的子域映射到 Wardley Map 的演进阶段上。此外,DDD 的战略设计还能帮助应用 Wardley 教义中的一些通用原则(图 3.14)。应用这些通用教义原则,会增强组织对外部变化的适应能力;与此同时,应用 DDD 则使组织能够构建面向快速变化流的自适应系统。

image.png

图 3.14 领域驱动设计的战略设计与 Wardley Mapping 的教义原则

DDD 的战略设计通过以下方式支持 Wardley 的教义原则:

  • DDD 的核心,是领域专家与开发团队之间的紧密协作,双方共同分析业务领域以获取领域知识。这些知识随后会在某个限界上下文内,用一种共享语言——统一语言——来表述。通过统一语言进行协作式分享与获取知识,支持了 Wardley 教义中的两个原则:挑战假设以形成更好的理解,以及使用共同语言来实现有效沟通。不过需要注意的是,统一语言并不是公司范围内统一的一种语言;它只在某个限界上下文内,被该上下文的所有团队成员共享。
  • 通过协作获取领域知识,有助于做到“了解细节”,而这正是教义原则之一。此外,当切换到 DDD 的战略设计解决方案空间和战术设计时,了解“需要哪些组件来满足用户需求”这类细节会变得非常有用。具体而言,它帮助团队将问题领域拆分为限界上下文、推导领域模型,并设计与实现一个尽可能贴合问题领域需求的解决方案。
  • 核心域是提供竞争优势的子域。发现核心型、支撑型与通用型子域,使组织能够聚焦于高水平的态势感知,并理解组织所处及竞争的环境。
  • 发现子域并将系统拆分为限界上下文,支持了“从小处着手”这一教义原则,也就是把更大的环境拆分为更小的部分。限界上下文还有助于让团队与组织的变化流对齐,这一点会在本书后续章节中进一步讨论。
  • 将限界上下文映射到演进阶段及其相关子域类型上,可以揭示组织应当在何处投入最多的开发资源、何处更适合购买现成产品或使用开源软件,以及何处应当外包给 utility 供应商。此外,从 DDD 的完整范围来看——包括战术设计(见第 4 章“使用战术性领域驱动设计实现领域模型”)——它最适合用于建模和实现复杂的领域逻辑与业务规则。发现子域,并把开发工作聚焦到核心域上,也支持了“针对不同演进阶段使用合适的方法”这一教义原则。

高内聚与松耦合

在其经典著作《Structured Design》中,Yourdon 和 Constantine 强调:“耦合和内聚都是设计模块化结构时非常有力的工具”[3.14]。与这一原则相呼应,Endres 和 Rombach 后来指出:“如果一个结构具有强内聚和低耦合,那么它就是稳定的”[3.15]。高内聚与松耦合,使系统更易于应对变化。

限界上下文内部的高内聚

为了创建一个能够演进、并且能够适应变化的系统,找到合适的边界至关重要。有了合适的边界,组织就能够安全且快速地进行频繁变更,并且这些变更对系统其他部分的影响会更小。解决这一点的一种方式,是确保高内聚,而限界上下文正可以实现这一点。2 当相关行为被放在同一个地方时,就实现了高内聚。当进行某个行为变更时,高内聚意味着只需要在一个地方——也就是某一个限界上下文内——完成修改。相反,如果需要跨多个限界上下文在不同地方同时改动,变化流就会被拖慢,因为这通常要求与其他团队进行协调,才能让变更真正生效。
2. Constantine 和 Yourdon 曾描述过七种内聚水平:偶然内聚、逻辑内聚、时间内聚、过程内聚、通信内聚、顺序内聚和功能内聚 [3.14]。限界上下文追求的是一种很高程度的内聚,类似于功能内聚,但范围更广:它确保边界内的所有行为与数据都围绕着一个一致的领域模型来统一组织。

限界上下文之间的松耦合

另一个支撑快速变化流的关键概念,是模块或限界上下文之间的松耦合。如果一个模块与另一个模块发生交互,那么它们之间就存在依赖关系。耦合反映的是模块之间依赖关系的强度。只要模块之间发生交互,耦合就是不可避免的;但理想状态下应当是松耦合。当模块之间是松耦合时,对系统某一部分引入变化,应当只对系统其他部分产生最小影响。因此,我们的目标应当是减少限界上下文之间不必要的耦合

弱耦合使模块能够在不破坏系统其他部分的情况下独立演进。拥有这些弱耦合模块的团队,在推动变更时会快得多;否则,如果他们面对的是一个强耦合系统,那么一个变化就可能引入未知的副作用。如果修改一个模块必须同时修改另一个模块,那么它们就是耦合的(变更耦合,change coupling)。模块之间耦合越强,做出变更所需的工作量就越大。高变更耦合会提高变更成本,因此最小化这一因素非常重要。

不过,与稳定模块的耦合,比起与不稳定模块的耦合,问题要小得多。本章后文“与核心集成时评估变更耦合”一节,在讨论与易变核心域相关限界上下文集成时,会更详细地考察这一点。本书仅聚焦于变更耦合这一种耦合。在其著作《Balancing Coupling in Software Design》[3.16] 中,Vladik Khononov 描述了现代分布式系统中的多种耦合类型,并提出了一种新的多维耦合模型,作为一种强有力的软件设计工具。

上下文映射概览

当我们把问题领域拆分为模块化组件(即限界上下文)之后,战略设计的下一步,就是映射它们之间的团队关系集成关系。这些关系可以通过构建上下文映射(context maps)来理解、描述和管理。上下文映射会把变更耦合显式化、可视化,从而揭示:系统某一部分的变化,会在多大程度上影响其他部分。上下文映射聚焦于限界上下文之间围绕功能与数据的、基于合约的集成关系,以及它们背后的团队间动态,如图 3.15 所示。

image.png

图 3.15 上下文映射描述集成与关系模式

上下文映射并不规定限界上下文在运行时必须采用某一种技术通信风格,例如同步还是异步。即便限界上下文之间已经建立了异步、非阻塞、事件驱动的通信风格,系统仍然可能在变更层面上强耦合。例如,承载某个事件的消息签名一旦变更,就可能要求所有该事件的发布者与消费者都进行调整。上下文映射的价值在于:把原本隐含的依赖与过程显式化。技术通信风格处理的是服务在运行时的动态耦合,而上下文映射关注的则是变更耦合

上下文映射由一组描述性模式构成,它们表达了不同类型的关系和影响程度:从自由关系,到上游—下游关系,再到限界上下文及其团队之间的相互依赖(图 3.16)。上游—下游依赖描述的是这样一种关系:上游团队的行动会影响下游团队,而反过来却未必成立。正如 Eric Evans 所说:“下游需要上游提供东西,但上游并不对下游的交付结果负责”[3.17]。如果限界上下文及其团队能够独立运作,它们就属于一种自由关系(separate wayspublished language),团队之间不需要为了让变更生效而进行协调与沟通。如果两个或更多限界上下文彼此高度依赖,那么它们就是相互依赖的(shared kernelpartnership),这就要求相关团队之间具备很高的沟通带宽。图 3.16 从左到右展示的上下文映射模式表明:当涉及对相应限界上下文进行变更实现与交付时,团队间所需的沟通与协调成本会逐步增加。

image.png

图 3.16 上下文映射模式

下面几节会更详细地描述这些上下文映射模式。每一种模式都反映了限界上下文及其背后团队之间的一种特定关系。

Separate Ways(SW)

当限界上下文与其他限界上下文没有连接时,它们就各走各路(separate ways)。有时,与另一个限界上下文集成的代价过高,或者团队之间的沟通本身存在问题,那么采用这种模式就是合理的。在这种情况下,复制功能并实现各自独立的解决方案,可能比与其他团队集成或协作的成本更低。Separate Ways 不需要团队之间的协调,能够降低限界上下文之间的耦合,但会引入跨上下文的公共功能重复。

Published Language(PL)

发布语言(published language)描述的是两个限界上下文之间一种有良好文档化、标准化、共享的交换语言。它通常与稍后会介绍的 Open-Host Service(OHS) 结合使用。共享语言的格式会以文档化良好的 schema 形式发布,并实现某种标准。在《Strategic Monoliths and Microservices》[3.18] 一书中,Vaughn Vernon 和 Tomasz Jaskula 描述了发布语言可以实现的多个标准层级,从行业范围标准,到组织内部标准,再到上下文/子域级标准。发布语言通常由一个联盟来创建,而开放且灵活的关系使得消费者可以协商,使自身需求被纳入既定合约之中。

Anticorruption Layer(ACL)

防腐层(anticorruption layer)负责把外部上游模型翻译为内部下游模型。它保护下游模型免受上游模型合约中的外来概念以及频繁变化的影响。它还通过建立一层隔离层,防止设计糟糕的上游模型继续向下游传播。防腐层帮助把那些与下游模型无关的外来概念挡在外面,从而保持概念完整性。对于下游的核心域来说,防腐层尤其适合用来保护问题领域中业务关键部分的纯净性,使其不受外部概念污染。

Conformist(CF)

顺从者模式(conformist)会让下游模型直接贴合上游模型,而不做额外转换。下游团队原样采用上游模型,从而减少两个上下文之间的转换复杂度。这简化了集成,但也提高了耦合。Conformist 适用于这样一种场景:上游模型稳定、设计良好,并且与下游团队的需求相匹配。它最适合在这样一种情况下使用:对于一个复杂但不具差异化的功能集合,下游团队实际上不适合去“对抗”一个现成的上游解决方案(可与前面提到的 Separate Ways 模式对比理解)。

Open-Host Service(OHS)

开放主机服务(open-host service)不是为某个单一客户端量身定制的。它不会为每个客户端都做一套专门集成,而是试图暴露出一套定义良好、内聚一致、方便多个下游消费者使用的协议——例如,一个公共 API。这个公共 API 可以通过多种方式提供,例如 REST、RPC、GraphQL、消息等。开放主机服务会把上游模型的实现与其公共 API 进行弱耦合——两者可以不同。这就使它们能够以不同节奏演进,而不必把上游内部模型的每一次变化或全部细节都暴露给下游消费者。发布语言通常会与开放主机服务搭配使用。

Customer–Supplier

在一种上游—下游关系中,下游上下文依赖于上游上下文;也就是说,上游的行动会影响下游的结果,而下游上下文对上游上下文的决策与行动几乎没有或根本没有影响力。为了响应下游需求,上游与下游团队可以形成一种客户—供应商关系(customer–supplier)。这种团队关系模式使下游团队(客户)能够获得对上游团队(供应商)一定程度的影响力。例如,下游客户团队可以通过明确提出自己的需求,影响上游供应商团队的优先级、规划和任务安排,而上游上下文则尝试去满足这些需求。

Shared Kernel(SK)

当两个或更多团队共享其领域模型的一个子集,并把它作为共享工件使用——例如 Java JAR 包、DLL 动态链接库,或共享数据库——它们就是在使用共享内核(shared kernel)。领域模型的某个子集会通过共享内核传播到这些限界上下文中。修改共享内核需要团队之间的同步,因此相关团队是非常紧耦合的。在这种情况下,共享内核所涉及的团队很可能会形成一种伙伴关系(partnership)。这种做法适合于这样一种情况:如果各个限界上下文分别去集成变更、复制功能,那么其代价会高于在一个共享内核上进行协同变更的代价。共享内核的目标是减少重复并简化集成,但代价是要求团队之间具备很高程度的协调。

当涉及一些更通用的类型时,例如金额货币或其他组织级标准类型,共享内核甚至可以被更广泛的一组团队共同使用。

共享内核不应与公司级的规范模型(canonical model)混淆。共享内核通常是很小且专门化的;而规范模型往往很庞大,通常又无法很好地满足任何一个团队的具体需求,因此它实际上与 DDD 的目标相违背。

Partnership(PS)

伙伴关系(partnership)这种上下文映射中,团队会协作以实现一个对齐的共同目标。伙伴关系中的团队会在开发与集成上紧密合作和协调。这样的伙伴关系要求团队之间具备最高水平的沟通与协调带宽,而这一点本身就可能使它变得不切实际。即便在某些情况下可行,它也未必能长期持续。

Big Ball of Mud(BBoM)

Big Ball of Mud 指的是没有清晰边界、模型混乱且边界模糊的一种状态。大概率意味着,系统要么从未应用过架构原则,要么这些原则早已被丢弃。如果另一个限界上下文必须与一个 Big Ball of Mud 集成,那么团队应当确保这种混乱模型不会向其他上下文传播——例如,可以在下游消费者一侧建立一个防腐层

组合上下文映射的示例

前面描述的上下文映射模式是可以组合使用的。图 3.17 展示了一个例子。在这个例子中,上游限界上下文所属团队提供了一个公共 API,作为开放主机服务,使其限界上下文能够被多个消费者访问。一个下游团队决定直接顺从这个开放主机服务,而不做额外翻译,因为该开放主机服务定义清晰、稳定,并且满足其需求。另一个下游团队则决定使用防腐层,把这个开放主机服务翻译为内部下游模型。他们选择防腐层,是为了让自己的下游模型不受上游开放主机服务中外来概念的污染。

image.png

图 3.17 组合上下文映射的示例

用上下文映射显式化隐式依赖

上下文映射不仅展示限界上下文之间当前或未来的集成关系及其团队关系,还能够揭示其中的隐式依赖。为了说明这一点,参考 Michael Plöd 的《Hands-on Domain-Driven Design: By Example》[3.19] 中的一些示例会很有帮助。

图 3.18 展示了三个不同的限界上下文(A、B、C),它们之间存在 customer–supplier 和 conformist 关系。customer–supplier 团队关系中的客户(B)可以影响其对应供应商(A)的计划、优先级与任务。但由于 C 又在顺从 A,因此 B 其实也在影响 C,尽管它们之间并没有直接连接。如果 B 恰好又是一个会否决需求的客户,那么这种隐式影响就会变得很具阻塞性:B 可能阻止供应商团队(A)做出某些重要变更,而 conformist 上下文(C)对应的团队其实正等待着这些变更。最终,客户 B 也就连带阻塞了团队 C,即便它们之间并没有任何直接关系。

image.png

图 3.18 客户 B 显式影响供应商 A,并隐式影响顺从者 C

图 3.19 则展示了一种情况:两个限界上下文(B、C)形成了 shared kernel 关系,同时它们还分别顺从于其他限界上下文(A、D)。这种上下文映射模式的组合,会导致大量模型传播以及 A、C、B、D 之间的隐式依赖,哪怕它们之间并没有显式连线。

image.png

图 3.19 通过 shared kernel 与 conformist 形成的隐式模型传播

这些例子表明,上下文映射有助于澄清由于模型传播而形成的、限界上下文之间的隐式依赖,以及团队之间所需的沟通与协调成本。对这些动态关系形成深入理解,可能会促使团队采取更加防御性的姿态,例如要求引入开放主机服务、发布语言,甚至彼此之间建立防腐层。接下来会对这些内容作更详细解释。

与核心集成时评估变更耦合

本节通过上下文映射来评估在与核心域相关的限界上下文集成时的变更耦合。由于核心域相关的限界上下文能够提供竞争优势,因此它们对业务具有战略重要性。它们往往复杂,并且变化频繁。当与这些核心域相关的限界上下文集成时,关键是要关注松散的变更耦合,以免阻碍系统中最业务关键部分的演进。要在与核心集成时实现松耦合,可以考虑以下几个方面:

  • 使上游核心能够独立演进。 为了在与核心域相关的限界上下文集成时实现弱耦合,必须确保核心领域模型可以独立演进。这要求避免把内部核心领域模型直接暴露给外部。取而代之,需要建立一种转换机制。开放主机服务(OHS)为多个下游消费者提供公共 API。它通过把内部领域模型转换为外部 API,帮助将内部领域模型的实现与外部世界解耦。
  • 保护下游核心免受设计糟糕的上游模型影响。 当与一个设计糟糕的上游模型集成时——例如一个 Big Ball of Mud(BBoM)——关键是要防止这种混乱模型继续向下游传播到核心域。防腐层(ACL)把外部上游模型翻译为内部下游模型,并保护下游模型不受设计糟糕的上游模型影响。使用 ACL 来防御混乱上游模型,不仅对核心域相关限界上下文重要,对所有场景都适用;只是对于系统中业务关键的部分,这一点尤为重要。
  • 在没有向后兼容保障时,避免去顺从一个易变的核心。 conformist(CF)会让下游模型在不做额外转换的情况下贴合上游模型。如果上游模型本身是易变的(例如核心领域模型),而又没有提供向后兼容性,那么要让下游限界上下文保持稳定,就必须投入大量注意力来处理持续不断的变化与风险来源。这会把注意力从构建客户价值上拖走,转而变成持续处理风险来源。
  • 最小化复杂核心行为的重复。 Separate Ways(SW)倾向于优先复制功能,而不是与其他限界上下文集成。如果把 Separate Ways 用在核心域相关的限界上下文上,那么在变更成本方面会非常昂贵。因为这会要求多个限界上下文中重复的复杂核心功能被频繁更新,同时还要求相关团队频繁协调,才能让变更生效。此外,在多个限界上下文中复制核心功能,也会削弱核心领域行为本应具备的高内聚性。
  • 最小化其他团队对核心造成的竞争性变更影响。 来自其他团队的竞争性变更请求需要尽量减少。在 customer–supplier(CUS-SUP)关系中,客户可以获得对上游供应商团队优先级、规划与任务的一定影响力。如果这种 customer–supplier 关系发生在核心域相关的限界上下文上,就可能导致相互竞争的变更请求。同样地,如果客户否决了供应商的变更,那么这一决定就可能阻碍一个代表系统中高度业务关键部分的供应商的演进。
  • 最小化与其他团队之间过高的沟通带宽。 在 partnership(PS)关系中,团队为了实现一个共同对齐的目标而协作。这种关系要求很高程度的沟通与协调带宽。如果一个团队与易变的核心域相关限界上下文团队形成 partnership,就会拖慢这些战略重要部分的开发速度,而这些部分本应快速变化。这样的关系可能会妨碍核心域中的变化流。

图 3.20 总结了上下文映射如何帮助暴露那些会导致紧密变更耦合的问题性依赖,而这些依赖会阻碍并拖慢系统中最业务关键部分——也就是核心域——的变化流。

image.png

图 3.20 在与核心域相关限界上下文集成时,对上下文映射进行评估

在讨论与核心域相关限界上下文集成时,把这些限界上下文的演进阶段也一并纳入考虑,还能进一步帮助可视化潜在的问题关系,如图 3.21 所示。

image.png

图 3.21 在 Wardley Map 上可视化与核心域相关限界上下文集成时的潜在问题关系

会议活动策划器示例中的上下文映射

图 3.22 展示了会议活动策划器示例中的一种可能的上下文映射。

image.png

图 3.22 会议活动策划器示例的上下文映射

CfP 管理提供了一个公共 API,作为上游开放主机服务,而议题处理以下游防腐层的方式进行消费。议题评审日程管理需要与投稿处理集成。为此,投稿处理限界上下文提供了一个上游开放主机服务,而议题评审与日程管理则通过下游防腐层来消费它。

位于 custom-builtgenesis 演进阶段中的核心域相关限界上下文之间,采用上游开放主机服务与下游防腐层的组合,代表了一种稳定的关系。这种做法通过引入转换机制来应对那些潜在易变、频繁变化的领域模型,从而尽量减少模型差异,以及在内部领域模型结构或行为发生变化时对整个系统进行调整的必要性。

支撑型通用型子域相关的限界上下文,例如通知处理身份和访问管理,则提供开放主机服务,并实现了一种定义清晰且稳定的发布语言。某个联盟甚至可能参与了该发布语言的定义。由于支撑型和通用型子域相关限界上下文的发布语言几乎不会发生变化,因此下游服务可以直接采用 conformist。

技术通信风格与上下文映射

上下文映射描述了限界上下文之间的集成与团队间关系模式,并反映了变更耦合,但它们本身是技术无关的,也不会强制规定某一种技术通信风格。在微服务架构风格下,限界上下文可以作为微服务的粗粒度、领域特定边界存在,而这些微服务运行在彼此分离、隔离的进程中。它们需要通过网络彼此通信(进程间通信)。

在进程间通信中,就会涉及不同的通信风格模式,例如请求—响应事件驱动通信。在请求—响应通信中,一个服务向另一个服务发送请求,以请求状态变更或信息获取,并期待收到响应。等待这个响应的方式,可以是同步阻塞的,也可以是异步非阻塞的。同步阻塞时,请求发送方会在收到响应之前一直阻塞自己的操作完成;异步非阻塞时,请求发送方会继续自己的操作,并在稍后某个时间收到响应。

事件驱动架构中,承载事件的消息会在发布方服务与消费方服务之间异步交换。Sam Newman 在其著作《Building Microservices》[3.20] 中更详细地描述了微服务之间的不同通信模式。

由于上下文映射是技术无关的,因此它并不会影响限界上下文之间到底选择请求—响应还是事件驱动的通信风格。以开放主机服务为例:它既可以被实现为一种同步、阻塞式的请求—响应通信方式(例如 HTTP 上的 REST 或 RPC),也可以被实现为一种异步、非阻塞、消息驱动的通信方式(例如基于 topic 的消息代理)。开放主机服务的公共 API,既可以以 REST API 的形式对外提供,也可以采用消息驱动的方式,在通过消息代理发出事件时进行消息交换。

小结

本章介绍了战略设计的解决方案空间,并将其与 Wardley Mapping 中的原则、模式与实践连接起来,如图 3.23 所示。

image.png

图 3.23 战略性 DDD 及其与 Wardley Mapping 连接关系的总结

战略设计的解决方案空间,关注的是把子域设计为限界上下文,并使用定义明确的上下文映射模式来映射它们之间的关系。限界上下文为领域模型形成边界,并维护其概念完整性。上下文映射帮助评估限界上下文及其团队之间的集成与关系。领域模型代表某个特定子域中的业务行为与逻辑,并存在于一个限界上下文之中。

结合第 2 章与本章的内容,我们可以看到:战略设计如何补充 Wardley Mapping,并在业务战略软件设计之间架起桥梁。应用战略设计,也意味着可以自动应用 Wardley 教义中的一部分原则。再次提醒,应用教义原则使组织能够更轻松地适应变化,并更快地响应变化。此外,限界上下文也可以作为组件,被表示在 Wardley Map 的价值链中。将限界上下文映射到演进阶段,并结合上下文映射一起使用,有助于可视化那些潜在问题关系,而这些关系可能会导致紧密的变更耦合。

本章讨论了如何借助建模技术(例如 EventStorming),为先前介绍的会议活动策划子域设计限界上下文。基于它们各自对应的子域类型(核心型、支撑型、通用型),这些限界上下文被映射为对应 Wardley Map 价值链中的组件,并放置到相应演进阶段上。这有助于可视化组织在做出 build–buy–outsource 决策时,应当把战略投入优先放在哪里。随后,又通过上下文映射模式来补充并评估这些限界上下文之间的关系。

下一章将聚焦于如何使用战术设计中的构建模块,把领域模型实现为代码。