TypeScript-微服务-全-

TypeScript 微服务(全)

原文:zh.annas-archive.org/md5/042BAEB717E2AD21939B4257A0F75F63

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在过去的几年中,微服务已经成为摇滚明星,并且现在是企业中最切实可行的解决方案之一,用于快速、有效和可扩展的应用程序。微服务包括一种架构风格和模式,其中一个庞大的系统被分解为更小的服务,这些服务是自给自足的、自治的、自包含的,并且可以单独部署。

TypeScript 的明显崛起和从 ES5 到 ES6 的长期演变已经看到许多大公司转向 ES6 堆栈。由于其支持类和模块、静态类型检查和与 JavaScript 类似的语法等巨大优势,TypeScript 已成为许多企业的事实解决方案。由于其异步、非阻塞、轻量级的特性,Node.js 已被许多公司广泛任命。用 TypeScript 编写的 Node.js 为各种机会打开了大门。

然而,微服务也有自己的困难需要解决,比如监控、扩展、分发和服务发现。最大的挑战是规模化部署,因为我们不希望最终出现系统故障。在不了解或解决这些问题的情况下采用微服务将导致重大问题。本书最重要的部分涉及采用实用的技术独立方法来处理微服务,以便充分利用一切。

这本书分为三个部分,解释了这些服务是如何工作的,以及构建任何应用程序的微服务方式的过程。您将遇到基于设计的架构方法,以及实施各种微服务元素的指导。您将获得一组配方和实践,以满足实际、组织和文化挑战。这本书的目标是让用户了解一种实用的、逐步的方法,以实现规模化的反应式微服务。本书将带领读者深入了解 Node.js、TypeScript、Docker、Netflix OSS 等内容。本书的读者将了解如何使用 Node.js 和 TypeScript 部署可以独立运行的服务。用户将了解无服务器计算的不断发展趋势,以及不同的 Node.js 功能,实现 Docker 进行容器化的用途。用户将学习如何使用 Kubernetes 和 AWS 对系统进行自动扩展。

我相信读者会喜欢本书的每一部分。此外,我相信这本书不仅对 Node.js 开发人员有价值,对于其他想要尝试微服务并成功在业务中实施它们的人也有帮助。在整本书中,我采取了实用的方法,提供了许多例子,包括来自电子商务领域的案例研究。在本书结束时,您将学会如何使用 Node.js、TypeScript 框架和其他实用工具实现微服务架构。这些是经过实战考验的、强大的工具,用于开发任何微服务,并按照 Node.js 的最新规范编写。

这本书适合谁?

这本书适合寻求利用他们的 Node.js 和 TypeScript 技能构建微服务并摆脱单片式架构风格的 JavaScript 开发人员。假定读者具有 TypeScript 和 Node.js 的先验知识。本书将帮助回答一些关于微服务解决了什么问题以及组织如何结构化采用微服务的重要问题。

本书涵盖的内容

第一章《揭秘微服务》为您介绍了微服务的世界。它从单块到微服务架构的转变开始。本章使您熟悉了微服务世界的演变;回答了关于微服务经常被问到的问题,并让您熟悉了各种微服务设计方面、微服务的十二要素应用;以及微服务实现的各种设计模式,以及它们的优缺点,何时使用以及何时不使用它们。

第二章《为旅程做准备》介绍了 Node.js 和 TypeScript 中必要的概念。它从准备我们的开发环境开始。然后讨论了基本的 TypeScript 要点,比如类型、tsconfig.json、为任何节点模块编写自定义类型以及 Definitely Typed 存储库。然后,我们转向 Node.js,在那里我们用 TypeScript 编写我们的应用程序。我们将学习一些基本要点,比如 Node 集群、事件循环、多线程和 async/await。然后,我们转向编写我们的第一个 hello world TypeScript 微服务。

第三章《探索响应式编程》进入了响应式编程的世界。它探讨了响应式编程的概念以及其方法和优势。它解释了响应式编程与传统方法的不同之处,然后转向使用 Highland.js、Bacon.js 和 RxJS 的实际示例。最后,它比较了这三个库以及它们的优缺点。

第四章《开始您的微服务之旅》开始了我们的微服务案例研究——购物车微服务。它从系统的功能需求开始,然后是整体业务能力。我们从架构方面、设计方面和生态系统中的整体组件开始这一章,然后转向微服务的数据层,我们将深入讨论数据类型以及数据库层应该是什么样的。然后,我们以关注点分离的方式开发微服务,并学习一些微服务的最佳实践。

第五章《理解 API 网关》探讨了 API 网关涉及的设计。它解释了为什么需要 API 网关以及它的功能是什么。我们将探讨各种 API 网关设计以及其中的各种选项。我们将看看断路器以及它为何在客户端弹性模式中扮演重要角色。

第六章《服务注册表和发现》讨论了在您的微服务生态系统中引入服务注册表。服务的数量和/或位置可能会根据负载和流量而变化。固定的位置会扰乱微服务的原则。本章涉及解决方案,并深入讨论了服务发现和注册表模式。本章进一步解释了各种可用选项,并详细讨论了 Consul 和 Eureka。

第七章《服务状态和服务间通信》着重于服务间通信。微服务需要相互合作以实现业务能力。本章探讨了各种通信模式。然后讨论了包括 RPC 和 HTTP 2.0 协议在内的下一代通信样式。我们了解了服务状态以及状态可以持久化的位置。我们将介绍各种数据库系统以及它们的用例。本章探讨了缓存世界、在依赖项之间共享代码以及版本控制策略,并详细介绍了如何使用客户端弹性模式来处理故障。

第八章,《测试、调试和文档编制》,概述了开发之后的生活。我们学习如何编写测试用例,并通过一些著名的工具集深入了解 BDD 和 TDD 的世界。我们看到了微服务的合同测试——一种确保没有重大变化的方法。然后我们看到了调试以及如何对我们的服务进行性能分析以及我们可以使用的所有选项。我们继续进行文档编制,并了解文档编制的需求以及围绕 Swagger 的所有工具集,这是我们将使用的工具。

第九章,《部署、日志记录和监控》,涵盖了部署和其中涉及的各种选项。我们看到了一个构建流水线,并熟悉了持续集成和持续交付。我们详细了解了 Docker,并通过在微服务前面添加 Nginx 来将我们的应用程序 docker 化。我们继续进行日志记录,并了解了定制的、集中的日志记录解决方案的需求。最后,我们转向监控,并了解了各种可用的选项,如 keymetrics、Prometheus 和 Grafana。

第十章,《加固您的应用》,着眼于加固应用,解决安全性和可扩展性问题。它讨论了应该在部署时采取的安全最佳实践,以避免任何意外事件。它提供了一个详细的安全检查表,可在部署时使用。然后我们将学习如何使用 AWS 和 Kubernetes 实现可扩展性。我们将看到如何通过自动添加或删除 EC2 实例来解决 AWS 的扩展问题。本章以 Kubernetes 及其示例结束。

附录,《Node.js 10.x 和 NPM 6.x 有什么新内容?》,涵盖了 Node.js v10.x 和 NPM v6.x 的新更新。附录不在书中,但可以通过以下链接下载:www.packtpub.com/sites/default/files/downloads/Whats_New_in_Node.js_10.x_and_NPM_6.x.pdf

我写这本书的目的是让这本书的主题与职业和业务用例相关、有用,并且最重要的是,专注于实用示例。我希望您阅读这本书时能像我写这本书时一样享受,这肯定会让您乐在其中!

为了充分利用本书

这本书需要在 Unix 机器上安装先决条件。在继续阅读本书之前,需要对 Node.js 和 TypeScript 有基本的了解。大部分代码在 Windows 上也可以运行,但安装方式不同;因此,建议使用 Linux(带有 Ubuntu 的 Oracle VM Box 也是一个完美的选择)。为了充分利用本书,尽快尝试练习示例并将概念应用到自己的示例中。这些程序或案例研究中的大多数都使用可以轻松安装或设置的开源软件。然而,有些情况确实需要设置一些付费设置。

在第九章,《部署、日志记录和监控》,我们需要在 logz.io 上拥有一个账户,以便准备完整的 ELK 设置,而不是单独管理它。试用版可用 30 天,但您可以延长一些计划。需要一个关键指标的账户来发掘其全部潜力。

在第十章,《加固您的应用》,您需要获取 AWS 账户以进行扩展和部署。您还需要获取 Google Cloud Platform,以独立测试 Kubernetes,而不是通过手动设置过程。这两个账户都有免费套餐,但您需要使用信用/借记卡进行注册。

下载示例代码文件

您可以从您在www.packtpub.com的账户中下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,文件将直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packtpub.com

  2. 选择“支持”选项卡。

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名并按照屏幕上的说明操作。

下载文件后,请确保使用以下最新版本的软件解压缩文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/TypeScript-Microservices。如果代码有更新,将在现有的 GitHub 存储库中更新。

我们还有来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/TypeScriptMicroservices_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。以下是一个例子:“在前面的表中可以找到所有运算符的示例,位于rx_combinations.tsrx_error_handing.tsrx_filtering.ts。”

代码块设置如下:

let asyncReq1=await axios.get('https://jsonplaceholder.typicode.com/posts/1');
console.log(asyncReq1.data);
let asyncReq2=await axios.get('https://jsonplaceholder.typicode.com/posts/1');
console.log(asyncReq2.data);

任何命令行输入或输出都将按照以下方式书写:

sudo dpkg -i <file>.deb
sudo apt-get install -f # Install dependencies

粗体:表示新术语、重要词或屏幕上看到的词。例如,菜单或对话框中的单词会在文本中以这种方式出现。以下是一个例子:“打开您的实例,然后转到负载均衡|负载均衡器选项卡。”

警告或重要提示会以这种方式出现。

提示和技巧会以这种方式出现。

第一章:揭秘微服务

“如果我问人们他们想要什么,他们会说更快的马。”

  • 亨利·福特

无论您是技术负责人、开发人员还是渴望适应新的现代网络标准的技术专家,上述内容都概括了您当前的生活状况。今天成功业务的口号是,快速失败,快速修复和迅速崛起,更快的交付,频繁的变化,适应不断变化的技术和容错系统是一些日常要求。出于同样的原因,最近技术世界已经看到了架构设计的快速变化,这导致行业领袖(如 Netflix、Twitter、亚马逊等)放弃了单片应用程序,转向了微服务。在本章中,我们将揭秘微服务,研究它们的解剖学,并了解它们的概念、特点和优势。我们将了解微服务设计方面,并了解一些微服务设计模式。

在本章中,我们将讨论以下主题:

  • 揭秘微服务

  • 微服务的关键考虑因素

  • 微服务常见问题解答

  • 微服务如何满足应用程序的十二因素

  • 当前世界的微服务

  • 微服务设计方面

  • 微服务设计模式

揭秘微服务

微服务开发的核心思想是,如果应用程序被分解为更小的独立单元,每个组都能很好地执行其功能,那么构建和维护应用程序就变得简单。整体应用程序只是各个单元的总和。让我们开始揭秘微服务。

微服务的崛起

今天的世界正在呈指数级增长,并且需要一种能够满足以下问题的架构,这些问题使我们重新思考传统的架构模式,并催生了微服务。

根据需求选择多种语言

技术独立性的需求非常迫切。在任何时候,语言和采用率都会发生变化。像沃尔玛这样的公司已经放弃了 Java 堆栈,转向了 MEAN 堆栈。今天的现代应用程序不仅仅局限于网络界面,还需要移动和智能手表应用程序。因此,用一种语言编写所有内容根本不是可行的选择。我们需要一种架构或生态系统,可以让多种语言共存并相互通信。例如,我们可以在 Go、Node.js 和 Spring Boot 中暴露 REST API,一个网关作为前端的单一联系点。

易于处理所有权

今天的应用程序不仅包括单一的网络界面,还涉及到移动设备、智能手表和虚拟现实(VR)。将逻辑分离成单独的模块有助于控制每个团队拥有一个单独的单元。此外,多个事物应该能够并行运行,从而实现更快的交付。团队之间的依赖关系应该降低到零。追踪正确的人来解决问题并使系统重新运行需要微服务架构。

频繁的部署

应用程序需要不断发展,以适应不断发展的世界。当 Gmail 开始时,它只是一个简单的邮件工具,现在它已经发展成了更多。这些频繁的变化要求频繁的部署,以便最终用户甚至不知道新版本正在发布。通过分成更小的单元,团队可以处理频繁的部署和测试,并迅速将功能交付给客户。应该有优雅的退化,即快速失败并解决问题。

自我维护的开发单元

不同模块之间的紧密依赖很快就会影响整个应用程序。这就需要更小的独立单元,以便如果一个单元不可操作,整个应用程序也不会受到影响。

现在让我们深入了解微服务,它们的特点,优势以及在实施微服务架构时所面临的所有挑战。

什么是微服务?

微服务没有通用的定义。简单地说——微服务可以是任何操作块或单元,它可以非常有效地处理其单一责任

微服务是构建自主、自我维持、松耦合的业务能力的现代风格,这些能力汇总成一个整个系统。我们将深入了解微服务的原则和特征,微服务提供的好处,以及需要注意的潜在风险。

原则和特征

有一些原则和特征定义了微服务。任何微服务模式都可以通过这些要点进一步区分和解释。

没有单片模块

微服务只是满足单个操作业务需求的另一个新项目。微服务与业务单元的变化相关联,因此必须松耦合。微服务应该能够持续满足不断变化的业务需求,而不受其他业务单元的影响。对于其他服务来说,只是一种消费方式,消费方式不应改变。实现可以在后台更改。

愚蠢的通信管道

微服务促进基本、经过时间考验的微服务之间的异步通信机制。根据这一原则,业务逻辑应保留在端点内,而不应与通信渠道混合在一起。通信渠道应该是愚蠢的,并且只在决定的通信协议中进行通信。HTTP 是一种受欢迎的通信协议,但更具反应性的方法——队列如今更为普遍。Apache KafkaRabbitMQ是一些普遍的愚蠢通信管道提供者。

去中心化或自我治理

在使用微服务时,经常会出现故障。一个应急计划最终可以阻止故障传播到整个系统。此外,每个微服务可能都有自己的数据存储需求。去中心化管理了这一需求。例如,在我们的购物模块中,我们可以将客户及其交易相关信息存储在 SQL 数据库中,但由于产品数据高度非结构化,我们将其存储在 NoSQL 相关数据库中。每个服务都应该能够在故障情况下做出决策。

服务合同和无状态性

微服务应通过服务合同进行明确定义。服务合同基本上提供了有关如何使用服务以及需要传递给该服务的所有参数的信息。SwaggerAutoRest是一些广泛采用的用于创建服务合同的框架。另一个显著的特征是微服务不存储任何东西,也不维护任何状态。如果需要持久化某些东西,那么它将被持久化在缓存数据库或某些数据存储中。

轻量级

微服务作为轻量级,有助于在任何托管环境中轻松复制设置。容器比虚拟化更受青睐。轻量级应用容器帮助我们保持较低的占用空间,从而将微服务绑定到某个上下文。设计良好的微服务应该只执行一个功能,并且执行得足够好。容器化的微服务易于移植,从而实现轻松的自动扩展。

多语种

在微服务架构中,服务 API 后面的一切都是抽象和未知的。在前面的购物车微服务示例中,我们可以将我们的支付网关完全作为云中部署的服务(无服务器架构),而其余服务可以使用 Node.js。内部实现完全隐藏在微服务后面,唯一需要关注的是通信协议在整个过程中应该是相同的。

现在,让我们看看微服务架构为我们提供了哪些优势。

微服务的优点

采用微服务有许多优势和好处。我们将看看在使用微服务时获得的好处和更高的商业价值。

自主团队

微服务架构使我们能够独立扩展任何操作,按需提供可用性,并在零到非常少的配置下非常快速地引入新服务。技术依赖也大大减少。例如,在我们的购物微服务架构中,库存和购物模块可以独立部署和处理。库存服务只会假设产品存在并相应地工作。只要库存和产品服务之间的通信协议得到满足,库存服务可以用任何语言编码。

服务的优雅降级

任何系统的故障都是自然的,优雅降级是微服务的一个关键优势。故障不会级联到整个系统。微服务设计成遵守约定的服务水平协议;如果服务水平协议未能达到,则服务将被丢弃。例如,回到我们的购物微服务示例,如果我们的支付网关宕机,那么对该服务的进一步请求将停止,直到服务恢复运行。

支持多语言体系结构和 DevOps

微服务根据需要利用资源或有效地创建多语言体系结构。例如,在购物微服务中,您可以将产品和客户数据存储在关系数据库中,但任何审计或日志相关数据都可以存储在 Elasticsearch 或 MongoDB 中。由于每个微服务都在其有界上下文中运行,这可以促进实验和创新。变更影响的成本将会非常低。微服务使得 DevOps 达到了全面水平。成功的微服务架构需要许多 DevOps 工具和技术。小型微服务易于自动化,易于测试,如果需要,易于污染故障,并且易于扩展。Docker 是容器化微服务的主要工具之一。

事件驱动架构

一个良好设计的微服务将支持异步事件驱动架构。事件驱动架构有助于追踪任何事件,每个动作都是任何事件的结果,我们可以利用任何事件来调试问题。微服务设计采用发布-订阅模式,这意味着添加任何其他服务只需订阅该事件即可。例如,您正在使用一个购物网站,有一个用于添加到购物车的服务。现在,我们想要添加新功能,以便每当产品添加到购物车时,库存应该更新。然后,可以准备一个只需订阅添加到购物车服务的库存服务。

现在,我们将研究微服务架构引入的复杂性。

微服务的不好和具有挑战性的部分

伟大的力量带来了更大的挑战。让我们看看设计微服务的具有挑战性的部分。

组织和编排

这是在适应微服务架构时面临的最大挑战之一。这更多是一个非功能性挑战,新的组织团队需要被组建,并且他们需要在采用微服务、敏捷和 Scrum 方法论方面得到指导。他们需要在这样的环境中进行模拟,以便能够独立工作。他们开发的结果应该以松耦合的方式集成到系统中,并且可以轻松扩展。

平台

创建完美的环境需要一个合适的团队,以及跨所有数据中心的可扩展的故障安全基础设施。选择正确的云服务提供商(AWSGCPAzure),添加自动化、可扩展性、高可用性,管理容器和微服务实例是一些关键考虑因素。此外,微服务还需要其他组件需求,如企业服务总线、文档数据库、缓存数据库等。在处理微服务时,维护这些组件成为了一个额外的任务。

测试

完全独立地测试具有依赖关系的服务是极具挑战性的。当微服务引入生态系统时,需要适当的治理和测试,否则它将成为系统的单点故障。任何微服务都需要多个级别的测试。它应该从服务能否访问横切关注点(缓存、安全、数据库、日志)开始。应该测试服务的功能,然后测试它将要进行通信的协议。接下来是与其他服务协同测试微服务。之后是可扩展性测试,然后是故障安全测试。

服务发现

在分布式环境中定位服务可能是一项繁琐的任务。不断变化和交付是当今不断发展的世界的迫切需求。在这种情况下,服务发现可能具有挑战性,因为我们希望团队独立并且团队之间的依赖最小化。服务发现应该是这样的,可以为微服务提供动态位置。服务的位置可能会根据部署、自动扩展或故障而不断变化。服务发现还应该密切关注已经停止或性能不佳的服务。

微服务示例

以下是我们将在整本书中实施的购物微服务的图表。正如我们所看到的,每个服务都是独立维护的,有独立的模块或较小的系统——计费模块客户模块产品模块供应商模块。为了与每个模块协调,我们有API 网关服务注册表。添加任何额外的服务变得非常容易,因为服务注册表将维护所有动态条目,并相应地进行更新。

采用微服务时的关键考虑因素

微服务架构引入了明确定义的边界,这使得在边界内隔离故障成为可能。但与其他分布式系统一样,应用级别可能存在故障的可能性。为了最小化影响,我们需要设计容错的微服务,对某些类型的故障有预定义的反应。在适应微服务架构时,我们增加了一个网络层来进行通信,而不是内存中的方法调用,这引入了额外的延迟和需要管理的另一个层。以下是一些需要在设计微服务时小心处理的考虑因素,这将对系统产生长期利益。

服务降级

微服务架构允许您隔离故障,从而使您能够隔离故障并获得优雅的降级,因为故障被包含在服务的边界内,不会被级联。例如,在社交网络网站上,消息服务可能会中断,但这不会阻止最终用户使用社交网络。他们仍然可以浏览帖子,分享状态,签到位置等。服务应该被制定以符合某些 SLA。如果微服务停止满足其 SLA,那么该服务应该被恢复备份。Netflix 的 Hystrix就是基于同样的原则。

适当的变更治理

在没有任何治理的情况下引入变化可能会是一个巨大的问题。在分布式系统中,服务相互依赖。因此,当您引入新变化时,应该给予最大的考虑,以确保不会引入任何副作用或不良影响,其影响应该是最小的。应该提供各种变更管理策略和自动部署选项。此外,代码管理中应该有适当的治理。开发应该通过 TDD 或 BDD 进行,只有在达成约定的百分比后才应该进行部署。发布应该逐渐进行。一个有用的策略是蓝绿红黑部署策略,其中您运行两个生产环境。您只在一个环境中部署变化,并在验证变化后将负载均衡器指向更新的版本。这在维护一个分级环境时更有可能。

健康检查、负载均衡和高效的网关路由

根据业务需求,微服务实例可能会在某些故障、内存不足、自动扩展等情况下启动、重新启动、停止,这可能会使其暂时或永久不可用。因此,架构和框架应相应设计。例如,Node.js 服务器是单线程的,在故障情况下会立即停止,但使用PM2等优雅的工具可以使其一直运行。应该引入一个网关,这将是微服务消费者的唯一联系点。网关可以是一个负载均衡器,应该跳过不健康的微服务实例。负载均衡器应该能够收集健康信息指标并相应地路由流量,它应该能够智能分析任何特定微服务上的流量,并在需要时触发自动扩展。

自愈

自愈设计可以帮助系统从灾难中恢复。微服务实现应该能够自动恢复丢失的服务和功能。诸如 Docker 之类的工具在服务失败时会重新启动服务。Netflix 提供了广泛的工具作为编排层来实现自愈。Eureka 服务注册表和 Hystrix 断路器是常用的。断路器使您的服务调用更具弹性。它们跟踪每个微服务端点的状态。每当遇到超时时,Hystrix 会断开连接,触发对该微服务的治疗需求,并恢复到一些安全策略。Kubernates是另一个选择。如果一个 pod 或者 pod 内的任何容器宕机,Kubernates 会启动系统并保持副本集完整。

故障转移缓存

故障转移缓存有助于在临时故障或一些故障时提供必要的数据。缓存层应设计得能够智能决定在正常情况下或故障转移情况下缓存可以使用多长时间。可以使用在 HTTP 中设置缓存标准响应头。max-age 头部指定资源被视为新鲜的时间。stale-if-error 头部确定资源应该从缓存中提供的时间。您还可以使用诸如MemcacheRedis等库。

重试直到

由于其自我修复能力,微服务通常可以在很短的时间内启动并运行。微服务架构应该具有重试逻辑直到条件的能力,因为我们可以预期服务将恢复,或者负载均衡器将将服务请求重定向到另一个健康的实例。频繁的重试也可能对系统产生巨大影响。一个常见的想法是在每次失败后增加重试之间的等待时间。微服务应该能够处理幂等性问题;比如说你正在重试购买订单,那么客户不应该出现重复购买。现在,让我们花点时间重新审视微服务的概念,并了解关于微服务架构的最常见问题。

微服务常见问题

在理解任何新术语时,我们经常会遇到一些问题。以下是我们在理解微服务时经常遇到的一些最常见的问题:

  • 微服务不就像面向服务的架构(SOA)吗?我不是已经有了吗?我应该何时开始?

如果你在软件行业工作了很长时间,那么看到微服务可能会让你想起 SOA。微服务确实从 SOA 中借鉴了模块化和基于消息的通信的概念,但它们之间有很多不同之处。虽然 SOA 更注重代码重用,微服务遵循“在自己的捆绑上下文中发挥作用”的规则。微服务更像是 SOA 的一个子集。微服务可以根据需求进行扩展。并非所有的微服务实现都相同。在医疗领域使用 Netflix 的实现可能是一个坏主意,因为医疗报告中的任何错误都可能值得一个人的生命。一个有效的微服务的简单答案可能是明确服务的操作目标,如果不能执行操作,则在失败时应该做什么。关于何时以及如何开始使用微服务,有各种不同的答案。Martin Fowler,微服务的先驱之一,建议从单体架构开始,然后逐渐转向微服务。但问题是——在这个技术创新时代,是否有足够的投资再次进行相同的阶段?简短的答案是早期使用微服务有巨大的好处,因为它将从一开始就解决所有问题。

  • 我们将如何处理所有的部分?谁负责?

微服务引入了本地化和自主规则。本地化意味着之前由中央团队完成的大量工作将不再由中央团队完成。拥抱自主规则意味着信任所有团队让他们自己做决定。这样,软件的更改甚至迁移变得非常容易和快速。话虽如此,并不意味着根本没有中央机构。随着更多的微服务,架构变得更加复杂。然后中央团队应该处理所有集中控制,如安全性、设计模式、框架、企业安全总线等。应该引入某些自我治理流程,如 SLA。每个微服务都应该遵守这些 SLA,系统设计应该聪明地设计,以便如果 SLA 未达到,那么微服务应该被丢弃。

  • 我如何引入变化或者如何开始微服务开发?

几乎所有成功的微服务故事都始于一个变得太大而无法管理并被分解的单体架构。突然改变架构的某个部分将产生巨大影响,应该逐渐引入一种“分而治之”的方式。考虑以下问题来决定要在单体架构中分解哪个部分——我的应用是如何构建和打包的?我的应用代码是如何编写的?我可以有不同的数据源,当我引入多个数据源时,我的应用将如何运行?——根据这些部分的答案,重构该部分并测量和观察该应用的性能。确保应用保持在其边界上下文中。另一个可以开始的部分是当前单体架构中性能最差的部分。发现阻碍变化的瓶颈对组织来说是有益的。引入集中化操作最终将允许多个事情并行运行,并使公司受益匪浅。

  • 需要什么样的工具和技术?

在设计微服务架构时,应该对任何特定阶段的技术或框架选择进行适当的思考。例如,微服务特性、云基础设施和容器的理想环境。容器提供了异构和易于移植或迁移的系统。使用 Docker 可以在微服务中按需提供弹性和可伸缩性。微服务的任何部分,如 API 网关或服务注册表,都应该是 API 友好的,适应动态变化,而不是单点故障。容器需要在服务器上进行开关,跟踪所有应用程序升级,为此需要适当的框架,如 Swarm 或 Kubernetes 来进行框架部署。最后,一些监控工具可以对所有微服务进行健康检查并采取必要的行动。Prometheus 就是这样一个著名的工具。

  • 如何管理微服务系统?

有很多并行服务开发正在进行,有一个集中的管理政策是一个原始的需求。我们不仅需要关注认证和服务器审计,还需要关注集中的问题,如安全性、日志记录、可伸缩性,以及团队所有权、在各种服务之间共享问题、代码检查器、特定于服务的问题等分布式问题。在这种情况下,可以制定一些标准指南,例如每个团队应提供一个 Docker 配置文件,该文件从获取依赖项到构建软件并生成具有服务特定信息的容器。然后可以以任何标准方式运行 Docker 镜像,或者使用诸如 Amazon EC2、GCP 或 Kubernetes 之类的编排工具。

  • 所有微服务都应该用相同的语言编码吗?

对这个问题的一般回答是这不是一个先决条件。微服务通过预定义的协议进行相互交互,例如 HTTP、Sockets、Thrift、RPC 等,我们稍后将更详细地看到。这意味着不同的服务可以使用完全不同的技术堆栈编写。微服务的内部语言实现并不重要,重要的是外部结果,即端点和 API。只要保持通信协议,语言实现就不重要,虽然不仅拥有一种语言是一个优势,但添加太多语言也会导致系统开发人员维护语言环境需求的复杂性增加。整个生态系统不应该是一个你可以种植任何东西的野生丛林。

基于云的系统现在有一套标准的指导方针。我们将看一下著名的十二要素应用程序以及微服务如何遵循这些指导方针。

微服务的十二要素应用

“当你没有一个好的流程和平台来帮助你时,好的代码会失败。当你没有一个拥抱 DevOps 和微服务的良好文化时,好的团队也会失败。”

  • Tim Spann

十二要素应用程序是一种软件即服务(SaaS)或部署在云中的 Web 应用程序或软件的方法论。它告诉我们关于这些应用程序期望的输出特征。它基本上只是概述了制作结构良好且可扩展的云应用程序的必要条件:

  • 代码库:我们为每个微服务维护一个单一的代码库,具有特定于它们自己的环境的配置,如开发、QA 和生产。每个微服务都将在版本控制系统(如 Git、mercurial 等)中拥有自己的存储库。

  • 依赖关系:所有微服务都将它们的依赖项作为应用程序包的一部分。在 Node.js 中,有一个package.json,其中列出了所有的开发依赖和总体依赖。我们甚至可以有一个私有仓库,从中获取依赖项。

  • 配置:所有配置应该是外部化的,基于服务器环境。应该将配置与代码分离。您可以在 Node.js 中设置环境变量,或者使用 Docker compose 来定义其他变量。

  • 后备服务:任何通过网络消耗的服务,如数据库、I/O 操作、消息查询、SMTP、缓存,都将作为微服务暴露出来,并使用 Docker compose,并独立于应用程序。

  • 构建、发布和运行:我们将在分布式系统中使用 Docker 和 Git 等自动化工具。使用 Docker,我们可以使用其推送、拉取和运行命令来隔离所有三个阶段。

  • 进程:设计的微服务将是无状态的,并且不共享任何东西,因此实现零容错和轻松扩展。卷将用于持久化数据,从而避免数据丢失。

  • 端口绑定:微服务应该是自治的和自包含的。微服务应该将服务监听器嵌入到服务本身中。例如,在 Node.js 应用程序中使用 HTTP 模块,服务网络公开服务以处理所有进程的端口。

  • 并发性:微服务将通过复制进行扩展。微服务是通过扩展而不是扩大规模的。微服务可以根据工作负载的流动进行扩展或缩小。并发性将得到动态维护。

  • 可处置性:最大限度地提高应用程序的健壮性,实现快速启动和优雅关闭。各种选项包括重启策略,使用 Docker swarm 进行编排,反向代理以及使用服务容器进行负载平衡。

  • 开发/生产一致性:保持开发/生产/暂存环境完全相同。使用容器化的微服务通过构建一次,随处运行策略有所帮助。相同的镜像部署在各种 DevOps 阶段。

  • 日志:为日志创建单独的微服务,使其集中化,将其视为事件流,并将其发送到诸如弹性堆栈ELK)之类的框架。

  • 管理进程:管理或任何管理任务应该作为其中一个进程打包,这样它们可以轻松执行、监视和管理。这将包括诸如数据库迁移、一次性脚本、修复错误数据等任务。

当前世界中的微服务

现在,让我们来看看当前世界中微服务的先驱实施者,他们获得的优势以及未来的路线图。这些公司采用微服务的共同目标是摆脱单片地狱。微服务甚至在前端看到了它的采用。像Zalando这样的公司也使用微服务原则在 UI 层面进行组合。

Netflix

Netflix是微服务采用的先驱之一。Netflix 每天处理数十亿次观看事件。它需要一个强大和可扩展的架构来管理和处理这些数据。Netflix 使用多语言持久性来获得他们采用的每种技术解决方案的优势。他们使用Cassandra进行高容量和较低延迟的写操作,以及具有调整配置的手工模型进行中等容量的写操作。他们在缓存级别使用Redis进行高容量和较低延迟的读取。Netflix 定制的几个框架现在是开源的,可供使用:

Netflix Zuul 用于外部世界的边缘服务器或门卫。它不允许未经授权的请求通过。这是外部世界的唯一联系点。
Netflix Ribbon 服务消费者用于在运行时查找服务的负载均衡器。如果找到多个微服务实例,ribbon 使用负载平衡来均匀分配负载。
Netflix Hystrix 用于保持系统运行的断路器。Hystrix 会断开那些最终会失败的服务的连接,只有当服务恢复正常时才会重新连接。
Netflix Eureka 用于服务发现和注册。它允许服务在运行时注册自己。
Netflix Turbine 用于检查运行中微服务的健康状况的监控工具。

仅仅检查这些存储库上的星星就可以给出使用 Netflix 工具采用微服务的速度的想法。

沃尔玛

沃尔玛是黑色星期五上最受欢迎的公司之一。在黑色星期五期间,每分钟有超过 600 万次页面浏览。沃尔玛采用了微服务架构,以适应 2020 年的世界,以合理的成本实现 100%的可用性。迁移到微服务架构给公司带来了巨大的提升。转化率提高了 20%。他们在黑色星期五没有停机时间。他们节省了 40%的计算能力,整体节省了 20-50%的成本。

Spotify

Spotify每月有 7500 万活跃用户,平均会话长度为 23 分钟。他们采用了微服务架构和多语言环境。Spotify 是一个拥有 90 个团队、600 名开发人员和两个大陆上的五个办公室的公司,所有人都在同一个产品上工作。这在尽可能减少依赖关系方面起到了重要作用。

Zalando

Zalando在前端实施了微服务。他们引入了作为前端的独立服务的片段。片段可以根据提供的模板定义在运行时组合在一起。与 Netflix 类似,他们外包了使用库:

Tailor 这是一个布局服务,它由各种片段组成页面,因为它进行异步和基于流的获取,所以具有出色的首字节时间TTFB)。
Skipper 用于通信的 HTTP 路由器,更像是 HTTP 拦截器,它具有修改请求和响应的能力。
Shaker 用于在多个团队开发片段时提供一致用户体验的 UI 组件库。
Quilt 带有 REST API 的模板存储和管理器。
Innkeeper 路由的数据存储。
Tesselate 服务器端渲染器和组件树构建器。

现在它服务于 1500 多个时尚品牌,创造了超过 34.3 亿美元的收入,开发团队有 700 多人。

在下一节中,我们将从设计的角度来揭示微服务。我们将看到微服务设计中涉及的组件,并了解广泛存在的微服务设计模式。

微服务设计方面

在设计微服务时,需要做出各种重要决策,例如微服务之间如何通信,如何处理安全性,如何进行数据管理等。现在让我们看看微服务设计中涉及的各种方面,并了解其可用的各种选项。

微服务之间的通信

让我们通过一个真实世界的例子来理解这个问题。在购物车应用程序中,我们有产品微服务、库存微服务、结账微服务和用户微服务。现在用户选择购买一个产品;对于用户来说,产品应该被添加到他们的购物车中,支付金额,在成功支付后,结账完成,并更新库存。现在如果支付成功,那么只有结账和库存应该被更新,因此服务需要相互通信。现在让我们看一些微服务可以用来相互通信或与任何外部客户端通信的机制。

远程过程调用(RPI)

简而言之,远程过程调用是一种协议,任何人都可以使用它从网络中远程访问其他提供者的服务,而无需了解网络细节。客户端使用请求和回复协议来请求服务,这是大数据搜索系统中最可行的解决方案之一。它具有序列化时间的主要优势之一。提供 RPI 的一些技术包括Apache ThriftGoogle 的 gRPC。gRPC 是一个广泛采用的库,每天从 Node.js 下载量超过 23,000 次。它具有一些很棒的实用程序,如可插拔身份验证、跟踪、负载平衡和健康检查。它被 Netflix、CoreOS、Cisco 等公司使用。

这种通信模式具有以下优势:

  • 请求和回复很容易

  • 维护简单,因为没有中间代理

  • 使用基于 HTTP/2 的双向流传输方法

  • 在微服务风格的架构生态系统中高效地连接多语言服务

这种模式的通信对以下挑战和问题需要考虑:

  • 调用方需要知道服务实例的位置,即维护客户端注册表和服务器端注册表

  • 它只支持请求和回复模式,不支持其他模式,如通知、异步响应、发布/订阅模式、发布异步响应、流等

RPI 使用二进制而不是文本来保持有效负载非常紧凑和高效。这些请求在单个 TCP 连接上进行多路复用,这可以允许多个并发消息在不牺牲网络消耗的情况下进行传输。

消息传递和消息总线

当服务必须处理来自各种客户端接口的请求时,就会使用这种通信模式。服务需要相互协作来处理一些特定的操作,为此它们需要使用进程间通信协议。异步消息传递和消息总线就是其中之一。微服务通过在各种消息通道上交换消息来相互通信。Apache KafkaRabbitMQActiveMQKestrel是一些广泛可用的消息代理,可用于微服务之间的通信。

消息代理最终执行以下功能集:

  • 将来自各种客户端的消息路由到不同的微服务目的地。

  • 根据需要将消息更改为所需的转换。

  • 能够进行消息聚合,将消息分隔成多个消息,并根据需要发送到目的地并重新组合它们。

  • 响应错误或事件。

  • 使用发布-订阅模式提供内容和路由。

  • 使用消息总线作为微服务之间的通信手段具有以下优势:

  • 客户端与服务解耦;它们不需要发现任何服务。整体上松散耦合的架构。

  • 消息代理具有高可用性,因为它会持久保存消息,直到消费者能够对其进行操作。

  • 它支持各种通信模式,包括广泛使用的请求/回复、通知、异步响应、发布-订阅等。

虽然这种模式提供了几个优点,但增加了添加消息代理的复杂性,该代理应该具有高可用性,因为它可能成为单点故障。这也意味着客户端需要发现消息代理的位置,即联系点。

protobufs

协议缓冲区protobufs是由谷歌创建的二进制格式。谷歌将 protobufs 定义为一种语言和平台中立的序列化结构化数据的广泛方式,可用作通信协议之一。 Protobufs 还定义了一组定义消息结构的一些语言规则。一些演示有效地表明 protobufs 比 JSON 快六倍。它非常容易实现,包括三个主要阶段,即创建消息描述符、消息实现和解析和序列化。在微服务中使用 protobufs 具有以下优势:

  • protobufs 的格式是自解释的-正式的格式。

  • 它具有 RPC 支持;您可以将服务器 RPC 接口声明为协议文件的一部分。

  • 它具有结构验证的选项。由于它具有在 protobufs 上序列化的较大数据类型消息,因此可以由负责交换它们的代码自动验证。

虽然 protobuf 模式提供了各种优势,但也有一些缺点,如下所示:

  • 这是一种新兴的模式;因此您不会找到许多资源或详细的 protobuf 实现文档。如果您只在 Stack Overflow 上搜索 protobuf 标签,您只会看到大约 1 万个问题。

  • 由于它是二进制格式,与 JSON 相比,它是不可读的,而 JSON 在另一方面是简单易读和分析的。下一代 protobuf 和 flatbuffer 现在已经可用。

服务发现

接下来要注意的明显方面是任何客户端接口或任何微服务将发现任何服务实例的网络位置的方法。基于微服务的现代应用程序在虚拟化或容器化环境中运行,其中包括服务实例的数量和位置动态变化。此外,基于自动扩展、升级等,服务实例集会动态变化。我们需要一个详细的服务发现机制。下面讨论的是广泛使用的模式。

服务注册表用于服务-服务通信

不同的微服务和各种客户端接口需要知道服务实例的位置,以便发送请求。通常,虚拟机或容器具有不同或动态的 IP 地址,例如,应用自动扩展的 EC2 组,它根据负载自动调整实例的数量。有多种选项可用于在任何地方维护注册表,例如客户端端或服务器端注册。客户端或微服务查找该注册表以查找其他微服务进行通信。

让我们以 Netflix 的真实例子为例。Netflix Eureka 是一个服务注册提供者。它有各种选项用于注册和查询可用的服务实例。使用公开的POST API告知服务实例的网络位置。必须每 30 秒使用公开的PUT API进行不断更新。任何接口都可以使用GET API获取该实例并根据需求使用。一些广泛可用的选项如下:

  • etcd:用于共享配置和服务发现的键值存储。诸如 Kubernates 和 Cloud Foundry 之类的项目都基于etcd,因为它可以是高可用的、基于键值的和一致的。

  • consul:另一个用于服务发现的工具。它具有广泛的选项,如公开的 API 端点,允许客户端注册和发现服务,并执行健康检查以确定服务的可用性。

  • ZooKeeper:非常广泛使用,高可用性和高性能的协调服务,用于分布式应用程序。Zookeeper 最初是 Hadoop 的一个子项目,是一个广泛使用的顶级项目,并且预配置了各种框架。

一些系统具有隐式内置的服务注册表,作为其框架的一部分内置。例如,Kubernates、Marathon 和 AWS ELB。

服务器端发现

对任何服务的所有请求都通过已知客户端接口的位置运行的路由器或负载均衡器路由。然后,路由器查询维护的注册表,并根据查询响应转发请求。AWS 弹性负载均衡器是一个经典示例,它具有处理负载平衡、处理内部或外部流量和作为服务注册表的能力。EC2 实例可以通过公开的 API 调用或自动扩展注册到 ELB。其他选项包括 NGINX 和 NGINX Plus。还有可用的 consul 模板,最终从 consul 服务注册表生成nginx.conf文件,并根据需要配置代理。

使用服务器端发现的一些主要优势如下:

  • 客户端不需要知道不同微服务的位置。他们只需要知道路由器的位置,服务发现逻辑完全抽象化,客户端端没有任何逻辑。

  • 一些环境免费提供此组件功能。

虽然这些选项有很大的优势,但也有一些需要处理的缺点:

  • 它有更多的网络跳数,即来自客户端服务注册表和另一个来自服务注册表微服务。

  • 如果负载均衡器不是由环境提供的,那么就必须设置和管理它。如果处理不当,它可能成为单点故障。

  • 选定的路由器或负载均衡器必须支持不同的通信协议以进行通信模式。

客户端发现

在这种发现模式下,客户端负责处理可用微服务的网络位置,并在它们之间负载平衡传入请求。客户端需要查询服务注册表(在客户端维护的可用服务的数据库)。然后,客户端根据算法选择服务实例,然后发出请求。Netflix 广泛使用此模式,并已开源其工具 Netflix OSS、Netflix Eureka、Netflix Ribbon 和 Netflix Prana。使用此模式具有以下优势:

  • 高性能和可用性,因为转换跳数较少,也就是说,客户端只需调用注册表,注册表将根据其需求重定向到微服务。

  • 这种模式相当简单且高度具有弹性,因为除了服务注册表外没有其他移动部分。由于客户端了解可用的微服务,他们可以轻松地做出智能决策,例如何时使用哈希,何时触发自动扩展等。

  • 使用此服务发现模式的一个重大缺点是,必须在服务客户端使用的每种编程语言的框架中实现客户端端服务发现逻辑。例如,Java、JavaScript、Scala、Node.js、Ruby 等。

注册模式-自注册

在使用此模式时,任何微服务实例都负责从维护的服务注册表中注册和注销自己。为了维护健康检查,服务实例发送心跳请求以防止其注册表过期。Netflix 使用了类似的方法,并已外包了他们的 Eureka 库,该库处理了服务注册和注销的所有方面。它在 Java 中有自己的客户端,也有 Node.js。Node.js 客户端(eureka-js-client)每月下载量超过 12,000 次。自注册模式具有重大优势,例如任何微服务实例都将知道自己的状态,因此可以轻松实现或切换到其他模式,例如启动可用等。

但它也有以下缺点:

  • 它将服务紧密耦合到自服务注册表,这迫使我们在框架中使用的每种语言中启用服务注册代码

  • 任何运行中但无法处理请求的微服务通常会不知道要追求哪种状态,并且通常最终会忘记从注册表中注销

数据管理

微服务设计方面的另一个重要问题是微服务应用程序中的数据库架构。我们将看到各种选项,例如是否维护私有数据存储,管理事务以及在分布式系统中轻松查询数据存储。最初的想法可能是使用单个数据库,但是如果我们深入思考,很快就会发现这是一个不明智且不合适的解决方案,因为它会导致紧密耦合、不同的需求以及任何服务的运行时阻塞。

每个服务一个数据库

在分布式微服务架构中,不同的服务具有不同的存储需求和使用情况。关系型数据库在维护关系和进行复杂查询时是一个完美的选择。当存在非结构化复杂数据时,NoSQL 数据库如 MongoDB 是最佳选择。有些可能需要图形数据,因此使用 Neo4j 或 GraphQL。解决方案是将每个微服务的数据保持私有,并且只能通过 API 访问。每个微服务都维护其数据存储,并且是该服务实现的私有部分,因此其他服务无法直接访问。

在实施这种数据管理模式时,您可以选择以下一些选项:

  • 每个微服务都有一组定义的表或集合,只能由该服务访问

  • 每个服务都有一个模式,只能通过其绑定的微服务访问

  • 每个微服务维护自己的数据库,根据自己的需求和要求

当考虑到,每个服务维护一个模式似乎是最合乎逻辑的解决方案,因为它将具有较低的开销,并且所有权可以清晰可见。如果一些服务具有高使用率和吞吐量,并且具有不同的使用情况,那么维护单独的数据库是合乎逻辑的选择。一个必要的步骤是添加障碍,以限制任何微服务直接访问数据。添加此障碍的各种选项包括分配具有受限权限的用户 ID 或访问控制机制,例如授予。这种模式具有以下优点:

  • 松散耦合的服务可以独立运行;对一个服务的数据存储的更改不会影响任何其他服务。

  • 每个服务都有自由选择所需的数据存储。每个微服务都可以根据需要选择关系型或非关系型数据库。例如,任何需要对文本进行密集搜索结果的服务可能会选择 Solr 或 Elasticsearch,而任何需要结构化数据的服务可能会选择任何 SQL 数据库。

这种模式具有以下需要小心处理的缺点和优点:

  • 处理涉及跨多个服务的事务的复杂场景。CAP 定理指出,在分布式数据存储中不可能同时满足一致性、可用性和分区中的三个保证中的超过两个,因此通常避免事务。

  • 跨多个数据库的查询具有挑战性且消耗资源。

  • 管理多个 SQL 和非 SQL 数据存储的复杂性。

为了克服缺点,在维护每个服务的数据库时使用以下模式:

  • Saga:一个 saga 被定义为一批本地事务的序列。批中的每个条目都会更新指定的数据库,并通过发布消息或触发下一个批次中的事件来继续。如果批中的任何条目在本地失败,或者违反了任何业务规则,那么 saga 将执行一系列补偿事务,以补偿或撤消批次更新所做的更改。

  • API 组合:这种模式坚持认为应用程序应该执行连接而不是数据库。举个例子,一个服务专门用于查询组合。因此,如果我们想要获取每月产品分布,那么我们首先从产品服务中检索产品,然后查询分布服务以返回检索到的产品的分布信息。

  • 命令查询责任分离(CQRS):这种模式的原则是有一个或多个不断发展的视图,通常这些视图的数据来自各种服务。基本上,它将应用程序分为两部分——命令或操作方和查询或执行方。这更像是一个发布-订阅模式,其中命令方操作创建/更新/删除请求,并在数据发生变化时发出事件。执行方监听这些事件,并通过维护视图来处理这些查询,这些视图根据命令或操作方发出的事件的订阅而保持最新。

共享关注点

分布式微服务架构中的下一个重要问题是如何处理共享关注点。诸如 API 路由、安全性、日志记录和配置等一般事务将如何工作?让我们逐一看看这些要点。

外部化配置

一个应用程序通常会使用一个或多个基础设施的第三方服务,比如服务注册表、消息代理、服务器、云部署平台等等。任何服务都必须能够在多个环境中运行,而不需要进行任何修改。它应该具有获取外部配置的能力。这种模式更多地是一个指导方针,建议我们将所有配置外部化,包括数据库信息、环境信息、网络位置等,创建一个启动服务来读取这些信息并相应地准备应用程序。有各种可用的选项。Node.js 提供设置环境变量;如果您使用 Docker,那么它有docker-compose.yml文件。

可观测性

重新审视应用程序所需的十二要素,我们可以观察到,即使是分布式的,任何应用程序都需要一些集中的功能。这些集中的功能帮助我们在出现问题时进行适当的监控和调试。让我们看一些常见的可观测性参数。

日志聚合

每个服务实例都会以标准化格式生成有关其正在执行的操作的信息,其中包含各种级别的日志,如错误、警告、信息、调试、跟踪、致命等。解决方案是使用集中式日志服务,从每个服务实例收集日志并将其存储在用户可以搜索和分析日志的某个常见位置。这使我们能够为某些类型的日志配置警报。此外,集中式服务还将有助于进行审计日志记录、异常跟踪和 API 指标。可用且广泛使用的框架包括 Elastic Stack(Elasticsearch、Logstash、Kibana)、AWS CloudTrail 和 AWS CloudWatch。

分布式跟踪

下一个重大问题是理解行为和应用程序,以便在需要时解决问题。这种模式更像是一个设计指南,指出要维护一个由微服务维护的唯一外部请求 ID。这个外部请求 ID 需要传递给处理该请求的所有服务以及所有日志消息。另一个指南是在微服务执行操作时包括请求和操作的开始时间和结束时间。

基于前述的设计方面,我们将看到常见的微服务设计模式,并深入了解每种模式。我们将看到何时使用特定模式,它解决了什么问题,以及在使用该设计模式时要避免的陷阱。

微服务设计模式

随着微服务的发展,其设计原则也在不断发展。以下是一些常见的设计模式,可以帮助设计高效和可扩展的系统。Facebook、Netflix、Twitter、LinkedIn 等公司遵循了一些模式,提供了一些最可扩展的架构。

异步消息传递微服务设计模式

在分布式系统中需要考虑的最重要的事情之一是状态。尽管 REST API 功能强大,但它有一个非常原始的缺陷,即同步和阻塞。这种模式是关于实现非阻塞状态和异步性,以可靠地在整个应用程序中保持相同的状态,避免数据损坏,并允许应用程序快速变化的速度:

  • 问题:在特定上下文中,如果我们遵循单一责任原则,应用程序中的模型或实体可能对不同的微服务意味着不同的东西。因此,每当发生任何更改时,我们需要确保不同的模型与这些更改同步。这种模式通过异步消息传递来解决这个问题。为了确保整个过程中的数据完整性,需要在微服务或数据存储之间复制关键业务数据和业务事件的状态。

  • 解决方案:由于这是异步通信,客户端或调用者假设消息不会立即收到,继续并将回调附加到服务。回调是为了在接收到响应时进行进一步操作。最好使用轻量级消息代理(不要与 SOA 中使用的编排器混淆)。消息代理是愚蠢的,也就是说,它们对应用程序状态一无所知。它们与处理事件的服务通信,但它们从不处理事件。一些广泛采用的示例包括 RabbitMQ、Azure 总线等。Instagram 的动态由这个简单的 RabbitMQ 提供动力。根据项目的复杂性,您可以引入单个接收器或多个接收器。单个接收器虽然不错,但很快就可能成为单点故障。更好的方法是采用响应式并引入发布-订阅通信模式。这样,发送方的通信将一次性提供给订阅微服务。实际上,当我们考虑常规情况时,对模型的任何更新都将触发所有订阅者的事件,这可能进一步触发它们自己模型的更改。为了避免这种情况,事件总线通常在这种类型的模式中引入,可以充当微服务之间的通信角色并充当消息代理。一些常见的可用库包括AMQPRabbitMQNserviceBusMassTransit等,用于可扩展的架构。

以下是使用 AMQP 的示例:gist.github.com/parthghiya/114a6e19208d0adca7bda6744c6de23e

  • 注意:要成功实现这个设计,应考虑以下几个方面:

  • 当您需要高可伸缩性,或者您当前的领域已经是基于消息的领域时,应优先考虑基于消息的命令而不是 HTTP。

  • 在微服务之间发布事件,以及在原始微服务中更改状态。

  • 确保事件是跨越通信的;模仿事件将是一个非常糟糕的设计模式。

  • 保持订阅者的消费者位置以提高性能。

  • 何时进行 REST 调用,何时使用消息调用。由于 HTTP 是同步调用,只有在需要时才应使用。

  • 何时使用:这是最常用的模式之一。根据以下用例,您可以根据自己的需求使用这种模式或其变体:

  • 当您想要使用实时流时,使用Event Firehouse模式,其中KAFKA是其关键组件之一。

  • 当您的复杂系统是由各种服务编排时,该系统的变体之一,RabbitMQ,非常有帮助。

  • 通常,直接订阅数据存储而不是服务订阅是有利的。在这种情况下,使用GemFireApache GeoCode遵循这种模式是有帮助的。

  • 不适用于:在以下情况下,不推荐使用这种模式:

  • 当事件传输期间有大量数据库操作时,因为数据库调用是同步的。

  • 当您的服务是耦合的

  • 当您没有定义处理数据冲突情况的标准方式时

前端后端

当前的世界要求在任何地方都采用移动优先的方法。服务可能会对移动设备和网页作出不同的响应,在移动设备上,它可能只显示少量内容,因为内容很少。在网页上,它可能要显示大量内容,因为有很多空间。根据设备,情景可能会有很大的不同。例如,在移动应用中,我们可能允许条形码扫描,但在桌面上,这不是一个明智的选择。这种模式解决了这些问题,并有助于有效地设计跨多个接口的微服务:

  • 问题:随着支持多个接口的服务的发展,管理一个服务中的所有内容变得非常痛苦。任何单个接口的不断变化都可能很快成为一个瓶颈和难以维护的问题。

  • 解决方案:与维护通用 API 不同,为每个用户体验或接口设计一个后端,更好地称为前端的后端(BFFs)。BFF 与单个接口或特定用户体验紧密相关,并由其特定团队维护,以便轻松适应新变化。在实施这种模式时,经常出现的一个问题是维护 BFF 的数量。更通用的解决方案是分离关注点,并让每个 BFF 处理自己的责任。

  • 注意:在实施这种设计模式时,应注意以下几点,因为它们是最常见的陷阱:

  • 要考虑要维护的 BFF 数量。只有在可以将通用服务的关注点分离出特定接口时,才应创建新的 BFF。

  • BFF 应该只包含客户端/接口特定的代码,以避免代码重复。

  • 在团队之间分配 BFF 的维护责任。

  • 这不应该与Shim混淆,它是一个转换器,用于将转换为特定接口格式所需的类型接口。

  • 何时使用:在以下情况下,这种模式非常有用:

  • 通用后端服务在多个接口之间存在差异,并且在单个接口中可能会有多个更新。

  • 您希望优化单个接口,而不会干扰其他接口的效用。

  • 有各种团队,并且要为特定接口实现另一种语言,并且希望将其单独维护。

  • 不适用于以下情况:虽然这种模式解决了许多问题,但在以下情况下不建议使用这种模式:

  • 不要使用此模式来处理通用参数问题,如身份验证、安全性或授权。这只会增加延迟。

  • 如果部署额外服务的成本太高。

  • 当接口发出相同的请求并且它们之间没有太大的区别时。

  • 当只有一个接口,不支持多个接口时,BFF 就没有太多意义。

网关聚合和卸载

将专门的、常见的服务和功能转储或移动到网关。通过将共享功能移入单一部分,这种模式可以引入简单性。共享功能可以包括 SSL 证书的使用、身份验证和授权。网关还可以用于将多个请求合并为单个请求。这种模式简化了客户端必须对不同的微服务进行多次调用的需求:

  • 问题:通常,为了执行简单的任务,客户端可能需要向各种不同的微服务发出多个 HTTP 调用。向服务器发出太多调用会增加资源、内存和线程,从而对性能和可伸缩性产生不利影响。许多功能通常在多个服务中共同使用;身份验证服务和产品结账服务都会以相同的方式使用登录。这种服务需要配置和维护。此类服务还需要额外的关注,因为它们是必不可少的。例如,令牌验证、HTTPS 证书、加密、授权和身份验证。随着每次部署,跨整个系统进行管理变得困难。

  • 解决方案:这种设计模式中的两个主要组件是网关和网关聚合器。网关聚合器应始终放置在网关后面。因此,实现了单一责任,每个组件都执行其预定的操作。

  • 网关:它将一些常见操作,如证书管理,认证,SSL 终止,缓存,协议转换等,转移到一个地方。它简化了开发,并将所有这些逻辑抽象到一个地方,加快了在一个大型组织中的开发,不是每个人都能访问网关,只有专门的团队才能使用它。它在整个应用程序中保持一致性。网关可以确保最少量的日志记录,从而帮助找到有问题的微服务。这很像面向对象编程中的外观模式。它的作用如下:

  • 过滤器

  • 暴露各种微服务的单一入口点

  • 解决常见操作,比如授权,认证,中央配置等,将这些逻辑抽象成一个地方

  • 路由器用于流量管理和监控

Netflix 使用了类似的方法,他们能够处理超过每小时 50,000 个请求,并且他们开源了 ZuuL:

  • 网关聚合器:它接收客户端请求,然后决定要将客户端请求分派给哪些不同的系统,获取结果,然后将它们聚合并发送回客户端。对于客户端来说,这只是一个请求。客户端和服务器之间的总往返次数减少了。

这里有一个聚合器的例子:gist.github.com/parthghiya/3f1c3428b1cf3cc6d76ddd18b4521e03.js

  • 注意:为了成功实现微服务中的这种设计模式,应该正确处理以下陷阱:

  • 不要引入服务耦合,也就是说,网关可以独立存在,没有其他服务消费者或服务实现者。

  • 在这里,每个微服务都将依赖于网关。因此,网络延迟应该尽可能低。

  • 确保网关有多个实例,因为只有一个网关实例可能会引入单点故障。

  • 每个请求都经过网关。因此,应该确保网关具有高效的内存和足够的性能,并且可以轻松扩展以处理负载。进行一轮负载测试,确保它能够处理大量负载。

  • 引入其他设计模式,如舱壁、重试、节流和超时,以实现高效的设计。

  • 网关应该处理逻辑,比如重试次数,等待服务直到。

  • 应该处理缓存层,这可以提高性能。

  • 网关聚合器应该在网关后面,因为请求聚合器将有另一个。将它们合并在一个网关中可能会影响网关及其功能。

  • 在使用异步方法时,你会发现自己被太多的回调地狱所困扰。采用响应式方法,更具有声明性风格。响应式编程在从 Java 到 Node.js 到 Android 都很普遍。你可以查看这个链接,了解不同链接上的响应式扩展:github.com/reactivex

  • 业务逻辑不应该在网关中。

  • 何时使用:在以下情况下应该使用这种模式:

  • 有多个微服务,客户端需要与多个微服务通信。

  • 当客户端在较小范围的网络或蜂窝网络中时,想要减少频繁的网络调用。将其分解为一个请求是有效的,因为这样前端或网关只需要缓存一个请求。

  • 当你想要封装内部结构或向你组织中存在的大团队引入一个抽象层时。

  • 不适用于:以下情况是这种模式不适合的情况:

  • 当你只想减少网络调用时。你不能为了满足这个需求引入整个层级的复杂性。

  • 网关的延迟太大。

  • 您在网关中没有异步选项。您的系统对网关中的操作进行了太多同步调用。这将导致阻塞系统。

  • 您的应用程序无法摆脱耦合的服务。

代理路由和节流

当您有多个微服务想要跨单个端点公开,并且该单个端点根据需要路由到服务时。当您需要处理即将发生的瞬态故障并在操作失败时进行重试循环时,这种应用是有帮助的,从而提高了应用的稳定性。当您想要处理微服务使用的资源消耗时,这种模式也是有帮助的。

这种模式用于满足约定的 SLA,并在需求增加时处理资源负载和资源分配消耗:

  • 问题:当客户端必须消耗大量微服务时,很快就会出现挑战,例如客户端管理每个端点并设置单独的端点。如果您重构任何服务中的任何部分,则客户端也必须更新,因为客户端直接与端点联系。此外,由于这些服务在云中,它们必须具有容错能力。故障包括临时失去连接或服务不可用。这些故障应该是自我纠正的。例如,正在处理大量并发请求的数据库服务应该在内存负载和资源利用率减少之前限制进一步的请求。在重试请求时,操作完成。任何应用程序的负载在时间段上都会有很大的变化。例如,社交媒体聊天平台在高峰办公时间负载很小,而购物门户在节日季销售期间负载很大。为了使系统高效运行,必须满足约定的 LSA,一旦超过,需要停止后续请求,直到负载消耗减少。

  • 解决方案:将网关层放置在微服务前面。该层包括节流组件以及一旦失败的重试组件。通过添加这一层,客户端只需与该网关交互,而不是与每个不同的微服务交互。它允许您将后端调用从客户端抽象出来,从而使客户端端简单,因为客户端只需与网关交互。任意数量的服务可以添加,而无需在任何时间点更改客户端。这种模式还可以用于有效处理版本控制。可以并行部署微服务的新版本,并且网关可以根据传递的输入参数进行路由。只需在网关级别进行配置更改即可轻松维护新更改。这种模式可以用作自动扩展的替代策略。该层应该仅允许网络请求达到一定限制,然后节流请求并在资源释放后进行重试。这将有助于系统维护 SLA。在实施节流组件时应考虑以下几点:

  • 节流的考虑参数之一是用户请求或租户请求。假设特定租户或用户触发了节流,那么可以安全地假设调用者存在某些问题。

  • 节流并不一定意味着停止请求。如果有低质量的资源可用,可以提供,例如,移动友好的网站,低质量的视频等。谷歌也是这样做的。

  • 优先考虑微服务。根据优先级,它们可以放置在重试队列中。作为理想的解决方案,可以维护三个队列——取消、重试和稍后重试。

  • 注意:在成功实施这种模式时,以下是一些常见的陷阱:

  • 网关可能是单点故障。在开发过程中必须采取适当的步骤,确保它具有容错能力。此外,应该运行多个实例。

  • 网关应该有适当的内存和资源分配,否则会引入瓶颈。应该进行适当的负载测试,以确保故障不会级联。

  • 可以根据 IP、标头、端口、URL、请求参数等进行路由。

  • 重试策略应该根据业务需求非常小心地制定。在某些地方,可以选择“请重试”而不是等待一段时间和重试。重试策略也可能影响应用程序的响应性。

  • 为了有效应用,这种模式应该与断路器应用程序相结合。

  • 如果服务是幂等的,那么只有在这种情况下才应该重试。在其他服务上尝试重试可能会产生不良后果。例如,如果有一个支付服务等待其他支付网关的响应,重试组件可能会认为它失败,然后发送另一个请求,导致客户被收取两次费用。

  • 根据异常情况,应该相应地处理重试逻辑。

  • 重试逻辑不应干扰事务管理。应根据重试策略使用。

  • 所有触发重试的失败都应该被记录并妥善处理,以备将来的情况。

  • 需要考虑的一个重要点是,这并不是异常处理的替代品。始终应该优先考虑异常,因为它们不会引入额外的层并增加延迟。

  • 限流应该尽早添加到系统中,因为一旦系统实施,就很难添加;它应该被精心设计。

  • 限流应该快速执行。它应该足够智能,能够检测活动增加并采取适当措施做出相应反应。

  • 根据业务需求决定限流和自动扩展之间的考虑。

  • 应该根据优先级有效地将被限流的请求放入队列中。

  • 何时使用:这种模式在以下情况下非常有用:

  • 确保维护约定的 LSA。

  • 避免单个微服务消耗大部分资源池,并避免单个微服务耗尽资源。

  • 处理微服务消耗突然增加的情况。

  • 处理瞬态和短暂的故障。

  • 何时不使用:在以下情况下,不应该使用这种模式:

  • 限流不应该被用作处理异常的手段。

  • 当故障持续时间很长时。如果在这种情况下应用这种模式,它将严重影响应用程序的性能和响应性。

大使和边车模式

当我们想要分离常见的连接功能,如监视、日志记录、路由、安全性、身份验证、授权等时,就会使用这种模式。它创建了充当大使和边车的辅助服务,以实现代表服务发送请求的目标。它只是位于进程外部的另一个代理。专门的团队可以在此上工作,让其他人不必担心它,以提供封装和隔离。它还允许应用程序由多个框架和技术组成。

这种模式中的边车组件就像连接到摩托车上的边车一样。它与父微服务具有相同的生命周期,与父微服务一样退役,并且执行基本的外围任务:

  • 解决方案:找到一组在不同微服务中通用的操作,并将它们放在它们自己的容器或进程中,从而为整个系统中的所有框架和平台服务提供相同的接口。添加一个充当应用程序和微服务之间代理的大使层。这个大使可以监视性能指标,比如延迟量、资源使用等。大使内的任何内容都可以独立于应用程序进行维护。大使可以部署为容器、常见进程、守护进程或 Windows 服务。大使和侧车不是微服务的一部分,而是连接到微服务的一部分。这种模式的一些常见优势如下:

  • 与语言无关的开发侧车和大使,也就是说,你不必为架构中的每种语言构建侧车和大使。

  • 只是主机的一部分,因此它可以访问与任何其他微服务相同的资源。

  • 由于与微服务的连接,几乎没有延迟

Netflix 使用了类似的方法,并且他们已经开源了他们的工具Prana (github.com/Netflix/Prana)。看一下下面的图表:

  • 注意事项:应该注意以下几点,因为它们是最常见的陷阱:

  • 大使可能会引入一些延迟。应该深思熟虑是否使用代理或将通用功能暴露为库。

  • 在大使和侧车中添加通用功能是有益的,但对于所有情况都是必需的吗?例如,考虑向服务重试的次数,这可能并不适用于所有用例。

  • 大使和侧车将构建、管理和部署的语言或框架的策略。根据需要创建单个实例或多个实例的决定。

  • 灵活性,可以从服务传递一些参数到大使和代理,反之亦然。

  • 部署模式:当大使和侧车部署在容器中时,这是非常合适的。

  • 微服务之间的通信模式应该是框架无关或语言无关的。这在长期来看是有益的。

  • 何时使用:这种模式在以下情况下非常有帮助:

  • 当涉及多个框架和语言,并且您需要一组通用功能,例如客户端连接、日志记录等,贯穿整个应用程序。大使和侧车可以被应用程序中的任何服务使用。

  • 服务由不同的团队或不同的组织拥有。

  • 您需要独立的服务来处理这些横切功能,并且它们可以独立维护。

  • 当您的团队庞大,您希望专门的团队来处理、管理和维护核心横切功能时。

  • 您需要支持遗留应用程序或难以更改的应用程序中的最新连接选项。

  • 您希望监视整个应用程序的资源消耗,并在其资源消耗巨大时切断微服务。

  • 不适用于:虽然这种模式解决了许多问题,但在以下情况下不建议使用这种模式:

  • 当网络延迟至关重要时。引入代理层会带来一些开销,这将导致轻微延迟,这对实时情况可能不利。

  • 当连接功能无法通用化,并且需要与另一个服务进行另一级别的集成和依赖时。

  • 当创建客户端库并将其作为软件包分发给微服务开发团队时。

  • 对于引入额外层实际上是一种负担的小型应用程序。

  • 当一些服务需要独立扩展时;如果是这样,更好的选择是将其单独部署和独立运行。

反腐微服务设计模式

通常,我们需要在传统和现代应用程序之间进行互操作或共存。通过在现代和传统应用程序之间引入一个外观,这种设计为此提供了一个简单的解决方案。这种设计模式确保了应用程序的设计不会受到传统系统依赖的阻碍或阻挠:

  • 问题:新系统或正在迁移过程中的系统通常需要与传统系统进行通信。新系统的模型和技术可能会有所不同,考虑到旧系统通常比较薄弱,但仍然可能需要传统资源进行某些操作。通常,这些传统系统的设计和模式设计都很差。为了实现互操作性,我们可能仍然需要支持旧系统。这种模式是为了解决这种腐败,并且仍然拥有一个更清洁、更整洁、更易于维护的微服务生态系统。

  • 解决方案:为了避免使用传统代码或传统系统,设计一个层,完成以下任务:作为与传统代码通信的唯一层,防止直接访问传统代码,不同的人可能以不同的方式处理它们。核心概念是通过放置一个 ACL 来分离传统或腐败应用程序,从而避免改变传统层,并且避免妥协其方法或主要技术变更。

  • 反腐层ACL)应该包含根据新需求从旧模型进行翻译的所有逻辑。这一层可以作为一个独立的服务或翻译器组件引入到需要的任何地方。组织 ACL 设计的一般方法是将外观、适配器、翻译器和通信器结合起来,以与系统进行通信。ACL 用于防止外部系统的意外行为泄漏到现有上下文中:

  • 注意:在有效实施这种模式时,应考虑以下几点,因为它们是一些主要的陷阱:

  • ACL 应该被适当地扩展,并提供更好的资源池,因为它会增加两个系统之间的通话延迟。

  • 确保您引入的腐败层实际上是一种改进,而不是引入另一层腐败。

  • ACL 添加了额外的服务;因此必须进行有效的管理、维护和扩展。

  • 有效地确定 ACL 的数量。引入 ACL 可能有很多原因——将对象的不良格式转换为所需格式的手段,在不同语言之间进行通信等等。

  • 有效措施确保在两个系统之间保持事务和数据一致性,并且可以进行监控。

  • ACL 的持续时间,它会是永久的吗,通信将如何处理。

  • 虽然 ACL 应该成功处理来自腐败层的异常,但不应完全处理,否则将非常难以保留有关原始错误的任何信息。

  • 何时使用:在以下情况下,强烈推荐使用反腐模式,并且极其有用:

  • 有一个大型系统需要从单片到微服务进行重构,计划进行分阶段迁移,而不是一次性迁移,其中传统系统和新系统需要共存并相互通信。

  • 如果您正在处理任何数据源的系统,其模型是不良的或与所需模型不同步,可以引入这种模式,并且它将完成从不良格式到所需格式的翻译任务。

  • 每当需要链接两个有界上下文时,也就是说,一个系统是由完全不同的其他人开发的,对它的理解非常有限,这种模式可以作为系统之间的链接引入。

  • 不适用于:在以下情况下,强烈不建议使用这种模式:

  • 新系统和传统系统之间没有主要区别。新系统可以在没有传统系统的情况下共存。

  • 您有大量的事务操作,并且在 ACL 和损坏层之间维护数据一致性会增加太多的延迟。在这种情况下,可以将此模式与其他模式合并。

  • 您的组织没有额外的团队来在需要时维护和扩展 ACL。

分隔设计模式

将微服务应用程序中的不同服务分开到各种池中,以便如果其中一个服务失败,其他服务将继续运行而不受失败的影响。为每个微服务创建一个不同的池以最小化影响:

  • 问题:这种模式受到船体的分隔部分的启发。如果一艘船的船体受损,那么只有受损的部分会进水,这将防止船沉没。假设您正在连接各种使用共同线程池的微服务。如果其中一个服务开始显示延迟,那么所有池成员都会过度等待响应。逐渐地,来自一个服务的大量请求会耗尽可用资源。这就是这种模式建议为每个单独的服务提供专用池的地方。

  • 解决方案:根据负载和网络使用情况将单独的服务实例分成不同的组。这样可以隔离系统故障并防止连接池资源耗尽。这个系统的主要优势是防止故障传播和能够配置资源池的容量。对于优先级较高的服务,可以分配更高的池。

例如,给出了一个示例文件,我们可以看到服务购物管理的池分配:gist.github.com/parthghiya/be80246cc5792f796760a0d43af935db

  • 注意:确保注意以下几点,以确保正确实施分隔设计:

  • 根据业务和技术要求在应用程序中定义适当的独立分区。

  • 分隔可以以线程池和进程的形式引入。决定哪种适合您的应用程序。

  • 在微服务的部署中进行隔离。

  • 何时使用:在以下情况下,分隔模式具有优势:

  • 应用程序庞大,您希望保护它免受级联或传播故障的影响

  • 您可以将关键服务与标准服务隔离,并为它们分配单独的池

  • 何时不使用:不建议在以下情况下使用这种模式:

  • 当你没有足够的预算来维护成本和管理方面的独立开销时

  • 维护单独的池的额外复杂性是不必要的

  • 您的资源使用是意外的,您无法隔离您的租户并对其进行限制,因为当您将多个租户放在一个分区中时是不可接受的

断路器

有时服务需要相互协作处理请求。在这种情况下,另一个服务不可用、显示高延迟或不可用的可能性非常高。这种模式通过引入断路器来解决这个问题,停止整个架构中的传播:

  • 问题:在微服务架构中,当服务之间进行通信时,需要调用远程调用而不是内存调用。可能会出现远程调用失败或达到超时限制而挂起没有任何响应的情况。在这种情况下,如果有很多调用者,那么所有这些被锁定的线程可能会耗尽资源,整个系统将变得无响应。

  • 解决方案:解决这个问题的一个非常原始的想法是引入一个保护函数调用的包装器,它监视失败。现在,这个包装器可以通过任何触发,比如失败的特定阈值、数据库连接失败等。所有进一步的调用都将返回错误并停止灾难性的传播。这将打开断路器,并且在断路器打开时,它将避免进行受保护的调用。实现分为以下三个阶段,就像电路一样。它有三个阶段:关闭状态打开状态半开状态,如下图所示:

以下是 Node.js 中实现的示例:Netflix 开源了 Hystrix gist.github.com/parthghiya/777c2b423c9c8faf0d427fd7a3eeb95b

  • 注意事项:当您想应用断路器模式时,需要注意以下事项:

  • 由于您正在调用远程调用,并且可能有许多远程调用异步和反应性原则,因此必须使用未来、承诺、异步和等待。

  • 维护一个请求队列;当您的队列过度拥挤时,您可以轻松地触发断路器。始终监视断路器,因为您经常需要再次激活它以获得高效的系统。因此,准备好重置和故障处理程序的机制。

  • 您有一个持久存储和网络缓存,比如MemcacheRedis来记录可用性。

  • 记录、异常处理和转发失败的请求。

  • 何时使用:在以下用例中,您可以使用断路器模式:

  • 当您不希望耗尽资源时,也就是说,一个注定会失败的操作在修复之前不应该尝试。您可以使用它来检查外部服务的可用性。

  • 当您可以在性能上做出一些妥协,但希望获得系统的高可用性并且不耗尽资源。

  • 不适用的情况:在以下情景中,不建议使用断路器模式:

  • 您没有一个有效的缓存层,用于在集群节点之间维护服务状态的给定时间内的请求。

  • 用于处理内存结构或作为业务逻辑中异常处理的替代方案。这会增加性能开销。

窒息器模式

今天的世界是一个技术不断发展的世界。今天写的东西,明天就成了遗留代码。这种模式在迁移过程中非常有帮助。这种模式是关于通过逐步用新的微服务应用程序和服务替换特定功能的遗留系统。最终引入一个代理,将流量重定向到遗留系统或新的微服务,直到迁移完成,最后可以关闭窒息器或代理:

  • 问题:随着老化系统、新兴的开发工具和托管选项,云和无服务器平台的发展,维护当前系统变得非常痛苦,因为需要增加新功能和功能。完全替换一个系统可能是一项艰巨的任务,需要逐步迁移,以便仍然处理尚未迁移的部分。这种模式解决了这个问题。

  • 解决方案:窒息器解决方案类似于一根藤蔓,它缠绕在树上窒息。随着时间的推移,迁移的应用程序窒息原始应用程序,直到您可以关闭单片应用程序。因此,整个过程如下:

  • 重构:构建一个新的应用程序或站点(基于现代原则的无服务器或 AWS 云)。以敏捷的方式逐步重构功能。

  • 共存:保留旧应用程序不变。引入一个最终充当代理并根据当前迁移状态决定路由请求的外观。这个外观可以根据 IP 地址、用户代理或 cookie 等各种参数在 Web 服务器级别或编程级别引入。

  • 终止:将所有内容重定向到现代迁移的应用程序,并解除与旧应用程序的所有联系。

可以在此链接找到充当外观的.htaccess的示例要点:gist.github.com/parthghiya/a6935f65a262b1d4d0c8ac24149ce61d

解决方案指示我们创建一个具有拦截请求能力的外观或代理,该请求将发送到后端旧系统。然后,外观或代理决定是将其路由到旧应用程序还是新的微服务。这是一个渐进的过程,两个系统可以共存。最终用户甚至不会知道迁移过程何时完成。它的附加优势是,如果采用的微服务方法不起作用,有一种非常简单的方法可以更改它。

  • 注意:有效应用窒息模式需要注意以下要点:

  • 外观或代理需要随着迁移而更新。

  • 外观或代理不应该是单点故障或瓶颈。

  • 迁移完成后,外观将作为适配器适用于旧应用程序。

  • 新编写的代码应该易于拦截,这样将来我们可以在迁移中替换它。

  • 何时使用:当要用微服务替换旧的单片应用程序时,窒息应用程序非常有用。该模式在以下情况下使用:

  • 当您想要遵循测试驱动或行为驱动开发,并快速运行全面测试,以便访问代码覆盖率并适应 CI/CD 时。

  • 您的应用程序可以在区域内应用有界上下文。例如,在购物车应用程序中,产品模块将是一个上下文。

  • 不适用于:在以下情况下,此模式可能不适用:

  • 当您无法拦截用户代理请求,或者无法在架构中引入外观时。

  • 当您考虑一次一页页迁移或一次全部迁移时。

  • 当您的应用程序更多地受前端驱动时;这就是您必须完全改变并重新设计基于前端与服务交互的框架的地方,因为您不希望暴露用户代理与服务交互的各种方式。

总结

在本章中,我们揭示了微服务,以了解微服务的演变、微服务的特点和优势。我们讨论了微服务的各种设计原则,从单片应用程序到微服务的重构过程,以及各种微服务设计模式。

在下一章中,我们将开始我们的微服务之旅。我们将了解微服务之旅所需的所有设置。我们将学习与本书始终相关的 Node.js 和 TypeScript 相关的概念。我们还将创建我们的第一个微服务Hello World

第二章:为旅程做好准备

在学习了关于微服务的理论之后,我们现在将转向实际实现。本章将为前进的旅程奠定基础,并重新审视对本书至关重要的 Node.js 和 TypeScript 概念。它将告诉您关于这两种语言的趋势和采纳率。我们将通过所有必需的安装,并准备好我们的开发环境。我们将通过实现传统的Hello World微服务来测试开发环境。在本章中,我们将重点关注以下主题:

  • 设置主要开发环境:我们将设置一个带有所有必需先决条件的主要环境。我们将了解微服务开发所需的所有方面。

  • TypeScript 入门:在本节中,我们将介绍一些我们将在整本书中使用的主要 TypeScript 主题。我们将证明在 Node.js 中使用 TypeScript 作为我们的语言,并了解如何使用 TypeScript 和 Node.js 编写应用程序。

  • Node.js 入门:在本节中,我们将介绍一些高级的 Node.js 主题,如 Node.js 中的集群、最近引入的 async/await 等。我们将了解事件循环,并简要介绍 Node 流和 Node.js 的最新趋势。

  • 微服务实现:我们将编写一个Hello World微服务,该微服务将使用我们的开发环境。

设置主要环境

在这一部分,我们将设置我们前进旅程所需的环境。您已经全局安装了 Node.js 和 TypeScript。在撰写本文时,Node.js 的可用版本是9.2.0,TypeScript 的版本是2.6.2

Visual Studio Code(VS Code)

VS Code是目前最好的 TypeScript 编辑器之一。默认情况下,VS Code TypeScript 会显示有关不正确代码的警告,这有助于我们编写更好的代码。VS Code 提供了 Linter、调试、构建问题、错误等功能。它支持 JSDoc、sourcemaps、为生成的文件设置不同的输出文件、隐藏派生的 JavaScript 文件等。它支持自动导入,直接生成方法骨架,就像 Java 开发人员的 Eclipse 一样。它还提供了版本控制系统的选项。因此,它将是我们作为 IDE 的首选。您可以从code.visualstudio.com/download下载它。

在 Windows 上安装它是最简单的,因为它是一个.exe文件,您只需选择一个路径并按照步骤操作即可。在 Unix/Ubuntu 机器上安装它涉及下载deb文件,然后执行以下命令行:

sudo dpkg -i <file>.deb
sudo apt-get install -f # Install dependencies

一旦 VS Code 可用,打开扩展并下载marketplace.visualstudio.com/items?itemName=pmneo.tsimportermarketplace.visualstudio.com/items?itemName=steoates.autoimport。我们将使用这些扩展的优势,这将有助于轻松管理代码、预构建骨架等。

PM2

它是 Node.js 的高级处理器管理器。Node.js 是单线程的,需要一些附加工具来进行服务器管理,如重新启动服务器、内存管理、多进程管理等。它有一个内置的负载均衡器,并允许您使应用程序永久运行。它具有零停机时间和其他简化生活的系统管理选项。它还作为一个模块暴露出来,因此我们可以在 Node.js 应用程序的任何阶段运行时触发各种选项。要安装 PM2,请打开终端并输入以下命令:

npm install pm2 -g

更详细的选项和 API 可以在pm2.keymetrics.io/docs/usage/pm2-api/找到。

NGINX

NGINX是最受欢迎的 Web 服务器之一。它可以用作负载均衡器、HTTP 缓存、反向代理和减震器。它具有处理超过 10,000 个同时连接的能力,占用空间非常小(大约每 10,000 个非活动连接占用 2.5 MB en.wikipedia.org/wiki/HTTP_persistent_connection)。它专门设计用来克服 Apache。它大约可以处理比 Apache 多四倍的每秒请求。NGINX 可以以各种方式使用,例如以下方式:

  • 独立部署

  • 作为 Apache 的前端代理,充当网络卸载设备

  • 充当减震器,防止服务器突然出现的流量激增或慢的互联网连接

它是我们微服务应用程序的完美选择,因为容器化的微服务应用程序需要一个前端,能够隐藏和处理其后运行的应用程序的复杂和不断变化的特性。它执行一些重要的功能,如将 HTTP 请求转发到不同的应用程序,减震保护,路由,日志记录,Gzip 压缩,零停机时间,缓存,可伸缩性和容错性。因此,它是我们理想的应用交付平台。让我们开始 NGINX 101。

从这个网站nginx.org/en/download.html下载最新版本,根据你的操作系统。在撰写本文时,主线版本是1.13.7

解压后,您可以按照以下方式简单地启动 NGINX:

start nginx

要检查 NGINX 是否启动,可以在 Windows 中输入以下命令:

tasklist /fi "imagename eq nginx.exe"

在 Linux 的情况下,您可以使用以下命令行:

ps waux | grep nginx

以下是其他有用的 NGINX 命令:

nginx -s stop 快速关闭
nginx -s quit 优雅关闭
nginx -s reload 更改配置,使用新配置启动新的工作进程,并优雅地关闭旧的工作进程
nginx -s reopen 重新打开日志文件

Docker

Docker是一个开源平台,用于开发、发布和运行应用程序,其主要优势是将应用程序与基础架构分离,因此您可以轻松快速地适应重大变化。Docker 提倡容器的理念。容器是任何配置图像的可运行实例。容器与其他容器和主机完全隔离。这非常类似于我们的微服务理念。当我们进行部署时,我们将更详细地了解 Docker。让我们在系统上安装 Docker。

Windows 的 Docker 需要 Windows 10 专业版和 Hyper-V。因此,作为一个普遍可用的替代方案,我们将选择 Linux。Windows 用户可以下载 Oracle VM VirtualBox,下载任何 Linux 镜像,然后按照相同的过程进行。请按照这里给出的步骤进行操作:docs.docker.com/engine/installation/linux/docker-ce/ubuntu/

要检查安装情况,请输入以下命令:

sudo docker run hello-world

您应该看到如下输出:

Docker 安装

TypeScript 入门

TypeScript起源于 JavaScript 开发中的缺陷,随着将 JavaScript 用于大规模应用的出现。TypeScript 引入了一个 JavaScript 编译器,具有语法语言扩展的预设、基于类的编程和将扩展转换为常规 JavaScript 的方法。TypeScript 因引入了 JavaScript 的类型安全而变得极为流行,而 JavaScript 恰好是最灵活的语言之一。这使 JavaScript 成为了一种更面向对象和编译安全的语言。TypeScript 更像是 ES 标准的超集,它使开发人员能够编写更清晰、易于重构和可升级的代码。在本节中,我们将介绍 TypeScript 的各种主要主题,这对我们未来的旅程至关重要。TypeScript 是带有类型注释的 JavaScript。TypeScript 具有一个转译器和类型检查器,如果类型不匹配,则会抛出错误,并将 TypeScript 代码转换为 JavaScript 代码。我们将简要介绍以下主题,这些主题基本上将帮助我们编写 TypeScript 中的 Node.js:

  • 理解tsconfig.json

  • 理解类型

  • 在 Node.js 中调试 TypeScript

理解 tsconfig.json

添加tsconfig.json文件表示有一个 TypeScript 项目的目录,并且需要一个配置文件来将 TypeScript 编译成 JavaScript。您可以使用tsc命令将 TypeScript 编译成 JavaScript。调用它,编译器会搜索在tsconfig.json中加载的配置。您可以指定对整个项目(从当前目录到父目录)的编译,或者您可以为特定项目指定tsc。您可以使用以下命令找到所有可能的选项:

tsc --help 

让我们看看这个命令做了什么:

tsc 帮助命令

截至撰写时,TypeScript 的版本为2.6.2,所有上下文都将基于相同的版本进行。如果您没有更新的版本,请运行以下命令:

npm uninstall typescript -g

npm install typescript@latest -g

现在让我们看一下示例tsconfig.json文件和所有可用的选项:

{ 
   "compilerOptions":{ 
      "target":"es6",
      "moduleResolution":"node",
      "module":"commonjs",
      "declaration":false,
      "noLib":false,
      "emitDecoratorMetadata":true,
      "experimentalDecorators":true,
      "sourceMap":true,
      "pretty":true,
      "allowUnreachableCode":true,
      "allowUnusedLabels":true,
      "noImplicitAny":true,
      "noImplicitReturns":false,
      "noImplicitUseStrict":false,
      "outDir":"dist/",
      "baseUrl":"src/",
      "listFiles":false,
      "noEmitHelpers":true
   },
   "include":[ 
      "src/**/*"
   ],
   "exclude":[ 
      "node_modules"
   ],
   "compileOnSave":false
}

现在让我们解析这个文件,并了解最常用的选项。

compilerOptions

这里提到了编译项目所需的所有设置。可以在此网站找到所有编译器选项的详细列表,以及默认值:www.typescriptlang.org/docs/handbook/compiler-options.html。如果我们不指定此选项,那么将选择默认值。这是我们指示 TypeScript 如何处理各种事物的文件,例如各种装饰器、支持 JSX 文件和转译纯 JavaScript 文件。以下是一些最常用的选项,我们可以根据前面的示例代码了解:

noImplicitAny 这告诉tsc编译器,如果发现变量声明具有接受任何类型的声明,但缺少任何类型的显式类型定义,则会发出警告。
experimentalDecorators 此选项启用在 TypeScript 项目中使用装饰器。ES 尚未引入装饰器,因此默认情况下它们是禁用的。装饰器是可以附加到类声明、方法、访问器、属性或参数的任何声明。使用装饰器简化了编程。
emitDecoratorMetaData TypeScript 支持为具有装饰器的任何声明发出某些类型的元数据。要启用此选项,必须在tsconfig.json中将其设置为 true。
watch 此选项更像是livereload;每当源文件中的任何文件更改时,编译过程将重新触发,以再次生成转译文件。
reflect-metadata 它保留对象元数据中的类型信息。
module 这是输出模块类型。Node.js 使用 CommonJS,所以模块中有 CommonJS。
target 我们正在针对的输出预设;Node.js 使用 ES6,所以我们使用 ES6。
moduleResolution 此选项将告诉 TypeScript 使用哪种解析策略。Node.js 用户需要一个模块策略,因此 TypeScript 使用此行为来解析这些依赖项。
sourceMap 这告诉 TypeScript 生成源映射,可以像调试 JavaScript 一样轻松地用于调试 TypeScript。
outDir 应该保存转换后文件的位置。
baseUrlpaths 指导 TypeScript 在哪里可以找到类型文件。我们基本上告诉 TypeScript 对于每个(*)在 .ts 文件中找到的内容,它需要在文件位置 <base_url> + src/types/* 中查找。

包括和排除

在这里,我们定义了项目的上下文。它基本上采用了一个需要包含在编译路径中的全局模式数组。您可以包含或排除一组全局模式,以添加或删除文件到转换过程中。请注意,这不是最终值;还有属性文件,它们接受文件名数组,并覆盖包含和排除。

extends

如果我们想要扩展任何基本配置,那么我们可以使用此选项并指定它必须扩展的文件路径。您可以在 json.schemastore.org/tsconfig 找到 tsconfig.json 的完整模式。

理解类型

如果我们想要有效地和全局地使用 TypeScript,TypeScript 需要跨越其他 JavaScript 库。TypeScript 使用 .d.ts 文件来提供未在 ES6 或 TypeScript 中编写的 JavaScript 库的类型。一旦定义了 .d.ts 文件,就可以很容易地看到返回类型并提供简单的类型检查。TypeScript 社区非常活跃,并为大多数文件提供类型:github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types

重新审视我们的 tsconfig.json 文件,我们已经指定了选项 noImplicitAny: true,并且我们需要为我们使用的任何库都有一个强制的 *.d.ts 文件。如果将该选项设置为 false,tsc 将不会给出任何错误,但这绝对不是推荐的做法。为我们使用的每个库都有一个 index.d.ts 文件是标准做法之一。我们将看看各种主题,比如如何安装类型,如果类型不可用怎么办,如何生成类型,以及类型的一般流程是什么。

从 DefinitelyTyped 安装类型

任何库的类型都将是一个 dev 依赖项,您只需从 npm 安装它。以下命令安装 express 类型:

npm install --save-dev @types/express

此命令将下载 express 类型到 @types 文件夹,并且 TypeScript 会在 @types 文件夹中查找以解析该类型的映射。由于我们只在开发时需要它,所以我们添加了 --save-dev 选项。

编写自己的类型

许多时候,我们可能需要编写自己的 .d.ts 文件,以便有效地使用 TypeScript。我们将看看如何生成我们自己的类型,并指导 TypeScript 从哪里找到这些类型。我们将使用自动化工具,并学习如何手动编写我们自己的 .d.ts 文件,然后告诉 TypeScript 在哪里找到自定义类型的位置。

使用 dts-gen 工具

这是微软提供的一个开源实用工具。我们将使用它为任何项目生成我们的类型。作为管理员启动终端,或者使用 sudo su - 并输入以下内容:

npm install -g dts-gen

对于所有全局模块,我们将在 Windows 上使用命令提示符作为管理员,而在 Linux/Mac 上,我们将使用 root 用户或 sudo su -

我们将使用一个全局可用的模块并生成其类型。安装 lusca 并使用以下命令生成其类型:

dts-gen -m lusca

你应该看到输出,比如Wrote 83 Lines to lusca.d.ts,当你检查时,你可以看到所有的方法声明,就像一个接口一样。

编写你自己的*.d.ts 文件

当你编写自己的*.d.ts文件时,风险非常高。让我们为任何模块创建我们自己的*.d.ts文件。比如我们想为my-custom-library编写一个模块:

  1. 创建一个名为my-custom-library.d.ts的空文件,并在其中写入以下内容:
declare module my-library

这将使编译器静音,不会抛出任何错误。

  1. 接下来,你需要在那里定义所有的方法以及每个方法期望的返回类型。你可以在这里找到几个模板:www.typescriptlang.org/docs/handbook/declaration-files/templates.html。在这里,我们需要定义可用的方法以及它们的返回值。例如,看一下以下代码片段:
declare function myFunction1(a: string): string; 
declare function myFunction2(a: number): number;

调试

下一个重要的问题是如何调试返回 TypeScript 的 Node.js 应用程序。调试 JavaScript 很容易,为了提供相同的体验,TypeScript 有一个名为sourcemaps的功能。当 TypeScript 中启用 sourcemaps 时,它允许我们在 TypeScript 代码中设置断点,当命中等效的 JavaScript 行时会暂停。sourcemaps 的唯一目的是将生成的源映射到生成它的原始源。我们将简要看一下在我们的编辑器 VS Code 中调试 Node.js 和 TypeScript 应用程序。

首先,我们需要启用 sourcemaps。首先,我们需要确保 TypeScript 已启用 sourcemaps 生成。打开你的tsconfig.json文件,并写入以下内容:

"compilerOptions":{
    "sourceMap": true  
}

现在当你转译你的项目时,你会在生成的每个 JavaScript 文件旁边看到一个.js.map文件。

接下来要做的是配置 VS Code 进行调试。创建一个名为.vscode的文件夹,并添加一个名为launch.json的文件。这与使用node-inspector非常相似。我们将调试node-clusters项目,你可以在源代码中找到。在 VS Code 中打开该项目;如果没有dist文件夹,则在主级别执行tsc命令生成一个分发,这将创建dist文件夹。

接下来,创建一个名为.vscode的文件夹,在其中创建一个名为launch.json的文件,并进行以下配置:

VS Code 调试

当你点击开始调试时,会出现以下屏幕。看一下屏幕,其中有关调试点的详细描述:

VS 调试器

Node.js 入门

Node.js 经过多年的发展,现在已成为任何想要拥抱微服务的人的首选技术。Node.js 是为了解决大规模 I/O 扩展问题而创建的,当应用于我们的微服务设计时,将会产生一种天作之合。Node.js 的包管理器比 Maven、RubyGems 和 NuGet 拥有更多的模块,可以直接使用并节省大量的生产时间。异步性质、事件驱动 I/O 和非阻塞模式等特性使其成为创建高端、高效性能、实时应用程序的最佳解决方案之一。当应用于微服务时,它将能够处理极大量的负载,响应时间低,基础设施低。让我们来看一下 Node.js 和微服务的成功案例之一。

PayPal看到 Node.js 的趋势,决定在他们的账户概览页面使用 Node.js。他们对以下结果感到困惑:

  • Node.js 应用程序开发的速度是 Java 开发的两倍,而且人手更少

  • 代码的行数LOC)减少了 33%,文件减少了 40%

  • 单核 Node.js 应用程序处理的请求每秒是五核 Java 应用程序设置的两倍

Netflix、GoDaddy、Walmart 等许多公司都有类似的故事。

让我们看一些对 Node.js 开发至关重要的主要和有用的概念,这些概念将贯穿我们的旅程。我们将涉及各种主题,如事件循环、如何实现集群、异步基础知识等。

事件循环

由于 Node.js 的单线程设计,它被认为是最复杂的架构之一。作为完全事件驱动的,理解事件循环对于掌握 Node.js 至关重要。Node.js 被设计为一个基于事件的平台,这意味着在 Node.js 中发生的任何事情都只是对事件的反应。在 Node.js 中进行的任何操作都会经过一系列的回调。完整的逻辑被开发人员抽象出来,并由一个名为libuv的库处理。在本节中,我们将对事件循环有一个全面的了解,包括它的工作原理、常见误解、各种阶段等。

以下是关于事件循环的一些常见谬误以及实际工作的简要介绍:

  • 谬误#1—事件循环在与用户代码不同的线程中工作:有两个线程,一个是用户相关代码或用户相关操作运行的父线程,另一个是事件循环代码运行的线程。任何时候执行操作,父线程将工作传递给子线程,一旦子线程操作完成,就会通知主线程执行回调:

  • 事实:Node.js 是单线程的,一切都在单个线程内运行。事件循环维护回调的执行。

  • 谬误#2—线程池处理异步事件:所有异步操作,如回调到数据库返回的数据,读取文件流数据和 WebSockets 流,都会从libuv维护的线程池中卸载:

  • 事实libuv库确实创建了一个包含四个线程的线程池来传递异步工作,但今天的操作系统已经提供了这样的接口。因此,作为一个黄金法则,libuv将使用这些异步接口而不是线程池。线程池只会被用作最后的选择。

  • 谬误#3—事件循环像 CPU 一样维护操作的堆栈或队列:事件循环按照FIFO 规则维护一系列异步任务的队列,并执行队列中维护的定义的回调:

  • 事实:虽然libuv中涉及类似队列的结构,但回调并不是通过堆栈处理的。事件循环更像是一个阶段执行器,任务以循环方式处理。

理解事件循环

现在我们已经排除了关于 Node.js 中事件循环的基本误解,让我们详细了解事件循环的工作原理以及事件循环阶段执行周期中的所有阶段。Node.js 在以下阶段处理环境中发生的所有事情:

  • 定时器:这是所有setTimeout()setInterval()回调被执行的阶段。这个阶段会尽早运行,因为它必须在调用函数中指定的时间间隔内执行。当定时器被安排时,只要定时器是活动的,Node.js 事件循环将继续运行。

  • I/O 回调:除了定时器、关闭连接事件、setImmediate()之外,大多数常见的回调都在这里执行。I/O 请求既可以是阻塞的,也可以是非阻塞的。它执行更多的事情,比如连接错误,无法连接到数据库等。

  • 轮询:当阈值已经过去时,这个阶段执行定时器的脚本。它处理轮询队列中维护的事件。如果轮询队列不为空,事件循环将同步迭代整个队列,直到队列为空或系统达到硬峰值大小。如果轮询队列为空,事件循环将继续下一个阶段——检查并执行那些定时器。如果没有定时器,轮询队列是空闲的,它将等待下一个回调并立即执行它。

  • 检查:当轮询阶段处于空闲状态时,将执行检查阶段。现在将执行使用setImmediate()排队的脚本。setImmediate()是一个特殊的计时器,它使用libuv API,并且安排在轮询阶段之后执行回调。它被设计成在轮询阶段之后执行。

  • 关闭回调:当任何句柄、套接字或连接突然关闭时,将在此阶段发出关闭事件,例如socket.destroy(),连接close(),也就是说,所有(close)事件回调都在此处处理。虽然不是事件循环的技术部分,但另外两个主要阶段是nextTickQueue和其他微任务队列。nextTickQueue在当前操作完成后处理,不管事件循环的阶段如何。它会立即触发,在调用它的同一阶段,并且独立于所有阶段。nextTick 函数可以包含任何任务,它们只是按照以下方式调用:

process.nextTick(() => {
  console.log('next Tick')
})

接下来重要的部分是微任务和宏任务。NextTickQueue的优先级高于微任务和宏任务。任何在nextTickQueue中的任务都将首先执行。微任务包括已解决的 promise 回调等函数。一些微任务的例子可以是promise.resolveObject.resolve。这里有一个有趣的地方要注意,原生 promise 只属于微任务。如果我们使用qbluebird等库,我们会看到它们首先被解决。

Node.js 集群和多线程

任何 Node.js 实例都在单个线程中运行。如果发生任何错误,线程会中断,服务器会停止,你需要重新启动服务器。为了利用系统中所有可用的核心,Node.js 提供了启动一组 Node.js 进程的选项,以便负载均衡。有许多可用的工具可以做同样的事情。我们将看一个基本的例子,然后学习关于PM2这样的自动化工具。让我们开始吧:

  1. 第一步是创建一个 express 服务器。我们需要expressdebugbody-parsercookie-parser。打开终端并输入以下命令:
npm install body-parser cookie-parser debug express typescript --save
  1. 接下来,我们下载这些模块的类型:
npm install @types/debug @types/node @types/body-parser @types/express
  1. 然后,我们创建我们的app.tswww.ts文件。构建你的app.ts文件如下:

表达 TypeScript 的方式

  1. 对于www.ts,我们将使用cluster模块,并创建可用作核心数量的工作进程。我们的逻辑将分为以下几部分:
import * as cluster from "cluster";
import { cpus } from "os";
if (cluster.isMaster) {
  /* create multiple workers here cpus().length will give me number of   cores available
  */
  cluster.on("online", (worker) => { /*logic when worker becomes online*/ });
  cluster.on("exit", (worker) => { /*logic when worker becomes online*/ });
} else {
  //our app intialization logic 
}
  1. 现在当我们转译源代码并运行www.js时,我们会看到多个工作进程在线。

完整的文件可以在node-clusters/src/bin/www.ts中找到。去运行应用程序。现在你应该看到多个工作进程在线了。

另一种方法是使用PM2 (www.npmjs.com/package/pm2)。PM2 有各种选项,如livereload,零停机重新加载和集群启动模式。PM2 中可用的一些示例命令如下:

pm2 start www.js -i 4 在集群模式下启动应用程序的四个实例。它将平衡负载到每个节点。
pm2 reload www.js 重新加载www.js并进行零停机时间。
pm2 scale www.js 10 将集群应用程序扩展到 10 个进程。

异步/等待

由于 JavaScript 是异步的,一旦一个进程完成,就很难维护任务的执行。曾经以回调开始,很快就转向了 promises、async 模块、生成器和 yield,以及 async 和 await。让我们从 async/await 101 开始:

  • 异步/等待是编写异步代码的现代方式之一

  • 建立在 promise 之上,不能与普通回调或 Node promises 一起使用

  • 异步/等待是非阻塞代码,尽管它看起来是同步的,这是它的主要优势

  • 基于node-fibers,它是轻量级的,并且对 TypeScript 友好,因为类型已嵌入其中

现在让我们看一下 async/await 的实际实现。曾经作为巨大的回调地狱和嵌套的.then()链的东西现在可以简化为以下内容:

let asyncReq1=await axios.get('https://jsonplaceholder.typicode.com/posts/1');
console.log(asyncReq1.data);
let asyncReq2=await axios.get('https://jsonplaceholder.typicode.com/posts/1');
console.log(asyncReq2.data);

现在我们将研究两种常见的 async/await 设计模式。

重试失败的请求

通常,我们在系统中添加安全或重试请求,以确保如果服务返回错误,我们可以在服务暂时关闭时重试服务。在此示例中,我们有效地使用了异步/等待模式作为指数重试参数,即 1、2、4、8 和 16 秒后重试。可以在源代码中的retry_failed_req.ts中找到一个工作示例:

wait(timeout: number){
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve()
    }, timeout)
  })
 } 
async requestWithRetry(url: string){
  const MAX_RETRIES = 10;
  for (let i = 0; i <= MAX_RETRIES; i++) {
    try { return await axios.get(url); }
    catch (err) {
      const timeout = Math.pow(2, i);
      console.log('Waiting', timeout, 'ms');
      await this.wait(timeout);
      console.log('Retrying', err.message, i);
    }
  }
}

您将看到以下输出:

指数重试请求

并行多个请求

使用 async/await 执行多个并行请求变得非常简单。在这里,我们可以同时执行多个异步任务,并在不同的地方使用它们的值。完整的源代码可以在src中的multiple_parallel.ts中找到:

async function executeParallelAsyncTasks() {
  const [valueA, valueB, valueC] = await
    Promise.all([
            await axios.get('https://jsonplaceholder.typicode.com/posts/1')
            await axios.get('https://jsonplaceholder.typicode.com/posts/2'),
            await axios.get('https://jsonplaceholder.typicode.com/posts/3')])
            console.log("first response is ", valueA.data);
            console.log(" second response is ", valueB.data);
            console.log("third response is ", valueC.data);
      }

简而言之,是 Node.js 中用于连续流式传输数据的抽象接口。流可以是从源头不断传输数据到目的地的数据序列。源头可以是任何东西——5000 万条记录的数据库,大小为 4.5GB 的文件,一些 HTTP 调用等等。流不是一次性全部可用的;它们不适合内存,它们只是一次传输一些数据块。流不仅用于处理大文件或大量数据,而且它们通过管道和链接提供了一个很好的组合选项。流是响应式编程的一种方式,我们将在下一章中更详细地讨论。Node.js 中有四种流可用:

  • 可读流:只能从中读取数据的流;也就是说,这里只能消耗数据。可读流的示例可以是客户端上的 HTTP 响应、zlib流和fs读取流。该流中的任何阶段的数据都处于流动状态或暂停状态。在任何可读流上,可以附加各种事件,如数据、错误、结束和可读。

  • 可写流:可以写入数据的流。例如,fs.createWriteStream()

  • 双工流:可读可写的流。例如,net.socket或 TCP 套接字。

  • 转换流:基本上是一个双工流,可以在写入或读取数据时用于转换数据。例如,zlib.createGzip是用于使用 gzip 压缩大量数据的流之一。

现在,让我们通过一个示例来了解流的工作原理。我们将创建一个自定义的Transform流,并扩展Transform类,从而一次看到读取、写入和转换操作。在这里,转换流的输出将从其输入中计算出来:

  • 问题:我们有用户的信息,我们想隐藏敏感部分,如电子邮件地址、电话号码等。

  • 解决方案:我们将创建一个转换流。转换流将读取数据并通过删除敏感信息来进行转换。所以,让我们开始编码。创建一个空项目,使用npm init,添加一个文件夹src和之前部分的tsconfig.json文件。现在,我们将从DefinitelyTyped中添加 Node.js 类型。打开终端并输入以下内容:

npm install @types/node --only=dev

现在,我们将编写我们自定义的过滤器转换流。创建一个filter_stream.ts文件,在其中编写转换逻辑:

import { Transform } from "stream";
export class FilterTransform extends Transform {
  private filterProps: Array<String>;
  constructor(filterprops: Array<String>, options?: any) {
    if (!options) options = {};
    options.objectMode = true;
    super(options);
    this.filterProps = filterprops;
  }
  _transform(chunk: any, encoding?: string, callback?: Function) {
    let filteredKeys = Object.keys(chunk).filter((key) => {
      return this.filterProps.indexOf(key) == -1;
    });
    let filteredObj = filteredKeys.reduce((accum: any, key: any) => {
    accum[key] = chunk[key];
      return accum;
    }, {})
    this.push(filteredObj);
    callback();
  }
  _flush(cb: Function) {
    console.log("this method is called at the end of all transformations");
  }
}

我们刚刚做了什么?

  • 我们创建了一个自定义的转换并导出它,这样它可以在其他文件中的任何地方使用。

  • 如果构造函数中的选项不是必需的,我们可以创建默认选项。

  • 默认情况下,流期望缓冲区/字符串值。有一个objectMode标志,我们必须在流中设置它,以便它可以接受任何 JavaScript 对象,这是我们在构造函数中做的。

  • 我们重写了 transform 方法以适应我们的需求。在 transform 方法中,我们删除了在过滤选项中传递的那些键,并创建了一个经过过滤的对象。

接下来,我们将创建一个过滤器流的对象,以测试我们的结果。并行创建一个名为stream_test.ts的文件,添加以下内容:

import { FilterTransform } from "./filter_stream";
//we create object of our custom transformation & pass phone and email as sensitive properties
let filter = new FilterTransform(['phone', 'email']);
//create a readable stream that reads the transformed objects.
filter.on('readable', function () { console.log("Transformation:-", filter.read()); });
//create a writable stream that writes data to get it transformed
filter.write({ name: 'Parth', phone: 'xxxxx-xxxxx', email: 'ghiya.parth@gmail.com', id: 1 });
filter.write({ name: 'Dhruvil', phone: 'xxxxx-xxxxx', email: 'dhruvil.thaker@gmail.com', id: 2 });
filter.write({ name: 'Dhaval', phone: 'xxxxx-xxxxx', email: 'dhaval.marthak@gmail.com', id: 3 });
filter.write({ name: 'Shruti', phone: 'xxxxx-xxxxx', email: 'shruti.patel@gmail.com', id: 4 });
filter.end();

打开您的package.json文件,并在scripts标签中添加"start":"tsc && node .\\dist\\stream_test.js"。现在当您运行npm start时,您将能够看到转换后的输出。

请注意,如果您使用的是 Linux/macOS,请用//替换\\

编写您的第一个 Hello World 微服务

让我们从编写我们的第一个微服务开始。基于之前的主题,我们将使用最佳实践和广泛使用的node_modules构建我们的第一个微服务。我们将使用:

CORS (www.npmjs.com/package/cors) 添加 CORS 标头,以便跨应用程序可以访问它。
Routing Controllers (www.npmjs.com/package/routing-controllers) 此模块提供了美丽的装饰器,帮助我们轻松编写 API 和路由。
Winston (www.npmjs.com/package/winston) 具有许多高级功能的完美日志记录模块。

因此,打开终端并创建一个带有默认package.json的 Node 项目。按照以下步骤进行。可在提取源中的first-microservice文件夹中找到用于参考的完整源代码:

  1. 首先,我们将下载前面的依赖项和基本的 express 依赖项。输入以下命令行:
npm install body-parser config cookie-parser cors debug express reflect-metadata rimraf routing-controllers typescript winston --save
  1. 按照以下方式下载必要模块的类型:
npm install @types/cors @types/config @types/debug @types/node @types/body-parser @types/express @types/winston --only=dev
  1. 现在,我们将创建我们的应用程序结构,如下截图所示:

文件夹结构

  1. 因此,让我们创建我们的 express 文件,并使用routing_controllers模块进行配置。创建一个 express 配置类,并指示它使用我们的目录控制器作为可以找到路由的源:
export class ExpressConfig {
  app: express.Express;
  constructor() {
    this.app = express();
    this.app.use(cors());
    this.app.use(bodyParser.json());
    this.app.use(bodyParser.urlencoded({ extended: false }));
    this.setUpControllers();
  }
  setUpControllers() {
    const controllersPath = path.resolve('dist', 'controllers');
    /*useExpressServer has lots of options, can be viewed at node_modules\routing-controllers\RoutingControllersOptions.d.ts*/
    useExpressServer(this.app, {
      controllers: [controllersPath + "/*.js"]
    }
    );
  }
}
  1. 现在,让我们在application.ts中编写我们的应用程序启动逻辑:
export class Application {
  server: any; express: ExpressConfig;
  constructor() {
  this.express = new ExpressConfig();
    const port = 3000; this.server =
      this.express.app.listen(port, () => {
        logger.info(`Server Started! Express: http://localhost:${port}`);
      });
  }
}
  1. 下一步是编写我们的控制器并返回 JSON:
@Controller('/hello-world')
export class HelloWorld {
  constructor() { }
  @Get('/')
  async get(): Promise<any> {
    return { "msg": "This is first Typescript Microservice" }
  }
}
  1. 下一步是在index.ts中创建我们的Application文件的新对象:
'use strict';
/* reflect-metadata shim is required, requirement of routing-controllers module.*/
import 'reflect-metadata';
import { Application } from './config/Application';
export default new Application();
  1. 您已经完成了;编译您的 TypeScript 并启动index.ts的转译版本。当您访问localhost:3000/hello-world时,您将看到 JSON 输出—{"msg":"This is first Typescript Microservice"}

  2. 为了在启动服务器时自动执行所有任务,我们在package.json中定义脚本。第一个脚本是始终在转译之前进行清理:

"clean":"node ./node_modules/rimraf/bin.js dist",

下一个脚本是使用node模块中可用的typescript版本构建 TypeScript:

"build":"node ./node_modules/typescript/bin/tsc"

最后一个基本上指示它清理、构建并通过执行index.js启动服务器:

"start": "npm run clean && npm run build && node ./dist/index.js".
  1. 下一步是创建 Docker 构建。创建一个Docker文件,让我们编写 Docker 镜像脚本:
#LATEST NODE Version -which node version u will use.
FROM node:9.2.0
# Create app directory
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
#install depedencies
COPY package.json /usr/src/app
RUN npm install
#bundle app src
COPY . /usr/src/app
CMD [ "npm" , "start" ]
  1. 我们将在以后的章节中更详细地学习 Docker。现在,继续并输入以下命令:
sudo docker build -t firstypescriptms .

在构建镜像时,不要忘记命令的末尾加上点。点表示我们在本地目录中使用 Dockerfile。

您的 Docker 镜像将被构建。您将看到以下类似的输出:

Docker 创建镜像

  1. 您可以使用sudo docker images命令来检查镜像,稍后您可以在任何地方使用它。要运行镜像,只需使用以下命令行:
sudo docker run -p 8080:3000 -d firstypescriptms:latest
  1. 之后,您可以访问localhost:8080/hello-world来检查输出。

虽然我们只是暴露了 REST API,对外部世界来说,它只是 8080 端口上的另一个服务;内部实现对消费者来说是抽象的。这是 REST API 和微服务之间的主要区别之一。容器内的任何内容都可以随时更改。

总结

在本章中,我们首先介绍了一些 Node.js 和 TypeScript 的最基本概念,这些概念对于开发适合企业需求的可扩展应用程序至关重要。我们搭建了我们的主要环境,并学习了 Docker、PM2 和 NGINX。最后,我们用 Node.js 的 TypeScript 方式创建了我们传统的Hello World微服务。

在下一章中,我们将学习响应式编程的基本原理、响应式编程的优势,以及如何在 Node.js 中进行响应式编程。我们将了解响应式编程中提供的各种操作符,这些操作符可以简化我们日常开发工作。我们将结合传统的基于 SOA 的编排和响应式流程,通过各种情况来看看哪种方法适用于哪里。

第三章:探索响应式编程

到目前为止,我们描述了我们的应用程序是非常著名的行业术语的混合体,如异步、实时、松散耦合、可扩展、分布式、消息驱动、并发、非阻塞、容错、低延迟和高吞吐量。在本章中,我们将进一步了解响应式编程,它将所有这些特征汇集在一起。我们将看到并了解响应式宣言-一组原则,当集体应用时,将带来所有前述的优势。我们将了解响应式微服务的一些关键方面,它应该是什么,响应式编程的主要优势是什么。我们将看看响应式编程解决了什么问题,响应式编程的不同风格,等等。

在本章中,我们将重点关注以下内容:

  • 响应式编程介绍

  • 响应式宣言

  • 响应式微服务-主要构建模块和关注点

  • 何时反应,何时不反应(编排)-混合方法的介绍

  • 在 Node.js 中成为响应式

响应式编程介绍

如果我们想从 5 万英尺的高空看响应式编程的视图,它可以简要地被称为:

当任何函数中的输入 x 发生变化时,相应的输出 y 会在对应的响应中自动更新,而无需手动调用。简而言之,唯一的目的是在输出世界提示时不断响应外部输入。

响应式编程是通过 map、filter、reduce、subscribe、unsubscribe、streams 等实用程序实现的。响应式编程更注重事件和消息驱动模式,而不是手动处理庞大的实现细节。

让我们以一个实际的日常例子来理解响应式编程。我们从 IT 生涯的开始就使用 Excel。现在,假设你根据一个单元格的值编写一个公式。现在,每当单元格的值发生变化时,基于该值的所有相应结果都会自动反映出变化。这就是所谓的响应式

简要了解响应式编程,当与处理各种数据流相结合时,响应式编程可以是具有处理以下内容的高级数据流的能力:

  • 事件流,我们可以接入和订阅的流,然后使用订阅输出作为数据源。

  • 拥有流使我们能够操作流,从原始流创建新流,并根据需要应用转换。

  • 转换应该在分布式系统中独立工作。特定的转换可以是从各个地方接收到的多个流的合并。

我们将使用函数式响应式编程。简而言之,我们的函数式响应式微服务应具有以下两个基本属性:

  • 指示性或表示性:每个函数、服务或类型都是精确、简单、单一、负责和实现无关的。

  • 连续时间:编程应该考虑到时间变化的值。函数式响应式编程中的变量值持续时间很短。它应该为我们提供转换灵活性、效率、模块化、单一责任。

函数式响应式编程的特点如下:

  • 动态:知道如何对时间做出反应或处理各种输入变化

  • 处理时间变化:当反应值不断变化时,处理适当的变化

  • 高效:当输入值发生变化时,只在需要时进行最少量的处理

  • 了解历史转换:在本地维护状态变化,而不是全局

既然我们简要了解了响应式编程,让我们看看在采用响应式编程时我们能得到什么优势。下一节将讨论并给出非常强烈的理由,说明为什么你应该放下一切开始响应式编程。

为什么我应该考虑采用反应式编程?

现在我们已经揭开了反应式编程的神秘面纱,下一个重要问题是为什么我们应该关注反应式编程以及在进行反应式编程时可以获得什么优势。在本节中,我们将看到反应式编程的主要优势以及如何轻松地管理代码,以在任何时候引入重大新功能:

  • 与回调或中间件相比,更容易解释或利用任何功能。

  • 轻松处理错误和内存管理,无需任何集中配置。单个订阅可以有一个错误函数,您可以轻松地处理资源。

  • 高效处理与时间相关的复杂性。有时,我们受到调用一些外部 API 的速率限制约束,例如 Google Cloud Vision API。在这种情况下,反应式编程具有巨大的用例。

  • 走向市场率更快。当正确实施时,反应式编程大大减少了老式代码到很少的代码行。

  • 易于处理可节流的输入流,即,我的输入流是动态的。它可以根据需求增加或减少。

现在我们已经了解了反应式编程的一些主要优势,在下一节中我们将讨论反应式编程的结果,即反应式系统。我们将看到在反应式宣言中定义的一组标准。

反应式宣言

反应式系统旨在更松散耦合,灵活,易于迁移,并且可以根据需求轻松扩展。这些特质使其易于开发,优雅地处理故障,并对错误做出反应。错误会得到优雅的处理,而不是引起恐慌性灾难。反应式系统是有效的,并立即做出反应,为用户提供有效和互动的反馈。为了总结反应式系统的所有特征,引入了反应式宣言。在本节中,我们将看一下反应式宣言和所有所需的标准。现在,让我们看看反应式宣言陈述了什么。

响应式系统

作为响应标准的一部分,反应式系统始终需要响应。它们需要及时地向用户提供和响应。这提高了用户体验,我们可以更好地处理错误。服务的任何故障都不应传播到系统,因为这可能会导致一系列错误。响应是一个重要的事情。即使服务降级,也应该提供响应。

对错误具有弹性

系统应该对所有错误具有弹性。弹性应该是这样的,错误应该得到优雅处理,而不是导致整个系统崩溃。可以通过以下方式实现弹性架构:

  • 复制以确保在主节点出现故障时有一个副本。这避免了单点故障。为了确保组件或服务应该以这样一种方式委托服务,以便单一责任得到处理。

  • 确保系统中的组件被包含在其边界内,以防止级联错误。组件的客户端不需要处理自己的故障。

弹性可扩展

这通常用于指代系统处理不同负载的能力,通过增加或减少在某个时间内利用的资源数量。反应式系统应该能够对某一时刻的负载做出反应,并相应地采取行动来提供成本效益的解决方案,即在资源不需要时缩减规模,在需要时仅扩展到所需资源的百分比,以保持基础设施成本在预设值以下。系统应该能够分片或复制组件,并在它们之间分发输入。系统应该能够根据需要为客户服务请求生成下游和上游服务的新实例。应该有一个高效的服务发现过程来帮助弹性扩展。

消息驱动

异步消息传递是反应式系统的基础。这有助于我们在组件之间建立边界,并同时确保松耦合、隔离和位置透明性。如果某个特定组件现在不可用,系统应该将失败委托为消息。这种模式帮助我们通过控制系统中的消息队列来实现负载管理、弹性和流量控制,并在需要时应用反压。非阻塞通信会减少系统开销。有许多可用于消息传递的工具,如 Apache Kafka、Rabbit MQ、Amazon Simple Queue Service、ActiveMQ、Akka 等。代码的不同模块通过消息传递相互交互。深入思考反应式宣言,微服务似乎只是反应式宣言的延伸。

主要构建模块和关注点

继续我们的反应式之旅,我们现在将讨论反应式编程(确切地说是函数式反应式编程)的主要构建模块以及反应式微服务实际应该处理的关注点。以下是反应式编程的主要构建模块及其处理的内容。反应式微服务应该基于类似的原则进行设计。这些构建模块将确保微服务是隔离的,具有单一职责,可以异步传递消息,并且是可移动的。

可观察流

可观察流实际上就是随时间构建的数组。项目不是存储在内存中,而是随时间异步到达。可观察流可以被订阅,并且可以监听并对其发出的事件做出反应。每个反应式微服务都应该能够处理本机可观察事件流。可观察对象允许您通过调用系列中的next()函数向订阅者发出值。

  • 热和冷可观察流:可观察流根据订阅者的生产者进一步分类为热和冷。如果需要创建多次,则称为热可观察流,而如果只需要创建一次,则称为冷可观察流。简单来说,热可观察流通常是多播,而冷可观察流通常是单播。举个例子,当你在 YouTube 上打开任何视频时,每个订阅者都会看到相同的序列,从开始到结束,这基本上是一个冷可观察流。然而,当你打开一个直播流时,你只能看到最近的视图并进一步查看。这是一个热可观察流,其中只有对生产者/订阅者的引用,生产者并不是从每次订阅的开始创建的。

  • 主题:主题只是一个可以自行调用next()方法的可观察对象,以便根据需要发出新值。主题允许您从一个公共点广播值,同时限制订阅只发生一次。创建一个共享订阅。主题可以被称为观察者和可观察对象。它可以充当一组订阅者的代理。主题用于实现通用工具的可观察对象,如缓存、缓冲、日志等。

订阅

虽然可观察对象是随时间填充的数组,但订阅是一个随时间迭代该数组的for循环。订阅提供了易于使用和易于处理的方法,因此没有内存加载问题。在取消订阅时,可观察对象将停止监听特定的订阅。

发射和映射

当一个可观察对象抛出一个值时,有一个订阅者监听可观察对象抛出的值。发射映射允许您监听这个值并根据您的需求对其进行操作。例如,它可以用于将 HTTP 可观察对象的响应转换为 JSON。为了进一步扩展链,提供了flatMap操作符,它从函数的返回值创建一个新的流。

操作符

当一个可观察对象发出值时,它们并不总是以我们期望的形式。操作符很有用,因为它们帮助我们改变可观察对象发出值的方式。操作符可以在以下阶段使用:

  • 在创建可观察序列时

  • 将事件或一些异步模式转换为可观察序列

  • 处理多个可观察序列,将它们合并为单个可观察对象

  • 共享可观察对象的副作用

  • 对可观察序列进行一些数学转换

  • 基于时间的操作,如节流

  • 处理异常

  • 过滤可观察序列发出的值

  • 分组和窗口化发出的值

反压策略

到目前为止,我们已经玩过可观察对象和观察者。我们使用数据流(可观察对象)模拟了我们的问题,将其转换为我们期望的输出(使用操作符),并丢弃了一些值或一些副作用(观察者)。现在,也可能出现这样一种情况,即可观察对象的数据抛出速度比观察者处理速度快。这最终导致数据丢失,这就是反压问题。为了处理反压,我们需要接受数据丢失,或者我们需要缓冲可观察流并在不允许数据丢失时以块的方式处理它。在这两种选择中都有不同的策略:

当输掉是一个选择 当输掉不是一个选择
去抖动:只有在经过一段时间后才发出数据。 缓冲:设置一定时间或最大事件数来缓冲。
暂停:暂停源流一段时间。 缓冲暂停:缓冲源流发出的任何内容。
受控流:这是生产者推送事件,消费者只拉取它能够处理的事件的推送-拉取策略。

柯里化函数

柯里化是一个逐个评估函数参数的过程,在每次评估结束时产生一个少一个参数的新函数。当函数的参数需要在不同的地方进行评估时,柯里化是有用的。使用柯里化过程,一个参数可以在某个组件中进行评估,然后可以传递到任何其他地方,然后结果可以传递到另一个组件,直到所有参数都被评估。这似乎与我们的微服务类比非常相似。当我们有服务依赖关系时,我们将在以后使用柯里化。

何时做出反应,何时不做出反应(协调)

现在,我们已经熟悉了微服务的核心概念。我们经常接触的下一个问题是关于微服务的实现,以及它们如何相互交互。最常见的问题是何时使用编排,何时使用反应,以及是否可能使用混合方法。在本节中,我们将了解每种方法,其优缺点,并查看每种方法的实际示例。让我们从编排开始。

编排

编排更多地是一种面向服务的架构(SOA)方法,在 SOA 中我们处理各种服务之间的交互。当我们说编排时,我们维护一个控制器,即编排者或所有服务交互的主协调者。这通常遵循更多的请求/响应类型模式,其中通信模式可以是任何东西。例如,在我们的购物微服务中可以有一个编排者,它同步执行以下任务——首先接受客户订单,然后检查产品,准备账单,成功付款后更新产品库存。

优势

它提供了一种系统化的处理编程流程的方式,您可以实际控制请求的发出方式。例如,您可以确保只有在请求 A 完成后才能成功调用请求 B。

缺点

虽然编排模式看起来有利,但这种模式涉及到一些权衡,比如:

  • 对系统有严格的依赖。比如如果最初的某个服务宕机,那么链中的下一个服务将永远不会被调用。系统很快就会成为一个瓶颈,因为会有几个单点故障。

  • 系统中将引入同步行为。总的端到端时间将是处理所有单个服务所需时间的总和。

反应式方法

微服务是为了能够独立存在的。它们不应该相互依赖。反应式方法倾向于解决编排方法的一些挑战。与控制逻辑在何时发生哪些步骤的编排器不同,反应式模式促进了服务知道逻辑要提前构建和执行。服务知道要对什么做出反应以及如何提前处理。服务之间的通信模式是愚蠢的管道,它们内部没有任何逻辑。由于其异步性质,它消除了编排过程中的等待部分。服务可以产生事件并继续处理。生产和消费服务是解耦的,因此生产者不需要知道消费者是否在线。在这种方法中可以有多种模式,其中生产者可能希望从消费者那里收到确认。集中的事件流在反应式方法中处理所有这些事情。

优势

反应式方法有很多优势,它克服了许多传统问题:

  • 并行或异步执行可以更快地完成端到端处理。异步处理基本上不会在提供请求时阻止资源。

  • 拥有集中的事件流或愚蠢的通信管道作为通信模式具有在任何时间点轻松添加或删除任何服务的优势。

  • 系统的控制是分布式的。系统中不再有单一故障点作为编排者。

  • 当这种方法与其他几种方法结合时,就可以实现各种好处。

  • 当这种方法与事件溯源结合时,所有事件都被存储,并且它可以进行事件重放。因此,即使某个服务宕机,事件存储仍然可以在服务再次在线时重放该事件,并且服务可以检查更新。

  • 另一个优点是命令查询责任分离CQRS)。如第一章所示,揭秘微服务,我们可以将这种模式应用于分离读取和写入活动。因此,任何服务都可以独立扩展。这在应用程序是读取或写入密集型的情况下非常有帮助。

缺点

虽然这种方法解决了大部分复杂性,但也引入了一些权衡:

  • 异步编程有时可能很难处理。仅通过查看代码无法弄清楚。必须深入了解事件循环,如第二章所示,为旅程做准备,才能理解异步编码的实际工作流程。

  • 复杂性和集中的代码现在转移到了各个服务中。流程控制现在被分解并分布到所有服务中。这可能会在系统中引入冗余代码。

像所有事物一样,一刀切的方法在这里行不通。出现了几种混合方法,它们充分利用了两种过程。现在让我们来看看一些混合方法。混合方法可以增加很多价值。

外部反应,内部编排

第一个混合模式促进了不同微服务之间的反应式模式和服务内的编排。让我们举个例子来理解这一点。考虑我们的购物微服务示例。每当有人购买产品时,我们将检查库存,计算价格,处理付款,结账付款,添加推荐产品等。每个微服务都是不同的。在这里,我们可以在产品库存服务、付款服务和推荐产品之间采用反应式方法,在结账服务、处理付款和发货产品之间采用编排方法。一个集体服务根据这三个服务的结果产生一个事件,然后可以产生。有几个优点和附加值,例如:

  • 大多数服务是解耦的。只有在需要时才会出现编排。应用程序的整体流程是分布式的。

  • 具有异步事件和基于事件的方法可确保没有单点故障。如果服务错过了事件,那么可以在服务再次上线时重放事件。

虽然有几个优点,但也引入了一些权衡:

  • 如果服务耦合在一起,它们很快就会成为单点故障。它们无法独立扩展。

  • 同步处理可能会导致系统阻塞,资源会被占用,直到请求完全完成。

驱动流程的反应式协调器

第二种方法引入了更像是反应式协调器的东西,用于驱动各种服务之间的流程。它更多地使用基于命令和基于事件的方法来控制整个生态系统的整体流程。命令指示需要完成的任务,事件是完成命令的结果。反应式协调器接收请求并生成命令,然后将其推送到事件流中。已经为命令设置的各种微服务消耗这些命令,进行一些处理,然后在成功执行命令时抛出一个事件。反应式协调器消耗这些事件,并根据需要编程和反应事件。这种方法有几个附加值,例如:

  • 服务是解耦的;即使协调器和服务之间似乎存在耦合,但反应式方法和集中的事件流解决了大部分以前的缺点。

  • 事件流或集中的事件总线确保微服务之间的异步编程。事件可以按需重放。没有单点故障。

  • 整体流程可以在反应式协调器中集中在一个地方。所有这样的集中逻辑都可以在那里保留,而且任何地方都不会有重复的代码。

虽然有很多好处,但这种方法引入了以下权衡——协调器需要被照顾。如果协调器出现问题,整个系统可能会受到影响。协调器需要知道需要哪些命令以便做出反应或执行一组预设动作。

概要

在经历了纯反应式、纯编排和两种不同的混合方法之后,我们现在将介绍可以应用前面四种方法的各种用例。我们将学习哪种方法适用于哪种情况。

当纯反应式方法是一个完美的选择时

在以下情况下,纯粹的反应式方法是一个完美的选择:

  • 当应用程序中的大部分处理可以异步完成时。当应用程序可以进行并行处理时,反应式架构模式非常适合处理应用程序需求。

  • 在每个服务中分散应用程序流程是可以管理的,而且不会成为一个痛点。对于监控和审计,可以使用相关 ID(UUIDGUIDCUID)生成集中视图。

  • 当应用程序需要快速部署,市场速度是最重要的目标。当微服务与反应式方法结合时,它有助于增加解耦,最小化依赖关系,处理临时关闭的情况,从而有助于更快地将产品推向市场。

当纯编排是一个完美的选择

在以下情况下,纯编排方法是一个完美的选择:

  • 当应用程序的需求无法通过并行处理满足时。所有步骤必须按顺序进行处理,没有机会进行并行处理。

  • 如果应用程序需要集中的流程控制。各个领域,如银行ERP,都有需要在一个地方查看端到端流程的需求。如果有 100 个服务,每个服务都有自己的控制流程,那么维护集中的流程可能很快成为分发的瓶颈。

在外部反应,内部编排是一个完美的选择

在以下情况下,混合方法,更具体地说是在外部反应,在内部编排,是一个完美的选择:

  • 大部分处理可以异步完成。您的服务可以通过事件流相互通信,并且您可以在系统中进行并行处理,也就是说,您可以通过事件流或基于系统的命令传递数据。例如,每当付款成功入账时,一个微服务显示相关产品,另一个微服务将订单发送给卖家。

  • 在每个微服务中分散流程很容易管理,而且不会在各处重复代码。

  • 市场速度是主要优先事项。

  • 顺序步骤不适用于系统,但适用于服务。只要顺序步骤不适用于整个系统。

引入反应式协调器是完美的选择时

在以下情况下,引入一个反应式协调器是完美的解决方案:

  • 根据正在处理的数据,应用程序的流程可能会发生变化。流程可能包括数百个微服务,应用程序需要临时关闭,一旦应用程序恢复在线,事件就可以被重放。

  • 系统中有几个需要同步处理的异步处理块。

  • 它允许轻松的服务发现。服务可以随时轻松扩展。整个服务可以轻松地移动。

根据您的整体需求,您可以在微服务架构中选择任何一种策略。

在 Node.js 中成为反应式

现在我们已经了解了响应式编程的概念和在微服务中的优势,让我们现在看看在 Node.js 中响应式编程的一些实际实现。在本节中,我们将通过在 Node.js 中实现响应式编程来了解响应式编程的构建模块。

Rx.js

这是最流行的库之一,它得到了积极的维护。该库以不同形式提供给大多数编程语言,如RxJavaRxJSRx.NetRxScalaRxClojure等。在撰写本文时,上个月的下载量超过 40 万次。除此之外,该库还有大量的文档和在线支持可用。我们将大部分时间使用这个库,除非需要其他库。您可以在以下网址查看:reactivex.io/。在撰写本文时,Rx.js 的稳定版本是5.5.6。Rx.js 有很多操作符。我们可以使用 Rx.js 操作符进行各种操作,如组合各种内容,根据需要应用条件,从承诺或事件创建新的 observables,错误处理,过滤数据,具有发布者-订阅者模式,转换数据,请求-响应工具等。让我们快速动手试试。为了安装 RxJS,我们需要安装 Rx 包和 Node-Rx 绑定。打开终端并输入npm install rx node-rx --save。由于此库需要支持我们的 Node.js 作为构建系统,因此我们还需要安装一个模块。在终端中输入以下命令:npm install @reactivex/rxjs --save。在本章中,我们将使用我们在第二章中创建的Hello World微服务骨架,并继续进行。以下是我们将在演示中看到的各种选项:

forkjoin 当我们有一组 observable 并且只想要最后一个值时使用。如果其中一个 observable 永远不完成,则无法使用此操作符。
combineAll 通过等待外部 observable 完成,然后自动应用combineLatest来简化/组合 observable 的 observable。
race 首先发出值的 observable 将被使用。
retry 如果发生错误,重试特定次数的 observable 序列。
debounce 忽略少于指定时间的发出值。例如,如果我们将防抖设置为一秒,那么在一秒之前发出的任何值都将被忽略。
throttle 仅在由提供的函数确定的持续时间后发出值。

以下示例将值节流到两秒钟:

const source = Rx.Observable.interval(1000);
const example2 = source.throttle(val => Rx.Observable.interval(2000));
const subscribe2 = example2.subscribe(val => console.log(val));

以下示例将在 observables 上触发竞争条件:

let example3=Rx.Observable.race(
 Rx.Observable.interval(2000)
            .mapTo("i am first obs"),
  Rx.Observable.of(1000)
            .mapTo("i am second"),
 Rx.Observable.interval(1500)
            .mapTo("i am third")
  )
let subscribe3=example3.
                  subscribe(val=>console.log(val));

您可以在源文件夹中的using_rxjs中跟随源代码。在前面的表中,可以在rx_combinations.tsrx_error_handing.tsrx_filtering.ts中找到所有操作符的示例。可以在reactivex.io/rxjs/找到完整的 API 列表。

Bacon.js

Bacon.js是一个小巧的函数式响应式编程库。与 Node.js 集成后,您可以轻松将混乱的代码转换为清晰的声明式代码。它每月的下载量超过 29,000 次。在撰写本文时,可用的版本是1.0.0。让我们快速动手试试。为了安装 Bacon.js,我们需要安装 Bacon.js 及其类型。打开终端并输入npm install baconjs --savenpm install @types/baconjs --only=dev。现在,让我们看一个基本示例,看看代码有多清晰。我们有一个 JSON 对象,其中一些产品与数字1(手机)、2(电视)等相对应。我们创建一个服务来返回产品名称,如果产品不存在,则应返回Not found。以下是服务代码:

baconService(productId: number){
  return Bacon.constant(this.productMap[productId])
}

以下是控制器代码:

@Get('/:productId')
async get(@Req() req: Request,@Res() res: Response,@Param("productId") productId: number) {
  let resp: any;
  this.baconService.baconService(productId)
    .flatMap((x) => {
      return x == null || undefined ? "No Product Found" : x;
    })
    .onValue((o: string) => {
      resp = o;
    })
  return resp;
} 

您可以在源文件夹中的using_baconjs中查看源代码。完整的 API 列表可以在baconjs.github.io/api.html找到。

HighLand.js

这更像是一个通用的函数库,它是建立在 Node.js 流之上的,因此允许它处理异步和同步代码。HighLand.js最好的特点之一是它处理背压的方式。它具有用于暂停和缓冲的内置功能,也就是说,当客户端无法处理更多数据时,流将被暂停,直到准备好,如果源无法暂停,那么它将保持一个临时缓冲区,直到可以恢复正常操作。是时候用一个实际的例子来动手了。让我们偏离 express 主题,专注于文件读取主题。我们将看到 Node.js I/O 操作与并行执行的强大功能。打开终端并输入npm install highland --save

根据我们之前的骨架,创建index.ts,其中包含以下代码,基本上读取三个文件并打印它们的内容:

import * as highland from "highland";
import { Stream } from "stream";
import * as fs from "fs";

var readFile = highland.wrapCallback(fs.readFile);
console.log("started at", new Date());

var filenames = highland(['file1.txt', 'file2.txt', 'file3.txt']);
filenames
  .map(readFile)
  .parallel(10) //reads up to 10 times at a time
  .errors((err: any, rethrow: any) => {
    console.log(err);
    rethrow();
  })
  .each((x: Stream) => {
    console.log("---");
    console.log(x.toString());
    console.log("---");
  });
console.log("finished at", new Date());

转换文件,保持三个.txt文件与package.json平行,并运行node文件。内容将被读取。您可以在源代码的src文件夹中的using_highlandjs项目中跟踪。完整的 API 列表可以在highlandjs.org/找到。

主要观点

现在我们已经看到了这三个库,我们将总结以下关键点和显著特点:

Rx.js Bacon.js Highland.js
文档 文档完善,API 非常成熟,有很多选项,在其他语言中也有扩展。 Node.js 示例较少,API 文档很好,对 Node.js 有原生支持。 文档很少,辅助方法较少,发展中的足迹。
背压 已实现。 不支持。 最佳实现。
社区 被 Netflix 和微软等大公司使用。在所有其他语言中都有类似的概念,更像是 Java,学习曲线陡峭。 比 Rx.js 小,学习曲线降低。 社区活动最少,必须直接深入代码库。
许可证 Apache 2.0 MIT Apache 2.0

摘要

在本章中,我们了解了响应式宣言。我们将响应式原则应用于微服务。我们学习了如何在 Node.js 中应用响应式编程。我们了解了设计微服务架构的可能方法,看到了它的优点和缺点,并看到了一些实际场景,以找出在哪些情况下可以应用这些模式。我们看到了编排过程、反应过程和两种混合方法的特殊情况。

在下一章中,我们将开始开发我们的购物车微服务。我们将设计我们的微服务架构,编写一些微服务,并部署它们。我们将看到如何将我们的代码组织成适当的结构。

第四章:开始您的微服务之旅

微服务是企业中最具体的解决方案之一,可以快速、有效和可扩展地构建应用程序。然而,如果它们没有得到正确的设计或理解,错误的实施和解释可能导致灾难性或无法挽回的失败。本章将通过深入实际实施来开始我们的微服务之旅。

本章将以对购物微服务的描述开始,这是我们将在整个旅程中开发的微服务。我们将学习如何将系统切分为一组相互连接的微服务。我们将设计购物车微服务的整体架构,定义分层,添加缓存级别等。

本章将涵盖以下主题:

  • 购物车微服务概述

  • 购物车微服务的架构设计

  • 购物车微服务的实施计划

  • 模式设计和数据库选择

  • 微服务前期开发方面

  • 为购物车开发一些微服务

  • 微服务设计最佳实践

购物车微服务概述

在处理新系统时最重要的方面就是它的设计。一个糟糕的初始设计总是导致更多挑战的主要原因。与其之后抱怨、解决错误或应用补丁来掩盖糟糕的设计,总是明智的不要急于通过设计过程,花足够的时间,并拥有一个灵活的防错设计。这只能通过清楚地理解需求来实现。在本节中,我们将简要概述购物车微服务;我们需要通过微服务解决的问题;以及业务流程、功能视图、部署和设计视图的概述。

业务流程概述

我们的场景用例非常简单明了。以下流程图显示了我们需要转换为微服务的端到端购物流程。用户将商品添加到购物车,更新库存,用户支付商品,然后可以结账。基于业务规则,涉及了几个验证。例如,如果用户的支付失败,那么他们就不应该能够结账;如果库存不可用,那么商品就不应该被添加到购物车等等。看一下以下流程图:

业务流程概述

功能视图

每个业务能力及其子能力都显示在一行中,这基本上构成了购物车微服务。一些子能力涉及到多个业务能力,因此我们需要管理一些横切关注点。例如,库存服务既用作独立流程,也用于用户结账产品。以下图显示了购物车微服务的功能视图:

功能视图

该图将业务能力结合成一张图片。例如,库存服务说明有两个子功能——添加产品详情和添加产品数量和库存项目。这总结了库存服务的目标。为我们的系统创建一个功能视图可以让我们清楚地了解所有涉及其中的业务流程和相关事项。

部署视图

部署的要求非常简单。根据需求,我们需要随时添加新的服务来支持各种业务能力。比如,现在支付方式是PayPal,但将来可能需要支持一些本地支付选项,比如银行钱包。那时,我们应该能够轻松地添加新的微服务,而不会破坏整个生态系统。以下图表显示了部署视图。现在有两个节点(一个主节点和一个从节点),但根据需求,节点的数量可能会根据业务能力、流量激增和其他要求而增加或减少:

部署视图

在这一部分,我们简要概述了我们的购物车微服务系统。我们了解了它的功能、业务流程和部署视图。在下一节中,我们将看到购物车微服务的架构设计。

我们系统的架构设计

在这一部分,我们将看一下分布式微服务涉及的架构方面。我们将看一下我们将在整本书中制作的整体架构图,并关注诸如分离关注点、如何应用反应式模式以及微服务效率模型等方面。所以,让我们开始吧。

现在我们知道了我们的业务需求,让我们设计我们的架构。根据我们对微服务和其他概念的了解,我们有最终的整体图表,如下所示:

微服务架构

我们将在后面的章节中更详细地研究 API 网关、服务注册表和发现等组件。在这里,它们只是作为整体视图的一部分提到。

让我们了解前面图表中的关键组件,以更好地了解我们的架构。

不同的微服务

如果我们正确理解了我们的业务需求,我们将得出以下业务能力:

  • 产品目录

  • 价格目录

  • 折扣

  • 发票

  • 支付

  • 库存

根据我们的业务能力和单一责任,我们将我们的微服务简要地划分为各种较小的应用程序。在我们的设计中,我们确保每个业务能力由一个单独的微服务实现,我们不会将一个微服务过载超过一个微服务。我们将整个系统简要地划分为各种微服务,如购物车微服务、产品微服务、支付微服务、消费者微服务、缓存微服务、价格计算和建议微服务等。整体的细粒度流程可以在前面的图表中看到。另一个重要的事情是,每个微服务都有自己的数据存储。不同的业务能力有不同的需求。例如,当一个人结账时,如果交易失败,那么所有的交易,比如将产品添加到客户的购买项目中,从产品库存中扣除数量等,都应该被回滚。在这种情况下,我们需要能够处理事务的关系型数据库,而在产品的情况下,我们的元数据不断变化。一些产品可能比其他产品具有更多的功能。在这种情况下,拥有固定的关系模式是不明智的,我们可以选择 NoSQL 数据存储。

在撰写本书时,MongoDB 4.0 尚未推出。它提供了以下事务加 NoSQL 的优势。

缓存微服务

接下来我们要看的是集中式缓存存储。这个微服务直接与所有微服务进行交互,我们可以使用这个服务在需要时缓存我们的响应。通常情况下,可能会出现一个服务停止运行,但我们仍然可以通过显示缓存数据来保留应用程序(例如产品信息和元数据很少改变;我们可以将它们缓存一段时间,从而避免额外的数据库访问)。拥有缓存可以提高系统的性能和可用性,最终导致成本优化。它提供了极快的用户体验。由于微服务不断移动,通常它们可能无法被访问。在这种情况下,当访问可用性区域失败时,拥有缓存响应总是有利的。

服务注册表和发现

在图表的开始,我们包括了服务注册表。这是一个动态数据库,记录了所有微服务的启动和关闭事件。服务订阅注册表并监听更新,以了解服务是否已经停止。整个过程通过服务注册表和发现完成。当服务停止或启动时,注册器会更新注册表。这个注册表被所有订阅注册表的客户端缓存,所以每当一个服务需要交互时,地址都是从这个注册表中获取的。我们将在《第六章》服务注册表和发现中详细讨论这个过程。

Registrator

接下来我们要看的是与缓存一起提供的Registrator (gliderlabs.github.io/registrator/latest/)。Registrator 是一个第三方服务注册工具,基本上监视微服务的启动和关闭事件,并根据这些事件的输出动态更新集中式服务注册表。不同的服务可以直接与注册表通信,以获取服务的更新位置。Registrator 确保注册和注销代码在系统中不会重复。我们将在《第六章》服务注册表和发现中更详细地讨论这个问题,其中我们将 Registrator 与 consul 集成。

日志记录器

任何应用程序的一个重要方面是日志。当使用适当的日志时,分析任何问题变得非常容易。因此,这里我们有一个基于著名的 Elastic 框架的集中式日志记录器微服务。Logstash 监视日志文件,并在推送到 Elasticsearch 之前将其转换为适当的 JSON 格式。我们可以通过 Kibana 仪表板可视化日志。每个微服务都将有其独特的 UUID 或一些日志模式配置。我们将在《第九章》部署、日志记录和监控中更详细地讨论这个问题。

网关

这是我们微服务的最重要部分和起点。这是我们将处理诸如身份验证、授权、转换等横切关注点的中心点。在不同服务器上创建不同的微服务时,我们通常会将主机和端口的信息从客户端中抽象出来。客户端只需向网关发出请求,网关通过与服务注册表和负载均衡器的交互,并将请求重定向到适当的服务,来处理其余的事情。这是微服务中最重要的部分,应该使其高度可用。

在通过架构图之后,现在让我们了解一些与架构相关的方面,这些方面我们以后会用到。

涉及的设计方面

在实际编码之前,我们需要了解“如何”和“为什么”。比方说,如果我必须砍树(PS:我是一个热爱大自然的人,我不支持这个),我宁愿先磨削斧头,而不是直接砍树。我们将做同样的事情,先磨削我们的斧头。在这一部分,我们将看看设计微服务所涉及的各个方面。我们将看看要经历哪些通信模型,微服务中包括什么,以及为了实现高效的微服务开发而需要注意的哪些方面。

微服务效率模型

根据各种需求和要求,我们已经定义了微服务效率模型。任何微服务的适当实现必须遵守它并提供一套标准的功能,如下所示:

  • 通过 HTTP 和 HTTP 监听器进行通信

  • 消息或套接字监听器

  • 存储能力

  • 适当的业务/技术能力定义

  • 服务端点定义和通信协议

  • 服务联系人

  • 安全服务

  • 通过 Swagger 等工具的服务文档

在下图中,我们总结了我们的微服务效率模型:

微服务效率模型

现在让我们看看前面图表的四个部分。

核心功能

核心功能是微服务本身的一部分。它们包括以下功能:

  • 技术能力:任何需要的技术功能,如与服务注册表交互,向事件队列发送事件,处理事件等,都涉及在这里。

  • 业务能力:编写微服务以实现业务能力或满足业务需求。

  • HTTP 监听器:技术能力的一部分;在这里,我们为外部消费者定义 API。在启动服务器时,将启动 HTTP 监听器,消除任何其他需求。

  • 消息监听器:事件驱动通信的一部分,发送方不必担心消息监听器是否已实现。

  • API 网关:终端客户端的通信单一点。API 网关是处理任何核心关注点的单一位置。

  • 文档存储或数据存储:我们应用程序的数据层。根据我们的需求,我们可以使用任何可用的数据存储。

支持效率

这些是帮助实现核心微服务的解决方案。它们包括以下组件:

  • 负载均衡器:应用程序负载均衡器,根据服务器拓扑的变化进行重定向。它处理动态服务的上线或下线。

服务注册表:服务的运行时环境,如果服务上线或下线,需要发布到其中。它维护所有服务的活动日志以及可用实例。

中央日志:核心的集中式日志记录解决方案,以观察所有地方的日志,而不是单独打开容器并在那里寻找日志。

安全:通过常用的可用机制(如 OAuth,基于令牌,基于 IP 等)检查真实的客户端请求。

测试:测试微服务和基本功能,如微服务间通信,可伸缩性等。

基础设施角色

以下是实现高效微服务所需的基础设施期望:

  • 服务器层:选择部署我们的微服务的有效机制。众所周知的选项包括亚马逊 EC2 实例,红帽 OpenShift 或无服务器。

  • 容器:将应用程序容器化,以便在任何操作系统上轻松运行,而无需安装太多。

  • CI/CD:维护简单部署周期的过程。

  • 集群:服务器负载均衡器,以处理应用程序中的负载或峰值。

治理

流程和参考信息,以简化我们在应用程序开发中的整体生命周期,包括以下内容:

  • 合同测试:测试微服务的期望和实际输出,以确保频繁的更改不会破坏任何东西

  • 可扩展性:根据需求产生新实例并在需求减少时移除这些实例以处理负载峰值

  • 文档:生成文档以便轻松理解别人实际在做什么

在接下来的部分,我们将为我们的微服务开发制定一个实施计划。

购物车微服务的实施计划

微服务开发中的一个关键挑战是确定微服务的范围:

  • 如果一个微服务太大,你最终会陷入单体地狱,难以添加新功能和实施错误修复

  • 如果一个微服务太小,要么我们会在服务之间出现紧密耦合,要么会出现过多的代码重复和资源消耗

  • 如果微服务的大小合适,但有界上下文并不固定,比如服务共享数据库,会导致更高的耦合和依赖

在这一部分,我们将为我们的购物车微服务制定一个实施计划。我们将制定一个通用的工作流程或计划,并根据计划设计我们的系统。我们还将看看当我们的范围不清晰时该怎么办,以及如何在这种情况下继续,最终达到我们的微服务目标。我们将看看如何潜在地避免上述的漏洞。

当范围不清晰时该怎么办

到目前为止,我们已经设计了基于微服务范围的架构计划,但那是在我们的需求非常明确的情况下。我们清楚地知道我们需要做什么。但在大多数情况下,你可能不会有类似的情景。你要么是从单体系统迁移到微服务,要么是被不断变化的需求或业务能力所困扰,或者可能是技术能力的复杂性在初期无法估计,使得确定微服务的范围变得困难。接下来的部分是针对这种情况的,你可以执行以下步骤:

  1. 梦想大,从大开始:决定微服务的范围总是一个巨大的任务,因为它定义了整体的有界上下文。如果这不明确,我们最终会陷入单体地狱。然而,如果范围过于狭窄,也有其缺点。你将遇到困难,因为你最终会在两个微服务之间出现数据重复,责任不清晰,以及难以独立部署服务。从现有微服务中划分出微服务要比管理范围过窄的微服务容易得多。

  2. 从现有微服务中分离出微服务:一旦你觉得一个微服务太大,你需要开始分离服务。首先,需要根据业务和技术能力为现有和新的微服务确定范围。任何与新微服务有关的内容都放入自己的模块。然后,现有模块之间的任何通信都移动到公共接口,比如 HTTP API/基于事件的通信等等。微服务也可以计划以后开发;如果有疑问,总是创建一个单独的模块,这样我们可以轻松地将其移出去。

  3. 确定技术能力:技术能力是支持其他微服务的任何东西,比如监听事件队列发出的事件,注册到服务注册表等等。将技术能力保留在同一个微服务中可能是一个巨大的风险,因为它很快会导致紧密耦合,同样的技术能力可能也会被许多其他服务实现。

  4. 基于业务和技术能力的微服务遵循标准:微服务遵循固定的标准——自给自足、弹性、透明、自动化和分布。每个点都可以简要陈述为:

  • 微服务提供单一的业务能力(模块化是关键)。

  • 微服务可以很容易地单独部署。每个服务都将有自己的构建脚本和 CI/CD 流水线。共同点将是 API 网关和服务注册表。

  • 你可以很容易地找出微服务的所有者。它们将是分布式的,每个团队可以拥有一个微服务。

  • 微服务可以很容易地被替换。我们将通过服务注册表和发现有共同的注册选项。我们的每个服务都可以通过 HTTP 访问。

通过遵循这些步骤,最终将达到微服务级别,其中每个服务将提供单一的业务能力。

模式设计和数据库选择

任何应用程序的主要部分是其数据库选择。在本节中,我们将看看如何为微服务设计我们的数据库,是将其保持独立,还是共享,以及选择哪种数据库——SQL 还是 NoSQL?我们将根据数据类型和业务能力来分类数据存储。有很多选择。微服务支持多语言持久性。根据业务能力和需求选择特定数据存储的方法称为多语言持久性。以下几点讨论了基于用例选择哪种数据库:

  • 我们可以利用 Apache Cassandra 来支持表格数据,例如库存数据。它具有分布式一致性和轻量级事务的选项,以支持 ACID 事务。

  • 我们可以利用 Redis 来支持缓存数据,其中数据模型只是一个键值对。Redis 中的读操作非常快。

  • 我们可以利用 MongoDB 来支持以非结构化形式存储的产品数据,并具有在任何特定字段上建立索引的能力。像 MongoDB 这样的面向文档的数据库具有强大的选项,比如在特定属性上建立索引以实现更快的搜索。

  • 我们可以利用 GraphQL 来支持复杂的关系。GraphQL 对于多对多关系非常有用,例如我们的购物车推荐系统。Facebook 使用 GraphQL。

  • 我们可以使用关系数据库来支持传统系统或需要维护结构化关系数据的系统。我们在数据不经常更改的地方使用关系数据。

在本节中,我们将详细了解这些要点,并了解微服务中数据层应该如何。然后,我们将了解数据库类型及其优缺点和用例。所以,让我们开始吧。

如何在微服务之间划分数据

微服务最困难的是我们的数据。每个微服务都应该通过拥有自己的数据库来维护数据。数据不应通过数据库共享。这条规则有助于消除导致不同微服务之间紧密耦合的常见情况。如果两个微服务共享相同的数据库层,并且第二个服务不知道第一个服务更改了数据库模式,它将失败。由于这个原因,服务所有者需要保持不断联系,这与我们的微服务路径不同。

我们可能会想到一些问题,比如数据库在微服务世界中如何保持?服务是否会共享数据库?如果是的话,共享数据会有什么后果?让我们回答这些问题。我们都知道这句话,“拥有就意味着责任”。同样,如果一个服务拥有数据存储,那么它就是唯一负责保持其最新的。此外,为了实现最佳性能,微服务需要的数据应该是附近或本地的,最好是在微服务容器内部,因为微服务需要经常与其进行交互。到目前为止,我们已经了解了如何划分数据的两个原则:

  • 数据应该被划分,以便每个微服务(满足某种业务能力)可以轻松确保数据库是最新的,并且不允许任何其他服务直接访问。

  • 与该微服务相关的数据应该在附近。将其放得太远会增加数据库成本和网络成本。

对数据进行分离的一般过程之一是建立一个包含实体、对象和聚合的领域模型。假设我们有以下用例——允许客户搜索产品,允许客户购买特定类型的产品,以及允许客户购买产品。我们有三个功能——搜索、购买和库存。每个功能都有自己的需求,因此产品数据库存储在产品目录服务中,库存以不同的方式存储,搜索服务查询产品目录服务,这些结果被缓存。

在本节中,我们将通过一个例子详细讨论这些规则,这将帮助我们决定在哪里保留数据层以及如何划分数据层以获得最大优势。

假设 1 - 数据所有权应通过业务能力进行规范

在决定数据在微服务系统中属于哪里的一个主要想法是基于业务能力进行决定。微服务只是满足业务能力的服务,而没有数据存储是不可能的。业务能力定义了微服务的包含区域。属于处理该能力的一切东西都应该驻留在微服务内部。例如,只有一个微服务应该拥有客户的个人详细信息,包括送货地址和电子邮件地址。另一个微服务可以拥有客户的购买历史,第三个微服务可以拥有客户的偏好。负责业务能力的微服务负责存储数据并保持其最新状态。

假设 2 - 为了速度和鲁棒性而复制数据库

在选择在微服务系统中存储数据的位置时,第二个因素是基于范围或局部性来决定。即使我们谈论的是相同的数据,如果数据存储在微服务附近或远离微服务,都会有很大的变化。微服务可以查询自己的数据库获取数据,或者微服务可以查询另一个微服务获取相同的数据。后者当然会带来缺点和紧密的依赖关系。在本地邻域查找比在不同城市查找要快得多。一旦你决定了数据的范围,你会意识到微服务需要经常彼此交流。

这种微服务通常会创建非常紧密的依赖关系,这意味着我们被困在同样的旧式单片系统中。为了解除这种依赖,耦合缓存数据库或者维护缓存存储通常会很有用。你可以将响应缓存下来,或者你可以添加一个读取模型来在一定时间间隔后使缓存失效。拥有本地数据的微服务应该处于最佳位置,根据业务能力来决定何时特定的代码变得无效。应该使用 HTTP 缓存头来控制缓存。管理缓存就是简单地控制缓存控制头。例如,cache-control: private, max-age:3600这一行将响应缓存 3,600 秒。

在下一节中,我们将根据以下标准来选择最佳数据库:

  • 我的数据是什么?是一堆表、一个文档、一个键值对还是一个图?

  • 我的数据写入和读取频率有多高?我的写入请求是随机的还是在时间上均匀分布的?是否存在一次性读取所有数据的情况?

  • 写操作多还是读操作多?

如何为你的微服务选择数据存储

在设计微服务时最基本的问题之一是如何选择正确的数据存储?我们将在第七章的服务状态和服务间通信部分中更详细地讨论这个问题,但在这里,让我们先搞清楚基本原理。

选择任何理想数据存储的首要步骤是找出我们微服务数据的性质。根据数据的性质,我们可以简要定义以下类别:

  • 短暂或短暂的数据:缓存服务器是短暂数据的经典示例。它是一个临时存储,其目标是通过实时提供信息来增强用户体验,从而避免频繁的数据库调用。这在大部分操作都是读取密集的情况下尤为重要。此外,此存储没有额外的耐久性或安全性问题,因为它没有数据的主要副本。然而,这不应被轻视,因为它必须具有高可用性。故障可能导致用户体验不佳,并随后使主数据库崩溃,因为它无法处理如此频繁的调用。此类数据存储的示例包括 Redis、Elasticsearch 等。

  • 瞬态或瞬时数据:例如日志和消息等数据通常以大量和频率出现。摄取服务在将信息传递到适当的目的地之前处理这些信息。这种数据存储需要高频率的写入。时间序列数据或 JSON 格式等功能是额外的优势。瞬态数据的支持要求更高,因为它主要用于基于事件的通信。

  • 运营或功能性数据:运营数据侧重于从用户会话中收集的任何信息,例如用户配置文件、用户购物车、愿望清单等。作为主要数据存储,这种微服务提供了更好的用户体验和实时反馈。为了业务连续性,这种数据必须被保留。这种数据的耐久性、一致性和可用性要求非常高。根据我们的需求,我们可以根据需要提供以下任何一种结构的数据存储:JSON、图形、键值、关系等。

  • 事务性数据:从一系列流程或交易中收集的数据,例如支付处理、订单管理,必须存储在支持 ACID 控制以避免灾难的数据库中(我们将主要使用关系数据库来处理事务性数据)。在撰写本书时,仍然没有支持事务性数据的 MongoDB 4.0。一旦普遍可用,NoSQL 数据存储甚至可以用于事务管理。

产品微服务的设计

根据我们的需求,我们可以将数据分类为以下各种部分:

微服务 数据存储类型
缓存 短暂(例如:ELK)
用户评论、评分、反馈和畅销产品 瞬态
产品目录 运营
产品搜索引擎 运营
订单处理 事务性
订单履行 事务性

对于我们的产品目录数据库,我们将按照以下设计进行。

在当前章节中,我们将使用产品目录服务,这要求我们使用运营数据存储。我们将使用 MongoDB。产品至少包括以下项目:变体、价格、层次结构、供应商、反馈电子邮件、配置、描述等。我们将使用以下模式设计,而不是在单个文档中获取所有内容:

{"desc":[{"lang":"en","val":"TypescriptMicroservicesByParthGhiya."}],"name":"TypescriptMicroservices","category":"Microservices","brand":"PACKT","shipping":{"dimensions":{"height":"13.0","length":"1.8","width":"26.8"},"weight":"1.75"},"attrs":[{"name":"microservices","value":"exampleorientedbook"},{"name":"Author","value":"ParthGhiya"},{"name":"language","value":"Node.js"},{"name":"month","value":"April"}],"feedbackEmail":"ghiya.parth@gmail.com","ownerId":"parthghiya","description":"thisistestdescription"}

这种模式设计的一些优点包括:

  • 可以进行快速毫秒级的分面搜索

  • 每个索引都将以_id结尾,使其对分页非常有用

  • 可以对各种属性进行高效的排序

微服务预开发方面

在本节中,我们将了解一些通常的开发方面,这些方面将贯穿整本书。我们将了解一些常见的方面,例如使用哪种 HTTP 消息代码,如何设置日志记录,保留哪些类型的日志记录,如何使用 PM2 选项,以及如何跟踪请求或附加唯一标识符到微服务。让我们开始吧。

HTTP 代码

HTTP 代码主导着标准 API 通信,并且是任何通用 API 的通用标准之一。它解决了向服务器发出的每个请求的常见问题,无论请求是否成功,是否产生服务器错误等等。HTTP 使用代码范围来指示代码的性质。HTTP 代码是基于各种代码和响应行为采取相应措施的标准(www.w3.org/Protocols/rfc2616/rfc2616-sec10.html),因此在这里基本上适用于不重复造轮子的概念。在本节中,我们将看一些标准代码范围以及它们的含义。

1xx – 信息

1xx 代码提供原始功能,例如后台操作、切换协议或初始请求的状态。例如,100 Continue表示服务器已收到请求头,并正在等待请求体,101 Switching Protocols表示客户端已请求从服务器更改协议,并且请求已获批准,102表示操作正在后台进行,需要一些时间来完成。

2xx – 成功

这是为了指示在 HTTP 请求中使用了一定程度的成功信息成功代码。它将多个响应打包成特定代码。例如,200 Ok表示一切正常,GET 或 POST 请求成功。201 Created表示 GET 或 POST 请求已完成,并为客户端创建了一个新资源。202 Accepted表示请求已被接受并正在处理。204 No Content表示服务器没有返回内容(与200非常相似)。206 Partial Content通常用于分页响应,表示还有更多数据要返回。

3xx – 重定向

3xx 范围涉及资源或端点的状态。它指示必须采取哪些额外操作才能完成该请求,因为服务器仍然接受通信,但所联系的端点不是系统中的正确入口点。最常用的代码包括301 Moved Permanently,表示未来的请求必须由不同的 URI 处理,302 Found,表示出于某种原因需要临时重定向,303 See other,告诉浏览器查看另一个页面,以及308 Permanent Redirect,表示该资源的永久重定向(与301相同,但不允许 HTTP 方法更改)。

4xx – 客户端错误

这一范围的代码是最为人熟知的,因为传统的404 Not found错误是一个众所周知的占位符,用于表示 URL 格式不正确。这一范围的代码表示请求存在问题。其他众所周知的代码包括400 Bad Request(语法错误的请求),401 Unauthorized(客户端缺乏身份验证),以及403 Forbidden(用户没有权限)。另一个常见的代码是429 Too Many Requests,用于限制请求速率,表示特定客户端的流量被拒绝。

5xx – 服务器错误

这些代码范围表示服务器上发生了处理错误或服务器出现了问题。每当发出5xx代码时,它表示服务器出现了某种问题,客户端无法解决,必须相应地处理。一些广泛使用的代码包括500 Internal Server Error(表示服务器软件发生错误,未披露任何信息),501 Not Implemented(表示尚未实现的端点,但仍在请求),以及503 Service Unavailable(表示服务器由于某种原因宕机,无法处理更多请求)。收到503时,必须采取适当措施重新启动服务器。

为什么 HTTP 代码在微服务中至关重要?

微服务是完全分布式且不断移动的。因此,如果没有标准的通信手段,我们将无法触发相应的故障转移措施。例如,如果我们实现了断路器模式,断路器应该知道每当它收到5xx系列代码时,它应该保持断路器打开,因为服务器不可用。同样,如果它收到429,那么它应该阻止来自该特定客户端的请求。完整的微服务生态系统包括代理、缓存、RPC 和其他服务,其中 HTTP 是共同的语言。根据上述代码,它们可以相应地采取适当的行动。

在下一节中,我们将学习有关日志记录方面以及如何处理微服务中的日志记录。

通过日志审计

到目前为止,我们听说微服务是分布式的,服务不断变化。我们需要跟踪所有服务和它们产生的输出。使用console.log()是一个非常糟糕的做法,因为我们无法跟踪所有服务,因为console.log()没有固定的格式。此外,每当出现错误时,我们需要堆栈跟踪来调试可能的问题。为了进行分布式日志记录,我们将使用winston模块(github.com/winstonjs/winston)。它具有各种选项,如日志级别、日志格式等。对于每个微服务,我们将传递一个唯一的微服务 ID,这将在我们聚合日志时对其进行标识。对于聚合,我们将使用著名的 ELK Stack,详见第九章,部署、日志记录和监控。以下是按优先级排序的各种日志类型,通常使用:

  • 严重/紧急(0):这是最灾难性的级别,当系统无法恢复或正常运行时使用。这会强制执行关机或其他严重错误。

  • 警报(1):收到这个严重的日志后,必须立即采取行动以防止系统关闭。这里的关键区别在于系统仍然可用。

  • 关键(2):在这里,不需要立即采取行动。此级别包括诸如无法连接到套接字、无法获取最新聊天消息等情况。

  • 错误(3):这是一个应该调查的问题。系统管理员必须被通知,但我们不需要把他从床上拽起来,因为这不是紧急情况。通常用于跟踪整体质量。

  • 警告(4):当可能存在错误或可能不存在错误时使用此级别。警告条件接近错误,但它们不是错误。它们指示可能有害的情况或事件,可能会导致错误。

  • 通知(5):这个级别是一个正常的日志,但具有一些重要的条件。例如,您可能会收到诸如在...中捕获到 SIGBUS 尝试转储核心之类的消息。

  • Info(6): 这个级别用于不可察觉的信息,比如服务器已经运行了 x 小时和有趣的运行时事件。这些日志立即在控制台上可见,因为这些日志的目的是保守。这些日志应该保持最少。

  • Debug(7): 这用于详细了解系统的流程。它包括用于调试的消息,例如,像“打开文件…”或“获取产品的产品 ID 47”。

需要启用日志。如果启用了致命日志,那么所有日志都将被看到。如果启用了信息日志,那么只有信息和调试日志会被看到。所有级别的日志都有自己的 Winston 自定义方法,我们可以添加我们自己的格式。

PM2 进程管理器

Node.js 是单线程的,这意味着任何对 JavaScript throw 语句的使用都会引发一个必须使用 try...catch 语句处理的异常。否则,Node.js 进程将立即退出,导致无法处理任何进一步的请求。由于 Node.js 运行在单进程未捕获异常上,需要小心处理。如果不处理,它将崩溃并导致整个应用程序崩溃。因此,在 Node.js 中的黄金法则是 如果任何异常未经处理冒泡到顶部,我们的应用程序就会死掉

PM2 是一个旨在永远保持我们服务运行的进程管理器。它是一个带有内置负载均衡器的生产进程管理器,是微服务的完美候选者。PM2 非常方便,因为它允许我们使用简单的 JSON 格式声明每个微服务的行为。PM2 是一个带有内置监控和零停机工具的高级任务运行器。扩展 PM2 命令只是简单地输入我们想要生成或减少的实例数量。使用 PM2 启动一个新进程将启动一个进程的分叉模式,并让负载均衡器处理其余部分。PM2 在主进程和进程工作线程之间进行轮询,以便我们可以同时处理额外的负载。PM2 提供的一些标准部署功能如下:

pm2 start <process_name> 以分叉模式启动进程,并在服务器宕机时自动重启
pm2 stop <process_name> 停止 PM2 进程
pm2 restart <process_name> 重新启动一个带有更新代码的进程
pm2 reload <process_name> 重新加载 PM2 进程,零停机时间
pm2 start <process_name> -i max 以最大分叉模式启动一个 PM2 进程;也就是说,它将根据可用的 CPU 数量生成最大数量的实例
pm2 monit 监控一个 PM2 进程
pm2 start ecosystem.config.js --env staging 启动一个进程,使用 ecosystem.config.js 中的配置

PM2 也可以用作部署工具或高级的 CI/CD 手段。你只需要在 ecosystem.config.js 文件中定义你的部署脚本,如下所示:

"deploy": {
    "production": {
        "key": "/path/to/key.pem", // path to the private key to authenticate
        "user": "<server-user>", // user used to authenticate, if its AWS than ec2-user
        "host": "<server-ip>", // where to connect
        "ref": "origin/master",
        "repo": "<git-repo-link>",
        "path": "<place-where-to-check-out>",
        "post-deploy": "pm2 startOrRestart ecosystem.config.js --env production"
    },
}

然后,我们只需要输入以下命令:

pm2 deploy ecosystem.config.js production

这个命令作为一个本地部署工具。添加路径、PEM 文件密钥等步骤是我们可以连接到服务器的步骤。一旦使用指定用户连接到服务器,PM2 进程就会启动,我们可以运行我们的应用程序。最新的 Git 存储库将被克隆,然后 PM2 将在 forever 选项中启动 dist/Index.js 文件。

追踪请求

追踪请求的来源非常重要,因为有时我们需要重构客户在我们系统中的整个旅程。它提供了有关系统的有用信息,例如延迟的来源。它还使开发人员能够观察如何通过搜索所有聚合日志来处理单个请求,使用一些唯一的微服务 ID,或者通过传递时间范围来找出用户的整个旅程。以下是通过 Winston 生成的示例日志:

{ level: 'info', serviceId: 'hello world microservice' , 
  message: 'What time is the testing at?', 
  label: 'right meow!', timestamp: '2017-09-30T03:57:26.875Z' }

所有重要数据都可以从日志中看到。我们将使用 ELK Stack 进行日志记录。ELK 具有巨大的优势,因为它结合了以下三个工具的功能——Logstash(配置为从各种来源读取日志或注册事件并将日志事件发送到多个来源)、Kibana(可配置的 Web 仪表板,用于查询 Elasticsearch 的日志信息并呈现给用户)和Elasticsearch(基于 Lucene 的搜索服务器,用于收集日志、解析日志并将其存储以供以后使用,提供 RESTful 服务和无模式的 JSON 文档)。它具有以下优势:

  • 每个Winston实例都配置了 ELK。因此,我们的日志服务是外部化的,日志的存储是集中的。因此,有一个单一的数据源可以追踪请求。

  • 由于 Winston 的自动模式定义和正确格式,我们拥有日志结构化数据。例如,如果我想查询从4:404.43的所有日志,我只需通过 Elasticsearch 查询,因为我知道我的所有日志在 JSON 中的固定级别上都有时间组件。

  • Winston 日志格式负责创建和传递跨所有请求的相关标识符。因此,如果需要,可以通过查询特定参数轻松追踪特定服务器的日志。

  • 我们可以通过 Elasticsearch 搜索我们的日志。Elasticsearch 提供 Kibana 以及 REST API,可以随时调用以查看数据源中的所有数据。基于 Lucene 的实现有助于更快地获取结果。

  • Winston 中的日志级别可以在运行时更改。我们可以有各种日志级别,并根据日志的优先级,可能会或可能不会看到较低级别的日志。这在解决生产级别的问题时非常有帮助。

在本节中,我们看了日志记录以及它如何解决了解客户行为(客户在页面上花费多少时间,每个页面上的操作花费多少时间,可能存在的一些问题等)等问题。在下一节中,我们将开始开发购物车微服务。

为购物车开发一些微服务

在本节中,我们将为购物车开发一些微服务,这些微服务以其业务能力而独特标识。因此,在动手之前,让我们快速概述一下我们当前的问题。购物车单体应用程序进展顺利,但随着数字化的出现,交易量大幅增加——比原始估计增加了 300-500 倍。端到端架构经过审查,发现了以下限制,基于这些限制引入了微服务架构:

  • 坚固性和稳固性:由于错误和线程阻塞,系统的坚固性受到了很大的影响,这迫使 Node.js 应用服务器不接受任何新的事务并进行强制重启。内存分配问题和数据库锁线程是主要问题。某些资源密集型操作影响整个应用程序,资源分配池总是被消耗。

  • 部署中断:由于添加了越来越多的功能,服务器中断窗口大大增加,因为服务器启动时间增加。由于node_modules的大小,导致了这个问题。由于整个应用程序被打包为单体应用,整个应用程序需要一遍又一遍地安装node模块,然后启动我们的 node-HTTP 服务器。

  • 锐度:随着时间的推移,代码的复杂性呈指数增长,工作的分布也是如此。团队之间形成了紧密的耦合依赖关系。因此,实施和部署变得更加困难。影响分析变得过于复杂。结果就是,修复一个 bug,就会出现 13 个其他 bug。这样的复杂性导致node_modules的大小超过 1GB。这样的复杂性最终停止了持续集成CI)和单元测试。最终,产品的质量下降了。

这样的情况和问题需要一种进化的方法。这样的情况需要一种微服务开发方法。在这一部分,我们将看到微服务设置方法,这将给我们带来各种优势,比如选择性服务扩展、技术独立性(易于迁移到新技术)、容错等等。

行程

让我们快速浏览一下我们将在本次练习中执行的行程:

  • 开发设置和先决模块:在这一部分,我们将总结项目中将使用的开发工具和npm模块。我们将关注应用程序属性、自定义中间件、依赖注入等先决条件。

  • 应用程序目录配置:我们将分析我们将在其他微服务中使用的结构,并了解我们将需要的所有文件以及在哪里编写逻辑。

  • 配置文件:我们将查看所有配置文件,通过这些文件我们可以指定各种设置,比如数据库主机名、端口 URL 等等。

  • 处理数据:我们将简要总结代码模式以及它们如何支持最佳开发者产出,并使开发者的生活更轻松。

  • 准备服务:我们将分析package.json和 Docker 文件,并看看如何使用这两个文件使我们的微服务准备好为任何服务请求提供服务。

所以,让我们开始我们的行程。

开发设置和先决模块

在这一部分,我们将看到在开发和创建我们的开发沙盒时需要注意的几个方面。我们将概述将使用的所有 node 模块以及每个node模块将满足的核心方面。所以,现在是动手的时候了。

注意:我们在第二章中看到了如何为任何不是用 ES6 编写的 node 模块编写自定义类型,为任何在DefinitelyTyped存储库中没有可用类型的模块利用这一点。

存储库模式

在这一部分,我们将了解存储库模式,它赋予我们将代码放在一个地方的能力。TypeScript 引入了泛型(就像 Java 中的特性),我们将充分利用这一点在我们的微服务中。存储库模式是创建企业级应用程序最广泛使用的模式之一。它使我们能够通过为数据库操作和业务逻辑创建一个新层直接在应用程序中处理数据。

结合泛型和存储库模式,可以带来无数的优势。在处理 JavaScript 应用程序时,我们需要解决诸如应用程序之间的代码共享和模块化等问题。泛型存储库模式通过在具有泛型的抽象类(或根据业务能力的多个抽象类)中给我们写入数据的抽象来解决这个问题,并且可以独立于数据模型重用实现层,只需将类型传递给某些类。当我们谈论存储库模式时,它是一个存储库,我们可以将数据库的所有操作(CRUD)集中在一个地方,适用于任何通用业务实体。当您需要在数据库中执行操作时,您的应用程序调用存储库方法,从而使调用者能够透明地进行调用。将这与泛型结合使用会导致一个抽象,一个具有所有常用方法的基类。我们的EntityRepository只扩展了具有所有数据库操作实现的基类。

此模式遵循开闭原则,其中基类对扩展开放但对修改关闭。

它有各种优势,如下:

  • 它可以用作可扩展性措施,您只需为所有常见操作编写一个类,例如 CRUD,当所有其他实体应具有类似操作时

  • 业务逻辑可以在不触及数据访问逻辑的情况下进行单元测试

  • 数据库层可以被重用

  • 数据库访问代码是集中管理的,以实施任何数据库访问策略,就像缓存一样简单

配置应用程序属性

根据十二要素标准(回想一下,微服务的十二要素应用程序,第一章中的揭秘微服务),一个代码库应该适用于多个环境,如 QA、开发、生产等。确保我们在应用程序中有应用程序属性文件,在其中可以指定环境名称和与环境相关的内容。Config(www.npmjs.com/package/config)就是这样一个模块,它可以帮助您组织所有配置。此模块只需读取./config目录中的配置文件(它应该与package.json处于同一级别)。

配置的显着特点如下:

  • 它可以支持 YAML、YML、JSON、CSV、XML 等格式。

  • 它可以创建一个与package.json并行的 config 目录,并在其中创建一个文件default.ext(这里,.ext可以是前述格式之一)。

  • 要从配置文件中读取,只需使用以下代码行:

import * as config from 'config';
const port = config.get('express.port');
  • 它支持各种配置文件,维护层次结构以支持各种环境。

  • 它甚至支持多个节点实例;非常适合微服务。

自定义健康模块

有时,向应用程序添加新模块会导致应用程序失序。我们需要自定义健康模块来实际监视服务并警告我们服务失序(服务发现正是这样做的,我们将在第六章中看到,服务注册表和发现)。我们将使用express-pingwww.npmjs.com/package/express-ping)来查看我们节点的健康状况。通过在我们的中间件中引入此模块,我们可以公开一个简单的 API,告诉操作员和其他应用程序有关其内部健康状况的信息。

express-ping的显着特点如下:

  • 这是一个零配置模块,只需将其注入中间件即可公开一个健康端点。

  • 要使用此模块,只需使用以下代码行:

import * as health from 'express-ping';
this.app.use(health.ping());
  • 仅添加先前的 LOCs 将公开一个<url>/health端点,我们可以用于健康检查目的。我们可以添加授权访问,甚至使用中间件来使用我们公开的/ping API,这只是普通的 express:
app.get('/ping', basicAuth('username', 'password'));
app.use(health.ping('/ping'));
  • 此端点可用于任何地方,只需检查应用程序的健康状况。

依赖注入和控制反转

在本节中,我们将看到如何使用基本原则,如依赖注入和控制反转。来自 Java 背景,我倾向于在任何应用程序中使用这些原则,以使我的开发过程更加顺畅。幸运的是,我们有与我们的要求完全匹配的模块。我们将使用inversifywww.npmjs.com/package/inversify)作为控制反转容器,typediwww.npmjs.com/package/typedi)用于依赖注入。

Inversify

控制反转IOC)是关于获得自由、更灵活,减少对他人的依赖。比如你正在使用一台台式电脑,你是被奴役的(或者说受控制)。你必须坐在屏幕前,使用键盘输入和鼠标导航。糟糕的软件也会让你类似地被奴役。如果你用笔记本电脑替换台式电脑,那么你就实现了控制反转。你可以轻松携带它并四处移动。所以,现在你可以控制你的电脑在哪里,而不是电脑控制它。软件中的 IOC 非常类似。传统上来说,IOC 是一个设计原则,根据这个原则,计算机程序的自定义部分从一个通用框架中接收控制流。我们有inversifyJS作为npm模块可用。根据官方文档:

InversifyJS 是一种轻量级的 TypeScript 和 JavaScript 应用程序的控制反转容器。IOC 容器将使用类构造函数来识别和注入其依赖项。它具有友好的 API,并鼓励使用最佳的面向对象编程和 IoC 实践,遵循 SOLID 原则。

Typedi

依赖注入是一种类、组件和服务指定其依赖库的方式。通过简单地将依赖项注入到微服务中,服务就能够直接引用依赖项,而不是在服务注册表中查找它们或使用服务定位器。封装任何服务、发现它并分发负载的能力对于微服务来说是一个非常有价值的补充。Typedi是 JavaScript 和 TypeScript 的依赖注入工具。使用 Typedi 非常容易。你所要做的就是创建一个容器,并开始在该容器上使用依赖注入原则。Typedi 提供各种注解,如@Service@Inject等。你甚至可以创建自己的自定义装饰器。

TypeORM

受 hibernate 和 doctrine 等框架的启发,Entity Framework TypeORMwww.npmjs.com/package/typeorm)是一个支持活动记录和数据映射器模式的 ORM 框架,不同于所有其他 JavaScript ORM。这使我们能够以最高效的方式编写高质量、松散耦合、可扩展和可维护的应用程序。它具有以下优势:

  • 使用多个数据库连接

  • 适用于多种数据库类型

  • 查询缓存

  • 钩子,如订阅者和监听器

  • 用 TypeScript 编写

  • 支持数据映射器和活动记录模式

  • 复制

  • 连接池

  • 流式原始结果(响应式编程)

  • 急切和懒惰的关系

  • 支持 SQL 和 NoSQL 数据库

应用程序目录配置

该应用程序的目录结构侧重于基于关注点分离的架构方法。每个文件夹结构将具有专门与文件夹名称相关的文件。在下面的截图中,您可以看到整体结构和详细结构:

配置结构

在前面的屏幕截图中,您可以看到两个文件夹结构。第一个是高级和整体的文件夹结构,突出显示重要的文件夹,而第二个是src文件夹的详细扩展视图。文件夹结构遵循关注点分离的方法,以消除代码重复并在控制器之间共享单例服务。

在计算机科学中,关注点分离SoC)是将计算机程序分成不同的部分或功能的设计原则,以便每个部分都处理一个单独的关注点,并且独立于其他部分。关注点是影响任何应用程序代码的一组信息。

让我们了解我们的文件夹结构及其包含的文件,以及该文件夹实际上解决的问题。

src/data-layer

这个文件夹负责数据的整体组织、存储和访问方法。模型定义和 iridium 文件可以在这里找到。它包括以下文件夹:

  • 适配器:这实现了连接到 MongoDB 数据库的连接方法,并在连接、错误、打开、断开连接、重新连接和强制退出方法上添加事件

  • 数据抽象:这里有表示每个 MongoDB 集合结构的模式和表示集合中每组数据的文档

  • 数据代理:这里有针对每个 MongoDB 集合的数据存储的查询事务

  • 模型:这里有一个由 MongoDB 文档描述的数据的 TypeScript 类表示

src/business-layer

这个文件夹包含了服务层或中间件层所需的业务逻辑和其他资源的实现,如下所示:

  • 安全:如果我们想在特定的微服务级别上添加一些安全性或令牌,这就是我们将添加我们的身份验证逻辑的地方(通常,我们不会在单个服务级别编写身份验证层)。相反,我们会在 API 网关级别编写它,我们将在第五章中看到,理解 API 网关。在这里,我们将编写用于服务注册/注销、验证、内部安全、微服务与服务注册表、API 网关等通信的代码。

  • 验证器:这里将包含用于验证 API 请求发送的数据的模式和处理逻辑。我们将在这里编写我们的 class-validator (www.npmjs.com/package/class-validator) 模式,以及一些自定义验证函数。

src/service-layer

这个文件夹包括建立 API 端点的过程,以路由的形式处理所有数据请求的响应。它包括以下文件夹:

  • 控制器:这用作处理与路由相关的任何数据请求的基础。自定义控制器npm模块routing-controllerswww.npmjs.com/package/routing-controllers)提供,使用内置装饰器,如@Get@Put@Delete@Param等。这些函数实现了基本的 GET、POST、DELETE 和 PUT 方法,用于通过 RESTful API 与数据库进行交互。我们甚至可以有套接字初始化代码等。我们将使用依赖注入来注入一些服务,这些服务将在这里使用。

  • 请求:这里有 TypeScript 接口定义和展示控制器中每种不同请求类型的属性。

  • 响应:这里有 TypeScript 接口定义和展示控制器中每种不同响应类型的属性。

src/middleware

这包含了任何服务器配置的资源,以及一个可以在整个应用程序中共享的某些实用程序过程的存储位置。我们可以有集中的配置,比如loggercacheelk等等:

  • common:这里有一个日志记录模块的实例化,可以在整个应用程序中共享。这个模块基于winston (www.npmjs.com/package/winston)。

  • config:这里有特定于供应商的实现。我们将在这里定义 express 配置和 express 中间件,以及组织 REST API 端点的所有重要配置。

  • custom-middleware:这个文件夹将包含我们所有自定义的中间件,我们可以在任何控制器类或任何特定方法中使用它们。

在下一节中,我们将查看一些配置文件,这些文件配置和定义了应用程序,并确定它将如何运行。例如,它将运行在哪个端口,数据库连接到哪个端口,安装了哪些模块,编译配置等等。

配置文件

让我们看一些我们将在整个项目中使用的配置文件,并使用它们来管理不同环境下的项目或根据用例:

  • default.json:Node.js 有一个很棒的模块,node-config。你可以在package.json旁边的config文件夹中找到config文件。在这里,你可以有多个配置文件,可以根据环境进行选择。例如,首先加载default.json,然后是{deployment}.json,依此类推。以下是一个示例文件:
{
    "express": {
        "port": 8081,
        "debug": 5858,
        "host": "products-service"
    }
 }
}
  • src/Index.ts:这将通过创建一个在middleware/config/application中定义的应用程序的新对象来初始化我们的应用程序。它导入了反射元数据,初始化了我们的依赖注入容器。

  • package.json:这在所有 Node.js 应用程序中作为清单文件。它将外部库分为两个部分,dependenciesdevDependencies。这提供了一个scripts标签,其中包含用于构建、运行和打包模块的外部命令。

  • tsconfig.json:这为 TypeScript 提供了选项,当它执行转换为 JavaScript 的任务时。例如,如果我们有sourceMaps:true,我们将能够通过生成的 sourcemaps 调试 TypeScript 代码。

  • src/data-layer/adapters/MongoAccess.ts:这将连接到 MongoDB 数据库,并附加到 MongoDB 的各种事件的各种事件处理程序,比如openconnectederrordisconnectedreconnected等等:

export class MongooseAccess {
  static mongooseInstance: any;
  static mongooseConnection: Mongoose.Connection;
  constructor() {
    MongooseAccess.connect();
  }
  static connect(): Mongoose.Connection {
    if (this.mongooseInstance) {
      return this.mongooseInstance;
    }
    let connectionString = config.get('mongo.urlClient').toString();
    this.mongooseConnection = Mongoose.connection;
    this.mongooseConnection.once('open', () => {
      logger.info('Connect to an mongodb is opened.');
    });
    //other events
  }
  • src/middleware/config/Express.ts:这是我们的 express 中间件所在的地方。我们将附加标准配置,比如helmetbodyparsercookieparsercors origin等等,并设置我们的controllers文件夹如下:
setUpControllers(){
  const controllersPath = 
       path.resolve('dist', 'service-layer/controllers');
  useContainer(Container);
  useExpressServer(this.app,
    {
      controllers: [controllersPath + "/*.js"],
      cors: true
    }
  );
}

处理数据

与大多数接受并处理来自客户端的请求的 Web 服务器一样,我们在这里有一个非常相似的东西。我们只是在宏观层面上将事物细分。整个流程的概述如下图所示:

处理数据

通过将任何示例端点通过前面图表中的每个部分来理解该过程。你可以在chapter-4/products-catalog service中找到整个示例:

  1. 向服务器发送一个基于产品属性的特定产品的 API 请求,http://localhost:8081/products/add-update-product
body: {//various product attributes}
  1. 使用/products路径注册的控制器捕获基于URI /products/的请求。如果在Express.ts中注册了中间件,它将首先被触发;否则,将调用控制器方法。注册中间件很简单。创建一个中间件类,其中包含以下代码:
import { ExpressMiddlewareInterface } from "routing-controllers";
export class MyMiddleware implements ExpressMiddlewareInterface {

  use(request: any, response: any, next?: (err?: any) => any): any {
    console.log("custom middleware gets called, here we can do anything.");
    next();
  }
}
  1. 要在任何控制器中使用此中间件,只需在任何方法/控制器的顶部使用@UseBefore@UseAfter装饰器。

  2. 由于我们想执行一些核心逻辑(例如从缓存中选择响应或记录),因此middleware函数首先执行。这位于middleware/custom-middleware/MyMiddleWare.ts中。使用 Node.js 的async功能,该方法将执行必要的操作,然后继续进行下一个请求,使用next()

  3. 在自定义中间件中,我们可以进行各种检查;例如,我们可能只想在存在有效的ownerId时才公开 API。如果请求没有有效的ownerId,则请求将不再通过应用程序的其余部分,并且我们可以抛出一个错误,指示真实性或无效的productId。但是,如果ownerId有效,则请求将继续通过路由进行。这是MyMiddleWare.ts的作用。接下来将介绍控制器的部分。

  4. 接下来是由路由控制器提供的装饰器定义的@JsonControllers。我们定义了我们的路由控制器和用于添加和更新产品的 post API:

@JsonController('/products')
@UseBefore(MyMiddleware)
export class ProductsController {
  constructor() { }

  @Put('/add-update-product')
  async addUpdateProduct( @Body() request: IProductCreateRequest,
    @Req() req: any, @Res() res: any): Promise<any> {
    //API Logic for adding updating product
  }
}

这将为API <host:url>/products/add-update-product创建一个 PUT 请求。@Body注释将请求体的转换转换为IProductCreateRequest (src/service-layer/request/IProductRequest.ts),并将其放入变量请求(如在addIpdateProduct方法的参数中所见),该变量将在整个方法中可用。requestresponses文件夹包含各种requestresponse对象的转换。

  1. 控制器的第一部分是验证请求。验证和安全逻辑将位于src/business-layer文件夹中。在validator文件夹中,我们将有ProductValidationSchema.tsProductValidatorProcessor.ts。在ProductValidationSchema.ts中,使用class-validatorwww.npmjs.com/package/class-validator)内置装饰器(@MinLength, @MaxLength, @IsEmail等)添加验证模式规则(通过这些验证消息,我们希望识别请求是否正确或是否包含垃圾数据):
export class ProductValidationSchema {
  @Length(5, 50)
  name: string;

  @MinLength(2, { message: "Title is too Short" })

  @MaxLength(500, { message: "Title is too long" })
  description: string;

  @Length(2, 15)
  category: string;

  @IsEmail()
  feedbackEmail: string;
  //add other attributes.
}
  1. 接下来,我们将使用这些消息来验证我们的请求对象。在ProductValidationProcessor.ts中,创建一个验证器方法,返回一个合并的消息数组:
async function validateProductRequest(productReqObj: any): Promise<any> {
  let validProductData = new ProductValidationSchema(productReqObj);
  let validationResults = await validate(validProductData);
  let constraints = []
  if (validationResults && validationResults.length > 0) {
    forEach(validationResults,
      (item) => {
        constraints.push(pick(item, 'constraints', 'property'));
      });
  }
  return constraints;
}
  1. ProductsController.ts中,调用该方法。如果请求中存在错误,则请求将在那里停止,并且不会传播到 API 的其余部分。如果响应有效,则它将通过数据代理传递数据到 MongoDB:
let validationErrors: any[] = await validateProductRequest(request);
logger.info("total Validation Errors for product:-", validationErrors.length);
if (validationErrors.length > 0) {
  throw {
    thrown: true,
    status: 401,
    message: 'Incorrect Input',
    data: validationErrors
  }
}
let result = await this.productDataAgent.createNewProduct(request);
  1. 当请求有效时,控制器ProductController.ts调用数据层中的ProductDataAgent.ts方法createNewProduct(..),以将数据放入 MongoDB。此外,基于 Mongoose 模式定义,它将自动维护重复检查条目:
@Put('/add-update-product')
async addUpdateProduct(@Body() request: IProductCreateRequest,
                       @Req() req: any, @Res() res: any): Promise < any > {
  let validationErrors: any[] = await validateProductRequest(request);
  logger.info("total Validation Errors for product:-", validationErrors.length);
  if(validationErrors.length> 0) {
    throw {
      thrown: true,
      status: 401,
      message: 'Incorrect Input',
      data: validationErrors
    }
  }
  let result = await this.productDataAgent.createNewProduct(request);
  if(result.id) {
    let newProduct = new ProductModel(result);
    let newProductResult = Object.assign({ product: newProduct.getClientProductModel() });
    return res.json(<IProductResponse>(newProductResult));
  }else{
    throw result;
  }
}

服务层中的控制器不仅通过用于协商与数据存储的查询的数据代理提供对数据层的访问,而且还提供了访问业务层以处理其他业务规则的入口,例如验证产品输入。ProductDataAgent.ts方法返回 MongoDB 返回的对象。它还有其他方法,例如deleteProductfindAllProductsfindProductByCategory等。

  1. 在与ProductDataAgent.ts中的数据存储完成交易后,以普通对象的形式返回一个承诺给ProductController.ts,指示失败或成功。当成功将产品添加到数据库时,将返回插入的对象以及 MongoDB 的ObjectID()。与产品相关的数据构造为ProductModel,并将解析为IProductResponseProductController.ts
async createNewProduct(product: any): Promise < any > {
  let newProduct = <IProductDocument>(product);
  if(newProduct.id) {
    let productObj = await ProductRepo.findOne({ productId: newProduct.id });
    if (productObj && productObj.ownerId != newProduct.ownerId) {
      return { thrown: true, success: false, status: 403, message: "you are not the owner of Product" }
    }
  }
  let addUpdateProduct = await ProductRepo.create(newProduct);
  console.log(addUpdateProduct);
  if(addUpdateProduct.errors) {
    return { thrown: true, success: false, status: 422, message: "db is currently unable to process request" }
  }
  return addUpdateProduct;
}

如果在ProductDataAgent.ts中处理查询时发生了一些意外,比如与数据存储的连接中断,将返回一个错误消息形式的结果。如果同名对象已经存在,将抛出类似的错误响应。

这完成了数据如何在应用程序中流动的示例。基于许多后端应用程序和交叉因素,这是为了实现流畅的流程并消除冗余代码而设计的。

同样,该项目将具有其他 API,如下所示:

  • 通过 GET 请求获取所有产品

  • 通过 ID 获取产品的 GET 请求

  • 通过 GET 请求按产品类型获取产品

  • 通过 DELETE 请求删除单个产品

准备好提供服务(package.json 和 Docker)

在本节中,我们将看看如何在package.json中编写脚本,然后使用 Docker 自动化整个过程。

package.json

现在我们知道数据如何流动,让我们了解如何使其准备好提供服务。安装 TypeScript 和rimraf作为依赖项,并在scripts标签内添加以下内容:

"scripts": {
  "start": "npm run clean && npm run build && node ./dist/index.js",
  "clean": "node ./node_modules/rimraf/bin.js dist",
  "build": "node ./node_modules/typescript/bin/tsc"
},

要运行整个过程,请执行以下命令:

npm run start

这将首先删除dist文件夹(如果存在),然后基于src文件夹,它将转译文件夹并生成dist文件夹。一旦生成了dist,我们就可以使用node ./dist/Index.jsnpm run start的组合来运行我们的服务器。

在后面的章节中,我们将在这里做更多的事情,包括测试覆盖和生成 swagger 文档。我们的构建脚本应该涵盖以下内容:

  • 通过swagger-gen生成文档

  • 调用Express.ts,其中将配置所有路由以及中间件和依赖注入

  • tsc命令将使用tsconfig.json中的“outputDirectory”:“./dist”属性将 TypeScript 文件转译为 JavaScript 文件,以确定 JavaScript 文件应放置的位置

  • SwaggerUI 将生成文档,可在网络上使用

现在,要测试 API,请创建以下顺序的产品 JSON,并使用以下有效负载进行 POST 请求:

{"desc":[{"lang":"en","val":"TypescriptMicroservicesByParthGhiya."}],"name":"TypescriptMicroservices","category":"Microservices","brand":"PACKT","shipping":{"dimensions":{"height":"13.0","length":"1.8","width":"26.8"},"weight":"1.75"},"attrs":[{"name":"microservices","value":"exampleorientedbook"},{"name":"Author","value":"ParthGhiya"},{"name":"language","value":"Node.js"},{"name":"month","value":"April"}],"feedbackEmail":"ghiya.parth@gmail.com","ownerId":"parthghiya","description":"thisistestdescription"}

您将看到一个成功的响应,响应代码为 200,以及 MongoDB 的 ObjectId。它看起来像这样:“id”:“5acac73b8bd4f146bcff9667”。

这是我们将如何编写我们的微服务的一般方法。它向我们展示了控制分离的更多行为,以及如何使用 TypeScript 和一些企业设计模式来实现它,薄控制器位于服务层,依赖于业务层和数据层的引用,以实现可以消除冗余代码并使控制器之间共享服务的过程。同样,您可以基于相同的方法编写无数的服务。假设您想编写一个支付微服务,您可以使用typeorm模块进行 SQL 操作,并具有相同的代码结构。

Docker

现在我们的应用程序已经启动运行,让我们将其容器化,这样我们就可以将我们的镜像推送给任何人。诸如 Docker 之类的容器帮助我们打包整个应用程序,包括库、依赖项、环境以及应用程序运行所需的任何其他内容。容器很有用,因为它们将应用程序与基础架构隔离开来,这样我们就可以轻松地在不同平台上运行它,而不必担心我们正在运行的系统。

我们的目标如下:

  1. 通过运行docker-compose up来启动我们的产品目录微服务的工作版本,Mongo 微服务

  2. Docker 工作流应该是我们使用包括转译和服务dist文件夹的 Node.js 工作流

  3. 使用数据容器初始化 MongoDB

所以,让我们开始吧。我们将创建我们的container文件,并通过执行以下步骤在其中编写启动脚本。您可以在Chapter 4/products-catalog -with-docker文件夹中找到源代码:

  1. 首先,创建.dockerignore文件以忽略我们不希望出现在构建容器中的内容:
Dockerfile
Dockerfile.dev
./node_modules
./dist
  1. 现在,我们将编写我们的Dockerfile。镜像由我们在Dockerfile中定义的一组层和指令组成。我们将在这里初始化我们的 Node.js 应用程序代码。
#LATEST NODE Version -which node version u will use.
FROM node:9.2.0
# Create app directory
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
#install dependencies
COPY package.json /usr/src/app
RUN npm install
#bundle app src
COPY . /usr/src/app
#3000 is the port which we want to expose for outside container world.
EXPOSE 3000 
CMD [ "npm" , "start" ]
  1. 我们已经完成了 Node.js 部分。现在,我们需要配置我们的 MongoDB。我们将使用docker compose,这是一个用于运行多个容器应用程序的工具,它将启动并运行我们的应用程序。让我们添加一个docker-compose.yml文件来添加我们的 MongoDB:
version: "2"
services:
  app:
    container_name: app
    build: ./ 
  restart: always
    ports:
      - "3000:8081"
    links:
      - mongo
  mongo:
    container_name: mongo
    image: mongo
    volumes:
      - ./data:/data/db
    ports:
      - "27017:27017"

在单个容器内运行多个容器是不可能的。我们将利用 Docker Compose up 工具(docs.docker.com/compose/overview/),可以通过运行sudo curl -L https://github.com/docker/compose/releases/download/1.21.0/docker-compose-$(uname -s)-$(uname -m) -o/usr/local/bin/docker-compose来下载。我们将在第九章中查看docker compose部署、日志和监控

分解此文件后,我们看到以下内容:

  • 我们有一个名为app的服务,为产品目录服务添加了一个容器。

  • 我们指示 Docker 在容器自动失败时重新启动容器。

  • 构建应用程序服务(我们的 TypeScript Node.js 应用程序),我们需要告诉Dockerfile的位置,它可以找到构建说明。build ./命令告诉 Docker,Dockerfiledocker-compose.yml在同一级别。

  • 我们映射主机和容器端口(这里我们保持两者相同)。

  • 我们已经添加了另一个服务 Mongo,它从 Docker Hub 注册表中拉取标准的 Mongo 镜像。

  • 接下来,我们通过挂载/data/db和本地数据目录/data来定义数据目录。

  • 这将具有类似于启动新容器时的优势。Docker compose 将使用先前容器的卷,从而确保没有数据丢失。

  • 最后,我们将应用程序容器链接到 Mongo 容器。

  • 端口3000:8081基本上告诉我们,Node.js 服务暴露给外部容器世界可以在端口3000访问,而在内部应用程序在端口8081上运行。

  1. 现在,只需在父级别打开终端并输入以下命令:
docker-compose up

这将启动两个容器并聚合两个容器的日志。我们现在已经成功地将我们的应用程序 Docker 化。

  1. 运行docker-compose up将给出一个错误,无法连接到 MongoDB。我们可能做错了什么?我们通过docker-compose选项运行多个容器。Mongo 在其自己的容器内运行;因此,它无法通过localhost:27017访问。我们需要更改我们的连接 URL,将其指向 Docker 服务而不是 localhost。在default.json中更改以下行:
"mongo":{"urlClient": "mongodb://127.0.0.1:27017/products"}, to 
"mongo":{"urlClient": "mongodb://mongo:27017/products"}
  1. 现在,运行docker-compose up,您将能够成功地启动和运行服务。

通过将我们的微服务 Docker 化,我们已经完成了开发和构建周期。在下一节中,我们将快速回顾我们到目前为止所做的工作,然后转向下一个主题,微服务最佳实践

概要

在本节中,我们将快速查看我们使用的一些模块,并描述它们的目的:

routing-controllers 具有各种选项,基于 ES6。它有许多装饰器,如@GET@POST@PUT,可以帮助我们设计无需配置的服务。
config 从中我们可以根据不同环境编写各种文件的配置模块,从而帮助我们遵守十二要素应用程序。
typedi 用作依赖注入容器。然后我们可以使用它将服务(@Service)注入到任何控制器中。
winston 用于日志记录模块。
typeORM 用 TypeScript 编写的用于处理关系数据库的模块。
mongoose 用于处理 MongoDB 的流行 Mongoose ORM 模块。
cors 为我们的微服务启用 CORS 支持。
class-validator 用于根据我们配置的规则验证任何输入请求。

同样,基于这个文件夹结构和模块,我们可以创建支持任何数据库的任意数量的微服务。现在我们已经清楚了如何设计微服务,在下一节中我们将看一些微服务设计最佳实践。

微服务设计最佳实践

现在我们已经开发了一些微服务,是时候了解一些围绕它们的模式和设计决策。为了获得更广泛的视角,我们将看看微服务应该处理什么,以及不应该处理什么。在设计微服务时需要考虑许多因素,要牢记最佳实践。微服务完全是基于单一责任原则设计的。我们需要定义边界并包含我们的微服务。以下部分涵盖了需要考虑的所有因素和设计原则,以便有效地开发微服务。

建立适当的微服务范围

设计微服务的一个最重要的决定是微服务的大小。大小和范围对微服务设计有很大影响。与传统方法相比,我们可以说每个容器或执行单一责任的任何组件应该有一个 REST 端点。我们的微服务应该是面向领域的,其中每个服务都与该领域中的特定上下文绑定,并将处理特定的业务能力。业务能力可以定义为为实现业务目标而做出的贡献。在我们的购物车微服务系统中,付款、加入购物车、推荐产品和发货是不同的业务能力。每个不同的业务能力应该由一个单独的微服务实现。如果我们采用这种模式,我们将在我们的微服务列表中得到产品目录服务、价格目录服务、发票服务、付款服务等。如果有的话,每个技术能力应该作为单独的微服务捆绑在一起。技术能力不直接为实现业务目标做出贡献,而是作为支持其他服务的简化。一个例子包括集成服务。我们应该遵守的主要要点可以总结为:

  • 微服务应该负责单一的能力(无论是技术还是业务)

  • 微服务应该可以单独部署和扩展

  • 微服务应该由一个小团队轻松维护,并且可以随时替换

自我管理的功能

在确定微服务范围时的另一个重要因素是决定何时提取功能。如果功能是自给自足的,即它对外部功能的依赖很少,它处理给定的输出并产生一些输出。那么它可以被视为微服务边界,并作为单独的微服务保留。常见的例子包括缓存、加密、授权、认证等。我们的购物车有许多这样的例子。例如,它可以是一个中央日志服务,或者是一个价格计算微服务,接受各种输入,如产品名称、客户折扣等,然后根据促销折扣计算产品的价格。

多语言架构

支持多语言架构是产生微服务的一个关键需求。不同的业务能力需要不同的处理方式。"一刀切"的原则不再适用。需要不同的技术、架构和方法来处理所有的业务和技术能力。当我们规划微服务时,这是另一个需要注意的关键因素。例如,在我们的购物微服务系统中,产品搜索微服务不需要关系数据库,但是添加到购物车和支付服务需要 ACID 兼容性,因为在那里处理交易是一个非常独特的需求。

独立可部署组件的大小

分布式微服务生态系统将充分利用当前不断增长的 CI/CD 流程进行自动化。自动化各种步骤,如集成、交付、部署、单元测试、扩展和代码覆盖,然后创建可部署单元,可以让生活更轻松。如果我们在一个单一的微服务容器中包含太多东西,那将带来巨大的挑战,因为涉及到很多过程,比如安装依赖项、自动文件复制或从 Git 下载源代码、构建、部署,然后启动。随着微服务的复杂性增加,微服务的大小将增加,这很快会增加管理的麻烦。一个设计良好的微服务可以确保部署单元保持可管理性。

根据需要分发和扩展服务

在设计微服务时,根据各种参数对微服务进行分解是很重要的,比如对业务能力的深入分析,基于所有权的服务划分,松散耦合的架构等等。以这种方式设计的微服务在长期内是有效的,因为我们可以根据需求轻松扩展任何服务,并隔离我们的故障点。在我们的产品微服务中,大约 60%的请求将基于搜索。在这种情况下,我们的搜索微服务容器必须单独运行,以便在需要时单独扩展。Elasticsearch 或 Redis 可以在这个微服务之上引入,这将提供更好的响应时间。这将带来各种优势,比如成本降低,资源的有效利用,业务利益,成本优化等等。

敏捷

随着需求的动态变化,敏捷开发方法已经被广泛采用。在规划微服务时,一个重要的考虑因素是以每个团队可以开发饼图的不同部分的方式进行开发。每个团队构建不同的微服务,然后我们构建完整的饼图。例如,在我们的购物车微服务中,我们可以有一个推荐服务,专门针对用户的偏好和历史来定位受众。这可以通过考虑用户的跟踪历史、浏览器历史等来开发,这可能导致复杂的算法。这就是为什么它将作为一个单独的微服务开发,可以由不同的团队处理。

单一业务能力处理程序

与传统的单一责任原则有些偏离,一个微服务应该处理一个业务能力或技术能力。一个微服务不应该承担多个责任。根据设计模式,一个业务能力可以被划分为多个微服务。例如,在我们的购物车微服务中的库存管理中,我们可以引入 CQRS 模式来实现一些质量属性,其中我们的读写将分布在不同的服务容器中。当每个服务映射到一个有界上下文,处理一个业务能力时,更容易管理它们。每个服务可以作为单独的产品存在,针对特定的社区。它们应该是可重用的,易于部署等等。

适应不断变化的需求

微服务应该被设计成可以很容易地从系统中分离出来,而不需要进行大量的重写。这使我们可以很容易地添加实验性功能。例如,在我们的购物车微服务中,我们可以根据收到的反馈添加一个产品排名服务。如果服务不起作用或者业务能力没有达到预期,这个服务可以被抛弃或者很容易地替换为另一个服务。在这里,微服务的范围起着重要的作用,因为可以制作一个最小可行产品,然后根据需求添加或删除功能。

处理依赖和耦合

在确定服务范围的另一个重要因素是服务的依赖和引入的耦合。必须评估微服务中的依赖关系,以确保系统中没有引入紧密耦合。为了避免高度耦合的系统,将系统分解为业务/技术/功能能力,并创建功能依赖树。过多的请求-响应调用、循环依赖等因素可能会破坏微服务。设计健壮的微服务的另一个重要方面是具有事件驱动架构;也就是说,微服务应该在接收到事件后立即做出反应,而不是等待响应。

决定微服务中端点的数量

虽然这可能看起来是设计微服务时需要考虑的重要点,但实际上并不是设计考虑的一部分。微服务容器可以承载一个或多个端点。更重要的考虑是微服务的边界。根据业务或技术能力,可能只有一个端点,而在许多情况下,一个微服务可能有多个端点。例如,回到我们的购物车服务和库存管理中引入了 CQRS 模式,我们有单独的读和写服务,每个包含单个端点。另一个例子可以是多语言体系结构,我们可以有多个端点以便在各种微服务之间进行通信。我们通常根据部署和扩展的需求将服务分解为容器。对于我们的结账服务,所有服务都连接并使用相同的关系数据库。在这种情况下,没有必要将它们分解为不同的微服务。

微服务之间的通信风格

在设计微服务时需要考虑的另一个重要因素是微服务之间的通信方式。可以有同步模式(发送请求,接收响应)或异步通信模式(发送并忘记)。这两种模式都有各自的优缺点,以及它们可以使用的特定用例。为了拥有可扩展的微服务,需要结合这两种方法。除此之外,现在“实时性”是新的趋势。基于套接字的通信促进了实时通信。另一种划分通信风格的方式是基于接收者的数量。对于单个接收者,我们有基于命令的模式(如前几章中所见的 CQRS)。对于多个接收者,我们有基于事件驱动架构,它基于发布和订阅模式的原则,其中使用服务总线。

指定和测试微服务契约

合同可以被定义为消费者和提供者之间的一组协议、请求体、地址等协议,这有助于平滑地进行它们之间的交互。微服务应该被设计成可以独立部署,而不依赖于彼此。为了实现这种完全的独立性,每个微服务都应该有良好编写、版本化和定义的合同,所有它的客户(其他微服务)都必须遵守。在任何时候引入破坏性变化可能会成为一个问题,因为客户可能需要合同的先前版本。只有在适当的沟通之后,才能停用或关闭合同。一些最佳实践包括并行部署新版本,并在 API 中包含版本信息。例如,/product-service/v1,然后/product-service/v2。使用消费者驱动的合同CDCs)是测试微服务的现代方式之一,与集成测试相比。此外,在本书中,我们将使用 Pact JS 来测试我们的合同(第八章,测试、调试和文档)。

容器中微服务的数量

将您的微服务容器化是部署微服务的最推荐方式之一。容器为您的系统提供了灵活性,并简化了开发和测试体验。容器在任何基础设施上都是可移植的,并且也可以轻松地部署在 AWS 上。决定容器中可以包含多少微服务是至关重要的,并且取决于各种因素,例如容器容量、内存、选择性扩展、资源需求、每个服务的流量量等。基于这些事实,我们可以决定是否可以将部署合并在一起。即使服务被合并在一起,也必须确保这些服务是独立运行的,它们不共享任何东西。选择性扩展也是决定容器中微服务数量的关键因素之一。它应该是这样的,即部署是自管理的,例如 AWS Lambda。以下是可用的模式和每种模式的限制:

  • 每个虚拟机一个服务实例:在这里,您将每个服务打包为虚拟机镜像(传统方法),例如 Amazon EC2 EMI。在这里,每个服务都是一个单独的 VM,它在单独的 VM 镜像中启动:

  • 限制

  • 资源利用效率低

  • 您需要为整个 VM 付费;因此,如果您没有利用整个 VM,您将为无用的费用付费

  • 在服务上部署新版本非常慢

  • 管理多个 VM 很快就会成为一个巨大的痛苦和耗时的活动

  • 每个容器一个服务实例:在这里,每个服务都在自己的容器上运行。容器是一种便携式的虚拟化技术。它们有自己的根文件系统和便携式命名空间。您可以限制它们的 CPU 资源和内存。

  • 限制

  • 容器不像 VM 那样成熟

  • 处理负载的激增是一个额外的任务

  • 监控 VM 基础设施和容器基础设施又是一个额外的任务

  • 无服务器:最新的“无忧趋势”之一是无服务器架构,您可以将微服务打包为 ZIP 文件,并部署到无服务器平台,如 AWS Lambda。您只需根据所用时间和内存消耗对每个请求进行计费。例如,Lambda 函数是无状态的。

  • 限制

  • 这种方法不适用于长期运行的服务。一个例子是一个服务依赖于另一个服务或第三方代理。

  • 请求必须在 300 秒内完成。

  • 服务必须是无状态的,因为每个单独的实例都是为每个请求运行的。

  • 服务必须快速启动,否则它们将超时。

  • 服务必须在支持的语言中运行。例如,AWS Lambda 支持 Java、Node.js 和 Python。

微服务中的数据源和规则引擎

另一个重要因素是应用规则引擎,并在我们的分布式系统中决定数据源。规则是任何系统的重要部分,因为它们帮助我们管理整个系统。许多组织使用集中式规则引擎或遵循 BPMN 标准示例的工作流程,比如 Drools。嵌入式规则引擎可以放置在服务内部,也可以根据使用情况放置在服务外部。如果存在复杂规则,具有嵌入式引擎的中央编写存储库将是最佳选择。由于它是集中分布的,可能存在技术依赖性,规则在某些应用服务器边界内运行规则等。

业务流程建模符号(BPMN)是标准化符号,其目标是创建任何业务或组织流程的可视模型。在业务能力中,我们经常需要一个明确定义的工作流程,可以根据需求进行更改。我们从不硬编码任何流程或编写自己的引擎,而是利用 BPMN 工具进行操作。

就像规则引擎一样,在微服务中决定数据存储也是至关重要的。事务边界应该在我们定义的业务能力内设置。例如,在我们的购物车微服务中,在结账时,我们需要维护事务,并且我们可以选择关系型数据库作为数据源,以确保完整性并遵循 ACID 原则。然而,产品目录数据库没有任何事务,我们可以为其使用 NoSQL 数据库。

总结

在本章中,我们开始为购物车服务设计我们的微服务。我们根据技术、功能和业务能力分析了我们的需求,这些是微服务范围的主要驱动因素。我们设计了我们的模式,分析了我们的微服务结构,并在 Docker 上运行了它。最后,我们研究了一些微服务设计的最佳实践,并学习了如何根据我们的业务能力来确定微服务的范围。

在下一章中,我们将学习如何向我们的微服务引入网关,并了解网关解决的问题。我们将看到 API 网关如何解决分布式系统中的集中式问题。我们将熟悉一些 API 网关设计模式,并为购物车微服务设计我们的网关。

第五章:理解 API 网关

设计了一些微服务后,我们将在这里讨论微服务网关。与单片应用程序相比,微服务不通过内存调用进行通信,而是使用网络调用。因此,网络设计和实现在分布式系统的稳定性中起着重要作用。我们将揭示 API 网关,并了解它如何处理基于微服务的架构中的重要关注点。

本章将从理解 API 网关及其必要性开始。然后将讨论 API 网关处理的所有集中关注点,以及引入网关的好处和缺点。我们将为购物车微服务设计我们的网关,并查看网关的所有可用选项,并熟悉 API 网关中涉及的设计模式和方面。本章将讨论以下主题:

  • 揭示 API 网关

  • API 网关处理的关注点

  • API 网关设计模式

  • 断路器及其作用

  • 我们的购物车微服务中需要网关

  • 可用的网关选项

  • 为购物车微服务设计我们的网关

揭示 API 网关

随着我们深入微服务开发,我们看到前方有各种陷阱。现在我们的微服务已经准备就绪,当我们考虑客户端利用这些微服务时,我们将遇到以下问题:

  • 消费者或 Web 客户端在浏览器上运行。前端没有任何发现客户端,负责识别容器/VM 服务的位置,也不负责负载平衡。我们需要一个额外的拼图,它连接后端不同容器中的微服务,并将该实现从客户端抽象出来。

  • 到目前为止,我们还没有讨论像认证服务、版本化服务、过滤或转换任何请求/响应等集中关注点。经过反思,我们意识到它们需要一个中央控制点,可以在整个系统中应用,而无需在每个地方重新实现相同的逻辑。

  • 此外,不同的客户端可能有不同的合同要求。一个客户端可能期望 XML 响应,而另一个需要 JSON 响应。我们需要一个中心组件来处理路由请求,根据协议需求翻译响应,并根据需要组合各种响应。

  • 如果我们想独立按需扩展任何微服务,需要根据需要添加新实例,其位置应该对客户端进行抽象。因此,我们需要一个不断与所有微服务通信并维护注册表的中央客户端。此外,如果服务宕机,它应该通知客户端并在那里断开连接,从而防止故障传播。此外,它可以作为中央缓存管理的地方。

API 网关是一种解决所有上述问题的服务类型。它是我们微服务世界的入口点,并为客户端提供与内部服务通信的共享层。它可以执行路由请求、转换协议、认证、对服务进行速率限制等任务。它是治理的中心点,有助于实现以下各种事项:

  • 监控整个分布式移动系统,并相应地采取行动

  • 通过抽象实例和网络位置,以及通过 API 网关路由每个请求,将消费者与微服务解耦

  • 通过将可重用代码保存在一个地方,避免代码重复

  • 根据需要实现按需扩展,并从一个地方对故障服务采取行动

  • 定义 API 标准,例如 Swagger,Thrift IDL 等

  • 设计合同

  • 跟踪 API 的生命周期,包括版本化、利用率、监控和警报、限流等

  • 避免客户端和微服务之间的啰嗦通信

作为进入完全移动的分布式系统的单一入口点,很容易强制执行任何新的治理标准(例如,每个消费者都应该有 JWT 令牌),进行实时监控,审计,API 消费政策等。

JWT 令牌模式利用加密算法:令牌验证方法。在任何成功的身份验证之后,我们的系统生成一个具有 userID 和时间戳值的唯一令牌。将此令牌返回给客户端,需要在进一步的请求中发送。在接收任何服务请求时,服务器会读取并解密令牌。此令牌通常被称为JSON Web TokenJWT。为了防止跨站点请求伪造CSRF)等攻击,我们使用这种技术。

网关提供了灵活性,可以自由操纵微服务实例,因为客户端完全与此逻辑抽象。这是处理基于客户端设备的转换需求的最佳位置。网关充当缓冲区,防止任何形式的攻击。服务被污染,不会危及整个系统。网关通过满足所有这些标准来处理安全性,保密性,完整性和可用性。随着利益的增加,如果网关没有得到适当处理,也会有很多缺点。网关可能会引入指数级的复杂性,随着动态系统的增加,响应时间会增加。

在下图中,详细解释了 API 网关:

现在我们知道网关的作用,让我们现在了解网关的基本知识以及它总体上处理的事情。

API 网关处理的问题

API 网关成为微服务架构中最重要的组件之一,因为它是处理核心问题的唯一位置。因此,在所有微服务实现中看到的常见实现是引入提供关键功能的 API 网关。此外,API 网关是连接到服务发现的部分,动态维护所有新添加服务的路由。在本节中,我们将研究网关功能,并了解我们中央操作层的整体架构的角色和影响。

安全性

随着分布的增加,自由度相当高。有很多移动服务,可能随时上升或下降。从安全性的角度考虑,当有很多移动部分时,事情可能出现相当大的问题。因此,需要一定的规则来管理安全性。因此,我们需要保护所有面向公众的 API 端点的远程服务调用。我们需要处理各种事情,如身份验证,威胁漏洞,授权,消息保护和安全通信。我们将添加 SSL/TLS 兼容的端点,以防范各种攻击,如中间人攻击,双向加密防止篡改。此外,为了处理 DDoS 攻击,API 网关将处理各种因素,如限制请求速率,按需连接数量等。网关将关闭慢速连接,黑名单或白名单 IP 地址,限制与其他后端微服务的连接,维护数据库连接数量等。API 网关将处理身份验证和授权等事项。我们可以在这里引入联邦身份,如OpenIDSAMLOAuth。此外,该层将生成 JWT 并验证所有请求。

微服务开发中的一个棘手部分是身份和访问管理。在大型企业中,通常通过 LDAP 等常见系统处理这一问题。联邦身份有点像授权服务器(它们在各种应用程序中使用:例如,您可以考虑将单个 Google 帐户链接到 Google 文档、Google Drive 等各种服务,然后授权用户并提供 ID 令牌和访问令牌)。著名的联邦身份提供者包括 OAuth 和安全断言标记语言SAML)。

愚蠢的网关

网关的最基本原则之一是网关始终是愚蠢的。在设计网关时,需要注意的一个重要方面是,网关不应过于雄心勃勃;也就是说,它不应包含非通用逻辑或任何业务需求。使其过于雄心勃勃会违背网关的目的,并且可能使其成为单点故障,并且也可能使其难以测试和部署。智能网关无法轻松进行版本控制或集成到大型管道中。此外,它引入了紧密耦合,因为当您升级网关时,通常必须处理升级其依赖项和与之相关的核心逻辑。

简而言之,API 网关应包含任何我们可以在其中验证或维护的内容,而无需其他服务或共享状态的帮助。除此之外的任何内容都应移出 API 网关。以下几点简要总结了 API 网关的愚蠢和其功能:

  • 像 JWT 令牌验证这样的验证(我们不请求任何外部服务)

  • 提高服务质量(例如缩小响应、HTTP 头缓存、发送缓存数据等)

  • 请求和响应操作(处理多种内容类型并相应操作请求和响应)

  • 与服务发现的交互(与服务注册表进行非阻塞交互以获取服务请求详细信息)

  • 速率限制和节流(隔离的功能)

  • 断路器(检测故障并相应处理)

转换和编排

我们已经很好地将我们的微服务划分为单一责任原则;然而,为了实现业务能力,我们经常需要结合多个微服务。例如,购买产品的人是支付微服务、库存微服务、运输微服务和结账微服务的混合体。就像 Linux 管道结合各种命令一样,我们需要类似的编排解决方案。这对消费者来说是至关重要的,因为逐个调用每个细粒度服务绝对是一场噩梦。以我们的购物车微服务为例。我们有以下两个微服务:

  • 受众定位:这些微服务接收用户信息并返回所有推荐的列表(它返回产品 ID 的列表)

  • 产品详情:这些微服务接收产品 ID 并通过提供产品元数据和详细信息来做出响应

假设我们正在为 20 个项目设计一个推荐页面。如果我们保持原样,那么消费者将不得不进行总共 21 次 HTTP 调用(1 次获取产品 ID 列表的调用,20 次获取产品详细信息的调用),这是一场噩梦。为了避免这种情况,我们需要编排器(可以组合所有这些 21 次调用的东西)。此外,微服务必须处理需要不同响应的不同客户端。API 网关是一个转换的地方,可以处理通信协议、响应格式、协议转换等所有事情。我们可以在 API 网关中放置诸如 JSON 到 XML 转换、HTTP 到 gRPC 或 GraphQL 协议转换等内容。

监控、警报和高可用性

微服务架构中有很多移动部件。因此,系统范围的监控和避免级联故障变得至关重要。API 网关为这个问题提供了一站式解决方案。我们可以监控和捕获所有数据流的信息,可以用于安全目的。我们可以监控健康、流量和数据。API 网关可以监控各种事物,如网络连接、日志维护、备份和恢复、安全性以及系统状态和健康状况。此外,API 网关还可以监控一些基本事物,如 API 的请求数、维护远程主机、浏览器、操作系统、性能统计、消息的堆栈跟踪、违反网关策略的违规行为等。API 网关可以集成警报工具,如 consul alerts (github.com/AcalephStorage/consul-alerts),并相应地采取适当的行动以实现高可用性。我们必须在负载均衡器后部署多个 API 网关实例,以有效地在多个 API 网关实例之间平衡流量。我们必须计划高容量和负载。如果部署在云中,我们可以启用自动扩展,如果没有,则必须确保它有足够的数据资源来处理未来的负载。

缓存和错误处理

为了实现最大的优化和性能,缓存经常被引入到分布式系统中。Redis 因为它轻量级并且可以很好地满足缓存的目的,因此得到了巨大的增长。此外,在某些业务能力中,可以容忍陈旧的数据,这是离线优先时代。API 网关可以处理这一部分,如果微服务宕机或者防止过多的数据库调用,提供缓存响应。设计缓存机制的黄金法则可以是那些实际上永远不需要进行的服务调用应该是最快的调用。例如,考虑 IMDB 中《复仇者联盟 4》页面的更新。它每秒都在获得超过 20,000 次的点击。

数据库受到这些访问的冲击,因为它还必须获取其他东西(如评论、评论等)。这就是缓存变得有用的地方。很少改变的东西,如演员描述、电影描述等,来自缓存层。返回的响应非常快,它节省了网络跳跃,也不会增加 CPU 性能。通过实施缓存层,API 网关确保用户体验不受影响。在分布式系统中,由于通信频繁,错误很可能发生,因此错误应该通过超时和断路器等模式得到适当处理,这些模式应该提供缓存响应。

我们可以在以下两个级别进行缓存管理:

  • 在 API 网关级别进行缓存: 选择这个选项,我们可以在网关或中央级别缓存服务响应。这样可以节省服务调用的优势,因为我们可以直接在网关级别返回数据。此外,在服务不可用或无响应的情况下,API 网关可以从缓存中返回数据。

  • 在服务级别进行缓存: 选择这个选项,每个服务都可以管理自己的缓存数据。API 网关不知道内部缓存或内部任何精确的东西。服务可以根据需要轻松地使缓存失效。然而,在实施这个选项时,我们应该在中央缓存级别准备好默认响应。

Netflix Hystrix 是一个非常有用的库,具有强大的选项,如超时调用超过特定阈值,不必要等待,定义回退操作,如返回默认值或从缓存返回值。它也有一个 Node.js 客户端 (www.npmjs.com/package/hystrixjs)。

服务注册和发现

微服务的一个关键优势是易于扩展。在任何时候,新的微服务都可以根据流量进行调整,可以进行扩展,并且现有的单片式可以分解为多个微服务。所有这些服务实例都具有动态分配的网络位置。API 网关可以维护与服务注册表的连接,该注册表可以跟踪所有这些服务实例。API 网关与包含所有实例的网络位置的数据库进行通信。每个服务实例在启动和关闭时都会告诉注册表其位置。与 API 网关连接的另一个组件是服务发现。消费各种微服务的客户端需要具有简单的发现模式,以防止应用程序变得过于啰嗦。

Consul 是最广泛使用的服务注册和发现工具之一。它知道特定服务有多少活动容器失败,如果该数字为零,它会将该服务标记为损坏。

有以下两种类型的方法:

  • 推送:微服务本身负责向 API 网关确认其入口

  • 拉取:API 网关负责检查所有微服务

断路器

API 网关处理的另一个重要问题是当服务宕机时断开连接。比如说一个微服务宕机并开始抛出大量错误。排队进一步请求该微服务是不明智的,因为它很快就会有很高的资源利用率。在这里引入的 API 网关可以实现诸如断开连接或者简单地说当某个阈值被超过时,网关将停止向该失败组件发送数据,直到组件被解决,分析日志,实施修复,推送更新,从而防止整个系统中的故障级联。因此,扩展底层和流行的微服务变得非常容易。网关因此可以水平和垂直扩展。API 网关通过以滚动方式部署配置来实现零停机时间,也就是说,在新部署时,电路被触发,新请求不会被服务,旧请求在单个集群中被接受,同时另一个集群接受新请求。我们将在第七章中看到断路器的实时示例,服务状态和服务间通信

版本控制和依赖解析

当微服务非常细粒度并且基于单一职责原则设计时,它们只处理特定的问题,因此它们变得啰嗦(太多的网络调用):也就是说,为了执行一组常规任务,需要向不同的服务发送许多请求。网关可以提供虚拟端点或外观,可以在内部路由到许多不同的微服务。API 网关可以解析所有依赖关系,并将所有响应分离成一个单一的响应,从而使客户端易于消费。此外,随着不断变化的业务需求和能力,我们需要保持版本控制,因此在任何时候,我们都可以回到旧服务。

API 版本控制有两种方式进行管理——一种是通过在 URI 中发送(URI 不要与 URL 混淆,它是包含信息的统一资源标识符,例如http://example.com/users/v4/1234/),另一种是通过在标头中发送。API 网关可以通过以下两种方式处理这个问题:

  • 微服务发现:这是最广泛使用的模式,其中微服务和客户端应用程序之间的耦合完全消除,因为微服务是动态注册的(我们将在下一章中更详细地看到这一点)。这个组件直接与 API 网关联系,并向其提供有关服务位置的信息,从而防止传统的 SOA 单片式方法。

  • 微服务描述:另一方面,这种方法更注重通过合同进行通信。它以非常详细的描述性合同表达微服务的特性,这些合同可以被其他客户端应用程序理解。合同还包含元数据信息,如 API 版本、要求等。

在这一部分,我们看了 API 网关副处理的所有关注点。对于 API 网关,应特别注意以下几个方面:

  • 它不应该是单点故障

  • 它不应该是集中化的或具有同步协调

  • 它不应该依赖于任何状态

  • 它应该只是另一个微服务

  • 业务逻辑不应该封装在内部

API 网关设计模式和方面

现在我们知道 API 网关处理什么,让我们现在看看 API 网关涉及的常见设计方面。在这一部分,我们将看看在设计 API 网关时需要考虑的所有设计方面。我们将了解设计 API 网关的模式,这将帮助我们设计一个具有高可用性的可扩展系统。

作为处理集中关注点并且是微服务的起点的核心部分,API 网关应该被设计成:

  • 它支持并发性:由于基于单一责任的设计而具有高度分布性,需要服务器端并发性,这可以减少网络通信。Node.js 是非阻塞和异步的,每个请求都与其他请求并行执行,因此单个重型客户端请求并不比许多轻量级非并发请求好多少。虽然业务用例可能需要对后端系统进行阻塞调用,但 API 网关应该通过响应式框架以高效的方式组合这些调用,这不会增加资源池的利用率。

  • 它应该是反应式的:反应式编程提供了各种操作符,能够过滤、选择、转换、组合和组合可观察对象,从而在 API 网关层实现高效的执行和组合。它提倡随时间填充的变量的概念。它提倡非阻塞架构,因为在可观察模式中,生产者只是在值可用时向消费者推送值,而不是在那段时间内阻塞线程。值可以在任何时间点异步或同步到达。它还有额外的优势,比如生产者可以向消费者发出结束信号,告诉消费者没有更多的数据,或者发生了错误。

  • 服务层遵循可观察模式:当 API 网关中的所有方法都返回Observable<T>时,默认启用并发性。服务层然后遵循诸如根据条件返回缓存响应以及如果资源不可用或服务不可用,则阻止请求等操作。这可以在不改变客户端端的情况下发生。

  • 它处理后端服务和依赖关系:由于网关在虚拟外观层后面抽象了所有后端服务和依赖关系,因此任何入站请求都可以访问业务能力而不是整个系统。这将允许我们在对依赖它的代码影响有限的情况下更改底层实现。因此,服务层确保所有模型和紧密耦合保持内部,并且被抽象化并且不允许泄漏到端点中。

  • 它们应该是无状态的:API 网关应该是无状态的,这意味着不创建任何会话数据。这将使我们能够扩展网关,因为在灾难情况下不需要在以后复制会话。但是,API 网关可以维护缓存数据,可以使用点对点关系复制这些数据,或者引入缓存库(如 Redis)而不是进行内存调用。以下是一些常见陷阱的一般指导方针:

  • 为了实现最佳的可用性,API 网关应该在主动-主动模式下使用。这意味着系统应该始终保持完全运作,并能够维持当前的系统状态。

  • 适当的分析和监控工具以防止消息洪泛。在这种情况下,对该服务的流量应该受到限制。

  • 使用工具不断监视系统,可以通过一些可用的工具、系统日志或网络管理协议。

主动-主动模式是一种处理故障转移、负载平衡和保持系统高度可用性的方法。这里使用两个或更多服务器,它们聚合网络流量负载,并一起工作作为一个团队将其分配给可用的网络服务器。负载均衡器还会持久保存信息请求,并将此信息保存在缓存中。如果它们返回寻找相同的信息,用户将直接锁定到之前提供其请求的服务器上。这个过程大大减少了网络流量负载。

断路器及其作用

在实际世界中,错误确实会发生。服务可能会超时,变得无法访问,或者需要更长时间才能完成。作为一个分布式系统,整个系统不应该崩溃。断路器是解决这个问题的方法,它是 API 网关中非常重要的组件。

该模式基本上分为两种状态。如果电路关闭,一切正常,请求被分派到目的地,接收到响应。但如果有错误或超时,电路就会打开,这意味着该路由目前不可用,我们需要采用不同的路线或方式来实现服务请求。为了实现这个功能,Netflix 开源了他们的项目——Hystrix。然而,这是同样的 Node.js 版本:www.npmjs.com/package/hystrixjs(这不是 Netflix 官方的,而是一个开源项目)。它甚至有用于监控目的的 Hystrix 仪表板。根据 Hystrix 的库,它具有以下功能:

  • 保护系统免受因网络问题或任何第三方客户端或库而发生的任何故障

  • 停止传播失败,避免错误的扩散

  • 快速失败,经常失败,更好地失败,向前失败,并迅速恢复与对策

  • 使用回退机制来降级失败,比如从缓存中返回响应

  • 提供监控目的的仪表板

看一下下面的图表:

断路器遵循与原始Hystrix模块相同的一套规则。为了计算命令的健康状况,执行以下步骤:

  1. 在整个电路中保持对音量的监控如下:
  • 如果电路中的网络音量没有超过预定义值,那么 Hystrix 可以简单地执行运行函数,而根本不需要比较任何东西。度量可以记录所有这些情况以供将来参考。

  • 如果电路中的网络音量超过配置的边界值,Hystrix 可以首先检查健康状况以采取预防措施。

  • 在检查健康状况时,如果错误百分比超过预定义的阈值,电路的转换会从关闭到打开,所有后续的请求都将被拒绝,以防止进一步的请求。

  1. 经过一段时间的组织,Hystrix 可以允许一个请求通过以检查服务是否已恢复。如果它通过了期望的测试,电路再次转换为关闭状态,并且所有计数器被重置。要在应用程序中使用它,只需创建服务命令并添加值:
var serviceCommand = CommandsFactory.getOrCreate("Service on port :"+ service.port +":"+ port)
 .circuitBreakerErrorThresholdPercentage(service.errorThreshold)
 .timeout(service.timeout)
 .run(makeRequest)
 .circuitBreakerRequestVolumeThreshold(service.concurrency)
 .circuitBreakerSleepWindowInMilliseconds(service.timeout)
 .statisticalWindowLength(10000)
 .statisticalWindowNumberOfBuckets(10)
 .errorHandler(isErrorHandler)
 .build();
 serviceCommand.service = service;
 commands.push(serviceCommand);
  1. 要执行这些命令,只需使用 execute 方法。在hystrix文件夹中的源代码中可以找到完整的要点。

我们购物车微服务中网关的需求

在详细解释网关之后,让我们回到我们的购物车微服务系统。我们将看看我们系统中网关的需求以及它将处理的内容,然后继续设计网关。在本节中,我们将看看在设计网关时需要考虑的各种设计方面。

处理性能和可伸缩性

作为系统性能、可伸缩性和 API 网关高可用性的入口点,非常关键。因为它将处理所有请求,使其成为异步非阻塞 I/O 似乎非常合乎逻辑,这正是 Node.js 的特点。来自我们的购物车微服务的所有请求都需要经过身份验证、缓存、监控,并不断发送健康检查。考虑一个场景,我们的产品服务有大量流量。API 网关应该自动产生服务器的新实例并维护新实例的地址。然后新实例需要不断向网关发送健康检查,以便知道哪些实例是活着的。考虑之前我们看到的同样的例子,我们有产品微服务,我们需要向客户显示 20 个项目的详细列表。现在客户不会发出 21 个 HTTP 请求,而是我们需要一个核心组合组件,它将从各种请求中组合响应。

响应式编程提高胜算

为了确保我们不必频繁更改客户端代码,API 网关简单地将客户端请求路由到微服务。它可能通过进行多个后端服务调用来发出其他请求,然后聚合所有结果。为了确保最小的响应时间,API 网关应该同时进行独立调用,这就是响应式编程模型发挥作用的地方。在各种情况下都需要 API 组合,比如获取用户的过去订单,我们首先需要获取用户详情,然后获取他们的过去订单。使用传统的异步回调编写组合逻辑很快就会导致回调地狱的问题,这将产生耦合、混乱、难以理解和容易出错的代码,这就是响应式编程非常有帮助的地方。

调用服务

微服务确实需要根据业务能力同步或异步地相互通信。必须有进程间通信机制。我们的购物车微服务可以有两种通信模式。一种涉及消息代理,它排队消息并在可用时将它们发送到服务。另一种涉及无代理通信,服务直接与另一个服务通信,这可能会导致数据丢失。有许多事件驱动的消息代理,如 AMQP、RabbitMQ 等,还有一些无代理的,如 Zeromq。

一些业务能力需要异步通信模式,比如在产品结账时,我们需要调用支付服务。只有成功支付,产品才能被购买。API 网关需要支持基于业务能力的各种机制。我们将在第七章中看到一个实时例子,服务状态和服务间通信,在NetFlix 案例研究部分。

发现服务

随着不断动态和发展的服务,我们的网关需要知道系统中每个微服务的位置(IP 地址、服务端口)。现在,这可以在系统中进行热插拔,但由于它们不断发展,我们需要更动态的方法,因为服务不断自动扩展和升级。例如,在我们的购物车微服务中,我们可能会根据用例不断添加新服务。现在 API 网关需要知道这些服务的位置,以便随时查询任何服务以返回响应给客户端。API 网关必须与服务注册表保持通信,服务注册表只是所有微服务位置及其实例的数据库。

处理部分服务故障

另一个需要解决的问题是处理部分故障。当一个服务调用另一个服务时,可能根本不会收到响应,或者可能会收到延迟的响应。随着服务数量的增加,任何服务都可能在任何时间点宕机。API 网关应能够通过实施以下一些/全部策略来处理部分故障:

  • 默认使用异步通信模式。仅在需要时使用同步模式。

  • 应处理多次重试,采用指数退避,即 1、2、4、16 等。

  • 定义良好的网络超时,以防止资源阻塞。

  • 断路器模式用于在服务宕机或过载时中断请求。

  • 回退或返回缓存值。例如,产品的图像不会经常更改,可以进行缓存。

  • 监控排队请求的数量。如果数量超过限制,那么发送进一步的请求就没有意义。

设计考虑

一个良好的 API 网关应遵循以下设计考虑,以便拥有坚固的微服务设计:

  • 依赖性:不应依赖任何其他微服务。API 网关只是另一个微服务。如果任何服务 ID 在预先配置的时间内不可用或不遵循 SLA,则 API 网关不应等待该服务。它应该使用断路器或其他回退策略快速失败,如返回缓存响应。

  • 数据库和业务逻辑:API 网关不应具有数据库连接。网关是愚蠢的,即它们没有任何状态。如果需要数据库,我们需要创建一个单独的微服务。同样,业务逻辑应该驻留在服务本身。网关只是将任何服务请求路由到适当的目的地。

  • 编排和处理多种内容类型:服务编排(微服务相互通信的模式)应该在 API 网关而不是编排中完成。网关应连接到服务注册表,这样我们就可以得到动态移动服务的位置。

  • 版本控制:网关应具有适当的版本控制策略。就像我们需要将一块巨大的岩石移到山上,但由于它太大,我们将岩石分成较小的块并分发给每个人。现在每个人都会按自己的步伐前进,但这并不意味着他必须满足其他人的期望,因为最终重要的是整块岩石而不是较小的块。同样,服务的任何特定版本都不应该破坏暴露的合同。新合同应根据需要进行更新,以便其他客户端了解新的期望,直到需要向后兼容性。

  • 高可用性:它应该是高可用和可扩展的。应该为高容量和高负载进行规划。如果部署在云中,我们可以选择:AWS 自动扩展。

在下一节中,我们将深入研究可用的网关选项并进行详细讨论。我们还将查看一些云提供商选项,并了解每个选项的优缺点。

可用的 API 网关选项

现在让我们看一些可用的 API 网关的实际实现。在本节中,我们将看到 Express 网关、Netflix OSS、消息代理、NGINX 作为反向代理以及用于设计网关的工具等选项。

HTTP 代理和 Express 网关

HTTP 代理是用于代理的 HTTP 可编程库。这对于应用反向代理或负载平衡非常有帮助。npm 中可用的http-proxy每天的下载量超过 10 万次。为了实现请求分发,我们可以使用http-proxy。这很容易实现,可以像这样实现:

const express = require('express')
const httpProxy = require('express-http-proxy')
const app = express();
const productServiceProxy= httpProxy('https://10.0.0.1/') 
//10.0.0.1 is product container location
// Authentication
app.use((req, res, next) => {
    // TODO: Central Authentication logic for all
    next()
    })
// Proxy request
app.get('/products/:productId', (req, res, next) => {
    productServiceProxy(req, res, next)

Express 网关是基于 Express.js 和 Node.js 构建的网关之一,是最简单易用的,具有诸如面向微服务用例的语言不可知性和可移植性等广泛选项,因此可以在 Docker 中的任何地方(公共或私有云)运行。它可以与任何 DevOps 工具一起使用,并且配备了预打包的经过验证和流行的模块。我们可以使用任何 express 中间件来扩展它,这完全基于配置,配置会自动检测并进行热重载。以下是 Express 网关中的核心组件:

端点(API 和服务) 它们只是 URL。Express 网关以两种形式维护它们。API 端点和服务端点。API 端点是公开的,并将 API 请求代理到服务中请求的微服务。
策略 一组条件、操作或合同,用于评估并对通过网关的任何请求采取行动。中间件被利用。
管道 一组与微服务相关联的策略,按顺序执行。对于策略执行,API 请求通过管道,最终遇到一个代理策略,该策略指导请求到服务端点。
消费者 消费微服务的任何人。为了处理不同的消费者,Express 网关配备了一个消费者管理模块。其中的黄金法则是应用程序必须属于一个用户。
凭证 认证和授权的类型。消费者或用户可能有一个或多个凭证。凭证与范围相关联。Express 网关配备了凭证管理模块。
范围 用于分配授权的标签。保护端点的授权策略查看凭证,以确保系统的完整性,并且消费者具有相应的范围。

现在让我们看一个 Express 网关的示例。以下是使用 Express 网关生成的gateway.config.yml文件的示例(www.express-gateway.io/)。

http:
  port: 8990
  serviceEndpoints:
    example: # will be referenced in proxy policy
    url: 'http://example.com'
  apiEndpoints:
  api:
    path: '/*'
  pipelines:
  example-pipeline:
    apiEndpoints: # process all request matching "api" apiEndpoint
    - api
  policies:
  - jwt:
  - action:
  secretOrPublicKeyFile: '/app/key.pem'
  - proxy:
  - action:
  serviceEndpoint: example # reference to serviceEndpoints Section

上述配置是网关和代理路由中 JWT 的最简单示例,并且是不言自明的。

Zuul 和 Eureka

接下来我们要看的选项是 Netflix 提供的 Zuul 代理服务器。Zuul 是一个边缘服务,其目标是代理请求到各种后端服务。因此,它充当了消费服务的“统一前门”。Zuul 可以与 Netflix 提供的其他开源工具集成,例如 Hystrix 用于容错、Eureka 用于服务发现、路由引擎、负载平衡等。Zuul 是用 Java 编写的,但可以用于任何语言编写的微服务。Zuul 在以下方面提供了便利:

  • 验证每个资源的合同要求。如果合同未得到满足,则拒绝那些不符合要求的请求。

  • 通过跟踪有意义的数据和统计信息,为我们提供准确的生产视图。

  • 连接到服务注册表,并根据需要动态路由到不同的后端集群。

  • 为了逐渐增加集群中的流量来衡量性能。

  • 丢弃超出限制的请求,从而通过为每种类型的请求分配容量来实现负载分担。

  • 处理静态或缓存响应,从而防止内部容器的频繁访问。

Zuul 2.1 正在积极开发,旨在在网关级别实现异步操作。然而,Zuul 2 是非阻塞的,并且完全依赖于 RxJava 和响应式编程。要将 Zuul 作为 API 网关运行,请执行以下步骤:

  1. Zuul 需要 Java 环境。克隆以下 Spring boot 项目:github.com/kissaten/heroku-zuul-server-demo

  2. 使用mvn spring-boot:run启动项目。

  3. 在项目的src/main/resources/application.yml文件中,我们将编写我们的 Zuul 过滤器逻辑。

  4. 我们将在那里添加故障转移逻辑。例如考虑以下示例配置:

zuul:
  routes:
    httpbin:
      path: /**
      serviceId: httpbin
    httpbin:
    ribbon:
      listOfServers: httpbin.org,eu.httpbin.org
    ribbon:
eureka:
  client:
    serviceUrl:
    defaultZone:  
    ${EUREKA_URL:http://user:password@localhost:5000}/eureka/

此配置告诉zuul将所有请求发送到httpbin服务。如果我们想在这里定义多个路由,我们可以。然后,httpbin服务定义了可用服务器的数量。如果第一个主机出现故障,那么代理将故障转移到第二个主机。

下一章通过另一个 Netflix 库 Eureka 实现了服务发现。

API 网关与反向代理 NGINX

在本节中,我们将查看服务器级别可用的可能选项。反向代理(NGINX 或 Apache httpd)可以执行诸如验证请求、处理传输安全性和负载平衡等任务。NGINX 是在微服务网关级别使用反向代理的广泛工具之一。以下代码示例描述了使用反向代理、SSL 证书和负载平衡的配置:

#user gateway;
 worker_processes 1;
 events {worker_connections 1024;}
 http {
     include mime.types;
     default_type application/json;
     keepalive_timeout 65;
     server {
         listen 443 ssl;
         server_name yourdomain.com;
         ssl_certificate cert.pem;
         ssl_certificate_key cert.key;
         ssl_session_cache shared:SSL:1m;
         ssl_session_timeout 5m;
         ssl_ciphers HIGH:!aNULL:!MD5;
         ssl_prefer_server_ciphers on;
         location public1.yourdomain.com {proxy_pass  
         http://localhost:9000;}
         location public2.yourdomain.com {proxy_pass 
         http://localhost:9001;}
         location public3.yourdomain.com {proxy_pass   
         http://localhost:9002;}
     }
 }

上述配置在中心级别添加了 SSL 证书,并在三个域中添加了代理,并在它们之间平衡所有请求。

RabbitMQ

RabbitMQ 是最广泛部署的消息代理之一,它使用 AMQP 协议。Node.js 的amqplib客户端被广泛采用,每天的下载量超过 16,000 次。在本节中,我们将查看amqp的示例实现,并了解它提供的选项。RabbitMQ 更多地遵循基于事件的方法,其中每个服务都监听 RabbitMQ 的“tasks”队列,当监听到事件时,服务完成其任务,然后将其发送到completed_tasks队列。API 网关监听completed_tasks队列,当收到消息时,将响应发送回客户端。因此,让我们通过执行以下步骤设计我们的 RabbitMQ 类:

  1. 我们将定义我们的构造函数如下:
constructor(host, user, password, queues, prefetch) {
    super();
    this._url = `amqp://${user}:${password}@${host}`;
    this._queues = queues || [ 'default' ];
    this._prefetch = prefetch;
}
  1. 接下来,我们将定义我们的连接方法如下:
connect() {
    return amqp.connect(this._url)
    .then(connection => (this._connection =  
    connection).createChannel())
    .then(channel => {
        this._channel = channel;
        channel.prefetch(this._prefetch);
        var promises = [];
        for (var queue of this._queues) {
            promises.push(
            channel.assertQueue(queue, { durable: true })
            .then(result => channel.consume(result.queue, 
            (message) => {
                if (message != null) {
                    this.emit('messageReceived',  
                    JSON.parse(message.content), 
                    result.queue, message);
                }
            }, { noAck: false }))
            );
        }
        return Promise.all(promises);
    });
}
  1. 接下来,我们将有一个send方法,该方法将消息发送到 RabbitMQ 通道,如下所示:
send(queue, message) {
    var messageBuff = new Buffer(JSON.stringify(message));
    return this._channel.assertQueue(queue, { durable: true })
    .then(result => this._channel.sendToQueue(result.queue, 
    messageBuff, { persistent: true }));
}

您可以在此处查看完整文件,其中将找到所有可用选项。与之前的用例类似,您还可以在 definitely typed 存储库中找到amqp模块的类型gist.github.com/insanityrules/4d120b3d9c20053a7c6e280a6d5c5bfb

  1. 接下来,我们只需使用该类。例如,看一下以下代码:
...
constructor(){ this._conn = new RabbitMqConnection(host, user, password, queues, 1);}
...
addTask(queue, task) {
    return this._conn.send(queue, task);
}
...

作为此项目的先决条件,RabbitMQ 必须安装在系统上,这需要安装 Erlang。一旦 RabbitMQ 启动运行,您可以通过键入rabbitmqctl status来检查 RabbitMQ 服务是否正在运行。

设计我们的购物车微服务网关

在看到各种选项后,现在让我们动手开始实现购物车微服务的微服务网关。在本节中,我们将从头开始实现网关,该网关将具有从公共端点到内部端点的请求分派功能,从多个服务聚合响应,并处理传输安全性和依赖关系解析。在继续编码之前,让我们先看一下我们将在此模块中使用的所有概念。

我们将使用什么?

在本节中,我们将查看所有以下节点模块和概念,以便有效地构建我们的网关:

  • ES6 代理:一般来说,代理服务器是指作为客户端请求的中间服务器。ES6 中最强大和有趣的功能之一就是代理。ES6 代理在 API 消费者和服务对象之间充当中间人。当我们希望在访问基础目标对象的属性时获得自己想要的行为时,通常会创建代理。为了配置代理的陷阱,控制基础目标对象,我们使用处理程序函数。

  • NPM 模块 dockerode:它是用于 Docker 远程 API 的 Node.js 响应式模块。它具有一些不错的功能,如用于响应式编程的流、支持附加的多路复用和承诺以及基于回调的接口,便于编程。

  • 依赖注入:这是最重要的设计模式之一(最初在 Java 中开始,现在到处都有),其中一个或多个服务的依赖项被注入或通过引用传递给依赖对象。

请查看第五章的源代码,其中包括服务发现的自定义实现。在完成第六章后,您可以重新访问这个练习。

总结

在本章中,我们揭示了 API 网关。我们了解了引入 API 网关的利弊,以及 API 网关可以集中处理哪些问题。我们研究了 API 网关的设计方面,并了解了在我们的系统中需要 API 网关的原因。我们看了一下断路器以及为什么拥有它是至关重要的。我们研究了可用的网关选项,如 Zuul、Express Gateway、反向代理,并为购物车微服务设计了我们自己的网关。

在下一章中,我们将学习服务注册表和服务发现。我们将看到网关如何连接到服务发现,自动了解移动服务的位置。我们将看到服务可以注册的方式,并了解每种方法的利弊。我们将看到一些选项,比如 consul,并在我们的购物车微服务中实现它们。

第六章:服务注册表和发现

通过网关处理我们分布式系统的核心问题后,我们现在将在本章中讨论服务注册表和发现。我们拥有的服务越多,仅使用预定义端口来处理它们就变得越复杂。在上一章中,我们看到网关与服务注册表进行交互,后者在数据库中维护服务位置。客户端请求根据数据库中的信息分派到服务。在本章中,我们将看到服务注册表是如何填充的,以及服务、客户端和网关如何与之交互。

本章将从理解服务发现开始,了解动态维护服务注册表的方式,注册服务到注册表的不同方式以及每种方式的利弊。我们将了解维护服务注册表的端到端流程,以及根据注册表发现服务的方式。我们将看到设计服务注册表的可用选项,熟悉每个步骤,然后使用可用的最佳实践设计我们的动态服务注册表。在本章中,我们将研究以下主题:

  • 服务注册表的介绍

  • 服务注册表和发现的什么、为什么和如何

  • 服务发现模式

  • 服务注册表模式

  • 服务注册表和发现选项

  • 如何选择服务注册表和发现

服务注册表的介绍

在本节中,我们将看到服务发现的需求以及服务注册表的需求,并尝试理解服务注册表和发现之间的区别。我们已经设置了一些购物车微服务,但是核心依赖于静态的网络位置。我们的代码从配置文件中读取一个值,并在服务位置发生任何变化时,在我们的配置中进行更新。在实际世界中,很难维护这一点,因为服务实例是动态分配位置的。此外,服务实例根据自动扩展、故障处理和更新过程的需要动态变化,这些过程是在微服务世界中从消费者客户端中抽象出来的。因此,客户端需要使用更加强大的服务发现机制。

服务发现可以定义为:

在一个中心位置(API 网关或数据库)注册服务的完整端到端流程,并通过在服务注册表中查找来到达目标服务的消费。

在微服务世界中,不同的微服务通常分布在平台即服务(PaaS)环境中。基础设施通常是不可变的,因为我们通常有容器或不可变的虚拟机镜像。服务通常可以根据流量和预设的指标进行扩展或缩减。由于一切都是不断变化的,直到服务准备好被使用和部署之前,服务的确切地址可能是未知的。这种动态性是微服务世界中需要处理的最重要的方面之一。一个逻辑和显而易见的解决方案是将这些端点持久化在某个地方,这本身就是服务注册表的基础。在这种方法中,每个微服务都向一个中央代理(我们在第五章中看到的组件,理解 API 网关)注册,并提供有关该微服务的所有详细信息,如端点地址、合同细节、通信协议等。消费服务通常会查询代理以查找该服务的可用位置,然后根据检索到的位置调用它。这方面一些常见的选项包括 Zookeeper、Consul、Netflix Eureka 和 Kubernetes,我们很快将更详细地了解它们。

服务注册表和发现的什么、为什么和如何

在简要了解了服务注册表之后,我们将在本节中了解服务注册和发现的原因、目的和方法。从理解服务发现的需求开始,然后了解涉及该过程的过程和组件。

服务注册和发现的原因

无论我们选择哪种容器技术,在生产环境中,我们总是会有三四个主机和每个主机内的多个容器。一般来说,我们在所有可用主机上分发我们的服务的方式是完全动态的,取决于业务能力,并且可以随时更改,因为主机只是服务器,它们不会永远持续下去。这就是服务发现和注册的作用。我们需要一个外部系统来解决常见 Web 服务器的限制,始终关注所有服务,并维护 IP 和端口的组合,以便客户端可以无缝地路由到这些服务提供者。

为了理解服务注册和发现的需求,我们将举一个经典的例子。假设我们有 10 个产品目录微服务的实例在任意数量的节点上运行。现在,为了拥有一个弹性系统,有人需要跟踪这 10 个节点,因为每当需要消费产品目录服务时,至少应该有一个正确的 IP 地址或主机名可用,否则消费者必须查询一个中央位置,找到产品目录服务的位置。这种方法非常类似于 DNS,不同之处在于这只是用于内部服务之间的通信。大多数基于微服务的架构都是动态变化的。服务根据开发、折旧和流量进行扩展和缩减。每当服务端点发生变化时,注册表都需要知道这个变化。服务注册表就是为了维护关于如何到达每个服务的所有信息。

市场上有很多可用的工具来解决这个问题,作为架构师,我们需要根据我们的需求来决定合适的工具。我们需要考虑诸如可以做多少自动化以及我们对工具有多少控制等因素。从低级工具如 Consul 到高级工具如 Kubernetes 或 Docker swarm,都可以满足高级需求,比如负载均衡容器和容器调度能力。

服务注册和发现是如何工作的?

今天,服务注册和发现有三种基本方法:

  • 首先,最基本和初步的方法是使用现有的 DNS 基础设施。一个良好部署的 DNS 将是高度可用和分布式的。这种方法的例子包括httpdconfdsystemd等。在这种方法中,标准的 DNS 库被用作注册客户端。每个微服务条目在 DNS 区域文件中接收一个条目,并进行 DNS 查找以连接或定位微服务。另一种方法是使用诸如 NGINX 之类的代理,它们定期轮询 DNS 以进行服务发现。这种方法的优点包括语言不可知性:它可以与任何语言一起工作,几乎不需要或零改变。然而,它也有一些缺点,比如 DNS 不能提供实时视图,管理服务注册和注销时的新区域文件,以及维护此组件的高可用性以实现弹性。

  • 第二种方法更加动态,更适合使用一致性键值数据存储的微服务,比如 Hashicorp 的 Consul、Apache Zookeeper、etcd 等。这些工具是高度分布式系统。通过键值存储和边车模式,解决了在使用 DNS 时遇到的所有问题。这种方法旨在对任何编写代码的开发人员完全透明。开发人员可以使用任何编程语言编写代码,而不必考虑微服务如何与其他服务交互。它也有一些限制,比如边车仅限于主机的服务发现,而不是更精细的路由。它还通过引入额外的跳跃为每个微服务增加了额外的延迟。

  • 服务发现的最终方法是采用诸如 Netflix Eureka 之类的现成框架,专门为服务发现而设计和优化。这种模型直接向最终开发人员公开功能。

无论选择哪种工具,每个微服务都需要一个中央客户端进行服务发现通信,其主要功能是允许服务注册和解析。每当一个服务启动时,服务发现就会使用注册过程向其他服务表明其可用性。一旦可用,其他服务就会使用服务解析来定位网络上的服务。涉及的两个过程如下。

服务注册

在启动和关闭时,服务会自行注册,或者通过第三方注册,服务注册客户端还会发送持续的心跳,以便客户端知道服务是活动的。心跳是定期发送给其他服务的消息,表明服务正在运行并且活动。它们应该是异步发送或者作为基于事件的实现,以避免性能问题。其他方法包括不断轮询服务。服务注册阶段还负责设置服务的契约,即服务名称、协议、版本等。

服务解析

这是返回微服务的网络地址的过程。理想的服务发现客户端具有缓存、故障转移和负载均衡等关键功能。为了避免网络延迟,缓存服务地址至关重要。缓存层订阅来自服务发现的更新,以确保它始终是最新的。典型的微服务实现层部署在各个位置以实现高可用性;服务解析客户端必须知道如何根据负载可用性和其他因素返回服务实例的地址。

服务注册和发现的内容

在这一部分,我们将看一下服务注册和发现的内容。我们将看到服务注册的所有方面,并查看关于维护它的所有可能选项。

维护服务注册表

在这一部分,我们将看到消费者最终如何找到服务提供者。我们将看到所有可用的方法,并查看每个选项的利弊:

  • 通过套接字进行更新:定期轮询很快就会成为一个问题,因为消费者最不关心向发现服务注册自己,对于发现服务来说,维护消费者列表也变得困难。更好的解决方案是客户端与发现服务建立套接字连接,并持续获取所有服务更改的最新列表。

  • 服务发现作为代理:这更多是一个服务器端的实现,路由逻辑存在于发现服务中,使得客户端不需要维护任何列表。他们只需向发现服务发出出站请求,发现服务将请求转发给适当的服务提供者,并将结果返回给提供者。

及时的健康检查

有两种方法可以进行及时的发现健康检查。一种方法是服务应该向集中式发现服务发送消息,而另一种方法是发现服务向服务提供者发送请求:

  • 服务轮询注册器:在这种方法中,服务提供者会定期向注册发现服务发送消息。发现服务会跟踪上次接收请求的时间,并且如果超过一定时间阈值,就会认为服务提供者已经失效。

  • 注册器轮询服务:这是一种中央发现服务向服务提供者发送请求的方法。然而,这种方法的一个缺点是,集中式发现服务可能会因为执行太多的出站请求而耗尽资源。此外,如果服务提供者消失,那么注册器就必须进行大量的失败健康查找,这将是网络资源的浪费。

服务发现模式

发现是客户端视角下的服务注册的对应物。每当客户端想要访问一个服务时,它必须找到关于服务的详细信息,它的位置以及其他合同信息。这通常使用两种方法来完成,即客户端发现和服务器端发现。服务发现可以简要总结如下:

  • 微服务或消费者对其他服务的物理位置没有任何先验知识。他们不知道服务何时下线或另一个服务节点何时上线。

  • 服务广播它们的存在和消失。

  • 服务能够基于其他广播的元数据找到其他服务实例。

  • 实例故障会被检测到,并且任何对于失败节点的请求都会被阻止并作废。

  • 服务发现不是单点故障。

在本节中,我们将研究服务发现的模式,并了解每种模式的优缺点。

客户端发现模式

在使用客户端模式时,客户端或网关的职责是确定可用服务实例的位置,并在它们之间进行负载均衡。客户端查询服务注册表,这只是一组可用的服务实例,存储其响应,然后根据响应中的位置地址路由请求。客户端使用一些著名的负载平衡算法来选择一个服务实例,并向该实例发出请求。每当服务启动时,该服务实例的物理网络位置会在注册表中注册,并在服务关闭时注销。服务实例的注册会使用心跳机制、轮询或通过实时更新的套接字来刷新。

优势:

  • 该模式除了服务注册表之外相当静态,因此更容易维护

  • 由于客户端知道服务实例,客户端可以做出智能的、特定于应用程序的、情境依赖的负载均衡决策,比如不断使用哈希

痛点:

  • 客户端与服务注册表紧密耦合

  • 需要在每种服务客户端使用的编程语言和框架中实现客户端服务发现

一个著名的客户端注册工具是 Netflix Eureka。它提供了一个用于管理服务实例注册和查询可用实例的 REST API。可以在github.com/Netflix/eureka/wiki/Eureka-REST-operations找到完整的 API 列表和可用选项,其中包含所有可用的操作。

服务器端发现模式

对此的反对意见是为注册表单独设置一个组件,这就是服务器端发现模式。在这种方法中,客户端通过负载均衡器向服务发出请求。负载均衡器然后查询服务注册表,并将每个请求路由到可用的服务实例,以向消费者提供服务响应。这种方法的一个典型例子是内置的 AWS 负载均衡器。Amazon 弹性负载均衡器ELB)通常用于处理来自互联网的大量外部流量,并在传入流量中进行负载均衡,但 ELB 的用途远不止于此。ELB 也可以用于将流量负载均衡到虚拟机的内部流量。当客户端通过其 DNS 向 ELB 发出请求时,ELB 会将流量在一组注册的 EC2 实例或容器之间进行负载均衡。

维护服务器端发现的方法之一是在每个主机上使用代理。这个代理扮演着服务器端发现负载均衡器的角色。代理透明地将请求转发到服务器上任何地方运行的可用服务实例。Kubernetes 采用了类似的方法。一些可用的工具是 NGINX 和 Consul 模板。这些工具配置了反向代理并重新加载 NGINX 或 HAProxy 服务器。

服务器端发现模式的优势:

  • 在服务器端的客户端发现中,代码更简单,因为我们不必为每个服务编写发现代码,而且它完全与客户端抽象无关

  • 通过这种方法来处理负载均衡等功能

服务器端发现模式的缺点:

  • 路由器是另一个需要在服务器上维护的组件。如果环境是集群的,那么它需要在每个地方进行复制。

  • 除非路由器是 TCP 路由器,否则路由器应该支持诸如 HTTP、RPC 等协议。

  • 与客户端发现相比,它需要更多的网络跳数。

让我们在这个图表中看看这两种方法:

客户端与服务器端服务发现

服务注册表模式

在分布式系统中发现服务的一个关键方面是服务注册表。服务注册表只是一个包含所有服务实例的网络位置的数据库。由于它包含关键信息,因此必须在高效的系统上保持高可用性并保持最新。根据系统客户端(在我们的情况下是 API 网关),我们甚至可以缓存从服务注册表获取的网络位置。然而,它必须每天更新,否则客户端将无法发现服务实例并按服务进行通信。为了保持高可用性,服务注册表由集群组成,其中使用复制协议来保持一致性。服务注册表保存微服务实例的元数据,其中包括实际位置、主机端口、通信协议等。微服务的启动和关闭过程会不断受到监控。在本节中,我们将看看服务注册表和常见的服务注册选项。我们将分析每种方法的优缺点。

所有服务实例必须在中央注册表中注册和注销,以建立一个容错系统。有各种方法来处理这个注册和注销的过程。一种选择是服务注册表提供端点,服务实例自行注册,即自注册。另一种选择是使用其他系统组件来管理服务实例的注册。让我们深入了解这两种模式的细节。

自注册模式

在使用自注册过程时,服务实例本身负责在服务注册表中注册和注销。此外,服务实例必须不断发送心跳请求,以便让注册表知道服务的状态。如果注册表没有收到心跳,它可以假定服务不再存在,并注销或停止监听该服务。自注册模式迫使微服务自己与服务注册表通信。每当服务启动或关闭时,它都必须与注册表通信,告知其状态。微服务处理单一关注点,因此在任何地方引入另一个关注点可能是额外的负担,可能看起来是一种反模式;但是,它的优势在于服务维护自己的状态模型,知道当前状态,即STARTINGAVAILABLESHUTDOWN,而不依赖于任何其他第三方服务。

自注册过程的一个著名例子是 Netflix OSS Eureka 客户端。Eureka 客户端处理客户端注册和注销的所有方面。我们将在后面的章节中看到 Eureka 的详细实现。

自注册模式的缺点:

  • 该服务与服务注册表耦合。它必须不断与服务器通信,告诉它有关服务状态的信息。

  • 服务注册逻辑不是集中的,必须在我们生态系统中的每种语言中实现。

第三方注册模式

在使用第三方注册过程和服务实例时,微服务遵循单一责任原则,不再负责向服务注册表注册自己。相反,我们在系统中引入一个新组件,服务注册器,它负责维护服务注册表。为了维护注册表,服务注册器通过轮询环境或订阅启动和关闭事件来跟踪实例。每当它注意到一个新的可用服务时,它就会将该实例注册到注册表中。同样,如果它无法收到健康检查,那么它就会从注册表中注销该服务。与自注册模式不同,微服务代码要简单得多,因为它不负责注册自己,但它也有缺点。如果注册器没有经过精心选择,它就会成为必须安装、配置、维护和高可用的另一个组件,因为它是系统的关键组件。在工业界,第三方注册通常是首选,因为它可以自动管理注册表。注册表所需的额外数据可以以策略或合同的形式提供,并可以在数据库中更新。Apache Zookeeper 或 Netflix Eureka 等工具通常与其他工具结合使用。

第三方注册有各种优势。比如,如果一个服务宕机,第三方注册器可以采取适当的措施,如提供安全回退,触发自我修复机制等。如果某个服务的流量很大,注册过程可以通过请求新的微服务实例自动添加新的端点。对服务执行的这些健康检查可以帮助自动注销,以阻止故障级联到整个系统。一个著名的例子是 Registrator,我们将在本章后面看到。

一些著名的第三方注册模式的例子包括:

  • Netflix Prana:由 Netflix 外包,Netflix OSS Prana 专门针对非 JVM 语言。它是边车模式的实现,与服务实例并行运行,并通过 HTTP 公开它们。Prana 使用 HTTP 与 Netflix Eureka 注册和注销服务实例。

  • 内置组件,如 ELB:大多数部署环境都有内置组件。通过自动扩展创建的 EC2 实例会自动注册到 ELB。同样,Kubernetes 服务会自动注册并可供发现(我们将在第十章的扩展部分中更详细地了解这一点,加固您的应用)。

第三方注册模式的优势如下:

  • 代码较少复杂,因为每个服务不必为自己的注册和注销编写代码

  • 中央注册器还包含执行健康检查的代码,这不需要在所有地方复制。

第三方注册模式的缺点如下:

  • 除非由服务发现工具提供,否则它是另一个需要维护并保持高可用性的组件

服务注册和发现选项

在本节中,我们将研究市场上一些常见的服务发现和注册选项。选项范围从提供高度控制的低级解决方案(如 CoreOS 的 etcd 和 HashiCorp 的 Consul)到提供容器调度解决方案的高端解决方案(如 Google 的 Kubernetes、Docker swarm 等)。在本节中,我们将了解各种选项,并查看每种选项的优缺点。

Eureka

Eureka 是由 Netflix 外包的服务注册和发现框架,需要主要用于定位服务以进行负载平衡和故障转移。在本节中,我们将使用 Eureka 进行服务发现和注册。

整体 Eureka 架构由两个组件组成:Eureka 服务器和客户端。Eureka 服务器是一个独立的服务器应用程序,负责:

  • 管理服务实例的注册表

  • 提供注册任何服务、注销任何微服务和查询实例作为服务发现的一部分的手段

  • 将实例的注册传播到其他 Eureka 服务器和客户端,提供类似心跳的机制来不断监视服务

Eureka 客户端是生态系统的一部分,具有以下责任:

  • 在启动、关闭等过程中向 Eureka 服务器注册和注销绑定的微服务

  • 通过不断发送心跳来保持与 Eureka 服务器的连接

  • 检索其他服务实例信息,缓存并每天更新

我们将经常在 Eureka 中使用以下术语:

Eureka 服务器 它是发现服务器。它通过注册和注销任何服务以及发现任何服务的 API 来拥有所有服务的注册表及其当前状态。
Eureka 服务 Eureka 服务注册表中发现的任何内容,以及为其他服务注册并且意图被发现的任何内容。每个服务都有一个逻辑标识符,可以引用该应用程序的实例 ID,称为 VIP 或服务 ID。
Eureka 实例 注册到 Eureka 服务器的任何应用程序,以便其他服务可以发现它。
Eureka 客户端 可以注册和发现任何微服务的任何微服务应用程序。

在本节中,我们将设置 Eureka 服务器注册一个示例微服务,并在其他微服务中找到该微服务的位置。所以,让我们开始吧。

设置 Eureka 服务器

Eureka 服务器是 Netflix OSS 产品,是一种服务发现模式的实现,其中每个微服务都注册,客户端在服务器上查找依赖的微服务。Eureka 服务器在 JVM 平台上运行,因此我们将直接使用可用模板。

要运行 Eureka 服务器,您需要安装 Java 8 和 Maven。

让我们来看看设置 Eureka 服务器的步骤:

  1. 转到本章节的提取源代码中的eureka文件夹。您将找到一个名为euraka-server的 Eureka 服务器的现成 Java 项目。

  2. 在根目录中,打开终端并运行以下命令:

mvn clean install
  1. 您应该看到依赖项正在安装,最后,您将收到一条确认成功构建的消息,并生成target文件夹。

  2. 打开target文件夹,您将能够看到 Eureka 服务器的.jar文件(demo-service-discovery-0.0.1-SNAPSHOT.jar)。

  3. 打开终端并输入以下命令。您应该会看到服务器启动:

java -jar demo-service-discovery-0.0.1-SNAPSHOT.jar

以下是上述命令的输出截图:

启动 Spring Eureka 服务器

  1. 访问http://localhost:9091/,您应该能够看到 Eureka 服务器已启动。您应该会看到类似于这样的内容:

Spring Eureka 服务器

现在我们已经启动了 Eureka 服务器,我们将向其注册我们的服务。在注册到 Eureka 服务器后,我们将能够在 Eureka 当前注册的实例下看到我们的服务。

向 Eureka 服务器注册

现在我们的 Eureka 服务器已经启动并准备好接受微服务的注册,我们将注册一个演示微服务并在 Eureka 仪表板上看到它。您可以跟随源文件中附带的源代码(first-microservice-register)。让我们开始吧:

  1. 从第二章中提取我们的第一个微服务代码,为旅程做准备。我们将在项目中使用eureka-js-client(www.npmjs.com/package/eureka-js-client)模块,这是 Netflix OSS Eureka 的 JavaScript 实现。

  2. 打开终端并安装eureka-js-client

npm i eureka-js-client --save
  1. 接下来,我们将安装eureka-js-client的类型,以在我们的 TypeScript 项目中使用。在撰写本文时,DefinitelyTyped存储库中的类型尚未更新。因此,我们现在将编写我们自定义的类型。

  2. 创建一个名为custom_types的文件夹,并在其中添加eureka-js-client.d.ts。可以从附加的源代码或我的 gist(gist.github.com/insanityrules/7461385aa561db5835c5c35279eb12bf)中复制内容

  3. 接下来,我们将使用 Eureka 注册我们的 Express 应用程序。打开Application.ts并在其中编写以下代码:

let client = new Eureka(
  {
    instance: {
      app: 'hello-world-chapter-6',
      hostName: 'localhost',
      ipAddr: '127.0.0.1',
      statusPageUrl: `http://localhost:${port}`,
      healthCheckUrl: `http://localhost:${port}/health`,
      port: {
        '$': port,
        '@enabled': true
      },
      vipAddress: 'myvip',
      dataCenterInfo: {
        '@class': 'com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo',
        'name': 'MyOwn',
      },
    }, eureka: {
      host: 'localhost',
      port: 9091,
      servicePath: '/eureka/apps/'
    }
  })

我们刚刚做了什么?请查看以下要点,以便更好地理解:

    • 我们使用名称为hello-world-chapter-6的应用实例注册了一个名为myvip的键和数据中心myOwn到 Eureka
  • 我们提供了statusPageURLIpAddress

  • 我们添加了 Eureka 信息,包括主机、端口和服务路径

  • 可以在此处找到完整的配置列表(www.npmjs.com/package/eureka-js-client)

  1. 接下来,我们将从客户端开始;只需添加以下内容:
client.start()
  1. 我们的注册已经准备就绪;现在我们可以使用npm start启动我们的服务。现在,转到localhost:9091检查服务器实例:

在 Eureka 服务器中注册的服务

  1. 我们的服务将不断获取服务注册表并发送心跳以告知服务正在运行。当我们的微服务被终止时,让我们停止并注销服务。只需将以下代码添加到Application.ts中:
process.on('SIGINT', function() {client.stop(); });

现在我们的服务已与 Eureka 同步,在下一节中我们将看到如何发现服务。

使用 Eureka 服务器进行发现

在本节中,我们将在另一个微服务中发现我们注册的服务。我们将从该服务获取响应,而不知道服务地址或在任何地方硬编码位置。从第二章中复制first-microservice的结构,为旅程做准备。由于我们将在各处需要 Eureka 客户端,我们将创建EurekaService.ts。您可以在项目的eureka/eureka-service-discovery/src文件夹中找到完整的源代码。

让我们来看看发现我们注册服务的步骤:

  1. 创建一个名为EurekaService.ts的文件,并创建用于初始化客户端的静态方法:
static getClient(): Eureka{
  if (!this._client) {
    this._client = new Eureka({
      instance: {}, //set instance specific parameters,
      Eureka: {} //set Eureka parameters
    })
  }
  1. Application.ts中,启动您的客户端并添加停止进程如下:
EurekaService.getClient().start();
…
process.on('SIGINT', () => {
  /*stop client*/
 EurekaService.getClient().stop();
  this.server.close()
 });
  1. 在我们的HelloWorld.ts中,我们将调用first-microservice-register中的服务并获取其响应。我们不会硬编码位置。在HelloWorld.ts中添加以下 LOCs:
let instances: any =
  EurekaService.getClient().getInstancesByAppId("HELLO-WORLD-CHAPTER-6");
let instance = null;
let msg = "404 Not Available";
if (instances != null && instances.length > 0) {
  instance = instances[0];
  let protocol = instances[0].securePort["@enabled"] == "true" ? "https" : "http";
  let url = protocol + "://" + instance.ipAddr + ":" + 
                       instances[0].port.$ + "/";
  const { res, payload } = await Wreck.get(url);
  msg = payload.toString();
} 

正如您所看到的,我们从我们的服务注册表中选择了协议、端口和 IP 地址。

  1. 运行您的应用程序,您将能够看到来自first-microservice-register的响应。

Eureka 的关键点

在进行 Eureka 服务注册和发现的练习之后,让我们来看看 Eureka 的一些要点:

  • Eureka 包括服务器组件和客户端组件。服务器组件是所有微服务通信的组件。它们通过不断发送心跳来注册其可用性。消费服务还使用服务器组件来发现服务。

  • 当使用我们的 Eureka 服务引导微服务时,它会联系 Eureka 服务器并广播其存在以及合同细节。注册后,服务端点每 30 秒发送心跳请求以更新其租约期。如果服务端点未能这样做一定次数,它将从服务注册表中移除。

  • 您可以通过设置以下选项之一来启用调试日志:

  • NODE_DEBUG=request

  • client.logger.level('debug');

  • 客户端不断地在每个预定义的点获取注册表并对其进行缓存。因此,当它想要发现另一个服务时,就可以防止额外的网络跳跃。

  • Eureka 客户端提供了可用服务的列表,并提供了按主机名或实例名提供它们的选项。

  • Eureka 服务器具有区域感知功能。在相同区域注册服务时可以提供区域信息。为了进一步引入负载均衡器,我们可以使用一个等同于 Netflix Ribbon 的弹性客户端(www.npmjs.com/package/resilient)。

  • 它具有健康检查、状态页面、注册、注销、最大重试次数等选项。

  • Eureka 是服务器端客户端注册和自注册选项的典型示例。

Consul

我们用于服务注册和发现的另一个选项是 HashiCorp Consul (www.consul.io/)。Consul 是分布式键值存储和其他服务发现和注册功能的开源实现。它可以作为主节点或代理运行。主节点编排整个网络并维护注册表。Consul 代理充当主节点的代理,并将所有请求转发到主节点。在本节中,我们将了解使用 Consul 进行服务发现和注册。

在这个练习中,我们将使用 Consul 进行服务注册和发现。我们将看看使用 Consul 进行自注册/注销的方法。让我们开始吧;在这个练习中,我们将使用 Linux 操作系统。

设置 Consul 服务器

让我们来看看设置 Consul 服务器的步骤:

  1. 设置 Consul 服务器非常简单。只需从www.consul.io/downloads.html下载可执行文件,并将其解压缩到您选择的位置。解压缩后,输入以下命令使其可用于二进制执行:
cp consul /usr/local/bin/
  1. 通过打开终端并输入consul -v来测试您的 Consul 安装;您应该能够看到版本 1.0.7。

  2. 现在,我们将打开 Consul UI 终端。Consul 默认带有一个 UI 仪表板;要启动带有 UI 仪表板的 Consul 终端,请输入以下命令:

consul agent -server -bootstrap-expect=1 -data-dir=consul-data -ui -bind=<Your_IPV4_Address>
  1. 打开localhost:8500;您应该能够看到类似于这样的东西:

Consul 服务器

我们已经成功启动了 Consul 服务器;接下来我们将在 Consul 中注册一些服务。

与 Consul 服务器交谈

与 Eureka 一样,Consul 也暴露了一些 REST 端点,可以用来与 Consul 服务器交互。在本节中,我们将看到如何:

  • 注册服务实例

  • 发送心跳并进行健康检查

  • 注销服务实例

  • 订阅更新

注册服务实例

让我们从第二章的第一个微服务开始克隆,为旅程做准备。您可以在chapter-6/consul/consul-producer文件夹中找到整个源代码:

  1. 打开终端并输入以下命令:
npm install consul  @types/consul --save
  1. 现在在Application.ts中,我们将初始化我们的 Consul 客户端。写下这段代码:
import * as Consul from 'consul';
import { ConsulOptions } from 'consul';
…
let consulOptions: ConsulOptions =
  { host: '127.0.0.1', port: '8500', secure: false, promisify: false }
….
let details =
  {
    name: 'typescript-microservices-consul-producer',
    address: HOST,
    check: { ttl: '10s', deregister_critical_service_after: '1m' },
    port: appPort, id: CONSUL_ID
  };
let consul = new Consul(consulOptions);
  1. 接下来,我们将在 Consul 中注册我们的服务:
consul.agent.service.register(
  details, err => {
    if (err) {
      throw new Error(err.toString());
    }
    console.log('registered with Consul');
  }
  1. 运行程序,您应该能够看到成功的日志。您将能够看到类似以下的输出:

与 Consul 和 Consul 仪表板的服务注册

发送心跳并进行健康检查

现在,我们将添加一个调度程序,不断发送心跳以告诉我们的 Consul 服务器它是活动的。在与上一个练习相同的代码中,只需添加以下代码行:

  setInterval(() => {
   consul.agent.check.pass({id:`service:${CONSUL_ID}`}, 
   (err:any) => {
        if (err) throw new Error(err); 
        console.log('Send out heartbeat to consul');
        });
   }, 5 * 1000);

我们做了什么?

  • 每五秒钟,我们向 Consul 发送心跳,以确保我们生成的CONSUL_ID的服务是活动的。

  • 定期心跳被发送出去,以确保 Consul 知道我们的服务是活动的,并且不会断开我们的服务。早些时候,我们在设置中保留了 TTL 值为 10 秒,这意味着如果 Consul 服务器在 10 秒后没有收到心跳,它将假定服务已经停止。

  • 较高的 TTL 值意味着 Consul 将在应用程序死亡或无法提供请求时知道得很晚。另一方面,较短的 TTL 值意味着我们在网络上传输了太多数据,这可能会淹没 Consul,因此这个值应该谨慎选择。

  • 您总是需要传递一个唯一的 ID,所以在这个练习中,我们生成了 UUID,并将主机和端口混合在一起。

  • 健康检查 API 可通过 HTTP 获得。我们所要做的就是输入以下内容:

GET /agent/check/pass/service:<service_id>

注销应用程序

在本节中,我们将在服务器终止或有人杀死服务器时注销我们的应用程序。这确保 Consul 不必等到 TTL 期限才真正知道服务已经停止。只需在Application.ts中添加以下代码行:

   process.on('SIGINT', () => {
  console.log('Process Terminating. De-Registering...');
  let details = { id: CONSUL_ID };
  consul.agent.service.deregister(details,
    (err) => {
      console.log('de-registered.', err);
      process.exit();
    });

现在,当您在优雅地终止应用程序时检查 Consul 服务器时,您将无法看到我们的 Consul 生产者已注册。

订阅更新

就像 Eureka 一样,我们将不断获取 Consul 注册表,所以每当我们需要与另一个注册表通信时,我们就不需要进行另一个注册表调用,因为注册表已经在我们这边缓存了。Consul 通过提供一个名为watch的功能来处理这个问题。对服务的响应将有一个索引号,可以用于将来的请求进行比较。它们只是一个用来跟踪我们离开的位置的光标。让我们向我们的应用程序添加观察者:

  1. 通过添加以下代码来创建一个新的观察者。在这里,我们在 Consul 中的名为data的服务上创建了一个观察者:
let watcher = consul.watch({
  method: consul.health.service,
  options: {
    service: 'data',
    passing: true
  }
});
  1. 接下来,我们将在我们的观察者上添加一个更改事件,所以每当它接收到新的更新时,我们将只是缓存我们的服务数据的注册表。创建一个数组,并在观察时持久化它接收到的条目:
let known_data_instances: string[];
..
watcher.on('change', (data, res) => {
  console.log('received discovery update:', data.length);
  known_data_instances = [];
  data.forEach((entry: any) => {
    known_data_instances.push(`http://${entry.Service.Address}:
    ${entry.Service.Port}/`);
  });
  console.log(known_data_instances);
});
  1. 添加一个错误处理程序:
watcher.on('error', err => {
  console.error('watch error', err);
});
  1. 就是这样。现在,使用npm start运行程序,并使用名称data注册另一个服务(注册新服务的步骤与注册新服务相同)。然后,您应该能够看到以下输出:
received discovery update: 1
 [ 'http://parth-VirtualBox:8081/' ]

就是这样。我们刚刚进行了服务注册,并与 Eureka 服务器进行了交互。每当数据服务关闭时,此值也将动态更新。现在我们有了动态地址和端口,我们随时可以使用它来发现服务的位置。

Consul 的关键点

完成了对 Consul 的练习后,现在让我们总结一下 Consul 的关键点:

  1. Consul 使用 gossip 协议(告诉每个活着并与其他人保持不断联系的人)来形成动态集群。

  2. 它具有内置的键值存储,不仅存储数据,还用于注册观察,可用于许多任务,如通知其他人有关数据更改、运行不同的健康检查以及根据用例运行一些自定义命令。

  3. 服务发现是内置的,因此我们不需要任何第三方工具。它具有内置功能,如健康检查、观察等。

它具有对多个数据中心的开箱即用支持,gossip 协议也适用于所有数据中心。它还可以用于发现有关其他部署服务和它们所在节点的信息。它具有内置的健康检查、TTL 和自定义命令支持,我们可以在其中添加自己的中间件函数。

Registrator

虽然 Consul 似乎是服务发现和注册的一个很好的选择,但存在一个相当大的缺点,即每个服务都需要维护它们的启动和关闭代码,这似乎在各处都有相当多的重复代码。我们需要一个工具,根据监听它们的启动和关闭事件,自动将服务注册到 Consul 服务器。Registrator 正是这样的工具。它是一个用于 Docker 的服务注册桥接器,具有根据需要插入适配器的选项。当服务上线或下线时,Registrator 会自动注册和注销服务。它具有可插拔的服务注册选项,这意味着它可以与各种其他服务注册客户端一起使用,如 Consul、etcd 等。

让我们开始使用 Registrator。在这个练习中,我们将使用 Consul 的服务注册表,将其插入 Registrator,然后启动一个服务,让 Registrator 在 Consul 服务器中自动注册它:

  1. 首先,使用以下命令启动 Consul 服务器:
consul agent -server -bootstrap-expect=1 -data-dir=consul-data -ui -bind=<Your_IPV4_Address>
  1. 现在,我们将拉取 Registrator 的 Docker 镜像,并指定将其插入到 Consul 注册表中,这样当 Registrator 发现任何服务时,它们将自动添加到 Consul 服务器。打开终端并输入以下命令:
sudo docker run -d 
 --name=registrator
 --net=host 
 --volume=/var/run/docker.sock:/tmp/docker.sock 
 gliderlabs/registrator:latest 
 consul://localhost:8500

我们以分离模式运行容器并对其命名。我们以主机网络模式运行,以确保 Registrator 具有实际主机的主机名和 IP 地址。最后一行是我们的注册 URI。Registrator 需要在每个主机上运行;对于我们的练习,我们选择了单个主机。要启动 Registrator,我们需要提供的基本配置是如何连接到注册表,在这种情况下是 Consul。

  1. 为了确保 Registrator 已成功启动,请输入以下命令,您应该能够看到日志流和消息Listening for Docker events ...
sudo  docker logs registrator
  1. 现在,我们只需使用 Docker 启动任何服务,我们的服务将自动注册到 Consul。打开终端,只需使用以下命令在 Docker 中启动我们的服务:
sudo docker run -p 8080:3000 -d firsttypescriptms:latest

或者您可以只是启动任何服务,比如redis,只需输入以下命令:

sudo docker run -d -P --name=redis redis 
  1. 打开 Consul 用户界面,您将能够在那里看到我们的服务已注册。

在这里,我们使用 Registrator 和 Consul 有效地实现了自动发现。它可以作为自动发现。

Registrator 的关键点

让我们讨论 Registrator 的关键要点:

  1. Registrator 充当自动发现代理,它监听 Docker 的启动和关闭事件。

  2. Registrator 具有以下内置选项,取自他们的 GitHubReadme文件:

Usage of /bin/registrator:
   /bin/registrator [options] <registry URI>
   -cleanup=false: Remove dangling services
   -deregister="always": Deregister exited services "always" or "on- 
    success"
   -internal=false: Use internal ports instead of published ones
   -ip="": IP for ports mapped to the host
   -resync=0: Frequency with which services are resynchronized
   -retry-attempts=0: Max retry attempts to establish a connection  
    with the backend. Use -1 for infinite retries
   -retry-interval=2000: Interval (in millisecond) between retry-
    attempts.
   -tags="": Append tags for all registered services
   -ttl=0: TTL for services (default is no expiry)
   -ttl-refresh=0: Frequency with which service TTLs are refreshed
  1. 使用 Consul 与 Registrator 可以为我们的服务发现和注册提供非常可行的解决方案,而无需在各处重复编写代码。

这些是目前广泛使用的一些解决方案。除此之外,还有其他解决方案,比如 ELB、Kubernetes 等。

在本节中,我们看到了使用 Eureka、Consul 和 Registrator 进行服务注册表和发现,并根据我们的服务发现和注册表模式看到了一些其他选项。在下一节中,我们将了解如何选择正确的服务注册表和发现解决方案。

如何选择服务注册表和发现

之前,我们根据服务注册表和发现模式看到了各种服务注册表和发现选项。因此,接下来显而易见的问题是,选择哪种解决方案?这个问题非常广泛,实际上取决于需求。您的需求很可能与大多数其他公司的需求不同,因此与其选择最常见的解决方案,不如根据您的需求进行评估,并基于此制定自己的策略。为了制定策略,应该适当评估以下问题:

  • 系统是否只使用一种语言编码,还是使用多语言环境?在不同语言中编写相同的代码非常麻烦。在这种情况下,Registrator 非常有帮助。

  • 是否涉及旧系统?这两个系统是否会运行一段时间?在这种情况下,自注册解决方案可能非常有帮助。

  • 服务发现过程有多简化?是否会有网关?是否会有负载均衡器在中间?

  • 是否需要为服务发现提供 API?个别微服务是否需要与其他微服务通信?在这种情况下,基于 HTTP 或 DNS 的解决方案非常有帮助。

  • 服务发现解决方案是否嵌入到每个微服务中,还是需要将逻辑集中嵌入?

  • 我们是否需要单独的应用程序配置,还是可以将其存储在诸如 Redis 或 MongoDB 之类的键值存储中?

  • 部署策略是什么?是否需要像蓝绿策略这样的部署策略?应根据适当的服务发现选择解决方案。

蓝绿是一种部署策略,通过运行两个名为蓝色和绿色的相同生产环境来减少停机时间。

  • 系统将如何运行?是否会有多个数据中心?如果是这样,那么运行 Eureka 是最合适的。

  • 如何维护您的确认?访问控制列表如何维护?如果是这样,那么 Consul 有内置解决方案。

  • 有多少支持?它是否开源并且有广泛的支持?有太多问题吗?

  • 如何决定自动扩展解决方案?

根据这些问题,并经过适当评估后,我们可以决定适当的解决方案。在仔细评估这些之后,我们可以选择任何解决方案。以下是在选择任何解决方案时需要注意的一些要点。

在选择 Consul 或 Eureka 时,请注意这些要点。

如果选择 Consul

虽然 Consul 有很多好处,但在选择 Consul 时需要注意以下几点:

  • 客户端需要编写自己的负载均衡、超时和重试逻辑。为了避免编写完整的逻辑,我们可以利用以下node模块:www.npmjs.com/package/resilient

  • 客户端需要单独实现获取逻辑、缓存和 Consul 故障处理,除非我们使用了 Registrator。这些需要分别为生态系统中的每种语言编写。

  • 无法为服务器设置优先级;需要编写自定义逻辑。

如果选择 Eureka

虽然 Eureka 有许多附加优势,但在选择 Eureka 时需要注意以下几点:

  • 客户端必须添加自己的负载均衡、超时和重试逻辑,因此我们需要将其与 Netflix Ribbon 等外部工具集成。

  • 文档非常贫乏。如果您使用非 JVM 环境,将无法使用 Eureka。Eureka 服务器需要在 JVM 平台上运行。对于非 JVM 客户端,文档非常模糊。

  • Web UI 非常单调且缺乏信息。

在本节中,我们了解了在选择 Eureka 或 Consul 时的主要要点。我们总结了一些重要观点,以帮助我们实际决定服务注册和发现解决方案。

摘要

在本章中,我们了解了服务注册和发现。我们深入了解了服务发现的时间、内容和原因,并了解了服务注册和发现模式。我们看到了每种模式的优缺点以及它们的可用选项。然后,我们使用 Eureka、Consul 和服务注册器实现了服务发现和注册。最后,我们看到了如何选择服务发现和注册解决方案,以及在选择 Eureka 或 Consul 时的关键要点。

在下一章中,我们将看到服务状态以及微服务之间的通信。我们将学习更多的设计模式,如基于事件的通信和发布-订阅模式,看到服务总线的运作,共享数据库依赖等等。我们将通过一些实际示例了解有状态和无状态的服务。

第七章:服务状态和服务间通信

现在我们已经开发了一些微服务,看到了 API 网关,并了解了服务注册表和发现,现在是时候深入了解微服务,并从单个微服务的角度了解系统。为了从微服务架构中获得最大的好处,系统中的每个组件都必须以恰当的方式进行协作,这种方式可以确保微服务之间几乎没有耦合,这将使我们能够灵活应对。

在本章中,我们将了解微服务之间的各种通信方式,以及服务之间如何交换数据。然后我们将转向服务总线,这是系统组件之间如何通信的企业方式。许多服务需要以一种形式或另一种形式持久化一些状态。我们将看到如何使我们的服务无状态。我们将了解当前的数据库格局并理解服务状态。我们将了解发布-订阅模式,并了解诸如 Kafka 和 RabbitMQ 之类的工具,以了解事件驱动架构。本章涵盖以下主题:

  • 核心概念-状态、通信和依赖关系

  • 通信方式

  • 同步与异步的数据共享方式

  • 微服务版本控制和故障处理

  • 服务总线

  • 微服务之间的数据共享

  • 通过 Redis 进行缓存

  • 发布-订阅模式

核心概念-状态、通信和依赖关系

每个微服务实现一个单一的能力,比如发货和从库存中扣除。然而,为了向最终用户交付一个服务请求,比如业务能力、用户需求或用户特定请求;可能是一组业务能力,也可能不是。例如,从用户的角度来看,想要购买产品的人是一个单一的服务请求。然而,这里涉及到多个请求,比如加入购物车微服务、支付微服务、发货微服务。因此,为了交付,微服务需要相互协作。在本节中,我们将看到微服务协作的核心概念,如服务状态、通信方式等。选择正确的通信方式有助于设计一个松散耦合的架构,确保每个微服务都有清晰的边界,并且它保持在其有界上下文内。在本节中,我们将看一些核心概念,这些概念将影响我们的微服务设计和架构。所以,让我们开始吧。

微服务状态

虽然我们确实应该努力使服务尽可能无状态,但有些情况下我们确实需要有状态的服务。状态只是在任何特定时间点的任何条件或质量。有状态的服务是依赖于系统状态的服务。有状态意味着依赖于这些时间点,而无状态意味着独立于任何状态。

在需要调用一些 REST 服务的工作流中,有状态的服务是必不可少的,我们需要在失败时支持重试,需要跟踪进度,存储中间结果等。我们需要在我们的服务实例边界之外的某个地方保持状态。这就是数据库出现的地方。

数据库是一个重要且有趣的思考部分。在微服务中引入数据库应该以这样一种方式进行,即其他团队不能直接与我们的数据库交谈。事实上,他们甚至不应该知道我们的数据库类型。当前的数据库格局对我们来说有各种可用的选项,包括 SQL 和 NoSQL 类别。甚至还有图数据库、内存数据库以及具有高读写能力的数据库。

我们的微服务可以既有无状态的微服务,也有有状态的微服务。如果一个服务依赖于状态,它应该被分离到一个专用容器中,这个容器易于访问,不与任何人共享。无状态的微服务具有更好的扩展性。我们扩展容器而不是扩展虚拟机。因此,每个状态存储应该在一个容器中,可以随时进行扩展。我们使用 Docker 来扩展数据库存储,它创建一个独立的持久化层,与主机无关。新的云数据存储,如 Redis、Cassandra 和 DynamoDB,最大程度地提高了可用性,同时最小化了一致性的延迟。设计具有异步和可扩展性特性的有状态微服务需要在问题上进行一些思考——找到一些通信状态的方法,以确保任何连续消息之间的通信状态,并确保消息不会混淆到任何不属于它们的上下文中。在本章中,我们将看到各种同步模式,如 CQRS 和 Saga,以实现这一点。

维护状态不仅仅是在服务层面上可以完成的事情。实际上,在网络中有三个地方可以维护状态:

  • HTTP:这实际上是应用层,大部分维护状态都是基于会话或持久化在数据库中。一般来说,通过在客户端和应用程序或服务之间维护通信层来维护状态。

  • TCP:这实际上是传输层。在这里维护状态的目的是确保客户端和应用程序或服务之间有一个可靠的传递通道。

  • SSL:这是在 TCP 和 HTTP 层之间没有家的层。它提供数据的机密性和隐私性。在这里维护状态,因为加密和解密完全依赖于客户端和应用程序或服务之间的连接的唯一信息。

因此,即使我们的服务是无状态的,TCP 和 SSL 层也需要维护状态。所以你永远不是纯粹的无状态。无论如何,我们将仅限于本书的范围内的应用层。

服务间通信

微服务因为粒度细和与范围紧密相关,需要以某种方式相互协作,以向最终用户提供功能。它们需要共享状态或依赖关系,或者与其他服务进行通信。让我们看一个实际的例子。考虑频繁购买者奖励计划微服务。这个微服务负责频繁购买者业务能力的奖励。该计划很简单——每当客户购买东西时,他们的账户中就会积累一些积分。现在,当客户购买东西时,他可以使用这些奖励积分来获得销售价格的折扣。奖励微服务依赖于客户购买微服务和其他业务能力。其他业务能力依赖于奖励计划。如下图所示,微服务需要与其他微服务协作:

微服务的需求

如前图所示,微服务被细分为业务能力。然而,最终用户功能需要多个业务能力,因此微服务必须需要相互协作,以向最终用户提供用例。当任何微服务协作时,协作方式主要分为三类——命令查询事件。让我们通过一些例子来了解这三类。

命令

命令在任何微服务想要另一个微服务执行操作时使用。它们是同步的,通常使用 HTTP POST 或 PUT 请求来实现。例如,在前面的图中,奖励计划微服务向用户配置文件微服务或发票微服务发送命令,关于基于奖励的促销优惠。当发送命令失败时,发送者将不知道接收者是否处理了命令。如果发送方和接收方没有遵循一组规则,这可能导致错误或一些功能降级。

查询

与命令类似,查询在一个微服务需要从另一个微服务获取一些信息时使用。例如,在我们的购物车微服务中的发票过程中,我们需要有关奖励积分总数的信息,以便提供促销折扣,因此发票微服务查询奖励积分微服务。这是一种同步的通信模式,通常使用 HTTP GET 请求。每当查询失败时,调用者将无法获取所需的数据。如果调用者能够很好地处理异常,那么影响将很小,但功能可能会有所降级。如果处理错误不够好,错误将在整个系统中传播。

事件

在偏离标准方法的同时,第三种方法更多地是一种反应性方法。事件通常在一个微服务需要对另一个微服务中发生的事情做出反应时使用。自定义日志微服务监听所有其他服务的日志条目,以便它可以将日志推送到 Elasticsearch。类似地,奖励微服务监听购物追踪微服务,以便根据用户购物相应地更新用户奖励。当订阅者轮询任何事件源,如果调用失败,影响非常有限。订阅者仍然可以稍后轮询事件源,直到事件源恢复,并随时开始接收事件。一些事件将被延迟,但这不应该是一个问题,因为一切都是异步完成。

交换数据格式

微服务之间通信的本质或基本是以任何格式交换消息。消息通常包含数据,因此数据格式是非常重要的设计方面。这可以极大地影响通信的效率、可用性和变化,以及随时间演变的服务。选择跨消息格式非常必要。有两种消息格式——文本和二进制。在本节中,我们将看看两者。

基于文本的消息格式

常用的消息格式,如 JSON 和 XML,是人类可读的并且是自描述的。这些格式使用户能够挑选出消费者感兴趣的值并丢弃其余部分。对模式格式的任何更改都可以很容易地向后兼容。使用基于文本的格式的缺点包括其本质上过于冗长以及解析整个文本的开销。为了更高效,建议使用二进制格式。

二进制消息格式

这些格式为消息定义了一个结构的类型标识语言。然后编译器为我们生成序列化和反序列化消息的代码(我们将在本章后面看到 Apache Thrift)。如果客户端使用的是静态类型的语言,那么编译器会检查 API 是否被正确使用。Avro、Thrift 和 Google 的 protobuf 是著名的二进制消息格式。

现在我们对通信要点有了清晰的了解,我们可以继续下一节的依赖性。在继续之前,让我们总结一下要点。

如果满足以下用例,可以选择使用命令和查询:

  • 为了处理服务请求,服务客户端需要响应以进一步推进其流程。例如,对于支付微服务,我们需要客户信息。

  • 情况需要异步操作。例如,只有在付款已经完成并且产品已经处理好准备交付给客户时,才应扣减库存。

  • 对其他服务的请求是一个简单的查询或命令,即可以通过 HTTP GETPUTPOSTDELETE方法处理的内容。

如果满足以下用例,可以选择使用事件:

  • 当您需要扩展应用程序时,纯命令和查询无法扩展到更大的问题集。

  • 生产者或发送方不关心接收方或消费者端进行了多少额外的处理,这对生产者端没有影响。

  • 当多个客户端读取单个消息时。例如,订单已开具发票,然后需要执行多个流程,如准备发货、更新库存、发送客户通知等。

依赖关系

现在我们已经意识到微服务中的通信风格,我们将学习开发中的下一个显而易见的事情——依赖关系和避免依赖地狱。随着越来越多的微服务的开发,你会发现多个微服务之间存在代码重复。为了解决这些问题,我们需要理解依赖关系以及如何分离支持代码。Node.js 拥有包管理器 NPM,可以获取应用程序的依赖项(以及依赖项的依赖项)。NPM 支持私有存储库,可以直接从 GitHub 下载,设置自己的存储库(如 JFrog、Artifactory),这不仅有助于避免代码重复,还有助于部署过程。

然而,我们不应忘记微服务 101。我们创建微服务是为了确保每个服务都可以独立发布和部署,因此我们必须避免依赖地狱。要理解依赖地狱,让我们考虑以下示例,购物车微服务具有列出产品的 API,现在已升级为具有特定品牌产品列表的 API。现在购物车微服务的所有依赖关系可能会发送消息到最初用于列出产品的特定品牌产品列表。如果不处理向后兼容性,那么这将演变成依赖地狱。为了避免依赖地狱,可以使用的策略包括——API 必须是前向和后向兼容的,它们必须有准确的文档,必须进行合同测试(我们将在第八章中看到,测试、调试和文档,在PACT下),并使用一个具有明确目标的适当工具库,如果遇到未知字段,则会抛出错误。为了确保我们要避免依赖地狱,我们必须简单地遵循这些规则:

  • 微服务不能调用另一个微服务,也不能直接访问其数据源

  • 微服务只能通过基于事件的机制或某些微服务脚本(脚本可以是任何东西,如 API 网关、UI、服务注册表等)调用另一个微服务

在下一节中,我们将研究微服务通信风格,看看它们如何相互协作。我们将研究基于不同分类因素的广泛使用的模式,并了解在什么时候使用哪种模式的场景。

通信风格

微服务生态系统本质上是在多台机器上运行的分布式系统。每个服务实例只是另一个进程。我们在之前的图表中看到了不同的进程通信。在本节中,我们将更详细地了解通信风格。

服务消费者和服务响应者可以通过许多不同类型的通信风格进行通信,每种通信风格都针对某些场景和预期结果。通信类型可以分为两个不同的方面。

第一个方面涉及协议类型,即同步或异步:

  • 通过命令和查询(如 HTTP)调用的通信是同步的。客户端发送请求等待服务端响应。这种等待是与语言相关的,即可以是同步的(例如 Java 等语言),也可以是异步的(响应可以通过回调、承诺等方式处理,在我们的例子中是 Node.js)。重要的是,只有客户端收到正确的 HTTP 服务器响应后,服务请求才能得到服务。

  • 其他协议,如 AMQP、sockets 等,都是异步的(日志和购物跟踪微服务)。客户端代码或消息发送者不会等待响应,只需将消息发送到任何队列或消息代理即可。

第二个方面涉及接收者的数量,无论是只有一个接收者还是有多个接收者:

  • 对于单个接收者,每个请求只能由一个接收者或服务处理。命令和查询模式就是这种通信的例子。一对一的交互包括请求/响应模型,单向请求(如通知)以及请求/异步响应。

  • 对于多个接收者,每个请求可以由零个或多个服务或接收者处理。这是一种异步的通信模式,以发布-订阅机制为例,促进事件驱动架构。多个微服务之间的数据更新通过通过某些服务总线(Azure 服务总线)或任何消息代理(AMQP、Kafka、RabbitMQ 等)实现的事件进行传播。

下一代通信风格

虽然我们看到了一些常见的通信风格,但世界在不断变化。随着各处的进化,甚至基本的 HTTP 协议也发生了变化,现在我们有了 HTTP 2.X 协议,带来了一些额外的优势。在本节中,我们将看看下一代通信风格,并了解它们所提供的优势。

HTTP/2

HTTP/2 提供了显著的增强,并更加关注改进 TCP 连接的使用。与 HTTP/1.1 相比,以下是一些主要的增强:

  • 压缩和二进制帧:HTTP/2 内置了头部压缩,以减少 HTTP 头部的占用空间(例如,cookies 可能增长到几千字节)。它还控制了在多个请求和响应中重复的头部。此外,客户端和服务器维护一个频繁可见字段的列表,以及它们的压缩值,因此当这些字段重复时,个体只需包含对压缩值的引用。除此之外,HTTP/2 使用二进制编码进行帧。

  • 多路复用:与单一请求和响应流(客户端必须在发出下一个请求之前等待响应)相比,HTTP/2 通过实现流(哇,响应式编程!)引入了完全异步的请求多路复用。客户端和服务器都可以在单个 TCP 连接上启动多个请求。例如,当客户端请求网页时,服务器可以启动一个单独的流来传输该网页所需的图像和视频。

  • 流量控制:随着多路复用的引入,需要有流量控制来避免在任何流中出现破坏性行为。HTTP/2 为客户端和服务器提供了适用于任何特定情况的适当流量控制的构建模块。流量控制可以让浏览器只获取特定资源的一部分,通过将窗口减小到零来暂停该操作,并在任何时间点恢复。此外,还可以设置优先级。

在本节中,我们将看看如何在我们的微服务系统中实现 HTTP/2。您可以查看第七章下的示例 http2,并跟随实现的源代码。

  1. Node.js 10.XX 支持 HTTP/2,但也有其他方法可以实现支持,而无需升级到最新版本,该版本是在写作时刚刚推出的(Node.js 10.XX 在写作时刚刚推出了两周)。我们将使用spdy节点模块,为我们的Express应用程序提供 HTTP/2 支持。从第二章中复制我们的first-microservice骨架,为旅程做准备,并使用以下命令将spdy安装为节点模块:
npm install spdy --save
  1. 为了使 HTTP/2 正常工作,必须启用 SSL/TLS。为了使我们的开发环境正常工作,我们将自动生成 CSR 和证书,这些证书可以在生产环境中轻松替换。要生成证书,请按照以下命令进行操作:
// this command generates server pass key.
openssl genrsa -des3 -passout pass:x -out server.pass.key 2048
//we write our RSA key and let it generate a password
openssl rsa -passin pass:x -in server.pass.key -out server.key
rm server.pass.key //this command removes the pass key, as we are just on dev env
//following commands generates the csr file
openssl req -new -key server.key -out server.csr
//following command generates server.crt file
openssl x509 -req -sha256 -days 365 -in server.csr -signkey server.key -out server.crt

所有这些步骤的结果将产生三个文件:server.crtserver.csrserver.key

  1. 接下来,我们需要更改启动 express 服务器的方式。我们需要使用spdy提供的方法,而不是使用默认方法。在Application.ts中进行以下更改。用以下代码替换this.express.app.listen
import * as spdy from 'spdy';
 const certsPath = path.resolve('certs');
 const options={         
     key:fs.readFileSync(certsPath+"/server.key"),
     cert:fs.readFileSync(certsPath+"/server.crt")
 }...
this.server=spdy.createServer(options,this.express.app)
                  .listen(port,(error:any)=>{                       
                  if(error){
                      logger.error("failed to start 
                      server with ssl",error);
                      return process.exit(1);}else{
                      logger.info(`Server Started! Express:                 
                      http://localhost:${port}`); }})
  1. 我们已经准备好开始处理 HTTP/2 请求了。启动服务器并打开https://localhost:3000/hello-world。打开开发者控制台,您应该能够看到 HTTP/2,就像以下截图中一样:

HTTP 支持

这些是 HTTP 调用。在下一节中,我们将看一下 RPC 机制,这是微服务之间协作的另一种方式。

使用 Apache Thrift 的 gRPC

gRPC是一个专为编写跨语言 RPC(远程过程调用)客户端和服务器而设计的框架。它使用二进制格式,并专注于以 API 优先的方式设计任何服务。它提供固定的 IDL(交互式数据语言固定格式),以后可以生成符合该固定 IDL 格式的客户端存根和服务器端骨架。编译器可以为大多数语言生成代码,并且它们使用 HTTP/2 进行数据交换,这在长期内是有益的。Apache Thrift 是编写跨语言 RPC 客户端和服务器的一个很好的替代方案。它具有 C 风格的 IDL 定义语言。编译器可以为各种语言生成代码,包括 C++、Java,甚至 TypeScript。Thrift 定义与 TypeScript 接口非常类似。Thrift 方法可以输出任何值,或者它们可以只是单向通信。具有返回类型的方法实现请求/响应模型,而没有返回类型的方法被定义为实现通知模型。Thrift 还支持 JSON 和二进制。让我们从一个示例开始。您可以在提取的源代码中的第七章中的thrift-rpc文件夹中跟随。

我们要做的整个过程如下:

  • 编写一个.thrift文件,描述我们的产品微服务和受欢迎度微服务

  • 为我们将要编写的服务通信生成 TypeScript 的源代码

  • 导入生成的代码并开始编写我们的服务

  • 将受欢迎度的生成源包含在产品中并编写我们的服务

  • 创建 API 网关作为单一入口点

尽管 Thrift 提供了 Node.js 和 TypeScript 库,但我们将使用CreditKarmagithub.com/creditkarma)的npm模块,因为原始模块在生成严格类型方面存在不足。所以让我们开始吧。

现在,让我们执行以下步骤:

  1. 初始化一个 Node.js 项目。我将使用npm模块而不是下载 Thrift。因此,将以下模块安装为依赖项:
npm install  @creditkarma/dynamic-config  @creditkarma/thrift-client @creditkarma/thrift-server-core @creditkarma/thrift-server-express @creditkarma/thrift-typescript --save
  1. 创建一个名为thrift的文件夹,在其中创建两个 Thrift 文件——PopularityService.thriftthrift/popularity/PopularityService.thrift)和ProductService.thriftthrift/product/ProductService.thrift)。Thrift 文件就像 TypeScript 接口:
namespace js com.popularity
struct Popularity {
    1: required i32 id
    2: required i32 totalStars
    3: required string review
    4: required i32 productId}
exception PopularityServiceException {
    1: required string message}
service PopularityService {
    Popularity getPopularityByProduct(4: i32 productId) 
    throws (1: PopularityServiceException exp)}

由于我们需要在产品中使用流行度,我们将在ProductService.thrift中导入它,您可以在此处检查其他默认语法thrift.apache.org/docs/idl

  1. 现在,我们将使用在前一步中定义的 IDL 文件生成我们的代码。打开package.json并在scripts标签内添加以下脚本:
"precodegen": "rimraf src/codegen",
"codegen": "npm run precodegen && thrift-typescript --target thrift-server --sourceDir thrift --outDir src/codegen"

这个脚本将为我们生成代码,我们只需要输入npm run codegen

  1. 下一部分涉及编写findByProductIdfindPopularityOfProduct方法。在提取的源代码中查看src/popularity/data.tssrc/product/data.ts以获取虚拟数据和虚拟查找方法。

  2. 我们现在将编写代码来启动PopluarityThriftServiceProductThriftService。在src/popularity/server.ts内创建一个serviceHandler如下:

const serviceHandler: PopularityService.IHandler<express.Request> = {
    getPopularityByProduct(id: number, context?:  
    express.Request): Popularity {
        //find method which uses generated models and types.
},
  1. 通过将ThriftServerExpress添加为中间件,将此server.ts作为express启动:
app.use(serverConfig.path,bodyParser.raw(),
ThriftServerExpress({
    serviceName: 'popularity-service',
    handler: new PopularityService.Processor(serviceHandler),
}), ) 
app.listen(serverConfig.port, () => {//server startup code)})
  1. 现在,在src/product/server.ts内,添加以下代码,将对PopularityService进行 RPC 调用以获取productId的流行度:
const popularityClientV1: PopularityService.Client = createHttpClient(PopularityService.Client, clientConfig)
const serviceHandler: ProductService.IHandler<express.Request> = {
    getProduct(id: number, context?: express.Request):      
    Promise<Product> {
        console.log(`ContentService: getProduct[${id}]`)
        const product: IMockProduct | undefined = findProduct(id)
        if (product !== undefined) {
            return       
            popularityClientV1.getPopularityByProduct(product.id)
            .then((popularity: Popularity) => {
            return new Product({
            id: product.id,
            feedback:popularity,
            productInfo: product.productInfo,
            productType: product.productType,
        })
})} else {
throw new ProductServiceException({
    message: `Unable to find product for id[${id}]`,
})}},}
  1. 同样,create gateway/server.ts。为/product/: productId定义一个路由,并将其作为 RPC 调用ProductMicroservice来获取传递的productId的数据。

  2. 运行程序并向localhost:9000/product/1发出请求,您将能够通过 RPC 调用看到组合通信响应。

在本节中,我们亲身体验了一些微服务通信风格以及一些实践。在下一节中,我们将看到如何对微服务进行版本控制,并使我们的微服务具有故障安全机制。

微服务版本控制和故障处理

进化是必要的,我们无法阻止它。每当我们允许其中一个服务进化时,服务版本控制就是维护的一个最重要的方面。在本节中,我们将看到与系统变化处理和克服系统中引入的任何故障相关的各种方面。

版本控制 101

首先应该考虑服务版本控制,而不是将其作为开发后的练习。API 是服务器和消费者之间的公开合同。维护版本帮助我们发布新服务而不会破坏现有客户的任何内容(并非每个人都在第一次尝试中接受变化)。新版本和旧版本应该并存。

流行的版本控制风格是使用语义版本。任何语义版本都有三个主要组成部分——major(每当有重大变化时),minor(每当有向后兼容的行为时),和patch(向后兼容的任何错误修复)。当微服务中有多个服务时,版本控制是极其棘手的。推荐的方法是在服务级别而不是在操作级别对任何服务进行版本控制。如果在任何操作中有单个更改,服务将升级并部署到Version2V2),这适用于服务中的所有操作。我们可以以三种方式对任何服务进行版本控制:

  • URI 版本控制:服务的版本号包含在 URL 本身中。我们只需要担心这种方法的主要版本,因为那会改变 URL 路由。如果有次要版本或任何可用的补丁,消费者无需担心变化。保持最新版本的别名为非版本化的 URI 是需要遵循的良好实践之一。例如,URL /api/v5/product/1234应该被别名为/api/product/1234—别名为v5。此外,传递版本号也可以这样做:
 /api/product/1234?v=1.5
  • 媒体类型版本控制:媒体类型版本控制采用略有不同的方法。这里,版本由客户端在 HTTP Accept 标头上设置。其结构与Accept: application/vnd.api+json类似。Accept 标头为我们提供了一种指定通用和不太通用的内容类型以及提供回退的方法。例如,Accept: application/vnd.api.v5+json命令明确要求 API 的v5版本。如果省略了 Accept 标头,消费者将与最新版本交互,这可能不是生产级别的。GitHub 使用这种版本控制。

  • 自定义标头:最后一种方法是维护我们自己的自定义标头。消费者仍然会使用 Accept 标头,并在其上添加一个新的标头。它可能是这样的:X-my-api-version:1

当比较前面三种方法时,客户端在 URI 方法中消费服务很简单,但在 URI 方法中管理嵌套的 URI 资源可能会很复杂。与媒体类型版本控制相比,基于 URI 的方法在迁移客户端时更复杂,因为我们需要维护多个版本的缓存。然而,大多数大公司,如谷歌、Salesforce 等,都采用 URI 方法。

当开发人员的噩梦成真

所有系统都会经历故障。微服务是分布式的,故障的概率非常高。我们如何处理故障和应对故障是定义开发人员的关键。虽然使整体产品生态系统具有弹性是令人惊叹的(活动包括集群服务器、设置应用程序负载均衡器、在多个位置之间分配基础设施以及设置灾难恢复),但我们的工作并不止于此。这部分只涉及系统的完全丢失。然而,每当服务运行缓慢或存在内存泄漏时,由于以下原因,极其难以检测问题:

  • 服务降级开始缓慢,但迅速获得动力并像感染一样传播。应用程序容器完全耗尽其线程池资源,系统崩溃。

  • 太多的同步调用,调用者必须无休止地等待服务返回响应。

  • 应用程序无法处理部分降级。只要任何服务完全停机,应用程序就会继续调用该服务,很快就会出现资源耗尽。

这种情况最糟糕的是,这种故障会像传染病一样级联并对系统产生不利影响。一个性能不佳的系统很快就会影响多个依赖系统。有必要保护服务的资源,以免因其他性能不佳的服务而耗尽。在下一节中,我们将看一些模式,以避免系统中的故障级联并引起连锁效应。

客户端弹性模式

客户端弹性模式允许客户端快速失败,不会阻塞数据库连接或线程池。这些模式在调用任何远程资源的客户端层中实现。有以下四种常见的客户端弹性模式:

  • 舱壁和重试

  • 客户端负载均衡或基于队列的负载平衡

  • 断路器

  • 回退和补偿交易

这四种模式可以在下图中看到:

客户端弹性模式

舱壁和重试模式

舱壁模式类似于建造船舶的模式,其中船被分成完全隔离和防水的舱壁。即使船体被刺穿,船也不会受到影响,因为它被分成防水的舱壁。舱壁将水限制在发生刺穿的船体特定区域,并防止船沉没。

类似的概念也适用于与许多远程资源交互的隔离模式。通过使用这种模式,我们将对远程资源的调用分解为它们自己的隔离区(自己的线程池),减少风险并防止应用因远程资源缓慢而崩溃。如果一个服务缓慢,那么该类型服务的线程池将变得饱和,以阻止进一步处理请求。对另一个服务的调用不会受到影响,因为每个服务都有自己的线程池。重试模式帮助应用程序处理任何预期的临时故障,每当它尝试连接到服务或任何网络资源时,通过透明地重试先前由于某些条件而失败的操作。它不是等待,而是进行固定次数的重试。

客户端负载均衡或基于队列的负载均衡模式

我们在第六章中看到了客户端负载均衡,服务注册和发现。它涉及客户端从任何服务发现代理(Eureka/Consul)查找所有服务的各个实例,然后缓存可用服务实例的位置。每当有进一步的请求时,客户端负载均衡器将从维护在客户端的服务位置池中返回一个位置。位置会根据一定的间隔定期刷新。如果客户端负载均衡器检测到任何服务位置存在问题,它将从池中移除它,并阻止任何进一步的请求命中该服务。例如,Netflix Ribbon。另一种弹性方法包括添加一个队列,作为任何任务和/或服务调用之间的缓冲,以便平稳处理任何间歇性负载,并防止数据丢失。

断路器模式

我们已经在第一章中看到了这种模式,揭秘微服务。让我们快速回顾一下。每当我们安装了断路器并调用远程服务时,断路器会监视调用。如果调用时间过长,断路器将终止调用并打开断路,使进一步的调用变得不可能。这就是快速失败,快速恢复的概念。

备用和补偿事务模式

在这种模式中,每当远程服务调用失败时,消费者会尝试以替代方式执行该操作,而不是生成异常。通常实现这一点的方法包括从备用数据源(比如缓存)获取数据,或将用户的输入排队以供将来处理。用户将被通知他们的请求将在以后处理,如果所有路由失败,系统会尝试补偿已经处理的任何操作。我们使用的一些常见的备用方法(由 Netflix 强调)包括:

  • 缓存:如果实时依赖项丢失,则从本地或远程缓存获取数据,定期刷新缓存数据以避免旧数据

  • 最终一致性:在服务可用时将数据持久化到队列中以进一步处理

  • 存根数据:保留默认值,并在个性化或服务响应不可用时使用

  • 空响应:返回空值或空列表

现在,让我们看一些实际案例研究,以处理故障并防止它们级联或造成连锁反应。

案例研究 - NetFlix 技术栈

在这个案例研究中,我们将拥抱 Netflix 堆栈并将其应用于我们的微服务。自时间开始以来,我们听说过:多语言开发环境。我们将在这里做同样的事情。在本节中,我们将使用 ZUUL 设置 API 网关,使用 Java 和 Typescript 添加自动发现。用户将不知道实际请求命中了哪里,因为他只会访问网关。案例研究的第一部分涉及介绍 Zuul、Eureka 并在其中注册一些服务,以及通过中央网关进行通信。下一部分将涉及更重要的事情,比如如何处理负载平衡、安全等。所以让我们开始吧。您可以在Chapter 7/netflix云文件夹中跟随示例。除非非常必要,否则我们不会重新发明轮子。让我们尽可能地利用这些资源。以下案例研究支持并鼓励多语言架构。所以让我们开始吧。

第一部分 - Zuul 和多语言环境

让我们看看以下步骤:

  1. 首先我们需要的是一个网关(第五章,理解 API 网关)和服务注册和发现(第六章,服务注册和发现)解决方案。我们将利用 Netflix OSS 的 Zuul 和 Eureka。

  2. 首先我们需要一个 Eureka 服务器,将源代码从Chapter-6/ eureka/eureka-server复制到一个新文件夹,或者按照第六章中的步骤,在 Eureka 部分创建一个新的服务器,该服务器将在 JVM 上运行。

  3. 没有什么花哨的,只需在相关位置添加注释@EnableEurekaServer@SpringBootApplicationDemoServiceDiscoveryApplication.java

  4. 通过添加以下内容在application.properties文件中配置属性,如端口号、健康检查:

eureka:
  instance:
    leaseRenewalIntervalInSeconds: 1         
    leaseExpirationDurationInSeconds: 2
  client:
  serviceUrl:
    defaultZone: http://127.0.0.1:8761/eureka/
    registerWithEureka: false
    fetchRegistry: true
  healthcheck:
    enabled: true
  server:
    port: 8761
  1. 通过以下命令运行 Eureka 服务器:
mvn clean install && java -jar target\demo-service-discovery-0.0.1-SNAPSHOT.jar

您应该能够在端口8761上看到 Eureka 服务器正在运行。

  1. 接下来是 Zuul 或我们的 API 网关。Zuul 将作为任何服务请求的路由点,同时它将与 Eureka 服务器保持不断联系。我们将启用服务与 Zuul 的自动注册,也就是说,如果任何服务注册或注销,我们不必重新启动 Zuul。将我们的网关放在 JVM 中而不是 Node.js 中也将显著提高耐用性。

  2. 打开start.spring.io/并通过添加 Zuul 和 Eureka 发现作为依赖项来生成项目。(您可以在Chapter 7/netflix cloud下找到zuuul-server)。

  3. 打开NetflixOsssApplication并在顶部添加以下注释。

@SpringBootApplication
@EnableDiscoveryClient
@EnableZuulProxy
public class NetflixOsssApplication { ...}
  1. 接下来,我们将使用应用程序级属性配置我们的 Zuul 服务器:
server.port=8762
spring.application.name=zuul-server
eureka.instance.preferIpAddress=true
eureka.client.registerWithEureka=true
eureka.client.fetchRegistry=true
eureka.serviceurl.defaultzone=http://localhost:9091/eureka/
  1. 通过mvn clean install && java -jar target\netflix-osss-0.0.1-SNAPSHOT.jar运行应用程序

  2. 您应该能够在 Eureka 仪表板中看到您的 Zuul 服务器已注册,这意味着 Zuul 已经成功运行起来。

  3. 接下来,我们将在 Node.js 和 Java 中创建一个服务,并在 Eureka 中注册它,因为 Zuul 已启用自动注册,我们的服务将直接路由,无需其他配置。哇!

  4. 所以首先让我们创建一个 Node.js 微服务。通过在Application.ts(初始化 Express 的地方)中添加以下代码将您的微服务注册到 Eureka:

 let client=new Eureka({
     instance: {
         instanceId:'hello-world-chapter-6',
         app: 'hello-world-chapter-6',
         //other attributes
     }, vipAddress: 'hello-world-chapter-6',
     eureka: {
         host: 'localhost',
         port: 8761,
         servicePath: '/eureka/apps/',
     }
 });

我们没有做任何新的事情,这是我们在第六章中的相同代码。只需记住instanceIdvipAddress应该相同。

  1. 现在通过npm start运行服务。它将在端口3001上打开,但我们的 Zuul 服务器正在端口8762上监听。因此,访问 URL http://localhost:8762/hello-world-chapter-6,其中hello-world-chapter-6vipAddress或应用程序名称。您将能够看到相同的输出。这证实了我们 Zuul 服务器的工作。

  2. 为了进一步了解微服务,我在 Java 中添加了一个微服务(http://localhost:8080/product)(没有花哨的东西,只是一个 GET 调用,请检查文件夹java-microservice)。在注册运行在端口8080的微服务之后,当我通过我的网关(http://localhost:8762/java-microservice-producer/product)进行检查时,它就像魅力一样运行。

我们的另一个可行选项包括使用 Netflix Sidecar。14.让我们休息一下,给自己鼓掌。我们已经实现了可以处理任何语言服务的自动注册/注销。我们已经创建了一个多语言环境。

B 部分- Zuul,负载平衡和故障恢复

哇!!A 部分太棒了。我们将继续同样的轨道。我们盘子里的下一部分是,当交通繁忙时会发生什么。在这一部分中,我们将看到如何利用 Zuul,它具有内置的 Netflix Ribbon 支持,以在没有太多麻烦的情况下负载平衡请求。每当请求到达 Zuul 时,它会选择其中一个可用的位置,并将服务请求转发到那里的实际服务实例。整个过程都是缓存实例的位置并定期刷新它和

将请求转发到实际位置是无需任何配置即可获得的。在幕后,Zuul 使用 Eureka 来管理路由。此外,我们将在此示例中看到断路器,并在 Hystrix 仪表板中配置它以查看实时分析。在本节中,我们将配置断路器并将这些流发送到 Hystrix。所以让我们开始吧。您可以在第七章/ hystrix中跟随示例:

  1. 在提取的源代码中抓取standalone-hystrix-dashboard-all.jar并输入java -jar standalone-hystrix-dashboard-all.jar命令。这将在端口7979上打开 Hystrix 仪表板。验证 URLhttp://localhost:7979/hystrix-dashboard以检查:

  1. 是时候编写一个简单的程序,在某个时间点打开一个电路。我们将利用opossum模块(www.npmjs.com/package/opossum)来打开一个电路。通过npm install opossum --save命令安装 opossum 模块,并写下其自定义类型,因为它们尚不可用。

  2. 我们将编写一个简单的逻辑。我们将初始化一个数字,如果达到阈值,那么电路将被打开-打开状态,我们的回退函数将触发。让我们做必要的事情。

  3. 让我们定义我们的变量:

private baseline:number;
private delay:number;
private circuitBreakerOptions = {
    maxFailures: 5,
    timeout: 5000,
    resetTimeout: 10000, //there should be 5 failures
    name: 'customName',
    group: 'customGroupName'
};
  1. 我们从计数 20 开始,并使用两个变量在时间上进行比较:
this.baseline=20;
 this.delay = this.baseline;
  1. 我们定义circuitBreaker并指示我们的 express 应用程序使用它:
import * as circuitBreaker from 'opossum';
    const circuit = circuitBreaker(this.flakeFunction,   
    this.circuitBreakerOptions);
    circuit.fallback(this.fallback);
    this.app.use('/hystrix.stream', 
    hystrixStream(circuitBreaker));
    this.app.use('/', (request:any, response:any) => {
        circuit.fire().then((result:any) => {
            response.send(result);
        }).catch((err:any) => {
            response.send(err);
    });
});
  1. 我们定义一个随时间增加的函数,直到它打开。并且我们定义一个类似的回退函数,比如糟糕!服务中断:
flakeFunction= ()=> {
    return new Promise((resolve, reject) => {
        if (this.delay > 1000) {
            return reject(new Error('Flakey Service is Flakey'));
        }
        setTimeout(() => {
            console.log('replying with flakey response 
            after delay of ', this.delay);
            resolve(`Sending flakey service. Current Delay at   
              ${this.delay}`);
            this.delay *= 2;
        }, this.delay);
    });
 }
 callingSetTimeOut(){
     setInterval(() => {
         if (this.delay !== this.baseline) {
              this.delay = this.baseline;
              console.log('resetting flakey service delay',    
              this.delay);
         }
     }, 20000);
 }
 fallback () => { return 'Service Fallback'; }
  1. 就是这样!打开 Hystrix,输入 URLhttp://localhost:3000/hystrix.stream到 Hystrix 流中,您将能够看到电路的实时监控:

一旦达到峰值阶段,它将自动打开:

在预先配置的时间之后,它将再次处于关闭状态,并准备好为请求提供服务。可以在这里找到完整的详细 APIwww.npmjs.com/package/opossum

消息队列和代理

消息队列是应用程序间通信问题的解决方案。无论我的应用程序或数据在哪里,无论我是在同一台服务器上,独立的服务器上,带有不同操作系统的服务器上或类似的地方,这种通信都会发生。消息队列是为诸如任务列表或工作队列之类的场景而构建的。消息队列通过队列传递和发送数据来解决问题。然后应用程序利用消息中的信息进行进一步交互。所提供的平台是安全可靠的。而消息代理是为了扩展消息队列的功能而构建的,它能够理解通过代理传递的每条消息的内容。对每条消息定义的一组操作被处理。与消息代理一起打包的消息处理节点能够理解来自各种来源的消息,如 JMS、HTTP 和文件。在本节中,我们将详细探讨消息总线和消息代理。流行的消息代理包括 Kakfa、RabbitMQ、Redis 和 NSQ。我们将在下一节中更详细地了解 Apache Kakfa,这是消息队列的高级版本。

发布/订阅模式介绍

与消息队列一样,发布/订阅(发布-订阅)模式将信息从生产者传递给消费者。然而,这里的主要区别在于这种模式允许多个消费者在一个主题中接收每条消息。它确保消费者按照消息系统中接收消息的确切顺序接收主题中的消息。通过采用一个真实的场景,可以更好地理解这种模式。考虑股票市场。它被大量的人和应用程序使用,所有这些人和应用程序都应该实时发送消息,并且只有确切的价格顺序。股票上涨和下跌之间存在巨大的差异。让我们看一个例子,Apache Kafka 是在发布/订阅模式下的一个出色解决方案。根据 Apache Kafka 的文档,Kafka 是一个分布式、分区、复制的提交日志服务。它提供了消息系统的功能,但具有独特的设计。

Kafka 是一个允许应用程序获取和接收消息的流平台。它用于制作实时数据管道流应用程序。让我们熟悉一下 Kafka 的术语:

  • 生产者是向 Kafka 发送数据的人。

  • 消费者是从 Kafka 读取数据的人。

  • 数据以记录的形式发送。每个记录都与一个主题相关联。主题有一个类别,它由一个键、一个值和一个时间戳组成。

  • 消费者通常订阅特定的主题,并获得一系列记录,并在新记录到达时收到通知。

  • 如果消费者宕机,他们可以通过跟踪最后的偏移量重新启动流。

消息的顺序是有保证的。

我们将分三个阶段开始这个案例研究:

  1. 本地安装 Kakfa:

  2. 要在本地设置 Kakfa,下载捆绑包并将其提取到所选位置。提取后,我们需要设置 Zookeeper。为此,请使用以下命令启动zookeeper - bin\windows\zookeeper-server-start.bat config\zookeeper.properties。对于这个案例研究,Java 8 是必不可少的。由于我在 Windows 上进行这个例子,我的命令中有Windows文件夹。一定要注意.sh.bat之间的区别。

  3. 接下来我们将启动 Kakfa 服务器。输入以下命令-

bin\windows\kafka-server-start.bat config\server.properties

  1. 我们将创建一个名为 offers 的主题,只有一个分区和一个副本 - bin\windows\kafka-topics.bat --create --zookeeper localhost:2181 --replication-factor 1 --partitions 1 --topic offers。您将收到提示 Created topic offers。要查看主题,我们可以输入bin\windows\kafka-topics.bat --list --zookeeper localhost:2181

  2. Kakfa 在localhost:2181上运行。我们甚至可以通过我们的代理或 Node.js 客户端创建主题。

  3. 创建 Kafka 生产者

  4. 我们将利用kakfa-node模块(www.npmjs.com/package/kafka-node)。根据需要,我们可以设置一个单独的服务或集成到现有的应用服务中。

  5. 现在我们将在两个不同的项目中编写两个单独的文件来测试我们的应用程序。

  6. 您可以检查Chapter-8/kakfka/node-producer以查看源代码:

const client = new kafka.Client("http://localhost:2181", "kakfka-client", {
     sessionTimeout: 300,
     spinDelay: 100,
     retries: 2
 });
 const producer = new kafka.HighLevelProducer(client);
 producer.on("ready", function() {
     console.log("Kafka Producer is ready.");
 });
 // For this demo we just log producer errors
 producer.on("error", function(error:any) {
     console.error(error);
 });
 const KafkaService = {
     sendRecord: ({ type, userId, sessionId, data }:any,  
       callback = () => {}) => {
         if (!userId) {
             return callback(new Error(`A userId
                has to be provided.`));
         }
         const event = {
             id: uuid.v4(),
             timestamp: Date.now(),
             userId: userId,
             sessionId: sessionId,
             type: type,
             data: data
         };
         const buffer:any = new    
           Buffer.from(JSON.stringify(event));
         // Create a new payload
         const record = [
         {
             topic: "offers",
             messages: buffer,
             attributes: 1
         }
         ];
         //Send record to Kafka and log result/error
         producer.send(record, callback);
     }
 };
  1. 您可以像这样绑定消息事件。通过相同的模块,我们可以创建一个客户端,他将监听报价消息并相应地处理事件:
const consumer = new kafka.HighLevelConsumer(client, topics, options);
 consumer.on("message", function(message:any) {
     // Read string into a buffer.
     var buf = new Buffer(message.value, "binary");
     var decodedMessage = JSON.parse(buf.toString());
     //Events is a Sequelize Model Object.
     return Events.create({
         id: decodedMessage.id,
         type: decodedMessage.type,
         userId: decodedMessage.userId,
         sessionId: decodedMessage.sessionId,
         data: JSON.stringify(decodedMessage.data),
         createdAt: new Date()
     });
 });

Kafka 是一个强大的工具,可以用于各种需要实时数据处理的场景。发布/订阅模式是实现事件驱动通信的一种很好的方式。

共享依赖

当构建可独立部署的可扩展代码库时,微服务非常出色,分离关注点,具有更好的弹性,多语言技术和更好的模块化,可重用性和开发生命周期。然而,模块化和可重用性是有代价的。更多的模块化和可重用性往往会导致高耦合或代码重复。将许多不同的服务连接到相同的共享库将很快使我们回到原点,最终我们将陷入单块地狱。

在本节中,我们将看到如何摆脱这种困境。我们将看到一些具有实际实现的选项,并了解共享代码和通用代码的过程。所以让我们开始吧。

问题和解决方案

在微服务之间共享代码总是棘手的。我们需要确保共同的依赖不会限制我们微服务的自由。我们在共享代码时要实现的主要目标是:

  • 在我们的微服务之间共享通用代码,同时确保我们的代码是不重复自己DRY)——这是一个编码原则,其主要目标是减少代码的重复

  • 通过任何共享的共同库避免紧密耦合,因为它会消除微服务的自由。

  • 使同步我们可以在微服务之间共享的代码变得简单

微服务会引入代码重复。为任何业务用例创建一个新的npm包是非常不切实际的,因为这将产生大量的开销,使维护任何代码更加困难。

我们将使用bitbitsrc.io/)来解决我们的依赖问题并实现我们的目标。Bit 的运作理念是组件是构建块,你是架构师。使用 bit,我们不必创建新的存储库或添加包来共享代码,而不是重复。您只需定义任何现有微服务的可重用部分,并将其共享到其他微服务作为任何包或跟踪的源代码。这样,我们可以轻松地使任何服务的部分可重用,而无需修改任何一行代码,也不会在服务之间引入紧密耦合。Bit 的主要优势在于它为我们提供了灵活性,使我们能够对与任何其他服务共享的代码进行更改,从而使我们能够在微服务生态系统中的任何地方开发和修改代码。

开始使用 bit

通过共同的库耦合微服务是非常糟糕的。Bit 提倡构建组件。我们只需隔离和同步任何可重用的代码,让 bit 处理如何在项目之间隔禅和跟踪源代码。这仍然可以通过 NPM 安装,并且可以从任何端口进行更改。假设您正在创建一些具有顶级功能的出色系统,这些功能在任何地方都很常见。您希望在这些服务之间共享代码。您可以在第七章的bit-code-sharing文件夹中跟随代码,服务状态和服务间通信

  1. Bit 将作为全局模块安装。通过输入以下内容安装bit
npm install bit-bin -g
  1. 在这个例子中,查看demo-microservice,它具有常见的实用程序,比如从缓存中获取、常见的日志实用程序等。我们希望这些功能在任何地方都可以使用。这就是我们将使用bit使我们的文件common/logging.ts在任何地方都可用的地方。

  2. 是时候初始化bit并告诉bit在跟踪列表中添加logging.ts。在终端中打开demo-microservice,然后输入bit init命令。这将创建一个bit.json文件。

  3. 接下来,我们将告诉bit开始跟踪common文件夹。在终端中输入以下命令:

bit add src/common/*

在这里,我们使用*作为全局模式,这样我们就可以跟踪相同路径上的多个组件。它将跟踪common文件夹中的所有组件,你应该能够看到一个跟踪两个新组件的消息。

  1. Bit 组件已添加到我们的 bit 跟踪列表。我们可以简单地输入bit status来检查我们微服务中 bit 的当前状态。它将在“新组件”部分下显示两个组件。

  2. 接下来,我们将添加构建和测试环境,以便在分享组件之前不会引入任何异常。首先是我们的构建环境。构建环境本质上是一个构建任务,由 bit 用于运行和编译组件,因为我们的文件是用 TypeScript 编写的。要导入依赖项,你需要在bitsrc.io创建一个账户,并注册公共层。

  3. 通过添加以下行来导入 TypeScript 编译器:

bit import bit.envs/compilers/typescript -c

你需要输入刚刚创建的账户的用户凭据。安装后,我们将使用公共作用域。

  1. 输入命令bit build,查看带有我们生成文件的distribution文件夹。你可以类似地编写测试来检查单元测试用例是否通过。Bit 内置支持 mocha 和 jest。我们现在只是创建一个hello-world测试。我们需要明确告诉 bit 对于哪个组件,哪个将是test文件。因此,让我们取消跟踪先前添加的文件,因为我们需要传递我们的规范文件:
bit untrack --all
  1. src文件夹内创建一个test文件夹,并通过以下命令安装测试库:
npm install mocha chai @types/mocha @types/chai --save
  1. tests文件夹内创建logging.spec.ts,并添加以下代码。类似地,创建cacheReader.spec.ts
import {expect} from 'chai';
describe("hello world mocha test service", function(){
    it("should create the user with the correct name",()=>{
        let helloDef=()=>'hello world';
        let helloRes=helloDef();
        expect(helloRes).to.equal('hello world');
    });});

我们将在第八章中看到详细的测试概念,测试、调试和文档

  1. 要告诉bit我们的测试策略,输入以下命令:
bit import bit.envs/testers/mocha --tester
bit add src/common/cacheReader.ts  --tests 'src/tests/cacheReader.spec.ts'
bit add src/common/logging.ts --tests 'src/tests/logging.spec.ts'
  1. 输入命令bit test,它将打印针对每个添加的组件的测试结果。

  2. 我们已经完成了。是时候与世界分享我们全新的组件了。首先,我们将锁定一个版本,并将其与此项目的其他组件隔离开来。输入以下命令:

bit tag --all 1.0.0

你应该能够看到一个输出,指出已添加组件common/logging@1.0.0common@cache-reader@1.0.0。当你执行bit status时,你将能够看到这些组件已从新组件移动到了暂存组件。

  1. 为了与其他服务共享,我们使用bit export导出它。我们将把它推送到远程作用域,这样它就可以从任何地方访问。转到bitsrc.io/,登录,然后在那里创建一个新的作用域。现在我们将把我们的代码推送到那个作用域:
bit export <username>.<scopename>

你可以登录你的账户,然后检查推送存储库中的代码。

  1. 要在其他工作区中导入,可以按照以下步骤进行:

  2. 我们需要告诉节点,bit 是我们的一个注册表之一,从中下载模块。因此,在npm config中添加bit仓库作为带有别名@bit的注册表之一:

npm config set '@bit:registry' https://node.bitsrc.io
  1. 要从任何其他项目中下载,请使用以下命令:
npm i @bit/parthghiya.tsms.common.logging

该命令类似于npm i <我们创建的别名>/<用户名>.<作用域名称>.<用户名>。安装后,你可以像使用任何其他节点模块一样使用它。查看chapter 9/bit-code-sharing/consumer

你还可以使用bit import和其他实用程序,比如进行更改、同步代码等。

共享代码对于开发和维护提供了必要的。然而,通过共享库紧密耦合服务破坏了微服务的意义。为任何新的常见用例在 NPM 中创建新的存储库是不切实际的,因为我们必须进行许多更改。像 bit 这样的工具拥有两全其美。我们可以轻松共享代码,还可以从任何端点进行制作和同步更改。

共享数据的问题

在微服务之间共享常见数据是一个巨大的陷阱。首先,不一定能满足所有微服务的需求。此外,它增加了开发时间的耦合。例如,InventoryService将需要与使用相同表的其他服务的开发人员协调模式更改。它还增加了运行时的耦合。例如,如果长时间运行的ProductCheckOut服务在ORDER表上持有锁定,那么使用相同表的任何其他服务都将被阻塞。每个服务必须有自己的数据库,并且数据不能直接被任何其他服务访问。

然而,有一个巨大的情况需要我们注意。事务问题以及如何处理它们。即使将与事务相关的实体保留在同一个数据库中并利用数据库事务似乎是唯一的选择,我们也不能这样做。让我们看看我们应该怎么做:

  • 选项 1:如果任何更新只发生在一个微服务中,那么我们可以利用异步消息/服务总线来处理。服务总线将保持双向通信,以确保业务能力得到实现。

  • 选项 2:这是我们希望处理事务数据的地方。例如,只有在付款完成后才能进行结账。如果没有,那么它不应该继续进行任何操作。要么我们需要合并服务,要么我们可以使用事务(类似于 Google Spanner 用于分布式事务)。我们卡在两个选项上,要么通过事务解决,要么相应地处理情况。让我们看看如何以各种方式处理这些情况。

为了管理数据一致性,最常用的模式之一是 saga 模式。让我们了解一个我们拥有的实际用例。我们有一个客户奖励积分服务,用于维护允许购买的总积分。应用程序必须确保新订单不得超过客户允许的奖励积分。由于订单和客户奖励积分存储在不同的数据库中,我们必须保持数据一致性。

根据 saga 模式,我们必须实现跨多个服务的每个业务交易。这将是一系列本地事务。每个单独的事务更新数据库并发布一个消息或事件,该消息或事件将触发 saga 中的下一个本地事务。如果本地事务失败,那么 saga 将执行一系列补偿事务,实际上撤消了上一个事务所做的更改。以下是我们将在我们的案例中执行的步骤。这是通过事件维护一致性的一个案例:

  • 奖励服务创建一个处于挂起状态的订单并发布一个积分处理的事件。

  • 客户服务接收事件并尝试阻止该订单的奖励。它发布了一个奖励被阻止的事件或奖励被阻止失败的事件。

  • 订单服务接收事件并相应地更改状态。

最常用的模式如下:

  • 状态存储:一个服务记录状态存储中的所有状态更改。当发生任何故障时,我们可以查询状态存储以找到并恢复任何不完整的事务。

  • 流程管理器:一个监听任何操作生成的事件并决定是否完成事务的流程管理器。

  • 路由滑动: 另一种主流方法是使所有操作异步进行。一个服务使用两个请求命令(借记和发货指令)创建一条称为路由滑动的滑动。这条消息从路由滑动传递到借记服务。借记服务执行第一个命令并填写路由滑动,然后将消息传递给完成发货操作的发货服务。如果出现故障,消息将被发送回错误队列,服务可以观察状态和错误状态以进行补偿。以下图表描述了相同的过程:

路由滑动

微服务中的数据共享如果处理不当,总是会成为一个痛点。有各种解决方案可以处理微服务之间的分布式事务。我们看到了广泛使用的解决方案,比如 saga,并且了解了处理数据最终一致性的各种方法。

缓存

现在我们基本上掌握了微服务开发的主导权。我们已经开发了微服务,通过网关连接它们,并在它们之间建立了通信层。由于我们已经将代码分布到各种服务中,可能会出现的问题之一是在正确的时间访问所需的数据。使用内存具有一系列挑战,我们绝不希望引入(例如,需要引入负载均衡器、会话复制器等)。我们需要一种方式在服务之间访问临时数据。这将是我们的缓存机制:一个服务创建并将数据存储在缓存中,而其他服务可能根据需要和情况或失败情况使用它。这就是我们将引入 Redis 作为我们的缓存数据库的地方。著名的缓存解决方案包括 Redis 和 Hazelcast。

缓存的祝福和诅咒

每当我们被要求优化应用程序的性能方面时,首先想到的就是缓存。缓存可以被定义为暂时将检索或计算的数据保存在数据存储(服务器的 RAM,像 Redis 这样的键值存储)中,希望将来访问这些信息会更快。更新这些信息可以被触发,或者这个值可以在一定的时间间隔后失效。缓存的优势一开始看起来很大。计算资源一次,然后从缓存中获取(读取有效资源)可以避免频繁的网络调用,因此可以缩短加载时间,使网站更具响应性,并提供更多的收入。

然而,缓存并不是一劳永逸的解决方案。缓存确实是静态内容和可以容忍到一定程度的过时数据的 API 的有效策略,但在数据非常庞大和动态的情况下并不适用。例如,考虑我们购物车微服务中给定产品的库存。对于热门产品,这个数量会变化得非常快,而对于其他一些产品,它可能会变化。因此,在这里确定缓存的合适年龄是一个难题。引入缓存还需要管理其他组件(如 Redis、Hazelcast、Memcached 等)。这增加了成本,需要采购、配置、集成和维护的过程。缓存还可能带来其他危险。有时从缓存中读取可能会很慢(缓存层未得到良好维护,缓存在网络边界内等)。使用更新的部署维护缓存也是一个巨大的噩梦。

以下是一些需要保持的实践,以有效使用缓存,即使我们的服务工作量减少:

  • 使用 HTTP 标准(如 If-modified-Since 和 Last-Modified 响应头)。

  • 其他选项包括 ETag 和 If-none-match。在第一次调用后,将生成并发送唯一的实体标签ETag)到服务请求,客户端在if-none-match-header中发送。当服务器发现 ETag 未更改时,它会发送一个带有304 Not Modified响应的空主体。

  • HTTP Cache-Control 头可以用于帮助服务控制所有缓存实体。它具有各种属性,如private(如果包含此头,则不允许缓存内容),no-cache(强制服务器重新提交以进行新的调用),public(标记任何响应为可缓存),以及max-age(最大缓存时间)。

查看以下图表以了解一些缓存场景:

缓存场景

Redis 简介

Redis 是一个专注于简单数据结构(键值对)的简单 NoSQL 数据库,具有高可用性和读取效率。Redis 是一个开源的,内存数据结构存储,可以用作数据库,缓存或消息代理。它具有内置数据结构的选项,如字符串,哈希,列表,集合,范围查询,地理空间索引等。它具有开箱即用的复制,事务,不同级别的磁盘持久性,高可用性和自动分区的选项。我们还可以添加持久性存储,而不是选择内存存储。

当 Redis 与 Node.js 结合使用时,就像天作之合一样,因为 Node.js 在网络 I/O 方面非常高效。NPM 仓库中有很多 Redis 包可以使我们的开发更加顺畅。领先的包有redis (www.npmjs.com/package/redis),ioredis (www.npmjs.com/package/ioredis)和hiredis (www.npmjs.com/package/hiredis)。hiredis包具有许多性能优势。要开始我们的开发,我们首先需要安装redis。在下一节中,我们将在项目中设置我们的分布式缓存。

使用 redis 设置我们的分布式缓存

为了理解缓存机制,让我们举一个实际的例子并实现分布式缓存。我们将围绕购物车的例子进行演变。将业务能力划分到不同的服务是一件好事,我们将我们的库存服务和结账服务划分为两个不同的服务。所以每当用户添加任何东西到购物车时,我们都不会持久化数据,而是将其临时存储,因为这不是永久的或功能性改变的数据。我们会将这种短暂的数据存储到 Redis 中,因为它的读取效率非常棒。我们对这个问题的解决方案将分为以下步骤:

  1. 首先,我们专注于设置我们的redis客户端。像所有其他东西一样,通过docker pull redis拉取一个 docker 镜像。

  2. 一旦镜像在我们的本地,只需运行docker run --name tsms -d redis。还有持久性存储卷的选项。您只需附加一个参数docker run --name tsms -d redis redis-server --appendonly yes

  3. 通过命令redis-cli验证 redis 是否正在运行,您应该能够看到输出 pong。

  4. 是时候在 Node.js 中拉取字符串了。通过添加npm install redis --savenpm install @types/redis --save来安装模块。

  5. 通过import * as redis from 'redis'; let client=redis.createClient('127.0.0.1', 6379);创建一个客户端。

  6. 像任何其他数据存储一样使用 Redis:

redis.get(req.userSessionToken + '_cart', (err, cart) => { if (err) 
 { 
    return next(err); 
 } 
//cart will be array, return the response from cache }
  1. 同样,您可以根据需要随时使用 redis。它甚至可以用作命令库。有关详细文档,请查看此链接 (www.npmjs.com/package/redis)。

我们不得不在每个服务中复制 Redis 的代码。为了避免这种情况,在后面的部分中,我们将使用 Bit:一个代码共享工具。

在下一节中,我们将看到如何对微服务进行版本控制,并使我们的微服务具有故障安全机制。

摘要

在本章中,我们研究了微服务之间的协作。有三种类型的微服务协作。基于命令的协作(其中一个微服务使用 HTTP POST 或 PUT 来使另一个微服务执行任何操作),基于查询的协作(一个微服务利用 HTTP GET 来查询另一个服务的状态),以及基于事件的协作(一个微服务向另一个微服务公开事件源,后者可以通过不断轮询源来订阅任何新事件)。我们看到了各种协作技术,其中包括发布-订阅模式和 NextGen 通信技术,如 gRPC、Thrift 等。我们看到了通过服务总线进行通信,并了解了如何在微服务之间共享代码。

在下一章中,我们将研究测试、监控和文档的方面。我们将研究我们可以进行的不同类型的测试,以及如何编写测试用例并在发布到生产环境之前执行它们。接下来,我们将研究使用 PACT 进行契约测试。然后,我们将转向调试,并研究如何利用调试和性能分析工具有效监视我们协作门户中的瓶颈。最后,我们将使用 Swagger 为我们的微服务生成文档,这些文档可以被任何人阅读。

第八章:测试、调试和记录

到目前为止,我们已经编写了一些微服务实现(第四章,开始您的微服务之旅);建立了一个单一的接触点,API 网关(第五章,理解 API 网关);添加了一个注册表,每个服务都可以记录其状态(第六章,服务注册表和发现);建立了微服务之间的协作(第七章,服务状态和服务间通信);并编写了一些实现。从开发者的角度来看,这些实现似乎很好,但是现在没有测试就没有人会接受。这是行为驱动开发和测试驱动开发的时代。随着我们编写越来越多的微服务,开发没有自动化测试用例和文档的系统变得难以管理和痛苦。

本章将从理解测试金字塔开始,深入描述微服务中涉及的所有不同类型的测试。我们将了解测试框架,并了解基本的单元测试术语。然后我们将学习调试微服务的艺术,最后学习如何使用 Swagger 记录我们的微服务。

本章涵盖以下主题:

  • 编写良好的自动化测试用例

  • 理解测试金字塔并将其应用于微服务

  • 从外部测试微服务

  • 调试微服务的艺术

  • 使用 Swagger 等工具记录微服务

测试

测试是任何软件开发的基本方面。无论开发团队有多么优秀,总会有改进的空间或者他们的培训中有遗漏的地方。测试通常是一项耗时的活动,根本没有得到应有的关注。这导致了行为驱动开发的普及,开发人员编写单元测试用例,然后编写代码,然后运行覆盖率报告以了解测试用例的状态。

什么和如何测试

由于微服务是完全分布式的,首先要考虑的问题是要测试什么以及如何测试。首先,让我们快速了解定义微服务并需要测试的主要特征:

  • 独立部署:每当

  • 当一个微服务部署了一个小的或安全的更改后,该微服务就准备好部署到生产环境了。但是我们如何知道更改是否安全呢?这就是自动化测试用例和代码覆盖率发挥作用的地方。有一些活动,比如代码审查、代码分析和向后兼容性设计,可能会起作用,但是测试是一项可以完全信任适应变化的活动。

  • 可以随意替换:一组良好的测试总是有助于了解新实现是否等同于旧实现。任何新实现都应该针对具有正常工作流程的等效实现进行测试。

  • 小团队的所有权:微服务是小型的,专注于一个团队,以满足单一的业务需求。我们可以编写覆盖微服务所有方面的测试。

测试过程必须快速、可重复,并且应该是自动化的。接下来的问题是如何测试以及测试时要关注什么。通常,所有测试被分为以下四个部分:

  • 理解用户:主要的测试模式是发现用户需要什么以及他们遇到了什么问题。

  • 功能检查:这种测试模式的目标是确保功能正确并符合规格。它涉及用户测试、自动化测试等活动。

  • 防止不必要的更改:此测试的目标是防止系统中不必要的更改。每当部署新更改时,都会运行几个自动化测试,生成代码覆盖率报告,并可以决定代码覆盖级别。

  • 测试金字塔 - 测试什么?

服务测试(中层):这些测试检查系统业务能力的完整执行。它们检查特定的业务需求是否已经实现。它们不关心背后需要多少服务来满足需求。

系统测试

测试金字塔是一个指导我们编写何种测试以及在哪个级别进行测试的工具。金字塔顶部的测试表明需要较少的测试,而金字塔底部需要更多的测试。

系统测试(顶层):这些测试跨越完整的分布式微服务系统,并通常通过 GUI 实现。

测试金字塔由四个级别组成,如下所述:

  • 防止运行时行为:此测试的目标是检查系统存在哪些运行时问题。在这里,我们通过压力测试、负载测试和监控来保护系统。

  • 我们将在接下来的部分中更详细地讨论所有这些级别。

  • 单元测试(底层):这些测试在微服务中执行非常小的功能片段。几个较低级别的单元测试组合成一个微服务。单元测试仅涉及微服务内的一个小方面,或者我们可以说它们在宏观级别上运行。例如,我们的产品目录服务有许多服务。为其编写单元测试将涉及传递产品 ID 并确保我获得正确的产品。

  • 在我们的购物车微服务中,系统测试的一个示例将是完整的结账流程。它使用添加到购物车系统的 Web UI,在那里我们添加多个项目,生成发票,应用折扣代码,并使用测试信用卡付款。如果测试通过,我们可以断言折扣代码可以应用并且可以收到付款。如果断言失败,任何事情都可能导致失败,例如商品的价格错误,可能添加了额外费用,或者可能支付服务失败。为了解决此问题,我们需要测试所有微服务以找到确切的罪魁祸首。

合同测试(较低级别):这些测试在外部服务的边界上进行,以验证是否符合消费服务期望的合同。

在接下来的部分中,我们将讨论微服务中的测试金字塔。

位于金字塔顶部的是系统测试或端到端测试。它们具有非常广泛的范围,或者我们可以说它们具有 5 万英尺的范围,并试图在很少的测试中涵盖很多内容。它们不会降到宏观级别。每当系统测试失败时,很难确定问题所在,因为它的范围很大。测试覆盖整个分布式系统,因此问题可能出现在任何地方,任何组件中。

覆盖大量服务和更广泛的领域,系统测试通常倾向于缓慢和不精确(因为我们无法确定失败的确切服务)。而不是使用模拟系统,实际进行服务请求,将事物写入真实数据存储,并甚至轮询真实事件源以监视系统。

一个重要的问题是关于需要运行多少系统测试。系统测试成功时可以给予很大的信心,但它们也缓慢且不精确;我们只能为系统的最重要的用例编写系统级测试。这可以让我们覆盖系统中所有重要业务能力的成功路径。

对于完整的端到端测试,我们可以采取以下行动之一:

  • 使用 JSON 请求测试我们的 API

  • 使用 Selenium 测试 UI,模拟对 DOM 的点击。

  • 使用行为驱动开发,将用例映射到我们应用程序中的操作,并在我们构建的应用程序上执行

我的建议是只编写面向业务的重要业务能力系统测试,因为这样可以对完全部署的系统进行大量练习,并涉及利用生态系统中的所有组件,如负载均衡器、API 网关等。

服务测试

这些测试处于测试金字塔的中间层,它们专注于与一个微服务的完整交互,并且是独立的。这个微服务与外部世界的协作被模拟 JSON 所取代。服务级测试测试场景,而不是进行单个请求。它们进行一系列请求,共同形成一个完整的图片。这些是真正的 HTTP 请求和响应,而不是模拟的响应。

例如,信用计划的服务级测试可以执行以下操作:

  1. 发送命令以触发信用类别中的用户(这里的命令遵循 CQRS 模式,见第一章,“揭秘微服务”)。CQRS 遵循同步通信模式,因此,它的测试代码是相同的。我们发送命令以触发其他服务来满足我们的服务测试标准。

  2. 根据用户的月度消费决定最佳的忠诚度优惠。这可以是硬编码的,因为它是一个不同的微服务。

  3. 记录发送给用户的优惠,并发送响应以检查服务的功能。

当所有这些方面都通过时,我们可以断言信用计划微服务成功运行,如果任何一个功能失败,我们可以肯定问题出在信用计划微服务中。

服务级测试比系统级测试更精确,因为它们只涵盖一个单一的微服务。如果这样的测试失败,我们可以肯定地断言问题出在微服务内部,假设 API 网关没有错误,并且它提供了与模拟中写的完全相同的响应。另一方面,服务级测试仍然很慢,因为它们需要通过 HTTP 与被测试的微服务进行交互,并且需要与真实数据库进行交互。

我的建议是,应该为最重要的可行故障场景编写这些测试,要牢记编写服务级测试是昂贵的,因为它们使用微服务中的所有端点,并涉及基于事件的订阅。

合同测试

在分布式系统中,微服务之间有很多协作。协作需要作为一个微服务对另一个微服务的请求来实现。端点的任何更改都可能破坏调用该特定端点的所有微服务。这就是合同测试的作用所在。

当任何微服务进行通信时,发出请求的微服务对另一个微服务的行为有一些期望。这就是协作的工作方式:调用微服务期望被调用的微服务实现某个固定的合同。合同测试是为了检查被调用的微服务是否按照调用微服务的期望实现了合同的测试。

尽管契约测试是调用方微服务代码库的一部分,但它们也测试其他微服务中的内容。由于它们针对完整系统运行,因此有利于针对 QA 或分阶段环境运行它们,并配置在每次部署时自动运行契约测试。当契约失败时,意味着我们需要更新我们的测试替身或更改我们的代码以适应契约所做的新更改。这些测试应该根据外部服务的更改数量来运行。契约测试的任何失败都不会像普通测试失败那样破坏构建。这表明消费者需要跟上变化。我们需要更新测试和代码以使一切保持同步。这将引发与生产者服务的对话,讨论该变化如何影响其他方面。

我的结论是,契约测试与服务测试非常相似,但区别在于契约测试侧重于满足与服务通信的先决条件。契约测试不设置模拟协作者,实际上会向正在测试的微服务发出真实的 HTTP 请求。因此,如果可能的话,它们应该针对每个微服务进行编写。

单元测试

这些是测试金字塔底部的测试。这些测试也涉及单个微服务,但与服务测试不同,它们不关注整个微服务,也不通过 HTTP 工作。单元测试直接与正在测试的微服务的部分/单元进行交互,或通过内存调用。单元测试看起来就像您正在进行真实的 HTTP 请求,只是您在处理模拟和断言。通常涉及两种类型的单元测试:一种涉及数据库调用,另一种直接涉及内存调用。如果测试的范围非常小,并且测试代码和微服务中的生产代码在同一个进程中运行,那么测试可以被称为单元测试。

单元测试的范围非常狭窄,因此在识别问题时非常精确。这有助于有效处理故障和错误。有时,您可以通过直接实例化对象然后对其进行测试,使微服务的范围更窄。

对于我们的信用计划,我们需要几个单元测试来测试端点和业务能力。我们需要测试用户设置,包括有效和无效数据。我们需要测试读取现有和不存在的用户,以检查我们的忠诚度和月度福利。

我的建议是,我们应该决定最窄的单元测试可以有多窄。从测试应该覆盖的内容开始,然后逐渐添加更精细的细节。一般来说,我们可以使用两种单元测试风格:经典的(基于状态的行为测试)或模拟的(通过模拟实际行为支持的交互测试)。

在下图中,我们可以看到应用于微服务的所有测试类型:

测试类型

现在我们知道了微服务级别需要的所有测试类型,是时候看看我们的微服务测试框架了。在下一节中,我们将看到不同类型测试的实际实现,并进行微服务的代码覆盖率。让我们开始吧。

实践测试

现在是时候动手使用微服务测试框架了。在本节中,我们将首先了解测试基础知识,然后继续编写一些单元测试、合同测试和服务级测试。编写测试有很大的优势。我们被迫思考如何将代码分解为子函数,并根据单一职责原则编写代码。全面的测试覆盖率和良好的测试使我们了解应用程序的工作原理。在本节中,我们将使用一些著名的工具集:Mocha,Chai,Sinon 和 Ava。Ava 将是我们的测试运行器,Chai 将是我们的断言库,Sinon 将是我们的模拟库。

我们的库和测试工具类型

测试工具可以分为各种功能。为了充分利用它们,我们总是使用它们的组合。让我们根据它们的功能来看看可用的最佳工具:

  • 提供测试基础:Mocha,Jasmine,Jest,Cucumber

  • 提供断言函数:Chai,Jasmine,Jest,Unexpected

  • 生成、显示和观察测试结果:Mocha,Jasmine,Jest,Karma

  • 生成和比较组件和数据结构的快照:Jest,Ava

  • 提供模拟、间谍和存根:Sinon,Jasmine,Enzyme,Jest,test double

  • 生成代码覆盖报告:Istanbul,Jest,Blanket

  • E2E 测试:Casper,Nightwatch

在本节中,我们将快速浏览 Ava,Chai,Mocha 和 Sinon,并了解它们对我们有什么提供。

尽管 Mocha 是一个标准库,但我选择了 Ava,因为它与 Mocha 相比非常快,它将每个测试作为单独的 Node.js 进程运行,从而节省 CPU 使用率和内存。

这是一个基本的断言库,遵循 TDD/BDD,可以与任何其他库一起使用,以获得高质量的测试。一个断言 i

任何必须实现的语句,否则应该抛出错误并停止测试。这是一个非常强大的工具,可以编写易于理解的测试用例。

它提供了以下三个接口,使测试用例更易读和更强大:

  • should

  • expect

  • assert

除了这三个接口,我们还可以使用各种自然语言词汇。完整列表可以在www.chaijs.com/api/bdd/找到。

你一定想知道shouldexpect之间的区别是什么。嗯,这是一个自然的问题。尽管shouldexpect做同样的事情,但根本区别在于assertexpect接口不修改Object.prototype,而should则会。

Mocha

Mocha 是最著名和广泛使用的库之一,遵循行为驱动开发测试。在这里,测试描述了任何服务的用例,并且它使用另一个库的断言来验证执行代码的结果。Mocha 是一个测试运行器。它被用来

组织和运行测试通过describe和它的操作符。 Mocha 提供了各种功能,比如:

  • beforeEach(): 在测试文件中的每个规范之前调用一次,从中运行测试

  • afterEach(): 在测试文件中的每个规范之后调用一次

  • before (): 这在任何测试之前运行代码

  • after(): 这在所有测试运行后运行代码

Ava

Ava,像 Mocha 一样,是一个测试运行器。Ava 利用 Node.js 的并行和异步特性,并通过单独的进程并行处理运行测试文件。根据统计数据,在pageres(一个捕获屏幕截图的插件)中从 Mocha 切换到 Ava,将测试时间从 31 秒降至 11 秒(github.com/avajs/ava/blob/master/readme.md)。它有各种选项,如快速失败、实时监视(在更改文件时以监视模式重新运行测试)、存储快照等。

Ava 是为未来设计的,完全使用 ES6 编写。测试可以并行运行,可以选择同步或异步进行测试。默认情况下,测试被认为是同步的,除非它们返回一个 promise 或一个 observable。它们大量使用异步函数:

import test from 'ava';
const fn = async () => Promise.resolve('typescript-microservices');
test(
  async (t) => {
    t.is(await fn(), 'typescript-microservices');
  });

它有各种选项,如:

  • 报告(显示测试覆盖率的美观报告)

  • 快速失败(在第一个失败的测试用例后停止)

  • 跳过测试

  • 未来的测试

Sinon

通常,微服务需要调用其他微服务,但我们不想调用实际的微服务;我们只想关注方法是否被调用

或者不。为此,我们有 Sinon,一个框架,它给我们提供了模拟和间谍的选项,通过提供模拟响应或创建间谍服务来实现我们的目的。它提供以下功能:

  • Stub:存根是一个带有预先记录和特定响应的虚拟对象。

  • Spy:间谍是真实对象和模拟对象之间的混合体。一些方法被间谍对象遮蔽。

  • Mock:模拟是替换实际对象的虚拟对象。

伊斯坦布尔

这是一个代码覆盖工具,用于跟踪语句、分支和功能覆盖。模块加载器可以在不需要配置的情况下即时对代码进行检测。它提供多种报告格式,如 HTML、LCOV 等。它也可以用于命令行。通过将其嵌入为自定义中间件,它可以用作 Node.js 的服务器端代码覆盖工具。

使用 Pact.js 进行合同测试

每个微服务都有自己独立的实现;比如我们的类别服务(产品目录服务)。它有一个用于获取类别列表、获取与这些类别相关的产品列表、添加任何新类别等的端点。现在我们的购物车微服务(消费者)利用这个服务,但在任何时候,类别微服务(提供者)可能会发生变化。

在任何时候:

  • 提供者可能会将端点/categories/list更改为/categories

  • 提供者可能会更改有效负载中的几个内容

  • 提供者可能会添加新的强制参数或引入新的身份验证机制

  • 提供者可能会删除消费者所需的端点

任何这些情况都可能导致潜在的灾难!这些类型的测试不会被单元测试处理,传统方法是使用集成测试。但是,我们可以看到集成测试的潜在缺点,例如以下内容:

  • 集成测试很慢。它们需要设置集成环境,满足提供者和消费者的依赖关系。

  • 它们很脆弱,可能因其他原因而失败,比如基础设施。集成测试的失败并不一定意味着代码有问题。由于集成测试的范围很广,要找出实际问题变得非常痛苦。

因此,我们需要进行合同测试。

什么是消费者驱动的合同测试?

合同测试意味着我们根据一组期望(我们定义为合同的内容)来检查我们的 API,这些期望是要实现的。这意味着我们想要检查,当收到任何 API 请求时,我们的 API 服务器是否会返回我们在文档中指定的数据。我们经常忽略关于我们的 API 客户需求的精确信息。为了解决这个问题,消费者可以定义他们的期望集作为模拟,在单元测试中使用,从而创建他们期望我们实现的合同。我们收集这些模拟,并检查我们的提供者在以与模拟设置相同的方式调用时是否返回相同或类似的数据,从而测试服务边界。这种完整的方法被称为消费者驱动的合同测试。

消费者驱动的合同的想法只是为了规范消费者和提供者之间的任何或所有交互。消费者创建一个合同,这只是消费者和提供者之间关于将发生的交互量或简单地陈述消费者对提供者的期望的协议。一旦提供者同意了合同,消费者和提供者都可以拿到合同的副本,并使用测试来验证系统的任何一端不会发生合同违反。这种测试的主要优势是它们可以独立和本地运行,速度非常快,而且可以毫不费力地运行。同样,如果提供者有多个消费者,我们需要验证多个合同:每个消费者一个。这将帮助我们确保对提供者的更改不会破坏任何消费者服务。

Pact 是一个著名的开源框架,可以进行消费者驱动的合同测试。 Pact 有各种平台的不同实现,例如 Ruby、JVM 和.NET。我们将使用 JavaScript 版本的 Pact JS。所以让我们开始吧。让我们开始 Pact 之旅。

Pact.js 简介

我们将利用 NPM 中可用的pact模块(www.npmjs.com/package/pact)。整个过程将如下所示,我们将

需要在消费者和提供者两个级别进行操作。

我们将把我们的实现分为两部分。我们将建立一个提供者以及一个客户端,以测试服务是否相互通信:

  • 在消费者端
  1. 我们将创建一个模拟的网络服务器,它将充当服务提供者,而不是进行实际调用。 Pact.js 提供了这个功能。

  2. 对于我们想要检查的任何请求,我们将定义模拟服务需要返回的预期响应,以检查是否有任何突然的变化。在 Pact 语言中,我们称这些为交互;也就是说,对于给定的请求,消费者希望提供者返回什么?

  3. 接下来,我们创建单元测试,我们将运行我们的服务客户端与模拟提供者进行检查,以确保客户端返回这些预期值。

  4. 最后,我们将创建一个包含消费者期望的合同的pact文件。

  • 在提供者端
  1. 提供者端从消费者那里获取 pact 文件。

  2. 它需要验证它不违反消费者的预期交互。Pact.js将读取pact文件,执行每个交互的请求,并确认服务是否返回消费者期望的有效负载。

  3. 通过检查提供者不违反任何消费者的合同,我们可以确保对提供者代码的最新更改不会破坏任何消费者代码。

  4. 这样,我们可以避免集成测试,同时对我们的系统充满信心。

在了解了整个过程之后,现在让我们来实现它。我们将依次遵循关于消费者和提供者的前述步骤。完整的示例可以在chapter-8/pact-typescript中找到。我们的示例项目是类别微服务,我们将围绕它进行操作。所以,让我们开始吧:

  1. 我们首先创建一个提供者。我们将创建一个返回一些动物的服务以及一个在传递 ID 时给我动物的特定动物服务。

  2. 按照提供者的代码,通过从packt-typescript/src/provider添加provider.tsproviderService.tsrepository.ts以及从pact-typescript/data添加data.json

  3. 添加以下依赖项:

npm install @pact-foundation/pact --save
  1. 现在我们将创建一个消费者。消费者从提供者那里获取文件。我们将创建一个 Pact 服务器:
const provider = new Pact({
  consumer: "ProfileService",
  provider: "AnimalService",
  port: 8989,
  log: path.resolve(process.cwd(), "logs", "pact.log"),
  dir: path.resolve(process.cwd(), "pacts"),
  logLevel: "INFO",
  spec: 2
});
  1. 接下来,我们定义我们的期望,我们将说:
const EXPECTED_BODY = [{..//JSON response here ...//…..}]
  1. 接下来,我们编写通常的测试,但在添加测试之前,我们在 Pact 中添加这些交互:
describe('and there is a valid listing', () => {
     before((done) => {
       // (2) Start the mock server
       provider.setup()
         // (3) add interactions to the Mock Server, 
                as many as required
         .then(() => {
           return provider.addInteraction({//define interactions here })
                          .then(() => done())
  1. 接下来,我们编写通常的测试:
// write your test(s)
     it('should give a list for all animals', () => {
  // validate the interactions you've registered 
     and expected occurrance
           // this will throw an error if it fails telling you 
              what went wrong
});
  1. 关闭模拟服务器:
after(() => {provider.finalize()})
  1. 现在我们已经完成了提供者方面的工作,我们需要验证我们的提供者。启动provider服务,并在其测试文件中添加以下代码:
const { Verifier } = require('pact');
let opts = { //pact verifier options};
new Verifier().verifyProvider(opts)
              .then(function () {
                 // verification complete.
});

奖励(容器化 pact broker)

在动态环境中,我们需要跨应用程序共享 Pacts,而不是在单个应用程序中工作。为此,我们将利用 Pact broker 的功能。您可以从hub.docker.com/r/dius/pact-broker/简单地下载它。您可以使用docker pull dius/pact-broker通过 Docker 下载它。一旦启动,您可以使用curl -v http://localhost/9292 #访问经纪人,您也可以在浏览器中访问!您还可以使用数据库配置它,并运行一个组合的docker-compose.yml文件。可以在github.com/DiUS/pact_broker-docker/blob/master/docker-compose.yml找到配置为 Postgres 的 pact-broker 的演示配置。通过执行docker-compose up命令配置后,可以在端口 80 或端口 443 上访问pact broker,具体取决于是否启用了 SSL。

重新审视测试关键点

在继续本书的下一部分之前,让我们回顾一下测试的关键点:

  • 测试金字塔表示每种测试所需的测试数量。金字塔顶部的测试数量应该比它们下面的级别少。

  • 由于其更广泛的范围,系统级测试应该是缓慢和不精确的。

  • 系统级测试应该只用于为重要的业务功能提供一些测试覆盖。

  • 服务级测试比系统级测试更快,更精确,因为它们只需处理较小的范围。

  • 应该遵循一种实践,即为成功和重要的失败场景编写服务级测试。

  • 合同测试很重要,因为它们验证一个微服务对另一个微服务的 API 和行为的假设。

  • 单元测试应该快速,并且通过只包括一个单元或使用单一职责原则来保持快速。

  • 为了拥有更广泛的测试覆盖范围,总是先编写服务测试,当编写服务测试变得难以管理时再编写单元测试。

  • 我们使用 Sinon,Ava,Chai 和 Istanbul 来测试我们的微服务。

  • 要编写服务级测试:

  • 编写被测试微服务的模拟端点

  • 编写与微服务交互的场景

  • 对来自微服务的响应和它对协作者的请求进行断言

  • 通过使用 Pact,您可以编写合同级别的测试,从而避免集成测试。

  • 合同测试非常有帮助,因为它们确保微服务遵守其预先制定的合同,并且服务的任何突然变化都不会破坏任何业务功能。

  • 高级: 有时您可能需要在实时环境中尝试代码片段,无论是为了重现问题还是在真实环境中尝试代码。Telepresence (telepresence.io/) 是一个工具,允许您在 Kubernetes 中交换运行的代码。

  • 高级: Ambassador (www.getambassador.io/) 是一个 API 网关,允许微服务轻松注册其公共端点。它有各种选项,例如有关流量的统计信息,监控等。

  • 高级: Hoverfly (hoverfly.io/) 是实现微服务虚拟化的一种方式。我们可以通过它模拟 API 中的延迟和故障。

经过测试流程后,现在是时候通过调试解决问题了。我们将学习有关调试和分析微服务的内容。

调试

调试是任何系统开发中最重要的方面之一。调试或解决问题的艺术在软件开发中至关重要,因为它帮助我们识别问题、对系统进行分析,并确定导致系统崩溃的罪魁祸首。有一些关于调试的经典定义:

“调试就像解决一起谋杀案,而你是凶手。如果调试是消除错误的过程,那么软件开发就是将这些错误放入其中的过程”

  • Edsgar Dijkstra。

调试 TypeScript 微服务与调试任何 Web 应用程序非常相似。在选择开源免费替代方案时,我们将选择 node-inspector,因为它还提供非常有用的分析工具。

我们已经在第二章《为旅程做准备》中通过 VS Code 进行了调试。

在下一节中,我们将学习如何使用 node-inspector 对我们的应用程序进行分析和调试。我们将看看远程调试的各个方面,以及如何构建一个代理来调试我们的微服务。所以,让我们开始吧。

构建一个代理来调试我们的微服务

微服务是基于业务能力分布的。对于最终用户来说,它们可能看起来像是单一功能,比如购买产品,但在幕后,涉及到许多微服务,比如支付服务、加入购物车服务、运输服务、库存服务等等。现在,所有这些服务不应该驻留在单个服务器内。它们根据设计和基础设施进行分布和分发。在某些情况下,两个服务器会相互协作,如果这些服务没有受到监控,就可能在任何级别出现不良行为。这是微服务中一个非常常见的问题,我们将使用http-proxy和隧道来解决。我们将创建一个非常简单的示例,记录任何请求的原始标头。这些信息可以为我们提供有关网络实际发生了什么的宝贵信息。这个概念与我们在 API 网关中使用的非常相似。通常,API 网关是所有请求的代理;它查询服务注册表动态获取微服务的位置。这个代理层,我们的网关,有各种优势,我们在第五章《理解 API 网关》中看到了。我们将使用 node 模块http-proxywww.npmjs.com/package/http-proxy)并在那里记录请求标头。初始化一个 Node.js 项目,添加srcdisttsconfig.json文件夹,添加http-proxy模块及其类型。然后,在 index.ts 中输入以下代码以创建代理服务器。完整的代码可以在提取的源代码中找到,位于第八章/ts-http-proxy下:

export class ProxyServer {
  private proxy: any;
  constructor() {
    this.registerProxyServer();
    this.proxy = httpProxy.createProxyServer({});
    //we are passing zero server options, but we can pass lots of options such as buffer, target, agent, forward, ssl, etc. 
  }
  registerProxyServer(): void {
    http.createServer((req: IncomingMessage, res: ServerResponse) => {
      console.log("===req.rawHeaders====", req.rawHeaders);
      this.proxy.web(req, res, {
        target: 'http://127.0.0.1:3000/
            hello-world'})
        }).listen(4000)
    }}
  //after initializing make an object of this class
  new ProxyServer();

接下来,当您访问localhost:4000时,它将打印所有原始标头,您可以在源代码中检查并查看服务的响应。

在下一节中,我们将看看 Chrome 调试扩展和分析工具。

分析过程

在分析服务性能方面,分析是一个关键过程。Node.js 有一些原生工具可以对任何正在运行的 V8 进程进行分析。这些只是包含有关 V8 处理过程的统计信息的有效摘要的快照,以及 V8 在编译时如何处理该过程以及在优化运行热代码时所做的操作和决策。

我们可以通过传递--prof标志在任何进程中生成 v8 日志。prof代表配置文件。例如node --prof index.js。那不会是一个可读的格式。要创建一个更可读的格式,运行node --prof-process <v8.logfilename>.log >命令的配置文件。

在本节中,我们将学习如何使用配置文件日志进行分析、获取堆快照,并利用 Chrome 的 CPU 分析来进行微服务。所以,让我们开始吧。您可以使用node --prof <file_name>.js处理任何文件的日志。

转储堆

堆是一个巨大的内存分配。当我们谈论我们的情况时,它是分配给 V8 进程的内存(回想一下 Node.js 的工作原理-事件循环和内存分配)。通过检查内存使用情况,您可以跟踪诸如内存泄漏之类的问题,或者只是检查服务的哪个部分消耗最多,根据这一点,您可以相应地调整代码。我们有一个非常好的npm模块(github.com/bnoordhuis/node-heapdump),它可以生成一个稍后用于检查的转储。让我们熟悉读取转储过程以及何时进行转储,尽管以下步骤:

  1. 我们安装 Heap Dump 并创建一个准备好使用的转储。打开任何项目,并使用以下命令安装heapdump模块:
npm install heapdump --save and npm install @types/heapdump --save-dev
  1. 接下来,将以下代码行复制到您想要创建快照的任何进程中。我将它们保留在Application.ts中,只是一个例子。您可以在chapter8/heapdump_demo中遵循代码:
import * as heapdump from 'heapdump';
import * as path from 'path';
heapdump.writeSnapshot(path.join(__dirname, `${Date.now()}.heapsnapshot`),
  (err, filename) => {
    if (err) {
      console.log("failed to create heap snapshot");
    } else {
      console.log("dump written to", filename);
    }
  }
);
  1. 现在,当您运行程序时,您可以在我们运行前面的代码行的目录中找到快照。您将找到类似于转储写入到/home/parth/chapter 8/heapdump_demo/../<timestamp>.heapsnapshot的输出。

  2. 我们必须有类似<current_date_in_millis>.heapsnapshot的东西。它将以不可读的格式存在,但这就是我们将利用 Chrome 的 DevTools 的地方。打开 Chrome DevTools 并转到 Memory | Select profiling type | Load 选项。打开快照文件,您将能够看到以下屏幕截图:

  1. 单击 Statistics,您将能够看到这个:

您可以通过以下链接深入了解性能分析:

我们可以定期进行转储,或者在发生错误时进行转储,这将有助于找到微服务中的问题。接下来,我们将看看如何进行 CPU 分析。

CPU 分析

Chrome 开发者工具有一些非常好的选项,不仅限于调试。我们还可以利用内存分配、CPU 分析等。让我们深入研究 CPU 分析。为了理解工具,我们将启动一个消耗大量 CPU 的程序:

  1. 创建任何 express 应用程序并创建一个随机路由,基本上迭代 100 次并在内存中分配 10⁸的缓冲区。您可以在chapter 8/cpu-profiling-demo中遵循代码:
private $alloc(){
  Buffer.alloc(1e8, 'Z');
}

router.get('/check-mem',
  (req, res, next) => {
    let check = 100;
    while (check--) {
      this.$alloc()
    }
    res.status(200).send('I am Done');
  }
)
  1. 下一步是在 Chrome DevTools 中运行 Node.js 进程。要这样做,只需在node --inspect ./dist/bin/www.js中添加--inspect标志。

Chrome 调试协议包含在 Node.js 核心模块中,我们不需要在每个项目中都包含它。

  1. 打开chrome://inspect,我们将能够在其中看到我们的进程。单击 inspect,我们就可以像标准 Web 应用程序一样调试 Node.js 应用程序。

  2. 单击 Profiler,这是我们将调试 CPU 行为的地方。单击 Start,打开任何选项卡,然后点击localhost:3000/check-mem。回到我们的选项卡。当您能够看到 I am done 时,单击 Stop。您应该能够看到类似于图中的分析和分析详细信息:

性能分析

  1. 现在,将鼠标悬停在单行上,您将能够看到这样的详细视图:

分析细节

实时调试/远程调试

倒数第二个重要功能是实时调试问题。随着 Node.js 内部引入检查器协议,这变得非常容易,因为我们所要做的就是创建一个运行进程的--inspect版本。这将打印出调试打开的进程的 URL,类似于这样:

Debugger listening on ws://127.0.0.1:9229/1309f374-d358-4d41-9878-8448b721ac5c

您可以安装 Chrome 扩展程序 Node.js V8 --inspector Manager (NiM),从chrome.google.com/webstore/detail/nim-node-inspector-manage/gnhhdgbaldcilmgcpfddgdbkhjohddkj用于调试远程应用程序,或者您甚至可以生成一个用于调试的进程并指定一个端口。

node inspect --port=xxxx <file>.js

您可以在这里找到其他选项:nodejs.org/en/docs/guides/debugging-getting-started/#command-line-options。当使用--inspect开关启动任何进程时,Node.js 通过套接字侦听它,以诊断命令唯一地标识主机和端口。每个进程都被分配一个唯一的 UUID 以进行跟踪。Node-Inspector 还提供了一个 HTTP 端点来提供有关调试器的元数据,包括其 WebSocket URL、UUID 和 Chrome DevTools URL。我们可以通过访问<host:port>/json/list来获取这些信息。

调试很棒,但我们应该确保它不会带来副作用。调试意味着打开一个端口,这将带来安全隐患。应该特别注意以下几点:

  • 公开暴露调试端口是不安全的

  • 在内部运行的本地应用程序可以完全访问应用程序检查器

  • 应该保持同源策略

这结束了我们的调试和分析会话。在下一节中,我们将重新讨论关键点,然后转向文档编制。

调试的关键点

在本节中,我们看到了调试和与分析相关的核心方面。我们学习了如何诊断泄漏或观察堆转储内存以分析服务请求。我们看到了代理通常可以帮助,即使它增加了网络跳数:

  • 为了避免过载,我们有一个提供503中间件的模块。有关实现细节,请参阅github.com/davidmarkclements/overload-protection

  • Chrome Inspector 是调试 Node.js 微服务的非常有用的工具,因为它不仅提供了调试界面,还提供了堆快照和 CPU 分析。

  • VS Code 也是一个非常用户友好的工具。

  • Node.js 拥抱了 node-inspector 并将其包含在核心模块中,从而使远程调试变得非常容易。

现在我们知道了调试的基本方面,让我们继续进行开发人员生活的最后一部分。是的,你猜对了:适当的文档,这不仅为技术团队节省了一天,也为非技术人员节省了一天。

文档编制

文档是后端和前端之间的一种约定,它负责管理两侧之间的依赖关系。如果 API 发生变化,文档需要快速适应。开发中最容易出错的之一就是缺乏对其他人工作的可见性或意识。通常,传统的方法是编写服务规范文档或使用一些静态服务注册表来维护不同的内容。无论我们如何努力,文档总是过时的。

需要文档

开发文档和组织对系统的理解增加了开发人员的技能和速度,同时处理微服务采用中出现的两个最常见的挑战——技术和组织变革。彻底、更新的文档的重要性不容小觑。每当我们问别人在做任何新事物时面临的问题时,答案总是一样。我们都面临同样的问题:我们不知道这个东西是如何工作的,它是一个新的黑匣子,给出的文档毫无价值。

依赖项或内部工具的文档不完善会使开发人员的生活变成一场噩梦,并减慢他们的能力和服务的生产就绪性。这浪费了无数的时间,因为唯一剩下的方法是重新设计系统,直到我们找到解决方案。爱迪生确实说过,“我找到了 2000 种不制造灯泡的方法”,但我更愿意把时间花在找到让自己更出色的 2000 种方法上。服务的文档不完善也会影响到为其做出贡献的开发人员的生产力。

生产就绪文档的目标是制作和组织关于服务的知识的集中存储库。分享这些信息有两个方面:服务的基本部分以及服务对实现哪一部分功能的贡献。解决这两个问题需要标准化共享微服务理解的文档方法。我们可以总结以下文档要点:

  • 任何服务都应该有全面和详细的文档(应该包括服务是什么以及它对什么做出了贡献)

  • 文档应该定期更新(所有新方法和维护的版本)

  • 所有人都应该理解,而不仅仅是技术团队

  • 其架构每隔一段固定的时间进行审查和审核

在接近微服务时,随着我们将每个业务能力划分为不同的服务,痛苦呈指数级增加。我们需要一种更通用的方法来记录微服务。Swagger 目前是文档的领先者。

有了 Swagger,您将得到以下内容:

  • 不再有不一致的 API 描述。这些将被更新为完整的合同细节和参数信息。

  • 您将不再需要编写任何文档;它将自动生成。

  • 当然,再也不会有关于文档不完善的争论了。

本节将探讨如何使用 Swagger,了解其核心工具、优势和工作实现。所以,让我们开始吧。

Swagger 101

Swagger 是您的微服务或者任何 RESTful API 的强大表示。成千上万的开发人员支持 Swagger 几乎在每一种编程语言和环境中。有了 Swagger-enabled 环境,我们可以得到交互式文档、客户端 SDK 生成、可发现性和测试。

Swagger 是 Open API 倡议的一部分(一个标准化 REST API 应该如何描述的委员会)。它提供了一组工具来描述和记录 RESTful API。Swagger 最初是一个 API 文档工具,现在还可以通过 Swagger Codegen(https://github.com/wcandillon/swagger-js-codegen)生成样板代码。Swagger 有一个庞大的工具生态系统,但主要我们将使用以下一组工具。我们将了解如何将 Swagger 与现有应用程序集成,或者编写符合 Swagger 标准的 API,通过这些 API 我们的文档将自动生成。从以下图表中可以了解到涉及的整个过程:

Swagger_workflow

现在让我们看一下涉及过程的整体工具,以便全面了解其中涉及的所有方面。

Swagger 编辑器和描述符

Swagger Descriptor 采用了设计驱动开发的方法。在这里,我们通过在 YML/YAML 文件或 JSON 文件中描述它们来设计我们端点的行为。(当然,作为开发人员,我甚至懒得写这个文件,我更希望它是自动生成的,我们将在后面的部分中看到。)这是最重要的部分,因为它是有关服务的上下文信息。

查看第八章/hello_world_swagger.yaml以了解描述文件的内容。

Swagger 和描述符的关键点

  • 您的 URL 路由、参数和描述都在.yaml文件中定义。

  • 无论参数是否必需,您都可以使用 required true 进行传递,这将在测试参数时进行验证

  • 它还可以返回响应代码及其描述

  • Swagger 读取这个.yaml文件来生成其 Swagger UI 并使用 Swagger 检查器测试服务

Swagger Editor

Swagger Editor 是一个在线工具,可以帮助您

您可以通过在浏览器中预览实时文档来编辑 Swagger API 规范。这样,我们可以看到应用最新更改后文档的实际外观。编辑器具有清晰的界面,易于使用,并具有许多功能,可设计和记录各种微服务。它可以在线访问:editor2.swagger.io/#!/。只需编写或导入一个swagger.yaml文件,我们就可以实时查看 Swagger UI。

让我们通过 Swagger Editor 和 Swagger Descriptor 动手:

  1. 打开editor2.swagger.io,并输入我们之前的描述符(hello_world_swagger.yaml)。

  2. 您将能够在右侧看到实时文档:

Swagger Editor

  1. 尝试在描述符文件中插入更多代码,并查看交互式文档。另外,尝试运行“尝试此操作”。它将提供 HTTP 请求的描述以及所有标头和响应。

Swagger Codegen

Swagger Codegen 是一个脚手架引擎,它可以根据 Swagger 定义生成交互式文档、API 客户端和服务器存根。我们在 Swagger Editor 中看到的以前的选项(生成服务器和生成客户端)类似于 Swagger Codegen 的实现。它支持许多语言。

客户端脚手架工具,支持 TypeScript Angular、TypeScript Node、JavaScript、Python、HTML、Java 和 C#等语言。服务器端脚手架工具支持 Haskell、Node.js、Go 语言和 Spring 等语言。

Swagger CodeGen (swagger.io/swagger-codegen/)帮助我们更快地构建 API,并通过遵循 OpenAPI 定义的规范来提高质量。它生成服务器存根和客户端 SDK,因此我们可以更专注于 API 实现和业务逻辑,而不是代码创建和采用标准:

  • Swagger CodeGen 的优势

  • 它生成服务器代码、客户端代码和文档

  • 它允许更快地更改 API

  • 生成的代码是开源的

  • Swagger CodeGen 的缺点

  • 通过添加额外的工具和库以及管理这些工具的复杂性,项目复杂性增加了

  • 它可能会生成用户无法消化的大量代码

您可以查看第八章/typescript-node-client/api.ts,以查看基于我们最初的 Swagger 描述符定义生成的自动生成代码。

Swagger UI

Swagger UI 允许我们可视化 RESTful API。可视化是从 Swagger 规范自动生成的。Swagger UI 接收 Swagger 描述文件并在 UI 中使用 Swagger 检查器创建文档。Swagger UI 就是我们在前面截图中右侧看到的内容。此外,这可以根据权限进行访问。Swagger UI 是一组 HTML、JavaScript 和 CSS 资源,可以从符合 Swagger 的 API 动态生成美丽的文档。我们将为我们的产品目录微服务生成文档,并在其中使用 Swagger UI 组件。

Swagger 检查器

这是一种基于 OpenAPI 规范生成文档的无痛方式。一旦您检查了 SWAGGER 检查器的工作原理,然后您可以创建文档并与世界分享。我们可以通过选择历史记录中先前测试过的端点来轻松自动生成文档,然后发出创建 API 定义的命令。这在网上很像 Postman。您可以将 Swagger 检查器作为 Chrome 扩展程序下载。它具有以下选项:

Swagger 检查器

现在我们已经熟悉了 Swagger,让我们看看如何在微服务中使用 Swagger 为我们生成美丽的文档。接下来的部分讨论了我们可以集成 Swagger 的可能方法。

使用 Swagger 的可能策略

Swagger 主要用于记录服务和测试服务。在实施 Swagger 时有两种基本方法。它们如下:

  • 自上而下或设计优先方法:在这里,使用 Swagger 编辑器创建 Swagger 定义,然后使用 Swagger Code-gen 生成客户端和服务器的代码。在编写任何代码之前,Swagger 将用于设计 API 和源。

  • 自下而上方法:在这里,对于任何现有的 API,Swagger 用于生成文档。

我们将研究这两种方法以及我们可以使用的最佳实践。

自上而下或设计优先方法

通常,通过添加几行代码来生成有效的 Swagger 文件和文档似乎是一个好主意。我们已经编写了所有的代码,然后我们记得:天哪,我要如何向其他人解释这个?我需要记录每一个 API 吗?在这种情况下,通过添加注释来实时生成文档似乎是一个梦想成真。TSOA(www.npmjs.com/package/tsoa)就是基于这样的原则设计的。根据 TSOA 的 README 文件,它从编写的控制器和包括以下内容的模型生成有效的 Swagger 规范。这本质上是一种自下而上的方法,我们已经有了现有的 REST API,并且利用 Swagger 来记录现有的 API。

TSOA 从控制器和模型生成有效的 Swagger spec文件,其中包括:

  • 各种 REST URL 的路径(例如:获取用户:- server_host/users/get_users

  • 基于 TypeScript 接口的定义(这些是模型文件或属性描述符)

  • 参数类型;也就是说,根据 TypeScript 语法,模型属性标记为必需或可选(例如,productDescription?: string在 Swagger 规范中标记为可选)

  • jsDoc 支持对象描述(大多数其他元数据可以从 TypeScript 类型中推断出)

与 routing-controllers 类似,路由可以为我们选择的任何中间件生成。选项包括 Express、Hapi 和 Koa。与 routing-controllers 类似,TSOA 内置了类验证器。TSOA 尽可能地减少样板代码,并提供了大量的注释。您可以在npm中查看文档,以详细了解各种可用的选项。我们主要关注@Route注释,它将为我们生成 Swagger 文档。在示例中,我们将使用 TSOA 并生成文档。

请参阅自上而下方法的提取源,示例非常简单,严格遵循文档。

自下而上的方法

哇!经过自上而下的方法,似乎是完美的计划。但是当我们已经开发了项目,现在我们想要生成我们的文档时怎么办呢?我们陷入了困境。我们该怎么办呢?幸运的是,我们有解决方案。我们将利用swagger-ui-express (www.npmjs.com/package/swagger-ui-express)来生成文档。它每周有超过 45,000 次下载。这是一个由社区驱动的包,为您的 express 应用程序提供中间件,根据 Swagger 文档文件提供 Swagger UI。我们需要添加一个路由,用于托管 Swagger UI。文档很好,一切都在那里——我们需要的一切。所以,让我们开始吧。您可以在Chapter 8/bottom-up-swagger文件夹中跟随源代码。

  1. npm中安装模块作为依赖项:
npm install swagger-ui-express --save
  1. 接下来,我们需要添加一个路由,用于托管 Swagger UI。我们需要生成 Swagger 定义,并在每次部署时更新它。

  2. 我们有两种选项来生成 Swagger 文档。要么我们在每个路由处理程序中添加注释,要么我们使用 Swagger inspector 来测试所有 REST API,将它们合并,并生成一个定义文件。

  3. 无论我们选择哪种路线,我们的目标都是相同的:生成swagger.json文件。采用第一种方法,我们将使用swagger-jsdoc (www.npmjs.com/package/swagger-jsdoc)。通过以下命令将模块作为依赖项下载:

npm install swagger-jsdoc --save
  1. 让我们开始配置。首先,我们需要在 Express 启动时初始化 Swagger JS Doc。创建一个类SwaggerSpec,并在其中添加以下代码:
export class SwaggerSpec {
  private static swaggerJSON: any;
  constructor() { }
  static setUpSwaggerJSDoc() {
    let swaggerDefinition = {
      info: {
        title: 'Bottom up approach Product Catalog',
        version: '1.0.0',
        description: 'Demonstrating TypeScript microservice bottom up approach'
      },
      host: 'localhost:8081',
      basePath: '/'
    };
    let options = {
      swaggerDefinition: swaggerDefinition,
      apis: ['./../service-layer/controllers/*.js']
    }
    this.swaggerJSON = swaggerJSDoc(options);
  }

  static getSwaggerJSON() {
    return this.swaggerJSON;
  }
}

在这里,我们初始化了 JSDoc,并将swagger.json存储在私有静态变量swaggerJSON:any中,这样在需要提供 JSON 时就可以使用它。我们在JSDoc对象中保留了通常的配置。

  1. 接下来,在 express 启动时,我们需要初始化setUpSwaggerJSDoc方法,这样我们就可以在服务器启动时填充 JSON。

  2. 创建一个新的Controller,它会给我们提供swagger.json作为 HTTP 端点。

@JsonController('/swagger')
export class SwaggerController {
  constructor() { }
  @Get('/swagger.json')
  async swaggerDoc( @Req() req, @Res() res) {
    return SwaggerSpec.getSwaggerJSON();
  }
}
  1. 访问http://localhost:8081/swagger/swagger.json以查看初始的 Swagger JSON。

  2. 现在,我们需要在每个路由中添加 JSDoc 风格的注释以生成 Swagger 规范,并在路由处理程序中添加 YAML 注释。添加适当的注释将填充我们的swagger.json

/**
* @swagger
* definitions:
* Product:
* properties:
* name:
* type:string
* /products/products-listing:
* get:
* tags:
* - Products
* description: Gets all the products
* produces:
* - application/json
* responses:
* 200:
* description: An array of products
* schema:
* $ref: '#/definitions/Product'
*/
getProductsList() {
 //
}
  1. 另一个选择是使用 Swagger inspector 生成文档。现在我们已经完成了 Swagger 生成,我们需要生成 Swagger UI。在Express.ts中添加以下内容:
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
app.use('/api/v1', router);

Swagger 是一个很好的文档工具,可以满足我们所有的需求。无论是从一开始使用还是在开发之后使用,它都是满足我们文档需求的好选择。./api/v1文件将为您生成 Swagger 文档。

从 Swagger 定义生成项目

到目前为止,我们是从我们的源代码中生成 swagger 定义。反过来也是成立的。我们可以轻松地从 Swagger 定义和语言类型中生成项目(我们在第七章中看到了类似的内容,服务状态和服务间通信。有印象吗?没错。rPC 和代码生成)。让我们下载 swagger-code-generate 并创建我们的项目:

  1. 检查提取的 src chapter 8/swagger-code-gen中更新的hello_world_swagger.yml。它增加了一个用于更新产品信息的 API 路由/端点。

  2. 下一步是从github.com/swagger-api/swagger-codegen下载 swagger-code-gen,这样我们甚至可以将其配置为自动化或根据需要使用,而不是每次都去在线 Swagger 编辑器。你也可以在本书的提取源中找到 swagger-code-gen。

  3. 由于这是一个在 JVM 上运行的项目,我们构建项目以便运行它。输入命令mvn package来构建 JAR。

  4. 接下来,我们将生成源代码:

java -jar modules/swagger-codegen-cli/target/swagger-codegen-cli.jar generate -i  ..\hello_world_swagger.yaml -l typescript-node -o ../typescript-nodejs
  1. 你可以在chapter-8/swagger-code-gen中探索typescript-nodejs,以了解生成的结构并进行实际操作。同样,你也可以选择任何其他语言。更多文档可以在这里找到github.com/swagger-api/swagger-codegen/blob/master/README.md

Swagger 是一个很棒的工具,可以按需生成文档。生成的文档即使对于产品经理或合作伙伴也是易懂的,可读性强,且易于调整。它不仅使我们的生活变得更加轻松,而且使 API 更易消费和管理,因为它符合 OpenAPI 规范。Swagger 被 Netflix、Yelp、Twitter 和 GitHub 等领先公司广泛使用。在本节中,我们看到了它的各种用途以及其周期和各种方法。

总结

在本章中,我们讨论了测试、调试和文档编制。我们研究了测试的一些基本方面。我们研究了测试金字塔以及如何进行单元测试、集成测试和端到端测试。我们使用 Pact 进行了契约测试。然后,我们看了一下调试和分析过程,这对解决关键问题非常有帮助。我们看到了在关键故障发生时如何进行调试。最后,我们看了一下文档工具 Swagger,它有助于保持中央文档,并且我们研究了引入 Swagger 到我们的微服务的策略。

在下一章中,我们将讨论部署。我们将看到如何部署我们的微服务,介绍 Docker,并了解 Docker 的基础知识。然后,我们将了解一些监控工具和日志选项。我们将集成 ELK 堆栈以进行日志记录。

第九章:部署、日志记录和监控

“没有战略的战术是失败前的噪音。”

  • 孙子

在上线生产并开始赚取收入之前,我们需要一个非常强大的部署策略。缺乏计划总是会导致意外紧急情况,从而导致严重的失败。这就是我们在本章中要做的事情。现在我们已经完成了开发工作,并通过测试和提供文档添加了双重检查,我们现在要着手进行上线阶段。我们将看到部署中涉及的所有方面,包括当前流行的术语——持续集成、持续交付和新的无服务器架构。然后我们将看到日志的需求以及如何创建自定义的集中式日志解决方案。更进一步,我们将看看Zipkin——一个用于分布式系统日志记录的新兴工具。最后,我们将看到监控的挑战。我们将研究两个著名的工具——KeymetricsPrometheus

本章涵盖以下主题:

  • 部署 101

  • 构建流水线

  • Docker 简介

  • 无服务器架构

  • 日志记录 101

  • 使用 ELK 进行定制日志记录

  • 使用 Zipkin 进行分布式跟踪

  • 监控 101

  • 使用 Keymetrics、Prometheus 和 Grafana 等工具进行监控

部署

在生产环境中发布一个应用程序,有足够的信心它不会崩溃或让组织损失资金,这是开发者的梦想。即使是手动错误,比如没有加载正确的配置文件,也会造成巨大问题。在本节中,我们将看到如何自动化大部分事情,并了解持续集成和持续交付(CI 和 CD)。让我们开始了解整体构建流水线。

决定发布计划

自信是好事,但过分自信是不好的。在部署到生产环境时,我们应该随时准备好回滚新的更改,以防出现重大关键问题。需要一个整体的构建流水线,因为它可以帮助我们规划整个过程。在进行生产构建时,我们将采用这种技术:

构建流水线

整个构建过程始于开始块。每当发生任何提交时,WebHooks(由 Bitbucket 和 GitHub 提供)会触发构建流水线。Bitbucket 也有构建流水线工具(bitbucket.org/product/features/pipelines)。这个构建流水线可以在主分支合并时触发。一旦到达构建阶段,我们首先运行一些代码覆盖分析和单元测试。如果测试结果不符合要求的 SLA,我们会中止流程。如果符合整体 SLA,我们就会根据它创建一个镜像,并在暂存服务器上构建它(如果我们没有暂存服务器,我们可以直接移动到生产服务器)。一旦你有一个准备好的 Docker 镜像,你就根据你部署的位置设置环境。之后,运行一些理智检查以确保我们不部署破损的代码。在流水线的所有级别上运行它们是一个极好的想法,可以最大程度地减少错误的机会。现在,一旦服务符合 SLA,现在是时候在真实环境中部署它了。我通常遵循的一个良好实践是生产服务器不应该有版本控制。根据我们使用的任何工具(OpenShift、Kubernetes、Docker 等),我们将这些工具传递给它们来启动镜像。然后我们需要开始集成测试,其中包括检查容器是否健康以及与服务注册表和 API 网关检查服务是否注册。为了确保没有任何破坏,我们需要进行滚动更新,其中我们逐个部署新实例并移除旧实例。我们的代码库应该能够处理旧/遗留代码,并且只有在每个依赖方都接受后才能废弃它。完成集成测试后,下一个任务涉及运行契约测试和验收测试。一旦这些测试成功运行,我们就可以从暂存环境移动到生产环境或上线。如果流水线失败,上一个成功的源代码将作为回滚策略部署回来。

整个过程应该是自动化的,因为我们更容易出错。我们将研究 CI/CD 以及它们如何让我们的生活变得更加轻松。CI/CD 承诺,我们可以在功能完成时部署它,并且仍然相当有信心它不会破坏产品。我们所看到的流水线有大量与之相关的任务和阶段。让我们看看以下阶段:

  • 开发阶段/功能分支:我们通过创建功能分支来开始开发。我们保持主分支不变,并且只在主分支中保留经过验证和测试的代码。这样,我们的生产环境就是主分支的复制品,我们可以在开发分支中进行任意数量的实验。如果某些东西失败了,我们总是可以回到主分支并丢弃或删除一个分支。

  • 测试阶段/QA 分支:一旦我们的开发完成,我们将代码推送到 QA 分支。现代开发方法更进一步,我们采用 TDD/BDD。每当我们将代码推送到 QA 时,我们运行测试用例以获得精确的代码覆盖率。我们运行一些代码检查工具,这些工具给我们一个关于代码质量的想法。在所有这些之后,如果这些测试成功,那么我们才将代码推送到 QA 分支。

  • 发布阶段/主分支:一旦我们的 QA 完成并且我们的测试用例覆盖通过了,我们将代码推送到主分支,希望将其推送到生产环境。我们再次运行我们的测试用例和代码覆盖工具,并检查是否有任何破坏。一旦成功,我们将代码推送到生产服务器并运行一些冒烟测试和契约测试。

  • 发布/标签:一旦代码推送到生产环境并成功运行,我们会为发布创建一个分支/标签。这有助于确保我们可以在不久的将来返回到这一点。

在每个阶段手动进行这样的过程是一个繁琐的过程。我们需要自动化,因为人类容易出错。我们需要一个持续交付机制,其中我的代码中的一个提交可以确保我部署的代码对我的生态系统是安全的。在下一节中,我们将看看持续集成和持续交付:

  • 持续集成:这是将新功能从其他分支集成或合并到主分支,并确保新更改不会破坏现有功能的实践。一个常见的 CI 工作流程是,除了代码,您还编写测试用例。然后创建代表更改的拉取请求。构建软件可以运行测试,检查代码覆盖率,并决定拉取请求是否可接受。一旦拉取请求(PR)合并,它就进入 CD 部分,即持续交付。

  • 持续交付:这是一种方法,我们旨在随时无缝交付一小块可测试且易于部署的代码。CD 是高度可自动化的,在某些工具中,它是高度可配置的。这种自动化有助于快速将组件、功能和修复程序分发给客户,并让任何人对生产环境中有多少以及有什么有一个确切的想法。

随着 DevOps 的不断改进和容器的兴起,出现了许多新的自动化工具来帮助 CI/CD 流水线。这些工具与日常工具集成,例如代码存储库管理(GitHub 可以与 Travis 和 CircleCI 一起使用,Bitbucket 可以与 Bitbucket pipelines 一起使用)以及跟踪系统,如 slack 和 Jira。此外,出现了一个新的趋势,即无服务器部署,开发人员只需关注他们的代码和部署,其他问题将由提供者解决(例如,亚马逊有 AWS,谷歌有 GCP 函数)。在下一节中,我们将看看各种可用的部署选项。

部署选项

在这一部分,我们将看一些著名的可用部署选项,并了解它们各自的优势和劣势。我们将从容器的世界开始,看看为什么现在所有东西都是 docker 化的。所以,让我们开始吧。

在开始之前,让我们先了解一下 DevOps 101,以便理解我们将要使用的所有术语。

DevOps 101

在这一部分,我们将了解一些基本的 DevOps 基础知识。我们将了解什么是容器以及它有什么优势。我们将看到容器和虚拟机之间的区别。

容器

随着云计算的进步,世界正在看到容器系统的重新进入。由于技术的简化(Docker 遵循与 GIT 相同的命令),容器已被广泛采用。容器在操作系统之上提供私有空间。这种技术也被称为系统中的虚拟化。容器是构建、打包和运行隔离的机制(软件仅驻留和限制在该容器中)。容器处理自己的文件系统、网络信息、内置内部进程、操作系统实用程序和其他应用程序配置。容器内部装载多个软件。

容器具有以下优势:

  • 独立的

  • 轻量级

  • 易于扩展

  • 易于移动

  • 更低的许可和基础设施成本

  • 通过 DevOps 自动化

  • 像 GIT 一样进行版本控制

  • 可重复使用

  • 不可变的

容器与虚拟机(VMs)

虽然鸟瞰图似乎两者都在说同样的事情,但容器和虚拟机(VM)有很大的不同。虚拟机提供硬件虚拟化,例如 CPU 数量、内存存储等。虚拟机是一个独立的单元,还有操作系统。虚拟机复制完整的操作系统,因此它们很重。虚拟机为在其上运行的进程提供完全隔离,但它限制了可以启动的虚拟机数量,因为它很重且消耗资源,并且需要维护。与虚拟机不同,容器共享内核和主机系统,因此容器的资源利用率非常低。容器作为在主机操作系统之上提供隔离层,因此它们是轻量级的。容器镜像可以公开使用(有一个庞大的 Docker 存储库),这使得开发人员的生活变得更加轻松。容器的轻量特性有助于自动化构建、在任何地方发布构件、根据需要下载和复制等。

Docker 和容器世界

虚拟化是 DevOps 中目前最大的趋势之一。虚拟化使我们能够在各种软件实例之间共享硬件。就像微服务支持隔离一样,Docker 通过创建容器来提供资源隔离。使用 Docker 容器进行微服务可以将整个服务以及其依赖项打包到容器中,并在任何服务器上运行。哇!安装软件在每个环境中的日子已经过去了。Docker 是一个开源项目,用于在新环境中轻松打包、运输和运行任何应用程序作为轻量级容器,而无需安装所有东西。Docker 容器既不依赖于平台也不依赖于硬件,这使得可以轻松地在任何地方运行容器,从笔记本电脑到任何服务器,而无需使用任何特定的语言框架或打包软件。当今,容器化通常被称为 dockerization。我们已经从第二章开始进行了 docker 化,为旅程做准备。因此,让我们了解涉及的整个过程和概念。

我们已经在第二章中看到了 Docker 的安装,为旅程做准备。现在,让我们深入了解 Docker。

Docker 组件

Docker 有以下三个组件:

  • Docker 客户端:Docker 客户端是一个命令行程序,实际上通过套接字通信或 REST API 与 Docker 主机内的 Docker 守护程序进行通信。使用具有 CLI 选项的 Docker 客户端来构建、打包、运输和运行任何 Docker 容器。

  • Docker 主机:Docker 主机基本上是一个服务器端组件,包括一个 Docker 守护程序、容器和镜像:

  • Docker 守护程序是在主机机器上运行的服务器端组件,包含用于构建、打包、运行和分发 Docker 容器的脚本。Docker 守护程序为 Docker 客户端公开了 RESTful API,作为与其交互的一种方式。

  • 除了 Docker 守护程序,Docker 主机还包括在特定容器中运行的容器和镜像。无论哪些容器正在运行,Docker 主机都包含这些容器的列表,以及启动、停止、重启、日志文件等选项。Docker 镜像是那些从公共存储库构建或拉取的镜像。

  • Docker 注册表:注册表是一个公开可用的存储库,就像 GitHub 一样。开发人员可以将他们的容器镜像推送到那里,将其作为公共库,或者在团队之间用作版本控制。

在下图中,我们可以看到所有三个 Docker 组件之间的整体流程:

Docker 组件和流程

以下是典型的 Docker 流程:

  1. 每当我们运行诸如sudo docker run ubuntu /bin/echo 'hello carbon five!'的命令时,该命令会传递给守护进程。它会尝试搜索是否存在具有名称 Ubuntu 的现有镜像。如果没有,它会转到注册表并在那里找到镜像。然后它将在主机内下载该容器镜像,创建一个容器,并运行echo命令。它会将 Ubuntu 镜像添加到 Docker 主机内可用的镜像列表中。

  2. 我们的大多数镜像都将基于 Docker Hub 存储库(hub.docker.com/)中的可用镜像。除非非常需要,我们不会重新发明轮子。Docker pull 会向 Docker 主机发出命令,从存储库中拉取特定镜像,并使其在 Docker 主机的镜像列表中可用。

  3. docker build命令从 Dockerfile 和可用的上下文构建 Docker 镜像。构建的上下文是指在 Dockerfile 中指定的路径或 URL 中的文件集。构建过程可以引用上下文中的任何文件。例如,在我们的情况下,我们下载了 Node.js,然后根据package.json执行了npm install。Docker 构建创建一个镜像,并使其在 Docker 主机内的镜像列表中可用。

Docker 概念

现在我们已经了解了核心的 Docker 流程,让我们继续了解 Docker 涉及的各种概念。这些概念将使我们更容易编写 Docker 文件并创建自己的微服务容器镜像:

  • Docker 镜像:Docker 镜像只是 Docker 业务能力组成部分的快照。它是操作系统库、应用程序及其依赖项的只读副本。一旦创建了镜像,它将在任何 Docker 平台上运行而不会出现任何问题。例如,我们的微服务的 Docker 镜像将包含满足该微服务实现的业务能力所需的所有组件。在我们的情况下,Web 服务器(NGINX)、Node.js、PM2 和数据库(NoSQL 或 SQL)都已配置为运行时。因此,当有人想要使用该微服务或在某处部署它时,他们只需下载镜像并运行它。该镜像将包含从 Linux 内核(bootfs)到操作系统(Ubuntu/CentOS)再到应用程序环境需求的所有层。

  • Docker 容器:Docker 容器只是 Docker 镜像的运行实例。您可以下载(或构建)或拉取 Docker 镜像。它在 Docker 容器中运行。容器使用镜像所在的主机操作系统的内核。因此,它们基本上与在同一主机上运行的其他容器共享主机内核(如前图所示)。Docker 运行时确保容器具有其自己的隔离的进程环境以及文件系统和网络配置。

  • Docker Registry:Docker Registry 就像 GitHub 一样,是 Docker 镜像发布和下载的中心位置。hub.docker.com是 Docker 提供的中央可用的公共注册表。就像 GitHub(提供版本控制的存储库),Docker 也提供了一个特定于需求的公共和私有镜像存储库(我们可以将我们的存储库设为私有)。我们可以创建一个镜像并将其注册到 Docker Hub。因此,下次当我们想在任何其他机器上使用相同的镜像时,我们只需引用存储库来拉取镜像。

  • Dockerfile:Dockerfile 是一个构建或脚本文件,其中包含了构建 Docker 镜像的指令。可以记录多个步骤,从获取一些公共镜像到在其上构建我们的应用程序。我们已经编写了 Docker 文件(回想一下第二章中的.Dockerfile为旅程做准备)。

  • Docker Compose:Compose 是 Docker 提供的一个工具,用于在一个容器内运行多容器 Docker 应用程序。以我们的产品目录微服务为例,我们需要一个 MongoDB 容器以及一个 Node.js 容器。Docker compose 正是为此而设计的。Docker compose 是一个三步过程,我们在 Docker 文件中定义应用程序的环境,在docker-compose.yml中使其他服务在隔离的环境中运行,然后使用docker-compose up运行应用程序。

Docker 命令参考

现在我们已经了解了 Docker 的概念,让我们来学习 Docker 命令,以便我们可以将它们添加到我们的实验中:

命令 功能
docker images 查看我的机器上所有可用的 Docker 镜像。
docker run <options> <docker_image_name>:<version> <operation> 将 Docker 镜像启动到容器中。
docker ps 检查 Docker 容器是否正在运行。
docker exec -ti <container-id> bash 通过实际在 bash 提示符上运行来查看 Docker 镜像内部的内容。能够使用诸如lsps之类的命令。
docker exec <container_id> ifconfig 查找 Docker 容器的 IP 地址。
docker build 根据.DockerFile中的指令构建镜像。
docker kill <containername> && docker rm <containername> 终止正在运行的 Docker 容器。
docker rmi <imagename> 从本地存储库中删除 Docker 镜像。
docker ps -q &#124; x args docker kill &#124; xargs docker rm 终止所有正在运行的 Docker 容器。

使用 NGINX、Node.js 和 MongoDB 设置 Docker

现在我们知道了基本命令,让我们为一个带有 NGINX 的产品目录服务编写 Dockerfile 和 Docker compose 文件,以处理负载平衡,就像我们在第四章中为 MongoDB 和 Node.js 编写docker compose up一样,开始您的微服务之旅。您可以按照第九章/Nginx-node-mongo中的示例进行操作,该示例只是在产品目录微服务的副本上添加了 NGINX,以便服务只能通过 NGINX 访问。创建以下结构:

NGINX-mongodb-node.js 文件结构

现在让我们写一些规则:

  1. 我们将为 Node.js 创建 Dockerfile。它将与我们之前使用的内容相同。

  2. 我们将为 NGINX 编写 Dockerfile。我们基本上告诉 NGINX 启用sites-enabled文件夹中定义的应用程序的规则:

FROM tutum/nginx
RUN rm /etc/nginx/sites-enabled/default
COPY nginx.conf /etc/nginx.conf
RUN mkdir /etc/nginx/ssl
COPY certs/server.key /etc/nginx/ssl/server.key
COPY certs/server.crt /etc/nginx/ssl/server.crt
ADD sites-enabled/ /etc/nginx/sites-enabled
  1. 接下来,我们在 NGINX 中定义一些加固规则,以便处理我们的负载平衡以及缓存和其他需求。我们将在两个地方编写我们的规则——nodejs_projectnginx.conf。在nodejs_project中,我们定义所有代理级别设置和 NIGINX 服务器设置。在nodejs_project中写入以下代码:
server {
listen 80;
server_name product-catalog.org;
access_log /var/log/nginx/nodejs_project.log;
charset utf-8;
location / {
proxy_pass http://chapter9-app:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}}
  1. 让我们看一些用于配置 NGINX 以用于生产级别(加固我们的 Web 服务器)的示例规则。我们将这些规则写在nginx.conf中。为了压缩发送到我们的 NGINX 服务器的所有输入和输出请求,我们使用以下代码:
http {...
gzip on;
gzip_comp_level 6;
gzip_vary on;
gzip_min_length 1000;
gzip_proxied any;
gzip_types text/plain text/html text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;
gzip_buffers 16 8k;
...
}

前面的参数只是配置了任何入站或出站的 HTTP 请求,具有这些属性。例如,它将对响应进行 gzip 压缩,对所有类型的文件进行 gzip 压缩等。

  1. 无论服务器之间交换了什么资源,我们都有选项将其缓存,这样每次都不需要再次查询。这是在 Web 服务器层进行缓存:
http {
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=one:8m max_size=3000m inactive=600m;
proxy_temp_path /var/tmp;
}
  1. 最后,我们创建我们的docker compose文件来启动 MongoDB、Node.js 和 NGINX 来定义。从源中复制docker-compose.yml文件以执行构建。

  2. 打开终端,输入docker-compose up --build,看看我们的部署实际运行情况。

所有内部端口现在都将被阻止。唯一可访问的端口是默认端口80。访问localhost/products/products/products-listingURL 以查看我们的部署实时运行。再次访问 URL,将从缓存中加载响应。请参阅以下屏幕截图:

缓存响应

现在我们已经使用包含 Web 层的容器映像运行起来了,在接下来的部分中,我们将看一下我们的构建流水线以及 WebHooks 在其中扮演的重要角色。

我们构建流水线中的 WebHooks

WebHooks 是项目中可以用来绑定事件的东西,无论何时发生了什么。比如一个拉取请求被合并,我们想立即触发一个构建 - WebHooks 就可以做到这一点。WebHook 本质上是一个 HTTP 回调。您可以通过转到设置并添加 WebHook 来在存储库中配置 WebHook。典型的 WebHook 屏幕如下所示:

WebHook

如前面的屏幕截图所示,它有各种触发器,例如推送、分叉、更新、拉取请求、问题等。我们可以根据这个 WebHook 设置警报并触发各种操作。

在下一节中,我们将看到微服务开发中出现的新趋势,即无服务器部署。

请检查提取的源/流水线,以查看端到端流水线的运行情况。

无服务器架构

这些天出现的新趋势是无服务器拓扑结构。这并不实际上意味着无服务器或没有服务器。服务器被用户抽象化,用户只关注开发方面,其他一切都交给供应商。AWS Lambda 就是无服务器架构的一个例子,您只需将微服务打包为 ZIP 并上传到 AWS Lambda。亚马逊会处理其他事情,包括启动足够的实例来处理大量服务请求。

Lambda 函数是一个无状态函数。它通过调用 AWS 服务来处理请求。我们只需根据请求次数和提供这些请求所花费的时间来计费。同样,Google 也有云函数。但是,这种模式有以下优点和缺点:

  • 优点:

  • 我们只关注代码,不需要担心底层基础设施的细节。AWS 具有内置的网关,可与 Lambda 函数一起使用。

  • 极具弹性的架构。它自动处理负载请求。

  • 您只需为每个请求付费,而不是租用整个虚拟机并每月付费。

  • 缺点:

  • 仅支持少数语言。没有多语言环境的自由。

  • 这些始终是无状态的应用程序。AWS Lambda 不能用于像 RabbitMQ 这样的队列处理。

  • 如果应用程序启动不够快,无服务器架构就不适合我们。

这基本上就是部署的内容。在下一节中,我们将看一下日志记录以及如何创建定制的集中式日志记录解决方案。

日志记录

微服务完全分布式,作为单个请求可以触发对其他微服务的多个请求,跟踪失败或故障的根本原因或跨所有服务的请求流程变得困难。

在本节中,我们将学习如何通过正确的方式记录不同的 Node.js 微服务。回顾我们在第四章中看到的日志记录概念和日志类型,开始您的微服务之旅。我们将朝着这个方向前进,并创建一个集中式日志存储。让我们首先了解在分布式环境中我们的日志记录需求以及我们将遵循的一些最佳实践来处理分布式日志。

日志记录最佳实践

一旦在开发后出现任何问题,我们将完全迷失,因为我们不是在处理单个服务器。我们正在处理多个服务器,整个系统不断移动。哇!我们需要一个完整的策略,因为我们不能随意到处走动,检查每个服务的日志。我们完全不知道哪个微服务在哪个主机上运行,哪个微服务提供了请求。要在所有容器中打开日志文件,搜索日志,然后将其与所有请求相关联,这确实是一个繁琐的过程。如果我们的环境启用了自动扩展功能,那么调试问题将变得非常复杂,因为我们实际上必须找到提供请求的微服务实例。

以下是微服务日志记录的一些黄金规则,这将使生活更轻松。

集中和外部化日志存储

微服务分布在生态系统中,以简化开发并实现更快的开发。由于微服务在多个主机上运行,因此在每个容器或服务器级别都记录日志是不明智的。相反,我们应该将所有生成的日志发送到一个外部和集中的位置,从那里我们可以轻松地从一个地方获取日志信息。这可能是另一个物理系统或任何高可用性存储选项。一些著名的选项包括以下内容:

  • ELK 或弹性堆栈:ELK 堆栈(www.elastic.co/elk-stack)由 Elasticsearch(一个分布式、全文可扩展搜索数据库,允许存储大量数据集)、Logstash(它从多种来源收集日志事件,并根据需要进行转换)、和 Kibana(可视化存储在 Elasticsearch 中的日志事件或任何其他内容)组成。使用 ELK 堆栈,我们可以在由KibanaLogstash提供的 Elasticsearch 中拥有集中的日志。

  • CloudWatch(仅当您的环境在 AWS 中时):Amazon CloudWatch(aws.amazon.com/cloudwatch/)是用于监视在 AWS 环境中运行的资源和应用程序的监控服务。我们可以利用 Amazon CloudWatch 来收集和跟踪指标,监视日志文件,设置一些关键警报,并自动对 AWS 资源部署中的更改做出反应。CloudWatch 具有监视 AWS 资源的能力,其中包括 Amazon EC2 实例、DynamoDB 表、RDS 数据库实例或应用程序生成的任何自定义指标。它监视所有应用程序的日志文件。它提供了系统级别的资源利用情况可见性,并监视性能和健康状况。

日志中的结构化数据

日志消息不仅仅是原始消息,还应包括一些内容,如时间戳;日志级别类型;请求所花费的时间;元数据,如设备类型、微服务名称、服务请求名称、实例名称、文件名、行号;等等,从中我们可以在日志中获取正确的数据来调试任何问题。

通过相关 ID 进行标识

当我们进行第一次服务请求时,我们会生成一个唯一标识符或相关 ID。生成的唯一 ID 会传递给其他调用的微服务。这样,我们可以使用来自响应的唯一生成的 ID 来获取指定于任何服务请求的日志。为此,我们有一个所谓的相关标识符或唯一生成的 UUID,将其传递给事务经过的所有服务。要生成唯一 ID,NPM 有模块 UUID(www.npmjs.com/package/uuid)。

日志级别和日志机制

根据应用程序的不同方面,我们的代码需要不同的日志级别,以及足够的日志语句。我们将使用winstonwww.npmjs.com/package/winston),它将能够动态更改日志级别。此外,我们将使用异步日志附加器,以便我们的线程不会被日志请求阻塞。我们将利用异步钩子nodejs.org/api/async_hooks.html),它将帮助我们跟踪我们的进程中资源的生命周期。异步钩子使我们能够通过向任何生命周期事件注册回调来监听任何生命周期事件。在资源初始化时,我们会得到一个唯一的标识符 ID(asyncId)和创建资源的父标识符 ID(triggerAsyncId)。

可搜索的日志

在一个地方收集的日志文件应该是可搜索的。例如,如果我们得到任何 UUID,我们的日志解决方案应该能够根据它来查找请求流程。现在,让我们看看我们将要实现的定制日志解决方案,并了解它将如何解决我们的日志问题:

日志定制流

图表解释了核心组件及其定义的目的。在进入实施部分之前,让我们先看看所有组件及其目的:

  • 日志仪表板:它是我们定制的中央日志解决方案的 UI 前端。我们将在 Elasticsearch 数据存储之上使用 Kibana(www.elastic.co/products/kibana),因为它提供了许多开箱即用的功能。我们将能够使用已记录的任何参数搜索索引日志。

  • 日志存储:为了实现实时日志记录和存储大量日志,我们将使用 Elasticsearch 作为我们定制日志解决方案的数据存储。Elasticsearch 允许任何客户端根据基于文本的索引查询任何参数。另一个著名的选项是使用 Hadoop 的MapReduce程序进行离线日志处理。

  • 日志流处理器:日志流处理器分析实时日志事件,用于快速决策。例如,如果任何服务持续抛出 404 错误,流处理器在这种情况下非常有用,因为它们能够对特定的事件流做出反应。在我们的情况下,流处理器从我们的队列获取数据,并在发送到 Elasticsearch 之前即时处理数据。

  • 日志发货人:日志发货人通常收集来自不同端点和来源的日志消息。日志发货人将这些消息发送到另一组端点,或将它们写入数据存储,或将它们推送到流处理端点进行进一步的实时处理。我们将使用 RabbitMQ 和 ActiveMQ 等工具来处理日志流。现在我们已经看到了我们定制实现的架构,在下一节中我们将看到如何在我们当前的应用程序中实现它。所以,让我们开始吧。

集中式定制日志解决方案实施

在本节中,我们将看到定制日志架构的实际实施,这是我们在上一节中看到的。所以,让我们开始我们的旅程。作为一组先决条件,我们需要安装以下软件:

  • Elasticsearch 6.2.4

  • Logstash 6.2.4

  • Kibana 6.2.4

  • Java 8

  • RabbitMQ 3.7.3

设置我们的环境

我们在上一节讨论了相当多的软件。我们需要确保每个软件都已正确安装并在各自的端口上正常运行。此外,我们需要确保 Kibana 知道我们的 Elasticsearch 主机,Logstash 知道我们的 Kibana 和 Elasticsearch 主机。让我们开始吧:

  1. www.elastic.co/downloads/elasticsearch下载 Elasticsearch 并将其提取到所选位置。提取后,通过eitherelasticsearch.bat./bin/elasticsearch启动服务器。访问http://localhost:9200/,您应该能够看到 JSON 标语:You Know, for Search,以及 Elasticsearch 版本。

  2. 接下来是 Kibana。从www.elastic.co/downloads/kibana下载 Kibana 并将其提取到所选位置。然后打开<kibana_home>/config/kibana.yml并添加一行elasticsearch.url: "http://localhost:9200"。这告诉 Kibana 关于 Elasticsearch。然后从bin文件夹启动 Kibana 并导航到http://localhost:5601。您应该能够看到 Kibana 仪表板。

  3. www.elastic.co/downloads/logstash下载 Logstash。将其提取到所选位置。我们将通过编写一个简单的脚本来检查 Logstash 的安装。创建一个文件logstash-simple.conf,并编写以下代码。您可以在第九章/logstash-simple.conf中找到此片段:

input { stdin { } }
output { elasticsearch { hosts => ["localhost:9200"] }
stdout { codec => rubydebug }}

现在运行logstash -f logstash-simple.conf

您应该能够看到 Elasticsearch 信息的打印输出。这确保了我们的 Logstash 安装正常运行。

  1. 接下来,我们需要安装 RabbitMQ。RabbitMQ 是用 Erlang 编写的,需要安装 Erlang。安装 Erlang 并确保环境变量ERLANG_HOME已设置。然后安装 RabbitMQ。安装完成后,按以下步骤启动rabbitmq服务:
rabbitmq-service.bat stop
rabbitmq-service.bat install
rabbitmq-service.bat start
  1. 现在访问http://localhost:15672。您应该能够使用默认的 guest/guest 凭据登录,并且能够看到 RabbitMQ 仪表板。

如果您无法看到服务器,则可能需要启用插件,如下所示:

rabbitmq-plugins.bat enable rabbitmq_management rabbitmq_web_mqtt rabbitmq_amqp1_0

我们已成功安装了 RabbitMQ、Logstash、Elasticsearch 和 Kibana。现在我们可以继续我们的实施。

请检查提取的源代码/customlogging,以查看我们解决方案的运行情况。该解决方案利用了我们之前解释的架构。

Node.js 中的分布式跟踪

分布式跟踪就像跟踪跨越涉及提供该请求的所有服务的特定服务请求一样。这些服务构建了一个图形,就像它们形成了一个以启动初始请求的客户端为根的树。Zipkin 提供了一个仪表层,用于为服务请求生成 ID,基于这个 ID,我们可以通过使用该 ID 跟踪所有应用程序的数据。在本节中,我们将看看如何使用 Zipkin。您可以在第九章/Zipkin中找到完整的源代码:

  1. 从第四章 开始您的微服务之旅中启动我们的第一个微服务或任何单个微服务项目。我们将向其添加zipkin依赖项:
npm install zipkin zipkin-context-cls zipkin-instrumentation-express zipkin-instrumentation-fetch zipkin-transport-http node-fetch --save
npm install @types/zipkin-context-cls --save-dev
  1. 现在我们需要一个 Zipkin 服务器。我们将配置它以使用 Zipkin 服务器以及其默认设置,并只安装其 jar。从https://search.maven.org/remote_content?g=io.zipkin.java&a=zipkin-server&v=LATEST&c=exec下载jar,或者您可以在第九章/zipkinserver文件夹下找到它。下载完成后,按以下步骤打开 Zipkin 服务器:
java -jar zipkin-server-2.7.1-exec.jar

以下屏幕截图显示了一个 Zipkin 服务器:

记录 Zipkin

如屏幕截图所示,Zipkin 服务器有很多选项,包括提供用于接收跟踪信息的收集器、存储和 UI 选项以检查它。

  1. 现在,我们将配置多个 Express 服务器,以观察 Zipkin 如何仪器化整个过程。我们将首先在单个微服务上设置 Zipkin,然后稍后在多个微服务上设置。我们在上一章的代码中将任何产品信息添加到我们的 MongoDB 数据库中。我们将在这里配置 Zipkin。我们需要告诉 Zipkin 要发送跟踪数据的位置(这是显而易见的!这将是运行在9411上的我们的 Zipkin 服务器)以及如何发送跟踪数据(这是个问题——Zipkin 有三种支持选项 HTTP、Kafka 和 Fluentd。我们将使用 HTTP)。因此,基本上我们向 Zipkin 服务器发送一个 POST 请求。

  2. 我们需要一些导入来配置我们的 Zipkin 服务器。打开Express.ts并添加以下代码行:

import {Tracer} from 'zipkin';
import {BatchRecorder} from 'zipkin';
import {HttpLogger} from 'zipkin-transport-http';
const CLSContext = require('zipkin-context-cls');
  • Tracer用于提供诸如何在哪里以及如何发送跟踪数据的信息。它处理生成traceIds并告诉传输层何时记录什么。

  • BatchRecorder格式化跟踪数据以发送到 Zipkin 收集器。

  • HTTPLogger是我们的 HTTP 传输层。它知道如何通过 HTTP 发布 Zipkin 数据。

  • CLSContext对象是指 Continuation Local Storage。Continuation passing 是指函数调用链中的下一个函数使用它需要的数据的模式。其中一个例子是 Node.js 自定义中间件层。

  1. 我们现在正在将所有部分放在一起。添加以下代码行:
const ctxImpl=new CLSContext();
const logRecorder=new BatchRecorder({
logger:new HttpLogger({
endpoint:`http://loclhost:9411/api/v1/spans` }) })
const tracer=new Tracer({ctxImpl:ctxImpl,recorder:logRecorder})

这将设置 Zipkin 基本要素以及将生成 64 位跟踪 ID 的跟踪器。现在我们需要为我们的 Express 服务器进行仪器化。

  1. 现在,我们将告诉我们的express应用程序在其中间件层中使用ZipkinMiddleware
import {expressMiddleware as zipkinMiddleware} from 'zipkin-instrumentation-express';
...
this.app.use(zipkinMiddleware({tracer,serviceName:'products-service'}))

在我们的情况下,服务的名称'products-service'实际上将出现在跟踪数据中。

  1. 让我们调用我们的服务,看看实际结果是什么。运行程序,向products/add-update-product发出 POST 请求,并打开 Zipkin。您将能够在服务名称下拉菜单中看到products-service(我们在 Zipkin 服务器下注册的服务名称)。当您进行搜索查询时,您将能够看到类似以下内容的东西:

Zipkin 服务日志

这就是当我们处理一个微服务时的情况。您在这里也会得到有关成功和失败服务调用的跟踪,就像图中所示的那样。我们希望能够理解涉及多个微服务的服务。

对于直接运行代码的人,请确保在ProductsController.tslet文件中注释掉以下行—userRes= await this.zipkinFetch('http://localhost:3000/users/user-by-id/parthghiya');console.log("user-res",userRes.text());

  1. 假设在我们的情况下,我们还涉及另一个微服务,基于我们的业务能力,它与所有者的真实性有关。因此,每当添加产品时,我们希望检查所有者是否是实际用户。

我们将只创建两个带有虚拟逻辑的项目。

  1. 创建另一个带有用户的微服务项目,并使用@Get('/user-by-id/:userId')创建一个 GET 请求,该请求基本上返回用户是否存在。我们将从现有项目中调用该微服务。您可以从chapter-9/user中跟随。

  2. 在现有项目中,我们将 Zipkin 的配置移出到外部文件中,以便在整个项目中重复使用。查看ZipkinConfig.ts的源代码

  3. ProductController.ts中,实例化一个新的 Zipkin 仪器化 fetch 对象,如下所示:

import * as wrapFetch from 'zipkin-instrumentation-fetch';
this.zipkinFetch = wrapFetch(fetch, {
tracer,
serviceName: 'products-service'
});
  1. 进行 fetch 请求,如下所示:
let userRes= await this.zipkinFetch('http://localhost:3000/users/user-by-id/parthghiya');
  1. 打开 Zipkin 仪表板,您将能够看到以下内容:

Zipkin 组合

点击请求即可查看整体报告:

跟踪报告

追踪是一个无价的工具,它可以通过跟踪整个微服务生态系统中的任何请求来帮助诊断问题。在下一节中,我们将了解监控微服务。

监控

微服务是真正分布式系统,具有庞大的技术和部署拓扑。如果没有适当的监控,运营团队可能很快就会遇到管理大规模微服务系统的麻烦。为了给我们的问题增加复杂性,微服务根据负载动态改变其拓扑。这需要一个适当的监控服务。在本节中,我们将了解监控的需求,并查看一些监控工具。

监控 101

让我们从讨论监控 101 开始。一般来说,监控可以被定义为一些指标、预定义的服务水平协议(SLAs)、聚合以及它们的验证和遵守预设的基线值的集合。每当服务水平出现违规时,监控工具必须生成警示并发送给管理员。在本节中,我们将查看监控,以了解系统的用户体验方面的行为,监控的挑战,以及了解 Node.js 监控所涉及的所有方面。

监控挑战

与记录问题类似,监控微服务生态系统的关键挑战在于有太多的动态部分。由于完全动态,监控微服务的主要挑战如下:

  • 统计数据和指标分布在许多服务、多个实例和多台机器或容器上。

  • 多语言环境增加了更多的困难。单一的监控工具无法满足所有所需的监控选项。

  • 微服务部署拓扑在很大程度上不同。诸如可伸缩性、自动配置、断路器等多个参数会根据需求基础改变架构。这使得不可能监控预配置的服务器、实例或任何其他监控参数。

在接下来的部分,我们将看一下监控的下一个部分,即警示。由于错误,我们不能每次都发出警示。我们需要一些明确的规则。

何时警示何时不警示?

没有人会因为某些事情阻止客户使用系统并增加资金而在凌晨 3 点被吵醒而感到兴奋。警示的一般规则可以是,如果某事没有阻止客户使用您的系统并增加您的资金,那么这种情况不值得在凌晨 3 点被吵醒。在本节中,我们将查看一些实例,并决定何时警示何时不警示:

  • 服务宕机:如果是单体化,这肯定会是一个巨大的打击,但作为一个优秀的微服务编码人员,您已经设置了多个实例和集群。这只会影响一个用户,该用户会在服务请求后再次获得功能,并防止故障级联。但是,如果许多服务宕机,那么这绝对值得警示。

  • 内存泄漏:内存泄漏是另一件令人痛苦的事情,只有经过仔细监控,我们才能真正找到泄漏。良好的微服务实践建议设置环境,使其能够在实例超过一定内存阈值后停用该实例。问题将在系统重新启动时自行解决。但是,如果进程迅速耗尽内存,那么这是值得警示的事情。

  • 服务变慢:一个慢的可用服务不值得警示,除非它占用了大量资源。良好的微服务实践建议使用基于事件和基于队列的异步架构。

  • 400 和 500 的增加:如果 400 和 500 的数量呈指数增长,那么值得警示。4xx 代码通常表示错误的服务或配置错误的核心工具。这绝对值得警示。

在下一节中,我们将看到 Node.js 社区中可用的监控工具的实际实现。我们将在 Keymetrics 和 Grafana 中看到这些工具的实际示例。

监控工具

在这一节中,我们将看一些可用的监控工具,以及这些工具如何帮助我们解决不同的监控挑战。在监控微服务时,我们主要关注硬件资源和应用程序指标:

硬件资源
内存利用率指标
CPU 利用率指标
磁盘利用率指标
应用程序指标
每单位时间抛出的错误
每单位时间的调用次数/服务占用率
响应时间
服务重启次数

LINUX 的强大使得查询硬件指标变得容易。Linux 的/proc文件夹中包含了所有必要的信息。基本上,它为系统中运行的每个进程都有一个目录,包括内核进程。那里的每个目录都包含其他有用的元数据。

当涉及到应用程序指标时,很难使用一些内置工具。一些广泛使用的监控工具如下:

  • AppDynamics、Dynatrace 和 New Relic 是应用程序性能监控领域的领导者。但这些都是商业领域的。

  • 云供应商都有自己的监控工具,比如 AWS 使用 Amazon Cloudwatch,Google Cloud 平台使用 Cloud monitoring。

  • Loggly、ELK、Splunk 和 Trace 是开源领域中的热门候选者。

现在我们将看一些 Node.js 社区中可用的工具。

PM2 和 keymetrics

我们已经看到了 PM2 的强大之处,以及它如何帮助我们解决各种问题,比如集群、使 Node.js 进程永远运行、零停机时间等等。PM2 也有一个监控工具,可以维护多个应用程序指标。PM2 引入了 keymetrics 作为一个完整的工具,具有内置功能,如仪表板、优化过程、来自 keymetrics 的代码操作、异常报告、负载均衡器、事务跟踪、CPU 和内存监控等等。它是一个基于 SAAS 的产品,有免费套餐选项。在这一节中,我们将使用免费套餐。所以,让我们开始吧:

  1. 我们需要做的第一件事是注册免费套餐。创建一个账户,一旦你登录,你就能看到主屏幕。注册后,我们将来到一个屏幕,在那里我们配置我们的 bucket。

一个 bucket 是一个容器,上面连接了多个服务器和多个应用程序。一个 bucket 是 keymetrics 定义上下文的东西。例如,我们的购物车微服务有不同的服务(支付、产品目录、库存等等)托管在某个地方,我们可以监控一个 bucket 中的所有服务器,这样一切都很容易访问。

  1. 一旦我们创建了我们的 bucket,我们将会得到一个像下面这样的屏幕。这个屏幕上有所有启动 keymetrics 所需的信息和必要的文档:

创建 bucket 后的 Keymetrics

我们可以看到连接 PM2 到 keymetrics 和 Docker 与 keymetrics 的命令,我们将在接下来使用:

pm2 link <your_private_key> <your_public_key>
docker run -p 80:80 -v my_app:/app keymetrics/pm2 -e "KEYMETRICS_PUBLIC=<your_public_key>" -e "KEYMETRICS_SECRET=<your_secret_key>" 

作为安装的一部分,你将需要 PM2 监视器。一旦安装了 PM2,运行以下命令:

pm2 install pm2-server-monit
  1. 下一步是配置 PM2 将数据推送到 keymetrics。现在,为了启用服务器和 keymetrics 之间的通信,需要打开以下端口:需要打开端口 80(TCP 输出)和 43554(TCP 输入/输出)。PM2 将数据推送到 keymetrics 的端口80,而 keymetrics 将数据推送回端口43554。现在,我们将在我们的产品目录微服务中配置 keymetrics。

  2. 确保在您的系统中安装了 PM2。如果没有,请执行以下命令将其安装为全局模块:

npm install pm2 -g
  1. 然后通过执行以下命令将您的 PM2 与 keymetrics 连接起来:
pm2 link 7mv6isclla7z2d0 0rb928829xawx4r
  1. 一旦打开,只需更改您的package.json脚本,以使用 PM2 而不是简单的 node 进程启动。只需在package.json中添加以下脚本:
"start": "npm run clean && npm run build && pm2 start ./dist/index.js",

一旦作为 PM2 进程启动,您应该能够看到进程已启动和仪表板 URL:

使用 keymetrics 启动 PM2

  1. 转到 keymetrics,您将能够看到实时仪表板:

Keymetrics 仪表板

  1. 它为我们提供了有趣的指标,比如 CPU 使用率、可用内存、HTTP 平均响应时间、可用磁盘内存、错误、进程等等。在接下来的部分,我们将看看如何利用 keymetrics 来解决我们的监控挑战。

Keymetrics 监控应用程序异常和运行时问题

尽管 PM2 在保持服务器运行良好方面做得很好,但我们需要监视所有发生的未知异常或潜在的内存泄漏源。PMX 正好提供了这个模块。您可以在第九章/pmx-utilities中查看示例。像往常一样初始化pmx。只要有错误发生,就用notify方法通知pmx

pmx.notify(new Error("Unexpected Exception"));

这足以向 keymetrics 发送错误,以便提供有关应用程序异常的信息。您也将收到电子邮件通知。

PMX 还监视服务的持续使用,以便检测内存泄漏。例如,检查路由/memory-leak

以下显示了几个重要的 keymetrics 亮点:

Pmx 实用程序

添加自定义指标

最后,我们将看到如何根据我们的业务能力和需求添加自定义指标。大多数情况下,我们经常需要一些定制,或者我们无法使用现成的功能。Keymetrics 为我们提供了用于此目的的探针。在 keymetrics 中,探针是以编程方式发送到 keymetrics 的自定义指标。我们将看到四种探针及其示例:

  • 简单指标:可以立即读取的值,用于监视任何变量值。这是一个非常基本的指标,开发人员可以为推送到 keymetrics 的数据设置一个值。

  • 计数器:递增或递减的事物,比如正在处理的下载、已连接的用户、服务请求被命中的次数、数据库宕机等。

  • 计量器:被视为事件/间隔进行测量的事物,比如 HTTP 服务器每分钟的请求次数等。

  • 直方图:它保留了一个与统计相关的储备,特别偏向于最后五分钟,以探索它们的分布,比如监控最近五分钟内查询执行的平均时间等。

我们将使用pmxwww.npmjs.com/package/pmx)来查看自定义指标的示例。PMX 是 PM2 运行器的主要模块之一,允许公开与应用程序相关的指标。它可以揭示有用的模式,有助于根据需求扩展服务或有效利用资源。

简单指标

设置 PM2 指标值只是初始化一个探针并在其中设置一个值的问题。我们可以通过以下步骤创建一个简单的指标。您可以在第九章/简单指标中查看源代码:

  1. 从第二章复制我们的first microservice骨架,为旅程做准备。我们将在这里添加我们的更改。安装pm2pmx模块作为依赖项:
npm install pm2 pmx -save
  1. HelloWorld.ts中,使用以下代码初始化pmx。我们将添加一个简单的度量名称'Simple Custom metric'以及变量初始化:
constructor(){
this.pmxVar=pmx.init({http:true,errors:true, custom_probes:true,network:true,ports:true});
this.probe=this.pmxVar.probe();
this.metric=this.probe.metric({ name:'Simple custom metric' });}

我们用一些选项初始化了 pmx,比如以下内容:

  • http:HTTP 路由应该被记录,并且 PM2 将被启用来执行与 HTTP 相关的度量监视

  • errors:异常日志记录

  • custom_probes:JS 循环延迟和 HTTP 请求应该自动公开为自定义度量

  • 端口:它应该显示我们的应用正在监听的端口

  1. 现在你可以在任何地方使用以下方法初始化这个值:
this.metric.set(new Date().toISOString());

现在你可以在 keymetrics 仪表板中看到它,如下所示:

简单度量

计数器度量

这个度量是非常有用的,可以看到事件发生的次数。在这个练习中,我们将看到我们的/hello-world被调用的次数。你可以在Chapter 9/counter-metric中的示例中跟着做:

  1. 像往常一样初始化项目。添加pmx依赖项。创建一个带有路由控制器选项的CustomMiddleware
import { ExpressMiddlewareInterface } from "routing-controllers";
 const 
 pmx=require('pmx').init({http:true,errors:true, custom_probes:true,network:true,ports:true}); 

const pmxProbe=pmx.probe();
 const pmxCounter=pmxProbe.counter({
    name:'request counter for Hello World Controller',
    agg_type:'sum'}) 

export class CounterMiddleWare implements ExpressMiddlewareInterface {
    use(request: any, response: any, next: (err?: any) => any ):any {
        console.log("custom middle ware");
        pmxCounter.inc();
      next();   }} 
  1. HelloWorld.ts之前添加注释并运行应用程序:
@UseBefore(CounterMiddleWare)
@Controller('/hello-world')
export class HelloWorld { ... }

你应该能够看到类似以下的东西:

计数器度量

计量

这个度量允许我们记录事件实际发生的时间以及每个时间单位内事件发生的次数。计算平均值非常有用,因为它基本上给了我们一个关于系统负载的想法。在这个练习中,我们将看一下如何利用计量度量:

  1. 像往常一样初始化项目。安装pmxpm2依赖项。它包括以下关键字:
  • 样本:此参数对应于我们想要测量指标的间隔。在我们的案例中,这是每分钟的呼叫次数,因此是60

  • 时间范围:这是我们想要保存 keymetrics 数据的时间长度,它将被分析的总时间范围。

在构造函数中添加以下代码以初始化计量器度量依赖项:

this.pmxVar=pmx.init({http:true,errors:true,custom_probes:true,network:true,ports:true});
  this.probe=this.pmxVar.probe();
 this.metric=this.probe.meter({
 name: 'averge per minute',
 samples:60,
 timeframe:3600 }) 
  1. 在路由中,@Get('/')将初始化这个标记。这将给我们一个路由<server_url>/hello-world每分钟平均呼叫次数。

  2. 现在运行这个度量。你将能够在 keymetrics 仪表板中看到这个值。同样,你可以使用直方图度量。

在下一节中,我们将看一下更高级的可用工具。

Prometheus 和 Grafana

Prometheus 是一个著名的开源工具,它为 Node.js 监控提供了强大的数据压缩选项以及快速的时间序列数据查询。Prometheus 具有内置的可视化方法,但它的可配置性不足以在仪表板中利用。这就是 Grafana 的作用。在本节中,我们将看一下如何使用 Prometheus 和 Grafana 监控 Node.js 微服务。所以让我们开始动手编码吧。你可以在源代码中的Chapter 9/prometheus-grafana中的示例中跟着做:

  1. 像往常一样,从chapter-2/first microservice初始化一个新项目。添加以下依赖项:
npm install prom-client response-time --save

这些依赖项将确保我们能够监控 Node.js 引擎,并能够从服务中收集响应时间。

  1. 接下来,我们将编写一些中间件,用于跨微服务阶段使用,比如在 Express 中注入,并在后期使用中间件。创建一个MetricModule.ts文件,并添加以下代码:
import * as promClient from 'prom-client';
 import * as responseTime from 'response-time';
 import { logger } from '../../common/logging'; 

export const Register=promClient.register;
 const Counter=promClient.Counter;
 const Histogram=promClient.Histogram;
 const summary=promClient.Summary; 
  1. 接下来我们将创建一些自定义函数用作中间件。在这里,我们将创建一个函数;你可以在Chapter 9/prometheus-grafana/config/metrics-module/MetricModule.ts中查看其他函数:
//Function 1
 export var numOfRequests=new Counter({
    name:'numOfRequests',
    help:'Number of requests which are made through out the service',
    labelNames:['method']
 }) 
/*Function 2  to start metric collection */
 export var startCollection=function(){
    logger.info(" Metrics can be checked out at /metrics");
    this.promInterval=promClient.collectDefaultMetrics(); } 

/*THis function 3 increments the counters executed */
 export var requestCounters=function(req:any,res:any,next:any){
    if(req.path!='metrics'){
        numOfRequests.inc({method:req.method});
        totalPathsTakesn.inc({path:req.path});
   }   next();} 
//Function 4: start collecting metrics 
export var startCollection=function(){
  logger.info(" Metrics can be checked out at /metrics");
    this.promInterval=promClient.collectDefaultMetrics();} 

看一下前面代码中提到的以下函数:

  • 第一个函数启动一个新的计数器变量

  • 第二个功能启动 Prometheus 指标

  • 第三个功能是一个中间件,用于增加请求的数量

  • 除了指标路由之外的功能计数器

  1. 接下来,我们添加指标路由:
@Controller('/metrics')
 export class MetricsRoute{
    @Get('/')
    async getMetrics(@Req() req:any,@Res() res:any):Promise<any> {
        res.set('Content-Type', Register.contentType);
        res.end(Register.metrics());   };} 
  1. 接下来,我们在express应用程序中注入中间件。在express.ts中,只需添加以下 LOC:
..
this.app.use(requestCounters);
this.app.use(responseCounters)
..
startCollection()
  1. Node.js 设置完成。现在是启动 Prometheus 的时候了。创建一个名为prometheus-data的文件夹,在其中创建一个yml 配置文件:
Scrape_configs:
 - job_name: 'prometheus-demo'
   scrape_interval: 5s
   Static_configs:
     - targets: ['10.0.2.15:4200']
       Labels:
         service: 'demo-microservice'
         group: 'production'
  1. 通过运行以下命令来启动 Docker 进程:
sudo docker run -p 9090:9090 -v /home/parth/Desktop/prometheus-grafana/prometheus-data/prometheus.yml prom/prometheus
  1. 您的 Prometheus 应该已经启动并运行,并且您应该看到以下屏幕:

Prom 仪表板

  1. 在应用程序上执行一些操作,或者使用一些压力测试工具,如 JMeter 或www.npmjs.com/package/loadtest。然后打开 Prometheus,在查询 shell 中写入sum(numOfRequests)。您将能够看到实时图形和结果。这些结果与我们访问<server_url>/metrics时看到的结果相同。尝试使用以下查询来查看 Node.js 内存使用情况avg(nodejs_external_memory_bytes / 1024 / 1024) by (service)

  2. Prometheus 很棒,但不能用作仪表板。因此,我们使用 Grafana,它具有出色的可插拔可视化平台功能。它具有内置的 Prometheus 数据源支持。输入以下命令以打开 Grafana 的 Docker 镜像:

docker run -i -p 3000:3000 grafana/grafana

一旦启动,转到localhost:3000,并在用户名/密码中添加admin/admin以登录。

  1. 登录后,添加一个类型为 Prometheus 的数据源(打开“添加数据源”屏幕),并在 HTTP URL(您的 Prometheus 运行 URL)中输入 IP 地址:9090,在“访问”文本框中输入“服务器(默认)”(您访问 Prometheus 的方式),以配置 Prometheus 作为数据源。单击保存并测试以确认设置是否有效。您可以查看以下屏幕截图以更好地理解:

Grafana

  1. 一旦配置了数据源,您可以通过 GUI 工具自定义图形或其他内容,并设计自己的自定义仪表板。它将如下所示:

Grafana

Prometheus 不仅是监控单个 Node.js 应用程序的强大工具,还可以在多语言环境中使用。使用 Grafana,您可以创建最适合您需求的仪表板。

这些是在 Node.js 监控部署中使用的重要工具。还有其他工具,但整合它们需要多语言环境。例如,Simian Army。它被 Netflix 广泛使用和推广,用于处理各种云计算挑战。它构建了各种类猴工具来维护网络健康,处理流量,并定位安全问题。

可投入生产的微服务标准

我们将快速总结一个可投入生产的微服务及其标准:

  • 一个可投入生产的微服务对服务请求是可靠和稳定的:

  • 它遵循符合 12 因素应用标准的标准开发周期(回顾第一章,揭秘微服务

  • 它的代码经过严格的测试,包括 linter、单元测试用例、集成、合同和端到端测试用例

  • 它使用 CI/CD 流水线和增量构建策略

  • 在服务失败的情况下,有备份、替代、回退和缓存

  • 它具有符合标准的稳定的服务注册和发现过程

  • 一个可投入生产的微服务是可扩展和高可用的:

  • 它根据任何时间到来的负载自动扩展

  • 它有效利用硬件资源,不会阻塞资源池

  • 它的依赖随着应用程序的规模而扩展

  • 它的流量可以根据需要重新路由

  • 它以高性能的非阻塞和最好是异步的反应方式处理任务和进程

  • 一个可以立即投入生产的微服务应该准备好应对任何未经准备的灾难:

  • 它没有任何单点故障

  • 它经过足够的代码测试和负载测试来测试其弹性

  • 故障检测,阻止故障级联,以及故障修复都已经自动化,并且具备自动扩展能力

  • 一个可以立即投入生产的微服务应该得到适当的监控:

  • 它不仅在微服务级别不断监控其识别的关键指标(自定义指标,错误,内存占用等),还扩展到主机和基础设施级别

  • 它有一个易于解释的仪表板,并且具有所有重要的关键指标(你打赌,PM2 是我们唯一的选择)

  • 通过信号提供阈值(Prometheus 和时间序列查询)定义可操作的警报

  • 一个可以立即投入生产的微服务应该有文档支持:

  • 通过 Swagger 等工具生成的全面文档

  • 架构经常审计和审查,以支持多语言环境

总结

在本章中,我们了解了部署过程。我们看到了一些上线标准,部署流水线,并最终熟悉了 Docker。我们看到了一些 Docker 命令,并熟悉了 Docker 化的世界。然后,我们看到了处理大型分布式微服务时涉及的一些日志记录和监控方面的挑战。我们探索了各种日志记录的解决方案,并实施了使用著名的 ELK 堆栈的自定义集中式日志记录解决方案。在本章的后半部分,我们看到了一些监控工具,比如 keymetrics 和 Prometheus。

下一章将探讨我们产品的最后部分:安全性和可扩展性。我们将看到如何保护我们的 Node.js 应用程序免受暴力攻击,以及我们的安全计划应该是什么。然后,我们将研究可扩展性,并通过 AWS 实现微服务的自动扩展。

第十章:加固您的应用程序

要做好安全性是非常困难的。总是似乎有一些开放的门让入侵者溜进来。安全错误一直都在发生,比如著名的WannaCry 勒索软件攻击(造成 50 亿美元的损失)、以太坊盗窃(3,200 万美元的抢劫)等等。这些攻击总是让我们采取额外的步骤来加强安全,以避免这样的灾难。由于微服务是动态的,任何实例都可能崩溃导致业务损失。

本章重点关注处理安全性和自动扩展,探讨了一些安全基础知识和微服务最佳实践,以使系统更安全和健壮,并使其能够轻松处理任何数量的流量。随着容器的出现,我们还将关注容器级别的安全性,以及应用程序级别的安全性。本章还着重介绍了自动扩展,旨在使应用程序随时可用以处理任何负载,在新部署期间实现零停机。本章涵盖以下内容:

  • 在应用安全机制时应该问的问题

  • 个别服务/应用程序的安全最佳实践

  • 容器的安全最佳实践

  • 扩展您的应用程序

在应用安全时应该问的问题

在不断发展的世界中,我们不能有一套预定义的规则来应用于微服务设计。相反,我们可以提出一些预定义的问题,以评估整个系统和流程。以下各节列出了各个级别的所有标准问题,我们可以将其用作评估检查表。稍后,我们将根据这些问题的解决方案升级我们的安全性。

核心应用程序/核心微服务

我们将从最核心的地方开始——我们的微服务。每当我们编写任何微服务来满足任何业务能力时,一旦设计完成,我们需要注意服务是否暴露给任何漏洞。以下问题可以用来对应用程序级别的安全性进行一般了解:

  • 系统是否在所有地方都得到了适当的安全保障,还是只在边界处?

  • 如果入侵者溜进来,系统是否足够强大以侦测到并将其驱逐出去?

  • 入侵者有多容易通过模仿正常行为、获取流量访问或过载流量来进入网络?

  • 即使它们也经常调用它们,每个微服务是否都信任其他微服务?

  • 您的服务契约是否有身份验证,还是网络处理身份验证?

  • 调用者的身份是否传递给每个微服务,还是仅在网关处丢失?

  • 确保不发生 SQL 注入的措施是什么?

  • 系统是否更新到足以以加密形式存储密码?

  • 如果我们需要升级任何密码存储算法,是否可以在不造成大规模中断用户的情况下进行?

  • 系统中如何处理私人和敏感数据?

  • 您的日志记录解决方案是否能够检测和分析安全漏洞?

中间件

下一个级别是我们的中间件。这是所有服务都将通过的中心位置或起点。我们需要确保中间件是安全的,不能暴露给任何风险,因为它有各种参数,如消息中间件、数据库访问配置等等:

  • 我们是否遵循最小权限原则(即,跨所有服务只有一个数据库登录)?

  • 每个服务只能访问它需要的数据吗?

  • 如果入侵者获得了服务数据库凭据,他们将获得多少数据访问权限?

  • 我们是否在所有服务中都有一个单一的消息中间件?

  • 消息中间件或服务总线是否有登录凭据?

  • 传统系统是否将微服务系统置于风险之中?

API 网关

下一个级别是我们的 API 网关。网关在微服务中扮演着重要的角色,因为它是任何请求的起点,并且被所有微服务用作它们之间的通信手段。因此,它不应该暴露给任何其他安全漏洞:

  • 是否有 TLS 实现?

  • TLS 实现是否消除了降级攻击或弱密码攻击?

  • 您如何确保内部网站和管理员 URL 被抽象到互联网上?

  • 通过您的网关服务的身份验证 API 传播了哪些信息?

  • 其余的服务是否过于信任网关,或者他们能否发现网关被攻破了?

团队和运营活动

最后阶段是团队和运营活动。由于分布式的性质,每个团队都是独立工作的。在这种情况下,每个团队都有足够的安全培训成为一个必要的先决条件。以下问题有助于我们评估运营层面的安全性:

  • 如何将安全活动融入到每个开发团队中?

  • 您如何确保每个人都了解常见的安全原则?

  • 您给团队提供了什么安全培训,并且是否会及时更新他们的漏洞信息?

  • 您使用什么自动化级别来确保安全控制始终存在?

在下一节中,我们将看看如何加固应用程序和容器,并了解各种安全最佳实践。

个别服务/应用的安全最佳实践

微服务架构改变了复杂性。与单一的非常复杂的系统不同,有一堆简单的服务和复杂的交互。我们的目标是确保复杂性受到控制并在范围内。安全确实很难做到。有无数种方法可以侵入应用程序。Node.js 也不例外。在本节中,我们将看看如何防止安全漏洞。本节旨在作为一个基本的检查表,以确保我们的微服务解决了一些最大的安全威胁。所以,让我们开始吧。

检查已知的安全漏洞

由于npm中有大量的模块可用,我们可以直接在应用程序上工作,并依赖生态系统提供现成的解决方案。然而,由于庞大的模块,即使对于成熟的流行框架,也可能随时出现较大的安全漏洞。在本节中,我们将看看一些有价值的工具,以确保应用程序依赖的软件包中没有漏洞,甚至在更新时也没有漏洞:

Auditjs

一个简单的实用程序,使用 OSS index v2 REST API 对npm项目进行审计,以识别已知的漏洞和过时的软件包版本。

使用它非常简单:

  1. 将其作为dev依赖项安装npm install auditjs --save-dev

  2. npm脚本中添加审计脚本:

scripts:{ … "audit":"auditjs" ...}
  1. 运行npm run audit命令。完整的示例可以在chapter 10/auditjs文件夹下的提取文件夹中看到。

有关更多信息,您可以访问链接www.npmjs.com/package/auditjs

Snyk.io

这是另一个模块,我们可以用来对抗synk.io维护的漏洞数据库中的任何模块进行审查。这个模块的主要优势是我们不需要安装它进行审计。这个模块可以在使用任何第三方模块之前作为预检查使用:

  1. 全局安装——npm install snyk -g

  2. 安装后,您需要通过点击snyk auth来进行身份验证

  3. 一旦设置好snyk,现在可以使用synk test <module_name>来审查任何模块

有关更多信息,您可以访问链接www.npmjs.com/package/snyk

以下是一些有用的命令:

snyk wizard 查找并修复项目中已知的漏洞
snyk protect 应用补丁并抑制漏洞
snyk monitor 记录依赖项的状态,因此每当推出新的漏洞或补丁时,我们都可以收到警报

以下是一些进一步阅读材料:

  • 还有许多其他可用的模块(我们之前看到了 Node 安全性)

  • retire.jsretirejs.github.io/retire.js/)是另一个执行类似漏洞检查的模块,甚至可以用作命令行扫描器、grunt/gulp插件、Chrome 或 Firefox 扩展程序等。

通过添加速率限制器来防止暴力攻击或洪水攻击

暴力攻击是常见的,通常作为黑客的最后手段。他们系统地枚举所有可能的解决方案,并检查每个候选解决方案是否满足问题陈述。为了防止这种攻击,我们必须实施某种速率限制算法,这将有效地阻止 IP 地址发出大量请求,从而阻止意外崩溃应用程序的可能性。

您可以在Chapter 10/rate-limiter文件夹下找到速率限制的实现,我们在其中使用了与 Redis 数据库一起使用的速率限制算法。

现在,让我们按照以下步骤进行:

  1. 安装express-limiterredis
npm install express-limiter redis --save
  1. 创建 redis 客户端并设置 express-limiter:
let client = require('redis').createClient()
..
let limiter = require('express-limiter')(this.app, client)
..
//limits requests to 100 per hour ip ip address
    limiter({
        lookup:['connection.remoteAddress'],
        total:100,
        expire:1000*60*60
    });
  1. 现在运行程序。它将限制每小时的请求次数为100次,之后将开始抛出429: Too Many Requests

防止恶意正则表达式

最常见的漏洞之一是格式不正确的正则表达式。如果正则表达式在应用于非匹配输入时花费指数时间,则被称为恶意正则表达式,应该予以防止。恶意正则表达式包含具有重复的分组、具有重叠的替换和重复组内的单词。让我们看一个例子,Regex : (b+)+, ([a-zA-Z]+)*,(a|aa)+,等等。

所有这些正则表达式都暴露给输入bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb!。这些重复可能会成为障碍,因为可能需要几秒甚至几分钟才能完成。由于 Node.js 的事件循环,执行不会继续进行,这将有效地导致服务器冻结,因为应用程序完全停止运行。为了防止这样的灾难,我们应该使用 safe-regex 工具(www.npmjs.com/package/safe-regex)。它可以检测潜在的灾难性指数时间正则表达式。

您可以在safe-regex文件夹中查看源代码。您可以通过输入node safe.js '<whatever-my-regex>'来检查正则表达式。

阻止跨站点请求伪造

入侵应用程序的常见方法是通过不安全的站点将数据输入应用程序,通过一种称为跨站点请求伪造的常见网络钓鱼技术。试图进行网络钓鱼的入侵者可以通过表单或其他输入发起请求,通过应用程序暴露的输入创建应用程序的请求。

为了加强应用程序对这种攻击的防范,我们可以使用 CSRF 令牌实现。每当用户发出请求时,都会生成一个新的 CSRF 令牌并添加到用户的 cookie 中。此令牌应添加为应用程序模板中输入的值,并将根据用户发送信息时 CSRF 库生成的令牌进行验证。NPM 提供了csurf模块(www.npmjs.com/package/csurf),可以直接在 express 中间件中使用,并且我们可以根据csurf令牌进行相应操作。

加强会话 cookie 和有效的会话管理

在应用程序中,安全使用 cookie 的重要性不容忽视。这尤其适用于需要在无状态协议(如 HTTP)中保持状态的有状态服务。Express 具有默认的 cookie 设置,可以进行配置或手动加强以增强安全性。有各种选项:

  • secret:cookie 必须用其盐化的秘密字符串。

  • name:cookie 的名称。

  • httpOnly:基本上标记 cookie,以便它们可以被发出 web 服务器访问,以防止会话劫持。

  • secure:这要求 TLS/SSL,以便 cookie 仅在 HTTPS 请求中使用。

  • domain:这表示只能从中访问 cookie 的特定域。

  • path:从应用程序域接受 cookie 的路径。

  • expires:正在设置的 cookie 的到期日期。如果没有及时的到期日期,资源消耗将非常高,资源将永远不会被释放。

在下面的例子中,我们将使用 express-session 安全地设置 cookie,从而实现有效的会话管理。您可以在typescript-express-session下跟着例子进行:

  1. 从第二章克隆first-microservice,并安装express-session@types/express-session

  2. express.ts中添加以下代码,这将使我们的应用程序使用具有以下安全参数的 cookie:

this.app.use(
  session({
    secret: 'mySecretCookieSalt',
    name: 'mycookieSessionid',
    saveUninitialized: true,
    resave: false,
    cookie: {
      httpOnly: true,
      secure: true,
      domain: 'typescript-microservices.com',
      path: '/hello-world',
      expires: new Date(Date.now() + 60 * 60 * 1000)
    }
  }))

该模块有效地帮助我们通过提供各种选项(如 cookie 标志、cookie 范围等)来处理有状态的会话。

添加 helmet 以配置安全标头

helmet模块(www.npmjs.com/package/helmet)是一个包含 11 个安全模块的集合,可以防止针对 express 微服务的各种攻击。它很容易使用,我们只需添加两行代码。添加一些基本配置可以帮助保护应用程序免受可能的安全意外。您可以通过简单添加 helmet 来使用:

this.app.use(helmet())

此代码可以在chapter-10/typescript-express-session中找到。

helmet模块有 12 个包,作为一些中间件来阻止恶意方破坏或使用应用程序。这些标头包括helmet-csp(内容安全策略 HTTP 标头的标头)、dns-prefetch协议、frameguardshide-powered-byhpkphstsienoopennocachedont-sniff-mimetypereferrer-policyx-xss protectionsframeguard以防止clickjackings等。

保护标头的另一个选项是luscawww.npmjs.com/package/lusca),可以与 express-session 结合使用。示例可以在chapter-10/express-lusca目录中找到。

避免参数污染

在 Node.js 中,如果没有为处理相同名称的多个参数定义标准,那么事实上的标准是将这些值视为数组。这非常有用,因为对于单个名称,如果预期结果是字符串,那么如果传递了具有相同名称的多个参数,则类型将更改为数组。如果在查询处理中没有考虑到这一点,应用程序将崩溃并使整个系统崩溃,从而成为可能的 DoS 向量。例如,检查此链接:http://whatever-url:8080/my-end-point?name=parth&name=ghiya

在这里,当我们尝试读取req.query.name时,我们期望它是一个字符串,但实际上我们得到的是一个数组,['parth','ghiya'],如果不小心处理,这将使应用程序崩溃。为了确保应用程序不会失败,我们可以做以下事情:

  • 各种策略以不同的方式实现污染机制;例如,有些可能采用第一次出现,有些可能采用最后一次出现

  • 使用 TypeScript 类型来验证请求。如果类型失败,通过提供参数错误来停止请求

  • 确保对 HTTP GET、PUT 或 POST 中的参数进行编码。

  • URL 重写必须遵循严格的正则表达式。

你可以在www.owasp.org/index.php/Testing_for_HTTP_Parameter_pollution_(OTG-INPVAL-004)上查看完整列表以及如何处理。

传输安全

如果应用程序有任何移动部分(如 POST、PUT 和 DELETE 等 HTTP 方法),包括从客户端记录或发送推文等会改变信息的操作,使用 HTTPs 是确保信息在传输过程中不被修改的重要实施。成本可能是不投资 SSL 证书的一个简单借口。但现在有新的、完全免费的 SSL 证书资源,比如Let's Encryptletsencrypt.org/)。此外,Node.js 应用程序不应直接暴露在互联网上,SSL 终止应在请求到达 Node.js 之前处理。使用 NGINX 来做这个是一个非常推荐的选择,因为它专门设计用于比 Node.js 更有效地终止 SSL。要有效地将 express 应用程序设置在代理后面,请参考此链接:expressjs.com/en/4x/api.html#trust.proxy.options.table。一旦 HTTP 设置好了,我们可以使用nmapsslyzeOpenSSL来测试 HTTP 证书传输。

防止命令注入/SQL 注入

注入攻击可能发生在入侵者发送利用解释器语法的基于文本的攻击时。SQL 注入包括通过用户输入注入部分或完整的 SQL 查询,这可能会暴露敏感信息,也可能具有破坏性。同样,命令注入是攻击者在远程 Web 服务器上运行 OS 命令的一种技术。通过这种方法,甚至密码也可能会被暴露。为了防范这些攻击,我们应该始终过滤和清理用户输入。使用eval等 JavaScript 语句也是注入攻击的另一种方式。为了防止这些攻击,如果你使用postgres,可以使用node-postgreswww.npmjs.com/package/pg),它提供了位置查询参数。防御注入的常见技术包括以下内容:

  • 为了避免 SQL 注入,可以使用一种技术来转义用户输入。许多库都提供了这个功能。

  • 参数化 SQL 查询是避免 SQL 注入的另一种方法,其中你使用位置查询参数创建一个查询,并用值填充位置查询参数。

  • eval()与用户输入是注入命令的一种方式,根本不应该使用(在下一节中,我们将编写一个linter,它将避免这种情况)。

  • 同样,express 应用程序容易受到 MongoDB 攻击。如果不明确设置查询选择器,我们的数据将容易受到简单查询的攻击。

我们有db.users.find({user: user, pass: pass}),其中userpass来自 POST 请求体。现在,由于没有类型,我们可以简单地在这个查询中传递查询参数,比如{"user": {"$gt": ""},"pass": {"$gt": ""}},这将返回所有用户及其密码。为了解决这个问题,我们需要明确传递查询选择器,这将使我们的查询变为db.users.find({user: { $in: [user] }, pass: { $in: [pass] }})

TSLint/静态代码分析器

在本节中,我们将看一种分析所有编写的代码并根据安全漏洞列表进行检查的方法之一。我们将把这作为我们部署计划的一个阶段。我们将编写一个linter,有一个.tslint文件,其中提到了要检查的所有规则,然后我们将运行 lint。所以,让我们开始吧。TsLint是一种检查和验证源代码的方法。它是一个静态分析代码工具,运行在 Node.js 上,以保持您的源代码清洁,找到可能的错误,发现安全问题,并强制执行所有团队的一致风格:

  1. 从第二章中克隆我们的first-typescript-microservices为旅程做准备,并在其中添加以下命令:
npm install tslint --save
npm install tslint-microsoft-contrib --save
  1. 接下来,我们将编写tslint.json,其中包含我们要根据的基本规则。从github.com/Microsoft/tslint-microsoft-contrib/blob/master/recommended_ruleset.js复制规则。

  2. 接下来,我们将编写一个初始化脚本:

 "analyze":"node ./node_modules/tslint/bin/tslint 
            -r node_modules/tslint-microsoft-contrib/
            -c tslint.json
            -s src/**/*.ts"
  1. 现在我们可以在任何地方利用这个脚本,因为当我们运行它时,它将输出所有根据该规则集评估时发现的错误。

  2. 我们可以在前面的脚本中添加一个--fix标志,这将在大多数情况下自动采取必要的措施。

您可以在第十章/tslinter文件夹下找到源代码。在本节中,我们看了一些在加固我们的应用程序针对各种可能的攻击时需要做的事情。在下一节中,我们将看一些可以应用的容器级安全性。

容器的安全最佳实践

随着容器的出现,云原生应用程序和基础设施需要一种完全不同的安全方法。让我们看看最佳实践。这是云原生方法的时代。云原生方法是指将软件打包成称为容器的标准单元,并将这些单元排列成相互通信的微服务,以形成应用程序的过程。它确保运行的应用程序完全自动化,以实现更大的好处-标准速度、灵活性和可伸缩性。让我们看看需要解决的安全考虑,以建立一个全面的安全计划。

保护容器构建和标准化部署

这个阶段侧重于对开发人员工作流程和持续集成和部署流水线应用控制,以减轻容器启动后可能发生的安全问题。以下是标准的实践方法:

  • 即使在容器级别,也要应用单一责任规则。容器图像应该只包含启动每个容器所需的基本软件和应用程序代码,以最小化攻击面。

  • 应扫描已知漏洞和曝光的图像。我们可以在其中验证图像的公共漏洞和曝光数据库(就像应用程序级别一样)。

  • 构建图像后,应进行数字签名。使用私钥对图像进行签名提供了确保每个用于启动容器的图像都是由受信任方创建的保证。

  • 由于在主机上运行的容器共享相同的操作系统,因此它们以受限的能力集开始是非常重要的。使用诸如 SELinux 之类的模块。

  • 使用秘密管理技术(一种通过该技术只在需要时将秘密(如敏感数据)分发给使用它们的容器的技术)。

在运行时保护容器

对于运行时阶段的安全性,我们需要检查以下事项——可见性、检测、响应、预防、停止违反政策的容器等。以下是需要注意的一些重要因素:

  • 分析微服务和容器的行为

  • 关联分布式威胁指标,并确定单个容器是否被污染,或者它是否传播到许多容器中

  • 拦截以阻止未经授权的容器引擎命令

  • 自动化响应操作

这些是我们需要做的一些基本工作,以确保我们的容器不受任何漏洞的影响。在下一节中,我们将看到一个可以在整个微服务开发阶段使用的一般清单。

安全清单

微服务开发是一组标准工具和大量支持工具的平台,一切都在不断变化。在这一部分,我们将看一下一个总体清单,我们可以用它来验证我们的开发,或者它可以给我们一个关于我们微服务开发的一般想法。

服务必需品

开发的第一和主要级别是个体微服务开发,满足一些业务能力。在开发微服务时可以使用以下清单:

  • 服务应该独立开发和部署

  • 服务不应该有共享数据;它们应该有自己的私有数据

  • 服务应该足够小,以便专注并能够提供巨大价值

  • 数据应该存储在数据库中,服务实例不应该被存储

  • 尽可能将工作卸载到异步工作者

  • 应该引入负载均衡器来分发工作

  • 安全应该是分层的,我们不需要重新发明轮子;例如,OAuth 可以用来维护用户身份和访问控制

  • 安全更新应该是自动化的

  • 应该使用具有集中控制的分布式防火墙(如 Project Calico)

  • 应该使用监控工具,比如 Prometheus

  • 应该为容器使用安全扫描器

服务交互

下一个级别是微服务之间的通信。当微服务相互通信时,应该遵循一个清单。这个清单有助于确保如果任何服务失败,那么失败是被包含的,不会传播到整个系统:

  • 数据应该以序列化格式传输,比如 JSON 或 protobuf

  • 应该谨慎使用错误代码,并相应地采取行动。

  • API 应该简单、有效,并且合同应该清晰

  • 应该实施服务发现机制以便轻松找到其他服务

  • 分散的交互优于集中的编排器

  • API 应该有版本

  • 断路器有助于阻止错误在整个系统中传播

  • 服务交互应该只通过公开的端点

  • 对所有 API 进行身份验证并通过中间件传递,可以更清晰地了解使用模式

  • 连接池可以减少突然请求激增的下游影响

开发阶段

接下来要注意的是开发过程。这个清单遵循 12 个因素的标准。以下是开发标准的清单,有助于更顺畅的开发过程:

  • 应该使用一个共同的源代码控制平台

  • 应该有单独的生产环境

  • 应该遵循无发布,快速发布的原则

  • 共享库很难维护

  • 简单的服务易于替换

部署

部署清单侧重于部署时代。它指示容器和图像如何帮助更快地部署。它建议关键值和属性配置来管理不同环境中的部署:

  • 应该使用图像和容器

  • 配置一个机制,可以在任何环境中部署任何服务的任何版本(CI/CD 加适当的 Git 分支和标签)

  • 配置应该在部署包之外进行管理,例如环境变量、键值存储、外部 URL 链接等。

操作

运营清单包含在运营级别执行的最佳实践清单,以使系统在发布后的生活更加轻松。它建议使用集中日志、监控软件等。它展示了自动化如何可以使生活更轻松:

  • 所有日志应该放在一个地方(ELK 堆栈)。

  • 所有服务的常见监控平台

  • 无状态服务可以轻松进行自动扩展,因为我们不必在所有地方复制会话。

  • 自动化是快速开发的关键。

基本上就是这样!在下一节中,我们将介绍可伸缩性,并查看一些可用的可伸缩性工具,然后结束本书。

可伸缩性

今天,在竞争激烈的营销世界中,一个组织的关键点是让他们的系统正常运行。任何故障或停机直接影响业务和收入;因此,高可用性是一个不容忽视的因素。由于技术的深度使用和我们使用技术的多种方式,每天信息量不断增加。因此,负载平均值超过了预期。每天,数据呈指数增长。

在某些情况下,数据不能超过某个限制或者各种用户不会超出边界是不可预测的。可伸缩性是处理和满足任何时间点的意外需求的首选解决方案。可伸缩性可以水平扩展(通过向资源池添加更多机器来扩展)和垂直扩展(通过向现有机器添加更多 CPU/RAM 来扩展)。在数据方面,跨数据库和多台服务器上的应用程序查询。我们可以添加实例来处理负载,在一段时间后进行缩减等。向集群添加计算能力以处理负载是很容易的。集群服务器可以立即处理故障并管理故障转移部分,以使系统几乎始终可用。如果一个服务器宕机,它将重定向用户的请求到另一个节点并执行请求的操作。在本节中,我们将介绍两个最著名的工具——Kubernetes 和 AWS 负载均衡器。

AWS 负载均衡器

对于负载平衡,我们应该了解亚马逊网络服务弹性负载均衡器ELB),它可以帮助我们实现负载均衡。我们应该特别了解 AWS ELB;然而,对于其他可用的负载平衡替代方案,大部分概念仍然是相同的。有各种可用的替代方案来实现负载平衡。其中一些是 HAProxy、Apache Web 服务器、NGINX Http 服务器、Pound、Google Cloud 负载均衡、F5 负载均衡器、Barracuda 负载均衡器等。一般来说,以下是描述负载平衡流程的架构:

负载均衡器。

ELB 是 AWS 提供的众多服务之一,它可以自动将传入的网络流量或应用程序流量分发到网络中可用的 EC2 实例。ELB 还会监视 EC2 实例的健康状况,通过自动添加或删除 EC2 实例来为应用程序提供高可用性。它只会将流量发送到可用且健康的实例。您还可以配置 ELB 进行内部负载均衡或面向公众的负载均衡。ELB 成为了应用程序所在的地方后面运行的 EC2 实例的面孔。根据实例的状态或可用性,健康检查会将其标记为InService——如果它处于健康状态,或OutOfService——如果它处于不健康状态。负载均衡器只会将流量路由到健康的实例。借助这种健康检查,负载均衡器为我们提供了一个容错的应用程序,并根据我们配置的参数(高流量、高资源利用率等)确保应用程序全天候的高可用性。

使用负载均衡器的好处

负载均衡器帮助我们提供一个容错的应用程序,更好的高可用性,应用程序的灵活性,以及安全性,因为我们不会直接将后端系统暴露给用户。让我们快速浏览一下拥有负载均衡器的一些好处。

容错性

负载均衡器有助于监视后端实例的健康状况。如果其中一个实例不可用,它将被标记为不可用。同样,如果实例健康,它将可用于处理请求。流量只会路由到可用的健康实例。这提供了一个容错的应用程序,因此当后端有不可用的实例时,到达应用程序的流量不会受到影响。然而,如果后端没有系统不可用来处理请求,负载均衡器会将所有实例标记为不健康,用户将受到不可用应用程序的影响。

高可用性

如果我们没有负载均衡器运行的应用程序呢?如果对应用程序的请求数量增加,我们的实例可能无法处理请求的负载,应用程序的性能会下降。不仅如此,这也可能影响应用程序的可用性。如果我们有一个负载均衡器,它可以根据轮询方法将流量路由到所有实例,并可以轻松地分配负载到各个实例。这有助于克服高可用性的情况,不限制有限的实例被意外的高峰所淹没,这可能会影响业务。

灵活性

尽管我们讨论了容错性和高可用性,但我们的请求可能超出了应用程序的预期限制。事实上,它们也可能低于限制。其中任何一种情况都可能导致业务损失,无论是额外运行实例的成本还是降低的应用程序性能。为了管理这些情况,许多负载均衡器,特别是在 AWS ELB、Google Cloud Load Balancing 等公共云中,提供了根据特定标准(如内存或 CPU 利用率)自动扩展实例的灵活性,当它扩展或缩减时,可以增加或删除负载均衡器中的实例数量。具有这些功能的负载均衡有助于确保我们管理意外的高效或低效的高峰。

安全性

负载均衡器还可以配置为不仅是面向公共的实例;它们也可以配置为面向私有的实例。这在存在安全网络或站点到站点 VPN 隧道的情况下会有所帮助。这有助于保护我们的实例免受公共接口的影响,并将它们限制在私有网络中。借助面向私有的负载均衡器,我们还可以配置后端系统的内部路由,而不将其暴露给互联网。

负载均衡器具有各种功能,例如配置协议、粘性会话、连接排空、空闲连接超时、指标、访问日志、基于主机的路由、基于路径的路由、将负载均衡到同一实例上的多个端口以及 HTTP/2 支持。

我们已经看了很多负载均衡器的功能,但还有一个重要的功能要看。是的,你说对了;它就是健康检查。健康检查作为负载均衡器和我们的应用的心跳。让我们更详细地了解一下健康检查,以了解它们为什么是负载均衡器的心跳。

健康检查参数

为了发现和维护 EC2 实例的可用性,负载均衡器定期发送 ping(尝试连接)或发送测试请求来测试 EC2 实例。这些检查称为健康检查参数。以下是所有健康检查参数的列表。健康检查帮助我们确定实例是否健康或不健康。让我们看看大多数负载均衡器中可用的健康检查的一些常见配置选项。

不健康阈值

在不健康的尝试中,预计通过负载均衡器从后端未收到积极响应的次数来验证。例如,如果我们配置了五次不健康的尝试,只有在负载均衡器从实例未收到五次健康响应时,负载均衡器才会将实例标记为不健康。

健康阈值

这与不健康的尝试完全相反;预计通过负载均衡器从后端收到积极响应的次数来验证。例如,如果我们配置了两次健康的尝试,只有在负载均衡器从实例收到两次健康响应时,负载均衡器才会将实例标记为可用。

超时

健康检查可以配置为检查 URI。比如说,负载均衡器应该检查/login.html以获取健康响应,但在超时指定的时间内没有响应。这将被视为不健康的响应。如果有一个实例由于受限的系统资源可用性而受影响,并且未按预期时间响应,这种配置将对我们有所帮助。理想情况下,建议将超时配置为接近实例实际响应时间,以实现有效使用。

健康检查协议

我们可以以多种方式配置健康协议类型。我们可以基于 HTTP 响应、HTTPS 响应、TCP 响应或 HTTP/HTTPS 主体响应进行健康检查。这些是大多数负载均衡器中最常见的健康检查类型。

健康检查端口

我们还可以配置健康检查端口。我们可以根据刚刚讨论的各种协议来配置健康检查,但如果我们的应用有自定义端口,负载均衡器可以相应地进行配置。例如,如果我们的后端实例上运行的 HTTP 服务器在端口81上而不是默认端口80上,我们可以在健康检查端口中定义自定义端口81,并将 HTTP 配置为健康检查协议。

间隔

此配置参数确定健康检查应在多长时间后计算我们的后端实例的心跳。一般来说,它以秒为单位配置,因此,如果我们配置为 10 秒的间隔,负载均衡器将每 10 秒重复一次检查。

配置负载均衡器

现在我们知道了我们的健康检查参数,让我们配置一个负载均衡器。您需要在 AWS 上创建一个帐户并启动一个实例:

  1. 打开您的实例,然后转到负载平衡|负载均衡器选项卡。

  2. 创建一个负载均衡器。您可以选择应用负载均衡器、网络负载均衡器或经典负载均衡器。这些的用途在以下截图中显示:

负载均衡器的类型

  1. 下一步是配置负载均衡器,并根据您的要求添加健康检查。所有步骤都可以在以下截图中看到:

配置 ELB 负载均衡器

  1. 您可以根据讨论的理论指定健康参数。健康检查确保请求流量从失败的实例中转移。

自动扩展-与 AWS 一起实际操作

现在,我们将使用AWS 自动扩展组、负载均衡器和配置属性进行自动扩展。所以,让我们开始吧。以下是我们将要遵循的整个过程:

  1. 创建一个启动配置,从第二章运行我们的first-typescript microservice为旅程做准备,启动 HTTP 服务器

  2. 创建一个自动扩展组

  3. 创建一个自动扩展策略,当 CPU 负载大于 20%一分钟时,增加两个实例

  4. 添加删除自动扩展组的标准。

  5. 自动终止实例

所以,让我们动手吧。

创建启动配置

登录到 AWS 控制台,转到 EC2 仪表板。选择启动配置以启动向导。根据向导创建启动配置:

启动配置

我们应该准备好 EC2 实例来托管我们的first-typescript-microservices应用程序。

创建自动扩展组并配置自动扩展策略

现在我们有一个准备好的蓝图,我们需要的是我们的自动扩展组。创建一个自动扩展组,然后会出现以下实例。输入适当的值:

创建一个自动扩展组

这是配置扩大和缩小策略的向导外观:

自动扩展和自动终止策略

审核后,点击“确定”,您的 AWS 扩展组现在已准备就绪。

创建一个应用负载均衡器并添加目标组

下一步是创建一个应用负载均衡器并将目标组附加到它。因此,创建一个新的 ELB(我们可以使用之前为健康检查配置创建的 ELB)。为目标组添加名称,并在图 5 的第 5 步(配置 ELB 负载均衡器)中,注册由自动扩展组启动的实例:

将实例附加到负载均衡器

下一步是将此目标组与我们的自动扩展组关联。要做到这一点,编辑您的自动扩展组,并按名称添加目标组(应该有一个自动完成下拉菜单)。我们已经完成了 AWS 的配置。

测试时间

为了进行负载测试,我更喜欢使用一个简单的负载测试模块(www.npmjs.com/package/loadtest),而不是设置完整的 Jmeter。只需通过npm install loadtest -g安装该模块。

接下来,只需运行压力测试,我们可以使用以下命令:

loadtest -c 100 --rps 250 <my-aws-load-balancer-url>

在这里,-c表示并发请求,--rps表示每个客户端的每秒请求。这将触发一个警报,增加 2 个 CPU 计数。在警报时间/等待期过去后,转到 AWS 控制台检查您新创建的实例。当负载增加时,您将能够看到实例,负载减少后,它将自动开始排水和终止。

我们成功地根据策略自动扩展了我们的实例。

AWS 有一个有趣的术语——spot 实例。这使我们能够重用未使用的 EC2 实例,这可以显著降低我们的 EC2 实例。由于自动缩放实例的跨度不是很大,在扩展时使用 spot 实例是非常有利的,从货币角度来看也是有利的。

使用 Kubernetes 进行扩展

Kubernetes 是用于自动化部署、扩展和管理容器化应用程序的开源系统。在撰写本书时,Kubernetes 版本为1.9.2。在本节中,我们将看一些 Kubernetes 提供的基本功能和其中使用的术语。所以,让我们开始吧。

Kubernetes 解决了什么问题?

使用 Docker,我们有诸如docker rundocker builddocker stop之类的命令。与这些命令不同,这些命令在单个容器上执行操作,没有像 docker deploy 这样的命令来将新镜像推送到一组主机。为了解决这个问题,Kubernetes 是最有前途的工具之一。Kubernetes 提供了强大的抽象,完全解耦了应用程序操作,如部署和扩展。Kubernetes 将底层基础设施视为一组计算机,我们可以在其中放置容器。

Kubernetes 概念

Kubernetes 具有客户端-服务器架构,Kubernetes 服务器在我们部署应用程序的集群上运行。我们使用kubectl CLI与 Kubernetes 服务器进行交互。

  • Pods:我们正在运行的容器化应用程序,具有磁盘等环境变量。Pods 在部署时会迅速产生和死亡。

  • 节点:节点是运行 Kubernetes 的物理或虚拟机,可以在其中调度 Pod。

  • 秘密:我们将凭据与环境变量分开。

  • 服务:通过将其标记为其他应用程序或所需 IP 和端口的外部世界,这将暴露我们正在运行的 Pod。

  • Kubernetes 流程

对于开发目的,我们可以使用minikubekubectl。在生产级别上,理想的方式是使用GCP(Google Cloud Platform)内置的 Kubernetes。在 VMBox 中尝试运行minikubekubectl是不可能的,因为它将成为嵌套虚拟化。您可以根据此处找到的说明下载 Kubernetes 在 NativeOS 上运行kubernetes.io/docs/setup/

在本节中,我们将在结束之前使用 Kubernetes 运行我们的应用程序。您需要一个 Google Cloud Platform 帐户来进行此练习。Google 提供了 300 美元的免费信用额度。所以,让我们开始吧:

  1. Kubectl 是针对 Kubernetes 运行命令的 CLI 工具,我们需要 Google Cloud SDK。安装 Google Cloud SDK 和 Kubectl,并使用gcloud init命令初始化您的 SDK。

  2. 下一步是设置一个项目,在 Web UI 中创建一个项目,并在使用 CLI 时设置默认项目 ID,方法是运行:

 gcloud config set project <project_id>
  1. 重新查看第二章,为旅程做准备,以获取本地的docker builddocker run命令:
sudo docker build -t firstypescriptms
sudo docker run -p 8080:3000 -d firsttypescriptms:latest
  1. 接下来,我们将创建一个包含三个实例的集群,我们将在其中部署我们的应用程序:
gcloud container clusters create <name> --zone <zone>

例如,gcloud container clusters create hello-world-cluster --zone us-east1-b --machine-type f1-microF1-mico是最小的可用单位。我们可以使用以下命令将kubectl客户端连接到 Kubernetes 服务器:

gcloud container clusters get-credentials hello-world-cluster --zone us-east1-b
  1. 现在,我们有了一个 Docker 镜像和服务器集群,我们希望部署该镜像并启动容器。因此,请使用以下代码构建并上传镜像:
docker build -t gcr.io/<PROJECT_ID>/hello-world-image:v1 .
gcloud docker -- push gcr.io/<PROJECT_ID>/hello-world-image:v1
  1. 要部署,请从以下 gist (gist.github.com/insanityrules/ef1d556721173b7815e09c24bd9355b1) 创建以下 deployment.yml 文件,这将创建两个 pod。要应用此文件,请运行以下命令:
kubectl create -f deployment.yml --save-config

现在,当您执行 kubectl get pods 命令时,您将获得三个 pod。要检查系统日志,我们可以使用 kubectl logs {pod-name} 命令。

要将其暴露到互联网并为负载均衡器添加可伸缩性,请执行以下命令:

kubectl expose deployment hello-world-deployment --type="LoadBalancer"

在本节中,我们在 Kubernetes 上部署了我们的应用程序,有三个副本,我们可以自动扩展或关闭不需要的实例,就像 AWS 一样。

总结

在本章中,我们经历了我们的加固层。我们查看了我们的服务面临的所有漏洞,并学习了如何解决它们。我们了解了一些基本原理,比如速率限制、会话处理、如何防止参数污染等等。我们熟悉了容器级别的安全性,并学习了在处理微服务安全性之前的所有最佳实践,然后转向可伸缩性。我们研究了 Kubernetes 和亚马逊负载均衡器,并且对两者都有了实际操作。

到目前为止,您已经学会了如何在 Node.js 平台上使用 TpeScript 构建微服务,并了解了微服务开发的各个方面,从开发、API 网关、服务注册表、发现、服务间通信、Swagger、部署和测试等。本书的目标是为您提供一个实用的微服务开发指南,以及对基本方面的理解,让您可以立即上手。我真的希望这本书填补了 Node.js 社区与 Spring Cloud 和 Java 社区相比所缺少的空白。

posted @ 2024-05-23 16:00  绝不原创的飞龙  阅读(38)  评论(0编辑  收藏  举报