分解复杂的应用程序

到目前为止,本章中我们的分析主要集中在比例立方体的 X 轴上。 我们看到它如何代表分配负载和扩展应用程序的最简单、最直接的方法,同时还提高了其可用性。 在下一节中,我们将重点关注缩放立方体的 Y 轴,其中应用程序通过按功能和服务分解来缩放。 正如我们将了解到的,这种技术不仅使我们能够扩展应用程序的容量,而且最重要的是,能够扩展其复杂性。

整体架构

“整体”一词可能会让我们想到一个没有模块化的系统,其中应用程序的所有服务都是互连的并且几乎无法区分。 然而,这并非总是如此。 通常,单体系统具有高度模块化的架构,并且其内部组件之间具有良好的解耦水平。

一个完美的例子是 Linux 操作系统内核,它属于称为整体内核的类别的一部分(与其生态系统和 Unix 哲学完全相反)。 Linux 有数千个服务和模块,我们可以动态加载和卸载,甚至在系统运行时也是如此。 然而,它们都在内核模式下运行,这意味着其中任何一个出现故障都可能导致整个操作系统崩溃(您见过内核恐慌吗?)。 这种方式与微内核架构相反,微内核架构中只有操作系统的核心服务运行在内核模式下,而其余的服务则运行在用户模式下,通常每个服务都有自己的进程。 这种方法的主要优点是,这些服务中的任何一个出现问题都更有可能导致其单独崩溃,而不是影响整个系统的稳定性。

Torvalds-Tanenbaum 关于内核设计的争论可能是计算机科学史上最著名的口水战之一,其中争论的要点之一正是整体设计与微内核设计。 您可以在 nodejsdp.link/torvalds-tanenbaum 上找到该讨论的网络版本(最初出现在 Usenet 上)。

值得注意的是,这些已有 30 多年历史的设计原则今天仍然可以应用于完全不同的环境中。 现代单体应用程序与单体内核相当:如果其中任何组件发生故障,整个系统都会受到影响,这用 Node.js 术语来说,意味着所有服务都是同一代码库的一部分,并在单个进程中运行( 未克隆时)。

图 12.9 显示了一个整体架构示例:

image 2024 05 08 13 04 55 941
Figure 1. 图 12.9:整体架构示例

图 12.9 显示了典型电子商务应用程序的架构。 它的结构是模块化的:我们有两个不同的前端,一个用于主商店,另一个用于管理界面。 在内部,我们对应用程序实现的服务进行了明确的分离。 每个服务负责应用程序业务逻辑的特定部分:产品、购物车、结账、搜索、身份验证和用户。 然而,前面的架构是整体式的,因为每个模块都是同一代码库的一部分,并且作为单个应用程序的一部分运行。 任何一个组件出现故障都可能导致整个在线商店崩溃。

这种架构的另一个问题是其模块之间的互连。 事实上,它们都位于同一个应用程序中,这使得开发人员可以轻松地在模块之间构建交互和耦合。 例如,考虑购买产品时的用例:Checkout 模块必须更新 Product 对象的可用性,如果这两个模块位于同一应用程序中,则开发人员很容易获取引用 到 Product 对象并直接更新其可用性。 在整体应用程序中保持内部模块之间的低耦合非常困难,部分原因是它们之间的界限并不总是清晰或正确执行。

高耦合通常是应用程序增长的主要障碍之一,并且在复杂性方面阻碍了其可扩展性。 事实上,复杂的依赖关系图意味着系统的每个部分都是一种责任,必须在产品的整个生命周期中进行维护,并且任何更改都应该仔细评估,因为每个组件就像叠叠乐塔中的木块 :移动或移除其中之一可能会导致整个塔倒塌。 这通常会导致建立惯例和开发流程来应对项目日益复杂的情况。

微服务架构

现在,我们将揭示 Node.js 中编写大型应用程序最重要的模式:避免编写大型应用程序。 这似乎是一个微不足道的陈述,但它是扩展软件系统的复杂性和容量的极其有效的策略。 那么,除了编写大型应用程序之外,还有什么其他选择呢? 答案就在尺度立方体的 Y 轴上:按服务和功能进行分解和分割。 这个想法是将应用程序分解为其基本组件,创建单独的、独立的应用程序。 它实际上与整体架构相反。 这完全符合我们在本书开头讨论的 Unix 哲学和 Node.js 原则; 特别是“让每个程序做好一件事”的座右铭。

