—— 从遗留单体系统转型为现代分布式系统的实战经验
照片由 Shamin Haky 提供,来自 Unsplash
你好啊,我是一名经验丰富的软件工程师,专注于大规模应用的设计。多年来,我见过各种架构——从庞大的单体架构,到精细调整过的微服务基础设施。
有一个核心概念,一直帮助我保持系统的有序性和灵活性,那就是在领域驱动设计(DDD)中,正确理解聚合(Aggregates)和实体(Entities)。
我还记得最早尝试把一个十年历史的单体应用拆分成离散、可维护的组件时的情景。
最大的挑战不只是决定在哪里拆分代码库,更重要的是确保业务逻辑始终保持一致和连贯。
根据我的经验,大约 70% 的迁移过程中遇到的问题,都是由于边界定义不清晰导致的。
我们团队当时的许多人经常要修复数据不一致的问题,还要处理事务范围的复杂性,而这最终引发了性能瓶颈。
那么,我们该如何在问题发生之前就解决这些麻烦呢?这个问题引出了聚合和实体的重要性。在这篇文章里,我会围绕五个主要问题展开讨论:
-
如何在软件设计中识别聚合和实体?
-
什么是聚合和实体?
-
为什么聚合和实体很重要?
-
(再问一次)为什么聚合和实体很重要?——我们会把这当作一次强化和拓展概念的机会。
-
你如何验证你的聚合和实体?
是的,我知道问题 #3 和 #4 听起来有点重复,但请放心,我们会充分利用这个重叠之处,以提供更深刻的见解,而不是简单地重复相同的说法。
在讨论的过程中,我会引用一个虚构的项目管理系统——我认为这是一个大家都比较熟悉的领域——来展示这些概念如何在实际场景中应用。
如何在软件设计中识别聚合和实体?
当我刚开始接触微服务时,其中一个关键步骤就是弄清楚如何围绕数据和行为的逻辑分组划定边界。
在这个过程中,实体和聚合都起到了关键作用。为了识别它们,你需要关注软件工程中的许多基本概念。这里有一些细节。
一个简单的关联关系,表明 Project 可以作为一个聚合根,把任务(Tasks)归类到它之下
这个类图展示了我们如何识别核心领域对象(Project、Task 和 TeamMember),并将它们作为实体,突出了它们的唯一 ID。这种结构可以帮助你发现哪些地方需要原子性更新(比如,在单个项目内部更新任务)。
从业务相关性的角度来看,实体通常代表业务领域中至关重要的事物。在我们的项目管理示例中,我们可能会有 Project(项目)、TeamMember(团队成员)或 Milestone(里程碑)这样的实体。如果某个类或结构没有太多业务意义,或者它不是业务的核心部分,那它往往只是一个值对象。
从唯一标识的角度来看,实体始终有一个持久的身份,即使它的属性发生变化。在项目管理系统中,一个 Project 实体可能会在它的整个生命周期中保持相同的唯一标识符,即使它的名称、描述或截止日期发生变化。
现在来说说具有内聚性的实体群组。聚合是由相互关联的实体和值对象组成的一个集合。
例如,Project 可以是一个聚合根,封装了相关的实体,比如 Task 或 DiscussionThread。这些子实体通常脱离它们的父实体就没有意义,或者至少它们与父实体有很强的联系。
始终要在一致性边界上保持平衡。聚合的一个关键概念是,它定义了一个事务边界。所有的变更都应该在聚合内部原子地发生,以保持领域状态的稳定性。
观察系统中数据是如何被修改的,往往可以给你关于聚合边界的重要线索。
如果某些数据总是一起变化,或者某些字段必须始终保持一致,那么它们很可能属于同一个聚合。
最后,为了在你的模型中获得良好的行为和通用语言,与你的领域专家沟通——也就是那些真正了解业务运作方式的人。
他们使用的通用语言会引导你找到领域的自然边界。
如果所有的利益相关者都把“Project”当作核心单位进行交流,那就说明 Project 很可能是你的聚合根。
识别聚合和实体一开始可能会觉得有些困难,因为你实际上是在与真实的业务世界较量,而现实本身就是复杂的。
但一旦你搞清楚它们,带来的清晰度和可维护性提升将是巨大的。
照片由 Oyemike Princewill 提供,来自 Unsplash
什么是聚合和实体?
行吧,咱们来深入探讨这两个核心概念。
实体(Entities)
实体是带有唯一身份的领域对象。这个身份在对象的生命周期内保持不变,即使它的内部属性发生变化。
在我们的项目管理示例中,Project 绝对是一个实体。因为它可能会更换负责人、改名、调整截止日期,但对业务来说,它仍然是同一个项目。本质上:
• 你通常会用一个主键(比如 ProjectId)把实体存储到数据库中。
• 你需要小心保护它的身份,因为这个 ID 是系统中引用它的主要方式。
• 你需要应用适当的领域逻辑来处理并发和状态变化,以确保它保持一致。
聚合(Aggregates)
另一方面,聚合表示一组紧密相关的实体和值对象。这些分组形成边界,帮助你管理数据完整性。
在每个聚合内部,都有一个 聚合根(Aggregate Root),它是主要的实体,负责控制聚合内部所有内容的访问。通常来说:
• 所有外部请求都必须通过聚合根来处理。
• 内部组件——无论是子实体还是值对象——都被屏蔽,不能被直接操作。
• 整个聚合范围内都适用于一致性规则。如果修改其中一部分会破坏某个规则,你就必须拒绝或调整整个操作。
这张图更加强调了聚合根(标记为 <<AggregateRoot>>)、实体(<<Entity>>)和值对象(<<ValueObject>>)之间的区别
例如,一个 Project 聚合可能由以下部分组成:
• Project(聚合根)
• Task 实体(每个任务都有唯一 ID,但它们始终属于同一个项目)
• DiscussionThread 实体(代表团队讨论内容,也限定在该项目范围内)
• 值对象(比如 TaskDeadline、ProjectSchedule 或 ProjectBudget)
为什么聚合和实体很重要?
这个问题到现在可能已经很明显了,但值得强调其核心动机。
首先,从领域模型的清晰度来看。
聚合和实体能够帮助你在代码中反映业务的思维模式。
这促进了开发人员和领域专家之间的对齐。在我的经验里,大型软件项目中大约 85% 的困惑都来源于对领域语言的误解。相信我,当代码结构和业务概念匹配时,误解会大幅减少。
接下来是数据完整性和一致性。定义清晰的边界,意味着你的事务始终保持 ACID(原子性、一致性、隔离性和持久性)。
比如,当你向一个 Project 添加 Task 时,你可以确保相关的截止日期、预算和更新都保持在一个有效的状态,从而减少部分更新导致系统不一致的风险。
这个时序图展示了聚合和实体如何确保事务完整性。这个示例展示了一个客户端请求如何通过聚合根(Aggregate Root)来添加任务,并确保任何业务规则或不变量都被正确应用。
在可扩展性和自主性方面,在微服务架构中,每个服务通常都会拥有一个或多个聚合。
如果你正确地定义这些聚合,就能减少耦合,使得各个服务更容易独立扩展。
当我参与拆解一个庞大的单体应用时,我们发现 60% 的功能其实只围绕某个特定实体展开。这样一来,我们的微服务架构变得更加简单,并且可以针对特定业务场景进行选择性扩展。
那么可维护性呢?如果一个开发人员需要修改任务的处理方式,他们可以专注于相关的领域逻辑,而不会影响系统的其他部分。这就意味着更好的可维护性、更短的特性交付周期,以及更少的回归问题。
这也引出了有界上下文(Bounded Contexts)的概念。聚合和有界上下文在领域驱动设计(DDD)中紧密相连。
有了清晰的领域模型,每个业务领域都能拥有明确的边界。
因此,在一个定义良好的有界上下文内,你可以放心地调整或重构聚合,而不用担心影响系统的其他部分。
照片由 kaleb ap 提供,来自 Unsplash
(再问一次)为什么聚合和实体很重要?
你可能会想,为什么要重复这个问题?
实际上,正是在我们开始反复思考的时候,某些深层的理解才会真正浮现。
你看,聚合和实体的意义不仅仅在于让代码结构正确,更在于战略性地与业务目标对齐,并确保你的软件具备可持续性。
当业务用户讨论 Project、Task 或 Milestone 时,他们实际上已经在定义关系、约束和工作流程。
当我们将这些概念建模成聚合和实体时,你的代码就变成了一个可交付的业务词汇表,直接为利益相关者提供理解依据。
我个人发现,在我的上一个项目中,一致的命名方式让团队的沟通效率提升了不少,也减少了需求错位的情况。
同时,你还需要关注增长和变化中的风险控制。
当你的业务发展——比如增加新功能,或是外部合作伙伴接入系统——拥有清晰的聚合定义可以极大地降低混乱。
与其到处修改代码,你可以精准地定位变更的地方。
这通常会带来更少的 bug 和 更快的交付速度。
此外,聚合因为是自包含的(Self-contained),所以更容易测试。
你可以围绕一个聚合根创建测试场景,输入不同的命令或数据,然后检查输出状态是否符合预期。这比测试一个充满相互依赖的代码库要简单得多。
这里有一个更宏观的示例,展示了在微服务架构中,每个微服务如何拥有各自的聚合,这有助于保持清晰的边界,并促进系统的可扩展性
在我之前的一个项目中,我们在引入清晰的聚合后,自动化测试覆盖率(Sonar 检测)从 10% 提高到了 65%。
在事件驱动的微服务架构中,聚合就像是天然的事件发布者。例如,当一个 Task 被添加到 Project 时,可能会触发 TaskCreated 事件。
这个事件可以被其他微服务消费,比如分析系统、通知服务或资源管理模块——而不需要它们直接访问 Project 聚合的内部结构。
因此,重新审视“为什么聚合和实体重要”这个问题,可以帮助我们更深入地理解它们不仅仅是代码层面的概念,而是构建健壮、可扩展架构的核心支柱。
你如何验证你的聚合和实体?
既然我们已经了解了什么是聚合和实体,以及它们为什么重要,最后一步就是确保我们的定义是正确的。
我始终认为,验证是必要的,因为领域模型并不是一成不变的,它会随着业务的发展不断演进。
首先,最好的反馈来源就是领域专家。
带上你的聚合和实体设计图——比如 UML 图,展示 Project、Task 和 DiscussionThread 之间的关系。
请业务专家确认,这些关系和边界是否符合真实业务需求。我通常会安排一些简短、高效的会议,来澄清领域中的模糊概念。
这个图展示了 Project 实体的生命周期,突出显示了如何在不同状态下跟踪项目,确保转换符合业务规则。
有时候,只需要一次头脑风暴会议,就能避免未来几周的大量代码重构。
其次,保持领域语言的一致性。所有人都应该使用相同的术语。如果业务专家称其为 Project Phase,而代码中写的是 Project Stage,那一定会导致混乱。
保持术语一致,能够极大地减少误解。
我曾经见过一个项目,只是改了领域类的名字,让它们与组织内的专业术语匹配,结果用户接受度大幅提升。
然后,作为开发者,你应该写具体的示例和单元测试。
我倾向于测试驱动开发(TDD),或者至少写足够的自动化测试来验证领域逻辑。
例如,你可以测试以下场景:
• 创建一个 Project
• 添加多个 Task
• 更新任务的截止日期
• 开启讨论线程
如果某个操作违反了业务规则(比如任务不能严重重叠,或者预算超出一定限额需要审批),测试就会失败,从而提醒你调整设计。
此外,当系统上线后,或者至少在测试环境中,你要收集真实的用户反馈。
如果业务团队或终端用户在完成基本任务时遇到困难,可能说明你的聚合边界需要调整。
在一个项目管理系统的实际部署中,我们发现几乎一半的任务需要跨项目关联(即所谓的“项目依赖”)。这促使我们要么放宽现有的聚合边界,要么引入新的领域概念来优雅地处理跨项目依赖。
最后,记住要不断迭代和优化。领域驱动设计是一个持续演进的过程。
每次迭代结束后,评估你的聚合是否仍然符合当前业务。如果某个设计感觉别扭,不要犹豫,重构它。
在一个案例中,我们的 Milestone(里程碑)最初只是一个简单的日期标记,但后来演变成了一个完整的工作流引擎,这促使我们对其进行彻底重构。这听起来可能很麻烦,但如果你的聚合设计良好,这类修改其实相当顺畅。
当你不断验证聚合和实体,你就能 100% 确保最终的设计与业务需求完美对齐。
这正是领域驱动设计的核心价值所在——它能帮助你避免未来的技术债,减少维护成本。
希望这次对聚合和实体的五个关键问题的探讨,能给你带来一些启发,帮助你优化自己的系统设计。
无论你是在重构遗留代码,还是在构建全新的微服务架构,花时间识别、设计和验证这些领域组件,都会在未来带来丰厚的回报!