如何将单体应用拆分为微服务
解耦什么以及何时解耦
随着单体系统变得过于庞大而难以处理,许多企业开始将其分解为微服务架构风格。这是一段值得的旅程,但并非易事。我们已经了解到,要做到这一点,我们需要从一个简单的服务开始,然后提取基于对业务至关重要且经常发生变化的垂直功能的服务。这些服务最初应该很大,最好不要依赖于剩余的单体应用。我们应该确保迁移的每一步都代表着对整体架构的原子改进。
2018 年 4 月 24 日
将单体系统迁移到 微服务生态系统 是一段史诗般的旅程。那些踏上这段旅程的人有着诸如提高运营规模、加快变化速度和摆脱高昂的变更成本等愿望。他们希望增加团队数量,同时使团队能够并行且独立地交付价值。他们希望快速尝试其业务的核心能力,并更快地交付价值。他们还希望摆脱与对现有单体系统进行更改相关的巨额成本。
决定何时解耦什么功能以及如何增量迁移是将单体应用分解为微服务生态系统的一些架构挑战。在这篇文章中,我分享了一些技巧,可以指导交付团队——开发人员、架构师、技术经理——在旅程中做出这些分解决策。
为了阐明这些技巧,我使用了一个多层在线零售应用程序。该应用程序紧密耦合了用户界面、业务逻辑和数据层。我选择这个例子是因为它的架构具有许多企业运行的单体应用程序的特征,并且它的技术栈足够现代,足以证明分解而不是完全重写和替换是合理的。
微服务生态系统目的地
在开始之前,至关重要的是每个人都对 微服务生态系统 有共同的理解。微服务生态系统是一个服务平台,每个服务都封装了一个业务能力。业务能力代表着企业在特定领域为了实现其目标和责任而执行的操作。每个微服务都公开了一个 API,开发人员可以以自助方式发现和使用该 API。微服务具有独立的生命周期。开发人员可以独立地构建、测试和发布每个微服务。微服务生态系统强制执行自治的长期团队的组织结构,每个团队负责一个或多个服务。与普遍的看法和微服务中的“微”相反,每个服务的规模最不重要,可能会根据组织的运营成熟度而有所不同。正如 Martin Fowler 所说,“微服务是一个标签,而不是描述”。
图 1:服务封装业务能力,通过自助 API 公开数据和功能
旅程指南
在深入研究指南之前,重要的是要知道,将现有系统分解为微服务会产生很高的总体成本,并且可能需要多次迭代。开发人员和架构师有必要仔细评估将现有单体应用分解是否正确,以及微服务本身是否 正确的目的地。明确这一点后,让我们来浏览一下指南。
用一个简单且相当解耦的功能热身
走上微服务之路需要最低限度的运营准备。它需要按需访问部署环境,构建新型的持续交付管道以独立构建、测试和部署可执行服务,以及安全、调试和监控分布式架构的能力。无论我们是在构建全新服务还是分解现有系统,都需要运营准备成熟度。有关此运营准备的更多信息,请参阅 Martin Fowler 关于 微服务先决条件 的文章。好消息是,自从 Martin 的文章发表以来,用于运行微服务架构的技术已经迅速发展。这包括 服务网格 的创建,这是一个专门的基础设施层,用于运行快速、可靠和安全的微服务网络,容器编排系统,用于提供更高层次的部署基础设施抽象,以及持续交付系统的演进,例如 GoCD,用于构建、测试和部署微服务作为容器。
我的建议是开发人员和运营团队使用他们分解或构建的第一个和第二个服务来构建底层基础设施、持续交付管道和 API 管理系统。从与单体应用相当解耦的功能开始,它们不需要更改当前使用单体应用的许多面向客户端的应用程序,并且可能不需要数据存储。交付团队在此阶段优化的目标是验证他们的交付方法、提高团队成员的技能,以及构建交付独立部署的安全服务所需的最小基础设施,这些服务公开自助 API。例如,对于在线零售应用程序,第一个服务可以是“最终用户身份验证”服务,单体应用可以调用该服务来验证最终用户,第二个服务可以是“客户资料”服务,这是一个提供更好客户视图的面向新客户端应用程序的 фасадный 服务。
首先,我建议解耦简单的边缘服务。接下来,我们采用不同的方法来解耦深度嵌入在单体系统中的功能。我建议先做边缘服务,因为在旅程开始时,交付团队最大的风险是无法正确运行微服务。因此,最好使用边缘服务来练习他们需要的 运营先决条件。一旦他们解决了这个问题,他们就可以解决拆分单体应用的关键问题。
图 2:使用具有较小变更半径的简单功能热身,以构建我们的运营准备
最小化对单体应用的依赖
作为一项基本原则,交付团队需要最小化新形成的微服务对单体应用的依赖。微服务的主要优势是拥有快速独立的发布周期。对单体应用的依赖——数据、逻辑、API——将服务耦合到单体应用的发布周期,从而禁止这种优势。通常,从单体应用迁移的主要动机是单体应用中锁定功能的高成本和缓慢的变更速度,因此我们希望通过消除对单体应用的依赖来逐步朝着解耦这些核心功能的方向发展。如果团队在将功能构建到自己的服务中时遵循此准则,他们会发现,相反,单体应用到服务的依赖关系。这是期望的依赖关系方向,因为它不会减慢新服务的变更速度。
考虑一个零售在线系统,其中“购买”和“促销”是核心功能。“购买”在结账过程中使用“促销”为客户提供他们有资格获得的最佳促销,具体取决于他们购买的商品。如果我们需要决定接下来解耦这两个功能中的哪一个,我建议先解耦“促销”,然后再解耦“购买”。因为按照这种顺序,我们减少了对单体应用的依赖。按照这种顺序,“购买”首先仍然锁定在单体应用中,并依赖于新的“促销”微服务。
接下来的指南提供了其他方法来决定开发人员解耦服务的顺序。这意味着他们可能无法始终避免对单体应用的依赖。在新的服务最终需要调用单体应用的情况下,我建议从单体应用公开一个新的 API,并通过新服务中的 反腐败 层访问该 API,以确保单体应用的概念不会泄漏出去。努力定义反映明确定义的领域概念和结构的 API,即使单体应用的内部实现可能并非如此。在这种不幸的情况下,交付团队将承担更改单体应用、测试和发布与单体应用发布耦合的新服务的成本和难度。
图 3:首先解耦不需要依赖单体应用的服务,并将对单体应用的更改降至最低
尽早拆分粘性功能
我假设此时交付团队已经习惯了构建微服务,并准备着手解决棘手的问题。但是,他们可能会发现自己受到限制,无法解耦下一个功能,而不会依赖单体应用。造成这种情况的根本原因通常是单体应用中存在一个泄漏的功能,该功能没有很好地定义为领域概念,并且许多单体应用功能都依赖于它。为了能够继续前进,开发人员需要识别粘性功能,将其分解为明确定义的领域概念,然后 具体化 这些领域概念为单独的服务。
例如,在基于 Web 的单体应用中,“(Web)会话”是那些最常见的耦合因素之一。在在线零售示例中,会话通常是一个存储许多属性的容器,这些属性跨越不同的领域边界,例如运输和支付偏好,以及用户意图和交互,例如最近访问的页面、点击的商品和愿望清单。除非我们解决解耦、分解和具体化当前的“会话”概念,否则我们将难以解耦许多未来的功能,因为它们将通过泄漏的会话概念与单体应用纠缠在一起。我同样不建议在单体应用之外创建“会话”服务,因为它只会导致与当前在单体应用进程中存在的类似紧密耦合,只是更糟糕,跨进程和跨网络。
开发人员可以从粘性功能中增量提取微服务,一次提取一个服务。例如,首先重构“客户愿望清单”并将其提取到一个新的服务中,然后将“客户支付偏好”重构到另一个微服务中,并重复此过程。
图 4:识别最耦合的概念,并将其解耦、分解和具体化为具体的领域服务
垂直解耦并尽早释放数据
从单体应用中解耦功能的主要驱动因素是能够独立地发布它们。这一首要原则应该指导开发人员在执行解耦时做出的每一个决定。单体系统通常由紧密集成的层甚至多个系统组成,这些系统需要一起发布,并且具有脆弱的相互依赖关系。例如,在在线零售系统中,单体应用由一个或多个面向客户的在线购物应用程序、一个实现许多业务功能的后端系统以及一个集中集成的数据库来保存状态组成。
大多数解耦尝试从提取面向用户的组件和一些门面服务开始,为现代 UI 提供开发者友好的 API,而数据仍然锁定在一个模式和存储系统中。虽然这种方法可以带来一些快速收益,例如更频繁地更改 UI,但在核心功能方面,交付团队只能像最慢的部分一样快,即单体及其单体数据存储。简而言之,如果没有解耦数据,架构就不是微服务。将所有数据保存在同一个数据存储中与微服务的分散数据管理特性相悖。
策略是垂直地迁移出功能,将核心功能与其数据解耦,并将所有前端应用程序重定向到新的 API。
多个应用程序写入和读取集中共享数据的行为是解耦数据和服务的最大障碍。交付团队需要根据他们是否能够同时重定向和迁移所有数据读取器/写入器,来制定适合其环境的数据迁移策略。Stripe 的四阶段数据迁移策略适用于许多需要增量迁移通过数据库集成的应用程序的环境,而所有正在更改的系统都需要持续运行。
图 5:将功能与其数据解耦到一个暴露新接口的微服务,修改和重定向消费者到新的 API
解耦对业务重要且经常变化的部分
将功能从单体中解耦是困难的。我听说Neal Ford用小心进行器官手术的比喻来形容。在在线零售应用程序中,提取功能涉及仔细提取功能的数据、逻辑、面向用户的组件,并将它们重定向到新的服务。由于这并非易事,开发人员需要不断评估解耦的成本与他们获得的收益,例如更快地运行或扩展规模。例如,如果交付团队的目标是加快对锁定在单体中的现有功能的修改,那么他们必须确定修改次数最多的功能以提取。解耦那些不断发生变化、受到开发人员关注且最能限制他们快速交付价值的代码部分。交付团队可以分析代码提交模式,找出历史上变化最大的部分,并将这些信息与产品路线图和组合叠加,以了解未来即将受到关注的最受欢迎的功能。他们需要与业务和产品经理沟通,了解对他们真正重要的差异化功能。
例如,在在线零售系统中,“客户个性化”是一个经历大量实验以提供最佳客户体验的功能,它是解耦的良好候选者。它是一个对业务、客户体验非常重要的功能,并且经常被修改。
图 6:识别和解耦最重要的功能:为业务和客户创造最大价值,同时定期更改。
解耦功能而不是代码
每当开发人员想要从现有系统中提取服务时,他们有两种方法:提取代码或重写功能。
通常,服务提取或单体分解被认为是将现有实现原封不动地提取到单独服务中的一种情况。部分原因是我们对我们设计和编写的代码有一种认知偏差。无论构建过程多么痛苦,结果多么不完美,构建的劳动都会让我们对它产生爱意。这实际上被称为宜家效应。不幸的是,这种偏差会阻碍单体分解工作。它会导致开发人员,更重要的是技术经理,忽视提取和重用代码的高成本和低价值。
或者,交付团队可以选择重写功能并弃用旧代码。重写为他们提供了一个重新审视业务功能的机会,与业务进行沟通以简化遗留流程,并挑战随着时间的推移而构建到系统中的旧假设和约束。它还提供了一个技术更新的机会,使用最适合该特定服务的编程语言和技术栈来实现新服务。
例如,在零售系统中,“定价和促销”功能是一段智力上复杂的代码。它支持动态配置和应用定价和促销规则,根据各种参数(例如客户行为、忠诚度、产品捆绑等)提供折扣和优惠。
可以说,此功能是重用和提取的良好候选者。相比之下,“客户资料”是一个简单的CRUD功能,它主要由用于序列化、处理存储和配置的样板代码组成,因此,它是重写和弃用的良好候选者。
根据我的经验,在大多数分解场景中,团队最好将功能重写为新服务并弃用旧代码。考虑到重用的高成本和低价值,原因如下
- 存在大量处理环境依赖关系的样板代码,例如在运行时访问应用程序配置、访问数据存储、缓存,并且使用旧框架构建。大多数这些样板代码需要重写。托管微服务的新基础设施与数十年的应用程序运行时环境截然不同,将需要一种截然不同的样板代码。
- 现有功能很可能不是围绕清晰的领域概念构建的。这会导致传输或存储不反映新领域模型的数据结构,需要进行大规模重构。
- 经过多次迭代更改的长期遗留代码可能具有很高的代码毒性水平和低重用价值。
除非功能相关,与清晰的领域概念一致,并且具有很高的知识产权,否则我强烈建议重写并弃用旧代码。
图 7:重用和提取高价值、低毒性的代码,重写和弃用低价值、高毒性的代码
先宏观,再微观
在遗留单体中找到领域边界既是一门艺术,也是一门科学。作为一般规则,应用领域驱动设计技术来查找定义微服务边界的限界上下文是一个良好的起点。我承认,我经常看到从大型单体到非常小的服务的过度修正,这些服务的設計灵感和驱动来自现有的规范化数据视图。这种识别服务边界的方法几乎总是会导致大量针对 CRUD 资源的贫血服务的寒武纪大爆发。对于许多刚接触微服务架构的人来说,这会创建一个高摩擦的环境,最终无法通过服务的独立发布和执行测试。它会创建一个难以调试的分布式系统,一个跨事务边界被破坏的分布式系统,因此难以保持一致,一个对组织的运营成熟度来说过于复杂的系统。虽然有一些关于微服务应该“多小”的启发式方法:团队规模、重写服务的时间、它必须封装多少行为等等。我的建议是,大小取决于交付和运营团队可以独立发布、监控和操作多少服务。从围绕逻辑领域概念的较大服务开始,当团队在运营上准备就绪时,将服务分解成多个服务。
例如,在解耦零售系统的过程中,开发人员可以从一个名为“购买”的服务开始,该服务封装了“购物车”的内容以及购买购物车的功能,即“结账”。随着他们形成更小的团队和发布更多服务的能力的提高,他们可以将“购物车”从“结账”中解耦到一个单独的服务中。
图 8:围绕丰富的领域概念解耦宏服务,并在准备好时,将服务分解成更小的领域概念
以原子进化步骤迁移
将遗留单体通过解耦成设计精美的微服务而消失的想法有点像神话,而且可以说是不希望的。任何经验丰富的工程师都可以分享遗留迁移和现代化尝试的故事,这些尝试在开始时就过于乐观地计划和启动,充其量是在某个足够好的时间点被放弃。此类努力的长期计划被放弃,因为宏观条件发生了变化:项目资金耗尽,组织将重点转移到其他事情上,或者支持它的领导层离开。因此,这种现实应该在团队如何处理从单体到微服务的旅程中得到设计。我称这种方法为“架构演化的原子步骤迁移”,其中迁移的每一步都应该使架构更接近其目标状态。每个演化单元可能是一个小步骤或一个大飞跃,但它是原子的,要么完成,要么回滚。这尤其重要,因为我们正在采用一种迭代和增量的方法来改进整体架构和解耦服务。每次增量都必须让我们在架构目标方面处于更好的位置。使用演化架构适应度函数的隐喻,每次原子迁移步骤后的架构适应度函数应该生成一个更接近架构目标的值。
让我用一个例子来说明这一点。想象一下,微服务架构的目标是提高开发人员修改整个系统以交付价值的速度。团队决定将最终用户身份验证解耦到一个基于 OAuth 2.0 协议的单独服务中。该服务旨在取代现有(旧架构)客户端应用程序如何验证最终用户,以及新架构微服务如何验证最终用户。让我们将此演化中的增量称为“身份验证服务引入”。引入新服务的一种方法是首先执行以下步骤
(1) 构建身份验证服务,实现 OAuth 2.0 协议。
(2) 在单体后端添加一个新的身份验证路径,以调用身份验证服务来验证代表其处理请求的最终用户。
如果团队止步于此,并转向构建其他服务或功能,他们会将整个架构置于熵增加的状态。在这种状态下,存在两种验证用户的方法:新的 OAuth 2.0 基路径和旧客户端的密码/会话基于路径。此时,团队实际上离他们加快更改速度的总体目标更远了。任何新加入单体代码的开发人员都需要处理两个代码路径,增加理解代码的认知负荷,以及减慢更改和测试它的过程。
相反,团队可以在我们的原子演化单元中包含以下步骤
(3) 用 OAuth 2.0 路径替换旧客户端的密码/会话基于身份验证
(4) 从单体中弃用旧的身份验证代码路径
此时,我们可以认为团队已经更接近目标架构了。
图 9:通过原子步骤演进架构,逐步向微服务迁移,在每个步骤之后,整体架构都会朝着目标方向改进,即使中间代码变更可能使其偏离适应性目标。
单体分解的原子单元包括:
- 解耦新服务
- 将所有消费者重定向到新服务
- 在单体中移除旧代码路径。
反模式:解耦新服务,用于新消费者,但从未移除旧代码路径。
我经常发现团队在将功能从单体中迁移出来后,一旦新功能构建完成,就会宣告胜利,而不会移除旧代码路径,这就是上面描述的反模式。造成这种情况的主要原因是 (a) 关注引入新功能的短期利益,以及 (b) 面临构建新功能的竞争优先级时,移除旧实现所需的总工作量。为了做正确的事情,我们需要努力使原子步骤尽可能小。
使用这种方法迁移,我们可以将旅程分解成更短的行程。我们可以安全地停下来,恢复并生存下来,完成这场漫长的旅程,最终战胜单体。
重大修订
2018 年 4 月 24 日:首次发布