Node-秘籍第五版-全-

Node 秘籍第五版(全)

原文:zh.annas-archive.org/md5/521133866c0296badc6dba6ad6d57cd0

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Node.js 现在已经进入第二个十年,作为一项技术已经显著成熟。如今,它是构建各种规模应用程序的常见技术选择。许多大型企业都在生产中使用 Node.js,包括 Netflix、PayPal、IBM,甚至是 NASA。由于 Node.js 的广泛使用和依赖,它被移至 OpenJS 基金会(前身为 Node.js 基金会)之下。OpenJS 基金会为 JavaScript 项目提供了一个中立的家园,并专注于开放治理。

创建于 2009 年,Node.js 将 Google Chrome 的 JavaScript 引擎 V8 进行了封装,使得 JavaScript 能够在浏览器之外运行。Node.js 将 JavaScript 带到了服务器端,并遵循事件循环架构进行构建,这使得它能够有效地处理输入/输出和并发操作。如今,Node.js 已成为构建各种类型应用程序的流行技术选择,包括 HTTP 网络服务器、微服务、命令行应用程序等。Node.js 成功的关键在于它使得全栈开发可以在一种通用语言——JavaScript 中进行。

Node.js 模块的庞大生态系统支持了其成功。现在,在npm注册表中已有超过三百万个模块可用,许多模块将底层实现细节抽象为更高层次且更易于使用的 API。在npm模块之上构建你的应用程序可以加快开发过程,同时促进代码共享和重用。

近年来,Node.js 似乎偏离了其小巧的核心哲学,提供了一种更“一应俱全”的运行时。这种演变包括将更多功能纳入核心运行时,例如内置的测试运行器,增强了其开箱即用的开发者体验。

《Node.js 烹饪秘籍,第五版》《Node.js 烹饪秘籍,第四版》 的更新版本。内容已根据 Node.js 的最新长期支持版本 Node.js 22 进行了更新。本版涵盖了运行时中的一些新特性,包括内置的测试运行器。

本书面向对象

如果你有些 JavaScript 或其他编程语言的知识,并希望对 Node.js 的基本概念有一个广泛的理解,那么 《Node.js 烹饪秘籍,第五版》 就适合你。本书将提供基础知识,使你能够开始探索 Node.js 和npm生态系统,并开始构建 Node.js 应用程序。

对于已经了解一些 Node.js 的读者来说,可以加深和拓宽他们对 Node.js 概念和特性的了解,而对于初学者来说,可以使用实用的食谱来获得基础知识。

本书涵盖内容

第一章介绍 Node.js 22,作为 Node.js 的介绍,包括如何安装 Node.js 22、访问相关 API 文档以及 Node.js 事件循环的介绍。

第二章与文件系统交互 ,专注于允许我们与标准 I/O、文件系统和网络交互的核心 Node.js API。

第三章使用流 ,探讨了 Node.js 流的基本原理。

第四章使用 Web 协议 ,展示了如何使用 Node.js 核心 API 在低级别上与 Web 协议交互,包括最近添加的 Fetch API。

第五章开发 Node.js 模块 ,教授 Node.js 模块系统的工作原理,并演示如何创建和发布自己的模块到 npm 注册表。

第六章使用 Fastify – 网络框架 ,介绍了 Fastify,这是 Node.js 中最快、最有效的网络框架。强调开发者体验和应用性能,Fastify 遵循 Web 标准,以确保可靠性和兼容性。本章将指导您创建 API 入门项目,将代码拆分为插件,并探索 Fastify 的关键特性,如封装、数据验证、使用序列化提高性能,以及从头开始配置和测试 Fastify 应用程序。

第七章持久化到数据库 ,展示了如何使用 Node.js 将数据持久化到各种数据库中,包括 SQL 和 NoSQL 变体。

第八章使用 Node.js 进行测试 ,教授测试 Node.js 应用程序的基本原理,介绍了关键测试框架和工具,例如各种测试库、模拟 HTTP 请求、浏览器自动化以及配置持续集成测试。

第九章处理安全问题 ,展示了可以对 Node.js 应用程序发起的常见攻击以及我们如何减轻这些攻击。

第十章优化性能 ,展示了我们可以使用的流程和工具来识别 Node.js 应用程序中的瓶颈。

第十一章部署 Node.js 微服务 ,教授如何构建微服务并将其使用容器技术部署到云端。

第十二章调试 Node.js ,展示了用于调试 Node.js 应用程序的工具和技术。

为了充分利用本书

预期您对 JavaScript 或其他编程语言有一些先前的了解。此外,您应该熟悉如何使用终端或 shell,以及如何使用代码编辑器,例如 Visual Studio Code:

本书涵盖的软件/硬件 操作系统要求
Node.js 22 (和 npm) Windows, Mac OS X, 和 Linux (任何版本)
Google Chrome Windows, Mac OS X, 和 Linux (任何版本)
cURL Windows, Mac OS X, 和 Linux (任何版本)
Docker Desktop Windows、Mac OS X 和 Linux(任何)

任何需要上述列出的特定软件的章节或食谱将涵盖技术要求准备工作部分中的安装步骤。

许多终端步骤假设您正在 Unix 环境中操作。在 Windows 上,您应该能够使用Windows 子系统(WSL 2)来完成这些步骤。

食谱步骤已在 macOS Sonoma 上使用 Node.js 22 的最新版本进行了测试。

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

下载示例代码文件

您可以从 GitHub(github.com/PacktPublishing/Node.js-Cookbook-Fifth-Edition)下载本书的示例代码文件。如果代码有更新,它将在现有的 GitHub 仓库中更新。

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

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“process.nextTick()回调在事件循环的当前阶段完成后执行,但在事件循环移动到下一个阶段之前。”

代码块设置如下:

  const name = data.toString().trim().toUpperCase();
  process.stdout.write(`Hello ${name}!`);

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

const fs = require('node:fs');
const rs = fs.createReadStream('./file.txt');
const newFile = fs.createWriteStream('./newFile.txt');
rs.map((chunk) => chunk.toString().toUpperCase()).pipe(newFile);

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

$ mkdir interfacing-with-io
$ cd interfacing-with-io
$ touch greeting.js

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“首先,我们需要在左侧导航窗格中找到并点击文件系统。”

小贴士或重要注意事项

显示如下。

部分

在本书中,您会发现一些经常出现的标题(准备工作如何操作...工作原理...还有更多...参见)。

为了清楚地说明如何完成食谱,请按照以下方式使用这些部分:

准备工作

本节告诉您在食谱中可以期待什么,并描述了如何设置任何必需的软件或任何为食谱所需的初步设置。

如何操作…

本节包含遵循食谱所需的步骤。

工作原理…

本节通常包含对前一个节中发生情况的详细解释。

还有更多…

本节包含有关食谱的附加信息,以便您对食谱有更深入的了解。

参见

本节提供了对其他有用信息的链接,这些信息对食谱很有帮助。

联系我们

我们始终欢迎读者的反馈。

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

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,请访问www.packtpub.com/support/errata ,选择书籍,点击勘误提交表单链接,并输入详细信息。

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

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《Node.js 第五版 烹饪秘籍》,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

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

下载此书的免费 PDF 副本

感谢您购买此书!

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

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

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

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

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

按照以下简单步骤获取福利:

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

img

packt.link/free-ebook/978-1-80461-981-0

  1. 提交您的购买证明

  2. 就这些!我们将直接将您的免费 PDF 和其他福利发送到您的邮箱。

第一章:介绍 Node.js 22

Node.js 于 2009 年创建,是一个跨平台的、开源的 JavaScript 运行时,允许你在浏览器之外执行 JavaScript。Node.js 使用 Google Chrome 的 JavaScript 引擎 V8,使 JavaScript 能够在浏览器之外运行。Node.js 将 JavaScript 带到服务器,使我们能够使用 JavaScript 与操作系统、网络和文件系统交互。Node.js 是按照事件循环架构构建的,这使得它能够有效地处理输入/输出和并发操作。

今天,Node.js 是构建许多类型应用程序的流行技术选择,包括 HTTP 网络服务器、微服务、实时应用程序等。Node.js 成功的部分原因在于它使全栈开发在一种通用语言,JavaScript 中成为可能。

模块的大量生态系统支持了 Node.js 的成功。在 npm 注册表中,有超过 300 万个模块可用,其中许多将低级实现细节抽象为更高级、更易于使用的 API。在 npm 模块之上构建您的应用程序可以加快开发过程,同时促进代码共享和重用。

Node.js 现在已经超过十年历史,作为一项技术已经成熟。如今,它是构建各种规模应用程序的常见技术选择。许多大型企业都在生产中使用 Node.js。由于 Node.js 的广泛应用和依赖性,它被移至 OpenJS 基金会(之前称为 Node.js 基金会)之下。OpenJS 基金会为 JavaScript 项目提供了一个中立的平台,强调对开放治理的坚定承诺。开放治理促进了透明度和问责制,反过来,这有助于确保没有任何个人或公司对项目有过多控制。

本章介绍了 Node.js – 包括如何安装运行时和访问必要文档的说明。

本章将涵盖以下内容:

  • 使用 nvm 安装 Node.js 22

  • 访问 Node.js API 文档

  • 在 Node.js 22 中采用新的 JavaScript 语法

  • 介绍 Node.js 事件循环

技术要求

本章将需要访问终端、您选择的浏览器和互联网。

使用 nvm 安装 Node.js 22

Node.js 遵循发布计划并采用 长期支持LTS)策略。发布计划基于 语义化版本控制semver.org/)规范。

根据 Node.js 发布策略,Node.js 每年进行两次主要更新,分别定于四月和十月。这些主要版本可能引入对 API 的更改,可能会破坏兼容性。然而,Node.js 项目努力将此类破坏性更改的数量和严重性降到最低,旨在减少对最终用户的任何不便。

Node.js 的偶数主版本发布 6 个月后升级为 LTS 版本。偶数版本总是计划在 4 月发布,并在 10 月升级为 LTS。LTS 版本支持长达 30 个月。建议在生产中使用 Node.js 的 LTS 版本。LTS 发布计划的目的是为最终用户提供稳定性,同时也为用户提供一个可预测的发布时间表,以便用户可以适当地管理他们的升级。所有 Node.js 的 LTS 版本都有代号,以元素命名。Node.js 22 的 LTS 代号为“Jod”。

奇数主版本发布于 10 月,仅支持 6 个月。奇数版本主要推荐用于尝试新功能和测试迁移路径,但通常不建议在生产应用中使用。

Node.js 发布工作组负责 Node.js 的发布计划和流程。Node.js 发布计划和政策的文档可以在github.com/nodejs/release找到。

在这本书中,我们将全程使用 Node.js 22。Node.js 22 于 2024 年 4 月发布。Node.js 22 于 2024 年 10 月升级为 LTS 版本,并计划支持至 2027 年 4 月。本食谱将涵盖如何使用node 版本管理器nvm)安装 Node.js 22。nvm是 OpenJS 基金会的一个项目,提供了一种方便的方式来安装和更新 Node.js 版本。

准备工作

您可能还需要在您的设备上具有适当的权限来安装nvm。本食谱假设您在类 Unix 平台上。如果您在 Windows 上,应在 Windows WSL 下运行。

如何操作...

在本食谱中,我们将使用nvm安装 Node.js 22。请按照以下步骤操作:

  1. 首先,我们需要安装nvmnvm提供了一个可以下载和安装nvm的脚本。在您的终端中输入以下命令以执行nvm安装脚本:

    $ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
    
  2. nvm将自动尝试将其自身添加到您的路径中。关闭并重新打开您的终端以确保更改已生效。然后,输入以下命令以列出我们已安装的nvm版本;这也会确认nvm是否在我们的路径中可用:

    $ nvm --version
    0.40.1
    
  3. 要安装 Node.js 22,我们可以使用$ nvm install命令。我们可以提供我们希望安装的特定版本或主版本号。如果我们只指定主版本号,nvm将安装该主版本线的最新版本。输入以下命令以安装 Node.js 22 的最新版本:

    $ nvm install 22
    Downloading and installing node v22.9.0...
    Downloading https://nodejs.org/dist/v22.9.0/node-v22.9.0-darwin-arm64.tar.xz...
    ######################################################################### 100.0%
    Computing checksum with sha256sum
    Checksums matched!
    Now using node v22.9.0 (npm v10.8.3)
    

    注意,此命令将安装 Node.js 22 的最新版本,因此您特定的版本安装可能与前面输出中显示的不同。

  4. Node.js 22 的最新版本现在应该已安装并可在您的路径中使用。您可以通过输入以下命令来确认:

    $ node --version
    v22.9.0
    
  5. nvm还会安装与已安装的 Node.js 版本捆绑的npm版本。输入以下命令以确认已安装的npm版本:

    $ npm --version
    10.8.3
    
  6. nvm使得安装和切换多个 Node.js 版本变得容易。我们可以输入以下命令来安装并切换到最新的 Node.js 20 版本:

    $ nvm install 20
    Downloading and installing node v20.17.0...
    Downloading https://nodejs.org/dist/v20.17.0/node-v20.17.0-darwin-arm64.tar.xz...
    ############################################################################################################################################### 100.0%
    Computing checksum with sha256sum
    Checksums matched!
    Now using node v20.17.0 (npm v10.8.2)
    
  7. 一旦我们安装了这些版本,我们可以使用nvm use命令在它们之间切换:

    $ nvm use 22
    Now using node v22.9.0 (npm v10.8.3)
    

这样,我们就使用nvm安装了 Node.js 的最新版本 22。

它是如何工作的…

nvm是 Unix-like 平台上的 Node.js 版本管理器,并支持可移植操作系统接口POSIX)兼容的 shell。POSIX 是一组由 IEEE 计算机协会定义的操作系统兼容性标准。

首先,我们下载并执行了nvm安装脚本。在底层,nvm安装脚本执行以下操作:

  1. 克隆nvmGitHub 仓库(github.com/nvm-sh/nvm)到~/.nvm/

  2. 尝试向适当的配置文件中添加一些源行以导入和加载nvm,其中配置文件是/.bash_profile**、**/.bashrc/.profile**或**/.zshrc

如果你使用的是之前提到的以外的配置文件,你可能需要手动将以下行添加到你的配置文件中以加载nvm。以下行在nvm安装文档(github.com/nvm-sh/nvm#install--update-script)中指定:

export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm

每次使用$ nvm install安装 Node.js 版本时,nvm将从官方 Node.js 下载服务器下载适合你平台的相应二进制文件。官方 Node.js 下载服务器可以直接访问nodejs.org/dist/nvm将存储它安装的所有 Node.js 版本在~/.nvm/versions/node/目录中。

nvm支持别名,可以用来安装 Node.js 的长版本。例如,你可以使用$ nvm install --lts命令来安装最新的 LTS 版本。

要卸载一个 Node.js 版本,你可以使用$ nvm uninstall命令。要更改默认的 Node.js 版本,使用$ nvm alias default 命令。默认版本是在你打开终端时将可用的版本。

如果你不想使用或无法使用nvm,你可以手动安装 Node.js。访问 Node.js 的下载页面(见nodejs.org/en/download)以下载适合你平台的相应二进制文件。

Node.js 项目为许多平台提供了安装用的 TAR 文件。要通过 TAR 文件安装,你需要下载并解压 TAR 文件,然后将二进制位置添加到你的路径中。

除了 TAR 文件外,Node.js 项目还为 macOS(.pkg)和 Windows(.msi)提供了安装程序。由于手动安装 Node.js,当您需要时,您将需要手动安装 Node.js 的更新版本。

更多信息...

根据语义化版本规范允许的情况,当从 Node.js 主版本升级时,您可能会遇到破坏性更改,这些更改可能会影响或停止您的脚本或应用程序(包括任何依赖项)在先前版本下的执行方式。

在调试升级时,以下是一些建议:

  • 查阅主要版本的发布说明。这些发布说明将突出显示任何破坏性更改、新功能或已弃用功能以及重要更新。了解发生了什么变化可以帮助您识别问题。请注意,如果您是从/to Node.js LTS 版本升级(例如,从 Node.js 20 升级到 22),您应该首先至少查阅每个中间主要版本的变更日志——即 21.0.0 和 22.0.0。

  • 如果您是从非常旧的 Node.js 版本升级,通过中间版本进行增量升级可能是个明智的选择。这可以使识别和逐步解决兼容性问题变得更加容易。当使用如 nvm 这样的 Node.js 版本管理器时,这会变得更容易,因为您可以在不同的版本上运行和测试您的代码。

  • 检查您项目的依赖项,并确保它们与新的 Node.js 版本兼容。过时或未维护的包可能无法与最新的 Node.js 版本正确工作。

  • 为您的应用程序创建全面的测试套件。在升级 Node.js 之前和之后运行您的测试套件,以确保您的代码按预期运行。

  • 使用 Node.js 调试工具。Node.js 提供了各种诊断工具,可以帮助您在升级过程中识别和解决问题。

  • 利用在线社区、论坛和文档。Node.js 社区中的其他人可能在升级过程中遇到过类似问题,并可以提供宝贵的见解和解决方案。这可能包括在官方 Node.js 存储库之一上提出 GitHub 问题(github.com/nodejs/nodegithub.com/nodejs/help)。

请记住,调试 Node.js 升级可能需要时间和彻底的测试。为潜在挑战做好准备,并制定计划以减轻对应用程序功能可能造成的任何中断至关重要。

访问 Node.js API 文档

Node.js 项目提供了全面的 API 参考文档。Node.js API 文档是理解您正在使用的 Node.js 版本中哪些 API 可用的关键资源。Node.js 文档还描述了如何与 API 交互,包括给定方法接受的参数和方法返回值。

本指南将展示如何访问和浏览 Node.js API 文档。

准备工作

你需要访问你选择的浏览器和互联网连接来访问 Node.js API 文档。

如何操作…

这个菜谱将演示如何浏览 Node.js API 文档。按照以下步骤操作:

  1. 首先,在你的浏览器中导航到 nodejs.org/api/

    你将看到 Node.js API 文档的最新版本:

图 1.1 – Node.js API 文档首页

图 1.1 – Node.js API 文档首页

  1. 将鼠标悬停在 Other versions 下拉菜单上,查看 Node.js 的其他发布版本。这样你可以更改你正在查看文档的 Node.js 版本:

图 1.2 – 显示 Other versions 下拉菜单的 Node.js API 文档

图 1.2 – 显示 Other versions 下拉菜单的 Node.js API 文档

  1. 现在,假设我们想要找到 fs.readFile() 方法的文档。fs.readFile() 方法通过 文件系统 核心模块暴露。首先,我们需要在左侧导航栏中找到并点击 文件系统。点击 文件系统 将带我们到 文件系统 核心模块 API 文档的目录:

图 1.3 – Node.js API 文档的文件系统子系统

图 1.3 – Node.js API 文档的文件系统子系统

  1. 滚动直到你在目录中找到列出的 fs.readFile() 方法。在寻找特定的 API 时,使用浏览器搜索功能定位 API 定义可能是有益的。点击目录中的 fs.readFile() 链接。这将打开 API 定义:

图 1.4 – 显示 fs.readFile() API 定义的 Node.js API 文档

图 1.4 – 显示 fs.readFile() API 定义的 Node.js API 文档

  1. 现在,点击左侧导航栏中的 命令行选项。此页面详细介绍了可以传递给 Node.js 进程的所有可用命令行选项:

图 1.5 – 显示可用命令行选项的 Node.js API 文档

图 1.5 – 显示可用命令行选项的 Node.js API 文档

通过这样,我们已经学会了如何访问和浏览 Node.js API 文档。

它是如何工作的…

Node.js API 文档是构建 Node.js 应用程序时的一个重要参考资源。文档针对 Node.js 的每个版本。在本食谱中,我们访问了 Node.js 最新版本的文档,这是在nodejs.org/api/渲染的默认文档版本。以下 URL 可以用来访问 Node.js 特定版本的文档:nodejs.org/docs/v22.0.0/api/index.html(将v22.0.0替换为您希望查看文档的特定版本)。

API 文档详细说明了 Node.js API 的使用,包括以下内容:

  • 接受的参数及其类型

  • 如果适用,API 返回的值和类型

在某些情况下,文档将提供更多信息,包括使用示例或示例代码来展示 API 的使用方法。

注意,有一些 API 没有文档。一些 Node.js API 有意未记录,因为它们被认为是仅限内部使用,并且不打算在 Node.js 核心运行时之外使用。

API 文档还详细说明了 API 的稳定性。Node.js 项目定义并使用以下四个稳定性指标:

  • 0 – 已废弃:不鼓励使用这些 API。使用这些 API 时可能会发出警告。已废弃的 API 也将列在nodejs.org/docs/latest-v22.x/api/deprecations.html

  • 1 – 实验:这些 API 被认为是不稳定的,可能会进行一些非向后兼容的更改。实验性 API 不受语义版本控制规则的限制。在生产和环境中使用这些 API 应谨慎进行。最近,Node.js 文档中的“实验”状态已被分解为多个阶段,以尝试表明功能的成熟度:

    • 1.0 - 早期开发

    • 1.1 - 活跃开发

    • 1.2 - 候选发布版

  • 2 – 稳定:对于稳定的 API,Node.js 项目将尝试确保兼容性。

  • 3 – 旧版:旧版功能可能不再维护,或者可能有更现代的替代方案。然而,它们不太可能被移除,并继续遵守语义版本控制规则。

Node.js 文档由 Node.js 项目在 Node.js 核心仓库中维护。任何错误或建议的改进都可以在github.com/nodejs/node提出。

还有更多...

Node.js 项目为 Node.js 的每个发布分支维护一个.md文件,详细说明每个发布中包含的个别提交。Node.js 22 的CHANGELOG.md文件可以在github.com/nodejs/node/blob/main/doc/changelogs/CHANGELOG_V22.md找到。

以下是从 Node.js 22 CHANGELOG.md 文件中的一个片段:

图 1.6 – 示例 Node.js 22.0.0 CHANGELOG.md 条目

图 1.6 – 示例 Node.js 22.0.0 CHANGELOG.md 条目

Node.js 项目突出了每个版本中的显著变化。CHANGELOG.md 文件标明了哪些提交根据语义化版本控制标准(semver.org/)被确定为 SEMVER-MINOR。标记为 SEMVER-MINOR 的条目表示功能添加。CHANGELOG.md 文件还将标明何时一个发布被认为是安全发布(修复了一个安全问题)。在安全发布的情况下,显著变化 部分会以句子 This is a security release. 开头。

对于主要版本,Node.js 项目在 Node.js 网站上发布一个发布公告,详细说明新功能和变化。Node.js 22 版本的发布公告可在 nodejs.org/en/blog/announcements/v22-release-announce 查找。

在升级 Node.js 时,可以使用 Node.js CHANGELOG.md 文件作为参考,帮助你了解新版本中包含哪些更新和变化。

在 Node.js 22 中采用新的 JavaScript 语法

JavaScript 语言的正式规范是 ECMAScript。新的 JavaScript 功能通过更新 Node.js 所基于的底层 V8 JavaScript 引擎而进入 Node.js。ECMAScript 每年都会更新,提供新的 JavaScript 语言特性和语法。

Node.js 的新主要版本通常包括对 V8 引擎的重大升级。Node.js 版本 22.0.0 伴随着 V8 版本 12.4 的发布。然而,V8 版本可能会在 Node.js 22 的生命周期内升级。

V8 的更新版本为 Node.js 运行时带来了底层性能改进以及新的 JavaScript 语言特性和语法。本配方将展示 Node.js 22 中引入的一些较新的 JavaScript 语言特性。

准备工作

对于这个配方,你需要安装 Node.js 22。你还需要能够访问终端。

如何操作…

在这个配方中,我们将使用 Node.js Read Eval Print Loop ( REPL ) 来测试 Node.js 22 中可用的较新 JavaScript 功能。按照以下步骤操作:

  1. 首先,让我们打开 Node.js REPL。在你的终端中输入以下命令:

    $ node
    
  2. 这应该会打开 REPL,这是一个我们可以用来执行代码的接口。预期会看到以下输出:

图 1.7 – Node.js REPL

图 1.7 – Node.js REPL

  1. 首先输入以下命令。此命令将返回你使用的 Node.js 版本中嵌入的 V8 版本:

    > process.versions.v8
    '12.4.254.21-node.19'
    
  2. 自 Node.js 20 以来,新增了两个 JavaScript String 方法:

    • String.prototype.isWellFormed:此方法返回提供的 String 是否是格式良好的 UTF-16

    • String.prototype.toWellFormed:此方法将替换不成对的代理字符为替换字符U+FFFD),从而使 UTF-16 字符串格式良好。

    你可以在 REPL 中演示这一点:

    > "006E006F00640065".isWellFormed()
    true
    
  3. 另一个最近添加的功能是Intl.NumberFormat内置对象,它提供基于语言的数字格式化。让我们来测试一下。在 REPL 中声明一个数字:

    > const number = 123456.789;
    undefined
    
  4. 接下来,让我们将这个数字格式化为英国英镑GBP)的形式:

    > new Intl.NumberFormat('en-UK', { style: 'currency', currency: 'GBP' }).format(number);
    '£123,456.79'
    
  5. 在 Node.js 22 中,作为 V8 12.4 更新的部分,添加了新的Set方法,如并集交集差集。这些增强功能使得对数值集合的操作更加容易。以下是一个涉及质数和奇数的示例:

    > const oddNumbers = new Set([1, 3, 5, 7]), primeNumbers = new Set([2, 3, 5, 7]);
    undefined
    > console.log('All Numbers:', [...(oddNumbers.union(primeNumbers))].toString());
    All Numbers: 1,3,5,7,2
    undefined
    > console.log('Common Numbers:', [...(oddNumbers.intersection(primeNumbers))].toString());
    Common Numbers: 3,5,7
    undefined
    > console.log('Exclusive Primes:', [...(primeNumbers.difference(oddNumbers))].toString());
    Exclusive Primes: 2
    

使用 REPL,我们已经探索了 Node.js 22 中可用的几个新的 JavaScript 语言特性。这次学习的目标是通过升级底层 Google Chrome V8 引擎,使新的 JavaScript 语言特性变得可用。

它是如何工作的...

新的 JavaScript 语言特性是通过更新底层 Google Chrome V8 JavaScript 引擎引入到 Node.js 中的。JavaScript 引擎解析并执行 JavaScript 代码。将 Google Chrome V8 引擎嵌入到 Node.js 中使得在浏览器之外执行 JavaScript 成为可能。Chrome 的 V8 JavaScript 引擎是许多可用的 JavaScript 引擎之一,Mozilla 的 SpiderMonkey 也是另一个主要的 JavaScript 引擎,它被用于 Mozilla Firefox 浏览器。

每 6 周,Google Chrome 的 V8 引擎就会发布一个新版本。Node.js 22 将继续将更新整合到 V8 中,前提是它们可以与应用程序二进制接口ABI)兼容。ABI 描述了程序如何通过编译程序与函数和数据结构交互。它可以被认为是应用程序编程接口API)的编译版本。

一旦发布了一个不再允许 ABI 兼容性的 V8 版本,Node.js 的具体发布线将固定在那个版本的 V8 上。然而,具体的 V8 补丁和修复可能继续直接应用于那个 Node.js 发布线。目前,Node.js 20 已固定在 V8 版本 11.3 上,而 Node.js 22 在撰写本文时处于 V8 12.4。Node.js 22 中的 V8 版本将继续更新,直到无法再维护新版本的 V8 的 ABI 兼容性。

V8 JavaScript 引擎使用即时编译JIT)内部编译 JavaScript。JIT 编译加速了 JavaScript 的执行。当 V8 执行 JavaScript 时,它会获取有关正在执行的代码的数据。从这个数据中,V8 引擎可以进行推测性优化。推测性优化根据最近执行的代码预测即将到来的代码。这允许 V8 引擎为即将到来的代码进行优化。

V8 博客提供了新 V8 发布的公告,并详细介绍了 V8 的新功能和更新。V8 博客可通过v8.dev/blog访问。

介绍 Node.js 事件循环

Node.js 事件循环是 Node.js 中的一个基本概念,它使 Node.js 能够高效地执行异步和非阻塞操作。这是一个负责在事件驱动环境中管理代码执行机制的机制。理解 Node.js 事件循环对于构建可扩展和响应式的应用程序至关重要,尤其是在处理如读取文件、发起网络请求或同时处理多个客户端连接等 I/O 密集型任务时。

准备工作

你需要安装 Node.js 22。你还需要能够访问终端。

如何做…

在这个菜谱中,我们将创建两个文件。一个将演示如何阻塞事件循环,而另一个将演示如何不阻塞事件循环。按照以下步骤操作:

  1. 创建一个名为 blocking.js 的文件。

  2. 添加以下代码:

    // Blocking function
    function blockingOperation() {
      console.log('Start blocking operation');
      // Simulate a time-consuming synchronous operation (e.g., reading a large file)
      for (let i = 0; i < 1000000000; i++) {
        // This loop will keep the CPU busy for a while, blocking other operations
      }
      console.log('End blocking operation');
    }
    console.log('Before blocking operation');
    // Call the blocking function
    blockingOperation();
    console.log('After blocking operation');
    
  3. 运行脚本并观察它是如何等待阻塞操作的:

    $ node blocking.js
    Before blocking operation
    Start blocking operation
    End blocking operation
    After blocking operation
    

    此脚本通过使用保持 CPU 忙碌的同步循环来演示阻塞操作。当你运行脚本时,你会注意到它记录 Start blocking operation ,执行阻塞循环,并最终记录 End blocking operationAfter blocking operation

  4. 现在,让我们实现一个非阻塞脚本。创建一个名为 non-blocking.js 的文件。

  5. 添加以下代码:

    console.log('Before non-blocking operation');
    // Non-blocking operation (setTimeout)
    setTimeout(() => {
      console.log('Non-blocking operation completed');
    }, 2000); // Simulate a non-blocking operation that takes 2 seconds
    console.log('After non-blocking operation');
    
  6. 运行脚本并观察其执行情况:

    $ node non-blocking.js
    Before non-blocking operation
    After non-blocking operation
    Non-blocking operation completed
    

    此脚本通过使用 setTimeout 函数演示了非阻塞操作,该函数实现了至少 2 秒的延迟。当你运行此脚本时,它会记录 Before non-blocking operation ,安排超时,立即记录 After non-blocking operation ,然后,在 2 秒后,记录 Non-blocking operation completed 。这个例子演示了在 2 秒的延迟期间,Node.js 保持响应,可以继续执行其他任务,这表明此操作是非阻塞的。

  7. 让我们通过 process.nextTick() 来演示 Node.js 事件循环。为此,创建一个名为 next-tick.js 的文件。

  8. 将以下代码添加到 next-tick.js 中:

    console.log('Start');
    process.nextTick(() => {
      console.log('Callback scheduled with
        process.nextTick #1');
    });
    setTimeout(() => {
      console.log('setTimeout #1 callback');
    }, 0);
    process.nextTick(() => {
      console.log('Callback scheduled with
        process.nextTick #2');
    });
    console.log('End');
    
  9. 运行程序并观察它们的执行顺序:

    $ node next-tick.js
    Start
    End
    Callback scheduled with process.nextTick #1
    Callback scheduled with process.nextTick #2
    setTimeout #1 callback
    

StartEnd 记录语句立即执行,因为它们是主代码执行的一部分。使用 process.nextTick() 安排的两个回调在所有其他安排的回调之前执行,在 setTimeout 回调之前。这是因为 process.nextTick() 回调具有最高优先级,并在下一个事件循环周期的开始处运行。在 process.nextTick() 回调执行后,setTimeout 回调才会执行。

它是如何工作的…

Node.js 在单线程环境中运行,这意味着它使用单个主执行线程来执行您的 JavaScript 代码。然而,Node.js 可以通过利用异步、非阻塞 I/O 来处理许多并发操作。

Node.js 是事件驱动的,这意味着它依赖于事件和回调来响应各种操作或事件执行代码。事件可以是 I/O 操作(例如,读取文件或发起网络请求),定时器,或由您的代码触发的自定义事件。

关于 Node.js 处理 I/O 有一些关键概念需要理解:

  • 非阻塞:Node.js 在操作完成之前不会等待每个操作,这被称为非阻塞。Node.js 可以同时处理多个任务,使其在 I/O 密集型操作中非常高效。

  • 事件队列:当您执行异步操作,如读取文件时,Node.js 不会阻塞整个程序。相反,它将这些操作放入一个称为事件队列的队列中,并继续执行其他任务。

  • 事件循环事件循环持续运行并检查事件队列。如果队列中有完成的操作(例如,文件读取完成),它将执行与该操作关联的回调函数。

  • 回调函数:当启动异步操作时,您通常会提供一个回调函数。当操作完成时,该函数会被调用。例如,如果您正在读取文件,回调函数将处理文件内容可用时的操作。

libuv (libuv.org/)作为底层库,通过提供跨平台的、高效的、并发的 I/O 框架来为 Node.js 事件循环提供动力。它使 Node.js 能够在各种操作系统上保持兼容性的同时,实现非阻塞、异步的特性。

更多内容

Node.js 事件循环操作一系列阶段。深入理解这一流程对于调试、性能优化以及充分利用 Node.js 的非阻塞方法至关重要。

当 Node.js 进程启动时,事件循环被初始化,并处理输入脚本。事件循环将持续运行,直到事件循环中没有待处理项或显式调用process.exit()

事件循环阶段如下:

  • 定时器阶段:此阶段检查是否有需要执行的已计划定时器。这些定时器通常使用如setTimeout()setInterval()等函数创建。如果定时器指定的时长已过,其回调函数将被添加到 I/O 轮询阶段。

  • 待处理回调阶段:在此阶段,事件循环检查已完成(或出错)I/O 操作的事件。这包括例如文件系统操作、网络请求和用户事件。如果这些操作中的任何一项已完成,它们的回调函数将在这一阶段执行。

  • 空闲和准备阶段:这些阶段在典型应用开发中很少使用,通常保留用于特殊用例。空闲阶段运行在空闲期间安排执行的回调函数,而准备阶段用于为轮询事件做准备。

  • 轮询阶段:轮询阶段是事件循环中大多数动作发生的地方。它执行以下任务:

    • 检查新的 I/O 事件(例如套接字上的传入数据)并执行其回调函数(如果有的话)。

    • 如果没有挂起的 I/O 事件,它将检查回调队列,以查找由计时器或 setImmediate() 安排的挂起回调。如果找到任何,它们将被执行。

    • 如果没有挂起的 I/O 事件或回调函数,事件循环可能会进入阻塞状态,等待新事件到来。这被称为“轮询”。

  • 检查阶段:在这个阶段,使用 setImmediate() 注册的回调函数将被执行。任何回调函数都会在当前的轮询阶段之后执行,但在任何 I/O 回调函数之前。

  • 关闭回调阶段:这个阶段负责执行关闭事件回调,例如使用 socket.on('close', ...) 事件注册的回调。

完成所有这些阶段后,事件循环检查是否有挂起的计时器、I/O 操作或其他事件。如果有,它将回到适当的阶段来处理它们。如果没有进一步的挂起事件,Node.js 进程将结束。

process.nextTick() 在阶段中未详细说明。这是因为 process.nextTick() 将提供的回调函数安排在事件循环的下一次 tick 上运行。重要的是,这个回调函数的执行优先级高于其他异步操作。process.nextTick() 回调函数在事件循环的当前阶段完成后执行,但在事件循环移动到下一个阶段之前。这允许你安排任务以更高的优先级运行,这使得它在确保某些函数在当前操作之后立即运行时非常有用。

第二章:与文件系统交互

在 Node.js 之前,JavaScript 主要用于浏览器。Node.js 将 JavaScript 带到服务器,并使我们能够通过 JavaScript 与操作系统交互。今天,Node.js 是构建服务器端应用最受欢迎的技术之一。

Node.js 在基本层面上与操作系统交互:输入和输出 ( I/O )。本章将探讨 Node.js 提供的核心 API,这些 API 允许我们与标准 I/O、文件系统和网络堆栈交互。

本章将向您展示如何同步和异步地读取和写入文件。Node.js 是为了处理异步代码并启用非阻塞模型而构建的。了解如何读取和写入异步代码是基本的学习,它将展示如何利用 Node.js 的功能。

我们还将了解 Node.js 提供的核心模块。我们将重点关注 文件系统 模块,它允许您与文件系统及文件交互。Node.js 的新版本添加了许多文件系统 API 的 Promise 变体,这些内容也将在本章中涉及。

本章将涵盖以下食谱:

  • 与文件系统交互

  • 文件操作

  • 获取元数据

  • 监视文件

技术要求

本章假设您已安装了最新版本的 Node.js 22,一个 终端 或 shell,以及您选择的编辑器。本章的代码可在 GitHub 的 github.com/PacktPublishing/Node.js-Cookbook-Fifth-Edition 中的 Chapter02 目录找到。

本章将使用 CommonJS 语法;有关 CommonJS 和 ECMAScript 模块的信息,请参阅 第五章

与文件系统交互

标准输入 ( stdin ) 指的是程序可以用来从命令行或终端读取输入的输入流。同样,标准输出 ( stdout ) 指的是用于写入输出的流。标准错误 ( stderr ) 是与 stdout 分离的流,通常用于输出错误和诊断数据。

在这个食谱中,我们将学习如何处理 stdin 的输入,将输出写入 stdout,并将错误记录到 stderr

准备工作

对于这个食谱,我们首先创建一个名为 greeting.js 的单个文件。程序将通过 stdin 请求用户输入,通过 stdout 返回问候语,当提供无效输入时将错误记录到 stderr。同时,我们也创建一个工作目录:

$ mkdir interfacing-with-io
$ cd interfacing-with-io
$ touch greeting.js

现在我们已经设置了目录和文件,我们可以继续进行食谱步骤。

如何操作…

在这个食谱中,我们将创建一个程序,可以从 stdin 读取并写入 stdoutstderr

  1. 首先,我们需要告诉程序监听用户输入。这可以通过向 greeting.js 添加以下行来实现:

    console.log('What is your name?');
    process.stdin.on('data', (data) => {
      // processing on each data event
    });
    
  2. 我们可以使用以下命令运行文件。注意,应用程序没有退出,因为它正在继续监听 process.stdin 数据事件:

    $ node greeting.js
    
  3. 使用 Ctrl + C 退出程序。

  4. 我们现在可以告诉程序每次检测到数据事件时应该做什么。在 // processing on each data event 注释下方添加以下行:

      const name = data.toString().trim().toUpperCase();
      process.stdout.write(`Hello ${name}!`);
    
  5. 你现在可以向程序输入。当你按下 Enter 时,它将返回一个问候语和你的大写名字:

    $ node greeting.js
    What is your name?
    Beth
    Hello BETH!
    
  6. 我们现在可以添加一个检查输入字符串是否为空的检查,并在它是空的情况下向 stderr 记录。将你的文件更改为以下内容:

    console.log('What is your name?');
    process.stdin.on('data', (data) => {
      // processing on each data event
      const name = data.toString().trim().toUpperCase();
      if (name !== '') {
        process.stdout.write(`Hello ${name}!`);
      } else {
        process.stderr.write('Input was empty.\n');
      }
    });
    
  7. 再次运行程序并输入没有输入的 Enter

    $ node greeting.js
    What is your name?
    Input was empty.
    

我们现在创建了一个可以从 stdin 读取并写入 stdoutstderr 的程序。

它是如何工作的…

process.stdinprocess.stdoutprocess.stderr 属性都是进程对象上的属性。全局进程对象提供了对 Node.js 进程的信息和控制。对于每个 I/O 通道(标准输入、标准输出、标准错误),它们在接收到每个数据块时都会发出数据事件。在本配方中,我们以交互模式运行程序,其中每个数据块由你在 shell 中按下 Enter 时的换行符确定。

The process.stdin.on('data', (data) => {...}); 实例是监听这些数据事件的。每个数据事件返回一个 Buffer 对象。该 Buffer 对象(通常命名为 data)返回输入的二进制表示。

const name = data.toString() 实例是将 Buffer 对象转换为字符串。trim() 函数会从字符串的开始和结束处移除所有空白字符——包括空格、制表符和换行符。空白字符包括空格、制表符和换行符。

我们使用进程对象上的相应属性(process.stdout.writeprocess.stderr.write)向 stdoutstderr 写入。

在配方过程中,我们还使用了 Ctrl + C 来在 shell 中退出程序。Ctrl + C 向 Node.js 进程发送 SIGINT,或信号中断。有关信号事件的更多信息,请参阅 Node.js 进程 API 文档:nodejs.org/api/process.html#process_signal_events

重要提示

控制台 API:在底层,console.logconsole.err 使用 process.stdoutprocess.stderr。控制台方法是高级 API,包括自动格式化。通常,当需要更多对流的控制时,会使用控制台方法来提高便利性,并使用低级进程方法。

还有更多…

截至 Node.js 17.0.0 版本,Node.js 提供了一个实验性的 Readline Promises API,用于逐行读取文件。此 Promises API 变体允许您使用 async / await 而不是回调,提供了一种更现代、更简洁的处理异步操作的方法。

下面是一个如何使用 Promises API 变体创建一个类似于主菜谱中创建的 greeting.js 文件的示例程序:

const readline = require('node:readline/promises');
async function greet () {
  const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout
      });
  const name = await rl.question('What is your name?\n');
  console.log(`Hello ${name}!`);
  rl.close();
}
greet();

此 Node.js 脚本使用了 node:readline/promises 模块,该模块提供了 Readline API 的 Promise 变体。它定义了一个异步函数 greet(),该函数在控制台中提示用户输入他们的名字,然后用个性化的消息问候他们——类似于主菜谱程序。使用 Readline Promises API 允许我们使用 async / await 语法来编写更干净的异步代码流。我们将在后面的菜谱和章节中介绍更多关于 async / await 语法的知识。

参见

  • 第三章解耦 I/O 菜谱

文件操作

Node.js 提供了多个核心模块,包括 fs 模块。fs 代表文件系统,此模块提供了与文件系统交互的 API。

在本菜谱和整本书中,我们将使用 node: 前缀来导入核心模块。

在本菜谱中,我们将学习如何使用 fs 模块中提供的同步函数来读取、写入和编辑文件。

准备工作

让我们先为这个菜谱准备一个目录和文件:

  1. 为此菜谱创建另一个目录:

    $ mkdir working-with-files
    $ cd working-with-files
    
  2. 现在,让我们创建一个用于读取的文件。在您的 shell 中运行以下命令以创建包含一些简单文本的文件:

    $ echo Hello World! > hello.txt
    
  3. 我们还需要为我们的程序创建一个文件——创建一个名为 readWriteSync.js 的文件:

    $ touch readWriteSync.js
    

重要提示

touch 工具是 Unix 类操作系统中的一个命令行工具,用于将文件或目录的访问和修改日期更新为当前时间。然而,当 touch 在没有额外参数的情况下运行在不存在文件上时,它将创建一个空文件,并使用该名称。touch 工具是创建空文件的典型方法。

如何操作…

在本菜谱中,我们将使用 fs 模块提供的同步函数同步地读取名为 hello.txt 的文件,操作文件内容,然后使用同步函数更新文件:

  1. 我们将首先引入 fspath 内置模块。将以下行添加到 readWriteSync.js 中:

    const fs = require('node:fs');
    const path = require('node:path');
    
  2. 现在,让我们创建一个变量来存储我们之前创建的 hello.txt 文件的文件路径:

    const filepath = path.join(process.cwd(), 'hello.txt');
    
  3. 我们现在可以使用 fs 模块提供的 readFileSync() 函数同步地读取文件内容。我们还将使用 console.log() 将文件内容打印到 stdout

    const contents = fs.readFileSync(filepath, 'utf8');
    console.log('File Contents:', contents);
    
  4. 现在,我们可以编辑文件的内容了——我们将把小写文本转换为大写:

    const upperContents = contents.toUpperCase();
    
  5. 要更新文件,我们可以使用 writeFileSync() 函数。之后,我们还会添加一个 log 语句来指示文件已被更新:

    fs.writeFileSync(filepath, upperContents);
    console.log('File updated.');
    
  6. 使用以下命令运行您的程序:

    $ node readWriteSync.js
    File Contents: Hello World!
    File updated.
    
  7. 要验证内容是否已更新,您可以在终端中使用 cat 命令来显示 hello.txt 的内容:

    $ cat hello.txt
    HELLO WORLD!
    

现在,您有一个程序,当运行时,将读取 hello.txt 的内容,将文本内容转换为大写,并更新文件。

它是如何工作的…

如同常见情况,文件的前两行需要程序所需的必要核心模块。

const fs = require('node:fs'); 这一行将导入 Node.js 核心文件系统模块。Node.js 文件系统模块的 API 文档可在 nodejs.org/api/fs.html 找到。fs 模块提供了使用 Node.js 与文件系统交互的 API。同样,核心 path 模块提供了用于处理文件和目录路径的 API。path 模块 API 文档可在 nodejs.org/api/path.html 找到。

接下来,我们使用 path.join()process.cwd() 函数定义了一个变量来存储 hello.txt 的文件路径。path.join() 函数将提供的路径部分与特定平台的分隔符(例如,Unix 环境中的 / 和 Windows 环境中的 ****)连接起来。

process.cwd() 函数是全局 process 对象上的一个函数,它返回 Node.js 进程的当前目录。此程序期望 hello.txt 文件与程序位于同一目录。

接下来,我们使用 fs.readFileSync() 函数读取文件。我们将要读取的文件路径和编码 UTF-8 传递给此函数。编码参数是可选的——当省略此参数时,函数将默认返回一个 Buffer 对象。

为了对文件内容进行操作,我们使用了字符串对象上可用的 toUpperCase() 函数。

最后,我们使用 fs.writeFileSync() 函数更新了文件。我们向 fs.writeFileSync() 函数传递了两个参数。第一个参数是我们希望更新的文件的路径,第二个参数是更新后的文件内容。

重要提示

readFileSync()writeFileSync() API 都是同步的,这意味着它们将在文件读取或写入完成后才会阻塞/延迟并发操作。为了避免阻塞,您将想要使用这些函数的异步版本,这在本食谱的 还有更多… 部分中进行了介绍。

还有更多…

在整个过程中,我们都是同步地对文件进行操作的。然而,Node.js 的开发重点是使非阻塞 I/O 模型成为可能;因此,在许多(如果不是大多数)情况下,您会希望操作是异步的。

今天,在 Node.js 中处理异步代码有三种显著的方法——回调、Promises 和 async / await 语法。Node.js 的最早版本只支持回调模式。Promises 是随着 ECMAScript 2015(也称为 ES6)的 JavaScript 规范一起添加的,随后 Node.js 也添加了对 Promises 的支持。在添加 Promise 支持之后,Node.js 也添加了对 async / await 语法的支持。

所有当前支持的 Node.js 版本现在都支持回调、Promises 和 async / await 语法 - 你可能会在现代 Node.js 开发中看到这些中的任何一个。让我们探索如何使用这些技术异步处理文件。

异步处理文件

异步编程可以在其他操作进行时使某些任务或处理继续进行。

来自 与文件一起工作 食谱的程序是使用 fs 模块中可用的同步函数编写的:

const fs = require('node:fs');
const path = require('node:path');
const filepath = path.join(process.cwd(), 'hello.txt');
const contents = fs.readFileSync(filepath, 'utf8');
console.log('File Contents:', contents);
const upperContents = contents.toUpperCase();
fs.writeFileSync(filepath, upperContents);
console.log('File updated.');

这意味着程序被阻塞,等待 readFileSync()writeFileSync() 操作完成。这个程序可以被重写以利用异步 API。

readFileSync() 的异步版本是 readFile()。一般规则是,同步 API 的名称将附加“sync”后缀。异步函数需要一个回调函数传递给它。回调函数包含我们希望在异步任务完成时执行的代码。

以下步骤将实现与 与文件一起工作 食谱中的程序相同的行为,但使用异步方法:

  1. 在本食谱中,readFileSync() 函数可以被更改为使用以下异步函数:

    const fs = require('node:fs');
    const path = require('node:path');
    const filepath = path.join(process.cwd(),
      'hello.txt');
    fs.readFile(filepath, 'utf8', (err, contents) => {
      if (err) {
        return console.log(err);
      }
      console.log('File Contents:', contents);
      const upperContents = contents.toUpperCase();
      fs.writeFileSync(filepath, upperContents);
      console.log('File updated.');
    });
    

    注意,所有依赖于文件读取的处理都需要在回调函数内部进行。

  2. writeFileSync() 函数也可以替换为 writeFile() 异步函数:

    const fs = require('node:fs');
    const path = require('node:path');
    const filepath = path.join(process.cwd(),
      'hello.txt');
    fs.readFile(filepath, 'utf8', (err, contents) => {
      if (err) {
        return console.log(err);
      }
      console.log('File Contents:', contents);
      const upperContents = contents.toUpperCase();
      fs.writeFile(filepath, upperContents, (err) => {
        if (err) throw err;
        console.log('File updated.');
      });
    });
    

    注意,我们现在有一个异步函数调用了另一个异步函数。不建议有太多的嵌套回调,因为它可能会对代码的可读性产生负面影响。考虑以下内容,看看过多的嵌套回调是如何阻碍代码的可读性的,这有时被称为“回调地狱”:

    first(args, () => {
        second(args, () => {
            third(args, () => {});
        });
    });
    
  3. 可以采取一些方法来避免过多的嵌套回调。一种方法是将回调函数拆分为显式命名的函数。例如,我们可以将文件重写,使得 writeFile() 调用包含在其自己的命名函数 updateFile() 中:

    const fs = require('node:fs');
    const path = require('node:path');
    const filepath = path.join(process.cwd(), 'hello.txt');
    fs.readFile(filepath, 'utf8', (err, contents) => {
      if (err) {
        return console.log(err);
      }
      console.log('File Contents:', contents);
      const upperContents = contents.toUpperCase();
      updateFile(filepath, upperContents);
    });
    function updateFile (filepath, contents) {
      fs.writeFile(filepath, contents, function (err) {
        if (err) throw err;
        console.log('File updated.');
      });
    }
    

    另一种方法是使用 Promises,我们将在本章的 使用 fs Promises API 部分中介绍。但是,由于 Node.js 的最早版本不支持 Promises,回调的使用在许多 npm 模块和现有应用程序中仍然很普遍。

  4. 为了演示此代码是异步的,我们可以使用setInterval()函数在程序运行时将字符串打印到屏幕上。setInterval()函数允许你安排在指定的毫秒延迟后执行一个函数。将以下行添加到程序末尾:

    setInterval(() => process.stdout.write('**** \n'), 1).unref();
    

    注意到字符串每毫秒都会继续打印,即使在文件正在读取和重写之间。这表明文件读取和写入是以非阻塞方式实现的,因为操作仍在处理文件时完成。

重要提示

setInterval()中使用unref()意味着这个计时器不会保持 Node.js 事件循环活跃。这意味着如果它是事件循环中唯一的活跃事件,Node.js 可能会退出。这对于你希望在将来执行某个操作但不想仅为了保持 Node.js 进程运行而使用计时器的情况非常有用。

  1. 为了进一步演示这一点,你可以在文件的读取和写入之间添加一个延迟。为此,将updateFile()函数包裹在setTimeout()函数中。setTimeout()函数允许你传递一个函数和一个毫秒延迟:

    setTimeout(() => updateFile(filepath, upperContents), 10);
    
  2. 现在,我们程序的输出应该在文件读取和写入之间打印出更多的星号,因为这是我们添加了 10 毫秒延迟的地方:

    $ node readFileAsync.js
    ****
    ****
    File Contents: HELLO WORLD!
    ****
    ****
    ****
    ****
    ****
    ****
    ****
    ****
    ****
    File updated.
    

我们现在可以看到,我们已经将程序从“与文件一起工作”食谱转换为使用回调语法异步处理文件操作。

使用 fs Promises API

fs Promises API 在 Node.js v10.0.0 版本中发布。该 API 提供了返回Promise对象而不是回调的文件系统函数。并非所有原始fs模块 API 都有等效的基于Promise的 API,因为只有原始 API 的一个子集被转换为提供Promise API。请参阅 Node.js API 文档以获取通过fs Promises API 提供的fs函数的完整列表:nodejs.org/docs/latest/api/fs.html#promises-api .

Promise是一个用于表示异步函数完成的对象。命名基于该术语“promise”的一般定义——即做某事或某事将要发生的协议。一个Promise对象始终处于以下三种状态之一:

  • 待定

  • 已完成

  • 已拒绝

Promise最初处于待定状态,并且将保持待定状态,直到它变为已完成——当任务成功完成时——或已拒绝——当任务失败时。

以下步骤将再次实现与食谱中程序相同的行为,但使用fs Promise API 方法:

  1. 要使用该 API,你首先需要导入它:

    const fs = require('node:fs/promises');
    
  2. 然后,可以使用readFile()函数读取文件:

    fs.readFile(filepath, 'utf8').then((contents) => {
        console.log('File Contents:', contents);
    });
    
  3. 你还可以将fs Promises API 与async / await语法结合使用:

    const fs = require('node:fs/promises');
    const path = require('node:path');
    const filepath = path.join(process.cwd(),
      'hello.txt');
    async function run () {
      try {
        const contents = await fs.readFile(filepath,
          'utf8');
        console.log('File Contents:', contents);
      } catch (error) {
        console.error(error);
      }
    }
    run();
    

本实现有两个值得注意的方面,包括以下内容的使用:

  • async function run() {...} : 定义了一个名为 run() 的异步函数。异步函数允许使用 await 关键字以更同步的方式处理承诺。

  • await fs.readFile(filepath, 'utf8') : 使用 await 关键字异步读取指定文件的文件内容。

现在,我们已经学会了如何使用 fs Promises API 与文件进行交互。

重要提示

由于本章使用了 CommonJS,因此有必要将 async / await 示例包装在一个函数中,因为 await 只能在 CommonJS 的异步函数内部调用。从 第五章 开始,我们将介绍 ECMAScript 模块,由于 ECMAScript 模块支持 顶层 await,因此不需要这个包装函数。

参见

  • 本章的 获取元数据 食谱

  • 本章的 监视文件 食谱

  • 第五章

获取元数据

fs 模块通常提供基于 Portable Operating System Interface ( POSIX ) 函数的 API。fs 模块包括便于读取目录和文件元数据的 API。

在这个食谱中,我们将创建一个小程序,使用 fs 模块提供的函数返回有关文件的信息。

准备工作

  1. 开始创建一个工作目录:

    $ mkdir fetching-metadata
    $ cd fetching-metadata
    
  2. 我们还需要创建一个用于读取的文件和一个用于程序的文件:

    $ touch metadata.js
    $ touch file.txt
    

如何做…

使用 准备工作 部分中创建的文件,我们将创建一个程序,该程序提供有关作为参数传递给它的文件的信息:

  1. 与之前的食谱一样,我们首先需要导入必要的核心模块。对于这个食谱,我们只需要导入 fs 模块:

    const fs = require('node:fs');
    
  2. 接下来,我们需要程序能够读取文件名作为命令行参数。为了读取文件参数,我们可以使用 process.argv[2]。将以下行添加到你的程序中:

    const file = process.argv[2];
    
  3. 现在,我们将创建我们的 printMetadata 函数:

    function printMetadata(file) {
      const fileStats = fs.statSync(file);
      console.log(fileStats);
    }
    
  4. 添加对 printMetadata 函数的调用:

    printMetadata(file);
    
  5. 你现在可以运行程序,传递给它 ./file.txt 参数。使用以下命令运行你的程序:

    $ node metadata.js ./file.txt
    
  6. 预期看到以下输出:

    Stats {
      dev: 16777231,
      mode: 33188,
      nlink: 1,
      uid: 501,
      gid: 20,
      rdev: 0,
      blksize: 4096,
      ino: 16402722,
      size: 0,
      blocks: 0,
      atimeMs: 1697208041116.9521,
      mtimeMs: 1697208041116.9521,
      ctimeMs: 1697208041116.9521,
      birthtimeMs: 1697208041116.9521,
      atime: 2023-10-13T14:40:41.117Z,
      mtime: 2023-10-13T14:40:41.117Z,
      ctime: 2023-10-13T14:40:41.117Z,
      birthtime: 2023-10-13T14:40:41.117Z
    }
    

    你可以尝试向 file.txt 添加一些随机文本,保存文件,然后重新运行你的程序;观察到的 sizemtime 值已更新。

  7. 现在,让我们看看当我们向程序传递一个不存在的文件时会发生什么:

    $ node metadata.js ./not-a-file.txt
    node:fs:1658
      const stats = binding.stat(
                            ^
    Error: ENOENT: no such file or directory, stat './not-a-file.txt'
    

    程序抛出了异常。

  8. 我们应该捕获这个异常,并向用户输出一条消息,说明提供的文件路径不存在。为此,将 printMetadata 函数更改为以下内容:

    function printMetadata(file) {
      try {
        const fileStats = fs.statSync(file);
        console.log(fileStats);
      } catch (err) {
        console.error('Error reading file path:', file);
      }
    }
    
  9. 再次运行程序,使用一个不存在的文件:

    $ node metadata.js ./not-a-file.txt
    Error reading file: ./not-a-file.txt
    

这次,你应该看到程序处理了错误而不是抛出异常。

它是如何工作的…

process.argv 属性是全局进程对象的一个属性,它返回一个包含传递给 Node.js 进程的参数的数组。process.argv 数组的第一个元素 process.argv[0] 是正在运行的 node 二进制文件的路径。第二个元素是我们正在执行的文件路径——在本例中,是 metadata.js 。在配方中,我们将文件名作为第三个命令行参数传递,因此用 process.argv[2] 来引用它。

接下来,我们创建了一个名为 printMetadata() 的函数,该函数调用了 statSync(file)statSync() 是一个同步函数,它返回传递给它的文件路径的信息。传递给该函数的文件路径可以是文件或目录。返回的信息以 stats 对象的形式呈现。以下表格列出了在 stats 对象上返回的信息:

表 2.1 – 列出在 stats 对象上返回的属性的表格

表 2.1 – 列出在 stats 对象上返回的属性的表格

重要提示

在本配方中,我们只使用了同步的文件系统 API。对于大多数 fs API,每个函数都有同步和异步版本。有关使用异步文件系统 API 的更多信息,请参阅 Working with files 配方的 Working with files asynchronously 部分。

在本配方的最后几个步骤中,我们编辑了我们的 printMetadata 函数,以处理无效的文件路径。我们通过将 statSync 函数包裹在 try / catch 语句中来做到这一点。

还有更多...

接下来,我们将探讨如何检查文件访问权限、修改文件权限以及如何检查一个 符号链接symlink)。

检查文件访问

如果您尝试读取、写入或编辑文件,建议您遵循我们在配方中使用的处理文件未找到错误的方法。

然而,如果您只想检查文件的存在,可以使用 fs.access()fs.accessSync() API。具体来说,fs.access() 函数测试用户访问传递给它的文件或目录的权限。该函数还允许传递一个可选的 mode 参数,您可以使用 Node.js 文件访问常量请求函数执行特定的访问检查。Node.js 文件访问常量的列表可在 Node.js fs 模块 API 文档中找到:nodejs.org/api/fs.html#fs_file_access_constants 。这些使您能够检查 Node.js 进程是否可以读取、写入或执行提供的文件路径。

重要提示

现在已弃用的旧版 API,称为 fs.exists()。不建议您使用此函数。弃用此函数的原因是该方法的接口被发现存在错误,可能导致意外的竞争条件。应使用 fs.access()fs.stat() API。

修改文件权限

Node.js fs 模块提供了可以用来更改给定文件权限的 API。与其他许多 fs 函数一样,存在异步 API chmod() 和等效的同步 API chmodSync()。两个函数分别接受文件路径和 mode 作为前两个参数。chmod() 函数接受第三个参数,即完成时要执行的回调函数。

重要提示

chmod 命令用于更改 Unix 和类似操作系统上的文件系统对象的访问权限。如果您不熟悉 Unix 文件权限,建议您参考 Unix 手册页(linux.die.net/man/1/chmod)。

mode 参数可以是使用 fs 模块提供的系列常量构成的数字掩码形式,或者是一个由三个八进制数字组成的序列。用于创建定义用户权限的掩码的常量在 Node.js API 文档中定义:nodejs.org/api/fs.html#fs_file_modes

假设你有一个文件,当前具有以下权限:

  • 拥有者可读和可写

  • 群组可读

  • 只能由所有其他用户读取(有时被称为世界可读)

如果我们还想额外授予同一组用户的写访问权限,可以使用以下 Node.js 代码:

const fs = require('node:fs');
const file = './file.txt';
fs.chmodSync(
  file,
  fs.constants.S_IRUSR |
    fs.constants.S_IWUSR |
    fs.constants.S_IRGRP |
    fs.constants.S_IWGRP |
    fs.constants.S_IROTH
);

如您所见,此代码相当冗长。添加复杂的权限序列需要传递许多常量以创建数字掩码。或者,我们可以向 chmodSync() 函数传递文件权限的八进制表示,这在使用 Unix 命令行上的 chmod 命令时很常见。

我们将使用命令行上的 chmod 664 的等效方式来更改权限,但通过 Node.js 实现:

const fs = require('fs');
const file = './file.txt';
fs.chmodSync(file, 0o664);

重要提示

请参考mason.gmu.edu/~montecin/UNIXpermiss.htm获取更多关于 Unix 权限如何工作的详细信息。

Windows 文件权限:Windows 操作系统上的文件权限不如 Unix 那样精细——只能表示文件为可写或不可写。

检查符号链接

符号链接是一个特殊的文件,它存储了对另一个文件或目录的引用。当在 获取元数据 菜单中的 stat()statSync() 函数上运行符号链接时,该方法将返回符号链接所引用的文件的信息,而不是符号链接本身。

尽管如此,Node.js fs 模块确实提供了名为 lstat()lstatSync() 的方法,这些方法可以检查符号链接本身。以下步骤将演示您如何使用这些方法来检查我们将创建的符号链接:

  1. 要创建符号链接,可以使用以下命令:

    $ ln -s file.txt link-to-file
    

    现在,你可以使用 Node.js 的 Read-Eval-Print Loop ( REPL ) 来测试 lstatSync() 函数。Node.js REPL 是一个交互式外壳,我们可以向其中传递语句,它将评估它们并将结果返回给用户。

  2. 要进入 Node.js REPL,请在您的壳中输入 node

    $ node
    Welcome to Node.js v22.9.0.
    Type ".help" for more information.
    >
    
  3. 你可以输入以下命令:

    > console.log('Hello World!');
    Hello World!
    undefined
    
  4. 现在,你可以尝试使用 lstatSync 命令:

    > fs.lstatSync('link-to-file');
    Stats {
      dev: 16777224,
      mode: 41453,
      nlink: 1,
      ...
    }
    

注意,我们不需要显式导入 Node.js fs 模块。REPL 自动加载核心(内置)Node.js 模块,以便它们可供使用。REPL 是一个有用的工具,可以在不创建文件的情况下测试命令。

相关内容

  • 本章的 监视文件 示例

监视文件

Node.js 的 fs 模块提供了功能,使您能够监视文件并跟踪文件或目录何时被创建、更新或删除。

在这个示例中,我们将创建一个名为 watch.js 的小程序,它使用 watchFile() API 监视文件中的更改,并在发生更改时打印一条消息。

准备工作

  1. 对于这个食谱,我们希望在新的目录内工作。创建并切换到名为 file-watching 的目录:

    $ mkdir file-watching
    $ cd file-watching
    
  2. 我们还需要创建一个我们可以监视的文件:

    $ echo Hello World! > file.txt
    
  3. 创建一个 watch.js 文件:

    $ touch watch.js
    

现在我们已经创建了我们的目录和文件,我们可以继续到食谱。

如何实现...

我们将创建一个程序来监视给定文件中的更改——在这个例子中,是我们在前面创建的 file.txt 文件。我们将使用 fs 模块,特别是 watchFile() 方法来实现这一点:

  1. 要开始,导入所需的 Node.js 核心模块:

    const fs = require('node:fs');
    const path = require('node:path');
    
  2. 我们还需要程序访问我们创建的文件:

    const file = path.join(process.cwd(), 'file.txt');
    
  3. 接下来,我们调用 fs.watchFile() 函数:

    fs.watchFile(file, (current, previous) => {
        return console.log(`${file} updated
          ${(current.mtime)}`);
    });
    
  4. 现在,你可以使用以下命令在您的壳中运行程序:

    $ node watch.js
    
  5. 在您的编辑器中打开 file.txt 并进行一些编辑,每次编辑后保存。您会注意到每次保存时,在您运行 watch.js 的终端中都会出现一条日志条目:

    ./file.txt updated Mon Oct 16 2023 00:44:19 GMT+0100 (British Summer Time)
    
  6. 当我们在这里时,我们可以使时间戳更易读。为此,我们将使用 Intl.DateTimeFormat 对象。这是一个内置的 JavaScript 工具,用于操作日期和时间。

  7. 添加并更改以下行以使用 Intl.DateTimeFormat 格式化日期:

      const formattedTime = new Intl.DateTimeFormat('en-
        GB', {
        dateStyle: 'full',
        timeStyle: 'long'
      }).format(current.mtime);
      return console.log(`${file} updated
        ${formattedTime}`);
    
  8. 重新运行程序并对 file.txt 进行进一步编辑——注意现在时间以您时区更易读的格式显示:

    $ node watch.js
    ./file.txt updated Monday 16 October 2024 at 00:45:27 BST
    

工作原理...

在这个示例中,我们使用了 watchFile() 函数来监视给定文件上的更改。该函数接受三个参数——一个文件名、一个可选的选项列表和一个监听函数。options 对象可以包括以下内容:

  • BigIntBigInt对象是一个 JavaScript 对象,允许你更可靠地表示更大的数字。默认值为false;当设置为true时,从Stats对象返回的数值将指定为BigInt

  • persistent:此值表示 Node.js 进程是否应在文件仍在监视时继续运行。它默认设置为true

  • intervalinterval值控制文件应该多久轮询一次以检查更改,以毫秒为单位。当未提供间隔时,默认值为 5,007 毫秒。

传递给watchFile()函数的监听函数会在检测到更改时执行。监听函数的参数 current 和 previous 都是Stats对象,代表文件的当前状态和之前状态。

我们传递给watchFile()的监听函数会在被监视的文件检测到更改时执行。每次我们的updated函数返回true时,它都会将更新消息记录到stdout

Node.js 的fs模块提供了一个名为watch()的另一个函数,该函数可以监视文件的变化,也可以监视目录。这个函数与watchFile()不同,因为它利用操作系统的底层文件系统通知实现,而不是轮询更改。

虽然比watchFile() API 更快、更可靠,但 Watch API 在不同平台之间并不一致。这是因为 Watch API 依赖于底层操作系统通知文件系统更改的方法。Node.js API 文档详细介绍了 Watch API 在不同平台上的限制:nodejs.org/docs/latest/api/fs.html#fs_availability

watch()函数类似地接受三个参数——文件名、选项数组和一个监听函数。可以通过options参数传递的选项如下:

  • persistentpersistent选项是一个布尔值,表示 Node.js 进程是否应在文件仍在监视时继续运行。默认情况下,persistent选项设置为true

  • recursiverecursive选项是另一个布尔值,允许用户指定是否应该监视子目录中的更改——默认值设置为falserecursive选项仅在 macOS 和 Windows 操作系统上受支持。

  • encodingencoding选项用于指定应使用哪种字符编码来指定文件名——默认为utf8

  • Signal:一个AbortSignal对象,可以用来取消文件监视。

传递给watch() API 的监听函数与传递给watchFile() API 的监听函数略有不同。监听函数的参数是eventTypetrigger,其中eventTypechangerename,而trigger是触发事件的文件。以下代码表示与我们在食谱中实现的任务类似,但使用的是监视 API:

const fs = require('node:fs');
const file = './file.txt';
fs.watch(file, (eventType, filename) => {
  const formattedTime = new Intl.DateTimeFormat('en-GB',
  {
    dateStyle: 'full',
    timeStyle: 'long'
  }).format(new Date());
  return console.log(`${filename} updated
    ${formattedTime}`);
});

食谱的最后几步涵盖了使用综合的Intl.DateTimeFormat实用工具来操作日期和时间。有关可用格式和 API 的列表,请参阅MDN Web 文档developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat

重要提示

moment.js库曾经是 JavaScript 中日期操作和格式化的首选库。然而,随着现代 JavaScript 的发展,内置功能如Intl.DateTimeFormat提供了类似的原生功能。此外,moment.js的维护者已经将其置于维护模式,这意味着不会添加新功能。考虑到其包大小的问题,许多开发者发现moment.js对于他们的项目来说不再是必需的,而是使用内置功能或更现代的替代库。

还有更多...

nodemon实用工具是 Node.js 中一个流行的npm模块实用工具,当它检测到代码更改时会自动重启您的应用程序。您无需在每次代码更改后手动停止和启动服务器,nodemon会自动处理。

nodemon的典型安装和使用方法如下:

$ npm install --global nodemon // globally install nodemon
$ nodemon app.js // nodemon will watch for updates and restart

更新版本的 Node.js(晚于 v18.11.0)具有内置的监视模式功能。要启用监视模式,您需要提供--watch命令行进程标志:

$ node --watch app.js

在监视模式下,对观察文件的修改将触发 Node.js 进程的重启。默认情况下,内置的监视模式将监视主入口文件以及任何所需的或导入的模块。

您也可以使用--watch-path命令行进程标志指定要监视的确切文件:

$ node --watch-path=./src --watch-path=./test app.js

更多信息可以在 Node.js API 文档中找到:nodejs.org/dist/latest-v22.x/docs/api/cli.html#--watch

参见

  • 第一章中,关于在 Node.js 22 中采用新 JavaScript 语法的食谱

第三章:处理流

流是 Node.js 的关键特性之一。大多数 Node.js 应用程序都依赖于 Node.js 的底层流实现,无论是用于读取/写入文件、处理 HTTP 请求还是其他网络通信。流提供了一种机制,可以顺序地读取输入和写入输出。

通过顺序读取数据块,我们可以处理非常大的文件(或其他数据输入),这些文件通常太大,无法一次性读入内存并整体处理。流对于大数据应用或媒体流服务是基本的,在这些服务中,数据太大,无法一次性消费。

Node.js 中有四种主要的流类型:

  • 可读流:用于读取数据,例如读取文件或从请求中读取数据。

  • 可写流:用于写入数据,例如写入文件或将数据发送到响应。

  • 双工流:用于读取和写入数据,例如 TCP 套接字。

  • 转换流:一种双工流,它转换数据输入,然后输出转换后的数据。一个常见的例子是压缩流。

本章将演示我们如何创建这些不同类型的流,以及如何将这些类型的流连接起来形成流管道。

本章将涵盖以下食谱:

  • 创建可读和可写流

  • 与暂停的流交互

  • 管道流

  • 创建转换流

  • 构建流管道

重要提示

本章的食谱将专注于 Node.js 22 中 Node.js 核心stream模块提供的流实现。因此,我们将不使用readable-stream模块(github.com/nodejs/readable-stream)。readable-stream模块旨在通过提供一个可独立安装的模块作为流实现的镜像来减轻 Node.js 版本之间流实现的不一致性。在撰写本文时,readable-stream的最新主要版本是版本 4,它与 Node.js 18 的流实现相一致。

技术要求

对于本章,您应该已经安装了 Node.js 22,最好是 Node.js 22 的最新版本。您还需要访问终端、编辑器和互联网。

本章的代码示例可在本书的 GitHub 仓库(github.com/PacktPublishing/Node.js-Cookbook-Fifth-Edition)的Chapter03目录中找到。

创建可读和可写流

Node.js 的stream核心模块提供了 Node.js 流 API。本食谱将介绍如何在 Node.js 中使用流。它将涵盖如何创建可读流和可写流,以使用 Node.js 核心fs模块与文件交互。

准备工作

在深入这个食谱之前,我们必须通过创建目录和文件来设置我们的工作区:

  1. 首先,让我们创建一个工作目录:

    $ mkdir learning-streams
    $ cd learning-streams
    
  2. 创建以下两个文件:

    $ touch write-stream.js
    $ touch read-stream.js
    

现在,我们准备好开始这个菜谱了。

如何操作…

在这个菜谱中,我们将学习如何创建可读流和可写流。首先,我们将创建一个可写流,以便我们可以写入大文件。然后,我们将使用可读流读取这个大文件:

  1. 首先,将 Node.js 核心文件系统模块导入到write-stream.js中:

    const fs = require('node:fs');
    
  2. 接下来,我们将使用fs模块上的createWriteStream()方法创建可写流:

    const file = fs.createWriteStream('./file.txt');
    
  3. 在这一点上,我们可以开始向文件写入内容。让我们多次将随机字符串写入文件:

    const fs = require('node:fs');
    const file = fs.createWriteStream('./file.txt');
    for (let i = 0; i <= 100000; i++) {
      file.write(
        'Node.js is a JavaScript runtime built on Google
        Chrome\'s V8 JavaScript engine.\n'
      );
    }
    
  4. 现在,我们可以使用以下命令运行脚本:

    $ node write-stream.js
    
  5. 这将在你的当前目录中创建一个名为file.txt的文件。文件大小约为7.5M。要检查文件是否存在,请在你的终端中输入以下命令:

    $ ls -lh file.txt
    -rw-r--r--  1 bgriggs  staff   7.5M  8 Nov 16:30 file.txt
    
  6. 接下来,我们将创建一个脚本,该脚本将创建一个可读流来读取文件内容。从导入fs核心模块开始read-stream.js文件:

    const fs = require('node:fs');
    
  7. 现在,我们可以使用createReadStream()方法创建我们的可读流:

    const rs = fs.createReadStream('./file.txt');
    
  8. 接下来,我们可以注册一个data事件处理程序,每次读取到数据块时都会执行:

    rs.on('data', (data) => {
      console.log('Read chunk:', data);
    });
    
  9. 我们还将添加一个end事件处理程序,当没有更多数据可以从流中消费时,它将被触发:

    rs.on('end', () => {
      console.log('No more data.');
    });
    
  10. 使用以下命令运行程序:

    $ node read-stream.js
    

    预期将看到以Buffer数据形式记录的数据块:

图 3.1 – 流读取的数据块片段

图 3.1 – 流读取的数据块片段

  1. 如果我们在data事件处理函数中的数据块上调用toString(),我们将看到处理过程中输出的String内容。将data事件处理函数更改为以下内容:

    rs.on('data', (data) => {
      console.log('Read chunk:', data.toString());
    });
    
  2. 使用以下命令重新运行脚本:

    $ node read-stream.js
    

    预期将看到以下输出:

图 3.2 – 流读取的数据块片段,以字符串形式

图 3.2 – 流读取的数据块片段,以字符串形式

通过这种方式,我们使用createWriteStream()创建了一个文件,然后使用createReadStream()读取了该文件。

它是如何工作的…

在这个菜谱中,我们使用createReadStream()createWriteStream()核心fs方法顺序地写入和读取文件。Node.js 核心fs模块依赖于底层的 Node.js stream核心模块。通常,Node.js stream核心模块不会直接交互。你通常会通过高级 API 与 Node.js stream实现交互,例如fs模块公开的 API。

重要提示

关于 Node.js 底层流实现和 API 的更多信息,请参阅 Node.js stream模块文档,链接为nodejs.org/docs/latest-v22.x/api/stream.html

我们通过fs.createWriteStream()方法创建了一个可写流,以顺序写入我们的文件内容。fs.createWriteStream()方法接受两个参数。第一个是要写入文件的路径,而第二个是一个options对象,可以用来向流提供配置。

以下表格详细说明了我们可以通过一个options对象传递给fs.createWriteStream()方法的配置:

Option 描述 默认值
flags 定义文件系统标志。 w
encoding 文件的编码。 utf8
fd fd值预期为一个文件描述符。当提供此值时,将忽略path参数。 null
mode 设置文件权限。 0o666
autoClose autoClose设置为true时,文件描述符将自动关闭。当false时,需要手动关闭文件描述符。 true
emitClose 控制流在销毁后是否发出close事件。 false
start 可以用作整数,指定开始写入数据的位置。 0
fs 用于覆盖fs实现。 null
signal 用于指定一个AbortSignal对象以编程方式取消流的写入。 null
highWaterMark 用于指定在应用背压之前可以缓冲的最大字节数。 16384

表 3.1 – 可以传递给 createWriteStream()方法的配置

重要注意事项

关于文件系统标志的更多信息,请参阅nodejs.org/api/fs.html#fs_file_system_flags

然后,我们创建了一个可读流来顺序读取我们文件的 内容。createReadStream()方法是一个可读流的抽象。同样,此方法期望两个参数 - 第一个是读取内容的路径,第二个是一个options对象。以下表格详细说明了我们可以通过一个options对象传递给createReadStream()方法的选项:

Option 描述 默认值
flags 定义文件系统标志。 r
encoding 文件的编码。 null
fd fd值预期为一个文件描述符。当提供此值时,将忽略path参数。 null
mode 设置文件权限,但仅在文件创建时。 0o666
autoClose autoClose设置为true时,文件描述符将自动关闭。当false时,需要手动关闭文件描述符。 true
emitClose 控制流在销毁后是否发出close事件。 false
start 可以用作整数,指定开始读取数据的位置。 0
end 可以用作整数,指定停止读取数据的位置。 Infinity
highWaterMark 指定在流停止读取输入之前存储在内部缓冲区中的最大字节数。 64 KiB
fs 用于覆盖fs实现。 null
signal 用于指定一个AbortSignal对象以程序化取消流的读取。 null

表 3.2 – 可以传递给 createReadStream()方法的配置

read-stream.js中,我们注册了一个data事件处理程序,每次我们的可读流读取一块数据时都会执行。我们可以看到随着读取,屏幕上会显示各个数据块的输出:

Read chunk: <Buffer 20 62 75 69 6c 74 20 6f 6e 20 47 6f 6f 67 6c 65 20 43 68 72 6f 6d 65 27 73 20 56 38 20 4a 61 76 61 53 63 72 69 70 74 20 65 6e 67 69 6e 65 2e 0a 4e 6f ... 29149 more bytes>

一旦读取了所有文件数据,我们的end事件处理程序就会被触发——导致出现没有更多****数据的消息。

所有 Node.js 流都是EventEmitter类的实例(nodejs.org/api/events.html#events_class_eventemitter)。流会发出一系列不同的事件。

以下事件在可读流上发出:

  • close:当流及其任何资源被关闭时发出。不会发出更多事件。

  • data:当从流中读取新数据时发出。

  • end:当所有可用数据都被读取时发出。

  • error:当可读流遇到错误时发出。

  • pause:当可读流被暂停时发出。

  • readable:当有可读数据时发出。

  • resume:当可读流在暂停状态下恢复时发出。

以下事件在可写流上发出:

  • close:当流及其任何资源被关闭时发出。不会发出更多事件。

  • drain:当可写流可以恢复写入数据时发出。

  • error:当可写流遇到错误时发出。

  • finish:当可写流结束,所有写入都已完成后发出。

  • pipe:当在可读流上调用stream.pipe()方法时发出。

  • unpipe:当在可读流上调用stream.unpipe()方法时发出。

更多内容...

让我们更深入地了解可读流,包括如何从无限数据源读取。我们还将学习如何使用更现代的异步迭代语法与可读流一起使用。

与无限数据交互

流使得与无限量的数据交互成为可能。让我们编写一个脚本,该脚本将按顺序、无限地处理数据:

  1. learning-streams目录下,创建一个名为infinite-read.js的文件:

    $ touch infinite-read.js
    
  2. 我们需要一个无限的数据源。我们将使用/dev/urandom文件,该文件可在类 Unix 操作系统上使用。这是一个伪随机数生成器。将以下内容添加到infinite-read.js以计算/dev/urandom的持续大小:

    const fs = require('node:fs');
    const rs = fs.createReadStream('/dev/urandom');
    let size = 0;
    rs.on('data', (data) => {
      size += data.length;
      console.log('File size:', size);
    });
    
  3. 使用以下命令运行脚本:

    $ node infinite-read.js
    

    预期将看到类似以下输出,显示/dev/urandom文件不断增长的大小:

图 3.3 – 显示/dev/urandom 文件不断增长大小的输出

图 3.3 – 显示/dev/urandom 文件不断增长大小的输出

此示例演示了我们可以如何使用流来处理无限量的数据。

带有异步迭代器的可读流

可读流是异步可迭代对象。这意味着我们可以使用for await...of语法来遍历流数据。在以下步骤中,我们将使用for await...of语法实现与主要食谱相同的功能:

  1. 创建一个名为for-await-read-stream.js的文件:

    $ touch for-await-read-stream.js
    
  2. 要使用异步迭代器实现此食谱中的read-stream.js逻辑,请使用以下代码:

    const fs = require('node:fs');
    const rs = fs.createReadStream('./file.txt');
    async function run () {
      for await (const chunk of rs) {
        console.log('Read chunk:', chunk.toString());
      }
      console.log('No more data.');
    }
    run();
    
  3. 使用以下命令运行文件:

    $ node for-await-read-stream.js
    

有关for await...of语法的更多信息,请参阅 MDN 网络文档(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of)。

重要提示

通常,开发者应选择使用 Node.js 流 API 样式之一,因为使用on('data')on('readable')pipe()和/或异步迭代器的组合可能会导致行为不明确。

使用 Readable.from()生成可读流

Readable.from()方法由 Node.js 核心stream模块公开。此方法用于使用迭代器构建可读流。让我们更详细地看看:

  1. 创建一个名为async-generator.js的文件:

    $ touch async-generator.js
    
  2. stream模块导入Readable类:

    const { Readable } = require('node:stream');
    
  3. 定义异步生成器函数。这将形成我们的可读流内容:

    async function * generate () {
      yield 'Node.js';
      yield 'is';
      yield 'a';
      yield 'JavaScript';
      yield 'Runtime';
    }
    

    注意使用函数语法。这种语法定义了一个生成器函数。有关生成器语法的更多详细信息,请参阅 MDN 网络文档(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*)。

  4. 使用Readable.from()方法创建可读流,将generate()函数作为参数传递:

    const readable = Readable.from(generate());
    
  5. 要输出我们的可读流内容,注册一个data事件处理程序,打印块:

    readable.on('data', (chunk) => {
      console.log(chunk);
    });
    
  6. 在您的终端中输入以下命令来运行程序:

    $ node async-generator.js
    

    预期将看到以下生成的值作为输出:

    Node.js
    is
    a
    JavaScript
    Runtime
    

参见

  • 第二章

  • 本章的与暂停流交互食谱

  • 本章的管道流食谱

  • 本章的创建转换流食谱

  • 本章的 构建流管道 菜单

与暂停流交互

Node.js 流可以是流动模式或暂停模式。在流动模式下,数据块会自动读取,而在暂停模式下,必须调用 stream.read() 方法来读取数据块。

在这个菜谱中,我们将学习如何与处于暂停模式的可读流交互,这是它在创建时的默认模式。

准备工作

在上一个菜谱中创建的 learning-streams 目录中,创建以下文件:

$ touch paused-stream.js

现在我们已经准备好开始这个菜谱了。

如何做到这一点…

在这个菜谱中,我们将学习如何与处于暂停模式的可读流交互:

  1. 首先,将 fs 模块导入到 paused-stream.js 中:

    const fs = require('node:fs');
    
  2. 接下来,使用 createReadStream() 方法创建一个可读流来读取 file.txt 文件:

    const rs = fs.createReadStream('./file.txt');
    
  3. 接下来,我们需要在可读流上注册一个 readable 事件处理程序:

    rs.on('readable', () => {
      // Read data
    });
    
  4. 现在,我们可以在 readable 事件处理程序中添加手动逻辑来读取数据块,直到没有更多数据可以消费:

      // Read data
      let data = rs.read();
      while (data !== null) {
        console.log('Read chunk:', data.toString());
        data = rs.read();
      }
    
  5. 现在,我们可以为我们的可读流注册一个 end 事件处理程序,当所有数据都被读取后,它会打印一条消息,指出 没有更多数据

    rs.on('end', () => {
      console.log('No more data.');
    });
    
  6. 使用以下命令运行脚本:

    $ node paused-stream.js
    

    预期看到以下输出,表明可读流的块正在被读取:

图 3.4 – 读取过程中的可读流块概述

图 3.4 – 读取过程中的可读流块概述

有了这些,我们已经学会了如何在暂停模式下与可读流交互。我们通过监听可读事件并手动调用 read() 方法来做到这一点。

它是如何工作的…

在这个菜谱中,我们学习了如何与处于暂停模式的可读流交互。

默认情况下,可读流处于暂停模式。然而,在以下情况下,可读流会切换到流动模式:

  • 当注册 data 事件处理程序时

  • 当调用 pipe() 方法时

  • 当调用 resume() 方法时

由于我们在这个菜谱中的程序没有做任何这些操作,所以我们的流保持在暂停模式。

如果一个可读流处于流动模式,在以下情况下它会切换回暂停模式:

  • 当调用 pause() 方法且没有管道目标时

  • 当在所有管道目标上调用 unpipe() 方法时

我们向我们的可读流添加了一个 readable 事件处理程序。如果可读流已经处于流动模式,注册一个可读事件处理程序将停止流(它切换到暂停模式)。

当可读流处于暂停模式时,必须手动调用readableStream.read()方法来消费流数据。在这个配方中,我们在readable事件处理器中添加了逻辑,继续读取流数据,直到数据值为null。数据值为null表示流已结束(所有当前可用的数据都已读取)。readable事件可以多次触发,表示有更多数据可用。

当流处于暂停模式时,我们可以更好地控制数据读取的时间。本质上,我们是从流中提取数据,而不是自动推送。

重要提示

通常情况下,如果可能的话,使用pipe()方法来处理可读流的消费数据是有价值的,因为内存管理是自动处理的。以下配方,管道流,将更详细地介绍pipe()方法。

参见

  • 第二章

  • 本章的创建可读和可写流配方

  • 本章的管道流配方

  • 本章的创建转换流配方

  • 本章的构建流管道配方

管道流

管道是一种单向重定向形式。在我们的终端(DOS 或类 Unix),我们经常使用管道操作符(|)将一个程序的输出作为另一个程序的输入。例如,我们可以输入$ ls | head -3ls命令的输出通过管道传递给head -3命令,结果返回目录中的前三个文件。

就像我们可以在我们的 shell 中使用管道操作符在程序之间传递输出一样,我们也可以使用 Node.js 的pipe()方法在流之间传递数据。

在这个配方中,我们将学习如何使用pipe()方法。

准备工作

按照以下步骤操作:

  1. 创建一个工作目录:

    $ mkdir piping-streams
    $ cd piping-streams
    
  2. 首先创建一个名为file.txt的文件:

    $ touch file.txt
    
  3. file.txt添加一些示例数据,如下所示:

    Node.js is a JavaScript runtime built on Google Chrome's V8 JavaScript engine.
    Node.js is a JavaScript runtime built on Google Chrome's V8 JavaScript engine.
    Node.js is a JavaScript runtime built on Google Chrome's V8 JavaScript engine.
    

现在,我们已经准备好开始这个配方。

如何操作…

在这个配方中,我们将学习如何将可读流通过管道连接到可写流:

  1. 创建一个名为pipe-stream.js的文件:

    $ touch pipe-stream.js
    
  2. 接下来,通过导入fs模块来启动pipe-stream.js文件:

    const fs = require('node:fs');
    
  3. 使用createReadStream()方法创建一个可读流来读取file.txt

    const rs = fs.createReadStream('file.txt');
    
  4. 现在,我们需要将我们的可读流通过管道连接到process.stdout,它返回一个连接到STDOUT的可写流:

    rs.pipe(process.stdout);
    
  5. 使用以下命令运行程序:

    $ node pipe-stream.js
    

    预期看到以下输出:

    Node.js is a JavaScript runtime built on Google Chrome's V8 JavaScript engine.
    Node.js is a JavaScript runtime built on Google Chrome's V8 JavaScript engine.
    Node.js is a JavaScript runtime built on Google Chrome's V8 JavaScript engine.
    

通过这样,我们已经使用pipe()方法将可读流连接到了可写流。

工作原理…

在这个食谱中,我们使用createReadStream()方法创建了一个可读流来读取我们的file.txt文件。然后,我们使用pipe()方法将这个可读流的输出导向process.stdout(一个可写流)。pipe()方法将数据事件处理程序附加到源流上,该处理程序将传入的数据写入目标流。

pipe()方法用于将数据通过流流导向。在底层,pipe()方法管理数据流,以确保目标可写流不会被更快的可读流淹没。

pipe()方法内置的管理有助于解决背压问题。当输入超过系统容量时,就会发生背压。对于流来说,这可能会发生在我们消费一个快速读取数据的流时,而可写流无法跟上。这可能导致在可写流写入之前,在进程中保留大量内存。在内存中存储的大量数据可能会降低 Node.js 进程的性能,或者在最坏的情况下导致进程崩溃。

默认情况下,当使用pipe()方法时,当源可读流发出end事件时,会在目标可写流上调用stream.end()。这意味着目标不再可写。

要禁用此默认行为,我们可以通过options参数向pipe()方法提供{ end: false }

sourceStream.pipe(destinationStream, {end: false});

此配置指示目标流在源流发出end事件后仍然保持打开状态。

更多内容...

Node.js 中的流链式操作允许通过连接多个流来实现高效的数据处理。这种方法通过最小化内存开销来实现数据转换,这对于压缩等操作来说是非常理想的。在下面的示例中,我们将演示读取文件、压缩其内容并将压缩数据写入新文件的过程,以突出pipe()在流链式操作中的使用:

const fs = require('node:fs');
const zlib = require('node:zlib');
const readStream = fs.createReadStream('input.txt');
const writeStream = fs.createWriteStream('output.txt.gz');
// Chain the streams: read -> compress -> write
readStream.pipe(zlib.createGzip()).pipe(writeStream);

在此示例中,readStream.pipe(zlib.createGzip()).pipe(writeStream);input.txt读取数据,即时压缩,并将压缩数据写入output.txt.gz。这一系列操作以高效的方式执行,展示了 Node.js 中流链式操作在数据处理任务中的优雅和强大。

在提供的示例中,没有明确显示错误处理,但在实际应用中这是至关重要的。在 Node.js 中,当链式操作流时,错误可以通过链传播。当使用pipe()时,应在每个涉及的流上监听错误,通过为每个流附加一个error事件监听器来实现。这确保了错误在发生时被捕获和管理。

参见

  • 本章的创建可读和可写流食谱

  • 本章的创建转换流食谱

  • 本章的构建流管道食谱

创建转换流

转换流允许我们消费输入数据,处理这些数据,然后以处理后的形式输出数据。我们可以使用转换流以功能性和异步方式处理数据操作。可以将多个转换流串联起来,这样我们就可以将复杂处理分解为一系列任务。

在这个菜谱中,我们将使用 Node.js 核心模块 stream 创建一个转换流。

重要提示

through2 模块(www.npmjs.com/package/through2)非常受欢迎,并为创建 Node.js 转换流提供了一个包装器。然而,在过去的几年中,Node.js 核心流实现已经进行了许多简化和改进。今天,Node.js 流 API 提供了简化的构建方式,如本菜谱所示,这意味着我们可以直接使用 Node.js 核心来达到等效的语法,而不需要 through2

准备工作

按照以下步骤操作:

  1. 创建一个工作目录:

    $ mkdir transform-streams
    $ cd transform-streams
    
  2. 创建一个名为 transform-stream.js 的文件:

    $ touch transform-stream.js
    
  3. 我们还需要一些用于转换的样本数据。因此,创建一个名为 file.txt 的文件:

    $ touch file.txt
    
  4. file.txt 文件中添加一些示例文本数据,例如以下内容:

    Node.js is a JavaScript runtime built on Google Chrome's V8 JavaScript engine.
    Node.js is a JavaScript runtime built on Google Chrome's V8 JavaScript engine.
    Node.js is a JavaScript runtime built on Google Chrome's V8 JavaScript engine.
    

现在,我们已经准备好开始这个菜谱。

如何操作…

在这个菜谱中,我们将学习如何使用 Node.js 核心模块 stream 创建一个转换流。我们将创建的转换流将把文件中的所有文本转换为大写:

  1. 首先将 Node.js 核心模块 文件系统 导入到 transform-stream.js 中:

    const fs = require('node:fs');
    
  2. 接下来,我们需要从 Node.js 核心模块 stream 中导入 Transform 类:

    const { Transform } = require('node:stream');
    
  3. 创建一个可读流以读取 file.txt 文件:

    const rs = fs.createReadStream('./file.txt');
    
  4. 一旦我们的文件内容通过我们的转换流处理,我们将将其写入一个名为 newFile.txt 的新文件。使用 createWriteStream() 方法创建一个可写流来写入此文件:

    const newFile = fs.createWriteStream('./newFile.txt');
    
  5. 接下来,我们需要定义我们的转换流。我们将命名我们的转换流为 uppercase()

    const uppercase = new Transform({
      transform (chunk, encoding, callback) {
        // Data processing
      }
    });
    
  6. 现在,在我们的转换流中,我们将添加逻辑以将块转换为一个大写字符串。在 // 数据处理 注释下方,添加以下行:

        callback(null, chunk.toString().toUpperCase());
    

    这将调用转换流的回调函数,并传递转换后的块。

  7. 到目前为止,我们需要将所有流串联起来。我们将使用 pipe() 方法来完成。将以下行添加到文件底部:

    rs.pipe(uppercase).pipe(newFile);
    
  8. 在您的终端中输入以下命令来运行程序:

    $ node transform-stream.js
    
  9. 预期 newFile.txt 已由我们的程序创建。您可以通过在终端中运行 cat 命令,然后跟上新文件的名称来确认这一点:

    $ cat newFile.txt
    NODE.JS IS A JAVASCRIPT RUNTIME BUILT ON GOOGLE CHROME'S V8 JAVASCRIPT ENGINE.
    NODE.JS IS A JAVASCRIPT RUNTIME BUILT ON GOOGLE CHROME'S V8 JAVASCRIPT ENGINE.
    NODE.JS IS A JAVASCRIPT RUNTIME BUILT ON GOOGLE CHROME'S V8 JAVASCRIPT ENGINE.
    

    注意,内容现在已为大写,这表明数据已通过转换流。

有了这些,我们已经学会了如何创建转换流来处理数据。我们的转换流将输入数据转换为大写字符串。之后,我们将可读流管道连接到转换流,并将转换流连接到可写流。

它是如何工作的...

转换流是双工流,这意味着它们实现了可读和可写流接口。转换流用于处理(或转换)输入,然后将其作为输出传递。

要创建转换流,我们必须从 Node.js 核心模块 stream 中导入 Transform 类。转换流构造函数接受以下两个参数:

  • transform:实现数据处理/转换逻辑的函数。

  • flush:如果转换过程产生额外的数据,将使用 flush 方法来刷新数据。此参数是可选的。

transform() 函数处理流输入并产生输出。请注意,通过输入流提供的块的数量不必要等于转换流输出的数量——在转换/处理过程中可能会省略一些块。

在底层,transform() 函数被附加到转换流的 _transform() 方法上。_transform() 方法是 Transform 类的一个内部方法,不打算直接调用(因此有下划线前缀)。

_transform() 方法接受以下三个参数:

  • chunk:要转换的数据。

  • 编码:如果输入是 String 类型,编码将是 String 类型。如果是 Buffer 类型,此值设置为 buffer

  • callback(err, transformedChunk):一旦块被处理,就要调用的回调函数。期望回调函数有两个参数——第一个是错误,第二个是转换后的块。

在这个菜谱中,我们的 transform() 函数使用处理后的数据调用了 callback() 函数(我们的处理数据是 chunk.toString().toUpperCase(),将输入转换为一个大写字符串)。

重要提示

Node.js 内置了一些转换流。Node.js 核心模块 cryptozlib 都公开了转换流。例如,zlib.createGzip() 方法是 zlib 模块公开的转换流,它压缩了被管道传输到它的文件。

更多内容...

在本节中,我们将学习如何使用 ECMAScript 6(ES6)语法创建转换流,以及我们如何创建一个对象模式的转换流。

采用 ES6 语法

在这个菜谱中,我们使用简化的构造函数方法实现了转换流。也可以使用 ES6 类语法来实现这些。以下步骤将演示这一点:

  1. 创建一个名为 transform-stream-es6.js 的文件:

    $ touch transform-stream-es6.js
    
  2. 从这个菜谱中转换流可以如下实现:

    const fs = require('node:fs');
    const { Transform } = require('node:stream');
    const rs = fs.createReadStream('./file.txt');
    const newFile = fs.createWriteStream('./newFile.txt');
    class Uppercase extends Transform {
      _transform (chunk, encoding, callback) {
        this.push(chunk.toString().toUpperCase());
        callback();
      }
    }
    rs.pipe(new Uppercase()).pipe(newFile);
    

    通过这段代码,我们可以更清晰地看到我们正在用我们的转换逻辑覆盖 _transform() 方法。

这个例子使用 ES6 语法创建了一个自定义的转换流,该流从 file.txt 读取内容,将其转换为大写,并写入到 newFile.txt 中。Uppercase 类扩展了 Transform 类,并覆盖了 _transform 方法来处理数据块,在将它们推送到写入流之前,使用 chunk.toString().toUpperCase() 将它们转换为大写。回调函数 callback() 被调用以指示当前数据块的处理的完成,允许流处理下一个数据块并保持数据流的规律性。

创建对象模式转换流

默认情况下,Node.js 流操作 StringBufferUint8Array 对象。然而,也可以使用 Node.js 流在 对象模式 下工作。这允许我们使用其他 JavaScript 值(除了 null 值)。在对象模式下,从流中返回的值是通用的 JavaScript 对象。对象模式流的一个用例是实现一个查询数据库以获取大量用户记录的应用程序,然后逐个处理每个用户记录。

与对象模式的主要区别在于,highWaterMark 的值指的是对象的数目,而不是字节数。在之前的菜谱中,我们了解到 highWaterMark 的值决定了在流停止读取输入之前内部缓冲区中存储的最大字节数。对于对象模式流,此值设置为 16 – 意味着一次缓冲 16 个对象。

要设置对象模式的流,我们必须通过 options 对象传递 { objectMode: true }

让我们演示如何创建一个对象模式的转换流:

  1. 让我们创建一个名为 object-streams 的文件夹,包含一个名为 object-stream.js 的文件,并使用 npm 初始化项目:

    $ mkdir object-streams
    $ cd object-streams
    $ npm init --yes
    $ touch object-stream.js
    
  2. 安装 ndjson 模块:

    $ npm install ndjson
    
  3. object-stream.js 中,从 Node.js 核心模块 stream 中导入 Transform 类:

    const { Transform } = require('node:stream');
    
  4. 接下来,从 ndjson 模块导入 stringify() 方法:

    const { stringify } = require('ndjson');
    
  5. 创建转换流,指定 { objectMode: true } :

    const Name = Transform({
      objectMode: true,
      transform: ({ forename, surname }, encoding,
        callback) => {
          callback(null, { name: forename + ' ' + surname
        });
      }
    });
    
  6. 现在,我们可以创建我们的流链。我们将把 Name 转换流连接到 stringify() 方法(来自 ndjson),然后将结果连接到 process.stdout :

    Name.pipe(stringify()).pipe(process.stdout);
    
  7. 最后,仍然在 object-stream.js 中,我们将使用 write() 方法向 Name 转换流写入一些数据:

    Name.write({ forename: 'John', surname: 'Doe' });
    Name.write({ forename: 'Jane', surname: 'Doe' });
    
  8. 使用以下命令运行程序:

    $ node object-stream.js
    

    这将输出以下内容:

    {"name":"John Doe"}
    {"name":"Jane Doe"}
    

在这个例子中,我们创建了一个名为 Name 的转换流,它聚合了两个 JSON 属性(forenamesurname)的值,并返回一个新的属性(name)带有聚合值。Name 转换流处于对象模式,并且既读取又写入对象。

我们将 Name 转换流连接到由 ndjson 模块提供的 stringify() 函数。stringify() 函数将流式 JSON 对象转换为换行符分隔的 JSON。stringify() 流是一个转换流,其中可写部分处于对象模式,但可读部分不是。

使用转换流(和双工流),你可以通过提供以下配置选项来独立指定流的可读或可写部分是否处于对象模式:

  • readableObjectMode : 当 true 时,双工流的可读部分处于对象模式

  • writableObjectMode : 当 true 时,双工流的可写部分处于对象模式

注意,也可以使用以下配置选项为双工流的可读或可写部分设置不同的 highWaterMark 值:

  • readableHighWaterMark : 为流的可读部分配置 highWaterMark

  • writableHighWaterMark : 为流的可写部分配置 highWaterMark

如果提供了 highWaterMark 值,则 readableHighWaterMarkwritableHighWaterMark 配置值将没有效果,因为 highWaterMark 值具有优先级。

使用 map 和 filter 函数

Node.js 的较新版本(晚于 16.4.0 版本)为可读流提供了 实验性 的类似数组方法。这些方法可以像数组方法一样使用 - 例如,Readable.map()Readable.filter() 方法提供了类似于 Array.prototype.map()Array.prototype.filter() 的功能。

可以使用 map() 方法对流进行映射。对于流中的每个块,都会调用指定的函数。在本菜谱中创建的 transform stream 可以使用 map() 方法重写如下:

const fs = require('node:fs');
const rs = fs.createReadStream('./file.txt');
const newFile = fs.createWriteStream('./newFile.txt');
rs.map((chunk) =>
  chunk.toString().toUpperCase()).pipe(newFile);

可以使用 Readable.filter() 方法来过滤可读流:

const { Readable } = require('node:stream');
async function* generate() {
    yield 'Java';
    yield 'JavaScript';
    yield 'Rust';
}
// Filter the stream for words with 5 or more characters
Readable.from(generate()).filter((word) => word.length >=
  5).pipe(process.stdout);

这些是最近添加的两个函数,它们在可读流上提供了类似数组的方法。现在流上可用的类似数组方法还有很多:

  • . drop()

  • . every()

  • . filter()

  • . find()

  • . flatMap()

  • . forEach()

  • . map()

  • . reduce()

  • . some()

  • . take()

  • . toArray()

更多信息,包括这些方法的用法和参数,可以在 Node.js 流 API 文档中找到:nodejs.org/docs/latest-v22.x/api/stream.html .

重要提示

在撰写本文时,类似数组的流方法被指定为 实验性 状态。

相关内容

  • 本章的 创建可读和可写流 菜单

  • 本章的 管道流 菜单

  • 本章的 构建流管道 菜单

构建流管道

Node.js 核心stream模块提供了一个pipeline()方法。类似于我们可以使用 Node.js 核心流pipe()方法将一个流连接到另一个流,我们也可以使用pipeline()方法将多个流连接在一起。

pipe()方法不同,pipeline()方法还转发错误,这使得处理流中的错误变得更加容易。

这个配方基于本章其他配方中涵盖的许多流概念。在这里,我们将使用pipeline()方法创建一个流管道。

准备工作

在深入这个配方之前,让我们通过创建目录和文件来设置我们的工作区:

  1. 首先,创建一个名为stream-pipelines的工作目录:

    $ mkdir stream-pipelines
    $ cd stream-pipelines
    
  2. 创建一个名为pipeline.js的文件:

    $ touch pipeline.js
    
  3. 我们还需要一些样本数据来转换。创建一个名为file.txt的文件:

    $ touch file.txt
    
  4. 将一些虚拟文本数据添加到file.txt文件中:

    Node.js is a JavaScript runtime built on Google Chrome's V8 JavaScript engine.
    Node.js is a JavaScript runtime built on Google Chrome's V8 JavaScript engine.
    Node.js is a JavaScript runtime built on Google Chrome's V8 JavaScript engine.
    

现在,我们已经准备好开始这个配方。

如何操作…

在这个配方中,我们将使用pipeline()方法创建一个流管道。我们的管道将读取file.txt文件,使用转换流将文件内容转换为大写,然后将新内容写入新文件:

  1. 首先,将 Node.js 核心fs模块导入到pipeline.js中:

    const fs = require('node:fs');
    
  2. 接下来,我们需要从 Node.js 核心stream模块导入pipeline()方法和Transform类:

    const { pipeline, Transform } = require('node:stream');
    
  3. 接下来,我们将创建我们的转换流(有关转换流的更多信息,请参阅本章中的创建转换流配方)。这将把输入转换为大写字符串:

    const uppercase = new Transform({
      transform (chunk, encoding, callback) {
        // Data processing
        callback(null, chunk.toString().toUpperCase());
      }
    });
    
  4. 现在,我们可以开始创建流管道。首先,让我们调用pipeline()方法:

    pipeline();
    
  5. pipeline()方法期望第一个参数是一个可读流。我们的第一个参数将是一个可读流,它将使用createReadStream()方法读取file.txt文件:

    pipeline(
      fs.createReadStream('./file.txt')
    );
    
  6. 接下来,我们需要将我们的转换流作为pipeline()方法的第二个参数添加:

    pipeline(
      fs.createReadStream('./file.txt'),
      uppercase,
    );
    
  7. 然后,我们可以将我们的可写流添加到管道中,以将newFile.txt文件写入管道:

    pipeline(
      fs.createReadStream('./file.txt'),
      uppercase,
      fs.createWriteStream('./newFile.txt'),
    );
    
  8. 最后,管道的最后一个参数是一个回调函数,该函数将在管道运行完成后执行。此回调函数将处理管道中的任何错误:

    pipeline(
      fs.createReadStream('./file.txt'),
      uppercase,
      fs.createWriteStream('./newFile.txt'),
      (err) => {
        if (err) {
          console.error('Pipeline failed.', err);
        } else {
          console.log('Pipeline succeeded.');
        }
      }
    );
    
  9. 在您的终端中,使用以下命令运行程序。您应该会看到一个消息表明管道成功

    $ node pipeline.js
    Pipeline succeeded.
    
  10. 为了确认流管道成功,请验证newFile.txt文件是否包含file.txt的内容,但为 uppercase:

    $ cat newFile.txt
    NODE.JS IS A JAVASCRIPT RUNTIME BUILT ON GOOGLE CHROME'S V8 JAVASCRIPT ENGINE.
    NODE.JS IS A JAVASCRIPT RUNTIME BUILT ON GOOGLE CHROME'S V8 JAVASCRIPT ENGINE.
    NODE.JS IS A JAVASCRIPT RUNTIME BUILT ON GOOGLE CHROME'S V8 JAVASCRIPT ENGINE.
    

通过这种方式,我们已经使用 Node.js 核心stream模块公开的pipeline()方法创建了一个流管道。

它是如何工作的…

pipeline()方法允许我们将流连接到一起——形成一个流流。

我们可以向流的pipeline()方法传递以下参数:

  • source:一个数据源流,从中读取数据

  • ...转换:可以处理数据(包括0)的任意数量的转换流

  • 目的地:一个目标流,用于写入处理后的数据

  • 回调:当管道完成时调用的函数

我们将 pipeline() 方法传递给一系列流,按照它们需要运行的顺序,然后使用一个回调函数,该函数在管道完成时执行。

pipeline() 方法优雅地将流中发生的错误转发到回调。这是使用 pipeline() 方法而不是 pipe() 方法的优点之一。

pipeline() 方法还会通过调用 stream.destroy() 清理任何未终止的流。

还有更多...

在 Node.js 15 及更高版本中,有一套用于流的异步实用函数,这些函数使用 Promise 对象而不是回调。这些函数可以在 stream/promises 核心模块中找到。此模块包括与 Promises 兼容的 stream.pipeline()stream.finished() 版本,提供了一种更现代、更符合 Promise 的流处理方法。

让我们将主菜谱中的流管道转换为使用 Promise 版本的 stream.pipeline()

  1. 创建一个名为 promise-pipeline.js 的文件:

    $ touch promise-pipeline.js
    
  2. 将以下内容添加到导入 Node.js 核心模块 fsstream/promises

    const fs = require('node:fs');
    const { Transform } = require('node:stream');
    const { pipeline } = require('node:stream/promises');
    
  3. 添加转换流:

    const uppercase = new Transform({
      transform(chunk, encoding, callback) {
        // Data processing
        callback(null, chunk.toString().toUpperCase());
      },
    });
    
  4. 由于我们将等待 pipeline(),我们需要将 pipeline() 逻辑包裹在一个异步函数中:

    async function run() {
      await pipeline(
        fs.createReadStream('./file.txt'),
        uppercase,
        fs.createWriteStream('./newFile.txt')
      );
      console.log('Pipeline succeeded.');
    }
    
  5. 最后,我们可以调用我们的 run() 函数,捕获任何错误:

    run().catch((err) => {
      console.error('Pipeline failed.', err);
    });
    
  6. 使用以下命令运行程序:

    $ node promise-pipeline.js
    Pipeline Succeeded.
    

通过这样,我们展示了如何使用 Streams Promises API 通过 pipeline() 方法与 Promises 一起使用流。

重要提示

以前,pipeline() 方法可能通过使用 util.promisify() 实用方法转换为 Promise 形式。util.promisify() 方法用于将回调风格的函数转换为 Promise 形式。Streams Promises API 取代了使用此方法的需求。

参见

  • 本章的 创建可读和可写流 菜谱

  • 本章的 管道流 菜谱

  • 本章的 创建转换流 菜谱

第四章:使用 Web 协议

Node.js 是以构建 Web 服务器为目标的。使用 Node.js,我们可以用几行代码快速创建一个 Web 服务器,这使我们能够自定义服务器的行为。

HTTP 代表 超文本传输协议,是一种支撑 万维网WWW)的应用层协议。HTTP 是一种无状态协议,最初设计用于促进浏览器和服务器之间的通信。本章的食谱将重点介绍如何处理和发送 HTTP 请求。尽管这些食谱不需要深入了解 HTTP 的工作原理,但如果您对这一概念完全陌生,阅读一个高级概述将是有益的。MDN Web 文档 提供了 HTTP 的概述,请参阅 developer.mozilla.org/en-US/docs/Web/HTTP/Overview

本章将展示 Node.js 为与 Web 协议交互提供的低级核心 应用程序编程接口APIs)。我们将从发送 HTTP 请求、创建 HTTP 服务器以及学习如何处理 POST 请求和文件上传开始。本章后面,我们将学习如何使用 Node.js 创建 WebSocket 服务器以及如何创建 简单邮件传输协议SMTP)服务器。

理解 Node.js 如何与底层 Web 协议交互非常重要,因为这些 Web 协议和基本概念构成了大多数实际 Web 应用程序的基础。稍后,在 第六章 中,我们将学习如何使用将 Web 协议抽象为高级 API 的 Web 框架,但理解 Node.js 在低级别如何与 Web 协议交互同样重要。

本章将涵盖以下食谱:

  • 发送 HTTP 请求

  • 创建 HTTP 服务器

  • 接收 HTTP POST 请求

  • 处理文件上传

  • 创建 WebSocket 服务器

  • 创建 SMTP 服务器

技术要求

本章要求您安装 Node.js – 最好是 Node.js 22 的最新版本。此外,您还需要访问您选择的编辑器和浏览器。本章使用的代码示例可在 GitHub 上的 github.com/PacktPublishing/Node.js-Cookbook-Fifth-EditionChapter04 目录中找到。

发送 HTTP 请求

程序和应用程序通常需要从其他来源或服务器获取数据。在现代 Web 开发中,这通常是通过向来源或服务器发送 HTTP GET 请求来实现的。同样,应用程序或程序可能还需要将数据发送到其他来源或服务器。这通常是通过向目标来源或服务器发送包含数据的 HTTP POST 请求来实现的。

除了用于构建 HTTP 服务器外,Node.js 的核心 httphttps 模块还公开了可以用于向其他服务器发送 HTTP 请求的 API。

在这个菜谱中,我们将使用 Node.js 核心模块 httphttps 来发送 HTTP GET 请求和 HTTP POST 请求。

准备工作

首先,为这个菜谱创建一个名为 making-requests 的目录。我们还将创建一个名为 requests.js 的文件:

$ mkdir making-requests
$ cd making-requests
$ touch requests.js

如何做到这一点…

我们将使用 Node.js 核心模块 http 来发送 HTTP GET 请求和 HTTP POST 请求。

  1. 首先,在您的 requests.js 文件中导入 http 模块:

    const http = require('node:http');
    
  2. 现在,我们可以发送一个 HTTP GET 请求。我们将向 example.com 发送请求。这可以用一行代码完成:

    http.get('http://example.com', (res) =>
      res.pipe(process.stdout));
    
  3. 使用以下命令执行您的 Node.js 脚本。您应该会看到 example.com 的 HTML 表示形式打印到 stdout

    $ node requests.js
    
  4. 现在,我们可以看看我们是如何发送 HTTP POST 请求的。首先,用 // 注释掉 HTTP GET 请求——保留它会使后续步骤的输出难以阅读:

    // http.get('http://example.com', (res) =>
      res.pipe(process.stdout));
    
  5. 对于我们的 HTTP POST 请求,我们首先需要定义我们要与请求一起发送的数据。为了实现这一点,我们定义一个名为 payload 的变量,它包含我们数据的 JavaScript 对象表示法 ( JSON ) 表示:

    const payload = JSON.stringify({
        'name': 'Laddie',
        'breed': 'Rough Collie'
    });
    
  6. 我们还需要为我们要与 HTTP POST 请求一起发送的选项创建一个配置对象。我们将向 postman-echo.com 发送 HTTP POST 请求。这是一个测试端点,它将返回我们的 HTTP 头部、参数和 HTTP POST 请求的内容——镜像我们的请求:

    const opts = {
      method: 'POST',
      hostname: 'postman-echo.com',
      path: '/post',
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(payload)
      }
    };
    

重要提示

Postman (postman.com ) 是一个用于 API 开发的平台,并提供了一个可以下载使用的 表示状态转换 ( REST ) 客户端应用程序,用于发送 HTTP 请求。Postman 还提供了一个名为 Postman Echo 的服务——这提供了一个端点,您可以将其用于测试发送的 HTTP 请求。有关 Postman Echo 文档,请参阅此处:docs.postman-echo.com/?version=latest

  1. 要发送 HTTP POST 请求,请添加以下代码。这将把 HTTP 状态码和请求体的响应写入 stdout,一旦收到响应:

    const req = http.request(opts, (res) => {
      process.stdout.write('Status Code: ' +
        res.statusCode + '\n');
      process.stdout.write('Body: ');
      res.pipe(process.stdout);
    });
    
  2. 我们还应该捕获请求过程中发生的任何错误:

    req.on('error', (err) => console.error('Error: ',
      err));
    
  3. 最后,我们需要携带负载发送我们的请求:

    req.end(payload);
    
  4. 现在,执行您的程序,您应该会看到 Postman Echo API 对我们的 HTTP POST 请求做出响应:

    $ node requests.js
    Status Code: 200
    Body: {
      "args": {},
      "data": {
        "name": "Laddie",
        "breed": "Rough Collie"
      },
      "files": {},
      "form": {},
      "headers": {
        "x-forwarded-proto": "http",
        "x-forwarded-port": "80",
        "host": "postman-echo.com",
        "x-amzn-trace-id": "Root=1-656ddcfe-
          52b1cf7a1671685c6985fa59",
        "content-length": "53",
        "content-type": "application/json"
      },
      "json": {
        "name": "Laddie",
        "breed": "Rough Collie"
      },
      "url": "http://postman-echo.com/post"
    }%
    

我们已经学习了如何使用 Node.js 核心模块 http 来发送 HTTP GET 和 HTTP POST 请求。

它是如何工作的…

在这个菜谱中,我们利用了 Node.js 核心模块 http 来发送 HTTP GET 和 HTTP POST 请求。Node.js 核心模块 http 依赖于底层的 Node.js 核心模块 net

对于 HTTP GET 请求,我们使用两个参数调用 http.get() 函数。第一个参数是我们希望发送请求的端点,第二个参数是回调函数。回调函数在 HTTP GET 请求完成后执行,在这个菜谱中,我们的函数将我们从端点收到的响应转发到 stdout

要发送 HTTP POST 请求,我们使用 http.request() 函数。此函数也接受两个参数。

request() 函数的第一个参数是 options 对象。在菜谱中,我们使用 options 对象来配置要使用的 HTTP 方法、主机名、请求应发送到的路径以及请求上要设置的标头。可以在 Node.js HTTP API 文档中查看可以传递给 request() 函数的完整配置选项列表(nodejs.org/api/http.html#http_http_request_options_callback)。

request() 函数的第二个参数是在 HTTP POST 请求完成后要执行的回调函数。我们的请求函数会写入 HTTP 状态码,并将请求的响应转发到 标准输出stdout)。

在请求对象上添加了一个错误事件监听器来捕获和记录任何错误到 stdout

req.on('error', (err) => console.error('Error: ', err));

req.end(payload); 语句发送我们的请求并附带有效负载。

还可以将此 API 与 Promise 语法结合使用。将以下内容添加到名为 requestPromise.js 的文件中:

const http = require('node:http');
function httpGet (url) {
  return new Promise((resolve, reject) => {
    http
      .get(url, (res) => {
        let data = '';
        res.on('data', (chunk) => {
          data += chunk;
        });
        res.on('end', () => {
          resolve(data);
        });
      })
      .on('error', (err) => {
        reject(err);
      });
  });
}
const run = async () => {
  const res = await httpGet('http://example.com');
  console.log(res);
};
run();

httpGet() 函数使用 Promise 来管理异步 HTTP GET 请求:在成功完成时解析为完整数据,如果请求失败则拒绝并返回错误。这种设置使得与 async / await 集成处理异步 HTTP 操作变得简单。

更多内容...

菜谱展示了如何通过 HTTP 发送 GETPOST 请求,但考虑如何通过 HTTPS 发送请求也同样值得。HTTPS 代表 HyperText Transfer Protocol Secure。HTTPS 是 HTTP 协议的扩展。通过 HTTPS 的通信是加密的。Node.js 核心提供了 https 模块,与 http 模块一起使用,用于处理 HTTPS 通信。

有可能将菜谱中的请求更改为使用 HTTPS,通过导入 https 核心模块并将任何 http 实例更改为 https 来实现。你还需要将请求发送到 HTTPS 端点:

const https = require('node:https');
https.get('https://example.com', ...);
https.request('https://example.com', ...);

在使用传统的 HTTP 和 HTTPS 模块进行请求的基础知识之后,让我们转向探索如何使用 Promise 语法和最近添加的 Fetch API。

使用 Fetch API

让我们探索 Fetch API,这是一个为发送 HTTP 请求而设计的现代 Web API。虽然它在浏览器中已经存在了一段时间,但最近它已成为 Node.js 的默认功能。在 Node.js 中,Fetch API 是核心 HTTP 模块的高级替代品,它对底层的 HTTP API 提供了简化和用户友好的抽象。它采用基于 Promise 的方法来处理异步操作。

从 Node.js 版本 18 开始,Fetch API 作为全局 API 立即可用。Node.js 中的实现由 undici 提供,这是一个从头开始为 Node.js 开发的 HTTP/1.1 客户端。你可以在 undici.nodejs.org/#/ 找到有关 undici 的更多信息。

实现灵感来源于常用 node-fetch (npmjs.com/package/node-fetch) 包。Node.js 对 Fetch API 的实现力求尽可能符合规范,但 Fetch API 规范的一些方面更偏向浏览器,因此在 Node.js 实现中被省略。

重要提示

你可以直接将 undici 作为模块用于对 HTTP 请求进行更底层和更精细的控制。阅读 undici API 文档以获取更多信息:undici.nodejs.org/#/

让我们看看使用 Node.js Fetch API 发送 HTTP GET 和 HTTP POST 请求的示例:

  1. 创建一个名为 fetchGet.js 的文件和一个名为 fetchPost.js 的文件:

    $ touch fetchGet.js fetchPost.js
    
  2. 将以下内容添加到 fetchGet.js 中:

    async function performGetRequest() {
      const url = 'https://api.github.com/orgs/nodejs';
      try {
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error(`HTTP error! Status:
            ${response.status}`);
        }
        const data = await response.json();
        console.log('GET request successful:', data);
      } catch (error) {
        console.error('Error during GET request:',
          error);
      }
    }
    performGetRequest();
    
  3. 你可以使用以下命令运行此示例:

    $ node fetchGet.js
    GET request successful: {
      login: 'nodejs',
      id: 9950313,
      ...
    }
    
  4. 为了演示使用 Node.js Fetch API 发送 HTTP POST 请求,将以下内容添加到 fetchPost.js 中:

    async function performPostRequest() {
      const url = 'https://postman-echo.com/post';
      const postData = {
        name: 'Laddie',
        breed: 'Rough Collie'
      };
      try {
        const response = await fetch(url, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify(postData)
        });
        if (!response.ok) {
          throw new Error(`HTTP error! Status:
            ${response.status}`);
        }
        const data = await response.json();
        console.log('POST request successful:', data);
      } catch (error) {
        console.error('Error during POST request:',
          error);
      }
    }
    performPostRequest();
    

    注意使用配置对象设置 HTTP 方法为 POST 并设置内容类型。

  5. 使用以下命令运行示例:

    $ node fetchPost.js
    POST request successful: {
      args: {},
      data: { name: 'Laddie', breed: 'Rough Collie' },
    ...
    

由于 Node.js 中 Fetch API 的实现旨在尽可能与规范兼容,你可以参考 MDN Web 文档 以获取更详细的使用信息:developer.mozilla.org/en-US/docs/Web/API/Fetch_APIMDN Web 文档 为网络开发者提供了一个全面且常被认为是权威的资源。

重要提示

建议关注其更新和状态变化,因为 Node.js 可能会发布更新版本,以改进 Fetch API 的实现。请参阅 API 文档:nodejs.org/dist/latest-v22.x/docs/api/globals.html#fetch

参见

  • 本章中的 创建 HTTP 服务器 菜单

  • 本章中的 接收 HTTP POST 请求 菜单

  • 第三章第六章第九章

创建 HTTP 服务器

在构建大型复杂应用程序时,通常使用高级 Web 框架来实现 HTTP 服务器,而不是与核心 Node.js API 交互。然而,理解底层 API 是很重要的,在某些情况下,仅与底层 Node.js API 交互才能提供在某些情况下所需的细粒度控制。

在上一节中,我们探讨了 HTTP 的基础概念以及相关的 Node.js 核心 API。在本教程中,我们将指导您使用 Node.js 构建 HTTP 服务器的过程,我们最初将专注于处理 GET 请求——这是网络服务器的基本功能。

准备工作

首先,为这个食谱创建一个目录,并创建一个名为 server.js 的文件,该文件将包含我们的 HTTP 服务器:

$ mkdir http-server
$ cd http-server
$ touch server.js

如何操作…

对于这个食谱,我们将使用核心 Node.js http 模块。http 模块的 API 文档可在 nodejs.org/api/http.html 查找。在本食谱中,我们将创建一个“待办事项”任务服务器。

  1. 首先,我们需要通过在 server.js 中添加以下行来导入核心 Node.js http 模块:

    const http = require('node:http');
    
  2. 我们首先定义服务器的域名和端口号:

    const HOSTNAME = process.env.HOSTNAME || '0.0.0.0';
    const PORT = process.env.PORT || 3000;
    
  3. 接下来,我们可以创建服务器并添加一些路由处理。在 createServer() 函数中,我们将引用我们在以下步骤中创建的 error()todo()index() 函数:

    const server = http.createServer((req, res) => {
      if (req.method !== 'GET') return error(res, 405);
      if (req.url === '/todo') return todo(res);
      if (req.url === '/') return index(res);
      error(res, 404);
    });
    
  4. 现在,让我们创建我们的 error() 函数。这个函数将接受一个响应对象和一个状态码作为参数,其中代码预期是一个 HTTP 状态码:

    function error (res, code) {
      res.statusCode = code;
      res.end(`{"error":
        "${http.STATUS_CODES[code]}"}`);
    }
    
  5. 我们现在将创建我们的 todo() 函数。目前,这个函数将只返回一个静态的 JSON 字符串,表示“待办事项”列表中的一个条目:

    function todo (res) {
      res.end('[{"task_id": 1, "description": "walk the
        dog"}]}');
    }
    
  6. 需要创建的最后一个函数是 index() 函数,当我们在 / 路由上执行 GET 请求时,该函数将被调用:

    function index (res) {
      res.end('{"name": "todo-server"}');
    }
    
  7. 最后,我们需要在我们的服务器上调用 listen() 函数。我们还将向 listen() 函数传递一个回调函数,该函数将在服务器启动后记录服务器正在监听的地址:

    server.listen(PORT, HOSTNAME, () => {
      console.log(`Server listening on port
        ${server.address().port}`);
    });
    
  8. 现在可以从我们的终端启动我们的服务器:

    $ node server.js
    Server listening on port 3000
    
  9. 在一个单独的终端窗口中,我们可以使用 cURL 向我们的服务器发送 GET 请求,或者在浏览器中访问我们的各种端点:

    $ curl http://localhost:3000/
    {"name": "todo-server"}%
    $ curl http://localhost:3000/todo
    [{"task_id": 1, "description": "walk the dog"}]}%
    $ curl -X DELETE http://localhost:3000/
    {"error": "Method Not Allowed"}%
    $ curl http://localhost:3000/not-an-endpoint
    {"error": "Not Found"}%
    

我们已经构建了一个基本的“待办事项”列表服务器,我们可以向其发送 HTTP GET 请求,服务器会以 JSON 数据响应。

它是如何工作的…

Node.js 核心模块 http 提供了对 HTTP 协议功能的接口。

在这个食谱中,我们使用 http 模块暴露的 createServer() 函数创建了一个服务器。我们向 createServer() 函数传递了一个请求监听函数,该函数会在每次请求时执行。

每次收到指定路由的请求时,请求监听函数都会执行。请求监听函数有两个参数,reqres,其中 req 是请求对象,res 是响应对象。http 模块根据请求中的数据创建 req 对象。

可以将 options 对象作为第一个参数传递给 createServer() 函数。请参阅 Node.js 的 http 模块 API 文档,以了解可以传递给各种 http 函数的参数和选项:nodejs.org/api/http.html

createServer() 函数返回一个 http.Server 对象。我们通过调用 listen() 函数来启动服务器。我们向 listen() 函数传递我们的 HOSTNAMEPORT 参数,以指示服务器应该监听哪个主机名和端口号。

我们在菜谱中的请求处理器由三个 if 语句组成。第一个 if 语句检查 req.method 属性,以确定传入请求使用了哪个 HTTP 方法:

  if (req.method !== 'GET') return error(res, 405);

在这个菜谱中,我们只允许 GET 请求。当检测到传入请求上的任何其他 HTTP 方法时,我们返回并调用我们的错误函数。

后两个 if 语句检查 req.url 的值:

  if (req.url === '/todo') return todo(res);
  if (req.url === '/') return index(res);

请求对象上的 url 属性告诉我们请求被发送到了哪个路由。req.url 属性不提供完整的 统一资源定位符 ( URL ),只是相对路径或“路由”段。这个菜谱中的 if 语句控制着对特定 URL 的每个请求调用哪个函数——这形成了一个 简单路由处理器

我们监听函数的最后一行调用了我们的 error() 函数。只有当我们的条件 if 语句都不满足时,这一行才会被执行。在我们的菜谱中,这将在请求发送到除 //todo 之外的其他任何路由时发生。

我们将响应对象 res 传递给我们的每个 error()todo()index() 函数。此对象是一个 Stream 对象。我们调用 res.end() 来返回所需的内容。

对于 error() 函数,我们传递一个额外的参数,code。我们使用它来传递并返回 HTTP 状态码。HTTP 状态码是 HTTP 协议规范的一部分(tools.ietf.org/html/rfc2616#section-10)。下表显示了 HTTP 响应代码是如何分组的:

Range 用途
1xx 信息
2xx 成功
3xx 重定向
4xx 客户端错误
5xx 服务器错误

表 4.1 – 列出 HTTP 状态码及其用途的表格

在菜谱中,我们返回了以下错误代码:

  • 404 – 未找到

  • 405 – 方法不允许

http 模块公开了一个常量对象,该对象存储了所有 HTTP 响应代码及其对应描述:http.STATUS_CODES。我们使用它来返回带有 http.STATUS_CODE 的响应消息。

还有更多…

在菜谱中,我们使用以下行定义了 HOSTNAMEPORT 值的常量:

const HOSTNAME = process.env.HOSTNAME || '0.0.0.0';
const PORT = process.env.PORT || 3000;

使用 process.env 允许将值设置为环境变量。如果环境变量未设置,那么我们使用 OR 逻辑运算符(||)将意味着我们的主机名和端口号默认为 0.0.0.03000

允许通过环境变量设置主机名和端口号是一种良好的做法,因为这允许部署编排器,如 Kubernetes,在运行时注入这些值。

还可以将您的 HTTP 服务器绑定到随机空闲端口。为此,我们将 PORT 值设置为 0。您可以将分配 PORT 变量的代码更改为以下内容,以指示服务器监听随机空闲端口:

const PORT = process.env.PORT || 0;

在 Node.js 中绑定到任何随机端口在部署到动态分配端口的平台(例如,云服务)或存在潜在端口冲突的场景(例如,同时运行多个实例)时很有用。

使用 --env-file

截至 Node.js 20.6.0 及更高版本,有一个新的命令行选项可以用于从文件中加载环境变量。这提供了类似于常用 npmdotenvwww.npmjs.com/package/dotenv)的功能,通过从包含环境变量的文件中加载环境变量到 process.env

文件中的每一行应包含一个键值对,表示环境变量,名称和值由等号(=)分隔。例如,您可以将以下内容添加到定义 HOSTNAMEPORT 变量的默认值,用于菜谱:

HOSTNAME='0.0.0.0'
PORT=3000

通常,此文件将被称为 .env 用于本地开发,但通常也有多个环境文件表示不同的应用程序环境,例如 .staging.env 用于与您的开发中的预发布应用程序对应的环境值。

要加载环境中的值,您需要提供 --env-file 命令行选项:

$ node --env-file=.env server.js

如果在环境和文件中定义了相同的变量,环境中的值将优先。

注意,在撰写本文时,此功能被指定为 实验性 状态,这意味着该功能可能会受到破坏性更改和/或删除的影响。更多详细信息可以在官方 API 文档中找到:nodejs.org/docs/latest-v22.x/api/cli.html#--env-fileconfig

参见

  • 本章中的 接收 HTTP POST 请求 菜单

  • 第六章第十一章

接收 HTTP POST 请求

与用于检索数据的 HTTP GET 方法不同,HTTP POST 方法用于向服务器传输数据。

为了能够接收 POST 数据,我们需要指导服务器如何接受和处理 POST 请求。一个 POST 请求通常在请求体中包含数据,这些数据被发送到服务器进行处理。通常,通过 HTTP POST 请求提交网页表单。

重要提示

在 PHP 中,可以通过 \(_POST** 数组访问 **POST** 数据。PHP 不遵循 Node.js 的非阻塞架构,这意味着 PHP 程序将等待或阻塞,直到 **\)_POST 值被填充。然而,Node.js 提供了与 HTTP 数据的异步交互,在较低级别上,这允许我们将传入的消息体作为流进行接口。这意味着传入流的处理在开发者的控制之下。

在这个菜谱中,我们将创建一个接受和处理 HTTP POST 请求的 Web 服务器。

准备工作

为了为这个菜谱做准备,我们首先设置项目结构。

  1. 首先,为这个菜谱创建一个目录。我们还需要一个名为 server.js 的文件,该文件将包含我们的 HTTP 服务器:

    $ mkdir post-server
    $ cd post-server
    $ touch server.js
    
  2. 我们还需要创建一个名为 public 的子目录,其中包含一个名为 form.html 的文件,该文件将包含一个 HTML 表单:

    $ mkdir public
    $ touch public/form.html
    

如何做…

我们将创建一个服务器,该服务器使用由 http 模块提供的 Node.js 核心 API 接受和处理 HTTP GET 和 HTTP POST 请求。

  1. 首先,让我们设置一个带有名字和姓氏输入字段的 HTML 表单。打开 form.html 并添加以下内容:

    <form method="POST">
        <label for="forename">Forename:</label>
        <input id="forename" name="forename">
        <label for="surname">Surname:</label>
        <input id="surname" name="surname">
        <input type="submit" value="Submit">
    </form>
    
  2. 接下来,打开 server.js 文件并导入 fshttppath Node.js 核心模块:

    const http = require('node:http');
    const fs = require('node:fs');
    const path = require('node:path');
    
  3. 在下一行,我们将创建对 form.html 文件的引用:

    const form = fs.readFileSync(path.join(__dirname, 'public', 'form.html'));
    
  4. 现在,将以下代码行添加到 server.js 文件中,以设置服务器。我们还将创建一个 get() 函数来返回表单,并创建一个名为 error() 的错误处理函数:

    http
      .createServer((req, res) => {
        if (req.method === 'GET') {
          get(res);
          return;
        }
        error(405, res);
      })
      .listen(3000, () => console.log('Server running on
        http://localhost:3000/'));
    function get (res) {
      res.writeHead(200, {
        'Content-Type': 'text/html'
      });
      res.end(form);
    }
    function error (code, res) {
      res.statusCode = code;
      res.end(http.STATUS_CODES[code]);
    }
    
  5. 启动您的服务器并确认您可以在浏览器中通过 http://localhost:3000 查看表单:

    $ node server.js
    

    预期在您的浏览器中看到以下 HTML 表单:

    图 4.1 – 显示 HTML 表单的浏览器窗口

图 4.1 – 显示 HTML 表单的浏览器窗口

  1. 在您的浏览器中,点击表单上的 提交 按钮。注意,您会收到一个 方法不被允许 的错误消息。这是因为我们还没有在我们的请求监听器函数中添加处理 POST 请求的条件语句。现在让我们添加一个。在检查 GET 请求的 if 语句下方添加以下代码:

      if (req.method === 'POST') {
          post(req, res);
          return;
        }
    
  2. 现在,我们还需要定义我们的 post() 函数。在您的 server.js 文件下方添加此函数,理想情况下就在 get() 函数定义下方:

    function post (req, res) {
      if (req.headers['content-type'] !==
        'application/x-www-form-urlencoded') {
        error(415, res);
        return;
      }
      let input = '';
      req.on('data', (chunk) => {
        input += chunk.toString();
      });
      req.on('end', () => {
        console.log(input);
        res.end(http.STATUS_CODES[200]);
      });
    }
    
  3. 重新启动你的服务器,在浏览器中返回到 http://localhost:3000,并提交表单。你应该看到一个 OK 消息返回。如果你查看运行服务器的终端窗口,你可以看到服务器接收到了你的数据:

    $ node server.js
    forename=Ada&surname=Lovelace
    

我们现在创建了一个服务器,它使用 http 模块提供的 Node.js 核心 API 接受并处理 HTTP GET 和 HTTP POST 请求。

它是如何工作的...

Node.js 的核心 http 模块建立在并交互于 Node.js 的核心 net 模块之上。net 模块与 Node.js 内置的底层 C 库交互,称为 libuvlibuv C 库处理网络套接字 输入/输出I/O)并处理 C 和 JavaScript 层之间的数据传递。

与之前的配方一样,我们调用 createServer() 函数,它返回一个 HTTP 服务器对象。然后,在服务器对象上调用 listen() 方法指示 http 模块在指定的地址和端口上开始监听传入的数据。

当服务器接收到 HTTP 请求时,http 模块将创建代表 HTTP 请求(req)和 HTTP 响应(res)的对象。之后,我们的请求处理器被调用,并带有 reqres 参数。

我们的路由处理器有以下 if 语句,它们检查每个请求以确定它是否是 HTTP GET 请求或 HTTP POST 请求:

http
  .createServer((req, res) => {
    if (req.method === 'GET') {
      get(res);
      return;
    }
    if (req.method === 'POST') {
      post(req, res);
      return;
    }
    error(405, res);
  })
  .listen(3000);

我们的 get() 函数将 Content-Type HTTP 头设置为 text/html,因为我们期望返回一个 HTML 表单。我们调用 res.end() 函数来完成 WriteStream,写入响应,并结束 HTTP 连接。有关 WriteStream 的更多信息,请参阅 第三章

同样,我们的 post() 函数检查 Content-Type 头部以确定我们是否可以支持提供的值。在这种情况下,我们只接受 Content-Type 头为 application/x-www-form-urlencode,如果请求发送了任何其他内容类型,我们的错误函数将被调用。

在我们的请求处理器函数中,我们注册了一个数据事件的监听器。每次接收到数据块时,我们使用 toString() 方法将其转换为字符串,并将其追加到我们的输入变量中。

一旦从客户端接收到所有数据,就会触发 end 事件。我们向 end 事件监听器传递一个回调函数,该函数仅在接收到所有数据时被调用。我们的回调记录接收到的数据并返回一个 HTTP OK 状态消息。

还有更多...

Node.js 服务器通常允许通过 JSON 进行交互。让我们看看我们如何处理发送 JSON 数据的 HTTP POST 请求。具体来说,这意味着接受和处理具有 application/json 内容类型的内容。

让我们将服务器从当前配方转换为处理 JSON 数据。

  1. 首先,将现有的 server.js 文件复制到一个名为 json-server.js 的新文件中:

    $ cp server.js json-server.js
    
  2. 然后,我们将更改我们的 post() 函数以检查请求的 Content-Type 标头是否设置为 application/json

    function post (req, res) {
      if (req.headers['content-type'] !==
        'application/json') {
          error(415, res);
          return;
        }
    ...
    
  3. 我们还需要将我们的 end 事件监听器函数更改为解析并返回 JSON 数据:

      req.on('end', () => {
        try {
          const parsed = JSON.parse(input);
          console.log('Received data: ', parsed);
          res.end('{"data": ' + input + '}');
        } catch (err) {
          error(400, res);
        }
      });
    
  4. 让我们现在测试我们的服务器是否可以处理 POST 路由。我们将使用 cURL 命令行工具来完成此操作。在一个终端窗口中启动你的服务器:

    $ node json-server.js
    
  5. 在另一个终端窗口中,输入以下命令:

    $ curl --header "Content-Type: application/json" \
      --request POST \
      --data '{"forename":"Ada","surname":"Lovelace"}' \
      http://localhost:3000/
    {"data": {"forename":"Ada","surname":"Lovelace"}}%
    
  6. 现在,我们可以在我们的 form.html 文件中添加以下脚本,该脚本将我们的 HTML 表单数据转换为 JSON,并通过 POST 请求发送到服务器。在关闭表单标签()之后添加以下内容:

    <script>
      document.forms[0].addEventListener("submit", (event) => {
        event.preventDefault();
        let data = {
          forename:
            document.getElementById("forename").value,
          surname:
            document.getElementById("surname").value,
        };
        console.log("data", data);
        fetch("http://localhost:3000", {
          method: "post",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify(data),
        }).then(function (response) {
          console.log(response);
          return response.json();
        });
      });
    </script>
    

使用 $ node json-server.js 重新启动你的 JSON 服务器,并在浏览器中导航到 http://localhost:3000。如果我们现在在我们的浏览器中完成输入字段并提交表单,我们应该在服务器日志中看到请求已成功发送到服务器。注意,我们使用 event.preventDefault() 将阻止浏览器在表单提交时重定向网页。

我们的表单和服务器的行为与我们在 接收 HTTP POST 请求 菜谱中创建的服务器类似,不同之处在于前端表单通过发送表单数据的 JSON 表示的 HTTP POST 请求与后端交互。通过 JSON 与后端服务器交互的客户端前端是现代网络架构的典型做法。

参见

  • 第三章第六章第十一章

处理文件上传

将文件上传到网络是一个常见的活动,无论是图片、视频还是文档。与简单的 POST 数据相比,文件需要不同的处理。浏览器将正在上传的文件嵌入到多部分消息中。

多部分消息允许将多个内容片段组合成一个有效负载。要处理多部分消息,我们需要使用多部分解析器。

在这个菜谱中,我们将使用 formidable 模块作为我们的多部分解析器来处理文件上传。本菜谱中的文件上传将存储在磁盘上。

准备工作

要开始,让我们为我们的文件上传菜谱设置基础。

  1. 首先,让我们创建一个名为 file-upload 的新文件夹,并创建一个 server.js 文件:

    $ mkdir file-upload
    $ cd file-upload
    $ touch server.js
    
  2. 由于我们将在这个菜谱中使用 npm 模块,我们需要初始化我们的项目:

    $ npm init --yes
    
  3. 我们还需要创建两个子目录:一个名为 public 的目录用于存储我们的 HTML 表单,另一个名为 uploads 的目录用于存储我们的上传文件:

    $ mkdir public
    $ mkdir uploads
    

如何做到这一点…

在这个菜谱中,我们将创建一个可以处理文件上传并将文件存储在服务器上的服务器。

  1. 首先,我们应该创建一个包含文件输入字段的 HTML 表单。在 public 目录中创建一个名为 form.html 的文件。将以下内容添加到 form.html 中:

    <form method="POST" enctype="multipart/form-data">
        <label for="userfile">File:</label>
        <input type="file" id="userfile"
          name="userfile"><br>
        <input type="submit">
    </form>
    
  2. 现在,我们应该安装我们的多部分解析器模块,formidable

    $ npm install formidable
    
  3. 现在,我们可以开始创建我们的服务器。在 server.js 中,我们将导入所需的模块并创建一个变量来存储我们的 form.html 文件的路径:

    const fs = require('node:fs');
    const http = require('node:http');
    const path = require('node:path');
    const form = fs.readFileSync(path.join(__dirname,
      'public', 'form.html'));
    const { formidable } = require('formidable');
    
  4. 接下来,我们将创建我们的服务器,并为 GETPOST 请求创建处理程序。这就像我们在 接收 HTTP POST 请求 食谱中构建的服务器:

    http
      .createServer((req, res) => {
        if (req.method === 'GET') {
          get(res);
          return;
        }
        if (req.method === 'POST') {
          post(req, res);
          return;
        }
        error(405, res);
      })
     .listen(3000, () => {
        console.log('Server listening on
          http://localhost:3000');
      });
    function get (res) {
      res.writeHead(200, {
        'Content-Type': 'text/html'
      });
      res.end(form);
    }
    function error (code, res) {
      res.statusCode = code;
      res.end(http.STATUS_CODES[code]);
    }
    
  5. 现在,我们将添加我们的 post() 函数。这个函数将处理文件上传:

    function post (req, res) {
      if (!/multipart\/form-
        data/.test(req.headers['content-type'])) {
          error(415, res);
          return;
      }
      const form = formidable({
        keepExtensions: true,
        uploadDir: './uploads'
      });
      form.parse(req, (err, fields, files) => {
        if (err) return error(400, err);
        res.writeHead(200, {
          'Content-Type': 'application/json'
        });
        res.end(JSON.stringify({ fields, files }));
      });
    }
    
  6. 启动服务器并在浏览器中导航到 http://localhost:3000

    $ node server.js
    Server listening on http://localhost:3000
    
  7. 点击按钮上传文件(它可能被命名为 Choose FileBrowse 或类似,具体取决于您的浏览器和/或操作系统)并在文件资源管理器中选择任何要上传的文件。您应该看到文件指示它已被选中。提交文件。您的服务器应该已成功接收并存储文件,并以 JSON 格式的数据响应存储的文件:

    {
      "fields": {
      },
      "files": {
        "userfile": [
          {
            "size": 21,
            "filepath": "/Users/beth/Node.js-
    Cookbook/Chapter04/file-upload/uploads/ac36e936ec65f3b0699442f00.txt",
            "newFilename":
               "ac36e936ec65f3b0699442f00.txt",
            "mimetype": "text/plain",
            "mtime": "2024-04-15T02:52:18.886Z",
            "originalFilename": "file.txt"
          }
        ]
      }
    }
    
  8. 如果我们列出 uploads 目录的内容,我们应该看到上传的文件:

    $ ls uploads
    ac36e936ec65f3b0699442f00.txt
    

我们创建了一个可以处理文件上传的服务器,并通过在浏览器中上传文件来测试了这一点。

它是如何工作的…

在食谱的第一步中,我们设置了一个带有文件输入的 HTML 表单。表单元素上的 enctype="multipart/form-data" 属性指示浏览器将请求的 Content-Type 标头设置为 multipart/form-data。这也指示浏览器将待上传的文件嵌入到多部分消息中。

post() 函数检查 Content-Type 标头是否设置为 multipart/form-data。如果此标头未设置,我们调用我们的错误函数并返回一个带有消息 Unsupported Media Type415 HTTP 状态码。

post() 函数中,我们使用配置选项初始化了一个 formidable 对象,并将其赋值给一个名为 form 的常量:

  const form = formidable({
    keepExtensions: true,
    uploadDir: './uploads'
  });

第一个配置选项 keepExtensions:true 指示 formidable 保留上传文件的文件扩展名。uploadDir 选项用于指示 formidable 应将上传的文件存储在哪里,在我们的食谱中,我们将此设置为 uploads 目录。

接下来,我们调用 form.parse() 函数。此函数解析请求并收集请求中的表单数据。解析后的表单数据作为字段数组和文件数组传递给我们的回调函数。

在我们的 form.parse() 回调函数中,我们首先检查在 form.parse() 函数执行过程中是否发生了任何错误,如果有错误发生,则返回一个错误。假设表单数据成功解析,我们返回对请求的响应,这是一个 HTTP 状态码 200,OK。我们还返回 formidable 默认提供的有关上传文件的信息,以 JSON 格式的字符串表示。

Node.js 中的强大库在上传文件时使用随机文件名以防止冲突。分配唯一的名称有助于避免诸如文件覆盖等问题,例如,多个用户可能会上传同名文件,从而替换现有数据。此方法还有助于通过防止故意尝试覆盖敏感文件或预测和访问服务器上的文件来减轻与用户输入相关的安全风险。

这个配方演示了社区模块如formidable如何承担繁重的工作并处理复杂但常见的问题。在这个例子中,它使我们免于从头开始编写多部分解析器。有关在选择要包含在您的应用程序中的模块时应考虑的因素,请参阅第五章中的消耗 Node.js 模块配方。

重要提示

允许上传任何类型和任何大小的文件会使您的服务器容易受到拒绝服务DoS)攻击。攻击者可能会故意尝试上传过大或恶意文件来减慢您的服务器。建议您添加客户端和服务器端验证来限制服务器将接受的文件类型和大小。

还有更多...

在这个配方中,我们看到了如何处理只包含一个文件输入的简单表单。现在,让我们看看我们如何一次性处理多个文件的上传,以及我们如何在上传文件的同时处理其他类型的表单数据。

上传多个文件

在某些情况下,您可能希望同时将多个文件上传到服务器。方便的是,使用formidable,这默认支持。我们只需要对我们的form.html文件进行一个更改,即在输入元素中添加multiple属性:

<form method="POST" enctype="multipart/form-data">
    <label for="userfile">File:</label>
    <input type="file" id="userfile" name="userfile"
      multiple><br>
    <input type="submit">
</form>

使用node server.js启动服务器并导航到http://localhost:3000。现在,当您点击上传时,您应该能够选择多个文件进行上传。在 macOS 上,要选择多个文件,您可以按住Shift键并选择多个文件。然后,在提交多个文件后,formidable将返回有关上传的每个文件的详细信息。您应该会看到如下所示的 JSON 输出:

{
  "fields": {},
  "files": {
    "userfile": [
      {
        "size": 334367,
        "filepath": "/Users/beth/Node.js-Cookbook/Chapter04/file-upload/uploads/8bcdb0be88a49a8e1aec95e00.jpg",
        "newFilename":
          "8bcdb0be88a49a8e1aec95e00.jpg",
        "mimetype": "image/jpeg",
        "mtime": "2024-04-15T02:57:23.589Z",
        "originalFilename": "photo.jpg"
      },
      {
        "size": 21,
        "filepath": "/Users/beth/Node.js-Cookbook/Chapter04/file-upload/uploads/8bcdb0be88a49a8e1aec95e01.txt",
        "newFilename":
          "8bcdb0be88a49a8e1aec95e01.txt",
        "mimetype": "text/plain",
        "mtime": "2024-04-15T02:57:23.589Z",
        "originalFilename": "file.txt"
      }
    ]
  }
}

处理多个输入类型

表单中包含多种输入类型是很常见的。除了文件输入类型外,它还可能包含文本、密码、日期或其他输入类型。formidable模块可以处理混合数据类型。

HTML 输入元素

对于定义的输入类型的完整列表,请参阅 MDN 网络文档developer.mozilla.org/en-US/docs/Web/HTML/Element/input

让我们扩展在配方中创建的 HTML 表单,以包含一些额外的文本输入字段,以展示formidable如何处理多个输入类型。

首先,让我们向我们的form.html文件添加一个文本输入:

<form method="POST" enctype="multipart/form-data">
    <label for="user">User:</label>
    <input type="text" id="user" name="user"><br>
    <label for="userfile">File:</label>
    <input type="file" id="userfile" name="userfile"><br>
    <input type="submit">
</form>

使用 node server.js 启动服务器,并导航到 http://localhost:3000。在 user 字段中输入文本,并选择要上传的文件。点击 提交

你将收到一个包含所有表单数据的 JSON 响应,如下所示:

{
  "fields": { "user" : ["Beth"] },
  "files": {
    "userfile": [
      {
        "size": 21,
        "filepath": "/Users/beth/Node.js-Cookbook/Chapter04/file-upload/uploads/659d0cc8a8898fce93231aa00.txt",
        "newFilename": "659d0cc8a8898fce93231aa00.txt",
        "mimetype": "text/plain",
        "mtime": "2024-04-15T02:59:22.633Z",
        "originalFilename": "file.txt"
      }
    ]
  }
}

字段信息由 form.parse() 函数自动处理,使字段可供服务器访问。

参见

  • 第五章第六章,和 第九章

创建 WebSocket 服务器

WebSocket 协议允许浏览器和服务器之间进行双向通信。WebSocket 通常用于构建实时网络应用程序,如即时消息客户端。

在这个食谱中,我们将使用第三方 ws 模块创建一个我们可以通过浏览器与之交互的 WebSocket 服务器。

准备工作

首先,我们需要准备我们的项目目录,包括食谱所需的必要文件。

  1. 首先创建一个名为 websocket-server 的目录,包含两个文件——一个名为 client.js,另一个名为 server.js

    $ mkdir websocket-server
    $ cd websocket-server
    $ touch client.js
    $ touch server.js
    
  2. 此外,为了我们的客户,让我们创建一个包含名为 index.html 文件的公共目录:

    $ mkdir public
    $ touch public/index.html
    
  3. 由于我们将使用第三方 npm 模块,我们还需要初始化我们的项目:

    $ npm init --yes
    

如何做到这一点…

在这个食谱中,我们将创建一个 WebSocket 服务器和一个客户端,并在两者之间发送消息。

  1. 首先安装 ws 模块:

    $ npm install ws
    
  2. server.js 中导入 ws 模块:

    const WebSocket = require('ws');
    
  3. 现在,我们可以定义我们的 WebSocketServer 实例,包括它应该可访问的端口号:

    const WebSocketServer = new WebSocket.Server({
      port: 3000
    });
    
  4. 我们需要监听对 WebSocketServer 实例的连接和消息:

    WebSocketServer.on('connection', (socket) => {
      socket.on('message', (msg) => {
        console.log('Received:', msg.toString());
        if (msg.toString() === 'Hello')
          socket.send('World!');
      });
    });
    
  5. 现在,让我们创建我们的客户端。将以下内容添加到 client.js 中:

    const fs = require('node:fs');
    const http = require('node:http');
    const index = fs.readFileSync('public/index.html');
    const server = http.createServer((req, res) => {
      res.setHeader('Content-Type', 'text/html');
      res.end(index);
    });
    server.listen(8080);
    
  6. 打开 index.html 并添加以下内容:

    <h1>Communicating with WebSockets</h1>
    <input id="msg" /><button id="send">Send</button>
    <div id="output"></div>
    <script>
        const ws = new WebSocket('ws://localhost:3000');
        const output =
          document.getElementById('output');
        const send = document.getElementById('send');
        send.addEventListener('click', () => {
            const msg =
              document.getElementById('msg').value;
            ws.send(msg);
            output.innerHTML += log('Sent', msg);
        });
        function log(event, msg) {
            return '<p>' + event + ': ' + msg + '</p>';
        }
        ws.onmessage = function (e) {
            output.innerHTML += log('Received', e.data);
        };
        ws.onclose = function (e) {
            output.innerHTML += log('Disconnected',
              e.code);
        };
        ws.onerror = function (e) {
            output.innerHTML += log('Error', e.data);
        };
    </script>
    
  7. 现在,在一个终端窗口中启动你的服务器,在另一个终端窗口中启动你的客户端:

    $ node server.js
    $ node client.js
    
  8. 在浏览器中访问 http://localhost:8080,你应该看到一个简单的输入框和一个 提交 按钮。在输入框中输入 Hello 并点击 提交。WebSocket 服务器应该响应 World!

如果我们查看运行服务器的终端窗口,我们应该看到服务器接收到了消息:Received: Hello。这意味着我们现在已经有一个客户端和服务器通过 WebSocket 进行通信。

我们已经创建了一个 WebSocket 服务器和客户端,并展示了它们如何交换消息。现在,让我们看看它是如何工作的。

它是如何工作的…

在这个食谱中,我们使用了 ws 模块来定义一个 WebSocket 服务器:

const WebSocketServer = new WebSocket.Server({
  port: 3000,
});

我们注册了一个连接事件的监听器。传递给这个函数的函数在每次有新的 WebSocket 连接时执行。在连接事件回调函数中,我们有一个 socket 实例,在其中注册了一个消息事件的监听器,该监听器在接收到该 socket 上的消息时执行。

对于我们的客户端,我们定义了一个常规 HTTP 服务器来提供我们的 index.html 文件。我们的 index.html 文件包含在浏览器中执行的 JavaScript。在这个 JavaScript 中,我们创建了一个连接到我们的 WebSocket 服务器,提供了 ws 对象正在监听的端点:

    const ws = new WebSocket('ws://localhost:3000');

要向我们的 WebSocket 服务器发送消息,我们只需在 ws 对象上调用 send 方法,使用 ws.send(msg)

我们将 ws.send(msg) 包裹在一个事件监听器中。该事件监听器正在监听 Submit 按钮的 “click” 事件,这意味着当 Submit 按钮被点击时,我们会将消息发送到 WebSocket。

在我们的 index.html 脚本中,我们在 WebSocket 上注册了事件监听器函数,包括 onmessageoncloseonerror 事件监听器:

    ws.onmessage = function (e) {
        output.innerHTML += log('Received', e.data);
    };
    ws.onclose = function (e) {
        output.innerHTML += log('Disconnected', e.code);
    };
    ws.onerror = function (e) {
        output.innerHTML += log('Error', e.data);
    };

这些函数在其相应的事件上执行。例如,当我们的 WebSocket 接收到消息时,onmessage() 事件监听器函数将执行。我们使用这些事件监听器根据事件向我们的网页添加输出。

更多内容…

现在,我们已经学会了如何使用 WebSocket 在浏览器和服务器之间进行通信。但也可以在 Node.js 中创建一个 WebSocket 客户端,使两个 Node.js 程序能够通过以下步骤使用 WebSocket 进行通信:

  1. 首先,在 websocket-server 目录内创建一个新文件,命名为 node-client.js

    $ touch node-client.js
    
  2. 导入 ws 模块并创建一个新的 WebSocket 对象,该对象配置为指向我们在 创建 WebSocket 服务器 菜谱中创建的 WebSocket 服务器:

    const WebSocket = require('ws');
    const ws = new WebSocket('ws://localhost:3000');
    
  3. 现在,我们将在我们的套接字上设置一些监听器。我们将添加对 openclosemessage 事件的监听:

    ws.on('open', () => {
      console.log('Connected');
    });
    ws.on('close', () => {
      console.log('Disconnected');
    });
    ws.on('message', (message) => {
      console.log('Received:', message.toString());
    });
    
  4. 现在,让我们每 3 秒向 WebSocket 服务器发送一个 'Hello' 消息。我们将使用 setInterval() 函数来实现这一点:

    setInterval(() => {
      ws.send('Hello');
    }, 3000);
    
  5. 在单独的终端窗口中启动 WebSocket 服务器和基于 Node.js 的客户端:

    $ node server.js
    $ node node-client.js
    
  6. 你应该期望服务器每 3 秒对你的 'Hello' 消息做出响应,并返回 World! 消息:

    Connected
    Received: World!
    Received: World!
    Received: World!
    

现在,你已经创建了两个 Node.js 程序之间的 WebSocket 通信。

相关内容

  • 第二章与标准 I/O 接口 菜谱

  • 第二章通过套接字通信 菜谱

  • 第六章

创建 SMTP 服务器

SMTP 是一种用于发送电子邮件的协议。在本菜谱中,我们将使用名为 smtp-server 的第三方 npm 模块来设置 SMTP 服务器。

你可能每天都会在收件箱中收到几封自动发送的电子邮件。在 更多内容… 部分中,我们将学习如何通过 Node.js 向我们创建的 SMTP 服务器发送电子邮件。

准备中

首先,让我们创建一个名为 server-smtp 的目录和一个名为 server.js 的文件:

$ mkdir server-smtp
$ cd server-smtp
$ touch server.js

由于我们将使用来自 npm 的第三方 smtp-server 模块,我们需要初始化我们的项目:

$ npm init --yes

重要提示

注意,我们无法将此菜谱的目录命名为 smtp-server,因为 npm 拒绝允许您安装项目名称与模块名称相同的模块。如果我们命名目录为 smtp-server,我们的 package.json 名称也将设置为 smtp-server,我们将无法使用相同名称安装模块。

如何做到这一点…

在这个菜谱中,我们将创建一个可以接收电子邮件消息的 SMTP 服务器。我们将使用 smtp-server 模块来实现这一点。

  1. 首先,开始安装 smtp-server 模块:

    $ npm install smtp-server
    
  2. 接下来,我们需要打开 server.js 并导入 server-smtp 模块:

    const SMTPServer = require('smtp-
      server').SMTPServer;
    
  3. 让我们定义 SMTP 服务器应可访问的端口号:

    const PORT = 4321;
    
  4. 现在,我们将创建一个 SMTP 服务器对象:

    const server = new SMTPServer({
      disabledCommands: ['STARTTLS', 'AUTH'],
      logger: true
    });
    
  5. 我们还应该捕获任何错误。在服务器对象上注册一个错误事件监听函数:

    server.on('error', (err) => {
      console.error(err);
    });
    
  6. 最后,我们可以调用 listen() 函数来启动我们的 SMTP 服务器:

    server.listen(PORT);
    
  7. 启动您的 SMTP 服务器:

    $ node server.js
    [2020-04-27 21:57:51] INFO  SMTP Server listening on [::]:4321
    
  8. 您可以使用新的终端中的 nctelnet 命令行工具测试到服务器的连接:

    $ telnet localhost 4321
    $ nc -c localhost 4321
    

我们现在已经确认我们的 SMTP 服务器可用,并正在端口 4321 上监听。

它是如何工作的…

在菜谱中,我们利用了 smtp-server 模块。此模块负责 SMTP 协议的实现,这意味着我们可以专注于程序的逻辑,而不是底层实现细节。

smtp-server 模块提供了高级 API。在菜谱中,我们使用了以下内容来创建一个新的 SMTP 服务器对象:

const server = new SMTPServer({
  disabledCommands: ['STARTTLS', 'AUTH'],
  logger: true
});

SMTPServer 对象的构造函数接受许多参数。可以传递给 SMTPServer 构造函数的完整选项列表可在 nodemailer 文档中找到,网址为 nodemailer.com/extras/smtp-server/

在这个菜谱中,我们添加了 disabledCommands: ['STARTTLS', 'AUTH'] 选项。此选项为了简化,禁用了 传输层安全性 ( TLS ) 支持和身份验证。然而,在生产环境中,不建议禁用 TLS 支持和身份验证。相反,建议强制执行 TLS。您可以使用 smtp-server 模块通过指定 secure:true 选项来实现这一点。

如果您希望强制执行连接的 TLS,您还需要定义一个私钥和证书。如果没有提供证书,则模块将生成自签名证书;然而,许多客户端会拒绝这些证书。

SMTPServer 构造函数中指定的第二个选项是 logger:true 选项,它启用了 SMTP 服务器的日志记录。

为了启动我们的 SMTPServer 构造函数,我们在 SMTPServer 对象上调用 listen() 函数。可以将端口号、主机名和回调函数传递给 listen() 函数。在这种情况下,我们只提供了端口号;主机名将默认为 localhost

还有更多…

现在我们已经设置了一个简单的 SMTP 服务器,我们应该尝试通过 Node.js 向其发送电子邮件。

使用 Node.js 发送电子邮件,我们可以使用来自npmnodemailer模块。此模块由与在创建 SMTP 服务器配方中使用的smtp-server模块相同的组织提供。

  1. 让我们从在我们的server-smtp目录中安装nodemailer模块开始:

    $ npm install nodemailer
    
  2. 接下来,我们将创建一个名为send-email.js的文件:

    $ touch send-email.js
    
  3. 我们需要添加到send-email.js文件以导入nodemailer模块的第一行代码如下:

    const nodemailer = require('nodemailer');
    
  4. 接下来,我们需要设置传输对象;我们将配置transporter对象以连接到在创建 SMTP 服务器配方中创建的 SMTP 服务器:

    const transporter = nodemailer.createTransport({
      host: 'localhost',
      port: 4321,
    });
    
  5. 接下来,我们可以在transporter对象上调用sendMail()函数:

    transporter.sendMail(
      {
        from: 'beth@example.com',
        to: 'laddie@example.com',
        subject: 'Hello',
        text: 'Hello world!',
      },
      (err, info) => {
        if (err) {
          console.log(err);
        }
        console.log("Message Sent:", info);
      }
    );
    

    sendMail()函数的第一个参数是一个表示电子邮件的对象,包括发送者和接收者的电子邮件地址、主题行和电子邮件正文。第二个参数是一个回调函数,在邮件发送后执行。

  6. 要测试我们的send-email.js程序,首先启动 SMTP 服务器:

    $ node server.js
    
  7. 在第二个终端窗口中,运行你的send-email.js程序:

    $ node send-email.js
    
  8. 你应该期望从服务器看到以下输出:

    [2020-04-27 23:05:44] INFO  [#cifjnbwdwbhcf54a] Connection from [127.0.0.1]
    [2020-04-27 23:05:44] DEBUG [#cifjnbwdwbhcf54a] S: 220 Beths-MBP.lan ESMTP
    [2020-04-27 23:05:44] DEBUG [#cifjnbwdwbhcf54a] C: EHLO Beths-MBP.lan
    [2020-04-27 23:05:44] DEBUG [#cifjnbwdwbhcf54a] S: 250-Beths-MBP.lan Nice to meet you, [127.0.0.1]
    [2020-04-27 23:05:44] DEBUG [#cifjnbwdwbhcf54a] 250-PIPELINING
    [2020-04-27 23:05:44] DEBUG [#cifjnbwdwbhcf54a] 250-8BITMIME
    [2020-04-27 23:05:44] DEBUG [#cifjnbwdwbhcf54a] 250 SMTPUTF8
    [2020-04-27 23:05:44] DEBUG [#cifjnbwdwbhcf54a] C: MAIL FROM:<beth@example.com>
    [2020-04-27 23:05:44] DEBUG [#cifjnbwdwbhcf54a] S: 250 Accepted
    [2020-04-27 23:05:44] DEBUG [#cifjnbwdwbhcf54a] C: RCPT TO:<laddie@example.com>
    [2020-04-27 23:05:44] DEBUG [#cifjnbwdwbhcf54a] S: 250 Accepted
    [2020-04-27 23:05:44] DEBUG [#cifjnbwdwbhcf54a] C: DATA
    [2020-04-27 23:05:44] DEBUG [#cifjnbwdwbhcf54a] S: 354 End data with <CR><LF>.<CR><LF>
    [2020-04-27 23:05:44] INFO  <received 261 bytes>
    [2020-04-27 23:05:44] DEBUG [#cifjnbwdwbhcf54a] C: <261 bytes of DATA>
    [2020-04-27 23:05:44] DEBUG [#cifjnbwdwbhcf54a] S: 250 OK: message queued
    [2020-04-27 23:05:44] INFO  [#cifjnbwdwbhcf54a] Connection closed to [127.0.0.1]
    
  9. 你应该从send-email.js程序看到以下输出:

    Message Sent: { accepted: [ 'laddie@example.com' ],
      rejected: [],
      ehlo: [ 'PIPELINING', '8BITMIME', 'SMTPUTF8' ],
      envelopeTime: 4,
      messageTime: 2,
      messageSize: 279,
      response: '250 OK: message queued',
      envelope: { from: 'beth@example.com', to: [
        'laddie@example.com' ] },
      messageId: '<fde460ce-f83a-95e2-5f8a-
        76dd11f6e61f@example.com>' }
    

这表明我们已经成功创建了一个 SMTP 服务器,并且能够从另一个 Node.js 程序向 SMTP 服务器发送电子邮件。

参见

  • 第五章第九章

第五章:开发 Node.js 模块

Node.js 的一个主要吸引力是庞大的外部第三方库生态系统。Node.js 模块是您想要包含在应用程序中的库或一系列函数。大多数模块将提供一个 API 来暴露功能。npm 注册处是大多数 Node.js 模块存储的地方,那里有超过一百万个可用的 Node.js 模块。

本章将首先介绍如何使用 npm 命令行 界面 ( CLI ) 从 npm 注册处消费现有的 Node.js 模块,以便在您的应用程序中使用。

在本章的后面部分,您将学习如何开发和发布您自己的 Node.js 模块到 npm 注册处。还将介绍如何使用 ECMAScript Modules ( ESM ) 语法,该语法在所有当前支持的 Node.js 版本中可用。本章中的菜谱是相互关联的,因此建议您按顺序完成它们。

本章将涵盖以下菜谱:

  • 消费 Node.js 模块

  • 搭建模块

  • 编写模块代码

  • 发布模块

  • 使用 ESM

技术要求

本章将要求您安装 Node.js,最好是最新版本的 Node.js 22,并且您应该已经安装了与 Node.js 捆绑的 npm CLI。nodenpm 应该在您的 shell(或终端)路径中。

重要提示

建议使用 Node Version Manager ( nvm ) 安装 Node.js。这是一个工具,它使您能够在大多数类 Unix 平台上轻松切换 Node.js 版本。如果您使用的是 Windows,您可以从 nodejs.org/en/ 安装 Node.js。

您可以通过在终端中输入以下命令来确认已安装的 Node.js 和 npm 版本:

$ node --version
v22.9.0
$ npm --version
10.8.3

npm CLI 是 Node.js 默认的包管理器,并且我们将在本章中使用捆绑的 npm CLI 来安装和发布模块。

重要提示

npm CLI 作为默认的包管理器捆绑在 Node.js 中。npm, Inc. 也是拥有 npm 注册处的公司的名称(npmjs.org/)。

注意,由于我们将从 npm 注册处下载和发布模块,因此本章将需要互联网访问。

消费 Node.js 模块

在这个菜谱中,我们将学习如何使用 npm CLI 从公共 npm 注册处消费 npm 模块。

重要提示

Yarn 是一个流行的 JavaScript 包管理器,于 2016 年创建,作为对 npm CLI 的替代。当 Yarn 发布时,npm 没有提供 package-lock.json 功能来保证安装特定模块版本的稳定性。这是 Yarn 的一个关键特性。在撰写本文时,Yarn CLI 提供了与 npm CLI 相似的使用体验。Yarn 维护了一个注册表,它是 npm 注册表的反向代理。有关 Yarn 的更多信息,请查看他们的 入门 指南:yarnpkg.com/getting-started

准备工作

要开始,我们首先需要创建一个新的工作目录:

$ mkdir consuming-modules
$ cd consuming-modules

我们还需要一个文件,我们可以尝试在其中执行导入的模块:

$ touch require-express.js

如何操作...

在本节中,我们将设置一个项目并安装 express 模块,这是一个常用的 Node.js 网络框架,也是新用户学习运行时经常学习的第一个模块之一。

  1. 首先,我们需要初始化一个新的项目。通过输入以下内容来完成此操作:

    $ npm init
    
  2. 您需要逐步通过实用程序来回答命令行实用程序中的问题。如果您不确定,可以简单地按 Enter 键接受默认值。

  3. npm init 命令应该在您的项目目录中生成了一个 package.json 文件。它应该看起来像这样:

    {
      "name": "consuming-modules",
      "version": "1.0.0",
      "main": "require-express.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" &&
          exit 1"
      },
      "author": "",
      "license": "ISC",
      "description": ""
    }
    
  4. 现在,我们可以安装我们的模块。要在项目目录中安装 express 模块,请输入以下命令:

    $ npm install express
    
  5. 如果我们再次查看 package.json 文件,我们应该会看到模块已经被添加到了 dependencies 字段中:

    {
      "name": "consuming-modules",
      "version": "1.0.0",
      "description": "",
      "main": "require-express.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" &&
          exit 1"
      },
      "author": "",
      "license": "ISC",
      "dependencies": {
        "express": "⁴.18.2"
      }
    }
    

    此外,请注意,现在在您的项目目录中已经创建了一个 node_modules 目录和一个 package-lock.json 文件。

  6. 现在,我们可以打开我们的 require-express.js 文件。我们只需要添加以下行来测试我们是否可以导入并使用该模块:

    const express = require('express');
    
  7. 预期程序在引入 express 模块后立即执行并终止。如果模块没有成功安装,我们会看到以下错误:

    $ node require-express.js
    internal/modules/cjs/loader.js:979
      throw err;
      ^
    Error: Cannot find module 'express'
    

我们现在已成功从 npm 注册表中下载了一个第三方模块并将其导入到我们的应用程序中,以便可以使用它。

它是如何工作的…

脚本使用了 npm(Node.js 中的 CLI 打包工具)和 npm 公共注册表来下载 express 第三方模块。

脚本的第一步是 npm init 命令。此命令在当前工作目录中初始化一个新的项目。默认情况下,运行此命令将打开一个 CLI 工具,它会询问一些关于您项目的信息。以下表格定义了请求的属性:

属性 定义
包名 指定项目的名称。在发布到 npm 注册表时,名称必须是唯一的。名称可以由一个作用域前缀;例如,@organization/package
版本 项目的初始版本。Node.js 模块通常遵循语义化版本控制标准。默认值是1.0.0
描述 对你的项目的简要描述,以帮助用户了解你的项目做什么以及其目的。
入口点 你的 Node.js 应用程序或模块的入口点文件。它是当你的模块被另一个应用程序要求时将被执行的主文件的路径。默认值是index.js
测试命令 用于定义在执行npm testnpm run test时运行的命令。通常,这将是你执行测试套件的命令。
Git 仓库 指定你的项目源代码仓库的位置。这对于想要访问代码、报告问题或贡献的开发者和用户很有帮助。
关键词 与你的项目相关的关键词。
作者 项目作者列表。
许可证 指示项目分发的许可类型。这对于用户了解他们如何使用和分享你的项目很重要。

表 5.1 – 表格详细说明了 package.json 文件的默认属性

必须的属性只有包名和版本。也可以跳过 CLI 实用程序,通过输入以下内容接受所有默认值:

$ npm init --yes

可以使用npm config命令配置默认答案。这可以通过以下命令实现:

$ npm config set init.author.name "Your Name"

一旦npm init命令完成,它将在你的当前工作目录中生成一个package.json文件。该package.json文件执行以下操作:

  • 它列出了你的项目所依赖的包,作为一个蓝图或一系列指令,说明需要安装哪些依赖项

  • 提供了一种机制,让你可以指定你的项目可以使用的包的版本——基于语义化版本控制规范(semver.org/)。

在下一步骤中,我们使用了npm install express命令来安装express模块。该命令会连接到npm注册表,下载具有express名称标识符的模块的最新版本。

重要提示

默认情况下,当提供模块名称时,npm install命令会查找具有该名称的模块,并从公共npm注册表下载它。但也可以传递其他参数给npm install命令,例如 GitHub URL,然后命令会安装该 URL 上可用的内容。有关更多信息,请参阅npm CLI 文档:docs.npmjs.com/cli/v10/commands/npm-install

安装命令完成后,它将模块内容放入一个node_modules目录中。如果当前项目中没有,但有package.json,该命令也会创建一个node_modules目录。

如果您查看 node_modules 目录的内容,您会注意到除了 express 模块之外还有其他内容。这是因为 express 有依赖项,它们的依赖项也可能有依赖项。

当安装一个模块时,您实际上和经常是在安装一个整个模块树。以下输出显示了配方中 node_modules 目录结构的一个片段:

$ ls node_modules
     |-- accepts
     |-- escape-html
     |-- ipaddr.js
     |-- raw-body
     |-- array-flatten
     |-- etag
     |-- media-typer
     |-- safe-buffer
     |-- ...

您也可以使用 npm list 命令来列出您的 node_modules 目录的内容。

您也可能注意到已创建了一个 package-lock.json 文件。package-lock.json 类型的文件是在 npm 版本 5 中引入的。package-lock.jsonpackage.json 的区别在于 package-lock.json 文件定义了 node_modules 树中所有模块的特定版本。

由于依赖项的安装方式,两个具有相同 package.json 文件的开发者运行 npm install 时可能会遇到不同的结果。这主要是因为 package.json 文件可以指定可接受的模块范围。

例如,在我们的配方中,我们安装了 express 的最新版本,这导致了以下范围:

"express": "⁴.18.2"

^ 字符表示它将允许安装所有高于 v4.18.2 的版本,但不能安装 v5.x.x。如果开发者在运行 npm install 命令之间发布了 v4.18.3,那么开发者 A 很可能得到 v4.18.2,而开发者 B 将得到 v4.18.3。

如果开发者在之间共享 package-lock.json 文件,他们将确保安装相同版本的 express 以及 express 所有依赖项的相同版本。

npm CLI 还可以使用 npm shrinkwrap 命令生成 npm-shrinkwrap.json 文件。npm-shrinkwrap.json 文件的结构与 package-lock.json 文件相同,并且具有类似的作用。package-lock.json 文件不能发布到注册表,而 npm-shrinkwrap.json 可以。通常,当发布 npm 模块时,您可能不想包含 npm-shrinkwrap.json 文件,因为它会阻止模块接收传递依赖项更新。

如果一个包中存在 npm-shrinkwrap.json 文件,这意味着该包的所有安装都将生成相同的依赖项。npm-shrinkwrap.json 文件对于确保生产环境中的安装一致性非常有用。

在配方的最后一步,我们导入了 express 模块来测试它是否已安装并可访问:

const express = require('express');

注意,这与您导入 Node.js 核心模块的方式相同。模块加载算法将首先检查您是否正在请求一个核心 Node.js 模块;然后它将在 node_modules 文件夹中查找具有该名称的模块。

还可以使用 require() 通过传递路径来导入文件,如下所示:

const file = require('./file.js');

还有更多...

现在我们已经了解了一些关于消费 Node.js 模块的知识,我们将探讨开发依赖、全局模块以及消费 Node.js 模块时应考虑的因素。

理解开发依赖

package.json 中,你可以区分开发依赖和常规依赖。开发依赖通常用于支持你开发应用程序的工具。

开发依赖不应该被要求运行你的应用程序。在区分运行应用程序所需的依赖和开发应用程序所需的依赖时,这一点尤其有用,尤其是在部署应用程序时。你的生产应用程序部署可以省略开发依赖,这使得生成的生产应用程序更小。开发依赖的一个非常常见的用途是用于代码检查和格式化。

要安装开发依赖,你需要向 install 命令提供 --save-dev 参数。例如,要安装 semistandard,我们可以使用以下命令:

$ npm install --save-dev --save-exact semistandard

--save-exact 参数将锁定 package.json 文件中的确切版本。

注意,在 package.json 中为开发依赖创建了一个单独的部分:

{
  "name": "consuming-modules",
  "version": "1.0.0",
  "description": "",
  "main": "require-express.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "⁴.18.2"
  },
  "devDependencies": {
    "semistandard": "17.0.0"
  }
}

你可以使用以下命令执行已安装的 semistandard 可执行文件:

$ ./node_modules/semistandard/bin/cmd.js

安装全局模块

有可能全局安装 Node.js 模块。通常,你将全局安装的模块类型是二进制文件或你希望在终端中可访问的程序。要全局安装一个模块,你需要在 install 命令中传递 --global 命令,如下所示:

$ npm install --global lolcatjs

这不会将 lolcatjs 安装到你的 node_modules 文件夹中。相反,它将被安装到你的 Node.js 安装目录下的 bin 文件夹中。要查看安装位置,你可以使用 which 命令(或在 Windows 上使用 where):

$ which lolcatjs
/Users/bgriggs/.nvm/versions/node/v20.11.0/bin/lolcatjs

bin 目录很可能已经包含在你的路径中,因为那里存储了 nodenpm 的二进制文件。因此,任何全局安装的可执行程序也将可在你的 shell 中使用。现在,你应该能够从你的 shell 中调用 lolcatjs 模块:

$ lolcatjs --help

npm v5.2 中,npm 向他们的 CLI 添加了 npx 命令。这个命令允许你执行全局模块,而无需在系统中全局安装它。你可以使用以下命令执行 lolcatjs 模块,而无需将其存储:

$ npx lolcatjs

通常,npx 应该足够用于大多数你希望执行的模块。使用 npx 可能更可取,因为它允许你运行包而不污染全局命名空间。它还可以在基于项目的每个包上执行不同版本时有所帮助,因为它避免了任何全局版本冲突。

负责任地消费模块

您可能会希望利用 Node.js 模块生态系统来构建您的应用程序。模块提供了常见问题和任务的解决方案和实现,因此重用现有代码可以在开发应用程序时节省您的时间。

正如您在菜谱中看到的,简单地引入网络框架express就引入了超过 80 个其他模块。引入这么多模块会增加风险,尤其是如果您正在使用这些模块进行生产工作负载。

在选择将 Node.js 模块包含到您的应用程序中时,您应该考虑许多因素。以下三个因素尤其需要注意:

  • 安全性:您能否依赖该模块来修复安全漏洞?第九章将更详细地介绍如何检查您模块中的已知安全问题。

  • 许可:如果您与开源库链接然后分发软件,您的软件需要遵守链接库的许可。许可可能从限制性/保护性到宽容性不等。在 GitHub 上,您可以导航到许可文件,它将为您提供关于许可允许内容的简要概述:

图 5.1 – GitHub 许可信息

图 5.1 – GitHub 许可信息

  • 维护:您还需要考虑该模块的维护情况。许多模块将源代码发布到 GitHub,并将错误报告作为 GitHub 问题公开。通过查看这些问题以及维护者如何/何时响应错误报告,您应该能够了解该模块的维护情况。

参见

  • 本章中的构建模块框架菜谱

  • 本章中的编写模块代码菜谱

  • 本章中的发布模块菜谱

  • 第六章第九章

构建模块框架

在这个菜谱中,我们将构建我们的第一个模块;也就是说,我们将为我们的模块设置一个典型的文件和目录结构,并学习如何使用npm CLI 初始化我们的项目。我们还将创建一个 GitHub 仓库来存储我们的模块代码。GitHub 是一个托管提供商,允许用户存储基于Git的仓库,其中 Git 是一个版本控制系统VCS)。

我们将要制作的模块将提供一个 API,该 API 可以将华氏温度转换为摄氏温度,反之亦然。

准备工作

本菜谱将要求您拥有 GitHub 账户(github.com/join)以发布源代码,以及npm账户(www.npmjs.com/signup)以发布您的模块。

如何做到这一点...

在这个菜谱中,我们将使用npm CLI 来初始化我们的temperature-converter模块。

  1. 让我们创建一个 GitHub 仓库来存储我们的模块代码。为此,您可以从 GitHub 导航栏点击 + | New repository,或者导航到 github.com/new 。指定仓库名称为 temperature-converter 。请注意,仓库名称不必与模块名称匹配。

  2. 当您在这里时,还建议添加 Node.js 的默认 .gitignore 文件和与 package.json 中的许可字段匹配的许可文件。您应该会看到以下 GitHub 用户界面 ( UI ) 用于创建新仓库:

图 5.2 – GitHub 创建新仓库界面

图 5.2 – GitHub 创建新仓库界面

重要提示

一个 .gitignore 文件会通知 Git 在项目中忽略哪些文件。GitHub 为每种语言或运行时提供默认的 .gitignore 文件。GitHub 为 Node.js 提供的默认 .gitignore 文件可在github.com/github/gitignore/blob/master/Node.gitignore 上查看。请注意,node_modules 会自动添加到 .gitignore 中。package.json 文件指示需要为项目安装哪些模块,并且通常期望每个开发者都会在自己的开发环境中运行 npm install 命令,而不是将 node_modules 目录提交到源代码控制。

  1. 现在仓库已初始化,我们可以使用 shell 中的 Git CLI 来克隆仓库。输入以下命令来克隆仓库,用您的 GitHub 用户名替换对仓库的引用:

    $ git clone git@github.com:username/temperature-converter.git
    Cloning into 'temperature-converter'...
    remote: Enumerating objects: 5, done.
    remote: Counting objects: 100% (5/5), done.
    remote: Compressing objects: 100% (5/5), done.
    remote: Total 5 (delta 0), reused 0 (delta 0), pack-reused 0
    Receiving objects: 100% (5/5), done.
    

重要提示

建议使用 Secure Shell ( SSH ) 来克隆仓库。如果您尚未为 GitHub 设置 SSH 密钥,那么您应该遵循docs.github.com/en/authentication/connecting-to-github-with-ssh 中的步骤。

  1. 切换到新克隆的目录并观察现有文件:

    $ cd temperature-converter
    $ ls
    LICENSE   README.md
    
  2. 您还可以运行 git statusgit log 命令来查看我们的状态:

    $ git status
    On branch main
    Your branch is up to date with 'origin/main'.
    nothing to commit, working tree clean
    
  3. 现在我们已经创建了我们的仓库,并在本地有一个副本来工作,我们可以初始化我们的模块:

    $ npm init
    
  4. 您可以使用 Enter 接受默认值或按以下方式完成值。该命令将为您创建一个 package.json 文件。打开文件,您应该会看到以下输出:

    {
      "name": "temperature-converter",
      "version": "0.1.0",
      "description": "Converts temperatures between Fahrenheit and Celsius.",
      "main": "index.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
      },
      "keywords": [
        "temperature",
        "converter",
        "utility"
      ],
      "author": "Beth Griggs",
      "license": "MIT",
      "repository": {
        "type": "git",
        "url": "git+https://github.com/BethGriggs/temperature-converter.git"
      },
      "bugs": {
        "url": "https://github.com/BethGriggs/temperature-converter/issues"
      },
      "homepage": "https://github.com/BethGriggs/temperature-converter#readme"
    }
    
  5. 打开 README.md 文件并添加一些简单的文本,然后保存。例如,您可以在 Markdown 格式中添加一个简单的标题:# temperature-converter

  6. 现在,我们将使用以下命令提交这些更改:

    $ git add package.json README.md
    $ git commit --message "first commit"
    $ git push origin main
    

    当这成功时,您应该会看到以下输出:

    Enumerating objects: 6, done.
    Counting objects: 100% (6/6), done.
    Delta compression using up to 10 threads
    Compressing objects: 100% (4/4), done.
    Writing objects: 100% (4/4), 1020 bytes | 1020.00 KiB/s, done.
    Total 4 (delta 1), reused 0 (delta 0), pack-reused 0
    remote: Resolving deltas: 100% (1/1), completed with 1 local object.
    To github.com:username/temperature-converter.git
       c0f53ef..6d27a2a  main -> main
    

我们已经看到了如何使用 Git 和 npm CLI 初始化我们的 temperature-converter 模块。

工作原理…

为了启动我们的项目,我们首先设置一个 GitHub 仓库,它作为存储和管理我们的代码库的中心枢纽。这涉及到在 GitHub 上创建一个新的仓库,我们将在此存储我们的模块代码,指定名称为temperature-converter。此外,我们抓住机会包括一个.gitignore文件,该文件通知 Git 排除哪些文件不进行版本控制,并添加一个许可文件,定义他人如何使用我们的代码。

一旦我们的仓库建立起来,我们使用 Git CLI 在本地克隆它。克隆会在我们的本地机器上创建仓库的副本,使我们能够离线工作在代码库上,并在准备好时将更改推送到远程仓库。我们进入克隆的目录以检查其内容,并使用git statusgit log来审查仓库的状态和历史。

我们的本地设置准备就绪后,我们使用npm初始化我们的模块。npm init命令引导我们创建一个package.json文件,该文件包含关于我们的项目的基本元数据,例如其名称、版本和依赖项。此文件作为我们模块的蓝图,并确保在不同环境中的一致性。

为了完成我们的初始设置,我们将更改提交到仓库中。这涉及到暂存package.jsonREADME.md文件,用描述性消息提交它们,并将更改推送到 GitHub 上的远程仓库。这一步确保了我们的项目历史记录得到良好的记录,并且我们的最新更改已发布。

重要提示

Git 是一个强大的工具,通常用于软件的源代码控制。如果你对 Git 不熟悉,GitHub 提供了一个交互式指南,你可以通过guides.github.com/introduction/flow/来学习。

还有更多...

在这个食谱中,我们指定了模块版本为 v0.1.0,以遵循语义化版本控制。让我们更详细地看看这一点。

语义化版本控制,通常缩写为SemVer,是一个广为人知的版本控制标准。Node.js 本身尽可能地遵循语义化版本控制。

语义化版本号的形式为X.Y.Z,以下适用:

  • X代表主版本

  • Y代表次要版本

  • Z代表补丁版本

简而言之,语义化版本控制指出,当你进行破坏性 API 更改时,增加主版本,即第一个值。第二个数字,即次要版本,是在向后兼容(或非破坏性)方式中添加新功能时增加的。补丁版本,即第三个数字,用于错误修复和非破坏性、非增量更新。

主版本号 0 保留用于初始开发,并且直到 v1 发布之前进行破坏性更改是可以接受的。关于初始版本应该是什么版本,常常存在争议。在菜谱中,我们以 v0.1.0 版本开始,这样我们就可以在早期开发中进行破坏性更改,而无需增加主版本号。

遵循语义版本控制是 Node.js 模块生态系统中的常见做法。npm CLI 通过允许在package.json中使用semver范围来考虑这一点 – 参考本章中消费 Node.js 模块菜谱的更多内容…部分或访问docs.npmjs.com/files/package.json#dependencies以获取有关npm版本范围的更多信息。

npm CLI 提供了一个 API 来支持语义版本控制。npm version命令可以与majorminorpatch一起使用,以在您的package.json文件中增加适当的版本号。还可以向npm版本命令传递其他参数,包括对预版本的支持 – 参考文档docs.npmjs.com/cli/version以获取更多信息。

参见

  • 本章中的编写模块代码菜谱

  • 本章中的发布模块菜谱

编写模块代码

在这个菜谱中,我们将开始编写我们的模块代码。我们将编写的模块将公开两个 API,用于将提供的温度从华氏度转换为摄氏度,反之亦然。我们还将安装一个流行的代码格式化工具,以保持我们的模块代码一致性,并添加一些简单的测试用例。

准备工作

确保您位于temperature-converter文件夹中,并且存在package.json,表示我们有一个初始化的项目目录。

我们还需要为我们的模块创建第一个 JavaScript 文件:

$ touch index.js

之后,我们将尝试测试导入和使用模块,因此让我们创建两个文件以备使用:

$ touch test.js

如何做到这一点…

我们将从这个菜谱开始安装一个代码格式化工具,以保持我们的模块代码风格一致。到这个菜谱结束时,我们将创建我们的第一个 Node.js 模块。

  1. 首先,让我们将semistandard添加为我们的模块的代码格式化工具。当知道其他用户将消费或贡献我们的模块时,保持代码格式的一致性非常重要:

    $ npm install --save-dev --save-exact semistandard
    
  2. 对于此模块的初始实现,我们将公开两个 API – 一个用于将华氏度转换为摄氏度,我们将命名为fahrenheitToCelsius(),另一个用于相反的转换,命名为celsiusToFahrenheit()。我们将使用已知的数学公式来转换这两种温度单位。首先打开index.js并添加以下内容以定义fahrenheitToCelsius()函数:

    // Convert Fahrenheit to Celsius
    function fahrenheitToCelsius(fahrenheit) {
        return (fahrenheit - 32) * 5 / 9;
    }
    
  3. 现在,我们可以添加相应的celsiusToFahrenheit()函数来进行反向转换:

    // Convert Celsius to Fahrenheit
    function celsiusToFahrenheit(celsius) {
        return (celsius * 9 / 5) + 32;
    }
    
  4. 接下来,我们在文件的底部添加关键行,使两个函数可用:

    // Export the conversion functions
    module.exports = {
        fahrenheitToCelsius,
        celsiusToFahrenheit
    };
    
  5. 现在,我们可以使用以下命令从命令行测试我们的小程序是否工作:

    $ node --print "require('./').fahrenheitToCelsius(100)"
    37.77777777777778
    $ node --print "require('./').celsiusToFahrenheit(37)"
    98.6
    
  6. 现在,让我们为我们的模块创建一个简单的测试文件。我们将使用核心的 assert 模块来实现这个:

    const assert = require('assert');
    const { fahrenheitToCelsius, celsiusToFahrenheit }
      = require('./index');
    // Test fahrenheitToCelsius
    assert.strictEqual(fahrenheitToCelsius(32), 0, '32°F should be 0');
    assert.strictEqual(fahrenheitToCelsius(212), 100, '212°F should be 100');
    // Test celsiusToFahrenheit
    assert.strictEqual(celsiusToFahrenheit(0), 32, '0°C should be 32');
    assert.strictEqual(celsiusToFahrenheit(100), 212, '100°C should be 212');
    console.log('All tests passed!');
    

    注意,我们正在导入模块并测试这两个温度转换函数。

  7. 现在,我们可以运行我们的测试文件:

    $ node test.js
    All tests passed!
    
  8. 现在,让我们定义 npm run lintnpm run test 脚本来分别运行我们的代码检查器和测试。打开 package.json 文件,并将 scripts 属性替换为以下内容:

      "scripts": {
        "lint": "semistandard *.js",
        "test": "node test.js"
      },
    
  9. 现在,我们可以使用 npm run lint 运行我们的代码检查器:

    $ npm run lint
    > temperature-converter@0.1.0 lint
    > semistandard *.js
    

    如果有任何代码检查问题,semistandard 会提醒你。这些问题可以通过运行以下命令来解决:

    $ npm run lint -- --fix
    
  10. 我们也可以使用 npm test 命令运行我们的测试:

    $ npm test
    > temperature-converter@0.1.0 test
    > node test.js
    All tests passed!
    
  11. 现在我们已经实现了、进行了代码检查和测试,我们可以使用 Git 提交更新并将模块代码推送到 GitHub:

    $ git add package.json package-lock.json index.js test.js
    $ git commit --message 'implement temperature converter, add tests'
    $ git push origin main
    

我们现在已经创建了一个模块,它公开了两个 API,并添加了一个测试用例作为良好习惯。

它是如何工作的...

为了确保我们的模块代码库的一致性和可读性,我们首先将流行的代码格式化工具 semistandard 纳入我们的开发工作流程。这确保了我们的代码遵循标准化的样式,使得其他开发者更容易理解和协作我们的项目。

由于已将 semistandard 作为开发依赖项安装,我们继续实现模块的核心功能。我们定义了两个转换函数,fahrenheitToCelsius()celsiusToFahrenheit(),利用已知的温度转换数学公式。这些函数封装在我们的 index.js 文件中,使得它们可以在模块内部使用。

为了将转换函数公开给外部使用,我们在文件的底部添加了一个 export 语句,允许其他模块根据需要导入和使用它们。这为与我们的模块功能交互提供了一个清晰的接口。

为了验证实现的正确性,我们创建了一个简单的测试文件 test.js,使用 Node.js 内置的 assert 模块。此文件包含每个转换函数的测试用例,确保它们在各种输入条件下产生预期的结果。

在运行测试文件后,我们确认所有测试都通过,这表明我们的模块功能按预期运行。然后,我们通过定义用于代码格式化和验证的 npm 脚本来增强我们的开发工作流程。

运行 npm run lint 会检查我们的代码库是否符合编码标准,而 npm test 则执行我们的测试套件以验证实现的正确性。任何偏离编码标准或失败的测试都会被突出显示以便解决。我们可以为项目创建尽可能多的自定义脚本。

重要提示

npm CLI 支持许多快捷方式。例如,npm install可以缩短为npm inpm test命令可以缩短为npm tnpm run-script命令可以缩短为npm run。有关更多详细信息,请参阅npm CLI 文档:docs.npmjs.com/cli-documentation/cli

最后,当我们的代码库实现、验证和组织完成后,我们使用 Git 提交我们的更改并将它们推送到我们的 GitHub 仓库。这确保了我们的项目历史记录得到良好记录,并且我们的最新更新可供协作者获取。

参见

  • 本章的“发布模块”菜谱

  • 第八章

发布模块

这个菜谱将指导你如何准备和发布你的模块到npm注册表。将你的模块发布到npm注册表将使其可供其他开发者查找并包含到他们的应用程序中。这就是npm生态系统的运作方式:开发者将编写和发布模块到npm,供其他开发者消费和重用在他们的 Node.js 应用程序中。

在这个菜谱中,我们将发布我们在这个章节的“编写模块代码”菜谱中创建的温度转换器模块到npm注册表。具体来说,我们将把我们的模块发布到一个作用域命名空间中,因此你可以预期你的模块将在@npmusername/temperature-converter处可用。

准备工作

此菜谱依赖于本章的“编写模块代码”菜谱。我们将把在该菜谱中创建的温度转换器模块发布到npm注册表。你可以从 GitHub 仓库github.com/PacktPublishing/Node.js-Cookbook-Fifth-Edition/tree/main/Chapter05/temperature-converter中的“编写模块代码”菜谱获取模块代码。

此菜谱还需要你有一个npm账户。前往www.npmjs.com/signup注册账户。注意记录你的npm用户名。

如何操作…

此菜谱将指导将模块发布到npm注册表的过程。

  1. 一旦你注册了npm账户,你可以使用以下命令授权你的npm客户端:

    $ npm login
    npm notice Log in on https://registry.npmjs.org/
    Login at:
    https://www.npmjs.com/login?next=/login/cli/{UUID}
    Press ENTER to open in the browser...
    Logged in on https://registry.npmjs.org/.
    
  2. 让我们更新在“搭建模块框架”菜谱中初始化 GitHub 仓库时自动为我们创建的README.md文件。拥有一个适当且清晰的README.md文件很重要,这样偶然发现该模块的用户可以了解它做什么以及它是否适合他们的用例。在你的编辑器中打开README.md文件,并更新以下内容,记得将npm用户名改为你自己的:

    # Temperature Converter Module
     A simple Node.js module for converting temperatures between Fahrenheit and Celsius.
    # Example usage
    ```js
    
    const { fahrenheitToCelsius, celsiusToFahrenheit }
    
    = require('@npmusername/temperature-converter');
    
    const celsius = fahrenheitToCelsius(100);
    
    console.log(`100°F is ${celsius}°C`);
    
    const fahrenheit = celsiusToFahrenheit(37);
    
    console.log(`37°C is ${fahrenheit}°F`);
    
    ```js
    # Running Tests
     To run tests and ensure the module is working as expected, navigate to the module's root directory and execute:
    ```sh
    
    $ npm run test
    
    ```js
    # License
    This project is licensed under the MIT License.
    

重要提示

我们刚刚创建的 README 文件是使用 Markdown 编写的。.md.MD 后缀表示它是一个 Markdown 文件。Markdown 是一种在 GitHub 上广泛使用的文档语法。要了解更多关于 Markdown 的信息,请查看 GitHub 的指南:guides.github.com/features/mastering-markdown/。许多流行的编辑器都有可用的插件,这样你就可以在你的编辑器中渲染 Markdown。

  1. 现在,我们需要在 package.json 文件中更新我们的模块名称,以匹配我们的范围模块名称。让我们也将这个模块版本设为 1.0.0。你可以手动编辑 package.json,或者重新运行 npm init 命令来用任何新值覆盖它。记得将 npm 用户名改为你自己的:

    {
      "name": "@npmusername/temperature-converter",
      "version": "1.0.0",
      "description": "Converts temperatures between Fahrenheit and Celsius.",
      "main": "index.js",
      "scripts": {
        "lint": "semistandard *.js",
        "test": "node test.js"
      },
      "keywords": [
        "temperature",
        "converter",
        "utility"
      ],
      "author": "Forename Surname",
      "license": "MIT",
      "devDependencies": {
        "semistandard": "17.0.0"
      },
      "repository": {
        "type": "git",
        "url": "git+https://github.com/username/temperature-converter.git"
      },
      "bugs": {
        "url": "https://github.com/username/temperature-converter/issues"
      },
      „homepage": „https://github.com/username/temperature-converter#readme"
    }
    
  2. 保持你的公共 GitHub 仓库是最新的是很理想的。通常,模块作者会在 GitHub 上创建一个与推送到 npm 的版本匹配的标签。这可以作为用户查看特定版本的模块源代码的审计跟踪,而无需通过 npm 下载它。然而,请注意,没有任何东西强制要求你发布到 npm 的代码必须与你发布到 GitHub 的代码匹配:

    $ git add .
    $ git commit --message "v1.0.0"
    $ git push origin main
    $ git tag v1.0.0
    $ git push origin v1.0.0
    
  3. 现在,我们准备好使用以下命令将我们的模块发布到 npm 注册表:

    $ npm publish --access=public
    
  4. 你可以通过导航到 www.npmjs.com/package/@npmusername/temperature-converter 来检查你的发布是否成功。你可能会看到以下关于你的模块的信息:

图 5.3 – npmjs.com 上的 npm 模块信息

图 5.3 – npmjs.com 上的 npm 模块信息

它是如何工作的...

我们首先使用 npm login 命令认证了我们的本地 npm 客户端。npm 客户端提供了设置访问控制的能力,以便某些用户可以发布到特定的模块或范围。

npm login 命令标识了你是谁以及你有什么权利发布。你也可以使用 npm logout 命令登出。

实际发布到注册表的命令如下:

$ npm publish --access=public

npm publish 命令尝试将包发布到 package.json 文件中 name 字段指定的位置。在配方中,我们将其发布到了一个范围包中——具体来说,我们使用了我们自己的用户名范围。范围包有助于避免命名冲突。你可以通过不传递一个命名范围来将你的包发布到全局范围,但如果你有一个常见的包名,你可能会遇到命名冲突。

我们还传递了--access=public标志。当发布到范围包时,我们需要明确表示我们希望该模块是公开的。npm CLI 允许您将范围包的模块发布为公开或私有。要私有发布模块,您需要有一个付费的npm账户。请注意,当发布到全局作用域时,不需要--access=public标志,因为全局命名空间中的所有模块都是公开的。

npm publish命令打包了我们的模块代码并将其上传到npm注册表。由于从npm init命令生成的package.json文件具有一致的属性,npm可以提取并在此模块页面上呈现该信息。如图所示,npm根据我们的package.json文件中的信息自动填充了README文件、版本和 GitHub 链接。

还有更多...

接下来,我们将考虑预发布脚本和.npmignore文件,并查看如何发布到私有注册表。

使用预发布脚本

npm CLI 支持prepublishOnly脚本。此脚本仅在模块打包和发布之前运行。这有助于在发布前捕获错误。如果发生错误,可能需要发布第二个版本来纠正此错误,这可能会给您的模块消费者带来潜在的避免不便。

让我们在我们的模块中添加一个prepublishOnly脚本。我们的prepublishOnly脚本现在只是运行我们的lint脚本。以下是如何添加prepublishOnly脚本:

  "scripts": {
    "prepublishOnly": "npm run lint",
    "lint": "semistandard *.js",
    "test": "node test.js"
  }

通常,模块作者会在他们的prepublishOnly脚本中包括重新运行他们的测试套件:

"prepublishOnly": "npm run lint && npm test",

使用.npmignore 和 package.json 的“files”属性

与指定哪些文件不应被跟踪或提交到存储库的.gitignore文件一样,.npmignore省略了其中列出的文件。.npmignore类型的文件不是必需的,如果您没有它但有.gitignore文件,那么npm将省略与.gitignore文件匹配的文件和目录。如果存在这样的文件,.npmignore文件将覆盖.gitignore

常常添加到.npmignore文件中的文件和目录类型是测试文件。如果您在大小方面有一个特别大的测试套件,那么您应该考虑通过将它们添加到您的.npmignore文件中来排除这些文件。使用您的模块的用户通常不需要将测试套件捆绑到他们的应用程序中——排除这些和其他多余的文件可以减少所有消费者模块的大小。

一个仅排除test目录的.npmignore文件看起来如下:

# Dependency directories
test/

请记住,一旦创建了 .npmignore 文件,它将被视为 .npm 包中应忽略哪些文件的 真实来源SOT)。值得检查您的 .gitignore 文件,并确保您添加到那里的项目也添加到 .npmignore 中。

package.json 文件中,您还可以定义一个 "files" 属性,允许您指定在发布包时应包含的文件路径或模式数组。这不仅仅是一个排除列表,它还充当了发布内容的包含列表。

例如,如果您有一个包含各种实用函数的 Node.js 模块,但只想公开主要功能和相关文档,您可以在您的 package.json 文件中指定 “ files": ["lib/", "docs/", "README.md"]。这确保了当用户安装您的包时,只包含指定目录中的文件以及 README.md 文件,而所有其他内部文件或目录都将从发布的包中排除。

package.json 文件中通过定义 "files" 而不是使用 .npmignore 文件中的排除列表来提供允许列表可能更可取,因为它消除了忘记在 .npmignore 文件中排除文件的风险。

为了确保您的包在发布时只包含所需的文件,您可以在本地环境中执行 npm pack 命令。此命令在当前工作目录中创建一个 tarball,反映了发布过程中使用的流程。

重要提示

虽然 TypeScript 和其他转换器在本书中没有详细介绍,但在发布到 npm 时,通常只发布输出文件,例如 JavaScript 文件,而不是源文件,例如 TypeScript 文件。这种做法确保包消费者只接收到运行模块所需的必要文件。为了实现这一点,您可以使用 .npmignore 文件或 package.json 中的 "files" 属性来排除源文件,并仅包含编译后的输出。

私有注册表

npm CLI 支持配置为指向 私有注册表。私有注册表是一种设置了某种形式的访问控制的注册表。通常,这些是由希望将其部分代码保留在公共注册表之外的企业和组织设置的。这可能是因为业务政策限制。这使得企业能够在遵守业务政策的同时,在其组织成员之间共享模块。同样,私有注册表也可以用作缓存机制。

您可以使用以下命令更改您指向的注册表:

$ npm config set registry https://registry.your-registry.npme.io/

您可以使用以下命令查看您指向的注册表:

$ npm config get registry
https://registry.npmjs.org/

注意,这两个都使用了 npm config 命令。您可以使用以下命令列出您所有的 npm config 设置:

$ npm config list
; "user" config from /Users/bgriggs/.npmrc
; node bin location = /Users/bgriggs/.nvm/versions/node/v22.9.0/bin/node
; node version = v22.9.0
; npm local prefix = /Users/bgriggs/Node.js-Cookbook/Chapter05/temperature-converter
; npm version = 10.2.4
; cwd = /Users/bgriggs/Node.js-Cookbook/Chapter05/temperature-converter
; HOME = /Users/bgriggs
; Run `npm config ls -l` to show all defaults

.npmrc 文件是 npm 的配置文件,可用于全局或按项目设置各种 npm 配置。此文件允许您持久地配置 npm 设置,例如注册表 URL 和身份验证令牌。例如,要将 npm 指向私有注册表,您可以将以下行添加到 . npmrc 文件中:

registry= https://registry.your-registry.npme.io/

在此文件中还有许多其他可配置设置和自定义选项;有关更多信息,请参阅 npm 文档:docs.npmjs.com/cli/v10/configuring-npm/npmrc

使用 ECMAScript 模块

ESM 代表了包装 JavaScript 代码以供重用的官方标准。ESM 在 ECMAScript 2015ES6)中引入,以将统一的模块系统带给 JavaScript 语言,这是多年来缺失且需求迫切的功能。与 Node.js 用于服务器端开发的 CommonJSCJS)模块不同,ESM 提供了一种静态分析代码导入和导出的方法,允许进行诸如摇树优化等优化,从而消除未使用的代码。

ESM 引入 Node.js 生态系统标志着重要的里程碑,为开发者提供了跨不同环境(包括浏览器)兼容的标准化模块系统的优势,在这些环境中,模块可以原生加载,无需捆绑工具。

配置 Node.js 以使用 ESM 涉及理解和设置项目结构以适应新的语法和模块解析策略。默认情况下,Node.js 将 .js 文件视为 CJS 模块,但通过在项目的 package.json 文件中包含一个 "type": "module" 条目,Node.js 将切换为将具有 .js 扩展名的文件视为 ESM。

或者,开发者可以使用 .mjs 扩展名来为打算作为模块处理的 JavaScript 文件。这个设置阶段至关重要,因为它为使用 importexport 关键字导入和导出模块奠定了基础,从而远离 CJS 的 requiremodule.exports 语法。在 Node.js 中启用 ESM 不仅使服务器端开发与前端实践保持一致,而且为 JavaScript 生态系统中的代码共享和模块化开辟了新的可能性。

准备工作

核心 ESM 支持默认启用,并在所有当前支持的 Node.js 版本中指定为稳定状态。然而,一些单个 ESM 功能仍然是实验性的。

在本食谱中,我们将创建一个计算各种几何形状面积和周长的迷你项目。这个食谱将作为在 Node.js 中使用 ESM 的介绍,并演示命名和默认导出的使用。

要开始,请确保您正在使用 Node.js 22 并创建一个工作目录:

$ mkdir ecmascript-modules
$ cd ecmascript-modules

我们还将为我们的几何形状模块准备一些文件:

$ touch index.js circle.js rectangle.js

如何操作…

在这个菜谱中,我们将创建用于计算圆形和矩形等几何形状面积和周长的模块。我们还将创建一个用于四舍五入的实用工具。

  1. 首先,我们需要初始化我们的模块。对于这个菜谱,我们只需接受默认值:

    $ npm init --yes
    
  2. 现在,由于我们计划在整个项目中使用 ESM 语法,我们应该在package.json文件中设置"type": "module"条目:

    {
      "name": "ecmascript-modules",
      "version": "1.0.0",
      "description": "",
      "type": "module",
      "main": "index.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
      },
      "keywords": [],
      "author": "",
      "license": "ISC"
    }
    
  3. 现在,让我们创建一个圆形模块。此模块将使用默认的导出语句来导出主函数——即计算面积。它还将公开一个用于计算圆周长的函数作为命名导出。将以下代码添加到circle.js

    const PI = Math.PI;
    function area(radius) {
      return PI * radius * radius;
    }
    function circumference(radius) {
      return 2 * PI * radius;
    }
    export default area;
    export { circumference };
    

    注意,我们正在使用Math命名空间对象来获取π的值。

  4. 接下来,我们将创建一个矩形模块rectangle.js)。此模块将导出面积和周长函数,但这次只使用命名导出。将以下代码添加到rectangle.js

    function area(length, width) {
      return length * width;
    }
    function perimeter(length, width) {
      return 2 * (length + width);
    }
    export { area, perimeter };
    
  5. 接下来,我们将实现一个小型的数学实用工具。我们只需添加一个四舍五入值的函数。创建一个名为mathUtils.js的文件:

    $ touch mathUtils.js
    
  6. 将以下代码添加到mathUtils.js

    export function round(number, precision) {
      const factor = Math.pow(10, precision);
      return Math.round(number * factor) / factor;
    }
    
  7. 最后,我们将实现主模块index.js)。此模块将导入并使用来自几何模块和实用工具模块的函数。此模块将展示我们可以以各种方式导入:

    import circleArea, { circumference } from './circle.js';
    import * as rectangle from './rectangle.js';
    import { round } from './mathUtils.js';
    function calculateCircleMetrics(radius) {
      console.log(`Circle with radius ${radius}:`);
      console.log(`Area: ${round(circleArea(radius),
        2)}`);
      console.log(`Circumference:
        ${round(circumference(radius), 2)}`);
    }
    function calculateRectangleMetrics(length, width) {
      console.log(`\nRectangle with length ${length}
        and width ${width}:`);
      console.log(`Area: ${round(rectangle.area(length,
        width), 2)}`);
      console.log(`Perimeter:
        ${round(rectangle.perimeter(length, width),
        2)}`);
    }
    calculateCircleMetrics(5);
    calculateRectangleMetrics(10, 5);
    

    此模块演示了如何使用不带花括号的默认导出、带花括号的命名导出,以及使用**import *** as语法将模块的所有命名导出作为对象导入。

  8. 通过运行以下命令来执行你的应用程序:

    $ node index.js
    Circle with radius 5:
    Area: 78.54
    Circumference: 31.42
    Rectangle with length 10 and width 5:
    Area: 50
    Perimeter: 30
    

    你应该能看到圆和矩形的计算度量,展示了如何在模块化的 Node.js 应用程序中使用默认和命名导出。

本项目展示了在 Node.js 项目中使用 ESM(模块化)来组织和结构化 JavaScript 代码的灵活性和效率。通过计算几何形状的度量示例,我们看到了如何正确使用默认和命名导出,使我们的代码模块化和可重用。

它是如何工作的...

本教程的结构围绕在 Node.js 环境中使用 ESM(模块化)进行模块化编程的概念。本菜谱的主要目标是展示如何利用 ESM 构建一个组织良好、易于维护和可扩展的项目。我们通过开发一个小型应用程序来实现这一点,该应用程序旨在计算几何形状的面积和周长。

项目以具有package.json文件的标准 Node.js 应用程序启动。通过在package.json文件中将"type": "module"设置为,我们指示 Node.js 将具有.js扩展名的文件视为 ESM 文件。

几何计算被划分为针对每个形状的独立模块。这种模块化方法展示了单一责任的概念,即每个模块负责与特定几何形状相关的一组特定功能。这种方法对于那些使用过其他 面向对象编程 ( OOP ) 语言(如 Java)的人来说将很熟悉。

对于圆模块( circle.js ),我们实现了计算圆面积(作为默认导出)和周长(作为命名导出)的函数。使用默认导出主要函数说明了如何展示模块的主要功能,而命名导出通常用于次要功能。

对于矩形模块( rectangle.js ),我们只包含了计算矩形面积和周长的函数的命名导出。使用命名导出突出了如何在单个模块中将多个相关功能分组并单独导出。

我们引入了一个实用模块( mathUtils.js )来执行舍入操作。这个模块的存在强调了将共享功能抽象到模块中的实用性,使它们在整个项目中可重用。

主要模块 index.js 作为应用程序的入口点。它动态导入几何模块和实用模块,使用它们的函数根据用户输入或预定义值执行计算。虽然这是一个简化的例子,但它展示了如何从模块中导入默认和命名导出,突出了 ESM 在处理各种导出类型方面的多功能性。

还有更多…

CJS 由于在庞大的 npm 模块生态系统中拥有悠久的历史,因此在 Node.js 应用程序中仍然被广泛使用。由于原生浏览器支持、模块优化能力和与 ECMAScript 标准的一致性,ESM 正在越来越多地被用于新项目。

理解这些差异对于在 Node.js 和更广泛的 JavaScript 生态系统中导航的开发者至关重要,尤其是在处理可能需要集成两种模块类型的应用程序和项目时。

重要提示

在当前 Node.js 开发状态下,开发者通常必须导航这两个模块系统。因此,本书后续章节中的一些食谱将使用 ESM 语法。在某些情况下,这是由于某些 npm 模块现在仅支持 ESM。

CJS 和 ESM 之间的差异

在 Node.js 中,CJS 和 ESM 之间的差异是理解如何在 JavaScript 应用程序中有效使用模块的基础。让我们更深入地看看这些:

CJS ESM
使用 require() 导入模块,使用 module.exportsexports 导出。 使用 importexport 语句导入和导出模块。
同步加载模块。 支持异步加载,允许使用动态 import() 语句。
支持非静态、运行时模块解析,允许根据运行时条件进行条件导入。 静态结构使得import/export语句可以在编译时进行分析,从而可能导致 JavaScript 引擎进行潜在优化。
CJS 模块可以通过动态的import()语句或通过创建包装模块来使用 ESM,但需要注意避免双重包风险等问题。 ESM 可以使用默认导入来导入 CJS 模块。
导出在导入时被复制,这意味着导入后对导出值的更改不会反映在导入模块中。 支持活绑定,允许导入的值在导出模块中更改时更新。
不支持顶层 await,因为它依赖于同步模块加载。 启用顶层 await,允许模块在继续之前等待异步操作。
通常使用.js扩展名,尽管也可以显式使用.cjs扩展名。 虽然.js可以用于(在package.json中设置"type": "module"),但.mjs扩展名可以用来显式标记文件为 ESM。
模块有自己的作用域但共享一个全局对象。 模块默认以严格模式执行,并拥有自己的作用域,具有更隔离的环境。

表 5.2 – CJS 和 ESM 模块之间的关键差异

与 CJS 模块的互操作性

在 Node.js 项目中采用 ESM 模块的一个关键方面是理解它们如何与传统 CJS 模块系统互操作。鉴于现有的大量 Node.js 包和应用都使用 CJS,掌握如何在单个项目中同时处理这两种模块系统至关重要。本节探讨了实现 ESM 和 CJS 模块之间互操作性的机制和实践,确保平稳过渡和集成过程。

ESM 可以使用import语句导入 CJS 模块,这得益于 Node.js 内置的互操作性支持。然而,由于 CJS 模块不能像 ESM 那样进行静态分析,因此需要注意一些细微差别:

  • 默认导入:在 ESM 文件中导入 CJS 模块时,整个模块的导出被视为单个默认导出。这意味着您不能直接从 CJS 模块中直接使用命名导入:

    // Importing a CommonJS module in ESM
    import cjsModule from './module.cjs';
    console.log(cjsModule.someFunction());
    
  • 动态导入:您可以使用import()函数动态导入 CJS 模块。这种方法返回一个Promise,该 Promise 解析为 CJS 模块的导出,允许异步模块加载:

    // Dynamically importing a CommonJS module
    import('./module.cjs').then((cjsModule) => {
      console.log(cjsModule.someFunction());
    });
    
  • 将 ESM 模块导出以供 CJS 使用:当在 CJS 代码中使用 ESM 模块时,由于 CJS 的require()函数是同步的,因此这个过程受到一定程度的限制,该函数不支持 ESM 的异步模块加载。然而,存在一些解决方案。

一种常见的方法是创建一个 CJS 包装模块,该模块动态导入 ESM 模块,然后导出其功能。这需要使用异步模式,如asyncawait或 promises:

// CJS wrapper for an ESM module
const esmModule = await import('./module.mjs');
module.exports = esmModule.default;

提供 ESM 和 CJS 入口点的项目需要意识到双重包风险,即以两种格式加载的单个包可能会导致状态问题或实例重复。

当创建模块时,建议您记录您的包或应用程序是否支持 ESM 和 CJS,并提供有关消费者如何将其导入其项目的指南。如果您同时发布,还建议您在 ESM 和 CJS 环境中彻底测试您的模块,以捕获任何互操作性问题。

重要提示:

Node.js 现在通过使用带有--experimental-require-module标志的require()来提供对加载 ES 模块的实验性支持。此功能允许在特定条件下加载 ES 模块,例如当文件具有.mjs扩展名或当最近的 package.json 中设置"type": "module"时。然而,如果模块或其依赖项包含顶层 await,将抛出ERR_REQUIRE_ASYNC_MODULE错误,需要import()来处理异步模块。

这个功能旨在提高 CommonJS 和 ES 模块之间的互操作性,但作为一个实验性特性,它可能在未来的 Node.js 版本中发生变化。更多信息,请访问:nodejs.org/api/modules.html#loading-ecmascript-modules-using-require

ECMAScript Modules 的高级主题

在 Node.js 中提高对 ESM 的理解涉及探索更复杂的功能和策略,这些功能和策略可以优化和增强您应用程序的功能。您可以探索一些更高级的主题,以了解 ESM 开发者的好处和考虑因素:

  • 动态导入表达式:动态导入允许您根据需要加载模块,使用返回Promiseimport()函数。这个特性特别有用,可以通过将代码拆分成更小的块,只在需要时加载,来减少初始加载时间并优化应用程序的资源利用。

  • 模块缓存和预加载:Node.js 缓存导入的模块以避免在每次需要时重新加载它们,从而提高性能。预加载模块涉及在需要之前加载模块,可能通过减少与运行时加载模块相关的延迟来加快应用程序的启动速度。

  • 摇树优化:摇树优化是一个与静态代码分析工具和打包器(如 Webpack)相关的术语。它指的是从最终包中消除未使用的代码。为了使摇树优化有效,模块必须使用静态 导入导出 语句,因为这允许打包器确定哪些导出被使用,哪些可以安全地删除,从而生成更小、更高效的包。

  • 模块解析自定义:也可以通过配置如何解析导入指定符到实际模块文件来自定义模块解析。Node.js 提供了一个名为 自定义钩子 的实验性功能,允许你通过注册一个导出钩子的文件来自定义模块解析和加载。有关更多信息,请参阅该功能的 Node.js API 文档:nodejs.org/api/module.html#customization-hooks

参见

  • 本章中的 搭建模块 菜谱

  • 本章中的 编写模块代码 菜谱

  • 第八章

第六章:使用 Fastify – Web 框架

第四章中,我们学习了 Node.js 核心提供的低级 API,用于构建 Web 应用程序。然而,使用这些 API 有时可能会具有挑战性,需要大量努力将概念性想法转化为功能性软件。因此,在 Node.js 生态系统中,Web 框架对于快速开发健壮的 HTTP 服务器至关重要。Web 框架将 Web 协议抽象为高级 API,允许你在不需要处理日常任务的情况下实现业务逻辑,例如解析 HTTP 请求体或重新发明内部路由器。

本章介绍了 Fastify,这是目前 Node.js 中速度最快且开销最低的 Web 框架。Fastify 高度重视提升开发者的体验,在确保卓越的应用程序性能的同时,帮助你构建 API。它紧密遵循 Web 标准,确保兼容性和可靠性。此外,它具有令人印象深刻的可扩展性,允许你根据独特需求自定义服务器。

我们将通过以下学习路径来探索 Fastify:

  • 使用 Fastify 创建 API 入门

  • 将代码拆分为小型插件

  • 添加路由

  • 使用钩子实现身份验证

  • 使用钩子破坏封装

  • 实现业务逻辑

  • 验证输入数据

  • 使用序列化提高应用程序性能

  • 配置和测试 Fastify 应用程序

技术要求

要成功完成本章,你需要以下内容:

本章中所有代码片段均可在 GitHub 上找到:github.com/PacktPublishing/Node.js-Cookbook-Fifth-Edition/tree/main/Chapter06

使用 Fastify 创建 API 入门

Fastify (fastify.dev/) 是一个用于构建 Web 应用程序的 Node.js Web 框架。它简化了 HTTP 服务器的开发以及 API 的创建,以简单、高效、可扩展和安全的方式进行。Fastify 的第一个稳定版本发布于 2018 年。从那时起,它已经积累了庞大的社区,每月下载量超过 700 万。此外,它还保持着一致的发布计划,大约每两年进行一次主要版本更新。

因为实践经验通常是学习最有效的方法,所以在本章中,我们将承担实现我们全新的幻想餐厅 API 服务器的任务!我们的目标包括显示菜单、允许厨师添加或删除食谱,以及允许客人下单,厨师将接收并烹饪!

因此,让我们开始我们的 Fastify 实践课程,并在本章结束时,您将评估 Fastify 是否易于使用!

准备工作

首先,我们需要设置开发环境。为此,您可以在终端中运行以下命令来创建一个新的 Node.js 项目:

$ mkdir fastify-restaurant
$ cd fastify-restaurant
$ npm init –yes
$ npm pkg set type=module

在这个阶段,我们已经使用安装的 fastify 模块初始化了 fastify-restaurant 文件夹。

如何操作...

要构建一个 Fastify 服务器,我们需要遵循以下步骤:

  1. 安装 fastify 版本 5 模块:

    $ npm install fastify@5
    
  2. 创建一个包含以下内容的 index.js 文件以导入依赖项:

    import { fastify } from 'fastify';
    
  3. 多亏了导入的依赖项,我们可以通过执行 fastify 工厂函数来实例化一个 Fastify 实例。app 常量将是我们 根应用程序实例,它标识您可用的 Fastify API:

    const serverOptions = {
      logger: true
    };
    const app = fastify(serverOptions);
    

    注意,我们正在将 serverOptions 对象作为参数传递。它包含 logger: true 属性以打开应用程序日志!fastify 工厂接受许多选项,我们将在本章后面看到。

  4. 使用 app 实例,我们可以通过 get() 方法向服务器添加路由。处理程序返回我们希望作为响应返回的有效负载。在这种情况下,我们向 / 端点添加一个 HTTP GET 处理程序:

    app.get('/', async function homeHandler () {
      return {
        api: 'fastify-restaurant-api',
        version: 1
      };
    });
    
  5. 我们创建一个 port 变量,以便选择服务器监听 HTTP 请求的位置:

    const port = process.env.PORT || 3000;
    

    我们从环境设置中读取变量或设置默认值。这很有用,因为通常在我们安装应用程序的服务器上,PORT 设置已经设置(例如,Heroku)。

  6. 最后,我们可以通过调用 listen 方法来启动我们的服务器。具有 0.0.0.0 值的 host 参数将配置您的服务器以接受来自任何 IPv4 地址的连接:

    await app.listen({ host: '0.0.0.0', port });
    

    这种设置对于在 Docker 容器中运行或任何直接可访问互联网的应用程序至关重要。没有这种配置,外部客户端将无法访问您的 HTTP 服务器。

  7. 我们现在可以使用以下命令启动服务器:

    $ node index.js
    {"level":30,"time":1693925618687,"pid":123,"hostname":"MyPc","msg":"Server listening at http://127.0.0.1:3000"}
    {"level":30,"time":1693925618687,"pid":123,"hostname":"MyPc","msg":"Server listening at http://192.168.1.174:3000"}
    

    如您所注意到的,我们可以看到多个 HTTP 服务器正在监听的 IP 地址。这是由于 0.0.0.0 主机配置,它监听本地主机名和本地 IP 地址以处理外部调用。如果我们将 0.0.0.0 更改为 localhost,我们的 HTTP 服务器将仅从本地 PC 可用,并打印一条日志消息。

  8. 控制台日志告诉我们服务器已成功启动;因此,如果您打开一个新的终端并运行 curl 命令,您将得到以下结果:

    $ curl http://localhost:3000
    {"api":"fastify-restaurant-api","version":1}
    

在几行代码中,你已经创建了一个带有日志记录器的 Fastify 服务器,该服务器已准备好使用,并且在 / 路由上响应 JSON 有效负载!

正如我们所见,Fastify 配备了众多内置功能,例如应用程序日志记录器,通过使用流行的 Node.js 日志记录器 pino (getpino.io/) 和自动处理 JSON 格式,无需额外的依赖项。

在下一个菜谱中,我们将重构代码,开始为我们的项目赋予形状。

将代码拆分为小型插件

我们在 使用 Fastify 创建 API 入门 菜谱中实现了 API 根端点,这通常用作健康检查,以验证服务器是否成功启动。然而,我们不能不断地将所有应用程序的路由添加到 index.js 文件中;否则,它很快就会变得难以阅读。因此,让我们将我们的 index.js 文件拆分。

如何操作...

要拆分我们的 index.js 文件,请按照以下步骤操作:

  1. 创建一个 app.js 文件,并将以下服务器配置的 serverOptions 常量移动到该文件中:

    const serverOptions = {
      logger: true
    };
    
  2. 我们定义我们的第一个插件接口:

    async function appPlugin (app, opts) {
      app.get('/', async function homeHandler () {
        return {
          api: 'fastify-restaurant-api',
          version: 1
        };
      });
    }
    

    插件是一个接受两个参数的 async 函数:第一个是一个 Fastify 服务器实例,第二个是一个 options 对象,目前它是空的。我们将在 使用钩子实现身份验证 菜谱中使用它。如果它不是一个 async 函数,这个函数可能会有不同的声明。在这种情况下,将会有第三个参数:function syncAppPlugin(app, opts, next){};这是一个我们必须调用来告诉 Fastify 框架插件何时加载的函数。

  3. 最后,我们需要将插件函数作为默认导出,将服务器配置作为命名导出选项:

    export default appPlugin;
    export { serverOptions as options };
    
  4. 现在,我们需要创建一个 server.js 文件,如下所示:

    import { fastify } from 'fastify';
    import appPlugin, { options } from './app.js';
    const app = fastify(options);
    app.register(appPlugin);
    const port = process.env.PORT || 3000;
    await app.listen({ host: '0.0.0.0', port });
    
  5. 现在,我们需要尝试确保我们已经正确完成了重构;您可以执行 node server.js 命令,它应该启动服务器,就像在之前的 使用 Fastify 创建 API 入门 菜谱中所做的那样。

我们已经在 app.js 中创建了我们的初始 Fastify 插件。

它是如何工作的...

重要提示,本节的目的不是深入探讨 Fastify 强大的插件系统,我们将在 使用钩子实现身份验证 菜谱中全面探索它。在此阶段,我们主要利用它作为组织代码为可管理组件的工具。

重要提示

app.js 文件作为我们应用程序的入口点。我们选择以与 fastify-cli 兼容的格式导出菜谱代码(github.com/fastify/fastify-cli)。这个工具旨在简化应用程序启动并提高我们的开发者体验。虽然我们不会在本书中深入探讨其细节,但值得注意的是,我们在这里编写的代码将为您提供无缝过渡到 fastify-cli 的灵活性,如果您将来选择这样做的话。

server.js 文件只有一个目的;它导入 app.js 文件并使用选项对象实例化根应用程序实例,就像我们在 *使用 * Fastify 创建 API 入门 *的食谱中所做的那样。

这里值得注意的添加是 register() 方法。这个 Fastify 函数将插件附加到 Fastify 服务器上,确保它们按照注册的顺序顺序加载。在注册一个函数插件后,它不会执行,直到我们执行 listen()ready()inject() 方法。我们将在 *配置和测试 Fastify * 应用程序 *的食谱中探讨后两种方法。

这次的微小重构代表了一个重要的进步,因为它增强了我们对 Fastify 插件接口的理解。此外,它巧妙地将业务逻辑与启动网络服务器的技术任务分开。因此,server.js 文件将不再改变,使我们能够专注于app.js 文件。

我们将在接下来的食谱中添加我们的初始业务逻辑路由,所以请保持关注!

添加路由

为了指定应用程序如何响应用户请求,必须定义路由。每个路由主要通过 HTTP 方法和一个 URL 模式来标识,这些必须与传入的请求相匹配以执行相关处理器函数。我们目前只公开了一个单独的路由:GET / 。如果你尝试访问不同的端点,你将收到 404 Not Found 响应:

$ curl http://localhost:3000/example
{"message":"Route GET:/example not found","error":"Not Found","statusCode":404}

Fastify 自动处理 404 响应。当客户端尝试访问不存在的路由时,Fastify 将默认生成并发送 404 响应。

由于我们正在开发一个网络服务器以提供我们幻想餐厅的 API,因此概述我们需要实现的路由以实现我们的目标至关重要。一些必要的路由可能包括以下内容:

  • GET /menu : 检索餐厅的菜单

  • GET /recipes : 与 GET / menu 处理器相同的逻辑进行响应

  • POST /recipes : 允许厨师向菜单添加新菜品

  • DELETE /recipes/:id : 允许厨师从菜单中删除一个菜谱

  • POST /orders : 允许客人点菜

  • GET /orders : 返回待处理订单列表

  • PATCH /orders/:orderId : 允许厨师更新订单状态

为了有效地实现所有这些路由,我们应该遵循迭代方法,在每次迭代中持续增强我们的代码。我们的开发流程步骤如下:

  1. 定义路由处理器 : 首先通过一个空处理器定义路由。我们将在本食谱中介绍它。

  2. 实现路由逻辑 : 在你的路由处理器中包含必要的逻辑以处理任务,例如检索菜单、添加新菜单项、处理订单和更新订单状态。我们将在 *使用 * hooks 实现身份验证 *的食谱中这样做。

  3. 验证和错误处理:实现验证检查以确保传入的数据准确无误,并通过提供有信息性的错误消息和适当的 HTTP 状态码来优雅地处理错误。

  4. 测试:彻底测试每个路由以确认其按预期工作。考虑各种场景,包括有效和无效的输入。我们将在 配置和测试 Fastify 应用程序 菜谱中介绍这一点。

  5. 文档:我们绝不能忘记在我们的源代码中编写一个全面的 README.md 文件,以简化我们团队的工作。

那么,让我们从第一步开始。

如何做到这一点...

在我们的端点集中,我们可以区分两个主要实体:recipesorders。对于定义路由处理程序,请遵循以下步骤:

  1. 为了提高代码组织性,我们将创建两个不同的文件,每个实体一个。此外,为了保持结构化的方法,我们将建立一个 routes/ 文件夹,并在其中创建 routes/recipes.js 文件。

  2. 我们需要定义的初始路由是 GET /menu。在这种情况下,我们使用通用的 route() 方法来构建它。此方法需要一个包含三个必填参数的输入对象:methodurlhandler,如下面的示例所示:

    function recipesPlugin (app, opts, next) {
      app.route({
        method: 'GET',
        url: '/menu',
        handler: menuHandler
      });
      next();
    }
    

    对于可接受参数的完整列表,请参阅 https://fastify.dev/docs/latest/Reference/Routes/#routes-options 上的文档。请注意,我们必须执行 next 参数,如 将代码拆分为小型插件 菜谱中讨论的那样。这仅是定义插件的一种另一种风格,并且在我们不需要在插件加载期间进行异步操作时,这是性能最佳的选择。此外,重要的是要记住,它必须是最后一个执行的操作,并且在调用它之后,无法再添加更多路由。

  3. plugin 函数旁边定义一个新的 menuHandler 函数,这可能会引发问题,我们如何访问服务器的资源?Fastify 简化了这一过程:

    async function menuHandler (request, reply) {
      this.log.info('Logging GET /menu from this');
      request.log.info('Logging GET /menu from request');
      throw new Error('Not implemented');
    }
    export default recipesPlugin;
    

    当你定义一个命名函数,如前一个代码示例所示,你可以在其上下文中使用 this 关键字。在这种情况下,this 等同于 app 变量,它让你可以访问所有服务器资源,例如数据库或配置设置,正如我们将在 添加路由 菜谱中探讨的那样。然而,正如这个特定的例子所示,我们引入了 this.logrequest.log 属性,它们提供了对日志对象的访问,使我们能够无缝地将日志集成到我们的应用程序中。

  4. 在继续之前,我们绝不能忘记在注册新插件时更新 app.js 文件:

    import recipesPlugin from './routes/recipes.js';
    async function appPlugin (app, opts) {
      // ...
      app.register(recipesPlugin);
    }
    
  5. 现在,我们可以使用 node server.js 命令启动服务器,并对它执行调用:

    $ curl http://localhost:3000/menu
    {"statusCode":500,"error":"Internal Server Error","message":"Not implemented"}%
    

    我们将在本食谱的 如何工作… 部分详细讨论 routes/recipes.js 的源代码。现在,我们可以在 recipesPlugin 函数的主体中定义剩余的路由。因此,我们可以通过添加新路由来更深入地了解 Fastify 的语法。

  6. GET /recipes 端点结合了我们在 创建 Fastify API 入门 食谱的 步骤 4 中看到的 get() 方法和通用的 route() 方法。你可以指定 url 作为第一个参数,以及路由的选项作为第二个参数:

      app.get('/recipes', { handler: menuHandler });
    

    这里最酷的事情是我们正在为 /menu/recipes 端点使用相同的 menuHandler 函数,这与我们在本食谱介绍中早期设定的要求相一致。

  7. 现在,在之前的工作基础上,定义 POST /recipes 路由似乎是一个简单直接的任务:

      app.post('/recipes', async function addToMenu
        (request, reply) {
          throw new Error('Not implemented');
        });
    
  8. 最后,让我们进一步讨论 DELETE /recipes/:id 路由的定义。首先,URL 字符串中的 :id 模式充当 路径参数。路径参数是 URL 的位置变量段。当客户端向 /recipes/something 发送 DELETE 请求时,something 的值将被分配给 request.params.id 属性。值得注意的是,request.params 是一个 JSON 对象,它包含你可以在 URL 中定义的所有路径参数。其次,我们已将 removeFromMenu 函数定义为同步函数,这意味着它不是 async。在这种情况下,我们不能直接返回或抛出所需的响应体。相反,我们必须调用 reply.send() 方法,该方法负责将响应负载传输到客户端。这个负载可以是一个字符串、一个 JSON 对象、一个缓冲区、一个流或一个错误对象:

      app.delete('/recipes/:id', function removeFromMenu
        (request, reply) {
          reply.send(new Error('Not implemented'));
        });
    

重要提示

不要混合异步和同步:强调一点,你不能混合异步和同步处理程序风格;否则,控制台上将出现意外的错误。作为一个关键要点,请记住以下指南:如果处理程序是异步的,则返回所需的负载;否则,如果处理程序是同步的,你必须使用 reply.send() 函数来发送响应。根据我的经验,在项目中坚持异步风格更有效,这样可以避免团队和不同背景之间的混淆。此外,reply 对象是 Fastify 的一个基本组件,它提供了额外的实用工具,使你能够根据需要自定义响应代码或附加新的响应头。我们将在 使用钩子实现身份验证 的食谱中展示一个示例。

它是如何工作的…

在前面的代码片段中,我们发现自己在重复执行与我们在使用 Fastify 创建 API 入门食谱中为GET /路由执行的过程类似的过程。然而,在这个例子中,我们正在使用 Fastify 提供的替代语法。在这个新的插件中,对于./routes/recipes.js声明,我们使用回调风格。重要的是要注意,我们在插件的末尾调用next()函数。如果您省略了它,Fastify 将失败启动,并触发FST_ERR_PLUGIN_TIMEOUT - 插件未在指定时间内启动:'recipesPlugin'。您可能忘记了调用'done'函数或解析一个Promise**错误。

curl请求说明了 Fastify 如何使用默认的错误处理器;它捕获任何抛出的错误,并返回带有错误消息的 500 HTTP 状态码。

如果我们转到服务器的日志输出,我们应该看到以下内容与记录的错误堆栈跟踪一起:

{"level":30,"time":1694013232783,"pid":1,"hostname":"MyPC","msg":"Logging GET /menu from this"}
{"level":30,"time":1694013232783,"pid":1,"hostname":" MyPC ","reqId":"req-2","msg":"Logging GET /menu from request"}

您可以观察到前面代码块中突出显示的差异。当您使用请求的log时,日志条目将包含一个reqId字段。这个功能在区分哪些日志与特定请求相关,以及帮助重建 HTTP 请求在应用程序中执行的全部操作序列方面非常有用。Fastify 默认为每个请求分配一个唯一的标识符,从req-1开始,并递增数字。此外,每次服务器重启后,这个计数器都会重置到初始状态。

重要提示

如果您想自定义请求 ID,您有两个选择。您可以配置requestIdHeader服务器选项,指示 Fastify 从特定的 HTTP 头中提取 ID。或者,您可以提供一个genReqId函数,让您完全控制 ID 生成过程。有关更多信息,请参阅官方文档fastify.dev/docs/latest/Reference/Server

还有更多...

到目前为止,我们已经为recipes.js文件建立了框架。现在是时候创建一个新的routes/orders.js文件,并定义完成我们目标所需的最后三个路由。我鼓励您将这项任务作为一个练习来承担。如果您遇到任何问题,您可以检查以下代码以获得灵感:

async function ordersPlugin (app, opts) {
  async function notImplemented (request, reply) {
    throw new Error('Not implemented');
  }
  app.post('/orders', { handler: notImplemented });
  app.get('/orders', { handler: notImplemented });
  app.patch('/orders/:orderId', { handler: notImplemented
    });
}
export default ordersPlugin;

不要忘记更新app.js文件以公开新的空路由。

按照这个食谱,您应该已经准备好声明各种路由并返回纯数据,例如字符串或 JSON 对象。在下一个食谱中,我们将通过探索 Fastify 插件系统和其组件来实现我们 API 的基本业务逻辑。

使用钩子实现身份验证

我们已经利用 Fastify 插件来组织路由并增强我们项目的可维护性,但这些只是 Fastify 插件系统提供的优势中的一部分。插件系统的关键特性如下:

  • 封装:所有添加到插件中的钩子、插件和装饰器都绑定到插件上下文,确保它们保持在插件的范围内。

  • 隔离:每个插件实例都是自包含的,独立运行,避免对同级插件的任何修改。这种隔离确保了一个插件中的更改或问题不会影响其他插件。

  • 继承:插件继承其父插件的配置,允许插件以分层和模块化的方式组织,使得管理复杂的应用程序结构更加容易。

这些概念一开始可能看起来很复杂,但在这个食谱中,我们将它们付诸实践。具体来说,我们将实现只有厨师才能访问的路由保护机制。这是防止未经授权的用户尝试对幻想餐厅的菜单进行破坏性更改的关键步骤。

认证必须允许厨师用户访问这些端点:

  • POST /recipes

  • DELETE /recipes/:id

  • PATCH /orders/:orderId

为了简化逻辑,我们定义厨师为任何包含有效密钥值的 x-api-key 标头的 HTTP 请求。如果认证失败,服务器必须返回 401 – 未授权 的 HTTP 响应。这种方法简化了厨师访问的验证过程。

准备工作

在进入代码之前,我建议测试所有列出的端点,以确认您可以访问它们并收到预期的 未实现 错误消息。在本食谱结束时,我们预计执行以下 curl 命令将导致 未授权 错误:

$ curl -X POST http://localhost:3000/recipes
$ curl -X DELETE http://localhost:3000/recipes/fake-id
$ curl -X PATCH http://localhost:3000/orders/fake-id

我们现在将通过一个试错示例来探索所有插件系统特性。因此,请准备好重新启动服务器并执行 curl 命令。

重要提示

通过手动终止 Node.js 进程来重新启动 Fastify 服务器可能很麻烦。为了简化此过程,您可以使用 node --watch server.js 参数运行应用程序。Node.js 20 引入了监视模式功能,该功能在文件更改时自动重新启动进程,使开发更加高效。

如何操作…

要实现认证,我们需要遵循以下步骤:

  1. 通过添加 onRequest 钩子 编辑 routes/recipes.js 文件:

    function recipesPlugin (app, opts, next) {
      app.addHook('onRequest', async function isChef
        (request, reply) {
          if (request.headers['x-api-key'] !== 'fastify-
            rocks') {
              reply.code(401)
              throw new Error('Invalid API key');
          }
        });
      // ...
      next();
    }
    

    钩子是一个函数,它在应用程序的生命周期中或在单个请求和响应周期中按需执行。它提供了将自定义逻辑注入框架本身的能力,增强了可重用性,并允许在应用程序执行的特定点上定制 行为

  2. 让我们通过运行以下 curl 命令来观察其效果:

    $ curl -X POST http://localhost:3000/recipes
    {"statusCode":401,"error":"Unauthorized","message":"Invalid API key"}
    $ curl -X PATCH http://localhost:3000/orders/fake-id
    {"statusCode":500,"error":"Internal Server Error","message":"Not implemented"}
    $ curl -X GET http://localhost:3000/recipes/fake-id
    {"statusCode":401,"error":"Unauthorized","message":"Invalid API key"}
    

它是如何工作的…

我们引入了onRequest钩子。这意味着每当有新的 HTTP 请求进入服务器时,isChef函数都会运行。这个钩子的逻辑是验证request.headers属性,检查预期的头是否具有fastify-rocks值。如果检查失败,钩子会在使用reply.code()方法设置 HTTP 响应状态码后抛出错误。

如果我们分析控制台输出,我们可以看到插件系统正在运行:

  • 封装:我们在recipesPlugin函数中集成了一个钩子,并且这个钩子的功能会在同一插件作用域内定义的每个路由上执行。因此,GET /recipes路由返回了一个 401 错误,展示了钩子如何在插件上下文中封装并一致地应用逻辑。

  • 隔离:每当 Fastify 执行register()方法时,它会生成一个新的插件实例,类似于plugin函数声明中的app参数。这个实例作为根应用程序实例的子对象,确保与兄弟插件隔离,并允许构建独立的组件。这种隔离是为什么PATCH /orders/fake-id请求不受影响,并继续返回旧的未实现错误。这突出了ordersPlugin的作用域与recipesPlugin的作用域保持隔离。

要评估继承功能,你必须将onRequest钩子从routes/recipes.js文件移动到app.js文件。经过这次修改后,执行之前的 curl 命令确实会导致未授权错误。这种结果发生是因为ordersPluginrecipesPlugin都是appPlugin插件实例的子实例,并继承了其所有钩子,包括onRequest钩子。

还有更多...

我们如何解决当前所有路由都受保护的场景?探索插件系统提供了多种方法来实现这一目标,因为它严重依赖于您的项目结构和您需要考虑的上下文。让我们看看routes/文件夹中每个插件的两个方法。

第一步是集中认证逻辑;为了实现这一点,我们引入了装饰器。装饰器使您能够增强 Fastify 组件的默认功能,减少代码重复,并提供快速访问应用程序资源,如数据库连接。装饰器可以附加到服务器实例、请求或回复对象;这取决于它所属的上下文。让我们在移除onRequest钩子后将其添加到app.js中:

async function appPlugin (app, opts) {
  app.decorateRequest('isChef', function isChef () {
    return this.headers['x-api-key'] === 'fastify-rocks';
  });
  app.decorate('authOnlyChef', async function (request,
    reply) {
      if (!request.isChef()) {
        reply.code(401);
        throw new Error('Invalid API key');
      }
  });
  // ...
}

我们定义了一个isChef请求装饰器,使得在appPlugin上下文及其子插件实例中执行request.isChef()函数成为可能。isChef函数中的逻辑很简单,只有当检测到有效的头信息时,才返回true的布尔值。需要注意的是,当我们定义请求或回复装饰器时,this上下文分别指向请求或回复对象。这个上下文对于在装饰器函数中访问这些对象至关重要。

接下来,我们引入了一个名为authOnlyChef的实例装饰器。这个装饰器提供了一个与之前定义的onRequest钩子相同的 API。它可以通过app.authOnlyChef属性访问,提供了一个方便的方式来在需要时为厨师在各个路由和插件中应用特定的认证逻辑。

定义装饰器不会执行任何逻辑;为了使它们独立执行,我们需要在路由中使用它们。让我们转到routes/orders.js文件并修改/orders/:orderId路由以实现保护:

  app.patch('/orders/:orderId', {
    onRequest: app.authOnlyChef,
    handler: notImplemented
  });

我们已经配置了onRequest路由的选项属性来定义一个特定于该路由的钩子。Fastify 为你提供了钩子附加的粒度控制;你可以将钩子函数分配给整个服务器实例或单个路由。此外,你可以将onRequest字段设置为钩子函数的数组,这些函数将按添加的顺序执行。这允许对请求处理流程进行精确控制。

当你需要设置少量路由时,这种语法是完美的,但如果我们有很多路由需要保护呢?让我们看看在routes/recipes.js文件中我们能做什么:

function recipesPlugin (app, opts, next) {
  app.get('/menu', { handler: menuHandler });
  app.get('/recipes', { handler: menuHandler });
  app.register(async function protectRoutesPlugin (plugin,
    opts) {
      plugin.addHook('onRequest', plugin.authOnlyChef);
      plugin.post('/recipes', async function addToMenu
        (request, reply) {
          throw new Error('Not implemented');
        });
      plugin.delete('/recipes/:id', function removeFromMenu
        (request, reply) {
          reply.send(new Error('Not implemented'));
        });
    });
  next();
}

为了简化对包含受保护路由和公开路由的食谱路由的保护,你可以在recipesPlugin中创建一个新的protectRoutesPlugin插件实例。在这个上下文中,你可以将onRequest钩子添加到在该上下文中定义的所有路由。在这种情况下,我将第一个参数命名为plugin,以区分app上下文。plugin参数作为app的子上下文,继承所有直到根应用程序实例的钩子和装饰器。这使得它可以访问authOnlyChef函数。此外,我们只将需要保护的路线移动到这个新的plugin函数中,有效地将它们从父作用域中隔离出来。请记住,继承是从父上下文流向子上下文,而不是相反。这种方法增强了代码组织,并保持了 Fastify 插件系统中封装、隔离和继承的好处。

通过我们所做的更改,你现在可以执行在此配方中最初测试的curl命令。你应该只期望为需要保护的路线收到未授权错误,而其他路线应保持自由访问。这证明了选择性路线保护中身份验证逻辑的成功实现。

Fastify 有两个不同的系统来管理其内部工作流程:应用程序生命周期请求生命周期。虽然 Fastify 内部管理这两个生命周期,但它提供了灵活性,允许你通过监听和响应与这些生命周期相关的事件来注入自定义逻辑。这种能力使你能够根据特定的应用程序需求和用例调整端点周围的数据流。

当你正在监听由应用程序生命周期触发的事件时,你应该参考应用程序钩子https://fastify.dev/docs/latest/Reference/Hooks#application-hooks)。这些钩子允许你在服务器启动和关闭期间进行干预。以下是一个这些钩子的快速列表以及它们何时触发:

钩子名称 在以下情况下触发 接口
onRoute 服务器实例添加了一个新的端点 它必须是一个同步函数
onRegister 创建了一个新的封装上下文 它必须是一个同步函数
onReady 通过 HTTP 服务器加载的应用程序尚未监听传入的请求 它可以是同步或异步函数
onListen 应用程序加载,HTTP 服务器正在监听传入的请求 它可以是同步或异步函数。如果它抛出错误,则不会阻塞应用程序启动
preClose 服务器开始关闭阶段,仍在监听传入的请求 它可以是同步或异步函数
onClose 服务器已停止监听新的 HTTP 请求并正在停止,允许你执行清理或最终化任务,例如关闭数据库连接 它可以是同步或异步函数。此钩子按相反顺序执行

表 6.1 – 应用程序钩子概述

表 6.1提供了所有应用程序钩子的全面概述。需要注意的是,这些钩子按照它们的注册顺序执行,除了onClose钩子,它按照执行的反向顺序执行,因为它确保最后创建的资源首先关闭,类似于后进先出LIFO)队列的操作。这种顺序对于在服务器关闭期间正确清理资源至关重要。另一个重要的方面是,Fastify 确保如果这些钩子中的任何一个未能成功执行,服务器将不会启动。这个特性非常有价值,因为这些钩子可以用来在应用程序处理器使用之前验证关键外部资源的就绪状态。这确保了你的应用程序以可靠的状态启动,增强了健壮性和稳定性。需要注意的是,防止服务器启动的规则不适用于onListenonClose钩子。在这些特定情况下,Fastify 保证所有注册的钩子函数都将被执行,无论其中一个是否遇到错误。这种行为确保了在存在某些钩子错误的情况下,在服务器启动和关闭期间执行必要的清理和最终化任务。

应用程序钩子服务于各种目的,但主要目的包括以下内容:

  • 缓存预热:你可以在服务器即将启动时使用onReady钩子来准备和预加载缓存,这可以显著提高处理器性能。

  • 资源检查:如果你的处理器依赖于第三方服务器或外部资源,你可以在服务器启动时使用这些钩子来验证资源是否正常运行,确保你的应用程序的依赖项可用。

  • 监控:这些钩子对于记录和监控服务器启动信息非常有价值,例如配置细节或服务器关闭的原因,有助于调试和可观察性。

  • 面向方面编程:通过利用onRegisteronRoute钩子,你可以应用面向方面编程技术来操作路由选项,并将额外的属性或行为注入到你的路由中。这允许对应用程序逻辑进行强大的定制和模块化。

作为一项练习,尝试将这些钩子添加到每个应用程序的文件中:

app.addHook('onReady', async function hook () {
  this.log.info(`onReady runs from file
    ${import.meta.url}`);
});
app.addHook('onClose', function hook (app, done) {
  app.log.info(`onClose runs from file
    ${import.meta.url}`);
  done()
});

事实上,在这些钩子上下文中,this 关键字代表 Fastify 实例,这使您能够访问服务器上的所有装饰器和资源。这包括应用程序日志记录器,它可以通过常见的、已建立的 Fastify 风格进行访问。值得注意的是,与插件声明类似,Fastify 的钩子支持异步和同步接口。在异步钩子的情况下,您不需要采取任何特定行动。然而,在同步钩子的情况下,您有一个访问 done 参数的权限,如前一个代码片段中的 onClose 钩子所示。在同步钩子中调用此函数是必要的,以指示成功执行;否则,钩子管道将被阻塞,并且它将不会完成,直到超时发生,这可能导致服务器关闭。

在对应用程序钩子进行了全面的概述之后,现在让我们将注意力转向与请求生命周期相关的 请求钩子fastify.dev/docs/latest/Reference/Hooks#requestreply-hooks),这些钩子与请求生命周期相关联。这个生命周期定义了 HTTP 请求进入服务器时经历的各个步骤。您可以在以下图中可视化此过程:

图 6.1 – 请求生命周期

图 6.1 – 请求生命周期

图 6 .1 中,请求生命周期步骤用包含在该特定阶段触发的钩子名称的虚线框表示。让我们跟随一个传入的 HTTP 请求的路径,并按发生的顺序描述 Fastify 内部发生的事情:

  1. 路由选择:当收到 HTTP 请求时,Fastify 根据请求的 URL 和 HTTP 方法将其路由到特定的处理程序。如果没有找到匹配的路由,Fastify 将执行默认的 404 处理程序。

  2. 请求初始化:在确定路由处理程序之后,执行 onRequest 钩子。在这个阶段,请求体尚未解析。请求对象不包含 body 属性。这是一个丢弃不应处理的请求(如未经授权的请求)的合适点。由于请求有效载荷尚未读取,服务器资源不会浪费在不必要的处理上。

  3. 请求有效载荷操作:如果 HTTP 请求被认为是可以处理的,preParsing 钩子提供了对请求有效载荷流的访问,可以进行操作。常见的用例包括解密加密的请求有效载荷或解压缩用户输入。

  4. 有效载荷验证:Fastify 包含一个内置的验证系统,我们将在本章的 验证输入数据 菜单中进一步探讨。您可以通过监听 preValidation 钩子来修改在验证之前解析的有效载荷。

  5. 完整请求解析:在执行包含业务逻辑的路由处理程序之前,会执行preHandler钩子。在此阶段,请求被完全解析,你可以通过request.body字段访问其内容。

  6. 路由处理程序执行:请求进入主路由处理程序以执行与路由定义关联的函数。当你使用reply.send()或返回有效载荷作为响应时,响应的最后一个阶段开始向客户端发送响应有效载荷。

  7. 有效载荷序列化:在序列化过程开始之前,会触发preSerialization钩子。在这里,你可以操作有效载荷,将其适配到特定格式,或将不可序列化的对象转换为纯 JSON 对象。

  8. 响应准备:在向客户端发送响应有效载荷之前,会调用onSend钩子。它可以访问序列化的有效载荷内容并应用额外的操作,例如加密或压缩。

  9. 请求完成:最后,请求生命周期中的最后一步是onResponse钩子。此钩子在成功将有效载荷发送到客户端后执行,标志着 HTTP 请求的完成。

确实,当简单的 HTTP 请求进入 Fastify 服务器时,涉及许多事情,如请求生命周期中所示。此外,图 6.1展示了三个专门用于管理在整个请求生命周期中可能发生的错误的额外钩子。这些特定错误的钩子提供了优雅和有效地处理错误的方法,确保了 Fastify 应用程序的可靠性和健壮性。Fastify 中的这三个特定错误的钩子提供了管理不同错误场景的方法:

  • onTimeout:当连接套接字处于空闲状态时,此钩子会被触发。要启用此钩子,你必须设置服务器的connectionTimeout选项(默认值为0,表示禁用)。你指定的毫秒数决定了应用程序完成请求生命周期的最大时间。如果超过此时间限制,onTimeout钩子就会启动并关闭连接。

  • onError:当服务器将错误作为响应有效载荷发送给客户端时,此钩子会被触发。它允许你在请求处理过程中发生错误时执行自定义操作。

  • onRequestAbort:当客户端在请求完全处理之前提前关闭连接时,此钩子会被执行。在这种情况下,由于连接已经关闭,你将无法向客户端发送数据。此钩子对于清理与已中止请求关联的任何资源非常有用。

现在,你已经全面了解了 Fastify 的钩子,这将在你深入使用插件系统时非常有价值。因此,让我们开始使用 Fastify 的所有强大功能,包括钩子、装饰器和插件,来实现幻想餐厅的业务逻辑。

打破封装

在这个新的菜谱中,我们将更深入地探讨 Fastify 插件系统,扩展我们对之前所探索内容的理解。Fastify 提供了一系列工具,每个工具都服务于特定的目的,熟悉它们将大大增强你自定义和控制应用程序生命周期和行为各个方面的能力。

准备工作

在之前的 使用钩子实现身份验证 菜谱中,我们学习了各种钩子,但并未看到它们的实际应用。现在,让我们通过开发一个自定义身份验证插件来应用我们的知识。目前,我们的身份验证逻辑分散在 app.js 文件中,然后被 orders.jsrecipes.js 所使用。虽然它可行,但缺乏集中化。为了解决这个问题,我们旨在创建一个公司范围内的插件,可以轻松集成到所有我们的项目中,在注册插件时直接提供标准化的身份验证逻辑。

如何做到这一点…

要通过打破 封装 创建一个通用插件,我们需要遵循以下步骤:

  1. 在一个新文件夹中创建一个新的 plugins/auth.js 实例,然后将 app.js 中的装饰器移动到这个新文件中:

    async function authPlugin (app, opts) {
      app.decorateRequest('isChef', function () {
        return this.headers['x-api-key'] === 'fastify-
          rocks';
      });
      app.decorate('authOnlyChef', async function(request,
        reply){
          if (!request.isChef()) {
            reply.code(401);
            throw new Error('Invalid API key');
          }
        });
    }
    export default authPlugin;
    
  2. 如往常一样,在 app.js 文件中注册插件:

    import authPlugin from './plugins/auth.js';
    async function appPlugin (app, opts) {
      app.register(authPlugin);
      app.register(recipesPlugin);
      app.register(ordersPlugin);
    }
    

    到目前为止,这没有什么新意,但如果你尝试启动服务器,它将无法工作。让我通过绘制 Fastify 上下文结构来展示原因:

图 6.2 – Fastify 树结构

图 6.2 – Fastify 树结构

图 6.2 中,每个节点代表一个自包含的上下文。多亏了插件系统,这些上下文中的每一个都可以拥有自己的钩子、装饰器和插件。在图形的左侧,你可以观察到我们应用程序的当前结构。值得注意的是,由于隔离,定义在 authPlugin 函数中的装饰器对 recipesPluginordersPlugin 函数不可用。为了纠正这一点,我们应该考虑将 authPlugin 节点在树结构中向上移动。通过这样做,食谱和订单插件将继承装饰器,从而实现无缝集成和功能。实施这一行动将涉及让 server.js 注册 authPlugin,然后 authPlugin 注册 appPlugin。虽然这种方法可行,但由于其复杂性和嵌套依赖,源代码难以理解。因此,在这种情况下,我们想要 打破封装,如图 6.2 的右侧所示。

  1. 通过运行 npm install fastify-plugin@5 安装一个新的模块。

  2. authPlugin 函数用 fastify-plugin 包装,如下面的代码片段所示:

    import fp from 'fastify-plugin';
    async function authPlugin (app, opts) {
      // ...
    }
    export default fp(authPlugin);
    

    重新启动服务器后,一切应该像之前一样正常工作。这是因为打破封装上下文就像使用父 Fastify 实例一样。如果我们将fastify-plugin应用到迄今为止我们实现的每个文件上,我们实际上会将所有内容合并到一个上下文中,相当于根应用程序上下文。不幸的是,这会导致丢失插件系统提供的所有功能。一般来说,您可能只将fastify-plugin用于那些您打算在组织内重用的插件。

  3. 我们的工作还没有完成,因为我们只移动了装饰器。现在,我们的目标是集中管理路由如何应用身份验证逻辑。为了实现这一点,我们将利用onRoute钩子。将以下代码添加到auth.js文件中:

    async function authPlugin (app, opts) {
      // ...
      app.addHook('onRoute', function hook (routeOptions) {
        if (routeOptions.config?.auth === true) {
          routeOptions.onRequest =
          [app.authOnlyChef].concat(routeOptions.onRequest
            || []);
        }
      });
    }
    

    如在更多内容...部分所述的使用钩子实现身份验证食谱中提到,onRoute钩子必须是一个同步函数。它将路由的选项作为其第一个参数接收。这个函数的目的是检查routeOptions是否包含设置为 true 的auth标志。如果满足这个条件,我们将authOnlyChef装饰器函数注入到routeOptions.onRequest中。

    值得强调的是,代码确保authOnlyChefonRequest链中的第一个函数。这很重要,因为 Fastify 会按照它们出现的顺序执行这些函数。此外,值得一提的是,输入的routeOption.onRequest可以是钩子数组或单个函数。代码示例使用Array.concat()函数无缝处理这两种情况。

  4. 现在,我们可以回到orders.js文件,并更新PATCH /orders/:orderId处理器如下:

      app.patch('/orders/:orderId', {
        config: { auth: true },
        handler: notImplemented
      });
    

    我们已经用新的方法替换了之前的onRequest [app.authOnlyChef]配置。

通过利用路由的config属性,我们将应用程序的属性与 Fastify 的字段隔离开来,以防止冲突。这种更新的设置提供了几个优点,包括authPlugin能够在不要求每次更新都更改路由配置的情况下随着时间的推移而发展。这种模式与面向方面编程相一致,因为它通过简单的布尔配置动态引入功能。

更多...

作为练习,现在尝试自己更新recipes.js文件,然后比较您的代码与以下解决方案:

function recipesPlugin (app, opts, next) {
  // ...
  app.post('/recipes', {
    config: { auth: true },
    handler: async function addToMenu (request, reply) {
      throw new Error('Not implemented');
    }
  });
  app.delete('/recipes/:id', {
    config: { auth: true },
    handler: function removeFromMenu (request, reply) {
      reply.send(new Error('Not implemented'));
    }
  });
  next();
}

如前所述,我们已经设置了config.auth选项,并且删除了protectRoutesPlugin代码,因为创建封装的上下文不再必要。

干得好!在这个菜谱中,我们涵盖了大量的内容,从钩子开始,然后转到装饰器,学习如何管理封装的上下文,并在必要时将其打破。在下一个菜谱中,我们将深入实现我们直到现在为止只声明的路由的业务逻辑。所以,让我们做好准备,开始吧!

使用钩子实现业务逻辑

假想餐厅的 API 有一个特定的目标:满足我们餐厅的需求。在添加 路由的菜谱中,我们考察了整体流程,但没有深入细节,例如以下内容:

  • 每个端点的输入是什么?

  • 每个服务的预期输出应该是什么?

  • 我们应该在哪里存储数据?

在这个菜谱中,我们将更详细地探讨这些关键方面。所以,让我们从数据和它的存储开始!

准备工作

我们需要一个数据库来存储和检索应用程序数据。为此,我们将使用知名的 NoSQL 数据库MongoDBwww.mongodb.com/)。MongoDB 是一个流行的 NoSQL 数据库,它以灵活的、类似 JSON 的文档形式存储数据,为各种应用程序提供可扩展性和高性能。需要注意的是,MongoDB 的细节不是本章的重点,因此我不会深入描述其内部工作原理。

重要提示

如果你安装了 Docker,你可以通过运行以下命令来运行 MongoDB 服务器:docker run -d -p 27017:27017 --name fastify-mongo mongo:5。它将使用官方的 MongoDB 镜像启动一个容器,并准备好使用。最后,要停止它,你可以运行这个命令代替:docker container stop fastify-mongo

如何做到这一点…

要将我们的应用程序连接到 MongoDB,我们需要遵循以下步骤:

  1. 安装官方的 Fastify 模块:

    $ npm i @fastify/mongodb@9
    
  2. 然后,我们可以在plugins/datasource.js文件中创建一个新的插件,我们将连接到数据库:

    import fp from 'fastify-plugin';
    import fastifyMongo from '@fastify/mongodb';
    async function datasourcePlugin (app, opts) {
      app.log.info('Connecting to MongoDB')
      app.register(fastifyMongo, {
        url: 'mongodb://localhost:27017/restaurant'
      });
    }
    export default fp(datasourcePlugin);
    
  3. 更新app.js,添加app.register(datasourcePlugin)代码,就像在打破 封装菜谱的步骤 2中执行的那样。

  4. 如果你使用正确初始化的数据库启动应用程序,它应该像往常一样启动,你应该观察到我们添加的新日志行,以确认我们的插件正在被加载。

  5. 接下来,我们必须在 MongoDB 数据源和我们的业务逻辑之间建立一个数据层。这允许我们确定我们的路由必须执行的基本操作,并从中提取一个子集作为装饰器:

    async function datasourcePlugin (app, opts) {
      app.register(fastifyMongo, { ... });
      app.decorate('source', {
        async insertRecipe (recipe) { /* todo */ },
        async readRecipes (filters, sort) { /* todo */ },
        async deleteRecipe (recipeId) { /* todo */ },
        async insertOrder (order) { /* todo */ },
        async readOrders (filters, sort) { /* todo */ },
        async markOrderAsDone (orderId) { /* todo */ }
      });
    }
    
  6. 在前面的代码片段中,我们添加了一个新的source对象装饰器。每个对象的字段引用一个async函数,它将只执行其名称所表示的操作。所以,让我们开始实现第一个函数:

        async insertRecipe (recipe) {
          const { db } = app.mongo;
          const _id = new app.mongo.ObjectId();
          recipe._id = _id;
          recipe.id = _id.toString();
          const collection = db.collection('menu');
          const result = await
            collection.insertOne(recipe);
          return result.insertedId;
        }
    

    此函数将输入 JSON 对象recipe插入到menu集合中,并返回生成的 id。如前所述,这一数据层不应执行任何业务逻辑。app.mongo是由@fastify/mongodb模块创建的装饰器,如在此处所述:github.com/fastify/fastify-mongodb,这指的是 MongoDB 客户端,因此你可以完全控制它。

重要提示

在前面的代码块中,recipe._id = _id;中的_id属性和recipe.id = _id.toString()中的id属性具有相同的值。我们引入id属性是为了防止暴露与我们的数据库相关的任何信息。当我们使用_id属性时,它主要是由 MongoDB 服务器为内部目的定义和使用的,我们选择使用id以保持应用程序数据的一定抽象和安全级别。

  1. 要使用insertRecipe函数,我们需要实现POST /recipes端点,如下所示:

      app.post('/recipes', {
        config: { auth: true },
        handler: async function addToMenu (request, reply)
        {
          const { name, country, description, order, price
            } = request.body;
          const newPlateId = await
            app.source.insertRecipe({
              name,
              country,
              description,
              order,
              price,
              createdAt: new Date()
            });
          reply.code(201);
          return { id: newPlateId };
        }
      });
    

    addToMenu函数处理器运行时,我们 100%确信认证钩子是成功的,并且只有有效的厨师在执行它。因此,函数逻辑从request.body输入数据读取以组成一个新的 JSON 对象。这一步是必要的,以避免插入客户端可能提交给我们的端点的意外字段(到数据库中)。然后,调用app.source.insertRecipe装饰器来保存数据。作为最后的操作,我们将 HTTP 响应状态设置为201 – 已创建(对于标准 HTTP 状态码的完整列表,请参阅此处:developer.mozilla.org/en-US/docs/Web/HTTP/Status)。

  2. 我们现在可以通过运行以下curl命令来尝试:

    $ curl -X POST http://localhost:3000/recipes -H "Content-Type: application/json" -H "x-api-key: fastify-rocks" -d '{"name":"Lasagna","country":"Italy","price":12}'
    {"id":"64f9f3eaee2d03172a8c5efe"}
    

    我们的测试还没有结束。你必须尝试运行相同的curl命令,但你需要删除x-api-key头信息或更改其值。在这些情况下,我们期望出现401 – 未授权错误。

重要提示

与使用curl向应用程序服务器运行 HTTP 请求相比,采用具有图形用户界面GUI)的 HTTP 客户端可能更容易。以下是一个完整的列表,你可以选择你喜欢的:github.com/mrmykey/awesome-http-clients/blob/main/Readme.md#gui

  1. 在考虑本节完成之前,我们需要从数据库中读取,因此让我们在plugins/datasource.js文件中实现readRecipes函数:

        async readRecipes (filters, sort = { order: 1 }) {
          const collection =
            app.mongo.db.collection('menu');
          const result = await
            collection.find(filters).sort(sort).toArray();
          return result;
        }
    

    在这个搜索函数中,我们使用的是标准的 MongoDB API(www.mongodb.com/docs/drivers/node/current/fundamentals/crud/read-operations/retrieve/)。我们可能想要过滤数据,因此我们期望有一个 filters 参数。而 sort 参数则是为了以正确的顺序返回菜品数组,例如,开胃菜将具有 order=0,主菜将具有 order=1,依此类推。

  2. 最后,我们可以更新 routes/recipes.js 文件,添加新的 menuHandler 代码:

    async function menuHandler (request, reply) {
      const recipes = await this.source.readRecipes();
      return recipes;
    }
    
  3. 如同往常,我们可以通过调用服务器来查看结果,以检查此代码是否按预期工作:

    $ curl http://localhost:3000/menu
    

    /menu 端点应该返回一个数组,包含我们在测试阶段存储在菜单集合中的所有菜品!

重要提示

我们是否应该实现分页?我们的 GET /menu 端点提供了一个数据列表,通常认为评估列表是否过大,以至于无法在一次 HTTP 请求中返回是一个最佳实践。在这个特定案例中,返回整个菜单被认为是可接受的。然而,如果菜单包含数百个菜谱,你可能需要考虑实现分页逻辑,将数据分成可管理的块。你可以在本文中找到如何实现两种不同分页模式的指导:backend.cafe/streaming-postgresql-data-with-fastify。尽管文章讨论的是 PostgreSQL,但这些分页模式也可以用于 MongoDB。

在这个菜谱中,我们学习了如何建立数据库连接。值得注意的是,Fastify 贡献者支持各种流行的数据库,包括 PostgreSQLMySQLRedis 等。你可以在 fastify.dev/ecosystem 找到支持的数据库的完整列表。在接下来的菜谱中,我们将讨论用于保护我们的端点免受恶意用户攻击的数据验证,并且我们将继续实现缺失的路由处理程序。

验证输入数据

实现业务逻辑 的菜谱中,我们将来自 POST /recipes 端点的输入数据存储到了数据库中。然而,我们没有实现任何验证逻辑,这意味着我们可能将字符串插入到 price 字段,或者插入一个没有 name 的菜谱。此外,考虑到安全问题,恶意用户可能会插入描述过于庞大的菜谱,这可能会对你的应用程序的性能和存储造成风险。

在后端世界中,有一条规则:永远不要信任用户的输入。Fastify 非常了解这一点,因此它集成了一个强大且功能齐全的验证过程。让我们看看它是如何发挥作用的。

如何操作...

按照以下步骤集成验证过程:

  1. schema属性添加到POST /recipes路由选项中:

      const jsonSchemaBody = {
        type: 'object',
        required: ['name', 'country', 'order', 'price'],
        properties: {
          name: { type: 'string', minLength: 3, maxLength:
            50 },
          country: { type: 'string', enum: ['ITA', 'IND']
            },
          description: { type: 'string' },
          order: { type: 'number', minimum: 0, maximum:
            100 },
          price: { type: 'number', minimum: 0, maximum: 50
            }
        }
      };
      app.post('/recipes', {
        config: { auth: true },
        schema: {
          body: jsonSchemaBody
        },
        handler: async function addToMenu (request, reply)
        {
          // ...
        }
      });
    

    jsonSchemaBody常量是在JSON schema格式中定义的对象。此格式遵循 JSON schema 标准中概述的规范,该标准为描述 JSON 文档的结构和约束提供了一个框架,包括请求体中的文档。通过使用 JSON schema 解释器,你可以评估给定的 JSON 对象是否符合预定义的结构和约束,从而增强 API 请求的验证过程。Fastify 包括 AJV (ajv.js.org/)模块来处理 JSON 模式并验证请求的组件。

    路由选项schema属性接受以下字段:

    • body:此模式用于在请求评估期间验证request.body,正如我们在图 6.2中看到的。

    • params:此模式验证request.params,它包含请求 URL 的路径参数。

    • headers:可以验证request.headers;因此,我们可以通过添加一个需要设置x-api-key头部的 JSON 模式来提高受认证的路由的安全性。

    • query:我们可以使用这个来验证包含所有查询字符串参数的request.query对象。

    • response:这是一个特殊的字段,它不接受默认的 JSON 模式。我们将在下一个通过序列化增强应用程序性能菜谱中看到它的实际应用。

  2. 现在,如果我们用新的路由配置重新启动服务器,我们将通过运行与实现业务逻辑菜谱中的步骤 8相同的命令来遇到第一个400 – Bad Request响应:

    $ curl -X POST http://localhost:3000/recipes -H "Content-Type: application/json" -H "x-api-key: fastify-rocks" -d '{"name":"Lasagna","country":"Italy","price":12}'
    {"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body must have required property 'order'"}%
    

    值得注意的是,在你的场景中,当你预期报告两个错误时,你只收到了一个错误消息:

    1. 第一个错误应该与不正确的country值相关。JSON 模式指定了仅两个 ISO 代码的枚举,而提供的数据不匹配任何一个。

    2. 第二个错误与缺少order属性有关,但似乎只有这个错误在输出消息中被显示。

    这种情况是由于 Fastify 使用的默认 AJV 配置引起的。你可以在github.com/fastify/ajv-compiler#ajv-configuration查看默认设置:

    {
      coerceTypes: 'array',
      useDefaults: true,
      removeAdditional: true,
      uriResolver: require('fast-uri'),
      addUsedSchema: false,
      allErrors: false
    }
    
  3. 要解决此问题并启用allErrors选项,你应该配置在app.js文件中导出的服务器选项对象。以下是你可以如何修改配置:

    const options = {
      logger: true,
      ajv: {
        customOptions: {
          allErrors: true
        }
      }
    };
    
  4. 通过重新启动应用程序并重新运行curl命令,我们应该得到这个新的输出:

    {"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body must have required property 'order', body/country must be equal to one of the allowed values"}
    
  5. 注意,ajv.customOptions字段将与默认配置合并,因此请验证每个选项,并将其设置为最适合你需求的方式。验证步骤是最重要的,需要额外的关注来保护你的 API。让我建议我首选的配置:

    customOptions: {
      removeAdditional: 'all'
    }
    

    removeAdditional 选项将强制删除所有在路由的 JSON 模式中未明确列出的输入字段。这个特性是一个有价值的补充,可以增强安全性。重要的是要注意,如果您未为特定路由指定 JSON 模式,则不会应用删除逻辑,并且所有输入字段将保持原样。

  6. 我们必须首先实现 deleteRecipe 函数;因此,我们进入 plugins/datasource.js 文件并编写以下代码:

        async deleteRecipe (recipeId) {
          const collection =
            app.mongo.db.collection('menu');
          const result = await collection.deleteOne({ _id:
            new app.mongo.ObjectId(recipeId) });
          return result.deletedCount;
        }
    

    要从 MongoDB 中删除项目,我们将 id 的输入封装在 ObjectId 中。这是必要的,因为 MongoDB 在执行文档删除时期望 _id 字段是一个 ObjectId

  7. 我们可以继续实现 DELETE /recipes/:id 处理程序,使用我们所学到的所有新知识:

      app.delete('/recipes/:id', {
        config: { auth: true },
        schema: {
          params: {
            type: 'object',
            properties: {
              id: { type: 'string', minLength: 24,
                maxLength: 24 }
            }
          }
        },
        handler: async function removeFromMenu (request,
          reply) {
          const { id } = request.params;
          const [recipe] = await app.source.readRecipes({
            id });
          if (!recipe) {
            reply.code(404);
            throw new Error('Not found');
          }
          await app.source.deleteRecipe(id);
          reply.code(204);
        }
      });
    

    路由的定义在 schema.params 属性中包含一个 JSON 模式以验证输入的 id。我们执行严格的检查以确保 id 的长度正好是 24 个字符,这是一项安全措施,旨在防止潜在的长期代码注入攻击。请注意,此验证严格相关于 MongoDB,并且它展示了您如何保护您的路由免受恶意行为者的影响。因此,根据您的需求调整此配置。

    同时,在 removeFromMenu 函数实现中,我们首先读取数据库中的食谱。注意使用数组解构,因为 readRecipes 函数返回一个数组。如果数据库中缺少项目,我们将返回一个 404 - Not Found 错误。否则,我们删除记录并返回一个 204 响应状态码,表示删除成功。

  8. 是时候测试我们的代码了。因此,我们可以像往常一样尝试使用 curl 命令:

    $ curl -X POST http://localhost:3000/recipes -H "Content-Type: application/json" -H "x-api-key: fastify-rocks" -d '{"name":"Lasagna","country":"ITA","price":12,"order":1}'
    {"id":"64fad8e761d11acc30098d0c"}
    $ curl -X DELETE http://localhost:3000/recipes/111111111111111111111111 -H "x-api-key: fastify-rocks"
    {"statusCode":404,"error":"Not Found","message":"Not found"}
    $ curl -X DELETE http://localhost:3000/recipes/64fad8e761d11acc30098d0c -H "x-api-key: fastify-rocks"
    

在这个食谱中,我们已经成功实现了在 routes/recipes.js 文件中定义的所有路由。在下一个食谱中,我们将继续实现 routes/orders.js 中定义的路由,同时介绍另一个令人兴奋的 Fastify 功能:序列化!

使用序列化提高应用程序性能

序列化步骤将业务逻辑生成的高级数据(包括 JSON 对象或错误)转换为低级数据,例如字符串或缓冲区,然后作为对客户端请求的响应发送。它涉及将复杂对象转换为适当的数据类型,以便有效地传输到客户端。实际上,正如在 使用钩子实现身份验证 食谱中提到的,只有当路由处理程序不返回字符串、流或缓冲区时,才会启动序列化过程,因为这些对象已经序列化并准备好作为对客户端的 HTTP 响应进行传输。尽管如此,当您处理 JSON 对象时,此过程是无法避免的。

Fastify 集成了一个序列化模块,它通过利用 JSON 模式定义来简化对象到 JSON 字符串的转换。这个模块被称为fast-json-stringify,与标准的JSON.stringify()函数相比,提供了显著的性能提升。实际上,对于小型负载,它将序列化过程加速了两倍。随着负载的增长,其性能优势会缩小,这在他们的基准测试中有所体现,基准测试可在github.com/fastify/fast-json-stringify/找到。

如何做到这一点...

我们将对/orders端点应用序列化,但首先,我们必须创建一个订单。为此,请遵循以下步骤:

  1. 让我们实现insertOrder函数:

        async insertOrder (order) {
          const _id = new app.mongo.ObjectId();
          order._id = _id;
          order.id = _id.toString();
          const collection =
            app.mongo.db.collection('orders');
          const result = await
            collection.insertOne(order);
          return result.insertedId;
        }
    

    代码片段应该对你来说很熟悉。我们立即将输入对象插入到orders集合中。然后,我们可以在routes/orders.js中实现路由处理器。

  2. 由于我们需要处理用户输入,我们可以定义这个 JSON 模式:

      const orderJsonSchema = {
        type: 'object',
        required: ['table', 'dishes'],
        properties: {
          table: { type: 'number', minimum: 1 },
          dishes: {
            type: 'array',
            minItems: 1,
            items: {
              type: 'object',
              required: ['id', 'quantity'],
              properties: {
                id: { type: 'string', minLength: 24,
                  maxLength: 24 },
                quantity: { type: 'number', minimum: 1 }
              }
            }
          }
        }
      };
    

    它定义了两个属性:

    • table:这有助于我们了解哪个客户下了订单。

    • dishes:这是一个必须至少包含一个项目的 JSON 对象数组。每个项目都必须包含食谱的idquantity

    多亏了 JSON 模式,我们可以避免很多无聊的if语句和检查,例如检查quantity输入字段是否为负值!

  3. 最后,我们可以继续进行路由实现:

      app.post('/orders', {
        schema: {
          body: orderJsonSchema
        },
        handler: async function createOrder (request,
          reply) {
            const order = {
              status: 'pending',
              createdAt: new Date(),
              items: request.body.dishes
            };
          const orderId = await
            this.source.insertOrder(order);
          reply.code(201);
          return { id: orderId };
        }
      });
    

    现在你可能已经熟悉了createOrder函数。为了简化,我们将直接存储request.body.dishes数组而不验证 ID。然而,我建议实现一个preHandler钩子来处理这个验证步骤,这是 JSON 模式无法完成的,我鼓励你将其作为练习来考虑。

  4. 我们已经准备好尝试这个路由:

    $ curl -X POST http://localhost:3000/orders -H "Content-Type: application/json" -d '{"table":42,"dishes":[{"id":"64fad8e761d11acc30098d0c","quantity":2},{"id":"64fad8e761d11acc30098d0z","quantity":1}]}'
    {"id":"64faeccfac24fcc42c6ffda8"}
    

    干得好!我们已经巩固了在实现业务逻辑食谱中学到的知识。我们现在可以继续到GET /orders路由,看看序列化过程是如何实际工作的。

  5. 如同往常,我们应该首先实现数据库访问;因此,在plugins/datasource.js中,我们可以编写以下代码:

    async readOrders (filters, sort = { createdAt: -1 }) {
          const collection =
            app.mongo.db.collection('orders');
          const result = await
            collection.find(filters).sort(sort).toArray();
          return result;
        }
    

    在提供的代码片段中,并没有什么实质性的新内容,除了我们正在使用createdAt进行反向排序来配置默认排序。这种安排优先处理旧订单,确保它们首先得到履行。

  6. 然后,我们可以转向端点处理器:

    app.get('/orders', {
      handler: async function readOrders (request, reply)
      {
        const orders = await this.source.readOrders({
          status: 'pending' });
        const recipesIds = orders.flatMap(order =>
          order.items.map(item => item.id));
        const recipes = await this.source.readRecipes({
          id: { $in: recipesIds } });
        return orders.map(order => {
          order.items = order.items
            .map(item => {
              const recipe = recipes.find(recipe =>
                recipe.id === item.id)
              return recipe ? { ...recipe, quantity:
                item.quantity } : undefined;
              })
              .filter(recipe => recipe !== undefined);
            return order;
          });
        }
      });
    

    readOrders函数引入了比我们在步骤 5中看到的更复杂的逻辑。以下是它所采取的步骤概述:

    1. 初始时,它读取所有待处理的订单。

    2. 然后,它收集所有订单中使用的食谱 ID,通过运行单个查询来选择实际使用的那些食谱,从而优化性能。

    3. 最后,它遍历订单数组,用从数据库中最初读取的项目数组替换相应的食谱项目。

    值得注意的是,我们已经展示了如何在单个处理程序中使用多个数据源方法。如果我们想进一步优化代码,我们可以使用 MongoDB 的 $lookup 来执行单个数据库查询,而不是我们之前所做的两个查询。

  7. 一个值得注意的细节是使用过滤器跳过系统中未找到的食谱。这是一个边缘情况考虑,因为订单可能包含在创建后已删除的食谱,在这种情况下,我们希望确保这些已删除的食谱不会显示在输出中。我们准备通过在 shell 中执行命令来测试此实现:

    $ curl http://localhost:3000/orders
    

    我们期望在系统中显示大量订单的输出。以下是一个订单输出的示例:

    [
      {
        "status": "pending",
        "createdAt": "2023-09-08T09:56:49.750Z",
        "items": [
          {
            "name": "Lasagna",
            "country": "ITA",
            "description": "Lasagna is a traditional Italian dish made with alternating layers of pasta, cheese, and sauce.",
            "order": 1,
            "price": 12,
            "quantity": 1,
            "createdAt": "2023-09-08T09:54:28.904Z",
            "id": "64faefcc9094146c83d2ffd7"
          }
        ],
        "id": "64faefe19094146c83d2ffd8"
      }
    ]
    

    如您所见,提供的信息包含比目标用户所需更多的细节。现在是时候配置序列化过程,以确保呈现给用户的数据简明扼要且与其需求相关。

  8. routes/orders.js 文件中,添加此 JSON 模式:

      const orderListSchema = {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            id: { type: 'string' },
            createdAt: { type: 'string', format: 'date-
              time' },
            items: {
              type: 'array',
              items: {
                type: 'object',
                properties: {
                  name: { type: 'string' },
                  order: { type: 'number' },
                  quantity: { type: 'number' }
                }
              }
            }
          }
        }
      };
    

    orderListSchema 专门说明了我们希望在响应有效载荷中获得的字段,仅映射属性类型,而不指定属性以定义最大字符串长度或有效数字范围。值得注意的是 format 属性,它允许对日期字段的输出进行自定义。然而,重要的是要澄清,这个概念有时可能会被误解:用于序列化的 JSON 模式不执行任何验证,而仅过滤数据。因此,添加到 JSON 模式中的任何附加验证规则在序列化过程中都将被忽略。

  9. 要将 JSON 模式应用于端点,我们必须按照以下方式编辑路由的选项:

      app.get('/orders', {
        schema: {
          response: {
            200: orderListSchema
          }
        },
        handler: async function readOrders (request,
          reply) {}
      };
    

    在序列化过程中使用 JSON 模式时,配置 schema.response 对象是至关重要的。值得注意的是,您可以指定特定模式应应用的 HTTP 状态码。您有灵活性来为不同的状态码定义不同的模式。此外,还有一个方便的 Fastify 模式可供利用。通过在模式中设置 "2xx" 属性,它将用于从 200 到 299 的所有 HTTP 状态码,从而简化了一组成功响应的方案配置。

  10. 如果我们从 步骤 7 运行 curl 命令,我们将得到更好的输出:

    [
      {
        "id": "64faefe19094146c83d2ffd8",
        "createdAt": "2023-09-08T09:56:49.750Z",
        "items": [
          {
            "name": "Lasagna",
            "order": 1,
            "quantity": 1
          }
        ]
      }
    ]
    

    通过成功实现订单端点的 JSON 模式以序列化输出,您已经提高了应用程序的速度和安全性。这种方法确保只返回指定的字段,防止随着时间的推移数据库演变时意外泄露敏感数据。将 JSON 模式作为响应的一部分指定,始终被认为是维护对客户端暴露的数据的控制并提高整体安全性的良好实践。

通过到目前为止我们所了解的知识和工具,您应该已经准备好完成最终 PATH /orders/:orderId 路由的实现。如果您遇到任何疑问或需要进一步指导,可以参考书中仓库中提供的完整源代码 github.com/PacktPublishing/Node.js-Cookbook-Fifth-Edition/tree/main/Chapter06

一旦您完成这个路由,您可以考虑整个应用程序已经完成。虽然还有进一步改进的空间,例如为所有路由添加 JSON 模式,但由于我们已经涵盖了其背后的基本概念,因此详细研究这一点可能被认为是可选的。现在,您已经准备好进入下一部分,在那里您将学习如何为您的 Fastify 应用程序编写测试。

配置和测试 Fastify 应用程序

在上一个菜谱中,我提到该应用程序可以被认为是完整的。然而,一个应用程序只有在拥有全面的测试套件时才真正达到完整,在这个菜谱中,我们的重点转向测试我们的端点,以断言其功能和正确性。这种测试确保了当我们未来对代码进行更改时,我们可以可靠地验证我们没有引入任何新的错误或回归。

在这个菜谱中,我们将使用新的 Node.js 测试运行器,提前预览第八章,我们将更深入地探讨这个主题,并以更专注的方式进行。目前,我们将介绍基础知识,以便您开始测试。所以,让我们开始这个新目标!

准备工作

我们将从这个菜谱开始,先读取应用程序的配置,然后编写应用程序测试。

到目前为止,我们在代码中硬编码了某些元素,包括以下内容:

  • 数据库连接 URL

  • 用于身份验证的 API 密钥

然而,这种方法并不适合我们的应用程序,因为我们应该有灵活性,根据需要更改这些值,尤其是在不同的环境中。在这种情况下,最佳实践是访问系统提供的环境变量。此外,这是编写测试的要求,允许我们根据需要注入不同的配置,以测试各种场景和环境。

如何操作...

要完成这个任务,我们需要遵循以下步骤:

  1. 安装一个新的 Fastify 模块:

    $ npm install @fastify/env@5
    
  2. 然后,我们需要创建一个新的文件,plugins/config.js,内容如下:

    import fp from 'fastify-plugin';
    import fastifyEnv from '@fastify/env';
    async function configPlugin (app, opts) {
      const envSchema = {
        type: 'object',
        required: ['API_KEY', 'DATABASE_URL'],
        properties: {
          NODE_ENV: { type: 'string', default:
            'development' },
          PORT: { type: 'integer', default: 3000 },
          API_KEY: { type: 'string' },
          DATABASE_URL: { type: 'string' }
        }
      };
      app.register(fastifyEnv, {
        confKey: 'appConfig',
        schema: envSchema,
        data: opts.applicationEnv
      });
    }
    export default fp(configPlugin);
    

    即使你是第一次遇到这段插件代码,它也应该相当容易理解。此插件定义了一个带有 JSON 模式的 envSchema 常量。此模式随后用作 @fastify/env 模块的配置。此模块将提供的输入模式与 process.env 对象进行验证。因此,如果缺少或配置错误所需的配置,应用程序将无法成功启动。此外,confKey 选项允许你为该模块将要添加的服务器装饰器设置一个自定义名称。

  3. 如果你将此插件注册到 app.js 文件中并尝试重启服务器,你将在启动过程中遇到错误:

    Error: env must have required property 'API_KEY', env must have required property 'DATABASE_URL'
    

    为了修复这个配置问题,我们需要开始在插件声明中传递 opts 参数。让我们使用自顶向下的方法来解决这个问题。

  4. 首先,打开 server.js 文件并执行以下更新:

    const app = fastify(options);
    app.register(appPlugin, {
      applicationEnv: {
        API_KEY: 'fastify-rocks',
        DATABASE_URL:
    'mongodb://localhost:27017/restaurant',
        ...process.env
      }
    });
    

    我们在注册 appPlugin 时引入了一个配置对象。applicationEnv 属性是通过将 process.env 与代码中指定的默认值合并而得到的。在 process.env 包含 API_KEYDATABASE_URL 的值的情况下,这些特定于环境的值将优先于代码中定义的默认值。

  5. 现在,我们需要回到 app.js 并相应地更新它:

    import configPlugin from './plugins/config.js';
    // ...
    async function appPlugin (app, opts) {
      // ...
      app.register(configPlugin, opts);
      // ...
    }
    

    在此上下文中,opts 参数对应于我们在 server.js 文件中最近添加的第二个对象参数。因此,configPlugin 也接收相同的对象,因为我们是在注册时添加的。如果我们回顾这个菜谱中的初始代码片段,它展示了 configPlugin 的实现,你会注意到我们已经向 @fastify/env 提供了 opts.applicationEnv 选项。这表明它现在正在读取正确的配置。

  6. 通过这些调整,我们应该能够成功重启服务器。

  7. 我们已经更改了很多代码,但仍需要从插件中删除硬编码的配置。现在,让我们从 app.js 文件开始做:

    async function appPlugin (app, opts) {
      // ...
      await app.register(configPlugin, opts);
      app.register(datasourcePlugin, { databaseUrl:
        app.appConfig.DATABASE_URL });
      app.register(authPlugin, { tokenValue:
        app.appConfig.API_KEY });
      // ...
    }
    

    在这个代码片段中,你会注意到一个新的语法:await app.register()。现在,await 是至关重要的,因为没有它,你的服务器将无法启动。作为提醒,在 将代码拆分为小插件 的菜谱中,我们讨论了插件函数只有在以下方法之一被调用时才会执行:app.listen()app.ready()app.inject()。虽然这个原则仍然是正确的,但使用 await app.register() 有效地触发了 Fastify 在等待行之前启动加载过程,确保在进一步执行之前完成必要的设置。实际上,我们在下一行使用了 app.appConfig 装饰器,并且如果不在(await)configPlugin 中等待,这个字段将保持未定义状态。

  8. plugins/datasource.js文件中,我们可以更新 MongoDB 的设置:

      app.register(fastifyMongo, {
        url: opts.databaseUrl
      });
    
  9. plugins/auth.js文件中,我们可以按照以下方式移除硬编码的 API 密钥:

      app.decorateRequest('isChef', function () {
        return this.headers['x-api-key'] ===
          opts.tokenValue;
      });
    

    干得好!你现在已经实现了一个动态应用程序,它根据环境调整其配置,在启动前验证先决条件,并通过利用等待插件的能力,利用 Fastify 插件系统的另一个特性。此外,你改进了身份验证和数据源插件,使它们更具可配置性,并适合在组织内的项目中使用。通过通过register方法配置插件,我们可以创建与应用程序其余部分解耦的插件,并且不需要app.appConfig装饰器即可工作。

    在所有这些组件就绪的情况下,你已充分准备开始编写你的测试套件。你创建的动态配置在你开始项目的测试阶段将证明非常有价值。

    你的 Fastify 应用程序可以有效地由appPlugin实例和由app.js文件导出的服务器配置来表示。实际上,测试server.js文件的价值很小,因为它主要是一个简单的运行器,当需要时可以很容易地由fastify-cli模块替换。

  10. 为 Fastify 应用程序编写测试的第一步是启用创建一个test/helper.js文件:

    import { fastify } from 'fastify';
    import appPlugin, { options } from '../app.js';
    const defaultTestEnv = {
      NODE_ENV: 'test',
      API_KEY: 'test-suite',
      DATABASE_URL: 'mongodb://localhost:27017/restaurant-
        test-run'
    };
    async function buildApplication (env, serverOptions =
      { logger: false }) {
        const testServerOptions = Object.assign({},
          options, serverOptions);
        const testEnv = Object.assign({}, defaultTestEnv,
          env);
        const app = fastify(testServerOptions);
        app.register(appPlugin, { applicationEnv: testEnv
        });
      return app;
    }
    export { buildApplication };
    

    我们已经准备好编写我们的第一个测试文件:test/app.test.js

  11. 我们必须导入新的Node.js 测试****runner模块:

    import { test } from 'node:test';
    import { strictEqual, deepStrictEqual, ok } from
      'node:assert';
    

    它的完整 API 列表可以在官方文档nodejs.org/api/test.html中找到。

  12. 我们需要导入buildApplication实用工具:

    import { buildApplication } from './helper.js';
    
  13. 我们需要使用test函数定义一个测试用例。第一个参数是一个描述性字符串,有助于识别当前正在执行的哪个测试。第二个参数是一个异步函数,它接受一个测试上下文参数:

    test('GET /', async function (t) {
      const app = await buildApplication();
      t.after(async function () {
        await app.close();
      });
      const response = await app.inject({
        method: 'GET',
        url: '/'
      });
      strictEqual(response.statusCode, 200);
      deepStrictEqual(response.json(), {
        api: 'fastify-restaurant-api',
        version: 1
      });
    });
    
  14. 要运行测试文件,我们需要执行以下命令:

    $ node --test test/app.test.js
    ✔ GET / (56.81025ms)
    ℹ tests 1
    ℹ suites 0
    ℹ pass 1
    ℹ fail 0
    ℹ cancelled 0
    ℹ skipped 0
    ℹ todo 0
    ℹ duration_ms 341.573042
    

它是如何工作的…

test/helper.js导出一个buildApplication函数,该函数实例化 Fastify 根服务器实例并注册appPlugin,正如server.js文件所执行的那样。以下是一些差异:

  • app常量只是返回,我们没有调用listen()方法。这样,我们就不会阻塞主机的端口。

  • 默认服务器的选项与server.js相同,只是关闭了日志记录。无论如何,我们可以通过向工厂函数提供第二个参数来自定义它们。

  • 默认的环境设置不读取process.env对象,但它定义了良好的默认值,以便在每一个本地开发环境中运行应用程序。

由于我们正在运行应用程序,将建立与数据库的连接。为了确保测试成功完成,在测试执行所有断言之后关闭服务器和数据库连接是至关重要的。这个清理步骤对于后续测试的正确运行和避免资源泄漏至关重要。

最后,我们可以使用 Fastify 的 app.inject() 方法。与调用 listen 方法不同,这种方法启动服务器时不会主动监听传入的 HTTP 请求,从而实现更快的执行。然后,inject 方法生成一个模拟的 HTTP 请求并将其发送到服务器,服务器会以处理真实请求相同的方式处理它,生成 HTTP 响应。此方法返回 HTTP 响应,使我们能够验证其内容以验证我们的预期。inject 方法接受一个对象参数,用于指定各种 HTTP 请求组件。我们将在本食谱的 还有更多... 部分中探索更多示例。

在测试用例结束时,我们断言响应具有正确的状态码和有效载荷。

测试输出提供了整个执行过程的摘要。在发生错误的情况下,它会显示一个详细的消息,指出失败的断言。出于实验目的,你可以尝试通过修改 deepStrictEqual 检查来破坏测试,例如,通过编辑 version 属性。这将帮助你观察测试如何对更改和失败做出响应,从而允许你根据需要对其进行优化和改进。

还有更多...

在结束这个食谱之前,检查一个更复杂的测试用例可能会有所帮助,所以让我们快速分析一下这段代码:

test('An unknown user cannot create a recipe', async function (t) {
  const testApiKey = 'test-suite-api-key';
  const app = await buildApplication({
    API_KEY: testApiKey
  });
  t.after(async function () {
    await app.close();
  });
  const pizzaRecipe = { name: 'Pizza', country: 'ITA',
    price: 8, order: 2 };
  const notChefResponse = await app.inject({
    method: 'POST',
    url: '/recipes',
    payload: pizzaRecipe,
    headers: {
      'x-api-key': 'invalid-key'
    }
  });
  strictEqual(notChefResponse.statusCode, 401);
});

这个新的测试用例检查了 未知用户不能创建食谱 的条件。在这种情况下,我们在 buildApplication 函数中注入一个自定义 API 密钥,然后确认如果 POST /recipes 请求缺少有效的头信息,它将被拒绝。我们还看到,inject 方法接受 payloadheaders 字段来控制每个请求的各个方面。

此外,我们可以通过引入一个额外的测试用例来增强代码,以确保有效的厨师确实可以创建食谱,并且新创建的食谱会出现在菜单上。这个额外的测试将进一步验证应用程序的功能:

test('Only a Chef can create a recipe', async function (t) {
  const testApiKey = 'test-suite-api-key';
  const app = await buildApplication({
    API_KEY: testApiKey
  });
  t.after(async function () {
    await app.close();
  });
  const pizzaRecipe = { name: 'Pizza', country: 'ITA',
    price: 8, order: 2 };
  const response = await app.inject({
    method: 'POST',
    url: '/recipes',
    payload: pizzaRecipe,
    headers: {
      'x-api-key': testApiKey
    }
  });
  strictEqual(response.statusCode, 201);
  const recipeId = response.json().id;
  const menu = await app.inject('/menu');
  strictEqual(menu.statusCode, 200);
  const recipes = menu.json();
  const expectedPizza = recipes.find(r => r.id ===
    recipeId);
  ok(expectedPizza, 'Pizza recipe must be found');
});

注意,app.inject() 方法还有一个快捷方式来运行简单的 GET 请求。它只需要 URL 字符串。

为应用程序的路由实现测试以覆盖所有用例是熟练掌握 API 和测试套件的有价值练习。你现在拥有了完成这项任务所需的基本知识,可以通过运用你所学的知识来应对。请参考书籍的源代码仓库以获取更多代码示例和指导。祝你好运,恭喜你在进步上取得的成就!

在本章中,你深入研究了 Fastify 的一些最关键特性,包括插件系统和大量的钩子。你还对 Fastify 应用程序的基本方面有了深入了解,例如配置和代码复用。此外,你在使用 MongoDB 方面的熟练度无疑也得到了提高。

如果你热衷于 Fastify 并渴望探索更多,你可能会发现由Packt Publishing出版的书籍《使用 Fastify 加速服务器端开发www.packtpub.com/product/accelerating-server-side-development-with-fastify/9781800563582是进一步深化你在这一强大框架中的知识和技能的无价资源。继续保持你的出色工作!

第七章:持久化到数据库

在应用开发的世界里,能够保存和检索数据是至关重要的。想象一下,你正在构建一个游戏,需要记录分数,或者一个社交媒体应用,用户需要保存他们的个人资料和帖子。很多时候,传统的关系型数据库正是你所需要的。它就像一个有组织的文件系统,其中每一项内容都有其所在的位置,并且这些表格可以以特定的方式相互关联。例如,一个表格可能存储关于书籍的信息,而另一个表格存储关于作者的信息,两者之间的链接可以显示哪个作者写了哪本书。

但如果你的数据不适合这种结构化格式怎么办?如果你处理的是更灵活或不可预测的事物,比如社交媒体上的帖子,其中一些帖子有图片,一些有视频,而另一些只有文本呢?这就是非关系型,或 NoSQL,数据库发挥作用的地方。它们被设计来处理各种数据结构,从简单的键值对到更复杂的文档或图。这使得它们成为现代应用程序的理想选择,这些应用程序需要灵活性和可扩展性。

重要提示

本章将专注于在 Node.js 中与这些数据库交互。因此,假设您对数据库和 结构化查询语言SQL)有一些基本知识。

我们将从设置一个简单的 SQL 数据库开始,以了解数据库操作的基本原理。然后,我们将探索 NoSQL 数据库的动态世界,学习如何与它们交互以处理更灵活的数据结构。到本章结束时,你将在 Node.js 应用程序中使用不同类型的数据库方面打下基础,这将为你提供选择适合你项目的正确存储解决方案的灵活性。

本章将涵盖以下食谱:

  • 连接到并持久化到 MySQL 数据库

  • 连接到并持久化到 PostgreSQL 数据库

  • 连接到并持久化到 MongoDB

  • 使用 Redis 持久化数据

  • 探索 GraphQL

技术要求

在本章中,我们将使用 Docker 在容器中提供数据库。在构建可扩展和弹性架构时,使用数据库容器是常见的做法——尤其是在使用容器编排器如 Kubernetes 时。

然而,我们将在本章中使用 Docker 容器的主要原因是为了避免手动在我们的系统上安装每个数据库的 命令行界面CLIs)和服务器。在本章中,我们将使用 Docker 来提供容器化的 MySQL、PostgreSQL、MongoDB 和 Redis 数据存储。

建议从 docs.docker.com/engine/install/ 安装 Docker Desktop。

如果你无法安装 Docker,你仍然可以完成这些食谱,但你将需要手动为每个食谱安装特定的数据库或连接到远程数据库服务。

注意,本章不会介绍如何从 Docker 容器中启用持久数据存储,因为这需要超出 Node.js 教程范围的 Docker 知识。因此,一旦容器被销毁或删除,教程期间积累的数据将会丢失。

完成每个配方后,也可以通过以下步骤清理并删除你的数据库容器:

  1. 在你的终端中输入 $ docker ps 以列出你的 Docker 容器。

  2. 从那里,找到容器标识符,并将其传递给 $ docker stop 命令以停止容器。

  3. 使用 $ docker rm --force 来删除容器。

或者,你可以使用以下命令来删除所有 Docker 容器:

$ docker rm --force $(docker ps --all --quiet)

如果你的设备上运行着与本书配方无关的其他 Docker 容器,使用此命令时要小心。

重要提示

Docker 既指代虚拟化技术,也指代创建了这项技术的公司 Docker Inc.。Docker 允许你将应用程序和服务打包成名为容器的包。有关 Docker 技术的更详细信息,请参阅第十一章

在几个配方中,我们还将使用dotenv模块(www.npmjs.com/package/dotenv)。dotenv模块将环境变量从.env文件加载到 Node.js 进程中。在需要的情况下,我们将把示例数据库凭据存储在.env文件中,然后使用dotenv模块将这些解析到我们的 Node.js 进程中。

你还需要安装 Node.js,最好是最新版本,Node.js 22,以及你选择的编辑器和浏览器。本章生成的代码示例可在 GitHub 上的github.com/PacktPublishing/Node.js-Cookbook-Fifth-EditionChapter07目录中找到。

连接到 MySQL 数据库并持久化

SQL 是用于与关系型数据库通信的标准。MySQL (www.mysql.com/) 和 PostgreSQL (www.postgresql.org/) 都是流行的开源关系型数据库管理系统RDBMSs)。SQL 数据库有许多实现,每个都有其扩展和专有功能。然而,所有这些 SQL 数据库都实现了存储、更新和查询数据的基本命令集。

在这个配方中,我们将使用mysql2模块(www.npmjs.com/package/mysql2)从 Node.js 与 MySQL 数据库进行通信。

准备工作

首先,我们需要在本地运行一个 MySQL 数据库。为此,以及在本章中的其他数据库,尽可能使用 Docker。MySQL 在 Docker Hub 上提供了一个官方镜像(hub.docker.com/_/mysql)。本食谱假设您对 SQL 和关系型数据库有一些,但最少量的先验知识。

重要提示

在本教程中,我们将使用来自 npmmysql2 包来与 Node.js 中的 MySQL 数据库交互,因为它与最新的 MySQL 功能兼容,并支持承诺。选择 mysql2 而不是之前使用的 mysql 包的原因是它更更新,使我们能够利用新的功能和功能,如 Promiseasync / await 语法。

要使用 Docker 设置 MySQL 数据库并准备您的项目,请按照以下步骤操作:

  1. 在终端窗口中,输入以下命令以启动一个监听在端口 3306 的 MySQL 数据库:

    $ docker run --publish 3306:3306 --name node-mysql --env MYSQL_ROOT_PASSWORD=PASSWORD --detach mysql:8
    

重要提示

Docker 命令中的 --publish 3306:3306 选项将主机机器上的端口 3306 映射到 Docker 容器上的端口 3306,允许外部访问在该端口上运行的服务器。

如果您没有本地镜像,那么 Docker 将首先从 Docker Hub 下载镜像。当 Docker 正在下载镜像时,您可能会看到如下输出:

Unable to find image 'mysql:8' locally
latest: Pulling from library/mysql
ea4e27ae0b4c: Pull complete
837904302482: Pull complete
3c574b61b241: Pull complete
654fc4f3eb2d: Pull complete
32da9c2187e3: Pull complete
dc99c3c88bd6: Pull complete
970181cc0aa6: Pull complete
d77b716c39d5: Pull complete
9e650d7f9f83: Pull complete
acc21ff36b4b: Pull complete
Digest: sha256:ff5ab9cdce0b4c59704b4e2a09deed5ab8467be795e0ea20228b8528f53fcf82
Status: Downloaded newer image for mysql:8
dbb88d7d042966351a79ae159eb73129d69961b2c3dab943d9f4cdd6697d5220

--detach 参数表示我们希望以分离模式启动容器——这意味着容器将在后台运行。省略 --detach 参数将意味着您的终端窗口将被容器占用。

  1. 接下来,我们将为这个食谱创建一个新的目录:

    $ mkdir mysql-app
    $ cd mysql-app
    
  2. 由于我们将要安装来自 npm 的模块,因此我们还需要初始化我们的项目:

    $ npm init --yes
    

    我们还将准备两个用于食谱的文件。第一个将是一个名为 setupDb.mjs 的脚本,用于创建数据库;第二个将是一个名为 task.mjs 的脚本,用于向数据库添加新任务。在此期间,让我们也创建一个 .env 文件,以便存储我们的数据库凭据:

    $ touch setupDb.mjs tasks.mjs
    $ touch .env
    
  3. 将我们 MySQL 实例的示例凭据添加到 .env 文件中:

    DB_MYSQL_USER=root
    DB_MYSQL_PASSWORD=PASSWORD
    

    请注意,这些示例凭据是为了简化而设置的——您应该在您的应用程序中使用更强的凭据。此外,请确保不要意外地将 .env 文件提交到 版本控制系统VCSs,如 Git),因为这可能导致敏感凭据泄露。

现在我们已经启动了 MySQL 数据库并初始化了项目,我们就可以继续进行食谱了。

如何操作...

在本食谱中,我们将关注如何从 npm 安装 mysql2 模块,连接到 MySQL 数据库,并执行基本的 SQL 查询。我们将使用一个简单的任务列表示例来说明这些概念。我们还将使用 ECMAScript 模块ESM)语法,这在 第五章使用 ECMAScript 模块 食谱中有介绍。

这种方法应该有助于您了解在 MySQL 数据库中管理和管理数据的实际应用。

  1. 首先,我们需要安装dotenv模块,用于解析环境变量配置,以及mysql2模块:

    $ npm install dotenv mysql2
    
  2. 我们将首先编写一个脚本来设置我们的任务列表数据库。为此,我们首先需要使用dotenv模块导入和加载我们的凭据,并导入mysql2模块。将以下内容添加到setupDb.mjs以执行此操作:

    import dotenv from 'dotenv';
    import mysql from 'mysql2/promise';
    dotenv.config();
    
  3. 现在,让我们构建一个main()函数。我们将随着教程步骤的进行向此函数中添加逻辑。将以下内容添加到setupDb.mjs

    async function main () {
    }
    main().catch(console.error);
    
  4. 现在,让我们开始添加我们的连接逻辑,我们将将其包装在try / catch / finally结构中,其中finally将关闭数据库连接。在main()函数中,添加以下内容:

    async function main() {
      let connection;
      try {
        connection = await mysql.createConnection({
          user: process.env.DB_MYSQL_USER,
          password: process.env.DB_MYSQL_PASSWORD,
        });
        console.log('Connected as id ' +
            connection.threadId);
       } catch (error) {
        console.error('Error connecting: ' + error.stack);
      } finally {
        if (connection) await connection.end();
      }
    }
    

    我们可以在我们的终端中运行此文件以测试连接:

    $ node setupDb.mjs
    Connected as id 10
    
  5. 现在,让我们添加我们的逻辑来创建表。为此,我们将使用两个独立的 SQL 语句。第一个将创建一个数据库并指示连接使用它。第二个将创建一个tasks数据库表。在main()函数中,在console.log('Connected as ...行下方添加以下内容:

    await connection.query('CREATE DATABASE IF NOT EXISTS
      tasks');
    console.log('Database created or already exists.');
    await connection.query('USE tasks');
    const createTasksTableSql =
      `CREATE TABLE IF NOT EXISTS tasks (
        id INT AUTO_INCREMENT PRIMARY KEY,
        task VARCHAR(255) NOT NULL,
        completed BOOLEAN NOT NULL DEFAULT FALSE
        )`;
    await connection.query(createTasksTableSql);
    console.log('Tasks table created or already exists.');
    
  6. 使用以下命令在您的终端中运行程序:

    $ node setupDb.mjs
    Connected as id 18
    Database created or already exists.
    Tasks table created or already exists.
    
  7. 现在,我们可以在tasks.mjs中实现我们的逻辑,将一些数据输入到我们的表中,同样通过 SQL 查询。首先,复制我们在setupDb.mjs中使用的相同连接逻辑:

    import dotenv from 'dotenv';
    import mysql from 'mysql2/promise';
    dotenv.config();
    async function main() {
      let connection;
      try {
        connection = await mysql.createConnection({
          user: process.env.DB_MYSQL_USER,
          password: process.env.DB_MYSQL_PASSWORD,
        });
        console.log('Connected as id ' +
            connection.threadId);
      } catch (error) {
        console.error('Error connecting: ' + error.stack);
      } finally {
        if (connection) await connection.end();
      }
    }
    main().catch(console.error);
    

    注意,我们使用connection.end()结束与 MySQL 数据库的连接。

  8. 现在,我们可以添加一些逻辑来从命令行接收任务详情。在console.log('Connected as …**行下方添加以下逻辑:

        if (process.argv[2]) {
          await connection.query(
              `INSERT INTO tasks.tasks (task) VALUES
                  (?);`,
              [process.argv[2]]
          );
        }
    
  9. 让我们添加一个查询,以获取tasks表的内容:

    const [results] = await connection.query('SELECT *
      FROM tasks.tasks;');
        console.log(results);
    
  10. 现在,使用以下命令运行程序:

    $ node tasks.mjs "Walk the dog."
    Connected as id 10
    [ { id: 1, task: 'Walk the dog.', completed: 0 } ]
    

    每次我们运行程序时,我们的插入查询都会被执行,这意味着在tasks表中将创建一个新的条目。

它是如何工作的…

mysql2模块暴露的createConnection()方法根据传递给方法的配置和凭据建立与 MySQL 服务器的连接。在配方中,我们使用环境变量将数据库的用户名和密码传递给createConnection()方法。mysql2模块默认在localhost:3306查找 MySQL 数据库,这是我们创建的 MySQL Docker 容器在配方中“准备就绪”部分暴露的位置。npm中的mysql2模块旨在提供与先前的npm模块中的mysql模块等效的功能。可以在mysql模块 API 文档中找到传递给createConnection()方法的完整选项列表,网址为github.com/mysqljs/mysql#connection-options

重要提示

连接池也可以被利用,通过重用现有连接而不是使用后关闭它们来最小化连接到 MySQL 服务器所需的时间。这种方法通过消除设置新连接相关的开销来提高查询延迟。这种策略对于大规模应用的开发至关重要。更多详情,请参阅sidorares.github.io/node-mysql2/docs#using-connection-pools的 API 文档。

在整个食谱中,我们使用了query()方法将 SQL 语句发送到 MySQL 数据库。setupDb.mjs文件中的 SQL 语句创建了一个tasks数据库和一个tasks表。task.mjs文件包含了将单个任务插入到tasks表的 SQL 语句。我们使用query()方法发送到数据库的最后一个 SQL 语句是一个SELECT语句,它返回了tasks表的内容。

每个 SQL 语句都会排队并异步执行。可以将回调函数作为参数传递给query()方法,但我们利用了async / await语法。

如其名所示,end()方法结束与数据库的连接。end()方法确保在结束连接之前没有查询仍在排队或正在处理。还有一个方法,destroy(),会立即终止与数据库的连接,忽略任何挂起或正在执行的查询的状态。

需要意识到的一种常见的针对面向用户的 Web 应用的攻击类型是 SQL 注入攻击。

SQL 注入是指攻击者向您的数据库发送恶意 SQL 语句。这通常是通过将恶意 SQL 语句插入到网页输入字段中实现的。这不是 Node.js 特有的问题;它也适用于其他编程语言,其中 SQL 查询是通过字符串连接创建的。减轻这些攻击的方法是对用户输入进行清理或转义,以确保我们的 SQL 语句不能被恶意操纵。

您可以通过使用connection.escape()手动转义用户提供的直接数据。然而,在食谱中,我们使用占位符(?)语法在我们的 SQL 查询中实现相同的效果:

await connection.query(
          `INSERT INTO tasks.tasks (task) VALUES (?);`,
          [process.argv[2]]
      );

如果我们将输入值通过query函数的第二个参数传递给查询,mysql2模块会为我们处理用户输入的清理。在 SQL 查询中,多个占位符(?)按照提供的顺序映射到值。

还有更多...

在本节中,我们将基于使用 Node.js 与 MySQL 交互的基础,介绍如何结合 MySQL 使用 Fastify 创建 REST API。我们将逐步讲解设置项目、启动 Fastify 服务器、使用@fastify/mysql插件(www.npmjs.com/package/@fastify/mysql)连接 MySQL 以及创建处理创建、读取、更新、删除CRUD)操作的路线。

确保您有一个 MySQL 数据库可用。为此,我们将重用主配方步骤中创建的数据库。

  1. 首先,我们将为fastify-mysql项目创建一个新的目录,并使用npm初始化它:

    $ mkdir fastify-mysql
    $ cd fastify-mysql
    $ npm init --yes
    
  2. 使用npm安装fastify@fastify/mysql插件:

    $ npm install fastify @fastify/mysql
    
  3. 在您的项目根目录下创建一个名为server.js的文件。此文件将配置 Fastify 服务器,连接到 MySQL 数据库,并定义路由:

    $ touch server.js
    
  4. 首先引入 Fastify – 我们也将启用日志记录:

    const fastify = require('fastify')({ logger: true });
    
  5. 现在,我们可以注册之前安装的@fastify/mysql插件:

    fastify.register(require('@fastify/mysql'), {
      connectionString:
        'mysql://root:PASSWORD@localhost/tasks'
    });
    

    注意,连接字符串包含我们 MySQL 数据库的凭据 – 理想情况下,此连接字符串应存储在之前配方中提到的.env文件中。

  6. 现在,让我们注册一个路由以返回数据库中的所有任务:

    fastify.get('/tasks', (req, reply) => {
      fastify.mysql.query(
        'SELECT * FROM tasks.tasks',
        function onResult (err, result) {
          reply.send(err || result);
        }
      );
    });
    
  7. 最后,我们将添加运行服务器的逻辑:

    fastify.listen({ port: 3000 }, err => {
      if (err) throw err;
      console.log(`server listening on
        ${fastify.server.address().port}`);
    });
    
  8. 让我们启动 Fastify MySQL 应用程序:

    $ node server.js
    
  9. 要测试您的 API,在服务器运行时打开一个新的终端窗口,并使用curl

    $ curl http://localhost:3000/tasks
    

本教程提供了使用 Fastify 和 MySQL 创建 REST API 的基本介绍,涵盖了项目设置、初始化服务器、连接到数据库以及从数据库检索项目。Fastify 为在此配方中使用的其他数据库提供了等效的插件。

参见

  • 本章中的连接并持久化到 PostgreSQL 数据库配方

  • 本章中的连接并持久化到 MongoDB配方

  • 本章中的使用 Redis 持久化数据配方

  • 第九章

连接到并持久化到 PostgreSQL 数据库

PostgreSQL 首次于 1996 年推出,是一个强大的开源对象关系型数据库系统,由于其可靠性、功能健壮性和性能而经受住了时间的考验。PostgreSQL 的突出特点之一是它既可以作为传统的数据库使用,其中数据存储在具有相互关系的表中,也可以作为文档数据库使用,例如 NoSQL 数据库,其中数据可以以 JSON 格式存储。这种灵活性允许开发者根据其应用程序的需求选择最合适的数据存储模型。

在整个教程中,我们将探索从 Node.js 应用程序与 PostgreSQL 数据库交互的基础。我们将使用pg模块,这是一个流行的、功能全面的 PostgreSQL 客户端,适用于 Node.js。pg模块简化了连接到并执行针对 PostgreSQL 数据库的查询。

准备工作

要开始,我们需要一个 PostgreSQL 服务器来连接。我们将使用 Docker 来配置一个容器化的 PostgreSQL 数据库。有关使用 Docker 配置数据库的更多信息,请参阅本章的 技术要求 部分。

我们将使用 Docker 官方的 PostgreSQL 镜像,hub.docker.com/_/postgres

以下步骤将初始化我们的 PostgreSQL 服务器并准备我们的项目目录:

  1. 在终端窗口中,键入以下命令以配置一个 postgres 容器:

    $ docker run --publish 5432:5432 --name node-postgres-latest --env POSTGRES_PASSWORD=PASSWORD --detach postgres:16
    

    假设您没有本地 PostgreSQL 镜像的副本,在 Docker 下载镜像时,您可能会看到以下输出:

    Unable to find image 'postgres:16' locally
    latest: Pulling from library/postgres
    f546e941f15b: Pull complete
    926c64b890ad: Pull complete
    eca757527cc4: Pull complete
    93d9b27ec7dc: Pull complete
    86e78387c4e9: Pull complete
    8776625edd8f: Pull complete
    d1afcbffdf18: Pull complete
    6a6c8f936428: Pull complete
    ae47f32f8312: Pull complete
    82fb85897d06: Pull complete
    ce4a61041646: Pull complete
    ca83cd3ae7cf: Pull complete
    f7fbf31fd41d: Pull complete
    353df72b8bf7: Pull complete
    Digest: sha256:f58300ac8d393b2e3b09d36ea12d7d24ee9440440e421472a300e929ddb63460
    Status: Downloaded newer image for postgres:16
    86ce1ac06849f737e669c34e50e6f91383074cdecb1a18f8f23a6becaa085ba0
    

    我们现在应该有一个在端口 5432 上监听的 PostgreSQL 数据库。

  2. 接下来,我们将设置一个目录和文件,为我们的 PostgreSQL 应用程序做好准备:

    $ mkdir postgres-app
    $ cd postgres-app
    $ touch tasks.js .env
    
  3. 由于我们将使用第三方模块,我们还需要使用 npm 来初始化一个项目。让我们只接受默认设置:

    $ npm init --yes
    

现在,我们已经准备好进入菜谱,我们将使用 pg 模块与我们的 PostgreSQL 数据库进行交互。

如何操作…

在这个菜谱中,我们将安装 pg 模块,使用 Node.js 与我们的 PostgreSQL 数据库进行交互。我们还将向数据库发送一些简单的查询。

  1. 首先,我们需要安装第三方 pg 模块:

    $ npm install pg
    
  2. 在这个菜谱中,我们还将使用 dotenv 模块;使用以下命令安装它:

    $ npm install dotenv
    
  3. 我们还将使用 .env 文件来存储我们的 PostgreSQL 数据库凭据,并使用 dotenv 模块将它们传递给我们的程序。将以下凭据添加到 .env 文件中:

    PGUSER=postgres
    PGPASSWORD=PASSWORD
    PGPORT=5432
    
  4. 打开 tasks.js 并使用 dotenv 模块导入我们的环境变量:

    require('dotenv').config();
    
  5. 接下来,在 tasks.js 中,我们需要导入 pg 模块并创建一个 PostgreSQL 客户端:

    const pg = require('pg');
    const db = new pg.Client();
    
  6. 现在,让我们允许我们的程序通过命令行参数处理输入:

    const task = process.argv[2];
    
  7. 接下来,我们将定义我们将要使用的 SQL 查询作为常量。这将提高我们代码的可读性:

    const CREATE_TABLE_SQL = `CREATE TABLE IF NOT EXISTS
      tasks (id SERIAL, task TEXT NOT NULL, PRIMARY KEY (
        id ));`;
    const INSERT_TASK_SQL = 'INSERT INTO tasks (task)
      VALUES ($1);';
    const GET_TASKS_SQL = 'SELECT * FROM tasks;';
    

    SELECT * FROM tasks; SQL 查询返回 tasks 表中的所有任务。

  8. 接下来,我们将添加以下代码来连接到我们的数据库。如果尚不存在,则创建一个 tasks 表,插入一个任务,最后列出数据库中存储的所有任务:

    db.connect((err) => {
      if (err) throw err;
      db.query(CREATE_TABLE_SQL, (err) => {
        if (err) throw err;
        if (task) {
          db.query(INSERT_TASK_SQL, [task], (err) => {
            if (err) throw err;
            listTasks();
          });
        } else {
          listTasks();
        }
      });
    });
    
  9. 最后,我们将创建我们的 listTasks() 函数,该函数将使用 GET_TASKS_SQL 。此函数还将结束与数据库的连接:

    function listTasks () {
      db.query(GET_TASKS_SQL, (err, results) => {
        if (err) throw err;
        console.log(results.rows);
        db.end();
      });
    }
    
  10. 运行 tasks.js,传递一个任务作为命令行参数。任务将被插入到数据库中,并在程序结束前列出:

    $ node tasks.js "Bath the dog."
    [
      { id: 1, task: 'Bath the dog.' }
    ]
    
  11. 我们也可以不传递任务来运行程序。当我们不带 task 参数运行 tasks.js 时,程序将输出数据库中存储的任务:

    $ node tasks.js
    [
      { id: 1, task: 'Bath the dog.' }
    ]
    

通过遵循这些步骤,您已经了解了如何将 PostgreSQL 与 Node.js 集成。

工作原理…

在本菜谱的 准备就绪 部分,我们使用 Docker Hub 上的 Docker 官方镜像配置了一个容器化的 PostgreSQL 数据库。配置的 PostgreSQL 数据库是在名为 node-postgres 的 Docker 容器中配置的。默认情况下,PostgreSQL Docker 镜像创建了一个名为 postgres 的用户和数据库。我们用于配置数据库的 Docker 命令指示容器在 localhost:5432 上使 PostgreSQL 数据库可用,并使用占位符密码 PASSWORD

连接到我们的 PostgreSQL 数据库所需配置信息已在 .env 文件中指定。我们使用 dotenv 模块将此配置信息作为环境变量加载到我们的 Node.js 进程中。

注意,我们不必直接将任何环境变量传递给客户端。这是因为 pg 模块会自动查找特定命名的变量(PGHOSTPGPORTPGUSER)。然而,如果我们想,我们可以在创建客户端时指定这些值,如下所示:

const client = new Client({
  host: 'localhost',
  port: 5432,
  user: 'postgres'
});

我们使用 connect() 方法连接到我们的 PostgreSQL 数据库。我们向此方法提供一个回调函数,以便在连接尝试完成后执行。我们在回调函数中添加了错误处理,以便如果连接尝试失败,则抛出错误。

在程序的剩余部分,我们使用 pg 模块提供的 query() 方法对 PostgreSQL 数据库执行 SQL 查询。我们对 query() 方法的每次调用都提供了一个回调函数,以便在查询完成后执行。

还有更多...

除了存储传统的关联数据,PostgreSQL 还提供了存储对象数据的能力。这使得可以在文档存储的同时存储关联数据。

我们可以将我们在 连接和持久化到 PostgreSQL 数据库 菜谱中创建的程序进行修改,以处理关联数据和对象数据。

  1. postgres-app 目录复制到名为 postgres-object-app 的目录中:

    $ cp -r postgres-app postgres-object-app
    $ cd postgres-object-app
    
  2. 现在,我们将编辑我们的 SQL 查询以创建一个名为 task_docs 的新表,用于存储文档数据。在 tasks.js 文件中将您的 SQL 查询常量更改为以下内容:

    const CREATE_TABLE_SQL = `CREATE TABLE IF NOT EXISTS
      task_docs (id SERIAL, doc jsonb);`;
    const INSERT_TASK_SQL = `INSERT INTO task_docs (doc)
      VALUES ($1);`;
    const GET_TASKS_SQL = `SELECT * FROM task_docs;`;
    
  3. 现在,当我们运行应用程序时,我们可以传递 JSON 输入来表示任务。请注意,我们需要将 JSON 输入用单引号括起来,然后为键值对使用双引号:

    $ node tasks.js '{"task":"Walk the dog."}'
    [ { id: 1, doc: { task: 'Walk the dog.' } } ]
    

    doc 字段是用 jsonb 类型创建的,它表示 JSON 二进制类型。PostgreSQL 提供了两种 JSON 数据类型:jsonjsonbjson 数据类型类似于常规的文本输入字段,但增加了验证 JSON 的功能。jsonb 类型是结构化的,便于在文档对象中进行查询和索引。当您需要查询或索引数据时,您会选择 jsonb 数据类型而不是 json 数据类型。

根据这个示例,一个 jsonb 查询看起来如下所示:

SELECT * FROM task_docs WHERE doc ->> task= "Bath the dog."

注意,我们可以在文档对象内的 task 属性上进行查询。有关 jsonb 数据类型的信息,请参阅官方 PostgreSQL 文档,网址为 www.postgresql.org/docs/9.4/datatype-json.html

参见

  • 本章中的 连接和持久化到 MySQL 数据库 菜谱

  • 本章中的 连接和持久化到 MongoDB 菜谱

  • 本章中的 使用 Redis 持久化数据 菜谱

连接和持久化到 MongoDB

MongoDB 是一个围绕文档模型构建的 NoSQL 数据库管理系统。数据存储在灵活的、类似 JSON 的文档中,称为 Binary JSON ( BSON ),这些文档组织成 集合,类似于关系数据库中的表。集合中的每个文档都可以有不同的结构,允许动态模式并易于修改数据模型。

MongoDB 支持使用其查询语言进行强大的查询功能,包括用于过滤、排序和操作数据的各种运算符和方法。

本菜谱将使用 MongoDB Node.js 驱动程序直接使用 book/author 示例。我们将编写函数来创建和查找 MongoDB 数据库中的作者和书籍。此脚本将展示基本的 CRUD 操作,不使用任何 Web 框架,纯粹关注数据库交互。

准备工作

使用 Docker 设置 MongoDB 数据库并准备好项目目录以供应用程序使用,请按照以下步骤操作:

  1. 与本章中的其他数据库一样,我们将使用 Docker 通过 MongoDB Docker 镜像提供 MongoDB 数据库,该镜像可在 hub.docker.com/_/mongo 找到:

    $ docker run --publish 27017:27017 --name node-mongo --detach mongo:7
    

    假设您没有 MongoDB 镜像的本地副本,在 Docker 下载镜像时,您可能会看到以下输出:

    Unable to find image 'mongo:7' locally
    latest: Pulling from library/mongo
    bccd10f490ab: Pull complete
    b00c7ff578b0: Pull complete
    a1f43ab85151: Pull complete
    9e72f6a5998a: Pull complete
    8424336879e4: Pull complete
    85a6d3c2e6c8: Pull complete
    c533c21e5fb8: Pull complete
    1fddf702bb73: Pull complete
    Digest: sha256:0e145625e78b94224d16222ff2609c4621ff6e2c390300e4e6bf698305596792
    Status: Downloaded newer image for mongo:7
    9230ee867d2b2272448f2596ddc19a7f4de5112c99e4dd31b2d7746b28fbc674
    
  2. 我们还将为 MongoDB Node.js 应用程序创建一个目录:

    $ mkdir mongodb-app
    $ cd mongodb-app
    
  3. 在这个菜谱中,我们需要从 npm 注册表中安装模块,因此我们需要使用 $ npm init 初始化我们的项目:

    $ npm init --yes
    
  4. 创建一个名为 index.js 的文件;这将包含与 MongoDB 交互的应用程序代码:

    $ touch index.js
    

现在我们已经启动了数据库并初始化了项目,我们可以继续进行菜谱。

如何做到这一点...

在这个菜谱中,我们将使用 mongodb 模块来演示我们如何与我们的 MongoDB 数据库进行交互。

  1. 首先,安装 mongodb 模块:

    $ npm install mongodb
    
  2. 首先,我们将向 index.js 文件添加逻辑以连接到我们的 MongoDB 数据库:

    const { MongoClient } = require('mongodb');
    const URI = 'mongodb://localhost:27017';
    const client = new MongoClient(URI);
    async function connectToMongoDB () {
      try {
        await client.connect();
        console.log('Connected successfully to server');
        return client.db('Library');
      } catch (err) {
        console.error('Connection to MongoDB failed:',
          err);
      }
    }
    
  3. 接下来,我们将创建一个函数将作者插入到 authors 集合中:

    async function createAuthor (db, author) {
      try {
        const result = await
          db.collection('authors').insertOne(author);
        console.log(`Author created with the following id:
          ${result.insertedId}`);
        return result.insertedId;
      } catch (err) {
        console.error('Create author failed:', err);
      }
    }
    
  4. 创建一个函数将书籍插入到 books 集合中:

    async function createBook (db, book) {
      try {
        const result = await
          db.collection('books').insertOne(book);
        console.log(`Book created with the following id:
          ${result.insertedId}`);
        return result.insertedId;
      } catch (err) {
        console.error('Create book failed:', err);
      }
    }
    
  5. 创建一个函数以查找 authors 集合中的所有作者:

    async function findAllAuthors (db) {
      try {
        const authors = await
          db.collection('authors').find().toArray();
        console.log('Authors:', authors);
        return authors;
      } catch (err) {
        console.error('Find all authors failed:', err);
      }
    }
    
  6. 创建一个函数以查找所有书籍并使用聚合管道填充作者详细信息:

    async function findAllBooksWithAuthors (db) {
      try {
        const books = await
          db.collection('books').aggregate([
          {
            $lookup: {
              from: 'authors',
              localField: 'authorId',
              foreignField: '_id',
              as: 'authorDetails'
            }
          }
        ]).toArray();
        console.log('Books with author details:', books);
        return books;
      } catch (err) {
        console.error('Find all books with authors
          failed:', err);
      }
    }
    
  7. 最后,我们将在 main() 函数中使用 createAuthor()createBook()findAllAuthors()findAllBooksWithAuthors() 函数按顺序执行操作:

    async function main () {
      const db = await connectToMongoDB();
      if (!db) return;
      const authorId = await createAuthor(db, { name:
        'Richard Adams' });
      if (!authorId) return;
      await createBook(db, { title: 'Watership Down',
        authorId });
      await findAllAuthors(db);
      await findAllBooksWithAuthors(db);
      client.close();
    }
    main().catch(console.error);
    
  8. 运行脚本:

    $ node index.js
    

在这个配方中,我们构建了一个 Node.js 脚本,它作为与 MongoDB 数据库交互的功能接口。

它是如何工作的…

在这个配方中,我们首先导入必要的模块,特别是来自 npmmongodb 模块的 MongoClient 类。设置 MongoDB 连接涉及定义一个连接到本地 MongoDB 服务器的 URI,并使用此 URI 初始化一个 MongoClient 实例。在我们的案例中,我们的数据库托管在 MongoDB 的典型默认主机和端口上:mongodb://localhost:27017

注意,当使用 Docker 时,MongoDB 默认不启用身份验证,因此在连接字符串中不需要任何身份验证参数。

connectToMongoDB() 函数异步尝试连接到 MongoDB 服务器,相应地记录成功或失败消息,并在成功时返回指定数据库的引用。

npmmongodb 模块公开了大量的 CRUD 方法,以与 MongoDB 数据库中的 MongoDB 集合进行交互。术语 CRUD 用于表示持久存储的基本功能。在这个配方中,我们使用了 find()insertOne() CRUD 方法。可用方法的完整列表定义在 Node.js MongoDB 驱动程序 API 文档中(mongodb.github.io/node-mongodb-native/6.5/)。

我们还在 findAllBooksWithAuthors() 函数中使用了 aggregate() 方法。聚合管道可以包含一个或多个阶段,以创建一个处理、转换并返回结果的流程。

main() 函数协调执行流程,首先连接到 MongoDB 数据库。连接成功后,它继续为 理查德·亚当斯 创建一个作者文档,以及一个标题为 《风车山》 的相应书籍文档,并将它们关联起来。随后,它使用定义的函数检索所有作者和书籍及其关联的作者详细信息。在整个脚本中,使用 try / catch 块实现错误处理,以处理执行过程中可能出现的任何潜在错误。最后,脚本通过关闭 MongoDB 客户端连接来结束。

总体而言,这个脚本作为一个实用的示例,展示了如何利用 Node.js 和 mongodb 包在 MongoDB 数据库上执行 CRUD 操作,演示了基本功能,如连接到数据库、插入文档、查询集合和有效处理错误。

参见

  • 本章中 连接并持久化到 MySQL 数据库 的配方

  • 本章中 连接并持久化到 PostgreSQL 数据库 的配方

  • 本章中 使用 Redis 持久化数据 的配方

使用 Redis 持久化数据

Redis 是一个开源的内存键值数据存储。在正确的设置下,Redis 可以成为一个高性能的数据存储。它通常用于提供应用程序的缓存,但也可以用作数据库。

Redis,即 远程字典服务器 的缩写,是一个内存数据结构存储,通常用作数据库、缓存和消息代理。它在需要高速和效率的场景中表现出色,如缓存、会话管理、实时分析和消息队列。Redis 支持各种数据结构的能力,结合其原子操作和 发布/订阅pub/sub)消息能力,使其成为增强 Node.js 应用程序性能和可扩展性的强大工具。其内存特性确保了数据的快速访问,与传统基于磁盘的数据库相比,显著降低了延迟,使其适用于速度至关重要的应用程序。

在 Node.js 的上下文中,Redis 特别适用于管理 Web 应用程序中的会话数据,实现快速数据检索并提高用户体验。它也被广泛用于实现缓存机制,减轻数据库负载并加快响应时间。此外,其 pub/sub 消息系统通过允许客户端和服务器之间的高效通信,促进了实时应用程序(如聊天应用程序或实时通知)的开发。无论您是想优化应用程序的性能、高效扩展还是构建功能丰富的实时交互,将 Redis 与 Node.js 集成都提供了一个强大的解决方案来满足这些需求。

准备工作

在深入研究 Redis 模块集成之前,重要的是要注意我们将使用 ESM 以确保兼容性。有关模块的更多信息,请参阅 第五章

  1. 与本章中之前提到的数据库一样,我们将使用 Docker 来部署 Redis 数据库,基于可在 hub.docker.com/_/redis 找到的 Docker 镜像。运行以下命令:

    $ docker run --publish 6379:6379 --name node-redis --detach redis
    

    默认情况下,容器化的 Redis 数据库将在 localhost:6379 上可用。

  2. 我们还将创建一个名为 redis-app 的新文件夹,其中包含一个名为 tasks.mjs 的文件:

    $ mkdir redis-app
    $ cd redis-app
    $ touch tasks.mjs
    
  3. 在这个菜谱中,我们将使用第三方 npm 模块;因此,我们需要初始化我们的项目:

    $ npm init --yes
    

现在我们已经启动了 Redis 并设置了我们的项目,我们可以继续进行下一步了。

如何操作…

在这个菜谱中,我们将使用 redis 模块与我们的 Redis 数据存储进行交互。

  1. 首先安装第三方 redis 模块:

    $ npm install redis
    
  2. 现在,我们需要在 tasks.mjs 中导入并创建一个 Redis 客户端:

    import { createClient } from 'redis';
    const client = createClient();
    
  3. 我们还将接受命令行输入以执行我们的任务:

    const task = process.argv[2];
    
  4. 接下来,我们将添加一个 错误 事件处理器来捕获 Redis 客户端上发生的任何错误:

    client.on('error', (err) => {
        console.log('Error:', err);
    });
    
  5. 我们需要初始化连接:

    await client.connect();
    
  6. 现在,我们将添加一个语句来控制我们程序的流程。如果将任务作为输入传递给我们的程序,我们将添加此任务并列出存储在 Redis 中的任务。如果没有提供任务,则只列出存储的任务:

    if (!task) {
        listTasks();
    } else {
        addTask(task);
    }
    
  7. 在此if语句下方,我们将创建我们的addTask()函数:

    async function addTask(task) {
        const key =
          `Task:${Math.random().toString(32).replace('.',
            '')}`;
        await client.hSet(key, 'task', task);
        listTasks();
    }
    
  8. 最后,在addTask()函数之后,我们将添加我们的listTasks()函数:

    async function listTasks() {
        const keys = await client.keys('Task:*');
        for (const key of keys) {
            const task = await client.hGetAll(key);
            console.log(task);
        }
        client.quit();
    }
    
  9. 现在,我们可以将任务作为命令行输入运行程序。该任务将被存储在 Redis 中,然后通过listTasks()函数打印出来:

    $ node tasks.mjs "Walk the dog."
    { task: 'Walk the dog.' }
    

我们现在已使用redis模块在我们的 Redis 数据存储中持久化了数据。

它是如何工作的…

createClient()方法初始化一个新的客户端连接。此方法将默认配置为连接到localhost:6379上的 Redis 实例,其中6379是 Redis 的传统端口。在npmredis模块的早期版本中,createClient()方法会自动连接到服务器。然而,现在需要显式调用client.connect()来建立连接。

在我们的addTask()函数中,我们生成一个随机字符串或哈希,并将其附加到我们的任务键上。这确保了每个任务键都是唯一的,同时仍然有一个指定器表明它是一个任务,以帮助调试。这是使用 Redis 时的常见约定。

hSet()方法在 Redis 中设置键和值;这正是我们在 Redis 中存储任务的方式。如果我们提供了一个已存在的键,此方法将覆盖其内容。

重要提示

在 Redis 的新版本中,传统的hmset()方法被认为是过时的。在配方中使用的hSet()方法应用于设置哈希值。

listTasks()函数中,我们使用keys()方法搜索所有存储在我们的 Redis 数据存储中且与Tasks:通配符匹配的键。我们正在利用keys()方法列出我们存储在 Redis 中的所有任务。请注意,在实际应用中,keys()*方法应谨慎使用。这是因为,在具有许多键的应用程序中,搜索可能会产生负面的性能影响。

一旦我们有了所有任务键,我们使用hGetAll()方法返回每个键的值。一旦获取,我们使用console.log()将其打印到STDOUT

redis模块提供的npm将所有可用的 Redis 命令进行了一对一的映射。请参阅redis.io/commands以获取 Redis 命令的完整列表。

更多内容…

你交互的 Redis 实例可能需要身份验证。让我们看看如何连接到一个需要密码的 Redis 实例。

使用 Redis 进行身份验证

要连接到需要身份验证的 Redis 客户端,我们可以在createClient()方法中提供凭证。

  1. 我们再次可以使用 Docker 来创建一个密码保护的 Redis 实例。这个 Redis 容器将在localhost:6380上可用:

    $ docker run --publish 6380:6379 --name node-redis-pw --detach redis redis-server --requirepass PASSWORD
    
  2. tasks.mjs 文件复制到一个名为 tasks-auth.mjs 的新文件中:

    $ cp tasks.mjs tasks-auth.mjs
    
  3. 现在,我们需要将新 Redis 实例的配置信息传递给 createClient() 方法:

    import { createClient } from 'redis';
    const client = redis.createClient({
        port: 6380,
        password: 'PASSWORD',
    });
    
  4. 现在,就像之前一样,我们可以通过命令行输入传递任务来运行程序:

    $ node tasks-auth.mjs "Wash the car."
    { task: 'Wash the car.' }
    

注意,因为我们指向的是不同的 Redis 实例,它将不会包含我们在主菜谱中添加的任务。

Redis 事务

redis 模块公开了一个名为 multi() 的方法,可以用来创建 事务。事务是一系列排队并作为单个单元执行的命令。

例如,我们可以使用以下命令作为事务更新任务,通过执行一个 get()set()get() 序列:

import { createClient } from 'redis';
const client = createClient();
client.on('error', (err) => {
  console.log('Error:', err);
});
await client.connect();
await client.set('Task:3', 'Write letter.');
const resultsArray = await client
  .multi()
  .get('Task:3')
  .set('Task:3', 'Mail letter.')
  .get('Task:3')
  .exec();
console.log(resultsArray);
// ['Write letter.', 'OK', 'Mail letter.']
client.quit();

每个任务都会排队,直到执行 exec() 方法。如果任何命令未能排队,则批处理中的所有命令都不会执行。在 exec() 方法期间,所有命令按顺序执行。

参见

  • 本章中的 连接和持久化到 MySQL 数据库 菜谱

  • 本章中的 连接和持久化到 PostgreSQL 数据库 菜谱

  • 本章中的 连接和持久化到 MongoDB 菜谱

探索 GraphQL

GraphQL 作为 API 的查询语言,并提供执行查询的运行时环境。与依赖于严格端点结构的 REST 不同,GraphQL 允许客户端请求他们确切需要的内容,而不需要更多,这使得它对于获取数据非常高效。这种灵活性减少了通过网络传输的数据量,并允许进行更精确和优化的查询。

在那些你的应用程序处理复杂、相互关联的数据结构的项目中,例如社交网络、电子商务平台或 内容管理系统CMSs),GraphQL 能够在单个请求中查询深度嵌套的数据,这使得它与 Node.js 完美匹配。这种组合减少了多个 REST 端点的需求,并最小化了数据过取,优化了网络性能和开发者体验。

准备工作

在本教程中,我们将使用 Fastify 和 Mercurius(npmjs.com/package/mercurius),一个 Fastify 的 GraphQL 适配器,创建一个简单的 GraphQL API,其中包含书籍和作者关系。本教程将指导你设置 Node.js 项目、安装依赖项、定义你的 GraphQL 模式、实现解析器和运行你的服务器。我们将使用一个简单的内存数据结构来模拟作者和书籍的数据库。

在深入使用 Fastify 和 Mercurius 创建 GraphQL API 之前,你需要设置你的开发环境。

  1. 首先为你的项目创建一个新的目录:

    $ mkdir fastify-graphql
    $ cd fastify-graphql
    
  2. 使用 npm 初始化 Node.js 项目:

    $ npm init --yes
    

在你的环境准备就绪且已安装依赖项后,让我们继续到菜谱步骤。

如何做到这一点…

我们现在可以构建 Fastify GraphQL API 的核心功能。这个过程的一部分涉及定义我们的数据模型,设置 GraphQL 模式,编写解析器来处理数据获取,最后启动我们的服务器。

  1. 让我们先安装必要的模块。我们的 GraphQL 服务器需要一些依赖项才能运行。具体来说,我们将使用 Fastify 作为 Web 框架,使用 Mercurius 作为 GraphQL 适配器。通过运行以下命令来安装这些模块:

    $ npm install fastify mercurius
    
  2. 现在,我们需要创建一些模拟数据来工作。这将帮助我们测试 GraphQL API,而无需数据库。在你的项目文件夹中,创建一个名为data.js的文件。此文件将包含作者和书籍的数组,建立它们之间简单的关系,其中每本书都与一个作者相关联:

    $ touch data.js
    
  3. 然后,将以下内容添加到data.js中,以填充一些作者和书籍数据:

    const authors = [
      { id: '1', name: 'Richard Adams' },
      { id: '2', name: 'George Orwell' }
    ];
    const books = [
      { id: '1', name: 'Watership Down', authorId: '1' },
      { id: '2', name: 'Animal Farm', authorId: '2' },
      { id: '3', name: 'Nineteen Eighty-four', authorId:
        '2' },
    ];
    module.exports = { authors, books };
    
  4. 接下来,我们需要创建一个 GraphQL 模式来表示我们的作者和书籍关系以及查询。创建一个名为schema.graphql的文件:

    $ touch schema.graphql
    

    现在,将以下 GraphQL 模式添加到schema.graphql中:

    type Query {
      books: [Book]
      authors: [Author]
    }
    type Book {
      id: ID
      name: String
      author: Author
    }
    type Author {
      id: ID
      name: String
      books: [Book]
    }
    
  5. 现在,创建一个名为resolvers.js的文件。此文件将包含处理数据获取逻辑的函数:

    $ touch resolvers.js
    
  6. 要实现我们的 GraphQL 解析器,将以下代码添加到resolvers.js中:

    const { authors, books } = require('./data');
    const resolvers = {
      Query: {
        books: () => books,
        authors: () => authors,
      },
      Book: {
        author: (parent) => authors.find(author =>
         author.id === parent.authorId),
      },
      Author: {
        books: (parent) => books.filter(book =>
          book.authorId === parent.id),
      },
    };
    module.exports = { resolvers };
    
  7. 最后,我们可以创建我们的 Fastify 服务器:

    $ touch server.js
    

    将以下内容添加到server.js中:

    const fastify = require('fastify')();
    const mercurius = require('mercurius');
    const { readFileSync } = require('node:fs');
    const { resolvers } = require('./resolvers');
    const schema = readFileSync('./schema.graphql', 'utf-
      8');
    fastify.register(mercurius, {
      schema,
      resolvers,
      graphiql: true
    });
    fastify.listen({ port: 3000 }, () => {
      console.log('Server running at
        http://localhost:3000');
    });
    
  8. 启动你的 Fastify 服务器:

    $ node server.js
    
  9. 打开您的浏览器并导航到http://localhost:3000/graphiql以访问 GraphiQL 界面。您应该期望看到一个类似这样的界面:图 7.1 – 显示查询结果的 GraphiQL 界面

图 7.1 – 显示查询结果的 GraphiQL 界面

  1. 尝试在 GraphiQL 界面中构建一些查询。例如,尝试执行以下查询以获取所有书籍及其作者:

     query {
      books {
        name
        author {
          name
        }
      }
    }
    

    期望看到以下输出:

图 7.2 – 显示查询结果的 GraphiQL 界面

图 7.2 – 显示查询结果的 GraphiQL 界面

本教程为您提供了使用 Fastify 和 Mercurius 创建 GraphQL API 的基础。从这里,您可以通过添加更复杂的数据类型、查询和突变来扩展您的 API,或者通过集成数据库来实现持久化存储。

它是如何工作的...

在本教程中,我们通过定义数据模型、建立 GraphQL 模式、实现数据获取的解析器和设置 Fastify 服务器,探讨了使用 Fastify 和 Mercurius 创建 GraphQL API 的过程。

通过在data.js中创建模拟数据,我们模拟了一个包含作者和书籍的后端数据存储。这种方法允许我们专注于 GraphQL 设置,而不必处理集成实际数据库的复杂性。这些数据代表了书籍和它们作者之间基本的关系,为我们 GraphQL 查询提供了基础。

schema.graphql 中定义的 GraphQL 模式充当服务器和客户端之间的合同。它指定了可以进行的查询类型、可以获取的数据类型以及不同数据类型之间的关系。在我们的案例中,该模式概述了如何查询书籍和作者,并指出每本书都与一个作者相关联,反之亦然。这种结构允许客户端理解和预测 API 返回的数据形状。

resolvers.js 中的解析器是处理在模式中指定的每个类型的获取数据逻辑的函数。它们将 GraphQL 查询连接到底层数据,本质上告诉服务器在哪里以及如何检索或修改数据。在本食谱中,解析器从模拟数据中获取书籍和作者,并解决它们之间的关系,例如找到一个书籍的作者或列出一位作者所写的所有书籍。

最后,设置 Fastify 服务器并集成 Mercurius 允许我们在 HTTP 上提供我们的 GraphQL API。服务器监听指定端口上的请求,并使用模式和解析器来处理 GraphQL 查询。

启动服务器后,您可以导航到 GraphiQL 界面,以可视化地构建和执行针对您的 API 的查询。这个交互式环境对于测试和调试查询非常有用。

是否 GraphQL 是您项目的适当架构是一个广泛的话题,它远远超出了本食谱中涵盖的基本内容。它涉及深入考虑,如优化查询性能、确保安全性、高效加载数据以避免过度或不足获取,以及与不同的数据库或 API 集成。虽然我们已经使用 Fastify 和 Mercurius 打下了基础,但深入研究这些更复杂的方面对于开发复杂的、生产就绪的 GraphQL 服务至关重要。

参见

  • 第六章

  • 第十一章

第八章:使用 Node.js 进行测试

测试使您能够快速有效地识别代码中的错误。测试用例应编写为验证每段代码是否产生预期的输出或结果。额外的优势是,这些测试可以作为您应用程序预期行为的文档形式。

单元测试是一种测试类型,其中对代码的各个单元进行测试。小型单元测试为您的程序提供了细粒度的规范,以便进行测试。确保您的代码库被单元测试覆盖有助于开发、调试和重构过程,因为它提供了行为和质量的基准度量。拥有全面的测试套件可以更早地发现错误,从而节省时间和金钱,因为错误发现得越早,修复成本就越低。

本章将首先介绍一些关键技巧,这些技巧包含在 Node.js 最近版本中内置的测试运行器中。我们还将探索一些流行的测试框架。测试框架提供组件和实用工具,例如测试运行器,用于运行自动化测试。本章后面的食谱将介绍其他测试概念——包括存根用户界面UI)测试以及如何配置持续集成CI)测试。

本章将涵盖以下食谱:

  • 使用 node:test 进行测试

  • 使用 Jest 进行测试

  • 模拟 HTTP 请求

  • 使用 Puppeteer

  • 配置 CI 测试

技术要求

本章假设您已安装 Node.js,最好是 Node.js 22 的最新版本。您还需要访问您选择的编辑器和浏览器。在整个食谱中,我们将从公共 npm 仓库安装模块。

食谱的代码可在本书的 GitHub 仓库(github.com/PacktPublishing/Node.js-Cookbook-Fifth-Edition)中的 Chapter08 目录中找到。

使用 node:test 进行测试

Node.js 在版本 18 中引入了内置测试运行器作为实验性功能,随后在版本 20 中将其稳定化。这一添加标志着 Node.js 运行时开发哲学的重大转变,从“小型核心”转向将更多实用工具添加到运行时本身。

包含内置测试运行器的决定受到了更广泛行业趋势的影响,即向编程语言和运行时中包含更多内置工具。这种转变部分是对安全问题的回应,例如与依赖项漏洞相关的风险。通过提供原生测试解决方案,Node.js 旨在使其环境中的测试成为一等公民,减少由第三方测试运行器提供的潜在攻击面。

Node.js 的内置测试运行器没有像 Jest 等许多常见和流行的测试框架那样广泛的 API。它被设计成一个最小化和轻量级的,但功能齐全的测试实用程序,没有额外功能和配置的开销。

本教程将指导你了解使用 Node.js 内置测试运行器的基础知识,演示它如何在不使用第三方测试框架的情况下在你的项目中执行有效的测试。

准备工作

在这个菜谱中,我们将创建并使用一个基本的计算器应用程序来演示使用内置的node:test模块进行单元测试的基本原理。在整个菜谱中,我们将使用在第五章中介绍的ECMAScript ModuleESM)语法。

  1. 让我们先创建一个工作目录并初始化我们的项目目录:

    $ mkdir testing-with-node
    $ cd testing-with-node
    
  2. 创建一个名为calculator.mjs的文件:

    $ touch calculator.mjs
    
  3. 现在,我们可以在calculator.mjs中添加以下内容来创建我们的计算器程序:

    export const add = (number1, number2) => {
        return number1 + number2;
    };
    export const subtract = (number1, number2) => {
        return number1 - number2;
    };
    export const multiply = (number1, number2) => {
        return number1 * number2;
    };
    export const divide = (number1, number2) => {
        return number1 / number2;
    };
    

现在我们已经设置了项目目录并准备好了一个应用程序进行测试,我们可以继续到菜谱步骤。

如何做...

在这个菜谱中,我们将使用在准备就绪部分创建的小型计算器应用程序的内置node:test模块添加单元测试。

  1. 第一步是确保我们使用的是具有node --test命令的 Node.js 版本。在您的终端中输入以下命令,并期望看到测试运行器执行:

    $ node --test
    ℹ tests 0
    ℹ suites 0
    ℹ pass 0
    ℹ fail 0
    ℹ cancelled 0
    ℹ skipped 0
    ℹ todo 0
    ℹ duration_ms 3.212584
    
  2. 现在,我们应该创建一个名为calculator.test.mjs的文件,它将包含我们的测试:

    $ touch calculator.test.mjs
    
  3. calculator.test.mjs中,我们首先需要导入node:test模块:

    import test from 'node:test';
    import assert from 'node:assert';
    
  4. 接下来,我们可以从我们的calculator.js程序中导入add()函数。我们将仅导入和测试add()函数作为示例:

    import { add } from './calculator.mjs';
    
  5. 有时,使用子测试来组织我们的测试是有用的。为了演示这一点,我们将为add()函数创建一个测试父级,稍后我们将添加子测试:

    test('add', async (t) => {
    });
    
  6. 现在,我们可以编写我们的第一个测试用例作为一个子测试。我们的第一个测试将向add()函数传递整数测试值,并确认我们得到预期的结果。添加以下内容:

    test('add', async (t) => {
      await t.test('add integers', () => {
        assert.equal(add(1, 2), 3);
        assert.equal(add(2, 3), 5);
        assert.equal(add(3, 4), 7);
      });
    });
    
  7. 使用终端中的node --test命令运行测试:

    $ node --test
    
  8. 接下来,我们可以添加第二个子测试。这次,我们将以字符串的形式传递数字而不是整数。这个测试预计会失败,因为我们的calculator.mjs程序不包含将字符串输入转换为整数的逻辑。在第一个子测试下方添加以下内容:

    await t.test('add strings', () => {
      assert.equal(add('1', '2'), 3);
    });
    
  9. 现在,我们可以在终端窗口中输入以下命令来运行测试:

    $ node --test
    
  10. 你应该会看到以下输出,表明第一个测试通过,第二个测试失败:

    ▶ add
      ✔ add integers (0.442953ms)
      ✖ add strings (1.909008ms)
        AssertionError [ERR_ASSERTION]: '12' == 3
            at TestContext.<anonymous> (file:///Users/beth/Node.js-Cookbook/testing-with-node/calculator.test.mjs:14:12)
            at Test.runInAsyncScope (node:async_hooks:206:9)
            at Test.run (node:internal/test_runner/test:639:25)
            at Test.start (node:internal/test_runner/test:550:17)
    ...
    

我们学习了如何使用node:test模块为我们的应用程序编写单元测试。我们已经执行了这些测试,并产生了测试结果的Test Anything ProtocolTAP)摘要。

它是如何工作的...

在提供的示例中,我们使用 Node.js 的内置模块node:test,首先使用 ESM 语法导入必要的模块。这包括从node:test导入的test,用于测试框架功能,从node:assert导入的assert,用于断言,以及从本地模块calculator.mjs导入的add()函数,该函数是待测试的函数。

重要提示

使用node:方案前缀导入node:test模块至关重要,如下所示:const test = require('node:test');。这个模块是第一个仅通过node:前缀暴露的模块之一。尝试不使用node:前缀导入它,如const test = require('test');,将会导致错误。

测试是通过test()函数构建的,其中每个测试用例都被封装在一个异步函数中。在每个测试中,使用await t.test(...)定义子测试,这有助于按层次组织测试,并在一个测试块中干净地管理多个断言或设置过程。对于断言条件,使用assert.strictEqual()来比较预期和实际结果,确保类型和值都相等。

Node.js 中的node:assert模块提供了一套断言函数,用于验证不变性,主要用于编写测试。关键断言包括assert.strictEqual(),它检查预期值和实际值之间的严格相等性,以及assert.deepStrictEqual(),它执行对象和数组的深度相等性比较。该模块还提供了assert.ok()来测试一个值是否为真值,以及assert.rejects()assert.doesNotReject()来处理应该或不应该拒绝的承诺。这个断言套件允许开发者强制执行代码中的预期行为和值。有关可用断言的完整列表,请参阅 Node.js assert 模块文档:nodejs.org/docs/latest/api/assert.html#assert

要运行这些测试,可以直接使用 Node.js 在命令行中执行脚本,运行node --test。这种方法直接将测试结果输出到控制台,指示哪些测试已通过或失败。使用 Node.js 的内置测试工具的方法简化了测试过程,消除了对外部库的需求——减少了开销并最小化了第三方依赖。

在配方中,我们使用spec格式输出了测试结果。当使用node:test模块与终端界面TTY)一起使用时,默认输出报告器设置为specspec报告器以人类可读的方式格式化测试结果。如果标准输出不是 TTY,模块默认使用tap报告器,它以 TAP 格式输出测试结果。

可以使用 --test-reporter 命令行标志指定替代测试报告输出。有关可用报告器的详细信息,请参阅 Node.js 文档:nodejs.org/docs/latest-v22.x/api/test.html#test-reporters

更多内容...

为了进一步加深你对核心 node:test 模块的理解,让我们探索测试运行器用于定位和执行测试的默认文件模式,以及简化测试过程的附加功能。

理解 Node.js 默认测试文件模式

Node.js 测试运行器会自动根据文件名查找并运行测试文件,通过查找匹配特定模式的文件来实现——本质上,这些模式是文件是测试的指示器。这些模式使用通配符()和可选组(?(...))来包含各种文件名和扩展名。双星号(*****)表示 Node.js 搜索所有目录和子目录,因此无论测试文件在哪里,只要它们匹配模式,就会被找到。

以下是 Node.js 测试运行器默认搜索的常见模式:

  • */.test.?(c|m)js:这将在任何目录中找到以 .test.js.test.cjs.test.mjs 结尾的文件

  • */-test.?(c|m)js:与第一个模式类似,但用于以 -test.js-test.cjs-test.mjs 结尾的文件

  • */_test.?(c|m)js 捕获以 _test.js_test.cjs_test.mjs 结尾的文件

  • */test-.?(c|m)js 查找以 test- 开头并以 .js.cjs.mjs 结尾的文件

  • **/test.?(c|m)js 匹配名为 test.jstest.cjstest.mjs 的文件

  • */test/**/.?(c|m)js 深入任何 test 目录并找到任何子目录中具有 .js.cjs.mjs 扩展名的文件

为了确保 Node.js 可以找到并运行你的测试而无需额外配置,建议按照这些模式命名你的测试文件。这有助于保持项目组织并符合常见的 Node.js 实践。

过滤测试

使用 node:test 模块,有多个选项可用于过滤测试,以管理在测试运行期间执行哪些测试。这种灵活性在开发或调试期间专注于特定测试时非常有用:

  • 跳过测试:可以使用 skip 选项或测试上下文中的 skip() 方法跳过测试。这对于暂时禁用测试而不从代码库中删除它非常有用。例如,使用 { skip: true } 标记测试或在使用 test() 函数时使用 t.skip() 将阻止其执行:

    test('add strings', { skip : true }, () => {
      assert.equal(add('1', '2'), 3);
    });
    
  • 标记测试为 todo:当一个测试尚未实现或已知为不可靠时,它可以被标记为 todo。这些测试仍然会运行,但它们的失败不会计入测试套件的成功。使用 { todo: true } 选项或 t.todo() 可以有效地注释这些测试。

  • 专注于特定测试:使用 { only: true } 选项来专注于运行特定的测试,跳过所有未标记此选项的其他测试。这在需要隔离测试进行审查而不运行整个测试套件时特别有用。

  • 按测试名称过滤:使用 --test-name-pattern 命令行选项可以根据测试名称进行过滤。当你想运行匹配特定命名模式或约定的测试子集时,这很有用。模式被视为正则表达式。例如,使用 --test-name-pattern="add" 运行测试套件将仅执行名称中包含 "add" 的测试。

收集代码覆盖率

代码覆盖率 是一个关键指标,用于评估在测试期间源代码的执行程度,帮助开发者识别其代码库中未测试的部分。在 Node.js 中,启用代码覆盖率很简单,但请注意,此功能目前是实验性的。

你可以通过使用带有 --experimental-test-coverage 命令行标志启动 Node.js 来启用它。此设置会自动收集覆盖率统计信息,并在所有测试完成后报告。报告不包括 Node.js 核心模块和 node_modules 目录内的文件覆盖率。

可以通过使用注解来控制哪些行包含在代码覆盖率中:

  • / node:coverage disable // node:coverage enable / ,它们可以排除特定行或代码块不被计数

  • / node:coverage ignore next / 用于排除以下行

  • / node:coverage ignore next n / 用于排除以下 n

可以通过内置的报告器如 tapspec 概括覆盖率结果,或通过 lcov 报告器详细展示,该报告器生成适合深入分析的 lcov 文件。

重要提示

当前 --experimental-test-coverage 的实现存在限制,例如缺少源映射支持以及无法从覆盖率报告中排除特定文件或目录。

要收集来自食谱示例的代码覆盖率,你可以运行以下命令:

$ node --test --experimental-test-coverage

预期将看到以下输出:

图 8.1 – 显示 node:test 代码覆盖率报告的终端窗口

图 8.1 – 显示 node:test 代码覆盖率报告的终端窗口

参见

  • 本章的 使用 Jest 进行测试 食谱

  • 本章的 配置持续集成测试 食谱

  • 编写模块代码 食谱在 第五章

使用 Jest 进行测试

Jest 是由 Facebook 开发的一个广泛使用的开源 JavaScript 测试框架。它特别适用于测试 React 应用程序,尽管其多功能性也扩展到 Node.js 环境。Jest 是一个具有众多捆绑功能的具有观点的测试框架。

在本指南中,我们将探讨如何有效地使用 Jest 编写和结构测试。您将学习 Jest 的关键原则以及如何设置您的测试环境。此外,我们将探索 Jest 在衡量和报告测试覆盖率方面的功能,以帮助您了解您的代码库被测试覆盖得有多好。

准备工作

我们将使用 Jest 测试一个提供一些文本实用函数的程序。

  1. 首先,让我们创建和初始化我们的项目目录:

    $ mkdir testing-with-jest
    $ cd testing-with-jest
    $ npm init --yes
    
  2. 我们需要一个程序来测试。创建一个名为 textUtils.js 的文件:

    $ touch textUtils.js
    
  3. 将以下代码添加到 textUtils.js 中:

    function lowercase (str) {
      return str.toLowerCase();
    }
    function uppercase (str) {
      return str.toUpperCase();
    }
    function capitalize (str) {
      if (!str) return str;
      return str.charAt(0).toUpperCase() +
        str.slice(1).toLowerCase();
    }
    module.exports = { lowercase, uppercase, capitalize };
    
  4. 我们还将创建一个名为 textUtils.test.js 的测试文件:

    $ touch textUtils.test.js
    

现在我们已经初始化了目录和文件,我们可以继续进行菜谱步骤。

如何做到这一点…

在这个菜谱中,我们将学习如何使用 Jest 编写和结构各种测试。

  1. 首先,我们需要将 Jest 作为开发依赖项安装:

    $ npm install --save-dev jest
    
  2. 我们还将更新我们的 package.json 文件中的 npm 测试脚本来调用 jest 测试运行器。将 "test" 脚本字段更改为以下内容:

      "scripts": {
        "test": "jest"
      }
    
  3. textUtils.test.js 中,我们首先需要导入我们的 textUtils.js 模块,以便我们可以对其进行测试。将以下行添加到测试文件的顶部:

    const { lowercase, uppercase, capitalize } =
      require('./textUtils');
    
  4. 添加一个 Jest describe() 块。Jest describe() 块用于对测试进行分组和结构。添加以下内容:

    describe('textUtils', () => {
    });
    
  5. describe() 块内,我们可以开始添加我们的测试用例。我们使用 Jest 的 test() 语法来定义每个测试。我们的测试将使用 Jest 的断言语法来验证当我们调用我们的 lowercase()uppercase() 函数时,它们会产生预期的结果。在 describe() 块内添加以下代码以创建三个测试用例:

      test('converts "HELLO WORLD" to all lowercase', ()
        => {
          expect(lowercase('HELLO WORLD')).toBe('hello
            world');
      });
      test('converts "hello world" to all uppercase', ()
        => {
          expect(uppercase('hello world')).toBe('HELLO
            WORLD');
      });
      test('capitalizes the first letter of "hello"', ()
        => {
          expect(capitalize('hello')).toBe('Hello');
      });
    
  6. 现在,我们可以运行我们的测试。我们可以在终端中输入 npm test 命令来运行测试。Jest 将打印出我们的测试结果摘要:

    $ npm test
    > testing-with-jest@1.0.0 test
    > jest
     PASS  ./textUtils.test.js
      textUtils
        ✓ converts "HELLO WORLD" to all lowercase (2 ms)
        ✓ converts "hello world" to all uppercase (1 ms)
        ✓ capitalizes the first letter of "hello"
    Test Suites: 1 passed, 1 total
    Tests:       3 passed, 3 total
    Snapshots:   0 total
    Time:        0.342 s, estimated 1 s
    Ran all test suites.
    
  7. Jest 提供了一个内置的代码覆盖率功能。运行此命令将显示我们的程序中哪些行已被测试用例覆盖。您可以通过将 --coverage 标志传递给 Jest 可执行文件来启用覆盖率报告。在您的终端中输入以下命令以引用已安装的 Jest 可执行文件并报告代码覆盖率:

    $ ./node_modules/jest/bin/jest.js --coverage
    

    期望看到以下输出:

图 8.2 – 显示 Jest 代码覆盖率报告的终端窗口

图 8.2 – 显示 Jest 代码覆盖率报告的终端窗口

注意,代码覆盖率报告指出我们在 textUtils.js 中没有覆盖第 8 行。注意,根据您的代码格式,具体的行号可能会有所不同。有了这些信息,我们可以添加一个测试用例来满足这一行。

  1. 将以下测试用例添加到 textUtils.test.js 中以覆盖缺失的行:

      test('return empty string as it is', () => {
        expect(capitalize('')).toBe('');
      });
    
  2. 现在,你可以使用以下命令重新运行代码覆盖率报告,并期望看到我们的代码现在是 100% 覆盖的:

    $ ./node_modules/jest/bin/jest.js --coverage
    

现在,我们已经使用 Jest 为我们的 textUtils.js 模块创建了一个测试,并学习了如何生成代码覆盖率报告。

它是如何工作的…

我们 textUtils.test.js 文件的第一行导入了我们的 textUtils.js 模块,这使得我们可以在测试时调用它。

我们使用 Jest 的 describe()test() 函数组织了我们的测试。describe() 函数用于定义一组测试。describe() 方法接受两个参数。第一个是测试组的名称,第二个参数是一个回调函数,该函数可以包含测试用例或嵌套的 describe() 块。

Jest 的 test() 语法用于定义测试用例。test() 方法接受两个参数。第一个是测试名称,第二个是包含测试逻辑的回调函数。

这个程序的测试逻辑只有一行,它断言当我们调用 uppercase('hello world') 时,会返回预期的 HELLO WORLD 值。这个断言使用了 Jest 的 Expect 内置断言库 (www.npmjs.com/package/expect)。我们使用了 Expect 库中的 toBe() 断言来比较两个值。

Expect 提供了许多断言方法,包括 toBe()toContain()toThrow() 等。断言的完整列表可以在 Jest 的 API 文档的 Expect 部分中找到 jestjs.io/docs/en/expect.html#methods

还可以通过在我们的语句中添加 .not 来反转断言,如下面的示例所示:

  expect(uppercase('hello')).not.toBe('hello');

要运行我们的测试用例,我们调用位于 node_modules 目录中的 jest 测试运行器。Jest 可执行文件运行测试,自动查找包含 test.js 的文件。运行器执行我们的测试,然后生成结果摘要。

在食谱的最后一步,我们启用了 Jest 的代码覆盖率报告。代码覆盖率是衡量在执行测试时我们的程序代码中有多少行被触发的度量。100% 的代码覆盖率意味着你的程序中的每一行都被测试套件所覆盖。这有助于你轻松地检测由代码更改引入的缺陷。一些开发者和组织为代码覆盖率设定了可接受的阈值,并实施限制,以确保代码覆盖率百分比不会下降。

还有更多……

Jest 提供的功能比其他一些流行的 Node.js 测试库更多 out of the box ( OOTB )。让我们看看其中的一些。

设置和清理

Jest 为测试提供了设置和清理功能。可以使用 beforeEach()beforeAll() 函数分别运行设置步骤,在每次或所有测试之前。同样,可以使用 afterEach()afterAll() 函数分别运行清理步骤,在每次或所有测试之后。

以下伪代码演示了这些函数如何使用:

describe('test', () => {
  beforeAll(() => {
    // Runs once before all tests
  });
  beforeEach(() => {
    // Runs before each test
  });
  afterEach(() => {
    // Runs after each test
  });
  afterAll(() => {
    // Runs after all tests
  });
});

使用 Jest 进行模拟

模拟(Mocks)允许你在不执行代码的情况下测试你的代码或函数的交互。模拟通常用于测试依赖于第三方服务或 API 的情况,你不想在运行测试套件时向这些服务发送真实请求。模拟(Mocking)有一些好处,包括测试套件的执行速度更快,并确保你的测试不会受到网络条件的影响。

Jest 提供了开箱即用的模拟功能。我们可以使用模拟来验证我们的函数是否以正确的参数被调用,而不需要执行该函数。

例如,我们可以将菜谱中的测试更改为使用以下代码模拟 uppercase() 模块:

describe('uppercase', () => {
  test('uppercase hello returns HELLO', () => {
    uppercase = jest.fn(() => 'HELLO');
    const result = uppercase('hello');
    expect(uppercase).toHaveBeenCalledWith('hello');
    expect(result).toBe('HELLO');
  });
});

jest.fn(() => 'HELLO'); 方法返回一个新的模拟函数。我们将这个函数赋值给一个名为 uppercase 的变量。参数是一个返回字符串 'HELLO' 的回调函数——这是为了演示我们如何模拟函数的返回值。

Expect.toHaveBeenCalled() 方法验证我们的模拟函数是否以正确的参数被调用。如果你在测试套件中无法执行某个函数,你可以使用模拟来验证该函数是否以正确的参数被调用。

测试异步代码

测试异步代码对于确保 Node.js 应用程序按预期运行至关重要,尤其是在处理 API 调用、数据库事务或任何依赖于承诺解析或回调的过程时。Jest 提供了一种清晰直接的方式来处理测试中的这些异步操作,确保在做出断言之前它们已经完成。

在 Jest 中测试异步代码最常见的方法是使用 async / await 语法以及 Jest 的 .resolves.rejects 匹配器。例如,考虑一个返回承诺解析到一些数据的 fetchData() 函数:

function fetchData() {
    return new Promise((resolve) => {
        setTimeout(() => resolve('hello'), 1000);
    });
}

你可以编写一个 Jest 测试来验证 fetchData() 是否解析为预期的值:

test('data is hello', async () => {
    await expect(fetchData()).resolves.toBe('hello');
});

这个测试将等待 fetchData() 承诺解析,多亏了 await 关键字,然后检查解析的值是否匹配 'hello'

或者,如果你正在处理使用回调的异步代码,你可以使用 Jest 的 done() 回调来处理这种模式:

function fetchDataCallback(callback) {
    setTimeout(() => { callback('hello');  }, 1000);
}
test('the data is hello', done => {
    function callback(data) {
        try {
            expect(data).toBe('hello');
            done();
        } catch (error) {
            done(error);
        }
    }
    fetchDataCallback(callback);
});

在这个测试中,一旦回调接收到数据,done() 就会被调用一次,向 Jest 信号测试已完成。如果期望中存在错误,使用带有 error 参数的 done() 调用允许 Jest 正确处理错误。

参见

  • 本章中的 配置持续集成测试 菜谱

  • 本章的 编写模块代码 菜谱 第五章

模拟 HTTP 请求

你构建的 Node.js 应用程序通常依赖于并消耗外部服务或 API。在进行单元测试时,你通常不希望你的测试向外部服务发送请求。你消耗的外部服务的请求是计费或速率限制的,你不想你的测试用例消耗这些配额。

也可能你的测试需要访问服务凭证。这意味着项目上的每个开发者都必须在运行测试套件之前访问这些凭证。

为了能够在不向外部服务发送请求的情况下对代码进行单元测试,你可以伪造一个请求和响应。这个概念被称为模拟。模拟可以用来模拟 API 调用,而不发送请求。模拟还有一个额外的优点,即减少任何请求延迟,可能使测试比发送真实请求更快地运行。

模拟和模拟测试的概念经常被混淆。模拟为被测试的单元提供预定义的响应以进行隔离,而模拟也通过确保方法以某些参数被调用来验证交互。

在这个菜谱中,我们将使用 Sinon.js,这是一个提供模拟功能的库。

准备工作

要开始,让我们为这个菜谱设置我们的目录和文件。

  1. 创建一个目录并初始化项目:

    $ mkdir stubbing-http-requests
    $ cd stubbing-http-requests
    $ npm init --yes
    
  2. 现在,我们将创建一个向第三方服务发送请求的程序。创建一个名为 github.mjs 的文件:

    $ touch github.mjs
    
  3. 在我们的 github.mjs 文件中,我们将向 https://api.github.com/users/ 端点发送一个 HTTP GET 请求。将以下内容添加到 github.mjs

    export async function getGitHubUser(username) {
      const response = await
        fetch(`https://api.github.com/users/${username}`);
      return response.json();
    }
    

现在我们有一个向 GitHub API 发送 HTTP 请求的程序,我们可以继续到菜谱步骤,我们将学习如何模拟请求。

如何做到这一点…

在这个菜谱中,我们将学习如何在测试中模拟 HTTP 请求。但首先我们需要创建一个测试用例。我们将使用 node:test 以避免安装额外的测试框架。

  1. 创建一个名为 github.test.mjs 的文件:

    $ touch github.test.mjs
    
  2. 将以下内容添加到 github.test.mjs 中,以创建一个用于 getGithubUser() 函数的测试用例,使用 node:test

    import * as assert from 'node:assert';
    import { test } from 'node:test';
    import { getGitHubUser } from './github.mjs';
    test('Get GitHub user by username', async (t) => {
      const githubUser = await getGitHubUser('octokit');
      assert.strictEqual(githubUser.id, 3430433);
      assert.strictEqual(githubUser.login, 'octokit');
      assert.strictEqual(githubUser.name, 'Octokit');
    });
    
  3. 我们可以运行测试来检查它是否通过:

    $ node --test --test-reporter=tap
    TAP version 13
    # Subtest: Get GitHub user by username
    ok 1 - Get GitHub user by username
      ---
      duration_ms: 279.80306
      ...
    1..1
    # tests 1
    # suites 0
    # pass 1
    # fail 0
    # cancelled 0
    # skipped 0
    # todo 0
    # duration_ms 426.372579
    
  4. 现在,我们可以继续到 模拟。我们首先需要安装 l sinonwww.npmjs.com/package/sinon)作为开发依赖项:

    $ npm install --save-dev sinon
    
  5. 然后,在 github.test.mjs 中,我们需要导入 sinon。在导入 node:test 模块的那行下面添加以下内容:

    import sinon from 'sinon';
    
  6. 为了能够模拟请求,我们需要存储对 GitHub API 的真实请求的输出。在这种情况下,我们将创建一个 fakeResponse 常量,只返回我们正在验证的值。将以下内容添加到测试用例的开始部分:

      const fakeResponse = Promise.resolve({
        json: () => Promise.resolve({
          id: 3430433,
          login: 'octokit',
          name: 'Octokit'
        })
      });
    
  7. 接下来,我们需要添加一行指令,让测试使用模拟的 fetch() 函数而不是真实函数:

      sinon.stub(global, 'fetch').returns(fakeResponse);
    
  8. 在测试用例中调用我们的 getGitHubUser('octokit') 之后,我们应该恢复原始的 fetch() 方法,以便其他测试或代码可以使用它。我们可以通过使用 sinon.restore(); 来实现这一点。将此行添加到调用 getGitHubUser('octokit') 的下方。

  9. 您的完整 github.test.mjs 文件现在应该看起来像以下这样:

    import * as assert from 'node:assert';
    import { test } from 'node:test';
    import sinon from 'sinon';
    import { getGitHubUser } from './github.mjs';
    test('Get GitHub user by username', async (t) => {
      const fakeResponse = Promise.resolve({
        json: () => Promise.resolve({
          id: 3430433,
          login: 'octokit',
          name: 'Octokit'
        })
      });
      sinon.stub(global, 'fetch').returns(fakeResponse);
      const githubUser = await getGitHubUser('octokit');
      sinon.restore();
      assert.strictEqual(githubUser.id, 3430433);
      assert.strictEqual(githubUser.login, 'octokit');
      assert.strictEqual(githubUser.name, 'Octokit');
    });
    
  10. 让我们重新运行测试并检查在模拟请求后它们是否仍然通过:

    $ node --test --test-reporter=tap
    TAP version 13
    # Subtest: Get GitHub user by username
    ok 1 - Get GitHub user by username
      ---
      duration_ms: 2.510738
      ...
    1..1
    # tests 1
    # suites 0
    # pass 1
    # fail 0
    # cancelled 0
    # skipped 0
    # todo 0
    # duration_ms 129.078933
    

注意这次测试运行的 duration_ms 值减少了 – 这是因为我们没有通过网络发送真实请求。

我们现在已经学会了如何使用 Sinon.js 模拟 API 请求。

它是如何工作的…

在配方中,Sinon.js 用于模拟从 GitHub API 获取用户数据的函数的行为。我们不是执行实际的网络请求,这可能会很慢并消耗有限的 API 请求配额,而是用“模拟”替换全局的 fetch() 方法。这个 stub() 函数旨在解析为一个预定的对象,该对象代表 GitHub 用户的资料。

初始时,必要的模块和实用工具被导入:node:assert 用于断言,node:test 用于定义测试用例,以及 sinon 用于创建模拟。我们还导入了计划要测试的 getGitHubUser() 函数。

Sinon.js 用于创建全局 fetch() 函数的模拟。该模拟旨在返回一个类似于实际 GitHub API 预期响应的假响应。这个假响应是一个 promise,它解析为一个具有 json() 方法的对象。这反过来又返回一个解析为包含我们的测试 GitHub 用户 idloginname 属性的对象的 promise – 模仿 GitHub API 响应的格式。

当使用 octokit 用户名调用 getGitHubUser() 时,模拟的 fetch() 函数拦截调用并返回一个假响应。因此,getGitHubUser () 将此响应处理为如果它是一个来自 API 的真实响应,但不会产生网络延迟。在模拟的 API 调用之后,实际的用户对象被等待并检查与预期值是否一致,以确认 getGitHubUser() 函数按预期处理响应。

断言之后,调用 sinon.restore(),这将恢复原始的 fetch() 方法。这确保了后续的测试或其他代码库的其它部分不会受到此测试中 fetch() 方法模拟的影响。这种做法确保了测试的隔离性,并防止了对其他测试的副作用。

这个配方通过演示如何使用 Sinon.js 模拟单个方法,提供了一个对模拟过程的概述。模拟可以用来替换测试系统中的任何部分,从单个函数到整个模块,这在微服务架构中尤其有用,因为服务可能依赖于其他服务的响应。

参见

  • 本章中的 使用 Jest 进行测试 配方

  • 本章中的 配置持续集成测试 配方

使用 Puppeteer

UI 测试是一种用于识别 图形用户界面GUIs)问题的技术,尤其是在网络应用程序中。尽管 Node.js 主要是一个服务器端平台,但它经常被用来开发网络应用程序,其中 UI 测试发挥着关键作用。

例如,如果你有一个包含 HTML 表单的应用程序,你可以使用 UI 测试来验证 HTML 表单是否包含正确的输入字段集合。UI 测试还可以验证与界面的交互,例如模拟按钮点击或超链接激活。

Puppeteer 是一个开源库,它提供了一个无头 Chromium 实例,可以与它进行程序化交互以自动化 UI 测试。由于其原生支持和易于集成,它特别适用于 Node.js 环境。

在这个配方中,我们将使用 Puppeteer (pptr.dev/) 对 http://example.com/ 网站进行 UI 测试。然而,Node.js 中用于 UI 测试的其他流行替代方案包括 Selenium、Cypress 和 Playwright。虽然这些工具的高层次原理和目的相似,但每个工具都有其优势,可以根据特定的需求(如跨浏览器测试、设置简便性和集成能力)进行选择。

准备工作

通过设置一个新的项目目录并创建一个初始测试文件来为 Puppeteer 准备你的开发环境。

  1. 创建一个目录并初始化我们的项目目录:

    $ mkdir using-puppeteer
    $ cd using-puppeteer
    $ npm init --yes
    
  2. 接下来,我们将创建我们的 UI 测试文件:

    $ touch test.js
    

现在我们已经初始化了项目目录,我们准备进入配方步骤。

如何做到这一点...

在这个配方中,我们将学习如何使用 Puppeteer 测试网页。我们将验证我们从 https://example.com 收到预期的内容。我们将使用 Node.js 核心库 assert 进行断言逻辑。

  1. 第一步是安装 puppeteer 模块。我们将把 puppeteer 模块作为开发依赖项安装,因为它只会在测试中使用:

    $ npm install --save-dev puppeteer
    

    注意,这可能需要很长时间,因为它正在下载无头浏览器 Chromium。

  2. 接下来,我们将打开 test.js 并添加以下行以导入 assertpuppeteer 模块:

    const assert = require('node:assert');
    const puppeteer = require('puppeteer');
    
  3. 接下来,我们将创建一个名为 runTest() 的异步函数,它将包含所有的测试逻辑:

    async function runTest() {
    }
    
  4. runTest() 函数中,我们需要启动 Puppeteer。通过添加以下行来实现,该行调用 Puppeteer 的 launch() 函数:

      const browser = await puppeteer.launch();
    
  5. 接下来,也在 runTest() 函数内部,我们需要创建一个新的 Puppeteer 浏览器页面:

      const page = await browser.newPage();
    
  6. 我们现在可以指示 Puppeteer 加载一个 URL。我们通过在 page 对象上调用 goto() 函数来实现这一点:

      await page.goto('https://example.com');
    
  7. 现在我们已经获得了网页的句柄( https://example.com ),我们可以通过调用 Puppeteer 的 \(eval()** 函数来从网页中提取值。我们向 **\)eval() 函数提供了 h1 标签,表示我们想要抽象 h1 元素和一个回调函数。回调函数将返回 h1 元素的 innerText 值。添加以下行以提取 h1 值:

      const title = await page.$eval('h1', (el) =>
        el.innerText);
    
  8. 现在,我们可以添加我们的断言。我们期望标题为 "Example Domain" 。添加以下断言语句。我们还将添加一个 console.log() 语句以输出值——你通常不会在真实测试用例中这样做,以避免在 STDOUT 中产生噪音,但它将帮助我们了解发生了什么:

      console.log('Title value:', title);
      assert.equal(title, 'Example Domain');
    
  9. 我们需要调用 browser.close();否则,Puppeteer 将继续模拟,Node.js 进程将永远不会退出。在 runTest() 函数中,添加以下行:

      browser.close();
    
  10. 最后,我们只需调用我们的 runTest() 函数。将以下内容添加到 test.js 的底部,在 runTest() 函数外部:

     runTest();
    
  11. 我们现在可以运行测试了。在您的终端中输入以下命令以运行测试:

    $ node test.js
    Title value: Example Domain
    

我们现在已经使用 Puppeteer 创建了我们的第一个 UI 测试。

它是如何工作的...

在配方中,我们使用了 Puppeteer 创建了一个测试,以验证 https://example.com 网页在 h1 HTML 元素标签内返回标题 'Example Domain'。大多数 Puppeteer API 都是异步的,因此我们在整个配方中使用了 async / await 语法。

当我们调用 puppeteer.launch() 时,Puppeteer 初始化了一个新的无头 Chrome 实例,我们可以通过 JavaScript 与之交互。由于使用 Puppeteer 进行测试具有无头 Chrome 实例的开销,因此它可能不如其他类型的测试性能好。然而,由于 Puppeteer 在底层与 Chrome 交互,它提供了非常接近最终用户与 Web 应用程序交互的模拟。

一旦启动了 Puppeteer,我们就通过在 browser 对象上调用 newPage() 方法初始化了一个 page 对象。page 对象用于表示一个网页。在 page 对象上,我们随后调用了 goto() 方法,该方法用于告诉 Puppeteer 应为该对象加载哪个 URL。

page 对象上调用 \(eval()** 方法以从网页中提取值。在配方中,我们将 **\)eval() 方法的 h1 作为第一个参数传递。这指示 Puppeteer 识别并提取 HTML

元素。第二个参数是一个回调函数,它提取

元素的 innerText 值。对于 http://example.com ,这提取了 'Example Domain' 值。

runTest() 函数的末尾,我们调用了 browser.close() 方法来指示 Puppeteer 结束 Chrome 模拟。这是必要的,因为 Puppeteer 将继续使用 Node.js 进程永不退出来模拟 Chrome。

这是一个简单的例子,但它为理解 UI 测试自动化工作原理奠定了基础。这个测试脚本很容易扩展,允许模拟更复杂用户交互,如表单提交、导航和错误处理。

还有更多...

也可以在非无头模式下运行 Puppeteer。你可以通过向 launch() 方法传递一个参数来实现:

   const browser = await puppeteer.launch({
        headless: false
    });

在此模式下,当你运行测试时,你会看到 Chromium UI,并且可以在测试执行时跟踪你的测试。这在调试 Puppeteer 测试时可能很有用。

参见

  • 本章中的 使用 Jest 进行测试 菜谱

  • 本章中的 配置持续集成测试 菜谱

配置持续集成测试

持续集成(CI)是一种开发实践,开发者会定期将他们的代码合并到源代码库中。为了保持源代码的完整性,通常在每次代码更改被接受之前都会运行自动化测试。

GitHub 是最广泛使用的源代码仓库托管平台之一。使用 GitHub,当你希望将更改合并到主 Git 分支或仓库时,你将打开一个 pull requestPR)。GitHub 提供了配置每个 PR 应该运行的检查的功能。要求 PR 在接受之前通过应用程序或模块的单元测试是一个常见且良好的实践。

有许多 CI 产品可以启用你的单元测试执行(GitHub Actions、Travis CI 以及许多其他)。这些程序中的大多数都为休闲开发者提供有限的免费层,为企业和企业提供了付费的商业计划。

在这个菜谱中,我们将学习如何配置 GitHub Actions 来运行我们的 Node.js 测试。

准备工作

对于这个菜谱,你需要一个 GitHub 账户。如果你不熟悉 Git 和 GitHub,请参考 第五章 中的 构建模块 菜谱。

为了能够配置 GitHub Actions 来运行单元测试,我们首先需要创建一个 GitHub 仓库和一些示例单元测试。

  1. 通过 github.com/new 创建一个新的 GitHub 仓库。将新仓库命名为 enabling-actions。同时,通过下拉菜单添加 Node .gitignore 模板。

  2. 使用以下命令克隆你的 GitHub 仓库,将 替换为你的 GitHub 用户名:

    $ git clone https://github.com/<username>/enabling-actions.git
    Cloning into 'enabling-actions'...
    remote: Enumerating objects: 3, done.
    remote: Counting objects: 100% (3/3), done.
    remote: Compressing objects: 100% (2/2), done.
    remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
    Receiving objects: 100% (3/3), done.
    
  3. 我们现在需要使用 npm 初始化我们的项目并安装 tape 测试库:

    $ cd enabling-actions
    
  4. 我们还需要创建一个测试。创建一个名为 test.mjs 的文件:

    $ touch test.mjs
    
  5. 将以下内容添加到 test.mjs 中以创建我们的单元测试:

    import { strictEqual } from 'node:assert';
    import { test } from 'node:test';
    test('test integer addition', async (t) => {
      strictEqual(1 + 1, 2, '1 + 1 should equal 2');
    });
    test('test string addition', async (t) => {
      // This test is expected to fail because "11" is not numerically 2
      strictEqual('1' + '1', 2, 'Concatenation of "1" and
        "1" does not equal 2');
    });
    
  6. 现在我们已经初始化了项目并有一些单元测试,我们可以继续配置 GitHub Actions。

如何操作...

在这个菜谱中,我们将学习如何配置 CI,以便在将新更改推送到我们的 GitHub 仓库时运行我们的单元测试。

  1. 我们需要在我们的仓库中创建一个 GitHub Actions 工作流程文件。创建一个 . github/workflows 目录:

    $ mkdir -p .github/workflows
    $ touch .github/workflows/test.yml
    
  2. 将以下内容添加到 test.yml 文件中。这将指示 GitHub Actions 使用 Node.js 20 运行我们的测试。请注意,YAML 文件对空白和缩进都很敏感:

    name: Node.js CI
    on:
      push:
        branches: [ main ]
      pull_request:
        branches: [ main ]
    jobs:
      build:
        runs-on: ubuntu-latest
        strategy:
          matrix:
            node-version: [ 20.x ]
        steps:
        - uses: actions/checkout@v4
        - name: Use Node.js ${{ matrix.node-version }}
          uses: actions/setup-node@v4
          with:
            node-version: ${{ matrix.node-version }}
        - run: node --test
    
  3. 现在,我们已经准备好提交我们的代码。在您的终端中输入以下内容以提交代码:

    $ git add .github/ test.mjs
    $ git commit --message "add workflows and test"
    $ git push origin main
    
  4. 在您的浏览器中导航到 https://github.com//enabling-actions 并确认您的代码已推送到仓库。预期它看起来如下:

图 8.3 – GitHub UI 显示 enabling-actions 仓库中的代码

图 8.3 – GitHub UI 显示 enabling-actions 仓库中的代码

  1. 一旦测试运行完成,GitHub Actions 将指示构建失败。这是故意的,因为我们故意创建了一个预期会失败的测试用例。这通过一个红色的叉号图标表示。当点击此图标时,我们将看到有关测试运行的更多详细信息:

图 8.4 – 失败的 GitHub Actions 构建模态

图 8.4 – 失败的 GitHub Actions 构建模态

  1. 点击 详情,它将带您到该测试运行的 操作 选项卡:

图 8.5 – GitHub Actions 构建日志

图 8.5 – GitHub Actions 构建日志

注意,我们可以看到具体的失败步骤,Run node --test。您应该能够点击每个步骤以展开并查看日志。

我们已经在我们的 GitHub 仓库中成功启用了 GitHub Actions CI。

它是如何工作的…

在 Node.js 应用程序的 GitHub Actions 工作流程配置中,我们概述了一个特定的 CI 流程,该流程旨在在向主分支提交和 PR 时自动化测试。以下是工作流程如何工作的详细分解,其中包含来自 test.yml 文件的代码片段示例。

工作流程从在 YAML 文件中的 on 键下定义事件触发器开始。它被设置为在 pushpull_request 事件上激活,具体针对 main 分支:

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

此代码片段确保任何推送到 main 或对 main 提出任何 PR 的代码都将启动 CI 流程。

接下来,我们定义作业环境和指定要测试的 Node.js 版本,使用矩阵策略。这种方法允许跨多个版本进行测试,增强兼容性验证:

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [20.x, 22.x]

当我们使用测试矩阵跨多个版本进行测试时,我们可能会看到以下类似界面:

图 8.6 – GitHub Actions 作业显示在 Node.js 20 和 22 上的构建

图 8.6 – GitHub Actions 作业显示在 Node.js 20 和 22 上的构建

runs-on: ubuntu-latest 步骤指定作业应在最新可用的 Ubuntu 版本上运行。matrix.node-version 初始设置为测试 Node.js 20,但它被扩展到也包括 Node.js 22,展示了如何轻松地将额外的版本纳入测试策略。

在环境设置之后,工作流程包括以下步骤:检出代码、设置 Node.js、安装依赖项以及运行测试:

    steps:
    - uses: actions/checkout@v4
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
    - run: node --test

actions/checkout@v4 步骤将仓库内容检出至 GitHub Actions 运行器,允许工作流程访问代码。actions/setup-node@v4 步骤配置运行器使用由矩阵定义的特定版本的 Node.js。

通过整合这些步骤,GitHub Actions 工作流程自动化了测试过程,确保所有集成到 main 分支的新代码都通过了严格的测试流程。这不仅确保了代码质量,还有助于在开发周期早期识别问题,使其更容易管理和修复。

GitHub 分支保护

可以配置 GitHub 以阻止合并请求(PR)直到它们通过构建/持续集成(CI)运行。这可以在您的 GitHub 仓库设置中进行配置。有关如何配置分支保护的信息,请参阅docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches

GitHub Actions,就像其他 CI 提供商一样,提供了一个强大且灵活的平台,用于自动化各种开发任务的流程。虽然本教程侧重于为典型的 Node.js 应用程序设置 CI 工作流程,但 GitHub Actions 的范围远不止于此,允许实现多种复杂的流程。

参见

  • 本章的 使用 node:test 进行测试 菜单

  • 第五章搭建模块 菜单

第九章:处理安全问题

在整本书中,我们学习了如何使用 Node.js 来构建应用程序。就像所有软件一样,你必须采取某些预防措施来确保你构建的应用程序是安全的。

首先,你应该确保你已经采用了包含安全修复的任何 Node.js 版本。因此,在可能的情况下,你应该目标是给定 Node.js 版本线的最新版本。

本章将涵盖 Node.js 网络应用程序安全的一些关键方面。后面的菜谱将展示一些常见的网络应用程序攻击,包括 跨站脚本XSS )和 跨站请求伪造CSRF )攻击。它们将展示如何防止和减轻这些攻击的风险。

本章将涵盖以下菜谱:

  • 检测依赖项漏洞

  • 使用 Fastify 进行身份验证

  • 使用 Helmet 强化头部

  • 预测恶意输入

  • 防止 JSON 污染

  • 防止 XSS

  • 防止 CSRF

技术要求

你应该安装了 Node.js,最好是 Node.js 22 的最新版本,并且可以使用你选择的编辑器和浏览器。

在整个菜谱中,我们将从 npm 注册表中安装模块——因此,需要互联网连接。

本章菜谱的代码可在本书的 GitHub 仓库中找到,网址为 github.com/PacktPublishing/Node.js-Cookbook-Fifth-Edition,在 第九章 目录下。

检测依赖项漏洞

在整本书中,我们利用了 npm 注册表中的模块来为我们构建的应用程序打下基础。我们学习了如何庞大的模块生态系统使我们能够专注于应用程序逻辑,而不必反复重新发明常见的解决方案。

这个生态系统是 Node.js 成功的关键。然而,它确实会导致我们的应用程序中出现大型、嵌套的依赖树。我们不仅必须关注我们自己编写的应用程序代码的安全性,还必须考虑我们依赖树中包含的代码的安全性。即使是成熟且流行的模块和框架也可能包含安全漏洞。

在这个菜谱中,我们将演示如何检测项目依赖树中的漏洞。

准备工作

对于这个菜谱,我们将创建一个名为 audit-deps 的目录,以便我们可以安装一些 Node.js 模块:

$ mkdir audit-deps
$ cd audit-deps
$ npm init --yes

我们不需要添加任何额外的代码,因为我们将专注于学习如何使用终端审计依赖项。

如何操作…

在这个菜谱中,我们将从 npm 注册表中安装一些模块,并对它们进行漏洞扫描:

  1. 首先,让我们安装一个旧版本的 express 模块。我们故意选择了一个已知存在漏洞的旧版本来演示如何审计我们的依赖项。这个版本的 Express.js 不建议在生产应用程序中使用:

    $ npm install express@4.16.0
    added 8 packages, removed 3 packages, changed 14 packages, and audited 52 packages in 674ms
    3 high severity vulnerabilities
    To address all issues, run:
      npm audit fix
    Run `npm audit` for details.
    

    注意到 npm 输出检测到 Express.js 此版本中的八个已知漏洞。

  2. 如输出所示,运行 $ npm audit 命令以获取更多详细信息:

    $ npm audit
    
  3. 观察以下截图所示的 $ npm audit 命令的输出。输出列出了个别漏洞,以及更多信息:

图 9.1 – express@4.16.0 的 npm audit 输出

图 9.1 – express@4.16.0 的 npm audit 输出

  1. 我们可以按照控制台输出中指定的 GitHub 链接导航到特定漏洞的咨询页面。这将打开一个网页,详细说明漏洞概述和修复措施:

图 9.2 – 示例 npm 漏洞咨询

图 9.2 – 示例 npm 漏洞咨询

  1. 我们可以使用 $ npm audit fix 命令尝试自动修复漏洞。这将尝试将任何依赖项更新到修复版本:

    $ npm audit fix
    npm WARN audit-deps@1.0.0 No description
    npm WARN audit-deps@1.0.0 No repository field.
    + express@4.17.1
    added 8 packages from 10 contributors, removed 4 packages and updated 17 packages in 1.574s
    fixed 9 of 9 vulnerabilities in 46 scanned packages
    
  2. 现在,当我们再次运行 $ npm audit 命令时,我们将得到以下输出,表明在我们的模块依赖项树中不再检测到任何已知漏洞:

    $ npm audit
    found 0 vulnerabilities
    

通过这样,我们已经学会了如何使用 $ npm audit 来扫描依赖项中的漏洞。

它是如何工作的…

$ npm audit 命令自 npm 版本 6 起就已经可用。该命令会提交我们应用程序中依赖项的报告,并将其与已知漏洞数据库进行比较。$ npm audit 命令将对直接、开发、捆绑和可选依赖项进行审计。然而,它不会审计 peer 依赖项。该命令需要同时存在 package.json 文件和 package-lock.json 文件;否则,它将失败。当使用 $ npm install 命令安装包时,审计将自动运行。

许多组织认为 $ npm audit 是一种预防措施,用于保护他们的应用程序免受已知安全漏洞的侵害。因此,将 $ npm audit 命令添加到您的 持续集成 ( CI ) 测试中是很常见的。当发现漏洞时,$ npm audit 命令会报告错误代码 1;这个错误代码可以用来表示测试失败。

在这个菜谱中,我们使用了 $ npm audit fix 命令来自动将我们的依赖项更新到修复版本。此命令只会将依赖项升级到较新的次要或补丁版本。

如果漏洞仅在新的大版本中修复,npm 将输出警告,表明修复可通过 npm audit fix --force 获取,如下面的截图所示:

图 9.3 – 显示破坏性变更解决的 npm audit 输出

图 9.3 – 显示破坏性变更解决的 npm audit 输出

需要更新到新主要版本的修复将不会由 $ npm audit fix 命令自动修复,因为您可能需要更新应用程序代码以适应依赖项中的破坏性更改。您可以通过使用 $ npm audit fix --force 命令来覆盖此行为并强制 npm 更新所有依赖项,即使它们包含破坏性更改。然而,在破坏性更改的情况下,审查单个模块漏洞并逐个手动更新模块可能是明智的。

在某些情况下,可能没有可用的依赖项修补版本。在这种情况下,npm 将通知您需要进行手动审查。在手动审查期间,尝试确定您的应用程序是否容易受到漏洞的影响是值得的。某些漏洞可能仅适用于某些 API 的使用,因此如果您在应用程序中没有使用这些 API,您可能可以排除特定的漏洞。如果漏洞适用于您的应用程序且没有可用的依赖项修补版本,您应考虑在可能的情况下,在您的应用程序的 node_modules 中修补它。实现这一点的常见方法是从 npm 使用 patch-package (www.npmjs.com/package/patch-package) 模块。

重要提示

通常,保持您应用程序的依赖项尽可能最新是值得的,以确保您拥有最新的可用错误和安全修复。例如,Dependabot (dependabot.com/) 等工具可以通过在 GitHub 上自动化更新来帮助保持您的依赖项最新。

注意,npm audit 通过将您的依赖项树与已知漏洞数据库进行比较来工作。npm audit 返回没有已知漏洞并不意味着您的依赖项没有漏洞——您的树中可能存在未报告或未知的漏洞。还有提供模块依赖项漏洞审计服务的商业服务。其中一些,如 Snyk (snyk.io/),维护自己的弱点与漏洞数据库,这些数据库可能包含不同的已知问题集,以审计您的依赖项。

使用 npm audit 时还有其他选项可供选择,以便您可以根据自己的需求进行定制:

  • --audit-level : 允许您指定 npm audit 应报告的最小漏洞级别。这些级别包括 infolowmoderatehighcritical

  • --dry-run : 模拟修复漏洞的操作,而不应用任何更改。

  • --force : 强制更新有漏洞的依赖项,绕过某些检查,例如依赖项兼容性检查。此选项应谨慎使用,因为它可能导致依赖项冲突或在你项目中引入破坏性更改。

  • --json : 以 JSON 格式输出审计结果。

  • --package-lock-only : 将审计限制在 package-lock.jsonnpm-shrinkwrap.json 文件中定义的项目依赖项,而不需要实际安装。

  • --no-package-lock : 在审计过程中忽略项目的 package-lock.jsonnpm-shrinkwrap.json 文件。当您想审计 node_modules 目录的状态时,这可能很有用。

  • --omit--include : 允许您配置在审计过程中要排除或包含的依赖类型(开发、可选或同行依赖)。

还有更多...

除了使用 npm audit 之外,您还可以利用 GitHub 的 Dependabot 来增强您项目的安全性并保持依赖项更新。Dependabot 自动化检查漏洞并创建拉取请求以更新您的依赖项。它持续监控您的项目依赖项,并在检测到任何漏洞时提醒您。Dependabot 可以自动打开拉取请求以更新过时的依赖项到最新版本。

通过将 Dependabot 集成到您的 GitHub 仓库中,您可以确保您的项目与最新的安全补丁和更新保持同步,从而降低潜在漏洞的风险。请参阅 docs.github.com/en/code-security/dependabot 了解 GitHub 关于启用和使用 Dependabot 的指南。

参见

使用 Fastify 进行身份验证

许多网络应用程序都需要登录系统。通常,网站的用户有不同的权限,为了确定他们可以访问哪些资源,他们必须首先通过身份验证进行识别。

这通常是通过设置会话来实现的,会话是用户和设备之间的临时信息交换。会话使服务器能够存储特定于用户的信息,这些信息可用于管理访问并在多个请求之间维护用户的状态。

在本食谱中,我们将为 Fastify 服务器实现一个身份验证层。请参阅 第六章 以获取有关 Fastify 的更多信息。

准备工作

让我们从创建一个 Fastify 服务器开始:

  1. 创建一个名为 fastify-auth 的项目目录以进行工作,并使用 npm 初始化项目。我们还将创建一些文件和子目录,我们将在本食谱的后续部分中使用它们:

    $ mkdir fastify-auth
    $ cd fastify-auth
    $ npm init --yes
    $ mkdir routes views
    $ touch server.js routes/index.js views/index.ejs
    
  2. 我们还需要安装几个模块:

    $ npm install fastify @fastify/view @fastify/formbody ejs
    
  3. 将以下代码添加到 server.js 文件中。这将配置一个初始的 Fastify 服务器,我们将对其进行扩展:

    const fastify = require('fastify')({ logger: true });
    const path = require('path');
    const view = require('@fastify/view');
    const fastifyFormbody = require('@fastify/formbody');
    const indexRoutes = require('./routes/index');
    fastify.register(fastifyFormbody);
    fastify.register(view, {
      engine: {
        ejs: require('ejs')
      },
      root: path.join(__dirname, 'views')
    });
    fastify.register(indexRoutes);
    const start = async () => {
      try {
        await fastify.listen({ port: 3000 });
        fastify.log.info(`Server listening on
          ${fastify.server.address().port}`);
      } catch (err) {
        fastify.log.error(err);
        process.exit(1);
      }
    };
    start();
    
  4. 将以下内容添加到 routes/index.js 中以创建一个基础路由器,该路由器将处理 / 上的 HTTP GET 请求:

    async function routes(fastify, options) {
        fastify.get('/', async (request, reply) => {
          return reply.view('index.ejs');
        });
      }
    module.exports = routes;
    
  5. 将以下内容添加到 views/index.ejs 中以创建一个 嵌入式 JavaScriptEJS)模板。目前,这只是一个简单的欢迎页面模板:

    <html>
        <head>
            <title>Authentication with Fastify</title>
        </head>
        <body>
            <h1>Authentication with Fastify</h1>
            <% if (typeof user !== 'undefined' && user) {
              %>
            <p>Hello <%= user.username %>!</p>
            <p><a href="/auth/logout">Logout</a></p>
            <% } else { %>
            <p><a href="/auth/login">Login</a></p>
            <% } %>
        </body>
    </html>
    
  6. 使用以下命令启动服务器,并在浏览器中导航到 http://localhost:3000

    $ node server.js
    

您应该会看到一个标题为 Authenticating with Fastify 的网页。使用 Ctrl + C 停止服务器。

现在我们有一个简单的 Fastify 服务器,我们可以开始实现身份验证层。

如何做到这一点...

在这个菜谱中,我们将使用 @fastify/cookie@fastify/session 模块将登录系统添加到我们的 Fastify 服务器中:

  1. 首先安装模块:

    $ npm install @fastify/cookie @fastify/session
    
  2. 我们将创建一个单独的路由器来处理身份验证,以及一个包含我们的 HTML 登录表单的 EJS 模板。现在让我们创建这些文件:

    $ touch routes/auth.js views/login.ejs
    
  3. 现在,让我们使用 EJS 模板创建我们的 HTML 登录表单。HTML 表单将有两个字段:用户名密码。此模板期望传递一个名为 fail 的值。当 fail 值为 true 时,将渲染 登录失败 的消息。将以下代码添加到 views/login.ejs

    <html>
      <head>
        <title>Authentication with Fastify - Login</title>
      </head>
      <body>
        <h1>Authentication with Fastify - Login</h1>
        <% if (fail) { %>
        <h2>Login Failed.</h2>
        <% } %>
        <form method="post" action="login">
          Username: <input type="text" name="username" />
          Password: <input type="password" name="password"
            />
          <input type="submit" value="Login" />
        </form>
      </body>
    </html>
    
  4. 现在,我们需要构建我们的身份验证路由器。我们将在 routes/auth.js 文件中完成此操作。身份验证路由器将包含 /login/logout 端点的路由处理程序。/login 端点的 HTTP POST 处理程序将接收并解析表单数据(用户名和密码)以验证用户凭据。将以下内容添加到 routes/auth.js 中以创建身份验证路由器:

    const users = [{ username: 'beth', password:
      'badpassword' }];
    async function routes (fastify, options) {
      fastify.get('/login', async (request, reply) => {
        return reply.view('login.ejs', { fail: false });
      });
      fastify.post('/login', async (request, reply) => {
        const { username, password } = request.body;
        const user = users.find((u) => u.username ===
          username);
        if (user && password === user.password) {
          request.session.user = { username: user.username };
          await request.session.save();
          return reply.view('index.ejs', { user:
            request.session.user });
        } else { return reply.view('login.ejs', { fail:
          true }); }
      });
      fastify.get('/logout', async (request, reply) => {
        request.session.destroy((err) => {
          if (err) { return reply.send(err); }
          else { return reply.redirect('/'); }
        });
      });
    }
    module.exports = routes;
    
  5. 接下来,我们需要更新我们的 routes/index.js 文件,以便我们可以将用户数据从会话传递到 EJS 模板:

    async function routes (fastify, options) {
      fastify.get('/', async (request, reply) => {
        const user = request.session.user;
        return reply.view('index.ejs', { user: user });
      });
    }
    module.exports = routes;
    
  6. 现在,将 @fastify/cookie@fastify/session 的导入添加到 server.js 文件中的其他导入旁边:

    ...
    const view = require('@fastify/view');
    const fastifyFormbody = require('@fastify/formbody');
    const fastifyCookie = require('@fastify/cookie');
    const fastifySession = require('@fastify/session');
    ...
    
  7. 导入 auth 路由器:

    const indexRoutes = require('./routes/index');
    const authRoutes = require('./routes/auth');
    ...
    
  8. 使用以下配置注册插件:

    fastify.register(fastifyCookie);
    fastify.register(fastifySession, {
      secret: 'a secret with minimum length of 32
        characters',
      cookie: {
        httpOnly: true
      },
      saveUninitialized: false,
      resave: false
    });
    
  9. 注册 authRoutes

    fastify.register(indexRoutes);
    fastify.register(authRoutes, { prefix: '/auth' });
    
  10. 使用以下命令启动服务器:

    $ node server.js
    
  11. 在浏览器中导航到 http://localhost:3000。预期会看到以下网页:

图 9.4 – 描述“使用 Fastify 进行身份验证”的网页

图 9.4 – 描述“使用 Fastify 进行身份验证”的网页

  1. 点击 登录;您将被导向 HTML 登录表单。提供一个随机的用户名和密码,然后点击 登录。由于这不符合我们的硬编码值,我们预计会看到 登录失败 的消息。

  2. 让我们尝试硬编码的值。提供一个用户名 beth 和密码 badpassword,然后点击 登录。登录过程应该是成功的。您将被重定向回 / 端点,在那里将显示 Hello beth! 消息。

  3. 最后,让我们尝试注销。点击 注销 链接。这应该将您重定向回相同的端点,但 Hello beth! 消息将被移除,因为会话已经结束。

这个配方介绍了 @fastify/cookie@fastify/session 模块,以及我们如何使用它们来构建简单的登录功能。现在,让我们看看它是如何工作的。

它是如何工作的...

在这个配方中,我们使用 @fastify/cookie@fastify/session 模块构建了一个登录系统。

首先,我们在 Fastify 应用程序中(在 server.js 文件中)导入并注册了 @fastify/session 插件。这个插件将一个会话对象注入到每个请求对象中。在用户认证之前,会话值将是一个空对象。

在注册 @fastify/session 插件时,我们提供了以下配置选项:

  • 密钥:用于签名会话 ID 糖果,确保其完整性并防止篡改。出于安全考虑,它必须至少有 32 个字符长。

  • Cookie.httpOnly:配置会话糖果。请注意,httpOnly: true 使得糖果对客户端 JavaScript 不可访问,增强了安全性。

  • SaveUninitialized:防止将未修改的会话保存到存储中,减少存储使用并提高性能。

  • Resave:防止重新保存未更改的会话,减少对会话存储的无需要写操作。

完整的配置选项列表可在 @fastify/session API 文档中找到,网址为 github.com/fastify/session?tab=readme-ov-file#api

在这个配方的演示应用程序中,网页上的登录超链接将用户重定向到 /auth/login 端点。这个端点的路由处理器是在一个单独的认证路由器(routes/auth.js)中声明的。这个路由渲染包含 HTML 登录表单的 views/login.ejs 模板。

当用户在表单中输入用户名和密码并点击 提交 时,浏览器将值编码并设置为请求体。我们的 HTML 表单将方法设置为 HTTP POSTmethod="post"),这指示浏览器在表单提交时发送 HTTP POST 请求。我们的 HTML 表单中的 action 属性设置为 login,这指示浏览器将 HTTP POST 请求发送到 / auth/login 端点。

routes/auth.js 中,我们为 /login 端点的 HTTP POST 请求注册了一个处理器。这个处理器从请求体中提取用户名和密码,并检查它们是否与我们的硬编码用户数组中的任何用户匹配。如果凭证有效,它将在会话中保存用户信息,并使用用户数据渲染 index.ejs 模板。

如果用户名和密码不匹配,我们的 HTTP POST /auth/login路由处理程序将渲染带有{ fail : true }值的views/login.ejs模板。这指示views/login.ejs模板渲染登录失败消息。

重要提示

不要在生产应用程序中以纯文本形式存储密码!你通常会验证提供的用户名和密码与存储在安全数据库中的凭据相匹配,密码以散列形式存储。请参阅此食谱中关于使用bcrypt散列的There’s more…部分。

当认证过程成功时,我们将req.session.user的值设置为提供的用户名,并将认证用户重定向回/端点。在此阶段,@fastify/session中间件创建一个会话标识符,并在请求上设置Set-Cookie HTTP 头。Set-Cookie头设置为会话键名和会话标识符。

@fastify/session插件默认使用进程内存储机制来存储会话令牌。然而,这些令牌不会过期,这意味着我们的进程将继续被越来越多的令牌填充。这最终可能导致性能下降或使我们的进程崩溃。再次强调,在生产环境中,你通常会使用会话存储。@fastify/session插件基于express-session兼容的会话存储列表,请参阅github.com/expressjs/session#compatible-session-stores

当请求被重定向到/时,它现在已设置了Set-Cookie HTTP 头。@fastify/session中间件识别会话键名并提取会话标识符。从这个标识符中,@fastify/session可以查询会话存储以获取任何关联的状态。在这种情况下,状态是我们将分配给req.session对象的用户对象,在auth.js中完成。

req.session.user的值传递给更新的views/index.ejs模板。此模板包含逻辑,当存在req.session.user值时,它将渲染Hello beth!字符串。模板中的逻辑还会根据用户是否认证来在显示登录注销链接之间切换。

点击注销会向/auth/logout端点发送 HTTP GET请求。此端点将req.session设置为null,结束会话并从会话存储中删除会话数据。我们的浏览器可能会继续存储并发送无效的会话 cookie,直到它过期,但由于会话存储中没有有效的匹配项,服务器将忽略会话并认为用户未认证。

更多内容...

以下部分将涵盖安全的会话 cookie 以及如何散列密码的简单示例。

会话 cookie 可以被标记为Secure属性。Secure属性强制浏览器不使用 HTTP 将 cookie 发送回服务器。这是为了避免中间人攻击MITM)。在生产应用中,应使用 HTTPS 和安全的 cookie。但在开发中,使用 HTTP 更容易。

在生产环境中,通常在负载均衡器层应用 SSL 加密。负载均衡器是应用架构中的一个技术,它通过将一系列任务分配到一系列资源来提高应用的效率——例如,将登录请求分配到服务器。

我们可以配置我们的 Fastify 服务器通过 HTTP 与负载均衡器通信,但仍然使用适当的 cookie 设置支持Securecookie。在生产中,cookie 的Secure选项应设置为 true。

使用 bcrypt 进行哈希

密码绝不应该以明文形式存储,而应该以哈希形式存储。密码通过哈希函数转换为哈希形式。哈希函数使用算法将值转换为不可识别的数据。这种转换是单向的,意味着从哈希中确定原始值是不太可能的。网站将通过将提供的密码应用于哈希函数并与存储的哈希值进行比较来验证用户的密码输入。

哈希通常与一种称为盐化的技术结合使用。盐化是指在生成哈希之前将一个唯一的值(称为盐)附加到密码上。这有助于防止暴力攻击,并使破解密码更加困难。

bcryptwww.npmjs.com/package/bcrypt)是一个流行的模块,用于在 Node.js 中哈希密码。以下示例演示了如何使用bcrypt模块生成带有盐的哈希:

  1. 首先,创建并初始化一个名为hashing-with-bcrypt的目录:

    $ mkdir hashing-with-bcrypt
    $ cd hashing-with-bcrypt
    $ npm init --yes
    $ touch hash.js validate-password.js
    
  2. 接下来,安装bcrypt模块:

    $ npm install bcrypt
    
  3. 我们的应用程序将期望密码作为参数提供。将以下内容添加到hash.js中,以提取参数值:

    const password = process.argv[2];
    
  4. 接下来,在hash.js中导入bcrypt模块:

    const bcrypt = require('bcrypt');
    
  5. 现在,我们必须定义盐的轮数。在这里,bcrypt将使用指定的轮数生成盐。轮数越多,哈希越安全。然而,它也会使你的应用生成和验证哈希的时间更长。在这个例子中,我们将盐的轮数设置为10

    const saltRounds = 10;
    
  6. 接下来,我们需要调用bcrypt模块的hash()方法。我们向此方法提供纯文本密码、盐的轮数以及一旦生成哈希就执行的回调函数。我们的回调将使用conosle.log()输出密码的哈希形式。将以下内容添加到hash.js中:

    bcrypt.hash(password, saltRounds, (err, hash) => {
      if (err) {
        console.error('Error hashing password:', err);
        process.exit(1);
      } else {
        console.log(hash);
      }
    });
    

    在实际应用中,你会在回调函数中包含你的逻辑,以便将哈希值持久化到数据库中。

  7. 使用以下命令运行程序。你应该期望生成一个唯一的哈希值:

    $ node hash.js 'badpassword'
    $2b$10$7/156fF/0lyqzB2pxHQJE.czJj5xZjN3N8jofXUxXi.UG5X3KAzDO
    

    每次运行此脚本时,都会生成一个新的唯一哈希值。

  8. 接下来,让我们学习如何验证密码。我们将创建一个程序,该程序期望密码和哈希值作为参数。程序将使用 bcrypt.compare() 方法比较密码和哈希值:

    const password = process.argv[2];
    const hash = process.argv[3];
    const bcrypt = require('bcrypt');
    bcrypt
      .compare(password, hash)
      .then((res) => {
        console.log(res);
      })
      .catch((err) => console.error(err.message));
    

    注意,当密码和哈希值匹配时,res 将为 true,不匹配时为 false

  9. 运行 validate-password.js 程序。第一个参数应该是你提供给 hash.js 程序的相同密码。第二个参数应该是 hash.js 程序创建的哈希值:

    $ node validate-password.js 'badpassword' '$2b$10$7/156fF/0lyqzB2pxHQJE.czJj5xZjN3N8jofXUxXi.UG5X3KAzDO'
    true
    

    注意,参数值应该用单引号括起来,以确保保留字面值。

这演示了我们可以如何使用 bcrypt 模块来创建哈希值,以及如何验证一个值与现有哈希值是否匹配。

参见

  • 第六章使用钩子实现身份验证 菜谱中

  • 本章的 防止跨站脚本攻击 菜谱

  • 本章的 防止跨站请求伪造 菜谱

使用 Helmet 加固头部

Express.js 是一个轻量级 Web 框架,因此通常为了更好地保护应用程序而采取的一些措施并没有在核心框架中实现。我们可以采取的一项预防措施是在请求上设置某些与安全相关的 HTTP 头部。有时,这被称为 加固 我们 HTTP 请求的头部。

Helmet 模块(github.com/helmetjs/helmet)提供了一个中间件,用于在我们的 HTTP 请求上设置与安全相关的头部,从而节省手动配置的时间。Helmet 将 HTTP 头部设置为合理的默认安全值,然后可以根据需要扩展或自定义。在这个菜谱中,我们将学习如何使用 Helmet 模块。

准备工作

我们将扩展一个 Express.js 应用程序,使其能够使用 Helmet 模块。因此,首先,我们必须创建一个基本的 Express.js 服务器:

  1. 创建一个名为 express-helmet 的目录,并使用 npm 初始化项目。我们还将安装 express 模块:

    $ mkdir express-helmet
    $ cd express-helmet
    $ npm init --yes
    $ npm install express
    
  2. 创建一个名为 server.js 的文件:

    $ touch server.js
    
  3. 将以下代码添加到 server.js 中:

    const express = require('express');
    const app = express();
    app.get('/', (req, res) => res.send('Hello World!'));
    app.listen(3000, () => {
      console.log('Server listening on port 3000');
    });
    

现在我们已经创建了一个基本的 Express.js 应用程序,我们可以继续进行完成这个菜谱的步骤。

如何做到这一点...

在这个菜谱中,我们将学习如何使用 Helmet 模块来加固我们的 Express.js 应用程序的 HTTP 头部:

  1. 首先,启动 Express.js 网络服务器:

    $ node server.js
    
  2. 现在,让我们检查我们的 Express.js 应用程序返回的头部信息。我们可以使用 cURL 工具来完成此操作。在第二个终端窗口中,输入以下命令:

    $ curl -I http://localhost:3000
    
  3. 你应该会看到一个类似以下响应,列出了请求返回的 HTTP 头部信息:

    HTTP/1.1 200 OK
    X-Powered-By: Express
    Content-Type: text/html; charset=utf-8
    Content-Length: 12
    ETag: W/"c-Lve95gjOVATpfV8EL5X4nxwjKHE"
    Date: Mon, 01 Jul 2024 02:19:46 GMT
    Connection: keep-alive
    Keep-Alive: timeout=5
    

    注意X-Powered-By: Express头信息。

  4. 现在,让我们使用helmet模块开始强化这些头信息。使用以下命令安装helmet模块:

    $ npm install helmet
    
  5. 我们需要在app.js文件中导入helmet中间件。通过在express导入下方添加以下行来完成此操作:

    const helmet = require('helmet');
    
  6. 接下来,我们需要指导 Express.js 应用程序使用helmet中间件。在const app = express();行下方,添加以下内容:

    app.use(helmet());
    
  7. 现在,重新启动服务器:

    $ node server.js
    
  8. 再次发送cURL请求:

    $ curl -I http://localhost:3000
    
  9. 在这一点上,我们可以看到请求返回了许多额外的头信息:

    HTTP/1.1 200 OK
    Content-Security-Policy: default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests
    Cross-Origin-Opener-Policy: same-origin
    Cross-Origin-Resource-Policy: same-origin
    Origin-Agent-Cluster: ?1
    Referrer-Policy: no-referrer
    Strict-Transport-Security: max-age=15552000; includeSubDomains
    X-Content-Type-Options: nosniff
    X-DNS-Prefetch-Control: off
    X-Download-Options: noopen
    X-Frame-Options: SAMEORIGIN
    X-Permitted-Cross-Domain-Policies: none
    X-XSS-Protection: 0
    Content-Type: text/html; charset=utf-8
    Content-Length: 12
    ETag: W/"c-Lve95gjOVATpfV8EL5X4nxwjKHE"
    Date: Mon, 01 Jul 2024 02:21:22 GMT
    Connection: keep-alive
    Keep-Alive: timeout=5
    

    注意,X-Powered-By 头信息已被移除。

通过这些,我们已经将helmet中间件添加到我们的 Express.js 服务器中,并观察到了它对我们请求返回的 HTTP 头信息所做的更改。

它是如何工作的...

helmet模块根据其安全默认值配置我们请求的一些 HTTP 头信息。在本例中,我们将helmet中间件应用于我们的 Express.js 服务器。

helmet模块移除了X-Powered-By: Express头信息,这使得发现服务器是基于 Express 变得更加困难。我们这样做的原因是为了保护攻击者尝试利用 Express.js 相关的安全漏洞,减缓他们确定应用程序中使用的服务器类型的速度。

在这一点上,helmet将以下头信息注入到我们的请求中,包括适当的默认值:

Header 描述
Content-Security-Policy 通过允许定义一个策略来控制用户代理可以加载哪些资源,从而帮助减轻 XSS 攻击
Cross-Origin-Opener-Policy 确保顶层文档只能与同一源文档进行交互
Cross-Origin-Resource-Policy 限制资源,使其只能由同一源文档访问
Origin-Agent-Cluster 确保文档在单独的代理集群中隔离,以防止不同源之间的数据泄露
Referrer-Policy 控制从网站发送的请求中包含的引用信息量
Strict-Transport-Security 指示浏览器仅允许使用 HTTPS 访问网站
X-Content-Type-Options 指示配置在Content-Type头信息中的 MIME 类型必须遵守
X-DNS-Prefetch-Control 控制 DNS 预取
X-Download-Options 禁用在下载时打开文件选项
X-Frame-Options 指示浏览器是否可以在
posted @ 2025-10-11 12:57  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报