如今,微服务架构是此类方法的主要参考模式,其中一组自给自足的服务取代了大型整体应用程序。 前缀 “micro” 意味着服务应该尽可能小,但始终在合理的范围内。 不要误以为创建一个包含一百个不同应用程序且仅公开一个 Web 服务的架构必然是一个不错的选择。 实际上,对于服务的大小并没有严格的规定。 在微服务架构的设计中,重要的不是大小,而是大小。 相反,它是不同因素的组合,主要是松耦合、高内聚和集成复杂性。

微服务架构的示例

现在让我们看看使用微服务架构的单体电子商务应用程序会是什么样子:

image 2024 05 08 13 06 36 095
Figure 2. 图 12.10:使用微服务模式的电子商务系统的示例实现

正如我们从图 12.10 中看到的,电子商务应用程序的每个基本组件现在都是一个自我维持和独立的实体,存在于自己的上下文中,拥有自己的数据库。 实际上,它们都是独立的应用程序,公开一组相关的服务。

服务的数据所有权是微服务架构的一个重要特征。 这就是为什么数据库也必须被拆分以保持适当级别的隔离和独立性的原因。 如果使用唯一的共享数据库,服务之间的协同工作将会变得更加容易; 然而,这也会引入服务之间的耦合(基于数据),从而抵消了拥有不同应用程序的一些优势。

连接所有节点的虚线告诉我们,它们必须以某种方式进行通信和交换信息,整个系统才能充分发挥作用。 由于服务不共享相同的数据库,因此需要更多的通信来维护整个系统的一致性。 例如,结帐服务需要了解产品的一些信息,例如价格和运输限制,同时,它需要在结帐完成时更新存储在产品服务中的数据,例如产品的可用性 。 在图 12.10 中,我们试图表示节点通用的通信方式。 当然,最流行的策略是使用 Web 服务,但正如我们稍后将看到的,这不是唯一的选择。

模式(微服务架构)

通过创建几个小型的、独立的服务来拆分复杂的应用程序。

微服务——优点和缺点

在本节中,我们将重点介绍实现微服务架构的一些优点和缺点。 正如我们将看到的,这种方法有望为我们开发应用程序的方式带来根本性的改变,彻底改变我们看待可扩展性和复杂性的方式,但另一方面,它也带来了新的重大挑战。

Martin Fowler 写了一篇关于微服务的精彩文章,您可以在 nodejsdp.link/microservices 找到该文章。

每一个服务是可扩展的

让每个服务都存在于自己的应用程序上下文中的主要技术优势是崩溃不会传播到整个系统。 目标是构建真正独立的服务,这些服务更小、更容易更改,甚至可以从头开始重建。 例如,如果我们的电子商务应用程序的结账服务由于严重错误而突然崩溃,系统的其余部分将继续正常工作。 某些功能可能会受到影响; 例如,购买产品的能力,但系统的其余部分将继续工作。

另外,想象一下,如果我们突然意识到用于实现组件的数据库或编程语言不是一个好的设计决策。 在单体应用程序中,我们几乎无法在不影响整个系统的情况下进行更改。 相反,在微服务架构中,我们可以更轻松地使用不同的数据库或平台从头开始重新实现整个服务,并且系统的其余部分甚至不会注意到它,只要新的实现与其余部分保持相同的接口 系统的。

跨平台和语言的可重用性

将大型整体应用程序拆分为许多小型服务使我们能够创建可以更轻松地重用的独立单元。 Elasticsearch (nodejsdp.link/elasticsearch) 是可重用搜索服务的一个很好的例子。 ORY(nodejsdp.link/ory)是可重用开源技术的另一个例子,它提供了完整的身份验证和授权服务,可以轻松集成到微服务架构中。

微服务方法的主要优点是,与单体应用程序相比,信息隐藏级别通常要高得多。 这是可能的,因为交互通常通过 Web API 或消息代理等远程接口进行,这使得隐藏实现细节并保护客户端免受服务实现或部署方式变化的影响变得更加容易。 例如,如果我们所要做的就是调用 Web 服务,那么我们就无法了解背后基础设施的扩展方式、它使用的编程语言、它使用什么数据库来存储数据等等。 所有这些决策都可以根据需要重新审视和调整,而可能不会对系统的其余部分产生影响。

一种扩展应用程序的方法

回到缩放立方体,很明显微服务相当于沿 Y 轴缩放应用程序,因此它已经是跨多台机器分配负载的解决方案。 此外,我们不应忘记,我们可以将微服务与立方体的其他两个维度相结合,以进一步扩展应用程序。 例如,可以克隆每个服务以处理更多流量,有趣的是它们可以独立扩展,从而实现更好的资源管理。

在这一点上,微服务似乎可以解决我们所有的问题。 然而,事实并非如此。 让我们看看使用微服务所面临的挑战。

