-NET--开发者的-Node-学习指南-全-

.NET 开发者的 Node 学习指南(全)

原文:zh.annas-archive.org/md5/878789c4c0fcdf44a8ede88a7586715b

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书的目的在于帮助.NET 或 Java 开发者过渡到 Node.js。您可能有一些 Web 开发经验,也许您过去写过一些基于浏览器的 JavaScript。可能并不明显为什么有人想要将 JavaScript 从浏览器中移出并用于服务器端开发。然而,这正是 Node.js 所做的事情。更重要的是,Node.js 已经存在了足够长的时间,已经成熟为一个平台,并且其令人印象深刻的增长势头已经远远超过了任何可以归因于新技术初期炒作的时期。

本书的首要目标是解释为什么 Node.js 是一项值得深入了解的引人注目的技术。前几章带着这个目标介绍 Node.js,快速帮助您上手 Node.js,并提供对 JavaScript 语言的重要(再)介绍,以帮助您走上正确的道路。

本书的主要部分将逐步引导您构建 Node.js Web 应用程序的示例。在这个过程中,我们将展示 Node.js 真实开发项目中所需的所有重要工具和技术。目标是充分利用您现有的开发专业知识,让您能够快速达到与 Node.js 的最佳实践和专业水平。

书的最后一章展示了如何使用 Node.js 进行 Web 应用程序之外的其他用途,以及如何继续学习 Node.js 并探索其生态系统。我们还将看到您如何可以将 Node.js 与.NET 一起使用,并从在两种技术中应用您的编程技能中受益。

本书涵盖内容

第一章, 为什么选择 Node.js?,介绍了 Node.js 作为一个编程平台。它涵盖了 Node.js 的执行模型,特别是它与.NET 和 Java 的不同之处,以及这些差异成为优势的使用场景。这一章还讨论了 JavaScript 作为开发语言的适用性。

第二章, Node.js 入门,直接深入创建 Node.js 应用程序。在这一章中,您将安装 Node.js,选择一个代码编辑器,并设置一个最小的 Web 应用程序项目。您还将学习一些用于与 Node.js 一起工作的重要命令行工具。

第三章, JavaScript 入门,介绍了在 JavaScript 编程时需要了解的最重要的事情。它描述了 JavaScript 类型系统和其特有的函数式面向对象编程风格,包括基于原型的继承。这一章还涵盖了一些关键陷阱和 JavaScript 语言的怪癖。

第四章, 介绍 Node.js 模块,解释了如何使用模块来结构化 JavaScript 应用。它介绍了 Node.js 模块系统,并展示了如何使用它来组织你的应用代码。

第五章, 创建动态网站,在上一章的示例基础上扩展,构建一个功能性的 Web 应用。你将为你的应用添加 JSON API 和动态视图,并使用 Ajax 在客户端和服务器之间进行通信。

第六章, 测试 Node.js 应用,展示了如何在 JavaScript 和 Node.js 中编写自动化测试。它介绍了一系列用于编写和运行 JavaScript 测试的工具和库,并指导你编写各种单元测试和集成测试。

第七章, 设置自动化构建,涵盖了 Node.js 中的构建自动化和持续集成。你将为你的应用设置一个 CI 服务器和任务运行器,添加自动任务以运行测试、执行静态分析和评估代码覆盖率。

第八章, 掌握异步编程,介绍了 JavaScript 中不同的异步编程模式。你将将这些模式应用到你的应用中,并充分利用 JavaScript 语言特性和库来简化异步代码。

第九章, 持久化数据,解释了可以与 Node.js 一起使用的持久化数据存储。它介绍了 MongoDB 和 Redis,解释了它们不同的数据模型及其用例。你将集成这两个数据存储与你的 Node.js 应用。

第十章, 创建实时 Web 应用,展示了如何在客户端和服务器之间实现实时双向通信。你将使用 Socket.IO 库为你的应用添加实时功能。你还将看到如何为此功能编写测试,以及如何使用 Redis 作为后端来编写可扩展的实时应用。

第十一章, 部署 Node.js 应用,演示了如何将 Node.js 应用部署到 Web 上。你将把你的应用部署到一个免费的云托管提供商。你将看到如何配置数据存储以及如何使用远程服务器日志进行调试。

第十二章,Node.js 中的身份验证,涵盖了 Node.js Web 应用程序的身份验证。您将使用第三方提供者实现身份验证,将其与您的应用程序集成,并展示登录和未登录用户的不同内容。

第十三章,创建 JavaScript 包,解释了如何创建供他人使用的独立 JavaScript 包。您将了解如何编写可在客户端和服务器上运行的通用 JavaScript 库,以及如何使用 Node.js 编写独立的命令行应用程序。

第十四章,Node.js 及其之后的内容,将本书的内容置于更广泛的背景下。它解释了 Node.js 和 JavaScript 是如何持续演进的,以便您可以准备并利用即将到来的变化。它涵盖了 Node.js 和 Web 的一些替代编程语言,以及这些语言如何与 JavaScript 相关。它讨论了 Node.js 的一些原则如何应用于.NET 编程,并展示了这些原则在.NET Core(.NET 的新版本)中的特别体现。它还展示了如何结合使用 Node.js 和.NET 以获得两者的最佳效果。

需要为本书准备的资源

本书使用的所有工具和服务均可在网上免费获得。大多数示例都需要在某个时刻保持活跃的网络连接。要开始学习,您需要的只是控制台、网络浏览器以及允许在您的机器上安装新软件的权限。为了支持来自.NET 背景的开发者,本书中的一些控制台列表或示例步骤使用了 Windows 约定(例如,路径中的反斜杠)。尽管如此,这些示例并不特定于 Windows。您可以在 Windows、Mac OSX 或 Linux 上完成本书的学习。

本书面向的对象

本书面向对学习 Node.js 感兴趣的.NET 或 Java 开发者。不需要具备 Node.js 的先验经验。您可能之前编写过一些客户端 JavaScript,但这不是必需的。本书的主要示例是一个 Node.js Web 应用程序。在.NET 或 Java 中有 Web 开发经验将有所帮助,但不需要对任何特定的应用程序库或框架有经验。

术语

在本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"ES2015 引入了let关键字来声明变量"。

代码块设置如下:

<!DOCTYPE html>
<html>
  <head>
    <title>{{ title }}</title>
    <link rel='stylesheet' href='/stylesheets/style.css' />
  </head>
  <body>
    <h1>{{ title }}</h1>
    <p>Welcome to {{ title }}</p>
  </body>
</html>

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

/* GET home page. */
router.get('/', function(req, res, next) {
 res.render('index', { title: 'Express', name: 'World' });
});

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

> npm install –g nodemon

新术语重要词汇将以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,将以这种方式显示:“点击下一步按钮将您带到下一屏幕。”

注意

警告或重要提示会以这样的框中出现。

小贴士

小贴士和技巧会像这样显示。

读者反馈

我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。

要向我们发送一般反馈,请简单地发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及书籍的标题。

如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南,链接为www.packtpub.com/authors

客户支持

现在,您已经是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从购买中获得最大收益。

下载示例代码

您可以从github.com/NodeJsForDevelopers和您的www.packtpub.com账户下载此书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持标签上。

  3. 点击代码下载与错误清单

  4. 搜索框中输入书籍的名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击代码下载

您也可以通过点击 Packt Publishing 网站上书籍网页上的代码文件按钮来下载代码文件。您可以通过在搜索框中输入书籍的名称来访问此页面。请注意,您需要登录您的 Packt 账户。

文件下载完成后,请确保您使用最新版本的软件解压缩或提取文件夹。

  • Windows 上的 WinRAR / 7-Zip

  • Mac 上的 Zipeg / iZip / UnRarX

  • Linux 上的 7-Zip / PeaZip

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/LearningNodejsForNETDevelopers_ColorImages.pdf下载此文件。

错误清单

尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者感到沮丧,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

盗版

在互联网上,版权材料盗版是一个跨所有媒体持续存在的问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过<copyright@packtpub.com>与我们联系,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

询问

如果您对本书的任何方面有问题,您可以通过<questions@packtpub.com>与我们联系,我们将尽力解决问题。

第一章. 为什么选择 Node.js?

与 .NET 和 Java 等平台相比,Node.js 仍然相对较新,但它在短时间内变得非常流行,甚至开始影响这些平台。这得益于其独特的编程模型、广泛的生态系统和强大的工具。

这些因素使 Node.js 成为其他平台的有力替代品。它们也可能使其令人畏惧。与其他平台相比,其独特的编程模型可能显得相当陌生。可用的库和工具的范围可能令人困惑。

本书将引导您了解 Node.js,以便您可以在应用程序中使用它。它将帮助您理解 Node.js,导航其生态系统,并利用您现有的开发技能在这个新环境中。

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

  • 介绍 Node.js 平台

  • 了解其执行模型如何工作

  • 探索 Node.js 生态系统

  • 将 JavaScript 视为语言选择

  • 考虑 Node.js 的使用案例范围

什么是 Node.js?

Node.js 由一个 JavaScript 引擎和用于核心服务器端功能的高级 API 组成。执行引擎是专为 Chrome 浏览器开发的 V8 引擎。Node.js 将此引擎嵌入到一个独立的应用程序中,可以在浏览器之外运行 JavaScript。

在 Node.js 中,浏览器中用于支持客户端 Web 开发的标准 API,如 文档对象模型 (DOM)XMLHttpRequest,并不存在。相反,有 API 支持通用应用程序开发。这些核心 API 覆盖以下低级功能:

  • 网络和安全

  • 访问文件系统

  • 定义和引入模块

  • 抛出和消费事件

  • 处理二进制数据流

  • 压缩

  • UTF-8 支持

  • 获取关于操作系统的基本信息

  • 管理子进程

其中一些 API 可能已经从客户端 JavaScript 开发中熟悉。例如,Timers API 揭示了熟悉的 setTimeoutsetInterval 函数。

Node.js 还提供了一些工具来帮助开发过程。这些包括控制台日志记录、调试、读取-评估-打印循环REPL)(或交互式控制台)以及基本的断言用于测试。

理解 Node.js 执行模型

Node.js 的执行模型遵循浏览器中 JavaScript 的执行模型。它与大多数通用编程平台的执行模型相当不同。

正式来说,Node.js 具有单线程、非阻塞、事件驱动的执行模型。我们将在本节中定义这些术语中的每一个。

非阻塞

简而言之,Node.js 认识到许多程序的大部分时间都在等待其他事情发生,例如,慢速 I/O 操作,如磁盘访问和网络请求。

Node.js 通过使这些操作非阻塞来解决这个问题。这意味着程序执行可以在它们发生时继续。例如,文件系统 API 的stat函数用于检索关于文件的统计信息,可以如下调用:

fs.stat('/hello/world', function (error, stats) {
  console.log('File last updated at: ' + stats.mtime);
});

fs.stat函数传递了两个参数:我们感兴趣的文件的名称和一个回调函数fs.stat调用立即返回,将执行控制权交还给当前线程,但不返回值。如果fs.stat调用之后还有其他命令,那么这些命令将被执行。否则,线程将被释放以执行其他工作。回调函数仅在运行时与文件系统通信完成后被调用(即'回调')。文件系统操作的结果传递给回调函数。

这种非阻塞方法也称为异步编程。其他平台也支持这种编程(例如,C#的async/await关键字和.NET 的 Task Parallel Library)。然而,在 Node.js 中,它被内建得既简单又自然。异步 API 方法都像fs.stat一样调用。它们都接受一个回调函数,该函数传递错误和结果参数。

事件驱动

Node.js 的事件驱动特性描述了操作是如何被安排的。在典型的过程式环境中,一个程序有一个入口点,执行一系列命令直到完成,或者进入循环并在每次迭代中执行一些处理。

Node.js 有一个内置的事件循环,它不对开发者公开。事件循环的职责是决定接下来执行哪段代码。通常,这将是一个准备好运行的回调函数,作为对其他事件的响应。例如,文件系统操作可能已完成,超时可能已过期,或者可能已到达新的网络请求。

这个内置的事件循环通过提供一致的方法并避免应用程序需要管理自己的调度,简化了异步编程。

单线程

Node.js 的单线程特性简单来说就是每个进程中只有一个执行线程。此外,每一块代码都保证能够运行完成,而不会被其他操作中断。这极大地简化了开发,并使得程序更容易推理。它消除了各种并发问题的可能性。例如,不需要像 Java 或.NET 那样同步/锁定对共享进程内状态的访问。一个进程不能自己死锁或在其代码中创建竞态条件。单线程编程只有在线程永远不会因为长时间运行的工作而阻塞等待时才是可行的。因此,这种简化的编程模型是由 Node.js 的非阻塞特性实现的。

介绍 Node.js 生态系统

内置的 Node.js API 为创建应用程序提供了一个低级核心。应用程序通常只直接使用这些 API 的一小部分。它们通常使用第三方库模块,这些模块为应用程序开发提供了更高层次的抽象。

Node.js 有自己的包管理器,npm。这与 .NET 的 NuGet 或 Java 的 Maven 的包管理方面类似。应用程序在简单的 JSON 文件中指定它们的依赖项。

npm 注册表提供了一个包的中央存储库。这个注册表迅速增长,并且已经比其他平台的相应存储库大得多(在可用的包数量方面)(见 www.modulecounts.com/)。有数十万个包可供选择,提供了广泛的功能。

npm 命令行工具可以用来下载包和安装新的包。库依赖项被安装到每个应用程序的本地。一些包提供命令行工具,这些工具可能被全局安装而不是在特定项目下。

在 npm 上可用的许多框架被分为一个小型的可扩展核心和多个可组合模块。这种方法使得理解你的应用程序所依赖的库变得容易,避免了需要推理复杂重量级框架的需求。

在 Node.js 中调用非阻塞(异步)API 方法的一致性也体现在其第三方库中。这种一致性使得构建全异步的应用程序变得容易。

为什么选择 JavaScript?

与其他流行的面向对象OO)语言相比,JavaScript 似乎是一种不太直观的语言。它还有一些怪癖和缺陷,这引起了批评(以及偶尔的嘲笑)。因此,它可能是一个新的编程平台的选择似乎令人惊讶。本节讨论了使 JavaScript 成为更具吸引力的选择的因素。

清晰的画布

JavaScript 的大小和复杂性是其吸引力的一部分。核心语言本身(不包括 DOM 等 API)小巧简单。这使得 Node.js 能够建立自己的风格和约定。

Node.js 提供的新 API 和一致的异步编程方法在更复杂、具有更大预存标准类库的语言中是不可能的。

函数性

JavaScript 最初被构建为浏览器客户端功能的一种编程语言。这可能不会让它成为通用编程的明显选择。

实际上,这两个用例确实有一些重要的共同点。用户界面代码自然是事件驱动的(例如,将事件处理器绑定到按钮点击)。Node.js 通过将事件驱动方法应用于通用编程来使这一点成为优点。

JavaScript 支持将函数作为一等对象。这意味着可以动态地创建函数并传递它们的引用。这与 Node.js 的异步、非阻塞方法很好地结合在一起。特别是,基于回调函数的 API 的暴露和使用变得非常容易。

光明的未来

在过去几年中,JavaScript 因其被广泛用于在 Web 上提供丰富功能而受到了很多关注。浏览器供应商投入了大量的工程努力来提高 JavaScript 的性能。Node.js 通过使用 Chrome 的 V8 引擎直接受益于这一点。

JavaScript 语言本身正在进行一些重大的改进,以使其变得更好。ECMAScript 2015 标准(之前称为 ES6)代表了该语言历史上最重大的修订。它引入了使语言更直观、更简洁的功能。它还解决了 JavaScript 过去被批评的缺陷,消除了陷阱,使程序更容易推理。

何时使用 Node.js

如本章前面所讨论的,Node.js 认识到 I/O 是许多应用的瓶颈。在大多数编程平台上,线程会在 I/O 操作上浪费时间。开发者可以采取一些方法来避免这种情况,但这些方法都需要在他们的代码中增加一些复杂性。在 Node.js 中,平台本身提供了一个完全自然的方法。

编写 Web 应用

Node.js 的旗舰用例是构建 Web 应用。这些应用本质上是事件驱动的,因为大多数或所有处理都是在响应 HTTP 请求时进行的。此外,许多网站本身进行的计算工作量很小。它们倾向于执行大量的 I/O 操作:

  • 客户端流式请求

  • 与本地或通过网络与数据库通信

  • 通过网络从远程 API 拉取数据

  • 从磁盘读取文件以发送回客户端

这些因素使得 I/O 操作很可能是 Web 应用的瓶颈。Node.js 的非阻塞编程模型允许 Web 应用充分利用单线程。一旦这些 I/O 操作中的任何一个开始,线程就会立即空闲出来,开始处理另一个请求。当 I/O 操作完成时,每个请求的处理将通过异步回调继续进行。处理线程只是启动并链接这些操作,而不会等待它们完成。这使得 Node.js 能够比其他平台处理每线程的请求数量要高得多。你也可以通过简单地运行多个 Node.js 进程实例来利用多个线程(例如,在多核 CPU 上)。

识别其他用例

当然,也有一些应用程序的 I/O 操作不多,更可能是 CPU 密集型的。Node.js 对于计算密集型应用程序可能不太适合。处理内存中大量数据的程序对 I/O 的关注较少。

虽然 Web 应用程序不是唯一的高 I/O 应用程序,但以下类别的程序也可能是 Node.js 的良好候选者:

  • 操作磁盘上大量数据的工具

  • 协调其他软件或硬件的监督程序

  • 需要响应用户输入的非浏览器图形用户界面应用程序

Node.js 特别适合作为粘合剂应用程序,将来自其他远程服务的功能组合在一起。微服务作为架构模式日益流行,使得这类应用程序更加常见。

为什么现在?

Node.js 已经存在了几年,但如果你还没有开始使用它,现在正是时候。

Node.js v4 在 2015 年底的发布巩固了项目的治理模式,预示着 Node.js 的成熟。它还允许项目与 V8 引擎保持更紧密的更新。这意味着 Node.js 可以更直接地从 V8 的持续开发中受益。例如,V8 的安全性和性能改进现在将更快地进入 Node.js。

如本章前面所讨论的,ECMAScript 2015 标准的发布使 JavaScript 成为一个更具吸引力的语言。它从其他流行的面向对象语言中引入了有用的功能,并解决了 JavaScript 中一些长期存在的问题。

同时,围绕 Node.js 和 JavaScript 的第三方库和工具生态系统继续增长。Node.js 被主要托管平台视为一等公民。像 Google 和 Microsoft 这样的公司也在支持 JavaScript 和相关技术。

摘要

在本章中,我们已经了解了 Node.js 及其独特的执行模型,探讨了围绕 Node.js 和 JavaScript 不断发展的生态系统,看到了选择 JavaScript 作为语言的原因,并描述了可以从 Node.js 中受益的应用类型。

现在你已经了解了 Node.js 的工作原理以及何时使用它,是时候深入其中,并启动我们的第一个 Node.js 应用程序。

第二章:Node.js 入门

本章将帮助你开始使用 Node.js。你会看到这有多快,以及开始编写网络应用有多容易。你还将选择一个用于 Node.js 开发的开发环境。在本章中,我们将涵盖以下主题:

  • 安装 Node.js

  • 编写我们的第一个 Node.js 网络应用程序

  • 设置我们的开发环境

安装和运行 Node.js

要安装 Node.js,请访问nodejs.org,下载并运行当前推荐版本的安装程序包。本书中的示例基于 2016 年 4 月发布的 Node.js v6,支持到 2018 年 4 月。

安装后,打开控制台窗口(在 Windows 上运行命令提示符,或在 Mac 上运行终端)并输入node

这将打开 Node.js 的 REPL,它就像浏览器中的 JavaScript 控制台一样工作。尝试输入几个命令并查看输出:

> function square(x) { return x*x; }
undefined
> square(42)
1764
> new Date()
2016-05-02T16:08:41.915Z
> var foo = { bar: 'baz' }
undefined
> typeof foo
'object'
> foo.bar
'baz'

现在让我们利用 Node.js 特有的 API 之一来创建一个 HTTP 服务器。在 REPL 中输入以下命令(每个命令的输出为了简洁起见省略):

> var listener = function(request, response) { response.end('Hello World!') }
> require('http').createServer(listener).listen(3000)

现在尝试在你的浏览器中访问http://localhost:3000。恭喜!你只用两行代码就编写了你的第一个网络服务器。第一行定义了一个处理 HTTP 请求并返回响应的回调函数。第二行设置了一个新的服务器,该服务器在端口 3000 上接受 HTTP 请求,并为每个请求调用我们的回调函数。

你可以通过输入process.exit()来退出 Node.js 的 REPL。

选择编辑器

当然,我们不会在 REPL 中编写所有的代码。你可以使用任何你喜欢的文本编辑器或 IDE 来编写 Node.js 的 JavaScript 代码。如果你不确定该使用什么,可以尝试以下之一:

这两个都是免费的、轻量级的 IDE,实际上是用 Node.js 实现的。它们都适用于 Windows、Mac 和 Linux。

本书其余部分中的代码列表将是 JavaScript 源代码文件,而不是要输入到 REPL 中的命令。

使用应用程序框架

我们在 REPL 中创建的服务器使用了 Node.js 内置的低级 HTTP 模块。这提供了一个 API 来创建一个从请求中读取数据并向响应写入的服务器。

与其他编程平台一样,有可用的框架提供了编写网络应用的高级抽象。这些包括诸如 URL 路由和模板引擎等功能。ASP.NET MVC、Ruby on Rails 和 Spring MVC 都是不同平台上此类框架的例子。

注意

示例代码

如果你在这本书的任何地方遇到困难,你可以通过github.com/NodeJsForDevelopers上的代码进行跟随(每个章节都有一个存储库,每个引入新代码的标题都有一个提交)。

在本书中,我们将使用名为 Express 的框架来在 Node.js 中编写网络应用程序。Express 是 Node.js 中最受欢迎的网络应用程序框架。它非常适合我们即将构建的小型应用程序。它还提供了对重要概念的良好介绍。大多数其他流行的 Node.js 网络应用程序框架在概念上与 Express 类似,其中几个实际上是在其之上构建的。

开始使用 Express

要启动我们的基于 Express 的应用程序,我们将使用 npm 安装 express-generator 包,该包将基于 Express 创建一个骨架应用程序。请在控制台(即您的常规终端,而不是 Node.js REPL 内部)运行以下命令:

> npm install -g express-generator@~4.x

-g 选项全局安装 Express 生成器,因此您可以从任何位置运行它。我们接下来要运行的命令将创建一个新的文件夹来存放我们的应用程序代码,因此请运行此命令,以便将此文件夹放置在您希望的位置:

> express --hogan chapter02

注意

模板引擎

Express 提供了模板引擎的选择。我们将使用 Hogan,它是 Mustache 模板引擎的一个实现。您可能已经从客户端库中熟悉了 Mustache。不过,即使不熟悉也不要担心。它非常容易上手。

如您从输出中看到的,这为我们设置了一个最小标准应用程序结构。现在,按照生成器的指示,运行以下命令来安装我们的应用程序所依赖的模块:

> cd chapter02
> npm install

生成器为我们创建了一个骨架 Node.js 网络应用程序。让我们尝试运行它:

> npm start

现在,再次访问 http://localhost:3000,您将看到如此处所示的 Express 欢迎页面:

开始使用 Express

探索我们的 Express 应用程序

让我们看看 Express 生成器为我们创建的文件夹:

  • node_modules:此文件夹包含我们的应用程序所依赖的第三方包,这些包在我们运行 npm install 时安装(通常会将此目录排除在源代码控制之外)

  • public:此文件夹包含我们的应用程序的静态资源:图像、客户端 JavaScript 和 CSS

  • routes:此文件夹包含我们的应用程序的逻辑

  • views:此文件夹包含我们的应用程序的服务器端模板

此外,还有一些文件不包含在任何前面的文件夹中:

  • package.json:此文件包含我们的应用程序的元数据,这些元数据由之前使用的 npm installnpm start 命令使用。我们将在第四章介绍 Node.js 模块中进一步探讨此文件。

  • app.js:此文件是应用程序的主要入口点,它将所有前面的组件粘合在一起并初始化 Express。我们将在本章后面更详细地介绍此文件。

  • bin/www:此文件是一个 Node.js 脚本,用于启动我们的应用程序。这是当我们运行 npm start 时执行的脚本。

在这个阶段,理解 bin/www 脚本中的所有内容并不重要。然而,请注意,它使用了与 REPL 示例中相同的 http.createServer 调用。不过,这次监听器参数不是一个简单的函数,而是我们整个应用程序(定义在 app.js 中)。

理解 Express 路由和视图

路由 在 Express 中包含处理请求和渲染适当响应的逻辑。它们在 MVC 框架(如 ASP.NET、Spring MVC 或 Ruby on Rails)中的控制器有类似的责任。

在浏览器中为我们刚刚查看的页面服务的路由可以在 routes/index.js 中找到,其看起来如下所示:

var express = require('express');
var router = express.Router();

/* GET home page. */
router.get('/', function(req, res, next) {
  res.render('index', { title: 'Express' });
});

module.exports = router;

require 调用导入了 Express 模块。我们将在第四章中更详细地讨论它是如何工作的,介绍 Node.js 模块。现在,把它想象成.NET 或 Java 中的 usingimport 语句。对 express.Router() 的调用创建了一个上下文,我们可以在其中定义新的路由。我们将在本章后面更详细地讨论这一点(见 使用 Express 创建模块化应用程序)。router.get() 调用为这个上下文添加了一个新的处理器,用于处理 '/' 路径的 GET 请求。

callback 函数接受一个请求和一个响应参数,类似于本章开头我们“Hello World!”服务器中的监听器。然而,这里的请求和响应是由 Express 提供的对象,具有额外的功能。

render 函数允许我们使用传递给它的数据来响应模板,这通常是路由的 callback 函数中最后做的事情。在这里,我们将包含标题 Express 的对象传递给视图模板。

视图模板可以在 views/index.hjs 中找到,其看起来如下所示:

<!DOCTYPE html>
<html>
  <head>
    <title>{{ title }}</title>
    <link rel='stylesheet' href='/stylesheets/style.css' />
  </head>
  <body>
    <h1>{{ title }}</h1>
    <p>Welcome to {{ title }}</p>
  </body>
</html>

这是一个 Hogan 模板。如前所述,Hogan 是 Mustache 的一个实现,Mustache 是一个非常轻量级的模板语言,它限制了视图中的逻辑量。您可以在mustache.github.io/mustache.5.html查看 Mustache 的完整语法。

我们的模板是一个简单的 HTML 页面,包含一些特殊的模板标签。{{ title }} 标签会被路由传递进来的数据中的标题字段所替换。

让我们改变视图中的标题,使其包含一个名称以及一个标题。它应该看起来像这样:

<h1>Hello, {{ name }}!</h1>

再次尝试重新加载页面。你应该会看到以下内容:

理解 Express 路由和视图

我们还没有名称。这是因为我们的视图数据中没有 name 字段。让我们通过编辑我们的路由来修复这个问题:

var express = require('express');
var router = express.Router();

/* GET home page. */
router.get('/', function(req, res, next) {
 res.render('index', { title: 'Express', name: 'World' });
});

module.exports = router;

如果我们在这个时候再次刷新浏览器,我们仍然看不到名称。这是因为我们的应用程序已经加载了我们的路由,所以不会检测到变化。

返回您的终端并终止正在运行的应用程序。再次启动它(使用npm start),并在浏览器中重新加载页面。现在您应该会看到文本Hello, World!

使用 nodemon 进行自动重启

每次我们进行更改时都重新启动应用程序确实有点繁琐。我们可以通过使用nodemon来运行我们的应用程序来做得更好,它会在我们进行更改时自动重新启动应用程序:

> npm install -g nodemon
> nodemon

再次尝试更新routes/index.js文件(例如,将字符串名称更改为您自己的名称),然后刷新浏览器。这次,更改应该会显示出来,而无需您手动停止和重新启动应用程序。请注意,这个过程是由 nodemon 重新启动的,所以如果我们的应用程序存储了任何内部状态,这将丢失。

使用 Express 创建模块化应用程序

要找出当请求被发送时我们的路由是如何被调用的,我们需要查看app.js引导文件。请看以下两行:

var routes = require('./routes/index');
...
app.use('/', routes);

这告诉 Express 使用在routes/index.js中定义的路由上下文来处理对根路径('/')的请求。

/users路径下设置路由的类似调用。尝试在浏览器中访问此路径。定义此响应的路由在/routes/users.js中。

注意,/routes/users.js中的路由绑定到了'/',与/routes/index.js中的路由相同。这样做的原因是这些路径各自相对于一个单独的路由器实例,而/routes/users.js中创建的实例在app.js中的/users路径下挂载。

这种机制使得构建由较小模块组成的大型应用程序变得容易。您可以将它视为类似于 ASP.NET MVC 中的区域功能,或者简单地将其视为 MVC 控制器分组操作方法的替代结构。

引导 Express 应用程序

让我们来看看app.js文件的其他部分。由于 Express 版本之间的细微差异,您的文件可能看起来与下面的列表不完全相同,但它将包含大致相同的部分。

文件顶部的各种require()调用导入了应用程序使用的模块,包括内置的 Node.js 模块(HTTP 和 Path)、第三方库以及应用程序自己的路由。以下行初始化 Express,告诉它在哪里查找视图模板以及使用什么渲染引擎(在我们的情况下,是 Hogan)。

var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', '{views}');

文件的其余部分由对app.use()的调用组成。这些注册了用于处理请求的各种不同的中间件。它们注册的顺序形成了一个请求处理管道。您可能已经熟悉这种模式来自 Java 中的 servlet 过滤器,或者来自 OWIN 和 ASP.NET 中的IAppBuilder/IApplicationBuilder/IBuilder接口。不过不用担心,我们将在这里彻底探讨中间件。

理解 Express 中间件

中间件函数是 Express 应用程序的基本构建块。它们只是接受请求和响应参数(就像我们之前的监听函数一样)以及链中下一个中间件的引用的函数。

每个中间件函数都可以在传递给链中的下一个中间件之前操作请求和响应对象。通过这种方式将中间件链接在一起,你可以从简单的模块化组件构建复杂的功能。这也允许你的应用程序逻辑与诸如日志记录、身份验证或错误处理等横切关注点之间进行清晰的分离。

函数不仅可以传递控制权给链中的下一个中间件,还可以结束请求的处理并返回一个响应。中间件也可以挂载到特定的路径或路由实例上,例如,如果我们想在网站的特定部分增强日志记录。

事实上,Express 路由只是中间件的一个例子:我们之前看过的路由是具有上述三个相同参数的普通中间件函数。它们只是恰好被挂载到特定的路径上,并返回一个响应。

实现错误处理

让我们更仔细地看看 app.js 中的某些中间件。首先,看看 404 错误处理器:

app.use(function(req, res, next) {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});

这个函数总是返回一个响应。那么为什么我们的应用程序不总是返回 404 呢?记住,中间件是按顺序调用的,并且路由(在函数注册之前)返回一个响应而不调用下一个中间件。这意味着只有不匹配任何路由的请求才会调用 404 函数,这正是我们想要的。

那么,app.js 中的其他两个错误处理器是什么?它们返回一个带有自定义错误页面的 500 响应。为什么我们的应用程序在所有情况下都不返回 500 响应?如果另一个中间件在调用 next() 之前抛出错误,这些是如何执行的?

在 Express 中,错误处理是一个特殊情况。错误处理中间件函数接受四个参数而不是三个,第一个参数是一个错误。它们应该在所有其他中间件之后注册。

在出现错误的情况下(无论是抛出错误还是中间件函数在调用 next() 时传递错误参数),Express 将跳过任何其他非错误处理中间件并开始执行错误处理器。

使用 Express 中间件

让我们通过使用 cookie 解析中间件(它是 express-generator 创建的骨架应用程序的一部分)来查看一些 Express 中间件的实际应用。我们可以通过使用一个 cookie 来存储某人访问网站次数来实现这一点。按照以下方式更新 routes/index.js

router.get('/', function(req, res, next) {
 var visits = parseInt(req.cookies.visits) || 0;
 visits += 1;
 res.cookie('visits', visits);
 res.render('index',
 { title: 'Express', name: 'World', visits: visits }
 );
});

并在 views/index.hjs 中添加一行新内容:

<p>You have visited this site {{visits}} time(s).</p>

现在再次访问http://localhost:3000/并刷新页面几次。您应该会看到访问计数根据存储在 cookie 中的值增加。要查看 cookie 解析中间件为我们做了什么,尝试删除或注释掉以下app.js中的这一行,然后重新加载页面:

app.use(cookieParser());

如您从错误中看到的那样,请求的cookies属性现在是未定义的。cookie 解析中间件会查看请求的 cookie 头并将其转换为对我们来说方便的 JavaScript 对象。这是中间件的一个常见用例。bodyParser中间件对请求体执行非常相似的任务,将原始文本转换为 JavaScript 对象,使其在我们的路由中更容易使用。

注意,上面的错误响应还展示了我们的错误处理中间件。尝试注释掉app.js文件末尾的错误处理程序,然后再次重新加载页面。我们现在得到的是默认的堆栈跟踪,而不是我们在处理程序中定义的自定义错误响应。

摘要

在本章中,我们安装了 Node.js,了解了如何从命令行与之交互,并开始用它来编写 Web 应用程序。我们学习了 Express 以及我们如何使用路由和中间件来构建应用程序的结构。

虽然我们在本章中看到了一些代码,但我们并没有真正详细地探索 JavaScript 语法。在我们向应用程序添加更多功能之前,我们应该确保我们对 JavaScript 足够熟悉。这是下一章的主题。

第三章。JavaScript 入门

理解 JavaScript 对于编写 Node.js 应用程序非常重要。JavaScript 不是一个庞大或复杂的语言,但它可能看起来有些不寻常,并且有一些需要注意的怪癖和陷阱。

ECMAScript 2015(之前称为 ES6)的最新发布引入了许多新的语言特性,使 JavaScript 编程更加容易和安全。然而,并非所有 ES2015 特性都已在所有实现中可用。但是,我们将在本章中提到的所有 ES2015 特性都在 Node.js 以及大多数其他 JavaScript 环境中可用。

在本章中,我们将熟悉 JavaScript,以便我们可以自信地编写 Node.js 应用程序。我们将涵盖以下主题:

  • JavaScript 的类型系统

  • JavaScript 作为一种函数式编程语言

  • JavaScript 中的面向对象编程

  • JavaScript 的基于原型的继承

介绍 JavaScript 类型

JavaScript 是一种动态类型语言。这意味着类型是在运行时检查的,当你尝试对变量进行操作时,而不是由编译器检查。例如,以下代码是有效的 JavaScript 代码:

var myVariable = 0; 
console.log(typeof myVariable); // Prints "number"
myVariable = "1";
console.log(typeof myVariable); // Prints "string"

虽然变量确实有类型,但这种类型可能会在变量的整个生命周期中发生变化。

JavaScript 还试图在可能的情况下隐式转换类型,例如,使用相等运算符:

console.log(2 == "2"); // Prints "true"

虽然这可能在前端 JavaScript 中有意义(例如,与表单输入的值进行比较),但通常来说,它更可能是错误或混淆的来源。因此,建议始终使用严格的相等和不相等运算符:

console.log(2 === "2"); // Prints "false"
console.log(2 !== "2"); // Prints "true"

JavaScript 原始类型

JavaScript 有少量原始类型,类似于 C# 和 Java。这些是字符串、数字和布尔值,以及特殊的单值类型 null 和 undefined。ES2015 还添加了 symbol 类型,但在这里我们不会介绍它,因为它的用例更为高级。

字符串是不可变的,就像在 C# 和 Java 中一样。连接字符串会创建一个新的字符串实例。字符串字面量可以用双引号(如在 C# 或 Java 中)或单引号定义。这些可以互换使用(通常使用起来更简单,可以避免转义)。

ES2015 还引入了对模板字符串的支持,这些字符串使用反引号定义,并可以包含插值表达式。

这里有一些定义相同字符串的方法:

var singleQuoted = '"Hey", I said, "I\'m a string"';
var doubleQuoted = "\"Hey\", I said, \"I'm a string\"";
console.log(doubleQuoted === singleQuoted); // Prints "true"

var expression = 'Hey';
var templated = `"${expression}", I said, "I'm a string"`;
console.log(templated === singleQuoted); // Prints "true"

数字是 JavaScript 唯一的内置数值类型。它是一个双精度 64 位浮点数,类似于 C# 或 Java 中的 double。它有特殊的值 NaN(不是一个数字)和 Infinity,用于表示无法用其他方式表示的值:

console.log(1 / 0); // Prints "Infinity"
console.log(Infinity + 1); // Prints "Infinity" 
console.log((1 / 0) === (2 / 0)); // Prints "true"

var notANumber = parseInt("foo");
console.log(notANumber); // Prints "NaN"
console.log(notANumber === NaN); // Prints "false"
console.log(isNaN(notANumber)); // Prints "true"

