你很有可能正在处理大型复杂的单体应用程序,每天开发和部署应用程序的经历都很缓慢而且很痛苦。微服务看起来非常适合你的应用程序,但它也更像是一项遥不可及的必杀技。如何才能走上微服务架构的道路?下面将介绍一些策略,帮你摆脱单体地狱,而无须从头开始重写你的应用程序。
通过开发所谓的绞杀者应用程序(strangler application),可以逐步将单体架构转换为微服务架构。绞杀者应用程序的想法来自绞杀式藤蔓,这些藤蔓在雨林中生长,它们包围绕树木生成,甚至有时会杀死树木。绞杀者应用程序是一个由微服务组成的新应用程序,通过将新功能作为服务,并逐步从单体应用程序中提取服务来实现。随着时间的推移,当绞杀者应用程序实现越来越多的功能时,它会缩小并最终消灭单体应用程序。开发绞杀者应用程序的一个重要好处是,与宇宙大爆炸式的彻底重写不同,它可以立刻落地,更快为企业提供价值。
有三种主要策略可以实现对单体的“绞杀”,并逐步用微服务替换之:
1) 将新功能实现为服务。 2)隔隔表现层和后端。 3) 通过将功能提取到服务中来分解单体。
***种策略阻止了单体的发展。它通常是一种快速展示微服务价值的方法,有助于让迁移和重构的工作获得公司内部各个层面支持。另外两种策略打破了单体。在重构单体时,你有时可能会使用第二种策略,但你肯定会使用第三种策略,因为它能实现将功能从单体迁移到绞杀者应用程序中。
下面让我们来看一看这些策略。
1.将新功能实现为服务
“挖坑法则”(The Law of Holes)指出:如果你发现自己已经陷入了困境,就不要再给自己继续挖坑了。当你的单体应用变得无法管理时,这是一个很好的可供参考的建议。换句话说,如果你有一个庞大的、复杂的单体应用程序,请不要通过向单体添加代码来实现新功能。这将使你的单体变得更庞大,更难以管理。相反,你应该将新功能实现为服务。
这是开始将单体应用程序迁移到微服务架构的好方法。它降低了单体的生长速度,加速了新功能的开发(因为是在全新的代码库中进行开发),还能快速展示采用微服务架构的价值。
把新的服务与单体集成
图 1显示了将新功能实现为服务后的应用程序架构。除了新服务和单体外,该架构还包括另外两个将服务集成到应用程序中的元素:
■ API Gateway:将对新功能的请求路由到新服务,并将遗留请求路由到单体。
■ 集成胶水代码:将服务与单体结合。它使服务能够访问单体所拥有的数据,并能够调用单体实现的功能。
集成胶水的代码不是一个独立组件。相反,它由单体中的适配器和使用一个或多个进程间通信机制的服务组成。
何时把新功能实现为服务
理想情况下,你应该在绞杀者应用程序中而不是在单体中实现每个新功能。你将实现新功能作为新服务或作为现有服务的一部分。这样你就可以避免和单体代码库打交道。不幸的是,并非每个新功能都可以作为服务实现。
因为微服务架构的本质是一组围绕业务功能组织的松耦合服务。例如,某个功能可能太小而无法成为有意义的服务。例如,你可能只需要向现有类添加一些字段和方法。或者新功能可能与单体中的代码紧耦合。如果你尝试将此类功能实现为服务,则通常会发现,由于过多的进程间通信而导致性能下降。你可能还会遇到数据一致性的问题。如果新功能无法作为服务实现,则解决方案通常是首先在单体中实现新功能。之后,你可以将该功能以及其他相关功能提取到自己的服务中。
以服务的方式实现新功能,可以加速这些功能的开发。这是快速展示微服务架构价值的好方法。它还能够降低单体的增长速度。但最终,你需要使用另外两种策略来分解单体。你需要通过将单体中的功能提取到服务,从而将单体中的功能迁移到绞杀者应用程序。你也可以通过水平分割单体架构来提高开发速度。我们来看看如何做到这一点。
2.隔离表现层与后端
缩小单体应用程序的一个策略是将表现层与业务逻辑和数据访问层分开。典型的企业应用程序包含以下各层:
■ 表现逻辑层:它由处理 HTTP 请求的模块组成,并生成实现 Web UI 的 HTML 页面。在具有复杂用户界面的应用程序中,表现层通常包含大量代码。
■ 业务逻辑层:由实现业务规则的模块组成,这些模块在企业应用程序中可能很复杂。
■ 数据访问逻辑层:包含访问基础设施服务(如数据库和消息代理)的模块。 表现逻辑层与业务和数据访问逻辑层之间通常存在清晰的边界。业务层具有粗粒度 API,由一个或多个封装业务逻辑的门面(Facade)组成。这个 API 是一个自然的接缝,你可以沿着它将单体分成两个较小的应用程序,如图 2 所示。
一个应用程序包含表现层,另一个包含业务和数据访问逻辑层。分割后,表现逻辑应用程序对业务逻辑应用程序进行远程调用。
以这种方式拆分单体应用有两个主要好处。它使你能够彼此独立地开发、部署和扩展这两个应用程序。特别是,它允许表现层开发人员快速迭代用户界面并轻松执行A/B测试,而无须部署后端。这种方法的另一个好处是它公开了业务逻辑的一组远程API,可以被稍后开发的微服务调用。
但这种策略只是部分解决方案。很可能至少有一个或两个最终的应用程序仍然是一个难以管理的单体。你需要使用第三种策略将单体替换为服务。
3.提取业务能力到服务中
将新功能实现为服务,并从后端拆分出前端Web应用程序并不会让你抵达胜利的彼岸。你仍将最终在单体代码中进行大量开发。如果你希望显著改进应用程序的架构并提高开发速度,则需要通过逐步将业务功能从单体迁移到服务来拆分单体应用。当你使用此策略时,随着时间推移,服务实现的业务功能数量会增加,而单体会逐渐缩小。
你想要提取到服务中的功能是对单体应用自上而下的一个“垂直切片”。该切片包含以下内容:
■ 实现API端点的入站适配器。
■ 领域逻辑。
■ 出站适配器,例如数据库访问逻辑。
■ 单体的数据库模式。
如图 3 所示,此代码从单体中提取并移至独立服务中。API Gateway 将调用提取的业务功能的请求路由到该服务,并将其他请求路由到单体。单体和服务通过集成胶水代码进行协作。集成胶水由服务中的适配器和使用一个或多个进程间通信机制的单体组成。
提取服务具有挑战性。你需要确定如何将单体的领域模型分成两个独立的领域模型,其中一个模型成为服务的领域模型。你需要打破对象引用等依赖。你甚至可能需要拆分类,以将功能移动到服务中。对了,你还需要重构数据库。
提取服务通常很耗时,尤其是当单体的代码库很混乱时。因此,你需要仔细考虑要提取的服务。应当重点关注重构那些能够提供很多价值的应用程序部分。在提取服务之前,问问自己这样做的好处是什么。
例如,提取一项实现对业务至关重要且不断发展的功能的服务是值得的。如果没有太多的好处,那么在提取服务方面投入精力是没有价值的。在本节的后面部分,我将介绍一些用于确定服务提取范围和时间的策略。但首先让我们更详细地了解一下在提取服务时将面临的一些挑战以及解决这些挑战的方法。
提取服务时会遇到以下这些挑战:
■ 拆解领域模型。
■ 重构数据库。
拆解领域模型
为了提取服务,你需要从单体的领域模型中提取服务相关的领域模型。你需要进行大动作来拆分领域模型。你将遇到的一个挑战是消除跨越服务边界的对象引用。保留在单体中的类可能会引用已移动到服务的类,反之亦然。例如,想象一下,如图 4 所示,你提取了Order Service,其Order类引用了单体的Restaurant类。因为服务实例通常是一个进程,所以让对象引用跨越服务边界是没有意义的。你需要消除这种类型的对象引用。
解决此问题的一个好方法是根据DDD聚合进行思考。聚合使用主键而不是对象引用相互引用。因此,你可以将 Order 和 Restaurant 类视为聚合,如图5所示,将Order类中对 Restaurant 的引用替换为存储主键值的restaurantId 字段。
使用主键替换对象引用的一个问题是,虽然这是对类的一个小改动,但它可能会对期望对象引用的类的客户端产生很大的影响。在本节的后面部分,我将介绍如何通过在服务和单体之间复制数据来减少更改的范围。例如,Delivery Service可以定义一个Restaurant类,后者是单体中Restaurant 类的复制品。
提取服务通常比将整个类移动到服务中的工作量要大得多。拆分领域模型面临的更大挑战是提取嵌入在具有其他职责的类中的功能。这个问题经常出现在具有过多职责的上帝类(God Class)中。例如,Order 类是FTGO应用程序中的上帝类之一。它实现了多种业务功能,包括订单管理、送餐管理等。Delivery 实体会实现之前与Order类中的其他功能捆绑在一起的送餐管理功能。
重构数据库
拆分领域模型不仅仅涉及更改代码。领域模型中的许多类都是在数据库中持久化保存的。它们的字段映射到具体的数据库模式。因此,当你从单体中提取服务时,你也会移动数据。你需要将表从单体的数据库移动到服务的数据库。
此外,拆分实体时,需要拆分相应的数据库表并将新表移动到服务中。例如,在将送餐管理提取到服务中时,你需要拆分Order实体并提取出一个Delivery实体。在数据库级别,你要拆分ORDERS表并定义新的DELIVERY表。然后,将DELIVERY表移动到该服务。
复制数据以避免更广泛的更改
如上所述,提取服务需要你对单体的领域模型做出更改。例如,使用主键和拆分类替换对象引用。这些类型的更改可能会影响代码库,并要求你对单体各个受影响的部分进行广泛的更改。例如,如果拆分Order实体并提取Delivery实体,则必须更改代码中引用被移动字段而受影响的每个部分。进行这些改变可能会非常耗时,并且可能成为打破单体的巨大障碍。
延迟并可能避免进行这些昂贵更改的一种好方法是使用类似于《数据库重构》一书中描述的方法。重构数据库的一个主要障碍是更改该数据库的所有客户端以使用新模式。本书中提出的解决方案是在过渡期内保留原模式,并使用触发器在原模式和新模式间同步。然后,你可以将客户端从旧模式迁移到新模式。
从单体中提取服务时,我们可以使用类似的方法。例如,在提取Delivery实体时,我们将Order实体在过渡期内大部分保持不变。如图6所示,我们将与交付相关的字段设置为只读,并通过将数据从Delivery Service复制回单体来使其保持***。因此,我们只需要在单体的代码中找到更新这些字段的位置,并更改它们为调用新的Delivery Service即可。
通过从Delivery Service复制数据来保留Order实体的结构,可以显著减少我们需要立即完成的工作量。随着时间的推移,我们可以将使用与交付相关的Order实体字段或ORDERS表列的代码迁移到Delivery Service。更重要的是,我们可能永远不需要在单体中做出改变。如果随后将该代码提取到服务中,则该服务可以访问DeliveryService。
确定提取何种服务以及何时提取
正如我所提到的,拆解单体是耗时的。它分散了实施新功能的人力资源。因此,你必须仔细确定提取服务的顺序。你需要专注于提取能够带来***收益的服务。更重要的是,你希望不断向业务部门展示迁移到微服务架构的价值。
在任何旅程中,了解你要去的地方至关重要。开始迁移到微服务的好方法是使用时间框架来定义工作。你应该花费很短的时间,例如几周,集思广益讨论理想架构并定义一组服务。这将为你提供一个目标。但是,重要的是要记住,这种架构并非一成不变。当你分解单体并获得经验后,你应该应用你所获得的经验对重构计划及时做出调整。
一旦确定了目标,下一步就是开始拆分单体结构。可以使用几种不同的策略来确定提取服务的顺序。
一种策略是有效地冻结单体架构的开发并按需提取服务。你可以提取必要的服务并进行更改,而不是在单体中实现功能或修复错误。这种方法的一个好处是它会迫使你打破单体。一个弊端是服务的提取是由短期需求而不是长期需求驱动的。例如,即使你对系统中相对稳定的部分进行了少量更改,也需要你提取服务。因此,你做的大量工作可能只能换来较小的收益。
另一种策略是更有计划的方法,你可以根据提取应用程序模块获得的预期收益,对应用程序的模块进行排名。提取服务有益的原因有以下几点:
■ 加速开发:如果你的应用程序的路线图表明应用程序的特定部分将在明年进行大量开发,那么将其转换为服务可加速开发。
■ 解决性能、可扩展性或可靠性问题:如果应用程序的特定部分存在性能、可扩展性问题或不可靠,那么将其转换为服务是有价值的。
■ 允许提取其他一些服务:由于模块之间的依赖关系,有时提取一个服务会简化另一个服务的提取。 你可以使用这些条件将重构任务添加到应用程序的“待办事项”中,并按预期收益排名。这种方法的好处在于它更具战略性,并且更符合业务需求。在做 Sprint 的计划时,你可以确定实现功能或提取服务哪个更有价值。