微服务的挑战

管理更多的节点在集成、部署和代码共享方面带来了更高的复杂性:它解决了传统架构的一些难题,但也带来了许多新问题。 我们如何使服务交互? 我们如何在部署、扩展和监控如此大量的应用程序时保持理智? 我们如何在服务之间共享和重用代码?

幸运的是,云服务和现代 DevOps 方法可以为这些问题提供一些答案,而且使用 Node.js 也能提供很大帮助。 它的模块系统是在不同项目之间共享代码的完美伴侣。 Node.js 被设计为分布式系统(例如微服务架构的系统)中的节点。

在以下部分中,我们将介绍一些集成模式,这些模式可以帮助管理和集成微服务架构中的服务。

微服务架构中的集成模式

微服务最严峻的挑战之一是连接所有节点以使它们协作。 例如,如果没有添加一些产品,我们的电子商务应用程序的购物车服务将毫无意义;如果没有要购买的产品列表(购物车),结账服务将毫无用处。 正如我们已经提到的,还有其他因素需要各种服务之间的交互。 例如,搜索服务必须知道哪些产品可用,并且还必须确保其信息保持最新。 Checkout 服务也是如此,它必须在购买完成后更新有关产品可用性的信息。

在设计集成策略时,考虑它将在系统中的服务之间引入的耦合也很重要。 我们不应该忘记,设计分布式架构涉及我们在设计模块或子系统时在本地使用的相同实践和原则。 因此,我们还需要考虑服务的可重用性、可扩展性等属性。

API 代理

我们要展示的第一个模式使用 API 代理(通常也称为 API 网关),它是代理客户端和一组远程 API 之间通信的服务器。 在微服务架构中,其主要目的是为多个 API 端点提供单一访问点,但它还可以提供负载平衡、缓存、身份验证和流量限制,所有这些功能都被证明对于实现微服务非常有用。 可靠的 API 解决方案。

这种模式对我们来说并不陌生,因为当我们使用 http-proxy 和 consul 构建自定义负载均衡器时,我们已经在本章中看到了它的实际应用。 对于该示例,我们的负载均衡器仅公开两个服务,然后,借助服务注册表,它能够将 URL 路径映射到服务,从而映射到服务器列表。 API 代理的工作方式相同; 它本质上是一个反向代理,通常也是一个负载均衡器,专门配置为处理 API 请求。 图 12.11 显示了我们如何将这样的解决方案应用到我们的电子商务应用程序中:

image 2024 05 08 13 13 48 195
Figure 3. 图 12.11:在电子商务应用程序中使用 API 代理模式

从上图中,应该清楚 API 代理如何隐藏其底层基础设施的复杂性。 这在微服务基础设施中非常方便,因为节点数量可能很高,特别是当每个服务跨多台机器扩展时。 因此,通过 API 代理实现的集成只是结构性的,因为没有语义机制。 它只是提供了复杂的微服务基础设施的熟悉的整体视图。

由于 API 代理模式本质上抽象了连接到系统中所有不同 API 的复杂性,因此它还可能允许自由地重组各种服务。 也许,随着您的需求发生变化,您需要将现有的微服务拆分为两个或多个解耦的微服务,或者相反,您可能会意识到,在您的业务上下文中,最好将两个或多个服务连接在一起。 在这两种情况下,API 代理模式将允许您进行所有必要的更改,而不会影响通过代理访问数据的上游系统。

随着时间的推移,在架构中实现增量更改的能力是现代分布式系统的一个非常重要的特征。 如果您有兴趣更深入地研究这个广泛的主题,我们推荐《构建进化架构》一书:nodejsdp.link/evolutionary-architectures。

API编排

接下来我们要描述的模式可能是集成和组合一组服务的最自然、最明确的方式,它被称为 API 编排。 Netflix API 工程副总裁 Daniel Jacobson 在他的一篇博客文章 (nodejsdp.link/orchestration-layer) 中将 API 编排定义如下:

“API 编排层 (OL) 是一个抽象层,它采用通用建模的数据元素和/或功能,并以更具体的方式为目标开发人员或应用程序准备它们。”

“通用建模元素和/或特征” 完美地符合微服务架构中服务的描述。 这个想法是创建一个抽象来连接这些点和块,以实现特定于特定应用程序的新服务。

让我们看一个使用电子商务应用程序的示例。 参见图12.12:

image 2024 05 08 13 16 20 083
Figure 4. 图 12.12:编排层与多个微服务交互的示例用法

图 12.12 显示了商店前端应用程序如何使用编排层通过组合和编排现有服务来构建更复杂和更具体的功能。 所描述的场景以假设的 completeCheckout() 服务为例,该服务在客户在结账结束时单击 “支付” 按钮时被调用。

