JavaScript-微服务实用指南-全-
JavaScript 微服务实用指南(全)
原文:
zh.annas-archive.org/md5/990c550250c5cf739698c4c42de89d4b
译者:飞龙
前言
你好!欢迎来到《JavaScript 微服务实战》,这是一段探索最受欢迎的编程语言之一与不断演变的微服务架构世界的迷人交汇之旅。JavaScript 凭借其多功能性和普及性,加上微服务的模块化和可扩展性,形成了现代软件开发中完美的搭档。本书旨在连接这两个世界,提供使用 JavaScript 构建、管理和扩展微服务的全面指南。
微服务的兴起已经彻底改变了我们对软件架构的看法。那些主导着市场、提供简单性但以可扩展性和灵活性为代价的单体应用的时代已经过去了。微服务,以其独立、自包含单元的承诺,带来了一种新的开发时代:敏捷、弹性且高度可扩展。然而,权力越大,责任越大,采用微服务架构的挑战不应被低估。
在这本书中,我们将一步步引导你理解并使用 JavaScript 实现微服务的过程。每一章不仅旨在向你介绍理论概念,还提供实际操作指导,这些指导可以帮助你在现实场景中应用。
我们将从微服务简介开始,探讨其核心原则、优势和挑战。从那里,我们将深入研究微服务的内部结构,重点关注构建稳健系统所必需的通信技术和模式。在深入更高级主题,如实时数据流、保护你的微服务和将它们部署到生产环境之前,我们还将涵盖你在 JavaScript 和 Node.js 中需要的基础知识。
这本书不仅关于编写代码,还关于理解那些将允许你构建可扩展、可维护和高效微服务的架构模式和最佳实践。无论你是寻求扩展技能集的资深开发者,还是渴望探索微服务世界的初学者,这本书都是为了你的指导而设计的。
感谢你与我一起踏上这段旅程。我希望这本书不仅能提高你的技术技能,还能激发你探索微服务和 JavaScript 所能达到的极限。
让我们开始吧!
这本书面向的对象
本书是为那些渴望使用 JavaScript 进入微服务世界的软件开发者、架构师和 IT 专业人士而设计的。无论你是微服务的新手还是希望增强现有知识,本书都提供了实用的见解和实际经验,帮助你在一个现实世界的环境中构建、管理和扩展微服务。
如果你属于以下任何一个群体,你可能会从这本书中受益:
JavaScript 开发者:如果你是一位对将技能扩展到微服务架构感兴趣的 JavaScript 开发者,本书将引导你完成这个过程。你将学习如何将现有的 JavaScript 知识应用于构建可扩展和健壮的微服务,并获得最佳实践、常见挑战和有效解决方案的见解。
软件架构师:对于想要设计和实施微服务架构的架构师来说,本书提供了对模式、工具和策略的全面探索。你将学习如何做出明智的决定来构建和部署微服务,确保你的系统既灵活又稳健。
DevOps 和 IT 专业人士:如果你参与微服务的部署、监控和维护,本书将为你提供有效管理这些系统的知识。你将深入了解 CI/CD 管道、容器化、编排和监控技术,这些对于在生产环境中运行微服务至关重要。
本书将为你提供所需的实用知识和工具,帮助你成功应对微服务架构的复杂性,使用 JavaScript 定位你在当今快速发展的开发环境中的成功。
本书涵盖的内容
第一章,微服务简介,介绍了微服务架构,探讨了其核心原则,并将微服务定义为专注于特定功能的独立小单元。本章还对比了微服务与传统单体架构,突出了每种方法的优缺点,为本书的其余部分奠定了基础。
第二章,深入微服务内部,更深入地探讨了微服务的内部运作机制。它涵盖了微服务通信技术,包括 REST、GraphQL 和远程过程调用(RPC),并解释了同步和异步通信方法。本章还探讨了流行的通信模式,如 API 网关和消息队列,为实际应用提供了必要的理论基础。
第三章,在开始之前你需要什么,专注于在构建微服务之前你需要了解的 JavaScript 和 Node.js 的基本概念。本章涵盖了 JavaScript 引擎内部结构、异步编程、Node.js 运行环境以及线程和运行时管理在构建有效微服务中的关键作用。
第四章, 堆栈开发技术,介绍了开发和管理使用 JavaScript 的微服务所需的必备工具和技术。这包括对 Node.js 和各种框架的深入探讨,选择合适的 IDE,以及 Docker 和 Git 的安装和使用。本章还涵盖了 Postman,这是在开发过程中测试 API 和与微服务交互的关键工具。
第五章, 基本 CRUD 微服务,通过指导您使用 Express.js 开发第一个微服务来采取实践方法。本章涵盖了所需的工具,微服务的内部架构,以及创建和测试基本CRUD(创建、读取/检索、更新、删除)微服务的逐步过程,为您更复杂的实现做好准备。
第六章, 同步微服务,探讨了微服务之间同步通信的创建和编排。本章侧重于使用 NestJS 构建第二个微服务,并建立服务之间的通信。
第七章, 异步微服务,深入探讨了异步通信的世界,这是构建可扩展系统的一个关键方面。本章涵盖了 Apache Kafka 的异步消息实现,使用 NestJS 设置 Kafka 的基础设施,并构建异步事务服务。它还分解了 Kafka 的核心概念,并解释了如何将异步通信纳入您的微服务架构。
第八章, 使用微服务的实时数据流,探讨了微服务生态系统中实时数据流的力量。本章涵盖了流的概念、其优势,以及如何使用 Node.js 实现流处理微服务。它还展示了如何将这些服务与 Apache Kafka 集成,以构建实时数据管道。
第九章, 保护微服务,侧重于通过实施强大的身份验证机制来保护微服务的根本方面。本章涵盖了使用 JSON Web Tokens (JWT)进行无状态身份验证,讨论了集中式和去中心化的安全方法,并展示了如何构建专门的认证微服务。此外,还提供了确保您的微服务机密性、完整性和可用性的最佳实践,为安全的微服务架构提供了坚实的基础。
第十章**,监控微服务,专注于为您的微服务配备强大的可观察性和监控实践。本章涵盖了在微服务架构中日志和监控的重要性,确保您能够有效地跟踪系统的健康和性能。它介绍了基本的可观察性概念,如日志、指标和跟踪,并探讨了使用 ELK 堆栈(Elasticsearch、Logstash 和 Kibana)的集中式日志。到本章结束时,您将拥有实施日志和监控策略的坚实基础,以保持微服务的弹性和响应性*。
第十一章,微服务架构,深入探讨了微服务的先进架构模式。本章探讨了API 网关、事件溯源和命令查询责任分离(CQRS)模式,以分离读取和写入。
第十二章,测试微服务,强调了测试在维护微服务稳定性和可靠性中的重要性。本章涵盖了基本的测试策略,包括单元测试和集成测试,确保您的微服务能够承受任何需求并无缝协同工作。
第十三章,为您的微服务构建 CI/CD 流水线,揭示了通过持续集成(CI)和持续交付(CD)自动化微服务开发的过程。本章涵盖了 CI/CD 流程的基本要素,使用 GitHub Actions,构建一个强大的流水线,简化从开发到生产的过渡,并将您的应用程序部署到 Azure 云。
为了最大限度地利用本书
为了充分利用本书,您应该具备 JavaScript 和基本编程概念的基础知识。熟悉 Node.js,包括其运行时环境和异步编程模型,将有助于您深入微服务开发。此外,对 Web 开发和 RESTful API 的基本了解将帮助您掌握书中讨论的通信技术和模式。虽然不是强制性的,但具有 Docker、Git 和 CI/CD 流程的经验将增强您跟随后续章节中涵盖的实用示例和部署策略的能力。
本书涵盖的软件/硬件 | 操作系统要求 |
---|---|
Node.js(Windows 版本 v20.12.1) | Windows、macOS 或 Linux |
Docker Desktop(Windows 版本 4.33.1) | Windows、macOS 或 Linux |
VS Code(Windows 版本 1.92.2) | Windows、macOS 或 Linux |
Postman(Windows 版本 11.8) | Windows、macOS 或 Linux |
虽然上述版本针对 Windows,但 Node.js、Docker Desktop、Visual Studio Code 和 Postman 在 macOS 和 Linux 上也能无缝工作。这些操作系统的用户应从官方网站下载它们适当的版本。核心功能在所有操作系统上保持一致,确保无论您的平台如何,都能获得相似的使用体验。
重要提示
如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。
为了您的方便,您可以下载源代码以跟随本书的内容。然而,大多数章节也提供了详细的说明,指导您从头开始编写一切,确保您理解每个步骤。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Hands-on-Microservices-with-JavaScript
。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/
找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
文本中的代码
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“为了跟踪更改,我们将添加createdAt
和updatedAt
字段。”
代码块设置如下:
{
"name":"AccName1",
"number":"Ac12345",
"type":"root",
"status":"new"
}
任何命令行输入或输出都应如下所示:
$ cd transactionservice
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“转到终端菜单,选择新建终端。”
小贴士或重要提示
看起来是这样的。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packtpub.com 与我们联系,并提供材料的链接。
如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问 authors.packtpub.com。
分享您的想法
一旦您阅读了《动手实践 JavaScript 微服务》,我们很乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?
您的电子书购买是否与您选择的设备不兼容?
不要担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不会就此结束,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取好处:
- 扫描二维码或访问以下链接
packt.link/free-ebook/978-1-78862-540-1
-
提交您的购买证明
-
就这些!我们将直接将您的免费 PDF 和其他好处发送到您的电子邮件。
第一部分:微服务架构基础
在本部分中,我们将全面了解微服务架构的基础原则和内部运作。我们将探讨微服务是什么,它们与传统单体架构相比如何,以及使微服务成为强大且可扩展解决方案的各种通信技术和模式。此外,我们还将学习开发微服务所必需的 JavaScript 和 Node.js 基本概念。
本部分包含以下章节:
-
第一章,微服务简介
-
第二章,深入微服务内部
-
第三章,开始之前你需要什么?
-
第四章,堆栈开发技术
第一章:微服务简介
作为人类,我们都会经历各种发展阶段。随着我们达到每个阶段,即使当时看起来是最好的,我们后来会意识到我们还有很长的路要走。每个时期都有其问题,根据其大小和性质,它们需要不同的解决方案。
我们人类倾向于简化事物。这就是为什么我们围绕问题和相应的解决方案来构建我们的生活。寻找问题的解决方案一直是我们的主要目标,这也许是因为我们生存的本能。
如果我们将每块软件视为一个个体,它们也有需要解决的问题。根据问题的规模和形状,软件具有不同的结构,我们称之为架构。问题的规模和性质直接影响软件的架构。我们使用的这些架构方法之一就是微服务。
微服务在构建可扩展的分布式应用程序、响应现代问题方面非常重要。当作为开发者被大多数大型公司面试时,这也是一个事实上的要求。我们今天使用的绝大多数技术都试图默认支持微服务开发。因此,在现代 IT 世界中,没有微服务知识的软件工程师并不理想。
从本章开始,我们将深入微服务的世界。在进入实践部分之前,我们将构建坚实的理论知识。
首先,我们将回顾并尝试理解在微服务之前有哪些流行的方法。微服务很重要,但理解应用它们的必要性更为重要。
在本章中,我们将涵盖以下主题:
-
介绍微服务
-
探索单体方法
-
什么是面向服务的架构?
-
SOA 与微服务之间的区别
-
微服务的优势
-
微服务的缺点
介绍微服务
微服务架构将应用程序分解为松散耦合、独立部署的服务,这些服务拥有自己的数据并通过轻量级协议进行通信。它将大型应用程序分解为更小、自包含的业务能力,从而实现更快的开发、更容易的扩展和更好的容错性。微服务通过允许团队独立构建、测试和部署功能,实现了持续交付和敏捷开发。你可以想象一个应用程序就像一个乐团,其中每个微服务都是一位演奏自己部分的乐手,但与其他人完美和谐,共同创作出美妙的交响乐。
我们刚才提到的是一种银弹,但正如你所知,没有什么是免费的,也没有一种适合所有问题的解决方案。微服务也是如此。
我们作为软件开发者,喜欢学习新的趋势并尝试将它们应用到实践中。但当我们深入了解细节后,我们理解每个趋势只是旧知识的封装。在将任何架构应用到软件之前,进行仔细的计划、讨论、协作和分析总是更好的。
向微服务迈进
创建软件不仅仅是学习一门编程语言并将其语法元素应用到代码中,以构建事物。这就像拿着锤子和钉子;拥有它们并不意味着你是一个熟练的建造者。同样,拥有所有工具并不意味着你是一个优秀的软件开发者。
当你开始创建一个基本的 hello world
类型应用程序时,它仍然只是那样——基本的。然而,重要的是要理解,这样的简单应用程序并不值得付费。如果你想让你的应用程序具有价值,它必须解决有形的现实世界挑战——简而言之,它应该具有商业价值。增加更多的商业价值也会带来复杂性。在大多数情况下,更多的商业意味着更多的复杂性。过了一段时间,你会发现,你开始处理的不再是业务,而是你的业务带给应用程序的复杂性。
在导航复杂性时,我们的目标是将其分解成更小、可维护、可扩展和可重用的组件。只有这样做,我们才能有效地处理复杂性和未来的变化。在编程中,唯一真正的常数是需要拥抱变化,这一原则不仅在创建应用程序的过程中保持不变,而且在整个过程中都保持不变。
这种不断的变化迫使我们必须不仅掌握选定的编程语言,还要了解业务领域。自然地,这导致我们采取以设计为导向的思维模式。如果没有对业务的良好了解,几乎不可能开发出有价值的软件。
尽管我们为了学习一门语言而编写的简单应用程序可能看似无用,但当我们连接这些点时,我们就更接近真理。难道我们的一生不都是在寻找真理吗?很快,你就会意识到满足客户业务需求的软件才是重要的软件,它反映了真理。
如果你开始开发过程而没有仔细分析和设计,你将在整个开发过程中付出更高的代价。你越早开始设计和分析,你遇到更大问题的可能性就越小。我们称我们的第一个没有经过适当分析和设计的应用程序为一团糟,它使用意大利面驱动的开发:
在软件设计中,短语“一团糟”用来引起人们对反模式或产生不良结果的设计方法的关注。让我们更深入地理解这个短语。
理解一团糟
大泥球(big ball of mud)的主要问题在于缺乏结构和组织。代码库中缺乏模块化和明确的问题分离,导致了一个复杂的相互连接的文件和函数网络。想象一下一栋房子,它只是一堆没有墙壁或其他区分特征的杂乱无章的房间和材料。因为里面的一切都是相互连接的,对某一部分的改动可能会对其他部分产生灾难性的影响。这就像在一件糟糕定制的毛衣上拉扯一根松散的线——你冒着整件衣服都散架的风险。同样,代码片段散布在整个代码库中,这在进行维护时会导致低效。
由于缺乏结构和文档,维护代码库和添加新功能对开发者来说具有挑战性。想象一下试图在一个没有布局或标签的房子中导航;这几乎是不可行的。
由于它们紧密耦合,某一区域的改动可能会无意中干扰看似无关的组件。由于其脆弱性,软件容易出错和回退。想象一下用薄弱、相互连接的支撑构建的房子,即使是微小的外部力量也可能造成严重损害。
大泥球可能一开始看起来是小型简单项目中的正确选择,但随着项目的增长和发展,其复杂性会迅速增加。为了保证长期的可维护性、可扩展性和开发者满意度,这种设计方法必须不惜一切代价避免。
猜猜看?我已经知道你已经经历过这个阶段——使用大泥球尝试并失败的阶段。这些困难帮助你学到了更多,而不是从成功中学习。
每个困难都教会我们一些东西,对吧?直到我生命中的某一年,我总是对我的生活感到感激。但随着时间的推移,我意识到正是那些困难塑造了我。在我改变思维方式之后,我开始感谢生活中的困难和那些让我受苦的人。如果你能回到过去,移除生活中的困难,相信我,你也会移除你现在的自己。困难使你变得更强大,让你成为一个坚强的人。
人类是一种很少听取建议的生物。我们必须陷入麻烦——我们必须尝试麻烦。我知道本节中提到的关于大泥球的缺点,只有经历过这种困难的人才能理解。最终,我们都是通过实验来学习的。
作为一名软件新手开发者,在某个时候尝试一下大泥球是有益的。你会很快发现,虽然它提供了一个快速的开始,但它的效率会随着时间的推移而下降。
让我们尝试总结一下大泥球的缺点:
-
未计划且混乱:大泥球的出现是设计不佳和编码技术的结果,而不是一个故意的架构决策。
-
紧密耦合:代码紧密相连;对某一部分的修改可能会在无关区域产生意外的效果。
-
理解和维护困难:代码库混乱且缺乏文档,使得开发者难以理解和修改。
-
易出错且脆弱:代码库导致修改时出现不可预测的错误和回归。在泥球式系统中,一切紧密相连,就像一团乱糟糟的电线。这使得当你改变一个部分时,很难知道会发生什么,就像试图在混乱的电线中修复一根松动的电线。这很容易导致意外问题和东西损坏,就像在混乱的电线中造成短路,使得长期开发和维护系统变得更加困难。
-
降低开发者生产力:你花费更多的时间在维护代码库上,而不是专注于新功能。
-
有限的扩展和增长:僵化的代码结构使得引入新功能或适应变化变得困难:
图 1.1:泥球式软件的质量图
当编写程序时,我们会发现它在短时间内就变成了问题而不是解决方案(见图 1.1)。前面的图表跟踪了项目随时间的发展。时间在底部(X 轴)上,添加的功能在旁边(Y 轴)上。
在没有明确计划的情况下开始一个项目,例如使用泥球式方法,一开始可能看起来很简单。想象一下用积木搭建——不需要指令,你可以快速组合东西。但对于这些项目,随着它们的功能越来越多(Y 轴值越高),整体质量就会下降(变得更差)。
在短期内(几周内),设计良好的项目和泥球式项目可能看起来很相似。但时间久了,混乱项目的质量就会下降。
总体来说,虽然泥球式方法一开始可能看起来更快,但从长远来看,它最终会带来更多问题。这就像走捷径,现在可能节省了时间,但后来却导致更大的问题。
随着时间的推移,我们的代码变成泥球式的一个因素是缺乏规划和组织。规划和组织结构是我们构建微服务架构时通常使用的属性。
理解软件开发过程
开发过程不仅涵盖编码——它还涉及业务、沟通、讨论、分析、设计和部署。让我们把这些称为软件开发过程的属性(见图 1.2)。软件开发远不止是编写代码行。虽然编码当然是一个重要的部分,但它只是拼图中的一块。正因为如此,理解业务的核心需求和目标是至关重要的:
图 1.2:软件开发生命周期
以下列表提供了对软件开发过程的全面洞察:
-
解决特定问题和从商业景观的角度思考是推动软件开发的动力。为了创建既相关又有意义的软件解决方案,开发者必须对市场动态、行业和用户需求有深刻的理解。
-
有效的协作和透明的沟通是每个阶段成功的基石。开发者与包括业务分析师、设计师、测试人员和客户在内的各种利益相关者进行互动。清晰的沟通确保每个人都对目标、需求和项目里程碑保持一致。
-
讨论想法、障碍和潜在解决方案非常重要。有效的头脑风暴会议、代码审查和细致的用户反馈都有助于提高软件质量。开放沟通使问题解决更加高效。
-
对需求、用户行为模式和数据分析进行彻底分析是至关重要的。为了创建一个稳固的软件设计策略,开发者必须仔细分析现有解决方案,识别用户需求,并分解复杂问题。
-
软件的架构、功能性和用户界面必须经过仔细考虑。经过精心设计的软件易于使用、有效且易于维护。友好的用户体验是开发者和设计师之间紧密合作的结果。
-
严格的测试程序对于保证软件的功能性、可靠性和符合用户期望至关重要。不同的测试方法针对不同的领域,如性能基准和核心功能。
-
确保最终用户能够访问软件是最后一步。这通常被称为部署。这包括设置基础设施、采取严格的预防措施,并在需要时提供全面的用户培训,以优化可用性和采用率。
现在我们已经了解了软件开发过程,让我们更深入地探讨单体软件开发方法。
探索单体方法
假设我们有一个单一代码库的电子商务网站,这个网站是几年前开发的。随着时间的推移,功能和功能被随机添加,导致代码混乱,包含重复内容,难以维护,且难以调试。以下是一个建议的过渡方案,以便您使您的应用程序响应或重新活跃起来:
-
分析应用程序的当前状态: 您需要识别影响开发效率和用户体验的关键痛点。尝试将问题分解成更小的部分。试图一次性覆盖所有内容将导致您遇到更多困难。关注更大代码库中的特定模块或功能,进行初步重构。您需要了解应用程序中的依赖关系、重复和复杂性。
-
沟通和协作规划: 下一步是确定改进领域并就共同架构原则达成一致。强调分阶段的方法,从小型、隔离的模块开始,在继续前进之前展示进度。
-
选择单体架构: 决定哪种架构风格和模式(分层、分层、MVC、MVVM 等)最适合您在特定环境中的需求。
-
从小处着手,逐步迭代: 设定小目标并应用迭代开发。
-
进行改进: 消除代码重复,清理乱麻代码(用于描述无结构且难以理解的编程代码的术语),并改进文档。
-
应用: 在每个重构步骤之后,最好应用单元测试、集成测试和回归测试,以确保代码功能并识别应用程序中的潜在回归。
-
反馈: 在整个过程中收集开发人员和用户的反馈,以适应和改进方法。
欢迎来到单体世界!但单体是什么概念呢?
图 1.3: 单体架构
许多在线文章深入探讨了单体架构的细节,但很少涉及更广泛的概念,即单体方法。这并不奇怪,因为这种架构具有明确的特征。我们喜欢具体,作为开发者,我们很少是理论爱好者。然而,重要的是要记住,单体方法涵盖了更广泛的选择。
单体方法是一个更广泛的概念,指的是将软件作为一个单一、自包含的单元进行构建的一般方式。它可以使用各种架构实现,而不仅仅是我们所知的传统单体架构。它强调简单性、快速开发和紧密集成。单体方法是架构无关的,这意味着可以使用各种架构风格或模式实现,甚至可以在没有任何特定架构框架的情况下实现,只要保持将组件合并为单一单元的核心原则。
另一方面,单体架构(见图 1.3)是一种特定的软件架构,其中从 UI 到业务逻辑到数据访问,所有内容都被构建为一个单一、紧密耦合的单元。它通常使用单个代码库、编程语言和数据库。
单体架构指的是用于实现单体方法的特定架构设计或模式。它包括单体系统的所有技术选择和结构设计,包括模块的排列、组件之间的交互以及数据管理过程。
单体方法本身并不指定特定的架构。然而,某些架构风格和模式与单体方法更自然地一致,并更有效地支持它,比其他方法更有效。例如,分层架构、MVC 架构和 N 层架构。单体方法也可以在不严格遵循特定架构的情况下实现,特别是对于小型项目。这里的关键特征是保持单一的代码库和部署单元。无论你选择结构化风格还是更有机的方法,核心原则保持不变:构建一个统一的软件单元。理解这种区别迫使你在导航软件架构的广阔世界中做出明智的决定。因此,虽然单体方法促进了软件作为一个统一实体的开发,但单体架构决定了这种统一是如何实现和维持的。了解这种差异使你能够带着知识和信心在软件架构的广阔世界中导航。
虽然单体方法并非没有缺点,但它为某些类型的项目提供了几个优点。以下列出了这些优点:
-
简洁与速度:单体架构通过将整个系统整合到一个单一的代码库中,实现了更快的开发和部署周期,减少了管理多个服务带来的开销。
-
可维护性和控制:所有内容都在一个地方,使得管理、控制应用性能以及维护和保障系统的方式更加统一。
-
性能和成本:这种架构提供了降低复杂性的优势,导致基础设施成本降低,并优化了具有简单要求的应用的性能。
-
额外优势:它为简单的项目提供了实际的优势,使得管理和应用操作更加容易,尤其是对于小型团队来说。
即使单体方法有诸如速度和简单性等好处,但并非所有项目都能从中受益。为了确定单体方法是否适合你的项目,请考虑以下一般性指南:
-
简单且定义明确的应用:单体架构在具有明确范围和少量功能的应用中表现良好。简单的移动应用、内部工具和基本的电子商务网站是一些例子。
-
快速产品发布和想法测试:这是由单体架构的敏捷性实现的,如果你的项目需要快速的开发周期或频繁的原型制作,这将非常有用。
-
小型团队和有限的经验:最初,对于缺乏分布式系统或微服务经验的团队来说,管理和维护单体可能更容易。
-
紧密的数据耦合和一致性:对于依赖于多个功能之间一致数据的应用程序,单体架构具有优势。它保证了整个应用程序的数据完整性,并简化了数据管理。
-
有限的可扩展性需求:在没有扩展微服务的麻烦的情况下,如果您的应用程序预期用户流量稳定且增长预期适中,单体架构可能能够满足您的可扩展性需求。
最佳架构取决于您特定应用程序的需求。在这里,您必须考虑诸如可扩展性、复杂性、技术需求以及开发团队结构等因素。正如我们之前提到的,软件开发中没有一种适合所有情况的解决方案。
虽然单体方法有其优点,但它并不适合每个应用程序。在以下方面,最好不要使用单体:
-
构建高度可扩展的应用程序
-
具有不断演变的功能、模块化和独立部署的应用程序
-
如果您的应用程序需要集成不同的技术或框架,也称为异构技术
-
如果高可用性和弹性至关重要,并且您的系统的一个重要属性是容错性
-
如果不同的团队负责不同的功能——也就是说,如果团队之间有独立开发和部署
-
当您拥有大型团队和分布式开发
除了其优缺点之外,单体通常是一个适合入门的架构,但并非构建更好应用程序的唯一架构。我们还有另一个首选架构,称为面向服务的架构(SOA),我们计划从下一页开始深入了解。
什么是面向服务的架构?
单体架构将所有组件/元素——包括用户界面和数据访问——统一到一个代码库中,促进了简单性和快速开发。尽管并非不可能,但将不同的技术结合到一个系统中可能难以维护,有时甚至不可行。在没有当代方法如功能标志和蓝绿部署的情况下,每次您想要更新单体应用程序时,都必须部署整个应用程序。在组织和顺利交付应用程序方面存在困难,这可能会搞乱其发布。
另一方面,SOA(见图 1**.4)侧重于模块化和重用,将功能分解为相互通信的独立服务,这些服务通过应用程序编程 接口(APIs)进行通信。
SOA 可以被定义为多个较小且通常粒度较粗的服务,每个服务都有特定的功能。这种模块化提供了诸如灵活性和可扩展性等优势。SOA 中的服务可以独立部署和扩展,这意味着您可以更新或扩展一个服务而不会影响其他服务。这是 SOA 的一个关键优势。
从单体到 SOA 的类比可以这样描述:您有一个做所有事情的大方法/函数(类似于单体)。过了一段时间,您的功能的一些其他部分被需要,以便这些功能可以被重用。而不是复制它,您将这个巨大的方法分解成可重用的部分(类似于 SOA)。在这种情况下,方法调用将成为我们的 API 调用:
图 1.4:面向服务方法概述
考虑多个应用程序(如图图 1.4 – 账户管理、CRM 和销售管理)需要共享公共功能。而不是为每个应用程序复制它们,我们提供面向服务的方法。乍一看,它们可能看起来是完美粒度的服务,但我们的重点是仅仅共享支持扩展和重用的公共行为。
为了封装通信复杂性,我们可能会使用服务总线,这允许我们编写额外的逻辑并将复杂性移动到应用程序的外部,该应用程序充当调解者。这是我们应在应用程序中使用架构调解器的迹象之一。
想象一下,在一个程序中存在两个函数,其中一个直接调用另一个。在 SOA 中,每个函数都成为一个独立的服务,通过定义的接口进行通信。这使它们能够独立部署、更新,甚至由不同的团队进行开发。
想象一下用乐高积木而不是单体块来构建。这就是 SOA 的本质:将应用程序分解为可重用、独立的、专注于特定任务的服务。而不是硬编码的连接,它们通过标准协议(如REST或SOAP)进行通信,使它们具有平台无关性和适应性。
SOA 提供了许多优势,可以显著提高您组织 IT 基础设施的灵活性、敏捷性和效率。让我们来发现它的关键好处:
-
业务敏捷性:SOA 支持快速开发和部署,帮助企业快速适应市场变化,并使软件与不断发展的业务目标保持一致。
-
技术优势:SOA 提供了灵活性和可扩展性,允许在不干扰整体功能的情况下,更容易地集成、升级和跨系统重用组件。
-
运营优势:SOA 通过减少维护开销和提高系统可靠性来简化操作,同时通过集中式管理增强安全性。
虽然 SOA 有许多优势,但也有缺点:
-
增强的复杂性:SOA 通过要求独立服务之间的仔细协调、需要熟练的人员以及详细的发展、测试和维护规划,引入了更多的复杂性。
-
性能可能存在的问题:SOA 可能会由于基于网络的交互而引入延迟,在确保服务之间安全高效通信时增加复杂性。
-
其他困难:SOA 具有高昂的前期成本,需要熟练的专业人员,这使得维护服务协调、管理责任和确保系统演变过程中的平滑集成具有挑战性。
SOA 是迈向微服务的一步。微服务的许多核心思想都来自 SOA。
在本章的最后部分,我们将了解微服务架构的益处和挑战。
SOA 和微服务之间的区别
微服务架构简化了构建分布式、灵活和可扩展的软件。它不是使用一个单体系统,而是将应用程序划分为小型、独立的微服务,每个微服务专注于特定的任务。这些服务通过简单的接口进行通信,允许独立部署和易于集成。在正确设计微服务时,我们得到松散耦合、可重用、可扩展且易于维护的应用程序。
当比较微服务和 SOA 时,它们在概念上可能看起来很相似。SOA 和微服务架构都是用于构建分布式系统的架构风格,但它们有一些关键的区别。让我们来比较一下:
-
范围和粒度:SOA 中的服务就像包含多个功能的大盒子,这些功能旨在在不同的应用程序之间重用。微服务就像小型、专业的工具,每个工具都专注于应用程序中的一个特定任务或功能。
-
通信协议:SOA 中的服务主要使用严格的协议进行通信,例如SOAP、XML-RPC、WSDL和UDDI。微服务更倾向于使用轻量级协议,如RESTful HTTP或消息队列,以实现更灵活的通信。
-
技术堆栈:SOA 可以与不同的技术和平台协同工作。微服务通常使用容器化工具,如Docker,以及编排工具,如Kubernetes,以实现更简单的部署和管理。
-
依赖管理:SOA 中的服务可能具有复杂的依赖关系,需要仔细协调。微服务追求松散耦合,减少服务之间的依赖关系,以简化开发和部署。
-
部署和扩展:在 SOA 中,服务通常在服务级别进行集中部署和扩展。微服务是独立部署的,允许单独扩展并更好地利用资源。
-
组织影响:一旦实施了 SOA,可能需要重大的组织变化以进行协调和管理。微服务通过给予小型、跨学科团队控制其服务的自主权来促进管理的去中心化。
在微服务方面,方法和架构之间的区别非常重要。
微服务方法全部关乎我们在设计软件时的思维方式。这就像拥有一种心态或哲学,即把大而复杂的系统分解成更小、更容易处理的部件。每个部分都专注于一项特定任务。这有点抽象,并强调诸如模块化(允许简单替换)、可伸缩性(允许增加工作量)和灵活性(允许适应变化)等概念:
图 1.5:微服务架构
每种方法都有其优点和缺点。没有什么是完美的。从微服务的角度来看,让我们来谈谈微服务的优缺点。
微服务的优点
在本节中,我们将探讨许多使微服务成为软件开发重要部分的原因:
-
可伸缩性:可以根据需求独立扩展每个微服务,确保资源分配到需要的地方,以实现最佳性能和成本效益。
-
灵活性和敏捷性:团队可以同时处理不同的服务,从而加快开发速度并使更新更容易。对业务不断变化的需求和市场的适应能力是敏捷性的关键。
-
故障隔离:由于它们的独立性,如果一个微服务失败,它并不一定会影响其他服务。这种隔离通过最小化停机时间来提高系统可靠性。
-
技术多样性:由于微服务,可以在单个应用程序中使用多种编程语言和技术。团队被鼓励通过为每个服务选择最佳工具来探索和发挥创造力。
-
易于维护和更新:与庞大的单体程序相比,较小的服务更容易理解、管理和更新。由于对某一服务的修改不会无意中影响其他服务,因此风险降低。
-
可扩展的开发团队:得益于微服务,小型、跨职能团队现在可以拥有独立的服务。这种配置促进了创造力,加速了决策过程,并提高了责任感。
-
提高容错性:微服务使得在服务级别实现冗余和故障转移技术变得更加简单。这增加了系统承受挫折的能力。
-
改进的部署实践:持续集成和持续部署(CI/CD)这两种当代部署技术与微服务架构完美结合。通过为每个服务的自动化部署管道简化发布流程,可以缩短上市时间。
-
资源利用改进:通过细粒度扩展,可以根据每个服务的独特需求进行资源分配,从而最大化资源效率并降低成本。
-
鼓励合作:通过微服务鼓励开发和运维团队之间的合作,使得实施 DevOps 原则变得更加容易。在服务层面,可以通过反馈循环、自动化和监控来提高整体质量和效率。
-
处理庞大且复杂的系统:微服务可以帮助你简化庞大且复杂的应用程序,因为你可以将它们分解成更小、更易于管理的部分。
-
处理大量用户:由于微服务允许你单独扩展每个组件以有效处理负载,因此它们非常适合经历高流量或拥有大量用户的应用程序。
-
需要频繁更新或添加新功能:微服务允许你通过更改单个组件而不影响整个应用程序来迅速响应变化的需求。
-
使用不同的技术:微服务允许你为应用程序的不同部分使用不同的工具和编程语言,以便为每个任务选择最佳选项。
-
由多个团队构建:如果你的应用程序由许多不同的团队共同开发,微服务允许每个团队专注于自己的部分,而不会相互干扰。
-
需要持续运行:微服务有助于你的应用程序保持运行状态,即使某个部分失败也是如此。这是因为每个部分都是独立的。因此,一个区域的问题不会导致整个系统崩溃。
-
适用于云环境:由于微服务设计得与云技术良好配合,因此它们非常适合在云中运行的应用程序。此外,容器和编排器等工具使得在云中管理它们变得更加容易。
总结来说,微服务提供了一种现代、灵活的软件开发方法,使企业能够快速创新、有效增长,并能更快地将高质量的软件产品推向市场。然而,不要试图将它们用于你创建的每一种应用程序。
尽管微服务提供了许多优点,但你应该意识到它们也带来了一些额外的复杂性,例如需要管理多个移动组件以及服务之间需要更多的通信。
微服务的缺点
在本章中,我们了解到软件开发中存在各种架构的主要原因是没有单一真理的迹象,并且根据需求,架构可能有所不同。设计中的每一种方法都有其缺点,在应用任何架构之前,您应该仔细分析和理解它们。
这里是微服务的一些重要缺点:
-
开发中的复杂性增加:将系统拆分为更小的服务可能导致开发、部署和测试的复杂性增加。
-
服务间通信:管理微服务之间的通信可能会变得复杂,需要仔细设计和实现 API 和协议。
-
基础设施复杂性:管理和部署大量微服务可能会引入运营开销,包括需要复杂的编排和监控工具。
-
基础设施成本:管理多个服务和相关基础设施的开销可能导致成本增加,尤其是在托管和运营费用方面。
-
安全问题:服务数量越多,攻击面就越大,可能增加安全风险。
-
通信安全:确保微服务之间的通信安全需要额外的关注,以防止未经授权的访问。
-
协调和通信:团队需要有效协调,以确保一个服务的更改不会对其他服务产生不利影响。
-
数据一致性:在微服务之间保持一致性可能具有挑战性,尤其是在处理分布式数据库时。确保数据完整性和一致性成为一个复杂任务。
-
团队专长:开发者需要在特定微服务的领域和技术堆栈方面都有专长,这可能会限制任务分配的灵活性。
因此,我们应该根据我们团队的专长、我们应用程序的需求以及我们组织对转变的准备情况,仔细考虑微服务是否是我们项目的正确选择。
摘要
本章向您介绍了微服务。我们讨论了缺乏适当设计和分析的编码,这导致我们陷入了一个大泥球。没有清晰的架构就像在海洋中间没有地图一样。
我们的第一步是从单体开始。我们讨论了单体方法的优缺点,并试图理解方法和架构之间的差异。
现在,应用程序的需求更加广泛和复杂,总是试图使用单体方法来处理它们可能不是一个好的解决方案。为了向架构中添加“分布式”等重要属性,我们在讨论其优缺点时考虑了 SOA。
我们最终的目标是微服务。我们为其提供了一个明确的定义,并试图了解使用它们的优缺点。
微服务给我们的生活带来了许多有趣的挑战,其中之一就是通信问题。将大问题分解成小块是好的,但要在这些小块之间建立适当的通信并不容易。当你准备好了,翻到下一章,和我一起探索这个问题。
第二章:深入了解微服务内部结构
微服务不仅仅是将大型应用程序拆分成更小、更易于管理的部分。它们还引入了挑战,其中之一就是服务之间的通信。我们在上一章讨论的单一架构应用程序,其元素之间的通信相对简单直接。然而,在微服务架构中,服务之间存在物理隔离。尽管我们希望微服务是独立的,以及易于重用、维护和扩展,但使它们能够有效通信成为一个主要挑战。
有效的微服务通信对于架构的整体成功至关重要。它使服务能够交换数据、协调行动并触发事件。如果微服务不能有效通信,它们就像孤岛一样,阻止应用程序正常工作并使其运行缓慢。精心设计的通信模式确保微服务能够有效地协作以提供所需的功能。这种通信策略还促进了松散耦合,这意味着一个服务的更改对其他服务的影响最小,从而使应用程序更具弹性和易于维护。
本章旨在为下一章的实践章节奠定坚实的基础,并提供关于微服务通信的全面信息。对微服务通信的深入了解将帮助您构建可靠、一致、可扩展和容错性强的微服务应用程序。
在本章中,我们将更深入地讨论以下主题:
-
微服务通信技术
-
同步微服务通信
-
异步微服务通信
-
重要的通信模式
巩固通信
随着应用程序的增长,管理其复杂性变得越来越具有挑战性。为了应对这一挑战,开发者依赖于最佳实践、设计模式和各种方法。在传统的软件开发中,抽象、封装和分解等技术帮助我们处理复杂性。
微服务架构通过关注点分离(SOC)原则提供了一种强大的解决方案来应对复杂性。这个原则将复杂系统分解成更小的独立部分,每个部分都有明确的职责。想象一个单一架构应用程序就像一座山;微服务允许我们通过边界上下文将其分解成更小、更易于管理的山丘。然而,这种自由是以某种方式确定如何建立微服务之间通信为代价的。当然,这并不像单一架构方法那样简单,因为那时所有内容都在一个代码库中。创建单体元素之间的连接就像调用任何方法一样简单。
解释单体和微服务之间关系的最佳方式是利用S.O.L.I.D原则的前两个原则,即单一职责原则(SRP)和开放封闭原则(OCP),作为隐喻。
SRP 的核心原则是将大问题分解为更小、更专注的单元,这与微服务方法相一致。我们将大图分解为更小、更易管理的模块,每个模块都有明确的职责和变更原因。这反映了 SRP 将类和模块拆分为可管理部分的目标。
图 2.1:应用 SRP
SRP 背后的主要思想是 SoC,这在微服务架构中也得到了采用。它提倡根据功能将系统划分为独立的部分。这提高了可维护性,降低了复杂性,并允许独立开发和部署每个部分。
从隐喻的角度来看,SRP 的抽象思想是将大模块分解为更小的模块(见图 2.2),这与我们将单体分解为称为微服务的较小服务时得到的结果相似。
图 2.2:单体架构到微服务架构
将单体应用程序拆分后的最终结果是微服务;在这个比较中,它们是软件实体。使它们易于扩展意味着有一个团队在处理它们。这个团队能够扩展和修改它们,而不依赖于其他团队或模块。服务之间的松散耦合使我们能够独立工作。
然而,这里有一个问题:通信。与一个单体类相比,多个较小的实体需要依赖通信才能作为一个整体运行。虽然 SRP 创建了独立的子系统,但它没有解决它们之间通信的挑战。事实上,SRP 本身也可能导致这种复杂性。SRP 可能会让你拥有多个较小的子系统,但并没有解决它们之间的通信问题。
这就是 OCP 发挥作用的地方。OCP 指出,软件应该易于扩展但不易于修改。从隐喻的角度来看,在微服务的背景下,这意味着设计灵活且适应未来变化的通信机制,而无需修改现有服务。
当一个微服务(我们将它称为微服务 A)从另一个微服务(我们将它称为微服务 B)请求某些资源时,它不需要了解微服务 B 的内部结构。作为交换,微服务 B 可能使用与微服务 A 相同的端点但不同实现形式,而无需通知微服务 A。
微服务通信技术
与微服务最大的通信挑战是建立它们之间可靠和可扩展的连接。我们使用不同的技术来处理微服务通信。值得一提的是,有多种技术可以实现微服务之间的适当通信。然而,在本章中,我们只会关注最流行和我们认为最重要的技术。让我们一起来探索这些技术。
API 简介
在软件开发中,应用程序编程接口(APIs)是帮助我们避免重复做同样任务的重要工具。API 保护我们免受工作环境和专业领域的复杂性,使我们更容易管理复杂的过程并忽略详细的领域知识。它们封装了复杂性,使得理解底层实现细节变得不必要。API 本身是黑盒,只提供所需的信息。这意味着我们不必担心琐碎的技术细节,可以专注于使用提供的接口与环境交互。例如,当我们使用框架时,我们看到所有的 DLL(包)都是 API,它们为我们提供了所需的函数。
基于网络的 API 只是 API 的一种类型。它用于帮助不同的程序通过标准网络规则和方法在互联网上共享信息。通常,这些 API 被视为 REST API 或简单对象访问协议(SOAP)服务,并依赖于客户端-服务器架构。这允许在网站或服务等各种在线资源之间建立连接,有助于构建像微服务或仅仅是在共享数据方面的大型系统。
图 2.3:客户端-服务器架构
我们在这里讨论了 REST,但它究竟意味着什么?让我们在下一节中讨论这个问题。
REST 究竟是什么?
表示状态转换(REST)是一种用于构建通过 HTTP 协议松散连接的应用程序的架构风格。需要注意的是,REST 主要是一种设计方法,而不是严格的架构模式。当我考虑架构风格和模式之间的区别时,我总是感知到抽象和实现。
建筑风格定义了一套组织和构建系统结构的原理和指南。它提供了一个高级抽象,说明了不同组件应该如何相互交互和通信。建筑风格是设计软件系统的广泛、概念性的方法。
另一方面,建筑模式是对重复出现的建筑问题的一种特定解决方案。它是一个可重用的设计蓝图或模板,描述了在给定的建筑风格中如何解决特定设计问题。它为实施风格的特定方面提供了一个具体的蓝图。
许多人称 REST 为协议。然而,REST 本身不是一个标准化的协议,尽管它今天通常使用网络标准来实现。虽然它通常与 HTTP 相关,但它并不仅限于这个协议;REST 也可以与其他协议一起工作。
简而言之,将 REST 视为系统通信的蓝图。它概述了客户端(如网页浏览器)如何从服务器请求信息,以及服务器如何响应,可能改变客户端的状态。
REST 的核心思想是资源、表示、状态和传输。当客户端从服务器请求资源时,服务器会发送该资源的表示,这本质上是一个副本。如果资源的状态后来发生变化,客户端可以再次请求它以获取最新版本。将此资源从服务器发送到客户端的过程是 REST 的传输部分。
图 2.4:REST 概述
那么,为什么 REST 很重要?
-
它将客户端和服务器分开,打破了它们对彼此的直接依赖。
-
它是平台无关的,这意味着它不受任何特定系统的限制。
-
它是语言无关的。无论你是在用 PHP、C#、Node.js 还是任何其他语言编码,你都可以实现 REST 服务。
-
它对数据格式灵活,支持 XML 和 JSON 等多种格式。
-
它促进了分布式系统的构建,使组件可以分布在不同的位置。
-
它提供了可发现性,使得识别和访问资源变得容易。
-
它使用简单,简化了服务集成的过程。
-
它利用 HTTP 缓存,通过在本地存储频繁访问的数据来提高性能。
基于这种理解,让我们来看看 REST 的一些约束。
有哪些 REST 限制?
一个服务遵循 REST 原则的关键指标在于它遵循的约束。REST 遵循六个约束,其中五个是强制性的。
-
students
是资源。 -
通过表示操作资源:如果客户端可以修改资源,则应在返回的表示中包含有关如何操作的数据。
-
自我描述的消息: 每个请求都应该包含所有必要的信息,这些信息通常通过 HTTP 头部传递。
-
超媒体作为应用状态引擎(HATEOAS):请求应提供文档,使客户端能够轻松发现其他资源。
-
客户端-服务器约束:这种约束将客户端和服务器分开。在这里,客户端和服务器促进数据交换。它们独立发展,并且对彼此的架构一无所知。
基于 REST 的最流行实现称为RESTful 服务。RESTful 服务高度依赖于 REST 原则。但这并不意味着使用 RESTful 服务创建工具就一定能确保您最终得到基于 REST 的服务。您应该学习和应用这些原则,使您的 API 更加RESTful。
将 RESTful API 视为微服务的一种通用语言。一个服务可以向另一个服务发送 HTTP 请求(例如获取用户数据
),并获取所需的信息。这使事情变得简单,避免了服务需要理解彼此内部工作的情况。
RESTful API 在微服务之间充当信使,确保它们即使作为独立的组件也能顺利协同工作。
RESTful API 是如何工作的?
想象一下在线存储的信息,如图片、文本或数据。这些信息就像是一种资源。当应用程序需要这种资源时,它会发送一个消息,称为请求,向服务器请求它。将请求视为一种礼貌地请求某物的途径。
为了确保服务器理解请求,应用程序遵循一系列指令,就像食谱一样。这些指令被称为文档,由服务器创建者提供。
根据信息的敏感程度,服务器可能会检查应用程序的身份,类似于进入安全场所前检查您的身份证件。这被称为身份验证。一旦服务器收到并理解了请求,它就会处理它并找到所需的信息。然后服务器将响应发送回应用程序。
基于 HTTP 的 REST 实现
开发者通常使用一种称为 HTTP 的特殊网络通信语言来构建 RESTful API,这种语言类似于与网站交谈的语言。在这种语言中,有一些特殊的词汇称为HTTP 方法,它们告诉服务器如何处理信息(资源)。让我们在接下来的几个小节中一起发现最常用的 HTTP 方法。
GET
当我们作为开发者想要请求给定资源的表示而不更改它时,我们使用 GET 方法。
HTTP GET 方法是服务器检索特定资源而不对其进行修改的请求。这就像说“请给我这个信息。”
这里是如何工作的分解:
-
你(客户端,比如你的网络浏览器)使用 GET 方法向服务器发送请求。这个请求就像一条消息,请求你想要的资源。
-
网站接收请求,并由于 GET 方法而理解了你的请求。
-
网站找到你请求的信息,并以响应的形式将其发送给你。这个响应可能包括文本、图像或其他数据。
想象一下,请求朋友在他们的手机上给你看一张照片。你不会只是抢过来;你会礼貌地请求看它。同样,GET 方法允许你礼貌地从网站获取信息。
重要的是要注意,GET 方法用于检索信息,不应用于更改或修改网站上的任何内容。想象一下,从图书馆借书,而不是在书上写东西。
POST
POST 方法用于在服务器上创建资源。
将 POST 方法想象成一种安全地向网站(服务器)发送信息的方法。这就像填写一个表单并点击 提交 来发送你的数据。
这里是如何工作的:
-
你(客户端)填写一个表单,包含你想要发送的信息(如你的名字和电子邮件)。
-
你的工具(通常是浏览器)使用 POST 方法向网站(服务器)发送请求。这个请求包括你在表单中输入的信息。
-
网站接收请求,并由于 POST 方法而理解了信息。
-
服务器处理你发送的信息,这可能涉及创建账户、存储评论或发送电子邮件等操作。
-
网站可能会然后发送一个响应给你,比如确认消息或一个新页面来查看。
想象一下,向朋友发送包裹。你不会只是把它放在他们的门口;你会打包并通过可靠的快递服务发送。同样,POST 方法允许你安全地向网站发送信息。
记住,POST 方法主要用于发送和加工信息,而不仅仅是查看信息。与用于检索信息的 GET 不同,POST 通常会在服务器上触发操作。
PUT
我们使用 HTTP PUT 方法来更新服务器上的现有信息。
将 PUT 想象成一种小心地更新网站(服务器)上现有信息的方法。这就像仔细修订文档或更新你的个人资料详情。
这里是如何工作的分解:
-
你(客户端,比如你的网络浏览器)准备更新的信息,例如更改你的个人资料图片或文档中的编辑文本。
-
您的浏览器使用 PUT 方法向网站(服务器)发送请求。此请求包含您准备好的更新信息。
-
网站(服务器)通过 PUT 方法接收请求并理解需要更新的内容。
-
服务器仔细地将您发送的更新版本替换现有的信息。这类似于仔细替换书中的修订版页面。
-
网站可能会随后向您发送响应,例如确认消息或更新的信息本身。
记住使用 PUT 需要谨慎和准确,因为它直接修改现有信息。想象一下,就像仔细编辑文档一样;犯错误可能会导致更改或丢失重要信息。
PUT 方法通常用于您确切知道需要更新的信息,并且希望完全替换它。它不用于检索信息(如 GET)或创建新信息(如 POST)。
DELETE
HTTP DELETE 方法就像数字橡皮擦,仔细地从服务器中删除特定的信息。您可以将其想象为从您的购物清单中删除单个项目,而其余部分保持不变。
下面是如何工作的:
-
您(客户端),就像您的网络浏览器一样,决定删除某些特定内容,例如旧照片或过时的文章。
-
您的浏览器向服务器发送 DELETE 请求,精确指出您想要删除的特定项目。这就像在您的购物清单上指明项目一样。
-
服务器通过 DELETE 方法接收请求并理解其意图。
-
服务器仔细地从其存储中移除指定的项目,类似于在您的清单上划掉项目。
服务器可能会回复一个确认消息,让您知道删除操作已成功完成,或者简单地保持沉默。
您应该记住使用 DELETE 就像使用橡皮擦一样:它是永久的。一旦某物被删除,通常就会永久消失。因此,在发送 DELETE 请求之前,请确保您确实想要删除该项目。
DELETE 与其他方法不同。与用于获取信息的 GET(或用于创建新信息的 POST)不同,DELETE 专门用于删除某些内容。
在 DELETE 请求中保持精确很重要,因为它们针对特定项目,并且无法轻易撤销。
PATCH
PATCH 方法类似于服务器上信息的修补工具。它与 PUT 方法类似,但 PATCH 允许您更新信息的一部分,例如编辑文档的特定部分而不改变整个文档。
想象一下,您有一个包含面包、牛奶和鸡蛋等项目的购物清单。您意识到您需要更多的牛奶,但其他一切都很正常。您不必重写整个清单,可以使用 PATCH 仅更新牛奶的数量。这样,清单的其余部分保持不变。
下面是分解:
-
您(用户)决定更改某个特定部分,例如在线更新商品的价格。
-
您向服务器发送一个 PATCH 请求,指出您想要修改的确切部分,例如在我们的例子中是价格。
-
服务器因为 PATCH 方法理解了请求。
-
服务器仔细地只更新了选定的部分,其余部分保持不变,类似于只编辑文档的一个部分。
-
服务器可能会发送确认信息,或者简单地显示更新后的信息。
注意,PATCH 是一种用于进行针对性更新的方法,仅修改资源的具体部分。当您只想更改某个特定部分时,保持其余部分不变,这使其比完全重写(如使用 PUT 方法)更灵活和高效。
现在我们已经了解了 HTTP 动词,让我们继续讨论 HTTP 状态码。
HTTP 响应状态码
HTTP 使用客户端-服务器架构。发送请求总是以响应结束。根据请求,您可能会得到不同的响应状态码,这些状态码表示操作的结果。
HTTP 状态码是服务器对您的请求成功或失败的反应。这些代码被组织成五个主要组:
-
信息响应(
100
–199
) -
成功响应(
200
–299
) -
重定向消息(
300
–399
) -
客户端错误响应(
400
–499
) -
服务器错误响应(
500
–599
)
让我们来看看最常用的 HTTP 响应状态码:
-
2
):-
200 OK:这是您可以得到的最好的消息!这意味着服务器理解了您的请求并完成了您的要求。
-
201 已创建:这个代码表示您的请求导致创建了新的东西,比如新的文档或账户。
-
-
3
):-
301 永久移动:这告诉您请求的项目已经永久移动到新的位置。服务器通常会提供新的地址。
-
302 找到:这个代码表示请求的项目暂时位于新的位置。服务器还提供了新的地址。
-
-
4
):-
404 未找到:这是一个常见的代码,表示服务器找不到您请求的项目,就像图书馆里丢失的一本书一样。
-
401 未授权:这个代码表示您无权访问请求的项目,就像试图进入一个锁着的房间一样。
-
403 禁止访问:这个代码表示您有权限,但没有权限访问特定的项目,就像有钥匙但被禁止进入特定的房间一样。
-
413 请求实体过大:这个代码表示您在请求中发送的数据对于服务器来说太大,就像试图把太多的书塞进一个小盒子一样。
-
429 请求过多:这个代码表示您在短时间内发出了过多的请求,就像试图一次性借阅太多的书一样。
-
-
5
):-
500 内部服务器错误:这个代码意味着计算机遇到了内部问题,无法满足你的请求,就像图书馆遇到技术问题一样。
-
503 服务不可用:这个代码表示计算机因维护或过载而暂时不可用,就像图书馆因翻修而关闭一样。
-
记住,这只是几个例子,但理解它们可以帮助你解码网站上的信息,并更顺畅地在网上导航。
GraphQL 简介
我们现在设计和处理 API 的两种主要方式是使用 REST 和GraphQL。
通常,当开发者开始他们的 API 开发之旅时,他们首先从创建一个 REST API 开始。然而,随着他们深入了解具体细节,他们意识到 REST 方法并不总是适用于所有情况。这时,GraphQL 就介入以帮助 REST 不适合的情况。
尽管 Facebook 早在 2012 年就开始在真实项目中使用 GraphQL(当时称为 SuperGraph),但它直到 2016 年才向公众分享。
现在,GraphQL 已经成为设计和交互 API 的首选方法,在受欢迎程度上超过了 REST。
采用 GraphQL 的主要原因是为了解决当应用的移动和桌面版本都使用相同的 API(特别是 REST API)时出现的问题。比如说你正在用手机上的网站。桌面版本可以处理更多的数据,因此从服务器接收的数据集更全面。与桌面版本不同,移动应用由于网络连接缓慢和计算能力有限,对数据的使用有限制,这使得处理大量数据集变得不可能。
这里有一个 REST 响应的例子,它包含过多的数据,而我们需要的信息只是其中的一部分:
{
"user": {
"id": 5,
"name": "username",
"surname": "surname",
"rank": 56,
"email": "example@gmail.com",
"profilephoto": "....................."
}
}
因此,当首次加载用户数据时,我们只需要在移动版本中这部分数据。正因为如此,基于资源的(使用 REST)数据分发似乎是一个理想的设计选择。由于大多数桌面用户互联网可用性没有问题,所以从不同资源接收数据并将它们显示为单一信息没有问题。
为了确保我们只获取所需的信息,我们根据其ID过滤数据。这样做,我们进行了一种同步操作。
然而,我们并不是在建议一个理想的移动数据检索解决方案。相反,我们提出要么为移动设备开发一个专门的 API,要么通过采用替代的 RESTGraphQL 方法来实现一个更灵活和动态的资源获取机制。GraphQL 提供了一种更高效的 API 交互和设计方法,尤其是在考虑移动和其他资源受限设备时。最初旨在用于移动应用,GraphQL 已经发展成为一种支持跨各种平台动态和有效数据交换的技术。与传统 REST API 设计不同,GraphQL 允许在单个查询中从多个资源同步检索信息,从而消除了按资源逐个获取数据的需求。
除了在移动开发中的应用外,GraphQL 现在在微服务设计中得到了广泛的应用,进一步展示了其多功能性和在现代软件开发实践中的广泛应用。
在实际应用 REST 和 GraphQL 时,理解它们之间的区别非常重要。让我们定义这两种强大通信技术之间的主要区别:
-
与桌面应用相比,移动应用需要更精简和高效的数据。常用于桌面的 REST API 对于移动应用来说可能会显得庞大。GraphQL 通过让您仅请求应用所需的具体数据来解决这一问题,避免了不必要的下载,同时减少了过度获取和不足获取。
-
REST 通过关注单个信息片段,如用户或帖子来工作。要获取相关细节,例如特定用户的帖子信息,您需要为每个片段单独发出请求。这可能会感觉比较慢,因为您必须等待一个请求完成才能发出下一个请求。简而言之,REST API 通常为每个访问点提供一组信息。相比之下,GraphQL 允许您从单个点请求各种数据组合,使其更加灵活和适应性强。
-
在 REST 中,从多个来源收集信息需要单独的请求,这有时会导致延迟。然而,GraphQL 允许您一次性从多个来源获取数据,并将其作为一个单一单元呈现给用户。这减少了请求的数量,使您的应用感觉更加流畅。
-
更新 REST API 通常需要对其进行版本控制,以避免破坏现有应用。GraphQL 通过允许在不要求应用端进行更改的情况下添加功能,避免了这种复杂性,使得保持内容更新变得更加容易。
-
与专注于管理特定数据片段的 REST 不同,GraphQL 是考虑到移动和前端体验而构建的。这意味着它允许您做以下事情:
-
仅获取您的应用所需的数据,减少传输的信息量(较小的有效载荷大小)
-
通过一次性获取所有内容来避免不必要的请求,使您的应用更快、更高效
-
-
REST API 由于其结构固定而难以应对频繁的变化。想象一个可以接收许多不同指令或以各种格式提供答案的端点。这可能会对 REST 产生问题。然而,GraphQL 处理这些情况要好得多。
-
在微服务架构中,每个服务管理自己的数据,GraphQL 发挥了作用。它允许你在单个请求中结合来自多个服务的信息,将其作为单一统一的数据块呈现给用户。这使得构建和管理复杂应用程序变得更加容易。
-
当开发单页或原生移动应用时,GraphQL 的“只提供用户所需的信息”和“专注于单一统一资源”的原则使我们能够开发前端密集型应用程序。
-
在 GraphQL 中,前端团队可以在不等待后端团队创建特定 REST 端点的情况下工作(用户故事)。这允许更快地进步和更快的应用程序更新。
-
RESTful API 使用 HTTP 方法如 PUT、DELETE、POST 和 PATCH 进行数据修改,在 GraphQL 中统称为突变。另一方面,GraphQL 使用查询操作来检索数据。
在大多数情况下,拥有 REST 和 GraphQL 就足够了。然而,它们并不是微服务的唯一通信机制。我们还有一个有趣的通信协议,称为 远程过程调用(RPC)。让我们深入了解。
远程过程调用
在建立微服务之间的通信时,我们还可以使用 RPC 这样的通信协议。使用 RPC,一个微服务可以请求位于给定网络上另一台计算机上的另一个微服务的服务,而无需了解网络细节。
RPC 是这些微服务之间通信的方法。它就像一个程序请求另一台不同计算机上的程序执行某个操作,尽管它们并没有直接连接。这使得微服务更容易协同工作。
让我们定义核心 RPC 流程:
-
想象一个微服务请求另一台不同计算机上的微服务运行一个特定的函数(如任务)。这个请求被伪装成常规函数调用,尽管它是在网络上发生的。
-
一个中间人服务(称为代理)拦截这个请求,并处理背后的复杂网络通信。
-
代理发送一个包含函数细节和所需数据的消息到目标微服务。
-
目标微服务接收消息,理解功能请求,并执行它。
-
一旦完成,它将通过代理发送一个响应消息。
-
最后,代理将响应发送给请求的微服务,使其看起来像常规函数调用。
这个流程听起来很熟悉,不是吗?那是因为它只是一个简单的客户端-服务器机制。在 RPC 的意义和流程明确之后,是时候了解其其他一些重要方面了。
RPC 的优缺点
微服务 RPC 的好处有哪些?让我们强调主要观点:
-
更容易开发:使用 RPC 就像进行常规函数调用一样,这是程序员已经熟悉的。这使得构建微服务变得简单,因为开发者不需要担心网络通信的技术细节。
-
可能更快:RPC 可能比其他方法更快,因为它使用专门为这些调用设计的预定义数据格式,而不是需要解释像 JSON 或 XML 这样的复杂格式。
在使用 RPC 时,有哪些重要的事项需要考虑?
让我们来看看:
-
紧密连接:RPC 可以使微服务更加依赖彼此。如果一个微服务更改了其函数的工作方式(它们的接口),它可能会影响所有依赖它的其他微服务。
-
有限灵活性:选择特定的 RPC 框架可能会使以后切换到另一个框架变得更加困难。
总结来说,RPC 是微服务之间通信的强大工具。它简化了开发过程,可以快速进行,但在决定它是否适合您的项目时,您应该考虑到更紧密连接和有限灵活性的潜在缺点。
RPC 工具
有一些专门为使微服务之间的通信更顺畅而构建的 RPC 框架。这些框架就像工具包一样简化了过程。以下是一些流行的框架:
-
gRPC:这个框架专注于使事物变得快速和高效。它使用一种特殊的格式,称为协议缓冲区,以紧凑且易于传输的方式打包数据。
-
Apache Thrift:另一个流行的选择,Thrift 以其能够与多种不同的编程语言一起工作而闻名,并且可以处理各种数据打包格式。
-
SOAP:提供了一种使用 XML 编码消息并使用 HTTP 和 HTTPs 协议(主要是 HTTP 和 HTTPs)传输消息的标准方式。我们可以使用 SOAP 来实现 RPC。这允许应用程序像调用本地过程一样调用远程服务器上的过程。但我们应该考虑到 SOAP 并不局限于 RPC,也可以用于更通用的消息交换过程。
-
Windows Communication Foundation(WCF):WCF 是由微软开发的一个框架,用于构建面向服务的应用程序。它可以用来实现包括 RPC 在内的各种通信机制。WCF 提供的功能远不止 RPC。它提供了诸如数据契约、服务托管和安全等功能。因此,虽然 WCF 可以用于 RPC,但它并不局限于这种特定方法。
RPC 是微服务之间通信的一种强大方式。它使开发变得更简单,并且可能比其他方法更快。然而,在决定它是否是您项目的最佳选择时,请记住更紧密连接和有限灵活性的潜在缺点。仔细权衡利弊,为您的特定需求做出正确的决定。
在讨论了通信协议背后的基本思想之后,现在是时候讨论微服务的通信方法了。了解每种方法的优缺点对于设计健壮和可扩展的微服务架构至关重要。
同步微服务通信
为了在微服务之间交换一些信息,它们需要能够相互交谈。在微服务通信中,我们主要使用两种主要模式来建立微服务之间的通信。它们是同步通信(sync)和异步通信(async)。
同步是两种通信模式中最简单的一种。
当一个微服务需要从另一个微服务获取信息时,它会直接发出请求并等待回复,然后再继续前进。这种通信本身更简单、更可靠。如果你将其与单体应用程序进行比较,它只是一个函数或方法调用。
下面是步骤分解:
-
调用微服务向另一个微服务发送消息。
-
调用微服务暂停其工作并等待回复。
-
另一个微服务处理请求并发送响应回来。
-
一旦调用微服务收到响应,它就可以继续其任务。
当实现依赖于 HTTP 的微服务之间的同步时,我们主要使用 REST、GraphQL 和 gRPC。
这张图像展示了订单 API 和库存 API 之间简单的同步通信。
图 2.5:同步微服务通信
现在我们来看看同步的一些重要方面。
它的一些优点如下:
-
简单且可预测:执行流程直截了当。
-
即时反馈:请求者立即收到响应,这使得它适合交互式应用程序。
当然,正如我们之前所学的,没有什么是没有缺点的。这种沟通形式也带来了一些不利因素:
-
阻塞:请求者在收到响应之前被阻塞,如果接收服务花费很长时间,可能会导致性能问题。
-
耦合:服务变得紧密耦合,这使得它们更难独立更改和扩展。
-
单点故障:如果响应的微服务不可用,整个流程将被阻塞。
在理解了这种通信的流程以及一些优点和缺点之后,了解我们如何将其应用于一些现实世界的情况也很重要。
下面是微服务中同步的一些现实世界例子:
-
购物车:想象你在网上填满购物车并点击支付。购物车服务(如你的购物清单)会直接与支付服务(如收银员)交谈,在创建订单之前确认支付。这样,你可以立即知道支付是否成功。
-
捕捉作弊者:当你在线下单时,订单服务(如订单接收者)会要求欺诈检查员(如保安)查看是否可以。检查员立即回答是或否,因此只有真实订单才会通过。
-
实时聊天 支持:当你在一个聊天中输入消息时,你的消息会发送到一个寻找代理(如寻找助手)的服务。代理收到你的消息并直接回复,这样你可以快速地来回聊天。
-
在线游戏:在在线游戏中,你的动作会发送到游戏服务器(如游戏裁判)。服务器根据你的动作更新游戏世界(如改变分数),并将其发送回所有玩家,保持游戏对所有玩家的流畅。
-
股票交易:当你在一个应用中买卖股票时,它会直接与你经纪公司的服务(如你的投资顾问)交谈。这个服务立即进行交易,告诉应用已完成,并更新你的账户余额。这让你能快速确认,以便管理你的资金。
这些只是几个例子,总的来说,你应该理解,以下情况下同步是理想的:
-
立即反馈和互动
-
实时对话
-
实时决策和执行
-
即时分析和实施
-
紧密耦合的工作流程,高度依赖
异步微服务通信
同步就像直接对话,但异步更像是留条子。微服务不会等待回复,它们只是发送信息然后继续。
在异步通信中,请求服务向接收服务发送消息,而不等待立即响应。响应稍后通过回调或通过单独的通道传递。
图 2.6:异步微服务通信
下面是步骤分解:
-
发送消息:调用微服务将包含信息的消息发送给另一个微服务。
-
继续进行:调用微服务不会等待回复。它继续自己的任务。
-
稍后处理:其他微服务在空闲时接收消息并对其进行处理。
-
回复:其他微服务可能会稍后发送响应,但这不是必需的。
想象成给某人留条子——他们有机会时就会看到。
在大多数情况下,我们使用消息代理来处理微服务之间的异步通信。
对于复杂的通信模式、高消息量或关键任务,消息代理是一个不错的选择。对于可靠性不那么关键的场景,直接队列或事件源可能是比消息代理更好的替代方案。
让我们来看看使用异步在微服务之间最重要的优点:
-
非阻塞:请求者可以在等待响应的同时继续处理,从而提高性能和可扩展性。
-
解耦:服务是松散耦合的,这使得它们更容易独立更改和扩展。
-
弹性:异步比同步更能优雅地处理失败和重试。
-
独立工作:微服务可以专注于自己的任务,无需担心其他服务正忙。
然而,它也有一些缺点:
-
延迟结果:你可能无法立即知道消息是否被接收或处理。
-
更复杂:设置异步可能比同步更困难。同步在建立通信时不需要额外的层。然而,正如我之前提到的,对于真正的异步,你通常需要在通信服务之间使用消息代理作为中间件。
接下来,让我们看看这种通信类型的一些实际应用:
-
发送电子邮件:订单服务可以发送订单确认电子邮件,而无需等待其发送。
-
更新库存:当销售发生时,订单服务可以向更新库存的消息发送消息。在此期间,它还可以继续处理其他订单。
-
长时间运行的任务:微服务可以向另一个服务发送消息以执行耗时的工作,例如视频编码,而无需等待。
-
社交媒体动态更新:发布服务向动态队列发布消息。动态服务订阅队列并在后台更新用户动态。
当以下任何条件适用时,最好选择在微服务之间使用异步:
-
立即响应不是必需的
-
需要后台任务或长时间运行的过程
-
解耦和可扩展性是关键考虑因素
异步非常适合不需要立即答案的任务,但对于需要快速响应的事情,同步可能更好。
重要的通信模式
在构建微服务时,我们通常需要一个单一的入口点,让我们的客户端消费我们开发的服务。你可以把它想象成覆盖你服务的装饰器或包装器。客户端通过一个简单的门(API 网关)与你的应用程序交谈,而不是需要了解混乱的内部工作(微服务)。网关根据 URL 模式、路径变量或头等因素智能地将传入的请求路由到适当的微服务。
微服务架构的一个重要组成部分是 API 网关。这个 API 提供了几个功能,以帮助管理和暴露多个微服务之间的常见功能。通常,客户端请求可能需要来自多个微服务的数据。网关充当协调者,从相关服务获取数据(如果需要的话),聚合它,并向客户端返回一个连贯的响应。简而言之,API 网关是微服务架构的必要组件。它们为客户端提供了一个单一的入口点,增强了安全性、可管理性以及微服务的整体效率)
图 2.7:API 网关
现在,让我们尝试总结 API 网关的好处:
-
单一入口点:无需了解微服务的内部结构。使用 API 网关可以帮助您隔离这些细节。
-
请求路由:API 网关充当智能中间件,将用户的查询路由到确切的微服务。
-
聚合器:网关充当协调者,从相关服务获取数据,如果需要的话,聚合它,并向客户端返回一个连贯的响应。
-
安全性:网关可以成为认证、授权和速率限制的中心枢纽。它验证客户端身份,执行访问控制,并防止潜在的滥用。
-
转换:网关可以操作请求和响应,以匹配后端服务的预期格式或为客户端定制响应。这包括内容协商、协议转换和数据验证等任务。
-
常见任务:网关可以处理常见的任务,如请求验证、缓存和日志记录,减轻单个微服务的负担。
-
负载均衡:当有大量工作要做(高流量)时,负载均衡就像一个交通指挥官。它巧妙地将传入请求分配到同一微服务的多个副本(实例)上。没有单个微服务会过载,从而最大限度地利用所有可用资源。
-
断路器:API 网关通常包含一个断路器,用于弹性、级联故障和系统稳定性保护。微服务中的断路器就像一个开关,如果服务响应不佳,它会自动停止向该服务发送请求,从而帮助防止系统级故障。
消息代理
在直接通信模型中,微服务必须了解彼此的位置和可用性。这创建了一个紧密耦合的系统,难以维护和更新。
通过引入消息代理,微服务变得松散耦合。它们只需向代理发送消息,代理处理路由和交付。这使得微服务可以独立运行,无需了解其他服务的具体细节。
使用消息代理,通信变得异步。生产者(微服务)可以发送消息,而无需等待消费者的响应(另一个微服务)。这提高了性能和可伸缩性,因为微服务不需要相互等待。
图 2.8:消息代理
消息代理使用不同的消息存储模型。其中最受欢迎的是消息队列(MQs)和主题。消息代理将消息存储在 MQ 中,直到它们被消费者接收。MQs 是消息代理的关键部分,充当数据的存储。与主题不同,它们在消费过程之后会删除数据。
让我们尝试理解典型消息代理的组件:
-
生产者(发布者):这是发送消息的那个。
-
消费者(订阅者):这是读取消息的那个。
-
MQ(或主题):这是存储消息的那个。主题允许发布-订阅模型,其中多个消费者可以接收相同的信息。另一方面,MQ 队列遵循先进先出(FIFO)的方法,确保消息按照接收的顺序进行处理。
目前有很多流行的消息代理实现。在实践中,我们将使用 Apache Kafka,这是最受欢迎的消息代理之一。让我们来谈谈最流行的消息代理实现:
-
RabbitMQ:这是一个广为人知的开源消息代理,以其灵活性和易用性而闻名。它支持不同的消息模式,包括点对点(只有一个特定的应用程序接收消息)和发布者与订阅者(pub/sub)。它充当您应用程序的中心邮局。应用程序可以发送和接收消息,而无需知道彼此的确切地址。它帮助我们实现紧密耦合的通信。它是灵活的、易用的,并且拥有庞大的社区。
-
Apache Kafka:它是实现消息代理的一个强大选项。它具有高吞吐量、持久性、可伸缩性、容错性、实时处理等关键属性。Apache Kafka 不仅仅是一个具有数据存储和流处理集成的消息代理。我们将在下一章中更多地讨论 Kafka 的内部结构。
-
Amazon Simple Queue Service(SQS):与其他消息代理实现一样,它提供了解耦和可扩展的微服务。SQS 充当一个队列,您可以在其中发送消息(数据),安全地存储它们,然后通过其他应用程序或服务检索它们。SQS 通过允许应用程序异步通信来解耦应用程序。发送者不需要等待接收者可用,从而提高了整体应用程序的响应性和可伸缩性。
如果你需要实时流处理,Apache Kafka 可能比 RabbitMQ 和 SQS 更适合你。另一方面,如果你喜欢有高级功能,如消息过滤或优先队列,请使用 RabbitMQ 而不是 SQS。
摘要
本章是关于微服务通信的。我们讨论了不同的通信技术,如 REST、GraphQL 和 RPC。
我们讨论了在微服务之间我们主要使用两种通信形式:同步和异步。同步简单且具有即时反馈,但它是一个阻塞操作,具有单点故障和耦合等属性。我们讨论了同步的优点和缺点,并讨论了在实践中的使用和不用同步的情况。
另一方面,我们了解到异步是非阻塞的,并带有延迟响应。它通常更可取(取决于任务),但带来了额外的复杂性。为了实现异步,我们了解到我们通常需要额外的层,如消息代理。
讨论的最后一部分集中在最常用的模式,如 API 网关和消息代理。
API 网关作为一个编排者,提供了一个单一的入口点,并提供了额外的功能,如安全、转换、负载均衡等。它是微服务通信的一个关键部分。
我们进一步学习了如何建立异步通信。使用消息代理,我们主要在微服务之间建立异步通信。它是在服务之间处理常见任务(取决于消息代理实现)的额外层。它有多个实现,如 RabbitMQ、Apache Kafka、Amazon SQS 等。
从下一章开始,我们将介绍在深入微服务开发细节之前你需要了解的 JavaScript 和 NodeJS 基础知识。敬请期待!
第三章:开始之前您需要什么?
微服务方法本身并不依赖于任何特定的编程语言。您可以使用不同的编程语言来实现它。微服务概念支持在单个应用程序中使用不同的语言来构建不同的服务。这意味着每个服务的编程语言选择可以基于其特定的需求和功能。例如,您可以使用 C#实现microservice A,但microservice B使用 JavaScript。这就是微服务开发的美丽之处,它允许我们绕过编程语言障碍。
本书是关于用 JavaScript 编写微服务的。与任何编程语言一样,在实施微服务方法之前,最好先了解该语言的基本知识,这将帮助我们构建更好、更有效的微服务。本章的重点是提供基础,而不是全面的指南,包括 Node.js。在实施任何使用该语言的微服务应用程序之前,有一些主题,尤其是在 JavaScript 中,需要先进行回顾。
在本章中,我们将探讨以下主题:
-
JavaScript 基础知识
-
Node.js 基础知识
技术要求
对于本章,您需要以下内容:
-
浏览器(选择您喜欢的)
-
Visual Studio Code(或者您可以使用操作系统默认的文本编辑器):只需访问
code.visualstudio.com/
并安装它 -
GitHub:访问
github.com/PacktPublishing/Hands-on-Microservices-with-JavaScript/tree/main/Ch03
下一章将详细解释所需的软件安装过程。目前,您可以在不使用任何 GitHub 命令的情况下下载 GitHub 仓库源代码,并对其进行实验。
JavaScript 基础知识
JavaScript 是一种流行的、单线程的、同步的编程语言,它主要帮助我们构建交互式 Web 应用程序。其优势在于能够混合不同的编程方法。这种混合让您可以用多种方式编写代码:关注对象、使用函数作为构建块、对事件做出反应,或提供逐步指令,使您的代码更加清晰且易于处理。在浏览器或 Node.js 中使用的 JavaScript 并非完全原生。这就是为什么我们需要区分JavaScript 引擎和JavaScript 运行时的概念。
JavaScript 引擎
JavaScript 引擎是一种特殊的程序,它读取、解析和将我们的 JavaScript 代码翻译成计算机可理解的语言(机器指令)——见 图 3.1。我们并不是所有浏览器都只有一个 JavaScript 引擎。例如,Google Chrome、Opera 和最新的 Microsoft Edge 浏览器使用 V8 引擎,Firefox 使用 SpiderMonkey,Safari 使用 JavascriptCore 引擎。任何有能力遵循 ECMAScript 引擎标准的人都可以创建自己的 JavaScript 引擎。
但我们如何确保在所有浏览器中具有兼容的 JavaScript 呢?我们如何确保 JavaScript 代码能在所有浏览器中运行?这就是为什么我们需要一个标准,它将确切地告诉我们需要做什么来确保 JavaScript 在所有浏览器中都能运行。幸运的是,我们有一套规则组合,可以确保在不同浏览器之间的一致性。这本脚本必备的规则手册被称为 ECMAScript (ES)。当然,ES 不仅适用于 JavaScript;它也适用于其他脚本语言,但 JavaScript 是其最著名的实现。
图 3.1:JavaScript 翻译过程
让我们在接下来的几个小节中深入探讨 JavaScript 引擎。
调用栈和内存堆
JavaScript 引擎由多个元素组成,其中两个是 调用栈 和 内存堆 (图 3.2)。
图 3.2:调用栈和内存堆
当我们运行我们的 JavaScript 应用程序时,代码是在调用栈中执行的。把它想象成代码可以按给定顺序遍历的一系列步骤。另一方面,堆是关于数据存储的。它是无结构的内存,用于存储对象。
JavaScript 翻译过程
翻译器 是一个可以将人类可读的源代码翻译成机器可读指令的程序。它有两个主要部分。第一部分是 编译器。在编译过程中,程序将整个代码一次性转换成机器代码。第二部分是 解释器。在解释过程中,解释器遍历源代码并逐行执行,将其转换成机器指令。几年前,JavaScript 一直是一种纯解释语言,但幸运的是,一些现代 JavaScript 引擎以混合模式进行翻译。现代 JavaScript 引擎结合了解释和 JIT 编译,其中解释器逐行运行代码,而编译器将频繁使用的代码转换成机器代码以优化性能。例如,V8 引擎结合了编译器和解释器,这被称为 即时 (JIT) 编译过程。
解释器在启动和运行方面速度很快。无需将源代码转换为另一种语言,这意味着没有编译步骤。对于立即执行等选项,解释器比编译器更合适。这里的主要问题是,如果你反复运行相同的代码(比如,相同的 JavaScript 函数),它可能会变得非常慢。解释器不会对你的代码进行任何优化。这时编译器就派上用场了。它比解释器花费更多的时间,因为它将你的代码转换为另一种语言,但它很聪明,当它再次看到相同的代码时,它会优化它以避免再次解释。
在翻译方面,JavaScript 结合了解释器和编译器的优势。
经典的编译意味着机器代码存储在一个可移植的文件中,并且可以随时执行,但对于即时编译器(JIT compiler)来说,情况略有不同。机器代码应该在编译结束时尽快执行。
让我们尝试理解在 Google 的 V8 引擎上如何工作 JavaScript 特定的 JIT。作为一个 JavaScript 运行时,Node.js 也依赖于 V8 引擎,理解其内部结构将极大地帮助我们从 Node.js 的角度来看。
当你执行用 JavaScript 编写的源代码时,JavaScript 引擎会解析它。解析器是 JavaScript 引擎的一个子元素,它接收你的源代码并输出标记。这就是 JavaScript 引擎理解是否存在错误的方式。它充当词法分析器,最终输出称为抽象语法树(AST)。要见证 AST 的美丽,你只需导航到 AST 浏览器(astexplorer.net/
),输入任何 JavaScript 代码,并查看解析过程。
AST 是引擎特定的数据结构,它通过将你的 JavaScript 代码的每一行分解成语言可理解的部分来生成。引擎使用它来生成机器代码。然后 AST 被解释器接收并转换为字节码。
字节码是一组特殊的指令集合,其与机器代码大致相似,但作为包装器并抽象化我们免受机器代码复杂性的影响。V8 的默认解释器称为Ignition,输出字节码;另一方面,编译器Turbofan将此字节码优化为高效的机器代码
Turbofan 也充当即时编译器。在解释器和编译器之间,有一个分析器,它分析解释过程(Ignition)并将需要优化的代码转发给编译器(Turbofan)。(见图 3**.3.)
图 3.3:JavaScript 引擎内部结构
好吧,但你可能想知道为什么我们需要理解 JavaScript 引擎底层的操作。实际上,这实际上会帮助你编写更好、更快的代码,并使用优化的微服务。这种知识将帮助你编写优化友好的代码。JavaScript 引擎并不总是能够应用优化,在某些情况下,它取决于你的编写风格。你可能会编写一些可能难以优化你的 JavaScript 引擎的代码。这就是为什么始终理解内部结构是更好的选择。例如,使用 delete
、evals
、arguments
和 with
关键字以及诸如 hidden classes 和 inline caching 这样的机制可能会减慢代码优化。这些概念超出了我们这本书的范围,但你可以通过查看开源 JavaScript 教程和文档来了解它们。
我们接下来需要讨论的是 JavaScript 的单线程特性,相信我,当我们深入研究线程细节时,事情会变得很有趣。
JavaScript 中的线程
正如我们之前提到的,JavaScript 是一种单线程、阻塞、同步的编程语言,它只有一个调用栈。JavaScript 是“单线程”的,但并不一定是“阻塞”的。它默认是同步的,但可以使用回调、承诺和 async/await 来处理异步代码执行。但这究竟意味着什么,为什么我们需要理解它呢?好吧,大多数编程语言都默认支持多线程。这意味着在给定时间内可以运行多个独立的操作。但是,当涉及到 JavaScript 时,事情就有些不同了。对于 JavaScript 来说,在给定时间内只能运行一组指令,这在多线程的世界中是一个大问题,尤其是对于长时间运行的任务。幸运的是,当你处理 JavaScript 时,你不仅处理 JavaScript 引擎,还处理一个叫做JavaScript 运行时的东西。
JavaScript 运行时
浏览器本质上是一个 JavaScript 引擎的包装器和运行时。运行时的责任是为给定上下文提供额外的支持,以实现所有必需的功能。在这里,在浏览器中,上下文是基于网络的交互式应用程序。Node.js 也是一个基于谷歌 V8 引擎的运行时。引擎可以帮助你扩展原生 JavaScript 引擎的功能,并给它们添加异步特性。这些功能在浏览器中统称为Web API。要查看基于浏览器的、重要的 Web API,请按照以下步骤操作:
-
打开您喜欢的浏览器(在我们的例子中,是谷歌 Chrome)。
-
右键点击并选择检查。
-
转到
window
。 -
按 Enter。
你也可以在图 3.4中看到这一点:
图 3.4:窗口全局对象
你可以看到的 window
对象的元素(其属性和方法)除了你的 JavaScript 引擎外,还内置在你使用的浏览器中。浏览器具有适用于所有浏览器的近似相似的 Web API。大多数核心 Web API 都被设计为标准化,以便网站可以在不同的浏览器中以类似的方式运行。然而,在实现或功能方面,浏览器之间可能存在细微的差异,并且某些 API 可能仅针对特定浏览器。大多数流行的 API 函数,如 fetch
、setTimeout
、setInterval
和 document
,都是这个称为 window
的大 API 的一部分。这意味着它们不是原生 JavaScript 函数,而是在给定上下文中为我们提供的基于引擎的函数。Web API 为我们的 JavaScript 代码添加了异步行为。
当你编写具有这些功能(fetch
、document
等)的代码时,JavaScript 引擎会将它们转发到 Web API。Web API 使用低级语言编写(在大多数情况下是 C/C++),执行后,你提供的回调,即给定指令中提供的回调,将被添加到回调队列中。所有原生 JavaScript 函数都将直接在调用栈中执行,但非原生指令需要首先在 Web API 中执行,执行的结果,作为一个回调,将被添加到回调队列(图 3.5.5)。
此外,还有事件循环。事件循环的职责仅仅是检查调用栈,并且只有在调用栈为空时才将回调队列元素推入其中。
为了确保我们提到的所有术语都得到很好的理解,让我们考虑一个简单的例子。看看图 3.5:
图 3.5: 带有回调队列的 JavaScript 事件循环
打开你喜欢的网络浏览器,在页面右键单击,选择书籍 GitHub 存储库中的 Ch03/js
文件夹中的 event_loop.js
:
function print(message) {
console.log(message);
}
setTimeout(() => {
print("Message from Timeout");
}, 0);
print("Message 1");
print("Message 2");
在我们的代码中,你可能会期望首先看到 setTimeout
的消息,然后按照给定的顺序看到其他消息。因为我们已经将 setTimeout
的值指定为 0
,它应该立即执行我们的代码。但输出是不同的,正如我们在图 3.6中看到的那样。
图 3.6: 调用栈在最后执行队列项
如你所猜,如果执行的函数是非原生的,这意味着它是一个基于 Web API 的函数,那么 JavaScript 引擎会将它转发到 Web API,并在执行后,将其回调添加到回调队列中。同时,setTimeout
将是一个非阻塞的异步操作,这就是为什么我们首先看到 print
函数的结果。
因此,无论你将setTimeout
的第二个参数设置为0
还是更多,它都会通过我们解释的管道。事件循环将检查调用栈是否为空,当两个print
函数都完成后,调用栈确实变为空,因此我们只能在事件循环将其推入调用栈后才能看到setTimeout
的结果。
现在让我们更详细地谈谈 JavaScript 的异步特性。
基于回调的异步 JavaScript
setTimeout
或setInterval
,你通常依赖于回调。
回调是必不可少的,但有时它们的用法会使你的代码更难以理解,更难以维护,尤其是在异步代码方面。为了证明这一点,请访问callback_hell.html
并在任何浏览器中运行它。(你可以在书籍 GitHub 仓库的Ch03
文件夹中找到callback_hell.html
。为了简化,所有 GitHub 仓库引用将只包含路径,即Ch03``/callback_hell.html
。)
该文件包含基于回调的多个异步操作,我们称之为回调地狱。
addScript("js/app.js", (script, error) => {
if (error) {
addErrorMessage("main", error.message);
} else {
setTimeout(() => {
let message = execute();
addSuccessMessage("main", message);
setTimeout(() => {
message = "operation completed successfully";
addSuccessMessage("main", message);
setTimeout(() => {
message = "ready for another execution";
addSuccessMessage("main", message);
}, 2000);
}, 3000);
}, 4000);
}
});
下面是这个脚本做了什么:
-
动态地将提供的脚本添加到 HTML 文件的头部。
-
运行属于该脚本的功能(在我们的例子中,它是一个
execute
函数)。 -
在三秒后运行
操作完成成功
的消息。 -
在前一条消息运行三秒后,它将
准备进行另一次执行
的消息输出到控制台。如果加载的文件不存在,则错误消息将被打印为输出。
如你所见,代码难以阅读、理解和维护,这主要是因为它的嵌套。在软件开发中,维护指的是保持软件正常、安全并更新到最新状态的持续过程。如果你有更多操作要做,可能会更复杂。
承诺的方式
虽然回调仍然是 JavaScript 的主要部分,但由于它们在可读性、错误处理和代码可维护性方面提供的优势,承诺(promises)是现代 JavaScript 开发中处理异步操作的首选方式。
承诺(Promises)是在 2015 年发布的ECMAScript 6(ES6)规范中添加到 JavaScript 中的,这使得在 JavaScript 中处理异步操作从回调地狱中解脱出来,现在我们有了异步编程的干净且更易于管理的途径。我们在 Node.js 开发中积极使用承诺,因此理解和正确使用它们至关重要。
要创建一个承诺(promise),我们使用一个Promise
对象。它有一个单一的回调参数,称为executor
。当承诺(promise)成功构建时,它会自动运行。executor
由两个回调组成:
-
resolve
: 我们使用这个来通知用户操作成功 -
reject
: 我们使用这个来表示出了问题
当承诺(promise)完成时,它应该调用其中一个函数,要么是resolve
(值)要么是reject
(错误)。
Promise 最初处于pending
状态。如果发生resolve
,它将移动到fulfilled
状态;否则(如果被拒绝),状态将移动到rejected
。
Promise 的结果最初是undefined
。如果执行resolve
,它将存储value
;否则(如果被拒绝),它将存储error
。
图 3.7:Promise 状态和结果
如果你只想提供一个执行路径,那么只使用resolve
或reject
作为executor
是完全正常的。
让我们尝试使用 Promise 在图 3.7中实现回调示例。
文件:Ch03``/js/promise_chaining.js
addPromisifiedScript ("js/app.js")
.then(() =>
new Promise((resolve) => {
setTimeout(() => {
let message = execute();
addSuccessMessage("main", message);
resolve();
}, 4000);
})
)
.then(() =>
new Promise((resolve) => {
setTimeout(() => {
let message = "operation completed successfully";
addSuccessMessage("main", message);
resolve();
}, 3000);
})
)
.then(() =>
new Promise((resolve) => {
setTimeout(() => {
let message = "ready for another execution";
addSuccessMessage("main", message);
resolve();
}, 2000);
})
)
.catch((error) => addErrorMessage("main", error.message));
现在,我们不再使用嵌套回调,而是使用 Promise 链。那么,为什么你应该选择 Promise 而不是回调?考虑以下优点:
-
多亏了它们的线性语法,Promise 帮助我们使代码更容易理解
-
我们有
.catch()
,它提供了一个集中处理错误的方式 -
Promises 允许你以更可读的方式将多个异步操作链接在一起
-
a``sync
/await
建立在 Promise 之上,并提供了一种更同步的方式来编写异步代码(我们将在本章中学习async
/await
)
那么如何使基于 Promise 的代码更易读呢?
文件:Ch03``/js/promise.js
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
addPromisifiedScript("js/app.js")
.then(() => delay(4000))
.then(() => {
let message = execute();
addSuccessMessage("main", message);
})
.then(() => delay(3000))
.then(() => {
let message = "operation completed successfully";
addSuccessMessage("main", message);
})
.then(() => delay(2000))
.then(() => {
let message = "ready for another execution";
addSuccessMessage("main", message);
})
.catch((error) => addErrorMessage("main", error.message));
如你所见,添加一个简单的延迟函数可以使我们的代码更易读,而不是使用嵌套回调。
Promise 充当生产者。如果你想处理 Promise 的响应,你需要注册一个消费者。这种注册主要是通过使用.then
指令完成的。这是一个 Promise 的延续,它获取前一个 Promise 的结果。这个指令可以处理响应(成功和失败)。
查看这里的代码,了解我们如何处理成功和错误情况。当addPromisifiedScript
操作成功时,代码的resolve
部分将被触发;否则,将触发reject
:
addPromisifiedScript("js/app.js").then(
(resolve) => {},//success continuation
(reject) => {}//error handling
);
我们通常只使用.then()
与resolve
模式。如果你想有一个集中处理错误的方式,最好使用.catch()
,它相当于应用.``then(null,reject=> {})
。
你可以设置一个 Promise 来处理在线图书订单(一个异步操作)。你可能收到订单的确认电子邮件(一个已解决的 Promise)或遇到错误(一个被拒绝的 Promise),但无论如何,你仍然完成了与书店网站的交互(finally
块)。你可以在finally
块中放置代码,在 Promise 完成后进行清理,无论输赢。这可能是关闭屏幕上的加载轮,关闭不再需要的连接,或任何其他无论结果如何都需要发生的事情。
Promise API
使用new
关键字实例化 Promise 对象并不是使用它的唯一方式。Promise API 有几个实用的静态方法,我们可以在实践中使用。让我们在以下小节中看看它们。
Promise.all()
如果你需要并行执行多个承诺,并且需要等待它们全部准备好,那么Promise.all()
可能是一个不错的选择。如果所有承诺都成功解析,它将返回一个结果数组;如果任何一个承诺失败,它将立即拒绝。
在 GitHub 章节仓库中打开github_avatars.html
,并通过双击运行它:
const usernames = ["TuralSuleymani", "rasulhsn"];
const url = "https://api.github.com/users/";
let requests = usernames.map((name) => fetch(url.concat(name)));
Promise.all(requests)
.then((responses) => Promise.all(responses.map((r) =>
r.json())))
.then((gitusers) =>
gitusers.forEach((user) => createAvatar(user.avatar_url))
);
Promise.all()
等待所有承诺并行执行并准备好,然后返回多个承诺。
在我们的例子中,我们在两种场景中使用它:
-
从 GitHub 并行获取数据(第 25 行)
-
从所有承诺中获取 JavaScriptON 数据(第 27 行)
对于Promise.all()
,我们等待所有承诺成功执行。如果即使有一个承诺失败(没有成功),Promise.all()
也会立即停止并完全放弃。它会忘记列表中的所有其他承诺,并完全忽略它们的结果。
我们刚才看到的代码应该将两个 GitHub 用户的头像作为图像渲染到.html
文件中。
假设你有一系列任务要运行,比如从互联网上获取东西。如果一个任务失败,其他任务可能仍然继续进行。但Promise.all()
将不再关心它们。它们最终可能完成,但它们的结果不会被使用。(Ch03``/js/promiseAPI.js
包含所有展示的 Promise API 示例。)
以下示例代码显示Promise.all
接受多个承诺作为数组:
Promise.all([
new Promise((resolve, reject) => setTimeout(() => resolve("success
resolve"), 500)),
new Promise((resolve, reject) => setTimeout(() => reject(new
Error("Something went wrong!!")), 1000)),
new Promise((resolve, reject) => setTimeout(() => resolve("another
success resolve"), 1500))
])
.then((success) => console.log(success))
.catch(alert); // Error: Something went wrong!!
Promise.all()
不会尝试停止其他任务,因为一旦承诺开始,就无法取消它们。
Promise.allSettled()
与Promise.all()
不同,Promise.allSettled()
更有耐心。即使有一个承诺失败,它也会等待所有承诺完成后再给你结果。
想象一个类似场景的任务。这次,Promise.allSettled()
将等待所有任务完成,无论其中一个是否失败。
最后,它将给出所有任务的报告,告诉你每个任务是成功(成功解析
)还是失败(出了点问题!!
):
Promise.allSettled([
new Promise((resolve, reject) => setTimeout(() => resolve("success
resolve"), 500)),
new Promise((resolve, reject) => setTimeout(() => reject(new
Error("Something went wrong!!")), 1000)),
new Promise((resolve, reject) => setTimeout(() => resolve("another
success resolve"), 1500))
])
.then(results => {
// 'results' is an array containing information about each promise (resolved or rejected)
console.log(results);
});
这样,你可以全面了解所有任务的情况,即使有些失败了。
Promise.race()
Promise.race()
就像是一系列承诺之间的比赛。你向它提供一串承诺,它等待第一个承诺要么成功要么失败。
无论是哪个承诺先完成(赢得比赛),其结果(成功或错误值)都将成为Promise.race()
的结果。其余的承诺将被完全忽略,无论它们最终是否成功或失败:
Promise.race([
new Promise((resolve, reject) => setTimeout(() => resolve("success
resolve"), 2500)),
new Promise((resolve, reject) => setTimeout(() => reject(new
Error("Something went wrong!!")), 1000)),
new Promise((resolve, reject) => setTimeout(() => resolve("another
success resolve"), 3500))
])
.then(result => {
console.log(result);
}).catch((err)=>console.log('Error detected', err));
当你使用Promise.race
时,需要小心以下原因:
-
当你只需要从最快的承诺中获取结果时,这很有用
-
一旦一个承诺完成,它就停止监听其他承诺
-
它从获胜的承诺返回结果(成功值或错误)
Promise.any()
Promise.any()
等待任何一个承诺成功,不一定是第一个承诺。
一旦某个承诺成功解决,Promise.any
立即停止等待其他承诺,并返回成功值。
然而,如果列表中的所有承诺最终都失败了(被拒绝),Promise.any
本身会以一个特殊错误 AggregateError
拒绝。这个错误包含了所有单个承诺失败的原因:
Promise.any([
new Promise((resolve, reject) => setTimeout(() => resolve("success
resolve"), 2500)),
new Promise((resolve, reject) => setTimeout(() => reject(new
Error("Something went wrong!!")), 1000)),
new Promise((resolve, reject) => setTimeout(() => resolve("another
success resolve"), 3500))
])
.then(result => {
console.log(result);
}).catch((err) => console.log('Error detected', err));//will not be executed
我们还有 Promise.resolve()
和 Promise.reject()
,但由于 async
/await
关键字,它们很少被使用。
JavaScript 中的 Async/await
承诺非常流行,以至于有一个特殊的语法来处理它们。这个组合在章节的仓库中的 async_await.js
文件中被称为 async
/await
关键字。
你可以为任何函数添加 async
关键字,甚至是一个简单的函数。但为什么要在你的函数之前使用 async
关键字呢?嗯,async
关键字是一种语法糖,它帮助我们将函数包装成一个承诺。看看这个函数:
async function sayHello() {
return "hello user";
}
这与这个完全一样:
function sayHello() {
return Promise.resolve("hello user");
}
函数的异步版本在幕后将生成一个承诺(图 3**.7)。这意味着使用这种语法,你可以向该函数添加续集,如 .then()
、.finally()
和 .catch()
。它只是一个基于承诺的函数。
图 3.8:异步函数基于承诺
因此,异步的责任是确保函数始终返回一个承诺。但这还不是全部。这对关键字中还有一个叫做 await
的关键字。如果你需要等待你的承诺解决,那么你就可以使用这个关键字。比如说我们有一个简单的 delayedMessage()
函数,它返回与提供的参数完全相同的信息,但有一些延迟:
function delayedMessage(msg) {
return new Promise((resolve)=> {
setTimeout(() => {
resolve(msg);
}, 3000);
});
}
与通过链式调用(使用 .then
)获取数据不同,你可以简单地应用同步编程技术。这意味着只需等待函数返回数据,获取数据,然后继续:
let message = await delayedMessage("hello");//wait here for the Promise to be settled
这很简单。所以,而不是使用 then
、catch
和 finally
,你可以仅使用基于 async 的语法与基于承诺的函数交互。简单来说,await 只是一种更优雅地处理承诺的方式。
这里是一个使用承诺从多个 URL 获取数据的简单示例:
文件:Ch03``/js/getdata_promise.js
const url = "https://jsonplaceholder.typicode.com";
const paths = ["/posts","/comments"];
let promises = Promise.all(paths.map(path=> fetch(url.concat(path))));
promises.then(responses=> Promise.all(responses.map(t => t.json())))
.then(data=> {
data.forEach(element => {
console.log(element);
});
})
在我们前面的例子中,我们使用承诺从 JavaScript onplaceholder
URL 的 /posts
和 /comments
获取数据。
使用 async/await,我们可以简化它,如下所示:
文件:Ch03``/js/getdata_async.js
const url = "https://jsonplaceholder.typicode.com";
const paths = ["/posts","/comments"];
let getData = async function() {
const responses = await Promise.all( paths.map(path=>fetch(url
.concat(path))));
constJavaScriptons = await Promise.all( responses
.map(response=>response.json()));
JavaScriptons.forEach(element => {
console.log(element);
});
}
getData();
而不是每次都使用 .then()
,我们现在能够使用基于同步编程的语法。
你可能想知道如何处理这段代码中的异常?如果我们可以自由地不使用 .catch()
,那么我们如何能够捕获异常呢?这里的答案也是非常简单的:只需使用 try..catch
。
我们已经足够多地讨论了承诺。现在是时候看看 JavaScript 内部是如何处理承诺的了。
微任务队列
我们已经讨论了 promises 和 async/await。现在是讨论相关主题 PromiseJobs
的确切时机——参见 图 3.8。为了更好地管理 JavaScript 中的异步任务,ECMA 标准添加了这个内部队列。在调用栈方面,它具有与回调队列相似的行为,因为这些任务的执行只有在调用栈中没有其他运行时才可能。
图 3.9:微任务队列
因此,当给定的 promise 准备就绪时,续集,如 then
/catch
/finally
处理程序,会被放入队列中。当调用栈为空时,JavaScript 引擎将按照 先进先出(FIFO)的顺序执行这些任务。
这里有一个简单的示例 (Ch03``/js/microtasks.js
):
let promise = Promise.resolve();
promise.then(() => console.log("planning to see this message first"));
console.log("but this message will be seen first");
输出的顺序可以在 图 3.10 中看到。
图 3.10:前述代码的输出
因为,当 JavaScript 引擎检测到 promise 时,它会将 .then()
移动到微任务队列中。这是一个异步操作,所以我们直接切换到下一行。只有在调用栈为空时,才能执行 promise。
当然,我们讨论的主题并不是你需要与 Node.js 一起工作的所有主题列表。我们跳过了一些简单和中级主题,只涵盖了我们认为可能在你使用 Node.js 时有所帮助的一些重要主题。JavaScript 技能是必不可少的,你需要了解其语法。拥有更好的 JavaScript 技能将在你学习本书的过程中起到指导作用。
Node.js 基础知识
Node.js 不是一个独立的编程语言。它是 Google 的 V8 引擎上的一个运行时环境。这意味着 Node.js 的创建者只是将 V8 引擎从浏览器中提取出来,并将其放入另一个运行时环境中。Node.js 是 V8 引擎的包装器,通过添加网络、I/O API 和其他操作来扩展它。Node.js 的一个关键方面是其非阻塞 I/O 模型。这意味着 Node.js 可以在不被缓慢操作阻塞的情况下同时处理多个请求。在这种情况下,事件循环和回调队列变得更加重要。
Node.js 在底层有不同的依赖,但对我们来说最有趣的一个是Libuv。Libuv 为处理某些任务提供了一个线程池,默认情况下有四个线程,但可以根据应用程序的要求进行配置。这是提供基于 I/O 的非阻塞操作的主要魔法。它是用 C 语言编写的,提供了一个基于事件的异步 I/O 模型。可以使用线程池执行阻塞操作以分配 CPU 负载。默认情况下,我们在 Libuv 中有四个线程可以使用。对于基于网络的异步操作,Libuv 依赖于操作系统本身,但对于某些其他异步函数,例如从文件中读取,Libuv 依赖于其线程池。线程池的概念允许我们在单独的线程中执行一些重要操作,而不会阻塞其他操作。
Node.js 主要关注异步 I/O,并旨在最小化阻塞操作。线程池主要用于那些无法由操作系统高效异步处理的任务,例如需要大量处理的密集计算或文件系统操作。
就像在浏览器中一样,当涉及到 Node.js 的异步特性时,我们有不同的队列可以使用。一般概念是相同的。它们都使用基于事件循环的模型,但 Node.js 有一些更多的队列。
事件循环是在你的 Node.js 应用程序运行期间持续运行的机制。它负责处理不同的异步事件。事件循环利用队列来组织这些事件,确保它们按正确的顺序处理。事件循环由六个不同的队列组成。
事件循环利用几个队列来管理不同类型的异步操作(图 3.9)。这些队列确保任务按照特定顺序处理,如下所示:
-
setTimeout
和setInterval
函数。这些回调在指定延迟后或定期执行。(技术上,它是一个用于高效调度的最小堆。) -
fs
和http
模块。事件循环在 I/O 操作完成时处理这些回调。 -
setImmediate
。这些回调被视为高优先级,并在下一次循环迭代之前执行。 -
关闭队列:这个队列包含在异步资源关闭时运行的函数,确保适当的清理。
-
process.nextTick
。这些也是高优先级,并在当前操作完成后立即执行。 -
Promise 队列:这个队列包含与解析或拒绝承诺相关的回调。这些回调在事件循环执行过程中遇到已解析或已拒绝的承诺时被处理。
需要注意的是,前四个队列(定时器、I/O、检查和关闭回调)由 Libuv 管理。微任务队列(nextTick
和Promise
)是独立的,但仍在确定事件循环中回调执行顺序方面发挥着关键作用。
图 3.11:Node.js 队列
让我们讨论一下队列的优先级顺序。首先,重要的是要理解用户编写的同步 JavaScript 代码在这些队列中具有最高优先级。这仅仅意味着,只有当调用栈为空时,这些队列元素才会被执行。然而,这六个队列的顺序又是什么呢?我们现在将更详细地讨论它们:
-
nextTick
队列,首先执行这些任务,然后是promise
队列中的任何回调。 -
setTimeout
和setInterval
。在计时器队列之后,事件循环将再次检查微任务队列。首先,将执行
nextTick
队列,然后执行promise
队列。 -
I/O 回调:接下来,事件循环处理 I/O 队列中的回调。这些代表异步操作,如文件 I/O 或网络请求,它们已经完成。
-
再次检查微任务:在处理 I/O 之后,事件循环再次检查微任务。这确保了在那些操作期间创建的任何微任务都能立即执行。
-
setImmediate
:然后事件循环执行检查队列中的回调,该队列包含使用setImmediate
计划的功能。这些被认为是高优先级任务,并在下一次循环迭代中先于其他回调执行。在检查队列之后,事件循环将再次检查微任务队列。首先,将执行
nextTick
队列,然后执行promise
队列。 -
关闭回调:最后,事件循环处理与关闭异步资源相关的回调,确保适当的清理。
再次检查微任务队列,如果存在回调则执行。
-
循环中的微任务:只要还有等待被邀请的回调,事件循环就会继续进行。一旦每个人都轮换过,并且没有剩下任何要做的,它就会优雅地退出舞台。
如您可能注意到的,微任务在事件循环中被检查多次。这确保了在执行其他回调期间创建的任何微任务都能立即得到处理。这优先处理了使用nextTick
和promise
解析计划的任务,保持了事件循环的响应性。
概述
本章是关于 JavaScript 和 Node.js 内部机制的。
首先,我们开始讨论 JavaScript 内部机制。主要目的是理解 JavaScript 引擎和 JavaScript 运行时。JavaScript 是一种单线程语言,但它具有异步特性,我们能够使用回调和 promises 来实现它。在异步编程中,由于 promises 的出现,回调现在有点过时了。我们还讨论了 Promise API,并学习了许多有趣的功能。
Promises 的流行给 JavaScript 带来了其他有趣的功能:async/await。通过示例,我们试图展示它们如何使我们的代码更易于阅读、理解和更接近同步代码。
对于承诺(promises),JavaScript 引擎管道中有一个特殊的队列,称为微任务队列。
然后,我们围绕 Node.js 进行了讨论,了解到它也是一个运行时环境,并使用 JavaScript 引擎来读取和翻译 JavaScript 代码。它将 JavaScript 推向了另一个层次,使用 Node.js,我们可以构建实时应用、微服务、Web API、流式应用、命令行工具等等。
内部来说,Node.js 严重依赖于 Libuv,并提供了大量的功能,这些功能大多可以归类为异步 I/O 和线程池。Libuv 为 Node.js 的异步编程模型提供了核心功能,使其在处理大量并发连接和 I/O 操作时既高效又可扩展。
本章的最后讨论是关于 Node.js 队列和事件循环。Node.js 中有几个队列,它们有特殊的顺序,我们需要理解这些顺序来构建更有效的应用。
在下一章中,我们将讨论在编写任何代码之前你需要了解的堆栈开发技术。
第四章:栈开发技术
了解要使用哪些工具和应用程序可以显著加快您的开发过程。这就是为什么我们需要在编写代码之前首先准备我们的开发环境。开发环境应该让您感到舒适,并在学习编写微服务的过程中为您提供帮助。
这就像建造一栋房子。选择合适的工具可以加快进程并节省时间。当然,拥有合适的工具并不意味着你一定会成功,但选择正确的工具失败总比因为工具选择不当而失败要好。
本章重点介绍安装所需的工具。编程语言和框架也是我们构建微服务的工具。以某种形式或另一种形式,我们在编写软件时使用的所有东西都是我们的工具。现在安装我们计划使用的所有工具没有意义,但至少理解如何安装其中大部分工具将帮助我们更快地进入编码阶段。
我们还将查看本书中计划使用的一些 Node.js 框架。理解它们对于快速和轻松的开发至关重要。
在本章中,我们将介绍以下主题:
-
Node.js 及其安装
-
Node.js 框架
-
选择合适的 IDE
-
理解和安装 Docker
-
理解和安装 Apache Kafka
-
理解和安装 Git
-
安装 Postman
-
安装 MongoDB
技术要求
要跟随本章内容,您只需要选择一个浏览器以及互联网连接。关于我们计划使用的其他工具,我们将在本章中学习如何安装它们。
Node.js 及其安装
正如我们在上一章中学到的,Node.js 是建立在 Google 的 V8 引擎之上的运行时环境。您可以使用它构建各种应用程序:
-
Web 应用程序:Node.js 是构建 Web 应用程序后端和前端的热门选择。其基于 JavaScript 的环境使得开发者能够轻松地在两个方向上工作。
-
实时应用程序:Node.js 的事件驱动架构和非阻塞 I/O 模型使其非常适合构建实时应用程序,如聊天应用、协作工具和流媒体服务。这些应用程序需要用户和服务器之间持续的通信,而 Node.js 可以高效地处理这一点。
-
单页应用程序(SPAs):SPAs 是一种网页应用程序,它加载单个 HTML 页面,并使用 JavaScript 动态更新内容。可以使用 Node.js 来构建后端 API,为 SPAs 的客户端代码提供数据。
-
API 驱动的应用程序:许多现代应用程序依赖于 应用程序编程接口(APIs)来访问其他服务的数据和功能。由于 Node.js 能够高效地处理大量并发请求,因此它是构建这些 API 的绝佳选择。
-
微服务:由于 Node.js 具有模块化和异步能力,非常适合构建微服务。
-
命令行工具:Node.js 可以用来创建命令行工具,以自动化任务或与其他系统交互。
-
桌面应用程序:虽然不太常见,但 Node.js 也可以使用 Electron 等框架来构建桌面应用程序。
当然,这并不是完整的列表,但它为你提供了一个很好的概念,即可以用 Node.js 构建的广泛的应用程序范围。
学习某些技术或语言通常从安装所需的工具开始。Node.js 也是如此。那么,让我们深入了解其安装过程。
安装 Windows 版本的 Node.js
你可能会感到惊讶,但我非常喜欢微软及其产品。这意味着我使用的是 Windows 操作系统(OS)。所有类型的安装都将主要使用Windows OS进行解释,但我将提供相关的链接,以帮助非 Windows 用户达到相同的进度水平。
当从官方网站(www.nodejs.org)安装 Node.js 时,它会自动检测你的操作系统并提供安装的确切说明。
我们还有不同的安装 Node.js 的方式,即使是对于同一操作系统。最受欢迎的是预构建安装程序和通过包管理器安装。
让我们按照以下分步说明来安装 Node.js:
-
访问 www.nodejs.org/en/download。在我的情况下,我将使用基于 Windows 的说明。
-
选择预构建安装程序。
-
在标签页中,选择要运行的 Node.js 的所需版本,包括操作系统和 CPU 架构(图 4.1)。
你可能想知道应该选择 Node.js 的哪个版本——LTS还是当前版本?答案是简单的:
-
对于长期使用,需要保持稳定并协同工作的情况,LTS版本是一个安全的选择。这是一个可靠的选项。
-
另一方面,当前版本拥有所有最新的功能和特性,这对于想要尝试新功能的程序员来说非常棒。
对于我们的代码示例,LTS 版本就足够了。选择它并继续。
-
图 4.1:Node.js 安装页面
这些相同的说明也适用于非 Windows用户。你需要选择你的适当操作系统及其平台,然后点击绿色的下载 Node.js v<版本号>按钮。根据所选版本,按钮上的版本将自动更新。我们将在下一节深入探讨这个安装过程。
- 在安装过程中,只需接受许可协议并按照向导的说明进行,无需进行任何自定义设置配置。在安装过程中间,向导将要求你选择应用程序在编译本地模块时的行为。最好选择如(图 4.2)所示的复选框:
图 4.2:Node.js 的“原生模块工具”窗口
如果你选择 自动安装 复选框(图 4.2),它将安装额外的工具,如 Chocolatey、Python 和 Visual Studio Build Tools。
- 安装完成后,只需按下 Win + R 组合键,然后在打开的窗口中输入
node
:
图 4.3:运行命令的输入窗口
- 输入
node
后,按 Enter 键。你应该会看到以下应用程序:
图 4.4:Node 命令行
在 图 4.4 中显示的窗口是一个 读取-评估-打印循环(REPL),它作为一个编程语言环境,类似于控制台窗口(图 4.5)。它接受用户输入的单个表达式,处理它,然后在控制台中显示结果。这是一个快速实验基本 JavaScript 代码的好方法。
图 4.5:Node.js REPL
现在,让我们继续讨论在 macOS 和 Linux 上安装 Node.js 的讨论。
为 macOS 和 Linux 安装 Node.js
对于 macOS 和 Linux 用户来说,最简单的方法之一是使用基于包管理器的安装。选择你的操作系统,然后转到 包管理器 部分。在 使用 部分,你可以选择活动管理工具(NVM、Brew、Chocolatey 或 Docker)并遵循该窗口中提供的说明(图 4.6):
图 4.6:不同操作系统的 Node.js 安装
如果你想使用 NVM 下载适用于 Linux 发行版的 Node.js,应遵循此处提供的说明:
# installs NVM (Node Version Manager)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
# download and install Node.js
nvm install 20
# verifies the right Node.js version is in the environment
node -v # should print `v20.12.1`
# verifies the right NPM version is in the environment
npm -v # should print `10.5.0`
请注意,在阅读本书时,Node.js 的版本可能会有所不同。对于更详细的安装说明,请阅读 Node.js 的官方页面。
虽然 Node.js 是我们开发微服务时的主要工具,但在整个学习过程中,我们也会使用一些 Node.js 的框架来简化我们的工作。本书中我们将使用最流行的两个框架,即 NestJS 和 Express.js。
Node.js 框架
Node.js 有很多有趣的框架。我们将使用 NestJS 和 Express.js(我们将在稍后详细介绍这些框架)但当然,更多的框架依赖于 Node.js,并且在一本书中不可能使用所有这些框架,即使可能,这样做也没有太多意义。
首先,Node.js 不是一个框架,而是一个在浏览器外执行 JavaScript 代码的运行时环境。一些开发者称之为框架,但这并不准确。我们需要理解框架这个术语,才能判断某物是否是框架。在编程中,框架基本上是一个预先构建的结构,作为创建软件应用的基础。它就像一个你可以根据特定需求定制的蓝图,而不是从头开始。框架提供了用于常见功能的预写代码,例如处理用户输入、数据库交互或安全性。这些组件作为构建块,你可以利用它们来节省时间和精力。
框架通常有定义好的代码组织方式,这确保了一致性,并使其他开发者更容易理解和维护代码库。
框架的一个关键方面是控制反转。不是你的代码调用框架,而是在特定的点上框架调用你的代码。这允许框架管理应用程序的整体流程。
有了这些信息,你可以知道 Node.js 不是一个框架。它只是一个运行时环境,但我们有建立在 Node.js 之上的框架。最受欢迎的包括 NestJS、Express.js、MeteorJS 和 SailsJS。顺便说一句——许多流行的框架,如 Sails、NestJS、Kraken、poet 和 Item API,都是建立在 Express.js 之上的。如前所述,我们将使用 NestJS 和 Express.js,让我们来仔细看看它们。
Express.js
Express.js 简化了网络开发过程。通过有效地使用 Express.js,你可以为用户创建动态和交互式的网络体验。由于 Express.js 轻量级,它是创建需要在高流量下表现良好的网络应用的绝佳选择。Express.js 的一个关键特性是它处理不同路由的能力,这些路由本质上是用户访问你网络应用特定部分所走的路径。
将 Node.js 想象成构建块和工具,就像砖块和灰浆。Express.js 就像预制墙体和管道——它为你提供了一个结构化的方式来组装这些块,以更快地创建网络应用。仅使用 Node.js 构建复杂网络应用可能会变得混乱。Express.js 提供了一个框架,这样你可以将代码组织成路由、中间件和视图,使其更易于维护和他人理解。
虽然你可以用 Node.js 从头开始构建一切,但 Express.js 提供了内置的功能来处理路由、处理 HTTP 请求和响应以及创建 API,为你节省时间和精力。
Express.js 是一个流行的框架,拥有庞大的开发者社区。这意味着你可以访问大量的资源、教程和额外的库,以简化开发。尽管有其他 Node.js 框架可供选择,但由于其简单性、灵活性和庞大的社区支持,Express.js 仍然是一个不错的选择。
NestJS
NestJS 是一个建立在 Node.js 之上的框架,专门用于构建健壮和可扩展的服务器端应用程序。
NestJS 为你的应用程序强制执行一个清晰和有序的架构。这使得复杂项目更容易管理和维护,特别是对于开发者团队。它与 TypeScript 无缝集成,TypeScript 是 JavaScript 的超集,它为代码添加了静态类型,提高了代码可靠性并减少了错误。它还促进了模块化方法,即将你的应用程序分解成更小、可重用的组件。这使得你的代码库更有组织,并且随着应用程序的增长更容易进行扩展。
注意
另一个好消息是,如果你熟悉 Angular,一个 NestJS 借用了许多概念的流行的前端框架,这将使 Angular 开发者更容易学习和使用。
NestJS 超越了基本的 Web 应用程序。你可以使用这个框架构建 RESTful API、GraphQL API、WebSocket 应用程序,甚至命令行界面。
它还利用了 依赖注入,这是一种管理应用程序不同部分之间依赖关系的强大技术。这促进了松散耦合,并使你的代码更容易进行测试。
简而言之,NestJS 在 Node.js 的基础上提供了一个结构化和功能丰富的工具包,使你能够高效地构建可扩展和可维护的服务器端应用程序。
在 Express.js 和 Node.js 之间做出选择
这本书不是关于 Node.js 框架的,我们的目标也不是提供关于它们的完整信息。然而,了解这两者之间的基本差异将有助于我们有一个更广泛的理解。让我们概述一些这些框架适合的情况。有了这种理解,你将能够做出更明智的决定,并为你的项目选择正确的框架:
-
在以下情况下使用 Express.js:
-
如果你的项目是小型的或中等规模的。对于更简单的 Web 应用程序或 API,Express.js 的轻量级和灵活特性可能非常理想。
-
如果你优先考虑灵活性。Express.js 提供了对你的应用程序结构的更多控制,允许你根据具体需求进行定制。
-
如果你熟悉 JavaScript。如果你对原生 JavaScript 感到舒适,Express.js 的学习曲线会更加平缓。
-
-
在以下情况下使用 NestJS:
-
如果你的项目很大或很复杂。NestJS 的结构化架构和功能对于管理和维护由多个开发者参与的大型应用程序非常有用。
-
如果你重视可扩展性,NestJS 的模块化设计使得随着功能和复杂性的增长,你的应用程序更容易进行扩展。
-
如果你需要内置功能,NestJS 提供了诸如依赖注入和 TypeScript 支持等开箱即用的功能,这提高了代码的可维护性和可靠性。
-
如果你的团队熟悉 Angular。如果你的开发者有 Angular 的经验,NestJS 的类似结构可以减轻学习曲线。
-
在安装 Node.js 之后,我们需要为开发准备我们的集成开发环境(IDE)。
选择合适的 IDE
选择合适的 IDE 可能有助于你快速开发。对于 Node.js 开发,尤其是为了跟随我们的代码示例,我们没有对 IDE 有严格的要求。你可以使用你喜欢的文本编辑器或操作系统内置的文本编辑器。选择合适的 IDE 是一种口味和功能偏好的选择。IDE 可以帮助你在语法高亮、自动完成和重构,并且可以轻松地与重要库进行交互。你可以使用不同的 IDE,例如Visual Studio Code、Eclipse Che、Sublime Text、WebStorm和IntelliJ IDEA。
我们更喜欢 Visual Studio Code,因为它免费、易于使用、可以通过扩展进行扩展、跨平台兼容、易于配置,并支持多种编程语言。
让我们安装 Visual Studio Code。要安装它,只需遵循以下说明:
-
点击你的操作系统版本,等待下载完成。
-
点击你下载的文件,并按照默认说明进行操作。
随着你学习微服务开发,你会熟悉经典的应用程序安装过程。因此,我们没有提供详细的截图,也不会在安装细节上花费太多时间。更多信息,你可以在 Google 上简单地搜索相关应用程序安装说明。
Visual Studio Code 为所有需求提供了丰富的扩展库(图 4.7)。你可以下载任何你想要的东西来帮助你快速开发、重构、突出显示你的代码等等:
图 4.7:Visual Studio Code 的扩展部分
安装完我们的集成开发环境(IDE)后,是时候安装 Docker 了。它是一个必备的工具,尤其是在如今微服务应用开发中。我们将在开发中积极使用 Docker,所以让我们深入了解一下。
理解和安装 Docker
在安装 Docker 之前,我们需要了解它的价值和目的。
Docker就像是你软件的运输集装箱。它将你的应用程序运行所需的一切——代码、库和设置——打包在一起,并将它们捆绑在一起。这确保了你的应用程序可以在任何操作系统上无任何问题地运行。它保证了如果应用程序在本地运行良好,那么它将在全球范围内运行。
Docker 确保您的应用程序每次都以您构建的方式运行。不再因为不同的计算机配置而出现惊喜。
Docker 中的主要概念是镜像和容器。容器轻量级且启动速度快,使得测试和开发周期更加顺畅。您可以轻松地将 Docker 容器发送到任何环境,无论是基于云的还是不是,并且可以确信它将正常工作。容器共享底层操作系统,因此您可以在单台机器上运行更多应用程序,而不会使其过载。
在 Docker 的世界里,镜像就像 Docker 容器的蓝图。它是一组指令,指定了容器内部需要什么,包括应用程序运行所需的软件、库和配置。您只需构建一次镜像,就可以用它来创建许多容器,节省您的时间和精力。镜像可以轻松共享和下载,允许您在安装了 Docker 的任何机器上运行您的应用程序。镜像的一个优点是,使用相同镜像的每个人都会得到相同的环境,减少了惊喜并确保了应用程序行为的可靠性。
如果您不想将某些应用程序永久安装在您的计算机上,并且在开发完成后轻松地移除它们,那么最好不是直接安装它们,而是使用 Docker 进行安装。
最后,Docker 简化了构建、运输和运行应用程序的过程。它就像是一个软件的通用盒子,确保它无论在哪里运行都能完美运行。
理论已经足够了——让我们深入了解 Docker 的安装过程。
您几乎可以在所有流行的操作系统上安装 Docker,例如 Windows、Linux 和 Mac。它可能对您的操作系统有特定的要求,您可以通过导航到docs.docker.com/engine/install/
来了解这些要求。然后,您可以选择您的操作系统进行安装。
注意,需求可能因操作系统的内部版本而异。在系统需求部分,您将看到基于所选操作系统内部版本的先决条件(图 4.8)。例如,对于 Windows,我们有WSL 2 后端和Hyper-V 后端以及 Windows 容器的系统需求:
图 4.8:Docker 的系统需求部分
如果您的操作系统满足提供的系统需求,您将能够安装 Docker 桌面(图 4.8)。对于 Windows,建议使用WSL 2而不是Hyper-V。
安装过程简单直接,不需要任何额外的配置:
图 4.9:Docker 桌面
Docker 不是我们在构建微服务时需要使用的唯一强大工具。我们还有另一个优秀的工具,它可以帮助我们在微服务之间进行实时通信:Apache Kafka。
理解和安装 Apache Kafka
Apache Kafka 是一个开源平台,用于处理实时数据流。最初设计为一个消息队列,它已经发展成为一个强大的数据流和事件驱动架构构建系统。微服务之间的通信至关重要,这正是 Kafka 发挥其优势的地方。
Kafka 本身是一个庞大的概念,需要另一本书来详细阐述。在这本书中,我们将提供足够的信息,以便我们能够将其集成并用于我们的微服务。如需更详细的信息,您可以在 Udemy 平台上跟随我的 Apache Kafka for Distributed Systems 课程(www.udemy.com/course/apache-kafka-for-distributed-systems/
)。
Kafka 使用 发布-订阅 消息模型。服务作为生产者,将事件发布到 Kafka 内部的特定通道,称为 主题。其他服务作为消费者,订阅相关主题并异步接收这些事件。
这种方法巧妙地将服务彼此解耦。生产者不需要等待消费者可用,消费者可以按自己的节奏处理事件。这提高了可扩展性和灵活性。
Kafka 还充当缓冲区,存储事件直到消费者准备好。这实现了异步处理,防止了瓶颈并提高了整体系统的响应速度。
Kafka 是为可靠性而构建的。它会在多个节点之间复制数据,确保即使服务器出现故障,消息也不会丢失。这增强了微服务架构中的容错能力。
重要的是要知道 Kafka 可以水平扩展。您可以轻松地添加更多节点来处理不断增长的数据量,而不会影响现有服务。这完美地适应了微服务的动态特性。
如前所述,Kafka 的一个关键特性是它处理实时数据流的能力。这对于需要迅速响应事件的微服务来说非常有价值,例如在欺诈检测或股票交易应用中。
Kafka 可以灵活地与编程语言相关联,并与各种编程语言无缝集成,使其能够适应不同的微服务环境。
Kafka Streams,Kafka 内部的一个强大 API,使您能够在 Kafka 集群内部对数据流进行实时计算和转换。这种流处理能力为微服务架构增添了显著的价值。
简而言之,通过集成 Apache Kafka,微服务开发从增加的可扩展性、弹性和敏捷性中受益。它促进了松散耦合的事件驱动方法,使微服务能够有效地进行通信和响应变化。这转化为更稳健、适应性强和高性能的应用程序架构。
Apache Kafka 的安装因操作系统而异,您需要根据每个操作系统进行一些额外的配置。
此外,除了 Apache Kafka,我们还可能使用 Zookeeper 和 Kafka UI。从 Apache Kafka v4
开始,您将不再需要 Zookeeper。然而,此功能仍在开发中,因此了解它也很有意义。Zookeeper 承担多个职责,并在协调器中扮演着关键角色。
Kafka 本身是一个基于 CLI 的工具,因此如果您想以图形方式查看内容,则需要额外的工具,例如 Kafka UI、偏移量探索器等。
但我有好消息要告诉您。如果您已安装 Docker,则无需深入了解安装过程。我们可以创建一个 docker-compose
文件来组合所需的工具并一起安装。您可以逐个下载文件,但 docker-compose
帮助您创建一个特殊的 YAML 文件,您可以在其中定义所有工具和应用程序依赖项并一起安装。以下是我们的 docker-compose
文件内容:
services:
zookeeper:
image: bitnami/zookeeper:3.8
ports:
- "2181:2181"
volumes:
- zookeeper_data:/bitnami
environment:
ALLOW_ANONYMOUS_LOGIN: "yes"
kafka1:
image: bitnami/kafka:3.6
ports:
- "9092:9092"
volumes:
- kafka_data1:/bitnami
environment:
KAFKA_CFG_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_CFG_LISTENERS: "PLAINTEXT://:9092" # Use only
one listener
KAFKA_CFG_ADVERTISED_LISTENERS:
"PLAINTEXT://kafka1:9092"
depends_on:
- zookeeper
kafka-ui:
image: provectuslabs/kafka-ui:latest
ports:
- 9100:8080
environment:
KAFKA_CLUSTERS_0_NAME: local
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka1:9092
KAFKA_CLUSTERS_0_ZOOKEEPER: zookeeper:2181
KAFKA_CLUSTERS_0_JMXPORT: 9997
depends_on:
- kafka1
volumes:
zookeeper_data:
driver: local
kafka_data1:
driver: local
要安装 Apache Kafka,您需要遵循以下说明:
-
打开 Docker Desktop。
-
前往
Ch04``/docker-compose.yml
并将其下载到您的计算机上。 -
从文件夹中打开命令行并输入
docker-compose
up -d
。
Docker 应该开始收集镜像并根据这些镜像创建容器(图 4**.10):
图 4.10:运行中的 Docker 容器
现在,我们已经准备好向前迈进,了解如何安装 Git,这是开发者工具箱中的必备工具。
理解和安装 Git
Git 是一种强大的 版本控制系统(VCS),用于软件开发。想象它就像你代码的时间机器。它跟踪你对项目所做的每一个更改,就像一本详细的日志簿。
使用 Git,您可以轻松地回滚到代码的先前版本,本质上撤销了任何错误。
它使协作变得容易。与团队一起工作?Git 允许每个人无缝协作。每个团队成员都可以在自己的项目部分工作,Git 帮助他们平滑地合并更改,避免冲突。
它还记录了所有更改的详细历史,让您可以确切地看到所做的修改以及由谁进行的。这对于跟踪进度和理解项目的发展历程至关重要。
Git 是我们在日常开发过程中使用的重要工具之一。您可以为任何流行的操作系统安装 Git,尤其是 Linux、Windows 和 Mac:
-
要获取关于 Linux 的更详细说明,请访问
git-scm.com/download/linux
。在那里,您可以找到基于 Linux 发行版的 Git 安装程序。 -
与其他流行工具一样,我们在 macOS 上安装 Git 有多种选择。请访问
git-scm.com/download/mac
了解更多信息。 -
对于 Windows,我更喜欢使用 Git for Windows 工具。你可以从
gitforwindows.org/
下载它。此应用程序提供 Git Bash 和 Git GUI 功能(图 4.11)。你可以在整个开发过程中使用它们:
图 4.11:Git for Windows
我们还需要安装另一个工具。让我们继续我们的 Postman 安装之旅。
安装 Postman
本章我们将介绍的最后一种工具是 Postman。我们将构建许多 API,并且我们需要一些工具来快速轻松地测试它们。这是一个帮助开发者与 API 交互和测试的工具。
这是 Postman 帮助你做的事情:
-
构建 API:你可以使用 Postman 的工具设计和规划你的 API。
-
测试 API:发送请求(例如请求信息)并查看 API 的响应(你得到的信息)。这有助于确保 API 按预期工作。
-
与 API 交互:Postman 允许你轻松地向 API 发送不同类型的请求并查看结果。这就像拥有 API 的遥控器。
这是你可以安装它的方法:
-
点击你的操作系统名称并下载相关文件。
-
安装它。
安装 Postman 后,打开它。此时,你可以在 URL 部分插入任何 URL。在那里,你还可以为该 URL 选择 HTTP 方法(GET
、POST
、PUT
等)并发送请求(图 4.12):
图 4.12:Postman 概览
因此,让我们继续本章的最后一个工具:MongoDB。
安装 MongoDB
MongoDB 是一个流行的 NoSQL 数据库。我们在开发微服务时会使用它,未来当你构建微服务时,它可能也是一个不错的选择。
你可以考虑 MongoDB 的以下特性:
-
面向文档:与传统的基于行和列的表格存储数据的数据库不同,MongoDB 使用灵活的类似 JSON 的文档来存储数据。这使得表示复杂的数据结构变得更容易。
-
可扩展性:MongoDB 通过水平扩展可以处理大量数据集和高流量应用程序。它是通过向数据库集群添加更多服务器来实现的。
-
灵活的模式:MongoDB 允许灵活的模式设计。集合内的文档可以有不同的结构。这对于存储不适合整齐的表格结构的数据库很有用。
-
跨平台:MongoDB 在各种操作系统上运行,包括 Windows、Linux 和 macOS。
-
开源:MongoDB 的核心服务器是开源的,并且免费使用,同时提供多种商业许可证,以提供额外的功能和支持。
要安装 MongoDB 服务器,只需导航到 www.mongodb.com/docs/manual/installation/
并选择您的平台。在撰写本文时,您可以在 Linux、macOS、Windows 和 Docker 上安装它。MongoDB 有两个可用的版本:社区版和企业版。为了实验和测试,社区版将足够使用。
另一个用于与 MongoDB 交互的有用产品是 MongoDB Compass 应用程序。您可以在 www.mongodb.com/try/download/compass
找到它,并且它是免费的。您可以为不同的平台安装它:
图 4.13:MongoDB Compass 支持不同的平台
MongoDB Compass 是一个免费、图形用户界面(GUI)工具,专门设计用于与 MongoDB 数据库交互。它本质上充当一个用户友好的客户端应用程序,简化了管理和处理您的数据。
您可以使用它进行以下目的:
-
可视化数据:Compass 允许您浏览集合,以清晰和有组织的格式查看文档,并探索您数据的结构。
-
查询:您可以直接在 Compass 中编写和执行查询,以过滤和从您的 MongoDB 数据库中检索特定数据。
-
构建复杂查询:Compass 提供了一个可视化界面来构建聚合管道,这些是转换和分析您数据的强大工具。
-
CRUD 操作:Compass 允许您轻松
-
在您的集合中创建、读取、更新和删除(CRUD)文档。
-
模式分析:Compass 通过提供模式可视化来帮助您理解您数据内部的结构和关系。
-
索引优化:Compass 可以推荐并协助在您的集合上创建索引以优化查询性能。
-
连接管理:Compass 允许您连接到各种 MongoDB 部署,包括本地服务器、云实例和容器。
总体而言,MongoDB Compass 提供了一种用户友好且直观的方式来与您的 MongoDB 数据库交互。它是数据库管理员、开发人员以及任何需要探索、分析和管理其 MongoDB 数据的人的有价值工具。
摘要
拥有一个准备良好的开发环境对于顺利和高效的软件开发过程至关重要。通过事先安装必要的工具和程序,您可以消除在开发过程中寻找它们的需要,节省宝贵的时间。一个配置好的环境确保了一致的工作流程。您确切地知道您的文件在哪里,如何运行它们以及要使用哪些命令。这减少了认知负荷,让您能够专注于编码。
在整个项目中使用相同的工具有助于保持代码风格和一致性。这使得代码更容易阅读、理解和维护,无论是对于您自己还是其他人。
当团队中的每个人都使用相同的开发环境时,协作变得更加顺畅。他们可以轻松地共享代码、解决问题,并理解彼此的工作。
在本章中,我们讨论了在开始我们的开发之旅之前需要安装的工具。当然,我们还没有安装所有工具,但主要工具都在这里。我们将在未来的章节中根据需要介绍额外的工具,确保安装过程简单明了。
如果你在这里,那么恭喜你!从下一章开始,我们将深入探讨微服务开发的实践细节。
第二部分:构建和管理微服务
在本部分,我们将深入探讨使用 JavaScript 构建微服务的实际方面。我们将从创建一个基本的 CRUD(创建、读取/检索、更新、删除)微服务开始,然后继续探讨更高级的概念,例如服务之间的同步和异步通信。我们还将涵盖实时数据流,这对于创建响应性和动态的应用程序至关重要。
本部分包含以下章节:
-
第五章,基本的 CRUD 微服务
-
第六章,同步微服务
-
第七章,异步微服务
-
第八章,使用微服务的实时数据流
第五章:基本 CRUD 微服务
我们前面的章节证明了微服务开发不仅仅是关于实现。你需要至少清楚地了解与其他方法相比(如我们在前几章中看到的单体和面向服务的),使用微服务的优缺点,并且你需要对你构建微服务时想要应用的技术有一个基本的理解。
将你所有的理论知识应用到实践中也不是一件容易的事情。本章将帮助我们应用微服务到实践中,并且是迈向现实世界微服务实施的一步。在本章中,我们计划为后续的实践章节提供一个坚实的基础。
在你的项目中实施微服务方法意味着你大部分时间都在处理一个复杂的企业领域,你的微服务的概念边界要求你实现这部分复杂逻辑的一部分。这表明微服务开发不仅仅是创建、检索/读取、更新、删除(CRUD),而且需要对微服务应用程序结构有一个基本的了解,本章是一个良好的起点。
我们将探讨以下主题:
-
理解业务需求
-
开发基本微服务的工具
-
准备我们的第一个项目
-
定义我们微服务的内部架构
-
实践账户微服务开发
-
运行和测试我们的第一个微服务
让我们开始吧!
技术需求
要开发和测试我们的第一个微服务,我们需要以下工具:
-
选择你喜欢的 IDE(我们更喜欢 Visual Studio Code)
-
Postman
-
MongoDB
-
选择你喜欢的浏览器
建议您从github.com/PacktPublishing/Hands-on-Microservices-with-JavaScript
文件夹下载本书的 GitHub 仓库,以便轻松跟随我们的代码片段。
理解业务需求
在你用 JavaScript 构建微服务之前,清楚地了解你的服务需要做什么非常重要。然后,根据你的项目需求,你可以选择合适的工具来帮助你创建这些微服务。
团队不仅仅由开发者组成。在构建具有商业价值的应用程序时,业务领域的人也是团队不可或缺的一部分。在软件开发中,最终和主要的产品是代码,它应该反映真实的业务。应用大家都能使用的领域语言将使你的代码成为宝贵的真相来源,而这只有在业务和开发者之间没有翻译的情况下才可能实现。开发这种类型微服务最流行的方法是用大家都能说的同一种语言,这种方法被称为领域驱动设计(DDD)。团队中的每个人都应该使用描述给定边界的业务的语言。这被称为通用语言(UL)。使用 UL,团队中的每个人都会说同一种语言,这种语言将反映在你的代码中。这意味着业务帮助你设计,开发者帮助业务有更清晰的理解。
在我们的学习过程中,我们会提到一些来自领域驱动设计(DDD)的想法,尽管这不是一本 DDD 书籍。请参考 Vaugh Vernon 的《实现领域驱动设计》(Implementing Domain-Driven Design)和 Eric Evans 的《领域驱动设计:软件核心的复杂性处理》(Domain-Driven Design: Tackling Complexity in the Heart of Software)以了解更多信息。
这本书不是关于分析和收集业务需求的。外面确实有很好的资源专注于这个话题。为了使事情简单化并减少理论性,我们将从提炼出的具有明确边界的业务需求开始。
我们将构建一个需要以下核心功能的账户构建微服务:
-
创建账户
-
更新账户
-
获取所有账户信息
-
根据给定的 ID 获取账户
-
删除未使用的账户
然后,我们还有以下以下非功能性要求:
-
可伸缩性:随着越来越多的人使用,微服务应该能够处理越来越多的请求。
-
性能:微服务应该快速响应用户请求,以保持用户满意。
-
弹性:微服务应该能够从问题中恢复过来,并保持正常工作。
-
易于测试:微服务应该简单易测试,以确保其正确工作。
-
无状态:微服务不应该依赖于记住与用户的过去交互,而应该将任何重要信息存储在数据库中。
-
易于更新:微服务应该简单易用,并在需要时进行更新。当然,通常你会有比这些更多的非功能性要求。然而,为了入门,这些应该已经足够了。
开发基本微服务所需的工具
开发应用程序不仅仅是编码过程。在本章中,为了开发我们的微服务,我们还需要选择 Node.js 框架并在我们的数据库中存储信息:
-
数据库:我们需要将信息存储在某个地方。最好是保持服务本身简单,并将信息存储在单独的数据库中:
对于这项服务,我们将使用 MongoDB,这是一个与传统 SQL 数据库工作方式不同的流行数据库。MongoDB 是使用 Node.js 技术构建 Web 服务的流行选择。
它旨在处理用户主要读取信息的情况,并且可以高效地存储大量数据。MongoDB 可以通过添加更多服务器轻松扩展。
-
Node.js 框架:您可以使用 Node.js 构建一个功能齐全的微服务,但这需要一些时间和大量的代码行。如今,大多数开发者使用 Node.js 框架来快速构建服务,同时代码行数最少。Node.js 本身是一个低级环境。框架为您提供了预定义的结构和组织,使得随着项目的发展,代码更容易管理和维护。它们通常内置了常见的功能,如 路由(处理不同的 URL 请求)、模板(生成网页内容)和数据库交互。这可以节省您自己编写这些部分的时间。
流行的 Node.js 框架拥有庞大的开发者社区。这意味着如果您遇到问题,可以在线访问丰富的资源、教程和解决方案。还有更多熟悉该框架的开发者,可以用于潜在的协作。
框架可以通过提供既定的编码实践和功能来防止常见攻击,从而帮助减轻安全漏洞。
对于本章,我们将使用 Express.js,这是构建微服务中最受欢迎的 Node.js 框架之一。
准备我们的第一个项目
本章的重点是创建一个提供真正简单 CRUD 操作的微服务。因此,它将主要关注领域。这就是为什么我们从数据库开始构建应用程序。
要跟随我们的示例,请打开本书 GitHub 仓库中的Ch05
文件夹,并使用您喜欢的文本编辑器打开它。
知道路径和走过路径是不同的。
我们希望您不仅下载并探索仓库,还尝试与我们一起编写一些代码。这将帮助您获得宝贵的实践经验。
理解包的概念
构建软件并不意味着您应该从头开始实现一切,这一事实并不取决于编程语言。它也适用于 Node.js 开发。我们始终应该关注解决业务问题,并使这个过程快速、简单、安全、可靠。几乎每种流行的编程语言都提供了一组库。在 Node.js 中,这些被称为 包。Node.js 开发通常从包配置开始。
当你安装 Node.js 时,你将自动安装 npm
,这是一个优秀且可靠的开发资源。例如,如果你需要为你的应用程序进行验证,你不需要从头开始构建一切。为什么不使用已经在流行库中实现过的流行实践呢?我们通常只自己构建业务特定的功能。其他相关功能,如连接数据库、验证、安全、日志记录等,可以作为包安装并重用。
这就是如何与 npm
交互并配置它的方法:
-
打开你喜欢的文本编辑器(我们使用 Visual Studio Code)。
-
创建一个文件夹(在我们的例子中,是
Ch05
)。 -
使用你的终端导航到该文件夹(使用
cd folder_name
命令导航到你的空文件夹——即使用cd Ch05
)。 -
输入
npm init
并遵循提供的说明(图 5.1):
图 5.1:创建 package.json 文件
-
按 Enter 生成 配置 包 (package.json)。
-
到目前为止,我们在项目中有一个特殊的文件叫做
package.json
,包含以下 JSON 内容:{ "name": "accountmicroservice", "version": "1.0.0", "description": "simple account microservice with crud functionalities", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { "type": "git", "url": "git+https://github.com/PacktPublishing/Hands-on- Microservices-with-JavaScript.git" }, "author": "Suleymani Tural", "license": "ISC", "bugs": { "url": "https://github.com/PacktPublishing/Hands-on- Microservices-with-JavaScript/issues" }, "homepage": "https://github.com/PacktPublishing/Hands-on- Microservices-with-JavaScript#readme" }
-
对于学习目的,也可以使用
npm init -y
命令代替npm init
,因为它会为你生成一个最小的package.json
文件以便你开始使用(图 5.2)。生成后,你可以手动更新任何你想要的行:
图 5.2:生成的 package.json 文件
让我们更深入地了解 package.json
文件。
理解 package.json 文件
package.json
文件是 Node.js 项目中的一个重要组成部分。它就像一个项目清单,存储有关你的项目的关键信息。
让我们总结一下 package.json
文件在我们 Node.js 项目中的作用:
-
它列出了你的项目为了运行所依赖的所有外部模块(依赖项)。
-
除了名称外,它还使用语义版本控制来指定所需的版本,以确保所有参与项目的人都使用兼容的依赖项版本。
-
它作为项目元数据(如项目的名称、版本、描述、许可证和作者信息)的中心位置(见 图 5.2)。
-
你可以在
package.json
文件中定义自定义脚本来自动化项目中的重复性任务。这些脚本可以从启动开发服务器到运行测试或构建项目以部署做任何事情。 -
如果你计划将你的项目作为可重用的包发布供他人使用,
package.json
就变得更加重要。它为包管理器(如npm
)提供了理解如何有效安装和使用你的项目所必需的信息。
简而言之,package.json
使你的项目井然有序,确保依赖项的一致性,并简化了开发者之间的协作。
理解 index.js 文件
在本章中,你将遇到名为 index.js
的文件。这些文件扮演着几个重要的角色。
按照惯例,index.js
作为我们的 Node.js 应用的入口点。当我们使用 node 运行应用时,index.js
文件是应用执行开始的起点。
在 index.js
中,你可以通常找到使用所需语句导入必要模块和库的代码,以配置你的应用(例如,设置 Web 服务器和连接到数据库)并定义应用的主逻辑或事件监听器。
这些文件也可以用于在项目中进行命名空间和组织。考虑一个包含多个具有相关功能的 JavaScript 文件的文件夹。该文件夹中的 index.js
文件可以作为导入这些相关文件和从这些文件中重新导出特定函数或类的中心点,使得它们可以通过单个导入语句在文件夹外部访问。
注意
需要注意的是,index.js
只是一种惯例,并非严格的要求。你可以将入口点文件命名为不同的名称(例如,app.js
或 main.js
)。只要你在使用 node 运行应用时指定了正确的文件名,它就会正常工作。
总结来说,index.js
文件在 Node.js 项目中充当一个常见的入口点,以及一种在文件夹内组织代码的方式。它们提供了一种干净且一致的方法来构建应用起点和管理相关功能。
安装所需的包
单独来看,package.json
不包含我们计划默认使用的任何必需包。它只是启动时的模板。以下是本章我们将使用的包及其安装命令列表:
-
Express (
npm install express
) -
Joi (
npm install joi
) -
mongoose (
npm install mongoose
) -
dotenv (
npm install dotenv –save
)
下面是 package.json
文件中的依赖项和 devDependencies
的样子:
"dependencies": {
"dotenv": "¹⁶.4.5",
"express": "⁴.19.2",
"joi": "¹⁷.12.3",
"mongoose": "⁸.3.2"
}
你可能已经意识到,当我们安装第一个包时,node 会自动生成另一个名为 package-lock.json
的文件。让我们总结一下它的作用:
它作为一个锁文件,指定了安装的包及其依赖的确切版本。这保证了无论谁安装项目或在哪里安装,都将使用相同的版本集,从而确保行为的一致性。
通过锁定版本,package-lock.json
允许开发者精确地重现项目环境。这对于维护稳定性和避免在部署期间或不同开发机器上出现意外问题至关重要。
当在团队中共享或在 package-lock.json
中使用时,确保所有相关人员都在使用相同的依赖项。这简化了协作并自动化了可靠的构建。
它与 package.json
一起工作。虽然 package.json
指定了所需的依赖项及其版本范围,但 package-lock.json
确定了安装过程中使用的确切版本。
总体而言,package-lock.json
对于在安装和团队工作流程中维护一致的、可重复的 Node.js 项目环境非常重要。
你可能已经注意到我们还有一个额外的文件夹:node_modules
。在 Node.js 项目中,node_modules
文件夹是一个特殊的目录,用于存储项目依赖的所有第三方库。这些库提供了预先编写的代码,用于实现你不需要从头开始构建的功能,从而节省时间和精力。
Node.js 项目通常依赖于来自各种来源的外部代码。node_modules
文件夹将所有这些依赖项组织在一个地方。每个项目都可以有自己的依赖项集合,以满足其特定需求。这样,不同的项目可以使用同一库的不同版本,而不会发生冲突。
到目前为止,我们已经为我们的应用程序构建了一个初始框架。因此,现在是时候开始实际开发过程了。
定义我们微服务的内部架构
想象一下,经过仔细分析后,我们决定有一个专门负责处理账户信息的特殊微服务,我们称之为 账户微服务。我们的账户包括一个 ID、账户名称、账户类型、账户状态 和 账户号码。为了跟踪更改,我们将添加 createdAt
和 updatedAt
字段。
微服务方法已应用于我们的整个项目,现在我们拥有多个服务。然而,我们具体微服务的内部设计取决于需求,并且由团队决定应用哪种架构模式。软件开发中最受欢迎和经典的架构模式之一是 模型-视图-控制器(MVC)。为了使事情简单易懂,我们将将其应用于我们的微服务设计。
MVC 架构模式
MVC 架构模式由于其强调关注点的分离,是结构化 Node.js API 的流行选择。
它具有以下主要组件:
-
模型:
-
表示 API 的数据层。
-
封装数据访问逻辑并与数据库(例如 MongoDB、MySQL 等)交互。
-
处理数据持久性和检索。
-
-
视图(在 Node.js API 中不直接使用):
-
传统上处理 Web 应用程序中的 UI 呈现。
-
不直接适用于 Node.js API,因为它们以数据为中心。
-
注意,视图 的概念可以扩展到表示 API 的响应格式(JSON、XML)。
-
-
GET
、POST
、PUT
和DELETE
)。 -
与模型交互以根据请求获取或操作数据。
-
准备以所需格式(JSON、XML)的数据响应。
-
向客户端返回响应。
但为什么使用 MVC 对 Node.js API 有益?
-
关注点分离:通过划分功能使代码更有组织和易于维护。
-
提高可测试性:每个层(模型、视图和控制器)都可以独立测试。
-
灵活性:更容易修改或更新 API 的特定部分,而不会影响其他部分。
-
可扩展性:通过添加更多控制器或模型更容易扩展应用程序。
现在我们已经对 MVC 有了足够的理论知识,是时候将其应用于实践了。
将 MVC 集成到我们的 Node.js 项目中
虽然 Node.js 没有内置的 MVC 框架,但像Express.js这样的流行 Web 框架可以用来实现该模式。Express.js 处理路由(将 URL 映射到控制器)并简化请求-响应处理。
您通常会使用为模型和控制器分别创建的单独文件夹来结构化项目。
我们将使用N 层架构来结构化我们的代码。这种架构之所以受欢迎,是因为它促进了关注点的分离,使应用程序更加模块化、易于维护和可扩展。
由于我们没有复杂的需求,因此从它开始是一个很好的起点。在您的 Node.js Express 项目中同时使用 MVC 和 N 层架构可以导致一个结构良好且易于维护的 API。以下是一些您可能会遇到的一些常见层:
-
表示层(UI 或 API)
-
业务逻辑层(核心应用程序逻辑)
-
数据访问层(与数据库的交互)
模型,代表数据和其逻辑,与数据访问层的责任相一致。services
文件夹中的脚本将存储我们的业务逻辑。控制器,它处理请求并操作数据,不应包含任何业务逻辑。相反,它应该作为一个桥梁,将用户的请求转发到业务逻辑层。
现在我们已经为我们的应用程序定义了一个通用的架构,我们准备专注于实现细节。从下一节开始,我们将实现第一个微服务的功能行为。
实践开发账户微服务
要从头开始,让我们创建一个名为src
的文件夹。我们计划在这个文件夹下组织我们的主要应用程序结构。这将是一个以数据为中心的应用程序,因此最好从数据库部分开始开发。
最后,我们计划为我们的应用程序构建以下项目结构:
图 5.3:最终项目结构
为了轻松跟随,别忘了从我们的仓库下载源代码。
实现我们的数据访问功能
我们总是将数据存储在某个地方。最常用的数据存储是数据库。我们计划实现的数据访问依赖于 MongoDB,并使我们免于使用 SQL 查询数据库的困难。它就像使用数组或列表,但在底层,它与数据库交互。
要开始创建我们的账户微服务,请执行以下操作:
在 src
下创建一个 db
文件夹。
-
将
index.js
文件添加到db
文件夹中。 -
首先,我们需要处理数据库通信过程。这就是为什么我们的当前文件 (
index.js
) 将提供连接和断开连接的功能:const db = require('mongoose'); let mongoUrl; async function connect({ mongo: { url } }) { mongoUrl = url; try { await db.connect(mongoUrl); } catch (err) { setTimeout(connect, 8000); } } const dbConnection = db.connection; function disconnect() { dbConnection.removeAllListeners(); return db.disconnect(); } module.exports = { connect, disconnect, };
我们已经提到了
mongoose
包。为了使用这样的包,我们有required
命令。Node.js 会自动处理node_modules
文件夹中的包,无需指定任何相对或完整路径。在这里,我们实现了两个主要功能。它们主要作为现有功能的包装器:
-
connect
函数尝试连接到指定的数据库。如果出现错误,我们将在8
秒后再次尝试连接到数据库。这取决于你如何配置,但在不成功操作后进行连接尝试是有意义的。 -
disconnect
函数处理断开连接的情况。在这里,我们通过db.disconnect()
手动移除我们数据库的所有监听器。
Node.js 有一个基于文件的模块方法。这意味着每个文件本身都可以被视为一个模块,其他模块可以使用它来构建更复杂的模块。你可以通过使用
exports
来使模块的一些功能对其他人可用。你应该只提供以下适当封装的所需函数。如果你的某些函数被同一模块中的其他函数使用,并且它们不是模块合同的一部分,那么最好不要在exports
列表中指定它们。在我们的例子中,我们有两个函数 –connect
和disconnect
– 我们提供外部使用,以便其他模块使用。 -
实现 MVC 中的 M
在我们的项目中,模型的责任是充当数据访问层。这一层涵盖了主要操作,如 INSERT
、UPDATE
、SELECT
和 DELETE
。我们在 src/models
文件夹下有 account.js
;这就是所有数据库相关功能所在的地方。下面是这个文件的样子:
const mongoose = require('mongoose');
const { Schema } = mongoose;
const AccountSchema = new Schema(
{
name: {
type: String,
required: true,
},
number: {
type: String,
required: true,
},
type: {
type: String,
enum: ['root', 'sub'],
default: 'root',
},
status: {
type: String,
enum: ['new', 'active', 'inactive', 'blocked'],
default: 'new',
},
createdAt: {
type: Date,
default: Date.now,
},
updatedAt: Date,
},
{ optimisticConcurrency: true },
);
module.exports = mongoose.model('account', AccountSchema);
让我们逐步分析这段代码:
-
const mongoose = require('mongoose');
: 这一行导入了 Mongoose 库,该库用于在 Node.js 中与 MongoDB 数据库交互。 -
const { Schema } = mongoose;
: 这一行使用解构从mongoose
对象中提取Schema
类。这使得代码更加简洁,更容易阅读。 -
new Schema({ ... })
: 这一行创建了一个新的 Mongoose 架构对象。作为参数传递的对象定义了将在 MongoDB 数据库的账户集合中存储的文档的结构。在大括号{}
内,你定义了集合中每个文档的属性(字段)。以下是每个属性的分解:-
name
:-
type: String
: 这指定了name
属性应该是一个字符串。 -
required: true
: 这使得name
属性是必需的。没有名称值的文档无法保存。
-
-
number
: 与name
类似,但也是必需的。 -
type
:-
type: String:
status
属性是一个字符串。 -
enum: ['new', 'active', 'inactive', 'blocked']
:类似于type
,这定义了状态允许值的列表:'new'
、'active'
、'inactive'
或'blocked'
。 -
default: 'new'
:如果没有指定状态,它将默认为'new'
。
-
-
createdAt
:type: Date
: 此属性存储文档创建的日期和时间,使用当前时间(Date.now
)。
-
updatedAt
:type: Date:
此属性旨在存储文档最后更新的日期和时间。然而,它在此处并未显式设置默认值。你很可能需要在应用程序逻辑中手动更新此字段。
-
{ optimisticConcurrency: true }
:此选项用于乐观并发控制,这是一种帮助防止更新期间数据不一致的机制(mongoosejs.com/docs/guide.html#optimisticConcurrency
)。
-
-
module.exports = mongoose.model('account', AccountSchema);
:这一行创建了一个基于你定义的AccountSchema
的 Mongoose 模型,名为account
。该模型作为与 MongoDB 数据库中的account
文档交互的蓝图。通过导出模型,你使它在 Node.js 应用程序的其他部分可用。
简而言之,这段代码为在 MongoDB 集合中存储账户信息设置了一个 Mongoose 模式。它定义了诸如name
、number
、type
、status
、创建时间
和最后更新时间
等属性,并具有验证规则和默认值。然后,代码导出一个模型,允许你在数据库中创建、读取、更新和删除账户文档。
存储配置
我们需要将应用程序相关的配置存储在某个地方。对于当前情况,我们需要 MongoDB 的 URL 和端口信息。直接将此信息硬编码到代码中并不是一个好主意,因为这会影响可维护性、可重用性和可扩展性。相反,我们更倾向于将其存储在一个单独的文件中。这就是为什么我们安装了dotenv
包。
Node.js 中的dotenv
包帮助你管理项目中的环境变量。它提供了一种将配置设置(如 API 密钥或数据库凭据)存储在代码之外,在.env
文件中的方法。这通过将敏感数据从代码库中排除来提高安全性。
为什么使用dotenv
?
-
安全性:它将敏感数据从代码库中排除,降低了意外暴露的风险
-
关注点分离:它将配置与代码分离,使你的代码更干净且易于管理
-
为不同环境(开发、测试和生产)创建具有特定配置的
.env
文件
.env
文件本身不应包含在版本控制系统(如 Git)中,以避免提交敏感信息。我们可以创建一个包含占位符值的 .env.example
文件,以指导开发者如何设置他们的环境变量。然而,对于这本书,我们将以原样将 .env
文件包含在我们的 Git 仓库中,以方便学习过程。
我们在 Ch05
文件夹下有一个 configs
文件夹。它是一个根级文件夹,包含一个无名的 .env
文件。以下是其内容:
PORT=3001
MONGODB_URL=mongodb://localhost:27017/account-microservice
我们需要根据我们查看的配置进行验证和创建配置对象。这就是为什么我们需要在 src
文件夹下创建一个名为 config
的额外文件夹。所以,让我们创建 config.js
并包含以下内容:
const dotenv = require('dotenv');
const Joi = require('joi');
const envVarsSchema = Joi.object()
.keys({
PORT: Joi.number().default(3000),
MONGODB_URL: Joi.string().required().description('Mongo DB url')
})
.unknown();
function createConfig(configPath) {
dotenv.config({ path: configPath });
const { value: envVars, error } = envVarsSchema
.prefs({ errors: { label: 'key' } })
.validate(process.env);
if (error) {
throw new Error(`Config validation error: ${error.message}`);
}
return {
port: envVars.PORT,
mongo: {
url: envVars.MONGODB_URL,
}
};
}
module.exports = {
createConfig,
};
使用 createConfig
函数,我们能够读取和构建配置对象。
有很多包可以用于数据验证。我们更喜欢使用 Joi,因为它受欢迎且易于使用。Joi 是一个流行的开源包,它提供了一种声明式的方式来定义数据模式并对这些模式进行验证。
它允许你创建代表应用程序输入(请求体、查询参数等)预期结构和数据类型的 JavaScript 对象。
它为常见的字符串、数字、数组和对象等数据类型提供了一系列验证规则。你可以定义存在性、格式、长度等规则。
它与 Express.js 中间件无缝集成,允许你在路由处理程序中直接验证数据。
通过将验证逻辑从你的路由处理程序中分离出来,你的代码变得更干净、更容易理解。
总体而言,Joi 是构建健壮和安全的 Node.js 应用程序的有价值工具。通过结合 Joi 进行数据验证,你可以确保你的应用程序接收干净、可靠的数据,从而带来更稳定和安全的开发体验。
我们将在我们的应用程序入口点 (src/index.js
) 中使用它。
实现业务层
业务层是 N 层架构的核心。它负责应用程序的核心功能,并实现了管理应用程序操作的具体业务规则。它将用户请求转换为基于业务规则的动作和决策。它还确定如何处理、验证和操作数据以满足这些请求。此外,它作为表示层(用户界面)和数据访问层(数据库)之间的中介。它从表示层接收数据请求,从数据访问层检索必要的数据,然后在返回处理后的数据之前应用业务逻辑。
通过将业务逻辑与表示层和数据访问层分离,业务层促进了松散耦合和可重用性。这使得应用程序更容易维护、测试和修改,因为业务需求不断变化。
我们在src
下有一个services
文件夹,用于物理定位服务功能。在account.js
中,我们主要拥有五个函数来覆盖与 CRUD 相关的主要操作:getAccountById
、getAllAccounts
、createAccount
、deleteAccountById
和updateAccountById
。让我们看看前四个:
const Account = require('../models/account');
//get account info by id
function getAccountById(id) {
return Account.findById(id);
}
//get all account information
function getAllAccounts() {
return Account.find({});
}
//create account based on name,number,type and status
function createAccount(name, number, type, status) {
return Account.create({ number, name, type, status });
}
//delete account by account id
async function deleteAccountById(id) {
const deletedAccount = await Account.findByIdAndDelete(id);
if(deletedAccount)
return true;
else
return false;
}
前四个函数很容易理解。使用require
,我们从模型中导入我们的账户。然后,我们用业务函数包装我们的数据访问操作。这个模块中最大的函数是updateAccountById
。让我们仔细看看:
//'new', 'active', 'inactive', 'blocked'
const availableAccountStatusesForUpdate = {
new: ['active', 'blocked'],
active: ['inactive', 'blocked'],
inactive: ['active'],
blocked: ['active'],
};
//'root', 'sub'
const availableAccountTypesForUpdate = {
root: ['sub'],
sub: ['root'],
};
const NO_VALID_DATA_TO_UPDATE = 0;
const INVALID_STATUS_CODE = 1;
const INVALID_TYPE_CODE = 2;
const INVALID_ACCOUNT = 3;
const INVALID_STATE_TRANSITION = 4;
const INVALID_TYPE_TRANSITION = 5;
async function updateAccountById(id, { name, number, type, status }) {
if (!name && !number && !type && !status) {
return { error: 'provide at least one valid data to be
updated', code: NO_VALID_DATA_TO_UPDATE };
}
if (status && !(status in availableAccountStatusesForUpdate)) {
return { error: 'invalid status for account', code: INVALID_
STATUS_CODE };
}
if (type && !(type in availableAccountTypesForUpdate)) {
return { error: 'invalid type for account', code: INVALID_
TYPE_CODE };
}
const account = await Account.findById(id);
if (!account) {
return { error: 'account not found', code: INVALID_ACCOUNT };
}
//check for available status and transition
if (status) {
const allowedStatuses =
availableAccountStatusesForUpdate[
account.status];
if (!allowedStatuses.includes(status)) {
return {
error: `cannot update status from '${account.status}'
to '${status}'`,
code: INVALID_STATE_TRANSITION,
};
}
}
//check for available type and transition
if (type) {
const allowedTypes = availableAccountTypesForUpdate[account
.type];
if (!allowedTypes.includes(type)) {
return {
error: `cannot update type from '${account.type}' to
'${type}'`,
code: INVALID_TYPE_TRANSITION,
};
}
}
account.status = status ?? account.status;
account.type = type ?? account.type;
account.name = name ?? account.name;
account.number = number ?? account.number;
account.updatedAt = Date.now();
await account.save();
return account;
}
最后,我们需要导出所需的代码块,以便其他服务可以使用:
module.exports = {
getAccountById,
getAllAccounts,
createAccount,
updateAccountById,
deleteAccountById,
errorCodes: {
NO_VALID_DATA_TO_UPDATE,
INVALID_STATUS_CODE,
INVALID_TYPE_CODE,
INVALID_ACCOUNT,
INVALID_STATE_TRANSITION,
INVALID_TYPE_TRANSITION,
},
};
在更新我们的账户信息之前,我们有以下检查:
-
只有当提供一个字段时才允许更新发生。
-
如果提供了无效的状态代码,则返回错误。
-
如果提供了无效的类型,则返回错误。
-
如果给定 ID 的账户不存在,我们需要返回错误。
我们有一些规则来更新状态。首先,availableAccountStatusesForUpdate
描述了规则:如果状态是new
,则可以更新为active
或blocked
。对于active
,可以更新为inactive
和blocked
。如果状态是inactive
,则只允许更新为active
。blocked
状态只能过渡到active
。
这些并不是你可以实现的所有可能的验证,但它们展示了你可以如何应用检查逻辑来更新功能。最终,我们公开带有错误代码的函数供上层使用。
实现控制器
在使用 MVC 模式的 Node.js 项目中,控制器充当中枢神经系统,处理用户请求并协调应用程序的响应。它是用户请求的第一个接触点。它解释 URL、HTTP 方法(GET
、POST
等)以及请求中包含的任何参数。控制器本质上不实现业务逻辑;相反,它根据请求指导应用程序的流程。它可能需要与模型交互以检索或操作数据,或者它可能在继续之前执行一些基本的验证或处理。
控制器与模型交互以获取满足用户请求所需的数据。这可能涉及从数据库中获取数据、执行计算或模型层中定义的任何其他操作。
一旦控制器有了数据或处理了请求,它就会选择适当的视图来渲染经典 UI 应用程序的响应。它也可能准备数据,以便它可以被视图消费,例如将其格式化为特定的模板。在我们的情况下,我们没有完整的 UI,我们的数据 JSON 表示充当我们的 UI。
最后,控制器生成响应并发送给用户。这可能是一个 HTML 页面,API 的 JSON 数据,或任何适合请求的格式。
从本质上讲,控制器充当中间人,管理用户(通过视图)和数据层(通过模型)之间的通信流程。它保持视图和模型分离,促进更干净的代码和更易于维护。
为了在我们的项目中实现控制器机制,我们需要在 src
文件夹下创建一个名为 controllers
的文件夹,并添加一个名为 account.js
的新 JavaScript 文件:
const accountService = require('../services/account');
const getAccounts = async (req, res) => {
const result = await accountService.getAllAccounts();
res.status(200).json({ success: true, account: result.map(x =>
mapToResponse(x)) });
};
const createAccount = async (req, res) => {
const { name, number, type, status } = req.body;
const result = await accountService.createAccount(name, number,
type, status);
res.status(201).json({
success: true,
Account: mapToResponse(result),
});
};
const deleteAccountById = async (req, res) => {
const isDeleted = await accountService.deleteAccountById(req
.params.id);
if(isDeleted)
res.status(204).json({
success: true
});
else
res.status(400).json({ success: false, message: 'No valid data to
delete' });
};
实现检索(get)、创建和删除账户时事情很简单。然而,当我们更新账户信息时,我们应该考虑一些额外的因素:
首先,getAccounts
调用 getallAcccounts
服务函数并返回 200
响应。
然后,createAccount
调用服务中同名函数并返回 201
,这意味着资源已被创建。
最后,deleteAccountById
调用服务中同名函数并返回 204
,这意味着无内容的成功。如果删除操作失败,它将返回 400
状态码。
接下来,让我们看看更新实现:
const updateAccountById = async (req, res) => {
const result = await accountService.updateAccountById(
req.params.id, req.body);
if (result.error) {
switch (result.code) {
case accountService.errorCodes.NO_VALID_DATA_TO_UPDATE:
res.status(400).json({ success: false, message:
result.error });
return;
case accountService.errorCodes.INVALID_STATUS_CODE:
res.status(400).json({ success: false, message:
'invalid status' });
return;
case accountService.errorCodes.INVALID_TYPE_CODE:
res.status(400).json({ success: false, message:
'invalid type' });
return;
case accountService.errorCodes.INVALID_ACCOUNT:
res.status(404).json({ success: false, message:
'Account not found' });
return;
case accountService.errorCodes.INVALID_STATE_TRANSITION:
res.status(400).json({ success: false, message:
result.error });
return;
case accountService.errorCodes.INVALID_TYPE_TRANSITION:
res.status(400).json({ success: false, message:
result.error });
return;
default:
res.status(500).json({ success: false, message:
'internal server error' });
return;
}
}
res.status(200).json({
success: true,
Account: mapToResponse(result),
});
};
在这里,updateAccountById
有几行额外的代码。根据导出的错误代码,它准备不同的 HTTP 状态码。如果提供的数据有效,它将返回 200
成功代码。
我们还有一个名为 mapToResponse
的简单函数。在 Node.js 中,mapToResponse
作为工具函数,用于将账户对象转换或映射到特定的格式或结构,这适合作为响应发送,通常在 API 中。下面是这个函数的样子:
function mapToResponse(account) {
const {
id, name, number, type, status,
} = account;
return {
id,
name,
number,
type,
status
};
}
module.exports = {
getAccountById,
getAccounts,
createAccount,
deleteAccountById,
updateAccountById,
};
我们唯一未导出的 private
函数是 mapToResponse
。正如您所知,用户可能不需要检索整个账户数据结构。使用此函数,我们只将所需的字段作为响应返回给用户。
最后一部分代码是通过 ID 检索账户 (getAccountById
):
const accountService = require('../services/account');
const getAccountById = async (req, res) => {
const result = await accountService.getAccountById(req.params.id);
if (result) {
res.status(200).json({ success: true, account:
mapToResponse(result) });
} else {
res.status(404).json({ success: false, message: 'Account not
found' });
}
};
在这里,getAccountById
将查询重定向到适当的服务,并根据服务的响应返回成功或未找到的消息。
最后,为了在路由过程中使用主控制器函数,我们必须导出它们。
为您的 API 进行简单的数据验证
未经验证的数据可能导致意外行为、错误和安全漏洞。验证有助于确保从用户或外部来源接收到的数据符合您的应用程序的期望。
恶意用户可能会尝试将无效或意外的数据注入到您的应用程序中。验证通过拒绝不符合定义规则的数据来帮助防止这些攻击。
通过提前定义验证规则,您可以在开发早期阶段捕获错误,减少调试时间并提高代码的可维护性。
让我们在 src
下创建一个名为 account.js
的验证文件夹,其中包含以下代码行:
const Joi = require('joi');
const objectId = Joi.string().regex(/^[0-9a-fA-F]{24}$/);
const getAccountById = {
params: Joi.object().keys({
id: objectId.required(),
}),
};
const deleteAccountById = {
params: Joi.object().keys({
id: objectId.required(),
}),
};
const createAccount = {
body: Joi.object().keys({
name: Joi.string().required(),
number: Joi.string().required(),
status: Joi.string().valid('new', 'active', 'completed',
'cancelled').optional(),
type: Joi.string().valid('root', 'sub').optional(),
}),
};
让我们更仔细地看看代码:
-
安装完
Joi
包后,只需在require
命令中指定它即可。 -
正则表达式定义了一个验证 ID 的规则。我们将使用此 ID 进行
GET
、PUT
和DELETE
操作。 -
const createAccount = { ... }
: 这行代码声明了一个名为createAccount
的常量变量,并将其赋值为一个对象字面量。此对象将包含创建账户的验证模式。 -
body
: 此属性名指定验证模式适用于请求体(通常,在POST
请求的体中发送的数据)。 -
Joi.object()
: 这将创建一个 Joi 对象模式,用于验证请求体的结构(特定属性的必要性)。 -
.keys({ ... })
: 这定义了请求体中预期存在的属性集及其相应的验证规则。 -
name: Joi.string().required()
: 这验证了名为name
的属性的必要性,并确保它是一个字符串值。.required()
部分使其成为必填项。 -
number: Joi.string().required()
: 与name
类似,这验证了一个名为number
的必需字符串属性。 -
status: Joi.string().valid('new', 'active', 'completed', 'cancelled').optional()
: 这验证了一个名为status
的可选字符串属性。.valid()
方法限制了允许的值为'new'
、'active'
、'completed'
和'cancelled'
。 -
type: Joi.string().valid('root', 'sub').optional()
: 与status
类似,这验证了一个名为type
的可选字符串属性,其允许的值为'root'
和'sub'
。 -
通常,前面的代码确保创建账户的请求必须包含以下属性:
-
name
: 必需的字符串值 -
number
: 必需的字符串值 -
status
: 可选的字符串值,可以是'new'
、'active'
、'completed'
或'cancelled'
-
type
: 可选的字符串值,可以是'root'
或'sub'
-
通过使用此模式,您可以确保接收到的创建账户数据符合预期的格式,并防止意外或无效的数据进入您的应用程序:
const updateAccountById = {
params: Joi.object().keys({
id: objectId.required(),
}),
body: Joi.object().keys({
name: Joi.string().required(),
number: Joi.string().required(),
status: Joi.string().valid('new', 'active', 'completed',
'cancelled').optional(),
type: Joi.string().valid('root', 'sub').optional(),
}),
};
module.exports = {
getAccountById,
createAccount,
deleteAccountById,
updateAccountById,
};
updateAccountById
对象指定参数必须包括一个id
参数,该参数是必需的,并且必须是一个有效的对象 ID。请求的body
部分必须包含name
和number
字段,这两个字段都是必需的字符串,并且可选的status
字段只能为指定的值之一('new'
、'active'
、'completed'
或'cancelled'
),以及一个type
字段,可以是'root'
或'sub'
。此验证确保传入的更新账户请求符合预期的格式和数据类型。最后,为了使用这些规则,我们需要使用module.exports
导出它们。
我们还有一个与数据验证相关的模块,位于src
文件夹下的middleware
文件夹中。validate.js
文件包含以下内容:
const Joi = require('joi');
function take(object, keys) {
return Object.assign({}, ...keys
.filter(key => object.hasOwnProperty(key))
.map(key => ({ [key]: object[key] })));
}
function validate(schema) {
return (req, res, next) => {
// Extract relevant parts of the schema based on request type
const selectedSchema = take(schema, ['params', 'query',
'body']);
const objectToValidate = take(req,
Object.keys(selectedSchema));
// Perform Joi validation with improved error handling
const { error, value } = Joi.compile(selectedSchema)
.prefs({ errors: { label: 'key' }, abortEarly: false })
.validate(objectToValidate);
if (error) {
const errorMsg = error.details.map(d => d.message).join(',
');
return res.status(400).json({ success: false, message:
errorMsg });
}
// Attach validated data to the request object
Object.assign(req, value);
next();
};
}
如果存在错误,中间件使用 error.details.map(...)
提取单个错误消息,并将它们合并成一个以逗号分隔的字符串(errorMessage
)。然后发送一个包含错误消息的 JSON 格式的 400 Bad Request
响应。
如果验证通过(!error
),则使用 Object.assign
将从 Joi 获得的验证数据(值)附加到 req
对象上。这使得验证数据在后续的路由处理器中易于访问。
此中间件充当你的路由守门人,确保传入的请求符合提供的验证模式。
实现路由
路由 是使用 Node.js 和 Express 构建网络应用程序的基本方面。它本质上将传入的 HTTP 请求定向到应用程序中的适当处理器。
路由允许你定义 URL(端点)和处理它们的代码之间的清晰分离。这促进了模块化,并使你的代码库更易于阅读和管理。
它还允许你为特定 URL 定义针对每个 HTTP 方法的特定处理器。这允许你适当地处理获取数据(GET
)、提交数据(POST
)、更新数据(PUT
)或删除数据(DELETE
)的请求。
通过定义映射到资源和相应 HTTP 方法的路由,你可以建立一个结构良好且可预测的 API,其他应用程序可以与之交互。
随着你的应用程序的发展,路由可以帮助你轻松地添加新功能和功能。
你可以为新功能创建单独的路由处理器,保持你的代码库有组织且可扩展。
路由还允许你将相关的路由分组,促进代码在应用程序不同部分的复用。
简而言之,路由就像是你应用程序的交通控制器,根据它们的 URL 和 HTTP 方法将传入的请求定向到指定的目的地(处理器)。这保持了你的代码的组织性和可维护性,并使你能够构建健壮且可扩展的 Web 应用程序和 API。
我们在 src
文件夹下有一个 routes
文件夹,其中定义了我们应用程序的所有路由规则。目前,这是我们的第一个版本,所以 v1
文件夹表示我们 API 的第一个版本。版本控制允许你在保持与现有客户端兼容性的同时引入更改。
让我们将 accounts
文件夹和 index.js
文件添加到我们的 v1
文件夹中,并定义我们的路由规则。
文件的完整路径将是 src/routers/v1/accounts/index.js
:
const { Router } = require('express');
const accountController = require('../../../controllers/account');
const accountValidation = require('../../../validation/account');
const validate = require('../../../middlewares/validate');
const router = Router();
router.get('/', accountController.getAccounts);
router.get('/:id',
validate(accountValidation.getAccountById),
accountController.getAccountById);
router.post('/',
validate(accountValidation.createAccount),
accountController.createAccount);
router.put('/:id',
validate(accountValidation.updateAccountById),
accountController.updateAccountById);
router.delete('/:id',
validate(accountValidation.deleteAccountById),
accountController.deleteAccountById);
module.exports = router;
Express.js 提供了路由功能。使用它,我们定义了以下内容:
-
用户可以使用
/accounts/:id
通过 ID 获取 一个账户 -
用户可以通过向
/accounts
发送POST
请求来 创建 一个新账户 -
用户可以通过向
/accounts/:id
发送PUT
请求来 更新 一个账户 -
用户可以通过向
/accounts/:id
发送DELETE
请求来 删除 一个账户
验证中间件确保请求在到达处理实际账户管理逻辑的控制函数之前符合预期的格式。
如你所猜,我们没有任何指示符来表示我们的路由需要使用 /accounts
前缀。
我们还需要一个 JavaScript 文件来处理这个问题。让我们在 routes/v1
文件夹下创建一个名为 index.js
的文件,并使用以下实现:
const { Router } = require('express');
const accountRouter = require('./accounts');
const router = Router();
router.use('/accounts', accountRouter);
module.exports = router;
module.exports = router;
现在,我们将能够使用 /accounts
前缀导航到我们的资源。
构建我们的 Web 应用程序
现在,是时候使用 Express.js 框架为 Node.js 应用程序定义基本结构了。
让我们在 src
文件夹下创建一个名为 app.js
的文件,并使用以下代码结构:
const express = require('express');
const v1 = require('./routes/v1');
const app = express();
// service
app.use(express.json());
// V1 API
app.use('/v1', v1);
module.exports = app;
这段代码片段定义了构建我们 Web 应用程序的基本结构:
-
const express = require('express');
:这一行导入 Express.js 框架,提供了构建 Web 服务器和处理 HTTP 请求和响应的功能。 -
const v1 = require('./routes/v1');
:这一行导入一个名为v1.js
的模块,该模块位于名为routes/v1
的文件夹中。该模块定义了应用程序 API 第 1 版的路由(URL 路径)。 -
const app = express();
:这一行使用express()
函数创建 Express 应用程序的实例。这个app
对象将用于定义路由和中间件,以及处理应用程序逻辑。 -
.use(express.json())
:这一行将一个中间件函数注册到 Express 应用程序中。express.json()
中间件解析请求体中的 JSON 数据,使其在路由处理程序中可用。 -
.use('/v1', v1);
:这一行对于路由至关重要。它将导入的v1
模块中定义的路由挂载到应用程序的/v1
路径上。任何以/v1
开头的 URL 请求都将由v1
模块中的函数处理。 -
module.exports = app;
:这一行导出app
对象,这是你的 Express 应用程序的核心。这允许项目中的其他模块导入和使用这个应用程序实例。
实质上,这段代码创建了一个 Express 应用程序,配置了用于处理 JSON 的中间件,挂载了用于 API 第 1 版的单独模块中的路由,并使应用程序实例可供项目其他部分导入和使用。
结合所有元素
我们应用程序的最终步骤是将所有内容组合在一起,就像使用乐高积木一样。这个乐高积木将是一个可运行的主体应用程序,它将帮助我们实现应用程序元素之间的通信。
让我们在 src
文件夹下创建一个包含以下代码的 index.js
文件:
const path = require('path');
const db = require('./db');
const app = require('./app');
const { createConfig } = require('./config/config');
async function execute() {
const configPath = path.join(__dirname, '../configs/.env');
const appConfig = createConfig(configPath);
await db.connect(appConfig);
const server = app.listen(appConfig.port, () => {
console.log('account service started', { port: appConfig.port
});
});
const closeServer = () => {
if (server) {
server.close(() => {
console.log('server closed');
process.exit(1);
});
} else {
process.exit(1);
}
};
const unexpectedError = (error) => {
console.log('unhandled error', { error });
closeServer();
};
process.on('uncaughtException', unexpectedError);
process.on('unhandledRejection', unexpectedError);
}
execute();
这段 Node.js 代码定义了一个名为 execute
的异步函数,它作为应用程序的入口点。以下是其功能分解:
-
const path = require('path');
:导入path
模块以操作文件路径。 -
const db = require('./db');
: 导入db
模块,可能包含连接和与数据库交互的函数 -
const app = require('./app');
: 导入主应用程序模块,可能包含 Express 应用程序实例和应用程序逻辑 -
const { createConfig } = require('./config/config');
: 从config/config.js
模块导入createConfig
函数,可能负责创建应用程序配置 -
async function execute() { ... }
: 定义一个名为execute
的异步函数,当脚本启动时执行*const configPath = path.join(__dirname, '../configs/.env');
: 使用path
模块构建到配置文件(可能是一个.env
文件)的绝对路径,该文件位于当前脚本位置的上两个目录中*const appConfig = createConfig(configPath);
: 使用配置文件路径调用导入的createConfig
函数,可能用于读取和解析配置设置*await db.connect(appConfig);
: 尝试使用db
模块和加载的配置(appConfig
)对象连接到数据库。此行是异步的,因此函数在继续之前等待连接建立*const server = app.listen(appConfig.port, ...);
: 调用导入的app
对象上的方法(可能是listen
),该对象可能是 Express 应用程序。这将在配置中指定的端口上启动服务器(appConfig.port
)。当服务器成功启动时,回调函数记录一条消息*const closeServer = () => { ... }
: 定义一个名为closeServer
的箭头函数,用于优雅地关闭服务器。它检查服务器对象是否存在,然后调用其close
方法。close
的回调函数在服务器关闭时记录一条消息并退出进程,退出代码为1
.*const unexpectedError = (error) => { ... }
: 定义一个名为unexpectedErrorHandler
的箭头函数,用于处理未捕获的错误或未处理的承诺拒绝。它记录错误消息。它调用closeServer
函数以优雅地关闭服务器*process.on('uncaughtException', unexpectedError);
: 将unexpectedErrorHandler
函数附加到process
对象的uncaughtException
事件。这确保了任何在async
函数或承诺链之外抛出的错误都被捕获和处理*process.on('unhandledRejection', unexpectedError);
: 将unexpectedErrorHandler
函数附加到process
对象的unhandledRejection
事件。这确保了任何来自承诺的未处理拒绝都被捕获和处理*execute();
: 调用execute
函数以启动应用程序。由于execute
是异步的,整个应用程序启动过程变为异步,确保在应用程序继续之前数据库连接和服务器启动已完成。
总结来说,此代码设置了应用程序配置,连接到数据库,启动服务器,并实现了健壮且优雅的启动和关闭过程。
运行和测试我们的第一个微服务
我们在本章中不会编写任何单元或集成测试。在第十一章中,我们将深入了解这些主题的细节。对于本章,我们将通过 Postman 进行手动测试。要运行我们的应用程序,请按照以下步骤操作:
-
从我们的 GitHub 仓库下载
Ch05
。 -
通过 Visual Studio Code 打开项目(
Ch05
)。 -
前往终端 | 新建终端。
-
从
Ch05
文件夹运行npm install
命令以加载所需的包。 -
切换到
src
目录(使用cd src
命令)。 -
运行
node index.js
命令。在运行此命令之前,Mongo 应该已经安装。查看第四章以获取有关 Mongo 安装过程的更多信息。 -
打开 Postman。
在接下来的几个小节中,我们将逐一测试我们的端点。
创建一个新账户
要创建一个新账户,请按照以下步骤操作:
-
在 Postman 中创建一个新标签页。
-
从HTTP 动词中选择
POST
。 -
在URL部分输入
localhost:3001/v1/accounts
。 -
前往
raw
并将Text
更改为JSON
。将以下 JSON 添加到文本区域:{ "name":"AccName1", "number":"Ac21345", "type":"root", "status":"new" }
-
点击发送按钮以发送请求。您将从端点获得以下响应:
{ "success": true, "Account": { "id": "662c081370bd2ba6b5f04e94", "name": "AccName1", "number": "Ac21345", "type": "root", "status": "new" } }
现在,让我们通过 ID 获取账户。
通过 ID 获取账户
要获取具有给定 ID 的账户,请按照以下步骤操作:
-
在 Postman 中创建一个新标签页。
-
从HTTP 动词中选择
GET
。 -
在
662c081370bd2ba6b5f04e94
中输入localhost:3001/v1/accounts/
。 -
点击发送按钮以发送请求。您将从端点获得以下响应:
{ "success": true, "account": { "id": "662c081370bd2ba6b5f04e94", "name": "AccName1", "number": "Ac21345", "type": "root", "status": "new" } }
现在,让我们学习如何更新我们现有的账户。
通过 ID 更新账户
要更新指定的账户,请按照以下步骤操作:
-
在 Postman 中创建一个新标签页。
-
从HTTP 动词中选择
PUT
。 -
在 URL 部分输入
localhost:3001/v1/accounts/{accountID}
。 -
前往
raw
并将Text
更改为JSON
。将以下 JSON 添加到文本区域:{ "name":"updated account", "number":"AE33333" }
-
点击发送按钮以发送请求。您将从端点获得以下响应:
{ "success": true, "Account": { "id": "662c081370bd2ba6b5f04e94", "name": "updated account", "number": "AE33333", "type": "root", "status": "new" } }
对于大多数 API,我们通常希望检索所有数据。接下来,我们将学习如何获取所有账户信息。
获取所有账户
要检索所有账户,请按照以下步骤操作:
-
在 Postman 中创建一个新标签页。
-
从HTTP 动词中选择
GET
。 -
在URL部分输入
localhost:3001/v1/accounts
。 -
点击发送按钮以发送请求。您将从端点获得以下响应:
{ "success": true, "account": [ { "id": "662c081370bd2ba6b5f04e94", "name": "updated account", "number": "AE33333", "type": "root", "status": "new" } ] }
最终端点涉及删除账户。让我们检查一下。
通过 ID 删除账户
最后,要按其 ID 删除已存在的账户,请按照以下步骤操作:
-
在 Postman 中创建一个新标签页。
-
从HTTP 动词中选择
DELETE
。 -
在URL部分输入
localhost:3001/v1/accounts/{accountID}
。提供有效的accountID值以删除记录。 -
点击端点返回的
204 no-content
响应。
这样,我们就为账户有了完全功能的 CRUD 端点。我们可能没有复杂的企业案例,但本章的目的是向您展示如何为您的微服务实现端点。
摘要
在本章中,我们创建了我们的第一个微服务。这是我们关于创建微服务的第一个实践章节。为此,对您的需求有清晰的理解很重要。我们提供了简单的需求,以便我们的第一个微服务更容易理解和跟随。在那里,我们学习了如何设置我们的项目。我们讨论了开发我们的第一个微服务所需的工具;在开始每个微服务开发过程之前,我们需要定义我们计划使用的工具和技术。我们还使用 MVC 和 N 层架构创建了我们的内部结构。这些是最受欢迎的选择,因此,在您的第一个开发项目中使用它们是您学习流行技术的绝佳机会。本章的实践方面涵盖了创建模型、业务逻辑和控制器。在那里,我们学习了使用 JOI 包进行验证的基本知识。应用程序需要一个单独的文件来存储配置,因此我们使用了dotenv
包。我们还学习了路由,这对于我们希望访问我们的功能来说很重要。然后,我们使用 Express.js 集成了路由。
最后,我们学习了如何使用 Postman 检查我们的功能。在接下来的章节中,我们将深入探讨第二个微服务的开发,重点关注微服务之间的同步通信。我们将为事务微服务引入一个新的堆栈,使用 NestJS、Prisma 和 Axios 等工具,以展示 JavaScript 在微服务开发中的多功能性。
第六章:同步微服务
我们在上一章实现了我们的第一个微服务,但为了演示微服务通信,我们需要运行至少一个额外的服务。为了展示 JavaScript 在微服务开发中的美感,我们将使用不同的 Node.js 框架 NestJS 来完成本章。
正如我们之前讨论的,微服务架构由多个服务组成,这种方法带来的复杂性之一是通信。我们已经知道,虽然微服务在可扩展性和开发方面提供了优势,但与单体应用相比,它们在通信方面引入了额外的复杂性。与所有内容都在一起运行的单体应用不同,微服务通过网络进行通信。这引入了延迟(请求处理和响应接收所需的时间)、可靠性(因为网络问题可能会中断通信)和安全性(因为您需要确保服务之间的通信安全)的挑战。
在本章中,我们将深入了解实践中微服务之间的同步通信的细节,并学习服务之间同步通信的用例。
本章涵盖了以下主题:
-
理解事务微服务的要求
-
开发事务微服务的工具
-
实践事务微服务开发
-
建立与账户微服务的同步通信
技术需求
为了开发和测试第二个微服务,我们需要以下内容:
-
IDE(我们更喜欢Visual Studio Code(VS Code))
-
Postman
-
您选择的浏览器
建议您从github.com/PacktPublishing/Hands-on-Microservices-with-JavaScript
下载我们的仓库,并打开Ch06
文件夹,以便轻松跟随我们的代码片段。
理解事务微服务的要求
一切都从需求开始。软件需求基本上是告诉程序员软件程序需要做什么的指令。它们就像程序的食谱,概述了所需的成分(功能)和步骤(功能)。在我们开始开发之前,我们需要了解我们的需求。
系统由两个主要微服务组成:
-
事务微服务:这个微服务将负责处理事务。它将接收事务信息,验证与事务关联的账户,并处理事务。
-
账户微服务:这个微服务将提供账户信息和验证功能。我们在第五章中实现了这个服务。它负责验证账户是否存在且状态良好。
交易微服务将与账户微服务通信,以验证提供的 accountId
值。账户微服务将验证 accountId
值是否存在。
只有当账户存在并且处于 活动 或 新 状态时,交易才应该是成功的。对于其他状态,我们应该在交易服务中添加一个新的条目,带有 失败 状态:
图 6.1:交易微服务和账户微服务之间的通信
首先,我们需要开发我们的交易微服务。在拥有一个正常工作的微服务之后,我们将创建交易和之前构建的账户微服务之间的同步通信。
开发交易微服务的工具
为了构建我们的第二个微服务,我们计划使用完全不同的工具来展示我们即使在 JavaScript 中也不依赖于具体的工具和技术。你可以使用不同的技术来开发相同的微服务,拥有多个技术选项允许你为开发选择最佳的堆栈工具。
NestJS
作为 Node.js 框架,我们计划使用 NestJS。官方页面将其描述为“一个用于构建高效、可靠和可扩展的服务器端应用的渐进式 Node.js 框架。”尽管 Express.js 已经成为使用 Node.js 构建网络应用的既定标准,但它并不强迫你在所有类型的网络应用中使用 Express.js。
首先——NestJS 是另一个 Node.js 框架。查看 第四章 的 Node.js 框架 部分,了解更多关于 NestJS 的信息。以下是它提供的一些总结:
-
它推广了一种 模块化架构,允许你构建可扩展且易于组织的应用程序。你可以轻松地将你的应用程序组织成模块、组件、控制器和服务。
-
NestJS 是基于 TypeScript 构建的,并且在其核心使用 TypeScript。如果你像我一样是 强类型 工具/语言的忠实粉丝,那么它非常适合你。强类型语言强制执行类型安全,这意味着编译器会检查操作。这可以防止意外的崩溃和后续的不正确结果。
-
NestJS 默认支持 验证。它验证传入的数据,这在构建 API 时可能很有帮助。
直接将所有必需的应用程序安装到您的计算机上并不总是最佳选择。这就是为什么我们使用 Docker。虽然直接安装应用程序本身并没有错误,但 Docker 为某些情况提供了一种更高效和可管理的解决方案。
Docker
Docker 帮助开发者更轻松地构建诸如软件程序之类的东西。想象一个包含程序运行所需的所有工具和部件的盒子。这个盒子就像一个 Docker 容器。Docker 允许你将你的程序及其所有组成部分放入这个盒子中,这样它就可以在任何计算机上以相同的方式运行。
查看第四章以了解如何在你的计算机上设置 Docker 的更多信息。
Prisma ORM
Prisma 是下一代 对象关系映射器(ORM)。在编程的世界里,ORM 作为两种不同数据处理方式之间的桥梁:面向对象编程(OOP)和关系数据库。
Prisma,作为一个开源的 ORM,简化了 Node.js 应用程序中的数据库交互。它就像一套有用的工具,为你处理很多复杂的任务。好消息是,你不需要处理纯 SQL 查询。以下是它提供的内容:
-
Prisma 客户端:这个工具自动构建代码以访问你的数据库,使其安全且简单。它甚至在你编写代码时检查错误(如果你使用 TypeScript)。
-
Prisma Migrate:这个工具帮助你定义数据库的结构,并随着应用程序的变化保持其最新状态。
-
Prisma Studio:这是一个可视化工具,让你可以直接查看和编辑存储在数据库中的信息,就像一个用户友好的仪表板。
在底层,你可以使用 PostgreSQL、MySQL、SQL Server、SQLite、MongoDB 以及更多。当你的应用程序需要从 Prisma 支持的数据库迁移到另一个数据库时,它不会影响你的项目源代码,因为 Prisma 将你的代码从内部细节中抽象出来。
Prisma 客户端与多种构建 Node.js 应用程序的方式兼容:
-
传统的 REST API
-
现代 GraphQL API
-
高效的 gRPC API
-
任何需要使用数据库的 Node.js 项目
简而言之,Prisma 简化了 Node.js 中与数据库的交互,节省了你的时间和精力。它提供各种功能以满足你项目的需求。
现在,我们已经准备好开发我们的事务服务,从下一节开始,我们将深入了解开发过程的细节。
实践事务微服务开发
从技术书籍中学习的最佳方式是遵循其指示。对于所有实践章节,最好是跟随我们一起经历这个过程,并和我们一起输入每个命令。从 Git 仓库下载源代码并调查源代码也是实践中学习事物的好方法。
使用 NestJS 开始比 Express.js 更容易。它具有代码生成步骤和易于使用的包,帮助你快速开发。如果你在寻找一个更好的、现代的模板来开始,NestJS 是实现这一目标的一种方式。NestJS 提供了一个内置的命令行工具,称为 Nest CLI。它在你的 NestJS 应用程序的生命周期中充当一个强大的助手。它提供了以下有趣和有用的功能:
-
项目初始化:快速使用遵循最佳实践的目录布局设置新的 NestJS 项目。
-
开发支持:以开发模式运行你的应用程序以实现热重载和简化调试。
-
生产构建:将应用程序捆绑以部署到生产环境,优化其效率。
-
代码生成:使用脚本来生成各种组件,如控制器、服务、模块等,这样可以节省您的时间并确保一致性。
让我们开始开发过程:
-
为您的项目创建一个文件夹(在我们的 Git 仓库中是
Ch06
)。 -
打开 VS Code 并从其中打开您的文件夹。
-
前往 终端 菜单,然后选择 新建终端。
-
输入
npm i -g @nestjs/cli
并按 Enter 键。
安装 NestJS CLI 后,我们可以使用单个命令创建我们的项目模板。只需输入 nest new transactionservice
并再次按 Enter 键。
如果在尝试在 Windows 中运行脚本时遇到 Cannot Be Loaded Because Running Scripts is Disabled on This System
错误消息,请按照以下步骤解决:
-
打开 Windows PowerShell:按 Win + X 并选择 Windows PowerShell (管理员) 以具有管理员权限打开它。
-
设置执行策略:在 PowerShell 窗口中,输入以下命令并按 Enter 键:
Y (for Yes) and press Enter to confirm.
-
再次运行您的脚本:再次尝试运行您的脚本。问题现在应该已经解决。
在项目设置期间,您将得到一个提示,要求您选择包管理器(图 6.2)。我们有以下选项:
-
npm
-
Yarn
-
Pnpm
我们选择 npm
作为这个项目的包管理器。它在包管理器世界中是一个强有力的竞争者,尤其是对于 Node.js 项目。它拥有庞大的包注册库,是 Node.js 的默认选择,并且拥有庞大的社区(图 6.2):
图 6.2:NestJS 提供选择包管理器
在您做出选择后,CLI 将为我们生成一个项目模板(图 6.3):
图 6.3:NestJS 的 CLI 生成的文件夹结构
src
和 test
文件夹不为空,并包含初始项目骨架(图 6.4):
图 6.4:NestJS 的 CLI 生成的 src 和 test 文件夹
要成功运行生成的模板,请按照以下步骤操作:
-
使用
cd transactionservice
命令从终端导航到transactionservice
文件夹。对于所有类型的命令,我们都需要导航到这个文件夹才能正确运行它们。如果您不想每次都输入cd
命令,可以直接从 VS Code 中打开transactionservice
文件夹。 -
输入
npm run start:dev
。此命令启动一个特殊的服务器,它可以帮助您快速查看更改。它密切监视您的文件,如果它看到任何不同,它会自动修复并刷新服务器。这意味着您可以直接看到更新,而无需自己重新启动一切。
-
打开您喜欢的浏览器并导航到
http://localhost:3000
(图 6.5):
图 6.5:成功的 NestJS 项目运行结果
下一个子节将帮助我们了解如何准备我们的环境,并轻松构建我们的微服务。
Docker 化你的 PostgreSQL 实例
PostgreSQL 是在数据库中存储数据时最佳选择之一。我们将使用 Docker 来容器化我们的数据库,将其与其他环境隔离开来。
右键单击你的根项目文件夹(对于我们来说是 transactionservice
),并添加一个 docker-compose.yml
文件。
打开这个空文件并添加以下行:
networks:
my-app-network: # Define the network name exactly as used later
services:
postgres:
image: postgres
env_file:
- .env
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
ports:
- ${POSTGRES_PORT}:5432
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- my-app-network # Add the service to the network
pgadmin:
image: dpage/pgadmin4
env_file:
- .env
environment:
- PGADMIN_DEFAULT_EMAIL=${PGADMIN_DEFAULT_EMAIL}
- PGADMIN_DEFAULT_PASSWORD=${PGADMIN_DEFAULT_PASSWORD}
- POSTGRES_HOST=postgreshost
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
- PGADMIN_CONFIG_SERVER_MODE=False
- PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED=False
ports:
- ${PGADMIN_PORT}:80
depends_on:
- postgres
user: root
volumes:
- postgres_data:/var/lib/pgadmin/data
networks:
- my-app-network # Add the service to the network
volumes:
postgres_data:
当我们讨论安装 Apache Kafka 时,我们使用了 docker-compose.yml
在 第四章。我们有一个关于容器化的单独章节,但让我们在这里解释这个文件本身,以便更清晰。
docker-compose.yml
文件是一个 YAML 配置文件,用于定义和管理多容器 Docker 应用程序。Docker Compose 是一个工具,允许你在一个文件中定义应用程序所需的服务、网络和卷,这使得管理复杂的设置更加容易。
每个服务代表一个容器化的应用程序组件。
你可以为你的服务定义自定义网络,以便它们之间进行通信。默认情况下,Docker Compose 为你的应用程序创建一个默认网络,但你也可以定义自定义网络来控制特定服务之间的通信。
你也可以定义命名卷或将主机目录挂载到容器中,以持久化数据或在不同容器之间共享文件。
Docker Compose 允许你通过单个命令启动所有服务,而不是逐个运行服务,docker-compose
帮助你通过单个命令 (docker-compose up
) 启动整个基础设施,并在不同的环境中一致地管理它。
这个 docker-compose.yml
文件定义了一个 Docker Compose 配置,用于设置两个服务:postgres
和 pgadmin
。让我们来分解一下:
-
networks
:在你的 Docker Compose YAML 文件中的此部分定义了命名网络,这些网络可以被应用程序的服务使用。这些网络提供了一种容器之间以受控和隔离的方式相互通信的方法。 -
services
:此部分定义要创建的服务。-
postgres
:此服务使用官方的 PostgreSQL Docker 镜像。它设置了一个 PostgreSQL 数据库容器。 -
image: postgres
:指定用于此服务要使用的 Docker 镜像。 -
env_file
:指定一个文件,从中读取环境变量。 -
environment
:为 PostgreSQL 容器设置环境变量,包括用户名、密码和数据库名。 -
ports
:将容器的 PostgreSQL 端口映射到主机上的端口,允许外部访问。 -
volumes
:挂载一个卷以持久化 PostgreSQL 数据。
-
-
pgadmin
:此服务使用pgAdmin
4 Docker 镜像来为 PostgreSQL 设置基于 Web 的管理界面。-
image: dpage/pgadmin4
:指定pgAdmin
4 的 Docker 镜像。 -
env_file
:类似于postgres
服务,这指定了一个用于读取环境变量的文件。 -
environment
:为pgAdmin
设置环境变量,包括默认电子邮件、密码和 PostgreSQL 连接详情。 -
ports
:将容器的端口80
映射到主机上的一个端口。 -
depends_on
:指定此服务依赖于postgres
服务,确保在启动pgAdmin
之前 PostgreSQL 数据库可用。 -
user: root
:指定容器应以 root 用户运行。 -
volumes
:将卷挂载以持久化pgAdmin
数据。
-
-
volumes
:此部分定义了一个名为postgres_data
的命名卷,它被两个服务用于持久化数据。
最后,这个 Docker Compose 配置设置了一个 PostgreSQL 数据库容器和一个 pgAdmin
容器,提供了一个方便的方式来使用基于 Web 的界面管理和交互 PostgreSQL 数据库。要运行你的 docker-compose
文件,只需导航到该文件的文件夹,并在终端中输入 docker-compose up -d
。
我们不是直接在 docker-compose
文件中添加凭据/值,而是可以从一个 .env
文件中指定(我们之前已经讨论过这个文件),Docker 可以从环境变量中读取所需的数据。只需在你的主文件夹内(对我们来说就是 transactionservice
文件夹)创建一个 .env
文件,并添加 Docker 运行成功所需的缺失配置:
# PostgreSQL settings
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=tservice_db
POSTGRES_PORT=5438
# pgAdmin settings
PGADMIN_DEFAULT_EMAIL=admin@tservice.com
PGADMIN_DEFAULT_PASSWORD=tservice_password
PGADMIN_PORT=5050
现在我们已经启动并运行了我们的 PostgreSQL 数据库。在大多数情况下,开发者更喜欢不直接使用 SQL 查询与数据库交互。并非所有开发者都对 SQL 有坚实的理解,即使如此,在大多数情况下,使用纯 SQL 查询来操作数据库也不是一个好的选择。相反,我们有各种可用的包,它们抽象了原始 SQL 的复杂性,使我们能够在不需要深入 SQL 知识的情况下创建美观的应用程序。其中之一就是 Prisma。正如我们之前提到的,Prisma 是一个开源的 ORM,它自动化并抽象了你处理数据库时需要执行的大多数操作。
要开始使用 Prisma 的工作,我们需要一个 CLI。Prisma CLI 是一系列工具的组合,帮助我们轻松地进行迁移、种子和执行其他数据库相关操作。你只需从终端运行 npm install prisma -D
命令。执行命令后,npm
应该成功地将 Prisma CLI 作为开发依赖项安装。你可以在 package.json
的 devDependencies
部分进行检查。
在 Prisma CLI 之后,现在是时候安装 Prisma 本身了。npx prisma init
命令处理 Prisma 包的初始化。它将创建一个名为 prisma
的额外文件夹,其中包含一个 schema.prisma
文件和一个 .env
文件。在我们的情况下,我们已经有了一个 .env
文件,所以运行前面的命令将更新我们现有的 .env
文件。打开你的 .env
文件,并在文件末尾更新 DATABASE_URL
的值:
DATABASE_URL="postgres://postgres:postgres@localhost:5438/tservice_db"
在你的 Prisma 设置的核心是 schema.prisma
文件。这个文件使用 Prisma 模式语言(PSL),这是一种定义数据库结构的声明式方法。它作为 Prisma 的中心配置,指定了数据库连接和 Prisma 客户端 API 的生成。以下代码演示了如何为 Prisma 定义一个简单的模式文件:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
用 PSL 编写的 schema.prisma
文件充当你的数据库蓝图,有三个关键部分:
-
generator
: 这个部分配置了 Prisma 客户端生成器。然后生成强大的 API Prisma 客户端,以帮助你访问数据库。 -
datasource
: 在这里,你定义数据库连接的详细信息。这包括数据库提供者和连接字符串,通常利用DATABASE_URL
环境变量以提高便利性。 -
Model:
这里是数据库模式的核心所在。你通过指定表及其对应的字段来定义你的数据结构。
下一个部分描述了如何在 schema.prisma
文件中建模你的数据。
数据建模
schema.prisma
文件是我们需要添加模型的主要地方。建模是一种特殊的语言,在 SQL 之上。它将你与 SQL 的内部细节隔离开来,并以更易于阅读的语言提供数据。
打开 prisma
文件夹下的 schema.prisma
文件,并添加以下模型结构:
model Transaction {
id Int @id @default(autoincrement())
status Status
accountId String @default(uuid())
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum Status {
CREATED
SETTLED
FAILED
}
提供的代码定义了一个名为 Transaction
的 Prisma 模型以及在你 NestJS 应用程序模式中的一个名为 Status
的枚举。
下面是每个部分的分解:
-
id
: 这个字段代表每个交易的唯一标识符。它的类型是Int
,并自动标记为使用@id
指令的主键。此外,@default(autoincrement())
确保每个交易自动生成一个新且唯一的 ID。 -
status
: 这个字段定义了交易的当前状态。它的类型是Status
,将引用Status
枚举(Enumeration(enum Status)
)。 -
accountId
: 这个字段存储参与交易的关联账户的标识符。它的类型是String
,并使用@default(uuid())
默认生成一个 全局唯一标识符(UUID)。 -
description
: 这个可选字段允许存储交易的简要描述。它的类型是String?
,表示它可以null
。 -
createdAt
: 这个字段捕获了交易创建的时间戳。它的类型是DateTime
,并使用@default(now())
自动将创建时间设置为当前时刻。 -
updatedAt
: 这个字段在交易记录被修改时自动更新。它的类型是DateTime
,并使用@updatedAt
指令来实现这种行为。
好的——但是如何根据我们在 schema.prisma
中定义的模式生成 SQL 呢?
从命令行(VS Code 终端)运行 npx prisma migrate dev --name init
以开始迁移之旅。在 NestJS 和 Prisma 的上下文中,迁移指的是管理数据库模式随时间变化的过程。
这里是命令的分解:
-
npx prisma migrate dev
:此命令以开发模式调用 Prisma 迁移工具。 -
--name init
:此选项指定新迁移的名称。在这里,它设置为init
,可能表示数据库模式的初始设置。
通过运行此命令,你实际上是在创建一个起点,用于使用 Prisma 迁移管理你的数据库模式变化。随着你对 schema.prisma
文件进行修改,Prisma 将自动生成新的迁移来反映这些更改。
命令最终会在 migrations
文件夹中创建一个 migration.sql
文件(图 6**.6):
图 6.6:自动生成的迁移结构
检查生成的 migration.sql
文件:
-- CreateEnum
CREATE TYPE "Status" AS ENUM ('CREATED', 'SETTLED', 'FAILED');
-- CreateTable
CREATE TABLE "Transaction" (
"id" SERIAL NOT NULL,
"status" "Status" NOT NULL,
"accountId" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Transaction_pkey" PRIMARY KEY ("id")
);
现在,你应该在你的数据库中有名为 Transaction
和 _prisma_migrations
的表格。Docker 设置服务可能需要几秒钟,所以可能需要稍等片刻。为了检查这一点,让我们执行以下操作:
-
打开 Docker Desktop 并确保所有服务都在运行。
-
点击
postgres
容器。 -
(从
172.26.0.2
): -
从浏览器导航到
http://localhost:5050/browser/
。 -
右键点击 服务器,然后从菜单中选择 注册 | 服务器。
-
在
localhost
下检查其值。 -
前往
172.26.0.2
(为我们操作) -
5432
-
tservice_db
-
postgres
-
postgres
图 6.7:postgres 服务器注册窗口
-
点击 保存 按钮,你的服务器连接应该成功。
-
现在,展开
localhost
(或你的名称)|tservice_db
| 模式 | 公共 | 表格(图 6**.8):
图 6.8:通过 Prisma 迁移后的 postgres 表格
-
如果你已经在本地上安装了
pgAdmin
,要连接到你的 Dockerpostgres
实例,只需将以下内容输入到postgres
的服务器注册窗口(图 6**.7):-
localhost
-
5438
-
tservice_db
-
postgres
-
postgres
-
-
点击 保存 按钮,你的服务器连接应该成功。
当使用 NestJS 和 Prisma ORM 时,你看到的 _prisma_migrations
表在管理数据库模式变化中起着至关重要的作用。它有以下职责:
-
跟踪应用的数据库迁移
-
确保迁移只应用一次
-
保持 Prisma 模式和实际数据库结构之间的一致性
每次你运行 Prisma 迁移时,都会在 _prisma_migrations
表中添加一个新条目。
当 Prisma 需要应用迁移时,它会检查 _prisma_migrations
表以查看基于唯一哈希哪些迁移已经被运行。
这防止了多次应用相同的迁移,从而可能损坏您的数据。
手动修改 _prisma_migrations
表可能会导致不一致性和错误。不要编辑、删除或修改它。此表对于 Prisma 有效地管理迁移至关重要。
简而言之,_prisma_migrations
表充当您数据库模式更改的日志簿,确保迁移过程平稳且受控。
种子测试数据
种子数据涉及将一组初始数据填充到您的数据库中。如果您希望在运行应用程序之前数据库已有初始数据,您可以使用种子操作。
在 prisma
文件夹下添加一个 seed.ts
文件,并包含以下内容:
// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
// initialize Prisma Client
const prismaClient = new PrismaClient();
async function seedData() {
// create two dummy recipes
const first_transaction = await
prismaClient.transaction.upsert({
where: { id:1 },
update: {},
create: {
id:1,
status: 'CREATED',
accountId: '662c081370bd2ba6b5f04e94',
description: 'simple transaction',
}
});
console.log(first_transaction);
}
// execute the seed function
seedData()
.catch(e => {
console.error(e);
process.exit(1);
})
.finally(async () => {
// close Prisma Client at the end
await prismaClient.$disconnect();
});
前往 package.json
并在 devDependencies
之后添加以下内容:
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
现在,打开终端窗口并输入 npx prisma db seed
命令。您应该会看到一个表示操作成功的消息(图 6.9):
图 6.9:执行种子操作
使用 PgAdmin
打开 Transaction
表,您将看到您成功插入的第一行数据(图 6.10):
图 6.10:种子操作后的交易表
是时候解释我们已插入 seed.ts
文件中的内容了:
-
PrismaClient from @prisma/client
: 这行代码导入必要的类,以便使用 Prisma 与我们的数据库模式进行交互。 -
const prismaClient = new PrismaClient()
: 在这里,我们创建PrismaClient
类的实例,该实例将用于执行数据库操作。 -
async function seedData() { ... }
: 这个函数是脚本的灵魂,被标记为async
,因为它包含涉及与数据库交互的异步操作。 -
const first_transaction = await prismaClient.transaction.upsert({ ... })
: 这行代码执行核心的种子操作。 -
prismaClient.transaction
: 这部分通过初始化客户端访问 Prisma 模式中的事务模型。 -
.upsert({ ... }):
upsert
方法是一种方便的方式,用于在数据库中创建或更新记录。它根据提供的where
子句检查现有数据,并执行相应的操作。 -
如果已存在具有
id: 1
(假设您的模式具有 ID 字段)的记录,则会发生以下情况:-
update
对象(此处为空)将用于更新具有id:
1
的现有记录(但由于它是空的,因此不会发生更新)。 -
create
对象定义了如果不存在具有 ID 的记录时新事务记录的数据。-
create Object (Seed Data)
: 此对象定义了要创建的虚拟事务的详细信息。 -
id: 1
: 将事务的 ID 设置为 1(如果需要,请替换为唯一值)。 -
status: 'CREATED'
:将事务的初始状态设置为CREATED
。 -
accountId: '662c081370bd2ba6b5f04e94'
:将账户 ID 分配给事务(你可以使用任何 ID)。 -
description: 'simple transaction'
:为事务提供描述性文本。
-
-
要将业务规则应用到我们的应用程序中,我们需要在数据库之上添加一个额外的层,这将是我们的服务层。下一节将介绍用于事务数据库的服务层。
实现事务服务
我们已经完成了数据库的工作。作为一个经典的开发风格,现在是时候在我们的数据库上创建一个服务了。使用 NestJS 创建服务层很简单,尤其是如果你处理 Prisma ORM 的话。
首先,让我们使用npx nest generate module
prisma
命令创建我们的模块。
这个 Prisma CLI 命令应该会生成一个名为prisma
的新文件夹,并在该文件夹下生成一个prisma.module.ts
文件。此命令还将影响src
文件夹下的app.module.js
文件。
我们还需要运行一个额外的命令来生成我们的服务文件:
Npx nest generate service prisma
此命令将在src/prisma
下创建prisma.service.ts
、prisma.service.spec.ts
文件,并更新prisma.module.ts
文件。
对于本章,你可以从项目中删除所有具有.spec.ts
扩展名的文件。这些文件包含应用程序组件的单元测试,通常是服务和控制器。我们有一个单独的章节来处理单元测试;为了章节的简洁性,我们不需要它们。现在,将prisma.service.ts
的内容替换为以下内容:
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient {}
在这段代码中,我们对prisma
服务有一个直接的实现:
-
import { Injectable } from '@nestjs/common';
:这一行从@nestjs/common
模块中导入了Injectable
装饰器。这个装饰器将类标记为 NestJS 可注入的服务,使其在应用程序的其他部分可用,以便进行依赖注入(DI)。 -
import { PrismaClient } from '@prisma/client';
:这一行从@prisma/client
包中导入了PrismaClient
类。这个类提供了一个接口,用于使用 Prisma 查询与数据库进行交互。 -
@Injectable()
: 这个应用于类声明的装饰器将类标记为 NestJS 可注入的服务。NestJS 将管理这个服务的生命周期,并将其提供给需要数据库访问的其他组件。 -
export class PrismaService extends PrismaClient {}
: 这一行定义了PrismaService
类。它从PrismaClient
类继承,从而获得了 Prisma 提供的一切数据库交互方法。
从本质上讲,这段代码创建了一个专门用于通过 Prisma 与我们的数据库交互的服务。然后,这个服务可以被注入到我们应用程序的其他部分(如控制器)中,以执行数据库操作。
prisma.service.ts
文件作为 Prisma 客户端的包装器。它是一个可注入的元素,我们可以将其注入到模块中。
让我们更新我们的prisma.module.ts
文件以包含以下内容:
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Module({
providers: [PrismaService],
exports: [PrismaService]
})
export class PrismaModule {}
要使 Prisma 服务在您的 NestJS 应用程序中可用,您需要创建一个专门的模块。此模块将导入PrismaService
类并将其提供给其他模块或组件注入。这就是为什么我们有prisma.module.ts
文件的原因。
现在,使用 UI 来处理 API 很流行,它允许我们记录和轻松使用端点。允许我们这样做的一个包是 Swagger。下一节将解释如何为我们的端点集成 Swagger。
配置 Swagger
为了使我们的 API 具有可见的文档和可视化的使用方式,我们将配置 Swagger UI。
打开 VS Code 终端并输入以下内容:
npm install --save @nestjs/swagger swagger-ui-express
打开src/main.ts
并更新其内容以集成 Swagger:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
// bootstrap function
async function bootstrap() {
// Create a NestJS application instance
const app = await NestFactory.create(AppModule);
// new Swagger document configuration
const config = new DocumentBuilder()
.setTitle('Transaction API') // title of the API
.setDescription('Transaction API description')
// description of the API
.setVersion('1.0') // version of the API
.build(); // Build the document
// Create a Swagger document
const document = SwaggerModule.createDocument(app,
config);
// Setup Swagger module
SwaggerModule.setup('api', app, document);
// Start the application and listen for requests on port 3000
await app.listen(3000);
}
// Call the bootstrap function to start the application
bootstrap();
让我们理解这里的代码:
-
NestFactory
来自@nestjs/core
: 这个导入提供了创建 NestJS 应用程序实例的核心功能。 -
AppModule
来自./app.module
: 这将导入您的主应用程序模块,其中定义了您的 NestJS 应用程序的所有必要组件和服务。 -
SwaggerModule
和DocumentBuilder
来自@nestjs/swagger
: 这些导入用于将 Swagger 文档与您的 NestJS 应用程序集成。 -
Bootstrap 函数(
async
):-
这个函数被标记为
async
,因为它涉及异步操作,例如创建应用程序实例和监听传入的请求。 -
它作为您的 NestJS 应用程序的入口点,通常在
main.ts
文件的底部调用。
-
-
const app = await NestFactory.create(AppModule);
: 这行代码使用AppModule
类创建一个新的 NestJS 应用程序实例。await
关键字表示该函数将在应用程序创建完成后才继续执行。 -
Swagger 配置:
-
const config = new DocumentBuilder()...
: 这里,您正在使用DocumentBuilder
类配置 Swagger 文档。 -
.setTitle('Transaction API')
: 将您的 API 文档的标题设置为Transaction API
。 -
.setDescription('Transaction API description')
: 提供了您 API 的简要描述。 -
.setVersion('1.0')
: 将您的 API 版本设置为1.0
。 -
.build()
: 根据提供的配置选项构建 Swagger 文档。
-
-
const document = SwaggerModule.createDocument(app, config);
: 这行代码使用SwaggerModule
类生成实际的 Swagger 文档。它将 NestJS 应用程序实例(app
)和构建的配置(config
)作为参数。 -
SwaggerModule.setup('api', app, document);
: 这段代码将 Swagger 文档与您的应用程序集成。它将文档的路径前缀设置为api
(例如,http://localhost:3000/api
),并将生成的文档(document
)与应用程序(app
)关联。这使得开发人员可以在指定的 URL 访问交互式 Swagger 文档。 -
await app.listen(3000);
:此行启动 NestJS 应用程序并使其在端口3000
上监听传入的请求。您可以将此端口号更改为您想要的选项。
总体而言,此main.ts
文件执行两个关键任务:
-
AppModule
类和启动服务器监听请求 -
集成 Swagger 文档:它为您的 API 配置并提供 Swagger 文档,允许开发者通过交互式界面探索您的 API 端点、理解数据模型并与您的 API 交互
导航到localhost:3000/api
,您应该能看到 Swagger 页面(图 6.11):
图 6.11:Swagger UI
如您将意识到的那样,我们还没有任何端点;下一节将讨论创建它们。
在事务实现上工作
要开始处理事务,首先,我们需要生成资源。为了实现事务的创建、读取、更新和删除(CRUD)操作,我们首先将生成 REST 资源,为模块、控制器、服务和数据传输对象(DTO)创建样板代码。
运行npx nest generate resource transaction
命令以生成事务的资源:
图 6.12:选择传输层
它将询问您要选择哪个传输层(图 6.12)。选择REST API
并按Enter键。接下来,您将被询问您想要生成 CRUD 入口点吗?
选择Y
,然后应该会生成以下文件(图 6.13):
图 6.13:事务的 CRUD 生成
运行npm run start:dev
并导航到localhost:3000/api
。您应该会看到一个存储事务样板端点的页面(图 6.14):
图 6.14:事务的 Swagger UI
当然,我们不需要实现所有这些 CRUD 端点。我们需要以下函数:
-
获取所有事务(
GET /transaction
) -
通过 ID 获取事务(
GET /transaction/{id}
) -
创建事务(
POST /transaction
)
让我们移除其余未使用的代码块和文件。
移除以下文件:
-
transaction/transaction.controller.spec.ts
-
transaction/dto/update-transaction.dto.ts
-
transaction/transaction.service.spec.ts
移除以下代码块:
-
transaction.service.ts
中的remove
和update
函数 -
transaction.controller.ts
中的remove
和update
函数
如果您还没有移除它们,请也移除以下文件:
-
app.controller.ts
-
app.module.ts
-
app.service.ts
将main.ts
更新为与TransactionModule
一起工作,而不是AppModule
:
import { NestFactory } from '@nestjs/core';
import { TransactionModule } from
'./transaction/transaction.module';
import { SwaggerModule, DocumentBuilder } from
'@nestjs/swagger';
// bootstrap function
async function bootstrap() {
// Create a NestJS application instance
const app = await NestFactory.create(TransactionModule);
……..
最终,您将拥有三个端点(图 6.15):
图 6.15:最终事务端点
在生成 REST 资源后,我们准备好集成我们的 PrismaClient
类。拥有这个客户端将帮助我们轻松地与数据库交互。首先,让我们更新我们的 transaction.module.ts
文件以包含 PrismaModule
:
import { Module } from '@nestjs/common';
import { TransactionService } from './transaction.service';
import { TransactionController } from
'./transaction.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [TransactionController],
providers: [TransactionService],
})
export class TransactionModule {}
在 imports
数组中包含 PrismaModule
将使 PrismaService
可用于 TransactionService
。
现在,打开 transaction.service.ts
文件并做出以下更改:
import { Injectable } from '@nestjs/common';
import { CreateTransactionDto } from
'./dto/create-transaction.dto';
import { PrismaService } from 'src/prisma/prisma.service';
@Injectable()
export class TransactionService {
constructor(private readonly prisma: PrismaService) {}
…….
交易控制器文件 (transaction.controller.ts
) 已经将 transactionservice
作为注入的服务。它具有请求交易服务并检索数据所需的所有合约。
打开 transaction.controller.ts
并查看 findAll()
方法:
@Get()
findAll() {
return this.transactionService.findAll();
}
同样适用于 POST
和单个 GET
请求。我们唯一需要做的是在调用 transactionservice
的 findAll()
方法时调用 Prisma 以提供所有数据。因此,打开 transaction.service.ts
并更新 findAll()
方法的内容 (图 6.14):
findAll() {
return this.prisma.transaction.findMany();
}
使用 findMany()
,我们能够通过 Prisma 从交易表调用所有交易数据。让我们运行我们的应用程序(运行 npm run start:dev
)并从 Swagger UI 运行我们的端点。
从 Swagger UI 中打开 GET /transaction
,点击 尝试操作 按钮,然后点击 执行 按钮。现在,你应该只看到我们在讨论播种数据时迁移到我们数据库中的数据 (图 6.16):
图 6.16:获取所有交易的响应
为了修改你的通过 ID 的 GET
请求端点使其正常工作,打开 transaction.service.ts
并将 findOne()
方法替换为以下内容:
findOne(id: number) {
return this.prisma.transaction.findUnique({
where: { id },
});
}
当涉及到检索数据时,一切都非常简单,但创建数据又是如何呢?
我们在 transaction.controller.ts
中有一个 POST
端点,这是在我们生成文件本身时自动生成的:
@Post()
create(@Body() createTransactionDto:
CreateTransactionDto) {
return
this.transactionService.create(createTransactionDto);
}
当我们的资源被创建时,CreateTransactionDTO
也被生成;你可以在 src/transaction/dto
文件夹中找到它。令人惊讶的是,它只有一个类声明:
export class CreateTransactionDto {}
你应该手动将所需的属性添加到类中。我们的 DTOs 只是从源到目的地传输数据。DTOs 在各种编程语言和框架中使用,并不特定于 NestJS。它们作为在不同应用层之间高效传输数据的方式。我们还在从用户获取数据并基于此数据创建 DTOs 之前有验证的可能性。这就是为什么我们将使用 class-validator
包来验证我们的数据。要从终端安装它,请运行以下命令:
npm install class-validator
打开 create-transaction.dto.ts
并添加以下内容:
import { IsString, IsOptional, IsEnum, IsNotEmpty,IsUUID }
from 'class-validator';
enum Status {
CREATED = 'CREATED',
SETTLED= 'SETTLED',
FAILED = 'FAILED',
}
export class CreateTransactionDto {
@IsNotEmpty()
@IsEnum(Status)
status: Status;
@IsUUID()
@IsNotEmpty()
accountID: string;
@IsOptional()
@IsString()
description?: string;
}
更新你的 POST
方法 (transaction.controller.ts
) 以接受 CreateTransactionDTO
并执行它:
@Post()
create(@Body() createTransactionDto:
CreateTransactionDto) {
return
this.transactionService.create(createTransactionDto);
}
现在,让我们运行我们的应用程序。从 Swagger UI 中,打开 POST/ transaction
并提供以下 JSON 负载:
{
"status": "CREATED",
"accountId": "662c081370bd2ba6b5f04e94",
"description": "Optional transaction description"
}
点击 执行 按钮,现在我们在这里 (图 6.17):
图 6.17:成功创建交易
从下一节开始,我们将探讨如何建立交易和账户微服务之间的通信。
建立与账户微服务的同步通信
我们已经完成了交易服务,但唯一缺少的是我们的账户服务。交易服务允许我们从有效载荷中指定accountId
值和状态。我们需要进行以下更改:
-
验证提供的
accountId
是否存在且处于有效状态(新或活动状态) -
如果
accountId
有效,则创建一个状态为Created
的交易 -
如果
accountId
无效,则创建一个状态为Failed
的交易
这里的目的不是完全实现交易域。当然,当前的域比之前的域有更多的要求,但我们的重点是练习并建立交易和账户服务之间的同步通信。
我们已经讨论了微服务之间同步通信的优缺点。虽然异步通信为微服务提供了许多好处,但在某些情况下,同步通信可能更适合。如果微服务之间的交互需要直接的逻辑和即时响应,同步通信可能更容易实现。此外,对于需要立即显示信息或确认操作的用例,使用同步通信是有益的。它创建了紧密耦合,但在某些情况下,微服务可能紧密耦合,并且高度依赖于彼此的结果来完成任务。同步通信允许更受控的流程,确保一个服务在另一个服务提供必要信息之前不继续进行。在某些时候,同步通信可能更容易调试。由于整个交互是一次性发生的,因此跟踪错误和理解数据流更为直接。这有助于开发或解决特定问题。
为了与账户服务通信,我们需要一个 HTTP 客户端包。最常用的包之一是axios
。让我们使用npm
来安装它:
npm i --save @nestjs/axios axios
现在,我们需要从axios
导入HttpModule
,并从transaction.module.ts
中导入它。以下是该文件中的最终代码:
import { Module } from '@nestjs/common';
import { TransactionService } from './transaction.service';
import { TransactionController } from
'./transaction.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { HttpModule } from '@nestjs/axios';
@Module({
imports: [PrismaModule,HttpModule],
controllers: [TransactionController],
providers: [TransactionService],
})
export class TransactionModule {}
在从transaction.module.ts
导入HttpModule
之后,我们能够在transaction.service.ts
中使用HttpService
从axios
。让我们将其导入并注入为交易服务的一个服务。打开transaction.service.ts
并将代码修改为以下行:
import { Injectable } from '@nestjs/common';
import { CreateTransactionDto } from
'./dto/create-transaction.dto';
import { PrismaService } from 'src/prisma/prisma.service';
import { HttpService } from '@nestjs/axios';
import { AccountApiResponse } from './dto/account.dto';
@Injectable()
export class TransactionService {
constructor(
private readonly prisma: PrismaService,
private readonly httpService: HttpService) {}
//the rest of the code
根据我们的业务需求,我们需要从 createTransactionDto
中移除状态属性,因为根据账户服务,我们应该在内部定义交易的状态。这就是为什么我们要从 src/transaction/dto/create-transaction.dto
中移除状态。以下是该文件的最终版本:
import { IsString, IsOptional, IsNotEmpty,IsUUID } from
'class-validator';
export class CreateTransactionDto {
@IsUUID()
@IsNotEmpty()
accountId: string;
@IsOptional()
@IsString()
description?: string;
}
太好了!现在,让我们再次打开 transaction.service.ts
文件并更改我们的 创建 功能。在注入 httpService
之后,我们应该能够向任何服务发送请求并获取响应。
这是我们的计划:
-
向账户服务的
http://url/v1/accounts/{account_id}
端点发送请求,并根据端点提供的 ID 获取账户信息 -
如果通过
accountId
给定的账户不存在,我们将抛出异常 -
如果账户存在,并且其状态是
'new'
或'active'
,则交易应该以'CREATED'
状态创建 -
如果账户存在,并且其状态既不是
'new'
也不是'active'
,我们应该创建一个状态为'FAILED'
的交易
就这么简单。打开 transaction.service.ts
并使用以下代码行进行更新:
import { Injectable } from '@nestjs/common';
import { CreateTransactionDto } from
'./dto/create-transaction.dto';
import { PrismaService } from 'src/prisma/prisma.service';
import { HttpService } from '@nestjs/axios';
import { AccountApiResponse } from './dto/account.dto';
@Injectable()
export class TransactionService {
constructor(
private readonly prisma: PrismaService,
private readonly httpService: HttpService,
) {}
async create(createTransactionDto: CreateTransactionDto) {
const { accountId, description } = createTransactionDto;
let accountApiResponse = await
this.httpService.axiosRef.get<AccountApiResponse>(
`http://localhost:3001/v1/accounts/${createTransactionDto.accountId}`,
);
const {account} = accountApiResponse.data;
if (!account) {
throw new Error('Transaction creation failed: Account
not found');
}
if(account.status == 'new' || account.status ==
'active')
{
return this.prisma.transaction.create({
data: { accountId, description, status:
'CREATED' },
});
}
else
{
return this.prisma.transaction.create({
data: { accountId, description, status:
'FAILED' },
});
}
}
//rest of the functionalities
}
我们最后需要做的是在账户服务中配置 跨源资源共享(CORS)。按照以下步骤操作:
-
从 VS Code 中打开您的账户服务。
-
从菜单中选择 终端 | 新建终端。
-
导航到
src
文件夹。 -
执行
npm install cors
以安装cors
包。 -
打开
app.js
并在创建app
对象之后添加以下代码:const corsOptions = { origin: 'http://localhost:3001', //(https://your-client-app.com) optionsSuccessStatus: 200, }; app.use(cors(corsOptions));
app.js
中的最终代码应如下所示:
const express = require('express');
const v1 = require('./routes/v1');
const cors = require('cors');
const app = express();
//added while implementing transaction service, for Ch06
const corsOptions = {
origin: 'http://localhost:3001', //(https://your-client-app.com)
optionsSuccessStatus: 200,
};
app.use(cors(corsOptions));
// service
app.use(express.json());
// V1 API
app.use('/v1', v1);
module.exports = app;
在运行我们的应用程序之前,请确保以下内容:
-
您的账户服务位于
localhost:3001
-
账户服务在您的表中至少有一条有效的账户信息
-
确保 Docker 正在运行并且
postgres
容器处于活动状态
前往我们的账户服务并运行它(更多详情,请参阅第五章)。使用 npm run start:dev
运行我们新创建的交易服务。打开您喜欢的浏览器,导航到 http://localhost:3000/api
。打开 POST /transaction
并添加以下有效载荷:
{
"accountId": "663fd142ecbdce73baf1ed1a",
"description": "Optional transaction description"
}
您从有效载荷中提供的 accountId
值应在账户服务中存在,以便成功操作。在成功请求的情况下,您将收到以下响应:
{
"id": {your_transaction_id},
"status": "CREATED",
"accountId": "663fd142ecbdce73baf1ed1a",
"description": "Optional transaction description",
"createdAt": "2024-05-12T17:21:38.727Z",
"updatedAt": "2024-05-12T17:21:38.727Z"
}
如果服务不可用且账户不存在,您将收到错误。我们没有在本章中涵盖异常处理,但我们将在我们即将到来的章节中学习它。
摘要
另一次微服务之旅在这里结束。本章的主要内容是创建第二个微服务并在微服务之间建立同步通信。我们以业务需求开始本章。在明确了解我们应该做什么之后,我们开始介绍用于开发事务微服务的主要工具栈。我们没有使用之前开发账户服务时使用的工具。当涉及到开发微服务时,JavaScript 确实拥有丰富的工具和框架。为了展示拥有多个工具的美丽之处,我们使用了 NestJS 以及一些流行的包,如 Prisma 和 Axios。最后,我们使用同步通信模型与一个已经存在的微服务(账户微服务)建立了通信。当然,我们仍然遗漏了很多。我们没有涵盖异常处理、弹性以及我们计划在后续章节中介绍的其他许多有趣话题。
第七章 探讨了如何使用 Apache Kafka 和 NestJS 在 JavaScript 微服务中实现异步通信,重点关注构建可扩展的系统、配置 Kafka 以及为事务和账户等服务适配异步消息。
第七章:异步微服务
微服务被设计成独立和自包含的。明确定义的通信协议和 API 确保这些服务在没有依赖彼此内部工作的情况下进行交互。定义微服务之间的适当通信对于良好的微服务架构至关重要。
在本章中,我们计划讨论和学习另一个重要的通信机制:微服务之间的异步通信。
本章涵盖了以下主题:
-
理解需求
-
探索异步通信
-
实现异步事务微服务
-
适应账户服务的新需求
-
测试我们的微服务
让我们深入探讨!
技术要求
为了跟随本章内容,您需要一个 IDE(我们更喜欢 Visual Studio Code)、Postman、Docker 以及您选择的浏览器。
建议您从github.com/PacktPublishing/Hands-on-Microservices-with-JavaScript
下载仓库,并打开Ch07
文件夹,以便轻松地跟随代码片段。
理解需求
到目前为止,我们已经开发了两个简单的微服务,对于当前章节,我们计划扩展我们的事务微服务以满足以下需求:
-
每个事务都应该支持以下状态:
CREATED
、FAILED
、APPROVED
、DECLINED
和FRAUD
。 -
事务服务现在应该有一个新的方法,将给定事务的状态更改为
FRAUD
。它将更新事务的状态为FRAUD
并产生关于事务的消息。 -
账户服务将消费此消息,并在三次欺诈尝试后,账户服务应读取并暂停/阻止指定的账户。
我们计划在微服务之间使用异步通信,任何其他微服务都可能使用此消息进行内部目的。您可以查看第二章以获取有关微服务之间异步通信的更多信息。
探索异步通信
您可以使用各种模式和技术在微服务之间实现异步通信,每种都适合不同的用例和需求。以下是一些常见的例子:
-
消息代理:消息代理通过允许微服务发布和订阅消息来促进异步通信。流行的消息代理包括RabbitMQ,它支持多种消息协议和模式,如发布/订阅和路由,以及为高吞吐量和容错性事件流设计的Apache Kafka——这是实时数据处理的最佳选择之一。一个消息代理的例子是一个生产服务向队列或主题发送消息,而消费者服务订阅队列或主题并处理消息。
-
事件流平台: 事件流平台捕获和处理事件流。这些平台对于实时分析和数据管道构建特别有用。流行的事件流平台包括常被用作消息代理和事件流平台的Apache Kafka,以及用于大规模实时数据处理的托管服务Amazon Kinesis。以下是一个例子:生产服务向 Kafka 主题发出事件,消费者服务从主题中消费事件并对它们做出反应。
-
发布-订阅模式: 在发布/订阅模式中,消息被发布到一个主题,多个订阅者可以异步地消费这些消息。使用发布/订阅模式的流行服务包括完全托管的实时消息服务Google Pub/Sub,以及允许向多个订阅者发布消息的AWS Simple Notification Service(SNS)。例如,发布者服务将事件发布到主题,而订阅者服务接收通知并处理事件。
-
任务队列: 任务队列用于异步地将任务分配给工作服务。这对于从主服务中卸载重或耗时的任务非常有用。一些流行的任务队列包括基于分布式消息传递的异步任务队列/作业队列Celery,以及完全托管的Amazon Simple Queue Service(SQS),这是一个完全托管的消息队列服务。以下是任务队列的工作原理:生产服务创建一个任务并将其放入队列,然后工作服务从队列中提取任务并处理它。
-
事件驱动架构: 在事件驱动架构中,服务通过事件进行通信。当一个服务发生显著事件时,它会发出一个其他服务可以监听并对其做出反应的事件。在事件驱动架构中,事件源服务发布一个事件,事件监听服务对事件做出反应并执行它们的逻辑。
-
WebSockets: WebSockets 允许在单个 TCP 连接上建立全双工通信通道,这对于实时应用程序(如聊天应用或实时更新)非常有用。以下是一个例子:服务器通过 WebSockets 向客户端推送更新,客户端实时接收更新并对其做出反应。
-
服务器发送事件(SSE):SSE 是一种服务器推送技术,允许服务器在建立初始客户端连接后向客户端推送实时更新。以下是一个例子:服务器通过 HTTP 连接向客户端发送事件,客户端监听传入的消息并处理它们。
-
支持流式传输的 gRPC: gRPC 支持双向流,允许客户端和服务器使用单个连接发送一系列消息。gRPC 的工作方式是这样的:客户端和服务器可以作为单个 RPC 调用的部分,持续交换消息流。
对于本章,我们将积极使用 Apache Kafka,这是一个开源、高性能的事件流平台。由于其能够实现强大且可扩展的事件驱动架构,它成为微服务之间异步通信的流行选择。虽然我们已经讨论了如何通过 Docker 运行服务,但本章将专注于在 Docker 上托管 Apache Kafka。
让我们快速看一下 Apache Kafka 解决的问题:
-
通信复杂性:在微服务环境中,你有多个来源(每个 API 都充当来源)和多个目标(每个 API 可以有多个来源写入)。来源和目标可扩展的事实总是伴随着通信问题。在这种情况下,问题是我们应该解决由来源和目标产生的复杂性,而不是专注于业务需求实现。现在你有多个来源和目标,这可能会产生以下问题:
-
每个目标都需要不同的协议进行通信。
-
每个目标都有自己的数据格式来处理。
-
每个不同的目标都需要维护和支持。
简单来说,假设你有一个微服务应用,每个服务都有自己的目标。除此之外,每个服务还可以有多个来源,并且服务可以使用公共来源。Apache Kafka 帮助你避免微服务之间的复杂通信。
-
-
通信复杂性重复:每当开发类似系统时,我们必须一次又一次地重写这样的通信过程。让我们想象一下,我们正在处理几个不同的项目。尽管这些项目的领域不同,尽管它们在抽象层面上解决不同的问题,但这些项目的共同点是通信复杂性。这意味着我们在重复自己,每次都试图解决相同的问题。
-
容错性:系统应该能够在各种类型的故障(如硬件故障、网络问题或软件崩溃)存在的情况下继续运行并提供可靠的数据处理和消息传递。
-
高性能:在大多数情况下,这种通信问题(来源-目标)会导致应用程序性能下降。无论应用程序中目标数和来源数的动态变化如何,程序都应该始终支持高性能属性。
-
可伸缩性:系统应该能够水平扩展来源和目标。水平扩展,也称为扩展,是软件设计中通过添加更多机器(节点)来增加系统容量的技术。
-
实时通信:可能的目标和来源通信属性之一是实时通信。根据用例,系统应允许来源和目标之间进行实时数据交换。
-
日志和数据聚合:这是将日志和数据组合并处理的能力。日志和数据聚合在现代软件中发挥着至关重要的作用,通过集中和组织来自各种来源的信息,使其更容易分析、故障排除和优化应用程序。
-
数据转换和处理:目标与源之间的通信不仅限于数据交换,信息还应基于转换的可能性。
现在让我们谈谈我们需要用于实现我们的微服务的基础设施。
实现异步事务微服务
我们将使用我们在第六章中实现的相同事务微服务,但会进行一些额外的更改,以帮助我们为其添加异步行为。首先,我们应该准备我们的基础设施。以下是它将包含的内容:
-
Apache Kafka:用于在微服务之间创建松散耦合。
-
Kafka UI:这是一个用于管理 Apache Kafka 集群的 Web 应用程序。它为 Kafka 提供了一个图形用户界面(GUI),而不是传统的命令行界面(CLI),这使得许多用户与 Kafka 交互变得更加容易。
-
Zookeeper:这是一款开源软件,作为大型分布式系统的中央协调器。将其想象为交响乐队的指挥,保持一切同步。
-
PostgreSQL:用于存储数据。
-
PgAdmin:一个用于直观查看数据库元素的图形工具。
我们在根目录(Ch07/transactionservice
)中有一个docker-compose.yml
文件。
此docker-compose
文件定义了一个多服务设置,用于 PostgreSQL 数据库、一个用于管理数据库的 PgAdmin 实例以及一个带有 Zookeeper 协调器的 Kafka 消息系统。服务通过一个自定义 Docker 网络my-app-network
连接,该网络使容器间通信成为可能。对于 Kafka,确保已配置正确的网络设置以避免连接问题,特别是在多网络设置中,可能需要advertised.listeners
以同时支持内部和外部地址。PostgreSQL 服务将其数据存储在名为postgres_data
的命名卷中,而PgAdmin
依赖于 PostgreSQL 处于运行状态。Kafka 和 Zookeeper 服务已设置为消息代理,Kafka UI 提供管理和监控,依赖于 Zookeeper 来维护分布式系统配置。
导航到根目录并运行docker-compose up -d
命令以启动基础设施。
这是成功运行后的样子(图 7.1)。
图 7.1:Docker 基础设施
在成功运行我们的 Docker 基础设施后,我们就可以切换到源代码来实现我们的需求。
首先,我们需要更新我们的交易服务以支持额外的状态。打开位于prisma/migrations
文件夹下的schema.prisma
文件,并将enum
更改为以下内容:
enum Status {
CREATED
FAILED
APPROVED
DECLINED
FRAUD
}
正如我们已经知道的,Prisma 的一个职责是隔离我们与数据库内部,并在这内部提供一种独特、更易于理解的编程语言。这就是为什么我们有.prisma
扩展,要将它映射到真实的 SQL,我们需要运行迁移。我们已经了解了迁移步骤及其对您开发的影响(有关更详细的信息,请参阅第六章),因此在本章中,我们只提供确切命令而不作解释:
npx prisma migrate dev --name transaction-status-updated
运行命令后,您应该得到一个额外的文件夹,其中包含migration.sql
文件,文件夹名称是生成日期和您从命令中提供的名称的组合(图 7**.2)。
图 7.2:新生成的状态迁移上下文
我们计划添加到交易服务的主要功能是欺诈功能。如果交易不是失败的,则此方法应将交易状态更改为FRAUD
。更新状态后,它应向代理(在这种情况下为 Apache Kafka)发布一条消息。
Kafka 在 NestJS 中的入门
正如我们在第六章中学到的,NestJS 有很多有用的包可以与不同的技术一起使用。您不需要编写任何这些包就可以将它们集成到您的项目中。这也适用于 Apache Kafka。我们不需要从头开始开发一个单独的包;只需运行以下命令来安装所需的包:
npm install @nestjs/microservices kafkajs
安装成功后,您将在package.json
文件中看到额外的更改。NestJS 有一个特殊的模式组合来配置服务。这就是为什么我们首先需要创建我们的kafka
模块。正如我们已经学到的,没有必要手动创建此文件。您只需运行以下命令:
nest generate module kafka
它应该生成一个名为kafka
的文件夹,其中包含kafka.module.ts
文件。此模块应将KafkaService
作为其提供元素,但我们没有 Kafka 服务。运行以下命令将生成kafka.service.ts
和kafka.service.spec.ts
文件:
nest generate service kafka
我们不需要在kafka.service.spec.ts
上工作,是否删除它取决于您。这些文件是自动生成的测试文件,我们不会为本章运行任何测试。为了使事情尽可能简单,我们将其删除。运行最后一个命令后,您应该意识到kafka.module.ts
也被自动更新了。以下是它的样子:
import { Module } from '@nestjs/common';
import { KafkaService } from './kafka.service';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [ConfigModule],
providers: [KafkaService],
})
export class KafkaModule {}
由于其极简的行数,kafka.module.ts
中的代码简单易懂。稍后我们还将讨论nestjs/config
包。我们将在kafka.service.ts
文件中实现主要功能。打开您的kafka.service.ts
文件,并将其替换为以下代码行:
import { Injectable, OnModuleInit, OnModuleDestroy } from
'@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Kafka, Producer } from 'kafkajs';
@Injectable()
export class KafkaService implements OnModuleInit, OnModuleDestroy {
private readonly producer: Producer;
private readonly topic: string;
constructor(private readonly configService: ConfigService) {
const clientId = this.configService.get<
string>('KAFKA_CLIENT_ID');
const brokers = this.configService.get<string>('KAFKA_BROKERS')
.split(',');
this.topic = this.configService.get<string>('KAFKA_TOPIC');
const kafka = new Kafka({ clientId, brokers });
this.producer = kafka.producer({ retry: { retries: 3 }
});
}
async onModuleInit(): Promise<void> {
await this.producer.connect();
}
async onModuleDestroy(): Promise<void> {
await this.producer.disconnect();
}
async send(value: any, key?: string): Promise<void> {
const messages = [{ key, value: JSON.stringify(value)
}];
await this.producer.send({ topic: this.topic, messages
});
}
}
现在我们来理解我们刚才做了什么:
-
Injectable
: 这表示该类可注入到其他服务中。 -
OnModuleInit
和OnModuleDestroy
: 这些是初始化和清理的生命周期钩子。 -
ConfigService
: 这提供了访问环境变量和配置的权限。 -
Kafka
和Producer
: 这些是来自kafkajs
库的类。 -
@Injectable()
: 这使得KafkaService
可注入。 -
implements OnModuleInit
和OnModuleDestroy
: 这实现了生命周期钩子。 -
producer
: Kafkaproducer
实例用于发送消息。 -
topic
: 这是预配置的用于消息传递的 Kafka 主题(从环境变量中获取)。 -
configService
: 这是用于访问配置的注入实例。 -
类的构造函数从环境变量中获取 Kafka 配置值:
-
KAFKA_CLIENT_ID
: 这是您应用程序的客户端 ID。 -
KAFKA_BROKERS
: 这是一个以逗号分隔的 Kafka 代理地址列表。 -
KAFKA_TOPIC
: 这是发送消息的 Kafka 主题。 -
const kafka = new Kafka({ clientId, brokers });
: 这使用配置创建了一个 Kafka 客户端。 -
this.producer = kafka.producer({ retry: { retries: 3 } })
: 这创建了一个具有消息可靠性重试配置的生产者实例(默认设置为重试三次)。 -
onModuleInit
: 这在 NestJS 模块初始化时连接 Kafka 生产者,确保生产者准备好发送消息。 -
onModuleDestroy
: 这在 NestJS 模块被销毁时断开 Kafka 生产者连接,释放资源。 -
send
: 这接受一个要发送的值(任何类型)和一个可选的键(字符串)用于消息标识。它构建一个带有键和值(序列化为 JSON)的消息对象,并使用生产者将消息发送到预配置的主题。
-
敏感信息不应直接存储在kafka.service.ts
中。对于本章,请在本地将配置设置存储在.env
文件中。然而,请避免将此文件提交到版本控制。对于生产部署,考虑使用安全的保险库服务,如 AWS Secrets Manager 或 Azure Key Vault,以安全地管理敏感配置。从前面的代码中,很明显我们将在.env
文件中存储我们的三个主要 Kafka 配置。打开您的.env
文件,并在文件末尾添加以下行:
#KAFKA Configuration
KAFKA_CLIENT_ID=transaction-service
KAFKA_BROKERS=localhost:29092
KAFKA_TOPIC=transaction-service-topic
我们已经使用 .env
文件来配置 postgresql
(第六章),但为了本章,我们需要指定一个可以读取 .env
文件的机制。另一个 NestJS 包,名为 config
,将帮助我们处理这个问题。让我们使用以下命令安装它:
npm install @nestjs/config
就这些了。我们已经将包导入到 kafka.service.js
中以使用它。现在是我们讨论 Kafka 知识要点的时候了。当我们生产或消费消息时,我们需要与 Apache Kafka 交互,在使用 Kafka 之前,你需要了解一些 Kafka 的基础知识。
Kafka 中的集群和代理
在生产中,一个 Kafka 集群通常由多个代理组成,每个代理存储和管理分配给主题的分区。Kafka 使用 ZooKeeper(或较新版本中的 KRaft)来协调代理元数据并确保集群中分区分布的一致性。代理与Kafka 服务器同义。每个代理都是一个服务器。代理的目的是服务数据。
无论它是否是物理的,最终,一个代理应该像服务器一样运行。虽然从技术上讲,在集群中只有一个代理是可能的,但这通常只用于测试或自学目的。
集群的目的包括以下内容:
-
使用多个代理并行处理多个请求
-
提供高吞吐量
-
确保可伸缩性
Kafka 集群中的每个代理也是一个引导服务器,包含有关所有其他代理、主题和分区的元数据。当消费者加入消费者组时,Kafka 的组协调器使用范围或轮询等分配策略来分配分区,确保均匀分布并平衡消费者之间的负载。这意味着当你连接到一个代理时,你自动连接到整个集群。在大多数情况下,一个好的起点是拥有三个代理。Apache Kafka 中的三个代理在容错性和效率之间提供了平衡。有了三个代理,Kafka 可以在多个节点之间复制数据,即使一个代理失败也能确保高可用性。它还允许三个副本因子,这使得系统在发生代理故障时不会丢失数据,同时避免了管理过多代理的开销。然而,在高负载系统中,你可能会拥有数百个代理。
根据主题的配置,作为存储类型的代理由多个 分区 组成。当我们创建一个主题时,我们定义该主题下的分区数量。作为分布式系统,Kafka 使用最佳算法将这些分区分配给代理。
让我们考虑一个有三个代理的 Kafka 集群。当创建一个名为 tracking_accounts
的主题并具有三个分区时,Kafka 将尝试将分区分配给三个代理。在最佳情况下,这将导致每个代理一个分区。当然,这取决于各种因素,包括负载均衡。你不需要干预;作为 分布式框架 的 Kafka 自动管理所有这些内部操作。
如果你有三个分区和四个代理,Kafka 将尝试将它们分配,每个代理分配一个分区,留下一个代理没有分区。但为什么创建比分区数量更多的代理呢?当你遇到代理宕机的问题时,这个值就变得明显了。众所周知,Kafka 最重要的属性之一是其容错性。当一个代理失败时,Kafka 会自动使用其他代理进行恢复。另一个重要的问题是生产者和消费者如何知道与哪个代理通信来读取和写入数据。
答案很简单。由于 Kafka 代理也充当 引导服务器,它们拥有关于其他服务器所有必要的信息。
例如,考虑任何生产者。在生产数据之前,生产者向任何代理(无论哪个代理——即使是最近的代理也可以)发送一个后台请求,以检索元数据信息。这些元数据包含有关其他代理、它们的主题、分区和领导者分区的所有相关细节(我们将在未来的讨论中介绍)。
使用这些元数据,生产者知道应该将数据发送给哪个代理。我们称这个过程为 Kafka 代理发现。
Apache Kafka 中的主题和分区概念
Kafka 生产者的责任是生产数据。另一方面,Kafka 消费者是你消息的客户端。Kafka 集群充当生产者和消费者的隔离器和 存储。在生产数据之前,Kafka 代理需要临时存储来保存数据。这些存储容器被称为 主题。
主题是一个数据流,它作为分区上的逻辑隔离器。
从用户的角度来看,主题很重要,因为在读取/写入数据时,我们主要指的是主题而不是分区。(当然,在生产和消费过程中定义分区时,必须指定主题名称,但在一般情况下,可以在不直接指定分区的情况下生产和消费数据。)主题概念帮助我们普通人与 Kafka 交互,而不用担心内部存储机制。每个主题都应该有一个唯一的名称,因为主题的识别过程是通过它们的名称来完成的。你可以创建尽可能多的主题,或者根据你的业务需求创建。
在生产系统中,主题不是只能存在于一个代理中的东西。相反,通过使用分区,主题分散到各个代理。这意味着,通过使用分区,一个主题存在于多个代理中。这有助于 Kafka 构建一个容错、可扩展和分布式系统。
主题是持久的,这意味着它们中的数据被持久化到磁盘上。这使得 Kafka 成为需要可靠存储和处理数据流的应用程序的良好选择。
但分区又是如何工作的呢?在底层,Kafka 使用分区来存储数据。生产环境中的每个主题都由多个分区组成。Kafka 使用主题概念主要出于两个目的:
-
为了将分区分组在一个盒子下存储“一个业务点”数据
-
为了帮助用户与 Kafka 交互,无需担心其内部结构
Kafka 使用分区来实现并行性和可扩展性。这意味着多个生产者和消费者可以同时处理同一个主题,数据在集群中的代理之间均匀分布。
那么,为什么我们需要分区的概念,如果我们已经有了主题呢?嗯,通过使用分区,Kafka 实现了分布式数据存储和同步副本(ISR)的概念。分区帮助我们分配主题并实现容错系统。
每个分区都通过其 ID 来识别。每个主题可以拥有你想要的/业务所需的任意数量的分区。在生产环境中,在创建主题时定义分区数量非常重要——否则,系统将使用分区数量的默认配置。这意味着,如果没有定义分区数量,系统将自动为每个主题创建分区数量。分区数量应与业务需求相一致;例如,一个主题可能需要四十个分区,而另一个可能需要两百个。
你可以将分区视为一个具有堆栈算法的集合。每个分区都是一个数组,它们的索引被称为偏移量。分区具有动态的偏移量计数,并且没有固定的大小。分区是动态可扩展的,它们的大小可以在同一个主题内变化。分区中的每个信息单元称为消息。消费者可以以堆叠的方式读取数据。
Kafka 分区使用轮询算法分配到 Kafka 代理。这意味着集群中的每个代理尽可能被分配相同数量的分区。
但是,将分区分配到 Kafka 代理的过程也取决于以下因素:
-
分区数量:当你创建一个 Kafka 主题时,你应该指定它可以拥有的分区数量。这个数字决定了可以与主题一起工作的并行消费者或生产者的数量。分区数量应根据预期的负载和所需的并行级别来选择。
-
代理分配:分配通常以平衡的方式进行,以确保分区在代理之间均匀分布,但它可以受到分区分配策略的影响。
-
分区分配策略:Kafka 提供了不同的分区分配策略,主要由消费者组协调器控制。
-
副本因子:Kafka 通过在多个代理之间复制数据来确保容错性。每个分区都有一个指定的副本因子,它决定了维护的数据副本数量。
简而言之,我们需要在 Kafka 中使用分区,因为它们是并行和分布的核心单元,并帮助 Kafka 横向扩展和分布数据。它们还实现了高吞吐量和容错性,并充当内部存储机制。
值得注意的是,一旦创建了一个具有特定分区数的主题,就不再适当更改该主题的分区数。相反,您需要创建一个新的主题,并具有所需数量的分区,如果需要,迁移数据。Apache Kafka 本身是一个庞大的概念,如果您想了解更多,可以查看我在 Udemy 上的 Apache Kafka for Distributed Systems 课程(www.udemy.com/course/apache-kafka-for-distributed-systems/
)。
配置 Apache Kafka
我们讨论了 Apache Kafka 的理论方面,现在是时候将其付诸实践了。您可以使用 Kafka CLI 与 Kafka 交互,但我们已经安装了 Kafka UI 以简化我们的工作,避免处理命令行的复杂性。
我们的 .env
文件定义了一个名为 transaction-topic
的主题,要创建它,请按照以下步骤操作:
-
打开 Docker Desktop。确保本章所有服务都在运行。
-
打开您喜欢的浏览器,导航到
http://localhost:9100/
。 -
从左侧的仪表板中选择 主题。
-
点击右上角的 添加主题 按钮,并填写输入(图 7**.3)。
图 7.3:在 Apache Kafka 中为代理创建主题
创建成功后,您将在 主题 列表中看到您的主题。
到目前为止,我们已经配置了 Apache Kafka 的主题,并使用 kafka.module.ts
创建了 kafka.service.ts
。我们计划在交易中实现欺诈功能,因此我们需要更改另外三个文件(transaction.controller.ts
、transaction.module.ts
和 transaction.service.ts
)以集成我们的新欺诈功能。
为事务微服务添加异步特性
我们需要做的是将配置读取和 Kafka 功能集成到事务服务中。transaction.module.ts
的最终版本将看起来像这样:
import { Module } from '@nestjs/common';
import { TransactionService } from './transaction.service';
import { TransactionController } from './transaction.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { HttpModule } from '@nestjs/axios';
import { KafkaService } from 'src/kafka/kafka.service';
import { ConfigService } from '@nestjs/config';
@Module({
imports: [PrismaModule,HttpModule],
controllers: [TransactionController],
providers:
[TransactionService,KafkaService,ConfigService],
})
export class TransactionModule {}
我们刚刚添加了 KafkaService
和 ConfigService
。我们计划将 KafkaService
注入到 transaction.service.ts
文件中,并且它依赖于 ConfigService
。这就是为什么我们需要将 KafkaService
和 ConfigService
都添加到 providers
列表中的原因。
让我们切换到 transaction.service.ts
文件本身。文件修改后的版本如下所示:
import { Injectable } from "@nestjs/common";
import { CreateTransactionDto } from "./dto/create-transaction.dto";
import { PrismaService } from "src/prisma/prisma.service";
import { HttpService } from "@nestjs/axios";
import { AccountApiResponse } from "./dto/account.dto";
import { KafkaService } from "src/kafka/kafka.service";
@Injectable()
export class TransactionService {
constructor(
private readonly prisma: PrismaService,
private readonly httpService: HttpService,
private readonly kafkaService: KafkaService
) {}
async create(createTransactionDto: CreateTransactionDto)
{
//same as Chapter 6
}
findAll() {
//same as Chapter 6
}
findOne(id: number) {
//same as Chapter 6
}
//newly added functionality
async fraud(id: number) {
const transaction = await this.findOne(id);
if (transaction.status !== "FRAUD" &&
transaction.status !== "FAILED") {
const newTransaction =
this.prisma.transaction.update({
where: { id },
data: { status: "FRAUD" },
});
this.kafkaService.send(transaction, null);
return newTransaction;
} else throw new Error("Transaction is not in a valid status");
}
如您可能已经注意到的,我们注入了 KafkaService
,并且事务还有一个名为 fraud
的额外功能。
这个名为 fraud
的异步函数旨在处理将交易标记为欺诈。它获取交易详情,验证其当前状态,如果有效则将其更新为 FRAUD
,可能发送通知,并返回更新的交易对象。该函数以 id: number
作为输入,表示要标记为欺诈的唯一标识符的交易。函数开始时使用 await
this.findOne(id)
异步从数据库检索交易数据。然后它使用严格不等号运算符 (!==
) 检查交易当前的状态既不是 FRAUD
也不是 FAILED
。这确保函数不会再次尝试标记已经欺诈或失败的交易。如果状态不符合标准,将抛出一个错误,错误信息为 Transaction is not in a valid status
,以防止意外行为。假设状态检查通过(即交易尚未欺诈或失败),代码将继续更新交易数据。它使用 Prisma 库 (this.prisma.transaction.update
) 来修改交易记录。where
属性指定更新应针对具有提供的 ID 的特定交易。
data
属性定义了要进行的更改。在这种情况下,它将事务的 status
属性设置为 FRAUD
。
函数中包含行 this.kafkaService.send(transaction, null)
。这表明使用了 Kafka 消息代理来广播有关欺诈交易的通告。第二个参数是一个键。消息键是您可以在 Apache Kafka 中与消息一起包含的可选元素。它在系统内部消息的路由和处理中扮演着重要角色。消息键主要用于在主题内分区消息。Kafka 主题进一步分为分区,作为分布式数据的存储单元。通过包含一个键,您可以影响消息被发送到哪个分区。
最后,如果状态检查通过并且更新成功,该函数将返回 newTransaction
对象。此对象包含更新的交易详情,包括新设置的 FRAUD
状态。
从本质上讲,此函数提供了一个机制,用于根据当前状态标记交易为欺诈,并通过 Kafka 发送通知。
最后一个元素是控制器。在交易控制器中,我们有一个新的端点,具有以下行为:
@Post(':id')
fraud(@Param(‹id›) id: string) {
return this.transactionService.fraud(+id);
}
要一起测试所有内容,您应该执行以下操作:
-
从根目录(
Ch07``/transactionservice
文件夹)运行npm run start:dev
。 -
导航到
localhost:3000/api
。
我们已经默认迁移了交易。您可以使用它们的 ID 来测试我们新创建的 API,或者您可以从头创建一个交易并测试它。让我们测试我们的一个种子交易。我将使用 id = 1
的交易 (图 7**.4).
图 7.4:执行欺诈端点
在成功执行欺诈端点后,我们将得到以下响应:
{
"id": 1,
"status": "FRAUD",
"accountId": "662c081370bd2ba6b5f04e94",
"description": "simple transaction",
"createdAt": "2024-05-10T08:43:41.389Z",
"updatedAt": "2024-05-29T17:47:07.233Z"
}
现在让我们打开 Apache Kafka 并检查我们的消息。从您喜欢的浏览器中打开 localhost:9100
,然后转到 transaction-service-topic
并从 消息 选项卡中选择 值 部分 (图 7**.5):
图 7.5:Apache Kafka 中的成功消息
太好了!我们能够向 Apache Kafka 发送消息,现在我们需要以某种方式接收并处理这条消息。从源(对我们来说是 Apache Kafka)读取消息称为消费过程,而读取器是消费者。
适应新的账户服务需求
账户服务是我们需要为给定上下文实现的主要消费者。首先,我们需要能够与 Apache Kafka 交互。只需复制已经实现的账户微服务并继续在该服务上工作。导航到 Ch07/accountService
并运行以下命令来安装 kafkajs
包:
npm install kafkajs
现在我们需要开发一个单独的模块来与 Apache Kafka 一起工作。Apache Kafka 有其变量(代理、主题等),这就是为什么我们使用与交易服务相同的 .env
文件。在 accountService
文件夹下,我们有 configs/.env
,并添加了以下配置项:
#KAFKA Configuration
KAFKA_CLIENT_ID=account-service
KAFKA_BROKERS=localhost:29092
KAFKA_TOPIC=transaction-service-topic
KAFKA_GROUP_ID=account-group
要读取 .env
文件,我们使用位于 src/config
下的特殊配置,并称为 config.js
。但我们需要向该文件添加必要的更改以支持新的键值对。以下是 config.js
的最终版本:
const dotenv = require('dotenv');
const Joi = require('joi');
const envVarsSchema = Joi.object()
.keys({
PORT: Joi.number().default(3000),
MONGODB_URL:
Joi.string().required().description('Mongo DB url'),
KAFKA_CLIENT_ID: Joi.string().required(),
KAFKA_BROKERS: Joi.string().required(),
KAFKA_TOPIC: Joi.string().required(),
KAFKA_GROUP_ID: Joi.string().required()
})
.unknown();
function createConfig(configPath) {
dotenv.config({ path: configPath });
const { value: envVars, error } = envVarsSchema
.prefs({ errors: { label: ‹key› } })
.validate(process.env);
if (error) {
throw new Error(`Config validation error:
${error.message}`);
}
return {
port: envVars.PORT,
mongo: {
url: envVars.MONGODB_URL,
},
kafka: {
clientID: envVars.KAFKA_CLIENT_ID,
brokers: envVars.KAFKA_BROKERS,
topic: envVars.KAFKA_TOPIC,
groupId: envVars.KAFKA_GROUP_ID,
}
};
}
module.exports = {
createConfig,
};
我们只是添加了额外的行来支持 .``env
文件中新添加的 config
元素。
到目前为止,我们添加了配置读取机制和 Kafka 包。现在是时候开发一个 Kafka 模块来与 Apache Kafka 交互了。
在 src
文件夹中,创建一个名为 modules
的新文件夹,并添加一个名为 kafkamodule.js
的新文件。这个新的“适应需求”模块应该具有以下实现:
const { Kafka } = require('kafkajs');
const Account = require('../models/account');
const path = require('path');
const { createConfig } = require('../config/config')
const configPath = path.join(__dirname, '../../configs/.env');
const appConfig = createConfig(configPath);
const kafka = new Kafka({
clientId: appConfig.kafka.clientId,
brokers: [appConfig.kafka.brokers],
});
const consumer = kafka.consumer({ groupId:
appConfig.kafka.groupId });
const consumerModule = async () => {
await consumer.connect();
await consumer.subscribe({ topic: appConfig.kafka.topic });
await consumer.run({
eachMessage: async ({ topic, partition, message }) =>
{
const transaction =
JSON.parse(message.value.toString());
const accountId = transaction.accountId;
try {
const blockedAccount =
await Account.findOne({ accountId, status:
{ $ne: 'blocked' } });
if (!blockedAccount) {
const updatedAccount =
await Account.findOneAndUpdate(
{ _id: accountId },
{ $inc: { count: 1 } },
{ new: true }
);
if (updatedAccount.count === 3)
await Account.findOneAndUpdate(
{ _id: accountId },
{ status: 'blocked' },
{ new: true }
);
}
else
console.log(`not a valid accountId ${accountId}`);
}
catch (error) {
console.log(error);
}
},
});
};
module.exports = consumerModule;
我们需要以下元素来使账户微服务能够与交易服务通信:
-
Kafka
: 用于从 Kafka 消费消息 -
Account
: 用于与数据库交互 -
path
: 用于指定读取配置文件的路径 -
createConfig
: 用于检索 Kafka 配置
让我们一步一步地查看代码,了解它做了什么:
-
const configPath = path.join(__dirname, '../../configs/.env');
:这一行构建到.env
配置文件的路径,该文件位于当前目录向上两个目录,然后在configs
目录中。 -
const appConfig = createConfig(configPath);
:这一行调用createConfig
函数,并将configPath
作为参数。createConfig
函数读取配置文件,并将配置设置作为对象返回(appConfig
)。 -
以下行使用
KafkaJS
库创建 Kafka 客户端的实例。它使用clientId
和从appConfig
对象获取的经纪人列表配置客户端。const kafka = new Kafka({ clientId: appConfig.kafka.clientId, brokers: [appConfig.kafka.brokers], });
-
const consumer = kafka.consumer({ groupId: appConfig.kafka.groupId });
:这一行创建一个新的 Kafka 消费者实例,指定从appConfig
对象获取的groupId
。groupId
用于管理 Kafka 中的消费者组协调。 -
我们在
consumerModule
函数内部实现了我们模块的主要功能。这个函数连接到 Kafka 并订阅给定的主题。 -
await consumer.connect();
:这一行将 Kafka 消费者连接到 Kafka 经纪人。 -
await consumer.subscribe({ topic: appConfig.kafka.topic });
:这一行将消费者订阅到指定的 Kafka 主题,该主题从appConfig
对象获取。 -
Consumer.run
启动消费者以监听订阅的 Kafka 主题的消息。它为每个消息定义了一个异步处理函数,该函数处理每个消费的消息。该函数接受一个具有topic
、partition
和message
属性的对象。对于每条消息,它解析检索到的消息并提取账户 ID。真正的业务规则从这里开始。首先,我们检查给定的账户 ID 是否存在于我们的数据库中,并且它没有被阻止。如果账户 ID 存在,那么我们应该更新这个账户,增加其计数。如果增加的计数是3
,那么状态将被更新为blocked
。
其余的代码行都很直接。
要使用kafkamodule.js
文件的功能,我们需要将其导入到app.js
中并调用它。以下是app.js
的样貌:
const express = require('express');
const v1 = require('./routes/v1');
const consumerModule = require('./modules/kafkamodule');
const app = express();
consumerModule();
// service
app.use(express.json());
// V1 API
app.use('/v1', v1);
module.exports = app;
嗯,正如你可能猜到的,我们遗漏了一个重要的信息。是的——它是一个新创建的count
。为了跟踪欺诈操作,我们需要在账户模式中添加一个名为count
的新项。打开models/account.js
,并将以下行添加到你的模式中:
count: {
type: Number,
default: 0, // Optional:(defaults to 0)
},
我们不需要更改账户服务和账户控制器来使用我们新的count :{}
。这是一个实现细节,用户不应该能够直接与此列交互。一切准备就绪,我们可以测试我们的服务。
之前,我们在没有 Docker Compose 的情况下运行账户微服务,但现在我们为它添加了一个 docker-compose.yml
文件(Ch07/accountservice/docker-compose.yml
)。事务服务已经有一个自己的 docker-compose.yml
文件,用于托管 Kafka。为了一起测试这两个服务,我们必须从 accountservice
和 transactionservice
目录运行 docker-compose.yml
文件。
一起测试我们的微服务
我们应该一起运行事务和账户微服务来测试生产和消费过程。首先,让我们从账户微服务开始。如前所述,不要忘记运行两个服务的 docker-compose.yml
文件。
要测试新更新的账户,请按照以下步骤操作:
-
从终端导航到
Ch07/accountservice/src
。 -
使用命令行中的
node index.js
命令运行账户服务。 -
打开 Postman,从新标签页粘贴服务 URL(对我们来说它是
http://localhost:3001/v1/accounts
),对于 HTTP 方法,选择POST
。选择 Body | raw,将 Text 更改为 JSON,并粘贴以下内容:{ "name":"AccName1", "number":"Ac12345", "type":"root", "status":"new" }
你应该得到以下响应:
{ "success": true, "Account": { "id":{your_account_id}, //for the given request, it is "6658ae5284432e40604018d5" for us "name": "AccName1", "number": "Ac12345", "type": "root", "status": "new" } }
在我们的数据库中,它看起来是这样的:
{
"_id": {
"$oid": "6658ae5284432e40604018d5"
},
"name": "AccName1",
"number": "Ac12345",
"type": "root",
"status": "new",
…………………
}
根据账户微服务中的内容,目前一切正常。我们的主要触发器,生产者,是事务微服务。让我们生产一条消息,看看账户微服务是否可以消费这条消息。我们仍然需要账户服务与事务微服务并行运行,这就是为什么我们需要打开一个新的终端来运行事务微服务:
-
打开一个新的终端并导航到
Ch07/transactionservice
。 -
运行
npm run start:dev
以启动事务微服务。 -
导航到
http://localhost:3000/api/
并选择POST /transaction/
. -
将以下 JSON 粘贴以创建一个新的交易:
{ "accountId": "6658ae5284432e40604018d5", "description": "Optional transaction description" }
你应该使用账户微服务创建的账户 ID。你将得到以下响应:
{ "id": {your_id},//it is '37' for us but in your case, it may have a different value "status": "CREATED", "accountId": "6658ae5284432e40604018d5", "description": "Optional transaction description", ……………… 37 for us) to the POST /transaction/Id API. It is a fraud endpoint. The response will be like the following:
{
"id": 37,
"status": "FRAUD",
"accountId": "6658ae5284432e40604018d5",
"description": "可选的事务描述",
……..
}
Before executing the fraud endpoint, make sure that the Docker infrastructure is running. After running a fraud request, the account microservice should read and update the data. Running the same account ID in a fraud context three times should block the account itself in the account microservice:
{
….
"type": "root",
"status": "blocked",
"count": 3,
………
}
通过理解异步通信技术,你可以自信地将它应用到你的项目中。
摘要
在本章中,我们通过学习定义微服务之间适当通信的重要性开始了我们的旅程。我们主要使用两种主要的通信形式:异步和同步。选择其中一种总是基于上下文的选择——上下文是王。然后,我们讨论了异步通信的优点。实现异步通信有多种方式,我们讨论了大多数流行的选择。任何事物都有代价,集成微服务架构也不例外。它带来了许多额外的复杂性,我们需要考虑其中之一就是异步通信。
我们讨论了 Apache Kafka,它帮助我们克服了存在的问题。我们学习了诸如集群、代理、主题、消息和分区等基本概念。我们的实际例子涵盖了两个主要的微服务。事务服务是我们的生产者,它产生消息,而账户微服务是一个消费者,它消费那个消息。当然,还有很多子主题,例如重构、异常处理、测试和部署等,我们还没有涉及,接下来的章节将详细讨论这些内容。
第八章:使用微服务进行实时数据流处理
某些微服务应用程序,如金融交易平台和叫车服务,需要以最小延迟产生和消费事件。由于实时数据流能够基于最新数据提供即时、连续的洞察和响应,其在现代软件开发中变得越来越重要。这种实时数据使用在金融、医疗保健和物流等行业尤为重要,在这些行业中,数据处理延迟可能导致重大损失甚至危及生命。
依赖于实时数据的应用程序可以提供更响应和互动的用户体验。例如,社交媒体平台、在线游戏和体育直播都依赖于实时数据来保持用户参与并提供无缝体验。
本章全部关于使用微服务进行实时流处理。我们的目的是了解在处理微服务时何时以及如何建立此类通信。
本章涵盖以下内容:
-
什么是实时流处理?
-
开始使用地震流 API
-
实现地震流消费者
技术要求
要跟随本章内容,您需要一个 IDE(我们推荐 Visual Studio Code)、Postman、Docker 以及您选择的浏览器。
建议您从github.com/PacktPublishing/Hands-on-Microservices-with-JavaScript
下载我们的仓库,并打开Ch08
文件夹,以便轻松跟随代码片段。
什么是实时流处理?
实时流处理是一种数据处理范式,其中数据在创建时即被连续生成、传输和处理,延迟最小。与定期收集和批量处理大量数据或批次的批量处理不同,实时流处理侧重于数据的即时和连续流动,从而实现即时分析和响应。这就像观看直播流而不是等待视频完全下载一样。
在我们继续前进之前,让我们看看实时流处理的一些关键特性:
-
连续数据流:实时流处理就像来自不同地方的信息永不停止的流动。这些信息可能来自传感器、在线使用物品的人、买卖货币,如比特币等。
-
低延迟:实时流处理的主要目标是使信息从创建到使用之间的延迟尽可能短。
-
事件驱动处理:实时流处理通过跟踪事件的发生来工作,例如事物的创建或变化。每个事件都是单独处理或在小批量中处理的,这样系统就可以立即对新情况做出反应。
-
可扩展性:实时流系统可以处理不同数量和速度的信息,根据接收到的信息量的大小进行扩展或缩小。
-
容错性:为确保持续运行,实时流系统集成了容错机制,如数据复制和从故障中自动恢复。正如我们在前面的章节中提到的,这是 Apache Kafka 的重要属性之一,我们计划在本章中也使用它。
-
数据一致性:在实时流中维护数据一致性很重要,尤其是在处理涉及多个分布式组件时。采用如精确一次处理和幂等性等技术来确保准确性。精确一次处理确保即使在失败或重试的情况下,每条消息也只被处理一次,从而防止重复。由于我们大多数章节都使用 Apache Kafka,你可以在其中轻松配置幂等性和精确一次行为。
现代应用中实时数据的重要性不容忽视。在当今以数据驱动的世界中,能够处理和利用生成数据的能力提供了显著的竞争优势。
为什么实时数据是必不可少的
以下是为什么实时数据对于大多数现代应用来说是必不可少的几个关键原因:
-
增强决策能力:实时数据可以通过以下方式增强应用程序的决策能力:
-
即时洞察:实时数据提供即时洞察,使企业能够快速做出明智的决策。这在股票交易等动态环境中尤为重要,因为市场状况可能会迅速变化。
-
主动问题解决:通过持续监控数据,组织可以在问题升级之前识别并解决它们,减少停机时间并提高运营效率。
-
-
改进的用户体验:实时数据使应用程序能够通过增强交互性和响应性,并根据个人偏好定制内容,提供更动态和个性化的用户体验。
-
运营效率:组织可以通过使用实时数据显著提高效率,这既可以实现实时监控,也可以实现流程的自动化,有助于简化运营并降低成本。
-
竞争优势:利用实时数据通过提高灵活性和促进创新,为业务提供独特的优势,使他们能够迅速响应市场变化并创造尖端的产品和服务。
-
增加收入:利用实时数据通过优化的营销策略和动态欺诈检测,使企业能够增强其收入流,确保更有效的客户参与和财务安全。
-
增强安全性:实时数据通过实现连续监控和异常检测来加强安全性,使组织能够快速识别和应对潜在的威胁和系统异常。
-
可扩展性和灵活性:实时数据系统提供了高效处理大量数据的能力,同时保持适应性,确保即使在数据负载和要求波动时也能保持最佳性能。
-
客户满意度:实时数据通过提供即时支持和即时反馈来提高客户满意度,使企业能够迅速解决关注点并持续改进其产品和服务。
实时流允许数据在生成时进行处理,提供即时的洞察和响应。这种连续的数据流动,结合低延迟和事件驱动处理,在金融、医疗保健和物流等行业至关重要。实时决策的能力、提升用户体验和改善运营效率为企业提供了竞争优势,促进了创新,增加了收入,同时确保了系统的可扩展性、容错性和增强的安全性。
理解使用案例
在本章的开头,我简要提到了实时数据的使用可以对某些行业产生惊人的影响。因此,在设计微服务时理解实时数据的使用案例至关重要。让我们来看看这些案例:
-
金融服务:实时数据在这一行业中扮演着关键角色,它使算法股票交易能够做出毫秒级的决策,并支持持续的风险管理,以确保合规并减轻潜在的金融威胁。
-
医疗保健:实时数据通过实现连续的患者监测以进行及时干预,并通过实时视频咨询和数据共享来增强远程医疗,改善患者护理和可及性。
-
零售与电子商务:实时数据通过优化库存管理以防止短缺,并启用动态定价策略,这些策略能够根据需求和竞争对手的活动进行调整,从而增强零售和电子商务的运营。
-
交通运输与物流:实时数据通过优化路线规划和配送时间,优化车队管理,同时实时交通数据增强交通管理,减少拥堵并提高整体流动性。
-
电信:实时数据通过确保持续的性能监控以优化服务质量,同时通过快速解决网络问题来改善客户体验。
最后,我们可以肯定地说,实时数据是现代应用的基础,推动决策能力、用户体验、操作效率和竞争优势的提升。通过利用实时数据,组织可以创新、适应并在快速变化的数字领域中蓬勃发展。
实时流式传输与微服务之间的关系
现在我们已经了解了什么是实时数据以及为什么它是必要的,是时候了解实时流式传输与微服务之间的关系了。实时流式传输与微服务的结合是一种共生关系,它扩展了现代软件架构的力量、生产力和可扩展性。现在,作为服务的系统,由于这种集成,变得更加反应灵敏、更加适应性强,运行速度也更快。让我们尝试了解实时流式传输和微服务是如何相互配合的:
-
解耦和可扩展性:实时流式传输通过促进松散耦合和独立扩展,补充了微服务,允许服务异步通信并根据需求高效扩展。
-
灵活性和敏捷性:实时流式传输与微服务的结合增强了灵活性和敏捷性,使服务能够持续进化,并为需要即时洞察和快速迭代的实时数据处理应用提供支持。
-
弹性和容错性:将实时流式传输与微服务集成,通过将故障隔离到单个服务并确保数据持久性,增强了弹性和容错性,即使在服务中断的情况下,也能实现无缝恢复和持续运行。
-
实时通信:实时流式传输通过启用事件驱动架构和立即数据传播,增强了微服务内部的通信,允许服务异步交互并快速响应事件,从而实现更响应和同步的系统。
-
操作效率:将实时流式传输与微服务相结合,通过优化资源利用和简化数据管道,提高了操作效率,允许持续数据流动并减少传统批量处理方法的复杂性。
-
增强的监控和分析:将实时流式传输与微服务集成,可以实现实时监控和分析,提供对服务性能的即时可见性,并提供可操作见解,允许主动管理和动态优化服务。
实时流和微服务之间的协同作用为构建响应迅速、可扩展和高效的系统提供了一个强大的框架。通过利用这两种范例的优势,组织可以创建能够处理动态工作负载、提供实时洞察并交付卓越用户体验的应用程序。这种组合在需要快速数据处理和即时反应以成功的环境中尤其强大。
我们将开发的微服务
为了使我们的学习过程更加互动和易于理解,我们将开发两个简单的微服务。第一个微服务将充当流的生产者,该微服务的领域是地震。一个提供关于地震实时信息的 API 可以出于几个原因而变得有价值:
-
应急响应:对于需要评估地震造成的损害并迅速部署资源的应急响应人员来说,实时数据可能至关重要。API 将提供关于地震的位置、震级和深度的信息,这有助于响应人员优先考虑可能受影响最大的地区。
-
公众意识:API 可以用于公众意识,创建向受影响地区的人们发送警报的应用程序。这有助于人们在必要时寻找避难所或疏散。
-
研究:研究人员可以使用 API 跟踪地震活动并提高他们对地震模式的理解。这些数据可以用于开发更好的地震预测模型并改进建筑规范。
-
新闻和媒体:新闻机构可以使用 API 获取关于地震活动的实时更新,这有助于他们报道最新进展。
除了这些,此类 API 还有商业应用。例如,保险公司可以使用它来评估潜在的风险和损失,或者工程公司可以使用它来设计抗震结构。
当然,在构建此类生产 API 时,我们需要选择一个可靠的地震数据来源;但为了展示实时数据流的目的和实现,我们的 API 将充当事实来源。
从数据格式角度来看,我们应该选择一个易于使用且易于与其他应用程序集成的数据格式。常见的格式包括 JSON 和 XML。我们的选择是 JSON。
通过提供有价值且及时的数据,您的地震 API 可以成为各种用户的有用工具。
第二个微服务将成为数据的消费者。在我们学习的过程中,我们已经使用不同的包和框架实现了微服务,几乎包含了完整的骨架。对于当前章节,我们的重点是流处理,而不是从头开始构建应用程序骨架。我们的重点不在于实现任何架构。如果你想添加额外的功能并使其成为一个完全自包含的架构应用程序,可以参考前面的章节。
开始使用地震流式 API
在我们的 GitHub 仓库中,Ch08
文件夹下有两个子文件夹:earthquakeService
,地震流式 API,和 earthquakeConsumer
,消费者 API。正如我们之前提到的,我们的主要重点是实现流处理。为了使本章更专注于主题,我们没有为这个 API 实现详细的设计。消费者 API 也是如此。
最好的做法是跟随我们从头开始创建所有内容。
earthquakeService
有以下依赖项:
"dependencies": {
"dotenv": "¹⁶.4.5",
"express": "⁴.19.2",
"joi": "¹⁷.13.1",
"node-rdkafka": "³.0.1"
}
首先,你需要生成一个包含所有依赖项的 package.json
文件。要创建该文件,请运行 npm init
并遵循终端中的提示。在 package.json
创建完成后,运行 npm install 'your_required_package_names'
模板命令逐个安装包。例如,要安装 express
包,只需运行 npm install express
,然后按 Enter。我们已经讨论过 package.json
和包安装过程。你可以查看前面的章节以获取更多信息。虽然我们在当前章节中重复使用了上一章的一些微服务,但我们还将使用对我们来说全新的 node-rdkafka
包。
node-rdkafka
是一个 Node.js 库,它为原生的 librdkafka 库提供了一个包装器,使得与 Apache Kafka 进行高效数据流通信成为可能。它利用 librdkafka
的强大功能,以高效的方式与 Kafka 通信,并处理诸如平衡写入和管理代理等复杂性,使得 Kafka 交互对开发者来说更加容易。
你可以使用 npm
安装 node-rdkafka
:
npm install node-rdkafka
这并不是用于流处理的唯一包,根据你个人的喜好,你可以选择任何其他包。node-rdkafka
包支持非常简单的流写入和读取过程,这就是为什么我们选择在本章中用于学习目的。
注意
你应该始终尝试在生产应用程序中使用官方包。使用官方包有助于保持你的应用程序安全,因为可信的开发者管理它们,并且它们经常被检查。它们也更加可靠,因为它们经过测试、更新,并且有良好的支持,这对于生产中的应用程序来说非常重要。
我们使用 Apache Kafka 作为流平台。因此,你需要确保 Apache Kafka 正在运行。和之前一样,我们计划使用 docker-compose.yml
文件,该文件应该已经启动并运行 Apache Kafka。本例中的 docker-compose.yml
文件将只包含 Kafka 所需的服务,排除不必要的组件(如 PostgreSQL)以减少资源使用。当然,你可以从之前章节中使用的包含 Apache Kafka 的 docker-compose.yml
文件运行,但额外的服务将占用你电脑上更多的资源。
这里是我们的 docker-compose.yml
文件:
services:
zookeeper:
image: bitnami/zookeeper:3.8
ports:
- "2181:2181"
volumes:
- zookeeper_data:/bitnami
environment:
ALLOW_ANONYMOUS_LOGIN: "yes"
kafka1:
image: bitnami/kafka:3.6
volumes:
- kafka_data1:/bitnami
environment:
KAFKA_CFG_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_CFG_LISTENERS: INTERNAL://:9092,EXTERNAL://0.0.0.0:29092
KAFKA_CFG_ADVERTISED_LISTENERS: INTERNAL://kafka1:9092,EXTERNAL://localhost:29092
KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT
KAFKA_CFG_INTER_BROKER_LISTENER_NAME: INTERNAL
KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE: 'true'
ALLOW_PLAINTEXT_LISTENER: 'yes'
ports:
- "9092:9092"
- "29092:29092"
depends_on:
- zookeeper
kafka-ui:
image: provectuslabs/kafka-ui:latest
ports:
- 9100:8080
environment:
KAFKA_CLUSTERS_0_NAME: local
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka1:9092
KAFKA_CLUSTERS_0_ZOOKEEPER: zookeeper:2181
depends_on:
- kafka1
volumes:
zookeeper_data:
driver: local
kafka_data1:
driver: local
在此配置中,我们定义了 INTERNAL
和 EXTERNAL
监听器以区分 Docker 网络内的连接(INTERNAL://kafka1:9092
)和来自 Docker 网络外部的连接,例如你的本地机器(EXTERNAL://localhost:29092
)。这种分离确保 Docker 网络内的服务可以使用内部地址,而外部客户端(如运行在主机上的 Node.js 应用程序)可以使用外部端口连接。通过这样做,Kafka 可以正确地向不同的客户端广告正确的地址,避免因监听器配置不匹配而导致的连接问题。
此文件包含 Apache Kafka、Kafka UI 和 ZooKeeper。只需检查我们的根文件夹(Ch08/earthquakeService
)以找到并运行它。要运行 docker-compose.yml
文件,首先启动 Docker Desktop,确保它正在运行,然后按照以下步骤操作:
-
从仓库中拉取并打开
Ch08
。 -
从 Visual Studio Code(或你喜欢的任何文本编辑器)打开项目并导航到
Ch08
。 -
如果你使用 Visual Studio Code,则从 菜单 中选择 终端 | 新建终端;否则,使用命令行导航到根目录。
-
从终端运行
docker-compose up -d
命令(图 8**.1)。
图 8.1:运行 docker-compose.yml 文件后的 Docker Desktop
要连接到 Apache Kafka,我们需要将所需的配置存储在单独的文件中。这就是为什么我们使用 dotenv
包来读取配置信息。在根目录下(Ch08/earthquake
)创建一个 configs
文件夹并添加一个 .env
文件。
注意
config
和 configs
文件夹是分开的,用于不同的目的。请确保使用正确的文件夹以避免混淆。我们将 .env
文件存储在 configs
文件夹下。另一方面,我们将 config.js
文件存储在 config
文件夹下,该文件使用 dotenv
包加载环境变量,使用 Joi
进行验证,并为基于 Kafka 的微服务返回一个配置对象,如果验证失败则抛出错误。
这就是 configs/.env
文件应该看起来像的:
PORT=3001
#KAFKA Configuration
KAFKA_CLIENT_ID=earthquake-service
KAFKA_BROKERS=localhost:29092
KAFKA_TOPIC=earthquake-service-topic
我们有 Kafka 配置,如客户端 ID、代理和主题名称以及端口信息。正如我们之前所学的,所有应用程序源代码都位于 src
文件夹下。在 configs
文件夹同一级别创建 src
文件夹(图 8**.2)。
图 8.2:earthquakeService 的一般结构
我们在 .env
文件中存储配置信息,但我们需要在 config
上添加一个读取和验证机制。为了实现正确的读取和验证,我们需要在 src/configc
文件夹下创建一个 configs.js
文件。它看起来是这样的:
const dotenv = require('dotenv');
const Joi = require('joi');
const envVarsSchema = Joi.object()
.keys({
PORT: Joi.number().default(3000),
KAFKA_CLIENT_ID: Joi.string().required(),
KAFKA_BROKERS: Joi.string().required(),
KAFKA_TOPIC: Joi.string().required()
})
.unknown();
function createConfig(configPath) {
dotenv.config({ path: configPath });
const { value: envVars, error } = envVarsSchema
.prefs({ errors: { label: 'key' } })
.validate(process.env);
if (error) {
throw new Error(`Config validation error:
${error.message}`);
}
return {
port: envVars.PORT,
kafka: {
clientID: envVars.KAFKA_CLIENT_ID,
brokers: envVars.KAFKA_BROKERS,
topic: envVars.KAFKA_TOPIC
}
};
}
module.exports = {
createConfig,
};
我们使用与账户微服务相同的 config
读取和验证机制。我们已经在 第七章 中解释了此文件。
我们的 services
文件夹负责存储服务文件。为了实现实时流功能,我们需要在 services
文件夹下创建一个名为 earthquake.js
的新文件。它看起来是这样的:
const Kafka = require('node-rdkafka');
const { createConfig } = require('../config/config');
const path = require('path');
class EarthquakeEventProducer {
constructor() {
this.intervalId = null;
}
#generateEarthquakeEvent() {
return {
id: Math.random().toString(36).substring(2,
15),
magnitude: Math.random() * 9, // Random magnitude between 0 and 9
location: {
latitude: Math.random() * 180 - 90, // Random latitude between -90 and 90
longitude: Math.random() * 360 - 180, // Random longitude between -180 and 180
},
timestamp: Date.now(),
};
}
……..
此代码定义了一个名为 EarthquakeEventProducer
的类,该类模拟生成并将地震事件数据发布到 Kafka 主题。让我们来分析一下代码的各个元素:
-
require('node-rdkafka')
: 导入node-rdkafka
库以与 Kafka 集群交互。 -
require('../config/config')
: 导入一个函数(可能来自../config/config.js
),该函数从文件中读取配置设置。 -
require('path')
: 导入path
模块以进行文件路径操作。 -
The EarthquakeEventProducer class
: 此类处理地震事件生成和发布。 -
#generateEarthquakeEvent()
: 这个私有方法生成一个具有以下属性的模拟地震事件对象:-
id
: 一个随机唯一的标识符字符串。 -
magnitude
: 表示地震强度的介于0
和9
之间的随机浮点数 -
location
: 包含以下内容的对象: -
latitude
: 一个介于-90
和90
之间的随机浮点数,表示纬度。 -
longitude
: 一个介于-180
和180
之间的随机浮点数,表示经度。 -
timestamp
: 当前时间戳(毫秒)。
-
这里是如何指定我们的主方法 runEarthquake
的:
async runEarthquake() {
const configPath = path.join(__dirname,
'../../configs/.env');
const appConfig = createConfig(configPath);
// Returns a new writable stream
const stream = Kafka.Producer.createWriteStream({
'metadata.broker.list':
appConfig.kafka.brokers,
'client.id': appConfig.kafka.clientID
}, {}, {
topic: appConfig.kafka.topic
});
// To make our stream durable we listen to this event
stream.on('error', (err) => {
console.error('Error in our kafka stream');
console.error(err);
});
this.intervalId = setInterval(async () => {
const event =
await this.#generateEarthquakeEvent();
// Writes a message to the stream
const queuedSuccess = stream.write(Buffer.from(
JSON.stringify(event)));
if (queuedSuccess) {
console.log('The message has been queued!');
} else {
// If the stream's queue is full
console.log('Too many messages in queue already');
}
}, 100);
}
让我们在这里分解这段代码:
-
runEarthquake()
: 这个异步方法负责设置 Kafka 生产者和发布地震事件。 -
configPath
: 使用path.join
构建配置文件的路径。 -
appConfig
: 使用导入的createConfig
函数从文件中读取配置。 -
stream
: 使用Kafka.Producer.createWriteStream
创建一个 Kafka 生产者写入流。配置包括以下内容:-
'metadata.broker.list'
: 从配置中获取的 Kafka 代理地址的逗号分隔列表 -
'client.id'
: 从配置中为这个生产者客户端提供的唯一标识符 -
Topic
: 应该接收流数据的确切主题
-
-
stream.on('error')
: 这为 Kafka 流中的错误附加了一个事件监听器。它将错误消息记录到控制台。 -
setInterval
: 这设置了一个间隔计时器,每 100 毫秒(可调整)生成和发布事件。在间隔回调中是以下内容:-
event
: 使用#generateEarthquakeEvent
生成一个新的地震事件对象 -
stream.write
: 尝试将事件数据(使用JSON.stringify
转换为缓冲区)写入 Kafka 流 -
queuedSuccess
: 检查stream.write
的返回值:-
true
: 表示消息成功排队。将成功消息记录到控制台。 -
false
: 表示流的队列已满。将关于超出队列容量的消息记录到控制台。
-
-
为了停止我们的地震服务,我们需要清除间隔:
stopEarthquake() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
console.log('Earthquake event stream stopped.');
} else {
console.log('No running earthquake event stream to stop.');
}
}
module.exports = EarthquakeEventProducer;
stopEarthquake()
方法通过检查是否存在正在运行的间隔(由 this.intervalId
的存在表示)来停止正在进行的地震事件流。如果存在间隔,它使用 clearInterval()
停止事件生成并将 this.intervalId
重置为 null
以指示流已停止。当流停止时,记录一条成功消息。如果没有正在运行的间隔(即 this.intervalId
为 null
),它记录一条消息说没有活动流可以停止。这确保了该函数只能停止现有流,而不会尝试停止不存在的流。
最后,此代码模拟地震事件生成,并定期将这些事件发布到 Kafka 主题,展示了带有错误处理和日志记录的基本 Kafka 生产者使用。
我们计划通过 API 启动流式传输,但为了使事情尽可能简单,我们使用了一个不需要我们创建控制器的最小 API 方法。这种行为在 app.js
文件中实现。以下是该文件:
const express = require('express');
const EarthquakeEventProducer = require('./services/earthquake');
const app = express();
const earthquakeProducer = new EarthquakeEventProducer();
// Function to run streaming
app.post('/earthquake-events/start', async (req, res) => {
earthquakeProducer.runEarthquake();
res.status(200).send('Earthquake event stream started');
});
// Stop the earthquake event stream
app.post('/earthquake-events/stop', (req, res) => {
earthquakeProducer.stopEarthquake();
res.status(200).send('Earthquake event stream stopped');
});
module.exports = app;
代码使用 Express.js 定义了两个 API 端点以启动和停止地震事件流。/earthquake-events/start
端点从 EarthquakeEventProducer
类触发 runEarthquake()
函数,启动事件流,并返回成功消息。/earthquake-events/stop
端点调用 stopEarthquake()
函数停止事件流,并也返回成功消息。earthquakeProducer
对象是 EarthquakeEventProducer
类的实例,它管理事件流操作。最后,Express 应用程序被导出以在其他应用程序的部分中使用。这种设置允许外部客户端,如 Postman,通过 API 调用来控制 Kafka 事件流。
在 Express.js 应用程序中,根目录中的 index.js
文件通常作为服务器的入口点。它充当配置和启动 Express 应用的中心枢纽。以下是我们的 index.js
文件:
const path = require('path');
const app = require('./app');
const { createConfig } = require('./config/config');
async function execute() {
const configPath = path.join(__dirname, '../configs/.env');
const appConfig = createConfig(configPath);
const server = app.listen(appConfig.port, () => {
console.log('earthquake service started',
{ port: appConfig.port });
});
const closeServer = () => {
if (server) {
server.close(() => {
console.log('server closed');
process.exit(1);
});
} else {
process.exit(1);
}
};
const unexpectedError = (error) => {
console.log('unhandled error', { error });
closeServer();
};
process.on('uncaughtException', unexpectedError);
process.on('unhandledRejection', unexpectedError);
}
execute();
我们在 index.js
文件中有以下功能:
-
导入 Express 应用(
app.js
)和配置函数(config.js
)。 -
使用
createConfig
从文件中读取配置。 -
使用配置的端口通过
app.listen
启动服务器并记录一条消息。 -
定义函数以优雅地关闭服务器和处理意外错误。
-
为未捕获的异常和未处理的承诺拒绝附加事件监听器,调用错误处理函数。
-
最后,调用
execute
函数以启动所有操作。
我们已经实现了earthquakeService
;现在是时候测试它了。以下是您可以这样做的方法:
-
如果您正在使用 Visual Studio Code,请从菜单中选择终端 | 新终端。
-
导航到
src
文件夹。 -
运行
node index.js
命令:PS C:\packtGit\Hands-on-Microservices-with-JavaScript\Ch08\earthquakeService\src> node index.js Debugger listening on ws://127.0.0.1:61042/876d7d9e-3292-482a-b011-e6c2d66e7615 For help, see: https://nodejs.org/en/docs/inspector Debugger attached. http://localhost:3001/earthquake-events/start.
-
要停止流,打开 Postman 并向
http://localhost:3001/earthquake-events/stop
发送 POST 请求(图 8.3)。
图 8.3:停止事件流
主题应自动创建一些事件(图 8.4)。
图 8.4:Apache Kafka 事件流后
我们已经实现了流式 API。现在是时候消费数据了。
实现地震流消费者
如果没有消费者来消费数据,生产就变得没有价值。我们的第二个微服务,称为earthquakeConsumer
,将从 Apache Kafka 中消费数据。它与我们的流式 API 具有相似的代码结构(图 8.5)。
图 8.5:地震消费者 API 结构
让我们从configs
文件夹开始。正如我们在第第五章中的第一个微服务中一样,文件夹内有一个.env
文件。这个文件夹的职责是存储相关配置。以下是它的样子:
PORT=3002
#KAFKA Configuration
KAFKA_CLIENT_ID=earthquake-consumer-service
KAFKA_BROKERS=localhost:29092
KAFKA_TOPIC=earthquake-service-topic
KAFKA_GROUP_ID=earthquake-consumer-group
我们引入了一个额外的配置,KAFKA_GROUP_ID
,它用于标识消费者组,允许 Kafka 在消费者之间平衡分区分配。它是一个字符串属性,用于标识一组消费者实例,并作为将消费者粘合在一起以进行协作消费的粘合剂。
Kafka 自动将主题分区分配给同一组中的消费者,允许并行处理,同时确保组内每次只有一个消费者消费一个分区。如果组中的消费者失败,Kafka 将其分区重新分配给剩余的活跃消费者,确保消息处理不间断。通过适当的配置,消费者组可以实现一次且仅一次的交付语义,保证每条消息只被一个消费者恰好处理一次。当与 Kafka 消费者组一起工作时,了解它们如何管理消息消费和跨多个消费者的工作负载分配是至关重要的。以下是在配置和利用消费者组进行高效消息处理时需要记住的关键点:
-
在组内,一次只有一个消费者可以处理一个分区。
-
不同组 ID 的消费者将主题视为独立的流,并且不共享工作负载。
-
总是考虑使用有意义的组 ID 来提高集群管理和监控。
为了读取和验证此配置,我们使用与流 API 相同的机制。我们有 src/config/config.js
。它使用额外的 KAFKA_GROUP_ID
读取和验证我们的配置。
主要功能已在 src/service/earthquake.js
文件中实现。以下是我们的流消费过程:
const Kafka = require('node-rdkafka');
const { createConfig } = require('../config/config');
const path = require('path');
class EarthquakeEventConsumer {
constructor() {
const configPath = path.join(__dirname,
'../../configs/.env');
this.appConfig = createConfig(configPath);
// Create the Kafka consumer stream here (once)
this.stream =
Kafka.KafkaConsumer.createReadStream({
'metadata.broker.list':
this.appConfig.kafka.brokers,
'group.id': this.appConfig.kafka.groupID,
'socket.keepalive.enable': true,
'enable.auto.commit': false
}, {}, {
topics: this.appConfig.kafka.topic,
waitInterval: 0,
objectMode: false
});
}
async consumeData() {
// Now use the pre-created stream for data consumption
this.stream.on('data', (message) => {
console.log('Got message');
console.log(JSON.parse(message));
});
}
}
module.exports = EarthquakeEventConsumer;
此代码定义了一个名为 EarthquakeEventConsumer
的类,它作为包含地震事件数据的 Kafka 主题消息的消费者。以下是代码的分解:
-
node-rdkafka
中的Kafka
: 这个库提供了与 Kafka 交互作为消费者或生产者的功能。 -
createConfig
从../config/config
: 这从另一个文件(config/config.js
)导入了一个函数,用于读取配置详细信息。 -
path
: 这是一个内置的 Node.js 模块,用于操作文件路径。 -
EarthquakeEventConsumer
: 这个类负责消费地震事件数据。 -
constructor()
: 这是一个特殊的方法,在创建EarthquakeEventConsumer
的新实例时被调用。 -
configPath
: 这构建了指向包含 Kafka 连接详细信息(如代理和组 ID)的配置文件(如.env
文件)的路径。 -
appConfig
: 这调用从另一个文件导入的createConfig
函数,从.env
文件中读取配置详细信息,并将其存储在this.appConfig
中。这使得配置在整个对象的生命周期内可访问。 -
this.stream
: 这一行是关键部分。它使用Kafka.KafkaConsumer.createReadStream
来创建一个用于从 Kafka 读取消息的流。以下是传递给createReadStream
的选项所执行的操作:-
'metadata.broker.list'
: 这指定了要连接的 Kafka 代理列表,从存储在this.appConfig
中的配置中获得。 -
'group.id'
: 这设置消费者组 ID,也来自配置。同一组中的消费者将在彼此之间共享主题的消息。 -
'socket.keepalive.enable'
: 这启用了与代理保持连接活跃的机制。 -
'enable.auto.commit'
: 这设置为true
以启用自动提交偏移量。 -
topics
: 这指定了要消费的 Kafka 主题名称,从配置中获得(在这种情况下可能是librdtesting-01
)。 -
waitInterval
: 这设置为0
,表示如果没有可用的消息,则尝试接收消息之间没有等待时间。 -
objectMode
: 这设置为false
,意味着从流中接收到的消息将是原始缓冲区,而不是 JavaScript 对象。
关键的是,这个流创建只在构造函数中发生一次,确保效率。
-
-
async consumeData()
: 这是一个异步方法,用于启动数据消费过程。 -
.on('data', ...)
: 这设置了一个监听器,用于监听由预先创建的流 (this.stream
) 发出的数据事件。回调函数在每次收到新消息时执行,记录已收到消息,并解析 JSON 编码的数据以进行进一步处理。回调函数记录一条消息,表明已收到新消息。然后它使用JSON.parse
解析原始消息缓冲区(假设它是 JSON 编码的数据),并将解析后的数据记录下来。 -
module.exports = EarthquakeEventConsumer
: 这行代码导出EarthquakeEventConsumer
类,以便在应用程序的其他部分使用。
总结来说,代码定义了一个连接到 Kafka、订阅特定主题并监听传入地震事件数据的消费者。然后它解析 JSON 编码的消息并将它们记录到控制台。这里的改进之处在于在构造函数中只创建一次 Kafka 消费者流,使代码更高效。
要运行服务,我们有 app.js
和 index.js
,它们的结构与我们流式 API 的结构相同。
我们现在已经实现了 earthquakeConsumer
,现在是时候对其进行测试了:
-
如果您使用 Visual Studio Code,从菜单中选择 终端 | 新终端。
-
导航到
src
文件夹 (Ch08/earthquakeConsumer/src
). -
运行
node index.js
命令。
注意
您不需要每次想要启动应用程序时都手动导航到 src
文件夹并运行 node index.js
。相反,您可以通过在 package.json
文件中配置脚本来自动化此过程。只需将以下内容添加到 package.json
的 scripts
部分:
{
“``scripts”: {
“start”: “``node src/index.js”
}
}
-
一旦设置好,您可以通过简单地运行以下命令从项目的根目录启动应用程序:
npm start
这将自动启动应用程序,每次运行代码时都能节省您的时间和精力。当使用 Node.js 运行地震消费者服务时,以下输出确认服务已成功启动并准备就绪:
PS C:\packtGit\Hands-on-Microservices-with-JavaScript\Ch08\earthquakeConsumer> npm start Debugger listening on ws://127.0.0.1:62120/3f477ceb-6d5a-4d84-a98a-8f6185f8f11d For help, see: https://nodejs.org/en/docs/inspector Debugger attached. > earthquakeconsumer@1.0.0 start > node src/index.js Debugger listening on ws://127.0.0.1:62125/d84e3d2b-6be1-4a3f-8ba3-2bca4d1fe710 For help, see: https://nodejs.org/en/docs/inspector Debugger attached. earthquakeService streaming API to start the streaming process.
-
前往 Postman 并点击 发送。
-
虽然
earthquakeService
流式 API 打印 “消息已入队!”,但我们的消费者 API 将打印出如以下所示的消费数据:Got message { id: 's0iwb737f2', magnitude: 6.473388041641288, location: { latitude: -26.569165455403734, longitude: -167.263244317978 }, timestamp: 1725611270994 } Got message { id: 'agmk58tick6', magnitude: 1.9469044303512526, location: { latitude: -19.102647524780792, longitude: 58.15282259841075 }, timestamp: 1725611271106 }
您可以给这些服务添加一些额外的逻辑,但这应该足以尽可能简单地演示流式处理。
摘要
本章探讨了微服务架构中实时数据流的概念。我们使用地震数据流服务的例子来说明微服务如何高效地处理连续的信息流。
与存储大量数据不同,生产者服务以连续流的形式发布数据,允许在新的数据点到达时立即进行处理和分析。这种方法对于需要立即处理和分析的实时场景非常有用。
在这种情况下,另一个微服务充当消费者。它订阅由第一个服务产生的地震数据流。当新数据到达时,消费者微服务会实时接收并处理它。
消费者微服务可以根据地震数据执行各种操作。它可能会触发警报、更新仪表板,或与其他服务集成以进行进一步分析和响应。
使用微服务进行实时数据流提供了一种强大的方法来处理连续的信息流。在第九章中,您将学习如何通过身份验证、授权和 API 保护来保护微服务,同时实施日志记录和监控工具以主动检测和解决潜在问题。
第三部分:保护、测试和部署微服务
在本最后一部分,我们将重点关注保护、测试和部署微服务的关键方面。我们将学习如何通过实现身份验证、授权和监控工具来确保您的微服务安全可靠。本节还涵盖了构建 CI/CD 流水线的过程,这对于自动化微服务的部署至关重要,并以将我们的微服务部署到生产环境的策略结束。
本部分包含以下章节:
-
第九章, 保护微服务
-
第十章, 监控微服务
-
第十一章, 微服务架构
-
第十二章, 测试微服务
-
第十三章, 为您的微服务构建 CI/CD 流水线
第九章:保护微服务
在当今的数字世界中,许多应用程序是由较小的、独立的服务共同构建而成的。这些微服务提供了灵活性和可扩展性,但保持它们的安全至关重要。想象一下,微服务就像繁忙街道上的一家小商店。您希望确保只有授权的客户可以进入(身份验证)并且只有那些有权限的人可以访问特定区域(授权)。同样,您会加密敏感信息,如信用卡详情(数据加密)。通过不断监控可疑活动并保持商店更新(打补丁),您可以保持一个安全且安全的购物体验。本章将指导您使用类似实用的策略来保护您的微服务,并介绍更多内容!
本章涵盖以下主题:
-
微服务中的安全、身份验证和授权
-
开始使用 JSON Web Tokens
-
实现身份验证微服务
技术要求
要跟随本章内容,您需要安装一个 IDE(我们更喜欢 Visual Studio Code)、Postman、Docker 以及您选择的浏览器。
最好从github.com/PacktPublishing/Hands-on-Microservices-with-JavaScript/tree/main/Ch09
下载我们的存储库,以便轻松跟随我们的代码片段。
微服务中的安全、身份验证和授权
在微服务架构中,由于系统的分布式特性,确保强大的安全性、身份验证和授权至关重要。正确实施这些机制可以保护微服务免受未经授权的访问,确保整个系统中的数据完整性和机密性。
理解安全性
在微服务中,安全指的是用于保护系统组件、数据和通信通道免受未经授权访问、违规和攻击的措施和实践。它包括对每个服务进行单独的安全保护,以及服务之间的交互,确保数据在传输和静止状态下都是安全的。微服务中的安全通常包括加密、身份验证、授权和监控等机制,以保护系统免受漏洞的侵害。
微服务虽然提供了灵活性和可扩展性的优势,但也引入了独特的安全挑战。与具有单一攻击面的单体应用不同,微服务创建了一个具有许多潜在攻击入口点的分布式系统。这就是为什么与单体应用相比,安全性变得更加重要。一个微服务的安全漏洞可以迅速危及整个系统。在我们部署服务之前,我们应该在我们的微服务上提供一个经过充分测试且完全功能的安全层。
探索身份验证
身份验证是一个验证用户或服务身份的过程,它在确保微服务应用程序安全中起着关键作用。在一个拥有众多访问点的分布式系统世界中,身份验证确保只有授权用户和服务可以与您的微服务交互。
但为什么身份验证在微服务中很有价值呢?让我们在这里回答这个问题:
-
增强安全性:微服务创建了一个分布式攻击面。强大的身份验证充当守门人,防止未经授权的访问和潜在的违规行为。
-
细粒度控制:身份验证允许您为不同的用户和服务定义访问级别。这确保了只有授权实体可以在每个微服务内执行特定操作。
-
增强信任:通过实施强身份验证,您与依赖您的微服务的用户和外部系统建立信任。他们可以确信他们的数据是安全的。
-
微服务通信安全:身份验证确保了微服务之间的通信安全。这阻止了未经授权的服务冒充合法服务并访问敏感数据。
由于 Node.js 的包,应用身份验证并不困难,但在开始应用之前,您应该考虑一些微服务和身份验证挑战。我们将在本节中讨论其中的两个。
首个挑战是选择一个集中式或分布式的身份验证服务。决定采用集中式身份验证服务或将其嵌入到每个微服务中可能是一个挑战。在简单性和潜在的瓶颈之间存在着权衡。让我们在这里看看这两种类型的服务:
-
集中式身份验证服务,也称为身份提供者(IdP),是一个受信任的第三方系统,它管理着跨多个应用程序或微服务的用户身份验证过程。而不是每个微服务独立处理身份验证,IdP 承担这一责任,提供一致、安全和简化的身份验证机制。
-
分布式身份验证服务涉及每个微服务独立管理其自身的身份验证过程。与集中式系统不同,其中单个身份提供者(IdP)处理身份验证,分布式服务允许每个微服务拥有自己的嵌入式身份验证逻辑,为每个服务提供更大的自主性和灵活性,但同时也引入了维护一致性的复杂性。
在选择集中式和分布式身份验证之间,考虑因素如应用复杂性、可扩展性需求、安全容忍度和开发资源,因为集中式 IdP 简化了安全执行但增加了复杂性,而分布式选项需要每个微服务更多的开发工作。
如果您无法选择其中之一,那么一种混合方法可能更适合您的案例。在某些情况下,混合方法可能是一个不错的选择。中央身份提供者(IdP)可以处理用户身份验证并颁发令牌,而各个微服务可以独立验证这些令牌。这种方法在安全、灵活性和弹性之间提供了平衡。正如我们之前提到的,没有一种适合所有情况的解决方案。评估您的具体需求,并选择与您的安全目标和开发需求最佳匹配的方法。
第二个挑战可能是会话管理。传统的会话管理技术可能不适合微服务的无状态特性。JSON Web Tokens(JWTs)等替代方案通常更受欢迎。我们将在本章后面更详细地讨论 JWTs。
定义授权
在微服务中,授权至关重要,原因有几个,主要关注安全、资源管理和合规性。它确保只有具有适当权限的用户或服务才能访问或对特定资源或数据进行操作。这防止了未经授权的访问和潜在的滥用。通过执行严格的访问控制,潜在的攻击面被最小化。未经授权的用户被限制访问系统的敏感部分,从而降低了数据泄露和其他恶意活动的风险。
微服务通常处理广泛的职能和数据。授权允许对谁可以访问哪些服务以及他们可以执行哪些操作进行细粒度控制,确保资源得到适当使用。通过定义明确的访问控制,资源分配和利用更加高效,防止未经授权的资源消耗,这可能会降低系统性能。
在微服务架构中,每个微服务都被设计来执行特定的功能。授权确保每个服务只能访问它所需的数据和操作,促进了最小权限原则。这最小化了潜在的安全风险,并有助于保持一个安全、高效的系统。
通过在所有服务中统一定义和执行访问策略,集中式授权管理可以进一步简化此过程。这种方法简化了访问控制的维护和更新,使得确保微服务生态系统的一致性变得更加容易。
实施授权有多种方式,例如基于角色的访问控制(RBAC)、基于属性的访问控制(ABAC)和基于策略的访问控制(PBAC)。虽然这些内容超出了本书的范围,但通过采用适当的方法,您可以确保您的系统安全策略既强大又适应您的具体需求。
授权的最佳实践
最佳实践对于确保在微服务中管理访问控制和授权的稳健性、一致性和效率至关重要。让我们在这里看看一些这些最佳实践:
-
最小权限原则: 只授予用户或服务完成工作所需的权限,这有助于减少未经授权访问的机会。
-
集中式授权管理: 使用单一系统来管理所有微服务中谁可以访问什么,使其更容易维护并确保安全性。
-
• 定期审计和审查: 定期检查和审查谁有权访问什么,以确保一切安全且保持最新。
-
预留访问: 根据角色设置权限,确保用户和服务只能访问与其职责相匹配的内容。
-
令牌过期和撤销: 使用快速过期的令牌,并在需要时有一种取消它们的方法,以降低令牌被泄露的风险。
我们深入探讨了授权在微服务中的关键重要性及其在维护安全、资源管理和合规性中的作用。现在,让我们区分授权和认证。
认证与授权的区别
在微服务架构中,授权在确保资源和数据访问安全方面发挥着关键作用。在深入细节之前,我们需要了解并区分术语认证和授权。认证验证试图访问系统的用户或服务的身份。它通常涉及检查凭证,如用户名和密码、API 密钥或授权服务器发行的令牌。这类似于在建筑物的入口处检查您的身份证。
另一方面,授权确定经过验证的用户或服务可以在系统中执行哪些操作。它涉及根据用户角色、权限或与请求关联的属性执行预定义规则。这类似于一旦您被验证可以进入建筑物(认证),您的访问卡就决定了您可以进入哪些楼层或区域(授权)。
下面是认证和授权之间的关键区别:
-
目标: 认证回答“你是谁?”,而授权回答“你能做什么?”。
-
时间: 认证通常首先发生,然后是对特定操作的授权检查。
-
重点: 认证处理身份验证,而授权侧重于访问控制。
微服务和授权:
在单体系统中,授权通常是集中的。但微服务,由于其分布式特性,需要一种更分布式的授权方法。以下是一些常见的策略:
• 服务级授权: 每个微服务管理其资源和数据的授权。
• API 网关: 一个中央 API 网关可以在将请求路由到单个服务之前处理授权检查。
• 专用授权服务:一个独立的服务管理授权策略并在所有微服务中强制执行
应该选择哪一个?嗯,选择正确的方法取决于诸如系统复杂性、安全要求和可扩展性需求等因素。正如我们之前提到的,让我们一起来了解 JWT。JWT 在分布式系统中广泛用于身份验证和授权目的,如微服务,因为它是无状态的,这意味着服务器不需要存储会话数据。
开始使用 JWT
真实世界不断变化,程序需要能够适应处理不同的情况。程序元素也会被评估。你 10 年前使用的技巧可能现在不再有效。
几年前,我们曾经使用过基于会话的授权,它简单、流行、易于理解且易于适应。它仍然是一个讨论的话题,但我们更倾向于使用更安全的不同类型的身份验证技术。在切换到 JWT 之前,讨论基于会话的授权是有帮助的。
在这种类型的身份验证中,你输入用户名和密码。服务器检查你的凭据是否有效。如果有效,服务器会创建一个带有唯一标识符(会话 ID)的会话。这个会话 ID 可能会被存储在你的浏览器上的 cookie 中。在会话期间对网站的每次请求,你的浏览器都会将会话 ID 发送回服务器。服务器检查会话 ID,如果有效,则授予访问权限,允许你保持登录状态。会话在一段时间的不活动后(例如,30 分钟)或当你登出时过期。这会使会话 ID 无效。
另一方面,基于令牌的身份验证与基于会话的身份验证相比具有几个优势。你的会话依赖于服务器存储有关每个活跃用户的信息。对于拥有大量用户的程序,这可能会变得负担沉重。存储在客户端的令牌减轻了服务器上的这种压力。
第二个重要的区别是,基于会话的身份验证要求服务器为每个用户维护会话数据。基于令牌的身份验证是无状态的,这意味着服务器只验证令牌本身,而不引用任何存储的用户数据。这简化了服务器架构并可能提高性能。
从安全角度来看,令牌可以自包含,包括诸如过期时间和用户角色等信息。这减少了依赖于容易受到盗窃的 cookie 的依赖。此外,令牌可以被配置为短寿命,以最小化被破坏的机会。
令牌的另一个重要特性是灵活性。令牌,如 JWT,可以嵌入除用户身份之外的其他数据。这允许对访问进行更细粒度的控制,并简化授权过程。令牌还可以用于不同服务之间的 API 调用,而会话通常与特定的 Web 应用程序相关联。JWT 是一种紧凑、URL 安全的表示声明的方式,用于在双方之间传输。它通常用于授权目的。JWT 由三部分组成:头部、有效载荷和签名。这些部分由点(.
)分隔,并以 Base64 URL 格式编码。
头部通常由两部分组成:令牌类型(JWT)和正在使用的签名算法,例如HMAC-SHA256或RSA。下面是一个头部的示例:
{
"alg": "HS256",
"typ": "JWT"
}
有效载荷包含声明。声明是关于实体(通常是用户)及其附加数据的陈述。有三种类型的声明:
-
iss
(发行者),exp
(过期时间),sub
(主题)和aud
(受众)。 -
公共声明:用户可以定义的自定义声明。它们应该是具有抗碰撞性的名称,例如使用 URI 或命名空间以避免冲突。
-
私有声明:创建自定义声明以在同意使用它们的各方之间共享信息。
下面是一个有效载荷的示例:
{
"sub": "1234567890",
"name": "David West",
"admin": true,
"iat": 1516239022
}
最后一个元素是签名。要创建签名部分,您需要取编码后的头部、编码后的有效载荷、一个密钥以及头部中指定的算法。签名用于验证 JWT 的发送者是否为它所声称的人,并确保消息在传输过程中未被更改。
例如,如果您使用HMAC-SHA256算法,签名将按以下方式创建:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
算法的输出是三个由点连接的 Base64-URL 字符串,可以在 HTML 和 HTTP 环境中轻松传递。
下面是一个HMAC-SHA256输出的示例:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibm FtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.SflKxw RJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
现在我们已经了解了 JWT 的各个组成部分,让我们通过前面的示例来看看它在身份验证中的工作原理。用户使用其凭据登录。服务器验证凭据并颁发一个使用密钥签名的 JWT。客户端(通常是浏览器)将 JWT(通常在本地存储或 cookie 中)存储起来。
客户端在后续请求的Authorization
头中发送 JWT 以访问受保护资源:
Authorization: Bearer <token>
然后,它进行令牌验证,其中服务器验证令牌的签名并检查其有效性(过期时间、发行者等)。如果令牌有效,服务器将处理请求。在继续之前,重要的是要注意 JWTs 是无状态的、紧凑的且自包含的,这使得它们在不需要服务器端会话存储的情况下安全地传输用户信息非常有效。当使用 JWTs 时,确保密钥安全,始终通过 HTTPS 传输令牌,并使用短期令牌以及定期刷新来降低安全风险。现在我们知道 JWTs 是处理现代网络应用程序中认证和授权的一种强大且灵活的方式,它提供了安全和便利。
现在我们已经讲解了理论部分,接下来让我们进入实践环节,一起实现认证微服务。
实现认证微服务
在微服务开发中,为认证和授权(通常称为 Auth 服务)开发一个独立的微服务是一种常见的做法。以下是它的价值所在:
-
集中式安全管理:拥有一个专门的 Auth 服务允许我们在一个地方管理认证和授权逻辑。这简化了更新和安全审计,并确保所有微服务之间的一致性规则。
-
可伸缩性:Auth 服务可以根据其负载独立扩展,与其他具有不同资源需求的微服务分开。
-
可重用性:Auth 服务可以被所有其他微服务重用,减少代码重复并促进一致性。
-
提高可维护性:将认证逻辑隔离出来,使得维护和更新系统的安全方面变得更加容易。
-
关注点分离:将认证和授权从其他微服务中解耦,使它们的职责更加集中,促进代码的整洁性和更好的可维护性。
-
灵活性:专门的 Auth 服务可以被设计成支持不同的认证流程(例如 OAuth、JWT)和授权策略(例如 RBAC),为你的微服务架构提供一个灵活的基础。
让我们一起实现我们的 Auth 微服务。我们将使用之前所做的方式,通过 ExpressJS 开发我们的新微服务。大部分事情都是相同的。你应该在你的电脑上任何你想创建的位置打开/创建一个新的文件夹,并输入 npm init -y
来初始化我们的项目。在整个开发过程中,我们将使用以下库:bcryptjs
、dotenv
、express
、joi
、jsonwebtoken
和 mongoose
。
注意
npm init -y
命令不会自动在 package.json
中生成脚本部分。你需要手动添加它以简化应用程序的运行。
让我们浏览一下我们的 package.json
文件:
-
bcryptjs
: 这个库提供了安全的密码散列和比较功能。它允许您在数据库中安全地存储密码,并验证用户登录尝试与散列密码。 -
dotenv
: 这个库帮助您从.env
文件中加载环境变量。这是一种安全地存储敏感信息(如 API 密钥、数据库凭据和您的 JWT 密钥)的方法,将它们从代码中排除。 -
express
: 这是一个流行的 Node.js 网络框架,它帮助您构建 Web 应用程序和 API。它提供了一种结构化的方法来处理请求、路由、中间件和响应。 -
joi
: 这个库为进入您应用程序的数据提供模式验证。您可以为请求体定义验证规则,并确保接收到的数据符合您期望的格式和结构,从而提高数据完整性并防止潜在错误。 -
jsonwebtoken
(JWT): 这个库帮助您处理 JWT。它允许您生成用于身份验证的令牌,以安全且可验证的格式包含用户信息。您可以使用 JWT 在您的微服务中授权用户访问受保护资源。 -
mongoose
: 这是一个 Node.js 中 MongoDB 的对象数据建模(ODM)库。它通过将您的应用程序数据模型映射到 MongoDB 文档,提供了一个方便的方式来与 MongoDB 数据库交互。它简化了数据操作和检索。
我们需要一个安全的方式来存储用户信息,数据库通常用于此目的。mongoose
包将帮助我们与数据库交互。为了连接和断开与数据库的连接,在src/db
文件夹下创建一个名为index.js
的新文件,其内容与第五章中的内容相同。
在src/models
文件夹下,创建一个名为user.js
的新文件,包含以下代码块:
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true
},
password: {
type: String,
required: true
}
});
// Hash password before saving
userSchema.pre('save', async function (next) {
if (this.isModified('password')) {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
}
next();
});
module.exports = mongoose.model('User', userSchema);
您可以扩展此模式以包含更多信息,但为了演示身份验证和授权,我们只需要这些字段。
我们已经在之前的章节中讨论了mongoose
,这就是为什么我们将跳过已知细节。这里唯一的新逻辑是与散列密码一起工作。当我们通过 API 创建用户时,我们将提供一个电子邮件和密码。出于安全原因,我们需要在将密码存储到数据库之前对其进行散列。
以userSchema.pre("…")
开始的代码是一个mongoose
中间件函数,它在用户文档保存到数据库之前执行。这个中间件确保我们的数据库中永远不会以纯文本形式存储密码。在保存之前,它会安全地散列密码,使得从存储的散列中恢复原始密码在计算上是不切实际的。
现在,我们需要提供一个与数据库交互的服务层。为了简单起见,您可以跳过这一层,但为了提供一个完整的画面,我们将包括它。在src/services
文件夹下,创建一个名为user.js
的新文件,包含以下代码块:
const User = require('../models/user');
const createUser = async (userData) => {
const user = new User(userData);
await user.save();
return user;
};
const getUserById = async (userId) => {
return await User.findById(userId);
};
const getUserByEmail = async (email) => {
return await User.findOne({ email: email });
};
// ... Add methods for other user operations (e.g., update, delete)
module.exports = { createUser, getUserById, getUserByEmail };
为了简化,我们没有实现完整的 CRUD 操作。为了演示我们的功能,我们只需要其中的一些,例如 create
和 get user
。
现在,让我们切换到我们的控制器,看看我们如何创建一个用户。在 src/controllers
文件夹下,创建一个名为 user.js
的新文件,并包含以下代码块:
const userService = require('../services/user');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const path = require('path');
const { createConfig } = require('../config/config');
// Register a new user
const createUser = async (req, res) => {
try {
const { email, password } = req.body;
const existingUser = await userService.getUserByEmail(email);
if (existingUser) {
return res.status(400).json({ message: 'Email already
exists' });
}
const user = await userService.createUser({ email, password
});
res.status(201).json({ message: 'User created successfully',
user: user });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Server error' });
}
};
此代码片段定义了一个名为 createUser
的异步函数,用于处理 Node.js 应用程序中的用户注册。以下是详细信息:
-
const createUser = async (req, res) => { ... }
:这定义了一个名为createUser
的异步函数,它接受两个参数,req
(请求对象)和res
(响应对象)。 -
const { email, password } = req.body;
:这从请求体(req.body
)中提取了email
和password
属性。这些属性假设由客户端在注册请求中发送。 -
const existingUser = await userService.getUserByEmail(email);
:这调用userService
中的一个函数(用于检查是否已存在提供电子邮件的用户。它等待结果(existingUser
)。 -
if (existingUser) { ... }
:如果existingUser
不是null
(表示存在具有该电子邮件的用户),则返回一个包含表示电子邮件冲突的消息的400 Bad Request
响应。 -
const user = await userService.createUser({ email, password });
:如果电子邮件是唯一的,它调用userService
中的另一个函数(可能是用于用户创建的函数),传递一个包含提取的电子邮件和密码的对象。它等待结果(user
),这是新创建的用户文档。 -
.status(201).json({ message: 'User created successfully', user: user });
:如果用户创建成功,它发送一个包含消息和新生成的用户对象(user
)的201 Created
响应。 -
一个
try...catch
块:这将核心逻辑包裹在一个try
-catch
块中,以处理注册过程中可能出现的任何潜在错误。 -
res.status(500).json({ message: 'Server error' });
:在出现任何错误的情况下,它发送一个通用的500 Internal Server Error
响应。 -
createUser
:此函数为我们应用程序中的用户注册提供了一个基本结构。它检查电子邮件冲突,将用户创建逻辑委托给单独的服务,并使用适当的响应处理成功和错误情况。
但创建用户还不够。我们需要实现登录/登录功能。在同一个文件中,我们有以下代码用于登录:
const loginUser = async (req, res) => {
try {
const { email, password } = req.body;
// Fetch user by email
const user = await userService.getUserByEmail(email);
if (!user) {
return res.status(401).json({ message: 'Invalid email or
password' }); // Use 401 for unauthorized
}
// Compare password hashes securely using bcrypt
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).json({ message: 'Invalid email or
password' });
}
const configPath = path.join(__dirname, '../../configs/.env');
const appConfig = createConfig(configPath);
const payload = { userId: user._id }; // Include only essential user data
const jwtSecret = appConfig.jwt.access_token; // Replace with your secret from an environment variable
const accessToken = await jwt.sign(payload, jwtSecret, {
expiresIn: '1h' }); // Set appropriate expiration time
// Send successful login response
res.status(200).json({ accessToken: accessToken });
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Server error' });
}
};
代码定义了一个名为 loginUser
的异步函数,该函数用于处理 Express.js 应用程序中的用户登录。以下是它的功能分解:
-
const loginUser = async (req, res) => { ... }
:这定义了一个名为loginUser
的异步函数,它接受两个参数,req
(请求对象)和res
(响应对象)。 -
const { email, password } = req.body;
:这从请求体(req.body
)中提取了email
和password
属性。这些属性假设由客户端在登录请求中发送。 -
const user = await userService.getUserByEmail(email);
:这调用userService
(可能是另一个模块)中的函数来获取具有提供的电子邮件的用户。它等待结果(user
)。 -
if (!user) { ... }
:如果user
是null
(表示没有找到具有该电子邮件的用户),则返回一个包含表示凭据无效的消息的401 未授权
响应。 -
const isMatch = await bcrypt.compare(password, user.password);
:这使用bcrypt.compare
将提供的密码与从获取的用户文档(user.password
)中存储的散列密码进行比较。它等待结果(isMatch
),这是一个布尔值,指示密码是否匹配。 -
if (!isMatch) { ... }
:如果isMatch
是false
(表示密码不匹配),则返回一个包含表示凭据无效的消息的401 未授权
响应。 -
const configPath = path.join(__dirname, '../../configs/.env');
:这构建了环境变量文件的路径(假设它位于当前文件上方四个文件夹处)。 -
const appConfig = createConfig(configPath);
:这调用一个函数来读取和解析.env
文件中的环境变量。 -
const payload = { userId: user._id };
:这为 JWT 创建一个包含用户 ID 的有效载荷对象。这里只包含必要用户数据。 -
const jwtSecret = appConfig.jwt.access_token;
:这从解析后的环境配置中检索 JWT 访问令牌秘密。 -
const accessToken = await jwt.sign(payload, jwtSecret, { expiresIn: '1h' });
:这使用jsonwebtoken
使用有效载荷、密钥和一小时过期时间(expiresIn: '1h'
)来对 JWT 进行签名。它等待生成的令牌(accessToken
)。 -
res.status(200).json({ accessToken: accessToken });
:如果登录成功,它发送一个包含生成的accessToken
的200 OK
响应体。
总体而言,登录函数使用 JWT 认证提供了一个安全的登录流程。它获取用户、验证凭据、使用秘密密钥生成 JWT,并将其发送回客户端以进行后续的授权访问。
为了拥有一个完全功能的登录功能,我们需要提供秘密访问令牌。在 JWT 中,秘密访问令牌在确保令牌的完整性和真实性方面起着至关重要的作用。当创建 JWT 时,使用秘密访问令牌通过加密散列算法(例如 HMAC-SHA256)对头部和有效载荷(包含用户信息)进行签名。这个秘密密钥就像一个只有签发 JWT 的服务器和验证它的方才知道的密码。当客户端在授权头中发送 JWT 以访问受保护资源时,服务器接收该令牌。
服务器使用相同的秘密访问令牌来验证接收到的 JWT 的签名。此验证过程确保以下内容:
-
该令牌在传输过程中未被篡改。
-
该令牌确实是由一个可信的来源(知道秘密的服务器)签发的。
如果秘密访问令牌被泄露(例如,泄露或被盗),任何拥有秘密的人都可以伪造看起来有效的 JWT,可能冒充合法用户并获取对资源的未授权访问。因此,秘密访问令牌对于维护基于 JWT 的认证的安全性至关重要。永远不要在代码或应用程序内部存储秘密访问令牌。使用环境变量或专门的秘密管理服务来保持其机密性。为您的秘密访问令牌选择一个密码学上强大的随机字符串(理想情况下,至少 256 位),使其难以猜测或破解。考虑定期轮换您的秘密访问令牌以减轻潜在泄露的影响。通过遵循这些实践,您可以在应用程序中利用 JWT 的益处进行安全的认证,同时最大限度地减少与秘密访问令牌相关的风险。
在实现访问令牌时,请注意不要生成长期有效的访问令牌。长期有效的访问令牌存在安全风险,因为如果被盗,它们允许攻击者长时间访问,并且撤销它们是困难的。相比之下,刷新令牌通过允许发行短期访问令牌提供更好的安全性,限制了潜在的损害。它们还允许更细粒度的控制,因为泄露的令牌可以单独列入黑名单,而不会影响其他令牌,减少了频繁认证的需求并降低了服务器负载。
刷新令牌在用户便利性(避免频繁登录)和安全之间提供了一个良好的平衡。尽管与访问令牌相比,它们的过期时间更长,但它们的使用仅限于获取新的、短期有效的访问令牌。
在实施我们的刷新令牌之前,考虑为访问令牌提供有限的时间,因为短期访问令牌在泄露的情况下减少了滥用的风险,最小化了攻击者的机会窗口。在这个例子中,我们将它设置为5
分钟。五分钟后,给定的访问令牌将过期,我们应该将刷新令牌发送到新的端点以获取新的短期访问令牌。首先,让我们修改我们的登录端点以返回刷新令牌:
const jwtRefreshTokenSecret = appConfig.jwt.refresh_token;
const accessToken = await jwt.sign(payload, jwtSecret, { expiresIn: '5m' }); // Set appropriate expiration time
const refreshToken = await jwt.sign(payload, jwtRefreshTokenSecret, { expiresIn: '7d' });
// Send successful login response
res.status(200).json({ accessToken: accessToken, refreshToken: refreshToken });
我们使用相同的方法来获取两个令牌。对于刷新令牌,我们设置一个稍长的周期,例如七天。我们将创建一个新的端点来返回新的访问令牌,这就是为什么我们需要在我们的控制器中添加新的功能:
const getAccessTokenbyRefreshToken = async (req, res) => {
try {
const refreshToken = req.body.refreshToken;
if (!refreshToken) {
return res.status(400).json({ message: 'Missing refresh
token' });
}
const configPath = path.join(__dirname, '../../configs/.env');
const appConfig = createConfig(configPath);
const refreshTokenSecret = appConfig.jwt.refresh_token;
// Verify the refresh token
jwt.verify(refreshToken, refreshTokenSecret, (err, decoded) =>
{
if (err) {
return res.status(401).json({ message: 'Invalid
refresh token' });
}
const userId = decoded.userId;
// Generate a new access token
const newAccessTokenPayload = { userId };
const newAccessToken = jwt.sign(newAccessTokenPayload,
appConfig.jwt.access_token,
{ expiresIn: '5m' });
res.status(200).json({ accessToken: newAccessToken });
});
} catch (error) {
console.error(error);
res.status(500).json({ message: 'Server error' });
}
};
此功能允许用户通过提供刷新令牌(类似于备用钥匙)来获取新的访问令牌(访问资源的密钥)。它使用密钥检查刷新令牌是否有效。如果有效,它可以生成一个新的、短期有效的访问令牌(默认过期时间为 5 分钟)供用户使用。这样,用户不需要频繁登录,但仍然可以通过短期访问令牌保持安全性。
存储刷新令牌的最佳实践涉及安全性和用户便利性之间的平衡。以下是你需要了解的内容:
-
由于 JavaScript 的可访问性和通过 XSS 攻击的潜在盗窃,刷新令牌不应存储在浏览器 cookie 中。
-
如果使用 cookie,请选择带有
Secure
标志设置的HttpOnly
cookie。这阻止 JavaScript 访问并减轻 XSS 攻击。然而,这种方法有局限性(例如,在跨站上下文中,不是所有浏览器都支持)。 -
另一方面,本地存储是一个可行的选项,但实施安全措施,如静态和传输中的加密,以保护令牌在泄露时。评估提供安全本地存储机制的库或框架。
-
在某些场景中,你可能考虑将刷新令牌存储在服务器端(例如,数据库)以增加安全性或集中管理。然而,这并不总是必要的,并且会增加复杂性。
-
虽然刷新令牌的过期时间比访问令牌长(例如,几天或几周),但应避免过长的持续时间,以最大限度地减少泄露时的潜在损害。
-
实施一种机制,在一段时间的不活跃期(例如,一周)或用户登出后,将刷新令牌列入黑名单。这阻止攻击者无限期地使用被盗令牌。
-
考虑刷新令牌轮换。当使用刷新令牌颁发新的访问令牌时,生成一个新的刷新令牌并存储它。这减少了单个刷新令牌在较长时间内被泄露的风险。
-
如果性能和快速访问是首要任务,考虑使用 Redis 的速度和自动处理过期时间。然而,如果需要,解决潜在的持久性问题。
-
如果数据持久性和与现有数据库的集成至关重要,可以将刷新令牌存储在数据库中作为选项。然而,与 Redis 相比,评估潜在的性能影响。
要访问我们新的控制器功能,为它提供一个路由。打开routes/v1/users/index.js
文件并添加以下行:
router.post('/token', validate(loginSchema),userController.getAccessTokenbyRefreshToken);
就这些了。现在我们有了注册、登录和检索新访问令牌的端点。
我们将秘密令牌和刷新令牌存储在.env
文件中。在src
文件夹同一级别创建一个configs
文件夹,并添加一个包含以下内容的.env
文件:
PORT=3006
MONGODB_URL=mongodb://localhost:27017/auth-microservice #provide your MONGO_URL
SECRET_ACCESS_TOKEN={YOUR_SECRET_KEY}
SECRET_REFRESH_TOKEN={YOUR_REFRESH_TOKEN}
为了生成刷新和秘密令牌,执行以下操作:
-
打开终端并输入
node
。 -
在给定的输入窗口中,输入
require('crypto').randomBytes(64).toString('hex')
。
以下图示展示了你可以多么容易地完成它:
图 9.1:生成秘密令牌
每次调用最后一个命令时,你都会得到一个不同的生成结果。只需复制该值并将其粘贴到.env
文件中,与SECRET_ACCESS_TOKEN
配对。尝试第二次使用相同的注释以获取一个完全不同的值,并将其粘贴为SECRET_REFRESH_TOKEN
。刷新令牌和秘密令牌必须具有不同的值。
如您所知,我们有一个 src/config
文件夹,其中包含 config.js
,它可以以编程方式读取 .env
文件。我们向其中添加了读取令牌的功能。
下面是这个样子:
const dotenv = require('dotenv');
const Joi = require('joi');
const envVarsSchema = Joi.object()
.keys({
PORT: Joi.number().default(3006),
MONGODB_URL: Joi.string().required().description('Mongo DB url'),
SECRET_ACCESS_TOKEN: Joi.string().hex().required(),
SECRET_REFRESH_TOKEN: Joi.string().hex().required(),
})
.unknown();
function createConfig(configPath) {
dotenv.config({ path: configPath });
const { value: envVars, error } = envVarsSchema
.prefs({ errors: { label: 'key' } })
.validate(process.env);
if (error) {
throw new Error(`Config validation error: ${error.message}`);
}
return {
port: envVars.PORT,
mongo: {
url: envVars.MONGODB_URL,
},
jwt: {
access_token: envVars.SECRET_ACCESS_TOKEN,
refresh_token: envVars.SECRET_REFRESH_TOKEN
}
};
}
module.exports = {
createConfig,
};
此代码提供了一个名为 jwt
的对象,用于访问刷新和密钥令牌。
如您所猜测,我们尚未直接验证用户提供的数据。我们需要验证数据,这就是为什么我们计划使用与账户微服务相同的结构。在 src/middlewares
文件夹下,我们有相同的 validate.js
文件来验证我们的模式。这就是我们没有在每个端点实施验证的主要原因。现在是时候为电子邮件和密码验证提供规则了。在 src/validation
文件夹下创建 user.js
,并包含以下代码块:
const Joi = require('joi');
const loginSchema = Joi.object({
body: Joi.object().keys({
email: Joi.string()
.required()
.error(errors => {
if (errors[0].code === 'any.required') {
return new Error('Email is required');
}
if (errors[0].code === 'string.email') {
return new Error('Invalid email format');
}
return errors;
}),
password: Joi.string()
.min(6) // Minimum password length
.required()
.error(errors => {
if (errors[0].code === 'any.required') {
return new Error('Password is required');
}
if (errors[0].code === 'string.min') {
return new Error('Password must be at least 6 characters long');
}
return errors;
})
})
});
module.exports = { loginSchema };
此代码片段使用 Joi
定义了一个针对应用程序中登录请求的特定验证模式。它专注于请求体,确保它包含一个有效的电子邮件地址和密码,该密码满足最小长度要求(在本例中定义为六个字符)。该模式还提供了关于缺失或无效电子邮件和密码的详细自定义错误消息,通过引导用户到正确的凭据格式来改善用户体验。通过实施此验证,您可以防止格式错误的登录请求到达后端逻辑,并增强应用程序的整体安全性。
我们可以直接在我们的控制器中提供路由,但我们将遵循我们在实现账户微服务之前所遵循的相同约定。因此,在 routes/v1/users
文件夹下,创建一个包含以下内容的 index.js
文件:
const { Router } = require('express');
const userController = require('../../../controllers/user');
const { loginSchema } = require('../../../validation/user');
const validate = require('../../../middlewares/validate');
const router = Router();
router.post('/register', validate(loginSchema), userController.createUser);
router.post('/login', validate(loginSchema), userController.loginUser);
module.exports = router;
代码定义了两个端点。一个用于注册(/register
),另一个用于登录(/login
)功能。routes/v1
文件夹还包含一个包含以下内容的 index.js
文件:
const { Router } = require('express');
const userRouter = require('./users');
const router = Router();
router.use('/users', userRouter);
module.exports = router;
如您所见,我们使用了我们在账户微服务中使用的相同代码。我们只是将路由更改为 users
。现在,用户可以使用 v1/user/{endpoint_name}
访问我们的端点。我们微服务的最后元素是 app.js
和根 index.js
文件,它们与我们的已实现的账户微服务相同。
让我们测试我们的身份验证微服务的端点。从终端运行 npm start
,并准备我们的 POST
请求来创建用户:
-
打开 Postman 应用程序。
-
创建一个新的 Postman 请求窗口。
-
将 GET 改为 POST。
-
提供端点 URL(对我们来说它是
http://localhost:3006/v1/users/register
)。 -
前往 Body,选择 raw,然后选择 JSON。
-
提供一个有效载荷并点击 Send(图 9**.2)。
图 9.2:成功的注册
用户已准备好。现在,我们可以获取 JWT。以下是获取 JWT 的步骤:
-
创建一个新的 Postman 请求窗口。
-
将 GET 改为 POST。
-
提供端点 URL(对我们来说它是
http://localhost:3006/v1/users/login
)。 -
前往 Body,选择 raw,然后选择 JSON。
-
提供有效载荷并点击 发送(图 9**.3)。
图 9.3:成功登录
生成的访问令牌将在给定的时间段内过期(图 9**.4)。过期后,我们不需要提供电子邮件和密码来获取新的访问令牌。我们可以简单地使用刷新令牌来刷新并获取新的访问令牌。
图 9.4:关于过期令牌的消息
但如果你想要基于刷新令牌的新访问令牌呢?这很简单。下面是如何做的:
-
在 Postman 的新窗口中,将请求类型设置为
v1/users/token
端点。 -
打开 正文 部分,提供刷新令牌。点击 发送 按钮(图 9**.5)。
图 9.5:基于刷新令牌获取新的访问令牌
现在,你可以使用这个访问令牌来访问我们的账户微服务资源。嗯,就是这样。现在是时候在我们的账户微服务中测试 JWT 了。
集成基于令牌的认证到账户微服务
我们迄今为止实现的微服务还没有认证和授权功能。作为作业,你可以开始将它们集成进去,为了学习目的,我们将为账户微服务实现 JWT。打开我们迄今为止开发的账户微服务。为了使用来自 Auth 微服务的相同访问令牌,账户微服务应该使用相同的密钥令牌。打开 configs/.env
文件并添加以下行:
SECRET_ACCESS_TOKEN={USE_THE_SAME_TOKEN_YOU_USED_IN_AUTH_MICROSERVICE}
打开 config/config.js
文件,按照以下步骤修改以读取密钥令牌配置字段:
const dotenv = require('dotenv');
const Joi = require('joi');
const envVarsSchema = Joi.object()
.keys({
....
SECRET_ACCESS_TOKEN: Joi.string().hex().required(),
.....
})
.unknown();
function createConfig(configPath) {
.............
return {
..............
jwt: {
access_token: envVars.SECRET_ACCESS_TOKEN
}
};
}
module.exports = {
createConfig,
};
我们需要添加到账户微服务的唯一真正功能是一个用于验证我们的令牌的中间件。
在 src/middlewares
文件夹下,创建一个名为 verify.js
的文件,内容如下:
const jwt = require('jsonwebtoken');
const path = require('path');
const { createConfig } = require('../config/config');
const verifyJWT = (req, res, next) => {
const authHeader = req.headers.authorization;
// Check for presence and format of Authorization header
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
message: 'Unauthorized: Missing JWT token',});
}
const token = authHeader.split(' ')[1];
const configPath = path.join(__dirname, '../../configs/.env');
const appConfig = createConfig(configPath);
// Verify the JWT token
jwt.verify(token, appConfig.jwt.access_token, (err, decoded) => {
if (err) {
// Handle JWT verification errors
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({
message: 'Unauthorized: Invalid JWT token format',
});
} else if (err.name === 'TokenExpiredError') {
return res.status(401).json({
message: 'Unauthorized: JWT token expired',
});
} else {
// Handle other errors (e.g., signature verification failure)
console.error('JWT verification error:', err);
return res.status(500).json({
message: 'Internal Server Error',});
}
}
// Attach decoded user information to the request object
req.user = decoded;
next(); // Allow the request to proceed
});
};
module.exports = verifyJWT;
这段代码定义了一个名为 verifyJWT
的中间件函数,用于 Express.js 应用程序。它处理传入请求的 JWT 验证。它检查请求中是否存在授权头,并且以 Bearer
开头。如果没有,它将返回一个 401 未授权
响应,表明缺少 JWT。
如果头部存在并且格式正确,它将从授权头中提取 JWT 本身。我们的中间件构建了包含 JWT 密钥的环境变量文件的路径。它调用一个函数(可能来自一个单独的 config
模块)来读取和解析配置。
然后,我们使用 jsonwebtoken.verify()
函数来验证从配置中检索到的密钥密钥提取的令牌。如果验证失败(err
),它将检查错误类型:
-
JsonWebTokenError
:表示无效的令牌格式,返回带有特定信息的401
-
TokenExpiredError
:表示令牌已过期,返回带有特定信息的401
-
其他错误(例如签名验证失败)被记录,并出于安全原因发送通用的
500 内部服务器错误
响应。
如果验证成功(!err
),它将 JWT 中解码的用户信息附加到 req.user
对象中,以便在您的应用程序逻辑中进行进一步访问。
最后,它调用 next()
以允许请求继续到预期的路由处理程序。
总体而言,这个中间件充当守门人,确保只有具有有效 JWTs 的请求才能访问您应用程序中的受保护资源。
为了使用我们的中间件,我们在 app.js
文件中导入它并使用它:
const express = require('express');
const v1 = require('./routes/v1');
const consumerModule = require('./modules/kafkamodule');
const morganMiddleware = require('./middlewares/morganmiddleware');
const jwtVerifyMiddleware = require('./middlewares/verify');
const app = express();
app.use(jwtVerifyMiddleware);
app.use(morganMiddleware);
consumerModule();
app.use(express.json());
// V1 API
app.use('/v1', v1);
module.exports = app;
代码导入我们的中间件并使用它。现在,让我们运行账户微服务并尝试获取所有账户信息。执行以下操作:
-
导航到
Ch09/accountservice
文件夹。 -
为了正确运行账户微服务,您还需要从根目录运行
docker-compose
文件,使用docker-compose up -``d
命令。 -
执行完两个
docker-compose
设置后,通过运行npm start
命令启动账户微服务。 -
打开 Postman 并向
v1/accounts
发送一个GET
请求(在我们的例子中,它是http://localhost:3001/v1/accounts
)。 -
您将收到一个关于未授权请求的消息(图 9**.6)。
图 9.6: 未授权访问
- 现在,运行我们的身份验证微服务,并按照我们在身份验证微服务中提到的步骤操作以获取访问令牌(图 9**.3)。对于对账户微服务的相同查询,只需从 Postman 中打开 授权 部分,将授权类型更改为 Bearer Token,并将从身份验证服务中获得的令牌粘贴到输入框中(图 9**.7)。
图 9.7: 访问账户微服务
- 按下 发送 按钮,我们就可以开始了:
图 9.8: 获取账户微服务资源
如果您正确地遵循了步骤,现在您应该能够检索账户数据。在下一章中,我们将深入了解可观察性,并探讨如何使用 ELK 堆栈来实现它。
摘要
本章探讨了身份验证、授权的基本安全概念及其在保护微服务中的作用。我们阐明了验证用户身份(身份验证)和确定访问权限(授权)对于系统安全性的重要性。
为了实现这一点,我们实现了一个专门的微服务来颁发访问令牌(JWTs)和刷新令牌。JWTs 授予临时访问权限,而刷新令牌允许用户在不重新输入凭据的情况下获取新的访问令牌。一个实际演示展示了该微服务如何与另一个微服务,即账户微服务交互。
在我们接下来的章节中,我们将探讨微服务的监控及其在微服务架构中的重要性。我们将在账户微服务中实现日志记录功能,并将其与Elasticsearch、Logstash 和 Kibana(ELK)堆栈集成。这将建立一个集中式日志系统,实现高效的日志收集、分析和可视化。
第十章:监控微服务
微服务已成为构建可扩展和灵活应用程序的核心架构方法,但确保它们的健康和性能与它们的功能一样重要。没有适当的可见性,在如此分布式的系统中识别问题就像在海绵中寻找针一样。想象一下,将监控和日志记录视为在繁忙城市的不同部分放置摄像头和传感器,其中每个微服务都是一个商店。这些工具帮助您观察系统如何运行,捕捉关键事件,并检测任何异常行为。通过建立强大的日志和监控实践,您可以快速定位问题,并确保微服务平稳运行。
本章涵盖了以下主题:
-
可观察性的重要性
-
日志记录简介
-
使用Elasticsearch、Logstash 和 Kibana(ELK)堆栈进行集中式日志记录
技术要求
为了跟随本章内容,我们需要安装一个 IDE(我们更喜欢 Visual Studio Code),Postman,Docker 以及您选择的浏览器。
我们建议您从github.com/PacktPublishing/Hands-on-Microservices-with-JavaScript/tree/main/Ch10
下载我们的存储库,以便轻松跟随我们的代码片段。
可观察性的重要性
在软件的世界里,尤其是在微服务领域,可观察性至关重要。它通过分析其输出,使我们能够深入了解系统的工作方式。可观察性是监控和理解系统的重要概念。它指的是通过检查系统的输出而获得系统内部工作情况洞察的能力。让我们尝试理解其构建块:
-
日志:日志是系统内部发生事件的详细记录。它们提供了发生事件的记录,包括错误、警告和信息性消息。日志可以通过显示系统活动的逐步记录来帮助识别和诊断问题。
-
指标:指标是表示系统性能和行为数值。它们可以包括 CPU 使用率、内存消耗、请求速率和错误率等数据。指标提供了系统健康和性能的定量度量。
-
警报:当指标达到特定阈值时,会触发警报。它们用于实时通知管理员或操作员潜在问题或异常行为,以便快速响应问题。
-
跟踪:跟踪提供了对系统请求流程的详细视图。它们显示了请求如何从一个组件移动到另一个组件,突出了系统不同部分之间的交互和依赖关系。跟踪有助于理解请求的路径,并识别瓶颈或故障点。
可观察性通过使用日志、指标、警报和跟踪来帮助理解系统内部正在发生的事情。日志提供了事件的详细记录,指标提供了性能的数值数据,警报通知潜在问题,而跟踪显示了请求的流程。这些输出共同提供了一个系统的全面视图,有助于监控、故障排除和优化性能。
现在我们已经介绍了概念,让我们深入探讨微服务日志记录的世界。
日志记录简介
你有没有开过仪表盘损坏的汽车?速度表可能卡住,燃油表可能不可靠,警告灯可能神秘地闪烁。如果没有关于发动机运行状况的明确信息,就很难诊断问题或确保安全行驶。
在软件的世界里,尤其是在使用微服务构建的复杂系统中,日志记录扮演着类似的角色。日志是系统内部事件和活动的详细记录。
当你在构建微服务时,仅仅考虑业务实现是不够的。微服务本质上很复杂,有许多独立的服务相互交互。日志记录有助于理解单个服务的表现并定位特定服务中的问题。当事情出错时,日志提供了审计跟踪以诊断和修复问题。它们有助于识别错误、丢失的请求或性能瓶颈。每个微服务应用程序都应该有一个适当的日志记录机制。
日志记录微服务对于诊断至关重要,但它也带来了挑战,例如处理不同机器和语言之间的分布式日志的大量数据,这使得聚合和解释它们变得更加困难。此外,遗漏关键细节和确保日志中的敏感信息安全存储增加了有效管理日志的复杂性。
通过理解这些挑战,我们可以实施有效的日志记录策略,让我们的微服务团队保持沟通,并确保系统平稳运行。
日志级别和节点库
在实际示例之前,我们需要了解一些与日志记录相关的基础知识,其中之一就是日志级别。不同的日志级别用于对日志消息的严重性或重要性进行分类。
错误日志捕获需要立即关注的严重问题,例如崩溃或系统故障,而警告日志则突出可能需要调查的潜在问题。信息日志跟踪一般系统操作,调试日志提供详细的诊断信息,而跟踪日志提供最细粒度的日志记录,用于跟踪执行流程。
当然,您不需要从头开始实现日志算法。Node.js 的一个优点是它为我们提供了一系列酷炫的库供我们使用。当我们构建我们的微服务时,我们有不同的流行日志库可以集成和使用。当您记录微服务时,您可以使用 winston
、pino
、morgan
(Express.js 的日志中间件)、bunyan
、log4js
等等。我们将在本章中集成 winston
和 morgan
作为日志库,但选择其中一个取决于您。
日志格式
在 Node.js 微服务中,日志格式可以分为非结构化日志、结构化日志和半结构化日志。以下是每种日志的解释:
-
非结构化日志:非结构化日志涉及编写纯文本日志消息。这种格式简单直接,但可能更难通过程序解析和分析。以下是一个展示非结构化日志的示例:
const logger = console; logger.log('Server started on port 3000'); logger.error('Database connection failed: connection timeout'); logger.info('User login successful: userId=12345');
-
.csv
、.xml
或其他格式,但最常用的格式是 JSON。这种方法使得通过程序搜索、过滤和分析日志变得更加容易。以下是一个展示结构化日志的示例:{ "level": "error", "time": "2024-06-26T12:34:57.890Z", "service": "my-microservice", "buildInfo": { "nodeVersion": "v16.13.0", "commitHash": "abc123def456" }, "msg": "Failed to connect to database", "eventId": "evt-2000", "correlationId": "corr-67890", "stack": "Error: Connection timeout\n at Object.<anonymous> (/path/to/your/file.js:15:19)\n at Module._compile (internal/modules/cjs/loader.js:999:30)\n at Module.load (internal/modules/cjs/loader.js:985:32)\n at Function.Module._load (internal/modules/cjs/loader.js:878:14)\n at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:71:12)\n at internal/main/run_main_module.js:17:47", "source": { "file": "/path/to/your/file.js", "line": 15, "function": "logError" } }
-
半结构化日志:它结合了非结构化和结构化日志的元素。它通常涉及在纯文本日志中的一致模式或分隔符,这使得它们比完全非结构化的日志更容易解析,但不如完全结构化的日志健壮。
我们探讨了日志在微服务中的重要性,以及其挑战,并讨论了不同的日志级别、流行的 Node.js 日志库以及如何为您的微服务选择正确的日志格式。现在,让我们来看看日志的最佳实践。
日志的最佳实践
有效的日志可以帮助您理解系统行为、诊断问题和监控性能。以下是 Node.js 微服务日志的一些基本最佳实践:
-
使用结构化日志格式:确保日志是结构化的(例如,JSON),以便日志管理工具可以轻松解析和搜索。这有助于更有效地进行日志分析和过滤。
-
包含上下文信息:通过时间戳、服务名称、关联 ID 和用户信息等上下文信息丰富日志,以便在微服务之间进行更好的跟踪和关联。
-
在适当的级别记录日志:应用合适的日志级别(错误、警告、信息、调试、跟踪),根据严重性对日志消息进行分类,这有助于根据相关性过滤日志和进行故障排除。
-
避免记录敏感信息:在记录之前,确保敏感数据(如密码和个人详细信息)被删除或屏蔽,以保持安全和合规性。
-
集中日志:使用如 ELK 堆栈或基于云的日志服务之类的工具,在集中位置聚合所有微服务的日志,以实现简化的监控、分析和警报。
这些实践将帮助您确保您的日志记录既高效又安全,可扩展,从而更容易监控系统行为,诊断问题,并维护整体性能。
在您的微服务中实现日志记录
由于 Node.js 的包,实现日志记录实际上非常简单。在本节中,我们将使用winston
和morgan
来演示在微服务中日志记录的使用。让我们将日志支持集成到我们之前开发的Account
微服务中。为了跟随本章内容,请访问我们的 GitHub 仓库,并使用您喜欢的 IDE 下载源代码和Ch10
。我们计划将监控功能集成到我们在第九章中实现的微服务中。您只需复制Ch09
文件夹并开始工作即可。
要在账户微服务中安装winston
和morgan
库,请从accountservice
文件夹运行以下命令:
npm install -E winston morgan
现在,我们的package.json
文件应该包含适当的版本以使用这些库。让我们首先尝试使用winston
进行日志记录。在src/log
文件夹下创建一个名为logger.js
的文件,并包含以下内容:
const winston = require('winston');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
defaultMeta: {
service: "account-microservice",
buildInfo: {
version: '1.0.0',
nodeVersion: process.version
}
},
transports:
[new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}),
new winston.transports.File({
format: winston.format.combine(
winston.format.json(),
winston.format.timestamp()
),
filename: 'combined.log'
}),
new winston.transports.File({
format: winston.format.combine(
winston.format.json(),
winston.format.timestamp()
),
filename: 'error.log',
level: 'error'
})
]
});
module.exports = {
logger
};
此代码定义了 Node.js 中名为account-microservice
的应用程序的winston
日志记录器。让我们一步一步地分解代码:
-
const winston = require('winston');
: 这行代码导入了winston
库,这是一个流行的 Node.js 日志框架。 -
const logger = winston.createLogger({...});
: 这行代码创建了一个新的winston
日志记录器实例,并将其存储在logger
常量中。大括号({}
)包含日志记录器的配置选项。 -
level: process.env.LOG_LEVEL || 'info'
: 这设置了将被捕获的日志的最小严重级别。它首先检查LOG_LEVEL
环境变量。如果没有设置,则默认为'info'
级别。存在诸如'error'
、'warn'
、'info'
、'debug'
等级别,其中'error'
是最严重的。 -
defaultMeta
: 这定义了将被附加到每个日志消息的附加信息。这里,它包括服务名称(account-microservice
)和构建信息(版本和nodeVersion
)。 -
transports
: 这配置了日志消息将被发送到何处。这里,它是一个定义了三个传输的数组:winston.transports.Console
: 这将日志发送到控制台(通常是您的终端)
-
format: winston.format.combine(...)
: 这定义了当日志消息发送到控制台时将如何格式化。它组合了两个格式化程序:-
winston.format.colorize()
: 这为控制台输出添加颜色,以便更好地阅读。 -
winston.format.simple()
: 这以简单的文本格式格式化消息。
-
-
winston.transports.File({ filename: 'combined.log' })
: 这将所有日志(基于级别设置)发送到名为combined.log
的文件中。 -
format: winston.format.combine(...)
: 与控制台类似,它组合了格式化程序:-
winston.format.json()
: 这会将消息格式化为 JSON 对象,以便机器更容易解析。 -
winston.format.timestamp()
: 这会给每个日志消息添加时间戳。
-
-
winston.transports.File({ filename: 'error.log', level: 'error' })
: 这只会将错误级别的日志发送到名为error.log
的单独文件。它使用相同的格式化程序(json
和timestamp
)。 -
module.exports =[];:
这行代码使得创建的日志记录器(logger
)可以在应用的其它部分导入和使用。
总结来说,这段代码为我们的应用设置了一个全面的日志系统。它将消息记录到控制台和文件中,根据严重程度进行不同的格式化和过滤。这使我们能够轻松监控应用行为,调试问题,并分析日志以获得更深入的见解。
让我们将日志集成到accountController
中,看看结果。这里是一个简化的版本:
const accountService = require('../services/account');
const { logger } = require('../log/logger');
const getAccountById = async (req, res) => {
logger.info('getAccountById method called', { accountId: req.params.id });
….
当你调用负责执行getAccountById
方法的端点时,你将在终端看到一个日志消息和一个combined.log
文件。我们还把日志集成到我们应用的index.js
中,以查看应用运行是否一切正常:
{
"buildInfo": {
"nodeVersion": "v20.12.1",
"version": "1.0.0"
},
"level": "info",
"message": "account service started",
"port": 3001,
"service": "account-microservice"
}
{
"accountId": "6658ae5284432e40604018d5",
"buildInfo": {
"nodeVersion": "v20.12.1",
"version": "1.0.0"
},
"level": "info",
"message": "getAccountById method called",
"service": "account-microservice"
}
如果你有任何错误,你将在终端看到错误消息,并且它将自动添加到error.log
文件中。
在 Node.js 中,尤其是在使用 Express.js 构建 Web 应用时,morgan
包是一个流行的工具,用于简化 HTTP 请求日志记录。它自动捕获并记录有关应用接收到的请求的信息。
这里是为什么你可能需要使用它的原因:
-
morgan
通过自动捕获诸如请求方法、URL、状态码、响应时间等信息来消除这一点。这节省了开发时间并确保了日志记录的一致性。 -
morgan
提供了关于你的应用如何处理请求的宝贵见解。这对于调试目的至关重要,可以帮助你识别应用请求处理中的潜在问题或性能瓶颈。 -
监控应用流量:通过审查日志,你可以更好地了解应用流量模式。这对于监控整体应用健康、识别使用趋势以及就扩展或资源分配做出明智决策非常有用。
-
(
combined
,common
, 和dev
),它们针对不同级别的细节。你也可以创建自定义格式来捕获与你的应用需求相关的特定数据点。
我们已经安装了morgan
包,现在是时候使用它了。我们通常将其用作中间件,以下是如何实现自己的morgan
中间件的步骤。在src/middlewares
文件夹下创建一个名为morganmiddleware.js
的新文件。将以下内容复制粘贴到其中:
const fs = require('fs');
const path = require('path');
const morgan = require('morgan');
const { logger } = require('../log/logger-logstash');
const morganFormat = JSON.stringify({
method: ':method',
url: ':url',
status: ':status',
responseTime: ':response-time ms',});
// Path to the combined.log file
const logFilePath = path.join(__dirname,
'../../combined.log');
// Create a write stream for the log file
const logFileStream = fs.createWriteStream(logFilePath,
{ flags: 'a' });
// Custom message handler function for logging
function messageHandler(message) {
const parsedMessage = JSON.parse(message.trim());
// Write log to logstash
logger.info('Request received for logging',
parsedMessage);
// Also write the log to combined.log file
logFileStream.write(`${message}\n`);
}
// Create morgan middleware with custom format and stream
const morganMiddleware = morgan(morganFormat, {
stream: {
write: messageHandler,
},
});
module.exports = morganMiddleware;
此代码定义了一个用于使用 morgan
库以 JSON 格式记录 HTTP 请求的自定义中间件函数。该代码定义了一个使用 morgan
中间件在 Node.js 应用程序中记录 HTTP 请求的日志机制。它将日志记录与 combined.log
文件和 Logstash 服务器集成,以进行外部日志管理。
morganFormat
是一个自定义格式,它记录每个请求的 HTTP 方法、URL、状态码和响应时间等详细信息。然后,这些日志由自定义的 messageHandler
函数处理。
在 messageHandler
中,传入的日志消息被解析为一个 JSON 字符串到对象。然后,解析后的日志通过 logger.info
函数发送到 Logstash,该函数是从 logger-logstash
模块导入的。同时,原始的日志消息也被写入一个名为 combined.log
的本地文件。这是通过使用 Node.js 的 fs
模块创建一个写入流到文件来完成的,它将每个新的日志追加到文件中。
最后,使用 morgan
函数创建了自定义的 morganMiddleware
,日志流被定向到 messageHandler
。然后,这个中间件被导出,以便在其他应用程序部分用于日志记录目的。
此设置确保 HTTP 请求日志既记录在本地文件中,也发送到外部 Logstash 服务进行进一步处理。
我们已经完成了中间件功能,现在是时候应用它了。打开 app.js
,这是我们配置中间件流程的地方,并进行以下更改:
const morganMiddleware = require('./morganmiddleware');
const app = express();
app.use(morganMiddleware);
在中间件流程中的所有其他内容之前,我们需要使用 morganMiddleware
,现在你可以通过 winston
移除之前所做的日志记录函数。运行应用程序并调用你想要的任何端点。在运行账户微服务之前,确保 Docker 正在运行,并使用适当的 docker-compose
文件。别忘了运行两个 docker-compose
文件(accountservice/docker-compose.yml
和 accountservice/elk-stack/docker-compose.yml
)。
这是日志记录的终端输出:
图 10.1:日志的终端输出
检查 combined.log
文件和终端窗口以查看日志。
在下一节中,我们将介绍集中式日志记录。
使用 Elasticsearch、Logstash 和 Kibana (ELK) 堆栈进行集中式日志记录
在微服务架构中,应用程序被分解为独立、松散耦合的服务,集中式日志记录对于有效的监控和故障排除变得至关重要。我们有充分的理由使用它:
-
分散的日志:通常,日志会散布在各个微型应用程序中。想象一下在它们之间寻找问题——就像在杂乱的房子里找一只丢失的袜子一样!
-
一次性查看所有内容:集中式日志记录将所有日志集中在一个地方,就像把所有的袜子都放在篮子里一样。这样,你可以轻松地看到一切是如何工作的,以及是否有任何部分引起麻烦。
-
更快地解决问题:所有日志都在一个地方,就像有一个超级放大镜来查找问题。您可以快速搜索日志以查看发生了什么,节省您的时间和挫败感。
-
关注细节:集中式日志记录通常与监控工具一起工作,就像有一个袜子的仪表板。这使您能够看到一切的表现如何,并识别任何缓慢的区域。
-
日志管理变得简单:将所有内容集中在一起使得管理日志变得更加简单。就像有一个专门的袜子抽屉!可以使用工具来保持事物有序,删除旧日志,并遵循您需要遵循的任何规则。
通过使用集中式日志记录,您将获得一个强大的工具来监控您的微服务,更快地解决问题,并保持一切运行顺畅。
在构建微服务时,我们有多种不同的选项来实现集中式日志记录,其中之一就是 ELK 堆栈。
ELK 堆栈是一套强大的工具,用于集中式日志记录、实时搜索和数据分析。以下是每个组件的简要概述:
-
Elasticsearch:这是一个分布式搜索和分析引擎。我们使用它来快速存储、搜索和分析大量数据,几乎实时。Elasticsearch 建立在 Apache Lucene 之上,并为与您的数据交互提供了一个 RESTful 接口。
-
Logstash:这是一个服务器端数据处理管道,可以同时从多个来源摄取数据,对其进行转换,然后将它发送到您选择的stash,例如 Elasticsearch。它可以处理各种数据格式,并提供丰富的插件来执行不同的转换和增强。
-
Kibana:这是一个用于分析并可视化存储在 Elasticsearch 中的数据的数据可视化和探索工具。它提供了一个用户友好的界面,用于创建仪表板和执行高级数据分析。
但它们是如何协同工作的呢?好吧,Logstash 从各种来源(例如,服务器日志、应用程序日志、网络日志)收集和处理日志数据,并将其转发到 Elasticsearch。Elasticsearch 索引并存储数据,使其几乎实时可搜索。Kibana 连接到 Elasticsearch,并提供查询、可视化和分析数据的工具,使用户能够创建自定义仪表板和报告。
使用 ELK 堆栈有多个好处:
-
可扩展性:ELK 堆栈可以水平扩展,允许您处理大量日志数据。
-
实时洞察:Elasticsearch 的实时搜索功能提供了对数据的即时洞察。
-
灵活性:Logstash 从各种来源和格式摄取数据的能力使其非常灵活。
-
可视化:Kibana 丰富的可视化选项使您能够创建用于监控和分析的交互式仪表板。
-
开源:ELK 堆栈是开源的,拥有庞大的社区和丰富的插件和扩展。
和往常一样,我们更喜欢通过 Docker 安装工具,这也适用于 ELK 堆栈。转到 Ch10/accountservice/elk-stack
文件夹,并使用 docker-compose up -d
命令运行 docker-compose.yml
文件。我们不会深入探讨 docker-compose
的细节,因为我们已经在之前的章节中介绍过了。简单来说,我们在给定的 docker-compose.yml
文件中安装了 Elasticsearch、Logstash 和 Kibana。
Logstash 的简要介绍
我们可以使用 Logstash 收集和转换日志。我们能够从多个不同的来源获取输入,例如其他应用程序生成的日志、纯文本或网络。对于日志摄取,我们有不同的方法可以遵循:
-
直接传输: 我们可以将我们的应用程序配置为直接向 Elasticsearch 发送数据。是的,这是一个选项,但不是一种理想的日志摄取方式。
-
将日志写入文件: 正如我们在微服务中实现的那样,实现此类类型的日志记录是更可取的,因为其他应用程序,如作为独立进程的 Logstash,将能够读取、解析并将数据转发到 Elasticsearch。这需要更多的配置,但它是更健壮且更可取的生产日志记录方式。
Logstash 配置通常写入配置文件(例如,logstash.conf
)。此文件由三个主要部分组成:input
、filter
和 output
。每个部分定义了数据处理管道的不同方面。以下是每个部分的分解和示例配置:
-
input
部分定义了 Logstash 应从何处收集数据。这可能是文件、syslog、传输控制协议(TCP)/ 用户数据报协议(UDP)端口或其他各种来源。 -
filter
部分用于处理和转换数据。过滤器可以解析、丰富和修改日志数据。常见的过滤器包括用于模式匹配的grok
、用于修改字段的mutate
和用于解析日期/时间信息的date
。 -
output
部分指定了处理后的数据应发送到何处。这可能是 Elasticsearch、文件、消息队列或其他目的地。
要查看详细说明的实际操作,只需打开 logstash.conf
文件:
input {
tcp {
port => 5000
}
}
filter {
json {
source => "message"
}
}
output {
elasticsearch {
hosts => ["elasticsearch:9200"]
index => "app-%{+YYYY.MM.dd}"
}
stdout { }
}
让我们深入了解给定配置的细节:
-
tcp { port => 5000 }
: 此部分定义了一个input
插件,它监听端口5000
上通过 TCP 套接字传入的数据。任何发送到该端口的日志或事件都将被 Logstash 摄取。 -
json { source => "message" }
:此filter
插件解析传入的数据,假设其格式为 JSON,并从名为message
的字段中提取值。这个字段很可能是实际日志内容所在的位置。通过将其解析为 JSON,Logstash 可以理解数据的结构,并在后续处理步骤中使其更容易处理。*elasticsearch { hosts => ["elasticsearch:9200"], index => "app-%{+YYYY.MM.dd}" }
:此output
插件将处理后的数据发送到 Elasticsearch,这是一个针对处理大量日志数据进行了优化的搜索和分析引擎。主机选项指定了 Elasticsearch 实例的位置(可能运行在名为elasticsearch
的机器上,默认端口为9200
)。*index
选项定义了一个动态索引命名模式。每天的日志将存储在名为app-YYYY.MM.dd
的单独索引中(其中YYYY
代表年份,MM
代表月份,dd
代表日期)。这种模式有助于高效的日志管理,并允许你轻松地搜索特定日期的日志。*stdout { }
:此output
插件简单地将处理后的数据打印到控制台(标准输出),用于调试或监控目的。空的大括号 ({}
) 表示标准输出的默认配置。
此 Logstash 配置从 TCP 源摄取数据,解析 JSON 格式的日志,然后将它们发送到 Elasticsearch 进行存储和分析。为有序日志管理创建每日索引。stdout
插件提供了一种在开发或故障排除期间查看处理数据的方法。
让我们将日志集成到我们的账户微服务中。在 accountmicroservice/src/log
文件夹下创建一个名为 logger-logstash.js
的新文件,内容如下:
const winston = require('winston');
const LogstashTransport = require('winston-logstash
/lib/winston-logstash-latest.js');
const serviceName = 'account-microservice'
const logstashTransport = new LogstashTransport({
host: 'localhost',
port: 5000
})
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(winston
.format.timestamp(), winston.format.json()),
defaultMeta: {
service: serviceName,
buildInfo: {
nodeVersion: process.version
}
},
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}),
logstashTransport
]
})
module.exports = {
logger
};
我们已经讨论了 winston
配置,这里唯一的新内容是 logstashTransport
。我们添加了两个 transports
通道:一个用于终端的控制台,另一个用于将日志发送到 logstash
。要使用给定的文件与 morgan
一起使用,只需将 morganmiddleware.js
的记录器更改为以下内容:
const { logger } = require('./log/logger-logstash');
现在,运行我们的应用程序并访问 http://localhost:5601
;这是我们使用的 Kibana。从左侧菜单中,找到 管理 | 开发工具。点击 执行 按钮,你将看到日志的总值(图 10.2.2*)
图 10.2:从 Kibana 获取日志
现在,我们的日志正在流入 ELK 堆栈。你可以将 Elasticsearch 视为一个具有数据仓库功能的搜索和分析引擎。
对 Elasticsearch 的简要介绍
Elasticsearch 是一个为速度和可扩展性而构建的强大搜索引擎。在其核心,它是一个分布式系统,旨在几乎实时地存储、搜索和分析大量数据。它是面向文档的,并使用 JSON。
让我们更深入地了解 Elasticsearch 的关键属性:
-
分布式:Elasticsearch 可以在集群中的多个节点(服务器)上存储数据。这种分布允许以下操作:
-
容错性:如果一个节点失败,其他节点可以处理请求,保持您的搜索服务正常运行。
-
横向扩展:随着您的数据量或搜索流量的增长,您可以轻松地向集群添加更多节点。
-
-
可扩展性:如前所述,Elasticsearch 在横向扩展方面表现出色。您可以向集群添加更多节点来处理不断增长的数据和搜索需求。这种可扩展性使其适用于大型数据集和高搜索量。
-
搜索和分析:Elasticsearch 专注于全文搜索,它分析您文档的整个文本内容。这使得您能够在数据中搜索关键词、短语,甚至概念。它还提供了强大的分析功能。您可以聚合数据,识别趋势,并从搜索结果中获得洞察。
-
灵活的搜索:Elasticsearch 提供了广泛的查询选项。您可以搜索特定术语,根据各种标准过滤结果,并执行复杂的聚合。这种灵活性允许您根据特定需求定制搜索,并从数据中挖掘有价值的信息。
-
搜索速度:由于其分布式架构和高效的索引技术,Elasticsearch 提供了快速的搜索结果。这对于用户期望对其查询立即做出响应的应用程序至关重要。
在本节中,我们简要概述了 Elasticsearch,重点关注使其成为搜索和数据分析强大工具的核心属性。
对 Kibana 的简要介绍
Kibana 是我们 ELK 堆栈中的最后一项。它是数据存储和搜索功能的可视化层,补充了 Elasticsearch 的数据存储和搜索能力。它是一个开源平台,充当您 Elasticsearch 数据的窗口,让您能够通过清晰的可视化来探索、分析和理解它。
Kibana 有以下有趣的潜力:
-
可视化强大:Kibana 允许您创建包含各种图表、图形和地图的交互式仪表板。这种视觉表示将原始数据转化为易于消化的洞察。
-
数据探索:Kibana 提供了在 Elasticsearch 中探索、搜索和过滤数据的工具。您可以深入了解特定细节,并揭示隐藏的模式。
-
共享洞察:创建的仪表板可以与他人共享,促进协作并推动数据驱动的决策。
选择 Kibana 用于微服务有以下几个令人信服的理由:
-
无缝集成:作为 ELK 堆栈(Elasticsearch、Logstash 和 Kibana)的一部分,Kibana 与 Elasticsearch 无缝集成。这种紧密集成简化了在 Elasticsearch 中可视化存储数据的流程。
-
实时洞察:Kibana 允许您几乎实时地可视化数据,在数据流进来时提供有价值的见解。这对于需要立即对变化做出响应的应用程序至关重要。
-
自定义选项:Kibana 提供了广泛的可视化自定义选项。您可以根据自己的特定需求定制仪表板,并有效地向您的受众传达见解。
-
开源和免费:作为开源软件,Kibana 可以免费使用,并提供了一个充满活力的社区以支持和发展。
微服务架构涉及多个独立服务协同工作。Kibana 在此环境中表现出色,原因有以下几点:
-
监控性能:在 Kibana 仪表板上可视化微服务的关键指标,以监控其健康和性能。这有助于识别瓶颈并确保平稳运行。
-
日志分析:在 Kibana 中集中和分析来自所有微服务的日志。这种统一的视图简化了故障排除和定位系统中的错误。
-
应用洞察:通过在 Kibana 中可视化使用模式和趋势,了解用户如何与您的微服务互动。这些数据可以指导开发工作并改善用户体验。
学习 ELK 堆栈,深入了解 Elasticsearch 查询和与 Kibana 相关的主题,例如自定义仪表板,以及与指标一起工作,这些都超出了本书的范围,这就是为什么我们只通过一个简单的介绍来结束本章。
摘要
本章深入探讨了微服务架构中监控和日志记录的关键方面,强调了可观察性在维护分布式系统健康和性能中的重要性。我们首先解释了可观察性如何通过日志、指标、警报和跟踪等关键组件提供对系统行为的深入洞察。
然后,我们将重点转向微服务中日志记录的重要性,这对于捕获系统事件的详细记录、识别性能瓶颈和实时诊断问题至关重要。我们探讨了不同的日志级别——错误、警告、信息、调试和跟踪——并讨论了它们如何根据严重程度对日志消息进行分类,从而提高故障排除效率。此外,本章还涵盖了 Node.js 中流行的日志库,如 winston
和 morgan
。
在理论基础上,我们通过将 winston
和 morgan
集成到账户微服务中,展示了如何在现实场景中实现日志记录。
本章接着转向集中式日志记录,介绍了强大的 ELK 堆栈。我们解释了 Logstash 如何收集和处理日志数据,Elasticsearch 如何存储和索引数据以进行实时搜索,以及 Kibana 如何通过交互式仪表板可视化信息。通过整合这些工具,我们建立了一个集中式日志系统,简化了日志收集、分析和可视化。
在下一章中,我们将探讨如何有效地使用流行的微服务架构元素来管理多个微服务。你将了解如何使用 API 网关,它作为一个单一入口点来管理请求并将它们引导到正确的服务,以及通过 CQRS 和事件溯源这两种重要方法来组织系统内的数据和操作,这些方法有助于处理复杂的数据流。到结束时,你将清楚地理解以高效且易于维护的方式构建和连接服务的方法。
第十一章:微服务架构
软件开发的世界正在不断演变。随着应用程序复杂性的增加,传统的单体架构难以跟上步伐。本章深入探讨了一些关键设计模式,这些模式赋予开发者构建可扩展和弹性系统的能力——API 网关、命令查询责任分离(CQRS)、事件溯源以及服务注册和发现。
这些模式,尤其是在微服务架构中联合使用时,提供了许多好处。它们促进了服务之间的松散耦合,使得它们更容易独立开发、维护和部署。它们还通过允许根据特定需求对单个服务进行扩展来增强可伸缩性。此外,这些模式有助于提高容错性和弹性,确保即使个别服务遇到问题,应用程序也能保持稳健。
本章将全面介绍这些模式中的每一个,概述其核心概念、优势和用例。我们将探讨如何应用其中的一些模式来构建现代、可扩展应用程序的坚实基础。通过理解这些模式,您将能够设计并开发能够在软件开发不断变化的领域中茁壮成长的应用程序。
本章涵盖了以下主题:
-
开始使用 API 网关
-
CQRS 和事件溯源
-
微服务中的服务注册和发现
让我们进入本章内容!
技术要求
为了跟随本章内容,我们需要一个集成开发环境(我们更倾向于 Visual Studio Code),Postman,Docker 以及您选择的浏览器。
建议从github.com/PacktPublishing/Hands-on-Microservices-with-JavaScript
下载仓库,并打开Ch11
文件夹,以便轻松跟随我们的代码片段。
开始使用 API 网关
API 网关通过充当中央枢纽,管理客户端应用程序和分布式微服务之间的通信,与微服务架构集成。当我们构建微服务时,我们希望它们能够独立开发、部署和扩展,而不影响客户端应用程序。客户端仅与 API 网关交互,这可以保护他们免受底层微服务网络的复杂性。
图 11.1:一个简单的 API 网关
API 网关接收来自客户端的请求,并根据请求内容或 URL 智能地将它们路由到适当的微服务(们)。它可以处理简单的路由或复杂场景,涉及多个微服务共同完成一个请求。让我们探讨将 API 网关集成到我们的微服务架构中的重要性:
-
简化客户端交互:客户端有一个集中的入口点/单一联系点(即 API 网关)来与应用程序交互,无论涉及多少个微服务。这减少了客户端的开发复杂性。
-
提高可扩展性:API 网关可以独立扩展以处理增加的流量,而不会影响单个微服务。微服务也可以根据其特定的工作负载独立扩展,突显了 API 网关的重要性。
-
增强安全性:API 网关的集中式安全管理加强了整体应用程序的安全性。API 网关可以实施身份验证、授权和其他安全策略,以保护微服务免受未经授权的访问。
-
减少开发复杂性:开发者不需要在每个微服务中实现诸如路由、安全和监控逻辑等功能。API 网关集中处理这些跨领域关注点。
让我们看看 API 网关是如何工作的。
API 网关的工作原理
在微服务架构中,API 网关充当所有客户端请求的中央入口点。它在管理和优化客户端与后端服务之间的通信流中发挥着关键作用。通过处理身份验证、路由、负载均衡和其他重要功能,API 网关确保微服务保持松散耦合和可扩展。
下面是 API 网关通常处理客户端请求的步骤分解:
-
客户端请求:客户端(例如,一个 Web 或移动应用)向 API 网关发送请求。请求包括诸如 HTTP 方法、URL 路径、头部和可能的内容体等详细信息。
-
请求处理:API 网关接收请求并检查其内容。根据 URL 路径或其他路由规则,网关确定哪个后端服务应该处理该请求。
-
身份验证和授权:API 网关会检查请求中的身份验证令牌(例如,JWT 或 OAuth 令牌)。它验证令牌的有效性并检查客户端是否有访问请求资源的必要权限。
-
请求转换:API 网关可能会修改请求以适应后端服务的需求。这可能包括更改协议、修改头部或修改请求体。
-
路由和聚合:网关将请求路由到适当的后端服务。如果请求涉及多个服务,网关将处理与每个服务的通信并将它们的响应聚合为单个响应发送给客户端。
-
缓存和负载均衡:网关检查响应是否已缓存,以便快速提供而无需击中后端服务。它还将在后端服务的多个实例之间分配请求负载,以平衡流量并提高性能。
-
速率限制和节流:API 网关强制执行速率限制,以控制客户端在指定时间段内可以发出的请求数量。如果客户端超过允许的请求速率,它可能会限制请求。
-
响应处理:一旦后端服务响应,网关可能会在将其发送回客户端之前修改响应。这可能包括添加或删除头信息、转换数据格式或聚合多个响应。
-
日志记录和监控:API 网关记录请求和响应的详细信息,以便进行监控和分析。跟踪的指标包括请求数量、响应时间和错误率,以监控服务的健康和性能。
既然我们已经了解了 API 网关的工作原理,让我们看看在特定情况下更好的选择是单一还是多个 API 网关。
单个与多个 API 网关
你可以在微服务架构中实现多个 API 网关,但这并不总是最直接或推荐的方法。在某些情况下,这可能会带来好处,但通常,出于简单性和可维护性的考虑,单个 API 网关更受欢迎。
当你希望实现集中管理、一致的客户端体验和简化的可扩展性时,单个 API 网关是理想的——所有这些都有助于简化 API 操作并降低复杂性。
虽然单个网关通常更受欢迎,但在某些情况下可能需要考虑使用多个网关:
-
异构客户端类型:如果你有使用截然不同协议或通信风格的客户端(例如,移动应用、Web 应用和遗留系统),可以使用单独的 API 网关来满足这些特定需求,使用自定义协议或功能。然而,这种做法在长期维护中可能会变得复杂。
-
物理分离:如果你的微服务地理分布在不同的数据中心或云区域,出于性能考虑,你可能考虑在每个位置放置一个 API 网关。然而,这会引入额外的管理开销,以保持网关之间的一致性。
-
安全分区:在非常具体的、对安全性敏感的场景中,你可以在应用程序的不同安全区域内实现单独的 API 网关。这允许对某些微服务的访问进行更严格的控制。然而,这需要仔细的设计和专业知识,以避免创建不必要的复杂性。
通常,单一 API 网关的好处超过了使用多个网关的潜在优势,因为前者促进了简单性、可维护性和一致的客户端体验。
如果你想要获得多个 API 网关的好处而又不增加复杂性,这里有一些替代方案:
-
按客户端类型路由的 API 网关:考虑使用一个单一的 API 网关,其路由逻辑能够区分不同的客户端类型并相应地定制响应。
-
微服务外观:在部分微服务中实现一个外观模式(稍后将详细介绍),以处理特定的客户端交互,可能减少对多个网关的需求。
在实施多个 API 网关之前,你应该仔细考虑你的具体需求。在大多数情况下,一个设计良好的单一 API 网关将为你的微服务架构提供最佳解决方案。
外观模式
在这个上下文中,外观指的是在部分微服务中实现一个层,专门处理与客户端的交互。而不是引入多个 API 网关,这可能会增加复杂性,微服务外观充当一个简化的接口或前端,为客户端抽象微服务的内部工作。
是时候实施并看到 API 网关在实际中的力量了。下一节将深入探讨 API 网关实际实施的细节。
使用 API 网关实现微服务
使用不同的形式和不同的库实现 API 网关模式是可能的。在我们的仓库中,Ch11
/ApiGateway
文件夹就是这种情况。
为了展示 API 网关模式的实际价值,我们需要至少有两个微服务。需要至少两个微服务来展示 API 网关模式真实价值的原因是,该模式旨在处理多个服务并整合它们的功能以供客户端使用。在本章中,我们将使用以下两个微服务:
-
后端微服务
-
用户微服务
实现后端微服务
我们的第一个微服务,后端微服务,作为 jsonplaceholder
服务的包装/抽象。jsonplaceholder
是一个免费在线服务,提供带有伪造数据的 REST API。它通常被开发者用来轻松访问和利用看起来真实的样本数据(用户、帖子、评论等),而无需设置自己的数据库。这使得他们可以快速测试 API 端点、前端功能以及用户交互。
-
创建一个新的文件夹(在我们的例子中是
post-microservice
文件夹)。 -
运行
npm install express axios
来安装所需的包。
你的 package.json
应该看起来像这样:
{
"dependencies": {
"axios": "¹.7.2",
"express": "⁴.19.2"
}
}
对于所有章节,你不需要安装列出的确切包版本。虽然我们的重点是使用包本身而不是特定版本,但如果新版本中有重大更改或破坏性差异,请参阅官方文档以获取更新。
现在,让我们在我们创建的文件夹(即 post-microservices
)中创建一个名为 server.js
的新文件,并使用以下代码块:
const express = require('express');
const axios = require('axios'); // Requires the axios library for making HTTP requests
const app = express();
const port = 3001; // Port on which the server will listen
app.get('/posts/:id', async (req, res) => {
const postId = req.params.id; // Extract the ID from the URL parameter
try {
const response = await axios.get(
`https://jsonplaceholder.typicode.com/posts/${postId}`);
const post = response.data;
if (post) {
res.json(post); // Send the retrieved post data as JSON response
} else {
res.status(404).send('Post not found'); // Respond with 404 if post not found
}
} catch (error) {
console.error(error);
res.status(500).send('Internal Server Error'); // Handle errors with 500 status
}
});
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
此代码片段使用 Express 框架创建一个简单的 Web 服务器,监听端口 3001
。它导入 axios
库以发送 HTTP 请求。服务器有一个单一的路径 /posts/:id
,它响应 GET
请求。当对该路径发出请求时,它会从 URL 中提取 id
参数。然后服务器异步地向 https://jsonplaceholder.typicode.com/posts/${postId}
发送请求以获取特定的帖子。如果找到帖子,它将帖子数据作为 JSON 响应发送。如果没有找到帖子,它将返回 404
状态码。如果在请求过程中出现任何错误,它将记录这些错误并以 500
-状态码响应,表示内部服务器错误。
使用 node server.js
命令运行我们的微服务,并测试是否一切正常。打开您喜欢的浏览器,导航到 localhost:3001/posts/1
(图 11.2).
图 11.2:帖子微服务的响应
实现用户微服务
我们的第二个微服务被称为 用户微服务。它的实现与我们的帖子微服务大致相同,但端口不同(3002
)和服务抽象(GitHub 服务抽象)不同:
const express = require('express');
const axios = require('axios'); // Requires the axios library for making HTTP requests
const app = express();
const port = 3002; // Port on which the server will listen
app.get('/users/:id', async (req, res) => {
const userId = req.params.id; // Extract the ID from the URL parameter
try {
const response = await
axios.get(`https://api.github.com/users/${userId}`);
const user = response.data;
if (user) {
res.json(user); // Send the retrieved employee data as JSON response
} else {
res.status(404).send('User not found'); // Respond with 404 if employee not found
}
} catch (error) {
console.error(error);
res.status(500).send('Internal Server Error'); // Handle errors with 500 status
}
});
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
使用 node server.js
命令运行我们的微服务,并测试是否一切正常。打开您喜欢的浏览器,导航到 localhost:3002/users/1
(图 11.3).
图 11.3:用户微服务的响应
让我们构建我们的 API 网关作为第三个微服务,并将帖子微服务和用户微服务结合起来。
开发 API 网关
实现了两个微服务后,我们准备好展示 API 网关的价值和力量。我们计划为 API 网关实现速率限制、缓存和响应聚合功能。在理解了基本知识后,您可以添加更多功能,如日志记录、适当的异常处理、监控和其他有趣的行为。
首先,您需要理解 API 网关本身就是一个独立的微服务。因此,为它创建一个新的文件夹(在我们的 GitHub 仓库中称为 api-``g``ateway
)。我们有 package.json
,内容如下:
{
"dependencies": {
"apicache": "¹.6.3",
"axios": "1.7.2",
"express": "4.19.2",
"express-rate-limit": "7.3.1"
}
}
我们将使用 express-rate-limit
包在我们的微服务中实现速率限制功能。在微服务架构中,应用程序被分解成更小、更独立的微服务时,速率限制是一种用于控制服务在特定时间段内可以接收的请求数量的技术。它就像交通控制器一样,防止服务因请求激增而超载。
相反,apicache
用于实现 API 网关的缓存行为。缓存是指允许您将后端服务的响应存储一段时间内的功能。然后,这些缓存数据可以服务于后续请求,提高性能并减少后端负载。
让我们创建一个server.js
文件来实现 API 网关。我们导入的包看起来像这样:
const express = require('express');
const apicache = require('apicache');
const axios = require('axios');
const rateLimit = require('express-rate-limit');
首先,让我们配置我们的速率限制:
const limiter = rateLimit({
windowMs: 60000, // 1 minute window
max: 100, // 100 requests per minute
message: 'Too many requests, please slow down!'
});
我们使用express-rate-limit
来控制用户在一分钟内可以访问您的 API 网关的次数。它就像一个守门人。如果一个用户在一分钟内请求少于一百次,他们可以通过。如果他们超过一百次,他们将收到一个Too many requests, please slow down
(请求过多,请慢点)的消息。这保护了我们的 API 免受过载,并确保每个人都能有一个良好的用户体验。我们将在指定端点路由时使用此limiter
对象。让我们继续并实现数据聚合:
async function getAggregatedData(id) {
const postResponse = await axios.get(
`http://postmicroservice:3001/posts/${id}`);
const userResponse = await axios.get(
`http://usermicroservice:3002/users/${id}`);
const aggregatedData = {
data: {
id: userResponse.data.login,
followers_url: userResponse.data.followers_url,
following_url: userResponse.data.following_url,
subscriptions_url:
userResponse.data.subscriptions_url,
repos_url: userResponse.data.repos_url,
post: postResponse.data
},
location: userResponse.data.location
};
return aggregatedData;
}
这个函数,getAggregatedData
,从两个不同的微服务中检索数据以构建一个组合响应。它接受一个 ID 作为输入:
-
首先,它使用
axios.get
进行两次独立的异步调用。一次从端口号为3001
的帖子微服务中获取帖子数据,另一次从端口号为3002
的用户微服务中获取用户数据。 -
然后,它将数据合并成一个名为
aggregatedData
的单个对象。包括用户数据,如位置、跟随者的 URL 以及通过 URL 被跟随的人。此外,从第一次调用中检索到的帖子数据被添加到键post
下。 -
最后,该函数返回包含有关用户及其帖子所有相关信息的
aggregatedData
对象。
通过在 API 网关中聚合数据,我们向客户端应用程序提供了一个简化的 API。它们只需要调用网关内的单个端点(/users/:id
),即可接收组合的用户和帖子数据,而不是分别对每个微服务进行单独调用。
例如,当请求localhost:3000/users/1
时,我们应该从帖子微服务和用户微服务中获取用户信息。以下是获取多个微服务聚合数据的方法:
app.get('/users/:id', limiter, async (req, res) => {
const id = req.params.id;
try {
const aggregatedData = await getAggregatedData(id);
res.json(aggregatedData);
}
catch {
res.status(400).json({ success: false, message:
'Bad request' });
}
});
此代码使用 Express.js 定义了一个 API 网关的路由处理器。它处理对/users/:id
URL 路径的GET
请求,其中:id
是一个动态参数,代表用户 ID。在路由处理器函数之前应用了limiter
中间件,这确保只有允许的请求(通常基于之前的代码,每分钟一百次)可以继续。在函数内部,API 从请求参数中提取 ID。然后调用getAggregatedData
函数以异步检索和合并用户和帖子数据。如果成功,该函数发送包含检索到的聚合数据的 JSON 响应。如果在数据检索过程中出现错误,它将发送一个状态码为400
(错误请求)和通用错误消息的响应。
我们 API 网关的最后一个功能是缓存。我们需要将以下代码片段添加到 server.js
文件中:
let cache = apicache.middleware;
app.use(cache('5 minutes'));
使用此代码,我们对所有类型的端点应用五分钟的缓存。
我们的基础设施(帖子微服务、API 网关和用户微服务)已经完成;现在是时候测试它们全部一起了。
在 Docker 中测试 API 网关
要测试 API 网关,您可以单独运行每个微服务,但如您所知,我们在 getAggregatedData
函数中为微服务有不同的名称 – http://post-microservice:3001
和 http://user-microservice:3002
。为了使这些微服务正常工作并且不必每次都运行每个微服务,我们将它们容器化。
对于每个微服务,我们都有 Dockerfile
,如下图所示:
图 11.4:API 网关项目结构
Dockerfile
是一个包含构建 Docker 镜像指令的文本文件。它就像一个食谱,告诉 Docker 如何采取步骤来创建一个为您的应用程序提供自包含环境的容器。
所有三个 Docker 文件完全相同,内容如下:
FROM node:alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD [ "node", "server.js" ]
此 Dockerfile
为 Node.js 应用程序创建一个镜像。它从一个轻量级的 Node.js 基础镜像开始,安装依赖项,复制您的整个项目,然后在启动时运行您的服务器代码。
在我们的根目录中有一个 docker-compose.yml
文件,它将结合这三个 Dockerfile
文件并将它们组合起来:
services:
post-microservice:
build:
context: ./post-microservice
dockerfile: Dockerfile
ports:
- 3001:3001
user-microservice:
build:
context: ./user-microservice # Correct the path if necessary
dockerfile: Dockerfile
ports:
- 3002:3002
api-Gateway:
build:
context: ./api-Gateway
dockerfile: Dockerfile
ports:
- 3000:3000
depends_on:
- post-microservice
- user-microservice
此 docker-compose.yml
文件定义了一个多容器应用程序。它创建了三个服务 – post-microservice
、user-microservice
和 api-gateway
。每个服务都从单独的目录(例如,./post-microservice
)使用共同的 Dockerfile
构建自己的镜像。
每个服务都在特定的端口上公开(帖子为 3001
,用户为 3002
,网关为 3000
)。
api-Gateway
依赖于 post-microservice
和 user-microservice
在其启动之前处于活动状态,以确保依赖项可用。要组合这些微服务的 Docker 文件,导航到包含 docker-compose.yml
文件的文件夹,并运行 docker-compose up -d
命令。它应该一起构建和运行组合服务。以下是使用 Docker 一起运行所有必需服务的外观:
图 11.5:API 网关在 Docker 中的表现
从您的浏览器导航到 localhost:3000/users/1
,您应该得到以下聚合数据:
图 11.6:API 网关在实际应用中的表现
到目前为止,我们已经探讨了 API 网关在微服务架构中的作用,强调了它如何通过充当路由、安全和负载均衡的中心入口点来简化客户端交互。我们学习了 API 网关如何从多个微服务中聚合数据,应用缓存和速率限制,并增强可伸缩性。通过将其集成到我们的架构中,我们提高了性能和安全性,同时保持了单个微服务的灵活性和独立性。最后,我们使用 Docker 容器化了微服务和 API 网关,以实现高效的测试和部署。
在下一节中,我们将探讨其他有趣的模式,例如 CQRS 和事件溯源。首先,我们将了解它们是什么,以及为什么我们会使用它们。
CQRS 和事件溯源
CQRS 是一种在分布式系统(通常是微服务)中使用的软件设计模式,用于分离读写操作。这种分离提供了几个优点,尤其是在处理具有高读写差异或复杂数据模型的应用程序时。
当你申请使用分布式架构的应用程序的工作时,你经常会听到关于 CQRS 的消息,很可能会被问到其使用情况。首先,我们需要理解的是,CQRS 不是一个架构风格;它既不是架构也不是架构原则。它只是一个没有广泛使用的模式。那么,CQRS 是什么?在回答这个问题之前,让我们了解 CQRS 试图解决的问题。
传统的单体应用程序通常使用单个数据库来读取和写入数据。随着应用程序的增长,这种方法可能会导致以下挑战:
-
扩展瓶颈:当读流量激增时,可能会影响写性能(反之亦然)。
-
数据模型不匹配:最优的读写模型可能不同。读取可能从去规范化数据中受益以实现更快的检索,而写入可能需要规范化结构以保持数据完整性。这种不匹配会导致低效或重复。
-
事务冲突:更新和读取可能会竞争资源,可能相互阻塞或导致不一致(违反ACID(原子性,一致性,隔离性,持久性)原则)。
-
优化挑战:优化读取可能会妨碍写性能,反之亦然。
当我们与单体应用一起工作时,我们通常使用一个单一的数据存储。这意味着我们在同一个数据库中有多个读写指令。我们使用相同的数据存储模型,当只涉及一个单一存储时,开发方面的一切都很简单。但是,这真的是全部吗?好吧,当我们只有一个数据存储时,并不是所有事情都顺利。根据我们的需求,我们可能需要将我们的数据库分成读数据库和写数据库。
理解 CQRS
CQRS 帮助我们将数据存储区分为读取和写入数据存储。为什么?一个原因是我们需要优化我们的读写操作。使用 CQRS,我们可以优化我们的读取数据存储以有效地读取数据。我们还可以配置我们的模式以优化读取操作。同样适用于写入数据存储。
当我们有独立的数据存储时,根据负载,我们可以独立扩展它们。当我们有独立的读取和写入数据存储时,我们可以根据每个的具体负载要求独立扩展它们。这在读取操作需求高的应用程序中特别有用。通过解耦读写操作,我们可以扩展读取数据存储以处理负载,而不会影响写入数据存储的性能,反之亦然。这种方法允许更有效的资源分配,确保每个数据存储都针对其特定角色进行了优化。
在 CQRS 中,读取和写入是分离的存储,我们有两个不同的数据模型。现在我们可以专注于优化和构建它们,以支持仅一个操作——要么是读取,要么是写入。
总结一下,以下是 CQRS 的好处:
-
改进性能:优化的读写模型可以显著提高读写操作的性能。
-
增强的可扩展性:您可以根据其访问模式独立扩展读写模型。这允许您更有效地处理波动性的读写负载。
-
数据建模的灵活性:每个模型都可以根据其特定目的进行设计,从而提高整体数据管理并减少复杂性。
CQRS 是不是银弹?当然不是。当您将 CQRS 集成到项目中时,应考虑以下因素:
-
增加复杂性:与单一存储相比,实现 CQRS 引入了额外的复杂性。为了成功实施,需要进行仔细的设计和权衡分析。
-
数据一致性:在读取和写入模型之间保持一致性需要仔细考虑。可以采用诸如最终一致性或物化视图等策略。
CQRS 是一种适用于具有高读写差异(例如,具有频繁的产品查看和较少购买的电子商务)的应用程序的有价值模式,具有对读写操作有不同要求的复杂数据模型,以及需要独立扩展读写操作的场景。
在采用 CQRS 之前,仔细分析您应用程序的需求。虽然它在特定场景中提供了显著的好处,但增加的复杂性可能对于更简单的应用程序来说并不必要。
讨论 CQRS 时,讨论事件源也很重要。它们是互补的模式,可以很好地一起工作,但它们解决了应用程序架构的不同方面。
事件源
事件溯源是一种将数据作为一系列事件持久化的设计模式。你不会存储实体的当前状态(例如用户账户),而是记录每个修改该实体的操作。这创建了一个不可变的历史变更记录,允许你执行以下操作:
-
回放事件以在任何时间点重建状态。
-
深入了解应用程序的历史,以便进行审计和调试。
-
简化数据演变,因为可以添加新事件而无需修改现有事件。
这些事件代表已经发生的事情,而不是数据的当前状态。通过回放事件流,你可以在任何时间点重建状态。CQRS 中的传统数据库可以用于写入模型(即存储命令)。事件溯源在 CQRS 的读取模型方面表现出色。事件溯源的事件流作为读取模型的真实来源。读取模型是通过回放相关事件构建的实体化投影。
然而,非常重要的一点是要注意,CQRS 可以在不使用事件溯源的情况下实现。事件溯源在管理读取模型时通常受益于 CQRS,因为这两种模式在许多场景中都很好地协同工作:
-
CQRS 通过使用优化的读取模型来高效地处理大量读取操作。
-
事件溯源提供了完整的记录来构建这些读取模型。
-
事件流的更新会自动触发读取模型中的更新,确保一致性(尽管可能适用最终一致性)。
事件溯源与事件流
事件流与事件溯源不同,尽管它们密切相关且经常一起使用。关键区别在于,事件流是一种在不同系统部分之间或不同系统之间传输一系列事件的机制。事件流侧重于事件的交付,确保它们被感兴趣的各方接收。它可以用于各种目的,例如实时通知、数据管道或触发其他微服务的操作。
相反,事件溯源是一种数据持久化模式,其中实体的整个变更历史都存储为一系列事件。它侧重于事件作为系统真实来源的存储和利用。这些事件用于回放历史并在需要时重建数据的当前状态。
这里有一个类比来更好地理解。想象一下事件流就像一个直播——它持续地向任何订阅者提供更新(事件)。事件溯源就像一个详细的日志——它保留所有过去更新的永久记录,供将来参考。
但这两者是如何联系起来的呢?事件溯源通常利用事件流来高效地存储和传输事件的序列。事件溯源的事件流可以被订阅了它的其他系统或服务使用。一些事件存储(稍后将进一步讨论),这些是专门为事件溯源设计的事件存储数据库,可能具有内置的事件流功能。本质上,事件流是一个更广泛的概念,用于描述数据在运动中的状态。事件溯源利用事件流来保存其事件历史。
让我们快速看一下事件存储。
事件存储
在我们的 CQRS 和事件溯源生态系统中,我们需要考虑的另一个元素是一个事件存储。这是一种专门类型的数据库,专门设计用于存储事件的序列。与传统关注数据当前状态的关系型数据库不同,事件存储将每个实体所做的每个更改记录为一个独特的事件。这创建了一个不可变的历史记录,记录了所有已发生的行为,从而带来了以下好处:
-
可审计性和调试:通过审查事件序列,您可以轻松跟踪更改并识别问题。这提供了关于发生了什么、何时以及为什么的详细日志。
-
数据演变:随着您的应用程序的发展,可以添加新的事件到存储中,而无需修改现有逻辑。这使得适应不断变化的需求而不会破坏现有功能变得更加容易。
-
可回放性:通过以特定顺序回放事件流,您可以在任何时间点重建实体的状态。这对于各种目的都很有用,例如重建物化视图或灾难恢复。
-
可扩展性:事件存储通常优化以处理大量写入操作,这使得它们非常适合具有频繁数据更改的事件驱动架构。
从本质上讲,事件存储不仅捕获了更改的完整且不可变的历史记录,而且还增强了灵活性和可扩展性。通过保存修改实体状态的每个事件,事件存储为可靠的审计跟踪、轻松适应新的业务需求以及按需重建状态提供了基础。这些功能使其成为现代架构中不可或缺的组件,尤其是在高数据吞吐量和问责制重要的领域。
这就是事件存储通常的工作方式:
-
事件:每个对实体的操作或更改都表示为一个事件。这些事件包含有关更改的相关数据,例如时间戳、用户 ID 和特定的修改。
-
只追加:事件以只追加的方式存储,这意味着一旦添加后,它们不能被修改或删除。这确保了事件历史的不可变性。
-
事件流:每个实体通常都有自己的事件流,这是一系列与该实体相关的所有事件。
事件存储通常通过将每个实体或更改表示为事件来工作。这些事件捕获有关更改的相关信息,例如发生时间、负责的用户以及修改的特定细节。一旦事件被记录,它将以追加方式存储,这意味着一旦添加后就不能被更改或删除。这确保了事件历史保持不可变,提供了一个可靠的审计跟踪。此外,每个实体都与自己的事件流相关联,这是一个与该特定实体相关的所有事件的按时间顺序序列。这个流允许你根据存储中记录的事件序列,从实体的初始状态追踪到其当前形式。
事件存储提供了以下显著的优点,使其非常适合现代架构,特别是那些由事件驱动的架构:
-
其中一个关键优势是创建一个不可变历史记录。系统中的每一次更改都以事件的形式存储,确保过去的行为不会被篡改或更改。这创建了一个可靠、防篡改的审计跟踪,允许你跟踪实体的完整生命周期,这对于调试、合规性和历史分析特别有用。
-
在可伸缩性方面,事件存储被设计成高效地处理大量写入操作。由于事件是追加到存储中而不是修改现有记录,因此它们可以支持频繁数据变更的应用程序,并确保性能保持一致,即使数据量增长。这使得它们成为需要处理大量数据或处理实时事件流的系统的绝佳选择。
-
另一个重要的好处是数据演化。随着应用程序的演变和新业务需求的产生,事件存储允许你在不影响现有功能的情况下进行适应。可以添加新事件来反映系统中的变化,同时保留旧事件数据,从而保留完整的历史记录。这种灵活性简化了随着时间的推移演化应用程序的过程,同时保持与先前数据版本的向后兼容性。
-
可重放性是事件存储的另一个重要特性。通过重放事件流,你可以重建实体在任何时间点的状态。这种能力对于灾难恢复、重建物化视图,甚至模拟过去系统状态以进行分析或测试非常有价值。它赋予你回顾过去并确切了解实体如何达到当前状态的能力,这是传统数据库所无法实现的,因为传统数据库只存储数据的最新状态。
这些好处使事件存储成为构建可伸缩、灵活和健壮系统的强大工具,尤其是在事件驱动架构中,维护更改的详细历史记录至关重要。
这里是使用事件存储的挑战:
-
查询:传统的数据库查询技术可能不直接适用。在事件流上设计高效的查询可能需要不同的方法。
-
复杂性增加:与传统的数据库相比,事件存储需要不同的数据管理思维。
最后,让我们看看一些流行的事件存储选项,包括以下内容:
-
EventStoreDB:领先的专用事件存储解决方案。
-
Apache Kafka:一个可以用于事件存储的分布式流平台。
-
传统数据库(经过修改):例如 PostgreSQL 这样的关系型数据库可以被配置为仅追加功能,以作为基本的事件存储。
总结来说,事件存储库是构建事件驱动架构和需要详细变更历史、数据演变能力和弹性的应用的宝贵工具。
理论已经足够了;现在是时候将 CQRS 和事件溯源付诸实践了。
实现 CQRS 和事件溯源
让我们创建一个简单的应用程序,该程序使用 CQRS 和事件溯源。我们的应用程序将允许我们将多个支付机制附加到我们的账户上。我们将能够注册支付机制到账户中,禁用它们,并启用它们。我们将使用 NestJS,但你也可以使用任何其他框架。在 Ch11
的 CQRS_EventSourcing
文件夹中,我们的 Git 仓库中有 cqrs_app
文件夹。你可以下载它以正确地跟随本章,但另一种选择是从头开始实现一切,正如我们在这里计划做的那样:
-
创建任何文件夹并打开你喜欢的 IDE。将空文件夹加载到你的 IDE 中,然后在命令行中输入
npx @nestjs/cli new cqrs_app
或npm i -g @nestjs/cli
并使用nest
new cqrs_app
。 -
这应该在文件夹中安装 NestJS 模板。现在,让我们安装所需的包:
npm i @nestjs/cqrs npm i @eventstore/db-client npm i uuid EventStoreDB for Docker. You can easily run it using a simple Dockerfile; however, to operate it with all the necessary infrastructure components, you’ll need to compose them together in the future. Create a docker-compose.yml file with the following content:
services:
eventstore.db:
image: eventstore/eventstore:24.2.0-jammy
environment:
-
EVENTSTORE_CLUSTER_SIZE=1
-
EVENTSTORE_RUN_PROJECTIONS=All
-
EVENTSTORE_START_STANDARD_PROJECTIONS=true
-
EVENTSTORE_HTTP_PORT=2113
-
EVENTSTORE_INSECURE=true
-
EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP=true
ports:
- '2113:2113'
volumes:
- type: volume
source: eventstore-volume-data
target: /var/lib/eventstore
- type: volume
source: eventstore-volume-logs
target: /var/log/eventstore
volumes:
eventstore-volume-data:
eventstore.db 使用 eventstore/eventstore:24.2.0-jammy 镜像,这是 EventStoreDB 的一个特定版本。你可以使用其他版本,只需进行一些不同的配置。该服务使用几个环境变量来配置 EventStore,包括启动所有投影和启用不安全连接(这不建议在生产环境中使用)。该服务将主机上的端口 2113 映射到容器内的端口 2113,允许访问 EventStoreDB 实例。最后,它定义了用于数据和日志的持久卷,以确保即使在容器重启的情况下信息也能得到保留。
-
-
运行
docker-compose up -d
命令来运行它。运行成功后,您可以导航到localhost:2213
以访问EventStoreDB
仪表板。
图 11.7:事件存储仪表板
-
现在,在我们的
src
文件夹中,创建一个eventstore.ts
文件,内容如下:import {EventStoreDBClient, FORWARDS, START} from '@eventstore/db-client' const client = EventStoreDBClient.connectionString( 'esdb://localhost:2113?tls=false', ) const connect = () => { try { client.readAll({ direction: FORWARDS, fromPosition: START, maxCount: 1, }) } catch (error) { console.error('Failed to connect to EventStoreDB:', error) } } export {client, connect} @eventstore/db-client library to interact with EventStoreDB. It establishes a connection (stored in the client), using a connection string that points to a local EventStoreDB instance (localhost:2113) with EventStoreDB for production is that it provides encryption for data transmitted over a network. Without TLS, data transmitted between the client and EventStoreDB, such as commands, events, and sensitive information, is sent in plain text. This means anyone with access to the network could potentially intercept and read the data, leading to security vulnerabilities, including data theft or man-in-the-middle attacks.
提供的
connect
函数尝试从事件流的开始(direction: FORWARDS, fromPosition: START
)读取单个事件(maxCount: 1
)。在读取操作过程中遇到的任何错误都会被捕获并记录到控制台。最后,客户端连接和connect
函数都被导出,以便在其他代码部分中潜在使用。 -
我们将存储基于账户的元素,如事件、命令和聚合体,并将它们一起存储。将基于账户的元素(如事件、命令和聚合体)一起存储有助于在领域模型内保持一致性和清晰性。这些元素是紧密相连的命令,它们启动改变聚合体状态的动作,这些变化被捕获为事件。将它们放在一起简化了操作的逻辑流程,确保所有相关组件都易于访问和组织。这就是为什么我们需要在
src
下创建一个名为account
的文件夹。创建文件夹后,在src
/account
下创建一个名为account.commands.ts
的新文件,内容如下:import {ICommand} from '@nestjs/cqrs' export class RegisterAccountUnitCommand implements ICommand { constructor( public readonly aggregateId: string, public readonly paymentmechanismCount: string, ) {} } export class DisableAccountUnitCommand implements ICommand { constructor(public readonly aggregateId: string) {} } export class EnableAccountUnitCommand implements ICommand { constructor(public readonly aggregateId: string) {} }
此代码定义了在 NestJS 应用程序的账户单元系统中使用 CQRS 的三个命令:
-
RegisterAccountUnitCommand
:此命令接受一个aggregateId
(账户单元的唯一标识符)和一个paymentmechanismCount
(关联的支付方式数量)。它用于创建一个新的账户单元。 -
DisableAccountUnitCommand
:此命令仅接受aggregateId
,并可能禁用账户单元。 -
EnableAccountUnitCommand
:与禁用命令类似,它接受aggregateId
并通常重新启用之前已禁用的账户单元。
这些命令代表了用户可能在账户单元上执行的不同操作,并且通过专注于修改系统状态(即创建、禁用或启用)来遵循 CQRS 模式。
-
-
我们不会直接调用所需的功能,而是使用命令来封装它们。我们的命令基于命令设计模式。使用命令模式,可以将每个动作/请求封装为一个对象。这种封装带来了许多额外的功能,具体取决于上下文;可以实现延迟执行、重做、撤销、事务操作等。
ICommand
接口帮助我们实现这一点。我们还需要实现其他合约来覆盖使用事件源进行 CQRS 的事件。在
src/account
文件夹中,创建一个名为account.events.ts
的新文件,内容如下:import {UUID} from 'uuid' import {IEvent} from "@nestjs/cqrs"; export class AccountEvent implements IEvent { constructor( public readonly aggregateId: UUID, public readonly paymentmechanismCount: string ) {} } export class AccountRegisteredEvent extends AccountEvent {} export class AccountDisabledEvent extends AccountEvent {} export class AccountEnabledEvent extends AccountEvent {}
在 CQRS 中,事件用于传达系统发生的变化。通过从 @nestjs/cqrs
包提供的 IEvent
继承,我们确保 AccountEvent
及其子类符合 CQRS 框架中预期的事件结构。这允许框架适当地处理这些事件,例如将它们发布到事件总线或持久化以实现最终一致性:
-
AccountEvent
(基类):作为所有账户事件的基类。它继承自IEvent
(来自@nestjs/cqrs
)并持有常见的属性,如aggregateId
和paymentmechanismCount
。 -
AccountRegisteredEvent
继承自AccountEvent
,针对特定操作(例如注册、禁用和启用)进行定制,如果需要,可能包含额外的属性。
这种方法促进了代码的重用,并保持了不同账户单元事件的事件数据一致性。
我们已经指定了我们的命令和事件,但尚未使用它们。src
| account
目录下的 account.aggregate.ts
文件的目的正是如此。我们首先需要指定我们的命令处理器。如果你有一个命令,就应该有一个处理器来处理它。
命令和处理器
命令代表用户或外部系统想要在领域模型上执行的操作。它们封装了执行操作所需的数据。在我们的例子中,RegisterAccountUnitCommand
、DisableAccountUnitCommand
和 EnableAccountUnitCommand
都是代表对账户单元执行操作的命令。
命令通常定义为接口或类。它们通常包括指定操作和任何必要数据的属性(例如我们命令中的 aggregateId
)。相反,命令处理器(在本章中也称为处理器)负责接收命令,执行必要的逻辑以修改系统状态,并可能产生反映这些变化的事件。它们在命令和领域模型之间充当桥梁。
每个命令通常都有一个相应的命令处理器。处理器接收命令,与领域逻辑(即聚合根、实体和服务)交互,并相应地更新系统状态。它还可能触发事件的创建,以传达这些变化。
我们的 account.aggregate.ts
包含 AggregateRoot
、CommandHandler
和 EventHandler
的实现。首先,我们将查看命令处理器:
@CommandHandler(RegisterAccountUnitCommand)
export class RegisterAccountUnitHandler
implements ICommandHandler<RegisterAccountUnitCommand>
{
constructor(private readonly publisher: EventPublisher) {}
async execute(command: RegisterAccountUnitCommand): Promise<void> {
const aggregate = this.publisher.mergeObjectContext
(new AccountAggregate())
aggregate.registerAccount(command.aggregateId,
command.paymentmechanismCount)
aggregate.commit()
}
}
这段 NestJS 代码定义了一个命令处理器,用于使用 CQRS 注册账户单元。@CommandHandler
装饰器将其与RegisterAccountUnitCommand
关联。它注入EventPublisher
(用于事件存储)。在execute
方法中,它创建一个AccountAggregate
实例,使用命令数据调用其registerAccount
方法,并可能提交更改。这展示了通过与领域模型交互并可能发布事件来处理命令。我们稍后会讨论AggregateRoot
。现在,我们只需关注命令背后的基本思想。
我们还有两个具有类似实现但方法调用不同的命令:
@CommandHandler(DisableAccountUnitCommand)
export class DisableAccountUnitHandler implements
ICommandHandler<DisableAccountUnitCommand> {
constructor(private readonly publisher: EventPublisher){}
async execute(command: DisableAccountUnitCommand):
Promise<void> {
const aggregate = this.publisher.mergeObjectContext(
await AccountAggregate.loadAggregate
(command.aggregateId)
);
if (!aggregate.disabled) {
aggregate.disableAccount();
aggregate.commit();
}
}
}
DisableAccountUnitHandler
使用AccountAggregate.loadAggregate
检索与command.aggregateId
关联的AccountAggregate
实例。
它使用!aggregate.disabled
验证账户是否已经被禁用。如果没有被禁用,它调用aggregate.disableAccount
来执行禁用逻辑,然后调用aggregate.commit
以将更改作为事件持久化。
此处理器确保账户单元只被禁用一次,并在成功禁用后触发事件发布(如果适用)。最后一个处理器是EnableAccountHandler
,它是DisableAccountUnitHandler
的对立面:
@CommandHandler(EnableAccountUnitCommand)
export class EnableAccountUnitHandler implements
ICommandHandler<EnableAccountUnitCommand> {
constructor(private readonly publisher: EventPublisher){}
async execute(command: EnableAccountUnitCommand):
Promise<void> {
const aggregate = this.publisher.mergeObjectContext(
await AccountAggregate.loadAggregate
(command.aggregateId)
);
if (aggregate.disabled) {
aggregate.enableAccount();
aggregate.commit();
}
}
}
我们已经完成了处理器。现在是时候探索@nestjs/cqrs
包中的IEventHandler<T>
接口了。这些处理器响应由聚合体发出的特定领域事件。
在 CQRS 的上下文中,事件处理器负责处理系统内部发生的领域事件。这些事件代表你的聚合体中的重大状态变化,事件处理器通过执行聚合体本身之外的效果或附加逻辑来响应这些变化。
在同一文件(account.aggregate.ts
)中,我们有三个事件处理器(AccountRegisteredEventHandler
、AccountDisabledEventHandler
和AccountEnabledEventHandler
):
interface AccountEvent {
aggregateId: string;
paymentmechanismCount: string;
}
async function handleAccountEvent(eventType: string, event:
AccountEvent): Promise<void> {
const eventData = jsonEvent({
type: eventType,
data: {
id: event.aggregateId,
paymentmechanismCount: event.paymentmechanismCount,
},
});
await eventStore.appendToStream(
'Account-unit-stream-' + event.aggregateId,
[eventData],
);
}
所有事件处理器都有相同的契约,这就是我们使用AccountEvent
接口的原因。它实现了一个函数,名为handleAccountEvent
,该函数接受一个事件类型和一个事件对象作为参数。该函数以 JSON 兼容的格式准备数据,并使用事件存储服务来持久化事件信息,这些信息属于涉及到的账户聚合的特定流。
现在,让我们看看具体的事件处理器实现:
@EventsHandler(AccountRegisteredEvent)
export class AccountRegisteredEventHandler
implements IEventHandler<AccountRegisteredEvent> {
async handle(event: AccountRegisteredEvent):
Promise<void> {
await handleAccountEvent('AccountUnitCreated', event);
}
}
@EventsHandler(AccountDisabledEvent)
export class AccountDisabledEventHandler implements
IEventHandler<AccountDisabledEvent> {
async handle(event: AccountDisabledEvent): Promise<void> {
await handleAccountEvent('AccountUnitDisabled', event);
}
}
@EventsHandler(AccountEnabledEvent)
export class AccountEnabledEventHandler implements
IEventHandler<AccountEnabledEvent> {
async handle(event: AccountEnabledEvent): Promise<void> {
await handleAccountEvent('AccountUnitEnabled', event);
}
}
在此代码中,我们定义了用于账户注册、禁用和启用的事件处理器。当一个账户被注册时,AccountRegisteredEventHandler
触发与账户创建相关的逻辑。同样,AccountDisabledEventHandler
和AccountEnabledEventHandler
分别处理账户禁用和启用事件。这些处理器利用handleAccountEvent
函数进行集中式事件处理。
那是很好的,但这些命令如何与事件交互呢?为了演示这一点,我们需要讨论另一个概念,称为聚合根,这是领域驱动设计(DDD)中的一种流行模式。
实现聚合根
在 DDD 中,聚合根是一个用于建模复杂领域的根本概念。它作为相关对象簇(也称为聚合)中的中心实体。
一个聚合根封装了与特定领域概念相关的核心数据和逻辑。在我们的例子中,AccountAggregate
将包含关于账户的所有必要信息(即,ID、支付机制计数和禁用状态)。这集中了账户的状态并促进了数据完整性。
聚合根在事件源技术中扮演着至关重要的角色,该技术将领域对象的变化作为一系列事件进行持久化。在我们的代码中,AccountAggregate
的方法,如 registerAccount
,将事件应用到聚合中,反映状态变化。通过从事件流中重建状态,聚合根成为账户历史的中心真相来源。
聚合根定义了我们领域内的交易边界。在一个聚合内,所有相关实体(包括根本身)的状态更改必须原子性地发生。这确保了聚合内的数据一致性。
聚合根还作为外部与聚合交互的唯一入口点。这意味着应用程序的其他部分(或其他聚合)应通过聚合根的方法与领域交互。这促进了松散耦合并简化了对领域逻辑的推理。
聚合根通过集中状态管理和定义事务边界来促进数据一致性和完整性。它们通过提供一个清晰的交互入口来简化领域逻辑。它们还通过封装相关实体及其行为来提高代码的可维护性。
通过有效地利用 DDD 中的聚合根,我们可以构建健壮且可维护的领域模型,这些模型准确地反映了您的业务流程。现在,让我们看看如何通过从事件存储中读取其事件流来重建 AccountAggregate
的状态:
export class AccountAggregate extends AggregateRoot {
..........
static async loadAggregate(aggregateId: string):
Promise<AccountAggregate> {
const events = eventStore.readStream(
'Account-unit-stream-' + aggregateId);
let count = 0;
const aggregate = new AccountAggregate();
for await (const event of events) {
const eventData: any = event.event.data;
try {
switch (event.event.type) {
case 'AccountUnitCreated':
aggregate.applyAccountRegisteredEventToAggregate({
aggregateId: eventData.id,
paymentmechanismCount:
eventData.paymentmechanismCount,
});
break;
case 'AccountUnitDisabled':
aggregate.accountDisabled();
break;
case 'AccountUnitEnabled':
aggregate.accountEnabled();
break;
default:
break
}
} catch(e) {
console.error("Could not process event")
}
count++;
}
return aggregate;
}}
这段 NestJS 代码定义了一个名为 loadAggregate
的异步函数,它接受一个聚合 ID 作为输入。它从事件存储中检索与该 ID 相关的事件 stream
。然后该函数遍历每个事件,并将它描述的更改应用到 AccountAggregate
对象上。有处理不同事件类型的情况,例如 AccountUnitCreated
、AccountUnitDisabled
和 AccountUnitEnabled
。如果事件类型不被识别,则跳过。如果处理事件时出现错误,它记录错误消息但继续迭代。最后,该函数返回填充的 AccountAggregate
对象。
下载我们的 Git 仓库以获取实现聚合根的更完整示例。以下是处理操作的聚合根片段:
export class AccountAggregate extends AggregateRoot {
……
registerAccount(aggregateId: string,
paymentmechanismCount: string) {
this.apply(new AccountRegisteredEvent(aggregateId,
paymentmechanismCount));
}
enableAccount(): void {
if(this.disabled) {
this.apply(new AccountEnabledEvent(this.id,
this.paymentmechanismCount))
}
}
disableAccount() {
if (!this.disabled) {
this.apply(new AccountDisabledEvent(this.id,
this.paymentmechanismCount));
}
}
…
}
如你所猜,命令通过聚合根与事件交互,后者封装了触发事件的逻辑。
实现投影
在 CQRS 和事件源架构中,Account Created
或Account Disabled
)。
投影就像电影院中的放映室。它们将事件流(电影胶片)投影到特定格式,适合读取。这种格式称为读取模型,它针对高效查询数据进行了优化。
有了这些,让我们了解为什么投影很重要:
-
读取效率:投影有助于从事件流中重建整个系统状态,因为为每个读取查询这样做会非常慢。投影预先处理事件流,为频繁访问的信息创建一个单独的、优化的数据结构。
-
灵活性:我们可以使用投影创建多个针对不同读取需求的投影。一个投影可能专注于账户详情,而另一个可能分析购买历史。
接下来,让我们看看投影是如何工作的:
-
事件监听器:投影充当事件监听器,订阅事件流。
-
处理事件:随着新事件的到来,投影逐个处理它们,并相应地更新其内部读取模型。
-
读取模型访问:当读取查询到达时,系统从投影的读取模型而不是整个事件流中检索相关数据。
投影不是事件存储的替代品。事件存储仍然是所有历史事件的单一真相来源。投影只是提供了一种从该历史中高效访问特定数据的方法。话虽如此,让我们看看投影的一些好处:
-
更快地读取:针对读取模型的查询比重新播放整个事件流要快得多。
-
可伸缩性:投影可以独立扩展以处理增加的读取流量。
-
灵活性:不同的投影满足不同的读取需求,而不会影响写入性能。
我们计划实现一个简单的投影,以展示在 CQRS 和事件源架构中使用投影的用法。
在src
/paymentmechanism
文件夹下,创建一个paymentmechanism-total.projection.ts
文件,具有以下功能:
@EventsHandler(AccountRegisteredEvent,
AccountDisabledEvent, AccountEnabledEvent)
export class PaymentMechanismProjection implements
IEventHandler<AccountRegisteredEvent |
AccountDisabledEvent | AccountEnabledEvent> {
private currentPaymentMechanismTotal: number = 0;
constructor() {
console.log('Account info Projection instance created:', this);
}
handle(event: AccountRegisteredEvent |
AccountDisabledEvent | AccountEnabledEvent): void {
if (event instanceof AccountRegisteredEvent) {
this.handleAccountRegistered(event);
} else if (event instanceof AccountDisabledEvent) {
this.handleAccountDisabled(event);
} else if (event instanceof AccountEnabledEvent) {
this.handleAccountEnabled(event);
}
}
........
.......
此代码定义了一个名为PaymentMechanismProjection
的事件处理类,在 CQRS 架构和事件源中。它监听与账户管理相关的三个特定事件:
-
AccountRegisteredEvent
:当创建新账户时触发。 -
AccountDisabledEvent
:当账户被停用时触发。 -
AccountEnabledEvent
:当停用的账户被重新激活时触发。
该类跟踪支付机制的总数(currentPayment
MechanismTotal),但其初始值为零。
handle
方法是核心功能。它检查传入事件的类型,并根据事件类型调用特定的处理函数:
-
handleAccountRegistered
:通过根据事件数据中的信息递增currentPaymentMechanismTotal
来处理AccountRegisteredEvent
。 -
handleAccountDisabled
:处理AccountDisabledEvent
并递减currentPaymentMechanismTotal
。 -
handleAccountEnabled
:处理AccountEnabledEvent
并执行handleAccountDisabled
的相反操作。
这是一个简化的例子,但它展示了事件处理程序投影如何监听特定事件并根据相应的事件数据更新其内部状态,保持对数据的视图优化,以适应特定目的(例如,跟踪总支付机制)。以下是这个类中我们的详细处理方法:
handleAccountRegistered(event: AccountRegisteredEvent) {
const pmCount = parseInt(event.paymentmechanismCount,
10);
this.currentPaymentMechanismTotal += pmCount;
console.log("currentPaymentMechanismTotal",
this.currentPaymentMechanismTotal)
}
handleAccountDisabled(event: AccountDisabledEvent) {
const pmCount = parseInt(event.paymentmechanismCount,
10);
this.currentPaymentMechanismTotal -= pmCount;
console.log("currentPaymentMechanismTotal",
this.currentPaymentMechanismTotal)
}
handleAccountEnabled(event: AccountEnabledEvent) {
const pmCount = parseInt(event.paymentmechanismCount,
10);
this.currentPaymentMechanismTotal += pmCount;
console.log("currentPaymentMechanismTotal",
this.currentPaymentMechanismTotal)
}
我们的处理程序简单地与 currentPaymentMechanismTotal
交互,并围绕它构建逻辑。这个想法很简单,但你可以根据这个知识实现更复杂的逻辑。
实现 API 功能
我们使用控制器作为请求流程的入口点。在经典流程中,控制器接受请求并将它们转发到相关的服务。当我们应用 CQRS 和事件溯源时,我们通常使用相同的控制器,但不是指定直接的服务,而是应用命令模式来提供命令及其处理程序。控制器作为客户端和后端逻辑之间的中介,确定应用程序应该如何响应各种请求。控制器将特定的路由映射到包含业务逻辑的相应方法。通过在控制器内组织请求处理,应用程序保持了关注点的清晰分离,使其更加结构化、可扩展且易于管理。
在 src
文件夹下创建一个名为 api
的新文件夹。然后,在 src
/ api
下创建一个名为 account.controller.ts
的新文件,内容如下:
@Controller('Account')
export class AccountUnitController {
constructor(private readonly commandBus: CommandBus) {}
@Post('/register')
async registerAccount(@Query('paymentmechanismCount')
paymentmechanismCount: string): Promise<any> {
const aggregateId = uuid()
await this.commandBus.execute(new
RegisterAccountUnitCommand(aggregateId,
paymentmechanismCount))
return { message: 'Request received as a command',
aggregateId };
}
@Post('/:id/disable')
async disableAccount(@Param('id') id: string):
Promise<any> {
await this.commandBus.execute(new
DisableAccountUnitCommand(id))
return { message: 'Request received as a command' };
}
@Post('/:id/enable')
async enableAccount(@Param('id') id: string):
Promise<any> {
await this.commandBus.execute(new
EnableAccountUnitCommand(id))
return { message: 'Request received as a command' };
} }
这个 NestJS 控制器处理账户管理。它命名为 AccountUnitController
并映射到 /Account
路由。控制器使用命令总线发送命令。通过 POST
请求暴露了三个功能:
-
registerAccount
允许你通过发送RegisterAccountUnitCommand
创建一个带有支付机制计数的新的账户。 -
disableAccount
通过DisableAccountUnitCommand
使用 ID 使账户失效。 -
enableAccount
使用EnableAccountUnitCommand
根据其 ID 激活账户。
所有成功的请求都会返回一条消息,表明已收到命令和聚合 ID(用于注册)。
为了启用控制器的功能,我们需要导入几个基本元素。Controller
、Param
、Post
和Query
从@nestjs/common
是必要的,用于定义控制器、处理路由参数和处理带有查询参数的 HTTP POST
请求。CommandBus
从@nestjs/cqrs
允许我们根据 CQRS 模式分发命令。我们从account.commands
文件导入特定的命令(DisableAccountUnitCommand
、EnableAccountUnitCommand
和RegisterAccountUnitCommand
)以对账户单元执行特定操作。最后,我们导入uuid
包以生成这些操作的唯一 ID:
import {Controller, Param, Post, Query} from
'@nestjs/common'
import {CommandBus} from '@nestjs/cqrs'
import {
DisableAccountUnitCommand,
EnableAccountUnitCommand,
RegisterAccountUnitCommand
} from '../account/account.commands'
import {v4 as uuid} from 'uuid'
我们的控制器不了解事件。它只与命令交互。请求将流向命令处理器,它们将触发我们的事件。
除了控制器外,我们还有account.module.ts
文件,其中包含AccountModule
:
export class AccountModule implements OnModuleInit {
async onModuleInit() {
this.startSubscription();
}
private startSubscription() {
(async (): Promise<void> => {
await this.subscribeToAll();
})();
}
private async subscribeToAll() {
const subscriptionList = eventStore.subscribeToAll({
filter: streamNameFilter({ prefixes: ["Account-unit-stream-"]
}),
});
for await (const subscriptionItem of subscriptionList){
console.log(
`Handled event ${subscriptionItem.event?.revision}@${subscriptionItem.event?.streamId}`
);
const subscriptionData: any =
subscriptionItem.event.data;
console.log("subscription data:", subscriptionData);
}
}
}
要查看包含导入功能的完整示例,请查看我们的仓库。
此代码定义了用于 CQRS 架构和事件源模式的AccountModule
。它实现了OnModuleInit
生命周期钩子,该钩子在模块初始化后调用。
下面是功能分解:
-
onModuleInit
: 当模块准备就绪时调用此方法。 -
startSubscription (private)
: 这是一个私有方法,用于初始化对事件流的订阅。它使用立即调用的函数表达式(IIFE)来封装异步逻辑。
最后,我们将查看subscribeToAll(private, async)
;这是一个私有的异步方法,执行实际的订阅工作。它使用eventStore.subscribeToAll
来订阅所有以Account-unit-stream-
前缀开始的事件流。此方法通常捕获与账户管理相关的所有事件。它使用for await...
循环遍历订阅。对于收到的每个事件,它记录事件修订号和流 ID,提取事件数据,并记录它。AccountModule
订阅事件存储中的特定类别的事件(与账户相关的事件)。每当有新的与账户相关的事件到达时,它会记录有关事件及其数据的详细信息,以供潜在的处理或监控。
测试应用程序
在运行我们的应用程序之前,您应该通过docker-compose up -d
命令运行提供的docker-compose
文件。这确保我们已经有EventStoreDB
作为数据存储。要确保数据存储正在运行,只需导航到localhost:2113
,你应该会看到EventStoreDB
的仪表板。
要运行我们的应用程序,从命令行执行nest start
命令。打开您的 Postman 应用程序,创建一个新标签。选择paymentmechanismcount
设置为67
。然后,点击发送按钮。
图 11.8:账户注册
操作成功后,你应该在 VS Code 控制台中看到以下消息。
图 11.9:账户注册日志
在您的情况下,ID 将不同,因为它是由系统自动生成的。在运行具有不同支付机制计数的相同命令(在我们的情况下是二十三)后,您应该会收到以下消息,其中 currentPaymentMechanismCount=90
。ID 再次不同,但如果您使用相同的支付机制计数,值应根据 currentPaymentMechanismTotal = currentPaymentMechanismTotal + paymentMechanismCount
公式进行总计:
图 11.10:账户注册计算
现在,我们有两个不同的 ID(聚合 ID),我们可以使用其中任何一个来启用和禁用请求。
在 Postman 中打开一个新标签页,并向 http://localhost:8080/account/YOUR_AGGREGATE_ID/disable
发送 POST 请求。最后一个聚合 ID 存储了 paymentmechanismCount
的值,即二十三。因此,禁用端点应该最终使 currentPaymentMechanismTotal = 67
的值。逻辑是九十减去二十三等于六十七。
运行 http://localhost:8080/account/90f80d89-4620-4526-ae3e-02a8156df9a1/disable
并点击 发送:
图 11.11:禁用账户的响应
要启用账户,只需将 disable
替换为 enable
并再次运行命令。它应该将 currentPaymentMechanismTotal
恢复到 90
。
除了 CQRS 和事件溯源之外,我们还有微服务的服务注册和发现。下一节将帮助我们更详细地了解它们。
微服务中的服务注册和发现
微服务开发本身包含大量的模式和最佳实践。确实不可能在一本书中涵盖所有这些。在本节中,我们将提供在微服务开发中使用的流行模式和技巧。
在微服务架构中,应用程序被构建为一系列小型、独立的服务。这些服务需要相互通信以处理用户请求。服务注册和发现是一种机制,通过使服务能够动态地找到彼此来简化这种通信。
理解服务注册和发现
想象一个中心数据库。这个数据库被称为 服务注册表,作为系统中所有微服务的目录。每个服务实例(即运行微服务副本的个体)都会向注册表注册自己。在注册过程中,服务提供如下详细信息:
-
网络位置:服务可以找到的地址(IP 地址和端口号)。
-
能力:服务能做什么(例如,处理支付或提供用户数据)。
-
健康信息:服务当前是否健康以及是否可以处理请求的状态细节。
你可以使用诸如 Consul、ZooKeeper 和 Eureka Server(如 Netflix 所使用)等工具进行实际的服务注册。
服务注册经常与 API 网关集成,API 网关是外部客户端访问微服务的单一入口。API 网关可能会利用服务注册来发现它需要路由请求到的微服务的最新位置。
相反,服务 发现是微服务找到它们需要交互的其他服务位置的过程。有两种主要方法:
-
客户端发现:需要另一个服务(客户端)的服务直接查询注册表以找到目标服务的地址。
-
服务器端发现:一个单独的组件,如负载均衡器,位于服务之前。该组件从注册表中检索服务位置并将请求路由到适当的服务实例。
让我们看看服务注册和发现的一些好处:
-
动态服务位置:服务不需要将其他服务的地址硬编码。它们可以从注册表中按需发现它们,使系统更能适应变化。
-
可扩展性和弹性:随着你添加或删除服务实例,注册表会自动反映这些变化。这确保了客户端始终与可用的服务交互。
-
松耦合:服务变得松耦合,因为它们依赖于注册表进行通信。这促进了微服务的独立开发和部署。
通过使用中央注册表并启用动态发现,服务注册和发现简化了通信并促进了微服务架构的灵活性。
实现服务注册和发现的方法
在 Node.js 微服务中实现服务注册和发现有两种主要方法:
-
第一种选择是使用专用的服务注册工具。这种方法利用一个专门为服务注册和发现功能设计的独立服务。我们可以使用流行的选项,如 Consul、ZooKeeper 和 Eureka Server(Netflix)。这些工具提供了注册、发现、健康检查等功能。
-
第二种选择是 Node.js 客户端库。每个注册工具通常提供一个 Node.js 客户端库,它简化了与注册表的交互。该库允许您的微服务自行注册、发现其他服务并监控其健康。
最后,让我们在结束本章之前看看如何实现服务注册。
实现服务注册
现在,让我们简要地看看如何实现服务注册:
-
选择一个服务注册工具,并安装其 Node.js 客户端库:
-
在启动期间,每个微服务都会使用库将自己注册到注册表中。它提供其网络位置、能力和健康信息。
-
在客户端发现中,需要另一个服务的服务使用库查询注册表以获取目标服务的地址。
-
在服务器端发现中,一个单独的组件,例如负载均衡器,从注册表中检索服务位置并相应地路由请求。
-
-
现在,让我们继续构建一个简单的注册表:对于较小的部署或学习目的,您可以使用 Node.js 本身实现基本的服务注册。以下是一个简化的示例:
-
数据存储:使用轻量级的内存数据存储,例如 Redis,或者简单的 Node.js 对象来存储服务信息
-
注册:在启动过程中,每个微服务通过发送包含其详细信息的消息来向注册表注册自己
-
发现:服务可以通过查询注册表来检索可用服务及其地址列表
-
在结束本节之前,让我们看看服务注册和发现的一些重要考虑因素,首先是安全性。在实现自己的注册表时,确保适当的身份验证和授权机制来控制对注册和发现功能的访问。接下来是可扩展性。自制的注册表可能不适合大型部署。考虑为生产环境使用专用工具。最后,健康检查非常重要。定期检查已注册服务的健康状态,以确保它们可用。
本章已经涵盖了关于微服务架构的所有内容。现在是时候总结一下了。
摘要
本章深入探讨了强大微服务架构的构建块。它涵盖了 API 网关,解释了其目的、用例以及如何实现它们以获得最佳性能,包括缓存、速率限制和响应聚合。然后,本章探讨了 CQRS 和事件源模式,以及使它们工作的技术——事件流。最后,本章讨论了服务注册和发现,这对于微服务之间相互通信至关重要。本章提供了构建设计良好且可扩展的微服务基础设施的知识和实践示例。
在下一章中,我们将深入探讨测试策略,并介绍如何为您的微服务编写有效的单元和集成测试。
第十二章:测试微服务
测试是软件开发生命周期(SDLC)中的一个关键阶段,对于确保软件满足所需标准并按预期运行至关重要。测试可以检测开发过程中引入的错误。它验证软件是否正确且高效地执行其预期功能,并确保软件满足用户需求和规格。通过应用测试,我们可以降低软件故障或故障的风险。
在本章中,我们将讨论微服务内部软件测试的不同策略。到本章结束时,您将了解如何为您的微服务编写单元和集成测试,无论是独立还是与其他微服务集成。
我们将涵盖以下主题:
-
理解微服务架构中的测试
-
理解和实现单元测试
-
为账户微服务实现单元测试
-
为事务微服务编写单元测试
-
在单元测试中比较模拟、存根和间谍
-
理解和实现集成测试
技术要求
要实现我们的测试,我们需要以下内容:
-
您选择的 IDE(我们更喜欢 Visual Studio Code)。
-
本书可在
github.com/PacktPublishing/Hands-on-Microservices-with-JavaScript
的 GitHub 仓库中下载。打开Ch12
文件夹,以便您可以轻松地跟随。
理解微服务架构中的测试
微服务架构将应用程序划分为更小、松散耦合的服务,每个服务负责特定的业务功能。这种方法提供了许多好处,例如提高了可扩展性和灵活性。然而,它也引入了复杂性,尤其是在测试方面。全面的测试对于确保这些独立服务正确且协同工作至关重要。让我们尝试理解在微服务架构中测试的重要性:
-
在微服务架构中使用测试的第一个原因是确保功能。每个服务在微服务架构中执行一个独特的功能,并且是独立开发的。测试确保每个服务正确执行其预期功能。我们主要使用单元和功能测试来实现这一目标:
-
单元测试关注服务内的单个组件,验证每个函数是否按预期工作。这有助于我们在开发早期阶段捕捉到错误。
-
另一方面,功能测试确保服务整体满足其功能要求。这涉及到测试服务的端点,并确保它们返回预期的结果。
-
-
在微服务中使用测试的第二个原因是保持互操作性。微服务必须相互通信才能作为一个整体应用运行。确保服务之间无缝的互操作性至关重要。为此,我们主要关注集成和契约测试:
-
集成测试关注服务之间的交互,验证数据交换和通信协议是否正确实现。
-
契约测试确保服务遵守定义的 API 或契约。当不同团队独立开发服务时,这尤其重要,因为它有助于保持一致的通信标准。
-
-
第三个原因是性能保证。性能测试确保服务在各种负载条件下高效运行,这对于维护良好的用户体验至关重要。我们可以通过负载测试和压力测试来实现这一目标:
-
负载测试评估服务处理预期负载水平的能力。这有助于识别性能瓶颈并确保服务能够处理实际使用情况。
-
压力测试检查服务在极端条件下的表现,如高流量或资源短缺。这有助于了解服务的断裂点和弹性。
-
-
测试对于安全性验证也很重要。在微服务架构中,安全性是一个关键问题,因为每个服务都可能处理敏感数据,必须保护其免受漏洞的侵害。我们可以使用安全测试和渗透测试来实现我们的目标:
-
安全测试识别漏洞并确保服务能够保护敏感数据。这包括测试常见的安全问题,如 SQL 注入、跨站脚本攻击(XSS)和身份验证缺陷。
-
渗透测试模拟攻击以识别潜在的安全漏洞。这有助于主动保护服务免受现实世界威胁。
-
-
微服务需要可靠和稳定,尤其是在更新或变更期间。测试确保服务在长时间内保持可靠。
-
回归测试确保新的更改或更新不会引入新的错误或破坏现有功能。这对于在每次部署后维护服务可靠性至关重要。
-
混沌工程涉及故意引入系统故障以测试其弹性。这有助于我们了解服务如何应对意外问题并提高整体稳定性。
-
在整体理解测试之后,让我们继续探讨单元测试。
理解单元测试
单元测试是一种软件测试技术,当你想要确保软件的各个单元或组件在隔离状态下被测试时最为重要。单元测试的目的是验证每个软件单元是否按预期执行。
它是软件开发的重要方面,尤其是在微服务架构中。Node.js 以其异步和事件驱动的特性,为单元测试带来了独特的挑战和机遇。在这种情况下,单元测试涉及测试 Node.js 应用程序中的单个函数、方法或类。让我们来谈谈单元测试对微服务的重要性:
-
它确保了代码质量:单元测试有助于在开发周期早期识别错误,确保代码单元按预期工作。这在微服务中尤为重要,因为服务被设计成小、独立和模块化。
-
它促进了重构:拥有全面的单元测试套件,开发者可以自信地重构代码,知道任何更改都将通过测试得到验证。这对于随着时间的推移维护和改进代码库至关重要。
-
它支持持续集成/持续部署(CI/CD):单元测试是 CI/CD 管道的组成部分。它们对代码更改提供即时反馈,使快速迭代和稳定部署成为可能。
-
更好的文档:良好的单元测试充当代码的文档。它们展示了各个单元预期如何表现,使得新开发者更容易理解代码库。
让我们来看看在 Node.js 微服务中进行单元测试的一些好处:
-
提高了可靠性:单元测试确保每个微服务按预期行为,减少了运行时错误的可能性,并提高了整体系统的可靠性。
-
更快的开发周期:自动化的单元测试通过允许开发者快速识别和修复问题,从而加速开发过程。这在微服务中尤其有益,因为服务是独立开发和部署的。
-
减少了调试时间:有了单元测试,可以快速检测和隔离错误,从而减少调试时间。
-
增加了对代码更改的信心:单元测试为开发者提供了一个安全网,使他们有信心进行更改和添加新功能,而不会破坏现有功能。
在 Node.js 微服务中进行单元测试增强了可靠性,通过早期捕捉问题来加速开发,减少调试时间,并提高开发者对在不破坏功能的情况下进行代码更改的信心。现在,让我们专注于编写单元测试所需的单元测试包。
介绍单元测试包
在编写任何代码行之前,我们需要准备我们的环境。更准确地说,我们需要安装编写单元测试所需的包。
Chai、Mocha 和 Sinon 是 Node.js 应用程序(包括微服务)中常用的库,用于测试。这些库各自扮演着特定的角色,并且它们通常协同工作,提供全面的测试框架。
介绍 Mocha
首先,让我们谈谈 Mocha。Mocha 是一个功能丰富的 JavaScript 测试框架,在 Node.js 上运行,使异步测试变得简单和有趣。它提供了一个测试环境,您可以定义测试并运行它们。
它具有以下特性:
-
describe
和it
块。 -
支持异步测试:它支持同步和异步测试。
-
before
,after
,beforeEach
, 和afterEach
用于设置和清理测试条件。 -
可扩展性:可以通过各种插件和报告器扩展以自定义测试设置。
您可以使用 npm install --save-dev mocha
命令来安装它。
介绍 Chai
Chai 是一个流行的断言库,常与 Node.js 一起使用,通常与 Mocha 等测试框架结合使用。它提供了多种接口和风格来编写测试,使其灵活且易于使用。
它支持不同的断言风格,其中我们将探讨两种:
-
第一种也是最常用的风格是
expect
和should
接口。它们用于编写表达性和可读性的断言。这种风格允许使用自然语言断言,使测试更容易理解。 -
第二种风格是
assert
接口,用于编写传统的单元测试断言。这种风格更传统和直接,适合熟悉 xUnit 框架的开发者。
我们可以使用 npm install --save-dev chai
命令来安装它。
虽然 Chai 有很多特性,但让我们了解其中三个最相关的特性。我们将首先查看其 chai-as-promised
用于承诺断言和 chai-http
用于 HTTP 断言的第一个特性。第二个关键特性是 可扩展性。Chai 可以通过其插件 API 扩展以创建自定义断言。这允许开发者将特定领域的语言添加到他们的测试中。另一方面,Chai 有一个名为可读和表达性语法的酷特性。Chai 的 BDD 风格断言旨在易于阅读和表达,这使得测试更容易编写和理解。最后,Chai 还 集成 与 Mocha 无缝,为编写和运行测试提供了强大的组合。
介绍 Sinon
Sinon 是另一个强大的 JavaScript 测试库,特别适用于创建间谍(spies)、存根(stubs)和模拟(mocks)以控制和监控函数的行为。它在单元测试中非常有价值,可以隔离被测试的代码与其依赖项,确保测试专注于正在测试的特定功能。
在我们学习如何实现单元测试之前,让我们看看 Sinon 的关键特性:
-
间谍(Spies):跟踪和监控函数的行为。
-
存根(Stubs):用预定义的行为替换函数。
-
模拟(Mocks):创建具有行为预期的假对象。
-
伪造(Fakes):将间谍和存根的行为组合起来以简化使用场景。
-
计时器(Timers):在测试中控制和模拟时间的流逝。
-
使用
XMLHttpRequest
和 Fetch API 来测试 AJAX 请求。
我们可以使用npm install --save-dev sinon
命令来安装它。Sinon 可以模拟函数、HTTP 请求等,使其非常适合单元测试。
Node.js 中的其他包
除了Chai
,我们还有其他流行的断言库,如Jest
。Jest拥有自己的断言库,它与Jest
完全集成并优化了使用体验。
在实现单元测试时,我们还需要模拟和存根库。在单元测试中,模拟和存根用于通过模拟依赖项的行为来隔离正在测试的代码单元。这允许您测试特定单元的功能,而无需依赖于外部组件,如数据库、网络服务或其他模块。
除了Sinon
,我们还使用Testdouble
,这是一个用于 JavaScript 的最小、独立的测试替身库。您可以使用npm install --save-dev testdouble
命令来安装它。Testdouble 提供了创建、使用和验证 JavaScript 中测试替身(test doubles)的工具。
在单元测试中我们应该测试什么?
在我们的项目中实施单元测试之前,我们需要回答一个简单的问题:我们应该单元测试什么?让我们来看看:
-
业务逻辑:这是测试中最关键的部分。业务逻辑包括规定数据如何转换、操作和控制的规则和操作。它确保应用程序在各种条件下都能正确运行。
-
边界情况:测试应用程序的边界和限制。这包括检查应用程序如何处理意外、极端或无效的输入。
-
错误处理:确保应用程序能够正确响应错误条件,例如无效输入或操作失败。
-
状态转换:如果应用程序涉及状态变化(例如状态更新),请确保这些转换按预期发生。
-
返回值:验证函数对于给定的输入返回正确的值。
-
依赖和交互:虽然单元测试理想情况下应该独立测试一个单元,但模拟依赖项并验证组件之间的交互对于确保它们正确协作非常重要。
现在我们知道了应该测试什么,是时候实施单元测试,以便我们能在实践中看到它的效果。
实施账户微服务的单元测试
本书 GitHub 仓库的Ch12
文件夹中可以找到第七章的源代码。本章将演示如何测试不同的微服务。我们将从账户微服务开始。
在与src
文件夹同一级别的位置创建一个新的文件夹,并将其命名为tests
。我们在这里的主要重点是测试src/services/account.js
文件。它包含应用程序中实现的主要逻辑和所需业务规则。
接下来,在tests
文件夹下创建一个名为accountservice.test.mjs
的文件。为什么使用.mjs
扩展名?这个扩展名在 Node.js 项目中用于指示一个 JavaScript 文件应该被视为import
和export
语法。通过使用.mjs
,Node.js 可以明确地确定该文件应该被视为 ES 模块,即使它与具有.js
扩展名的CommonJS
文件共存。这避免了混淆和潜在冲突,尤其是在使用两种模块系统的项目中。使用.mjs
使开发人员和工具明确知道该文件是 ES 模块,帮助他们避免错误和配置错误。
简而言之,在 Node.js 项目中使用.mjs
扩展名有助于将 ES 模块与 CommonJS 模块分开。这确保 Node.js 正确处理它们,并保持您的代码与现代 JavaScript 标准兼容。通过使用.mjs
,您可以简化模块设置,并使您的代码更具未来性,因为 JavaScript 仍在不断发展。
我们计划为account.js
文件实现单元测试,该文件位于src /
services
文件夹中。
在这个服务中,我们有许多函数。首先,让我们考虑对getAccountById
函数的一些单元测试。这是原始函数:
function getAccountById(id) {
return Account.findById(id);
}
从实现的角度来看,我们还没有实现有效的异常处理。我们直接从数据库层抛出相同的异常到 API 层。通常,最好在最低层(数据库层)或最高层(API 层)处理异常。低层应主要关注在出现意外条件时检测和抛出异常。这保持了关注点的清晰分离,并防止低层暴露实现细节。一些异常,如数据库连接失败或文件读写错误,可能需要在最低层立即处理,以防止数据损坏或资源泄露。例如,如果文件写入操作由于磁盘已满而失败,在此级别处理异常可以防止进一步的问题。如果异常是预期的,并且可以在低层处理而无需暴露内部细节,那么这样做是合理的。在最高层集中处理错误可以提供跨应用程序的一致错误管理。
首先,让我们安装所需的包。为此,运行npm install --save-dev mocha chai sinon
命令。安装完成后,我们将在package.json
文件中的devDependencies
部分看到以下内容:
"devDependencies": {
"chai": "⁵.1.1",
"mocha": "¹⁰.7.0",
«sinon": "¹⁸.0.0"
}
现在,是时候导入必要的包和功能了:
import * as chai from 'chai';
import sinon from 'sinon';
const expect = chai.expect;
import * as accountService from '../src/services/account.js';
import account from '../src/models/account.js'
const { errorCodes } = accountService.default;
我们应该在账户服务中测试什么?需要测试的第一个函数是getAccountById
。我们应该检查如果给定的账户存在于我们的数据库中,该函数是否会返回确切的账户信息。这是我们的第一个测试用例:
describe('getAccountById service', () => {
let findByIdStub;
beforeEach(() => {
findByIdStub = sinon.stub(account, 'findById');
});
afterEach(async () => {
await findByIdStub.restore();
});
it('should return the account if found by id', async () => {
const expectedAccountId = '12345';
const expectedAccount = { name: 'Test Account',
number: '123-456-7890' };
findByIdStub.withArgs(expectedAccountId)
.resolves(expectedAccount);
const account = await accountService
.getAccountById(expectedAccountId);
expect(account).to.deep.equal(expectedAccount);
expect(findByIdStub.calledOnceWith(expectedAccountId))
.to.be.true;
});
});
好吧,这个代码一开始看起来可能有点复杂,但本节提供的详细解释将帮助你轻松理解它。
在 Mocha 中,describe
和it
块是编写和组织测试的基本结构。
describe
块用于分组相关的测试用例。它有助于将测试组织成逻辑部分,使它们更容易阅读和理解。它通常用于分组与特定功能或函数相关的测试。describe
块有两个参数:
-
description
:一个描述测试组的字符串。这个描述将出现在测试的输出中。 -
function
:一个包含测试用例(使用 it 块)和任何设置/清理逻辑的回调函数。
it
块用于定义单个测试用例。每个it
块代表一个执行特定断言或断言集的单个测试。
你可以嵌套describe
块来为你的测试创建层次结构,使其更容易组织和理解复杂的测试套件。以下是一个例子:
describe('Math operations', function() {
describe('Addition', function() {
it('should add two numbers correctly', function() {
expect(1 + 1).to.equal(2);
});
});
describe('Subtraction', function() {
it('should subtract two numbers correctly', function()
{
expect(2 - 1).to.equal(1);
});
});
});
在实践中,你通常使用describe
来按功能或被测试的代码单元分组测试,并使用it
来定义你期望从该代码中得到的特定行为。
让我们回到我们的例子。我们的代码片段描述了一个名为getAccountById
的服务函数的单元测试。该测试使用名为Sinon
的模拟库来模拟账户模块中名为findById
的函数的行为。
简而言之,这个测试检查getAccountById
服务函数是否能够通过findById
函数正确地通过 ID 检索账户。它确保当找到 ID 时,服务返回预期的账户数据。
这里是对我们第一个单元测试逐行解释:
-
第 1 行声明了一个名为
getAccountById service
的测试套件。传递给describe
的函数将包含与getAccountById service
相关的测试用例。 -
第 2 行声明了
findByIdStub
变量,稍后将用于存储由 Sinon 创建的存根。 -
第 3 行设置了一个函数,在
describe
块内的每个测试用例之前运行。 -
在第 4 行的
beforeEach
函数中,Sinon
为account
模型的findById
方法创建了一个存根。这个存根将替换原始的findById
方法,使我们能够在测试期间控制其行为。 -
第 7 行设置了一个函数,在
describe
块内的每个测试用例之后运行。 -
在第 8 行的
afterEach
函数中,对findByIdStub
调用了restore
方法。这恢复了账户模型的原始findById
方法,确保存根不会影响其他测试。 -
第 11 行声明了一个带有描述
should return the account if found by id
的测试用例。传递给它的函数包含测试逻辑。 -
第 12 行声明了一个常量
expectedAccountId
,并将其值设置为'12345'
。这是将用于搜索账户的 ID。 -
第 13 行 声明了一个常量,
expectedAccount
,并将其赋值为一个模拟的账户对象。这是findById
模拟方法将返回的账户。 -
第 14 行 设置
findByIdStub
,当使用expectedAccountId
调用时,返回expectedAccount
(通过返回一个解析到expectedAccount
的承诺)。这模拟了在数据库中查找账户的行为。 -
第 15 行 调用
getAccountById
服务函数,使用expectedAccountId
并等待其结果。结果被赋值给account
变量。 -
第 16 行 断言服务函数返回的账户与
expectedAccount
深度相等。深度相等检查对象的所有属性是否相等。 -
第 17 行 断言
findByIdStub
被恰好一次使用expectedAccountId
调用。这验证了服务函数尝试通过正确的 ID 查找账户。
在同一个 describe
块内,以下是接下来的几个单元测试:
describe('getAccountById service', () => {
.........
.........
it('should return null if account not found', async () => {
const expectedAccountId = '54321';
findByIdStub.withArgs(expectedAccountId).resolves(null);
const account = await accountService
.getAccountById(expectedAccountId);
expect(account).to.be.null;
expect(findByIdStub.calledOnceWith(expectedAccountId))
.to.be.true;
});
it('should rethrow errors from findById', async () => {
const expectedAccountId = '98765';
const expectedError = new Error('Database error');
findByIdStub.withArgs(expectedAccountId)
.rejects(expectedError);
try {
await accountService.getAccountById(expectedAccountId);
} catch (error) {
expect(error).to.equal(expectedError);
expect(findByIdStub.calledOnceWith(expectedAccountId))
.to.be.true;
} }); });
在这个单元测试套件中,为 getAccountById
服务定义了两个测试用例:
-
第一个测试用例,命名为
should return null if account not found
,设置了一个场景,其中 ID 为'54321'
的账户不存在。在这里,findByIdStub
被配置为当使用此 ID 调用时返回null
。然后测试调用getAccountById
使用'54321'
并期望结果为null
。它还验证findByIdStub
被恰好一次使用'54321'
调用。 -
第二个测试用例,命名为
should rethrow errors from findById
,测试了当findById
方法抛出错误时的行为。在这里,findByIdStub
被设置为当使用 ID'98765'
调用时抛出Database error
。测试调用getAccountById
并期望调用抛出相同的错误。这通过 try-catch 块进行验证,检查捕获的错误是否等于预期的错误。此外,它还验证findByIdStub
被恰好一次使用'98765'
调用。这些测试确保getAccountById
服务正确处理账户未找到和数据库访问期间发生错误的情况。请记住,重新抛出错误应包括有意义的手动处理,例如记录或向错误添加更多上下文。
除了 updateAccountById
之外的其他测试遵循大约相同的测试风格。在给定的服务中,最复杂的实现存在于 updateAccountById
函数中。请参阅 第五章 了解更多关于 Account
微服务和其业务案例的信息。
我们应该覆盖我们在原始功能中实现的所有业务规则。以下是更新账户时需要覆盖的第一个条件:
async function updateAccountById(id, { name, number, type, status }) {
if (!name && !number && !type && !status) {
return { error: 'provide at least one valid data to be updated', code: NO_VALID_DATA_TO_UPDATE };
}
........
}
此函数通过要求至少提供一条有效信息(名称、编号、类型或状态)来确保账户更新是有意义的。如果没有提供任何信息,它将返回一个错误,以强制执行避免无效更新的业务规则。
这是单元测试片段:
describe('updateAccountById service', () => {
let findByIdStub, saveStub;
beforeEach(() => {
findByIdStub = sinon.stub(account, 'findById');
saveStub = sinon.stub(account.prototype, 'save');
});
afterEach(async () => {
await findByIdStub.restore();
await saveStub.restore();
});
it('should return error for no data to update', async () => {
const id = '12345';
const updateData = {};
const result = await accountService.updateAccountById(id,
updateData);
expect(result).to.deep.equal({
error: 'provide at least one valid data to be updated',
code: errorCodes.NO_VALID_DATA_TO_UPDATE,
});
expect(findByIdStub.calledOnceWith(id)).to.be.false;
expect(saveStub.calledOnce).to.be.false;
});
......
....... });
上述代码片段描述了一个名为updateAccountById
的服务函数的单元测试。这个函数负责根据给定的 ID 和更新数据更新账户。测试使用Sinon
来替代账户模块中findById
和save
方法的行为。
在每个测试用例之前,为findById
和save
方法建立Sinon
存根,以实现可控的测试场景。在每个测试之后,这些存根都会恢复到它们原始的状态。
特定的测试用例专注于验证没有提供更新数据时的错误处理过程。它构建了一个账户 ID 和一个空的更新对象。随后,它使用这些参数调用updateAccountById
服务,并捕获返回的结果。
测试随后断言返回的结果是一个包含特定错误消息和代码的错误对象,表明缺少有效的更新数据。为了确保正确的行为,它进一步验证了findById
和save
方法都没有被调用,因为在这种情况下不需要检索或更新账户。
简而言之,这个单元测试保证了updateAccountById
服务正确处理没有提供更新数据的情况,返回适当的错误响应而不执行不必要的操作。
updateAccountById
函数中的下一部分逻辑定义如下:
async function updateAccountById(id, { name, number, type, status }) {
......
if (status && !(status in availableAccountStatusesForUpdate)) {
return { error: 'invalid status for account', code: INVALID_STATUS_CODE }; }
......
}
上述代码片段表明,如果状态不是由业务规则定义的允许状态之一,它将返回一个错误消息,防止使用无效或不支持的状态进行更新。这确保了只有可接受的状态更改被做出,保持业务一致性和数据完整性。
下面的单元测试验证了对无效状态更新的错误处理:
it('should return error for invalid status update', async () => {
const id = '12345';
const updateData = { status: 'invalid_status' };
const result = await accountService.updateAccountById(id,
updateData);
expect(result).to.deep.equal({
error: 'invalid status for account',
code: errorCodes.INVALID_STATUS_CODE,
});
expect(findByIdStub.calledOnceWith(id)).to.be.false;
expect(saveStub.calledOnce).to.be.false;
});
上述单元测试验证了updateAccountById
服务中对无效状态更新的错误处理。它模拟了一个使用无效状态的更新。当服务遇到这个无效输入时,测试期望一个包含特定详细信息的错误对象。为了隔离测试,存根防止了数据库交互。通过断言正确的错误,测试确保服务在面对错误数据时表现如预期。
使用我们在前面的单元测试中实施的方法,我们可以测试我们服务的所有可能情况。为了更完整的实现,请查看本书的 GitHub 仓库以及本章的相关文件夹。
要运行单元测试,从命令行导航到根文件夹(对我们来说,这是Ch12
/accountservice
文件夹),并运行以下命令:
npx mocha .\tests\accountservice.tests.mjs
这是结果:
图 12.1:测试运行结果
通过这样,我们已经展示了如何为 Express.js 项目编写测试。对于 Nest.js 应用程序,相同的单元测试逻辑也是一样的。你可以轻松地将上述想法应用到你的 Nest.js 应用程序中。
为事务微服务编写单元测试
现在,我们将展示如何轻松地为你的 Nest.js 应用程序编写单元测试。在这本书的 GitHub 仓库中,在Ch12
文件夹中,我们有与第七章中实现相同的交易服务。
打开transaction.service.spec.ts
文件,该文件位于src/test
文件夹中。它包含所有必要的测试,帮助我们了解如何编写单元测试。如果你想从头开始实现所有内容,只需在src
文件夹内创建一个名为test
的文件夹。
首先,我们需要安装所需的包来实现我们项目的单元测试。要为transaction.service.js
编写单元测试,我们需要安装@nestjs/testing
包。以下是操作步骤:
npm install --save-dev jest @nestjs/testing
安装完成后,创建一个名为transaction.service.spec.ts
的文件。首先,我们需要导入所需的引用:
import { Test, TestingModule } from '@nest.js/testing';
import { TransactionService } from '../transaction/transaction.service';
import { PrismaService } from '../prisma/prisma.service';
import { HttpService } from '@nest.js/axios';
import { KafkaService } from '../kafka/kafka.service';
import { CreateTransactionDto } from '../transaction/dto/create-transaction.dto';
前面的代码导入了测试所需的必要服务:TransactionService
处理事务逻辑,PrismaService
与数据库交互,HttpService
处理外部 HTTP 请求,KafkaService
执行消息处理,CreateTransactionDto
定义了事务数据结构。Test
和TestingModule
导入来自 Nest.js 测试模块,用于为TransactionService
创建测试环境。
下面是一个简单的单元测试示例:
describe('TransactionService', () => {
let service: TransactionService;
let prismaService: PrismaService;
let httpService: HttpService;
let kafkaService: KafkaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TransactionService,
{
provide: PrismaService,
useValue: {
transaction: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
},
},
{
provide: HttpService,
useValue: {
axiosRef: {
get: jest.fn(),
},
},
},
{
provide: KafkaService,
useValue: {
send: jest.fn(),
},
},
],
}).compile();
我们已经了解了describe
块及其在单元测试中的作用。前面的代码片段为TransactionService
建立了一个测试环境。它首先导入必要的模块和服务:TransactionService
、用于数据库交互的PrismaService
、用于外部请求的HttpService
、用于消息处理的KafkaService
以及用于数据传输的CreateTransactionDto
。
一个describe
块封装了TransactionService
的测试。在内部,声明变量以保存服务的实例。beforeEach
块使用Test.createTestingModule
设置测试模块。它为PrismaService
、HttpService
和KafkaService
提供模拟实现,以在测试期间隔离TransactionService
。PrismaService
模拟包括create
、findMany
、findUnique
和update
等方法来模拟数据库操作。如果你打开transaction.service.ts
,你会意识到我们使用这些方法来实现事务服务的功能。同样,HttpService
和KafkaService
模拟使用 Jest 的jest.fn()
来模仿它们各自的功能。这种设置允许在不依赖实际外部依赖的情况下对TransactionService
进行受控测试。
在这里,jest.fn()
是由 Jest,一个流行的 JavaScript 测试框架提供的函数,用于创建模拟函数。模拟函数本质上是一个占位函数,可以在测试期间用来替换真实函数。在单元测试中使用此类功能有很多好处和价值。以下是使用jest.fn()
的好处:
-
隔离性:它允许我们通过用模拟函数替换依赖项来隔离你正在测试的组件或函数。这有助于我们专注于代码的特定行为,而不会受到外部因素的影响。
-
可验证性:我们可以断言模拟函数被调用的次数、调用参数以及返回值。这有助于验证代码的正确行为。
-
自定义实现:我们可以使用模拟实现来定义模拟函数的行为,以控制其返回值或操作。
通过使用jest.fn()
,我们可以有效地测试不同的场景和边缘情况,而无需依赖于模拟函数的实际实现:
service = module.get<TransactionService>(TransactionService);
prismaService = module.get<PrismaService>(PrismaService);
httpService = module.get<HttpService>(HttpService);
kafkaService = module.get<KafkaService>(KafkaService);
使用module.get
方法来访问在TestingModule
设置中定义的提供者。它接受服务类作为参数,并返回该服务的实例。通过为每个服务(TransactionService
、PrismaService
、HttpService
和KafkaService
)调用module.get
,代码获取了这些服务的引用,然后可以用于测试目的。
这些服务实例通常在测试用例中使用,以与被测试的系统交互并验证其行为。
首先,让我们从一个简单的测试用例开始:
it(‹should be defined', () => {
expect(service).toBeDefined();
});
此代码片段定义了一个基本的测试用例,以确保服务实例被正确注入。
it('should be defined', () => { ... })
块创建了一个描述为should be defined
的测试用例。在这个块内部,expect(service).toBeDefined();
断言检查service
变量是否有定义的值。这是一个基本的测试,用于验证依赖注入过程是否成功提供了一个TransactionService
的实例。如果服务是null
或undefined
,则测试将失败。
实质上,这个测试用例作为一个理智检查,以确保在继续进行更复杂的测试场景之前,测试环境已经正确设置。现在,让我们切换到测试事务创建过程:
describe('create', () => {
it('should create a transaction with status CREATED if account status is new or active', async () => {
const createTransactionDto: CreateTransactionDto = {
accountId: '1',
description: 'Test transaction',
};
const accountApiResponse = {
data: {
account: {
id: '1',
status: 'active',
},
},
};
jest.spyOn(httpService.axiosRef, 'get').mockResolvedValue(accountApiResponse);
jest.spyOn(prismaService.transaction, 'create').mockResolvedValue({
id: 1,
accountId: '1',
description: 'Test transaction',
status: 'CREATED',
createdAt: new Date(),
updatedAt: new Date(),
});
const result = await service.create(createTransactionDto);
expect(result).toEqual(expect.objectContaining({
id: 1,
accountId: '1',
description: 'Test transaction',
status: 'CREATED',
}));
expect(httpService.axiosRef.get).toHaveBeenCalledWith('http://localhost:3001/v1/accounts/1');
expect(prismaService.transaction.create).toHaveBeenCalledWith({
data: {
accountId: '1',
description: 'Test transaction',
status: 'CREATED',
}, }); });
此测试用例旨在验证在特定条件下TransactionService
的create
方法。
它首先定义了一个测试场景,其中账户状态可以是new
或active
。使用必要的数据创建了一个CreateTransactionDto
对象。为了模拟外部依赖,使用jest.spyOn
来模拟httpService
和prismaService
。
在这里,jest.spyOn
是 Jest 中的一个函数,用于在现有函数上创建一个间谍。与创建新模拟函数的jest.fn()
不同,jest.spyOn
将现有函数包装起来以跟踪调用并可能修改其行为。
我们可以使用jest.spyOn
来观察特定函数在代码中的使用情况,而不会改变其原始实现。它还记录有关函数调用信息,如参数、返回值和调用次数。这对于验证我们代码不同部分之间的交互非常有用。
虽然是可选的,但我们也可以更改被监视函数的行为。当我们想要控制特定测试用例的函数输出时,这很有帮助。测试完成后,我们可以恢复原始函数的行为。
httpService.axiosRef.get
方法被模拟以返回一个具有active
状态的成功的账户响应。prismaService.transaction.create
方法也被模拟以返回一个具有CREATE
状态的已创建交易。
然后调用service.create
方法,并使用准备好的createTransactionDto
对象。测试断言返回的结果与预期的交易数据匹配,表明创建成功。此外,它还验证了httpService.axiosRef.get
是否以正确的 URL 调用以获取账户信息,以及prismaService.transaction.create
是否以正确的数据调用以持久化交易。
本质上,这个测试用例确保create
方法正确地与httpService
交互以获取账户详情,根据账户状态确定交易状态,并通过prismaService
使用预期数据将交易持久化到数据库。通过模拟依赖项,测试隔离了create
方法的逻辑并验证了其行为,而不依赖于外部系统。
现在应该很容易理解我们已经在transaction.service.spec.ts
文件内部实现的其余单元测试。
在实现单元测试时,你可能会听到很多关于模拟、存根和间谍的内容。作为我们关于单元测试的最后一个主题,让我们探索和理解它们的职责。
要运行所有测试,你只需运行npm test
命令(图 12**.2)。
可能存在测试无法正确运行的情况。为了解决这个问题,请参考本书的 GitHub 仓库,并确保package-lock.json
文件与仓库中指定的包版本匹配:
图 12.2:运行事务测试
如前所述,我们可以使用各种构建块进行测试,例如模拟(mocks)、存根(stubs)和间谍(spies)。让我们尝试理解和区分它们。
在单元测试中比较模拟、存根和间谍
我们将在本节中比较单元测试中的模拟、存根和间谍(spy),因为它们是在测试期间隔离和模拟组件行为的关键工具。了解它们之间的差异将帮助我们选择正确的方法,有效地测试系统中的各种交互和功能。
模拟
模拟是一个模拟对象,用于在单元测试中替换真实的依赖项。它旨在模仿原始对象的行为,但对其动作有完全的控制。为什么?这种隔离允许我们专注于审查代码,而无需依赖外部因素。
我们可以为模拟对象定义确切的返回值、异常或动作序列。这使我们能够测试各种场景和边缘情况。模拟可以记录交互,使我们能够验证方法是否以正确的参数、正确的顺序和预期的频率被调用。通过用模拟替换真实依赖项,我们可以创建一个受控的环境,防止意外的副作用,并确保测试的可靠性。
让我们来看看使用模拟的好处:
-
提高测试焦点:模拟帮助你集中精力测试代码的逻辑,而不会被外部组件的复杂性所分散。
-
更快的测试执行:由于模拟不涉及真实交互(如数据库调用或网络请求),测试运行速度显著提高
-
增加测试覆盖率:模拟允许你测试可能难以或在真实环境中无法复制的不同场景和边缘情况。
-
增强代码可靠性:通过在隔离状态下彻底测试代码,你可以在开发早期阶段识别并修复潜在的问题。
接下来,我们将探讨存根。
存根
另一方面,存根是组件的简化实现,用于在测试中替换真实组件。它为测试期间发出的调用提供预制的答案,专注于测试用例所需的特定行为。
存根只包含测试所需的必要逻辑,并返回预定的值或异常。它通常不验证交互或期望。这很好,但你在什么情况下可以使用它们?
-
当你需要通过提供受控的响应来隔离被测试的单元时。
-
当依赖项的行为对测试用例不是关键时。
-
当你想要通过避免复杂逻辑来加快测试执行速度时。
虽然存根和模拟都用于在测试中替换真实组件,但它们之间存在一个关键区别。存根专注于提供预定义的响应,并不验证交互,而模拟允许更复杂的行为,包括期望和验证交互。
在许多情况下,存根(stub)可以满足基本的测试需求,但随着测试要求的日益复杂,模拟(mock)提供了更大的灵活性和控制力。
间谍
间谍是围绕现有对象或函数的包装器,它记录有关其使用情况的信息。与存根和模拟不同,它们替换原始对象,间谍观察真实对象的行為。
这里是间谍的一些关键特性:
-
包装真实对象:间谍可以围绕现有对象或函数创建。
-
记录交互:它们跟踪方法调用、参数和返回值。
-
验证行为:间谍用于确保方法被正确调用,并且使用预期的参数。
你可以在以下情况下使用间谍:
-
当你想验证特定方法是否以某些参数被调用时。
-
当你需要检查方法调用的顺序时。
-
当你想观察函数的副作用而不控制其行为时。
虽然间谍和模拟都可以验证交互,但有一个关键的区别——间谍观察真实对象的行為,而模拟用模拟对象替换真实对象。
此外,存根提供预定义的响应,而不验证交互。模拟替换对象,允许复杂的行為和验证。另一方面,间谍观察真实对象的行為而不对其进行修改。
理解和实现集成测试
集成测试是稳健测试策略的重要组成部分,关注应用程序不同部分之间的交互。与隔离单个组件的单元测试不同,集成测试评估这些组件作为一个整体系统如何协同工作。
在 Express.js 的上下文中,集成测试确保路由、控制器、模型和数据库无缝交互。它们验证数据在这些组件之间正确流动,并且应用程序产生预期的结果。
为什么集成测试是必需的?它们有助于防止集成问题,这些问题通常很复杂且难以调试。通过测试组件之间的交互,你可以在开发周期早期捕捉到潜在的问题,从而降低生产中出现意外行为的风险。
集成测试不是单元测试的替代品。单元测试关注单个函数和模块的正确性,而集成测试验证这些组件如何协同工作。全面的测试策略应包括单元和集成测试。
通过投入时间编写有效的集成测试,你可以显著提高你的 Express.js 应用程序的质量和可靠性。它们有助于防止集成问题,增加对你代码库的信心,并最终提供更好的用户体验。
Node.js 微服务的集成测试专注于验证不同组件或服务之间的交互。它确保这些组件无缝协同工作以产生预期的结果。
集成测试可以在开发早期捕捉到问题,减少生产故障。通过编写全面的集成测试,您可以鼓励更好的代码设计和可维护性。成功的集成测试增强了系统整体可靠性的信心。
在我们继续之前,让我们了解微服务集成测试的关键方面:
-
测试边界:集成测试主要关注微服务之间的接口。它们验证数据交换、合同遵守和错误处理。
-
依赖管理:有效管理依赖是至关重要的。您可能需要使用模拟、存根或测试替身来隔离组件以进行测试。
-
数据一致性:集成测试应验证不同服务之间的数据完整性。这包括测试数据转换、一致性检查和错误处理。
-
性能考虑:集成测试可以帮助识别性能瓶颈和可扩展性问题。
话虽如此,了解何时使用集成测试也同样重要。让我们看看您会在这里使用集成测试的实例:
-
API 交互:测试不同的微服务如何通过 API 进行通信,验证请求/响应格式、错误处理和身份验证。
-
数据库交互:确保数据在多个服务中正确存储、检索和更新。
-
消息队列:验证异步通信模式中的消息投递、处理和错误处理。
-
外部系统:测试与外部系统(如支付网关、电子邮件服务或第三方 API)的交互。
简而言之,Node.js 微服务中的集成测试验证了不同的组件或服务是否正确交互,确保了无缝的功能,并防止了生产中的复杂问题。现在,让我们为事务微服务实现集成测试。
本小节的目的在于向您展示如何为您的 Node.js 服务实现集成测试,特别是对于 Nest.js。集成测试的一般思想适用于所有类型的应用程序,无论您是否使用 Express.js 或其他框架。
和往常一样,我们需要安装所需的包来为我们的项目编写集成测试。转到Ch12/transactionservice
并运行以下命令来安装jest
和supertest
包:
npm install --save-dev jest @types/jest supertest @nestjs/testing
我们已经讨论了jest
包。supertest
包是一个用于测试 HTTP 服务器的高级抽象。它使得向您的 Nest.js 应用程序发送 HTTP 请求并检查响应变得容易,从而模拟现实世界的客户端行为。
Nest.js 与 Jest 和 Supertest 都有出色的集成,使得设置和运行集成测试变得简单。你可以测试 Nest.js 应用程序的各个方面,包括控制器、服务和数据库交互。集成测试可以包含在 CI/CD 管道中,以便在开发早期阶段捕捉到问题。
进入根文件夹(Ch12/transactionservice
)并创建一个名为 jest.config.js
的文件,内容如下:
// jest.config.js
module.exports = {
moduleFileExtensions: [
'js',
'json',
'ts',
],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: [
'**/*.(t|j)s',
],
coverageDirectory: '../coverage',
testEnvironment: 'node',
globalSetup: './test/global-setup.js',
globalTeardown: './test/global-teardown.js',
};
让我们在这里分解一下代码:
-
jest.config.js
文件是一个自定义 Jest 行为的配置文件。这个特定的配置指定了 Jest 应该查找 TypeScript、JavaScript 和 JSON 文件(moduleFileExtensions
)。它将项目根目录设置为src
,定义测试文件为以.spec.ts
结尾的文件,并使用ts-jest
处理 TypeScript 文件。配置还启用了代码覆盖率报告到../coverage
,将测试环境设置为 Node.js,并在所有测试之前执行global-setup.js
,在所有测试之后执行global-teardown.js
。 -
接下来,我们在 Jest 配置中有了
globalSetup
和globalTeardown
,分别用于在整个测试套件运行之前和之后执行代码。 -
然后,
globalSetup
在所有测试之前运行一次。它非常适合设置测试所需的数据库、服务器或其他外部依赖资源。 -
最后,
globalTeardown
在所有测试完成后运行一次。它用于清理在globalSetup
中创建的资源,例如关闭数据库连接或停止服务器。
在提供的配置中,这些操作的脚本位于 ./test/global-setup.js
和 ./test/global-teardown.js
文件中。然而,我们还没有这些文件。所以,让我们创建它们。进入 test
文件夹并创建这两个文件。
这里是我们的 global-setup
文件:
const { execSync } = require('child_process');
module.exports = async () => {
console.log('Starting Docker Compose...');
execSync('docker-compose -f docker-compose.tests.yml up
--build -d', { stdio: 'inherit' });
// You might need to add a delay here to give services time to initialize
await new Promise(resolve => setTimeout(resolve, 15000)); };
这个全局设置脚本初始化一个用于测试的 Docker Compose 环境。它首先记录一条消息,然后使用指定的 docker-compose.tests.yml
文件执行 docker-compose up --build -d
命令。最后,它引入了十五秒的延迟,以便在测试执行开始之前给服务足够的时间启动。
这里是 global-teardown
文件:
const { execSync } = require('child_process');
module.exports = async () => {
console.log(‹Stopping Docker Compose...›);
execSync('docker-compose -f docker-compose.tests.yml down',
{ stdio: 'inherit' });
};
这个 global-teardown
脚本终止 Docker Compose 环境。它记录一条消息指示过程,然后使用指定的 docker-compose.tests.yml
文件执行 docker-compose down
命令,以停止所有正在运行的容器并删除网络。
我们从 Docker 文件中运行所有依赖服务,这就是为什么在 Ch12/transactionservice
下有一个名为 docker-compose.tests.yml
的特殊文件。查看这本书的 GitHub 仓库以获取 docker-compose.tests.yml
文件的源代码。
这个 Docker Compose 文件定义了一个用于微服务应用的多个容器环境。它包括用于 PostgreSQL 数据库的服务、用于数据库管理的PgAdmin
、MongoDB、Zookeeper、Kafka 以及一个 Kafka UI。文件还定义了一个由本地Dockerfile
文件构建的账户服务,并配置了其对 MongoDB 和 Kafka 的依赖。每个服务都指定了环境变量、端口、卷和网络配置。在这里,app-network
用于容器之间的内部通信。
当为 Nest.js 应用编写集成测试时,我们通常会创建一个测试配置文件。这个配置文件指定了测试环境所需的配置值,例如数据库连接、API 密钥或其他敏感信息。你可以为测试目的设置内存或临时数据库,以隔离测试数据并防止与生产数据冲突。这也有助于我们配置模拟库或框架,以便我们可以用测试替身替换真实的外部服务,提高测试隔离性和性能。我们主要定义测试框架或库(如 Jest 或 Supertest)的配置选项,以自定义其集成测试的行为。通过在单独的文件中集中测试特定的配置,你可以增强代码组织、可维护性和可重用性。这也有助于防止敏感信息意外提交到主代码库。查看Ch12/transactionservice/test-configuration.ts
文件以获取更多内容。
我们的测试配置文件为集成测试设置了一个 Nest.js 测试模块。它导入数据库连接(TypeOrm
)、微服务(ClientsModule
)和目标模块(TransactionModule
)所需的必要模块。它还使用环境变量或默认值配置了一个 PostgreSQL 数据库,并建立了一个 Kafka 客户端。最后,testConfiguration
函数编译测试模块,并将其返回以供集成测试使用。
如果你还没有安装它,别忘了运行以下命令以确保你的测试配置可以正常运行:
npm install --save @nestjs/microservices @nestjs/testing @nestjs/typeorm
在这里,@nestjs/typeorm
是一个包,它无缝地将流行的对象关系映射器(ORM)TypeORM
与 Nest.js 框架集成。它提供了一个方便的方式来在 Nest.js 应用中与关系型数据库(如 PostgreSQL、MySQL、SQLite 等)交互。
你必须将你的数据库表定义为 TypeScript 类(实体)。在这里,TypeORM
处理你的代码与数据库模式之间的映射。它支持诸如仓库、迁移、事务等功能,使数据库操作高效且可靠。
现在,是时候为事务微服务编写我们的简单集成测试了。transaction.controller.spec.ts
文件位于Ch12/transactionservice/src/test
下,并包含以下内容:
import { INestApplication } from '@nest.js/common';
import { testConfiguration } from '../test/test-configuration';
import * as request from 'supertest';
describe('AppController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture = await testConfiguration();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
it('/transactions (POST) should create a transaction', async () => {
const createTransactionDto = {
accountId: '6658ae5284432e40604018d5', // UUID
description: 'Test transaction',
};
return request(app.getHttpServer())
.post('/transaction')
.send(createTransactionDto)
.expect(400);
}, 10000); });
此测试导入测试和 HTTP 请求所需的模块。beforeAll
钩子通过使用testConfiguration
函数创建 Nest.js 应用程序来设置测试环境,而afterAll
钩子通过关闭应用程序来进行清理。测试用例侧重于创建事务。它使用示例数据构建事务 DTO,并向/transaction
端点发送POST
请求。预期的响应状态是400
(Bad Request),表示请求中存在错误。测试设置了10000
毫秒(10
秒)的超时。此测试用例验证了事务创建端点的基本功能,并为进一步的测试场景提供了基础。
要运行您的测试,只需执行npm run test
命令。在执行任何集成测试之前,请确保 Docker 正在运行:
图 12.3:Docker 服务
当使用 Docker 化的账户微服务运行集成测试时,主要挑战是确保每个测试的一致数据状态。这涉及到以下操作:
-
数据准备:在每次测试之前创建必要的账户或记录。
-
数据清理:在每次测试后删除测试数据,以防止数据污染。
-
数据库隔离:确保测试数据不会干扰其他测试或环境。
为了处理这些挑战,我们可以使用多种解决方案:
-
使用
typeorm
和sequelize
为您的账户数据库创建迁移脚本:-
使用
globalSetup
或beforeEach
钩子向数据库中填充测试数据。 -
使用
globalTeardown
或afterEach
钩子来清理数据库。
-
-
Docker 卷:为您的账户微服务的数据库定义一个 Docker 卷:
-
挂载卷:将卷挂载到容器中,以在测试运行之间持久化数据。
-
截断或删除数据:在每次测试之前,截断或删除数据库的内容以确保干净的状态。
-
-
测试容器:对于复杂场景,使用专门的容器进行测试数据的准备和清理:
- 使用 Docker Compose 进行编排:使用 Docker Compose 来管理测试容器和账户微服务之间的关系。
-
内存数据库:对于更简单的场景,使用如 SQLite 之类的内存数据库进行测试:
- 优点:更快地启动,隔离,无需数据迁移。
有了这些,我们就到了本章的结尾!让我们回顾一下我们学到了什么。
摘要
在本章中,我们深入探讨了测试在微服务架构中的关键作用。在之前对微服务创建的探索基础上,我们强调了严格测试对于确保代码质量和可靠性的重要性。我们介绍了单元测试和集成测试的概念,解释了它们的独特目的和好处。
为了巩固我们的理解,我们对账户和交易微服务都实现了单元测试。这些测试验证了单个代码单元在隔离状态下的正确行为。此外,我们还探讨了模拟、存根和间谍的细微差别,展示了它们在测试期间隔离组件的实用性。
为了评估不同微服务之间的交互,我们引入了集成测试。通过将单元测试与集成测试相结合,我们为我们的微服务建立了一个稳健的测试策略。
在下一章中,我们将深入探讨 CI/CD 管道的实际实施。我们将探讨如何利用 GitHub Actions 自动化工作流程并简化微服务的部署,特别是关注部署到 Azure 云。你将学习如何构建一个完全自动化的管道,确保你的应用程序在部署时始终保持一致,并且需要最少的手动干预。
第十三章:为您的微服务构建 CI/CD 管道
持续集成(CI)和持续交付/部署(CD)是现代软件开发中的基本实践,构成了高效 DevOps 工作流程的骨干。它们共同自动化并简化了集成代码更改、测试和部署应用程序的过程,确保软件始终处于可部署状态。
对于现代软件开发人员来说,至少理解和掌握构建管道以及与不同自动化系统协作的基本技能是一个现代软件开发的要求。
本章是关于理解和应用 CI/CD 到您的微服务中。培养这些基本的 DevOps 技能将帮助您与现代化开发实践保持一致。
我们将涵盖以下主题:
-
CI/CD 流程的基本要素
-
使用 Azure 云进行协作
-
使用 GitHub Actions 进行协作
-
构建管道
您不需要任何 CI/CD 的先前经验来覆盖和理解本章内容。
CI/CD 流程的基本要素
CI 和 CD 是 Node.js 微服务开发中的基本实践,用于简化开发和发布流程。CI 自动将代码更改集成到主分支,确保每个更新都通过自动化测试进行测试和验证。这减少了集成问题的风险,并有助于保持代码质量。
在 CD 管道中,CI 的每个成功构建都会自动部署到生产或预发布环境中。这种自动化显著缩短了开发和发布之间的时间,使团队能够快速迭代功能和解决问题。
理解 CI
CI 是将代码更改频繁集成到共享仓库的实践。这个过程通常是自动化的,代码每天被合并和测试多次。CI 的主要目标是尽早检测集成问题,减少错误到达生产环境的可能性,并确保新代码始终与现有代码库兼容。
作为开发者,我们频繁地将代码更改(通常每天几次)提交到共享仓库。这减少了冲突和集成问题的可能性。当我们有如 CI 这样的自动化系统时,每次提交后都会触发一个自动构建过程。代码被编译,并解决必要的依赖项。这确保代码库始终处于可构建状态。成功的构建表明代码库处于健康状态,可以继续到下一步,例如测试。
自动测试在构建过程之后执行。这些测试可以包括单元测试、集成测试,有时甚至包括端到端测试。目标是尽早捕捉到开发周期中的任何错误或问题。如果构建或测试失败,开发者会立即收到反馈。这允许他们在问题变得更大之前快速解决问题。
每次提交新的代码更改时,都会触发一个自动构建过程。此过程编译代码,解决依赖关系,并在必要时打包应用程序。
CI 鼓励使用单个共享仓库,该仓库作为项目的单一事实来源。此仓库包含代码库的最新和稳定版本,确保所有团队成员都从相同的基础工作。这种做法特别有助于在团队中保持一致性。
现在,让我们尝试涵盖 CI 的好处:
-
早期错误检测:通过频繁集成代码更改并运行每次集成时的自动化测试,持续集成(CI)使团队能够在开发过程中早期发现错误和问题。这种早期检测减少了修复错误的成本和复杂性。
-
减少集成冲突:频繁集成代码更改意味着冲突可以快速检测和解决。
-
加快开发周期:自动化构建和测试通过消除手动测试和构建过程的需求,为开发者节省时间。这导致开发周期加快,新功能和错误修复的交付更快。
-
提高代码质量:作为 CI 一部分的自动化测试确保只有通过预定义测试集的代码才能集成到主线中。这提高了代码库的整体质量和稳定性。
-
增强协作:CI 通过简化代码集成和共享,鼓励团队成员之间的协作。这促进了透明度和代码库集体所有权的文化。
-
持续反馈:持续的反馈循环为开发者提供有关其更改影响的信息。这有助于保持高代码质量,并减少调试和故障排除所花费的时间。
CI 工作流程帮助团队早期捕捉错误,减少集成挑战,并改善团队成员之间的协作。通过自动化测试和构建的过程,CI 确保代码库保持稳定,并准备好进一步的开发或部署,促进更快和更可靠的发布周期。以下是它是如何工作的:
-
开发者进行更改:第一步是进行更改。开发者在其本地机器上编写新代码或修改现有代码。一旦更改完成,他们将更改提交到版本控制系统(VCS),例如 Git。
-
代码推送到仓库:开发者将提交的更改推送到共享仓库。这触发了 CI 过程。
-
CI 服务器检测更改:CI 服务器(例如 Jenkins、Travis CI、CircleCI 或 GitHub Actions)监控仓库以查找新更改。当检测到更改时,CI 服务器自动触发构建过程。
-
构建是自动化的:CI 服务器拉取最新代码并启动构建过程。这包括编译代码、解决依赖关系以及在必要时创建构建工件。Maven、Gradle 和 Ant 等工具用于自动化构建过程、管理依赖关系和编译代码。
-
测试是自动化的:在构建成功后,CI 服务器运行自动化测试。这些测试可以包括单元测试、集成测试和项目特定的其他类型测试。如果测试通过,CI 过程将继续。如果任何测试失败,过程将停止,开发者将被通知失败情况。JUnit、NUnit、Mocha、Jest 和 Selenium 是用于编写和执行自动化测试的测试框架示例。
-
向开发者提供反馈:CI 服务器向开发者提供反馈,通常通过通知或网页界面。如果构建或测试失败,反馈将包括关于失败详情的信息,帮助开发者快速识别和修复问题。
-
更改合并到主线:一旦构建和测试通过,更改将合并到存储库的主线或 master 分支。这个分支始终代表代码的最新稳定版本。
-
构建被部署:在某些情况下,成功的构建可能会自动部署到预发布环境以进行进一步测试。这可能是持续交付(CD)管道的一部分。
持续集成(CI)工作流程旨在自动化代码更改的集成,确保在合并到主线之前,新更新能够快速测试和验证。通过遵循这一结构化流程,团队可以早期发现问题,减少集成困难,并更高效地交付高质量代码。
几种工具和平台在软件开发生命周期中实施持续集成(CI)方面发挥着重要作用。这些工具确保代码集成、构建和测试过程自动化且高效。如 Git 和 Subversion 等版本控制系统(VCS)管理和跟踪代码库中的更改,而如 Jenkins 和 GitHub Actions 等 CI 服务器自动化构建和测试过程。构建工具如 Maven 和 Gradle 处理依赖关系和编译,而测试框架如 Mocha 和 Jest 使自动化测试成为可能,确保开发过程中的每个阶段代码质量。
理解持续交付(CD)
持续交付(CD)是一种软件开发实践,它使团队能够在更短、更频繁的周期内开发和发布软件,确保可以自信地随时部署。作为持续集成(CI)的演变,CD 不仅强调构建和测试代码,还强调自动化部署过程到生产环境,从而实现更快、更可靠的发布。
这里是持续交付(CD)的核心原则:
-
自动化测试:每个更改都经过自动化测试过程,以确保其适用于生产环境。
-
自动化部署:部署过程是自动化的,这减少了与手动部署相关的风险和错误。
-
增量更新:软件以小而可管理的块形式发布,而不是大型的单体更新。
-
环境一致性:测试、预生产和生产环境保持尽可能相似,以避免部署过程中出现意外问题。
-
持续反馈:从生产环境持续监控和获取反馈,可以快速检测和解决问题。
那很好,但我们如何将其应用到我们的 Node.js 微服务中呢?将 CD 应用于 Node.js 微服务架构涉及几个步骤。
将 CI/CD 集成到微服务中
将 CI/CD 集成到微服务中确保了独立服务的无缝、自动化部署和测试,从而实现更快的开发周期和一致、可靠的更新。这种方法通过简化分布式微服务架构的发布流程,增强了可扩展性和敏捷性。以下是我们在 Node.js 微服务中如何集成它的方法:
-
首先,您需要设置 CI。您可以使用 CI 工具,如 GitHub Actions、Jenkins 或其他工具来自动化构建、测试和打包您的 Node.js 微服务的流程。确保您的 CI 管道在每次提交时都运行单元测试、集成测试和静态代码分析。
-
使用 Docker 对每个 Node.js 微服务进行容器化。这确保了服务在不同环境中的一致运行。为每个微服务定义一个 Dockerfile,以指定依赖项、环境变量和启动命令。
-
下一步是为每个微服务编写自动化测试,包括单元测试、集成测试和端到端测试。使用测试框架,如 Mocha、Jest 或 Supertest 来编写和运行您的测试。
-
最后,确保您的 CI 管道在每次代码更改时都运行这些测试。
-
第二个巨大的步骤是设置 CD 管道。扩展您的 CI 管道以自动将微服务部署到预生产环境。这可以使用之前提到的工具,如 GitHub Actions、Jenkins 或其他工具来完成。使用部署工具,如 Kubernetes、Docker Swarm 或 AWS ECS(弹性容器服务)来管理预生产和生产环境中的容器。
-
通过定义将 Docker 镜像推送到容器注册库(如 Docker Hub 或 AWS ECR)并更新预生产环境中的服务的脚本来自动化部署流程。
-
确保您的本地、测试、预生产和生产环境尽可能相似。这减少了环境特定错误的可能性。
-
为了管理每个环境的不同配置,别忘了使用环境变量。
-
使用 Prometheus、Grafana、ELK Stack 或 Datadog 等工具对您的微服务进行监控和日志记录。设置警报以通知您的团队生产环境中出现的任何问题。
-
使用诸如金丝雀发布或蓝绿部署等部署策略,以最小化部署微服务新版本时的风险。这允许你在将新版本推广到整个用户群之前,用一小部分用户测试新版本。
-
监控生产中服务的性能和日志。从用户那里收集反馈,并在部署导致问题时自动回滚。
-
持续迭代你的流程和工具以改进你的持续交付(CD)管道。通过遵循这些步骤,你可以在 Node.js 微服务开发过程中有效地实施 CD,从而实现更快、更安全、更可靠的部署。
CI 和 CD 是现代软件开发中紧密相关的概念,但它们关注软件开发生命周期的不同阶段。以下是它们之间的区别。
持续集成(CI)的主要焦点是将多个开发者的代码更改集成到一个共享仓库中,每天多次。它还通过早期捕捉集成问题来确保代码始终处于可部署状态。因此,CI 管道侧重于集成和测试代码。它包括代码检查、单元测试、集成测试,有时还包括代码覆盖率报告。
另一方面,持续部署(CD)建立在 CI 的基础上,通过自动化代码更改的交付到各种环境(如预发布和生产环境),在它们通过 CI 管道后。CD 的主要目标是确保代码始终准备好发布到生产环境,并且发布可以频繁且可靠地进行。CD 管道扩展了 CI 管道,包括将代码部署到各种环境的步骤。这可能包括部署脚本、环境配置和自动回滚机制。
从本质上讲,CI 是基础,CD 则扩展了它以涵盖部署方面,允许持续向最终用户提供新功能和更新。
使用 Azure 云服务
云指的是托管在互联网上的远程服务器网络,用于存储、管理和处理数据,而不是依赖于本地服务器或个人计算机。它允许企业和个人从世界任何地方按需访问计算资源,如存储、计算能力、数据库等。
Azure 是微软的云计算平台,提供包括虚拟机、数据库、AI 工具等多种服务。它使开发者和企业能够通过微软管理的全球数据中心网络构建、部署和管理应用程序。Azure 提供灵活性、可扩展性和成本效益,使其适用于从小型初创企业到大型企业的一切。
使用 Azure 云 提供了几个优势,包括与微软生态系统的无缝集成、高可用性和强大的安全功能。它还支持混合云环境,允许企业将他们的本地基础设施与云连接起来。Azure 的全球存在确保了低延迟并符合区域法规。此外,它还提供高级分析、人工智能和机器学习服务,使企业能够在数字时代创新并保持竞争力。
为了使我们的示例尽可能简单,我们将从 第五章 中的 Account
微服务开始。我们将首先获取我们示例所需的全部资源。
我们的 Account
服务将数据存储在 Postgres 数据库中。我们将部署我们的应用程序到 Azure,但您可以使用任何您想要的云基础设施。
首先,我们需要创建一个 Azure 资源组 来部署我们的应用程序。我们可以通过以下步骤来完成:
-
首先,让我们创建一个用于在 Azure 中存储数据的 Postgres 资源。如果您尚未注册,请访问
portal.azure.com
并注册以获取免费账户。使用免费订阅,您可以获得 200 美元的免费信用额度用于 Azure 产品和服务,以及十二个月的流行免费服务。 -
下一步是使用 Azure 门户设置 Azure 资源并创建一个资源组:
-
登录到 Azure 门户。
-
在左侧边栏中,选择 资源组。
-
点击 创建。
-
填写必要的详细信息,例如订阅、资源组名称和区域,然后点击 审查 + 创建(图 13**.1):
-
图 13.1:在 Azure 中创建资源组
-
下一步是创建一个应用服务计划:
-
在 Azure 门户中,在搜索栏中搜索
App Service plans
并选择顶部结果。 -
点击 创建。
-
选择您的订阅,选择您刚刚创建的资源组,并为您的应用服务计划输入一个名称。
-
在 操作系统 下,选择 Linux。
-
选择定价层(例如,B1 基本计划)。
-
点击 审查 + 创建。
在 Azure 中创建应用服务计划是部署使用 Azure App Services 的 Web 应用、API 和其他工作负载时的一个重要步骤。应用服务计划定义了支持您的 Web 应用、API 或函数应用的底层基础设施。它决定了您的应用程序如何托管,包括它使用的 CPU(处理能力)、内存(RAM)、存储(磁盘空间)和网络容量。
通过创建应用服务计划,您指定了运行应用程序所需的资源和容量,确保它具有处理预期负载所需的能力。它通过以下两个选项直接影响在 Azure 中运行应用程序的成本:
-
定价层:您选择的计划决定了定价层,这会影响基于分配的资源成本。Azure 提供各种定价层,从小型应用的免费和共享层到高性能、生产级应用的 premium 层。
-
扩展选项:应用服务计划还定义了您的应用的扩展选项。您可以根据应用的需求进行扩展(增加实例的大小)或扩展(增加实例的数量)。不同的定价层提供不同的扩展能力。
图 13.2*展示了如何在 Azure 中创建应用服务计划:
-
图 13.2:在 Azure 中创建应用服务计划
-
下一步是创建一个 Web 应用:
-
在 Azure 门户中,搜索
App Services
并选择最上面的结果。 -
点击创建并选择Web 应用。
-
选择您的订阅,选择您的资源组,并为您的 Web 应用输入一个名称。确保您为您的 Web 应用选择了一个唯一的名称。
-
对于发布,选择代码。
-
对于运行时堆栈,选择Node 20 LTS。(选择您认为最适合您需求的节点版本)。
-
对于操作系统,选择Linux。
-
在区域下,选择离您或您的用户最近的位置。
-
在应用服务计划下,选择您之前创建的计划。
-
点击审查 + 创建然后创建。见图 13.3*:
-
图 13.3:在 Azure 中创建一个 Web 应用
-
接下来,为 MongoDB API 创建一个 Azure Cosmos DB:
-
在 Azure 门户中,搜索
Azure Cosmos DB
并选择创建。 -
Azure 会要求您选择资源类型,这将是一个请求的单元数据库账户或vCore 集群。vCore 集群是微软推荐的一种资源。
-
在API下,选择Azure Cosmos DB 用于 MongoDB。
-
输入您的订阅、资源组、账户名称和其他必要详情。
-
点击审查 + 创建然后创建:
-
图 13.4:选择 Azure Cosmos DB 用于 MongoDB
您可以在以下图中看到 Azure Cosmos DB 账户创建页面:
图 13.5:Azure Cosmos DB 账户创建页面
-
最后,让我们获取一个 MongoDB 连接字符串:
-
一旦创建了 Cosmos DB 账户,转到概览页面。
-
在设置部分下点击连接字符串。
-
点击眼睛图标。
-
复制主 连接字符串:
-
图 13.6:Azure 中的连接字符串页面
我们已经完成了资源的获取。现在是配置的时候了。
-
现在,让我们配置 Azure Web 应用以使用 MongoDB。为此,导航到您的 Web 应用:
-
在 Azure 门户中,转到
MONGODB_URL
和值(粘贴您之前复制的 MongoDB 连接字符串)。 -
点击确定然后保存。
-
最后,是时候准备您的 Node.js 应用程序了。确保您的 Node.js 应用程序已设置好,可以从环境变量中读取 MongoDB 连接字符串。以下是实现此目的的步骤:
-
前往
src/config/config.js
文件,并按照如下方式修改createConfig
函数:function createConfig(configPath) { dotenv.config({ path: configPath }); const { value: envVars, error } = envVarsSchema .prefs({ errors: { label: 'key' } }) .validate({ PORT: process.env.PORT || dotenv .config({ path: configPath }) .parsed.PORT, MONGODB_URL: process.env.MONGODB_URL || dotenv.config({ path: configPath }).parsed .MONGODB_URL }); if (error) { throw new Error(`Config validation error: ${error.message}`); } return { port: envVars.PORT, mongo: { url: envVars.MONGODB_URL, } }; }
-
当我们运行我们的 Node.js 应用程序时,它将自动连接到一个端口号等于
3001
(取决于您在.env
文件中编码的内容)。我们默认更新了.env
文件以使用PORT=443
。它看起来是这样的:PORT=443 MONGODB_URL=mongodb://localhost:27017/account-microservice
-
我们还对
src/index.js
文件进行了少量更改,以支持winston
库并使用进程中的端口:async function execute() { logger.info('preparing account service ...'); const configPath = path.join(__dirname, '../configs/.env'); const appConfig = createConfig(configPath); logger.info({configPath:configPath}); await db.connect(appConfig); const port = process.env.PORT || appConfig.port; const server = app.listen(port, () => { logger.info('account service started', { port: port }); }); const closeServer = () => { if (server) { server.close(() => { logger.error('server closed'); process.exit(1); }); } else { process.exit(1); } };
如您所猜,对于前两个示例,我们使用了
process.env
。在 Node.js 应用程序中使用process.env.PORT
和process.env.MONGODB_URL
是管理特定于环境的配置的最佳实践。Node.js 应用程序通常需要在不同的环境中运行(开发、测试、预发布、生产),每个环境都有自己的配置集。使用环境变量允许您根据环境自定义行为,而无需更改代码。现在,让我们更详细地看看我们代码中使用的环境变量:
-
process.env.PORT
用于定义 Node.js 应用程序将监听传入请求的端口号。通过使用端口号的环境变量,您可以轻松地在不同的端口上运行应用程序,具体取决于环境。例如,在开发环境中,您可能希望它在端口3000
上运行,而在生产中,应用程序可能需要在一个由托管提供商(例如 Azure、Heroku)分配的端口上运行。云提供商通常为应用程序分配动态端口。通过使用process.env.PORT
,您的应用程序可以适应在运行时分配的任何端口。 -
另一方面,
process.env.MONGODB_URL
用于定义您的 MongoDB 数据库的连接字符串。将敏感信息,如数据库连接字符串存储在环境变量中,可以防止它们出现在源代码中,这是一种安全最佳实践。这可以防止在 VCSs(例如 Git)中意外泄露。不同的环境可能使用不同的数据库或数据库服务器。例如,开发环境可能使用本地 MongoDB 实例,而生产可能使用 MongoDB Atlas 等托管 MongoDB 服务。通过使用process.env.MONGODB_URL
,您可以轻松地在这些之间切换,而无需更改代码。
-
-
部署成功后,Azure 应该运行您的应用程序。这就是为什么您需要更新
package.json
文件以包含start
脚本,如下所示:"scripts": { "start": "node src/index.js", ...other commands }
但关于包安装过程呢?正如您所知,我们不发布 node_modules
,但它应该位于服务器上以正确运行您的应用程序。要处理 node 模块安装并从 package.json
执行启动命令,您可以按照以下步骤操作:
-
从 Azure 门户转到应用服务。
-
选择您的 Web 应用程序。
-
导航到设置。
-
选择配置。
-
前往常规设置。
-
转到
npm install && npm start
:
图 13.7:Azure Web 应用的启动命令
当然,这并不是运行 Node.js 应用程序的唯一正确方式,但在这个例子中,这已经足够了。
现在,一切准备就绪。我们可以使用 GitHub Actions 实现我们的管道。
使用 GitHub Actions
GitHub Actions是 GitHub 的一个强大功能,允许您直接在 GitHub 仓库中自动化、自定义和执行软件开发工作流程。它旨在帮助您从 GitHub 构建、测试和部署代码。GitHub Actions 是一个帮助您在软件开发生命周期内自动执行任务的工具。在我们的案例中,我们将创建一个工作流程,每当您向主分支推送更改时,它将自动将您的 Node.js 微服务部署到 Azure。
首先,如果您还没有,请创建一个账户。账户创建后,创建一个将存储您的源代码的仓库。接下来,让我们看看 GitHub Actions 的一些关键特性:
-
工作流程自动化:GitHub Actions 使您能够在仓库中发生特定事件时自动执行任务,例如运行测试、构建应用程序、部署到云服务等(例如,向分支推送、拉取请求或创建问题)。您还可以使用 GitHub Actions 在您的代码上运行 linting 工具或静态分析,确保代码质量标准得到维护。
-
在您的仓库的
.github/workflows/
目录中。这些文件描述了您想要运行的自动化流程。 -
事件驱动:动作可以由各种 GitHub 事件触发,例如推送、拉取请求、问题创建或按计划执行。这种灵活性允许您创建精细调整到您开发流程的工作流程。
-
内置的 CI/CD:GitHub Actions 提供了内置的持续集成和持续部署支持。您可以使用它在每个提交后自动测试您的代码并将其部署到生产环境或 AWS、Azure 或 Heroku 等云服务。
-
可重用动作:您可以重用社区创建的动作或在不同项目间共享您自己的动作。GitHub 有一个市场,您可以在其中找到用于设置语言、部署到云服务等各种任务的动作。
-
秘密管理:您可以在工作流程中安全地管理和使用敏感信息,如 API 密钥、令牌和其他凭证,而无需在代码中暴露它们。
-
调度任务:您可以使用 GitHub Actions 按计划运行脚本或维护任务,例如夜间构建或数据库备份。
GitHub Actions 与其他 GitHub 功能无缝集成,例如 问题、拉取请求和包,这使得创建涵盖整个开发生命周期的流程变得容易。
现在让我们看看 GitHub Actions 中的密钥。
理解密钥
GitHub Actions 中的密钥是加密的环境变量,您在您的流程中使用它们。它们被安全地存储,可以在您的流程中访问,而不会暴露敏感信息。
要安全地将您的 Azure 凭据(如发布配置文件)传递给 GitHub Actions,您需要将它们作为密钥添加。以下是您可以这样做的方法:
-
前往 Azure 门户并导航到您的网络应用。
-
在网络应用的概览页面,寻找获取发布配置文件按钮并下载发布配置文件文件。它是一个包含 GitHub Actions 工作流程将用于部署应用的凭据的 XML 文件。别忘了将您的 Azure 网络应用的设置-配置选项卡中的平台设置进行更改。
-
一旦您这样做,您将能够下载发布配置文件(图 13**.8)。
-
现在转到
<web_app_name>.PublishSettings
(在我们的例子中是account-microservice-webapp.PublishSettings
)。
向 GitHub Actions 提供 Azure 发布配置文件对于自动化将您的应用程序部署到 Azure 是必不可少的。发布配置文件包含 GitHub Actions 用于验证和授权部署到您的 Azure 资源的凭据。这确保只有授权进程可以部署您的应用程序。
它还包括将您的应用程序部署到特定 Azure App Service 或其他资源的所有必要设置。它简化了配置,避免了在您的流程中手动定义所有部署细节。
在 GitHub Actions 中使用发布配置文件允许您在 GitHub 仓库中安全地存储和管理凭据作为密钥。这可以防止在您的流程文件中暴露敏感信息。以下是下载发布配置文件的平台设置应如何看起来:
图 13.8:下载发布配置文件的平台设置
让我们在 GitHub 中创建密钥:
-
前往您的 GitHub 仓库。
-
导航到设置 | 密钥 | 动作。
-
添加以下密钥:
-
AZURE_WEBAPP_PUBLISH_PROFILE
:Azure 发布配置文件的全部内容(图 13.9)。 -
MONGODB_URL
:在此阶段,您应该粘贴您之前复制的 MongoDB 连接字符串。如果您还没有检索它,请在继续之前现在就做。以下是我们可以这样做的方法:
-
图 13.9:添加 Azure 网络应用的发布配置文件
使用 Secrets 是至关重要的,因为它可以防止敏感数据在您的仓库代码或日志中暴露。只有授权的工作流程可以访问这些秘密。
我们现在已将所有秘密信息提供给 GitHub,因此让我们专注于使用 GitHub Actions 构建一个简单的 pipeline。
构建 pipeline
虽然 GitHub Actions 在其文档中并未明确使用 pipeline 一词,但 pipeline 是一个更广泛的概念,它代表了代码从开发到生产的整个过程序列。在许多 CI/CD 工具中,pipeline 通常由多个阶段(如构建、测试和部署)组成,这些阶段按照特定的顺序执行。
仓库的 .github/workflows/
目录。工作流程由事件触发,例如仓库的推送、拉取请求或计划事件。每个工作流程可以有多个并行或顺序执行的作业,每个作业可以有多个执行命令或操作的步骤。
工作流程是在 .yml
文件中定义的。以下是我们的定义方式:
-
要创建此文件,您应该打开您的网页浏览器并访问您的 GitHub 仓库。在您的仓库内,点击 添加文件 按钮,然后选择 创建 新文件。
-
将文件命名为
.github/workflows/azure-deploy.yml
。这将创建必要的目录结构和文件。提交azure-deploy.yml
文件并将其推送到您的 GitHub 仓库。 -
azure-deploy.yml
文件由多个步骤组成。要查看更完整的示例,请检查我们的 GitHub 仓库(Ch13/.github/workflows/azure-deploy.yml
)。以下是我们的第一步:name: CI/CD Pipeline on: push: branches: - main # The workflow will trigger on pushes to the main branch
GitHub Actions 工作流程文件,命名为
CI/CD Pipeline
,被设置为在仓库的main
分支有推送时自动触发。这意味着任何提交并推送到main
分支的更改都将激活定义的工作流程。on: push:
部分指定了启动工作流程的事件——在这种情况下,是推送到main
分支的事件。这种设置通常用于 CI/CD,确保main
分支的更新自动通过工作流程中定义的构建、测试和部署过程。 -
让我们继续讨论工作流程文件中的下一行:
jobs: security-scan: name: Run Security Scan runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 - name: Set up Node.js uses: actions/setup-node@v3 with: node-version: '20' - name: Install dependencies run: npm install - name: Run npm audit run: npm audit --audit-level=high
此部分 GitHub Actions 工作流程定义了一个名为
security-scan
的作业,该作业负责对您的代码库进行安全扫描。该作业将在由runs-on: ubuntu-latest
指定的最新版本的 Ubuntu 上执行。在此作业中,概述了几个步骤。第一步是
Checkout code
,使用actions/checkout@v3
动作将代码库的代码克隆到工作流程环境中。接下来,Set up Node.js
步骤使用actions/setup-node@v3
动作在环境中设置 Node.js 版本 20。环境准备就绪后,Install dependencies
步骤运行npm install
以安装所有必需的 Node.js 包。最后,Run npm audit
步骤执行npm audit --audit-level=high
命令,该命令检查已安装包中的安全漏洞,重点关注严重级别高的漏洞。此作业确保在 CI/CD 管道中扫描应用程序以查找关键安全问题。 -
以下 GitHub Actions 工作流程的代码定义了一个名为
check-dependencies
的作业,该作业旨在检查项目中是否有任何依赖项过时。该作业将在最新版本的 Ubuntu 上运行,如runs-on: ubuntu-latest
所示:check-dependencies: name: Check Dependencies runs-on: ubuntu-latest needs: security-scan steps: - name: Checkout code uses: actions/checkout@v3 - name: Install dependencies run: npm install - name: Check for outdated dependencies run: npm outdated
needs: security-scan
行指定了此作业只有在security-scan
作业成功完成后才会运行。这在这两个作业之间创建了一个依赖关系,确保在检查过时的依赖项之前,安全扫描必须通过。作业包含几个步骤。首先,
Checkout code
步骤使用actions/checkout@v3
动作将代码库的代码克隆到环境中。然后,Install dependencies
步骤运行npm install
以安装项目所需的所有 Node.js 包。最后,Check for outdated dependencies
步骤运行npm outdated
,列出任何有新版本可用的依赖项。此作业通过确保您了解可能需要更新的任何过时包,帮助维护项目的健康状态。 -
以下 GitHub Actions 工作流程的部分定义了一个名为
test
的作业,该作业负责在代码库上运行测试。该作业在最新版本的 Ubuntu 上运行,如runs-on: ubuntu-latest
所指定:test: name: Run Tests runs-on: ubuntu-latest needs: check-dependencies steps: - name: Checkout code uses: actions/checkout@v3 - name: Set up Node.js uses: actions/setup-node@v3 with: node-version: '20' - name: Install dependencies run: npm install - name: Run tests run: npm test
needs: check-dependencies
行表示此作业只有在check-dependencies
作业成功完成后才会启动。这确保在运行测试之前,所有依赖项都是最新的,这对于确保测试过程的一致性和可靠性非常重要。作业由几个步骤组成。首先,
Checkout code
步骤使用actions/checkout@v3
动作将代码库的代码克隆到工作流程环境中。然后,Set up Node.js
步骤使用actions/setup-node@v3
动作在环境中配置 Node.js 版本 20。接着,Install dependencies
步骤运行npm install
以安装项目所需的所有包。最后,Run tests
步骤执行npm test
命令,该命令运行项目中定义的测试套件。此作业确保在代码合并或部署之前,在受控环境中测试代码,以捕获任何问题。 -
工作流程的以下部分负责在测试完成后将您的应用程序部署到 Azure 网站应用程序。作业在 GitHub Actions 提供的基于 Ubuntu 的虚拟机上运行。在部署之前,工作流程会检出您代码的最新版本,以确保包含最新的更改:
deploy: name: Deploy to Azure Web App runs-on: ubuntu-latest needs: test # Run this job after testing succeeds steps: - name: Checkout code uses: actions/checkout@v3 - name: Clean up unnecessary files run: | rm -rf .git rm -rf .github rm -rf _actions rm -rf _PipelineMapping rm -rf _temp - name: Deploy to Azure Web App uses: azure/webapps-deploy@v3 with: app-name: 'account-microservice-webapp' # Matches the "msdeploySite" in your publish profile publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }} # Ensure this secret contains the publish profile XML content package: ${{ github.workspace }}
-
在部署准备过程中,工作流程会清理不必要的文件和目录,例如
.git
文件夹(包含存储库的 Git 历史)、.github
文件夹(用于 GitHub 特定配置)以及部署应用程序中不需要的其他临时或内部文件夹。这种清理有助于减少部署包的大小,并消除应用程序运行不需要的任何文件。 -
最后,工作流程使用
azure/webapps-deploy@v3
动作将应用程序部署到指定的 Azure 网站应用程序。app-name
配置设置为与 Azure 发布配置文件中的站点名称匹配,而publish-profile
机密包含必要的凭据。要部署的包设置为整个工作区,确保部署的是清理后的代码。
一旦您推送了更改,GitHub Actions 将自动触发工作流程。在您的 GitHub 仓库的 操作 选项卡中监控部署过程:
图 13.10:GitHub Actions 工作流程
这是成功部署日志的示例:
{
id: '77ef5e3e-161d-4b4c-afb2-e44d537ec921',
*******
is_temp: false,
is_readonly: true,
url: 'https://account-microservice-webapp.scm.azurewebsites.net/api/deployments/77ef5e3e-161d-4b4c-afb2-e44d537ec921',
log_url: 'https://account-microservice-webapp.scm.azurewebsites.net/api/deployments/77ef5e3e-161d-4b4c-afb2-e44d537ec921/log',
site_name: 'account-microservice-webapp',
build_summary: { errors: [], warnings: [] }}
Deploy logs can be viewed at https://account-microservice-webapp.scm.azurewebsites.net/api/deployments/77ef5e3e-161d-4b4c-afb2-e44d537ec921/log
Successfully deployed web package to App Service.
App Service Application URL: https://account-microservice-webapp.azurewebsites.net
一旦部署成功,您可以使用简单的 FTPS 连接检查已部署的文件。要使用 FTP 客户端连接到您的服务器,您可以使用您想要的任何 FTP 客户端工具。我们使用 FileZilla,它是免费且易于使用的。您可以从 filezilla-project.org/
下载它。
要找到您的服务器的 FTP 凭据,请按照以下步骤操作:
-
前往 Azure 门户。
-
选择 应用服务。
-
找到您的网站应用程序。
-
前往 部署 | 部署中心
-
选择 FTPS 凭据 选项卡。参见 图 13.11:
图 13.11:Azure 网站应用程序的 FTPS 凭据选项卡
现在,您可以使用这些凭据连接到服务器。以下是连接并导航到 wwwroot
文件夹后的样子:
图 13.12:已部署存储库的 FTP 视图
现在,我们已经准备好测试事情是否正常工作。正如您可能猜到的,在调查 Account 微服务的源代码后,我们在 app.js
中添加了一个简单的中间件:
..............
// Define a route for the welcome page
app.get('/welcome', (req, res) => {
res.send('<h1>Welcome to Express.js Application!</h1>');
});
..............
只需前往 Azure 门户,选择您的网站应用程序,然后在 概览 选项卡中,您将找到默认域名:
图 13.13:Azure 网站应用程序的域名选项卡
打开任何浏览器,将 <Default_domain>/welcome
作为 URL 输入:
图 13.14:已部署 Node.js 应用程序的欢迎页面
要测试是否可以连接到 MongoDB 并创建账户信息,请按照以下步骤操作:
-
打开 Postman。
-
点击+按钮创建新标签页。
-
选择
<default_domain>/v1/accounts
模板(对我们来说,它是https://account-microservice-webapp.azurewebsites.net/v1/accounts
)。 -
进入正文部分并选择raw | Json。
-
将您的有效负载粘贴以创建账户,然后点击发送。
这是我们看到的样子:
图 13.15:使用 Postman 创建账户
要测试是否可以检索账户信息,请按照以下步骤操作:
-
打开 Postman。
-
点击+按钮创建新标签页。
-
选择
<default_domain>/v1/accounts
模板(对我们来说,它是https://account-microservice-webapp.azurewebsites.net/v1/accounts
)。 -
点击发送。
这是我们看到的样子:
图 13.16:检索账户信息
在本节中,我们探讨了将应用程序部署到 Azure 并测试其功能的过程。我们介绍了如何使用 Postman 验证一切是否按预期工作,确保您的应用程序已准备好投入生产环境。现在,您已经对如何在云中部署和验证微服务有了坚实的理解。
摘要
在本章中,我们全面探索了 CI/CD 流程,强调了它们在现代软件开发中的关键作用。我们首先理解了 CI 和 CD 的基础知识以及它们如何简化代码更改的集成和部署过程。
我们的旅程继续深入探讨与 Azure Cloud 一起工作,我们讨论了如何利用其强大的基础设施来部署和管理应用程序。然后我们深入研究了 GitHub Actions,这是一个强大的自动化工作流程的工具,使我们能够高效地构建、测试和部署我们的代码。
本章的大部分内容都致力于构建 CI/CD 管道。我们介绍了创建无缝和自动化管道所需的步骤,确保我们的应用程序始终处于就绪状态,以便部署。
在本书中,我们涵盖了您开始使用 JavaScript 构建微服务所需的一切。从设计基本结构到部署和监控服务,每一章都为您提供了实用的步骤和知识,帮助您创建灵活高效的应用程序。现在,您已经准备好使用微服务承担真实项目,这可以使您的系统更容易扩展、更新和管理。
然而,请记住微服务并非万能良药。最佳设计取决于许多因素,包括您项目的规模、团队结构和业务需求。随着您继续学习和实践,保持好奇心,并牢记技术总是在变化。享受您在微服务世界中的旅程吧!
继续前行,愿您终身编码。直到我们再次相遇。