Node-初学者指南-全-

Node 初学者指南(全)

原文:zh.annas-archive.org/md5/232259533cf3fd193ebb5095ed2ef16f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Hello world!《Node.js 初学者指南》是一本旨在实现特定目标的书籍:在构建一个真实应用的同时,尽可能快地将你从零基础带到部署,并强化每一章的教训。

Node.js 多年来一直是一种领先的技术,尽管有大量资源可供学习,但本书采取了独特的方法。你在这里获得的知识,即使你决定改变技术栈的部分内容,也将保持相关性。让我现在用一个例子来说明这一点。

在整本书中,我们使用 MongoDB,一个非关系型数据库,来构建我们的项目。如果你更喜欢使用 PostgreSQL 等数据库,可能会想知道如何适应项目。我在写这本书时采取的方法将使这种过渡更加顺畅。你将拥有单元测试和一个清晰的接口来无缝管理这些更改。我专门写了一章来介绍使用 Node.js API 进行测试以及使用第三方库如 Jestsupertest,我们将测试集成作为一个安全网,使我们能够在不担心的情况下重构代码。

我写这本书是从 2024 年的角度出发,思考当我刚开始接触 Node.js 时希望被教授的内容。

本书涵盖了你在构建 Web 应用程序时可能会遇到的各种挑战,从 REST API 设计原则到安全性,以及使用 Docker、持续集成等正确应用分发,等等。

这本书是我过去十年里一直在向学生传授并分享给社区的内容的总结。我希望你会发现阅读这本书像写作时一样愉快。

此外,在每一章的结尾,你都会找到额外的资源,帮助你更深入地探索,并学习对你最相关的概念。

本书面向的对象

Node.js 初学者指南》是一本全面介绍,适合那些刚开始接触 Node.js 和/或 Web 应用程序开发的新手,它能让你迅速掌握相关知识。对于那些只想刷新或扩展知识的人来说,这本书也会有所帮助。

本内容的目标受众主要有以下三个角色:

  • 任何希望快速学习 Node.js 或想用 Node.js 开发 Web 应用的开发者

  • 前端开发者,他们想了解更多关于后端开发的知识,或者想成为使用 Node.js 的全栈开发者

  • 已经在日常使用 Node.js 的开发者,并希望扩展或刷新他们在某些领域的知识

本书涵盖的内容

第一章**,Node.js 简介,介绍了 Node.js 作为运行时环境,并解释了单线程背后的核心架构。它还将涵盖 Node.js 的版本和发布计划。

第二章**,设置开发环境,介绍了如何在最流行的操作系统上安装 Node.js,您还将学习如何在同一台机器上管理多个 Node.js 版本。它还将涵盖如何使用 Node.js REPL 和网页浏览器控制台来调试 Node.js 和 JavaScript 应用程序。

第三章**,JavaScript 基础,帮助您刷新对 JavaScript 基础知识(如运算符和循环)的了解。您还将学习如何使用特定的 JavaScript 功能,如闭包、提升和原型继承。

第四章**,异步编程,教您如何实现回调模式,处理承诺,并使用 Async/Await 语法,您还将学习如何正确地组合所有模式,包括错误处理

第五章**,Node.js 核心库,涵盖了核心库的结构,包括 Node.js 二进制的稳定性指数和命令行选项。此外,您还将学习如何使用 ESM 和 CJS 模块化任何代码,以及如何将它们结合起来。

第六章**,外部模块和 npm,介绍了如何使用 NPM CLI 管理依赖项,并使用 npx 在不将它们添加到您的项目的情况下使用 CLI 工具。您将学习如何构建可以在 Node.js 和浏览器中执行的等价代码,并将您的第一个包发布到 npm。我们还将讨论 npm 的替代方案,如 Yarn 或 PnPM。

第七章**,事件驱动架构,探讨了事件驱动架构如何包含在许多核心库中,如 fshttp。您将创建对文件更改做出反应的应用程序或接收 HTTP 请求的应用程序,并将学习如何在您的模块中包含事件 API 作为 API 层。

第八章**,Node.js 中的测试,探讨了在 Node.js 中如何进行测试以及所有可能的方法。我们将使用核心测试库和 Jest 来构建单元测试,并使用覆盖率报告来了解如何改进我们的测试策略。此外,我们还将探讨实际意义上的测试驱动开发TDD)方法。

第九章**,处理 HTTP 和 REST API,介绍了构建网络应用程序的不同策略(如单页应用和服务器端渲染),以及 HTTP 如何以允许我们构建现代和稳固的 API(带有 HTTP 头部、状态码、有效载荷和版本)的方式进行结构化。我们还将学习如何使用 URL 构建强大的接口,同时在传输数据时使用 JSON 格式。

第十章**,使用 Express 构建 Web 应用程序,展示了如何深入使用 Express(请求、响应、重定向、状态码和头部管理),并涵盖了如何使用中间件库以及构建自己的中间件。

第十一章**,从头开始构建 Web 应用程序项目,我们将开始我们的项目工作,并使用 supertest 库构建一个受测试的 REST API。该项目将不断发展,我们将迭代项目,添加新功能和新的测试,以便你可以体验使用 Node.js 开发真实世界应用程序的完整开发周期。

第十二章**,使用 MongoDB 进行数据持久化,展示了如何设置 MongoDB 以及如何在 Node.js 中处理秘密(.env 文件和环境变量)。我们将使用 Mongoose 探索 ORM 世界,并将项目发展到使用 MongoDB 作为数据库解决方案,包括测试和覆盖率报告。

第十三章**,使用 Passport.js 进行用户身份验证和授权,介绍了身份验证和授权之间的区别,并详细探讨了现代网络安全如何基于密码学构建,包括如何工作JSON Web Tokens (JWT)。我们还将在这个项目中实现我们的中间件,并学习如何使用 Passport.js 来处理社交登录策略。

第十四章**,Node.js 中的错误处理,介绍了如何通过正确定义和处理任何类型的错误来使我们的应用程序更具弹性。我们还展示了如何优雅地关闭应用程序并避免生成僵尸进程。

第十五章**,保护 Web 应用程序,探讨了社交对项目的影响和攻击向量。我们将探讨 OWASP Top 10、常见弱点枚举 (CWE) 和 常见漏洞和暴露 (CVE) 如何共同评估风险并减轻我们的应用程序中的风险。我们还涵盖了官方 Node.js 安全最佳实践和线程模型。你将有机会通过我们一起构建的项目将这些知识付诸实践,同时探索通过探索道德黑客领域来在这个领域成长的其他方式。

第十六章**,部署 Node.js 应用程序,我们将把我们的应用程序部署到公共互联网,强调明确的需求和解决方案选择。我们将使用 GitHub Actions 进行 CI,并使用 DigitalOcean、PM2 和 MongoDB Atlas 作为数据库。

第十七章**,将 Node.js 应用程序 Docker 化,我们将使用 Docker 和 DigitalOcean 将我们的应用程序部署到公共互联网。我们还将使用 GitHub Actions 进行持续集成 (CI)。我们将探索域名设置、Cloudflare SSL 和十二要素应用程序原则。

作者承认使用了尖端的人工智能,如 ChatGPT,其唯一目的是为了提高本书的语言和清晰度,从而确保读者有一个顺畅的阅读体验。重要的是要注意,内容本身是由作者创作的,并由专业的出版团队编辑的。

要充分利用本书

本书涵盖的软件/硬件 操作系统要求
JavaScript Windows、macOS 或 Linux
Node.js 和 Node.js 核心库 Docker
Docker Node.js 20.x
npm 包(Express、Mongoose、Passportjs 等)

如果你使用的是本书的数字版,我们建议你亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助你避免与代码的复制和粘贴相关的任何潜在错误。

下载示例代码文件

你可以从 GitHub(github.com/PacktPublishing/NodeJS-for-Beginners)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

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

代码实战

本书“代码实战”视频可在packt.link/FDJvJ查看。

使用的约定

本书使用了多种文本约定。

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“要使用.nvmrc文件,你需要在项目的根目录下创建一个名为.nvmrc的文件,并指定你想要使用的 Node.js 版本。”

代码块设置如下:

userSchema.pre('save', async function (next) { 
  const user = this 
  if (user.isModified('password')) { 
    const salt = await bcrypt.genSalt() 
    user.password = await bcrypt.hash(user.password, salt)
  } 
  next() 
}) 

当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:

userSchema.pre('save', async function (next) {
  const user = this
  if (user.isModified('password')) {
    const salt = await bcrypt.genSalt()
    user.password = await bcrypt.hash(user.password, salt)
}
 next()
})

任何命令行输入或输出都如下所示:

encodeURIComponent('P@ssword') // P%40ssword

粗体: 表示新术语、重要单词或你在屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“通过右键单击页面并点击检查来打开 DevTools。”

小贴士或重要注意事项

看起来是这样的。

联系我们

我们欢迎读者的反馈。

一般反馈: 如果你对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误表: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将非常感激你向我们报告。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上遇到我们作品的任何形式的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。

如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问 authors.packtpub.com

分享您的想法

读完 Node.js for Beginners 后,我们非常乐意听到您的想法!请点击此处直接进入本书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢随时随地阅读,但无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

请放心,现在,每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠不会就此结束,您还可以获得独家折扣、时事通讯和每日免费内容的每日访问权限

按照以下简单步骤获取好处:

  1. 扫描二维码或访问以下链接

免费电子书

  1. 提交您的购买证明

  2. 就这些!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件

第一部分:Node.js 概述和 JavaScript 语言

第一部分,您将了解 Node.js 是如何工作的,以及为什么它是今天用于构建 Web 项目的最受欢迎的工具之一。我们将一起设置开发环境,您将学习 JavaScript 语言的细节以及如何利用其异步编程。

本部分包括以下章节:

  • 第一章, Node.js 简介

  • 第二章, 设置开发环境

  • 第三章, JavaScript 基础

  • 第四章, 异步编程

第一章:Node.js 简介

欢迎来到本书的第一章!Node.js 是最相关的技术之一,它允许您在同一堆栈内构建任何类型的项目(Web、桌面、CLI 工具、微服务、物联网等)。围绕这个项目的社区非常强大且富有创新精神。

在本章中,我们将探讨 Node.js 的主要特性和它为何随着时间的推移而变得如此受欢迎。然后,我们将探讨 Node.js 的架构以及它是如何工作的。最后,我们将探讨我们可用的不同版本的 Node.js。

在本章中,我们将涵盖以下主要主题:

  • 使 Node.js 如此特别并使其成为革命性技术的因素是什么

  • Node.js 的架构和它的工作原理

  • 如何确定适合您项目的正确 Node.js 版本

这项知识将帮助您决定何时适合您的项目,并引导您了解复杂的生态系统。

技术要求

本章的代码文件可以在 github.com/PacktPublishing/NodeJS-for-Beginners 找到。

为什么 Node.js 如此受欢迎?

Node.js 的官方定义非常简单,但它并没有解释为什么 Node.js 随着时间的推移变得如此受欢迎:

Node.js® 是一个开源的、跨平台的 JavaScript 运行时环境。*”

图 1.1 中,我们可以看到 Node.js 的流行度是如何随着时间的推移而增加的,甚至至今仍在快速增长。

图 1.1 – 使用 Google Trends 生成的 Node.js 兴趣度

图 1.1 – 使用 Google Trends 生成的 Node.js 兴趣度

接下来,让我们探讨 Node.js 如此受欢迎的主要原因。

轻量级且快速

Node.js 是一个基于 V8 JavaScript 引擎的轻量级且快速的运行时环境,V8 引擎同样也是 Google Chrome 和 Microsoft Edge 等浏览器所使用的引擎。它基于单线程架构和事件驱动模型,这意味着它不需要为每个请求创建一个新的线程,就像其他流行的工具如 PHP 那样。这是一个巨大的优势,因为内存消耗非常低,性能非常高。

我们将在接下来的章节中详细探讨单线程架构。

跨平台和多用途

Node.js 是跨平台的,这意味着我们可以在现代市场上任何可用的操作系统和架构上运行它。

Node.js 不仅用于构建 Web 应用程序,还可以用于构建任何类型的应用程序,从简单的命令行工具到复杂的桌面应用程序,如 Slack 或 Visual Studio Code。

学习曲线平缓

Node.js 基于 JavaScript,这是世界上最流行的编程语言之一。这意味着数百万的开发者已经熟悉这门语言,他们可以轻松地开始使用 Node.js。

任何可以用 JavaScript 编写的应用程序最终都会用 JavaScript 编写。

– Jeff Atwood (Atwood’s Law)

此外,Node.js 的 应用程序编程接口API)——Node.js 为我们提供的用于使用的各种方法、库和实用工具——非常简单且易于使用,因此学习曲线非常小。你不需要精通 Node.js API 就可以开始构建 Web 应用程序;你可以在构建应用程序的同时逐步学习。

学习 Node.js 的资源非常丰富,从官方文档到多种语言的在线课程和教程,面向不同的用户群体。

生态系统

Node.js 拥有一个庞大的生态系统,包括社区开发的包、JavaScript 库和资源,可用于构建任何类型的应用程序。npm 注册表中提供了超过两百五十万个包 (www.npmjs.com/),这是 Node.js 的官方包管理器。

此外,Node.js 获得了云服务提供商的大力支持,这意味着你可以轻松地将你的应用程序部署到云端,并根据需要扩展它。

大多数新兴技术都为 Node.js 提供了 软件开发工具包SDKs),因此你可以轻松地将你的应用程序与它们集成。许多公司都在生产中使用 Node.js,因此你可以轻松找到支持和资源来解决你可能遇到的任何问题。

此外,许多流行的库都是同构的,这意味着它们可以在浏览器和服务器中使用,因此你可以重用你的代码并避免重复。

社区驱动

对我来说,Node.js 如此受欢迎的最重要原因是社区。Node.js 拥有一个庞大的开发者社区,他们不断为项目做出贡献。这意味着你可以轻松找到支持和资源来解决你可能遇到的任何问题,也可以包括新功能或解决特定的错误。

Node.js 基金会在 2019 年与 JS 基金会合并,成立了 OpenJS 基金会 openjsf.org/,这是目前管理 Node.js 项目以及其他 JavaScript 生态系统中的关键项目(如 Appium、jQuery、Electron、Express 和 webpack)的组织。

重要信息

你可以在 openjsf.org/about/governance/ 找到 OpenJS 基金会的治理模式,以及在 nodejs.org/en/about/governance 找到 Node.js 项目的治理模式。

许多公司都是 OpenJS 基金会的成员,例如 Google、IBM、Microsoft、Netflix、Red Hat、GitHub 以及许多其他公司 (openjsf.org/about/members/)。这些公司为保持项目活力提供了大量的支持和资源。

如你所见,许多因素都在帮助 Node.js 变得如此受欢迎,从经过验证的社区驱动模式到一个为 Node.js 带来许多能力的稳固生态系统。看起来 Node.js 在未来仍将保持流行!

在下一节中,我们将探讨底层架构是如何工作的。

Node.js 的单线程架构

当 Node.js 在 2009 年推出时,它是 Web 开发世界的一次革命,因为 Node.js 的创造者 Ryan Dahl 当时决定采用一个非常不寻常的方法:单线程架构。

在他的 JSConf (www.youtube.com/watch?v=EeYvFl7li9E) 上的 Node.js 演讲中,Ryan Dahl 说他构建 Node.js 时想要实现两个关键目标:服务器端 JavaScript 和非阻塞 I/O。

I/O 需要以不同的方式完成

在 Web 应用程序中,I/O 操作的一般方法是为每个请求创建一个新的线程。这是一个非常昂贵的操作,因为内存消耗很高,性能很低。

这种方法的理念是将系统资源分割并分配给每个线程。这是一个非常低效的方法,因为大多数时候,CPU 都是空闲的,只是在等待资源。

另一个问题是我们可使用的内存量有限,因为每个线程都需要有自己的内存空间。

总体来说,这个过程非常低效,而且不具有可扩展性。

非阻塞 I/O

在 Node.js 中,我们采用不同的方法。我们不会分割资源;我们保持一个单线程,并使用非阻塞 I/O 模型,这允许我们在等待时释放资源,因此我们可以继续处理请求。

为了实现这一点,Node.js 有两个关键依赖:libuv (libuv.org/) 和 V8 (v8.dev/)。

图 1.2 – 展示用户代码、V8、Node API 和 libuv(事件队列和工作线程)之间关系的图

图 1.2 – 展示用户代码、V8、Node API 和 libuv(事件队列和工作线程)之间关系的图

如您所见,这个架构有很多部分,一开始可能会有些令人不知所措。这个图并不是完整的画面,但它是一个理解本章中 Node.js 工作原理的好起点。从这个图中可以理解很多内容,所以让我们一步一步来。

Node.js 应用程序

这是构建我们应用程序的代码。它将使用 JavaScript 编写,并可以使用 Node.js API 和第三方库。

V8

这是 Node.js 中封装的引擎,它将执行我们的 JavaScript 代码。V8 是 Chrome 浏览器底层使用的相同引擎。

Node.js 绑定

对于许多开发者来说,看到 Node.js 主要是用 C/C++编写的可能会感到惊讶,但这是 Node.js 如此快速的一个原因。Node.js 的绑定是当我们在底层使用 Node.js API 时将被执行的 C/C++代码。

libuv

这是一个支持多平台的 C 库,它将处理 I/O 操作。它将使用线程池来执行阻塞操作,并在操作完成后通知 Node.js 绑定。我们将通过定义在特定异步操作完成后将执行的函数来编程 Node.js。例如,当我们尝试从文件中读取内容时,当内容可用时,我们将执行某些代码。libuv 处理这种协调的低级逻辑。

深入了解事件循环

事件循环是 Node.js 架构中最关键的部分。牢记这一点将有助于你理解 Node.js 的工作原理。

正如我们之前所看到的,新的 I/O 操作方法并非魔法,而是一种非常聪明的处理和抽象异步层的方式,这种异步层可以用 JavaScript 轻松处理。这引出了我们需要了解如何进行异步编程的需求。我们将在第四章中更详细地介绍这个主题,但就目前而言,我们需要理解事件循环是如何工作的。

深入理解事件循环的一个绝佳资源是 Philip Roberts 在 JSConf EU 2014 上的这次演讲:究竟什么是事件循环? (www.youtube.com/watch?v=8aGhZQkoFbQ)。它还包括一个名为 Loupe (latentflip.com/loupe) 的工具,你可以用它自己实验事件循环架构。

如你所见,Node.js 是结合了多种技术的产物。事件循环是一个相当高级的话题,你需要一些时间来消化和完全理解,但不用担心,即使你还没有完全清楚事件循环以及所有部件是如何协同工作的,你仍然可以开始使用 Node.js。你可以在书中的练习中更好地了解它。现在,让我们探索 Node.js 是如何组织版本的。

Node.js 版本

Node.js 遵循语义版本化SemVer)(semver.org/),为了选择最适合项目的版本,理解这种版本化工作方式非常重要。

语义版本化(SemVer)

在考虑语义版本化时,有助于确定作为用户可以预期哪些变化,特别是它们是否可能造成中断。这种理解有助于我们的最终用户为潜在更新做好准备。

语义版本化是软件版本化中最受欢迎的方式之一。在下面的图中,我们可以区分构建发布版本所使用的元素。

图 1.3 – 语义版本号的组成部分(来源:Devopedia 2020,https://devopedia.org/images/article/279/2766.1593275997.svg)

图 1.3 – 语义版本号的组成部分(来源:Devopedia 2020,devopedia.org/images/article/279/2766.1593275997.svg)

当发布新版本时,版本号将根据 SemVer 规则递增:

  • 主要版本添加了不兼容的 API 更改

  • 次要版本以向后兼容的方式添加功能

  • 修补版本添加了向后兼容的错误修复

遵循这些规则,我们可以在任何项目中轻松升级 Node.js 版本,而不会在变更被列为次要或修补时破坏代码。

如果我们想升级到新主要版本,我们需要在升级前检查是否有任何需要解决的破坏性变更。在大多数情况下,破坏性变更与我们自己的代码无关,而是与我们在项目中使用的依赖项有关。

重要提示

元数据是可选的,它不用于定义软件版本,但提供额外信息。通常,我们将尽量避免使用带有元数据的版本,因为它们不是稳定版本,但它们可以用于测试目的。

发布详细信息

在我们继续到发布计划之前,了解我们如何检查任何发布的详细信息非常重要。如果我们计划升级到主要版本,这一点尤为重要,因为其中包含破坏性变更。

在这种情况下,我们将分析 Node.js 20.0.0 版本发布,这样我们就可以通过博客详情看到最新 LTS 版本的详细信息:nodejs.org/en/blog/release/v20.0.0/

每个发布都有一个包含以下信息的结构化博客文章:

  • 摘要:在这里,我们可以找到发布的一个简要描述。

  • 显著变更:在这里,我们可以找到发布中的最重要的变更,包括示例以及新功能或弃用背后的许多上下文。我们还可以看到可能影响 Node.js API 的依赖项中的更多相关变更。

  • Semver-(*) 提交:在这里,我们可以找到与 SemVer 变更相关的提交(Semver-Major 提交Semver-Minor 提交Semver-Patch 提交)并直接使用提交引用访问代码变更。

信息

发布信息直接在变更日志中提供。变更日志版本包括对包含在发布中的所有提交和拉取请求的引用,因此当您需要从另一个 Node.js 版本迁移时,它是一个极好的信息来源。您可以在github.com/nodejs/node/blob/main/doc/changelogs/CHANGELOG_V20.md#2023-04-18-version-2000-current-rafaelgss找到变更日志版本。

探索发布中变更的最好方法之一是直接使用 Node.js 文档——例如,nodejs.org/dist/latest-v20.x/docs/api/。网站提供了导航不同版本的选择,这样我们可以更轻松地检查版本间的 API 变更。

图 1.4 – Node.js 官方文档截图

图 1.4 – Node.js 官方文档截图

发布时间表

Node.js 项目有一个发布时间表,该时间表发布在官方网站上(nodejs.org/en/about/releases/),并由 Node.js 发布工作组更新。

图 1.5 – 来自 Node.js 网站的官方发布时间表

图 1.5 – 来自 Node.js 网站的官方发布时间表

在 Node.js 中,发布有三个不同的阶段:

  • 当前 阶段是向项目中添加新功能(非主要变更)的阶段。这个阶段非常活跃,并不总是建议在生产环境中使用它,因为它不是一个稳定的版本。

  • 活跃长期支持LTS)阶段是版本稳定并由 LTS 团队更新的阶段。这个阶段仍然包括新功能、错误修复和更新。这个阶段是稳定的,因此建议在生产环境中使用它。

  • 维护 阶段是版本不再接收任何新功能,只进行关键错误修复和安全更新的阶段。这个阶段适用于那些尚未能够升级到最新活跃 LTS 版本的项目。

重要提示

奇数发布行不会被提升为活跃 LTS,因此不建议用于生产环境。

截至目前,对于任何新的项目,我建议使用最新的 LTS 版本,即 20.11.0。这个版本将得到支持直到 2026 年 4 月,因此对于任何新的项目来说都是一个不错的选择。

对于任何正在使用 Node.js v18 的现有项目,建议开始迁移到 Node.js 20,因为 v18 正进入 维护 阶段。

重要提示

虽然发布新版本看起来像是一项简单的任务,但实际上并非如此。发布工作组已经定义了完整的流程,其中包括超过 20 个步骤。您可以在官方文档(github.com/nodejs/node/blob/main/doc/contributing/releases.md)或这次演讲中找到所有相关信息:丹妮尔·亚当斯在 NodeConf EU 2022 上的 Node.js 发布的生命周期www.youtube.com/watch?v=OiSBodpU174)。

摘要

在本章中,我们探讨了是什么让 Node.js 如此特别,以及它与其他后端系统的不同之处。我们还介绍了 Node.js 的历史以及它是如何随着时间发展的。

此外,我们介绍了 Node.js 架构及其内部工作原理。我们学习了事件循环以及它是如何使 Node.js 高效地处理许多并发请求的。

在下一章中,我们将学习如何设置开发环境并开始使用 Node.js。

进一步阅读

第二章:设置开发环境

要使用 Node.js,我们首先需要准备我们的开发环境。在本章中,我们将详细介绍如何安装 Node.js 并检查一切是否按预期工作,以便我们能够执行 JavaScript 和 Node.js。

Node.js 是一种非常简单且易于安装的软件,因此我们不会在这个主题上花费太多时间。然而,我们将介绍一些重要的细节,这些细节对于你能够在任何环境中使用 Node.js 至关重要。

总结一下,以下是本章我们将探讨的主要主题:

  • 在任何环境中安装 Node.js

  • 管理 Node.js 版本

  • 使用 Chrome DevTools 和 Node.js REPL 与 JavaScript 和 Node.js 交互。

在本章中,你将学习如何在任何环境中正确设置 Node.js,例如 Windows、Linux 或 macOS。这些知识在你将项目部署到云端或特定设备时也同样适用。

此外,你还将学习如何使用浏览器内嵌的调试工具和 Node.js REPL 来调试任何问题。

最后,你将学习如何管理在同一台机器上运行的多个 Node.js 版本。当你需要在不同的 Node.js 版本之间迁移项目时,这项技能将非常有用。

技术要求

本章的代码文件可以在github.com/PacktPublishing/NodeJS-for-Beginners找到。

查看本章在youtu.be/xElsOS9Pz4k上的代码执行视频。

在 macOS、Windows 和 Linux 上安装 Node.js

Node.js 可以通过三种不同的方式安装:

  • 从官方网站下载二进制文件:这是初学者的推荐选项,因为这是安装 Node.js 最简单的方法。你只需从官方网站下载二进制文件并运行安装程序即可。

  • 使用包管理器:这是在 Linux、FreeBSD、IBM i、Android 和类似环境中安装 Node.js 最常见的方式。你只需使用你的系统包管理器并从那里安装 Node.js 即可。

  • 从源码构建:这是安装 Node.js 最先进的方法,它打开了众多自定义的大门,并且仅推荐给高级用户。你需要从官方仓库下载源代码并在你的机器上编译它。

重要提示

作为 Node.js 持续集成的一部分,Node.js 在许多不同的环境和架构中进行测试,这意味着 Node.js 随着时间的推移保持了稳定的跨平台支持。

在撰写本书时,最新的 Node.js 版本是 20.11.0,因此我们将使用这个版本作为参考。然而,你可以使用可用的最新 LTS 版本,因为所有版本的安装过程都是相同的。

接下来的部分将解释如何在各种操作系统上安装 Node.js,从 macOS 开始。

macOS

在 macOS 上安装 Node.js 最简单的方法是从官方网站下载二进制文件。您只需访问 Node.js 下载页面nodejs.org/en/download/,下载 macOS 安装程序,并按照安装向导进行操作。

您也可以使用包管理器安装 Node.js,但这对初学者来说并不推荐。如果您想使用包管理器安装 Node.js,可以使用 Homebrew (brew.sh/) 或 MacPorts (www.macports.org/)。

要使用 Homebrew,打开您的终端并输入以下命令,该命令将为您管理安装过程:

brew install node

要使用 MacPorts,打开终端并输入以下命令以启动安装过程:

port install nodejs20

接下来,我们将看看如何在 Windows 上安装它。

Windows

在 Windows 上安装 Node.js 最简单的方法是从官方网站下载二进制文件。

您只需访问 Node.js 下载页面nodejs.org/en/download/,下载 Windows 安装程序,并按照安装向导进行操作。

接下来,我们将看看如何在 Linux 上安装它。

Linux

最好的方法是使用您的包管理器安装 Node.js,但您也可以使用 NodeSource 分发的二进制文件(github.com/nodesource/distributions/blob/master/README.md)。这将涵盖基于 Debian 和 Ubuntu 的发行版(deb)以及基于企业 Linux 的发行版(rpm)。

让我们以 Ubuntu 为例。

首先,使用curl从 NodeSource 下载设置脚本:

curl -sL https: //deb .nodesource. com/setup_20\. x -o / tmp/ nodesource_setup. sh

然后,检查脚本的内容(可选):

cat /tmp/nodesource_setup.sh

最后,以root用户身份执行脚本并安装 Node.js:

sudo bash /tmp/nodesource_setup.sh
sudo apt install nodejs

其他环境

Node.js 构建工作组提供了一个官方平台列表,其中包括所有受支持的平台和架构及其不同级别的支持。您可以在github.com/nodejs/node/blob/main/BUILDING.md#platform-list找到它。

此外,Node.js 还有一个名为非官方构建项目的倡议,为其他平台和架构提供支持,包括loong64riscv64linux-armv6llinux-x86linux-x64-glibc-217linux-x64-musl。更多信息请见github.com/nodejs/unofficial-builds

如果您对 Docker 有扎实的技能,您还可以使用 Node.js 提供的官方 Docker 镜像来避免在您的机器上安装 Node.js 二进制文件(hub.docker.com/_/node)。

验证安装

Node.js 与 npm 一起提供。我们现在将检查 Node.js 和 npm 是否正确安装。安装的版本可能因您安装的 Node.js 版本而异,但如果它没有抛出错误,则安装正确。

我们将使用终端来检查这两个(Node.js 和 npm)的安装是否正确完成。

要验证 Node.js 的安装,请打开你的终端并输入以下命令:

node –v

预期的输出是已安装的 Node.js 版本:

v20.11.0

要验证 npm 是否已安装,请输入以下命令:

npm -v

预期的输出是已安装的 npm 版本:

10.2.4

恭喜!你已经在你的机器上安装了 Node.js!在下一节中,我们将熟悉 Node.js 版本,以便我们更好地了解我们应该为我们的下一个项目使用哪个 Node.js 版本。

管理 Node.js 版本

Node.js 是一个快速发展的项目,因此每隔几个月就会发布新版本。为了管理机器上的 Node.js 版本,你需要使用 Node.js 版本管理器。

可用的 Node.js 版本管理器有很多,但最流行的是以下这些:

在这本书中,我们将使用 nvm 作为 Node.js 版本管理器,但你也可以使用你喜欢的任何其他版本管理器。

重要信息

在生产环境中,你应该使用可用的最新 LTS 版本,因为这个版本最稳定,并且支持时间更长。在大多数情况下,你不需要在生产机器上安装版本管理器,因为你将使用特定的版本。

既然我们已经熟悉了 Node.js 版本的组织方式,我们将需要一些工具来帮助我们处理同一环境中多个 Node.js 版本。我们将在下一节中介绍 nvm。

使用 nvm 管理 Node.js

nvm 是在机器上管理多个 Node.js 版本最流行且适合初学者的方式。

我使用 nvm 来管理我的 Node.js 版本,因为它是一个非常好的工具,但 nvm 的安装可能有些棘手,所以你需要仔细遵循安装说明。有关常见问题和解决方案的故障排除指南请参阅 github.com/nvm-sh/nvm#installing-and-updating

这是我在 macOS 上安装 nvm 的首选方式,因为它是最容易安装的方式:

brew install nvm

对于 Linux 和 macOS,从官方仓库下载并执行安装脚本:

curl -o- https: //raw. githubusercontent. com/ nvm- sh/ nvm/v0\. 39.3/install. sh | bash

nvm 在 Windows 上不工作,所以如果你使用 Windows,你需要使用另一个版本管理器或 Windows Subsystem for LinuxWSL)。

Windows 上 nvm 的替代方案如下:

安装 nvm 后,您可以使用它来安装和管理 Node.js 版本。

安装和使用版本

为了使用特定的 Node.js 版本,您首先需要安装它:

nvm install 20.11.0

然后,您可以使用它:

nvm use 20.11.0
# Now using node v20.11.0 (npm v10.2.4)

您可以使用以下命令检查正在使用的 Node.js 版本:

node -v
# v20.11.0

您还可以为您的机器设置默认的 Node.js 版本:

nvm alias default 20.11.0

您可以使用 ls 命令列出已安装的 Node.js 版本:

nvm ls

输出将是一个已安装的所有 Node.js 版本的列表。

您可以使用 ls-remote 命令列出可用的 Node.js 版本:

nvm ls-remote

输出将是一个所有可用 Node.js 版本的长列表!

随着时间的推移,我们往往会累积多个 Node.js 版本,因此卸载不再使用的 Node.js 版本是一个良好的实践。

要卸载 Node.js 版本,您需要使用 uninstall 命令:

nvm uninstall 20.11.0

使用 .nvmrc 文件

您还可以使用 .nvmrc 文件来指定项目中想要使用的 Node.js 版本。当您与其他开发者合作开发项目,并确保每个人都使用相同的 Node.js 版本时,这非常有用。

要使用 .nvmrc 文件,您需要在项目的根目录下创建一个名为 .nvmrc 的文件,并指定您想要使用的 Node.js 版本:

20.11.0

然后,当您进入项目目录并运行以下命令时,nvm 可以使用 .nvmrc 文件中指定的 Node.js 版本:

nvm use
# Now using node v20.11.0 (npm v10.2.4)

如果 .nvmrc 文件中指定的 Node.js 版本尚未安装,nvm 将抛出错误,并且不会更改正在使用的 Node.js 版本:

Found '/<full path>/.nvmrc' with version <20.11.0>
N/A: version "20.11.0 -> N/A" is not yet installed.
You need to run "nvm install 20.11.0" to install it before using it.

如果您运行命令而文件未找到,nvm 将抛出错误:

No .nvmrc file found
Please see `nvm --help` or https: //github. com/nvm- sh/ nvm#nvmrc for more information.

现在我们已经熟悉了 nvm 的使用方法,是时候开始在终端中使用 Node.js 了,因此在下节中,我们将探讨如何在交互式环境中使用 Node.js REPL。

Node.js REPL

是的,Node.js 有一个 REPL,并且它在测试代码和尝试新事物时非常有用。

REPL 代表 Read-Evaluate-Print Loop,它是一个简单的交互式计算机编程环境,接受单个用户输入,执行它们,并将结果返回给用户。

要启动 Node.js REPL,您需要运行不带任何参数的 node 命令。输出将类似于以下内容:

Welcome to Node.js v20.11.0.
Type ".help" for more information.
>

现在,您可以开始编写 JavaScript 代码,它将被立即执行:

> console.log("The Node.js REPL is awesome!")
"The Node.js REPL is awesome!"
undefined
> 1 + 1
2
>

要退出 REPL,您可以使用 .exit 命令:

> .exit

您还可以使用 .help 命令来获取所有可用命令的列表:

> .help
.break    Sometimes you get stuck, this gets you out
.clear    Alias for .break
.editor   Enter editor mode
.exit     Exit the REPL
.help     Print this help message
.load     Load JS from a file into the REPL session
.save     Save all evaluated commands in this REPL session to a file
Press Ctrl+C to abort current expression, Ctrl+D to exit the REPL

如您所见,Node.js REPL 非常简单,但它在测试代码和尝试新事物时非常有用。您可以在官方文档中了解更多关于 Node.js REPL 的信息:nodejs.org/en/learn/command-line/how-to-use-the-nodejs-repl

除了 Node.js REPL,我们还可以使用网络浏览器来调试和测试我们的 JavaScript 代码。在下一节中,我们将使用 Google Chrome 来演示这一点。

使用 Chrome DevTools 与 JavaScript 交互

Chrome 浏览器中包含了一组工具(developer.chrome.com/docs/devtools/overview/),定义如下:

“Chrome DevTools 是一套直接构建在 Google Chrome 浏览器中的 Web 开发者工具。DevTools 可以帮助你实时编辑页面并快速诊断问题,这最终有助于你更快地构建更好的网站。”

所有基于 Chrome 的浏览器都有 Chrome DevTools,因此你可以使用任何基于 Chromium 的浏览器,如 Google Chrome、Microsoft Edge、Brave 等。

Node.js 的 REPL 非常有用,但为了使用 Node.js 构建 Web 应用,我们可以使用 Chrome DevTools 进行调试。这种调试将仅限于客户端 JavaScript,因为 Node.js 代码不是在浏览器中直接执行的。

Chrome DevTools 是一个非常完整的工具,因此一开始可能会让人感到有些不知所措,但我们将专注于本书最重要的功能:控制台网络面板。

控制台面板

控制台面板是与网站上的 JavaScript 交互的主要方式。控制台是交互式的,因此我们可以编写 JavaScript 代码,它将立即执行;我们还可以读取控制台输出。

以下视频提供了该工具的概述:www.youtube.com/watch?v=76U0gtuV9AY

你可以在这里阅读官方文档:developer.chrome.com/docs/devtools/console/

网络面板

网络面板非常强大。它允许我们检查 HTTP 请求和响应,以便我们可以看到头部、主体、状态码等。当我们需要调试任何类型的 Web 应用时,这将非常有用。你可以在www.youtube.com/watch?v=e1gAyQuIFQo找到一个很好的教程。

你可以在这里阅读官方文档:developer.chrome.com/docs/devtools/network/

使用 Chrome DevTools

在我们的案例中,我们将从一个空网站开始。我们将使用控制台面板来编写将改变页面的 JavaScript 代码,然后我们将检查 HTTP 请求。按照以下步骤操作:

  1. 在你的浏览器中,前往about:blank;默认情况下,这将显示一个空白页面。

  2. 通过右键单击页面并点击检查来打开 DevTools。

  3. 前往document.body.innerHTML = '<h1>Hello World!</h1>'并按Enter

图 2.1 – Web 浏览器截图

图 2.1 – Web 浏览器截图

  1. 现在,你应该能在页面上看到Hello World!文本。

图 2.2 – 包含 Hello World!文本的 Web 浏览器截图

图 2.2 – 包含 Hello World!文本的 Web 浏览器截图

  1. 前往网络选项卡并导航到packt.com。你应该会看到很多活动。

图 2.3 – Web 浏览器活动

图 2.3 – 网络浏览器活动

这是一个简单的示例,让您熟悉 Chrome DevTools,但您可以用它做更多的事情。我建议您阅读官方文档以了解更多信息。

摘要

恭喜!您的环境已准备好开始使用 Node.js 开发新项目!

在本章中,我们探讨了在各个操作系统上安装 Node.js 的过程。Node.js 与 Windows、macOS 和 Linux 兼容,但我们还研究了其他操作系统的安装过程,包括非官方支持的系统。

此外,我们还深入探讨了使用 nvm 来管理多个 Node.js 版本的方法。nvm 允许您轻松地在不同的 Node.js 版本之间切换。这在处理需要特定 Node.js 版本的项目或测试不同版本之间的兼容性时尤其有用。

此外,本章还涵盖了 Node.js REPL 和 Chrome DevTools 的使用。Node.js REPL 是一个交互式外壳,允许开发者实验 JavaScript 代码,执行命令,并立即看到输出。它提供了一个方便的环境,可以快速测试代码和调试问题。Chrome DevTools 是一组内置在 Google Chrome 浏览器中的网络开发工具。它允许开发者检查和调试 JavaScript 代码,以及调试网络请求等。

在下一章中,我们将学习 JavaScript 的基础知识。您将使用 Chrome DevTools 和 Node.js REPL 来运行示例并进行实践。

进一步阅读

第三章:JavaScript 基础

在本章中,我们将回顾与本书相关的所有 JavaScript 方面。虽然这个主题本身可以是一本独立的书籍,但本章综合了最基本的部分(数组、对象、字符串和数据类型),以便对最复杂的部分进行更深入的分析,例如函数和闭包。

即使你已经熟悉 JavaScript,这一章也会帮助你刷新某些领域的知识。此外,你还将了解由最新规范引入的 JavaScript 的最新变化。

我们还将学习 JavaScript 如何成为在语言请求变更时做出决策的标准。

此外,我们还将回顾一些工具,这些工具将帮助我们通过使用代码风格检查器、调试工具和适当的代码文档来编写更好的 JavaScript。

总结一下,以下是本章我们将探讨的主要主题:

  • 刷新或获取 JavaScript 知识,包括其许多特性

  • 理解 JavaScript 版本和 TC39 委员会

  • 熟悉 JavaScript 文档和代码风格检查

  • 理解 JavaScript 中最常用的部分(注释、数据类型、运算符、条件语句、循环、函数、对象、数组等)

  • 理解高级 JavaScript 概念,如闭包和原型

技术要求

本章的代码文件可以在github.com/PacktPublishing/NodeJS-for-Beginners找到。

查看本章代码的实际操作视频,请访问youtu.be/BxM8XZzINmg

JavaScript 是一种强大的语言

JavaScript 是一种非常强大的语言。它被用于前端、后端、移动、桌面、物联网等领域。它非常灵活,而且很容易入门,但深入掌握它也非常困难。

Douglas Crockford 有一句非常著名的引言(www.crockford.com/javascript/javascript.html)说:

JavaScript 是世界上被误解最多的编程语言。

JavaScript 是一种多范式语言,这意味着你可以使用不同的编程风格,如面向对象编程、函数式编程或声明式编程。这非常有用,因为你可以使用最适合你需求的编程风格。但另一方面,对于初学者来说,这可能会非常令人困惑,而且并非所有编程风格都得到语言的同等支持。

JavaScript 是一种非常动态的语言,这意味着你可以在运行时更改语言的行为。多亏了 JavaScript,你可以学习复杂的概念,如闭包和原型,并使用它们来创建非常强大和复杂的应用程序。但你也可以使用它们来创建非常混乱且难以维护的应用程序。

在接下来的章节中,我们将学习如何使用 JavaScript 创建强大的应用程序,但我们也会学习如何以易于理解和维护的方式使用它。

重要提示

如果你并不熟悉所提到的任何一种范式,请不要担心。在这本书中,我们将逐步融入每种范式的元素,根据需要介绍它们。

在下一节中,我们将探讨 TC39 在 JavaScript 中的作用以及规范是如何工作的。

理解版本控制 – TC39

JavaScript 正在变得陈旧;它是由 Brendan Eich 在 1995 年于 Netscape Communications Corporation 创建的。最初它被称为 Mocha,但后来改名为 LiveScript,最终成为 JavaScript。

JavaScript 的第一个版本于 1996 年发布。它被称为ECMAScript 1ES1),并于 1997 年由欧洲计算机制造商协会ECMA)标准化。

理解版本控制 – ECMAScript

在过去的几年里,语言中添加了许多新特性,例如类、模块和箭头函数。这些新特性是通过一个名为 ECMAScript 提案的提交流程添加到语言中的,该流程由 TC39 直接管理(github.com/tc39/proposals),它指的是负责语言演变的 ECMA 委员会(tc39.es/process-document/)。

从 1997 年到 2015 年,每隔几年语言就会增加新特性,但在 2015 年,TC39 决定每年发布一个新的语言版本,这意味着语言的演变速度比以往任何时候都要快。这也帮助我们更快地采用新特性,因为我们不需要等待很多年才能在生产环境中使用它们。

目前,语言的最新版本是 ECMA-262 2023(tc39.es/ecma262/),于 2023 年 6 月发布。

下一个版本的 JavaScript 将包括什么?

为了向语言添加新特性,TC39 委员会有一个分为几个阶段的过程。任何人都可以向 TC39 委员会提交提案,但这并不是一件容易的事情,因为提案在实施之前需要得到委员会的批准。

你可以在 TC39 的 GitHub 仓库中找到所有提案(github.com/tc39/proposals)。你可以参与讨论并融入社区。

JavaScript 规范中不包括什么?

JavaScript 规范非常大,但它不包括许多在 JavaScript 应用程序中常用的 API,例如浏览器 API 和 Node.js API。

如果你正在浏览器中使用 JavaScript,你可以使用浏览器 API,例如文档对象模型DOM)。如果你正在 Node.js 中使用 JavaScript,你可以使用 Node.js API,例如文件系统或 HTTP。

最后,JavaScript 只是一种编程语言。如果你习惯在浏览器中构建 JavaScript 应用程序,你可能熟悉许多不在 JavaScript 规范中包含且在 Node.js 中不可用的 API。例如,window 对象(developer.mozilla.org/en-US/docs/Web/API/Window)在浏览器中可用,但在 Node.js 中不可用。

现在我们已经了解了规范的工作方式,是时候在下一节中探索 JavaScript 文档了。

探索 JavaScript 文档

虽然 ECMA-262 (262.ecma-international.org/14.0/) 是一个很好的信息来源,但它对初学者来说并不友好。

最完整的信息来源是 MDN Web Docs (developer.mozilla.org/en-US/docs/Web/JavaScript),这是一个由社区驱动的文档。它非常全面,定期更新,甚至被翻译成其他语言。

如果你熟悉前端开发,你可能之前使用过 MDN Web Docs,因为它是浏览器 API(如 DOM developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Introduction 和 Fetch API developer.mozilla.org/en-US/docs/Web/API/Fetch_API)的主要信息来源。

如果你需要更简洁的文档,可以使用 W3Schools (www.w3schools.com/js/default.asp),它是初学者的绝佳信息来源,有很多示例。

最后,如果你在寻找某个问题的具体答案,可以使用 Stack Overflow (stackoverflow.com/questions/tagged/javascript),这是一个由社区驱动的问答网站。

在下一节中,我们将学习如何使用代码检查工具轻松地改进我们的 JavaScript 代码。

检查 JavaScript 代码

代码检查是运行一个程序的过程,该程序将分析你的代码以查找潜在的错误。在运行代码之前捕捉错误非常有用,这样你就可以在它们造成任何问题之前修复它们。

JavaScript 是一种非常灵活的语言,这意味着很容易犯错。随着你对它的熟悉程度增加,你犯的错误会越来越少,但始终有一个代码检查器来帮助你总是好的。

在下一章中,我们将使用 ESLint (eslint.org/) 来检查我们的代码,但还有其他选项可用,例如 JSLint (www.jslint.com/) 和 JSHint (jshint.com/)。

配置代码检查器不是一项简单的工作,但这是值得努力的。有许多规则可供选择,而且很难知道哪些规则应该使用。我强烈建议您使用标准规则 (standardjs.com/),这是最受欢迎的规则之一,被许多开源项目(包括 Node.js、Express 和 MongoDB)和公司所使用。您可以在 JavaScript Standard Style 页面上找到所有可用的规则 (standardjs.com/rules.html)。

在 *图 3**.1 中,您可以了解标准是如何用于审查项目源代码的。它将推荐如何遵循配置的规则。

图 3.1 – GitHub Codespaces 的截图

图 3.1 – GitHub Codespaces 的截图

在下一节中,我们将学习如何记录我们的代码,使其更容易维护。

注释 JavaScript 代码

您有多种方式在代码中包含注释:

// Single line comment
/*
Multiline
comment
*/

如果您是 JavaScript 的初学者,我建议您使用大量的注释来帮助您理解代码中的内容。随着您经验的增加,您将需要更少的注释。注释还有助于其他开发者阅读和理解您的代码。

使用 JSDoc

如果您需要有关如何编写良好注释的指导,您可以使用 JSDoc (jsdoc.app/) 语法。使用 JSDoc 的另一个额外好处是,您可以使用它来自动生成代码的文档。

这是一个相当流行的解决方案。例如,Lodash 就使用这种方法。使用以下链接查看 _.chunk 方法的文档:

在下一节中,我们将学习如何使用 console 来加速我们的调试过程。

打印值和调试

console 对象是非标准的;它不是 JavaScript 语言的组成部分,但它由浏览器和 Node.js 提供。您可以使用它将消息打印到控制台,这对于调试目的以及本书的示例跟踪非常有用。通常,人们会使用它来打印变量的值。以下是一个例子:

const name = "Ulises";
console.log(name); // Ulises

是的,您可以使用 console.log 同时打印多个值,这些值由逗号分隔,甚至可以包含额外的信息来解释您正在打印的内容。您不必担心变量的类型,就像在其他语言中一样;console.log 会为您处理。

在某些情况下,您可能需要帮助 console.log 打印变量的值;例如,如果您想打印一个对象,有时您会得到 [object, object] 或类似的输出消息。在这种情况下,您需要使用 console.log(JSON.stringify(object)) 来将对象作为字符串打印:

const data = {
  nestedData: {
    moreNestedData: {
      value: 1
    }
  }
};
console.log(data); // [object, object]
console.log(JSON.stringify(data)); // {"nestedData":{"moreNestedData":{"value":1}}}

随着时间的推移,JavaScript 引擎改进了 console 输出,因此这个简单的示例可能在您的浏览器中按预期打印;但某些复杂对象可能仍然需要转换为字符串,例如,来自长时间 HTTP 请求的响应。

重要提示

console 对象提供了许多方法来以不同格式打印信息,这将大大提高您的开发者体验。文档可在网络浏览器中查看(developer.mozilla.org/en-US/docs/Web/API/console)和 Node.js 中查看(nodejs.org/api/console.html)。

在下一节中,我们将学习 JavaScript 如何使用常量和变量来存储我们在构建应用程序时所需的信息。

变量和常量

我们使用变量来存储值,使用常量来存储不会改变的值。在 JavaScript 中,我们可以使用 let 关键字来声明变量,使用 const 关键字来声明常量。在 ES6 之前,我们只能使用 var 关键字来声明变量,但现在不建议再使用它。

命名规范

在 JavaScript 中,使用 camelCase 命名变量和常量非常常见,但也支持其他规范,例如 snake_casePascalCase。变量也可以以符号开头,但通常不推荐这样做。

在命名变量和常量时,我们需要考虑一些限制:

  • 避免以符号开头,例如 $resource

  • 不要以数字开头,例如 1variable

  • 不要使用空格,例如 const my variable = 1

  • 不要使用保留字,例如 const const = "``constant"

letconst

我们使用 let 来声明变量,使用 const 来声明常量。主要区别在于我们可以重新分配变量的值,但不能重新分配常量的值。以下是将值重新分配给变量的示例:

let userName = "Joe Doe";
console.log(userName); // Joe Doe
userName = "Jane Doe";
console.log(userName); // Jane Doe

如我们所见,我们不能将值重新分配给常量:

const userName = "Joe Doe";
console.log(userName); // Joe Doe
userName = "mary"; // TypeError: Assignment to constant variable.

重要的是要注意,如果常量的值是对象,我们可以更改其值,但不能将新值重新分配给常量:

const user = {
  name: "Joe Doe"
}
console.log(user.name); // Joe Doe
user.name = "Jane Doe";
console.log(user.name); // Jane Doe
user = "Mr. Joe"; // TypeError: Assignment to constant variable.

在本章的后面部分,我们将更详细地探讨对象,并更深入地理解这些突变。

在 JavaScript 中,还有一个你需要了解的机制。提升(Hoisting)是 JavaScript 中的一个行为,在编译阶段,变量和函数声明会被移动到它们包含的作用域的顶部。这样做是为了优化代码,但可能会产生一些副作用。你可以在 www.freecodecamp.org/news/what-is-hoisting-in-javascript-3 找到一篇很好的指南。

既然我们已经清楚变量和常量的工作方式,现在是时候探索 JavaScript 中可用的不同数据类型了。

理解数据类型

在 JavaScript 中,有几个原始类型。我们可以将它们分为两组:ES6 之前(undefinedobjectbooleannumberstringfunction)和 ES6 之后(bigintsymbol)。为了检查变量的类型,我们可以使用 typeof 操作符。

undefined

并非所有语言都有 undefined 类型,但 JavaScript 有。它用于表示值的缺失。它也用作未初始化变量的默认值。

object

object 类型用于表示数据集合。它是一个非常通用的类型,用于表示许多不同的事物,例如数组(列表)、对象(字典)、类实例和 null

boolean

boolean 类型用于表示逻辑值。它可以是 truefalse。此类型也可以通过使用 Boolean 函数生成,因为 JavaScript 中的任何内容都可以转换为 boolean 值。

number

number 类型用于表示数值。它可以是整数或浮点数。它也用于表示特殊的数值,如 Infinity-InfinityNaN(代表“非数字”)。

string

string 类型用于表示字符序列。它可以通过使用单引号 ('), 双引号 ("), 或反引号(`)显式创建,或者通过使用 String 函数或表达式隐式创建。

function

function 类型用于表示函数。JavaScript 中的函数非常强大。我们将在本章中详细探讨它们。创建函数有两种方式,一种是使用 function 关键字,另一种是使用箭头函数语法。

bigint

bigint 在 ES6 中引入,以便处理大数字。number 限制在 -(253 – 1) 和 253 – 1 之间

symbol

symbol 类型用于表示唯一标识符。它是在 ES6 中引入的新类型;你不需要真正熟悉它就能跟随这本书的内容。

在下一节中,我们将深入探讨数字,包括 Math 内置库、常用的比较运算符以及转换数字和字符串的有用方法。

探索数字

JavaScript 对数学运算和日期的支持良好,但有时它可能比其他编程语言更复杂、更有限,因此许多开发者在应用程序需要高级数学时使用专门的库。例如,如果你需要处理向量、矩阵或复数,你应该使用 Math.js 这样的库 (mathjs.org/)。

这里是浮点精度问题的典型示例:

console.log(0.1 + 0.2); // 0.30000000000000004
console.log(0.1 + 0.2 === 0.3); // false

正如你所见,0.1 + 0.2 的结果是 0.3,而不是 0.30000000000000004。这是因为 JavaScript 使用 IEEE 754 标准 (en.wikipedia.org/wiki/IEEE_754) 来表示数字,并且无法用二进制表示所有十进制数字。这是许多编程语言中常见的难题;这并不是 JavaScript 独有的问题。但你可以通过使用 NumbertoPrecision 函数来解决这个问题,你将隐式地将数字转换为字符串,然后再将其转换回数字:

let impreciseOperation = 0.1 + 0.2;
Number(impreciseOperation.toPrecision(1)) === 0.3; // true

正如你所见,有一些边缘情况不容易直观理解或解决。大多数时候,你不需要担心这个问题,但重要的是要知道这个问题存在,如果你在 JavaScript 中对数字不够熟悉,你可以使用库。

算术运算符

JavaScript 有预期的算术运算符,+-*/%**,并且像任何现代语言一样使用括号来表示优先级。

赋值运算符

JavaScript 有预期的赋值运算符,=, +=, -=, *=, /=, %=, 和 **=,与其他语言类似。

此外,你可以使用 ++-- 来增加和减少变量的值。这个运算符可以放在变量之前或之后,它将在操作之前或之后改变变量的值:

let a = 5;
console.log(a++); // 5
console.log(a);   // 6
console.log(++a); // 7
console.log(a);   // 7

其他信息

JavaScript 还支持位运算,因此你可以处理一组 32 位(零和一),而不是十进制、十六进制或八进制数字。你可以在这里查看完整的文档:developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Expressions_and_operators#bitwise_operators

有用的方法

有一些方法对于在日常工作中进行数学运算或转换至关重要:

数学对象

JavaScript 内置了一个 Math 对象,它提供了许多用于执行数学运算的有用方法。以下是一些例子,但完整的列表可以在 MDN 文档中找到 (developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math)。

有用的方法

在日常工作中执行数学运算或转换的关键方法有:

其他数字

在 JavaScript 中,有一些特殊值是数字,但它们不是实数。这些值是 NaNInfinity

不是一个数字 (NaN)

NaN 是一个特殊值,表示“不是一个数字”。它是无效或未定义的数学运算的结果,例如,0 除以 0,或者无穷大乘以 0。你可以使用 isNaN() 来检查一个值是否为 NaN (developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/isNaN)。

无穷大

Infinity是一个表示无穷大的特殊值。它是数学运算超过可能的最大数字的结果。你可以使用isFinite()来检查一个值是否是有限的(developer.mozilla.org/es/docs/Web/JavaScript/Reference/Global_Objects/isFinite)。

在下一节中,我们将深入探讨日期。

探索日期对象

对于任何编程语言或系统来说,日期都是一个复杂的话题,因为你需要考虑许多因素,例如时区。如果你需要密集地处理日期,考虑使用像 Lunox(github.com/moment/luxon/)或 date-fns(date-fns.org/)这样的库。

对于更简单的场景,你可以使用内置的Date对象和 Intl API(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl)来格式化日期。

该 API 提供了多种方式通过使用数字、字符串或多个参数来生成日期对象。此外,你还有 getter 和 setter 来读取和修改特定的部分,例如年份或毫秒数。你也可以执行比较或添加时间等操作。

多年来,在 JavaScript 中格式化日期的唯一方法就是使用toLocaleString()方法。这个方法仍然有效,但它有很多限制,特别是当你想要以人类可读的方式比较日期时(例如,3 天前2 周前)。

在过去,我们需要使用外部库来实现这一点,但现在我们可以使用 Intl API(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl)来格式化日期。

在下面的代码中,你可以看到如何生成、操作和格式化日期:

const jsDateAnnouncement = new Date(818031600000);
const currentDate = new Date();
const diff = jsDateAnnouncement - currentDate;
const formatter = new Intl.RelativeTimeFormat('en', {
    numeric: 'auto'
});
const diffInDays = Math.round(diff / 86400000);
const diffInYears = Math.round(diffInDays / 365);
const diffInText = formatter.format(diffInDays, 'day');
console.log(`JavaScript was presented to the world ${formatter.format(diffInDays, 'day')}`);
// JavaScript was presented to the world 10,094 days ago
console.log(`JavaScript was presented to the world ${formatter.format(diffInYears, 'year')}`);
// JavaScript was presented to the world 28 years ago.

由于我写这段代码已经有一段时间了,所以结果可能会因机器而异。因此,请记住,你观察到的输出可能与我不同。

重要提示

TC39 正在很好地整合这个 API,它包括许多用于格式化日期、数字、货币等功能。我建议你关注该提案在 JavaScript 引擎中的进展和实现。

在下一节中,我们将通过使用 JavaScript 提供的几个工具来学习如何使用条件语句。

条件语句

在 JavaScript 中编写条件语句有许多方法,但最常见的是ifswitch和三元运算符(?:)。

数学比较运算符

对于数学运算,我们有以下运算符:>, <, >=, 和 <=。它们用于比较两个值并返回一个布尔值。它们的使用方式与大多数现代编程语言相同。

等于运算符

相等运算符用于比较两个值并返回布尔值。有两种类型的相等运算符:严格(===!==)和非严格(==!=)。

严格相等运算符不能用于比较非原始类型(如 objectarrayfunction)以及某些值(如 NaN),因为它总是会返回 false

console.log([1,2] === [1,2]) // false
console.log({ name: 'John' } === { name: 'John' }); // false
console.log(NaN === NaN); // false

不建议使用非严格相等运算符,因为它们可能会导致意外结果,因为这个运算符不会检查值的类型:

console.log(1 == '1'); // true
console.log(1 != '1'); // false

逻辑运算符

您可以使用逻辑运算符组合多个条件。有三个逻辑运算符,&&||!,以及它们的某些变体,&&=||=,这些变体用于减少某些操作的代码量。我们不会在这本书中涵盖所有这些内容。

您可以将运算符组合起来构建更复杂的验证:

const num = 2
console.log((num == 2) && (3 >= 6)); // false
console.log((num > 3) || (17 <= 40)); // true

NOT 运算符 (!)

NOT 运算符用于反转布尔值的值。如果值是假的,它将返回 true;如果值是真的,它将返回 false

console.log(!true); // false
console.log(!false); // true

这个例子并没有清楚地说明所有提供的选择,所以让我们尝试构建一个更冗长的结构,Boolean(value) === false 的类比。基本上,! 运算符将值转换为布尔值,然后与 false 值进行比较。

JavaScript 中的相等性

由于 JavaScript 的特性,可以使用任何值作为条件。条件将被评估为布尔值,如果值是真值,条件将为 true。如果值是假值,条件将为 false。这可能会有些令人困惑,所以让我们来探索 Boolean 方法,以了解不同数据值是如何转换的:

// The truthy values:
console.log("String:", Boolean("Ulises")  );
console.log("1235:", Boolean(1235));
console.log("-1235:", Boolean(-1235));
console.log("Object:", Boolean({text: "hi"}));
console.log("Array:", Boolean(["apple", -1, false]));
console.log("Function:", Boolean(function(){}));
console.log("Arrow function:", Boolean(() => {}));
// The falsy values:
console.log("Empty string:", Boolean("")  );
console.log("0:", Boolean(0));
console.log("-0:", Boolean(-0));
console.log("null:", Boolean(null));
console.log("undefined:", Boolean(undefined));
console.log("NaN:", Boolean(NaN));

我们可以很容易地得出结论,空值(如 nullundefined、空字符串或 NaN)和 0 是假值,而具有复杂数据类型(如对象和函数)或非空字符串和非零数字的值是真值。

当我们想要检查一个值是否为空时,这非常方便,如下面的例子所示:

function checkValue (value) {
    if(!value) {
        throw new Error ("The value is invalid! Try again.")
    }
}

如果您想要比较不同数据类型和值,例如 Boolean([]) === Boolean({}),这种布尔转换和比较可能会变得非常复杂。您可以在 MDN 文档中详细了解这个主题(developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness)。但一般来说,您不需要在这个领域成为专家,就能跟随这本书的内容。

注意

您可以通过探索 Dorey 的 JavaScript 相等表来更好地理解这个主题(github.com/dorey/Javascript-Equality-Table/)。

空值合并运算符 (??)

空值合并运算符是一个在 ES2020 中引入的新运算符。它用于检查一个值是否为 nullundefined;如果是,它将返回一个默认值:

const name = null ?? "John Joe";
console.log(name); // John Joe

if 语句

if 语句是编写条件语句最常见的方式。如果条件为真,它将执行块内的代码。else 语句允许我们在条件不满足时执行 else 语句中的代码。else if 语句是 if 语句的一种变体。如果条件为真,它将执行块内的代码。如果条件为假,它将执行 else 块内的代码。你可以根据需要添加任意多的 else if 语句:

const condition = true
const condition2 = true
if(condition) {
    console.log("The condition is true")
} else if (condition2) {
    console.log("The condition2 is true")
} else {
    console.log("The condition and condition2 are false")
}

你可以更改 conditioncondition2 中的值,以便更熟悉条件结构的操作。

return 语句的使用

return 语句被广泛用于避免使用 else 语句,并允许编写更干净的代码。以下是一个示例:

const condition = true;
if(condition) {
    return console.log("The condition is true");
}
console.log("The condition is false");

switch 语句

当你想将一个变量与多个值进行比较时,switch 语句是一个不错的选择。当你想根据条件给变量赋值时,它也很好用。

switch 结构由 switch 关键字组成,后面是你想要比较的变量,然后是一系列 case 语句。每个 case 语句由 case 关键字组成,后面是你想要比较的值,然后是一个双冒号 :。在双冒号之后,你可以编写如果条件为真时你想要执行的代码。default 语句是可选的,如果没有任何 case 语句为真,它将被执行,就像使用 if 语句时的 else 一样。

break 语句用于停止 switch 语句的执行。如果你没有添加 break 语句,代码将继续执行下一个 case 语句。以下是一个组合示例:

const extension = ".md";
switch (extension) {
  case ".doc":
    console.log("This extension .doc will be deprecated soon")
  case ".pdf":
  case ".md":
  case ".svg":
    console.log("Congratulations! You can open this file");
    break;
  default:
    console.log(`${extension} is not supported`);
}

三元运算符

三元运算符是 ifelse 语句的简写。当你想根据条件给变量赋值时,它是一个不错的选择。

结构由条件组成,后面跟着一个问号 ?,然后是如果条件为真时你想要分配的值,接着是一个双冒号 :, 然后是如果条件为假时你想要分配的值:condition ? valueIfTrue : valueIfFalse

让我们通过 ifelse 语句的示例来看一下:

const isMember = true;
console.log(`The payment is ${isMember ? "20.00€" : "50.00€"}`);
// The payment is 20.00€

三元运算符可以嵌套多个三元运算符,但并不推荐,因为这可能会非常难以阅读。此外,可以使用三元运算符执行多个操作,但也不推荐,即使使用括号,它也可能非常难以阅读。

既然我们已经清楚 JavaScript 中条件结构的工作方式,现在是时候探索下一节中的循环了。

理解循环

在 JavaScript 中创建循环有许多方法,但最常见的是 forwhile 语句以及针对数组和对象的特定变体。此外,JavaScript 中的函数也可以通过递归来创建循环。在本节中,我们将只查看 forwhiledo...while 语句。

while

while 语句创建一个循环,只要条件为真,就会执行代码块。条件在执行代码块之前被评估:

let i = 1;
while (i <= 10) {
    console.log(i);
    i++;
};

do...while

do...while 语句创建一个循环,即使条件不满足,也会至少执行一次代码块,然后只要条件为真,就重复循环。条件在执行代码块之后被评估:

let i = 0;
do {
    console.log(`i value: ${i}`);
    i++;
} while (false);
// i value: 0

for

for 语句创建一个循环,由三个可选表达式组成,这些表达式用括号括起来,并用分号分隔,然后是循环中执行的语句:

for (let i = 0; i < 10; i++) {
    console.log(i);
}

第一个表达式在循环开始之前执行。通常,它用于初始化将作为计数器的变量。

第二个表达式是在执行代码块之前评估的条件。如果条件为真,则执行代码块。如果条件为假,则循环停止。

第三个表达式在代码块执行之后执行。通常,它用于增加或减少计数器变量。

这种结构相当灵活,一些开发者倾向于滥用它。让我们通过一个可读性差的例子来看看:

for (let i = 0, x = 1, z = 2, limit = 10; i <= limit; x *= z, i++ ) {
    console.log(`i: ${i}. x: ${x}. z: ${z}`);
}
// i: 0\. x: 1\. z: 2
// ...
// i: 10\. x: 1024\. z: 2

可读性问题是由于在 for 循环中定义和更新的变量数量很多。重要的是要记住,我们编写的代码是其他程序员将来可以理解的。让我们看看以更可读的方式编写的相同代码:

let x = 1;
const z = 2, limit = 10;
for (let i = 0; i <= limit; i++ ) {
    console.log(`i: ${i}. x: ${x}. z: ${z}`);
    x *= z
}

你已经可以注意到区别;理解它需要更少的时间和精力。在下一节中,我们将学习如何使用字符串。

在 JavaScript 中使用字符串

字符串是原始值。它们是一系列字符。在 JavaScript 中创建字符串有三种方法:使用单引号 '、双引号 " 或反引号 `

console.log('Hello World');
console.log("Hello World");
console.log(`Hello World`);

字符串是不可变的,这意味着一旦它们被创建,就不能被修改,但你可以根据数据结构覆盖变量或引用。所以,你用来修改字符串的所有方法都将返回一个新的字符串(或数组):

模板字符串允许你使用占位符 ${} 在字符串中插入变量或表达式。还增加了对多行的支持:

const name = "John";
console.log(`Hello ${name}!`) //Hello John!

重要方法

有许多方法可以执行字符串操作,但在这个部分,我们将只看到你在日常工作中最常用的最重要的方法:

在下一节中,我们将学习如何使用数组,这是 JavaScript 中最灵活的数据结构之一。

探索数组

数组是非原始值;它们是一组值的集合。值可以是任何类型的值,包括其他数组。数组是可变的,这意味着你可以修改它们,并且更改将在原始数组中反映出来。

数组是零索引的,这意味着第一个元素位于索引 0,第二个元素位于索引 1,依此类推。

Array.isArray() 方法确定传递的值是否为数组:

const array = [1, 2, 3];
console.log(Array.isArray(array)); // true
const object = { name: "Ulises" };
console.log(Array.isArray(object)); // false
console.log(typeof array); // object
console.log(typeof object); // object
console.log("are object and array the same type?", typeof(array) === typeof(object)); // true

由于数组是对象,你需要小心,因为它们不能使用 ===== 操作符进行比较,因为这会比较引用,而不是值:

const array1 = [1, 2, 3];
const array2 = [1, 2, 3];
console.log(array1 === array2); // false

数组有一个 length 属性,它返回数组中的元素数量,并提供了一种轻松遍历数组的方法。

基本操作

在本节中,我们将探讨你将使用数组的最常见的操作。

创建数组

在 JavaScript 中创建数组有多种方法。最常见的是使用数组字面量表示法 [],但你也可以从其他数据类型创建数组,例如当你分割一个字符串时,或者使用 string.prototype.split() 方法。以下是一个使用数组字面量表示法创建数组的示例:

const emptyArray = [];
const numbers = [1, 2, 3];
const strings = ["Hello", "World"];
const mixed = [1, "Hello", true];

Array.of() 方法可以从一个可变数量的参数创建一个新的数组实例,无论参数的数量或类型如何:

const array = Array.of( 1, 2, 3 );

Array.from() 方法可以从一个类似数组的对象或可迭代对象创建一个新的数组实例:

console.log(Array.from('packt'));   // ['p', 'a', 'c', 'k', 't']

扩展运算符 ... 可以用来从一个现有的数组或字符串创建一个新的数组:

console.log([...[1, 2, 3]]);    // [1, 2, 3]
console.log([...'packt']);      // ['p', 'a', 'c', 'k', 't']

此外,你还可以将映射函数作为第二个参数传递,以便在创建数组时执行转换:

console.log(Array.from([1, 2, 3], x => x + x)); // [2, 4, 6]

访问项

你可以使用项的索引来访问数组中的项:

const fruits = ['banana', 'apple', 'orange'];
console.log(fruits[0]); // banana
console.log(fruits[1]); // apple
console.log(fruits[2]); // orange

替换项

你可以使用项的索引来替换数组中的项:

const fruits = ['banana', 'apple', 'orange'];
fruits[0] = 'pear';
console.log(fruits); // ['pear', 'apple', 'orange']

添加项

你可以使用两种主要方法向数组中添加项:

重要提示

总是最好将新项添加到数组的末尾,因为将项添加到数组的开头是一个昂贵的操作。这是因为它需要重新索引数组中的所有项。

删除项

有几种方法可以让你从数组中删除项:

遍历数组

正如我们在本章开头所看到的,可以使用 for 循环遍历数组,但还有其他方法可以遍历数组。

JavaScript 为声明式编程提供了很好的支持,这在需要遍历数组时特别有用。因此,让我们总结一下遍历数组最常见的方法。

大多数这些方法接收一个函数作为参数,并且该函数会对数组中的每个项执行。根据使用的方法和函数返回的数据,将得到一个或另一个结果。

另一个需要记住的重要事情是,这些方法可以串联在一起,因此可以在一个方法之后使用另一个方法,从而组合更复杂的操作。

遍历

由于数组可以存储大量元素,熟悉数组提供的方法对于正确遍历它们非常重要。最常见的是 Array.prototype.map()Array.prototype.forEach()。在两种情况下,我们都会遍历数组,但 Array.prototype.map() 会直接返回一个应用了转换的新数组。让我们通过一个比较这两种方法的例子来看一下:

const numbers = [1, 2, 3, 4, 5]
const mapTransformation = numbers.map(el => el * 10)
const forEachTransformation = []
numbers.forEach(el => {
    forEachTransformation.push(el * 10)
})
console.log(mapTransformation) // 10,20,30,40,50
console.log(forEachTransformation) // 10,20,30,40,50

验证

由于数组可以包含任何类型的数据,通常需要验证数组是否包含特定项,或者数组中的所有项是否满足某个条件。有几种方法,但最常见的是以下几种:

过滤

数组可以存储大量信息,存储嵌套结构,如大型对象,是很常见的。在 JavaScript 中,有许多进行过滤的方法。它们之间最重要的区别在于你期望的输出是什么,因为有时我们可能对包含过滤值的新的数组感兴趣,但其他时候我们可能想要数组中某些元素的(索引)位置。最常用的方法是 Array.prototype.filter(),它用于生成一个新数组,包含通过某些标准的元素。让我们看一个例子:

const numbers = [1, 2, 3, 4, 5]
const filteredNums = numbers.filter(el => el <= 3)
console.log(filteredNums) // [1, 2, 3]

你会发现这个类别中有几个相关的方法:

工具

有时你需要将一个数组数组展平。你可以使用 array.flat() 方法来实现这一点:

const data = [1, [2, 3], [4, 5]];
const flatData = data.flat();
console.log(flatData); // [1, 2, 3, 4, 5]

另一个常见的方法是 array.join() 方法,它用于将数组中的所有项连接成一个字符串:

const people = ['Joe', 'Jane', 'John', 'Jack'];
console.log(people.join()); // Joe,Jane,John,Jack
console.log(people.join(' + ')); // Joe + Jane + John + Jack

这在需要创建一个包含项目列表的字符串时非常有用,例如,当需要创建 HTML、XML、Markdown 等项目列表时:

const people = ['Joe', 'Jane', 'John', 'Jack'];
const structuredPeople = people.map(person => `<li>${person}</li>\n`);
console.log(`
    <ul>
        ${structuredPeople.join('')}
    </ul>
`)
// <ul>
//     <li>Joe</li>
//      ...
// </ul>

在处理数据时,我们经常需要根据数组中的项目进行排序。这可以通过 array.sort() 方法完成。通常,如果我们提供一个函数来指定如何正确排序项目,会更好,这样可以避免意外结果。让我们看一个例子:

const numbers = [7, 1,10, 3,15,20]
console.log(numbers.sort())
// [1, 10, 15, 20, 3, 7]
console.log(numbers.sort((a, b) => a - b))
// [1, 3, 7, 10, 15, 20]

有几种方法非常有用,你可能会发现自己非常频繁地使用它们:

解构赋值

ES6 引入了解构数组和对象的新语法。赋值语句的左侧现在是一个用于从数组和对象中提取值的模式。此模式可用于变量声明、赋值、函数参数和函数返回值。此外,您还可以在数组中不存在值的情况下使用默认值(失败软)。

在以下代码示例中,我们可以看到经典的失败软处理方式:

const list = [1, 2];
const a = list[0] || 0; // 1
const b = list[1] // 2
const c = list[2] || 4; // 4

以下代码片段包含相同的代码,但使用了 ECMAScript 6 的解构语法:

const list = [1, 2];
const [ a = 0, b, c = 4 ] = list;

如您所见,这个版本更为紧凑。目前,这是在可能结合解构的情况下分配默认值时更受欢迎的方法。

集合

ES6 引入了一种名为Set的新数据结构。Set 是一个值的集合,其中每个值只能出现一次。它可以用来存储值的集合,但它不是一个数组,因为它没有索引。它通常用于从数组中移除重复值,如下面的代码所示:

let arr = [1,2,2,3,1,4,5,4,5]
let set = new Set(arr)
let uniques = Array.from(set)
console.log(uniques) // [1,2,3,4,5]

您可以在developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set找到有关特定集合方法的更多信息。

在下一节中,我们将学习如何使用对象,这是 JavaScript 中最强大的数据结构之一。

在 JavaScript 中使用对象

对象是非原始值;它们是属性的集合。属性是一个键值对。键始终是字符串,而值可以是任何类型的值,包括其他对象。

基本操作

对象是 JavaScript 中最灵活的结构。在本节中,我们将学习如何创建对象,如何访问和修改它们的属性,以及如何遍历对象的属性。

创建对象

您可以使用对象字面量语法创建对象,即使用花括号:

const person = {}

您也可以创建一个对象并直接添加属性:

const person = {
    name: 'Jane',
}

您可以在对象中存储任何类型的值,包括其他对象或函数(方法):

const person = {
    name: 'Jane',
    id: 1,
    favoriteColors: ['blue', 'green'],
    address: {
        street: 'Main St',
        number: 1,
    },
    fullName: function() {
        return `${this.name} Doe`
    },
    sayHi: function() {
        console.log('Hello!')
    }
}
console.log(person.fullName()) // Jane Doe
person.sayHi() // Hello!
console.log(person.address.street) // Main St
console.log(person.id) // 1
console.log(person.favoriteColors[0]) // blue

创建和访问属性

您可以通过赋值来创建新属性或覆盖现有属性:

const person = {
    id: 12
}
person.name = 'Jane'
console.log(person.name) // Jane
person.id = 1
console.log(person.id) // 1

您还可以使用方括号表示法访问对象的属性,这在使用程序化访问或使用特殊字符或空格的键时很有用:

const person = {
    id: 12
}
console.log(person['id']) // 12
const specialKey = 'first name with spaces'
person[specialKey] = 'Jane'
console.log(person[specialKey]) // Jane

删除属性

您可以使用delete运算符从对象中删除属性,或者将其覆盖为undefined

const person = {
    id: 12,
    name: 'Jane'
}
delete person.id
person.name = undefined
console.log(person.id) // undefined
console.log(person.name) // undefined

遍历

让我们看看如何遍历对象的属性,以及如何获取包含对象键和值的数组。

这是我们的基础对象:

const users = {
    admin: 'Jane',
    moderator: 'Joe',
    user: 'Billy',
}

您可以使用for...in循环遍历对象的属性:

for (let role in users) {
    console.log(`${users[role]} is the ${role}`)}
// Jane is the admin
// Joe is the moderator
// Billy is the user

你也可以使用Object.keys()方法来获取一个包含对象键的数组,这样你就可以使用数组特定的方法来管理迭代,例如array.prototype.forEach()

const roles = Object.keys(users)
console.log(roles) // ['admin', 'moderator', 'user']
roles.forEach(role => {
    console.log(role) // admin
    console.log(users[role]) // Jane
})

你还可以使用语言中最近引入的额外方法:

浅拷贝与深拷贝

JavaScript 的工作方式意味着有时我们得不到变量的预期副本。让我们看看一个简单的例子:

const name = "Jane"
const number = 1
const array = [1, 2, 3]
const object = { id: 1, name: 'Jane' }
// Copy
let nameCopy = name
let numberCopy = number
const arrayCopy = array
const objectCopy = object
// Modify the copy
nameCopy = 'Joe'
numberCopy = 2
arrayCopy.push("additional item")
objectCopy.name = 'Joe'
// Check the original
console.log(name) // Jane
console.log(nameCopy) // Joe
console.log(number) // 1
console.log(numberCopy) // 2
console.log(array) // [1, 2, 3, "additional item"]
console.log(arrayCopy) // [1, 2, 3, "additional item"]
console.log(object) // { id: 1, name: 'Joe' }
console.log(objectCopy) // { id: 1, name: 'Joe' }

这是一种相当特定的 JavaScript 行为,让许多开发者感到沮丧。当我们修改副本时,原始变量是如何被修改的呢?答案是,在所有场景中,我们并没有复制变量(深拷贝),我们复制的是变量的引用(浅拷贝)。

只有原始类型(字符串、数字、布尔值、null、undefined符号)是通过值进行复制的;其余的都是通过引用复制的,所以你实际上得到的是原始变量的引用,就像一个快捷方式。

这允许你做一些有趣的事情,例如为非常嵌套的对象创建快捷引用:

const data = {item: {detail: { reference: {id: '123'} }}}
// make a shortcut reference
const ref = data.item.detail.reference
ref.name = 'Jane'
// check the original
console.log(data.item.detail.reference) // {id: '123', name: 'Jane'}

但是,正如你所看到的,这可能会导致原始对象的变化。如果我们不清楚原始结构是如何被复制的,这可能会是一个意外的行为。如果你使用嵌套结构,这可能会更难以检测。

如果你想要获取一个简单对象的深拷贝,你可以使用Object.assign()或扩展运算符...

const array = [1, 2, 3]
const object = { id: 1, name: 'Jane' }
// Copy
const arrayCopy = [...array]
const objectCopy = Object.assign({}, object)
// Modify the copy
arrayCopy.push("additional item")
objectCopy.name = 'Joe'
// Check the original
console.log(array) // [1, 2, 3]
console.log(arrayCopy) // [1, 2, 3, "additional item"]
console.log(object) // { id: 1, name: 'Jane' }
console.log(objectCopy) // { id: 1, name: 'Joe' }

但是嵌套对象将通过引用进行复制,所以你将得到与之前相同的行为:

const data = [{ 'a': 1 }, { 'b': 2 }];
const shallowCopy = [...data];
shallowCopy[0].a = 3;
console.log(data[0].a); // 3
console.log(shallowCopy[0].a); // 3

另一个选择是使用专门的库,如 Lodash (lodash.com/docs/4.17.15#cloneDeep),或者将其转换为 JSON 并消化其结构,但这有一些限制,例如无法复制函数或未在 JSON 规范中定义的项目 (datatracker.ietf.org/doc/html/rfc7159)。

对象合并

使用Object.assign合并两个对象是可以的,但你需要了解两件事:

  • 顺序很重要,所以当它们有共同属性时,第一个项目将被下一个项目覆盖。

  • 如果对象是复杂的数据结构,如嵌套对象或数组,则最终对象将复制引用(浅拷贝)

让我们看看一个例子:

const dst  = { quux: 0 }
const src1 = { foo: 1, bar: 2 }
const src2 = { foo: 3, baz: 4 }
Object.assign(dst, src1, src2)
console.log(dst) // {quux: 0, foo: 3, bar: 2, baz: 4}

解构

自从 ES6 以来,JavaScript 为对象提供了解构赋值,这对于提取和包含对象中的值非常有用。让我们用一个简单的对象来举个例子:

const name = "Jane";
const age = 25;
const data = { item: "Lorem Ipsum", status: "OK" };

如果我们没有使用解构赋值,我们就必须这样做:

const user = {
  name: name,
  age: age,
  data: data,
};
const item = data.item;
const status = data.status;

但有了解构赋值,我们可以更简洁地做到这一点:

const user = { name, age, data };
const { item, status } = data;

可选链(?.)

可选链操作符是 ES2020 中引入的新操作符。它允许您在不担心属性是否存在的情况下访问对象的深层嵌套属性。在可选链操作符之前,您在访问属性之前必须检查该属性是否存在。对于非常嵌套的结构来说,这相当繁琐。让我们看看一个实际例子:

const user = {
  name: "John",
  address: {
    street: "Main Street",
  },
};
const otherUser = {
  name: "Jane",
};
console.log(user.address?.street); // Main Street
console.log(otherUser.address?.street); // undefined
// without optional chaining:
console.log(user.address.street); // Main Street
console.log(otherUser.address.street); // TypeError: Cannot read properties of undefined (reading 'street')

现在我们已经熟悉了大多数数据结构,是时候在下一节探索函数了。

探索函数

函数是 JavaScript 中更有意义的结构之一。它们有一些特性使它们与其他编程语言不同;例如,它们是一等公民,这意味着它们可以被分配给变量、作为另一个函数的参数传递,或从另一个函数返回。

基础知识

与函数相关的先进概念有很多,但在这个部分,我们将只关注 JavaScript 中函数的基础知识。我们将从使用 function 关键字的声明、执行和参数开始。然后,我们将重点介绍箭头函数和闭包。

声明

从本质上讲,函数是一段代码块,当它被调用时可以执行。在 JavaScript 中,我们可以使用 function 关键字声明一个函数。其语法如下:

function myFunction() {
  console.log("This is a function body")
  // code to be executed
}

执行

函数在声明时不会执行;它在被调用时执行。要调用一个函数,我们只需写出函数名后跟括号。以下是一个例子:

const myFunction = function() {
  console.log("This is a function body")
  // code to be executed
}
myFunction() // This is a function body

匿名函数

函数也可以声明为函数表达式。这被称为匿名函数。一个简单的例子是我们将函数作为参数传递给另一个函数,比如当我们使用定时器时——在这个例子中是 setTimeout

setTimeout(function() {
    console.log('1 second later')
}, 1000);

返回值

函数可以使用 return 关键字返回一个值。这个值可以被分配给一个变量或在另一个函数中使用。以下是一个例子:

function isEven(number) {
  return number % 2 === 0
}
const result = isEven(2)
const otherResult = isEven(3)
console.log(result) // true
console.log(otherResult) // false

参数

函数可以接收参数;这些参数在函数被调用时传递给函数。以下是一个例子:

function sayHi (name) {
  console.log(`Hi ${name}!`);
};
sayHi('John'); // Hi John!

您不需要指定参数;您可以使用剩余操作符 (...) 来访问参数。在这个例子中,我们将计算传递给函数的所有数字的总和:

function sum (...numbers) {
  console.log("First Number:", numbers[0])
  console.log("Last Number:", numbers[numbers.length - 1])
  let total = 0
  for (let number of numbers) {
    total += number
  }
  console.log("Total (SUM):", total)
}
const result = sum(1, 2, 3, 4, 5)
// First Number: 1
// Last Number: 5
// Total (SUM): 15

箭头函数

ES6 中引入的最重要特性之一是箭头函数。它们是编写 JavaScript 函数的新语法,但它们也引入了一些重要的变化,需要注意:

  • 箭头函数引入了编写函数的新语法

  • 箭头函数总是匿名的

语法

从 JavaScript 的开始,我们就使用 function 关键字声明函数,如下例所示:

const sampleFunction = function () { }
const sayHelloNow = function (name) {
  const now = new Date()
  console.log(`Hello ${name}, at ${now}!`)
}

编写箭头函数的新语法使用 => 而不是 function 关键字。以下例子与上一个例子相同,但使用了新的语法:

const sampleFunction = () => {}
const sayHelloNow = name => {
  const now = new Date()
  console.log(`Hello ${name}, at ${now}!`)
}

新的语法有隐式的返回,所以如果你想返回一个值,你可以不使用return关键字来完成:

const alwaysTrue = () => true
const getData = (name, age) => ({ name: "John", age: 25 })

以下是将之前的例子翻译成之前语法的示例:

const alwaysTrue = function () { return true }
const getData = function (name, age) {
  return { name: "John", age: 25 }
}

箭头函数可以接收参数,但如果你想要接收多个参数,你需要使用括号:

const sum = function (a, b) { return a + b }
// Arrow function translation
const sum = (a, b) => a + b

行为变化

由于 JavaScript 与旧版本具有向后兼容性,箭头函数在函数的行为上引入了一些变化。其中最重要的与this关键字有关。

此外,箭头函数没有prototype属性,这意味着它们不能用作构造函数或方法处理器。

重要提示

JavaScript 中this的管理可能有点令人困惑,对于本书的目标来说相当高级。如果你想了解更多,可以阅读 MDN 文档:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this

闭包

这是 JavaScript 中最受欢迎和最重要的概念之一,但它有点高级,理解起来并不容易。

那么,什么是闭包?

基本上,闭包是由另一个函数返回的函数。这里有一个例子:

const outerFunction = function () {
  console.log("This is the outer function")
  const innerFunction = function () {
    console.log("This is the inner function")
  }
  return innerFunction
}

在这个例子中,outerFunction返回innerFunction,因此我们可以在调用outerFunction之后调用innerFunction

const innerFunction = outerFunction() // This is the outer function
innerFunction() // This is the inner function

现在,让我们通过在同一语句中执行两个操作来用更少的代码实现相同的结果:

// Execution in single line
outerFunction()()

但这有什么用?

闭包最重要的特点是它们可以访问甚至修改父函数的作用域(代码块和参数),即使父函数已经返回。让我们看看一个实际例子:

const createCounter = (initialValue = 0) => {
  let counter = initialValue
  return (incrementalValue) => {
    counter += incrementalValue
    console.log(counter)
  }
}

在这个例子中,我们向函数添加了initialValueincrementalValue参数,并且还定义了counter变量来存储计数器的当前值。在实践中,我们可以使用这个函数来创建一个从特定值开始的计数器,然后我们可以通过特定值来增加它。我们无法直接访问counter变量,因为它只存在于函数的作用域内,而不是外部,但我们可以使用闭包来访问它甚至操作它的值:

const addToCounter = createCounter(10)
addToCounter(12) // 22
addToCounter(1)  // 23

在这个例子中,我们看到了闭包的基本用法,但它们可以用在很多其他事情上。最常见的一种用法是创建抽象来管理第三方服务,例如数据库和 API。

在接下来的章节中,当我们使用 MongoDB 和 Express 时,我们将使用这个结构。

在下一节中,我们将学习如何创建和管理类,以及 JavaScript 中的原型继承是如何工作的。

创建和管理类

类是在 ES6 中引入的。它们是原型继承的语法糖。从历史上看,JavaScript 没有像我们通常从典型的面向对象编程(OOP)语言中期望的那样有正式的类。

在本节中,我们将学习如何创建类以及如何使用 ES6 中的类。此外,我们还将探讨原型继承是如何成为维护向后兼容性和扩展 JavaScript 核心功能的关键特性。

创建类

要创建一个类,我们需要使用class关键字,然后我们可以使用constructor方法来定义类的默认属性:

class Human{
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
}
const jane = new Human ("Jane", 30);
console.log(jane.name); // Jane
console.log(jane.age); // 30

在这个例子中,我们创建了一个名为Human的类,然后我们创建了名为jane的类的实例。我们可以使用点符号来访问类的属性。

类方法

要在类中定义一个方法,我们需要使用与在对象中定义方法相似的语法:

class Human {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
  sayHello() {
    console.log(`Hello, my name is ${this.name}!`);
  }
}
const jane = new Human ("Jane", 30);
jane.sayHello(); // Hello, my name is Jane!

在这个例子中,我们在Human类中定义了一个名为sayHello的方法,然后我们创建了类的实例,并调用了该方法。

扩展类

我们可以使用extends关键字来扩展类。这将允许我们继承父类的属性和方法:

class Colleague extends Human {
  constructor(name, age, stack) {
    super(name, age);
    this.stack = stack;
    this.canCode = true;
  }
  code() {
    console.log(`I can code in ${this.stack}!`);
  }
}
const jane = new Colleague ("Jane", 30, ['JavaScript', 'React', 'MongoDB']);
console.log(jane.name); // Jane
console.log(jane.canCode); // true
jane.sayHello(); // Hello, my name is Jane!
jane.code(); // I can code in JavaScript, React and MongoDB!

在这个例子中,我们创建了一个名为Colleague的类,它扩展了Human类,然后我们创建了类的实例,并调用了从两个类继承的方法和属性。

静态方法

静态方法是可以在不实例化类的情况下调用的方法。它们使用static关键字定义:

class Car {
  constructor(brand) {
    this.brand = brand;
  }
  move() {
    console.log(`The ${this.brand} is moving!`);
  }
  static speedLimits() {
    console.log("The speed limit is 120 km/h for new cars");
  }
}

现在,我们可以调用speedLimits方法而不需要实例化类:

Car.speedLimits(); // The speed limit is 120 km/h for new cars

获取器和设置器

就像支持面向对象编程的其他语言一样,你可以使用getset关键字分别定义获取器和设置器。这将允许你以更经典的方式访问和修改实例的属性:

class Rectangle {
    constructor (width, height) {
        this._width  = width
        this._height = height
    }
    set width  (width)  { this._width = width               }
    get width  ()       { return this._width                }
    set height (height) { this._height = height             }
    get height ()       { return this._height               }
    get area   ()       { return this._width * this._height }
}
const shape = new Rectangle(5, 2)
console.log(shape.area) // 10
console.log(shape.height) // 2
console.log(shape.width) // 5
shape.height = 10
shape.width = 10
console.log(shape.area) // 100
console.log(shape.height) // 10
console.log(shape.width) // 10

概述

在本章中,我们探讨了 JavaScript 的历史和当前状态。我们学习了语言的不同版本以及语言是如何随着时间的推移而演变的。我们还学习了新特性是如何添加到语言中的。

此外,我们还学习了如何找到关于语言的最佳文档,以及如何使用它来更深入地了解语言。

此外,我们还详细探讨了如何使用数字、日期、条件语句、循环、字符串、数组、对象和函数等。

此外,我们还学习了类和基于原型的继承,以及它是维护向后兼容性和扩展 JavaScript 核心功能的关键特性。

在下一章中,我们将学习使用 JavaScript 进行异步编程。你将应用在本章中学到的所有知识,通过不同的方法来管理异步代码,例如回调、Promise 和 async/await。

进一步阅读

第四章:异步编程

本章将详细解释如何使用 JavaScript 今天提供的所有异步机制,包括如何将回调转换为 Promise 以及执行批量异步操作。

您将深入了解您可用于管理简单和复杂异步活动的所有工具。我们将从遵循 Node.js 核心约定的回调开始,然后过渡到有效地使用 Promise 和async/await处理异步操作。在章节的末尾,我们将使用立即执行的函数表达式(IIFE)模式来执行异步代码。此外,我们还将提供如何在不同处理程序之间转换异步操作的全面概述,包括回调和 Promise。

总结一下,以下是本章我们将探讨的主要主题:

  • JavaScript 中的异步编程

  • 理解回调函数以及如何避免回调地狱

  • 掌握 Promise

  • 使用asyncawait处理异步代码

技术要求

该章节的代码文件可以在github.com/PacktPublishing/NodeJS-for-Beginners找到。

查看本章的代码演示视频,链接为youtu.be/FHzqWr4dK7s

JavaScript 中的异步编程

在 JavaScript 中,异步编程是语言的基本组成部分。它是允许我们在后台执行操作而不阻塞主线程执行的机制。这在浏览器中尤为重要,因为主线程负责更新用户界面和响应用户操作。

总体而言,异步编程是一个复杂的主题,需要大量的实践才能掌握,但在我看来,它需要改变你的思维方式。你需要开始思考如何将代码分解成可以在后台执行的小块,以及如何将它们组合起来以实现预期的结果。在用 JavaScript 编码时,你将经常遇到异步编程。大多数涉及与外部资源交互的操作,例如从服务器或数据库发送和接收数据以及从文件中读取内容,都需要使用它。

前置知识

第一章中,我们介绍了事件循环的概念,这是允许 JavaScript 异步执行的一种机制。在本章中,我们将探讨如何利用这一机制来发挥我们的优势。

第三章中,我们详细学习了 JavaScript 的使用;本章需要具备对函数和数组有扎实的了解。

让我们从探讨异步编程与常规编程的不同之处以及我们需要采取不同的思维方式开始。

本章使用同构 JavaScript 代码片段,因此代码可以在 Node.js 或浏览器中执行。

异步思维

掌握异步编程的第一步是改变你的思维方式。你需要开始以非线性方式思考你的代码;你将更多地考虑“接下来应该发生什么”,而不是“首先应该发生什么。”

当我们在第三章中学习函数时,我们看到了函数仅仅是一段可以在任何时间执行的代码。在本节中,我们将这段代码与之前的事件以及未来的事件联系起来。

在 JavaScript 中执行异步操作有许多方法。在本章中,我们将重点关注其中最常见的一些,如下所示:

  • 回调:回调是一个作为另一个函数参数的函数,它在某个事件发生时执行。这是在 JavaScript 中执行异步操作最基本的方式,也是所有其他机制的基础。

  • Promises:ES6 引入了 promise 的概念,你可以用它以更高级的方式处理异步操作,因为它们使用具有多个状态(挂起、已解决和拒绝)的状态机来跟踪操作。与回调相比,promises 在可读性、可重用性和整体简单性方面具有许多优势。这是现代 JavaScript 中执行异步操作最常见的方式。有关更多详细信息,请参阅本章中的掌握 promises部分。

  • Async/await:Async/await 作为 promises 的包装器,使代码更易读(语法糖)。目前是处理异步操作最受欢迎的方式。

在下一节中,我们将探讨如何在我们的应用程序中有效地使用回调,错误优先模式,以及其他应遵循的良好实践。稍后,我们将探讨如何将回调包装在 promises 中。

理解回调

回调利用 JavaScript 传递函数的能力。这个技术有两个基本部分:

  • 作为另一个函数参数传递的函数

  • 当某个事件发生时,传递的函数将被执行

让我们创建一个基本示例来阐述这个概念。在下面的代码片段中,我们将展示回调是如何作为一个参数定义的,以及当执行发生时,一个函数是如何作为一个参数传递的:

  1. 在这个例子中,我们将定义一个函数(doSomething),它期望一个函数作为参数:

    const doSomething = (cb) => {
      console.log('Doing something...');
      cb();
    };
    
  2. 到目前为止,我们有一个名为doSomething的函数,它接收一个函数作为参数,并在最后一步执行它,这说明了回调只是这样一个模式,我们期望下一个被执行的函数实际上是作为最终步骤调用的(完成后再调用我 - 回调)。让我们看看我们如何使用这个函数:

    const nextStep = () => {
      console.log('Callback called');
    };
    doSomething(nextStep);
    
  3. 函数执行后,预期的输出将是以下内容:

    Doing something...
    Callback called
    

现在,我们有一个名为 nextStep 的函数,它作为参数传递给 doSomething。当 doSomething 执行时,它将打印 Doing something...,然后执行传递给它的函数,最后一步将打印 Callback called

重要的是要注意,作为参数传递的函数不是立即执行的,因为我们只想在操作完成时执行它们。另一方面,立即执行将需要使用括号(doSomething(nextStep()))并会产生不同的结果和错误:

doSomething(nextStep())
// Callback called
// Doing something...
// Error: cb is not a function

我们也可以传递一个匿名函数作为参数。这是使用回调函数最常见的方式,因为我们不需要事先定义函数。在大多数情况下,我们不会在之后重用该函数:

doSomething(() => {
    console.log('Callback called');
});

也可以传递一个接收参数的函数:

const calculateNameLength = (name, cb) => {
  const length = name.length;
  cb(length);
};
calculateNameLength('John', (length) => {
  console.log(`The name length is ${length}`); // The name length is 4
});

如您所见,回调技术非常简单,但我们还没有看到任何异步操作。最终,我们假设回调函数字面意思是“当你完成时叫我回来”的方法。现在,让我们看看如何使用定时器和间隔来管理异步操作。

定时器和间隔

有两个常用的函数用于延迟函数的执行,即 setTimeoutsetInterval。这两个函数都接收一个回调函数作为参数,并在一定时间后执行它。现在,让我们通过示例定义和使用这些函数。

setTimeout 函数用于通过指定的时间延迟来推迟一个函数的执行。

让我们通过一个简单的例子看看 setTimeout 是如何工作的:

console.log('Before setTimeout');
const secondInMilliseconds = 1000;
setTimeout(() => {
  console.log('A second has passed');
}, secondInMilliseconds);
console.log('after setTimeout');

如果我们执行此代码,我们将看到以下输出:

Before setTimeout
after setTimeout
A second has passed

如您所见,回调函数是在其他代码执行完毕后执行的,即使它是在之前定义的。这是因为回调函数是异步执行的,这意味着它在后台执行,而其他代码则在主线程中执行。

setTimeout 函数接收两个参数。第一个参数是回调函数,第二个参数是回调函数应该延迟的时间量。时间量以毫秒为单位表示,因此在这种情况下,我们延迟回调函数的执行 1,000 毫秒,即 1 秒。

setInterval 函数用于在每次执行之间有固定时间延迟的情况下重复执行一个函数。

让我们通过一个简单的例子看看 setInterval 是如何工作的:

const secondInMilliseconds = 1000;
let totalExecutions = 0
console.log('Before setInterval');
setInterval(() => {
    totalExecutions++;
    console.log(`A second has passed, this is the ${totalExecutions} execution`);
}, secondInMilliseconds);
console.log('After setInterval');

如果我们执行此代码,我们将看到以下输出:

Before setInterval
After setInterval
A second has passed, this is the 1 execution
...
A second has passed, this is the 50 execution

如您所见,回调函数每秒执行一次,并且它在后台执行,因此其他代码在主线程中执行。

setInterval 函数接收两个参数。第一个参数是回调函数,第二个参数是回调函数应该延迟的时间量。时间量以毫秒为单位表示,因此在这种情况下,我们延迟回调函数的执行 1,000 毫秒,即 1 秒。

错误优先回调

在前面的章节中,我们看到了如何使用回调来管理异步操作,但没有看到如何处理错误。在本节中,我们将看到如何处理回调中的错误。

在回调中处理错误最常见的方式是使用错误优先模式。这个模式包括将错误作为回调的第一个参数传递,将结果作为第二个参数。让我们通过一个简单的例子看看它是如何工作的:

const doSomething = (cb) => {
  const error = new Error('Something went wrong');
  cb(error, null);
};
doSomething((error, result) => {
  if (error) {
    console.log('There was an error');
    return;
  }
  console.log('Everything went well');
});

此代码的输出将如下所示:

There was an error

在这个例子中,我们有一个名为 doSomething 的函数,它接收一个回调作为参数。这个回调接收两个参数。第一个是一个错误,第二个是结果。在这种情况下,我们将错误作为第一个参数传递,将 null 作为第二个参数,因为发生了错误。当回调执行时,我们检查第一个参数是否是错误,如果是,我们打印 There was an error。否则,我们打印 Everything went well

让我们看看当一切顺利时它是如何工作的:

const doSomething = (cb) => {
  const result = 'It worked!';
  cb(null, result);
};
doSomething((error, result) => {
  if (error) {
    console.log('There was an error');
    return;
  }
  console.log(result);
  console.log('Everything went well');
});

此代码的输出将如下所示:

It worked!
Everything went well

在这种情况下,我们传递 null 作为第一个参数,因为没有错误,将结果作为第二个参数。当回调执行时,我们检查第一个参数是否是错误,如果是,我们打印 There was an error。否则,我们打印结果,并打印 Everything went well

回调地狱

之前,我们看到了如何使用回调来管理异步操作,以及如何使用错误优先模式来处理错误。

回调的问题在于它们不是很容易阅读,当我们有很多嵌套的回调时,代码变得非常难以阅读。这被称为回调地狱,这是使用回调时一个非常常见的问题。

在下面的伪代码示例中,你可以看到函数是如何以倾斜的金字塔形式生成的,嵌套的回调使得代码难以跟踪。在下面的代码示例中,观察函数是如何以倾斜的金字塔形式结构化,嵌套的回调使得代码难以理解:

readFile("docs.md", (err, mdContent) => {
    convertMarkdownToHTML(mdContent, (err, htmlContent) => {
        addCssStyles(htmlContent, (err, docs) => {
            saveFile(docs, "docs.html",(err, result) => {
                ftp.sync((err, result) => {
                    // ...
                })
            })
        })
    })
})

如你所见,代码非常难以阅读,并且很容易出错。这就是为什么我们需要一种更好的方式来管理异步操作。有一些方法可以防止回调地狱,例如使用命名函数而不是匿名函数,但避免回调地狱最常见的方法之一是使用承诺。

当你需要链式处理异步操作时,承诺是一个很好的解决方案,让我们在下一节中探讨它。

掌握承诺

承诺作为一个状态机工作,表示异步操作最终的成功或失败,以及其结果值。它可以处于以下三种状态之一:挂起、实现或拒绝。

当承诺被创建时,它处于挂起状态。当承诺被实现时,它处于实现状态。当承诺被拒绝时,它处于拒绝状态。

以下图表显示了承诺的各种状态及其之间的连接:

图 4.1 – 由 Mozilla 贡献者提供的归属和版权许可受 CC-BY-SA 2.5 许可

图 4.1 – 由 Mozilla 贡献者提供的归属和版权许可受 CC-BY-SA 2.5 许可。developer.mozilla.org/en-US/docs/MDN/Writing_guidelines/Attrib_copyright_license

在承诺被解决或拒绝后,它变得不可更改。为了管理解决,我们使用 then 方法,而 catch 方法用于处理承诺的拒绝。

既然我们已经清楚承诺是什么以及状态是如何相关的,现在是时候观察它们在实际中的应用了。在下一节中,我们将探讨如何使用它们,并在 JavaScript 中轻松控制任何异步流程。

使用承诺

让我们通过一个简单的例子来看看使用 fetch 向外部 应用程序编程接口API)发起请求是如何工作的。此示例将使用我的简单-api 项目 (github.com/UlisesGascon/simple-api),该项目可在 api.demo.foo/__/docs/ 找到,并且是一个用于测试和快速原型设计的假在线 表示状态转移REST)API。

因此,在以下代码示例中,我们将执行网络请求,并通过互联网将数据带到我们的应用程序中,因为这个操作需要网络 I/O,它是异步的,所以我们需要使用承诺:

fetch('https://api.demo.foo/v1/todo')
  .then(response => response.json())
  .then(json => console.log(json))
  .catch(error => console.log(error));

此代码的输出将如下所示:

[{
  "id": "fc3f31b9-8d98-42e9-aab3-1586f2273c3a",
  "title": "We need to input the digital DNS capacitor!",
   "completed": true  }
...
]

在这个例子中,我们使用 fetch 函数向 API 发起请求。该函数产生一个承诺,允许我们使用 then 方法来管理成功的解决,并使用 catch 方法来处理潜在的拒绝。在这种情况下,我们使用了两次 then 方法:第一次是将响应解析为 JSON,第二次是将结果打印到控制台。我们还使用了 catch 方法将错误打印到控制台。

创建承诺

您可以使用 Promise 构造函数创建一个承诺,该构造函数接收一个回调作为参数。此回调接收两个参数,resolverejectresolve 函数用于解决承诺,而 reject 函数用于拒绝承诺。让我们通过一个简单的例子看看它是如何工作的:

const setTimeoutPromise = (time) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve();
    }, time);
  });
};
console.log('Before setTimeoutPromise');
setTimeoutPromise(1000).then(() => console.log('one second later'))
console.log('After setTimeoutPromise');

此代码的输出将如下所示:

Before setTimeoutPromise
After setTimeoutPromise
one second later

在这个例子中,我们有一个名为 setTimeoutPromise 的函数,它接收一个 time 作为参数。此函数返回一个承诺,该承诺将在指定时间后解决。当承诺解决时,我们将 one second later 打印到控制台。

使用承诺的回调地狱

承诺是处理当需要执行多个应按顺序执行的异步操作时,回调引入的限制的绝佳方式。

承诺更容易处理错误,因此代码的可读性应该更清晰,长期维护也更简单。

在上一节中,我们看到了回调地狱在 JavaScript 中是一个非常真实的问题。到目前为止,您应该对倾斜的金字塔和嵌套回调更加熟悉。以下是我们在前一个部分中用来解释如何轻松实现回调地狱的代码片段:

readFile("docs.md", (err, mdContent) => {
    convertMarkdownToHTML(mdContent, (err, htmlContent) => {
        addCssStyles(htmlContent, (err, docs) => {
            saveFile(docs, "docs.html",(err, result) => {
                ftp.sync((err, result) => {
                    // ...
                })
            })
        })
    })
})

现在我们来看看我们如何使用承诺解决这个问题:

readFile("docs.md")
  .then(convertMarkdownToHTML)
// shortcut for .then(mdContent => convertMarkdownToHTML(mdContent))
  .then(addCssStyles)
  .then(docs => saveFile(docs, "docs.html"))
  .then(ftp.sync)
  .then(result => {
    // ... other things
  })
  .catch(error => console.log(error));

如您所见,代码更容易阅读,并且更容易进行修改。这是使用承诺的主要优势之一。现在错误处理在最后的catch方法中完成,因此我们不需要在每个then方法中处理错误,这使得代码更加简洁。

并行承诺

使用承诺的另一个优点是我们可以在并行中运行多个承诺。基本上,我们提供一个承诺数组,并选择一个策略来处理结果(Promise.race()Promise.all())。这是一个减少执行时间的好方法,因为我们正在使用 Node.js 异步管理 I/O 操作的能力。

在以下示例中,我们将使用此函数来生成一个随机超时承诺,作为异步操作的示例:

const randomTimeOutPromise = () => {
  return new Promise((resolve, reject) => {
    const time = Math.floor(Math.random() * 100);
    setTimeout(() => {
      console.log(`Promise resolved after ${time}ms`);
      resolve(time);
    }, time);
  });
};

此函数将在 0 到 100 毫秒之间的随机时间后解决一个承诺。现在我们有一个异步函数,我们可以根据我们的具体需求采用各种策略来组合多个请求。在这个例子中,我们的目标是并行发起多个请求并等待它们的解决。

Promise.all(): all方法产生一个承诺,一旦所有承诺都解决或任何承诺被拒绝,它就会解决:

Promise.all([
  randomTimeOutPromise(),
  randomTimeOutPromise(),
  randomTimeOutPromise(),
  randomTimeOutPromise(),
  randomTimeOutPromise(),
]).then((results) => {
  console.log("results:", results);
});

当所有承诺都成功解决时,这段代码的输出将类似于以下内容:

Promise resolved after 0ms
Promise resolved after 26ms
Promise resolved after 31ms
Promise resolved after 37ms
Promise resolved after 62ms
results: [37, 31, 26, 62, 0]

如您所见,当所有承诺都解决时,将调用then方法,并且它将接收一个数组,其中包含每个承诺的结果,按照它们在承诺数组中添加的顺序,而不是按照它们解决的顺序。

在前面的例子中,所有承诺都成功解决,因为它们基于计时器操作。但当我们依赖于承诺来访问外部资源,如系统中的文件或从互联网获取数据时,我们需要考虑这些资源可能并不总是可用。例如,如果互联网断开,那么一个或多个承诺可能会失败,这将使我们的应用程序崩溃。显然,如果使用catch语句处理错误,可以避免这种情况,但即使在那种情况下,也非常重要记住,当我们使用这种并行方法时,我们需要考虑如果单个承诺生成错误,已解决的承诺将被忽略,就像我们使用单个承诺一样。

Promise.all()的另一种方法是聚合所有请求,但一旦第一个请求完成就解决承诺。这样,就无需等待所有请求的解决。

Promise.race(): race方法返回一个承诺,只要其中一个承诺解决或拒绝,它就会解决或拒绝。如果不小心管理,这可能会导致意外的结果,因为即使其中一个承诺已经解决或拒绝,承诺也不会停止运行:

Promise.race([
  randomTimeOutPromise(),
  randomTimeOutPromise(),
  randomTimeOutPromise(),
  randomTimeOutPromise(),
  randomTimeOutPromise(),
]).then((result) => {
  console.log("result:", result);
});

此代码的输出将类似于以下内容:

Promise resolved after 30ms
results: 30
Promise resolved after 33ms
Promise resolved after 60ms
Promise resolved after 79ms
Promise resolved after 83ms

如您所见,当第一个承诺解决时,将调用then方法,并且它将接收到已解决的第一个承诺的结果。其他承诺将继续运行,但then方法将不会再次被调用。

错误处理

在前面的例子中,我们看到了如何使用catch方法处理错误,但还有另一种处理错误的方法:使用reject函数。让我们看看这个例子是如何工作的:

const generatePromise = shouldFail => {
  return new Promise((resolve, reject) => {
    if (shouldFail) {
      return reject(new Error("Rejected!"));
    }
    resolve("Success!");
  });
};
generatePromise(true).catch(error => console.log("Error message:", error));
// Error message: Error: Rejected!
// ...

重要的是要注意,reject函数不会停止代码的执行,因此我们需要在调用reject函数后return函数。

最后一种情况是我们需要在承诺解决后执行一个动作,无论它是成功还是拒绝。重要的是要记住,未处理的承诺拒绝可能导致运行时错误,这会使您的应用程序崩溃。我们将在第十五章中了解更多。

Promise.finally(): 有时候,我们不在乎承诺是解决还是拒绝;我们只想知道承诺何时解决或拒绝。在这种情况下,我们可以使用finally方法:

generatePromise(true)
  .then(result => console.log("Result:", result))
  .catch(error => console.log("Error message:", error))
  .finally(() => console.log("Promise settled"));

承诺链式调用

我们还可以链式调用承诺;我们可以在then方法中返回一个承诺,这个承诺将在调用下一个then方法之前解决。如果链中的任何承诺被拒绝,将调用catch方法。让我们看一个例子:

generatePromise()
  .then(generatePromise)
  .then(result => {
    return generatePromise(true);
  })
  .then(() => console.log("This will not be called"))
  .catch(error => console.log("Error message:", error));

当调用第三个generatePromise时,它将返回一个将被拒绝的承诺,因此将调用catch方法,然后最后一个then将不会执行。

我们已经使用承诺有一段时间了,其语法可能相当冗长,需要像 thencatch 这样的关键字。一种更高级且更美观的语法涉及使用 asyncawait。我们将在下一节深入探讨这种方法。

使用 asyncawait 处理异步代码

ES2017 引入了一种处理异步代码的新方法,即 asyncawait 关键字。这些关键字是承诺的语法糖;它们不是处理异步代码的新方法,但它们使代码更容易阅读和编写。

实际上,async 关键字用于定义一个异步函数,而 await 关键字用于在函数内部暂停并等待一个承诺的解决。即使你使用了 async 这个词,它也不会让你的代码变为异步,只有当你实际上在代码中使用了异步代码(一个承诺)时,才会发生这种情况。为了使其更简单,我们可以这样说,为了使用 await,我们需要使用 async 来定义代码块。让我们更详细地探讨一下我们如何使用 async

async

当一个函数使用 async 关键字定义时,它将始终返回一个承诺,可以像任何常规承诺一样处理。让我们看看一个例子:

const asyncFun = async (generateError) => {
    if (generateError) {
        throw new Error("Error generated");
    }
    return 1;
};
asyncFun().then((result) => console.log(result));
asyncFun(true).catch((error) => console.log(error));

由于这是承诺的语法糖,我们可以使用承诺构建一个类似的功能:

const asyncFun =  (generateError) => new Promise((resolve, reject) => {
    if (generateError) {
        reject(new Error("Error generated"));
    }
    resolve(1);
});
asyncFun().then((result) => console.log(result));
asyncFun(true).catch((error) => console.log(error));

现在,让我们熟悉一下 await;我们将能够无缝地结合这两个关键字,并消除使用 thencatch 的需要。

await

让我们看看如何使用 await 关键字来等待承诺:

// Promises
fetch(' https://api.demo.foo/v1/todo')
  .then(response => response.json())
  .then(json => console.log(json))
  .catch(error => console.log(error));
// Async/Await
const fetchData = async () => {
  try {
    const response = await fetch('https://api.demo.foo/v1/todo');
    const json = await response.json();
    console.log(json);
  } catch (error) {
      console.log(error);
  }
}
fetchData(); // [{userId: 1, id: 1, title: 'delectus aut autem',
completed: false}]

如你所见,使用 asyncawait 可以使代码更容易阅读和编写。await 关键字只能用在 async 函数内部。我们需要使用 try/catch 块来处理错误。

try/catch 是 JavaScript 提供的一种机制,允许我们将某些代码封装在 try 块中,并使用 catch 块处理任何可能出现的错误。所以,在上一个例子中,由于我们正在进行 HTTP 请求,我们依赖于外部因素,如互联网的连接性或外部服务器返回我们请求的信息的能力。在我们的特定情况下,我们“静默失败”这个错误,因为在 catch 块中我们只打印有关错误的信息,但在其他情况下,我们可能在 UI 中显示一个警告消息或触发重试策略来尝试再次执行此 HTTP 请求。重要的是要记住,如果我们没有正确处理错误,我们的应用程序可能会崩溃。我们将在 第十五章 中详细探讨这个主题。

现在,让我们探讨如何将 async 与此语法糖结合使用,即使在较旧的 Node.js 版本中。

IIFEs

在某些情况下,我们希望在 async 函数外部使用 await 关键字,例如,当我们想在模块的最高级别使用 await 关键字时。在这种情况下,我们可以使用 IIFE 将 await 关键字包裹在 async 函数内。IIFE 是在创建后立即执行的函数。这是一种设计模式,用于避免将变量和函数污染全局作用域。在下面的示例中,我们可以观察到基本的语法:

(function () {
  // ... some code here
})();

想法是创建一个匿名函数,并在创建后立即执行它。为了实现这一点,我们需要在函数之间添加括号,然后添加另一对括号来执行函数:(...)()

我们可以在立即执行函数表达式(IIFE)中轻松使用 asyncawait

(async () => {
    const response = await fetch(' https://api.demo.foo/v1/todo ');
    const json = await response.json();
    console.log(json);
})()

这保证了代码将在创建后立即执行,我们可以在 IIFE 中使用 await 关键字。

摘要

在本章中,我们学习了 JavaScript 中的异步编程。我们探讨了异步 API,如 setTimeoutfetch,并学习了如何使用回调、promises 和 async/await 来处理异步代码。此外,我们还学习了错误优先回调约定以及如何使用命名函数和 promises 来避免回调地狱。最后,我们学习了如何管理 promises,如何使用 Promise.allPromise.race 方法进行批量操作,以及如何使用 asyncawait 关键字以更干净的方式处理异步代码。

在下一章中,我们将学习 HTTP 以及现代网络如何使用 REST API 工作。

进一步阅读

JavaScript 中的异步编程是一个广泛的主题,需要相当多的时间来掌握和完全理解。以下链接将向您展示一些宝贵的资源,这些资源将帮助您深入了解本章涵盖的主题:

第二部分:Node.js 生态系统和架构

第二部分 中,您将学习如何通过使用庞大的 npm 生态系统来使用 Node.js 核心库和第三方库。您还将详细了解如何使用和实现事件驱动架构,并了解如何在项目中使用和实现单元测试。

本部分包括以下章节:

  • 第五章, Node.js 核心库

  • 第六章, 外部模块和 npm

  • 第七章, 事件驱动架构

  • 第八章, Node.js 中的测试

第五章:Node.js 核心库

在本章中,我们将深入研究 Node.js 的核心库,并探讨模块化代码的技术。JavaScript 已经从仅限于浏览器的限制中走出来,Node.js 为我们提供了新的代码结构方式。我们将从理解在浏览器中组织代码的历史局限性以及它们如何导致各种模块系统的开发开始。我们将主要关注两个模块系统,CommonJSCJS)和ECMAScript 模块ESM),并讨论它们的用法、导入和导出。实现这两个系统之间的互操作性至关重要,我们将探讨使它们无缝工作的策略。

理解 Node.js 核心库的结构是关键。我们将更深入地研究包括 fshttp 在内的核心库,这些库处理文件操作,并探讨使用回调、同步函数和承诺进行异步 I/O 操作的使用。

此外,还将讨论更多与使用 C++ 插件扩展 Node.js 功能和通过 child_process 库执行外部命令相关的先进主题。我们还将回顾各种命令行选项(包括启用实验性功能和控制内存分配)以及允许您自定义 Node.js 行为的环境变量。我们将提供如何使用这些选项来启用实验性功能、控制内存分配以及微调您的 Node.js 应用程序的示例。

总结一下,以下是本章我们将探讨的主要主题:

  • 如何使用 ESM 和 CJS 方法创建和消费模块

  • 如何在 ESM 和 CJS 模块之间进行互操作

  • Node.js 核心库接口的结构

  • 在开始使用 Node.js 时,最相关的 Node.js 核心库是什么

如何通过使用命令行选项和 NODE_OPTIONS 环境变量来扩展 Node.js 功能

技术要求

本章的代码文件可以在 github.com/PacktPublishing/NodeJS-for-Beginners 找到。

查看本章的代码执行视频,请访问 youtu.be/WQzdXAFxdsc

模块化您的代码(ESM 与 CJS)

许多年来,JavaScript 仅限于浏览器中,我们组织代码的唯一方式是使用在 HTML 页面中按正确顺序加载的脚本文件。这是通过在 HTML 文件中包含特定引用来实现的,例如以下内容:

<!-- External Sources -->
<script src="img/jquery-3.7.0.min.js"></script>
<!-- Other files -->
<script src="img/script1.js"></script>
<!-- Direct Scripts -->
<script>
console.log("Hello world");
</script>

这种方法不可扩展,很容易污染全局作用域。为了解决这个问题,历史上我们使用了 IIFE 模式和模块模式。随着 JavaScript 的采用率开始增长,现代网站所需的 JavaScript 量急剧增加,社区开始创建库和框架来解决上述问题。结果包括 RequireJS (requirejs.org/)。

多年来,我们有四种不同的方式来组织我们的代码:

  • CommonJS (CJS)

  • ECMAScript Modules (ESM)

  • 异步模块 定义 (AMD)

  • 通用模块 定义 (UMD)

在这本书中,我们将重点关注前两种方法,CJS 和 ESM。目前 CJS 是 Node.js 的默认模块系统,但自从 Node.js 12 发布以来,ESM 现在也已可用。在本节中,我们将探讨如何使用这两种方法创建和消费模块。

重要提示

现在,在浏览器环境中,使用模块打包器(如 webpack 或 Rollup)来整合我们的代码是非常常见的。然而,在 Node.js 中,我们仍然直接使用 CJS 或 ESM。在本节中,我们将探讨如何使用这两种方法创建和消费模块。

CommonJS (CJS)

CommonJS 是 Node.js 默认使用的模块系统。这个模块系统是同步的,基于 requiremodule.exports 函数。需要注意的是,这个模块系统不是 ECMAScript 规范的一部分,但在 Node.js 生态系统中是最常用的模块系统,特别是如果你在寻找文档或教程的话。

在这里,我们需要了解 CJS 使用的两个方面:导入和导出。让我们从导入开始。

导入

因此,在我们的项目中,有两个文件,utils.jsindex.js。在这个例子中,我们在 index.js 文件中从 utils.js 文件导入 sayHello 函数,如下所示:

const sayHello = require('./utils.js');
sayHello();

require 函数是 Node.js 中可用的全局函数,用于导入模块。require 函数接收一个字符串作为参数,这个字符串是我们想要导入的模块的路径。在这个例子中,我们使用的是相对路径,但也可以使用绝对路径,甚至可以使用安装在 node_modules 文件夹中的模块名称。

导出

在这个例子中,我们在 utils.js 文件中导出 sayHello 函数:

function sayHello() {
  console.log('Hello world');
}
module.exports = sayHello;

module.exports 是 Node.js 中可用的全局对象,用于导出模块。在这个例子中,我们导出 sayHello 函数,但我们可以导出任何类型的值。

如果你执行 index.js 文件,你会看到以下输出:

$ node index.js
Hello world

但如果我们执行 utils.js 文件,我们将看不到任何东西。即使文件被执行,sayHello 函数本身也不会执行,只是定义了:

$ node utils.js

导出对象结构

在导出模块时,最常用的结构是对象结构,因为它非常灵活,允许我们导出多个值。如果我们想导出多个值,我们可以使用exports对象:

// You can export directly
exports.sayHello = () => {
  console.log('Hello world');
}
function sayGoodbye() {
  console.log('Goodbye world');
}
// You can also export using references
exports.sayGoodbye = sayGoodbye;

但我们也可以直接使用module.exports = {}导出一个对象:

const sayHello = () => {
  console.log('Hello world');
}
function sayGoodbye() {
  console.log('Goodbye world');
}
module.exports = {
  sayHello,
  sayGoodbye
}

我推荐前面的选项,因为它在处理大型文件时更易读。为了导入导出的值,我们可以使用解构语法:

const { sayHello, sayGoodbye } = require('./utils.js');
sayHello();
sayGoodbye();

JSON 支持

是的,你可以在 Node.js 项目中直接添加 JSON 文件,而无需使用任何外部库或解析内容:

{
  "name": "John",
  "lastName": "Doe"
}

现在我们可以直接引入该文件:

const user = require('./user.json');
console.log(user);
// { name: 'John', lastName: 'Doe' }

Node.js 中模块的工作方式与 IIFE 模式非常相似。当我们导入一个模块时,代码会被执行并且模块会被缓存。如果我们再次导入相同的模块,代码将不会再次执行,并且模块将从缓存中检索。基本上,模块只执行一次(单例模式)。

例如,如果我们修改了导入的 JSON 文件,一旦导入模块后,这些更改将不会反映在导入的模块中,因为它是只读的,并且内容被缓存在了程序内存中。

ECMAScript 模块(ESM)

Node.js 12 引入了对importexport关键字的支持。

为了使用 Node.js 20.11.0 中的模块,你需要创建一个package.json文件并添加以下配置:

{
  "type": "module"
}

重要提示

第六章中,我们将探讨如何创建package.json文件以及如何对其进行深度配置。

基本用法

在本例中,我们正在导出utils.js文件中的sayHello函数:

export default function sayHello() {
  console.log('Hello world');
}

export关键字用于导出模块。在这种情况下,我们正在导出sayHello函数,但我们可以导出任何类型的值。注意,我们使用了default关键字,这是因为我们正在导出一个单一值。如果我们想导出多个值,我们可以使用不带default关键字的export关键字。

在本例中,我们正在从utils.js文件中导入sayHello函数:

import sayHello from './utils.js';
sayHello();

导出对象结构

在导出模块时,最常用的结构是对象结构,因为它非常灵活,允许我们导出多个值。如果我们想导出多个值,我们可以使用export关键字:

const sayHello = () => {
  console.log('Hello world');
}
function sayGoodbye() {
  console.log('Goodbye world');
}
export { sayGoodbye, sayHello };

在本例中,我们以几种方式从utils.js文件中导入sayHellosayGoodbye函数:

// Import values directly
import { sayHello, sayGoodbye } from './utils.js';
// Use wildcards to import all the exported values
import * as utils from './utils.js';
sayHello();
utils.sayHello();

支持 JSON 文件

在使用 ESM 时,我们无法直接导入 JSON 文件,就像我们为 CJS 所做的那样。如果我们尝试导入一个 JSON 文件,我们将得到以下错误:

TypeError [ERR_IMPORT_ASSERTION_TYPE_MISSING]: Module "file:///{REDACTED}/user.json" needs an import assertion of type "json"

在未来,将可以直接导入 JSON 文件,有一个提案(github.com/tc39/proposal-import-attributes)将允许我们使用导入属性,例如 import json from "./foo.json" with { type: "json" };。但到目前为止,我们需要使用一种变通方法来导入 JSON 文件。我们可以通过理解 ESM 和 CJS 之间的互操作性来修复这个错误。

理解模块互操作性是如何工作的

虽然 ESM 是未来,但仍有许多库和框架仍在使用 CJS。好消息是 Node.js 支持这两种模块系统,并且可以在同一个项目中无问题地使用它们,但我们需要注意一些事项以确保其正常工作。

重要提示

互操作性一直是 Node.js 社区中的一个非常具有争议的话题,并且对此有很多讨论。如果你想了解更多,我推荐你阅读 Gil Tayar 的这篇文章:medium.com/@giltayar/native-es-modules-in-nodejs-status-and-future-directions-part-i-ee5ea3001f71

ESM 中的 JSON 文件

在前面的章节中,我们看到了在 ESM 中今天无法直接导入 JSON 文件。但我们可以使用 Node.js 内置的 module 库来导入 JSON 文件。module 库是一个全局对象,在所有模块中都是可用的,它包含 createRequire 方法,允许我们创建一个 require 函数,该函数可以用来导入 CJS 模块:

import { createRequire } from "module";
const require = createRequire(import.meta.url);
const user = require("./user.json");
console.log(user);
// { name: 'John', lastName: 'Doe' }

文件扩展名 (.cjs 和 .mjs)

为了在同一个项目中使用这两种模块系统,我们需要在我们的文件中使用不同的文件扩展名。.mjs 扩展名用于 ESM 模块,而 .cjs 扩展名用于 CJS 模块。

重要提示

如果你使用 .js 扩展名来为你的文件命名,Node.js 将默认尝试使用 CJS 模块系统,就像你使用 .cjs 扩展名一样。

这里是使用这两种模块系统的项目的文件结构:

├── index.cjs
├── index.mjs
├── utils.cjs
└── utils.mjs

utils.cjs 文件是一个 CJS 模块:

const sayGoodbye = () => {
  console.log('Goodbye world');
}
module.exports = { sayGoodbye }

utils.mjs 文件是一个 ESM 模块:

const sayHello = () => {
  console.log('Hello world');
}
export { sayHello }

index.mjs 文件是一个 ESM 模块。只要我们使用不同的文件扩展名,我们就可以在同一个文件中结合使用这两种模块系统:

import { sayHello } from './utils.mjs';
import { sayGoodbye } from './utils.cjs';
sayHello();
sayGoodbye();

index.cjs 文件是一个 CJS 模块。在这种情况下,由于 require 被设计为同步函数,而 ESM 模块是异步的,因此不能直接导入 ESM 模块。但我们可以使用 import 函数异步导入 ESM 模块:

const { sayGoodbye } = require('./utils.cjs');
import("./utils.mjs").then(({ sayHello }) => {
    sayHello();
    sayGoodbye();
});

重要信息

这种导入模块的方式是标准的一部分,被称为动态导入(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#dynamic_imports)。

在这本书中,我们默认使用 ESM 模块,但当我们需要与其他库和框架进行互操作性时,我们将使用 CJS 模块。

现在我们对如何以不同格式创建模块有了更清晰的认识,让我们在下一节中探讨 Node.js 核心 API 如何使用类似的方法来公开我们在项目中非常常用的众多功能。

核心库的结构

几年来,Node.js 已经发展壮大,核心库也是如此。有许多库可供我们使用,了解它们的结构对于能够正确使用它们非常重要。

大多数核心库都非常简单,并且以类似的方式构建,因此你知道在实际应用中可以期待什么。一旦你学会了如何使用其中一个,你将能够毫无问题地使用其余的库。

此外,你将能够创建自己的库并在 npm 上发布它们,其他开发者将能够轻松地使用它们,但我们在下一章中会讨论这一点。

库结构

让我们以fs库为例。fs库用于与文件系统交互,它是 Node.js 中最常用的库之一。

任何执行 I/O 操作的库都是异步的。从历史上看,Node.js 提供了两种处理 I/O 操作的方法:回调或同步函数。虽然回调仍然被支持,但 Node.js 目前提供了相同的功能,提供了一个 promise 接口。

在这个示例中,我们将使用readFile函数异步读取文件。这个函数接收要读取的文件的路径,以及当文件被读取时将被调用的回调函数。回调函数接收两个参数:一个错误对象和文件的内容:

import { readFile } from 'node:fs';
readFile('hello.txt', (err, content) => {
  if (err)  {
    console.error("OMG, there is an error:", err);
    return;
  }
  console.log(`File content: ${content}`);
  // File content: Hello world
});

当你运行前面的示例时,它将抛出一个错误,因为文件不存在。然而,我们使用回调中的错误优先模式来处理错误。你可以看到“OMG,那里... 错误信息”。现在,如果你创建一个包含内容Hello worldhello.txt文件,并再次运行脚本,你将看到预期的内容被打印出来。

在下一个示例中,我们将使用readFileSync函数来同步读取文件。这个函数接收要读取的文件的路径,并返回文件的内容:

import { readFileSync } from 'node:fs';
try {
  const content = readFileSync('hello.txt');
  console.log(`File content: ${content}`);
  // File content: Hello world
} catch (err) {
  console.error("OMG, there is an error:", err);
};

最后,在这个示例中,我们将使用readFile函数异步读取文件。这个函数接收要读取的文件的路径,并返回一个当文件被读取时将被解决的 promise。这个 promise 将被文件的内容解决:

import { readFile } from 'node:fs/promises';
readFile('hello.txt')
  .then(content => console.log(`File content: ${content}`))
  .catch(err => console.error("OMG, there is an error:", err))

无前缀的核心库

从历史上看,Node.js 提供了不带node:*前缀的核心库,例如const { readFile } from 'fs'。这主要是为了向后兼容。但建议使用带有前缀node:*的新语法。你可以在互联网上找到许多使用旧语法的示例。更多信息请参阅nodejs.org/api/modules.html

CJS 支持

所有核心库都作为 CJS 模块提供,因此你可以在项目中使用它们而不会遇到任何问题。你可以使用require函数来导入它们:

CJS ESM
const { readFile } = require('node:fs') import { readFile } from 'node:fs'
const { readFileSync } = require('node:fs') import { readFileSync } from 'node:fs'
const { readFile } = require('node:fs/promises') import { readFile } from 'node:fs/promises'
const { readFile } = require('node:fs') import { readFile } from 'node:fs'
const { readFileSync } = require('node:fs') import { readFileSync } from 'node:fs'

其他接口

你将经常使用的其他核心库,例如httphttps,结构类似,并提供了一个用于处理事件的接口。我们将在第七章中深入探讨这个主题。

稳定指数

稳定指数是一个表示核心库稳定性的数字。稳定指数介于 0 到 3 之间,其中 0 表示已弃用,1 表示实验性,2 表示稳定,3 表示遗留。

你可以在官方文档中找到每个核心库的稳定指数,以及有关稳定指数的更多详细信息,请参阅nodejs.org/dist/latest-v20.x/docs/api/documentation.html#stability-index

重要提示

如果你刚开始使用 Node.js,你应该使用稳定指数为 2 或 3 的核心库。稳定指数为 0 或 1 的核心库不建议用于生产环境。

让我们看看 Node.js 20 的一些示例:

  • 权限模型(nodejs.org/docs/latest-v20.x/api/permissions.html#permission-model):这是一个允许我们限制对系统资源(如网络或文件)访问的 API。目前,它处于积极开发中(稳定性=1),因此你可以尝试使用它,但它还不够成熟,不能用于构建生产系统,因为 API 可能会更改或出现意外的行为。

  • http (nodejs.org/docs/latest-v20.x/api/http.html#http):这是自 Node.js 诞生以来用于构建网络服务器应用程序和对外部资源发起 HTTP 请求的 API。目前它是稳定的(稳定性=2),但某些方法是遗留的(稳定性=3)。这个库非常适合在生产系统中使用。

其他核心库

fs 库只是一个例子;在这本书中,我们将介绍最重要的核心库,但你可以在 Node.js 文档中找到所有核心库的文档,网址为 nodejs.org/docs/latest-v20.x/api/index.html

在我谦逊的观点中,当你刚开始使用 Node.js 时,最重要的核心库如下,按字母顺序排列:

  • Buffer 在内存中高效地处理二进制数据,常用于文件操作和网络通信等任务。

  • Crypto 提供了加密、解密、哈希和数字签名等加密功能。

  • Events 允许我们在应用程序内部创建、触发和监听事件。

  • File System 提供了一个稳定的接口来处理文件系统(文件、文件夹、创建、删除等)。

  • HTTP 允许我们创建 HTTP 服务器并执行 HTTP 请求。

  • OS 提供了各种实用工具来检索有关系统架构、平台、CPU、内存、网络接口等信息。

  • Path 提供了处理文件路径和目录路径的实用工具。

  • Process 提供了关于当前 Node.js 进程方面的信息和控制,包括环境变量、生命周期事件等。

  • Stream 提供了可读和可写流,以及用于在数据通过时修改数据的转换流。这个模块对于在 Node.js 中构建可扩展和内存高效的、处理大量数据的应用程序至关重要。

  • Timers 包含了诸如 setTimeout()setInterval()setImmediate() 等函数。

还有其他一些核心库对于扩展 Node.js 的功能非常重要,但当你刚开始使用 Node.js 时,它们并不那么重要。

例如,child_process 库对于从 Node.js 执行外部命令(如 lscat)、复杂的应用程序(如 ffmpegimagemagick)以及直接执行 Python 脚本至关重要。

C++ Addons (nodejs.org/dist/latest-v20.x/docs/api/addons.html) 对于使用 C++ 代码扩展 Node.js 的功能非常重要。当你需要在 Node.js 应用程序中使用 C++ 库时,这非常有用。

命令行选项

Node.js 提供了许多命令行选项和环境变量,您可以使用它们来定制 Node.js 的行为。您可以在 Node.js 文档中找到完整的命令行选项列表,文档地址为 nodejs.org/dist/latest-v20.x/docs/api/cli.html#cli_command_line_options

例如,您可以使用 --experimental-json-modules 命令行选项来启用 ESM 中的 JSON 模块,例如 node --``experimental-json-modules index.js

index.js 文件的代码如下:

import data from './data.json' assert { type: 'json' };
console.log(data);

这确实有效,终端输出将指出 JSON 模块是实验性的:

(node:21490) ExperimentalWarning: Import assertions are not a stable feature of the JavaScript language. Avoid relying on their current behavior and syntax as those might change in a future version of Node.js.
(Use `node --trace-warnings ...` to show where the warning was created)
(node:21490) ExperimentalWarning: Importing JSON modules is an experimental feature and might change at any time

除了启用实验性功能外,您还可以使用 --max-old-space-size 命令行选项来增加 Node.js 的 RAM 使用限制。当您处理大文件、内存中有大量数据或调试复杂的内存泄漏时,这非常有用。

例如,您可以使用 --max-old-space-size=4096 命令行选项将 RAM 限制增加到 4GB:node --``max-old-space-size=4096 index.js

重要提示

您无法使用计算机中的所有 RAM,因为操作系统和其他应用程序也需要一些 RAM 来正常工作。

环境变量

您可以使用环境变量来定制 Node.js 的行为。您可以在 Node.js 文档中找到完整的环境变量列表,文档地址为 nodejs.org/dist/latest-v20.x/docs/api/cli.html#cli_environmental_variables

有时候使用环境变量而不是直接使用命令行选项会更方便,例如在使用基于 UNIX 的系统时:

# Define the environmental variable
export NODE_OPTIONS='--experimental-json-modules,--max-old-space-size=4096'
# Run the Node.js application as usual
node index.js

上述代码允许您使用 NODE_OPTIONS 环境变量来设置您想要使用的命令行选项。当您使用 nodemonpm2 等工具运行 Node.js 应用程序时,这非常有用。我们将使用来自 第十二章 的许多环境变量。

摘要

在本章中,我们介绍了 Node.js 中模块的工作方式、CJS 和 ESM 之间的区别以及它们之间的互操作性。

此外,我们还介绍了 Node.js 的核心库、如何使用它们、它们的结构和稳定性指数。我们列出了在开始使用 Node.js 时最重要的核心库,以及在其他更高级项目中变得至关重要的库。

最后,我们学习了如何使用命令行选项和环境变量来修改 Node.js 的行为。

在下一章中,我们将深入学习如何使用 node.js 包管理器npm)。我们将发布我们的第一个包,并了解我们如何将可用的庞大模块生态系统集成到我们的 Node.js 项目中。

进一步阅读

)

第六章:外部模块和 npm

Node 包管理器 (npm) 是全球最受欢迎的软件注册库之一。有超过两百万个包可供我们使用。在本章中,我们将探讨如何使用 npm 命令和 NPX,以及同构库是什么以及如何为我们的项目选择正确的依赖项,以便我们能够最小化风险。作为最后的实践,您将发布一个包到 npm。

在本章中,我们将探讨如何在我们的项目中使用外部模块。这将使我们能够重用其他开发者的代码,节省时间和精力。我们将一起探索 Node.js 模块的庞大生态系统,并学习如何为我们的项目选择正确的模块。

总结一下,以下是本章我们将探讨的主要主题:

  • 使用 package.json 管理应用

  • 为您的项目选择正确的依赖项

  • 安装依赖项

  • 移除依赖项

  • 理解 package-lock.json

  • 管理依赖项版本

  • 构建 Isomorphic JavaScript

  • 使用 npm 脚本

  • 使用 NPX 直接执行包

  • npm 替代方案

  • 发布您的第一个包

技术要求

本章的代码文件可以在 github.com/PacktPublishing/NodeJS-for-Beginners 找到

查看本章的代码执行视频,请访问 youtu.be/B-7vZyAfi2U

使用 package.json 管理应用

当您安装 Node.js 时,npm 也会一并安装。npm 是 Node.js 的包管理器。它用于从我们的项目中安装、更新和删除包。它还允许我们发布自己的包。

包是一个我们可以用于我们应用程序的 JavaScript 库,可以帮助我们加快开发自己项目的速度。有各种各样的包,从非常简单的,比如一个可以告诉我们一个数字是否为奇数的函数(www.npmjs.com/package/is-odd),到非常复杂的库,可以帮助我们使用 Firebase (firebase.google.com/?hl=es) 来存储用户的资料(www.npmjs.com/package/firebase)。在单个项目中使用许多库是非常常见的,一些公司甚至创建自己的私有库来分发工具、配置以及更多内容到他们的众多代码库中。

package.json 文件是我们项目的清单文件。它包含我们项目的元数据,例如名称、版本、描述、作者和许可证。它还包含我们项目的依赖项,包括运行时依赖项和开发依赖项,以及我们可以使用 npm 运行的脚本。

为了创建一个 package.json 文件,我们可以运行以下命令:

npm init

此命令会问我们几个问题,然后创建 package.json 文件。为了更快地创建,你可以使用 npm init -y 自动创建带有默认值的文件。

我们也可以手动创建 package.json 文件,但建议使用 npm init 命令。

package.json 文件可以非常简单,就像这样:

{
  "name": "my-project",
  "version": "1.0.0",
  "description": "My project",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "John Doe",
  "license": "MIT"
}

但它也可以是一个大文件,包含一个或多个依赖项、脚本和额外的元数据。目前,package.json 文件还没有官方标准,但 OpenJS 基金会的标准工作组正在努力制定它(github.com/openjs-foundation/standards/issues/233)。

目前,我们可以使用 npm 文档来了解在 package.json 文件中可以使用哪些字段。文档可在 docs.npmjs.com/cli/v7/configuring-npm/package-json 查找。

在接下来的章节中,我们将了解 package.json 文件中的一些最重要的字段,以及如何在我们的项目中使用它们。

为你的项目选择正确的依赖项

诚然,npm 生态系统非常稳固,并且每天都在增长。但同样真实的是,许多包已经不再维护,或者包含安全漏洞和性能问题。

社区对此有所了解,并且有很多关于这个话题的笑话和梗图。例如,以下这张图片:

![图 6.1 – MonkeyUser 的 npm 交付 – 一张经典的梗图,展示了我们倾向于在项目中包含多少依赖项(www.monkeyuser.com/2017/npm-delivery/])(img/B21678_06_01.jpg)

图 6.1 – MonkeyUser 的 npm 交付 – 一张经典的梗图,展示了我们倾向于在项目中包含多少依赖项(www.monkeyuser.com/2017/npm-delivery/

虽然这是一个基于我们在平均项目中安装的大量依赖项和子依赖项的笑话,但事实是我们选择项目依赖项时需要谨慎。在本节中,我们将了解如何为我们的项目选择正确的依赖项。

注意

大多数模块都依赖于其他模块,而这些模块又依赖于其他模块,以此类推。这被称为依赖树。当我们安装一个模块时,我们实际上是在安装该模块的所有依赖项,以及所有依赖项的依赖项,等等。这就是为什么选择正确的依赖项对我们项目来说很重要。

在选择依赖项之前,我们需要问自己以下问题:

  • 选择不良依赖项有哪些风险?

  • 我应该使用哪些标准来选择一个依赖项?

让我们看看这些问题的答案!

风险

在我们现代世界中,我们已经习惯了使用依赖项。没有使用依赖项构建现代网络应用将非常困难,或者直接不可能。

当我们选择一个依赖项时,我们正在承担风险。让我们看看与选择不良依赖项相关的主要风险:

  • 安全漏洞:一个依赖项可能存在安全漏洞,甚至可能是一段恶意代码。

  • 性能问题:一个依赖项可能存在性能问题,并产生内存泄漏,这可能会影响我们应用程序的性能,甚至导致其崩溃。

  • 维护问题:一个依赖项不再维护,并且可能在将来被弃用。这可能导致我们的应用程序在未来停止工作,或者阻止我们升级其他依赖项或 Node.js 本身。

在 2020 年,我发布了一篇名为 什么是后门? 让我们用 Node.js 构建一个 的有争议的博客文章(snyk.io/blog/what-is-a-backdoor/). 在那篇博客文章中,我解释说后门是一段代码,它允许我们在不经过身份验证过程的情况下访问系统。我还解释了如何使用几行代码用 Node.js 构建后门,并展示了发布和分发恶意包是多么容易。

我知道安全问题是一个非常敏感的话题,尤其是如果你刚开始你的 Web 开发之旅。本书的第十五章专门讨论了安全问题,我们将在那里深入探讨安全问题。

为了最小化风险,我们需要为我们的项目选择正确的依赖项。让我们看看如何做到这一点。

良好的标准

我们可以使用许多标准来选择适合我们项目的正确依赖项。在本节中,我们将看到其中一些最重要的标准。

我们试图避免什么?

我们试图避免以下事项:

  • 已不再维护的包

  • 存在已知安全漏洞且未修补的包

  • 依赖项众多或质量低下的包

  • 不受欢迎或质量低下的包

  • 存在许可问题或低质量的包

我们有什么证据?

在我们安装任何包之前,我们将做一些基本的 OSINT,并将详细检查两个数据源:npm 网站和 GitHub 或代码仓库。

OSINT 是通过收集和分析公开信息以回答特定情报问题而产生的情报。(Ritu Gill, www.sans.org/blog/what-is-open-source-intelligence/.)

真实示例

在这本书中,我们将使用 Express 库。Express 是 Node.js 中一个非常流行的库,用于构建 Web 应用程序和 API。在这张图片中,我们可以详细看到 Express 库如何在 npm 网站上展示:

图 6.2 – npm 中的 Express 库包 (https://www.npmjs.com/package/express)

图 6.2 – npm 中的 Express 库包 (https://www.npmjs.com/package/express)

从 npm 网站上,我们可以看到以下信息:

  • 有 31 个依赖项,其中大多数非常受欢迎,并且由相同的 Express 维护者精心维护

  • 有+77k 个依赖项,这意味着许多其他包的作者正在他们的项目中使用 Express

  • 已发布 271 个版本

  • 每周有近 31M 次下载,这意味着 Express 是 Node.js 社区中非常受欢迎的包

  • 一个 MIT 许可证,这是一个许可宽松的许可证,也是有效的开源许可证

  • 清晰且坚实的文档

  • 它最近几天才发布,这意味着该包得到了维护和定期更新

从 npm,我们可以访问该包的 GitHub 仓库。在下面的图片中,我们可以详细看到 Express 库在 GitHub 网站上的展示:

图片

图 6.3 – Express 库在 GitHub 上的仓库(https://github.com/expressjs/express)

从 GitHub 网站,我们可以看到以下信息:

  • 它有+10k 个分支,这意味着许多其他开发者正在为项目做出贡献

  • 它有+60k 个星标,这意味着项目在社区中很受欢迎

  • 它有+5k 次提交,这意味着该项目有着悠久的历史

  • 它有+3k 个已关闭的问题,+120 个开放的问题,+1k 个已关闭的拉取请求,和+60 个开放的拉取请求,这意味着项目是活跃的

  • 它有近 300 个贡献者,这意味着许多其他开发者正在为项目做出贡献并推动其发展

如我们所见,我们从 npm 网站和 GitHub 仓库中获得了大量信息,至少可以做出初步决定,尤其是如果我们想比较几个包。选择范围很广,有时很难选择正确的包。

规则的例外

我们需要对我们之前看到的规则相当灵活,因为很多时候我们可以找到规则的例外。

例如,johnny-five (www.npmjs.com/package/johnny-five) 是在 Node.js 中与 Arduino 和 Raspberry Pi 一起工作时使用的优秀库。但每周的总下载量非常低。在这种情况下,我们需要考虑与使用 Express 的开发者相比,使用 Arduino 和 Raspberry Pi 的开发者要少得多。

另一个例子是 Lodash (www.npmjs.com/package/lodash),这是一个非常受欢迎的库,并被许多其他包使用。但最后一个版本是在三年前发布的。在这种情况下,我们需要考虑项目基本上已经完成,并且它不再发展,只是在需要时发布新版本。

废弃通知

有时,我们可以找到一个已废弃的包。在这种情况下,我们应该避免使用它。我们可以在 npm 网站、GitHub 仓库或安装包时找到废弃通知。

图片

图 6.4 – 来自 npm 文档的图片,展示了 npm 中如何显示弃用警告(https://docs.npmjs.com/packages-and-modules/updating-and-managing-your-published-packages/deprecate-package.png)

非常常见,在弃用通知中,我们可以找到一个建议使用另一个包的建议。在这种情况下,我们应该遵循建议。

工具

在之前的 OSINT 分析中,我们回答了大部分问题,但并没有回答有关已知漏洞的问题。这些天,我使用两个工具来检查已知漏洞:Snyk (snyk.io/) 和 socket.dev (socket.dev/)。

第十五章 中,我们将详细了解它们的使用。为了正确使用这些工具,你需要了解依赖项树是如何工作的以及漏洞是如何分类的。否则,这些工具对于初学者来说可能会非常令人困惑。

我建议说“在下一节中,我们将学习如何在我们的项目中安装依赖项。

安装依赖项

现在我们知道了如何为我们的项目选择正确的依赖项,并且我们有了 package.json 文件,我们可以开始安装我们的依赖项。

本地或全局

我们可以通过两种方式安装依赖项:本地或全局:

  • 我们项目的 node_modules 文件夹。例如,express 是我们应用程序的本地依赖项。

  • 全局:这些是在我们系统的全局文件夹中安装的依赖项,因此它们可以在系统的任何地方使用,例如 Node.js 二进制文件,一旦我们打开终端,它就可用。

我们更愿意本地安装依赖项,因为这更容易管理我们项目的依赖项,并避免不同项目之间的冲突。只有在绝对必要时,我们才会全局安装依赖项。

我们将要安装的全局依赖项的一个例子是 yeoman,这是一个脚手架工具,我们将用它来生成一个新的项目。

依赖项或开发依赖项

我们可以通过两种方式安装本地依赖项:作为依赖项或作为开发依赖项:

  • express 是我们应用程序的依赖项。

  • standard,这是一个代码检查库,仅在开发代码时使用,而不是在运行时使用。

注意

安装依赖项还有另一种模式:依赖项的依赖。本书中我们将不涉及这种模式,但你可以在以下博客文章中找到更多信息和使用案例:nodejs.org/en/blog/npm/peer-dependencies

依赖项的分割非常重要,因为它允许我们在每个环境中只安装我们需要的依赖项,并减少我们应用程序和攻击面的尺寸。

添加新的依赖项

例如,如果我们想安装 express 包,我们可以使用以下命令:

# npm install <package-name>
npm install express

我们可以将standard包作为开发依赖项安装。开发依赖项是我们实际编码项目时需要的依赖项,但在项目部署或作为库分发时并不使用。由于standard是一个代码检查工具,我们仅在添加或更改代码时使用它,而在应用程序运行时不会使用它。这种依赖项的分割有很多好处,因为我们的最终应用程序将更小(忽略开发依赖项),并且更安全,因为我们有更少的外部代码。我们可以使用-D--save-dev参数来安装开发依赖项:

# npm install --save-dev <package-name>
# npm install  -D <package-name>
npm install --save-dev standard

我们可以看到package.json文件已经更新,包含两个不同部分的新的依赖项:dependenciesdevDependencies

{
  "dependencies": {
    "express": "⁴.18.3"
  },
  "devDependencies": {
    "standard": "¹⁷.1.0"
  }
}

还增加了一个新文件package-lock.json,并且已经创建了一个node_modules文件夹,其中依赖项以文件夹和文件的形式组织。

我们将在下一节中探讨package-lock.json文件是如何工作的。

注意

如果你使用 Git 或任何其他系统来分发你的源代码,node_modules不应该包含在项目源代码中。将node_modules文件夹包含在.gitignore文件中是一个好习惯,以避免将其包含在存储库中。如果你需要一个可靠的.gitignore文件用于 Node.js,你可以生成一个(www.toptal.com/developers/gitignore/api/node)。我们应该忽略node_modules,因为该文件夹可能非常大,包含许多文件和较重的权重,而且我们可以在任何时间安装依赖项,一旦我们保持package.log中的更改,我们就能安装正确的依赖项。

全局依赖项使用-g--global参数安装:

# npm install --global <package-name>
# npm install -g <package-name>
# Install yeoman globally
npm install --global yo

你可以使用listls命令查看全局依赖项列表:

# npm list --global
# npm ls --global
npm list --global

此命令的输出将类似于以下内容:

/Users/ulises/.nvm/versions/node/v20.11.0/lib
├── corepack@0.23.0
├── npm@10.2.4
└── yo@5.0.0

安装所有依赖项

如果我们想安装package.json文件中列出的所有依赖项,我们可以使用不带任何参数的installi命令:

npm install

我们也可以使用--only参数来仅安装依赖项或开发依赖项:

# Prod Only
npm install --only=prod
npm install --only=production
# Dev Only
npm install --only=dev
npm install --only=development

在生产环境中,我们希望避免使用开发工具,因为尽管这会使我们的应用程序更小、更安全,但在我们的开发环境中,我们需要所有依赖项来正确地完成我们的工作。

在下一节中,我们将探讨如何正确地从我们的项目中删除依赖项。

删除依赖项

你可以使用uninstall命令来删除依赖项:

# npm uninstall <package-name>
npm uninstall express

此命令将从package.jsonpackage-lock.json文件以及node_modules文件夹中删除依赖项。

全局依赖项使用-g--global参数删除:

# npm uninstall --global <package-name>
# npm uninstall -g <package-name>
# Remove yeoman globally
npm uninstall --global yo

在下一节中,我们将探讨package-lock.json文件如何帮助我们管理我们的依赖项。

理解package-lock.json

从历史上看,package.json文件是我们管理项目依赖项所需唯一文件。但这个文件有一个问题:它不包含我们已安装在我们项目中的每个子依赖项的确切版本,并且安装依赖项的速度也相当慢。

如果没有每个子依赖项的确切版本,可能会出现问题,因为如果我们在不同环境中安装相同的依赖项,最终可能会得到不同版本的相同依赖项。我们依赖项的不变性可能导致意外的错误和难以调试的 bug。

此外,默认情况下,当我们安装依赖项时,记录在package.json中的版本包括一个连字符^符号,例如"express"⁴.18.3"。这个符号意味着我们可以安装与记录在package.json`中的版本兼容的任何版本的依赖项。

package-lock.json文件是一个在我们安装新依赖项时自动生成的文件,并且因为它包含每个依赖项的确切版本及其来源,所以它还可以加快依赖项的安装速度。

文件可能很大,但每个依赖项的结构相当简单:

{
    "node_modules/express/node_modules/debug": {
        "version": "2.6.9",
        "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
        "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
        "dependencies": {
            "ms": "2.0.0"
        }
    }
}

如您所见,确切版本被包含在内,以及用于验证依赖项来源和避免在传输过程中对数据进行操作的resolvedintegrity字段,因为integrity提供了校验和。此外,还包括dependencies字段,以列出具有确切版本的子依赖项。

注意

package-lock.json应与项目的源代码一起分发,并应提交到仓库;基本上,它应该被视为分发中的package.json

现在我们已经知道如何对项目中的依赖项进行分类和组织,是时候探索如何安装特定版本并注意过时的依赖项了。

管理依赖项版本

如果我们想要安装特定版本的软件包,可以使用@符号。你可以尽可能具体:

# npm install <package-name>@<version>
npm install express@4
npm install express@4.17
npm install express@4.17.1

过时的依赖项

最终,我们项目中安装的依赖项将过时,我们需要更新它们。要检查我们是否有任何过时的依赖项,我们可以使用outdated命令:

npm outdated

此命令将列出所有过时的依赖项,包括当前版本、所需版本和最新版本:

Package  Current  Wanted  Latest  Location              Depended by
express   3.21.2  3.21.2  4.18.3  node_modules/express  my-project

现在我们已经清楚如何处理过时的依赖项,是时候在下一节中探索如何创建可以在所有环境中执行(浏览器和 Node.js)的同构 JavaScript 代码了。

构建 同构 JavaScript

同构 JavaScript 是一个用来描述可以在浏览器和 Node.js 中运行的 JavaScript 代码的术语。换句话说,它是一个可以在两种环境中使用的库。为了做到这一点,你将限制自己在两种环境中都有的功能。

例如,你无法在浏览器中使用 fs 模块,也无法在 Node.js 中使用 window 对象。

有时候,我们在项目中安装了设计用于在浏览器中使用的依赖项,我们试图在 Node.js 中使用它们,反之亦然。这是一个我们需要避免的常见错误。

大多数项目都会指定它们是为哪种环境设计的。以下是一个来自 Lodash 的例子 (lodash.com/):

图 6.5 – 来自 Lodash 文档的图片,详细解释了如何在两种环境中安装库 (lodash.com/)

很明显,lodash 是设计用于在 Node.js 和浏览器中使用的,从图中你可以看到如何在每个环境中安装它。

在下一节中,我们将学习如何使用 npm 脚本来提高我们在构建 Node.js 项目时的开发者体验。

使用 npm 脚本

npm 脚本是我们可以定义在 package.json 文件中的命令。这些命令可以使用 run 命令来执行:

# npm run <script-name>
npm run lint

这很棒,因为我们可以定义自己的命令,并可以使用它们来自动化任务。例如,我们可以定义一个命令来在我们的项目中运行代码检查器:

{
    "scripts": {
        "lint": "standard",
        "lint:fix": "standard --fix"
    },
    "devDependencies": {
        "standard": "¹².0.1"
    }
}

然后,我们可以运行以下命令:

npm run lint
npm run lint:fix

npm 脚本基本上是运行我们可以在终端中手动运行的命令的快捷方式。因此,你可以构建相当复杂的东西,比如启动/停止服务器、运行测试、准备基础设施以及部署你的应用程序。

这是一个非常强大的功能,我们可以用它来自动化项目中的任务,尤其是在我们作为一个团队工作时,我们想要确保每个人都在运行相同的命令或使用持续集成工具。

我们将在下一章中使用 npm 脚本来自动化项目中的任务。

直接使用 NPX 执行包

自从 5.2.0 版本以来,npm 附带了一个名为 npx 的新工具,它允许我们在不全局安装的情况下执行包。这对于一次性命令来说非常棒。

假设我们有一个具有过时依赖项的项目,我们想要更新它们:

{
  "dependencies": {
    "express": "³.21.2",
    "lodash": "¹.3.1"
  },
  "devDependencies": {
    "standard": "¹⁷.1.0"
  }
}

正如我们在上一节中看到的,我们可以使用 npm outdated 命令来检查哪些依赖项已经过时,但升级过程要复杂一些,因为我们需要手动升级每个依赖项或直接修改 package.json

幸运的是,有一个名为 npm-check-updates (www.npmjs.com/package/npm-check-updates) 的包,它允许我们升级项目中所有的依赖项。让我们学习如何使用它:

npx npm-check-updates

此命令将列出所有过时的依赖,并显示可用的最新版本:

express  ³.21.2  →   ⁴.18.3
lodash    ¹.3.1  →  ⁴.17.21

然后,我们可以使用-u标志升级所有依赖:

npx npm-check-updates –u

注意

npm-check-updates 包提供了许多选项来自定义升级过程,你可以查看www.npmjs.com/package/npm-check-updates文档获取更多信息。

package.json中升级了依赖,我们只需运行npm install即可使更改生效:

{
  "dependencies": {
    "express": "⁴.18.3",
    "lodash": "⁴.17.21"
  },
  "devDependencies": {
    "standard": "¹⁷.1.0"
  }
}

现在,我们可以通过在package.json文件中添加以下脚本来自动化此过程,这样我们就可以在未来加快此过程,我们只需添加以下脚本即可:

{
    "scripts": {
        "deps:check": "npx npm-check-updates",
        "deps:upgrade": "npx npm-check-updates -u && npm install"
    }
}

这是一个很好的例子,说明了如何结合 npm 脚本和 npx 来自动化项目中的任务,并提高其他贡献者的开发者体验,因为他们可以在需要时运行相同的命令来升级依赖。

此外,这种组合对于持续集成工具来说非常好,因为你可以在你自己的 CI 管道中运行相同的命令。

但最重要的是,你不需要安装任何全局或本地包,这样你可以将依赖保持在最低限度。

在下一节中,我们将了解关于 npm 当前替代方案的更多信息。

npm 替代方案

多年来,npm 已经成为 JavaScript 的标准包管理器,但还有其他替代方案可以在你的项目中使用。

大多数替代方案都与 npm 注册表兼容,所以你可以使用与 npm 相同的包,并且可以在它们之间无缝切换。

每个替代方案都有其自身的优缺点,所以你需要评估哪个最适合你的项目。大多数情况下,npm 将是最佳选择,但了解还有其他专为解决特定场景设计的替代方案是很好的。

让我们介绍其中的一些:

Yarn

Yarn (yarnpkg.com/)是由 Facebook 创建并于 2016 年发布的包管理器。它是为了解决 npm 当时的一些特定问题而创建的,但多年来,npm 已经取得了很大的进步,并解决了 Yarn 最初解决的问题中的大部分。

PNPM

node_modules 文件夹,它为项目中的所有依赖创建一个单独的文件夹。这种方法有一些优点,比如磁盘空间使用和网络使用。

Verdaccio

Verdaccio (verdaccio.org/)是一个私有 npm 注册表,你可以用它来托管自己的包。如果你想要为你的公司拥有一个私有注册表,或者想要有一个 npm 注册表的镜像,这是一个很好的选择。

注意

如果你遇到连接问题或想在发布包之前尝试 npm 注册表,Verdaccio 是一个很好的工具。

在下一节中,我们将学习如何发布和分发我们自己的包,这样我们就可以在项目之间重用我们的代码。此外,其他开发者也可以使用我们构建的库。

发布您的第一个包

我们已经看到了如何从 npm 注册表中安装包,但我们也可以发布我们自己的包。如果我们想与其他开发者共享我们的代码或者想在其他项目中重用我们的代码,这会非常棒。

那么,让我们看看如何在我们第一次在 npm 注册表中发布我们的包。

注册表

在我们开始之前,我们需要了解 npm 注册表是如何工作的。npm 注册表是一个公共仓库,其中存储了所有包。这是 npm 默认使用的注册表,但您也可以使用其他注册表,例如 Verdaccio(verdaccio.org/)或 GitHub Packages(github.com/features/packages)。

我们将在本章中使用 npm 注册表,但过程与其他注册表非常相似。一些开发者会在多个注册表中发布他们的包,因此您可以选择您喜欢的。

注意

如果您想发布一个私有包,更常见的是使用私有注册表,如 Verdaccio 或 GitHub Packages,但如果您想发布一个公开包,npm 注册表是最佳选择。

npm account

在我们能够发布我们的包之前,我们需要在 npm 注册表中创建一个账户。您可以通过在 npm 网站上遵循下一节中的步骤(docs.npmjs.com/creating-a-new-npm-user-account)来创建一个账户(www.npmjs.com/signup)。

准备包

因此,让我们首先为我们的包创建一个名为my-first-package的新文件夹。

我们将创建一个包含以下内容的package.json文件:

{
  "name": "@USERNAME/demo-package",
  "version": "1.0.0",
  "description": "Sample package: Node.js for beginners",
  "main": "index.mjs",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "YOUR NAME",
  "license": "MIT"
}

您需要将@USERNAME替换为您自己的 npm 用户名,在我的情况下是@ulisesgascon,并且还需要将author字段更改为您的名字。

然后,我们将创建一个包含以下内容的index.mjs文件:

function sum(a, b) {
  return a + b
}
export { sum }

最后一步是包含一个包含有关包信息的README.md文件:

# Sample package: Node.js for beginners
This is a sample package to learn how to publish packages in npm.
## Installation
```bash

npm install @USERNAME/demo-package

```js
## Usage
```js

import { sum } from '@USERNAME/demo-package'

console.log(sum(1, 2))

```js

@USERNAME替换为您之前在package.json中使用的 npm 用户名。

这是一个非常简单的包,但足以展示如何在 npm 注册表中发布一个包。

检查包

现在我们已经准备好了我们的包,我们可以在 npm 注册表中发布它。为此,我们需要在终端中运行以下命令:

npm publish --dry-run

--dry-run标志是可选的,但第一次使用时使用它是个好主意,这样可以看到将要发生什么。此命令将显示将要发布的文件以及有关包的一些信息。

npm notice
npm notice 📦  @ulisesgascon/demo-package@1.0.0
npm notice === Tarball Contents ===
npm notice 188B .vscode/settings.json
npm notice 267B README.md
npm notice 55B  index.mjs
npm notice 272B package.json
npm notice === Tarball Details ===
npm notice name:          @ulisesgascon/demo-package
npm notice version:       1.0.0
npm notice filename:      ulisesgascon-demo-package-1.0.0.tgz
npm notice package size:  617 B
npm notice unpacked size: 782 B
npm notice shasum:        cb55a05cdfb52f9dbd4b074d4940bfb5ad698d8f
npm notice integrity:     sha512-MDdDzLyysuWJS[...]H92x5C6Vvi0iA==
npm notice total files:   4
npm notice
npm notice Publishing to https: //registry. npmjs. org/ with tag latest and default access (dry-run)
+ @ulisesgascon/demo-package@1.0.0

如你所见,有一个不需要的文件,即.vscode/settings.json文件。这个文件由 Visual Studio Code 用于配置编辑器,但在包中并不需要。我们可以通过添加以下内容的.npmignore文件来删除它:

.vscode

这个文件将告诉 npm 在发布包时忽略.vscode文件夹。如果你再次运行命令,你会看到这个文件没有被包含在包中:

npm notice === Tarball Contents ===
npm notice 267B README.md
npm notice 55B  index.mjs
npm notice 272B package.json
npm notice === Tarball Details ===

发布包

现在我们已经准备好了我们的包,我们可以在 npm 注册表中发布它。要做到这一点,我们需要在终端中运行以下命令:

npm publish --access public

--access public标志使这个包对全世界开放,因此任何有互联网访问的人都可以下载你的包。

你可以在输出中看到这个包已经发布在 npm 注册表中:

npm notice
npm notice Publishing to https: //registry. npmjs. org/ with tag latest and public access
+ @ulisesgascon/demo-package@1.0.0

现在,如果你去 npm 网站(www.npmjs.com/)并搜索你的包,你将在搜索结果中看到它。你也可以直接使用以下 URL 访问包页面:https://www.npmjs.com/package/@USERNAME/demo-package(将@USERNAME替换为你的 npm 用户名,在我的情况下是@ulisesgascon)。

图 6.6 – npm 注册表中的已发布包

避免使用作用域包

在 npm 注册表中发布没有作用域的包是可能的,但找到一个未被占用的名字很难。因此,使用作用域包是一个好主意,比如在我们的例子中,使用@ulisesgascon/demo-package

但没有任何阻止你发布一个没有作用域的包,比如my-great-demo-package,如果这个名字还没有被占用。但如果你这么做,你需要小心选择名字,因为一旦你发布了包,你就不能更改名字。所以,如果你想更改名字,你需要用新名字发布一个新的包,并废弃旧的包。

发布新版本

让我们做一些修改来改进我们的包。我们将在index.mjs文件中添加一个新的multiply函数:

function sum(a, b) {
    return a + b
}
function multiply(a, b) {
    return a * b
}
export { sum, multiply }

我们也将它包含在README.md文件中:

## Usage
```js

`import { sum, multiply } from '@ulisesgascon/demo-package'

`console.log(sum(1, 2))`

`console.log(multiply(5, 2))`

```js

现在,我们再次准备好使用npm publish --access public发布包,但出现了一个错误:

npm notice Publishing to https: //registry. npmjs. org/
npm ERR! code E403
npm ERR! 403 403 Forbidden - PUT https: //registry .npmjs. org/@ulisesgascon %2fdemo-package - You cannot publish over the previously published versions: 1.0.0.

我们忘记在package.json文件中更改版本号,所以在再次发布包之前我们需要做这个更改。我们应该始终遵循语义版本控制(semver.org/),所以在这种情况下,我们将版本号更改为1.1.0,因为它是一个小版本更改,我们可以使用npm version minor命令来执行这个更改,结果我们可以看到package.json已经按预期更新:

{
  "version": "1.1.0",
}

现在,我们可以再次发布这个包,我们将在 npm 网站和终端中看到新版本:

npm notice
npm notice Publishing to https: //registry. npmjs. org/
+ @ulisesgascon /demo-package @1.1.0

如果我们再次检查 npm URL,我们可以看到新的版本和我们所做的更改:

图 6.7 – 在 npm 中更新的已发布包

防止意外发布

虽然不太常见,但有可能意外发布一个包,所以如果你不打算发布包,通过在 package.json 文件中添加 private 标志来防止这种情况是个好主意:

{
  "private": true
}

最佳实践

现在我们已经知道了如何创建和发布一个包,是时候讨论质量了。最好的包具有高级标准并遵循最佳实践。

一些最佳实践相当高级,所以我们不会在本书中涵盖它们,但这里有两大资源,可以了解更多关于它们的信息:

摘要

在本章中,我们探讨了如何从头开始创建一个包,以及如何随着时间的推移安装和维护我们的依赖项。我们学习了如何使用 package.json 文件来管理我们的依赖项,以及如何使用 package-lock.json 文件来锁定依赖项版本。

此外,我们还学习了如何使用 npm 脚本来自动化任务,以及如何使用全局依赖和 npx 来运行命令而无需全局安装它们。

最后,我们学习了如何创建我们自己的包并在 npm 注册表中发布它们,以及如何随着时间的推移更新它们。

在下一章中,我们将学习如何利用 Node.js 中的事件驱动架构来创建我们自己的事件并监听它们,以及如何使用像 HTTP 这样的核心库通过事件来通知我们关于传入请求的更多信息。我们将使用 HTTP 库构建我们的第一个网络服务器。

进一步阅读

第七章:事件驱动架构

事件是使用 Node.js 最强大的方式之一。Node.js 从头开始设计就是为了构建事件驱动的模块。许多核心库提供了易于使用和扩展的事件接口。此外,Node.js 还提供了一个强大的事件库,可以用来构建事件驱动的模块。

在本章中,我们将深入研究 Node.js 中的事件。我们将学习如何从核心库中使用事件,从事件监听器注册到事件发射,以及处理同一事件的多个监听器。

我们将使用事件构建我们的第一个 HTTP 服务器,并讨论事件监听器的组织和清理。

总结一下,以下是本章我们将探讨的主要主题:

  • 介绍事件

  • 监视文件更改

  • Node.js 事件发射器库

  • 你的第一个 HTTP 服务器

  • 为你的模块添加事件层

到本章结束时,你将知道如何使用事件,甚至如何在你的模块中包含事件接口。

技术要求

本章的代码文件可以在github.com/PacktPublishing/NodeJS-for-Beginners找到

查看本章动作视频中的代码,视频链接为youtu.be/opZER2MY1Yc

介绍事件

在现实世界中,事件是发生的事情。例如,当你点击一个按钮时,会触发一个点击事件。当你收到一条消息时,会触发一个消息接收事件。当你保存一个文件时,会触发一个文件保存事件。事件无处不在。在 Node.js 中,事件同样无处不在。

因此,当我们谈论 Node.js 中的事件时,我们谈论的是与现实世界相同的概念。事件是发生的事情,我们产生它们或消费它们。在某些情况下,一个实体产生一个事件,另一个实体消费它。在其他情况下,同一个实体既产生又消费事件。这可以非常灵活;甚至可能许多实体消费同一个事件,或者许多实体产生同一个事件。

如果你熟悉前端世界,你可能已经实现了当按钮被点击时的处理器,类似于这样:

document.getElementById('my-button').addEventListener('click', () => {
    console.log('Button clicked');
});

在这种情况下,addEventListener方法接收两个参数,事件名称和回调函数。当事件被触发时,将调用回调函数。在这种情况下,事件名称是click,但你也可以订阅许多其他事件,例如mouseovermouseoutkeydownkeyupchangesubmit

如果你与其他编程语言有过合作,你可能听说过观察者、发布/订阅和中介模式。在本章中,我们将探讨如何使用 Node.js 事件库构建事件驱动的模块,并探讨核心库是如何使用这种架构的。

熟悉事件的最佳方式之一是通过使用 Node.js 核心 API 来处理文件。我们可以订阅事件,并在文件被修改时做出反应。因此,在下一节中,我们将详细探讨这个主题。

监视文件更改

由于我们已经熟悉 Node.js 文件系统库,让我们构建一个简单的脚本,用于监视文件更改。我们将使用fs.watch方法来监视文件更改。此方法接收两个参数,要监视的文件的路径和当文件更改时将被调用的回调函数。回调函数接收两个参数,事件类型和文件名。事件类型可以是renamechange。当文件被重命名或删除时,会触发rename事件。当文件被修改时,会触发change事件。

现在,我们将创建一个简单的程序来检测文件更改:

  1. 让我们创建一个名为watch.mjs的文件,并添加以下代码:

    import { watch } from 'node:fs';
    console.log('Watching for file changes...');
    watch('./watch.txt', (eventType, filename) => {
        console.log('-----------------------------');
        console.log(`Event type is: ${eventType}`);
        if (filename) {
            console.log(`Filename provided: ${filename}`);
        }
    });
    
  2. 创建一个名为watch.txt的文件,并使用以下命令运行脚本:

    node watch.js
    
  3. 打开watch.txt文件,添加一些文本,并保存更改。您将看到脚本打印以下输出:

    Watching for file changes...
    -----------------------------
    Event type is: change
    Filename provided: watch.txt
    

如您所见,change事件被触发,并提供了文件名。现在,重命名文件并保存更改。您将看到脚本打印以下输出:

-----------------------------
Event type is: rename
Filename provided: watch2.txt

在下一节中,我们将学习如何在我们的应用程序中实现自定义事件,以及如何触发和消费它们。

Node.js 事件发射器库

现在我们已经知道了如何监视文件更改,让我们探索 Node.js 事件库。这个库提供了一个EventEmitter类,可以用来构建简单的接口来注册和注销事件监听器以及触发事件。

让我们创建一个名为event-emitter.mjs的文件,并添加以下代码:

import { EventEmitter } from 'node:events';
const emitter = new EventEmitter();
emitter.on('message', (message) => {
    console.log(`Message received: ${message}`);
});
emitter.emit('message', 'Hello world!');

在这个例子中,我们创建了一个EventEmitter类的实例,并为message事件注册了一个事件监听器。然后,我们使用消息Hello world!触发message事件。如果您运行脚本,您将看到消息在控制台中被打印出来。

您还可以为同一事件注册多个事件监听器和发射器;当您想要模块化代码以及/或者您希望从同一事件触发多个操作时,这是一种常见的做法。假设您收到一个传入的请求,并且您想存储该消息的副本并通知最终用户;通过使用事件,您可以并行处理这两个操作。让我们通过添加以下代码修改之前的例子:

setInterval(() => {
    emitter.emit('message', `Interval (${Date.now()})`);
}, 1000);
emitter.on('message', (message) => {
    console.log(`Additional listener received: ${message}`);
});
emitter.once('message', (message) => {
    console.log(`Once listener received: ${message}`);
});
setTimeout(() => {
    emitter.emit('message', 'Timeout says hello!');
}, 2500);

让我们分析一下代码。首先,我们使用setInterval方法每秒触发一次message事件。然后,我们为message事件注册了一个额外的监听器。每当message事件被触发时,都会调用此监听器。然后,我们使用once方法注册了一个事件监听器。此监听器只会被调用一次,但如果您想监听多个消息,可以使用on – 例如,在 HTTP 服务器应用程序中监听传入请求时。最后,我们使用setTimeout方法在 2.5 秒后触发message事件。如果您运行脚本,您将看到以下输出:

Message received: Hello world!
Message received: Interval (1691771547260)
Additional listener received: Interval (1691771547260)
Once listener received: Interval (1691771547260)
Message received: Interval (1691771548258)
Additional listener received: Interval (1691771548258)
Message received: Timeout says hello!
Additional listener received: Timeout says hello!

通过组织监听器来防止混乱

需要注意的一个重要事项是事件监听器是同步调用的。这意味着事件监听器是按照它们注册的顺序被调用的。此外,请记住,您可以使用更多通道在进程之间进行通信。在我们的示例中,我们使用了message,但您可以使用任何您想要的名称,或者有多个通道以更好地分段通信。

在不需要时移除监听器

EventEmitter类提供了removeListeneroff方法,可以用来移除事件监听器,以及一个removeAllListeners方法,可以用来移除给定事件的全部事件监听器。您可以在官方文档中找到更多关于它的信息:nodejs.org/docs/latest-v20.x/api/events.html

在下一节中,我们将使用 Node.js 创建我们的第一个 HTTP 服务器,这是在 Node.js 中进行 Web 应用程序开发时使用事件的最常见方式之一。

您的第一个 HTTP 服务器

现在我们已经知道了如何使用EventEmitter类,让我们构建一个简单的 HTTP 服务器。我们将使用http核心库创建服务器,并使用EventEmitter类来处理请求。在第九章中,我们将更详细地探讨如何构建 HTTP 服务器和客户端,但到目前为止,让我们专注于构建我们的第一个 HTTP 服务器。

让我们创建一个名为server.mjs的文件,并添加以下代码:

import { createServer } from 'node:http';
const port = 3000;
const server = createServer();
server.on('request', (request, res) => {
  res.writeHead(200, { 'Content-Type': 'text/html' });
  res.end('<h1>This is my first HTTP server in Node.js. Yay</h1>!');
});
server.listen(port, () => {
  console.log(`Server running at http://localhost:${port}/`);
});

在这个例子中,我们创建了一个http.Server类的实例,并为request事件注册了一个事件监听器。每当收到请求时,都会调用此事件监听器。然后,我们使用writeHead方法设置响应的状态码和内容类型。最后,我们使用end方法发送响应。如果您运行脚本,您将看到以下输出:

Server running at http://localhost:3000/

如果您在任何浏览器中打开此 URL,您将看到您的第一个 HTTP 服务器正在运行:

图 7.1 – 应用程序运行的截图

图 7.1 – 应用程序运行的截图

在下一节中,我们将学习如何在我们的模块中封装事件以及许多其他组件,以便轻松地发射和消费这些事件。这种技术相当流行,并且可以扩展到许多库。

为你的模块添加事件层

现在我们已经知道了如何使用EventEmitter类,让我们给我们的模块添加一个事件层。在这个例子中,我们将创建一个模块,它将被用来保存文件并在每次文件更改保存时触发一个事件。

让我们创建一个名为utils.mjs的文件,并添加以下代码:

import { writeFile } from 'node:fs/promises';
import { EventEmitter } from 'node:events';
const emitter = new EventEmitter();
const on = emitter.on.bind(emitter);
const save = async (location, data) => {
  await writeFile(location, data);
  emitter.emit('file:saved', { location, data });
};
export { save, on };

在这个例子中,我们创建了一个EventEmitter类的实例,并导出了save函数。这个函数将被用来保存文件并触发file:saved事件。然后,我们导出了EventEmitter类的on方法。这个方法将被用来为file:saved事件注册事件监听器。

重要信息

在这个例子中,我们使用了bind来检查this值的正确性。你可以在官方文档中找到更多关于它的信息:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bindbind的使用相当高级,所以你现在可以跳过它。

现在,让我们创建一个名为index.mjs的文件,并添加以下代码:

import { save, on } from './utils.mjs';
on('file:saved', ({ location, data }) => {
  console.log(`File saved at ${location}`);
});
console.log('Saving file...');
save('test.txt', 'Hello world!').catch('Error saving file');
console.log('The file is being saved but is not blocking the execution...');

如果你运行脚本,你将看到以下输出:

Saving file...
The file is being saved but is not blocking the execution...
File saved at test.txt

正如你所见,file:saved事件在save函数完成后被触发。这意味着save函数不会阻塞脚本的执行。在本书的前几个例子中,我们使用了then来处理 promise 的结果;在这种情况下,我们提供了一个替代方案,使用事件可以更容易地解耦应用程序的逻辑。

摘要

在本章中,我们学习了如何在 Node.js 中使用事件。我们学习了EventEmitter类以及如何使用它来发射和监听事件。我们还学习了如何使用事件来解耦我们应用程序的逻辑。

此外,我们还构建了一个脚本来监视我们系统中文件的更改,并且我们还构建了我们的第一个 HTTP 服务器,并学习了如何使用事件来处理请求。

最后,我们构建了一个简单的库,它导出一个事件层来解耦我们应用程序的逻辑。这将允许我们在未来的章节中构建更健壮的应用程序。

在下一章中,我们将学习如何将测试添加到我们的应用程序中。这将帮助我们构建更健壮的应用程序,避免错误,并且总体上,在学习 Node.js 的同时巩固我们对 Node.js 的知识。

进一步阅读

第八章:Node.js 中的测试

测试是目前最相关的实践之一;在过去的几十年里,它比过去更受欢迎。今天,我们构建的软件复杂,有许多依赖关系和要求,这些依赖关系和要求会随着时间的推移而演变。我坚信,当您学习一门新语言或工具时,测试是关键,因为它将为您提供一张安全网,让您可以承担更多风险,更快地移动而不会破坏之前的代码。

在本章中,我们将深入研究测试的重要性以及如何为您的应用程序选择正确的测试类型。您将编写您的第一个测试,然后我们将学习通过分组相关测试来创建测试套件,展示 Node.js 核心测试和 Jest 库。

编写良好的测试并不容易,但到本章结束时,您将清楚地了解每次应遵循的原则,以及您如何可以使用测试覆盖率工具随着时间的推移扩展和重构您的测试。

最后,我们将通过解决一些边缘情况来介绍测试驱动开发TDD)。

总结一下,以下是本章我们将探讨的主要主题:

  • 为什么测试很重要?

  • 测试方法和哲学

  • 我应该如何测试我的代码?

  • 编写我们的第一个测试套件

  • 掌握代码覆盖率工具

  • TDD 实践

技术要求

本章的代码文件可以在github.com/PacktPublishing/NodeJS-for-Beginners找到

查看本章的代码实践视频,请访问youtu.be/aK572sFboEM

为什么测试很重要?

如前几章所述,现代应用程序复杂,有许多动态部分,并且会有依赖关系。

总体来说,我们可以这样说,测试之所以重要,是因为它帮助我们确保我们的代码按预期工作,并且在添加新功能或修复错误时不会引入错误。

测试是一种复杂的文化

测试不仅仅是编写一些代码来验证您的应用程序。它是一种包含许多想法、原则、实践和工具的文化...您听说过 TDD 吗?BDD?单元测试?集成测试?端到端测试?模拟?存根?间谍?有许多概念需要学习和理解,我们将在本章中探讨其中的一些。

测试必须是一种团队活动

测试将帮助您轻松地将新开发者引入您的团队。即使您对代码库不深入了解,您也可以加入其他团队帮助他们构建新功能或修复错误。

我喜欢将测试视为应用程序的文档,或者更好的是,作为对世界的一种协议,说明您的应用程序在特定场景下应该如何表现。

但测试是团队的努力。编写测试不仅仅是开发者的责任,就像编写安全代码不仅仅是开发者的责任一样。整个团队都应该参与这个过程,并且团队应该有一个测试文化,并随着时间的推移坚持这种文化。

自动化你的测试是至关重要的。没有自动化,我们就需要手动测试我们的应用程序。这意味着我们将执行许多容易出错的重复性任务,并且我们需要花费大量时间来确保我们的应用程序按预期工作。

通过测试,我们可以自动化这个过程,并确保我们的应用程序按预期工作。我们可以在本地机器上运行测试,或者在合并拉取请求之前或在部署软件之前在远程机器上运行测试。我们可以在不同的环境中运行测试,并且可以并行运行它们以加快过程。

现在就利用这个机会

我习惯于在日常工作中进行测试,并且可以说,它们是帮助我构建更好软件的伟大工具。但总的来说,它们帮助我学习新事物并提高我的技能。

当你必须使用一种新的语言或工具时,你可以使用测试来学习它是如何工作的,并探索其功能。你可以通过测试来学习这个新事物的工作原理,并在学习过程中进行自己的实验。

但如果你不习惯进行测试,同时学习 Node.js 和测试可能会有些困难。所以,我建议你首先学习 Node.js,然后再更深入地研究测试。

在下一章中,我们将构建一个 Web 应用程序,并使用测试来确保我们的应用程序按预期工作。

在采用测试文化的过程中,你将面临许多挑战。就像任何文化变革一样,开始进行测试并不容易。这是一个你需要克服的挑战,你需要投入时间去学习如何进行测试。但我想说,这是值得的。

如果你与团队一起工作,你需要说服你的团队开始进行测试,并在一段时间内巩固这种文化。这并不容易,但这是可能的。

你可以在自己的代码中开始进行测试,并向你的团队展示其带来的好处。你可以从一个宠物项目或一个概念验证测试开始,并说服整个团队。

注意,你需要投入时间去学习如何进行测试,即使你知道如何进行测试,你也需要投入时间去编写测试。与仅编写代码所需的时间相比,这部分时间总是更长。但当你需要修复错误或添加新功能,以及在防止错误发生时,你将节省更多的时间。

既然我们已经明确了采用测试的动机,那么在下一节中,我们将学习关于我们应用程序可用的不同测试类型,以及其他行业如何使用不同的测试框架构建稳固的产品。

测试方法和哲学

当我们刚开始学习测试时,我们没有意识到的是,存在许多不同类型的测试,每种测试都有其不同的目的。

测试在世界上被广泛使用

在现实世界中,测试被用于许多行业。例如,如果我们想测试工厂制造的汽车的质量,我们可以做以下事情:

  • 在隔离状态下测试发动机

  • 在一个受控的环境中测试汽车以确保其按预期工作

  • 在真实环境中测试汽车以确保其按预期工作

  • 测试汽车的各个组件以确保特定的质量标准

  • 将汽车撞向墙壁或其他物体以确保其安全性

  • 汽车行业在工程测试框架方面拥有世界上最有趣的框架之一。今天制造的大多数汽车,对于绝大多数最终用户来说,都经过了许多方式的测试,包括碰撞模拟来评估潜在的损害。在下面的图中,你可以看到这些测试之一

图 8.1 – 图片来自维基百科 https://en.wikipedia.org/wiki/Crash_test#/media/File:Honda_Fit-Impact_Still.jpg

图 8.1 – 图片来自维基百科 en.wikipedia.org/wiki/Crash_test#/media/File:Honda_Fit-Impact_Still.jpg

软件产品也是如此。为了给你一个概念,我们可以单独测试网络应用程序组件,或者我们可以从最终用户的角度测试整个应用程序。还有选择只通过向应用程序发送大量不同结构化的请求来测试我们应用程序的性能,并检测任何瓶颈和低效之处。我们甚至可以通过进行渗透测试和尝试破解我们的应用程序来测试我们应用程序的安全性。

测试金字塔

因此,我们可以这样说,存在许多不同类型的测试,每种测试都有其不同的目的。让我们来看看测试金字塔:

图 8.2 – 图片来自马丁·福勒的《实用的测试金字塔》 https://martinfowler.com/articles/practical-test-pyramid.html

图 8.2 – 图片来自马丁·福勒的《实用的测试金字塔》 martinfowler.com/articles/practical-test-pyramid.html

如我们所见,金字塔的底部是单元测试,它们更加隔离且运行速度更快。在金字塔的顶部,我们有 UI 测试,它们更昂贵(因为它们需要更多的集成)且运行速度较慢。

以汽车为例,我们可以这样说,单元测试就像是在隔离状态下测试发动机,而 UI 测试则像是在真实环境中测试汽车。

我们可以很容易地理解,在隔离状态下测试发动机比在真实环境中测试汽车更快、更便宜,因为我们不需要构建整个汽车来测试发动机,也不需要准备文件,与保险公司协调,或者增加更多人员,如驾驶员和机械师。此外,在真实环境中测试汽车可能会导致外部因素(如天气、交通和道路状况)的影响。

与此相比,我们只需在工厂内使用工具和必要的人员,独立构建发动机并对其进行测试。我们可以更快、更便宜地完成这项工作,但这次测试将无法检测到我们在真实环境中测试汽车时可能遇到的一些问题。

因此,同样可以说,单元测试的运行速度和成本比 UI 测试低,但 UI 测试将能够检测到我们无法通过单元测试检测到的一些问题。

在本章中,我们将重点关注单元测试,但在接下来的章节中,当我们构建 Web 应用程序时,我们还将探讨其他类型的测试。

既然我们已经明确了不同类型的测试,那么现在是时候务实一些,探索如何在下一节中利用不同的库来构建我们的第一个测试用例。

我应该如何测试我的代码?

由于 JavaScript 的历史,大多数开发者在 JavaScript 仅限于浏览器并且几乎仅用于构建相对简单的脚本时,并没有测试代码的文化。

然而,随着语言和社区的演变,我们现在有很多工具和框架来帮助我们构建测试。

框架和库

当你对如何测试你的代码有一个清晰的想法时,你可以轻松地从一种工具迁移到另一种工具,直到找到最适合你需求的那一个。

在本章中,我们将探讨有前景的 Node.js 测试核心库以及最受欢迎的 Web 开发测试框架 Jest。

重要提示

我们正处于一个过渡时期,Node.js 核心库正在演变,以向开发者提供更好的体验。因此,在未来,它可能将成为默认的工具。但就目前而言,如果你是测试的新手,我建议你使用 Jest,因为有很多教程和博客文章,API 也更加稳定。

我们的第一测试

让我们看看一个简单的测试,然后我们将探索它的不同部分:

import { describe, it } from 'node:test';
import assert from 'node:assert';
const sum = (a, b) => a + b;
describe('Utils Test Suite', () => {
  it('Should sum two numbers', () => {
    assert.strictEqual(sum(1, 2), 3);
  });
});

在这个例子中,我们正在测试求和函数。首先,我们从node:test模块中导入describeit函数。然后,我们从node:assert模块中导入assert函数。

describe函数用于分组测试。在这种情况下,我们正在将所有与utils模块相关的测试分组在一起。

it函数用于定义一个测试。在这种情况下,我们定义了一个应该求和两个数字的测试。

最后,我们使用assert函数来检查sum函数的结果是否符合预期。

因此,我们可以这样说,一个测试由三个部分组成:

  • 安排:定义我们运行测试所需的数据。

  • 行动:运行我们想要测试的代码。

  • 断言:检查代码的结果是否是预期的。

测试原则和目标

当我们构建测试时,我们应该遵循一些原则。对我来说,这些原则可以总结为三个目标,即快速、可信和可维护。

快速

测试应该运行和编写速度快。我们将多次运行这个测试,所以如果测试运行缓慢,我们将浪费大量时间等待它完成。但最糟糕的部分是,我们可能会倾向于减少测试的运行频率或编写更少的测试。

然后,测试将让你和你的团队感到沮丧。在小项目中,你可能只有几十个测试,但在大项目中,你可能会有成千上万的测试。

重要提示

如果你有一个平均运行时间为 1 秒的测试,每次运行测试你都会失去 1 秒。如果你有 1,000 个测试,你将失去 1,000 秒,这超过 16 分钟!在大多数情况下,你将有机会使用并发来并行运行测试,因此总时间将大大降低。但这需要额外的步骤来设置(nodejs.org/api/test.html#runoptions)。

在大项目中,你需要投入时间和资源来重构和改进你的测试,以保持它们的快速。

可信

测试应该是可信的。如果你有不可靠的测试(即随机失败的测试),你的团队将非常沮丧,并失去对测试的信心。

为了避免这种情况,我们应该遵循以下原则:

  • 隔离:我们需要将测试与外部因素(如网络、文件系统、数据库和时间)隔离。

  • 可重复性和确定性:我们需要能够多次运行测试并获得相同的结果。

  • 自包含和独立:我们需要能够独立运行任何测试并获得相同的结果。

可维护性

测试也是代码,因此我们需要以与维护我们的生产代码相同的方式维护它们。我们应该遵循以下原则:

  • 可读性和明确性:测试应该易于阅读和理解。它们必须具有灵活性,以便与我们要测试的代码一起进化。

  • 专注性:单个测试应该测试单一事物。如果我们有一个测试在测试多个事物,我们将无法确切知道什么失败了。

  • 小而简单:拥有许多小而简单的测试比拥有少数大而复杂的测试要好。

恭喜!你已经编写了第一个测试,现在应该对机制更清晰了。在下一节中,我们将学习如何构建一个完整的测试套件来覆盖多个案例,以及如何自动化一些步骤。

编写我们的第一个测试套件

在本节中,我们将构建我们的第一个测试套件。我们将为上一章中创建和发布的utils模块构建一个测试套件。

我们将使用 node:testnode:assert 模块来构建我们的测试套件,然后我们将使用 Jest 框架构建相同的测试,这样我们就可以比较两种方法并看到差异。

工具模块

让我们先创建一个新的文件夹,然后使用 npm init 初始化一个新的 Node.js 项目。然后我们将创建一个 utils.js 文件,其中包含以下代码:

export const sum = (a, b) => a + b
export const multiply = (a, b) => a * b

代码非常简单。sum 函数会将两个数字相加,而 multiply 函数会将两个数字相乘。因此,测试也应该非常简单。

基本上,我们需要测试 sum 函数是否将两个数字相加,以及 multiply 函数是否将两个数字相乘。

测试核心库

最近,Node.js 引入了一个新的核心库来帮助我们构建测试。这个库叫做 assert,它是一个核心库,所以我们不需要安装它。我们只需导入并使用它即可。

添加 npm 脚本

让我们在 package.json 文件中添加以下 NPM 脚本,并添加 type:"module"

{
  "type": "module",
  "scripts": {
    "node-test": "node --test node_test/"
  }
}

在这种情况下,我们选择了 type: "module" 以默认启用 ESM 语法,这样我们就可以在文件中直接使用 import 关键字。你可以在 第六章**. 中找到有关如何导入模块的更多信息。

添加测试套件

让我们创建一个新的文件夹,node_test,并在其中包含一个名为 utils.test.js 的新文件,其中包含以下代码:

import { describe, it } from "node:test";
import assert from "node:assert";
import { sum, multiply } from "../utils.js";
describe("Utils Test Suite: sum", () => {
  it("Should sum two numbers", () => {
    assert.strictEqual(sum(1, 2), 3);
  });
});
describe("Utils Test Suite: multiply", () => {
  it("Should multiply two numbers", () => {
    assert.strictEqual(multiply(5, 3), 15);
  });
});

重要的是要注意,我们正在使用 ../ 来从当前文件引用父目录。这样,我们可以从计算机上的任何位置导入文件。也可以使用到特定资源的绝对路径。你可以在 www.redhat.com/sysadmin/linux-path-absolute-relative 找到更多关于这些差异的详细信息。

运行测试

现在,我们可以使用以下命令来运行测试:

npm run node-test

我们应该看到以下输出:

图 8.3 – 终端输出

图 8.3 – 终端输出

注意,终端使用不同的颜色来显示测试结果。在这种情况下,我们有两个测试,并且两个都通过了。正如你所看到的,输出非常简单,并且使用了我们在 describeit 函数中定义的文本。

使用 Jest 库

Jest 是一个在 JavaScript 社区中非常流行的 JavaScript 测试框架。它非常容易使用,并且拥有许多功能,可以帮助我们构建和维护测试套件,尤其是如果你正在使用现代框架作为库进行前端开发,例如 Angular、React 或 Vue。

安装 Jest

第一步是在我们的项目中将 Jest 安装为开发依赖。我们可以使用以下命令来完成:

npm install --save-dev jest@29

配置 Jest

由于我们之前已经为 Node.js 核心库设置了测试,因此我们需要为 Jest 使用自定义配置。在实际项目中,我们将只使用一个测试框架,在这种情况下,我们可以使用 npx jest@29 --init 来配置 Jest。

npx命令将执行我们在项目中安装的Jest命令。--init标志将为我们创建一个配置文件。

我们将创建一个新文件,jest.config.js,内容如下:

export default {
    modulePathIgnorePatterns: ['<rootDir>/node_test/' ]
}

modulePathIgnorePatterns将忽略node_test文件夹,因此我们可以忽略我们使用 Node.js 核心库创建的测试。<rootDir>是指jest.confg.js所在的文件夹,因此更容易引用其他资源。

由于 Jest 目前不支持 ESM 模块,我们将使用 Babel (babeljs.io/)来转译代码。我们将创建一个新文件,.babelrc,内容如下:

{
  "presets": ["@babel/preset-env"]
}

我们将安装以下依赖项:

npm i -D @babel/preset-env@7

添加 npm 脚本

让我们在package.json文件中添加以下 npm 脚本:

{
  "scripts": {
    "node-test": "node --test node_test/",
    "jest-test": "jest"
  }
}

添加测试套件

让我们创建一个新文件夹,jest_test,并在其中包含一个名为utils.test.js的新文件,内容如下:

import { sum, multiply } from "../utils.js";
describe("Utils Test Suite: sum", () => {
  it("Should sum two numbers", () => {
    expect(sum(1, 2)).toBe(3);
  });
});
describe("Utils Test Suite: multiply", () => {
  it("Should multiply two numbers", () => {
    expect(multiply(5, 3)).toBe(15);
  });
});

如您所见,代码与我们为 Node.js 核心库创建的代码非常相似。唯一的区别在于我们如何管理断言。

重要提示

注意,我们也没有导入describeit函数。这是因为 Jest 为我们提供了这些函数,我们不需要导入它们。

运行测试

现在,我们可以使用以下命令运行测试:npm run jest-test

我们应该看到以下输出:

图 8.4 – 终端输出

图 8.4 – 终端输出

如您所见,输出与我们在 Node.js 核心库中看到的输出非常相似。唯一的区别是输出使用了不同的颜色,文本略有不同。但最重要的是,我们得到了相同的信息。

现在,我们对我们的测试非常有信心,但随着源代码每天都在增长,你需要一个额外的工具来帮助你了解哪些代码已被测试覆盖或未被覆盖。因此,在下一节中,我们将详细学习如何使用测试覆盖率生成报告,这将帮助我们改进项目中的测试。

掌握代码覆盖率工具

当我们构建测试套件时,我们需要确保我们覆盖了所有对我们目的有意义的场景中的关键代码。这被称为代码覆盖率,它是衡量我们测试套件质量的一个重要指标。

有些人说我们需要达到 100%的代码覆盖率,但这并不总是真实或实用的。在我看来,代码覆盖率是一个帮助我们检测测试未覆盖的代码或过度测试的代码的指标。

总的来说,这是一个可以帮助我们浏览代码并检测需要添加或删除的潜在测试的指标。

配置

从历史上看,代码覆盖率是一个由第三方库(如 Istanbul istanbul.js.org/)提供的功能。但现在,Node.js 和 Jest 都自带了这个功能,所以我们不需要安装任何第三方库。

Jest 库

让我们在 package.json 文件中添加以下 npm 脚本:

{
  "scripts": {
    "node-test": "node --test node_test/",
    "jest-test": "jest",
    "jest-test:coverage": "jest --coverage"
  }
}

Node.js

Node.js 有一个实验性功能,我们可以用它来生成代码覆盖率。我们需要使用 --experimental-test-coverage 标志来启用此功能:

{
  "scripts": {
    "node-test": "node --test node_test/",
    "jest-test": "jest",
    "jest-test:coverage": "jest --coverage",
    "node-test:coverage": "node --test --experimental-test-coverage node_test/"
  }
}

运行测试

让我们在 utils.js 文件中添加一个新的函数,substract

export const sum = (a, b) => a + b
export const multiply = (a, b) => a * b
export const subtract  = (a, b) => a - b

现在,让我们运行 Node.js 和 Jest 的代码覆盖率以查看结果。

Node.js 报告

默认情况下,Node.js 将生成一个包含结果的 coverage 文件夹。我们可以在浏览器中打开 index.html 文件来查看结果:

npm run node-test:coverage

输出应类似于以下图示:

图 8.5 – 终端输出

图 8.5 – 终端输出

如您所见,我们的函数代码覆盖率是 66.67%,因为我们没有对 subtract 函数进行任何覆盖率测试。

Jest 报告

使用 Jest 运行代码覆盖率与运行测试非常相似:

npm run jest-test:coverage

输出应类似于以下图示:

图 8.6 – 终端输出

图 8.6 – 终端输出

如您所见,我们的代码覆盖率与 Node.js 相同。这是因为这两个工具以相同的方式用于计算代码覆盖率。

覆盖率 UI 报告

在这两种情况下,我们都生成了一个包含结果的 coverage 文件夹。我们可以在浏览器中打开位于 coverage/lcov-report 中的 index.html 文件来查看结果。

图 8.7 网络浏览器报告

图 8.7 网络浏览器报告

我们可以详细探索 utils.js 中哪些被覆盖了,哪些没有被覆盖。

图 8.8 – 网络浏览器报告

图 8.8 – 网络浏览器报告

如您所见,subtract 函数没有被我们的测试覆盖。因此,这里我们有改进测试的机会。

代码覆盖率报告是理解你的测试的一个很好的方式,尤其是当你与大型代码库一起工作时。所以我鼓励你尽可能多地使用它。

在下一节中,我们将改变方法。我们将学习在编写代码之前定义测试时我们获得的额外价值。虽然这可能听起来很复杂,但它将极大地帮助你明确你接下来需要构建什么以及如何以可以测试的方式进行构建。当你遵循这种方法时,你会对能节省多少时间感到惊讶。这被称为测试驱动开发。

测试驱动开发实践

在我们的 utils 模块中,有一些边缘情况我们没有涵盖。例如,如果我们向 sum 函数传递一个字符串会发生什么?

import { sum } from "../utils.js";
const result = sum("1", 2); // 12

当我们使用 sum 函数时,这不是预期的行为,因此我们需要修复它。

让我们在 jest-tests/utils.test.js 文件中添加一些测试来覆盖这些边缘情况:

describe("Utils Test Suite: sum", () => {
  it("Should sum two numbers", () => {
    expect(sum(1, 2)).toBe(3);
  });
  it("Should throw an error if we don't provide a valid number", () => {
    expect(() => sum("1", 2)).toThrow("Please provide a valid number");
  });
});

正如你所见,我们正在使用toThrow匹配器来测试函数是否抛出错误。现在,让我们使用npm run jest-test运行测试覆盖率。

图 8.9 – 终端输出

图 8.9 – 终端输出

我们的新测试失败是因为我们的代码没有满足我们的要求,所以让我们在utils.js中做一些修改:

export const sum = (a, b) => {
    if(typeof(a) !== 'number' || typeof(b) !== 'number') {
        throw new Error('Please provide a valid number')
    }
    return a + b
}
export const multiply = (a, b) => a * b
export const subtract  = (a, b) => a - b

现在,让我们再次运行测试。

图 8.10 – 终端输出

图 8.10 – 终端输出

我们的测试再次通过,所以我们可以说我们的代码按预期工作。首先编写测试然后编写代码以使测试通过的这个交互式过程被称为测试驱动开发,或 TDD。

虽然 TDD 是一个很大的话题,但我们可以把这个简单的例子作为一个探索性介绍,来了解 TDD 的好处,而不必严格遵循它。例如,我们可以测试边缘情况,然后使用它们来改进我们的代码。

我个人认为 TDD 在 Node.js 中是一个很好的方法,因为它帮助我把复杂任务分解成具有自己明确定义和测试功能的小块。虽然这可能对更资深开发者来说很显然,但由于 JavaScript 的特性,很容易构建过度工程化的解决方案。测试在这方面会极大地帮助我们。

此外,当你独立工作时,测试可以是一个伟大的盟友,例如,当你需要为 Web 应用程序构建 HTTP API,但前端团队不计划在 API 准备好之前开始时。所以,测试是验证前端团队实现的一个很好的方式。此外,测试也是让新成员加入团队的一个很好的方式,因为他们可以通过运行和阅读测试来轻松地了解应用程序中预期会发生什么。

对于更复杂的情况,这也是调试应用程序和重现客户或团队成员报告的 bug 的一个很好的方式。总的来说,我认为投资回报率非常高,尤其是在像 JavaScript 这样的动态语言中。

摘要

在本章中,我们学习了测试原则以及我们如何结合不同类型的测试来构建一个健壮的测试套件。我们还探讨了测试金字塔如何帮助我们构建一个易于维护和理解的测试套件。

此外,我们讨论了单元测试和集成测试之间的区别,以及我们如何使用它们来测试我们的代码。我们还探讨了在团队中推广测试的策略。

之后,我们探讨了如何使用 Node.js 核心模块和 Jest 给我们的代码添加单元测试。

最后,我们学习了如何使用代码覆盖率作为一个交互式工具来帮助我们精炼我们的测试并保持我们的代码库健壮。然后,我们使用 TDD 进行了一些练习,以修复我们库中的一个 bug。

在下一章中,我们将详细探讨 HTTP 协议的工作原理以及我们如何使用 Node.js 构建 RESTful API。

进一步阅读

第三部分:Web 应用程序基础

第三部分中,你将学习如何通过使用大多数公司采用的现代模式和技巧来构建 Web 应用程序。你还将学习如何构建稳固的 RESTful API。

本部分包括以下章节:

  • 第九章,处理 HTTP 和 REST API

  • 第十章,使用 Express 构建 Web 应用程序

第九章:处理 HTTP 和 REST API

在本章中,我们将从历史的角度和我们对互联网基础设施的实际理解来学习互联网。

我们将深入探讨那些使得创建网络项目成为可能的协议和架构,并会研究那些构成当前网络浏览体验基础的网络请求规范(RFCs)。

我们将掌握围绕 HTTP、URLs 和 REST API 的所有组件和理论概念。

总结一下,以下是本章我们将探讨的主要主题:

  • 互联网的历史以及互联网基础设施是如何工作的

  • 请求评论(RFCs)是什么以及如何使用它们

  • 服务器和客户端之间的 HTTP 通信(单页应用(SPA)与服务器端渲染)

  • 掌握 HTTP(头部、状态码、有效载荷、动词等)

  • 使用工具调试 HTTP 请求

  • REST API 的结构

  • JSON 规范的工作原理

  • 现代网络内部的工作原理

到本章结束时,你将对构成当前互联网的所有组成部分有一个清晰的认识,以及如何通过应用本章学到的知识来构建你的网络项目。

技术要求

本章的代码文件可以在github.com/PacktPublishing/NodeJS-for-Beginners找到。

查看本章的代码演示视频,请访问youtu.be/GleRpaaR2PQ

互联网内部的工作原理

我们每天都在使用互联网,但我们真的知道它是如何工作的吗?维基百科将互联网定义为如下:

“互联网(或互联网)是由使用互联网协议套件(TCP/IP)进行网络和设备间通信的全球互联计算机网络组成。它是一个由私有、公共、学术、商业和政府网络组成的网络,这些网络覆盖了从本地到全球的范围,并通过广泛的电子、无线和光网络技术相互连接。互联网承载着广泛的信息资源和服务,如万维网(WWW)的相互链接的超文本文档和应用、电子邮件、电话和文件共享。”

基本上,互联网是一个全球系统,通过网络将计算机连接起来,并采用某些协议和技术以弹性的方式实现通信。互联网被各种实体和个人用于通过电子邮件、文件共享等工具共享信息资源和服务。

但说实话,这个定义只是触及了表面。要了解互联网是如何工作的,我们需要回顾过去,了解它是如何开始的。

互联网的历史

我们今天所知道的互联网不是由单一个人或特定群体创造的。互联网是许多人为创造不同技术和思想的工作成果,随着时间的推移,这些技术和思想逐渐形成了我们今天所知道的现代互联网。

当我们试图从工程角度理解互联网的工作原理时,有两个主要概念需要我们牢记。这些概念如下:

  • 信息访问:当计算机世界还处于主机时代时,用户终端必须连接到主机。远程访问的想法开始兴起。随着时间的推移,人类发现如果我们把计算机连接起来,我们可以在它们之间共享信息和资源。基本上,我们可以分割和分配信息和计算机资源。我们可以比人类历史上任何时候都更快地与其他人建立联系并分享信息。

  • 弹性:在 20 世纪 60 年代,美国政府担心可能发生核攻击,这可能会摧毁通信基础设施。这种担忧是分布式网络想法的种子,这个网络没有单一故障点,可以抵御核攻击,这也是为什么互联网经常被称为网络之网的原因。

自 20 世纪以来,为了使互联网成为现实,还需要发生许多更多的事情,但这两个概念在互联网的架构中仍然非常强大。

重要信息

有一个非常好的视频以非常简单的方式解释了互联网的历史。你可以在这里观看:www.youtube.com/watch?v=9hIQjrMHTv4

互联网基础设施

只为了给你一个我们所有人都对互联网的依赖性的概念,有数百条光纤电缆横跨海洋和海域,使互联网连接成为可能。以下是连接世界的海底电缆地图:

图 9.1 – 我们的世界通过横跨海洋的数十条光纤电缆连接在一起。截图来自(https://www.submarinecablemap.com/) CC BY-SA 4.0.

图 9.1 – 我们的世界通过横跨海洋的数十条光纤电缆连接在一起。截图来自(www.submarinecablemap.com/) CC BY-SA 4.0。

光纤电缆并不是连接互联网的唯一方式。其他方式包括卫星和无线电波。多年来,由于电信领域的无尽研究和创新,互联网的速度提高了,连接的成本降低了。

请求评论 (RFC)

在本章中,我们将重点介绍我们需要熟悉以使我们的应用程序正常工作的规范、协议和标准。

如果你第一次探索这样的异国话题,你可能会因为需要消化的信息量而感到不知所措。但别担心,我们将以非常简单和实用的方式探讨所有这些话题。

互联网工程任务组(IETF)将请求评论(RFC)定义为如下 (www.ietf.org/standards/rfcs/):

“RFC 文档包含互联网的技术规范和组织注释。”

基本上,RFC 是一种描述将要作为互联网架构一部分设计的某种规范/协议/标准的文档。任何人都可以向 IETF 提交一个 RFC,如果该 RFC 被批准,它就变成了一个标准。虽然这听起来很简单,但这个过程可能需要很长时间,因为精炼和审查过程是详尽的。

这里是从 RFC 2616 (www.rfc-editor.org/rfc/rfc2616.txt) 中提取的一个简单示例,该文档在 175 页中描述了超文本传输协议 – HTTP/1.1

Network Working Group                       R. Fielding
Request for Comments: 2616
  UC Irvine
Obsoletes: 2068
  J. Gettys
Category: Standards Track                    Compaq/W3C                                                  J. Mogul
                                                 Compaq
                                             H. Frystyk
                                                W3C/MIT
                                            L. Masinter
                                                  Xerox
                                               P. Leach
                                              Microsoft
                                         T. Berners-Lee
                                                W3C/MIT
                                              June 1999
                Hypertext Transfer Protocol -- HTTP/1.1
Status of this Memo
   This document specifies an Internet standards track protocol for the
   Internet community, and requests discussion and suggestions for
   improvements.  Please refer to the current edition of the "Internet
   Official Protocol Standards" (STD 1) for the standardization state
   and status of this protocol.  Distribution of this memo is unlimited.
Copyright Notice
   Copyright (C) The Internet Society (1999).  All Rights Reserved.
Abstract
   The Hypertext Transfer Protocol (HTTP) is an application-level
   protocol for distributed, collaborative, hypermedia information
   systems. It is a generic, stateless, protocol which can be used for
   many tasks beyond its use for hypertext, such as name servers and
   distributed object management systems, through extension of its
   request methods, error codes and headers [47]. A feature of HTTP is
   the typing and negotiation of data representation, allowing systems
   to be built independently of the data being transferred.
   HTTP has been in use by the World-Wide Web global information
   initiative since 1990\. This specification defines the protocol
   referred to as "HTTP/1.1", and is an update to RFC 2068 [33].

是的,我知道……这不是一篇容易阅读的文章。我不期望你阅读整个 RFC,但我们将以实用的方式探讨其中的一些部分。

RFC 最好的方面是它们是免费的,你可以在线阅读。当你需要时,你可以找到大量有助于你理解互联网架构特定部分的高质量信息。

其他 RFC

为了消除那种令人不知所措的感觉,我想与你分享一些更易于阅读的其他 RFC:

而我最喜欢的一个:

这些有趣的 RFC 可以让你熟悉 RFC 讨论格式的力量。基本上,如果你想创建一个新的协议,你可以向 IETF 提交一个 RFC,如果该 RFC 被批准,它就变成了一个标准。你可以在这里了解更多关于 RFC 流程的信息:www.rfc-editor.org/about/independent/

作为一名网络开发者,你需要了解的最重要协议之一是 HTTP。在下一节中,我们将详细探讨这个协议,并学习它涉及的不同的架构和组件,这些组件现在是互联网的骨干,正如我们所知。

HTTP – 服务器和客户端关系

虽然网络开发可能是一个非常复杂的话题,但我们可以通过理解典型网络应用中服务器和客户端之间的关系来简化它。

我们有两个主要角色,服务器和客户端:

  • 服务器:服务器是运行应用、处理数据库查询以及许多其他事务的计算机。这个服务器通常被称为后端。

  • 客户端:在 Web 应用的情况下,客户端是最终用户在本地机器上执行的软件。用户使用网络浏览器来执行软件(HTML、CSS、JS 等)。客户端通常被称为前端。

服务器和客户端之间的通信是通过 HTTP 完成的。客户端向服务器发送请求,服务器回复响应。这是典型的请求/响应周期

请求和响应

请求和响应是 HTTP 的两个主要部分。请求由客户端发送到服务器,服务器返回响应。请求和响应由以下章节中将要探讨的不同部分组成。

图 9.2 – 服务器、互联网和多个客户端之间的关系

图 9.2 – 服务器、互联网和多个客户端之间的关系

如前图所示,一个服务器可以同时处理多个客户端。这是 Web 应用的典型架构。服务器处理来自客户端的请求,并返回相应的响应。

但非常常见的情况是,一个客户端会向单个服务器或多个服务器发送多个请求。让我们看看以下 HTML 片段:

<head>
<link rel="stylesheet" type="text/css" href="https://server1.com/style.css">
</head>
<body>
<img src="img/image.png">
<script type="text/javascript" src="img/script.js"></script>
</body>

如我们所见,客户端向三个不同的服务器(server1.comserver2.comserver3.com)发送三个请求,请求特定的资源。每个服务器最终都会响应并返回所需的资源。

作为简单的例子,让我们访问packtpub.com并在我们的浏览器中打开开发者工具。在网络标签页中,我们可以看到浏览器向服务器发送的所有请求以及服务器的响应:

图 9.3 – 网络浏览器截图

图 9.3 – 网络浏览器截图

如果你注意看图 9**.3的底部,你可以很容易地看到这个页面正在向不同的服务器发送超过 60 个请求以渲染页面。这在 Web 应用中是一个非常常见的场景:客户端向服务器发送多个请求以获取关键资源,包括 favicon、CSS 文件、JS 文件、图片、视频和原始数据。如果我们查看表格,我们可以看到加载的每个项目,并且可以调试和探索所发出的每个请求。这可能在开始时有些令人畏惧,但一旦你了解了过滤器的工作原理,并且花了一些时间与之工作,你会感到更加自在。

服务器端渲染

在最初,Web 应用非常简单,JavaScript 的使用非常有限。Web 应用在服务器上渲染,客户端只接收 HTML、CSS 和 JS 文件。这被称为服务器端渲染,并且在许多应用中仍然在使用。

虽然这个模型今天仍在使用,但它有一些明显的缺点。每次用户想要与应用程序交互时,服务器都需要重新渲染页面并发送给客户端。这产生了大量的流量,用户体验也不是最佳,因为在刷新之间,网站会出现空白的情况。

图 9.4 – 服务器端渲染方法中服务器和客户端之间的关系

图 9.4 – 服务器端渲染方法中服务器和客户端之间的关系

这种模式在智能手机的早期阶段尤其糟糕,当时移动设备没有足够强大的功能来渲染页面,而且连接也不是很好。用户体验非常糟糕。解决方案是将渲染移动到客户端,这被称为客户端渲染。

单页应用程序(SPAs)

在客户端渲染中,服务器将初始的 HTML、CSS 和 JS 文件发送到客户端。然后,JavaScript 接管应用程序并将在客户端渲染视图。因此,服务器只向客户端发送数据,客户端渲染页面。这被称为单页应用程序(SPA),它是今天最常用的模式。

图 9.5 – AJAX 方法中服务器和客户端之间的关系

图 9.5 – AJAX 方法中服务器和客户端之间的关系

起初,这种模式实现起来非常复杂,但随着 JavaScript 框架的发展,这种模式变得非常流行。今天,我们有很多框架可以帮助我们轻松构建 SPA。其中一些最受欢迎的框架是 Angular、React 和 Vue.js。SPA 模式使用相同的 HTTP,但通过异步 JavaScript 和 XML(AJAX)请求以不同的方式使用。

这种新技术在构建后端应用程序的方式上引入了许多变化和创新。后端应用程序更像是一个应用程序编程接口(API),它向客户端响应数据,而不仅仅是典型的网络客户端,现在服务器也可以使用这个 API 相互交换信息。

现在我们已经对组件和可能的网络架构有了清晰的认识,是时候深入探讨 HTTP 了,这样我们就可以使用标准化的服务器和客户端之间的通信来构建稳固的项目。

掌握 HTTP

现在我们对 HTTP 概念有了更好的理解,让我们看看构建网络应用需要了解的 HTTP 的不同部分。

我们已经看到了请求和响应,但让我们更深入地看看构成请求和响应的不同部分(头信息、有效载荷、版本和方法)。

HTTP 头信息

每个请求和响应都有一个头信息集合。这些是键值对,提供了关于请求或响应的额外信息。

虽然请求头和响应头看起来可能相似,但它们并不相同,尽管它们确实共享一些常见的键值对。

请求头

我们将首先在 图 9.6 中分析请求头信息包含的内容:

图 9.6 – 由 Mozilla 贡献者提供的归属和版权许可,根据 CC-BY-SA 2.5 许可

图 9.6 – 由 Mozilla 贡献者提供的归属和版权许可,根据 CC-BY-SA 2.5 许可

让我们将不同的头属性分组:

  • 表示头信息:content-typecontent-length

  • 通用头信息:keep-aliveupgrade-insecure-requests

  • 请求头:acceptaccept-encodingaccept-languagehostuser-agent

只需查看头信息,我们就可以了解许多关于请求的信息,例如客户端期望的内容类型、语言和使用的浏览器。服务器可以使用这些信息来向客户端提供更好的响应。

重要信息

这只是可能头信息列表中的一小部分。还有很多其他的头信息,我们可以使用它们来提供更多关于请求或响应的信息。我们甚至可以创建自己的键值对。您可以在以下链接找到 HTTP 头信息的列表:developer.mozilla.org/en-US/docs/Web/HTTP/Headers

响应头

我们将以分析 图 9.7 中响应头信息的内容作为结束。

图 9.7 – 由 Mozilla 贡献者提供的归属和版权许可,根据 CC-BY-SA 2.5 许可

图 9.7 – 由 Mozilla 贡献者提供的归属和版权许可,根据 CC-BY-SA 2.5 许可

响应头与请求头非常相似,但它们并不相同。它们可以被如下分组:

  • 表示头信息:content-typecontent-encodinglast-modified

  • 通用头信息:connectiondatekeep-alivetransfer-encoding

  • 响应头:access-control-allow-originetagserverset-cookievaryx-frame-options

通过响应头,我们还可以提供有助于浏览器和 Web 应用程序正确处理和显示信息的额外信息。

响应头对于应用程序的安全性非常重要,因为有许多头信息可以在浏览器环境中防止某些攻击。例如,我们可以使用 x-frame-options 来防止应用程序在 iframe 中加载,或者使用 feature-policy 来防止应用程序使用摄像头或麦克风等特性。我们将在 第十五章 中探讨这一点。

状态码

总体而言,在响应中我们可以找到的最重要信息之一是状态码。

状态码使我们能够了解请求是否成功,甚至可以提供更细粒度的反馈。我们可以将状态码分为以下几组:

  • 1xx: 信息性

  • 2xx: 成功

  • 3xx: 重定向

  • 4xx: 客户端错误

  • 5xx: 服务器错误

最常见的状态码是200 OK201 Created301 Moved Permanently400 Bad Request401 Unauthorized403 Forbidden404 Not Found429 Too Many Requests500 Internal Server Error503 Service Unavailable。你可以在这里找到状态码的完整列表:developer.mozilla.org/en-US/docs/Web/HTTP/Status

正如你所看到的,如果你知道给定的状态码,你就可以理解你的请求发生了什么。例如,当客户端在给定时间内发送了过多的请求时(“速率限制”),就会发生429错误代码,但如果你收到401,那么错误与你的身份验证有关。最后,如果在同一场景下你收到403,你已正确认证,但你没有足够的权限执行给定的操作,例如删除另一个用户账户。

我们都经历过404错误代码,当我们尝试访问不存在的资源时,这种情况非常常见。例如,如果我们尝试访问以下 URL,https://www.google.com/invented-resource,我们将收到404错误代码。

418 我是一把茶壶

互联网上有一个强大的文化,就是构建花哨的 404 页面。你可以在网上找到很多例子,但很少有人知道有一个特殊的错误代码418,RFC 2324 (tools.ietf.org/html/rfc2324)将其描述如下:

“任何试图用茶壶煮咖啡的行为都应该导致错误代码“418 我是一把茶壶”。结果实体体可能又短又结实。”

虽然这看起来可能只是一个玩笑,但实际上它得到了许多实体的支持,包括 Node (github.com/nodejs/node/issues/14644) 和 Google。

图 9.8 – google.com/teapot 的网页浏览器截图

图 9.8 – google.com/teapot 的网页浏览器截图

正如 Shane Brunswick 在 Save 418 运动网站上所说(save418.com/):

“这是一个提醒,计算机的底层过程仍然是由人类制造的。看到 418 号状态码消失将是一件非常遗憾的事情。”

我确实同意他的观点:在这些复杂的系统背后有人类,我们不应该忘记这一点,就像我们不应该忘记互联网不可能没有开源运动和黑客文化而存在一样。

请求方法

正如状态码对于理解响应非常重要一样,请求方法对于理解请求也是必不可少的。

请求方法有很多,但最常见的是以下几种:GETPOSTPUTPATCHDELETE。您可以在以下位置找到请求方法的完整列表:developer.mozilla.org/en-US/docs/Web/HTTP/Methods

我们后端开发者使用它们的方式可能略有不同,但最常见的方式如下:

  • GET: 获取资源

  • POST: 创建资源

  • PUT: 更新资源

  • PATCH: 部分更新资源

  • DELETE: 删除资源

当我们创建一个包含所有端点的实际 REST API 时,我们将在*第十一章**中详细探讨它们。

在互联网的早期,我们使用表单将数据发送到服务器,并在表单中指定了给定方法。例如,看以下内容:

<form action="/user" method="POST">
  <input type="text" name="username" />
  <input type="password" name="password" />
  <input type="submit" value="Submit" />
</form>

以下代码是向服务器发送数据以创建新用户的常见方式,但如今我们使用 JavaScript 将数据发送到服务器。例如,我们可以使用fetch API 将数据发送到服务器,如下所示:

fetch('/user', {
  method: 'POST',
  headers: {
   'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    username: 'john',
    password: '1234'
  })
})

然后我们将使用响应来通知用户请求是否成功。虽然使用 JavaScript 将数据发送到服务器更为复杂,但它为我们提供了更多的灵活性和对请求的控制。

重要提示

当你在浏览器中输入 URL 时,浏览器会向服务器发送一个GET请求。这是浏览器默认使用的方法。您已经长时间使用 HTTP 方法,却不知道这一点。

HTTP 有效载荷

HTTP 消息可以携带有效载荷,这意味着我们可以向服务器发送数据,服务器同样也可以向其客户端发送数据。这通常是通过POST请求完成的。

有效载荷可以有多种格式,但最常见的是以下几种:

  • application/json: 在共享 JSON 数据时使用

  • application/x-www-form-urlencoded: 在发送 ASCII 简单文本时使用,将数据发送到 URL 中

  • multipart/form-data: 在发送二进制数据(如文件)或非 ASCII 文本时使用

  • text/plain: 在发送纯文本时使用,例如日志文件

  • 您可以在以下位置找到内容类型的完整列表:developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types

HTTP 版本

随着时间的推移,HTTP 已经发展,我们有不同版本的协议:

版本 年份 状态
HTTP/0.9 1991 已废弃
HTTP/1.0 1996 已废弃
HTTP/1.1 1997 标准
HTTP/2 2015 标准
HTTP/3 2022 标准

目前,最常用的协议版本是HTTP/1.1版本,但HTTP/2版本正在变得越来越受欢迎。HTTP/3版本相当新,尚未得到广泛支持。

现在,Node 支持HTTP/1.1HTTP/2版本,但尚未支持HTTP/3版本。有一个正在进行中的战略计划来支持它:github.com/nodejs/node/issues/38478

在下一节中,我们将了解统一资源定位符(URL)的重要性以及我们如何使用它们来结构化我们网络应用程序中的资源访问。

在网络应用程序中使用 URL

让我们看一下 Node.js 制作的以下表格,它描述了 URL 的不同部分:

图 9.9 – 来自 Node.js 官方文档的 URL 结构。Node.js 贡献者的归属权和版权许可由 MIT 授权

图 9.9 – 来自 Node.js 官方文档的 URL 结构。Node.js 贡献者的归属权和版权许可由 MIT 授权

在接下来的章节中,我们将大量使用 URL 部分,所以请保留这个表格。解析 URL 有许多方法,但最常见的方法是使用URL类:

import { URL } from 'node:url';
const myUrl = new URL('https: //user:pass @sub.example. com:8080 /p/a/t/h?query=string#hash');
console.log(myUrl.hash); // #hash
console.log(myUrl.host); // sub.example.com:8080
console.log(myUrl.hostname); // sub.example.com

这个类在 Node.js 和浏览器中都是可用的。

重要信息

Node.js 20 引入了最有效的 URL 解析器之一,称为 Ada 2.0:www.yagiz.co/announcing-ada-url-parser-v2-0

现在我们已经了解了 URL 的灵活性,接下来让我们在下一节中探讨如何在我们的网络服务之上构建一个标准层。这个层是许多在线服务和 SaaS 产品的基石。我们将学习创建网络应用程序接口(API)的基础知识。

概述 REST API

REST代表表征状态转移,是一种用于构建 API 的架构风格。它在 2000 年由 Roy Fielding 在他的博士论文中提出(www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm)。

实际上,这个想法是定义一组可以通过 HTTP 由客户端访问的资源,正如我们在上一节中探讨的那样。

每个资源都由一个唯一的 URL 标识,客户端可以使用 HTTP 方法对其执行操作。当需要时,服务器将返回一个状态码和一个有效载荷。

例如,假设我们有一个用于管理电影数据库的 REST API。我们可以定义以下资源:

  • /movies:这个资源代表电影集合

  • /movies/:id:这个资源代表一个单一的电影

重要提示

URL 中的:id部分是一个用于用户 ID 的占位符。这被称为 URL 参数,其形式为/movies/1/movies/12345等。

客户端可以使用上述 HTTP 方法对这些资源执行以下操作:

  • GET /movies: 获取所有电影

  • GET /movies/:id:获取单个电影

  • POST /movies:创建一个新的电影

  • PUT /movies/:id:更新一个电影

  • DELETE /movies/:id:删除一个电影

大多数情况下,服务器将以 JSON 负载响应,但它也可以以其他格式响应,例如 XML 或 HTML。

让我们看看一个 REST API 的实际应用示例。我们将使用 simple-api (www.npmjs.com/package/@ulisesgascon/simple-api),这是一个非常简单的 HTTP API,用于快速构建原型。此 API 包含 Swagger 文档,可用于探索 API。

图 9.10 – 使用 Swagger 生成的 API 文档的 Web 浏览器截图

图 9.10 – 使用 Swagger 生成的 API 文档的 Web 浏览器截图

如您所见,API 非常直观且易于使用,因为它遵循 REST 原则。因此,您可以直观地了解如何使用它。当我们创建新的待办事项时,我们可以使用 Swagger 来探索 API 期望的负载的更多细节:

图 9.11 – 使用 Swagger 生成的 API 文档的 Web 浏览器截图

图 9.11 – 使用 Swagger 生成的 API 文档的 Web 浏览器截图

还可以探索 API 可以返回的潜在响应,对于任何可用的特定端点:

图 9.12 – 使用 Swagger 生成的 API 文档的 Web 浏览器截图

图 9.12 – 使用 Swagger 生成的 API 文档的 Web 浏览器截图

如果您理解 RESTful API 的工作原理,您将能够使用任何基于 HTTP 的 API。例如,GitHub API docs.github.com/en/rest 是一个使用 HTTP 公开其服务的 REST API。

有一个充满 API 的世界等待着您去使用,以构建令人惊叹的项目。这里有一份优秀的公共 API 列表:apilist.fun/

在前面的章节中,我们提到了 JSON,但还没有对其进行详细解释,因此在下节中我们将深入探讨它,因为它是现代 API 中最常见的交换数据格式。

探索 JSON 规范

JSON代表JavaScript 对象表示法,是一种轻量级的数据交换格式。它易于人类阅读和编写,也易于机器解析和生成。

我们可以使用JSON.stringify()方法轻松地将 JavaScript 对象转换为 JSON 字符串:

const user = {
  name: 'John',
  age: 30
};
const json = JSON.stringify(user);

我们还可以使用JSON.parse()方法将 JSON 字符串转换为 JavaScript 对象:

const json = '{"name":"John","age":30}';
const user = JSON.parse(json);

虽然 JSON 的名称中包含了 JavaScript,但它是一种与语言无关的数据格式。许多编程语言都有库来解析和生成 JSON。

JSON 是客户端和服务器之间交换数据最常用的格式,例如当我们使用或构建 REST API 时。

重要提示

JSON 规范相当简单,我强烈建议你阅读它。你可以在www.ecma-international.org/wp-content/uploads/ECMA-404_2nd_edition_december_2017.pdf找到它。

在下一节中,我们将探讨如何调试 HTTP 请求,这样我们就可以轻松构建复杂的项目。

调试 HTTP 请求

调试 HTTP 请求有许多方法。最常见的方法是使用开发者工具,因为这些工具在大多数网络浏览器中都很容易访问。当你开发网站时,保持这些工具开启并在标签之间导航以调试 UI 组件和网络请求也非常有帮助。

但也有其他你可以使用的工具,例如 Postman (www.postman.com/) 或 Insomnia (insomnia.rest/),这些工具专门为此目的而设计,并提供了许多开箱即用的功能(集合、认证等)。如果你没有网站,只是直接测试 API 端点,这些工具是最佳选择。

在下一章中,我们将使用浏览器的开发者工具来调试我们的 HTTP 事务,并使用 Jest 来测试和调试我们的 HTTP 请求。

其他开发者使用更高级的工具,如 Charles (www.charlesproxy.com/) 或 Wireshark (www.wireshark.org/),但它们对于本书的范围来说并不是必需的。

如果你不太熟悉浏览器中的开发者工具,你可以在第二章中了解更多相关信息。

概述

在本章中,我们学习了我们将用于构建应用程序的技术,以及构成现代互联网基础的技术。

此外,我们还学习了如何使用 RFC 文档来定义互联网的标准,以及如何利用它们来了解更多关于我们使用的技术。

之后,我们学习了服务器-客户端架构的工作原理以及 HTTP 如何在客户端和服务器之间详细交换数据,包括 HTTP 方法和状态码。

最后,我们探讨了 URL 的组成部分,并学习了如何使用它们来构建 RESTful API。我们还更详细地了解了 JSON 规范以及如何调试 HTTP 请求。

在下一章中,我们将探讨如何使用数据库来存储数据以及如何与它们交互。这是我们开始构建最终网络应用之前需要解决的最后一部分难题。

进一步阅读

第十章:使用 Express 构建 Web 应用程序

Express 是最流行的 JavaScript 网络框架,并且多年来一直是事实上的标准。它是一个非常简约的框架,非常容易学习,并且为构建网络应用提供了很多灵活性。

在本章中,我们将从一个最基本的“Hello World”应用程序开始,构建一个坚实且经过良好测试的 REST API 应用程序。我们将详细探讨 Express 的所有关键组件,包括请求和响应、中间件和路由。我们还将学习如何使用最常用的 Express 中间件以及如何构建自己的中间件。

总结一下,以下是本章我们将探讨的主要主题:

  • 为你的项目提供静态文件服务

  • 使用模板引擎构建服务器端渲染的着陆页

  • 使用 Express 构建典型的 CRUD REST API 应用程序

  • 使用最常用的 Express 中间件,包括第三方库

  • 构建自己的中间件

技术要求

本章的代码文件可以在 github.com/PacktPublishing/NodeJS-for-Beginners 找到

查看本章在 youtu.be/8QyDZVa7CNg 中的代码执行视频

熟悉 Express 库

Express 在其自己的网站上 (expressjs.com/) 如下定义:

快速、无偏见、简约的 Node.js 网络框架

所以,好消息是我们有很多自由度来构建我们的应用程序。坏消息是我们必须做出很多决定,我们必须小心不要犯错误。

与其他网络框架相比,Express 非常简约,因此当需要时,我们必须添加第三方库或构建自己的抽象。Express 有一个非常活跃的社区,因此我们可以找到很多库来解决常见问题。

此外,官方文档质量很高,有很多资源可以学习更多关于 Express 的知识,这使得 Express 成为初学者的绝佳选择。

由于 Express 是一个无偏见的框架,当你遵循教程或课程时,你会发现代码有时并不一致,也不遵循相同的模式。这是因为你在 Express 中有很多自由度,随着时间的推移,你将发展出自己的模式,并找到最适合你的构建应用程序的方式。

在这本书中,我们将使用 Express 版本 4.18.3,但任何 Express 4.x 版本都应该是可以的。我们将使用 Node.js 版本 20.11.0。这两个都是写作时的最新版本。

安装 Express

要安装 Express,我们必须在新的 Node.js 项目中运行以下命令:

npm install express@4

你不需要任何额外的配置;只需安装它,你就可以开始了。

Hello World

让我们从简单的例子开始,一个 Hello World 应用程序。创建一个名为 helloWorld.js 的新文件,并添加以下代码:

import express from 'express'
const app = express()
const port = 3000
app.get('/', (req, res) => {
  res.send('Hello World from Express!')
})
app.listen(port, () => {
  console.log(`Hello World app listening on port ${port}`)
})

非常简单,对吧?让我们来分解一下:

  1. 我们导入 Express 库并创建 Express 应用程序的一个实例。

  2. 我们为/根路径定义一个路由,并发送包含文本Hello World from Express!的响应。

  3. 我们启动服务器并监听端口3000

要运行应用程序,我们使用以下命令:

node helloWorld.js

如果一切正常,你应该看到以下输出:

Hello World app listening on port 3000

现在,如果你打开你的浏览器并转到http://localhost:3000,你应该看到文本Hello World from Express!,如下面的截图所示:

图 10.1 – 显示简单 Express 项目的 Web 浏览器截图

图 10.1 – 显示简单 Express 项目的 Web 浏览器截图

使用生成器

Express 有一个命令行工具可以生成基本的应用程序。要使用它,我们必须运行以下命令:

npx express-generator@4

这将生成一个包含许多文件和文件夹的新应用程序。我建议你创建一个新的文件夹并在那里运行命令,这样你就不会弄乱你的当前项目。

执行时的输出应该类似于以下内容:

   create : public/
   create : public/javascripts/
   create : public/images/
   create : public/stylesheets/
   create : public/stylesheets/style.css
   create : routes/
   create : routes/index.js
   create : routes/users.js
   create : views/
   create : views/error.jade
   create : views/index.jade
   create : views/layout.jade
   create : app.js
   create : package.json
   create : bin/
   create : bin/www
   install dependencies:
     $ npm install
   run the app:
     $ DEBUG=generated:* npm start

然后下一步是安装依赖项:

npm install

最后,我们可以启动应用程序:

npm start

如果一切正常,你应该看到以下输出:

> generated@0.0.0 start
> node ./bin/www
GET / 304 125.395 ms - -
GET /stylesheets/style.css 304 1.265 ms - -
GET / 304 11.043 ms - -
GET /stylesheets/style.css 304 0.396 ms - -
GET /ws 404 11.822 ms - 1322

如果你通过浏览器访问http://localhost:3000,你应该看到以下页面:

图 10.2 – 显示使用 express-generator 生成的 Express 应用的 Web 浏览器截图

图 10.2 – 显示使用express-generator生成的 Express 应用的 Web 浏览器截图

随意探索生成的代码,但如果你对某些内容不理解也不要担心,因为接下来的章节中我们会涵盖所有重要的部分。请注意,路由localhost:3000/users也是正常工作的,如果你尝试其他路由,你会得到一个 404 错误,例如localhost:3000/invented

调试

现在,让我展示 Express 生成器包含的另一个酷功能,我们将在我们的项目中稍后使用。如果你在终端输出中使用命令DEBUG=* npm startset DEBUG=* && npm start(如果你使用 Windows),输出将更加详细,你会看到关于请求和响应的很多信息:

图 10.3 – 终端截图

图 10.3 – 终端截图

这是因为 Express 和许多其他依赖项使用debug库(www.npmjs.com/package/debug)来记录信息。通过使用DEBUG=*环境变量,我们告诉调试库打印与所有命名空间相关的信息。但我们可以更加选择性地限制范围,例如,通过使用DEBUG=express:* npm start环境变量。

现在,我们已经对 Express 有了基本的了解,是时候探索我们如何使用模板引擎来渲染发送到浏览器的 HTML 页面了。

理解模板引擎

第九章中,我们学习了单页应用程序SPAs)和服务器端渲染之间的区别。

Express 提供了一种使用模板引擎渲染 HTML 页面的方法。这是使用 Express 构建服务器端渲染应用程序的关键特性。

选择模板引擎

我们必须做的第一件事是选择一个模板引擎。有许多选项可供选择。历史上最受欢迎的选项是 Jade(www.npmjs.com/package/jade),但它的名字已被改为 Pug(www.npmjs.com/package/pug)。您可以通过搜索这两个名字来找到许多教程和示例。

我个人更喜欢嵌入式 JavaScript 模板ejs)(www.npmjs.com/package/ejs),因为它简单且文档齐全。随着时间的推移,您将更加熟悉模板引擎,并能够选择最适合您需求的那个。

渲染模板

因此,回到我们的 hello world 示例,让我们创建一个名为helloWorldTemplate.js的新文件,并添加以下代码:

import express from ('express')
const app = express()
const port = 3000
app.set('view engine', 'ejs')
app.get('/', (req, res) => {
  res.render('index', {
    title: 'This is an Express app',
    subtitle: 'using EJS as template engine'})
})
app.listen(port, () => {
  console.log(`Application running in http://localhost:${port}`)
})

现在,我们必须创建一个名为views的新文件夹,在其中创建一个名为index.ejs的新文件,内容如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title><%= title %></title>
</head>
<body>
  <h1><%= title %></h1>
  <h2><%= subtitle %></h2>
</body>
</html>

如您所见,模板引擎使用<%=%>标签来插入值。在这种情况下,我们将title变量传递给模板。

最后,我们必须安装ejs依赖项:

npm install ejs@3

然后我们按照以下方式启动应用程序:

node helloWorldTemplate.js

如果您在浏览器中访问http://localhost:3000,您应该看到以下内容显示:

图 10.4 – 显示为最终用户渲染的模板的 Web 浏览器截图。

图 10.4 – 显示为最终用户渲染的模板的 Web 浏览器截图。

此外,如果您访问view-source:http://localhost:3000/,您将看到 Express 发送到浏览器的原始 HTML:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>This is an Express app</title>
</head>
<body>
  <h1>This is an Express app</h1>
  <h2>using EJS as template engine</h2>
</body>
</html>

如您所见,模板引擎正在为我们插入值并生成 HTML。

理解这个过程

现在,让我们理解代码中实际发生的事情:

app.set('view engine', 'ejs')

前面的行告诉 Express 我们将使用ejs作为我们的模板引擎,因此现在我们可以使用res.render来渲染模板。

res.render('index', {
    title: 'This is an Express app',
    subtitle: 'using EJS as template engine'
})

在前面的代码中,res.render方法接收两个参数。第一个参数是模板的名称,在这种情况下,indexviews/index.ejs),第二个参数是我们想要在模板中插入的数据。

然后模板引擎将用我们在res.render方法的第二个参数中传递的值替换<%= title %><%= subtitle %>标签。

在现实世界的应用程序中,我们传递给模板的数据将是动态的;例如,我们从数据库或外部 API 获取的数据。但就目前而言,我们将使用静态数据以使示例简单。

在下一节中,我们将学习如何利用请求对象构建更丰富和强大的应用程序。

掌握请求

第九章中,我们学习了关于 HTTP 请求和响应的所有理论。在这里,我们将介绍如何使用 Express 来处理这些请求。

在本节中,我们将专注于以下伪代码片段:

import express from ('express')
const app = express()
app.method(route, handler)

我们有三个元素需要理解:

  • method,即我们想要处理的 HTTP 方法,例如GETPOSTPUTDELETE等等

  • route,即我们想要处理的路径,例如//users/users/:id

  • handler,即当methodroute匹配时将被执行的回调函数

HTTP 方法

Express 为每种 HTTP 方法提供了一个方法。有很多种方法(getpostputheaddeleteoptionstracecopylockmkcolmovepurgepropfindproppatchunlockreportmkactivitycheckoutmergem-searchnotifysubscribeunsubscribepatchsearchconnect)。

最常见的是getpostputdelete,因此我们将重点关注它们:

app.get ('/', () => {})
app.post('/', () => {})
app.put('/', () => {})
app.delete('/', () => {})

如果你想在同一个路由中管理所有 HTTP 方法,你可以使用all方法:

app.all('/', () => {})

路由

路由非常灵活,可以是动态的。我们可以用不同的方式定义它们,包括正则表达式。

静态路径

静态路径是定义路由最常见的方式。它们用于处理对特定路径的请求,例如//users/user/me

app.get('/', () => {})
app.get('/users', () => {})
app.get('/user/me', () => {})

动态参数

动态参数用于处理对特定路径的请求。我们可以使用:字符来定义一个动态参数,例如/users/:id/users/:id/profile

app.get('/users/:id', () => {})

在这种情况下,:id是一个动态参数,因此它可以与任何值匹配,包括/users/1/users/peter/users/jane-doe等等。

你甚至可以将静态和动态参数结合起来,例如/users/:id/profile

app.get('/users/:id/profile', () => {})

上述示例将解析对/users/1/profile/users/peter/profile/users/jane-doe/profile等路径的请求。

这种模式在交通应用中相当常见,例如,你可以有一个像/users/:id/rides/:rideId这样的路由来获取特定行程的详细信息,或者当你使用像/flights/from/:originCity/to/:destinationCity这样的路由预订航班时。Express 将为req.params对象提供动态参数的值:

app.get('/users/:id', (req, res) => {
    const { originCity, destinationCity } = req.params
    res.send(`You are flying from ${originCity} to ${destinationCity}`)
})

可选参数

可选参数用于处理对特定路径的请求,但该参数是可选的。这可以通过使用?字符来完成,例如/invoice/:id?

app.get('/invoice/:id?', (req, res) => {
    const id = parseInt(req.params.id)
    if (id) {
        res.send(`You are looking for the invoice with id ${id}`)
    } else {
        res.send(`You are looking for all the invoices`)
    }
})

在这种情况下,:id参数是可选的,因此它可以与/invoice/invoice/167/invoice/G123S8123SD123MJ等匹配。

正则表达式

我们可以使用正则表达式来定义路由。当我们想要使用可预测的模式匹配路由时,这非常有用——例如,/.*fly$/ 将识别以 fly 结尾的任何文本:

app.get(/.*fly$/, (req, res) => {
  res.send(`Match with any route that ends with fly`)
})

前面的路由将匹配 /butterfly/dragonfly/fly/mcfly 等等。让我们创建一个不那么奇特示例:

app.get('/msg/:id/:action(edit|delete)', (req, res, next) => {
  res.send(`You request the action ${req.params.action} for the message ${req.params.id}`);
});

在这种情况下,路由将匹配 /msg/1/edit/msg/1/delete 等等。

注意

如果你不太熟悉正则表达式,不要担心:你可以使用其他选项来定义你的路由。但如果你想更深入地探索正则表达式,我建议你尝试 正则表达式 101 (regex101.com/)。

查询参数

第九章 中,我们学习了 URL 的不同部分,并看到查询参数是以 ? 开头的部分。这些用于向服务器发送额外信息。例如,在 URL /films?category=scifi&director=George+Lucas 中,我们发送了两个查询参数,categorydirector

我们可以在 req.query 对象中捕获查询参数,并在我们的路由中使用它们:

app.get('/films', (req, res) => {
    const { category, director } = req.query
    res.send(`You are looking for films with category ${category} and director ${director}`)
})

重要的是要注意,查询参数是可选的,这意味着请求可能根本不包含任何查询参数。在这种情况下,req.query 对象将是空的。

注意

URL 片段(即 /mypath#fragment)不是请求的一部分,浏览器也不会将其作为此类包含,因此我们无法捕获它们。有关更多信息,请参阅 https://github.com/expressjs/express/issues/1083。

顺序的重要性

路由是按照你定义的顺序注册的,这允许 Express 避免路由之间的冲突。让我们看一个例子:

app.get('/users/:id', () => {
    res.send(`You are looking for the user with id ${req.params.id}`)
})
app.get('/users/me', () => {
    res.send(`You are looking for the current user`)
})

如果你尝试访问 /users/me,你会得到消息 You are looking for the user with id me,因为 /users/:id 路由是首先注册的,所以它会匹配 /users/me,而 me 的值将被存储在 req.params.id 属性中。

你可以通过调整路由的顺序来解决这个问题:

app.get('/users/me', () => {})
app.get('/users/:id', () => {})

在大型项目中,如果你没有良好的策略来定义路由,这可能会成为一个问题。这也是在项目中包含自动化测试以避免意外配置路由错误的好理由。

处理器

处理器是在请求与路由匹配时执行的函数。虽然处理器是一个带有三个参数(reqresnext)的简单函数,但它承担着处理请求响应或委派请求给其他处理器的重大责任:

app.get('/', (req, res, next) => {
    res.send("Hello World")
})

让我们更详细地看看处理器的参数。

请求

请求对象(req)包含有关请求的所有信息,包括参数、IP、头部、主体等。如果你使用其他扩展 Express 功能的库,你经常会在这个对象中找到更多属性。

你可以在 Express 文档中找到更多关于请求对象的信息(expressjs.com/en/4x/api.html#req)。

response

响应对象 (res) 包含处理请求响应的所有方法,包括简单的 sendjson 方法,以及更复杂的方法,如下载或重定向。

在下一节中,我们将学习更多关于响应对象的功能。

next

下一个函数 (next) 用于将请求委派给下一个处理器。当你想要将处理器的逻辑拆分成多个函数或委派错误管理时,这很有用。

我们将在接下来的两个章节中学习这两种策略,届时我们将讨论中间件模式和掌握响应。

在下一节中,我们将学习如何利用响应对象,以及如何根据许多不同的场景自定义响应,例如 HTTP 重定向、HTTP 头部定制等。

掌握响应

响应是服务器在请求之后与客户端通信的方式,因此理解如何管理它们至关重要。在本节中,我们将学习如何添加头部、状态码、重定向、发送数据和发送文件。

当你开始构建更复杂的应用程序时,你将发现可用的方法。你可以在 Express 文档中找到更多关于响应对象的信息(expressjs.com/en/4x/api.html#res)。

头部管理

头部用于发送关于响应的额外信息。Express 通过 set 方法处理头部,它接受两个参数,即头部的名称和值:

app.get('/', (req, res, next) => {
    res.set('Content-Type', 'text/html')
    res.send("<h1>Hello World</h1>")
})

在前面的示例中,我们将 Content-Type 头部设置为 text/html,这样浏览器就会知道响应是一个 HTML 文档,并将其渲染为 HTML。

多个头部

你也可以通过传递一个对象作为第一个参数来使用 set 方法同时设置多个头部:

app.get('/', (req, res, next) => {
    res.set({
        'Content-Type': 'text/html',
        'x-powered-by': 'Unicorns and rainbows'
    })
    res.send("<h1>Hello World</h1>")
})

在前面的代码中,我们设置了两个头部,Content-Typex-powered-by

删除头部

你可以使用 removeHeader 方法来删除头部,它接受头部名称作为第一个参数:

app.get('/', (req, res, next) => {
    res.set({
        'Content-Type': 'text/html',
        'x-powered-by': 'Unicorns and rainbows'
    })
    res.removeHeader('x-powered-by')
    res.send("<h1>Hello World</h1>")
})

在前面的示例中,我们正在移除之前语句中刚刚添加的 x-powered-by 头部。

状态码

状态码是一个表示响应状态的数字。它用于将请求的状态传达给客户端。使用正确的状态码非常重要,因为它是我们之前讨论的 HTTP 协议的一部分,见 第九章

你可以使用 status 方法来管理状态码,它接受状态码作为第一个参数:

app.get('/', (req, res, next) => {
    res.status(200)
    res.send("<h1>Hello World</h1>")
})

在前面的示例中,我们将状态码设置为 200,这意味着请求成功。默认情况下,如果你没有设置状态码,Express 会将其设置为 200

方法链式调用

你可以将 status 方法与其他方法链式调用,例如 setsend

app.get('/', (req, res, next) => {
    res.status(200).set('Content-Type', 'text/html').send("<h1>Hello World</h1>")
})

仅发送状态码

如果你只想发送状态码,可以使用 sendStatus 方法,它将状态码作为第一个参数接收:

app.get('/', (req, res, next) => {
    res.sendStatus(500)
})

在前面的例子中,我们发送了 500 状态码,这意味着请求没有成功。

重定向

你可以使用 redirect 方法将请求重定向到另一个 URL,它将 URL 作为第一个参数接收:

app.get('/', (req, res, next) => {
    res.redirect('https://ulisesgascon.com/')
})

在前面的例子中,我们将请求重定向到 ulisesgascon.com

重定向的默认状态码是 302,但你可以通过指定状态码作为第一个参数来更改它:

app.get('/', (req, res, next) => {
    res.redirect(301, 'https://ulisesgascon.com/')
})

redirect 方法也接受相对 URL,因此你可以将请求重定向到应用程序中的另一个路由:

app.get('/', (req, res, next) => {
    res.redirect('/about')
})

你甚至可以将 URL 重定向到更高的层级:

app.get('/about/me', (req, res, next) => {
    res.redirect('..')
})

在这种情况下,请求将被重定向到 /about,类似于在终端中执行 cd..

你还可以使用 back 方法将请求重定向到引用 URL。如果请求中没有引用头,则请求将被重定向到 /

app.get('/', (req, res, next) => {
    res.redirect('back')
})

发送数据

在本章的开头,我们看到了如何使用 res.render 来渲染模板,但还有其他方法可以将数据发送到客户端。最常见的方法是使用 send 方法,它将数据作为参数接收。这可以是任何类型的数据,包括缓冲区:

app.get('/', (req, res, next) => {
    res.send("Hello World")
})

使用 res.send()

在底层,send 方法会将数据转换为字符串,并将 Content-Type 头设置为 text/html,除非你使用 res.set() 指定其他内容。它还会包含 Content-Length

如果你使用缓冲区,Content-Type 将被设置为 application/octet-streamContent-Length 将被设置为缓冲区的长度,但你可以通过使用 res.set() 来更改这些设置:

app.get('/', (req, res, next) => {
    res.set('Content-Type', 'text/html')
    res.send(Buffer.from('<p>Hello World</p>'))
})

使用 res.json()

如果你想要发送 JSON 数据,可以直接使用 json 方法,它将数据作为第一个参数接收。它将设置 Content-Type 头为 application/json 并为你进行数据的序列化:

app.get('/', (req, res, next) => {
    res.json({message: 'Hello World'})
})

这是最常见的发送 JSON 数据的方式,但你也可以使用 send 方法并设置 Content-Type 头为 application/json,自行进行数据的序列化:

app.get('/', (req, res, next) => {
    res.set('Content-Type', 'application/json')
    res.send(JSON.stringify({message: 'Hello World'}))
})

如果你想要使用不同的字符串化库,例如 fast-json-stringify (www.npmjs.com/package/fast-json-stringify),这将非常有用。

发送文件

你可以通过使用 sendFile 方法将文件发送到客户端,它将文件路径作为第一个参数接收:

app.get('/report', (req, res, next) => {
    res.sendFile('/path/to/file.txt')
})

在前面的示例中,我们将 /path/to/file.txt 文件发送到客户端。这种方法提供了巨大的灵活性,包括一个回调来管理可能出现的错误。请参阅文档 (expressjs.com/en/4x/api.html#res.sendFile) 获取更多信息。

发送文件的另一种方式是使用 res.download() 方法,它将文件路径作为第一个参数接收:

app.get('/report', (req, res, next) => {
    res.attachment('/path/to/file.txt')
})

此方法将设置 Content-Disposition 标头为 attachment,并将 Content-Type 标头设置为 application/octet-stream,除非您使用 res.set() 指定其他内容。此方法提供了巨大的灵活性,包括一个回调来管理可能出现的错误。您可以查看文档 (expressjs.com/en/4x/api.html#res.download) 获取更多信息。

在下一节中,我们将了解中间件模式有多么强大,以及我们如何利用它来构建更复杂的应用程序。Express 基于中间件模式,因此理解它非常重要,因为它将使我们能够轻松扩展 Express 的功能。

使用中间件模式

Express 的核心是中间件模式,它允许您通过添加将在请求-响应周期中执行的功能来扩展框架的功能。中间件函数按照它们添加到应用程序中的顺序执行,并且可以将它们添加到应用程序或路由中。

图 10.5 – 从应用中间件到主函数的中间件模式完整流程

图 10.5 – 从应用中间件到主函数的中间件模式完整流程

我们可以将中间件模式理解为一条管道,其中请求通过管道传递,每个中间件函数都可以修改请求和响应,并将请求传递给管道中的下一个中间件函数。中间件函数还可以通过向客户端发送响应来结束请求-响应周期。

图 10.6 – 仅限于应用中间件的中间件模式

图 10.6 – 仅限于应用中间件的中间件模式

我们可以向应用程序添加一个全局中间件,以验证请求是否经过认证。当用户正确认证后,我们可以将请求传递给管道中的下一个中间件函数,如果用户未认证,我们可以通过向客户端发送包含错误信息的响应来结束请求-响应周期。

图 10.7 – 从应用中间件到路由中间件的中间件模式

图 10.7 – 从应用中间件到路由中间件的中间件模式

我们还可以向路由添加一个中间件函数,例如验证用户是否有适当的权限访问该路由,如果有,则可以将请求传递给管道中的下一个中间件函数;如果用户没有适当的权限,我们可以通过向客户端发送错误消息来结束请求-响应周期。

图 10.8 – 中间件模式完整流程从应用程序中间件到主函数

图 10.8 – 从应用程序中间件到主函数的中间件模式完整流程

当中间件的主要功能出现任何问题,如异常,导致中间件无法继续进行请求-响应周期时,错误中间件可以接管控制权,并向客户端发送错误消息。

如您所见,中间件模式相当复杂,但同时也非常强大,因为它使我们能够轻松地抽象和重用代码。我们可以将请求解析为一系列函数,其中每个函数在需要时可以接管控制权,这样我们就可以很好地隔离适当的业务逻辑。

理解作用域

因此,中间件函数有三个可能的作用域:

  • 全局中间件:这将针对应用程序接收到的所有请求执行。

  • 路由中间件:这将针对接收到的所有请求执行。

  • 错误中间件:当中间件函数抛出错误时将执行。

中间件结构

那么,让我们看看中间件函数的结构:

const middleware(req, res, next) {}

基本上,中间件函数接收三个参数:请求对象、响应对象和下一个函数。

现在让我们详细看看我们可以用中间件函数做什么。

向请求添加上下文

中间件函数的一个非常常见的用途是向请求对象添加上下文。想法是扩展请求对象,添加额外的属性,这些属性将被管道中的下一个中间件函数使用。让我们看一个例子:

const detectLangMiddleware(req, res, next) {
    req.lang = req.headers['accept-language'] || 'en'
    next()
}

在前面的示例中,我们正在向请求对象添加一个名为lang的新属性。该属性可以被管道中的下一个中间件函数作为req.lang使用。

这是一个非常简单的例子,但相当常见,用于创建简单且易于组合的中间件函数。

如您所见,detectLangMiddleware正在使用next()来让 Express 知道中间件已完成且没有错误。在这种情况下,如果我们不调用next(),应用程序将永远挂起。

管理响应

中间件函数的另一个常见用途是管理响应。例如,我们可以添加一个中间件函数,如果用户正在使用 Internet Explorer,则将用户重定向到updatemybrowser.org/

const legacyBrowsersMiddleware(req, res, next) {
    if (req.headers['user-agent'].includes('MSIE')) {
        res.redirect('https://updatemybrowser.org/')
    } else {
        next()
    }
}

如您所见,如果用户正在使用 Internet Explorer,他们将被重定向到 updatemybrowser.org/。我们不调用 next(),因为我们不想继续进行请求-响应周期,因为我们已经使用 res.redirect() 向客户端发送了响应。

如果用户没有使用 Internet Explorer,我们将调用 next() 以继续进行常规的请求-响应周期。

额外配置

第三章 中,我们学习了闭包的工作原理。使用闭包向中间件函数添加额外配置是很常见的。让我们看看一个例子:

const detectLangMiddleware = defaultLang => (req, res, next) => {
    req.lang = req.headers['accept-language'] || defaultLang
    next()
}

在前面的代码中,我们将中间件函数从 before 改为使用闭包接收默认语言作为参数,因此我们不需要默认使用 en

因此,现在这个中间件函数将按以下方式执行:

import { detectLangMiddleware } from './utils'
// With the closure
app.use(detectLangMiddleware('es'))
// without the closure
app.use(detectLangMiddleware)

这是一种常见的中间件函数模式,需要额外的配置,例如本例中的默认语言,或者令牌等。

测试

这种中间件模式的另一个优点是,我们可以轻松地对中间件函数进行单元测试,因为它们只是接收请求、响应和下一个函数作为参数并执行它们的函数。我们可以模拟请求和响应,并模拟下一个函数以检查中间件是否正常工作。

我们将在本章的后面部分更详细地介绍这一点,但在此期间,您可以检查我名为 user-language-middleware 的库 (www.npmjs.com/package/user-language-middleware)。完整的测试套件可以在 github.com/UlisesGascon/user-language-middleware/blob/main/__tests__/userLanguageMiddleware.test.js 找到,以更熟悉中间件函数的测试。

向应用程序添加中间件

您可以使用 app.use() 方法向应用程序添加中间件。此方法接收一个中间件函数作为参数,并将为应用程序接收到的所有请求执行:

app.use(legacyBrowsersMiddleware)

注意,中间件函数的顺序很重要,因为它们将以它们被添加到应用程序中的相同顺序执行。

向路由添加中间件

您可以通过使用 app.METHOD() 方法而不是 app.use() 来以与向应用程序添加相同的方式向路由添加中间件:

app.get('/users', legacyBrowsersMiddleware, (req, res) => {
    res.send('Hello world')
})

因此,legacyBrowsersMiddleware 只会在 GET /users 路由上执行,如果 legacyBrowsersMiddleware 调用 next(),则管道中的下一个中间件函数将被执行,在这种情况下是 (req, res) => { res.send('Hello world') }

是的,我们自从本章开始就一直使用这种模式!确实可以说 所有路由都是 Express 中的中间件函数

中间件链式调用

你可以在同一个 app.METHOD() 方法中链式调用中间件函数,只需添加以下内容:

app.get('/users', legacyBrowsersMiddleware, detectLangMiddleware, (req, res) => {
    res.send('Hello world')
})

这在大型应用中非常常见,其中有很多中间件函数按照特定顺序执行。审查中间件函数的顺序是一个好习惯,以避免意外的行为,如果它们在多个路由中使用,则需要将它们迁移到应用级别,并根据需要调整业务逻辑。

Express 中常用的中间件

从历史上看,Express 团队已经在框架中包含了一些中间件函数,但自从 Express 4 以来,大多数中间件函数已经移动到外部包中。然而,框架中仍然包含了一些中间件函数。

静态文件

Express 包含一个中间件函数,用于从目录中提供静态文件。这对于提供 Web 应用的静态资源,如图像、CSS 文件或 JavaScript 文件非常有用:

app.use(express.static('public'))

你也可以使用多个目录来提供静态文件:

app.use(express.static('public'))
app.use(express.static('files'))

错误处理

Express 包含一个用于处理错误的中间件函数。这个中间件函数必须是管道中的最后一个,并且它必须接收四个参数而不是三个。第一个参数是错误,第二个是请求,第三个是响应,第四个是下一个函数:

app.use((err, req, res, next) => {
    console.error(err.stack)
    res.status(500).send('Ohh! The Server needs some love')
})

如果你在任何中间件函数中遇到错误,你可以调用 next(err),这样这个中间件函数就会被执行,就像你在路由处理程序中抛出错误时一样。让我们看看它是如何工作的:

import express from 'express'
const app = express()
const port = 3000
app.get('/next-error', (req, res, next) => {
    next(new Error('Ohh! Something went wrong'))
})
app.get('/throw-error', (req, res) => {
    throw new Error('Ohh! Something went wrong')
})
app.use((err, req, res, next) => {
    console.error(err.stack)
    res.status(500).send('Ohh! The Server needs some love')
})
app.listen(port, () => {
  console.log(`running at http://localhost:${port}`)
})

如果你现在访问 localhost:3000/next-errorlocalhost:3000/throw-error,你会看到错误处理中间件函数正在接管控制。

在下一节中,我们将继续学习中间件模式,但我们将关注生态系统中可用的第三方中间件函数。目前,有大量的中间件函数可以在你的 Express 应用程序中使用,因此了解如何正确使用它们非常重要。虽然使用第三方中间件函数可以节省大量时间和精力,但你必须小心,因为这意味着为你的项目添加更多的依赖项。

使用第三方中间件

你可以在你的 Express 应用程序中使用许多第三方中间件函数。让我们看看如何安装和使用它们。

最受欢迎的中间件函数之一是 body-parser (www.npmjs.com/package/body-parser)。基本上,它将解析传入请求的 HTTP 主体,并将其作为 req.body 属性提供。

使用以下方式使用 npm 安装它:

npm install body-parser@1

然后你可以导入它并在你的应用中使用它。创建一个名为 echo_payload.js 的新文件,并包含以下内容:

import express from 'express'
import bodyParser from 'body-parser'
const app = express()
const port = 3000
app.use(bodyParser.json())
app.post('/echo', (req, res) => {
    // Echo the request body
    res.json(req.body)
})
app.listen(port, () => {
  console.log(`running at http://localhost:${port}`)
})

现在用 node echo_payload.js 运行应用,然后使用 curl 或类似的工具向 /echo 路由发送 POST 请求:

curl -X POST -H "Content-Type: application/json" -d '{"name":"John"}' http://localhost:3000/echo

你将看到响应与你在请求体中发送的相同 JSON。

摘要

在本章中,我们学习了 Express 的多种用途,包括如何创建基本服务器、如何添加路由、如何添加静态文件以及如何使用模板。

此外,我们还学习了中间件模式的工作原理以及我们如何创建自己的中间件并在应用程序的不同级别使用它。我们还检查了一些第三方中间件,包括body-parser

在下一章中,我们将学习如何深入测试我们的第一个 API。我们将涵盖如何测试路由和存储,并创建一个我们将开发在接下来的章节中的稳固 API。

进一步阅读

第四部分:使用 Node.js 构建稳固的 Web 应用程序

第四部分,我们将一起使用 Express 和 MongoDB 作为主要栈来构建一个 Web 应用程序。你将通过在项目中实现它们来学习高级主题,如错误处理或安全性,同时学习所有理论和最佳实践来确保你的 Web 应用程序安全。

本部分包括以下章节:

  • 第十一章, 从零开始构建 Web 应用程序项目

  • 第十二章, 使用 MongoDB 进行数据持久化

  • 第十三章, 使用 Passport.js 进行用户身份验证和授权

  • 第十四章, Node.js 中的错误处理

  • 第十五章, 保护 Web 应用程序

第十一章:从零开始构建 Web 应用程序项目

在本章中,我们将开始一个新的项目,这个项目将是下一章的基础。我们将应用前几章学到的所有课程,并将异步编程、Node.js 核心库、外部模块、测试以及我们关于 REST API 学到的所有概念付诸实践。

这个项目将会发展,因此我们将迭代项目,添加新功能和新的测试,这样你可以体验使用 Node.js 构建真实世界应用程序的完整开发周期。

在本章中,我们将使用文件系统库来存储我们在项目中产生的更改,同时管理我们从创建的 REST API 中进行的操作。在下一章中,我们将学习如何将 Web 应用程序连接到 MongoDB,但我们将使用本章构建的测试进行迁移。

在本书的结尾,我们将以几种方式部署这个项目,并将我们的应用程序暴露给互联网和真实用户。

总结一下,以下是本章我们将探讨的主要主题:

  • 如何启动一个包含 UI 和 API REST 的 Express 应用程序

  • 如何使用 Supertest 和 Jest 测试 Express 应用程序

  • 如何在我们的项目中包含数据存储

技术要求

本章的代码文件可以在github.com/PacktPublishing/NodeJS-for-Beginners找到

查看本章的代码演示视频,请访问youtu.be/JYWmvQrGu78

项目启动

这真是太令人兴奋了!我们将把在前几章中学到的所有知识应用到构建一个使用 Express 的 CRUD REST API 上。我们将使用文件系统来存储数据,并且我们将使用最常用的 Express 中间件来构建一个健壮的 API。

项目目标

我们将构建一个名为“Whispering”的微型博客平台,用户可以创建、阅读、更新和删除短篇帖子。

预览

虽然我们将专注于后端,但我们将包含一个基本的客户端来测试 API。因此,我们将从一个简单的应用程序骨架开始工作,我们将在接下来的章节中逐步发展它。

图 11.1 – 在网页浏览器中预览项目主页

图 11.1 – 在网页浏览器中预览项目主页

要求

需求将在下一章中演变,但到目前为止,我们将关注以下内容:

  • 使用模板引擎添加欢迎页面

  • 提供静态文件服务

  • 使用 Express 添加 CRUD REST API

  • 使用文件系统以 JSON 格式存储数据

  • 添加测试以确保 API 按预期工作

从第一步开始

要开始工作,我们需要从 github.com/PacktPublishing/NodeJS-for-Beginners/archive/refs/heads/main.zip 下载项目并访问 step0 文件夹。现在,进入文件夹,随意探索代码。你会发现我们有一个以下结构的基架:

|____.babelrc
|____db.json
|____server.js
|____store.js
|____jest.config.js
|____tests
| |____server.test.js
| |____fixtures.js
| |____store.test.js
| |____utils.js
|____index.js
|____public
| |____index.html
| |____styles.css
| |____app.js
| |____people.jpg
|____package-lock.json
|____package.json
|____.nvmrc
|____views
| |____about.ejs

现在我们已经明确了项目目标,让我们开始构建应用程序。在下一节中,我们将通过添加依赖项、基本结构、存储和其他内容来构建一个坚固的 REST API,以便在下一章中使用。

构建 REST API

现在我们对 Express 有了一个基本的了解,让我们为微博平台构建一个 RESTful API。我们将从基本的 CRUD 操作开始,然后我们将添加更多功能。

第九章 中,我们学习了构建 RESTful API 的原则。我们现在将应用它们。由于平台被称为“whispering”,用户将能够创建、读取、更新和删除悄悄话,我们将有以下端点:

  • GET /api/v1/whisper:获取所有悄悄话

  • GET /api/v1/whisper/:id:通过 ID 获取一个悄悄话

  • POST /api/v1/whisper:创建一个新的悄悄话

  • PUT /api/v1/whisper/:id:通过 ID 更新一个悄悄话

  • DELETE /api/v1/whisper/:id:通过 ID 删除一个悄悄话

在这种情况下,我们使用了前缀 /api/v1/,因为我们正在构建 API 的第一个版本。在 URL 中对 API 进行版本控制是一个好的做法,因为将来你可能想要引入破坏性更改,如果你不版本控制 API,你的消费者将很难适应新的更改。

添加路由

作为第一步,让我们添加依赖项:

npm install express@4 body-parser@1

首先,我们将向 server.js 文件中添加路由并配置 Express:

import express from 'express'
import bodyParser from 'body-parser'
const app = express()
app.use(bodyParser.json())
app.get('/api/v1/whisper', (req, res) => {
    res.json([])
})
app.get('/api/v1/whisper/:id', (req, res) => {
    const id = parseInt(req.params.id)
    res.json({ id })
})
app.post('/api/v1/whisper', (req, res) => {
    res.status(201).json(req.body)
})
app.put('/api/v1/whisper/:id', (req, res) => {
  //const id = parseInt(req.params.id)
  res.sendStatus(200)
})
app.delete('/api/v1/whisper/:id', (req, res) => {
    res.sendStatus(200)
})
export { app }

我们为 CRUD 操作创建了基本的路由,并且我们正在返回一个包含我们接收到的请求数据的 JSON 响应。这次,我们做了一点小小的改变,我们将导出 app 对象,以便我们可以在测试中稍后使用它。现在,让我们在 index.js 文件中初始化服务器:

import { app } from "./server.js";
const port = 3000
app.listen(port, () => {
    console.log(`Running in http://localhost:${port}`)
})

现在,让我们在 package.json 文件中添加 npm 脚本来运行应用程序:

{
    "scripts": {
        "start": "node index.js"
    }
}

添加存储

由于这是一个简单的应用程序,我们将使用文件系统来存储数据。我们将创建一个 store.js 文件,并添加以下函数:

import fs from 'node:fs/promises'
import path from 'node:path'
const filename = path.join(process.cwd(), 'db.json')
const saveChanges = data => fs.writeFile(filename, JSON.stringify(data))
const readData = async () => {
    const data = await fs.readFile(filename, 'utf-8')
    return JSON.parse(data)
}
const getAll = readData
const getById = async (id) => {
    const data = await readData()
    return data.find(item => item.id === id)
}
const create = async (message) => {
    const data = await readData()
    const newItem = { message, id: data.length +1}
    await saveChanges(data.concat([newItem]))
    return newItem
}
const updateById = async (id, message) => {
    const data = await readData()
    const newData = data.map(current => {
        if(current.id === id) {
            return { ...current, message }
        }
        return current
    })
    await saveChanges(newData)
}
const deleteById = async id => {
    const data = await readData()
    await saveChanges(data
.filter(current => current.id !== id)
    )
}
export { getAll, getById, create, updateById, deleteById }

基本上,我们正在使用文件系统将数据存储在一个 JSON 文件中。我们使用 saveChanges 来保存数据,使用 readData 来读取数据。

然后,我们在 getAllgetByIdcreateupdateById 函数中定义了基本的 CRUD 操作。

现在,让我们为存储添加测试。作为第一步,让我们添加依赖项,npm install -D jest@29 @babel/preset-env@7,然后让我们将测试的骨架添加到 tests/store.test.js 文件中:

import { getAll, getById, create, updateById, deleteById } from '../store.js'
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
const dbPath = join(process.cwd(), 'db.json')
const restoreDb = () => writeFileSync(dbPath, JSON.stringify([]))
const populateDb = (data) => writeFileSync(dbPath, JSON.stringify(data))
const fixtures = [{ id: 1, message: 'test' }, { id: 2, message: 'hello world' }]
const inventedId = 12345
const existingId = fixtures[0].id
describe('store', () => {
    beforeEach(() => populateDb(fixtures))
    afterAll(restoreDb)
    // Here we will be the tests
})

第八章中,我们学习了测试的原则。其中一个原则是测试应该是独立的,这意味着测试不应该依赖于先前测试的状态,并且我们可以多次运行它们,结果不应该改变。

由于我们使用外部文件来存储数据,我们需要确保在每次测试之前数据处于初始状态。因此,我们使用beforeEach函数用固定值填充数据库,并使用afterAll函数将数据库恢复到初始状态。这样,我们可以确保测试始终从相同的状态开始。

此外,我们还添加了一些固定值和变量,当我们需要创建、更新或删除数据时,我们将在测试中使用它们。这将帮助我们避免硬编码值并使测试更易于阅读。

现在,让我们为getAll函数添加测试:

describe('getAll', () => {
    it("Should return an empty array when there's no data", async () => {
        restoreDb()
        const data = await getAll()
        expect(data).toEqual([])
    })
    it('Should return an array with one item when there is one item', async () => {
        const data = await getAll()
        expect(data).toEqual(fixtures)
    })
})

我们只有两个测试用例——当数据库为空时和当数据库有数据时。在这两种情况下,我们都期望得到一个数组。

现在,让我们为getById函数添加测试:

describe('getById', () => {
    it('Should return undefined when there is no item with the given id', async () => {
        const item = await getById(inventedId)
        expect(item).toBeUndefined()
    })
    it('Should return the item with the given id', async () => {
        const item = await getById(fixtures[0].id)
        expect(item).toEqual(fixtures[0])
    })
})

我们只有两个测试用例——当我们匹配一个项目时和当我们不匹配一个项目时。

现在,让我们为create函数添加测试:

describe('create', () => {
    it('Should return the created item', async () => {
        const newItem = { id: fixtures.length + 1, message: 'test 3' }
        const item = await create(newItem.message)
        expect(item).toEqual(newItem)
    })
    it('Should add the item to the db', async () => {
        const newItem = { id: fixtures.length + 1, message: 'test 3' }
        const { id } = await create(newItem.message)
        const item = await getById(id)
        expect(item).toEqual(newItem)
    })
})

在这种情况下,我们期望当函数返回时,包括 ID 的项目被返回,并且我们期望项目被添加到数据库中。

让我们为updateById函数添加测试:

describe('updateById', () => {
    it('Should return undefined when there is no item with the given id', async() => {
        const item = await updateById(inventedId)
        expect(item).toBeUndefined()
    })
    it('Should not return the updated item', async () => {
        const updatedItem = { id: existingId, message: 'updated' }
        const item = await updateById(updatedItem.id, updatedItem.message)
        expect(item).toBeUndefined()
    })
    it('Should update the item in the db', async () => {
        const updatedItem = { id: existingId, message: 'updated' }
        await updateById(updatedItem.id, updatedItem.message)
        const item = await getById(existingId)
        expect(item).toEqual(updatedItem)
    })
})

在这种情况下,我们期望只有当项目存在于数据库中时,项目才会在数据库中更新,但函数根本不会返回该项目。

让我们为deleteById函数添加最后的测试:

describe('deleteById', () => {
    it('Should return undefined when there is no item with the given id', async () => {
        const item = await deleteById(inventedId)
        expect(item).toBeUndefined()
    })
    it('Should not return the deleted item', async () => {
        const item = await deleteById(existingId)
        expect(item).toBeUndefined()
    })
    it('Should delete the item from the db', async () => {
        await deleteById(existingId)
        const items = await getAll()
        expect(items).toEqual(fixtures.filter(item => item.id !== existingId))
    })
})

我们期望与updateById函数类似的行为。只有当项目存在于数据库中时,项目才应该从数据库中删除,并且项目不应该从函数中返回。现在,让我们将测试脚本包含到package.json文件中:

{
    "scripts": {
        "start": "node index.js",
        "test": "jest",
        "test:coverage": "jest --coverage"
    }
}

使用npm run test运行测试。你的输出应该类似于以下内容:

图 11.2 – 终端截图

图 11.2 – 终端截图

我们的第一种存储方式已经工作,并且经过了全面测试。现在,让我们通过添加代码检查器来结束。首先,使用npm i -D standard@17安装代码检查器,然后更新package.json中的脚本:

{
    "scripts": {
        "start": "node index.js",
        "test": "jest",
        "test:coverage": "jest --coverage",
        "lint": "standard",
        "lint:fix": "standard --fix"
    }
}

现在,你可以使用npm run lint运行代码检查器并使用npm run lint:fix修复错误。有时你可能需要手动修复错误,但大多数情况下,代码检查器会为你修复它们。

添加静态文件

现在,让我们将静态文件添加到应用程序中。基本上,在public文件夹中,我们有几个想要提供给客户端的文件,例如index.htmlstyle.cssapp.js文件。因此,让我们将以下代码添加到server.js文件中:

const app = express()
app.use(express.static('public'))
app.use(bodyParser.json())

现在,如果我们使用npm run start启动服务器,然后你访问http://localhost:3000/styles.css,你将看到styles.css文件的内容。

注意

在我们完成本章之前,URL http://localhost:3000可能无法按预期工作,因为后端尚未完成。

添加模板

在这个项目中,我们将使用服务器渲染方法来处理应用程序的某些部分。因此,我们将安装ejs模板引擎:

npm i ejs@3

让我们在server.js文件中导入存储函数:

import express from 'express'
import bodyParser from 'body-parser'
import { getAll, getById, create, updateById, deleteById } from './store.js'
const app = express()

然后,在server.js文件中注册模板引擎:

app.use(bodyParser.json())
app.set('view engine', 'ejs')
app.get('/api/v1/whisper', async (req, res) => {
    const whispers = await getAll()
    res.json(whispers)
})

最后,我们将创建一个路由,about,它将渲染views/about.ejs模板,并将 whispers 提供给模板:

app.set('view engine', 'ejs')
app.get('/about', async (req, res) => {
    const whispers = await getAll()
    res.render('about', { whispers })
})
app.get('/api/v1/whisper', async (req, res) => {/*...*/})

现在,让我们使用npm run start启动服务器,并访问http://localhost:3000/about,你将看到渲染的模板。

信息

如果你在按照本章步骤运行项目时遇到问题,或者你尝试了另一种方法,你可以使用本章开头下载的源代码中的step1文件夹来比较和更容易地修复可能的错误。

在下一节中,我们将通过添加测试来继续构建 REST API。向 REST API 添加测试非常重要,因为它将确保 API 按预期工作,并允许我们在下一章中更容易地迭代它。

使用 supertest 进行测试

现在是确保我们的 REST API 按预期工作的时候了。在本节中,我们将学习如何在使用 Express 时构建坚实的测试。

将存储添加到服务器

我们将重构每个路由以使用存储函数。让我们从GET /api/v1/whisper路由开始:

app.get('/api/v1/whisper', async (req, res) => {
    const whispers = await getAll()
    res.json(whispers)
})

基本上,我们使用getAll函数获取所有 whispers,并在响应中返回它们。现在,让我们重构GET /api/v1/whisper/:id路由:

app.get('/api/v1/whisper/:id', async (req, res) => {
    const id = parseInt(req.params.id)
    const whisper = await getById(id)
    if (!whisper) {
        res.sendStatus(404)
    } else {
        res.json(whisper)
    }
})

在这种情况下,如果 whisper 不存在,我们将返回 404 状态码,如果存在,我们将返回 whisper。现在,让我们重构POST /api/v1/whisper路由:

app.post("/api/v1/whisper", async (req, res) => {
  const { message } = req.body;
  if (!message) {
    res.sendStatus(400);
  } else {
    const whisper = await create(message);
    res.status(201).json(whisper);
  }
});

在这种情况下,我们验证请求体中的消息不为空,在这些情况下返回 400 状态码。如果创建成功,我们返回 whisper 详细信息。现在,让我们重构PUT /api/v1/whisper/:id路由:

app.put('/api/v1/whisper/:id', async (req, res) => {
    const { message } = req.body
    const id = parseInt(req.params.id)
    if(!message) {
        res.sendStatus(400)
    } else {
        const whisper = await getById(id);
        if (!whisper) {
            res.sendStatus(404);
        } else {
            await updateById(id, message);
            res.sendStatus(200);
        }
    }
POST /api/v1/whisper and we validate that the whisper exists before updating it. Now, let’s refactor the DELETE /api/v1/whisper/:id route:

app.delete('/api/v1/whisper/:id', async (req, res) => {

const id = parseInt(req.params.id)

const whisper = await getById(id)

if(!whisper) {

res.sendStatus(404)

return

}

await deleteById(id)

res.sendStatus(200)

})


			In this case, we validate that the whisper exists before deleting it. Now, let’s add the tests for the routes.
			Creating test utils
			Before we start adding the tests, there is some code that we can reuse between the test files, such as the fixtures and the functions to populate and restore the database. So, let’s do a little refactoring first.
			As the first step, let’s create a file called `fixtures.js` in the `tests` folder, and let’s add the following content:

const whispers = [{ id: 1, message: 'test' }, { id: 2, message: 'hello world' }]

const inventedId = 12345

const existingId = whispers[0].id

导出 {

whispers,

inventedId,

existingId

}


			Then, create a file called `utils.js` in the `tests` folder, and let’s add the following content:

从 'node:fs' 导入 { writeFileSync }

从 'node:path' 导入 { join }

const dbPath = join(process.cwd(), 'db.json')

const restoreDb = () => writeFileSync(dbPath, JSON.stringify([]))

const populateDb = (data) => writeFileSync(dbPath, JSON.stringify(data))

导出 { restoreDb, populateDb }


			Now, let’s refactor the `store.test.js` file to use the new files:

从 '../store.js' 导入 { getAll, getById, create, updateById, deleteById }

从 './utils.js' 导入 { restoreDb, populateDb }

import { whispers, inventedId, existingId } from './fixtures.js'

describe('store', () => {

//...

})


			Also, find and replace the `fixtures` variable with `whispers` in the tests.
			Now you can run the tests with `npm run test` and you will see that the tests are passing:
			![Figure 11.3 – Terminal screenshot](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/node-bgn/img/B21678_11_3.jpg)

			Figure 11.3 – Terminal screenshot
			Adding server tests
			Now, let’s add the tests for the routes. In this case, we will use supertest ([`www.npmjs.com/package/supertest`](https://www.npmjs.com/package/supertest)) to test the routes. As the first step, let’s install the new dependency:

npm i -D supertest@6


			Defining the tests
			You can use `it.todo` to mark the tests that you need to add. This way, you can focus on the description of the tests and not on the implementation details. So, let’s create the `tests/server.test.js` file with the following content:

import supertest from 'supertest'

import { app } from '../server'

import { restoreDb, populateDb } from './utils.js'

import { whispers, inventedId, existingId } from './fixtures.js'

import { getById } from '../store'

describe('Server', () => {

beforeEach(() => populateDb(whispers))

afterAll(restoreDb)

describe("GET /api/v1/whisper", () => {

it.todo("当没有数据时,应返回空数组")

it.todo("应返回所有 whisper")

})

describe("GET /api/v1/whisper/:id", () => {

it.todo("当 whisper 不存在时,应返回 404")

it.todo("应返回 whisper 详情")

})

describe("POST /api/v1/whisper", () => {

it.todo("当 body 为空时,应返回 400")

it.todo("当 body 无效时,应返回 400")

it.todo("当 whisper 被创建时,应返回 201")

})

describe("PUT /api/v1/whisper/:id", () => {

it.todo("当 body 为空时,应返回 400")

it.todo("当 body 无效时,应返回 400")

it.todo("当 whisper 不存在时,应返回 404")

it.todo("当 whisper 被更新时,应返回 200")

})

describe("DELETE /api/v1/whisper/:id", () => {

it.todo("当 whisper 不存在时,应返回 404")

it.todo("当 whisper 被删除时,应返回 200")

})

})


			Run the tests with the `npm run` `test` command:
			![Figure 11.4 – Terminal screenshot](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/node-bgn/img/B21678_11_4.jpg)

			Figure 11.4 – Terminal screenshot
			You will see that the previous tests pass, and the new tests are marked as *todo*. This is a good practice to keep track of the tests that you need to add, and it does not break the test suite.
			Adding the tests with supertest
			Basically, we will use supertest to make requests to the server and we will validate the response. Let’s start with the `GET /api/v1/whisper` route. Let’s replace the `it.todo` tests with the following code:

describe("GET /api/v1/whisper", () => {

it("当没有数据时,应返回空数组", async () => {

await restoreDb() // 清空数据库

const response = await supertest(app).get("/api/v1/whisper")

expect(response.status).toBe(200)

expect(response.body).toEqual([])

})

it("应返回所有 whisper", async () => {

const response = await supertest(app).get("/api/v1/whisper")

expect(response.status).toBe(200)

expect(response.body).toEqual(whispers)

})

})


			In each request, we check that the status code and the response payload are correct. Now, let’s add the tests for the `GET /``api/v1/whisper/:id` route:

describe("GET /api/v1/whisper/:id", () => {

it("当 whisper 不存在时,应返回 404", async () => {

const response = await supertest(app).get(/api/v1/whisper/${inventedId})

expect(response.status).toBe(404)

})

it("应返回 whisper 详情", async () => {

const response = await supertest(app).get(/api/v1/whisper/${existingId})

expect(response.status).toBe(200)

expect(response.body).toEqual(whispers.find(w => w.id === existingId))

})

})


			As you can see, the tests are very similar to the ones we did for `storage.test.js`. Now, let’s add the tests for the `POST /``api/v1/whisper` route.
			We will start by adding the parent description for the route:

describe("POST /api/v1/whisper", () => {

// it("....")

})


			All the tests will be added inside the `describe` function. So, let’s define all the scenarios that we want to cover in the tests:
			We want to be sure that we return a 400 status code when the request does not include a body:

it("当 body 为空时,应返回 400",

async () => {

const response = await supertest(app)

.post("/api/v1/whisper")

.send({})

expect(response.status).toBe(400)

})


			We want to be sure that we return a 400 status code when the request does not include a proper body, for example, when some required properties are missing:

it("当 body 无效时,应返回 400",

async () => {

const response = await supertest(app)

.post("/api/v1/whisper")

.send({invented: "This is a new whisper"})

expect(response.status).toBe(400)

})


			We want to be sure that we return a 201 status and the details of the new whisper when the payload in the request is correct. Also, we want to check that the whisper was properly stored in the database:

it("当 whisper 被创建时,应返回 201",

async () => {

const newWhisper = {

id: whispers.length + 1,

message: "This is a new whisper"

}

const response = await supertest(app)

.post("/api/v1/whisper")

.send({message: newWhisper.message})

// HTTP 响应

expect(response.status).toBe(201)

expect(response.body).toEqual(newWhisper)

// 数据库变更

const storedWhisper = await getById(newWhisper.id)

expect(storedWhisper).toStrictEqual(newWhisper)

})


			As you can see, when we created a new whisper we also validated that the whisper was added to the database. This is because these tests are integration tests and we want to make sure that the changes are recorded in the *database* as well.
			Now, let’s add the tests for the `PUT /api/v1/whisper/:id` route. We will start by adding the parent description for the route:

describe("PUT /api/v1/whisper/:id", () => {

// it("....")

})


			All the tests will be added inside the `describe` function. So, let’s define all the scenarios that we want to cover in the tests:
			We want to be sure that we return a 400 status code when the request does not include a body:

it("当 body 为空时,应返回 400",

async () => {

const response = await supertest(app)

.put(/api/v1/whisper/${existingId})

.send({})

expect(response.status).toBe(400)

})


			We want to be sure that we return a 400 status code when the request does not include a proper body, for example, when some required properties are missing:

it("当 body 无效时,应返回 400",

async () => {

const response = await supertest(app)

.put(/api/v1/whisper/${existingId})

.send({invented: "这是一个新字段"})

expect(response.status).toBe(400)

})


			We want to be sure that we return a 404 status code when the request is targeting a non-existent whisper:

it("当 whisper 不存在时,应返回 404",

async () => {

const response = await supertest(app)

.put(/api/v1/whisper/${inventedId})

.send({message: "Whisper updated"})

expect(response.status).toBe(404)

})


			We want to be sure that we return a 200 status when the payload and the target are correct. Also, we want to check that the whisper was properly updated in the database:

it("当 whisper 更新时,应返回 200",

async () => {

const response = await supertest(app)

.put(/api/v1/whisper/${existingId})

.send({message: "Whisper updated"})

expect(response.status).toBe(200)

// 数据库变更

const storedWhisper = await getById(existingId)

expect(storedWhisper).toStrictEqual({id: existingId, message: "Whisper updated"})

})


			Finally, let’s add the tests for the `DELETE /api/v1/whisper/:id` route. We will start by adding the parent description for the route:

describe(" DELETE /api/v1/whisper/:id", () => {

// it("....")

})


			All the tests will be added inside the `describe` function. So, let’s define all the scenarios that we want to cover in the tests:
			We want to be sure that we return a 404 status code when the request is targeting a non-existent whisper:

it("当 whisper 不存在时,应返回 404",

const response = await supertest(app)

.delete(/api/v1/whisper/${inventedId})

expect(response.status).toBe(404)

})


			We want to be sure that we return a 200 status code when the request is targeting a valid whisper. Also, we want to check that the whisper was properly removed from the database:

it("当 whisper 被删除时,应返回 200",

const response = await supertest(app)

.delete(/api/v1/whisper/${existingId})

expect(response.status).toBe(200)

// 数据库变更

const storedWhisper = await getById(existingId)

expect(storedWhisper).toBeUndefined()

})


			Now, you can run the tests with `npm run test` and you will see that the tests are passing:
			![Figure 11.5 – Terminal screenshot](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/node-bgn/img/B21678_11_5.jpg)

			Figure 11.5 – Terminal screenshot
			Information
			If you are having issues running the project in this chapter while following the steps, or you tried an alternative approach, you can use the `step2` folder from the source code that you downloaded at the beginning of the chapter to compare and fix possible bugs more easily.
			In the next section, we will review the final result and we will see how to use the application and what we are planning to do in the next chapters.
			Reviewing the final result of the project
			At this point, you should have a fully functional REST API with Express and if your tests are passing, you can start using the application.
			The about page
			If you go to `http://localhost:3000/about`, you will see the about page:
			![Figure 11.6 – Web browser screenshot](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/node-bgn/img/B21678_11_6.0.jpg)

			Figure 11.6 – Web browser screenshot
			This page was served using the server render approach, and we are using the EJS template engine to render the page. We are using the whispers data from the database to render the page. The text *Currently there are 3 whispers available* is dynamic text that will change depending on the number of whispers in the database.
			You can see the reference in the `views/about.ejs` file:

当前有 <%= whispers.length %> 个 whispers 可用


			Web interface
			The web interface is a simple page where you can create, update, and delete whispers. You can access the web interface at `http://localhost:3000`. It will start with an empty list of whispers. In my case, I have three whispers in the database, so I will see the following page:
			![Figure 11.7 – Web browser screenshot](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/node-bgn/img/B21678_11_7.0.jpg)

			Figure 11.7 – Web browser screenshot
			In order to make the frontend source code more readable, I used plain JavaScript to make the requests to the API and old browser APIs such as `prompts` and `confirms` to interact with the user. You can see the source code in the `public/app.js` file. For a production application, you should avoid these browser APIs as they are quite limited and implement a solution that works on all devices using UI elements that are properly integrated. Also, you will need to handle errors and loading states. For larger projects, it is quite common to use UI libraries such as tailwind ([`tailwindcss.com/`](https://tailwindcss.com/)) or frameworks such as Vue ([`vuejs.org/`](https://vuejs.org/)).
			Adding whispers
			It is possible to add whispers to the list. Just click on the **Spread a whisper** button and you will see a prompt asking for the message of the whisper:
			![Figure 11.8 – Web browser screenshot](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/node-bgn/img/B21678_11_8.0.jpg)

			Figure 11.8 – Web browser screenshot
			Editing whispers
			It is possible to edit whispers. Just click on the pencil button and you will see a prompt asking for the new message of the whisper:
			![Figure 11.9 – Web browser screenshot](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/node-bgn/img/B21678_11_9.0.jpg)

			Figure 11.9 – Web browser screenshot
			Deleting whispers
			It is possible to delete whispers. Just click on the trash button and you will see a confirm dialog asking for the confirmation:
			![Figure 11.10 – Web browser screenshot](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/node-bgn/img/B21678_11_10.jpg)

			Figure 11.10 – Web browser screenshot
			Your challenge
			If you are familiar with frontend development, you can try to improve the web interface and make it more user-friendly or directly replace it with a modern frontend framework such as React, Vue, or Angular. If you are not familiar with front-end development, you can skip this challenge and continue with the next chapter.
			Let’s celebrate it!
			Feel free to explore the code and play with it, you can start the application with `npm run start` and you can go to `http://localhost:3000` and create a few whispers that you can later edit or remove from the web interface.
			Next steps
			Congratulations! You have created a solid REST API, but there are a lot of things that you can do to improve it. In the next chapter, we will see how to properly store the information in the database.
			In *Chapter 13*, we will see how to add authentication to the API, so only authenticated users can create, update, or delete whispers and multiple users will be able to use our application.
			Summary
			In this chapter, we learned how to use supertest to test our API in depth. We learned how to test the routes and how to test the stores. We created a solid API that we will evolve in the next chapters.
			In the next chapter, we will see how to properly store the information in the database, using MongoDB. We will take the opportunity to refactor our project and use a better software pattern to organize the code and a MongoDB database to store the data.

第十二章:使用 MongoDB 实现数据持久化

在本章中,我们将解释 MongoDB 的工作原理以及为什么它是 Web 应用程序的绝佳起点。我们将学习如何使用带有 Docker 和 Docker Compose 的容器在本地安装 MongoDB,以及如何使用外部 MongoDB 实例。

我们将探讨如何使用 Mongoose 与 MongoDB 交互,并将我们的应用程序迁移到使用 MongoDB 而不是 JSON 文件,我们将使用测试来确保迁移正确完成,并且没有引入任何回归。

总结来说,以下是本章我们将探讨的主要主题:

  • 如何使用 Docker 和 Docker Compose 在本地设置 MongoDB

  • 如何使用 对象关系映射ORM)库如 Mongoose 与 MongoDB 交互

  • 如何将我们的应用程序迁移到使用 MongoDB 而不是 JSON 文件

  • 如何使用 MongoDB 测试任何应用程序

  • 如何使用环境变量存储敏感信息以及如何在 Node.js 中加载它们

到本章结束时,你将能够舒适地在 Node.js 项目中使用 MongoDB,并且将了解如何使用测试来规划更复杂的功能,例如数据库迁移。

技术要求

本章的代码文件可以在 github.com/PacktPublishing/NodeJS-for-Beginners 找到。

查看本章动作视频中的代码 youtu.be/0CHOQ35c-_Y

要开始本章的工作,我们需要从 github.com/PacktPublishing/NodeJS-for-Beginners/archive/refs/heads/main.zip 下载项目并访问 step2 文件夹。

什么是 MongoDB?

如果你熟悉关系型数据库,你会发现 MongoDB 非常不同。MongoDB 是一个面向文档的数据库,这意味着它以文档的形式存储数据而不是表格。文档是一组键值对,它是 MongoDB 中的基本数据单元。文档类似于 JSON 对象,并且存储在集合中。集合是一组具有相同结构的文档。在 MongoDB 中,文档以 二进制 JSONBSON)的形式存储,这是 JSON 文档的二进制表示。

图 12.1 – 将 SQL 数据结构与 MongoDB 数据结构进行比较

图 12.1 – 将 SQL 数据结构与 MongoDB 数据结构进行比较

在前面的图中,我们可以更清楚地看到关系型数据库和面向文档的数据库之间的区别。

版本

MongoDB 有几个版本,但最受欢迎的是 MongoDB 社区服务器。在我们的项目中,我们也将使用 MongoDB 社区服务器,这对我们来说没有额外的成本。

第十六章 中,当我们将应用程序部署到云端时,我们将探索更多 MongoDB 的版本。

如果您想了解更多关于 MongoDB 不同版本的信息,您可以查看以下链接:www.mongodb.com/try/download/community

在下一节中,我们将解释如何使用 Docker 和 Docker Compose 在容器中本地安装 MongoDB,以及如何使用外部 MongoDB 实例。

设置 MongoDB

安装 MongoDB 有几种方法,但我们将使用 Docker Compose 在本地安装它。Docker Compose 是一个用于定义和运行多容器 Docker 应用程序的工具。使用 Docker Compose,我们将能够在不同的容器中运行 MongoDB 和我们的 Web 应用程序。如果您不熟悉 Docker,MongoDB 提供了一个出色的指南 (www.mongodb.com/compatibility/docker),可以帮助您更深入地了解。

安装 Docker

如果您尚未安装 Docker,您可以按照您操作系统的说明在以下链接中操作:docs.docker.com/get-docker/

检查安装

让我们检查 Docker 是否已正确安装。打开终端并运行以下命令:

docker --version

您应该看到已安装的版本 - 在我的情况下,是 24.0.2:

Docker version 24.0.2, build cb74dfc

我们还可以检查 Docker Compose 是否已正确安装。打开终端并运行以下命令:

docker-compose --version

您应该看到类似以下内容:

Docker Compose version v2.19.1

使用容器运行 MongoDB

Docker 的美妙之处在于我们可以在容器中运行 MongoDB。容器是一个标准的软件单元,它将代码及其所有依赖项打包在一起。这样,我们就可以创建一个 MongoDB 容器,并在我们的本地机器上运行它,我们不需要在本地安装 MongoDB。当我们不再需要容器时,我们可以停止它并删除它。

在我们的情况下,我们将使用 Mongo 7.0.0,这是 MongoDB 的最新版本。我们将使用 MongoDB 的官方镜像,该镜像可在 Docker Hub 上找到。您可以在以下链接中找到有关此镜像的更多信息:hub.docker.com/_/mongo

要在容器中运行 MongoDB,我们将使用以下命令:

docker run --name whispering-database -p 27017:27017 -d mongo:7.0.0

此命令将创建一个名为 whispering-database 的容器,并将容器中的端口 27017 映射到主机机的端口 27017-d 标志表示容器将在后台运行。

输出应该类似于以下内容:

Unable to find image 'mongo:7.0.0' locally
7.0.0: Pulling from library/mongo
99de9192b4af: Pull complete
18b9e63943e7: Pull complete
ccf1fde52048: Pull complete
8317989437cb: Pull complete
1bde6bf8acc1: Pull complete
11fb005be9eb: Pull complete
81a254c162fc: Pull complete
2a574922bf90: Pull complete
22659e13b0a2: Pull complete
Digest: sha256:a89d79ddc5187f57b1270f87ec581b7cc6fd697efa12b8 f1af72f3c4888d72b5
Status: Downloaded newer image for mongo:7.0.0
27ead2313a72c0cb0d2d1bf18ef2a37062a63851ebc9355359dbc1a4741ac168

如输出所示,本地未找到镜像,因此它从 Docker Hub 下载。如果端口 2701 已被占用,您可能会遇到错误,因为容器无法接管。您可以通过以下步骤轻松检查:kb.vmware.com/s/article/1003971。如果一切顺利,容器将在后台运行,因此我们可以使用以下命令检查它是否正在运行:

docker ps

输出应该类似于以下内容:

CONTAINER ID   IMAGE         COMMAND                  CREATED         STATUS         PORTS                      NAMES
7d28f8c555b9   mongo:7.0.0   "docker-entrypoint.s…"   7 seconds ago   Up 6 seconds   0.0.0.0:27017->27017/tcp   whispering-database

您可以使用以下命令停止容器:

docker stop whispering-database

您可以使用以下命令删除容器:

docker rm whispering-database

如果您删除了容器,您可以使用以下命令再次创建一个新的容器:

docker run --name whispering-database -p 27017:27017 -d mongo:7.0.0

使用 Docker Compose 运行 MongoDB

使用容器运行 MongoDB 的另一种选择是使用 Docker Compose。Docker Compose 是一个工具,用于使用 YAML 文件定义和运行多容器 Docker 应用程序。使用 Docker Compose 的一个优点是,我们可以在 YAML 文件中定义容器的配置,这样我们就不必记住运行容器的命令。

让我们为我们的项目创建一个包含以下内容的 docker-compose.yml 文件:

version: '3.8'
services:
  database:
    container_name: whispering-database
    image: mongo:7.0
    ports:
      - '27017:27017'
    volumes:
      - db-storage:/data/db
volumes:
  db-storage:

在此文件中,我们定义了一个名为 database 的服务,它使用 mongo:7.0 镜像。我们还映射了容器的端口 27017 到主机的端口 27017。最后,我们定义了一个名为 db-storage 的卷,它将用于存储数据库的数据,这样我们在停止容器时就不会丢失它。

为了在后台运行容器,我们必须运行以下命令:

docker-compose up -d

输出应该类似于以下内容:

[+] Running 1/1
✓ database Pulled                         1.8s
[+] Running 3/3
✓ Network app_default       Created     0.1s
✓ Volume "app_db-storage"   Created     0.0s
✓ Container app-database-1  Started     0.5s

您的容器现在已准备好使用,但您可以通过在相同文件夹中运行以下命令来停止它们:

docker-compose down

在下一节中,我们将学习如何将 Docker 相关的命令作为 npm 脚本添加到 package.json 中。

将 Docker 命令添加到 package.json

有时,记住 docker compose 命令可能会有点困难,因此我们可以将它们添加到 package.json 文件中。添加以下脚本:

"scripts": {
    "start": "node index.js",
    "test": "jest",
    "test:coverage": "jest --coverage",
    "lint": "standard",
    "lint:fix": "standard --fix",
    "infra:start": "docker-compose up -d --build",
    "infra:stop": "docker-compose down --remove-orphans"
}

然后,我们可以使用 npm run infra:startnpm run infra:stop 在本地机器上管理项目数据库。

连接到 MongoDB

连接到 MongoDB 有两种方式——使用 mongo shell 或端口 27017。在本节中,我们将解释如何使用这两种方式连接到 MongoDB。

我们可以使用以下命令通过 mongo shell 连接到 MongoDB,如果我们使用 Docker 的话:

npm run infra:start
docker exec -it whispering-database /bin/bash

现在,您可以看到我们已经在容器内部,作为替代,您可以直接使用 docker compose 命令来访问容器 docker-compose exec database /bin/bash。现在,我们可以使用以下命令连接到 MongoDB:

mongod

您应该看到类似以下的内容:

root@7d515e1c8f85:/# mongod
{"t":{"$date":"2023-08-19T13:45:08.554+00:00"},"s":"I",  "c":"CONTROL",  "id":23285,   "ctx":"main","msg":"Automatically disabling TLS 1.0, to force-enable TLS 1.0 specify --sslDisabledProtocols 'none'"}
{"t":{"$date":"2023-08-19T13:45:08.556+00:00"},"s":"I",  "c":"NETWORK",  "id":4915701, "ctx":"main","msg":"Initialized wire specification","attr":{"spec":{"incomingExternalClient":{"minWire Version":0,"maxWireVersion":21},"incomingInternalClient":{"minWire Version":0,"maxWireVersion":21},"outgoing":{"minWireVersion":6,"maxWire Version":21},"isInternalClient":true}}}

这样,如果需要,我们可以直接访问 mongo shell。在接下来的章节中,我们将解释如何通过端口 27017 连接到 MongoDB。

安装 MongoDB 的其他方法

如果您不想使用 Docker Compose,您可以在本地安装 MongoDB。您可以在以下链接找到您操作系统的说明:docs.mongodb.com/manual/administration/install-community/

请记住,您还可以使用 MongoDB Atlas (www.mongodb.com/atlas) 或任何提供 MongoDB 服务的其他云提供商。

现在我们已经启动了 MongoDB,我们可以开始使用它,但首先,我们需要了解如何在 Node.js 中使用秘密,以便我们可以以安全模式将连接字符串传递给应用程序。因此,在下一节中,我们将解释如何在 Node.js 中加载秘密。

如何在 Node.js 中加载秘密

我们的应用程序需要连接到 MongoDB,因此我们需要将连接字符串存储在安全的地方。你不应该在代码中存储秘密;一个非常常见的做法是将它们存储在环境变量中。在本节中,我们将解释如何在 Node.js 中从环境变量中加载秘密。

环境变量

环境变量是在进程运行的环境中设置的变量。它们通常在操作系统中设置,但我们也可以在终端中设置它们。我们可以在 Node.js 中使用process.env对象访问环境变量:

console.log(process.env.MY_SECRET)

你可以使用以下命令在终端中设置环境变量:

export MY_SECRET=secret

然后,你可以使用以下命令运行你的应用程序:

node index.js

或者,你可以在同一命令中设置环境变量:

MY_SECRET=secret node index.js

重要提示

如果你使用的是 Windows,你可能需要使用不同的方法在终端中处理环境变量。阅读(www3.ntu.edu.sg/home/ehchua/programming/howto/Environment_Variables.html)获取更多信息。

在下一节中,我们将学习如何使用.env文件以更便捷的方式管理秘密。

.env文件

虽然在终端中直接使用环境变量是一个非常常见的做法,但它并不方便。我们可以使用一个名为.env的文件来存储我们的环境变量。我们可以创建一个包含以下内容的.env文件:

MY_SECRET=secret

然后,我们可以使用dotenv包(www.npmjs.com/package/dotenv)从.env文件中加载环境变量,但值得一提的是,Node.js 20.6.0 版本引入了对从.env文件加载环境变量的支持,因此我们不再需要使用第三方包了(github.com/nodejs/node/releases/tag/v20.6.0)。

警告

我们永远不应该将.env文件提交到仓库,因为它包含秘密。你可以将.env文件添加到.gitignore文件中,以避免将.env 文件与项目源代码一起提交。

dotenv

.env文件加载环境变量的最常见方法是使用dotenv包(www.npmjs.com/package/dotenv)。我们可以使用以下命令安装它:

npm install dotenv@16

然后,我们可以使用以下代码从.env文件中加载环境变量:

import 'dotenv/config'

或者,我们可以直接使用--require标志来执行:

node --require dotenv/config index.js

在下一节中,我们将解释如何使用 对象关系映射ORM)与 MongoDB 交互,以及这如何使我们在第一次构建 Web 应用程序时生活更加轻松。

使用 ORM – Mongoose

我们可以直接使用 MongoDB,但这将需要更深入的理解和更多的代码来与数据库交互。由于本书的目的是学习 Node.js,我们将使用 ORM 来与 MongoDB 交互。ORM 是一个库,它允许我们使用对象而不是 SQL 查询来与数据库交互。在本节中,我们将使用 Mongoose (mongoosejs.com/)。或者,您也可以使用 MongoDB Node.js 驱动程序,这是 Node.js 的官方 MongoDB 驱动程序 (docs.mongodb.com/drivers/node/)。官方文档可以在 mongoosejs.com/docs/guide.html 找到。

Mongoose 提供了几个对 Web 应用程序来说非常方便的功能:

  • 模式验证:我们可以定义文档的模式,Mongoose 会在将其保存到数据库之前验证数据

  • 模型:我们可以为每个集合定义一个模型,并使用它来与数据库交互

  • 中间件:我们可以定义在特定事件之前或之后执行的中介函数 – 例如,我们可以定义一个在将文档保存到数据库之前执行的中介函数

  • 插件:我们可以使用插件来扩展 Mongoose 的功能

此外,如果你是 Node.js 或 MongoDB 的初学者,你会发现 Mongoose 比直接使用 MongoDB 更容易使用,而且有很多教程和资源可以帮助你快速熟悉它。

信息

Mongo 拥有一个庞大的生态系统,一开始可能会有些令人感到不知所措,但你可以在 github.com/ramnes/awesome-mongodb 找到精心挑选的 MongoDB 资源列表。

现在我们已经运行了 MongoDB 并熟悉了环境变量,我们可以在项目中开始使用 Mongoose。在下一节中,我们将解释如何从本地文件存储迁移到 MongoDB。

将 Web 应用程序迁移到 MongoDB

我们已经使用 Docker Compose 和 npm 命令将 MongoDB 添加到我们的项目中,但我们还没有开始使用它。在本节中,我们将迁移一个 Web 应用程序到 MongoDB。

安装依赖项

我们将安装以下依赖项:

npm install mongoose@7.4 dotenv@16

管理秘密

我们将创建一个包含以下内容的 .env 文件:

MONGODB_URI=mongodb://localhost:27017/whispering-database
PORT=3000

然后,我们将使用以下代码将环境变量从 .env 文件加载到 index.js 中:

import { app } from './server.js'
import mongoose from 'mongoose'
const port = process.env.PORT
try {
  await mongoose.connect(process.env.MONGODB_URI);
  console.log('Connected to MongoDB')
  app.listen(port, () => {
    console.log(`Running in http://localhost:${port}`)
  })
} catch (error) {
  console.error(error)
}

我们已经包含了 mongoose 包,并使用 MONGODB_URI 环境变量连接到 MongoDB。我们还包含了 PORT 环境变量,以便在不同的端口上运行应用程序。

注意

如您所见,在打开 HTTP 服务器连接之前,数据库必须正在运行。这是因为我们需要连接到数据库以检索对 HTTP 请求的响应所需的信息。

现在,我们需要更新 npm 脚本来使用 dotenv

"scripts": {
    "start": "node --require dotenv/config index.js",
    "test": "jest --setupFiles dotenv/config",
    "test:coverage": "jest --coverage --setupFiles dotenv/config",
    "lint": "standard",
    "lint:fix": "standard --fix",
    "infra:start": "docker-compose up -d --build",
    "infra:stop": "docker-compose down"
}

现在,我们可以使用以下命令运行应用程序:

npm run infra:start
npm run start

我们应该看到以下输出:

Connected to MongoDB
Running in http://localhost:3000

如果数据库没有运行,我们将看到类似的错误:

MongooseServerSelectionError: connect ECONNREFUSED ::1:27017, connect 
ECONNREFUSED 127.0.0.1:27017
    at _handleConnectionErrors (node_modules/mongoose/lib/connection.js:788:11)
    at NativeConnection.openUri (node_modules/mongoose/lib/connection.js:763:11)
    at async file:///index.js:7:4 {
  reason: TopologyDescription {
    type: 'Unknown',
    servers: Map(1) { 'localhost:27017' => [ServerDescription] },
    stale: false,
    compatible: true,
    heartbeatFrequencyMS: 10000,
    localThresholdMS: 15,
    setName: null,
    maxElectionId: null,
    maxSetVersion: null,
    commonWireVersion: 0,
    logicalSessionTimeoutMinutes: null
  },
  code: undefined
}

基本上,它告诉我们无法连接到数据库;您可以通过运行以下命令生成相同的错误:

npm run infra:stop
npm run start

在下一节中,我们将开始对数据层进行迁移。

迁移数据层

我们希望重构 store.js 文件以使用 MongoDB 而不是 JSON 文件。为了保持简单,我们将把模式和模型添加到同一个文件中,但当我们介绍认证时,这可以稍后进行更改。

被认为是一种良好的实践,将数据库相关的代码封装在特定的文件中,其理念是提供一个接口,该接口可以在将来被代码的其他部分用来对数据层进行更改,而无需了解数据层在底层是如何实现的。这种抽象是一种非常流行的解决方案,如果你决定在未来迁移或结合其他存储系统,这将为你提供很多支持。因此,我们将创建一个名为 database.js 的新文件,并在接下来的段落中一起探讨其结构和每个语句所实现的内容。文件内容如下:

import mongoose from 'mongoose'
mongoose.set('toJSON', {
  virtuals: true,
  transform: (doc, converted) => {
    delete converted._id
    delete converted.__v
  }
})
const whisperSchema = new mongoose.Schema({
  message: String
})
const Whisper = mongoose.model('Whisper', whisperSchema)
export {
  Whisper
}

创建 模式

第一步是创建模式,这是我们要存储在数据库中的文档结构的定义。在我们的例子中,我们只有一个名为 message 的字段,它是一个字符串:

const whisperSchema = new mongoose.Schema({
  message: String
})

创建 模型

第二步是创建模型,这是一个我们用来与数据库交互的类。在我们的例子中,我们将使用 Whisper 模型与 whispers 集合交互:

const Whisper = mongoose.model('Whisper', whisperSchema)

转换器

我们必须做的事情之一是从响应中删除 _id__v 字段。我们可以通过使用 toJSON 方法全局更改此行为,这样我们就不必为每个方法都做这件事:

mongoose.set('toJSON', {
  virtuals: true,
  transform: (doc, converted) => {
    delete converted._id;
    delete converted.__v;
  }
});

这意味着我们开始于以下数据结构:

{
  "_id": "5dff03d3218b91425b9d6fab",
  "message": "I love MongoDB!",
  "__v": 0
}

然后,我们继续到以下数据结构:

{
  "id": "5dff03d3218b91425b9d6fab",
  "message": "I love MongoDB!"
}

重构方法

在这次迁移中的关键是要保持相同的接口,这样我们就不必更改我们导出的函数的行为。我们将使用相同的数据 I/O,但我们将使用 Mongoose 与 MongoDB 进行交互:

import {
  Whisper
} from './database.js'
const getAll = () => Whisper.find()
const getById = id => Whisper.findById({ _id: id })
const create = async (message) => {
  const whisper = new Whisper({ message })
  await whisper.save()
  return whisper
}
const updateById = async (id, message) => Whisper.findOneAndUpdate({ _id: id }, { message }, { new: false })
const deleteById = async (id) => Whisper.deleteOne({ _id: id })
export { getAll, getById, create, updateById, deleteById }

如您所见,我们在每个方法(getAllgetByIdcreateupdateByIddeleteById)中保持相同的输入和输出,所以我们不必更改我们导出的函数的行为。

这是我们在上一章中讨论的效果;我们可以更改方法的实现,但不必更改接口。这就是抽象的力量。

因此,即使您将来想更改数据库,您也不必更改方法的界面;您只需更改实现,代码仍然可以工作。这是因为业务逻辑没有与数据库接口耦合。

删除旧的 数据库文件

现在,我们可以删除 db.json 文件,因为我们不再使用它了。

改进 路由

在上一章中,我们使用了数值 ID,只是为了使代码更简单,所以现在我们需要更改路由以使用 MongoDB ID,它们是字母数字字符串。我们只需要从 server.js 文件中移除对 parseInt 的引用。更改是从 parseInt(req.params.id)req.params.id。您甚至可以使用 查找和替换 来更改文件中所有对 parseInt 的引用。

运行 应用程序

在这一点上,您只需运行以下命令来享受迁移:

npm run infra:start
npm run start

如果您访问 http://localhost:3000,您将看到应用程序与 MongoDB 一起工作,而界面没有任何变化。

现在,我们确信应用程序按预期工作,但我们不应该忘记正确测试这些更改。因此,在下一节中,我们将重构测试以使用 MongoDB,并且一旦所有测试都通过(绿色),重构将完成,我们就可以进入下一章。

测试我们的 MongoDB 集成层

是的,我们已经完成了迁移,一切似乎都在正常运行,但我们需要确保测试按预期工作。目前,测试使用文件系统来存储数据,因此我们需要更改测试以使它们使用 MongoDB。

更新工具

我们将编辑 test/utils.js 文件,使用 MongoDB 而不是文件系统。由于我们现在使用 MongoDB,我们需要在数据库中加载 fixtures 以了解 ID。因此,现在 fixtures 将保持相同的结构,但它们将通过 populateDb 和新的 getFixtures 函数存储和收集在数据库中:

import mongoose from 'mongoose'
import {
  Whisper
} from '../database.js'
const ensureDbConnection = async () => {
   try {
        if (mongoose.connection.readyState !== 1) {
            await mongoose.connect(process.env.MONGODB_URI);
        }
    } catch (error) {
        console.error('Error connecting to the database:', error);
        throw error; // Re-throw the error for handling at a higher level
    }
}
const closeDbConnection = async () => {
    if (mongoose.connection.readyState === 1) {
        await mongoose.disconnect()
    }
}
const restoreDb = () => Whisper.deleteMany({})
const populateDb = () => Whisper.insertMany([{ message: 'test' }, { message: 'hello world' }])
const getFixtures = async () => {
    const data = await Whisper.find()
    const whispers = JSON.parse(JSON.stringify(data))
    const inventedId = '64e0e5c75a4a3c715b7c1074'
    const existingId = data[0].id
    return { inventedId, existingId, whispers }
}
const normalize = (data) => JSON.parse(JSON.stringify(data))
export { restoreDb, populateDb, getFixtures, ensureDbConnection, normalize, closeDbConnection }

现在,我们可以删除 test/fixtures.js 文件,因为我们不再使用它了。

重构测试套件

所以到目前为止,我们的测试比实际需要的更多。我们可以删除特定于 stores 的测试,因为它们已经被集成测试覆盖,并且我们可以删除 test/store.test.js 文件。

作为迁移的一部分,我们需要在测试准备执行的方式上做一些更改。由于数据库是一个外部服务,在执行测试之前,我们需要控制某些方面。例如,在执行任何测试之前,我们需要一个有效的数据库连接,因为这可能是测试失败的原因,但它与我们正在测试的代码无关。此外,我们需要确保数据库中存储了特定的数据,这样我们的测试就可以独立多次执行,而不会因为我们在数据库中做出的更改而污染执行上下文。这可以通过在执行任何特定测试之前添加某些步骤来实现,例如使用beforeAllbeforeEachafterAllafterEach方法,这些方法是我们可用的 Jest 方法的一部分。现在,让我们更新测试以使用新函数。我们将更新test/server.test.js文件以使用新函数:

import supertest from 'supertest'
import { app } from '../server'
import { getById } from '../store.js'
import { restoreDb, populateDb, getFixtures,
ensureDbConnection, normalize, closeDbConnection } from './utils.js'
let whispers
let inventedId
let existingId
describe('Server', () => {
  beforeAll(ensureDbConnection)
  beforeEach(async () => {
    await restoreDb()
    await populateDb(whispers)
    const fixtures = await getFixtures()
    whispers = fixtures.whispers
    inventedId = fixtures.inventedId
    existingId = fixtures.existingId
  })
  afterAll(closeDbConnection)
  //... unchanged tests
})

在下一节中,我们将完成测试套件案例的更新,因为 MongoDB 在测试上下文中查询数据时引入了一些差异,我们需要注意这些差异。

一些测试必须更改

为了保持简单,对于本书的范围,一些测试必须更改。所有使用 store 的测试都将按以下方式重构。

在创建或更新 whispers 时,我们将检查数据库以确认 whispers 是否正确存储。为了正确比较数据,我们将使用normalize函数。这样,我们就可以在不比较_id__v字段的情况下,以规范化的方式比较数据,就像我们在发送 HTTP 响应时将数据转换为 JSON 时做的那样:

it('Should return a 201 when the whisper is created', async () => {
    const newWhisper = { message: 'This is a new whisper' }
    const response = await supertest(app)
    .post('/api/v1/whisper')
    .send({ message: newWhisper.message })
    expect(response.status).toBe(201)
    expect(response.body.message).toEqual(newWhisper.message)
    // Database changes
    const storedWhisper = await getById(response.body.id)
    expect(normalize(storedWhisper).message).toStrictEqual(newWhisper.message)
})
it('Should return a 200 when the whisper is updated', async () => {
    const response = await supertest(app)
    .put(`/api/v1/whisper/${existingId}`)
    .send({ message: 'Whisper updated' })
    expect(response.status).toBe(200)
    // Database changes
    const storedWhisper = await getById(existingId)
    expect(normalize(storedWhisper)).toStrictEqual({ id: existingId, message: 'Whisper updated' })
})

当删除 whisper 时,我们需要检查 whisper 是否不再在数据库中。之前,我们检查数据库在未找到时返回undefined;使用 MongoDB,我们将得到null,因此我们需要按以下方式更改测试:

it('Should return a 200 when the whisper is deleted', async () => {
    const response = await supertest(app).delete(`/api/v1/whisper/${existingId}`)
    expect(response.status).toBe(200)
    // Database changes
    const storedWhisper = await getById(existingId)
    expect(storedWhisper).toBe(null)
})

由于我们已经完成了测试的重构,现在是审查测试覆盖率的好时机。在本节中,我们将详细审查这一点。

检查覆盖率

现在,我们可以运行测试并检查覆盖率:

npm run infra:start
npm run test:coverage

输出应该类似:

--------------|---------|----------|---------|---------|-------------------
File          | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------|---------|----------|---------|---------|-------------------
All files     |   97.43 |    85.71 |   94.44 |   97.18 |
app          |   96.66 |      100 |   91.66 |   96.42 |
  database.js |     100 |      100 |     100 |     100 |
  server.js   |   95.34 |      100 |   83.33 |   95.34 | 11-12
  store.js    |     100 |      100 |     100 |     100 |
app/tests    |     100 |       50 |     100 |     100 |
  utils.js    |     100 |       50 |     100 |     100 | 7-12
--------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       13 passed, 13 total
Snapshots:   0 total
Time:        1.945 s, estimated 2 s
Ran all test suites.

基本上,我们的覆盖率与之前相同,但我们删除了一些测试,store.js文件覆盖率达到了 100%。

如我们所见,在server.js中有一行未被覆盖(11–12)。在前一章中,我们添加了一个新的路由来渲染GET /about中的模板,但我们忘记添加适当的测试。所以,让我们添加以下测试:

describe('/about', () => {
    it('Should return a 200 with the total whispers in the platform', async () => {
        const response = await supertest(app).get('/about')
        expect(response.status).toBe(200)
        expect(response.text).toContain(`Currently there are ${whispers.length} whispers available`)
    })
})

如果你再次运行测试,你会看到这一行现在已被覆盖,覆盖率已增加到 100%。我们还可以通过从覆盖率报告中移除tests文件夹来提高评分,我们可以通过在jest.config.js文件中添加以下行来实现:

export default {
  modulePathIgnorePatterns: ['<rootDir>/node_test/'],
  "coveragePathIgnorePatterns": [
    "<rootDir>/tests/"
  ]
}

在你的覆盖率报告中明确跟踪或不跟踪哪些文件非常重要;否则,代码覆盖率将只是一个指标,它不会引导你专注于最关键的应用程序部分。阅读关于 100%覆盖率目标相关挫折的文章相当普遍,但在大多数情况下,我们不需要追求这个大数字,我们应该清楚哪些代码部分不需要进行测试。

无论你是单独工作还是团队合作,精确的指标都会提高所有参与项目的开发者的体验。正如你所看到的,覆盖率现在是 100%,因为我们忽略了我们不打算测试的文件:

PASS  tests/server.test.js
  Server
    GET /about
      ✓ Should return a 200 with the total whispers in the platform (61 ms)
    GET /api/v1/whisper
      ✓ Should return an empty array when there's no data (19 ms)
      ✓ Should return all the whispers (14 ms)
    GET /api/v1/whisper/:id
      ✓ Should return a 404 when the whisper doesn't exist (14 ms)
      ✓ Should return a whisper details (12 ms)
    POST /api/v1/whisper
      ✓ Should return a 400 when the body is empty (27 ms)
      ✓ Should return a 400 when the body is invalid (9 ms)
      ✓ Should return a 201 when the whisper is created (17 ms)
    PUT /api/v1/whisper/:id
      ✓ Should return a 400 when the body is empty (9 ms)
      ✓ Should return a 400 when the body is invalid (9 ms)
      ✓ Should return a 404 when the whisper doesn't exist (11 ms)
      ✓ Should return a 200 when the whisper is updated (18 ms)
    DELETE /api/v1/whisper/:id
      ✓ Should return a 404 when the whisper doesn't exist (10 ms)
      ✓ Should return a 200 when the whisper is deleted (13 ms)
-------------|---------|----------|---------|---------|-------------------
File         | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------|---------|----------|---------|---------|-------------------
All files    |     100 |      100 |     100 |     100 |
database.js |     100 |      100 |     100 |     100 |
server.js   |     100 |      100 |     100 |     100 |
store.js    |     100 |      100 |     100 |     100 |
-------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       14 passed, 14 total
Snapshots:   0 total
Time:        2.024 s, estimated 3 s
Ran all test suites.

信息

如果你在按照步骤运行本章的项目时遇到问题,或者你尝试了替代方法,你可以使用你在本章开头下载的源代码中的step3文件夹来比较和更容易地修复可能的错误。

现在,我们已经完成了迁移,是时候在下一节中进行回顾了。

摘要

在本章中,我们学习了 MongoDB 与其他数据库的不同之处。我们学习了如何使用容器、Docker 和 Docker Compose 在本地安装 MongoDB。

此外,我们还探讨了如何使用环境变量和dotenv包来管理我们应用程序中的敏感信息。我们还学习了如何使用 Mongoose 与 MongoDB 交互。

最后,我们将我们的应用程序迁移到使用 MongoDB 而不是 JSON 文件。这给了我们一个机会,正确地学习如何重构和重新组织我们之前的代码。这次迁移还使得维护和部署应用程序变得容易,因为数据作为外部源存储和查询。这将有助于我们在未来实现大量扩展,因为我们可以将多个后端副本连接到同一个数据库实例。我们还学习了如何使用 MongoDB 测试我们的应用程序,并使用这种测试方法确保迁移成功完成。

在下一章中,我们将向我们的应用程序引入身份验证和授权。我们将使用 JWT 进行用户身份验证,并使用中间件保护需要身份验证的路由。此外,我们将重构代码以使用数据库存储用户,并使用bcrypt库对密码进行散列。最后,多个用户将能够使用我们的应用程序,其中包括私密消息。

进一步阅读

第十三章:使用 Passport.js 进行用户认证和授权

在本章中,我们将学习在现代 Web 应用程序中认证和授权是如何工作的。我们将探索许多安全机制背后的加密技术,并学习如何使用JSON Web TokensJWT)在我们的 Web 应用程序中实现这些概念。我们还将了解如何使用 Passport.js 扩展我们的认证策略,包括第三方提供者如 Facebook 或 Spotify。

在本章结束时,我们将通过迭代上一章中生成的代码,在我们的 Web 应用程序项目中实现认证和授权。我们还将学习如何具体测试它们。

总结一下,以下是本章我们将探讨的主要主题:

  • Web 应用程序中认证和授权的工作原理

  • 如何在我们的 Web 应用程序中使用 JWT 进行用户认证

  • 我们需要了解的加密基础知识,以便理解现代认证和授权机制

  • 了解 Passport.js 的工作原理以及如何使用它在我们的 Web 应用程序中实现与第三方提供者(如 Facebook 或 Spotify)的认证

  • 如何使用 JWT 和 Express 将认证和授权层添加到任何 Web 项目中

技术要求

为了跟随本章,以下是一些建议:

您应该熟悉我们在上一章中生成的代码,因为这是我们对上一章生成代码的迭代

  • 在您的机器上安装了 Node.js 20.11.0

  • 一个代码编辑器,例如 Visual Studio Code

  • 已经设置并运行了 Docker

  • 一个现代 Web 浏览器,例如 Chrome 或 Firefox

本章的代码文件可以在github.com/PacktPublishing/NodeJS-for-Beginners找到

查看本章的代码演示视频,视频链接为youtu.be/mdE5eXS5enM

理解现代认证和授权

认证授权是两个经常被混淆的不同概念。认证涉及确认用户的身份,而授权涉及验证他们拥有的特定访问权限。在本章中,我们将探讨如何在“将认证和授权添加到我们的 Web 应用程序”部分中实现这两个概念。

认证

HTTP 协议的一个重大挑战是它是无状态的。这意味着服务器不会保留任何关于客户端的信息。每个请求都是独立的,因此我们需要设计和提供机制,使我们能够知道执行请求的用户是谁。这是认证过程的主要目标。

在 Web 应用程序中实现认证有许多方法。最常见的方法是使用用户名和密码,同时也有许多库可以帮助我们实现这一机制以及不同的实现方法。

我们可以将大部分工作委托给第三方提供商,例如 Auth0,或者我们自己来实现。

在本章中,我们将探讨如何使用 Passport.js 库JSON Web 令牌 (JWT) 在我们的 Web 应用程序中实现身份验证。

授权

我们需要实现一种明确的方式来确定用户是否有权执行某些操作,例如创建新帖子或删除旧帖子。即使是访问特定页面这样简单的事情,也需要我们实现一种确定用户是否有权访问的方法。

在身份验证部分投入大量精力,但忘记授权方面是很常见的情况。从历史上看,互联网初期的 Web 系统较为简单,我们没有为每个用户分配很多角色,所以我们更关注你是谁,而不是你是否应该能够执行某些操作。今天,构建复杂的系统是很常见的,最终会有访问控制表来定义动作和角色之间的关系。例如,我们可以参考 图 13.1 中描述的基于角色的授权策略插件。使用这个插件,我们可以轻松理解和更新角色与潜在活动之间的关系。例如,构建者角色可以取消作业但不能配置它们。

图 13.1 – 来自 https://github.com/jenkinsci/role-strategy-plugin 的 Web 浏览器截图,可在 MIT 许可下获得

图 13.1 – 来自 github.com/jenkinsci/role-strategy-plugin 的 Web 浏览器截图,可在 MIT 许可下获得

没有正确考虑授权是一个非常常见的错误,可能会导致严重的安全问题。例如,如果我们忘记实现授权部分,我们可能会得到一个允许任何用户访问任何页面或执行任何操作的 Web 应用程序。这是一个非常危险的情况,可能会导致安全风险。

我们将在本章的 将身份验证和授权添加到我们的 Web 应用程序 部分中从实际角度探讨这个主题,看看如何在我们的 Web 应用程序中实现适当的授权策略。

既然我们已经清楚地理解了身份验证和授权之间的区别,让我们来探讨如何在我们的 Web 应用程序中实现它们。在下一节中,我们将学习如何使用 JWT 在我们的 Web 应用程序中验证用户。

JWT 简述

在 Web 应用程序中实现身份验证最流行的方法之一是使用 JWT。

因此,让我们看看一些定义:

JSON Web Token 是一个提议的互联网标准,用于创建带有可选签名和/或可选加密的数据,其有效载荷包含断言一定数量声明的 JSON。这些令牌可以使用私有密钥或公私钥进行签名。

(JSON Web Token, en.wikipedia.org/wiki/JSON_Web_Token)

JSON Web Tokens 是一种开放、行业标准的 RFC 7519 方法,用于在双方之间安全地表示声明。

(JWT, jwt.io/)

因此,基本上,JWT是一个包含信息(声明)的字符串(JSON),并使用一个密钥进行签名。这个过程确保 JWT 内的信息保持安全且不可篡改,允许在后续请求中进行验证。尽管这听起来可能是一个简单的概念,但深入了解会揭示许多复杂性和考虑因素,必须首先理解。

让我们列出我们期望支持的某些最关键的功能,以了解其背后的复杂性:

  • 任何人都可以向我们的服务器发送请求,因此我们默认不能信任任何请求。

  • 任何人都可以尝试操纵请求,因此我们需要实现一个机制,允许我们验证请求没有被操纵。

  • 我们需要实现一个机制,允许我们验证请求,而无需在服务器上存储任何信息。这样我们就可以无问题地扩展我们的应用,甚至可以在多个服务器上使用相同的 JWT。

该过程

简而言之,用户将使用用户名和密码进行认证,然后服务器将返回一个 JWT。用户将在每个请求中发送 JWT,服务器将验证 JWT 以认证用户。

理论

JWT 是一个包含用户信息(如姓名、角色等)的字符串,并使用一个密钥进行签名。因此,服务器可以使用密钥验证 JWT,并从中提取用户信息。任何修改 JWT 的尝试都将使签名无效,因此服务器将拒绝请求。

因此,为了正确地签名令牌,我们首先需要了解密码学的基础知识。

密码学 101

要使 JWT 工作,我们需要了解两件事:哈希和签名。

哈希

哈希是一个过程,它接受一个字符串并返回一个固定长度的字符串。此算法作为一个单向函数工作,因此我们可以对字符串进行哈希,但我们不能从哈希中获取原始字符串。

下面是一个使用 Node.js 中的SHA256算法对字符串进行哈希的示例:

import crypto from 'crypto';
const hash = crypto.createHash('sha256');
hash.update('Hello World');
console.log(hash.digest('hex'));
// a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e

我们将在本章的“向我们的 Web 应用添加认证和授权”部分稍后使用此算法来哈希用户的密码。

签名

签名是一个过程,它接受一个字符串和一个密钥,并返回一个新的字符串。此算法作为一个双向函数工作,因此我们可以对字符串进行签名,然后我们可以使用密钥验证签名。

广泛使用

这种使用哈希和签名的模式在许多不同的软件领域都非常常见。例如,当一个新的 Node.js 版本发布时,Node.js 团队将发布每个二进制文件的哈希值。这允许我们下载二进制文件,然后使用 Node.js 团队发布的哈希值来验证文件的哈希值。如果哈希值相同,那么我们可以确信文件没有被修改。

在发布哈希文件之前,会对文件进行签名,因此我们可以使用 Node.js 团队成员的公钥来验证签名。如果签名有效,那么我们可以确信哈希文件没有被修改。

例如,以下链接是 Node v20.11.0 的文件 shasum (nodejs.org/dist/v20.11.0/SHASUMS256.txt.asc)。以下代码块是文件的内容(为了节省空间已省略)以了解其工作原理:

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256
 f76a47616ceb47b9766cb7182ec6b53100192349de6a8aebb11f3abce045748f  node-v20.11.0-aix-ppc64.tar.gz
...
 dce7cd4b62a721d783ce961e9f70416ac63cf9cdc87b01f6be46540201333b1e  win-x86/node_pdb.zip
-----BEGIN PGP SIGNATURE-----
iQGzBAEBCA...aig9KO/s=
=B/OP
-----END PGP SIGNATURE-----

如您所见,文件包含两部分(消息和签名)并使用-----BEGIN PGP SIGNED MESSAGE----------BEGIN PGP SIGNATURE-----来分隔。这有助于我们验证文件的真实性——基本上,我们可以验证一个 Node.js 发布者创建了此文件,并且内容没有被篡改,即使下载文件的服务器被攻破。

消息本身包含每个二进制文件的哈希值,因此我们可以下载node-v20.11.0-aix-ppc64.tar.gz文件并检查文件内容是否与消息中发布的哈希值相同,即f76a47616ceb47b9766cb7182ec6b53100192349de6a8aebb11f3abce045748f。如果哈希值相同,那么我们可以确信文件没有被修改。这允许我们安全地分发信息。

重要注意事项

使用 JWT 时,我们将使用类似的模式,但我们将使用不同的算法来签名内容。您可以在 RFC 7518 中找到支持的算法列表 (tools.ietf.org/html/rfc7518#section-3.1)。

JWT 结构

JWT 是一个由点分隔成三部分的字符串。每一部分都使用base64编码。这三部分如下:

  • 标题:包含有关令牌类型和用于签名令牌的算法的信息

  • 有效载荷:包含我们想要存储在令牌中的声明(信息)

  • 签名:包含用于验证令牌的令牌签名

签名是使用密钥对标题和有效载荷进行签名的结果。最好的部分是,我们可以使用密钥来验证签名,因此我们可以在服务器上不需要存储任何信息的情况下验证令牌。此外,信息是使用 base64 编码的,因此任何人都可以解码并阅读它,但我们无法修改它。

有一个重要的事情需要提及,那就是你绝不应该在有效载荷中存储敏感信息,因为任何人都可以解码并读取它。这包括用户的密码和银行账户详情以及其他敏感信息。

JWT.io

处理 JWT 的最佳工具之一是 JWT Debugger (jwt.io/)(见 图 13**.2)。这个网站允许我们编码和解码 JWT,以及验证令牌的签名。你可以用它来尝试或调试你的 JWT。

图 13.2 – 网络浏览器截图,展示如何解析和验证编码后的令牌

图 13.2 – 网络浏览器截图,展示如何解析和验证编码后的令牌

在进入本章的下一节之前,你可以随意尝试并探索它的工作原理。

在下一节中,我们将学习 Passport.js 的工作原理以及如何在我们 web 应用程序中使用它来实现与第三方提供者(如 Facebook 或 Spotify)的身份验证。

理解 Passport.js 基础知识

Passport.js 是一个出色的库,广泛用于在 Node.js 应用程序中实现身份验证。Passport.js 的官方网站将这个库定义为如下:

Passport 是 Node.js 的身份验证中间件。它极其灵活且模块化,可以无缝地集成到任何基于 Express 的 web 应用程序中。一套全面的策略支持使用用户名和密码、Facebook、Twitter 等进行身份验证。

(Passport.js, www.passportjs.org/)

在本质上,Passport.js 是一个中间件 (expressjs.com/en/guide/using-middleware.html),我们将将其包含到我们的 Express 应用程序中,以提供多种不同的策略来实现身份验证。拥有这样的策略选择,我们可以选择最适合我们需求的一个。我们可以使用这个库轻松实现社交登录功能(如 Facebook、Twitter、Spotify、GitHub 以及更多,超过 500 种策略)以及典型的用户名/密码登录。

在下一节中,我们将迭代我们的 web 应用程序中的代码,以包含我们在本章中迄今为止所学到的身份验证和授权机制。

将身份验证和授权添加到我们的 web 应用程序中

在本节中,我们将向我们的 web 应用程序添加身份验证和授权。我们将使用 jsonwebtoken 库来实现身份验证部分,并使用自定义中间件来实现授权部分。

克隆基础项目

身份验证和授权的添加并不复杂,但跟随起来相当长,所以对于本章,您可以下载项目github.com/PacktPublishing/NodeJS-for-Beginners/archive/refs/heads/main.zip并访问step4文件夹。实现已就绪,但我将评论自上一章(step3文件夹)以来我们所做的最相关更改,以便您能轻松跟踪我们所做的工作。

设置

我们的第一步是探索文件夹,安装依赖项,配置环境,并启动基础设施。这可以通过运行以下命令来完成:

  1. 使用npm i安装依赖项。

  2. 更新密钥,在根文件夹中添加.env文件,内容如下:

    MONGODB_URI=mongodb://localhost:27017/whispering-database
    PORT=3000
    SALT_ROUNDS=10
    JWT_SECRET=Tu1fo3mO0PcAvjq^q3wQ24BXNI8$9R
    Run npm run infra:stop && npm run infra:start.
    
  3. 运行npm run infra:stop && npm run infra:start

现在,基础设施和配置已就绪,但在我们开始对应用程序进行更多更改之前,建议运行一些测试。

运行测试

接下来,我们需要运行一些测试以确保代码按预期工作,通过在终端中输入npm run test

新增测试

我们可以看到,我们有一些与登录/注册相关的新路由以及针对它们的特定测试。当我们执行测试时,我们会看到测试消息(描述)在说明路由预期做什么以及我们希望通过测试做什么方面是清晰且一目了然的,即使我们还不熟悉代码:

图 13.3 – 终端截图展示了如何测试路由

图 13.3 – 终端截图展示了如何测试路由

我们应该能认出这些测试,因为我们已经在上一章中处理了这些路由。但如果我们继续滚动查看测试输出,我们应该会看到还添加了新的测试。

更新测试

之前的测试已更新,包括与 JWT 进行身份验证的路由的新测试用例:

图 13.4 – 终端截图显示测试通过以及如何通过描述轻松跟踪正在测试的内容

图 13.4 – 终端截图显示测试通过以及如何通过描述轻松跟踪正在测试的内容

如您所见,测试中的用例涵盖了更多与身份验证和授权相关的场景,例如“当用户未进行身份验证时应返回 401”和“当用户不是作者时应返回 403”。

UI 更改

但总体而言,最重要的变化与 UI 相关,因为我们现在有新的路由和视图用于登录/注册等。因此,我们可以通过运行npm run start来启动应用程序

登录

您可以在http://localhost:3000/login处输入凭据进行登录,此时后端 API 将返回一个 JWT,您可以使用它进行任何 CRUD 操作的身份验证。

图 13.5 – 展示用户可以输入用户名和密码的登录页面的网页浏览器截图

图 13.5 – 展示用户可以输入用户名和密码的登录页面的网页浏览器截图

注册

您可以随时在 http://localhost:3000/signup 创建新账户。此操作将在数据库中生成新用户,并且后端将返回一个 JWT,您可以使用它来执行 CRUD 操作并验证您对 API 的访问。

图 13.6 – 展示用户可以创建新账户或使用现有凭据登录的网页浏览器截图

图 13.6 – 展示用户可以创建新账户或使用现有凭据登录的网页浏览器截图

重要提示

服务器已定义有关用户名、电子邮件和密码的某些规则。因此,例如,您可以使用以下值:

  • 用户名:nodejs

  • 邮箱:demo@demo.com

  • 密码:aA1#dt$tu

CRUD 操作

如前所述,了解授权的工作方式非常重要。因此,以下是我们的业务逻辑规则:

  • 任何已登录的用户都可以看到 Whispering 平台上的所有 whispers。

  • 您只能修改或删除您创建的 whispers。

这些明确的规则将帮助我们构建一个涵盖所有场景的授权系统,因此,例如,您将无法删除其他用户创建的 whispers。在某些应用程序中,这种方法可能非常复杂,例如 Google Drive 或 Facebook。在这些场景中,拥有一个设置良好且文档齐全的权限矩阵非常有用。GitLab 提供了一个很好的例子(docs.gitlab.com/ee/user/permissions.html

图 13.7 – 展示所有 whispers 及其从 UI 交互按钮的主页的网页浏览器截图

图 13.7 – 展示所有 whispers 及其从 UI 交互按钮的主页的网页浏览器截图

如您所见,我们只能修改我们创建的 whispers,但此选项在视觉上对其他 whispers 不可用。

虽然 UI 是管理授权的关键因素,但我们需要确保后端也正确地管理其部分的授权,因此它不会允许用户修改或删除其他用户的 whispers。为了确保应用程序能够防止这些场景(如修改其他用户的 whispers),强烈建议添加特定的测试用例。检查测试套件用例,因为我们已经包括了涵盖403 Forbidden响应的这些场景。

添加的依赖项

我们包括了以下依赖项:

这些依赖项将在以后用于正确构建我们的应用程序。在 Node.js 项目中依赖第三方库是很常见的。最重要的是要确保我们使用的是没有已知漏洞的高质量外部依赖项,正如我们在第六章**.中学到的。

前端的变化

我们添加了一个名为public/auth.js的新文件来管理用户登录或使用平台注册时的表单提交。在发送请求后,我们将 JWT 存储在本地存储中,这样我们就可以轻松地恢复 JWT,即使我们刷新页面:

fetch('/login', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json'
    },
    body: JSON.stringify({
        username,
        password
    })
})
.then(response => {
    if(response.status !== 200) {
        throw new Error("Invalid credentials")
    }
    return response.json()
})
.then(({accessToken}) => {
    localStorage.setItem('accessToken', accessToken);
    window.location.href = '/';
})

通过之前的更改,我们现在使用POST HTTP 方法将用户名和密码发送到/login路由。作为响应,我们期望包含访问令牌的 JSON 数据,我们将将其存储在本地存储中,以保持会话持久性,以防用户刷新页面。最后,我们将用户重定向到主页,因为身份验证已成功完成。

我们还在public/app.js中添加了 JWT,以针对 API 的任何 CRUD 操作进行请求:

const fetchAllWhispers = () => fetch('http://localhost:3000/api/v1/whisper', {
    headers: {Authentication: `Bearer ${accessToken}`}
}).then((response) => response.json())

如您所见,每个请求都包含带有Bearer TOKEN值的Authentication头,这是对后端进行身份验证的预期方式。我们还使用 JWT 来获取用户名,并在 Whispers 视图中在 UI 中显示它。

此外,我们还禁用了 Whisper 视图中当前用户不是创建它们的用户时的编辑/删除按钮:

`<article data-id="${whisper.id}">
    <div class="actions" ${controlEdition(whisper, user)}>
        <button data-action="edit"></button>
        <button data-action="delete"></button>
    </div>
</article>`

controlEdition函数可以根据作者隐藏/显示操作:

const controlEdition = (whisper, user) => {
    if(whisper.author.id === user.id) {
        return ''
    } else {
        return 'style="display:none;"'
    }
}

既然我们已经清楚前端部分所做的更改,现在是时候跳转到后端部分并审查在数据库中正确管理用户身份验证数据所需进行的更改。我们将从存储的更改开始。

添加了新的用户存储

最相关的更改添加到了database.js文件中,其中为用户添加了一个新的模式。我们现在包括更高级的验证和转换。用户有usernameemailpassword属性:

const userSchema = new mongoose.Schema({
  //...
  password: {
    type: String,
    required: [true, 'Password is required'],
    minlength: [8, 'Password must be at least 8 characters long'],
    validate: {
      validator: checkPasswordStrength
    }
  }
  //...
})

password的情况下,我们通过在utils.js工具文件中添加的新函数增加了额外的验证。这个新函数使用正则表达式来验证密码强度(至少八个字符,至少一个字母,一个数字和一个特殊字符):

export function checkPasswordStrength(password) {
   const strengthRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/
   return strengthRegex.test(password)
}

现在,whisperSchema模式与User有关联,因为每个 whisper 都属于特定的作者:

const whisperSchema = new mongoose.Schema({
  author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
  message: String,
  updatedDate: {
    type: Date,
    default: Date.now
  },
  creationDate: {
    type: Date,
    default: Date.now
  }
})

我们可以在 stores/whisper.js 中看到这种关系是如何发生的,因为我们可以在查询中填充数据:

const getAll = () => Whisper.find().populate('author', 'username')
const getById = id => Whisper.findById({ _id: id }).populate('author', 'username')
const create = async (message, authorId ) => {
  const whisper = new Whisper({ message, author: authorId })
  await whisper.save()
  return whisper
}

密码管理

作为 database.js 中正确密码管理的一部分,我们将使用 bcrypt 库,特别是 pre 中间件,在将密码存储到数据库之前对其进行哈希处理。pre 中间件是一个在执行特定操作(如保存)之前被触发的函数。您可以在官方文档(mongoosejs.com/docs/middleware.html#pre)中找到很好的示例。

userSchema.pre('save', async function (next) {
  const user = this
  if (user.isModified('password')) {
    const salt = await bcrypt.genSalt()
    user.password = await bcrypt.hash(user.password, salt)
  }
  next()
})

同样,在同一个 database.js 文件中,我们将添加一个新的函数来比较用户存储的密码与用户在请求中发送的密码:

userSchema.methods.comparePassword = async function (candidatePassword) {
  const user = this
  return await bcrypt.compare(candidatePassword, user.password)
}

这样我们就可以安全地存储和比较密码,永远不会以纯文本形式存储。

JWT 工具

我们现在使用 jsonwebtoken 库来完成此目的进行身份验证。

utils.js 文件中,我们添加了一个生成 JWT 的函数:

export function generateToken (data) {
   return jwt.sign({
      data: data
    }, process.env.JWT_SECRET, { expiresIn: '1h' })
}

我们还添加了一个解析 JWT 的函数;在我们的情况下,是一个 Express 中间件,它将解析 JWT 并将用户添加到请求中:

export function requireAuthentication (req, res, next) {
   const token = req.headers.authentication
   if (!token) {
      res.status(401).json({ error: 'No token provided' })
      return
   }
   try {
      const accessToken = token.split(' ')[1]
      const decoded = jwt.verify(accessToken, process.env.JWT_SECRET)
      req.user = decoded.data
      next()
   } catch (err) {
      res.status(401).json({ error: 'Invalid token' })
   }
}

如您所见,我们使用 JWT_SECRET 来签名和验证 JWT。这个环境变量存储在 .env 文件中,因此我们可以在任何环境中轻松更改它。此外,我们为 JWT 设置了 1 小时的过期时间,在此之后,用户将需要再次进行身份验证。短过期时间相当常见,这样如果令牌真的被泄露,它被用于造成伤害的时间就会有限。这是一种非常流行的安全措施,可以与刷新令牌(auth0.com/learn/refresh-tokens)结合使用,以实现更稳健的实现。

如果令牌已被修改或密钥不匹配,那么 jwt.verify 函数将抛出错误,因此我们可以捕获它并向用户返回错误。如果令牌已过期,也会发生相同的情况。

如果令牌有效,我们将用户添加到请求中,这样我们就可以在下一个中间件或路由处理程序中使用它。

这完成了身份验证部分——我们现在可以在我们的 Web 应用程序中验证用户了!需要注意的是,我们不会在服务器上存储任何信息,因此我们可以无任何问题地扩展我们的应用程序,但这也存在一些缺点,我们将在第十五章中探讨。

添加新路由

现在我们有了实现身份验证所需的所有工具,因此我们可以包含新的路由。在我们的例子中,我们将包含以下路由:

  • 使用 GET /login 向用户渲染登录视图:

    app.get('/login', (req, res) => {
      res.render('login')
    })
    
  • 使用 POST /login 处理登录请求,存储新用户,并返回 JWT:

    app.post('/login', async (req, res) => {
      try {
        const { username, password } = req.body
        const foundUser = await user.getUserByCredentials(username, password)
        const accessToken = generateToken({ username, id: foundUser._id})
        res.json({ accessToken})
      } catch ( err ){
        res.status(400).json({ error: err.message })
      }
    })
    
  • 使用 GET /signup 向用户渲染注册视图:

    app.get('/signup', (req, res) => {
      res.render('signup')
    })
    
  • 使用 POST /signup 处理注册请求并返回 JWT:

    app.post('/signup', async (req, res) => {
      try {
        const { username, password, email } = req.body
        const newUser = await user.create(username, password, email)
        const accessToken = generateToken({ username, id: newUser._id})
        res.json({ accessToken})
      } catch ( err ){
        res.status(400).json({ error: err.message })
      }
    })
    
  • 然后,我们还需要更新需要认证的路由,以使用 require 认证 中间件,并修改内部逻辑以确保授权得到适当管理。例如,用户不应能够修改/删除其他用户的 whisper:

    app.put('/api/v1/whisper/:id', requireAuthentication, async (req, res) => {
      const { message } = req.body
      const id = req.params.id
      if (!message) {
        res.sendStatus(400)
        return
      }
      const storedWhisper = await whisper.getById(id)
      if (!storedWhisper) {
        res.sendStatus(404)
        return
      }
      if(storedWhisper.author.id !== req.user.id) {
        res.sendStatus(403)
        return
      }
      await whisper.updateById(id, message)
      res.sendStatus(200)
    })
    

如你所见,我们使用 requireAuthentication 中间件来确保用户已认证,然后检查用户是否是我们试图修改的 whisper 的作者。如果用户不是作者,则返回 403 禁止 错误。

在测试中,我们还涵盖了其他一些场景,例如当未找到 whisper 时。在这些情况下,我们预计在每个情况下都应返回适当的 HTTP 错误代码。

改进的测试工具

我们修改了测试工具,包括用户的有效固定值,这样我们就有了预定义的用户,我们可以使用它们来测试认证功能。

此外,我们还包含了用于测试的示例 whisper,这样我们就可以使用它们来测试授权部分。

最后,我们包含了一些包含每个用户有效 JWT 的固定值,这样我们就可以使用它们来测试授权部分。

你可以在 tests/utils.js 文件中详细检查更改。

测试用例更改

关于测试用例,我们更新了它们以包括新路由并测试授权部分。你可以在 tests/server.test.js 文件中详细检查更改。

通常,现在大多数路由都包括特定的测试用例来测试授权部分,确保授权得到适当管理。

我们为每个路由添加了测试用例来测试未认证用户的请求:

it('Should return a 401 when the user is not authenticated', async () => {
  const response = await supertest(app)
  .delete(`/api/v1/whisper/${existingId}`)
  expect(response.status).toBe(401)
  expect(response.body.error).toBe('No token provided')
})

此外,在某些路由中,我们添加了测试用例来测试授权部分,以确保授权得到适当管理:

it('Should return a 403 when the user is not the author', async () => {
  const response = await supertest(app)
  .delete(`/api/v1/whisper/${existingId}`)
  .set('Authentication', `Bearer ${secondUser.token}`)
  expect(response.status).toBe(403)
})

总体而言,许多测试被修改以包含具有特定 Bearer 令牌的 JWT,形式为 .set('Authentication', ` ````Bearer ${firstUser.token}`)

测试覆盖率

如果我们使用 npm run test:coverage 运行测试,我们可以详细看到更改如何影响测试覆盖率。如果你检查 coverage/lcov-report/index.html 文件,你可以看到覆盖的详细信息:

图 13.8 – 带有测试覆盖率报告的网页浏览器截图

图 13.8 – 带有测试覆盖率报告的网页浏览器截图

总体而言,覆盖率相当好(在 94-98%之间),但我们可以看到有一些行没有被覆盖。我们可以改进测试以覆盖它们,但这些是边缘情况。

摘要

在本章中,我们有机会了解在 Web 应用程序中认证和授权是如何工作的。我们使用 JWT 实现了认证部分,使用自定义中间件实现了授权部分。

此外,我们还详细探讨了 JWT 的工作原理以及如何在 Node.js 应用程序中实现它们。

最后,我们向我们的网络应用添加了身份验证和授权功能,因此我们现在可以验证用户,并确保用户只能修改/删除他们创建的 whispers。

在下一章中,我们将更详细地学习如何正确管理我们的网络应用以及任何 Node.js 应用或库中的错误。

进一步阅读

第十四章:Node.js 中的错误处理

Node.js 应用程序需要对错误进行稳固和一致的控制。大多数应用程序都是使用许多依赖项构建的,或者高度依赖于异步操作(网络、磁盘等),这使得错误管理变得更加复杂。

在本章中,我们将学习在 Node.js 应用程序中可能遇到的不同类型的错误以及如何正确处理它们。我们还将学习如何抛出自定义错误,以及如何捕获和从任何类型的错误中恢复应用程序,包括在 Express 应用程序中发生的错误。

我们还将学习如何在服务崩溃时进行优雅的关闭,如何根据情况使用退出代码,以及如何防止僵尸进程。

总结一下,以下是本章我们将探讨的主要主题:

  • 如何抛出自定义错误

  • 如何捕获和从任何类型的错误中恢复

  • 如何在 Express 中管理应用程序和用户错误

  • 如何在服务崩溃时进行优雅的关闭

  • 如何防止僵尸进程

  • 如何使用退出代码来指示应用程序关闭的原因

技术要求

本章的代码文件可以在github.com/PacktPublishing/NodeJS-for-Beginners找到。

请查看本章关于代码执行的视频:youtu.be/VPXV1L1epIk

探索错误类型

正如我们在第一章中学到的,Node.js 是一个单线程应用程序。这意味着如果发生错误而我们没有正确处理它,应用程序将会崩溃。这就是为什么正确处理错误很重要的原因。

Node.js 中主要有两种错误类型:语法错误和运行时错误。

语法错误

当代码解析时,如果它无效,就会抛出语法错误。这些错误由 JavaScript 引擎抛出,通常很容易修复。许多 IDE 和代码编辑器可以检测这些错误并在代码编辑器中突出显示,这样你就可以在运行应用程序之前修复它们。在我们的案例中,我们已经在之前的章节中使用 StandardJS 作为代码检查器(这是一种帮助我们检测语法错误并强制执行一致代码风格的工具)。

这是一个语法错误的例子:

executeThisFunction()

之前的代码将抛出ReferenceError错误,因为executeThisFunction函数未定义。这个错误可以通过定义函数来轻松修复:

executeThisFunction()
ReferenceError: executeThisFunction is not defined
    at file:///file.js:1:1
    at ModuleJob.run (node:internal/modules/esm/module_job:192:25)
    at async DefaultModuleLoader.import (node:internal/modules/esm/loader:228:24)
    at async loadESM (node:internal/process/esm_loader:40:7)
    at async handleMainPromise (node:internal/modules/run_main:66:12)

运行时错误

运行时错误也被称为操作错误。这些错误在应用程序运行时抛出,与代码的语法无关。这些错误可以由应用程序本身或应用程序使用的依赖项抛出。

有许多方式可以生成运行时错误,例如通过访问未定义对象的属性、调用不存在的函数、尝试读取不存在的文件、尝试连接不可用的数据库以及尝试访问不可用的网络资源。

如您所见,有多种方式可以生成运行时错误。这就是为什么正确处理它们很重要。如果我们不处理它们,应用程序将会崩溃并停止工作。因此,在编写应用程序代码时,牢记可能抛出的运行时错误以及如何处理它们是非常重要的。

一些错误可以恢复,而另一些则不能,这取决于错误的类型。例如,如果我们有一个 REST API 应用程序,并且数据库不可用,我们可以通过返回一个503 HTTP 状态码和一条消息给客户端来从该错误中恢复。您将始终负责决定错误是否可以恢复以及如何处理它。

现在我们已经了解了在 Node.js 应用程序中可能抛出的错误类型,让我们在下一节中看看如何抛出有意义的错误。

抛出有意义的错误

当发生错误时,重要的是它是有意义的。这意味着错误应该包含足够的信息来了解发生了什么,以及可能如何修复它。

错误对象

错误对象是Error类的一个实例。这个类有一个接受消息作为参数的构造函数。这个消息将用来描述错误。以下是一个示例:

const myError = new Error('This is an error message')
throw myError

以下是上一段代码的输出:

file:///file.js:1
const myError = new Error('This is an error message')
                ^
Error: This is an error message
    at file:///file.js:1:17
    at ModuleJob.run (node:internal/modules/esm/module_job:192:25)
    at async DefaultModuleLoader.import (node:internal/modules/esm/loader:228:24)
    at async loadESM (node:internal/process/esm_loader:40:7)
    at async handleMainPromise (node:internal/modules/run_main:66:12)
Node.js v20.11.0

如您所见,错误信息在输出中显示。这是我们传递给Error类构造函数的消息。如果您将其与ReferenceError: executeThisFunction is not defined进行比较,我们可以看到错误信息描述不够详细,并且我们正在使用一个通用的错误类。

自定义错误

您可以通过扩展Error类来创建自己的自定义错误。当您想创建自己的Error类并向错误对象添加更多信息时,这非常有用。以下是一个示例:

class NotEnoughSleep extends Error {
  constructor (message) {
    super(message)
    this.requireSleep = true
    this.isRecoverable = true
  }
}
throw new NotEnoughSleep('Looks like you need more sleep')

如果我们运行上一段代码,我们将得到以下输出:

file:///file.js:9
  throw new NotEnoughSleep('Looks like you need more sleep')
NotEnoughSleep [Error]: Looks like you need more sleep
    at file:///file.js:9:9
    at ModuleJob.run (node:internal/modules/esm/module_job:192:25)
    at async DefaultModuleLoader.import (node:internal/modules/esm/loader:228:24)
    at async loadESM (node:internal/process/esm_loader:40:7)
    at async handleMainPromise (node:internal/modules/run_main:66:12) {
  requireSleep: true,
  isRecoverable: true
}

如您所见,输出中显示了错误信息看起来你需要更多的睡眠,以及类名NotEnoughSleep。此外,我们还在错误对象中添加了两个属性:requireSleepisRecoverable。这些属性是我们自己创建的,我们可以根据需要创建任意多个,并且可以尽可能具体。这些属性可以用来向错误对象添加更多信息,这样我们就可以使用这些属性在try/catch块中正确地处理错误:

try {
  throw new NotEnoughSleep("Looks like you need more sleep");
} catch (error) {
  if (error.isRecoverable) {
    console.log("You are lucky, because you can recover from this error");
  }
  if (error.requireSleep) {
    console.log("Please, go to sleep!");
  }
}

以下是上一段代码的输出:

You are lucky, because you can recover from this error
Please, go to sleep!

如您所见,我们使用了isRecoverablerequireSleep属性来处理错误。这是一个非常简单的例子,但您可以向错误对象添加更多属性来正确处理错误。

在下一节中,我们将学习如何在使用 Express 时捕获和恢复任何类型的错误。

在 Express 中管理错误

在前面的章节中,我们学习了如何使用 Express 创建 REST API 应用程序,并看到了如何在 Express 应用程序中处理错误,但在这个章节中,我们将刷新这些概念并对其进行扩展。

错误处理中间件

Express 有一个内置的错误处理中间件,可以用来集中处理应用程序中的错误。当应用程序中发生错误时,将执行此中间件。此中间件在所有其他中间件和路由执行之后执行。它仅在发生错误时执行,因此非常重要,需要将其添加到中间件链的末尾,如下所示:

import express from 'express'
const app = express()
// Other middlewares...
app.use((err, req, res, next) => {
  console.error(err.stack)
  res.status(500).send('Something broke!')
})
// Route handler...

自定义错误

如果你正在构建 REST API 应用程序,你可以在错误对象中添加一个属性来指示应返回给客户端的 HTTP 状态码。这样,你可以在错误处理中间件中正确处理错误,并将适当的 HTTP 状态码返回给客户端。以下是一个示例:

class NotFoundError extends Error {
  constructor (message) {
    super(message)
    this.statusCode = 404
  }
}
try {
  throw new NotFoundError('The resource was not found')
} catch (error) {
  console.log(error.statusCode)
  res.status(error.statusCode).send(error.message)
}

如你所见,我们可以使用 statusCode 属性返回适当的 HTTP 状态码给客户端。这是一个非常简单的示例,但你可以在错误对象中添加更多属性来正确处理错误。

现在我们已经知道了如何处理错误,是时候学习如何在应用程序无法从错误中恢复时优雅地关闭应用程序了。

优雅地关闭应用程序

在整本书中,我们学习了如何使用 try/catch 块、错误优先回调、catch 用于承诺以及事件来处理错误,但有时我们需要全局处理错误。

Node.js 提供了一种处理错误的全局方法,并在发生错误时优雅地关闭应用程序:使用 process.on()。你还可以使用 process.exit() 以特定的退出代码退出应用程序。这在 CI/CD 管道中非常有用,可以指示应用程序是否因为错误而关闭,以及在生产环境中。

事件

有许多事件可以用来处理全局错误:

  • uncaughtException:当发生未捕获的异常时,此事件被触发

  • unhandledRejection:当发生未处理的拒绝时,此事件被触发

  • exit:当 Node.js 进程即将退出时,此事件被触发

  • SIGINTSIGTERM:当 Node.js 进程接收到这些信号时,这些事件被触发

可以使用许多其他事件来处理全局错误,但这些都是最常见的。在以下示例中,我们将结合一些场景来处理全局错误:

const events = ['uncaughtException','unhandledRejection', 'exit', 'SIGINT'];
events.forEach(event => {
  process.on(event, (error) => {
    console.log(`This is an ${event} that we track!`)
  })
})
setTimeout(() => {
  throw new Error('Exception!')
}, 10000)
setTimeout(() => {
  Promise.reject(new Error('Rejection!'))
}, 20000)

如果你运行前面的代码,你会看到由于未处理的拒绝,应用程序将在 20 秒后关闭,但未捕获的异常最终被捕获,进程继续运行。此外,如果你在任何时候按下 Ctrl + C,应用程序将因为 SIGINT 信号而关闭。

在以下示例中,我们可以看到,当我们关闭 Node.js 应用程序时,exit事件总是被触发。因此,在应用程序关闭之前执行一些操作是很常见的:

This is an uncaughtException that we track!
This is an unhandledRejection that we track!
This is an exit that we track!

请注意,exit事件不仅在发生错误时触发,而且当应用程序优雅地关闭且不支持异步操作时也会触发。

在下一节中,我们将学习如何使用退出码来指示应用程序关闭的原因。这在 CI/CD 管道中非常有用,可以指示应用程序是否是因为错误而关闭的。

退出码

退出码用于指示应用程序关闭的原因,以及应用程序是否因为错误而关闭,以及应用程序是否是优雅关闭的。

如果退出码是0,则表示应用程序是优雅关闭的。如果退出码不同于0,则表示应用程序是因为错误而关闭的。默认情况下,当应用程序中没有其他事情要做时,Node.js 将以退出码0退出。

通过使用process.exit(),我们可以指定我们想要使用的退出码。例如,如果我们想表示应用程序是因为错误而关闭的,我们可以使用process.exit(1)。如果我们想表示应用程序是优雅关闭的,我们可以使用process.exit(0)

一些进程可能在执行上正确完成,但使用错误码。例如,当我们运行并完成应用程序测试时,如果任何测试失败,退出码将不同于0。这样,执行输出将是一个错误,可以防止持续执行 CI 管道。

在下一节中,我们将学习如何在处理错误时使用 process 库来防止僵尸进程。

避免僵尸进程

我喜欢僵尸电影,但我不喜欢僵尸进程。僵尸进程是指在后台运行且不做任何事情的进程。这类进程会消耗主机机的资源,在某些场景下(如低功耗设备)可能成为大问题。

使用process.on()可能很危险,因为它可能会阻止 Node.js 进程退出。这就是为什么在需要时使用process.exit()以特定的退出码退出应用程序很重要的原因。

让我们看看一个例子。如果我们不使用process.exit(),应用程序将不会退出,并且它将永远运行,即使执行一个未定义的函数时发生错误:

process.on('uncaughtException', (error) => {
  console.log('We are not going to exit the application!')
})
setInterval(() => {
    executeThisFunction()
}, 1000)

这在以下输出中显示:

We are not going to exit the application!
We are not going to exit the application!
We are not going to exit the application!
We are not going to exit the application!
We are not going to exit the application!

我们可以通过添加process.exit()来防止这种情况,以特定的退出码退出应用程序:

process.on("uncaughtException", (error) => {
  console.log("Now, exit the application!");
  process.exit(1);
});
setInterval(() => {
  executeThisFunction();
}, 1000);

输出将如下所示:

Now, exit the application!

如您所见,应用程序被关闭是因为我们使用了 process.exit() 来使用特定的退出代码退出应用程序。如果我们不使用 process.exit(),应用程序将永远运行,使其成为一个僵尸进程。

摘要

在本章中,我们学习了在 Node.js 应用程序中可能抛出的错误类型。我们看到了如何抛出自定义错误以及如何捕获和从任何类型的错误中恢复。

此外,我们还回顾了如何在 Express 中管理应用程序和用户错误。我们还学习了如何在服务崩溃时管理优雅关闭。

最后,我们学习了如何防止僵尸进程。

在下一章中,我们将学习更多关于安全性的内容,包括如何通过应用最佳实践来保护我们的应用程序,以及如何评估 CVE 和安全漏洞。

进一步阅读

第十五章:保护 Web 应用程序

在本章中,我们将探讨如何提高我们 Web 应用程序的安全性。我们将从讨论安全事件对业务的影响以及如何在日常工作中开始考虑安全开始。然后我们将探讨关键资源,如 OWASP Top 10、常见弱点枚举CWE)和常见漏洞和暴露CVE),以加深我们对现代 Web 应用程序安全性的理解。

然后,我们将探讨 Node.js 威胁模型和官方 Node.js 最佳实践,以提高我们应用程序的安全性。我们将应用这些知识来创建一个清单,我们可以使用它来提高现有应用程序的安全性。

最后,我们将探讨如何利用我们的安全知识成为道德黑客,以及如何在参与社区活动和漏洞赏金计划中提升技能。

总结来说,以下是本章我们将探讨的主要主题:

  • 安全的重要性

  • 从哪里开始考虑安全

  • 提高我们应用程序的安全性

  • 成为道德黑客

技术要求

本章的代码文件可以在github.com/PacktPublishing/NodeJS-for-Beginners找到。

安全的重要性

从历史上看,开发者并没有将应用程序的安全性视为优先事项。主要原因在于,安全文化并没有在开发者的思维中占据主导地位,因为主要目标是尽快交付功能,而历史上我们构建的系统对业务来说并非至关重要,也没有像今天这样全天候暴露在互联网上。

“只有两种公司:那些已经被黑客攻击的公司,以及那些将要被攻击的公司。”

– 罗伯特·穆勒,FBI 局长,2012 年

现在,我们依赖于许多第三方库和服务,我们无法控制它们,也不知道它们是否安全。我们构建了非常复杂的系统,这些系统包含许多超出我们自身业务逻辑的层级,我们需要意识到我们承担的风险以及如何减轻这些风险。

最后,我们作为个人需要意识到风险,我们应用程序的安全性是我们的责任。很多时候,我们假设安全是别人的责任,但这并不正确。即使在拥有安全团队的组织中,开发者也是第一道防线,他们需要意识到风险以及如何减轻这些风险。

社会工程

我喜欢好莱坞黑客电影,这些电影使用超级复杂的工具来破解虚构的系统,但现实是,最常见的攻击向量是社会工程学。欺骗一个人比破解一个系统要容易,这就是为什么社会工程学是最常见的攻击向量。有许多技术(如钓鱼、编造理由、诱饵等),并且随着时间的推移,它们会变得更加复杂。所有这些类型的攻击都有心理操纵的共同点。这可能包括使用权威角色(例如攻击者假装是警察),或者攻击者使用稀缺策略(例如,在特别优惠中声称只有五个设备以超低价出售,这是一个骗局)。这只是两个例子,但还有更多策略利用了我们作为人类共有的弱点/欲望。

“社会工程学绕过了所有技术,包括防火墙。”

– 凯文·米特尼克

我们都收到过声称来自我们的银行或我们使用的服务的电子邮件,要求我们点击一个非常可疑的链接或下载一个文件。这些电子邮件中的大多数都是钓鱼攻击,你的电子邮件提供商会检测并将它们分类为垃圾邮件。作为技术人员,我们认为自己不会上当受骗,但现实是,有越来越多的复杂攻击针对开发者,而且很难检测。

让我向你介绍lodash包,但你打错了字,安装了lodahs包。

这种攻击可能进行的两种恶意活动是搜索你项目中的.env文件并将它们发送到攻击者的服务器,或者在您的应用程序中安装后门。还有许多其他活动。

我们过去见过这种攻击;例如,流行的cross-env包(www.npmjs.com/package/cross-env),它用于以跨平台的方式设置环境变量,有一个恶意版本叫做crossenv(snyk.io/advisor/npm-package/crossenv),其中包含恶意负载。

你可以在snyk.io/blog/typosquatting-attacks/找到关于这种攻击的更多详细信息。

信息

目前,npm 有政策禁止你发布与之前已发布的包具有相似名称的包,以防止这种攻击(docs.npmjs.com/threats-and-mitigations#by-typosquatting--dependency-confusion)。

供应链中的风险

多年来,我一直宣传供应链安全的重要性。有时很难理解风险,因为供应链对我们来说是不可见的,而且对我们代码的影响不如 SQL 注入或内存泄漏那样清晰。

在 2020 年,我撰写了一篇博客文章,讨论了如何使用 npm 上的恶意包在 Node.js 应用程序中构建后门(snyk.io/blog/what-is-a-backdoor/)。后门是一段代码,将允许我们远程控制应用程序。基本上,我们有了对操作系统终端的远程访问权限,可以以运行应用程序的用户的权限执行任何我们想要的命令。

为了使其更真实,我创建了一个恶意的 Express 中间件,允许我们在操作系统终端中执行任何我们想要的命令。然后,我将这个包发布到 npm 上,使其对社区可用。这个包被命名为browser-redirect,预期行为是将任何未使用基于 Chromium 的浏览器的用户重定向到browsehappy.com/。为此,你只需将这个包作为中间件在你的 Express 应用程序中使用。这个包执行了预期的行为,但在后台还做了更多恶意的事情,包括向 HTTP 响应中添加特定的头信息,这有助于使用 Shodan(www.shodan.io/)等工具在互联网上找到受感染的服务器,并在满足某些条件时在操作系统终端中执行命令。这段代码不需要任何外部库来执行任何恶意活动,因此恶意负载在运行扫描工具时不易被识别。

信息

你可以在 Snyk 博客(snyk.io/blog/what-is-a-backdoor/)中找到这篇博客文章,其中详细解释了代码执行、缓解策略等内容。

商业影响

任何安全事件都会对业务产生直接影响。我们可能会失去我们的声誉和客户的信任。此外,事件可能会直接影响到我们的客户;例如,如果我们遭受勒索软件攻击,我们无法向客户提供服务,或者更糟糕的是,如果我们丢失了客户的资料,这可能导致法律问题以及直接针对我们的客户的额外攻击。

数据泄露事件如此普遍,以至于我们在大众媒体上甚至都不太关注它们。然而,对用户的影响可能非常高。近年来,我们目睹了许多影响数百万用户的数据泄露事件,泄露了诸如密码、信用卡详情、支付日志、性取向、犯罪记录、地理位置记录等信息。你可以在www.upguard.com/blog/biggest-data-breaches找到详细的排名。

在 2013 年,Troy Hunt (www.troyhunt.com/about/) 创建了网站 haveibeenpwned.com/,您可以在该网站上检查您的电子邮件地址是否在数据泄露中被泄露。您还可以设置警报,以便在您的电子邮件地址出现在新的数据泄露中时收到通知。

既然我们已经看到了业务中安全的重要性,那么让我们看看在下一节中我们可以从哪里开始学习更多关于安全的知识。

从哪里开始学习安全

安全是一个广泛的话题,需要多本书来涵盖所有方面;即使如此,也需要更多的资源来保持其更新。在本节中,我们将探讨一些资源以开始学习,但范围限于 Node.js 生态系统和 Web 应用程序开发。

我们将学习关于 OWASP Top 10、CVE 和 CWE 的知识,这样我们就可以有一个清晰的指南,作为初学者在安全世界中导航。

OWASP Top 10 概述

许多可能的攻击都可能影响我们的应用程序,我们无法涵盖所有这些攻击,因此任务变得繁重。为了优先考虑最常见的攻击,开放网络应用安全项目OWASP)基金会创建了一个列表,列出了影响 Web 应用程序的 10 种最常见攻击,并且这个列表每隔几年就会更新。您可以在owasp.org/www-project-top-ten找到这个列表。

让我们看看 2021 年 OWASP Top 10 的列表:

  • A01:2021 破坏性访问控制

  • A02:2021 密码学失败

  • A03:2021 注入

  • A04:2021 不安全设计

  • A05:2021 安全配置错误

  • A06:2021 易受攻击和过时组件

  • A07:2021 身份验证和授权失败

  • A08:2021 软件和数据完整性失败

  • A09:2021 安全日志和监控失败

  • A10:2021 服务器端请求伪造(SSRF

我建议您仔细阅读 OWASP Top 10,并尝试找出攻击的共同点,这样您就可以理解攻击的根本原因,从而帮助您定义更好的策略来减轻它们。

例如,我们可以看到A05 安全配置错误A06 - 易受攻击和过时组件与我们的依赖项的安装和配置方式以及如何保持它们更新有关。因此,我们可以开始思考如何改进我们的 CI/CD 管道以自动化依赖项的安装和更新,并且我们可以讨论我们是否需要更深入地了解我们所依赖的依赖项。在非常具体的情况下,这可能意味着我们有一个配置不当且过时的 NGINX(反向代理)实例,我们需要投入精力来改进配置并定期更新 NGINX 版本。

了解 OWASP Top 10 的另一个好方法是逐年比较该列表。例如,让我们比较 2017 年的 OWASP Top 10 和 2021 年的 OWASP Top 10:

图 15.1 – 比较 OWASP TOP 10 2017 与 2021 年(来源:OWASP)

图 15.1 – 比较 OWASP TOP 10 2017 与 2021 年(来源:OWASP)

如您所见,有一些新增、删除和合并,但总体上,列表与之前非常相似,只是顺序上有所变化。这使得在列表更新时,每隔几年刷新您的知识变得更加容易。

CWE

Owasp Top 10 包括对 CWE 的引用,这是一个由社区开发的常见软件和硬件弱点列表。您可以在cwe.mitre.org/data/definitions/699.html找到这份列表。

CWE 是一个非常广泛的列表,涵盖了众多主题,因此阅读全部内容非常困难。然而,它是一个寻找特定主题更多信息的绝佳资源。例如,让我们看看CWE-798:使用硬编码凭证cwe.mitre.org/data/definitions/798.html,这将显示其描述、与其他 CWE 的关系、后果以及更多内容。

CVE

CVE 是一份公开披露的网络安全漏洞列表。您可以在cve.mitre.org/找到这份列表。这份列表与 CWE 不同,因为它是一份特定软件产品中发现的安全漏洞列表,而不是可能弱点的列表。

简而言之,CWE 是一份可能弱点的列表,CVE 是一份在野外发现的安全漏洞列表。我们需要非常关注可能影响我们应用程序的 CVE,并尽快缓解它们。一种常见的策略是订阅我们应用程序中使用的依赖项的安全邮件列表,以便在发现新的 CVE 时得到通知,或者使用 Snyk([snyk.io/](https://snyk.io/))等自动化工具,当我们的依赖项中发现新的 CVE 时,它会通知我们。

如果我们没有明确的缓解 CVE 的策略,我们很容易被每天发现的 CVE 数量所淹没,尤其是在 Node.js 生态系统中,我们的应用程序中通常有很多依赖项。

Node.js 中的 CVE

如果我们访问https://www.cvedetails.com/vulnerability-list/vendor_id-12113/Nodejs.html,我们可以看到多年来在 Node.js 中发现的所有 CVE 列表。

图 15.2 – CVE 列表

图 15.2 – CVE 列表

如您所见,我们将选择最新的一个(CVE-2023-45143),并将在www.cvedetails.com/cve/CVE-2023-45143/上对其进行详细探讨:

图 15.3 – CVE 详情

图 15.3 – CVE 详情

我们应该找到对漏洞的明确描述和发布日期的引用。这将帮助我们确定我们是否受到该漏洞的影响。此外,我们可以看到漏洞类别(在这种情况下,信息泄露)和未来 30 天内利用活动的可能性(在这种情况下,1.37%)。这将帮助我们优先考虑缓解漏洞,而整体评分将帮助我们确定漏洞的严重性。

考虑到这些因素,我们可以快速决定是否需要缓解。此外,我们可以找到一份参考文献列表,这将帮助我们更详细地了解漏洞,以及与漏洞相关的 CWEs。

如何缓解漏洞取决于我们。在某些情况下,我们只需更新依赖项以包含修复即可;在其他情况下,没有可用的修复,我们需要找到一种解决方案或删除依赖项。在其他情况下,我们可以简单地忽略漏洞,因为它与我们应用程序不相关,因为它没有影响。

作为建议,我建议您与更多同事一起进行分析,这样您就可以有不同的观点,并做出坚实的团队决策。

信息

Node.js 在控制 CVE 方面做得很好。您可以在 Node.js 博客中找到有关每个 CVE 的更多信息。例如,CVE-2023-45143nodejs.org/en/blog/vulnerability/october-2023-security-releases中进行了描述。

如果您正在开发现代前端应用程序,您可能会使用几个库来构建应用程序。通常,这会成为 CVE 的一个大来源,因为我们有许多我们不控制的间接依赖。而且,升级依赖项并不容易,因为我们需要等待维护者更新它们,并且需要与其他依赖项兼容。因此,请仔细评估您在应用程序中使用的依赖项,并将它们分为运行应用程序的部分和仅作为构建过程的一部分。这将大大简化审查过程。

Node.js 威胁模型

Node.js 有一个威胁模型,这是一个很好的资源,可以帮助理解 Node.js 如何评估社区报告的漏洞。您可以在github.com/nodejs/node/blob/main/SECURITY.md#the-nodejs-threat-model找到威胁模型,我们已在 Node.js 安全工作组(github.com/nodejs/security-wg)中进行了详细阐述。

我们可以从威胁模型中提取的一个明确想法是,Node.js 项目默认信任许多事物,例如我们在应用程序中使用的依赖项、我们自己的代码或我们运行应用程序的基础设施。这意味着我们必须知道在我们的应用程序中运行的是哪些代码(我们的或来自第三方的),并确保基础设施是安全的。

Node.js 官方推荐

除了威胁模型之外,在 Node.js 安全工作组中,我们创建了一份参考文档,旨在扩展当前的威胁模型,并提供关于如何保护 Node.js 应用程序的详细指南。它包括我们推荐遵循的最佳实践列表,以提高您 Node.js 应用程序的安全性。我们以 CWE 列表作为起点,提供了详细的缓解策略和建议:

  • HTTP 服务器拒绝服务(CWE-400)

  • DNS 重绑定(CWE-346)

  • HTTP 请求走私(CWE-444)

  • 通过时间攻击泄露信息(CWE-208)

  • 恶意第三方模块(CWE-1357)

  • 内存访问违规(CWE-284)

  • 猴子补丁(CWE-349)

  • 原型污染攻击(CWE-1321)

  • 不受控制的搜索路径元素(CWE-427)

您可以在nodejs.org/en/guides/security/找到该列表。

您还可以找到其他流行的社区构建资源,以更好地了解 Node.js 中的安全性:

现在我们对 Node.js 中的安全性有了更好的理解,让我们在下一节中更详细地看看如何提高我们 Web 应用程序的安全性。

提高我们应用程序的安全性

我们将在本节中探讨的工具和技术正在迅速发展,所以我建议您关注 Node.js 安全工作组(github.com/nodejs/security-wg)和社区,以了解最新的趋势。然而,这里有一个可靠的清单,您可以使用它来提高您应用程序的安全性:

  • 在我们的项目中使用bcrypt库(www.npmjs.com/package/bcrypt)来加密用户的密码:

    userSchema.pre('save', async function (next) {
      const user = this
      if (user.isModified('password')) {
        const salt = await bcrypt.genSalt()
        user.password = await bcrypt.hash(
    user.password, salt
        )
      }
      next()
    })
    
  • 使用validator(www.npmjs.com/package/validator)或joi(www.npmjs.com/package/joi)来验证数据。例如,您可以轻松验证电子邮件,如下所示:

    import validator from 'validator';
    validator.isEmail('foo@bar.com'); //=> true
    validator.isEmail('<script>alert("XSS")</script>'); //=> false
    
  • 使用 helmet (helmetjs.github.io/) 来提高我们应用程序的安全性。您可以在 blog.ulisesgascon.com/how-to-use-helmet-in-express 找到如何实现 helmet 的优秀指南。

  • 使用 pino (getpino.io/) 来安全地保存日志,我们可以使用这些信息来审计应用程序和来自第三方的滥用。您可以在官方文档中找到如何使用 Express 实现 pino 的优秀指南 (getpino.io/#/docs/web?id=pino-with-express)。

  • 监控:一旦应用程序部署并暴露在互联网上,我们需要了解其运行情况。监控应用程序将帮助我们检测停机时间、性能问题等。我们可以设置警报以实时了解是否存在问题。我们可以使用 New Relic (newrelic.com/)、Datadog (www.datadoghq.com/) 或 Sentry (sentry.io/welcome/)。您可以在官方文档中找到如何使用 Express 实现 Sentry 的优秀指南 (docs.sentry.io/platforms/node/guides/express/)。

  • 跟踪依赖项:您需要了解您的依赖项并保持它们更新。您可以使用 Snyk (snyk.io/) 或 Socket (socket.dev/) 来监控依赖项,当发现新的 CVE 时会收到通知,甚至可以自动化依赖项的更新。

  • 备份和恢复:您需要为您的关键信息进行备份,并在发生灾难时制定恢复计划。即使您没有遭受安全事件,您也可能遭受自然灾害,例如火灾或洪水,这些灾害可能发生在存储您信息的数据中心。如果您依赖于云服务提供商,您需要了解多区域复制的工作原理以及如何在灾难发生时恢复数据。

  • 减少攻击面:有时,我们在业务逻辑的核心部分编写的代码比业务逻辑本身所需的要多。我们编写的每一行代码都是一个潜在的风险,因此我们可以通过在安全关键区域使用 API 来减少攻击面。例如,我们可以使用 Auth0 (auth0.com/) 来处理我们的身份验证和授权,或者使用 Stripe (stripe.com/) 来处理支付。这将减少我们需要编写和维护的代码量,并且敏感信息,如信用卡号码,将由该领域的专家第三方处理。当然,我们需要信任第三方并了解服务的成本。

  • 在您的服务中强制执行双因素认证(2FA):我们需要在我们的公司活动中使用的所有服务(Slack、GSuite 等)以及我们用于运行应用程序的基础设施(Github、AWS、Azure 等)中强制执行 2FA。这将降低社会工程攻击的风险。此外,如果我们能在自己的应用程序中实施 2FA 以减轻用户密码被盗的风险,那就更好了。

  • 审查秘密:泄露秘密是我们可能犯的一个非常常见的错误。我们可以使用 GitGuardian([www.gitguardian.com/](https://www.gitguardian.com/))等工具扫描我们的存储库,并在我们泄露秘密时通知我们。

  • 实施良好实践:作为团队,我们可以遵循许多良好实践来提高我们应用程序的安全性。例如,使用控制版本,使用拉取请求来审查更改,使用 CI/CD 工具来自动化代码审查,等等。

  • 自动化:一旦你设置了 CI/CD 工具,你可以自动化许多任务,甚至与基础设施相关的任务。例如,你可以使用 Terraform([https://www.terraform.io/](https://www.terraform.io/))来自动化基础设施的创建,并减少人为错误。

  • 持续学习:安全与其他技术话题并无不同;它发展非常快,我们需要持续学习。

  • 使用安全协议:我们需要使用 HTTPS 等安全协议来保护客户端和服务器之间的通信。

将其付诸实践

现在我们有了清单并对 Node.js 中的安全有了更好的理解,我建议您回顾我们在前几章中构建的应用程序,并审查该应用程序以寻找可能的改进,使其更加安全。

这里有一些开始的想法:

  • 审查依赖项并检查是否存在任何已知漏洞

  • 使用代码风格检查器来强制执行代码风格

  • 使用静态代码分析工具查找可能的错误,例如 SonarQube 或 CodeQL

  • 添加关键库以提高安全性,例如helmetvalidator

  • 添加控制版本系统并使用 CI/CD 工具自动化代码审查,例如 Github Actions

  • 添加一个控制版本系统以审查拉取请求中的更改

在下一节中,我们将探讨如何利用我们的安全知识成为道德黑客。

成为道德黑客

当我们想到黑客时,我们往往会想到试图窃取我们的数据或控制我们系统的网络犯罪分子。实际上,情况并非如此简单。让我们阅读来自论文《黑客思维:网络安全专家及其心理模型研究》中对黑客的定义(https://papers.ssrn.com/sol3/papers.cfm?abstract_id=2326634):

“无论一个人是哪种类型的黑客,识别系统弱点都需要逻辑推理和系统地思考可能采取的行动、替代方案和潜在结论的能力。这种推理和系统思维的组合意味着使用心智模型。黑客是一种需要非凡技术和推理能力的认知活动。”

因此,基本上,我们是在谈论一个具有高度技术技能的人,他们通过跳出思维定势来做事情。问题不在于活动本身,而在于活动的意图。

今天,我们将道德黑客定义为具有良好意图做事的人。例如,当您在自己的应用程序上进行渗透测试以寻找可能存在的漏洞时,您正在进行道德黑客活动。有时,您最终会发现您不拥有的其他应用程序/库/服务中的漏洞。在这些情况下,我们可能会面临两种不同的场景:进行协调漏洞披露(以前称为负责任披露)或尝试漏洞赏金计划

协调漏洞披露(CVD)

当您发现第三方应用程序/库/服务中的漏洞时,您可以联系应用程序/库/服务的所有者,并解释漏洞以及如何缓解它。虽然这个过程看起来很简单,但并不总是容易。

首先,一个未知的漏洞可能会影响许多用户、公司和服务。因此,您需要非常小心地沟通漏洞以及向谁沟通。此外,这还需要一个验证过程,以确保漏洞是真实的,并且它不是一个误报。然后,预计在公开披露之前,将开发补丁并将其提供给受影响的服务。

为了使过程更容易,许多公司和开源项目都有一个安全策略,解释如何报告漏洞以及可以期待的过程。例如,您可以在nodejs.org/en/security/找到 Node.js 的安全策略:

“通常,您的报告将在 5 天内得到确认,您将在 10 天内收到对您的报告的更详细回应,表明处理您的提交的下一步行动。当我们的分级志愿者休假时,这些时间表可能会延长,尤其是在年底时。”

在对您的报告进行初步回复后,安全团队将努力让您了解修复和全面公告的进展情况,并可能要求提供有关报告问题的额外信息或指导。

其他公司,如谷歌,有一个专门的网站来报告其产品中的漏洞(www.google.com/about/appsecurity/)。

奖金计划

一些公司和开源项目有一个赏金计划,如果你能发现它们的应用程序/库/服务中的漏洞,将会得到奖励。实际上,这种情况与 CVD 非常相似,但在这个情况下,你有一套明确的参与和边界规则。

让我们看看 Node.js 赏金计划hackerone.com/nodejs,以更好地了解这个过程。我们还有一个 hacktivity 页面(hackerone.com/nodejs/hacktivity),我们可以看到已经提交的报告和报告的状态,包括大量的详细信息,包括奖励。

图 15.4 – Node.js 赏金计划

图 15.4 – Node.js 赏金计划

如图中所示,有一个披露信息列表,在许多情况下,这些信息会导致安全补丁。您可以自由探索它们,看看每个案例中的沟通是如何进行的,以及流程是如何运作的。您可以从这些披露报告中学到很多东西。

获取技能

开发软件与破坏软件不同。你需要学习很多技能,也需要熟悉一些工具。一个关于你需要学习的技能的绝佳概述是roadmap.sh/,其中有一个专门针对网络安全的部分(roadmap.sh/cyber-security)。

为了获得更多实践技能,您可以使用Hack the Box平台(www.hackthebox.eu/),这将允许您在一个安全的环境中练习您的技能。Hack the Box中的挑战被社区中的许多黑客和安全专业人士用作学习工具,因此您可以在互联网上找到很多帮助您解决挑战的 write-ups。

摘要

在本章中,我们探讨了现代 Web 应用程序中安全性的重要性以及安全事件可能对业务产生的影响。然后,我们学习了如何通过使用 OWASP、CWE、CVE、Node.js 威胁模型和官方 Node.js 最佳实践来在日常工作中开始关注安全。

此外,我们还学习了如何通过使用简单的清单来提高我们应用程序的安全性,以及如何在我们的应用程序中应用它。

最后,我们学习了如何利用我们的安全知识成为一名道德黑客。

在下一章中,我们将学习如何以不同的方式将我们的应用程序部署到互联网和本地设备上。

进一步阅读

第五部分:掌握 Node.js 部署和可移植性

第五部分 中,我们将把我们的应用程序部署到公共互联网。我们将学习如何根据我们的需求决定最佳方法,然后我们将使用 DigitalOcean 中的虚拟机来使用 PM2 部署应用程序,并且我们还将学习如何将我们的应用程序 docker 化以提高可移植性。

我们将使用 GitHub 来存储我们的应用程序代码,并且我们将学习如何为我们的项目创建持续部署管道。

我们旅程的最后一步将是使用 Cloudflare 来管理我们的域名和 SSL 证书,并且我们将探索十二要素应用原则。

本部分包含以下章节:

  • 第十六章部署 Node.js 应用程序

  • 第十七章将 Node.js 应用程序 docker 化

第十六章:部署 Node.js 应用程序

在本章中,我们将学习如何将我们的应用程序部署到公共互联网。我们将了解明确定义需求的重要性以及如何选择最适合我们需求的解决方案。我们将把应用程序代码推送到 GitHub 仓库,以便使用 GitHub Actions 进行持续集成。

最后,我们将在 DigitalOcean 上部署应用程序,并使用 PM2 保持应用程序运行。我们将配置并使用 MongoDB Atlas 在云端托管数据库。

总结来说,以下是本章我们将探讨的主要主题:

  • 如何定义需求以及如何选择最适合我们需求的最佳解决方案

  • 如何将应用程序代码推送到 GitHub 仓库

  • 如何使用 MongoDB Atlas 将数据库作为外部资源托管

  • 如何使用 DigitalOcean Droplet 托管应用程序

  • 如何使用 PM2 保持应用程序运行

技术需求

为了遵循本章中的示例,您需要创建以下提供商的账户:

本章的代码文件可以在 github.com/PacktPublishing/NodeJS-for-Beginners 找到。

查看本章的操作视频代码在 youtu.be/cWkqR2xJJ0k

注意

我们将使用 DigitalOcean 来托管应用程序,但你也可以使用任何其他提供商,甚至是你自己的笔记本电脑(作为替代方案)。如果你没有 DigitalOcean 账户,你可以在这里创建一个:www.digitalocean.com/

定义需求

我们已经在计算机上使应用程序运行,但我们需要将其部署到公共互联网。因此,我们需要定义需求,以便选择最适合我们需求的最佳解决方案。

首先,我们需要考虑以下技术问题:

  • 目标环境是什么 (裸机,虚拟机,容器,云解决方案...) 由于我们的应用程序使用 Node.js 和标准 NPM 库,我们可以轻松地在裸机或虚拟机上直接部署它。其他解决方案也是可能的,但需要在配置方面做一些工作。

  • 目标平台是什么 (AWS, Azure, GCP, DigitalOcean, Heroku...) 在我们的案例中,我们预期不会有太多流量或用户。此外,我们不是在团队中工作,也没有任何具体要求,例如服务级别协议(SLA)。我们可以安全地选择使用最简单的提供商,该提供商在入职流程方面具有竞争力定价。在我们的案例中,我们将使用 DigitalOcean。

  • 目标操作系统是什么(Linux, Windows, macOS...) Node.js 能够在常见的和异构的操作系统上运行。我们的应用程序对操作系统没有特殊依赖,因此我们可以轻松选择 Linux,因为它是服务器上最受欢迎的操作系统,并且提供了最广泛的提供商服务。

  • 目标架构是什么(x86, ARM...) 在这种情况下,我们的应用程序是纯 JavaScript。Node.js 支持这两种架构(x86 和 ARM),因此我们可以轻松选择 x86,因为它是一种更常见的服务器架构,通常价格更低。

  • 目标 Node.js 版本是什么(18, 20, 21...) 我们对 Node.js 20.11.0 有明确的依赖,因为我们开发应用程序时使用了这个版本,但我们可以确信应用程序应该能在任何 Node.js 20 LTS 版本上运行。

  • 目标数据库是什么(MongoDB, MySQL, PostgreSQL, Redis...) 我们依赖于 MongoDB,因此我们需要将其作为我们基础设施决策的依赖项考虑。除此之外,没有更多外部依赖项或服务。管理数据库并不简单,因此在这种情况下,我们可以安全地选择任何托管服务。MongoDB 提供了 MongoDB Atlas (www.mongodb.com/atlas/database) 作为他们在云中的 MongoDB 解决方案。此外,免费层应该能满足我们的需求。

因此,总结一下,我们将部署我们在前几章中构建的应用程序。我们将部署一个使用 Express 的 Node.js 应用程序。唯一的外部依赖项是 MongoDB。我们将使用具有 x86 架构的 Linux 机器和 Node.js 20.x 版本。此外,我们将使用 MongoDB Atlas 来托管数据库,这样我们就不必过多担心数据库的运营方面。

此外,我们还需要考虑以下与团队和项目相关的事项,特别是如果我们在一个专业环境中工作,如果我们计划长期部署应用程序,或者预期很快会进行扩展:

  • 预算是多少?

  • 我们预计会有多少次部署?

  • 团队规模是多少?

  • 团队的经验和知识是什么?

将 Node.js 应用程序部署到宠物项目与部署到拥有积极 服务级别协议SLAs)和经验丰富的基础设施团队的大型公司并不相同。

在我们的情况下,我将假设这是您第一次部署 Node.js 应用程序。此外,我将假设有限的预算、经验和时间来维护基础设施,因此我们将尝试尽可能便宜的选择。当然,我们不会有太多的部署,也不会有太多的流量。因此,我们不需要担心可扩展性、性能或高可用性。

总体而言,我们有两个主要选项,我们将在本章和下一章中探讨:

  • 在裸金属机器或虚拟机上部署应用程序

  • 在云解决方案中部署应用程序

您可以在裸机机器上部署应用程序,这可以是一台旧笔记本电脑,一个单板计算机SBC)如树莓派,或者您自己计算机上的虚拟机。在这种情况下,您可以选择是否启用对机器的远程访问。但无论如何,这是一个学习和测试应用程序的好方法。

另一个选项是面向公共互联网,并在云解决方案中部署应用程序。那里有许多提供大量产品服务的提供商。所以,为了保持简单,我将专注于单个计算资源提供商(DigitalOcean)和单个数据库提供商(MongoDB Atlas)。

在下一节中,我们将创建 GitHub 仓库,并将代码推送到仓库。

使用 GitHub 仓库

我们将使用 GitHub 来托管代码并部署应用程序。我们将使用 GitHub Actions 来运行测试并检查代码质量。然后我们将使用 GitHub 从仓库中拉取代码并部署应用程序。

创建 GitHub 仓库

您可以使用此指南创建一个新的仓库:docs.github.com/en/repositories/creating-and-managing-repositories/quickstart-for-repositories

在我的情况下,我创建了一个名为 nodejs-for-beginners 的仓库,如图表所示:

图 16.1– 创建的仓库的网页浏览器截图

图 16.1– 创建的仓库的网页浏览器截图

现在我们已经准备好了仓库,是时候通过将我们的代码添加到其中来开始使用它了。

将代码推送到仓库

您需要从 github.com/PacktPublishing/NodeJS-for-Beginners/archive/refs/heads/main.zip 下载项目,并访问 step4 文件夹,然后您需要将代码推送到仓库。您需要确保 package.json 文件位于仓库根目录中。

这里有两个指南可以帮助您将代码推送到仓库:

注意

为了简化流程,我们将只使用 main 分支。但在现实世界中,大多数团队使用多个分支来管理他们的代码,以便他们可以使用诸如拉取请求、代码审查等出色功能。这超出了本书的范围。

一旦完成这些,仓库应该看起来像这样:

图 16.2 – 添加了文件和文件夹的仓库的网页浏览器截图

图 16.2 – 添加了文件和文件夹的仓库的网页浏览器截图

注意

如果你在按照本章步骤运行项目时遇到问题,或者你尝试了另一种方法,你可以使用本章开头下载的源代码中的step5文件夹来比较和更容易地修复可能的错误。

在下一节中,我们将使用 GitHub Actions 实现持续集成。这是确保应用程序按预期工作的绝佳方式。

使用 GitHub Actions 进行持续集成

我们可以将持续集成理解为对代码进行自动检查的一种方式。这将帮助我们减少人为错误,并帮助我们使检查项目质量的过程自动化。

这是一个可选步骤,不是部署应用程序所必需的,但如果你想更好地了解专业开发环境,你可以跟随操作。

因此,第一步是定义我们期望的自动化内容,然后我们可以实施它。在我们的例子中,我们想要安装依赖项,运行 lint 工具,并运行测试。我们希望在每次将代码推送到仓库时都这样做。

为了实现这一点,我们将创建包含以下内容的.github/workflows/ci.yml文件:

name: Continous Integration
on: [push]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install dependencies
        run: npm install
      - name: Check code style
        run: npm run lint
      - name: Generate a random JWT secret
        id: generate-secret
        run: echo "::set-output name=JWT_SECRET::$(openssl rand -base64 30)"
        shell: bash
      - name: Prepare environment
        run: npm run infra:start
      - name: Run tests
        run: npm test
        env:
          MONGODB_URI: mongodb://localhost:27017/whispering-database
          PORT: 3000
          SALT_ROUNDS: 10
          JWT_SECRET: ${{ steps.generate-secret.outputs.JWT_SECRET }}

此 YAML 文件定义了一个名为持续集成的工作流,每次我们推送代码到仓库时都会触发。此工作流将在装有 Ubuntu 的虚拟机上运行,并执行以下步骤:

  1. 检查仓库中的代码。

  2. 通过运行npm install命令安装依赖项。

  3. 通过运行npm run lint命令运行 lint 工具。

  4. 生成一个随机的 JWT 密钥。我们生成一个 30 个字符的随机字符串,稍后将用作 JWT 密钥。

  5. 通过运行npm run infra:start命令准备环境。

  6. 使用MONGODB_URIPORTSALT_ROUNDSJWT_SECRET环境变量运行测试,我们将使用上一步生成的 JWT 密钥。

一旦我们将代码推送到仓库,我们可以在动作选项卡中检查工作流的状态:

图 16.3 – 显示 GitHub actions 的网页浏览器截图

图 16.3 – 显示 GitHub 动作的网页浏览器截图

如果我们点击工作流,我们可以看到它的详细信息:

图 16.4 – 显示 GitHub Action 执行细节的网页浏览器截图

图 16.4 – 显示 GitHub Action 执行细节的网页浏览器截图

如我们所见,所有的检查都通过了,因此我们可以有信心应用程序按预期工作。

我们可以点击运行测试步骤来查看测试的详细信息:

图 16.5 – 展示测试执行步骤详细信息的网络浏览器截图

图 16.5 – 展示测试执行步骤详细信息的网络浏览器截图

如您所见,测试正在通过,就像在我们的本地机器上一样。最终,持续集成机器只是一个将遵循我们定义的步骤的远程机器,在这方面它与我们的环境没有太大区别。

现在,我们已经设置了持续集成,我们可以在下一节中开始考虑使用 Atlas 准备 MongoDB 实例。

使用 MongoDB Atlas

我们将使用 MongoDB Atlas 托管数据库。我们将创建一个免费层集群,并使用连接字符串来连接到数据库。

这里有一些指南将帮助您:

在我的情况下,我创建了一个名为nodejs-for-beginners的免费层集群,如以下屏幕截图所示:

图 16.6 – 展示集群创建详细信息的网络浏览器截图

图 16.6 – 展示集群创建详细信息的网络浏览器截图

在过程结束时,您将得到一个类似这样的连接字符串(但使用您自己的凭据):

mongodb+srv://<username>:<password>@<cluster-url>/test?retryWrites=true&w=majority

您可以使用该连接字符串从应用程序连接到数据库。您只需要在.env文件中将MONGODB_URI环境变量的值替换为新连接字符串。

重要的是要注意,用户名和密码需要进行 URI 编码,以便将特殊字符转换。这可以通过encodeURIComponent函数轻松完成(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent)。以下是一个转换示例:

encodeURIComponent('P@ssword') // P%40ssword

如果您在本地运行测试或运行应用程序,您将看到应用程序正在使用新的数据库,数据如预期地持久保存在云端。

在以下屏幕截图中,您可以看到数据库中的数据:

图 16.7 – 展示项目详细信息的网络浏览器截图

图 16.7 – 展示项目详细信息的网络浏览器截图

注意

一旦您准备好数据库,您可以将.env文件恢复到原始状态,以避免在未来的执行中将测试数据污染数据库。

现在,我们已经准备好了外部数据库,我们可以开始考虑部署应用程序。在下一节中,我们将使用 PM2 准备应用程序。

使用 PM2 部署 Node.js 应用程序

这是一个非常激动人心的时刻!我们即将将我们的应用程序发布到公共互联网上。在这种情况下,我们将使用 DigitalOcean Droplet 来托管应用程序。Droplet 是一个运行 Ubuntu 23.10 和 0.5 GB RAM 的虚拟机,将托管应用程序。我们将使用 PM2 来保持应用程序运行,并在它崩溃时重新启动它。

如果你不想使用 DigitalOcean,作为替代方案,你可以使用至少有 4 GB RAM 的旧电脑,安装 Ubuntu(或另一个 Linux 发行版),并启用 SSH 通信(目前不需要安装 Node.js 或部署网站)。旧笔记本电脑是一个很好的选择,甚至可以是 Raspberry PI(3、4 或 5)(www.raspberrypi.com/),安装 Raspbian (www.raspberrypi.com/software/),这样也能完成任务。在这里,你可以找到两个教程,帮助你完成设置:

如果设置正确完成,你可以跳过下一节,直接跳到 准备机器 部分。

创建 DigitalOcean Droplet

我们将使用 DigitalOcean 来托管应用程序。我们将使用最基本、最便宜的 Droplet,目前拥有 512 MB RAM 和 1 个虚拟 CPU。

注意

我们将使用 SSH 密钥来访问机器,所以如果你不知道如何操作,请遵循以下指南:

docs.digitalocean.com/products/droplets/how-to/add-ssh-keys/

在我的情况下,我创建了一个名为 nodejs-for-beginners 的 Droplet,如下面的截图所示:

图 16.8 – 显示 Droplet 详细信息的网页浏览器截图

图 16.8 – 显示 Droplet 详细信息的网页浏览器截图

如你所见,Droplet 可在 IP 地址 144.126.217.34 上找到,我们将使用该 IP 地址通过 SSH 或 HTTP 访问机器,当应用程序运行时。

连接到机器

使用 SSH 访问机器有多种方法。最常见的是直接使用终端。但在这个案例中,我们将使用 VSCode 连接到机器。你可以按照code.visualstudio.com/docs/remote/ssh上的指南学习如何操作,因为它比直接从你的终端连接到机器更方便。

在这两种情况下,我们需要使用相同的凭据。用户名是 root,密码被替换为你添加到 Droplet 中的 SSH 密钥。

现在我们已经能够连接到机器,是时候开始设置环境了。

准备机器

一旦你连接到机器,你可以在终端中运行以下命令以创建工作目录并访问新创建的目录:

mkdir nodejs-for-beginners
cd nodejs-for-beginners

然后,我们将使用 nvm 安装 Node.js 20.11.0:

apt update
wget -qO- https: //raw.githubusercontent. com/creationix/nvm/ v0.39.3/install. sh | bash
source ~/.profile
nvm --version
nvm install 20.11.0

输出应该类似于以下内容:

Downloading and installing node v20.11.0...
Downloading https: //nodejs. org/dist/ v20.11.0/n ode-v20.11.0-linux- x64.tar. xz...
################################################################################################## 100.0%
Computing checksum with sha256sum
Checksums matched!
Now using node v20.11.0 (npm v10.2.4)
Creating default alias: default -> 20.11.0 (-> v20.11.0)

下一步是在机器上全局安装 PM2:

npm install pm2 -g

你可以使用以下方法检查 PM2 的版本:

pm2 --version

输出应该类似于以下内容:

[PM2] PM2 Successfully daemonized
5.2.2

现在,机器已经准备好与我们的代码一起工作。我们的下一步是将应用程序代码带到我们的机器上。

克隆仓库

如果你正在使用私有仓库,你需要将 SSH 密钥添加到机器上。你可以遵循docs.github.com/en/github/authenticating-to-github/connecting-to-github-with-ssh上的指南,但如果你将仓库设置为公开,则可以跳过此步骤。

然后我们将克隆仓库:

git clone https: //github. com/YOUR- USER/ YOUR-REPO .git

我们可以通过检查 VSCode 中的目录或运行以下命令来检查代码是否存在于文件夹中:

ls -la

此命令将列出当前目录中的所有文件(包括隐藏文件),并显示这些文件的详细信息。输出应该类似于以下内容:

README.md     docker-compose.yml   node_modules        public      tests
coverage      index.js             package-lock.json   server.js   utils.js
database.js   jest.config.js       package.json        stores      views

我们可以确认代码已被下载,因此我们的下一步将是安装依赖项。

安装依赖项

在克隆仓库后,我们将安装依赖项:

npm install

由于机器性能不是很好,这个过程可能需要一些时间,但应该会无错误地完成。如果你遇到错误或过程比预期长得多,你可以尝试增加 Droplet 的大小,但这会增加每小时的费用。

准备环境

我们将创建一个 .env 文件,就像我们在前面的章节中所做的那样,但我们将使用上一节中创建的 MongoDB Atlas 集群的连接字符串。

一旦准备就绪,应用程序就准备好运行了,但我们将使用 PM2 保持应用程序运行并在它崩溃时重新启动它。

使用 PM2 管理应用程序

我们决定使用 PM2 作为我们应用程序的进程管理器,因此你不会直接使用 node 命令(如 node index.js)启动应用程序。我们将让 PM2 处理应用程序的生命周期。

我们将使用 PM2 启动应用程序:

pm2 start index.js

我们可以使用以下方法检查应用程序的状态:

pm2 status

我们可以使用以下方法检查应用程序的日志:

pm2 logs

我们可以使用以下方法停止应用程序:

pm2 stop index.js

现在,我们可以再次使用 PM2 启动应用程序并检查应用程序是否可以通过互联网访问。

访问应用程序

现在,我们可以使用 Droplet 的 IP 地址访问应用程序。在我的情况下,IP 地址是 144.126.217.34,应用程序运行在端口 3000 上,因此我可以使用以下 URL 访问应用程序:http://144.126.217.34:3000

注意

如果你使用的是不同的主机,例如你本地网络中的机器,这可能会不同,因为它将取决于你的本地网络配置和/或防火墙。但如果你的网络设置正确,那么你应该能够通过使用你本地网络中机器的 IP 地址来访问网站,例如,192.168.1.44

我们可以看到应用程序按预期运行:

图 16.9 – 使用 Droplet IP 运行项目的网页浏览器截图

图 16.9 – 使用 Droplet IP 运行项目的网页浏览器截图

是的!应用程序按预期运行。我们将在下一章探索另一种运行我们应用程序的方法,但这次我们将使用 Docker。

摘要

在本章中,我们学习了如何将我们的应用程序部署到公共互联网。我们了解到明确定义需求以及如何为我们的需求选择最佳解决方案是多么重要。我们为本章中使用的提供商创建了账户,并将应用程序代码推送到 GitHub 仓库以实现适当的源代码控制。

最后,我们使用 MongoDB Atlas 作为外部资源托管数据库,并使用 DigitalOcean Droplet 托管应用程序。我们学习了如何使用 PM2 保持应用程序运行。

进一步阅读

第十七章:将 Node.js 应用 Docker 化

在本章中,我们将学习如何使用 Docker 将我们的应用部署到公共互联网。我们将探讨如何使用 GitHub Actions 确保我们的 Docker 镜像在持续集成CI)管道中运行良好。

我们将学习如何将应用 Docker 化并将镜像发布到 Docker Hub,以实现更好的可移植性,这样我们就可以在不同的环境中下载我们的镜像。

最后,我们将讨论如何进行适当的域名设置,以及如何使用 Cloudflare 添加安全套接字层SSL)证书到应用。我们还将探讨十二要素应用原则。

总结一下,以下是本章我们将探讨的主要主题:

  • 如何使用 GitHub Actions 进行持续集成

  • 如何使用 DigitalOcean Droplet 托管 Docker 应用

  • 如何使用 Docker 构建应用并将镜像发布到 Docker Hub

  • 如何使用 Cloudflare 进行适当的域名设置并添加 SSL 证书到应用

  • 十二要素应用原则是什么,它们如何帮助您成长?

技术要求

要开始本章的工作,我们需要继续使用我们在上一章上传到 GitHub 的代码。如果您还没有完成上一章,您可以从github.com/PacktPublishing/NodeJS-for-Beginners/archive/refs/heads/main.zip下载项目,并作为参考访问step5文件夹。

为了跟随本章中的示例,您需要在与以下提供者创建账户:

本章的代码文件可以在github.com/PacktPublishing/NodeJS-for-Beginners找到。

查看本章动作视频中的代码youtu.be/VWBuF_Q3KPY

使用 Docker 的容器和云原生解决方案

虽然使用虚拟机是一个不错的选择,但它并不是许多应用的最好选择。目前,容器是部署应用最流行的方式。容器轻量级、可移植且易于使用。在本节中,我们将学习如何使用 Docker 部署 Node.js 应用。

在前面的章节中,我们已经介绍了 Docker 的基础知识,并且一直在使用 Docker 和 Docker Compose 运行 MongoDB 数据库。我们现在需要学习如何为我们的应用创建 Docker 镜像以及如何部署它。

Docker 生命周期

我们需要清楚地理解 Docker 的生命周期才能正确使用它。让我们从简要介绍开始。在下面的图中,我们可以看到 Docker 的生命周期:

图 17.1 – Docker 生命周期图

图 17.1 – Docker 生命周期图

我们需要从一个Dockerfile开始,这是一个包含构建镜像指令的文件。然后,我们可以使用docker build命令来构建镜像。接下来,我们可以使用docker run命令来运行容器。

如果我们想与其他人共享镜像,我们可以使用docker push命令将镜像推送到一个注册表。然后,其他人可以使用docker pull命令从注册表中拉取镜像。这一步与npm publish非常相似,但不同的是,我们分享的是镜像而不是代码。

既然我们已经明白了理论,那么在下一节中,我们将把我们的应用程序 Docker 化。

将应用程序 Docker 化

在您的本地机器上,使用 Docker Desktop 1.18,您可以在项目根目录(package.json所在的位置)中运行docker init来创建一个Dockerfile(见docs.docker.com/engine/reference/commandline/init/)。因此,我们可以通过交互式过程自动创建文件:

Let's get started!
? What application platform does your project use? Node
? What version of Node do you want to use? 20.11.0
? Which package manager do you want to use? npm
? What command do you want to use to start the app? npm start
? What port does your server listen on? 3000
CREATED: .dockerignore
CREATED: Dockerfile
CREATED: compose.yaml
✔ Your Docker files are ready!

此工具将创建以下文件:.dockerignoredockerfilecompose.yaml。我们将使用dockerfile来构建镜像,并使用compose.yaml来运行容器。

Dockerfile 将看起来像这样:

# syntax=docker/dockerfile:1
ARG NODE_VERSION=20.11.0
FROM node:${NODE_VERSION}-alpine
ENV NODE_ENV production
WORKDIR /usr/src/app
RUN --mount=type=bind,source=package.json,target=package.json \
    --mount=type=bind,source=package-lock.json,target=package-lock.json \
    --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev
USER node
COPY . .
EXPOSE 3000
CMD npm start

这是一个在 Docker 容器内设置 Node.js 环境的Dockerfile。它首先指定要使用的 Node.js 版本(20.11.0),并使用 Node.js 的 Alpine 版本以较小的体积。它将NODE_ENV环境变量设置为production。然后,它将容器内的工作目录设置为/usr/src/appRUN命令将主机上的package.jsonpackage-lock.json文件挂载到容器中,并为npm模块设置缓存。然后,它运行npm ci --omit=dev来仅安装production依赖项。出于安全原因,它将用户更改为node,将主机当前目录下的所有文件复制到容器当前目录,暴露端口3000以便应用程序可访问,并最终将启动应用程序的命令设置为npm start

对于我们当前的应用程序,我们可以删除compose.yaml文件,因为我们不需要它。重要的是要检查.dockerignore文件的内容,因为它在执行 Dockerfile 中的COPY . .命令时排除了构建过程中的某些文件。

我们已经有了使用 Docker 管理我们应用程序所需的所有文件,因此在下一段中,我们将详细介绍这一点。

使用 Docker 管理应用程序

在上一章中,我们使用了 PM2 来管理应用程序。这次,我们将使用 Docker。我们可以使用以下命令来构建镜像:

docker build -t nodejs-for-beginners .

然后,我们可以使用以下命令来运行容器,该命令将暴露端口3000并使用特定的环境变量:

docker run \
-e MONGODB_URI='mongodb+srv://<username>:<password>@<cluster-url>/test?retryWrites=true&w=majority' \
-e PORT='3000' \
-e SALT_ROUNDS='10' \
-e JWT_SECRET='Tu1fo0mO0PcAvjq^q3wQ24BXNI8$9R' \
-p 3000:3000 \
nodejs-for-beginners

您需要将mongodb+srv://<username>:<password>@<cluster-url>/test?retryWrites=true&w=majority替换为您的 MongoDB Atlas 集群的连接字符串。

如果你打开浏览器并访问 localhost:3000,你会看到应用程序按预期运行。

现在我们知道 Docker 化的应用程序运行良好,我们可以在 CI 中添加一个步骤来确保 Docker 镜像正确生成。

将 docker build 添加到 CI

我们可以将 docker build 步骤添加到 CI 流程中,以确保镜像正确构建。我们可以在 .github/workflows/ci.yml 中的 CI 流程中添加以下步骤:

- name: Build Docker image
  run: docker build -t nodejs-for-beginners .

一旦提交这些更改,你可以在“构建 Docker 镜像”步骤中检查工作流程的状态:

图 17.2 – 检查工作流程状态

图 17.2 – 检查工作流程状态

正如你在 图 17.2 中可以看到的,我们成功构建了 Docker 镜像。在下一节中,我们将学习如何使这个镜像公开。

推送镜像到 Docker Hub

你需要在 Docker Hub 中创建一个新的仓库:hub.docker.com/repositories/new。在我的情况下,我创建了一个名为 nodejs-for-beginners 的私有仓库,如图所示:

图 17.3 – 创建新的仓库

图 17.3 – 创建新的仓库

我建议你创建一个公共镜像,但如果你想要创建一个私有镜像,那么你将需要在目标机器(DigitalOcean Droplet 或其他替代方案)上使用 Docker CLI 登录 Docker Hub。

然后,从你的本地机器,你需要使用以下命令登录 Docker Hub:

docker login

然后,你可以使用以下命令使用存储库的名称构建镜像:

docker build -t YOUR-USER/YOUR-PROJECT:latest .

你需要将 YOUR-USER/YOUR-PROJECT 替换为你的用户和项目名称。在我的情况下,我使用了 ulisesgascon/nodejs-for-beginners

此命令将打印大量日志,但最终,你不应该看到任何错误。

然后,你需要使用以下命令将镜像推送到 Docker Hub:

docker push YOUR-USER/YOUR-PROJECT

输出应该是这样的,使用默认的 latest 标签:

The push refers to repository [docker.io/ulisesgascon/nodejs-for-beginners]
204442a0fb02: Pushed
c797ca72cc32: Pushed
c2f374546252: Pushed
9841711cc266: Mounted from library/node
b748d0576055: Mounted from library/node
f866f7afbf16: Mounted from library/node
4693057ce236: Mounted from library/node
latest: digest: sha256:b82d23e398cf03165e89b8d1661125eda0f7b930e21 eef8c62281acd427e2d06 size: 1787

如果你访问 Docker Hub 仓库,你会看到镜像已经被推送,并准备好在其他机器上使用 docker pull YOUR-USER/YOUR-PROJECT:latest 命令。

正如你在以下图中可以看到的,镜像在 Docker Hub 仓库中可用:

图 17.4 – Docker Hub 仓库中的镜像

图 17.4 – Docker Hub 仓库中的镜像

使用 GitHub Actions 发布镜像

作为将镜像推送到 Docker Hub 的另一种方式,我们可以直接使用 GitHub Actions 发布镜像。这是一个自动化流程的绝佳方式,避免了在本地机器上安装 Docker 的需要,并确保镜像正确构建。

我邀请你自己完成这本书的最后一个挑战。以下是一些提示来帮助你:

在下一节中,我们将学习如何使用 Docker 在 DigitalOcean Droplet 中运行项目。

运行容器

在上一章中,我们使用了 PM2 来管理应用程序的生命周期。这次我们将有所不同:我们将直接使用 Docker。

我们的第一步将是使用 SSH 在目标机器上安装 Docker。遵循安装指南 (docs.docker.com/engine/install/ubuntu/),然后运行 docker run hello-world。该命令将正常运行而不会生成任何错误,这是一个简单的测试,以检查 Docker 引擎是否正确设置并运行。

请确保在我们进行下一步之前,您已经停止了 PM2 应用程序,因为只有一个服务可以控制端口 3000。然后,我们的最后一步将是运行容器,但这次我们不需要构建容器,因为我们直接从 Docker Hub 拉取镜像:

docker run \
-e MONGODB_URI='mongodb+srv://<username>:<password>@<cluster-url>/test?retryWrites=true&w=majority' \
-e PORT='3000' \
-e SALT_ROUNDS='10' \
-e JWT_SECRET='Tu1fo0mO0PcAvjq^q3wQ24BXNI8$9R' \
-p 3000:3000 \
YOUR-USER/YOUR-PROJECT

您需要将 mongodb+srv://<username>:<password>@<cluster-url>/test?retryWrites=true&w=majority 替换为您的 MongoDB Atlas 集群的连接字符串,并将 YOUR-USER/YOUR-PROJECT 替换为您的用户和项目名称。在我的情况下,我使用了 ulisesgascon/nodejs-for-beginners

我们可以使用与运行 PM2 时相同的 IP 地址和端口来查看应用程序按预期运行:

图 17.5 – 使用 Droplet 外部 IP 运行的应用程序

图 17.5 – 使用 Droplet 外部 IP 运行的应用程序

在下一节中,我们将学习如何使用 Cloudflare 来处理域名和证书,这样您的用户就不需要记住服务器的 IP 地址来访问它。如果您正在使用本地机器,那么您的设置将不同,因为您可能没有静态 IP 地址,所以我建议您遵循这个教程:www.youtube.com/watch?v=DCxt9SAnkyc。这样,您的项目就可以通过 ngrok (ngrok.com/) 从互联网上访问。这将生成一个连接隧道到您的机器,并将您的服务暴露为 https://xxxxsxx.ngrok.io,无需担心网络设置。请注意,对互联网流量开放的自托管应用程序需要具备扎实的网络安全知识 (www.youtube.com/watch?v=URWlY3Qr9l8),尤其是如果您计划长期使用这种方法。

注意

如果您在遵循本章步骤运行项目时遇到问题,或者您尝试了替代方法,您可以使用本章开头下载的源代码中的 step6 文件夹来比较和修复可能的错误。

在下一节中,我们将讨论如何进行适当的域名设置以及如何向应用程序添加 SSL 证书。

使用 Cloudflare

应用程序正在 DigitalOcean Droplet 中运行,但只能通过 IP 地址和端口号访问。因此,我们需要进行适当的域名设置并向应用程序添加 SSL 证书。获取域名会有相关的财务成本,根据域名注册商的不同,这个成本可能会有所不同,有些域名的价格可能比其他域名更高。SSL 与 传输层安全性TLS)一起作为机制,我们可以将其添加到我们的 Web 项目中,这将允许客户端和服务器之间的加密。用简单的话说,这将是在使用 http://myproject.comhttps://myproject.com 访问您的网站之间的区别。

许多浏览器今天都会阻止访问未使用 https:// 的网站。我们可以使用 Cloudflare 来启用两者(httphttps),并且对于基本功能是免费的,所以以下是需要遵循的步骤:

  1. 在 Cloudflare 中添加一个新的域名:www.youtube.com/watch?v=7hY3gp_-9EU

  2. 在 Cloudflare 中添加一个新的 DNS 记录:www.youtube.com/watch?v=PYSIt3fEEoI。在我们的例子中,我们将添加一个 A 记录,包含域名或子域名以及 Droplet 的 IP 地址。

    您需要等待 DNS 传播;这可能需要一段时间。

  3. 当 DNS 传播完成后,您可以使用域名访问应用程序。在我的情况下,我可以通过域名 https://demo.ulisesgascon.com 访问应用程序。在以下图中,您可以看到在 Cloudflare 中的域名设置:

图 17.6 – Cloudflare 中的域名设置

图 17.6 – Cloudflare 中的域名设置

如果您不想在 URL 中指定端口号,您可以将应用程序运行在端口 443https 的默认端口)或 80http 的默认端口)上,而不是端口 3000

现在我们已经完成了域名设置,我们可以考虑更高级的主题。在下一节中,我们将探讨十二要素应用原则。

高级内容 – 十二要素应用原则

继续学习的一个好方法是遵循十二要素应用原则。这是一个构建现代、可扩展、可维护和可移植应用程序的方法论,它由 12 个原则组成。以下是与定义一起的 12 个原则,摘自 12factor.net

  • 代码库:一个代码库在版本控制中跟踪,多次部署

  • 依赖项:明确声明并隔离依赖项

  • 配置:将配置存储在环境中

  • 后端服务:将后端服务视为附加资源

  • 构建、发布、运行:严格分离构建和运行阶段

  • 进程:以一个或多个无状态进程执行应用

  • 端口绑定:通过端口绑定导出服务

  • 并发性:通过进程模型进行扩展

  • 可丢弃性:通过快速启动和优雅关闭最大化鲁棒性

  • 开发/生产一致性:尽可能使开发、预发布和生产保持相似

  • 日志:将日志视为事件流

  • 管理进程:以一次性进程运行管理/管理任务

我们在这本书中已经讨论了许多原则,例如配置管理,但还有一些我们尚未涉及。例如,我们没有设置预发布环境,也没有讨论管理进程。我们构建并部署了一个简单的应用,它不是为了供真实用户使用或处理真实流量而设计的,但如果你想要构建和部署实际项目,强烈建议遵循这些原则。

总体来说,这是一个继续学习和更深入理解主题的绝佳方式,同时还能提高我们在这本书中共同构建的应用。

在下一节中,我们将回顾清理本章中使用的资源的步骤,以防你不再需要它们。

清理

一旦我们完成应用,我们可以清理本章中使用的资源,因为我们近期内不再需要它们。大多数资源是免费的,但我强烈建议删除不再需要的资源,尤其是如果你为其中任何一项付费的话。

这些是可以删除的资源:

  • 本章中创建的 DigitalOcean Droplet(s)

  • MongoDB Atlas 集群

  • Docker Hub 仓库

  • Cloudflare 域名

  • GitHub 仓库(尽管我建议你保留它,因为你可以将其用作未来的参考)

信息

你可以通过删除不再需要的 Docker 镜像或你在跟随本书时创建的node_modules文件夹,在本地环境中获得额外的硬盘空间。

这个清理过程是我们这次旅程的最后一个步骤。在你进入最后一节之前,我建议你整理你在这次旅程中记录的笔记,并妥善存储它们,以便将来可以访问。在下一节中,我们将总结本章内容。

摘要

在本章中,我们学习了如何使用 Docker 将我们的应用部署到公共互联网,以及如何使用 GitHub Actions 对我们的 Docker 镜像进行 CI。

我们学习了如何使用 Docker 构建应用,如何将镜像发布到 Docker Hub,并讨论了如何使用 Cloudflare 进行适当的域名设置以及如何将 SSL 证书添加到应用中。我们研究了十二要素应用原则,并回顾了清理本章中使用的资源的步骤。

恭喜你,你做到了!这是旅程的终点。我希望你喜欢这段经历,并且学到了很多。我希望你能继续学习和提升你的技能,并且继续用 Node.js 构建令人惊叹的应用程序。我很乐意听到你的声音,了解你对这本书的看法。你可以在 X/Twitter (twitter.com/kom_256) 或 LinkedIn (www.linkedin.com/in/ulisesgascon/))上联系我。

进一步阅读

posted @ 2025-10-11 12:57  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报