该图显示了 completeCheckout() 是如何由三个不同步骤组成的复合操作:

  1. 首先,我们通过调用 checkoutService/pay 来完成交易。

  2. 然后,当付款成功处理后,我们需要告诉购物车服务该商品已购买并且可以从购物车中删除。 我们通过调用 cartService/delete 来做到这一点。

  3. 另外,当付款完成后,我们需要更新刚刚购买的产品的可用性。 这是通过 productsService/update 完成的。

正如我们所看到的,我们从三个不同的服务中获取了三个操作,并构建了一个新的 API 来协调服务以维持整个系统处于一致状态。

API 编排层执行的另一个常见操作是数据聚合,或者换句话说,将来自不同服务的数据组合成单个响应。 想象一下,我们想要列出购物车中包含的所有产品。 在这种情况下,编排需要从 Cart 服务检索产品 ID 列表,然后从 Products 服务检索有关产品的完整信息。 我们组合和协调服务的方式是无限的,但要记住的重要模式是编排层的角色,它充当多个服务和特定应用程序之间的抽象。

编排层是进一步功能拆分的绝佳候选者。 事实上,将其实现为专用的独立服务是很常见的,在这种情况下,它采用 API Orchestrator 的名称。 这种做法完全符合微服务理念。

图 12.13 显示了我们架构的进一步改进:

image 2024 05 08 13 19 12 930
Figure 5. 图 12.13:API Orchestrator 模式在电子商务示例中的应用

如上图所示,创建独立的编排器可以帮助将客户端应用程序(在我们的示例中为应用商店前端)与微服务基础设施的复杂性解耦。 这与 API 代理类似,但有一个关键的区别:编排器执行各种服务的语义集成,它不仅仅是一个简单的代理,而且它经常公开与底层服务公开的 API 不同的 API 。

与消息代理集成

Orchestrator 模式为我们提供了一种以显式方式集成各种服务的机制。 这既有优点也有缺点。 它易于设计、易于调试、易于扩展,但不幸的是,它必须对底层架构以及每个服务如何工作有完整的了解。 如果我们谈论的是对象而不是架构节点,那么编排器将是一种称为“上帝对象”的反模式,它定义了一个知道并做太多事情的对象,这通常会导致高耦合、低内聚,但最重要的是,高复杂性。

我们现在要展示的模式尝试在服务之间分配同步整个系统信息的责任。 然而,我们最不想做的就是在服务之间创建直接关系,这会导致高耦合,并且由于节点之间互连数量的增加而进一步增加系统的复杂性。 目标是保持每个服务解耦:每个服务都应该能够工作,即使没有系统中的其余服务或与新服务和节点相结合。

解决方案是使用消息代理,这是一个能够将消息发送者与接收者解耦的系统,使我们能够实现集中式发布/订阅模式。 实际上,这是分布式系统的观察者模式的实现。 我们将在第 13 章消息传递和集成模式中详细讨论此模式。 图 12.14 显示了如何将其应用于电子商务应用程序的示例:

image 2024 05 08 13 21 26 136
Figure 6. 图 12.14:使用消息代理在我们的电子商务应用程序中分发事件

从图 12.14 中我们可以看到,Checkout 服务的客户端,即前端应用程序,不需要与其他服务进行任何显式集成。

它所要做的就是调用 checkoutService/pay 来完成结帐过程并从客户那里取钱; 所有集成工作都在后台进行:

  1. Store 前端调用 Checkout 服务上的 checkoutService/pay 操作。

  2. 操作完成后,Checkout 服务会生成一个事件,附加操作的详细信息,即 cartId 和刚刚购买的产品列表。 该事件被发布到消息代理中。 此时,Checkout 服务不知道谁将接收该消息。

  3. Cart 服务订阅了经纪人,因此它将接收 Checkout 服务刚刚发布的购买事件。 购物车服务的反应是从其数据库中删除用消息中包含的 ID 标识的购物车。

  4. 产品服务也订阅了消息代理,因此它接收相同的购买事件。 然后,它根据这些新信息更新数据库,调整消息中包含的产品的可用性。

整个过程的发生无需外部实体(例如协调器)的任何显式干预。 传播知识和保持信息同步的责任分布在各个服务本身中。 没有上帝服务必须知道如何移动整个系统的齿轮,因为每个服务都负责自己的集成部分。

消息代理是用于解耦服务并降低其交互复杂性的基本元素。 它还可能提供其他有趣的功能,例如持久消息队列和保证消息的排序。 我们将在下一章详细讨论这一点。