Node-编程入门指南-全-
Node 编程入门指南(全)
原文:Get Programming with Node.js
译者:飞龙
第 0 单元:准备工作
在我向你介绍如何将 Node.js 作为 Web 开发平台使用之前,你需要准备你的环境(你将在其上进行开发的计算机)。在本单元中,你将安装所有开始使用 Node.js 所需的工具。这些工具帮助你编写代码,使你的应用程序能够工作并在互联网上运行。到本单元结束时,你将安装所有开始编码和运行 Node.js 应用程序所需的一切。为了实现这一目标,第 0 单元涵盖了以下主题:
-
第 0 课讨论了本书中你将要学习的内容及其重要性。我向你介绍了 Node.js,提供了一些背景信息,并讨论了为什么它是一个好的 Web 开发平台。本课还涵盖了你可以从本书中期待得到的内容。我谈到了一些先决条件和你在努力构建健壮的 Web 应用程序时需要注意的事项。
-
第 1 课将指导你安装每个工具和库的安装过程,以便开始下一单元。尽管本课的重点是安装 Node.js,但将你的计算机设置为开发环境需要更多步骤。
-
第 2 课介绍了你的第一个 Node.js 应用程序和一些测试,以确保你的计算机上运行着兼容的 Node.js 版本。
我首先谈谈 Node.js。
第 0 课:设置 Node.js 和 JavaScript 引擎
在本课中,你将了解本书中将要学习的内容及其重要性。无论你是 Web 开发的新手还是一个希望构建更好应用的资深开发者,本课都作为进入 Node.js 世界的入门指南。
本课涵盖
-
回顾你将要学习的内容
-
理解 Node.js
-
学习为什么我们在 Node.js 中开发
-
为本书做好准备
0.1. 你将要学习的内容
本书的目标是教会你如何在名为 Node.js 的平台使用 JavaScript 语言构建 Web 应用程序。从本课开始,每个单元都旨在在前一单元的概念和开发技能基础上进行扩展。
在完成每个课时过程中,你将掌握新的 Web 开发概念、术语和编码技能,这些技能将帮助你构建 Web 应用程序。尽管本书围绕使用 Node.js 展开,但以下单元中教授的许多概念也适用于其他主要平台和编程语言。
注意
Web 开发技能与典型的软件工程或计算机理论知识不同。除了教授编码概念外,本书还帮助解释了互联网在你的项目之外是如何工作的。我会尽我所能解释,使事情变得更容易理解。
下面是每个单元你将要学习内容的概述:
-
单元 0 提供了您开始所需的背景知识,并指导您安装 Node.js 和开发工具。
-
单元 1 涵盖了一些基本的网络开发概念,并提供了构建您的第一个 Node.js 网络应用程序的指导步骤。
-
单元 2 介绍了 Express.js,这是大多数 Node.js 开发者用来构建应用程序的 Web 框架。您将了解 Express.js 提供了什么、它是如何工作的以及您可以如何定制它。在本单元中,您还将了解模型-视图-控制器 (MVC) 应用程序架构模式。
-
单元 3 指导您将应用程序连接到数据库。本单元还帮助您安装一些新工具,并使用 MongoDB 结构化您的数据库。
-
单元 4 教您在应用程序中构建数据模型,其中 CRUD 操作用于从数据库中创建、读取、更新和删除数据。
-
单元 5 帮助您构建代码以在面向对象的结构中表示用户账户。在本单元中,您将了解如何保护您的数据并为新用户构建登录表单。
-
单元 6 介绍了构建应用程序编程接口 (API)。您将了解 API 的构成、如何保护它以及如何使用 REST 架构来设计它。
-
单元 7 邀请您将实时聊天系统集成到您的应用程序中。本单元介绍了轮询、WebSockets 以及使用 Socket.io 库广播数据,该库是主流应用程序用来更快、更高效地将数据传递给用户所使用的库。
-
单元 8 指导您完成部署过程。本单元帮助您设置必要的工具和账户。
首先,让我们谈谈 Node.js 究竟是什么。
0.2. 理解 Node.js
Node.js 是一个用于解释 JavaScript 代码并运行应用程序的平台。JavaScript 已经存在了几十年;随着每一次的改进,它越来越远离客户端脚本语言,成为了一个全功能的后端编程语言,用于管理数据。
由于 Node.js 是用 Google Chrome 的 JavaScript 引擎(一种将 JavaScript 语言解释为有意义的计算机命令的工具)构建的,因此被认为功能强大,能够支持 JavaScript 作为服务器端语言。JavaScript 可以用于协助网页(客户端)交互,并处理传入的应用程序数据和数据库通信。(这些工作通常留给 C、Java、Python 和 Ruby 等其他语言)。现在,开发者可以致力于掌握 JavaScript 来构建完整的 Web 应用程序,而不是需要掌握多种语言来完成相同任务。
客户端与服务器端
作为一般概述,Web 开发可以大致分为两大类:
-
客户端—(前端)指的是你编写的代码,它导致用户在浏览器中看到的东西。客户端代码通常包括用于在网页加载时动画化用户体验的 JavaScript。
-
服务器端—(后端)指的是用于应用程序逻辑(数据如何组织并保存到数据库)的代码。服务器端代码负责在登录页面上验证用户,运行计划任务,甚至确保客户端代码到达客户端。
在下面的图中,客户端代表用户可能查看应用程序的浏览器。服务器是应用程序运行和处理用户提交的任何数据的地方。此外,在客户端期望的情况下,服务器通常渲染用户界面。
注意
在本书中使用的“应用程序”一词指的是用编程语言编写的计算机程序,并在计算机上运行。本书侧重于用 JavaScript 编写并在 Node.js 上运行的 Web 应用程序。

客户端-服务器交互
你会在应用程序开发中经常听到这两个术语的使用,并且由于 JavaScript 已被用于这两种类型的开发,这两个世界之间的界限正在消失。全栈开发,使用 JavaScript,定义了这种新开发,其中 JavaScript 在服务器和客户端以及之前不存在的设备、硬件和架构上使用。
Node.js 在单个线程上使用事件循环。线程是执行程序任务所需的计算能力和资源的集合。通常,线程负责启动和完成任务;需要同时运行的任务越多,所需的线程就越多。在大多数其他软件中,多个任务由计算机可以同时提供的线程池匹配和处理(并发)。然而,Node.js 一次只处理一个任务,并且只为那些无法由主线程处理的任务使用更多的线程。
这个过程可能听起来有些反直觉,但在大多数不需要计算密集型任务(需要大量计算机处理能力的任务)的应用程序中,这个单线程可以快速管理和执行所有任务。请参见图 0.1 中事件循环的简化图。当任务准备运行时,它们会进入队列,等待事件循环的特定阶段进行处理。
图 0.1. Node.js 事件循环的简化模型

如其名所示,Node.js 的事件循环在循环中永无止境地运行,监听由服务器触发的 JavaScript 事件,以通知某些新任务或其他任务的完成。随着任务数量的增加,任务会排队等待事件循环逐步处理。尽管如此,你编写代码时并不需要考虑这个事实。你通过使用异步约定来编写代码,而 Node.js 架构会在幕后为你安排任务处理。因此,Node.js 因其能够创建持续监听数据来回传输的实时应用而变得流行。
你可以将事件循环想象成一个办公室经理。办公室经理的职责是处理收到的消息、工作分配和办公室相关任务。办公室经理可能有一长串待完成的任务,从委派创建完整的财务报告到接听电话和布置办公室派对装饰。由于一些任务所需时间比其他任务长,办公室经理在处理新任务之前并不一定要完成任何单个任务。例如,如果她正在为派对做准备,电话响了,她可以停止布置去接电话。更好的是,她可以接听电话,并将来电者转接到另一位员工那里,这样她就可以回去布置了。
类似地,事件循环处理一系列任务,总是同时处理一个任务,并使用计算机的处理能力来卸载一些较大的任务,同时事件循环缩短任务列表。在大多数其他平台上,新任务会被分配给新的进程,为每个任务创建一个新的事件循环。然而,增加任务数量就像在有限的空间内增加员工数量一样。你开始遇到新问题,如成本、计算能力和共享资源。(例如,如果两个员工需要同时使用电话,你会怎么办?)
进程和线程
重要的是要注意,Node.js 事件循环依赖于单个线程来管理所有任务,但它并不一定只使用该线程来运行每个任务直到完成。实际上,Node.js 被设计为将较大的任务传递给宿主计算机,计算机可能会创建新的线程和进程来操作这些任务。
一个 线程 是用于在任务中运行一系列指令分配的计算机资源。通常,线程处理的任务简单且快速。因此,Node.js 事件循环只需要一个线程来作为所有其他任务的经理。线程通过计算机进程提供,一些更密集的任务需要它们自己的进程来运行。
一个 进程 也是一个用于任务执行的计算机资源和能力的集合,尽管通常用于比线程处理的任务更大的任务。必须存在一个进程来创建一个线程,这意味着每个 Node.js 应用程序都在自己的进程中运行。
尽管 Node.js 可能是单线程的,但你可以在并行运行多个进程实例的同时处理传入的请求和任务。因此,Node.js 具有良好的可扩展性;它异步调度任务,仅在必要时才使用额外的线程和进程,而不是为每个任务生成新的进程。随着需要处理的任务列表增加,对计算机的需求也会增加。Node.js 最好用于最小化并发进程的数量。
你可能会听到 线程 和 进程 这两个术语一起出现。对于这本书,你只需要知道 Node.js 在任何给定时间都依赖于单个任务处理器。有关 Node.js 中线程和进程的更多信息,请阅读关于 Node.js 可扩展性的文章medium.freecodecamp.org/node-js-child-processes-everything-you-need-to-knowe69498fe970a。
在这本书中,我进一步探讨了 Node.js 在构建 Web 应用程序方面的某些优势。然而,在深入探讨之前,让我们谈谈为什么 Node.js 有益。
快速检查 0.1
Q1:
真或假:Node.js 的事件循环在处理下一个任务之前会先完成每个任务的执行。
QC 0.1 答案
1:
错误。Node.js 的事件循环按顺序从队列中移除任务,但它可能会将任务卸载到应用程序正在运行的机器上处理,或者在处理新任务的同时等待某些任务的完成。
0.3. 为什么学习在 Node.js 中开发?
很可能你已经选择了这本书,希望成为一名更好的程序员并构建 Web 应用程序,这也是你使用 Node.js 并提高 JavaScript 编程技能的主要原因。
许多其他选项,如 Ruby on Rails 和 PHP,可以帮助你构建一个对用户来说与 Node.js 应用程序无法区分的应用程序。考虑以下学习 Node.js 的原因:
-
你可以专注于 JavaScript 作为开发的核心语言,而不是在保持应用程序运行的同时平衡多种语言。
-
如果你想要连续流式传输数据或有一些聊天功能,Node.js 在其他平台之上获得了显著的关注。
-
Node.js 由 Google 的 V8 JavaScript 解释器支持,这意味着它得到了广泛的支持,预计性能和功能将不断增长,并且不会很快消失。访问
node.green/以查看 Node.js 每个版本的受支持功能。 -
Node.js 在 Web 开发社区中获得了很大的流行。你很可能会遇到并从那些可能已经使用 Node.js 开发了长达五年的其他开发者那里获得支持。此外,现在为 Node.js 构建的支持性、开源工具比其他较老的平台更多。
-
对于具有具体 JavaScript 技能的开发者,有更多的工作机会。当你了解 Node.js 时,你可以申请前端或后端开发职位。
如果你作为一个新程序员试图进入 Web 开发世界,或者如果你以前开发过软件,正在寻找大家都在谈论的新事物,Node.js 是你的首选平台,而这本书是你的指南。
0.4. 为本书做准备
从本书的第一个单元开始,你将通过在 Node.js 中构建一个基本的 Web 服务器的过程来了解 Web 开发。随着本书的进展,你将向你的应用程序添加代码以完成一个健壮的 Web 应用。
为了准备好学习这些新主题,请确保你仔细阅读每一节课,并亲自编写所有代码示例。如果你养成复制粘贴代码的习惯,你可能会遇到一些错误;更重要的是,你不会学习到概念。
因为 JavaScript 是本书的重要先决条件,如果你在完成任务时遇到困难,请在网上搜索最佳实践和其他常见解决方案。在整个书中,你将找到练习和“快速检查”问题来测试你的知识。(你在第二部分完成了你的第一个“快速检查”。)从第 3 课开始,每节课的结尾都会有一个名为“尝试这个”的部分,你可以练习在课程中较早提出的某些编码概念。
每个单元末尾的练习和综合项目标志着你在创建一个功能齐全的 Web 应用道路上的里程碑。
将每个单元视为一个课程主题,将每节课视为一次讲座。你可能发现有些课程在理解或应用代码方面比其他课程花费的时间更长。请耐心一些,但也要通过重复和实践不断构建你的开发技能。
本书的目标是让你在构建一个像在综合课程中构建的 Web 应用时感到舒适。在这些综合课程中,你为名为 Confetti Cuisine 的公司构建一个 Web 应用,该公司提供烹饪课程,并允许用户注册、连接和相互讨论食谱。尝试遵循综合课程的指南,并在第一次尝试后重新做部分或全部项目。
小贴士
考虑将练习题做三次。第一次,按照指南进行;第二次,参考指南中的内容进行练习;第三次,在没有任何帮助的情况下独立完成。到第三次时,你将对涉及的概念有一个具体的理解。
本书中的大多数练习都要求你使用电脑的终端(命令行)。Node.js 是一个跨平台工具——这意味着它可以在 Windows、Mac 和 Linux 机器上运行——但我在本书中从 UNIX 的角度来教授它。Windows 用户可以使用内置的命令行来运行 Node.js,但可能会发现一些终端命令有所不同。因此,我建议 Windows 用户安装 Git Bash,这是一个可以让你使用 UNIX 命令并跟随本书所有示例的终端窗口。然而,你仍然可以使用 Node.js 安装包中附带的控制台环境完成很多事情。有关安装 Git Bash 的信息,请访问git-scm.com/downloads。
在完成每个单元后,回顾自上次综合练习以来的进步。到第 7 单元结束时,你将使用 Node.js 构建一个完整的网络应用程序。
我会在过程中提醒你以下事项,但你应该在阅读本书的过程中记住它们:
-
源文件是用 JavaScript 编写的,并具有.js 文件扩展名。
-
书中每个示例使用的主要应用程序文件被称为 main.js,除非另有说明。
-
我建议使用最新的 Google Chrome 浏览器来运行需要网络浏览器的书练习。你可以从
www.google.com/chrome/browser/下载该浏览器。
在我的课程中,我会尽力解释与 Node.js 学习体验相关的术语和概念。然而,如果你需要关于书中提到的任何主题的更多信息,你可以参考以下资源:
-
《HTML5 动作》由 Rob Crowther、Joe Lennon、Ash Blue 和 Greg Wanish 著(Manning, 2014)
-
《深度解析 CSS》由 Keith J. Grant 著(Manning, 2018)
-
《你不知道的 JS:入门》(
github.com/getify/You-Dont-Know-JS),由 Kyle Simpson 著(O’Reilly Media, 2015) -
《ES6 动态实践》(
www.manning.com/livevideo/es6-in-motion),由 Wes -Higbee 著
摘要
在本课中,我的目标是教你了解这本书的结构,Node.js 是什么,以及为什么它很重要。我还谈到了你应该如何对待这本书。如果你将这本书视为一个包含子主题和讲座的课程,你将逐步构建你的知识和技能,直到成为一个合格的网络开发者。在下一课中,你将安装你需要开始编码的工具。
第 1 课. 配置你的环境
在本课中,您将安装开始使用 Node.js 构建应用程序所需的所有工具。您将安装一个与最新 JavaScript ES6 更新兼容的 Node.js 版本。接下来,您将安装一个文本编辑器——您将通过它编写应用程序的代码。最后,您将通过使用名为 REPL 的 Node.js 沙盒环境,从计算机的命令行终端对 Node.js 进行测试运行。
本课涵盖
-
安装 Node.js
-
安装文本编辑器
-
设置版本控制和部署工具
-
在终端中使用 Node.js REPL
1.1. 安装 Node.js
Node.js 的流行度和支持度正在增长。因此,新的下载版本被频繁部署,了解最新版本可能如何影响您构建的应用程序非常重要。在本写作时,要下载的 Node.js 版本是 11.0.0 或更高版本。
注意
Node.js 8.8.1 版本的发布带来了对 ES6 语法的支持。ES6(ECMAScript 2015)是 JavaScript 的一次近期更新,它改进了变量、函数和面向对象代码的语法。为了跟上 JavaScript 的更新,随着您开发的进展,下载 Node.js 的最新稳定版本。
您有几种方法可以下载和安装 Node.js,所有这些方法都在 Node.js 主网站上列出,nodejs.org。
由于 Node.js 是平台无关的,您可以在 Mac、Windows 或 Linux 计算机上下载并安装它,并期望获得完整的功能。
安装 Node.js 最简单的方法是访问 nodejs.org/en/download/ 上的下载链接,并按照说明和提示下载最新版本 Node.js 的安装程序 (图 1.1)。
图 1.1. Node.js 安装程序页面

Node 版本管理器
或者,您可能想使用 Node.js 版本管理器 (NVM) 来处理您的 Node.js 安装并管理计算机上的一或多个 Node.js 版本。使用版本管理器的优点是,您可以在发布新版本的同时测试 Node.js 的新版本,同时如果出现兼容性问题,还可以安装更旧、更稳定的版本。您可以在 github.com/creationix/nvm 上找到安装说明,或者在 UNIX 机器上按照以下步骤操作:
-
在新终端窗口中运行
curl -o https://raw.githubusercontent.com/creationix/nvm/v0.33.8/install.sh | bash。在安装完成后,您可能需要退出并重新启动终端。 -
在终端窗口中运行
nvm list以查看您的计算机上是否已安装任何版本的 Node.js。 -
在终端中运行
nvm ls-remote以检查可安装的 Node.js 版本。 -
在终端中运行
nvm install 11.0.0以安装当前版本的 Node.js。 -
在终端中运行
node -v以验证你是否已安装了 9.3.0 版本。
如果你熟悉通过 NVM 安装 Node.js 并且没有图形界面来引导你完成过程,这个设置就适合你。安装完成后,不要使用本课中的另一组说明再次安装 Node.js。
| |
注意
NVM 不支持 Windows。你可以使用两种替代版本管理器之一:nvm-windows 和 nodist,你可以通过分别遵循github.com/coreybutler/nvm-windowsandhttps://github.com/marcelklehr/nodist中的说明来安装。
当你安装 Node.js 时,你也会得到 npm,这是 Node.js 的外部库生态系统(其他人编写的多个代码文件),可以导入到你的未来项目中。npm 与 Python 中的 pip 和 Ruby 中的 gem 类似。你可以在单元 1 中了解更多关于 npm 的信息。
当安装程序文件下载完成后,在你的浏览器下载面板或电脑的下载文件夹中双击文件。安装程序打开一个新窗口,看起来像图 1.2,并将所有必要的文件和核心 Node.js 库写入到你的系统中。你可能需要接受许可协议或给予安装程序在电脑上安装 Node.js 的权限。按照提示点击通过安装过程。
图 1.2. Node.js 向你的机器写入

终端和你的 PATH
你将主要在电脑的终端中工作,终端是内置软件,用于在没有图形界面的情况下导航和运行计算机上的命令。本书教授使用 UNIX 终端(Bash)命令。对于 Windows 用户,可以通过使用 Windows 的 CMD 终端窗口来跟随教程(但可能需要在书中查找命令等效项)。你可以参考access.redhat.com/documentation/en-US/Red_Hat_Enterprise_Linux/4/html/Step_by_Step_Guide/ap-doslinux.html中的表格,该表格比较了 Windows 和 UNIX 命令。为了在 Windows 上更容易操作,你可以从git-scm.com/downloads下载并安装一个名为 Git Bash 的额外 Bash 终端。
记下你的 Node.js 和 npm 版本在你的机器上的安装位置。这些信息出现在安装程序的最后一个窗口中。安装程序尝试将这些目录位置添加到你的系统 PATH 变量中。
PATH 是一个 环境变量——一个可以设置以影响机器上操作行为的变量。你的电脑的 PATH 变量指定了执行系统操作所需的目录和可执行文件的位置。
这个变量的值是终端首先查找开发中使用的资源的第一个位置。将 PATH 变量想象成你电脑的索引,可以快速找到所需的工具。当你将这些工具的原始文件路径或目录位置添加到 PATH 变量中时,终端将不会有任何问题找到它们。
下图显示了终端如何通过 PATH 变量来识别特定程序和可执行文件的目录,因为这些目录可能在不同计算机上的不同位置。如果你在终端中启动 Node.js 时遇到任何问题,请遵循www.tutorialspoint.com/nodejs/nodejs_environment_setup.htm上的安装步骤。

带有 PATH 变量的终端功能
现在你已经安装了 Node.js,请使用终端确保一切安装正确。打开终端(或 Git Bash),并在提示符下输入以下命令:node -v。
此命令的输出应显示你安装的 Node.js 版本。同样,你可以在命令提示符下运行命令 npm -v 来检查你安装的 npm 版本。
注意
如果你的终端响应错误或没有任何输出,可能表明你的 Node.js 安装不成功。如果出现错误,尝试将错误复制粘贴到搜索引擎中查找常见解决方案。否则,重复本节中的步骤。
现在你已经安装了 Node.js 并且终端正在运行,你需要一个地方来编写你的代码。
小贴士
如果你忘记了 Node.js 或 npm 的安装位置,你可以在命令窗口中打开,并在提示符下输入 which node 或 which npm 来查看相应的位置。在 Windows 命令行提示符中,使用 where 代替 which。
1.2. 安装文本编辑器
文本编辑器 是你在开发应用程序时用来编写代码的软件应用。尽管文本编辑器有多种形式,也可以用来创建非代码文件,但为开发者设计的文本编辑器通常预装了有用的工具和插件。
对于这本书,我推荐下载并安装 Atom 文本编辑器,这是一个用于多种编程语言的免费开源软件应用。Atom 由 GitHub 开发,并提供了许多用 Node.js 编写的附加插件。Atom 将帮助你轻松编写 Node.js 应用程序。
按照以下步骤安装 Atom:
-
在你的浏览器中,访问
atom.io。 -
点击下载链接。
-
按提示在 Mac、Windows 或 Linux 计算机上安装软件。
安装完成后,打开你计算机上应用程序所在的文件夹。从那里,你可以通过双击程序文件来启动 Atom 编辑器。
小贴士
你可能对在集成开发环境(IDE)中编写代码感兴趣。例如,Visual Studio Code (code.visualstudio.com/) 等 IDE 提供了诸如编辑器内的终端窗口、代码自动完成和项目调试器等有用的工具。
在你的文本编辑器就绪后,测试一些 Node.js 终端命令。
1.3. 设置版本控制和部署工具
在本节中,你将设置 Git 和 Heroku 命令行界面(CLI),你将在本书的末尾使用它们将你的应用程序部署到网上。部署是一个术语,用来描述你的应用程序从你的电脑迁移到可以公开访问和使用的位置。软件配置管理(SCM)是在新功能和对代码进行更改时,管理你的应用程序在不同环境中的过程。你可以使用 Git 和 Heroku CLI 一起将你的代码从开发部署到生产,并管理你的应用程序。
Git是一个版本控制工具,用于分离应用程序代码的各个演变层。它允许你在开发的各个阶段保存或拍摄代码的快照,这样如果你发现最新的更改破坏了应用程序的功能,你可以快速回到一个工作状态。对于本书来说更重要的是,你需要 Git 将你的代码版本发送到 Heroku,这样人们就可以开始在互联网上使用你的应用程序了。
如果你有一台 Mac,Git 可能已经安装好了。如果你在 Windows 机器上安装了 Git Bash,Git 也应该已经打包并安装了。如果你不确定你是否安装了 Git,你可以在终端窗口中输入git --version。除非你的窗口响应 Git 版本号,否则你应该直接从git-scm.com/downloads下载它。选择你的操作系统,如图 1.3 所示。下载的文件会打开一个图形界面,通过该界面你可以在你的机器上安装 Git。
图 1.3. 从下载页面安装 Git

当 Git 安装完成后,你可以在终端中使用它,通过在项目中初始化git init来使用它。然后,你可以通过运行git add后跟文件的相对路径来将单个项目文件添加到你的新版本中。你也可以通过运行git add来添加项目中的所有文件。(包括命令中的句号)。为了确认这些文件,运行git commit -m “some message”,其中引号内的消息描述了你所做的更改。如果你熟悉 Git,我建议你在运行本书中的代码时使用它。否则,你直到第 8 单元才需要它。你可以在git-scm.com/doc的视频和文档中了解更多关于 Git 的使用方法。
小贴士
要获取 Git 命令的有用速查表,请访问education.github.com/git-cheat-sheet-education.pdf。
Heroku 是一个用于在线托管应用程序的服务。要使用 Heroku,您需要在signup.heroku.com创建一个新账户。在必填字段中输入您的姓名和其他信息,并验证您的电子邮件地址。账户创建后,Heroku 允许您免费上传三个应用程序。最好的部分是您可以直接从终端完成所有工作。
接下来,您需要安装 Heroku CLI。在 Mac 上,您可以使用 Homebrew 进行安装。要安装 Homebrew,请在终端窗口中运行列表 1.1 中显示的命令。此安装过程在brew.sh/中有所描述。
列表 1.1. 在 Unix 计算机上使用终端安装 Homebrew
/usr/bin/ruby -e "$(curl -fsSL
https://raw.githubusercontent.com/Homebrew/install/master/
install)" *1*
- 1 在终端窗口中运行安装命令
运行brew install heroku/brew/heroku或下载安装程序devcenter.heroku.com/articles/heroku-cli#macos。对于 Windows,您可以在devcenter.heroku.com/articles/heroku-cli#windows找到安装程序。Linux 用户可以通过在终端中运行sudo wget -q0- https://toolbelt.heroku.com/install-ubuntu.sh | sh来安装 Heroku CLI。如果您使用图形化安装程序,可以逐步通过默认设置和提示。
当 Heroku CLI 设置完成后,您可以在终端中使用 heroku 关键字。此设置过程的最后一部分是从终端登录您的 Heroku 账户。输入heroku login,然后输入您用于设置 Heroku 账户的电子邮件地址和密码。您已准备好部署到 Heroku。
1.4. 在终端中使用 Node.js REPL
在本节中,您将通过 Node.js REPL 环境从终端开始使用 Node.js。Node.js 交互式外壳是 Node.js 版本的 Read-Evaluate-Print Loop (REPL)。此外壳是一个空间,您可以在其中编写纯 JavaScript,并在终端窗口中实时评估您的代码。在窗口内,您的代码被 Node.js 读取和评估,并将结果打印回控制台。在本节中,我将探讨您在 REPL 中可以执行的一些操作。
您已经使用终端检查了 Node.js 是否正确安装。另一种查看安装是否成功的方法是键入node并按 Enter 键。当您看到终端提示符更改为>时,您就会知道此命令已成功。要退出此提示符,请键入.exit或按 Ctrl-C 两次。
一些特定于 Node.js 的关键字允许你的终端和 REPL 环境理解你何时正在运行 Node.js 命令。在附录 A 中,我讨论了 Node.js 中的关键字以及它们如何与应用程序开发相关。
注意
如果你需要更多关于终端命令的练习,请参阅 Steven Ovadia 所著的《一个月午餐时间学会 Linux》的第二部分(Manning,2016 年)。
你可以通过在终端窗口中输入node关键字并跟随任何文本来进入 REPL。当你被提示>时,你可以在 JavaScript 中输入一个命令。尽管这个环境是为测试和沙盒代码保留的,但 Node.js 壳在开发中可以提供很多好处。例如,你可以输入并评估简单的数学表达式,或者你可以执行完整的 JavaScript 语句。你还可以在这里存储值并在自定义类中实例化对象。有关一些示例 REPL 交互,请参阅列表 1.2。
在这些代码示例中,我展示了书中出现的一些 JavaScript ES6 语法。除了在 REPL 壳中运行的基本算术运算外,我还使用let关键字设置了一个变量。这个关键字允许我定义一个作用域限于代码块的变量。这些块包括函数块,其中var定义的变量具有作用域,以及条件块和循环。
我还使用新的类语法来定义一个对象。这里的语法类似于面向对象编程语言,但主要作为现有 JavaScript 原型结构的包装器。
列表 1.2. REPL 命令示例
$ node *1*
>
> 3 + 3 *2*
6
> 3 / 0
Infinity
> console.log("Hello, Universe!"); *3*
Hello, Universe!
> let name = "Jon Wexler";
> console.log(name);
Jon Wexler
> class Goat { *4*
eat(foodType) {
console.log(`I love eating ${foodType}`);
}
}
> let billy = new Goat();
> billy.eat("tin cans");
I love eating tin cans
-
1 进入 REPL。
-
2 执行基本命令和表达式。
-
3 将消息记录到控制台。
-
4 创建 ES6 类并实例化对象。
在 REPL 环境中,你可以访问 Node.js 附带的所有核心模块。"核心模块"是与你的 Node.js 安装一起提供的 JavaScript 文件。我在单元 1 中更多地讨论了模块。你很快就会在自己的自定义应用程序中看到,你需要导入一些模块才能在 REPL 中使用它们。有关 REPL 中可用的命令的简短列表,请参阅表 1.1。
表 1.1. 需要记住的 REPL 命令
| REPL 命令 | 描述 |
|---|---|
| .break (或 .clear) | 退出 REPL 会话中的块,这在陷入代码块时非常有用 |
| .editor | 打开一个内部编辑器,让你编写多行代码。ctrl-d 保存并退出编辑器 |
| .exit | 退出 REPL 会话 |
| .help | 列出其他命令和有用的提示,帮助你在这个交互式 shell 环境中感到舒适 |
| .load | 后跟一个本地文件名;使 REPL 能够访问该文件的代码 |
| .save | 后跟一个你选择的新文件名;将你的 REPL 会话代码保存到文件中 |
通过运行你已知的 JavaScript 命令来探索 REPL。在下节课中,你将学习如何将先前编写的代码导入 REPL。
总结
在本课中,你安装了 Atom 文本编辑器和 Node.js。你还通过在 REPL 中运行一些命令来验证你的 Node.js 环境是否准备好评估 JavaScript 代码。在下节课中,你将学习如何使用 Node.js 和终端来构建和启动应用程序。
第 2 课:运行 Node.js 应用程序
在本课中,你使用 Node.js 编写并运行你的第一个 JavaScript 文件。最后,我将向你展示如何将 JavaScript 文件导入 REPL,以便你可以使用预先编写的代码。
本课涵盖
-
创建和保存一个 JavaScript 文件
-
使用 Node.js 运行你的 JavaScript 文件
-
将文件加载到 REPL 中
考虑以下内容
你正在测试你用 JavaScript 编写的某些代码。假设这段代码是以下代码片段中显示的函数,它接受一个数字数组并将它们打印到屏幕上。
注意
在这个代码示例中,我使用 ES6 语法将变量 printNumbers 赋值给一个使用单个 arr 参数和箭头符号代替传统 function 关键字的函数。我在 forEach 调用内部使用另一个箭头函数作为回调函数。
let printNumbers = arr => { *1*
arr.forEach(num => console.log(num));
};
- 1 打印数组元素。
要测试这段代码是否工作,你可以将其保存为 .js 文件,链接到 .html 网页,并在浏览器中运行该文件,在浏览器检查器窗口中查看结果。使用 Node.js,你可以通过在终端中直接运行 JavaScript 文件来获得即时的满足感。
2.1. 创建一个 JavaScript 文件
要开始你的第一个 Node.js 应用程序,创建一个 JavaScript 文件以打印消息到终端控制台。为此,请按照以下步骤操作:
-
打开你的文本编辑器到一个新窗口。
-
在那个空文件中输入以下代码:
console.log(“Hello, Universe!”); -
将此文件保存为桌面上的 hello.js。
这就是你需要做的全部。你已经创建了一个 Node.js 可以执行的 JavaScript 文件。在下一节中,你将运行该文件。
严格模式
在 JavaScript 中,你可以选择以 严格模式 编写代码——在这种模式下,即使 Node.js 引擎或你使用的网络浏览器允许这些错误通过,也会捕获到一些随意的 JavaScript 错误。
要使用严格模式,请在每个你编写的 JavaScript 文件顶部添加“use strict”(在其它任何语句之前)。为了使严格模式生效,一个相关项目中所有文件都必须标记为使用严格模式。
查看严格模式的文档。
注意
严格模式将一些之前被接受的错误转换为错误,因此它们会被发现并迅速修复。
严格模式发现的错误包括
-
意外创建全局变量——你将无法在不使用
var、let或const关键字的情况下创建变量。 -
分配无法分配的变量——你不能使用 undefined 作为变量名,例如。
-
在对象字面量中使用非唯一函数参数名称或属性名称——在赋值时,你需要选择在相同作用域内不重复的名称。
注意
JavaScript 为了向后兼容保留了 "use strict" 作为字符串。较老的 JavaScript 引擎将其视为字符串并忽略它。
JavaScript 可以很宽容,但为了学习目的,以及考虑到大多数开发者可能犯的偶然错误,我在代码中使用严格模式,并建议你也这样做。尽管我在书中的代码示例中没有显示 "use strict"; 这一行,但这条线出现在我编写的每个 JavaScript 文件和运行的文件顶部。
2.2. 使用 Node.js 运行你的 JavaScript 文件
当你导航到 JavaScript 文件的目录并在文件名前加上 node 关键字时,Node.js JavaScript 引擎可以解释你的 JavaScript 代码。
完成以下步骤以运行你的 JavaScript 文件:
-
打开一个新的终端窗口。
-
通过输入
cd ~/Desktop导航到你的桌面。 -
通过输入
node关键字后跟文件名来运行你的 JavaScript 文件。你也可以在没有文件扩展名的情况下运行相同的命令。例如,对于名为 hello.js 的文件,在提示符下输入node hello(图 2.1)。
图 2.1. 使用 Node.js 运行 JavaScript 文件

如果你的文件创建和运行正确,你应该会在屏幕上看到打印出的 Hello, Universe!。如果你没有看到任何响应,请确保 hello.js 文件中有内容,并且你的最新更改已保存。此外,请确保你是在该文件的目录下运行命令。
这里到底发生了什么?Node.js 的 console.log 函数允许你将任何 JavaScript 命令的结果输出到控制台窗口(或你的终端的标准输出窗口)。如果你之前在浏览器中调试过 JavaScript,你会注意到在 Node.js 控制台窗口中使用 console.log 和输出到你的调试工具控制台窗口之间的相似之处。
提示
有关 console.log 和其他日志类型的更多信息,请参阅附录 B appendix B。
| |
快速检查 2.1
Q1:
如果你有一个名为 hello.js 的文件,在终端中运行 node hello 会发生什么?
| |
QC 2.1 答案
1:
因为 Node.js 专为执行 JavaScript 代码而设计,所以运行文件时不需要添加 .js 文件扩展名。你可以运行文件为 node hello.js 或 node hello;两者都可以工作。
2.3. 运行单个 JavaScript 命令
假设你正在开发一个向用户发送积极信息的应用程序。在你将积极信息的文件完全集成到应用程序之前,你希望在 Node.js REPL 中对其进行测试。你通过创建一个名为 messages.js 的 JavaScript 文件,并使用以下列表中的代码,将你的信息作为数组添加到一个 .js 文件中。
列表 2.1. 在 messages.js 中声明一个 JavaScript 变量
let messages = [
"A change of environment can be a good thing!",
"You will make it!",
"Just run with the code!"
]; *1*
- 1 列出信息数组。
而不是使用 Node.js 执行此文件(目前不会提供任何内容),你通过使用 node 关键字启动 REPL 环境,并通过 .load messages.js 命令导入此文件,如 列表 2.2 所示。通过导入文件,你使 REPL 能够访问该文件的内容。文件导入后,窗口会响应文件的内容。你还可以在 REPL 环境中访问 messages 变量。
注意
确保你从保存 messages.js 文件的同一目录启动你的 REPL 会话;否则,你需要导入文件的绝对路径而不是相对路径。文件的 绝对 路径是从你的根目录开始的文件位置。例如,在我的电脑上,/usr/local/bin/node 是我的 Node.js 安装位置的绝对路径。从本地目录的相对路径将是 /bin/node。
列表 2.2. 将 JavaScript 文件加载到 REPL 中
> .load messages.js
"use strict";
let messages = [
"A change of environment can be a good thing!",
"You will make it!",
"Just run with the code!"
]; *1*
- 1 加载包含三个字符串的数组
你计划通过你的 Node.js 应用程序列出每条信息给你的用户。为了测试这个列表,通过在 REPL 窗口中直接输入以下列表中的代码来遍历数组并广播每条信息。
列表 2.3. 在 REPL 中使用文件的內容
> messages.forEach(message => console.log(message)); *1*
- 1 使用单行箭头函数记录每条信息。
消息以它们的数组顺序在终端窗口中打印,如下所示。
列表 2.4. 控制台日志循环的结果
A change of environment can be a good thing!
You will make it!
Just run with the code!
undefined *1*
- 1 打印信息并显示 undefined 作为返回值
如果你对你 REPL 窗口中编写的代码感到满意,你可以通过在 REPL 中输入 .save positiveMessages.js 将代码保存到名为 positiveMessages.js 的文件中。这样做可以避免在 REPL 环境中重新输入任何工作。
快速检查 2.2
1
你可以通过哪三种方式退出 REPL 环境?
2
你如何将不在你的项目文件夹中的文件加载到 REPL 中?
3
如果你使用已存在的文件名运行 .save 会发生什么?
| |
QC 2.2 答案
1
要退出 Node.js REPL 环境,你可以输入
.exit,连续按两次 Ctrl-C,或者连续按两次 Ctrl-D。2
对于不在你在终端中导航到的目录中的文件,你可能需要使用该文件的绝对路径。
3
运行
.save将您的 REPL 会话保存到文件中,并覆盖任何具有相同名称的文件。
通过实践,您可以轻松地导航 Node.js REPL 环境。请记住,在终端中访问 node 以快速检查和测试可能需要更长的时间来修改的大型应用程序中的代码。接下来,您将开始从头开始构建 Web 应用程序并正确设置它们。
总结
在本课中,您学习了可以使用 Node.js 在终端中运行 JavaScript 文件。在您第一次使用 Node.js 的经历中,您创建了并运行了您的第一个应用程序。然后,您通过加载您的 JavaScript 文件并保存您的 REPL 沙盒代码来探索 REPL 环境。在下一课中,您将创建一个 Node.js 模块并使用 npm 安装工具。
尝试以下操作
console.log 将很快成为您在 Web 开发中的最佳朋友之一,因为日志注释将帮助您找到错误。通过一些练习和变化来熟悉您的新朋友。如本课之前所述,console 是 Node.js 中的一个全局对象,来自 Console 类。log 是您可以在此对象上运行的许多实例方法之一。
| |
注意
字符串插值 意味着将代表变量的文本片段插入到另一段文本中。
尝试将以下内容打印到控制台:
-
使用
console.log(“Hello %s”,“Universe”);包含插值字符串变量的消息 -
使用
console.log(“Score: %d”, 100);包含插值整数变量的消息
尝试使用下一列表中的代码构建一个名为 printer.js 的文件。
列表 2.5. 字符串插值示例
let x = "Universe";
console.log(`Hello, ${x}`); *1*
- 1 记录一个插值字符串。
当您在终端中运行 node printer.js 时,您期望发生什么?
第 1 单元. Node.js 入门
既然你已经完成了 第 0 单元 并安装并运行了 Node.js,现在是时候看看它的工作情况了。第 1 单元 是关于从头开始构建。你首先在 Node.js 中构建一个小型网络应用程序,并逐渐拼凑起幕后工作的组件。在本单元中,你将学习所有必要的知识,以便在 Node.js 上运行一个网络服务器,该服务器提供一些简单的静态内容:HTML 页面、图片和样式表。为了实现这一目标,你将查看以下主题:
-
第 3 课 介绍了 npm 并讨论了如何配置新的 Node.js 应用程序。在本课中,你将构建一个 Node.js 模块,并了解包和模块如何为你的应用程序提供工具和支持。
-
第 4 课 介绍了在 Node.js 上运行的网络服务器作为启动简单网站的方法。你将学习如何设置服务器并编写代码,以便使你的网站内容可查看。
-
第 5 课 在 第 2 课 的基础上,为应用程序提供足够的信息,以便根据不同的请求加载网络内容。在本课中,你构建了你的第一个应用程序路由——一个将内容与应用程序中的 URL 相连接的系统。
-
第 6 课 教你如何从你的 web 服务器上提供不同的 HTML 文件,而不是简单的响应。本课增加了对应用程序资源的支持:CSS、在用户设备上运行的 JavaScript 以及图像加载。这些概念共同使你能够以更少的代码杂乱来组织和结构化你的应用程序,以处理更多对网站的请求。
-
最后,第 7 课 通过构建一个完整的多页应用程序来展示如何将所有内容整合在一起。你从头开始创建一个新的应用程序;然后添加三个视图、视图的路由和资源,以及一个公共客户端文件夹。
当你牢固掌握了如何从头开始构建静态网站,第 2 单元 将带你进入下一步:使用框架更快地构建应用程序。
第 3 课. 创建 Node.js 模块
在本课中,你通过创建一个 Node.js 模块(JavaScript 文件)来启动 Node.js 应用程序开发。然后你将 npm 引入到开发工作流程中,并了解一些常见的 npm 命令和工具,用于设置新的应用程序。
本课涵盖了
-
创建 Node.js 模块
-
使用 npm 构建一个 Node.js 应用程序
-
使用 npm 安装 Node.js 包
考虑这一点
你想构建一个应用程序,帮助人们分享食谱并相互学习。通过这个应用程序,用户可以订阅、加入在线课程,使用应用程序的食谱练习烹饪,并与其他用户建立联系。
你计划使用 Node.js 来构建这个网络应用程序,并且你想要从验证用户的 ZIP 码开始,以确定你受众的位置和人口统计信息。除了应用程序之外,你还需要构建一个用于检查 ZIP 码的工具吗?
幸运的是,你可以使用 npm 来安装 Node.js 包——其他人编写的代码库,这些代码库为你的应用程序添加了特定的功能。实际上,有一个基于 ZIP 码验证位置的包可用。在本课中,你将查看该包以及如何安装它。
一个 Node.js 应用程序由许多 JavaScript 文件组成。为了使你的应用程序保持有序和高效,当需要时,这些文件需要能够访问彼此的内容。每个包含代码库的 JavaScript 文件或文件夹都称为 模块。
假设你正在使用来自 单元 0 的积极信息开发一个食谱应用程序。你可以创建一个名为 messages.js 的文件,其中包含以下代码:let messages = ["You are great!","You can accomplish anything!","Success is in your future!"];。
将这些消息与你要编写的显示它们的代码分开,可以使你的代码更加有序。要在另一个文件中管理这些消息,你需要将 let 变量定义更改为使用 exports 对象,如下所示:exports.messages = ["You are great!","You can accomplish anything!","Success is in your future!"];。与其他 JavaScript 对象一样,你正在向 Node.js 的 exports 对象添加一个 messages 属性,并且这个属性可以在模块之间共享。
注意
exports 对象是 module 对象的一个属性。module 既是 Node.js 中代码文件的名字,也是其全局对象之一。exports 是 module.exports 的简写。
模块已准备好被另一个 JavaScript 文件所需的(导入)。你可以通过创建另一个名为 printMessages.js 的文件来测试此模块,该文件的目的在于遍历消息并将它们通过下一列表中显示的代码记录到你的控制台。首先,使用 require 对象和模块的文件名(带或不带 .js 扩展名)来引入本地模块。然后,如下一列表所示,通过在 printMessages.js 中设置的变量来引用模块的数组。
列表 3.1. 在 printMessages.js 中向控制台打印日志
const messageModule = require("./messages"); *1*
messageModule.messages.forEach(m => console.log(m)); *2*
-
1 引入本地的 messages.js 模块。
-
2 通过 messageModule.messages 引用模块的数组。
require 是另一个 Node.js 全局对象,用于在本地引入来自其他模块的方法和对象。Node.js 将 require("messages") 解释为在项目目录中查找名为 messages.js 的模块,并允许 printMessages.js 中的代码使用 messages.js 中 exports 对象上的任何属性。
使用 require
要在 Node.js 中加载代码库和模块,请使用 require()。这个 require 函数,就像 exports 一样,来自 module.require,这意味着该函数存在于全局 module 对象上。
Node.js 使用 CommonJS,这是一个帮助 JavaScript 在浏览器外运行的工具,它通过帮助定义模块的使用方式来实现。对于模块加载,CommonJS 指定了 require 函数。对于导出模块,CommonJS 为每个模块提供了 exports 对象。您在这本书中使用的大部分语法和结构都源于 CommonJS 模块设计。
require 负责将代码加载到您的模块中,它通过将加载的模块附加到您的模块的 exports 对象上来完成此操作。因此,如果您导入的代码需要以任何方式重用,则无需每次都重新加载。
模块类还会执行一些额外的步骤来缓存和正确管理所需的库,但在这里要记住的重要事情是,一旦模块被 require,则在整个应用程序中都会使用该模块的同一实例。
在下一节中,您将使用 npm,这是添加模块到您项目的另一个工具。
快速检查 3.1
Q1:
用于使一个模块内的函数或变量对其他模块可用的对象是什么?
| |
QC 3.1 答案
1:
exports用于在应用程序内共享模块属性和功能。module.exports也可以用来代替它。
3.1. 运行 npm 命令
随着您安装 Node.js,您还获得了 npm,它是 Node.js 的包管理器。npm 负责管理您应用程序中的外部包(其他人构建并在网上提供的模块)。
在整个应用程序开发过程中,您使用 npm 来安装、删除和修改这些包。在终端中输入 npm -l 会显示一个包含简要说明的 npm 命令列表。
您需要了解列在 表 3.1 中的少数 npm 命令。
表 3.1. 您需要了解的 npm 命令
| npm 命令 | 描述 |
|---|---|
| npm init | 初始化 Node.js 应用程序并创建 package.json 文件 |
| npm install |
安装 Node.js 包 |
| npm publish | 将您构建的包保存并上传到 npm 包社区 |
| npm start | 运行您的 Node.js 应用程序(前提是 package.json 文件已设置为此命令) |
| npm stop | 停止运行中的应用程序 |
| npm docs |
打开您指定包的可能文档页面(网页) |
当你使用 npm install <package> 时,将 --save 添加到你的命令会将包安装为应用程序的依赖项。将 --global 添加到命令中会将包全局安装到你的电脑上,以便在终端的任何地方使用。这些命令扩展,称为 标志,分别有 -S 和 -g 的简写形式。npm uninstall <package> 会撤销安装操作。在 单元 2 中,你将使用 npm install express -S 来安装 Express.js 框架用于你的项目,并使用 npm install express-generator -g 来安装 Express.js 生成器作为命令行工具。
注意
默认情况下,你的包安装会显示在你的依赖项中作为生产就绪包,这意味着当你的应用程序上线时,将使用这些包。要明确安装用于生产的包,请使用 --save-prod 标志。如果包仅用于开发目的,请使用 --save-dev 标志。(截至 NPM 5,--save 标志不再需要。)
在后来准备你的应用程序用于生产时,使其可供全世界使用,你可以使用 --production 标志来区分包。
模块、包和依赖
在您使用 Node.js 进行开发的过程中,您会经常听到 模块、包 和 依赖 这些术语。以下是你需要了解的内容:
-
模块 是包含与单个概念、功能或库相关的代码的单独 JavaScript 文件。
-
包 可能包含多个模块或单个模块。它们用于将提供相关工具的文件分组。
-
依赖 是应用程序或另一个模块使用的 Node.js 模块。如果一个包被视为应用程序依赖项,那么在应用程序预期成功运行之前,必须安装该包(安装的版本由应用程序指定)。
如果你想在应用程序中集成一些功能,你很可能会在 www.npmjs.com 找到一个执行此任务的包。为你的食谱应用程序添加根据用户的邮政编码查找用户位置的功能。如果你有这些信息,你可以确定用户是否足够接近,可以一起烹饪。
要添加此功能,你需要安装 cities 包 (www.npmjs.com/package/cities),它将文本地址转换为位置坐标。但在你可以成功安装包之前,你还需要为这个项目做一件事。在下一节中,你将正确初始化一个 Node.js 项目并创建一个 package.json 文件,npm 将使用它来安装 cities。
快速检查 3.2
Q1:
如果你想在你的电脑上全局安装一个包,你使用什么标志?
QC 3.2 答案
1:
--global或-g标志将包安装为在您的计算机上全局使用的命令行工具。该包可以被其他项目访问,而不仅仅是您正在工作的项目。
3.2. 初始化 Node.js 应用程序
每个 Node.js 应用程序或模块都包含一个 package.json 文件来定义该项目的属性。该文件位于项目的根目录级别。通常,此文件用于指定当前发布版本的版本号、应用程序的名称以及主应用程序文件。此文件对于 npm 保存任何包到在线的 node 社区非常重要。
要开始,创建一个名为 recipe_connection 的文件夹,在终端中导航到您的项目目录,并使用 npm init 命令初始化您的应用程序。您将被提示填写项目的名称、应用程序的版本、简短描述、您将从中启动应用程序的文件名(入口点)、测试文件、Git 仓库、您的名字(作者)和许可证代码。
目前,请确保输入您的名字,使用 main.js 作为入口点,并按 Enter 键接受所有默认选项。当您确认所有这些更改后,您应该在项目目录中看到一个新创建的 package.json 文件。这个文件的内容应该类似于下一条列表。
列表 3.2. 在终端 recipe_connection 项目中的 package.json 文件的结果
{
"name": "recipe_connection",
"version": "1.0.0",
"description": "An app to share cooking recipes",
"main": "main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Jon Wexler",
"license": "ISC"
} *1*
- 1 显示 package.json 的内容,包含名称、版本、描述、起始文件、自定义脚本、作者和许可证。
现在,您的应用程序有一个保存和管理应用程序配置和包的起点。您应该能够在终端导航到您的项目文件夹并运行以下命令来安装 cities:npm install cities --save (图 3.1)。
图 3.1. 在终端中安装一个包

运行此命令后,您的 package.json 将获得一个新的 dependencies 部分,其中包含对您安装的 cities 包及其版本的引用,如下所示。
列表 3.3. 在终端中安装包后您的 package.json 文件的结果
{
"name": "recipe_connection",
"version": "1.0.0",
"description": "An app to share cooking recipes",
"main": "main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Jon Wexler",
"license": "ISC",
"dependencies": { *1*
"cities": "².0.0"
}
}
- 1 显示 package.json 的依赖项部分。
此外,通过这次安装,您的项目文件夹将获得一个名为 node_modules 的新文件夹。在这个文件夹中,您将找到您安装的 cities 包的代码内容 (图 3.2)。
图 3.2. 包含 node_modules 的 Node.js 应用程序结构

注意
您还会在项目目录的根级别看到一个名为 package-lock.json 的文件被创建。此文件由 npm 自动创建并使用,以跟踪您的包安装情况,并更好地管理项目依赖项的状态和历史。您不应修改此文件的内容。
--save 标志将 cities 包保存为该项目的依赖项。现在检查你的 package.json 文件,看看该包如何在 dependencies 下列出。由于你的 node_modules 文件夹会增长,我建议你在在线分享项目代码时不要包含它。然而,任何下载项目的人都可以输入 npm install 以自动安装此文件中列出的所有项目依赖项。
通过将 列表 3.4 中的行添加到 main.js 中来测试这个新包。首先,引入本地安装的 cities 包,并在该文件中使其可用。然后使用 cities 包中的 zip_lookup 方法通过该 ZIP 代码查找城市。结果存储在一个名为 myCity 的变量中。
注意
我将继续在适当的地方使用 var 关键字进行变量定义。因为 myCity 是一个可能改变值的变量,所以我在这里使用 var。cities 变量代表一个模块,所以我使用 const。当我的代码的范围内可以特别受益于其使用时,我使用 let 变量。
列表 3.4. 在 main.js 中实现 cities 包
const cities = require("cities"); *1*
var myCity = cities.zip_lookup("10016"); *2*
console.log(myCity); *3*
-
1 引入
cities包。 -
2 使用 zip_lookup 方法分配结果城市。
-
3 将结果记录到你的控制台。
那个 ZIP 代码的结果数据将按以下列表所示打印到控制台。zip_lookup 方法返回一个包含坐标的 JavaScript 对象。
列表 3.5. 在终端运行 main.js 的示例结果
{
zipcode: "10016",
state_abbr: "NY",
latitude: "40.746180",
longitude: "-73.97759",
city: "New York",
state: "New York"
} *1*
- 1 显示 zip_lookup 方法的输出结果。
快速检查 3.3
Q1:
什么终端命令初始化一个带有 package.json 文件的 Node.js 应用程序?
| |
QC 3.3 答案
1:
npm init初始化一个 Node.js 应用程序,并提示你创建一个 package.json 文件。
摘要
在本课中,你学习了 npm 以及如何使用其工具集创建新的 Node.js 应用程序和安装外部包。你构建了自己的 Node.js 模块,并在主应用程序文件中引入了它。最后,你安装了一个外部包,并在你的示例应用程序中使其工作。下一步是将这些工具集成到 Web 应用程序中。我在 第 4 课 中讨论了构建 Web 服务器的第一步。
尝试这个
创建几个新的模块,并练习向 exports 对象添加简单的 JavaScript 对象和函数。
你可以添加一个函数,如下面的列表所示。
列表 3.6. 导出函数
exports.addNum = (x, y) => { *1*
return x + y;
};
- 1 导出一个函数。
看看当你从项目文件夹中的另一个目录中引入模块时会发生什么。
第 4 课. 在 Node.js 中构建简单的 Web 服务器
本课涵盖了http模块的一些基本功能,这是一个用于处理互联网请求的 Node.js 代码库。技术社区对 Node.js 及其将 JavaScript 用作服务器端语言的使用赞不绝口。在本课中,您将构建您的第一个 Web 服务器。在几个简短的步骤中,您将几行 JavaScript 代码转换为一个可以在您的 Web 浏览器中与之通信的应用程序。
本课涵盖
-
使用 Node.js 和 npm 生成基本的 Web 服务器
-
编写处理浏览器请求并发送响应的代码
-
在浏览器中运行 Web 服务器
考虑这一点
您正在构建您的第一个 Web 应用的道路上。在您交付完整的应用之前,烹饪社区希望看到的是一个简单的网站,它具有未来改进和添加功能的灵活性。您认为构建原型需要多长时间?
使用 Node.js,您可以在几小时内使用http模块获得一个功能齐全的 Web 服务器。
4.1. 理解 Web 服务器
Web 服务器是大多数 Node.js Web 应用的基础。它们允许您向您的应用用户加载图片和 HTML 网页。在您开始之前,我将讨论一些重要的 Web 服务器概念。毕竟,如果您对结果有清晰的预期,最终产品看起来和感觉会好得多。
Web 服务器和 HTTP
Web 服务器是一种软件,旨在通过加载或处理数据来响应互联网上的请求。想象一下 Web 服务器就像银行柜员,其工作是对您存钱、取钱或查看账户中金钱的请求进行处理。就像银行柜员遵循协议以确保正确处理您的请求一样,Web 服务器遵循超文本传输协议(HTTP),这是一个全球范围内观察到的标准化系统,用于查看网页和通过互联网发送数据。
客户端(您的计算机)和服务器通过 HTTP 动词进行通信的一种方式。这些动词指示正在进行的请求类型,例如用户是否试图加载新的网页或更新其个人资料页面中的信息。用户与应用程序的交互上下文是请求-响应周期的一个重要部分。
这里是您将遇到的最常用的两种 HTTP 方法:
-
GET—此方法从服务器请求信息。通常,服务器会以您可以在浏览器中查看的内容(例如,通过点击链接查看网站的首页)响应。 -
POST—此方法将信息发送到服务器。服务器在处理您的数据(例如填写并提交注册表单)后,可能会以 HTML 页面响应或重定向您到应用中的另一个页面。
我在第 18 课中讨论了更多方法。
大多数网络应用程序都进行了修改以采用 HTTP 安全 (HTTPS),其中数据传输是加密的。当您的应用程序在互联网上运行时,您将希望创建一个由受信任的数字证书颁发机构签发的公钥证书。此密钥位于您的服务器上,允许与客户端进行加密通信。例如,letsencrypt.org 这样的组织提供免费证书,这些证书每 90 天需要更新一次。有关 HTTPS 的更多信息,请阅读 developers.google.com/web/fundamentals/security/encrypt-in-transit/why-https 上的文章。
当您访问例如 www.google.com 时,实际上您是在向 Google 的服务器发送请求,服务器随后将响应发送回您,渲染著名的 Google 搜索登录页面。这种请求-响应关系允许用户与应用程序之间建立通信渠道。在 图 4.1 中,数据包以请求的形式发送到应用程序的服务器,当服务器处理请求时,它会以响应的形式发送回数据包。这个过程就是您在互联网上大部分交互得以实现的方式。
图 4.1. 服务器根据请求发送网页、图片和其他资源到您的浏览器。

当您在浏览器中输入想要查看的 URL 时,会向另一处的物理计算机发送一个 HTTP 请求。此请求包含一些信息,指示您是否想要加载网页或向该计算机发送信息。
您可能可以构建一个功能丰富的应用程序,拥有许多花哨的功能,但核心是一个网络服务器来处理其在互联网上的通信。(随着我在本书中讨论这些概念,这些概念将更加有意义。)在下一节中,您将开始构建您的网络服务器。
快速检查 4.1
Q1:
服务器从客户端接收什么,又发送回什么?
QC 4.1 答案
1:
服务器从客户端接收请求并发送响应。
4.2. 使用 npm 初始化应用程序
在开始使用 Node.js 网络应用程序之前,您需要在终端中的项目文件夹中初始化项目。打开一个终端窗口,使用 mkdir 创建一个名为 simple_server 的新目录。您可以使用 npm init 初始化项目。
注意
npm 是 Node.js 的包管理器。您的 Node.js 项目依赖于这个工具来安装和构建应用程序。您可以在 docs.npmjs.com 上了解更多关于 npm 及其使用方法的信息。
运行 npm init 命令将启动一个提示,创建一个 package.json 文件。正如提示所解释的,你将在这个文件中配置你的 Node.js 应用程序的最基本设置。现在,你可以将 main.js 添加为入口点,包括一个简短的描述和你的名字作为作者,并选择按 Enter 键使用默认值,直到达到提示的末尾。
然后你将需要通过预览你的 package.json 文件来确认你的设置。按 Enter 键确认并返回常规终端提示符。
4.3. 编写应用程序代码
当你安装 Node.js 时,核心库也一起安装了。在这个库中有一个名为 http 的模块。你将使用这个模块来构建你的 Web 服务器。在本节中,你还使用了一个名为 http-status-codes 的包来提供在应用程序响应中需要使用 HTTP 状态码的常量。
注意
Node.js 中的模块是代码库,打包以向你的应用程序提供特定的功能。在这里,http 模块帮助你通过 HTTP 进行网络通信。
在你的文本编辑器中,创建一个名为 main.js 的新文件,并将其保存在包含你之前创建的 package.json 文件的 simple_server 项目文件夹中。这个文件将作为核心应用程序文件,你的应用程序将在这个文件中为用户服务网页。在终端中,运行 npm i http-status-codes -S 以将 http-status-codes 包保存为应用程序依赖项。
在我分析你即将构建的每个方面之前,先看看 列表 4.1 中的所有代码。代码的第一行将你将用于此应用程序的端口号设置为 3000。
注意
端口 3000 通常用于开发中的 Web 服务器。这个数字没有意义,你可以通过一些例外来自定义它。端口 80 和 443 通常分别保留用于 HTTP 和 HTTPS。
然后你使用 require 来导入一个名为 http 的特定 Node.js 模块并将其保存为一个常量。因为这个变量你不会重新赋值,所以将这个模块保存为常量。你还导入 http-status-codes 包来提供代表 HTTP 状态码的常量。
接下来,你使用 http 变量作为 HTTP 模块的引用来创建一个服务器,使用该模块的 createServer 函数,并将生成的服务器存储在一个名为 app 的变量中。
注意
使用 ES6 语法,你将带有参数的回调函数用括号括起来,然后用 ⇒ 替代 function 关键字。
createServer 函数生成一个 http.Server 的新实例,这是一个内置的 Node.js 类,具有评估 HTTP 通信的工具。使用这个新创建的服务器实例,你的应用程序准备接收 HTTP 请求并发送 HTTP 响应。
警告
这些方法名是区分大小写的。例如,使用 createserver 将会引发错误。
createServer中的参数是一个回调函数,每当服务器内部发生某些事件时都会被调用。例如,当服务器正在运行并且你的应用程序的根 URL(主页)被访问时,一个 HTTP 请求事件会触发这个回调,并允许你运行一些自定义代码。在这种情况下,服务器返回一个简单的 HTML 响应。
你记录了客户端接收到的请求,并在回调函数中使用response参数将内容发送回用户,即你首先接收请求的用户。第一行使用writeHead方法来定义响应 HTTP 头的一些基本属性。HTTP 头包含描述请求或响应中传输的内容的信息字段。头字段可能包含日期、令牌、关于请求和响应来源的信息,以及描述连接类型的描述性数据。
在这个情况下,你返回了httpStatus.OK,这代表了一个200响应代码,并且指定了 HTMLcontent-type来表明服务器已成功接收请求,并将以 HTML 形式返回内容。在此代码块之后,你分配了一个局部变量responseMessage,其中包含你的 HTML 响应消息。
注意
200是表示 OK 的 HTTP 状态代码,用于指示在 HTTP 响应头中返回内容时没有发生问题。要获取其他 HTTP 状态代码的列表,请在 Node.js REPL 外壳中输入http.STATUS_CODES。使用httpStatus.OK代替显式的数字。
在那行代码下面,你使用write写入一行 HTML 到响应中,并使用end关闭响应。你必须使用end来告诉服务器你不再写入内容。如果不这样做,将会使客户端与服务器之间的连接保持开放,阻止客户端接收响应。你也在此时记录了响应,以便你可以看到服务器本身发送了响应。
代码的最后一行将服务器实例app传递给listen方法,以表明服务器已准备好在端口3000上接收传入的请求。
列表 4.1. main.js 中的简单 Web 应用程序代码
const port = 3000,
http = require("http"), *1*
httpStatus = require("http-status-codes"),
app = http.createServer((request, response) => { *2*
console.log("Received an incoming request!");
response.writeHead(httpStatus.OK, {
"Content-Type": "text/html"
}); *3*
let responseMessage = "<h1>Hello, Universe!</h1>";
response.write(responseMessage);
response.end();
console.log(`Sent a response : ${responseMessage}`);
});
app.listen(port); *4*
console.log(`The server has started and is listening on port number:
${port}`);
-
1 需要引入 http 和 http-status-codes 模块。
-
2 使用请求和响应参数创建服务器。
-
3 向客户端写入响应。
-
4 告诉应用程序服务器监听端口 3000。
注意
response对象由 Node.js 使用,并在整个应用程序中携带,作为从函数到函数传递关于当前客户端事务信息的方式。response对象上的一些方法允许你向对象中添加或从对象中删除数据;writeHead和write是这类函数中的两个。
你的应用程序就在那里,所有这些都在它的荣耀之中!并不那么可怕。仅用几行代码,你也将以这种方式构建一个 Web 服务器。
注意
如果你没有指定端口号,操作系统将为你选择一个端口号。这个端口号是你很快将通过浏览器确认你的网络服务器正在运行的那个端口号。
| |
Node.js 中的回调
Node.js 之所以如此快速和高效的部分原因在于它使用了回调函数。回调函数在 JavaScript 中并不新鲜,但在 Node.js 中却被广泛使用,这里值得提一下。
一个回调是一个匿名函数(一个没有名字的函数),它被设置为在另一个函数完成时立即调用。使用回调的好处是,你不必等待原始函数完成处理后再运行其他代码。
考虑通过上传图片到你的银行移动应用来在银行账户中虚拟存入一张支票。回调相当于几天后收到通知,说支票已被验证并存入。在此期间,你能够继续你的正常日常活动。
在http网络服务器示例中,客户端的请求是滚动接收的,然后将其作为 JavaScript 对象传递给回调函数,如下面的图所示:

服务器上的回调表示何时响应客户端。
在这里放置好代码后,你就可以从终端开始运行你的 Node.js 应用程序了。
快速检查 4.2
Q1:
为什么你应该使用
const而不是var来存储你的应用程序中的 HTTP 服务器?
| |
QC 4.2 答案
1:
因为你的服务器将继续监听来自客户端的通信,所以不要重新分配表示服务器的变量非常重要。在 ES6 中,已经成为了惯例,将这些对象标记为常量,而不是可重新分配的变量。
4.4. 运行应用程序
最后一步很简单。使用终端导航到你的项目目录,并在终端窗口中运行node main。接下来,在任何浏览器中打开地址localhost: 3000。你应该会看到一个表示服务器已启动的消息。你的终端窗口应该类似于图 4.2。
图 4.2. 运行基本的 Node.js 服务器

浏览器窗口应该用问候语问候你和你所在的宇宙,如图图 4.3 所示。恭喜!你的第一个 Node.js 网络应用程序已经完成。它很大,而且即将变得更大更好。
图 4.3.你的第一个网页显示

要停止应用程序,请在终端窗口中按 Ctrl-C。你也可以关闭终端窗口,但可能会存在无法正确关闭应用程序的风险,在这种情况下,应用程序可能会在后台继续运行(需要更多的命令行技巧来终止进程)。
快速检查 4.3
Q1:
当您的服务器正在运行时,您导航到 http://localhost:3000/,您正在发起什么类型的 HTTP 请求?
QC 4.3 答案
1:
在应用程序开发的这个阶段,您可以预见的几乎所有请求,包括对 http://localhost:300/的请求,都是 HTTP GET 请求。
摘要
在本课中,您学习了 Node.js 通过 http 模块具有创建网络服务器的内置功能。您通过 package.json 文件配置了一个新的 Node.js 应用程序。使用 http 模块和 createServer 方法,您以最小的努力创建了一个网络服务器,这是构建 Node.js 强大应用程序的垫脚石。通过终端,您能够运行一个网络服务器应用程序。
完成 “尝试这个” 练习以检查您的理解。
尝试这个
npm init 以交互方式生成一个 package.json 文件,尽管您也可以自己创建此文件。
为本课的项目从头开始创建一个新的 package.json 文件。不要使用 npm init;看看您是否可以构建一个类似的 JSON 结构文件。
第 5 课. 处理传入的数据
在第 4 课中,我向您介绍了网络服务器,并展示了如何使用 Node.js 创建一个服务器。每次用户访问指向您应用程序的 URL 时,都会发起一个请求,并且每个请求都必须由您编写的代码进行处理。在本课中,您将学习如何收集和处理这些请求中的某些信息。您还将构建应用程序路由——将请求与适当的响应匹配的代码逻辑。
本课涵盖
-
收集和处理请求数据
-
使用
curl命令提交POST请求 -
使用基本路由构建网络应用程序
考虑这个
当您为您的食谱应用程序规划网页时,您意识到您构建的基本网络服务器只知道如何用单行 HTML 响应。如果您想展示一个完整的首页和不同内容的联系页面呢?
每个网络应用程序都会使用路由与它的网络服务器一起,以确保用户能看到他们具体请求的内容。使用 Node.js,您可以以尽可能少的步骤定义这些路由。
5.1. 重新编写您的服务器代码
要开始本课,重新排列第 4 课中的代码,以更好地了解服务器的行为。在它自己的项目目录中创建一个名为 second_server 的新项目,并在其中添加一个新的 main.js 文件。
注意
在本课及随后的课程中,我期望您使用 npm init 初始化您的 Node.js 应用程序,并遵循第 4 课中的指导创建一个 package.json 文件。
在您的代码中,您有一个服务器对象,它有一个回调函数 (req, res) ⇒ {},该函数在每次向服务器发出请求时运行。当您的服务器正在运行时,如果您在浏览器中访问 localhost:3000 并刷新页面,该回调函数将运行两次——每次刷新时运行一次。
注意
req 和 res 代表 HTTP 请求和响应。您可以使用任何变量名,但请注意顺序;在这个方法中,请求总是在响应之前。
换句话说,当服务器接收到请求时,它会将请求和响应对象传递给一个函数,您可以在该函数中运行您的代码。另一种为该服务器编写代码的方法请参阅代码列表 5.1。当触发 request 事件时,服务器在回调函数中执行代码。当用户访问您的应用程序的网页时,花括号内的代码运行。然后服务器通过分配响应代码 200 并定义响应中的内容类型为 HTML 来准备响应。最后,服务器发送括号内的 HTML 内容,并同时关闭与客户端的连接。
代码列表 5.1. 在 main.js 中的简单服务器和请求事件监听器
const port = 3000,
http = require("http"),
httpStatus = require("http-status-codes"),
app = http.createServer();
app.on("request", (req, res) => { *1*
res.writeHead(httpStatus.OK, {
"Content-Type": "text/html"
}); *2*
let responseMessage = "<h1>This will show on the screen.</h1>";
res.end(responseMessage); *3*
});
app.listen(port);
console.log(`The server has started and is listening on port number:
${port}`);
-
1 监听请求。
-
2 准备响应。
-
3 以 HTML 响应。
在终端中运行 node main,然后在您的网页浏览器中访问 http://localhost:3000/ 以查看包含一行 HTML 的响应。
注意
您可能需要重新安装 http-status-codes 包以用于此新项目,方法是通过运行 npm i http-status-codes -save-dev。
在屏幕上显示一些内容是很好的,但您希望根据您收到的请求类型修改内容。例如,如果用户正在访问联系页面或提交他们填写过的表单,他们将在屏幕上看到不同的内容。第一步是确定请求头中的 HTTP 方法以及 URL。在下一节中,您将查看这些请求属性。
快速检查 5.1
Q1:
服务器每次接收到请求时调用的函数叫什么名字?
QC 5.1 答案
1:
每次接收到请求后调用的函数是回调函数。因为该函数没有标识名称,所以它也被视为匿名函数。
5.2. 分析请求数据
路由 是您的应用程序确定如何响应用户请求的一种方式。一些路由是通过匹配请求对象中的 URL 来设计的。您将在本课中通过这种方法构建您的路由。
每个请求对象都有一个 url 属性。您可以使用 req.url 查看客户端请求的 URL。通过将它们记录到控制台来测试此属性和其他两个属性。将下一列表中的代码添加到 app.on(“request”) 代码块中。
代码列表 5.2. 在 main.js 中记录请求数据
console.log(req.method); *1*
console.log(req.url); *2*
console.log(req.headers); *3*
-
1 记录使用的 HTTP 方法。
-
2 记录请求 URL。
-
3 记录请求头。
因为请求中的某些对象内部可能包含其他嵌套对象,你可以通过在自己的自定义包装函数 getJSONString 中使用 JSON.stringify 来将这些对象转换为更易读的字符串,如 列表 5.3 所示。这个函数接受一个 JavaScript 对象作为参数,并返回一个字符串。现在你可以更改你的日志语句以使用这个函数。例如,你可以通过使用 console.log (Method: ${getJSONString(req.method)}) 来打印请求方法。
列表 5.3. 在 main.js 中记录请求数据
const getJSONString = obj => {
return JSON.stringify(obj, null, 2); *1*
};
- 1 将 JavaScript 对象转换为字符串。
当你重新启动你的服务器时,再次运行 main.js,并在你的网页浏览器中访问 http://localhost:3000,你会在终端窗口中注意到指示已向 / URL(主页)发出 GET 请求的信息,随后是那个请求的头数据。尝试输入不同的 URL,例如 http://localhost:3000/testing 或 http://localhost: 3000/contact。注意,你仍然在浏览器中看到相同的 HTML 文本,但你的控制台继续记录你在浏览器中输入的 URL。
你主要处理的是 GET 请求类型。如果你正在构建一个用户需要填写表单的应用程序,那么你的服务器应该能够处理该表单数据并响应用户,让他们知道数据已被接收。
请求对象,就像 Node.js 中的大多数对象一样,也可以监听事件,类似于服务器。如果有人向服务器发送 POST 请求(试图向服务器发送数据),那么那个 POST 的内容就存在于请求的体中。因为服务器永远不知道正在发送多少数据,所以发布的数据通过数据块进入 http 服务器。
注意
数据块允许信息流入和流出服务器。Node.js 允许你通过 ReadableStream 库在信息到达时处理信息的一部分,而不是等待大量信息到达服务器。
要收集服务器上所有已发布的数据,你需要监听每条接收到的数据并自行整理这些数据。幸运的是,请求监听一个特定的 data 事件。当为特定请求接收到数据时,req.on(“data”) 被触发。你需要在事件处理程序外部定义一个新的数组,body,并将数据块按顺序添加到其中,当它们到达服务器时。注意查看 图 5.1 中发布的交换数据。当所有数据块都接收完毕后,它们可以作为一个单一的数据项收集。
图 5.1. 一个网络服务器收集发布的数据并对其进行整理。

在 app.on(“request”) 代码块中,添加新的请求事件处理器到 列表 5.4 以读取传入的数据。在此代码示例中,每次向服务器发出请求时,您都会执行回调函数中的代码。创建了一个数组并命名为 body,每次接收到请求的数据时,您都会在另一个回调函数中处理它。接收到的数据被添加到 body 数组中。当数据传输完成时,您在第三个回调函数中执行代码。body 数组被转换为文本字符串,请求的内容被记录到您的控制台。
列表 5.4. 在 main.js 中处理已发布的请求数据
app.on("request", (req, res) => { *1*
var body = []; *2*
req.on("data", (bodyData) => { *3*
body.push(bodyData); *4*
});
req.on("end", () => { *5*
body = Buffer.concat(body).toString(); *6*
console.log(`Request Body Contents: ${body}`);
}); *7*
console.log(`Method: ${getJSONString(req.method)}`);
console.log(`URL: ${getJSONString(req.url)}`);
console.log(`Headers: ${getJSONString(req.headers)}`);
res.writeHead(httpStatus.OK, {
"Content-Type": "text/html"
});
let responseMessage = "<h1>This will show on the screen.</h1>";
res.end(responseMessage);
});
app.listen(port);
console.log(`The server has started and is listening on port number:
${port}`);
-
1 监听请求。
-
2 创建一个数组来存储数据块内容。
-
3 在另一个回调函数中处理它。
-
4 将接收到的数据添加到体数组中。
-
5 数据传输结束时运行代码。
-
6 将数组体转换为文本字符串。
-
7 将请求的内容记录到您的控制台。
添加此代码后,您的应用程序已准备好接收收集到数组中的已发布数据并将其转换回 String 格式。当触发事件,表明某些数据块到达服务器时,您通过将数据块(表示为 Buffer 对象)添加到数组中来处理该数据。当指示请求连接结束的事件结束时,您随后通过获取数组的全部内容并将它们转换为可读的文本来跟进。为了测试此过程,请尝试从终端向您的服务器发送 POST 请求。
因为您还没有构建表单,所以可以使用 curl 命令。按照以下步骤操作:
-
在一个终端窗口中运行您的网络服务器时,打开一个新的终端窗口。
-
在新窗口中,运行以下命令:
curl --data "username= Jon&password=secret" http://localhost:3000
提示
curl 是模拟浏览器向服务器发送请求的一种简单方式。使用 curl 关键字,您可以使用不同的标志,例如 –data, 通过 POST 请求将信息发送到服务器。
| |
注意
如果您是 Windows 用户,在您的计算机上安装 curl 之前,请安装名为 Chocolatey 的软件和包管理器(chocolatey.org/install)。然后您可以在命令行中运行 choco install curl。
在第一个终端窗口中,您应该看到请求体的内容已记录到屏幕上,这表明您的服务器已接收并处理了请求(图 5.2)。
图 5.2. 运行 curl 命令的结果

提示
为了使向您的应用程序提交数据更加用户友好,请安装 Insomnia (insomnia.rest/download/)。
在 第 8 课 中,您将了解处理请求内容的一些更简单的方法。现在,尝试根据请求的 URL 和方法控制您向客户端写回的响应类型。
快速检查 5.2
Q1:
正确或错误:每个提交的表单都将其全部内容以单个数据块的形式发送。
| |
QC 5.2 答案
1:
错误。数据以块的形式流式传输到服务器,这使得服务器可以根据接收到的部分数据或收集到的数据的大小进行响应。
5.3. 向 web 应用程序添加路由
路由 是确定应用程序如何响应对特定 URL 的请求的一种方式。应用程序应该将请求路由到主页的方式与提交登录信息的请求不同。
您已经确定用户可以向您的 web 服务器发出请求;从那里,您可以评估请求的类型,并给出适当的响应。考虑您简单的 HTTP web 服务器代码,到目前为止,它对任何请求只有一个响应。此示例接受对服务器(localhost)端口 3000 的任何请求,并在屏幕上显示一行 HTML。
列表 5.5. main.js 中的简单服务器示例
const port = 3000,
http = require("http"),
httpStatus = require("http-status-codes"),
app = http
.createServer((req, res) => {
res.writeHead(httpStatus.OK, {
"Content-Type": "text/html"
});
let responseMessage = "<h1>Welcome!</h1>";
res.end(responseMessage); *1*
})
.listen(port);
- 1 对每个请求响应 HTML。
作为第一个 web 应用程序,这个应用程序是一个巨大的成就,但您需要开始构建一个具有更多功能的应用程序。例如,如果这个项目是一个真正在互联网上运行的合法应用程序,您可能希望根据用户正在寻找的内容显示内容。如果用户想查看信息页面,您可能希望他们在 /info URL(http://localhost:3000/info)中找到该信息。目前,如果用户访问这些 URL,他们将看到相同的 HTML 欢迎行。
下一步是检查客户端的请求,并根据该请求的内容确定响应体。这种结构通常被称为 应用路由。路由标识特定的 URL 路径,可以在应用程序逻辑中进行定位,并允许您指定要发送给客户端的信息。创建这些路由对于实现完全集成的应用程序体验是必要的。
将 simple_server 项目文件夹复制一份,并使用新名称:simple_routes。然后在 main.js 文件中添加一些路由,如 列表 5.6 所示。
您设置了一个名为 routeResponseMap 的路由到响应的映射。当请求 http://localhost:3000/info 时,您检查请求的 URL 是否在 routeResponseMap 中有匹配项,并响应一个信息页面标题。当请求 http://localhost:3000/contact 时,您响应一个联系页面标题。对于所有其他请求,您响应一个通用的问候语。
列表 5.6. main.js 中的 web 服务器简单路由
const routeResponseMap = { *1*
"/info": "<h1>Info Page</h1>",
"/contact": "<h1>Contact Us</h1>",
"/about": "<h1>Learn More About Us.</h1>",
"/hello": "<h1>Say hello by emailing us here</h1>",
"/error": "<h1>Sorry the page you are looking for is not here.</h1>"
};
const port = 3000,
http = require("http"),
httpStatus = require("http-status-codes"),
app = http.createServer((req, res) => {
res.writeHead(200, {
"Content-Type": "text/html"
});
if (routeResponseMap[req.url]) { *2*
res.end(routeResponseMap[req.url]);
} else {
res.end("<h1>Welcome!</h1>"); *3*
}
});
app.listen(port);
console.log(`The server has started and is listening on port number:
${port}`);
-
1 定义带有响应的路由映射。
-
2 检查请求路由是否在映射中定义。
-
3 响应默认 HTML。
在你的代码添加后,你可以区分几个 URL,并相应地提供不同的内容。你仍然不关心请求中使用的 HTTP 方法,但你可以检查用户是否正在搜索/info路由或/contact路由。用户可以更直观地确定他们需要输入哪些 URL 才能到达该页面的预期内容。
尝试运行这段代码。将代码保存在名为 main.js 的项目文件中,并在终端中运行该文件。然后尝试在 Web 浏览器中访问 http://localhost:3000/info 或 http://localhost:3000/contact。任何其他 URL 都应导致原始默认欢迎 HTML 行。
为了模拟服务器执行的重处理或外部调用,你可以在以下列表中添加代码到一个路由,以手动延迟对客户端的响应。
列表 5.7. main.js 中的带定时器的路由
setTimeout(() => res.end(routeResponseMap[req.url]), 2000); *1*
- 1 使用 setTimeout 手动延迟响应。
如果你再次运行此文件,你会注意到页面的加载时间大约长了两秒。你对执行什么代码以及向用户提供什么内容有完全的控制权。记住这个事实:随着你的应用程序增长,你的网页响应时间自然会变长。
查看图 5.3 中/contact URL 的浏览器截图。
图 5.3. /contact URL 的浏览器视图

快速检查 5.3
Q1:
使用什么 URL 将请求路由到主页?
QC 5.3 答案
1:
/路由代表应用程序的主页。
摘要
在本课中,你学习了如何处理请求内容,以可查看的 HTML 进行响应,并构建服务器路由。通过识别请求的内容,你可以处理请求中的提交数据,并根据目标 URL 分离响应内容。路由的创建塑造了你的应用程序逻辑。随着 Web 应用程序的扩展,其路由也会随之扩展,它能够提供的内容类型也会增加。
在下一课中,我将讨论服务单个 HTML 文件、图像和网页样式。
尝试这个
你的简单 Web 应用程序正在处理你为/info和/contact创建的两个路径请求。一个正常的应用程序可能需要访问更多的页面。为以下路径添加三个更多路由到应用程序中:
-
/about—当用户访问 http:/localhost:3000/about 时,响应一行 HTML,声明了解更多关于我们的信息。 -
/hello—当用户访问 http:/localhost:3000/hello 时,响应一行 HTML,声明通过在此处给我们发邮件来打招呼。在“这里”这个词周围包含一个链接到你的电子邮件的锚标签。 -
/error—当用户访问 http://localhost:3000/error 时,以状态码404(表示未找到页面)和一行纯文本“抱歉,您要查找的页面不在这里”响应。
注意
注意
打开多个网络浏览器(如 Apple 的 Safari、Google Chrome 和 Mozilla Firefox),并在这些浏览器中访问不同的 URL。注意请求头的变化。你应该看到相同的宿主但不同的用户代理。
第 6 课. 编写更好的路由和提供外部文件
在第 5 课中,你使用一个路由系统将 URL 流量导向,该系统将请求 URL 与自定义响应匹配。在本课中,你将学习如何提供整个 HTML 文件和客户端 JavaScript、CSS 和图片等资产。告别纯文本响应。在本课结束时,你将改进你的路由代码,并将你的逻辑放在自己的模块中以实现更整洁的组织。
本课涵盖
-
使用
fs模块提供整个 HTML 文件 -
提供静态资产
-
创建一个路由模块
考虑这一点
是时候构建一个基本的食谱网站了。该网站应该有三个静态页面,包含一些图片和样式。你很快就会意识到你迄今为止构建的所有应用程序都只响应单个 HTML 行。你如何在不使主应用程序文件杂乱的情况下为每个页面提供丰富内容?
仅使用 Node.js 安装附带的工具,你可以从你的项目目录中提供 HTML 文件。你可以创建三个单独的页面,使用纯 HTML,而不再需要将你的 HTML 放在 main.js 中。
6.1. 使用 fs 模块提供静态文件
以构建三个页面的静态网站为目标,使用这些 HTML 片段可能会变得繁琐,并使你的 main.js 文件杂乱。相反,创建一个你将在未来响应中使用的 HTML 文件。此文件位于你的服务器相同的项目目录中。参见图 6.1。在这个应用结构中,你想要展示给用户的所有内容都放在 views 文件夹中,而确定显示哪些内容的所有代码都放在 main.js 文件中。
图 6.1. 带有视图的应用结构

你将 HTML 文件添加到 views 文件夹的原因有两个:所有你的 HTML 页面都将组织在一个地方。这个约定将在你将在第 2 单元中学习的 Web 框架中使用。图 6.1。
按照以下步骤操作:
-
创建一个名为 serve_html 的新项目文件夹。
-
在该文件夹中,创建一个空的 main.js 文件。
-
在 serve_html 中创建一个名为 views 的文件夹。
-
在 views 中创建一个 index.html 文件。
在下一个列表中添加 HTML 样板代码到 main.html。
列表 6.1. index.html 页面的样板 HTML
<!DOCTYPE html>
<html> *1*
<head>
<meta charset="utf-8">
<title>Home Page</title>
</head>
<body>
<h1>Welcome!</h1>
</body>
</html>
- 1 在你的 views 中添加基本的 HTML 结构。
注意
这本书不是关于教授 HTML 或 CSS 的。对于这个例子,我提供了一些基本的 HTML 来使用,但对于未来的例子,我不会提供 HTML,这样我可以更快地进入重要的内容。
客户端只能通过另一个 Node.js 核心模块fs的帮助在浏览器中看到这个页面的渲染,该模块代表你的应用程序与文件系统交互。通过fs模块,你的服务器可以访问并读取你的 index.html 文件。你将在项目的主.js 文件中的 http 服务器内部调用fs.readFile方法,如列表 6.2 所示。
首先,将fs模块引入到一个常量中,例如http。有了fs常量,你可以在相对目录中指定一个特定的文件(在这个例子中,是 views 文件夹中的 index.html 文件)。然后创建一个routeMap来将路由与服务器上的文件配对。
接下来,找到并读取路由映射中的文件内容。fs.readFile返回可能发生的任何错误和文件内容,这两个参数分别是error和data。最后,使用该数据值作为返回给客户端的响应体。
列表 6.2. 在 main.js 中的服务器响应中使用fs模块
const port = 3000,
http = require("http"),
httpStatus = require("http-status-codes"),
fs = require("fs"); *1*
const routeMap = { *2*
"/": "views/index.html"
};
http
.createServer((req, res) => {
res.writeHead(httpStatus.OK, {
"Content-Type": "text/html"
});
if (routeMap[req.url]) {
fs.readFile(routeMap[req.url], (error, data) => { *3*
res.write(data); *4*
res.end();
});
} else {
res.end("<h1>Sorry, not found.</h1>");
}
})
.listen(port);
console.log(`The server has started and is listening
on port number: ${port}`);
-
1 引入 fs 模块。
-
2 为 HTML 文件设置路由映射。
-
3 读取映射文件的內容。
-
4 以文件内容作为响应。
注意
当你的电脑上的文件正在被读取时,文件可能会损坏、无法读取或丢失。在代码执行之前,你的代码并不一定知道这些情况,所以如果出现问题,你应该预期回调函数的第一个参数是一个错误。
通过在命令行中进入这个项目的目录并输入node main.js来运行这个文件。当你访问 http://localhost:3000 时,你应该能看到你的 index.html 页面被渲染。你的简单路由将任何其他请求的 URL 扩展的响应引导到抱歉,未找到信息。
提示
如果你没有看到 index.html 文件被渲染,请确保所有文件都在正确的文件夹中。另外,别忘了检查拼写!
在以下示例中,您只为请求 URL 中指定的文件提供服务。如果有人访问 http://localhost:3000/sample.html,您的代码会获取请求的 URL,/sample.html,并将其附加到 views 以创建一个字符串:views/sample.html。以这种方式设计的路由可以根据用户的请求动态查找文件。尝试将您的服务器重写为类似于 列表 6.3 中的代码。创建一个新的 getViewUrl 函数,用于获取请求的 URL 并将其插入到视图的文件路径中。例如,如果有人访问 /index 路径,则 getViewUrl 返回 views/index.html。接下来,将 fs.readFile 中的硬编码文件名替换为 getViewUrl 调用的结果。如果文件在 views 文件夹中不存在,此命令将失败,返回错误消息和 httpStatus.NOT_FOUND 代码。如果没有错误,您将读取文件的数据传递给客户端。
列表 6.3. 在 main.js 中使用 fs 和路由动态读取和提供文件
const getViewUrl = (url) => { *1*
return `views${url}.html`;
};
http.createServer((req, res) => {
let viewUrl = getViewUrl(req.url); *2*
fs.readFile(viewUrl, (error, data) => { *3*
if (error) { *4*
res.writeHead(httpStatus.NOT_FOUND);
res.write("<h1>FILE NOT FOUND</h1>");
} else { *5*
res.writeHead(httpStatus.OK, {
"Content-Type": "text/html"
});
res.write(data);
}
res.end();
});
})
.listen(port);
console.log(`The server has started and is listening on port number:
${port}`);
-
1 创建一个函数将 URL 插入到文件路径中。
-
2 获取文件路径字符串。
-
3 将请求 URL 插入到 fs 文件搜索中。
-
4 使用 404 响应代码处理错误。
-
5 响应文件内容。
注意
ES6 中的字符串插值允许您使用 ${} 语法插入一些文本、数字或函数结果。通过这种新语法,您可以更轻松地连接字符串和其他数据类型。
现在,您应该能够访问 http://localhost:3000/index,并且您的服务器将在 views/index 中查找 URL。
警告
您需要处理所有可能发生的错误,因为可能会请求不存在的文件。
将您的新 HTML 文件添加到您的 views 文件夹中,并尝试使用它们的文件名作为 URL 来访问它们。现在的问题是 index.html 文件不是您想要提供的唯一文件。因为响应体高度依赖于请求,您还需要更好的路由。在本课结束时,您将实现 图 6.2 中概述的设计模式。
图 6.2. 服务器路由逻辑以渲染视图

快速检查 6.1
Q1:
如果您尝试读取计算机上不存在的文件,会发生什么?
| |
QC 6.1 答案
1:
如果您尝试读取计算机上不存在的文件,
fs模块会在其回调中传递一个错误。您如何处理该错误取决于您。它可以崩溃您的应用程序或简单地将其记录到您的控制台。
6.2. 提供资源
您的应用程序 资源 包括与客户端视图一起工作的图像、样式表和 JavaScript。像您的 HTML 文件一样,这些文件类型,如 .jpg 和 .css,需要它们自己的路由才能由您的应用程序提供。
要开始这个过程,在你的项目根目录中创建一个公共文件夹,并将所有资产移动到那里。在公共文件夹中,为图片、css 和 js 创建各自的文件夹,并将每个资产移动到相应的文件夹中。到这一点,你的文件结构应该看起来像图 6.3。
图 6.3. 安排你的资产以便更容易分离和提供

现在你的应用程序结构已经组织好,请细化你的路由以更好地匹配列表 6.4 中的目标。这段代码可能看起来令人不知所措,但你所做的只是将文件读取逻辑移动到自己的函数中,并添加if语句来处理特定文件类型的请求。
在收到请求后,将请求的 URL 保存到变量url中。在每个条件下,检查url是否包含文件的扩展名或 MIME 类型。根据提供的文件自定义响应的内容类型。在 main.js 的底部调用你自己的customReadFile函数以减少重复代码。最后一个函数使用fs.readFile根据请求的名称查找文件,使用该文件的数据写入响应,并将任何消息记录到控制台。
注意,在第一个路由中,你正在检查 URL 是否包含.html;如果是,你将尝试读取与 URL 同名的文件。通过将读取文件的代码移动到自己的readFile函数中,你进一步抽象了你的路由。你需要检查特定的文件类型,设置响应头,并将文件路径和响应对象传递给此方法。仅使用少量动态路由,你现在就可以准备响应多种文件类型。
列表 6.4. 为你的项目中的每个文件设置特定路由的 Web 服务器
const sendErrorResponse = res => { *1*
res.writeHead(httpStatus.NOT_FOUND, {
"Content-Type": "text/html"
});
res.write("<h1>File Not Found!</h1>");
res.end();
};
http
.createServer((req, res) => {
let url = req.url; *2*
if (url.indexOf(".html") !== -1) { *3*
res.writeHead(httpStatus.OK, {
"Content-Type": "text/html"
}); *4*
customReadFile(`./views${url}`, res); *5*
} else if (url.indexOf(".js") !== -1) {
res.writeHead(httpStatus.OK, {
"Content-Type": "text/javascript"
});
customReadFile(`./public/js${url}`, res);
} else if (url.indexOf(".css") !== -1) {
res.writeHead(httpStatus.OK, {
"Content-Type": "text/css"
});
customReadFile(`./public/css${url}`, res);
} else if (url.indexOf(".png") !== -1) {
res.writeHead(httpStatus.OK, {
"Content-Type": "image/png"
});
customReadFile(`./public/images${url}`, res);
} else {
sendErrorResponse(res);
}
})
.listen(3000);
console.log(`The server is listening on port number: ${port}`);
const customReadFile = (file_path, res) => { *6*
if (fs.existsSync(file_path)) { *7*
fs.readFile(file_path, (error, data) => {
if (error) {
console.log(error);
sendErrorResponse(res);
return;
}
res.write(data);
res.end();
});
} else {
sendErrorResponse(res);
}
};
-
1 创建一个错误处理函数。
-
2 将请求的 URL 存储在变量 url 中。
-
3 检查 URL 是否包含文件扩展名。
-
4 自定义响应的内容类型。
-
5 调用 readFile 来读取文件内容。
-
6 查找名为请求的文件。
-
7 检查文件是否存在。
现在你的应用程序可以正确处理对不存在的文件的请求。你可以访问 http://localhost:3000/test.js.html,甚至 http://localhost:3000/test 来查看错误消息!要使用这些更改渲染索引页面,请将文件类型追加到 URL:http://localhost:3000/index.html。
下一个部分将向你展示如何进一步重新定义你的路由结构,并为你的路由提供它们自己的模块。
快速检查 6.2
Q1:
如果找不到路由,你应该提供什么默认响应?
| |
QC 6.2 答案
1:
如果你的应用程序无法找到某些请求的路由,你应该返回一个带有消息的 404 HTTP 状态码,表明客户端正在寻找的页面找不到。
6.3. 将你的路由移动到另一个文件
本节的目标是使管理和编辑你的路由更加容易。如果你的所有路由都在一个if-else块中,当你决定更改或删除一个路由时,这个更改可能会影响块中的其他路由。此外,随着你的路由列表的增长,你会发现根据使用的 HTTP 方法来分离路由会更加容易。例如,如果/contact路径可以响应POST和GET请求,那么你的代码将在识别请求的方法后立即路由到相应的函数。
随着 main.js 文件的增长,过滤你编写的所有代码的能力变得更加复杂。你很容易发现自己有成百上千行代码仅代表路由!
为了减轻这个问题,将你的路由移动到一个名为 router.js 的新文件中。同时重新构建存储和处理路由的方式。将列表 6.5 中的代码添加到 router.js 中。在 manning.com/books/get-programming-with-node-js 提供的源代码中,这段代码存在于一个名为 better_routes 的新项目文件夹中。
在这个文件中,你定义一个routes对象来存储映射到POST和GET请求的路由。随着在 main.js 中创建路由,它们将根据其方法类型(GET或POST)添加到这个routes对象中。这个对象不需要在这个文件外部被访问。
接下来,创建一个名为handle的函数来处理路由的回调函数。该函数通过请求的 HTTP 方法访问routes对象,使用routes[req.method],然后通过请求的目标 URL 找到相应的回调函数,使用[req.url]。例如,如果你对/index.html URL 路径发出GET请求,routes["GET"]["/index.html"]将给你在routes对象中预定义的回调函数。最后,找到的任何回调函数都会在routes对象中被调用,并传递请求和响应,这样你就可以正确地响应用户。如果没有找到路由,则使用httpStatus.NOT_FOUND响应。
handle函数通过 HTTP 方法和 URL 检查传入的请求是否与routes对象中的路由匹配;否则,它会记录一个错误。使用try-catch尝试路由传入的请求并处理错误,否则应用程序可能会崩溃。
你还定义了get和post函数并将它们添加到exports中,以便可以从 main.js 中注册新的路由。这样,在 main.js 中,你可以通过输入get("contact.html", <回调函数>)在routes对象中添加新的回调关联,例如一个/contact.html 页面。
列表 6.5. 在 router.js 模块的exports对象中添加函数
const httpStatus = require("http-status-codes"),
htmlContentType = {
"Content-Type": "text/html"
},
routes = { *1*
"GET": {
"/info": (req, res) => {
res.writeHead(httpStatus.OK, {
"Content-Type": "text/plain"
})
res.end("Welcome to the Info Page!")
}
},
'POST': {}
};
exports.handle = (req, res) => { *2*
try {
if (routes[req.method][req.url]) {
routes[req.method]req.url;
} else {
res.writeHead(httpStatus.NOT_FOUND, htmlContentType);
res.end("<h1>No such file exists</h1>");
}
} catch (ex) {
console.log("error: " + ex);
}
};
exports.get = (url, action) => { *3*
routes["GET"][url] = action;
};
exports.post = (url, action) => {
routes["POST"][url] = action;
};
-
1 定义一个 routes 对象来存储映射到 POST 和 GET 请求的路由。
-
2 创建一个名为 handle 的函数来处理路由回调函数。
-
3 在 main.js 中构建 get 和 post 函数以注册路由。
注意
这里可以添加更多的 HTTP 方法,但你不需要担心这些方法,直到 第 4 单元。
当你调用 get 或 post 时,你需要传递路由的 URL 和当该路由被访问时要执行的功能。这些函数通过将它们添加到 routes 对象中来注册你的路由,这样它们就可以通过处理函数被访问和使用。
注意,在 图 6.4 中,routes 对象由 handle、get 和 post 函数内部使用,这些函数通过模块的 exports 对象对其他项目文件是可访问的。
图 6.4. exports 对象为其他文件提供了对特定功能访问的权限。

最后一步涉及将 router.js 导入到 main.js 中。你将以导入其他模块相同的方式完成此操作,使用 require("./router")。
你需要在 main.js 中每个函数调用前加上 router,因为现在这些函数属于路由器。你也可以导入 fs 模块,如果你计划像以前一样提供资产和静态 HTML 文件。你的服务器代码应该看起来像 列表 6.6 中的代码。
在创建你的服务器后,每个请求都由你的 router 模块中的 handle 函数处理,然后是一个回调函数。现在你可以通过使用 router.get 或 router.post 来定义你的路由,以指示你期望从该路由的请求中得到的 HTTP 方法。第二个参数是当收到请求时要运行的回调函数。创建一个自定义的 readFile 函数,称为 customReadFile,以使你的代码更具可重用性。在这个函数中,你尝试读取传入的文件,并返回文件的内容。
列表 6.6. 在 main.js 中处理和管理你的路由
const port = 3000,
http = require("http"),
httpStatusCodes = require("http-status-codes"),
router = require("./router"),
fs = require("fs"),
plainTextContentType = {
"Content-Type": "text/plain"
},
htmlContentType = {
"Content-Type": "text/html"
},
customReadFile = (file, res) => { *1*
fs.readFile(`./${file}`, (errors, data) => {
if (errors) {
console.log("Error reading the file...");
}
res.end(data);
});
};
router.get("/", (req, res) => { *2*
res.writeHead(httpStatusCodes.OK, plainTextContentType);
res.end("INDEX");
});
router.get("/index.html", (req, res) => {
res.writeHead(httpStatusCodes.OK, htmlContentType);
customReadFile("views/index.html", res);
});
router.post("/", (req, res) => {
res.writeHead(httpStatusCodes.OK, plainTextContentType);
res.end("POSTED");
});
http.createServer(router.handle).listen(3000); *3*
console.log(`The server is listening on port number:
${port}`);
-
1 创建一个自定义的 readFile 函数以减少代码重复。
-
2 使用 get 和 post 注册路由。
-
3 通过 router.js 处理所有请求。
在添加这些更改后,重新启动你的 Node.js 应用程序,并尝试访问你的主页或 /index.html 路由。此项目结构遵循了一些应用程序框架使用的某些设计模式。在 第 2 单元 中,你将了解更多关于框架的信息,并了解为什么这种组织方式可以使你的代码更高效和可读。
快速检查 6.3
Q1:
真或假:没有被添加到其模块
exports对象中的函数和对象仍然可以被其他文件访问。
QC 6.3 答案
1:
错误。
exports对象的目的是允许模块共享函数和对象。如果一个对象没有被添加到模块的exports对象中,它将保持在该模块中本地,如 CommonJS 所定义。
总结
在本节课中,你学习了如何服务单个文件。首先,你将fs模块添加到你的应用程序中,以便在views文件夹中查找 HTML 文件。然后你扩展了这一功能以应用于应用程序资源。你还学习了如何将你的路由系统应用于其自身的模块,并从主应用程序文件中选择性注册路由。在第 2 单元中,我谈到了你可以如何使用 Express.js 提供的应用程序结构,这是一个 Node.js 网络框架。
尝试这个
你目前设置了一个路由来从本节课的示例中读取 HTML 文件。尝试添加新的路由,以本节课介绍的风格加载资源。
第 7 课. 终极项目:创建你的第一个网络应用程序
当我第一次涉足网页开发时,我非常想建立一个人们可以前往查看有趣食谱的网站。幸运的是,一家当地的烹饪学校 Confetti Cuisine 希望我为他们建立一个网站,包括一个展示课程提供的着陆页、一个食谱页面,以及一个潜在学生可以注册的地方。
作为一位烹饪爱好者,我认为这个项目是一个很好的日常使用项目。更重要的是,这个网站将很有趣,可以用 Node.js 来构建。将所有前面的课程整合成一个完整的多页面应用程序,这些步骤应该足以让我为 Confetti Cuisine 构建一个静态网站。
我将从零开始创建一个新的应用程序,并添加三个视图、视图和资源的路由以及一个公共客户端文件夹。首先,我将构建应用程序逻辑,目标是编写干净、非重复的代码。然后我将添加一些面向公众的视图和自定义样式。在本节课结束时,我将有一个网络服务器来处理对项目中特定文件和资源的请求。最终产品是一个我可以逐步构建并在我客户的要求下连接到数据库的产品。
要创建此应用程序,我将使用以下步骤:
-
初始化应用程序的 package.json。
-
设置项目目录结构。
-
在 main.js 中创建应用程序逻辑。
-
创建三个视图,每个视图都应该有一个可点击的图片,可以独立提供:
-
索引(主页)
-
课程
-
联系
-
感谢
-
错误
-
-
添加自定义资源。
-
构建应用程序的路由器。
-
处理应用程序错误。
-
运行应用程序。
我准备好大干一场了。
首先,我使用 npm 创建一个包含我正在开发的应用程序概要的 package.json 文件。我导航到电脑上我想保存此项目的目录,然后创建一个新的项目文件夹,使用以下终端命令:mkdir confetti_cuisine && cd confetti_cuisine和npm init。
我遵循命令行说明,除了以下内容外,我接受所有默认值:
-
使用 main.js 作为入口点。
-
将描述更改为“一个预订烹饪课程的网站。”
-
将我的名字作为作者。
接下来,我在项目中运行 npm install http-status-codes --save 来安装 http-status-codes 包。在我的 confetti_cuisine 文件夹中,我的 package.json 文件应该类似于下一列表中的示例。
列表 7.1. 项目 package.json 文件内容
{
"name": "confetti_cuisine",
"version": "1.0.0",
"description": "A site for booking classes for cooking.",
"main": "main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
},
"author": "Jon Wexler",
"license": "ISC",
"dependencies": {
"http-status-codes": "¹.3.0"
}
} *1*
- 1 在终端中显示我的 package.json
从现在开始,我能够将此文件作为我应用程序配置的总结来引用。
7.2. 理解应用程序目录结构
在我继续编写更多代码之前,我想回顾一下应用程序的目录结构。在项目结构中,我希望我的 main.js、package.json 和 router.js 文件位于目录的根级别。任何 HTML 内容都将表示为单独的 .html 文件,这些文件将位于项目文件夹中的 views 文件夹内。我的完整应用程序项目目录将类似于以下列表中的结构。
列表 7.2. confetti_cuisine 的项目目录结构
. *1*
|____main.js
|____router.js
|____public
| |____css
| | |____confetti_cuisine.css
| | |____bootstrap.css
| |____images
| | |____product.jpg
| | |____graph.png
| | |____cat.jpg
| | |____people.jpg
| |____js
| | |____confettiCuisine.js
|____package-lock.json
|____package.json
|____contentTypes.js
|____utils.js
|____views
| |____index.html
| |____contact.html
| |____courses.html
| |____thanks.html
| |____error.html
- 1 从根目录显示目录树
我的应用程序服务器将以我的 views 文件夹中的 HTML 文件响应。那些文件所依赖的资产将位于一个名为 public 的文件夹中。
注意
HTML 文件将由客户端查看,但它们不被视为资产,也不放入公共文件夹中。
公共文件夹包含一个 images、js 和 css 文件夹,用于存放应用程序面向客户端的资产。这些文件定义了应用程序与其用户之间的样式和 JavaScript 交互。为了给我的应用程序添加一些快速样式,我从 getbootstrap.com/docs/4.0/getting-started/download/ 下载了 bootstrap.css 并将其添加到公共文件夹中的 css 文件夹。我还创建了一个 confetti_cuisine.css 文件,用于应用任何我想为该项目添加的自定义样式规则。
接下来,我在项目的终端窗口中运行 npm install http-status-codes --save 来安装 http-status-codes 包。在我的 confetti_cuisine 文件夹中,我的 package.json 文件应该类似于下一列表中的示例。
7.3. 创建 main.js 和 router.js
现在我已经设置了文件夹结构并初始化了项目,我需要向网站添加主应用程序逻辑,以便它在端口 3000 上提供服务。我将把路由保存在一个单独的文件中,所以我需要引入该文件以及 fs 模块,以便我可以提供静态文件。
我创建了一个名为 main.js 的新文件。在该文件中,我分配了应用程序的端口号,并引入了 http、http-status-codes 模块以及即将构建的自定义模块 router、contentTypes 和 utils,如 列表 7.3 所示。
注意
contentTypes 和 utils 模块只是帮助我在 main.js 中组织变量。
列表 7.3. main.js 的内容以及所需的模块
const port = 3000, *1*
http = require("http"),
httpStatus = require("http-status-codes"),
router = require("./router"),
contentTypes = require("./contentTypes"),
utils = require("./utils");
- 1 导入所需的模块。
在创建本地模块之前,应用程序不会启动,所以我将首先创建 contentTypes.js,使用以下列表中的代码。在这个文件中,我导出一个对象,该对象将文件类型映射到它们的头值,用于我的响应。稍后,我将通过在 main.js 中使用 contentTypes.html 来访问 HTML 内容类型。
列表 7.4. contentTypes.js 中的对象映射
module.exports = { *1*
html: {
"Content-Type": "text/html"
},
text: {
"Content-Type": "text/plain"
},
js: {
"Content-Type": "text/js"
},
jpg: {
"Content-Type": "image/jpg"
},
png: {
"Content-Type": "image/png"
},
css: {
"Content-Type": "text/css"
}
};
- 1 导出内容类型映射对象。
接下来,我设置了一个新模块 utils 中的函数,该函数将用于读取文件内容。在 utils.js 中,我添加了下一个列表中的代码。在这个模块中,我导出一个包含 getFile 函数的对象。这个函数在提供的路径中查找文件。如果文件不存在,我立即返回一个错误页面。
列表 7.5. utils.js 中的实用函数
const fs = require("fs"),
httpStatus = require("http-status-codes"),
contentTypes = require("./contentTypes"); *1*
module.exports = { *2*
getFile: (file, res) => {
fs.readFile(`./${file}`, (error, data) => {
if (error) {
res.writeHead(httpStatus.INTERNAL_SERVER_ERROR,
contentTypes.html);
res.end("There was an error serving content!");
}
res.end(data);
});
}
};
-
1 导入在 getFile 中使用的模块。
-
2 导出一个读取文件并返回响应的函数。
最后,在新的文件中,我添加了 列表 7.6 中的代码。这个 router.js 文件需要 http-status-codes 和我的两个自定义模块:contentTypes 和 utils。
router 模块包含一个 routes 对象,该对象通过我的 get 函数映射到 GET 请求,通过我的 post 函数映射到 POST 请求。handle 函数是 main.js 中 createServer 调用的回调函数。get 和 post 函数接受一个 URL 和回调函数,然后在 routes 对象中将它们映射到对方。如果没有找到路由,我使用 utils 模块中的自定义 getFile 函数来响应错误页面。
列表 7.6. 在 router.js 中处理路由
const httpStatus = require("http-status-codes"),
contentTypes = require("./contentTypes"),
utils = require("./utils");
const routes = { *1*
"GET": {},
"POST": {}
};
exports.handle = (req, res) => { *2*
try {
routes[req.method]req.url;
} catch (e) {
res.writeHead(httpStatus.OK, contentTypes.html);
utils.getFile("views/error.html", res);
}
};
exports.get = (url, action) => { *3*
routes["GET"][url] = action;
};
exports.post = (url, action) => {
routes["POST"][url] = action;
};
-
1 创建一个包含路由函数的路由对象。
-
2 创建处理请求的 handle 函数。
-
3 创建获取和设置函数以映射路由函数。
为了让我的应用程序服务器运行,我需要设置应用程序的路由和视图。
7.4. 创建视图
视图是面向客户端的,可能会影响用户对应用程序的体验。我将为每个页面使用类似的模板,以减少应用程序的复杂性。每个 HTML 页面的顶部应该有一些 HTML 布局、一个 head、一个链接到即将构建的自定义样式表,以及导航。Confetti Cuisine 网站的首页将类似于 图 7.1,在页面的右上角有链接到我的三个视图。
图 7.1. Confetti Cuisine 的示例主页

对于主页,我将在 views 文件夹中创建一个新的视图 index.html,并添加特定于索引页面的内容。因为我使用 bootstrap.css,所以我需要在 HTML 页面的 head 标签中添加 <link rel="stylesheet" href="/bootstrap.css"> 来链接到该文件。我将对自定义样式表 confetti_cuisine.css 做同样的事情。
接下来,我创建了一个 courses.html 文件来展示可用的烹饪课程列表,以及一个包含以下表单的 contact.html 文件。此表单通过POST将联系信息提交到/路由。表单的代码应类似于下一列表中的代码。
列表 7.7. contact.html 中提交到主页路由的示例表单
<form class="contact-form" action="/" method="post"> *1*
<input type="email" name="email" required>
<input class="button" type="submit" value="submit">
</form>
- 1 构建一个表单,用于将姓名提交到主页。
我的网站联系页面将类似于图 7.2。
图 7.2. Confetti Cuisine 的示例联系页面

每个页面都通过导航栏链接到其他页面。我需要确保在创建我的路由时,这些文件中使用的所有资源都已计入。如果缺少任何资源,当应用程序尝试查找其对应的文件时,可能会崩溃。
我将添加这些资源,以便我的页面具有更丰富的内容。
7.5. 添加资源
对于这个应用程序,我创建了一些自定义样式,供每个视图使用。我想要在网站元素中进行的任何颜色、尺寸或位置更改都将放入 public/css 中的 confetti_cuisine.css,它与 bootstrap.css 位于同一目录下。
当这个文件保存时,我的视图在加载时将具有颜色和结构。如果我想使用任何客户端 JavaScript,我需要创建一个.js 文件,将其添加到我的 public/js 文件夹中,并在每个文件中使用<script>标签链接到它。最后,我将我的图片添加到public/images。这些图片的名称应该与我在我 HTML 视图中使用的名称相匹配。
剩下的唯一步骤是为我的项目中每个视图和资源注册路由和处理。
7.6. 创建路由
最后一个重要的拼图碎片是路由。我的应用程序的路由将决定哪些 URL 可供客户端访问以及我将提供哪些文件。
我特别创建了一个 router.js 文件来处理我的路由,但我仍然需要注册它们。注册我的路由基本上意味着将一个 URL 和回调函数传递给我的router.get或router.post函数,具体取决于我正在处理哪种 HTTP 方法。这些函数将我的路由添加到router.routes,这是一个 JavaScript 对象,它将我的 URL 映射到当访问该 URL 时要调用的回调函数。
为了总结,要注册一个路由,我需要声明以下内容:
-
请求是
GET还是POST请求 -
URL 的路径
-
要返回的文件名
-
HTTP 状态码
-
返回文件的类型(作为内容类型)
在每个回调函数中,我需要指出将放入响应中的内容类型,并使用fs模块将我的视图和资源的内容读取到响应中。我在 main.js 中的 require 行下方添加路由和代码。
列表 7.8. 在 main.js 中使用router模块注册单个路由
router.get("/", (req, res) => { *1*
res.writeHead(httpStatus.OK, contentTypes.htm);
utils.getFile("views/index.html", res);
});
router.get("/courses.html", (req, res) => {
res.writeHead(httpStatus.OK, contentTypes.html);
utils.getFile("views/courses.html", res);
});
router.get("/contact.html", (req, res) => {
res.writeHead(httpStatus.OK, contentTypes.html);
utils.getFile("views/contact.html", res);
});
router.post("/", (req, res) => {
res.writeHead(httpStatus.OK, contentTypes.html);
utils.getFile("views/thanks.html", res);
});
router.get("/graph.png", (req, res) => {
res.writeHead(httpStatus.OK, contentTypes.png);
utils.getFile("public/images/graph.png", res);
});
router.get("/people.jpg", (req, res) => {
res.writeHead(httpStatus.OK, contentTypes.jpg);
utils.getFile("public/images/people.jpg", res);
});
router.get("/product.jpg", (req, res) => {
res.writeHead(httpStatus.OK, contentTypes.jpg);
utils.getFile("public/images/product.jpg", res);
});
router.get("/confetti_cuisine.css", (req, res) => {
res.writeHead(httpStatus.OK, contentTypes.css);
utils.getFile("public/css/confetti_cuisine.css", res);
});
router.get("/bootstrap.css", (req, res) => {
res.writeHead(httpStatus.OK, contentTypes.css);
utils.getFile("public/css/bootstrap.css", res);
});
router.get("/confetti_cuisine.js", (req, res) => {
res.writeHead(httpStatus.OK, contentTypes.js);
utils.getFile("public/js/confetti_cuisine.js", res);
});
http.createServer(router.handle).listen(port); *2*
console.log(`The server is listening on
port number: ${port}`);
-
1 为网页和资源添加一系列路由。
-
2 启动服务器。
注意
注意POST路由,它将处理 contact.html 页面上的表单提交。而不是响应另一个 HTML 页面,这个路由响应一个 HTML“感谢您支持产品”页面。
现在我应该能够使用node main启动我的应用程序,并通过访问 http://localhost: 3000 来查看我的网络应用程序的主页。
注意
我只为我在项目中表示为文件的资源(图像、js 和 css)创建路由。
摘要
在这个综合练习中,我构建了一个完整的网络应用程序,为 Confetti Cuisine 提供静态网页。为了完成这个任务,我需要在主应用程序文件中添加自己的路由模块。然后我创建了一个自定义系统来路由用户请求以提供特定内容。在构建用于以有组织、系统的方式注册路由的自定义函数后,我创建了从各自的目录中提供视图和资源的文件。
在这里正在进行大量的代码逻辑处理,这些代码逻辑正朝着全球 Node.js 应用程序所使用的专业结构发展。
在第 3 单元中,我探讨了网络框架,并展示了它们如何使用这种应用程序结构和一些脚手架(预构建的文件夹和结构)以更少的步骤和更少的麻烦完成相同的应用程序。
单元 2. 使用 Express.js 更容易地进行网络开发
单元 1 教导了你如何使用 Node.js 构建网络服务器以及如何使用内置模块构建有意义的内 容。本单元是关于通过使用网络框架和动态内容将你的应用程序提升到一个更稳健和专业的水 平。网络框架 是一个预定义的应用程序结构和一组开发工具库,旨在使构建网络应用程序更加容易和一致。
在本单元中,你将学习如何使用 Express.js 设置应用程序并组织你的应用程序文件结构以优化页面之间数据通信。你还将了解模型-视图-控制器(MVC)应用程序架构,它将你的代码组织成三个不同的职责:
-
为你的数据构建结构
-
显示该数据
-
处理请求以与该数据交互
为了在 单元 1 中学习到的课程基础上进行构建,并修改你的代码以充分利用 Express.js,本单元涵盖了以下主题:
-
第 8 课 介绍了 Express.js 并展示了如何配置新的 Node.js 应用程序。在本课中,你将了解一个网络框架如何帮助你开发应用程序的概述。
-
第 9 课 讲解了使用 Express.js 进行路由。你已经学习了从头开始编写路由的方法。本课将向你介绍本书其余部分将使用的路由风格。你还将了解 MVC 模式,并了解在这种结构中路由如何像控制器一样行为。
-
第 10 课 介绍了布局和动态渲染视图的概念。到目前为止,你只处理了静态内容,但在本课中,你使用 Express.js 在每次页面刷新时向你的视图提供内容。本课还讨论了 Node.js 中的模板化。在 Express.js 中,模板引擎正在工作,允许你在 HTML 页面中写入动态内容的占位符。
-
第 11 课 在前面的课程基础上,展示了如何处理应用程序错误并使用 npm 配置启动脚本。
-
最后,第 12 课 展示了如何使用 Express.js 从 单元 1 重建你的项目。你重新创建了烹饪学校的网站的前端三个视图,并添加了从你的应用程序服务器动态填充内容的功能。
本单元是你进入可能感觉更熟悉的网络应用程序的第一步。熟悉 Express.js 和外部包将使你成为一个更熟练的开发者。当你的 Node.js 应用程序在 Express.js 上成功运行时,单元 3 将讨论如何将你的应用程序连接到数据库并保存用户信息。
第 8 课. 使用 Express.js 设置应用程序
随着 Web 框架的加入,构建 Web 应用程序已经变得更容易。Node.js 中的 Web 框架是一个模块,为你的应用程序提供结构。通过这个结构,你可以轻松地构建和自定义应用程序的感觉,而无需担心从头开始构建某些功能,例如服务单个文件。在本课结束时,你将了解如何开始使用 Web 框架,以及本书中使用的 Express.js 如何减少你使应用程序运行所需的时间。
本课涵盖
-
使用 Express.js 设置 Node.js 应用程序
-
导航 Web 框架
考虑这一点
你从单元 1 中的静态 Web 应用程序已经成功了。烹饪社区希望你添加更多功能并服务更多网页。你意识到你的应用程序还没有完全准备好处理更多的路由,更不用说处理错误或服务其他类型的资源了。有没有一种更简单的方法可以从已有结构开始开发?
幸运的是,你可以使用 Node.js 应用程序安装一个 Web 框架。Express.js,这本书中使用的框架,直接处理了大多数应用程序需要的许多任务,例如错误处理和静态资源服务。你对这个框架的方法和关键字越熟悉,你构建应用程序的速度就越快。
8.1. 安装 Express.js 包
Express.js 提高了开发速度,并为构建应用程序提供了一个稳定的结构。像 Node.js 一样,Express.js 提供了开源的工具,并由一个庞大的在线社区管理。
首先,我将讨论为什么 Express.js 是你应该学习的 Web 框架。随着每年过去,Node.js 获得了新的框架,其中一些提供了令人信服的理由去切换到它的库。Express.js 于 2010 年发布,从那时起,其他可靠的框架也日益流行。表 8.1 列出了你可以考虑的其他框架。
表 8.1. 你应该了解的 Node.js 框架
| Node.js 框架 | 描述 |
|---|---|
| Koa.js | 由构建 Express.js 的开发者设计,专注于 Express.js 中未提供的库方法(koajs.com/) |
| Hapi.js | 设计与 Express.js 具有相似架构,并专注于编写更少的代码(hapijs.com/) |
| Sails.js | 建立在 Express.js 之上,提供了更多的结构,以及更大的库和更少的定制机会(sailsjs.com/) |
| Total.js | 建立在核心 HTTP 模块之上,因其高性能的请求处理和响应而受到赞誉(www.totaljs.com/) |
注意
关于 Node.js Web 框架的更多信息,你可以查看 GitHub 仓库的更新列表,网址为nodeframework.com/。
最终,框架旨在帮助您克服从头开始构建 web 应用程序时的一些常见开发挑战。Express.js 是 Node.js 社区中最常用的框架,确保您与其他较新框架提供的支持相比,能够找到所需的支持。尽管我推荐使用 Total.js,因为它在性能和可扩展性方面表现良好,但它不一定是最适合开始的框架。
由于您是第一次使用 Node.js 来构建 web 应用程序,您需要一些工具来帮助您在这个过程中。Web 框架旨在提供一些在 web 开发中常用的工具。Express.js 提供了处理请求、提供静态和动态内容、连接数据库以及跟踪用户活动等方法和模块。您将在后面的课程中了解更多关于 Express.js 如何提供这种支持的信息。
Express.js 被新的和专业的 Node.js 开发者共同使用,所以如果您在任何时候感到不知所措,请记住,有成千上万的人可以帮助您克服开发障碍。
现在,您已经准备好使用 Express.js 初始化应用程序了。首先,您需要通过创建一个名为 first_express_project 的新项目目录,在新的终端窗口中进入该目录,并输入 npm init 来初始化您的应用程序。您可以按照提示将 main.js 保存为主入口点,并保存所有其他默认值。
注意
如 课程 1 中所述,初始化新项目会创建一个 package.json 文件,您可以使用它来定义应用程序的一些属性,包括您下载和依赖的包。
由于 Express.js 是一个外部包,它不会与 Node.js 预先安装。您需要在终端中运行以下命令来下载和安装它:npm install express --save。
注意
在撰写本文时,Express.js 的最新版本是 4.16.3。为确保您的 Express.js 版本与本书中使用的版本一致,请运行 npm install express@4.16.3 --save 来安装该包。
警告
如果在创建 package.json 之前尝试在特定项目中安装 Express.js,您可能会看到一个错误,抱怨没有目录或文件可以完成安装。
使用 --save 标志,以便将 Express.js 列为应用程序依赖项。换句话说,您的应用程序依赖于 Express.js 来工作,因此您需要确保它已安装。打开 package.json 来查看此 Express.js 包安装位于 dependencies 列表中。
提示
如果您想从终端窗口访问 Express.js 包的文档,请输入 npm docs express。此命令将打开您的默认网页浏览器,访问 expressjs.com。
在下一节中,您将创建您的第一个 Express.js 应用程序。
快速检查 8.1
Q1:
如果你在为你的应用程序安装 Express.js 时没有使用
--save标志,会发生什么?
QC 8.1 答案
1:
没有使用
--save标志,你的 Express.js 安装不会被标记为应用程序依赖项。你的应用程序仍然可以在本地运行,因为 Express.js 将被下载到你的项目node_modules文件夹中,但如果你不包含该文件夹上传你的应用程序代码,你的package.json文件中不会有任何指示表明需要 Express.js 包来运行你的应用程序。
8.2. 构建你的第一个 Express.js 应用程序
要开始使用 Express.js,你需要创建一个主应用程序文件并引入express模块。将代码保存在列表 8.1 中,并将其保存为项目中的main.js文件。
你通过引用模块名express并将其存储为一个常量来引入 Express.js。express提供了一个方法库和功能,包括一个具有内置网络服务器功能的一个类。express网络服务器应用程序被实例化并存储在一个常量中,以便引用为app。在整个项目的其余部分,你将使用app来访问 Express.js 的大部分资源。
与第一个项目一样,Express.js 提供了一种定义GET路由及其回调函数的方法,而无需构建额外的模块。如果对主页发起请求,Express.js 会捕获它并允许你进行响应。
以纯文本形式发送响应到浏览器。注意 Express.js 的send方法,它与http模块中的write方法行为类似。Express.js 还支持http模块的方法。如果你使用write,请记住使用end来完成你的响应。最后,你设置应用程序监听本地主机的 3000 端口上的请求,并在应用程序成功运行时将一条有用的消息记录到你的控制台。
列表 8.1. main.js 中的简单 Express.js 网络应用程序
const port = 3000,
express = require("express"), *1*
app = express(); *2*
app.get("/", (req, res) => { *3*
res.send("Hello, Universe!"); *4*
})
.listen(port, () => { *5*
console.log(`The Express.js server has started and is listening
on port number: ${port}`);
});
-
1 将 express 模块添加到你的应用程序中。
-
2 将 express 应用程序分配给 app 常量。
-
3 为主页设置一个 GET 路由。
-
4 使用 res.send 从服务器向客户端发送响应。
-
5 设置应用程序以监听 3000 端口。
尝试一下。确保你在命令行中处于你的项目目录。运行node main,然后访问 http://localhost:3000。如果你在屏幕上看到Hello, Universe!,那么你已经构建了第一个成功的 Express.js 应用程序。
安装和使用 nodemon
要查看你的应用程序服务器代码更改的效果,你需要在终端中重新启动服务器。通过按 Command-D(Windows 上的 Ctrl-C)关闭现有服务器,然后再次输入node main.js。
你对应用程序应用的变化越多,这项任务就越繁琐。这就是我推荐安装 nodemon 包的原因。你可以使用这个包来首次启动你的应用程序,并在应用程序文件更改时自动重新启动它。
要全局安装 nodemon,请输入 npm i nodemon -g。你可能需要在命令前加上 sudo 或以管理员身份在终端中运行它。
或者,你可以将 nodemon 安装为开发依赖项(devDependency)或仅在应用程序开发期间使用的资源。运行 npm i nodemon --save-dev 或 npm i nodemon -D。nodemon 会与你的 npm start 脚本(在 第 11 课 中讨论)一起启动。将 nodemon 作为 devDependency 安装的好处是,每个项目都有自己的 nodemon 模块,反映了开发时包的最新版本。
当安装了 nodemon 后,使用起来很简单:nodemon 会检测你的 package.json 中的主属性。你的 package.json 也应该修改为包含 npm start 脚本。在 package.json 的 scripts 部分添加 "start": "nodemon main.js",这样你就可以使用 npm start 通过 nodemon 运行你的应用程序。在终端中进入你的项目目录,并输入 nodemon。这个命令会启动你的应用程序,并且你未来所做的任何更改都会通知 nodemon 重新启动,而无需输入另一个命令。
你可以通过在终端中的 nodemon 窗口中按下相同的键组合(Windows 上的 Command-D 或 Ctrl-C)来关闭服务器。
| |
注意
express 常量仍然用于一些与配置应用程序相关的 Express.js 工具。app 主要用于创建用于应用程序数据传输和用户交互的任何内容。
在下一节中,我将讨论 Express.js 作为网络框架提供的一些支持方式。
快速检查 8.2
Q1:
express和app常量之间的区别是什么?
| |
QC 8.2 答案
1:
app代表了你的应用程序的大部分内容,包括路由和其他模块的访问。express代表了一组更广泛的方法,这些方法不一定局限于你的应用程序。express可能会提供一种方法来分析或解析某些文本,而你的应用程序可能并不依赖于这些文本。
8.3. 在网络框架中工作
一个网络框架旨在为你完成许多繁琐的任务,并为你提供一个直观的结构来定制你的应用程序。Express.js 提供了一种通过回调函数监听特定 URL 的请求并响应的方法。
类似于 Express.js 这样的网络框架通过函数作为中间件来操作,因为这些函数位于 Web 上的 HTTP 交互和 Node.js 平台之间。中间件是一个通用术语,用于指代在数据与应用逻辑交互之前,帮助监听、分析、过滤和处理 HTTP 通信的代码。
您可以将中间件想象成一个邮局。在您的包裹进入配送网络之前,邮递员需要检查包裹的大小,并确保它已正确付费并符合配送政策(包裹内没有危险物品)。请参阅中间件的图示图 8.1。
图 8.1. Express.js 位于 HTTP 请求和您的应用程序代码之间。

注意
中间件可以比 Express.js 更小,一些中间件在数据传递到核心应用之前,扮演着检查传入请求的安全角色。
因为您仍在处理 HTTP 方法,所以您的应用程序与浏览器之间的整体交互与第 1 单元(../Text/kindle_split_014.html#part01)中使用 http 模块的应用程序相比变化不大。您获得相同的请求和响应对象,其中包含有关发送者和其内容的丰富信息。Express.js 提供了使您更容易获取这些信息的方法。
除了响应对象上的 send 方法外,Express.js 还提供了从请求体中提取和记录数据的更简单方法。将以下代码添加到 main.js 中的 GET 路由处理程序中。
列表 8.2. main.js 中的 Express.js 请求对象方法
console.log(req.params); *1*
console.log(req.body);
console.log(req.url);
console.log(req.query);
- 1 访问请求参数。
您可以从请求中提取表 8.2 中的值。
表 8.2. 请求对象数据项
| 请求数据对象 | 描述 |
|---|---|
| params | 允许您从 URL 中提取 ID 和令牌。当您学习到第 4 单元中的 RESTful 路由时,这个请求属性允许您识别在电子商务网站上请求的是哪些项目,或者您应该导航到哪个用户配置文件。 |
| body | 包含请求的大部分内容,通常包括来自 POST 请求的数据,如提交的表单。您可以从请求体中快速收集信息并将其保存到数据库中。 |
| url | 提供有关访问的 URL 的信息(类似于第 1 单元(../Text/kindle_split_014.html#part01)的基本 Web 服务器中的 req.url)。 |
| 查询 | 与 body 类似,允许您从提交到应用服务器的数据中提取信息。然而,这些数据不一定来自 POST 请求,通常作为查询字符串在 URL 中请求。 |
在重启您的应用程序并访问 http://localhost:3000 后,您会在服务器的终端窗口中看到这些值被记录下来。当您在学习 第 9 课 中关于 Express.js 路由时,您会探索如何更好地利用请求体。
小贴士
查询字符串 是以键/值对形式表示的文本,位于主机名后的问号 (?) 之后。例如,http://localhost:3000?name=jon 正在发送 name(键)与 jon(值)配对的 数据。这些数据可以在路由处理程序中提取和使用。
快速检查 8.3
Q1:
为什么大多数开发者使用网络框架而不是从头开始构建网络应用程序?
QC 8.3 答案
1:
网络框架使开发工作变得容易得多。网络开发很有趣,最好的部分不是那些容易出错且繁琐的任务。有了网络框架,开发者和企业都可以专注于应用程序更有趣的部分。
摘要
在本课中,您学习了如何初始化 Express.js 项目,并启动了一个简单的应用程序,在您的网页浏览器中显示“你好”。您还了解了 Express.js 作为网络框架,并看到了您将如何从其方法中受益。在 第 9 课 中,您将应用一些 Express.js 方法来构建路由系统。
尝试这个
将您的 index.js 文件中的 get 方法更改为 post。重启您的应用程序,并尝试在 http://localhost:3000 访问主页时,看看您的应用程序的行为有何不同。您应该会看到 Express 的默认错误消息,告诉您 / 没有对应的 GET 路由。
原因是您更改了您正在监听请求的方法。如果您向主页发送 curl POST 请求,您会看到您原始的响应内容。
第 9 课。Express.js 中的路由
在 第 8 课 中,我介绍了 Express.js 作为 Node.js 网络应用程序的框架。本单元的其余部分致力于探索 Express.js 的功能并使用其便捷的方法。本课涵盖了路由以及如何在构建视图之前,使用一些额外的 Express.js 方法向用户发送有意义的 数据。您还了解了如何收集请求的查询字符串。课程结束时简要提到了 MVC 设计模式。
本课涵盖
-
为您的应用程序设置路由
-
从另一个模块返回数据
-
收集请求 URL 参数
-
将路由回调移动到控制器
考虑这个
您想为您的食谱应用程序构建一个主页视图,人们可以访问以查看应用程序的预计完成日期。有了您的新、干净的 Express.js 设置,您希望将日期变量放在一个单独的文件中,这样您就可以轻松地更改它,而无需修改 main.js 文件。
在设置好路由后,你将能够在单独的模块中存储一些数据,并使用这些数据动态响应。使用单独的模块,你将能够修改该文件的內容,而无需编辑主应用程序文件。这种结构有助于你在不断更改值的同时避免在代码中出错。
9.1. 使用 Express.js 构建路由
在第 8 课中,你构建了你的第一个 Express.js 应用程序,该应用程序处理对主页 URL 的GET请求。另一种描述此路由的方式是将其视为一个应用程序端点,该端点接受 HTTP 方法和路径(URL)。Express.js 中的路由应该对你来说很熟悉,因为你已经在第 1 单元的结尾构建了相同的路由结构。在 Express.js 中,路由定义从你的app对象开始,后面跟着小写的 HTTP 方法及其参数:路由路径和回调函数。
处理到/contact路径的POST请求的路由应该看起来像以下列表。此示例使用 Express.js 提供的post方法。
列表 9.1. main.js 中的 Express.js POST路由
app.post("/contact", (req, res) => { *1*
res.send("Contact information submitted successfully.");
});
- 1 使用 Express.js 的 post 方法处理请求。
你可以在app对象上使用这些 HTTP 方法,因为app是主 Express.js 框架类的实例。通过安装此包,你继承了路由方法,而无需编写任何其他代码。
Express.js 允许你在路径中编写带有参数的路由。这些参数是通过请求发送数据的一种方式。(另一种方式是查询字符串,我在本课的结尾会提到。)路由参数在参数之前有一个冒号(:),并且可以在路径的任何位置存在。列表 9.2 显示了带有参数的路由的示例。此列表中的路由期望一个对/items/的请求,加上一些蔬菜名称或数字。例如,对"/items/lettuce"的请求将触发路由及其回调函数。响应通过请求对象的params属性将项目从 URL 发送回用户。
列表 9.2. 在 main.js 中使用路由参数来指示蔬菜类型
app.get("/items/:vegetable", (req, res) => { *1*
res.send(req.params.vegetable);
});
- 1 使用路径参数进行响应。
初始化一个名为 express_routes 的新项目,安装 Express.js,并将代码添加到 require 和实例化 Express.js 模块。然后创建一个带有参数的路由,并像列表 9.2 中所示那样响应该参数。此时,你的 main.js 应该看起来像下一个列表中的代码。
列表 9.3. main.js 中的完整 Express.js 示例
const port = 3000,
express = require("express"),
app = express();
app.get("/items/:vegetable", (req, res) => { *1*
let veg = req.params.vegetable;
res.send(`This is the page for ${veg}`);
});
app.listen(port, () => {
console.log(`Server running on port: ${port}`);
});
- 1 添加一个获取 URL 参数的路由。
路由参数对于在您的应用程序中指定数据对象很有用。例如,当您开始在数据库中保存用户账户和课程列表时,您可以使用 /users/:id 和 /course/:type 路径分别访问用户的个人资料或特定课程。这种结构对于开发您在 单元 4 中学习的表示状态传输 (REST) 架构是必要的。
关于 Express.js 路由的最后一句话:我谈到了 Express.js 是一种中间件,因为它在请求被接收和处理之间添加了一个层。这个特性很棒,但您可能想添加自己的自定义中间件。例如,您可能想记录发送到您应用程序的每个请求的路径以供自己记录。您可以通过向每个路由添加日志消息或在 列表 9.4 中创建中间件函数来完成此任务。此列表定义了一个带有额外 next 参数的中间件函数,将请求的路径记录到您的终端控制台,然后调用 next 函数以继续请求-响应周期中的链。
next 提供了一种在请求-响应执行流程中调用下一个函数的方式。从请求进入服务器的那一刻起,它访问一系列中间件函数。根据您添加自己的自定义中间件函数的位置,您可以使用 next 通知 Express.js 您的函数已完成,并且您希望继续链中的下一个函数。
与 HTTP 方法一样,您可以使用 app.use 创建在每次请求上运行的路由。区别在于您在回调中添加了一个额外的参数:next 函数。此中间件函数允许您在请求的 URL 路径与您的应用程序中的任何其他路由匹配之前运行自定义代码。当您的自定义代码完成后,next 将请求指向下一个与路径匹配的路由。
尝试将此中间件函数添加到您的 express_routes 应用程序中。如果请求 /items/lettuce,则请求首先由您的中间件函数处理,然后是您之前创建的 app.get("/items/:vegetable") 路由。
列表 9.4. main.js 中的 Express.js 中间件函数用于记录请求路径
app.use((req, res, next) => { *1*
console.log(`request made to: ${req.url}`); *2*
next(); *3*
});
-
1 定义一个中间件函数。
-
2 将请求的路径记录到控制台。
-
3 调用 next 函数。
警告
在函数末尾调用 next 是必要的,以通知 Express.js 您的代码已完成。如果不这样做,您的请求将处于挂起状态。中间件按顺序运行,因此不调用 next 将阻止您的代码继续执行直到完成。
您还可以指定一个您希望中间件函数运行的路径。例如,app.use("/items", <callback>) 将为以 items 开头的每个请求运行您的自定义回调函数。图 9.1 显示了中间件函数如何与服务器上的请求交互。
图 9.1. 中间件函数的作用

在下一节中,我将讨论在您的路由中处理数据并使用该数据响应。
快速检查 9.1
Q1:
Express.js 的
use方法做什么?
QC 9.1 答案
1:
use方法允许您定义您想要与 Express.js 一起使用的中间件函数。
9.2. 分析请求数据
在您的应用程序中准备花哨和动态的响应很重要,但最终,您将需要证明应用程序能够从用户的请求中捕获数据的能力。
您有两种主要方式从用户那里获取数据:
-
通过
POST请求中的请求体 -
通过 URL 中的请求查询字符串
在第一个综合项目中,您成功构建了一个将数据提交到 POST 路由(监听特定 URL 上提交数据的路由)的表单。但 http 进入的数据表示为 Buffer 流,这不是人类可读的,并且为使该数据可处理添加了一个额外的步骤。
Express.js 使用 body 属性使检索请求体变得简单。为了帮助读取 body 内容(截至 Express.js 版本 4.16.0),您将 express.json 和 express.urlencoded 添加到您的 app 实例中,以分析传入的请求体。注意在 列表 9.5 中使用 req.body 将发布的数据记录到控制台。将此代码添加到您项目的 main.js 中。使用 Express.js 的 app.use,指定您想要解析的传入请求是 URL 编码的(通常是表单 POST 和 utf-8 内容)以及 JSON 格式。然后为发布的数据创建一个新的路由。这个过程就像使用 post 方法并指定一个 URL 一样简单。最后,使用请求对象及其 body 属性打印发布表单的内容。
列表 9.5. 在 main.js 中从请求体捕获发布数据
app.use(
express.urlencoded({
extended: false
})
); *1*
app.use(express.json());
app.post("/", (req, res) => { *2*
console.log(req.body); *3*
console.log(req.query);
res.send("POST Successful!");
});
-
1 告诉您的 Express.js 应用程序解析 URL 编码数据。
-
2 为主页创建一个新的 POST 路由。
-
3 记录请求的体。
通过以下 curl 命令向 http://localhost:3000 提交 POST 请求来测试此代码:curl --data "first_name=Jon&last_name=Wexler" http://localhost:3000.
您应该看到如下所示的内容记录到您的服务器控制台窗口:{ first_name: "Jon", last_name: "Wexler" }。
现在当您向客户演示后端代码时,您可以通过模拟表单提交来向他们展示数据如何在服务器上收集。
收集数据的另一种方式是通过 URL 参数。无需额外的包,Express.js 允许您收集存储在 URL 路径末尾的值,后面跟着一个问号 (?)。这些值被称为 查询字符串,它们通常用于跟踪网站上的用户活动以及存储有关用户访问页面的临时信息。
检查以下示例 URL:http://localhost:3000?cart=3&pagesVisited=4&utmcode=837623。这个 URL 可能正在传递有关用户购物车中项目数量的信息,他们访问的页面数量,以及一个营销代码,让网站所有者知道用户最初是如何找到您的应用的。
要在服务器上查看这些查询字符串,请将 console.log(req.query) 添加到 main.js 中的中间件函数。现在尝试访问相同的 URL。你应该看到 { cart: "3", pagesVisited: "4", utmcode: "837623" } 在您的服务器控制台窗口中记录。
在下一节中,我将讨论 MVC 架构以及 Express.js 路由如何融入该结构。
快速检查 9.2
Q1:
需要哪些额外的中间件函数来解析使用 Express.js 的请求体中的传入数据?
| |
QC 9.2 答案
1:
express.json 和 express.urlencoded 用于解析传入服务器中的数据。其他包,如
body-parser,充当中间件并执行类似任务。
9.3. 使用 MVC
本节课是关于在您的路由中处理请求数据。Express.js 打开了自定义模块和代码的大门,以便在请求-响应周期内读取、编辑和响应数据。为了组织这个不断增长的代码库,你将遵循一个名为 MVC 的应用程序架构。
MVC 架构侧重于您应用程序功能的主要三个部分:模型、视图和控制器。您在以前的应用程序中使用了视图来在响应中显示 HTML。请参阅 表 9.1 中的分解和定义。
表 9.1. 模型-视图-控制器部分
| 视图 | 从您的应用程序中渲染数据。在 第 3 单元 中,你学习了模型,甚至创建了您自己的模型。 |
|---|---|
| 模型 | 代表您应用程序和数据库中面向对象数据的类。在您的食谱应用程序中,您可能创建一个模型来表示客户订单。在这个模型中,您定义订单应包含哪些数据以及可以在该数据上运行哪些类型的函数。 |
| 控制器 | 视图和模型之间的粘合剂。当接收到请求时,控制器执行大部分逻辑,以确定如何处理请求体数据以及如何涉及模型和视图。这个过程应该听起来很熟悉,因为在 Express.js 应用程序中,您的路由回调函数充当控制器。 |
要遵循 MVC 设计模式,将你的回调函数移动到反映这些函数目的的独立模块中。例如,与用户账户创建、删除或更改相关的回调函数可以放在 controllers 文件夹中的 usersController.js 文件中。用于渲染主页或其他信息页面的函数可以按照惯例放在 homeController.js 中。图 9.2 显示了你的应用程序将遵循的文件结构。
图 9.2. Express.js MVC 文件结构

图 9.3 显示了 Express.js 作为一层覆盖在你的应用程序之上,处理请求但同时也为你的应用程序控制器提供数据。回调函数决定是否渲染视图或向客户端发送某些数据。
图 9.3. Express.js 可以遵循 MVC 结构,路由为控制器提供数据

要将 express_routes 应用程序重构为遵循此结构,请按照以下步骤操作:
-
在你的项目文件夹内创建一个 controllers 文件夹。
-
在 controllers 文件夹内创建一个 homeController.js 文件。
-
通过在 main.js 的顶部添加以下内容将你的主控制器文件引入到应用程序中:
const homeController = require("./controllers/homeController"); -
将你的路由回调函数移动到主控制器,并将它们添加到该模块的
exports对象中。例如,响应带有蔬菜参数的路由可以移动到你的主控制器,看起来像 列表 9.6。在 homeController.js 中,你将exports.sendReqParam分配给回调函数。sendReqParam是一个变量名,所以你可以选择自己的名字来描述该函数。列表 9.6. 将回调函数移动到 homeController.js
exports.sendReqParam = (req, res) => { *1* let veg = req.params.vegetable; res.send(`This is the page for ${veg}`); };- 1 创建一个处理特定路由请求的函数。
-
在 main.js 中,将路由更改为下一个列表所示的样子。当对这条路径发出请求时,分配给主控制器中
sendReqParam的函数将被执行。列表 9.7. 在 main.js 中用控制器函数替换回调函数
app.get("/items/:vegetable", homeController.sendReqParam); *1*- 1 处理对“/items/:vegetable”的 GET 请求。
-
将此结构应用到其余的路由中,并继续使用控制器模块来存储路由的回调函数。例如,你可以将请求记录中间件移动到主控制器中的一个函数,称为
logRequestPaths。 -
重新启动你的 Node.js 应用程序,并查看路由是否仍然工作。在这种设置下,你的 Express.js 应用程序正在以 MVC 的形式采取新的形式。
在下一课中,我将讨论如何使用 Express.js 提供视图和资源。
安装和使用 express-generator
随着你继续演进你的 Express.js 应用程序,你遵循特定的文件结构。然而,根据其预期用途,你有多种构建应用程序的方法。为了在 Express.js 框架中快速启动你的应用程序,你可以使用一个名为 express-generator 的包。
express-generator 为应用程序提供了一些样板代码。这个工具提供了脚手架(预构建的文件夹、模块和配置),这可能需要你花费几个小时从头开始构建。要安装此包,使用 npm install 命令的全局标志。在终端中输入以下命令:npm install express-generator -g。对于 UNIX 计算机,你可能需要在命令前添加 sudo 或以管理员身份运行。
当这个包安装后,你可以在新的终端窗口中输入 express 和项目名称来创建一个新的项目。例如,如果你的项目名为 Generation Generator,请在终端中输入 express generation_generator。在这个上下文中,express 关键字使用 express-generator 在终端中构建应用程序,其中包含一些视图和路由。
虽然这个工具对于快速构建应用程序非常出色,但我不建议在本书的练习中使用它。你应该使用与 express-generator 提供的结构略有不同的应用程序结构。有关此包的更多信息,请访问 expressjs.com/en/starter/generator.html。
快速检查 9.3
Q1:
控制器在 MVC 中扮演什么角色?
QC 9.3 答案
1:
控制器负责通过与模型通信、执行代码逻辑和调用在服务器响应中渲染视图来处理数据。
摘要
在本课中,你学习了如何使用 Express.js 构建路由和中间件函数。然后你使用中间件函数与 Express.js 一起处理请求体内容。在本课结束时,你了解了 MVC 模式,并看到了如何将路由重写以在应用程序中使用控制器。在第 10 课中,你将进入视图和布局等丰富功能。有了这些工具,你可以更快地构建视图。
尝试这个
你已经为 MVC Express.js 应用程序设置了目录结构。尝试为 /sign_up 路径创建一个 POST 路由,使用 Express.js 方法和控制器的函数作为路由的回调。
控制器中函数的名称可以像 userSignUpProcessor 一样。
第 10 课:连接视图与模板
在第 9 课中,你为你的 Express.js 应用程序构建了一个路由系统。在本课中,你将学习关于模板引擎的内容,并了解如何将你的路由连接到视图。你将学习如何使用嵌入式 JavaScript (EJS),这是一种在视图内应用 JavaScript 函数和变量的语法,以及如何从你的控制器中将数据传递到这些视图。你首先设置 EJS 与你的应用程序,并了解模板引擎是如何工作的。在本课结束时,你将了解在 Express.js 应用程序中掌握 EJS 所需的语法。课程结束时,你将安装express-ejs-layouts包以在应用程序中使用动态布局。
本课涵盖
-
将模板引擎连接到你的应用程序
-
从你的控制器向你的视图传递数据
-
设置 Express.js 布局
考虑这一点
你有一些线框图,展示了你的应用程序页面将如何看起来,并且你注意到许多页面都共享组件。你的主页和联系页面都使用了相同的导航栏。你不想为每个视图重写表示导航栏的 HTML 代码,你希望编写一次代码并在每个视图中重用它。
在 Node.js 应用程序中使用模板,你可以做到这一点。实际上,你将能够为所有应用程序页面渲染单个布局,或者将视图内容共享在称为部分的代码片段中。
10.1. 连接模板引擎
在第 9 课中,你重新组织了你的路由,使用 Express.js 路由方法和 MVC 应用程序结构来提供响应。下一步是使用你的路由以单行文本以外的内容来响应。与单元 1 一样,你将渲染单独的文件,但这些文件不是纯 HTML,你也不需要显式地使用fs模块来提供它们。
Express.js 之所以如此受欢迎的部分原因在于它能够与其他包和工具协同工作。其中一个工具就是模板引擎。模板允许你用能够插入动态数据的能力来编写视图。在本书中,你将使用 HTML 编写视图,并使用 EJS——页面中嵌入的 JavaScript 对象以特殊语法形式存在。这些文件具有.ejs 扩展名。有许多模板语言,如 EJS,但本书假设你有一定的 HTML 经验,并且 EJS 证明是在这种背景下最有效且最简单的模板语言之一。如果你想探索其他模板引擎,可以考虑列表中的一些表 10.1。
表 10.1. 模板引擎
| 模板引擎 | 描述 |
|---|---|
| Mustache.js | 没有 Handlebars.js 提供的自定义助手,这个模板引擎简单轻量,并且可以编译成除 JavaScript 以外的许多语言(mustache.github.io/)。 |
| Handlebars.js | 功能上类似于 EJS,这个模板引擎专注于使用花括号,或handlebars,将动态内容插入到您的视图中(handlebarsjs.com/)。 |
| Underscore.js | 除了其他 JavaScript 函数和库之外,这个引擎还提供了可自定义语法和符号的模板功能(underscorejs.org/)。 |
| Pug.js | 这个引擎提供的语法类似于 Ruby 中的 Jade,简化了 HTML 标签名称,并且对缩进敏感(pugjs.org)。 |
模板引擎是 Express.js 用来处理您的视图并将它们转换为浏览器可读的 HTML 页面的工具。任何非 HTML 行都会被转换为 HTML,其中嵌入变量的值会被渲染出来。参见图 10.1 以了解转换过程。
图 10.1. 将 EJS 转换为 HTML

在名为 express_templates 的新应用程序项目中,初始化您的应用程序,将express作为依赖项安装,并创建您的控制器文件夹,包括一个主页控制器。在您的 main.js 文件中,引入正常的 Express.js 模块和app对象、homeController.js,并将您的服务器设置为监听端口 3000。接下来,使用以下终端命令安装ejs包:npm install ejs --save。
注意
您也可以通过运行以下命令在一行内安装express和ejs:npm install express ejs --save。
| |
设置方法
set通常用于将值分配给应用程序使用的预定义配置变量。这些变量被称为应用程序设置属性,在expressjs.com/en/api.html#app.set中列出。一些变量由app本身使用,以允许应用程序在您的计算机上运行。使用set分配变量是设置应用程序配置的另一种方式。
您已经将应用程序的端口号设置为 3000。虽然 3000 是网络开发中常用的端口号,但应用程序上线部署时端口号不会保持不变。
app.set允许您将值分配给您计划在应用程序中重用的某些键。以下代码将端口号设置为环境变量PORT的值或3000,如果前者未定义。例如,您可以使用app.set("port", process.env.PORT || 3000);
要使用此设置值,您需要将应用程序 main.js 文件末尾的硬编码3000替换为app.get("port")。同样,您也可以运行app.get("view engine")。现在,您甚至可以将您的console.log替换为更动态的语句,例如console.log(Server running at http://localhost😒{ app.get("port") });
使用添加的代码重新启动此应用程序,以确保它仍然可以正确运行。
现在 ejs 包已经安装,您需要让您的 Express.js 应用程序知道您计划使用它进行模板化。为此,在 main.js 中的 require 语句下方添加 app.set("view engine", "ejs")。这一行告诉您的 Express.js 应用程序将其 view engine 设置为 ejs。这一行是您的应用程序知道在主项目目录中的视图文件夹中期望使用 EJS 的方式。
现在您的应用程序已准备好解释 EJS,请在您的视图文件夹中创建一个 index.ejs 文件,并包含 列表 10.1 中的代码。在这段代码中,您使用 EJS 语法 <% %> 在视图中定义和分配一个变量。这些字符内的所有内容都作为有效的 JavaScript 运行。HTML 的每一行都包含一个嵌入的变量。通过使用 `<%= %>》,您能够在 HTML 标签内打印该变量的值。
列表 10.1. index.ejs 视图中的示例 EJS 内容
<% let name = "Jon"; %> *1*
<h1> Hello, <%= name %> </h1> *2*
-
1 在 EJS 中定义和分配一个变量。
-
2 在 HTML 中嵌入一个变量。
最后,在 main.js 中为 /name 路径创建一个路由。您可以想一个与该函数将要执行的操作相关的控制器函数名称。以下示例调用函数 respondWithName:app.get("/name", homeController.respondWithName)。当请求 /name 路径时,此路由会运行,然后调用控制器中的 respondWithName 函数。
在 homeController.js 中,添加 respondWithName 函数,如下所示。您使用响应对象的 render 方法从您的视图文件夹中响应视图。
列表 10.2. 在 homeController.js 中从控制器操作渲染视图
exports.respondWithName = (req, res) => {
res.render("index"); *1*
};
- 1 响应一个自定义的 EJS 视图。
注意
注意,您不需要为 index.ejs 视图添加 .ejs 扩展名,也不需要指定此视图所在的文件夹。Express.js 会为您处理所有这些。只要您继续将视图添加到视图文件夹并使用 EJS,您的应用程序就会知道该怎么做。
重新启动您的应用程序,并在浏览器中访问 localhost:3000/name。如果您遇到任何问题,请尝试重新安装 ejs 和 express 包,并确保您的文件位于正确的文件夹中。
在下一节中,我将讨论从控制器传递数据到您的 EJS 视图。
快速检查 10.1
Q1:
什么是模板引擎?
QC 10.1 答案
1:
模板引擎是 Express.js 在您的应用程序中用于处理模板视图的工具。因为模板视图包含 HTML 和 JavaScript 内容的混合,所以引擎的任务是将这些信息转换为浏览器可以使用的 HTML 文件。
10.2. 从控制器传递数据
现在您的模板正在渲染,最佳做法是从控制器将数据传递到视图,而不是直接在视图中定义这些变量。为此,请从 index.ejs 中删除定义和分配 name 变量的行,但保留 H1 标签及其 EJS 内容。
将您的路由修改为在其路径中接受一个参数,然后将该参数发送到视图。您的路由应类似于以下代码:app.get("/name/:myName", home Controller.respondWithName)。现在路由在 /name 路径的末尾接受一个参数。
要使用此参数,您需要从 home -Controller.respondWithName 函数中的请求参数访问它。然后您可以将名称变量作为 JavaScript 对象中的 name 键的值传递(该键的名称应与视图中的变量名称匹配)。您的函数应类似于以下列表中的代码。在此代码块中,您将路由参数设置为一个局部变量;然后您将名称变量作为 name 键的值传递(该键的名称应与视图中的变量名称匹配)。
列表 10.3. 在 homeController.js 中将路由参数传递到您的视图
exports.respondWithName = (req, res) => {
let paramsName = req.params.myName; *1*
res.render("index", { name: paramsName }); *2*
};
-
1 将局部变量分配给请求参数。
-
2 将局部变量传递给渲染的视图。
重新启动您的应用程序,并在浏览器中访问 localhost:3000/name/jon。
警告
/name/jon 与 /name/ 是不同的路径。如果您不将名称作为路由参数添加,您的应用程序将抱怨没有路由匹配您的请求。您必须在 URL 中的第二个正斜杠之后添加一些文本。
在下一节中,我将讨论布局和部分,并讨论它们如何允许您在视图中编写更少的代码以获得相同的结果。
快速检查 10.2
Q1:
您从控制器发送数据到视图的格式是什么?
QC 10.2 答案
1:
要从您的控制器发送数据,您可以在 JavaScript 对象中传递一个变量。属于您控制器上下文的局部变量遵循键,其名称应与视图中的变量名称匹配。
10.3. 设置部分和布局
在前两个部分中,您已经向视图引入了动态数据。在本节中,您将以不同的方式设置视图,以便您可以在多个页面之间共享视图内容。
首先,创建一个应用程序布局。一个 布局 是一个外壳,在其中渲染视图。将布局视为在浏览网站时页面之间不改变的内容。例如,页面的底部(页脚)或导航栏可能保持不变。而不是重新创建这些组件的 HTML,将这些组件添加到其他视图可以共享的 layout.ejs 文件中。
要实现这一点,请安装express-ejs-layouts包,并在您的main.js文件中通过layouts = require("express-ejs-layouts")来引入它。然后,通过在main.js文件中添加app.use(layouts),让 Express.js 知道使用此包作为额外的中间件层。
接下来,在您的视图文件夹中创建一个layout.ejs文件。您可以从布局文件中的简单 HTML 开始,如下一列表所示。body关键字由 Express.js 和布局express-ejs-layouts使用,以填充其他视图的内容。
列表 10.4. layout.ejs中的 EJS 布局文件内容
<body>
<div id="nav">NAVIGATION</div>
<%- body %> *1*
<div id="footer">FOOTER</div>
</body>
- 1 将 body 包裹在样板 HTML 中。
当您访问一个渲染视图的路由时,您会注意到在您的渲染视图之间有导航和页脚文本。这个布局将在每次页面加载时与您的视图一起渲染。要查看,请重新启动您的应用程序,并在浏览器中访问/name/:myName路径。
部分与布局的工作方式类似。部分是可以在其他视图中包含的视图内容片段。在您的食谱应用程序中,您可能想在几个页面上添加一个通知框。为此,创建一个名为notification.ejs的部分,并使用include关键字将其添加到选定的 EJS 视图中。为了创建导航元素的片段,将那个 div 的代码移动到一个名为navigation.ejs的新文件中。将该文件放置在视图文件夹中的新文件夹partials内。然后在layout.ejs文件中使用以下代码包含该文件:<%- include('partials/navigation') %>。稍加样式,您的视图应该类似于图 10.2。
图 10.2. 名称页面的示例视图

在 EJS 的箭头中,使用include关键字后跟一个相对路径到您的部分。因为布局已经在视图文件夹中,它需要在该目录级别的partials文件夹中查找导航部分。
重新启动您的应用程序,并再次访问/name/:myName路径。如果一切设置正确,该视图中的内容自添加布局文件以来不应有任何变化。为了证明部分正在工作,尝试更改导航部分中的文本或添加新标签,以查看浏览器中的内容如何变化。
注意
当您在视图中进行更改时,您不需要重新启动应用程序。
现在您有一个使用 EJS 模板引擎、布局和接受动态数据的部分的应用程序。在第 11 课中,您将学习如何处理错误并向您的package.json文件添加一些配置。
快速检查 10.3
Q1:
您使用什么关键字在多个视图中共享部分?
QC 10.3 答案
1:
include关键字在提供的相对路径中查找部分,并在其位置渲染它。
总结
在本节课中,你学习了如何在你的应用程序中使用模板与 EJS。你还学习了如何从控制器传递数据到应用程序视图。在本节课结束时,你学习了如何使用express-ejs-layouts包和部分创建布局,以在视图之间共享内容。在第 11 课中,你添加了一个配置来使用不同的命令启动你的应用程序,并使用新的中间件函数处理错误。
尝试以下操作
现在你已经在你的应用程序中有了模板、部分和布局,你应该使用它们来创建多个视图。尝试为你的食谱应用程序创建一个使用应用程序布局和名为notificationBox.ejs的通知框部分的联系页面。将这个部分添加到你的index.ejs视图。
第 11 课. 配置和错误处理
在第 10 课中,你向你的应用程序视图添加了嵌入式 JavaScript(EJS)。在本节课中,你通过修改你的 package.json 文件来使用启动脚本,为你的应用程序添加了最后的修饰。这个脚本改变了你从终端启动应用程序的方式。然后,你添加了错误处理中间件函数来记录错误并返回错误页面。
本节课涵盖
-
修改你的应用程序启动脚本
-
使用 Express.js 提供静态页面
-
创建错误处理的中间件函数
考虑以下内容
你正在全力开发你的食谱应用程序。在编程中很常见,你会遇到很多错误,但在你的浏览器中并没有清晰的错误指示。
在本节课中,你将探索在适当的时候向浏览器窗口提供错误页面的方法。
11.1. 修改你的启动脚本
要开始本节课,你需要修改一个你很久没动过的文件。每当初始化一个新的 Node.js 应用程序时,都会创建一个 package.json 文件,但你几乎没有手动更改过它的任何值。在第 4 课中,我提到了如何使用npm start命令来启动你的应用程序,当这个脚本配置在你的项目的 package.json 中时。
从第 10 课复制你的 express_templates 应用程序文件夹。在你的 package.json 文件中,找到scripts属性;你应该看到一个测试脚本的占位符。在该测试脚本末尾添加一个逗号,然后添加"start": "node main.js"。这个脚本允许你运行npm start来启动你的应用程序,并抽象出你需要知道你的主应用程序文件名的需求。你的 package.json 文件中的这部分应该看起来像下面的列表。在scripts对象中,你可以使用键start通过运行npm start、npm run start或npm run-script start来启动你的应用程序。
列表 11.1. 将 npm start 脚本添加到你的 package.json
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node main.js" *1*
},
- 1 在 package.json 中添加一个启动脚本。
保存您的文件,并使用npm start运行您的应用程序。从功能上讲,您的应用程序不应该有任何其他变化,它应该像往常一样启动。
小贴士
如果您在重新启动应用程序时遇到任何问题,请尝试将node main还原,以排除在 main.js 文件中意外做出的任何更改。
在下一节中,您将改进处理应用程序中错误的方式。
快速检查 11.1
Q1:
您的 package.json 文件中
scripts对象的作用是什么?
QC 11.1 答案
1:
scripts对象允许您为使用 npm 运行的命令定义别名。
11.2. 使用 Express.js 处理错误
到目前为止,Express.js 对开发过程来说是一个很大的改进。一个好处是,当请求一个不存在路由的路径时,应用程序不会永远挂起。然而,当您请求主页时,如果没有路由来处理该请求,您会在浏览器中看到一个不友好的Cannot GET /。
您可以使用几种方法来处理 Express.js 中的错误。第一种方法是在发生错误时将日志记录到控制台。您可以像在第 10 课中记录请求路径一样记录错误。因为我处理的是一个与提供常规信息页面无关的主题,我建议您创建一个新的控制器,并在项目终端窗口中运行npm install http-status-codes --save来安装http-status-codes包。
在您的 controllers 文件夹中创建 errorController.js,并添加列表 11.2 中显示的函数。这个函数比正常的中间件函数多一个参数。如果在请求-响应周期中发生错误,它将作为第一个参数出现。与console.log一样,您可以使用console.error来记录error对象的stack属性,这会告诉您出了什么问题。与之前的中间件函数一样,next参数调用链中的下一个函数或路由,这次传递错误对象,以防需要进一步处理。
注意
您需要在这个错误处理程序中接受四个参数,第一个参数始终代表错误对象。如果没有所有四个参数,该函数将不会被解释为错误处理中间件,而是一个正常的中间件函数。
列表 11.2. 向错误控制器添加函数,errorController.js
exports.logErrors = (error, req, res, next) => { *1*
console.error(error.stack); *2*
next(error); *3*
};
-
1 添加中间件来处理错误。
-
2 记录错误堆栈。
-
3 将错误传递给下一个中间件函数。
小贴士
使用console.log对于一般的调试来说很好,但随着您的应用程序变得更加复杂,您可能希望更改日志消息。例如,Chrome 浏览器控制台窗口的工具可以为您着色这些消息,以便您区分一般日志消息和错误消息。
接下来,你需要告诉 Express.js 使用这个中间件函数,通过引入 errorController.js 并在 main.js 文件中添加 app.use(errorController.logErrors)。你可以通过注释掉 respondWithName 函数中定义 paramsName 变量的行来引发一个错误。然后,当你访问 http://localhost:3000/name/jon 时,你的 logErrors 函数将会运行。记住,完成之后取消注释那行代码。
警告
确保在 main.js 中将中间件行添加到你的正常路由定义之后。
默认情况下,Express.js 在处理请求的末尾处理任何错误。但是,如果你想用自定义消息响应,你可以在你的路由末尾添加一个通配符路由来响应,如果页面未找到,则返回 404 状态码,如果应用程序在处理过程中发生错误,则返回 500 状态码。这段代码应该看起来像 errorController.js 中的 列表 11.3。
在 errorController.js 中,第一个函数会向用户发送一条消息,告知请求页面在你的路由中未找到。第二个函数会通知用户请求处理过程中发生的内部错误。在这里,你使用 http-status-codes 模块代替代码值本身。
列表 11.3. 在 errorController.js 中使用自定义消息处理缺失路由和错误
const httpStatus = require("http-status-codes");
exports.respondNoResourceFound = (req, res) => { *1*
let errorCode = httpStatus.NOT_FOUND;
res.status(errorCode);
res.send(`${errorCode} | The page does not exist!`);
};
exports.respondInternalError = (error, req, res, next) => { *2*
let errorCode = httpStatus.INTERNAL_SERVER_ERROR;
console.log(`ERROR occurred: ${error.stack}`)
res.status(errorCode);
res.send(`${errorCode} | Sorry, our application is
experiencing a problem!`);
};
-
1 以 404 状态码响应。
-
2 捕获所有错误并以 500 状态码响应。
在 main.js 中,顺序很重要。respondNoResourceFound 会捕获没有匹配路由的请求,而 respondInternalError 会捕获任何在请求处理过程中发生的错误。将这两个中间件函数添加到 main.js 中,如下面的列表所示。
列表 11.4. 使用自定义消息处理缺失路由和错误:main.js
app.use(errorController.respondNoResourceFound); *1*
app.use(errorController.respondInternalError);
- 1 将错误处理中间件添加到 main.js 中。
如果你想自定义错误页面,你可以在你的公共文件夹中添加一个 404.html 和一个 500.html 页面,使用基本的 HTML。然后,而不是用纯文本消息响应,你可以用这个文件来响应。这个文件不会使用你的模板引擎来处理响应。你错误控制器中的 respondNoResourceFound 函数看起来像下面的列表。在这段代码中,res.sendFile 允许你指定一个指向你的错误页面的绝对路径,如果你的正常模板渲染器不起作用,这会很有帮助。
列表 11.5. 使用自定义消息处理缺失路由和错误
exports.respondNoResourceFound = (req, res) => { *1*
let errorCode = httpStatus.NOT_FOUND;
res.status(errorCode);
res.sendFile(`./public/${errorCode}.html`, { *2*
root: "./"
});
};
-
1 以自定义错误页面响应。
-
2 在 404.html 中发送内容。
现在你已经将错误消息提供给用户并记录到终端,你应该确保你的应用程序已设置好以服务静态文件,如 404.html 页面。
快速检查 11.2
Q1:
为什么处理缺失路由的中间件在你的正常应用路由之后?
| |
QC 11.2 答案
1:
响应
404状态码的中间件函数在if-else代码块中类似于else。如果没有其他路由路径与请求匹配,此函数将向用户显示消息。
11.3. 服务器静态文件
最后这部分内容较短。在您的应用程序中,从第 1 单元开始,提供所有不同类型的静态文件和资源可能需要数百行代码。使用 Express.js,这些文件类型会自动处理。您需要做的只是告诉 Express.js 在哪里可以找到这些静态文件。
注意
静态文件包括您的资源和自定义错误页面,如 404.html 和 500.html。这些 HTML 页面不会通过模板引擎,因为它们不包含任何 EJS 值。
要设置此任务,您需要使用express模块中的static方法。此方法接受包含您的静态文件的文件夹的绝对路径。然后,与任何其他中间件函数一样,您需要告诉 Express.js 的app实例使用此功能。要启用静态文件的提供,请在 main.js 中添加app.use(express.static("public"))。此行代码告诉您的应用程序使用位于项目目录中与 main.js 同一级别的相应公共文件夹来提供静态文件。
在此代码设置完成后,您可以直接访问 http://localhost:3000/404.html。您也可以在公共文件夹中放置一个图像或其他静态资源,并通过 URL 中的主域名后的文件名来访问它。如果您在名为 images 的子目录中添加了一个图像,例如 cat.jpg,您可以通过访问 http://localhost:3000/images/cat.jpg 来单独查看该图像。
快速检查 11.3
Q1:
您公共文件夹中存放着哪些重要的静态文件?
QC 11.3 答案
1:
您的公共文件夹包含用于错误页面的静态 HTML 文件。如果您的应用程序出现错误,这些文件可以被发送回客户端。
总结
在本课中,您学习了如何更改应用程序的启动脚本。您还学习了如何记录和管理 Express.js 应用程序中发生的一些错误。在本课结束时,您设置了 Express.js 以从公共文件夹提供静态资源。现在,您有相当多的工具可以使用来构建您的食谱应用程序。在第 12 课中,您通过重构 Confetti Cuisine 应用程序来测试您所学的内容。
尝试以下操作
现在您有了提供静态文件的能力,为您的应用程序构建一个用于 404 和 500 错误的创意 HTML 页面。这些文件不使用您用于模板的常规布局文件,因此所有样式都必须位于 HTML 页面内部。
第 12 课:综合:使用 Express.js 增强 Confetti Cuisine 网站
经过一些考虑,我决定依赖一个网络框架来帮助我构建 Confetti Cuisine 的网络应用程序会更简单。构建自定义路由和应用程序逻辑已经变成了一项繁琐的任务,所以我将把我的应用程序转换为使用 Express.js。
我仍然希望应用程序拥有主页、课程和注册页面。我需要将路由转换为使用 Express.js 中找到的关键字和语法。我需要确保从公共目录中提供我的静态资源,并且为本地启动应用程序设置了所有必要的 package.json 配置。当我准备好进行这种转换时,我将首先使用 npm init 初始化项目。
12.1. 初始化应用程序
为了开始这个网站的重设计,我将创建一个新的项目目录,命名为 confetti_cuisine 并进入该文件夹。在终端的项目文件夹中,我将使用 npm init 初始化应用程序的 package.json 文件。
记住我之前设置的配置,我将保留项目名称的默认设置,并使用入口点 main.js。
现在我的 package.json 已经设置好了,我将在 "scripts" 下添加一个启动脚本,这样我就可以使用 npm start 而不是 node <filename> 来运行应用程序。我在脚本列表中添加了 "start": "node main.js"。
提示
不要忘记用逗号分隔多个脚本项。
初始化过程的最后一步是向此项目添加主 Express.js 网络框架、EJS 模板、布局和 http-status-codes 包。为此,我在命令行中运行 npm install express ejs express-ejs-layouts http-status-codes --save。
注意
--save 标志将 express 包保存为项目 package.json 中的依赖项。这样,任何未来的项目工作都将确保在能够工作之前安装了 Express.js。
我生成的 package.json 文件如下所示。
列表 12.1. package.json 中的项目配置
{
"name": "confetti_cuisine",
"version": "1.0.0",
"description": "A site for booking classes for cooking.",
"main": "main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node main.js"
},
"author": "Jon Wexler",
"license": "ISC",
"dependencies": { *1*
"ejs": "².6.1",
"express": "⁴.16.4",
"express-ejs-layouts": "².5.0",
"http-status-codes": "¹.3.0"
}
}
- 1 列出为此项目安装的依赖项。
在我添加任何新文件之前,我将设置我的应用程序的目录结构。最终的项目结构将看起来像 列表 12.2。我将添加以下内容:
-
一个视图文件夹来存放我的 HTML 页面
-
一个控制器文件夹来存放任何路由函数
-
一个公共文件夹,其中包含 css、js 和 images 文件夹,用于存放我的客户端资源
列表 12.2. Confetti Cuisine 项目文件结构
. *1*
|____main.js
|____public
| |____css
| | |____confetti_cuisine.css
| | |____bootstrap.css
| |____images
| | |____product.jpg
| | |____graph.png
| | |____cat.jpg
| | |____people.jpg
| |____js
| | |____confettiCuisine.js
|____package-lock.json
|____package.json
|____controllers
| |____homeController.js
| |____errorController.js
|____views
| |____index.ejs
| |____courses.ejs
| |____contact.ejs
| |____error.ejs
| |____thanks.ejs
| |____layout.ejs
- 1 从根目录列出项目目录
太好了。现在我已经准备好添加应用程序逻辑。
12.2. 构建应用程序
现在应用程序已经设置好,并安装了 Express.js,我将创建我的 main.js 应用程序文件。尽管这个文件将类似于我的 http 模块版本,但从头开始编写它将消除在逐行转换时的许多头痛。我的 main.js 将看起来像 列表 12.3 中的代码。
main.js的第一行需要 Express.js 包的内容,并将它们分配给一个名为express的常量。与第一个版本中的应用程序中的app常量一样,我将实例化express对象,将其表示为名为app的另一个常量,代表本项目的主要应用程序框架。app常量将能够设置一个GET路由,监听对根 URL(/)的请求,并使用响应上的 Express.js send 函数进行响应。我最终可以设置服务器监听端口 3000,并在运行时向我的控制台记录一条消息。
列表 12.3. 在main.js中设置主要应用程序逻辑
const express = require("express"), *1*
app = express(); *2*
app.set("port", process.env.PORT || 3000);
app.get("/", (req, res) => { *3*
res.send("Welcome to Confetti Cuisine!");
});
app.listen(app.get("port"), () => { *4*
console.log(
`Server running at http://localhost:${app.get(
"port"
)}`
);
});
-
1 引入 express。
-
2 实例化 Express 应用程序。
-
3 为主页创建路由。
-
4 将应用程序设置为监听端口 3000。
在这个逻辑到位后,我可以通过在命令行中运行npm start来启动应用程序。
将使用 json 和 urlencoded Express.js 中间件函数作为中间件,为我解析传入请求的请求体。在main.js中,我将在下一列表中添加代码。
列表 12.4. 在main.js顶部添加 body 解析
app.use(
express.urlencoded({ *1*
extended: false
})
);
app.use(express.json());
- 1 告诉 Express.js 应用程序使用 body-parser 处理 URL 编码和 JSON 参数。
现在我的应用程序已准备好分析传入请求中的数据。接下来,我需要创建路由以访问应用程序中的视图。
12.3. 添加更多路由
现在我的应用程序有一个起点,我将为课程和注册页面创建路由。此外,我还会添加一个POST路由来处理从注册页面表单提交的请求。
首先,我在controllers文件夹中创建一个主页控制器,这是我将存储路由将使用的函数的地方。我需要在main.js中通过添加const homeController = require("./controllers/homeController")来引入这个控制器。我在下一列表中添加的代码到主页控制器中,在我的应用程序的第一个路由下方。这三个函数都响应一个反映请求路由的 EJS 页面。我需要创建以下视图:courses.ejs、contact.ejs和thanks.ejs。
列表 12.5. 在homeController.js中添加路由操作
exports.showCourses = (req, res) => { *1*
res.render("courses");
};
exports.showSignUp = (req, res) => {
res.render("contact");
};
exports.postedSignUpForm = (req, res) => {
res.render("thanks");
};
- 1 为特定路由添加回调函数。
在我的 main.js 中,我添加了以下路由,并修改了我原始的主页路由以使用我的主页控制器,如列表 12.6 所示。第一个路由处理查看课程列表的 GET 请求。在大多数情况下,此路由的行为与主页相似。联系页面的路由也监听 GET 请求,因为当请求 /contact URL 时,大多数人都会期望在这个页面上有一个注册表单。最后一个路由是针对 /contact URL 的 POST 请求。GET 路由用于内部查看谁提交了请求以取得联系。POST 路由由联系页面上的注册表单使用。
列表 12.6. 在 main.js 中为每个页面和请求类型添加路由
app.get("/courses", homeController.showCourses); *1*
app.get("/contact", homeController.showSignUp);
app.post("/contact", homeController.postedSignUpForm);
- 1 为课程页面、联系页面和联系表单提交添加路由。
现在所有路由都已定义,但我仍然缺少大部分内容。是时候添加和渲染一些视图了。
12.4. 路由到视图
使用 Express.js,我的视图将更干净且更容易渲染。我需要创建表 12.1 中列出的视图。
表 12.1. Confetti Cuisine 视图
| 文件名 | 目的 |
|---|---|
| layout.ejs | 作为应用程序的主要样式和导航基础 |
| index.ejs | 生成主页的内容 |
| courses.ejs | 显示课程内容 |
| contact.ejs | 显示联系表单 |
| thanks.ejs | 在表单提交后显示感谢信息 |
| error.ejs | 当找不到页面时显示错误信息 |
我将首先生成我的应用程序布局视图,它将处理导航和网站结构从页面到页面的样子。
为了使布局工作,我需要在主应用程序文件中包含它,就在初始化 Express.js 模块之后,如列表 12.7 所示。首先,我需要引入 express-ejs-layouts 模块,以便我可以使用 layout.ejs 文件。然后,我将应用程序服务器设置为使用 ejs 渲染模板。最后,我将应用程序服务器设置为使用最近引入的 layouts 模块。这样,当渲染新的视图时,它将通过 layout.ejs 文件。
列表 12.7. 在 main.js 中启用 EJS 布局渲染
const layouts = require("express-ejs-layouts"); *1*
app.set("view engine", "ejs"); *2*
app.use(layouts); *3*
-
1 需要引入 express-ejs-layouts 模块。
-
2 将应用程序设置为使用 ejs。
-
3 将应用程序设置为使用布局模块。
我将添加一个名为 layout.ejs 的文件到我的视图文件夹中。该文件的关键组件包括 <%- body %>,它将被我的目标路由的渲染内容所替换。
以下每个视图都将使用此布局来提供视觉一致性(以及避免在文件之间重复代码)。在 views 文件夹中,我将创建 index.ejs、courses.ejs、contact.ejs、thanks.ejs 和 error.ejs 文件。与布局文件类似,这些视图以嵌入式 JavaScript 的形式渲染,允许我从服务器文件动态地向页面提供内容。在创建 index.ejs 之后,我将我的主页路由 (/) 修改为渲染索引页面,而不是发送纯文本。
我需要关注的视图是 contact.ejs,在这里我将让潜在的学生填写表单,向我的应用程序的 /sign-up 路由提交 POST 请求。这个表单将类似于下一条列表中的 HTML。请注意,表单的 action 是 /contact,表单的方法是 POST。当表单提交时,它将向 /contact 路由发送 POST 请求。
列表 12.8. contact.ejs 中的联系表单
<form action="/contact" method="post">
<label for="name">Name</label>
<input type="text" name="name">
<label for="email">Email</label>
<input type="email" name="email">
<input type="submit" value="Submit">
</form> *1*
- 1 显示示例联系表单。
我应该已经设置好了。如果我将路由命名以匹配并渲染相应的视图,我应该能够启动我的应用并看到那些视图在构建的布局中渲染。唯一缺少的是我的应用提供图片和其他静态文件的能力,这将在下一部分进行介绍。
注意
布局文件在访问的每个页面上都会被渲染。尝试在 <%- body %> 标记的上方和下方添加新的 HTML 内容。请注意,这些元素应用于每个页面。
12.5. 服务器静态视图
在我第一次使用 http 开发这个应用程序时,提供静态资源变得一团糟。每当我在项目目录中添加新的资源时,我都需要创建一个新的路由并相应地处理它。幸运的是,Express.js 很好地处理了这个任务,几乎不需要我做出任何努力来处理我想要应用提供的任何和所有静态文件。为了启用静态资源,我将在应用程序文件中 Express.js 初始化之后添加 app.use(express.static("public"))。这个添加允许直接提供应用程序中的单个资源。
将应用转换为 Express.js 应用程序的最后一步主要是在视图中使用动态内容。
12.6. 将内容传递到视图中
Confetti Cuisine 经常修改其课程列表,因此应用程序最好不在静态网页上显示这些课程。使用 Express.js,从服务器逻辑向视图传递内容易如反掌。
对于这个应用,我需要将课程列表以 JavaScript 对象的形式显示。然后我可以将这个对象发送到我的渲染视图。我在 代码列表 12.9 中添加了代码到 homeController.js 文件。通过将 courses 变量赋值给一个 JavaScript 对象数组,我可以使用这个列表并针对视图中的特定键进行操作。res.render 方法允许我将 courses 对象传递到视图中,并在该页面上将其引用为 offeredCourses。
注意
在视图中,我可以通过使用变量名 offeredCourses 来访问这个数组。在主控制器中,这个数组名为 courses。
列表 12.9. 在 homeController.js 中设置内容并在渲染的视图中传递
var courses = [
{
title: "Event Driven Cakes",
cost: 50
},
{
title: "Asynchronous Artichoke",
cost: 25
},
{
title: "Object Oriented Orange Juice",
cost: 10
}
]; *1*
exports.showCourses = (req, res) => {
res.render("courses", {
offeredCourses: courses *2*
});
};
-
1 定义课程数组。
-
2 将课程数组传递到视图中。
为了利用这个功能,我需要在 courses.ejs 中添加一些 EJS 和 HTML 来遍历 offeredCourses 列表并打印相关内容,如列表 12.10 所示。
列表 12.10. 在 courses.ejs 视图中遍历并显示动态内容
<h1>Our Courses</h1>
<% offeredCourses.forEach(course => { %> *1*
<h5> <%= course.title %> </h5>
<span>$ <%= course.cost %> </span>
<% }); %>
- 1 在视图中遍历课程数组。
现在应用程序已经完成。我的课程页面看起来像图 12.1。每次课程提供修改时,我无需修改我的 courses.ejs 视图,只需更改主应用程序文件中的数组即可。现在运行应用程序变得简单。
图 12.1. 课程页面视图

我应该预料到事情不会完全按计划进行,因此总是明智地准备处理某些错误并相应地处理它们。不久,当这个课程数组被持久数据库中的内容替换时,我无需对代码进行任何更改来更新课程列表。
处理大多数预期结果的应用程序确保为用户提供相当一致和良好的体验。我知道我的应用程序可能缺少一些万无一失的逻辑,但当我遇到这些错误时,我更喜欢向我的客户端观众发送我自己的自定义错误消息。
对于错误处理,我将创建一个错误控制器,errorController.js,来存储我的函数,如列表 12.11 所示。第一个函数处理所有之前未处理的请求,这符合访问没有活动路由的 URL 类别,并导致 404 错误,提供 error.ejs。最后一个函数处理发生的任何内部服务器错误。我更喜欢一个友好的消息,而不是必然崩溃并吓跑观众。
列表 12.11. 在 errorController.js 中添加错误处理路由
const httpStatus = require("http-status-codes");
exports.pageNotFoundError = (req, res) => { *1*
let errorCode = httpStatus.NOT_FOUND;
res.status(errorCode);
res.render("error");
};
exports.internalServerError = (error, req, res, next) => { *2*
let errorCode = httpStatus.INTERNAL_SERVER_ERROR;
console.log(`ERROR occurred: ${error.stack}`)
res.status(errorCode);
res.send(`${errorCode} | Sorry, our application is taking a nap!`);
};
-
1 处理所有之前未处理的请求。
-
2 处理任何内部服务器错误。
然后,我为这些函数添加路由。我将在列表 12.12 中添加路由,以触发我的错误控制器中的函数,如果先前的路由没有响应请求。
注意
路由的顺序很重要。这些路由必须放在任何现有路由之下,因为它们充当通配符并覆盖其下任何路由。
列表 12.12. 在 main.js 中添加错误处理路由
app.use(errorController.pageNotFoundError); *1*
app.use(errorController.internalServerError);
- 1 将错误处理程序作为中间件函数添加。
我需要在 main.js 文件的顶部添加 const errorController = require("./controllers/errorController") 来引入这个控制器。现在我的应用程序已经准备好处理错误并启动。当访问没有对应路由的 URL 时,用户会看到我的猫亨德里克斯在错误页面上放松(图 12.2)。
图 12.2. 错误页面视图

摘要
通过这个项目,我重新定义了 Node.js 项目文件结构以适应网络框架。我使用 npm 安装了三个外部包。然后我重新构建了主要应用程序文件,使用了 Express.js 语法。为了为特定的 URL 创建路径,我设置了新的路由,使用了 Express.js 的关键字。为了保持一致的用户界面,我使用了 EJS 的布局。使用 Express.js 的静态库,我在公共文件夹中设置了静态资源以供客户端服务。最后,我在主要项目应用程序文件中添加了内容,并设置了这些内容以动态地服务于我的某个视图。
通过持续练习这些技术和适当的错误处理,我可以用 Express.js 在几个步骤中构建未来的应用程序。有了布局和动态内容等新功能,我可以尝试将内容发送到应用程序中的其他视图,或者尝试在应用程序中使用时修改布局。
在 单元 3 中,我讨论了如何使用 Express.js 围绕持久数据组织应用程序代码。
单元 3:连接到数据库
单元 2 教会了你如何使用 Express.js 设置 Node.js 应用程序。到这一点,你应该已经能够舒适地使用 Express.js 的路由和模板构建一个基本的 Web 应用程序。本单元是关于将你在单元 2 中构建的应用程序连接到数据库。数据库是一个可以永久存储值的地方,与之前课程中的数据不同,后者每次应用程序重启时都会重置。
在这本书中,你将学习如何使用 MongoDB,这是 Node.js 中流行的数据库。首先,你需要在你的计算机上下载并安装 Mongo。然后,你将探索 MongoDB shell,这是一个类似于 Node.js REPL shell 的数据库环境。接下来,你将学习一些数据库理论,了解如何构建你的数据库及其中的数据。你将看到模型如何适应模型-视图-控制器(MVC)架构,以及它们如何通过名为 Mongoose 的包与你的应用程序数据库交互。最后,你将探索数据库模式——你结构化数据的轮廓——如何帮助你将数据对象相互关联。
以构建一个可以存储用户信息并在屏幕上显示这些信息的 Node.js 应用程序为目标,本单元涵盖了以下主题:
-
第 13 课介绍了 MongoDB,这是一个 NoSQL 数据库,它以 JSON 结构存储数据。在本课中,你将学习 MongoDB 如何与 Express.js 协同工作,并在你的计算机上安装数据库程序。你还将使用 MongoDB shell 创建数据库并插入一些数据。
-
第 14 课展示了如何将你的 MongoDB 数据库连接到 Express.js 应用程序。在初始设置之后,你将学习面向对象编程(OOP)如何帮助你为 MVC 结构的 Node.js 应用程序构建可靠的模型。对于你的模型,你将安装并使用 Mongoose 包,这是一个对象文档映射器(ODM)。
-
第 15 课讨论了你可以从 Node.js 应用程序内部使用的查询命令类型。你还实现了 JavaScript 承诺,以使用 Mongoose 构建更流畅、更符合 ES6 的应用程序。
-
最后,第 16 课展示了如何通过为 Confetti Cuisine 烹饪学校应用程序实现 MongoDB 数据库来测试你的技能。在这个综合练习中,你保存用户数据和时事通讯电子邮件。
准备在第 13 课中收集和存储数据。
第 13 课:设置 MongoDB 数据库
在 第 2 单元 中,你使用 Express.js 构建了 Web 应用程序。通过将应用程序结构化为使用模型-视图-控制器 (MVC) 架构,你现在可以通过控制器处理请求并提供服务视图。第三件基本工具是模型,你将使用它来组织你计划永久存储的数据。在本课中,你将安装 MongoDB,这是你将用于存储持久数据的数据库系统。你还将探索 MongoDB 中的文档数据库结构为何特别适合 Node.js 应用程序。在本课结束时,你将设置数据库并将其连接到你的应用程序。
本课涵盖
-
安装 MongoDB
-
在 MongoDB 壳中读取和输入数据
-
将 MongoDB 连接到 Node.js 应用程序
考虑这一点
你想从你的应用程序开始将数据保存到数据库中,但你不确定使用哪个数据库。使用 Node.js,你可以与几乎所有常见的数据库一起工作,例如 MySQL、PostgreSQL、Cassandra、Redis 和 Neo4j。你可以通过探索它们在 npm 上的相关包来了解最支持和最受欢迎的数据库管理系统。
然而,MongoDB 提供了一种独特的数据存储风格,类似于 JSON——一个对 JavaScript 友好的格式,这可能使你在第一次使用 Node.js 保存数据时更容易与数据库一起工作。
13.1. 设置 MongoDB
存储数据可以说是应用程序开发中最重要的一部分。没有长期存储,你在与用户互动的方式上会受到限制。到目前为止,你构建的每个应用程序中的数据,每次重启应用程序时都会消失。如果社交网络中的数据每次用户关闭浏览器或每次重启该应用程序时都会消失,用户将不得不创建新账户并从头开始。
一个 数据库 是一个为你的数据设计的组织结构,以便你的应用程序能够轻松访问和高效地修改。数据库就像一个仓库:你需要存储的物品越多,你越会喜欢一个帮助你找到这些物品的有序系统。就像一个网络服务器一样,你的应用程序连接到 MongoDB 数据库并请求数据。
在本单元中,我讨论了如何将信息保存到数据库中进行长期存储。即使应用程序关闭,你的数据也会持续存在。
MongoDB 是一个开源的数据库程序,它通过使用文档来组织数据。MongoDB 文档 以类似 JSON 的结构存储数据,允许你使用键值对来关联数据对象和属性。
这种存储系统遵循熟悉的 JavaScript 语法。注意在 图 13.1 中,文档的内容类似于 JSON。实际上,MongoDB 将文档存储为 BSON(JSON 的二进制形式)。与大多数应用程序使用的关系型数据库不同,MongoDB 的非关系型数据库系统在 Node.js 应用程序社区中处于领先地位。
图 13.1. 示例文档

关系型数据库的概述
本书重点介绍 MongoDB 以及其文档如何补充基于 JavaScript 的应用程序平台(如 Node.js)。然而,值得注意的是 MongoDB 不是什么,以及编程世界的其他部分是如何与数据库一起工作的。
大多数软件和 Web 应用程序使用的数据库在数据存储模型上与 MongoDB 中使用的文档结构不同。大多数数据库是 关系型 的,这意味着它们通过表格关联数据,就像标准的电子表格一样。在这些表格中,列定义了应存储的数据类型,而行存储与列对应的值。在以下图中,表示人员、课程以及哪些人员注册了特定课程的数据分别显示在不同的表格中。

示例关系型数据库结构
在这个例子中,两个表通过它们的 ID 值相关联。为了将一个人与他们想要的烹饪课程联系起来,将 people 和 courses 表中的项目 ID 添加到新的连接表行中。连接表 通常只包含相关项的 ID,以定义这些项之间的关系。这种通过引用 ID 设计的关系是数据库系统名称的由来。使用这种结构的数据库通常是基于 SQL 的,这使得 MongoDB 成为一个 NoSQL 数据库系统。
你可以使用 Node.js 设置关系型数据库——实际上,许多应用程序都是这样做的——但要最好地利用 SQL 数据库,了解如何编写 SQL 语言很有帮助。对于有 JavaScript 背景的人来说,MongoDB 的查询语言更容易理解。
关于关系型数据库的更多信息,我建议阅读 Oracle 提供的概述 docs.oracle.com/javase/tutorial/jdbc/overview/database.html。
在本节中,你将安装 MongoDB 并查看一些数据。Windows 和 Macintosh 的安装过程略有不同。对于 Mac,推荐的方法是使用名为 Homebrew 的终端命令行工具。你可以通过输入下一列表中的命令来安装 Homebrew。
列表 13.1. 在终端中安装 Homebrew 的命令
mkdir homebrew && curl -L
https://github.com/Homebrew/brew/tarball/master |
tar xz --strip 1 -C homebrew *1*
- 1 在终端中运行命令以在 MacOS 机器上安装 Homebrew。
注意
Homebrew 是一个帮助你安装软件和其他底层工具(如数据库管理系统)的工具。更多信息,请访问 brew.sh。
当 Homebrew 安装完成后,您应该能够在任何新的终端窗口中输入 brew 并看到可用 Homebrew 命令的列表,其中之一是 brew install。通过运行 brew install mongodb 来安装 MongoDB。
提示
如果您的计算机在安装过程中的任何时刻抛出错误或抱怨权限问题,您可能需要以超级用户身份运行命令,即在命令后附加 sudo。然后您将被提示输入计算机的登录密码。
接下来,在您的计算机根目录下(在终端窗口中尽可能多地 cd ..)创建一个名为 data 的新文件夹,并在其中创建一个名为 db 的新文件夹。您可以通过在终端窗口中输入 mkdir -p /data/db 来创建此文件夹。
您可能需要为您的用户账户提供使用此文件夹的权限。为此,请运行 sudo chown <your_username> /data/db,并输入您的计算机密码。对于 Windows,步骤如下:
-
在您的浏览器中访问
www.mongodb.com/download-center#community。 -
下载 Windows 版本的 MongoDB (.msi)。
-
下载完成后,打开文件,并按照默认的安装步骤进行操作。
-
当安装程序完成后,转到您的 C:\驱动器,创建一个名为 data 的新文件夹,并在其中创建一个名为 db 的文件夹。
注意
在 Windows 中,您可能需要将 MongoDB 文件夹路径添加到您的环境变量 PATH 中。要添加它,请右键单击计算机,选择属性 => 高级系统设置 => 环境变量 => 编辑环境变量 => PATH,并将您的 MongoDB 可执行路径添加到该字符串中。您的 MongoDB 路径可能看起来像 C:\Program Files\MongoDB\Server\3.6.2\bin。
对于更多安装说明,包括针对 Ubuntu Linux 机器的说明,请访问 docs.mongodb.com/v3.0/tutorial/install-mongodb-on-ubuntu。
到目前为止,您已经在您的计算机上安装了 MongoDB。像 Web 服务器一样,MongoDB 需要启动以创建您应用程序的新数据库。您可以通过在终端窗口中运行 mongod 来启动 MongoDB。此命令为 MongoDB 分配一个端口,并确定其数据库的位置在 data/db。
注意
要在 Mac 上使用 Homebrew 启动和停止 MongoDB,请运行 brew services start mongodb 或 brew services stop mongodb。Homebrew 在后台运行数据库服务器,因此如果 mongod 不起作用,您可能已经在其他地方使用 Homebrew 启动了 MongoDB。
您可以通过在新的终端窗口中输入 mongo 来测试 Mongo 是否安装成功。此命令会启动 MongoDB shell,这是一个环境,您可以在其中运行 MongoDB 命令并查看数据。此 shell 环境类似于 REPL,因为它将您的终端窗口隔离出来,允许您仅与 MongoDB 语法进行交互。当您有一些数据要处理时,您可以进一步探索这个环境。
快速检查 13.1
Q1:
MongoDB 使用什么数据结构来存储数据?
| |
QC 13.1 答案
1:
MongoDB 使用文档来存储数据。
13.2. 在 MongoDB shell 中运行命令
现在 MongoDB 已启动,它准备好接收添加、查看、删除或更改数据的命令。在你将 MongoDB 连接到应用程序之前,你可以在 MongoDB shell 中测试一些命令。
警告
你在 MongoDB shell 中运行的命令是永久的。如果你删除了数据(或整个数据库),就无法恢复。
在新的终端窗口中运行 mongo。此命令应提示 shell 启动。你会看到你的 MongoDB 版本号,可能还有一些警告(你现在可以忽略),以及熟悉的 > 来指示 shell 已处于活动状态并准备好接收命令。
MongoDB 可以存储多个数据库;它是所有应用程序数据库的管理系统。首先,MongoDB shell 将你置于测试数据库中。你可以通过输入 db 来查看这个测试数据库,以列出你的当前数据库,在提示符后 (图 13.2)。
图 13.2. MongoDB shell 查看当前测试数据库

要查看所有可用的数据库,运行 show dbs。对于 MongoDB 的干净安装,你的 shell 的响应应该看起来像下面的列表。你的测试数据库是 MongoDB 预先打包的三个数据库之一。在数据库名称的右侧是数据库的大小。因为你还没有存储任何数据,所以数据库是空白的。
列表 13.2. 在终端中显示所有数据库
admin 0.000GB
local 0.000GB
test 0.000GB *1*
- 1 查看本地数据库。
你可以通过输入 use <new_database_name> 来创建一个新的数据库并同时切换到它。尝试通过输入 use recipe_db 切换到新的食谱应用程序数据库。然后再次运行 db 以查看你已处于 recipe_db 数据库中。
注意
你不会在数据库列表中看到你的新数据库,直到添加数据。
要向数据库添加数据,你需要指定一个与该数据关联的集合名称。MongoDB 的 集合 代表你的数据模型,将所有与该模型相关的文档存储在同一组中。例如,如果你想为食谱应用程序创建一个联系人列表,可以创建一个新的集合,并使用以下列表中的命令添加一个数据项。insert 方法在 MongoDB 集合上运行,以将 JavaScript 对象的元素添加到新文档中。
列表 13.3. 在终端中向新集合添加数据
db.contacts.insert({
name: "Jon Wexler",
email: "jon@jonwexler.com",
note: "Decent guy."
}) *1*
- 1 将新数据插入到数据库中。
在这个阶段,没有严格的集合结构;你可以在新文档中添加任何值,而无需遵循之前的数据模式。使用以下属性将另一个项目插入到 contacts 集合中:{first_name: "Jon", favoriteSeason: "spring", countries_visited: 42}。MongoDB 允许你添加这些看似冲突的数据元素。
注意
尽管 MongoDB 允许您存储不一致的数据,但这并不意味着您应该这样做。在第 14 课中,我讨论了围绕您应用程序模型组织数据的方法。
要列出集合的内容,您可以输入db.contacts.find()。您应该看到类似于以下列表的响应。两个插入的项目都存在,MongoDB 添加了一个额外的属性。id属性存储一个唯一的值,您可以使用它来区分和定位数据库中的特定项目。
列表 13.4. 终端中查找所有数据响应
{"_id": ObjectId("5941fce5cda203f026856a5d"), "name": "Jon
Wexler", "email": "jon@jonwexler.com", "note":
"Nice guy."} *1*
{"_id": ObjectId("5941fe7acda203f026856a5e"), "first_name":
"Jon", "favoriteSeason": "spring", "countries_visited": 42}
- 1 显示数据库文档的结果。
ObjectId
为了保持您的数据有组织和唯一,MongoDB 使用ObjectId类来记录有关其数据库文档的一些有意义的信息。例如,ObjectId("5941fe7acda203f026856a5e")构建了一个表示数据库中文档的新ObjectId。传递给ObjectId构造函数的十六进制值引用了文档、记录创建的时间戳以及有关您的数据库系统的一些信息。
生成的ObjectId实例提供了许多有用的方法,您可以使用它们在数据库中对数据进行排序和组织。因此,_id属性在 MongoDB 中比文档 ID 的字符串表示形式更有用。
通过输入db.contacts .find({_id: O``b``jectId("5941fce5cda203f026856a5d")})在contacts集合中搜索特定项目。
注意
将此示例中的ObjectId替换为您自己的数据库结果中的一个。
| |
MongoDB Compass
随着您对 MongoDB 的熟悉,您可能需要一个比终端中的 MongoDB shell 更用户友好的窗口来查看您的 MongoDB 数据库。MongoDB 团队同意了这一点,并为所有主要操作系统开发了一个名为 MongoDB Compass 的图形用户界面。
MongoDB Compass 使用简单。要查看为您的食谱应用程序设置的数据库,请按照以下步骤操作:
-
按照安装步骤将 MongoDB Compass 添加到您的应用程序文件夹中。
-
运行 MongoDB Compass 并接受默认的连接设置到您现有的 MongoDB 设置。
-
查看您的数据库(包括
recipe_db),并列出查看其中集合和文档的选项,如图 13.3 所示。
图 13.3. MongoDB Compass 中的数据库视图

MongoDB Compass 中的数据库视图
我建议在您在应用程序中使用 MongoDB 时,将 MongoDB Compass 作为一个辅助工具。
您可以使用许多 MongoDB 命令。表 13.1 列出了您应该了解的一些命令。
表 13.1. MongoDB Shell 命令
| 命令 | 描述 |
|---|---|
| show collections | 显示您数据库中的所有集合。稍后,这些集合应与您的模型匹配。 |
| db.contacts.findOne | 从您的数据库中随机返回一个项目或返回一个匹配作为参数传入的标准的单个项目,这可能看起来像 findOne({name: ‘Jon’})。 |
| db.contacts.update({name: “Jon”}, {name: “Jon Wexler”}) | 使用第二个参数的属性值更新任何匹配的文档。 |
| db.contacts.delete({name: “Jon Wexler”}) | 删除集合中任何匹配的文档。 |
| db.contacts.deleteMany({}) | 删除该集合中的所有文档。这些命令是不可逆的。 |
为了更多练习,请查看 docs.mongodb.com/manual/reference/mongo-shell/ 中的命令速查表。
在下一节中,您将了解如何将 MongoDB 添加到您的 Node.js 应用程序中。
快速检查 13.2
Q1:
您可以使用哪个 MongoDB 命令来查看数据库中的现有集合?
| |
QC 13.2 答案
1:
show collections列出 MongoDB shell 中活动数据库中的集合。
13.3. 将 MongoDB 连接到您的应用程序
要将 MongoDB 添加到您的 Node.js 食谱应用程序中,请在终端中进入您的项目文件夹(或创建一个新的初始化项目),并运行 npm i mongodb@3.6.3 -S 命令来安装 mongodb 包。此命令将 mongodb 包保存到您的项目 package.json 依赖项中。
注意
在本课程的相应代码存储库中,已从上一个综合项目添加了一些视图和样式规则。
在 main.js 文件顶部,添加 清单 13.5 中显示的代码。使用 MongoClient 类需要引入 MongoDB 模块。MongoClient 在其默认端口上设置与本地数据库的连接。回调函数返回到 MongoDB 服务器的连接。然后从服务器连接中获取名为 recipe_db 的数据库。如果不存在提供的名称的数据库,MongoDB 将为应用程序创建一个。
注意
记得在尝试连接之前运行 mongod 以确保您的 MongoDB 服务器正在运行。
接下来,要求数据库查找 contacts 集合中的所有记录,并将它们作为数组返回。结果数据在回调函数中返回。然后您可以将结果记录到控制台。
列表 13.5. 在 main.js 中添加 MongoDB 连接到 Express.js
const MongoDB = require("mongodb").MongoClient, *1*
dbURL = "mongodb://localhost:27017",
dbName = "recipe_db";
MongoDB.connect(dbURL, (error, client) => { *2*
if (error) throw error;
let db = client.db(dbName); *3*
db.collection("contacts")
.find()
.toArray((error, data) => { *4*
if (error) throw error;
console.log(data); *5*
});
});
-
1 引入 MongoDB 模块。
-
2 设置到本地数据库服务器的连接。
-
3 从 MongoDB 服务器连接中获取 recipe_db 数据库。
-
4 在 contacts 集合中查找所有记录。
-
5 将结果打印到控制台。
注意
这里的 find 查询方法与传统函数式编程语言中的 find 查询不同。如果你在 MongoDB 中使用 find 没有匹配项,你会得到一个空数组。
你可以在你的 Node.js 应用程序中使用与在 MongoDB 命令行界面中相同的命令。例如,要向数据库中添加一个新项目,你可以在你的 MongoDB 连接回调函数中添加 代码清单 13.6 中的代码。
当你查询数据库中的所有项目时,你连接到 contacts 集合并插入一个项目。如果新数据插入成功,你将数据库消息记录到控制台。
代码清单 13.6. 从你的 Node.js 应用程序中将数据插入到终端
db.collection("contacts")
.insert({
name: "Freddie Mercury",
email: "fred@queen.com"
}, (error, db) => { *1*
if (error) throw error;
console.log(db); *2*
});
-
1 将一个新联系人插入到数据库中。
-
2 记录结果错误或保存的项目。
在 第 14 课 中,你探索了一个名为 Mongoose 的包,它与 MongoDB 一起工作,为你的应用程序存储提供更多组织。
快速检查 13.3
Q1:
判断对错:如果你尝试连接到一个不存在的数据库,MongoDB 会抛出一个错误。
QC 13.3 答案
1:
错误。MongoDB 会根据你提供的名称创建一个新的数据库,而不是抛出一个错误。
摘要
在本课中,你学习了如何设置 MongoDB 以及如何使用某些命令来管理计算机上的数据库。在本课结束时,你将集合和文档插入到自己的数据库中,并将该数据库连接到你的 Node.js 应用程序。在 第 14 课 中,你将构建模型来表示你希望在应用程序中存储的数据类型。
尝试这个
想象一下,你正在创建一个用于跟踪冰淇淋车统计数据的程序。创建一个名为 ice_cream_flavors 的适当命名的数据库集合。尝试插入一些口味,并包括有助于你的统计分析的字段。
第 14 课. 使用 Mongoose 构建模型
在 第 13 课 中,你开始使用 MongoDB。连接到你的 Node.js 应用程序的数据库后,你就可以保存和加载数据了。在本课中,你将采用更面向对象的方法来处理数据。首先,你安装了 Mongoose 包,这是一个在应用程序逻辑和数据库之间提供语法层的工具。Mongoose 允许你将应用程序数据转换为适合模型结构。在课程的后半部分,你将构建第一个模型和模式来表示食谱应用程序的订阅者。
本课涵盖
-
将 Mongoose 安装并连接到你的 Node.js 应用程序
-
创建模式
-
构建 Mongoose 数据模型和实例化
-
使用自定义方法加载数据和保存数据
考虑这一点
你最终将数据库连接到你的应用程序,但数据会随时间变化。有一天,你可能希望所有食谱都遵循相同的格式。你如何确定这种结构并确保所有保存的数据都遵循该结构的规则?
在本节课中,你将探索 Mongoose,这是一个用于创建模型模式的库。当你使用这些模式时,你的数据开始遵循只有你可以定制的严格规则。
14.1. 使用 Node.js 应用程序设置 Mongoose
你已经体验了 Express.js 并看到了它是如何帮助你处理 HTTP 请求和响应的。同样,还有其他包可用于协助你的 Node.js 应用程序与其数据库之间的通信。你将要使用的工具被称为 Mongoose。Mongoose 是一个对象-文档映射器(ODM),它允许你以保持应用程序面向对象结构的方式运行 MongoDB 命令。例如,仅使用 MongoDB 时,很难保持一个保存的文档与下一个的一致性。Mongoose 通过提供构建具有定义可以保存的数据类型的模式的模型工具来改变这种情况。
我在单元 2 中讨论了模型-视图-控制器(MVC)架构,并描述了控制器如何与视图和模型通信以确保正确数据通过应用程序流动。模型类似于 Mongoose 用于组织你的数据库查询的 JavaScript 对象的类。在本节中,你将安装 Mongoose 并查看你的应用程序中的模型看起来像什么(图 14.1)。
图 14.1. 使用 Mongoose 创建的模型映射到 MongoDB 中的文档。

要安装 Mongoose,请在终端中运行 npm i mongoose -S,在项目文件夹内。使用 Mongoose,你不再需要在 main.js 的顶部引入 mongodb 或使用来自第 13 课的任何 MongoDB 代码。将 清单 14.1 中的代码添加到 main.js 中。将 mongoose 引入应用程序文件。设置应用程序与 MongoDB 数据库的连接。(这里适用的规则与正常 MongoDB 连接相同。)然后将数据库连接分配给 db 变量,你可以在文件中稍后使用它进行数据更改或数据库状态更改。
清单 14.1. 在 main.js 中配置 Mongoose 与你的 Node.js 应用程序
const mongoose = require("mongoose"); *1*
mongoose.connect(
"mongodb://localhost:27017/recipe_db", *2*
{useNewUrlParser: true}
);
const db = mongoose.connection; *3*
-
1 引入 mongoose。
-
2 设置与数据库的连接。
-
3 将数据库分配给 db 变量。
注意
请记住,MongoDB 服务器需要在后台运行。要运行 MongoDB,请在终端窗口中输入 mongod。
你需要做的所有事情来设置 Mongoose。你可以在数据库连接后立即记录一条消息,通过将下一列表中的代码添加到 main.js 中。数据库连接在接收到数据库的“open”事件后,仅在回调函数(日志消息)中运行一次代码。
列表 14.2. 在 main.js 中数据库连接时记录消息
db.once("open", () => { *1*
console.log("Successfully connected to MongoDB using Mongoose!");
});
- 1 当应用程序连接到数据库时记录一条消息。
在下一节中,您将探索如何建模数据以充分利用 Mongoose。
快速检查 14.1
Q1:
ODM 是什么?
| |
QC 14.1 答案
1:
ODM 是对象-文档映射器,这是 Mongoose 在您的应用程序开发中的角色。ODM(类似于对象-关系映射器)使您在应用程序中以纯粹的对象方式思考变得更容易,而不用担心数据在数据库中的结构。
14.2. 创建模式
模式类似于某些语言中的类定义,或者更广泛地说,是您希望数据在应用程序中特定对象中组织的方式的蓝图。为了避免不一致的数据,例如,一些文档有 email 字段而其他没有,您可以创建一个模式,声明所有 contact 对象都需要有电子邮件字段才能保存到数据库中。
因为您想在食谱应用程序中添加一个新闻通讯订阅表单,所以为订阅者创建一个模式。将 列表 14.3 中的代码添加到 main.js 中。mongoose.Schema 提供了一个构造函数,允许您使用给定的参数构建一个模式对象。然后添加对象属性以声明对象的字段名称和数据类型。例如,某人的姓名不能是数字。
列表 14.3. main.js 中的订阅者模式
const subscriberSchema = mongoose.Schema({ *1*
name: String, *2*
email: String,
zipCode: Number
});
-
1 使用 mongoose.Schema 创建一个新的模式。
-
2 添加模式属性。
注意
MongoDB 不强制执行您的模式;Mongoose 是。有关 Mongoose 模式数据类型的更多信息,请访问 mongoosejs.com/docs/schematypes.html。
现在模式已定义,您需要通过使用 const Subscriber = mongoose.model(“Subscriber”, subscriberSchema) 将其应用于模型。模型是您将用于实例化新的 Subscriber 对象的,您创建的模式可以用于该模型。model 方法接受您选择的模型名称和先前定义的模式(在本例中为 subscriberSchema)。
您可以通过引用 Subscriber 从此模型中实例化新对象。您有两种生成新对象的方法,如 列表 14.4 所示。您可以通过使用 new 关键字并传递符合本节中较早提到的 subscriberSchema 的属性来构造 Subscriber 模型的新实例。要将这个新创建的 Subscriber 对象保存到数据库中,您可以在其上调用 save 并通过回调函数处理任何错误或返回的数据。
错误可能与您之前定义的模式类型不匹配的数据有关。保存的项目返回您可以在应用程序的其他地方使用的数据。例如,您可能想通过姓名感谢订阅者注册。create在一步中完成了new和save的功能。如果您知道您想立即创建并保存对象,请使用此 Mongoose 方法。
注意
从您的 Mongoose 模型中实例化对象类似于从 Java-Script 对象中实例化。new关键字可以与 JavaScript 数组和其他数据类型一起使用。
列表 14.4. 在 main.js 中创建和保存模型的语句
var subscriber1 = new Subscriber({
name: "Jon Wexler",
email: "jon@jonwexler.com"
}); *1*
subscriber1.save((error, savedDocument) => { *2*
if (error) console.log(error); *3*
console.log(savedDocument); *4*
});
Subscriber.create(
{
name: "Jon Wexler",
email: "jon@jonwexler.com"
},
function (error, savedDocument) { *5*
if (error) console.log(error);
console.log(savedDocument);
}
);
-
1 实例化一个新的订阅者。
-
2 将订阅者保存到数据库中。
-
3 将潜在的错误传递给下一个中间件函数。
-
4 记录保存的数据文档。
-
5 一步创建并保存订阅者。
将本节中的代码添加到您的 main.js 文件中。一旦您使用node main.js启动应用程序,您应该看到您的 MongoDB recipe_db数据库中填充了新的订阅者。
快速检查 14.2
Q1:
正误:使用
new Subscriber({ name:“Jon”, email:“jon@jonwexler.com”})将新记录保存到您的数据库中。
| |
QC 14.2 答案
1:
错误。此代码仅创建一个新的虚拟对象。如果您将此行的值存储到变量中并在该变量上调用
save,则新订阅者将被存储在数据库中。
14.3. 组织您的模型
现在您有了以 Mongoose 模型形式保存数据的方法,您可能想要组织您的模型,以便它们不会使您的 main.js 文件杂乱。就像您为视图和控制器做的那样,在应用程序的根级别创建一个名为 models 的文件夹。在该文件夹内,创建一个名为 subscriber.js 的新文件。
此文件是您将模型代码移动到的位置。将所有模式和模型定义代码移动到该文件,并将模型移动到文件的exports对象中。(参见以下列表。)任何需要subscriber.js的模块都将能够访问Subscriber模型。模式不需要在文件外部公开。
列表 14.5. 将模式和模型移动到单独的模块
const mongoose = require("mongoose"),
subscriberSchema = mongoose.Schema({
name: String,
email: String,
zipCode: Number
});
module.exports = mongoose.model("Subscriber", subscriberSchema); *1*
- 1 将订阅者模型作为唯一的模块导出。
注意
您需要在这个模块中引入mongoose,因为模式和模型都使用 Mongoose 方法来工作。Node.js 只将一个模块加载到项目中一次,所以在这里引入它不应该减慢您的应用程序;您正在告诉 Node.js 您想使用一个已加载的模块。
接下来,通过在您的其他所需模块下方添加const Subscriber = require(“./models/subscriber”)来在 main.js 中引入此模型。现在您应该能够像以前一样使用该模型。
在 main.js 中,使用 Mongoose 的 findOne 和 where 查询方法在你的数据库中查找文档。例如,你可以使用 Subscriber.findOne({ name: "Jon Wexler"}) .where("email", /wexler/) 来查找并返回一个匹配以下条件的文档:电子邮件包含字符串 "wexler"。
这个自定义查询示例展示了你的查询如何灵活,以获取你需要的数据。Mongoose 允许你链式调用查询的一部分,甚至可以将查询存储在变量中。你可以创建一个变量 var findWexlers 并将其分配给查询电子邮件中包含单词 wexler 的代码。然后你可以通过使用 findWexlers.exec() 在以后运行查询。(有关 exec 的更多信息,请参阅 第 15 课。)
如果你计划立即运行查询而不使用 exec 方法,你需要一个带有两个参数的回调函数。第一个参数表示发生的任何错误,第二个参数表示数据库返回的任何数据,如下所示。尝试通过遵循 mongoosejs.com/docs/queries.html 上的某些示例查询来创建你自己的查询。
列表 14.6. 在 main.js 中运行的示例查询
var myQuery = Subscriber.findOne({
name: "Jon Wexler"
})
.where("email", /wexler/);
myQuery.exec((error, data) => {
if (data) console.log(data.name);
}); *1*
- 1 运行一个带有回调函数的查询以处理错误和数据。
注意
对于表示将从数据库返回多个项目的查询,你应该期望一个数组。如果没有找到文档,你将得到一个空数组。
现在你有自由创建更多模块,并通过使用它们的名称而不是 MongoDB 集合名称来保存它们。
在 单元 4 中,你学习了如何创建一个更健壮的模型,其值可以创建、读取、更新和删除——CRUD 应用程序中的四个核心模型功能。我在那个单元中详细讨论了这种方法。
快速检查 14.3
Q1:
在 Mongoose 架构中指定每个字段需要哪些两个组件?
QC 14.3 答案
1:
架构需要属性名称和数据类型。
摘要
在本课中,你学习了如何设置 Mongoose 并使用你的 MongoDB 连接将数据映射到数据库。你还了解了一些 Mongoose 语法和方法。通过本课中的步骤,你学习了如何创建用于存储持久数据的架构和模型。最后,你组织了你的模型,为新的工具腾出空间。在 第 15 课 中,你通过在数据库查询中实现 JavaScript promises 来清理本课中构建的一些功能。
尝试这个
最终,你将为你的食谱应用创建更多模型。开始思考这些模型将是什么样子。例如,你可能需要一个模型来表示通过该计划提供的不同类型的课程。尝试创建一个食谱条目的架构和模型。
第 15 课:连接控制器和模型
到目前为止,你已经设置了你的 Node.js 应用程序来处理数据并将数据存储在 MongoDB 数据库中。在 Mongoose 的帮助下,你已经使用模型和模式结构化了你的数据。在本课中,你将你的路由连接到控制器和这些模型,以便你可以开始根据用户的 URL 请求保存有意义的基于用户的数据。首先,你将为订阅者路由构建一个新的控制器。然后,你将把这些路由转换为使用 JavaScript ES6 允许的 Promise。添加 Promise 现在给你的数据库调用提供了更多的灵活性,并且随着应用程序的增长。最后,你将本课总结为新的视图和表单,订阅者可以在其中发布他们的信息。
本课涵盖
-
将控制器连接到模型
-
通过控制器动作保存数据
-
使用 Promise 实现数据库查询
-
处理提交的表单数据
考虑以下内容
你的食谱应用程序正在使用 Mongoose 模型来表示数据库中的数据。然而,JavaScript 在你的应用程序中是异步的,所以数据库调用需要回调在完成时运行。但是,回调可能会很混乱,尤其是在复杂的查询中。
幸运的是,你可以使用多种其他类型的语法来包装你的回调,并以更优雅的方式处理返回的数据或错误。Promise 是实现这一点的途径,Mongoose 也提供了在你的应用程序中使用 Promise 语法的支持。
15.1. 创建订阅者控制器
回想一下,控制器是你模型(数据)和视图(网页)之间的粘合剂。现在你已经设置好了模型,你需要一个控制器来处理针对与你的模型相关的数据的特定外部请求。如果有人请求主页路径/,你可以通过主页控制器中的逻辑返回一个视图。现在,如果有人想要注册为订阅者,你需要实现一个订阅者控制器。在你的控制器文件夹中创建一个名为subscribersController.js的新文件。
注意
通常,控制器以你的模型复数形式命名。没有严格的规则,正如你所见,你已经有一个homeController.js,但这个控制器在应用程序中并不代表一个模型。
此文件需要访问 mongoose 和你的 Subscriber 模型,这两个都可以在文件顶部引入。接下来,你可以创建一个控制器操作,用于处理对数据库中所有订阅者视图的请求。代码看起来像 列表 15.1。引入 mongoose 以便访问将模型保存到数据库所需的工具。接下来,从你的 subscriber 模块中引入 Subscriber 模型,以便将其集成到你的代码逻辑中;你不再需要在 main.js 中引用 Subscriber 模型。getAllSubscribers 将对任何需要此模块的文件可用。你可以使用这个导出的回调函数从数据库返回数据。
在此控制器操作中,你使用 Mongoose 的 find 方法在 Subscriber 模型上,告诉 MongoDB 你想要数据库中所有订阅者的数组。
注意
使用不带任何参数的 find 查询方法与空对象 ({}) 相同。在这里,你使用空对象来明确表示你想要获取所有订阅者,没有任何附加条件。
如果在从数据库读取时发生错误,将其发送到下一个中间件函数。否则,将来自 MongoDB 的数据设置到请求对象中。然后这个对象可以被中间件链中的下一个函数访问。
列表 15.1. 在 subscribersController.js 中构建你的订阅者控制器
const Subscriber = require("../models/subscriber"); *1*
exports.getAllSubscribers = (req, res, next) => { *2*
Subscriber.find( {}, (error, subscribers) => { *3*
if (error) next(error); *4*
req.data = subscribers; *5*
next(); *6*
});
};
-
1 引入订阅者模块。
-
2 导出
getAllSubscribers以将数据库数据传递给下一个中间件函数。 -
3 在订阅者模型上使用 find 查询。
-
4 将错误传递给下一个中间件函数。
-
5 将来自 MongoDB 的请求数据设置在请求对象中。
-
6 继续到下一个中间件函数。
注意
因为模型在不同的文件夹中,你需要使用 .. 来表示在进入模型文件夹之前退出当前文件夹,然后引入它。
确保你仍然安装并正确设置了 Express.js。下一步是在 main.js 中设置路由。首先,确保通过使用 const subscribersController = require("./controllers/subscribers -Controller") 在 main.js 中引入订阅者控制器。你使用的路由看起来像 列表 15.2 中的代码。
在此代码中,你正在寻找对 /subscribers 路径发出的 GET 请求。在接收到请求后,将请求传递给 subscribersController.js 中的 getAllSubscribers 函数。因为在该函数中你没有对数据进行任何操作,所以将查询结果附加到请求对象上,并将其传递给下一个中间件函数。在这种情况下,该函数是一个自定义回调,用于在浏览器中渲染数据。
列表 15.2. 在 main.js 中使用订阅者控制器
app.get("/subscribers", subscribersController.getAllSubscribers, *1*
(req, res, next) => {
console.log(req.data); *2*
res.send(req.data); *3*
});
-
1 将请求传递给
getAllSubscribers函数。 -
2 记录请求对象中的数据。
-
3 在浏览器窗口上渲染数据。
通过运行 npm start 重新启动您的应用程序来测试此代码。如果一切按计划进行,您可以通过访问 http://localhost:3000/subscribers 来查看按姓名和电子邮件列出的数据库中所有订阅者(图 15.1)。
图 15.1. 包含订阅者数据的示例浏览器响应

您可以通过在视图中响应数据而不是返回数据来立即改进此操作。修改操作的返回语句,并用 Express.js 中的 res.render 替换它们。渲染名为 subscribers.ejs 的视图的行可能看起来像 res.render(“subscribers”;, {subscribers: req.data})。响应调用渲染一个名为 subscribers.ejs 的视图,并通过一个名为 subscribers 的变量将数据库中的订阅者传递给该视图。现在您需要构建视图来显示这些订阅者。
注意
最终,这个页面将由应用程序的管理员使用,以查看谁注册了食谱应用程序。但到目前为止,这个页面对其关联的路由的访问是公开的。
在您的视图文件夹中创建一个名为 subscribers.ejs 的文件,并添加 列表 15.3 中的代码。使用 EJS 模板语法,遍历从您刚刚创建的操作中传递的 subscribers 数组。对于每个订阅者 s,您可以打印一些信息。您在段落标签中打印订阅者的姓名和电子邮件地址。
列表 15.3. 在 subscribers.ejs 中循环并打印订阅者
<%subscribers.forEach(s => { %> *1*
<p><%= s.name %></p> *2*
<p><%= s.email %></p>
<% }); %>
-
1 遍历订阅者。
-
2 将订阅者数据插入到视图中。
您的视图 http://localhost:3000/subscribers 应该列出您的订阅者,如图 图 15.2 所示。
图 15.2. 列出订阅者数据的示例浏览器视图

在下一节中,您将添加两个额外的路由来处理通过表单发布的信息。
快速检查 15.1
Q1:
您从哪个模块将数据传递到视图中?
QC 15.1 答案
1:
您可以从控制器将数据传递到视图中。在 subscribersController.js 中,您在渲染的
subscribers.ejs中传递一个订阅者数组。
15.2. 将发布的数据保存到模型中
到目前为止,当向您的应用程序的 web 服务器发出请求时,应该只有一个方向的数据流动。下一步是将用户提交的数据以订阅者对象的形式保存。图 15.3 展示了从表单到您数据库的信息流。
图 15.3. 从网页表单到您的数据库的流程

回想一下,根据其模式,订阅者对象必须包含名称、电子邮件和邮政编码字段,所以你应该有一个包含这些输入字段的表单视图。将 contact.ejs 中的表单更改为使用下一个列表中显示的表单。表单将通过 HTTP POST 请求将数据提交到 /subscribe 路径。表单的输入与订阅者模型的字段相匹配。
列表 15.4. 在 contact.ejs 中将表单数据发布到订阅者数据
<form action="/subscribe" method="post"> *1*
<input type="text" name="name" placeholder="Name">
<input type="text" name="email" placeholder="Email">
<input type="text" name="zipCode" placeholder="Zip Code"
pattern="[0-9]{5}">
<input type="submit" name="submit">
</form>
- 1 添加订阅表单。
因为这个表单将在 contact.ejs 渲染时显示,所以创建一个路由,当从订阅者控制器请求 /contact 路径时渲染这个视图。你需要为 /subscribe 路径构建一个 GET 路由,并修改现有 /contact 路径的 POST 路由。这些路由看起来像 列表 15.5 中的代码。
第一条路由监听对 /subscribe 的请求,并在 subscribersController 中使用 getSubscriptionPage 回调。第二条路由仅对使用 POST 方法的请求使用 saveSubscriber 回调函数。
注意
在这些更改之后,你不再需要在 homeController.js 中使用联系表单路由处理程序或 main.js 中的它们的路由。
列表 15.5. 主.js 中的订阅路由
app.get("/contact", subscribersController.getSubscriptionPage); *1*
app.post("/subscribe", subscribersController.saveSubscriber); *2*
-
1 添加一个用于订阅页面的 GET 路由。
-
2 添加一个 POST 路由来处理订阅数据。
要完成这里的工作,创建 getSubscriptionPage 和 saveSubscriber 函数。在 subscribersController.js 中,添加 列表 15.6 中的代码。第一个操作从视图文件夹渲染一个 EJS 页面。saveSubscriber 从请求中收集数据,并允许 body-parser 包(在 单元 2 中安装)读取请求体的内容。创建一个新的模型实例,将订阅者的字段映射到请求体参数。作为最后一步,尝试保存订阅者。如果失败,则响应错误。如果成功,则响应 thanks.ejs。
列表 15.6. 订阅路由的控制器操作在 subscribersController.js
exports.getSubscriptionPage = (req, res) => { *1*
res.render("contact");
};
exports.saveSubscriber = (req, res) => { *2*
let newSubscriber = new Subscriber({
name: req.body.name,
email: req.body.email,
zipCode: req.body.zipCode
}); *3*
newSubscriber.save((error, result) => { *4*
if (error) res.send(error);
res.render("thanks");
});
};
-
1 添加一个渲染联系页面的操作。
-
2 添加一个保存订阅者的操作。
-
3 创建一个新的订阅者。
-
4 保存一个新的订阅者。
注意
MongoDB 返回新创建的订阅者的 _id。示例中的 result 变量包含此信息。
你可以通过在 http://localhost/contact 填写自己的表单来尝试这段代码。然后访问 http://localhost:3000/subscribers 来查看订阅者列表,包括你的新帖子。在下一节中,你将通过使用 JavaScript promises 来添加数据库查询的一个额外功能。
快速检查 15.2
Q1:
除了 Express.js 之外,还需要哪些中间件来处理表单数据?
QC 15.2 答案
1:
为了轻松解析请求体,你需要
express.json和express.urlencoded中间件函数的帮助。这些模块在 Express.js 接收并完全处理请求之间充当中间件。
15.3. 使用 Mongoose 的承诺
ES6 使使用承诺来促进异步查询中函数链(通常是回调函数)的想法变得流行。承诺是一个包含有关函数调用状态和链中下一个调用需要看到的信息的 JavaScript 对象。类似于中间件,承诺可以允许一个函数开始并耐心等待它完成,然后再将其传递给下一个回调函数。最终,承诺提供了一种更干净的方式来表示嵌套回调,并且随着数据库查询现在被引入到你的应用程序中,你的回调函数可以变得很长。
幸运的是,Mongoose 被构建为与承诺一起工作。你需要做的只是让 Mongoose 知道你想要使用原生的 ES6 承诺,通过在 main.js 的顶部附近添加mongoose.Promise = global.Promise。现在,对于每个查询,你可以选择返回正常的数据库响应或包含该响应的承诺。例如,在列表 15.7 中,从数据库获取所有订阅者的查询返回了一个包含数据库响应的新承诺。
使用承诺重写此操作仍然允许查询数据库中的所有订阅者。在查询中,而不是立即渲染视图,返回一个包含有关通过渲染视图解决或通过记录错误拒绝的数据的承诺。通过使用find之后的exec调用,你正在调用查询以返回一个承诺。
注意
即使不使用exec,你仍然可以使用then和catch来处理后续命令。然而,没有exec,你将不会有一个真实的承诺——只有 Mongoose 的承诺查询版本。但是,一些 Mongoose 方法,例如save,返回一个承诺并且不能与exec一起使用。你可以在mongoosejs.com/docs/promises.html了解更多关于区别的信息。
如果在处理过程中发生错误,错误会沿着承诺链传播到catch块。否则,查询返回的数据会传递到下一个then块。这种承诺链过程遵循在承诺块中拒绝或解决代码的承诺约定,以确定应该运行哪个代码(图 15.4)。
图 15.4. Mongoose.js 中的承诺链

当承诺完成时,它会调用next来使用 Express.js 中的任何后续中间件。你通过链式调用一个then方法来告诉承诺在数据库响应后立即执行此任务。这个then块是你渲染视图的地方。接下来,链式调用catch方法来处理承诺中拒绝的任何错误。
注意
then 仅在 promise 的上下文中使用。next 在中间件函数中使用。如果两者都使用,如 列表 15.7 中所示,您将等待 promise 通过 then 解决,然后调用 next 以转到另一个中间件函数。
您可以添加任意多的 then 链,最终告诉您的 promise 在其他所有操作完成后运行该块内的代码。最后的 then 块会在您的控制台记录一条消息,以告知您 promise 已完成。
列表 15.7. 在 subscribersController.js 中使用 promises 获取所有订阅者
exports.getAllSubscribers = (req, res) => { *1*
Subscriber.find({})
.exec() *2*
.then((subscribers) => { *3*
res.render("subscribers", {
subscribers: subscribers
}); *4*
})
.catch((error) => { *5*
console.log(error.message);
return [];
})
.then(() => { *6*
console.log("promise complete");
});
};
-
1 重写 getAllSubscribers 动作。
-
2 从 find 查询返回 promise。
-
3 将保存的数据发送到下一个代码块。
-
4 从数据库中提供结果。
-
5 捕获 promise 中拒绝的错误。
-
6 使用日志消息结束 promise 链。
您也可以修改 saveSubscriber 中的 save 命令,以使用以下列表中所示的方式使用 promises。在此示例中,不需要 exec。
列表 15.8. 修改 saveSubscriber 以在 subscribers-Controller.js 中使用 promises
newSubscriber.save()
.then(result => { *1*
res.render("thanks");
})
.catch(error => {
if (error) res.send(error);
});
- 1 使用 promise 返回值保存新的订阅者。
最后,如果您想在开发中批量向应用程序添加数据,而不是通过联系表单逐个输入新订阅者,您可以为此创建一个模块。在您的项目目录中创建 seed.js,并在 列表 15.9 中添加代码。此文件与您的本地数据库建立连接,并遍历订阅者数组以创建订阅者。首先,使用 remove 清除现有的订阅者数据库。然后,promise 库的 Promise.all 等待所有新的订阅者文档创建完成后才打印日志消息。
列表 15.9. 在 seed.js 中创建新数据
const mongoose = require("mongoose"),
Subscriber = require("./models/subscriber");
mongoose.connect( *1*
"mongodb://localhost:27017/recipe_db",
{ useNewUrlParser: true }
);
mongoose.connection;
var contacts = [
{
name: "Jon Wexler",
email: "jon@jonwexler.com",
zipCode: 10016
},
{
name: "Chef Eggplant",
email: "eggplant@recipeapp.com",
zipCode: 20331
},
{
name: "Professor Souffle",
email: "souffle@recipeapp.com",
zipCode: 19103
}
];
Subscriber.deleteMany()
.exec() *2*
.then(() => {
console.log("Subscriber data is empty!");
});
var commands = [];
contacts.forEach((c) => { *3*
commands.push(Subscriber.create({
name: c.name,
email: c.email
}));
});
Promise.all(commands) *4*
.then(r => {
console.log(JSON.stringify(r));
mongoose.connection.close();
})
.catch(error => {
console.log(`ERROR: ${error}`);
});
-
1 设置与数据库的连接。
-
2 删除所有现有数据。
-
3 遍历订阅者对象以创建 promises。
-
4 在 promises 解决后记录确认信息。
您可以通过在终端中输入 node seed.js 来运行此文件,以避免在后续课程中有一个空或不一致的数据库。我在 单元 8 中更多地讨论了如何使用种子数据。
快速检查 15.3
Q1:
使用 exec 在 Mongoose 查询上与运行返回新 promise 的查询相同,对吗?
| |
QC 15.3 答案
1:
是的。
exec设计为运行查询,如果您的 Mongoose 设置配置了 promises,则返回 promise。
摘要
在本课中,你学习了如何将你的模型与控制器动作连接起来。你还通过从数据库中加载订阅者列表,在模型、视图和控制器之间建立了完整的连接。在本课结束时,你被介绍到与 Mongoose 和 Node.js 一起使用的承诺。在第 16 课中,你将在这个单元中学到的所有内容应用到架构练习中构建一个应用程序的数据库。在第 4 单元中,你将通过构建更健壮的模型和动作来进一步执行这些步骤,以实现除了保存和查看数据之外的功能。
尝试这个
尝试将你的其他控制器动作转换为使用承诺。你还可以链式调用其他 Mongoose 查询方法,如where和order。每个方法都将一个承诺传递给下一个命令。
第 16 课. 架构:保存用户订阅
我向 Confetti Cuisine 展示了 Express.js 应用程序,他们非常喜欢。他们告诉我,他们准备开始推广他们的烹饪课程,并希望访问网站的人订阅学校的通讯。这个通讯的订阅者是潜在客户,所以 Confetti Cuisine 希望我保存每个订阅者的姓名、电子邮件地址和邮政编码。
当我有一个数据库可以使用时,Confetti Cuisine 对进入构建用户账户的下一阶段感到满意。为了完成这个任务,我需要构建一个具有以下功能的应用程序:
-
一个 MongoDB 数据库
-
Mongoose 包
-
具有三个字段的数据库模式
-
网站上的订阅表单
-
一个处理
POST请求并保存订阅者数据模型的路由
16.1. 设置数据库
现在,Confetti Cuisine 准备保存用户数据,我需要为这个项目安装 MongoDB 和 Mongoose。首先,我在 Mac 上通过运行brew install mongodb使用 Homebrew 安装 MongoDB。然后我通过运行mongod在本地启动 MongoDB 服务器。
在一个新的终端窗口中,在我的项目目录中,我通过在项目文件夹中的新终端窗口中输入npm i mongoose -S来安装mongoose包。
接下来,我打开项目的主.js 文件,并使用列表 16.1 中的代码,引入mongoose以及我的数据库配置。我在这个项目中引入mongoose是为了使用模块的方法来建立与我的 MongoDB 数据库的连接。然后我设置了一个连接到本地计算机上的confetti_cuisine数据库。如果这个数据库还不存在,那么在我第一次运行这个应用程序时就会创建它。
列表 16.1. 在 main.js 中设置 Mongoose 的 Node.js 应用程序
const mongoose = require("mongoose"); *1*
mongoose.connect(
"mongodb://localhost:27017/confetti_cuisine", *2*
{useNewUrlParser: true}
);
-
1 引入 mongoose。
-
2 设置数据库连接。
接下来,我需要构建我的数据在进入数据库之前应该是什么样子。
16.2. 建模数据
因为 Confetti Cuisine 要求我为新订阅者存储三个字段,我将创建一个 Mongoose 模式来定义这些字段。首先,我创建一个新的models文件夹和一个新的subscriber.js文件,其中包含列表 16.2 中的模式。
我需要将mongoose引入到这个文件中,以便我可以访问该模块的工具和方法。这个 Mongoose 模式定义了订阅者模型应该包含的内容。在这种情况下,每个订阅者对象都应该有名称和电子邮件字段,它们都是String类型,以及一个zipCode字段,它是Number类型。
列表 16.2. 在subscriber.js中定义订阅者模式
const mongoose = require("mongoose"), *1*
subscriberSchema = mongoose.Schema({
name: String,
email: String,
zipCode: Number
}); *2*
-
1 引入 mongoose。
-
2 定义模式属性。
现在模式已经定义,我需要定义一个模型来使用这个模式。换句话说,我已经定义了一套规则,现在我需要创建一个模型来使用这些规则。
订阅者模型也位于subscriber.js文件中,但与模式不同,模型应该可以被应用程序中的其他模块访问。因此,我将模型添加到模块的exports对象中,如列表 16.3 所示。
我将我的订阅者模型分配给module.exports对象。其他模块将需要引入此文件以访问Subscriber模型。
列表 16.3. 在subscriber.js中创建导出的订阅者模型
module.exports = mongoose.model("Subscriber",
subscriberSchema); *1*
- 1 导出模型。
因为我知道我需要保存提交网站表单的订阅者,所以我将准备一个路由和一些逻辑来创建和保存新的订阅者到数据库。所有我的代码都与订阅者相关,因此我将在包含我的操作的controllers文件夹内创建一个新的subscribersController.js文件,以响应一个POST路由。该控制器中的代码在列表 16.4 中显示。
首先,我引入了subscriber.js模块。因为这个模块位于另一个本地文件夹中,所以引入行看起来是相对于controllers文件夹的models文件夹。Node.js 在models文件夹中查找subscriber.js文件,并将该模块的exports内容分配给一个本地常量,称为Subscriber。目前,这个模块是唯一需要使用Subscriber模型的地方。现在我可以创建Subscriber模块的实例或在该主应用程序文件中对该模型进行调用。
第一个操作使用find来运行一个查询,查找数据库中的所有订阅者并返回一个承诺。我使用then来继续查询链,并在成功接收到数据或捕获错误时使用catch渲染视图。第二个操作不需要承诺;它渲染一个视图。第三个操作创建一个Subscriber实例并将其保存到数据库。这种行为自动通过 Mongoose 返回一个承诺,并允许我链式调用更多功能或捕获错误。我在main.js中添加mongoose.Promise = global.Promise,以便 Mongoose 将支持我的承诺链。
列表 16.4. subscribersController.js中的订阅者控制器操作
const Subscriber = require("../models/subscriber"); *1*
exports.getAllSubscribers = (req, res) => { *2*
Subscriber.find({})
.exec()
.then((subscribers) => {
res.render("subscribers", {
subscribers: subscribers
});
})
.catch((error) => {
console.log(error.message);
return [];
})
.then(() => {
console.log("promise complete");
});
};
exports.getSubscriptionPage = (req, res) => { *3*
res.render("contact");
};
exports.saveSubscriber = (req, res) => { *4*
let newSubscriber = new Subscriber( {
name: req.body.name,
email: req.body.email,
zipCode: req.body.zipCode
});
newSubscriber.save()
.then( () => {
res.render("thanks");
})
.catch(error => {
res.send(error);
});
};
-
1 需要引入订阅者模型。
-
2 检索所有订阅者。
-
3 渲染联系页面。
-
4 保存订阅者。
到目前为止,我的应用程序可以正常启动 npm start,但我还没有创建连接到我的新控制器操作的路线。首先,我创建了一个与我的 getSubscriptionPage 函数相对应的表单。
16.3. 添加订阅者视图和路由
最后一个拼图是添加我的视图和访客可以使用来提交他们信息的表单。subscribers.ejs 视图在 HTML 标签内包含一个循环,以显示数据库中的所有订阅者,如列表 16.5 所示。EJS 允许基本的 JavaScript 与 HTML 内容并行运行。在这里,我正在遍历从订阅者控制器中的 getAllSubscribers 操作中获得的 subscribers。
列表 16.5. 在 subscribers.ejs 中遍历订阅者
<% subscribers.forEach(s => {%> *1*
<p>< s.name %></p>
<p><%= s.email%></p>
<% })%>
- 1 遍历订阅者数组。
我还需要另一个视图,用于订阅表单,它替换了 contact .ejs 中的表单。该表单提交到 /subscribe 路由,看起来像列表 16.6。此表单包含与 Subscriber 模式中的字段名称匹配的输入字段。当表单提交时,可以通过模型字段名称轻松提取数据,并在新的 Subscriber 实例中保存。
注意
我正在弃用主控制器中的 postedContactForm。可以移除旧路由和操作。
列表 16.6. 在 contact.ejs 中为新订阅者
<form action="/subscribe" method="post"> *1*
<label for="name">Name</label>
<input type="text" name="name" placeholder="Name">
<label for="email">Email</label>
<input type="email" name="email" placeholder="Email">
<label for="zipCode">Zip Code</label>
<input type="text" pattern="[0-9]{5}" name="zipCode"
placeholder="Zip Code">
<input type="submit" name="submit">
</form>
- 1 添加订阅表单。
为了让这些视图显示,我需要在 main.js 中添加和修改一些路由,如列表 16.7 所示。首先,我将 subscribersController.js 引入到文件顶部。然后我添加了一个新的路由来查看所有订阅者;此路由使用 subscribersController.js 中的 getAllSubscribers 函数(图 16.1)。
图 16.1. 在订阅者页面上列出订阅者数据

我没有为订阅视图创建新的路由,而是修改了 /contact 路由以使用我的 getSubscriptionPage 函数。当用户点击网站导航中的联系按钮时,他们会看到我的订阅表单。最后,我添加了一个 POST 路由,让我的 save-Subscriber 函数处理订阅表单的提交。
列表 16.7. 在 main.js 中添加订阅者路由
const subscribersController = require(
"./controllers/subscribersController"); *1*
app.get("/subscribers", subscribersController.getAllSubscribers); *2*
app.get("/contact", subscribersController.getSubscriptionPage); *3*
app.post("/subscribe", subscribersController.saveSubscriber); *4*
-
1 需要引入订阅者控制器。
-
2 添加一个路由来查看所有订阅者。
-
3 添加一个路由来查看联系页面。
-
4 添加一个路由来处理提交的表单数据。
结果是,从联系页面可以访问的表单,新访客可以发送他们的信息(图 16.2)。
图 16.2. 在联系页面上列出订阅表单

所需组件都已就位,应用程序准备就绪,可以发布。我将向 Confetti Cuisine 展示这个应用程序。我通过npm start重新启动我的应用程序,并演示订阅过程,以查看公司是否感兴趣。这个新增功能可能是衡量新闻通讯订阅者兴趣的好方法。
摘要
在这个项目中,我将一个主要静态的 Express.js 应用程序修改为开始保存和显示动态数据。通过这些更改以及 Express.js 中的模板引擎和中间件的帮助,这个应用程序正在成形。
我首先通过将应用程序与 Mongoose 连接起来,并使用 Mongoose 提供的模式和建模工具来结构化应用程序数据。接下来,我将这些模型与新的控制器和路由连接起来,这些控制器和路由处理查看和保存订阅者数据的特定请求。最后,我加入了一个表单,用户可以通过它最终与应用程序互动,并将他们的信息传递给 Confetti Cuisine 团队进行处理和审查。借助承诺(promises),代码整洁且准备好处理可能出现的错误。
在第 4 单元中,你将学习如何通过构建用户模型来在更高层次上使用 Mongoose。通过这个模型,你将了解在创建、读取、更新和删除(CRUD)数据时采取的验证和安全步骤。
第 4 单元。构建用户模型
在 第 3 单元 中,您学习了如何将应用程序连接到数据库。您还构建了您的第一个模式和模型。本单元通过向模型引入更多功能来构建这些课程。首先,您将更多地了解如何使用 Mongoose 模式和方法与模型进行更可靠的交互。您构建了一个表示用户数据和连接性的模型。每个用户都需要创建账户、编辑和删除账户。在本单元中,我讨论了应用程序开发中的创建、读取、更新和删除(CRUD)函数,并展示了您需要创建一个健壮模型的内容。在本单元结束时,您将拥有一个支持三个模型的应用程序,每个模型都与另一个模型相关联,并且可以从浏览器视图中进行管理。
本单元涵盖了以下主题:
-
第 17 课 深入探讨了 Mongoose 模式和模型。在本课中,您添加数据库验证以确保只有满足您设置的要求的数据才会被保存。您还学习了如何将模型相互关联。您首先将某些技术应用于
Subscriber模型,然后转向应用程序的其他模型。 -
第 18 课 展示了如何构建用户模型。本课介绍了管理模型数据的核心 CRUD 控制器操作。您首先构建了一个用户索引页面。
-
第 19 课 指导您构建用户模型的创建和读取路由、操作和视图。在本课中,您创建了从浏览器视图保存用户数据所需的一切。
-
第 20 课 指导您构建用户模型的更新和删除路由、操作和视图。在本课结束时,您的 CRUD 功能将完整。
-
第 21 课 通过指导您构建用户模型和 Confetti Cuisine 应用程序所需的必要模型关联来结束本单元。
准备在 第 4 单元 中收集、存储和关联数据。
第 17 课。改进您的数据模型
在本课中,您利用 Mongoose 的模式创建和模型创建工具。首先,您改进了您的简单模型,并为模型添加属性以限制可以保存到数据库中的数据。接下来,您将了解如何将数据关联到 NoSQL 数据库,如 MongoDB。最后,您为模型构建了静态和实例方法。您可以直接在 Mongoose 模型对象上运行这些方法,并为它们创建必要的控制器操作以与应用程序一起工作。
本课涵盖以下内容
-
在您的模型中添加验证
-
为您的模型创建静态和实例方法
-
在 REPL 中测试您的模型
-
在多个模型上实现数据关联
考虑这一点
您已经为访问您的食谱应用的人设置了一个订阅通讯的表单。现在您想用课程填充您的应用,用户可以在其中注册并学习烹饪。
在 Mongoose 的帮助下,您将能够设置模型,以便订阅者可以在注册为用户之前对某个特定程序表示兴趣。
到目前为止,您已经使用 Mongoose 构建了一个模型。您创建的模型是数据的一个抽象,在您的 MongoDB 数据库中以文档的形式表示。由于这种抽象,您可以使用 Mongoose 模式创建您想要的数据外观和行为蓝图。
查看您的食谱应用在列表 17.1 中的订阅者数据模型。订阅者的模式让您的应用知道它在寻找特定数据类型的三种属性。然而,它并没有指定这些属性是否可以重复,是否存在大小限制(例如,ZIP 码可以保存为 15 位数字),或者这些属性是否是保存到数据库所必需的。如果数据库中的订阅者记录大部分是空的,那么它们将没有任何帮助。接下来,您添加了一些验证方法,以确保您的属性确保数据的一致性。
列表 17.1. 在 subscriber.js 中定义订阅者模式
const mongoose = require("mongoose"); *1*
const subscriberSchema = mongoose.Schema({
name: String,
email: String,
zipCode: Number
});
module.exports = mongoose.model("Subscriber", subscriberSchema);
- 1 定义一个 subscriberSchema 以包含名称、电子邮件和 zipCode 属性。
到目前为止定义的模式是有效的,但它也允许您在没有任何有意义数据的情况下保存 Subscriber 模型的一个实例。
SchemaTypes
Mongoose 提供了一组数据类型,您可以在模式中指定这些数据类型;这些数据类型被称为 SchemaTypes。这些类型类似于 JavaScript 中的数据类型,尽管它们与 Mongoose 库有特定的关系,而常规 JavaScript 数据类型则没有。以下是一些您应该了解的 SchemaTypes:
-
String—此类型,如
Boolean和Number,非常直接。指定为String类型的模式属性意味着此属性将保存以 JavaScriptString(非 null 或 undefined)形式呈现的数据。 -
Date—日期在数据文档中很有用,因为它们可以告诉您数据何时被保存或修改,或者何时发生了涉及该模型的事情。此类型接受 JavaScript 日期对象。 -
Array—
Array类型允许属性存储项目列表。在指定Array类型时,使用数组字面量,用方括号 [] 封闭,而不是其名称。 -
Mixed—这种类型与 JavaScript 对象最为相似,因为它在模型上存储键值对。要使用Mixed类型,您需要指定mongoose.Schema.Types.Mixed。 -
ObjectId—类似于您的 MongoDB 数据库中每个文档的
ObjectId值,此类型引用该对象。当将模型相互关联时,此类型尤为重要。要使用此类型,请指定mongoose.Schema.Types.ObjectId。
为了开始改进你的模型,添加一些 Mongoose 验证器。验证器 是应用于模型属性的规则,除非满足这些规则,否则它们将阻止数据保存到你的数据库中。参见 列表 17.2 中的修改后的模式。注意,每个模型属性可以直接分配一个类型,或者作为 JavaScript 对象传递一串选项。
你想要要求名称属性,并使其类型为 String。邮箱属性应该是必需的,因为没有任何两个记录可以拥有相同的邮箱,它也是 String 类型。
注意
在这个例子中,require 表示在模型实例保存到数据库之前,数据必须存在。这与我一直用来要求模块的方式不同。
你还添加了一个设置为 true 的 lowercase 属性,以指示所有保存到数据库的邮箱都不区分大小写。最后,邮编属性不是必需的,但它有一个最小和最大数字位数。如果输入的数字小于 10000,将使用错误信息 "邮编太短"。如果数字超过 99999,或者长度为 5 位,你将收到 Mongoose 的通用错误,并且数据无法保存。
列表 17.2. 在 subscriber.js 中向订阅者模式添加验证器
const mongoose = require("mongoose");
const subscriberSchema = new mongoose.Schema({
name: { *1*
type: String,
required: true
},
email: { *2*
type: String,
required: true,
lowercase: true,
unique: true
},
zipCode: { *3*
type: Number,
min: [10000, "Zip code too short"],
max: 99999
}
});
-
1 要求名称属性。
-
2 需要邮箱属性,并添加小写属性。
-
3 使用自定义错误消息设置 zipCode 属性。
注意
在 email 属性上使用的 unique 选项不是一个验证器,而是一个 Mongoose 模式辅助器。辅助器就像方法一样,执行像验证器一样的任务,在这种情况下。
因为订阅者模式定义了 Subscriber 模型实例的行为,你还可以向模式添加实例和静态方法。在传统的面向对象编程中,实例 方法作用于 Subscriber 模型的实例(一个 Mongoose 文档),并由 subscriberSchema.methods 定义。静态 方法用于与许多 Subscriber 实例相关的通用查询,并由 subscriberSchema.statics 定义。
接下来,你将 列表 17.3 中的两个实例方法添加到你的食谱应用程序中。
getInfo 可以在 Subscriber 实例上调用,以一行返回订阅者的信息,这可能有助于快速了解数据库中的订阅者。findLocalSubscribers 以相同的方式工作,但返回一个订阅者数组。这个实例方法涉及一个 Mongoose 查询,其中 this 指的是调用该方法的 Subscriber 实例。在这里,你要求所有具有相同邮编的订阅者。exec 确保你得到一个承诺而不是需要在这里添加异步回调。
列表 17.3. 在 subscriber.js 中向模式添加实例方法
subscriberSchema.methods.getInfo = function() { *1*
return `Name: ${this.name} Email: ${this.email} Zip Code:
${this.zipCode}`;
};
subscriberSchema.methods.findLocalSubscribers = function() { *2*
return this.model("Subscriber")
.find({zipCode: this.zipCode})
.exec(); *3*
};
-
1 添加一个获取订阅者全名的方法。
-
2 添加一个查找具有相同 ZIP 编码的订阅者的方法。
-
3 访问 Subscriber 模型以使用 find 方法。
警告
在本书编写时,当使用 Mongoose 的方法时,你将无法使用 ES6 箭头函数而不受影响。Mongoose 使用绑定 this,而箭头函数会移除它。在函数内部,你可以再次使用 ES6。
| |
注意
回想一下,在设置这些方法之后,你需要使用 module.exports = mongoose.model("Subscriber", subscriberSchema) 来导出 Subscriber 模型。这一行允许你通过在另一个文件中导入此模块直接 require Subscriber 模型。
Mongoose 提供了数十种其他查询方法。你可以在 subscriber.js 中添加更多方法和验证,但 Mongoose 已经为你提供了许多查询文档的方法。表 17.1 列出了一些你可能觉得有用的查询方法。
表 17.1. Mongoose 查询
| 查询 | 描述 |
|---|---|
| find | 返回与查询参数匹配的记录数组。你可以通过运行 Subscriber.find({name: "Jon"}) 来搜索所有名为 "Jon" 的订阅者。 |
| findOne | 当你不想得到一个值数组时,返回单个记录。运行 Subscriber.findOne({name: "Jon"}) 将返回一个返回的文档。 |
| findById | 允许你通过 ObjectId 查询数据库。这个查询是你修改数据库中现有记录最有用的工具。假设你知道一个订阅者的 ObjectId,你可以运行 Subscriber.findById("598695b29ff27740c5715265")。 |
| remove | 允许你通过运行 Subscriber.remove({}) 来删除数据库中的文档。小心使用这个查询。你也可以删除特定的实例,例如 subscriber.remove({})。 |
注意
这些查询每个都返回一个 promise,因此你需要使用 then 和 catch 来处理结果数据或错误。
有关 Mongoose 查询的更多信息,请访问 mongoosejs.com/docs/-queries.html。
在你开始编写路由和用户界面与你的新模型交互之前,尝试另一种测试是否一切正常的方法:REPL。在下一节中,你将应用本课中较早的代码到一个新的 REPL 会话中。
快速检查 17.1
Q1:
当你使用 Mongoose 查询与 promises 一起时,查询应该始终返回什么?
| |
QC 17.1 答案
1:
当使用 promises 与 Mongoose 一起时,你应该期望从数据库查询中得到一个 promise。返回一个 promise 确保可以适当地处理结果或错误,而无需担心异步查询的时序问题。
17.2. 在 REPL 中测试模型
要通过使用Subscriber模型与数据库交互,你需要通过在新的终端窗口中键入node关键字并添加列表 17.4 中的行进入 REPL。通过引入 Mongoose 设置环境。(你需要处于终端中的项目目录中,以便此过程生效。)接下来,设置到 MongoDB 的连接。输入你的数据库名称——在本例中为recipe_db。
列表 17.4. 在终端 REPL 中设置订阅者模型
const mongoose = require("mongoose"), *1*
Subscriber = require("./models/subscriber"); *2*
mongoose.connect( *3*
"mongodb://localhost:27017/recipe_db",
{useNewUrlParser: true}
);
mongoose.Promise = global.Promise; *4*
-
1 在 REPL 中引入 Mongoose。
-
2 使用模型名称和本地项目文件将 Subscriber 模型分配给一个变量。
-
3 使用 recipe_db 设置数据库连接。
-
4 告诉 Mongoose 使用与 main.js 中相同的本地承诺。
现在你可以测试你的模型及其方法是否正常工作了。在 REPL 中运行列表 17.5 中的命令和查询,以查看你是否正确设置了模型。
创建一个名为"Jon"和电子邮件"jon@jonwexler.com"的新订阅者文档。尝试运行此行两次。第一次,你应该在控制台看到保存的文档被记录。第二次,你应该看到一个错误消息,表明电子邮件已经在数据库中存在,这意味着你的电子邮件验证器正在工作。
接下来,设置一个变量,你可以将以下查询结果分配给它。使用 Mongoose 的findOne查询,你正在搜索你刚刚创建的文档。然后将结果记录分配给你的subscriber变量。你可以通过记录subscriber记录或更好的是,记录这个实例上自定义的getInfo方法的结果来测试这段代码是否工作。
结果文本应读取为:Name: Jon Email: jon@jonwexler.com Zip Code: 12345。
注意
由于电子邮件必须是唯一的,当保存具有相同信息的新的记录时,可能会遇到重复键错误。在这种情况下,你可以运行Subscriber .remove({})来从数据库中清除所有订阅者数据。
列表 17.5. 在终端 REPL 中测试模型方法和 Mongoose 查询
Subscriber.create({
name: "Jon",
email: "jon@jonwexler.com",
zipCode: "12345"
})
.then(subscriber => console.log(subscriber))
.catch(error => console.log(error.message)); *1*
var subscriber; *2*
Subscriber.findOne({
name: "Jon"
}).then(result => {
subscriber = result; *3*
console.log(subscriber.getInfo()); *4*
});
-
1 创建一个新的订阅者文档。
-
2 设置一个变量来保存查询结果。
-
3 搜索你刚刚创建的文档。
-
4 记录订阅者记录。
你的终端控制台窗口应该类似于图 17.1 中的那个。
图 17.1. Mongoose REPL 命令的示例响应

尝试创建不同内容的新的记录。通过创建 ZIP 代码为 890876 或 123 的新Subscriber来检查你的zipCode属性的验证器是否工作。然后尝试直接从 REPL 中删除一个或所有订阅者记录。
接下来,我将向你展示如何将这个新模型与其他新模型关联起来。
提示
这部分代码可以保存并重复使用。将你的 REPL 代码添加到项目目录下名为 repl.js 的文件中。下次你打开 REPL 时,你可以将此文件的 内容加载到环境中。记住:Node.js 以异步方式运行,所以如果你尝试在一个命令中创建一个记录,并在之后立即查询该记录,这两个命令几乎会同时运行。为了避免任何错误,请单独运行命令,或者在每个命令的 then 块中嵌套查询。
| |
快速检查 17.2
Q1:
为什么你需要将数据库连接和 Mongoose 模型引入 REPL 来测试你的代码?
| |
QC 17.2 答案
1:
在你构建视图与数据库交互之前,REPL 是运行 CRUD 操作在模型上的一个很好的工具。但你需要引入你想要测试的模块,以便你的 REPL 环境知道要将数据保存到哪个数据库以及你正在创建哪个
Subscriber模型。
17.3. 创建模型关联
在 单元 3 中,我讨论了如何在 MongoDB 中结构化数据以及 Mongoose 如何作为数据库之上的一个层,将文档映射到 JavaScript 对象。Mongoose 包通过提供易于查询数据库并快速以面向对象的方式生成结果的方法,在开发过程中为你节省了大量时间。
如果你的背景是关系型数据库,你可能熟悉在应用程序中关联数据的方式,如图 17.2 所示。
图 17.2. 关系型数据库关联

因为你在使用基于文档的数据库,所以你没有表格——当然也没有连接表。但你确实有相当简单的方法使用 Mongoose 来设置 表 17.2 中描述的数据关系。
表 17.2. 数据关系
| 关联 | 描述 |
|---|---|
| 一对一 | 当一个模型可以与另一个模型有关联时。这种关联可能是一个具有一个配置文件的 User;该配置文件仅属于该用户。 |
| 一对多 | 当一个模型可以与另一个模型有多个关联,但另一个模型只能有一个反向关联到第一个模型时。这种关联可能是一个具有多个 Employee 实例的 Company。在这个例子中,员工只为一家公司工作,而这家公司有多个员工。 |
| 多对多 | 当一个模型的一个实例可以与另一个模型有多个关联,反之亦然时。许多剧院实例可以展示相同的电影实例,每部电影都可以追溯到许多剧院实例。通常,在关系型数据库中,连接表用于将记录映射到彼此。 |
如果两个模型以某种方式关联——一个用户有多个图片,一个订单有一个付款,多个班级有多个注册的学生——您添加一个属性,其名称与关联的模型名称相同,其中 type 是 Schema.Types.ObjectId,ref 属性设置为关联模型的名称,Schema 是 mongoose.Schema。以下代码可能代表具有多个图片的用户的模式属性:pictures: [{type: Schema.Types.ObjectId, ref: "Picture"}]。
在这个食谱应用中添加另一个名为 Course 的模型,并将其与 Subscriber 关联。这个课程模型代表应用中可供选择的食谱课程。每个课程在不同的地点提供不同的食物。将 列表 17.6 中的代码添加到您模型文件夹中名为 course.js 的新模型文件中。
课程必须有标题,且不能与其他课程的标题相同。课程有一个 description 属性,用于告知网站用户课程提供的内容。它们还有一个 items 属性,它是一个字符串数组,用于反映包含的项目和成分。zipCode 属性使人们更容易选择离他们最近的课程。
列表 17.6. 在 course.js 中创建新的模式和模型
const mongoose = require("mongoose");
const courseSchema = new mongoose.Schema({
title: { *1*
type: String,
required: true,
unique: true
},
description: {
type: String,
required: true
},
items: [],
zipCode: {
type: Number,
min: [10000, "Zip code too short"],
max: 99999
}
});
module.exports = mongoose.model("Course", courseSchema);
- 1 向课程模式添加属性。
您可以在 Course 模型中添加一个 subscribers 属性,该属性存储每个订阅者的 ObjectId 引用,该 ObjectId 来自 MongoDB。然后您将像这样引用 Mongoose 模型名称 Subscriber:subscribers: [{type: mongoose.Schema.Types.ObjectId, ref: "Subscriber"}]。技术上讲,您不需要模型相互引用;一个模型引用另一个模型就足够了。因此,在 Subscriber 模型上添加关联。
返回到 subscriber.js,并向 subscriberSchema 添加以下属性:courses: [{type: mongoose.Schema.Types.ObjectId, ref: "Course"}]
向订阅者添加一个 courses 属性,该属性存储每个相关课程的 ObjectId 引用。ID 来自 MongoDB。然后引用 Mongoose 模型名称 Course。
注意
注意属性名称是复数形式,以反映订阅者和课程之间可能存在多个关联的可能性。
如果您想限制订阅者一次只能选择一个课程,您可以移除属性周围的括号。括号表示多个引用对象的数组。如果订阅者只能注册一个课程,则 course 属性将如下所示:course: {type: mongoose.Schema.Types.ObjectId, ref: "Course"}。
在这种情况下,每个订阅者只能与单个课程相关联。你可以将其视为允许订阅者一次只能注册一个课程。从某种意义上说,这种数据库限制也可以作为一种功能,防止订阅者同时注册多个课程。然而,只要每个订阅者有一个课程关联,就不会阻止不同的订阅者注册相同的课程。
在实践中,要关联两个不同模型的两个实例,依赖于 JavaScript 赋值运算符。假设你有一个分配给变量subscriber1的订阅者和一个表示为course1的课程实例。要关联这两个实例,假设订阅者模型可以有多个课程关联,你需要运行subscriber1.courses.push(course1)。因为subscriber1.courses是一个数组,所以使用push方法添加新的课程。
或者,你可以将ObjectId推入subscriber.courses而不是使用整个课程对象。例如,如果course1有ObjectID "5c23mdsnn3k43k2kuu",你的代码将如下所示:subscriber1.courses.push("5c23mdsnn3k43k2kuu")。
要从订阅者中检索课程数据,你可以使用课程的ObjectID并在Course模型上查询,或者使用populate方法查询订阅者及其关联的课程内容。你的subscriber1 MongoDB 文档将包含嵌套的course1文档。因此,你只得到关联模型的ObjectIDs。
在下一节中,你将进一步探索populate方法。
快速检查 17.3
Q1:
你如何区分一个与另一个模型的一个实例相关联的模型与多个实例相关联的模型?
QC 17.3 答案
1:
在定义模型模式时,你可以通过将关联模型括在括号中来指定该模型的关系为一对多。括号表示关联记录的数组。如果没有括号,关联为一对一。
17.4. 从关联模型中填充数据
填充是 Mongoose 中的一个方法,允许你获取与你的模型关联的所有文档并将它们添加到查询结果中。当你populate查询结果时,你正在用关联文档的内容替换它们的ObjectIds。要完成这个任务,你需要将populate方法链接到你的模型查询。例如,Subscriber.populate(subscriber, "courses")将获取与subscriber对象关联的所有课程,并将它们的ObjectIds 替换为订阅者courses数组中的完整Course文档。
注意
你可以在mongoosejs.com/docs/populate.html找到一些有用的示例。
在设置好这两个模型后,返回 REPL,并测试模型关联。参见列表 17.7 中的命令。首先,在 REPL 环境中使用Course模型,设置两个变量在 promise 链作用域之外,以便稍后分配和使用它们。创建一个新的课程实例,其值符合Course模式要求。创建后,将保存的课程对象分配给testCourse。或者,如果你已经创建了一个课程,你可以使用Course.findOne({}).then(course => testCourse = course);从数据库中获取它。
假设你在课程早期创建了一个订阅者,这一行从数据库中拉取一个订阅者并将其分配给testSubscriber。你将testCourse课程推入testSubscriber的课程数组中。你需要确保再次保存模型实例,以便更改在数据库中生效。最后,使用populate在Subscriber模型上定位所有订阅者的课程,并在订阅者的课程数组中填充它们的数据。
列表 17.7. 使用终端中的 REPL 测试模型关联
const Course = require("./models/course"); *1*
var testCourse, testSubscriber; *2*
Course.create( {
title: "Tomato Land",
description: "Locally farmed tomatoes only",
zipCode: 12345,
items: ["cherry", "heirloom"]
}).then(course => testCourse = course); *3*
Subscriber.findOne({}).then(
subscriber => testSubscriber = subscriber *4*
);
testSubscriber.courses.push(testCourse._id); *5*
testSubscriber.save(); *6*
Subscriber.populate(testSubscriber, "courses").then(subscriber =>
console.log(subscriber) *7*
);
-
1 引入 Course 模型。
-
2 在 promise 链外部设置两个变量。
-
3 创建一个新的课程实例。
-
4 查找订阅者。
-
5 将 testCourse 课程推入 testSubscriber 的课程数组中。
-
6 再次保存模型实例。
-
7 在模型上使用 populate。
备注
对于这些示例,你不会用catch处理潜在的错误以保持代码简洁,尽管在测试时你将想要添加一些错误处理。即使是一个简单的catch(error => console.log(error.message))也可以帮助你调试如果在 promise 管道中发生错误。
运行这些命令后,你应该会在列表 17.8 中看到结果。注意,testSubscriber的courses数组现在已填充了Tomato Land课程的数据。要揭示该课程的项目,你可以在最后运行的 REPL populate命令中记录subscriber.courses[0].items。
列表 17.8. 终端中 REPL 的结果控制台日志
{ _id: 5986b16782180c46c9126287,
name: "Jon",
email: "jon@jonwexler.com",
zipCode: 12345,
__v: 1,
courses:
[{ _id: 5986b8aad7f31c479a983b42,
title: "Tomato Land",
description: "Locally farmed tomatoes only",
zipCode: 12345,
__v: 0,
subscribers: [],
items: [Array]}]} *1*
- 1 显示填充对象的查询结果。
现在你有了访问关联模型数据的能力,你的查询变得更加有用。有兴趣创建一个页面来显示所有订阅了ObjectId 5986b8aad7f31c479a983b42的Tomato Land课程的订阅者吗?你需要执行的查询是Subscriber .find({courses: mongoose.Types.ObjectId("5986b8aad7f31c479a983b42")})。
如果你想按顺序运行本课的所有示例,可以将列表 17.9 中的代码添加到 repl.js 中,通过输入node重新启动你的 REPL 环境,然后通过运行.load repl.js来加载此文件。
repl.js 中的代码清除了你的数据库中的课程和订阅者。然后,在一个有组织的承诺链中,创建了一个新的订阅者并将其保存到一个名为 testSubscriber 的外部变量中。同样,也创建了一个课程,并将其保存到 testCourse 中。最后,这两个模型实例被关联,它们的关联被填充并记录。按顺序执行的命令展示了 REPL 在测试代码方面的强大功能。
列表 17.9. REPL.js 中的命令序列
const mongoose = require("mongoose"),
Subscriber = require("./models/subscriber"),
Course = require("./models/course");
var testCourse,
testSubscriber;
mongoose.connect(
"mongodb://localhost:27017/recipe_db",
{useNewUrlParser: true}
);
mongoose.Promise = global.Promise;
Subscriber.remove({}) *1*
.then((items) => console.log(`Removed ${items.n} records!`))
.then(() => {
return Course.remove({});
})
.then((items) => console.log(`Removed ${items.n} records!`))
.then(() => { *2*
return Subscriber.create( {
name: "Jon",
email: "jon@jonwexler.com",
zipCode: "12345"
});
})
.then(subscriber => {
console.log(`Created Subscriber: ${subscriber.getInfo()}`);
})
.then(() => {
return Subscriber.findOne( {
name: "Jon"
});
})
.then(subscriber => {
testSubscriber = subscriber;
console.log(`Found one subscriber: ${ subscriber.getInfo()}`);
})
.then(() => { *3*
return Course.create({
title: "Tomato Land",
description: "Locally farmed tomatoes only",
zipCode: 12345,
items: ["cherry", "heirloom"]
});
})
.then(course => {
testCourse = course;
console.log(`Created course: ${course.title}`);
})
.then(() => { *4*
testSubscriber.courses.push(testCourse);
testSubscriber.save();
})
.then( () => { *5*
return Subscriber.populate(testSubscriber, "courses");
})
.then(subscriber => console.log(subscriber))
.then(() => { *6*
return Subscriber.find({ courses: mongoose.Types.ObjectId(
testCourse._id) });
})
.then(subscriber => console.log(subscriber));
-
1 删除所有订阅者和课程。
-
2 创建一个新的订阅者。
-
3 创建一个新的课程。
-
4 将课程与订阅者关联。
-
5 在订阅者中填充课程文档。
-
6 查询与课程 ObjectId 相同的订阅者。
提示
使用 Mongoose 和 MongoDB 进行查询可能会变得复杂。我建议探索 Mongoose 的示例查询,并练习一些集成的 MongoDB 查询语法。在开发过程中,当你需要时,你会发现哪些查询对你来说最有意义。
在第 18 课中,你扩展了这些关联。你添加了一些控制器操作来管理你与数据交互的方式。
快速检查 17.4
Q1:
你为什么不希望在每次查询时都填充每个关联的模型?
QC 17.4 答案
1:
populate方法对于收集记录的所有关联数据很有用,但如果使用不当,会增加查询记录所需的时间和空间开销。通常,如果你不需要访问关联记录的特定细节,你不需要使用populate。
摘要
在本课中,你学习了如何创建更健壮的 Mongoose 模型。你还为你的模型创建了实例方法,这些方法可以从应用程序的其它地方运行特定的模型实例。稍后,你在 REPL 中首次测试了你的模型,并创建了一个新的 Course 模型,它与现有的 Subscriber 模型建立了多对多关联。这种关系允许网站上的订阅者对特定的食谱课程表示兴趣,从而通过位置和兴趣更好地定位你的用户。在第 18 课中,你将构建一个用户模型,以及任何应用程序管理其数据所需的基本 CRUD 方法。
尝试这个
现在你已经设置了两个模型,是时候提升你的 Mongoose 方法技能了。首先,练习创建一打订阅者和六门课程。然后运行一行代码,将数据库中的每个订阅者随机关联到一个课程。记得在将课程推入订阅者的课程数组后保存你的更改。
当你完成时,使用 populate 在 REPL 中将每个订阅者记录到你的控制台,以查看你为每个订阅者关联了哪些课程。
第 18 课. 构建用户模型
在第 17 课中,您通过添加验证器和实例方法改进了您的模型。您还创建了第一个模型关联并从引用模型中填充数据。在本课中,您将应用这些技术到用户模型。这样做的同时,您也会通过各自的控制器和路由与这些模型进行交互。最后,您构建了一些表单和表格,以便更容易地可视化应用程序中的所有数据。
本课涵盖
-
使用用户模型创建模型关联
-
使用虚拟属性
-
在用户模型上实现 CRUD 结构
-
构建一个索引页面来查看您数据库中的所有用户
考虑这一点
您有两个模型与您的食谱应用程序一起工作:订阅者和课程。您仍然希望访客创建账户并开始注册食谱项目。用户模型几乎存在于每个现代应用程序中,以及一个从数据库创建、读取、更新和删除(CRUD)数据的系统。借助 Mongoose、Express.js 和 CRUD,您的用户很快就会有一种登录您应用程序的方式。
18.1. 构建用户模型
现在您已经有了防止数据库中不必要数据的模型,您需要为应用程序中最重要的模型:用户,做同样的事情。您的食谱应用程序目前有一个订阅者模型和一个课程模型,允许潜在用户对某些食谱项目表示兴趣。下一步是允许用户注册并加入这些课程。
与订阅者模型一样,用户模型需要有关每个注册人员的某些基本信息。该模型还需要与课程和订阅者模型建立关联。(例如,如果以前的订阅者决定作为用户注册,您希望连接这两个账户。)然后您想跟踪用户决定参与的课程。
要创建用户模型,将列表 18.1 中的代码添加到您模型文件夹中的一个新文件中,文件名为 user.js。用户模式包含来自订阅者模式的大量重叠属性。在这里,name属性不再是单一的String,而是包含first和last的对象。这种分离可以帮助您通过仅使用名字或姓氏来称呼用户。请注意,trim属性设置为true,以确保不将任何额外的空白保存到数据库中。Email和zipCode与订阅者模式中的相同。password属性目前以字符串形式存储用户的密码,在创建账户之前是必需的。
警告
对于本单元,您将只将密码以纯文本形式保存到数据库中。然而,这种方法并不安全或不推荐,您将在第 5 单元中了解到。
与订阅者模式类似,您将用户与多个课程关联。用户也可能与单个订阅者的账户关联。您可以命名属性为 subscribed-Account 并移除括号以表示仅关联一个对象。一组新的属性 createdAt 和 updatedAt 会在创建用户实例时以及您在模型中更改值时填充日期。timestamps 属性让 Mongoose 知道要包含 createdAt 和 updatedAt 值,这对于记录数据如何以及何时更改非常有用。将 timestamps 属性添加到订阅者和课程模型中,也是如此。
注意
注意到 Mongoose Schema 对象使用了对象解构。{Schema} 将 mongoose 中的 Schema 对象赋值给同名常量。稍后,您将应用此新格式到其他模型中。
列表 18.1. 在 user.js 中创建用户模型
const mongoose = require("mongoose"),
{Schema} = mongoose,
userSchema = new Schema({ *1*
name: { *2*
first: {
type: String,
trim: true
},
last: {
type: String,
trim: true
}
},
email: {
type: String,
required: true,
lowercase: true,
unique: true
},
zipCode: {
type: Number,
min: [1000, "Zip code too short"],
max: 99999
},
password: {
type: String,
required: true
}, *3*
courses: [{type: Schema.Types.ObjectId, ref: "Course"}], *4*
subscribedAccount: {type: Schema.Types.ObjectId, ref:
"Subscriber"} *5*
}, {
timestamps: true *6*
});
-
1 创建用户模式。
-
2 添加姓名和姓氏属性。
-
3 添加一个密码属性。
-
4 添加一个课程属性以连接用户和课程。
-
5 添加一个 subscribedAccount 属性以连接用户和订阅者。
-
6 添加一个 timestamps 属性以记录 createdAt 和 updatedAt 日期。
由于姓名和姓氏有时可能在一行中很有用,您可以使用 Mongoose 虚拟属性来为每个实例存储这些数据。虚拟属性(也称为计算属性)类似于常规模式属性,但不会保存到数据库中。要创建一个虚拟属性,请在您的模式上使用 virtual 方法,并传递属性和您想要使用的新虚拟属性名称。用户全名的虚拟属性类似于列表 18.2 中的代码。这个虚拟属性不会保存到数据库中,但它将像用户模型上的任何其他属性一样表现,例如 user.zipCode。您可以使用 user.fullName 获取此值。下面是创建用户模型的代码。
列表 18.2. 在 user.js 中向用户模型添加虚拟属性
userSchema.virtual("fullName")
.get(function() {
return `${this.name.first} ${this.name.last}`;
}); *1*
module.exports = mongoose.model("User", userSchema);
- 1 添加一个虚拟属性以获取用户的全名。
注意
在本书编写时,您无法在这里使用箭头函数,因为 Mongoose 方法使用词法 this,而 ES6 箭头函数不再依赖于它。
立即在 REPL 中测试此模型。请记住,为了使用您的新模型,需要重新引入 Mongoose 以及此环境所需的所有内容。在新的 REPL 会话中,您需要再次引入 Mongoose,指定 Mongoose 使用原生 promises,并通过输入 mongoose.connect("mongodb://localhost:27017/recipe_db", {useNewUrlParser: true}) 连接到您的数据库。然后使用 const User = require ("./models/user") 引入新的用户模型。
在 REPL 中创建一个新的用户实例,并将返回的用户或错误记录下来,以查看模型是否设置正确。列表 18.3 显示了一个创建示例用户的有效行。在此示例中,用户被创建并保存到数据库中,具有所有必需的属性。请注意 last 字段中的额外空格,这应该在通过 Mongoose 保存到数据库之前通过修剪。
提示
您可以将这些示例中的 REPL 命令添加到您的 REPL.js 文件中以供将来使用。
列表 18.3. 在终端的 REPL 中创建新用户
var testUser;
User.create({
name: {
first: "Jon",
last: "Wexler"
},
email: "jon@jonwexler.com",
password: "pass123"
})
.then(user => testUser = user)
.catch(error => console.log(error.message)); *1*
- 1 创建新用户。
注意
如果您收到关于唯一电子邮件地址的错误,这可能意味着您正在尝试创建一个与数据库中已有的信息相同(由于您在用户模式中设置的规则,这是不允许的)的用户。为了绕过此限制,请使用不同的电子邮件地址创建用户,或者使用 find() 方法而不是 create,如下所示:User.findOne({email: "jon@jonwexler.com"}).then(u => testUser = u) .catch(e => console.log(e.message));。
user 变量现在应包含下一列表中显示的文档对象。请注意,此用户的 courses 属性是一个空数组。稍后,当您将此用户与课程关联时,该属性将填充 ObjectIds。
列表 18.4. 在终端中显示已保存的用户对象的结果
{ _id: 598a3d85e1225d0bbe8d88ae,
email: "jon@jonwexler.com",
password: "pass123",
__v: 0,
courses: [],
name: { first: "Jon", last: "Wexler" } } *1*
- 1 查询响应的显示
现在,您可以使用此用户的信息将系统中的任何具有相同电子邮件的订阅者链接起来。要链接订阅者,请参阅 列表 18.5 中的代码。您正在设置一个 targetSubscriber 变量,其作用域在查询之外,并将查询结果分配给订阅者模型。这样,您可以在查询完成后使用 targetSubscriber 变量。在此查询中,您正在使用之前 create 命令中的用户电子邮件来搜索订阅者。
列表 18.5. 在终端的 REPL 中将订阅者连接到用户
var targetSubscriber;
Subscriber.findOne({
email: testUser.email
})
.then(subscriber => targetSubscriber = subscriber); *1*
- 1 将
targetSubscriber变量设置为使用用户电子邮件地址找到的订阅者。
执行这些命令后,您的 targetSubscriber 变量应包含与用户共享电子邮件地址的订阅者对象的价值。您可以使用 console.log(target Subscriber); 在您的 REPL 环境中查看该内容。
使用承诺,您可以将这些两个操作压缩成一个,如 列表 18.6 中所示。通过嵌套调用关联订阅者的查找,您得到一个可以整体移动到控制器动作中的承诺链。首先,创建新用户。您将返回使用您用于搜索具有相同电子邮件的订阅者的电子邮件的新用户。第二个查询返回任何存在的订阅者。当您找到具有相同电子邮件的订阅者时,您可以通过用户模型上的 subscribedAccount 属性将其与用户链接。最后,保存更改。
列表 18.6. 在终端的 REPL 中将订阅者连接到用户
var testUser;
User.create({
name: {
first: "Jon",
last: "Wexler "
},
email: "jon@jonwexler.com",
password: "pass123"
})
.then(user => {
testUser = user;
return Subscriber.findOne({
email: user.email
}); *1*
})
.then(subscriber => {
testUser.subscribedAccount = subscriber; *2*
testUser.save().then(user => console.log("user updated"));
})
.catch(error => console.log(error.message));
-
1 查找具有用户电子邮件的订阅者。
-
2 连接订阅者和用户。
现在,您可以在 REPL 中创建用户并将其连接到另一个模型,下一步是将这种交互移动到控制器和视图中。
注意
您已移动到 REPL 以测试数据库查询,因此可以从 main.js 中删除不再需要的 subscriber 模块。
| |
快速检查 18.1
Q1:
虚拟属性与正常模型属性有何不同?
| |
QC 18.1 答案
1:
虚拟属性不会保存到数据库中。这些属性与正常模式属性不同,仅在应用程序运行期间存在;它们不能从数据库中提取或直接通过 MongoDB 找到。
18.2. 将 CRUD 方法添加到您的模型中
在本节中,我将讨论您需要与用户、订阅者和群体模型采取的下一步行动。这三个模型都有在 REPL 中工作的模式和关联,但您可能希望在使用浏览器时使用它们。更具体地说,您希望作为站点的管理员管理每个模型的数据,并允许用户创建自己的用户账户。首先,我将讨论数据库操作中的四个主要功能:创建、读取、更新和删除(CRUD)。图 18.1 展示了这些功能。
图 18.1. 每个 CRUD 操作的视图

在网络开发中,CRUD 应用程序为任何更大或更进化的应用程序奠定了基础,因为从根本上说,以某种方式,您始终需要在每个模型上执行 表 18.1 中列出的操作。
表 18.1. CRUD 操作
| 操作 | 描述 |
|---|---|
| 创建 | 创建功能分为两部分:new 和 create。new 代表查看用于创建模型新实例的表单的路由和操作。例如,要创建新用户,您可能访问 http://localhost:3000/users/ new 来查看位于 new.ejs 中的用户创建表单。创建路由和操作处理来自该表单的任何 POST 请求。 |
| 读取 | 读取功能只有一个路由、操作和视图。在本书中,它们的名称是 show,以反映您正在显示该模型的信息,很可能是作为个人资料页面。尽管您仍在从数据库中读取,但 show 操作和 show.ejs 视图是用于此操作的更传统的名称。 |
| 更新 | 更新功能分为两部分:edit 和 update。edit,就像 new 一样,处理对编辑路由和 edit.ejs 视图的 GET 请求,在那里您将找到一个用于更改模型属性值的表单。当您修改值并通过使用 PUT 请求提交表单时,更新路由和操作处理该请求。这些功能依赖于数据库中预先存在的模型实例。 |
| Delete | 删除函数可能是最简单的函数。虽然你可以创建一个视图来询问用户他是否确定要删除记录,但这个函数通常是通过发送一个包含用户 ID 的 DELETE 请求到路由的按钮来实现的。然后删除路由和操作会从你的数据库中删除记录。 |
对于 new.ejs 和 edit.ejs 表单,你需要将表单提交路由到create和update路由,分别。例如,当你提交表单以创建新用户时,表单数据应该被发送到user/create路由。以下示例将指导你创建用户模型的 CRUD 操作和视图,但你应该将相同的技巧应用到课程和订阅者模型。
CRUD HTTP 方法
在本书的前面,你学习了GET和POSTHTTP 方法,这些方法占到了互联网上大多数请求的很大一部分。许多其他 HTTP 方法在特定情况下使用,并且通过更新和删除功能,你可以引入另外两种,如表 18.2 所示。
表 18.2. PUT 和 DELETE HTTP 方法
| HTTP 方法 | 描述 |
|---|---|
| PUT | 用于指示你向应用程序服务器提交数据,目的是修改或更新现有记录的方法。PUT 通常用一组新的属性替换现有记录,即使某些属性没有变化。虽然 PUT 是更新记录的首选方法,但有些人更喜欢 PATCH 方法,它旨在仅修改已更改的属性。要处理 Express.js 中的更新路由,你可以使用 app.put。 |
| DELETE | 用于指示你从数据库中删除记录的方法。要处理 Express.js 中的删除路由,你可以使用 app.delete。 |
虽然你可以使用GET和POST来更新和删除记录,但在使用 HTTP 方法时最好遵循这些最佳实践。一致性可以使你的应用程序在出现问题时运行得更好,并且具有更好的透明度。我在第 19 课中进一步讨论了这些方法。
在你开始之前,查看你的控制器,并为它们准备翻新。到目前为止,你通过将它们添加到模块的exports对象中来创建新的控制器操作。你创建的操作越多,你重复的exports对象就越多,这在控制器模块中并不特别美观。你可以通过将所有操作一起使用对象字面量中的module.exports导出,来清理你的控制器操作。将你的主控制器修改为列表 18.7 中的代码。
在这个例子中,你的操作现在是逗号分隔的,这使得操作名称更容易识别。在你将此更改应用到控制器后,你不需要更改任何其他代码,应用程序就可以像以前一样运行。
列表 18.7. 修改 homeController.js 中的操作
var courses = [
{
title: "Event Driven Cakes",
cost: 50
},
{
title: "Asynchronous Artichoke",
cost: 25
},
{
title: "Object Oriented Orange Juice",
cost:10
}];
module.exports = { *1*
showCourses: (req, res) => {
res.render("courses", {
offeredCourses: courses
});
}
};
- 1 导出包含所有控制器操作的字面量对象。
将此结构应用于你的其他控制器(errorController.js 和 subscribers-Controller.js)以及未来的所有控制器。当你构建 CRUD 操作并在你路由中组织中间件时,这些修改将变得很重要。
注意
还应在你的 controllers 文件夹中创建 coursesController.js 和 usersController.js,以便在接下来的几节课中为课程和用户模型创建相同的操作。
在下一节中,你将构建用户模型所需的形式。首先,创建一个经常被忽视的应用视图:index.ejs。还要为每个应用模型创建此索引页面。index 路由、操作和视图的目的是获取所有记录并在单个页面上显示它们。你将在下一节中构建索引页面。
快速检查 18.2
Q1:
哪些 CRUD 函数不一定需要一个视图?
QC 18.2 答案
1:
虽然每个 CRUD 函数都可以有自己的视图,但某些函数可以存在于模态中或通过基本链接请求访问。
delete函数不一定需要自己的视图,因为你正在发送一个删除记录的命令。
18.3. 构建索引页面
首先,通过在视图文件夹中创建一个新的用户文件夹并添加 列表 18.8 中的代码来创建 index.ejs 视图。
在此视图中,你正在遍历一个 users 变量,并为每个用户的属性创建一个新的表格行。相同类型的表格也可以用于订阅者和课程。你需要在控制器级别使用一个用户数组来填充 users 变量。
注意
你应该将相同的方法应用于应用程序中的其他模型。例如,订阅者模型视图现在将放在视图文件夹中的 subscribers 文件夹内。
列表 18.8. 在 index.js 中列出所有用户
<h2>Users Table</h2>
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Zip Code</th>
</tr>
</thead>
<tbody>
<% users.forEach(user => { %> *1*
<tr>
<td><%= user.fullName%></td>
<td><%= user.email %></td>
<td><%= user.zipCode%></td>
</tr>
<% }); %>
</tbody>
</table>
- 1 在视图中遍历用户数组。
要测试此代码,你需要一个路由和控制器操作来加载此视图。在控制器文件夹中创建一个 usersController.js,并包含 列表 18.9 中的代码。
你需要在 usersController.js 中引入用户模型,以便在此控制器中访问它。首先,你从数据库接收一个包含你的用户的响应。然后你在 index.ejs 视图中渲染你的用户列表。如果发生错误,将消息记录到控制台并将响应重定向到主页。
列表 18.9. 在 usersController.js 中创建索引操作
const User = require("../models/user"); *1*
module.exports = {
index: (req, res) => {
User.find({})
.then(users => { *2*
res.render("users/index", {
users: users
})
})
.catch(error => { *3*
console.log(`Error fetching users: ${error.message}`)
res.redirect("/");
});
}
};
-
1 引入用户模型。
-
2 使用用户数组渲染索引页面。
-
3 记录错误消息并将重定向到主页。
注意
在订阅者控制器中,index 动作替换了你的 getAllSubscribers 动作。记住要修改 main.js 中动作对应的路由,将其指向 index,并将 subscribers.ejs 文件更改为 index.ejs。这个视图现在应该位于 views 中的 subscribers 文件夹内。
最后一步是将 usersController 引入到 main.js 中,并通过在 清单 18.10 中添加代码到 main.js 来添加 index 路由。
首先,将 usersController 引入到 main.js 中。在你的 subscribers-Controller 定义下方添加此行。创建你的第一个用户路由,将传入的请求发送到 /users,并使用 usersController 中的 index 动作。
列表 18.10. 在 main.js 中添加 usersController 和路由
const usersController = require("./controllers/usersController"); *1*
app.get("/users", usersController.index); *2*
-
1 需要使用 usersController。
-
2 创建索引路由。
在终端启动你的应用程序,并访问 http://localhost:3000/users。你的屏幕应该类似于 图 18.2。
图 18.2. 浏览器中用户索引页面的示例

这个列表是你查看数据库的窗口,同时不会向公众透露任何敏感数据。在你继续之前,请对路由和动作进行最后一次修改。
快速检查 18.3
Q1:
索引视图的目的是什么?
QC 18.3 答案
1:
The index view displays all documents for a particular model. This view can be used internally by a company to see the names and email addresses of everyone who subscribed. It can also be visible to all users so that everyone can see who signed up.
18.4. 清理你的动作
目前,你的索引动作被设计为仅提供来自数据库的数据的 EJS 模板视图。然而,正如你在 单元 6 中学到的,你并不总是想以视图的形式提供数据。为了更好地使用你的动作,将它们分解为运行查询的动作和通过视图提供结果的动作。
将用户控制器修改为 清单 18.11 中显示的代码。在这段修改后的代码中,你有一个 index 动作,它会在用户模型上调用 find 查询。如果你成功生成结果,将这些结果添加到 res.locals 对象中——这是响应中的一个独特对象,允许你定义一个变量,你将在视图中访问它。通过将结果分配给 res.locals.users,你不需要更改你的视图;变量名 users 在视图中本地匹配。然后调用下一个中间件函数。如果在查询中发生错误,记录错误,并将其传递给下一个中间件函数,该函数将处理错误。在这种情况下,该函数是错误控制器中的 internalServerError 动作。indexView 动作渲染索引视图。
列表 18.11. 在 usersController.js 中重新访问索引动作
const User = require("../models/user");
module.exports = {
index: (req, res, next) => {
User.find() *1*
.then(users => {
res.locals.users = users; *2*
next();
})
.catch(error => {
console.log(`Error fetching users: ${error.message}`);
next(error); *3*
});
},
indexView: (req, res) => {
res.render("users/index"); *4*
}
};
-
1 仅在索引动作中运行查询。
-
2 在响应中存储用户数据并调用下一个中间件函数。
-
3 捕获错误,并将其传递给下一个中间件。
-
4 在单独的动作中渲染视图。
为了让你的应用程序在索引页面上加载用户数据,就像之前一样,将 indexView 动作作为中间件函数添加到你的路由中,该函数位于 index 动作之后。为此,将 main.js 中的 /users 路由更改为以下代码:app.get("/users", usersController.index, usersController.indexView)。当 usersController.index 完成查询并将数据添加到响应对象中时,会调用 usersController.indexView 来渲染视图。通过这个更改,你可以在另一个路由的索引动作之后决定调用不同的中间件函数,这正是你将在第 6 单元中做的。
现在你除了 REPL 和 MongoDB shell 之外,还有一种方法可以查看数据库中的用户、课程和订阅者。在第 19 课中,你将更多的功能引入到视图中。
快速检查 18.4
Q1:
如果你主要在浏览器中工作,为什么你需要将错误消息记录到控制台?
| |
QC 18.4 答案
1:
虽然你正在将更多的数据和功能移入视图,但你的终端仍然是应用程序的核心。你的控制台窗口是你应该期望看到应用程序错误、发出的请求以及你创建的自定义错误消息的地方,这样你就可以知道在哪里查找以修复问题。
摘要
在本课中,你学习了如何创建用户模型以及如何开始使用 CRUD 函数。你还了解了两种新的 HTTP 方法,并看到了如何创建一个索引页面来显示所有用户。有了这个索引页面,你开始从浏览器与你的应用程序进行交互。最后,你修改了控制器和路由,以便更好地使用中间件函数和动作之间的交互性。在第 19 课中,你将 create 和 read 函数应用于你的三个模型。
尝试这个
在设置好索引页面后,试着思考你的应用程序管理员可能会如何使用这个页面。你创建了用于显示用户数据的表格,但你可能还想在这个表格中添加其他列。创建新的用户实例方法来获取每个用户名字符的数量,然后在这个表格中创建一个新列来显示每个用户的这个数字。
尝试为用户模型创建一个新的虚拟属性。
第 19 课。创建和读取你的模型
在第 18 课中,你构建了你的用户模型并构建了一个索引页面来在同一页面上显示用户。在本课中,你通过关注 CRUD 的创建和读取功能来为你的应用程序添加更多功能。你首先创建了一个 EJS 表单,该表单将用户的属性作为输入处理。然后你创建了路由和动作来处理该表单数据。最后,你构建了一个show页面作为用户的个人资料页面。
本课涵盖
-
构建模型创建表单
-
从浏览器保存用户到数据库
-
在视图中显示关联模型
考虑这一点
在为你的食谱应用程序创建新课程的新方法中,你发现将单个文档添加到 REPL 数据库中变得繁琐。你决定创建专用路由来创建新的模型实例、编辑它们并显示它们的数据。这些路由是 CRUD 方法的基础,允许你的数据交互通过应用程序视图进行。
19.1. 构建新用户表单
要在数据库中创建一个新的用户实例,你需要一种方法来检索该用户的数据。到目前为止,你一直直接在 REPL 中输入这些数据。因为你正在将所有数据交互移动到浏览器,你需要一个表单,让新用户可以通过它创建他们的账户。在 CRUD 术语中,该表单位于名为 new.ejs 的视图中。
首先,通过将列表 19.1 中的代码添加到视图中/users 文件夹的 new.js 中,构建该表单。生成的表单看起来像图 19.1。在提交时,该表单会向/users/create路由发送一个POST请求。你需要确保在尝试提交任何内容之前创建该路由;否则,你的应用程序将会崩溃。
图 19.1. 浏览器中用户创建表单的示例

该表单使用了 Bootstrap 进行装饰,但主要收获是每个用户属性都表示为一个表单输入,并且该属性的名称设置为该输入的name属性——在姓氏的情况下,name="first"。你将在以后使用这些名称属性来识别控制器中的值。请注意,password、email和zipCode字段有一些独特的属性。这些 HTML 验证是一些你可以防止无效或不安全信息从网页进入你的应用程序的方法。
列表 19.1. 在 new.ejs 中构建用户创建表单
<div class ="data-form">
<form action="/users/create" method="POST"> *1*
<h2>Create a new user:</h2>
<label for="inputFirstName">First Name</label> *2*
<input type ="text" name="first" id="inputFirstName"
placeholder ="First" autofocus>
<label for="inputLastName">First Name</label>
<input type ="text" name="last" id="inputLastName"
placeholder ="Last">
<label for="inputPassword">Password</label> *3*
<input type="password" name="password" id="inputPassword"
placeholder="Password" required>
<label for="inputEmail">Email address</label>
<input type="email" name="email" id="inputEmail"
placeholder="Email address" required>
<label for="inputZipCode">Zip Code</label>
<input type="text" name="zipCode" id="inputZipCode" pattern="\d*"
placeholder="Zip Code" required>
<button type="submit">Sign in</button>
</form>
</div>
-
1 构建一个创建用户账户的表单。
-
2 将用户属性作为输入添加到表单中。
-
3 将 HTML 属性应用于保护密码和电子邮件字段。
现在你有了新的视图,你需要一个路由和控制器动作来服务该视图。你还在下一节中添加了create路由和动作来处理来自该视图的数据。
快速检查 19.1
问题 1:
哪个表单输入属性必须有一个值,以便控制器动作能够识别表单数据?
QC 19.1 答案
1:
在表单中必须填写
name属性以创建新记录。映射到name属性的任何值都是控制器用于与模型模式进行比较的值。
19.2. 从视图创建新用户
新用户表单收集与用户模式相关的数据。接下来,您需要为该表单创建动作。为了使表单能够渲染和处理数据,将 列表 19.2 中的用户动作代码添加到 usersController.js 中。
new 动作将接收到的请求用于创建新用户并在 new.ejs 中渲染表单。create 动作接收来自 new.ejs 表单的接收到的已发布数据,并通过响应对象将生成的创建的用户传递给下一个中间件函数。下一个中间件函数 redirectView 根据响应对象中接收到的重定向路径确定要显示的视图。如果用户创建成功,则重定向到索引页面。
在 create 动作中,将收集到的传入数据分配给 userParams 变量。然后调用 User.create 并传递这些参数,在成功时将用户重定向到 /users 索引页面,在失败时重定向到错误页面。
注意
对于订阅者控制器,new 和 create 动作实际上替换了你在本书早期创建的 getSubscriptionPage 和 saveSubscriber 动作。在交换这些新动作后,您需要更改 main.js 路由中的动作名称以匹配。
列表 19.2. 向 usersController.js 添加创建动作
new: (req, res) => { *1*
res.render("users/new");
},
create: (req, res, next) => { *2*
let userParams = {
name: {
first: req.body.first,
last: req.body.last
},
email: req.body.email,
password: req.body.password,
zipCode: req.body.zipCode
}; *3*
User.create(userParams)
.then(user => {
res.locals.redirect = "/users";
res.locals.user = user;
next();
})
.catch(error => {
console.log(`Error saving user: ${error.message}`);
next(error);
});
},
redirectView: (req, res, next) => { *4*
letredirectPath =res.locals.redirect;
if (redirectPath)res.redirect(redirectPath);
else next();
}
-
1 添加新动作以渲染表单。
-
2 添加创建动作以将用户保存到数据库。
-
3 使用表单参数创建用户。
-
4 在单独的重定向视图动作中渲染视图。
要查看此代码的工作情况,将 new 和 create 路由添加到 main.js 中,如 列表 19.3 所示。第一个路由接收指向 /users/new 的 GET 请求并在 usersController 中渲染 new.ejs。第二个路由接受指向 /users/create 的 POST 请求,并将该接收到的请求体传递给 create 动作,然后通过 usersController.js 中的 redirectView 动作进行视图重定向。这些路由可以放在您的用户索引路由下面。
注意
将 new 和 create 动作添加到订阅者控制器意味着您可以移除 getAllSubscribers 和 saveSubscriber 动作,以支持新的 CRUD 动作。同样,您在主页控制器中需要的唯一动作是提供主页:index.ejs。
现在你开始在 main.js 中积累你使用的路由数量,你可以通过在 main.js 文件中添加const router = express .Router()来使用 Express.js 的 Router 模块。这一行创建了一个 Router 对象,它提供了自己的中间件和路由,与 Express.js 的app对象一起使用。很快,你将使用这个router对象来组织你的路由。现在,修改你的路由以使用router而不是app。然后在 main.js 中的路由顶部添加app.use("/", router)。这段代码告诉你的 Express.js 应用程序使用路由对象作为中间件和路由的系统。
列表 19.3. 在 main.js 中添加新的和创建路由
router.get("/users/new", usersController.new); *1*
router.post("/users/create", usersController.create,
usersController.redirectView); *2*
-
1 处理查看创建表单的请求。
-
2 处理从创建表单提交数据的请求,并显示一个视图。
重新启动你的应用程序,填写 http://localhost:3000/users/new 上的表单,并提交表单。如果你成功了,你应该能在首页上看到你新创建的用户。
当你的用户成功保存到数据库中时,添加一个收尾工作。你已经设计了具有对Subscriber模型关联的User模式。理想情况下,每当创建新用户时,你都想检查具有相同电子邮件地址的现有订阅者并将两者关联起来。你可以使用 Mongoose 的pre("save")钩子来完成此操作。
Mongoose 提供了一些称为钩子的方法,允许你在数据库更改(如save)运行之前执行操作。你可以通过在定义模式之后和注册模型之前在 user.js 中添加列表 19.4 中的代码来添加此钩子。为此钩子要正常工作,你需要将Subscriber模型引入 user.js 中。使用const Subscriber = require("./subscriber")。
此钩子在用户创建或保存之前立即运行。它接受next中间件函数作为参数,以便在完成此步骤后可以调用下一个中间件函数。由于这里不能使用箭头函数,因此需要在承诺链之外定义user变量。
注意
就本书的编写而言,箭头函数不与 Mongoose 钩子一起工作。
只有当用户还没有关联的订阅者时,你才执行此功能,这样可以节省不必要的数据库操作。使用用户的电子邮件地址搜索一个订阅者账户。如果找到一个具有匹配电子邮件地址的订阅者,将该订阅者分配给用户的subscribedAccount属性。除非发生错误,否则继续在下一个中间件函数中保存用户。你还需要通过在 user.js 顶部添加const Subscriber = require("./subscriber")来在 user.js 中添加对订阅者模型的引用。
列表 19.4. 在 user.js 中添加预(‘save’)钩子
userSchema.pre("save", function (next) { *1*
let user = this; *2*
if (user.subscribedAccount === undefined) { *3*
Subscriber.findOne({
email: user.email
}) *4*
.then(subscriber => {
user.subscribedAccount = subscriber; *5*
next();
})
.catch(error => {
console.log(`Error in connecting subscriber:
${error.message}`);
next(error); *6*
});
} else {
next(); *7*
}
});
-
1 设置预(‘save’)钩子。
-
2 在回调中使用函数关键字。
-
3 添加对现有订阅者连接的快速条件检查。
-
4 查询单个订阅者。
-
5 将用户与订阅者账户关联。
-
6 将任何错误传递给下一个中间件函数。
-
7 如果用户已经有关联,则调用下一个函数。
通过在 REPL 中创建一个新的订阅者(或者如果你已经创建了,通过订阅者的new页面)并然后在浏览器中创建一个新的用户,使用相同的电子邮件地址来尝试这段新代码。回到 REPL,你可以检查该用户的subscribedAccount是否有值反映了关联的订阅者的ObjectId。这个值将在下一节构建用户的show页面时派上用场。
快速检查 19.2
Q1:
为什么 Mongoose 的
pre("save")钩子将next作为参数?
| |
QC 19.2 答案
1:
pre("save")钩子是 Mongoose 中间件,与其他中间件一样,当函数完成时,它将移动到下一个中间件函数。这里的next表示中间件链中要调用的下一个函数。
19.3. 使用 show 读取用户数据
现在你已经可以创建用户,你想要一种方法在专用页面上显示用户信息(例如用户的个人资料页面)。你需要在数据库上执行的唯一操作是读取数据,通过特定的 ID 查找用户并在浏览器中显示其内容。
首先,创建一个新的视图,名为 show.ejs。调用视图和动作show,使其明确你的意图是展示用户数据。在 show.ejs 中,创建一个类似于 index.ejs 中的表格,除了你不需要循环。你想要展示所有用户的属性。将列表 19.5 中的代码添加到 views/users 文件夹中的 show.ejs 中。
此表单使用user变量的属性来填充每个表格数据框。最后,检查该用户是否有subscribedAccount。如果没有,则不显示任何内容。如果有关联的订阅者,则显示一些文本并链接到订阅者的展示页面。
列表 19.5. show.ejs 中的用户展示表格
<h1>User Data for <%= user.fullName %></h1>
<table class="table"> *1*
<tr>
<th>Name</th>
<td><%= user.fullName %></td>
</tr>
<tr>
<th>Email</th>
<td><%= user.email %></td>
</tr>
<tr>
<th>Zip Code</th>
<td><%= user.zipCode %></td>
</tr>
<tr>
<th>Password</th>
<td><%= user.password %></td>
</tr>
</table>
<% if (user.subscribedAccount) {%> *2*
<h4 class="center"> This user has a
<a href="<%=`/subscribers/${user.subscribedAccount}` %>">
subscribed account</a>.
</h4>
<% } %>
-
1 添加一个表格来显示用户数据。
-
2 检查是否有链接的订阅者账户。
注意
为了使此链接页面正常工作,你需要同时为订阅者创建 CRUD 函数和视图。锚标签的 href 路径是/subscribers/${user.subscribedAccount},它代表订阅者的show路由。
为了更容易地访问用户的show页面,在 index.ejs 中,将用户的名字包裹在一个链接标签中,链接到users/加上用户的 ID。那里的表格数据应该看起来像下一个列表。你可以在链接标签的href以及表格数据内容中嵌入 JavaScript。
列表 19.6. index.ejs 中更新的名称数据
<td>
<a href="<%= `/users/${user._id}` %>"> *1*
<%= user.fullName %>
</a>
</td>
- 1 在 HTML 中嵌入用户的名字和 ID。
如果你刷新用户索引页面,你会注意到名字变成了链接(图 19.2)。如果你现在点击其中一个链接,你会得到一个错误,因为没有路由来处理这个请求。
图 19.2. 浏览器中的用户索引页面,带有链接的名字

接下来,在usersController.js中添加show动作,如图 19.7 所示。列表 19.7。首先,从 URL 参数中收集用户的 ID;你可以从req.params.id获取这个信息。这段代码只有在使用:id定义路由时才有效(参见列表 19.7)。
使用findById查询,并传递用户的 ID。因为每个 ID 都是唯一的,你应该期望返回单个用户。如果找到用户,将其添加为响应对象上的局部变量,并调用next中间件。很快,你将设置下一个函数为showView,在那里你渲染展示页面并将用户对象传递以显示该用户的信息。如果发生错误,记录消息,并将错误传递给下一个中间件函数。
列表 19.7. usersController.js中特定用户的展示动作
show: (req, res, next) => {
let userId = req.params.id; *1*
User.findById(userId) *2*
.then(user => {
res.locals.user = user; *3*
next();
})
.catch(error => {
console.log(`Error fetching user by ID: ${error.message}`);
next(error); *4*
});
},
showView: (req, res) => {
res.render("users/show"); *5*
}
-
1 从请求参数中收集用户 ID。
-
2 通过 ID 查找用户。
-
3 将用户通过响应对象传递给下一个中间件函数。
-
4 记录错误并传递给下一个函数。
-
5 渲染展示视图。
最后,在main.js中添加用户的show路由,代码如下:router.get ("/users/:id", usersController.show, usersController.showView)。这个show路由使用/users路径以及一个:id参数。当你点击表格中的用户名时,从索引页面传入的用户 ID 将被填充到这个参数中。
注意
你可以在main.js中将与同一模型相关的路由分组,以获得更好的组织。
重新启动你的应用程序,点击一个用户的名字。你应该会被导向该用户的展示页面,如图 19.3 所示。
图 19.3. 浏览器中的用户展示页面

你现在可以在你的应用程序中创建数据,并在几个网页上查看它。在第 20 课中,你将探索更新和删除这些数据的方法。
快速检查 19.3
Q1:
正误判断:代表用户 ID 的 URL 参数必须命名为
:id。
QC 19.3 答案
1:
错误。
:id参数对于获取你想要显示的用户 ID 是必需的,但你可以选择任何名称来引用这个参数。如果你决定使用:userId,确保在整个代码中一致地使用这个名字。
总结
在本课中,你学习了如何为你的模型创建index、new和show页面。你还创建了路由和动作来处理用户数据并创建新账户。最后,你自定义了用户show页面以显示用户数据和链接的订阅账户的指示器。你已经完成了四个 CRUD 构建块中的两个。在第 20 课中,你将update和delete函数应用于你的三个模型。
尝试这个
你的用户账户创建表单已经准备好创建新账户,但你已经在用户模型上实现了某些验证,这可能会允许在没有保存数据的情况下提交表单。尝试测试一些你的验证以确保它们正确工作,如下所示:
-
当你输入带有大写字母的电子邮件地址时会发生什么?
-
当缺少必填字段时会发生什么?
虽然你再次被重定向到new页面是好事,但你需要在屏幕上显示的错误信息中进行改进。
第 20 课. 更新和删除你的模型
在第 19 课中,你为你的模型构建了create和read功能。现在,是时候完成 CRUD 方法了。在本课中,你将添加update和delete功能的路由、动作和视图。首先,你创建一个表单来编辑现有用户的属性。然后,你在一个update动作中管理修改后的数据。在本课结束时,你将实现一种快速从用户索引页删除用户的方法。首先,确保你的 MongoDB 服务器正在运行,通过在终端窗口中输入mongod来检查。
本课涵盖
-
构建模型编辑表单
-
更新数据库中的用户记录
-
删除用户记录
考虑以下
你的食谱应用程序已经准备好接受新用户,但你收到了关于创建了多个不必要的账户以及一些用户不小心输入了错误电子邮件地址的投诉。通过update和delete CRUD 函数,你将能够清除不需要的记录并修改现有的记录以持久化在你的应用程序中。
20.1. 构建编辑用户表单
要更新用户信息,你需要在特定的update动作中使用一些 Mongoose 方法。不过,首先你需要创建一个编辑用户信息的表单。这个表单看起来像 create.js 中的表单,但表单的动作指向users/:id/update而不是users/create,因为你希望你的路由表明表单的内容是更新现有用户,而不是创建新用户。
你还希望将每个表单输入中的值替换为用户的现有信息。例如,用户名输入可能看起来像下面的列表。这里的value属性使用现有用户的姓名。这段代码仅在将user对象传递到该页面时才有效。
列表 20.1. edit.ejs 中带有用户数据的输入示例
<input type="text" name="first" id="inputFirstName" value="<%=
user.name.first %>" placeholder="First" autofocus> *1*
- 1 在编辑表单中应用现有用户的属性值。
为了确保现有用户的数据填充此表单,请向用户索引页面中的表格添加另一列。您的索引页面应类似于图 20.1。
图 20.1. 浏览器中带有编辑链接的用户索引页面

此列包含一个用于编辑每个特定用户的链接。您可以添加一个锚点标签,如下一列表中所示。编辑链接标签的href值会向/users加上用户的Id加上/edit路由发起一个GET请求。
列表 20.2. 修改后的表格,在 index.ejs 中添加编辑用户的链接
<td>
<a href="<%=`/users/${user._id}/edit` %>">
Edit
</a>
</td> *1*
- 1 在编辑标签链接中嵌入用户的 ID。
接下来,您想要修改edit.ejs中的表单,以提交带有修改后用户数据的PUT请求,但您的 HTML 表单元素仅支持GET和POST请求。在您的 CRUD 函数中使用预期的 HTTP 方法非常重要,这样就不会在未来对请求是添加新数据还是修改现有数据产生混淆。
您需要解决的问题之一是 Express.js 将如何接收此请求。Express.js 将您的 HTML 表单提交作为POST请求接收,因此您需要某种方式来解释具有您意图的 HTTP 方法的请求。存在几种解决这个问题的方法。本课中您使用的解决方案是method-override包。
method-override是一个中间件,根据特定的查询参数和 HTTP 方法解释请求。使用_method=PUT查询参数,您可以解释POST请求为PUT请求。通过在项目终端窗口中运行npm i method-override -S来安装此包,并将列表 20.3 中的行添加到 main.js 中。
首先,将method-override模块引入到您的项目中。告诉应用程序使用methodOverride作为中间件。具体来说,您正在告诉此模块在 URL 中查找_method查询参数,并使用该参数的值指定的方法来解释请求。例如,您想要处理为PUT请求的POST请求,将会有?_method=PUT附加到表单的动作路径上。
列表 20.3. 在 main.js 中添加method-override到您的应用程序
const methodOverride = require("method-override"); *1*
router.use(methodOverride("_method", {
methods: ["POST", "GET"]
})); *2*
-
1 引入
method-override模块。 -
2 配置应用程序路由器以使用 methodOverride 作为中间件。
您想要修改edit.ejs中的表单,使其以POST方法提交到/users/:id/update?_method=PUT路由。打开表单标签将看起来像列表 20.4。
动作是动态的,取决于用户的 ID,并指向/users/:id/update路由。您的method-override模块解释查询参数,并帮助 Express.js 将请求方法与适当的路由匹配。
列表 20.4. 将编辑表单指向 edit.ejs 中的update路由
<form method="POST" action="<%=`/users/${user._id}/update
?_metho d=PUT`%>"> *1*
- 1 添加一个更新用户数据的表单。
您可以在下一个列表中参考完整的用户编辑表单,它应该看起来像浏览器中的 图 20.2 中的现有用户。
图 20.2. 浏览器中的用户编辑页面

列表 20.5. 完整的用户编辑表单在 edit.ejs 中
<div class="data-form" > *1*
<form method="POST" action="<%=`/users/${user._id}/update
?_method=PUT`%>">
<h2>Edit user:</h2>
<label for="inputFirstName">First Name</label>
<input type="text" name="first" id="inputFirstName" value="<%=
user.name.first %>" placeholder="First" autofocus>
<label for="inputLastName">Last Name</label>
<input type="text" name="last" id="inputLastName" value="<%=
user.name.last %>" placeholder="Last">
<label for="inputPassword">Password</label>
<input type="password" name="password" id="inputPassword"
value="<%= user.password %>" placeholder="Password" required>
<label for="inputEmail">Email address</label>
<input type="email" name="email" id="inputEmail" value="<%=
user.email %>" placeholder="Email address" required>
<label for="inputZipCode">Zip Code</label>
<input type="text" name="zipCode" id="inputZipCode"
pattern="\d*" value="<%= user.zipCode %>" placeholder="Zip
Code" required>
<button type="submit">Update</button>
</form>
</div>
- 1 显示用户编辑表单。
在下一节中,您将添加使此表单工作以及处理表单数据的路由和操作。
快速检查 20.1
Q1:
为什么您使用
PUT方法编辑表单,而使用POST方法创建新表单?
QC 20.1 答案
1:
编辑表单正在更新现有记录的数据。按照惯例,提交数据到您的服务器的请求应使用 HTTP
PUT方法。要创建新记录,请使用POST。
20.2. 从视图中更新用户
现在用户编辑表单已经在其自己的视图中,添加控制器操作和路由以补充表单。edit 路由和操作将用户发送到 view/edit.ejs 视图。update 路由和操作用于在数据库中内部更改用户。然后 redirectView 操作作为 update 后的操作,将您重定向到您指定的视图。将 列表 20.6 中的操作添加到 usersController.js。
edit 操作,就像 show 操作一样,通过用户的 ID 从数据库中获取用户信息并加载一个用于编辑用户的视图。注意,如果通过 ID 参数找不到用户,你需要将错误传递给错误处理中间件函数。当编辑表单提交时,会调用 update 操作;与 create 操作类似,它会识别用户的 ID 和 userParams,并将这些值传递给 Mongoose 的 findByIdAndUpdate 方法。此方法接受一个 ID 后跟一些参数,你可以使用 $set 命令来替换该文档的参数。如果用户更新成功,则在下一个中间件函数中重定向到用户的 show 路径;否则,让错误处理中间件捕获任何错误。
列表 20.6. 向 usersController.js 添加 edit 和 update 操作
edit: (req, res, next) => { *1*
let userId = req.params.id;
User.findById(userId) *2*
.then(user => {
res.render("users/edit", {
user: user
}); *3*
})
.catch(error => {
console.log(`Error fetching user by ID: ${error.message}`);
next(error);
});
},
update: (req, res, next) => { *4*
let userId = req.params.id,
userParams = {
name: {
first: req.body.first,
last: req.body.last
},
email: req.body.email,
password: req.body.password,
zipCode: req.body.zipCode
}; *5*
User.findByIdAndUpdate(userId, {
$set: userParams
}) *6*
.then(user => {
res.locals.redirect = `/users/${userId}`;
res.locals.user = user;
next(); *7*
})
.catch(error => {
console.log(`Error updating user by ID: ${error.message}`);
next(error);
});
}
-
1 添加编辑操作。
-
2 使用
findById通过 ID 在数据库中定位用户。 -
3 渲染数据库中特定用户的用户编辑页面。
-
4 添加更新操作。
-
5 从请求中收集用户参数。
-
6 使用
findByIdAndUpdate通过 ID 定位用户并更新文档记录,只需一个命令。 -
7 将用户添加到响应作为局部变量,并调用下一个中间件函数。
最后,您需要将 列表 20.7 中的路由添加到 main.js 中。编辑用户的路径是一个简单的带有 id 参数的路由。从编辑表单更新用户的 POST 路由遵循相同的路径结构,但使用 update 操作。您还将重用 redirectView 操作来显示您在响应的 locals 对象中指定的视图。
列表 20.7. 在main.js中添加edit和update路由
router.get("/users/:id/edit", usersController.edit); *1*
router.put("/users/:id/update", usersController.update,
usersController.redirectView); *2*
-
1 添加处理查看的路由。
-
2 处理编辑表单中的数据,并显示用户展示页面。
重新启动您的应用程序,访问用户索引页面,并点击用户的编辑链接。尝试更新一些值,并保存。
在能够create、read和update用户数据之后,您还缺少一种删除不再需要的记录的方法。下一节将介绍delete函数。
快速检查 20.2
Q1:
真或假:
findByIdAndUpdate是 Mongoose 方法。
| |
QC 20.2 答案
1:
真的。
findByIdAndUpdate是 Mongoose 方法,用于使您的查询在服务器代码中更加简洁和可读。除非安装了 Mongoose 包,否则该方法不能使用。
20.3. 使用删除操作删除用户
要删除用户,您只需要一个路由和对用户索引页面的修改。在index.ejs中,添加一个标题为删除的列。就像您对编辑列所做的那样,将每个用户链接到users/:id/delete路由(图 20.3)。
图 20.3. 在浏览器中带有删除链接的用户索引页面

注意
您可以使用 HTML 的onclick="return confirm('Are you sure you want to delete this record?')"添加一些基本的安全措施。
记住,您需要使用_method=DELETE查询参数,这样您的应用程序才能将GET请求解释为DELETE请求。在用户索引页面中添加删除列的代码,如列表 20.8 所示。通过附加的查询参数发送DELETE请求,此链接将用户的 ID 传递给处理DELETE请求的 Express.js 路由。确认脚本显示一个模态框以确认您要提交链接并删除记录。
列表 20.8. 用户索引页面中的删除链接
<td>
<a href="<%= `users/${user._id}/delete?_method=DELETE` %>"
onclick="return confirm('Are you sure you want to delete
this record?')">Delete</a> *1*
</td>
- 1 在索引页面上添加删除操作的链接。
接下来,将控制器操作添加到delete用户记录的 ID。将列表 20.9 中的代码添加到usersController.js。
您正在使用 Mongoose 的findByIdAndRemove方法来定位您点击的记录并将其从数据库中删除。如果您成功定位并删除了文档,请在控制台记录被删除的用户,并在下一个中间件函数中重定向到用户索引页面。否则,像往常一样记录错误,并让错误处理器捕获您传递给它的错误。
列表 20.9. 将delete操作添加到usersController.js
delete: (req, res, next) => {
let userId = req.params.id;
User.findByIdAndRemove(userId) *1*
.then(() => {
res.locals.redirect = "/users";
next();
})
.catch(error => {
console.log(`Error deleting user by ID: ${error.message}`);
next();
});
}
- 1 使用
findByIdAndRemove方法删除用户
唯一缺少的部分是以下路由,您将其添加到main.js中:router.delete ("/users/:id/delete", usersController.delete, usersController.redirectView)。此路由处理与路径users/、用户 ID 和/delete匹配的DELETE请求。然后,当记录被删除时,该路由将重定向到指定的重定向路径。
尝试通过再次运行应用程序并访问用户索引页面来运行这段新代码。点击其中一个用户的删除链接,然后观察它从你的页面上消失。
最后,为了使用户从其个人资料页面更容易地使用您的新 CRUD 操作,请将以下列表中的链接添加到 show.ejs 的底部。
列表 20.10. 将用户 CRUD 操作的链接添加到 show.ejs
<div>
<a href="/users">View all users</a>
</div>
<div>
<a href="<%=`/users/${user._id}/edit`%>">
Edit User Details
</a>
</div>
<div>
<a href="<%= `/users/${user._id}/delete?_method=DELETE` %>"
onclick="return confirm('Are you sure you want to delete
this record?')">Delete</a>
</div> *1*
- 1 在个人资料页面添加编辑和删除用户账户的链接。
用户的展示页面应该类似于图 20.4。
图 20.4. 带有编辑和删除链接的用户展示页面

快速检查 20.3
Q1:
为什么在链接路径的末尾需要
?_method=DELETE?
| |
QC 20.3 答案
1:
method-override会查找_method查询参数及其映射的方法。因为您正在使用此包来过滤传入的GET和POST请求作为替代方法,您需要添加此参数及其值。
摘要
在本课中,您学习了如何编辑数据库中的记录和删除记录。您还看到了如何使用method-override包来帮助处理 HTML 限制以提交某些请求方法。随着 CRUD 功能的完成,现在是时候构建一个具有关联模型和用户界面以将有意义的数据保存到数据库中的应用程序了。在下一项综合练习(第 21 课 lesson 21)中,尝试将您在本单元中学到的所有内容应用于构建 Confetti Cuisine 应用程序。
尝试这个
现在您已经为用户账户的每个 CRUD 函数都设置了工作状态,请确保为组和订阅者也设置了相同的设置。在您继续进行综合练习(第 21 课 lesson 21)之前,请确保所有三个模型都有工作的索引、新建、编辑和展示页面。然后,就像在第 19 课 lesson 19 中一样,尝试将关联模型纳入每个记录的展示页面。
第 21 课:综合:将 CRUD 模型添加到 Confetti Cuisine
Confetti Cuisine 对我连接他们的应用程序到数据库并设置处理订阅者信息的进展感到满意。他们已经给我发了一份他们想要开始在网站上宣传的烹饪课程列表。本质上,他们希望订阅者选择他们最感兴趣的参加的课程。然后,如果订阅者后来创建了一个用户账户,该业务希望这两个账户能够相互链接。
为了完成这个任务,我需要改进Subscriber模型并构建User和Course模型。我需要记住这些模型之间的关系,并在必要时从关联模型中填充数据。最后,我需要生成所有必要的功能,以允许创建、读取、更新和删除(CRUD)模型记录。在这个项目中,我将创建一个用户登录表单,允许用户创建账户,然后编辑、更新和删除账户。我将重复大部分过程,为 Confetti Cuisine 的通讯录中的课程和订阅者。
当我完成时,我将有一个应用程序,可以向 Confetti Cuisine 的团队展示,让他们能够在正式推出项目之前注册新用户并监控他们的课程。
为了这个目的,我需要以下内容:
-
用户、订阅者和课程模型的模式
-
应用程序中所有模型的 CRUD 操作
-
显示模型之间链接的视图
21.1. 设置环境
从我上次停止的地方继续,我已经将 MongoDB 数据库连接到我的应用程序,使用 Mongoose 包驱动我的Subscriber模型和原始文档之间的通信。在接下来的工作中,我需要相同的核心和外部包。此外,我需要安装method-override包来帮助处理 HTML 链接和表单目前不支持的所有 HTTP 方法。我可以通过在项目目录的新终端窗口中运行以下代码来安装此包:npm i method-override -S。然后,我将method-override模块引入到 main.js 中,通过在文件顶部添加const methodOverride = require("method-override")。我配置应用程序使用method-override来识别GET和POST请求作为其他方法,通过添加以下行:app.use(methodOverride("_method", {methods: ["POST", "GET"]}))。
接下来,我需要考虑当我完成时,这个项目的目录结构将是什么样子。因为我将向三个模型添加 CRUD 功能,所以我将创建三个新的控制器,在视图内创建三个新的文件夹,以及三个模型模块。结构类似于图 21.1。
图 21.1. 项目文件结构

注意,我只创建了四个视图:index、new、show和edit。尽管delete可以有自己的视图作为删除确认页面,但我将通过每个模型的index页面上的链接来处理删除。
接下来,我首先改进Subscriber模型,并同时构建我的User和Course模型。
21.2. 构建模型
我的Subscriber模型为 Confetti Cuisine 收集了有意义的数据,但他们希望在数据层上增加安全性。我需要在Subscriber模式上添加一些验证器,以确保在数据进入数据库之前,订阅者数据符合客户的要求。我的新模式看起来像列表 21.1。
我首先将 Mongoose 引入这个模块,并将 Mongoose Schema 对象拉入其自己的常量。我通过使用 Schema 构造函数并传递一些订阅者属性来创建我的订阅者模式。每个订阅者都必须输入一个名称和一个在数据库中不存在的电子邮件地址。每个订阅者可以选择输入一个五位数的 ZIP 码。timestamps 属性是 Mongoose 提供的一个附加功能,用于记录该模型的 createdAt 和 updatedAt 属性。
每个订阅者可以订阅多个课程,因此这个关联允许订阅者与一系列引用课程相关联。我需要创建课程模型来实现这个功能。getInfo 是添加到订阅者模式中的一个实例方法,用于快速检索任何订阅者的 name、email 和 zipCode。使用这种新模式导出订阅者模型,使其在应用程序的其他模块中可访问。
列表 21.1. 改进的 subscriber.js 中的 Subscriber 模式
const mongoose = require("mongoose"),
{ Schema } = mongoose, *1*
subscriberSchema = new Schema({
name: { *2*
type: String,
required: true
},
email: {
type: String,
required: true,
lowercase: true,
unique: true
},
zipCode: {
type: Number,
min: [10000, "Zip code too short"],
max: 99999
},
courses: [{type: Schema.Types.ObjectId, ref: "Course"}] *3*
}, {
timestamps: true
});
subscriberSchema.methods.getInfo = function () { *4*
return `Name: ${this.name} Email: ${this.email}
Zip Code: ${this.zipCode}`;
};
module.exports = mongoose.model("Subscriber",
subscriberSchema); *5*
-
1 需要 mongoose。
-
2 添加模式属性。
-
3 关联多个课程。
-
4 添加
getInfo实例方法。 -
5 导出订阅者模型。
这个模型看起来不错,所以我将应用一些相同的技巧到 course.js 和 user.js 中的 Course 和 User 模型。每个课程都必须有一个标题和描述,没有初始限制。课程有 maxStudents 和 cost 属性,默认为 0,不能保存为负数;否则,会出现我的自定义错误消息。
Course 模式包含以下列表中的属性。
列表 21.2. course.js 中 Course 模式的属性
const mongoose = require("mongoose"),
{ Schema } = require("mongoose"),
courseSchema = new Schema(
{
title: { *1*
type: String,
required: true,
unique: true
},
description: {
type: String,
required: true
},
maxStudents: { *2*
type: Number,
default: 0,
min: [0, "Course cannot have a negative number of students"]
},
cost: {
type: Number,
default: 0,
min: [0, "Course cannot have a negative cost"]
}
},
{
timestamps: true
}
);
module.exports = mongoose.model("Course", courseSchema);
-
1 需要标题和描述。
-
2 默认
maxStudents和cost为 0,并禁止负数。
User 模型包含最多的字段和验证,因为我希望防止新用户输入无效数据。这个模型需要链接到 Course 和 Subscriber 模型。User 模式在 列表 21.3 中显示。
每个用户的姓名保存为 first 和 last 名称属性。email 和 zipCode 属性的行为与 Subscriber 中相同。每个用户都必须有一个密码。对于订阅者来说,用户与多个课程相关联。因为订阅者最终可能会创建用户账户,所以我需要在这里链接这两个账户。我还添加了 timestamps 属性,以跟踪数据库中用户记录的变化。
列表 21.3. 在 user.js 中创建 User 模型
const mongoose = require("mongoose"),
{ Schema } = require("mongoose"),
Subscriber = require("./subscriber"),
userSchema = new Schema(
{
name: { *1*
first: {
type: String,
trim: true
},
last: {
type: String,
trim: true
}
},
email: {
type: String,
required: true,
unique: true
},
zipCode: {
type: Number,
min: [10000, "Zip code too short"],
max: 99999
},
password: {
type: String,
required: true
}, *2*
courses: [
{
type: Schema.Types.ObjectId,
ref: "Course"
} *3*
],
subscribedAccount: {
type: Schema.Types.ObjectId,
ref: "Subscriber"
} *4*
},
{
timestamps: true *5*
}
);
module.exports = mongoose.model("User", userSchema);
-
1 添加首名和姓氏属性。
-
2 需要密码。
-
3 将用户与多个课程关联。
-
4 将用户与订阅者关联。
-
5 添加时间戳属性。
我对用户模型所做的两个额外添加是一个返回用户全名的虚拟属性和一个 Mongoose pre("save")钩子,用于将具有相同电子邮件地址的订阅者和用户链接起来。这些添加可以直接添加到 user.js 中的模式定义下方,并在列表 21.4 中显示。
这个第一个虚拟属性允许我通过调用用户的fullName来获取用户的first和last名字作为一个值。pre("save")钩子在用户被保存到数据库之前运行。我传递next参数,以便当这个函数完成时,我可以调用中间件链中的下一个步骤。为了链接到当前用户,我将用户保存到一个新的变量中,这个变量超出了我下一个查询的作用域。我只在用户还没有链接的subscribedAccount时运行查询。我在 Subscriber 模型中搜索包含该用户电子邮件地址的文档。如果存在订阅者,我在保存记录并调用中间件链中的下一个函数之前,将返回的订阅者设置为用户的subscribedAccount属性。
列表 21.4. 在 user.js 中添加虚拟属性和 pre(“save”)钩子
userSchema.virtual("fullName").get(function() { *1*
return `${this.name.first} ${this.name.last}`;
});
userSchema.pre("save", function (next) { *2*
let user = this;
if (user.subscribedAccount === undefined) { *3*
Subscriber.findOne({
email: user.email
}) *4*
.then(subscriber => {
user.subscribedAccount = subscriber;
next(); *5*
})
.catch(error => {
console.log(`Error in connecting subscriber:
${error.message}`);
next(error);
});
} else {
next();
}
});
-
1 添加 fullName 虚拟属性。
-
2 添加一个 pre(‘save’)钩子来链接订阅者。
-
3 检查是否有链接的 subscribedAccount。
-
4 在 Subscriber 模型中搜索包含该用户电子邮件的文档。
-
5 调用下一个中间件函数。
在设置好这个模型后,我需要构建 CRUD 功能。我开始创建视图:index.ejs、new.ejs、show.ejs 和 edit.ejs。
21.3. 创建视图
对于 Subscriber 模型,index.ejs 通过一个 HTML 表格列出数据库中的所有订阅者,如列表 21.5 所示。这个视图是一个包含五列的表格。前三列显示订阅者数据,最后两列链接到单个订阅者的编辑和删除页面。对于我的订阅者索引页面,我添加了一些新的样式(图 21.2)。
图 21.2. 浏览器中的订阅者索引页面

注意
由于这些视图在不同模型中具有相同的名称,我需要按照模型名称将它们组织在单独的文件夹中。例如,views/users 文件夹有自己的 index.ejs。
为了为每个订阅者生成一行新数据,我遍历subscribers变量,这是一个 Subscriber 对象的数组,并访问每个订阅者的属性。订阅者的名字被包裹在一个锚标签中,通过使用用户的不_id链接到该订阅者的show页面。删除链接需要在路径后附加?_method=DELETE查询参数,以便我的method-override中间件可以将此请求作为DELETE请求处理。我必须记住在 EJS 中关闭我的代码块。
列表 21.5. 在 index.ejs 中列出订阅者
<h2 class="center">Subscribers Table</h2>
<table class="table"> *1*
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Edit</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
<% subscribers.forEach(subscriber => { %>
<tr> *2*
<td>
<a href="<%= `/subscribers/${subscriber._id}` %>">
<%= subscriber.name %> *3*
</a>
</td>
<td><%= subscriber.email %></td>
<td>
<a href="<%=`subscribers/${subscriber._id}/edit` %>">
Edit
</a>
</td>
<td>
<a href="<%=`subscribers/${subscriber._id}/delete?_method=DELETE` %>"
onclick="return confirm('Are you sure you want to delete this
record?')">Delete</a> *4*
</td>
</tr>
<% }); %>
</tbody>
</table>
-
1 在索引页面添加一个表格。
-
2 为每个订阅者生成一行新数据。
-
3 将订阅者的名字包裹在锚标签中。
-
4 添加一个删除链接。
我将遵循完全相同的结构来构建课程和用户索引页面,确保替换变量名称和属性以匹配相应的模型。
在有了这个索引页面之后,我需要一种创建新记录的方法。我从列表 21.6 中的订阅者 new.ejs 表单开始。此表单将通过 POST 请求提交数据到 /subscribers/- create 路径,从该路径我将创建新的订阅者记录在订阅者控制器中。注意,表单通过 POST 请求提交数据。每个输入都反映了模型的属性。每个表单输入的 name 属性很重要,因为我将使用它在控制器中收集保存新记录所需的数据。表单的末尾有一个提交按钮。
列表 21.6. 在 new.ejs 中创建新的订阅者表单
<div class="data-form">
<form action="/subscribers/create" method="POST"> *1*
<h2>Create a new subscriber:</h2>
<label for="inputName">Name</label>
<input type="text" name="name" id="inputName" placeholder="Name"
autofocus>
<label for="inputEmail">Email address</label>
<input type="email" name="email" id="inputEmail"
placeholder="Email address" required>
<label for="inputZipCode">Zip Code</label>
<input type="text" name="zipCode" id="inputZipCode"
pattern="[0-9]{5}" placeholder="Zip Code" required>
<button type="submit">Create</button>
</form>
</div>
- 1 添加一个表单以创建新的订阅者。
我为用户和课程重新创建了此表单,确保替换表单的动作和输入以反映我正在创建的模型。我的订阅者编辑表单看起来像图 21.3 中的那个。
图 21.3. 浏览器中的订阅者编辑页面

当我在表单上工作时,我创建了 edit.ejs 视图,其表单类似于 new.ejs。需要注意的是以下更改:
-
编辑表单—此表单需要访问我正在编辑的记录。在这种情况下,
subscriber来自订阅者控制器。 -
表单动作—此动作指向
/subscribers/${subscriber._id}/ update?_method=PUT而不是create动作。 -
属性—每个输入的
value属性设置为subscriber变量的属性,如<input type="text" name="name" value="<%= subscriber.name %>">。
这些相同的点也适用于用户和课程的 edit.ejs 表单。下一个列表显示了完整的订阅者编辑页面。
列表 21.7. edit.ejs 中的订阅者编辑页面
<form action="<%=`/subscribers/${subscriber._id}/update
?_method=PUT` %>" method="POST"> *1*
<h2>Create a new subscriber:</h2>
<label for="inputName">Name</label>
<input type="text" name="name" id="inputName" value="<%=
subscriber.name %>" placeholder="Name" autofocus>
<label for="inputEmail">Email address</label>
<input type="email" name="email" id="inputEmail" value="<%=
subscriber.email %>" placeholder="Email address" required>
<label for="inputZipCode">Zip Code</label>
<input type="text" name="zipCode" id="inputZipCode"
pattern="[0-9]{5}" value="<%= subscriber.zipCode %>"
placeholder="Zip Code" required>
<button type="submit">Save</button>
</form>
- 1 显示订阅者的编辑表单。
最后,我为每个模型构建了 show 页面。对于订阅者,这个页面类似于个人资料页面,详细说明了每个订阅者在索引页面上的信息。这个页面相当直接:我展示了足够的数据来总结单个订阅者记录。订阅者的 show 页面有一个表格,使用以下列表中显示的 EJS 模板元素创建。这个页面使用 subscriber 变量的属性来显示 name、email 和 zipCode。
列表 21.8. show.ejs 中的订阅者展示页面
<h1>Subscriber Data for <%= subscriber.name %></h1> *1*
<table>
<tr>
<th>Name</th>
<td><%= subscriber.name %></td>
</tr>
<tr>
<th>Email</th>
<td><%= subscriber.email %></td>
</tr>
<tr>
<th>Zip Code</th>
<td><%= subscriber.zipCode %></td>
</tr>
</table>
- 1 显示订阅者属性。
注意
对于这些视图中的某些,我将添加链接以导航到该模型的其他相关页面。
我还希望在 show 页面添加一段代码,以显示记录是否与数据库中的其他记录相关联。对于用户来说,显示相关记录的代码可以在页面底部添加一个额外的标签来显示用户是否有 subscribedAccount 或相关 courses。对于订阅者,我将添加一行来显示已订阅课程的数目,如列表 21.9 所示。
这一行给 Confetti Cuisine 提供了人们订阅课程数目的洞察。我可以通过在这个订阅者上使用 Mongoose 的 populate 方法来进一步扩展这一行,以显示相关课程详情。
列表 21.9. 在 show.ejs 中显示已订阅课程的数目
<p>This subscriber has <%= subscriber.courses.length %> associated
course(s)</p> *1*
- 1 显示相关课程的数目。
最后一步是将模型、视图与控制器动作和路由结合起来。
21.4. 结构化路由
Confetti Cuisine 的表单和链接已经准备好显示,但仍然没有通过浏览器访问它们的方法。在 main.js 中,我将添加必要的 CRUD 路由并引入所需的控制器,以确保一切正常工作。
首先,我将从列表 21.10 中添加订阅者的路由到 main.js。为了确保 subscribersController 在文件顶部与我的其他控制器一起被引入,我添加了 const subscribersController = require("./controllers/subscribersController")。我还将 Express.js 的 Router 引入到我的项目中,通过在 main.js 中添加 const router = express.Router() 来帮助区分应用程序的路由和其他配置。有了这个 router 对象,我将 app 对象处理的每个路由和中间件都改为使用 router 对象。然后,我在 main.js 中添加 app.use("/", router) 来告诉应用程序使用这个 router 对象。
对 /subscribers 路径的 GET 请求将我带到 subscribers-Controller 的 index 动作。然后,通过另一个名为 indexView 的动作渲染 index.ejs 页面。相同的策略也适用于其他 GET 路由。第一个 POST 路由是用于 create 的。这个路由处理来自表单的请求以保存新的订阅者数据。我需要在 create 动作中创建保存新订阅者的逻辑。然后,我使用一个名为 redirectView 的动作,在成功创建订阅者记录后,将重定向到我的某个视图。
show 路由是我需要从路径中获取订阅者 ID 的第一个情况。在这种情况下,:id 代表订阅者的 ObjectId,这允许我在 show 动作中在数据库中搜索该特定订阅者。然后,我使用 showView 在视图中显示订阅者的数据。update 路由与 create 路由类似,但我指定路由只接受 PUT 请求,这表示正在请求特定更新现有记录。同样,我在此之后使用 redirectView 动作来显示视图。最后一个路由 delete 只接受 DELETE 请求。请求将从 index.ejs 中的链接发出,并使用 redirectView 链接到索引页面。
列表 21.10. 在 main.js 中添加订阅者 CRUD 路由
router.get("/subscribers", subscribersController.index,
subscribersController.indexView); *1*
router.get("/subscribers/new", subscribersController.new);
router.post("/subscribers/create", subscribersController.create,
subscribersController.redirectView); *2*
router.get("/subscribers/:id", subscribersController.show,
subscribersController.showView); *3*
router.get("/subscribers/:id/edit", subscribersController.edit);
router.put("/subscribers/:id/update", subscribersController.update,
subscribersController.redirectView); *4*
router.delete("/subscribers/:id/delete",
subscribersController.delete,
subscribersController.redirectView); *5*
-
1 添加 GET 路由以显示视图。
-
2 添加第一个用于创建的 POST 路由。
-
3 添加一个基于 ObjectId 显示订阅者的路由。
-
4 添加一个更新订阅者的路由。
-
5 添加一个删除订阅者的路由。
需要对用户和课程创建相同的七个路由。我还会更新导航链接:联系链接将指向订阅者的新视图,课程列表链接将指向课程的索引视图。
注意
到目前为止,我可以移除一些过时的路由,例如指向 getAllSubscribers、getSubscriptionPage 和 saveSubscriber 的路由,这些路由位于订阅者控制器中,以及指向主页控制器中 showCourses 的路由。我还可以将主页路由移动到主页控制器的 index 动作中。最后,我想确保更新我的导航链接,使其指向 /subscribers/new 而不是 /contact。
我剩下的工作就是创建相应的控制器。
21.5. 创建控制器
在 main.js 中创建的路由需要 subscribersController、coursesController 和 usersController。我开始在控制器文件夹中创建这些文件。
注意
我还清理了我的错误控制器,以使用 http-status-codes 和 error.ejs 视图,就像之前的应用程序示例中那样。
接下来,对于订阅者控制器,我添加了 列表 21.11 中显示的操作来处理对我的现有路由发出的请求。在将 Subscriber 模型引入此文件后,我创建了 index 动作以找到数据库中的所有订阅者文档,并通过 indexView 动作将它们传递到 index.ejs 中的 subscribers 变量。new 和 edit 动作也渲染一个视图来订阅和编辑订阅者数据。
create 动作通过我的自定义 getSubscriberParams 函数收集请求体参数,该函数列在代码列表的第二个常量中,以创建一个新的订阅者记录。如果操作成功,我将通过 locals 变量对象将用户对象传递到我的响应中。然后,我将在 redirectView 动作中指定重定向到索引页面。
show 操作从 URL 中通过 req.params.id 提取订阅者的 ID。此值用于在数据库中搜索一个匹配的记录,然后将该记录通过响应对象传递给下一个中间件函数。在 showView 中,show 页面显示此 subscriber 变量的内容。update 操作的行为类似于 create,并使用 findByIdAndUpdate Mongoose 方法为现有的订阅者文档设置新值。在这里,我还通过响应对象传递更新后的用户对象,并在 redirectView 操作中指定要重定向到的视图。
delete 操作使用请求参数中的订阅者 ID 来从数据库中 findByIdAndRemove 一个匹配的文档。getSubscriberParams 函数旨在减少我的代码中的重复。因为 create 和 update 操作使用表单参数,它们可以调用此函数。redirectView 操作也旨在通过允许多个操作(包括 delete 操作)在主函数完成后指定要渲染的视图来减少代码重复。
列表 21.11. 在 subscribersController.js 中添加订阅者控制器操作
const Subscriber = require("../models/subscriber"),
getSubscriberParams = (body) => { *1*
return {
name: body.name,
email: body.email,
zipCode: parseInt(body.zipCode)
};
};
module.exports = {
index: (req, res, next) => { *2*
Subscriber.find()
.then(subscribers => {
res.locals.subscribers = subscribers;
next();
})
.catch(error => {
console.log(`Error fetching subscribers: ${error.message}`);
next(error);
});
},
indexView: (req, res) => {
res.render("subscribers/index");
},
new: (req, res) => {
res.render("subscribers/new");
},
create: (req, res, next) => { *3*
let subscriberParams = getSubscriberParams(req.body);
Subscriber.create(subscriberParams)
.then(subscriber => {
res.locals.redirect = "/subscribers";
res.locals.subscriber = subscriber;
next();
})
.catch(error => {
console.log(`Error saving subscriber:${error.message}`);
next(error);
});
},
redirectView: (req, res, next) => {
let redirectPath = res.locals.redirect;
if (redirectPath) res.redirect(redirectPath);
else next();
},
show: (req, res, next) => { *4*
var subscriberId = req.params.id;
Subscriber.findById(subscriberId)
.then(subscriber => {
res.locals.subscriber = subscriber;
next();
})
.catch(error => {
console.log(`Error fetching subscriber by ID:
${error.message}`)
next(error);
});
},
showView: (req, res) => {
res.render("subscribers/show");
},
edit: (req, res, next) => {
var subscriberId = req.params.id;
Subscriber.findById(subscriberId)
.then(subscriber => {
res.render("subscribers/edit", {
subscriber: subscriber
});
})
.catch(error => {
console.log(`Error fetching subscriber by ID:
${error.message}`);
next(error);
});
},
update: (req, res, next) => { *5*
let subscriberId = req.params.id,
subscriberParams = getSubscriberParams(req.body);
Subscriber.findByIdAndUpdate(subscriberId, {
$set: subscriberParams
})
.then(subscriber => {
res.locals.redirect = `/subscribers/${subscriberId}`;
res.locals.subscriber = subscriber;
next();
})
.catch(error => {
console.log(`Error updating subscriber by ID:
${error.message}`);
next(error);
});
},
delete: (req, res, next) => { *6*
let subscriberId = req.params.id;
Subscriber.findByIdAndRemove(subscriberId)
.then(() => {
res.locals.redirect = "/subscribers";
next();
})
.catch(error => {
console.log(`Error deleting subscriber by ID:
${error.message}`);
next();
});
}
};
-
1 创建一个自定义函数以从请求中提取订阅者数据。
-
2 创建索引操作以查找所有订阅者文档。
-
3 创建创建操作以创建一个新的订阅者。
-
4 创建显示操作以显示订阅者数据。
-
5 创建更新操作以设置现有订阅者文档的新值。
-
6 创建删除操作以移除一个订阅者文档。
在为每个模型设置这些控制器操作后,应用程序准备启动并管理记录。我加载每个模型的视图,然后创建新的订阅者、课程和用户。在 单元 5 中,我通过添加用户身份验证和登录表单来改进 Confetti Cuisine 的网站。
摘要
在这个综合练习中,我通过向三个新模型添加 CRUD 功能来改进 Confetti Cuisine 的应用程序。这些模型允许订阅者注册 Confetti Cuisine 即将推出的课程,并创建用户账户以参与烹饪课程产品。在 单元 5 中,我通过添加闪存消息、密码安全和使用 passport 模块的用户身份验证来清理这些视图。
第 5 单元:验证用户账户
在第 4 单元中,你为应用程序中的模型构建了 CRUD 函数。你还学习了 Mongoose 和一些外部包如何帮助你定义模型之间的关联,并在浏览器中显示引用模型的数据库数据。
在本单元中,你学习了使用会话和 cookie 进行闪存消息、数据加密和用户认证。你首先实现基本的会话存储来处理请求之间的称为闪存消息的小信息。然后你修改User模型以使用bcrypt包处理密码加密。在设置好第一个登录表单后,你使用bcrypt通过将用户的登录数据与数据库中加密的密码进行比较来验证用户。在最后一课中,你重新实现了用户认证的过程——在允许用户访问应用程序之前确认账户的有效性。你探讨了验证账户、加密密码以保障安全以及通过 Passport.js 提供的工具为普通用户提供在应用程序中移动的工具。到本单元结束时,你将能够注册新用户,甚至开始基于数据库中的用户数据构建逻辑。
本单元涵盖了以下主题:
-
第 22 课 讨论了会话,并展示了如何通过在客户端存储信息来保留用户的登录状态。你学习了如何应用闪存消息;这些在页面之间传递的简短消息会告诉你某些服务器操作是否成功。
-
第 23 课 指导你构建注册表单的过程。在这本书中你已经构建过表单了,但这个表单处理用户的电子邮件和密码,因此你需要采取稍微不同的方法来确保你的数据安全且一致。在
bcrypt包的帮助下,一种加密算法确保不会将明文密码保存到你的数据库中。在课程结束时,你使用express-validator应用了额外的验证中间件。 -
第 24 课 教你如何为你的用户添加应用程序认证。在本课中,通过 Passport.js 中间件和一些有用的 npm 包,为你的应用程序和
User模型添加了一层安全防护。你还修改了视图布局,以便快速访问登录表单、显示当前登录的用户并提供快速登出的方式。 -
第 25 课 通过引导你构建 Confetti Cuisine 应用程序所需的用户加密和认证来结束本单元。你应用了闪存消息、验证中间件、加密和一个健壮的认证过程。
在第 22 课中,通过向你的应用程序添加 cookie 开始烹饪。
第 22 课:添加会话和闪存消息
在本节课中,你通过在页面之间传递消息来清理 CRUD 函数之间的流程,以找出服务器操作是否成功或是否发生了某些类型的错误。目前,错误消息被记录到控制台,应用程序的用户无法知道他们可以如何不同地操作。你使用 connect-flash 包以及会话和 cookie 来将这些消息传递到你的视图中。在本节课结束时,你将拥有一个能够以视觉描述操作成功或失败的应用程序。
本节课涵盖
-
设置会话和 cookie
-
在你的控制器操作中创建闪存消息
-
在传入数据上设置验证中间件
考虑这一点
你的食谱应用程序开始通过你在第 4 单元中创建的视图表单收集数据。然而,用户开始感到沮丧,因为他们不知道你设置了哪些验证,如果他们未能满足验证器的期望,他们会被无通知地重定向到另一个页面。
通过一些有用的包,你可以将闪存消息整合到你的应用程序中,以通知你的用户应用程序中发生的特定错误。
22.1. 设置闪存消息模块
闪存消息是半永久性数据,用于向应用程序的用户显示信息。这些消息起源于你的应用程序服务器,作为会话的一部分传送到用户的浏览器。会话包含有关用户与应用程序之间最近交互的数据,例如当前登录用户、页面超时前的时间长度,或打算一次性显示的消息。
你有多种方法将闪存消息整合到你的应用程序中。在本节课中,你通过在终端中输入 npm i connect-flash -S 来安装其包,将 connect-flash 中间件模块作为依赖项添加到你的应用程序中。
注意
会话曾经是 Express.js 的依赖项,但由于并非每个人都会使用每个 Express.js 依赖项,并且很难保持依赖项与主包的更新同步,因此必须安装独立的包 cookie-parser 和 express-session。
现在你需要在终端中运行 npm i cookie-parser express-session -S 来安装另外两个包。然后,在你的 main.js 文件中引入这三个模块——connect-flash、cookie-parser 和 express-session——以及一些使用这些模块的代码(列表 22.1)。
您需要 express-session 模块在您的应用程序和客户端之间传递消息。这些消息在用户的浏览器中持久存在,但最终存储在服务器上。express-session 允许您以几种方式在用户的浏览器中存储您的消息。Cookies 是会话存储的一种形式,因此您需要 cookie-parser 包来指示您想要使用 cookies,并且您希望您的会话能够解析(或解码)从浏览器发送回服务器的 cookie 数据。
使用 connect-flash 包来创建您的 flash 消息。此包依赖于会话和 cookies 在请求之间传递 flash 消息。您告诉您的 Express.js 应用程序使用 cookie-parser 作为中间件,并使用您选择的某个秘密密码。cookie-parser 使用此代码来加密发送到浏览器的 cookies 中的数据,因此请选择一个难以猜测的密码。接下来,您让应用程序通过告诉 express-session 使用 cookie-parser 作为其存储方法,并在大约一个小时后使 cookies 过期来使用会话。
您还需要提供一个密钥来加密您的会话数据。通过将 saveUninitialized 设置为 false,指定您不希望在没有消息添加到会话的情况下向用户发送 cookie。还指定您不希望在现有会话没有变化的情况下更新服务器上的现有会话数据。最后,让应用程序使用 connect-flash 作为中间件。
注意
在此示例中,密钥以纯文本形式显示在您的应用程序服务器文件中。然而,我不建议在这里显示您的密钥,因为这会使您的应用程序容易受到安全漏洞的攻击。相反,将您的密钥存储在环境变量中,并使用 process.env 访问该变量。我在第 8 单元中进一步讨论了此主题。单元 8。
列表 22.1. 在 main.js 中要求使用 flash 消息
const expressSession = require("express-session"), *1*
cookieParser = require("cookie-parser"),
connectFlash = require("connect-flash");
router.use(cookieParser("secret_passcode")); *2*
router.use(expressSession({
secret: "secret_passcode",
cookie: {
maxAge: 4000000
},
resave: false,
saveUninitialized: false
})); *3*
router.use(connectFlash()); *4*
-
1 要求三个模块。
-
2 配置您的 Express.js 应用程序以使用 cookie-parser 作为中间件。
-
3 配置 express-session 以使用 cookie-parser。
-
4 配置您的应用程序以使用 connect-flash 作为中间件。
总的来说,这三个包提供了中间件,帮助您处理带有必要 cookie 数据的传入请求和传出响应。
Cookie 解析
在服务器和客户端之间每次请求和响应时,都会将一个 HTTP 头部与通过网络发送的数据捆绑在一起。此头部包含有关正在传输的数据的大量有用信息,例如数据的尺寸、数据的类型以及发送数据的浏览器。
请求头中的另一个重要元素是 Cookies。Cookies是从服务器发送到用户浏览器的小数据文件,包含有关用户与应用程序之间交互的信息。一个 Cookie 可能表明哪个用户最后访问了应用程序,用户是否成功登录,甚至用户提出了什么请求,例如他是否成功创建了账户或进行了多次失败的尝试。
在这个应用程序中,您使用带有密钥加密的加密 Cookies 来存储有关每个用户在应用程序中的活动信息以及用户是否仍然登录的信息,以及要在用户浏览器中显示的简短消息,以告知他们最近的请求是否发生错误。
| |
注意
因为请求是相互独立的,如果一个创建新用户的请求失败并且您被重定向到主页,那么这个重定向是另一个请求,并且没有向用户发送任何响应来告知他们创建账户的尝试失败。在这种情况下,Cookies 非常有用。
在创建自定义密钥时,请记住使它们对其他人来说更难以猜测。接下来,您通过在控制器操作上设置闪存消息来使用这些添加的模块。
快速检查 22.1
Q1:
Cookie 的密钥如何改变数据在浏览器中的发送和存储方式?
| |
QC 22.1 答案
1:
与 Cookies 一起使用的密钥允许数据进行加密。加密对于确保通过互联网发送的数据的安全性以及确保用户浏览器中的数据不会被修改非常重要。
22.2. 向控制器操作添加闪存消息
要使闪存消息正常工作,您需要在向用户渲染视图之前将它们附加到请求中。通常,当用户对页面发出GET请求时——比如说,加载主页——您不需要发送闪存消息。
闪存消息在您想通知用户请求成功或失败时非常有用,通常涉及数据库。在这些请求中,例如用户创建,您通常会根据结果重定向到另一个页面。如果用户被创建,您将重定向到/users路由;否则,您可以重定向到/user/new。闪存消息与将本地变量提供给视图并无不同。因此,您需要为 Express 设置另一个中间件配置,以便将您的connectFlash消息视为响应中的本地变量,如列表 22.2 所示。
通过添加这个中间件函数,你告诉 Express 将一个名为flashMessages的本地对象传递给视图。该对象的价值等于你使用connect-flash模块创建的闪存消息。在这个过程中,你将消息从请求对象转移到响应对象。
列表 22.2. 将connectFlash关联到响应上的闪存消息的中间件
router.use((req, res, next) => {
res.locals.flashMessages = req.flash(); *1*
next();
});
- 1 将闪存消息分配到响应对象上的本地
flashMessages变量。
使用这个中间件,你可以在控制器级别添加消息到req.flash,并通过flashMessages在视图中访问这些消息。接下来,通过更改usersController中的create动作的代码来匹配列表 22.4 添加一个闪存消息。
在这个动作中,你正在修改处理catch块中错误的方式。不是将错误传递给 error-handler 动作,而是设置错误闪存消息,并允许redirectView动作再次显示用户的 new.ejs 页面。第一条闪存消息是success类型,传达了用户账户已创建的消息。当账户未创建时传递的闪存消息是error类型。
列表 22.3. 在函数中包装用户参数
const getUserParams = body => {
return {
name: {
first: body.first,
last: body.last
},
email: body.email,
password: body.password,
zipCode: body.zipCode
};
};
注意
getUserParams是从上一个综合练习(课程 21)借用的。这个函数在整个控制器中重用,用于在一个对象中组织用户属性。你应该为你的其他模型控制器创建相同的函数。
列表 22.4. 在 usersController.js 中添加闪存消息到 create 动作
create: (req, res, next) => {
let userParams = getUserParams(req.body);
User.create(userParams)
.then(user => {
req.flash("success", `${user.fullName}'s account created
successfully!`); *1*
res.locals.redirect = "/users";
res.locals.user = user;
next();
})
.catch(error => {
console.log(`Error saving user: ${error.message}`);
res.locals.redirect = "/users/new";
req.flash(
"error",
`Failed to create user account because: ${error.message}.` *2*
);
next();
});
},
注意
虽然你在这里使用请求对象来临时存储闪存消息,因为你将这些消息连接到响应上的一个本地变量,所以这些消息最终到达响应对象。
当页面重定向到/users或/users/new时,你的闪存消息就可供视图使用。
注意
error和success是我创建的两个闪存消息类型。你可以按自己的喜好自定义这些类型。如果你想有一个superUrgent类型的闪存消息,你可以使用req.flash("superUrgent", "Read this message ASAP!")。然后superUrgent将是获取你附加的任何消息所使用的键。
使闪存消息功能生效的最后一步是在视图中添加一些代码来接收和显示这些消息。因为你想让每个视图都显示潜在的成功或失败信息,所以将列表 22.5 中的代码添加到 layout.ejs 中。你可能还希望在 public/css 文件夹中添加自定义样式,以便将消息与正常视图内容区分开来。
首先,检查是否存在任何flashMessages。如果存在success消息,则在div中显示这些成功消息。如果存在error消息,则使用不同样式的类显示这些消息。
列表 22.5. 在 layout.ejs 中添加闪存消息
<div class="flashes">
<% if (flashMessages) { %> *1*
<% if (flashMessages.success) { %>
<div class="flash success"><%= flashMessages.success %></div> *2*
<% } else if (flashMessages.error) { %>
<div class="flash error"><%= flashMessages.error %></div> *3*
<% } %>
<% } %>
</div>
-
1 检查是否存在闪存消息。
-
2 显示成功消息。
-
3 显示错误信息。
提示
如果你一开始没有在屏幕上看到任何消息,尝试移除围绕消息的所有样式,以在视图中获取纯文本消息。
通过启动 Node.js 应用程序,访问/users/new并填写表单以创建新用户来测试显示消息的新代码。如果你成功创建了新用户,重定向后的页面应类似于图 22.1。
图 22.1. 在/users 页面上显示的成功消息

如果你尝试使用现有的电子邮件地址创建新用户,你的重定向屏幕应类似于图 22.2。
图 22.2. 在主页上显示的错误消息

当你刷新页面或创建任何新的请求时,此消息会消失。因为你可能选择发送多个成功或错误消息,你可能发现循环遍历视图中的消息而不是显示所有映射到error和success键的内容是有用的。
如果你需要在渲染的视图中显示消息,直接将消息作为局部变量传递。下面的列表显示了如何将成功消息添加到用户的索引页面。当你直接将flashMessages对象传递给视图时,你不需要等待重定向或使用connect-flash。
列表 22.6. 将消息添加到渲染的索引视图
res.render("users/index", {
flashMessages: {
success: "Loaded all users!"
}
}); *1*
- 1 与渲染视图一起传递消息。
快速检查 22.2
Q1:
使用
req.flash方法需要两个参数是什么?
QC 22.2 答案
1:
req.flash需要一个消息类型和一个消息。
总结
在本课中,你学习了关于会话和 cookie 的内容,并了解了为什么它们是服务器和客户端之间数据传输不可或缺的部分。你还设置了connect-flash以使用 cookie,并在某些视图中临时显示成功和失败信息。在第 23 课中,你将看到如何通过在用户密码上实现加密来加密不仅仅是 cookie 数据。
尝试这个
现在你已经设置了消息闪现,是时候将其添加到所有的 CRUD 操作中。你希望用户看到他们尝试订阅、创建账户、删除账户或更新用户信息是否成功。为所有三个模型涉及数据库的每个操作添加消息闪现。
第 23 课。构建用户登录和散列密码
在第 22 课中,你在控制器动作和视图中添加了闪存消息。在本课中,你通过创建注册和登录表单来深入了解User模型。然后,你通过哈希用户密码并保存用户的登录状态来为你的应用程序添加一层安全性。接下来,你使用express-validator包在控制器级别添加了一些额外的验证。在本课结束时,用户应该能够创建账户,将他们的密码安全地保存在你的数据库中,并按需登录或注销。
本课涵盖
-
创建用户登录表单
-
使用
bcrypt在数据库中哈希数据
考虑以下内容
你提供了一个食谱应用的原型,用户可以在其中创建账户并将未加密的密码存储在你的数据库中。你合理地担心你的数据库可能被黑客攻击,或者(更加尴尬的是)你可能将用户密码以纯文本形式显示给所有用户。幸运的是,安全性是编程世界中的一个重大关注点,有工具和安全技术可以用来保护敏感数据不被泄露。bcrypt就是这样一种工具,你将用它来在数据库中隐藏密码,这样它们在未来就不容易被黑客攻击。
23.1. 实现用户登录表单
在深入处理用户登录到食谱应用的逻辑之前,先确定他们的注册和登录表单的外观。
注册表单的外观和行为将与 new.ejs 中的表单相似。因为大多数用户将通过注册表单创建自己的账户,所以你会参考创建视图和create动作来处理新用户注册。你需要但还没有的是用户登录表单。此表单需要两个输入:email和password。
首先,创建一个基本的用户登录视图,并将其与新的路由和控制器动作连接起来。然后,在 users 文件夹中创建一个新的 login.ejs 视图,并使用下一列表中的代码。注意这里的重要添加:表单标签中的/users/login动作。你需要创建一个路由来处理对该路径的POST请求。
列表 23.1. 在 login.ejs 中创建用户登录表单
<form action="/users/login" method="POST"> *1*
<h2>Login:</h2>
<label for="inputEmail">Email address</label>
<input type="email" name="email" id="inputEmail"
placeholder="Email address" required>
<label for="inputPassword">Password</label>
<input type="password" name="password" id="inputPassword"
placeholder="Password" required>
<button type="submit">Login</button>
</form>
- 1 添加用户登录表单。
接下来,通过在列表 23.2 中添加代码到 main.js 中,添加登录路由。第一个路由允许你在对/users/login路径发起GET请求时看到登录表单。第二个路由处理对同一路径的POST请求。在这种情况下,你将请求路由到authenticate动作,然后是redirectView动作来加载页面。
注意
你需要在你的 show 和 edit 路由上方添加这些路由;否则,Express.js 会将路径中的单词login误认为是用户 ID,并尝试找到该用户。当你在这行上方添加路由时,你的应用程序将首先识别完整的login路由,然后再在 URL 中查找用户 ID。
列表 23.2. 在 main.js 中添加登录路由
router.get("/users/login", usersController.login); *1*
router.post("/users/login", usersController.authenticate,
usersController.redirectView); *2*
-
1 添加一个路由来处理对/用户/login 路径的 GET 请求。
-
2 添加一个路由来处理同一路径的 POST 请求。
在你的用户控制器中创建必要的控制器操作以使登录表单工作。将列表 23.3 中的代码添加到 usersController.js 中。
login操作渲染用户登录的login视图。authenticate操作查找与匹配的电子邮件地址的用户。由于此属性在数据库中是唯一的,它应该找到该单个用户或根本找不到用户。然后,将表单密码与数据库密码进行比较,如果密码匹配,则重定向到该用户的show页面。与先前的操作一样,将res.locals.redirect变量设置为一个redirectView操作将为您处理的路径。还要设置一个闪存消息,让用户知道他们已成功登录,并将user对象作为局部变量传递给该用户的show页面。在这里调用next,将调用下一个中间件函数,即redirectView。如果没有找到用户,但在搜索用户时没有发生错误,设置一个错误闪存消息,并将重定向路径设置为将用户带回到登录表单以再次尝试。
如果发生错误,请在控制台记录它,并将错误传递给处理错误的下一个中间件函数(在你的错误控制器中)。
列表 23.3. 在 usersController.js 中添加登录和认证操作
login: (req, res) => { *1*
res.render("users/login");
},
authenticate: (req, res, next) => { *2*
User.findOne({
email: req.body.email
}) *3*
.then(user => {
if (user && user.password === req.body.password){
res.locals.redirect = `/users/${user._id}`;
req.flash("success", `${user.fullName}'s logged in successfully!`);
res.locals.user = user;
next();
} else {
req.flash("error", "Your account or password is incorrect.
Please try again or contact your system administrator!");
res.locals.redirect = "/users/login";
next();
}
})
.catch(error => { *4*
console.log(`Error logging in user: ${error.message}`);
next(error);
});
}
-
1 添加登录操作。
-
2 添加认证操作。
-
3 比较表单密码与数据库密码。
-
4 将错误记录到控制台,并重定向。
到目前为止,你应该能够重新启动你的 Node.js 应用程序,并访问users/login URL 以查看图 23.1 中的表单。尝试使用你数据库中用户的电子邮件地址和密码进行登录。
图 23.1. 浏览器中用户登录页面示例

如果你输入了错误的电子邮件或密码,你将被重定向到登录屏幕,并显示一个类似于图 23.2 的消息。如果你成功登录,你的屏幕将看起来像图 23.3。
图 23.2. 浏览器中失败的用户登录页面

图 23.3. 浏览器中的成功用户登录页面

但是,你有一个问题:密码仍然以纯文本形式保存。在下一节中,我将讨论如何对信息进行散列的方法。
快速检查 23.1
Q1:
为什么在 main.js 中放置
/users/login路由的位置很重要?
| |
QC 23.1 答案
1:
因为你有处理 URL 中参数的路由,如果这些路由(例如
/users/:id)排在前面,Express.js 将将/users/login的请求视为对用户show页面的请求,其中login是:id。顺序很重要:如果/users/login路由排在前面,Express.js 将在检查处理参数的路由之前匹配该路由。
23.2. 散列密码
加密 是将某些独特密钥或密码短语与敏感数据结合以产生一个代表原始数据但其他方面无用的值的过程。这个过程包括散列数据,其原始值可以使用用于散列函数的私有密钥检索。这个 散列 值存储在数据库中而不是敏感数据。当你想要加密新数据时,通过加密算法传递该数据。当你想要检索该数据或将其与,比如说,用户的输入密码进行比较时,应用程序可以使用相同的独特密钥和算法来解密数据。
bcrypt 是一种复杂的散列函数,它允许你将应用程序中的一些独特密钥组合起来,以在数据库中存储密码等数据。幸运的是,你可以使用几个 Node.js 包来实现 bcrypt 散列。首先,在新的终端窗口中输入 npm i bcrypt@5.0.0 -S 安装 bcrypt 包。接下来,将 bcrypt 引入你将执行散列的模块中。散列可以在 usersController 中进行,但更好的方法是创建一个 Mongoose pre-save 钩子在 User 模型中。在 user.js 中使用 const bcrypt = require("bcrypt") 引入 bcrypt。然后,在 module.exports 行之前但 schema 定义之后,将 代码列表 23.4 中的代码添加到 User 模型中。
注意
你只会对密码进行散列,而不是加密,因为从技术上讲,你不想检索密码的原始值。实际上,你的应用程序不应该知道用户的密码。应用程序只能散列密码。稍后,我将在这个部分更详细地讨论这个话题。
Mongoose 的 pre 和 post 钩子是在用户保存到数据库前后运行代码的绝佳方式。将钩子附加到 userSchema 上,它(就像其他中间件一样)接受 next 作为参数。bcrypt.hash 方法接受一个密码和一个数字。这个数字代表你想要散列密码的复杂度级别,10 通常被认为是可靠的数字。当密码散列完成时,promise 链的下一部分接受生成的 hash(你的散列密码)。
将用户的密码分配给这个 hash,并调用 next,这将用户保存到数据库中。如果发生任何错误,它们将被记录并传递给下一个中间件。
注意
由于在运行 bcrypt.hash 时你失去了上下文,我建议将 this 保留在一个变量中,以便在散列函数中访问。
passwordComparison 是你在 userSchema 上的自定义方法,允许你将表单输入的密码与用户的存储和散列密码进行比较。为了异步执行此检查,使用 bcrypt 的 promise 库。bcrypt.compare 返回一个布尔值,比较用户的密码与 inputPassword。然后返回 promise 给调用 passwordComparison 方法的任何人。
列表 23.4. 在 user.js 中添加散列前置钩子
userSchema.pre("save", function(next) { *1*
let user = this;
bcrypt.hash(user.password, 10).then(hash => { *2*
user.password = hash;
next();
})
.catch(error => {
console.log(`Error in hashing password: ${error.message}`);
next(error);
});
});
userSchema.methods.passwordComparison = function(inputPassword){ *3*
let user = this;
return bcrypt.compare(inputPassword, user.password); *4*
};
-
1 在用户模式中添加一个前置钩子。
-
2 散列用户的密码。
-
3 添加一个比较散列密码的函数。
-
4 比较用户密码与存储的密码。
注意
当用户被保存时,会运行 save 的 pre 钩子:在创建和通过 Mongoose save 方法更新后。
最后一步是重写 usersController.js 中的 authenticate 动作,使用 bcrypt.compare 来比较密码。将 authenticate 动作代码块替换为 列表 23.5 中的代码。
首先,通过 email 明确查询一个用户。如果找到用户,将结果分配给 user。然后检查是否找到用户或返回 null。如果找到具有指定电子邮件地址的用户,则在用户实例上调用你的自定义 passwordComparison 方法,并将表单的输入密码作为参数传递。
由于 passwordComparison 返回一个解析为 true 或 false 的 promise,嵌套另一个 then 以等待结果。如果 passwordsMatch 为 true,则重定向到用户的显示页面。如果未找到具有指定电子邮件地址的用户或输入密码不正确,则返回登录屏幕。否则,抛出错误,并将其传递给你的 next 对象。在此过程中抛出或发生的任何错误都会被捕获并记录。
列表 23.5. 修改 usersController.js 中的 authenticate 动作
authenticate: (req, res, next) => {
User.findOne({email: req.body.email}) *1*
.then(user => {
if (user) { *2*
user.passwordComparison(req.body.password) *3*
.then(passwordsMatch => {
if (passwordsMatch) { *4*
res.locals.redirect = `/users/${user._id}`;
req.flash("success", `${user.fullName}'s logged in
successfully!`);
res.locals.user = user;
} else {
req.flash("error", "Failed to log in user account:
Incorrect Password.");
res.locals.redirect = "/users/login";
}
next(); *5*
});
} else {
req.flash("error", "Failed to log in user account: User
account not found.");
res.locals.redirect = "/users/login";
next();
}
})
.catch(error => { *6*
console.log(`Error logging in user: ${error.message}`);
next(error);
});
}
-
1 通过电子邮件查询一个用户。
-
2 检查是否找到了用户。
-
3 在 User 模型上调用密码比较方法。
-
4 检查密码是否匹配。
-
5 使用重定向路径和闪存消息调用下一个中间件函数。
-
6 将错误记录到控制台并传递给下一个中间件错误处理器。
重新启动你的 Node.js 应用程序,并创建一个新用户。从现在开始,你需要创建新账户,因为之前的账户密码没有使用 bcrypt 安全散列。如果不这样做,bcrypt 将尝试散列和比较你的输入密码与纯文本密码。账户创建后,尝试使用相同的密码在 /users/login 上再次登录。然后更改用户 show 页面中的密码字段以在屏幕上显示密码。访问用户的 show 页面以查看新的散列密码,而不是旧的纯文本密码 (图 23.4)。
图 23.4. 在浏览器中用户 show 页面显示散列密码

注意
你也可以通过在新的终端窗口中输入 mongo 并然后输入 use recipe_db 和 db.users.find({}) 来 MongoDB shell 验证密码是否在数据库级别被散列。或者,你可以使用 MongoDB Compass 软件查看此数据库中的新记录。
现在当你为一个散列密码的用户登录时,你应该在成功认证后重定向到该用户的 show 页面。如果你输入了错误的密码,你会看到一个像 图 23.5 中的屏幕。
图 23.5. 浏览器中的错误密码界面

在下一节中,你通过在调用这些操作之前添加验证中间件来为 create 和 update 操作添加更多安全性。
快速检查 23.2
Q1:
对或错:
bcrypt的compare方法比较数据库中的明文密码与用户输入的明文值。
QC 23.2 答案
1:
错误。数据库中唯一的密码值是一个散列密码,因此没有明文值可以比较。比较是通过散列用户的新输入并与数据库中存储的散列值进行比较来完成的。这样,应用程序仍然不知道你的实际密码,但如果两个散列密码匹配,你可以安全地说你的输入与最初设置的密码匹配。
23.3. 使用 express-validator 添加验证中间件
到目前为止,你的应用程序在视图和模型级别提供了验证。如果你尝试创建一个没有电子邮件地址的用户账户,你的 HTML 表单应该阻止你这样做。如果你绕过了表单,或者如果有人试图通过你的应用程序编程接口(API)创建账户,就像你在第 6 单元中看到的那样,你的模型模式限制应该阻止无效数据进入你的数据库——尽管更多的验证也无妨。实际上,如果你能在模型到达应用程序之前添加更多验证,你就可以节省大量用于制作 Mongoose 查询和重定向页面的计算时间和机器能量。
由于这些原因,你需要验证中间件,并且正如 Node.js 中大多数常见需求一样,有一些包可以帮助你构建这些中间件函数。你将安装的包是 express-validator,它提供了一组你可以用来检查传入数据是否遵循特定格式的函数,以及修改该数据以删除不需要的字符的函数。例如,你可以使用 express-validator 来检查某些输入数据是否以美国电话号码的格式输入。
您可以通过在终端的项目文件夹中输入 npm i express-validator -S 来安装此包。当此包安装后,在 main.js 中使用 const expressValidator = require("express-validator") 引入它,并通过添加 router.use(expressValidator()) 告诉您的 Express.js 应用程序使用它。您需要在引入 express.json() 和 express.urlencoded() 中间件之后添加此行,因为在验证之前必须解析请求体。
然后,您可以将此中间件添加到在 usersController 中的 create 动作之前直接运行。为了完成此任务,您需要在 main.js 中 /users/create 路由的路径和 create 动作之间创建一个名为 validate 的动作,如图 列表 23.6 所示。在路径 /users/create 和 usersController.create 动作之间,您引入了一个名为 validate 的中间件函数。通过这个 validate 动作,您将确定数据是否符合您的要求,以便继续到 create 动作。
列表 23.6. 在 main.js 中将验证中间件添加到用户创建路由
router.post("/users/create", usersController.validate,
usersController.create, usersController.redirectView); *1*
- 1 将验证中间件添加到用户创建路由。
最后,在 usersController.js 中创建 validate 动作来处理在到达 create 动作之前到达的请求。在这个动作中,您添加以下内容:
-
Validators—检查传入的数据是否符合某些标准。
-
Sanitizers—在数据进入数据库之前,通过删除不需要的元素或转换数据类型来修改传入的数据。
将 列表 23.7 中的代码添加到 usersController.js 中。
第一个验证函数使用请求和响应,并且它可能传递到中间件链中的下一个函数,因此您需要 next 参数。从对 email 字段的清理开始,使用 express-validator 的 normalizeEmail 方法将所有电子邮件地址转换为小写,然后 trim 移除空白。接着验证 email,确保它符合 express-validator 设置的电子邮件格式要求。
zipCode 验证确保值不为空,是一个整数,并且长度正好是五位数字。最后的验证检查 password 字段是否为空。req.getValidationResult 收集先前验证的结果,并返回一个包含这些错误结果的承诺。
如果发生错误,您可以收集它们的错误消息并将它们添加到请求的闪存消息中。在这里,您使用 " and " 将一系列消息连接成一个长的String。如果验证过程中发生了错误,设置req.skip = true。在这里,set是您添加到请求对象中的新自定义属性。此值告诉您的下一个中间件函数create,由于验证错误不要处理您的用户数据,而是跳转到您的redirectView操作。为了使此代码正常工作,您需要在create操作中添加if (req.skip) next()作为第一行。这样,当req.skip为true时,您可以立即继续到下一个中间件。
在验证错误的情况下,再次渲染new视图。您的flashMessages也向用户指示了她的输入数据中发生了什么错误。
列表 23.7. 在 usersController.js 中创建一个validate控制器
validate: (req, res, next) => { *1*
req.sanitizeBody("email").normalizeEmail({
all_lowercase: true
}).trim(); *2*
req.check("email", "Email is invalid").isEmail();
req.check("zipCode", "Zip code is invalid")
.notEmpty().isInt().isLength({
min: 5,
max: 5
}).equals(req.body.zipCode); *3*
req.check("password", "Password cannot be empty").notEmpty(); *4*
req.getValidationResult().then((error) => { *5*
if (!error.isEmpty()) {
let messages = error.array().map(e => e.msg);
req.skip = true; *6*
req.flash("error", messages.join(" and ")); *7*
res.locals.redirect = "/users/new"; *8*
next();
} else {
next(); *9*
}
});
}
-
1 添加验证函数。
-
2 使用 trim 方法去除空白。
-
3 验证 zipCode 字段。
-
4 验证密码字段。
-
5 收集先前验证的结果。
-
6 将跳过属性设置为 true。
-
7 将错误消息作为闪存消息添加。
-
8 为新视图设置重定向路径。
-
9 调用下一个中间件函数。
注意
您可以采取许多创造性的方法来重新填充表单数据。您可能会发现一些包有助于协助这项任务。当您找到最适合您的技术时,将应用程序中的所有表单更改为处理数据重新填充。
您可以为这些验证尝试一下。启动您的应用程序,并以应该使您的验证失败的方式创建一个新用户。如果您想测试notEmpty验证,可能需要先从您的 HTML 表单中删除required标签。失败的password和zipCode验证应该会将您带到类似于图 23.6 的屏幕。
图 23.6. 失败的express-validator验证消息

因为express-validator使用validator包,您可以在github.com/chriso/validator.js#sanitizers获取有关要使用的清洗器的更多信息。
快速检查 23.3
Q1:
清洗器和验证器之间的区别是什么?
| |
QC 23.3 答案
1:
清洗器通过修剪空白、更改大小写或删除不需要的字符来清理数据。验证器测试数据质量,以确保其输入方式符合您的数据库要求。
摘要
在本课中,你为用户的密码实现了一个散列函数。然后,你通过使用bcrypt.compare方法创建了一个登录表单和动作,将散列密码与登录时的用户输入进行匹配。最后,你通过添加额外的中间件函数来对输入数据进行额外的验证,以在将其保存到数据库之前清理数据。在第 24 课中,你将再次通过 Passport.js 工具查看加密和身份验证,这些工具使得设置安全用户账户变得更加容易。
尝试以下操作
散列用户密码可能是使用散列函数的主要场景,但你可以在你的模型的其他字段上使用散列函数。例如,你可能会对用户的电子邮件地址进行散列,以防止这些数据落入错误的手中。毕竟,获取用户的电子邮件地址就相当于黑客攻击用户账户的一半。尝试在密码之外,为用户电子邮件添加散列。
注意
注意
当你散列用户的电子邮件地址时,你将无法在任何视图中显示它。尽管你可能会选择以纯文本形式保留用户电子邮件,但当你应用程序中其他敏感数据进入时,遵循这种做法是好的。
第 24 课. 添加用户身份验证
在第 23 课中,你学习了手动散列密码以及保护用户数据的重要性。在本课中,你将探索一些流行的且实用的工具,这些工具可以使散列过程更加整洁。你修改了你的散列方法,以使用passport-local-mongoose包,该包结合使用passport和mongoose在幕后为你执行散列。接下来,你将学习如何使用 Passport.js 在你的应用程序上验证用户账户。这个过程涉及到会话 cookie,类似于闪存消息使用它们的方式。在本课结束时,你将拥有一个注册和登录表单,它只允许你的应用程序的真实用户访问。
本课涵盖
-
使用
passport包在整个应用程序中验证用户 -
在用户模型上实现
passport-local-mongoose插件 -
在用户登录之前创建身份验证操作
考虑这一点
你已经向你的应用程序添加了一个流行的散列方法,但你希望简化代码,或者更好的是,将其放在幕后。了解散列的工作原理是很好的,而且有可用的工具可以执行你想要的散列,而无需手动设置自己的散列标准。例如,passport.js包可以散列和验证用户交互,而无需在模式中指定密码字段。在本课中,你将查看passport包的最快和最有效实现。
24.1. 实现 Passport.js
Passport.js 是 Node.js 中间件,用于对新的用户密码进行哈希处理并验证他们在应用程序上的活动。Passport.js 使用不同的方法来创建和登录用户账户,从基本的用户名和密码登录到使用 Facebook 等第三方服务的登录。这些登录方法被称为 策略,你将为你的食谱应用程序使用的策略是一个 local 策略,因为你没有使用外部服务。
这些策略通过管理哈希和比较密码以及与用户登录状态相关的数据来检查传入数据是否真实。有关 Passport.js 策略的更多信息,请访问 www.passportjs.org。
首先,安装应用程序所需的必要包。你需要通过在项目终端窗口中运行 npm i passport passport-local-mongoose -S 来安装 passport 包以及 passport-local-mongoose 包。这些包中的模块协同工作,提供哈希和认证方法以及与你的 Mongoose 架构直接通信的支持。在你将这些包作为依赖项安装后,在应用程序中需要的地方引入它们。将 列表 24.1 中的以下行添加到 main.js 中。
首先,引入 passport 模块。Passport.js 使用名为策略的方法让用户登录。local 策略指的是用户名和密码登录方法。你 initialize passport 模块,并让 Express.js 应用程序使用它。现在你已经在应用程序中准备好了 passport 作为中间件。passport.session 告诉 passport 使用你已与应用程序设置的任何会话。在这种情况下,在此行之前,你已经为闪存消息设置了 express-session。
列表 24.1. 在 main.js 中引入和初始化 passport
const passport = require("passport"); *1*
router.use(passport.initialize()); *2*
router.use(passport.session()); *3*
-
1 引入 passport 模块。
-
2 初始化 passport。
-
3 在 Express.js 中配置 passport 使用会话。
接下来,你需要在用户模型上设置你的登录策略,并告诉 passport 为你处理会话中的用户数据哈希。passport-local-mongoose 使此过程简单且几乎自动化。将 列表 24.2 中的行添加到 main.js 中。
注意
passport.session 告诉 passport 使用之前定义的任何 Express.js 会话。在此行之前必须定义会话。
在你继续将用户模型与 passport 连接之前,你需要确保你的用户模型在 main.js 中可用。通常,你需要设置一些配置来为模型创建登录策略,但由于你正在使用默认的本地登录策略,你只需要告诉 passport 使用为用户模型创建的策略。接下来的两行告诉 passport 通过 User 模型序列化和反序列化你的用户。这些行指导了加密和解密存储在会话中的用户数据的过程。
列表 24.2. 在 main.js 中设置 passport 序列化
const User = require("./models/user"); *1*
passport.use(User.createStrategy()); *2*
passport.serializeUser(User.serializeUser()); *3*
passport.deserializeUser(User.deserializeUser());
-
1 需要用户模型。
-
2 配置用户的登录策略。
-
3 设置 passport 以序列化和反序列化您的用户数据。
Passport 将用户数据序列化和反序列化以传递到会话中。会话存储此序列化数据——用户信息的压缩形式,并将其发送回服务器以验证用户是客户端最后登录的用户。反序列化从其压缩版本中提取用户数据,以便您可以验证用户的信息。
序列化数据
当在应用程序中处理对象时,您希望保留允许您轻松访问和修改属性的数据结构。例如,您的用户对象允许您检索诸如email之类的信息,甚至可以使用用户模型的虚拟属性fullName。尽管该模型在您的应用程序中特别有用,但您没有直接的方法将此用户对象及其方法和 Mongoose 对象-文档映射器(ODM)工具发送到客户端。因此,您需要序列化用户数据。
序列化是将数据从某些数据结构转换为紧凑的可读格式的过程。这些数据可以采用多种格式,例如 JSON、YAML 和 XML。用户数据被扁平化,通常转换为字符串,以便在 HTTP 事务中发送。
Passport.js 执行序列化过程并加密用户数据,以便将其存储为客户端浏览器会话 cookie 的一部分。因为这个 cookie 包含有关用户的信息,它让您的应用程序服务器知道,在下次请求发生时,这个用户之前已经登录过,这是您在应用程序中验证某人状态的方式。
当同一用户再次向您的应用程序发出请求时,Passport.js 将数据反序列化以将用户恢复到其原始模型对象形式。当这个过程完成并且您验证用户有效后,您可以使用用户对象再次像以前一样使用,应用模型方法和使用 Mongoose 查询。
在构建将用户登录到您的应用程序的认证动作之前,最后一步是将您的用户模型连接到passport-local-mongoose模块。在 user.js 的顶部添加const passportLocalMongoose = require("passport-local-mongoose"),您将在其中向用户模式添加 Passport.js 插件,如列表 24.3 所示。使用 Mongoose 的plugin方法,您告诉您的userSchema使用passportLocalMongoose进行密码散列和存储。您还告诉passportLocalMongoose使用电子邮件字段作为用户的登录参数,而不是用户名,因为用户名是这个模块的默认字段。
注意
这行必须在您注册用户模型之前出现。
列表 24.3. 将passport-local-mongoose插件添加到用户模式
userSchema.plugin(passportLocalMongoose, {
usernameField: "email"
}); *1*
- 1 将 passport-local-mongoose 模块作为插件应用到用户模式中。
当这一行被放置到位时,Passport.js 会自动处理密码存储,因此你可以从userSchema中删除password属性。此插件在幕后修改你的模式,将hash和salt字段添加到你的User模型中,以替换正常的password字段。
哈希和盐
你在第 24 课中学习了哈希,但你让bcrypt通过一个你不需要理解的算法执行哈希过程。bcrypt和 Passport.js 究竟是如何哈希用户密码的?
现代哈希将用户的输入密码转换为不可解密的哈希。这个哈希是一堆字符和数字的混合,使其比明文密码更安全地存储在数据库中。如果有人黑入数据库,他只有哈希密码。他最好的办法是将自己的猜测密码输入自己的哈希函数,看看生成的哈希是否与你的匹配。这是一项繁琐的任务,但黑客找到破解你的哈希密码的方法并不是不可能的。盐的引入是为了对抗这种漏洞。
盐是一系列随机的字符字符串,在哈希密码之前添加到明文密码中。这样,如果有人恶意猜测你的密码,他们还需要知道与之关联的盐以及它在原始密码中的位置。黑客攻击变得更加困难。
Passport.js 将哈希密码和盐都存储在数据库中,以便你可以在应用程序内一致地执行哈希操作。当你使用 Passport.js 注册第一个用户时,请按照以下步骤查看 MongoDB 中的他们的数据,以查看这些值:
-
在一个新的终端窗口中,运行
mongo。 -
运行
use recipe_db来加载你的食谱数据库。 -
运行
db.users.find({}, { password: 1})来查看所有用户密码。 -
比较哈希和非哈希密码。
注意
确保删除应用程序中对password属性的任何引用。因为passport-local-mongoose向用户模型添加了新的密码字段,所以你将不再使用它。
在下一节中,你将使用passport包来进一步简化认证过程。
快速检查 24.1
Q1:
真或假:哈希密码需要盐。
QC 24.1 答案
1:
错误。盐可以帮助通过在哈希之前将随机文本与明文密码混合来增强密码的哈希强度,但盐不是必需的。
24.2. 修改创建操作以使用 passport 注册
使用 Passport.js 已经简化了您的代码,并使您更容易指定您想要散列和验证的模型。下一步是修改您的 create 动作,这样在创建用户账户之前不再使用您的 bcrypt 散列函数,而是使用 Passport.js。通过集成 Passport.js 模块,您将能够访问一个方法库,以简化账户注册过程。将 usersController.js 中的 create 动作更改为使用 register 方法,如 列表 24.4 所示。
注意
您必须注释掉或删除 User 模型中的 userSchema.methods.passwordComparison 和 pre("save") 钩子,对于 bcrypt。如果您不删除这些钩子,bcrypt 仍然会在 passport 之前尝试散列用户密码,这也会导致一个未处理的承诺错误。
register 方法是 Passport.js 的一部分。因为您正在将 passport-local-mongoose 作为 User 模型的插件使用,所以您可以使用此方法来注册用户。如果您成功保存了一个新用户,创建一个闪存消息并将重定向到 /users 路由。否则,通过将重定向到 users/new 路由来处理发生的任何错误,以便可以再次尝试创建用户账户。
列表 24.4. 在 usersController.js 的 create 动作中注册新用户
create: (req, res, next) => {
if (req.skip) return next();
let newUser = new User( getUserParams(req.body) );
User.register(newUser, req.body.password, (error, user) => { *1*
if (user) {
req.flash("success", `${user.fullName}'s account created
successfully!`);
res.locals.redirect = "/users";
next(); *2*
} else {
req.flash("error", `Failed to create user account because:
${error.message}.`);
res.locals.redirect = "/users/new";
next(); *3*
}
});
}
-
1 注册新用户。
-
2 设置成功创建用户的重定向。
-
3 在闪存消息中设置重定向和记录错误。
在此操作生效后,您可以使用位于 /users/new.ejs 的表单通过 Passport.js 创建用户账户。尝试启动您的应用程序并创建一个新用户。您不应该注意到行为上的变化;您的用户账户将被创建,并且您将看到 success 提示信息。
如果您在新的终端窗口中通过输入 mongo 来查看 MongoDB 中的原始文档,然后输入 use recipe_db 和 db.users.find({}) 来查看您数据库中的用户。任何使用 bcrypt 保存的用户仍然保留有包含散列密码的 password 字段。您最新的用户有两个由 Passport.js 添加的属性:salt 和 hash。
提示
将您的 seed.js 文件更新为使用 passport 而不是 Mongoose 的 create 方法来注册用户账户。这种做法使您在开发过程中随着应用程序的增长重新填充数据库变得更加容易。
将您的 seed.js 文件更新为使用 Passport 而不是 Mongoose 的 create 方法来注册用户账户,这将使您在开发过程中随着应用程序的增长重新填充数据库变得更加容易。
您的用户仍然安全,但您仍然需要一个方法来登录他们。在下一节中,您将修改登录表单以使用 Passport.js。
快速检查 24.2
Q1:
Passport.js 为什么需要您在数据库中保存
hash和salt?
| |
QC 24.2 答案
1:
Passport.js 保存了
salt和hash,这样每个用户都可以拥有他们自己的唯一哈希因子。虽然可以为每个用户账户使用相同的salt,并且只将哈希存储在数据库中,但这种方法的安全性较低。
24.3. 登录时认证用户
允许用户登录应用程序的最终一步是将bcrypt认证方法替换为passport中间件。在 usersController.js 中修改你的authenticate动作,如列表 24.5 所示。你还需要通过在文件顶部添加const passport = require("passport")将passport引入到用户控制器中。
这个authenticate动作被设置为直接调用passport.authenticate方法,并带有passport重定向和提示信息选项。当你调用usersController.authenticate时,你实际上是在调用passport.authenticate。在这个函数中,passport试图将描述用户的传入请求数据与数据库记录进行比较。如果找到用户账户并且输入的密码与哈希密码相匹配,你将从该动作重定向。
列表 24.5. 在 usersController.js 中添加 Passport 认证中间件
authenticate: passport.authenticate("local", { *1*
failureRedirect: "/users/login", *2*
failureFlash: "Failed to login.",
successRedirect: "/",
successFlash: "Logged in!"
}),
-
1. 调用 Passport 通过本地策略认证用户。
-
2. 根据用户的认证状态设置成功和失败提示信息以及重定向路径。
登录路由不再需要你的usersController.redirectView动作作为后续函数。从第 23 课中设置的router.post("/users/login", usersController.authenticate);路由开始,你的应用程序已经准备好验证现有用户。重新启动你的应用程序,并使用你在/users/login创建的用户账户登录。如果你成功,你应该会看到success提示信息。
如果有一个视觉指示表明你已经登录,以及可能有注销的方式,那就很好了。将列表 24.6 中的代码添加到 layout.ejs 中的导航栏。你正在检查局部变量loggedIn是否设置为true。如果是,显示文本已登录为后跟从currentUser局部变量获取的用户的fullName。这个列表项被包裹在一个锚标签中,当点击时,会带你到当前登录用户的show页面。如果loggedIn状态为false,显示一个链接到Sign In,带你到/users/login路由。
列表 24.6. 在 layout.ejs 中添加导航栏的登录状态
<% if (loggedIn) { %> *1*
Logged in as <a href="<%=`/users/${currentUser._id}`%>">
<%= currentUser.fullName %></a>
<%} else {%> *2*
<a href="/users/login">Log In</a>
<% } %>
-
1. 检查用户是否已登录。
-
2. 显示一个登录链接。
如果您刷新应用程序,您可能仍然在导航栏中看不到任何变化。您需要创建loggedIn和currentUser变量,以便它们在每个视图中本地显示。为此,添加一些自定义中间件,以便在每次新的请求中,您将这些变量添加到响应中。因为您已经创建了一个用于设置flashMessages为本地对象的中间件函数,所以您可以在 main.js 中的该中间件函数内添加代码,见代码列表 24.7。
isAuthenticated是 Passport.js 提供的一个方法,您可以在传入的请求上调用它,以查看是否在请求的 cookie 中存储了现有用户。loggedIn可以是true或false。如果请求中有用户,您可以将其提取出来并分配给您的currentUser变量。添加此代码后,您可以在每个页面上访问这两个变量以及flashMessages。
代码列表 24.7. 在自定义中间件中添加本地变量
res.locals.loggedIn = req.isAuthenticated(); *1*
res.locals.currentUser = req.user; *2*
-
1 设置
loggedIn变量以反映护照登录状态。 -
2 设置
currentUser以反映已登录用户。
重新启动您的应用程序以查看您的名字是否出现在导航栏中。您的屏幕可能看起来像图 24.1。
图 24.1. 浏览器中成功登录的示例

此图在导航栏中包含一个注销链接。要创建此链接,在currentUser名称出现的行下方添加<a href="/users/logout">注销</a>。要使此链接工作,您需要创建一个用于注销的路由和动作。首先,在 main.js 中您的login路由旁边添加router.get("/users/logout", usersController.logout, usersController.redirect-View)。然后,将代码列表 24.8 中的logout动作添加到 usersController.js 中。
此行为使用 Passport.js 在请求上提供的logout方法来清除用户的会话。在通过您的自定义中间件的下一个传递过程中,isAuthenticated返回false,并且将不再有当前用户。随后通过redirectView行为将用户重定向到主页,并显示一条消息提示用户已注销。
代码列表 24.8. 在 usersController.js 中添加注销动作
logout: (req, res, next) => { *1*
req.logout();
req.flash("success", "You have been logged out!");
res.locals.redirect = "/";
next();
}
- 1 添加一个注销用户的行为。
在此动作就绪后,是时候测试完整的登录过程了。重新启动您的应用程序,登录,然后点击导航栏中的注销链接(图 24.2)。您的会话应该被清除,并且您的账户成功注销。
图 24.2. 浏览器中成功注销用户的示例

在第 25 课中,您将用户身份验证应用于毕业设计项目。
快速检查 24.3
Q1:
您如何在应用程序中访问 Passport.js 的方法?
QC 24.3 答案
1:
由于你在 Express.js 中添加了
passport模块作为中间件,因此你可以访问 Passport.js 提供的方法库。这些方法扩展到了请求进入应用程序时。当请求通过中间件链传递时,你可以在任何你想要的地方调用这些passport方法。
在本课中,你添加了一些 Passport.js 包来协助加密和验证用户数据。通过将额外的验证操作连接到你的用户登录中间件链,你可以确保用户密码安全,并且登录体验一致。在下一课的综合练习(第 25 课)中,你将应用这些验证、散列、加密和认证技术来改善 Confetti Cuisine 应用程序体验。
尝试这个
你已经成功实现了 Passport.js 与你的 User 模型和 Mongoose ODM 一起工作。由于 Passport.js 为你做了很多繁重的工作,你可能觉得登录过程没有太多可以添加的,但你总是有更多中间件的空间。在验证和加密之间添加一个名为 logEmail 的中间件函数,这个中间件应该将用户的电子邮件地址域名(如 gmail、yahoo 或 live)记录到控制台,并将其传递给下一个中间件函数。
第 25 课。综合练习:为 Confetti Cuisine 添加用户认证
我在 Confetti Cuisine 的联系人对于他们应用程序的进展感到非常高兴。他们已经开始了添加新的课程提供、管理新的订阅者以及宣传创建新用户账户的工作。我警告他们,尽管可以创建用户账户,但应用程序还没有准备好安全地处理用户。
客户端和我都认为数据加密和适当的用户认证是前进的方向,因此,为了我对应用程序的下一步改进,我打算添加几个使用 Passport.js 的包来协助设置安全的用户登录流程。我还会添加闪存消息,以便用户在重定向或页面渲染后能够知道他们的上一个操作是否成功。然后,我将借助 express-validator 中间件包添加一些额外的验证。
到了开发这个阶段结束时,我可以放心地鼓励 Confetti Cuisine 为他们的应用程序注册用户。然而,由于应用程序尚未上线,因此当用户注册时,客户需要在他们的机器上本地运行它。
对于这个综合练习,我需要做以下几件事情:
-
在页面请求之间添加会话和 cookies
-
在视图中添加新的自定义中间件,用于验证和设置局部变量
-
创建登录表单
-
为
User模型添加 passport 认证和加密 -
添加一个视觉指示器来显示哪个用户已登录
基于我在上一个综合练习(课程 21)中编写的代码,我目前实现了三个模型,并为每个模型实现了 CRUD 动作。为了继续改进 Confetti Cuisine 的应用程序,我需要安装一些额外的包:
-
express-session允许我存储有关用户与应用程序交互的临时数据。生成的会话让我知道用户是否最近登录过。 -
cookie-parser允许我在客户端存储会话数据。生成的 cookie 会随每个请求和响应发送,其中包含反映最后使用该客户端的用户的消息和数据。 -
connect-flash允许我使用会话和 cookie 在用户的浏览器中生成闪存消息。 -
express-validator允许我通过中间件函数为传入的用户数据添加一层验证。 -
passport允许我为User模型设置一个无痛苦的加密和认证过程。 -
passport-local-mongoose允许我通过在User模型上使用插件进一步集成passport,从而简化我需要编写的代码。
要安装这些包,我将在项目的终端窗口中运行 npm i express-session cookie-parser connect-flash express-validator passport passport-local-mongoose -S。我已经设置了 create 动作和 new 表单用于用户。我需要很快修改这些,但首先,我将创建用户登录应用程序所需的登录表单。
25.2. 创建登录表单
我想这个表单包含两个简单的输入:email 和 password。我将在用户文件夹中创建一个新的 login.ejs 视图,并添加下一个列表中的代码。此表单将向 /users/login 路由提交 POST 请求。此表单的输入将处理用户的 email 和 password。
列表 25.1. 在 users/login.ejs 中添加登录表单
<form class="form-signin" action="/users/login" method="POST"> *1*
<h2 class="form-signin-heading">Login:</h2>
<label for="inputEmail" class="sr-only">Email</label>
<input type="text" name="email" id="inputEmail" class="form-
control" placeholder="Email" autofocus required>
<label for="inputPassword" class="sr-only">Password</label>
<input type="password" name="password" id="inputPassword"
class="form-control" placeholder="Password" required>
<button class="btn btn-lg btn-primary btn-block" type="submit">
Login</button>
</form>
- 1 创建登录表单。
在此表单可以工作或被查看之前,我将添加 login 路由和动作。login 将接受 GET 和 POST 请求,如下所示。
注意
我将所有与路由相关的代码添加到 router 对象上。
列表 25.2. 在 main.js 中添加登录路由
router.get("/users/login", usersController.login); *1*
router.post("/users/login", usersController.authenticate); *2*
router.get("/users/logout", usersController.logout,
usersController.redirectView ); *3*
-
1 到登录动作的路由。
-
2 将提交的数据发送到认证动作。
-
3 添加一个退出路由并重定向到一个视图。
在这些路由就位后,在表单在 /users/login 可视化之前,我需要创建它们对应的动作。首先,我将添加下一个列表中的 login 动作到 users-Controller.js。
列表 25.3. 在 usersController.js 中添加 login 动作
login: (req, res) => {
res.render("users/login"); *1*
}
- 1 添加一个动作来渲染我的表单以供浏览器查看。
在下一节中,我使用 passport 包开始加密用户数据,这样这个登录表单就会有实际用途。
25.3. 使用 Passport.js 添加加密
要开始使用 Passport.js,我需要在 main.js 和 users-Controller.js 中引入 passport 模块,通过在两个文件的顶部添加 const passport = require("passport") 来实现。这些文件是我将在其中设置哈希和认证的文件。接下来,我需要在 Express.js 中作为中间件初始化并使用 passport。因为 passport 使用会话和 cookie,所以我还需要在 main.js 中引入 express-session 和 cookie-parser,将 列表 25.4 中的行添加到该文件中。
要开始使用 passport,我需要使用一个密钥来配置 cookieParser 以加密客户端上存储的 cookie。然后,我将让 Express.js 也使用会话。在设置过程中,这一阶段是 passport 开始存储应用程序活跃用户信息的地方。通过告诉 Express.js 在这一行初始化并使用它,passport 正式成为中间件。因为在此行之前已经设置了会话,所以我指示 Express.js 让 passport 使用那些现有的会话来存储其用户数据。
我设置了默认登录策略,这是通过即将添加到 User 模型的 passport-local-mongoose 模块提供的,以使用 passport 为用户提供身份验证。最后两行允许 passport 在服务器和客户端之间发送数据时压缩、加密和解密用户数据。
列表 25.4. 在 main.js 中使用 Express.js 添加 passport
const passport = require("passport"),
cookieParser = require("cookie-parser"),
expressSession = require("express-session"),
User = require("./models/user");
router.use(cookieParser("secretCuisine123")); *1*
router.use(expressSession({
secret: "secretCuisine123",
cookie: {
maxAge: 4000000
},
resave: false,
saveUninitialized: false
})); *2*
router.use(passport.initialize()); *3*
router.use(passport.session()); *4*
passport.use(User.createStrategy()); *5*
passport.serializeUser(User.serializeUser()); *6*
passport.deserializeUser(User.deserializeUser());
-
1 使用密钥配置 cookieParser。
-
2 配置 Express.js 使用会话。
-
3 配置 Express.js 初始化并使用 passport。
-
4 指示 passport 使用会话。
-
5 设置默认登录策略。
-
6 设置 passport 以压缩、加密和解密用户数据。
注意
在我能够使用 createStrategy 方法之前,我需要确保在 main.js 中引入了 User 模型。此方法只有在用 passport-local-mongoose 设置了用户模型之后才能工作。
在设置好此配置后,我可以将注意力转向 user.js 中的 User 模型,以添加 passport-local-mongoose。我需要在 user.js 的顶部添加 const passportLocalMongoose = require("passport-local-mongoose") 来在用户模型中引入 passport-local-mongoose。
在此文件中,我将模块作为插件附加到 userSchema 上,如 列表 25.5 所示。这一行设置 passportLocalMongoose 在我的数据库中的用户模型上创建 salt 和 hash 字段。它还将 email 属性视为登录和验证的有效字段。此代码应放置在 module.exports 行之上。
列表 25.5. 将 passport-local-mongoose 作为插件添加到 User 模型
userSchema.plugin(passportLocalMongoose, {
usernameField: "email"
}); *1*
- 1 将 passport-local-mongoose 模块作为用户模式插件添加。
注意
在我的 User 模型中添加此功能后,我不再需要在用户模式中保留纯文本密码属性。我现在将删除该属性,以及用户 show 页面上的密码表行。
在下一节中,我修改了usersController.js中的create动作,以使用passport注册新用户,并设置闪存消息,以便用户知道账户创建是否成功。
25.4. 添加闪存消息
当会话和 cookie 准备好将数据附加到请求并响应用户时,我准备通过使用connect-flash来集成闪存消息。为了配置connect-flash,我需要在 main.js 中将其作为一个名为connectFlash的常量引入,添加以下行:const connectFlash = require("connect-flash")。然后,我告诉我的 Express.js 应用将其用作中间件,通过在 main.js 中添加router.use(connectFlash())来实现。
现在中间件已安装,我可以在我的应用程序中的任何请求上调用flash,这允许我将消息附加到请求。为了将请求闪存消息传递到响应,我在 main.js 中添加了一些自定义中间件,如列表 25.6 所示。通过告诉 Express.js 应用使用这个自定义中间件,我能够将一个名为flashMessages的局部变量分配给包含在控制器动作中创建的闪存消息的对象。从这里开始,我将在我的视图中访问flashMessages对象。
列表 25.6. 在 main.js 中添加使用闪存消息的自定义中间件
router.use((req, res, next) => {
res.locals.flashMessages = req.flash(); *1*
next();
});
- 1 将闪存消息分配给局部变量。
因为我想让闪存消息出现在每个页面上,我会在我的 layout.ejs 文件中添加一些代码来查找flashMessages并在它们存在时显示它们。我会在列表 25.7 中添加代码到 layout.ejs 的<%- body %>之上。
我打算只显示成功和错误消息。首先,我检查flashMessages是否已定义;然后,我显示与对象关联的成功消息或错误消息。
列表 25.7. 在 layout.ejs 中添加使用闪存消息的逻辑
<div class="flashes">
<% if (flashMessages) { %> *1*
<% if (flashMessages.success) { %>
<div class="flash success"><%= flashMessages.success %></div>
<% } else if (flashMessages.error) { %>
<div class="flash error"><%= flashMessages.error %></div> <% } %>
<% } %>
</div>
- 1 在视图中显示闪存消息。
最后,我通过修改用户的create操作以使用passport和闪存消息,并将代码添加到usersController.js中的列表 25.8 来测试这个新添加的代码。create操作使用 Passport.js 提供的register方法创建新的用户账户。结果是数据库中包含散列密码和盐的用户文档。如果用户成功保存,我会在index视图中添加一个成功闪存消息。否则,我会在用户创建页面上显示一个错误消息。
列表 25.8. 在创建动作中添加 passport 注册和闪存消息
create: (req, res, next) => { *1*
if (req.skip) next();
let newUser = new User(getUserParams(req.body));
User.register(newUser, req.body.password, (e, user) => {
if (user) {
req.flash("success", `${user.fullName}'s account
created successfully!`); *2*
res.locals.redirect = "/users";
next();
} else {
req.flash("error", `Failed to create user account
because: ${e.message}.`);
res.locals.redirect = "/users/new";
next();
}
});
}
-
1 添加创建用户动作。
-
2 响应闪存消息。
在这个动作到位后,我准备演示我的新的带有闪存消息的 Passport.js 注册过程。接下来,我在用户创建之前添加一些自定义验证。
25.5. 使用 express-validator 添加验证中间件
express-validator 模块提供了在数据进入此应用程序时清理和验证数据的有用方法。我开始在 main.js 中通过添加 const expressValidator = require( "``express-validator``"``) 并告诉我的 Express.js 应用程序将此模块作为中间件使用,添加 router.use(expressValidator()) 到同一文件中。
我知道我想要数据在到达 usersController 中的 create 动作之前通过一些中间件验证函数,所以我将我的 /users/create 路由改为考虑这一要求,如 列表 25.9 所示。这个 validate 动作位于 usersController 中,在 create 动作之前运行,这确保了我的自定义验证中间件在有机会到达我的 User 模型之前过滤掉不良数据。
列表 25.9. 在 main.js 中在创建之前添加验证动作
router.post("/users/create", usersController.validate,
usersController.create, usersController.redirectView); *1*
- 1 向用户创建路由添加验证中间件。
然后我在 usersController.js 中通过使用 列表 25.10 中的代码创建了一个 validate 动作。这个 validate 动作解析传入的请求并清理请求体中的数据。在这种情况下,我正在从 email 字段中去除空白字符。
我使用 express-validator 提供的一些其他方法来确保数据库中的电子邮件保持一致,并且 ZIP 码长度符合要求。我还会检查用户在注册时是否输入了密码。我收集在验证步骤中可能发生的任何错误。然后我将错误消息连接成一个单独的字符串。我在请求对象上设置一个属性,req.skip = true,这样就可以跳过 create 动作并直接返回视图。所有闪存消息都在 users/new 视图中显示。如果没有错误,我调用 next 来移动到 create 动作。
列表 25.10. 在 usersController.js 中添加 validate 动作
validate: (req, res, next) => { *1*
req
.sanitizeBody("email")
.normalizeEmail({
all_lowercase: true
})
.trim();
req.check("email", "Email is invalid").isEmail();
req
.check("zipCode", "Zip code is invalid")
.notEmpty()
.isInt()
.isLength({
min: 5,
max: 5
})
.equals(req.body.zipCode); *2*
req.check("password", "Password cannot be empty").notEmpty();
req.getValidationResult().then((error) => {
if (!error.isEmpty()) {
let messages = error.array().map(e => e.msg);
req.skip = true;
req.flash("error", messages.join(" and "));
res.locals.redirect = '/users/new'; *3*
next();
} else {
next();
}
});
}
-
1 添加验证动作。
-
2 清理并检查输入字段数据。
-
3 收集错误,并使用闪存消息响应。
应用程序已准备好验证用户创建的数据。最后一步是将我的登录表单连接到之前设置的认证动作。
25.6. 使用 Passport.js 添加认证
Passport.js 通过提供一些默认方法作为请求中间件来简化我的生活。当我添加 passport-local-mongoose 时,我的用户模型继承了比 passport 单独提供的更多有用的方法。因为 passport-local-mongoose 模块被添加为用户模型的插件,所以很多认证设置都在幕后得到了处理。
register方法是passport提供最强大和直观的方法之一。为了使用它,我需要调用passport.register并传递我计划使用的登录策略。因为我使用的是默认的本地策略,我可以在 usersController.js 中创建我的authenticate动作来使用passport.authenticate方法,如代码列表 25.11 所示。
注意
我需要确保const passport = require("passport")位于我的用户控制器顶部。
这个动作直接指向passport.register方法。我已经在 main.js 中为我的User模型创建了一个本地策略,并告诉passport在认证成功时序列化和反序列化用户数据。我添加的选项决定了认证成功或失败时采取的路径,并附带闪存消息。
代码列表 25.11. 在 usersController.js 中添加authenticate动作
authenticate: passport.authenticate("local", { *1*
failureRedirect: "/users/login",
failureFlash: "Failed to login.",
successRedirect: "/",
successFlash: "Logged in!"
})
- 1 添加带有重定向和闪存消息选项的认证中间件。
我已经准备好测试在/users/login的登录表单进行认证。到目前为止,一切应该正常工作,以便将现有用户登录到应用程序中。我只需要对我的布局文件做一些最后的润色,并添加一个登出链接。
25.7. 登录和登出
我已经让登录过程工作正常。现在我想添加一些视觉指示来表明用户已登录。首先,我设置了一些变量,帮助我知道是否有未过期的会话用于已登录用户。为此,我在 main.js 中添加了代码列表 25.12 中的代码到我的自定义中间件中,其中添加了flashMessages局部变量。
通过这个中间件函数,我可以访问loggedIn来确定是否有账户通过发送请求的客户端登录。isAuthenticated告诉我是否有用户的活跃会话。如果该用户存在,currentUser被设置为已登录的用户。
代码列表 25.12. 通过中间件向响应中添加局部变量
res.locals.loggedIn = req.isAuthenticated(); *1*
res.locals.currentUser = req.user; *2*
-
1 设置 loggedIn 变量以反映 passport 登录状态。
-
2 设置 currentUser 变量以反映已登录用户。
现在,我可以通过在布局中的导航栏添加代码列表 25.13 中的代码来使用这些变量。我检查loggedIn是否为true,这告诉我用户已登录。如果是这样,我显示指向该用户show页面的currentUser的fullName和登出链接。否则,我显示一个登录链接。
代码列表 25.13. 在 layout.ejs 的导航栏中添加登录状态
<div class="login">
<% if (loggedIn) { %> *1*
<p>Logged in as
<a href="<%=`/users/${currentUser._id}`%>">
<%= currentUser.fullName %></a>
<a href="/users/logout">Log out</a>
</p> *2*
<%} else {%>
<a href="/users/login">Log In</a>
<% } %>
</div>
-
1 检查用户是否已登录。
-
2 显示当前用户的名字和登出链接。
最后,由于我的/users/logout路由已经就绪,我需要在usersController中添加logout操作,如列表 25.14 所示。这个操作使用传入请求上的logout方法。这个由passport提供的方法清除了活动用户的会话。当我重定向到主页时,没有currentUser存在,现有用户成功注销。然后我调用下一个中间件函数来显示主页。
列表 25.14. 向 usersController.js 添加注销操作
logout: (req, res, next) => {
req.logout(); *1*
req.flash("success", "You have been logged out!");
res.locals.redirect = "/";
next();
}
- 1 添加一个注销用户操作的函数。
在这个最后的部分工作完成后,我可以告诉 Confetti Cuisine 的联系人开始宣传用户账户。当他们成功登录时,屏幕将看起来像图 25.1。我坚信注册和登录过程比之前更安全、更可靠、更直观。
图 25.1. 在 Confetti Cuisine 上成功登录

摘要
在这个综合练习中,我通过添加几个包来增强 Confetti Cuisine 应用程序的安全性,并使其对用户更加透明。安装了会话和 cookie 后,我能够使用passport和connect-flash等包在服务器和客户端之间共享有关用户与 Confetti Cuisine 应用程序交互的信息。我为用户密码添加了加密,并在User模型上通过passport-local-mongoose插件设置了两个新的用户属性。通过更严格的验证,我的自定义验证操作作为中间件来过滤不必要的数据,并确保表单数据符合我的模式要求。最后,在实现了身份验证之后,passport提供了一种跟踪哪些用户登录到我的应用程序的方法,使我能够为积极参与的注册用户提供特定内容。在下一个单元中,我将添加一些功能以在应用程序内搜索内容,并在这个过程中在服务器上构建一个 API。
第 6 单元. 构建一个 API
在第 5 单元中,你添加了一些新功能,以允许用户安全地登录到你的应用程序。这一添加使你能够开始区分你只想向已登录用户显示的内容,而不是向公众显示。毕竟,你可能希望用户只能删除他们自己的内容,而不是他人的。这些改进增加了用户与浏览器交互的可能性。然而,互联网浏览器只是可能想要与你的数据交互的许多客户端类型之一。
在本课中,我将讨论如何更好地利用你的应用程序编程接口(API)。API 是客户端与你的应用程序数据交互的方法。目前,这种交互是通过渲染的 HTML 页面进行的,仅限于网络客户端,尽管你可能希望修改你的控制器操作以响应不同类型的请求,并使用相同数据的不同格式。你可以通过 XML 或 JSON 使用其他数据格式。你可能希望在用户编辑页面内访问课程列表,而无需切换视图,例如。也许你在编辑表单中有未保存的内容,你希望快速查看课程列表,而无需更新你的用户数据。
在第一课中,你设置了一个基本的 API,使用 RESTful 路由以 JSON 格式响应课程列表。然后你使用客户端 JavaScript 在屏幕上显示数据。在本单元结束时,你将对 API 应用一些安全屏障,以防止不想要的请求访问你的数据库。
本单元涵盖了以下主题:
-
第 26 课介绍了在技术行业中 API 的使用方式以及响应不同数据格式的方法。在本课中,你为更易于维护的 API 组织路由,并使用查询参数来确定响应的数据类型。
-
第 27 课展示了如何通过客户端 JavaScript 使用 AJAX 在无需刷新页面的情况下加载数据。在本课中,你创建了一个新的路由,并处理对
/api命名空间的传入请求。 -
第 28 课指导你了解在没有视觉登录方式的情况下如何保护你的 API 的基本方法。
第 29 课通过提供你需要执行的步骤来总结本单元,这些步骤用于从用户个人资料页面发送 AJAX 请求以加载 Confetti Cuisine 课程数据。然后你可以在不离开个人资料页面的情况下让用户注册。
第 26 课. 向你的应用程序添加 API
在本节课中,你首先查看重新组织你的路由结构并响应数据。首先,你创建新的文件夹来存放 main.js 中构建的路由。新的结构遵循你在之前课程中设置的一些应用程序编程接口(API)约定。接下来,你修改一些控制器操作以根据查询参数响应嵌入式 JavaScript(EJS)和 JSON。最后,你通过从客户端 JavaScript 创建 Ajax GET 请求来测试你的新 API 连接。
本节课涵盖
-
使用命名空间组织路由
-
创建响应 JSON 的 API 端点
-
从视图中发送 Ajax 请求
考虑这一点
你的食谱应用程序渲染许多页面,并在每个页面上提供特定的功能。为了使用户体验不那么复杂,你希望允许用户从他们的个人资料页面查看可用的程序。为此,你决定有条件地以 JSON 格式提供数据,并通过 JavaScript 和 HTML 在客户端显示这些数据。当你修改控制器操作时,你的应用程序可以提供超出服务网页的 API。
26.1. 组织你的路由
随着你的应用程序的增长,main.js 文件中的路由开始压倒其他中间件和配置。路由是应用程序的重要组成部分,以一种多开发者可以管理和理解的方式组织你的路由同样重要。
为了开始本节课,你将你设置的易于遵循的目录结构中的路由结构分解。在第 4 单元和第 5 单元中,你创建了路由以反映 REST 架构中的 CRUD 功能。表示性状态转移(REST)是一种编程方式,用于表示应用程序资源在互联网上的参与。你的应用程序的资源是存储在数据库中并在视图中显示的用户、订阅者和课程。你通过构建包含模型名称、HTTP 方法、执行的操作以及必要时模型 ID 的路由来实现 RESTful 结构。router.get("users/:id/edit", usersController .edit)告诉你,例如,有一个 HTTP GET 请求发送到了 u``s``ers/:id/edit 路径。
这些路由使用户能够确切地知道需要哪些信息才能获取他们想要看到的数据——在这种情况下,是现有用户的编辑表单。仅从路径本身,你就知道你正在尝试编辑特定的用户记录。从那里,你可以连接到相应的操作并重定向到另一个 RESTful 路由。
注意
重定向通常是在数据库中创建或更新信息时的次要操作。在到达修改数据的初始控制器操作后,你将重定向到另一个路由,将用户发送到另一个页面以查看。
在本节中,您将您的路由重新组织成单独的模块,以反映它们所使用的模型。当您决定扩展应用程序中使用的路由和响应数据类型时,这种结构将非常有用。
首先在项目根目录下创建一个名为 routes 的新文件夹,并在该文件夹中创建以下新模块:
-
userRoutes.js
-
courseRoutes.js
-
subscriberRoutes.js
-
errorRoutes.js
-
homeRoutes.js
-
index.js
这六个模块将划分 main.js 中当前的路由。目前,请专注于用户路由。
首先在模块顶部导入 Express.js 的Router和usersController。然后导入登录路由和 CRUD 路由,并将它们添加到本地的router对象中。这样做允许这些路由由同一个路由器处理。将所有工作路由附加到router后,您可以导出路由对象。注意在这个例子中,您省略了users路径。您将在 index.js 中稍后定义路径的这一部分。
将 main.js 中与用户相关的所有路由(CRUD 操作、登录和认证)复制到 userRoutes.js 中,具体内容见下文。
列表 26.1. 将用户路由移动到 userRoutes.js
const router = require("express").Router(),
usersController = require("../controllers/usersController"); *1*
router.get("/", usersController.index,
usersController.indexView); *2*
router.get("/new", usersController.new);
router.post("/create", usersController.validate,
usersController.create, usersController.redirectView);
router.get("/login", usersController.login); *3*
router.post("/login", usersController.authenticate);
router.get("/logout", usersController.logout,
usersController.redirectView);
router.get("/:id/edit", usersController.edit);
router.put("/:id/update", usersController.update,
usersController.redirectView);
router.get("/:id", usersController.show,
usersController.showView);
router.delete("/:id/delete", usersController.delete,
usersController.redirectView);
module.exports = router; *4*
-
1 导入 Express.js Router 和 users 控制器。
-
2 添加 CRUD 路由。
-
3 添加登录和认证路由。
-
4 导出模块路由。
命名空间
命名空间是一种在特定字符串或路径的伞状下定义路由、路径和其他应用程序项的方法。您不必定义具有相同路径前缀(/users)的数十个路由,而是可以将该前缀作为这些路由的命名空间。
命名空间特别有助于根据返回内容格式在您的 API 中分离路由。例如,如果 iOS 应用程序想要访问您的食谱应用程序中的数据,您可能会创建具有命名空间/ios的特定路由。然后您可以定义路径,如/ios/courses和/ios/subscribers。通过此命名空间下定义的路由,iOS 应用程序可以访问数据。
对于其他路由文件,遵循相同的策略。订阅者路由放在 subscriberRoutes.js 中,错误路由放在 errorRoutes.js 中。
index.js 模块要求所有路由模块都在一个地方。这种约定使得在一个文件中识别所有路由类型变得更容易,并且只需要一个文件导入到 main.js 中。与路由模块一样,您在 index.js 中也需要导入 Express.js 的Router。接下来,导入每个相对路由模块。添加了这些模块后,告诉本地的router对象使用这些路由和特定的命名空间。
对于主页和错误路由,不需要命名空间。通过为 26.1 列表 中定义的用户路由添加 /users 命名空间,你将返回到路由的原有功能。最后一步是在 main.js 中引入此 index.js 模块。在 main.js 的顶部添加 const router = require("./routes/index"),并在你的中间件函数之后添加 app.use("/", router)。
为了将这些路由与你的应用程序使用的相同路由器绑定,请将下一列表中的代码添加到 index.js 中。
列表 26.2. 将所有路由导入 index.js
const router = require("express").Router(), *1*
userRoutes = require("./userRoutes"), *2*
subscriberRoutes = require("./subscriberRoutes"),
courseRoutes = require("./courseRoutes"),
errorRoutes = require("./errorRoutes"),
homeRoutes = require("./homeRoutes");
router.use("/users", userRoutes); *3*
router.use("/subscribers", subscriberRoutes);
router.use("/courses", courseRoutes);
router.use("/", homeRoutes);
router.use("/", errorRoutes);
module.exports = router; *4*
-
1 需要 Express.js 路由器。
-
2 需要同一目录内的所有路由模块。
-
3 使用带有命名空间的相对路由模块中的路由。
-
4 从 index.js 导出路由器。
注意
顺序很重要。确保将更详细的路由放在 index.js 的顶部附近。否则,错误路由将在它们能够到达你打算到达的路由之前处理所有传入的请求。
Express.js 的 router 对象通过中间件操作。在其内部,你可以定义你希望在传入请求上执行的具体任务。在这种情况下,你正在使用 router 在不同的命名空间下加载路由。与其他中间件一样,如果你想将路由器中间件添加到主应用程序的中间件流程中,你需要使用 app.use 添加它。在 main.js 中,删除所有控制器的 require 语句,以及 express.Router() 的 require 语句。main.js 中的其余中间件用于 app 对象。
注意
重要的是要将 main.js 中剩余的所有中间件更改为由 app 使用而不是 router,因为你在请求到达文件底部的路由之前,希望 app 解析请求并使用你的模板引擎。中间件的顺序很重要!
重新启动你的应用程序,并确认应用程序的原有功能保持完好。如果你遇到任何错误,或者某些路由找不到,请确保所有路由命名空间都已正确定义,并且已从原始路径中删除资源名称前缀。在新命名空间下,例如,你的用户索引路由应读取 router.get("/", usersController.index, usersController.indexView) 而不是 router.get("/users", usersController.index, usersController.indexView)。
在下一节中,你将学习如何使用现有的路由来返回两种数据格式。
快速检查 26.1
Q1:
你为什么在 main.js 中添加
app.use("/", router)?
QC 26.1 答案
1:
当路由在 main.js 中定义时,你需要告诉 Express.js 应用程序将其用作中间件。
26.2. 创建 API
API 是在你的应用程序内设置的结构,允许外部来源访问你的应用程序数据。实际上,通过创建 Express.js 网络服务器,你已经构建了一个 API。通过提供 HTML 和 EJS,你已经提供了一条途径,让应用程序的用户可以通过网络浏览器访问你的数据:网页浏览器。然而,并非每个用户都希望通过你应用的网页浏览器以特定的样式和格式查看应用程序数据。
将你当前的 Express.js 应用程序想象成一家餐厅的菜单。大多数人可能会参考打印的菜单来了解餐厅提供的食物项目。获取硬拷贝菜单需要亲自前往餐厅。通过提供电话号码以查询菜单项目以及显示餐厅菜单的网站,你为顾客提供了更多获取所需信息的选择。同样,一个强大的 API 提供了不同格式的应用程序数据,你可以通过不同的方式访问这些数据。
在本节中,你将重构一些应用程序的路由和动作,以便除了渲染 EJS 视图外,还能以 JSON 格式响应数据。在 Express.js 中,以 JSON 格式响应数据很简单。将 coursesController.js 中 indexView 动作的 res.render("courses/index") 行更改为 res.json(res.locals.courses)。当你重新启动应用程序并访问 http://localhost:3000/courses 时,你的浏览器应该以 JSON 格式显示数据库中的所有课程(图 26.1)。
图 26.1. 浏览器中 JSON 课程结果的显示

此输出应类似于你在新终端窗口中运行 mongo 命令时的 MongoDB 服务器输出:use recipe_db 和 db.courses.find({}),如图 26.2 所示。运行这些命令启动 MongoDB 环境,并列出你的食谱数据库中的所有课程。在应用程序中,你实际上是在浏览器中显示完整的数据库文档。
图 26.2. MongoDB 中的课程显示

你可以通过仅响应 JSON 格式来进一步改进索引动作。你可以用多种方式完成这项任务。一种方式是使用查询参数。在这段代码中,你检查了 format 查询参数。如果它存在且等于 json,则以 JSON 格式响应课程数据。否则,像往常一样响应渲染的 EJS 视图。将 courses indexView 动作更改为下一列表中的代码。
列表 26.3. 在 usersController.js 中存在查询参数时响应 JSON
indexView: (req, res) => {
if (req.query.format === "json") {
res.json(res.locals.courses); *1*
} else {
res.render("courses/index"); *2*
}
}
-
1 如果格式查询参数等于 json,则响应 JSON 格式。
-
2 如果格式查询参数不等于 json,则响应 EJS 视图。
重新启动你的应用程序,并访问 http://localhost:3000/courses 以确保你的原始 EJS index 视图仍然在渲染。要显示 JSON 数据而不是正常视图,请将 ?format=json 追加到 URL 的末尾:访问 http://localhost:3000/courses?format=json。这个额外的查询参数告诉你的课程控制器以 JSON 格式而不是 EJS 渲染数据。
实施这一变化后,如果外部应用程序想要访问课程列表,它可以向带有查询参数的 URL 发送请求。尽管如此,外部应用程序只是可以从这一实现中受益的一组消费者之一。你可以在自己的应用程序中以多种方式使用此数据端点。(API 端点 是对接受网络请求的一个或多个应用程序路径的引用。)
快速检查 26.2
Q1:
你在响应上使用什么方法来以 JSON 格式将数据发送回客户端?
| |
QC 26.2 答案
1:
在 Express.js 中,你可以使用
res.json后跟你想要以 JSON 格式发送的参数。
26.3. 从客户端调用你的 API
在餐厅的类比中,菜单项可以通过不同的媒体提供:印刷、电话或网络。这种多样性使得顾客更容易了解餐厅提供的食物,同时也可能使餐厅工作人员更快地访问菜单项。毕竟,在繁忙的夜晚打开网页是找到菜单的一个方便的替代方案。
在你的应用程序的许多地方,你可以从返回 JSON 数据的应用程序路由中受益。主要的好处是,你可以通过客户端向不希望刷新的页面发送 Ajax 请求来受益。例如,如果你想让用户能够在不改变他们当前页面的情况下查看课程列表,那会怎么样?
通过 Ajax 请求用课程数据填充一个 模态(一个覆盖主浏览器屏幕的带有某些说明或内容的窗口)来实现解决方案。首先,在 views/courses 文件夹中创建一个名为 _coursesModal.ejs 的部分视图。使用如以下列表所示的一个简单的 Bootstrap 模态。
在此模态中,你有一个触发模态出现的按钮。该模态有一个带有 modal-body 类的标签。针对此类填充课程数据。
列表 26.4. 在 _coursesModel.ejs 中的简单 Bootstrap 模态
<button id="modal-button" type="button" data-toggle="modal"
data-target="#myModal">Latest Courses</button>
<div id="myModal" class="modal fade" role="dialog">
<div class="modal-dialog">
<div class="modal-body"> *1*
</div>
<div class="modal-footer">
<button type="button" data-dismiss="modal">Close</button>
</div>
</div>
</div>
- 1 添加一个你将填充 modal-body 的模态。
在你的 layout.ejs 文件中包含这个部分视图,这样你就可以通过添加 <li><%- include courses/_coursesModal %></li> 作为布局导航中的一个项目,在任何应用位置访问它。为了使这个模态框正常工作,你还需要有 bootstrap 客户端 JavaScript 以及 jQuery。你可以在 ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js 获取 jQuery.min.js 的压缩代码,并在 maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js 获取 bootstrap.min.js。
注意
我建议复制这个内容交付网络中的代码,并将其保存为与 public/js 中相同名称的本地文件。
然后,在 layout.ejs 中,链接到以下列表中的 JavaScript 文件。
列表 26.5. 将 jquery 和 bootstrap 导入 layout.ejs
<script type="text/javascript" src="/js/jquery.min.js"></script>
<script type="text/javascript" src="/js/bootstrap.min.js"></script> *1*
- 1 添加来自 public/js 的本地 JavaScript 文件。
通过一些样式更改,你可以重新启动你的应用程序。你应该在你的顶部导航栏中看到一个按钮,点击它将打开一个模态框,如图 图 26.3 所示。
图 26.3. 导航栏中的简单模态按钮

为了给这个模态框提供一些数据,在你的 public 文件夹的 js 文件夹中创建 recipeApp.js。这个 JavaScript 文件将在客户端运行。确保在 layout .ejs 文件中将此文件链接到 bootstrap 和 jQuery 下方,通过添加 <script type="text/javascript" src="/js/recipeApp.js"></script>。
在 recipeApp.js 中,添加 列表 26.6 中的代码。你将代码块包裹在 $(document) .ready 中,以确保在 DOM 加载并准备好之前不执行任何 JavaScript。然后你添加一个点击监听器到 modal-button ID。当在导航栏中点击该按钮时,执行一个到 /courses?format=json 路径的 Ajax GET 请求。通过添加查询参数,你期望响应包含作为 JSON 数组的数据。然后你遍历该数组以访问单个课程记录,并使用 $(".modal-body").append 为每个课程的标题和描述添加一些 HTML。
列表 26.6. recipeApp.js 中的 Ajax 函数用于在模态框中加载数据
$(document).ready(() => { *1*
$("#modal-button").click(() => { *2*
$(".modal-body").html(''); *3*
$.get("/courses?format=json", (data) => { *4*
data.forEach((course) => { *5*
$(".modal-body").append(
`<div>
<span class="course-title">
${course.title}
</span>
<div class="course-description">
${course.description}
</div>
</div>` *6*
);
});
});
});
});
-
1 等待 DOM 加载。
-
2 监听模态按钮的点击事件。
-
3 清除模态框中的任何先前内容。
-
4 异步从 /courses?format=json 请求数据。
-
5 遍历响应中的数据数组。
-
6 将每个课程添加到模态框中。
在此 Ajax 请求设置完成后,重新启动应用程序并将课程数据加载到模态框中。点击模态按钮从服务器获取新数据,如图 图 26.4 所示。
图 26.4. 在模态框中填充课程数据

现在,用户可以从任何页面查看课程列表。即使数据库中添加了新的课程,点击模态按钮也会获取新的列表。
Ajax
异步 JavaScript 和 XML (Ajax)是一种允许客户端异步请求而不干扰应用程序页面任何行为或显示的技术。Ajax 使用 JSON 和 XML 来格式化数据和要求发送到服务器。通过仅管理浏览器中应用程序的数据层,Ajax 允许你异步发送请求并通过回调函数处理结果响应中的数据。
由于 Ajax 与后端服务器交互的方式无需重新加载你的网页,它被广泛用于实时动态更新内容。通过多个 Ajax 请求,理论上网页可能永远不需要重新加载。
| |
快速检查 26.3
Q1:
当你进行 Ajax 请求时,如果数据库中没有课程,你期望会发生什么?
| |
QC 26.3 答案
1:
Ajax 请求从数据库返回一个项目数组。如果没有记录,响应将包含一个空数组。
总结
在本课中,你学习了如何修改你的应用程序路由结构以腾出空间用于广泛的 API。首先,你将路由重新组织成单独的模块。接下来,你添加了一种从控制器操作中响应 JSON 数据的方式。最后,你添加了客户端 JavaScript,以便在视图中从服务器进行异步请求。在第 27 课中,你将进一步探索命名空间,并了解如何从模态本身注册用户到课程中。
尝试这个
通过修改一个响应 JSON 数据的操作,尝试将相同的技巧应用到其他操作上。首先,将查询参数条件添加到其他模型索引操作中;然后实现它用于显示操作。
请记住,show操作返回单个记录,而不是数组。
第 27 课. 从你的应用程序访问你的 API
在本课中,你通过添加 API 命名空间来改变访问 JSON 格式化数据的方式。然后你修改 AJAX 函数,允许用户直接从模态中注册课程。最后,你创建了一个通过新路由链接用户和课程的操作。
本课涵盖
-
创建 API 命名空间
-
构建用于异步获取数据的 UI 模态
-
使用 MongoDB 方法连接模型
考虑这个
用户现在可以从应用程序的任何页面查看课程列表,但他们想做的不仅仅是查看这个列表。通过 Ajax 请求,你不仅可以异步将数据拉入页面,还可以执行其他操作,例如创建新记录和编辑现有记录。
在本课中,你将探索如何更好地使用你的 API 以及 AJAX 如何能帮助你。
27.1. 应用 API 命名空间
我在 第 26 课 中讨论了命名空间。现在你将实现一个用于返回 JSON 数据或异步执行操作的 API 端点的命名空间。要开始,在你的 routes 文件夹中创建一个新的路由模块,名为 apiRoutes.js。此模块将包含所有具有 JSON 响应体的 API 路由。通过添加 const apiRoutes = require("./apiRoutes") 在 index.js 中引入此新模块。然后告诉你的路由器在 api 命名空间下使用此模块,使用 router.use("/api", apiRoutes)。
注意
你必须将此新路由添加到主页和错误路由之上。这些路由针对 / 命名空间,这意味着任何在到达错误或主页路由之前不匹配路由名称的 URL 都默认为错误页面。
创建你的第一个路由,并将其指向 coursesController.js。将 列表 27.1 中的代码添加到 apiRoutes.js 中。在 ../controllers/coursesController 中引入 Express.js 路由器以及你的课程控制器。然后将 GET 请求指向 /courses 路径到 coursesController.js 的 index 动作,并导出路由器,然后是 respondJSON。与其他错误处理中间件一样,告诉此路由器如果早期动作没有返回响应,则使用 errorJSON。
注意
如果一个动作没有明确响应客户端,连接仍然打开,请求会继续通过中间件函数链流动。通常,这种情况意味着发生了错误,并且该错误会传播,直到错误处理中间件捕获它。
列表 27.1. 在 apiRoutes.js 中添加一个路由以显示所有课程
const router = require("express").Router(),
coursesController =
require("../controllers/coursesController"); *1*
router.get("/courses", coursesController.index,
coursesController.respondJSON); *2*
router.use(coursesController.errorJSON); *3*
module.exports = router;
-
1 引入课程控制器。
-
2 将 API 路由添加到 Express.js 路由器。
-
3 添加 API 错误处理中间件。
要使此代码生效,请在 courses-Controller.js 中创建 respondJSON 和 errorJSON 动作。将 列表 27.2 中的代码添加到该动作的课程控制器中。
coursesController.js 中的 index 动作已经将 courses 添加到响应的 locals 对象中。取这个 locals 对象并以 JSON 格式显示,而不是在 EJS 中渲染数据。如果在课程查询中发生错误,将错误传递给您的 e``r``rorJSON 动作。您正常的错误控制器动作只响应浏览器视图。如果发生错误,而不是重定向到另一个页面,以 500 状态码响应,表示发生了内部错误。
列表 27.2. 在 coursesController.js 中添加课程 JSON 响应
respondJSON: (req, res) => { *1*
res.json({
status: httpStatus.OK,
data: res.locals
}); *2*
},
errorJSON: (error, req, res, next) => { *3*
let errorObject;
if (error) {
errorObject = {
status: httpStatus.INTERNAL_SERVER_ERROR,
message: error.message
};
} else {
errorObject = {
status: httpStatus.INTERNAL_SERVER_ERROR,
message: "Unknown Error."
};
}
res.json(errorObject);
},
-
1 处理来自先前中间件的请求并提交响应。
-
2 以 JSON 格式响应响应的本地数据。
-
3 以 JSON 格式响应 500 状态码和错误消息。
注意
你还需要在 coursesController.js 的顶部添加 const httpStatus = require("http-status-codes")。
重新启动你的应用程序,并在浏览器中访问 http://localhost:3000/api/courses 以查看 JSON 格式的课程数据。将这些路由和控制器与你的 web 应用程序的路由和控制器分开,可以防止你将来犯错误。就现状而言,你总是希望在访问 /courses 时渲染 EJS 或重定向,并且你总是期望从 /api/courses 获取 JSON 响应。
在此新的 API 命名空间、路由和控制器动作就绪后,将 recipeApp.js 中的 AJAX GET 请求更改为调用 /api/courses 而不是 /courses?format=json。然后从你的课程 indexView 动作中删除检查 format 查询参数的条件块。重新启动你的应用程序,并检查你是否仍然可以在模态框中加载课程数据。
此外,因为你现在返回的数据被包含在另一个包含你的状态码的 JavaScript 对象中,你需要修改你的 AJAX 调用来正确处理返回的数据。如下一列表所示,更改 recipeApp.js 中的 AJAX 调用。
列表 27.3. 修改 recipeApp.js 中的 AJAX 调用
$.get("/api/courses", (results = {}) => {
let data = results.data; *1*
if (!data || !data.courses) return; *2*
data.courses.forEach((course) => { *3*
$(".modal-body").append(
`<div>
<span class="course-title">
${course.title}
</span>
<div class='course-description'>
${course.description}
</div>
</div>`
);
});
});
-
1 设置一个局部变量来表示数据。
-
2 检查数据对象是否包含课程信息。
-
3 遍历课程数据,并将元素添加到模态框中。
重新启动你的应用程序,并点击模态框按钮以查看功能是否与上一节相同。
在下一节中,你将添加更多功能到模态框,允许用户加入课程。
快速检查 27.1
Q1:
你为什么为 API 控制器创建一个新的文件夹?
QC 27.1 答案
1:
为 API 控制器和动作设置一个单独的文件夹可以使将应用程序分成两部分变得更加容易。应用程序的一部分提供具有视觉方面的数据,另一部分提供寻找原始数据的数据源。
27.2. 通过模态框加入课程
在模态框中列出课程是一项伟大的成就。在本节中,你通过允许用户通过模态框异步加入课程来进一步改进模态框。添加一个允许用户加入课程的按钮。通过 AJAX,你向一个 API 端点提交请求,控制器动作尝试将用户添加到课程中,并返回 JSON 格式的成功或失败消息。
首先,通过将 列表 27.4 中的 HTML 代码添加到 recipeApp.js 中原始 AJAX 调用生成的 HTML 的底部来添加加入课程的链接。这个按钮需要一个自定义类 join-button,并且可以放在模态框中的课程标题旁边。它还需要将 data-id 设置为 ${course._id},这样你就可以知道你选择了哪个课程列表。
注意
在这些情况下,HTML 中的数据属性很有帮助。你可以给每个按钮标记一个 data-id 属性,以便每个按钮的唯一 ID 与某个相应的课程 ID 匹配。
列表 27.4. 在 recipeApp.js 中添加加入课程的按钮
<button class="join-button" data-id="${course._id}">
Join
</button> *1*
- 1 添加一个带有 target-class join-button 的按钮以加入课程。
如果现在重新启动应用程序,您应该在每个课程项旁边看到一个按钮,如图 27.1 所示。尽管这些按钮目前还没有任何功能。
图 27.1. 添加加入按钮

要使这些按钮生效,请将 recipeApp.js 中的代码更改为使用 列表 27.5 中的代码。在这个例子中,您创建了一个名为 addJoinButtonListener 的函数,该函数为具有类 join-button 的每个按钮设置点击事件监听器。您需要在 AJAX 请求完成后立即调用此函数,因为您想在按钮在页面上创建后附加监听器。为此,将一个 then 块附加到 AJAX 请求上。
注意
AJAX 函数使用承诺(promises),因此你可以在请求的末尾链式调用 then 和 catch 块来在获取响应后运行代码。success 块的行为方式相同。
在 addJoinButtonListener 中,您获取点击的目标——按钮——然后提取您之前使用课程 ID 设置的数据 ID。有了这些信息,您可以对 /api/courses/:id/join 端点发起一个新的 AJAX GET 请求。为了使此请求生效,您需要确保用户已登录。此路由允许您通过使用课程 ID 来针对特定的课程进行加入。
处理该请求的路由和操作返回 JSON 值 success: true,如果您能够将用户添加到课程中。如果您成功了,通过添加新的 joined-button 类并移除旧的 join-button 类来更改按钮的文本和颜色,以指示用户已加入。这种类的交换允许您在 recipe_app.css 中使用不同的样式规则来样式化每个按钮,同时也防止点击事件触发另一个请求。如果您没有看到按钮颜色的变化,请确保您正在针对正确的按钮类。如果加入课程导致错误,请更改按钮的文本,告诉用户重试。
注意
变量 $button 前面只有 $ 来表示它代表一个 jQuery 对象。这种语法是风格和传统的,但不是必须的,以使您的代码生效。
列表 27.5. 在 recipeApp.js 中为每个按钮添加事件监听器
$(document).ready(() => {
$("#modal-button").click(() => {
$(".modal-body").html("");
$.get("/api/courses", (results = {}) => {
let data = results.data;
if (!data || !data.courses) return;
data.courses.forEach((course) => {
$(".modal-body").append(
`<div>
<span class="course-title">
${course.title}
</span>
<button class="join-button" data-id="${course._id}">
Join
</button>
<div class="course-description">
${course.description}
</div>
</div>`
);
});
}).then(() => {
addJoinButtonListener(); *1*
});
});
});
let addJoinButtonListener = () => { *2*
$(".join-button").click((event) => {
let $button = $(event.target),
courseId = $button.data("id"); *3*
$.get(`/api/courses/${courseId}/join`, (results = {}) => { *4*
let data = results.data;
if (data && data.success) { *5*
$button
.text("Joined")
.addClass("joined-button")
.removeClass("join-button");
} else {
$button.text("Try again");
}
});
});
}
-
1 在 AJAX 请求完成后,调用 addJoinButtonListener 方法为你的按钮添加事件监听器。
-
2 为模态按钮创建事件监听器。
-
3 获取按钮和按钮 ID 数据。
-
4 使用课程的 ID 发起一个 AJAX 请求以加入课程。
-
5 检查加入操作是否成功,并修改按钮。
现在您的应用程序已准备好在点击加入按钮时发送 AJAX 请求并处理其响应。在下一节中,您将创建处理此请求的 API 端点。
快速检查 27.2
Q1:
为什么需要在模态内容创建后调用
addJoinButtonListener函数?
| |
QC 27.2 答案
1:
addJoinButtonListener在模态内容中为特定类设置事件监听器。要设置监听器,您必须首先在模态中创建内容。
27.3. 创建一个 API 端点以连接模型
要完成课程模态,您需要创建一个路由来处理对当前用户加入课程的请求。为此,将 router.get("/courses/:id/join", courses-Controller.join, coursesController.respondJSON) 添加到 apiRoutes.js 中。此路由允许 get 请求通过 join 动作,并将结果传递给您的 respondJSON 动作,该动作返回给客户端。在 coursesController.js 的顶部,使用 const User = require("../models/user") 引入 User 模型。然后,在 coursesController.js 中,在 列表 27.6 中添加 join 动作。
在这个 join 动作中,您从 URL 参数中获取当前登录用户和课程的 ID。如果存在 currentUser,使用 Mongoose 的 findByIdAndUpdate 方法定位 user 对象,并更新其课程数组以包含目标课程 ID。在这里,您使用 MongoDB 的 $addToSet 方法,确保数组中没有重复的 ID。如果操作成功,向响应的 locals 对象中添加一个 success 属性,该对象随后传递给 respondJSON,再传递回客户端。如果用户未登录或在更新用户关联时发生错误,传递一个 error 给错误处理中间件进行处理。
列表 27.6. 在 coursesController.js 中创建加入课程的动作
join: (req, res, next) => { *1*
let courseId = req.params.id,
currentUser = req.user; *2*
if (currentUser) { *3*
User.findByIdAndUpdate(currentUser, {
$addToSet: {
courses: courseId *4*
}
})
.then(() => {
res.locals.success = true; *5*
next();
})
.catch(error => {
next(error); *6*
});
} else {
next(new Error("User must log in.")); *7*
}
}
-
1 添加加入动作以允许用户加入课程。
-
2 从请求中获取课程 ID 和当前用户。
-
3 检查当前用户是否已登录。
-
4 更新用户的课程字段以包含目标课程。
-
5 响应一个包含成功指示器的 JSON 对象。
-
6 响应一个包含错误指示器的 JSON 对象。
-
7 将错误传递给下一个中间件函数。
在此动作就绪后,重新启动您的应用程序,并在模态中尝试加入课程。如果您未登录,您可能会在按钮上看到“重试”文本。否则,根据您的自定义样式,您的按钮应该在每个按钮点击时变为绿色并更改文本,如图 27.2 所示。
图 27.2. 加入课程后的示例模态

您可以通过让用户知道他们是否已经加入了一个或多个课程来改善用户体验。
根据你的应用程序结构和模型模式,你可以通过将中间件函数 filterUserCourses 添加到 coursesController.js 中来过滤你的结果,如 列表 27.7 所示。在这段代码中,你在继续之前检查用户是否已登录。如果用户已登录,使用数组中的 map 函数。在此函数中,查看每个课程并检查其 _id 是否在登录用户的课程数组中。some 函数返回一个布尔值,让你知道是否发生匹配。例如,如果用户已加入 ID 为 5a98eee50e424815f0517ad1 的课程,则该 ID 应该存在于 currentUser.courses 中,并且该课程的 userJoined 值为 true。最后,将 courses Mongoose 文档对象转换为 JSON,以便使用 Object.assign 添加一个附加属性。这个属性 joined 让你在用户界面中知道用户是否以前加入过该课程。如果没有用户登录,调用 next 以传递未修改的课程结果。
列表 27.7. 在 coursesController.js 中添加过滤课程的动作
filterUserCourses: (req, res, next) => {
let currentUser = res.locals.currentUser;
if (currentUser) { *1*
let mappedCourses = res.locals.courses.map((course) => { *2*
let userJoined = currentUser.courses.some((userCourse) => {
return userCourse.equals(course._id); *3*
});
return Object.assign(course.toObject(), {joined: userJoined});
});
res.locals.courses = mappedCourses;
next();
} else {
next();
}
}
-
1 检查用户是否已登录。
-
2 修改课程数据以添加表示用户关联的标志。
-
3 检查课程是否存在于用户的课程数组中。
要使用此中间件函数,你需要在返回 JSON 响应之前将其添加到 /courses API 路由中。该路由看起来像 router.get("/courses", coursesController.index, coursesController.filterUserCourses, coursesController.respondJSON),其中 coursesController.filterUserCourses 位于 coursesController.index 中查询课程之后。
最后一步是将 recipeApp.js 中的客户端 JavaScript 修改为检查当前用户是否已经加入课程,并修改课程列表模态中的按钮。在 列表 27.8 中,你在按钮的类属性和主要文本内容中使用三元运算符。这些运算符检查课程数据的 joined 属性是 true 还是 false。如果是 true,则创建按钮以指示用户已经加入。否则,显示一个邀请用户加入的按钮。
列表 27.8. 在 recipeApp.js 中添加动态按钮样式
<button class='${course.joined ? "joined-button" : "join-button"}'
data-id="${course._id}"> *1*
${course.joined ? "Joined" : "Join"} *2*
</button>
-
1 添加适当的类以反映加入状态。
-
2 添加按钮文本以反映加入状态。
应用这些更改后,重新启动你的应用程序并登录。你的课程列表按钮的颜色和文本将正确反映数据库中关联的状态。
备注
如果你在维护登录账户时遇到问题,确保在初始化 passport 和自定义中间件之前使用会话和 cookies。
快速检查 27.3
Q1:
你为什么需要使用
findByIdAndUpdate方法?
QC 27.3 答案
1:
findByIdAndUpdateMongoose 方法结合了find和update方法,因此您可以方便地执行单个步骤来更新用户文档。
摘要
在本课中,您学习了如何修改命名空间结构以适应 JSON 数据响应的 API。您还通过允许用户在不更改页面的情况下加入特定课程来改进了您的课程模式。通过您创建的 AJAX 请求和 API 端点,您应用程序的功能可以更多地移动到单个页面,并远离每个动作的独立视图。在 第 28 课 中,我讨论了您可以用来保护您的 API 的一些方法。
尝试以下操作
在这个新 API 就位后,您可能希望为每个可能返回数据的路由创建端点。例如,您可能希望将 api 目录中的每个 index 和 show 动作添加到控制器中。
创建这些动作以及一个额外的创建用户的动作,并返回包含成功或失败确认的 JSON,而不是渲染视图。
第 28 课. 添加 API 安全性
在本课中,您将一些安全策略应用到您的 API 路由上。由于没有浏览器来存储 cookies,一些外部应用程序可能在没有验证用户身份的方式下难以使用您的 API。首先,您通过提供必须附加到每个请求的 API 令牌来实现一些基本安全措施。然后,您通过在账户创建时为每个用户生成唯一的 API 密钥来改进这一策略。最后,您探讨了 JSON Web Tokens (JWT),这是一种通过散列用户数据和交换令牌来验证用户账户的系统,而不需要浏览器。
本课涵盖
-
添加安全令牌验证中间件
-
创建
pre("save")钩子以生成 API 密钥 -
实现 JWT 头部认证
考虑以下内容
您为食谱应用程序构建了一个强大的 API。您的端点包括创建新用户和更新现有用户的路由。由于 API 端点可以从任何可以发出 HTTP 请求的设备访问,因此无法预测谁可能会在没有首先创建账户并在服务器上存储会话数据的情况下向您的 API 发出请求。
在您的 API 路由上实施某种形式的安全措施可以确保您的数据不会落入错误的手中。
28.1. 实现简单安全
单元 5 指导您完成了用户账户创建和身份验证的过程。借助几个包的帮助,您创建了一个全面的过程,用于验证和加密用户数据,并确保这些用户在访问某些页面之前已经通过身份验证。
即使没有外部包的帮助,你也可以采取一些简单的步骤来保护你的 API。在本课中,你将使用的第一种方法是生成一个 API 令牌,该令牌必须由访问你的 API 的用户使用。用户需要令牌,因为他们可能不是使用浏览器来访问 API,所以你当前的 Passport.js、cookies 和 session 实现可能无法与客户端一起工作。额外的令牌可以降低这种风险,确保只有使用有效令牌发起请求的用户才能看到数据。例如,你可以在 main.js 中添加app.set("token", process.env.TOKEN || "recipeT0k3n")。然后这个应用程序变量就会设置为你在TOKEN环境变量中使用的值,或者默认为recipeT0k3n。令牌可以通过使用app.get("token")来检索。
因为你想在apiRoutes模块中监控对 API 的传入请求,所以在 api 文件夹中的 usersController.js 中设置令牌为一个常量,使用const token = process.env.TOKEN || "recipeT0k3n"。这个令牌将由 usersController.js 中的中间件用于验证传入的 API 请求。通过在 usersController.js 中添加列表 28.1 中的代码来创建这个中间件函数。
这个中间件函数verifyToken检查一个名为apiToken的查询参数,该参数与之前设置的令牌匹配。如果令牌匹配,调用next以继续中间件链;否则,传递一个带有自定义消息的错误。这个错误会到达你的错误处理中间件,并以 JSON 格式显示消息。
列表 28.1. 在 usersController.js 中添加验证 API 令牌的中间件函数
verifyToken: (req, res, next) => { *1*
if (req.query.apiToken === token) next(); *2*
else next(new Error("Invalid API token.")); *3*
}
-
1 创建带有 next 参数的 verifyToken 中间件函数。
-
2 如果令牌匹配,调用下一个中间件函数。
-
3 如果令牌不匹配,则返回错误消息。
要添加usersController.verifyToken中间件,以便在处理每个 API 请求之前运行,你可以在 apiRoutes.js 中将router.use(usersController.verifyToken)作为第一个函数添加。你还需要通过在 apiRoutes.js 中添加const usersController = require("../controllers/usersController")来引入用户控制器。
重新启动你的应用程序,当你访问 http://localhost:3000/api/courses 时,请注意以下错误消息:{"status":500, "message":"Invalid API token."}。这是一个好兆头。这意味着你的 API 验证正在工作,因为你没有使用有效的 API 令牌发起请求。
要绕过此消息,请添加apiToken查询参数。访问 http://localhost:3000/api/courses?apiToken=recipeT0k3n 应该会以 JSON 格式显示原始课程数据。如果你选择以这种方式实现你的 API 安全,你需要将此令牌与你的信任用户共享。为了使你的 AJAX 请求正常工作,你还需要在 recipeApp.js 中将?apiToken=recipeT0k3n查询参数添加到那些 URL 中。
这个简单的安全屏障无疑是开始,但随着更多用户需要令牌来访问您的 API,它很快就会成为一个不可靠的系统。拥有相同令牌的访问用户越多,该令牌落入非用户手中的可能性就越大。当您快速构建需要薄层安全的应用程序时,这种方法可能足够用。然而,当应用程序上线时,您将希望修改 API 安全性,以独特的方式处理每个用户请求。
在下一节中,您将探索保持每个用户令牌唯一性的方法。
快速检查 28.1
Q1:
为什么您可能会在
process.env.TOKEN中存储一个秘密令牌?
QC 28.1 答案
1:
您可以将敏感或秘密数据存储在
process.env中作为环境变量。这些变量通常存储在服务器上,但不需要出现在代码中。这种做法使得直接在服务器上更改令牌变得更加容易(您不必每次都更改代码),并且这是一种更安全的约定。
28.2. 添加 API 令牌
您刚刚构建了一个中间件函数来验证 URL 中作为查询参数传递的 API 令牌。这种方法在保护您的 API 方面非常有效,但它不能阻止非用户获取唯一的令牌。
为了改进这个系统,为每个用户账户添加一个自定义令牌。通过在用户模式中添加一个新的 apiToken 字段(类型为 String)来实现。接下来,在 User 模型上构建一个 pre("save") 钩子,在账户创建时为该用户生成一个唯一的 API 令牌。在您看到代码之前,使用 Node.js 包来帮助生成令牌。
rand-token 包提供了一些创建所需长度的新字母数字令牌的简单工具。运行 npm install rand-token -S 在此项目中安装 rand-token 包,并在 user.js 中通过添加 const randToken = require ("rand-token") 来引入它。
将以下代码添加到 user.js 中。此代码首先检查用户的 -apiToken 字段是否已设置。如果没有,则使用 rand-Token.generate 生成一个新的唯一 16 位字符令牌。
列表 28.2. 在 user.js 中创建一个 pre("save") 钩子以生成 API 令牌
userSchema.pre("save", function(next) {
let user = this;
if (!user.apiToken) user.apiToken =
randToken.generate(16); *1*
next();
});
- 1 检查现有的 API 令牌,并使用 randToken.generate 生成一个新的。
注意
您可以通过比较生成的令牌与其他用户的令牌来改进这里的函数,以确保不会发生重复。
接下来,将 apiToken 字段添加到用户 show 页面表格中的项目。这样,当新用户访问他们的个人资料页面时,他们将能够访问他们的 API 令牌。例如,在 图 28.1 中,我的用户账户的令牌是 2plMh5yZMFULOzpx。
图 28.1. 在用户的显示页面上显示 API 令牌

要使用此令牌,您需要修改verifyToken中间件以将apiToken查询参数与数据库中的令牌进行比较。将/api/users-Controller.js 中的verifyToken更改为使用列表 28.3 中的代码。
在此修改后的中间件函数中,您将令牌作为查询参数获取。如果 URL 中出现了令牌,则在用户数据库中搜索具有该 API 令牌的单个用户。如果存在这样的用户,则继续到下一个中间件函数。如果没有用户具有该令牌,如果查询中发生错误,或者没有使用查询参数,则传递错误。
列表 28.3. 在 usersController.js 中改进令牌验证动作
verifyToken: (req, res, next) => {
let token = req.query.apiToken;
if (token) { *1*
User.findOne({ apiToken: token }) *2*
.then(user => {
if (user) next(); *3*
else next(new Error("Invalid API token."));
})
.catch(error => { *4*
next(new Error(error.message));
});
} else {
next(new Error("Invalid API token."));
}
}
-
1 检查是否存在作为查询参数的令牌。
-
2 搜索具有提供的 API 令牌的用户。
-
3 如果存在具有 API 令牌的用户,则调用 next。
-
4 将错误传递给错误处理器。
重新启动您的应用程序,并创建一个新的用户账户。访问该新用户的展示页面,并找到apiToken值。然后访问 http://localhost:3000/api/courses? api-Token=后跟该用户的 API 令牌。例如,jon@jonwexler.com用户将使用以下 URL:http://localhost:3000/api/courses?apiToken= 2plMh5yZMFULOzpx。您应该会看到与之前相同的课程列表。
这个新系统降低了所有用户使用单个 API 令牌的脆弱性。与用户账户关联的 API 令牌,您还可以验证数据库中的用户信息,并跟踪该用户 API 请求的数量或质量。要使客户端 JavaScript 在 API 调用中使用此令牌,您可以在 layout.ejs 中添加一个隐藏元素,包含当前用户的令牌。例如,您可以在块内添加<div id="apiToken" data-token="<%= currentUser.apiToken %>" style="display: none;">来检查用户是否已登录。然后,当 recipeApp.js 中的文档准备好时,您可以找到令牌,使用let apiToken = $(``"``#apiToken``"``).data (``"``token``"``),并在/api/courses?apiToken=${apiToken}上调用您的 Ajax 请求。
尽管如此,您还可以采取更安全的构建 API 认证的方法,其中不一定涉及网络浏览器。该方法使用 JSON Web 令牌(JWT)。
快速检查 28.2
Q1:
randToken.generate(16)做什么?
| |
QC 28.2 答案
1:
此方法生成一个随机的 16 位字母数字令牌。
28.3. 使用 JSON Web 令牌
你可以通过使用 cookies 来构建一个安全的 API,但 API 的功能仍然依赖于其客户端支持并存储这些 cookies。考虑一下,例如,有人编写了一个脚本,仅从他们的终端窗口向你的 API 发送请求。在这种情况下,如果你想在传入的请求上应用用户认证,你需要某种方式来跟踪哪些用户正在请求以及他们是否最近登录。没有可视化的登录页面,这项任务可能会很困难。你可以尝试一些替代解决方案,其中之一就是使用 JSON web tokens。
JSON web tokens (JWT) 是作为表示已认证用户请求的手段,在服务器和客户端之间传递的签名或加密数据。最终,JWTs 就像不同格式的会话,在 Web 通信中用法不同。你可以将 JWTs 视为在每次登录时重新生成的 API 令牌。JWTs 包含三个部分,如 表 28.1 中定义的。
表 28.1. JWT 的组成部分
| JWT 部分 | 描述 |
|---|---|
| --- | --- |
| 头部 | 一个 JSON 对象,详细说明了 JWT 中数据的准备和哈希方式。 |
| 有效载荷 | 存储在 JWT 中的数据,用于验证之前已认证的用户。有效载荷通常包括用户的 ID。 |
| 签名 | 使用头部和有效载荷值生成的哈希码。 |
提示
有效载荷越小,JWT 越小,每次响应发送的速度就越快。
这三个值共同提供了一个特定用户最近登录状态的独特数据排列。首先,用户发起请求并传递他们的电子邮件和密码。服务器响应一个编码的 JWT,以验证用户的正确登录信息。对于每个后续的用户请求,必须将相同的 JWT 发送回服务器。然后服务器通过解码其值并定位有效载荷中指定的用户来验证 JWT。与 Passport.js 和 bcrypt 中的密码加密不同,JWTs 不是通过哈希和加盐来加密的。JWTs 是编码的,这意味着服务器可以解码 JWT 来揭示其内容,而无需知道用户设置的某些秘密值。
在本节中,你将在 jsonwebtoken 包的帮助下应用 JWT API 安全性。通过在终端中运行 npm i jsonwebtoken -S 来安装 jsonwebtoken 包。由于你打算在 API 中使用 JWT 进行用户验证,请在 users-Controller.js 中使用 const jsonWebToken = require("jsonwebtoken") 引入 jsonwebtoken。
要使用 JWTs,你需要允许用户在没有浏览器的情况下登录。通过将 清单 28.4 中的代码添加到 usersController.js 中来创建一个新的 API 登录操作。
注意
你可以在 github.com/auth0/node-jsonwebtoken 上找到有关 jsonwebtoken 包的更多信息。
此操作使用你在第 24 课中设置的 Passport.js local策略。通过 authenticate 方法,验证用户电子邮件地址和密码是否与数据库中用户的匹配。然后,通过回调函数,如果找到具有匹配电子邮件和密码的用户,使用jsonWebToken.sign创建一个包含用户 ID 和设置为一日签发时间的过期日期的令牌。最后,返回一个包含成功标签和已签名的令牌的 JSON 对象;否则,返回错误消息。
列表 28.4. 在 usersController.js 中为 API 创建登录操作
apiAuthenticate: (req, res, next) => { *1*
passport.authenticate("local",(errors, user) => {
if (user) {
let signedToken = jsonWebToken.sign( *2*
{
data: user._id,
exp: new Date().setDate(new Date().getDate() + 1)
},
"secret_encoding_passphrase"
);
res.json({
success: true,
token: signedToken *3*
});
} else
res.json({
success: false,
message: "Could not authenticate user." *4*
});
})(req, res, next);
}
-
1 使用 passport.authenticate 方法进行身份验证。
-
2 如果存在与匹配的电子邮件和密码匹配的用户,则对 JWT 进行签名。
-
3 返回 JWT。
-
4 返回错误消息。
现在,此令牌可以用于 24 小时,以对受保护的 API 端点进行请求。
接下来,将以下POST路由添加到apiRoutes.js:router.post("'/login'", usersController.apiAuthenticate)。您可以通过向/api/login路由发送带有电子邮件和密码的POST请求来生成令牌,而无需浏览器。为此,在终端中运行 curl 命令,例如curl -d "email=jon@jonwexler.com&password=12345" http://localhost:3000/api/login。在此示例中,-d标志表示用户正在将他们的电子邮件和密码作为数据发送到提供的 URL。运行此命令后,您应该期望收到类似于下一个列表中响应的响应。
列表 28.5. 成功 JWT 身份验证的终端示例响应
{"success":true,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJkYXRhIjoiNTljOWNkN2VmNjU5YjMwMjk4YzkzMjY4IiwiZXhwIjox
NTA2NDk2NDMyODc5LCJpYXQiOjE1MDY0MTAwMzJ9.Gr7gPyodobTAXh1p
VuycIDxMEf9LyPsbrR4baorAbw0"} *1*
- 1 在身份验证后显示带有 JWT 的成功响应。
要保护所有 API 端点,添加一个验证传入 JWT 的操作,并将该中间件添加到每个 API 路由。将代码添加到列表 28.6 中的 usersController.js。
首先,从请求头中提取传入的令牌。然后,如果存在令牌,使用jsonWebToken.verify以及令牌和密钥短语来解码令牌并验证其真实性。以下回调提供了可能发生的任何错误,以及解码后的有效载荷。你可以检查有效载荷是否有值。如果有,从payload.data中提取用户的 ID,并查询具有该 ID 的用户。如果不存在这样的用户,该用户的账户可能已被删除,或者 JWT 可能已被篡改,因此返回错误消息。如果用户 ID 匹配,调用next并继续到 API 端点。这种通信方式会持续到令牌过期,用户创建新的 JWT。
列表 28.6. 在 usersController.js 中为 API 创建验证操作
verifyJWT: (req, res, next) => {
let token = req.headers.token; *1*
if (token) {
jsonWebToken.verify( *2*
token,
"secret_encoding_passphrase",
(errors, payload) => {
if (payload) {
User.findById(payload.data).then(user => { *3*
if (user) {
next(); *4*
} else {
res.status(httpStatus.FORBIDDEN).json({
error: true,
message: "No User account found."
});
}
});
} else {
res.status(httpStatus.UNAUTHORIZED).json({
error: true,
message: "Cannot verify API token." *5*
});
next();
}
}
);
} else {
res.status(httpStatus.UNAUTHORIZED).json({
error: true,
message: "Provide Token" *6*
});
}
}
-
1 从请求头中检索 JWT。
-
2 验证 JWT 并解码其有效载荷。
-
3 检查 JWT 有效载荷中解码的用户 ID 是否存在用户。
-
4 如果找到具有 JWT ID 的用户,则调用下一个中间件函数。
-
5 如果无法验证令牌,则返回错误信息。
-
6 如果请求头中没有找到令牌,则返回错误信息。
最后一步是将此verifyJWT中间件函数放置在处理任何 API 请求之前。在apiRoute.js中在login路由下方和所有其他路由上方添加router.use(usersController.verifyJWT)。这一步骤确保除了用于生成 JWT 的login路由外,每个路由都需要使用verifyJWT中间件。
备注
到此为止,你不再需要在用户模型上的令牌生成钩子或过去两种 API 安全技术的任何残留部分来使用 JWT。然而,你可能希望保留这些最近实施的 API 安全技术作为访问你的 API 的备用方案。要使这些安全方法协同工作,还需要做更多的工作。
你可以通过在终端中运行另一个 curl 命令并识别请求头中的令牌来测试你的 JWT。使用列表 28.5 中的令牌,该命令看起来像列表 28.7。
在此命令中,你使用-H标志来指示 JWT 的引号内的 header 键值对。通过发送请求并传递有效的 JWT,你应该能够访问应用程序的数据。
备注
你需要移除usersController.verifyToken操作以使这种新方法生效。否则,你的应用程序将同时寻找 JWT 头和apiToken。
列表 28.7. 在 usersController.js 中为 API 创建验证操作
curl -H "token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkY
XRhIjoiNTljOWNkN2VmNjU5YjMwMjk4YzkzMjY4IiwiZXhwIjoxNT
A2NDk2NDMyODc5LCJpYXQiOjE1MDY0MTAwMzJ9.Gr7gPyodobTAX
h1pVuycIDxMEf9LyPsbrR4baorAbw0" http://localhost:3000
/api/courses *1*
- 1 在头部发送 JWT 请求。
警告
你构建 API 以使用 JWT 的方式将干扰你已经在客户端 Ajax 请求中完成的工作。请将本节视为使用 JWT 的介绍,而不是替换迄今为止在食谱应用中实现的安全的必要替代品。
如果你的请求成功,你应该会看到与本课程第一部分的 JSON 相同的课程列表。如果你计划使用 JWT 来保护你的 API,你需要明确告诉你的 API 用户你期望他们如何进行身份验证和验证他们的令牌。一种方法是为用户提供一个额外的登录表单,用户可以在其中提交他们的电子邮件和密码以获取 API 令牌作为响应。该令牌可以像上一节中的随机令牌一样临时存储在用户模型中。
备注
使用 JWT 需要客户端以某种方式存储令牌。如果不能临时存储 JWT,则在登录时创建令牌后,将无法创建未来的请求。
JWT 可以帮助防止对应用程序数据的攻击并确保通过 API 的安全访问,但这需要更多步骤来实现。最终,你可能发现从更简单的方法开始更有意义,例如为每个用户生成随机令牌。
快速检查 28.3
Q1:
为什么你在请求头中传递 JWT?
QC 28.3 答案
1:
你可以在请求体中传递 JWT,但由于并非所有请求都是
POST,因此头部提供了一个更方便的位置。
摘要
在本课中,你学习了如何在你的 API 上实现三个安全令牌。第一种策略是一个简单的安全令牌,可以被所有客户端使用。第二种策略要求在创建用户时为每个用户生成一个新的随机令牌。在第三种方法中,你使用 JWT 提供最安全的选项来验证用户访问你的 API。在第 29 课(本单元的总结练习)中,你有机会构建一个具有本单元中介绍的一些功能的 API。
尝试这个
现在你有一些基本的安全选项可以选择,尝试创建更多需要 JWT 的 API 路由。你也可以排除某些路由,例如login路由,不需要令牌。从你的 API 安全中排除两个路由。
第 29 课。总结:实现 API
Confetti Cuisine 对用户与应用程序的互动赞不绝口。然而,为了鼓励更多用户报名他们的课程,他们希望我能在每个页面上添加更多数据。更具体地说,他们希望我在每个页面上包含一个列出提供的课程并链接到每个课程的模态。
为了完成这个任务,我将在客户端使用 Ajax 向我的应用服务器发送请求。通过在幕后对服务器进行异步调用,我无需在用户点击按钮报名之前加载课程数据。使用 Ajax 的这种改变应该有助于初始页面加载时间,并确保用户查看时课程数据是最新的。
首先,我需要修改我的应用布局视图,以包含包含我的模态的嵌入式 JavaScript (EJS)。接下来,我将创建客户端 JavaScript 代码以请求课程数据。为了使这些数据显示出来,我需要创建一个 API 端点以 JSON 格式响应课程数据。当这个端点工作后,我将添加一个动作来处理用户报名课程,并在完成后返回 JSON。这个端点将允许用户从任何页面报名课程,而无需离开或刷新他们所在的页面。
在我开始之前,我将重构我的路由,为我的新 API 端点铺平道路。
29.1. 重构路由
要开始改进应用程序,我将我的路由移动到它们自己的模块中,以清理我的主应用程序文件。随着应用程序的增长,路由也会增加。我希望这个项目的未来开发者能够轻松地找到他们需要的路由。因为我的每个模型资源的路由已经符合 RESTful——这意味着路由路径考虑了我的应用程序的模型和 CRUD 函数——所以重构过程要简单得多。我的新应用程序结构将根据控制器名称来分离我的路由,如图 29.1 所示。
图 29.1. 带有路由文件夹的应用程序结构

首先,我在应用程序目录的根级别创建一个新的路由文件夹。在这个文件夹内,我创建了三个模块来存放我模型各自的路由:
-
userRoutes.js
-
courseRoutes.js
-
subscriberRoutes.js
接下来,我将所有用户路由从main.js移动到userRoutes.js。这个新的路由文件类似于列表 29.1 中的代码。
注意
我还将我的主页和错误路由移动到它们自己的主页:Routes.js 和 errorRoutes.js,分别。
在这个文件的顶部,我导入了 Express.js 路由器和 usersController.js。这两个模块允许我在整个应用程序中将路由附加到同一个对象上,并将这些路由链接到用户控制器的操作。然后,我为用户应用了get、post、put和delete路由,包括 CRUD 操作的路线以及登录和登出的路由。在我继续之前,我将路由路径中的所有users文本删除。相反,我将在稍后应用这些路由到users命名空间下。这些路由绑定到router对象上,我通过这个模块导出它,使其在项目中的其他模块可用。
列表 29.1. 用户路由在 userRoutes.js 中
const router = require("express").Router(), *1*
usersController = require("../controllers/usersController");
router.get("/", usersController.index,
usersController.indexView); *2*
router.get("/new", usersController.new);
router.post("/create", usersController.validate,
usersController.create, usersController.redirectView);
router.get("/login", usersController.login);
router.post("/login", usersController.authenticate);
router.get("/logout", usersController.logout,
usersController.redirectView);
router.get("/:id/edit", usersController.edit);
router.put("/:id/update", usersController.update,
usersController.redirectView);
router.get("/:id", usersController.show,
usersController.showView);
router.delete("/:id/delete", usersController.delete,
usersController.redirectView);
module.exports = router; *3*
-
1 需要 Express.js 路由器和 usersController。
-
2 在路由器对象上定义用户路由。
-
3 从模块中导出路由器对象。
然后,我将相同的策略应用到其他模型路由上,并在每个模块中导出router对象。导出router对象允许任何其他模块导入这些路由。我的路由组织得更好,每个模块只需要使用它需要的控制器。为了在main.js中访问这些路由,我在路由文件夹中创建了一个名为index.js的新文件。这个文件导入了所有相关的路由,以便它们可以在一个地方访问。然后,我将在main.js中导入index.js。
注意
main.js中剩余的所有中间件都应该应用到app.use,并且不再使用router。
我首先引入 Express.js 路由以及所有我的路由模块。在这个例子中,我包括模型路由和错误路由以及我的主页控制器路由。router.use告诉我的路由器使用第一个参数作为命名空间,第二个参数作为特定于该命名空间的路由模块。在文件末尾,我导出我的router对象,它现在包含所有之前定义的路由。index.js 中的代码在下一个列表中显示。
列表 29.2. index.js 中的所有路由
const router = require("express").Router(), *1*
userRoutes = require("./userRoutes"),
subscriberRoutes = require("./subscriberRoutes"),
courseRoutes = require("./courseRoutes"),
errorRoutes = require("./errorRoutes"),
homeRoutes = require("./homeRoutes");
router.use("/users", userRoutes); *2*
router.use("/subscribers", subscriberRoutes);
router.use("/courses", courseRoutes);
router.use("/", homeRoutes);
router.use("/", errorRoutes);
module.exports = router; *3*
-
1 引入 Express.js 路由和路由模块。
-
2 为每个路由模块定义命名空间。
-
3 导出完整的路由对象。
在重新组织这些路由后,我仍然可以通过/courses和/courses/:id路径分别访问我的课程索引和单个课程。因为我的路由更加有序,我有空间引入新的路由模块而不会使我的代码结构复杂化。为了将这些路由导入到应用程序中,我需要在 main.js 的顶部使用const router = require("./routes/index")来引入 index.js。这个router对象替换了之前的那个。然后我告诉 Express.js 应用程序以与我之前告诉路由器使用先前定义的路由相同的方式使用这个路由器,确保app.use("/", router)在 main.js 中。
注意
我还需要从 main.js 中删除所有控制器的 require 行,因为它们在该模块中不再被引用。
在此新的路由结构下,我的应用程序继续按原样运行。我可以开始通过创建显示课程的模态框来实施 API 修改。
29.2. 添加课程部分
要创建一个模态框,我使用默认的 Bootstrap 模态 HTML,它提供了一个按钮代码,该按钮在屏幕中央显示一个简单的模态框。我将此代码添加到我的课程文件夹中名为 _coursesModal.ejs 的新文件中。下划线区分了部分名称和常规视图名称。
这个只包含下一个列表中显示的模态框代码的部分,需要包含在我的 layout.ejs 文件中。我将部分作为列表项包含在我的导航栏中,使用<%- include courses/_coursesModal %>。
列表 29.3. _coursesModal.ejs 中的模态框代码
<button id="modal-button" type="button"
data-toggle="modal"
data-target="#myModal"> Latest Courses</button> *1*
<div id="myModal" class="modal fade" role="dialog"> *2*
<div class="modal-dialog">
<h4 class="modal-title">Latest Courses</h4>
<div class="modal-body">
</div>
<div class="modal-footer">
<button type="button" data-dismiss="modal">Close</button>
</div>
</div>
</div>
-
1 添加打开模态框的按钮。
-
2 添加模态窗口的代码。
注意
我还需要确保将 Bootstrap 和 jQuery 的 JavaScript 文件添加到我的 public/js 文件夹中,并通过 script 标签导入到我的 layout.ejs 中。否则,我的模态框在屏幕上不会动画显示。我可以从code.jquery.com下载最新的 jQuery 代码,从www.bootstrapcdn.com下载 Bootstrap 代码。
当我重新启动我的应用程序时,我在导航栏中看到一个按钮,点击时会打开一个空白的模态框(图 29.2)。
图 29.2. 布局导航中的模式按钮

下一步是使用 AJAX 和新的 API 端点填充这个模态,使用课程数据。
29.3. 创建 AJAX 函数
一种在不刷新我的网页的情况下访问应用程序数据的方法是向我的服务器发送异步的 Ajax 请求。这个请求在应用程序客户端使用的浏览器中幕后发生,并源自公共文件夹中客户端的 JavaScript 文件。
为了让这个 Ajax 函数工作,我需要确保 jQuery 已添加到我的项目中,并从布局文件中链接,因为我将使用它的一些方法来填充我的模态。然后,通过我的公共/js 文件夹中的自定义 confettiCuisine.js 文件,我可以添加 列表 29.4 中的代码。我可以在 layout.ejs 中使用以下脚本标签引用此文件:<script type="text/javascript" src="js/confettiCuisine.js"></script>。
这个 Ajax 函数仅在文档对象模型 (DOM) 加载且模态按钮被点击时运行。我通过向我的 API 端点 /api/courses 发送 GET 请求来处理点击事件。这个请求等同于在我的网络浏览器中发送一个 GET 请求到 http://localhost:3000/api/courses 并接收一页 JSON 数据。我很快就会创建这个路由。
接下来,我通过 results 对象处理响应中的结果。在这个对象中,我期望看到一个 data 对象。如果没有 data 或 course 对象,我 return 以退出函数。我解析 data 对象以获取 JSON,并遍历其内容数组以填充我的模态。对于 data 对象中的每个条目,我在 HTML 标签内显示标题、费用和描述。
在每个课程列表旁边,我链接一个按钮到该课程的注册路由。我创建一个名为 addJoinButtonListener 的函数,在元素被添加到 DOM 之后为每个课程列表添加一个事件监听器。该函数监听带有 .join-button 类的加入按钮的点击事件。当该按钮被点击时,我通过我的 API 命名空间向 /api/courses/${courseId}/join 发送另一个 AJAX 请求,针对我选择的特定课程列表。如果我的服务器返回一个表示我成功加入课程的响应,我改变按钮的颜色和文本。使用三元运算符 ${course.joined ? "joined-button" : "join-button" },我确定按钮样式的类,取决于 course.joined 的值。我将在每个课程对象上创建这个属性,以便我的用户界面知道当前登录的用户是否已经加入了课程。
列表 29.4. 在 confettiCuisine.js 中创建一个用于检索课程数据的 Ajax 函数
$(document).ready(() => { *1*
$("#modal-button").click(() => { *2*
$(".modal-body").html(""); *3*
$.get(`/api/courses`, (results = {}) => { *4*
let data = results.data;
if (!data || !data.courses) return;
data.courses.forEach((course) => { *5*
$(".modal-body").append(
`<div>
<span class="course-cost">$${course.cost}</span>
<span class="course-title">
${course.title}
</span>
<button class="${course.joined ? "joined-button" :
"join-button"} btn btn-info btn-sm" data-id="${course._id}">
${course.joined ? "Joined" : "Join"}
</button>
<div class="course-description">
${course.description}
</div>
</div>`
); *6*
});
}).then(() => {
addJoinButtonListener(); *7*
});
});
});
let addJoinButtonListener = () => {
$(".join-button").click((event) => {
let $button = $(event.target),
courseId = $button.data("id");
$.get(`/api/courses/${courseId}/join`, (results = {}) => { *8*
let data = results.data;
if (data && data.success) {
$button
.text("Joined")
.addClass("joined-button")
.removeClass("join-button");
} else {
$button.text("Try again");
}
});
});
}
-
1 等待 DOM 加载。
-
2 处理模态按钮上的点击事件。
-
3 将模态体的内容重置为空字符串。
-
4 通过 AJAX GET 请求获取课程数据。
-
5 遍历每个课程,并将其追加到模态体中。
-
6 链接以注册当前用户。
-
7 调用
addJoinButtonListener以在课程列表上添加事件监听器。 -
8 调用 API 接口加入所选课程。
要使此代码正常工作,我需要创建两个新的 API 端点。一个端点用于检索课程数据作为 JSON 格式;另一个端点处理我在/api/course/${courseId}/join上的用户注册请求。我将在下一节中添加这些端点。
29.4. 添加 API 端点
现在,我的 Confetti Cuisine 应用程序已配置为与两个新的 API 端点通信,我需要创建处理这些请求的路由。第一步是将路由添加到位于routes文件夹中的index.js文件。对于 AJAX 请求,我需要在api命名空间下创建一个特定的路由,因为我希望请求发送到/api/courses,而不仅仅是/courses。为了完成这个任务,我在routes文件夹中创建了apiRoutes.js,其中包含列表 29.5 中的代码。
此文件需要 Express.js 路由器和我的coursesController。然后我让该路由器对象处理对/courses路径的GET请求。此路由从课程控制器中的index操作获取课程列表。然后课程列表通过filterUserCourses中间件函数标记当前用户已加入的课程,并通过respondJSON函数发送回结果。在api命名空间下,此路径是/api/courses。第二个路由处理对名为join的新操作的GET请求。我为这个 API 添加了一个额外的中间件。我引用了errorJSON操作,该操作处理由此 API 中任何路由引起的所有错误。最后,我导出路由器。
列表 29.5. 在apiRoutes中创建一个 API 路由
const router = require("express").Router(), *1*
coursesController = require("../controllers/
coursesController");
router.get("/courses", coursesController.index,
coursesController.filterUserCourses,
coursesController.respondJSON); *2*
router.get("/courses/:id/join", coursesController.join,
coursesController.respondJSON); *3*
router.use(coursesController.errorJSON); *4*
module.exports = router;
-
1 引入 Express.js 路由器和 coursesController。
-
2 为课程数据端点创建一个路由。
-
3 通过 ID 创建一个加入课程的路由。
-
4 处理所有 API 错误。
接下来,我需要将此路由器添加到在index.js中定义的路由器。我在index.js中通过添加const apiRoutes = require("./apiRoutes")来引入apiRoutes.js。我向index.js添加router.use ("/api", apiRoutes)以使用在apiRoutes.js中定义的、位于/api命名空间下的路由。我已经创建了index操作来从我的数据库中获取课程。现在我需要在课程控制器中创建filterUserCourses、respondJSON和errorJSON操作,以便我可以以 JSON 格式返回我的数据。为此,我在以下列表中添加了代码到coursesController.js。
列表 29.6. 在coursesController.js中创建一个将用户注册到课程中的操作
respondJSON: (req, res) => { *1*
res.json({
status: httpStatus.OK,
data: res.locals
});
},
errorJSON: (error, req, res, next) => { *2*
let errorObject;
if (error) {
errorObject = {
status: httpStatus.INTERNAL_SERVER_ERROR,
message: error.message
};
} else {
errorObject = {
status: httpStatus.OK,
message: "Unknown Error."
};
}
res.json(errorObject);
},
filterUserCourses: (req, res, next) => { *3*
let currentUser = res.locals.currentUser;
if (currentUser) {
let mappedCourses = res.locals.courses.map((course) => {
let userJoined = currentUser.courses.some((userCourse) => {
return userCourse.equals(course._id);
});
return Object.assign(course.toObject(), {joined: userJoined});
});
res.locals.courses = mappedCourses;
next();
} else {
next();
}
}
-
1 通过数据属性返回课程数组。
-
2 如果发生错误,返回错误消息和状态码 500。
-
3 检查用户是否已登录,并返回一个包含已加入属性反映用户关联的课程数组。
在设置好这些新端点后,我可以重新启动我的应用程序,并在点击导航按钮时看到课程列表填充到模态中(图 29.3)。
图 29.3. 通过浏览器中的模态显示课程列表

注意
在测试这个 API 端点是否正常工作之前,我需要注释掉指向 join 的路由,直到我将动作添加到我的课程控制器中。否则,我的应用程序将抱怨它在寻找一个不存在的回调。
最后的阶段是创建一个路由和动作来处理想要注册课程的用户,并过滤课程列表以反映那些已经加入的用户。
29.5. 创建一个将用户注册到课程中的动作
要将用户注册到烹饪课程中,我需要当前用户的 ID 和所选课程的 ID。我可以从由 passport 提供的请求中的用户对象中获取用户 ID,即 req.user._id 或我在上次工作在这个项目时创建的 currentUser 变量(课程 25)。我也可以通过 RESTful 路由轻松访问课程 ID。课程 ID 是路由路径中的第二个元素。我的第二个路由 '/courses/:id/join' 在 apiRoutes.js 中指向我的课程控制器中的 join 动作。
最后一步是添加一个控制器动作以将用户注册到所选课程中。我开始创建一个名为 join 的新动作,并为课程和用户 ID 定义局部变量。因为我在这个控制器中引用了用户模型,所以我需要在 coursesController.js 中添加 const User = require("../models/user")。然后我检查用户是否已登录。如果没有,我以 JSON 格式返回错误消息。
注意
您还需要在 coursesController.js 的顶部添加 const httpStatus = require("http-status-codes") 和 const User = require("../models/user")。
如果用户已登录,我使用 Mongoose 的 findByIdAndUpdate 查询方法通过用户对象、currentUser 和 MongoDB 数组更新运算符 $addToSet 将所选课程插入到用户的 courses 列表中。这种关联表示注册。我通过 列表 29.7 中的代码完成所有这些任务。
注意
$addToSet 确保在 courses 数组中不会出现重复值。我本可以使用 MongoDB 的 $push 运算符将课程 ID 添加到用户的 courses 数组中,但这个运算符可能会意外地允许用户多次注册同一课程。
列表 29.7. 在 coursesController.js 中创建一个将用户注册到课程中的动作
join: (req, res, next) => {
let courseId = req.params.id,
currentUser = req.user; *1*
if (currentUser) { *2*
User.findByIdAndUpdate(currentUser, { *3*
$addToSet: {
courses: courseId
}
})
.then(() => {
res.locals.success = true;
next(); *4*
})
.catch(error => {
next(error); *5*
});
} else {
next(new Error("User must log in."));
}
}
-
1 为课程和用户 ID 定义局部变量。
-
2 检查用户是否已登录。
-
3 查找并更新用户以连接所选课程。
-
4 继续到下一个中间件。
-
5 继续到错误中间件,如果用户未能注册,则显示错误信息。
在这个操作到位后,我可以重新启动应用程序。当我尝试在登录前注册课程时,我看到了图 29.4 中的消息。
图 29.4. 在登录前尝试注册

在我成功登录并点击加入课程的按钮后,屏幕类似于图 29.5。此外,在加入课程后,我刷新窗口仍然可以在模态窗口中看到我的joined状态被保留。
图 29.5. 成功注册课程

通过一个新的 API 命名空间,我可以打开这个应用程序,使其能够接受更多的 Ajax 请求以及其他想要访问 Confetti Cuisine 原始 JSON 数据的应用程序。我可以保护 API,但在这个小改动中这样做不是必需的。
现在我已经实现了一个新功能,允许用户注册课程,接下来我将致力于改进应用程序的其他部分,这些部分可能将从对我的 API 的单页异步调用中受益。
摘要
在这个综合练习中,我通过引入一个 Ajax 请求到新的 API 端点,改善了 Confetti Cuisine 应用程序的体验。我开始通过重新组织应用程序的路由,将 Web 路由与 API 路由分开。然后,我在客户端 JavaScript 中创建了一个 Ajax 函数,用于从自定义 API 端点填充模态窗口中的课程列表结果。最后,我创建了一个路由和操作,允许用户从应用程序的任何页面注册课程。随着这一新改进的实施,Confetti Cuisine 的市场营销团队对向用户传达信息和鼓励他们参加课程感到更加自信。
第 7 单元:添加聊天功能
到目前为止,你应用的主要结构已经完成。现在是时候考虑可以改善应用整体交互的新功能,但这些功能对于基本功能并不是必需的。在之前的课程中,我讨论了 Node.js 在处理数据流方面的特别有用。如果你想通过互联网发送大量数据,Node.js 通过支持数据分块使这个过程变得更简单。数据块在到达服务器时连接起来,并在有足够数据可以进行有意义操作时进行处理。这种方法适用于各种类型的数据流,并且是通过 Node.js 的事件发射和事件处理功能实现的。
在本单元中,你将探索如何使用 Node.js 通过 WebSocket 的事件驱动通信来促进实时聊天应用。我讨论了如何使用最简单的 HTML 工具构建聊天应用,以及 WebSocket 和socket.io相比传统的客户端-服务器通信更加高效和复杂。你将把聊天功能应用到现有的应用中,以便现有用户可以在群组环境中进行交流。然后,你将进一步通过创建聊天消息的数据模型,并在打开应用聊天页面时从数据库中加载消息来实现。最后,你将在导航栏中实现一个图标,作为聊天页面活跃时的指示器,即使用户在另一个页面上也是如此。
本单元涵盖了以下主题:
-
第 30 课介绍了 WebSocket,并展示了
socket.io包如何帮助你通过实时聊天应用连接应用的用户。在本课中,你将学习如何在现有的食谱应用上创建一个简单的聊天页面。 -
第 31 课展示了如何通过将消息保存到 MongoDB 数据库中,将你的聊天应用提升到下一个层次。在本课中,你将创建一个消息模型,并将消息与发送者连接起来。这样,你就能识别出哪些消息属于已登录的用户。
-
第 32 课指导你如何在导航栏中实现一个活跃的聊天指示器。当聊天页面上的消息被分享时,这个图标会进行动画。
在第 33 课(总结课程)中,你将使用在本单元中学到的概念为 Confetti Cuisine 应用构建聊天功能。
第 30 课:使用 Socket.Io
在 Node.js 中构建一个网络应用可以非常有趣。通常,你会发现最具挑战性的方面主要来自于从网络开发的角度来架构应用。很容易忘记 Node.js 在正常请求-响应周期之外的能力。在本课中,你将探索客户端和服务器之间通过开放的 TCP 连接进行通信。这种连接是通过socket.io包实现的,该包在 Web 套接字和长轮询上运行,通过在服务器上保持更长时间的 HTTP 请求,在返回响应之前,以促进客户端和服务器之间实时数据流的实现。你首先学习如何使用 Express.js 实现socket.io。然后,你在一个新的应用视图中创建一个聊天框。最后,你通过socket.io触发和处理的自定义事件,将客户端 JavaScript 和服务器代码连接起来。
本课涵盖
-
在 Node.js 应用中实现
socket.io -
在控制器中组织你的
socket.io监听器 -
创建简单的聊天功能
考虑这一点
你构建了一个功能齐全的应用程序,众多用户纷纷前来注册。不幸的是,这些用户之间没有沟通的方式。鉴于你正在构建一个社区驱动的应用,成员之间的沟通非常重要。用户数据已经在数据库中。你所需要做的就是通过支持实时通信的工具将数据关联起来。
在socket.io的一点点帮助下,你很快就能连接用户,使他们能够互相聊天。
30.1. 使用 socket.io
你已经构建了具有客户端到服务器通信功能的 Node.js 网络应用。当客户端想要查看网页或提交数据时,你的应用程序会向服务器生成一个 HTTP 请求。这种在互联网上的通信方式已经存在很长时间了,在 2017 年庆祝了它的 20 岁生日。在技术年数中,这已经很老了。尽管开发者仍然严重依赖请求-响应周期,但这并不是每个用例中最有效的通信方法。
例如,如果你想实时查看 NBA 篮球比赛的得分,会怎样?你可以加载包含得分和统计信息的页面,但每次你想查看信息更新时,都需要重新加载页面。对于篮球比赛,这些变化可能每秒就会发生。反复向服务器创建GET请求对客户端来说是一项繁重的工作。"轮询"用于从客户端向服务器生成重复请求,以期待服务器数据的更新。轮询使用你迄今为止使用的标准技术来在客户端和服务器之间传输数据,但它发送请求的频率如此之高,以至于在双方参与者之间产生了一个开放通信通道的错觉(图 30.1)。
图 30.1. 客户端和服务器之间的轮询

为了进一步提高这项技术,开发了长轮询来减少获取更新数据所需的请求数量。长轮询的行为与轮询类似,即客户端重复向服务器请求更新数据,但请求数量更少。不是在只有几十个请求接收到了更新数据时才发送数百个请求,长轮询允许请求在 HTTP 允许的情况下保持打开状态,直到请求超时。在这段时间内——比如说,10 秒——服务器可以保持对请求的控制,并在服务器收到更新数据时响应更新数据,或者在请求超时前响应无变化。这种更有效的方法使得网络浏览器和设备能够在几十年来变化不大的协议上体验到实时信息交换的感觉。
尽管这两种方法被广泛使用,但最近的一个新加入的方法使得像 Node.js 这样的平台得以繁荣发展。WebSocket于 2011 年推出,允许客户端和服务器之间建立开放的通信流,创建了一个真正的开放通道,使得信息可以在服务器或客户端可用的情况下双向流动。WebSocket 使用与 HTTP 不同的互联网协议,但可以在普通的 HTTP 服务器上使用。在大多数情况下,启用 WebSocket 的服务器允许其开放通道通过与典型请求-响应交换相同的应用程序端口访问(图 30.2)。
图 30.2. 客户端和服务器之间打开 WebSocket 连接

虽然 WebSocket 是实时通信的首选方法,但许多较老的浏览器和客户端不支持它。这项相对较新的技术允许开发者构建能够实时传输数据的应用程序,并且你可以将其集成到现有的 Node.js 应用程序中:socket.io,这是一个当 WebSocket 可用时使用 WebSocket,而在 WebSocket 不可用时使用轮询的 JavaScript 库。
socket.io也是一个可以在 Node.js 应用程序中安装的包,为 WebSocket 提供库支持。它使用 Node.js 和 WebSocket 的事件驱动通信,允许客户端和服务器通过触发事件来发送数据。例如,作为一个寻找更新篮球比赛统计数据的客户端,你可能会有客户端 JavaScript 监听由服务器触发的updated data事件。然后你的浏览器会处理updated data事件以及与之一起传递的任何数据,以修改你网页的内容。这些事件可以连续不断地到来,或者根据需要隔几个小时才来一次。如果你想向服务器发送消息给所有其他监听客户端,你可以触发一个服务器知道如何处理的事件。幸运的是,你可以控制客户端和服务器端的代码,因此你可以实现任何你想要的触发和处理事件。
首先,在您的项目终端窗口中运行 npm i socket.io -S 以在您的食谱应用程序中安装 socket.io。在接下来的章节中,您将使用这个库为用户提供实时聊天功能。
快速检查 30.1
Q1:
长轮询与轮询有何不同?
| |
QC 30.1 答案
1:
长轮询通过发送持续时间比典型请求更长的服务器请求来实现。轮询依赖于许多单独的
GET请求。长轮询更高效,因为它保持单个GET请求活跃更长时间,允许服务器在客户端发出另一个请求之前接收更新并做出响应。
30.2. 创建聊天框
要开始使用聊天功能,您需要构建一个包含聊天框和提交按钮的基本视图。当您构建代码以允许客户端处理服务器事件时,此聊天框将填充数据。
在您的视图文件夹中创建一个名为 chat.ejs 的新视图。在这个视图中,添加 代码清单 30.1 中的代码。在这个代码中,您有一个接受输入和提交按钮的表单。在表单代码下方是创建的聊天框标签。通过一些简单的 CSS 样式,您可以添加边框和尺寸到聊天框,提示用户输入表单输入并将其提交以将内容添加到下面的聊天窗口中。
列表 30.1. 在 chat.ejs 中创建聊天框
<div class="container">
<h1>Chat</h1>
<form id="chatForm"> *1*
<input id="chat-input" type="text"> *2*
<input type="submit" value="Send">
</form>
<div id="chat"></div> *3*
</div>
-
1 添加一个用于聊天输入的 HTML 表单。
-
2 添加一个用于聊天内容的自定义输入元素。
-
3 创建聊天框的标签。
要加载此视图,请向您的路由文件夹中的 homeRoutes.js 添加一个新路由和操作。将 router.get("/chat", homeController .chat) 添加到您的路由文件夹中的 homeRoutes.js。此新路由将被 index.js 路由文件吸收并由 main.js 使用。现在您需要在 homeController.js 中创建聊天操作,如下一列表所示。在这个操作中,您只需渲染 chat.ejs 视图。
列表 30.2. 在 homeController.js 中添加聊天操作
chat: (req, res) => {
res.render("chat"); *1*
}
- 1 渲染聊天视图。
重新启动您的应用程序,并访问 http://localhost:3000/chat 以查看 图 30.3 中显示的聊天框。
图 30.3. 显示聊天视图

注意
您的聊天页面不会与 图 30.3 完全相同,除非您为其添加自定义样式。
在设置好此聊天页面后,您需要记住在 HTML 中使用的标签 ID。在下一节中,您将针对 #chat 框发送聊天消息,并将新消息发送到 #chat-input 中的服务器。
快速检查 30.2
Q1:
为什么具有 ID
chat的 HTML 元素没有任何内容?
| |
QC 30.2 答案
1:
在每次页面加载时,
#chat元素都是空的。您将使用客户端 JavaScript 来填充元素,以便在从服务器接收内容时显示内容。
30.3. 连接服务器和客户端
现在你已经有了聊天页面,你需要勇气让它工作。安装了socket.io后,你需要将其引入到你的项目中。因为你希望你的 socket 服务器运行在现有的 Express.js HTTP 服务器上,所以需要引入socket.io,并将其传递给 Express.js 服务器。将引入行添加到 main.js 中,在告诉你的应用程序监听指定端口的行下面,如代码清单 30.3 所示。在这段代码中,你将正在运行的服务器实例保存到一个常量server中,这样你就可以将相同的 Express.js HTTP 服务器传递给socket.io。这个过程允许socket.io(我将称之为io)附加到你的应用程序服务器。
代码清单 30.3. 在 main.js 中添加服务器io对象
const server = app.listen(app.get("port"), () => {
console.log(`Server running at http://localhost:
${ app.get("port") }`);
}), *1*
io = require("socket.io")(server); *2*
-
1 将服务器实例保存到 server。
-
2 将服务器实例传递给 socket.io。
现在你可以开始使用io来构建你的 socket 逻辑。尽管如此,像你的其他代码一样,将这段代码封装到它自己的控制器中。在你的 controllers 文件夹中创建一个新的 chatController.js,并在引入socket.io之后引入它。为了引入控制器,将require("./controllers/chatController")(io)添加到 main.js 中。在这行代码中,你将io对象传递给你的聊天控制器,以便你可以从那里管理你的 socket 连接。你不需要将这个模块存储在常量中,因为你不会在 main.js 中进一步使用它,所以你可以直接引入。
注意
在定义io对象之后引入 chatController.js 是很重要的。否则,你将无法在控制器中使用配置好的socket.io。
在 chatController.js 中,添加代码清单 30.4 中的代码。在这段代码块中,你导出控制器的内容并接受一个参数:来自 main.js 的io对象。在这个文件中,你使用io来监听某些事件。首先,io监听connection事件,表示客户端已连接到 socket 通道。在处理这个事件时,你可以使用特定的客户端 socket 来监听用户断开连接或自定义事件,例如你创建的message事件。如果服务器接收到message事件,它将使用io的emit方法向所有连接的客户端发送数据字符串。
代码清单 30.4. 在 chatController.js 中处理聊天 socket 连接
module.exports = io => { *1*
io.on("connection", client => { *2*
console.log("new connection");
client.on("disconnect", () => { *3*
console.log("user disconnected");
});
client.on("message", () => { *4*
io.emit("message", {
content: "Hello"
}); *5*
});
});
};
-
1 导出聊天控制器内容。
-
2 监听新用户连接。
-
3 监听用户断开连接时的情况。
-
4 监听自定义消息事件。
-
5 向所有连接的用户广播消息。
注意
注意你使用的是参数名client,因为这段代码将在每个新的客户端连接时运行。client代表服务器另一侧连接的实体。客户端监听器仅在建立初始io连接时运行。
在此代码的基础上,你需要设置客户端代码来处理来自服务器的数据并发送事件到服务器。为了完成这个任务,在你的公共文件夹中的 recipeApp.js JavaScript 代码中添加一些代码。
在这段代码中,在客户端初始化 socket.io,使你的服务器能够检测到新用户的连接。然后,使用 jQuery,通过向服务器发送一个 message 事件来处理表单提交,并使用 return false 阻止表单自然提交。socket.emit 接收一个字符串参数作为事件名称,并将事件发送回服务器。使用 socket.on,你监听来自服务器的 message,以及一个字符串消息。你通过将消息作为列表项添加到你的 #chat 元素中来显示该消息。在服务器端,你已经在 chatController.js 中为 message 事件设置了一个处理程序,向客户端发送消息内容 "Hello"。
列表 30.5. 在 recipeApp.js 中添加 socket.io 的客户端 JavaScript 代码
const socket = io(); *1*
$("#chatForm").submit(() => { *2*
socket.emit("message");
$("#chat-input").val("");
return false;
});
socket.on("message", (message) => { *3*
displayMessage(message.content);
});
let displayMessage = (message) => { *4*
$("#chat").prepend($("<li>").html(message));
};
-
1 在客户端初始化 socket.io。
-
2 当表单提交时触发事件。
-
3 监听事件,并填充聊天框。
-
4 在聊天框中显示来自服务器的消息。
最后一步是在客户端加载 socket.io 库,通过在生成聊天的视图中添加一个脚本标签来实现。为了简化这个任务,将标签添加到你的布局文件中。在 layout.ejs 中,在你的其他脚本和链接标签下方添加 <script src="/socket.io/socket.io.js"></script>。这个标签告诉你的 Node.js 应用程序在 node_modules 文件夹中查找 socket.io 库。
重新启动你的应用程序,访问 http://localhost:3000/chat,在输入框中输入一些文本,然后点击发送。你应该在你的聊天框中看到 "Hello"(图 30.4)。每次新的文本提交都会出现一个新的行。
图 30.4. 在聊天框中显示文本

在 第 31 课 中,你改进了这个聊天应用,使其能够将消息保存到你的数据库中。
快速检查 30.3
Q1:
io.emit是做什么的?
| |
QC 30.3 答案
1:
io对象控制着服务器和客户端之间的大部分通信。emit允许io通过触发事件并通知所有已连接的客户端套接字来发送一些特定的数据。
摘要
在本课中,你学习了 socket.io 并了解了如何在 Node.js 应用程序中安装它。然后,你通过使用 Express.js 服务器上的 WebSocket 来创建你的第一个聊天应用程序,从而实现客户端和服务器之间的事件和数据交换。当这个聊天功能安装后,用户可以实时相互沟通。然而,当客户端刷新网页时,聊天历史会被清除。更重要的是,你没有指示哪个用户发送了哪条消息。在第 31 课中,你创建了一个新的数据模型,并将用户账户关联起来,以便可以识别消息作者,并使聊天可以在用户会话之间持续。
尝试以下操作
在实现了聊天功能后,尝试在客户端和服务器之间发送更有意义的数据。消息内容允许所有客户端同时看到相同的消息,但也许你想要看到比消息本身更多的信息。尝试发送显示消息发送到服务器的日期戳。然后,使用客户端 JavaScript 收集那个日期戳,并在聊天框中显示在消息旁边。
第 31 课. 保存聊天消息
你的聊天功能正在逐步完善,你可以从多个方向来改进它。尽管聊天功能允许实时通信,但当你刷新页面时,所有消息都会消失。下一步是将这些消息持久化到你的数据库中。在本课中,你实现了一个简单的模型来表示每个聊天消息。然后,你将这个模型连接到用户模型,允许发送者将消息与自己的消息关联起来。最后,每次页面重新加载时,你都会查询数据库以获取最新的消息。当你完成这些步骤后,聊天将开始类似于你在熟悉的网站和应用中使用的聊天。
本课涵盖
-
创建消息模型
-
在
socket.io事件处理器中保存消息 -
在新的 socket 连接上查询消息
考虑以下内容
你已经有一个可以工作的聊天页面,最终允许用户相互交谈。一旦用户刷新他们的页面,他们的聊天历史就会消失。尽管这个功能可以作为一个安全实现来推广,但它并不实用。你想要保存消息,并且希望在不会打断你的聊天应用程序上运行的快速、事件驱动系统的情况下完成。在本课中,你使用 Mongoose 和现有的应用程序结构来支持保存和加载聊天消息。
31.1. 将消息连接到用户
在 第 30 课 中,你为你的应用程序创建了一个聊天功能,允许用户触发一个 message 事件,提示服务器以相同的 "Hello" 文本消息内容进行响应。你可以通过将实际在聊天输入框中输入的内容发送到服务器来改进这个功能。为此,修改你的客户端代码,使得你的表单提交事件处理器看起来像 列表 31.1 中的那样。
这个小小的改动允许你在用户点击提交按钮后立即获取他输入的文本。然后你将文本作为一个对象发送,当向服务器发出 message 事件时。
列表 31.1. 在 recipeApp.js 中从客户端发出事件
$("#chatForm").submit(() => {
let text = $("#chat_input").val(); *1*
socket.emit("message", {
content: text
}); *2*
$("#chat_input").val("");
return false;
});
-
1 从视图输入字段中获取文本。
-
2 将表单数据发送到服务器。
作为响应,让服务器向所有监听客户端发出这个表单数据。你可以通过修改聊天控制器的 message 事件处理器来向所有客户端发出数据。在 chatController.js 中的 io.emit 行周围更改代码到 列表 31.2 中的代码。在这里,你从客户端获取数据并将其发送回去。如果你重新启动你的应用程序并尝试输入一个新的聊天消息,该特定消息会出现在聊天框中。你还可以打开第二个浏览器窗口来模拟两个用户,这两个浏览器允许进行多个套接字连接以提交数据,并在另一个浏览器的聊天框中实时显示新消息(图 31.1)。
图 31.1. 使用两个套接字显示聊天

列表 31.2. 在 chatController.js 中将发出消息改为数据
client.on("message", data => { *1*
io.emit("message", { content: data.content }); *2*
});
-
1 收集数据作为参数。
-
2 将消息事件中的数据作为内容返回。
你接下来想要做的是添加一些关于发布聊天消息的用户的信息。目前,你只向服务器发送了消息内容,但你也可以发送用户的姓名和 ID。修改你的聊天表单以包含两个隐藏数据项,如 列表 31.3 中所示。在这个例子中,你使用 passport 提供的响应中的数据检查是否有 currentUser 登录。如果有用户,使用该用户在表单中的 _id 属性作为隐藏字段。然后这个值可以在你提交消息时传递给服务器。
列表 31.3. 在 chat.ejs 中添加聊天表单的隐藏字段
<% if (currentUser) { %> *1*
<h1>Chat</h1>
<form id="chatForm">
<input id="chat-input" type="text">
<input id="chat-user-name" type="hidden"
value="<%= currentUser.fullName %>">
<input id="chat-user-id" type="hidden"
value="<%= currentUser._id %>"> *2*
<input type="submit" value="Send">
</form>
<div id="chat"></div>
<% } %>
-
1 检查是否有登录用户。
-
2 添加包含用户数据的隐藏字段。
现在你已经在你的聊天表单中包含了用户字段,只有当用户登录时才会显示聊天框。在登录之前尝试加载 /chat。然后使用你的本地用户账户之一登录后再次尝试。第二次尝试会显示聊天页面的内容。
接下来,修改你的自定义客户端 JavaScript 代码,在表单提交时提取这些值。用下一列表中的代码替换你的表单提交事件监听器。在这个修改后的代码中,你获取用户的 ID 并将值传递给服务器,使用相同的局部变量名。
列表 31.4. 在 recipeApp.js 中从聊天表单中提取隐藏字段值
$("#chatForm").submit(() => {
let text = $("#chat-input").val(),
userId = $("#chat-user-id").val(); *1*
socket.emit("message", {
content: text,
userId: userId
}); *2*
$("#chat-input").val("");
return false;
});
-
1 从表单中提取隐藏字段数据。
-
2 使用消息内容和用户数据触发一个事件。
现在你可以通过更改 chatController.js 中的 message 事件处理器的代码来在服务器端处理这些数据,将传递给服务器的所有单个属性收集到一起(列表 31.5)。通过将这些值保存到一个新对象中,你可以过滤掉任何你未在 messageAttributes 对象中指定的不想要的值。然后向其他客户端发送包含消息内容和用户信息的值。
注意
此代码必须存在于 io.on("connection"... 块中。你只能监听已连接的客户端套接字上的特定事件。
列表 31.5. 在 chatController.js 中接收套接字数据
client.on("message", (data) => {
let messageAttributes = {
content: data.content,
userName: data.userName,
user: data.userId
}; *1*
io.emit("message", messageAttributes); *2*
});
-
1 收集所有传入的数据。
-
2 带有用户数据的消息触发。
最后,你需要安排这些数据并在视图中适当地显示它们。回到 recipeApp.js,将 displayMessage 中的代码更改为与 列表 31.6 中的代码匹配。此函数向与登录用户关联的消息添加一个 HTML 类属性。通过比较表单中用户的 ID 与与聊天消息关联的 ID,你可以过滤掉登录用户的消息。
要完成这个任务,添加 getCurrentUserClass 来确定聊天中的消息是否属于当前登录的用户。如果是,添加一个 current-user 类,你可以用它来在视觉上区分该用户的消息。在此更改之后,每个被识别为属于当前登录用户的消息都将关联此样式类。因为你在这个函数中使用用户的 ID 和消息内容,所以你需要传递整个 message 对象,而不仅仅是之前的消息内容,到 displayMessage。
注意
将你的调用 displayMessage(message.content) 改为 displayMessage (message),这样你就可以使用消息对象的所有属性。
列表 31.6. 在 recipeApp.js 中从聊天表单中提取隐藏字段值
let displayMessage = (message) => {
$("#chat").prepend(
$("<li>").html(`
<div class="message ${getCurrentUserClass(message.user)}">
${message.content} *1*
</div>`)
);
};
let getCurrentUserClass = (id) => {
let userId = $("#chat-user-id").val();
return userId === id ? "current-user": ""; *2*
};
-
1 在聊天框中显示消息内容以及用户名。
-
2 检查消息的用户 ID 是否与表单的用户 ID 匹配。
现在给 current-user 类元素添加一些样式,区分不同的聊天消息。在两个并排的浏览器窗口中,有两个用户登录,聊天可以看起来像 图 31.2。
图 31.2. 使用两个套接字对用户消息进行样式化

您已经实现了将消息与用户关联的逻辑,并在视图中区分这些消息。然而,这个聊天似乎仍然缺少一些要点。尽管登录用户可以识别自己的消息,但他们不知道其他用户的身份。在下一节中,您将向聊天消息添加用户名。
快速检查 31.1
Q1:
为什么您需要在客户端 JavaScript 中将聊天消息的用户 ID 与聊天表单上的用户 ID 进行比较?
QC 31.1 答案
1:
表单的用户 ID 反映了登录用户的 ID。如果聊天消息中的用户 ID 与表单中的 ID 匹配,您可以安全地将该消息标记为属于登录用户,并应用样式来表示这一点。
31.2. 在聊天中显示用户名
您越接近将消息与创建它们的用户账户耦合,用户之间的沟通就会越容易。为了消除混淆,您希望将用户的名字用作聊天消息的标识符。为此,您需要在第一部分的代码中实施一些小的更改。
您已经在聊天表单中添加了一个隐藏的输入字段来提交用户的 fullName。当登录用户提交他们的聊天消息时,他们的名字也会被发送。
接下来,在 recipeApp.js 中通过从表单提交时提取 #chat_user_name 输入的值来获取此字段值,并将其保存到变量中。新的 submit 事件处理程序看起来像下一个列表中的代码。然后,在同一个对象中与 userName 键配对发送该值。您将在服务器上稍后使用此键。
列表 31.7. 从 recipeApp.js 中的聊天表单中提取额外的隐藏字段值
$("#chatForm").submit(() => {
let text = $("#chat-input").val(),
userName = $("#chat-user-name").val(), *1*
userId = $("#chat-user-id").val();
socket.emit("message", {
content: text,
userName: userName,
userId: userId
}); *2*
$("#chat_input").val("");
return false;
});
-
1 提取用户的名字。
-
2 向服务器发送包含消息内容的自定义事件。
在服务器上,您需要将此用户名包含在您收集的消息属性中,以便它们可以发送到其他客户端套接字。您可以使用用户的 ID 来检索他们的名字,但这种方法可以节省您与数据库通信。在 chatController.js 中的 message 事件处理程序中,您的消息属性变量赋值应读取 let messageAttributes = {content: data.content, userName: data.userName, user: data.userId}。
最后,安排这些数据,并在视图中适当地显示。回到 recipeApp.js,将 displayMessage 函数中的代码更改为 列表 31.8 中的代码。此更改显示了与发布的消息关联的用户的名字。您仍然可以使用 getCurrentUserClass 函数来确定聊天中的消息是否属于当前登录的用户。
列表 31.8. 在 recipeApp.js 中显示用户名
$("#chat").prepend($("<li>").html(`
<strong class="message ${getCurrentUserClass(
message.user )}">
${message.userName}
</strong>: ${message.content} *1*
`));
- 1 以粗体显示用户名,并如果 currentUser 则进行样式化。
实施这些更改后,您可以看到在聊天中发布消息的用户的名字(图 31.3)。
图 31.3. 显示带有两个套接字的用户名

通过这个改进,用户可以通过发送者的名字来识别特定聊天消息的作者。这个功能很棒,因为它减少了聊天的匿名性,并允许注册用户相互联系。然而,您仍然面临聊天消息随着页面加载而消失的问题。您需要将这些聊天消息连接到您的数据库,而最好的方法是通过 Mongoose 数据模型。在下一节中,您将探索聊天消息所需的模型模式。
快速检查 31.2
Q1:
为什么您将用户的姓名传递到服务器而不是使用用户的 ID 在您的数据库中查找姓名?
| |
QC 31.2 答案
1:
使用用户的 ID 查找他们的姓名可以工作,但这会增加涉及数据库的另一层工作量。由于没有立即需要使用您的数据库进行此聊天,您可以传递额外的字符串值。
31.3. 创建消息模型
要使这个聊天页面值得再次访问,您需要保存共享的消息。为此,您需要将消息保存到您的数据库中,您有几种保存消息的方法:
-
您可以修改您的用户模式以保存消息数组。每当任何用户提交新消息时,该消息就会被添加到用户的
messages数组中。这种方法可以工作,但您很快就会得到长长的列表,这些列表既不高效也不必要存储在用户模型中。 -
您还可以创建一个新的模型来表示聊天及其消息。这种方法需要一个新的模型模块,但最终可以节省您的工作量,并使您更容易理解您正在处理和保存的数据。
在本节中,您将构建一个 Message 模型来包含您在本课中一直在使用的值。在您的项目模型文件夹中创建一个新的 message.js 文件,并将 列表 31.9 中的代码添加到该文件中。
在此代码中,您正在定义一个包含 content、userName 和 user 属性的消息模式。聊天消息的内容是必需的,用户的姓名和 ID 也是如此。本质上,每条消息都需要一些文本和一个作者。如果有人试图以某种方式保存消息而没有登录和验证,您的数据库将不允许保存这些数据。您还设置了 timestamps 为 true,这样您就可以跟踪聊天消息何时添加到您的数据库中。如果您想显示聊天框中的时间戳,这个功能非常有用。
列表 31.9. 在 message.js 中创建消息模式
const mongoose = require("mongoose"),
{ Schema } = require("mongoose");
const messageSchema = new Schema({
content: {
type: String,
required: true
}, *1*
userName: {
type: String,
required: true
}, *2*
user: {
type: Schema.Types.ObjectId,
ref: "User",
required: true
} *3*
}, { timestamps: true }); *4*
module.exports = mongoose.model("Message", messageSchema);
-
1 每条消息都需要内容。
-
2 每条消息都需要用户的姓名。
-
3 每条消息都需要一个用户 ID。
-
4 保存每条消息的时间戳。
接下来,通过在文件顶部添加 const Message = require ("../models/message") 来在 chatController.js 中引入这个新模型。
注意
../models/message意味着你正在离开 controllers 文件夹进入 models 文件夹以找到 message.js。
要开始将传入的数据保存到消息模型中,你需要使用你的messageAttributes作为新消息对象的属性。然后尝试将此消息保存到你的 MongoDB 数据库中,如果你成功,则发射该消息。使用下一个列表中的代码修改你的代码,以更改 chatController.js 中的client.on("message")块。
列表 31.10. 在 chatController.js 中保存消息
client.on("message", (data) => { *1*
let messageAttributes = {
content: data.content,
userName: data.userName,
user: data.userId
},
m = new Message(messageAttributes);
m.save() *2*
.then(() => {
io.emit("message", messageAttributes); *3*
})
.catch(error => console.log(`error: ${error.message}`));
});
-
1 使用 messageAttributes 创建一个新的消息对象。
-
2 保存消息。
-
3 如果保存成功,则发射消息值,或记录任何错误。
只需这样做就可以开始保存你的消息。你可以重新启动你的应用程序,登录,并发送消息,它们将在幕后保存。你不会注意到任何变化,因为当你刷新聊天页面时,你仍然会清除聊天历史,即使消息已经保存在你的数据库中。为了纠正这个问题,你需要在用户重新连接到聊天套接字时加载一些最近的聊天消息。在 chatController.js 中,添加列表 31.11 中的代码来找到最近的十条聊天消息,并使用一个新的自定义事件将它们发射出来。使用sort({createdAt: -1})按降序排列你的数据库结果。然后使用limit(10)来限制这些结果只包含最近的十条。当你向客户端套接字发射自定义的"load all messages"事件时,只有新连接的用户聊天框会刷新显示最新的聊天消息。使用messages.reverse()反转消息列表,以便你可以在视图中将它们前置。
列表 31.11. 在 chatController.js 中加载最近的聊天消息
Message.find({})
.sort({ createdAt: -1 })
.limit(10)
.then(messages => { *1*
client.emit("load all messages", messages.reverse()); *2*
});
-
1 查询最近的十条消息。
-
2 只向新套接字发射包含十条消息的自定义事件。
最后一步是在你的客户端 JavaScript 中处理这个新的自定义事件。在 recipeApp.js 中,添加列表 31.12 中的事件处理程序。此代码监听发射到该特定套接字的自定义"load all messages"事件。在这里接收到的任何数据都通过将data数组中的每条消息发送到你的displayMessage函数来处理,以便将消息内容前置到你的聊天框中。
列表 31.12. 在 recipeApp.js 中显示最近的聊天消息
socket.on("load all messages", (data) => { *1*
data.forEach(message => {
displayMessage(message); *2*
});
});
-
1 通过解析传入的数据来处理“load all messages”。
-
2 将每条消息发送到 displayMessage 以在聊天框中显示。
尝试比较在套接字刷新其连接之前和之后两个相邻套接字的视图。用户的新的连接会使用数据库中的消息刷新聊天框。现在,用户可以更容易地参与聊天,并保留共享的消息历史。
快速检查 31.3
Q1:
“
load all messages”事件的目的是什么?
| |
QC 31.3 答案
1:
“
load all messages”是你创建的一个自定义事件,用于与客户端 socket 通信,以便在它们连接时立即将数据库消息加载到聊天框中。你可以使用任何自定义事件名称。这个独特的名称是描述性的,你可以在客户端 JavaScript 中以任何你喜欢的样子处理它。
摘要
在本节课中,你学习了如何在聊天框中整理消息,以显示关于消息作者的详细信息。你还显示了用户的姓名,以便在聊天页面上增加透明度。在本节课结束时,你创建了一个消息模型,并开始将消息保存到应用程序的数据库中。这种实现允许消息在多个 socket 连接之间持久化。通过在每次新的 socket 连接时加载最新的消息,你立即让用户参与到对话中。在第 32 课中,你将查看一种使用socket.io事件通知用户新消息的方法,即使他们没有在聊天页面上活跃。
尝试以下操作
现在你已经将消息保存到数据库中,并且与用户账户相关联,现在在控制器层添加另一层安全措施。虽然你在消息中保存了用户 ID,但你并没有确保该用户 ID 在数据库中是有效的。在 chat-Controller.js 中保存消息的 promise 链中添加一些代码,通过相同的 ID 在数据库中查找用户并验证它,然后再正式保存消息。为此任务,你需要在这个控制器中引入用户模型。
第 32 课:添加聊天通知指示器
你的聊天页面正在成形。现在用户可以登录并查看最新的聊天消息,无论它们是几分钟前还是几周前发送的。聊天页面目前促进了应用程序聊天功能的所有视觉方面。socket.io的好处是它不需要存在于一个页面上。因为你的聊天是通过发射和处理事件来工作的,所以你可以用其他方式使用这些事件。在本节课中,你构建了一个自定义事件发射器,以通知所有活跃用户当聊天消息正在提交时。然后你在导航栏中构建了一个小的视觉指示器,当有新消息分享时它会动画化。通过这个小技巧,用户即使在浏览不同页面时也能得到一个活跃聊天室的视觉指示。
本节课涵盖
-
广播自定义事件
-
响应事件动画图标
考虑以下
用户正在享受你的应用程序中的聊天页面,但他们希望浏览应用程序中的其他页面,而不是等待聊天页面上的新消息到来。然而,他们不希望错过聊天再次活跃的时候。在本节课中,你依赖于服务器发出的自定义事件来动画化导航栏图标。当这个图标动画化时,应用程序任何页面的用户都知道聊天正在进行。
32.1. 向所有其他套接字广播
关于 socket.io 有一件事要知道,它可以配置为在多个特定的聊天室和不同的命名空间中工作。它甚至允许用户被添加到或从特定的组中移除。除了这些功能之外,消息不总是需要发送给每个客户端。实际上,如果发出消息的客户端正在断开连接,那么向每个人发出消息通常是没有意义的。
在本节中,您实现了一个新功能,当用户的套接字断开连接时,通知聊天中的所有其他用户。为此,在 io.on("connect") 块内将 列表 32.1 中的代码添加到 chatController.js 中。
在此代码中,您正在监听某个客户端断开连接的情况。您之前使用此代码块在控制台记录消息。除了记录此信息外,使用 client.broadcast.emit("user disconnected") 向除了发出消息的套接字之外的所有套接字发送消息。client.broadcast 向连接的聊天用户发送一个名为 'user disconnected' 的自定义事件。
您之所以广播消息而不是发出消息,是因为发出消息的客户端已经断开连接,无法再处理该自定义事件。即使发出消息的套接字没有断开连接,您也可以使用 broadcast 向所有其他套接字发出消息。
列表 32.1. 在 chatController.js 中向所有其他用户广播事件
client.on("disconnect", () => {
client.broadcast.emit("user disconnected"); *1*
console.log("user disconnected");
});
- 1 向所有其他连接的套接字广播消息。
随着这个新事件的触发,您需要在客户端处理它。就像您处理其他事件一样,监听 "user disconnected" 事件,并在聊天框中打印一些指示。在 列表 32.2 中添加事件处理程序到 recipeApp.js。在这段代码中,您重用了 displayMessage 函数来发布一条硬编码的消息,以便让其他用户知道有人断开连接。
列表 32.2. 在 recipeApp.js 中显示用户断开连接时的消息
socket.on("user disconnected", () => { *1*
displayMessage({
userName: "Notice",
content: "User left the chat"
});
});
- 1 监听“user disconnected”事件,并显示自定义消息。
现在重新启动您的应用程序,并通过在两个不同的浏览器上登录或使用浏览器的高级隐私模式以新会话登录来登录多个账户。当两个聊天窗口并排打开时,您应该能看到当另一个聊天窗口中的用户连接时的情况。在 图 32.1 中,左侧的聊天窗口显示当右侧窗口刷新时用户断开连接。在这种情况下,页面刷新会导致立即重新连接。
图 32.1. 在聊天中显示用户断开连接

快速检查 32.1
Q1:
client.broadcast.emit和client.emit之间的区别是什么?
QC 32.1 答案
1:
client.broadcast.emit向所有除了它自身的套接字发出事件,而client.emit向包括它自身的所有套接字发出事件。
32.2. 在导航中创建聊天指示器
您将对聊天应用程序进行的最后一个添加是一个功能,让应用程序中其他页面的用户知道聊天页面上有活动。这个功能可能对查看个人资料或菜谱的用户或在家页面上闲逛的用户有帮助;他们可能想知道其他用户是否在聊天室中醒来并互相交谈。为了添加这个功能,在导航栏中添加一个图标。当聊天室中提交消息时,您将导航栏中的聊天图标动画化,让其他用户知道聊天活动。
首先,通过在 layout.ejs 中添加 <a href="/chat" class="chat-icon"> @</a> 将图标添加到导航栏中。有了这个图标,您下次重新启动应用程序时应该会在导航栏中看到 @。如果您点击此图标,它将带您到 /chat 路由。
接下来,当任何用户发送消息时,使图标闪烁两次以进行动画处理。为了完成这个任务,在接收到 "message" 事件时,使用 jQuery 的 fadeOut 和 fadeIn 方法对聊天图标进行操作。修改 recipe-App.js 中的 socket.on("message") 处理器,使其看起来像下一个列表中的代码。在这个例子中,您仍然使用 displayMessage 函数将消息发布到您的聊天视图;然后,通过一个简单的 for 循环,您将聊天图标动画化使其闪烁两次。
列表 32.3. 在 recipeApp.js 中发送消息时动画化聊天图标
socket.on("message", (message) => {
displayMessage(message);
for (let i = 0; i < 2; i++) {
$(".chat-icon").fadeOut(200).fadeIn(200); *1*
}
});
- 1 当发送消息时使聊天图标闪烁。
重新启动您的应用程序,并在两个不同的浏览器账户下登录。注意,现在当一位用户发送消息时,无论另一位用户在应用程序的哪个位置,他们都会看到导航栏中的聊天图标闪烁两次(图 32.2)。
图 32.2. 在导航栏中动画化聊天图标

在 第 33 课 中,您将应用这些步骤并在您的毕业设计中完全实现聊天功能。
快速检查 32.2
Q1:
对错:您可以在应用程序的任何页面上处理
socket.io事件。
| |
QC 32.2 答案
1:
对。对于本课的例子,您在 layout.ejs 文件中导入了
socket.io库,该文件用于每个视图。同样,您的客户端 JavaScript 也位于导入到布局文件中的文件中。如果您只在特定视图中导入socket.io客户端,您就只能在该特定页面上处理事件。
摘要
在本课中,你学习了如何自定义 socket.io 事件以用于正常聊天功能之外。因为事件可以在具有 socket.io 客户端的任何应用程序部分中使用,所以你可以为许多类型的开放连接数据传输创建事件。首先,你创建了一个新事件,用于在用户断开连接时通知其他用户。然后你使用现有事件在布局导航中触发非聊天功能。随着聊天功能的运行,是时候将相同的工具应用到你的毕业设计项目(课程 33)上了。然后就是部署的时候了!
尝试这个
现在您的聊天应用已经有一个功能,可以让用户知道当其他用户断开连接时,那么知道用户连接的时间也会很有用。使用 io.on("connection") 触发一个新事件到您的客户端,让他们知道有新用户加入了聊天。
当你完成时,看看你是否可以在连接消息中添加用户的姓名,例如 通知:Jon Wexler 已加入聊天。
课程 33. 毕业设计:为 Confetti Cuisinex 添加聊天功能
在这个阶段,我的应用程序的基础已经完成。我可以继续改进现有功能或构建新功能。在应用程序发布到生产环境并供所有人使用之前,Confetti Cuisine 要求我添加一个有趣的功能来吸引用户。毫不犹豫地,我告诉他们这正好是在他们的 Node.js 应用程序中构建聊天功能的绝佳机会。因为我不想在部署前让应用程序过于复杂,所以我将保持聊天简单。
聊天将只允许有账户的用户相互通信。每次发送消息时,我都会在幕后保存消息并将其与发送者关联起来。此外,我将利用 socket.io 维护连接客户端和服务器之间的开放连接,以实现实时通信。通过这个库的事件驱动工具,我可以从服务器向单个客户端或所有客户端发出事件,也可以从客户端向服务器发出事件。我还可以向选定的一组客户端发出事件,但在这个应用程序中我不需要实现该功能。
之后,我将连接导航栏中的聊天图标,以便在发送聊天消息时进行动画。所有用户都会看到这个图标在消息发出时进行动画。这个图标还充当了聊天页面的链接。是时候为 Confetti Cuisine 应用程序添加最后的修饰了。
33.1. 安装 socket.io
首先,我需要安装 socket.io 包。socket.io 提供了一个 JavaScript 库,通过使用 WebSockets 和长轮询来维护客户端和服务器之间的开放连接,帮助我构建实时通信门户。为了将此包作为依赖项安装,我在项目的终端窗口中运行 npm i socket.io -S。
安装了这个包后,我需要在主应用程序文件和客户端中引入它。
33.2. 在服务器上设置 socket.io
在我需要socket.io之前,我需要将使用 Express.js 创建的服务器实例保存下来,通过将 main.js 中的app.listen行赋值给一个名为server的常量。在这行代码下面,我在项目中通过添加const io = require("socket.io")(server)来引入socket.io。在这行代码中,我同时引入了socket.io模块,并传递了 Express.js 使用的 HTTP 服务器实例。这样,socket.io使用的连接将与我主应用程序使用的 HTTP 服务器共享。将我的socket.io实例存储在io常量中后,我就可以开始使用io来构建我的聊天功能了。
首先,我为聊天功能设置了一个新的控制器。尽管所有的socket.io代码都可以存在于 main.js 中,但将其放在自己的控制器中更容易阅读和维护。我开始在 main.js 中引入一个新的控制器,并通过添加const chatController = require("./controllers/chatController")( io )到 main.js 的底部来传递io对象。接下来,我在 controllers 文件夹中创建了一个名为 chatController.js 的新文件。在这个文件中,我添加了列表 33.1 中的代码。
我使用在 main.js 中创建的相同的io对象来监听特定的 socket 事件。io.on ("connection")在新的客户端连接到我的 socket 服务器时做出反应。client.on ("disconnect")在已连接的客户端断开连接时做出反应。client.on("message")在客户端 socket 向服务器发送自定义的message事件时做出反应。我可以命名这个事件,因为我正在处理聊天消息,这个事件名看起来似乎是合适的。在最后一个块中,我使用io.emit将来自单个客户端的数据发送回所有已连接的客户端,这样每个人都能收到单个用户提交的相同消息。
列表 33.1. 在 chatController.js 中添加聊天操作
module.exports = io => { *1*
io.on("connection", client => { *2*
console.log("new connection");
client.on("disconnect", () => { *3*
console.log("user disconnected");
});
client.on("message", (data) => { *4*
let messageAttributes = {
content: data.content,
userName: data.userName,
user: data.userId
};
io.emit("message"); *5*
});
});
};
-
1 导出聊天控制器的内容。
-
2 监听新用户连接。
-
3 监听用户断开连接时的情况。
-
4 监听自定义消息事件。
-
5 向所有已连接用户广播消息。
最后一行代码发送了我期望从客户端接收的特定消息属性集。也就是说,我期望客户端在发送内容、用户名和用户 ID 的同时触发一个message事件。我需要在视图中发送这三个属性。
33.3. 在客户端设置 socket.io
为了建立一个成功的聊天连接,我需要一个视图来促进客户端的 socket 连接。我想在名为 chat.ejs 的视图中构建我的聊天框,该视图可通过/chat URL 路径访问。我在 homeRoutes.js 中为这个路径添加了一个新的路由,通过添加router.get("/chat", homeController.chat)。
然后,我通过添加下一个列表中的代码到 homeController.js 中来匹配这个路由。这段代码渲染了我的 chat.ejs 视图。
列表 33.2. 在 homeController.js 中添加聊天操作
chat: (req, res) => {
res.render("chat"); *1*
}
- 1 渲染聊天视图。
为了渲染我的聊天视图,我需要构建视图。我在我的视图文件夹中创建了一个名为 chat.ejs 的新文件,并添加了列表 33.3 中的代码。在这段嵌入式 JavaScript (EJS) 代码中,我首先检查视图中是否存在currentUser。之前,我通过 Passport.js 设置了currentUser作为局部变量,以反映一个活跃的用户会话。如果用户已登录,我显示聊天表单。表单包含三个输入。其中两个输入是隐藏的,但携带用户的名称和 ID。我将在以后使用这些输入将消息作者的标识发送到服务器。第一个输入是实际的消息内容。以后,我将从这个输入中获取值作为提交给服务器的消息内容。
列表 33.3. 在chat.ejs中的聊天表单中添加隐藏字段
<% if (currentUser) { %> *1*
<h1>Chat</h1>
<form id="chatForm">
<input id="chat-input" type="text">
<input id="chat-user-id" type="hidden" value="<%=
currentUser._id %>">
<input id="chat-user-name" type="hidden" value="<%=
currentUser.fullName %>"> *2*
<input type="submit" value="Send">
</form>
<div id="chat"></div>
<% } %>
-
1 检查是否有已登录的用户。
-
2 添加包含用户数据的隐藏字段。
这个谜题的最后几块是添加一些客户端 JavaScript 来监控这个聊天页面的用户交互,并提交socket.io事件以通知服务器有新消息。在我的公共文件夹中,我找到了 confettiCuisine.js 并将其中的代码添加到列表 33.4。在这段代码中,我为客户端导入socket.io并添加了通过 WebSockets 与服务器交互的逻辑。在第一个代码块中,我使用 jQuery 来处理我的表单提交并从我的表单的三个输入中获取所有值。我期望在服务器的client.on("message")事件处理器中接收到这三个相同的属性。
第二段代码使用socket对象来表示将运行此代码的特定客户端。socket.on("message")设置客户端监听从服务器发出的message事件。当该事件被触发时,每个客户端都会将随事件传递的消息传递给一个我创建的自定义displayMessage函数。这个函数定位视图中的聊天框,并将消息添加到屏幕上。
列表 33.4. 在 confettiCuisine.js 中为客户端添加 socket.io
const socket = io(); *1*
$("#chatForm").submit(() => { *2*
let text = $("#chat-input").val(),
userName = $("#chat-user-name").val(),
userId = $("#chat-user-id").val();
socket.emit("message", {
content: text,
userName: userName,
userId: userId
}); *3*
$("#chat-input").val("");
return false;
});
socket.on("message", (message) => { *4*
displayMessage(message);
});
let displayMessage = (message) => { *5*
$("#chat").prepend( $("<li>").html(message.content));
};
-
1 在客户端初始化 socket.io。
-
2 在聊天表单中监听提交事件。
-
3 在表单提交时触发事件。
-
4 监听事件,并填充聊天框。
-
5 在聊天框中显示消息。
在我的应用程序可以使用此文件中的io对象之前,我需要在 layout.ejs 中通过添加以下脚本标签在我的 confettiCuisine.js 导入行上方来require它:<script src="/socket.io/socket.io.js"></script>。这一行从我的 node_modules 文件夹中为客户端加载socket.io。
我已经准备好启动我的应用程序,并看到聊天消息从一个用户流到下一个用户。通过一些样式,我可以让用户更容易地区分他们的消息。我还可以在聊天框中使用用户的名称,这样发送者的名称和消息就会并排显示。为此,我修改了我的 displayMessage 函数,如下一列表所示。我检查正在显示的消息是否属于该用户,通过比较当前用户的 ID 和消息对象中的 ID。
列表 33.5. 从 confettiCuisine.js 中的聊天表单中提取隐藏字段值
let displayMessage = (message) => {
$("#chat").prepend( $("<li>").html(`
<div class='message ${getCurrentUserClass(message.user)}'>
<span class="user-name">
${message.userName}:
</span>
${message.content}
</div>
`)); *1*
};
let getCurrentUserClass = (id) => {
let userId = $("#chat-user-id").val();
if (userId === id) return "current-user"; *2*
else return "";
};
-
1 与消息一起显示用户的名称。
-
2 检查消息是否属于当前用户。
接下来,我需要通过创建消息模型来保留这些消息在我的数据库中。
33.4. 创建消息模型
为了确保我的聊天功能值得使用,并且是 Confetti Cuisine 应用程序上用户的实用工具,消息不能在每次用户刷新页面时消失。为了解决这个问题,我将构建一个消息模型来包含聊天表单中的消息属性。我在项目的模型文件夹中创建了一个新的 message.js 文件,并将 列表 33.6 中的代码添加到该文件中。
在这段代码中,我定义了一个包含 content、userName 和 user 属性的消息模式。聊天消息的内容是必需的,用户的名称和 ID 也是必需的。本质上,每条消息都需要一些文本和一个作者。如果有人试图在不登录和验证的情况下以某种方式保存消息,数据库将不允许保存这些数据。我还将 timestamps 设置为 true,这样我就可以跟踪聊天消息何时被添加到数据库中。这个功能允许我在想要的情况下在聊天框中显示时间戳。
列表 33.6. 在 message.js 中创建消息模式
const mongoose = require("mongoose"),
{ Schema } = require("mongoose");
const messageSchema = new Schema({
content: {
type: String,
required: true
}, *1*
userName: {
type: String,
required: true
}, *2*
user: {
type: Schema.Types.ObjectId,
ref: "User",
required: true
} *3*
}, { timestamps: true }); *4*
module.exports = mongoose.model("Message", messageSchema);
-
1 在每条消息中要求内容。
-
2 在每条消息中要求用户的名称。
-
3 在每条消息中要求用户 ID。
-
4 将时间戳与每条消息一起保存。
这个 Mongoose 模型已经准备好在我的聊天控制器中使用。实际上,当我的聊天控制器收到一条新消息时,我会尝试保存它,然后将其发送给其他用户的聊天。我在 chatController.js 文件的顶部通过添加 const Message = require ("../models/message") 来引入这个新模型。我的 chatController.js 文件中 client.on("message") 的代码在 列表 33.7 中展示。我首先使用控制器中早先的相同 message-Attributes 来创建一个新的 Message 实例。然后我尝试保存这条消息。如果消息保存成功,我会将其发送给所有已连接的套接字;否则,我会记录错误,并且消息永远不会从服务器发送出去。
列表 33.7. 在 chatController.js 中保存消息
client.on("message", (data) => {
let messageAttributes = {
content: data.content,
userName: data.userName,
user: data.userId
},
m = new Message(messageAttributes); *1*
m.save() *2*
.then(() => {
io.emit("message",
messageAttributes); *3*
})
.catch(error => console.log(`error: ${error.message}`));
});
-
1 使用消息属性创建一个新的消息对象。
-
2 保存消息。
-
3 如果保存成功,则发射消息值,并记录任何错误。
这段代码允许消息保存到我的数据库中,但对于第一次连接的用户,聊天消息历史仍然没有显示。我将通过将旧消息加载到我的数据库中来纠正这个问题。
在保持聊天框中消息的第二个任务是保持聊天历史中聊天框中消息的一致数量。我决定允许聊天框在任何给定时刻包含最近的十条聊天记录。为了做到这一点,我需要从我的数据库中加载这十条最近的聊天记录,并在它们连接到聊天时立即将它们发射给每个客户端。
在 chatController.js 中,我添加了列表 33.8 中的代码来查找最近的十条聊天消息,并通过一个新的自定义事件将它们发射出来。我使用sort({createdAt: -1})来按降序排列我的数据库结果。然后,我添加limit(10)来限制这些结果只包含最近的十条。通过在客户端 socket 上发射自定义的"load all messages"事件,只有新连接的用户将刷新他们的聊天框以显示最新的聊天消息。然后,我使用messages.reverse()反转消息列表,以便在视图中将它们前置。
列表 33.8. 在 chatController.js 中加载最近的聊天消息
Message.find({})
.sort({
createdAt: -1
})
.limit(10)
.then(messages => { *1*
client.emit("load all messages",
messages.reverse()); *2*
});
-
1 查询最近的十条消息。
-
2 只向新 socket 发射包含十条消息的自定义事件。
为了处理客户端的"load all messages"事件,我在下一个列表中添加了事件处理程序到 confettiCuisine.js。在这段代码块中,我监听"load all messages"事件的发生。当它发生时,我遍历客户端接收到的消息,并通过displayMessage函数在聊天框中逐个显示它们。
列表 33.9. 在 confettiCuisine.js 中显示最近的聊天消息
socket.on("load all messages", (data) => { *1*
data.forEach(message => {
displayMessage(message); *2*
});
});
-
1 通过解析传入的数据来处理‘load all messages’。
-
2 将每条消息发送到 displayMessage 以在聊天框中显示。
聊天功能最终完成并准备好本地测试。为了模拟两个独立用户之间的通信,我重新启动了我的应用程序并在两个不同的网络浏览器上登录。我导航到聊天页面,看到我的聊天消息正在通过 Node.js 应用程序的socket.io实时发送。
我想向这个应用程序添加一个最后的特性:一个图标,让应用程序其他部分的用户知道聊天是否活跃。我可以很容易地通过现有的socket.io事件设置来实现这个功能。我需要做的只是在我的应用程序导航栏中添加一个图标,通过在 layout.ejs 中添加<a href="/chat" class="chat-icon">@</a>来实现。仅此一行,我就在我的导航栏中添加了一个链接到/chat路由的图标。
接下来,每当发送一条聊天消息时,我会让图标闪烁两次来动画化它。因为每次提交新消息时,我已经从服务器端发射了message事件,所以我可以在客户端为该事件的处理程序中添加图标动画。
在 confettiCuisine.js 中,我修改了socket.on("message")代码块,使其看起来像以下列表中的代码。在这个代码中,我像往常一样在聊天框中显示消息,并额外定位一个具有chat-icon类的元素。这个元素代表我在导航栏中的聊天图标。然后我快速淡出并淡入图标,共两次。
列表 33.10. 在 confettiCuisine.js 中发送消息时动画化聊天图标
socket.on("message", (message) => {
displayMessage(message);
for (let i = 0; i < 2; i++) {
$(".chat-icon").fadeOut(200).fadeIn(200); *1*
}
});
- 1 当发送消息时动画化聊天图标。
通过这个额外功能,用户可以有一些迹象表明聊天页面上正在进行对话。
我可以通过很多方式来增强这个聊天功能。例如,我可以为每个 Confetti Cuisine 班级创建单独的聊天室,或者使用socket.io事件在用户被标记在聊天中时通知他们。我将在未来考虑实现这些功能。
摘要
在这个综合练习中,我为我的 Confetti Cuisine 应用程序添加了实时聊天功能。我使用了socket.io来简化服务器和多个客户端之间的连接。我使用了一些内置和自定义事件在打开的套接字之间传输数据。最后,我添加了一个功能,通知不在聊天室中的用户其他人正在积极交流。添加了这个功能后,我就可以部署应用程序了。
第 8 单元:在生产环境中部署和管理代码
在您应用程序开发的任何阶段,您可能都会想知道人们何时可以开始使用您所构建的内容。这种渴望是合理的。幸运的是,您有多种方法可以使您的应用程序上线。对于构建网络应用程序的新开发者来说,部署应用程序是其中最令人畏惧的任务之一。部分挑战在于理解有助于部署的资源和服务。部署过程远不止是将您的应用程序代码上传到某个地方,至少在您的第一次尝试中是这样的。如果操作得当,对生产应用程序进行更改可以变得简单。在您的生产应用程序中进行更改时可能遇到的问题包括遇到限制,这些限制限制了您可以修改的数据库内容,意外删除用于验证传入数据的代码,以及在本地环境中进行更改,这些更改在生产环境中不起作用,例如配置更改。
在本单元中,您将设置应用程序以便在 Heroku 上部署,Heroku 是一种云服务,可为您托管和运行应用程序。首先,您准备应用程序的配置文件,以确保功能可以在本地和在生产环境中正常工作。然后,您遵循几个步骤在 Heroku 上启动应用程序并设置 MongoDB 数据库。经过简短的课程学习后,您的食谱应用程序将在一个您可以与家人和朋友分享的 URL 下运行。在随后的课程中,您将探索改进代码以供未来改进的方法。我谈论了代码检查,这是一个使用外部包来识别低效代码的过程。在本单元结束时,您将有机会对您的代码进行单元和集成测试。这些测试提供了对未来可能意外破坏代码的基本保护。您安装 mocha 和 chai 包以帮助设置 Express.js 动作和路由的测试。
本单元涵盖了以下主题:
-
第 34 课将引导您完成在应用程序准备就绪之前需要准备的工作步骤。在本课中,您将设置应用程序以便部署到 Heroku,同时使用 Heroku 上的服务插件提供的新的 MongoDB 数据库。
-
第 35 课展示了如何通过代码检查过程捕捉代码中的小错误,以及如何借助调试工具纠正这些错误。在本课结束时,您将掌握一整套技巧,以便在需要清理代码时随时使用。
-
第 36 课介绍了 Node.js 中的测试概念。本课触及了您可以编写的测试代码的表面,以确保您应用程序的功能不会随着时间的推移而损坏。
第 37 课(总结课程)将指导您如何使用在本单元中学到的部署步骤来部署 Confetti Cuisine 应用程序。
第 34 课. 部署您的应用程序
在这个阶段,您已经完成了应用程序的几个迭代,现在是时候让它对整个互联网开放了。本课介绍了使用 Heroku 的应用程序部署。首先,您设置您的应用程序以与 Heroku 的服务和插件协同工作。在几个简单的步骤中,您将使您的应用程序上线,并拥有一个独特的 URL,您可以与您的朋友分享。接下来,您将了解如何设置您的 MongoDB 数据库,并用内容填充您的应用程序。最后,您将了解可以使用 Heroku 的工具来监控生产中的应用程序,以及关于在您的生产代码中进行未来更改和值得进一步探索的 Heroku 插件的指南。
本课涵盖
-
为 Heroku 配置 Node.js 应用程序
-
部署 Node.js 应用程序
-
设置远程 MongoDB 数据库
考虑这一点
您已经花费了无数小时为您的应用程序添加功能和功能,结果它只在本地的个人计算机上运行。是时候将您的食谱应用程序的工作公之于众了。开发过程的最后一步是部署。在本课中,我将讨论使您的应用程序为生产准备所需的步骤。
34.1. 部署准备
部署是将您的应用程序代码从开发环境带到互联网上发布和运行的过程,使其对公众可访问。到目前为止,您一直在本地环境中开发您的应用程序。开发者会将运行在 http://localhost:3000的应用程序称为运行在您的开发环境中。
一个选择是设置一个新的环境。您需要重新创建使您的应用程序在您的机器上运行所需的系统设置和资源:安装了 Node.js 的物理计算机、安装任何外部包的能力以及运行应用程序的 JavaScript 引擎。您的应用程序依赖于物理硬件来运行这一事实是无法避免的。因此,将您的应用程序部署到生产环境,即其他人可以在线访问的地方,需要某种机器或服务来运行您的应用程序。
您可以设置自己的计算机来运行您的应用程序,并配置您的家庭网络以允许用户通过您家的外部 IP 地址访问您的应用程序。尽管配置步骤有些复杂;它们可能对您的家庭互联网网络构成安全威胁;并且它们超出了本书的范围。此外,如果您的计算机关闭,您的应用程序将无法访问。
流行的替代方案是使用许多云服务之一来托管和运行你的应用程序。这些服务通常需要付费,但为了演示目的,你可以通过 Heroku 的免费账户服务来部署你的应用程序。Heroku 是一个基于云的平台,它提供服务器——物理处理计算机和内存——来运行你的应用程序。更重要的是,这些计算机通常预装了你需要安装的 Node.js,并且对开发者的设置要求非常少。
要开始部署,请确保你已经通过在终端中运行heroku --version(在 Windows 命令行中为heroku version)安装了 Heroku 命令行界面。同时,确保你已经通过运行git --version安装了 Git。如果你在屏幕上看到这些工具的某个版本,你可以继续到部署步骤。
注意
如果你还没有创建你的 Heroku 账户,设置命令行界面(CLI),或者安装 Git,请按照课程 2 中的说明进行操作。
在你将应用程序部署到 Heroku 之前,你需要对应用程序进行一些更改,使其与 Heroku 提供的服务兼容。Heroku 将通过使用应用程序的PORT环境变量来运行你的应用程序,因此你需要让你的应用程序准备好在两个端口上监听,如下一列表所示。在这段代码中,你创建了一个常量port,并将其分配给PORT环境变量,如果它存在的话。否则,端口默认为 3000。这个端口号应该与之前的课程中的一致。
列表 34.1. 在 main.js 中更改应用程序的端口
app.set("port", process.env.PORT || 3000); *1*
const server = app.listen(app.get("port"), () => { *2*
console.log(`Server running at http://localhost:
${app.get("port")}`);
});
-
1 分配端口号常量。
-
2 监听分配给端口的端口。
与 Heroku 指定应用程序端口的类似方式,你将要使用的数据库也可以在环境变量中定义。在 main.js 中,将数据库连接行更改为mongoose.connect(process.env.MONGODB_URI || "mongodb://localhost:27017/recipe_db", {useNewUrlParser: true})。这一行告诉 Mongoose 连接到MONGODB_URI中定义的数据库,或者默认连接到你的本地recipe_db数据库位置。(参见第三部分了解为什么存在这个环境变量。)
最后,在应用程序的根目录下创建一个名为 Procfile 的新文件。此文件没有扩展名或后缀,并且其名称区分大小写。Heroku 使用此文件来确定如何启动你的应用程序。将web: node main.js添加到此文件。这一行告诉 Heroku 创建一个新的服务器,称为dyno,用于网络交互,并使用node main.js来启动应用程序。
在这三个更改到位后,你终于可以部署应用程序了。
快速检查 34.1
Q1:
为什么你的项目文件夹中需要 Procfile?
| |
QC 34.1 答案
1:
Heroku 使用 Procfile 作为配置文件来启动你的应用程序。
34.2. 部署你的应用程序
在适当配置的情况下,你可以使用 Git 和 Heroku CLI 来部署你的应用程序。在整个这本书中,你没有使用 Git 进行版本控制。尽管在你的开发环境中对代码进行版本控制不是必需的,但这是一个好习惯,在部署的情况下,这是必需的,以便将你的应用程序部署到 Heroku 的生产环境。如果你是第一次使用 Git,请在终端中转到你的项目根目录,通过运行git init来使用 Git 初始化项目。在下一步中,你添加你想要添加到 Git 仓库的文件,但你不想在这个仓库中添加某些文件。
你可能记得,当你运行npm install时,会创建一个node_modules文件夹。这个文件夹可能相当大,不建议将其添加到你的 Git 仓库中。为了忽略这个文件夹,在你的应用程序目录的根目录下创建一个新的文件名为.gitignore。在你的文本编辑器中将/node_modules添加到该文件中,并保存。这就是 Git 知道不要添加这个文件夹内文件的所有操作。
要将你的应用程序代码捆绑到特定版本,通过运行git add .(包括点号)将应用程序的其余文件添加到 Git 的暂存区。然后运行命令git commit -m "Initial application commit"来保存并提交此版本的代码,并接收一条反馈信息。
注意
你所做的任何其他更改,如果没有按照相同的过程添加和提交,将不会出现在你的生产环境中。
使用版本控制中的代码,你可以在终端中使用heroku关键字来启动一个新的应用程序进行部署。在终端中运行heroku create命令,在你的项目目录中生成一个新的项目 URL。详细说明你的 Heroku 应用程序名称、URL 和 Git 仓库的响应应该类似于以下列表。此命令还会创建一个连接到你的代码在 Heroku 的远程 Git 仓库。你可以运行git remote -v命令来查看该仓库的 URL。
列表 34.2. 创建新的 Heroku 应用程序
Creating app... done, crazy-lion-1990 *1*
https://crazy-lion-1990.herokuapp.com/ |
https://git.heroku.com/crazy-lion-1990.git
- 1 显示创建新 Heroku 应用程序的结果。
接下来,将你的最新版本代码从计算机推送到你设置的 Heroku 仓库。发布你的代码就像是将你的代码上传到服务器一样,该服务器将在互联网上托管你的应用程序。你可以通过运行命令git push heroku master来发布。这一步是整个过程中最重要的部分,因为这是所有代码上传和发布到 Heroku 服务的地方。这一步也是 Heroku 运行npm install来下载所有应用程序包依赖的时候。
这个过程可能需要大约一分钟,具体取决于你的互联网连接。如果你在过程中遇到任何问题或注意到错误,在再次尝试之前,请确保你仍然可以在本地运行你的应用程序。
如果你的应用程序不依赖于数据库,你可以直接在浏览器中访问 heroku create 命令后面的 URL。如果你尝试访问应用程序的 /courses URL,你可能会看到一个错误页面(图 34.1)。然而,由于你的主页不依赖于任何持久数据,因此该页面应该能够无错误地加载。
图 34.1. 显示 Heroku 错误页面

注意
如果你项目中仍然有 bcrypt 包的残留,根据你的 Node.js 版本,你可能会在部署到 Heroku 时遇到问题。尝试卸载 bcrypt 并在 usersController.js 中替换为 bcrypt。在终端中,你需要运行 npm uninstall bcrypt && npm i bcrypt -S。
这个错误很可能是因为你还没有设置你的数据库。不过,你可以在项目的终端窗口中运行命令 heroku logs --tail 来验证。这个命令提供了应用程序在线日志的实时流。你在这里会找到很多消息,如果你在未来的应用程序中遇到任何问题,我推荐你首先检查这里。假设你看到一个关于缺失数据库的错误。你可以通过连接到一个 MongoDB 数据库来修复这个问题。
注意
如果你需要一些关于 Heroku CLI 命令的帮助,请在终端中运行命令 -heroku help,或者访问 https://devcenter.heroku.com/articles/heroku-cli--commands`。
| |
快速检查 34.2
Q1:
heroku create命令的作用是什么?
| |
QC 34.2 答案
1:
heroku create命令会为你的应用程序在 Heroku 的服务上注册一个新的应用程序名称和代码仓库。它还会通过名为heroku的远程仓库将你的本地 Git 仓库链接起来。
34.3. 在生产中设置你的数据库
由于你无法直接访问运行生产应用程序的服务器,你不能像在开发中那样在该服务器上下载、安装和运行 MongoDB 数据库。然而,Heroku 提供了一个免费的插件,你可以使用它来设置一个小型的 MongoDB 数据库。要从终端添加此插件,请运行命令 heroku addons:create mongolab:sandbox。这一行从 MongoLab(mLab)配置了一个沙盒数据库。
注意
使用 mLab docs.mlab.com/shutdown-of-heroku-add-on/ 插件部署你的应用程序到 Heroku 的引用不再有效;mLab 已被弃用并从 Heroku 选项中移除。有关如何将数据库迁移到新的 MongoDB Atlas 免费层的说明,请点击此链接:docs.mlab.com/how-to-migrate-sandbox-heroku-addons-to-atlas/。
在 Amazon 和 Google 等其他云服务的帮助下,mLab 提供了可以通过 URL 远程访问的数据库和 MongoDB 服务器。您获得的 URL 被添加到您的应用程序中作为环境变量 MONGODB_URI。这个变量意味着您的应用程序可以使用变量 MONGODB_URI 来获取数据库的 URL。
警告
mLab 提供的 URL 是您应用程序数据的直接链接。只有您在 Heroku 上的应用程序应该使用此 URL;否则,您可能会面临数据库安全漏洞的风险。
您之前已设置应用程序使用此变量。您可以通过在终端中运行 heroku config 命令来验证它在您的应用程序中是否存在。运行此命令的结果是应用程序使用的配置变量列表。此时您应该只看到一个数据库变量。
注意
您可以通过运行命令 heroku config:set NAME=VALUE 来添加新的环境变量,其中 Name 是您想要设置的变量的名称,而 VALUE 是其值。我可能会设置 heroku config:set AUTHOR_EMAIL=jon@jonwexler.com。
几分钟后,您的应用程序应该准备好查看。在您的网页浏览器中,访问 Heroku 之前提供的 URL,并添加 /courses 路径以查看一个空表,如图 34.2 所示。您应该看到您应用程序的主页。尝试通过您在之前的课程中创建的表单创建新的用户账户、订阅者和组。
图 34.2. 显示 Heroku 课程页面

您可能想知道是否有比手动在浏览器表单中输入信息更简单的方法来在线填充您的新数据库。确实有!我在第 35 课中向您展示了这种方法,以及其他一些工具和技巧。
快速检查 34.3
Q1:
您如何在 Heroku 应用程序中查看和设置环境变量?
QC 34.3 答案
1:
要在您的 Heroku 应用程序中查看环境变量,请在项目终端窗口中运行
heroku config。您可以通过使用heroku config:set来设置新变量。
在本课中,您学习了如何为您的应用程序准备生产环境并将其部署到 Heroku。首先,您更改了一些应用程序配置以帮助 Heroku dyno 处理和运行您的应用程序。接下来,您通过终端 Heroku CLI 部署了应用程序。最后,您通过使用 Heroku 的 mLab 插件设置了远程 MongoDB 数据库。在第 35 课中,您将了解如何管理生产环境中的应用程序,添加数据以及调试问题。
尝试这样做
在 Heroku 上运行你的应用程序,测试所有功能以确保其正常工作。一开始,一切可能看起来都按预期进行,但请记住,环境是不同的,有时你的代码可能不会按预期工作。尝试打开一个终端窗口,运行heroku logs --tail,同时打开一个包含你的生产应用程序的浏览器窗口,并观察 Heroku 打印的日志消息。
第 35 课. 生产环境管理
你的应用程序终于上线了,你想要确保它保持完全功能的状态。在本节课中,我将讨论在表单使用之前将数据放入应用程序的方法。你可能想要添加一些你在开发中使用的课程数据,以便你的应用程序在上线时有一个数据丰富的起点。将课程数据添加到你的实时应用程序中将减少使网站页面呈现所需的时间。然后,我将讨论一些提高代码质量的方法,确保你不会犯可能导致应用程序在生产环境中崩溃的错误。最后,我将讨论在生产环境中记录、调试和监控应用程序的方法,以帮助你调查问题开始出现时的情况。
本节课涵盖
-
将种子数据加载到你的生产应用程序中
-
为你的代码设置代码检查
-
调试你的应用程序
考虑以下内容
你的应用程序终于上线了,这是一个值得骄傲的时刻,但你的客户很快发现了在开发过程中未被发现的错误。你遵循什么协议来在本地修复代码并将其上传到生产环境?
在本节课中,你将学习如何使用一些工具来维护你的生产环境中的应用程序。
35.1. 加载种子数据
在第 34 课中,你已经设置了数据库,但你可能想知道是否有简单的方法来用数据填充你的生产应用程序。你可以在 Heroku 上通过几种方式将数据上传到你的应用程序。
种子数据是在你首次在新环境中设置应用程序时输入应用程序的数据库记录。其他语言和平台有不同的约定,用于在不同环境中加载包含种子数据的文件。在 Node.js 中,你可以创建一个包含你想要加载的数据的 JavaScript 文件。例如,你可能在任何用户注册之前就想要用食谱课程填充你的应用程序。为此,你可以使用现有的种子文件或在应用程序目录中创建一个名为 seed.js 的新文件。此文件定义并创建与你的 Mongoose 插件通信的新记录。因此,你需要引入 Mongoose 以及你打算使用的模型,如列表 35.1 所示。
为了避免与现有的种子文件冲突,创建 courseSeed.js。在这个例子中,你包括创建新数据对象所需的必要模块。然后,你创建多个具有你希望在生产应用程序中看到的值的记录。当这个文件包含你想要使用的数据时,使用 Heroku 命令行界面(CLI)运行此文件中的代码。
列表 35.1. 在 courseSeed.js 中通过种子数据添加内容
const mongoose = require("mongoose"),
Course = require("./models/course"); *1*
mongoose.Promise = global.Promise;
mongoose.connect(
process.env.MONGODB_URI || "mongodb://localhost:27017/recipe_db",
{ useNewUrlParser: true }
);
Course.remove({}) *2*
.then(() => { *3*
return Course.create({
title: "Beets sitting at home",
description: "Seasonal beets from the guy down
the street.",
zipCode: 12323,
items: ["beets"]
});
})
.then(course => console.log(course.title))
.then(() => {
return Course.create({
title: "Barley even listening",
description: "Organic wheats and barleys for bread,
soup, and fun!",
zipCode: 20325,
items: ["barley", "rye", "wheat"]
});
})
.then(course => console.log(course.title))
.then(() => {
return Course.create({
title: "Peaching to the choir",
description: "Get fresh peaches from the local farm.",
zipCode: 10065,
items: ["peaches", "plums"]
});
})
.then(course => console.log(course.title))
.catch(error => console.log(error.message))
.then(() => {
console.log("DONE");
mongoose.connection.close();
});
-
1 需要模型来生成数据。
-
2 删除所有现有文档。
-
3 运行代码以创建新的数据库文档。
小贴士
作为替代方案,你可以使用 mLab URL 直接将种子数据加载到你的生产数据库中。尽管这种方法很快,但我不建议这样做,因为它会使你的生产数据库面临安全风险。
另外两种方法是使用 Heroku CLI 工具启动你的生产应用程序的 REPL 或终端环境。你可能还记得,REPL 可以访问你的项目目录中的文件和文件夹,因此它是从终端插入数据的好方法。通过在你的项目终端窗口中运行命令 heroku run node 来启动 REPL。有了这个为你生产应用程序提供的类似 REPL 的环境,你可以简单地复制并粘贴 courseSeed.js 中的内容到终端。另一种方法是,在你的项目终端窗口中运行 heroku run bash。这个命令会弹出一个提示符,你可以在其中运行 node courseSeed 以直接加载所有内容。首先,你需要将 courseSeed.js 文件提交到 git 并推送到 Heroku。
如果你操作成功,你应该会看到每个课程创建的日志输出,这些输出也会立即出现在你应用程序在线的 /courses 路由中(图 35.1)。
图 35.1. 已填充的课程页面显示

注意
要上传项目的新更改,运行 git add . 然后运行 git commit -m “一些提交信息” 和 git push heroku master。
在下一节中,我将讨论维护代码完整性的方法,并确保不会出现新的错误。
快速检查 35.1
Q1:
当你运行
heroku run node时会发生什么?
| |
QC 35.1 答案
1:
heroku run node在你的生产应用程序上下文中为你打开一个新的 REPL 窗口。从那里,你可以像本地一样运行 JavaScript 命令并加载特定于应用程序的模块,同时可以访问你的生产数据库。
35.2. Linting
缺陷和编码错误是开发过程的一部分。你能做些什么来预防那些不可避免地会阻碍生产的错误?除了代码质量外,通过执行特定的标准来执行代码检查的过程也是减少错误的一种方式。代码检查涉及运行一个程序来阅读你的代码,并通知你那些你可能没有捕捉到的错误或问题。你可能在开发过程中也会错过(某些浏览器可能会忽略)可能导致应用程序在不同环境中崩溃的语法错误。要检查你的代码,通过在终端中运行 npm install -g eslint 全局安装一个名为 eslint 的包。ESLint 是一个开源工具,用于在终端中运行代码的静态分析。通过这种分析,你可以识别代码风格和结构问题。你还可以使用其他代码检查库,例如 JSLint 和 JSHint。你可以在 eslint.org/ 上了解更多关于 ESLint 的信息。
注意
你也可以通过在终端中运行 npm install eslint --save-dev 在你的项目目录中安装这个项目的包。--save-dev 标志表示这个包不需要在生产环境中安装;它将在你的应用程序的 package.json 中被标记为这种方式。要使用已作为开发依赖项安装的 eslint,你需要从 ./node_modules/.bin/eslint 访问它。
当你使用 npm init 初始化一个新的 package.json 文件时,通过在你的项目终端窗口中运行 eslint --init 来初始化一个 .eslintrc.js 文件。选择通过在终端中回答问题来设置你的文件,如 列表 35.2 中所示。你需要让代码检查器知道要查找 ES6 语法和方法,因为你会在整个应用程序中使用它们。你还告诉代码检查器在服务器和客户端上分析你的代码,因为你为两者都编写了 JavaScript。
列表 35.2. 在终端中设置你的 .eslintrc.js 文件
? How would you like to configure ESLint? Answer questions about
your style *1*
? Are you using ECMAScript 6 features? Yes
? Are you using ES6 modules? Yes
? Where will your code run? Browser, Node
? Do you use CommonJS? No
? Do you use JSX? No
? What style of indentation do you use? Tabs
? What quotes do you use for strings? Double
? What line endings do you use? Unix
? Do you require semicolons? Yes
? What format do you want your config file to be in? JavaScript
- 1 设置代码检查器的问题答案
查看在提示末尾生成的 .eslintrc.js 文件,如 列表 35.3 中所示。注意,你正在格式化代码检查器的配置为 JavaScript,而不是像你的 package.json 文件那样的 JSON。就像你的其他 JavaScript 模块一样,这些配置被分配给 module.exports。接下来的大多数配置都是相当直接的。你的环境被指定为包括 node、网络浏览器和 ES6 语法。然后是 eslint 规则,它们定义了何时警告你不一致性。在这种情况下,当你使用空格而不是制表符、在语句末尾缺少分号或文本周围使用单引号时,你会抛出一个代码检查器错误。你可以根据你的偏好更改这些配置。
列表 35.3. 示例 .eslintrc.js 配置文件
module.exports = {
"env": { *1*
"browser": true,
"es6": true,
"node": true
},
"extends": "eslint:recommended",
"parserOptions": {
"sourceType": "module"
},
"rules": { *2*
"indent": [
"error",
"tab"
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"double"
],
"semi": [
"error",
"always"
]
}
};
-
1 指定要分析的环境。
-
2 定义 ESLint 规则。
通过运行eslint main.js在 main.js 文件上测试你的代码检查器。我希望你一开始不会看到任何错误。尝试删除一个分号或定义一个后来不再使用的变量。注意eslint如何输出带有行号的错误,这样你可以轻松地纠正代码。干净的代码有助于确保应用程序的完整性和可读性。
注意
请记住,一些代码检查规则比其他规则更严格。这些规则旨在保持你代码的一致性。如果你看到有关空格与制表符的错误,这些错误并不意味着你的代码有问题——只是它可能需要清理。
你终端窗口中错误的输出详细说明了你需要访问哪些文件和行号来纠正你的语法或代码结构。
快速检查 35.2
Q1:
.eslintrc.js 的作用是什么?
QC 35.2 答案
1:
与 package.json 类似,.eslintrc.js 存储了你在终端初始化过程中设置的
eslint配置设置。此文件包含规则,根据这些规则,代码检查器会确定你的代码是否需要修复。
35.3. 调试你的应用程序
在本书的前面部分,你了解了几种调试应用程序的方法。你使用了console.log在 Express.js 中间件函数中打印自定义消息、错误消息和请求/响应特定数据。然后你使用终端窗口中的日志来确定要修复的问题的位置。例如,如果保存用户到数据库时发生错误,你会在你的 promise 链中捕获错误并将其记录到控制台。
当正确使用时,日志记录很有帮助。日志提供了事务和与你的应用程序交互的记录历史。即使你的应用程序运行顺利,你也希望你的开发日志告诉你更多关于应用程序性能的信息,并且你希望你的生产日志通知你可疑活动。
在本地,你可以通过以调试模式启动应用程序来获取有关请求-响应周期的更多信息。在你的项目终端窗口中,输入命令DEBUG=* node main来设置DEBUG环境变量,以便在应用程序运行时记录其所有元素的日志。
注意
在 Windows 机器上,首先设置环境变量,然后通过运行命令set DEBUG=* & node main来运行应用程序。
你会立即注意到,你的终端窗口中的日志行数反映了 Express.js 注册你的路由所执行的操作,以及它在你的 web 服务器启动前所做的某些配置(列表 35.4)。现在,当你在本地的应用程序中访问任何页面时,调试日志会流到你的终端窗口。方便的是,Express.js 还会在其日志消息中告诉你每个操作花费了多少时间。在开发过程中,这些信息可以帮助你确定应用程序的某些部分是否表现不佳,以便你可以进一步调查。
列表 35.4. 在终端中通过 Express.js 的日志消息示例
express:router:route new "/new" +0ms *1*
express:router:layer new "/new" +0ms
express:router:route get "/new" +0ms
express:router:layer new "/" +0ms
express:router:route new "/create" +0ms
express:router:layer new "/create" +0ms
- 1 在调试模式下记录 Express.js 路由注册。
如果你发现运行带有调试日志的应用程序很有帮助,你可以在 package.json 文件中添加一个启动脚本,以避免每次都编写整个命令。在你的启动脚本后添加 "debug": "DEBUG=* node main"。然后,无论何时你想查看这些日志,只需运行 npm run debug 命令。
虽然在生产环境中你不想以调试模式运行你的应用程序,但这些日志在生产环境中同样有价值。相反,安装另一个包来处理你希望在生产中看到的重要数据的记录。安装一个名为 morgan 的包,以提供更好的 Node.js 应用程序控制台日志消息。
通过运行命令 npm i morgan -S 安装 morgan 包。然后,在 main.js 中,通过添加 const morgan = require("morgan") 来引入 morgan 模块。然后,这个过程就像告诉你的 Express.js 应用程序使用 morgan 并传递一些格式化选项一样简单。例如,你可以添加 app.use(morgan(":method :url :status * :response-time ms")) 来记录请求方法、URL、状态码和响应处理所需的时间。
此输出应立即类似于 Express.js 在调试模式下生成的日志。通过运行 npm start 启动你的应用程序,并注意每个请求的日志,如下一列表所示。我建议使用 morgan("combined") 格式,其中组合的格式化选项提供了你监控生产应用程序中的请求-响应周期所需的大部分信息。
列表 35.5. 使用 morgan 的日志消息示例
GET / 200 * 20.887 ms *1*
GET /js/jquery.min.js 304 * 2.504 ms
GET /js/bootstrap.min.js 304 * 1.402 ms
GET /js/recipeApp.js 304 * 0.893 ms
GET /css/recipeApp.css 304 * 1.432 ms
- 1 使用 morgan 记录自定义消息。
在设置好日志记录后,调试问题的最佳方法是在问题发生的地方暂停你的应用程序,并分析周围的代码。这种做法说起来容易做起来难,但有一些工具可以帮助你识别有问题的代码。Node.js 内置了一个调试工具,允许你逐行执行代码。在每一行代码之后,你可以评估变量和数据,以确定它们的值是否如你所期望。
要运行内置调试器,请在项目终端窗口中运行 node inspect main.js 命令。运行此命令后,你将立即看到你的 main.js 文件的第一行显示在终端窗口中。工具会在你的应用程序启动时立即暂停,显示 Break on start in main.js:1。你可以通过输入 n 跳到下一行,逐行增量跳过,或者输入 c 继续运行你的应用程序。如果你输入 c,你的应用程序将像平常一样运行。当你认为你的代码在某些地方没有正确工作时,调试器特别有用。例如,如果你认为你的代码在用户的展示页面中没有正确找到用户,你可能想要暂停该控制器动作中的代码。要在特定位置暂停,请在你的代码中添加 debugger;,如 列表 35.6 所示。
通过添加此行,再次在终端中运行调试器,并输入 c 让应用程序运行,你正在设置应用程序在渲染视图之前查询数据库中的用户时为你停止。
列表 35.6. 在 usersController.js 中调试 show 动作
User.findById(userId)
.then(user => {
debugger; *1*
res.render("users/show", {
user: user
});
});
- 1 在数据库中找到用户时添加调试器断点。
当你在浏览器中访问用户的展示页面时,页面会暂停,你的终端窗口会显示你放置 debugger; 的代码位置。从那里,你可以通过进入 REPL 环境来调查这段代码中的变量。在终端的调试器窗口中输入 repl,你可以在被调试的代码上下文中运行正常的 REPL 命令。在这个例子中,你正在检查从数据库检索到的用户是否有有效的电子邮件地址,因此运行以下语句:console.log(user.email)。如果你得到 undefined 或用户电子邮件地址以外的某些值,你知道问题与电子邮件有关,并且可以进一步调查。当你完成调试后,输入 c 继续执行,并按 Ctrl-D 退出。有关此调试器的更多信息,请访问 nodejs.org/api/debugger.html。
内置调试工具可以是有助于分析应用程序运行时数据的有用方式。然而,完全以这种方式调试你的代码涉及几个步骤,所以我建议探索其他调试工具,例如 node-inspector,它允许你使用 Google Chrome 中的控制台进行调试。你还可以使用 Node.js 与集成开发环境(如 Atom 中的 TernJS)一起使用,它在你编辑代码时提供调试工具。
快速检查 35.3
Q1:
当你在应用程序代码中添加
debugger时会发生什么?
| |
QC 35.3 答案
1:
在您的代码中添加
debugger允许 Node.js 的调试工具在应用程序运行时暂停在该特定位置。在调试工具之外,此添加不会阻止您的应用程序正常运行。
总结
在本节课中,您学习了如何通过 Heroku 控制台向您的生产应用程序添加数据。然后,您安装了 eslint 来检查您的应用程序中的错误或代码中的语法不一致性。最后,我介绍了一些调试技巧,以帮助您识别生产错误并立即知道去哪里修复它们。
尝试以下操作
尝试使用 Node.js 的调试器来评估应用程序中不同变量的值。尝试以调试模式运行您的应用程序,并在用户的 create 动作中中断以评估传入的请求参数。
第 36 课:测试您的应用程序
在生产环境中持续维护您的应用程序需要修复错误。修复错误意味着编写新代码。编写新代码往往会导致现有功能中断。在本节课中,您将采取一些步骤,通过在您的 Node.js 应用程序上实施测试来防止现有代码的破坏。在 Node.js 中编写测试与其他平台和语言的测试类似。首先,您将学习如何为应用程序中的函数编写简单的测试。然后,您将实现控制器操作和模型的测试,以覆盖您应用程序代码的大部分内容。在本节课结束时,您将具备开始测试您的 Node.js 应用程序所需的基本技能。
本节课涵盖
-
使用核心模块编写断言测试
-
使用
mocha和chai编写 Node.js 测试 -
使用
chai-http构建和运行控制器操作的测试 -
为您的 API 实现测试
考虑以下情况
您的食谱应用程序在生产环境中看起来很棒,您已经从一些当地开发者那里获得了开发支持。您的应用程序代码正在由多个人共同工作,新开发者并不一定知道他们实现的新功能将如何影响您已经构建的功能。
新开发者向用户控制器添加了一个新的 index 动作。这个新动作没有响应您最初计划的所有用户数据,这影响了您的 API 和视图。如果您为 index 动作编写测试,指定您期望它返回的数据,新开发者将有一个参考点,了解他们的修改允许哪些功能发生变化。
36.1. 使用核心模块进行基本测试
在技术行业中,应用程序测试是一种标准做法。当您编写具有明确功能的一些代码时,您想确保该功能不会改变,除非它是打算改变的。为了帮助确保您的代码不会意外地受到您实现(或另一位开发者实现)的更改和新功能的影响,您可以编写测试。测试包含三个组件:
-
测试数据表示您在应用程序中预期接收到的样本数据
-
期望详细说明在您的测试数据和应用程序代码下,一个函数或一系列操作应该输出什么
-
一个测试框架来运行您的测试并确定您定义的期望是否得到满足
在了解您可以使用的一些外部工具来测试您的应用程序之前,您可以使用 Node.js 附带的核心模块。assert 模块提供了一些基本函数,您可以使用它们来确认两个值的相等性。您可以将这些函数视为被测试语言包裹的条件语句。
您可以通过导航到一个名为 simple_test 的新项目文件夹,并创建一个名为 test.js 的新文件,其中包含列表 36.1 中显示的代码来使用此模块。在这个例子中,您需要引入 assert 模块。然后,您通过使用 assert.equal 来编写一个断言测试,以确定第一个值,即对您自定义的 add 函数的调用结果,是否等于第二个参数,0。最后,您编写 add 函数来接受两个值并返回它们的和。在这个例子中,您期望 5 和 4 的相加等于 0。正如您所期望的,这个测试应该失败,并且当它失败时,最终参数中的消息应该出现在终端中。
在 simple_test 项目目录中输入 node test 来运行此文件,以在终端中查看断言错误。该错误应读取为 AssertionError [ERR_ASSERTION]: 5 plus 4 should equal 9。
列表 36.1. test.js 中的简单断言测试
const assert = require("assert"); *1*
assert.equal(add(5, 4), 0, "5 plus 4 should equal 9"); *2*
let add = (x, y) => { *3*
return x + y;
};
-
1 引入
assert模块。 -
2 编写断言测试。
-
3 实现测试中指定的函数。
为了纠正这个测试,您需要将 0 改为 9。您也可以在这里添加另一个断言测试来指定您的 add 函数不应该返回什么。例如,您可以编写 assert.notEqual (add(5, 4), 0)。如果这个测试失败,您就会知道您的 add 函数有问题需要修改。
assert 模块是开始编写 Node.js 测试的绝佳方式。然而,对于您的应用程序,您将从测试更复杂功能的外部包中受益。有关 assert 模块的更多信息,请访问 nodejs.org/api/assert.html。
测试驱动开发
测试驱动开发(TDD)是一种应用程序开发策略,其中首先编写指定代码期望的测试,然后是实现功能以通过初始测试的设计。
你想要确保你的测试全面覆盖应用程序的功能,这意味着编写测试来指定当应用程序提供有效和无效数据时应该如何工作。有时,当你已经实现了应用程序代码后编写测试时,很容易错过测试套件中没有考虑到的边缘情况。因此,TDD 可以提供更全面的开发体验。
TDD 包括以下步骤:
-
使用样本数据和预期的结果编写测试,使用你稍后将要构建的方法或函数通过该样本数据。
-
运行你的测试。此时,所有测试应该都失败。
-
实现代码,以便你的测试按照你在测试中定义的期望执行。
-
再次运行你的测试。此时,所有测试应该都通过。
如果你编写了应用程序代码后测试没有通过,这可能意味着你的应用程序代码还没有完善。
如果你正在使用 TDD 来实现一个名为 reverse 的函数,该函数接受一个字符串作为参数并反转它,例如,你可能遵循以下步骤:
-
为
reverse函数编写一个测试,使用测试字符串var s = "Hello",当你运行reverse(s)时,你期望结果是"olleH"。 -
运行测试,并预期它们会失败。
-
编写反转字符串的代码。
-
重新运行测试,直到所有测试都通过。
| |
快速检查 36.1
Q1:
什么是断言测试?
| |
QC 36.1 答案
1:
断言测试 是你编写的代码,用于表达你对某些样本数据可能如何变化、相等或以其他方式与另一个值相关联的期望。这个测试可以是两块原始数据之间的比较,或者是一个函数调用或一系列操作产生的数据之间的比较。
36.2. 使用 mocha 和 chai 进行测试
要开始测试你的应用程序,请在 recipe-application 终端窗口中通过运行命令 npm i mocha -g 和 npm i chai -S 安装 mocha 和 chai 包。mocha 是一个测试框架。与 Express.js 类似,mocha 提供了结构和方法,可以一起使用来测试你的应用程序代码。你全局安装 mocha,因为你需要在终端中使用 mocha 关键字,并且你可能会测试其他项目。chai 应该作为开发依赖项安装,因为你只会在本地测试你的代码;你不需要在这个生产环境中安装此包。
要使用 mocha 模块,请在终端中运行项目目录下的 mocha。运行此命令将 mocha 指向项目文件夹内的测试文件夹。与任何框架一样,使用传统的目录结构来保持你的测试组织良好,并与其他代码文件分开,因此你需要在应用程序目录的根目录下创建该测试文件夹。
注意
访问 mochajs.org 获取有关 mocha 框架的更多信息,从安装到在终端中使用。
mocha 帮助你描述和运行测试,但它不提供你确定代码输出是否符合预期所需的工具。为此目的,你需要一个断言引擎来运行断言,这些断言描述了代码应该如何输出指定的值。
chai 是你将在本课中使用的断言引擎。为了使用 chai,在每个你计划运行的测试文件中引入它。然后,就像你的核心模块中的 assert 方法一样,你可以使用 expect、should 或 assert 作为函数动词来检查你的代码是否在测试中返回了预期的结果。对于以下示例,使用 expect 函数。chai 还有一些描述性函数,可以帮助你在断言本身之前解释你的测试。你将使用 describe 函数来指定你正在测试的模块和函数。
注意
describe 函数可以嵌套。
对于实际测试,使用 it 函数来解释你在测试中期望发生什么。从语义上讲,这个函数允许你的测试以这种方式阅读:在特定的模块中,对于特定的函数,当提供一些特定数据时,你的代码(it)应该以某种方式表现。你将在下一个示例中更详细地了解这种语义结构。
使用这些包的最后几个步骤是创建测试文件,引入任何包含你想要测试的方法的自定义模块,并在你的测试中提供样本数据。为你的食谱应用程序编写一个简单的测试,使用 mocha 和 chai。在你的项目目录中的测试文件夹内创建一个名为 usersControllerSpec.js 的新文件。按照开发惯例,Spec 用于文件名以表示测试套件。
在此文件中,测试你在第二十五部分的基石练习中使用的 getUserParams 函数。为了测试目的,将 getUserParams 函数添加到 usersController.js 中,如 列表 36.2 所示。
注意
你可以在 create 动作中使用此函数,通过以下行创建一个新的 User 实例:let newUser = new User(module.exports.getUserParams(req.body))。你可以通过 module.exports 来引用 getUserParams。
除非你导出这个函数,否则其他模块无法访问该函数。
列表 36.2. 导出 getUserParams 函数
getUserParams: (body) => { *1*
return {
name: {
first: body.first,
last: body.last
},
email: body.email,
password: body.password,
zipCode: body.zipCode
};
}
- 1 在 usersController.js 中导出 getUserParams。
在 usersControllerSpec.js 中,需要chai和 usersController.js。你的测试文件中的代码类似于列表 36.3 中的代码。因为你使用了expect断言函数,你可以直接从chai模块中引入它;你不需要chai做其他任何事情。然后通过声明你要测试的模块来定义你的第一个describe块。接下来的describe块指定了你正在测试的函数。在这个嵌套的describe块内部,你可以运行多个与getUserParams相关的测试。在这种情况下,你正在测试getUserParams在提供样本请求体时是否返回包含你的name属性的数据。第二个测试确保空白请求体导致空对象。你使用deep.include来比较两个 JavaScript 对象的内容。有关chai断言方法的更多信息,请访问chaijs.com/api/bdd/。
列表 36.3. 在 usersControllerSpec.js 中导出getUserParams函数
const chai = require("chai"),
{ expect } = chai, *1*
usersController = require("../controllers/usersController");
describe("usersController", () => { *2*
describe("getUserParams", () => {
it("should convert request body to contain
the name attributes of the user object", () => { *3*
var body = {
first: "Jon",
last: "Wexler",
email: "jon@jonwexler.com",
password: 12345,
zipCode: 10016
}; *4*
expect(usersController.getUserParams(body))
.to.deep.include({
name: {
first: "Jon",
last: "Wexler"
}
}); *5*
});
it("should return an empty object with empty request
body input", () => {
var emptyBody = {};
expect(usersController.getUserParams(emptyBody))
.to.deep.include({});
});
});
});
-
1 引入 expect 函数。
-
2 在 describe 块中定义你的测试焦点。
-
3 详细说明你的测试期望。
-
4 提供示例输入数据。
-
5 期望结果中包含某些对象。
要运行此测试,请在你的项目终端窗口中输入mocha命令。你应该会看到两个测试都通过(图 36.1)。如果你遇到错误或测试失败,请确保你的模块可以相互访问,并且你的代码与代码列表匹配。
图 36.1. 在终端显示通过测试

注意
在终端退出你的 mocha 测试,请按 Ctrl-D。
在下一节中,你将实现一个覆盖多个函数的测试。
快速检查 36.2
Q1:
describe和it之间的区别是什么?
| |
QC 36.2 答案
1:
describe将相关于特定模块或函数的测试包裹起来,这使得在终端中显示测试结果时更容易分类。it块包含你实际编写的断言测试。
36.3. 使用数据库和服务器进行测试
要测试一个 Web 框架,你需要比一些样本数据和访问你正在测试的模块更多。理想情况下,你希望重新创建你的应用程序通常运行的环境,这意味着提供一个功能性的 Web 服务器、数据库以及你的应用程序使用的所有包。
您的目标是在开发环境之外设置一个环境。您可以通过process.env.NODE_ENV环境变量定义测试环境。在任何测试文件顶部添加process.env.NODE_ENV = "test",以便让 Node.js 知道您正在测试环境中运行应用程序。这种区分可以帮助您区分数据库和服务器端口。如果您在测试环境中运行应用程序,您可以告诉应用程序使用recipe_test_db数据库并在端口 3001 上运行,例如。这样,您可以测试从数据库中保存和检索数据,而不会干扰您的开发数据或开发服务器。
现在,指示您的应用程序在测试环境中使用recipe_test_db测试数据库,在其他情况下默认使用生产环境和开发数据库,如下一列表所示。在此示例中,您在代码中较早定义了一个db变量并将其分配给本地数据库。如果环境变量process.env.NODE_ENV告诉您您处于测试环境,则db变量指向您的测试数据库 URL。
列表 36.4. 在 main.js 中分离环境数据库
if (process.env.NODE_ENV === "test") "mongoose.
connect(mongodb://localhost:27017/recipe_test_db", { *1*
useNewUrlParser: true});
else mongoose.connect(process.env.MONGODB_URI ||
"mongodb://localhost:27017/recipe_db",{ useNewUrlParser: true }); *2*
-
1 在测试环境中将端口分配给您的测试数据库。
-
2 默认使用生产环境和开发数据库。
注意
如果不存在,MongoDB 会为您创建这个测试数据库。
您将相同的逻辑应用于您的服务器端口,如下所示列表所示。在此,如果您处于测试环境,则使用端口 3001。否则,您将使用迄今为止使用的常规端口。
列表 36.5. 在 main.js 中设置测试服务器端口
if (process.env.NODE_ENV === "test")
app.set("port", 3001); *1*
else app.set("port", process.env.PORT || 3000);
- 1 将端口分配给 3001(测试),默认端口为 3000(生产)。
最后,您需要通过在 main.js 底部添加module.exports = app来导出包含在app中的应用程序。导出应用程序允许您从您编写的测试文件中访问它。此外,在您的控制器测试中,您需要另一个包的帮助来向您的服务器发送请求。通过运行npm i chai-http -S命令安装chai-http包,将其保存为开发依赖项。
实施这些更改后,您就可以编写对模型和控制器进行全面测试了。在以下示例中,您测试用户的控制器操作和 User 模型。首先,通过在您的测试文件夹中创建一个名为 userSpec.js 的文件并包含列表 36.6 中的代码来测试 User 模型。
在此文件中,您可以在 User 模型上创建多个测试。您编写的第一个测试是为了确保用户可以被创建并保存到您的数据库中。您需要引入 User 模块、mongoose和chai。从chai中提取expect函数到其自己的常量中,以便您的测试更易于阅读。
接下来,实现mocha提供的beforeEach函数,在您运行每个测试之前从您的测试数据库中删除任何用户。此函数确保先前测试的结果不会影响此文件中的其他测试。您的describe块表明您正在测试用户模型上的保存功能。您的it块包含两个期望,以确定您是否可以成功将单个用户保存到数据库中。首先,提供一些您的应用程序可能自然接收作为输入数据的一些样本数据。然后设置两个承诺来保存用户并在数据库中查找所有用户。内部嵌套的承诺是您运行期望的地方。
最后,创建两个断言,您期望您的承诺的结果产生一个数组,其中第二个项目包含您数据库中的所有用户。因为您创建了一个单个用户,所以您期望用户数组的长度为1。同样,您期望该数组中的唯一用户具有_id属性,这表明它已保存到您的 MongoDB 数据库中。当您的测试完成时,调用done以指示测试完成且承诺已解决。
列表 36.6. 在 userSpec.js 中测试保存 Mongoose 用户
process.env.NODE_ENV = "test"; *1*
const User = require("../models/user"),
{ expect } = require("chai"); *2*
require("../main");
beforeEach(done => { *3*
User.remove({})
.then(() => {
done();
});
});
describe("SAVE user", () => { *4*
it("it should save one user", (done) => { *5*
let testUser = new User({
name: {
first: "Jon",
last: "Wexler"
},
email: "Jon@jonwexler.com",
password: 12345,
zipCode: 10016
}); *6*
testUser.save()
.then(() => {
User.find({})
.then(result => {
expect(result.length)
.to.eq(1); *7*
expect(result[0])
.to.have.property("_id");
done(); *8*
});
});
});
});
-
1 引入必要的模块并将环境设置为测试。
-
2 将变量分配给 chai.expect 函数。
-
3 在每个测试之前从数据库中删除所有用户。
-
4 描述一系列保存用户的测试。
-
5 定义一个保存单个用户的测试。
-
6 设置使用样本数据保存用户并随后从数据库中检索所有用户的承诺。
-
7 期望数据库中存在一个具有 ID 的用户。
-
8 使用 promises 完成测试的调用。
通过在项目终端窗口中运行mocha命令来运行您的测试。此命令启动 MongoDB 测试数据库并保存一个测试用户。如果您的测试未通过,请确保您的模块连接正确,并且用户在浏览器中的应用程序中已保存。了解用户模型是否正确工作是有帮助的,并且您可以为此文件添加更多测试。您可以使用不应保存的示例数据,或者尝试保存具有相同电子邮件地址的两个用户,例如。您的验证应该防止两个用户都保存。
接下来,测试一个控制器操作。毕竟,控制器操作连接了您的模型和视图,提供了您希望在应用程序中保留的更多体验。在以下示例中,您测试用户索引操作,该操作从数据库中检索所有用户并将这些用户发送到您的视图的响应体中。
对于此测试文件,您需要通过添加 const chaiHTTP = require ("chai-http") 来引入 chai-http,并通过添加 const app = require("../main") 来引入您的主 app 模块。然后通过添加 chai.use(chaiHTTP) 告诉 chai 使用 chaiHTTP,这样您就可以准备进行服务器请求了。在以下示例中,您使用 chai.request(app) 与服务器进行通信。要特别测试索引操作,请将 列表 36.7 中的代码添加到您的测试文件夹中的 users-ControllerSpec.js 文件中。
您可以使用表示 users-Controller 测试的 describe 块来包装您的测试,另一个 describe 块指定测试是针对 /users 的 GET 请求。
注意
describe 的第一个参数是您选择的任何字符串,用于解释测试正在测试什么。您不需要遵循此示例中显示的文本。
您用于显示数据库中所有用户的测试使用 chai.request 与您的应用程序通信,这反过来设置了一个在端口 3001 上运行的网络服务器。然后您使用 chai 的辅助方法链式一个 get 请求以到达 /users 路由。在您的应用程序中,这应该带您到用户控制器中的用户索引操作。您使用 end 结束请求,并在从服务器返回的响应上写下您的期望。您期望响应的状态码为 200 且没有错误。
列表 36.7. 测试用户索引操作
describe("/users GET", () => { *1*
it("it should GET all the users", (done) => {
chai.request(app) *2*
.get("/users")
.end((errors, res) => { *3*
expect(res).to.have.status(200); *4*
expect(errors).to.be.equal(null);
done(); *5*
});
});
});
-
1 描述您的用户索引操作测试块。
-
2 向您的测试服务器发送一个 GET 请求。
-
3 使用回调结束请求以运行您的期望。
-
4 期望您的应用程序响应状态为 200。
-
5 在您的测试中调用 done 以完成服务器交互。
通过在项目终端窗口中输入 mocha 来运行此测试,以查看两个测试通过。您的测试套件包含测试文件夹中所有文件的测试。如果您只想测试 usersControllerSpec,可以运行 mocha test/usersControllerSpec。
快速检查 36.3
Q1:
chai.request做了什么?
QC 36.3 答案
1:
chai.request接受一个 Node.js 网络服务器,并允许您的测试环境进行请求。这些请求模仿了生产应用程序中的请求,允许对您的代码进行更集成、更全面的测试。
摘要
在本课中,您学习了如何测试 Node.js 应用程序。您从 assert 核心模块开始,并迅速跳转到使用 chai、mocha 和 chai-http 测试您的模型和控制器。有了这些工具和其他工具,您将能够重新创建用户与您的应用程序的实际体验的大部分。如果您能够通过预测用户体验和边缘情况并在它们进入生产之前进行测试来领先,那么您将面临的生产崩溃将少得多。
尝试以下操作
编写测试套件并不是一项简单的任务,因为你可以编写无数个测试。你想要确保使用各种样本数据覆盖你应用程序中的大多数场景。
为你应用程序中的每个控制器和模型创建一个测试模块。然后尝试为每个动作构建describe块和测试。
第 37 课:综合项目:部署 Confetti Cuisine
现在是时候将我的应用程序迁移到生产环境了。我在过程中与 Confetti Cuisine 就原始期望和功能变更进行了协调。结果是运行在 Express.js、MongoDB 和各种连接用户与 Confetti Cuisine 烹饪学校的包上的 Node.js 应用程序。我多次有机会在没有数据库或保存有意义数据的能力的情况下部署这个应用程序。现在,我已经清理了代码并编写了一些测试,我将转向 Heroku 来展示我的应用程序对世界的影响。
虽然步骤很短,并且不涉及更多的编码,但我想要小心,不要在部署过程中犯任何错误。开发中的故障排除比生产中简单得多。
我将首先为我的应用程序准备部署到 Heroku。然后,我将在终端的 Heroku 命令行界面(CLI)中创建一个新的 Heroku 应用程序。在本地使用 Git 保存和版本控制我的更改后,我将把我代码推送到 Heroku。
接下来,我将设置我的应用程序的 MongoDB 数据库,并添加一些种子数据以开始。当这些任务完成时,我将使用一些生产工具来监控我的应用程序日志,并为有意义的数据和与我的应用程序的交互做好准备。
37.1. 代码检查和日志记录
在部署我的应用程序之前,我想确保我没有提交带有任何错误或低效性的代码。尽管我已经有意识地编写代码,但总有可能出现错误影响我的生产环境中的应用程序。为了防止部署过程中的潜在问题,我全局安装eslint以通过运行npm install -g eslint来对我的代码进行代码检查。
对我的代码进行代码检查(Linting)为我提供了一个列表,列出了我应用程序代码中可能需要修复的行,这些行从删除未使用的变量到没有正确处理承诺和异步函数。我在项目的终端窗口中通过运行命令eslint --init来初始化eslint。在终端的提示下,我选择对 ES6 语法以及服务器端和客户端 JavaScript 进行代码检查。在终端中运行eslint会创建一个.eslintrc.js配置文件,eslint使用该文件来评估我的代码。我在项目的终端窗口中运行全局eslint关键字,以查看我的代码可以改进的地方。
我还希望在应用程序进入生产之前有更好的日志记录。我决定使用 morgan 来记录请求和响应信息。首先,我通过运行 npm i morgan -S 在本地安装该包,将其保存为应用程序依赖项。然后我在 main.js 中通过添加 const morgan = require("morgan") 来引入 morgan。最后,我想使用 morgan 的特定配置,该配置将请求中的有意义数据结合到日志中。我在 main.js 中添加了 app.use(morgan("combined")),以便让我的 Express.js 应用程序知道使用 morgan 的 combined 日志格式。
在我的代码清理完毕后,我在开发环境中最后一次运行我的应用程序,以确保没有持续的错误阻止我的应用程序启动。然后我继续准备我的应用程序以进行部署。
37.2. 准备生产
Confetti Cuisine 给了我选择生产平台的选择。因为我熟悉 Heroku,所以我决定开始准备我的应用程序,使其能够在 Heroku 服务器上运行。
注意
以下步骤允许我使用 Heroku,但它们不能阻止我的应用程序与其他服务一起工作。
我首先验证我的机器上已安装了 Heroku CLI 和 Git。在终端中运行 heroku --version 和 git --version 应该能让我知道它们是否已安装以及它们的版本。我需要 Heroku 允许服务器端口在生产中使用环境变量,而不仅仅是端口 3000。我会在 main.js 中确保我的端口通过 app.set("port", process.env.PORT || 3000) 设置。如果存在这样的值,端口号最初将被分配给 process.env.PORT 中的端口号。否则,端口将默认为 3000。
接下来,我修改了我的数据库连接,以便如果存在,使用 MONGODB_URI 环境变量。我在 main.js 中添加了 mongoose.connect(process.env.MONGODB_URI || "mongodb://localhost:27017/confetti_cuisine", { useNewUrlParser: true })。稍后,当我为我的生产应用程序配置数据库时,MONGODB_URI 将作为应用程序的配置变量之一出现,设置为数据库的外部 URL。
最后一步是创建一个 Procfile,这是一个 Heroku 用来作为启动我的应用程序的起点文件。Heroku 可以与几种互联网协议一起工作。我将设置此应用程序通过 HTTP 工作,所以我将 web: node main.js 添加到 Procfile 中。这一行代码告诉 Heroku 将我的应用程序作为应该通过 HTTP 接收请求和响应的 Web 服务器运行。此外,我还告诉 Heroku 使用 main.js 来启动应用程序。
我的代码几乎准备好部署了。我需要保存我的更改并遵循几个更多步骤,将我的代码发送到生产环境。
37.3. 部署到 Heroku
现在我对我的代码状态感到满意,我将添加并提交我的更改到 Git。首先,我想运行git init来使用 Git 初始化我的项目。如果我已执行过这一行,Git 会无害地重新初始化项目;我的先前更改不受影响。Git 将我的所有代码捆绑在一起,因此我想确保不会捆绑我不希望通过互联网发送的内容,包括密码、任何类型的敏感数据以及我的 node_modules 文件夹。我已经将敏感数据排除在我的应用之外,因此我想确保我的 node_modules 文件夹不会进入生产环境;该文件夹可能相当大,会减慢我的部署过程。此外,一旦部署,Heroku 会为我运行npm install。我创建了一个名为.gitignore 的文件,并将 node_modules 添加到该文件中。
接下来,我运行git add .将所有文件添加到暂存区,准备提交。我运行git status以确认将要提交的文件,并运行git commit -m "first production deployment"来指示在生产之前这一版本的代码。将我的代码保存后,我在终端中使用heroku关键字来将我的应用注册到 Heroku。在终端中,从我的项目目录运行heroku create confetti-cuisine。
警告
如果名称confetti-cuisine在 Heroku 上尚未被其他应用使用,此命令将生成一个 URL,通过该 URL 我可以访问我的应用。遵循我的步骤的人需要在此命令中为他们的 heroku 应用选择一个不同的名称。
那个 URL 是confetti-cuisine.herokuapp.com。此命令还为我创建了一个远程 Git 仓库。这种配置允许我将我的本地 Git 仓库提交到该地址;从那里,Heroku 将安装并运行我的应用。我可以通过运行git remote -v来验证远程仓库的 URL。我看到我的远程仓库被命名为heroku,所以当我准备好时,我可以使用名称heroku将我的代码推送到生产环境。
确保我有一个可靠的互联网连接,然后运行git push heroku master。master是 Git 中包含我的代码的容器名称,我将该容器中的代码上传到与heroku关联的 URL 上的同名容器。运行此命令将启动一系列操作,Heroku 使用这些操作来设置应用并安装其包依赖项。对于我的应用,整个过程不到一分钟。完成之后,我可以运行heroku open在网页浏览器中启动我的生产 URL。
我立刻注意到应用没有工作(图 37.1),因为我的数据库尚未设置,而我的应用需要数据库才能加载任何页面。
图 37.1. 在 Heroku 上应用无法加载

在下一节中,我为我的生产应用设置一个 MongoDB 数据库。
37.4. 设置数据库
我选择使用 MongoDB 作为我应用程序的数据库有几个原因。其中一个原因是它在生产环境中设置非常简单。设置开发和测试数据库是一项轻松的任务。现在我需要添加一个 Heroku 插件来关联数据库服务,然后通过一步操作,我的应用程序就可以开始工作了。
我在我的项目终端窗口中运行heroku addons:create mongolab:sandbox来为我的应用程序创建一个 mLab MongoDB 数据库。因为我已经将我的本地项目与我的注册 Heroku 应用程序关联起来,所以我可以在终端中继续使用 Heroku CLI 来管理我的生产应用程序。此命令提供了一个由 mLab 托管的免费层数据库。然而,由于其大小和可用性限制,此沙盒数据库不建议用于生产。
注意
如果 Confetti Cuisine 喜欢我在 Heroku 上应用程序的外观和行为,我可以通过运行heroku addons:create mongolab:shared-cluster-1来以成本增加我的 mLab 计划。
警告
我不想在确定需要额外空间之前升级我的数据库账户。从终端升级可能会在我的 Heroku 账户中产生费用。
或者,我可以在任何外部位置设置我的 MongoDB 数据库,并将MONGODB_URI变量设置为该外部数据库的 URL。
我通过运行heroku config:get MONGODB_URI来验证与 Heroku 的数据库 URL 设置。此命令会响应我的 mLab 数据库 URL,以及我需要用于访问数据库的安全凭据。如果我想在网页浏览器中查看我的数据库内容,我可以运行heroku addons:open mongolab来打开一个新网页,通过 Heroku 指向我的 mLab 站点上的数据库(图 37.2)。
图 37.2. 显示 mLab 数据库的内容

现在,当我访问confetti-cuisine.herokuapp.com/时,我终于看到我的主页加载了(图 37.3)。
图 37.3. 加载主页

在我的应用程序投入生产后,我想通过预先加载一些数据来使其更具吸引力。我有几种方法可以将种子数据加载到我的应用程序中,包括直接链接到我的 mLab 数据库和将数据推送到我的数据库。相反,我将在项目终端窗口中运行heroku run node以进入生产 REPL 环境。与开发中的 REPL 一样,我可以在这里与我的 Node.js 应用程序交互,甚至将其保存到数据库中。我已经准备了一些我想保存的课程,所以我复制了创建这些课程的代码行并将其粘贴到这个 REPL shell 中。首先,我需要复制需要模块的行,例如mongoose和 Course 模型本身。我将列表 37.1 中的代码输入到我的终端窗口中,并观察课程如何填充到我的应用程序中。我可以点击 Ajax 课程模态来查看这些新列表。
注意
在将代码粘贴到终端窗口之前,首先将代码格式化到您的文本编辑器中可能会有所帮助。
列表 37.1. 向我的生产应用程序添加种子数据
const mongoose = require("mongoose"),
Course = require("./models/course"); *1*
mongoose.Promise = global.Promise;
mongoose.connect(
process.env.MONGODB_URI ||
"mongodb://localhost:27017/confetti_cuisine",
{ useNewUrlParser: true }
);
Course.remove({}) *2*
.then(() => {
return Course.create({
title: "Chocolate World",
description: "Dive into the divine world of sweet
and bitter chocolate making.",
cost: 22,
maxStudents: 14
});
})
.then(course => console.log(course.title))
.then(() => {
return Course.create({
title: "Pasta Boat",
description: "Swim through original recipes and
paddle your way through linguine",
cost: 43,
maxStudents: 8
});
})
.then(course => console.log(course.title))
.then(() => {
return Course.create({
title: "Hot Potato",
description: "Potatoes are back and they are hot!
Learn 7 different ways you can make potatoes
relevant again.",
cost: 12,
maxStudents: 28
});
})
.then(course => console.log(course.title))
.catch(error => console.log(error.message))
.then(() => {
console.log("DONE");
mongoose.connection.close();
});
-
1 需要为 REPL 请求必要的模块和数据库连接。
-
2 为我的生产数据库创建新课程。
加载这些数据后,我终于可以向 Confetti Cuisine 展示完成的应用程序。不过,我需要密切关注日志,以防任何新用户在使用实时应用程序时遇到问题。
37.5. 生产环境中的调试
我的角色已经从开发者转变为错误修复者和维护者。我需要确保我编写的代码保留了承诺的功能,并且我会迅速修复不履行承诺的代码。
因为我的代码不是从我的个人电脑上运行的,所以我需要通过在项目的终端窗口中运行 heroku logs --tail 来访问 Heroku 的日志。此命令与 Heroku 通信,提供日志的实时流。日志告诉我何时发生错误,我的应用程序是否崩溃,以及我需要了解的所有关于传入请求和传出响应的信息。
当我理解日志消息时,如果遇到问题,我可以在我的电脑上尝试本地重现。我可以在项目的终端窗口中运行 heroku local web 来启动本地生产环境中的应用代码。此命令在我的 http://localhost:5000/ 上运行我的应用程序。如果我在这里测试时看到错误发生,我可以更好地了解需要修复的内容。最后,我可以在我怀疑导致错误的代码行上设置断点来使用 Node.js 调试工具。通过在我的代码中添加 debugger,我可以逐步执行运行中的应用程序,暂停,并分析特定函数中的值。
我相信这个应用程序将遇到很少的问题,并为 Confetti Cuisine 提供一种与观众互动的新方式。同时,如果公司需要我的帮助,我会在这里。我只需 git add .,git commit -m "<some message>" 和 git push heroku master 就可以部署更新。
摘要
在这个最后的综合练习中,我将我的应用程序部署为可供公众访问。通过设置正确的配置并运行一个有效的 Node.js 应用程序,我能够将我的应用程序上传到生产服务器。从这个服务器,将处理传入的请求并对外部数据库进行查询。我的应用程序现在依赖于各种可能产生费用的资源,随着我的应用程序收集更多数据和受欢迎程度,这些费用可能会增加。随着我的应用程序的流量和需求增加,将需要更多的资源,我需要考虑将我的 Node.js 应用程序托管在能够支持其不断增长的数据库和受欢迎程度的场所的成本。可扩展性、高可用性和性能改进都是我接下来对这个应用程序的迭代中的主题,我希望 Confetti Cuisine 在实施未来的改进时能够乐意合作。
附录 A. ES6 中引入的 JavaScript 语法
在本附录中,我介绍了 ES6 中引入的 JavaScript 语法,这些语法适用于 Node.js。我从变量定义和新的String插值风格开始。然后我讨论了箭头函数。
A.1. ES6 中的新特性
自 2015 年以来,ECMAScript 6 为使用 JavaScript 进行开发提供了新的语法和约定。因此,本书涵盖了您将使用的一些 ES6 关键字和格式。关键字是具有 JavaScript 中保留意义的术语,用于提供代码的语法和可解释性。
A.1.1. let关键字
您可能已经习惯了使用var关键字声明变量。在 ES6 中,使用let关键字定义变量更为合适,因为它们适用于特定的作用域块。直到在特定代码块中定义了变量,您才能访问它。
在if块中定义的let变量不能在块外访问,例如,而var变量的作用域限于定义它的函数内,如下面的列表所示。
列表 A.1. let关键字的示例用法
function sample() {
var num = 60;
if (num > 50){
let num = 0;
}
console.log(num);
}
由于let变量的作用域限于函数之外的代码块,它们可以是模块或整个应用程序的全局变量。因此,let提供了更多的变量定义安全性,并且比var更受欢迎。
注意
当使用"use strict";时,您不能重新定义相同的let变量,而可以使用var。
A.1.2. 常量变量
const变量不能重新赋值。通常,您应该使用此关键字代替let,对于您不期望在代码中操作的变量。此指南也适用于在 Node.js 中加载库或模块,如单元 1 中所示。如果您尝试重新赋值const变量,则会得到“重复声明错误”。
下一个列表中的代码会崩溃,因为正在使用现有常量的名称声明一个新的let变量。
列表 A.2. const变量的示例用法
function applyDiscount(discountPrice) {
const basePrice = 1000;
let basePrice = basePrice - discountPrice;
console.log(basePrice);
}
A.1.3. 字符串插值
在 ES6 之前,要在字符串中打印或记录变量的值,您必须将字符串附加到变量周围,如下面的列表所示。
列表 A.3. 字符串连接示例
var x = 45;
console.log("It is " + x + " degrees outside!");
在 ES6 中,您可以使用反引号(`)和${}将变量插值到字符串中,如下面的列表所示。
列表 A.4. 使用反引号插值字符串
var x = 45;
console.log(`It is ${x} degrees outside!`);
产生的代码更简洁、更易于阅读和编辑。
A.1.4. 箭头函数
箭头函数是 ES6 使代码更简洁、更易于阅读的一种方式。使用 => 箭头符号和函数语法的改变,您可以将多行函数转换为单行。以下列表中的示例展示了这一点。
列表 A.5. 使用function关键字定义函数
function printName(name) {
console.log(`My name is ${name}`);
}
您可以将此代码重写为以下列表所示。
列表 A.6. 定义箭头函数
let printName = name => console.log(`My name is ${name}`);
更重要的是,ES6 中的箭头函数保留了其外部作用域的 this 变量,如下一个列表所示。
列表 A.7. 函数内 this 关键字的示例用法
let dog = {
name: "Sparky",
printNameAfterTime: function() {
setTimeout(function() {
console.log(`My name is ${this.name}`);
}, 1000);
}
}
在这个例子中,dog.printNameAfterTime() 打印 My name is undefined,因为 this .name 超出了 setTimeout 函数的作用域,尽管假设 this 指的是 dog 对象。然而,使用箭头函数时,this 在 setTimeout 函数中保持不变,如下一个列表所示。
列表 A.8. 使用箭头函数的 this 关键字的示例
let dog = {
name: "Sparky",
printNameAfterTime() {
setTimeout(() => {
console.log(`My name is ${this.name}`);
}, 1000);
}
}
现在,你可以打印 My name is Sparky,代码更加紧凑!
要在 Node.js 中取得成功,你需要在 JavaScript 方面取得成功。因为 Node.js 需要足够的对一些核心 JavaScript 和编程概念的了解,所以本课回顾了你需要知道的内容才能开始。如果你在 JavaScript 方面没有太多经验,我建议阅读 John Resig 和 Bear Bibeault 所著的 《JavaScript 忍者秘籍,第二版》(Manning, 2016)。
A.2. REPL
当你安装了 Node.js 后,运行你的代码的第一个地方是 Read-Evaluate-Print Loop (REPL)。这个交互式环境类似于 Chrome 网络浏览器中的控制台窗口。在 REPL 中,你可以运行任何 JavaScript 代码。你还可以引入 Node.js 模块来测试应用程序的各个方面。
A.2.1. 在 REPL 中运行 JavaScript
要启动 REPL,请导航到你的电脑上的任何终端窗口,并输入 node。此命令立即返回一个提示符(>),之后你可以输入任何 JavaScript 语句。你可以把 REPL 看作是一个运行中的 Node.js 应用程序,它会即时响应你的命令。也就是说,你不需要在单独的文件中编写你的 JavaScript 代码然后运行它;你可以在 REPL 窗口中直接输入那段 JavaScript 代码。尝试定义几个变量,如下一个列表所示。你会注意到,每运行一个 JavaScript 语句,REPL 就会输出该语句的返回值。对于变量赋值,返回值是 undefined。
列表 A.9. 在 REPL 中定义变量
> let x = 42;
undefined
> let sentence = "The meaning of life is ";
undefined
现在对这些变量执行一些操作。例如,你可以将两个值连接起来,如下面的列表所示。
列表 A.10. 在 REPL 中连接变量
> sentence + x;
The meaning of life is 42
你可以使用 REPL 环境的任何方式来模拟你使用或见过的任何 Node.js 应用程序。你还可以使用制表键来自动完成变量或函数名,并列出对象属性。例如,如果你通过变量名 sentence 定义了一个字符串,但你不确定可以调用该字符串的哪些函数,你可以在变量名末尾添加一个点(.)并按 Tab 键来列出该变量的可用函数和属性,如下一个列表所示。
列表 A.11. 在 REPL 中列出变量属性
> sentence.
sentence.anchor sentence.big
sentence.blink sentence.bold
sentence.charAt sentence.charCodeAt
sentence.codePointAt sentence.concat
sentence.endsWith sentence.fixed
sentence.fontcolor sentence.fontsize
sentence.includes sentence.indexOf
sentence.italics sentence.lastIndexOf
你可以在第 1 课中找到额外的 REPL 命令。
A.2.2. 在应用程序开发中使用 REPL
使用 REPL 的另一种有用方式是通过 Node.js 应用程序代码中的repl模块。随着您在项目中构建更多的自定义模块,您会注意到将所有这些文件加载到 REPL 中以测试您所编写代码的功能是繁琐的。如果您编写了一个名为 multiply.js(列表 A.12)的模块,该模块包含一个用于乘以两个数字的函数,您需要通过输入require("./multiply")将此模块导入 REPL,以及您创建的每个其他模块。更重要的是,您需要为每个新的 REPL 会话输入这些行。
列表 A.12. 在 multiply.js 中创建单函数模块
module.exports = {
multiply: (x, y) => {
return x * y;
}
};
您不必在每个 REPL 会话中导入模块,您可以将 REPL 带入您的模块中。列表 A.13 展示了您如何在项目中使用repl模块。您可以在项目目录中创建一个名为 customRepl.js 的模块,同时导入您想要测试的所有模块。此文件显示了repl模块被导入,然后启动了一个 REPL 服务器。就像 Node.js HTTP 服务器一样,这个 REPL 服务器有一个上下文,您可以在其中加载自定义变量。REPL 服务器启动后,添加一个name变量和您的multiply模块。
列表 A.13. 在 customRepl.js 中使用repl模块
const repl = require("repl"),
replServer = repl.start({
prompt: "> ",
});
replServer.context.name = "Jon Wexler";
replServer.context.multiply = require("./multiply").multiply;
现在您只需在终端导航到您的项目目录,并输入node customRepl。您将看到 REPL 提示,但这次,您的 REPL 会话的上下文包含您想要测试的所有模块。当您想要测试在数据库中创建或修改记录,而不必复制和粘贴代码来导入数据库配置时,这种技术非常有用。
摘要
本附录概述了您在本书中应了解的 JavaScript 关键字和语法。随着 ES6 现在在开发社区中得到广泛应用,开始编写反映最新和最伟大 JavaScript 变化的代码变得非常重要。您对使用 REPL 和 JavaScript 命令越熟悉,开发应用程序就会越容易。
附录 B. 日志和 Node.js 全局对象的使用
B.1. 日志
日志可以帮助你了解正在运行哪些函数和中间件,显示你的应用程序正在产生哪些错误,并为你提供更深入的了解应用程序中正在发生的事情。
console 模块是 Node.js 的核心模块和全局对象,这意味着你可以在应用程序代码的任何地方访问 console 关键字。当你运行 console.log(),传递一些文本字符串作为消息时,输出通常会在终端窗口或文件中打印出来。为了本书的目的,console 模块提供了剖析应用程序代码的正确日志工具。除了 第二部分 中的日志提示外,还有一些日志命令需要记住。
console 模块有两个输出:标准输出和错误输出。尽管这两个输出都在你的终端窗口中显示文本,但在浏览器控制台中它们的行为不同。下一列表展示了你可以与 console 一起使用的其他日志记录函数。
列表 B.1. 使用日志函数
console.log("Standard output log message"); *1*
console.error("Error output log message"); *2*
console.info("Standard output log message"); *3*
console.warn("Error output log message"); *4*
-
1 将日志消息打印到你的控制台
-
2 使用错误输出打印日志消息
-
3 将日志消息作为 console.log 的别名打印
-
4 将日志消息作为 console.error 的别名打印
在 Node.js 应用程序中,这四个函数在服务器上的行为相似。当你使用这些日志函数在客户端 JavaScript 中时,你会注意到你的浏览器控制台以与消息类型相对应的格式打印你的日志消息。例如,警告消息有橙色背景,错误消息则显示为红色。
你可能还会发现两个有用的函数:console.time 和 console.timeEnd。这两个函数可以一起使用,以记录代码中某些操作开始和结束之间所花费的时间。这些函数内的文本需要匹配,才能使计时器正常工作。在下一列表中,函数 xyz 花费了一秒钟,然后记录了一条消息。这个操作的记录时间略多于一秒。
列表 B.2. 记录操作的时间
console.time("function xyz"); *1*
(function xyz() {
setTimeout(function() {
console.log("prints first"); *2*
console.timeEnd("function xyz"); *3*
}, 1000);
})();
-
1 开始控制台计时器
-
2 将 console.log 消息作为函数操作的一部分打印
-
3 记录结束时的计时
console.log 将成为你在网络开发中最好的朋友之一,因为日志注释帮助你找到错误。通过一点实践和变化,了解你的新朋友。
B.2. 全局对象
在 Node.js 中,全局对象可以在任何应用程序中访问。你可以在 Node.js 应用程序的任何位置使用这些对象。这些对象可以包含有关应用程序或文件系统的信息。以下全局对象在 Node.js 应用程序中使用得最为频繁:
-
console将输出到控制台或标准输出,无论你的应用程序在哪里运行。 -
__dirname返回你机器上目录位置的绝对路径,如下所示:console.log(__dirname); >> /Users/Jon/Desktop -
__filename提供了您机器上应用程序目录的绝对路径,如下所示:console.log(__filename); >> /Users/Jon/Desktop/filename_example.js -
process指的是应用程序运行在其上的进程(线程)。此对象是应用程序资源的主要来源,以及与文件系统的连接。
一些对象看起来与 Node.js 的全局对象相似,但它们来自您项目中引入的其他库。这些对象在大多数 Node.js 应用程序中都是可用的。随着您学习如何使用以下对象,它们的使用案例将变得更加清晰:
-
module指的是您正在工作的当前模块(JavaScript 文件),并允许您访问该文件中的其他变量。 -
exports指的是一个键/值对对象,用于存储模块的函数或对象,以便它们可以在其他模块之间共享。使用此对象的方式与使用module.exports类似。在以下示例中,accessibleFunction被导出以供其他模块使用:exports.accessibleFunction = () => { console.log("hello!"); } -
require允许您将其他模块的代码导入到当前模块中,并让您访问当前工作文件外的代码。require关键字的使用方法如下:const http = require("http");




0.1. 你将要学习的内容
浙公网安备 33010602011771号