注意

注意,尽管只有一个 NaN 值,但它不会被视为等于自身。JavaScript 提供了特殊的 isNaN 函数来测试变量是否包含 NaN 值。

null类型有一个实例,由字面量null表示,就像在 C#或 Java 中一样。JavaScript 也有undefined类型。从未被分配的变量或参数将具有undefined值:

var declared;
console.log(typeof declared); // Prints "undefined"
console.log(declared === undefined); // Prints "true"

console.log(typeof undeclared); // Prints "undefined"
console.log(undeclared === undefined); // throws ReferenceError

注意,我们的undeclared标识符在正常代码中不能作为变量访问,因为它没有被声明。然而,我们可以将它传递给typeof运算符,它评估为undefined类型。

函数式面向对象编程

JavaScript 是一种函数式面向对象编程语言。然而,它与 C#或 Java 等其他面向对象编程语言相当不同。尽管语法相似,但有一些重要的区别。

JavaScript 中的函数式编程

在 JavaScript 中,函数是一等对象。这意味着函数可以像任何其他对象一样被对待:它们可以动态创建,分配给变量,或作为参数传递给方法。

这使得指定事件回调或使用高阶函数进行更函数式编程变得非常容易。高阶函数是接受其他函数作为参数,或返回另一个函数的函数。以下是一个简单的例子,首先以命令式风格然后以函数式风格过滤数字数组。注意,此示例还展示了 JavaScript 的数组字面量表示法,用于创建数组,使用方括号。它还演示了 JavaScript 的条件构造和其中一个循环构造,这些在其他语言中应该是熟悉的:

var numbers = [1,2,3,4,5,6,7,8];

var filteredImperatively = [];
for (var i = 0; i < numbers.length; ++i) {
    var number = numbers[i];
    if (number % 2 === 0) {
        filteredImperatively.push(number);
    }
}
console.log(filteredImperatively); // Prints [2, 4, 6, 8]

var filteredFunctionally =
    numbers.filter(function(x) { return x % 2 === 0; });
console.log(filteredFunctionally); // Prints [2, 4, 6, 8]

示例中的第二种方法使用函数表达式在行内定义一个新的匿名函数。通常,这被称为 lambda 表达式(数学中的 lambda 演算之后)。这个函数被传递给 JavaScript 数组上可用的内置filter表达式。

在 C#中,最初只能使用委托来执行赋值和传递行为。自 C# 3.0 以来,对 lambda 表达式的支持使得使用函数的方式更加简单。这允许更函数式的编程风格,例如,使用 C#的语言集成查询LINQ)功能。

在 Java 中,长期以来没有一种让函数独立存在的方法。你必须在一个(可能是匿名)类上定义一个方法,并将这个方法传递出去,这增加了大量的样板代码。Java 8 以类似于 C#的方式引入了对 lambda 表达式的支持。

虽然 C# 和 Java 可能需要一段时间才能赶上,但你可能会想,JavaScript 现在可能落后了。与 C# 和 Java 中的 lambda 语法相比,JavaScript 定义新函数的语法相当笨拙。

这尤其不幸,因为 JavaScript 使用类似于 C 语言的语法,以便与其他语言如 Java 兼容!在 ES2015 中,箭头函数解决了这个问题,允许我们将前面的示例重写如下:

var numbers = [1,2,3,4,5,6,7,8];
var filteredFunctionally = numbers.filter(x => x % 2 === 0);
console.log(filteredFunctionally); // Prints [2, 4, 6, 8]

这是一个带有单个参数和单个表达式的简单箭头函数。在这种情况下,表达式是隐式返回的。

注意

在箭头函数中,可以将 => 符号读作 goes to

箭头函数可以有多个(或零个)参数,在这种情况下,它们必须被括号包围。如果函数体被大括号包围,它可能包含多个语句,在这种情况下没有隐式返回。这些语法规则与 C# 中的 lambda 表达式语法完全相同。

这里是一个更复杂的箭头函数表达式,它返回两个参数中的最大值:

var max = (a, b) => {
    if (a > b) {
        return a;
    } else {
        return b;
    }
};

理解 JavaScript 中的作用域

传统上,在 JavaScript 中,只有两种可能的变量作用域:全局和函数。也就是说,一个标识符(变量名)是在全局定义的,或者是对整个函数定义的。这可能会导致一些令人惊讶的行为,例如:

function scopeDemo() {
    for (var i = 0; i < 10; ++i) {
        var j = i * 2;
    }
    console.log(i, j);
}
scopeDemo();

在大多数其他语言中,您会期望 ifor 循环的整个过程中存在,而 j 在每次循环迭代中存在。因此,您会期望这个函数记录 undefined undefined。实际上,它记录的是 10 18。这是因为变量不是作用域到 for 循环的块中,而是作用域到整个函数。因此,前面的代码等同于以下代码:

function scopeDemo() {
    var i, j;
    for (i = 0; i < 10; ++i) {
        j = i * 2;
    }
    console.log(i, j);
}
scopeDemo();

JavaScript 将所有变量声明视为在函数顶部创建的。这被称为 变量提升。虽然一致,但这可能会令人困惑并导致微妙的错误。

ES2015 引入了 let 关键字用于声明变量。这与 var 的行为完全相同,只是变量是块作用域的。还有一个 const 关键字,它与 let 的行为相同,只是不允许重新赋值。建议您始终使用 let 而不是 var,并在可能的情况下使用 const。以下代码为例:

function scopeDemo() {
    "use strict";
    for (let i = 0; i < 10; ++i) {
        let j = i * 2;
    }
    console.log(i, j); // Throws ReferenceError: i is not defined
}
scopeDemo();

注意前面示例中的 "use strict" 字符串。我们将在下一节中讨论这个问题。

严格模式

"use strict" 字符串是给 JavaScript 解释器的提示,以启用 严格模式。这使得语言更安全,因为它将某些语言用法视为错误。例如,在严格模式下,如果误输变量名,则会在全局级别定义一个新变量,而不是引发错误。

严格模式现在也被一些浏览器用于启用 JavaScript 最新版本中的功能,例如之前显示的 letconst 关键字。如果您在浏览器中运行这些示例,您可能会发现前面的列表在没有严格模式的情况下无法工作。

在任何情况下,您都应该在所有生产代码中始终启用严格模式。"use strict" 字符串影响当前作用域中的所有代码(即 JavaScript 的传统函数或全局作用域),因此通常应放置在函数的顶部(或 Node.js 中模块脚本文件的顶部)。

JavaScript 中的面向对象编程

任何不是 JavaScript 内置原始类型(字符串、数字、null 等)的东西都是一个 对象。这包括函数,正如我们在上一节中看到的。函数只是可以带参数调用的特殊类型的对象。数组是具有类似列表行为的特殊类型的对象。所有对象(包括这两种特殊类型)都可以有属性,属性只是带有值的名称。你可以把 JavaScript 对象想象成一个具有字符串键和对象值的字典。

可以使用对象字面量表示法创建具有属性的对象,如下例所示:

var myObject = {
    myProperty: "myValue",
    myMethod: function() {
        return `myProperty has value "${this.myProperty}"`;
    }
};
console.log(myObject.myMethod());

即使你没有写过任何 JavaScript,你也可能对这个表示法很熟悉,因为它是 JSON 的基础。请注意,方法只是一个恰好具有函数值的对象属性。还要注意,在方法内部,我们可以使用 this 关键字来引用包含的对象。

最后,请注意,我们不需要为我们的对象定义一个类。JavaScript 在面向对象的语言中很独特,因为它实际上没有类。

无类编程

在大多数面向对象的语言中,我们可以在类中声明方法,供所有对象实例使用。我们还可以通过继承在类之间共享行为。

假设我们有一个包含大量点的图。这些点可能是由动态创建的具有一些共同行为的对象表示。我们可以这样实现点:

function createPoint(x, y) {
    return {
        x: x,
        y: y,
        isAboveDiagonal: function() {
            return this.y > this.x;
        }
    };
}

var myPoint = createPoint(1, 2);
console.log(myPoint.isAboveDiagonal()); // Prints "true"

这种方法的一个问题是,isAboveDiagonal 方法在我们的图上的每个点上都被重新定义,因此占用更多的内存空间。

我们可以使用 原型继承 来解决这个问题。虽然 JavaScript 没有类,但对象可以继承自其他对象。每个对象都有一个 原型。如果我们尝试访问一个对象的属性,而这个属性不存在,解释器将在对象的原型上查找具有相同名称的属性。如果那里也不存在,它将检查原型的原型,依此类推。原型链将以内置的 Object.prototype 结束。

我们可以这样为我们点的对象实现:

var pointPrototype = {
    isAboveDiagonal: function() {
        return this.y > this.x;
    }
};

function createPoint(x, y) {
    var newPoint = Object.create(pointPrototype);
    newPoint.x = x;
    newPoint.y = y;
    return newPoint;
}

var myPoint = createPoint(1, 2); 
console.log(myPoint.isAboveDiagonal()); // Prints "true"

isAboveDiagonal 方法现在只在内存中的 pointPrototype 对象上存在一次。

当我们尝试在一个单独的点对象上调用 isAboveDiagonal 方法时,它不存在,而是在原型上找到。

注意,这告诉我们关于 this 关键字的重要信息。它实际上指的是当前函数被调用的对象,而不是定义它的对象。

使用 new 关键字创建对象

我们可以以略微不同的形式重写前面的代码示例,如下所示:

var pointPrototype = {
    isAboveDiagonal: function() {
        return this.y > this.x;
    }
}

function Point(x, y) {
    this.x = x;
    this.y = y;
}

function createPoint(x, y) {
    var newPoint = Object.create(pointPrototype);
 Point.apply(newPoint, arguments);
    return newPoint;
}

var myPoint = createPoint(1, 2);

这使用了特殊的 arguments 对象,它包含当前函数的参数数组。它还使用了 apply 方法(所有函数都可用)来以相同的参数在 newPoint 对象上调用 Point 函数。

目前,我们的 pointPrototype 对象与 Point 函数并没有特别紧密的联系。让我们通过使用 Point 函数的 prototype 属性来解决这个问题。这是一个默认情况下所有函数都有的内置属性。它只包含一个空对象,我们可以向其中添加额外的属性:

function Point(x, y) {
    this.x = x;
    this.y = y;
}

Point.prototype.isAboveDiagonal = function() {
    return this.y > this.x;
}

function createPoint(x, y) {
 var newPoint = Object.create(Point.prototype);
    Point.apply(newPoint, arguments);
    return newPoint;
}

var myPoint = createPoint(1, 2);

这可能看起来是一种不必要的复杂做事方式。然而,JavaScript 有一个特殊的操作符,允许我们极大地简化之前的代码,如下所示:

function Point(x, y) {
    this.x = x;
    this.y = y;
}

Point.prototype.isAboveDiagonal = function() {
    return this.y > this.x;
}

var myPoint = new Point(1, 2);

new 操作符的行为与上一个示例中的 createPoint 函数相同。有一个小的例外:如果 Point 函数实际上返回了一个值,那么就会使用这个值而不是 newPoint。在 JavaScript 中,如果函数打算与 new 操作符一起使用,通常以大写字母开头。

使用类进行编程

虽然 JavaScript 实际上并没有类,但 ES2015 引入了一个新的 class 关键字。这使得以可能比其他面向对象语言更熟悉的方式实现共享行为和继承成为可能。

我们之前代码的等价物看起来如下所示:

class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }

    isAboveDiagonal() {
        return this.y > this.x;
    }
}

var myPoint = new Point(1, 2);

注意,这实际上与我们的上一段代码是等价的。class 关键字只是设置已讨论的基于原型的继承的语法糖。

基于类的继承

如前所述,一个对象的原型可能还有另一个原型,从而允许继承链。使用上一节中提到的基于原型的方法设置这样的链变得相当复杂。使用类关键字会更直观,如下面的示例(可能用于绘制带有误差棒的图表)所示:

class UncertainPoint extends Point {
    constructor(x, y, uncertainty) {
        super(x, y);
        this.uncertainty = uncertainty;
    }

    upperLimit() {
        return this.y + this.uncertainty;
    }

    lowerLimit() {
        return this.y - this.uncertainty;
    }
}

var myUncertainPoint = new Point(1, 2, 0.5);

概述

在本章中,我们介绍了 JavaScript 的类型系统,理解了函数在 JavaScript 中的第一类对象,看到了 JavaScript 与其他面向对象语言的不同之处,使用了原型和类来实现继承,并学习了 ECMAScript 2015(ES6)的新特性,这些特性使语言更安全、更易于使用。

现在你已经对 JavaScript 有了一个坚实的基础,你可以自信地开始编写 Node.js 应用程序。在下一章中,我们将扩展我们的 Express 项目,并了解 Node.js 中的模块系统如何允许我们构建我们的代码库。

第四章:介绍 Node.js 模块

现在我们已经熟悉了 JavaScript 语言的语法,我们可以开始构建我们的应用程序。为此,我们需要知道如何构建我们的应用程序,使其能够以可维护的方式增长。

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

  • 使用模块结构 JavaScript 代码

  • 声明和使用我们自己的模块

  • 将模块组织到文件和目录中

  • 实现 Express 中间件模块

组织你的代码库

大多数编程平台都提供了几种结构代码的机制。考虑 C#/.NET 或 Java:你可以使用类、命名空间或包,以及编译单元(程序集或 JAR/WAR 文件)。注意从小规模的组织单元(类)到大规模的组织单元(程序集)的范围。这允许你通过在每个细节级别提供秩序来使代码库更易于接近。

经典的基于浏览器的 JavaScript 开发相当无结构。函数是唯一内置的语言特性,用于组织你的代码。你可以将你的代码分割成单独的脚本文件,但这些文件都在网页的相同全局上下文中共享。

随着时间的推移,人们已经发展出了组织 JavaScript 代码的方法。现在的标准做法是使用模块。JavaScript 有几种不同的模块系统可用,但它们的工作方式相似。每个模块系统都包括以下方面:

  • 一种通过名称和其自己的作用域声明模块的方法

  • 定义模块提供功能的方法

  • 将模块导入另一个脚本的方法

在每个系统中,当你导入一个模块时,你会得到一个普通的 JavaScript 对象,你可以将其分配给一个变量。对于大多数模块,这将是一个包含多个属性和函数的对象。但它可以是任何有效的 JavaScript 对象,例如,一个单独的函数。

大多数模块系统都期望或至少鼓励你将每个模块定义在单独的文件中,就像在其他语言中使用类一样。大型模块通常由其他更小的模块组成。这些模块将组合在一起放在同一个目录下。这样,模块更像命名空间或包。

模块的灵活性意味着你可以使用它们以不同的规模来结构你的代码。JavaScript 中缺乏内置的组织单元层次结构提供了更多的灵活性。这也迫使你更多地思考如何结构你的代码。

JavaScript 模块系统

ECMAScript 2015 将模块作为语言的一个内置特性引入。虽然这已经是一种常见的做法,但对于客户端编程来说,这种做法一直依赖于使用第三方库来提供模块系统。

你可能见过 RequireJS,它提供了一种使用函数来定义模块的方法。RequireJS 使用纯 JavaScript 并在任何环境中工作。它在浏览器中最有用,因为可以通过互联网加载额外的模块。RequireJS 解决了动态和异步加载额外脚本的一些问题。

Node.js 环境有其自己的模块系统,我们将在本章的剩余部分探讨。它利用文件系统来组织模块。

小贴士

你可能会遇到 AMDCommonJS 这样的术语。这些是定义模块的标准。RequireJS 是 AMD 的一个实现,而 Node.js 模块遵循 CommonJS 标准。ECMAScript 2015 模块定义了一个新的标准,具有新的 exportimport 语言关键字。虽然语法与本书中我们将使用的 Node.js 模块系统非常相似,但两者之间切换也很容易。

在 Node.js 中创建模块

我们实际上已经使用了一些 Node.js 模块并创建了一些自己的。让我们再次从 第二章,Node.js 入门,回顾我们的应用程序。

以下代码来自 routes/index.jsroutes/users.js

module.exports = router;

以下是从 app.js 的代码:

var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');

var routes = require('./routes/index');
var users = require('./routes/users');

我们的每个路由(index 和 users)都是一个模块。它们通过 Node.js 定义的内置 module 对象暴露其功能,该对象是每个模块的变量作用域。在前面的例子中,我们每个路由模块提供的对象是一个 Express 路由实例。app.js 脚本使用内置的 require 函数导入这些模块。

注意到 app.js 也使用 require 导入了各种 npm 包。请注意,它使用文件路径来引用我们自己的模块,而 npm 模块则通过名称引用。

让我们看看 Node.js 模块是如何满足 JavaScript 模块功能的三个方面的。

声明一个具有名称和自身作用域的模块

在 Node.js 中,每个独立的 JavaScript 文件自动被视为一个新的模块。与加载到网页中的脚本不同,每个文件都有自己的作用域。模块的名称是文件的名称。

定义模块提供的功能

Node.js 为从模块中导出功能提供了两个内置变量。这些是 module.exportsexportsmodule.exports 被初始化为一个空对象。exports 只是 module.exports 的引用。它等同于以下内容出现在你的脚本之前:

var exports = module.exports = {};

在你的脚本末尾包含在 module.exports 变量中的任何内容都是你模块的导出值。当你的模块在其他地方导入时,这将返回。以下都是等效的:

module.exports.foo = 1;
module.exports.bar = 2;

module.exports = { foo: 1, bar: 2 };

exports.foo = 1;
exports.bar = 2;

注意,以下内容与之前的示例不同。它只是重新分配了 exports,但完全没有改变 module.exports

exports = { foo: 1, bar: 2 };

将模块导入到另一个脚本中

Node.js 提供了另一个用于导入模块的内置变量。这就是我们在本章前面 app.js 中看到的 require 函数。这个函数由 Node.js 提供,始终可用。它接受一个参数,即你想要导入的模块的名称或路径。以下是从 app.js 中摘录的内容,展示了如何通过名称加载第三方模块,以及通过文件路径加载我们自己的模块:

var express = require('express');
...
var routes = require('./routes/index');

注意,我们不需要为我们的模块指定 .js 文件扩展名。Node.js 会自动为我们添加这个扩展名。

定义目录级模块

如本章开头所述,模块也可以更像命名空间。我们可以将整个目录视为一个模块,由单个文件中的较小模块组成。最简单的方法是在目录中创建一个 index.js 文件。

当调用 require('./directoryName') 时,Node.js 将尝试加载一个名为 './directoryName/index.js' 的文件(相对于当前脚本)。index.js 本身并没有什么特殊之处。这只是一个暴露模块入口点的另一个脚本文件。如果 directoryName 包含一个 package.json 文件,Node.js 将首先加载这个文件,并查看是否指定了一个 main 脚本,在这种情况下,Node.js 将加载这个脚本而不是寻找 index.js

要导入本地模块,我们使用文件或目录路径,即以 '/''../''./' 开头,就像前面的例子一样。如果我们用纯字符串调用 require,Node.js 会将其视为相对于 node_modules 文件夹。npm 包只是这个文件夹下的目录级模块。我们将在后面的章节中更详细地探讨定义我们自己的 npm 包。

实现一个 Express 中间件模块

让我们回到我们在 第二章 中开始的 Node.js 应用程序,Node.js 入门。我们将编写一个应用程序,用户可以为彼此设置谜题。首先,我们需要一种识别当前用户的方法。我们将在大多数请求中这样做,使其成为一个跨领域关注点。这是一个中间件的好用例。

现在,我们将以最简单的方式实现用户,只需在 cookie 中存储一个 ID。我们将在后面的章节中探讨更健壮的识别方法。然而,请注意,我们使用中间件意味着我们可以很容易地稍后更改我们的方法。这个关注点封装在我们的用户中间件中,所以我们只需要在一个地方更改它。

首先,我们需要一种生成唯一 ID 的方法。为此,我们将使用 npm 中的 UUID 模块。我们可以在命令行上运行以下命令将其添加到我们的项目中:

> npm install uuid --save

--save 标志将此模块的名称存储在我们的 package.json 文件中,以便它可以通过 npm install 自动安装。这对于从源代码的干净检出中恢复我们的应用程序非常有用(回想一下,人们通常会将 node_modules 目录排除在源代码控制之外,正是因为它可以通过这种方式轻松恢复)。

现在我们已经准备好创建我们的中间件,它将被放置在 middleware/users.js 下:

'use strict';

const uuid = require('uuid');

module.exports = function(req, res, next) {
    let userId = req.cookies.userId;
    if (!userId) {
        userId = uuid.v4();
        res.cookie('userId', userId);
    }
    req.user = {
        id: userId
    };
    next();
};

注意,我们使用 ES2015 的 const 关键字来引用 uuid 模块,因为这个引用永远不会改变。但我们使用 let 关键字来引用 userId 变量,因为这个变量可以被重新赋值。另外,注意我们调用 next() 而不是返回响应,这样下一个中间件就可以继续处理请求。

最后,我们需要将此中间件添加到我们的应用程序中的 app.js 文件:

var users = require('./middleware/users');
var routes = require('./routes/index');
var app = express();

...

app.use(users);
app.use('/', routes);

...

注意,这替换了为我们生成的 ./routes/users 模块的导入和使用。这个路由并不特别有用,但我们很快就会添加更多路由。

我们可以通过修改我们的索引路由和视图来检查我们的中间件是否工作:

routes/index.jsrouter.get('/', function(req, res, next) {
 res.render('index', { title: 'Welcome', userId: req.user.id });
});

以下为 views/index.hjs 的代码:

  <body>
 <h1>{{ title }}</h1>
 <p>Your user ID is {{ userId }}.</p>
  </body>

启动应用程序并访问 http://localhost:3000/。你应该看到一个随机生成的用户 ID。刷新页面,你应该保留相同的 ID。在不同的浏览器(或隐身/私密浏览窗口)中打开网站。这个单独的浏览器会话应该看到不同的 ID。

摘要

在本章中,我们看到了如何使用 Node.js 模块来结构化我们的代码库,以及如何创建一个 Express 中间件模块来实现跨切面关注点。

现在我们有了结构化我们的代码库的方法和识别用户的方法,我们可以继续实现我们应用程序的功能。在下一章中,我们将开始向我们的应用程序添加一些交互性。

创建动态网站

既然我们已经为我们的应用程序建立了一个基本结构,我们就可以开始添加更多功能,并构建一个能够响应用户输入的动态网站。

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

  • 为我们的应用程序添加一个新模块以存储和删除数据

  • 提供 JSON API 以处理用户提交的数据

  • 使用 Ajax 实现客户端和服务器之间的通信

  • 使用部分模板构建更复杂的 HTML 视图

第五章:处理用户提交的数据

我们将实现经典的猜字游戏——Hangman(见en.wikipedia.org/wiki/Hangman_(game))。用户将能够发布新词进行猜测,以及猜测他人发布的词。我们将首先查看创建新游戏。

首先,我们将为管理我们的游戏添加一个新的模块。目前,我们只需将游戏存储在内存中。如果我们将来想要将游戏存储在某种持久存储中,这就是我们将要更改的模块。尽管如此,接口(即添加到module.exports中的函数)可以保持不变。

我们在services/games.js下添加以下代码:

'use strict';

const games = [];
let nextId = 1;

class Game {
    constructor(id, setBy, word) {
        this.id = id;
        this.setBy = setBy;
        this.word = word.toUpperCase();
    }
}

module.exports.create = (userId, word) => {
    const newGame = new Game(nextId++, userId, word); 
    games.push(newGame);
    return newGame;
}

module.exports.get =
  (id) => games.find(game => game.id === parseInt(id, 10));

现在,让我们从上到下查看我们的应用程序。在我们的索引视图(views/index.hjs)中,我们将添加一个简单的 HTML 表单来创建新游戏。

  <body>
    <h1>{{ title }}</h1>
 <form action="/games" method="POST">
 <input type="text" name="word"
 placeholder="Enter a word to guess..." />
 <input type="submit" />
 </form>
  <body>

当提交此表单时,它将向/games发出 POST 请求。目前,这将返回一个 404 错误,因为我们没有在该路由上挂载任何内容(如果您喜欢,可以在浏览器中尝试)。我们可以添加一个新的游戏路由来处理此请求。我们在routes/games.js下添加以下代码:

'use strict';

const express = require('express');
const router = express.Router();
const service = require('../services/games');

router.post('/', function(req, res, next) {
    const word = req.body.word;
    if (word && /^[A-Za-z]{3,}$/.test(word)) {
        service.create(req.user.id, word);
        res.redirect('/');
    } else {
        res.status(400).send('Word must be at least three characters long and contain only letters');
    }
});

module.exports = router;

在我们的新路由中间件中有很多事情在进行:

  • router.post创建一个处理 HTTP POST 请求的处理程序。

  • req.body包含表单值,归功于app.js中的bodyParser中间件。

  • req.user.id包含当前用户,归功于我们的users中间件。

  • res.redirect()发出重定向以重新加载页面。在成功的 POST 请求后始终发出重定向是很重要的。这避免了表单的重复提交。

  • res.status()为响应设置一个替代的 HTTP 状态码,在这种情况下是一个 400,表示验证失败。

我们的路由在请求体中查找名为word的字段。然后检查该字段是否已定义且不为空(在 JavaScript 中,未定义和空字符串都是falsey,因此在条件测试中表现为 false)。它还检查该字段是否与指定我们有效性规则的正则表达式匹配。

最后,该路由利用我们的服务模块实际创建新游戏。将应用逻辑委托给其他模块是路由中间件的常见做法。其主要责任是定义应用程序的 HTTP 接口。其他模块负责实现实际的应用逻辑。这样,我们的路由和中间件与 MVC 框架中的控制器相当。

我们还需要在 /games 路径上挂载这个路由。以下代码来自 app.js

var routes = require('./routes/index');
var games = require('./routes/games');
...
app.use('/', routes);
app.use('/games', games);

通过 Ajax 进行通信

创建了一个游戏后,我们需要一种玩它的方法。既然猜词游戏的核心在于单词是保密的,我们不想将整个单词发送给客户端。相反,我们只想让客户端知道单词的长度,并提供一种方式让他们验证自己的猜测。

要做到这一点,我们首先需要扩展我们的游戏服务模块:

class Game {
    constructor(id, setBy, word) {
        this.id = id;
        this.setBy = setBy;
        this.word = word.toUpperCase();
    }

 positionsOf(character) {
 let positions = [];
 for (let i in this.word) {
 if (this.word[i] === character.toUpperCase()) {
 positions.push(i);
 }
 }
 return positions;
 }
}

现在,我们可以在我们的游戏路由中添加两个新的路由:

const checkGameExists = function(id, res, callback) {
    const game = service.get(id);
    if (game) {
        callback(game);
    } else {
        res.status(404).send('Non-existent game ID');
    }
}

router.get('/:id', function(req, res, next) {
    checkGameExists(
        req.params.id,
        res,
        game => res.render('game', {
            length: game.word.length,
            id: game.id
        }));
});

router.post('/:id/guesses', function(req, res, next) {
    checkGameExists(
        req.params.id,
        res,
        game => {
            res.send({
                positions: game.positionsOf(req.body.letter)
            });
        }
    );
});

这两个路由都使用了一个共享函数来检索游戏,如果游戏不存在则返回 404 状态码。GET 处理器渲染一个视图,就像我们的索引路由一样。POST 处理器调用 res.send(),传入一个 JavaScript 对象。Express 会自动将其转换为客户端的 JSON 响应。这使得在 express 中构建基于 JSON 的 API 非常容易。

现在,我们将创建一个视图和客户端脚本,用于与这个 API 进行通信。我们在 views/game.hjs 下添加以下代码:

<!DOCTYPE html>
<html>
  <head>
    <title>Hangman - Game #{{id}}</title>
    <link rel="stylesheet" href="/stylesheets/style.css" />
    <script src="img/jquery.min.js"></script>
    <script src="img/game.js"></script>
    <base href="/games/{{ id }}/">
  </head>
  <body>
    <h1>Hangman - Game #{{id}}</h1>
    <h2 id="word" data-length="{{ length }}"></h2>
    <p>Press letter keys to guess</p>
    <h3>Missed letters:</h3>
    <p id="missedLetters"></p>
  </body>
</html>

我们在 public/scripts/game.js 下添加以下代码:

$(function() {
    'use strict';

    var word = $('#word');
    var length = word.data('length');

    // Create placeholders for each letter
    for (var i = 0; i < length; ++i) {
        word.append('<span>_</span>');
    }

    var guessedLetters = [];
    var guessLetter = function(letter) {
        $.post('guesses', { letter: letter })
            .done(function(data) {
                if (data.positions.length) {
                    data.positions.forEach(function(position) {
                        word.find('span').eq(position).text(letter);
                    });
                } else {
                    $('#missedLetters')
                        .append('<span>' + letter + '</span>');
                }
            });
    }

    $(document).keydown(function(event) {
        // Letter keys have key codes in the range 65-90
        if (event.which >= 65 && event.which <= 90) {
            var letter = String.fromCharCode(event.which);
            if (guessedLetters.indexOf(letter) === -1) {
                guessedLetters.push(letter);
                guessLetter(letter);
            }
        }
    });
});

注意,在客户端脚本中,我们退回到 ECMAScript 5 标准(例如,使用 var 而不是 let,以及没有箭头函数)。这确保了最大的兼容性。尽管如此,所有主流浏览器的最新版本都会支持我们迄今为止使用的 ES2015 语法元素。

还要注意,客户端没有可用的 Node.js 模块。我们退回到将代码包裹在函数中来隔离作用域。我们将在后面的章节中探讨使客户端代码更模块化的方法。

我们的客户端脚本使用 jQuery。我们不会深入讨论客户端框架,但这里快速解释一下这里使用的功能。jQuery 库提供了一个跨所有浏览器的一致 API 用于 DOM 操作,以及一些用于客户端功能的有用工具。

主要的 jQuery API 通过 $ 对象提供,它是一个函数。我们的脚本首先调用 $ 并传递一个回调,jQuery 将在页面加载完成后执行这个回调。我们的其他 $ 调用传递一个字符串或一个 DOM 元素。字符串被解释为选择元素的 CSS 选择器。在两种情况下,$ 都返回一组 DOM 元素的包装,并带有一些有用的方法,例如:

  • data 方法允许我们读取元素的 data- 属性

  • append 方法允许我们添加新的子元素

  • keydown 等方法允许我们为事件绑定处理函数

$ 对象本身还定义了一些实用方法。这些更像是静态方法,与特定的 DOM 元素无关。post() 方法就是这样的一个例子。

我们的脚本使用 jQuery 的post()方法发出 Ajax POST 请求。这返回一个具有done()方法的对象,我们可以向其中传递一个回调,当请求完成时执行。在这里,我们可以利用我们的 API 返回的 JSON 数据。在这种情况下,我们填充任何与我们的猜测字母匹配的位置。

如果我们现在运行应用程序,我们将有一个(非常)最小的工作游戏。首先,访问http://localhost:3000/并提交一个有效的单词来创建一个新游戏。然后访问http://localhost:3000/games/1来玩游戏。它应该看起来像以下内容:

通过 Ajax 进行通信

实现其他数据操作

到目前为止,我们已经看到了如何创建或检索单个游戏,或者为游戏提交单个猜测。应用程序通常还需要列出数据或删除条目。这里的原理与我们之前看到的是一样的。但是,为了实现这些操作,我们需要一些新的语法。

在视图中列出数据

让我们首先允许用户查看他们创建或他人创建的游戏列表。我们选择的视图引擎 Hogan 基于 Mustache,它有一个非常简单的语法来显示列表。我们可以在index.hjs视图中添加这两个列表,如下所示:

    <h2>Games created by you</h2>
    <ul id="createdGames">
      {{#createdGames}}
        <li>{{word}}</li>
      {{/createdGames}}
    </ul>
    <h2>Games available to play</h2>
    <ul id="availableGames">
      {{#availableGames}}
        <li><a href="/games/{{id}}">#{{id}}</a></li>
      {{/availableGames}}
    </ul>

为了填充这些列表,我们需要在我们的games.js服务模块中添加几个新方法:

module.exports.createdBy =
  (userId) => games.filter(game => game.setBy === userId);

module.exports.availableTo =
  (userId) => games.filter(game => game.setBy !== userId);

最后,我们需要从我们的路由中公开这些内容到我们的首页:

var express = require('express');
var router = express.Router();
var games = require('../services/games');

router.get('/', function(req, res, next) {
  res.render('index', {
    title: 'Hangman',
    userId: req.user.id,
 createdGames: games.createdBy(req.user.id),
 availableGames: games.availableTo(req.user.id)
  });
});

module.exports = router;

现在,我们的首页显示了当前用户创建的游戏,并为其他用户创建的游戏提供了方便的链接。您可以通过再次使用两个独立的浏览器会话来访问http://localhost:3000来实验这个功能。结果应该类似于以下内容:

在视图中列出数据

从客户端发出删除请求

为了允许用户删除他们创建的游戏,我们首先需要在我们的 Game 类中添加一个方法:

class Game {
    constructor(id, setBy, word) {
        this.id = id;
        this.setBy = setBy;
        this.word = word.toUpperCase();
    }

    positionsOf(character) {
        let positions = [];
        for (let i in this.word) {
            if (this.word[i] === character.toUpperCase()) {
                positions.push(i);
            }
        }
        return positions;
    }

 remove() {
 games.splice(games.indexOf(this), 1);
 }
}

接下来,我们可以在我们的游戏路由中创建一个新的delete请求处理程序:

router.delete('/:id', function(req, res, next) {
    checkGameExists(
        req.params.id,
        res,
        game => {
            if (game.setBy === req.user.id) {
                game.remove();
                res.send();
            } else {
                res.status(403).send(
                    'You don't have permission to delete this game'
                );
            }
        }
    );
});

最后,我们可以从客户端使用这个功能。以下代码来自views/index.hjs

  <head>
    <title>{{ title }}</title>
    <link rel="stylesheet" href="/stylesheets/style.css" />
 <script src="img/jquery.min.js"></script>
 <script src="img/index.js"></script>
  </head>

  ...

      {{#createdGames}}
 <li class="game">
 {{word}}
 <a class="delete" href="/games/{{id}}">(delete)</a>
 </li>
      {{/createdGames}}

我们在public/scripts/index.js下添加以下代码:

$(function() {
    'use strict';

    $('#createdGames').on('click', '.delete', function() {
        var $this = $(this);
        $.ajax($this.attr('href'), {
            method: 'delete'
        }).done(function() {
            $this.closest('.game').remove();
        });
        event.preventDefault();
    });
});

注意,与 GET 和 POST 不同,jQuery 没有为delete请求提供便利函数。因此,我们退回到较低级别的.ajax()函数,并明确指定 HTTP 方法。

如果您在浏览器中访问应用程序并再次创建一个新游戏,您现在应该看到一个删除游戏的链接。

使用部分拆分 Express 视图

删除游戏不会使页面刷新,但创建新游戏会。我们可以通过通过 Ajax 调用创建游戏来修复这个问题,这与我们删除游戏的方式一致。为了使这生效,处理调用的客户端脚本需要知道在创建新游戏时应该添加到页面的 HTML。

我们可以在客户端 JavaScript 中重复视图的 HTML 结构。然而,让服务器返回正确的 HTML 片段,并重用与最初在页面上渲染列表时相同的模板会更好。

我们可以通过将游戏列表中的 HTML 结构拆分为部分视图来实现这一点。这是一个 HTML 片段的视图模板,而不是完整的页面。我们在views/createdGame.hjs下添加以下代码:

<li class="game">
  {{word}}
  <a class="delete" href="/games/{{id}}">(delete)</a>
</li>

使用我们正在使用的视图引擎(Hogan),在渲染视图时我们需要让视图知道可用的部分(其他视图引擎允许自动解析部分)。以下代码来自routes/index.js

  res.render('index', {
    title: 'Hangman',
    userId: req.user.id,
    createdGames: games.createdBy(req.user.id),
    availableGames: games.availableTo(req.user.id),
 partials: { createdGame: 'createdGame' }
  });

我们可以在主视图中使用部分,我们还将为我们的 HTML 元素添加 ID,我们将在稍后从客户端 JavaScript 中引用它们。以下代码来自views/index.hjs

 <form action="/games" method="POST" id="createGame">
 <input type="text" name="word" id="word"
             placeholder="Enter a word to guess..." />
      <input type="submit" />    </form>
    <h2>Games created by you</h2>  
    <ul id="createdGames">
      {{#createdGames}}
 {{> createdGame}}
      {{/createdGames}}
    </ul>

现在,我们可以更新我们的游戏路由,在创建新游戏时只向客户端返回这个片段。以下代码来自routes/games.js

router.post('/', function(req, res, next) {
    let word = req.body.word;
    if (word && /^[A-Za-z]{3,}$/.test(word)) {
        const game = service.create(req.user.id, word); 
 res.redirect(`/games/${game.id}/created`);
    } else {
        ...
    }
});
...
router.get('/:id/created', function(req, res, next) {
 checkGameExists(
 req.params.id,
 res,
 game => res.render('createdGame', game));
});

最后,我们可以在客户端脚本中使用它。以下代码来自public/scripts/index.js

$(function() {
  'use strict';    
 $('#createGame').submit(function(event) {
 $.post($(this).attr('action'), { word: $('#word').val() },
 function(result) {
 $('#createdGames').append(result);
 });
 event.preventDefault();
 });
  ...
});

摘要

在本章中,我们通过创建新的中间件和服务模块开始构建自己的应用程序。我们从表单中读取用户提交的数据并对其进行了处理。我们在服务器端实现了 JSON API,并使用 Ajax 从客户端与其通信。我们使用了部分视图来渲染常见组件。

到目前为止,我们已经了解了如何编写 JavaScript 代码并在 Node.js 中实现各种功能。这对于原型设计来说很好,但对于可维护的项目来说还不够。编写自动化测试来测试我们的代码也同样重要,这是下一章的主题。

第六章。测试 Node.js 应用程序

到目前为止,我们只是通过手动执行来测试我们的代码。随着应用程序的增大,这不是一个可持续的方法。理想情况下,我们应该定期测试我们应用程序的所有功能以检查回归。如果我们继续仅使用手动测试,这将迅速变得耗时且难以承受。维护一系列自动化测试要有效得多。这些测试还带来了许多其他好处,例如,作为我们代码的其他开发者的文档。

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

  • 为我们的应用程序编写自动单元测试

  • 介绍新的库来帮助我们编写更详细的测试

  • 了解如何在 JavaScript 中创建和使用测试替身

  • 使用 HTTP 客户端测试来锻炼我们的应用程序的 Web 界面

  • 使用浏览器自动化添加全栈集成测试

  • 在扩展我们的代码库时,为编写进一步的测试建立结构

在 Node.js 中编写一个简单的测试

Node.js 内置了一个名为 assert 的模块,可用于测试。我们可以使用它为我们在 第五章 中编写的游戏服务编写一个简单的测试,即 Building Dynamic Websites。我们在 gameServiceTest.js 下添加以下代码:

'use strict';

let assert = require('assert');
let service = require('./services/games.js')

// Given
service.create('firstUserId', 'testing');

// When
let games = service.availableTo('secondUserId');

// Then            
assert.equal(games.length, 1);            
let game = games[0];
assert.equal(game.setBy, 'firstUserId');
assert.equal(game.word, 'TESTING');

注意,assert.equal 函数将实际值作为第一个参数,将预期值作为第二个参数。这与 JUnit 的内置 Assert.Equals 和 NUnit 的经典样式 Assert.AreEqual 方向相反。正确地处理这些参数很重要,这样当断言失败时,它们会在错误消息中正确显示。

小贴士

Given, When, Then

在前面的测试中的 GivenWhenThen 注释并不特定于 JavaScript 或我们将要使用的任何测试框架,但它们通常是一个很好的工具,用于构建测试以保持其专注和可读性。

我们现在可以使用以下命令验证我们的代码:

> node gameServiceTest.js
> echo %errorlevel%

退出代码为 0 表示我们的测试成功完成,没有任何错误。尽管我们还没有遵循测试驱动开发(先编写一个失败的测试,然后再添加任何新代码),但仍然很重要,要看到每个测试失败以确认它在测试某些内容。尝试修改 services/games.js 中的 availableTo 函数以返回一个空数组,然后再次运行测试。

不仅我们现在得到了一个非零的退出代码,我们还得到了一个包含我们的断言失败错误的消息。尽管如此,我们的测试输出仍然并不特别吸引人。此外,我们测试脚本中的结构缺失将使得随着测试的增加而难以导航。我们可以通过使用 JavaScript 可用的测试库之一来解决这两个问题。

为测试构建代码库的结构

随着我们为应用程序编写更多的测试,我们将从测试的结构化中受益。通常,每个生产模块至少有一个测试文件。同时运行所有测试并查看整体结果也将很有用。

我们将开始在 test 目录下添加测试。从本书的这一部分开始,我们也将把所有应用程序代码放在一个 src 目录下。这将使导航我们的代码库和保持生产代码与测试代码分离变得更加容易。

如果你在这一部分跟随本书,你应该将 app.js 和所有文件夹(除了 bin 文件夹)移动到一个新的 src 目录下,并在 bin/www 中更新启动脚本如下:

var app = require('../src/app');
var debug = require('debug')('hangman:server');
var http = require('http');

使用 Mocha 编写 BDD 风格的测试

从 C# 或 Java,你可能最熟悉 NUnit、JUnit 等使用的 xUnit 风格的测试。这种风格将测试结构化为类,并将方法名称转换为测试名称。这可能有点限制性,在 JavaScript 测试中并不常见。JavaScript 测试框架利用语言更不结构化和更动态的特性,以提供更大的灵活性。

在 JavaScript 中编写测试有几种不同的风格。最常见的是所谓的行为驱动开发BDD)风格,其中我们用普通的英语描述我们应用程序的行为。这是最受欢迎的 JavaScript 测试框架的默认风格。在其他编程平台的框架中也很常见,尤其是 Ruby 的 RSpec。

我们将使用一个流行的测试框架,名为 Mocha。首先,让我们将其添加到我们的应用程序中:

> npm install mocha --save-dev

注意,--save-dev 将 Mocha 添加到我们的 package.json 文件中作为开发依赖项。这表示它不需要在我们的生产代码中,并且 npm 不需要在生产环境中安装它。我们还将更新此文件,让 npm 使用 Mocha 运行我们的测试,如下添加测试脚本:

  "scripts": {
    "start": "node ./bin/www",
 "test": "node node_modules/mocha/bin/mocha test/**/*.js"
  },

这告诉 npm 当我们从命令行运行 npm test 时,使用 Mocha 执行 /test/ 目录下的脚本作为测试。

注意

Mocha 和 Jasmine

可用于 JavaScript 的测试框架有很多。最著名的是 Jasmine 和 Mocha。它们具有相似的功能,并且都支持相同的测试编写语法。它们都有很好的文档,两者之间的切换也很容易。

Jasmine 最初旨在测试浏览器中的客户端 JavaScript。Mocha 最初更多地关注测试服务器端 Node.js 代码。

现在,这两个框架都适合任何环境。Jasmine 还包含更多的“电池”,这可以使开始使用它更快。Mocha 将更多功能委托给其他库,使用户能够有更多选择,关于他们更喜欢如何编写测试。

现在我们只需要添加一些测试!Mocha 提供了名为describeit的全局函数来结构化我们的测试。这些函数各自接受两个参数:一个描述我们应用程序行为的字符串和一个定义该行为的回调函数。以下代码片段展示了使用 Mocha 重写的我们之前的测试。我们在test/services/games.js下添加以下代码:

'use strict';

const assert = require('assert');
const service = require('../../src/services/games.js');

describe('Game service', () => {
    const firstUserId = 'user-id-1';
    const secondUserId = 'user-id-2';

    describe('list of available games', () => { 
        it('should include games set by other users', () => {
            // Given
            service.create(firstUserId, 'testing');

            // When
            const games = service.availableTo(secondUserId);

            // Then
            assert.equal(games.length, 1);
            const game = games[0];
            assert.equal(game.setBy, firstUserId);
            assert.equal(game.word, 'TESTING');
        });
    });
});

现在尝试使用npm test运行之前的测试。你应该会看到如下输出(确切的外观取决于你使用的控制台):

使用 Mocha 编写 BDD 风格的测试

注意我们如何得到一个更详细的测试输出。同时注意我们在测试中使用嵌套的 describe 回调来构建应用程序的描述。随着我们添加更多的测试,这个优势变得更加明显。尝试在第一个测试之后添加以下测试:

    it('should not include games set by the same user', () => {
        // Given
        service.create(firstUserId, 'first');
        service.create(secondUserId, 'second');

        // When
        const games = service.availableTo(secondUserId);

        // Then
        assert.equal(games.length, 1);
        const game = games[0];
        assert.notEqual(game.setBy, secondUserId);
    });

再次使用npm test运行测试。这次,我们从 Mocha 那里得到了一个测试失败:

使用 Mocha 编写 BDD 风格的测试

测试之间的状态重置

我们的第二个测试失败了,因为它从服务中检索了两个游戏。但这并不是因为我们的生产代码未能正确过滤游戏。实际上,有两个是由第一个用户创建的游戏。其中一个在之前的测试中被保留了下来。

对于测试来说,它们之间独立且相互隔离是很重要的。为此,我们需要在测试之间清理任何状态。在这种情况下,我们想要删除我们创建的所有游戏。游戏服务没有提供清除所有游戏的方法。我们只能在检索到它们之后逐个删除游戏。这里有几个可供我们选择的方法:

  • 我们可以在每个测试期间跟踪我们创建的所有游戏,并在结束时删除它们。这似乎是最明显的解决方案,但它有点脆弱。可能会错过一个游戏,这可能导致后续的测试失败变得令人困惑。

  • 我们可以将游戏服务模块重写为导出一个创建新服务函数,并为每个测试实例化一个新的服务。一般来说,尝试在每个测试下创建新的对象以隔离测试是一个好主意。然而,这只有在对象不存储任何外部状态时才有用。我们可能会在以后更改游戏服务的实现,以便将数据存储在外部持久数据存储中。

  • 我们可以在游戏服务中添加一个清除方法来清除所有数据。为了支持测试而创建这样的方法并没有错。然而,如果可能的话,最好通过应用程序现有的 API 与之交互。

游戏服务确实提供了一种检索所有当前游戏的方法。我们只需要传入一个与任何游戏设置者不匹配的用户 ID。然后我们可以遍历并删除所有游戏。我们希望在每次测试之前做这件事,可以使用 Mocha 的beforeEach钩子:

describe('Game service', () => {
    const firstUserId = 'user-id-1';
    const secondUserId = 'user-id-2';

 beforeEach(() => {
 let gamesCreated = service.availableTo("not-a-user");
 gamesCreated.forEach(game => game.remove());
 });

    describe('list of available games', () => {    

如果我们重新运行我们的测试,它们现在都正确通过。Mocha 还有一个afterEach钩子,我们可以用它来代替。这会起作用,但测试通过先清理来保护自己比依赖其他测试来清理自己更安全。

使用 Chai 进行断言

使我们的测试更具描述性的另一种方法是编写我们的断言。尽管内置的 Node.js 断言模块到目前为止很有用,但它有点有限。它只包含少量简单方法来进行基本断言。

你可能对 Fluent Assertions 或.NET 的 NUnit 约束模型,或者 Java 的 AssertJ 有所了解。与这些相比,Node.js 断言模块可能看起来相当原始。

有几个断言框架可用于 JavaScript。我们将使用 Chai (chaijs.com),它支持编写断言的三个不同风格。assert风格遵循传统的 xUnit 断言,如 JUnit 或 NUnit 的经典模型。shouldexpect风格提供了自然语言界面来构建更具描述性的断言。

这些任何风格都是编写测试断言的有效选择。重要的是为你的代码库选择一种风格并始终如一地使用它。我们将在这本书中使用 Chai 的expect语法。这是 JavaScript 测试中更常见的风格之一。Jasmine 测试框架内置了遵循类似风格的断言。

让我们首先通过在命令行运行以下命令来安装 Chai:

> npm install chai --save-dev

然后更新我们的测试以使用它:

const expect = require('chai').expect;
const service = require('../../src/services/games.js');

...

    it('should include games created by other users', () => {
        // Given
        service.create(firstUserId, 'testing');

        // When
        const games = service.availableTo(secondUserId);

        // Then
 expect(games.length).to.equal(1);
        const game = games[0];
 expect(game.setBy).to.equal(firstUserId);
 expect(game.word).to.equal('TESTING');
    });

    it('should not include games created by the same user', () => {
        // Given
        service.create(firstUserId, 'first');
        service.create(secondUserId, 'second');

        // When
        const games = service.availableTo(secondUserId);

        // Then
 expect(games.length).to.equal(1);
        let game = games[0];
 expect(game.setBy).not.to.equal(secondUserId);
    });

由于我们目前只进行简单的断言,所以这个变化并不特别引人注目。但自然语言界面将允许我们以描述性的方式指定更详细的断言。

创建测试替身

我们可以为游戏服务编写更多的测试,但现在让我们看看不同的模块。我们如何测试我们的users中间件呢?以下代码来自middleware/users.js

module.exports = function(req, res, next) {
    let userId = req.cookies.userId;
    if (!userId) {
        userId = uuid.v4();
        res.cookie('userId', userId);
    }
    req.user = {
        id: userId
    };
    next();
};

为了测试这个类,我们需要为与我们的代码交互的reqresnext参数传递参数。我们没有真实的请求、响应或中间件管道可用,因此我们需要创建一些替代值。这样的替代值通常被称为测试替身。我们的代码从请求中读取一个属性,并在响应上调用 cookie 方法。我们可以在test/middleware/users.js下的新测试脚本中创建这些测试替身,如下所示:

'use strict';

const middleware = require('../../middleware/users.js');
const expect = require('chai').expect;

describe('Users middleware', () => {    
    const defaultUserId = 'user-id-1';
    let request, response;

    beforeEach(() => {
        request = { cookies: {} };
        response = { cookie: () => {} };
    });

    it('if the user already signed in, reads their ID from a cookie and exposes the user on the request', () => {
        // Given
        request.cookies.userId = defaultUserId;

        // When
        middleware(request, response, () => {});

        // Then
        expect(request.user).to.exist;
        expect(request.user.id).to.equal(defaultUserId);
    }); 
});

在这里,我们仅仅创建一个普通的 JavaScript 对象来表示请求。这允许我们验证生产代码是否正确地从请求属性中读取和写入。我们只传递响应对象的最小可能输入和 next 函数,以允许代码执行。这在 JavaScript 中非常容易做到,部分原因是因为它不是静态类型。在 C# 或 Java 中创建这样的测试替身可能会更加费力,因为编译器会坚持测试替身与相应的参数类型匹配。

我们还需要测试我们的中间件是否调用了链中的下一个中间件,因为这是一个重要的行为。这比仅仅创建一个具有简单属性的对象要复杂一些。我们仍然可以通过定义一个新的函数来创建一个合适的测试替身,该函数记录何时被调用(这种类型的测试替身被称为 间谍):

    it('calls the next middleware in the chain', () => {
        // Given
        let calledNext = false;
        const next = () => calledNext = true;

        // When
        middleware(request, response, next);

        // Then
        expect(calledNext).to.be.true;
    });

这工作得非常好,但如果我们要测试更复杂的调用,例如,如果我们想检查多个调用或对传入的参数进行进一步的断言,将会变得比较繁琐。我们可以通过使用一个框架来为我们创建测试替身来简化这个过程。

使用 Sinon.JS 创建测试替身

Sinon.JS 是一个用于创建各种测试替身的框架。让我们首先通过在命令行运行以下命令将其安装到我们的应用程序中:

> npm install sinon --save-dev

现在,让我们简化之前的测试,并使用 Sinon.JS 创建的测试替身编写一个更复杂的测试:

const expect = require('chai').expect;
const sinon = require('sinon');

...

    it('calls the next middleware in the chain', () => {
          // Given
 const next = sinon.spy();

        // When
        middleware(request, {}, next);

        // Then
        expect(next.called).to.be.true;
    });

 it('if the user is not already signed in, ' +
 'creates a new user id and stores it in a cookie', () => {
 // Given
 request.cookies.userId = undefined;
 response = { cookie: sinon.spy() };

 // When
 middleware(request, response, () => {});

 // Then
 expect(request.user).to.exist;
 const newUserId = request.user.id;
 expect(newUserId).to.exist;
 expect(response.cookie.calledWith(
 'userId', newUserId)).to.be.true;
    });

Sinon.JS 间谍会跟踪所有对其发出的调用的详细信息,并提供一个方便的 API 来检查这些信息。这使得我们能够保持测试代码简单易读。除了 calledcalledWith 用户属性之外,还有许多其他属性。请查看 Sinon.JS 文档 sinonjs.org/docs/#spies-api,以了解我们可以验证间谍所发出的调用的其他方式。

注意

间谍、存根和模拟

如果你阅读更多 Sinon.JS 的文档,你会看到它非常明确地说明了间谍、存根和模拟之间的区别。这与 Java 和 .NET 中大多数流行的测试替身框架形成对比,这些框架倾向于用相同的名称(通常是模拟或伪造)来调用所有的测试替身。然而,在现实中,大多数测试替身实例通常只充当间谍(用于验证副作用)或存根(用于提供数据或抛出异常以测试错误处理)。真正的模拟会验证特定的调用序列,并返回特定的数据给被测试的代码。尽管 Java 和 .NET 中的一些早期模拟框架只支持这种类型的测试替身(现在有时称为 严格模拟),但这不再是常见的做法。这是因为它非常紧密地将测试和生产代码耦合在一起,使得重构变得更加困难。在一个单独的测试中,拥有多个模拟(而不是仅仅一个存根或间谍)的情况尤其罕见。

测试 Express 应用程序

虽然使用 Sinon.JS 可以使我们的测试更整洁,但它们仍然依赖于 Express 中间件 API 的细节以及我们如何使用它。这可能适合我们的中间件模块,因为我们想确保它满足特定的契约(特别是调用next和设置request.user)。但对于大多数中间件来说,尤其是我们的路由,这种方法可能会使我们的测试过于紧密地耦合到我们的实现。

通过向它发送 HTTP 请求并检查响应来测试每个路由的实际行为,而不是检查与请求和响应对象的特定低级交互,会更好。这使我们能够更灵活地更改我们的实现和重构我们的代码,而无需更改测试。因此,我们的测试可以支持这个过程(通过捕获回归),而不是阻碍它(需要更新以匹配我们的实现)。

在其他平台上,测试整个应用程序可能是一个相当重量级的流程。例如,在 Java 中使用 Jetty 或在.NET 中使用 Katana,可以在进程中启动服务器。较新的应用程序框架,如 Spring Boot 或 NancyFx,也使这个过程更容易。尽管如此,这些测试仍然可能是相对缓慢和资源密集型的。

在 Node.js 中,启动应用程序服务器既简单又轻量级。我们只是使用之前看到的相同的http.createServer调用,并传递一个应用程序。为了单独测试我们的路由,我们将启动一个新的应用程序,其中只包含这个路由。让我们看看我们如何使用它来测试游戏路由的删除端点。我们在test/routes/games.js下添加以下代码:

'use strict';

const http = require('http');
const express = require('express');
const bodyParser = require('body-parser');
const expect = require('chai').expect;
const gamesService = require('../../src/services/games.js');

const TEST_PORT = 5000, userId = 'test-user-id';

describe('/games', () => {
  let server;
  const makeRequest = (method, path, callback) => {
    http.request({
      method: method,
      port: TEST_PORT,
      path: path
    }, callback).end();
  };

  before(done => {
    const app = express();
    app.use(bodyParser.json());
    app.use((req, res, next) => {
      req.user = { id: userId }; next();
    });

    const games = require('../../src/routes/games.js');
    app.use('/games', games);

    server = http.createServer(app).listen(TEST_PORT, done);
  });

  afterEach(() => {
    const gamesCreated = gamesService.availableTo("non-user");
    gamesCreated.forEach(game => game.remove());
  });

  after(done => {
    server.close(done);
  });

  describe('/:id DELETE', () => {
    it('should allow users to delete their own games', done => {
      const game = gamesService.create(userId, 'test');

      makeRequest('DELETE', '/games/' + game.id, response => {
        expect(response.statusCode).to.equal(200);
        expect(gamesService.createdBy(userId)).to.be.empty;
        done();
      });
    });
  });
});

这可能看起来像是相当多的代码,但请记住,我们在这里启动的是一个完整的应用程序。此外,大部分的代码将会在多个测试中重复使用。让我们看看它具体做了什么。

before回调创建我们的服务器,正如我们在第二章 “Node.js 入门”中看到的,监听一个特殊端口供我们的测试使用。它还设置了一些存根中间件来模拟请求中的当前用户。afterEach回调清除创建的任何游戏(正如我们在游戏服务测试中之前看到的)。请注意,由于我们运行在同一个进程中,我们可以轻易地与我们的应用程序使用的相同数据层进行交互。最后,after函数请求服务器停止监听连接。

测试本身非常简单:我们只是创建一个当前用户(如我们之前的服务测试中所述)的游戏设置,然后发出一个删除它的请求。这使用了我们自己的makeRequest函数,该函数简单地调用 Node 的http.request。然后我们可以检查响应对象以查找适当的状态码,并检查服务以查看期望的效果。

小贴士

在 Mocha 中编写异步测试

注意到我们的测试以及上面讨论的所有对 Mocha 的钩子函数的回调(除了 afterEach)都接受一个 done 参数。这是因为所有这些测试都执行了一些异步操作。Mocha 使得编写异步测试或钩子变得非常简单:你只需让你的回调函数接受一个参数(按照惯例称为 done),并在处理完成后调用它。如果在超时时间内(默认为 2 秒,但可以更改)没有调用,那么 Mocha 会判定测试失败。

让我们再次使用 npm test 命令运行我们的测试。注意,尽管我们正在启动整个服务器端应用程序,但所有测试仍然完成得非常快(在我的机器上为数十毫秒)。你可能还会注意到输出有些混乱,这是由于服务器日志输出造成的。我们可以通过以下方式轻松地抑制这种输出:更新 app.js 如下:

//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
if (app.get('env') === 'development') {
 app.use(logger('dev'));
}
app.use(bodyParser.json());

Express 应用程序的 'env' 属性来自 NODE_ENV 环境变量(如果不存在,则默认为开发环境)。这对于区分生产环境和开发环境非常有用。由于它默认为 development,因此我们还需要将其设置为其他值,以便在我们的测试中抑制此日志记录。我们可以通过以下方式更新我们的测试脚本 package.json

  "scripts": {
    "start": "node ./bin/www",
 "test": "set NODE_ENV=test && node node_modules/mocha/bin/mocha test/**/*.js"
  },

使用 SuperAgent 简化测试

虽然我们的测试速度快,设置服务器也很直接,但我们确实有很多用于向服务器发送请求和处理响应的代码。如果我们需要制作更多种类的请求,或者对响应状态码或头信息以外的内容感兴趣,这将会变得更加复杂。

我们可以通过使用提供更简单服务器通信 API 的库来简化我们的测试。SuperAgent (visionmedia.github.io/superagent/) 是一个 JavaScript 库,它提供了一种流畅、易读的语法来制作 HTTP 请求。这可以用于浏览器中的 Ajax 请求,或者像我们在这里所做的那样在 Node.js 应用程序中进行请求。

我们将使用一个名为 SuperTest 的轻量级包装器通过 SuperAgent 来利用它,这使得测试基于 Node.js 的 HTTP 应用程序变得更加方便。

首先,我们使用 npm 将 SuperTest 添加到我们的应用程序中,通过在命令行运行以下命令:

> npm install supertest --save-dev

现在,我们可以将我们的测试重写如下:

'use strict';

const express = require('express');
const bodyParser = require('body-parser');
const request = require('supertest');
const expect = require('chai').expect;
const gamesService = require('../../src/services/games.js');

const userId = 'test-user-id';

describe('/games', () => {
 let agent, app;

  before(() => {
    app = express();
    app.use(bodyParser.json());
    app.use((req, res, next) => {
      req.user = { id: userId }; next();
    });

    const games = require('../../src/routes/games.js');
    app.use('/games', games);
  });

 beforeEach(() => {
 agent = request.agent(app);
 });

  describe('/:id DELETE', () => {
    it('should allow users to delete their own games', done => {
 const game = gamesService.create(userId, 'test');

 agent
 .delete('/games/' + game.id)
 .expect(200)
 .expect(() =>
 expect(gamesService.createdBy(userId)).to.be.empty)
 .end(done);
    });
  });
});

SuperTest 和 SuperAgent 负责启动我们的应用程序服务器,并提供一个更简单的 API 来制作请求。注意使用了一个请求 代理,它代表一个单独的浏览器会话。

SuperAgent 提供了多个函数(getpostdelete 等)用于发起 HTTP 请求。这些可以通过对 expect 函数的调用进行链式调用(不要与 Chai 的 expect 混淆)来验证响应的属性,例如状态码。我们还可以传递一个回调来对响应进行特定检查,或验证副作用(就像我们在前面的例子中所做的那样)。

注意,始终调用 end 函数以确保抛出任何期望错误并使测试失败是很重要的。当请求完成时,我们可以传递 Mocha 的 done 回调来结束测试。

现在我们已经简化了测试代码,我们可以轻松地为我们的路由添加更多测试。例如,让我们添加一些测试来覆盖删除端点的负面情况:

    it('should not allow users to delete games that they did not set', done => {
      const game = gamesService.create('another-user-id', 'test');
      agent
        .delete('/games/' + game.id)
        .expect(403)
        .expect(() => expect(gamesService.get(game.id).ok))
        .end(done);
    });

    it('should return a 404 for requests to delete a game that no longer exists', done => {
      const game = gamesService.create(userId, 'test');
      agent
        .delete(`/games/${game.id}`)
        .expect(200)
        .end(function(err) {
          if (err) {
            done(err);
          } else {
            agent
              .delete('/games/' + game.id)
              .expect(404, done);
          }
        });
    });

使用 PhantomJS 进行全栈测试

我们现在已经为应用程序核心的逻辑编写了单元测试,并为服务器端路由编写了集成测试。我们还没有任何自动化测试覆盖我们的视图和客户端脚本,正如我们在前几章中的手动测试所做的那样。

我们可以使用 Mocha 为客户端脚本编写单元测试。然而,我们当前的所有客户端脚本都与服务器交互,因此不是单元测试的理想候选者。我们的手动测试实际上是整个应用程序的全栈测试,包括服务器和客户端之间的交互。

为了在自动化测试中实现这一点,我们需要使用某种形式的浏览器自动化。PhantomJS 是一个具有 JavaScript API 的无头浏览器,它允许我们直接自动化它。我们可以使用这个 API 为我们的游戏页面编写一个简单的测试。

首先,我们将在命令行中运行以下命令,在我们的项目中安装 PhantomJS:

> npm install phantomjs-prebuilt --save-dev

注意

PhantomJS 不是一个 Node.js 模块。它是一个独立的、无头浏览器。npm 模块只是安装它的方便方式,并使其成为项目的依赖项。PhantomJS 不能从 Node.js 中调用,除非作为单独的子进程执行。

现在,我们可以在 integration-test/game.js 下实现以下测试:

(function() {
    'use strict';

    var expect = require('chai').expect;
    var page = require('webpage').create();
    var rootUrl = 'http://localhost:3000';

    withGame('Example', function() {
        expect(getText('#word')).to.equal('_______');

        page.evaluate(function() {
            $(document).ajaxComplete(window.callPhantom);
        });

        page.sendEvent('keydown', page.event.key.E);
        page.onCallback = verify(function() {
            expect(getText('#word')).to.equal('E_____E');
            expect(getText('#missedLetters')).to.be.empty;

            page.sendEvent('keydown', page.event.key.T);
            page.onCallback = verify(function() {
                expect(getText('#word')).to.equal('E_____E');
                expect(getText('#missedLetters')).to.equal('T');

                console.log('Test completed successfully!');
                phantom.exit();
            });
        });
    });

    function withGame(word, callback) {
        ...
    }

    function getText(selector) {
        return page.evaluate(function(s) {
            return $(s).text();
        }, selector);
    }

    function verify(expectations) {
        return function() { 
            try {
                expectations();
            } catch(e) {
                console.log('Test failed!');
                handleError(e.message);
            }
        }
    }

    function handleError(message) {
        console.log(message);
        phantom.exit(1);
    }

    phantom.onError = page.onError = handleError;
}());

确保应用程序正在运行(使用 npm start),然后在命令行中运行以下命令来执行测试:

> node node_modules/phantomjs-prebuilt/bin/phantomjs integration-test/game.js

让我们看一下代码,了解它是如何工作的。注意,我们在这里是在浏览器环境中运行,而不是 Node.js,因此回退到 ECMAScript 5 语法(例如,使用 var 而不是 let,以及没有箭头函数)。

被省略的 withGame 方法(你可以在书的配套代码中找到)使用 PhantomJS 加载索引视图并提交一个新的游戏,然后清除 PhantomJS 的 Cookie 并以新用户身份打开游戏,在调用传递给 withGame 的回调之前。

在我们的测试中,我们创建了一个猜单词 example 的游戏,然后在页面中调用 JavaScript 来对其内容进行断言。getText 函数使用 PhantomJS 的 page.evaluate 函数在页面上下文中运行一些 JavaScript,并返回一个值。请注意,传递给 page.evaluate 的回调函数没有访问我们脚本更广泛的执行上下文。然而,我们可以为 page.evaluate 调用指定额外的参数,这就是我们如何传递 jQuery 选择器的方式。

然后,我们再次使用 page.evaluate 来设置一个回调,每次 Ajax 请求完成时都会调用。在这里,我们使用 window.callPhantom,它在页面上下文中执行,并触发 page.onCallback,它在我们的测试上下文中执行。

最后,我们使用 page.sendEvent 来在浏览器中触发一个键盘事件。请注意,这不同于在浏览器中使用纯 JavaScript 触发 DOM 事件,而是直接向 PhantomJS 发送指令来模拟 keypress 事件,就像它来自用户一样。

如果我们将所有这些放在一起,我们得到以下结果:

  • 我们使用 page.sendEvent 来模拟按下一个键盘键

  • 这导致我们的生产代码发送一个 Ajax 请求

  • 当这个请求完成时,在浏览器上下文中调用 window.callPhantom

  • 这导致 PhantomJS 调用我们的 page.onCallback 函数

  • 然后,我们在 page.evaluate 中使用 jQuery(通过 getText)来从页面检索值

文件剩余的内容(verifyhandleError)确保 PhantomJS 将所有错误写入控制台,并在失败的情况下设置适当的退出代码。

摘要

在本章中,我们学习了如何在 Node.js 中编写单元测试,使用 Mocha 和 Chai 编写更详细的测试,使用 Sinon.JS 创建测试双胞胎,使用 SuperAgent 和 SuperTest 编写应用级别的测试,并在 PhantomJS 中实现全栈测试。

虽然我们现在已经在应用的每一层都有测试,但我们还没有涵盖所有的代码。找到我们应该编写更多测试的任何差距将是有用的。我们还需要调用几个不同的命令来运行所有的单元和集成测试。在下一章中,我们将看到如何将这些和其他过程自动化,作为持续集成构建的一部分。

第七章. 设置自动化构建

在上一章中,我们通过开始编写自动化测试,从演示应用程序到可维护的代码库迈出了重要的一步。现实世界软件项目的另一个重要组成部分是构建自动化。

自动化构建允许整个团队以一致的方式在项目上工作。执行常见任务的标准方式使得新开发者更容易开始。它还避免了开发者因各种原因得到不同结果而引起的烦恼问题。

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

  • 配置集成服务器以自动构建和运行我们的测试

  • 设置自动化任务运行器以简化测试的执行

  • 自动化更多任务以帮助维护编码标准和测试覆盖率

设置集成服务器

构建和测试自动化允许通过集成服务器验证代码更改,这是一个独立于个人开发者机器的自动化服务器。这有助于通过早期捕获错误或回归来保持项目稳定。集成服务器可以自动提醒引入问题的开发者。然后,他们有机会在问题对整个团队或项目造成影响之前修复问题。

在每次提交时自动构建代码库和运行测试被称为持续集成CI)。有许多 CI/构建服务器可用。这些可以是自托管的,也可以作为第三方服务提供。你可能之前使用过的例子包括 Jenkins(以前称为 Hudson)、Atlassian Bamboo、JetBrains TeamCity 和微软的 Team Foundation Server。

我们将使用 Travis CI (travis-ci.org/),这是一个托管服务,用于运行自动化构建。对于公共源代码仓库,它是免费的。为了使用 Travis CI 的免费服务,我们需要将我们的应用程序代码托管在公共 GitHub 仓库中。

设置公共 GitHub 仓库

如果你已经根据本书的内容熟悉了示例应用程序代码,并且已经熟悉 GitHub,你可以将你的代码推送到你自己的新 GitHub 仓库。否则,你可以从示例章节仓库中 fork 一个。

如果你想要跟随本章的变化,请使用github.com/NodeJsForDevelopers/chapter06/。这包含了第六章末尾的示例代码,我们将在此基础上构建。你可以使用 GitHub 上的Fork按钮创建这个仓库的自己的 fork。当你访问之前提到的 URL 时,它应该出现在屏幕的右上角:

设置公共 GitHub 仓库

这将在你的 GitHub 账户下创建一个新的仓库,以示例代码作为起点。

注意

这就是本章开始所需的所有内容。然而,如果你不熟悉 Git 和/或 GitHub 并想了解更多信息,你可以在help.github.com/找到更多信息。

在 Travis CI 上构建项目

现在,我们将设置在 Travis CI 上为我们应用程序构建。如果你在上一节中创建了您自己的公共仓库,你可以亲自尝试。访问travis-ci.org并使用 GitHub 登录。你应该会看到一个列出你的仓库的个人资料页面。启用你刚刚创建的仓库。

我们必须创建一个简单的config文件来告诉 Travis CI 在什么环境下构建我们的应用程序。按照以下方式在项目的根目录中创建一个文件(注意文件名中的前导点.travis.yml):

language: node_js
node_js:
 - 6
 - 4

这指示 TravisCI 使用当前稳定和长期支持版本的 Node.js(截至写作时)来构建我们的项目。如果你熟悉 Git,你可以在本地仓库的克隆版本中做出这个更改,提交并推送到 master。如果你是 Git 的新手,最简单创建此文件的方法是导航到你的仓库在github.com上,并点击新建文件按钮。这将打开一个基于网页的编辑器,你可以从中创建和提交文件。

一旦你将此文件添加到你的仓库中,再次访问travis-ci.org。现在你应该会看到你仓库的通过构建:

在 Travis CI 上构建项目

TravisCI 为我们指定的每个 Node.js 版本构建了我们的项目两次。如果你点击任何一个构建,你可以看到命令行输出。注意 TravisCI 自动使用标准的npm test命令运行了我们的测试。

使用 Gulp 自动化构建过程

很好,TravisCI 自动运行了我们的测试。但这不是我们想要自动化的唯一任务。当然,由于 JavaScript 是一种解释型语言,我们在构建过程中没有编译步骤。我们还想执行其他任务,例如检查我们的代码风格、运行集成测试和收集代码覆盖率。我们可以使用构建工具来自动化这些任务,并允许我们以一致的方式运行它们。你之前可能在使用.NET 时使用过 MSBuild,或者使用过 Java 工具,如 Maven 或 Gradle。

对于 Node.js,有几种不同的构建工具可用。最受欢迎的两个是 Grunt 和 Gulp。它们都有庞大的社区和广泛的功能插件,用于执行不同的操作。Grunt 的模型是每个操作读取文件并将其写回文件系统。Gulp 使用 Node.js 流将处理从一项操作传递到下一项操作。

Grunt 的模型稍微简单一些,可能更容易上手,特别是如果你有适度的构建需求。Gulp 的模型对于某些类型的任务来说可能更快,并且可以减少你需要编写的构建配置代码的数量。两者都是优秀的、受良好支持的构建工具。我们将使用 Gulp,但本章中我们做的所有事情也可以用 Grunt 实现。

使用 Gulp 运行测试

我们首先需要安装 Gulp,既要在全局范围内(将其添加到我们的路径中)也要在项目中安装。然后我们添加 Gulp 插件来控制 Mocha 和环境变量:

> npm install -g gulp-cli
> npm install gulp@~3.x --save-dev
> npm install gulp-mocha --save-dev
> npm install gulp-env --save-dev

现在我们为 Gulp 添加一个配置文件到我们的项目中。按照惯例,Gulp 会寻找一个名为 gulpfile.js 的文件:

'use strict';

const gulp = require('gulp');
const mocha = require('gulp-mocha');
const env = require('gulp-env');

gulp.task('test', function() {
  env({ vars: { NODE_ENV: 'test' } });
  return gulp.src('test/**/*.js')
    .pipe(mocha());
});

gulp.task('default', ['test']);

这创建了一个测试任务,并创建了一个空的默认任务来运行它。'default' 任务名称是特殊的,当我们在命令行中运行 gulp 时将会调用它。我们现在可以从 package.json 中删除我们的测试脚本,并更新我们的 .travis.yml 文件以运行 Gulp:

language: node_js
before_script:
 - npm install -g gulp
script: gulp
node_js:
 - 6
 - 4 

这还没有给我们带来太多好处。我们现在只是有一个稍微简短的命令来执行我们的测试。然而,随着我们添加更多自动化任务,使用构建工具将变得更加有价值。让我们看看我们可能希望将哪些其他过程纳入我们的构建中。

使用 ESLint 检查代码风格

虽然我们不需要编译器,但我们仍然可以从计算机对我们代码的静态分析中受益。代码检查工具在许多语言中很常见,用于发现可能导致微妙错误或混乱代码的常见编程错误。你可能熟悉 .NET 的 CodeRush、StyleCop 以及其他工具,或者 Java 的 CheckStyle、Findbugs、Sonar 以及其他工具。

我们将使用一个名为 ESLint 的 JavaScript/ECMAScript 代码检查工具。让我们首先全局安装它:

> npm install -g eslint

现在创建一个配置文件来告诉 ESLint 使用哪些规则,作为 .eslintrc.json

{
    "extends": "eslint:recommended",
    "env": {
        "node": true,
        "es6": true,
        "mocha": true,
        "browser": true,
        "jquery": true
	},
    "rules": {
        "semi": [2, "always"],
        "quotes": [2, "single"]
    }
}

在这里,我们告诉 ESLint 在我们的脚本中使用的环境中使用其标准推荐规则。我们还告诉它检查语句末尾的分号,并优先使用单引号。你可以按照以下方式运行 ESLint:

> eslint **/*.js

ESLint 输出它找到的所有错误,包括以下内容:

  • app.js 中的一个未使用的 favicon 本地变量

  • 在各种中间件函数中未使用的 next 参数

  • 在我们的 PhantomJS 集成测试中使用 console.log

  • 在我们的 PhantomJS 集成测试中使用 phantom 变量

这些问题中最简单的一个是:我们可以直接删除变量声明(这是由第二章的 express 应用程序模板为我们创建的,第二章,Node.js 入门)。我们也可以对中间件函数上的 next 参数做同样处理。然而,我更喜欢中间件函数有一个标准且易于识别的签名。而不是删除这个参数,我们可以告诉 ESLint 忽略这个特定的参数,如下所示:

    "rules": {
        "semi": [2, "always"],
        "quotes": [2, "single"],
 "no-unused-vars": [2, {"argsIgnorePattern": "next"}]
    }

最后两个项目符号都与我们的 PhantomJS 集成测试相关。这是一个相当特殊的文件,所以我们将特别更改 ESLint 对此文件的行为,使用注释指令。我们可以在有问题的文件 integration-test/game.js 的最顶部添加以下指令:

/*eslint-env phantomjs */
/*eslint-disable no-console */

这些指令中的第一个告诉 ESLint,此脚本文件将在 PhantomJS 环境中运行,其中 phantom 变量将为我们提供,因此 ESLint 不需要警告我们引用它。第二个指令禁用了 ESLint 对使用 console logging 的规则。

如果你再次运行 ESLint,你应该会发现之前列出的错误已经消失。任何剩余的错误应该是较小的问题,例如缺少分号或不一致的引号使用。这些问题应该很快就能手动修复,但实际上,ESLint 可以为我们做这件事,正如我们将在下一节中看到的。

在 ESLint 中自动修复问题

ESLint 能够自动纠正它发现的一些问题。如果 ESLint 目前没有报告任何错误,尝试从项目的一个源文件中移除一个分号。运行 ESLint,你应该会看到一个关于这个的错误。

现在按照以下方式运行 ESLint 并使用 --fix 选项:

> eslint **/*.js --fix

ESLint 为我们替换了分号。并不是 ESLint 的所有规则都可以以这种方式修复,但许多规则可以。这取决于规则错误是否总是有一个单一且明确的修复。包括哪些规则可以修复的完整列表可以在 ESLint 网站上找到,网址为 eslint.org/docs/rules/

现在你应该能够运行 ESLint 而没有任何错误或警告。ESLint 现在准备好捕捉我们编写的新代码中的错误。

从 Gulp 运行 ESLint

为我们的 Phantom 集成测试指定特殊排除项有些杂乱。不幸的是,我们还全局启用了 Node.js、Mocha、浏览器和 jQuery 环境。Mocha 环境只需要在我们的测试代码中使用。浏览器和 jQuery 环境只需要在我们的客户端代码中使用,而 Node.js 环境则不需要。

如果我们分别在不同的文件集上运行 ESLint,这将更容易管理。如果我们手动这样做,这会开始变得繁琐且容易出错。但这是一个构建工具的绝佳用例。我们可以使用 Gulp 为不同的文件集设置单独的 ESLint 配置文件。首先,安装 Gulp ESLint 插件:

> npm install gulp-eslint --save-dev

现在我们可以创建 Gulp 任务来检查每一组源代码。默认情况下,gulp-eslint 插件使用我们 .eslintrc.json 文件中的规则。因此,我们可以将其缩减为只与所有源代码相关的规则:

{
    "extends": "eslint:recommended",
    "rules": {
        "no-unused-vars": [2, { "args": "after-used" }],
        "quotes": [2, "single"],
        "semi": [2, "always"]
    }
}

然后,我们可以在各自的 Gulp 任务中指定每一组源代码的相关规则或环境。这也允许我们从集成测试脚本的顶部移除特殊的指令注释:

const eslint = require('gulp-eslint');

gulp.task('lint-server', function() {
    return gulp.src(['src/**/*.js', '!src/public/**/*.js'])
        .pipe(eslint({
            envs: [ 'es6', 'node' ],
            rules: {
                'no-unused-vars': [2, {'argsIgnorePattern': 'next'}]
            }
        }))
        .pipe(eslint.format())
        .pipe(eslint.failAfterError());
});

gulp.task('lint-client', function() {
    return gulp.src('src/public/**/*.js')
        .pipe(eslint({ envs: [ 'browser', 'jquery' ] }))
        .pipe(eslint.format())
        .pipe(eslint.failAfterError());
});

gulp.task('lint-test', function() {
    return gulp.src('test/**/*.js')
        .pipe(eslint({ envs: [ 'es6', 'node', 'mocha' ] }))
        .pipe(eslint.format())
        .pipe(eslint.failAfterError());
});

gulp.task('lint-integration-test', function() {
    return gulp.src('integration-test/**/*.js')
        .pipe(eslint({
            envs: [ 'browser', 'phantomjs', 'jquery' ],
            rules: { 'no-console': 0 }
        }))
        .pipe(eslint.format())
        .pipe(eslint.failAfterError());
});

最后,我们将任务之间的依赖关系连接起来:

gulp.task('test', ['lint-test'], function() {
  env({ vars: { NODE_ENV: 'test' } });
  return gulp.src('test/**/*.js')
    .pipe(mocha());
});

gulp.task('lint', [
 'lint-server', 'lint-client', 'lint-test', 'lint-integration-test'
]);
gulp.task('default', ['lint', 'test']);

在这里,我们让 test 任务依赖于 lint-test,并创建一个新的整体 lint 任务来运行所有其他任务作为默认构建的一部分。尝试运行 Gulp 并观察输出。注意,它并行启动所有 lint 任务,但在运行测试之前等待 lint-test 完成。默认情况下,如果可能,Gulp 将并发运行任务。如果一个任务在结束时返回一个流(从 gulp.src 获得的对象),Gulp 能够使用这个流来检测任务何时完成。Gulp 将在开始任何依赖于它的任务之前等待任务完成。

为了了解 ESLint 失败如何影响 Gulp,让我们添加另一个 ESLint 规则,以确保使用 JavaScript 的严格模式,如第三章“JavaScript 入门”中所述。以下代码来自 .eslintrc.json

{
    "extends": "eslint:recommended",
    "rules": {
        "no-unused-vars": [2, { "args": "after-used" }],
        "quotes": [2, "single"],
        "semi": [2, "always"],
 "strict": [2, "safe"]
    }
}

ESLint 足够聪明,能够根据每组文件指定的环境来确定如何应用严格模式:对于客户端脚本在函数顶部,对于将成为 Node.js 模块的文件全局应用。它还会发现我们无必要多次指定严格模式,无论是全局还是嵌套函数中。

当你执行 Gulp 时,注意 ESLint 任务中的失败会阻止依赖的测试任务运行。如果你修复了严格模式错误,那么 Gulp 将再次成功运行。

收集代码覆盖率统计信息

尽管我们对我们的应用程序有一些测试,但它们当然还没有全面。能够看到我们的代码哪些部分被测试覆盖将很有用。为此,我们将使用 Istanbul,一个 JavaScript 代码覆盖率工具。首先,安装 gulp-instanbul 插件:

> npm install gulp-istanbul --save-dev

现在,我们需要添加一个 Gulp 任务来为我们的生产代码进行覆盖率测试:

const istanbul = require('gulp-istanbul');

...

gulp.task('instrument', function() {
    return gulp.src('src/**/*.js')
        .pipe(istanbul())
        .pipe(istanbul.hookRequire())
});

最后,我们需要更新我们的测试任务以输出覆盖率报告,并在低于阈值时失败构建:

gulp.task('test', ['lint-test', 'instrument'], function() {
    gulp.src('test/**/*.js')
        .pipe(mocha())
 .pipe(istanbul.writeReports())
 .pipe(istanbul.enforceThresholds({
 thresholds: { global:90 }
 }));
});

现在,当我们运行 Gulp 时,会出现三个新的结果:

  • 在命令行上出现覆盖率摘要

  • 一组覆盖率报告出现在 coverage 文件夹下

  • 构建失败是因为我们的覆盖率低于阈值

命令行上的构建摘要非常有用。在 coverage/lcov-report/index.html(项目目录中)出现的 HTML 报告中还有更多详细信息。

尽管我们需要提高测试覆盖率,但我们不想让构建失败。目前,我们将覆盖率目标设置在当前水平以下,以防止进一步下降。我们可以通过传递给 istanbul.enforceThresholds 的选项来实现这一点:

gulp.task('test', ['lint-test', 'instrument'], function() {
    return gulp.src('test/**/*.js')
        .pipe(mocha())
        .pipe(istanbul.writeReports())
        .pipe(istanbul.enforceThresholds({
 thresholds: {
 global: {
 statements: 70,
 branches: 50
 }
 }
        }));
});

从 Gulp 运行集成测试

Gulp 任务只是普通的 JavaScript 函数,因此可以包含我们喜欢的任何功能。让我们看看一个更复杂的使用案例。我们将创建一个任务,启动我们的服务器,运行集成测试,然后关闭服务器。为此,我们需要 Gulp Shell 插件:

> npm install gulp-shell --save-dev

首先,我们更新我们的集成测试脚本,以便我们可以传递测试服务器的端口号。这使用了 PhantomJS 的 'system' 模块,如下所示(在 integration-test/game.js 中):

var rootUrl = 'http://localhost:' +
                  require('system').env.TEST_PORT || 3000;

现在,我们可以定义一个 Gulp 任务来运行服务器和集成测试:

const shell = require('gulp-shell');

...

gulp.task('integration-test',
          ['lint-integration-test', 'test'], (done) => {
  const TEST_PORT = 5000;
  let server = require('http')
    .createServer(require('./src/app.js'))
    .listen(TEST_PORT, function() {
      gulp.src('integration-test/**/*.js')
        .pipe(shell('node node_modules/phantomjs-prebuilt/bin/phantomjs <%=file.path%>', {
            env: { 'TEST_PORT': TEST_PORT }
        }))
        .on('error', () => server.close(done))
        .on('end', () => server.close(done))
    });
});

这将启动应用程序,然后利用 gulp-shell 插件来执行我们的集成测试脚本。最后,我们确保在完成后关闭服务器,通过传递 Gulp 的异步回调。就像返回一个流一样,使用这个回调允许 Gulp 知道任务何时完成。

我们使这个任务依赖于 test 任务,这样它们就不会相互干扰。我们不将这部分作为我们的默认任务,因为它是一个更重的操作。但我们确实希望它在我们的构建服务器上运行,所以我们将它添加到 .travis.yml 中,与默认任务一起:

language: node_js
before_script:
  - npm install -g gulp
script: gulp default integration-test
node_js:
 - 5
 - 4

现在,如果我们向远程主分支推送,TravisCI 将对我们的代码执行静态分析,执行所有单元和集成测试,并检查单元测试覆盖率。

摘要

在本章中,我们使用 Travis CI 设置了一个集成构建,使用 ESLint 添加了代码的静态分析,使用 Gulp 自动化了我们的测试和其他任务,并开始使用 Istanbul 工具测量测试覆盖率。

现在我们已经建立了稳定开发的基础设施,我们可以开始扩展我们的项目。在下一章中,我们将向应用程序引入持久化数据存储。

第八章。掌握异步性

我们的 JavaScript 入门指南(第三章)涵盖了所有重要的概念,使我们能够开始构建我们的应用程序。但 JavaScript 编程的一个基本方面值得更深入地探索:异步性。

第一章,为什么选择 Node.js?,讨论了 Node.js 的异步编程模型。它描述了 Node.js API 和第三方库中使用的统一方法。回想一下,每个异步方法都接受一个回调函数,该函数传递错误和结果参数,例如我们在第一章,为什么选择 Node.js?中看到的fs.stat函数:

fs.stat('/hello/world', function (error, stats) {
  console.log('File last updated at: ' + stats.mtime);
});

然而,回调模式有一些弱点。执行错误处理和组合多个异步操作的结果可能会变得相当笨拙。JavaScript 中有一些替代的异步模式可以解决这些问题。多个竞争模式本身可能看起来令人担忧。在第一章,为什么选择 Node.js?中讨论的 Node.js 的一个好处是拥有一个单一的一致方法。

我们还应该重新审视 Node.js API 和库始终异步这一想法。我们需要考虑这如何应用到我们自己的代码中。这不仅仅是在为第三方编写模块时需要担心的事情。即使在我们的应用程序内部,大多数模块也需要通过异步接口公开其功能。如果不这样做,我们将严重限制我们实现这些模块的自由度。

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

  • 向我们的模块引入异步接口

  • 观察回调模式的某些弱点

  • 通过重构移除回调,使我们的异步代码更易读

  • 看看我们如何仍然可以从 Node.js 异步编程模型的一致性中受益

使用回调模式进行异步代码

让我们看看我们游戏服务中的一种方法:

module.exports.get = (id) => games.find(game => game.id === id);

这个函数的接口是同步的:你调用它并返回一个值。第四章,介绍 Node.js 模块,介绍了游戏服务作为负责我们存储游戏的模块。如果我们更改存储实现,接口不需要改变。然而,目前情况并非如此。

如前所述,大多数 Node.js 库都是异步的。同步接口无法利用异步实现。假设get函数想要在第三方datastore库中使用异步方法。那会是什么样子?以下(非工作)代码中的注释描述了问题:

module.exports.get = (id) => {
    datastore.getById(id, (err, result) => {
        // Result available, but outer method has already returned
    });
    return ???; // Need to return here, but have no result yet
};

这是一个普遍存在的问题,不仅仅是在 JavaScript 中。在其他平台上,你可以延迟返回,直到异步操作完成。这会将异步操作转换为阻塞操作。在 Node.js(以及其他 JavaScript 环境中),这种方式是不可行的。它将与单线程、非阻塞、事件驱动的执行模型不兼容。

暴露回调模式

为了使我们的游戏服务能够使用异步库,我们需要给它一个异步接口。请注意,Node.js 生态系统中的几乎所有库都是异步的。如果不是,它们将受到与我们的游戏服务当前相同的限制。

我们可以将我们的get函数的接口重写为遵循标准的异步回调模式。让我们看看这会对使用异步第三方datastore库产生什么影响(再次强调,以下代码是无效的,包含一个虚构的datastore对象):

module.exports.get = (id, callback) => {
  datastore.getById(id, (err, result) => {
    // Can now make use of the result by passing to the callback
    callback(err, result);
  }
  // No longer need to return here
}

当然,在这种情况下,我们可以将前面的代码简化如下:

module.exports.get = (id, callback) => {
    datastore.getById(id, callback);
}

然而,通常我们可能想要对第三方库的结果进行更多处理。因此,我们的函数可能看起来更像是这样:

module.exports.get = (id, callback) => {
    datastore.getById(id, (err, result) => {
        if (err) {
            callback(err);
        } else {
            callback(null, processResult(result));
        }
    }
}

假设processResult是我们模块内部的一个函数,它现在有一个同步接口是完全可以的。如果它需要稍后执行异步工作,我们可以更改其接口,而不会影响我们模块的消费者。

我们的游戏服务模块的公共接口确实需要完全异步。我们还没有真正改变模块的实现。这使得更新接口变得相当直接。我们可以在src/services/games.js中做出以下更改:

'use strict';

const games = [];
let nextId = 1;

class Game {
    ...

 remove(callback) {
        games.splice(games.indexOf(this), 1);
 callback();
    }
}

module.exports.create = (userId, word, callback) => {
    const newGame = new Game(nextId++, userId, word); 
    games.push(newGame);
 callback(newGame);
};
module.exports.get = (id, callback) =>
 callback(null,
 games.find(game => game.id === parseInt(id, 10)));
module.exports.createdBy = (userId, callback) =>
 callback(null, games.filter(game => game.setBy === userId));
module.exports.availableTo = (userId, callback) =>
 callback(null, games.filter(game => game.setBy !== userId));

注意,这虽然有些不切实际。通常,在异步方法完成之前,控制权会返回给调用者。我们可以通过使用process.nextTick在事件循环的下一次 tick 上安排回调的执行来实现这一点(如果你想要复习事件循环,请参阅第一章,为什么 Node.js?):

'use strict';

const games = [];
let nextId = 1;

const asAsync = (callback, result) =>
 process.nextTick(() => callback(null, result));

class Game {
    ...

    remove(callback) {
        games.splice(games.indexOf(this), 1);
 asAsync(callback);
    }
}

module.exports.create = (userId, word, callback) => {
    let game = new Game(nextId++, userId, word);
    games.push(game);
 asAsync(callback);
};
module.exports.get = (id, callback) =>
 asAsync(callback,
 games.find(game => game.id === parseInt(id, 10)));
module.exports.createdBy = (userId, callback) =>
 asAsync(callback, games.filter(game => game.setBy === userId));
module.exports.availableTo = (userId, callback) =>
 asAsync(callback, games.filter(game => game.setBy !== userId));

将我们的应用程序的其余部分更新为消费这个异步接口是一个更复杂的任务。这就是为什么始终编写从开始就是异步的模块接口是值得的。我们绝对应该在进一步扩展我们的应用程序之前解决这个问题。

消费异步接口

游戏服务被游戏路由、索引路由以及我们的测试调用。让我们依次查看这些更改。以下代码来自src/routes/games.js

'use strict';

const express = require('express');
const router = express.Router();
const service = require('../service/games.js');

router.post('/', function(req, res, next) {
    let word = req.body.word;
    if (word && /^[A-Za-z]{3,}$/.test(word)) {
 service.create(req.user.id, word, (err, game) => {
 if (err) {
 next(err);
 } else {
 res.redirect(`/games/${game.id}/created`);
 }
 });
    } else {
        res.status(400).send('Word must be at least three characters long and contain only letters');
    }
});

const checkGameExists = function(id, res, onSuccess, onError) {
 service.get(id, function(err, game) {
 if (err) {
 onError(err);
 } else {
 if (game) {
 onSuccess(game);
 } else {
 res.status(404).send('Non-existent game ID');
 }
 }
 });
};

router.get('/:id', function(req, res, next) {
    checkGameExists(
        req.params.id,
        res,
        game => { ... },
 next);
});

router.post('/:id/guesses', function(req, res, next) {
    checkGameExists(
        req.params.id,
        res,
        game => { ... },
 next);
});

router.delete('/:id', function(req, res, next) {
    checkGameExists(
        req.params.id,
        res,
        game => {
            if (game.setBy === req.user.id) {
 game.remove((err) => {
 if (err) {
 next(err);
 } else {
 res.send();
 }
 });
            } else {
                res.status(403).send('You don't have permission...');
            }
        },
 next);
});

router.get('/:id/created', function(req, res, next) {
    checkGameExists(
        req.params.id,
        res,
        game => res.render('createdGame', game),
 next);
});

module.exports = router;

在这种情况下,更改很简单。现在对游戏服务函数的每次调用都传递一个回调。回调包含原本跟随游戏服务函数调用的逻辑。每个回调还需要处理错误值的可能性。在这种情况下,我们只需将其传递给 Express 的next回调,这样它就会被我们的全局错误处理器处理。

虽然这些变化很简单,但它们在我们的代码中引入了一些重复的模板代码。在索引路由中,这个问题更为严重;看看src/routes/index.js中的代码:

var express = require('express');
var router = express.Router();
var games = require('../service/games.js');

router.get('/', function(req, res, next) {
 games.createdBy(req.user.id, (err, createdGames) => {
 if (err) {
 next(err);
 } else {
 games.availableTo(req.user.id, (err, availableGames) => {
 if (err) {
 next(err);
 } else {
 res.render('index', {
 title: 'Hangman',
 userId: req.user.id,
 createdGames: createdGames,
 availableGames: availableGames,
 partials: { createdGame: 'createdGame' }
 });
 }
 });
 }
  });});

module.exports = router;

在这里,我们需要合并两个不同异步调用的结果。这导致了嵌套回调。我们还得在每个阶段重复错误处理代码。注意,我们只有在第一个异步操作完成后才开始第二个异步操作。并行启动操作会更好。

回想一下,虽然 JavaScript 本身是单线程的,但异步操作可以在并行中执行工作,例如网络、磁盘和其他 I/O 操作。并行运行多个操作需要更复杂的(且容易出错的)模板代码。为了说明这可能如何工作,考虑将游戏服务测试中的beforeEach函数变为异步操作所做的更改。以下代码来自src/test/services/games.js

describe('Game service', function() {
    let firstUserId = 'user-id-1';
    let secondUserId = 'user-id-2';

 beforeEach(function(done) {
 service.availableTo('not-a-user', (err, gamesAdded) => {
 let gamesDeleted = 0;
 if (gamesAdded.length === 0) {
 done();
 }
 gamesAdded.forEach(game => {
 game.remove(() => {
 if (++gamesDeleted === gamesAdded.length) {
 done();
 }
 });
 });
 });
 });

    ...
});

在这里,我们需要对异步的移除方法进行未知数量的调用。当所有调用都完成时,必须调用done回调。有几种实现方式,但它们都涉及额外的模板代码。这里的方法是最简单的,通过计数完成操作的次数。另外,请注意,我们省略了错误处理,因为这是测试代码。在生产代码中,我们还需要考虑错误处理,这使得事情变得更加复杂。

注意

测试中还有一些其他的变化,以利用游戏服务的新的异步接口。为了简洁,这里省略了这些变化。它们与index.js中的变化类似。您可以通过查看 Git 仓库中该章节的第一个提交来查看完整的变更集,Git 仓库地址为github.com/NodeJsForDevelopers/chapter08

所有这些都显得相当令人不满意。我们的代码变得更加复杂、重复,且难以阅读。幸运的是,我们可以通过使用不同的方法来编写异步代码来解决这些问题。

使用承诺(promises)编写更干净的异步代码

承诺(Promises)是编写异步代码的回调模式的替代方案。承诺代表一个尚未完成但预期将来会完成的操作。正如其名“承诺”所暗示的,承诺是一个最终提供值或失败原因(即错误)的合同。您可能已经从.NET 中的任务或 Java 中的未来(Futures)中熟悉了这种模式。承诺有三个可能的状态:

  • pending 表示一个进行中的操作

  • fulfilled 表示一个成功的操作,带有结果值

  • rejected 表示一个不成功的操作,带有失败原因

当执行单个操作时,基于回调和基于承诺的方法看起来非常相似。承诺的力量在于组合异步操作。

考虑一个例子,我们有一个异步库函数用于获取、处理和聚合数据。我们希望依次执行这些操作,然后显示结果,并在执行过程中处理错误。使用回调,代码可能看起来像这样(以下为不可运行的虚构代码):

lib.getInitialData(function(e, initialData) {
  if (e) {
    console.log('Error: ' + e);
  } else {
    lib.processData(initialData, (e, processedData) => {
      if (e) {
        console.log('Error: ' + e);
      } else {
        lib.aggregateData(processedData, (e, aggregatedData) => {
          if (e) {
            console.log('Error: ' + e);
          } else {
            console.log('Success! Result=' + aggregatedData);
          }
        });
      }
    });
  }
});

这与我们之前章节中自己代码中遇到的问题有很多相同之处:嵌套回调、额外的样板代码和重复的错误处理。如果这些函数返回承诺,那么上述代码的等效代码如下:

lib.getInitialData()
    .then(lib.processData)
    .then(lib.aggregateData)
    .then(function(aggregatedData) {
        console.log('Success! Result=' + result);
    }, function(error) {
        console.log('Error: ' + error);
    });

then函数将一个函数应用到承诺的结果值上,并返回一个新的承诺。通过这种方式,我们构建了一系列操作表示的承诺链。

then函数接受两个参数,这两个参数都是回调。如果异步操作返回错误,则将调用第二个参数。在上面的例子中,如果library.aggregateData调用失败,我们将记录一个错误。

如果省略了第二个then回调参数,任何错误都会沿着承诺链传播。在上面的例子中,这意味着如果library.processData调用失败,则不会调用library.aggregateData,并且我们的错误日志回调仍然会被调用。

如果你只关心错误情况,你可以直接使用catch函数指定一个错误回调,而不是使用then。你还可以结合传播机制来更清晰地重写前面的代码:

library.getInitialData()
    .then(library.processData)
    .then(library.aggregateData)
    .then(function(aggregatedData) {
        console.log('Success! Result=' + result);
 })
 .catch(function(error) {
 console.log('Error: ' + error);
    });

在这里,任何位置的错误都会传播到最后一个承诺,我们检查是否有错误。请注意,这个重写的版本也会捕获成功日志回调抛出的任何错误,而前面的版本则不会。你应该始终在承诺链的末尾调用catch,除非你正在返回结果承诺对象以供其他地方消费。

实现基于承诺的异步代码

让我们将承诺模式应用到我们现有的应用程序中。首先,我们需要更新我们的游戏服务 API,以暴露承诺而不是回调。和之前一样,这很简单,因为我们的游戏服务在实现中实际上并没有使用任何异步操作(目前还没有)。我们的游戏服务的基于承诺的版本如下(在src/services/games.js中):

'use strict';

const games = [];
let currentId = 1;

class Game {
    ...

 remove() {
        games.splice(games.indexOf(this), 1);
 return Promise.resolve();
    }
}

module.exports.create = (userId, word) => {
    const newGame = new Game(nextId++, userId, word); 
    games.push(newGame);
 return Promise.resolve(newGame);
};
module.exports.get = (id) =>
 Promise.resolve(
 games.find(game => game.id === parseInt(id, 10)));
module.exports.createdBy = (userId) =>
 Promise.resolve(games.filter(game => game.setBy === userId));
module.exports.availableTo = (userId) =>
 Promise.resolve(games.filter(game => game.setBy !== userId));

创建基于承诺的接口甚至比基于回调的接口更简单。我们可以使用Promise.resolve()函数为已知值创建一个承诺。我们游戏服务中的每个函数看起来都和原始的同步版本很相似,只是多了一个调用Promise.resolve

注意

如果你将承诺参数传递给Promise.resolve,那么你将得到一个行为与原始参数相同的承诺。如果你传递任何其他值,你将得到一个已解析的承诺,该承诺对应于该值。这在你需要操作可能是一个承诺或值的变量时很有用。你可以将其传递给Promise.resolve,然后一致地将其作为承诺处理。

消费承诺模式

现在我们需要更新我们的代码库的其余部分以使用承诺。让我们看看之前的相同文件,从游戏路由开始。请看以下来自 src/routes/games.js 的代码:

'use strict';

const express = require('express');
const router = express.Router();
const service = require('../service/games.js');

router.post('/', function(req, res, next) {
    let word = req.body.word;
    if (word && /^[A-Za-z]{3,}$/.test(word)) {
 service.create(req.user.id, word)
 .then(game =>
 res.redirect(`/games/${game.id}/created`))
 .catch(next);
    } else {
        res.status(400).send('Word must be at least three characters long and contain only letters');
    }
});

const checkGameExists = function(id, res, onSuccess, onError) {
 service.get(id)
 .then(game => {
 if (game) {
 onSuccess(game);
 } else {
 res.status(404).send('Non-existent game ID');
 }
 })
 .catch(onError);
};

...

router.delete('/:id', function(req, res, next) {
    checkGameExists(
        req.params.id,
        res,
        game => {
            if (game.setBy === req.user.id) {
 game.remove()
 .then(() => res.send())
 .catch(next);
            } else {
                res.status(403).send('You do not have permission to delete this game');
            }
        },
        next);
});

这个文件之前是最简单的,所以这里的变化最少。我们仍然有一些样板代码的重复(例如,catch 调用)。尽管如此,基于承诺的方法比回调更紧凑且易于阅读。现在让我们看看来自 src/routes/index.js 的索引路由代码:

var express = require('express');
var router = express.Router();
var games = require('../service/games.js');

router.get('/', function(req, res, next) {
 games.createdBy(req.user.id)
 .then(gamesCreatedByUser => 
 games.availableTo(req.user.id)
 .then(gamesAvailableToUser => {
 res.render('index', {
 title: 'Hangman',
 userId: req.user.id,
 createdGames: gamesCreatedByUser,
 availableGames: gamesAvailableToUser
 });
 }))
 .catch(next);
});

module.exports = router;

这有点改进。重复较少,但仍然有一些嵌套和样板代码。注意最外层的 then 回调返回一个承诺(从 games.availableTo 连接)。当一个 then 回调返回一个承诺时,这实际上会被扁平化,因此整体承诺返回内部承诺的值。这种扁平化也适用于错误的传播,因此我们不需要在内部承诺上显式调用 catch

这段代码仍然有点难以理解。实际上有一种方法可以使它更容易阅读,我们稍后会回到这个问题。首先,让我们看看以下来自 test/service/games.js 的游戏服务测试中的 beforeEach 函数:

describe('Game service', function() {
    let firstUserId = 'user-id-1';
    let secondUserId = 'user-id-2';

    beforeEach(function(done) {
 service.availableTo('non-existent-user')
 .then(games => games.map(game => game.remove()))
 .then(gamesRemoved => Promise.all(gamesRemoved))
 .then(() => done(), done);
    });
});

这已经变得非常短且线性。让我们分析每一行的作用:

  • service.availableTo 返回一个包含游戏数组的承诺

  • 第一个 then 回调使用 array.map 将其转换为 包含删除操作承诺的数组承诺

  • 下一个 then 回调使用 Promise.all 将其转换为整个删除操作数组的单个承诺

    注意

    Promise.all 函数接受一个承诺数组,并在数组中所有承诺都解析或数组中的任何承诺被拒绝时立即拒绝返回一个承诺。

  • Promise.all 返回的承诺解析时,即所有删除操作完成时,将调用 Mocha 的 done 回调

注意,与基于回调的方法不同,错误处理也非常简单。我们只需将 done 回调作为错误处理程序(第二个参数)传递给最终的 then 调用。我们可以在测试本身中采取类似的方法,就像我们在 beforeEach 回调中所做的那样。再次提醒,为了简洁,省略了测试的更新,但你可以从书籍的配套代码中找到它们。

使用承诺并行化操作

我们还可以利用 Promise.all 函数简化索引路由。回想一下,我们的代码是依次调用两个异步操作。在基于回调的方法中,尝试并行执行这些操作会使代码更加复杂。使用承诺,实际上使我们的代码更易于阅读:

var express = require('express');
var router = express.Router();
var games = require('../service/games.js');

router.get('/', function(req, res, next) {
 Promise.all([
 games.createdBy(req.user.id),
 games.availableTo(req.user.id)
 ])
 .then(results => {
 res.render('index', {
 title: 'Hangman',
 userId: req.user.id,
 createdGames: results[0],
 availableGames: results[1]
 });
 })
 .catch(next);
});

module.exports = router;

这更短,更容易理解。我们启动两个异步操作来加载数据,然后在两个操作都完成后立即使用这些数据。

小贴士

前述方法的唯一小缺点是我们必须通过索引从数组中获取两个值。在 Node.js v6 或更高版本中,我们可以通过使用 解构 来从数组中的值分配两个命名参数,从而避免这种情况,并使代码更加易于阅读,如下所示:

        .then(([created, available]) => { ...

这在上述示例中没有用于与 Node.js v4 的向后兼容性。我们将在第十四章([part0081.xhtml#aid-2D7TI1 "第十四章. Node.js 和更远"])中更详细地讨论解构,Node.js 和更远

组合异步编程模式

承诺(Promises)使我们能够解决回调模式的一些不足,并编写更易于阅读的代码。然而,我们遇到了一个新的问题。Node.js 的一大优点是它对异步编程的一致性方法。通过引入承诺以及传统的回调模式,我们似乎已经否定了这一点。

此外,尽管原生的承诺是 ECMAScript 2015 中的新特性,但这个概念并不新鲜。有许多现有的库提供了它们自己的承诺实现。

幸运的是,这些异步编程的竞争方法实际上非常一致。Node.js 风格回调模式一致性的最大价值来自于以下方面:

  • 所有库函数默认都是异步的(非阻塞的)

  • 所有异步操作都返回单个值或错误

承诺与上述点完全一致。JavaScript 中不同承诺实现的兼容性也非常好。这要归功于 Promises/A+ 规范(promisesaplus.com)。这本质上定义了 then 方法的行为。你可能会遇到的任何承诺库都将遵循此规范。原生的 JavaScript 承诺也设计为与之兼容。这意味着所有这些库和原生 JavaScript 承诺都是可互操作的。

因此,所有使用回调的库都遵循相同的约定,所有承诺库都遵循相同的规范。唯一剩下的问题是转换承诺和回调。有几个承诺库可以为我们完成这项工作。

如果你只想将几个标准回调函数转换为承诺,可以使用 denodeify,它可以通过 npm 安装。我们之前提到的 fs.stat 示例将看起来像这样:

const denodeify = require('denodeify');
const stat = denodeify(require('fs').stat));
stat('/hello/world')
    .then(stats => console.log('File last updated at: ' + stats.mtime));

你还会发现许多库公开了可以返回承诺或接受回调的函数,因此可以使用任一模式调用。

摘要

在本章中,我们看到了如何在我们的模块中公开标准的 Node.js 回调接口。我们使用了承诺来生成更易于阅读的异步代码。最后,我们看到了如何结合使用承诺和标准的 Node.js 回调。

现在我们能够实现自己的异步 API,我们可以扩展我们的应用程序并开始使用提供异步接口的其他库。在下一章中,我们将利用这一点向我们的应用程序引入持久存储。

第九章. 持久化数据

大多数应用程序都需要持久化某种类型的数据。在本章中,我们将探讨 Node.js 应用程序的数据持久化的一些方法。

在很长一段时间内,持久化的默认选择一直是传统的数据库。你可能使用过RDBMS(关系型数据库管理系统),例如 Microsoft SQL Server、Oracle、MySQL 或 PostgreSQL。这些系统通常被归类为SQL 数据库,因为它们都使用 SQL 作为它们的查询语言。

最近,所谓的NoSQL数据库的数量激增。这个总称作为分类并不特别有用。一些 NoSQL 数据库与传统的关系型数据库没有更多的共同之处。

最有趣的是可用的数据库范围以及它们所满足的使用案例。传统的 RDBMS(关系型数据库管理系统)依然强大且灵活,是许多情况下的正确选择。在本章中,我们将考虑其他两种类型的数据库,以及如何和何时使用它们。

我们将要探讨的系统是MongoDBRedis。这两个系统都于 2009 年首次发布,现在被广泛使用。深入探讨其中的任何一个都足以写成一本书。本章的目标是提供一个对每个系统的介绍和高级概述。

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

  • 这些系统使用的概念数据模型

  • 它们提供最大利益的用例

  • 将它们与 Express 应用程序集成

  • 测试数据持久化代码

介绍 MongoDB

MongoDB 是一个面向文档的 DBMS。MongoDB 文档以二进制 JSONBSON)的形式存储。这类似于 JSON,但支持更多的数据类型。JSON 字段值只能是字符串、数字、对象、数组、布尔值或 null。BSON 支持更具体的数值类型、日期和时间戳、正则表达式和二进制数据。正如其名所示,BSON 文档以二进制数据的形式存储和传输。这比 JSON 的字符串表示可能更高效。

MongoDB 文档存储在集合中。它们在传统的关系型数据库中的工作方式非常相似。文档可以被插入、更新和查询。与传统的关系型数据库有两个关键的区别:

  • MongoDB 不支持服务器端连接。在传统的 RDBMS 中,你会将数据规范化到多个表中,并通过外键在它们之间进行连接。在 MongoDB 中,你相反地使用 BSON 的嵌套结构将每个实体的数据非规范化到一个单独的文档中。

  • 关系型数据库的关系属性是表中的所有行都包含相同字段,且具有相同的意义。在 MongoDB 中,文档可以拥有任何一组字段。

在实践中,同一集合中的文档通常具有相同的字段,或者至少有一个共同的字段集。MongoDB 支持在集合的常用字段上创建索引,以提高查询效率。

为什么选择 MongoDB?

MongoDB 有一些特性使其在某些用例中具有吸引力,尤其是在基于 Node.js 的应用程序中。我们将在本节中介绍这些内容。

对象建模

MongoDB 的基于文档的方法非常适合持久化领域实体。你可能有过在关系型数据库中使用对象关系映射器ORM)存储领域实体的经验。Hibernate 和 Entity Framework 是 ORM 的流行例子。ORM 执行的一项工作是将单个实体映射到规范化模式中的多个表。当实体从数据库加载时,它通过这些表之间的JOIN查询重建。这是 ORM 的关键特性之一。这也是使用 ORM 时配置问题和性能问题最常见的原因之一。MongoDB 将每个实体持久化为单个文档,这可以简单得多。

当然,跨表连接也可以用于遍历实体之间的关系。虽然 ORM 通常使这变得容易,但这本身也可能成为性能问题的来源。相关实体的隐式加载经常导致N+1问题,发出数千个数据库查询。无论使用哪种类型的数据库,妥善处理这些关系都需要仔细思考。

当使用 ORM 和 RDBMS 时,所有实体间的关系都是外键,但你需要仔细考虑如何加载它们。在 MongoDB 中建模数据时,你必须选择嵌入文档或文档引用来表示实体间的关系。在任一技术栈下,设计决策取决于应用程序的数据访问需求,以及设计数据模型以减少实体间关系的普遍性将简化问题。

JavaScript

MongoDB 特别适合 Node.js。JSON-like 格式的使用很好地映射到基于 JavaScript 的编程环境。MongoDB 本身也原生支持 JavaScript。数据库操作可以利用在服务器上执行的定制 JavaScript 函数。

可扩展性

MongoDB 的扩展方式与 Node.js 类似。它使用分区和复制来支持在通用硬件上的水平扩展。没有技术原因要求你的应用程序和数据库必须以相同的方式扩展,但从业务角度来看,这可能更容易规划可扩展性。

当使用 RDBMS 时,垂直扩展数据库更为直接。这意味着提供一台高性能的数据库服务器,它可以支持多个应用程序服务器。这比水平扩展应用程序和数据库服务器需要更仔细的计划和更多的前期投资。

开始使用 MongoDB

访问 www.mongodb.org/downloads 下载并安装适用于您操作系统的最新版本的 MongoDB 社区服务器版。用户手册中有更详细的安装说明,请参阅 docs.mongodb.org/manual/installation/

本节余下的命令使用了 MongoDB /bin 目录中的可执行文件。您可以在该目录中运行这些命令,或者更好的做法是将它添加到您的 PATH

为 MongoDB 创建一个目录以存储其数据。然后启动 MongoDB 守护进程(即服务),如下提供该目录的路径:

> mongod --dbpath C:\data\mongodb

使用 MongoDB shell

您可以使用 MongoDB 的内置 shell 应用程序从控制台与 MongoDB 交互。您可以通过运行 mongo 命令来启动 MongoDB shell,如下所示:

> mongo demo

这将连接到本地服务器上名为 demo 的数据库(如果需要则创建它)。如果您没有指定数据库,则 shell 将连接到名为 test 的数据库。

首先要注意的是,shell 只是另一个 JavaScript 环境。我们可以尝试运行与第二章开头相同的某些命令,Node.js 入门

> function square(x) { return x*x; }
> square(42)
1764
> new Date()
ISODate("2016-01-01T20:05:39.652Z")
> var foo = { bar: "baz" }
> typeof foo
object
> foo.bar
baz

正如 Node.js 通过在服务器端应用程序开发中构建在 JavaScript 之上,使其更适合服务器端应用程序开发一样,MongoDB 添加了更多对数据持久性有用的功能。请注意,前面代码中的 new Date() 返回一个 ISODate,这是 MongoDB 在 BSON 文档中表示日期的标准数据类型。

您可以随时通过输入 exit 来退出控制台。

MongoDB 还添加了一些新的全局变量,用于与数据库交互。其中最重要的是 db 对象。让我们尝试向我们的数据库添加一些文档。回想一下,MongoDB 将文档存储在集合中。要创建一个新的集合,我们只需要开始向其中插入文档。作为一个简单的例子,我们将使用 2016 年的英国银行假日。我们可以使用以下脚本填充这个集合:

db.holidays.insert(
  { name: "New Year's Day", date: ISODate("2016-01-01") });
db.holidays.insert(
  { name: "Good Friday", date: ISODate("2016-03-25") });
db.holidays.insert(
  { name: "Easter Monday", date: ISODate("2016-03-28") });
db.holidays.insert(
  { name: "Early May bank holiday", date: ISODate("2016-05-02") });
db.holidays.insert(
  { name: "Spring bank holiday", date: ISODate("2016-05-30") });
db.holidays.insert(
  { name: "Summer bank holiday", date: ISODate("2016-08-29") });
db.holidays.insert(
  { name: "Boxing Day", date: ISODate("2016-12-26") });
db.holidays.insert(
  { name: "Christmas Day", date: ISODate("2016-12-27"),
    substitute_for: ISODate("2016-12-25") });

注意,2016 年的圣诞节在星期日,因此银行假日发生在下一个工作日。这给了我们一个理由来有一个只与集合中某些文档相关的字段。

您可以将这些 insert 命令手动输入到控制台中,但告诉 MongoDB 从脚本文件中加载它们会更简单:

> mongo demo holidays.js --shell

之前的命令连接到名为 demo 的数据库,运行了(书中配套代码中的)holiday.js 脚本,然后打开一个 shell 以允许我们与数据库交互。我们可以在 MongoDB 控制台中运行以下命令来查看集合的完整内容:

> db.holidays.find()
{ "_id" : ObjectId("572f760fffb6888d70c45eeb"), "name" : "New Year's Day", "date" : ISODate("2016-01-01T00:00:00Z") }
{ "_id" : ObjectId("572f7610ffb6888d70c45eec"), "name" : "Good Friday", "date" : ISODate("2016-03-25T00:00:00Z") }
...

注意,MongoDB 已经为我们自动在每个文档中添加了一个 _id 字段。

小贴士

您可以通过查看 insert 方法的源代码来了解 MongoDB 是如何做到这一点的。只需在 shell 中输入 db.holidays.insert(不带括号)即可。

我们可以通过 _id 或其他单个字段提取记录:

> db.holidays.find({name: "Boxing Day"})

这将返回与传递给 find 的对象匹配的所有对象。要按除精确相等之外的其他方式查找文档,我们可以使用 MongoDB 的查询运算符。这些运算符以美元符号为前缀,并指定为对象属性。例如,要查找下半年的假日,我们可以使用如下所示的 大于等于 运算符:

> db.holidays.find({ date: { $gte: new Date("2016-07-01") }})

MongoDB 的 聚合管道 允许我们通过一系列称为 管道阶段 的操作构建复杂的查询。这是 MongoDB 中与 SQL 中的复杂查询最接近的东西。在这里,我们使用 MongoDB 的 $group 管道阶段来计算每个月的银行假日数量,这与 SQL 的 GROUP BY 子句类似:

> db.holidays.aggregate({
 $group: { _id: { $month: "$date" }, count: { $sum: 1 } }})

2016 年日历的一个奇怪特性意味着圣诞节银行假日实际上是在节礼日之后(因为圣诞节本身是在星期日)。在以下示例中,我们按标记的节日日期(如果与银行假日日期不同,则存储在 $substitute_for 字段中)对银行假日进行排序:

> db.holidays.aggregate([
 { $project: { _id: false, name: "$name",
 date: { $ifNull: ["$substitute_for", "$date"] } } },
 { $sort: { date: 1 } }
])

之前的管道由两个阶段组成:

  • $project 阶段指定一组基于底层数据的字段(类似于 SQL 中的 SELECT)。请注意,_id 字段默认包含在内,但在这里我们将其排除。

  • $sort 阶段指定一个排序字段和方向(类似于 SQL 的 SORT BY 子句)。这里的 1 表示升序排序。

我们在这里只是触及了表面。MongoDB 中还有许多其他管道阶段可用。您可以在 MongoDB 文档中了解更多关于聚合的信息,请参阅 docs.mongodb.com/manual/core/aggregation-pipeline/

MongoDB 还有一个内置的 Map-Reduce 函数,用于使用任意 JavaScript 函数进行强大的聚合数据处理。这超出了本书的范围,但您可以在 docs.mongodb.com/manual/core/map-reduce/ 中了解更多关于 Map-Reduce 和 MongoDB 对其的实现。

使用 MongoDB 与 Express

我们应用程序中的游戏服务模块目前将其所有数据存储在内存中。这对于演示目的来说已经足够好了,但并不适合实际应用。每次应用程序重启时,我们都会丢失所有数据。这也阻止了我们跨多个进程扩展应用程序。每个实例都会有自己的游戏服务,具有不同的数据。用户会看到不同的数据,这取决于哪个服务器恰好处理了他们的请求。

我们将更新我们的游戏服务,以便将其数据存储在 MongoDB 中。为此,我们将使用一个名为 Mongoose 的库。

使用 Mongoose 持久化对象

请记住,与关系型数据库不同,MongoDB 不要求同一集合中的文档具有相同的字段。然而,我们通常期望集合中的大多数项目至少共享一个共同的字段核心。

Mongoose 是一个用于在 MongoDB 中存储实体的对象建模库。它帮助编写常见的功能,如验证、查询构建和类型转换。它还提供了将业务逻辑与我们的实体关联的钩子。这些功能与 ORM(如 Entity Framework 或 Hibernate)提供的一些功能类似。然而,Mongoose 本身不是一个 ORM。回想一下,对象关系映射对于文档数据库(如 MongoDB)来说是不相关的。

要使用 Mongoose,我们首先定义一个模式。这定义了 MongoDB 集合中文档的常见字段。回到前几章的演示应用程序,让我们安装 Mongoose 并为我们的游戏集合定义一个模式:

> npm install mongoose --save

以下代码添加到src/services/games.js中:

'use strict';

const mongoose = require('mongoose');

const Schema = mongoose.Schema;
const gameSchema = new Schema({
 word: String,
 setBy: String
});

模式定义了文档字段并指定了每个字段的类型。为了使用此模式开始持久化文档,我们需要创建一个模型

模型是与 MongoDB 集合对应的构造函数。Mongoose 模型的实例对应于该集合中的文档。模型还提供了修改集合的函数。我们通过指定模式和(单数)集合名称来创建模型:

const gameSchema = new Schema({
    word: String,
    setBy: String
});

const Game = mongoose.model('Game', gameSchema);

模型构造函数替换了之前的游戏类及其构造函数。这个类还包含两个实例方法:positionsOfremove。我们可以在模式上定义自定义实例方法,这些方法将在所有模型实例上可用。这些方法必须在创建模型之前定义:

const gameSchema = new Schema({
    word: String,
    setBy: String
});

gameSchema.methods.positionsOf = function(character) {
 let positions = [];
 for (let i in this.word) {
 if (this.word[i] === character.toUpperCase()) {
 positions.push(i);
 }
 }
 return positions;
};

const Game = mongoose.model('Game', gameSchema);

注意

注意,在前面的代码中,我们使用的是传统的函数定义,而不是箭头函数。这对于函数内部的this关键字正确工作来说是必要的。更多详情请见derickbailey.com/2015/09/28/do-es6-arrow-functions-really-solve-this-in-javascript/

我们不再需要定义remove方法,因为 Mongoose 会自动提供这个方法。它还提供了一个save方法,我们可以用它来持久化新的游戏:

const Game = mongoose.model('Game', gameSchema);

module.exports.create = (userId, word) => {
 let game = new Game({setBy: userId, word: word.toUpperCase()});
 return game.save();
};

我们不再需要指定 ID,因为 Mongoose 也会提供这个 ID。注意,我们确实需要指定word.toUpperCase(),这曾经位于游戏构造函数中。这不是问题,因为构造函数是模块私有的。模块外部的代码不能直接调用构造函数。toUpperCase调用发生的地方只是一个实现细节。

还要注意,Mongoose 的所有异步操作都返回承诺作为使用回调的替代方案。Mongoose 支持前一章中讨论的两种异步编程模式。Mongoose 使用自己的承诺实现。尽管如此,我们可以配置 Mongoose 使用 ECMAScript 6 承诺。我们还需要告诉 Mongoose 连接到 MongoDB 数据库。目前,我们将硬编码 URL,但我们将很快看到如何使其可配置:

const mongoose = require('mongoose');
mongoose.Promise = Promise;
mongoose.connect('mongodb://localhost/hangman');

最后,我们需要实现从数据库检索游戏的三种方法。我们可以使用 Mongoose 的 find 方法来完成此操作:

module.exports.create = (userId, word) => {
    ...
};
module.exports.createdBy =
 (userId) => Game.find({setBy: userId});
module.exports.availableTo =
 (userId) => Game.find({setBy: { $ne: userId } });
module.exports.get =
 (id) => Game.findById(id);

Mongoose 的 find 方法与我们在上一节中在 使用 MongoDB shell 中看到的 MongoDB find 方法类似。它接受一组 MongoDB 查询条件,并异步提供文档列表。findById 接受一个 ID 并异步提供一个单独的文档,或 null。

Mongoose 还提供了一个 where 方法,可以通过函数调用构建条件。availableTo 函数可以重写如下:

module.exports.availableTo =
  (userId) => Game.where('setBy').ne(userId);

只要您仍然在本地上运行 MongoDB(如本章前面在 MongoDB 入门 中所述),现在应该能够运行应用程序。尝试停止并重新启动应用程序,并注意游戏现在可以在重启之间持久化。

隔离持久化代码

将代码与真实数据库集成以验证我们的持久化代码是否正常工作是有用的。但并非总是适合让我们的测试依赖于外部 MongoDB 实例。

我们希望开发者能够检出代码并运行应用程序,而无需运行数据库实例。此外,外部依赖项会减慢我们的测试速度。MongoDB 在磁盘上存储数据,因此给我们的测试引入了额外的 I/O 工作。

应用程序在生产中应依赖于外部数据库。在集成测试中,我们希望在本地服务器上使用真实数据库。在开发机器上,默认使用内存数据库会更好。因此,我们需要能够配置数据库 URL,并在开发环境中回退到内存数据库。

最后,我们需要在使用我们的游戏服务之前初始化 Mongoose。这包括指定数据库 URL 并等待建立连接。这是异步发生的,因此不能成为游戏服务模块定义的一部分。我们也不想让游戏服务的客户端在每个函数调用中传递 Mongoose 实例。

通过在我们的应用程序中引入依赖注入,我们可以解决所有这些问题。我们将把游戏服务作为依赖项传递给需要它的模块,并将 Mongoose 作为依赖项传递给游戏服务。

小贴士

这也将给我们提供为传递给游戏服务本身的测试双胞胎的模块编写单元测试的选项,因此不要使用 MongoDB。在更大的应用程序中,这种测试隔离对于编写快速且可维护的测试非常重要。

Node.js 中的依赖注入

你可能已经使用过 .NET 或 Java 中的 依赖注入DI)框架,如 Unity、Autofac、NInject 或 Spring。这些框架提供了声明性配置和自动绑定依赖项的功能。JavaScript 也有类似的 DI 容器。然而,在 JavaScript 中,显式传递依赖项更为常见。JavaScript 的模块模式使这种方法比其他语言更自然。我们不需要添加很多字段和构造函数/属性来设置依赖项。我们只需将模块包裹在一个初始化函数中,该函数接受依赖项作为参数。

在我们的应用程序中,app 模块将连接一切。整个应用程序依赖于数据库。游戏和索引路由依赖于游戏服务。为了允许路由依赖游戏服务,我们只需要用函数来首尾包裹它们:

'use strict';

module.exports = (gamesService) => {
    var express = require('express');
    var router = express.Router();
    ...
 return router;
};

游戏服务本身稍微复杂一些。我们之前向module.exports添加了几个函数,所以我们需要将这些函数放在一个对象上。然而,这实际上使代码更短。此外,请注意,我们只在Game模式尚未定义的情况下创建它,以防止我们的导出函数被多次调用:

module.exports = (mongoose) => {
    'use strict';

 let Game = mongoose.models['Game'];

 if (!Game) {
        const Schema = mongoose.Schema;
        const gameSchema = new Schema({
            word: String,
            setBy: String
        });

        gameSchema.methods.positionsOf = function(character) {
            ...
        };

        Game = mongoose.model('Game', gameSchema);
 }

 return {
 create: (userId, word) => {
 const game = new Game({
 setBy: userId, word: word.toUpperCase()
 });
 return game.save();
 },
 createdBy: userId => Game.find({setBy: userId}),
 availableTo: userId => Game.where('setBy').ne(userId),
 get: id => Game.findById(id)
 };
};

最后,应用程序本身依赖于数据库连接,并连接其他依赖项:

module.exports = (mongoose) => {
    ...

 let gamesService = require('./services/games')(mongoose);
 let routes = require('./routes/index')(gamesService);
 let games = require('./routes/games')(gamesService);
    ...

 return app;
};

提供依赖项

我们可以在环境变量中指定数据库 URL。如果这个变量不存在,我们的应用程序将使用 MongoDB 的内存实例。这将由一个名为 Mockgoose 的库提供。我们将它作为一个开发依赖项安装,以防我们在生产服务器上忘记设置环境变量。如果发生错误,我们将不会安静地使用非持久性数据库。

> npm install mockgoose@~5.x --save-dev

我们在src/config/mongoose.js下创建一个新的模块来初始化 Mongoose,并返回一个当它连接到数据库时将解决的承诺:

'use strict';

const mongoose = require('mongoose');
const debug = require('debug')('hangman:config:mongoose');

mongoose.Promise = Promise;
if (!process.env.MONGODB_URL) {
    debug('MongoDB URL not found. Falling back to in-memory database...');
    require('mockgoose')(mongoose);
}

let db = mongoose.connection;
mongoose.connect(process.env.MONGODB_URL);
module.exports = new Promise(function(resolve, reject) {
    db.once('open', () => resolve(mongoose));
    db.on('error', reject);
});

现在我们只需要将这个模块传递给我们的应用程序。以下是从bin/www的代码:

...

require('../src/config/mongoose').then((mongoose) => {
 var app = require('../src/app')(mongoose);
    ...
    server.on('listening', onListening);
}).catch(function(error) {
 console.log(error);
 process.exit(1);
});

为了允许我们的测试运行,我们还需要添加新的before函数来使用这个模块。以下代码来自test/services/games.js

'use strict';

const expect = require('chai').expect;

describe('Game service', () => {
  const firstUserId = 'user-id-1';
  const secondUserId = 'user-id-2';

 let service;
 before(done => {
 require('../../src/config/mongoose.js').then((mongoose) => {
 service = require('../../src/services/games.js')(mongoose);
 done();
 }).catch(done);;
 });
  ...

以下代码来自test/routes/games.js

'use strict';

const request = require('supertest');
const expect = require('chai').expect;

describe('/games', () => {
  let agent, userId;
 let mongoose, gamesService, app;

 before(function(done) {
 require('../../src/config/mongoose.js').then((mongoose) => {
 app = require('../../src/app.js')(mongoose);
 gamesService =
 require('../../src/services/games.js')(mongoose);
 done();
 }).catch(done);
 });

    ...

我们还会添加一个全局清理函数,在所有测试完成后关闭数据库连接。这只是一个在任意describe块上下文之外的 Mocha after 钩子。我们在test/global.js下的一个新文件中添加这个函数:

'use strict';

after(function(done) {
    require('../src/config/mongoose.js').then(
        (mongoose) => mongoose.disconnect(done));
});

最后,我们需要更新我们的gulpfile.js,以便我们的集成测试能够运行新的依赖项:

gulp.task('integration-test',
        ['lint-integration-test', 'test'], function(done) {
    const TEST_PORT = 5000;

 require('./src/config/mongoose.js').then((mongoose) => {
 let server, teardown = (error) => {
 server.close(() =>
 mongoose.disconnect(() => done(error)));
 };
 server = require('http')
 .createServer(require('./src/app.js')(mongoose))
            .listen(TEST_PORT, function() {
                gulp.src('integration-test/**/*.js')
                    .pipe(
                        ...
                    )
 .on('error', teardown)
 .on('end', teardown)
            });
 });
});

我们现在可以在本地开发机器上运行我们的应用程序和测试,而无需运行 MongoDB,或者如果我们想使用真实的 MongoDB 实例,可以指定MONGO_DB环境变量。

在 Travis CI 上运行数据库集成测试

我们确实希望定期对我们的应用程序进行集成测试,以针对真实的 MongoDB 实例。幸运的是,Travis CI 作为其环境的一部分提供了各种数据存储。我们只需要将其添加到我们的travis.yml文件中,告诉它我们的构建需要 MongoDB。我们还需要设置MONGODB_URL环境变量,以便测试能够连接到数据库:

services:
 - mongodb
env:
 global:
 - MONGODB_URL=mongodb://localhost/hangman

现在我们可以使用适合开发机器和 CI 服务器的合适 MongoDB 实例来运行我们的应用程序以及单元和集成测试。

介绍 Redis

Redis 通常被归类为键值数据存储。Redis 将自己描述为数据结构存储。它提供了与大多数编程语言中找到的基本数据结构类似的数据存储类型。

为什么使用 Redis?

Redis 完全在内存中运行,这使得它非常快。这一点,加上其键值特性,使其非常适合用作缓存。它还支持发布/订阅通道,这使得它可以作为一个消息代理。我们将在第十章创建实时 Web 应用中进一步探讨。

更普遍地,Redis 可以作为有用的后端,允许多个 Node.js 进程相互协调。Node.js 水平扩展,大多数网站都会运行多个 Node.js 进程。许多网站有“工作”数据,这些数据不需要长期持久化,但需要在所有进程中快速且一致地可用。Redis 的内存特性和一系列原子操作使其非常适合这个用途。

Redis 更注重速度而不是持久性。有多种配置选项,但所有选项都预期在故障发生时会有一定程度的数据丢失。这是 Redis 为了速度而在内存中完全运行所做的妥协。可以通过在故障前不显著影响速度的情况下将数据丢失减少到最多一秒前的写入,来减少数据丢失。Redis 可以通过在每次操作后同步到磁盘来完全最小化数据丢失,但这会对性能产生更大的影响,并抵消了 Redis 内存特性的优势。

安装 Redis

Redis 的源代码分发可以从redis.io/download获取。

对于 Windows 系统,下载预构建的二进制文件更有用。它可以通过 NuGet 和 Chocolatey 作为签名包提供。如果你有 Chocolatey,可以通过运行以下命令来安装 Redis:

> choco install redis-64

或者,你可以从github.com/MSOpenTech/redis/releases下载未签名的安装程序版本。

安装完成后,可以通过运行redis-server来启动 Redis。在另一个窗口中,运行redis-cli以连接到服务器并运行命令。

将 Redis 用作键值存储

Redis 中的所有内容都是针对键进行存储的。Redis 中的键可以是任何二进制数据,但最好将它们视为字符串。各种类型的值可以存储在每个键下。

Redis 将简单的标量值称为字符串。Redis 还对标量整数有特殊处理。以下示例设置和更新名为counter的键:

127.0.0.1:6379> set counter 100
OK
127.0.0.1:6379> get counter
"100"
127.0.0.1:6379> incr counter
(integer) 101

这个增量操作是原子的。Redis 还支持原子地设置值。以下命令将失败,因为键已经存在:

127.0.0.1:6379> set counter 200 nx
(nil)

这些功能可以帮助服务器之间的协调。Redis 还支持为键设置过期时间。这使得提供类似于 memcache 的缓存行为成为可能。然而,Redis 的灵活性更大,我们将在下一节中看到。

在 Redis 中存储结构化数据

除了简单的键值对之外,Redis 还支持其他更结构化的数据类型。

列表是有序的值集合。它们作为链表而不是数组存储。这使得在列表的末尾添加/删除元素效率更高(以牺牲通过索引检索列表项的速度为代价),例如:

127.0.0.1:6379> rpush fruit apple banana pear
(integer) 3
127.0.0.1:6379> rpop fruit
"pear"
127.0.0.1:6379> lpush fruit orange
(integer) 3
127.0.0.1:6379> lrange fruit 0 -1
1) "orange"
2) "apple"
3) "banana"

注意,lrange接受起始和结束索引。负值从列表的末尾开始计数,因此-1指的是最后一个元素。能够从列表的两端进行 push/pop 意味着它们可以用作栈或队列,例如,允许进程以生产者-消费者模式进行通信。

哈希是一组字段-值对。它们不如 MongoDB 文档丰富,但允许我们将一些数据关联在一起。例如,我们可以使用 Redis 实现我们的游戏服务:

127.0.0.1:6379> hmset game:2 word JavaScript setBy user-id-7
OK
127.0.0.1:6379> hget game:2 word
"JavaScript"
127.0.0.1:6379> hgetall game:2
1) "word"
2) "JavaScript"
3) "setBy"
4) "user-id-7"

注意,这里的顶级键game:2只是一个约定。对于开发者来说,以这种方式对键进行命名空间可能很有用,但 Redis 只将它们视为字符串。

集合是无序的值集合,例如:

127.0.0.1:6379> sadd numbers one two three
(integer) 3
127.0.0.1:6379> smembers numbers
1) "two"
2) "three"
3) "one"

集合支持数学运算,如并集和交集。它们还支持检索(可选原子删除)随机元素。

有序集合是带有数值分数的值集合:

127.0.0.1:6379> zadd votes 3 Aye
(integer) 1
127.0.0.1:6379> zadd votes 4 No
(integer) 1
127.0.0.1:6379> zadd votes 1 Abstain
(integer) 1
127.0.0.1:6379> zrevrange votes 0 1
1) "No"
2) "Aye"

注意,默认情况下,范围是有序的最小到最大。我们请求一个反向范围,以首先获取具有最高分数的元素。有序集合对于实现投票系统(如前所述)或排名系统很有用。

使用 Redis 构建用户排名系统

我们希望能够根据用户完成的游戏数量来对用户进行排名。我们将创建一个用户服务,该服务在 Redis 中实现,并提供以下功能:

  • 记录用户成功完成游戏的时间

  • 返回网站上的前三个用户

  • 返回指定用户的排名

我们将首先添加一个功能,通过允许用户选择一个屏幕名来使网站更加用户友好。

从 Node.js 使用 Redis

首先,我们需要安装一个用于 Redis 的 Node.js 客户端库。我们还将使用 promise 库 Bluebird 将 Redis 客户端库转换为 promise:

> npm install redis --save
> npm install bluebird --save

首先,我们将创建一个模块来配置 Redis 客户端,如下所示在 src/config/redis.js

'use strict';

const bluebird = require('bluebird');
const redis = require('redis');
bluebird.promisifyAll(redis.RedisClient.prototype);
module.exports = redis.createClient(process.env.REDIS_URL);

现在,我们可以在 src/services/users.js 中创建一个新的用户服务,其中包含获取和设置用户名的方法:

'use strict';

let redisClient = require('../config/redis.js');

module.exports = {
    getUsername: userId =>
        redisClient.getAsync(`user:${userId}:name`),
    setUsername: (userId, name) =>
        redisClient.setAsync(`user:${userId}:name`, name)
};

注意,Redis 客户端为每个 Redis 命令提供了函数(如 getset)。Bluebird 提供了每个函数的基于 promise 的版本,后缀为 Async

当然,现在我们为我们的项目有了测试基础设施,我们应该在编写新代码时添加测试,如下所示 test/services/users.js

'use strict';

const expect = require('chai').expect;
const service = require('../../src/services/users.js');

describe('User service', function() {
    describe('getUsername', function() { 
        it('should return a previously set username', done => {
            const userId = 'user-id-1';
            const name = 'User Name';
            service.setUsername(userId, name)
                .then(() => service.getUsername(userId))
                .then(actual => expect(actual).to.equal(name))
                .then(() => done(), done);
        });

        it('should return null if no username is set', done => {
            const userId = 'user-id-2';

            service.getUsername(userId)
                .then(name => expect(name).to.be.null)
                .then(() => done(), done);
        });
    });
});

使用 redis-js 进行测试

就像我们对游戏服务的测试一样,我们希望能够与 CI 服务器上的 Redis 实例集成。但我们不希望为开发引入任何新的依赖项。这次,我们将使用一个名为 redis-js 的库进行本地测试。与 Mockgoose 不同,它不使用真实 DB 引擎的内存版本(Redis 已经是内存中的)。相反,这是一个 Node.js Redis 客户端的重新实现,它将所有数据存储在进程内:

> npm install redis-js --save-dev

现在,我们可以创建一个模块来获取适合环境的 Redis 引用,如下所示 src/config/redis.js

'use strict';

const bluebird = require('bluebird');
const debug = require('debug')('hangman:config:redis');

if (process.env.REDIS_URL) {
 let redis = require('redis');
 bluebird.promisifyAll(redis.RedisClient.prototype);
 module.exports = redis.createClient(process.env.REDIS_URL);
} else {
 debug('Redis URL not found. Falling back to mock DB...');
 let redisClient = require('redis-js');
 bluebird.promisifyAll(redisClient);
 module.exports = redisClient;
}

注意,与 Mongoose 不同,Node.js Redis 客户端可以立即使用。在它连接之前发出的任何命令实际上都是内部排队。这意味着我们只需从模块中返回客户端并直接 require 它。在这种情况下使用 Mongoose 的依赖注入不会有任何好处。

我们还需要将 Redis 添加到我们的 .travis.yml 文件中,以便它在 CI 服务器上运行:

services:
 - mongodb
 - redis-server
env:
  global:
    - MONGODB_URL=mongodb://localhost/hangman
 - REDIS_URL=redis://127.0.0.1:6379/

最后,一旦我们的测试完成,我们需要关闭客户端,就像我们使用 Mongoose 一样。我们还确保在启动时清空数据库(因为我们没有通过服务接口删除用户数据的方法,就像我们在游戏中做的那样)。以下代码来自 test/global.js

'use strict';

before(function(done) {
 require('../src/config/redis.js').flushdbAsync().then(() => done());
});

after(function(done) {
 require('../src/config/redis.js').quit();
    require('../src/config/mongoose.js').then(
        (mongoose) => mongoose.disconnect(done));
});

以下代码来自 gulpfile.js

        let server, teardown = (error) => {
 require('./src/config/redis.js').quit();
            server.close(() =>
                mongoose.disconnect(() => done(error)));
        };

使用 Redis 实现用户排名

现在我们已经准备好将用户排名功能添加到我们的服务中。以下代码来自 src/services/users.js

module.exports = {
  ...

  recordWin: userId =>
    redisClient.zincrbyAsync('user:wins', 1, userId),

  getTopPlayers: () =>
    redisClient.zrevrangeAsync('user:wins', 0, 2, 'withscores')
    .then(interleaved => {
      if (interleaved.length === 0) {
        return [];
      }
      let userIds = interleaved
        .filter((user, index) => index % 2 === 0)
        .map((userId) => `user:${userId}:name`);
      return redisClient.mgetAsync(userIds)
        .then(names => names.map((username, index) => ({
          name: username,
          userId: interleaved[index * 2],
          wins: parseInt(interleaved[index * 2 + 1], 10)
        })));
    }),

  getRanking: userId => {
    return Promise.all([
      redisClient.zrevrankAsync('user:wins', userId),
      redisClient.zscoreAsync('user:wins', userId)
    ]).then(out => {
      if (out[0] === null) {
        return null;
      }
      return { rank: out[0] + 1, wins: parseInt(out[1], 10) };
    });
  }
};

在这里使用的 Redis 命令大多数都来自本章前面的内容。最有趣的功能是 getTopPlayers。它使用带有 withscores 选项的 zrevrange。这返回一个包含用户 ID 和分数的数组(交错在一起)。我们使用 mget(多值获取)向数据库发出第二个请求,以检索所有用户的名称。一旦返回,我们就可以将每个用户的所有数据组合成一个对象。

利用用户服务

将此功能连接到我们的应用程序的其他部分不使用我们之前没有见过的技术,因此为了简洁起见,省略了打印的代码列表。完整的实现可以在本章的配套代码中找到,包括对用户服务其他方法的测试,在 github.com/NodeJsForDevelopers/chapter09

关于安全性的说明

我们一直使用 MongoDB 和 Redis 的默认出厂设置运行。这对于开发目的来说是不错的。将这些服务部署到生产环境中需要考虑额外的安全问题。您可以在docs.mongodb.com/manual/administration/security-checklist/redis.io/topics/security找到更多资源。

摘要

在本章中,我们已经了解了不同类型数据库之间的区别,并学习了 MongoDB 和 Redis 的关键特性。我们还使用这些数据库持久化我们的应用程序数据,并使用依赖注入使我们的应用程序更加灵活。我们还学习了如何配置我们的开发和集成环境以使用适当的数据库实例。

持久性可能被视为我们系统的底层。在下一章中,我们将引入实时客户端/服务器通信到我们的应用程序中。这种前端功能意味着更多地关注我们系统的顶层。然而,我们也会看到 Redis 在支持这一功能中扮演着重要的角色。

创建实时 Web 应用

互联网为用户提供了一种越来越动态和交互式的用户体验。在整个 90 年代,大部分互联网由静态页面或服务器端渲染的页面组成。框架和 iframe 使得以有限的方式重新加载页面成为可能。当 Ajax 在 2005 年中期出现时,它使得页面变得更加引人入胜。客户端 JavaScript 现在可以按需从服务器请求数据并动态更新页面。

实时 Web 应用是这一演变的下一步。这些应用中,服务器无需客户端发起请求即可向客户端推送数据。这允许用户被通知新信息,或者用户可以实时相互交互。

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

  • 在客户端和服务器之间建立双向通信通道

  • 为我们的应用添加实时交互性

  • 在多个服务器上扩展我们的实时应用的后端

第十章:了解实时通信的选项

实时 Web 应用需要在客户端和服务器之间建立一个双向通信通道。这是任何持久连接,允许服务器在需要时向客户端推送额外的数据。WebSockets 协议是这种通信的现代标准,并被大多数浏览器实现。

WebSocket 连接通过 HTTP 发起,但除此之外不依赖于它。WebSocket 协议定义了一种在 TCP 连接上双向发送消息的方式。TCP 是通常位于 HTTP 之下的低级传输协议。WebSockets 仍然是一种相对较新的技术,并非所有客户端和服务器都完全支持。今天,大多数现代 Web 浏览器都支持 WebSockets。然而,中间服务器(代理、防火墙和负载均衡器)可能会阻止 WebSocket 连接正常工作(要么是因为不支持,要么是有意阻止非 HTTP 流量)。在这些情况下,有其他方法可以实现实时通信。

EventSource 标准定义了一种服务器通过 HTTP 向客户端发送事件的方式,并定义了一个 JavaScript API 来处理这些事件。它不如 WebSockets 高效或广泛支持,但一些较旧的服务器和客户端对其支持更好。

最终的回退方案是长轮询。这是当客户端向服务器发起一个普通(Ajax)请求时,该请求保持打开状态,直到服务器有数据要发送。一旦客户端收到任何数据,它就会向服务器发起另一个请求以获取下一条消息。这相比 WebSockets 引入了额外的带宽开销和延迟,但它具有最广泛的支持,因为它仅使用普通的 HTTP 请求。

理想情况下,客户端和服务器可以协商以确定使用最佳可用类型的连接。尽管这个过程可能相当复杂。幸运的是,有一些库可以为我们处理这些。

介绍 Socket.IO

Socket.IO 是一个成熟且稳定的库,具有出色的跨浏览器支持。它旨在以跨浏览器兼容的方式快速且可靠地建立双向通信通道。它提供了一个基于惯用 JavaScript 事件的抽象层,用于客户端和服务器之间通过此通道进行实时通信。如果你曾经使用过.NET 中的 SignalR,你可以将 Socket.IO 视为其 JavaScript 等价物。

使用 Socket.IO 实现聊天室

让我们为我们的应用程序用户实现一个聊天大厅,以便他们可以互相交谈。首先,我们需要安装 Socket.IO:

> npm install --save socket.io

服务器端的实现非常简单。我们只需要告诉 Socket.IO,每当用户发送聊天消息时,我们希望将此消息广播给所有已连接的用户,如下所示src/realtime/chat.js

'use strict';

module.exports = io => {
    io.on('connection', (socket) => {
       socket.on('chatMessage', (message) => {
           io.emit('chatMessage', message);
        });
    });
 };

在这里,我们向 Socket.IO 的connection事件添加一个监听器。每当一个新的客户端连接到应用程序时,我们的监听器就会被触发。socket变量代表与该特定客户端的连接。

之前显示的io参数将是一个 Socket.IO 实例。要创建一个这样的实例,我们需要提供一个引用 HTTP 服务器,该服务器将托管我们的应用程序,以便 Socket.IO 可以添加它自己的连接处理。为了使事情更整洁,我们将在src/server.js中添加一个新的server模块来设置我们的服务器,启动我们的 Express 应用程序,并初始化 Socket.IO:

'use strict';

module.exports = require('./config/mongoose').then(mongoose => {
    const app = require('../src/app')(mongoose);
    const server = require('http').createServer(app);
    const io = require('socket.io')(server);
    require('./realtime/chat')(io);

    server.on('close', () => { 
        require('../src/config/redis.js').quit();
        mongoose.disconnect();
    });
    return server;
});

这也允许我们简化启动脚本和我们的集成测试,如bin/www中的所示:

#!/usr/bin/env node

var debug = require('debug')('hangman:server');
var port = normalizePort(process.env.PORT || '3000');
require('../src/server').then((server) => {
    server.listen(port);
    server.on('error', onError);
 server.on('listening', onListening.bind(server));
}).catch(function(error) {
    debug(error);
    process.exit(1);
});

...

function onListening() {
 var addr = this.address();
  ...
}

...以及在gulpfile.js中:

gulp.task('integration-test',
         ['lint-integration-test', 'test'], done => {
  const TEST_PORT = 5000;

 require('./src/server.js').then((server) => {
 server.listen(TEST_PORT);
 server.on('listening', () => {
      gulp.src('integration-test/**/*.js')
        .pipe(
          ...
        }))
 .on('error', error => server.close(() => done(error)))
 .on('end', () => server.close(done))
    });
  });
});

现在,我们需要添加客户端代码来与这个服务进行通信。首先,我们将为我们的聊天大厅添加一个位置到应用程序的主页,如下所示src/views/index.hjs

    {{/topPlayers}}
    </ol>
 <hr/>
 <h3>Lobby</h3>
 <form class="chat">
 <div id="messages"></div>
 <input id="message"/><input type="submit" value="Send"/>
 </form>
  </body>
</html>

现在,我们将创建客户端脚本以将其与服务器连接,如下所示src/public/scripts/chat.js

$(document).ready(function() {
    'use strict';
    var socket = io();

    $('form.chat').submit(function(event){
        socket.emit('chatMessage', $('#message').val());
        $('#message').val('');
        event.preventDefault();
    });

    socket.on('chatMessage', function(message){
        $('#messages').append($('<p>').text(message));
    });
});

最后,我们需要在页面中包含我们的新脚本,并包含定义前面io函数的 Socket.IO 客户端脚本src/view/index.hjs

<!DOCTYPE html>
<html>
  <head>
    <title>{{ title }}</title>
    <link rel="stylesheet" href="/stylesheets/style.css" />
    ...
    <script src="img/index.js"></script>
 <script src="img/socket.io.js"></script>
 <script src="img/chat.js"></script>
  </head>
  <body>
    ...

注意,我们还没有在任何地方创建socket.io.js脚本。这是通过在src/server.js中将 Socket.IO 附加到我们的服务器来提供的。由于我们没有在我们的脚本中定义io变量,我们需要让 ESLint 知道它作为一个全局变量存在,如gulpfile.js中所示:

gulp.task('lint-client', function() {
     return gulp.src('src/public/**/*.js')
         .pipe(eslint({ envs: [ 'browser', 'jquery' ],
 globals: { io: false } }))
         .pipe(eslint.format())
         .pipe(eslint.failAfterError());
 });  

现在,如果我们打开两个浏览器窗口中的我们的应用程序,它们可以互相发送聊天消息!

扩展实时 Node.js 应用程序

由于我们的聊天消息是通过服务器中继的,因此客户端目前只能与连接到同一服务器的其他客户端通信。如果我们想将应用程序水平扩展到多个服务器,这将是一个问题。

这很容易修复,但很难演示。为了做到这一点,我们需要运行我们应用程序的两个独立实例。如果它们还使用相同的共享数据库进行持久化,这将更加真实和有用。因此,我们需要启动 MongoDB 和 Redis,然后在不同的端口上启动我们应用程序的两个实例(这样它们就不会冲突)。

这意味着运行以下所有命令(根据你的设置替换 MongoDB 的 dbpath):

> redis-server
> mongod --dbpath C:\data\mongodb
> set MONGODB_URL=mongodb://localhost/hangman
> set REDIS_URL=redis://127.0.0.1:6379/
> set PORT=3000
> npm start
> set PORT=3001
> npm start

启动数据库或应用程序服务器的命令也占据了当前的控制台。因此,为了能够运行所有这些命令,我们需要在单独的窗口中执行它们,或者告诉它们在后台执行。在 Windows 上,可以通过以下批处理脚本来实现:

@echo off
START /B redis-server
START /B mongod --dbpath C:\data\mongodb
set MONGODB_URL=mongodb://localhost/hangman
set REDIS_URL=redis://127.0.0.1:6379/
SLEEP 2
set PORT=3000
START /B npm start
SLEEP 1
set PORT=3001
START /B npm start

现在,你可以将不同的浏览器连接到不同的应用程序实例,地址为http://localhost:3000http://localhost:3001。注意,连接到同一应用程序实例的两个客户端可以互相接收消息,但不能从其他应用程序实例的客户端接收消息。

为了解决这个问题,我们需要一个共享的后端,通过这个后端所有应用程序都可以进行通信。Redis 是这一点的完美候选人。

使用 Redis 作为后端

Socket.IO 使用适配器模式来支持不同的后端。适配器只是将一个接口转换为另一个接口的包装器。Socket.IO 有一个标准的后端接口和多种适配器,允许不同的实现与该接口一起工作。默认情况下,它使用一个内存适配器,限制在单个进程中。然而,Socket.IO 项目还提供了一个适配器,用于将 Redis 作为后端:

> npm install socket.io-redis --save

一旦安装,使用这个中间件只需告诉 Socket.IO 在哪里可以找到我们的 Redis 实例(我们在只有一个应用程序进程的测试环境中跳过这一步)如下所示src/server.js

'use strict';

module.exports = require('./config/mongoose').then(mongoose => {
    const app = require('../src/app')(mongoose);
    const server = require('http').createServer(app);
    const io = require('socket.io')(server);

 if (process.env.REDIS_URL && process.env.NODE_ENV !== 'test') {
 const redisAdapter = require('socket.io-redis');
 io.adapter(redisAdapter(process.env.REDIS_URL));
 }

    require('./realtime/chat')(io);

    ...
    return server;
 });

就这样!我们不需要对我们的代码进行任何其他更改来支持可扩展性。如果你现在重新启动你的应用程序实例,你应该会发现客户端可以在它们之间进行通信。

将 Socket.IO 与 Express 集成

到目前为止,除了共享同一个服务器外,我们的应用程序的 Socket.IO 和 Express 部分是完全独立的。虽然它们松散耦合是好事,但一些横切关注点可能对两者都相关。

例如,我们的应用程序的两个部分应该有一种相互一致的方式来识别当前用户。如果它们要共同提供单一连贯的用户体验,这一点尤为重要。

首先,让我们扩展我们的用户中间件,通过在用户服务中查找它们来提供当前用户的名称以及它们的 ID,如下所示src/middleware/users.js

'use strict';

module.exports = (service) => {
    const uuid = require('uuid');

    return function(req, res, next) {
        let userId = req.cookies.userId;
        if (!userId) {
            userId = uuid.v4();
            res.cookie('userId', userId);
            req.user = {
                id: userId
            };
            next();
 } else {
 service.getUsername(userId).then(username => {
 req.user = {
 id: userId,
 name: username
 };
 next();
 });
 }
    };
};

小贴士

你可以在本书的配套代码中找到这个中间件的更新测试。

这意味着将我们的用户服务作为依赖项注入,就像我们在src/app.js中为其他中间件模块(即路由)所做的那样:

  ...

  let gamesService = require('./service/games')(mongoose);
  let usersService = require('./service/users');

 let users = require('./middleware/users')(usersService);
  let routes = require('./routes/index')(gamesService, usersService);
  let games = require('./routes/games')(gamesService, usersService);
  let profile = require('./routes/profile')(usersService);
  ...

有趣的部分是允许 Socket.IO 使用这个中间件。Socket.IO 有自己的中间件概念,与 Express 非常相似。回想一下,Express 中间件函数接受当前请求、响应和一个 next 回调的参数。Socket.IO 中间件函数只接受一个通信套接字和一个 next 回调。然而,我们可以访问启动套接字的原始 HTTP 握手。这允许我们将 Express 中间件适配到 Socket.IO 中间件,并如下使用它,在 src/server.js

'use strict';

module.exports = require('./config/mongoose').then(mongoose => {
    let app = require('../src/app')(mongoose);
    let server = require('http').createServer(app);
    let io = require('socket.io')(server);

    if (process.env.REDIS_URL) {
        let redisAdapter = require('socket.io-redis');
        io.adapter(redisAdapter(process.env.REDIS_URL));
    }

 io.use(adapt(require('cookie-parser')()));
 const usersService = require('./services/users.js');
 io.use(adapt(require('./middleware/users')(usersService)));

    require('./realtime/chat')(io);

    ...
    return server;
}); 

function adapt(expressMiddleware) {
 return (socket, next) => {
 expressMiddleware(socket.request, socket.request.res, next);
 };
}

现在用户中间件将同时适用于 Socket.IO 和常规 HTTP 请求,使用户数据对 Socket.IO 也可用。让我们使用这个来在我们的聊天中包含用户名。首先,我们需要更新我们的服务器,如下所示 src/realtime/chat.js

'use strict';

module.exports = io => {
    io.on('connection', (socket) => {
        socket.on('chatMessage', (message) => {
 io.emit('chatMessage', {
 username: socket.request.user.name,
 message: message
 });
        });
    });
 }

注意到 Socket.IO 允许我们发送对象而不是简单的字符串作为事件负载。现在我们只需要在客户端使用它,如下所示 src/public/scripts/chat.js

$(document).ready(function() {
    'use strict';

    var socket = io();
    ...
 socket.on('chatMessage', function(data){
        $('#messages').append(
 $('<p>').text(data.message)
 .prepend($('<b>').text(data.username)));
});

如果你现在在单独的浏览器会话中打开应用程序并指定不同的用户名,你将看到这些用户名在聊天输出中。

指导 Socket.IO 消息

现在我们有了用户名访问权限,我们也可以宣布用户在大厅的到达。我们可以通过扩展我们的 Socket.IO 连接事件处理器来实现,如下所示 src/realtime/chat.js

'use strict';

module.exports = io => {

    io.on('connection', (socket) => {
 const username = socket.request.user.name;

 if(username) {
 socket.broadcast.emit('chatMessage', {
 username: username,
 message: 'has arrived',
 type: 'action'
 });
 }

        socket.on('chatMessage', (message) => {
            io.emit('chatMessage', {
 username: username,
                message: message
            });
        });
    });
 }

在这里,我们使用 socket.broadcast.emit 而不是 io.emit 来将事件发送给除了当前 socket 之外的所有客户端。请注意,我们还在消息中添加了额外的数据。这次我们添加了一个 type 字段(对于到达消息设置为 'action'),以便对不同类型的消息进行不同的视觉呈现。我们可以通过更新我们的客户端代码,根据消息类型设置额外的 CSS 类来实现,如下所示 src/public/scripts/chat.js

    socket.on('chatMessage', function(data){
        $('#messages').append(
 $('<p>').text(data.message).addClass(data.type)
                .prepend($('<b>').text(data.username)));
    });

小贴士

你可以在配套代码中找到示例应用的 CSS 文件。

让我们还要强制要求用户在选择参与聊天之前必须选择一个用户名,如下所示 src/realtime/chat.js

'use strict';

module.exports = io => {
    io.on('connection', (socket) => {
        ...
        socket.on('chatMessage', (message) => {
 if (!username) {
 socket.emit('chatMessage', {
 message: 'Please choose a username',
 type: 'warning'
 });
 } else {
                io.emit('chatMessage', {
                    username: username,
                    message: message
                });                
 }
        });
    });
 }

这里,我们使用 socket.emit 而不是 io.emit 来向与当前套接字关联的客户端发送消息。

测试 Socket.IO 应用程序

现在让我们看看如何测试我们的聊天模块。为了从我们的测试中与之通信,我们需要一个 Socket.IO 客户端。Socket.IO 项目为此提供了一个额外的包:

> npm install socket.io-client --save-dev

我们的测试基础设施包括设置一个服务器和多个客户端,如下所示 test/realtime/chat.js

'use strict';
describe('chat', function() {
    const expect = require('chai').expect;
    let server, io, url, createUser, createdClients = [];

    beforeEach(done => {
        server = require('http').createServer();

        server.listen((err) => {
            if (err) {
                done(err);
            } else {
                const addr = server.address();
                url = 'http://localhost:' + addr.port + '/chat'; 

                io = require('socket.io')(server);
                require('../../src/realtime/chat.js')(io);

                done();
            }
        });
    });

    afterEach(done => {
        createdClients.forEach(client => client.disconnect());
        server.close(done);
    });

    const createClient = require('socket.io-client');
    createUser = (name, room) => {
        let user = {
            name: name,
            client: createClient(url)
        };
        createdClients.push(user.client);
        return user;
    };
});

在这里,我们创建了一个没有指定地址的 HTTP 服务器,这样操作系统就会为我们分配一个可用的端口。然后我们使用这个服务器来托管我们的聊天实现。

由于我们独立运行聊天模块,我们没有用户中间件可用,因此需要一种替代方式来提供用户名。我们可以在测试中使用一个存根中间件直接从头部读取用户名:

'use strict';

describe('chat', function() {
    const expect = require('chai').expect;
    let server, io, url, createUser, createdClients = [];

    beforeEach(done => {
        server = require('http').createServer();

        server.listen((err) => {
            if (err) {
                done(err);
            } else {
                const addr = server.address();
                url = 'http://localhost:' + addr.port;

                io = require('socket.io')(server);
 io.use((socket, next) => {
 socket.request.user = {
 name: socket.request.headers.username
 };
 next();
 });

                require('../../src/realtime/chat.js')(io);

                done();
            }
        });
    });

    ...

    const createClient = require('socket.io-client');
    createUser = (name, room) => {
 let headers = {};
 if (name) {
 headers.username = name;
 }

        let user = {
            name: name,
 client: createClient(url, { extraHeaders: headers})
        };
        createdClients.push(user.client);
        user.client.emit('joinRoom', room);

        return user;
    };
});

现在我们准备实现我们的测试。前两个,对于从服务器发起的消息,相当简单:

    it('warns unnamed users to choose a username', done => {
        let unnamedUser = createUser();
        unnamedUser.client.emit('chatMessage', 'Hello!');
        unnamedUser.client.on('chatMessage', (data) => {
            expect(data.message).to.contain('choose a username');
            expect(data.username).to.be.undefined;
            expect(data.type).to.equal('warning');
            done();
        });
    });

    it('broadcasts arrival of named users', done => {
        let connectedUser = createUser();
        let newUser = createUser('User1');
        connectedUser.client.on('chatMessage', (data) => {
            expect(data.message).to.contain('arrived');
            expect(data.username).to.equal(newUser.name);
            expect(data.type).to.equal('action');
            done();
        });
    });

测试客户端之间发送的消息需要更多注意来捕获每个客户端接收消息的情况:

    it('emits messages from named users back to all users', done => {
        let namedUser = createUser('User1');
        let otherUser = createUser();
        let messageReceived = function(data) {
            this.received = data;
            if (namedUser.received && otherUser.received) {
                [namedUser.received, otherUser.received]
                .forEach(received => {
                    expect(received.message).to.equal('Hello!');
                    expect(received.username)
                        .to.equal(namedUser.name);
                });
                done();
            }
        };
        otherUser.client.on('chatMessage',
                            messageReceived.bind(otherUser));
        namedUser.client.on('chatMessage',
                            messageReceived.bind(namedUser));
        namedUser.client.emit('chatMessage', 'Hello!');
    });

组织 Socket.IO 应用程序

现在我们已经在应用程序的索引页上有一个聊天大厅,用户必须重新加载页面(并丢失聊天历史)才能了解新的游戏。我们可以使用 Socket.IO 来更新这些内容。

公开模型的实时更新

首先,我们需要我们的游戏服务本身来公开添加或删除游戏时的事件。在这里,我们使用 Mongoose 提供的 post 方法来挂钩游戏上的持久化操作,如这里所示 src/services/games.js

'use strict';

const EventEmitter = require('events');
const emitter = new EventEmitter();

module.exports = (mongoose) => {
    let Game = mongoose.models['Game'];

    if (!Game) {
        let Schema = mongoose.Schema;
        let gameSchema = new Schema({
            word: String,
            setBy: String
        });

        ...

 gameSchema.post('save', game =>
 emitter.emit('gameSaved', game));
 gameSchema.post('remove', game =>
 emitter.emit('gameRemoved', game));

        Game = mongoose.model('Game', gameSchema);
    }

    return {
        ...
        get: id => Game.findById(id),
 events: emitter
    };
};

module.exports.events = emitter;

我们公开一个 事件发射器,允许其他模块订阅添加或删除游戏时的事件。事件发射器是 Node.js 的内置功能,提供了一种简单的方式来公开自定义事件。请注意,Mongoose 的 Schema 类本身就是一个事件发射器,所以我们可以直接公开它。然而,这将泄露有关我们游戏服务实现的细节。

小贴士

再次,你可以在配套代码中找到这些更改的新测试。

使用命名空间组织 Socket.IO 应用程序

实时聊天和实时更新游戏列表是我们应用程序中相当不同的功能区域。Socket.IO 提供了 命名空间,允许我们组织事件。这允许我们仍然使用客户端和服务器之间的单个连接,而无需担心不同功能区域之间的事件名称冲突。当应用程序变得更大、更复杂时,这非常有用。

将我们的聊天功能放在一个命名空间下,对客户端和服务器(以及我们的测试)来说是一个非常简单的更改。

以下代码来自 src/public/scripts/chat.js

$(document).ready(function() {
    'use strict';
 var socket = io('/chat');
    ...

以下代码来自 src/realtime/chat.js

'use strict';

module.exports = io => {
 const namespace = io.of('/chat');

 namespace.on('connection', (socket) => {
         ...

         socket.on('chatMessage', (message) => {
             if (!username) {
                 ...
             } else {
 namespace.emit('chatMessage', {
                     username: username,
                     message: message
                 });
             }
         });
     });
 };

以下代码来自 test/realtime/chat.js

                const addr = server.address();
 url = 'http://localhost:' + addr.port + '/chat';

现在,我们可以添加一个新的 Socket.IO 模块来公开游戏的变化。这只需要将我们的游戏服务的事件转发给连接的 Socket.IO 客户端。

我们在 src/realtime/games.js 下添加以下代码:

'use strict';

module.exports = (io, service) => {
    io.of('/games').on('connection', (socket) => {
        forwardEvent('gameSaved', socket);
        forwardEvent('gameRemoved', socket);
    });

    function forwardEvent(name, socket) {
        service.events.on(name, game => {
            if (game.setBy !== socket.request.user.id) {
                socket.emit(name, game.id);
            }
        });
    }
};

我们还需要在服务器初始化时包含此模块。

以下代码来自 src/server.js

'use strict';

module.exports = require('./config/mongoose').then(mongoose => {
    ...

    require('./realtime/chat')(io);
 const gamesService = require('./services/games.js')(mongoose);
 require('./realtime/games')(io, gamesService);

    ...
    return server;
});

相应的客户端只需连接到 /games 命名空间并相应地更新列表。

以下代码来自 src/public/scripts/index.js

    var socket = io('/games');
    var availableGames = $('#availableGames');

    socket.on('gameSaved', function(game) {
        availableGames.append(
            '<li id="' + game + '"><a href="/games/' + game + '">' +
                game + '</a></li>');
    });
    socket.on('gameRemoved', function(game) {
        $('#' + game).remove();
    });

以下代码添加到 src/views/index.hjs 中:

    <h3>Games available to play</h3>
    <ul id="availableGames">
      {{#availableGames}}
 <li id="{{id}}"><a href="/games/{{id}}">{{id}}</a></li>
      {{/availableGames}}
    </ul>

小贴士

实际上,最好使用客户端 MV* 库,如 Knockout 或 Backbone,根据模型更改更新页面,而不是像这样操作 DOM,但这超出了本书的范围。

现在,如果你在两个不同的浏览器会话中打开应用程序并在一个浏览器窗口中创建一个新的游戏,它将立即出现在另一个窗口中。

使用房间分区 Socket.IO 客户端

在本章中,我们将添加的最后一个功能是允许玩相同游戏的用户之间进行交流。我们可以重用我们已编写的聊天功能。然而,我们希望主页上的大厅和每个游戏都有一个独立的聊天。

Socket.IO 提供了房间功能,用于将消息定向发送到不同的客户端组。请记住,命名空间允许我们将应用程序划分为不同的功能区域。房间功能使我们能够在同一功能区域内对客户端进行划分。

Socket.IO 中的房间只是字符串标识符,我们使用 socket.join 函数将客户端添加到房间中。我们将引入一个新的 joinRoom 事件,允许我们的客户端请求服务器将其添加到特定的房间。服务器端将按以下方式响应此事件:

以下代码来自 src/realtime/chat.js

'use strict';

module.exports = io => {
    const namespace = io.of('/chat');

    namespace.on('connection', (socket) => {
        const username = socket.request.user.name;

 socket.on('joinRoom', (room) => {
 socket.join(room);
            if (username) {
 socket.broadcast.to(room).emit('chatMessage', {
                    username: username,
                    message: 'has arrived',
                    type: 'action'
                });
            }

            socket.on('chatMessage', (message) => {
                if (!username) {
                    ...
                } else {
 namespace.to(room).emit('chatMessage', {
                        username: username,
                        message: message
                    });
                }
            });

 socket.on('disconnect', () => {
 if (username) {
 socket.broadcast.to(room).emit('chatMessage', {
 username: username,
 message: 'has left',
 type: 'action'
 });
 }
 });
        });
    });
};

注意,我们也会像宣布到达一样宣布用户离开特定房间。同样,您可以在示例代码中找到此功能的附加测试。

我们将把聊天功能添加到游戏页面,并使用聊天表单上的数据属性指定正确的房间。

以下代码来自 src/views/game.hjs

<!DOCTYPE html>
<html>
  <head>
    <title>Hangman - Game #{{id}}</title>
    <link rel="stylesheet" href="/stylesheets/style.css" />
    <script src="img/jquery.min.js"></script>
    <script src="img/game.js"></script>
 <script src="img/socket.io.js"></script>
 <script src="img/chat.js"></script>
    <base href="/games/{{ id }}/">
  </head>
  <body>
    <h1>Hangman - Game #{{id}}</h1>
    <h2 id="word" data-length="{{ length }}"></h2>
    <p>Press letter keys to guess</p>
    <h3>Missed letters:</h3>
    <p id="missedLetters"></p>
 <hr/>
 <h3>Discussion</h3>
 <form class="chat" data-room="{{id}}">
 <div id="messages"></div>
 <input id="message"/><input type="submit" value="Send"/>
 </form>
  </body>
</html>

以下代码来自 src/views/index.hjs

    <hr/>
    <h3>Lobby</h3>
 <form class="chat" data-room="lobby">
      <div id="messages"></div>
      <input id="message"/><input type="submit" value="Send"/>
    </form>

然后,我们需要更新客户端脚本,以便在连接时加入正确的房间。

以下代码来自 src/public/scripts/chat.js

$(document).ready(function() {
    'use strict';

    var chat = $('form.chat');
    var socket = io('/chat');

 socket.emit('joinRoom', chat.data('room')); 
 chat.submit(function(event){
        ...
    });
    ...
});

最后,我们需要确保输入聊天消息不会干扰游戏。我们可以通过仅在用户不在聊天消息框中输入时将按键视为游戏的猜测来实现这一点。

以下代码来自 src/public/javascript/game.js

    $(document).keydown(function(event) {
 if (!$('.chat #message').is(':focus') &&
                event.which >= 65 && event.which <= 90) {
            var letter = String.fromCharCode(event.which);
            if (guessedLetters.indexOf(letter) === -1) {
                guessedLetters.push(letter);
                guessLetter(letter);
            }
        }
    });

小贴士

您可以在配套代码中找到此功能的新的和更新的测试。

将所有这些整合起来,我们现在可以在不同的房间中让多个客户端相互交谈:

使用房间分区 Socket.IO 客户端

摘要

在本章中,我们使用 Socket.IO 创建了一个实时客户端/服务器通信通道,使用 Redis 作为后端以横向扩展实时应用程序,将 Socket.IO 与 Express 中间件集成,并使用 Socket.IO 命名空间和房间组织我们的应用程序。

随着我们应用程序的网络连接变得越来越复杂,在开发或 CI 环境之外在 Web 服务器上测试应用程序变得更加重要。在下一章中,我们将探讨如何将我们的应用程序部署到 Web 上。

第十一章。部署 Node.js 应用程序

到目前为止,我们只在本地开发环境中运行了我们的应用程序。在本章中,我们将将其部署到 Web 上。托管应用程序有多种不同的选项。我们将通过一个部署选项来快速启动应用程序。我们还将讨论部署 Node.js 应用程序的更广泛原则和替代方案。

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

  • 将我们的应用程序部署到 Web

  • 使用应用程序日志诊断远程服务器上的问题

  • 设置数据库服务器和环境配置

  • 从 Travis CI 自动部署

提示

如果您想跟随本章,可以使用github.com/NodeJsForDevelopers/chapter10/中的代码作为起点。这包含了第十章末尾的示例代码,创建实时 Web 应用,我们将在此基础上构建。

与 Heroku 协作

Heroku是一个基于云的 Web 应用程序平台。它的目标是让开发者能够专注于应用程序而不是基础设施。它提供了一个低摩擦的工作流程,可以快速部署新应用程序,同时支持长期的可扩展性。它还提供了一个附加服务市场,例如数据库和监控。

有几个与 Heroku 类似的服务,其中一些我们将在本章后面介绍。Heroku 是这类服务中的先驱之一。特别是,它是第一个将 Node.js 作为一等公民支持的服务之一。它还提供了许多免费功能,包括本节工作示例所需的一切。

注意

注意,Heroku 的免费功能足以部署用于开发、演示或实验目的的应用程序。它不足以用于为最终用户提供服务的应用程序的生产部署。有关 Heroku 定价层的详细信息,请参阅www.heroku.com/pricing

设置 Heroku 账户和工具

要跟随本节中的示例,您首先需要在signup.heroku.com/上注册 Heroku。

我们还将使用 heroku 工具包,这是一个用于配置 Heroku 的 CLI。从toolbelt.heroku.com/下载并安装适用于您平台版本的程序。

检查 heroku 工具包是否正确安装并且可在您的路径上使用。打开一个新的命令提示符并运行以下命令:

> heroku

您应该会看到带有可用命令列表的帮助文本。通过运行以下命令配置工具包以使用您的 Heroku 账户:

> heroku login

使用 Heroku 在本地运行应用程序

Heroku 需要一个小的配置文件(类似于 .travis.yml),告诉它如何运行我们的应用程序。这个文件名为 Procfile,在我们的情况下,它包含以下单行:

web: npm start

这告诉 Heroku,我们的应用程序由一个单一的 Web 进程组成,可以使用 npm start 启动。

注意

注意,特别是如果你习惯了 Windows 文件系统,文件名中的大写 P 很重要。应用程序将被部署到一个类似 Unix 的系统,其中文件名是区分大小写的。

为了验证我们的 Procfile,我们可以使用 Heroku 在本地运行我们的应用程序:

> heroku local

这将使用 Procfile 启动我们的应用程序。请注意,它还设置了默认端口 5000。你现在应该能够访问应用程序在 http://localhost:5000

heroku local 命令还为我们的应用程序设置了环境变量。这些是从我们应用程序根目录的本地 .env 文件中读取的:

MONGODB_URL=mongodb://localhost/hangman
REDIS_URL=redis://127.0.0.1:6379/

你可以通过启动 MongoDB 和 Redis 的本地实例来测试这一点。在单独的提示符中运行以下命令(根据需要设置 --dbpath):

> redis-server
> mongod --dbpath C:\data\mongodb
> heroku local

有这个 .env 文件意味着我们可以直接使用 npm start(就像我们之前做的那样)来运行带有模拟数据存储,当我们需要一个更真实的环境时,使用 heroku local,而不必跟踪我们的当前环境变量。

将应用程序部署到 Heroku

现在我们已经创建了一个 Procfile,将我们的应用程序部署到网络变得容易。首先,我们需要创建一个新的 Heroku 应用程序:

> heroku create

默认情况下,这将在 Heroku 上创建一个最小化应用程序,并分配一个随机名称。你可以选择指定一个应用程序名称作为第三个参数。

此命令还返回了我们新创建应用程序的公共 URL,我们现在可以访问。以下响应被返回:

将应用程序部署到 Heroku

由于我们还没有部署任何内容,所以没有太多可以看的。将应用程序部署到 Heroku 的最快方式是通过 Git。heroku create 命令还为我们创建了一个新的 Git 远程,我们可以将其推送到。你可以通过查看 Git 远程列表来看到这一点:

> git remote -v

现在我们有一个名为 heroku 的 Git 远程。确保新的 Procfile 已经被提交。现在,当我们把我们的 master 分支推送到这个远程时,它将自动构建和部署:

> git push heroku master

如果我们现在再次访问应用程序的 URL,我们会看到以下内容:

将应用程序部署到 Heroku

我们的应用程序已经部署,但现在正在返回错误。为了诊断我们应用程序的问题,我们需要查看日志。

与 Heroku 日志、配置和服务一起工作

我们可以通过运行 heroku logs 来查看我们应用程序的日志。如果你查看日志到错误堆栈跟踪,你会看到以下错误信息:

app[web.1]: Error: Cannot find module 'mockgoose'

mockgoose包不可用,因为 Heroku 使用package.json中的dependencies而不是devDependencies来构建我们的应用程序。回想一下第九章,持久化数据,这个错误是故意的。我们希望应用程序在没有配置 MongoDB URL 的情况下在实时环境中失败。

为了修复这个错误,我们需要设置一个 MongoDB 实例并配置我们的应用程序以连接到它。我们还需要为我们的 Redis 数据库做同样的事情。这两个数据存储都可以从 Heroku 市场作为服务使用。

设置 MongoDB

我们可以通过命令行添加 Heroku 市场服务。MongoLab 是一个第三方服务,提供 MongoDB 实例。我们可以按照以下方式将实例添加到我们的应用程序中:

> heroku addons:create mongolab:sandbox

这创建了一个沙盒(免费版)MongoDB 实例,适用于演示目的。注意,从该命令的输出中可以看出,它还创建了一个MONGOLAB_URI配置变量。Heroku 将在运行时将此变量作为环境变量提供给我们的应用程序。

我们的应用程序期望一个名为MONGODB_URL的环境变量。我们需要创建这个变量并将其设置为与MONGOLAB_URI相同的值。您可以通过以下方式查看和设置应用程序的配置变量:

> heroku config
> heroku config:set MONGODB_URL=mongodb://...

您应填写MONGODB_URL的值,以匹配第一个命令返回的MONGOLAB_URI的值。

设置 Redis

Heroku 还通过其市场提供 Redis 服务。我们将按照以下方式将其添加到我们的应用程序中:

> heroku addons:create heroku-redis:hobby-dev --as:REDIS

我们再次使用这个服务的免费版(hobby-dev)进行演示。稍后很容易将服务重新缩放到不同的级别。

Redis 服务还允许您为创建的服务实例指定别名。别名使用heroku addons:create命令中的--as参数指定。这对于 Redis 来说很有用,因为我们可能有一个与单个应用程序关联的多个 Redis 实例。对我们来说尤其有用,因为通过将我们的实例别名为 REDIS,Heroku 将创建一个REDIS_URL环境变量。这正是我们的应用程序期望看到的。

heroku addons:create命令会立即重启我们的应用程序。不过,我们的新数据库实例可能需要一分钟左右才能可用。在重启应用程序之前等待一分钟:

> heroku restart

现在,我们可以在浏览器中访问应用程序 URL,并看到它在 Web 上运行!

设置 Redis

通过 Travis CI 部署

通过 Git 部署是一种快速启动和运行的方式,对开发者很有用。但这并不是推送更改的健壮方式。如果我们正在实践持续交付,那么我们可能希望在每次提交时部署,至少部署到 UAT 环境。但我们仍然希望我们的 CI 服务器充当门卫,确保我们只部署良好的构建。

Travis CI 支持部署到广泛的托管提供商(以及通过自定义脚本的任意部署)。我们可以通过在 travis.yml 文件中添加一个 deploy 部分来告诉 Travis CI 部署到 Heroku,如下所示(将 application-name-12345 替换为我们之前创建的 Heroku 应用程序的名称):

services:
- mongodb
- redis-server
deploy:
 provider: heroku
 app: application-name-12345
 api_key:
env:
  global:
  - MONGODB_URL=mongodb://localhost/hangman
  - REDIS_URL=redis://127.0.0.1:6379/

Travis CI 只有在构建通过的情况下才会部署我们的应用程序。为了使 Travis CI 能够与 Heroku 通信,它需要我们的 Heroku API 密钥。但我们可能不想将其提交到源代码控制(尤其是如果我们的 Git 仓库是公开的)。Travis CI 允许您通过为构建指定加密的环境变量来避免这种情况。

设置加密的 Travis CI 环境变量

可以使用 Travis CI 与我们的仓库关联的公钥来加密环境变量。Travis CI 然后使用相应的私钥在构建时解密这些变量。

使用正确的密钥加密环境变量的最简单方法是使用 Travis CLI。这是一个 Ruby 包。

安装 Ruby

如果您的系统上尚未安装 Ruby,请参阅 www.ruby-lang.org/en/documentation/installation/。在 Windows 上安装的最佳方式是使用 RubyInstaller,来自 rubyinstaller.org/

您可以通过运行以下命令来检查 Ruby 是否已安装并配置在您的路径上:

> ruby -ver

您应该拥有 2.0.0 或更高版本。

创建一个加密的环境变量

一旦您已将 Ruby 安装到您的路径上,您可以通过以下方式安装 Travis CLI:

> gem install travis --no-rdoc --no-ri

注意

Gem 是 Ruby 包管理器,类似于 npm。这里的 --no-doc--no-ri 参数跳过了低级 API 文档的安装,这些我们不需要。

现在,我们可以添加我们的加密环境变量。首先,我们需要获取我们应用程序的 Heroku API 密钥:

> heroku auth:token

现在,我们可以将其添加到 .travis.yml 文件中,如下所示:

> travis encrypt [AUTH_TOKEN] --add deploy.api_key

[AUTH_TOKEN] 是上一条命令的输出。

这将加密 API 密钥并自动将加密版本添加到我们的 .travis.yml 文件中。在提交之前,尝试更新应用程序中的某些内容,例如 src/routes/index.js 中的页面标题:

...
            .then(results => {
                res.render('index', {
 title: 'Hangman online',
                            userId: req.user.id,
                            createdGames: results[0],
...

现在,提交并推送主分支(到 origin,而不是直接到 heroku),并等待 Travis CI 构建完成。构建输出显示我们的应用程序正在部署:

创建一个加密的环境变量

如果您再次访问应用程序,您应该看到带有更新标题的新版本。

请记住,Travis CI 实际上是在为多个 Node.js 版本构建我们的应用程序。默认情况下,Travis CI 在每个构建作业结束时部署我们的应用程序。这是不必要的,并且会减慢我们的整体构建速度。我们可以通过修改 .travis.yml 文件来告诉 Travis CI 只从特定的构建作业中部署,如下所示:

deploy:
  provider: heroku
  app: afternoon-cliffs-85674
 on:
 node: 6
  api_key:
    secure: ...

如果我们再次提交并检查 Travis CI 的输出,我们可以看到只有 Node.js v6 构建作业执行了部署。

进一步资源

关于部署 Web 应用程序的进一步考虑,请参阅《十二要素应用》(12factor.net/)。这是一份关于在 Heroku 等服务上运行企业级 Web 应用程序的重要考虑因素的详细资源。

当然,托管网络应用程序有很多选择。Azure 的 Web 应用程序服务和 AWS 的 Elastic Beanstalk 都支持 Node.js 作为一等公民。Modulus (modulus.io/)提供 Node.js 和 Mongo DB 托管,具有强大的扩展、监控和负载均衡功能。

上述内容都是应用程序托管平台的示例(平台即服务PaaS),在云术语中)。当然,您也可以将 Node.js 应用程序部署到裸机基础设施(无论是云基础设施还是您自己的机器)。有关详细指南,请参阅certsimple.com/blog/deploy-node-on-linux

您可能需要通过多个环境管理应用程序的发布。您的 CI 服务器可能首先将应用程序部署到集成环境并在那里运行测试,然后再部署到 UAT。然后您可能希望能够通过点击按钮将 UAT 环境中的确切同一发布版本推送到预发布和实时环境。

Heroku 管道和 Azure Web 应用程序部署槽允许您通过不同的环境管理应用程序的发布。Wercker (wercker.com/)是一个构建和部署服务,可以自动化更复杂的流程。它还提供基于 Docker 容器的隔离环境。

摘要

在本章中,我们使用 Heroku 将应用程序部署到网络,配置了环境设置并提供了数据库,设置了 Travis CI 以自动部署成功的构建,并了解了部署 Node.js 应用程序的更多选项和考虑因素。

现在我们的应用程序已上线,我们可以开始考虑如何将其与更广泛的网络集成。在下一章中,我们将探讨允许用户使用第三方社交媒体服务作为身份提供者进行登录。

第十二章. Node.js 中的认证

我们迄今为止构建的应用程序允许用户选择一个用户名来识别自己。然而,他们只能在浏览器会话期间保留这个身份。允许用户从一个会话持续到下一个会话保持一致的身份是很重要的。这使我们能够构建更丰富的用户体验。一些网站(如 Facebook)如果不能识别用户,甚至无法提供其主要功能。

识别用户需要我们实现认证。在本章中,我们将涵盖以下主题:

  • 通过社交网站实现第三方认证

  • 将第三方身份与我们的用户数据关联

  • 模拟用户认证以支持集成测试

介绍 Passport

Passport 是一个 Node.js 的认证框架。它可以作为 Express 中间件,使得与我们的应用程序集成变得容易。

就像我们之前讨论的一些其他库一样,Passport 非常模块化。它的核心包提供了一个通用的认证范式。Passport 的中间件执行认证,并在请求对象中添加一个user属性。

额外的 Passport npm 包支持数百种不同的策略进行认证。每个 Passport 策略都提供了识别用户的不同机制。在本章中,我们将探讨这些策略中的几个。Passport 使得为每个应用程序添加新策略变得容易。

选择认证策略

一个常见的入门示例是基于用户名/密码的认证。这使用登录表单来验证用户的凭据与应用程序数据库的匹配。尽管这是最容易理解的认证机制之一,但它并不是最有用的。强迫用户为我们的网站创建账户是使用它的额外障碍。用户也会厌倦为每个新网站创建账户和选择密码。

Passport 支持这种类型的认证,通过passport-local策略。我们将在本章后面部分使用这个策略进行测试,但不会在我们的生产代码中使用。最好允许用户使用已在其他地方建立的身份进行认证。这可以节省用户挑选新凭据的时间和精力,也可以让我们的网站不必管理这些凭据。这只是良好的关注点分离。

如果你登录 StackOverflow,你会注意到它建议使用 Google+或 Facebook 进行登录。它还支持 OpenID 和其他提供者。从头开始实现对这些每种登录机制的支持将会是一项大量工作。幸运的是,有 Passport 策略支持所有这些。

理解第三方认证

Passport 将为我们做大部分繁重的工作,但仍然值得了解第三方认证的基本工作原理。当客户端想要登录网站时,它会将他们发送到第三方提供者。第三方提供者会向客户端返回一个他们可以使用来对网站进行身份验证的令牌。当客户端是网络浏览器时,这个过程可以通过自动重定向几乎对用户不可见。

网站必须验证客户端向其展示的令牌确实来自第三方提供者。网站和第三方提供者可能已经为此目的建立了一个预共享密钥,该密钥可以用来创建一个可加密验证的令牌。或者,网站可能直接调用第三方提供者来验证令牌。在实践中,网站通常会调用第三方提供者以获取更多与用户身份相关的信息,例如他们的用户名或其他个人资料信息。

使用 Express 会话

Passport 的许多策略都是基于 HTTP 会话的。目前,我们的应用程序只是使用简单的 cookie 来存储用户 ID。为了使用 Passport 进行第三方认证,我们需要在我们的应用程序中添加会话支持。Express 在express-session模块中提供了会话支持。首先,我们将此添加到我们的应用程序中:

> npm install express-session --save

我们还需要一个地方来存储会话数据。Express 通过额外的模块支持多种会话存储。Redis 非常适合这项任务,我们已经有了一个 Redis 实例可用。我们可以使用connect-redis模块将会话存储在 Redis 中:

> npm install connect-redis --save

我们现在可以创建一个新的配置模块,将所有的会话逻辑放在一个地方。由于这将返回中间件,我们将它放在这里的middleware文件夹中src/middleware/sessions.js

'use strict';

const session = require('express-session');

let config = {
    secret: process.env.SESSION_SECRET,
    saveUninitialized: false,
    resave: false
};

if (process.env.REDIS_URL && process.env.NODE_ENV !== 'test') {
    const RedisStore = require('connect-redis')(session);
    config.store = new RedisStore({ url: process.env.REDIS_URL });
}

module.exports = session(config);

我们按照以下方式配置 Express 的session模块:

  • 使用环境变量的值作为会话密钥

  • 只保存包含某些数据的会话

  • 除非会话已更改,否则不要重新保存会话

  • 如果 Redis 可用,请将其用作会话存储

让我们逐一考虑每个配置属性。

指定会话密钥

Express 使用会话密钥来保护会话数据不被篡改。你应该通过设置本地的SESSION_SECRET环境变量来指定它。值是任意的,可以是任何内容,只要它不为空。我们还需要在我们的集成测试中指定这个值,以便它可以在 CI 服务器上运行。以下代码来自gulpfile.js

gulp.task('integration-test', ..., (done) => {
    const TEST_PORT = 5000;
 process.env.SESSION_SECRET =
 process.env.SESSION_SECRET || 'testOnly';
    require('./src/server.js').then((server) => {
        ...
    });
}); 

决定何时保存会话

避免不必要的保存是一种小优化,可以避免某些竞态条件。只有保存已初始化的会话,你才能在存储任何 cookie 之前请求用户同意。这可能对于遵守区域法律是必要的,尤其是在欧盟。更多信息请参阅www.cookiechoices.org/

使用替代会话存储

默认情况下,Express 将使用内存中的会话存储。这对于开发目的和在只有一个应用程序进程的测试环境中是可行的,但不适合生产使用。如果我们想要跨多个实例进行扩展,将会话存储在进程外部的 Redis 中很重要。我们使用现有的 Redis URL 配置 Redis 存储。

注意

在实践中,您可能希望为会话数据和应用程序的其他数据使用不同的 Redis 实例。这些是相当不同的用例,因此它们可能从不同的 Redis 配置中受益。例如,会话数据可能负载更高,但可以承受更高的易变性。对于本书中的示例应用等小型应用,单个 Redis 实例就足够了。

使用会话中间件

我们现在可以在应用程序的其他地方使用会话,而不是直接设置 cookie。以下代码来自src/app.js

 let sessions = require('./middleware/sessions');
    ...
    app.use(bodyParser.urlencoded({ extended: false }));
 app.use(sessions);
    app.use(express.static(path.join(__dirname, 'public')));
    ...

以下代码来自src/middleware/users.js

'use strict';

module.exports = (service) => {
    const uuid = require('uuid');

    return function(req, res, next) {
 let userId = req.session.userId;
        if (!userId) {
            userId = uuid.v4();
 req.session.userId = userId;
            req.user = {
                id: userId
            };
            next();
        } else {
            ...
        }
    };
};

以下代码来自src/server.js

'use strict';

module.exports = require('./config/mongoose').then(mongoose => {
    ...
 io.use(adapt(require('./middleware/sessions')));
    const usersService = require('./services/users.js');
    ...
});

实现社交登录

在我们的第一个示例中,我们将使用 Twitter 作为第三方身份验证提供者。如果您想跟随示例,您需要一个 Twitter 账户,设置起来非常快。

设置 Twitter 应用

为了让 Twitter 识别我们的应用程序,我们需要在 Twitter 开发者门户中创建一个新的应用:

  1. 访问apps.twitter.com/并点击创建新应用

  2. 填写名称、描述、网站和回调 URL 字段:

    • 如果您已将应用程序部署到 Heroku,您可以使用其 Heroku URL

    • 否则,只需为这两个字段填写占位符值(例如,http://test.example.com/callback

  3. 点击创建您的 Twitter 应用

  4. 点击设置选项卡,并确保启用回调锁定未勾选(不勾选此选项允许您为 URL 使用占位符值,并且对于本地测试也很有用)。

  5. 点击密钥和访问令牌选项卡以查看您的应用程序的消费者密钥(API 密钥)和消费者密钥(API 密钥)。

设置名为TWITTER_API_KEYTWITTER_API_SECRET的新本地环境变量,包含来自 Twitter 的相应值。您可能希望创建一个 shell 脚本或批处理文件来在控制台设置这些变量,或者将它们配置为 Heroku 环境变量(见第十一章,部署 Node.js 应用程序

配置 Passport

我们现在将使用 Passport 允许用户通过 Twitter 登录我们的网站。首先,我们需要安装相关的 npm 包:

> npm install passport --save
> npm install passport-twitter --save

现在,我们可以在src/config/passport.js下配置 Passport 以使用 Twitter 进行身份验证。我们添加以下代码:

'use strict';

const passport = require('passport');
const TwitterStrategy = require('passport-twitter').Strategy;

module.exports = (usersService) => {
    if(process.env.TWITTER_API_KEY &&
            process.env.TWITTER_API_SECRET) {
        passport.use(new TwitterStrategy({
            consumerKey: process.env.TWITTER_API_KEY,
            consumerSecret: process.env.TWITTER_API_SECRET,
            callbackURL: '/auth/twitter/callback',
            passReqToCallback: true
        }, (req, token, tokenSecret, profile, done) => {
            usersService.setUsername(req.user.id,
                    profile.username || profile.displayName)
                .then(() => { done(); }, done);
        }));
    }
    return passport;
};

这使用TwitterStrategy进行 Twitter 认证,通过配置对象传递我们的 API 密钥和密钥。第二个构造函数参数是一个函数,Passport 在通过 Twitter 认证后会调用此函数(在 Passport 文档中称为验证回调)。在这里,我们根据 Passport 提供的来自 Twitter 的profile.usernameprofile.displayName设置当前用户的名字。

注意

profile对象包含认证提供者返回的用户个人资料。Passport 将个人资料数据标准化,以便更容易与多个策略一起使用。有一个标准的字段集,例如displayName,所有 Passport 策略如果可能都会填充这些字段。我们更愿意使用 Twitter 用户名(例如,hgcummings)而不是显示名称(例如,Harry Cummings)。profile.username字段包含 Twitter 用户名。这不是标准字段之一,但许多策略会返回一个具有此名称的字段。因此,我们首先使用profile.username,但会回退到更标准的profile.displayName

现在我们只需要在我们的 Express 应用程序中使用新的 passport 模块。以下代码来自src/app.js

 let passport = require('./config/passport')(usersService);
    ... 

    app.use(users);
 app.use(passport.initialize());
app.post('/auth/twitter', passport.authenticate('twitter'));
app.get('/auth/twitter/callback',
 passport.authenticate('twitter',
 { successRedirect: '/', failureRedirect: '/' }));

    app.use('/', routes);
    ...

这告诉我们的应用程序执行以下三件事:

  • 使用 Passport 的 Express 中间件

  • 当用户向/auth/twitter发送 POST 请求时,通过 Twitter 进行用户认证

  • 在将用户重定向到主页之前,在/auth/twitter/callback处理 Twitter 身份验证结果

最后,我们需要提供一个登录按钮来访问我们新的端点,如src/views/index.js中所示:

    <h1>{{ title }}</h1>
 <h2>Account</h2>
 {{#ranking}}
 ...
 {{/ranking}}
 <form action="/auth/twitter" method="POST">
 <input type="submit" value="Log in using Twitter" />
 </form>
 <h3>Profile</h3>
    <form action="/profile" method="POST">
      ...
    </form>
    ...

如果你运行应用程序并点击使用 Twitter 登录,以下将发生:

  • 应用程序将重定向你的浏览器到 Twitter

  • 如果你尚未登录,Twitter 将提示你登录

  • Twitter 将询问你是否愿意应用程序查看你的个人资料详情和其他公开数据

  • Twitter 将然后重定向你的浏览器到/auth/twitter/callback端点

  • 你的浏览器将向此端点发送带有 Twitter 认证令牌的请求

  • Passport 将验证此令牌然后调用我们的登录处理函数

  • 当我们的函数完成后,Passport 将返回一个重定向响应到主页

我们现在已经将 Twitter 身份验证集成到我们的应用程序中!然而,我们并没有真正使用它来允许用户登录。我们只是将 Twitter 用户名与为每个会话创建的现有用户 ID 关联起来。你可以通过打开两个不同的浏览器会话来查看这一点。尝试使用它们中的每一个登录。如果你在一个浏览器中创建了一个新游戏,它会在另一个浏览器中出现在其他用户创建的游戏列表中。这是因为你现在有两个用户 ID 与同一个 Twitter 用户名相关联。

我们需要在用户使用相同的 Twitter 账号登录时识别出相同的用户。这不应该依赖于是否在同一个浏览器会话中。为了解决这个问题,我们需要执行以下操作:

  • 将用户账户持久化到我们的数据库

  • 告诉 Passport 如何存储和检索用户

  • 让 Passport 将用户与当前会话关联起来

使用 Redis 持久化用户数据

我们已经使用 Redis 将用户名与用户 ID 关联。现在我们希望也能将用户 ID 与 Twitter 账户关联起来。当用户第一次使用外部提供者登录时,我们希望创建一个新的用户,其名称来自外部配置文件。后续使用相同提供者进行身份验证的请求将看到相同的用户。

我们可以使用 Redis 的 SETNX 操作来实现这一功能。这只会设置一个键,如果它不存在,并返回这是否是这种情况。我们的实现如下,来自 src/services/users.js

'use strict';

const redisClient = require('../config/redis.js');
const uuid = require('uuid');

const getUser = userId =>
 redisClient.getAsync(`user:${userId}:name`)
 .then(userName => ({
 id: userId,
 name: userName
 }));

const setUsername = (userId, name) =>
 redisClient.setAsync(`user:${userId}:name`, name);

module.exports = {
 getOrCreate: (provider, providerId, providerUsername) => {
 let providerKey = `provider:${provider}:${providerId}:user`;
 let newUserId = uuid.v4();
 return redisClient.setnxAsync(providerKey, newUserId)
 .then(created => {
 if (created) {
 return setUsername(newUserId, providerUsername)
 .then(() => getUser(newUserId));
 } else {
 return redisClient
 .getAsync(providerKey).then(getUser);
 }
 });
 },
  getUser: getUser,    getUsername: userId => redisClient.getAsync(`user:${userId}:name`),
 setUsername: setUsername,
  ...
};

在这里,我们创建一个新的用户 ID,并告诉 Redis 将其与外部提供者(例如,Twitter)账户关联起来。如果我们之前已经看到过这个外部账户,我们将返回之前已经与之关联的用户。否则,我们将持久化一个新的用户 ID,并将其与外部配置文件中的用户名关联起来。对此功能的测试可以在配套代码中找到。

配置 Passport 以实现持久化

既然我们已经有了持久化用户的方式,我们需要告诉 Passport 如何利用这一点。首先,我们更新我们的验证回调,使用新的 getOrCreate 函数而不是仅仅设置一个用户名。然后我们需要告诉 Passport 如何通过将用户序列化为字符串并从字符串反序列化来识别和检索与会话关联的用户。以下代码来自 src/config/passport.js

'use strict'; 

const passport = require('passport');
const TwitterStrategy = require('passport-twitter').Strategy;

module.exports = (usersService) => {
    if(process.env.TWITTER_API_KEY &&
            process.env.TWITTER_API_SECRET) {
        passport.use(new TwitterStrategy({
            consumerKey: process.env.TWITTER_API_KEY,
            consumerSecret: process.env.TWITTER_API_SECRET,
            callbackURL: '/auth/twitter/callback',
            passReqToCallback: true
        }, (req, token, tokenSecret, profile, done) => {
 usersService.getOrCreate('twitter', profile.id,
 profile.username || profile.displayName)
 .then(user => done(null, user), done);
        }));
    }

 passport.serializeUser((user, done) => {
 done(null, user.id);
 });

 passport.deserializeUser((id, done) => {
 usersService.getUser(id)
 .then(user => done(null, user))
 .catch(done);
 });

    return passport;
};

Passport 将用户(由我们的 serializeUser 回调返回)的字符串版本存储在会话中。它使用我们的 deserializeUser 回调将此字符串转换为用户对象,并将其添加到请求中。在我们的例子中,用户的字符串表示只是他们的 ID,反序列化只是用户服务中的查找。

为了使这生效,我们还需要告诉我们的应用程序使用 Passport 的自己的会话中间件,它与 Express 会话一起工作。为了避免重复,我们将在会话中间件模块中指定所有与会话相关的中间件。以下是从 src/middleware/sessions.js 的代码:

...

const expressSession = session(config);
module.exports = passport => [
 expressSession, passport.initialize(), passport.session()
];

此模块现在返回三个中间件实例。我们希望同时使用 Express 和 Socket.IO。其中第一个很简单,因为我们可以将多个中间件对象传递给 Express 的 app.use 函数,就像在这里 src/app.js 一样:

    ...
    let passport = require('./config/passport')(usersService);
 let sessions = require('./middleware/sessions')(passport);
    ...
    app.use(bodyParser.json());
    app.use(bodyParser.urlencoded({ extended: false }));
 app.use(sessions);
    app.use(express.static(path.join(__dirname, 'public')));

    app.post('/auth/twitter', passport.authenticate('twitter'));
    ...

对于 Socket.IO,我们需要逐个调整每个中间件,就像在这里 src/server.js 一样:

    ...
    const usersService = require('./services/users.js');
 let passport = require('./config/passport');
 require('./middleware/sessions')(passport).forEach(
 middleware => io.use(adapt(middleware)));

    require('./realtime/chat')(io);
    ...

注意,在这两种情况下,我们的用户中间件不再需要,现在可以删除。然而,这个中间件之前确保请求中始终有一个用户对象。现在这只有在有登录用户的情况下才会发生,因此我们需要相应地更新我们的应用程序的其他部分。

在我们的应用程序中有几个地方假设请求中始终存在用户。由于这不再得到保证,有两种解决方法:我们可以更新我们的代码以处理请求中没有用户的情况,或者我们可以从未认证用户那里隐藏功能。

我们仍然希望未认证用户能够查看公共聊天室,并查看和玩游戏,因此我们将相应地更新这些功能。来自src/realtime/chat.js的代码更新如下:

    namespace.on('connection', (socket) => {
 let username = null;
 if (socket.request.user) {
 username = socket.request.user.name;
 }
        ...

以下代码来自src/realtime/games.js

    function forwardEvent(name, socket) {
        service.events.on(name, game => {
 if (!socket.request.user ||
 game.setBy !== socket.request.user.id) {
                socket.emit(name, game.id);
            }
        });
    }

以下代码来自src/routes/games.js

    router.post('/:id/guesses', function(req, res, next) {
        checkGameExists(
            req.params.id,
            res,
            game => {
 if (req.user && game.matches(req.body.word)) {
                    userService.recordWin(req.user.id);
                }
                ...
            },
            next
        );
    });

隐藏对未认证用户的功能

我们当然希望未认证用户能够访问我们应用程序的主页,但可能不希望向他们显示应用程序的所有功能。为了实现这一点,我们将更新我们的 index 路由,如下所示,来自src/routes/index.js

    router.get('/', function(req, res, next) {
 let userId = null;
 if (req.user) {
 userId = req.user.id;
 }

        Promise.all([gamesService.createdBy(userId),
                    gamesService.availableTo(userId),
                    usersService.getUsername(userId),
                    usersService.getRanking(userId),
                    usersService.getTopPlayers()])
            .then(results => {
                res.render('index', {
                            title: 'Hangman online',
 loggedIn: req.isAuthenticated(),
                            createdGames: results[0],
                            ...
                        });
                    })
            .catch(next);
    });

注意,这将在视图数据中添加一个loggedIn属性,而不是用户 ID。这个属性的值来自isAuthenticated函数,该函数由 Passport 添加到请求中。我们使用这个属性来隐藏对未认证用户不再起作用的特性,并从认证用户那里隐藏登录按钮。以下代码来自src/views/index.hjs

...
  <body>
    ...
 {{^loggedIn}}
      <form action="/auth/twitter" method="POST">
        <input type="submit" value="Log in using Twitter" />
      </form>
 {{/loggedIn}}
 {{#loggedIn}}
      <h3>Profile</h3>
      <form action="/profile" method="POST">    
        ...
      </form>
 {{/loggedIn}}
    <h2>Games</h2> 
 {{#loggedIn}}
      <form action="/games" method="POST" id="createGame">
        ...
      </form>
      <h3>Games created by you</h3>
      ...
 {{/loggedIn}}
    <h3>Games available to play</h3>
    ...
    <h2>Top players</h2>
    ...
    <h3>Lobby</h3>
    <form class="chat" data-room="lobby">
      <div id="messages"></dl>
 {{#loggedIn}}
        <input id="message"/><input type="submit" value="Send"/>
 {{/loggedIn}}
    </form>
  </body>
</html>

使用 Passport 进行集成测试

我们仍然有一个问题,即我们的集成测试将不再工作。现在只有登录用户可以创建游戏。写一个新的集成测试来检查 Twitter 认证是否工作将是一个好主意。但我们不想将 Twitter 账户依赖性引入当前的测试中。

相反,我们将利用 passport-local 策略来允许我们的测试登录。我们将将其作为开发依赖项安装,这样它就不会意外地在生产环境中运行:

> npm install passport-local --save-dev

我们配置 Passport 接受任何用户名和密码。如果实际使用 passport-local,这就是你会在数据存储中检查凭据的地方。以下代码来自src/config/passport.js

if (process.env.NODE_ENV === 'test') {
    const LocalStrategy = require('passport-local');
    const uuid = require('uuid');
    passport.use(new LocalStrategy((username, password, done) => {
            const userId = uuid.v4();
            usersService.setUsername(userId, username)
                .then(() => {
                    done(null, { id: userId, name: username });
                });
        }
    ));
}

然后,我们在应用程序中添加一个新的本地认证端点,如下所示src/app.js

  if (process.env.NODE_ENV === 'test') {
    app.post('/auth/test',
      passport.authenticate('local', { successRedirect: '/' }));
  }

最后,更新我们的测试以登录为第一步,如下所示,来自integration-test/game.js的代码:

    function withGame(word, callback) {        
 page.open(rootUrl + '/auth/test',
 'POST',
 'username=TestUser&password=dummy',
            function() {
                 ...
            }
        );
    }

允许用户注销

用户也会期望我们提供一种注销我们应用程序的方法。Passport 通过向请求添加一个logout函数来简化这一点。我们只需要在我们的其中一个路由中利用这个功能src/routes/index.js

    router.post('/logout', function(req, res){
        req.logout();
        res.redirect('/');
    });

我们可以在视图中添加一个注销按钮来利用这个新路由,如下所示src/views/index.hjs

    {{#loggedIn}}
 <form action="/logout" method="POST">
 <input type="submit" value="Log out" />
 </form>
      <h3>Profile</h3>

添加其他登录提供者

现在我们已经有了所有认证的一般基础设施,添加额外的提供者很容易。让我们以添加 Facebook 认证为例。首先,我们需要安装相关的 Passport 策略:

> npm install passport-facebook --save

然后,我们可以更新我们的 Passport 配置文件,如下所示src/config/passport.js

...
const FacebookStrategy = require('passport-facebook').Strategy;

module.exports = (usersService) => {
 const providerCallback = providerName =>
 function(req, token, tokenSecret, profile, done) {
 usersService.getOrCreate(providerName, profile.id,
 profile.username || profile.displayName)
 .then(user => done(null, user), done);
 };

    if(process.env.TWITTER_API_KEY &&
            process.env.TWITTER_API_SECRET) {
        passport.use(new TwitterStrategy({
            consumerKey: process.env.TWITTER_API_KEY,
            consumerSecret: process.env.TWITTER_API_SECRET,
            callbackURL: '/auth/twitter/callback',
            passReqToCallback: true
 }, providerCallback('twitter')));
    }

 if(process.env.FACEBOOK_APP_ID &&
 process.env.FACEBOOK_APP_SECRET) {
 passport.use(new FacebookStrategy({
 clientID: process.env.FACEBOOK_APP_ID,
 clientSecret: process.env.FACEBOOK_APP_SECRET,
 callbackURL: '/auth/facebook/callback',
 passReqToCallback: true
 }, providerCallback('facebook')));
 }
    ...
};

在这里,我们已经将验证回调函数泛化,使其接受不同的提供者名称,然后使用它来处理 Twitter 和 Facebook 身份验证策略。我们可以以相同的方式重用此功能来添加更多策略。我们只需设置相关的环境变量,它们就可以工作。

注意

要获取 Facebook App ID 和密钥,请在developers.facebook.com/apps/(需要您有一个 Facebook 账户)创建一个新的 Facebook 应用程序。这个过程与 Twitter 非常相似。只需创建一个类型为网站的新应用程序,其 URL 与您的开发环境相匹配(例如,http://localhost:3000)。创建后,App ID 和 App Secret 将在应用程序的仪表板页面上可见。

我们还需要将 Facebook 身份验证路由添加到我们的应用程序配置文件中。这些路由与对应的 Twitter 路由相同。与 Passport config 文件一样,我们可以通过参数化提供者名称来实现通用化。src/app.js中的代码如下:

  app.use(sessions);
 const addAuthEndpoints = provider => {
 app.post(`/auth/${provider}`, passport.authenticate(provider));
 app.get(`/auth/${provider}/callback`,
 passport.authenticate(provider, { successRedirect: '/',
 failureRedirect: '/', session: true }));
 };
 addAuthEndpoints('twitter');
  addAuthEndpoints('facebook');

最后,我们需要添加一个按钮,允许用户使用 Facebook 登录。以下代码来自src/views/index.hjs

    {{^loggedIn}}
      <form action="/auth/twitter" method="POST">
        <input type="submit" value="Log in using Twitter" />
      </form>
 <form action="/auth/facebook" method="POST">
 <input type="submit" value="Log in using Facebook" />
 </form>
    {{/loggedIn}}

添加额外的提供者很容易。要添加 Google+身份验证,我们只需遵循以下步骤:

  1. 安装passport-google npm模块

  2. 按照描述在developers.google.com/identity/protocols/OpenIDConnect创建一个新的应用程序。

  3. 更新上述三个文件,将 Google 提供者传递给我们的新通用函数

摘要

在本章中,我们使用 Passport 在我们的 Express 应用程序中添加了身份验证,使用 Redis 作为会话存储引入了 Express 会话,利用多个 Passport 策略来支持不同的外部提供者,并在 Redis 中持久化用户数据。

这完成了我们的示例 Web 应用程序。在下一章中,我们将探讨如何创建不同类型的 Node.js 项目:一个库和一个命令行工具。

第十三章. 创建 JavaScript 包

到目前为止,我们已经构建了一个网络应用程序,在创建过程中使用了各种 npm 包。这些包包括 Express 这样的库和 Gulp 这样的命令行工具。现在我们将看看如何创建我们自己的包。

在本章中,我们将:

  • 探索可用于 JavaScript 的不同模块系统

  • 创建我们自己的 JavaScript 库

  • 编写可以在客户端和服务器端运行的 JavaScript

  • 创建一个 JavaScript 命令行工具

  • 发布一个新的 npm 包

  • 在浏览器环境中使用 Node.js 模块

注意

本章中的代码示例与我们迄今为止所做的一切都是独立的。

编写通用模块

我们已经编写了许多自己的模块作为我们应用程序的一部分。我们还可以编写库模块供其他应用程序使用。

当为他人编写代码时,考虑它在什么环境中有用是值得的。一些库仅在特定环境中有用。例如,Express 是服务器特定的,jQuery 是浏览器特定的。但许多模块提供在任何环境中都很有用的功能,例如,我们在这本书的其他地方使用的 uuid 模块等实用模块。

让我们看看如何编写适用于多个环境的模块。我们需要支持不仅仅是 Node.js 风格的模块。我们还需要支持客户端模块系统,如 RequireJS。回顾 第四章,介绍 Node.js 模块,Node.js 和 RequireJS 实现了两种不同的模块标准(分别是 CommonJS 和 异步 模块定义AMD))。我们的包也可能在没有任何模块系统的网站上作为客户端使用。

例如,让我们创建一个提供简单 flatMap 方法的模块。这将像 .NET 的 LINQ 中的 SelectMany 一样工作。它将接受一个数组和为每个元素返回一个新数组的函数。它将返回一个包含合并结果的单一数组。

作为 Node.js/CommonJS 模块,我们可以这样实现:

module.exports = function flatMap(source, callback) {
    return Array.prototype.concat.apply([], source.map(callback));
}

比较 Node.js 和 RequireJS

回顾 第四章,介绍 Node.js 模块,每个模块系统都提供以下功能:

  • 一种声明具有名称和自身作用域的模块的方法

  • 定义模块提供功能的方法

  • 将模块导入另一个脚本的方法

Node.js 实现了 CommonJS 模块标准。模块名称对应文件路径,每个文件都有自己的作用域。模块使用 exports 别名定义它们提供的功能。模块通过 require 函数导入。

RequireJS 是为浏览器环境设计的。在浏览器中,每个文件没有新的作用域(所有脚本文件都在同一个作用域中执行,并且可以查看相同的变量)。此外,模块必须通过网络请求而不是从本地文件系统加载。

RequireJS 实现了 AMD 标准。AMD 指定了两个函数,RequireJS 将这两个函数添加到浏览器环境中的顶级 window 对象中:

  • define 函数允许通过提供模块名称和工厂函数来创建新的模块。模块的作用域将是其工厂函数的作用域。模块的功能由工厂函数的返回值定义。

  • require 函数允许导入模块。尽管这个名字与 Node.js 中的模块导入函数相同,但它们的工作方式非常不同。可以指定多个模块名称进行导入(作为一个数组)。require 函数是异步的,并接受一个回调函数,当所有依赖项都加载完成后执行。这允许 RequireJS 在浏览器环境中有效地加载模块。

支持浏览器环境

为了使我们的模块在浏览器环境中工作,我们需要支持 AMD 标准,以便 RequireJS 可以工作。我们还需要适应不使用任何模块加载器的网站。我们可以通过以下方式扩展我们的模块定义来实现这一点,在 scripts/flatMap.js 中:

(function (root, factory) {
    'use strict';
    if (typeof define === 'function' && define.amd) {
        define([], factory);
    } else if (typeof module === 'object' && module.exports) {
        module.exports = factory();
    } else {
        root.flatMap = factory();
    }
}(this, function () {
    'use strict';
    return function flatMap(source, clbk) {
        return Array.prototype.concat.apply([], source.map(clbk));
    }
}));

注意

注意使用立即调用的匿名函数,称为立即执行的函数表达式IIFE)。这是在具有内置模块的 JavaScript 环境中创建隔离作用域的常见方法。

首先,我们检查 AMD 风格的 define 函数的存在(AMD 标准还指定了 define.amd 属性的存在)。请注意,define 函数的异步性质意味着我们需要使用工厂函数来创建我们的模块。我们将依赖项列表(在这种情况下为空)和我们的工厂函数提供给 define 函数以创建我们的模块。

如果没有 AMD 模块系统,我们将检查 Node.js 使用的 CommonJS 风格的 module.exports。最后,如果两个模块系统都不存在,我们将我们的模块作为 root 参数的一个属性提供。我们为这个参数的论点是全局作用域中评估的 this 关键字。在浏览器中,这将是指 window 对象。

使用 RequireJS 与 AMD 模块

让我们创建一个简单的网页来检查我们的模块是否与 RequireJS 正确工作。我们还将展示如何使用 RequireJS 与外部库 jQuery 一起使用。

首先,我们为页面定义一个 HTML 文件:

<!DOCTYPE html>
<html>
    <head>
        <script data-main="scripts/main" src="img/require.min.js"></script>
        <style>input, pre { display: block; margin: 0.5em auto; width: 320px; }</style>
    </head>
    <body>
        <input type="text" />
        <input type="text" />
        <input type="text" />
        <input type="text" />
        <pre id="wordcounts"></pre>
    </body>
</html>

注意,页面上唯一的脚本标签是用于 RequireJS 自身的。此脚本标签还有一个数据属性,指示我们应用程序的入口点。路径 scripts/main 告诉 RequireJS 加载 scripts/main.js,它包含以下内容:

requirejs.config({
    paths: {
        jquery: 'https://cdnjs.cloudflare.com/ajax/libs/jquery/2.2.1/jquery.min'
    }
});

require(['flatMap', 'jquery'], function(flatMap, $) {
    $('input').change(function() {
        var allText = $.map($('input'), function(input) {
            return $(input).val();
        }).filter(function(text) {
            return !!text;
        });
        var allWords = flatMap(allText, function(text) {
            return text.split(' ');
        });
        var counts = {};
        allWords.forEach(function(word) {
            counts[word] = (counts[word] || 0) + 1;
        });
        $('#wordcounts').text(JSON.stringify(counts));
    })
});

此脚本首先配置 RequireJS。这里指定的唯一 config 属性是 path 属性。键 'jquery' 下的 jQuery 路径告诉 RequireJS 如何解析 'jquery' 依赖项。我们不需要为 flatMap.js 指定路径,因为我们已经将它保存在与 main.js 相同的目录下。

接下来,我们使用require函数加载 flatMap 和 jQuery,并将它们传递到我们的主应用程序函数中。在较大的使用 RequireJS 的应用程序中,这通常是一个非常短的引导函数。main.js文件也是你通常会看到require调用的唯一地方。大多数应用程序代码都使用define声明的模块。

由于这只是一个使用 RequireJS 测试我们库的例子,我们将把其余的应用程序代码放在我们的主应用程序函数中。我们使用我们的 flatMap 模块和 jQuery 来计算和显示所有文本输入的单词数。你可以在浏览器中打开index.html来查看这个功能:

使用 RequireJS 与 AMD 模块

同构 JavaScript

上面的flatMap.js示例是通用模块定义模式的实现。有关此模式的注释模板,请参阅github.com/umdjs/umd。这些模板还展示了如何声明遵循此模式的模块之间的依赖关系。

更一般地说,编写在服务器和浏览器上都能达到相同结果的代码被称为同构 JavaScript。有关更多解释和此原则的示例,请参阅isomorphic.net/

编写 npm 包

如果你创建了一些对他人有用的代码,你可以将其作为 npm 包分发。为了演示这一点,我们将实现一些稍微复杂的功能。

注意

你可以在github.com/NodeJsForDevelopers/autotoc找到本节示例代码。请注意,与之前的章节不同,这里没有每个提交每个标题的代码。本节其余部分的列表与代码的最终版本相匹配。

我们将实现一个通过爬取网站来生成目录ToC)的工具。为此,我们将使用几个其他的 npm 包:

  • request提供了一个用于发起 HTTP 请求的 API,它比 Node.js 内置的 http 模块更高级,使用起来也更简单

  • cheerio提供了在浏览器环境之外类似 jQuery 的 HTML 遍历功能

  • denodeify,在第八章掌握异步编程中提到,允许我们使用带有承诺而不是回调的请求库

提示

对于npm包来说,依赖其他包是很常见的。但如果你想让你的包对其他开发者更有吸引力,那么最小化你的包的依赖是有意义的。具有许多传递性依赖的包可能会给应用程序添加很多冗余,并使开发者更难确信他们理解了他们应用到应用程序中的所有内容。

我们模块的代码如下,如autotoc.js中所示:

'use strict';

const cheerio = require('cheerio');
const request = require('denodeify')(require('request'));
const url = require('url');

class Page {
  constructor(name, url) {
    this.name = name;
    this.url = url;
    this.children = [];
  }

  spider() {
    return request(this.url)
      .then(response => {
        let $ = cheerio.load(response.body);
        let promiseChildren = [];
        $('a').each((i, elem) => {
          let name = $(elem).contents().get(0).nodeValue;
          let childUrl = $(elem).attr('href');
          if (name && childUrl && childUrl !== '/') {
            let absoluteUrl = url.resolve(this.url, childUrl);
            if (absoluteUrl.indexOf(this.url) === 0 &&
                  absoluteUrl !== this.url) {
              let childPage = new Page(name.trim(), absoluteUrl);
              if (childUrl.indexOf('#') === 0) {
                promiseChildren.push(Promise.resolve(childPage));
              } else {
                promiseChildren.push(childPage.spider());
              }
            }
          }
        });
        return Promise.all(promiseChildren).then(children => {
          this.children = children;
          return this;
        });
      });
  }
}

module.exports = baseUrl => new Page('Home', baseUrl).spider();

我们不需要理解每一行,因为我们更感兴趣的是它将被如何打包。重要的一点是:

  • 我们加载起始页面,然后通过链接跳转到其他页面,并递归地处理这些页面以构建整个目录表(ToC)

  • 我们只跟随比当前页面更具体的 URL(即子路径)的链接,所以我们不会陷入无限循环

  • 在每个级别,我们并行加载所有子页面,并使用 Promise.all 来组合结果

我们还会添加一个简单的模块,将目录表(ToC)打印到控制台,如下所示 consolePrinter.js

'use strict';
const printEntry = function(entry, indent) {
        console.log(`${indent} - ${entry.name} (${entry.url})`);
        entry.children.forEach(childEntry => {
            printEntry(childEntry, indent + '  ');
        })
    }

module.exports = toc => printEntry(toc, '');

定义 npm 包

要定义一个 npm 包,我们必须添加一个文件作为我们包的入口点。这将只适当地暴露内部模块,如下所示 index.js

'use strict';
module.exports = require('./autotoc.js');
module.exports.consolePrinter = require('./consolePrinter.js');

我们还需要添加一个 npm package.json 文件来定义我们包的元数据。要创建此文件,你可以在命令行中运行 npm init 并按照提示操作。在我们的例子中,生成的文件如下所示:

{
  "name": "autotoc",
  "version": "0.0.1",
  "description": "Automatic table of contents generator for websites",
  "main": "index.js",
  "author": "hgcummings <npmjs@hgc.io> (http://hgc.io/)",
  "repository": "https://github.com/NodeJsForDevelopers/autotoc",
  "license": "MIT",
  "dependencies": {
    "cheerio": "⁰.20.0",
    "denodeify": "¹.2.1",
    "request": "².69.0"
  }
}

我们之前已经使用过 package.json 文件来指定 npm install 的依赖项。当将包发布到 npm 时,其他字段变得尤为重要。注意,我们使用 main 属性来指定我们包的入口点。实际上,index.js 是默认值,但明确指定可以使这更清晰。

将包发布到 npm

一旦我们定义了包的元数据,将其发布到 npm 就非常直接:

  • 如果你还没有 npm 账户,可以通过运行 npm adduser 并指定用户名和密码来创建一个

  • 使用 npm login 登录

  • 在包的 root 文件夹中,运行 npm publish

那就是我们需要做的全部!我们的包现在将出现在全局 npm 仓库中。我们可以通过(在一个新文件夹中)运行 npm install autotoc 并编写以下简单的演示脚本(如下所示 demo.js)来使用它:

'use strict';
const autotoc = require('autotoc');
autotoc('http://hgc.io')
    .then(autotoc.consolePrinter, err => console.log(err));

在命令行中运行 node demo.js 产生以下输出:

将包发布到 npm

在网络上运行自动化客户端

对自己的网站运行此类工具是可以的。这类技术有许多用例。例如,一个爬遍整个网站并检查每一页的脚本可以是一个有用的集成/烟雾测试。

涉及爬取你并不拥有的网站的用例需要更加小心。任何你可以在浏览器中访问的公开网站,你也可以使用像这样的自动化客户端访问。但是,对同一主机发出大量自动化请求是不受欢迎的。这最多被认为是不良礼仪,最坏的情况可能是 拒绝服务DoS)攻击。

客户端应设置适当的 User-Agent HTTP 头部。一些服务器可能会拒绝没有指定 User-Agent 或看起来不是浏览器的客户端的请求。按照惯例,爬虫应发送包含单词 botUser-Agent,并理想情况下提供一个可以了解更多关于爬虫信息的 URL。请求库通过传递一个选项对象来轻松指定头部。例如:

let options = {
  url: 'http://hgc.io',
  headers: {
    'User-Agent': 'Examplebot/1.0 (+http://example.com/why-im-crawling-your-website)'
  }
};
request(options).then(...);

爬虫也应该检查每个网站的 robots.txt 文件,并尊重其中包含的任何规则。更多信息请见 www.robotstxt.org/robotstxt.html

最后,第三方网站的合法爬虫也应该限制其请求的速率,以避免服务器过载。

发布独立工具到 npm

本书到目前为止使用的某些 npm 包是命令行工具而不是库,例如 Gulp。创建命令行工具包非常简单。首先,我们需要定义用户可以从命令行调用的脚本,如 cli.js 中所示:

#!/usr/bin/env node
'use strict';
const autotoc = require('./autotoc.js');
const consolePrinter = require('./consolePrinter.js');
autotoc(process.argv[2])
    .then(consolePrinter, err => console.log(err));

这看起来与之前的演示脚本非常相似,但有几点不同:

  • 脚本开头的一行(称为 shebang 行,以 #! 开头)指示操作系统使用 Node.js 来执行此脚本。

  • 要爬取的 URL 来自命令行参数

现在我们只需要在 package.json 中指定这个脚本:

{
  "name": "autotoc",
  "version": "0.1.1",
  "description": "Automatic table of contents generator for websites",
  "main": "index.js",
 "bin": {
 "autotoc": "./cli.js"
 },
  "author": "hgcummings <npmjs@hgc.io> (http://hgc.io/)","repository": "https://github.com/NodeJsForDevelopers/autotoc",
  "license": "MIT",
  "dependencies": {
    "cheerio": "⁰.20.0",
    "denodeify": "¹.2.1",
    "request": "².69.0"
  }
}

要发布我们的更新包,我们首先需要更新我们的版本号。你可以在包中直接更新或在 npm 中使用版本命令,例如

> npm version minor

这会自动将版本号更新到下一个主/次/补丁版本(如指定),并创建一个新的 git 提交来反映这一变化。

由于我们已经在 npm 中登录,现在我们可以通过再次运行 npm publish 来发布我们包的新版本。

我们现在可以这样使用我们的 CLI 工具(在一个新的命令提示符窗口中):

> npm install -g autotoc
> autotoc http://hgc.io

在浏览器中使用 Node.js 模块

在本章开头,我们讨论了创建可以在 Node.js 或浏览器中运行的通用模块。我们还可以让我们的代码在这两种环境中运行。

Browserify (browserify.org/) 允许你在浏览器中使用 Node.js 模块。它将你的代码及其依赖项打包在一起。它还提供了浏览器兼容的垫片来模拟 Node.js 内置模块。

你可以通过 npm 安装 Browserify:

> npm install -g browserify

Browserify 通常用于打包应用程序。例如,如果我们想打包上一节中演示的 autotoc 使用,我们可以运行:

> browserify demo.js -o bundle.js

Browserify 将创建一个包含 demo.js 代码及其依赖项和传递依赖项的单个 JavaScript 文件。如果我们将其包含在 HTML 页面中,现在我们可以在浏览器控制台中看到它的工作情况:

在浏览器中使用 Node.js 模块

你还可以使用 Browserify 生成符合浏览器规范的独立模块文件,遵循本章前面讨论的通用模块定义模式。例如,为了从上一节创建 autotoc.js 模块的 UMD 版本,我们可以运行:

> browserify autotoc.js -s autotoc -o browser/scripts/autotoc.js

我们现在可以利用 RequireJS 来使用它。让我们创建一个简单的应用程序,该应用程序使用 autotoc 与 jQuery 一起生成 HTML 目录。首先我们需要一个 HTML 文件来包含我们的应用程序并包含 RequireJS,如 browser/index.html 中所示:

<!DOCTYPE html>
<head>
    <script data-main="scripts/main" src="img/require.min.js"></script>
</head>
<body>
</body>

现在,我们可以实现我们的应用程序本身,如 browser/scripts/main.js 中所示:

requirejs.config({
  paths: {
    jquery: 'https://cdnjs.cloudflare.com/ajax/libs/jquery/2.2.1/jquery.min'
  }
});
require(['autotoc', 'jquery'], function(autotoc, $) {
  'use strict';
  autotoc('http://hgc.io').then(toc => {
    let printEntry = function(entry, parent) {
      let list = $(document.createElement('ul'));
      list.append(
        `<li><a href="${entry.url}">${entry.name}</a></li>`);
      entry.children.forEach(childEntry => {
        printEntry(childEntry, list);
      })
      parent.append(list);
    }

    printEntry(toc, $('body'));
  }, err => console.log(err));
});

这将产生以下输出:

在浏览器中使用 Node.js 模块

控制 Browserify 的输出

注意,默认情况下,Browserify 会生成一个包含您的代码及其所有依赖项的包。包括传递依赖项,这可能导致一个非常大的文件。autotoc 模块只有 42 行长,但生成的包却有超过 80,000 行!我们上面的应用程序包括 jQuery(通过 RequireJS)和 Cheerio 的一个版本(通过 Browserify)。这尤其浪费,因为 Cheerio 的大部分是 jQuery 的重新实现。

您可以指示 Browserify 排除特定的模块以及排除所有外部模块。这对于遵循 UMD 模式的第三方模块尤其有用。这些模块不需要被 browserified,并且可以从生成的包中排除。然后您可以在浏览器中单独加载它们,通过额外的脚本标签或使用 RequireJS。

关于 Browserify 使用选项的更多信息,请参阅官方文档github.com/substack/node-browserify#usage

Browserify 为以不同方式打包模块提供了很多灵活性。当在具有服务器端和客户端功能的单一代码库上工作时尤其有用。它允许您使用 Node.js 风格的模块编写所有代码,并轻松地在服务器和客户端之间共享模块。

摘要

在本章中,我们编写了一个遵循通用模块定义模式的跨环境模块,为库和命令行工具创建了一个 npm 包,并使用 Browserify 将 Node.js 代码打包到浏览器中。

这展示了 Node.js 的灵活性和 JavaScript 以及 npm 在服务器端代码之外的用例范围。在最后一章中,我们将探讨 Node.js 的更广泛背景。我们将看到一些针对该平台的新语言和即将推出的语言特性,以及 Node.js 如何与其他平台如 .NET 交互。

Node.js 与其超越

到目前为止,这本书已经向您展示了如何在各种用例中与 JavaScript 和 Node.js 一起工作。在本章中,我们将探讨 JavaScript 生态系统如何持续发展。我们还将看到.NET 和 JavaScript 生态系统如何相互影响,以及如何在单个项目中集成它们。

虽然到目前为止的章节旨在引导您进入 Node.js 和 JavaScript 的道路,但本章旨在绘制剩余的领域。前几章已经对单个主题进行了深入的逐步覆盖。本章将涵盖更广泛的主题范围,并提供进一步阅读的资源链接。

在本章中,我们将:

  • 理解 Node.js 和 JavaScript 如何持续发展

  • 介绍一些新的和即将推出的 JavaScript 语言特性

  • 查看 Node.js 和 Web 的替代编程语言

  • 考虑适用于.NET 编程的 Node.js 原则

  • 了解如何将 Node.js 与.NET 集成

第十四章:理解 Node.js 版本控制

如第一章中所述,为什么选择 Node.js?,2015 年 Node.js v4 的发布显示了该平台正在走向成熟。如果您在 2015 年底之前使用过 Node.js,您会看到如 v0.8.0 或 v0.12.0 之类的版本号。那么为什么会有 v4.0.0 的跳跃呢?

Node.js 简史

Node.js 是一个有企业赞助的开源项目,赞助商为 Joyent。这意味着一个公司对 Node.js 的发展方向有很大的影响力,但任何人都可以创建自己的源代码分支。这正是 2014 年底发生的事情。一组 Node.js 的主要贡献者将项目拆分,创建了一个新的分支,名为io.js。io.js 的一些关键特性包括:

  • 更为开放的治理模式

  • 更为规律的发布周期,与底层 V8 引擎保持更紧密的同步,以利用性能改进和新的 JavaScript 语言特性

  • 转向语义版本控制(见semver.org/),导致主版本号增加更快

在 2015 年期间,Node.js 项目重塑自身以承担上述特性并与 io.js 保持一致。2015 年 9 月,Node.js v4 的发布将两个项目重新整合到一个新的治理模式之下。Node.js v4 取代(并合并)了 Node.js v0.12 和 io.js v3.3。您可以在nodejs.org/en/about/governance/了解更多关于新治理模式的信息。

介绍 Node.js 长期支持(LTS)计划

Node.js 的发布时间表现在遵循一个常规的日程。每 6 个月就会有一个新的稳定版本发布。每个稳定分支都会收到修复以及成熟的新特性。稳定版本的寿命交替如下(如下表所示):

  • 奇数分支存活 9 个月

  • 偶数分支在 6 个月后进入 长期支持LTS)阶段,只接收错误修复,没有新特性

  • 长期支持持续 30 个月,最后 12 个月为维护模式(仅修复关键错误)

介绍 Node.js LTS 时间表

你可以在 github.com/nodejs/LTS 找到更多关于 LTS 模型的详细信息。

LTS 模型使你能够对 Node.js 作为应用程序平台有信心。本书中的代码针对 Node.js v6,这是出版时的当前稳定版本。这个版本将一直处于 LTS 状态,直到 2019 年 4 月,大约三年后。

理解 ECMAScript 版本

ECMAScript 是 JavaScript 语言的正式标准。语言的前三个版本发生在 1997 年至 1999 年之间。在 2009 年 12 月的 ECMAScript 5 之前有一个 10 年的间隔。ES5 引入了很少的新特性,并专注于清理语言。它引入了严格模式,并解决了早期版本中的各种不一致性、缺陷或陷阱。

2015 年对语言和版本方法都进行了重大改变。ECMAScript 2015(以前称为 ECMAScript 6)引入了许多重要的新语言特性。这些包括类、let/const 关键字和块作用域、箭头函数和原生的承诺。在本章的剩余部分,我们将探讨 ES2015 中的一些其他重要新特性。

从 ES6 到 ES2015 的名称变更表明了一个新的年度版本模型。从 2015 年开始,每年都会有新的 ECMAScript 标准版本。尚未准备好的计划特性将等待下一年。因此,ECMAScript 2016 是一个小版本,只包含几个新特性。

注意,ECMAScript 是一个标准,新特性被实现需要时间。实际上,一些 ES2015 的特性在流行浏览器的 JavaScript 引擎中仍然缺失。不过,主要的浏览器供应商都是 ECMAScript 标准流程的一部分。因此,浏览器,尤其是 Chrome 的 V8 引擎(Node.js 所使用),通常不会落后于最新的标准太远。

探索 ECMAScript 2015

我们已经在本书中使用了 ES2015 的许多新特性,例如箭头函数、模板字符串和承诺。我们已经在 第三章 JavaScript 入门 中看到了 ES2015 的类语法。

ES2015 是语言的一个重大更新,包括许多新特性和语法改进。本节将涵盖一些我们在本书中尚未见到的其他有用改进。要全面了解 ES2015 中的所有新特性,请参阅优秀的《探索 ES6》,可在 exploringjs.com/es6/ 获取。

理解 ES2015 模块

如前几章所述,ES2015 引入了一种新的模块规范。回想一下第四章,介绍 Node.js 模块,每个模块系统都提供以下功能:

  • 声明具有名称和自身作用域的模块的方式

  • 定义模块提供功能的方式

  • 将模块导入到另一个脚本中的方式

模块的作用域限定在其包含的文件中,就像 CommonJS 一样。模块通过一个新的export关键字提供功能。在表达式前加上export相当于将其作为module.exports变量的属性。特殊的default export相当于将module.exports的值赋给它。模块使用import关键字导入,而不是特殊的require函数。还有一个额外的限制:导入必须位于脚本顶部,在所有条件块或其他逻辑之前。

这些可能看起来像是微小的语法变化,但它们有着重要的含义。因为定义和导入模块不涉及赋值和方法调用,模块之间的依赖结构是静态的。这允许 JavaScript 引擎优化模块的加载(这在浏览器中尤为重要)。这也意味着可以解决模块之间的循环依赖。

你可以在jsmodules.io/了解更多关于新的 ES2015 模块语法的详细信息。

使用 ES2015 的语法改进

在本节中,我们将探讨一些 ES2015 中的一些新语法特性,这些特性我们在书中还没有使用过。这些特性在最新的 JavaScript 引擎中都是可用的,包括 Node.js v6。

for... of 循环

假设我们定义了一个如下所示的数组:

let myArray = [1, 2, 3];

让我们再假设另一个库已经为所有数组添加了一个辅助函数。可能类似于我们在第十三章中提到的flatMap函数,第十三章:创建 JavaScript 包。

Array.prototype.flatMap = function(callback) {
    return Array.prototype.concat.apply([], this.map(callback));
};

如果你想要遍历数组的所有成员,你可能倾向于使用 JavaScript 的for... in结构,如下所示:

for (let i in myArray) {
    console.log(myArray[i]);
}

然而,这并不太有效,因为它包括了数组原型的属性,并打印出了flatMap函数以及数组中的元素。这是for... in循环的一个常见问题,无论是与对象还是数组一起使用时。避免这种情况的标准方法是跳过原型属性,如下所示:

for (let i in myArray) {
    if (myArray.hasOwnProperty(i)) {
        console.log(myArray[i]);
    }
}

这将只打印出数组的元素,正如我们所期望的。类似的循环也可以用来打印对象的属性,而不会意外地尝试打印出原型上的函数(这些函数可能是由第三方库添加的)。

注意,for... in 在技术上并不保证迭代对象键的顺序。这意味着它并不是与数组一起使用的最佳选择,因为我们期望特定的顺序。这就是为什么标准的方法是使用普通的 for 循环来遍历数组,如下所示:

for (let i = 0; i < myArray.length; ++i) {
    console.log(myArray[i]);
}

ES2015 通过引入新的 for... of 循环来解决这些问题,其语法如下:

for (let value of myArray) {
    console.log(value);
}

语法与 for... in 循环非常相似。然而,你不需要过滤原型成员,因为这些成员被排除在外。它可以与任何可迭代对象(如数组)一起使用,并遵循可迭代对象的自然顺序。简而言之,for... of 循环就像 for... in 循环,但没有任何令人不快的惊喜。

扩展运算符和剩余参数

扩展运算符 允许你将数组视为一系列值。例如,要调用一个函数:

let myArray = [1, 2, 3];
let myFunc = (foo, bar, baz) => (foo + bar) * baz;
console.log(myFunc(...values)); // Prints 9

你还可以在数组字面量中使用扩展运算符,例如:

let subClauses = ['2a', '2b', '2c'];
let clauses = ['1', '2', ...subClauses, '3'];
    // Equivalent to ['1', '2', '2a', '2b', '2c', '3']

剩余参数 语法具有相反的作用,将一系列值转换为一个数组。这与 C# 中的 params 关键字或 Java 中的 varargs 类似。例如:

function foldLeft(combine, initial, ...values) {
    let result = initial;
    for (let value of values) {
       result = combine(result, value);
    }
    return result;
}
console.log(foldLeft((x, y) => x+y, 0, 1, 2, 3, 4)); // Prints 10

解构赋值

解构允许你使用结构化语法一起分配多个变量。例如,你可以使用数组字面量语法来解构数组:

let foo, bar;
[foo, bar] = [1, 2]; // Equivalent to foo = 1, bar = 2

你还可以将解构与扩展运算符结合使用:

[foo, bar, ...rest] = [1, 2, 3, 4, 5];
    // Equivalent to foo = 1, bar = 2, rest = [3, 4, 5]

最后,你可以使用对象字面量语法进行解构:

{ foo, bar } = { foo: 1, bar: 2 }; // Equivalent to foo=1, bar=2

解构对于处理复杂的返回值特别有用。想象一下,如果上述示例中等于号右侧的任何表达式实际上是函数调用。

解构对于在单个语句中执行多个赋值也很有用。例如:

[foo, bar] = [bar, foo]; // Swap foo and bar in place
[previous, current] = [current, previous + current];
    // Calculation step for a Fibonacci sequence

介绍生成器

ES2016 引入了 生成器函数yield 关键字。你可能已经熟悉 C# 中的 yield 关键字。返回 IEnumerable/IEnumerator 的方法可以包含 yield 关键字,一次返回一个元素,直到请求下一个值时才暂停方法的执行。你可以在 JavaScript 中的生成器函数中做同样的事情。以下是一个 JavaScript 实现的示例,它来自 C# 的 yield 的 MSDN 文档中的一个示例。它打印出 2 的前八个幂(注意函数关键字后面的星号,表示这是一个生成器函数):

'use strict';
function* powers(number, exponent) {
    let result = 1;
    for (let i = 0; i < exponent; ++i) {
        result = result * number;
        yield result;
    }
}
for (let i of powers(2, 8)) {
    console.log(i);
}

注意,for... of 循环与生成器一起工作。上面的循环等同于以下代码:

let generator = powers(2, 8);
let current = generator.next();
while (!current.done) {
    console.log(current.value);
    current = generator.next();
}

你可以看到生成器与 C# 中的 IEnumerator 接口非常相似。注意,尽管如此,它们的功能要强大一些。我们还可以将一个值传递到生成器的 next 方法中,以便在生成器函数继续执行时使用。以下是一个示例来说明这一点:

'use strict';
function* generator() {
    let received = yield 1;
    console.log(received);
    return 3;    
}
let instance = generator();
let first = instance.next();
console.log(first);
let last = instance.next(2);
console.log(last);

运行前面的示例会产生以下输出:

> { value: 1, done: false }
> 2
> { value: 3, done: true }

这种双向通信使得生成器在 JavaScript 中远不止是 IEnumerator。它们是一种强大的控制流机制,尤其是在与承诺结合使用时。参见www.promisejs.org/generators/,了解如何使用生成器和承诺(用 yield 代替 C# 的 await 关键字)实现类似 C# 的 async/await 功能。还值得注意的是,async 函数计划在 ECMAScript 的未来版本(可能是 ES2017)中实现,并且将以类似的方式工作。在此期间,您可以使用 bluebird 库提供的 Promise.coroutine 方法实现类似的编程模型,该方法基于生成器。有关详细信息,请参见bluebirdjs.com/docs/api/promise.coroutine.html

介绍 ECMAScript 2016

如本章前面所述,ECMAScript 2016 是一个小版本,只包含几个新功能。这些是一个用于数组的 includes 方法以及指数运算符 **

您可以使用 myArray.includes(value) 代替 myArray.indexOf(value) !== -1。请注意,这些表达式并不完全等价。您可以使用 includes 在数组中检查值 NaN,这是您无法使用 indexOf 做到的。

指数运算符允许您将 Math.pow(coefficient, exponent) 重写为 coefficient ** exponent

您还可以将其与赋值结合使用,例如 myVariable **= 2

超越 JavaScript

如果您想针对浏览器或 Node.js,JavaScript 是这些环境中唯一原生支持的编程语言。这与基于虚拟机的环境(如 .NET 运行时和 JVM)不同,这些环境支持多种语言。

.NET 运行时支持 C#、F#、VB.NET 等语言。JVM 支持 Java、Scala、Clojure 等语言。这些语言通过将代码编译成环境虚拟机的汇编语言来工作。在 .NET 中这是公共中间语言(CIL),在 JVM 的情况下是 Java 字节码。

尽管如此,程序员并不都编写 CIL 或 Java 字节码,这也有原因。这些都是低级机器语言,比 C#、Java 等语言要少有人性化。一般来说,高级语言可以支持更高的生产力,以及安全性(例如,通过类型系统和内存管理)。

.NET 程序员并不总是使用 C#,JVM 程序员也不总是使用 Java,这也有原因。不同的语言可以更好地满足不同的用例。这也可以仅仅是个人对特定语言语法的喜好问题。

JavaScript 被称为“Web 的汇编语言”(www.hanselman.com/blog/JavaScriptIsAssemblyLanguageForTheWebSematicMarkupIsDeadCleanVsMachinecodedHTML.aspx)。虽然 JavaScript 不是低级或机器语言,但它是其平台上的通用语言。像 CIL 和 Java 字节码一样,它可以作为其他语言的编译目标。而且,像.NET 和 JVM 一样,开发者对同一平台上的多种语言有需求。

探索编译到 JavaScript 的语言

有几种语言通过编译成 JavaScript 来支持 Web 和 Node.js 开发。在本节中,我们将探讨其中一些较为突出的语言。

TypeScript

TypeScript 语言由 Microsoft 开发和支持。其关键目标是包括有助于大规模应用程序开发的特性。TypeScript 可以编译成 ES2016、ES5,甚至 ES3。因此,它在任何现代 JavaScript 环境中都能工作。

TypeScript 基于 JavaScript 语法紧密构建。它是 JavaScript 的超集,因此您可以编写普通的 JavaScript,随着学习的深入,逐渐更多地使用 TypeScript 特性。TypeScript 还试图尽可能匹配即将推出的 JavaScript 特性的语法。这允许开发者更早地开始使用新的 JavaScript 特性。

TypeScript 最重要的特性有助于大规模应用程序的开发。TypeScript 已经有一段时间支持类和模块,以帮助结构化代码。正如其名所示,TypeScript 还增加了类型注解和类型推断。它还增加了定义和指定类型的新方法,包括枚举、泛型类型和接口。这使得语言更安全,因为编译器可以捕获更多的错误。它还允许 IDE 提供诸如代码补全(即 Intellisense)和更好的源代码导航等特性。

最后,TypeScript 使得为用纯 JavaScript 编写的库指定类型定义成为可能。许多第三方库的类型定义可以在github.com/DefinitelyTyped/DefinitelyTyped找到。这些提供了在处理库代码时的类型检查和代码补全。

这里是一个来自上一章的flatMap函数示例,使用了类型注解:

function flatMap<T, R>(
    source:T[],
    callback:(T)=>R[]): R[] {
    return Array.prototype.concat.apply([],
        source.map(callback));
}
let result = flatMap([1, 2, 3], (i:number) => [i, i + 0.5]);
console.log(result); // Prints [1, 1.5, 2, 2.5, 3, 3.5]

泛型的语法可能来自 C#。类型注解跟随表达式或参数,由冒号分隔。我们也可以在调用函数时指定泛型类型,但在这个例子中它可以被推断。请注意,我们的方法有两个泛型类型,因为我们的回调可以映射到不同元素类型的数组。TypeScript 编译器将推断result的类型为number[]。请注意,这种推断实际上需要几个步骤:

  • 我们指定callback参数i的类型为number

  • 因此,表达式 ii + 0.5 也都具有 number 类型

  • 因此,我们的 callback 的结果类型是 number[]

  • 因此,类型参数 R 的参数必须是 number

如果我们没有指定 i 的类型,那么编译器只会推断 result 的类型为 any[],即一个数组,但元素类型未指定。

你可以在 www.typescriptlang.org/ 上了解更多关于 TypeScript 的信息。

小贴士

如果你比 .NET 更熟悉 Java,尤其是如果你特别熟悉 Eclipse IDE,那么你也可能对 N4JS (numberfour.github.io/n4js/) 感兴趣。这种语言的目标与 TypeScript 类似,但受到 Java 的启发,并且有一个基于 Eclipse 的 IDE。

CoffeeScript

CoffeeScript 是最早成功的编译到 JavaScript 的语言之一。CoffeeScript 简化了 JavaScript 的语法,并添加了编写更简洁和更具表现力的代码的功能。

CoffeeScript 是一个很好的例子,说明了品味可能会影响语言选择。开发者可能会发现 CoffeeScript 更易读和/或更容易编写。Ruby 或 Python 程序员可能会特别适应 CoffeeScript。他们会发现其语法和许多语言特性都很熟悉。

许多来自 CoffeeScript 的特性后来出现在了 ES2015 中,例如箭头函数、解构和展开操作符。与 TypeScript 不同,CoffeeScript 并不试图匹配 JavaScript 的语法,无论是当前还是即将推出的特性。然而,它确实提供了与 JavaScript 代码的无缝互操作性。

理解是 CoffeeScript 最具表现力的特性之一,并且没有出现在 ES2015 中。你可能对 Python 中的理解很熟悉。它们也与 C# 中的 LINQ 有点相似,因为它们允许你在不使用循环的情况下对列表进行操作。以下示例首先以 JavaScript 打印偶数的平方,然后以 CoffeeScript 的一行代码打印。作为 squares.js

var i, n;
for (n = i = 1; i <= 10; n = ++i) {
    if (n % 2 === 0) {
        console.log(n * n);
    }
}

作为 squares.coffee

console.log n*n for n in [1..10] when n%2 is 0

以及更多...

TypeScript 和 CoffeeScript 是专门设计用来针对 JavaScript 的。存在许多其他项目允许更通用的语言编译成 JavaScript。请注意,并非所有这样的项目都成熟或维护良好。那些自己的项目团队支持并维护编译到 JavaScript 的语言往往是一个更安全的选择。Dart (www.dartlang.org/) 和 Clojure (clojure.org/) 都提供了对编译到 JavaScript 的第一级支持。

介绍真正的网络汇编语言

如上所述,虽然 JavaScript 可以成为网络和 Node.js 的通用编译目标,但它并不是一种真正的汇编语言。它是一种高级的可读语言,而不是优化的机器语言。尽管如此,有一些项目旨在将这种语言引入网络环境。这意味着定义一种由所有浏览器实现的汇编语言,包括 Chrome 的 V8 引擎和 Node.js。

理解 asm.js

对这种语言的第一次尝试是 asm.js (asmjs.org/),由 Mozilla 开发。这是一个严格的 JavaScript 子集,这意味着它可以在任何浏览器上运行。但支持 asm.js 的浏览器可以预先编译它,并对其执行进行大量优化。要求较高的应用程序,如 3D 游戏,可以重新编译为针对 asm.js,并在浏览器中无缝运行。完全支持 asm.js 的第一个环境是 Mozilla 自己的 Firefox 浏览器。它也将被 Microsoft 的新 Edge 浏览器支持。Chrome(和 Node.js)使用的 V8 引擎目前还没有预先编译 asm.js,但 V8 确实进行了一些优化,使得 asm.js 的运行速度比作为纯 JavaScript 解释要快得多。

理解 WebAssembly

WebAssembly (webassembly.github.io/) 是一种针对网络的真实汇编语言的新标准。与 asm.js 不同,它不是 JavaScript 的子集,并且不会在今天的浏览器上运行。它定义了一种新的汇编语言,更类似于 CIL 或 Java 字节码。它由 W3C 标准机构开发,并得到了主要浏览器供应商的反馈。Mozilla Firefox、Google Chrome 和 Microsoft Edge 的预览版本中都有 WebAssembly 的早期实现。

作为应用程序开发者,你不需要能够编写 WebAssembly,就像你不需要能够编写 CIL 或 Java 字节码一样。这些都是作为编译目标的低级语言。在未来,WebAssembly 可能会取代 JavaScript 成为网络(和 Node.js)的通用编译目标。包括 JavaScript 本身在内的其他语言都可能编译到 WebAssembly。

这意味着 JavaScript 将不再是网络和 Node.js 的唯一原生语言。但 JavaScript 几乎肯定将继续成为这些环境的默认开发语言,就像 C# 和 Java 分别是其各自环境的默认语言一样。了解 Node.js 的执行模型在任何语言中都将仍然相关,JavaScript 也将是这个执行模型最自然的选择。了解 JavaScript 对于使用基于它的许多成熟库也将非常重要。

WebAssembly 将为 JavaScript 带来其他好处。JavaScript 与其他语言的互操作性将变得更加容易。将会有更多选项用于实现性能关键代码。新的 JavaScript 版本将能够更快地推出(因为单个 JavaScript 到 WebAssembly 编译器可以针对所有浏览器引擎)。

JavaScript 和 ASP.NET

在服务器端,我们不需要等待 WebAssembly 成熟就可以与 Node.js 和.NET 一起工作。这两个平台上的编程已经有一些趋同,并且它们之间也支持互操作性。

探索.NET Core

.NET 的下一个版本,称为.NET Core,对该平台进行了重大更改。如果你花了一些时间与 Node.js 一起工作,其中一些更改可能看起来很熟悉。这并非巧合。微软正在将 Node.js 和其他地方行之有效的好想法整合到他们的生态系统中。

定义.NET Core 中的项目结构

.NET Core 将编程平台与 IDE 分离。微软仍然推荐使用 Visual Studio,但已经使使用其他编辑器变得更加容易。例如,OmniSharp 项目(www.omnisharp.net/)支持在其他编辑器中进行开发,并提供诸如 Visual Studio 之外的 Intellisense 等特性。

这些变化的一个方面是简化了.csproj文件的使用。在.NET 的早期版本中,这些大型 XML 文件是每个 C#项目的规范描述。它们包括编译选项、目标平台、构建步骤和依赖等重要内容。它们主要是由 Visual Studio 生成的,手动编辑困难,并且在源控制中合并时往往特别棘手。为了满足 Visual Studio,它们还需要列出项目中的每个单个源文件。

.NET Core 解决了许多这些缺点。新的工具使得从命令行编辑.csproj文件变得更加容易。项目源代码只是其父文件夹下的文件(不在.csproj或其他元数据文件中列出)。依赖项在更轻量级的基于 JSON 的文件中单独声明。

许多这些改进都受到了像 Node.js 这样的编程平台的影响。事实上,.NET Core 的早期候选版本完全去除了.csproj文件的需求,并引入了project.json文件(就像在 Node.js 中一样)来定义项目。尽管.NET Core 最终仍然使用.csproj文件(以保持与 MSBuild 的兼容性),但它旨在保留对开发者来说最重要的更轻量级方法的一些方面。

管理.NET Core 中的依赖项

NuGet 包管理器已经作为.NET 生态系统的一部分存在了几年。在.NET Core 中,NuGet 变得更加重要。框架和运行时本身都是以 NuGet 包的形式分发的。依赖项指定为 NuGet 包名称(和版本)而不是 DLL 路径。NuGet 包也可以成为你自己的项目的有用部署单元。

就像 Node.js 一样,你可以将你依赖项之一的源代码检出到一个本地文件夹中,并在那里引用它。这允许你修改开源库,并将它们作为你程序的一部分进行调试。

在 ASP.NET Core 中构建 Web 应用程序

ASP.NET Core 将 ASP.NET MVC 和 WebAPI 合并成一个单一框架。它还将 OWIN 提升为实施 Web 应用程序的标准抽象。

OWIN 简单地定义了在主机和应用程序之间传递请求和响应对象的标准。尽管 OWIN 已经存在一段时间并且有自己的历史,但这与 Node.js 中的 http.createServer 方法类似。您可以在 docs.asp.net/en/latest/fundamentals/owin.html 上了解更多关于 OWIN 的信息。

与此相关,ASP.NET 还使用中间件作为 Web 应用程序的标准构建块。尽管 .NET 中的中间件有自己的历史,但抽象与 Express 中的中间件非常相似。应用程序设置一个中间件管道,每个中间件都可以访问请求、响应和链中的下一个处理器。内置中间件可用于诸如身份验证、会话和路由等跨切面关注点。您可以在 docs.asp.net/en/latest/fundamentals/middleware.html 上了解更多关于中间件的信息。

与 JavaScript 的集成

Visual Studio 已经为客户端 JavaScript 开发提供了多年的良好支持。在最新的 ASP.NET 和 Visual Studio 版本中,微软对其进行了改进和更新:例如,通过包括与任务运行器(如 Gulp 和 Grunt)的更好集成。您可以在 docs.asp.net/en/latest/client-side/index.html 上了解更多关于客户端 JavaScript 的信息。

.NET 与服务器端 JavaScript 的集成

Edge.js 项目 (github.com/tjanczuk/edge) 允许 Node.js 和 .NET 在同一进程中运行。它还定义了在两者之间进行方法调用的非常简单的方式。这比在进程外调用(例如,通过本地机器上的进程的 HTTP 调用)进行调用要快得多。

Edge.js 允许您取 .NET 和 Node.js 的最佳之处。也许您想使用 Node.js 在现有的 .NET 业务逻辑之上放置一个 Web 界面。或者也许您正在使用 Node.js 快速开发应用程序的大部分,但有一个特别占用 CPU 的操作,在 .NET 中优化会更简单。

从 Node.js 调用 .NET(或反之亦然)非常简单。例如,如果我们有以下 .NET 类:

using System;
using System.Threading.Tasks;
namespace DeepThought
{
  public class UltimateQuestion
  {
    public Task<Object> GetAnswer(object input) {
      var result = new
      {
        description =
          "Answer to The Ultimate Question of " + input,
        value = 42
      };
      return Task.FromResult<object>(result);
    }
  }
}

我们可以在 JavaScript 中如下使用它(在运行 npm install edge 之后):

'use strict';
const edge = require('edge');
let getAnswer = edge.func({
    assemblyFile: 'bin\\Debug\\DeepThought.dll',
    typeName: 'DeepThought.UltimateQuestion',
    methodName: 'GetAnswer'
});
getAnswer('Life, the Universe, and Everything', (error, result) => {
    console.log(result);
});

编译我们的 C# 代码并运行我们的 JavaScript 文件会产生以下输出:

> node index.js
> { description: 'Answer to The Ultimate Question of Life, the Universe, and Everything', value: 42 }

您可以在 www.hanselman.com/blog/ItsJustASoftwareIssueEdgejsBringsNodeAndNETTogetherOnThreePlatforms.aspx 上找到关于 Edge.js 的良好介绍。

最后,回顾一下,OWIN 标准和 ASP.NET 中间件与 JavaScript 中的相应概念相当相似。Edge.js 使得将 .NET OWIN 应用程序作为中间件包含在 Node.js Express 应用程序中变得容易。有关详细信息,请参阅 connect-owin 项目,网址为 github.com/bbaia/connect-owin

摘要

在本章中,我们看到了 Node.js 和 JavaScript 的新发布周期如何为平台带来稳定性。我们介绍了一些 JavaScript 的新功能和即将推出的功能。我们探讨了 JavaScript 环境中的当前和未来替代语言。我们还看到了 .NET 和 Node.js 之间的共同点以及如何使用这些技术一起工作。

我希望这本书能帮助你开始使用 Node.js,并激发你进一步学习的兴趣。本章中的资源将帮助你继续你的 JavaScript 和 Node.js 之旅。

posted @ 2025-10-11 12:55  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报