Node-实战第二版-全-

Node 实战第二版(全)

原文:Node.js in Action 2e

译者:飞龙

协议:CC BY-NC-SA 4.0

第一部分. 欢迎来到 Node

Node 现在是一个成熟的 Web 开发平台。在第一章到 3 章中,您将了解 Node 的主要功能,包括如何使用核心模块和 npm。您还将看到 Node 如何使用现代 JavaScript,以及如何从头开始构建 Web 应用程序。阅读这些章节后,您将对 Node 能做什么以及如何创建自己的项目有一个坚实的理解。

第一章. 欢迎来到 Node.js

本章涵盖

  • 什么是 Node.js?

  • 定义 Node 应用程序

  • 使用 Node 的优势

  • 异步和非阻塞 I/O

Node.js 是一个异步、事件驱动的 JavaScript 运行时,它提供了一个强大而简洁的标准库。它由 Node.js 基金会管理和支持,该基金会是一个具有开放治理模式的行业联盟。目前有两个活跃支持的 Node 版本:长期支持(LTS)和当前版本。如果您想了解更多关于 Node 如何管理的相关信息,官方网站提供了丰富的文档(nodejs.org/)。

自从 Node.js 在 2009 年出现以来,JavaScript 已经从一种几乎不被容忍的以浏览器为中心的语言转变为各种软件开发中最重要的语言之一。这部分是由于 ECMAScript 2015 的推出,它解决了语言先前版本中的几个关键问题。Node 使用基于 ECMAScript 第六版的 Google V8 JavaScript 引擎,有时称为 ES6,缩写为 ES2015。这也得益于 Node、React 和 Electron 等创新技术,这些技术使得 JavaScript 可以在任何地方使用:从服务器到浏览器,以及原生移动应用程序。甚至像微软这样的大公司也在拥抱 JavaScript,微软甚至为 Node 的成功做出了贡献。

在本章中,您将了解更多关于 Node、其事件驱动的非阻塞模型以及 JavaScript 成为优秀的通用编程语言的一些原因。首先,让我们看看一个典型的 Node Web 应用程序。

1.1. 一个典型的 Node Web 应用程序

Node 和 JavaScript 的一般优势之一是它们的单线程编程模型。线程是常见的错误来源,尽管一些最近的编程语言,包括 Go 和 Rust,试图提供更安全的并发工具,但 Node 仍然保留了浏览器中使用的模型。在基于浏览器的代码中,我们编写一系列依次执行的指令;代码不会并行执行。然而,这对于用户界面来说是没有意义的:用户不希望等待缓慢的操作,如网络或文件访问完成。为了解决这个问题,浏览器使用事件:当你点击一个按钮时,会触发一个事件,并运行一个之前已定义但尚未执行的功能。这避免了线程编程中的一些问题,包括资源死锁和竞争条件。

1.1.1. 非阻塞 I/O

在服务器端编程的上下文中这意味着什么?情况类似:I/O 请求,如磁盘和网络访问,也是相对较慢的,所以我们不希望运行时在读取文件或通过网络发送消息时阻塞业务逻辑的执行。为了解决这个问题,Node 使用三种技术:事件、异步 API 和非阻塞 I/O。非阻塞 I/O 是从 Node 程序员的角度来看的一个低级术语。这意味着你的程序可以在做其他事情的同时请求网络资源,然后,当网络操作完成时,将运行一个回调来处理结果。

图 1.1 展示了一个典型的 Node 网络应用程序,该程序使用 Web 应用程序库 Express 来处理商店的订单流程。浏览器发出购买产品的请求,然后应用程序检查当前的库存,为用户创建账户,发送电子邮件收据,并发送 JSON HTTP 响应。同时,还有其他事情发生:发送电子邮件收据,并使用户的详细信息更新数据库。代码本身很简单,是命令式 JavaScript,但运行时是并发的,因为它使用了非阻塞 I/O。

图 1.1. Node 应用程序中的异步和非阻塞组件

图片 01fig01

在 图 1.1 中,数据库是通过网络访问的。在 Node 中,这种网络访问是非阻塞的,因为 Node 使用一个名为 libuv (libuv.org/) 的库来提供对操作系统非阻塞网络调用的访问。这在 Linux、macOS 和 Windows 中的实现方式不同,但你只需要关心你友好的 JavaScript 数据库库。当你编写 db.insert(query, err => {}) 这样的代码时,Node 在底层进行高度优化的非阻塞网络操作。

磁盘访问类似,但有趣的是并不完全相同。当生成电子邮件收据并从磁盘读取电子邮件模板时,libuv 使用线程池来提供使用非阻塞调用的错觉。管理线程池一点也不好玩,但编写 email.send('template.ejs', (err, html) => {}) 的确更容易理解。

使用异步 API 和非阻塞 I/O 的真正好处是,Node 可以在这些相对较慢的过程发生时做其他事情。尽管你只有一个线程、单进程的 Node 网络应用程序在运行,但它可以同时处理来自可能成千上万的网站访问者的多个连接。要理解这一点,你需要看看事件循环。

1.1.2. 事件循环

现在让我们聚焦于图 1.1 的一个具体方面:响应浏览器请求。在这个应用中,Node 的内置 HTTP 服务器库,即核心模块 http.Server,通过结合流、事件和 Node 的 HTTP 请求解析器(这是原生代码)来处理请求。这会在你的应用中触发一个回调函数的执行,该回调函数是通过 Express (expressjs.com/) 网络应用库添加的。运行的回调函数会导致数据库查询执行,最终应用通过 HTTP 以 JSON 格式响应。整个过程使用了至少三个非阻塞的网络调用:一个用于请求,一个用于数据库,另一个用于响应。Node 是如何调度所有这些非阻塞网络操作的?答案是事件循环。图 1.2 展示了事件循环是如何用于这三个网络操作的。

图 1.2. 事件循环

图片

事件循环以一个方向运行(它是一个先进先出队列)并经过几个阶段。图 1.2 展示了循环每次迭代运行的重要阶段的简化集合。首先,定时器执行,这些是使用 JavaScript 函数setTimeoutsetInterval安排的定时器。接下来,I/O 回调运行,如果任何 I/O 从非阻塞网络调用中返回,那么你的回调就会在这里被触发。轮询阶段是获取新的 I/O 事件的地方,然后使用setImmediate安排的回调在最后运行。这是一个特殊情况,因为它允许你在队列中已经存在的当前 I/O 回调之后立即安排一个回调。这个阶段可能听起来很抽象,但你应该记住的是,尽管 Node 是单线程的,但它确实为你提供了编写高效和可扩展代码的工具。

在过去的几页中,你可能已经注意到示例是使用 ES2015 箭头函数编写的。Node 支持许多新的 JavaScript 特性,所以在继续之前,让我们看看你可以使用哪些新的语言特性来编写更好的代码。

1.2. ES2015、Node 和 V8

如果你曾经使用过 JavaScript 并且因为缺乏类和奇怪的作用域规则而感到沮丧,那么你很幸运:Node 已经修复了这些问题中的大多数!你现在可以创建类,使用constlet(而不是var)可以解决作用域问题。截至 Node 6,你可以使用默认函数参数、剩余参数、spread操作符、for...of循环、模板字符串、解构、生成器等等。关于 Node 对 ES2015 支持的详细总结可以在node.green找到。

首先,让我们看看类。ES5 及更早版本需要使用原型对象来创建类似类的结构:

function User() {
  // constructor
}

User.prototype.method = function() {
  // Method
};

使用 Node 6 和 ES2015,你现在可以使用类来编写相同的代码:

class User {
  constructor() {}
  method() {}
}

这段代码更简洁,也更易于阅读。但不仅如此:Node 还支持子类化、super 和静态方法。对于那些熟悉其他语言的开发者来说,采用类语法使得 Node 比我们当时只能使用 ES5 时更容易上手。

Node 4 及以上版本中另一个重要的特性是 constlet 的加入。在 ES5 中,所有变量都是用 var 创建的。var 的问题在于它定义了函数或全局作用域中的变量,因此你无法在 if 语句、for 循环或其他块中定义块级变量。

我应该使用 const 还是 let?

当决定是否使用 constlet 时,你几乎总是想要 const。因为你的大部分代码将使用你自己的类的实例、对象字面量或不变的值,所以你大部分时间都可以使用 const。即使具有可变属性的对象的实例也可以用 const 声明,因为 const 只意味着引用是只读的,并不意味着值是不可变的。

Node 还具有原生的 promises 和 generators。Promises 被许多库支持,允许你以流畅的接口风格编写异步代码。你可能已经熟悉流畅的接口:如果你曾经使用过 jQuery 或甚至 JavaScript 数组这样的 API,你肯定见过。以下简短的例子展示了如何在 JavaScript 中通过链式调用操作数组:

[1, 2, 3]
  .map(n => n * 2)
  .filter(n => n > 3);

Generators 用于给异步 I/O 提供同步编程风格。如果你想看看 Node 中 generators 的实际例子,可以看看 Koa Web 应用程序库(koajs.com/)。如果你在 Koa 中使用 promises 或其他 generators,你可以对值进行 yield 而不是嵌套回调。

Node 中另一个有用的 ES2015 特性是 模板字符串。在 ES5 中,字符串字面量不支持插值或多行。现在通过使用反引号符号(`),你可以插入值并将字符串跨越多行。这在为 Web 应用快速生成 HTML 时非常有用:

this.body = `
  <div>
    <h1>Hello from Node</h1>
    <p>Welcome, ${user.name}!</p>
  </div>
`;

在 ES5 中,前面的例子将不得不这样写:

this.body = '\n';
this.body += '<div>\n';
this.body += '  <h1>Hello from Node</h1>\n';
this.body += '  <p>Welcome, ' + user.name + '</p>\n';
this.body += '<div>\n';

旧式语法不仅代码量更多,而且更容易引入错误。对 Node 开发者来说特别重要的最后一个大特性是箭头函数。箭头函数 可以让你简化语法。例如,如果你正在编写一个只有一个参数并返回值的回调函数,你可以几乎不使用任何语法就写出来:

[1, 2, 3].map(v => v * 2);

在 Node 中,我们通常需要两个参数,因为回调函数的第一个参数通常是错误对象。在这种情况下,你需要使用括号包围参数:

const fs = require('fs');
fs.readFile('package.json',
  (err, text) => console.log('Length:', text.length)
);

如果您需要在函数体中使用多行,则需要使用花括号。箭头函数的价值不仅在于简化的语法;它与 JavaScript 作用域有关。在 ES5 及之前版本中,在其它函数内部定义函数会使this引用成为全局对象。以下是一个受此问题影响的 ES5 风格类:

function User(id) {
// constructor
  this.id = id;
}

User.prototype.load = function() {
  var self = this;
  var query = 'SELECT * FROM users WHERE id = ?';
  sql.query(query, this.id, function(err, users) {
  self.name = users[0].name;
  });
};

分配self.name的行不能写成this.name,因为函数的this将是全局对象。过去,一种解决方案是在父函数或方法的入口点将变量分配给this。但箭头函数绑定正确。在 ES2015 中,前面的示例可以重写为更直观的形式:

class User {
  constructor(id) {
    this.id = id;
  }

  load() {
    const query = 'SELECT * FROM users WHERE id = ?';
    sql.query(query, this.id, (err, users) => {
    this.name = users[0].name;
    });
}
}

您不仅可以使用const更好地模拟数据库查询,而且也不需要笨拙的self变量。ES2015 有许多其他出色的功能,使 Node 代码更具可读性,但让我们看看在 Node 中是什么在驱动这些功能,以及它与您已经查看的非阻塞 I/O 功能有何关联。

1.2.1. Node 和 V8

Node 由 V8 JavaScript 引擎驱动,该引擎由 Chromium 项目为 Google Chrome 开发。V8 的显著特点是它可以直接编译成机器码,并且包含代码优化功能,有助于保持 Node 的快速运行。在第 1.1.1 节中,我们讨论了 Node 的另一个主要原生部分,libuv。该部分处理 I/O;V8 负责解释和运行您的 JavaScript 代码。要使用 libuv 与 V8 一起,您需要使用 C++绑定层。图 1.3 显示了构成 Node 的所有单独的软件组件。

图 1.3. Node 的软件栈

图片

Node 可用的具体 JavaScript 功能因此取决于 V8 支持的内容。这种支持通过功能组进行管理。

1.2.2. 与功能组一起工作

Node 根据 V8 提供的内容包含 ES2015 功能。功能被分组为已发布已测试进行中。默认情况下,已发布功能是开启的,但已测试和进行中可以通过命令行标志启用。如果您想使用已测试功能,这些功能几乎完成但 V8 团队尚未认为完全完成,那么您可以使用--harmony标志运行 Node。然而,进行中的功能稳定性较低,需要特定的功能标志来启用。Node 的文档建议通过搜索in progress来查询当前可用的进行中功能:

node --v8-options | grep "in progress"

列表将在 Node 版本之间有所不同。Node 本身也有一个版本计划,该计划定义了哪些 API 可用。

1.2.3. 理解 Node 的发布计划

Node 的版本分为长期支持(LTS)、当前版本(Current)和夜间版本(Nightly)。LTS 版本将获得 18 个月的支持和 12 个月的维护支持。版本发布遵循语义版本控制(SemVer)。SemVer 为版本分配主版本号、次版本号和补丁版本号。例如,6.9.1 的主版本号是 6,次版本号是 9,补丁号是 1。每次你看到 Node 的主版本号发生变化时,这意味着一些 API 可能与你的项目不兼容,你需要重新测试它们与这个版本的 Node。此外,在 Node 的版本发布术语中,主版本号的增加意味着一个新的当前版本已经发布。夜间构建每 24 小时自动生成,包含最新的更改,但通常仅用于测试 Node 的最新功能。

你使用的版本取决于你的项目和组织。有些人可能更喜欢长期支持版本(LTS),因为更新频率较低:这在大企业中可能效果很好,因为这些企业发现管理频繁更新比较困难。但如果你想要最新的性能和功能改进,当前版本(Current)是一个更好的选择。

1.3. 安装 Node

安装 Node 最简单的方法是使用nodejs.org上的安装程序。使用 Mac 或 Windows 安装程序安装最新的当前版本(在撰写本文时为版本 6.5)。你可以自己下载源代码,或者使用操作系统的包管理器进行安装。Debian、Ubuntu、Arch、Fedora、FreeBSD、Gentoo 和 SUSE 都有相应的包。还有 Homebrew 和 Smart-OS 的包。如果你的操作系统没有相应的包,你可以从源代码构建。

注意

附录 A 提供了关于安装 Node 的更多详细信息。

包的完整列表可以在 Node 的网站上找到 (nodejs.org/en/download/package-manager/),源代码在 GitHub 上 (github.com/nodejs/node)。如果你想在不需要下载的情况下查看源代码,将 GitHub 源代码添加书签是值得的。

安装 Node 后,你可以在终端中直接通过输入node -v来尝试它。这将打印出你刚刚下载和安装的 Node 版本。接下来,创建一个名为 hello.js 的文件,其内容如下:

console.log("hello from Node");

保存文件,通过输入node hello.js来运行它。恭喜你——你现在可以开始用 Node 编写应用程序了!

在 Windows、Linux 和 macOS 上快速入门

如果你总的来说对编程还比较新手,并且还没有一个偏好的文本编辑器,那么 Node 的一个不错的选择是 Visual Studio Code (code.visualstudio.com/)。它是微软开发的,但它是开源的,可以免费下载,并且支持 Windows、Linux 和 macOS。

Visual Studio Code 中的一些适合初学者的功能包括 JavaScript 语法高亮和 Node 核心模块完成,这样你的 JavaScript 看起来会更清晰,你可以在键入时看到支持的方法和对象的列表。你还可以打开一个命令行界面,在 Node 中只需输入Node即可调用。这对于运行 Node 和 npm 命令很有用。Windows 用户可能更喜欢这种方式而不是使用 cmd.exe。我们已经在 Windows 和 Visual Studio Code 上测试了列表,所以你不需要任何特殊的东西来运行示例。

要开始,你可以遵循 Visual Studio Code Node.js 教程(code.visualstudio.com/docs/runtimes/nodejs)。

当你安装 Node 时,你也会获得一些内置工具。Node 不仅仅是解释器:它是一套完整的工具,构成了 Node 平台。让我们更详细地看看 Node 附带的一些工具。

1.4. Node 的内置工具

Node 自带内置的包管理器,核心 JavaScript 模块支持从文件和网络 I/O 到 zlib 压缩的一切,以及调试器。npm 包管理器是这个基础设施的关键部分,所以让我们更详细地看看它。

如果你想要验证 Node 是否已正确安装,你可以在命令行上运行node -vnpm -v。这些命令显示了您已安装的 Node 和 npm 的版本。

1.4.1. npm

npm 命令行工具可以通过输入npm来调用。你可以用它从中央 npm 注册表安装包,但你也可以用它来查找和分享你自己的开源和闭源项目。注册表中的每个 npm 包都有一个网站,显示 readme 文件、作者和下载统计信息。

虽然这并不涵盖所有内容,但 npm 也是 npm, Inc.——这家公司运营 npm 服务,并为商业企业提供服务。这包括托管私有 npm 包:你可以支付每月费用来托管你公司的源代码,这样你的 JavaScript 开发者就可以轻松地使用 npm 安装它。

当使用 npm install 命令安装包时,你必须决定你是要将它们添加到当前项目还是全局安装。全局安装的包通常用于工具,通常是你在命令行上运行的程序。gulp-cli 包就是一个很好的例子。

要使用 npm,在将包含你的 Node 项目的目录中创建一个 package.json 文件。创建 package.json 文件的最简单方法就是使用 npm 为你完成。在命令行中输入以下内容:

mkdir example-project
cd example-project
npm init -y

如果你打开 package.json,你会看到一个简单的 JSON 文件,它描述了你的项目。如果你现在从www.npmjs.com安装一个模块并使用--save选项,npm 将自动更新你的 package.json 文件。你可以通过输入npm install或简写为npm i来尝试它:

npm i --save express

如果你打开你的 package.json 文件,你应该在 dependencies 属性下看到添加了 express。此外,如果你查看 node_modules 文件夹,你会看到一个 express 目录。这包含你刚刚安装的 Express 版本。你也可以使用 --global 选项全局安装模块。你应该尽可能使用本地模块,但全局模块对于你希望在 Node JavaScript 代码之外使用的命令行工具非常有用。一个可以使用 npm 安装的命令行工具示例是 ESLint (eslint.org/)。

当你刚开始使用 Node 时,你通常会使用 npm 的包。Node 包含许多有用的内置库,这些库被称为 核心模块。让我们更详细地看看这些模块。

1.4.2. 核心模块

Node 的核心模块与其他语言的标准库类似;这些是编写服务器端 JavaScript 所需的工具。JavaScript 标准本身不包含用于处理网络或文件 I/O 的任何内容,正如大多数服务器端开发者所知。Node 至少需要添加文件和 TCP/IP 网络的功能,才能成为一个可行的服务器端语言。

文件系统

Node 随带一个文件系统库(fs、path)、TCP 客户端和服务器(net)、HTTP(http 和 https)以及域名解析(dns)。有一个有用的断言库,主要用于编写测试(assert),还有一个操作系统库用于查询有关平台的信息(os)。

Node 还有一些独特的库。事件模块是一个用于处理事件的简单库,它被用作 Node 许多 API 的基础。例如,流模块使用事件模块来提供处理数据流的抽象接口。因为 Node 中的所有数据流都使用相同的 API,你可以轻松地组合软件组件;如果你有一个文件流读取器,你可以通过 zlib 转换器压缩数据,然后将数据通过文件流写入器写入文件。

以下列表展示了如何使用 Node 的 fs 模块创建可以管道传输到另一个流(gzip)以转换数据的读写流——在这种情况下,通过压缩数据。

列表 1.1. 使用核心模块和流
const fs = require('fs');
const zlib = require('zlib');
const gzip = zlib.createGzip();
const outStream = fs.createWriteStream('output.js.gz');

fs.createReadStream('./node-stream.js')
  .pipe(gzip)
  .pipe(outStream);
网络

一段时间里,我们曾经说创建一个简单的 HTTP 服务器是 Node 的真正 Hello World 示例。要在 Node 中构建服务器,你只需要加载 http 模块并给它一个函数。该函数接受两个参数:传入的请求和传出的响应。下一个列表展示了你可以在终端中运行的示例。

列表 1.2. 使用 Node 的 http 模块编写 Hello World
const http = require('http');
const port = 8080;

const server = http.createServer((req, res) => {
  res.end('Hello, world.');
});

server.listen(port, () => {
  console.log('Server listening on: http://localhost:%s', port);
});

将 列表 1.2 保存为 hello.js 并使用 node hello.js 运行它。如果你访问 http://localhost:8080,你应该能看到第 4 行的消息。

Node 的核心模块既精简又强大。你通常只需使用这些模块就能完成很多事情,甚至无需从 npm 安装任何东西。有关核心模块的更多信息,请参阅nodejs.org/api/.

最后一个内置工具是调试器。下一节将介绍 Node 的调试器及其示例。

1.4.3. 调试器

Node 包含一个支持单步执行和 REPL(读取-评估-打印循环)的调试器。调试器通过使用网络协议与你的程序通信来工作。要使用调试器运行你的程序,请在命令行中使用debug参数。假设你正在调试列表 1.2:

node debug hello.js

然后,你应该会看到以下输出:

< Debugger listening on [::]:5858
connecting to 127.0.0.1:5858 ... ok
break in node-http.js:1
> 1 const http = require('http');
  2 const port = 8080;
  3

Node 已经调用了你的程序,并通过在端口 5858 上连接来调试它。在这个时候,你可以输入help来查看可用命令列表,然后输入c来继续程序执行。Node 总是以中断状态启动程序,所以你总是需要在做其他任何事情之前继续执行。

你可以通过在代码的任何位置添加一个debugger语句来使调试器中断。当遇到debugger语句时,调试器将停止,允许你发出命令。想象一下,你已经编写了一个创建新用户账户的 REST API,而你创建用户代码似乎没有将新用户的密码散列持久化到数据库中。你可以在User类的save方法中添加debugger,然后逐条指令地查看发生了什么。

交互式调试

Node 支持 Chrome 调试协议。要使用 Chrome 的开发者工具调试脚本,请在运行程序时使用--inspect标志:

node --inspect --debug-brk

这将使 Node 启动调试器并在第一行中断。它会在控制台打印一个 URL,你可以在 Chrome 中打开它,以便使用 Chrome 内置的调试器。Chrome 的调试器允许你逐行执行代码,并显示每个变量和对象中的值。它是一个比输入console.log更好的替代方案。

调试将在第九章中详细介绍。如果你想现在尝试,最好的起点是 Node 的调试器手册页(nodejs.org/api/debugger.html).

到目前为止,在本章中,我们讨论了 Node 的工作原理以及它为开发者提供了什么。你可能也迫不及待地想了解人们在生产中用 Node 做什么。下一节将探讨你可以用 Node 制作的程序类型。

1.5. Node 程序的三种主要类型

Node 程序可以分为三种典型类型:网络应用程序、命令行工具和守护进程、桌面应用程序。网络应用程序包括提供单页应用程序、REST 微服务和全栈网络应用程序的简单应用程序。你可能已经使用过用 Node 编写的命令行工具——例如 npm、Gulp 和 webpack。守护进程是后台服务。一个很好的例子是 PM2 (www.npmjs.com/package/pm2) 进程管理器。桌面应用程序通常是用 Electron 框架编写的软件(electron.atom.io/),它将 Node 作为基于网络的桌面应用程序的后端。例如包括 Atom (atom.io/) 和 Visual Studio Code (code.visualstudio.com/) 文本编辑器。

1.5.1. 网络应用程序

Node 是服务器端 JavaScript,因此它作为构建网络应用程序的平台是有意义的。通过在客户端和服务器上运行 JavaScript,可以在每个环境中实现代码的重用。Node 网络应用程序通常使用 Express 等框架编写(expressjs.com/)。第六章 回顾了 Node 可用的主要服务器端框架。第七章 专门介绍 Express 和 Connect,第八章 则是关于网络应用程序模板。

你可以通过创建一个新的目录并安装 Express 模块来快速创建一个 Express 网络应用程序:

mkdir hello_express
cd hello_express
npm init -y
npm i express --save

接下来,将以下 JavaScript 代码添加到名为 server.js 的文件中。

列表 1.3. 一个 Node 网络应用程序
const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.send('Hello World!');
});

app.listen(3000, () => {
  console.log('Express web app on localhost:3000');
});

现在输入 npm start,你将有一个运行在端口 3000 上的 Node 网络服务器。如果你在浏览器中打开 http://localhost:3000,你将能够看到 res.send 行中的文本。

Node 也是前端开发世界的一个重要部分,因为它是将其他语言如 TypeScript 转换为 JavaScript 的主要工具。转译器将一种高级语言编译成另一种高级语言;这与将高级语言编译成低级语言的传统编译器形成对比。第四章 专门介绍前端构建系统,我们探讨了使用 npm 脚本、Gulp 和 webpack。

并非所有网络开发都涉及构建网络应用程序。有时你需要做一些事情,比如从旧网站中提取数据,以便在重建时使用。我们包括了附录 B,它全部关于网络爬取,作为展示如何使用 Node 的 JavaScript 运行时与文档对象模型 (DOM) 交互的一种方式,同时也展示了如何在典型的 Express 网络应用程序的舒适区之外使用 Node。如果你只想快速制作一个基本的网络应用程序,第三章 提供了一个关于构建 Node 网络应用程序的独立教程。

1.5.2. 命令行工具和守护进程

Node 用于编写命令行工具,如进程管理器和 JavaScript 转译器,这些工具被 JavaScript 开发者使用。但它也被用作编写方便的命令行工具的便捷方式,这些工具可以执行其他任务,包括图像转换和媒体播放控制的脚本。

这里有一个简单的命令行示例,你可以尝试。创建一个名为 cli.js 的新文件,并添加以下行:

const [nodePath, scriptPath, name] = process.argv;
console.log('Hello', name);

使用 node cli.js yourName 运行脚本,你会看到 Hello yourName。这是通过使用 ES2015 解构来从 process.argv 中提取第三个参数实现的。process 对象对每个 Node 程序都可用,并构成了当用户运行你的程序时接受参数的基础。

你可以使用 Node 命令行程序做几件事情。如果你在程序的开始处添加一行以 #! 开头,并授予它执行权限 (chmod +x cli.js),那么你可以让 shell 在调用程序时使用 Node。现在你可以像运行任何其他 shell 脚本一样运行你的 Node 程序。对于类 Unix 系统,你可以使用如下行:

#!/usr/bin/env node

通过这种方式使用 Node,你可以用 Node 替换你的 shell 脚本。这意味着 Node 可以与任何其他命令行工具一起使用,包括后台程序。Node 程序可以通过 cron 调用,或作为守护进程在后台运行。

如果这些都对你来说是新的,不要担心:第十一章 chapter 11 介绍了编写命令行工具,并展示了这类程序如何发挥 Node 的优势。例如,命令行工具大量使用流作为通用 API,而流是 Node 最强大的功能之一。

1.5.3. 桌面应用

如果你一直在使用 Atom 或 Visual Studio Code 文本编辑器,那么你一直在使用 Node。Electron 框架使用 Node 作为后端,因此每当需要磁盘或网络访问等 I/O 操作时,Electron 都会使用 Node。Electron 还使用 Node 来管理依赖项,这意味着你可以将 npm 包添加到 Electron 项目中。

如果你现在想快速尝试 Electron,你可以克隆 Electron 仓库并启动一个应用:

git clone https://github.com/electron/electron-quick-start
cd electron-quick-start
npm install && npm start
curl localhost:8081

要了解如何使用 Electron 编写应用,请翻到第十二章 chapter 12。

1.5.4. 适合 Node 的应用

我们已经介绍了一些可以使用 Node 构建的应用类型,但 Node 在某些类型的应用上表现出色。Node 通常用于创建实时网络应用,这可能意味着从面向用户的应用,如聊天服务器,到收集分析的后端。由于函数在 JavaScript 中是一等对象,并且 Node 内置了事件模型,因此编写异步实时程序比其他脚本语言更自然。

如果你正在构建传统的模型-视图-控制器(MVC)Web 应用程序,Node 可以很好地完成这项工作。流行的博客引擎都是用 Node 构建的,例如 Ghost (ghost.org/); Node 现在是一个经过验证的平台,用于构建这类 Web 应用程序。其开发风格与用 PHP 构建的 WordPress 不同,但 Ghost 支持类似的功能,包括模板和多用户管理区域。

Node 还可以做其他语言中很难做到的事情。它基于 JavaScript,并且可以在 Node 中运行浏览器 JavaScript。复杂的客户端应用程序可以适配在 Node 服务器上运行,允许服务器预先渲染 Web 应用程序,这可以加快浏览器中的页面渲染时间,并便于搜索引擎。

最后,如果你在考虑构建桌面或移动应用程序,你应该尝试 Electron,它由 Node 提供支持。现在,随着 Web 用户界面与桌面体验一样丰富,Electron 桌面应用程序可以与原生 Web 应用程序相媲美,并缩短开发时间。Electron 还支持三个主要平台,因此你可以在 Windows、Linux 和 macOS 之间重用代码。

1.6. 总结

  • 节点是一个事件驱动且非阻塞的平台,用于构建 JavaScript 应用程序。

  • V8 用作 JavaScript 运行时。

  • libuv 是提供快速、跨平台、非阻塞 I/O 的本地库。

  • Node 有一个小型的标准库,称为核心模块,它为 JavaScript 添加了网络和磁盘 I/O。

  • Node 自带调试器和依赖管理器(npm)。

  • Node 用于构建 Web 应用程序、命令行工具,甚至桌面应用程序。

第二章. Node 编程基础

本章涵盖

  • 将代码组织成模块

  • 使用回调处理一次性事件

  • 使用事件发射器处理重复事件

  • 实现串行和并行流控制

  • 使用流控制工具

与许多开源平台不同,Node 易于设置,对内存和磁盘空间的要求不高。不需要复杂的集成开发环境或构建系统。然而,一些基本知识在开始时将非常有帮助。在本章中,我们讨论了新 Node 开发者面临的两项挑战:

  • 如何组织你的代码

  • 异步编程是如何工作的

在本章中,你将学习重要的异步编程技术,这将使你能够紧密控制应用程序的执行方式。你将学习

  • 如何响应一次性事件

  • 如何处理重复事件

  • 如何编排异步逻辑

我们将首先介绍如何通过使用模块来处理代码组织的问题,这是 Node 保持代码组织并便于重用的方式。

2.1. 组织和重用 Node 功能

当创建一个应用程序时,无论是 Node 还是其他,你通常会达到一个点,将所有代码放入一个文件中会变得难以管理。当这种情况发生时,如图 2.1 所示的传统方法是将包含大量代码的文件取出来,尝试通过将相关的逻辑分组并移动到单独的文件中来组织它。

图 2.1. 如果将你的代码组织成目录和单独的文件,而不是将应用程序保存在一个长文件中,那么导航你的代码会更简单。

图 2.1

在某些语言实现中,例如 PHP 和 Ruby,从另一个文件(我们称之为 包含文件)中引入逻辑(我们称之为 包含文件)可能意味着该文件中执行的所有逻辑都会影响全局作用域。在包含文件中创建的任何变量和声明的任何函数都可能覆盖应用程序中创建和声明的变量和函数。

假设你在 PHP 中编程;你的应用程序可能包含以下逻辑:

function uppercase_trim($text) {
  return trim(strtoupper($text));
}
include('string_handlers.php');

如果你的 string_handlers.php 文件也尝试定义一个 uppercase_trim 函数,你会收到以下错误:

Fatal error: Cannot redeclare uppercase_trim()

在 PHP 中,你可以通过使用 命名空间 来避免这种情况,而 Ruby 通过 模块 提供类似的功能。然而,Node 通过不提供一种简单的方式来意外地污染全局命名空间,从而避免了这种潜在问题。

PHP 命名空间,Ruby 模块

PHP 命名空间在 PHP 语言手册中有讨论:php.net/manual/en/language.namespaces.php。Ruby 模块在 Ruby 文档中有解释:ruby-doc.org/core-2.3.1/Module.html

节点模块将代码捆绑起来以供重用,但它们不会改变全局作用域。例如,假设你正在使用 PHP 开发一个开源内容管理系统(CMS)应用程序,并且你想使用一个不使用命名空间的第三方 API 库。这个库可能包含一个与你的应用程序中相同名称的类,这会破坏你的应用程序,除非你更改应用程序或库中的类名。然而,更改应用程序中的类名可能会给其他使用你的 CMS 作为他们项目基础的开发者带来问题。在库中更改类名将要求你在应用程序的源树中更新库时记住重复这个技巧。命名冲突是一个最好完全避免的问题。

节点模块允许你选择从包含的文件中暴露给应用程序的哪些函数和变量。如果模块返回多个函数或变量,模块可以通过设置一个名为 exports 的对象的属性来指定这些函数或变量。如果模块只返回一个函数或变量,则可以设置 module.exports 属性。图 2.2 展示了这是如何工作的。

图 2.2. module.exports 属性或 exports 对象的设置允许模块选择与应用程序共享的内容。

图片 2.2

如果这听起来有点复杂,不要担心;我们将在本章中通过几个示例来讲解。通过避免全局作用域的污染,Node 的模块系统避免了命名冲突并简化了代码重用。然后,模块可以被发布到 npm(包管理器)注册表,这是一个在线的、可用的 Node 模块集合,并与 Node 社区共享,而无需担心使用模块的人会覆盖其他模块的变量和函数。

为了帮助你将逻辑组织到模块中,我们涵盖了以下主题:

  • 你可以如何创建模块

  • 模块在文件系统中的存储位置

  • 创建和使用模块时需要注意的事项

让我们通过启动一个新的 Node 项目并创建一个简单的模块来深入学习 Node 模块系统。

2.2. 开始一个新的 Node 项目

创建一个新的 Node 项目很简单:创建一个文件夹,然后运行 npm init。就这样!npm 命令会问你几个问题,你可以对它们都回答“是”。以下是一个完整的示例:

mkdir my_module
cd my_module
npm init -y

-y 标志表示“是”。这意味着 npm 将使用默认值创建一个 package.json 文件。如果你想有更多的控制权,请去掉 -y 标志,npm 将会引导你回答一系列关于项目许可证、作者姓名等问题。完成这些后,查看 package.json 的内容。你可以手动编辑它,但请记住,它必须是有效的 JSON 格式。

现在你有一个空项目,你可以创建自己的模块。

2.2.1. 创建模块

模块可以是单个文件,也可以是包含一个或多个文件的目录,正如你在图 2.3 中看到的。如果模块是一个目录,那么将被评估的模块目录中的文件通常命名为 index.js(尽管这可以被覆盖:参见第 2.5 节)。

图 2.3. 可以通过使用文件(示例 1)或目录(示例 2)来创建 Node 模块。

图片 2.3

要创建一个典型的模块,你需要创建一个文件,该文件定义了 exports 对象上的属性,可以是任何类型的数据,如字符串、对象和函数。

为了展示如何创建一个基本的模块,让我们看看如何向名为 currency.js 的文件添加一些货币转换功能。这个文件,如以下列表所示,将包含两个函数,用于将加拿大元转换为美元,反之亦然。

列表 2.1. 定义 Node 模块(currency.js)

图片 2.1

注意,只有 exports 对象的两个属性被设置。因此,只有两个函数,canadianToUSUSToCanadian,可以被应用程序包括模块访问。变量 canadianDollar 作为私有变量,影响 canadianToUSUSToCanadian 中的逻辑,但应用程序不能直接访问它。

要使用你的新模块,使用 Node 的 require 函数,该函数将模块的路径作为参数。Node 执行同步查找以定位模块并加载文件内容。Node 查找文件的顺序是首先核心模块,然后是当前目录,最后是 node_modules。

关于 require 和同步 I/O 的注意事项

require 是 Node 中可用的少数几个同步 I/O 操作之一。由于模块经常被使用,并且通常包含在文件的顶部,因此使 require 同步有助于保持代码整洁、有序和可读。

避免在应用程序的 I/O 密集部分使用 require。任何同步调用都会阻塞 Node 执行任何操作,直到调用完成。例如,如果你正在运行一个 HTTP 服务器,如果你在每次传入请求时都使用 require,那么你会遭受性能损失。这通常是为什么 require 和其他同步操作仅在应用程序最初加载时使用的原因。

在下一个列表中,展示了 test-currency.js,你 require 了 currency.js 模块。

列表 2.2. 引入模块(test_currency.js)

图片

引入以 ./ 开头的模块意味着,如果你在名为 currency_app 的目录中创建名为 test-currency.js 的应用程序脚本,那么如图 2.4 所示的 currency.js 模块文件也需要存在于 currency_app 目录中。在引入时,假定 .js 扩展名,所以如果你希望的话可以省略它。如果你不包括 .js,Node 也会检查 .json 文件。JSON 文件被加载为 JavaScript 对象。

图 2.4. 当你在模块 require 的开始处放置 ./ 时,Node 将在执行程序文件相同的目录中查找。

图片

在 Node 定位并评估你的模块之后,require 函数返回模块中定义的 exports 对象的内容。然后你可以使用模块返回的两个函数来执行货币转换。

如果你想要组织相关的模块,你可以将模块放入子目录中。例如,如果你想将货币模块放入名为 lib/ 的文件夹中,你可以通过将 require 的行更改为以下内容来实现:

const currency = require('./lib/currency');

填充模块的 exports 对象为你提供了一个简单的方法来在单独的文件中分组可重用代码。

2.3. 通过使用 module.exports 调整模块创建的细节

虽然使用函数和变量填充 exports 对象对于大多数模块创建需求来说是合适的,但有时你可能希望模块偏离这种模式。

例如,之前创建的货币转换模块可以重写为返回单个 Currency 构造函数,而不是包含函数的对象。面向对象的实现可能如下所示:

const Currency = require('./currency');
const canadianDollar = 0.91;
const currency = new Currency(canadianDollar);
console.log(currency.canadianToUS(50));

require 返回一个函数而不是一个对象,如果这是你从模块中需要的唯一东西,会使你的代码更加优雅。

要创建一个返回单个变量或函数的模块,你可能认为你需要将 exports 设置为你想要返回的内容。但这不会起作用,因为 Node 预期 exports 不会被重新分配给任何其他对象、函数或变量。下一个列表中的模块代码尝试将 exports 设置为一个函数。

列表 2.3. 模块无法按预期工作

要使先前的模块代码按预期工作,你需要将 exports 替换为 module.exportsmodule.exports 机制允许你导出一个变量、函数或对象。如果你创建了一个同时填充了 exportsmodule.exports 的模块,module.exports 将被返回,而 exports 将被忽略。

真正导出的内容

在你的应用程序中最终导出的是 module.exportsexports 被设置为对 module.exports 的全局引用,它最初被定义为可以添加属性的空对象。exports.myFuncmodule.exports.myFunc 的简写。

因此,如果 exports 被设置为其他任何内容,它就会破坏 module.exportsexports 之间的 引用。因为 module.exports 是被导出的内容,exports 将不再按预期工作——它不再引用 module.exports。如果你想保持这个链接,你可以按照以下方式再次使 module.exports 引用 exports

module.exports = exports = Currency;

根据你的需求,使用 exportsmodule.exports,你可以将功能组织到模块中,并避免不断增长的应用程序脚本中的陷阱。

2.4. 通过使用 node_modules 文件夹重用模块

在文件系统中按相对于应用程序的方式要求模块存在,对于组织特定于应用程序的代码很有用,但对于你希望在应用程序之间重用或与他人共享的代码来说并不那么有用。Node 包含一个独特的代码重用机制,允许在不了解它们在文件系统中的位置的情况下要求模块。这个机制就是使用 node_modules 目录。

在早期的模块示例中,你要求 ./currency。如果你省略了 ./ 并简单地要求 currency,Node 将遵循如图 2.5 所示的某些规则来搜索此模块。

图 2.5. 寻找模块的步骤

NODE_PATH 环境变量提供了一种指定 Node 模块替代位置的方法。如果使用,Windows 上的 NODE_PATH 应该设置为用分号分隔的目录列表,在其他操作系统上用冒号分隔。

2.5. 探索注意事项

尽管 Node 的模块系统本质上是简单的,但你应该注意两个要点。

首先,如果一个模块是一个目录,该模块目录中将被评估的文件必须命名为 index.js,除非模块目录中的名为 package.json 的文件有其他指定。要指定 index.js 的替代方案,package.json 文件必须包含定义一个具有 main 键的对象的 JavaScript 对象表示法(JSON)数据,该键指定了模块目录内主文件的路径。图 2.6 展示了一个总结这些规则的流程图。

图 2.6. 将 package.json 文件放置在模块目录中,允许你使用除 index.js 之外的文件定义你的模块。

这里是一个 package.json 文件指定 currency.js 是主文件的示例:

{
  "main": "currency.js"
}

另一点需要注意的则是 Node 能够将模块缓存为对象。如果一个应用程序中的两个文件需要相同的模块,第一个 require 会将返回的数据存储在应用程序内存中,这样第二个 require 就不需要访问和评估模块的源文件。这意味着在同一个进程中使用 require 加载的模块返回的是同一个对象。想象一下,你构建了一个 MVC 网络应用程序,其中有一个主应用程序对象。你可以设置该应用程序对象,导出它,然后在整个项目中的任何地方 require 它。如果你已经向应用程序对象添加了有用的配置值,那么你可以从其他文件中访问这些值,前提是目录结构如下:

project
   app.js
   models
      post.js

图 2.7 展示了它是如何工作的。

图 2.7. 网络应用程序中的共享应用程序对象

熟悉 Node 的模块系统最好的方法就是与之互动,亲自验证本节所描述的行为。现在你已经对模块的工作原理有了基本的了解,让我们继续学习异步编程技术。

2.6. 使用异步编程技术

如果你曾经进行过前端网络编程,其中界面事件(如鼠标点击)触发逻辑,那么你已经进行过异步编程。服务器端异步编程没有不同:发生事件触发响应逻辑。在 Node 世界中,用于管理响应逻辑的两种流行模型是回调和事件监听器。

回调 通常定义了针对一次性响应的逻辑。例如,如果你执行一个数据库查询,你可以指定一个回调来确定如何处理查询结果。回调可以显示数据库结果,根据结果执行计算,或者使用查询结果作为参数执行另一个回调。

另一方面,事件监听器是与一个概念实体(一个事件)相关联的回调。为了比较,鼠标点击是在浏览器中处理的事件,当有人点击鼠标时。例如,在 Node 中,当发起 HTTP 请求时,HTTP 服务器会发射一个request事件。你可以监听该request事件的发生,并添加响应逻辑。在下面的示例中,当使用Event-Emitter.prototype.on方法将事件监听器绑定到服务器时,handle-Request函数将在发射request事件时被调用:

server.on('request', handleRequest)

Node HTTP 服务器实例是一个事件发射器的例子,这是一个可以继承的类(Event-Emitter),它增加了发射和处理事件的能力。Node 的核心功能中的许多方面都继承自EventEmitter,你也可以创建自己的事件发射器。

既然我们已经确定了在 Node 中响应逻辑通常以两种方式组织,你现在可以通过学习以下内容来了解这一切是如何工作的:

  • 如何使用回调处理一次性事件

  • 如何使用事件监听器响应重复事件

  • 如何处理异步编程的一些挑战

让我们首先看看处理异步代码最常见的一种方式:使用回调。

2.7. 使用回调处理一次性事件

回调是一个函数,作为参数传递给异步函数,它描述了异步操作完成后要执行的操作。在 Node 开发中,回调的使用频率比事件发射器更高,而且它们的使用很简单。

为了演示在应用程序中使用回调,让我们看看如何创建一个简单的 HTTP 服务器,该服务器执行以下操作:

  • 异步拉取存储为 JSON 文件的最近帖子标题

  • 异步拉取基本 HTML 模板

  • 组装包含标题的 HTML 页面

  • 将 HTML 页面发送给用户

结果将与图 2.8 相似。

图 2.8. 从 JSON 文件中提取标题并返回网页的 HTML 响应

图片

下面的列表中显示了 JSON 文件(titles.json),它格式化为包含帖子标题的字符串数组。

列表 2.4. 帖子标题列表
[
  "Kazakhstan is a huge country... what goes on there?",
  "This weather is making me craaazy",
  "My neighbor sort of howls at night"
]

下面的 HTML 模板文件(template.html)仅包含插入博客帖子标题的基本结构。

列表 2.5. 用于渲染博客标题的基本 HTML 模板

图片

拉取 JSON 文件并渲染网页的代码如下(blog_recent.js)。

列表 2.6. 在简单应用程序中使用回调

图片

此示例嵌套了三个级别的回调:

http.createServer((req, res) => { ...
  fs.readFile('./titles.json', (err, data) => { ...
    fs.readFile('./template.html', (err, data) => { ...

使用三个级别并不坏,但回调级别的数量越多,你的代码看起来就越混乱,重构和测试就越困难,因此限制回调嵌套是好的。通过创建处理回调嵌套各个级别的命名函数,你可以以需要更多行代码的方式表达相同的逻辑,但可能更容易维护、测试和重构。以下列表在功能上等同于列表 2.6。

列表 2.7. 通过创建中间函数减少嵌套

你也可以使用 Node 开发中的另一个常见习语来减少由if/else块引起的嵌套:从函数中提前返回。以下列表在功能上相同,但通过提前返回避免了进一步的嵌套。它还明确表示函数不应该继续执行。

列表 2.8. 通过提前返回减少嵌套

现在你已经学会了如何使用回调来处理一次性的事件,例如在读取文件和 Web 服务器请求时定义响应,让我们继续通过使用事件发射器来组织事件。

Node 的异步回调约定

大多数 Node 内置模块使用两个参数的回调:第一个参数用于错误,如果发生错误,第二个参数用于结果。错误参数通常缩写为err

这里是一个这种常见函数签名的典型示例:

const fs = require('fs');
fs.readFile('./titles.json', (err, data) => {
  if (err) throw err;
  // do something with data if no error has occurred
});

2.8. 使用事件发射器处理重复事件

事件发射器会触发事件,并包括在触发时处理这些事件的能力。一些重要的 Node API 组件,如 HTTP 服务器、TCP 服务器和流,都是作为事件发射器实现的。你也可以创建自己的。

正如我们之前提到的,事件是通过使用监听器来处理的。一个监听器是将事件与一个回调函数关联起来,每当事件发生时,该回调函数就会被触发。例如,Node 中的 TCP 套接字有一个名为data的事件,每当套接字上有新数据可用时就会触发:

socket.on('data', handleData);

让我们看看如何使用data事件来创建一个回声服务器。

2.8.1. 一个示例事件发射器

在回声服务器中发生重复事件的简单示例。当你向回声服务器发送数据时,它会将数据回显,如图 2.9 所示:

图 2.9. 回声服务器重复发送给它的数据

列表 2.9 显示了实现回声服务器的代码。每当客户端连接时,就会创建一个套接字。套接字是一个事件发射器,你可以使用on方法添加监听器,以响应data事件。这些data事件会在套接字上有新数据可用时被发射。

列表 2.9. 使用on方法响应事件

你可以通过输入以下命令来运行这个回声服务器:

node echo_server.js

在 echo 服务器运行后,你可以通过输入以下命令来连接到它:

telnet 127.0.0.1 8888

每次从你的连接 telnet 会话向服务器发送数据时,它都会被回显到 telnet 会话中。

Windows 上的 Telnet

如果你使用的是 Microsoft Windows 操作系统,telnet 可能默认未安装,你需要自己安装它。TechNet 提供了各种 Windows 版本的说明:mng.bz/egzr

2.8.2. 响应只应发生一次的事件

可以定义监听器来重复响应事件,如前一个示例所示,或者可以定义监听器来只响应一次。以下使用once方法的列表修改了之前的 echo 服务器示例,使其只回显发送给它的第一块数据。

列表 2.10. 使用once方法响应单个事件

2.8.3. 创建事件发射器:发布/订阅示例

在上一个例子中,你使用了内置的 Node API,该 API 使用事件发射器。然而,Node 的内置事件模块允许你创建自己的事件发射器。

以下代码定义了一个具有单个监听器的channel事件发射器,该监听器响应有人加入频道的事件。请注意,你使用on(或,可选地,较长的形式addListener)向事件发射器添加监听器:

const EventEmitter = require('events').EventEmitter;
const channel = new EventEmitter();
channel.on('join', () => {
  console.log('Welcome!');
});

然而,这个join回调永远不会被调用,因为你还没有发射任何事件。你可以在列表中添加一行来触发一个事件,使用emit函数:

channel.emit('join');
事件名称

事件是具有任何字符串值的键:datajoin一些疯狂长的事件名称。只有一个名为error的事件是特殊的,你很快就会看到它。

让我们看看如何使用EventEmitter来创建一个通信通道,从而实现自己的发布/订阅逻辑。如果你运行列表 2.11 中的脚本,你将拥有一个简单的聊天服务器。聊天服务器频道作为响应客户端发出的join事件的发射器来实现。当客户端加入频道时,加入监听器逻辑反过来会向频道添加一个额外的客户端特定监听器,用于broadcast事件,该事件将向客户端套接字写入任何广播的消息。事件类型的名称,如joinbroadcast,完全是任意的。如果你愿意,可以为这些事件类型使用其他名称。

列表 2.11. 使用事件发射器实现的简单发布/订阅系统

在你的聊天服务器运行后,打开一个新的命令行并输入以下代码以进入聊天:

telnet 127.0.0.1 8888

如果你打开几个命令行,你会看到在其中一个命令行中输入的任何内容都会被回显到其他命令行中。

这个聊天服务器的问题在于,当用户关闭他们的连接并离开聊天室时,他们留下了一个监听器,该监听器将尝试向不再连接的客户端写入。这当然会生成一个错误。为了解决这个问题,你需要将以下列表中的监听器添加到channel事件发射器中,并添加逻辑到服务器的close事件监听器以发出通道的leave事件。leave事件移除了最初为客户端添加的broadcast监听器。

列表 2.12. 创建一个监听器以在客户端断开连接时进行清理

图片

如果出于某种原因你想阻止聊天但不想关闭服务器,你可以使用removeAllListeners事件发射器方法来移除特定类型的所有监听器。以下代码展示了如何在我们的聊天服务器示例中实现这一点:

channel.on('shutdown', () => {
  channel.emit('broadcast', '', 'The server has shut down.\n');
  channel.removeAllListeners('broadcast');
});

然后,你可以添加对触发关闭的聊天命令的支持。为此,将data事件的监听器更改为以下代码:

client.on('data', data => {
  data = data.toString();
  if (data === 'shutdown\r\n') {
    channel.emit('shutdown');
  }
  channel.emit('broadcast', id, data);
});

现在当任何聊天参与者将shutdown输入到聊天中时,它将导致所有参与者被踢出。

错误处理

在创建事件发射器时,你可以使用的一个约定是发出一个error类型的事件而不是直接抛出错误。这允许你通过设置一个或多个监听器来定义自定义事件响应逻辑。

以下代码展示了错误监听器如何通过在控制台记录来处理发出的错误:

const events = require('events');
const myEmitter = new events.EventEmitter();
myEmitter.on('error', err => {
  console.log(`ERROR: ${err.message}`);
});
myEmitter.emit('error', new Error('Something is wrong.'));

如果在发出error事件类型时未定义此事件类型的监听器,事件发射器将输出堆栈跟踪(一个程序指令列表,直到错误发生时执行)并停止执行。堆栈跟踪指示由emit调用的第二个参数指定的错误类型。这种行为是error类型事件独有的;当发出其他事件类型,并且它们没有监听器时,不会发生任何事情。

如果在未提供error对象作为第二个参数的情况下发出error类型的事件,堆栈跟踪将指示一个未捕获的,未指定的'error'事件错误,并且你的应用程序将停止。你可以使用一个已弃用的方法来处理这个错误——你可以通过以下代码定义一个全局处理程序来定义自己的响应:

process.on('uncaughtException', err => {
  console.error(err.stack);
  process.exit(1);
});

作为替代方案,如域(nodejs.org/api/domain.html)正在开发中,但它们尚未被认为是生产就绪的。

如果你想要为连接到聊天的用户提供当前连接用户数的计数,你可以使用以下listeners方法,它返回给定事件类型的监听器数组:

channel.on('join', function(id, client) {
const welcome = `
  Welcome!
    Guests online: ${this.listeners('broadcast').length}
  `;
  client.write(`${welcome}\n`);
  ...

为了增加事件发射器拥有的监听器数量,并避免 Node 在监听器超过 10 个时显示的警告,你可以使用setMaxListeners方法。以你的通道事件发射器为例,你使用以下代码来增加允许的监听器数量:

channel.setMaxListeners(50);

2.8.4. 扩展事件发射器:文件监控示例

如果你想要扩展事件发射器的行为,你可以创建一个新的 JavaScript 类,该类继承自事件发射器。例如,你可以创建一个名为Watcher的类,该类处理放置在指定文件系统目录中的文件。然后,你使用这个类创建一个工具来监控目录(将放置在其中的文件重命名为小写,然后将文件复制到另一个目录中)。

在设置好Watcher对象之后,你需要扩展从EventEmitter继承的方法,如下所示的两个新方法。

列表 2.13. 扩展事件发射器的功能

图片

watch方法遍历目录,处理找到的任何文件。start方法开始目录监控。监控使用 Node 的fs.watchFile函数,因此当被监控目录发生变动时,watch方法会被触发,遍历被监控目录并为每个找到的文件发出一个process事件。

现在你已经定义了Watcher类,你可以通过以下代码创建一个Watcher对象来使用它:

const watcher = new Watcher(watchDir, processedDir);

使用你新创建的Watcher对象,你可以使用从事件发射器类继承的on方法来设置处理每个文件的逻辑,如以下代码片段所示:

watcher.on('process', (file) => {
  const watchFile = `${watchDir}/${file}`;
  const processedFile = `${processedDir}/${file.toLowerCase()}`;
  fs.rename(watchFile, processedFile, err => {
    if (err) throw err;
  });
});

现在所有必要的逻辑都已经就绪,你可以使用以下代码开始目录监控:

watcher.start();

Watcher代码放入脚本中并创建监控和完成目录后,你应该能够通过使用 Node 运行脚本,将文件放入监控目录,然后在完成目录中看到文件出现,重命名为小写。这是事件发射器可以成为一个有用的类来创建新类的示例。

通过学习如何使用回调来定义一次性异步逻辑以及如何使用事件发射器来重复派发异步逻辑,你离掌握 Node 应用程序行为控制更近一步。然而,在一个回调或事件发射器监听器中,你可能想要包含执行额外异步任务的逻辑。如果这些任务执行的顺序很重要,你可能面临一个新的挑战:如何在一系列异步任务中精确控制每个任务的执行时机。

在我们讨论如何控制任务执行时机之前——将在第 2.10 节中介绍——让我们看看你在编写异步代码时可能会遇到的一些挑战。

2.9. 异步开发中的挑战

在创建异步应用程序时,你必须密切关注应用程序的流程,并密切关注应用程序状态:事件循环的条件、应用程序变量以及程序逻辑执行过程中发生变化的任何其他资源。

例如,Node 的事件循环跟踪尚未完成处理的异步逻辑。只要存在未完成的异步逻辑,Node 进程就不会退出。对于像 Web 服务器这样的应用程序,持续运行的 Node 进程是理想的行为,但并不希望命令行工具等在一段时间后结束的程序继续运行。事件循环跟踪任何数据库连接,直到它们被关闭,防止 Node 退出。

如果你不小心,应用程序变量也可能意外改变。列表 2.14 展示了异步代码执行顺序可能导致混淆的一个例子。如果示例代码是同步执行的,你会期望输出是“颜色是蓝色。”然而,由于示例是异步的,color变量的值在console.log执行之前就改变了,输出是“颜色是绿色。”

列表 2.14. 范围行为可能导致错误

要“冻结”color变量的内容,你可以修改你的逻辑并使用 JavaScript 闭包。在列表 2.15 中,你将asyncFunction的调用包裹在一个接受color参数的匿名函数中。然后你立即执行这个匿名函数,传递给它color的当前内容。通过将color作为匿名函数的参数,它变成了该函数作用域内的局部变量,当color的值在匿名函数外部改变时,局部版本不受影响。

列表 2.15. 使用匿名函数保留全局变量的值
function asyncFunction(callback) {
  setTimeout(callback, 200);
}

let color = 'blue';

(color => {
  asyncFunction(() => {
    console.log('The color is', color);
  });
})(color);

color = 'green';

这只是你在 Node 开发中会遇到许多 JavaScript 编程技巧之一。

闭包

更多关于闭包的信息,请参阅 Mozilla JavaScript 文档:developer.mozilla.org/en-US/docs/JavaScript/Guide/Closures

现在你已经了解了如何使用闭包来控制你的应用程序状态,让我们来看看如何顺序执行异步逻辑,以保持应用程序的流程控制。

2.10. 异步逻辑的顺序

在异步程序的执行过程中,一些任务可以在任何时候发生,独立于程序的其他部分正在做什么,而不会引起问题。但有些任务应该在某些其他任务之前或之后发生。

Node 社区将异步任务组的顺序概念称为流控制。有两种类型的流控制:串行并行,如图 2.10 所示。

图 2.10. 异步任务的串行执行在概念上类似于同步逻辑:任务按顺序执行。然而,并行任务不需要一个接一个地执行。

需要依次发生的任务被称为 串行。一个简单的例子是创建目录的任务和将文件存储在其中的任务。您不能在创建目录之前存储文件。

不需要依次发生的任务被称为 并行。这些任务相对于彼此开始和结束的时间并不一定重要,但它们应该在进一步逻辑执行之前全部完成。一个例子是下载多个文件,这些文件稍后将被打包成 zip 存档。文件可以同时下载,但所有下载应该在创建存档之前完成。

跟踪串行和并行流程控制需要程序化记账。当您实现串行流程控制时,您需要跟踪当前正在执行的任务或维护一个未执行任务队列。当您实现并行流程控制时,您需要跟踪已执行完成的任务数量。

流程控制工具为您处理记账,这使得分组异步串行或并行任务变得容易。尽管许多社区创建的附加组件处理异步逻辑的排序,但自己实现流程控制可以消除神秘感,并帮助您更深入地理解如何处理异步编程的挑战。

在以下章节中,我们将向您展示

  • 何时使用串行流程控制

  • 如何实现串行流程控制

  • 如何实现并行流程控制

  • 如何使用第三方模块进行流程控制

让我们先看看在异步世界中何时以及如何处理串行流程控制。

2.11. 何时使用串行流程控制

要按顺序执行大量异步任务,您可以使用回调函数,但如果您有很多任务,您将不得不组织它们。如果您不这样做,您将因为过多的回调嵌套而得到杂乱的代码。

以下代码是使用回调函数按顺序执行任务的示例。该示例使用 setTimeout 来模拟需要时间执行的任务:第一个任务需要一秒钟,下一个任务需要半秒钟,最后一个任务需要十分之一秒。setTimeout 只是一个人工模拟;在实际代码中,您可能正在读取文件、发起 HTTP 请求等。尽管这段示例代码很短,但它可能有点杂乱,而且没有简单的方法来程序化地添加另一个任务。

setTimeout(() => {
  console.log('I execute first.');
  setTimeout(() => {
    console.log('I execute next.');
    setTimeout(() => {
      console.log('I execute last.');
    }, 100);
  }, 500);
}, 1000);

或者,您可以使用流程控制工具,例如 Async (caolan.github.io/async/) 来执行这些任务。Async 使用简单,并且得益于拥有一个小型代码库(仅有 837 字节,已压缩和最小化)。您可以使用以下命令安装 Async:

npm install async

现在,使用下一列表中的代码重新实现之前的代码片段,使用串行流程控制。

列表 2.16. 使用社区创建的附加组件进行串行控制

虽然使用流程控制的实现意味着代码行数更多,但它通常更容易阅读和维护。你可能不会一直使用流程控制,但如果遇到想要避免回调嵌套的情况,它是一个提高代码可读性的实用工具。

现在你已经看到了使用专用工具进行串行流程控制的示例,让我们看看如何从头开始实现它。

2.12. 实现串行流程控制

要使用串行流程控制按顺序执行多个异步任务,你首先需要将这些任务按执行顺序放入一个数组中。如图 2.11 所示,这个数组充当一个队列:当你完成一个任务时,你会从数组中按顺序提取下一个任务。

图 2.11. 串行流程控制的工作原理

第二章图 11 的替代文本

每个任务都以函数的形式存在于数组中。当任务完成时,任务应调用处理函数来指示错误状态和结果。在这个实现中,如果存在错误,处理函数将停止执行。如果没有错误,处理函数将从队列中提取下一个任务并执行它。

为了演示串行流程控制的实现,你将创建一个简单的应用程序,该应用程序从随机选择的 RSS 源中显示单个文章的标题和 URL。可能的 RSS 源列表指定在一个文本文件中。应用程序的输出将类似于以下文本:

Of Course ML Has Monads!
http://lambda-the-ultimate.org/node/4306

我们的示例需要使用 npm 注册表中的两个辅助模块。首先,打开命令提示符,然后输入以下命令以创建示例目录并安装辅助模块:

mkdir listing_217
cd listing_217
npm init -y
npm install --save request@2.60.0
npm install --save htmlparser@1.7.7

request 模块是一个简化的 HTTP 客户端,你可以使用它来获取 RSS 数据。htmlparser 模块具有将原始 RSS 数据转换为 JavaScript 数据结构的功能。

接下来,在你的新目录中创建一个名为 index.js 的文件,其中包含以下代码。

列表 2.17. 在简单应用程序中实现的串行流程控制

第二章示例 17-0

第二章示例 17-1

在尝试应用程序之前,在应用程序脚本相同的目录中创建文件 rss_feeds.txt。如果你没有现成的源,你可以尝试 Node blog 的源,其地址为 blog.nodejs.org/feed/。将 RSS 源的 URL 放入文本文件中,每行一个。创建此文件后,打开命令行并输入以下命令以切换到应用程序目录并执行脚本:

cd listing_217
node index.js

如此例实现所示,串行流程控制是一种在需要时将回调函数投入使用的做法,而不是简单地嵌套它们。

现在你已经知道了如何实现串行流程控制,让我们看看如何并行执行异步任务。

2.13. 实现并行流程控制

要并行执行多个异步任务,你再次需要将任务放入一个数组中,但这次任务的顺序不重要。每个任务应调用一个处理函数,该函数将增加已完成任务的数目。当所有任务都完成后,处理函数应执行一些后续逻辑。

对于并行流控制示例,你将创建一个简单的应用程序,该应用程序读取文本文件的内容,并输出文件中单词使用的频率。读取文本文件的内容将使用异步的readFile函数,因此可以并行执行多个文件读取。图 2.12 显示了该应用程序的工作方式。

图 2.12. 使用并行流控制实现多个文件中单词使用的频率计数

第二章示例 19-1 的替代图

输出看起来类似于以下文本(尽管它可能更长):

would: 2
wrench: 3
writeable: 1
you: 24

打开命令行提示符,输入以下命令以创建两个目录——一个用于示例,另一个包含你想要分析的文本文件:

mkdir listing_218
cd listing_218
mkdir text

接下来,在 listing_218 目录中创建一个名为 word_count.js 的文件,其中包含以下代码。

列表 2.18. 在简单应用程序中实现并行流控制

第二章示例 18-0

第二章示例 18-1

在尝试应用程序之前,在之前创建的文本目录中创建一些文本文件。然后打开命令行,输入以下命令以切换到应用程序目录并执行脚本:

cd word_count
node word_count.js

现在你已经了解了串行和并行流控制的工作原理,让我们看看如何使用社区创建的工具,这些工具允许你轻松地在应用程序中受益于流控制,而无需自己实现。

2.14. 使用社区工具

许多社区插件提供了方便的流控制工具。一些流行的插件包括 Async、Step 和 Seq。尽管每个都值得检查,但我们将再次使用 Async 作为另一个示例。

流控制社区插件

有关流控制社区插件的更多信息,请参阅 Werner Schuster 和 Dio Synodinos 在 InfoQ 上的文章“虚拟面板:如何在 JavaScript 中生存异步编程”:mng.bz/wKnV

列表 2.19 是一个示例,展示了如何在脚本中使用 Async 按顺序执行任务,该脚本使用并行流控制同时下载两个文件,然后归档它们。

以下示例在 Microsoft Windows 中无法工作

由于 Windows 操作系统没有自带tarcurl命令,以下示例在该操作系统中无法工作。

在此示例中,我们使用串行控制确保在归档之前完成下载。

列表 2.19. 在简单应用程序中使用社区插件流控制工具

第二章示例 19-0

第二章示例 19-1

脚本定义了一个辅助函数,用于下载任何指定的 Node 源代码版本。然后执行两个连续的任务:并行下载两个版本的 Node 以及将下载的版本捆绑成一个新的存档文件。

2.15. 摘要

  • 节点模块可以被组织成可重用的模块。

  • require 函数用于加载模块。

  • module.exportsexports 对象用于在模块内部共享函数和变量。

  • package.json 文件用于指定依赖项以及哪个文件被导出为主文件。

  • 异步逻辑可以通过嵌套回调、事件发射器和流程控制工具进行控制。

第三章。什么是 Node 网络应用程序?

本章涵盖

  • 创建一个新的网络应用程序

  • 构建 RESTful 服务

  • 持久化数据

  • 使用模板

本章全部关于 Node 网络应用程序。阅读本章后,你将了解 Node 网络应用程序的样子以及如何开始构建它们。你将做现代网络开发者在构建应用程序时所做的所有事情。

你将构建一个名为 later 的网络应用程序,该应用程序灵感来源于流行的“稍后阅读”网站,如 Instapaper (www.instapaper.com) 和 Pocket (getpocket.com)。这包括启动一个新的 Node 项目,管理依赖项,创建 RESTful API,将数据保存到数据库中,并使用模板制作界面。这听起来可能很多,但你将在后续章节中再次探索本章中的每个想法。

图 3.1 展示了结果应该看起来像什么。

图 3.1. 一个“稍后阅读”网络应用程序

左侧的“稍后阅读”页面已经从目标网站中移除了所有导航,保留了主要内容和标题。更重要的是,文章被永久保存在数据库中,这意味着你可以在未来某个日期阅读它,而此时原始文章可能已无法检索。

在构建网络应用程序之前,你应该创建一个新的项目。下一节将展示如何从头开始创建 Node 项目。

3.1. 理解 Node 网络应用程序的结构

一个典型的 Node 网络应用程序具有以下组件:

  • package.json——一个包含依赖项列表和运行应用程序的命令的文件

  • public/——一个静态资产文件夹,例如 CSS 和客户端 JavaScript

  • node_modules/——项目依赖项安装的地方

  • 一个或多个包含你的应用程序代码的 JavaScript 文件

应用程序代码通常进一步细分为以下部分:

  • app.js 或 index.js——设置应用程序的代码

  • models/——数据库模型

  • views/——用于渲染应用程序页面的模板

  • controllers/ 或 routes/——HTTP 请求处理器

  • middleware/——中间件组件

没有规则规定你的应用程序应该如何构建:大多数 Web 框架都很灵活,需要配置。但这个模板是大多数项目中都会找到的一般轮廓。

如果你练习,学习如何做这件事会容易得多,所以让我们看看如何以经验丰富的 Node 程序员的方式创建一个骨架 Web 应用程序。

3.1.1. 开始一个新的 Web 应用程序

要创建一个新的 Web 应用程序,你需要创建一个新的 Node 项目。如果你想刷新记忆,请参考第二章,但为了回顾,你需要创建一个目录并使用默认值运行 npm init

mkdir later
cd later
npm init -fy

现在你有一个新的项目;接下来是什么?大多数人会添加一个来自 npm 的模块,使 Web 开发更容易。Node 有一个内置的 http 模块,它有一个服务器,但使用减少命令行 Web 开发任务样板代码的东西更容易。让我们看看如何安装 Express。

添加依赖项

要向项目中添加依赖项,请使用 npm install。以下命令安装了 Express:

npm install --save express

现在如果你查看 package.json,你应该会看到 Express 已经被添加。以下片段显示了相关部分:

"dependencies": {
  "express": "⁴.14.0"
}

Express 模块位于项目的 node_modules/ 文件夹中。如果你想从项目中卸载 Express,你可以运行 npm rm express --save。这将将其从 node_modules/ 中删除,并更新 package.json 文件。

一个简单的服务器

Express 专注于用 HTTP 请求和响应来建模你的应用程序,它是使用 Node 的内置 http 模块构建的。要创建一个基本的应用程序,你需要使用 express() 创建一个应用程序实例,添加一个路由处理程序,然后将应用程序绑定到一个 TCP 端口。以下是一个完整的示例:

const express = require('express');
const app = express();

const port = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.send('Hello World');
});

app.listen(port, () =>
  console.log(`Express web app available at localhost: ${port}`);
};

这并不像听起来那么复杂!将此代码保存到名为 index.js 的文件中,并通过输入 node index.js 来运行它。然后访问 http://localhost:3000 来查看结果。为了避免记住每个应用程序的运行方式,大多数人使用 npm 脚本来简化这个过程。

npm 脚本

要将你的服务器启动命令 (node index.js) 保存为 npm 脚本,请打开 package.json 并在 scripts 下添加一个名为 start 的新属性:

"scripts": {
  "start": "node index.js",
  "test": "echo \"Error: no test specified\" && exit 1"
},

现在,你可以通过输入 npm start 来运行你的应用程序。如果你看到错误,因为你的机器上端口 3000 已经被使用,你可以通过运行 PORT=3001 npm start 使用不同的端口。人们使用 npm 脚本做各种事情:构建客户端包、运行测试和生成文档。你可以放任何你喜欢的东西在那里;它基本上是一个迷你脚本调用工具。

3.1.2. 比较其他平台

为了比较,这里展示了等效的 PHP Hello World 应用程序:

<?php echo '<p>Hello World</p>'; ?>

它适合一行,易于理解,那么更复杂的 Node 示例有什么好处呢?区别在于编程范式:在 PHP 中,你的应用程序是一个 页面;在 Node 中,它是一个服务器。Node 示例对请求和响应有完全的控制权,因此你可以做各种事情而无需配置服务器。如果你想使用 HTTP 压缩或 URL 重定向,你可以将这些功能作为应用程序逻辑的一部分来实现。你不需要将 HTTP 和应用程序逻辑分开;它们成为你应用程序的一部分。

你不需要一个单独的 HTTP 服务器配置,你可以将其放在同一个地方,这意味着同一个目录。这使得 Node 应用程序易于部署和管理。

另一个使 Node 应用程序易于部署的功能是 npm。因为依赖项是按项目安装的,所以你不会在同一系统上的项目之间遇到冲突。

3.1.3. 接下来是什么?

现在你已经熟悉了使用 npm init 创建项目和用 npm install --save 安装依赖项,你可以快速创建新项目。这很好,因为它意味着你可以尝试新想法而不会弄乱其他项目。如果你想尝试一个热门的新网络框架,那么创建一个新的目录,运行 npm init,然后从 npm 安装模块。

在所有这些准备工作就绪后,你就可以开始编写代码了。在这个阶段,你可以将 JavaScript 文件添加到你的项目中,并使用 require 加载你用 npm --save 安装的模块。让我们专注于大多数网络开发者接下来会做的事情:添加一些 RESTful 路由。这将帮助你定义应用程序的 API 并确定需要哪些数据库模型。

3.2. 构建 RESTful 网络服务

你的应用程序将是一个 RESTful 网络服务,允许以类似 Instapaper 或 Pocket 的方式创建和保存文章。它将使用一个受原始 Readability 服务(www.readability.com)启发的模块,将混乱的网页转换为优雅的文章,你可以稍后阅读。

当设计 RESTful 服务时,你需要考虑你需要哪些操作,并将它们映射到 Express 的路由中。在这种情况下,你需要能够保存文章、获取它们以便阅读、获取所有文章的列表以及删除不再需要的文章。这对应以下路由:

  • POST /articles—创建一篇新文章

  • GET /articles/:id—获取单个文章

  • GET /articles—获取所有文章

  • DELETE /articles/:id—删除一篇文章

在涉及数据库和网页界面的问题之前,让我们专注于使用 Express 创建 RESTful 资源。你可以使用 cURL 向示例应用程序发出请求,以熟悉它,然后进行更复杂的操作,例如存储数据,使其更像一个真正的网络应用程序。

以下列表是一个简单的 Express 应用程序,它通过使用 JavaScript 数组存储文章来实现这些路由。

列表 3.1. RESTful 路由示例

将此列表保存为index.js,然后您应该能够使用node index.js`运行它。要使用此示例,请按照以下步骤操作:

mkdir listing3_1
cd listing3_1
npm init -fy
run npm install --save express@4.12.4

在第二章中更详细地探讨了创建新的 Node 项目。

运行示例和进行更改

要运行这些示例,每次编辑代码后请确保重新启动服务器。您可以通过按 Ctrl-C 结束 Node 进程,然后输入node index.js再次启动它来做到这一点。

示例以代码片段的形式呈现,因此您应该能够按顺序组合它们以生成一个可工作的应用程序。如果您无法运行它,请尝试从github.com/alexyoung/nodejsinaction下载本书的源代码。

列表 3.1 包含一个内置的样本数据数组,该数组用于通过 Express 的res.send方法以 JSON 格式响应所有文章!。Express 会自动将数组转换为有效的 JSON 响应,因此非常适合快速创建 REST API。

此示例还可以使用相同的原则响应单个文章!。您甚至可以使用标准的 JavaScript delete 关键字和一个在 URL 中指定的数字 ID 来删除文章!。您可以通过将它们放入路由字符串(/articles/:id)并在其中获取值来从 URL 中获取值,即req.params.id

列表 3.1 不能创建文章!,因为为此它需要一个请求体解析器;您将在下一节中了解这一点。首先,让我们看看如何使用 cURL(curl.haxx.se)使用此示例。

node index.js示例运行后,您可以使用浏览器或 cURL 向其发送请求。要获取一篇文章,请运行以下代码片段:

curl http://localhost:3000/articles/0

要获取所有文章,您需要向/articles 发送请求:

curl http://localhost:3000/articles

您甚至可以删除一篇文章:

curl -X DELETE http://localhost:3000/articles/0

但为什么我们说您不能创建文章呢?主要原因是在实现 POST 请求时需要解析请求体。Express 曾经附带一个内置的请求体解析器,但由于有太多的实现方式,开发者选择将其作为一个单独的依赖项。

请求体解析器知道如何接受 MIME 编码的(多用途互联网邮件扩展)POST 请求体,并将它们转换为可以在您的代码中使用的数据。通常,您会得到易于处理的 JSON 数据。每当您在网站上提交表单时,请求体解析器就会在服务器端软件的某个地方发挥作用。

要添加官方支持的请求体解析器,请运行以下 npm 命令:

npm install --save body-parser

现在,在文件的顶部附近加载 body parser,如下所示。如果你在跟随,你可以将其保存到与 listing 3.1 (listing3_1) 相同的文件夹中,但我们也在书籍的源代码中保存了它(ch03-what-is-a-node-web-app/listing3_2)。

列表 3.2. 添加 body parser

![Images/03lis02_alt.jpg]

这增加了两个有用的功能:JSON 主体解析 ![Images/circ1.jpg] 和表单编码的主体 ![Images/circ2.jpg]。它还提供了一个创建文章的基本实现:如果你发送一个名为 title 的字段的 POST 请求,一个新的文章将被添加到文章数组中!以下是 cURL 命令:

curl --data "title=Example 2" http://localhost:3000/articles

现在你离构建一个真正的网络应用程序不远了。你只需要两样东西:一种在数据库中永久保存数据的方法,以及一种生成网络上找到的文章的可读版本的方法。

3.3. 添加数据库

没有预定义的方法可以将数据库添加到 Node 应用程序中,但这个过程通常涉及以下步骤:

  1. 决定你想要使用的数据库。

  2. 查看 npm 上实现驱动程序或对象关系映射 (ORM) 的流行模块。

  3. 使用 npm -save 将模块添加到你的项目中。

  4. 创建封装数据库访问的 JavaScript API 的模型。

  5. 将模型添加到你的 Express 路由中。

在添加数据库之前,让我们继续关注 Express,通过设计步骤 5 中的路由处理代码来继续关注 Express。应用程序 Express 部分的 HTTP 路由处理程序将对数据库模型进行简单的调用。以下是一个示例:

app.get('/articles', (req, res, err) => {
  Article.all(err, articles) => {
    if (err) return next(err);
    res.send(articles);
  });
});

在这里,HTTP 路由是用于获取所有文章的,因此模型方法可能是 Article.all。这取决于你的数据库 API;典型的例子是 Article.find({}, cb),^([1]) 和 Article.fetchAll().then(cb)。^([2]) 注意,在这些例子中,cbcallback 的缩写。

¹

Mongoose: mongoosejs.com

²

Bookshelf.js bookshelfjs.org

考虑到有如此多的数据库,你如何决定使用哪一个?继续阅读,了解我们为什么在这个例子中使用 SQLite 的原因。

选择哪个数据库?

对于我们的项目,我们将使用 SQLite (www.sqlite.org),以及流行的 sqlite3 模块 (npmjs.com/package/sqlite3)。SQLite 很方便,因为它是一个进程内数据库:你不需要在你的系统上安装一个在后台运行的服务器。你添加的任何数据都会写入一个文件,该文件在应用程序停止和重新启动后仍然保留,因此它是开始使用数据库的好方法。

3.3.1. 创建自己的模型 API

文章应该被创建、检索和删除。因此,你需要以下方法来为 Article 模型类提供支持:

  • Article.all(cb)—返回所有文章。

  • Article.find(id, cb)—给定一个 ID,找到相应的文章。

  • Article.create({ title, content }, cb)—创建一个具有标题和内容的文章。

  • Article.delete(id, cb)—通过 ID 删除文章。

您可以使用 sqlite3 模块实现所有这些功能。此模块允许您使用 db.all 获取多行结果,使用 db.get 获取单行。首先您需要一个数据库连接。

以下列表展示了如何在 Node 中使用 SQLite 实现这些功能。此代码应保存为同一文件夹中的 db.js 文件。Node 将加载该模块图片,然后使用它来获取每篇文章图片,查找特定文章图片,以及删除文章图片

列表 3.3. Article 模型

图片

图片

在此示例中,创建了一个名为 Article 的对象,它可以使用标准 SQL 和 sqlite3 模块创建、获取和删除数据。首先,使用 sqlite3.Database 打开数据库文件图片,然后创建一个文章表图片IF NOT EXISTS SQL 语法在这里很有用,因为它意味着您可以重新运行代码,而不会意外删除和重新创建文章表。

当数据库和表就绪时,应用程序就绪以进行查询。要获取所有文章,您使用 sqlite3 的 all 方法图片。要获取特定文章,使用带值的问号查询语法图片;sqlite3 模块将 ID 插入查询中。最后,您可以使用 run 方法插入和删除数据图片

为了使此示例正常工作,您需要使用 npm install --save sqlite3 安装 sqlite3 模块。在编写时,它的版本是 3.1.8。

现在基本数据库功能已经就绪,您需要将其添加到来自列表 3.2 的 HTTP 路由中。

下一列表展示了如何添加除 POST 之外的所有方法。(您将单独处理 POST,因为它需要使用您尚未设置的易读性模块。)

列表 3.4. 将 Article 模型添加到 HTTP 路由

图片

图片

列表 3.4 假设您已将 列表 3.3 保存为 db.js 文件在同一目录下。Node 将加载该模块图片,然后使用它来获取每篇文章图片,查找特定文章图片,以及删除文章图片

最后要做的事情是添加创建文章的支持。为此,您需要能够下载文章并使用神奇的易读性算法处理它们。您需要的是 npm 中的一个模块。

3.3.2. 使文章可读并保存以备后用

现在您已经构建了一个 RESTful API,并且数据可以持久化到数据库中,您应该添加将网页转换为简化的“阅读视图”版本的代码。您不需要自己实现这一功能;相反,您可以使用 npm 中的一个模块。

如果您在 npm 中搜索 readability,您会发现相当多的模块。让我们尝试使用 node-readability(在撰写本文时版本为 1.0.1)。使用 npm install node-readability --save 安装它。该模块提供了一个异步函数,它下载一个 URL 并将 HTML 转换为简化表示。以下代码片段显示了如何使用 node-readability;如果您想尝试它,请将代码片段添加到 index.js 中,除了 列表 3.5:

const read = require('node-readability');
const url = 'http://www.manning.com/cantelon2/';
read(url, (err, result)=>  {
  // result has .title and .content
});

可以使用 node-readability 模块与您的数据库类一起使用,通过 Article.create 方法保存文章:

read(url, (err, result) => {
  Article.create(
    { title: result.title, content: result.content },
    (err, article) => {
      // Article saved to the database
    }
  );
});

要在应用程序中使用此功能,打开 index.js 文件并添加一个新的 app.post 路由处理程序,用于下载和保存文章。结合您在 Express 中学到的关于 HTTP POST 和 body parser 的所有知识,以下列表提供了示例。

列表 3.5. 生成可读文章并保存

在这里,您首先从 POST 主体中获取 URL ![Images/circ1.jpg],然后使用 node-readability 模块获取 URL ![Images/circ2.jpg]。您通过使用您的 Article 模型类来保存文章。如果发生错误,您将错误处理传递给 Express 中间件堆栈 ![Images/circ3.jpg];否则,将文章的 JSON 表示发送回客户端。

您可以通过使用 --data 选项来发送一个 POST 请求,使其与这个示例一起工作:

curl --data "url=http://manning.com/cantelon2/" http://localhost:3000/articles

在前一节中,您添加了一个数据库模块,创建了一个围绕它的 JavaScript API,并将其绑定到 RESTful HTTP API。这是一项大量工作,它将成为您作为后端开发人员努力的主要部分。随着您在本书中查看 MongoDB 和 Redis,您将在本书的后面部分了解更多关于数据库的内容。

现在您不仅可以保存文章,还可以以编程方式检索它们,因此您将添加一个网络界面,以便您可以阅读文章。

3.4. 添加用户界面

在 Express 项目中添加界面涉及几个方面。首先是使用模板引擎;我们将很快向您展示如何安装一个并渲染模板。您的应用程序还应提供静态文件,例如 CSS。在渲染模板和编写任何 CSS 之前,您需要知道如何使之前示例中的路由处理程序在必要时同时响应 JSON 和 HTML。

3.4.1. 支持多种格式

到目前为止,您已经使用 res.send() 来向客户端发送 JavaScript 对象。您使用 cURL 来发送请求,在这种情况下 JSON 很方便,因为它在控制台中易于阅读。但为了真正使用应用程序,它还需要支持 HTML。您如何支持两者?

基本技术是使用 Express 提供的 res.format 方法。它允许您的应用程序根据请求响应正确的格式。要使用它,提供一个包含响应所需方式的函数的格式列表:

res.format({
  html: () => {
    res.render('articles.ejs', { articles: articles });
  },
  json: () => {
    res.send(articles);
  }
});

在这个片段中,res.render 将会在 views 文件夹中渲染 articles.ejs 模板。但为了使其工作,你需要安装一个模板引擎并创建一些模板。

3.4.2. 渲染模板

可用的模板引擎有很多,其中一种简单且易于学习的是 EJS(嵌入式 JavaScript)。从 npm 安装 EJS 模块(在写作时 EJS 版本为 2.3.1):

npm install ejs --save

现在 res.render 可以渲染格式化过的 EJS HTML 文件。如果你将 列表 3.4 中的 app.get('/articles') 路由处理器的 res.send (articles) 替换掉,在浏览器中访问 http://localhost:3000/articles 应该会尝试渲染 articles.ejs。

接下来,你需要在 views 文件夹中创建 articles.ejs 模板。下面的列表显示了一个你可以使用的完整模板。

列表 3.6. 文章列表模板

文章列表模板使用了一个头部 和页脚模板,这些模板作为代码示例中的片段包含在内。这是为了避免在每个模板中重复头部和页脚。文章列表通过使用标准的 Java-Script forEach 循环进行迭代,然后使用 EJS <%= value %> 语法将文章 ID 和标题注入到模板中

以下是一个示例头部模板,保存为 views/head.ejs:

<html>
  <head>
    <title>Later</title>
  </head>
  <body>
    <div class="container">

这是对应的页脚(保存为 views/foot.ejs):

    </div>
  </body>
</html>

res.format 方法也可以用来显示特定的文章。从这里开始,事情开始变得有趣,因为为了让这个应用程序有意义,文章应该看起来整洁且易于阅读。

3.4.3. 使用 npm 管理客户端依赖

模板就绪后,下一步是添加一些样式。与其创建样式表,不如重用现有的样式,你甚至可以使用 npm 来这样做!流行的 Bootstrap (getbootstrap.com/) 客户端框架可在 npm (www.npmjs.com/package/bootstrap) 上找到,因此将其添加到这个项目中:

npm install bootstrap --save

如果你查看 node_modules/bootstrap/,你会看到 Bootstrap 项目的源代码。然后,在 dist/css 文件夹中,你会找到 Bootstrap 伴随的 CSS 文件。要在你的项目中使用这些文件,你需要能够提供静态文件服务。

提供静态文件

当你需要将客户端 JavaScript、图像和 CSS 发送到浏览器时,Express 有一些内置的中间件称为 express.static。要使用它,你需要将其指向包含静态文件的目录,然后这些文件将可供浏览器访问。

在主 Express 应用程序文件(index.js)的顶部附近,有一些加载项目所需中间件的代码行:

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

要加载 Bootstrap 的 CSS,使用 express.static 在正确的 URL 上注册文件:

app.use(
  '/css/bootstrap.css',
  express.static('node_modules/bootstrap/dist/css/bootstrap.css')
);

现在,你可以在模板中添加 /css/bootstrap.css 以获取一些酷炫的 Bootstrap 样式。以下 views/head.ejs 应该看起来像这样:

<html>
  <head>
    <title>later;</title>
    <link rel="stylesheet" href="/css/bootstrap.css">
  </head>
  <body>
    <div class="container">

这只是 Bootstrap 的 CSS;Bootstrap 还附带其他文件,包括图标、字体和 jQuery 插件。您可以将这些文件添加到您的项目中,或者使用工具将它们全部打包起来,以便更容易加载。

使用 npm 和客户端开发做更多的事情

之前的例子是使用 npm 通过浏览器库的简单示例。Web 开发者通常会下载 Bootstrap 的文件,然后手动将它们添加到他们的项目中,尤其是那些从事简单静态网站设计的网页设计师。

但现代前端开发者使用 npm 下载库并在客户端 JavaScript 中加载它们。借助 Browserify (browserify.org/) 和 webpack (webpack.github.io/) 等工具,您将获得 npm 安装和 require 加载依赖项的所有功能。想象一下,您不仅可以在 Node 代码中,还可以在前端开发代码中键入 const React = require('react')!这超出了本章的范围,但它为您展示了通过结合 Node 编程和前端开发的技术可以解锁的强大功能。

3.5. 摘要

  • 您可以使用 npm init 和 Express 快速从头开始构建一个 Node 网络应用程序。

  • 安装依赖项的命令是 npm install

  • Express 允许您使用 RESTful API 创建网络应用程序。

  • 选择合适的数据库和数据库模块需要一些前期调查,并取决于您的需求。

  • SQLite 对于小型项目来说很方便。

  • EJS 是在 Express 中渲染模板的一种简单方法。

  • Express 支持许多模板引擎,包括 Pug 和 Mustache。

第二部分. 使用 Node 进行 Web 开发

现在,你已经准备好深入了解后端开发了。Node 在服务器端代码之外找到了一个重要的细分市场:前端构建系统。在本部分中,你将学习如何使用 webpack 和 Gulp 开始项目。我们还将介绍最受欢迎的 Web 框架,并从多个开发者的角度进行比较,以帮助你决定适合你项目的最佳框架。

如果你想要详细了解 Connect 和 Express,第六章完全致力于使用这些模块构建 Web 应用程序。还有一个章节专门介绍模板和 Node 数据库的使用。

为了完成使用 Node 进行全栈 Web 开发的旅程,我们包括了测试和部署章节,这样你就可以准备你的第一个 Node 应用程序。

第四章. 前端构建系统

本章涵盖

  • 使用 npm 脚本简化复杂命令

  • 使用 Gulp 管理重复性任务

  • 使用 webpack 打包客户端 Web 应用

在现代 Web 开发中,Node 越来越多地被用来运行前端工程师依赖的工具和服务。作为一个 Node 程序员,你可能负责设置和维护这些工具。作为一个全栈开发者,你将希望使用这些工具来创建更快、更可靠的 Web 应用程序。在本章中,你将学习如何使用 npm 脚本、Gulp 和 webpack 构建可维护的项目。

使用前端构建系统的益处可能很大。它们可以帮助你编写更易读和面向未来的代码。当你可以用 Babel 进行转译时,无需担心 ES2015 浏览器支持问题。此外,因为你可以生成源映射,基于浏览器的调试仍然可行。

下一节简要介绍了使用 Node 进行前端开发。之后,你将看到一些现代前端技术,如 React 的示例,你可以将其用于自己的项目。

4.1. 使用 Node 理解前端开发

最近,前端和后端开发者已经转向使用 npm 来分发 JavaScript。这意味着 npm 被用于前端模块,如 React,以及后端代码,如 Express。但有些模块并不完全属于任何一方:lodash 就是一个可以在 Node 和浏览器中使用的通用库的例子。通过精心打包 lodash,相同的模块可以被 Node 和浏览器使用,并且可以在项目中使用 npm 管理依赖项。

你可能见过其他专门针对客户端开发的模块系统,例如 Bower (bower.io/)。你仍然可以使用这些工具,但作为一个 Node 开发者,你应该考虑使用 npm。

Node 不仅仅用于包分发。前端开发者也越来越依赖于生成可移植、向后兼容的 JavaScript 的工具。转换器,如 Babel (babeljs.io/),用于将现代 ES2015 转换为更广泛支持的 ES5 代码。其他工具包括压缩器(例如,UglifyJS;github.com/mishoo/UglifyJS)和代码检查器(例如,ESLint,eslint.org/),用于在发货前验证代码的正确性。

测试运行器也通常由 Node 驱动。您可以在 Node 进程中运行 UI 代码的测试,或者使用 Node 脚本驱动在浏览器中运行的测试。

同时使用这些工具也很典型。当您开始处理转换器、压缩器、代码检查器和测试运行器时,您需要记录构建过程的工作方式。一些项目使用 npm 脚本;其他项目使用 Gulp 或 webpack。您将在本章中查看所有这些方法,并了解一些相关的最佳实践。

4.2. 使用 npm 运行脚本

Node 自带 npm,npm 具有运行脚本的内置功能。因此,您可以依赖您的合作者或用户能够调用npm startnpm test等命令。要为 npm start 添加自己的命令,您需要将其添加到项目 package.json 文件的scripts属性中:

{
  ...
  "scripts": {
    "start": "node server.js"
  },
  ...
}

即使您没有定义startnode server.js也是默认的,所以技术上您可以留空,如果这就是您需要的所有内容——只需记住创建一个名为 server.js 的文件。定义test属性很有用,因为您可以将测试框架作为依赖项包含在内,并通过输入npm test来运行它。假设您正在使用 Mocha (www.npmjs.com/package/mocha) 进行测试,并且您已使用npm install --save-dev安装了它。为了避免全局安装 Mocha,您可以将以下语句添加到您的 package.json 文件中:

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

注意,在前面的示例中,向 Mocha 传递了参数。您也可以通过使用两个短横线来在运行 npm 脚本时传递参数:

npm test -- test/*.js

表 4.1 展示了部分可用的 npm 命令的分解。

表 4.1. npm 命令
命令 package.json 属性 示例用法
启动 scripts.start 启动 Web 应用服务器或 Electron 应用。
停止 scripts.stop 停止 Web 服务器。
重启 运行停止然后重启。
安装, 安装后 scripts.install, scripts.postinstall 在安装包之后运行原生构建命令。注意,安装后只能在 npm run postinstall 下运行。

更多命令可用,包括在发布前清理包的命令,以及迁移包版本的前/后版本命令。但对于大多数 Web 开发任务,starttest是您想要的命令。

你可能想要定义的大量任务可能不适合支持的命令名称。例如,假设你正在处理一个简单的项目,该项目是用 ES2015 编写的,但你希望将其转换为 ES5。你可以使用 npm run 来做这件事。在下一节中,你将运行一个教程,设置一个新的项目,可以构建 ES2015 文件。

4.2.1. 创建自定义 npm 脚本

npm run 命令,从 npm run-script 别名,用于定义任意脚本,通过 npm run script-name 调用。让我们看看如何创建一个使用 Babel 构建客户端脚本的脚本。

首先,设置一个新的项目和安装必要的依赖项:

mkdir es2015-example
cd es2015-example
npm init -y
npm install --save-dev babel-cli babel-preset-es2015
echo '{ "presets": ["es2015"] }' > .babelrc

现在你应该有一个新的 Node 项目,其中包含了基本的 Babel ES2015 工具和插件。接下来,打开 package.json 文件,并在 scripts 下添加一个 babel 属性。它应该运行已安装到项目 node_modules/.bin 文件夹中的脚本:

"babel": "./node_modules/.bin/babel browser.js -d build/"

这里有一个使用 ES2015 语法示例文件,你可以使用它;保存为 browser.js:

class Example {
  render() {
    return '<h1>Example</h1>';
  }
}

const example = new Example();
console.log(example.render());

你可以通过运行 npm run babel 来测试这一点。如果一切配置正确,你现在应该有一个包含 browser.js 的构建文件夹。打开 browser.js 以确认它确实是一个 ES5 文件。由于太长无法打印,所以请在文件顶部附近寻找类似 var_createClass 的内容。

如果你的项目在构建时只做这些,你可以在 package.json 文件中将它命名为 build 而不是 babel。但你可以更进一步,添加 UglifyJS:

npm i --save-dev uglify-es

可以通过使用 node_modules/.bin/uglifyjs 来调用 UglifyJS,所以将其添加到 package.json 中的 scripts 部分,命名为 uglify

./node_modules/.bin/uglifyjs build/browser.js -o build/browser.min.js

现在你应该能够调用 npm run uglify。你可以通过结合这两个脚本将所有这些整合在一起。添加另一个名为 buildscript 属性,它调用这两个任务:

"build": "npm run babel && npm run uglify"

两个脚本都是通过输入 npm run build 来运行的。现在,你们团队的人可以通过调用这个简单的命令来组合多个前端打包工具。这样做的原因是 Babel 和 UglifyJS 可以作为命令行脚本运行,并且它们都接受命令行参数,因此很容易将它们添加到 package.json 文件中的一行代码中。在 Babel 的例子中,你可以通过定义一个 .babelrc 文件来管理复杂的行为,这在本章的早期部分你已经做过。

4.2.2. 配置前端构建工具

通常,当与 npm 脚本一起使用时,你可以通过三种方式配置前端构建工具:

  • 指定命令行参数。例如,./node_modules/.bin/uglify --source-map

  • 创建一个具有选项的项目特定配置文件。这通常用于 Babel 和 ESLint。

  • 在 package.json 中添加配置选项。Babel 也支持这一点。

如果你的构建需求有更多步骤,包括复制、连接或移动文件等操作,你会怎么办?你可以创建一个 shell 脚本并通过 npm 脚本来调用它,但如果你使用 Java-Script,可能会帮助你的 JavaScript 熟练的协作者。许多构建系统提供了用于自动化构建的 JavaScript API。在下一节中,你将了解这样一个解决方案:Gulp。

4.3. 使用 Gulp 提供自动化

Gulp (gulpjs.com/) 是一个基于流的构建系统。你可以将流路由在一起来创建执行更多操作(不仅仅是转译或压缩代码)的构建过程。想象一下,你有一个使用 Angular 构建的管理区域,但你有一个基于 React 的公共区域;这两个子项目共享某些构建需求。使用 Gulp,你可以为每个阶段重用构建过程的一部分。图 4.1 展示了这两个共享功能的构建过程的示例。

图 4.1. 两个共享功能的构建过程

Gulp 通过两种技术帮助你实现高度的重用:使用插件和定义自己的构建任务。如图所示,构建过程是一个流,因此你可以将任务和插件通过彼此进行管道。例如,你可以使用 Gulp Babel (www.npmjs.com/package/gulp-babel/) 和内置的 gulp.src 文件 globbing 方法来处理上一个示例中的 React 部分:

gulp.src('public/index.jsx')
  .pipe(babel({
    presets: ['es2015', 'react']
  }))
  .pipe(minify())
  .pipe(gulp.dest('build/public.js'));

你甚至可以非常容易地将连接阶段添加到这个链中。在更仔细地查看这个语法之前,让我们看看如何设置一个小型 Gulp 项目。

4.3.1. 将 Gulp 添加到项目中

要将 Gulp 添加到项目中,你需要使用 npm 安装 gulp-cli 和 gulp 包。大多数人会将 gulp-cli 全局安装,这样就可以简单地通过输入 gulp 来运行 Gulp 脚本。请注意,如果你之前已经全局安装了 gulp 包,你应该运行 npm rm --global gulp。在下一个片段中,你将全局安装 gulp-cli 并创建一个新的具有 Gulp 开发依赖项的 Node 项目:

npm i --global gulp-cli
mkdir gulp-example
cd gulp-example
npm init -y
npm i –save-dev gulp

接下来创建一个名为 gulpfile.js 的文件:

touch gulpfile.js

打开 gulpfile 文件。现在你将使用 Gulp 来构建一个小型 React 项目。它将使用 gulp-babel (www.npmjs.com/package/gulp-babel)、gulp-sourcemaps 和 gulp-concat:

npm i --save-dev gulp-sourcemaps gulp-babel babel-preset-es2015
npm i --save-dev gulp-concat react react-dom babel-preset-react

记得在将 Gulp 插件添加到项目时使用 --save-dev 选项。如果你在尝试新的插件并决定移除它们,可以使用 npm uninstall --save-dev 来从 ./node_modules 中移除它们,并更新项目的 package.json 文件。

4.3.2. 创建和运行 Gulp 任务

创建 Gulp 任务涉及在名为 gulpfile.js 的文件中编写带有 Gulp API 的 Node 代码。Gulp 的 API 有用于查找文件并将它们通过以某种方式转换它们的插件进行管道的方法。

要亲自尝试:打开 gulpfile.js 并设置一个构建任务,使用 gulp.src 来查找 JSX 文件,使用 Babel 来处理 ES2015 和 React,然后使用 concat 将每个文件连接起来,如下所示。

列表 4.1. 使用 Babel 的 ES2015 和 React 的 gulpfile

列表 4.1 使用了多个 Gulp 插件来捕获、处理和写入文件。首先,使用文件通配符找到所有输入文件,然后使用 gulp-sourcemaps 插件收集客户端调试的源映射度量。请注意,源映射需要两个阶段:一个用于声明你想要使用源映射,另一个用于写入源映射文件。同时,gulp-babel 被配置为处理带有 ES2015 和 React 的文件。

这个 Gulp 任务可以通过在终端中输入 gulp 来运行。

在这个例子中,所有文件都通过使用单个插件进行转换。碰巧 Babel 正在转译 React JSX 代码并将 ES2015 转换为 ES5。一旦完成,文件就使用 gulp-concat 插件进行连接。现在所有转译都已完成,可以安全地编写源映射,并将最终构建放置在 dist 文件夹中。

你可以通过创建一个名为 app/index.jsx 的 JSX 文件来尝试这个 gulpfile。以下是一个简单的 JSX 文件,你可以用它来测试 Gulp:

import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.render(
  <h1>Hello, world!</h1>,
  document.getElementById('example')
);

Gulp 使得用 JavaScript 表达构建阶段变得容易,通过使用 gulp.task(),你可以向此文件添加自己的任务。任务通常遵循相同的模式:

  1. 源文件— 收集输入文件

  2. 转译— 通过一个插件将它们转换

  3. 合并— 将文件连接起来创建一个单体构建

  4. 输出— 设置文件目标或移动输出文件

在上一个例子中,sourcemaps 是一个特殊情况,因为它需要两个管道:一个用于配置,另一个用于输出文件。这是有道理的,因为源映射依赖于将原始行号映射到转译构建的行号。

4.3.3. 监视更改

前端开发者最不想看到的就是构建/刷新周期。简化构建的最简单方法就是使用 Gulp 插件来监视文件系统中的更改。但还有其他选择。一些库与热重载配合得很好,而更通用的基于 DOM 和 CSS 的项目也可以与 LiveReload (livereload.com/) 项目很好地工作。

例如,你可以在 列表 4.1 中的上一个项目中添加 gulp-watch (www.npmjs.com/package/gulp-watch)。将包添加到项目中:

npm i --save-dev gulp-watch

现在请记住在 gulpfile.js 中加载该包:

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

并添加一个监视任务,调用上一个示例中的默认任务:

gulp.task('watch', () => {
  watch('app/**.jsx', () => gulp.start('default'));
});

这定义了一个名为 watch 的任务,然后使用 watch() 来监视 React JSX 文件的变化。每当文件发生变化时,默认的构建任务就会运行。经过一些小的修改,这个配方可以用来构建 Syntactically Awesome Style Sheets (SASS) 文件,优化图像,或者几乎任何你可能需要的用于前端项目的东西。

4.3.4. 为大型项目使用单独的文件

随着项目的增长,它们往往需要更多的 Gulp 任务。最终,你可能会得到一个难以理解的冗长文件。然而,你可以这样做:将你的代码拆分成单独的模块。

正如你所见,Gulp 使用 Node 的模块系统来加载插件。没有特殊的插件加载系统;它只是使用标准模块。你也可以使用 Node 的模块系统来拆分长的 gulpfiles,使你的文件更易于维护。要使用单独的文件,你需要遵循以下步骤:

  1. 创建一个名为 gulp 的文件夹,以及一个名为 tasks 的子文件夹。

  2. 通过在单独的文件中使用常规的 gulp.task() 语法来定义你的任务。每个任务一个文件是一个很好的经验法则。

  3. 创建一个名为 gulp/index.js 的文件,用于引入每个 Gulp 任务文件。

  4. 在 gulpfile.js 中引入 gulp/index.js 文件。

文件树应该看起来像以下片段:

gulpfile.js
gulp/
gulp/index.js
gulp/tasks/development-build.js
gulp/tasks/production-build.js

这种技术可以帮助你组织具有复杂构建任务的项目,但它也可以与 gulp-help (www.npmjs.com/package/gulp-help) 模块结合使用。此模块允许你记录 Gulp 任务;运行 gulp help 会显示每个任务的信息。当你在一个团队中工作,或者在不同使用 Gulp 的众多项目之间切换时,这很有帮助。图 4.2 展示了输出看起来像什么。

图 4.2. 示例 gulp-help 输出

Gulp 是一个通用的项目自动化工具。当向项目中添加跨平台的维护脚本时很好——例如,运行复杂的客户端测试或为数据库提供固定值。尽管它可以用于构建客户端资源,但也有专门为此目的设计的工具,这意味着它们通常比 Gulp 需要更少的代码和配置。其中一个这样的工具是 webpack,它专注于打包 JavaScript 和 CSS 模块。下一节将演示如何为 React 项目使用 webpack。

4.4. 使用 webpack 构建 web 应用程序

webpack 是专门为构建 Web 应用程序而设计的。想象一下,你正在与一个已经为单页 Web 应用程序创建了一个静态站点的设计师合作,你想要将其调整为构建更高效的 CSS 和 ES2015 JavaScript。使用 Gulp,你编写 JavaScript 代码来驱动构建系统,因此这将涉及编写 gulpfile 和几个构建任务。使用 webpack,你编写一个配置文件,然后通过使用插件和加载器引入新的功能。在某些情况下,不需要额外的配置:你只需在命令行上输入 webpack 并指定源文件路径的参数,它就会构建你的项目。跳转到 章节 4.4.4 以查看其外观。

Webpack 的一大优点是它更容易快速设置一个支持增量构建的构建系统。如果你将其设置为在文件更改时自动构建,那么在单个文件更改时,它不需要重新构建整个项目。因此,构建可以更快且更容易理解。

本节展示了如何使用 webpack 为一个小型 React 项目。首先,让我们定义 webpack 使用的术语。

4.4.1. 使用包和插件

在设置 webpack 项目之前,应该明确一些术语。webpack 插件用于改变构建过程的行为。这可以包括自动将资源上传到 Amazon S3 (github.com/MikaAK/s3-plugin-webpack) 或从输出中删除重复文件。

与插件不同,加载器是对资源文件应用的转换。如果你需要将 SASS 转换为 CSS,或将 ES2015 转换为 ES5,你需要一个加载器。加载器是函数,将输入源文本转换为输出。它们可以是异步的或同步的。插件是类的实例,可以钩入 webpack 的更底层 API。

如果你需要转换 React 代码、CoffeeScript、SASS 或任何其他转译语言,你正在寻找一个 loader。如果你需要对你的 JavaScript 进行检测,或者以某种方式操作文件集,你需要一个 plugin

在下一节中,你将看到如何使用 Babel 加载器将 React ES2015 项目转换为浏览器友好的包。

4.4.2. 配置和运行 webpack

你将通过使用 webpack 重新创建 列表 4.1 中的 React 示例。要开始,在一个新项目中安装 React:

mkdir webpack-example
npm init -y
npm install --save react react-dom
npm install --save-dev webpack babel-loader babel-core
npm install --save-dev babel-preset-es2015 babel-preset-react

最后一行安装了 Babel 的 ES2015 插件和 React 转换器。现在你需要创建一个名为 webpack.config.js 的文件,该文件指导 webpack 在哪里找到输入文件,在哪里写入输出,以及使用哪些加载器。你将使用带有一些额外设置的 babel-loader,如下一列表所示。

列表 4.2. webpack.config.js 文件

图片 4.2

此配置文件封装了你成功构建一个使用 ES2015 的 React 应用的所有必需内容。设置非常简单:定义一个 entry,这是加载应用程序的主要文件。然后指定输出应该写入的目录;如果该目录尚不存在,则会创建它。接下来,定义一个 loader 并通过使用 test 属性将其与文件 glob 搜索关联起来。最后,确保为 loader 设置任何选项。在这个例子中,这些选项加载了 ES2015 和 React Babel 插件。

你需要在 app/index.jsx 中包含一个示例 React JSX 文件;使用 第 4.3.2 节 中的片段。现在运行 ./node_modules/.bin/webpack 将编译一个带有 React 依赖项的 ES5 版本的文件。

4.4.3. 使用 webpack 开发服务器

如果你想要避免每次 React 文件更改时都重新构建项目,你可以使用 webpack 开发服务器 (webpack.github.io/docs/webpack-dev-server.html)。在本书的源代码中,这可以在 webpack--hotload-example (ch04-front-end/webpack-hotload-example) 下找到。这个小 Express 服务器会在文件更改时运行 webpack 与你的 webpack 配置文件,然后向浏览器提供更改后的资源。你应该在主 Web 服务器之外的不同端口上运行它,这意味着你的脚本标签在开发期间将包含不同的 URL。服务器构建资源并将它们存储在内存中,而不是在你的 webpack 输出文件夹中。你还可以使用 webpack-dev-server 进行热模块加载,类似于 LiveReload 服务器。

要将 webpack-dev-server 添加到项目中,请按照以下步骤操作:

  1. 使用 npm i --save-dev webpack-dev-server@1.14.1 安装 webpack-dev-server。

  2. 在 webpack.config.js 的 output 属性中添加一个 publicPath 选项。

  3. 将一个 index.html 文件添加到你的构建目录中,作为加载你的 JavaScript 和 CSS 打包文件的 harness。确保端口与下一步指定的端口相同。

  4. 使用你想要的选项运行服务器。例如,webpack-dev-server --hot --inline --content-base dist/ --port 3001

  5. 访问 http://localhost:3001/ 并加载应用程序。

从 列表 4.2 打开 webpack.config.js 并将 output 属性更改为包含 publicPath

output: {
  path: path.resolve(__dirname, 'dist'),
  filename: 'bundle.js',
  publicPath: '/assets/'
},

创建一个名为 dist/index.html 的新文件,如下所示。

列表 4.3. 一个 React 网络应用的示例 HTML 模板

图片

接下来打开 package.json 并在 scripts 属性下添加运行 webpack 服务器的命令:

"scripts": {
  "server:dev": "webpack-dev-server --hot –inline
     --content-base dist/ --port 3001"
 },

--hot 选项使开发服务器使用热模块重新加载。如果你编辑 app/index.jsx 中的示例 React 文件,你应该看到浏览器刷新。刷新机制由 --inline 选项指定。内联刷新意味着开发服务器将注入代码来管理刷新包。还有一个 iframe 版本,它将整个页面包裹在一个 iframe 中。

现在运行开发服务器:

npm run server:dev

运行 webpack 开发服务器将触发构建并启动一个监听 3001 端口的服务器。你可以在浏览器中通过访问 http://localhost:3001 来测试一切。

热重载

由于 React 和其他框架(包括 AngularJS),存在针对特定框架的热模块重载项目。一些考虑了数据流框架,如 Redux 和 Relay,这意味着可以在保持当前状态的同时刷新代码。这是执行代码重载的理想方式,因为你不需要重复执行步骤来重新创建你正在工作的 UI 状态。

然而,我们在这里提供的示例不那么特定于 React,并且是开始使用 webpack 开发服务器的好方法。务必进行实验,以找到最适合你项目的选项。

4.4.4. 加载 CommonJS 模块和资源

我们在本章中使用了 React 和 Babel,但如果你使用 webpack 与更纯净的 CommonJS 项目一起使用,那么 webpack 可以提供你需要的一切,无需 CommonJS 浏览器补丁。它甚至能够加载 CSS 文件。

webpack 和 CommonJS

要在 webpack 中使用 CommonJS 模块语法,你不需要进行任何设置。假设你有一个使用 require 的文件:

const hello = require('./hello');

hello();

另一个定义了 hello 函数的文件:

module.exports = function() {
  return 'hello';
};

然后,你只需要一个小的 webpack 配置文件来定义入口点(第一个代码片段)和构建目标路径:

const path = require('path');
const webpack = require('webpack');

module.exports = {
  entry: './app/index.js',
  output: { path: __dirname, filename: 'dist/bundle.js' },
};

此示例说明了 Gulp 和 webpack 的不同之处。webpack 完全专注于构建包,作为其中的一部分,能够生成带有 CommonJS 补丁的包。如果你打开 dist/bundle.js,你会在文件顶部看到 webpackBootstrap 补丁,然后每个原始源树中的文件都被封装在闭包中以模拟模块系统。以下代码片段是包的一部分:

function(module, exports, __webpack_require__) {

  const hello = __webpack_require__(1);

  hello();

  /***/ },
  /* 1 */
  /***/ function(module, exports) {

  module.exports = function() {
    return 'hello';
  };

代码注释显示了模块的定义位置,文件通过其闭包的参数访问 moduleexports 对象,以模拟 CommonJS 模块 API。

使用 webpack 包含 npm 包

你可以通过包含从 npm 下载的模块来进一步操作。假设你想使用 jQuery。你不必在页面上创建一个 script 标签,而是可以使用 npm i --save-dev jquery 安装它,然后就像加载 Node 模块一样加载它:

const jquery = require('jquery');

这意味着 webpack 默认为你提供 CommonJS 模块和访问 npm 模块的能力,无需任何额外配置!

查找加载器和插件

webpack 网站有一个加载器列表(webpack.github.io/docs/list-of-loaders.html)和插件列表(webpack.github.io/docs/list-of-plugins.html)。你还可以在 npm 上找到 webpack 工具;webpack 关键字是一个不错的起点(www.npmjs.com/browse/keyword/webpack)。

4.5. 摘要

  • 如果你需要自动化简单的任务或调用脚本,npm 脚本非常完美。

  • Gulp 可以用 JavaScript 编写更复杂的任务,并且是跨平台的。

  • 当 gulp 文件过长时,你可以将代码分成单独的文件。

  • webpack 可以用来生成客户端包。

  • 如果你只需要构建客户端包,使用 webpack 可能比设置等效的 Gulp 脚本要简单。

  • webpack 支持热模块重新加载,这意味着你将看到代码更改而无需刷新浏览器。

第五章. 服务器端框架

本章涵盖

  • 与流行的 Node Web 框架一起工作

  • 选择正确的框架

  • 使用 Web 框架构建 Web 应用程序

这章全部关于服务器端 Web 开发。它回答了诸如我如何为特定项目选择完美的框架,以及每个框架的优缺点是什么等问题?

选择正确的框架很困难,因为很难在公平的竞争环境中比较它们。大多数人没有时间学习所有这些框架,所以我们往往只对那些我们有经验的框架做出肤浅的决定。在某些情况下,你可能会同时使用不同的框架。例如,Express 可以用于大型应用程序,而支持大型应用程序的微服务可以编写在 hapi 中。

想象你正在构建一个内容管理系统(CMS)。它用于管理由研究公司收集的法律文件。它可以输出 PDF,并具有电子商务组件。这样的系统可以用以下方式使用单独的框架构建:

  • 文档上传、下载和阅读—— Express

  • PDF 生成微服务——hapi

  • 电子商务组件—— Sails.js

对于特定项目,完美的框架取决于项目的需求和团队的需求。在本章中,我们使用角色——假设的人——作为一种探索特定类型项目适合哪种框架的方式。通过这些虚构的程序员,你将了解 Koa、hapi、Sails.js、DerbyJS、Flatiron 和 LoopBack。角色在下一节中定义。

5.1. 角色

我们不想推销一个你将在每个项目中使用的单一框架。更好的做法是多样化,使用适合每个问题的工具组合。使用角色来思考设计是一种普遍的做法,部分原因是因为它有助于设计师与用户产生共鸣。

在本章中,角色被用来帮助你从第三人称的角度思考框架,看看不同类别的项目适合不同的解决方案。角色是根据专业情况和开发工具来定义的。你应该能够与我们这里创造的三个角色中的至少一个产生共鸣。

5.1.1. Phil:代理开发者

Phil 作为一名全栈 Web 开发者工作了三年。他做过一点 Ruby、Python 和客户端 JavaScript:

  • 工作情况—— 员工,全栈开发者

  • 工作类型— 前端工程,服务器端开发

  • 计算机— MacBook Pro

  • 工具— Sublime Text, Dash, xScope, Pixelmator, Sketch, GitHub

  • 背景— 高中教育;最初是一名业余程序员

Phil 的典型一天涉及与设计师和用户体验专家在敏捷风格的会议中一起开发或审查新功能,以及维护和错误修复。

5.1.2. Nadine:开源开发者

Nadine 在作为企业级网络开发者成功起步后转向了合同工:

  • 工作情况— 合同工,JavaScript 专家

  • 工作类型— 服务器端编程,偶尔使用 Go 和 Erlang 进行高性能编程。还编写了一个流行的开源、基于 Web 的电影目录应用程序

  • 计算机— 高端 PC,Linux

  • 工具— Vim, tmux, Mercurial, shell 中的任何东西

  • 背景—计算机科学学位

Nadine 的一天通常涉及在为她的两个主要客户工作足够的时间与从事她的开源项目之间取得平衡。她的客户工作是以测试驱动的,但她的开源项目更注重功能驱动。

5.1.3. Alice:产品开发者

Alice 在一个成功的 iOS 应用程序上工作,同时也帮助她的公司处理 Web API:

  • 工作情况— 员工,程序员

  • 工作类型— iOS 开发;也负责 Web 应用程序和网络服务

  • 计算机— MacBook Pro, iPad Pro

  • 工具— Xcode, Atom, Babel, Perforce

  • 背景— 科学学位;她当前创业公司的前五名员工之一

Alice 不情愿地使用 Xcode、Objective-C 和 Swift,但秘密地更喜欢 JavaScript,并对 ES2015 和 Babel 感到兴奋。她喜欢开发新的网络服务来支持她公司的 iOS 和桌面应用程序,并希望更频繁地从事基于 React 的网络应用程序开发。

现在已经定义了角色,让我们来定义术语 框架

5.2. 什么是框架?

本章讨论的一些服务器端框架在技术上根本不是框架。术语 框架 很不幸地被过度使用,对不同程序员意味着不同的事情。在 Node 社区中,更准确地称这些项目为 模块,但在直接比较这一系列库时,一个更细致的定义是有用的。

LoopBack 项目 (loopback.io/resources/#compare) 使用以下定义:

  • API 框架— 用于构建 Web API 的库,由帮助结构化应用程序的框架支持。LoopBack 本身被定义为这种类型的框架。

  • HTTP 服务器库— 基于 Express 的任何东西都属于这一类别,包括 Koa 和 Kraken.js。这些库帮助您构建基于 HTTP 动词和路由的应用程序。

  • HTTP 服务器框架— 用于构建使用 HTTP 通信的模块化服务器的框架。hapi 是这种类型框架的例子。

  • Web MVC 框架— 包括 Sails.js 在内的模型-视图-控制器框架属于这一类别。

  • 全栈框架— 这些框架在服务器和浏览器上使用 JavaScript,并且能够在两端之间共享代码。这被称为 同构代码。DerbyJS 是一个全栈 MVC 框架。

大多数 Node 开发者将 框架 理解为第二个术语:HTTP 服务器库。下一节将介绍 Koa,这是一个使用创新 ES2015 语法(称为 生成器)来提供独特方式处理 HTTP 中间件的服务器库。

5.3. Koa

Koa (koajs.com/)isis) 基于 Express,但使用 ES2015 生成器语法来定义中间件。这意味着你可以以几乎同步的方式编写中间件。这部分解决了高度依赖回调的中间件问题。在 Koa 中,你可以使用 yield 关键字退出并重新进入中间件。表 5.1 是 Koa 主要功能的概述。

表 5.1. Koa 的主要功能
库类型 HTTP 服务器库
功能 基于生成器的中间件,请求/响应模型
建议用途 轻量级 Web 应用,非严格 HTTP API,服务单页 Web 应用
插件架构 中间件
文档 koajs.com/
流行度 10,000 GitHub 星标
许可证 MIT

以下列表展示了如何使用 Koa 通过将执行权传递给下一个中间件组件并在完成时在调用者中继续执行来基准测试请求。

列表 5.1. Koa 的中间件排序

列表 5.1 使用生成器 在两个中间件组件之间切换上下文。请注意,我们使用关键字 function*——在这里不能使用箭头函数。通过使用 yield 关键字 ,执行步骤会下降到中间件堆栈,然后在下一个中间件组件返回时再次返回 。使用生成器函数的一个额外好处是你可以直接设置 this.body。相比之下,Express 使用一个函数来发送响应:res.send(response)。在 Koa 中间件中,this 被称为 上下文。为每个请求创建一个上下文,并用于封装 Node 的 HTTP requestresponse 对象 (nodejs.org/api/http.html)。每次你需要从请求中访问某些内容时,例如 GET 参数或 cookies,你都可以使用上下文。对于响应也是如此:正如你在 列表 5.1 中看到的,你可以通过在 this.body 上设置值来控制发送到浏览器的数据。

如果你之前使用过 Express 中间件和生成器语法,Koa 应该很容易学习。如果你对这两者中的任何一个都不熟悉,Koa 可能很难理解——或者至少可能很难看到这种风格的好处。图 5.1 更详细地展示了 yield 如何在中间件组件之间传递执行。

图 5.1. Koa 中间件执行顺序

图 5.1 中的每个阶段都对应于 列表 5.1 中的数字。首先,计时器在第一个中间件组件中设置 ,然后执行权传递给第二个中间件组件,该组件渲染主体 。在发送响应后,执行权返回到第一个中间件组件,并计算时间 。这通过 console.log 在终端中显示,然后请求完成 。请注意,阶段 在 列表 5.1 中不可见;它由 Koa 和 Node 的 HTTP 服务器处理。

5.3.1. 设置

使用 Koa 设置项目需要安装模块,然后定义中间件。如果你想要更多功能,例如一个路由 API,它可以使定义和响应各种类型的 HTTP 请求更容易,那么你需要安装路由中间件。这意味着典型的流程需要在事先规划项目将使用的中间件,因此你需要首先研究流行的模块。

人物想法

爱丽丝:“作为一名产品开发者,我喜欢 Koa 的最小功能集——因为我们的项目有独特的要求,我们真的希望根据我们的需求塑造整个堆栈。”

菲利普:“作为一名机构开发者,我发现处理中间件研究阶段太麻烦了。我宁愿有人帮我处理,因为我的许多项目有类似的要求,我不想反复安装相同的模块来做基本的事情。”

下一个部分演示了一个第三方模块,它为 Koa 实现了一个强大的路由库。

5.3.2. 定义路由

一个流行的路由中间件组件是 koa-router (www.npmjs.com/package/koa-router)。像 Express 一样,它基于 HTTP 动词,但与 Express 不同,它有一个可链式调用的 API。下面的代码片段显示了如何定义路由组:

router
 .post('/pages', function*(next) {
   // Create a page
 })
 .get('/pages/:id', function*(next) {
   // Render the page
 })
 .put('pages-update', '/pages/:id', function*(next) {
   // Update a page
 });

可以使用额外的参数来命名路由。这很好,因为你可以生成 URL,而并非所有 Node Web 框架都支持这一点。以下是一个示例:

router.url('pages-update', '99');

此模块结合了 Express 和其他 Web 框架的独特功能。

人物想法

菲利普:“这个路由库让我想起了我喜欢 Ruby on Rails 的一些东西,所以 Koa 最终可能会赢得我的青睐!”

纳丁:“我可以看到使用 Koa 对现有项目进行模块化的机会,然后与社区分享这段代码。”

5.3.3. REST API

Koa 不自带实现某种路由处理中间件所需的工具来制作 RESTful API。前面的示例可以扩展到在 Koa 中实现 RESTful API。

5.3.4. 优势

很容易说 Koa 的优势来自于其对生成器语法的早期采用,但现在随着 ES2015 在 Node 社区中的普及,这已经不再像以前那样独特。目前,Koa 的主要优势在于它既简洁又拥有一些优秀的第三方模块;查看 Koa 维基以获取更多信息 (github.com/koajs/koa/wiki#middleware)。产品开发者喜欢它,因为它具有优雅的语法,并且可以根据特定要求进行定制。

5.3.5. 弱点

Koa 的可配置性程度让一些开发者感到困惑。使用 Koa 创建许多小型项目可能会导致代码重用率低,除非你已经实施了代码共享策略。

5.4. 克隆龙

克隆龙基于 Express,但通过 PayPal 开发的自定义模块增加了新功能。特别是有一个有用的模块是 Lusca (github.com/krakenjs/lusca),它提供了一个应用安全层。尽管可以在没有克隆龙的情况下使用 Lusca,但克隆龙的一个好处是它预定义了项目结构。Express 和 Koa 应用不需要特定的项目结构,所以如果你需要帮助开始新项目,克隆龙可以帮助你开始。 表 5.2 展示了克隆龙主要功能的概述。

表 5.2. 克隆龙的主要功能
库类型 HTTP 服务器库
功能 严格的项目结构、模型、模板(Dust)、安全加固(Lusca)、配置管理、国际化
推荐用途 企业级 Web 应用
插件架构 Express 中间件
文档 www.kraken.com/help/api
流行度 4,000 GitHub 星标
许可 Apache 2.0

5.4.1. 设置

如果你已经有了一个 Express 项目,你可以将克隆龙作为中间件组件添加:

const express = require('express'),
const kraken = require('kraken-js');

const app = express();
app.use(kraken());
app.listen(3000);

但如果你想开始一个新项目,你应该尝试克隆龙的 Yeoman 生成器。Yeoman 是一个帮助你生成新项目的工具。通过使用 Yeoman 生成器,你可以为各种框架创建初始化的项目。以下是使用 Yeoman 创建定制克隆龙项目的步骤,以及克隆龙首选的文件系统布局:

$ npm install -g yo generator-kraken bower grunt-cli
$ yo kraken

     ,'""`.
hh  / _  _ \
    |(@)(@)|   Release the Kraken!
    )  __  (
   /,'))((`.\
  (( ((  )) ))
   `\ `)(' /'

Tell me a bit about your application:

[?] Name: kraken-test
[?] Description: A Kraken application
[?] Author: Alex R. Young
...

生成器会创建一个新的目录,因此你不需要自己这样做。生成器完成后,你应该能够启动服务器并访问 http://localhost:8000 来尝试它。

5.4.2. 定义路由

在克隆龙中,路由定义与控制器并列。与 Express 将路由定义和路由处理程序分离不同,克隆龙采用了一种受 MVC 启发的轻量级方法,这得益于 ES6 箭头函数的使用:

module.exports = (router) => {
  router.get('/', (req, res) => {
    res.render('index');
  });
};

路由可以在 URL 中包含参数:

module.exports = (router) => {
  router.get('/people/:id', (req, res) => {
    const people = { alex: { name: 'Alex' } };
    res.render('people/edit', people[req.param.id]);
  });
};

Kraken 的路由 API 是 express-enrouten (github.com/krakenjs/express-enrouten),它部分从文件所在的目录推断路由。假设你有一个这样的文件布局:

controllers
 |-user
     |-create.js
     |-list.js

然后,Kraken 将生成如/user/create 和/user/list 之类的路由。

5.4.3. REST API

Kraken 可以用来制作 REST API,但不提供针对它们的特定支持。express-enrouten 的功能与解析 JSON 的中间件结合意味着你可以使用 Kraken 来实现 REST API。

Kraken 的路由器支持 HTTP 动词 DELETE、GET、POST、PUT 等,这使得实现 REST 与 Express 类似。

5.4.4. 优点

由于 Kraken 自带生成器,从高层次来看,Kraken 项目看起来很相似。尽管 Express 项目的布局可能千差万别,但 Kraken 项目通常将文件和目录放在相同的位置。

由于 Kraken 提供了模板库(Dust)和国际化(Makara),这两个功能无缝集成。要编写具有国际化的 Dust 模板,你需要指定一个键:

<h1>{@pre type="content" key="greeting"/}</h1>

然后在 locales/language-code/view-name.properties 中添加一个.properties 文件。这些属性文件是简单的键值对,所以如果前面的例子在一个名为 public/templates/profile.dust 的视图文件中,那么.profile 文件将是 locales/US/en/profile.properties。

角色思考

Phil: “Kraken 具有文件系统布局并使用控制器进行路由的事实让我印象很深。我们团队中的一些人了解 Django 和 Ruby on Rails,因此这对他们来说将是一个容易的过渡。Kraken 的文档看起来也非常好;博客上有很多有用的内容。”

Alice: “我喜欢通过 Lusca 获得更好的应用程序安全性的想法,但 Kraken 提供了一些我并不真正需要的东西。我将尝试仅使用 Lusca。”

5.4.5. 缺点

学习 Kraken 比学习 Koa 或 Express 需要更多的努力。在 Express 中通过编程完成的某些任务,在 Kraken 中是通过 JSON 配置文件完成的,有时很难确定需要哪些 JSON 属性才能按预期的方式工作。

5.5. hapi

hapi (hapijs.com/)是一个专注于 Web API 开发的服务器框架。它有自己的 hapi 插件 API,不提供任何客户端支持或数据库模型层。它包含一个路由 API 和自己的 HTTP 服务器包装器。在 hapi 中,你通过将服务器视为主要抽象来设计 API。内置的连接和日志记录功能使 hapi 在 DevOps 方面具有良好的扩展性和管理能力。表 5.3 包含 hapi 主要功能的概述。

表 5.3. hapi 的主要功能
库类型 HTTP 服务器框架
功能 高级服务器容器抽象,安全头部
建议用途 单页 Web 应用,HTTP API
插件架构 hapi 插件
文档 hapijs.com/api
流行度 6,000 GitHub 星标
许可 BSD 3 条款

5.5.1. 设置

首先,创建一个新的 Node 项目并安装 hapi:

mkdir listing5_2
cd listing5_2
npm init –y
npm install --save hapi

然后创建一个名为 server.js 的新文件。添加以下列表中的代码。

列表 5.2. 基本 hapi 服务器
const Hapi = require('hapi');
const server = new Hapi.Server();

server.connection({
  host: 'localhost',
  port: 8000
});

server.start((err) => {
  if (err) {
    throw err;
  }
  console.log('Server running at:', server.info.uri);
});

你可以直接运行这个示例,但没有任何路由它不会做很多事情。继续阅读以了解 hapi 如何处理路由。

5.5.2. 定义路由

hapi 内置了一个用于创建路由的 API。你必须提供一个包含请求方法、URL 和一个运行回调的对象,这个回调被称为 handler。下面的列表展示了如何使用处理方法定义一个路由。

列表 5.3. hapi hello world 服务器
const Hapi = require('hapi');
const server = new Hapi.Server();

server.connection({
  host: 'localhost',
  port: 8000
});

server.route({
  method: 'GET',
  path:'/hello',
  handler: (request, reply) => {
    return reply('hello world');
  }
});

server.start((err) => {
  if (err) {
    throw err;
  }
  console.log('Server running at:', server.info.uri);
});

将以下代码添加到前面的列表中,以定义一个将响应文本 hello world 的路由和处理程序。你可以通过输入 npm start 来运行此示例。打开 http://localhost:8000/hello 来查看响应。

hapi 不包含预定义的文件夹结构或任何 MVC 功能;它完全基于服务器。在这方面,它与 Express 类似。然而,请注意一个关键的区别:request, reply 路由处理程序签名与 Express 的 req, res 不同。hapi 的请求和回复对象也与 Express 的等效对象不同:你必须调用 reply 而不是操作 Express 的 res 对象。Express 更类似于 Node 内置的 HTTP 服务器。

要超越这个简单的示例并获得更多功能,例如提供静态文件,你需要插件。

5.5.3. 插件

hapi 有自己的插件架构,大多数项目需要几个插件来提供诸如身份验证和用户输入验证等功能。大多数项目都需要一个简单的插件 inert (github.com/hapijs/inert),它添加了静态文件和目录处理程序。

要将 inert 添加到 hapi 项目中,你需要首先使用 server.register 方法注册插件。这添加了 reply.file 方法用于发送单个文件,以及内置的目录处理程序。让我们看看目录处理程序。

确保你已经基于 列表 5.2 设置了一个项目。接下来,安装 inert:

npm install --save inert

现在,插件可以被加载并注册。打开 server.js 文件并添加以下行。

列表 5.4. 使用 hapi 添加插件
const Inert = require('inert');

server.register(Inert, () => {});

server.route({
  method: 'GET',
  path: '/{param*}',
  handler: {
    directory: {
      path: '.',
      redirectToSlash: true,
      index: true
    }
  }
});

hapi 路由不仅可以接受函数,还可以接受插件的配置对象。在这个列表中,directory 对象包含了惰性设置,用于在当前路径下提供文件服务并显示该目录中的文件索引。这与 Express 中间件不同,展示了插件如何在 hapi 应用程序中扩展服务器的行为。

5.5.4. REST API

hapi 支持 HTTP 动词和 URL 参数化,允许通过标准的 hapi 路由 API 实现 REST API。以下是一个用于通用删除方法的路由片段:

server.route({
  method: 'DELETE',
  path: '/items/{id}',
  handler: (req, reply) => {
    // Delete "item" here, based on req.params.id
    reply(true);
  }
});

此外,插件使创建 RESTful API 变得更加容易。例如,hapi-sequelize-crud (www.npmjs.com/package/hapi-sequelize-crud) 可以根据 Sequelize 模型 (docs.sequelizejs.com/en/latest/) 自动生成 RESTful API。

角色思考

Phil: “我肯定会尝试 hapi-sequelize-crud,因为我们已经有使用 PostgreSQL 和 MySQL 的应用程序,所以 Sequelize 可能是一个不错的选择。但是,由于 hapi 本身不提供这类功能,我担心这个插件可能会失去支持,所以我不确定 hapi 是否能在代理场景中良好工作。”

Alice: “作为一名产品开发者,我认为 hapi 很有趣,因为它像 Express 一样简洁,但插件 API 更加正式和表达性强。”

Nadine: “我可以看到为 hapi 制作开源插件的几个机会,并且现有的插件看起来都写得很好。hapi 似乎有一个技术能力强的受众,这对我很有吸引力。”

5.5.5. 优点

hapi 的插件 API 是使用 hapi 的最大优势之一。插件可以扩展 hapi 的服务器,也可以添加各种其他行为,从数据验证到模板化。此外,由于 hapi 基于 HTTP 服务器,它适合某些类型的部署场景。如果你需要部署许多需要连接在一起或负载均衡的服务器,你可能更喜欢 hapi 基于服务器的 API 而不是 Express 或 Koa。

5.5.6. 弱点

hapi 与 Express 有类似的弱点:它很简洁,因此没有关于项目结构的指导。你永远不能确定某个插件的开发是否会停止,所以过多地依赖插件可能会在未来造成维护问题。

5.6. Sails.js

你迄今为止看到的框架都是最小化的服务器库。Sails (sailsjs.org/) 是一个模型-视图-控制器(MVC)框架,它与服务器库有根本的不同。它包含一个用于与数据库工作的对象关系映射(ORM)库,并且可以自动生成 REST API。它还具有现代功能,包括内置的 WebSocket 支持。如果你是 React 或 Angular 的粉丝,你会很高兴知道它是前端无关的:它不是一个全栈框架,所以你可以与几乎任何前端库或框架一起使用。 表 5.4 显示了 Sails 的主要功能。

表 5.4. Sails 的主要功能
库类型 MVC 框架
功能 带有 ORM 的数据库支持,REST API 生成,WebSocket
建议用途 Rails 风格的 MVC 应用程序
插件架构 Express 中间件
文档 sailsjs.org/documentation/concepts/
流行度 6,000 GitHub 星标
许可证 BSD 3 条款

角色思考

Phil: “这听起来正是我想要的——有什么陷阱吗?!”

Alice: “我原以为这不适合我,因为我们已经在 React 应用上投入了开发时间,但因为它专注于服务器,它可能适合我们的产品。”

5.6.1. 设置

Sails 自带项目生成器,所以最好将其全局安装以简化创建新项目的过程。使用 npm 安装它,然后使用sails new来创建项目:

npm install -g sails
sails new example-project

这将创建一个新的目录,其中包含一个用于基本 Sails 依赖项的 package.json。新项目包括 Sails 本身、EJS 和 Grunt。你可以运行npm start来启动服务器,或者输入sails lift。当服务器运行时,你可以通过访问 http://localhost:1337 来查看内置的入门页面。

5.6.2. 定义路由

要添加路由,在 Sails 中称为自定义路由,请打开 config/routes.js 并为导出的路由添加一个属性。这个属性是 HTTP 动词和部分 URL。例如,以下是一些有效的 Sails 路由:

module.exports.routes = {
  'get /example': { view: 'example' },
  'post /items': 'ItemController.create
};

第一个路由期望一个名为 views/example.ejs 的文件。第二个路由期望一个名为 api/controllers/ItemController 的文件,其中包含一个名为create的方法。你可以通过运行sails generate controller item create来生成这个控制器。类似的命令可以用来快速创建 RESTful API。

5.6.3. REST API

Sails 将数据库模型和控制器组合成 API,所以要快速创建 RESTful API,请使用sails generate api resource-name。要使用数据库,你首先需要安装数据库适配器。添加 MySQL 涉及到找到 Waterline MySQL 包的名称(github.com/balderdashy/waterline)并将其添加到项目中:

npm install --save waterline sails-mysql

接下来,打开 config/connections.js 并填写你 MySQL 服务器的连接详情。Sails 模型文件允许你指定数据库连接,因此你可以使用不同的模型与不同的数据库一起使用。这允许像 Redis 中的用户会话数据库和其他在关系型数据库(如 MySQL)中的更永久资源这样的情况。

Waterline 是 Sails 的数据库库,并且它有自己的文档仓库(github.com/balderdashy/waterline-docs)。除了支持多种数据库外,Waterline 还有一些有用的功能:你可以定义表和列名以支持旧版模式,并且查询 API 支持 Promise,使得查询看起来像是现代 JavaScript。

人物想法

Phil: “创建 API 的便捷性以及 Waterline 模型能够支持现有的数据库模式意味着 Sails 对我们来说听起来非常理想。我们有一些客户,我们希望他们逐步从 MySQL 迁移到 PostgreSQL,所以我们可能可以使用 Waterline 来实现这一点。我们的一些开发者和设计师已经使用过 Ruby on Rails,所以我认为他们很快就能掌握使用 Node 的现代化 ES2015 语法的 Sails。”

Alice: “这个框架提供了一些我们产品不需要的功能。我觉得 Koa 或 hapi 可能更适合。”

5.6.4. 优点

内置的项目创建和 API 生成意味着设置项目和添加典型的 REST API 非常快。这对于快速创建新项目和协作很有用,因为 Sails 项目具有相同的文件系统布局。Sails 的创建者 Mike McNeil 和我 rl Nathan 写了一本名为《Sails.js in Action》(Manning Publications,2017)的书,展示了 Sails 如何欢迎 Node 初学者。

5.6.5. 缺点

Sails 与其他服务器端 MVC 框架共享一些弱点:路由 API 意味着您必须考虑 Sails 的路由功能来设计应用程序,并且您可能会发现很难将您的模式适应 Waterline 处理事物的方式。

5.7. DerbyJS

DerbyJS 是一个支持数据同步和视图服务器端渲染的全栈框架。它依赖于 MongoDB 和 Redis。数据同步层由 ShareJS 提供,并支持自动冲突解决。表 5.5 总结了 DerbyJS 的主要特性。

表 5.5. DerbyJS 特性
库类型 全栈框架
特性 使用 ORM(Racer)的数据库支持,同构
推荐用途 具有服务器端支持的单一页面 Web 应用程序
插件架构 DerbyJS 插件
文档 derbyjs.com/docs/derby-0.6
流行度 4,000 GitHub stars
许可证 MIT

5.7.1. 设置

如果您没有 MongoDB 或 Redis,您需要安装这两个来运行 DerbyJS 示例。DerbyJS 文档解释了如何在 Mac OS、Linux 和 Windows 上完成此操作(derbyjs.com/started#environment)。

要快速创建一个新的 DerbyJS 项目,安装 derby 和 derby-starter。derby-starter 包用于引导 Derby 应用程序:

mkdir example-derby-app
cd example-derby-app
npm init -f
npm install --save derby derby-starter derby-debug

Derby 应用程序被分割成几个较小的应用程序,因此创建一个新的应用程序目录,包含三个文件:index.js、server.js 和 index.html。以下列表展示了一个简单的 Derby 应用程序,它渲染了一个模板。

列表 5.5. Derby 应用程序的 index.js 文件
const app = module.exports = require('derby')
  .createApp('hello', __filename);
app.loadViews(__dirname);

app.get('/', (page, model) => {
  const message = model.at('hello.message');
  message.subscribe(err => {
    if (err) return next(err);
    message.createNull('');
    page.render();
  });
});

服务器文件只需要加载 derby-starter 模块,如下面的代码片段所示。将其保存为 app/server.js:

require('derby-starter').run(__dirname, { port: 8005 });

app/index.html 文件渲染一个输入字段和用户输入的消息:

<Body:>
  Holler: <input value="{{hello.message}}">
  <h2>{{hello.message}}</h2>

您应该能够通过在 example-derby-app 目录中输入node derby/server.js来运行应用程序。一旦它开始运行,编辑 app/index.html 文件将导致应用程序重新启动;在编辑代码和模板时,您将自动获得实时更新。

5.7.2. 定义路由

DerbyJS 使用 derby-router 进行路由。因为 DerbyJS 由 Express 驱动,所以服务器端路由的 API 相似,并且在浏览器中也使用相同的路由模块。当在 DerbyJS 应用程序中点击链接时,它将尝试在客户端渲染响应。

DerbyJS 是一个全栈框架,所以添加路由的方式与其他你在本章中查看的库略有不同。添加基本路由的最地道方式是通过添加视图。打开 apps/app/index.js 并使用 app.get 添加路由:

app.get('hello', '/hello');

接下来,打开 apps/app/views/hello.pug 并添加一个简单的 Pug 模板:

index:
  h2 Hello
  p Hello world

现在打开 apps/app/views/index.pug 并导入模板:

import:(src="./hello")

如果你已经运行了 npm start,项目应该会不断更新,所以现在打开 http://localhost:3000/hello 将会显示新的视图。

读取 index: 的行是视图的 命名空间。在 DerbyJS 中,视图名称有冒号分隔的命名空间,所以你创建了 hello:index。背后的想法是将视图封装起来,以便在大型项目中不会发生冲突。

5.7.3. REST API

在 DerbyJS 项目中,你需要通过添加路由和路由处理程序来创建 RESTful API。你的 DerbyJS 项目将有一个使用 Express 创建服务器的 server.js 文件。如果你打开 server/routes.js,你会找到一个使用标准 Express 路由 API 定义的示例路由。

在服务器路由文件中,你可以使用 app.use 来挂载另一个 Express 应用程序,因此你可以将 REST API 模拟为一个完全独立的 Express 应用程序,该应用程序由主 DerbyJS 应用程序挂载。

5.7.4. 优势

DerbyJS 有数据库模型 API 和数据同步 API。你可以用它来构建单页 Web 应用程序和现代实时应用程序。因为它内置了 WebSocket 和同步功能,所以你不必担心使用哪个 WebSocket 库,或者如何在客户端和服务器之间同步数据。

角色思考

Phil:“我们有一个客户询问基于实时数据构建数据可视化项目的事情,所以我认为 DerbyJS 对此可能很有用。但是学习曲线似乎很陡峭,所以我不确定我能否说服我们的开发者使用它。”

Alice:“作为一个产品开发者,我发现很难看到如何将我们产品的需求与 DerbyJS 的架构相匹配,所以我认为它不适合我的项目。”

5.7.5. 劣势

让已经熟悉服务器端或客户端库的人使用 DerbyJS 是一件困难的事情。例如,喜欢 React 的客户端开发者通常不想使用 DerbyJS。喜欢制作 REST API 或 MVC 项目并且对 WebSocket 感到舒适的服务器端开发者也未能被激励去学习 DerbyJS。

5.8. Flatiron.js

Flatiron 是一个包含 URL 路由、数据管理、中间件、插件和日志功能的 Web 框架。与大多数 Web 框架不同,Flatiron 的模块被设计为解耦的,因此你不必使用它们全部。你甚至可以在自己的项目中使用一个或多个——例如,如果你喜欢日志模块,你可以在 Express 项目中将其添加进去。与许多 Node 框架不同,Flatiron 的 URL 路由和中间件层不是使用 Express 或 Connect 编写的,尽管中间件与 Connect 向后兼容。表 5.6 总结了 Flatiron 的功能。

表 5.6. Flatiron 的功能
库类型 模块化 MVC 框架
功能 数据库管理层(资源丰富),解耦的可重用模块
建议用途 轻量级 MVC 应用,在其他框架中使用 Flatiron 模块
插件架构 Broadway 插件 API
文档 github.com/flatiron
流行度 1,500 GitHub stars
许可证 MIT

5.8.1. 设置

安装 Flatiron 需要全局安装命令行工具以创建新的 Flatiron 项目:

npm install -g flatiron
flatiron create example-flatiron-app

运行这些命令后,你将找到一个包含带有必要依赖项的 package.json 文件的新目录。运行npm install来安装依赖项,然后运行npm start来启动应用。

主 app.js 文件看起来很像一个典型的 Express 应用:

const flatiron = require('flatiron');
const path = require('path');
const app = flatiron.app;

app.config.file({ file: path.join(__dirname, 'config', 'config.json') });

app.use(flatiron.plugins.http);

app.router.get('/', () => {
  this.res.json({ 'hello': 'world' })
});

app.start(3000);

注意,然而,路由器与 Express 和 Koa 都不同。响应是通过使用this.res返回的,而不是响应回调函数的参数。让我们更详细地看看 Flatiron 的路由。

5.8.2. 定义路由

Flatiron 的路由库称为 Director。尽管它可以用于服务器路由,但它也支持浏览器中的路由,因此可以用于制作单页应用。Director 将 Express 风格的 HTTP 动词路由称为 ad hoc:

router.get('/example', example);
router.post('/example', examplePost);

路由可以有参数,参数可以用正则表达式定义:

router.param('id', /([\\w\\-]+)/);
router.on('/pages/:id', pageId => {});

要生成响应,使用res.writeHead发送头部信息,并使用res.end发送响应体:

router.get('/',  () => {
  this.res.writeHead(200, { 'content-type': 'text/plain' });
  this.res.end('Hello, World');
});

路由 API 也可以作为一个类使用,带有路由表对象。要使用它,实例化一个新的路由器,然后在 HTTP 请求到达时使用 dispatch 方法:

const http = require('http');
const director = require('director');
const router = new director.http.Router({
  '/example': {
    get: () => {
      this.res.writeHead(200, { 'Content-Type': 'text/plain' })
      this.res.end('hello world');
    }
  }
});
const server = http.createServer((req, res) =>
  router.dispatch(req, res);
});

使用路由 API 作为类也意味着你可以挂钩到流式 API。这使得以快速和简单的方式处理大型请求成为可能,这对于诸如解析上传数据并提前退出等操作是有益的:

const director = require('director');
const router = new director.http.Router();

router.get('/', { stream: true }, () => {
  this.req.on('data', (chunk) => {
    console.log(chunk);
  });
});

Director 具有作用域路由 API,这对于创建 REST API 很有用。

5.8.3. REST API

可以使用标准的 Express HTTP 动词风格方法或 Director 的作用域路由功能创建 REST API。这允许根据 URL 片段和 URL 参数将路由分组在一起:

 router.path(/\/users\/(\w+)/, () => {
  this.get((id) => {});
  this.delete((id) => {});
  this.put((id) => {});
});

Flatiron 还提供了一个高级 REST 包装器,称为 Resourceful (github.com/flatiron/resourceful),它支持 CouchDB、MongoDB、Socket.IO 和数据验证。

5.8.4. 优势

框架要获得影响力是很困难的,这就是为什么 Flatiron 的解耦设计是一个主要优势。你可以使用其模块而不必使用整个框架。例如,Winston 日志模块 (github.com/winstonjs/winston) 被许多不使用 Flatiron 其他部分的项目所使用。这意味着 Flatiron 的某些部分收到了良好的开源贡献。

The Director URL-routing API is isomorphic, so you can use it as a solution for both client- and server-side development. Director’s API differs from the Express-style routing APIs as well: Director has a simplified streaming API, and the routing object emits events before and after routes are executed.

与大多数 Node 网络框架不同,Flatiron 有一个插件管理器。因此,使用社区支持的插件扩展 Flatiron 项目更容易。

人物观点

Nadine: “我喜欢 Flatiron 的模块化设计,插件管理器也很棒。我已经能想到一些我想制作的插件。”

Alice: “我不喜欢 Flatiron 所有模块的声音,所以我想要尝试使用不同的 ORM 和模板库。”

5.8.5. 劣势

相比于其他一些框架,Flatiron 对于大型 MVC 风格的项目来说并不那么容易使用。例如,Sails 更容易设置。如果你正在创建几个中等大小的传统 Web 应用,Flatiron 可能会工作得很好。能够配置 Flatiron 是一个额外的优势,但请确保首先将其与其他选项进行比较评估。

LoopBack 是一个强大的竞争对手,它是本章中最后介绍的一个框架。

5.9. LoopBack

LoopBack 是由 StrongLoop 创建的,该公司提供多种支持 Node 网络应用开发的商业服务。它被定位为一个 API 框架,但它具有使它与数据库和 MVC 应用很好地工作的功能。它甚至提供了一个用于探索和管理 REST API 的网络界面。如果你在寻找可以帮助为移动和桌面客户端创建 Web API 的东西,LoopBack 的功能是理想的。见 表 5.7 了解 LoopBack 的详细信息。

表 5.7. LoopBack 的功能
库类型 API 框架
功能 ORM、API 用户界面、WebSocket、客户端 SDK(包括 iOS)
推荐用途 支持多个客户端(移动、桌面、Web)的 API
插件架构 Express 中间件
文档 loopback.io/doc/
流行度 6,500 GitHub 星标
许可证 双重许可:MIT 和 StrongLoop 订阅协议

LoopBack 是开源的,自从 StrongLoop 被 IBM 收购以来,该框架现在得到了主要商业认可。这使得它在 Node 社区中成为一个独特的提供物。它附带 Yeoman 生成器,可以快速设置应用程序骨架。在下一节中,您将看到如何创建新的 LoopBack 应用程序。

5.9.1. 设置

要设置新的 LoopBack 项目,您需要使用 StrongLoop 命令行工具(www.npmjs.com/package/strongloop)。全局安装 strongloop 包使得通过slc命令可用命令行工具。此包包括进程管理功能,但我们感兴趣的是 LoopBack 项目生成器:

npm install -g strongloop
slc loopback

StrongLoop 命令行工具会引导您完成设置新项目所需的步骤。输入项目名称,然后选择 api-server 应用程序骨架。当生成器完成安装项目的依赖项后,它将显示一些关于如何使用新项目的实用提示。图 5.2 显示了它应该看起来是什么样子。

图 5.2. LoopBack 的项目生成器

要运行项目,请输入 node .,要创建模型,请使用 slc loopback:model。在设置新的 LoopBack 项目时,您将经常使用 slc 命令。

当项目运行时,您应该能够访问 API 探索器,网址为 http://0.0.0.0:3000/explorer/。点击 User 以展开 User 端点。您应该看到一个包含标准 RESTful 路由(如 PUT /Users 和 DELETE /Users/{id})在内的可用 API 方法的大列表。图 5.3 显示了 API 探索器。

图 5.3. 强 Loop API 探索器显示 User 路由

5.9.2. 定义路由

在 LoopBack 中,您可以在 Express 级别添加路由。添加一个名为 server/boot/routes.js 的新文件,并通过访问 LoopBack Router 实例来添加路由:

module.exports = (app) => {
  const router = app.loopback.Router();
  router.get('/hello', (req, res) => {
    res.send('Hello, world');
  });
  app.use(router);
};

访问 http://localhost:3000/hello 现在将响应Hello, world。然而,以这种方式添加路由在 LoopBack 项目中并不典型。这可能对于某些不寻常的 API 端点来说是必需的,但通常,当生成模型时,路由会自动添加。

5.9.3. REST API

在 LoopBack 项目中创建 REST API 的最简单方法是使用模型生成器。这是slc命令功能的一部分。例如,如果您想添加一个名为product的新模型,请运行slc loopback:model

slc loopback:model product

slc命令会引导您完成创建模型的步骤,允许您选择模型是否为服务器端专用,并设置一些属性和验证器。添加模型后,查看相应的 JSON 文件——它应该在 common/models/product.json 中。这个 JSON 文件是一种轻量级的方式来定义模型的行为,包括您在上一步中指定的所有属性。

如果您想添加更多属性,请输入slc loopback:property。您可以在任何时候向模型添加属性。

人物想法

Phil:“我们的团队非常喜欢 LoopBack,主要是因为它能够快速添加 RESTful 资源并通过 API 浏览器浏览它们。但我喜欢它,因为它看起来足够灵活,可以支持我们的遗留 MVC Web 应用。我们可以将其连接到旧数据库,并将这些项目迁移到 Node。”

Alice:“这是唯一真正针对 iOS、Android 以及丰富 Web 客户端的框架。LoopBack 为 iOS 和 Android 提供了客户端库,对我们这些依赖移动应用的产品开发者来说这是一个很大的优势。”

5.9.4. 优点

即使从这简短的介绍中,也应该清楚 LoopBack 的一个优点是它消除了编写样板代码的需要。命令行工具生成您需要的几乎所有轻量级 RESTful Web API,甚至包括数据库模型和验证。同时,LoopBack 对前端代码的限制也不多。它还使您能够考虑哪些模型应该对浏览器可访问,哪些仅限于服务器端。一些框架在这方面做得不对,将所有内容都推送到浏览器。

如果你拥有需要与你的 Web API 通信的移动应用,请查看 LoopBack 的客户端 SDKs (loopback.io/doc/en/lb2/Client-SDKs.html)。LoopBack 支持 iOS 和 Android 的 API 集成和推送消息。

5.9.5. 缺点

LoopBack 基于 JSON 的模式 API 与大多数 JavaScript 数据库 API 不同。可能需要一段时间才能学会如何将其映射到现有项目的数据库模式。而且,由于 HTTP 层基于 Express,它在一定程度上受到 Express 支持的限制。尽管 Express 是一个可靠的 HTTP 服务器库,但 Node 现在有更多现代 API 的库可用。LoopBack 没有特定的插件 API。您可以使用 Express 中间件,但这不如 Flatiron 或 hapi 的插件 API 方便。

这结束了本章中涵盖的框架。在进入下一章之前,让我们比较这些框架,以帮助您决定哪个框架适合您的下一个项目。

5.10. 比较

如果你一直关注本章中的人物想法,你可能已经决定使用哪个框架。如果没有,本章的其余部分将比较每个框架的优点。而且,如果你仍然感到困惑,图 5.4 将通过回答一些问题来帮助你选择正确的框架。

图 5.4. 选择 Node 框架

如果你浏览一下 Node 流行的服务器端框架,它们听起来都很相似。它们提供轻量级的 HTTP API,并且使用服务器模型而不是 PHP 的页面模型。但它们设计上的差异对使用它们的项目的意义很大,因此为了比较这些框架,我们将从 HTTP 级别开始。

5.10.1. HTTP 服务器和路由

大多数 Node 框架都是基于 Connect 或 Express 构建的。在本章中,你已经看到了三个完全不基于 Express 的框架,它们有自己的 HTTP API 解决方案:Koa、hapi 和 Flatiron。

Koa 是由与 Express 相同的作者创建的,但通过使用更现代的 JavaScript 特性提供了新的方法。如果你喜欢 Express 但想使用 ES2015 生成器语法,Koa 可能适合你。

hapi 的服务器和路由 API 高度模块化,感觉与 Express 的不同。如果你觉得 Express 的语法不自然,你应该尝试一下 hapi。hapi 使得推理 HTTP 服务器变得更容易,所以如果你需要做诸如连接服务器或集群服务器的事情,你可能更喜欢 hapi 而不是 Express 的后代。

Flatiron 的路由器与 Express 向后兼容,但具有额外功能。路由器会发出事件并使用路由表。这与 Express 风格的中间件组件堆栈不同。你可以向 Flatiron 的路由器传递一个对象字面量。路由器也可以在浏览器中工作,所以如果你有试图处理现代客户端开发的服务器端开发者,他们可能会觉得 Flatiron 比使用 React Router 等工具更自在。

5.11. 编写模块化代码

并非所有在本章中讨论的框架都直接支持插件,但它们都以某种方式可扩展。基于 Express 的框架可以使用 Connect 中间件,但 hapi 和 Flatiron 有自己的插件 API。定义良好的插件 API 很有用,因为它们使得框架的新用户更容易扩展它。

如果你使用的是像 Sails.js 或 LoopBack 这样的较大 MVC 框架,插件 API 使得设置新项目变得容易得多。LoopBack 通过提供高度功能的项目管理工具部分避免了需要插件 API。如果你查看 StrongLoop 的 npm 账户(www.npmjs.com/~strongloop),你会看到许多与 loopback 相关的项目,它们增加了对 Angular 和几个数据库的支持。

5.12. 角色选择

本章中的人物角色现在已有足够的背景知识,可以为他们下一个项目做出正确的选择:

Phil: “最终我决定选择 LoopBack。这是一个艰难的选择,因为 Sails 和 Kraken 都拥有我们团队喜欢的出色功能,但我们感觉 LoopBack 有更强的长期支持,并且可以减少在服务器端开发上的大量努力。”

Nadine: “作为一个开源开发者,我选择了 Flatiron。它将适应我正在工作的各种项目。例如,一些项目将仅使用 Winston 和 Director,但其他项目将使用整个堆栈。”

Alice: “我已经为我的下一个项目选择了 hapi。它很简洁,因此我可以根据项目的独特需求进行适配。大部分代码将是 Node,并且不依赖于任何特定的框架,所以我感觉这与 hapi 很匹配。”

5.13. 总结

  • Koa 轻量级、最小化,并使用 ES2015 生成器语法进行中间件。它适合托管依赖外部 Web API 的单页 Web 应用。

  • hapi 专注于 HTTP 服务器和路由。它适用于由许多小型服务组成的轻量级后端。

  • Flatiron 是一组解耦的模块,可以用作 Web MVC 框架或更轻量级的 Express 库。Flatiron 与 Connect 中间件兼容。

  • Kraken 基于 Express 构建,并增加了安全特性。它可以用于 MVC。

  • Sails.js 是一个受 Rails/Django 启发的 MVC 框架。它有一个 ORM 和一个模板系统。

  • DerbyJS 是一个适用于实时应用程序的同构框架。

  • LoopBack 消除了编写用于快速生成带有数据库支持和 API 浏览器的 REST API 的样板代码的需求。

第六章. 深入理解 Connect 和 Express

本章涵盖

  • 理解 Connect 和 Express 的用途

  • 使用和创建中间件

  • 创建和配置 Express 应用程序

  • 使用关键的 Express 技术进行错误处理、渲染视图和表单

  • 使用 Express 架构技术进行路由、REST API 和身份验证

在第三章中,您看到了如何构建一个简单的 Express 应用程序。本章提供了对 Express 和 Connect 的更深入研究。这两个流行的 Node 模块被许多 Web 开发者使用。本章向您展示了如何使用最常用的模式构建 Web 应用程序和 REST API。

Connect 和 Express

下文中讨论的概念可以直接应用于高级框架 Express,因为它通过添加额外的更高级的糖来扩展和构建在 Connect 之上。阅读本节后,您将深刻理解 Connect 中间件的工作原理以及如何组合组件来创建一个应用程序。其他 Node 网络框架以类似的方式工作,因此学习 Connect 将在学习新框架时给您带来先机。

首先,让我们看看如何创建一个基本的 Connect 应用程序。在本章的后面部分,您将看到如何使用流行的 Express 技术构建一个更复杂的 Express 应用程序。

6.1. Connect

在本节中,您将了解Connect。您将看到如何使用其中间件来构建简单的 Web 应用程序,以及中间件排序的重要性。这将帮助您在以后构建更模块化的 Express 应用程序。

6.1.1. 设置 Connect 应用程序

Express 是用 Connect 构建的,但您知道您可以使用 Connect 单独创建一个功能齐全的 Web 应用程序吗?您可以通过以下命令从 npm 注册表中下载和安装 Connect:

$ npm install connect@3.4.0

这是一个最小化 Connect 应用程序的例子:

const app = require('connect')();
app.use((req, res, next) => {
  res.end('Hello, world!');
});
app.listen(3000);

这个简单的应用程序(在示例代码的 ch06-connect-and-express/hello-world 下找到)将响应Hello, world!传递给 app.use 的函数是一个中间件组件,通过发送Hello, world!文本作为响应来结束请求。中间件组件是所有 Connect 和 Express 应用程序的基础。让我们更详细地看看它们。

6.1.2. 理解 Connect 中间件的工作原理

在 Connect 中,一个 中间件组件 是一个 JavaScript 函数,按照惯例接受三个参数:一个请求对象、一个响应对象,以及一个通常命名为 next 的参数,它是一个回调函数,表示该组件已完成,可以执行后续的中间件组件。

在你的中间件运行之前,Connect 使用一个调度器,它接收请求并将它们传递给第一个添加到应用程序中的中间件组件。图 6.1 展示了一个典型的 Connect 应用程序,它由调度器以及包括日志记录器、体解析器、静态文件服务器和自定义中间件的中间件排列组成。

图 6.1. 两个 HTTP 请求通过 Connect 服务器的生命周期

如你所见,中间件 API 的设计意味着更复杂的行为可以由更小的构建块组成。在下一节中,你将看到如何通过组合组件来实现这一点。

6.1.3. 组合中间件

Connect 提供了一个名为 use 的方法来组合中间件组件。让我们定义两个中间件函数并将它们都添加到应用程序中。一个是之前简单的 Hello World 函数,另一个是日志记录器。

列表 6.1. 使用多个 Connect 中间件组件

这个中间件有两个签名:一个带有 next,另一个不带。这是因为该组件完成 HTTP 响应,并且永远不需要将控制权交还给调度器。

use() 函数返回一个 Connect 应用程序的实例以支持方法链式调用,如前所述。请注意,链式调用 .use() 调用不是必需的,如下面的代码片段所示:

const app = connect();
app.use(logger);
app.use(hello);
app.listen(3000);

现在你已经有一个简单的 Hello World 应用程序运行起来,我们将探讨为什么中间件 .use() 调用的顺序很重要,以及如何战略性地使用这种顺序来改变应用程序的工作方式。

6.1.4. 中间件排序

应用程序中中间件的顺序可以极大地影响其行为。可以通过省略 next() 来停止执行,并且可以将中间件组合起来实现如身份验证等功能。

当中间件组件没有调用 next 时会发生什么?考虑之前的 Hello World 示例,其中 logger 中间件组件首先使用,然后是 hello 组件。在那个例子中,Connect 将日志记录到 stdout 并响应 HTTP 请求。但考虑如果顺序被切换,如下所示会发生什么。

列表 6.2. 错误:hello 中间件组件在 logger 组件之前

在这个例子中,hello中间件组件首先被调用,并按预期响应 HTTP 请求。但是logger从未被调用,因为hello从未调用next(),所以控制权从未返回给调度器以调用下一个中间件组件。这里的教训是,当一个组件没有调用next()时,命令链中的任何剩余中间件都不会被调用。

图 6.2显示了此示例将跳过日志的情况以及如何纠正它。

图 6.2. 中间件的顺序很重要。

图片

如你所见,将hello放在logger之前是相当无用的,但使用得当,顺序可以为你带来好处。

6.1.5. 创建可配置的中间件

你已经学了一些中间件的基础知识;现在我们将深入探讨如何创建更通用和可重用的中间件。

中间件通常遵循一个简单的约定,以便为开发者提供配置能力:使用一个返回另一个函数(闭包)的函数。这种类型的可配置中间件的基本结构看起来像这样:

图片

这种类型的中间件的使用方法如下:

app.use(setup({ some: 'options' }));

注意,setup函数在app.use行中被调用,而在之前的示例中,你只是传递了一个函数的引用。

在本节中,你将应用这种技术来构建三个可重用和可配置的中间件组件:

  • 一个具有可配置打印格式的日志组件

  • 一个基于请求 URL 调用函数的路由组件

  • 一个将 URL 别名转换为 ID 的 URL 重写组件

你将从扩展你的日志组件开始,使其更具可配置性。本章前面创建的logger中间件组件不是可配置的。它在调用时硬编码为打印出请求的req.methodreq.url。但如果你想在未来的某个时刻改变日志显示的内容呢?

在实践中,使用可配置的中间件就像使用你迄今为止创建的任何中间件一样,只是你可以向中间件组件传递额外的参数来改变其行为。在你的应用程序中使用可配置组件可能看起来像以下示例,其中logger可以接受一个字符串,描述它应该打印的格式:

const app = connect()
  .use(logger(':method :url'))
  .use(hello);

要实现可配置的logger组件,你首先需要定义一个setup函数,它接受一个字符串参数(在这个例子中,你将命名为format)。当setup被调用时,返回一个函数,这是 Connect 将使用的中间件组件。返回的组件保留了访问format变量的权限,即使在setup函数返回之后,因为它是在同一个 JavaScript 闭包中定义的。然后loggerformat字符串中的占位符替换为req对象上关联的请求属性,记录到标准输出,并调用next(),如下面的列表所示。

列表 6.3. Connect 的可配置logger中间件组件

因为你已经将这个logger中间件组件创建为可配置的中间件,所以你可以在单个应用程序中多次使用.use()来配置 logger,或者在不同的应用程序中重用这个 logger 代码。这个简单的可配置中间件概念在整个 Connect 社区中被使用,并且用于所有核心 Connect 中间件以保持一致性。

要在列表 6.3 中使用 logger 中间件,你需要传递一个字符串,该字符串包含请求对象上的一些属性。例如,.use(setup(':method :url'))会打印出每个请求的 HTTP 方法(GETPOST等)和 URL。

在继续到 Express 之前,让我们看看 Connect 是如何支持错误处理的。

6.1.6. 使用错误处理中间件

所有应用程序都会出现错误,无论是在系统级别还是用户级别,为错误情况做好准备——即使是你没有预料到的——也是一件明智的事情。Connect 实现了一个遵循与常规中间件相同规则的错误处理变体中间件,它接受一个错误对象以及请求和响应对象。

Connect 的错误处理故意保持最小化,允许开发者指定错误处理的方式。例如,你可以只通过中间件传递系统错误和应用程序错误(例如,foo 未定义)或用户错误(密码无效)或两者的组合。Connect 让你选择最适合你应用程序的选项。

在本节中,你将使用这两种类型,并学习错误处理中间件的工作方式。你还将学习一些有用的模式,这些模式可以在我们查看以下内容时应用:

  • 使用 Connect 的默认错误处理器

  • 自行处理应用程序错误

  • 使用多个错误处理中间件组件

让我们直接看看 Connect 在没有任何配置的情况下是如何处理错误的。

使用 Connect 的默认错误处理器

考虑以下中间件组件,它将抛出一个ReferenceError错误,因为应用程序没有定义foo()函数:

const connect = require('connect')
connect()
  .use((req, res) => {
    foo();
    res.setHeader('Content-Type', 'text/plain');
    res.end('hello world');
  })
.listen(3000)

默认情况下,Connect 将以 500 状态码响应,包含文本内部服务器错误的响应体以及有关错误本身更多的信息。这很好,但在任何类型的实际应用程序中,你可能希望对那些错误进行更专业的处理,例如将它们发送到日志守护进程。

自行处理应用程序错误

Connect 还提供了一种方法,让您可以使用错误处理中间件自行处理应用程序错误。例如,在开发过程中,您可能希望向客户端返回错误的 JSON 表示形式,以便快速轻松地报告,而在生产环境中,您可能希望返回一个简单的 服务器错误,以免向潜在的攻击者暴露敏感的内部信息(如堆栈跟踪、文件名和行号)。

错误处理中间件函数必须定义以接受四个参数——errreqresnext——如 列表 6.4 所示,而常规中间件则接受 reqresnext 参数。以下列表显示了一个示例错误中间件。要查看带有服务器的完整示例,请参阅本书源代码中的 ch06-connect-and-express/listing6_4。

列表 6.4. Connect 中的错误处理中间件

使用 NODE_ENV 设置应用程序的模式

Connect 的一个常见约定是使用 NODE_ENV 环境变量 (process.env.NODE_ENV) 在服务器环境之间切换行为,例如生产环境和开发环境。

当 Connect 遇到错误时,它将切换到仅调用错误处理中间件,正如您可以在 图 6.3 中看到的那样。

图 6.3. 在 Connect 服务器中引发错误的 HTTP 请求的生命周期

假设您有一个允许人们登录博客管理区的应用程序。如果用户路由的中间件组件引发错误,则博客和 admin 中间件组件都将被跳过,因为它们不作为错误处理中间件——它们只定义了三个参数。然后 Connect 将看到 errorHandler 接受错误参数,并将调用它。中间件组件可能看起来像这样:

connect()
  .use(router(require('./routes/user')))
  .use(router(require('./routes/blog'))) // Skipped
  .use(router(require('./routes/admin'))) // Skipped
  .use(errorHandler);

基于中间件执行进行短路的功能是组织 Express 应用程序的基本概念。现在您已经了解了 Connect 的基础知识,是时候更详细地了解 Express 了。

6.2. 表达

Express 是一个流行的 Web 框架,最初建立在 Connect 之上,但仍然与 Connect 中间件兼容。尽管 Express 提供了基本功能,如静态文件服务、URL 路由和应用配置,但它仍然非常简洁。它提供了足够的结构,以便您可以在不限制开发实践的情况下组合可重用的块。

在接下来的几节中,您将通过使用 Express 骨干应用程序生成器来实现一个新的 Express 应用程序。这个过程比第三章中的简要概述更详细,因此到本章结束时,您应该对 Express 有足够的了解,可以构建自己的 Express Web 应用程序和 RESTful API。随着本章的继续,您将不断向骨架添加功能,最终生成一个完整的应用程序。

6.2.1. 生成应用程序骨架

Express 不会强迫开发者使用特定的应用程序结构。你可以将路由放在你想要的任何文件中,将公共资产放在你想要的任何目录中,等等。一个最小的 Express 应用程序可以小到以下列表所示,它实现了一个完全功能的 HTTP 服务器。

列表 6.5。一个最小的 Express 应用程序

图片

express-generator 包中提供的express(1)命令行工具(www.npmjs.com/package/express-generator)可以为你设置应用程序骨架。如果你是 Express 的新手,使用生成的应用程序是一个很好的开始方式,因为它会设置一个包含模板、公共资产、配置等的应用程序。

express(1)生成的默认应用程序骨架仅包含几个目录和文件,如图 6.4 所示。这种结构旨在让开发者能在几秒钟内开始使用 Express,但应用程序的结构完全由你和你的团队来创建。

图 6.4。使用 EJS 模板的默认应用程序骨架结构

图片

本章的示例使用嵌入式 JavaScript(EJS)模板,其结构与 HTML 相似。EJS 类似于 PHP、JSP(用于 Java)和 ERB(用于 Ruby),因为服务器端 JavaScript 嵌入在 HTML 文档中并在发送到客户端之前执行。你将在第七章中更详细地了解 EJS。

在本节中,你将执行以下操作:

  • 使用 npm 全局安装 Express

  • 生成应用程序

  • 探索应用程序并安装依赖项

让我们开始吧。

安装 Express 可执行文件

首先,使用 npm 全局安装 express-generator:

$ npm install -g express-generator

接下来,你可以使用--help标志来查看可用的选项,如图 6.5 所示。

图 6.5。Express 帮助

图片

其中一些选项会为你生成应用程序的小部分。例如,你可以指定一个模板引擎来为所选模板引擎生成一个占位符模板文件。同样,如果你使用--css选项指定了一个 CSS 预处理器,将为它生成一个占位符模板文件。

可执行文件安装完成后,让我们看看如何生成将成为照片应用程序的内容。

生成应用程序

对于这个应用程序,你使用-e(或--ejs)标志来使用 EJS 模板引擎。执行express -e shoutbox。如果你想复制我们 GitHub 仓库中的代码示例,使用express -e listing6_6

一个完全功能的应用程序在 shoutbox 目录中创建。它包含一个 package.json 文件来描述项目和依赖项,应用程序文件本身,公共文件目录,以及路由处理程序的目录。

探索应用程序

让我们更仔细地看看生成了什么。在您的编辑器中打开 package.json 文件,查看应用程序的依赖项,如图 6.6 所示。Express 无法猜测你想要的依赖项版本,因此提供模块的主版本、次版本和补丁级别是良好的实践,这样你就不太可能引入任何意外错误。例如,"express": "~4.13.1" 是明确的,并且将在每次安装时提供相同的代码。

图 6.6. 生成的 package.json 内容

图片

现在看看由 express(1) 生成的应用程序文件,如下所示。目前,你可以保持这个文件不变。你应该熟悉本章早期 Connect 部分中的这些中间件组件,但看看默认中间件配置是如何设置的也是值得的。

列表 6.6. 生成的 Express 应用程序骨架

图片

图片

你有 package.json 和 app.js 文件,但应用程序还不能运行,因为依赖项尚未安装。每次你从 express(1) 生成 package.json 文件时,都需要安装依赖项。执行 npm install 来完成此操作,然后执行 npm start 来运行应用程序。

通过在浏览器中访问 http://localhost:3000 来检查应用程序。默认应用程序看起来像 图 6.7 中的那样。

图 6.7. 默认 Express 应用程序

图片

现在你已经看到了生成的骨架,你可以开始构建一个真实的 Express 应用程序。该应用程序将是一个允许人们发布消息的 shoutbox。在构建此类应用程序时,大多数经验丰富的 Express 开发者会先规划他们的 API,因此需要所需的路线和资源。

规划 shoutbox 应用程序

这是 shoutbox 应用程序的要求:

  1. 它应该允许用户注册账户、登录和登出。

  2. 用户应该能够发布消息(条目)。

  3. 网站访客应该能够分页浏览条目。

  4. 应该有一个简单的 REST API 来支持身份验证。

你需要存储数据并处理身份验证。你还需要验证用户输入。必要的路由看起来像这样:

  • API 路由

  • GET /api/entries: 获取条目列表

  • GET /api/entries/page: 获取单个页面的条目

  • POST /api/entry: 创建新的 shoutbox 条目

  • Web UI 路由

  • GET /post: 新条目的表单

  • POST /post: 发布新条目

  • GET /register: 显示注册表单

  • POST /register: 创建新账户

  • GET /login: 显示登录表单

  • POST /login: 登录

  • GET /logout: 登出

这种布局与大多数 Web 应用程序类似。希望你能将本章中的示例作为你未来应用程序的模板。

在前面的列表中,你可能注意到了对 app.set 的调用:

app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');

这就是 Express 应用程序的配置方式。下一节将更详细地解释 Express 的配置。

6.2.2. 配置 Express 和你的应用程序

你的应用程序需求将取决于其运行的环境。例如,当你的产品处于开发阶段时,你可能希望有详细的日志记录,而当它处于生产阶段时,你可能希望有一组更精简的日志和 gzip 压缩。除了配置特定于环境的功能外,你可能还想定义一些应用程序级别的设置,以便 Express 知道你正在使用哪个模板引擎以及它可以在哪里找到模板。Express 还允许你定义自定义配置键/值对。

设置环境变量

要在 UNIX 系统中设置环境变量,你可以使用以下命令:

$ NODE_ENV=production node app

在 Windows 系统中,你可以使用以下代码:

$ set NODE_ENV=production
$ node app

这些环境变量将在你的应用程序的process.env对象中可用。

Express 有一个基于环境的最小配置系统,由几个方法组成,这些方法都由NODE_ENV环境变量驱动:

  • app.set()

  • app.get()

  • app.enable()

  • app.disable()

  • app.enabled()

  • app.disabled()

在本节中,你将了解如何使用配置系统来自定义 Express 的行为方式,以及如何在开发过程中使用此系统满足自己的目的。

让我们更详细地看看基于环境的配置意味着什么。尽管NODE_ENV环境变量起源于 Express,但许多其他 Node 框架都采用了它作为通知 Node 应用程序其操作环境的手段,默认为开发环境。

app.configure()方法接受表示环境的可选字符串和一个函数。当环境与传递的字符串匹配时,回调函数立即被调用;当只提供一个函数时,它将在所有环境中被调用。这些环境名称完全是任意的。例如,你可能会有developmentstagetestproduction,或者简称为prod

if (app.get('env') === 'development') {
  app.use(express.errorHandler());
}

Express 内部使用配置系统,允许你自定义 Express 的行为方式,但它也适用于你自己的使用。

Express 还提供了app.set()app.get()的布尔变体。例如,app.enable(setting)等同于app.set(setting, true),而app.enabled (setting)可以用来检查值是否已启用。app.disable (setting)app.disabled(setting)方法与真实变体互补。

在使用 Express 开发 API 时,一个有用的设置是json spaces选项。如果你将其添加到你的 app.js 文件中,你的 JSON 将以更可读的格式打印出来:

app.set('json spaces', 2);

现在你已经看到了如何利用配置系统来满足自己的需求,让我们看看如何在 Express 中渲染视图。

6.2.3. 渲染视图

在本章的应用中,你将使用 EJS 模板,尽管如前所述,Node 社区中的几乎任何模板引擎都可以使用。如果你不熟悉 EJS,不要担心。它与在其他 Web 开发平台(PHP、JSP、ERB)中找到的模板语言类似。我们在本章中介绍了 EJS 的一些基础知识,但在第七章中我们将更详细地讨论 EJS 和几个其他模板引擎。第七章。

无论是在渲染整个 HTML 页面、HTML 片段还是 RSS 源,对于几乎所有应用来说,渲染视图都是至关重要的。这个概念很简单:你将数据传递给一个 视图,然后这些数据被转换,通常是转换为 HTML 以供 Web 应用使用。你很可能熟悉视图的概念,因为大多数框架都提供了类似的功能;图 6.8 说明了视图如何为数据形成新的表示。

图 6.8. HTML 模板加数据 = 数据的 HTML 视图

图片

生成模板 6.8 的模板可以在以下片段中找到:

<h1><%= name %></h1>
<p><%= name %> is a 2 year old <%= species %>.</p>

Express 提供了两种渲染视图的方式:在应用级别使用 app.render(),以及在响应中使用 res.render(),后者内部使用前者。在本章中,你将只使用 res.render()。如果你查看 ./routes/index.js,会发现定义了一个函数,它调用 res.render('index') 以渲染 ./views/index.ejs 模板,如下面的代码(在列表 6_8 中找到)所示:

router.get('/', (req, res, next) => {
  res.render('index', { title: 'Express' });
});

在更详细地查看 res.render() 之前,让我们看看如何配置视图系统。

配置视图系统

配置 Express 视图系统很简单。尽管 express(1) 为你生成了配置,但了解幕后发生的事情仍然很有用,这样你就可以进行更改。我们将重点关注三个区域:

  • 调整视图查找

  • 配置默认模板引擎

  • 启用视图缓存以减少文件 I/O

首先是 views 设置。

更改查找目录

以下片段显示了 Express 可执行文件创建的 views 设置:

app.set('views', __dirname + '/views');

这指定了 Express 在视图查找期间将使用的目录。使用 __dirname 是一个好主意,这样你的应用程序就不会依赖于当前工作目录是应用程序的根目录。

__dirname

__dirname(带有两个前置下划线)是 Node 中的一个全局变量,用于标识当前运行文件所在的目录。通常在开发中,这个目录将与你的当前工作目录(CWD)相同,但在生产中,Node 可执行文件可能从另一个目录运行。使用 __dirname 有助于在不同环境中保持路径的一致性。

下一个设置是 view engine

使用默认模板引擎

express(1)生成应用程序时,view engine设置被分配为ejs,因为 EJS 是-e命令行选项选择的模板引擎。这个设置使你能够渲染index而不是 index.ejs。否则,Express 需要扩展名来确定要使用哪个模板引擎。

你可能想知道为什么 Express 甚至考虑扩展名。扩展名的使用允许你在单个 Express 应用程序中使用多个模板引擎,同时为常见用例提供干净的 API,因为大多数应用程序将使用一个模板引擎。

例如,如果你发现使用另一个模板引擎编写 RSS 源更容易,或者你可能正在从一个模板引擎迁移到另一个模板引擎。你可能将 Pug 作为默认引擎,将 EJS 用于/feeds 路由,如下面的代码所示,通过.ejs 扩展名:

app.set('view engine', 'pug');
app.get('/', function(){
  res.render('index');
 });
app.get('/feed', function(){
  res.render('rss.ejs')
;
});
保持 package.json 同步

请记住,你希望使用的任何额外的模板引擎都应该添加到你的 package.json 依赖对象中。尽量记得使用npm install --save package-name来安装包。使用npm uninstall --save package-name来删除它们,从而从 node_modules 和 package.json 中移除。这使你在尝试确定要使用哪个模板引擎时,实验不同的模板引擎变得更加容易。

视图缓存

在生产环境中,默认启用view cache设置,防止后续的render()调用执行磁盘 I/O。模板的内容保存在内存中,大大提高了性能。启用此设置的副作用是,你将无法在不重新启动服务器的情况下编辑模板文件,这就是为什么它在开发中是禁用的。如果你正在运行预发布环境,你可能会想要启用此选项。

如图 6.9 所示,当禁用view cache时,每次请求都会从磁盘读取模板。这就是为什么你可以修改模板而无需重新启动应用程序的原因。当启用view cache时,每个模板只访问磁盘一次。

图 6.9. 视图缓存设置

你已经看到了视图缓存机制如何帮助在非开发环境中提高性能。现在让我们看看 Express 是如何定位视图以便渲染的。

视图查找

查找视图的过程类似于 Node 的require()工作方式。当调用res.render()app.render()时,Express 首先检查在绝对路径下是否存在文件。接下来,Express 在视图目录中查找。最后,Express 尝试索引文件。这个过程在图 6.10 中用流程图表示。

图 6.10. 表达视图查找过程

因为ejs被设置为默认引擎,所以渲染调用省略了.ejs 扩展名,模板文件仍然可以正确解析。

随着应用程序的发展,你可能需要更多的视图,有时甚至需要为单个资源创建多个视图。使用 view lookup 可以帮助组织——例如,你可以使用与资源相关的子目录,并在其中创建视图。

添加子目录允许你消除名称中的冗余部分(如 edit-entry.ejs 和 show-entry.ejs)。然后 Express 添加 view engine 扩展,并将 res.render('entries/edit') 解析为 ./views/entries/edit.ejs。

Express 会检查视图目录的子目录中是否存在名为 index 的文件。当文件以复数资源命名,如 entries 时,这通常意味着资源列表。这意味着你可以使用 res.render('entries') 来渲染 views/entries/index.ejs 中的文件。

数据展示给视图的方法

你已经看到了如何直接将局部变量传递给 res.render() 调用,但你也可以使用一些其他机制来完成这个任务。例如,你可以使用 app.locals 来存储应用级别的变量,以及 res.locals 来存储请求级别的局部变量,这些变量通常由中间件组件在渲染视图的最终路由处理方法之前设置。

直接传递给 res.render() 的值优先于在 res.localsapp.locals 中设置的值,如 图 6.11 所示。

图 6.11. 在渲染模板时,直接传递给 render 函数的值具有优先级。

![Images/06fig11.jpg]

默认情况下,Express 只向视图暴露一个应用级别的变量 settings,它是一个包含所有使用 app.set() 设置的值的对象。例如,使用 app.set('title', 'My Application') 会在模板中暴露 settings.title,如下面的 EJS 片段所示:

<html>
  <head>
     <title><%= settings.title %></title>
  </head>
  <body>
    <h1><%= settings.title %></h1>
    <p>Welcome to <%= settings.title %>.</p>
  </body>

在内部,Express 使用以下 JavaScript 暴露此对象:

app.locals.settings = app.settings;

就这些了!现在你已经看到了如何渲染视图并将数据发送给它们,让我们看看如何定义路由,并了解如何编写可以为 shoutbox 应用程序渲染视图的路由处理程序。你还将设置数据库模型以持久化数据。

6.2.4. Express 路由 101

Express 路由的主要功能是将 URL 模式与响应逻辑配对。但路由也可以将 URL 模式与中间件配对。这允许你使用中间件为某些路由提供可重用的功能。

在本节中,你将执行以下操作:

  • 使用路由特定的中间件验证用户提交的内容

  • 实现路由特定的验证

  • 实现分页

让我们探索一些使用路由特定中间件的方法。

验证用户内容提交

为了让你有东西可以应用验证,你最终将添加到 shoutbox 应用程序中发布的能力。为了添加发布的能力,你需要做几件事情:

  • 创建条目模型

  • 添加与条目相关的路由

  • 创建条目表单

  • 添加逻辑以使用提交的表单数据创建条目

你将首先创建一个条目模型。

创建条目模型

在继续之前,你需要将 Node redis 模块安装到项目中。使用 npm install --save redis 安装它。如果你没有安装 Redis,请访问 redis.io/ 了解如何安装它;如果你使用 macOS,你可以很容易地使用 Homebrew (brew.sh/) 安装它,Windows 有 Redis Chocolatey 软件包 (chocolatey.org/)。

我们使用 Redis 来稍微作弊一下:Redis 的功能和 ES6 使得在没有复杂数据库库的情况下创建轻量级模型变得容易。如果你有雄心壮志,可以使用另一个数据库库(有关 Node 中的数据库的更多信息,请参阅第八章)。

让我们看看如何创建一个轻量级模型来存储你的喊话条目。创建一个文件来包含条目模型定义,在 models/entry.js 中。将以下列表中的代码添加到此文件。条目模型将是一个简单的 ES6 类,它将数据保存到 Redis 列表中。

列表 6.7. 条目模型

在基本模型完善之后,你现在需要添加一个名为 getRange 的函数,使用以下列表的内容。此函数将允许你检索条目。

列表 6.8. 获取条目范围的逻辑

在创建了一个模型之后,你现在可以添加创建和列出条目的路由。

创建条目表单

应用程序具有列出条目的能力,但没有添加条目的方法。你将在下一个步骤中添加此功能,首先在 app.js 的路由部分添加以下行:

app.get('/post', entries.form);
app.post('/post', entries.submit);

接下来,将以下路由添加到 routes/entries.js 中。此路由逻辑将渲染包含表单的模板:

exports.form = (req, res) => {
  res.render('post', { title: 'Post' });
};

接下来,使用以下列表中的 EJS 模板创建表单模板并将其保存到 views/post.ejs 中。

列表 6.9. 输入帖子数据的表单

此表单使用如 entry[title] 这样的输入名称,因此需要扩展体解析。要更改体解析器,打开 app.js,并移动到读取

app.use(bodyParser.urlencoded({ extended: false }));

将此更改为使用扩展解析:

app.use(bodyParser.urlencoded({ extended: true }));

在处理表单显示之后,让我们继续从提交的表单数据创建条目。

实现条目创建

要添加从提交的表单数据创建条目的功能,请将以下列表中的逻辑添加到文件 routes/entries.js 中。此逻辑将在表单数据提交时添加条目。

列表 6.10. 使用提交的表单数据添加条目

现在你使用浏览器访问应用程序上的 /post,你将能够添加条目。你将在 列表 6.21 中处理强制用户先登录。

在处理完帖子内容后,现在是时候渲染条目列表了。

添加条目的前页显示

首先创建文件 routes/entries.js。然后添加以下列表中的代码,以导入条目模型并导出一个用于渲染条目列表的函数。

列表 6.11. 列出条目

在为列出条目定义路由逻辑后,您现在需要添加一个 EJS 模板来显示它们。在 views 目录中创建一个名为 entries.ejs 的文件,并将以下 EJS 放入其中。

列表 6.12. entries.ejs 视图
<!DOCTYPE html>
<html>
  <head>
    <title><%= title %></title>
    <link rel='stylesheet' href='/stylesheets/style.css' />
  </head>
  <body>
    <% include menu %>
    <% entries.forEach((entry) => { %>
      <div class='entry'>
        <h3><%= entry.title %></h3>
        <p><%= entry.body %></p>
        <p>Posted by <%= entry.username %></p>
      </div>
    <% }) %>
  </body>
</html>

在运行应用程序之前,运行 touch views/menu.ejs 以创建一个临时文件,该文件将在稍后阶段保存菜单。当视图和路由准备就绪时,您需要告诉应用程序在哪里找到路由。

添加与条目相关的路由

在您将条目相关的路由添加到应用程序之前,您需要对 app.js 进行修改。首先,在您的 app.js 文件顶部添加以下 require 语句:

const entries = require('./routes/entries');

接下来,也在 app.js 中,将包含文本 app.get('/') 的行更改为以下内容,以便任何对路径 / 的请求都返回条目列表:

app.get('/', entries.list);

当您运行应用程序时,主页将显示条目列表。现在,条目可以创建和列出,让我们继续使用特定路由的中间件来验证表单数据。

使用特定路由的中间件

假设您希望帖子表单中的条目文本字段是必需的。您可能首先想到的解决这个问题的方法是在您的路由回调中直接添加它,如下面的代码片段所示。然而,这种方法并不理想,因为它将验证逻辑紧密地绑定到这个特定的表单上。在许多情况下,验证逻辑可以被抽象成可重用的组件,使开发更容易、更快、更声明性:

...
exports.submit = (req, res, next) => {
  let data = req.body.entry;
  if (!data.title) {
    res.error('Title is required.');
    res.redirect('back');
    return;
  }
  if (data.title.length < 4) {
    res.error('Title must be longer than 4 characters.');
    res.redirect('back');
    return;
  }
...

Express 路由可以可选地接受自己的中间件,仅在匹配该路由时应用,在最终的路线回调之前。您在本章中使用的路线回调本身并没有被特别处理。它们与其他中间件相同,甚至包括您即将为验证创建的中间件!

让我们从查看一种简单但不够灵活的实现验证作为特定路由中间件的方法开始。

使用特定路由的中间件进行表单验证

第一种可能性是编写一些简单但具体的中间件组件来执行验证。通过扩展 POST /post 路由使用此中间件可能看起来像以下这样:

app.post('/post',
  requireEntryTitle,
  requireEntryTitleLengthAbove(4),
  entries.submit
);

注意,这个路由定义,通常只有路径和路由逻辑作为参数,有两个额外的参数指定验证中间件。

下面的列表中的两个示例中间件组件说明了如何抽象出原始验证。但它们仍然不是模块化的,并且只为单个字段 entry[title] 工作。

列表 6.13. 两个更多潜在的但并不完美的验证中间件尝试
function requireEntryTitle(req, res, next) {
  const title = req.body.entry.title;
  if (title) {
    next();
  } else {
    res.error('Title is required.');
    res.redirect('back');
  }
}
function requireEntryTitleLengthAbove(len) {
  return (req, res, next) => {
    const title = req.body.entry.title;
    if (title.length > len) {
      next();
    } else {
      res.error(`Title must be longer than ${len}.`);
      res.redirect('back');
    }
  };
}

一个更可行的解决方案是抽象验证器并传递目标字段名。让我们看看如何以这种方式接近它。

构建灵活的验证中间件

你可以传递字段名,如下面的片段所示。这允许你重用验证逻辑,减少你需要编写的代码量:

app.post('/post',
          validate.required('entry[title]'),
          validate.lengthAbove('entry[title]', 4),
          entries.submit);

将 app.js 路由部分中的app.post('/post', entries.submit);行替换为以下片段。值得注意的是,Express 社区已经为公众创建了众多类似的库,但了解验证中间件的工作原理以及如何编写自己的中间件是无价的。

让我们继续前进。使用程序代码在列表 6.14 中创建一个名为./middleware/validate.js 的文件。在 validate.js 中,你将导出几个中间件组件——在这种情况下,validate.required()validate.lengthAbove()。实现细节并不重要;这个例子要说明的是,如果代码在应用程序中是通用的,那么一点小小的努力就能走得很远。

列表 6.14. 验证中间件实现

图片

图片

要使此中间件对应用程序可用,请在 app.js 顶部添加以下行:

const validate = require('./middleware/validate');

如果你现在尝试应用程序,你会发现验证将生效。这个验证 API 可以变得更加流畅,但我们将其留给你去调查。

6.2.5. 验证用户

在本节中,你将从零开始为应用程序创建一个身份验证系统。你将经历以下步骤:

  • 实现存储和验证已注册用户的逻辑

  • 添加账户注册功能

  • 允许人们登录

  • 创建并使用中间件来加载用户

你将继续使用 Redis 来实现用户账户。现在让我们看看如何创建一个用户模型,以便在 Node 代码中更轻松地使用 Redis。

保存和加载用户记录

在本节中,你将实现用户加载、保存和身份验证。你将执行以下操作:

  • 使用 package.json 文件定义应用程序依赖项

  • 创建用户模型

  • 使用 Redis 添加加载和保存用户数据的逻辑

  • 使用 bcrypt 安全地存储用户密码

  • 添加验证登录尝试的逻辑

Bcrypt 是一个加盐散列函数,作为一个专门为散列密码设计的第三方模块提供。Bcrypt 非常适合密码,因为它包含一个迭代计数参数,使其随着时间的推移而变慢。

在继续之前,将 bcrypt 添加到你的 shoutbox 项目中:

npm install --save redis bcrypt
创建用户模型

你现在需要创建一个用户模型。在 models/目录中添加一个名为 user.js 的文件。

列表 6.15 是用户模型。在这段代码中,需要redisbcrypt依赖项,然后使用redis.createClient()打开一个 Redis 连接。User函数接受一个对象,并将该对象的属性合并到其自身。例如,new User({ name: 'tobi' })创建一个对象,并将对象的name属性设置为Tobi

列表 6.15. 开始创建用户模型

图片

目前,用户模式只是一个占位符。你需要添加创建和更新用户记录的方法。

将用户保存到 Redis 中

下一个你需要的功能是保存用户的能力,使用 Redis 存储用户数据。在 列表 6.16 中显示的 save 方法检查用户是否已经有了一个 ID,如果有,则 save 调用 update 方法,按名称索引用户 ID,并用对象的属性填充 Redis 哈希。否则,没有 ID 的用户被视为新用户;然后 user:ids 的值增加,给用户一个唯一的 ID,密码在保存到 Redis 之前使用相同的 update 方法进行哈希处理。

将以下代码添加到 models/user.js 中。

列表 6.16. 更新用户记录

保护用户密码

当用户首次创建时,你需要将 .pass 属性设置为用户的密码。然后用户保存逻辑会将 .pass 属性替换为使用密码生成的哈希。

哈希是 加盐的。每个用户的加盐有助于防止雨彩虹表攻击:盐作为哈希机制的秘密密钥。你可以使用 bcrypt 的 genSalt() 生成一个 12 位的盐。

雨彩虹表攻击

雨彩虹表攻击使用预先计算的表来破解哈希密码。你可以在维基百科上了解更多关于这个主题的信息:en.wikipedia.org/wiki/Rainbow_table

盐生成后,调用 bcrypt.hash(),它将 .pass 属性和盐进行哈希处理。这个最终的 hash 值然后替换 .pass 属性,在 .update() 存储到 Redis 之前,确保不保存明文密码,只保存哈希。

以下列表,你将添加到 models/user.js 中,定义了一个函数,该函数创建加盐的哈希并将其存储在用户的 .pass 属性中。

列表 6.17. 向用户模型添加 bcrypt 加密

就这些了。

测试用户保存逻辑

要尝试保存用户,通过命令行输入 redis-server 启动 Redis 服务器。然后,将以下列表中的代码添加到 models/user.js 的底部,该代码创建了一个示例用户。然后,你可以在命令行上运行 node models/user.js 来执行示例用户的创建。

列表 6.18. 测试用户模型

你应该看到表示用户已创建的输出:例如 user id 1。在测试用户模型后,从 models/user.js 中删除 列表 6.18 中的代码。

当你使用随 Redis 一起提供的 redis-cli 工具时,你可以使用 HGETALL 命令来获取哈希中的每个键和值,如下面的命令行会话所示。

列表 6.19. 使用 Redis 命令行工具进行查询

定义了保存用户的逻辑后,你现在需要添加检索用户信息的逻辑。

你可以在 redis-cli 工具中运行的 Redis 命令

有关 Redis 命令的更多信息,请参阅 Redis 命令参考 redis.io/commands

获取用户数据

当用户尝试登录到网络应用程序时,用户通常会在表单中输入用户名和密码,然后这些数据将被提交给应用程序进行认证。一旦提交登录表单,你需要一种方法通过名称获取用户。

此逻辑在以下列表中定义为 User.getByName()。该函数首先使用 User.getId() 执行 ID 查找,然后将找到的 ID 传递给 User.get(),该函数获取该用户的 Redis 哈希数据。将以下方法添加到 models/user.js 中。

列表 6.20. 从 Redis 获取用户

图片

如果你想要尝试获取用户,你可以尝试以下代码:

User.getByName('tobi', (err, user) => {
  console.log(user);
});

在获取到散列密码后,你现在可以继续进行用户认证。

认证用户登录

用户认证所需的最后一个组件是以下列表中定义的方法,该方法利用了之前定义的用户数据检索函数。将此逻辑添加到 models/user.js 中。

列表 6.21. 认证用户的用户名和密码

图片

认证逻辑首先通过名称获取用户。如果找不到用户,回调函数将立即调用。否则,用户的存储盐和提交的密码将被散列以生成应与存储的 user.pass 散列相同的值。如果提交的散列和存储的散列不匹配,则用户输入了无效的凭据。当查找不存在的键时,Redis 将返回一个空哈希,这就是为什么使用 !user.id 而不是 !user 进行检查的原因。

现在你能够认证用户了,你需要一种让用户注册的方法。

6.2.6. 注册新用户

为了允许用户创建新账户并登录,你需要注册和登录功能。

在本节中,你将执行以下操作以实现注册:

  • 将注册和登录路由映射到 URL 路径

  • 向路由逻辑中添加显示注册表单

  • 添加逻辑以存储从表单提交的用户数据

表单看起来像 图 6.12。

图 6.12. 用户注册表单

图片

当用户使用网络浏览器访问 /register 时,将显示此表单。稍后你将创建一个类似的表单,允许用户登录。

添加注册路由

为了让注册表单显示出来,你首先需要创建一个路由来渲染表单并将其返回给用户的浏览器进行显示。

列表 6.22 展示了如何使用 Node 的模块系统从 routes 目录导入定义注册路由行为的模块,并将 HTTP 方法与 URL 路径关联到路由函数。这形成了一种“前端控制器”。正如你所见,既有 GETPOST 注册路由。

列表 6.22. 添加注册路由

图片

接下来,为了定义路由逻辑,在路由目录中创建一个名为 register.js 的空文件。通过从 routes/register.js 导出以下函数来开始定义注册路由的行为——一个渲染注册模板的路由:

exports.form = (req, res) => {
  res.render('register', { title: 'Register' });
};

此路由使用你接下来将要创建的 EJS 模板来定义注册表单的 HTML。

创建注册表单

为了定义注册表单的 HTML,在视图目录中创建一个名为 register.ejs 的文件。你可以使用以下列表中详细说明的 HTML/EJS 来定义此表单。

列表 6.23. 提供注册表单的视图模板

图片

注意到使用了 include messages,这包括另一个模板:messages.ejs。这个模板,你接下来将定义,用于与用户通信。

向用户传达反馈

在用户注册过程中,以及在典型应用的许多其他部分,向用户传达反馈可能是必要的。例如,用户可能会尝试使用已被他人使用的用户名进行注册。在这种情况下,你需要告诉用户选择另一个名字。

在你的应用中,messages.ejs 模板将用于显示错误。应用中的许多模板都将包含 messages.ejs 模板。

要创建消息模板,在视图目录中创建一个名为 messages.ejs 的文件,并将以下代码片段放入该文件中。模板逻辑检查 locals.messages 变量是否已设置。如果是,模板将遍历该变量,显示消息对象。每个消息对象都有一个 type 属性(允许你在需要时使用消息进行非错误通知)和一个 string 属性(消息文本)。应用逻辑可以通过向 res.locals.messages 数组中添加错误来排队显示错误。消息显示后,调用 removeMessages 来清空消息队列:

<% if (locals.messages) { %>
  <% messages.forEach((message) => { %>
    <p class='<%= message.type %>'><%= message.string %></p>
  <% }) %>
  <% removeMessages() %>
<% } %>

图 6.13 展示了显示错误消息时的注册表单。

图 6.13. 注册表单错误报告

图片

将消息添加到 res.locals.messages 是与用户通信的一种简单方式,但由于 res.locals 在重定向之间不持久,你需要通过使用会话在请求之间存储消息来使其更加健壮。

在会话中存储临时消息

常见的 Web 应用程序设计模式是 Post/Redirect/Get(PRG)模式。在这个模式中,用户请求一个表单,表单数据作为 HTTP POST请求提交,然后用户被重定向到另一个网页。用户被重定向到哪个网页取决于应用程序是否认为表单数据有效。如果表单数据被认为无效,应用程序将用户重定向回表单页面。如果表单数据有效,用户将被重定向到一个新网页。PRG 模式主要用于防止重复提交表单。

在 Express 中,当用户被重定向时,res.locals的内容会被重置。如果你在res.locals中存储消息给用户,消息在显示之前就会丢失。然而,通过将消息存储在会话变量中,你可以解决这个问题。然后可以在最终的跳转页面上显示消息。

为了适应将消息队列到会话变量的能力,你需要在应用程序中添加一个模块。创建一个名为./middleware/messages.js 的文件,并添加以下代码:

const express = require('express');

function message(req) {
  return (msg, type) => {
    type = type || 'info';
    let sess = req.session;
    sess.messages = sess.messages || [];
    sess.messages.push({ type: type, string: msg });
  };
};

res.message函数提供了一种从任何 Express 请求向会话变量添加消息的方法。express.response对象是 Express 用于响应对象的原型。向此对象添加属性意味着它们将随后对所有中间件和路由可用。在前面的代码片段中,express.response被分配给一个名为res的变量,以便更容易在对象上添加属性并提高可读性。

此功能需要会话支持。为了添加会话支持,你需要一个与 Express 兼容的中间件模块。有一个官方支持的包叫做 express-session。使用npm install --save express-session安装它,然后将中间件添加到 app.js 中,如下所示:

const session = require('express-session');
...
app.use(session({
  secret: 'secret',
  resave: false, saveUninitialized: true
}));

最佳做法是在将 cookie 中间件插入之后放置中间件(它应该在大约第 26 行附近)。

为了使添加消息更加容易,添加以下代码片段中的代码。res.error函数允许你轻松地将类型为error的消息添加到消息队列中。使用你在模块中先前定义的res.message函数:

res.error =  msg => this.message(msg, 'error');

最后一步是将这些消息暴露给模板以进行输出。如果你不这样做,你必须将req.session.messages传递到应用程序中的每个res.render()调用中,这并不完全理想。

为了解决这个问题,你将创建一个中间件,在每个请求中将res.locals.messages填充为res.session.messages的内容,从而有效地将消息暴露给任何渲染的模板。到目前为止,./middleware/messages.js 扩展了响应原型,但它没有导出任何内容。但是,向此文件添加以下代码片段可以导出所需的中间件:

module.exports = (req, res, next) => {
  res.message = message(req);
  res.error = (msg) => {
    return res.message(msg, 'error');
  };
  res.locals.messages = req.session.messages || [];
  res.locals.removeMessages = () => {
    req.session.messages = [];
  };
  next();
};

首先,定义一个 messages 模板变量来存储会话的消息;它是一个数组,可能存在于之前的请求中(记住这些是会话持久化的消息)。接下来,您需要一种方法从会话中删除消息;否则,它们会不断累积,因为没有清除它们。

现在,您要集成此新功能,只需在 app.js 中 require() 该文件即可。您应该在会话中间件下方挂载此中间件,因为它依赖于 req.session 被定义。请注意,因为这个中间件被设计为不接受选项且不返回第二个函数,所以您可以使用 app.use(messages) 而不是 app.use(messages())。为了确保兼容性,通常最好让第三方中间件使用 app.use(messages()),无论它是否接受选项:

...
const register = require('./routes/register');
const messages = require('./middleware/messages');
...
app.use(express.methodOverride());
app.use(express.cookieParser());
   app.use(session({
     secret: 'secret',
     resave: false,
     saveUninitialized: true
   }));
app.use(messages);
...

现在,您可以在任何视图中访问 messagesremoveMessages(),因此当 messages.ejs 包含在任何模板中时,应该可以完美工作。

完成注册表单的显示并制定了一种向用户传达任何必要反馈的方法后,让我们继续处理注册提交。

实现用户注册

您需要创建一个路由函数来处理对 /register 的 HTTP POST 请求。这个函数被称为 submit

当表单数据提交时,bodyParser() 中间件将提交的数据填充到 req.body 中。注册表单使用对象表示法 user[name],在经过 body parser 解析后转换为 req.body.user.name。同样,req.body.user.pass 用于密码字段。

在提交路由中,您只需要少量代码来处理验证,例如确保用户名尚未被占用,并将新用户保存,如列表 6.24 所示。

注册完成后,user.id 被分配给用户的会话,您稍后会检查以验证用户是否已认证。如果验证失败,则将消息暴露给模板作为 messages 变量,通过 res.locals.messages,并将用户重定向回注册表单。

要添加此功能,请将以下列表的内容添加到 routes/register.js 中。

列表 6.24. 使用提交的数据创建用户

图片

您现在可以启动应用程序,访问 /register,并注册一个用户。接下来,您需要一种方式让已注册用户通过 /login 表单进行身份验证。

6.2.7. 登录已注册用户

添加登录功能甚至比注册更简单,因为大部分必要的逻辑已经包含在之前定义的通用身份验证方法 User.authenticate() 中。在本节中,您将添加以下内容:

  • 路由逻辑以显示登录表单

  • 验证从表单提交的用户数据的逻辑

表单看起来像图 6.14。

图 6.14. 用户登录表单

图片

你将首先修改 app.js 以确保登录路由是必需的,并且建立了路由路径:

...
const login = require('./routes/login');
...
app.get('/login', login.form);
app.post('/login', login.submit);
app.get('/logout', login.logout);
...

接下来,你将添加显示登录表单的功能。

显示登录表单

实现登录表单的第一步是创建一个用于登录和注销相关路由的文件:routes/login.js。你需要添加以显示登录表单的路由逻辑几乎与之前用于显示注册表单的逻辑相同;唯一的区别是显示的模板名称和页面标题:

exports.form = (req, res) => {
  res.render('login', { title: 'Login' });
};

你将在 ./views/login.ejs 中定义的 EJS 登录表单与 register.ejs 非常相似;唯一的区别是说明文本和数据提交到的路由:

列表 6.25. 登录表单的视图模板

现在你已经添加了显示登录表单所需的路由和模板,下一步是添加处理登录尝试的逻辑。

验证登录

为了处理登录尝试,你需要添加路由逻辑来检查提交的用户名和密码,如果它们是正确的,则将一个会话变量设置为用户的 ID,并将用户重定向到主页。以下列表包含此逻辑,你应该将其添加到 routes/login.js 中。

列表 6.26. 处理登录的路由

在这里,如果用户通过 User.authenticate() 进行认证,req.session.uid 将以与 POST /register 路由相同的方式分配:会话将持久化此值,你可以在以后使用它来检索 User 或其他相关用户数据。如果没有找到匹配项,将设置错误并重新显示表单。

用户也可能更喜欢明确地注销,因此你应该在应用程序的某个位置提供一个链接。在 app.js 中,设置具有此功能的路由:

const login = require('./routes/login');
...
app.get('/logout', login.logout);

然后在 ./routes/login.js 中,以下函数将移除由 session() 中间件检测到的会话,导致会话被分配给后续请求:

exports.logout = (req, res) => {
  req.session.destroy((err) => {
    if (err) throw err;
    res.redirect('/');
  })
};

现在注册和登录页面已经创建,接下来你需要添加一个菜单,以便用户可以访问它们。让我们看看如何创建一个。

为认证用户和匿名用户创建菜单

在本节中,你将创建一个用于匿名用户和认证用户的菜单,允许他们登录、注册、提交条目和注销。图 6.15 显示了匿名用户的菜单。

图 6.15. 用于访问你创建的表单的用户登录和注册菜单

当用户认证时,你将显示一个不同的菜单,显示该用户的用户名,以及一个用于向喊盒发布消息的页面链接和一个允许用户注销的链接。图 6.16 显示了此菜单。

图 6.16. 用户认证时的菜单

你创建的每个 EJS 模板,代表一个应用页面,都在 <body> 标签之后包含了 <% include menu %> 代码。这包括了 ./views/menu.ejs 模板,你将在下一个列表中创建它。

列表 6.27. 匿名和认证用户菜单模板

在这个应用中,你可以假设如果 user 变量被暴露给模板,则用户已认证,因为你不会在其他情况下暴露这个变量;你将在下面看到这一点。当这个变量存在时,你可以显示用户名以及提交条目和注销链接。当匿名用户访问时,显示网站登录和注册链接。

你可能想知道这个 user 本地变量是从哪里来的——你还没有编写它。接下来,你将编写一些代码来为每个请求加载已登录用户的数据,并使这些数据可用于模板。

6.2.8. 与用户加载中间件协同工作

当你与网络应用协同工作时,一个常见的任务是从数据库中加载用户信息,通常表示为一个 JavaScript 对象。拥有这些数据可以简化与用户的交互。对于本章的应用,你将使用中间件在每次请求时加载用户数据。

你将把这个中间件脚本放在 ./middleware/user.js 中,从上面的目录(../models)中引入 User 模型。中间件函数首先被导出,然后它检查会话中的用户 ID。当用户 ID 存在时,用户已认证,因此可以从 Redis 中安全地获取用户数据。

由于 Node 是单线程的,所以没有线程局部存储。在 HTTP 服务器的案例中,请求和响应变量是唯一可用的上下文对象。高级框架可以在 Node 的基础上构建,以提供额外的对象来存储已认证的用户,但 Express 选择坚持 Node 提供的原始对象。因此,上下文数据通常存储在请求对象上,如 列表 6.28 中所示,其中用户存储为 req.user;后续的中间件和路由可以通过使用相同的属性来访问用户对象。

你可能会想知道将 res.locals.user 赋值的目的是什么。res.locals 是 Express 提供的请求级别对象,用于将数据暴露给模板,类似于 app.locals。它也是一个可以用来将现有对象合并到自身的函数。

列表 6.28. 加载已登录用户数据的中间件

要使用这个新的中间件,首先删除 app.js 中包含文本user的所有行。然后你可以像通常一样 require 模块,并将其传递给app.use()。在这个应用程序中,user在路由器之上使用,因此只有user之后的路由和中间件可以访问req.user。如果你使用的是像这个中间件一样加载数据的中间件,你可能希望将express.static中间件放在它之上;否则,每次服务静态文件时,都会进行不必要的数据库往返操作来获取用户。

下面的列表展示了如何在 app.js 中启用此中间件。

列表 6.29. 启用用户加载中间件

如果你再次启动应用程序并在浏览器中访问/login 或/register 页面,你应该能看到菜单。如果你想对菜单进行样式设计,请将以下 CSS 代码行添加到 public/stylesheets/style.css 中。

列表 6.30. 可添加到 style.css 中以样式化应用程序菜单的 CSS
#menu {
  position: absolute;
  top: 15px;
  right: 20px;
  font-size: 12px;
  color: #888;
}
#menu .name:after {
  content: ' -';
}
#menu a {
  text-decoration: none;
  margin-left: 5px;
  color: black;
}

菜单设置好后,你应该能够注册自己作为用户。然后你应该看到带有发布链接的已认证用户菜单。

在下一节中,你将学习如何为应用程序创建一个公共 REST API。

6.2.9. 创建公共 REST API

在本节中,你将实现一个针对 shoutbox 应用程序的 RESTful 公共 API,以便第三方应用程序可以访问并添加发布数据。REST 允许使用动词和名词(分别由 HTTP 方法和 URL 表示)查询和更改应用程序数据。REST 请求通常以机器可读的格式(如 JSON 或 XML)返回数据。

要实现 API,你需要执行以下操作:

  • 设计一个 API,允许用户展示、列出、删除和发布条目

  • 添加基本认证

  • 实现路由

  • 提供 JSON 和 XML 响应

可以使用各种技术来验证和签名 API 请求,但实现更复杂解决方案超出了本书的范围。为了说明如何集成认证,我们将使用 basic-auth 包。

设计 API

在开始实施之前,先大致规划一下涉及的路线是个好主意。对于这个应用,你将在 RESTful API 前加上/api 路径,但这是一个你可以更改的设计选择。例如,你可能希望使用一个子域名,如api.myapplication.com

下面的代码片段说明了为什么将回调函数移动到单独的 Node 模块中,而不是与app.VERB()调用内联定义,可能是一个好的选择。一个清晰的路线列表可以让你和你的团队清楚地了解已经实现了什么,以及实现回调函数的位置:

app.get('/api/user/:id', api.user);
app.get('/api/entries/:page?', api.entries);
app.post('/api/entry', api.add);
添加基本认证

如前所述,有许多方法可以处理 API 安全性和限制,但这些方法超出了本书的范围。但用基本认证来展示这个过程是值得的。

api.auth 中间件将抽象这个过程,因为实现将位于即将创建的 ./routes/api.js 模块中。app.use() 方法可以传递一个路径名,在 Express 中称为 挂载点。使用这个挂载点,以 /api 开头的路径名和任何 HTTP 动词都会触发此中间件的调用。

以下代码片段中显示的行 app.use('/api', api.auth) 应该放在加载用户数据的中间件之前。这样做是为了您可以在以后修改用户加载中间件以加载认证 API 用户的资料:

...
const api = require('./routes/api');
...
app.use('/api', api.auth);
app.use(user);
...

要执行基本认证,安装 basic-auth 模块:npm install --save basic-auth。接下来,创建 ./routes/api.js 文件,并像以下代码片段所示,引入 Express 和用户模型。basic-auth 包接受一个函数来执行认证,该函数签名是 (username, password, callback)。您的 User.authenticate 方法是完美的选择:

const auth = require('basic-auth');
const express = require('express');
const User = require('../models/user');

exports.auth = (req, res, next) => {
  const { name, pass } = auth(req);
  User.authenticate(name, pass, (err, user) => {
    if (user) req.remoteUser = user;
    next(err);
  });
};

认证准备就绪。让我们继续实施 API 路由。

实施路由

您将实现的第一条路由是 GET /api/user/:id。此路由的逻辑必须首先通过 ID 获取用户,如果用户不存在,则响应 404 Not Found 代码。如果用户存在,用户数据将被传递到 res.send() 进行序列化,应用程序将响应数据的 JSON 表示。在 routes/api.js 中添加以下代码片段的逻辑:

exports.user = (req, res, next) => {
  User.get(req.params.id, (err, user) => {
    if (err) return next(err);
    if (!user.id) return res.sendStatus(404);
    res.json(user);
  });
};

接下来,将以下路由路径添加到 app.js 中:

app.get('/api/user/:id', api.user);

您现在可以开始测试了。

测试用户数据检索

启动应用程序,并使用 cURL 命令行工具进行测试。以下代码片段显示了如何测试应用程序的 REST 认证。凭据在 URL tobi:ferret 中提供,cURL 使用它来生成 Authorization 头字段:

$ curl http://tobi:ferret@127.0.0.1:3000/api/user/1 -v

以下列表显示了成功测试的结果。要执行类似的测试,您需要确保您知道用户的 ID。如果 1 不起作用并且您已注册用户,请尝试使用 redis-cli 和 GET user:ids

列表 6.31. 测试输出

![Images/06lis31_alt.jpg]

删除敏感用户数据

如您从 JSON 响应中看到的,用户的密码和盐都包含在响应中。要更改此,您可以在 models/user.js 中的 User 上实现 .toJSON()

class User {
  // ...
  toJSON() {
    return {
      id: this.id,
      name: this.name
    };
  }

如果一个对象上存在 .toJSON,它将被 JSON.stringify 调用用于获取 JSON 格式。如果之前显示的 cURL 请求再次发出,您现在将只收到 ID 和名称属性:

{
  "id": "1",
  "name": "tobi"
}

您接下来要添加到 API 中的是创建条目的能力。

添加条目

通过 HTML 表单和通过 API 添加条目的过程几乎相同,因此您可能希望重用之前实现的 entries.submit() 路由逻辑。

然而,在添加条目时,路由逻辑会存储用户的名称,除了其他详细信息外,还会添加条目。因此,您需要修改用户加载中间件,以便使用basic-auth中间件加载的用户数据填充res.locals.userbasic-auth中间件返回这些数据,并将它们设置为req.remoteUser。在用户加载中间件中添加对此的检查很简单;按照以下方式更改中间件/user.js 中的module.exports定义,以便用户加载中间件与 API 一起工作:

...
module.exports = (req, res, next) => {
  if (req.remoteUser) {
    res.locals.user = req.remoteUser;
  }
  const uid = req.session.uid;
  if (!uid) return next();
  User.get(uid, (err, user) => {
    if (err) return next(err);
    req.user = res.locals.user = user;
    next();
  });
};

进行此更改后,您现在可以通过 API 添加条目。

然而,还需要进行的一项更改是提供 API 友好的响应,而不是重定向到应用程序的主页。为了添加此功能,将 routes/entries.js 中的entry.save调用更改为以下内容:

...
  entry.save(err => {
    if (err) return next(err);
    if (req.remoteUser) {
      res.json({ message: 'Entry added.' });
    } else {
      res.redirect('/');
    }
  });
...

最后,为了在您的应用程序中激活条目添加 API,将以下片段的内容添加到 app.js 的路由部分:

app.post('/api/entry', entries.submit);

通过使用以下 cURL 命令,您可以测试通过 API 添加条目。在这里,标题和正文数据使用与 HTML 表单中相同的字段名称发送:

$ curl -X POST -d "entry[title]='Ho ho ho'&entry[body]='Santa loves you'"
      http://tobi:ferret@127.0.0.1:3000/api/entry

现在您已经添加了创建条目的能力,您还需要添加检索条目数据的能力。

添加条目列表支持

下一个要实现的 API 路由是GET /api/entries/:page?。路由实现几乎与./routes/entries.js 中现有的条目列表路由相同。您还需要添加分页中间件,即以下片段中的page()。您很快就会添加page()

因为路由逻辑将访问条目,所以您需要在 routes/api.js 的顶部使用以下行来引入Entry模型:

const Entry = require('../models/entry');

接下来,将以下片段中的行添加到 app.js 中:

const Entry = require('./models/entry');
...
app.get('/api/entries/:page?', page(Entry.count), api.entries);

现在将以下片段中的路由逻辑添加到 routes/api.js 中。此路由逻辑与 routes/entries.js 中类似逻辑之间的区别反映了您不再渲染模板,而是渲染 JSON 的事实:

exports.entries = (req, res, next) => {
  const page = req.page;
  Entry.getRange(page.from, page.to, (err, entries) => {
    if (err) return next(err);
    res.json(entries);
  });
};
实现分页中间件

对于分页,您使用查询字符串?page=N的值来确定当前页。将以下中间件函数添加到./middleware/page.js 中。

列表 6.32. 分页中间件

此中间件获取分配给?page=N的值;例如,?page=1。然后它获取结果总数,并将预计算的page对象暴露给任何可能稍后渲染的视图。这些值在模板外进行计算,以允许模板更干净,包含更少的逻辑。

测试条目路由

以下 cURL 命令从 API 请求条目数据:

$ curl http://tobi:ferret@127.0.0.1:3000/api/entries

这个 cURL 命令应该产生类似于以下 JSON 的输出:

[
  {
    "username": "rick",
    "title": "Cats can't read minds",
    "body": "I think you're wrong about the cat thing."
  },
  {
    "username": "mike",
    "title": "I think my cat can read my mind",
    "body": "I think cat can hear my thoughts."
  },
...

在基本的 API 实现完成后,让我们继续探讨 API 如何支持多种响应格式。

6.2.10. 启用内容协商

内容协商允许客户端指定它愿意接受的格式以及它偏好的格式。在本节中,您将提供 API 内容的 JSON 和 XML 表示,以便 API 消费者可以决定他们想要什么。

HTTP 通过 Accept 头字段提供内容协商机制。例如,一个偏好 HTML 但愿意接受纯文本的客户端可以设置以下请求头:

Accept: text/plain; q=0.5, text/html

qvalue,或 质量值(例如本例中的 q=0.5),表示尽管指定了 text/html,但它比 text/plain 更受青睐 50%。Express 解析此信息并提供一个标准化的 req.accepted 数组:

[{ value: 'text/html', quality: 1 },
 { value: 'text/plain', quality: 0.5 }]

Express 还提供了 res.format() 方法,它接受一个 MIME 类型数组和回调。Express 将确定客户端愿意接受什么以及您愿意提供什么,并将调用适当的回调。

实现内容协商

在 routes/api.js 中实现 GET /api/entries 路径的内容协商可能看起来像 列表 6.33。JSON 支持如之前一样——您使用 res.send() 将条目序列化为 JSON。XML 回调迭代条目并在这样做时写入套接字。请注意,无需显式设置 Content-Typeres.format() 会自动将其设置为相关类型。

列表 6.33. 实现内容协商

如果您设置了默认响应格式回调,则当用户未请求您明确处理过的格式时,将执行此操作。

res.format() 方法还接受一个扩展名,该扩展名映射到相关 MIME 类型。例如,jsonxml 可以用来代替 application/jsonapplication/xml,如下面的代码片段所示:

...
res.format({
  json: () => {
    res.send(entries);
  },
  xml: () => {
    res.write('<entries>\n');
    entries.forEach((entry) => {
      res.write(```

        <entry>

            <title>${entry.title}</title>

        <body>${entry.body}</body>

        <username>${entry.username}</username>

        </entry>

        ```
      );
    });
    res.end('</entries>');
  }
})
...
以 XML 格式响应

在路由中编写大量自定义逻辑以响应 XML 可能不是最干净的方法,所以让我们看看如何使用视图系统来清理这个问题。

创建一个名为 ./views/entries/xml.ejs 的模板,该模板使用 EJS 迭代条目以生成 <entry> 标签。

列表 6.34. 使用 EJS 模板生成 XML

现在可以将 XML 回调替换为单个 res.render() 调用,传递 entries 数组,如下所示:

...
  xml: () => {
    res.render('entries/xml', { entries: entries });
  }
})
...

现在您已经准备好测试 API 的 XML 版本。在命令行中输入以下内容以查看 XML 输出:

curl -i -H 'Accept: application/xml'
http://tobi:ferret@127.0.0.1:3000/api/entries

6.3. 摘要

  • Connect 是一个 HTTP 框架,允许您在请求处理前后堆叠中间件组件。

  • Connect 中间件组件是接受 Node 的请求和响应对象、一个调用下一个中间件的函数以及一个可选的错误对象的函数。

  • Express 网络应用程序也是使用中间件组件构建的。

  • 你可以使用 Express 通过使用 HTTP 动词来定义路由来构建 REST API。

  • Express 路由可以响应 JSON、HTML 或其他数据格式。

  • Express 有一个简单的模板引擎 API,支持许多引擎。

第七章. Web 应用程序模板化

本章涵盖

  • 使用模板组织应用程序

  • 通过嵌入式 JavaScript 创建模板

  • 使用 Hogan 学习极简模板化

  • 使用 Pug 创建模板

在第三章和第六章中,你学习了在 Express 应用程序中使用模板的一些基础知识,以便创建视图。在本章中,你将专注于模板化,学习如何使用三个流行的模板引擎,以及如何通过将逻辑与表示标记分离来使用模板化使任何 Web 应用程序的代码保持整洁。

如果你熟悉模板化和模型-视图-控制器(MVC)模式,你可以快速浏览到第 7.2 节,在那里你将开始学习本章详细介绍的模板引擎,包括嵌入式 JavaScript、Hogan 和 Pug。如果你不熟悉模板化,请继续阅读——你将在接下来的几节中从概念上探索它。

7.1. 使用模板保持代码整洁

你可以使用 MVC 模式在 Node 中以及几乎所有的其他 Web 技术中开发传统 Web 应用程序。MVC 中的一个关键概念是逻辑、数据和表示的分离。在 MVC Web 应用程序中,用户通常从服务器请求资源,这会导致控制器模型请求应用程序数据,然后将数据传递给视图,最终为最终用户格式化数据。MVC 模式中的这个视图部分通常通过使用各种模板语言之一来实现。当一个应用程序使用模板化时,视图将模型返回的选定值传递给模板引擎,并指定定义如何显示提供的值的模板文件。

图 7.1 显示了模板逻辑如何适合 MVC 应用程序的整体架构。模板文件通常包含应用程序值的占位符以及 HTML、CSS,有时还有一小部分客户端 JavaScript 来实现动态行为,包括显示第三方小部件,如 Facebook 的“赞”按钮,或触发界面行为,如隐藏或显示页面的一部分。因为模板文件侧重于表示而不是逻辑,所以前端开发人员和后端开发人员可以共同工作,这有助于项目的劳动分工。

图 7.1. MVC 应用程序的流程及其与模板层的交互

在本节中,我们将展示带有和没有模板的 HTML 渲染,以展示它们之间的差异。但首先,让我们从一个模板应用的实例开始。

7.1.1. 模板应用实例

作为应用模板的一个快速示例,让我们看看如何从简单的博客应用中优雅地输出 HTML。每篇博客条目都有一个标题、条目日期和正文文本。在浏览器中,博客看起来类似于图 7.2。

图 7.2. 示例博客应用浏览器输出

博客条目从格式类似于以下entries.txt片段的文本文件中读取。以下列表中的---表示一个条目的结束和另一个条目的开始。

列表 7.1. 博客条目文本文件
title: It's my birthday!
date: January 12, 2016
I am getting old, but thankfully I'm not in jail!
---
title: Movies are pretty good
date: January 2, 2016
I've been watching a lot of movies lately. It's relaxing,
except when they have clowns in them.

博客应用代码在blog.js中开始时,通过以下列表展示了所需的模块导入和博客条目的读取操作。

列表 7.2. 简单博客应用中的博客条目文件解析逻辑

以下代码,当添加到博客应用中时,定义了一个 HTTP 服务器。当服务器收到 HTTP 请求时,它返回一个包含所有博客条目的页面。这个页面是通过一个名为blogPage的函数渲染的,您将在下一个步骤中定义它:

const server = http.createServer((req, res) => {
  const output = blogPage(entries);
  res.writeHead(200, {'Content-Type': 'text/html'});
  res.end(output);
});
server.listen(8000);

现在您需要定义blogPage函数,该函数将博客条目渲染成可以发送到用户浏览器的 HTML 页面。您将通过尝试两种方法来实现这一点:

  • 不使用模板渲染 HTML

  • 使用模板渲染 HTML

首先,让我们看看不使用模板的渲染。

7.1.2. 不使用模板渲染 HTML

博客应用可以直接输出 HTML,但将 HTML 包含在应用程序逻辑中会导致代码杂乱。在以下列表中,blogPage函数展示了显示博客条目的非模板方法。

列表 7.3. 模板引擎将表示细节与应用逻辑分离

注意,所有这些与表示相关的内容、CSS 定义和 HTML 都为应用程序添加了许多行。

使用模板渲染 HTML

使用模板渲染 HTML 允许您将 HTML 从应用程序逻辑中移除,从而显著清理代码。

要尝试本节中的示例,您需要将嵌入式 JavaScript (EJS) 模块安装到您的应用程序目录中。您可以在命令行中输入以下内容来完成此操作:

npm install ejs

以下代码片段从文件中加载一个模板,然后定义了一个新的blogPage函数版本,这次使用 EJS 模板引擎,我们将在 7.2 节中向您展示如何使用它:

const fs = require('fs');
const ejs = require('ejs');
const template = fs.readFileSync('./templatess/blog_page.ejs', 'utf8');
function blogPage(entries) {
  const values = { entries };
  return ejs.render(template, values);
}

完整的列表可以在本书的列表中找到,位于 ch07-templates/listing7_4/。EJS 模板文件包含 HTML 标记(将其保留在应用程序逻辑之外)和占位符,这些占位符指示数据传递到模板引擎时应放置的位置。以下列表展示了显示博客条目的 HTML 和占位符:

列表 7.4. 用于显示博客条目的 EJS 模板

社区贡献的 Node 模块也提供了模板引擎,并且存在各种各样的它们。如果你认为 HTML 和/或 CSS 不够优雅,因为 HTML 需要关闭标签,CSS 需要开闭花括号,那么请更仔细地看看模板引擎。它们允许模板文件使用特殊语言(例如我们在本章后面将要介绍的 Pug 语言)来提供一种指定 HTML、CSS 或两者的简写方式。

这些模板引擎可以使你的模板更简洁,但你可能不想花时间去学习一种指定 HTML 和 CSS 的替代方法。最终,你决定使用什么取决于个人偏好。

在本章的其余部分,你将通过三个流行的模板引擎的视角学习如何在你的 Node 应用程序中集成模板:

  • 嵌入式 JavaScript (EJS) 引擎

  • 极简主义的 Hogan 引擎

  • Pug 模板引擎

每个引擎都允许你以不同的方式编写 HTML。让我们从 EJS 开始。

7.2. 使用嵌入式 JavaScript 进行模板化

嵌入式 JavaScript (github.com/visionmedia/ejs)在模板方面采取了一种相当直接的方法,对于那些在其他语言中使用过模板引擎(如 Java Server Pages (JSP)、Smarty (PHP)、嵌入式 Ruby (ERB)等)的人来说,这将是一个熟悉的地方。EJS 允许你在 HTML 中嵌入 EJS 标签作为数据占位符。EJS 还允许你在模板中执行原始 JavaScript 逻辑,用于诸如条件分支和迭代等任务,就像 PHP 一样。

在本节中,你将学习以下内容:

  • 创建 EJS 模板

  • 使用 EJS 过滤器提供常用的、与展示相关的功能,例如文本操作、排序和迭代

  • 将 EJS 集成到你的 Node 应用程序中

  • 在客户端应用程序中使用 EJS

让我们更深入地探索 EJS 模板的世界。

7.2.1. 创建模板

在模板的世界里,发送给模板引擎进行渲染的数据有时被称为上下文。以下是一个使用 Node 和 EJS 在上下文中渲染简单模板的裸骨示例:

const ejs = require('ejs');
const template = '<%= message %>';
const context = { message: 'Hello template!' };
console.log(ejs.render(template, context));

注意在render函数的第二个参数中使用了locals。第二个参数可以包括渲染选项以及上下文数据,这意味着使用locals确保上下文数据的各个部分不会被解释为 EJS 选项。但在大多数情况下,可以将上下文本身作为第二个选项传递,如下面的render调用所示:

console.log(ejs.render(template, context));

如果你直接将上下文作为render函数的第二个参数传递给 EJS,确保不要使用以下任何术语来命名上下文值:cacheclientclosecompileDebugdebugfilenameopenscope。这些值被保留以允许更改模板引擎设置。

字符转义

在渲染时,EJS 会转义上下文值中的任何特殊字符,并用 HTML 实体代码替换它们。这是为了防止跨站脚本(XSS)攻击,恶意网络应用程序用户试图将 JavaScript 作为数据提交,希望当显示时,它会在其他用户的浏览器中执行。以下代码显示了 EJS 的转义操作:

const ejs = require('ejs');
const template = '<%= message %>';
const context = {message: "<script>alert('XSS attack!');</script>"};
console.log(ejs.render(template, context));

之前的代码显示了以下输出:

&lt;script&gt;alert('XSS attack!');&lt;/script&gt;

如果您信任模板中使用的数据,并且不想在 EJS 模板中转义上下文值,您可以在模板标签中使用<%-而不是<%=,如下面的代码所示:

const ejs = require('ejs');
const template = '<%- message %>';
const context = {
  message: "<script>alert('Trusted JavaScript!');</script>"
};
console.log(ejs.render(template, context));

注意,如果您不喜欢 EJS 用于指定标签的字符,您可以自定义它们,如下所示:

const ejs = require('ejs');
ejs.delimiter = '$'
const template = '<$= message $>';
const context = { message: 'Hello template!' };
console.log(ejs.render(template, context));

现在您已经了解了 EJS 的基础知识,让我们看看一些更详细的示例。

7.2.2。将 EJS 集成到您的应用程序中

由于将模板存储在与应用程序代码相同的文件中很不方便,这样做还会使你的代码变得杂乱,我们将向您展示如何使用 Node 的文件系统 API 从单独的文件中读取它们。

移动到工作目录,创建一个名为 app.js 的文件,包含以下列表中的代码。

列表 7.5。在文件中存储模板代码

图片

接下来,创建一个名为 templates 的子目录。您将在此目录中保存模板。在 templates 目录中创建一个名为 students.ejs 的文件。将以下列表中的代码输入到 templates/students.ejs 中。

列表 7.6。渲染学生数组的 EJS 模板
<% if (students.length) { %>
  <ul>
    <% students.forEach((student) => { %>
      <li><%= student.name %> (<%= student.age %>)</li>
    <% }) %>
  </ul>
<% } %>
缓存 EJS 模板

EJS 支持可选的内存缓存模板函数:在解析您的模板文件一次后,EJS 将存储由解析创建的函数。渲染缓存的模板将更快,因为可以跳过解析步骤。

如果您在进行 Node 网络应用程序的初始开发,并且希望立即看到您对模板文件所做的任何更改,请不要启用缓存。但如果是将应用程序部署到生产环境,启用缓存是一个快速、简单的方法。缓存是通过NODE_ENV环境变量条件性启用的。

要尝试缓存,将上一个示例中对 EJS 的render函数的调用更改为以下内容:

const cache = process.env.NODE_ENV === 'production';
const output = ejs.render(
  template,
  { students, cache, filename }
);

注意,filename选项不一定是文件;您可以使用一个唯一值来标识您正在渲染的任何模板。

现在您已经学习了如何将 EJS 集成到 Node 应用程序中,让我们看看 EJS 可以以不同的方式使用:在网页浏览器中。

7.2.3。在客户端应用程序中使用 EJS

要在客户端使用 EJS,您首先需要将 EJS 引擎下载到工作目录,如下面的命令所示:

cd /your/working/directory
curl -O https://raw.githubusercontent.com/tj/ejs/master/lib/ejs.js

下载 ejs.js 文件后,您可以在客户端代码中使用 EJS。以下列表显示了 EJS 的一个简单客户端应用程序。如果您将此文件保存为 index.html,您应该能够在浏览器中打开它以查看结果。

列表 7.7. 使用 EJS 向客户端添加模板功能

图片

您现在已经学会了如何使用功能齐全的 Node 模板引擎,现在是时候看看 Hogan 模板引擎了,它故意限制了模板代码可用的功能。

7.3. 使用 Hogan 与 Mustache 模板语言

Hogan.js (github.com/twitter/hogan.js) 是由 Twitter 为其模板需求创建的一个模板引擎。Hogan 是流行的 Mustache (mustache.github.com/) 模板语言标准的实现,该标准由 GitHub 的 Chris Wanstrath 创建。

Mustache 对模板采用了一种极简主义的方法。与 EJS 不同,Mustache 标准故意不包括条件逻辑,或除了转义内容以防止 XSS 攻击之外,没有其他内置的内容过滤功能。Mustache 倡导模板代码应尽可能简单。

在本节中,您将学习

  • 如何在您的应用程序中创建和实现 Mustache 模板

  • 如何使用 Mustache 标准中的各种模板标签

  • 如何使用部分来组织模板

  • 如何使用您自己的定界符和其他选项微调 Hogan

让我们看看 Hogan 提供的替代模板方法。

7.3.1. 创建模板

要在应用程序中使用 Hogan,或尝试本节中的演示,您需要在应用程序目录(ch07-templates/hogan-snippet)中安装 Hogan。您可以通过在命令行中输入以下命令来完成此操作:

npm i --save hogan.js

以下是一个使用 Hogan 在上下文中渲染简单模板的 Node 的裸骨示例。运行它将输出文本Hello template!

const hogan = require('hogan.js');
const templateSource = '{{message}}';
const context = { message: 'Hello template!' };
const template = hogan.compile(templateSource);
console.log(template.render(context));

现在您已经知道了如何使用 Hogan 处理 Mustache 模板,让我们看看 Mustache 支持哪些标签。

7.3.2. 使用 Mustache 标签

Mustache 标签在概念上类似于 EJS 的标签。Mustache 标签作为变量值的占位符,指示需要迭代的位置,并允许您增强 Mustache 的功能并在模板中添加注释。

显示简单值

要在 Mustache 模板中显示上下文值,请将值的名称包含在双大括号中。在 Mustache 社区中,大括号被称为胡子。如果您想显示上下文项name的值,例如,您可以使用 Hogan 标签{{name}}

与大多数模板引擎一样,Hogan 默认通过转义内容来防止 XSS 攻击。但要在 Hogan 中显示未转义值,您可以通过添加第三个胡子或在大括号前加上上下文项的名称来实现。使用之前的name示例,您可以通过使用{{{name}}}{{&name}}标签格式来未转义地显示上下文值。

如果您想在 Mustache 模板中添加注释,可以使用此格式:{{! This is a comment }}

部分:遍历多个值

虽然 Hogan 不允许在模板中包含逻辑,但它确实包含了一种优雅的方法,通过使用 Mustache 的部分来迭代上下文项中的多个值。例如,以下上下文包含一个包含值的数组:

const context = {
  students: [
    { name: 'Jane Narwhal', age: 21 },
    { name: 'Rick LaRue', age: 26 }
  ]
};

如果您想创建一个模板,该模板显示每个学生都在单独的 HTML 段落中,输出类似于以下内容,使用 Hogan 模板是一个简单直接的任务:

<p>Name: Jane Narwhal, Age: 21 years old</p>
<p>Name: Rick LaRue, Age: 26 years old</p>

以下模板生成了所需的 HTML:

{{#students}}
  <p>Name: {{name}}, Age: {{age}} years old</p>
{{/students}}
倒置部分:不存在值时的默认 HTML

如果上下文数据中students项的值不是数组怎么办?如果值是一个单独的对象,例如,模板将显示它。但是,如果对应项的值是未定义的、false 的,或者是一个空数组,则部分不会显示。

如果您希望模板输出一个消息,指示某个部分不存在值,Hogan 支持 Mustache 所说的倒置部分。以下模板代码,如果添加到之前的学生显示模板中,当上下文中不存在学生数据时将显示一条消息:

{{^students}}
  <p>No students found.</p>
{{/students}}
部分 lambda:部分块中的自定义功能

为了允许开发者增强 Mustache 的功能,Mustache 标准允许您定义处理模板内容的函数调用的部分标签,而不是迭代数组。这被称为部分 lambda

列表 7.8 展示了使用部分 lambda 在渲染模板时添加 Markdown 支持的一个示例。注意,该示例使用了 github-flavored-markdown 模块,您可以通过在命令行中输入npm install github-flavored-markdown --dev来安装它。如果您正在使用本书的源代码,请从ch07-templates/listing7_8运行npm install以运行示例。

在以下列表中,模板中的**Name**在通过由部分 lambda 逻辑调用的 Markdown 解析器渲染时,会被转换为<strong>Name</strong>

列表 7.8. 在 Hogan 中使用 lambda

部分 lambda 允许您轻松实现诸如缓存和翻译机制等特性。

部分模板:在其他模板中重用模板

在编写模板时,您希望避免在多个模板中不必要地重复相同的代码。避免这种情况的一种方法是通过创建部分模板。部分模板是作为构建块使用的模板,它们包含在其他模板中。部分模板的另一个用途是将复杂的模板分解成更简单的模板。

例如,以下列表使用部分模板将用于显示学生数据的模板代码与主模板分开。

列表 7.9. 在 Hogan 中使用部分模板

7.3.3. 精调 Hogan

Hogan 相对简单易用——在您学会了其标签词汇后,您应该能够顺利使用。在使用过程中,您可能只需要调整几个选项。

如果你不喜欢 Mustache 样式的花括号,你可以通过传递一个选项来覆盖 Hogan 使用的分隔符,将compile方法传递给选项。以下示例显示了使用 EJS 样式的分隔符在 Hogan 中进行编译:

hogan.compile(text, { delimiters: '<% %>' });

除了 Mustache 之外,还有其他模板语言可供选择。其中一种尝试尽可能消除 HTML 噪声的语言是 Pug。

7.4. 使用 Pug 进行模板化

Pug(pugjs.org),以前称为 Jade,提供了一种指定 HTML 的替代方法。它是 Express 中的默认模板引擎。Pug 与其他大多数模板系统的主要区别在于使用有意义的空白。在 Pug 中创建模板时,你使用缩进来表示 HTML 标签嵌套。HTML 标签也不必显式关闭,这消除了意外提前关闭标签或根本不关闭标签的问题。使用缩进还可以使模板在视觉上不那么密集,更容易维护。

为了快速了解这个功能的工作原理,让我们看看如何表示以下 HTML 片段:

<html>
  <head>
    <title>Welcome</title>
  </head>
  <body>
    <div id="main" class="content">
      <strong>"Hello world!"</strong>
    </div>
  </body>
</html>

以下 Pug 模板可以表示这个 HTML:

html
  head
    title Welcome
  body
    div.content#main
      strong "Hello world!"

与 EJS 一样,Pug 允许你在服务器或客户端嵌入 JavaScript,但你还可以使用 Pug 提供的一些额外功能,例如支持模板继承和混入。混入允许你轻松定义可重用的迷你模板,以表示常用视觉元素(如项目列表和框)所使用的 HTML。混入在概念上类似于你在上一节中学到的 Hogan.js 部分。模板继承使得将渲染单个 HTML 页面所需的 Pug 模板组织到多个文件中变得容易。你将在本节后面详细了解这些功能。

要在 Node 应用程序目录中安装 Pug,请在命令行中输入以下内容:

npm install pug --save

在本节中,你将学习

  • Pug 基础知识,例如指定类名、属性和块扩展

  • 如何通过使用内置关键字为你的 Pug 模板添加逻辑

  • 如何通过继承、块和混入来组织你的模板

要开始,让我们看看 Pug 的使用基础和语法。

7.4.1. Pug 基础知识

Pug 使用与 HTML 相同的标签名,但它允许你省略开闭的<>字符,而是使用缩进来表示标签嵌套。一个标签可以通过添加.<classname>与一个或多个 CSS 类相关联。一个应用了contentsidebar类的div元素将表示如下:

div.content.sidebar

CSS ID 通过在标签中添加#<ID>来分配。你可以在上一个示例中使用以下 Pug 表示法添加featured_content CSS ID:

div.content.sidebar#featured_content
使用 div 标签简写

由于div标签在 HTML 中常用,Pug 提供了一种简写的方式来指定它。以下示例渲染的 HTML 与上一个示例相同:

.content.sidebar#featured_content

现在你已经知道了如何指定 HTML 标签及其 CSS 类和 ID,让我们看看如何指定 HTML 标签属性。

指定标签属性

您可以通过将属性放在括号内来指定标签属性,用逗号分隔每个属性的指定。您可以使用以下 Pug 表示法指定一个将在新标签页中打开的超链接:

a(href='http://nodejs.org', target='_blank')

由于标签属性的指定可能导致 Pug 中出现长行,模板引擎为您提供了某些灵活性。以下 Pug 是有效的,并且与上一个示例等价:

a(href='http://nodejs.org',
  target='_blank')

您还可以指定不需要值的属性。以下 Pug 示例显示了包含预选select元素的 HTML 表单的指定:

strong Select your favorite food:
form
  select
    option(value='Cheese') Cheese
    option(value='Tofu', selected) Tofu
Specifying tag content

在前面的代码片段中,您还可以看到标签内容的示例:strong标签后的Select your favorite food:;第一个option标签后的Cheese;以及第二个option标签后的Tofu

这是指定 Pug 中标签内容的标准方式,但并非唯一方式。尽管这种风格非常适合短内容片段,但如果标签内容较长,可能会导致 Pug 模板中出现过长行。幸运的是,如下例所示,Pug 允许您使用|字符指定标签内容:

textarea
  | This is some default text
  | that the user should be
  | provided with.

如果 HTML 标签,如stylescript标签,只接受文本(意味着它不允许嵌套 HTML 元素),则可以完全省略|字符,如下例所示:

style
  h1 {
    font-size: 6em;
    color: #9DFF0C;
  }

有两种单独的方式来表达长标签内容和短标签内容,这有助于您保持 Pug 模板的优雅。Pug 还支持一种称为块扩展的替代嵌套表示方法。

使用块扩展保持组织结构

Pug 通常通过缩进来表示嵌套,但有时缩进会导致多余的空白。例如,以下是一个使用缩进来定义简单链接列表的 Pug 模板:

ul
  li
    a(href='http://nodejs.org/') Node.js homepage
  li
    a(href='http://npmjs.org/') NPM homepage
  li
    a(href='http://nodebits.org/') Nodebits blog

使用 Pug 块扩展表达上一个示例的更紧凑方式。使用块扩展,您在标签后添加一个冒号来指示嵌套。以下代码生成的输出与上一个列表相同,但只有四行而不是七行:

ul
  li: a(href='http://nodejs.org/') Node.js homepage
  li: a(href='http://npmjs.org/') NPM homepage
  li: a(href='http://nodebits.org/') Nodebits blog

现在您已经了解了如何使用 Pug 表示标记,让我们看看如何将 Pug 集成到您的 Web 应用程序中。

在 Pug 模板中嵌入数据

数据以与 EJS 相同的基本方式传递给 Pug 引擎。模板首先编译成一个函数,然后通过上下文调用该函数以渲染 HTML 输出。以下是一个示例:

const pug = require('pug');
const template = 'strong #{message}';
const context = { message: 'Hello template!' };
const fn = pug.compile(template);
console.log(fn(context));

在这里,模板中的#{message}指定了一个占位符,该占位符将由上下文值替换。

上下文值也可以用来为属性提供值。以下示例渲染了<a href="http://google.com"></a>

const pug = require('pug');
const template = 'a(href = url)';
const context = { url: 'http://google.com' };
const fn = pug.compile(template);
console.log(fn(context));

现在您已经了解了如何使用 Pug 表示 HTML,以及如何向 Pug 模板提供应用程序数据,让我们看看如何在 Pug 中嵌入逻辑。

7.4.2. Pug 模板中的逻辑

在你为 Pug 模板提供应用程序数据后,你需要逻辑来处理这些数据。Pug 允许你直接将 JavaScript 代码行嵌入到模板中,这就是你在模板中定义逻辑的方式。如 if 语句、for 循环和 var 声明等代码是常见的。在我们深入细节之前,这里有一个模板渲染联系名单的例子,以给你一个在实际应用中使用 Pug 逻辑的感觉:

h3.contacts-header My Contacts
if contacts.length
  each contact in contacts
    - var fullName = contact.firstName + ' ' + contact.lastName
    .contact-box
      p fullName
      if contact.isEditable
        p: a(href='/edit/+contact.id) Edit Record
      p
        case contact.status
          when 'Active'
            strong User is active in the system
          when 'Inactive'
            em User is inactive
          when 'Pending'
            | User has a pending invitation
else
  p You currently do not have any contacts

让我们先看看 Pug 在嵌入 JavaScript 代码时处理输出的各种方式。

在 Pug 模板中使用 JavaScript

在一行 JavaScript 逻辑前加上 - 将会执行 JavaScript,但不会将代码返回的任何值包含在模板的输出中。在 JavaScript 逻辑前加上 = 将会包含代码返回的值,并通过转义来防止 XSS 攻击。但如果你的 JavaScript 生成的代码不需要转义,你可以用 != 前缀。表 7.1(Table 7.1)总结了这些前缀产生的输出。

表 7.1. 用于在 Pug 中嵌入 JavaScript 的前缀
前缀 输出
= 转义输出(用于不受信任或不预测的值,XSS 安全)
!= 无转义输出(用于受信任或可预测的值)
- 无输出

Pug 包含了一些常用的条件性和迭代性语句,可以不使用前缀编写:ifelsecasewhendefaultuntilwhileeachunless

Pug 还允许你定义变量。以下展示了在 Pug 中赋值两种等效的方式:

- count = 0
count = 0

无前缀的语句没有输出,就像之前讨论的 - 前缀一样。

遍历对象和数组

在上下文中传递的值在 Pug 中的 JavaScript 中是可访问的。在下一个例子中,你将从文件中读取一个 Pug 模板,并将包含一些你打算在数组中显示的消息的上下文传递给 Pug 模板:

const pug = require('pug');
const fs = require('fs');
const template = fs.readFileSync('./template.pug');
const context = { messages: [
  'You have logged in successfully.',
  'Welcome back!'
]};
const fn = pug.compile(template);
console.log(fn(context));

Pug 模板包含以下内容:

- messages.forEach(message => {
  p= message
- })

最终的 HTML 输出如下所示:

<p>You have logged in successfully.</p><p>Welcome back!</p>

Pug 还支持一种非 JavaScript 形式的迭代:each 语句,它允许你轻松地遍历数组和对象属性。

以下与上一个例子等效,但使用了 each

each message in messages
  p= message

你可以通过轻微的变化来遍历对象属性,如下所示:

each value, key in post
  div
    strong #{key}
    p value
条件渲染模板代码

有时模板需要根据数据的值来决定如何显示数据。下一个例子演示了一个条件,大约一半的时间,script 标签会被输出为 HTML:

- n = Math.round(Math.random() * 1) + 1
- if (n == 1) {
  script
    alert('You win!');
- }

条件语句也可以通过使用更简洁的替代形式在 Pug 中编写:

- n = Math.round(Math.random() * 1) + 1
  if n == 1
    script
      alert('You win!');

如果你正在编写一个否定条件,例如 if (n != 1),你可以使用 Pug 的 unless 关键字:

- n = Math.round(Math.random() * 1) + 1
  unless n == 1
    script
      alert('You win!');
在 Pug 中使用 case 语句

Pug 还支持类似于 switch 的非 JavaScript 形式的条件语句:case 语句,它允许你根据各种模板场景指定一个结果。

以下示例模板展示了如何使用 case 语句以三种方式显示博客搜索的结果。如果没有找到任何内容,会显示一条消息。如果找到一个博客帖子,会详细显示。如果找到多个博客帖子,会使用 each 语句遍历帖子,显示它们的标题:

case results.length
  when 0
    p No results found.
  when 1
    p= results[0].content
  default
    each result in results
      p= result.title

7.4.3. 组织 Pug 模板

在定义了模板之后,接下来你需要了解如何组织它们。与应用程序逻辑一样,你不想让你的模板文件过大。单个模板文件应该对应一个概念性的构建块:一个页面、一个侧边栏或博客帖子内容等。

在本节中,你将了解一些机制,这些机制允许模板文件协同工作以渲染内容:

  • 使用模板继承结构化多个模板

  • 通过使用块的前缀/后缀实现布局

  • 包含模板

  • 使用混入重用模板逻辑

让我们从查看 Pug 中的模板继承开始。

使用模板继承结构化多个模板

模板继承是结构化多个模板的一种方法。从概念上讲,它将模板视为面向对象编程范式中的类。一个模板可以扩展另一个,而这个模板又可以扩展另一个。你可以使用任何有意义的继承级别。

作为简单的示例,让我们看看如何使用模板继承提供一个基本的 HTML 包装器,你可以用它来包装页面内容。在一个工作目录中,创建一个名为 templates 的文件夹,你将在其中放置示例的 Pug 文件。对于页面模板,你将创建一个名为 layout.pug 的文件,包含以下 Pug 代码:

html
  head
    block title
  body
    block content

layout.pug 模板包含了 HTML 页面的基本定义以及两个 。块在模板继承中用于定义子模板可以提供内容的位置。在 layout.pug 中,有一个 title 块,允许子模板设置标题,还有一个 content 块,允许子模板设置要在页面上显示的内容。

接下来,在你的工作目录的模板目录中,创建一个名为 page.pug 的文件。这个模板文件将填充 titlecontent 块:

extends layout
block title
  title Messages
block content
  each message in messages
    p= message

最后,添加以下列表中的逻辑(本节中较早示例的修改),这将显示模板结果,展示继承的实际应用。

列表 7.10. 模板继承的实际应用
const pug = require('pug');
const fs = require('fs');
const templateFile = './templates/page.pug';
const iterTemplate = fs.readFileSync(templateFile);
const context = { messages: [
  'You have logged in successfully.',
  'Welcome back!'
]};
const iterFn = pug.compile(
  iterTemplate,
  { filename: templateFile }
);
console.log(iterFn(context));

现在,让我们看看另一个模板继承功能:块的前缀和后缀。

通过使用块的前缀/后缀实现布局

在前面的示例中,layout.pug 中的块不包含任何内容,这使得在 page.pug 模板中设置内容变得简单。但如果继承模板中的块确实包含内容,则此内容可以通过子模板使用块前缀和后缀来构建,而不是替换。这允许你定义常见内容并添加到其中,而不是替换它。

以下 layout.pug 模板包含一个额外的块,名为 scripts,其中包含内容——一个加载 jQuery JavaScript 库的 script 标签:

html
  head
    - const baseUrl = "http://ajax.googleapis.com/ajax/libs/jqueryui/1.8/"
    block title
    block style
    block scripts
  body
    block content

如果你想要 page.pug 模板额外加载 jQuery UI 库,你可以通过以下列表中的模板来实现。

列表 7.11. 使用 block append 添加额外的 JavaScript 文件

模板继承不是集成多个模板的唯一方法。你还可以使用 include Pug 命令。

模板包含

组织模板的另一个工具是 Pug 的 include 命令。此命令结合了另一个模板的内容。如果你将 include footer 行添加到前面示例中的 layout.pug 模板中,你最终会得到以下模板:

html
  head
    block title
    block style
    block scripts
      script(src='//ajax.googleapis.com/ajax/libs/jquery/1.8/jquery.js')
  body
    block content
    include footer

此模板在 layout.pug 的渲染输出中包含了名为 footer.pug 的模板的内容,如图 7.3 所示。[#ch07fig03]

图 7.3. Pug 的 include 机制提供了一个简单的方法,在渲染期间将一个模板的内容包含到另一个模板中。

这可以用来向 layout.pug 添加有关网站或设计元素的信息。你还可以通过指定文件扩展名(例如,include twitter_widget.html)来包含非 Pug 文件。

使用混合重用模板逻辑

虽然 Pug 的 include 命令对于引入先前创建的代码块很有用,但它并不是创建可在页面和应用程序之间共享的可重用功能库的理想选择。为此,Pug 提供了 mixin 命令,它允许你定义可重用的 Pug 片段。

Pug 混合类似于 JavaScript 函数。混合可以像函数一样接受参数,并且可以使用这些参数生成 Pug 代码。

假设,例如,你的应用程序处理类似于以下的数据结构:

const students = [
  { name: 'Rick LaRue', age: 23 },
  { name: 'Sarah Cathands', age: 25 },
  { name: 'Bob Dobbs', age: 37 }
];

如果你想要定义一种方法来输出一个由每个对象的给定属性派生的 HTML 列表,你可以定义一个如以下所示的混合来完成此操作:

mixin list_object_property(objects, property)
  ul
    each object in objects
      li= object[property]

然后,你可以使用以下 Pug 代码行来使用混合显示数据:

mixin list_object_property(students, 'name')

通过使用模板继承、include 语句和混合,你可以轻松地重用表示标记,并防止你的模板文件变得比必要的还要大。

7.5. 概述

  • 模板引擎有助于保持应用程序逻辑和表示的有序性。

  • Node 有几个流行的模板引擎,包括 EJS、Hogan.js 和 Pug。

  • EJS 支持简单的控制流和转义或未转义的插值。

  • Hogan.js 是一个简单的模板引擎,不支持控制流,但支持 Mustache 标准。

  • Pug 是一个更复杂的模板语言,可以输出 HTML,但不使用尖括号。

  • Pug 依赖于空白字符来嵌入标签。

第八章. 存储应用程序数据

本章涵盖

  • 关系型数据库:PostgreSQL

  • NoSQL 数据库:MongoDB

  • ACID 类别

  • 托管云数据库和存储服务

Node.js 为各种开发者提供了一系列极其多样化的需求。没有单一的数据库或存储解决方案能够解决 Node 处理的使用案例数量。本章提供了数据存储可能性的广泛概述,以及一些重要的高级概念和术语。

8.1. 关系型数据库

在网络的大部分历史中,关系型数据库一直是应用程序数据存储的主导选择。这个主题在其他许多文本和大学课程中已经详细讨论过,所以我们本章不会在这个主题上花费太多时间进行阐述。

建立在关系代数和集合理论数学思想之上的关系型数据库自 20 世纪 70 年代以来一直存在。模式指定了各种数据类型的格式以及这些类型之间的关系。例如,如果您正在构建一个社交网络,您可能有UserPost数据类型,并定义UserPost之间的一对多关系。然后使用结构化查询语言(SQL),您可以对此数据进行查询,例如,“给我所有属于 ID 为 123 的用户的所有帖子”,或者用 SQL 表示:SELECT * FROM post WHERE user_id=123

8.2. PostgreSQL

MySQL 和 PostgreSQL(Postgres)都是 Node 应用程序中流行的关系型数据库选择。关系型数据库之间的差异主要是美学上的,所以本节的大部分内容也适用于在 Node 中使用其他关系型数据库,如 MySQL。首先,让我们看看如何在您的开发机上安装 Postgres。

8.2.1. 执行安装和设置

Postgres 需要在您的系统上安装。您不能简单地使用 npm 安装它。安装说明因平台而异。在 macOS 上,安装就像这样:

brew update
brew install postgres

如果您已经安装了 Postgres,可能会遇到升级问题。按照您平台的说明迁移现有数据库,或者清除数据库目录:

# WARNING: will delete existing postgres configuration & data
rm –rf /usr/local/var/postgres

然后初始化并启动 Postgres:

initdb -D /usr/local/var/postgres
pg_ctl -D /usr/local/var/postgres -l logfile start

这将启动 Postgres 守护进程。每次您启动计算机时都需要启动此守护进程。您可能希望在启动时自动启动 Postgres 守护进程,许多在线指南详细说明了针对您特定操作系统的此过程。

类似地,大多数 Linux 系统都有用于安装 Postgres 的软件包。对于 Windows,您应从 postgresql.org 下载安装程序(www.postgresql.org/download/windows/))。

Postgres 随安装附带了一些命令行管理工具。你可能想通过阅读它们的 man 页面来熟悉其中的一些。

8.2.2. 创建数据库

在 Postgres 守护进程运行后,你需要创建一个数据库来使用。这只需要做一次。最简单的方法是使用命令行中的 createdb。在这里,你创建一个名为 articles 的数据库:

createdb articles

如果成功,则没有输出。如果已存在具有此名称的数据库,则此命令不会执行任何操作并报告失败。

尽管可能配置了多个数据库,但大多数应用程序一次只连接到一个数据库,这取决于数据库运行的 环境。许多应用程序至少有两个环境:开发和生产。

要从现有数据库中删除所有数据,你可以从终端运行 dropdb 命令,并将数据库名称作为参数传递:

dropdb articles

在再次使用此数据库之前,你需要运行 createdb

8.2.3. 从 Node 连接到 Postgres

从 Node 连接到 Postgres 最受欢迎的包是 pg。你可以使用 npm 安装 pg

npm install pg --save

当 Postgres 服务器运行,数据库已创建,并且已安装 pg 包时,你就可以开始从 Node 使用数据库了。在你可以对服务器发出任何命令之前,你需要建立与它的连接,如下一列表所示。

列表 8.1. 连接到数据库

pg.Client 和其他方法的全面文档可以在 GitHub 上 pg 包的 wiki 页面上找到:github.com/brianc/node-postgres/wiki

8.2.4. 定义表

为了在 PostgreSQL 中存储数据,你首先需要定义一些表以及要存储的数据的形状,如以下列表所示(书中源代码的 ch08-databases/listing8_3)。

列表 8.2. 定义模式
db.query(`
  CREATE TABLE IF NOT EXISTS snippets (
    id SERIAL,
    PRIMARY KEY(id),
    body text
  );
`, (err, result) => {
  if (err) throw err;
  console.log('Created table "snippets"');
  db.end();
});

8.2.5. 插入数据

在你的表定义完成后,你可以通过使用 INSERT 查询将其中的数据插入,如下一列表所示。如果你没有指定 id 值,PostgreSQL 将为你选择一个 ID。要了解为特定行选择了哪个 ID,你可以在查询中附加 RETURNING id,它将出现在传递给回调函数的结果行的行中。

列表 8.3. 插入数据
const body = 'hello world';
db.query(`
  INSERT INTO snippets (body) VALUES (
    '${body}'
  )
  RETURNING id
`, (err, result) => {
  if (err) throw err;
  const id = result.rows[0].id;
  console.log('Inserted row with id %s', id);
  db.query(`
    INSERT INTO snippets (body) VALUES (
      '${body}'
    )
    RETURNING id
  `, () => {
    if (err) throw err;
    const id = result.rows[0].id;
    console.log('Inserted row with id %s', id);
  });
});

8.2.6. 更新数据

数据插入后,你可以通过使用 UPDATE 查询来更新数据,如下一列表所示。受影响行数将在查询结果的 rowCount 属性中可用。你可以在这个列表的完整示例中找到 ch08-databases/listing8_4。

列表 8.4. 更新数据
const id = 1;
const body = 'greetings, world';
db.query(`
  UPDATE snippets SET (body) = (
    '${body}'
  ) WHERE id=${id};
`, (err, result) => {
  if (err) throw err;
  console.log('Updated %s rows.', result.rowCount);
});

8.2.7. 查询数据

关系型数据库最强大的功能之一是能够对你的数据进行复杂的即席查询。查询是通过使用 SELECT 语句来执行的,以下列表展示了这一点的最简单示例。

列表 8.5. 查询数据
db.query(`
  SELECT * FROM snippets ORDER BY id
`, (err, result) => {
  if (err) throw err;
  console.log(result.rows);
});

8.3. Knex

许多开发者更喜欢不在他们的应用程序中直接处理 SQL 语句,而是使用抽象层。这是可以理解的,因为将字符串连接到 SQL 语句可能是一个笨拙的过程,而且查询可能会变得难以理解和维护。这对于 JavaScript 来说尤其如此,因为直到 ES2015 引入了模板字符串(请参阅 developer.mozilla.org/en/docs/Web/JavaScript/Reference/Template_literals),JavaScript 都没有表示多行字符串的语法。图 8.1 显示了 Knex 的统计数据,包括下载次数,这证明了它的流行。

图 8.1. Knex 的使用统计

Knex 是一个 Node 包,它实现了一种轻量级的 SQL 抽象,称为 查询构建器。查询构建器通过一个声明式 API 构建 SQL 字符串,该 API 与生成的 SQL 非常相似。Knex API 直观且不出所料:

knex({ client: 'mysql' })
  .select()
  .from('users')
  .where({ id: '123' })
  .toSQL();

这在 MySQL 方言的 SQL 中生成一个参数化 SQL 查询:

select * from `users` where `id` = ?

8.3.1. 数据库的 jQuery

尽管 ANSI 和 ISO SQL 标准自 1980 年代中期以来就已经存在,但大多数数据库仍然使用自己的 SQL 方言。PostgreSQL 是一个值得注意的例外;它自豪地宣称自己遵循 SQL:2008 标准。查询构建器可以标准化不同 SQL 方言之间的差异,为多种技术提供统一的 SQL 生成接口。这对经常在多种数据库技术之间切换的团队来说具有明显的优势。

Knex.js 目前支持以下数据库:

  • PostgreSQL

  • MSSQL

  • MySQL

  • MariaDB

  • SQLite3

  • Oracle

表 8.1 比较了 Knex 根据所选数据库生成插入语句的方式。

表 8.1. 比较 Knex 生成的各种数据库的 SQL
数据库 SQL
PostgreSQL、SQLite 和 Oracle insert into "users" ("name", "age") values (?, ?)
MySQL 和 MariaDB insert into users (name, age) values (?, ?)
Microsoft SQL Server insert into [users] ([name], [age]) values (?, ?)

Knex 支持 promises 和 Node 风格的回调。

8.3.2. 使用 Knex 连接和运行查询

与许多其他查询构建器不同,Knex 还可以为你选择的数据驱动程序连接和执行查询:

db('articles')
  .select('title')
  .where({ title: 'Today's News' })
  .then(articles => {
    console.log(articles);
  });

Knex 查询默认返回承诺,但也可以通过 .asCallback 支持 Node 的回调约定:

db('articles')
  .select('title')
  .where({ title: 'Today's News' })
  .asCallback((err, articles) => {
    if (err) throw err;
    console.log(articles);
  });

在 第三章 中,你通过直接使用 sqlite3 包与 SQLite 数据库交互。可以使用 Knex 重写此 API。要运行此示例,首先确保已从 npm 安装了 knex 和 sqlite3 包:

npm install knex@~0.12.0 sqlite3@~3.1.0 --save

下一个列表使用 sqlite3 实现了一个简单的 Article 模型。将此文件保存为 db.js;你将在 列表 8.7 中使用它来与数据库交互。

列表 8.6. 使用 Knex 连接和查询 sqlite3

现在,可以使用 db.Article 添加 Article 条目。以下列表可以与上一个列表一起使用来创建文章并打印它们。有关完整示例,请参阅 ch08-databases/listing8_7/index.js。

列表 8.7. 与 Knex 驱动的 API 交互
db().then(() => {
  db.Article.create({
    title: 'my article',
    content: 'article content'
  }).then(() => {
    db.Article.all().then(articles => {
      console.log(articles);
      process.exit();
    });
  });
})
.catch(err => { throw err });

SQLite 需要最少的配置:您不需要启动服务器守护进程或从应用程序外部创建数据库。SQLite 将所有内容写入单个文件。如果您运行前面的代码,您将在当前目录中找到一个 articles.sqlite 文件。擦除 SQLite 数据库就像删除这个文件一样简单:

rm articles.sqlite

SQLite 也支持内存模式,这种模式可以完全避免写入磁盘。这种模式通常用于减少自动化测试的运行时间。您可以通过使用特殊的 :memory: 文件名来配置内存模式。打开多个连接到 :memory: 文件会为每个连接提供其自己的私有数据库:

const db = knex({
  client: 'sqlite3',
  connection: {
    filename: ':memory:'
  },
  useNullAsDefault: true
});

8.3.3. 交换数据库后端

由于您正在使用 Knex,将 列表 8.6 和 8.7 更改为使用 PostgreSQL 而不是 sqlite3 是非常简单的。Knex 需要安装 pg 包以与 PostgreSQL 服务器通信,您需要安装并运行它。将 pg 包安装到包含 列表 8.7 的文件夹中(书中的代码为 ch08-databases/listing8_7),并记得使用 PostgreSQL 的 createdb 命令行工具创建适当的数据库:

npm install pg --save
createdb articles

使用此新数据库所需进行的唯一代码更改是在 Knex 配置中;否则,消费者 API 和使用方式相同:

const db = knex({
  client: 'pg',
  connection: {
    database: 'articles'
  }
})

注意,在实际场景中,您还需要迁移任何现有数据。

8.3.4. 谨防抽象泄漏

查询构建器可以标准化 SQL 语法,但对标准化行为所能做的很少。某些功能仅在特定数据库中受支持,并且某些数据库可能会在相同的查询下表现出完全不同的行为。例如,以下是在使用 Knex 定义主键时的两种方法:

  • table.increments('id').primary();

  • table.integer('id').primary();

在 SQLite3 中,这两种选项都能按预期工作,但第二种选项在 PostgreSQL 中插入新记录时会导致错误:

"null value in column "id" violates not-null constraint"

null 作为主键插入 SQLite 的值将被分配一个自动递增的 ID,无论主键列是否明确配置为自动递增。另一方面,PostgreSQL 需要显式定义自动递增列。数据库之间存在许多这样的行为差异,并且一些差异可能很微妙,没有明显的错误。如果您确实选择过渡到不同的数据库,则需要彻底测试。

8.4. MySQL 与 PostgreSQL

MySQL 和 PostgreSQL 都是成熟且强大的数据库,对于许多项目来说,在选择其中一个时与其他选择之间的差异可能很小。许多区别,直到项目需要扩展时才变得显著,存在于或位于面向应用程序开发者的接口边缘或下方。

详尽的关系型数据库比较通常超出了本书的范围,因为该主题很复杂。这里列出了一些显著的差异:

  • PostgreSQL 支持更丰富的数据类型,包括数组、JSON 和用户定义的类型。

  • PostgreSQL 内置全文搜索功能。

  • PostgreSQL 支持完整的 ANSI SQL:2008 标准。

  • PostgreSQL 的复制支持不如 MySQL 强大或经过实战检验。

  • MySQL 更老,拥有更大的社区。MySQL 有更多兼容的工具和资源。

  • MySQL 社区由于细微的分支(例如,来自 Facebook、Google、Twitter 等的 MariaDB 和 WebScaleSQL)而更加碎片化。

  • MySQL 的可插拔存储引擎可能会使其理解、管理和调优变得更加复杂。另一方面,这也可以被视为对性能进行更精细控制的机会。

MySQL 和 PostgreSQL 在不同类型的工作负载下表现出不同的性能特征,你的工作负载的微妙之处可能直到项目成熟才变得明显。

许多在线资源提供了关于关系型数据库之间更深入的比较:

你最初选择哪种关系型数据库不太可能是你项目成功的关键因素,所以不必过于担心这个决定。你可以稍后迁移到另一个数据库,但 Postgres 应该足够强大,能够提供你需要的几乎所有功能和可扩展性。但如果你有机会评估几个数据库,你应该熟悉 ACID 保证的概念。

8.5. ACID 保证

ACID 描述了数据库事务的一组理想属性:原子性、一致性、隔离性和持久性。这些术语的确切定义可能有所不同。一般来说,一个系统越严格地保证 ACID 属性,其性能妥协就越大。这种 ACID 分类是开发者快速沟通特定解决方案(如 NoSQL 系统中发现的)权衡的常见方式。

8.5.1. 原子性:事务要么完全成功,要么完全失败

一个原子事务不能部分执行:要么整个操作完成,要么数据库保持不变。例如,如果事务是要删除特定用户的全部评论,要么所有评论都会被删除,要么一个评论都不会被删除。不可能出现一些评论被删除而另一些没有被删除的情况。原子性即使在系统错误或电源故障的情况下也应该适用。"原子"在这里使用其原始意义,即不可分割

8.5.2. 一致性:约束始终得到执行

成功事务的完成必须维护系统定义的所有数据完整性约束。一些示例约束包括主键必须是唯一的,数据符合特定的模式,或者外键必须引用存在的实体。可能导致不一致状态的事务通常会导致事务失败,尽管一些小问题可能会自动解决;例如,将数据强制转换为正确的形状。这不要与 CAP 定理中的一致性(C)混淆,它指的是保证分布式存储中所有读者看到的数据视图的一致性。

8.5.3. 隔离:并发事务不干扰

隔离事务应该产生相同的结果,无论是并发执行还是顺序执行相同的交易。系统提供的隔离级别直接影响到其执行并发操作的能力。一个简单的隔离方案是使用全局锁,在整个事务期间锁定整个数据库,从而有效地按顺序处理所有事务。这提供了强大的隔离保证,但同时也非常低效:操作完全不同数据集的事务会被无谓地阻塞(例如,一个用户添加评论理想情况下不应该阻止另一个用户更新他们的个人资料)。在实践中,系统通过使用更细粒度和选择性的锁定方案(例如,按表、行或字段)提供各种隔离级别。更复杂的系统甚至可能会乐观地尝试以最小锁定并发执行所有事务,只有在检测到冲突的情况下才会通过使用越来越粗粒度的锁来重试事务。

8.5.4. 持久性:事务是永久的

事务的持久性是指其效果在重启、电源故障、系统错误甚至硬件故障后仍保证持续的程度。例如,使用 SQLite 内存模式的应用程序没有事务持久性;当进程退出时,所有数据都会丢失。另一方面,SQLite 将数据持久化到磁盘将具有良好的事务持久性,因为数据在机器重启后仍然持续存在。

这可能看起来是个显而易见的事情:只需将数据写入磁盘——然后,你就有持久的交易了。但磁盘 I/O 是应用程序可以执行的最慢的操作之一,并且可能会迅速成为应用程序中的瓶颈,即使在中等规模下也是如此。一些数据库提供了不同的持久性权衡,可以用来维持可接受的系统性能。

8.6. NoSQL

不适合关系模型的数据库的统称是NoSQL。今天,由于一些 NoSQL 数据库确实支持 SQL,因此 NoSQL 这个术语的含义更接近于非关系型或作为缩写词不仅 SQL

这里有一些可以被认为是 NoSQL 的范式和示例数据库的子集:

  • 键值/元组— DynamoDB, LevelDB, Redis, etcd, Riak, Aerospike, Berkeley DB

  • 图— Neo4J, OrientDB

  • 文档— CouchDB, MongoDB, Elastic(前身为 Elasticsearch)

  • 列— Cassandra, HBase

  • 时间序列— Graphite, InfluxDB, RRDtool

  • 多范式— Couchbase(文档数据库,键/值存储,分布式缓存)

对于 NoSQL 数据库的更完整列表,请参阅nosql-database.org/

如果你只使用过关系型数据库,那么理解 NoSQL 概念可能会有些困难,因为 NoSQL 的使用往往直接违反了已经确立的最佳实践:没有定义的架构。数据重复。约束宽松。NoSQL 系统承担了通常由数据库承担的责任,并将其置于应用领域。这一切可能看起来有些混乱。

通常,只有一小部分访问模式会为数据库创建大部分工作负载,例如生成应用程序着陆页的查询,其中需要检索多个领域对象。提高关系型数据库读取性能的常见技术是反规范化,其中领域查询被预处理并塑造成减少客户端消费所需读取次数的形式。

默认情况下,NoSQL 数据更频繁地使用反规范化。领域建模步骤可能完全被跳过。这可以阻止对数据模型进行过度设计,允许更快地执行更改,并导致整体上更简单、性能更好的设计。

8.7. 分布式数据库

应用程序可以通过增加运行它的机器的容量来垂直扩展,或者通过添加更多机器来水平扩展。垂直扩展通常是更简单的选项,但硬件的限制决定了单台机器可以扩展的范围。垂直扩展也往往会迅速变得昂贵。另一方面,水平扩展通过添加更多进程和机器来增加容量,具有更高的增长能力。这以在编排更多移动部件中的复杂性为代价。所有增长系统最终都会达到一个必须水平扩展的点。

分布式数据库从一开始就被设计为以横向扩展为基础。跨多台机器存储的数据通过消除任何单点故障来提高数据的持久性。许多关系型系统都有一些执行横向扩展的能力,形式为分片、主/从、主/主复制,尽管即使有这些能力,关系型系统也不是为了扩展到几百个节点以上而设计的。例如,MySQL 集群的上限是 255 个节点。另一方面,分布式数据库通过设计可以扩展到数千个节点。

8.8. MongoDB

MongoDB 是一种面向文档的、分布式数据库,在 Node 开发者中非常受欢迎。它是时尚的 MEAN 堆栈(MongoDB, Express, Angular, Node)中的 M,并且通常是人们开始使用 Node 时遇到的第一个数据库。图 8.2 显示了 mongodb 模块在 npm 上的流行程度。

图 8.2. MongoDB 的使用统计

图片

MongoDB 吸引了比其应得的更多批评和争议;尽管如此,它仍然是许多开发者的主要数据存储。MongoDB 在包括 Adobe、LinkedIn 和 eBay 在内的知名公司中有已知部署,甚至在欧洲核子研究组织(CERN)的大型强子对撞机的一个组件中也被使用。

MongoDB 数据库以无模式的 集合 存储文档。文档不需要有预定义的模式,单个集合中的文档也不需要共享相同的模式。这赋予了 MongoDB 很大的灵活性,尽管现在应用程序需要确保文档保持可预测的结构(保证一致性——ACID 中的 C)。

8.8.1. 执行安装和设置

MongoDB 需要安装到您的系统上。安装方式因平台而异。在 macOS 上,安装过程就像这样:

brew install mongodb

使用mongod可执行文件启动 MongoDB 服务器:

mongod --config /usr/local/etc/mongod.conf

最受欢迎的 MongoDB 驱动程序是 Christian Amor Kvalheim 的官方 mongodb 包:

npm install mongodb@².1.0 --save

Windows 用户应注意的是,驱动程序安装需要 msbuild.exe,该文件由 Microsoft Visual Studio 安装。

8.8.2. 连接到 MongoDB

在安装 mongodb 包并启动 mongod 服务器后,您可以从 Node 作为客户端连接,如下所示。

列表 8.8. 连接到 MongoDB
const { MongoClient } = require('mongodb');

MongoClient.connect('mongodb://localhost:27017/articles')
  .then(db  => {
    console.log('Client ready');
    db.close();
  }, console.error);

连接的成功处理程序会传递一个数据库客户端实例,所有数据库命令都从这个实例执行。

与数据库的大多数交互都是通过集合 API 进行的:

  • collection.insert(doc)—插入一个或多个文档

  • collection.find(query)—查找匹配查询的文档

  • collection.remove(query)—删除匹配查询的文档

  • collection.drop()—删除整个集合

  • collection.update(query)—更新匹配查询的文档

  • collection.count(query)—计算匹配查询的文档数量

find, insert, 和 delete 等操作根据操作的是单个还是多个值,有多种形式。例如:

  • collection.insertOne(doc)`—插入单个文档

  • collection.insertMany([doc1, doc2])—插入多个文档

  • collection.findOne(query)`—查找匹配查询的单个文档

  • collection.updateMany(query)—更新所有匹配查询的文档

8.8.3. 插入文档

collection.insertOne 将单个对象作为一个文档插入到集合中,如下一列表所示。成功处理程序传递一个包含操作状态元数据的对象。

列表 8.9. 插入文档

insertMany 调用类似,但它接受多个文档的数组。insertMany 响应将包含一个 insertedIds 数组,按文档提供的顺序排列,而不是单个 insertedId

8.8.4. 查询

从集合中读取文档的方法(如 find, update, 和 remove)接受一个查询参数,用于匹配文档。查询的最简单形式是一个对象,MongoDB 将使用该对象匹配具有相同结构和相同值的文档。例如,以下查询找到所有标题为“我喜欢蛋糕”的文章:

查询可以用来通过其唯一 _id 匹配对象:

collection.findOne({ _id: someID })

或者根据查询运算符进行匹配:

MongoDB 查询语言中存在许多查询运算符——例如:

  • $eq—等于特定值

  • $neq—不等于特定值

  • $in—在数组中

  • $nin—不在数组中

  • $lt, $lte, $gt, $gte—大于/小于或等于比较

  • $near—地理值接近某个坐标

  • $not, $and, $or, $nor—逻辑运算符

这些可以组合起来匹配几乎任何条件,并创建一个高度可读、复杂和表达性的查询语言。有关查询和查询操作器的更多信息,请参阅docs.mongodb.com/-manual/reference/operator/query/

下一列表展示了之前通过 MongoDB 实现的 Articles API 的示例,同时保持了几乎相同的外部接口。将此文件保存为 db.js(在本书的示例代码中为 listing8_10/db.js)。

列表 8.10. 使用 MongoDB 实现文章 API

以下代码片段展示了如何使用列表 8.10(示例代码中的 listing8_10/index.js):

const db = require('./db');

db().then(() => {
  db.Article.create({ title: 'An article!' }).then(() => {
    db.Article.all().then(articles => {
      console.log(articles);
      process.exit();
    });
  });
});

这使用来自列表 8.10 的一个承诺连接到数据库,然后使用 Articlecreate 方法创建一个文章。之后,它加载所有文章并将它们记录下来。

8.8.5. 使用 MongoDB 标识符

MongoDB 中的标识符以二进制 JSON(BSON)格式编码。文档上的_id属性是一个 JavaScript 对象,它包装了一个 BSON 格式的ObjectID值。MongoDB 使用 BSON 来表示内部文档和作为传输格式。BSON 比 JSON 更节省空间,并且可以更快地解析,这意味着使用更少的带宽进行更快的数据库交互。

BSON ObjectID不仅仅是一个随机的字节序列;它编码了关于 ID 在哪里以及何时生成的元数据。例如,ObjectID的前四个字节是一个时间戳。这消除了在文档中存储createdAt时间戳属性的需求:

有关ObjectID格式的更多信息,请参阅docs.mongodb.com/manual/reference/method/ObjectId/

ObjectIDs可能看起来像是字符串,因为它们在终端中的打印方式,但它们是对象。ObjectIDs受到经典对象比较陷阱的影响:看似完全等效的值报告为不等效,因为它们引用了不同的对象。

在以下代码片段中,我们两次提取了相同的值。使用节点的内置 assert 模块,我们尝试断言对象或 ID 是等效的,但两者都失败了:

const Articles = db.collection('articles');
Articles.find().then(articles => {
  const article1 = articles[0];
  return Articles
    .findOne({_id: article1._id})
    .then(article2 => {
      assert.equal(article2._id, article1._id);
    });
});

这些断言产生的错误消息最初看起来很令人困惑,因为实际值似乎与预期值匹配:

operator: equal
expected: 577f6b45549a3b991e1c3c18
actual:   577f6b45549a3b991e1c3c18
operator: equal
expected:
  { _id: 577f6b45549a3b991e1c3c18, title: 'attractive-money' ... }
actual:
  { _id: 577f6b45549a3b991e1c3c18, title: 'attractive-money' ... }

可以通过使用每个_id上可用的ObjectIDequal方法正确检测等效性,或者你可以强制转换标识符并将它们作为字符串比较,或者使用 Node 内置 assert 模块上的deepEquals方法:

传递给 Node mongodb 驱动程序的标识符必须是 BSON 格式的ObjectID。可以使用ObjectID构造函数将字符串转换为ObjectID

const { ObjectID } = require('mongodb');
const stringID = '577f6b45549a3b991e1c3c18';
const bsonID = new ObjectID(stringID);

在可能的情况下,应保持 BSON 形式;将字符串序列化和反序列化的成本与 MongoDB 通过将客户端标识符作为 BSON 提供所希望实现的性能提升相抵消。有关 BSON 格式的详细信息,请参阅bsonspec.org/

8.8.6. 使用副本集

MongoDB 的分布式功能大多超出了本书的范围,但在这个部分中,我们快速介绍了副本集的基本知识。许多mongod进程可以作为副本集的节点/成员运行。一个副本集由一个主节点和多个次节点组成。副本集的每个成员都需要分配一个唯一的端口和目录来存储其数据。实例不能共享端口或目录,并且目录必须在启动之前存在。

在以下列表中,您为每个成员创建一个唯一的目录,并从 27017 开始按顺序端口启动它们。您可能希望在新的终端标签中运行每个 mongod 命令,而不将其放入后台(不带尾随的 &)。

列表 8.11. 启动副本集

图片

副本集运行后,MongoDB 需要执行一些初始化操作。您需要连接到您希望成为第一个 主节点 的实例的端口(默认为 27017),并调用 rs.initiate(),如下所示。然后您需要将每个实例添加为副本集的成员。请注意,您需要提供您连接到的机器的主机名。

列表 8.12. 初始化副本集

图片

当 MongoDB 客户端连接时,需要了解所有可能的副本集成员,尽管并非所有成员都需要当前在线。连接后,您可以像往常一样使用 MongoDB 客户端。列表 8.13 展示了如何创建包含三个成员的副本集。

列表 8.13. 连接到副本集

图片

如果任何 mongod 节点崩溃,只要至少有两个实例正在运行,系统将继续工作。如果主节点崩溃,将自动选举一个次要节点提升为主节点。

8.8.7. 理解写关注点

MongoDB 允许开发者对应用程序各个区域可接受的性能和安全权衡进行细粒度控制。为了在不出现意外的情况下使用 MongoDB,特别是当副本集节点数量增加时,理解读写关注点的概念非常重要。在本节中,我们仅涉及写关注点,因为它们是最重要的。

写关注点 指定了在整体操作响应成功之前,数据需要成功写入多少个 mongod 实例。如果没有明确指定,默认写关注点是 1,这确保了数据至少已写入一个节点。它可能无法为关键数据提供足够的保证;如果节点在复制到其他节点之前崩溃,数据可能会丢失。

设置零写关注点是可能的,并且通常是期望的,这样应用程序就不需要等待任何响应:

db.collection('data').insertOne(data, { w: 0 });

零写关注点提供了最高的性能,但提供的持久性保证最少,通常仅用于临时或不重要的数据(例如日志或缓存的写入)。

如果您连接到副本集,您可以指定大于 1 的写关注点。将数据复制到更多节点可以降低数据丢失的可能性,但会以执行操作时增加延迟为代价:

db.collection('data').insertOne(data, { w: 2 });
db.collection('data').insertOne(data, { w: 5 });

您可能希望根据集群中节点数量的变化来扩展写入关注级别。如果将写入关注级别设置为majority,MongoDB 本身可以动态地完成此操作。这确保数据被写入超过 50%的可用节点:

db.collection('data').insertOne(data, { w: 'majority' });

默认的写入关注级别为 1 可能无法为关键数据提供足够的保证。如果节点在复制到其他节点之前崩溃,数据可能会丢失。

将写入关注级别设置为大于 1 确保在继续之前数据存在于多个 mongod 实例中。在同一台机器上运行多个实例可以提供保护,但无法帮助解决系统级故障,如磁盘空间或 RAM 耗尽。您可以通过在多台机器上运行实例并确保写入传播到这些节点来防止机器故障,但这又会使速度变慢,并且无法防止数据中心级故障。在不同数据中心运行节点可以防止数据中心中断,但确保数据在数据中心之间复制将大大影响性能。

总的来说,你添加的保证越多,系统就会变得越慢、越复杂。这不仅仅是一个特定于 MongoDB 的问题;它涉及到任何和所有数据存储的问题。没有完美的解决方案,你需要为应用程序的各个部分决定可接受的危险水平。

有关 MongoDB 复制工作方式的更多信息,请参阅以下资源:

8.9. 键值存储

键值存储中的每条记录由一个键和一个值组成。在许多键值系统中,值可以是任何数据类型,任何长度或结构。从数据库的角度来看,值是不透明的原子:数据库不知道也不关心数据的类型,并且不能被细分或访问,除非作为整体。这与关系型数据库中的值存储形成对比:数据存储在一系列表中,这些表包含数据行,这些行被分隔到预定义的列中。在键值存储中,管理数据格式的责任交给了应用程序。

键值存储通常可以在应用程序的性能关键路径中找到。理想情况下,值以这种方式排列,即完成任务所需的读取次数绝对最少。与其它数据库类型相比,键值存储具有更简单的查询能力。理想情况下,复杂查询是预先计算的;否则,它们需要在应用程序内部而不是数据库中执行。这种限制可能导致易于理解和预测的性能特征。

最受欢迎的键/值存储,如 Redis 和 Memcached,通常用于易失性存储(如果进程退出,数据将丢失)。避免写入磁盘是提高性能的最佳方法之一。当数据可以重新生成或损失无关紧要时,这可以是一个可接受的权衡;例如,缓存和用户会话。

键/值存储可能存在一种偏见,即它们不能用于主存储,但这并不总是正确的。许多键/值存储提供的持久性“与真实”数据库相当。

8.10. Redis

Redis 是一个流行的内存数据结构存储。尽管许多人认为 Redis 是一个键/值存储,但键和值仅代表 Redis 在各种有用、基本数据结构中支持的功能的一个子集。图 8.3 显示了 npm 上 redis 包的使用统计。

图 8.3. npm 上 redis 包的统计信息

内置在 Redis 中的数据结构包括以下内容:

  • 字符串

  • 哈希

  • 列表

  • 集合

  • 有序集合

Redis 还自带了许多其他有用的功能:

  • 位图数据— 在值中进行直接位操作。

  • 地理空间索引— 使用半径查询存储地理空间数据。

  • 通道— 一种发布/订阅数据交付机制。

  • TTLs— 可以配置过期时间,在此之后它们将自动删除。

  • LRU 过期— 可选地移除最近未使用过的值,以保持最大内存使用。

  • HyperLogLog— 高性能的集合基数近似,同时保持低内存占用(不需要存储每个成员)。

  • 复制、集群和分区— 水平扩展和数据持久性。

  • Lua 脚本— 通过自定义命令扩展 Redis。

在本节中,你会找到几个 Redis 命令的列表。它们不是参考,而是为了让你了解 Redis 的可能性。这是一个功能强大且多才多艺的工具。有关更多详细信息,请参阅 redis.io/commands

8.10.1. 执行安装和设置

你可以通过系统包管理工具安装 Redis。在 macOS 上,你可以使用 Homebrew 轻松安装它:

brew install redis

使用 redis-server 可执行文件启动服务器:

redis-server /usr/local/etc/redis.conf

服务器默认监听端口 6379。

8.10.2. 执行初始化

Redis 客户端实例是通过 redis npm 包的 createClient 函数创建的:

const redis = require('redis');
const db = redis.createClient(6379, '127.0.0.1');

此函数接受端口和主机作为参数。但如果你在本地机器上使用 Redis 服务器默认端口,你根本不需要提供任何参数:

const db = redis.createClient();

Redis 客户端实例是一个 EventEmitter,因此你可以为各种 Redis 状态事件附加监听器,如下一列表所示。你可以立即开始向客户端发送命令,它们将被缓冲,直到连接就绪。

列表 8.14. 连接到 Redis 并监听状态事件
const redis = require('redis');

const db = redis.createClient();
db.on('connect', () => console.log('Redis client connected to server.'));
db.on('ready', () => console.log('Redis server is ready.'));
db.on('error', err => console.error('Redis error', err));

如果发生连接或客户端问题,错误处理程序将触发。如果触发 error 事件但没有附加错误处理程序,应用程序进程将抛出错误并崩溃;这是 Node 中所有 EventEmitters 的特性。如果连接失败并提供了错误处理程序,Redis 客户端将尝试重试连接。

8.10.3. 操作键/值对

Redis 可以用作字符串和任意二进制数据的通用键/值存储。可以使用 setget 方法分别读取和写入键/值对:

db.set('color', 'red', err => {
  if (err) throw err;
});

db.get('color', (err, value) => {
  if (err) throw err;
  console.log('Got:', value);
});

如果您set一个现有的键,值将被覆盖。如果您尝试获取一个不存在的键,值将是null;这不被认为是错误。

可以使用以下命令来检索和操作值:

  • append

  • decr

  • decrby

  • get

  • getrange

  • getset

  • incr

  • incrby

  • incrbyfloat

  • mget

  • mset

  • msetnx

  • psetex

  • set

  • setex

  • setnx

  • setrange

  • strlen

8.10.4. 操作键

您可以使用 exists 检查键是否存在。这适用于任何数据类型:

db.exists('users', (err, doesExist) => {
  if (err) throw err;   console.log('users exists:', doesExist);
});

除了 exists 之外,以下所有命令都可以与任何键一起使用,无论值的类型如何(这些命令适用于字符串、集合、列表等):

  • del

  • exists

  • rename

  • renamenx

  • sort

  • scan

  • type

8.10.5. 编码和数据类型

Redis 服务器将键和值存储为二进制对象;它不依赖于传递给客户端的值的编码。任何有效的 JavaScript 字符串(UCS2/UTF16)都可以用作有效的键或值:

db.set('greeting', '你好', redis.print);
db.get('greeting', redis.print);
db.set('icon', '?', redis.print);
db.get('icon', redis.print);

默认情况下,键和值在写入时会转换为字符串。例如,如果您使用数字set一个键,当您尝试获取相同的键时,它将是一个字符串:

Redis 客户端将数字、布尔值和日期无声地转换为字符串,并且它也乐意接受缓冲区对象。尝试将任何其他 JavaScript 类型(例如,Object、Array、RegExp)作为值设置,将打印出应予以注意的警告:

db.set('users', {}, redis.print);

Deprecated: The SET command contains a argument of type Object.
This is converted to "[object Object]" by using .toString() now
  and will return an error from v.3.0 on.
Please handle this in your code to make sure everything works
  as you intended it to.

在未来,这将是一个错误,因此调用应用程序必须负责确保向 Redis 客户端传递正确的类型。

注意:单值与多值数组

如果您尝试设置一个值数组,客户端将产生一个神秘的错误,“ReplyError: ERR syntax error”。

db.set('users', ['Alice', 'Bob'], redis.print);

但请注意,当数组中只有一个值时,不会发生错误:

db.set('user', ['Alice'], redis.print);
db.get('user', redis.print);

这种类型的错误可能只有在您在生产环境中运行时才会显示症状,因为它可以轻易地逃避检测,如果测试套件恰好只产生单值数组,这在简化测试数据时很常见。请注意!

带缓冲区的二进制数据

Redis 能够存储任意字节数据,这意味着您可以在其中存储任何类型的数据。Node 客户端通过为 Node 的 Buffer 类型提供特殊处理来支持此功能。当缓冲区作为键或值传递给 Redis 客户端时,字节将未修改地发送到 Redis 服务器。这避免了意外数据损坏和字符串与缓冲区之间不必要的 marshalling 之间的性能惩罚。例如,如果您想将磁盘或网络中的数据直接写入 Redis,直接将缓冲区写入 Redis 比先将其转换为字符串更有效。

缓冲区

缓冲区 是您从 Node 的核心文件和网络 API 默认接收的内容。它们是围绕连续的二进制数据块的容器,在 Node 在 JavaScript 还没有自己的原生二进制数据类型(Uint8ArrayFloat32Array 等等)之前就已经被引入。今天,缓冲区在 Node 中作为 Uint8Array 的专用子类实现。Buffer API 在 Node 中全局可用;您不需要 require 任何内容就可以使用它。

查看 github.com/nodejs/node/blob/master/lib/buffer.js

Redis 最近添加了用于操作字符串值单个位的命令,这在处理缓冲区时可能很有用:

  • bitcount

  • bitfield

  • bitop

  • setbit

  • bitpos

8.10.6. 使用哈希

哈希 是键/值对的集合。hmset 命令接受一个键和一个表示哈希键/值对的对象。您可以通过使用 hmget 来获取键/值对,如下一列表所示。

列表 8.15. 在 Redis 哈希的元素中存储数据

您不能在 Redis 哈希中存储嵌套对象。它只提供单层键和值。

以下命令作用于哈希:

  • hdel

  • hexists

  • hget

  • hgetall

  • hincrby

  • hincrbyfloat

  • hkeys

  • hlen

  • hmget

  • hmset

  • hset

  • hsetnx

  • hstrlen

  • hvals

  • hscan

8.10.7. 使用列表

列表 是字符串值的有序集合。列表可以包含相同值的多个副本。列表在概念上与数组相似。列表最适合用作栈(LIFO:后进先出)或队列(FIFO:先进先出)数据结构。

以下代码展示了在列表中存储和检索值。lpush 命令向列表添加一个值。lrange 命令通过使用起始和结束索引检索值范围。以下代码中的 -1 参数表示列表的最后一个项目,因此这种使用 lrange 的方式检索所有列表项:

client.lpush('tasks', 'Paint the bikeshed red.', redis.print);
client.lpush('tasks', 'Paint the bikeshed green.', redis.print);
client.lrange('tasks', 0, -1, (err, items) => {
  if (err) throw err;
  items.forEach(item => console.log(`  ${item}`));
});

列表不包含任何内置方法来确定值是否在列表中,或者任何发现特定值在列表中索引的方法。您可以手动遍历列表以获取这些信息,但这是一种效率非常低的方法,应该避免。如果您需要这些类型的功能,您应该考虑不同的数据结构,例如集合,甚至可能是在列表之外使用。在多个数据结构之间复制数据通常是为了利用各种性能特性。

以下命令在列表上操作:

  • blpop

  • brpop

  • lindex

  • linsert

  • llen

  • lpop

  • lpush

  • lpushx

  • lrange

  • lrem

  • lset

  • ltrim

  • rpop

  • rpush

  • rpushx

8.10.8. 使用集合

集合 是一个无序的唯一值集合。测试成员资格,以及从集合中添加和删除项目可以在 O(1) 时间内完成,使其成为一个高性能的结构,适用于许多任务:

db.sadd('admins', 'Alice', redis.print);
db.sadd('admins', 'Bob', redis.print);
db.sadd('admins', 'Alice', redis.print);
db.smembers('admins', (err, members) => {
  if (err) throw err;
  console.log(members);
});

以下命令在 Redis 集合上操作:

  • sadd

  • scard

  • sdiff

  • sdiffstore

  • sinter

  • sinterstore

  • sismember

  • smembers

  • spop

  • srandmember

  • srem

  • sunion

  • sunionstore

  • sscan

8.10.9. 使用频道提供 pub/sub

Redis 通过提供频道超越了传统数据存储的角色。频道 是一种数据交付机制,它提供了发布/订阅功能,如图 8.4 所示的概念图。它们对于实时应用,如聊天和游戏,非常有用。

图 8.4. Redis 频道为常见的数据交付场景提供了一个简单的解决方案。

Redis 客户端可以订阅或发布到频道。发布到频道的消息将被发送到所有订阅者。发布者不需要了解订阅者,订阅者也不需要了解发布者。这种发布者和订阅者之间的解耦使得这种模式既强大又简洁。

以下列表显示了如何使用 Redis 的发布/订阅功能来实现 TCP/IP 聊天服务器的一个示例。

列表 8.16. 使用 Redis pub/sub 功能实现的简单聊天服务器

8.10.10. 提高 Redis 性能

hiredis npm 包是从 JavaScript 到官方 Hiredis C 库中的协议解析器的本地绑定。Hiredis 可以显著提高 Node Redis 应用程序的性能,尤其是如果您使用 sunion, sinter, lrange, 和 zrange 操作与大数据集。

要使用 hiredis,只需将其与应用程序中的 redis 包一起安装,Node redis 包将在下次启动时自动检测并使用它:

npm install hiredis --save

使用 hiredis 几乎没有缺点,但由于它是从 C 代码编译的,因此在为某些平台构建 hiredis 时可能会出现一些复杂或限制。与所有原生插件一样,在更新 Node 后,您可能需要使用 npm rebuild 重新构建 hiredis。

8.11. 嵌入式数据库

嵌入式数据库不需要安装或管理外部服务器。它直接嵌入到你的应用程序进程中运行。与嵌入式数据库的通信通常通过应用程序中的直接过程调用进行,而不是通过进程间通信(IPC)通道或网络。

在许多情况下,应用程序需要自给自足,因此嵌入式数据库是唯一的选择(例如,移动或桌面应用程序)。嵌入式数据库也可以用于 Web 服务器,通常用于驱动高吞吐量功能,如用户会话或缓存,有时甚至作为主要存储。

在 Node 和 Electron 应用程序中常用的一些嵌入式数据库如下:

  • SQLite

  • LevelDB

  • RocksDB

  • Aerospike

  • EJDB

  • NeDB

  • LokiJS

  • Lowdb

NeDB、LokiJS 和 Lowdb 都是用纯 JavaScript 编写的,这使得它们可以嵌入到 Node/Electron 应用程序中。大多数嵌入式数据库都是简单的键/值或文档存储,尽管 SQLite 是一个值得注意的例外,它是一个可嵌入的 关系型 存储。

8.12. LevelDB

LevelDB 是由 Google 在 2011 年初开发的一个可嵌入的、持久化的键/值存储,最初用于 Chrome 中 IndexedDB 实现的后端存储。LevelDB 的设计基于 Google Bigtable 数据库的概念。LevelDB 可以与 Berkley DB、Tokyo/Kyoto Cabinet 和 Aerospike 等数据库相媲美,但在本书的上下文中,你可以将 LevelDB 视为仅具有最基本功能的可嵌入 Redis。像许多嵌入式数据库一样,LevelDB 不是多线程的,也不支持使用相同底层文件存储的多个实例,因此在没有包装应用程序的情况下无法在分布式环境中工作。

LevelDB 以字典序对键进行排序存储任意字节数组。值通过使用 Google 的 Snappy 压缩算法进行压缩。数据始终持久化到磁盘;总数据容量不受机器上 RAM 量的限制,与 Redis 这样的内存存储不同。

LevelDB 提供了一组非常简单的自解释操作:GetPutDelBatch。LevelDB 还可以捕获当前数据库状态的快照,并创建双向迭代器以在数据集中向前或向后移动。创建迭代器会创建一个隐式快照;迭代器可以看到的数据不能通过后续写入进行更改。

LevelDB 以 LevelDB 分支的形式构成了其他数据库的基础。LevelDB 的重要分支数量可以归因于 LevelDB 本身的简单性:

  • RocksDB by Facebook

  • HyperLevelDB by Hyperdex

  • Riak by Basho

  • leveldb-mcpe by Mojang(Minecraft 的创造者)

  • bitcoin/leveldb for the bitcoind project

关于 LevelDB 的更多信息,请参阅 leveldb.org/

8.12.1. LevelUP 和 LevelDOWN

Node 的 LevelDB 支持由 Node 基金会主席和多产的澳大利亚开发者 Rod Vagg 编写的 LevelUP 和 LevelDOWN 包提供。LevelDOWN 是 LevelDB 的一个简单、无糖的 C++ 绑定,用于 Node,你不太可能直接与之交互。LevelUP 使用更方便、更符合 Node 习惯的接口包装 LevelDOWN API,增加了对键/值编码、JSON、缓冲写入直到数据库打开以及将 LevelDB 迭代器接口包装在 Node 流中的支持。图 8.5 展示了 levelup 在 npm 上的流行度。

图 8.5. levelup 包在 npm 上的统计信息

8.12.2. 安装

在 Node 应用程序中使用 LevelDB 的一大便利是它内嵌:你可以仅使用 npm 安装所需的一切。你不需要安装任何额外的软件;只需执行以下命令,你就可以开始使用 LevelDB:

npm install level --save

level 包是 LevelUP 和 LevelDOWN 包的简单便利包装,提供了一个预配置的 LevelUP API,用于使用 LevelDown 后端。可以在 LevelUP 的 README 文件中找到 level 包暴露的 LevelUP API 的文档:

8.12.3. API 概述

LevelDB 客户端存储和检索值的主要方法如下:

  • db.put(key, value, callback)—在键下存储一个值

  • db.get(key, callback)—获取键下的值

  • db.del(key, callback)—删除键下的值

  • db.batch().write()—执行批量操作

  • db.createKeyStream(options)—数据库中的键流

  • db.createValueStream(options)—数据库中的值流

8.12.4. 初始化

当你初始化 level 时,需要提供一个存储数据的目录路径,如下所示;如果目录不存在,则会创建该目录。有一个宽松的社区约定,即给这个目录添加一个 .db 扩展名(例如,./app.db)。

列表 8.17. 初始化 level 数据库
const level = require('level');

const db = level('./app.db', {
  valueEncoding: 'json'
});

在调用 level() 之后,返回的 LevelUP 实例立即准备好开始接受命令,同步地。在 LevelDB 存储打开之前发出的命令将被缓冲,直到存储打开。

8.12.5. 键/值编码

由于 LevelDB 可以存储任意类型的数据,无论是键还是值,因此处理数据序列化和反序列化的任务就交给了调用应用程序。LevelUp 可以通过以下数据类型直接配置来编码键和值:

  • utf8

  • json

  • binary

  • id

  • hex

  • ascii

  • base64

  • ucs2

  • utf16le

默认情况下,键和值都编码为 UTF-8 字符串。在列表 8.17 中,键将保持为 UTF-8 字符串,但值将编码/解码为 JSON。JSON 编码允许以类似于文档存储(如 MongoDB)的方式存储和检索结构化值,如对象或数组。但请注意,与真正的文档存储不同,使用 LevelDB 无法访问值内的键;值是透明的。用户还可以提供自己的自定义编码——例如,支持不同的结构化数据格式,如 MessagePack。

8.12.6. 读取和写入键/值对

核心 API 很简单:使用put(key, value)来写入值,get(key)来读取值,以及del(key)来删除值,如下一列表所示。下一列表中的代码应附加到列表 8.17 中的代码;要查看完整示例,请参阅书中示例代码的 ch08-databases/listing8_18/index.js。

列表 8.18. 读取和写入值
const key = 'user';
const value = {
  name: 'Alice'
};

db.put(key, value, err => {
  if (err) throw err;
  db.get(key, (err, result) => {
    if (err) throw err;
    console.log('got value:', result);
    db.del(key, (err) => {
      if (err) throw err;
      console.log('value was deleted');
    });
  });
});

如果你在一个已存在的键上put一个值,旧值将被覆盖。尝试获取一个不存在的键将导致错误。这个错误对象将是一个特定的类型,NotFoundError,并且有一个特殊属性err.notFound,可以用来区分它和其他类型的错误。这看起来可能有些不寻常,但因为在 LevelDB 中没有内置的方法来检查存在性,LevelUp 需要能够区分不存在的值和undefined的值。与get不同,尝试删除一个不存在的键不会导致错误。

列表 8.19. 获取不存在的键
db.get('this-key-does-not-exist', (err, value) => {
  if (err && !err.notFound) throw err;
  if (err && err.notFound) return console.log('Value was not found.');
  console.log('Value was found:', value);
});

所有的数据读取和写入操作都接受一个可选的options参数,用于覆盖当前操作的编码选项,如下一列表所示。

列表 8.20. 覆盖特定操作的编码
const options = {
  keyEncoding: 'binary',
  valueEncoding: 'hex'
};

db.put(new Uint8Array([1, 2, 3]), '0xFF0099', options, (err) => {
  if (err) throw err;
  db.get(new Uint8Array([1, 2, 3]), options, (err, value) => {
    if (err) throw err;
    console.log(value);
  });
});

8.12.7. 可插拔的后端

LevelUP/LevelDOWN 分离的一个令人高兴的副作用是 LevelUP 不受限于使用 LevelDB 作为存储后端。你可以使用 MemDown API 包装的任何内容作为 LevelUP 的存储后端,这允许你使用完全相同的 API 与许多数据存储进行接口。

一些替代后端的示例如下:

  • MySQL

  • Redis

  • MongoDB

  • JSON 文件

  • Google 电子表格

  • AWS DynamoDB

  • Windows Azure 表存储

  • 浏览器网页存储(IndexedDB/localStorage)

这种轻松更换存储介质或甚至编写自己的自定义后端的能力意味着你可以在许多情况和环境中使用单一、一致的数据库 API 和工具集。一个数据库 API 统治一切!

常用的替代后端是 memdown,它将值完全存储在内存中而不是磁盘上,类似于使用 SQLite 的内存模式。这在测试环境中可以特别有用,以减少测试设置和拆除的成本。

要运行以下列表,请确保你已经安装了 LevelUP 和 memdown 包:

npm install --save levelup memdown
列表 8.21. 使用 memdown 与 LevelUP

在这个示例中,你可以使用之前使用的相同级别的包,因为它只是 LevelUP 的包装器。但如果你没有使用 level 附带捆绑的 LevelDB 支持的 LevelDOWN,你只需使用 LevelUP,并通过 LevelDOWN 避免对 LevelDB 的二进制依赖。

8.12.8. 模块化数据库

LevelDB 的性能和简约风格与许多 Node 开发者产生共鸣,并在 Node 社区中催生了模块化数据库运动。概念是能够选择并选择应用程序需要的确切功能,并为特定的用例定制数据库。

这里只是 npm 包中通过模块化 LevelDB 功能的一些示例:

  • 原子更新

  • 自动递增键

  • 地理空间查询

  • 实时更新流

  • LRU 驱逐

  • Map/reduce 作业

  • 主/主复制

  • 主/从复制

  • SQL 查询

  • 二级索引

  • 触发器

  • 版本化数据

LevelUP 维基百科维护了一个相当全面的 LevelDB 生态系统概述:github.com/Level/levelup/wiki/Modules,或者你可以在 npm 上搜索leveldb,在撰写本文时,有 898 个包。图 8.6 显示了 LevelDB 在 npm 上的流行程度。

图 8.6. npm 上第三方 LevelDB 包的示例

8.13. 序列化和反序列化成本高昂

重要的是要记住,内置的 JSON 操作既昂贵又阻塞;在将数据从 JSON 序列化和反序列化时,你的进程无法执行其他任何操作。对于大多数其他序列化格式也是如此。在 Web 服务器上,序列化通常是一个关键瓶颈。减少其影响的最佳方法是尽量减少其执行频率和需要处理的数据量。

通过使用不同的序列化格式(例如,MessagePack 或 Protocol Buffers),你可能会有一些速度提升,但在从减少有效载荷大小和不必要的序列化/反序列化步骤中挤出可能的收益之后,才应考虑替代格式。

JSON.stringifyJSON.parse是原生函数,并且已经过充分优化,但当需要处理兆字节级的数据时,它们很容易被压垮。为了演示,以下列表对大约 10 MB 的数据进行了序列化和反序列化的基准测试。

列表 8.22. 序列化基准测试
const bytes = require('pretty-bytes');
const obj = {};
for (let i = 0; i < 200000; i++) {
  obj[i] = {
    [Math.random()]: Math.random()
  };
}

console.time('serialise');
const jsonString = JSON.stringify(obj);
console.timeEnd('serialise');
console.log('Serialised Size', bytes(Buffer.byteLength(jsonString)));
console.time('deserialise');
const obj2 = JSON.parse(jsonString);
console.timeEnd('deserialise');

在一台 2015 年 3.1 GHz Intel Core i7 MacBook Pro 上运行 Node 6.2.2,大约需要 140 毫秒来序列化,335 毫秒来反序列化大约 10 MB 的数据。如果这样的负载发生在 Web 服务器上,这将是一场灾难,因为这些步骤是完全阻塞的,并且必须按顺序处理。这样的服务器在序列化时只能处理大约每秒令人沮丧的七个请求,在反序列化时大约每秒三个请求。

8.14. 浏览器存储

Node 中使用的异步编程模型适用于许多用例,因为假设 I/O 是大多数 Web 应用中最大的瓶颈。你可以同时减少服务器负载并提高用户体验的最重要的事情之一是利用客户端数据存储。一个快乐的用户是不需要等待完整的网络往返才能得到结果的。客户端存储还可以通过允许应用程序在用户或您的服务离线时至少保持半功能状态,从而促进应用程序可用性的提高。

8.14.1. Web 存储:localStorage 和 sessionStorage

Web 存储定义了一个简单的键/值存储,并且在桌面和移动浏览器中都得到了很好的支持。使用 Web 存储,一个域可以在浏览器中持久化一定量的数据,并在稍后检索它,即使网站已被刷新,标签页已关闭或浏览器已关闭。Web 存储是客户端持久化的首选方案。其获胜的特点是它的简单性。

有两个 Web 存储 API:localStorage 和 sessionStorage。sessionStorage 实现了与 localStorage 相同的 API,尽管它在持久化行为上有所不同。与 localStorage 一样,存储在 sessionStorage 中的数据在页面重新加载时保持持久,但与 localStorage 不同,当页面会话结束时(当标签页或浏览器关闭时),所有 sessionStorage 数据都会过期。sessionStorage 数据不能从不同的浏览器窗口中访问。

Web 存储 API 的开发是为了克服浏览器 Cookie 的限制。具体来说,Cookie 不适合在同一个域的多个活动标签页之间共享数据。如果用户在多个标签页中执行活动,可以使用 sessionStorage 在这些标签页之间共享状态,而无需使用网络。

Cookie 不适用于处理需要在多个会话、标签页和窗口中持续存在的长期数据;例如,用户创建的文档或电子邮件。这正是 localStorage 被设计来处理的使用场景。根据特定浏览器的不同,可以存储在 Web 存储中的数据量上限也有所不同。移动浏览器仅限于 5 MB 的存储空间。

API 概述

localStorage API 提供了以下方法来处理键和值:

  • localStorage.setItem(key, value)—在键下存储一个值

  • localStorage.getItem(key)—获取键下的值

  • localStorage.removeItem(key)—移除键下的值

  • localStorage.clear()—移除所有键和值

  • localStorage.key(index)—获取索引处的值

  • localStorage.length—localStorage 中的键总数

8.14.2. 读取和写入值

键和值都必须是字符串。如果你传递了一个非字符串的值,它将被自动转换为字符串。这种转换不会生成 JSON 字符串;相反,它使用.toString进行简单的转换。对象最终会被序列化为字符串[object Object]。应用程序必须将值序列化和反序列化成字符串,以便在 Web 存储中存储更复杂的数据类型。下面的列表展示了如何将 JSON 存储在 localStorage 中。

列表 8.23. 在 Web 存储中存储 JSON
const examplePreferences = {
  temperature: 'Celcius'
};

// serialize on write
localStorage.setItem('preferences', JSON.stringify(examplePreferences));

// deserialize on read
const preferences = JSON.parse(localStorage.getItem('preferences'));
console.log('Loaded preferences:', preferences);

访问 Web 存储数据相对较快,尽管它也是同步的。Web 存储在执行读写操作时会阻塞 UI 线程。对于小负载,这种开销可能不明显,但应避免过度读取或写入,尤其是大量数据时。不幸的是,Web 存储在 Web Worker 中不可用,因此所有读取和写入都必须在主 UI 线程上执行。有关各种客户端存储技术性能影响的详细分析,请参阅 PouchDB 作者 Nolan Lawson 的这篇帖子:nolanlawson.com/2015/09/29/indexeddb-websql-localstorage-what-blocks-the-dom/

Web 存储 API 没有提供内置的查询、按范围选择键或搜索值的工具。你只能通过键访问项目。要执行搜索,你可以设置和维护自己的索引;或者如果你的数据集足够小,你可以遍历整个数据集。以下列表遍历了 localStorage 中的所有键。

列表 8.24. 在 localStorage 中遍历整个数据集
function getAllKeys() {
  return Object.keys(localStorage);
}

function getAllKeysAndValues() {
  return getAllKeys()
    .reduce((obj, str) => {
      obj[str] = localStorage.getItem(str);
      return obj;
    }, {});
}

// Get all values
const allValues = getAllKeys().map(key => localStorage.getItem(key));

// As an object
console.log(getAllKeysAndValues());

与大多数键/值存储类似,键只有一个命名空间。例如,如果你有postscomments,就无法为帖子或评论创建单独的存储。通过在每个键前使用前缀来创建自己的“命名空间”很容易,如以下列表所示。

列表 8.25. 命名空间键
localStorage.setItem(`/posts/${post.id}`, post);
localStorage.setItem(`/comments/${comment.id}`, comment);

要获取命名空间内的所有项目,你可以使用前面的getAllKeys函数过滤所有项目,如以下列表所示。

列表 8.26. 获取命名空间中的所有项目
function getNamespaceItems(namespace) {
  return getAllKeys().filter(key => key.startsWith(namespace));
}
console.log(getNamespaceItems('/exampleNamespace'));

注意,这个循环遍历 localStorage 中的每个键,所以在遍历大量项目时要当心性能。

由于 localStorage API 是同步的,因此在使用时存在一些限制。例如,你可以使用 localStorage 永久缓存任何接受并返回可序列化 JSON 数据的函数的结果,如以下列表所示。

列表 8.27. 使用 localStorage 进行持久化缓存
// subsequent calls with the same argument will fetch the memoized result
function memoizedExpensiveOperation(data) {
  const key = `/memoized/${JSON.stringify(data)}`;
  const memoizedResult = localStorage.getItem(key);
  if (memoizedResult != null) return memoizedResult;
  // do expensive work
  const result = expensiveWork(data);
  // save result to localStorage, never calculate again
  localStorage.setItem(key, result);
  return result;
}

注意,如果操作特别慢,那么缓存的好处才能超过序列化/反序列化过程的开销(例如,加密算法)。因此,localStorage 在节省跨网络移动数据的时间方面效果最佳。

Web 存储确实存在限制,但对于合适的任务,它可以是一个强大且简单的工具。其他需要调查的浏览器内存储主题如下:

  • IndexedDB

  • 服务工作者

  • 离线优先

8.14.3. localForage

Web 存储的主要缺点是其阻塞的、同步的 API 以及在某些浏览器中有限的存储容量。除了 Web 存储之外,大多数现代浏览器还支持 WebSQL 和 IndexedDB 中的一个或两个。这两个数据存储都是非阻塞的,并且可以可靠地存储比 Web 存储多得多的数据。

但是,像我们使用 Web 存储 API 那样直接使用这些数据库是不可取的。WebSQL 已弃用,其继任者 IndexedDB 有一个特别不友好且冗长的 API,更不用说浏览器支持的碎片化了。为了方便且可靠地在浏览器中存储数据而不阻塞,我们被迫使用非标准工具来“规范化”环境。来自 Mozilla 的 localForage 库([mozilla.github.io/localForage/](http://mozilla.github.io/localForage/))就是这样一种规范化的工具。

API 概述

便利的是,localForage 接口紧密地模仿了 Web 存储,尽管是以异步、非阻塞的形式:

  • localforage.setItem(key, value, callback)—在键下存储一个值

  • localforage.getItem(key, callback)—获取键下的值

  • localforage.removeItem(key, callback)—移除键下的值

  • localforage.clear(callback)—移除所有键和值

  • localforage.key(index, callback)—获取索引处的值

  • localforage.length(callback)—localForage 中的键的数量

localForage API 还包括一些没有 Web 存储等价的实用功能:

  • localforage.keys(callback)—移除所有键和值

  • localforage.iterate(iterator, callback)—遍历键和值

8.14.4. 读取和写入

localForage API 支持 promise 和 Node 的错误优先回调约定。

列表 8.28. 使用 localStorage 与 localForage 获取数据的比较

在底层,localForage 利用当前浏览器环境中可用的最佳存储机制。如果 IndexedDB 可用,localForage 将使用它。否则,它将尝试回退到 WebSQL,甚至在需要时使用 Web 存储。您可以配置尝试存储的顺序,甚至可以黑名单某些选项:

与 localStorage 不同,localForage 不仅限于存储字符串。它支持大多数 JavaScript 原语,如数组和对象,以及二进制数据类型:Typed-ArraysArrayBuffersBlobs。请注意,IndexedDB 是唯一可以原生存储二进制数据的后端:WebSQL 和 localStorage 后端将产生序列化开销:

Promise.all([
  localforage.setItem('number', 3),
  localforage.setItem('object', { key: 'value' }),
  localforage.setItem('typedarray', new Uint32Array([1,2,3]))
]);

通过镜像 Web 存储 API,localForage 的使用直观,同时也克服了在浏览器中存储数据时许多缺点和兼容性问题。

8.15. 托管存储

托管存储是你可以用来避免管理自己的服务器端存储的另一种策略。由亚马逊网络服务(AWS)等提供的主机基础设施服务通常被认为只是扩展和性能优化,但早期智能地使用托管服务可以节省大量时间,避免实施不必要的糟糕基础设施。

本章中列出的许多数据库(如果不是所有数据库)都有托管服务。托管服务允许你快速尝试工具,甚至可以部署公开可访问的生产应用程序,而无需自己设置数据库托管。但自己托管正变得越来越容易。许多云服务提供预构建的服务器镜像,其中包含了运行数据库所需的所有正确软件和配置。

8.15.1. 简单存储服务

亚马逊简单存储服务(S3)是作为流行的 AWS 套件的一部分提供的远程文件托管服务。S3 是一种经济高效的存储和托管网络可访问文件的方法。它是一个云端的文件系统。使用 RESTful HTTP 调用,可以将文件上传到存储桶,同时附带最多 2 KB 的元数据。然后可以通过 HTTP GET或 BitTorrent 协议访问存储桶内容。

存储桶及其内容可以通过各种权限进行配置,包括基于时间的访问权限。你还可以为存储桶内容本身指定一个生存时间(TTL),在此之后,它们将变得不可访问,并从你的存储桶中删除。将你的 S3 数据提升到内容分发网络(CDN)非常容易。AWS 提供了 CloudFront CDN,它可以轻松连接到你的文件,并且可以从世界各地以低延迟访问。

并非所有数据都需要或应该存储在数据库中。你的数据中是否有可以作为文件处理的组件?在你为用户完成一次昂贵的计算后,也许你可以将这些结果推送到 S3,然后永远退出这个环节。

S3 的一个常见且明显的用途是托管用户上传的资产,如图像。上传的资产存储在应用程序机器上的临时目录中,通过使用 ImageMagick 等工具来减小文件大小,然后上传到 S3 以供浏览器托管。这个过程可以通过直接将上传流式传输到 S3 进一步简化,在那里它们可以触发进一步的加工。客户端应用程序也可以直接上传到 S3。一些更面向开发者的服务甚至选择提供绝对零存储,要求用户提供访问令牌,以便应用程序可以使用他们的 S3 存储桶。

S3 不仅限于存储图像

S3 可以用来存储任何类型的文件,大小最高可达 5 太字节,格式不限。S3 最适合存储不经常更改且需要作为单个原子访问的大量数据块。

将数据存储在 S3 中可以绕过设置和维护用于文件托管和存储的服务器的复杂性和复杂性。它非常适合写入不频繁、需要作为单个原子访问大量数据,并且有多个读取和潜在多个读取位置的情况。

8.16. 哪个数据库?

在本章中,我们只介绍了 Node 应用程序中常用的一些数据库。成功的应用程序可以使用这些数据库中的任何一个来构建。在单个应用程序中,并不总是有一个理想的数据存储解决方案;没有一劳永逸的解决方案。每个数据库都提供其独特的权衡,开发者需要评估哪些权衡对当前项目状态是有意义的。技术的混合通常是最合适的。

而不是问“我应该使用哪个数据库?”,你可能会问,“完全不使用数据库我能走多远?”你能用最少的长期决策构建你项目的多少部分?通常最好推迟决策;当你有更多信息时,你总是会做出更好的决策。

8.17. 摘要

  • 无论是关系型数据库还是 NoSQL 数据库,都可以与 Node 一起使用。

  • 简单的 pg Node 模块非常适合与 SQL 语言一起工作。

  • Knex 模块允许您使用 Node 与多个数据库交互。

  • ACID 是数据库事务的一组属性,确保了安全性。

  • MongoDB 是一种使用 JavaScript 的 NoSQL 数据库。

  • Redis 是一种可以作为数据库和缓存使用的数据结构存储。

  • LevelDB 是 Google 开发的一种快速键/值存储,它将字符串映射到值。

  • LevelDB 是一个模块化数据库。

  • 基于网络的存储,包括 localForage 和 localStorage,可以用于在浏览器中保存数据。

  • 存储服务,如 Amazon S3,可以用于将数据持久化到云服务提供商。

第九章. 测试 Node 应用程序

本章涵盖

  • 使用 Node 的 assert 模块测试逻辑

  • 使用其他断言库

  • 使用 Node 单元测试框架

  • 使用 Node 模拟和控制网络浏览器

  • 当测试失败时获取更多详细信息

随着您应用程序功能的增加,引入错误的风险也在增加。如果一个应用程序未经测试,那么它就不完整。由于手动测试既繁琐又容易出错,因此自动测试在开发者中越来越受欢迎。自动测试涉及编写逻辑来测试你的代码,而不是手动运行应用程序的功能。

如果自动测试的概念对你来说是新的,那么你可以将其想象成一个机器人做所有无聊的工作,而你则专注于有趣的工作。每次你对代码进行更改时,机器人都会确保没有错误悄悄地进入。尽管你可能还没有完成或开始你的第一个 Node 应用程序,但了解如何实现自动测试是很好的,因为你在开发过程中将能够编写测试。

在本章中,你将了解两种类型的自动化测试:单元测试和验收测试。单元测试 用于验证逻辑,通常在函数或方法级别,并且适用于所有类型的应用程序。单元测试方法可以分为两种主要形式:测试驱动开发(TDD)和行为驱动开发(BDD)。从实际的角度来看,TDD 和 BDD 在很大程度上是同一件事,但它们在风格上有所不同。这很重要,取决于谁需要阅读你的测试。TDD 和 BDD 之间的其他差异存在,但它们超出了本书的范围。验收测试 是一种常用的测试层,通常用于 Web 应用程序。验收测试涉及脚本控制浏览器,并尝试使用它触发 Web 应用程序的功能。

本章涵盖了单元测试和验收测试的既定解决方案。对于单元测试,我们涵盖了 Node 的 assert 模块;Mocha、Vows 和 Should.js 框架;以及 Chai。对于验收测试,我们探讨了使用 Selenium 与 Node 的结合。图 9.1 将工具放置在其各自的测试方法和风味旁边。

图 9.1. 测试框架概述

让我们从单元测试开始。

9.1. 单元测试

单元测试 是一种自动化测试类型,其中你编写逻辑来测试应用程序的离散部分。编写测试有助于你更批判性地思考应用程序的设计选择,并帮助你早期避免陷阱。测试还让你对自己的最近更改没有引入错误充满信心。尽管单元测试在编写时需要一些前期工作,但它们可以通过减少每次更改应用程序时手动重新测试的需要来节省你的时间。

单元测试可能很棘手,测试异步逻辑可能也会带来自己的挑战。异步单元测试可以并行运行,因此你必须小心确保测试不会相互干扰。例如,如果你的测试在磁盘上创建临时文件,你必须小心,在测试后删除文件时,不要删除尚未完成的另一个测试的工作文件。因此,许多单元测试框架包括流程控制来序列化测试的运行。

在本节中,我们将向您展示如何使用以下内容:

  • Node 的内置 assert 模块— TDD 风格自动化测试的良好构建块

  • Mocha— 一个相对较新的测试框架,可用于 TDD 或 BDD 风格的测试

  • Vows— 一个广泛使用的 BDD 风格测试框架

  • Should.js— 一个模块,它基于 Node 的 assert 模块来提供 BDD 风格的断言

下一节将演示如何使用与 Node 一起提供的 assert 模块测试业务逻辑。

9.1.1. assert 模块

大多数 Node 单元测试的基础是内置的 assert 模块,它测试一个条件,如果条件不满足,则抛出错误。Node 的 assert 模块被许多第三方测试框架使用。即使没有测试框架,您也可以使用它进行有用的测试。如果您正在尝试快速的想法,您可以使用 assert 模块本身来编写快速测试。

一个简单的例子

假设您有一个简单的待办事项应用,它将项目存储在内存中,并且您想断言它正在做您认为它正在做的事情。

以下列表定义了一个包含核心应用功能的模块。模块逻辑支持创建、检索和删除待办事项。它还包括一个简单的doAsync方法,因此您还可以查看测试异步方法。将此文件保存为 todo.js。

列表 9.1. 待办事项列表的模型

现在,您可以使用 Node 的 assert 模块来测试代码。在一个名为 test.js 的文件中,输入以下代码以加载必要的模块,设置一个新的待办事项列表,并设置一个跟踪完成测试数量的变量。

列表 9.2. 设置必要的模块
const assert = require('assert');
const Todo = require('./todo');
const todo = new Todo();
let testsCompleted = 0;
使用等于测试变量的内容

接下来,您可以添加对待办事项应用删除功能的测试。将以下列表中的函数添加到 test.js 的末尾。

列表 9.3. 确保删除后没有待办事项剩余

此测试添加一个todo项然后删除它。因为在这个测试结束时应该没有待办事项,所以如果应用逻辑正常工作,todo.length的值应该是0。如果出现问题,将抛出异常。如果todo.length返回的值没有设置为0,断言将导致显示错误消息“不应该存在任何项”的堆栈跟踪输出到控制台。在断言之后,testsCompleted增加以记录一个测试已完成。

使用 notEqual 查找逻辑中的问题

接下来,将以下列表中的代码添加到 test.js 中。此代码是对待办事项应用添加功能的测试。

列表 9.4. 确保添加待办事项的测试

assert 模块还允许notEqual断言。这种断言在应用程序代码生成特定值时指示逻辑问题很有用。列表 9.4 显示了notEqual断言的使用。删除所有待办事项,添加一个项,然后应用逻辑获取所有项。如果项的数量是0,断言将失败并抛出异常。

使用附加功能:strictEqual,notStrictEqual,deep- pEqual,notDeepEqual

除了equalnotEqual功能外,assert 模块还提供了称为strictEqualnotStrictEqual的严格断言版本。这些使用严格相等运算符(===)而不是更宽容的版本(==)。

为了比较对象,assert 模块提供了deepEqualnotDeepEqual。这些断言名称中的deep表示它们会递归地比较两个对象,比较两个对象的属性,如果属性本身也是对象,则也会进行比较。

使用ok测试异步值是否为真

现在是时候添加对待办应用doAsync方法的测试了,如列表 9.5 所示。因为这是一个异步测试,你需要提供一个回调函数(cb)来通知测试运行器测试何时完成;你不能像同步测试那样依赖函数返回来告诉你,因为同步测试可以这样做。为了检查doAsync的结果是否为值true,使用ok断言。ok断言提供了一个简单的方式来测试一个值是否为true

列表 9.5. 测试doAsync回调是否传递了true

图片

测试抛出的错误是否正确

你还可以使用 assert 模块来检查抛出的错误消息是否正确,如下面的列表所示。throws调用中的第二个参数是一个正则表达式,用于在错误消息中查找文本requires

列表 9.6. 测试add在缺少参数时是否抛出异常

图片

添加逻辑来运行你的测试

现在你已经定义了测试,你可以在文件中添加逻辑来运行每个测试。以下列表中的逻辑运行每个测试,然后打印已运行和完成的测试数量。

列表 9.7. 运行测试并报告测试完成

图片

你可以使用以下命令运行测试:

$ node chapter09-testing/listing_09_1-7/test.js

如果测试没有失败,脚本会告诉你已完成的测试数量。它还可以智能地跟踪测试何时开始执行以及何时完成,以防止单个测试中的缺陷。例如,一个测试可能执行而没有达到断言。

为了使用 Node 的内置功能,每个测试用例都必须包含大量的样板代码来设置测试(例如删除所有项目)以及跟踪进度(completed计数器)。所有这些样板代码都将注意力从编写测试用例的主要关注点转移开,最好留给一个专门的框架来做这些繁重的工作,而你则专注于测试业务逻辑。让我们看看如何通过使用第三方单元测试框架 Mocha 来简化事情。

9.1.2. Mocha

Mocha,一个流行的测试框架,易于掌握。尽管它默认使用 BDD 风格,但你也可以用它来使用 TDD 风格。Mocha 具有许多功能,包括全局变量泄漏检测和客户端测试。

全局变量泄漏检测

你很少需要可读的全局变量,并且将它们的使用量最小化被认为是编程的最佳实践。但在 ES5 中,很容易忘记在声明变量时包含 var 关键字而意外创建全局变量。Mocha 通过在测试期间创建全局变量时抛出错误来帮助检测意外的全局变量泄漏。

如果你想要禁用全局泄漏检测,请使用 --ignored-leaks 命令行选项运行 mocha。或者,如果你想允许使用选定数量的全局变量,你可以通过使用 --globals 命令行选项后跟一个逗号分隔的允许的全局变量列表来指定它们。

默认情况下,Mocha 测试是通过使用名为 describeitbeforeafterbeforeEachafterEach 的 BDD 风格函数来定义和设置其逻辑的。或者,你也可以使用 Mocha 的 TDD 接口,它用 suite 替换 describe,用 test 替换 it,用 setup 替换 before,用 teardown 替换 after。在我们的例子中,你将坚持使用默认的 BDD 接口。

使用 Mocha 测试 Node 应用程序

让我们直接深入探讨如何创建一个名为 memdb 的小型项目——一个内存数据库,并使用 Mocha 来测试它。首先,你需要为项目创建目录和文件:

$ mkdir -p memdb/test
$ cd memdb
$ touch index.js
$ touch test/memdb.js
$ npm init -y
$ npm install --save-dev mocha

打开 package.json 并添加一个 scripts 属性,该属性定义了如何运行测试:

"scripts": {
  "test": "mocha"
},

test 目录是测试将驻留的地方。默认情况下,Mocha 使用 BDD 接口。以下列表显示了它的样子(本书示例代码中的 chapter09-testing/memdb)。

列表 9.8. Mocha 测试的基本结构
const memdb = require('..');
describe('memdb', () => {
  describe('.saveSync(doc)', () => {
    it('should save the document', () => {
    });
  });
});

Mocha 还支持 TDD 和 qunit,并导出样式接口,这些接口在项目网站上详细说明(mochajs.org/)。为了说明接口的概念,这里有一个 exports 接口:

module.exports = {
  'memdb': {
    '.saveSync(doc)': {
      'should save the document': () => {
      }
    }
  }
}

所有这些接口都提供相同的功能,但你现在将坚持使用 BDD 接口,并在 test/memdb.js 中编写第一个测试,如下所示。这个测试使用 Node 的 assert 模块来执行断言。

列表 9.9. 描述 memdb 的 .save 功能

图片 9.09

要运行测试,你只需要执行 npm test。Mocha 默认在 ./test 目录中查找要执行的 JavaScript 文件。因为你还没有实现 .saveSync() 方法,所以你会看到定义的单个测试失败,如图 9.2 所示。

图 9.2. Mocha 中的失败测试

图片 9.2

让它通过!将以下列表中的代码添加到 index.js 中。

列表 9.10. 添加了保存功能

图片 9.10

再次使用 npm 运行测试,结果应该类似于 图 9.3。

图 9.3. Mocha 中的成功测试

图片 9.3

使用 Mocha 钩子定义设置和清理逻辑

列表 9.10 中的测试用例假设 memdb.first() 正确工作,因此你也会想为它添加一些测试用例。修订后的测试文件,列表 9.11,包括了一个新概念——Mocha 的 钩子 概念。BDD 接口暴露了 beforeEach()afterEach()before()after(),它们接受回调来定义设置和清理逻辑。

列表 9.11. 添加 beforeEach 钩子

理想情况下,测试用例没有任何共享状态。为了在 memdb 中实现这一点,你需要通过在 index.js 中实现 .clear() 方法来删除所有文档:

exports.clear = () => {
  db.length = 0;
};

再次运行 Mocha 应该会显示有三个测试通过了。

测试异步逻辑

我们在 Mocha 中还没有看到的是测试异步逻辑。为了了解这是如何完成的,你将对 index.js 中定义的其中一个函数进行小的修改。通过将 save 函数更改为以下内容,可以可选地提供一个回调,该回调将在一小段时间后执行(用于模拟某种异步操作):

exports.save = (doc, cb) => {
  db.push(doc);
  if (cb) {
    setTimeout(() => {
      cb();
    }, 1000);
  }
};

Mocha 测试用例可以通过向定义测试逻辑的函数添加一个参数来定义为异步。该参数通常命名为 done。以下列表展示了如何编写异步 save 方法的测试。

列表 9.12. 测试异步逻辑

这条规则适用于所有钩子。例如,用于清除数据库的 beforeEach() 钩子可以添加一个回调,Mocha 将等待它被调用,然后继续。如果 done() 以错误作为第一个参数被调用,Mocha 将报告错误并将钩子或测试用例标记为失败:

beforeEach((done) => {
  memdb.clear(done);
});

更多关于 Mocha 的信息,请查看其完整的在线文档:mochajs.org。Mocha 也可以用于客户端 JavaScript。

Mocha 的非并行测试使用

Mocha 按顺序执行测试,而不是并行执行,这使得测试套件执行得更慢,但编写测试更容易。但 Mocha 不会让任何测试运行过长时间。默认情况下,Mocha 允许任何给定的测试运行最多 2,000 毫秒,然后将其标记为失败。如果你有运行时间较长的测试,你可以使用 --timeout 命令行选项运行 Mocha,然后指定一个更大的数字。

对于大多数测试,串行运行测试是可行的。如果你发现这有问题,其他框架,如 Vows,可以并行执行,这将在下一节中介绍。

9.1.3. Vows

使用 Vows 单元测试框架可以编写的测试用例比许多其他框架的结构更清晰,这种结构旨在使测试易于阅读和维护。

Vows 使用自己的 BDD 风格的术语来定义测试结构。在 Vows 的领域里,一个测试套件包含一个或多个批次。一个批次可以被视为一组相关的上下文,或者您想要测试的概念性关注区域。批次和上下文并行运行。一个上下文可能包含一个主题、一个或多个誓言和/或一个或多个相关上下文(内部上下文也并行运行)。一个主题是与上下文相关的测试逻辑。一个誓言是对主题结果的测试。图 9.4 显示了 Vows 如何结构测试。

图 9.4. Vows 可以使用批次、上下文、主题和誓言来结构测试套件。

图片

Vows,就像 Mocha 一样,旨在自动化应用程序测试。主要区别在于风味和并行性,Vows 测试需要特定的结构和术语。在本节中,我们将通过一个示例应用程序测试来解释如何使用 Vows 测试同时运行多个测试。

通过使用 npm 安装来将 Vows 添加到待办事项项目中:

mkdir -p vows-todo/test
cd vows-todo
touch todo.js
touch test/todo-test.js
npm init -y
npm install --save-dev –g vows

您需要将 Vows 添加到 package.json 中的测试属性,以便可以通过输入npm test来运行测试:

"scripts": {
  "test": "vows test/*.js"
},
使用 Vows 测试应用程序逻辑

您可以通过运行包含测试逻辑的脚本或使用vows命令行测试运行器来在 Vows 中触发测试。以下是一个独立测试脚本的示例(可以像其他任何 Node 脚本一样运行),它使用了待办事项应用程序核心逻辑的一个测试。

列表 9.13 创建了一个批次。在批次内,您定义一个上下文。在上下文中,您定义一个主题和一个誓言。注意代码如何使用回调来处理主题中的异步逻辑。如果一个主题不是异步的,则可以返回一个值,而不是通过回调发送。将文件保存为 test/todo-test.js。

列表 9.13. 使用 Vows 测试待办事项应用程序

图片

您应该能够通过输入npm test来运行此测试。如果您使用npm i -g vows全局安装 Vows,您还可以通过输入以下命令来运行名为test的文件夹中的所有测试:

$ vows test/*

更多关于 Vows 的信息,请查看项目的在线文档(vowsjs.org/),如图 9.5 所示。

图 9.5. Vows 结合了具有宏和流程控制的完整功能 BDD 测试。

图片

Vows 提供了一套全面的测试解决方案,但您可以通过使用不同的断言库来混合和匹配测试库功能。也许你喜欢 Mocha,但不喜欢 Node 的断言库。下一节介绍了 Chai,这是一个可以替代 Node assert 模块的断言库。

9.1.4. 茶叶

Chai (chaijs.com/) 是一个流行的断言库,它提供了三个接口:shouldexpectassert。下面的列表中展示了 assert 接口,它看起来像 Node 的内置断言模块,但它提供了比较对象、数组和它们的属性的有用工具。例如,typeOf 可以用来比较类型,而 property 检查对象是否具有所需的属性。

列表 9.14. Chai 的 assert 接口

你可能想要尝试 Chai 的主要原因是 shouldexpect 接口。它们提供了流畅的 API,更像是 BDD 风格的库。以下是 expect 接口:

const chai = require('chai');
const expect = chai.expect;
const foo = 'bar';
expect(foo).to.be.a('string');
expect(foo).to.equal('bar');

这个 API 读起来更像一个英文句子——声明式风格更冗长,但更容易朗读。should 接口则相反:对象被装饰以具有额外的属性,因此你不需要像 expect 那样在调用中包裹断言:

const chai = require('chai');
chai.should();
const foo = 'bar';
foo.should.be.a('string');
foo.should.equal('bar');

决定使用哪个接口取决于项目。如果你是先编写测试,然后使用它们来记录项目,那么详尽的 expectshould 接口将工作得很好。JavaScript 纯粹主义者更喜欢 expect,因为它不会改变原型,但那些有 Ruby 经验的人可能熟悉 should 这样的 API。

使用 Chai 的主要优势是插件的范围。这包括一些方便的工具,如 chai-as-promised (chaijs.com/plugins/chai-as-promised/),它有助于测试使用 promises 的代码,以及 chai-stats (chaijs.com/plugins/chai-stats/),这是一个根据统计方法比较数字的库。请注意,Chai 是一个断言库,因此你应该与像 Mocha 这样的测试运行器一起使用。

与 Chai 类似的另一个 BDD 断言库是 Should.js。下一节将介绍 Should.js 并演示如何使用它编写测试。

9.1.5. Should.js

Should.js 是一个断言库,它允许你以 BDD(行为驱动开发)风格表达断言,从而使你的测试更容易阅读。它设计用于与其他测试框架一起使用,这样你就可以继续使用你自己的首选框架。在本节中,你将学习如何使用 Should.js 编写断言,并以一个自定义模块的测试为例。

Should.js 与其他框架易于使用,因为它通过单个属性 should 增强了 Object. --proto-type。这允许你编写如 user.role.should.equal('admin')users.should.include ('rick') 这样的表达性断言。

假设你正在编写一个 Node 命令行小费计算器,你希望用它来确定当你和朋友分账时谁应该支付多少钱。你希望以易于你的非程序员朋友理解的方式编写你的计算逻辑的测试,因为这样他们就不会认为你在欺骗他们。

要设置小费计算器应用程序,输入以下命令,这些命令设置应用程序的文件夹,然后安装 Should.js 进行测试:

mkdir -p tips/test
cd tips
touch index.js
touch test/tips.js

现在你可以通过运行以下命令来安装 Should.js:

npm init -y
npm install --save-dev should

接下来,编辑 index.js 文件,该文件将包含定义应用程序核心功能的逻辑。具体来说,小费计算器的逻辑包括四个辅助函数:

  • addPercentageToEach—将给定百分比增加到数组中的每个数字

  • sum—计算数组中每个元素的总和

  • percentFormat—格式化用于显示的百分比

  • dollarFormat—格式化用于显示的美元值

通过在 index.js 中填充以下列表的内容来添加此逻辑。

列表 9.15. 分账时计算小费的逻辑

现在编辑 test/tips.js 中的测试脚本,如下所示。该脚本加载小费逻辑模块;定义了税费、小费百分比和要测试的账单项目;测试了将百分比添加到每个数组元素;并测试了账单总额。

列表 9.16. 分账时计算小费的逻辑

使用以下命令运行脚本。如果一切顺利,脚本应该不会生成任何输出,因为没有抛出断言,你的朋友们会放心你的诚实:

$ node test/tips.js

为了使其更容易运行,将其添加到 package.json 中 scripts 下的测试属性:

"scripts": {
  "test": "node test/tips.js"
}

Should.js 支持许多类型的断言—从使用正则表达式的断言到检查对象属性的断言—允许全面测试应用程序生成的数据和对象。项目的 GitHub 页面 (github.com/shouldjs/should.js) 提供了 Should.js 功能的全面文档。

间谍、存根和模拟通常与断言库一起使用,以控制被测试代码的执行方式。下一节将演示如何使用 Sinon.JS 进行这些操作。

9.1.6. 使用 Sinon.JS 的间谍和存根

测试工具箱中的最后一个工具是模拟和存根库。我们编写单元测试的原因是隔离系统的一部分进行测试,但有时这很困难。例如,想象你正在测试调整图像大小的代码。你不想写入真实的图像文件,那么你该如何编写测试?代码不应该有特殊的测试分支来避免接触文件系统,因为那样你就不会真正测试代码。在这种情况下,你需要存根文件系统功能。编写存根的实践也有助于你进行真正的 TDD,因为你可以存根尚未准备好的依赖项。

在本节中,你将学习如何使用 Sinon.JS (sinonjs.org/) 来编写测试间谍、存根和模拟。在开始之前,创建一个新的项目并安装 Sinon:

mkdir sinon-js-examples
cd sinon-js-examples
npm init -y
mkdir test
npm i --save-dev sinon

接下来创建一个用于测试的示例文件。我们使用的例子是一个简单的 JSON 键/值数据库。我们的目标是能够存根文件系统 API,使其不在文件系统中创建真实文件。这将允许我们只测试数据库代码,而不是文件处理代码,如下一个列表所示。

列表 9.17. 数据库类
const fs = require('fs');

class Database {
  constructor(filename) {
    this.filename = filename;
    this.data = {};
  }

  save(cb) {
    fs.writeFile(this.filename, JSON.stringify(this.data), cb);
  }

  insert(key, value) {
    this.data[key] = value;
  }
}

module.exports = Database;

将列表保存为 db.js。现在你将尝试使用 Sinon 的间谍进行测试。

间谍

有时你只是想查看一个方法是否被调用。间谍非常适合这个用途。API 允许你用一个你可以用来进行断言的东西替换一个方法。为了模拟 db.js 中的 fs.writeFile 调用,使用 Sinon 的方法替换方法 spy

sinon.spy(fs, 'writeFile');

测试完成后,你可以使用 restore 获取原始方法:

fs.writeFile.restore();

在像 Mocha 这样的测试库中,你会在 beforeEachafterEach 块中放置这些调用。以下列表展示了使用间谍的完整示例。将此文件保存为 spies.js。

列表 9.18. 使用间谍

在设置间谍 之后,运行待测试的代码。然后你确保预期的方法被 sinon.assert 调用。然后恢复原始方法 。在这个测试中,恢复它并不是严格必要的,但始终恢复你更改的方法是最佳实践。

存根

有时你需要控制代码流程。例如,你可能想强制执行错误分支以测试你的代码中的错误处理。前面的例子可以重写为使用存根而不是间谍来执行 writeFile 的回调。请注意,你仍然想避免调用原始方法,而是强制测试代码运行提供的回调。下一个列表展示了如何使用存根替换函数。将其保存为 stub.js。

列表 9.19. 使用存根

使用存根和间谍的组合是测试大量使用用户提供的函数、回调和承诺的 Node 代码的理想选择。既然你已经了解了为单元测试设计的工具,让我们继续探讨一种完全不同的测试风格:功能测试。

9.2. 功能测试

在大多数 Web 开发项目中,功能测试通过驱动浏览器,然后检查各种 DOM 变化是否符合用户特定的要求列表。想象你正在构建一个内容管理系统。对于图像库上传功能的功能测试将上传一个图像,检查它是否被添加,然后检查它是否被添加到相应的图像列表中。

在 Node 中实现功能测试的工具选择令人眼花缭乱。然而,从高层次来看,它们可以分为两大类:无头测试和基于浏览器的测试。无头测试通常使用类似 PhantomJS 的工具来提供一个终端友好的浏览器环境,但更轻量级的解决方案则使用 Cheerio 和 JSDOM 等库。基于浏览器的测试使用 Selenium 等浏览器自动化工具(www.seleniumhq.org),这样你可以编写脚本驱动真实浏览器。两种方法都可以使用相同的底层 Node 测试工具,因此你可以使用 Mocha、Jasmine,甚至 Cucumber 来驱动 Selenium 测试你的应用程序。图 9.6 展示了示例测试环境。

图 9.6.使用浏览器自动化进行测试

图 9.6

在本节中,你将了解 Node 的功能测试解决方案,以便你可以根据你的要求设置测试环境。

9.2.1. Selenium

Selenium是一个流行的基于 Java 的浏览器自动化库。借助特定语言的驱动程序,你可以连接到 Selenium 服务器并针对真实浏览器运行测试。在本节中,你将学习如何使用 WebdriverIO(webdriver.io/),这是一个 Node Selenium 驱动程序。

运行 Selenium 比纯 Node 测试库更复杂,因为你需要安装 Java 并下载 Selenium JAR 文件。为你的操作系统下载 Java,然后转到 Selenium 下载网站(docs.seleniumhq.org/download/)下载 JAR 文件。然后你可以这样运行 Selenium 服务器:

java -jar selenium-server-standalone-2.53.0.jar

注意,你的 Selenium 版本可能不同。你可能还需要提供一个浏览器二进制的路径。例如,在 Windows 10 上,将 Firefox 设置为browserName,你可以这样指定 Firefox 的完整路径:

java -jar -Dwebdriver.firefox.driver="C:\path\to\firefox.exe" selenium-server-standalone-3.0.1.jar

完整路径将取决于 Firefox 在你的机器上的安装方式。有关 Firefox 驱动程序的更多信息,请参阅 SeleniumHQ 文档(github.com/SeleniumHQ/selenium/wiki/FirefoxDriver)。Chrome 和 Microsoft Edge 的驱动程序配置方式类似。

现在创建一个新的 Node 项目并安装 WebdriverIO:

mkdir -p selenium/test/specs
cd selenium
npm init -y
npm install --save-dev webdriverio
npm install --save express

WebdriverIO 附带一个友好的配置文件生成器。要运行它,请运行wdio config

./node_modules/.bin/wdio config

按照提示并接受默认设置。图 9.7 显示了我的会话。

图 9.7.使用wdio配置 Selenium 测试

图 9.7

在 package.json 文件中更新wdio命令,以便可以使用npm test运行测试:

"scripts": {
  "test": "wdio wdio.conf.js"
},

现在向测试中添加一些内容。一个基本的 Express 服务器就足够了。以下列表中的示例将在后续列表中用于测试。将此列表保存为 index.js(在本书的示例代码中为 c09-testing/selenium/index.js)。

列表 9.20.示例 Express 项目
const express = require('express');
const app = express();
const port = process.env.PORT || 4000;

app.get('/', (req, res) => {
  res.send(`
<html>
  <head>
    <title>My to-do list</title>
  </head>
  <body>
    <h1>Welcome to my awesome to-do list</h1>
  </body>
</html>
  `);
});

app.listen(port, () => {
  console.log('Running on port', port);
});

WebdriverIO 的好处是它提供了一个简单、流畅的 API 来编写 Selenium 测试。语法清晰,易于学习——你甚至可以使用 CSS 选择器编写测试。下一节(在本书示例代码的 test/specs/todo-test.js 中找到)展示了设置 WebdriverIO 客户端并检查页面标题的简单测试。

列表 9.21. 一个 WebdriverIO 测试

图片

在 WebdriverIO 连接 之后,你可以使用客户端实例从你的应用程序中获取页面 。然后你可以查询浏览器中文档的当前状态——此示例使用 getTitle 从文档的 head 中获取 title 元素。如果你要查询文档中的 CSS 元素,可以使用 .elements 代替 (webdriver.io/api/protocol/elements.html)。有各种方法可以操作文档、表单,甚至是 cookie。

这个测试,看起来像本章中的其他 Mocha 测试,能够在一个 Node Web 应用程序上运行真实的浏览器。要运行它,请在端口 4000 上启动服务器:

PORT=4000 node index.js

然后输入 npm test。你应该看到 Firefox 打开,并在命令行中运行测试。如果你想使用 Chrome,打开 wdio.conf.js 并更改 browserName 属性。

使用 Selenium 进行更高级的测试

如果你使用 WebdriverIO 和 Selenium 测试一个更复杂的 Web 应用程序,该应用程序使用类似 React 或 Angular 这样的技术,你将想要查看 utility 方法。其中一些方法会在某些元素可用之前暂停测试,这对于可能异步渲染文档、根据远程数据可用性更新多次的 React 应用程序来说非常好。

查看一下 waitFor* 方法,例如 waitForVisible (webdriver.io/api/utility/waitForVisible.html) 以了解更多信息。

9.3. 处理失败的测试

当你在进行一个成熟的项目工作时,总会有测试开始失败的时候。Node 提供了几个工具来获取更多关于失败测试的详细信息,在本节中,你将了解如何丰富调试失败测试时生成的输出。

当测试失败时,首先要做的是生成更详细的日志输出。下一节将演示如何使用 NODE_DEBUG 来实现这一点。

9.3.1. 获取更详细的日志

当测试失败时,了解程序正在做什么非常有用。Node 有两种方式来做这件事:一种用于 Node 的内部模块,另一种用于 npm 模块。要调试 Node 的核心模块,请使用 NODE_DEBUG

使用 NODE_DEBUG

要了解 NODE_DEBUG 的工作原理,想象你有一个深层嵌套的文件系统调用,而你忘记使用回调函数。例如,以下示例将抛出异常:

const fs = require('fs');

function deeplyNested() {
fs.readFile('/');
}

deeplyNested();

堆栈跟踪只显示了关于异常的有限细节,特别是不包括异常起源的调用点的完整信息:

fs.js:60
      throw err;  // Forgot a callback but don't know where? Use NODE_DEBUG=fs
      ^

Error: EISDIR: illegal operation on a directory, read
    at Error (native)

没有有帮助的注释,许多程序员看到这样的跟踪并责怪 Node 的无帮助错误。但是,正如注释所指出的,可以使用 NODE_DEBUG=fs 来获取 fs 模块更多的信息。用这种方式运行脚本:

NODE_DEBUG=fs node node-debug-example.js

现在,您将看到一个更详细的跟踪,有助于调试问题:

fs.js:53
        throw backtrace;
        ^

Error: EISDIR: illegal operation on a directory, read
    at rethrow (fs.js:48:21)
    at maybeCallback (fs.js:66:42)
    at Object.fs.readFile (fs.js:227:18)
    at deeplyNested (node-debug-example.js:4:6)
    at Object.<anonymous> (node-debug-example.js:7:1)
    at Module._compile (module.js:435:26)
    at Object.Module._extensions..js (module.js:442:10)
    at Module.load (module.js:356:32)
    at Function.Module._load (module.js:311:12)
    at Function.Module.runMain (module.js:467:10)

从这个跟踪中可以清楚地看出,问题出在我们的文件中,在第四行的一个函数里,这个函数最初是从第七行调用的。这使得调试使用核心模块的任何代码都变得更容易,这不仅包括文件系统,还包括网络库,如 Node 的 HTTP 客户端和服务器模块。

使用 DEBUG

NODE_DEBUG 的公共替代品是 DEBUG。npm 上的许多包都在寻找 DEBUG 环境变量。它模仿了 NODE_DEBUG 使用的参数样式,因此您可以指定要调试的模块列表或使用 DEBUG='*' 来查看所有模块。图 9.8 展示了使用 DEBUG='*' 运行的来自 第四章 的项目。

图 9.8. 使用 DEBUG='*' 运行 Express 应用程序

如果您想将 NODE_DEBUG 功能集成到自己的项目中,请使用内置的 util.debuglog 方法:

const debuglog = require('util').debuglog('example');
debuglog('You can only see these messages by setting NODE_DEBUG=example!');

要创建配置了 DEBUG 的自定义调试日志记录器,您需要使用 npm 中的调试包(www.npmjs.com/package/debug)。您可以创建任意数量的日志记录器。想象您正在构建一个 MVC 网络应用程序。您可以分别为模型、视图和控制器创建单独的日志记录器。然后,当测试失败时,您将能够指定调试日志,以便调试应用程序的特定部分。以下列表(位于 ch09-testing/debug-example/index.js 中)展示了如何使用调试模块。

列表 9.22. 使用 debug 包
const debugViews = require('debug')('debug-example:views');
const debugModels = require('debug')('debug-example:models');

debugViews('Example view message');
debugModels('Example model message');

要运行此示例并查看视图日志,请将 DEBUG 设置为 debug-example:views

DEBUG=debug-example:views node index.js

调试日志的最后一个特性是,您可以在调试部分前加一个连字符来将其从日志中移除:

DEBUG='* -debug-example:views' node index.js

隐藏某些模块意味着您仍然可以使用通配符,但可以从输出中省略不需要或嘈杂的部分。

9.3.2. 获取更好的堆栈跟踪

如果您正在使用异步操作,包括您使用异步回调或承诺编写的任何内容,那么当堆栈跟踪不够详细时,您可能会遇到问题。npm 上的包可以帮助您在这种情况下。例如,当回调异步运行时,Node 不会保留操作排队时的调用栈。为了测试这一点,创建两个文件,一个名为 async.js,它定义了一个异步函数,另一个名为 index.js,它需要 async.js。以下片段是 async.js(位于书籍示例代码 ch09-testing/debug-stacktraces/async.js 中):

module.exports = () => {
  setTimeout(() => {
    throw new Error();
  })
};

而 index.js 只需要引入 async.js:

require('./async.js')();

现在如果你使用node index.js运行 index.js,你将得到一个简短的堆栈跟踪,它不会显示失败函数的调用者,只会显示抛出异常的位置:

    throw new Error();
    ^

Error
    at null._onTimeout (async.js:3:11)
    at Timer.listOnTimeout (timers.js:92:15)

为了改进这种报告,安装 trace 包 (www.npmjs.com/package/trace) 并使用node -r trace index.js运行它。-r标志告诉 Node 在加载其他任何内容之前先 require trace 模块。

堆栈跟踪的另一个问题是它们可能过于详细。这发生在跟踪包括太多关于 Node 内部细节的情况下。为了清理你的堆栈跟踪,使用clarify (www.npmjs.com/package/clarify)。同样,你可以使用-r标志运行它:

$ node -r clarify index.js
    throw new Error();
    ^

Error
    at null._onTimeout (async.js:3:11)

clarify特别有用,如果你想在 Web 应用的错误警报电子邮件中包含堆栈跟踪。

如果你正在 Node 中运行针对浏览器的代码,可能是作为同构 Web 应用的一部分,那么你可以通过使用 source-map-support (www.npmjs.com/package/source-map-support)来获得更好的堆栈跟踪。这可以通过-r标志运行,但它也适用于一些测试框架:

$ node -r source-map-support/register index.js
$ mocha --require source-map-support/register index.js

下次你遇到由异步代码生成的堆栈跟踪时,寻找像traceclarify这样的工具,以确保你从 V8 和 Node 提供的最佳功能中获益。

9.4. 摘要

  • 编写单元测试需要测试运行器,如 Mocha。

  • Node 有一个内置的断言库,称为 assert。

  • 还有其他断言库,包括 Chai 和 Should.js。

  • 如果你不想运行某些代码,例如网络请求,你可以使用 Sinon.JS。

  • Sinon.JS 还允许你监视代码并验证某些函数或方法是否已运行。

  • Selenium 可以通过脚本真实浏览器来编写浏览器测试。

第十章:部署 Node 应用程序和维护高可用性

本章涵盖

  • 选择托管 Node 应用程序的位置

  • 部署典型应用程序

  • 维护高可用性和最大化性能

开发 Web 应用是一回事,但将其投入生产是另一回事。对于每种 Web 技术,都有一些技巧和窍门可以提高稳定性和最大化性能,Node 也不例外。在本章中,你将了解如何为你的应用程序选择正确的部署环境,你还将了解如何维护高可用性。

以下部分概述了您将要部署的主要环境类型。然后您将了解如何保持高可用性。

10.1. 托管 Node 应用程序

你在这本书中开发的 Web 应用使用基于 Node 的 HTTP 服务器。浏览器可以与你的应用通信,而无需 Apache 或 Nginx 等专用 HTTP 服务器。然而,你可以在你的应用前面放置一个服务器,如 Nginx,这样 Node 就可以在之前能够运行 Web 服务器的任何地方托管。

云服务提供商,包括 Heroku 和 Amazon,也支持 Node。因此,您有三种方式以可靠和可扩展的方式运行您的应用程序:

  • 平台即服务— 在 Amazon、Azure 或 Heroku 上运行您的应用程序

  • 服务器或虚拟机— 在云中的 UNIX 或 Windows 服务器、私人托管公司或您的工作场所内部运行您的应用程序

  • 容器— 使用 Docker 等软件容器运行您的应用程序和任何其他相关服务

选择使用这三种方法中的哪一种可能会很困难,尤其是尝试它们并不总是容易。请注意,每个选项并不绑定到特定的供应商:例如,Amazon 和 Azure 都能够提供所有这些部署策略。为了了解哪种选项适合您,本节解释了它们的要求以及它们的优缺点。幸运的是,每个选项都有免费或负担得起的选项,因此它们都应该对业余爱好者和专业人士 alike 都可访问。

10.1.1. 平台即服务

使用平台即服务(PaaS),您通常通过注册服务、创建新应用程序并将 Git 远程添加到项目中来准备应用程序的部署。将应用程序推送到该远程位置将部署您的应用程序。默认情况下,它将在单个容器上运行——容器确切的定义因供应商而异——如果应用程序崩溃,服务将尝试重新启动应用程序。您将获得对日志、Web 和命令行界面的有限访问权限,用于管理您的应用程序。为了扩展,您将运行更多实例的应用程序,这会带来额外的费用。表 10.1 包含了典型 PaaS 提供的特性的概述。

表 10.1. PaaS 特性
易用性
特性 Git 推送部署,简单的水平扩展性
基础设施 抽象/黑盒
商业适用性 好:应用程序通常是网络隔离的
定价^([1]) 低流量:$$;热门网站:$$$
供应商 Heroku、Azure、AWS Elastic Beanstalk

¹

\(: 价格便宜,\)$$$: 价格昂贵

PaaS 提供商支持他们自己的首选数据库和第三方数据库。对于 Heroku,这是 PostgreSQL;对于 Azure,则是 SQL 数据库。数据库连接细节将在环境变量中,因此您可以在不将数据库凭据添加到项目源代码的情况下连接。PaaS 对业余爱好者来说很棒,因为它可以以便宜或有时免费的方式运行流量低的小项目。

一些供应商比其他供应商更容易使用:对于熟悉 Git 的程序员来说,Heroku 非常容易使用,即使几乎没有系统管理员或 DevOps 知识。PaaS 系统通常知道如何运行使用 Node、Rails 和 Django 等流行工具创建的项目,因此它们几乎是即插即用的。

示例:10 分钟内在 Heroku 上运行 Node

在本节中,你将部署一个应用程序到 Heroku。使用 Heroku 的默认设置,你将部署应用程序到一个轻量级的 Linux 容器,在 Heroku 术语中称为dyno,以服务你的应用程序。要将基本的 Node 应用程序部署到 Heroku,你需要以下先决条件:

在你有了这些元素之后,在命令行中登录 Heroku:

heroku login

Heroku 随后会提示你输入你的电子邮件地址和 Heroku 密码。接下来,创建一个简单的 Express 应用程序:

mkdir heroku-example
npm i -g express-generator
express
npm i

你可以运行npm start并访问 http://localhost:3000 来确保一切运行正常。下一步是创建一个 Git 仓库并创建一个 Heroku 应用程序:

git init
git add .
git commit –m 'Initial commit'
heroku create
git push heroku master

这显示了为你的应用程序生成的随机 URL 和一个 Git 远程仓库。无论何时你想部署,都可以使用 Git 提交你的更改并推送到heroku master。你可以使用heroku rename更改 URL 和应用程序的名称。

现在访问上一步骤中的herokuapp.com URL,查看你的基本 Express 应用程序。要查看应用程序日志,运行heroku logs,要进入应用程序的 dyno 的 shell,运行heroku run bash

Heroku 是运行 Node 应用程序的一种快速简单的方式。请注意,你不需要进行任何 Node 特定的定制——Heroku 默认运行基本的 Node 应用程序,无需额外配置。然而,有时你需要对环境有更多的控制,因此在下文中,我们将介绍使用服务器托管 Node 应用程序。

10.1.2. 服务器

拥有自己服务器相比 PaaS 有一些优势。你不必担心数据库的运行位置,如果你想的话,可以在同一服务器上安装 PostgreSQL、MySQL 甚至 Redis。你可以安装任何你喜欢的软件:自定义日志软件、HTTP 服务器、缓存层——这取决于你。表 10.2 总结了运行自己服务器的主要特性。

表 10.2. 服务器功能
易用性
功能 对整个堆栈有完全控制权,运行自己的数据库和缓存层
基础设施 对开发者(或系统管理员/DevOps)开放
商业适用性 如果你有能够维护服务器的员工,则适用性良好
定价 小型虚拟机:\(;大型托管服务器:\)$$$
供应商 Azure、Amazon、托管公司

你可以通过多种方式获取和维护服务器。你可以从 Linode 或 Digital Ocean 等公司获得便宜的虚拟机;这将是一个你可以按需配置的全服务器,但它将与同一硬件上的其他虚拟机共享资源。你也可以购买自己的硬件或租用服务器。一些托管公司提供托管服务,他们会帮助你维护服务器的操作系统。

你必须决定你想使用哪个操作系统。Debian 有几个版本,Node 在 Windows 和 Solaris 上也能很好地工作,所以选择比看起来更困难。

另一个关键的决定是如何将你的应用程序暴露给世界:流量可以从 80 端口和 443 端口重定向到你的应用程序,但你也可以在它前面放置 Nginx 来代理请求并可能处理静态文件。

你有多种方式将你的代码从你的仓库移动到服务器。你可以使用 scp、sftp 或 rsync 手动复制文件,或者使用 Chef 来控制多个服务器并管理发布。有些人设置了一个类似 Heroku 的 Git 钩子,它将根据对某个 Git 分支的推送自动更新服务器上的应用程序。

重要的是要认识到管理自己的服务器是困难的。配置需要大量工作,服务器还需要维护最新的操作系统错误修复和安全更新。如果你是爱好者,这可能会让你望而却步——但你将学到很多东西,也许会发现对 DevOps 的兴趣。

在虚拟机或完整服务器上运行 Node 应用程序不需要任何特殊的东西。如果你想看看在服务器上运行 Node 应用程序并长时间运行所使用的某些技术,请跳转到第 10.2 节,了解部署基础知识。否则,继续阅读以了解 Node 和 Docker。

10.1.3. 容器

使用软件容器是一种操作系统虚拟化,它自动化了应用程序的部署。最著名的项目是 Docker,它是开源的,但也提供商业服务,帮助你部署生产应用程序。表 10.3 显示了容器的主要功能。

表 10.3. 服务器功能
易用性 中等
功能 对整个堆栈有完全控制权,运行自己的数据库和缓存层,可以重新部署到各种提供商和本地机器
基础设施 对开发者(或系统管理员/DevOps)开放
商业适用性 极佳:部署到托管主机、Docker 主机或自己的数据中心
定价 $$
供应商 Azure、Amazon、Docker Cloud、Google Cloud Platform(带 Kubernetes)、允许你运行 Docker 容器的托管公司

Docker 允许你以镜像的形式定义你的应用程序。如果你构建了一个典型的内容管理系统,它有一个用于图像处理的微服务、一个用于存储应用程序数据的主要服务,然后是一个后端数据库,你可以使用四个单独的 Docker 镜像来部署它:

  • 图 1— 用于调整上传到 CMS 的图像的微服务

  • 图 2— PostgreSQL

  • 图 3— 带有管理界面的主要 CMS Web 应用程序

  • 图 4— 公共前端 Web 应用程序

由于 Docker 是开源的,您在部署 Docker 化应用程序时不受单一供应商的限制。您可以使用 Amazon 的 Elastic Beanstalk 来部署您的镜像,Docker Cloud,甚至 Microsoft 的 Azure。Amazon 还提供 EC2 容器服务(ECS),以及 AWS CodeCommit 用于云 Git 仓库,这些都可以以类似于 Heroku 的方式部署到 Elastic Beanstalk。

使用容器的一个令人惊叹之处在于,在您将应用程序容器化之后,您只需一个命令就可以启动它的一个全新实例。如果您得到一台新电脑,您只需检出应用程序的仓库,在本地安装 Docker,然后运行脚本以启动您的应用程序。因为您的应用程序有一个定义良好的部署配方,所以您和您的合作者更容易理解应用程序应该在本地开发环境之外如何运行。

示例:使用 Docker 运行 Node 应用程序

示例:nodejs.org/en/docs/guides/nodejs-docker-webapp/

要使用 Docker 运行 Node 应用程序,您首先需要做一些事情:

  1. 安装 Docker: docs.docker.com/engine/installation/.

  2. 创建一个 Node 应用程序。有关如何快速创建示例 Express 应用程序的详细信息,请参阅第 10.1.1 节,平台即服务。

  3. 在项目中添加一个名为 Dockerfile 的新文件。

Dockerfile 告诉 Docker 如何构建您的应用程序镜像,以及如何安装和运行应用程序。您将通过在 Dockerfile 中指定FROM node:boron来使用官方 Node Docker 镜像 (hub.docker.com/_/node/),然后使用RUNCMD指令运行npm installnpm start。以下是一个适用于简单 Node 应用程序的完整 Dockerfile 示例:

FROM node:argon

RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app

COPY package.json /usr/src/app/
RUN npm install

COPY . /usr/src/app

EXPOSE 3000
CMD ["npm", "start"]

创建 Dockerfile 后,在终端中运行docker build (docs.docker.com/engine/reference/commandline/build/) 命令来创建应用程序镜像。您只需指定要构建的目录,因此如果您在示例 Express 应用程序中,应该能够键入docker build .来构建镜像并将其发送到 Docker 守护进程。

运行docker images以查看镜像列表。获取镜像 ID,然后运行docker run -p 8080:3000 -d <image ID>来运行应用程序。我们已经将内部端口(3000)绑定到本地的 8080 端口,因此要访问应用程序,我们可以在浏览器中使用 http://localhost:8080

10.2. 理解部署基础知识

假设您创建了一个您想要展示的 Web 应用程序,或者您可能创建了一个商业应用程序,在将其投入全面生产之前需要对其进行测试。您可能从简单的部署开始,然后在以后的工作中最大限度地提高正常运行时间和性能。在本节中,我们将向您介绍一个简单的、临时的 Git 部署,以及如何使用 Forever 保持应用程序运行和运行的详细信息。临时部署在重启后不会持久存在,但它们的优势是设置快速。

10.2.1. 从 Git 仓库部署

让我们快速通过使用 Git 仓库的基本部署步骤来让您了解基本步骤。部署通常是通过以下步骤完成的:

  1. 使用 SSH 连接到服务器。

  2. 如果需要,在服务器上安装 Node 和版本控制工具(如 Git 或 Subversion)。

  3. 从版本控制仓库将应用程序文件(包括 Node 脚本、图像和 CSS 样式表)下载到服务器上。

  4. 启动应用程序。

这是在使用 Git 下载应用程序文件后启动应用程序的一个示例:

git clone https://github.com/Marak/hellonode.git
cd hellonode
node server.js

与 PHP 一样,Node 不会作为后台任务运行。因此,我们概述的基本部署需要保持 SSH 连接打开。一旦 SSH 连接关闭,应用程序将终止。幸运的是,通过使用简单的工具,保持应用程序运行相当容易。

自动化部署

您可以通过多种方式自动化部署您的 Node 应用程序。其中一种方法是使用像 Fleet(github.com/substack/fleet)这样的工具,它允许您通过使用git push将应用程序部署到一台或多台服务器上。另一种更传统的方法是使用 Capistrano,这在 Evan Tahler 的 Bricolage 博客上的“使用 Capistrano 部署 node.js 应用程序”文章中有详细说明(blog.evantahler.com/deploying-node-js-applications-with-capistrano-af675cdaa7c6#.8r9v0kz3l)。

10.2.2. 保持 Node 运行

假设您使用 Ghost 博客应用程序(ghost.org/)创建了一个个人博客,并且您想要部署它,确保即使您断开 SSH 连接,它也能保持运行。

在 Node 社区中,处理此问题的最受欢迎的工具是 Nodejitsu 的 Forever(github.com/foreverjs/forever)。它可以在您断开 SSH 连接后保持应用程序运行,并在应用程序崩溃时重新启动它。图 10.1 从概念上展示了 Forever 是如何工作的。

图 10.1. Forever 工具帮助您保持应用程序运行,即使它崩溃。

您可以使用sudo命令全局安装 Forever。

sudo 命令

有时在全局安装 npm 模块(使用-g标志)时,你需要将npm命令前缀为sudo命令(www.sudo.ws),以便以超级用户权限运行 npm。第一次使用sudo命令时,你将被提示输入你的密码。然后运行它后面的指定命令。

如果你正在跟随,现在请使用以下命令安装 Forever:

npm install -g forever

安装 Forever 后,你可以使用以下命令来启动你的博客并保持其运行:

forever start server.js

如果你想出于某种原因停止你的博客,你可以使用 Forever 的stop命令:

forever stop server.js

当使用 Forever 时,你可以使用它的list命令来获取它正在管理的应用程序列表:

forever list

Forever 的另一个有用功能是它可以在任何源文件更改时可选地重新启动你的应用程序。这让你从每次添加功能或修复错误时都需要手动重新启动的麻烦中解脱出来。

要以这种方式启动 Forever,请使用-w标志:

forever -w start server.js

虽然 Forever 是一个部署应用程序的极其有用的工具,但你可能想要使用功能更全面的工具进行长期部署。在下一节中,你将了解一些工业级监控解决方案,并了解如何最大化应用程序性能。

10.3. 最大化运行时间和性能

当一个 Node 应用程序值得发布时,你想要确保它在服务器启动和停止时启动和停止,并且在服务器崩溃时自动重新启动。很容易忘记在重启前停止应用程序,或者忘记在之后重新启动应用程序。

你还想要确保你正在采取步骤来最大化性能。例如,当你在一个拥有四核 CPU 的服务器上运行你的应用程序时,只使用单个核心是没有意义的。如果你只使用单个核心,并且你的 Web 应用程序的流量显著增加,单个核心可能没有足够的处理能力来处理流量,你的 Web 应用程序将无法持续响应。

除了使用所有 CPU 核心外,你还想避免使用 Node 为高流量生产站点托管静态文件。Node 针对交互式应用程序,如 Web 应用程序和 TCP/IP 协议,它不能像专门为此优化的软件那样有效地服务静态文件。对于服务静态文件,你应该使用像 Nginx(nginx.org/en/)这样的技术,它专门用于服务静态文件。或者,你也可以将所有静态文件上传到内容分发网络(CDN),如 Amazon S3(aws.amazon.com/s3/),并在你的应用程序中引用这些文件。

本节涵盖了一些服务器运行时间和性能提示:

  • 使用 Upstart 保持你的应用程序在重启和崩溃中持续运行

  • 使用 Node 的集群 API 进行多核处理器

  • 使用 Nginx 服务 Node 应用程序的静态文件

让我们先看看一个强大且易于使用的工具,用于维护正常运行时间:Upstart。

10.3.1. 使用 Upstart 维护正常运行时间

假设你对一个应用程序感到满意,并希望将其推向全球市场。你想要确保,如果你重启服务器,不要忘记重启你的应用程序。你还想确保,如果你的应用程序崩溃,它不仅会自动重启,而且崩溃会被记录下来,你会收到通知,这样你可以诊断任何潜在的问题。

Upstart (upstart.ubuntu.com) 是一个项目,它提供了一种优雅的方式来管理任何 Linux 应用程序的启动和停止,包括 Node 应用程序。现代版本的 Ubuntu 和 CentOS 支持使用 Upstart。对于 macOS 的替代方案是创建 launchd 文件(npm 上的 node-launchd 可以做到这一点),而 Windows 的等效方案是使用 Windows 服务,这由 npm 上的 node-windows 包支持。

如果你还没有安装 Upstart,可以使用以下命令在 Ubuntu 上安装:

sudo apt-get install upstart

如果你还没有安装 Upstart,可以使用以下命令在 CentOS 上安装:

sudo yum install upstart

在安装 Upstart 之后,你需要为你的每个应用程序添加一个 Upstart 配置文件。这些文件位于 /etc/init 目录中,命名类似于 my_application_name.conf。配置文件不需要标记为可执行。

以下为本章示例应用程序创建一个空的 Upstart 配置文件:

sudo touch /etc/init/hellonode.conf

现在将以下列表的内容添加到你的配置文件中。此设置将在服务器启动时运行应用程序,并在关闭时停止应用程序。exec 部分将由 Upstart 执行。

列表 10.1. 一个典型的 Upstart 配置文件

此配置将在服务器重启后以及意外崩溃后保持进程运行。所有应用程序生成的输出都将发送到 /var/log/upstart/hellonode.log,Upstart 将为你管理日志轮转。

现在你已经创建了一个 Upstart 配置文件,你可以使用以下命令启动你的应用程序:

sudo service hellonode

如果你的应用程序启动成功,你会看到类似以下的行:

hellonode start/running, process 6770

Upstart 具有高度的可配置性。查看在线食谱(upstart.ubuntu.com/cookbook/),了解所有可用选项。

Upstart 和重启

当使用 respawn 选项时,Upstart 在默认情况下会在崩溃时不断重新加载你的应用程序,除非应用程序在 5 秒内重启了 10 次。你可以通过使用 respawn limit COUNT INTERVAL 选项来更改此限制,其中 COUNT 是在 INTERVAL(以秒为单位指定)内的次数。例如,你可以这样设置 5 秒内 20 次的限制:

respawn
respawn limit 20 5

如果你的应用程序在 5 秒内(默认限制)重新加载 10 次,通常意味着代码或配置有问题,它将无法成功启动。Upstart 达到限制后不会尝试重新启动,以节省其他进程的资源。

在 Upstart 之外进行健康检查是个好主意,它可以通过电子邮件或其他快速通信方式向开发团队提供警报。对于一个 Web 应用程序,健康检查可以简单地涉及访问网站并查看你是否得到了有效的响应。你可以自己编写方法或使用 Monit (mmonit.com/monit/) 或 Zabbix (www.zabbix.com) 等工具来完成这项工作。

现在你已经知道了如何让你的应用程序在崩溃和服务器重启的情况下继续运行,接下来的一个合乎逻辑的担忧是性能。Node 的集群 API 可以帮助解决这个问题。

10.3.2. 集群 API:利用多个核心

大多数现代计算机 CPU 都有多个核心,但 Node 进程在运行时只使用其中之一。如果你在服务器上托管 Node 应用程序并希望最大化服务器的使用,你可以手动在不同的 TCP/IP 端口上启动多个应用程序实例,并使用负载均衡器将这些 Web 流量分配到这些实例,但这需要繁琐的设置。

为了使单个应用程序更容易使用多个核心,Node 中添加了集群 API。这个 API 使得你的应用程序能够同时在不同的核心上运行多个工作者,每个工作者都执行相同的事情并响应相同的 TCP/IP 端口。图 10.2 展示了在四核处理器上使用集群 API 组织应用程序处理的方式。

图 10.2. 在四核处理器上主进程产生三个工作者

下面的列表会自动为每个额外的核心启动一个主进程和一个工作者。

列表 10.2. Node 的集群 API 演示

由于主进程和工作者运行在独立的操作系统进程中,这是它们在单独的核心上运行所必需的,因此它们不能通过全局变量共享状态。但集群 API 确实为主进程和工作者提供了通信的途径。

下面的列表显示了一个示例,其中消息在主进程和工作者之间传递。主进程维护所有请求的计数,每当工作者报告处理了一个请求,它就会被转发给每个工作者。

列表 10.3. Node 的集群 API 演示

使用 Node 的集群 API 是创建利用现代硬件优势的应用程序的一种简单方法。

10.3.3. 托管静态文件和代理

虽然 Node 是提供动态 Web 内容的有效解决方案,但它不是提供静态文件(如图片、CSS 样式表或客户端 JavaScript)的最有效方式。在 HTTP 上提供静态文件是一项特定的任务,特定的软件项目已经针对这项任务进行了优化,因为它们已经专注于这项任务多年。

幸运的是,Nginx (nginx.org/en/),一个针对提供静态文件优化的开源 Web 服务器,与 Node 一起设置起来非常简单,用于提供这些文件。在典型的 Nginx/Node 配置中,Nginx 最初处理每个 Web 请求,将不是静态文件的请求回传给 Node。图 10.3 展示了这种配置。

图 10.3. 您可以使用 Nginx 作为代理快速将静态资源回传给 Web 客户端。

图片

下面的列表中的配置,将被放置在 Nginx 配置文件的 http 部分,实现了这种设置。配置文件通常存储在 Unix 服务器的 /etc 目录中的 /etc/nginx/nginx.conf。

列表 10.4. 使用 Nginx 代理 Node.js 和提供静态文件的配置文件

图片

图片

通过使用 Nginx 处理您的静态网络资源,您确保 Node 专注于它最擅长的事情。

10.4. 概述

  • Node 应用程序可以由 PaaS 提供商、专用服务、虚拟专用服务器和云托管来托管。

  • 您可以使用 Forever 和 Upstart 快速将 Node 应用程序部署到 Linux 服务器。

  • 为了使您的应用程序性能更佳,Node 的集群模块允许您运行多个进程。

第三部分:超越 Web 开发

数百万的人依赖于用 Node 构建的应用程序。如果你曾经使用过 Slack 或 Visual Studio Node,你就使用过由 Node 提供动力的应用程序。这部分介绍了 Electron 和用于用 Node 编写命令行工具的模块。如果你曾经想要为 Linux、macOS 或 Windows 制作应用程序,现在你可以做到了。

第十一章:编写命令行应用程序

本章涵盖

  • 通过使用常见约定设计命令行应用程序

  • 通过管道进行通信

  • 使用退出码

Node 命令行实用工具被广泛应用于各个领域,从项目自动化工具,如 Gulp 和 Yeoman,到 XML 和 JSON 解析器。如果你曾经想知道如何使用 Node 构建命令行工具,这一章将向你展示你需要知道的一切来开始。你将学习 Node 程序如何接受命令行参数以及如何通过管道处理 I/O。我们还包含了有助于你更有效地使用命令行的 shell 小贴士。

虽然使用 Node 构建命令行工具并不难,但遵循社区约定很重要。这一章包括了这些约定中的许多,这样你就能编写其他人可以使用而无需过多文档的工具。

11.1. 理解约定和哲学

命令行开发的一个重要部分是理解现有程序使用的约定。作为一个现实世界的例子,看看 Babel:

Usage: babel [options] <files ...>

Options:

  -h, --help                           output usage information
  -f, --filename [filename]            filename to use when reading from stdin
[ ... ]
  -q, --quiet                          Don't log anything
  -V, --version                        output the version number

在这里有几个要点值得注意。第一个是使用 -h--help 打印帮助:这是一个许多程序使用的标志。第二个标志是 -f 用于文件名——这是一个容易记忆的助记符。许多标志都是基于助记符的。使用 -q 进行安静输出也是一个流行的约定,同样 -v 用于显示程序的版本。你的应用程序应该包括这些标志。

然而,这个用户界面不仅仅是一个约定。使用连字符和双连字符 (--) 的做法得到了开放组实用约定(Utility Conventions)的认可。1 这份文件甚至指定了它们应该如何使用:

¹

“开放组基础规范第 7 版”,pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap11.html

  • 指南 4— 所有选项都应该以 - 分隔符字符开头。

  • 指南 10— 第一个不是选项参数的 -- 参数应该被接受为分隔符,表示选项的结束。任何随后的参数都应该被视为操作数,即使它们以 - 字符开头。

命令行应用程序设计的另一个方面是哲学。这可以追溯到 UNIX 的创造者,他们希望设计“小巧、锋利”的工具,这些工具可以与简单的基于文本的界面一起使用。

这是 UNIX 哲学:编写只做一件事并且做得好的程序。编写可以一起工作的程序。编写可以处理文本流的程序,因为这是一个通用的接口。

Doug McIlroy^([2])

²

Unix 哲学基础”, www.catb.org/~esr/writings/taoup/html/ch01s06.html

在本章中,我们提供了一个关于 shell 技术和 UNIX 约定的广泛概述,以便你可以设计其他人可以使用的命令行工具。我们还提供了 Windows 特定使用的指导,但就大部分而言,你的 Node 工具应该默认是跨平台的。

Shell 技巧:获取帮助

如果你在使用 shell 时遇到困难,尝试输入 man <cmd>。这将加载该命令的手册页。

如果你记不住命令的名称,可以使用 apropos <cmd> 来搜索系统命令的数据库。

11.2. 介绍 parse-json

对于 JavaScript 程序员来说,一个最简单且有用的应用程序是读取 JSON 并在它有效时打印它。通过遵循本章,你将重新创建这个工具。

让我们从这个应用程序的命令行应该是什么样子开始。以下代码片段显示了如何调用这样的程序:

node parse-json.js -f my.json

你需要做的第一件事是弄清楚如何从命令行获取 -f my.json;这些是程序的参数。你还需要从标准输入读取输入。继续阅读以了解如何完成这两件事。

11.3. 使用命令行参数

大多数但并非所有命令行程序都接受参数。Node 有一个内置的方式来处理这些参数,但 npm 上的第三方模块提供了额外的功能。你需要这些功能来实现一些广泛使用的约定。继续阅读以了解更多。

11.3.1. 解析命令行参数

可以通过使用 process.argv 数组来访问命令行参数。数组中的项是在运行命令时传递给 shell 的字符串。因此,如果你拆分了命令,就可以弄清楚数组中的每个项是什么。process.argv[0] 项是 nodeprocess.argv[1] 项是 parse-json.js[2]-f,以此类推。

如果你以前使用过命令行应用程序,你可能见过带有 --- 的参数。这些前缀是向应用程序传递选项的特殊约定:-- 表示选项名称的完整字符串,而 - 表示选项名称的单个字符。npm 命令行二进制文件是这种约定的一个很好的例子,其中 -h--help

参数约定

其他参数约定如下:

  • --version 打印应用程序的版本

  • -y--yes 使用任何缺失选项的默认值

为参数添加别名,如 -h–-help,在添加了对多个选项的支持后会使解析变得困难,但幸运的是有一个名为 yargs 的模块用于解析参数。以下代码片段显示了 yargs 在最简单情况下的工作方式。你所需要做的就是引入 yargs,然后访问 argv 属性来检查传递给脚本的参数:

const argv = require('yargs').argv;
console.log({ f: argv.f });

图 11.1 显示了 Node 内置的命令行参数与 yargs 生成的对象之间的差异。

图 11.1. Node 的 argv 与 yargs 的比较

虽然选项对象很有用,但它并没有为验证参数和生成使用文本提供太多结构。下一节将展示如何描述和验证参数。

11.3.2. 验证参数

yargs 模块包括用于验证参数的方法。以下列表展示了如何使用 yargs 解析您的 JSON 解析器所需的-f参数,并使用describenargs方法强制执行预期的参数格式。

列表 11.1. 使用 yargs 解析命令行参数

使用 yargs 比操作process.argv数组更容易,而且更好,因为可以强制执行规则。列表 11.1 使用demand强制一个参数,然后声明它需要一个参数,这个参数将是解析的 JSON 文件。为了使程序更容易使用,您还可以使用 yargs 提供使用文本。这里的约定是在传递-h--help时打印使用文本。您可以使用 yargs 添加这些,如下面的代码片段所示:

yargs
  // ...
  .usage('parse-json [options]')
  .help('h')
  .alias('h', 'help')
  // ...

现在,您的 JSON 解析器可以接受文件参数并处理文件。然而,对于这个项目来说,文件处理还没有完成,因为它还需要接受 stdin。继续阅读,了解如何使用常见的 UNIX 约定来完成这一点。

Shell 技巧:历史记录

您的 shell 存储了您之前输入的命令记录。输入history来查看记录;这通常被别名h

11.3.3. 将 stdin 作为文件传递

如果文件参数以连字符(-f -)给出,这意味着从 stdin 获取数据。这是另一个常见的命令行约定。您可以使用 mississippi 包轻松完成此操作。但是,在调用JSON.parse之前,您必须将所有管道到您的应用程序的数据连接起来,因为它期望一个完整的 JSON 字符串来解析。使用 mississippi 模块,示例现在看起来如下所示。

列表 11.2. 从 stdin 读取文件
#!/usr/bin/env node
const concat = require('mississippi').concat;
const readFile = require('fs').readFile;
const yargs = require('yargs');
const argv = yargs
  .usage('parse-json [options]')
  .help('h')
  .alias('h', 'help')
  .demand('f') // require -f to run
  .nargs('f', 1) // tell yargs -f needs 1 argument after it
  .describe('f', 'JSON file to parse')
  .argv;
const file = argv.f;
function parse(str) {
  const value = JSON.parse(str);
  console.log(JSON.stringify(value));
}

if (file === '-') {
  process.stdin.pipe(concat(parse));
} else {
  readFile(file, (err, dataBuffer) => {
    if (err) {
      throw err;
    } else {
      parse(dataBuffer.toString());
    }
  });
}

此代码加载 mississippi 并将其命名为concat。然后使用concat与 stdin 流。因为 mississippi 接受一个接收最终完整数据集的函数,所以原始的列表 11.1 中的parse函数仍然可以使用。这仅在文件名为-时进行。

11.4. 使用 npm 共享命令行工具

您希望他人能够使用的任何应用程序都应该能够通过 npm 轻松安装。使 npm 能够识别命令行应用程序的最简单方法是在 package.json 中使用bin字段。此字段使得 npm 安装的可执行文件对当前项目的任何脚本都可用。如果使用npm install --globalbin字段还会告诉 npm 全局安装可执行文件。这不仅仅对 Node 开发者有用,对任何可能想要使用您的脚本的任何人也是如此。

这个片段和列表 11.2 中的#!/usr/bin/env node行就是本章中 JSON 解析器示例所需的所有内容:

...
    "name": "parse-json",
    "bin": {
      "parse-json": "index.js"
    },
...

如果你使用npm install –global安装此包,它将使 parse-json 命令在系统范围内可用。要尝试它,打开一个终端(或在 Windows 中的命令提示符)并输入parse-json。请注意,即使在 Windows 上,这也适用,因为 npm 会自动安装一个包装器,使其在 Windows 上透明地工作。

11.5. 使用管道连接脚本

parse-json 程序很简单——它接受文本并验证它。如果你有其他想要与之一起使用的命令行工具怎么办?想象一下,你有一个程序可以为 JSON 文件添加语法高亮。如果 JSON 首先被解析然后高亮显示,那就太好了。在本节中,你将了解管道,它可以做所有这些以及更多。

你将使用 parse-json 和其他程序通过管道执行复杂的工怍流程。Windows 和 Unix shell 不同,但幸运的是,两者的重要部分是相同的。在调试期间会出现一些差异,但它们不应该影响你编写命令行应用程序时的情况。

11.5.1. 将数据管道输入到 parse-json

连接命令行应用程序的主要方式称为管道。管道是将一个应用程序的 stdout 连接到另一个进程的 stdin 流。它是进程间通信的核心组件:使程序能够相互通信。你可以在 Node 中使用process.stdin访问 stdin,因为它是一个可读流。查看以下代码以解析来自 stdin 的 JSON:

echo "[1,2,3]" | parse-json -f –

注意到|字符。这告诉 shell,echo '{}'应该将其输出发送到 parse-json 的 stdin。

Shell 技巧:键盘快捷键

现在你已经看到了管道的工作方式,你可以通过将historygrep结合来搜索命令历史:

history | grep node

访问先前命令的更好方法是使用键盘上的上箭头和下箭头。人们经常这样做——但还有更好的方法!输入 Ctrl-R 以递归搜索命令历史。这让你可以根据部分文本匹配查找长命令。

这里有一些快捷键:Ctrl-S 执行正向搜索,Ctrl-G 终止搜索。你还可以使用这些快捷键更有效地编辑文本:Ctrl-W 删除单词,ALT-F/B 向前或向后移动一个单词,Ctrl-A/E 移动到行的开始或结束。

11.5.2. 与错误和退出码一起工作

目前程序没有输出任何内容。但如果你给它错误的数据,你怎么知道它能够成功完成,即使你不知道可执行文件的预期输出?答案是退出码。你可以看到你运行的最后一个命令的退出码,但请注意,由于管道,echonode命令被视为一个单独的命令单元。

在 Windows 上,你可以使用以下方法检查退出码:

echo %errorlevel%

在 UNIX 上,你可以使用以下命令查看退出码:

echo $?

如果命令成功,它有一个退出代码为 0(零)。所以如果你向脚本提供错误的 JSON,它应该以非零值退出:

parse-json -f invalid.json

如果你运行这个程序,它将以非零状态退出并打印出错误信息,表明原因。这是因为当错误被抛出但未被捕获时,Node 会自动退出并打印错误信息。

错误流

虽然将输出打印到控制台可能很有用,但将其保存到文件以便阅读更好,因为你可以将其保留用于调试目的。幸运的是,你可以通过 shell 重定向 stdout 流来实现这一点:

echo 'you can overwrite files!' > out.log
echo 'you can even append to files!' >> out.log

当你尝试使用无效的 JSON 时,parse-json 保存错误信息是有意义的:

parse-json -f invalid.json >out.log

但这样做不会记录任何错误。一旦你理解了 stderr 和 stdout 之间的区别,这将是预期的行为:

  • stdout 是供其他命令行应用程序消费的。

  • stderr 是供开发者消费的。

当调用console.error或抛出错误时,Node 会将日志记录到 stderr。这与echo不同,echo将日志记录到 stdout,就像console.log一样。有了这些知识,你可能希望将 stderr 重定向到文件而不是 stdout。幸运的是,这是一个简单的更改。

stdin、stdout 和 stderr 流分别与 0 到 2 的数字相关联。stderr 的流号为 2。你可以通过使用2> out.log来重定向它,这告诉 shell 你想要重定向的流号以及放置输出的文件:

parse-json -f invalid.json 2> out.log

重定向输出是管道所做的,但使用的是进程而不是文件。以下是一个示例片段:

node -e "console.log(null)" | parse-json

你正在记录null并将其通过管道传递给 parse-json。在这里,null不会被记录到控制台,因为它只被传递到下一个命令。假设你做类似的事情,但使用console.error

node -e "console.error(null)" | parse-json

你会看到一个错误,因为没有文本被发送到 parse-json 进行消费。null被记录到 stderr 并将打印到控制台。数据应该通过 stdout 而不是 stderr 进行管道传输。

图 11.2 展示了如何使用管道和编号输出流来连接程序,然后将输出路由到单独的文件中。

图 11.2. 结合管道和输出流

图片 11fig02

Node 还有一个用于处理管道的 API。它基于 Node 流,因此你可以用它来处理实现了 Node 流类的任何东西。继续阅读,了解更多关于 Node 中管道的信息。

Shell 技巧:清除一行

其中一些命令相当长;当你需要删除一个长命令但又不想运行它时,你会怎么做?一个有用的快捷键是 Ctrl-U,它可以删除当前行。如果你输入 Ctrl-Y,你会得到该行,所以你可以像使用复制和粘贴一样使用这些键盘命令。

11.5.3. 在 Node 中使用管道

你现在将通过使用 Node 的 API 来学习管道的工作原理。为此,你需要编写一个简短的脚本,显示程序运行所需的时间,而不会中断管道。

一个程序可以通过等待 stdin 关闭然后管道传输结果到 stdout 来监控管道而不会中断它。因为 Node 程序在没有更多输入可消费时结束,所以你可以在程序退出时打印一条消息。以下是一个示例,你可以将其保存为 time.js 来尝试:

process.stdin.pipe(process.stdout);
const start = Date.now();
process.on('exit', () => {
  const timeTaken = Date.now() - start;
  console.error(`Time (s): ${timeTaken / 1000}`);
});

通过再次将数据管道传输到 stdout,你可以在管道命令的中间放置 time.js,并且它们仍然可以正常工作!实际上,parse-json 和 time.js 都可以很容易地与管道一起使用。例如,这显示了解析 JSON 并发送数据所需的时间:

parse-json -f test.json | node time.js

现在你已经对输出内容以及如何从其他应用程序获取输入有了基本的了解,你可以开始制作更复杂的应用程序。但首先,我们应该讨论在进程之间管道传输时的计时问题。

Shell 技巧:完成

除了提供命令历史记录外,大多数 shell 在按下 Tab 键时能够匹配命令或文件。有些甚至允许你使用 Alt-?查看完成情况。

11.5.4. 管道和命令执行顺序

当你使用管道命令时,每个命令都会立即开始执行。命令之间不会以任何方式等待彼此。这意味着管道传输的数据不会等待任何命令退出,你只能消费它给出的数据。因为命令不会等待,所以你无法知道前一个命令是如何退出的。

假设你只想在 JSON 成功解析时记录一条消息。为此,你需要新的运算符。&&||运算符在 shell 中的行为与在 JavaScript 中使用数字时的行为相似。使用&&会在前一个退出代码为零时执行下一个命令,而||会在退出代码为非零数时执行下一个命令。

让我们看看如何编写一个小脚本,当进程通过 stderr 退出时记录一条消息。重要的是要注意,这与echo不同,因为它是在 stderr 上打印——它是为开发者使用而不是其他程序而设计的。你只需要监听process退出事件,然后将参数写入 stderr:

process.stdin.pipe(process.stdout);
process.on('exit', () => {
  const args = process.argv.slice(2);
  console.error(args.join(' '));
});

使用&&,如果 JSON 解析成功,你可以调用 exit-message.js:

parse-json -f test.json && node exit-message.js "parsed JSON successfully"

但是,exit-message.js 不会获取 parse-json 的输出。&&运算符必须等待 parse-json.js 完成,以确定是否应该执行下一个命令。在使用&&时,没有像管道那样自动重定向。

重定向输入

你已经看到了如何重定向输出,但你也可以以类似的方式重定向输入。这虽然是一个罕见的需求,但如果可执行程序不接受文件名作为参数,它可能是一个宝贵的资产。如果你想使命令读取文件到 stdin,使用<filename来完成:

parse-json -f - <invalid.json

通过结合两种重定向形式,你可以使用临时文件来恢复 parse-json 的输出:

parse-json -f test.json >tmp.out &&
  node exit-message.js "parsed JSON successfully" <tmp.out

现在你已经学会了如何处理流、退出码和命令顺序,你应该能够为你的包编写使用 Node 命令的脚本。下一节将演示如何使用管道结合 Browserify 和 UglifyJS。

Shell 小贴士:清除显示

你有时可能会将二进制数据发送到终端,并基本上将其破坏。就像《黑客帝国》中的一个场景,乱码字符会出现在各个地方。在这种情况下,你可以按 Ctrl-L 来刷新显示,或者输入 reset 来重置终端。

11.6. 解释现实世界的脚本

你现在可以开始编写自己的 package.json 文件中的 scripts 字段了。作为一个例子,让我们看看如何结合 npm 中的 browserify 和 uglifyjs 包。Browserify (browserify.org/) 是一个应用程序,它将 Node 模块打包起来以便在浏览器中使用。UglifyJS (github.com/mishoo/UglifyJS2) 是一个应用程序,它将 JavaScript 文件压缩,以便在发送到浏览器时占用更少的带宽和时间。你的脚本将处理一个名为 main.js 的文件(在本书的 ch11-command-line/snippets/uglify-example 列表中找到),将其连接起来以便在浏览器中使用,然后压缩连接后的脚本:

{
  "devDependencies": {
    "browserify": "13.3.0",
    "uglify-js": "2.7.5"
  },
  "scripts": {
    "build": "browserify -e main.js > bundle.js && uglifyjs bundle.js > bundle.min.js"
  }
}

你可以通过输入 npm run build 来运行构建脚本。本例中的构建脚本会生成 bundle.js。然后,如果创建 bundle.js 成功,脚本会创建 bundle.min.js。通过使用 && 操作符,你可以确保只有在第一阶段成功的情况下,第二阶段才会运行。

使用本章中展示的技术,你可以创建和使用命令行应用程序。记住,你总是可以使用命令行将其他语言的脚本组合在一起——如果你有一个有用的 Python、Ruby 或 Haskell 命令行程序,你可以轻松地与你的 Node 程序一起使用。

11.7. 摘要

  • 命令行参数可以从 process.argv 中读取。

  • 如 yargs 这样的模块使得解析和验证参数变得更加容易。

  • 在 package.json 文件中定义 npm 脚本是一种方便地将脚本添加到你的 Node 项目的快捷方式。

  • 通过使用标准 I/O 管道,数据被读取和写入到命令行程序中。

  • 标准输入、输出和错误可以被重定向到不同的进程和文件。

  • 程序会发出退出码,用于确定它们是否成功运行。

  • 命令行程序遵循其他用户期望的既定惯例。

第十二章. 使用 Electron 征服桌面

本章涵盖

  • 使用 Electron 构建桌面应用程序

  • 显示桌面菜单

  • 发送桌面通知

  • 创建跨平台构建

在上一章中,您学习了如何使用 Node 构建命令行工具。然而,Node 正在开始成为另一个领域的突出人物:桌面软件。程序员越来越多地利用网络技术来解决跨平台开发的问题。在本章中,您将学习如何基于原生桌面功能、Node 和客户端网络技术制作桌面网络应用程序。您可以在 Linux、macOS 和 Windows 上开发和运行此应用程序。您还将使用与客户端-服务器网络应用程序开发不太脱离的模型中的 Node 模块。

12.1. 介绍电子

电子,最初被称为原子壳,允许您使用网络技术构建桌面应用程序。应用程序和用户界面由您使用 HTML、CSS 和 JavaScript 创建,但一些桌面软件的“难点”已经为您提供了。以下是一些包括的内容:

  • 自动更新

  • 崩溃报告

  • 微软 Windows 安装程序

  • 调试

  • 原生菜单和通知

一些著名的应用程序是用电子制作的。第一个是 Atom,GitHub 的文本编辑器,但更近期的应用程序包括流行的聊天服务 Slack 和微软的 Visual Studio Code,如图 12.1 所示。figure 12.1。

图 12.1. Visual Studio Code 的应用程序窗口和原生上下文菜单

图片

您应该尝试一些这些应用程序,看看使用电子可以实现哪些类型的事情。想到有了 Node 和 JavaScript 技能,您可以构建引人注目的桌面软件,这令人兴奋。

12.1.1. 电子的堆栈

在开始使用电子之前,您应该熟悉电子如何与 Node、HTML 和 CSS 结合。一个电子应用程序具有以下组件:

  • 主进程—— 启动应用程序并提供对原生 Node 模块的访问的 Node 脚本

  • 渲染进程—— 由 Chromium 管理的网页

然而,一个真实的应用程序还有几个其他的依赖。前面的列表可以扩展如下:

  • 包括主进程

  • 连接到原生数据库(例如,SQLite)

  • 与 Web API 通信

  • 读取和写入任何本地文件(例如,配置文件)

  • 提供对原生功能的访问(例如,上下文菜单)

  • 包括渲染进程

  • 显示使用您首选的客户端技术(例如,React 或 Angular)的现代丰富网络应用程序

  • 触发原生功能(例如,上下文菜单和通知)

  • 提供构建脚本

  • 使用您首选的构建系统(Grunt、Gulp、npm 脚本)生成前端 JavaScript

  • 准备分发版本

图 12.2 展示了典型 Electron 应用程序三个主要部分的概述。正如你所见,Node 用于运行主进程并与操作系统通信,包括打开文件、读写数据库以及与网络服务通信。尽管渲染过程中的大部分重点都在 UI 上,但 Node 仍然用于应用程序架构的关键部分。

图 12.2。典型 Electron 应用程序的主要部分

12.1.2。界面设计

现在你已经看到了 Electron 应用程序的主要组件,让我们来看看如何设计合适的界面。Electron 应用程序基于 HTML、CSS 和 Java-Script,因此你不能引入原生小部件。想象一下,你想要制作一个类似 Mac 风格的原生界面。你可以通过使用 CSS 渐变来伪造 macOS 工具栏。通过 CSS 可以使用 macOS 和 Windows 提供的原生字体,你甚至可以调整抗锯齿效果,使其看起来像原生应用程序。你也可以为某些 UI 组件移除文本选择,并使 UI 支持拖放。目前,大多数 Electron 应用程序使用的 CSS 与 macOS 和 Windows 的颜色、边框样式、图标和渐变相同。

一些应用程序在复制原生体验方面做得更多;一个例子是 N1 邮件应用程序(github.com/nylas/N1)。其他应用程序,如 Slack(slack.com/),有自己的独特品牌和身份,足够干净,无需太多修改就能在各个平台上良好工作。

当你构建自己的 Electron 应用程序时,你必须决定哪种方法适合你的项目。如果你想制作一个看起来像使用原生桌面小部件的应用程序,你必须创建适合每个平台的样式。这需要更多的时间来设计每个目标 UI。你的客户可能更喜欢它,但这也可能导致在部署新功能时产生更多开销。

在下一节中,你将使用 Electron 应用程序的骨架来创建一个新的应用程序。这是使用 Electron 构建新项目的标准方式。

12.2。创建 Electron 应用程序

要开始使用 Electron,最简单的方法是使用 GitHub 上的 electron-quick-start 项目(github.com/atom/electron-quick-start)。这个小型仓库包含了运行基本 Electron 应用程序所需的依赖项。

要使用它,请检出仓库并使用 npm 安装依赖项:

git clone https://github.com/atom/electron-quick-start
cd electron-quick-start
npm install

下载完成后,你可以使用 npm start 启动主进程。将此项目作为你其余 Electron 应用程序的基础是安全的;你不需要从头创建自己的项目。

当应用程序启动时,你应该会看到一个包含网页和 Chromium 开发者工具的窗口。如果你是一个使用 Chrome 的网页开发者,这可能不会显得那么令人兴奋:应用程序看起来像是一个没有 CSS 渲染的网页。但在幕后还有很多工作要做,才能使这一切工作。图 12.3 展示了它在 macOS 中的样子。

图 12.3. 在 macOS 中运行的 electron-quick-start 项目

图片 12.3

这是一个自包含的 macOS 应用程序包:它包含了一个与我的系统上运行的不同的 Node 版本,并且有自己的菜单项和关于窗口。

到目前为止,你可以通过使用 HTML、JavaScript 和 CSS 在 index.html 中开始构建你的 web 应用程序。但作为一个 Node 程序员,你可能渴望使用 Node 做些事情,所以让我们先看看如何做到这一点。

Electron 内置了一个名为 remote 的模块,该模块通过进程间通信(IPC)在渲染进程和主 Node 进程之间进行通信。远程模块甚至可以提供对 Node 模块的访问。要尝试它,请将一个名为 readfile.js 的文件添加到您的 Electron 项目中,并在以下列表中添加代码。

列表 12.1. 一个简单的 Node 模块
const fs = require('fs');

module.exports = (cb) => {
  fs.readFile('./main.js', { encoding: 'utf8' }, cb);
};

现在打开 index.html 并将其修改为添加一个具有 ID source 的元素,以及一个加载 readfile.js 的脚本,如以下列表所示。

列表 12.2. 从渲染进程加载 Node 模块
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Hello World!</title>
  </head>
  <body>
    <h1>Hello World!</h1>
    <pre id="source"></pre>
    <script>
var readfile = require('remote').require('./readfile');
readfile(function(err, text) {
  console.log('readfile:', err, text);
  document.getElementById('source').innerHTML = text;

});
    </script>
  </body>
</html>

列表 12.2 使用远程模块加载 readfile.js 并在主进程中运行它。两个进程之间的交互无缝,所以它看起来与使用标准 Node 模块并没有太大的不同。唯一的真正区别是使用了 require('remote').require(file)

12.3. 构建完整的桌面应用程序

现在你已经看到了如何创建一个基本的 Electron 应用程序以及如何使用 Node 模块,让我们更进一步,看看如何构建一个具有原生功能的完整桌面应用程序。你将创建的应用程序旨在成为一个用于制作和查看 HTTP 请求的开发者工具。把它想象成请求模块的 GUI (www.npmjs.com/package/request)。

虽然你可以使用纯 HTML、JavaScript、CSS 和 Node 构建 Electron 应用程序,但在这个例子中,你将使用现代前端开发工具来使应用程序更易于维护和扩展。以下是你将使用的内容列表:

  • 以 electron-quick-start 作为项目的基础

  • 请求模块用于发送 HTTP 请求

  • 用于用户界面代码的 React

  • Babel 用于将现代 ES6 转换为浏览器友好的 ES5

  • webpack 用于构建客户端应用程序

图 12.4 展示了完成的应用程序应该看起来是什么样子。

图 12.4. HTTP Master Electron 应用程序

图片 12.4

接下来,你将学习如何使用 webpack 和 Babel 设置基于 React 的项目。

12.3.1. 引入 React 和 Babel

在使用复杂的前端构建新应用时,最大的挑战是设置如 React 和 Babel 这样的库,并构建一个可维护的构建系统。你有许多选择,包括 Grunt、Gulp 和 webpack。而且,使事情更加困难的是,这些库会随时间变化,因此书籍和教程很快就会过时。

为了缓解前端开发的快节奏世界,我们指定了每个依赖的确切版本,因此你应该能够遵循教程并获得类似的结果。如果你迷失了方向,可以使用如 Yeoman (yeoman.io/) 这样的工具生成骨架应用。然后你可以修改它,使其像本章概述的应用一样工作。

12.3.2. 安装依赖

创建一个新的 electron-quick-start 项目。为了回顾,你必须从 GitHub 克隆项目:

git clone https://github.com/atom/electron-quick-start
cd electron-quick-start
npm install

现在安装 react、react-dom 和 babel-core:

npm install --save-dev react@0.14.3 react-dom@0.14.3 babel-core@6.3.17

接下来,你需要安装 Babel 插件。主要的一个是 babel-preset-es2015,对于一个仅限于 Chromium 的项目来说可能有些过度,但包括它会使你更容易实验 Chromium 尚不支持 ES2015 特性。使用以下命令进行安装:

npm install --save-dev babel-preset-es2015@6.3.13
npm install --save-dev babel-plugin-transform-class-properties@6.3.13

此插件为 Babel 添加 JSX 支持:

npm install --save-dev babel-plugin-transform-react-jsx@6.3.13

然后安装 webpack:

npm install --save-dev webpack@1.12.9

你还需要为 webpack 使用 babel-loader:

npm install --save-dev babel-loader@6.2.0

现在大多数依赖项都已准备就绪,向你的项目中添加一个 .babelrc 文件。它告诉 Babel 使用 ES2015 和 React 插件:

{
  "plugins": [
    "transform-react-jsx"
  ],
 "presets": ["es2015"]
}

最后,打开 package.json 并更新 scripts 属性以包含 webpack 调用:

"scripts": {
  "start": "electron main.js",
  "build": "node_modules/.bin/webpack --progress --colors"
},

这允许应用程序通过 npm run build 构建。Webpack 插件可用于 React 热加载,但这里我们不涉及。如果你想在文件更改时自动构建客户端代码,可以使用 fswatch 或 nodemon 等工具。

12.3.3. 设置 webpack

要使用 webpack,你需要一个 webpack.config.js 文件。将其添加到项目的根目录。基本格式是 JavaScript,使用 Node-style CommonJS 模块:

const webpack = require('webpack');
module.exports = {
  setting: 'value'
};

我们的项目需要设置以查找 React 文件 (.jsx),加载入口点 (/app/index.jsx),然后将输出放置在 Electron UI 可以找到的地方(js/app.js)。React 文件也必须通过 Babel 处理。将这些要求结合起来,产生以下列表中的文件。

列表 12.3. webpack.config.js
const webpack = require('webpack');
module.exports = {
  module: {
    loaders: [
     { test: /\.jsx?$/, loaders: ['babel-loader'] }
    ]
  },
  entry: [
    './app/index.jsx'
  ],
  resolve: {
    extensions: ['', '.js', '.jsx']
  },
  output: {
    path: __dirname + '/js',
    filename: 'app.js'
  }
};

在此列表中,webpack 通过 module.loaders 属性告诉 Babel 使用 Babel 转换 .jsx(React)文件。Babel 已经在 .babelrc 中设置好,以处理 React 文件。接下来,使用 entry 属性定义 React 代码的主要入口点。这很有效,因为 React 组件基于 HTML 元素。因为 HTML 元素必须有一个父节点,一个单独的入口点可以涵盖整个应用程序。

resolve.extensions 属性告诉 webpack .jsx 文件必须被视为模块。如果你使用 import {Class} from 'class' 这样的语句,它将检查 class.js 和 class.jsx。

最后,output 属性告诉 webpack 将输出文件写入何处。这里我使用了 js/,但你可以使用任何对 Electron UI 可访问的路径。

现在是开始充实 React 应用程序的好时机。让我们先看看主入口点以及它如何引入请求和响应 UI 元素。

12.4. React 应用程序

在 图 12.4 中,你看到了这个应用程序的预览。它有两个主要的 UI 组件组,可以分成七个项目:

  • 请求

  • URL:字符串

  • 方法:字符串

  • 头部:字符串对的集合

  • 响应

  • HTTP 状态码

  • 头部:字符串对的集合

  • 主体:字符串

  • 错误:字符串

但在 React 中,你不能并排渲染两个东西:它们需要被单个父级包含。你需要一个顶级应用对象,它包含请求和响应的 UI 元素。

给定 RequestResponse 类(你将在稍后实现),App 类本身应如下所示。

列表 12.4. App
import React from 'react';
import ReactDOM from 'react-dom';
import Request from './request';
import Response from './response';

class App extends React.Component {
  render() {
    return (
      <div className="container">
        <Request />
        <Response />
      </div>
    );
  }
}
ReactDOM.render(<App />, document.getElementById('app'));

将此文件保存为 app/index.jsx。它首先加载 RequestResponse 类,然后在 div 中渲染它们。最后一行使用 ReactDOM 渲染 App 类的 DOM 节点。React 允许你使用 <App /> 引用 App 类。

要使这生效,你还需要定义 RequestResponse 组件。

12.4.1. 定义请求组件

Request 类接受 URL 和 HTTP 方法的输入,然后使用 Node 请求模块生成一个请求。它通过 JSX 渲染界面,但与上一个示例不同,它不是直接使用 ReactDOM 渲染元素;这是在 app/index.jsx 中的主应用类中包含时发生的。

以下列表(app/request.js)包含了完整类的代码。我们已删除编辑标题的功能以缩短示例的长度;对于包含标题编辑等更多功能的示例,请参阅我们的 HTTP Wizard GitHub 仓库 (github.com/alexyoung/http-wizard)。

列表 12.5. Request
import React from 'react';
import Events from './events';

const request = remote.require('request');

class Request extends React.Component {
  constructor(props) {
    super(props);
    this.state = { url: null, method: 'GET' };
  }

  handleChange = (e) => {
    const state = {};
    state[e.target.name] = e.target.value;
    this.setState(state);
  }

  makeRequest = () => {
    request(this.state, (err, res, body) => {
      const statusCode = res ? res.statusCode : 'No response';
      const result = {
        response: `(${statusCode})`,
        raw: body ? body : '',
        headers: res ? res.headers : [],
        error: err ? JSON.stringify(err, null, 2) : ''
      };

      Events.emit('result', result);
      new Notification(`HTTP response finished: ${statusCode}`)
    });
  }

  render() {
    return (
      <div className="request">
        <h1>Request</h1>
        <div className="request-options">
          <div className="form-row">
            <label>URL</label>
            <input
              name="url"
              type="url"
              value={this.state.url}
              onChange={this.handleChange} />
          </div>
          <div className="form-row">
            <label>Method</label>
            <input
              name="method"
              type="text"
              value={this.state.method}
              placeholder="GET, POST, PATCH, PUT, DELETE"
              onChange={this.handleChange} />
          </div>
          <div className="form-row">
            <a className="btn" onClick={this.makeRequest}>Make request</a>
          </div>
        </div>
      </div>
    );
  }
}

export default Request;

列表的大部分内容由 render 方法的 HTML 组成。在介绍如何构建 UI 之前,让我们先关注其余部分。首先,我们在 app/events.jsx 中使用 Node 的 EventEmitter 的子类来在组件之间进行通信。以下片段是 app/events.jsx:

import { EventEmitter } from 'events';
const Events = new EventEmitter();
export default Events;

注意,RequestReact.Component 的子类。它定义了一个构造函数,用于设置默认状态:state 属性在 React 中是特殊的,只能在构造函数中这样设置。在其他地方,你必须使用 this.setState

handleChange 方法根据 HTML 元素的 name 属性设置状态。要了解这是如何工作的,请跳转到 render 方法中的 URL <input> 元素:

<input
  name="url"
  type="url"
  value={this.state.url}
  onChange={this.handleChange} />

这里指定的名称用于在编辑时设置 URL。设置状态也会导致 render 运行,React 将更新值属性以反映更新的状态。让我们继续看看这个类是如何使用请求模块的。

这个类是运行在网页中的客户端代码,所以你需要一种方法来访问请求模块以发起 HTTP 请求。Electron 提供了一种方法来加载远程模块,而不需要任何不必要的样板代码。在类的顶部附近,你使用全局 remote 对象来引入 Node 请求模块:

const request = remote.require('request');

然后在 makeRequest 中,可以通过简单的 request() 调用来发起 HTTP 请求。请求的参数已经在类的状态中设置,所以你只需要处理请求完成时运行的回调。在这里,这非常少的命令式代码:类的状态基于请求的结果设置,然后结果被发射出来,以便 Response 组件可以使用它。还会显示桌面通知;如果请求缓慢,用户将通过操作系统通知弹出窗口进行视觉通知:

new Notification(`HTTP response finished: ${statusCode}`)

图 12.5 展示了一个典型的通知。

图 12.5. 桌面通知

![Images/12fig05_alt.jpg]

现在,让我们看看 Response 组件是如何显示 HTTP 响应的。

12.4.2. 定义响应组件

Response 组件监听 result 事件,然后将其状态设置为包含上次请求的结果。它通过使用表格显示标题,div 显示请求正文和任何错误来显示结果。

以下列表显示了整个 Response 组件。此文件位于 app/response.jsx。

列表 12.6. Response 组件
import React from 'react';
import Events from './events';
import Headers from './headers';

class Response extends React.Component {
  constructor(props) {
    super(props);
    this.state = { result: {}, tab: 'body' };
  }

  componentWillUnmount() {
    Events.removeListener('result', this.handleResult.bind(this));
  }

  componentDidMount() {
    Events.addListener('result', this.handleResult.bind(this));
  }

  handleResult(result) {
    this.setState({ result: result });
  }

  handleSelectTab = (e) => {
    const tab = e.target.dataset.tab;
    this.setState({ tab: tab });
  }

  render() {
    const result = this.state.result;
    const tabClasses = {
      body: this.state.tab === 'body' ? 'active' : null,
      errors: this.state.tab === 'errors' ? 'active' : null,
    };
    const rawStyle = this.state.tab === 'body'
      ? null
      : { display: 'none' }
    const errorsStyle = this.state.tab === 'errors'
      ? null
      : { display: 'none' };

    return (
      <div className="response">
        <h1>Response <span id="response">{result.response}</span></h1>
        <div className="content-container">
          <div className="content">
            <div id="headers">
              <table className="headers">
                <thead>
                  <tr>
                    <th className="name">Header Name</th>
                    <th className="value">Header Value</th>
                  </tr>
                </thead>
                <Headers headers={result.headers} />
              </table>
            </div>
            <div className="results">
              <ul className="nav">
                <li className={tabClasses.body}>
                  <a data-tab='body' onClick={this.handleSelectTab}>Body</a>
                </li>
                <li className={tabClasses.errors}>
                  <a data-tab='errors' href="#" onClick={this.handleSelectTab}>Errors</a>
                </li>
              </ul>
              <div
                className="raw"
                id="raw"
                style={rawStyle}>{result.raw}</div>
              <div
                className="raw"
                id="error"
                style={errorsStyle}>{result.error}</div>
            </div>
          </div>
        </div>
      </div>
    );
  }
}

export default Response;

Response 组件没有处理 HTTP 响应的特定代码;它通过各种 HTML 元素显示其状态。它能够通过绑定一个 onclick 事件到 handleSelectTab 方法来切换标签页,该方法通过使用属性 (data-tab) 在正文和错误之间切换。

Response 组件使用另一个组件 Headers 来渲染 HTTP 响应头。将组件分解成越来越小的组件是 React 中的标准做法。每个头的值通过属性传递给子组件;在 React 中,这些被称为 props属性

<Headers headers={result.headers} />

以下列表显示了 Headers 组件。此文件位于 app/headers.jsx。

列表 12.7. Headers 组件
import React from 'react';

class Headers extends React.Component {
  render() {
    const headers = this.props.headers || {};
    const headerRows = Object.keys(headers).map((key, i) => {
      return (
        <tr key={i}>
          <td className="name">{key}</td>
          <td className="value">{headers[key]}</td>
        </tr>
      );
    });

    return (
      <tbody className="header-body">
        {headerRows}
      </tbody>
    );
  }
}

export default Headers;

注意在 render() 方法的顶部附近是如何访问 props 的,在 this.props.headers

12.4.3. React 组件之间的通信

RequestResponse类相当隔离;它们专注于解决特定的任务,而不直接相互调用。React 有其他更复杂的状态管理方法,但它们超出了本章的范围。这个示例应用程序不需要复杂通信机制,因为它只有两个主要组件,所以它使用 Node 的EventEmitter来通信。

要以这种方式使用EventEmitter,在它自己的文件中实例化它,然后导出实例。这个文件是本章示例项目中的 app/events.jsx:

import { EventEmitter } from 'events';
const Events = new EventEmitter();
export default Events;

现在组件可以要求events并发出事件或附加监听器来通信。Request组件在makeRequest方法中这样做,使用 HTTP 请求的结果:

Events.emit('result', result);

然后在Response类中,你可以在组件的生命周期早期设置一个监听器来捕获结果:

componentWillUnmount() {
  Events.removeListener('result', this.handleResult.bind(this));
}

随着应用程序的增长,这种模式变得越来越难以维护。一个特别的问题是跟踪事件名称。因为它们是字符串,所以很容易忘记它们或写错。这种模式的扩展是使用事件名称的常量列表。如果你再次扩展这种模式以分割分发事件和存储数据的责任,你最终会得到类似于 Facebook 的 Redux 状态容器(redux.js.org/)的东西,这就是为什么许多 React 程序员使用它来设计和构建大型应用程序的原因。

12.5. 构建和分发

现在你已经有一个可用的桌面应用程序,你可以将其捆绑成 macOS、Linux 和 Windows 版本。使用 Electron 进行应用分发有三个阶段:

  1. 将 Electron 应用重命名为你的应用程序名称和图标

  2. 将你的应用程序打包成一个文件

  3. 为每个平台创建一个二进制文件

电子快速入门项目已经几乎适合分发。你只需将你的代码复制到 macOS 中的 Electron 的 Contents/Resources/app 文件夹,或在 Windows 和 Linux 中的 electron/resources/app。

但手动复制文件并不是构建可分发二进制文件的最佳方式。一个更可靠的方法是使用 Max Ogden 的 electron-packager (www.npmjs.com/package/electron-packager)。这个包提供了一个用于为 Windows、Linux 和 macOS 构建可执行文件的命令行工具。

12.5.1. 使用 Electron Packager 构建

要安装 electron-packager,全局安装它。这将允许你为任何你想为特定平台创建二进制文件的项目构建:

npm install electron-packager –g

安装后,你可以从你的应用程序目录中运行它。你必须使用应用程序的路径、应用程序名称、平台、架构(32 位或 64 位)和 Electron 版本来调用它:

electron-packager . HttpWizard --version=1.4.5

这将下载 Electron 版本 1.4.5 并为所有支持的平台和架构生成二进制文件。这可能需要一些时间(Electron 大约 40 MB),但完成后,你将拥有可以在所有主要操作系统上运行的二进制文件。

隐藏开发者工具

在共享构建之前,您应该删除或更改 main.js 中打开 Chromium 开发工具的行:

mainWindow.webContents.openDevTools();

或者,您可以使用标志将其包装起来,在开发应用程序时隐藏它:

if (process.env.NODE_ENV === 'debug') {
  mainWindow.webContents.openDevTools();
}

12.5.2. 打包

要进一步提高应用程序的性能,您可以使用 Atom Shell 存档打包客户端和 Node JavaScript 文件(github.com/atom/asar)。这些存档被称为asar 文件,它们类似于 UNIX 的tar命令。它们隐藏了您的 JavaScript,但不足以阻止人们解码包,因此您不能用它真正混淆代码。但它们解决了在 Windows 中由于深层嵌套依赖项而导致的文件名过长的问题。

在 Electron 中,Chromium 可以读取 asar 文件以及 Node,因此您不需要做任何特殊的事情来支持它。此外,electron-packager 可以使用--asar命令行选项为您创建 asar 包。

图 12.6 展示了未使用 asar 打包的应用程序的外观。

图 12.6. 典型的 Electron 应用程序包的内容

注意,您可以打开 JavaScript 文件来查看源代码。Electron 应用程序中唯一的二进制文件是资源,如图像或二进制 Node 模块。

要使用 asar 文件生成构建,您可以使用带有--asar标志的 electron-packager:

electron-packager . HttpWizard --version=0.36.0 --asar=true

这是最简单的方法,因为 electron-packager 会运行所有必要的命令。要手动完成,您需要安装 asar,然后需要调用命令行工具来创建一个包:

npm install -g asar
asar pack path-to-your-app/ app.asar

在您拥有 asar 存档后,下载您想要支持的平台的 Electron 二进制文件(github.com/atom/electron/releases),并将存档添加到资源目录中,如图 12.6 所示。运行应用程序的可执行文件或包应该会导致您的应用程序运行。

通过编辑供应商提供的二进制文件,也是 Electron 应用程序品牌化的方式。您可以通过这种方式更改应用程序的名称和图标。如果您运行未经修改的 Electron 二进制文件,它将提供一个窗口,允许您运行使用 electron-quick-start 存储库制作的 Electron 应用程序。

12.6. 总结

  • 使用 Electron,您可以使用 Node、JavaScript、HTML 和 CSS 制作桌面应用程序。

  • 您可以生成原生菜单和通知,而无需使用 C++、C#或 Objective-C。

  • 如果您有有用的 Node 模块,您可以在 Electron 应用程序的 UI 中从客户端 JavaScript 中使用它们。

  • Electron 使用完整的浏览器,因此您可以使用最新的 Java-Script 技术,如 React 或 Angular 来构建 UI。

附录 A. 安装 Node

本附录提供了有关安装 Node.js 的更多详细信息。如果您对 Node 比较陌生,我们建议使用预构建包安装它。我们为每个主要操作系统解释了这一点。

根据您的需求,您可以使用其他方式安装 Node。如果您对 Node 更有经验或具有特定的 DevOps 需求,请跳过并查看其他安装 Node 的方式。

A.1. 使用安装程序安装 Node

Node 有两个安装程序和几个预构建的二进制包。如果您使用 macOS 或 Windows,您可以使用二进制文件或安装程序。二进制包包含可执行文件,但安装程序有安装向导,可以帮助您将 Node 安装到系统中的易于找到的位置,当您在终端中运行 node 或 npm 等命令时。

如果您是 Node 的新手,请使用安装程序。所有版本都可以在 Node 的网站下载部分(nodejs.org/en/download/)找到。

A.1.1. macOS 安装程序

对于 macOS,从 Node 的网站下载 64 位 .pkg 文件(nodejs.org/en/download/)。您可以使用 LTS 或 Current 版本。您应该会看到一个包文件,如图 A.1(#app01fig01)所示。

图 A.1. 安装程序 .pkg 文件

图片

下载安装程序后,双击它以打开安装向导(图 A.2)。

图 A.2. 安装向导

图片

点击“继续”按钮并按照说明操作;默认选项将正确安装 Node。安装过程完成后,您应该能够打开一个终端并输入 node 来运行 Node REPL。图 A.3(#app01fig03)显示了它应该看起来是什么样子。

图 A.3. Node 的 REPL

图片

下一个部分包括为 Windows 用户提供的相同说明。

A.1.2. Windows 安装程序

在 Node 下载页面(nodejs.org/en/download/)上,点击 Windows 安装程序图标,或点击 Windows 安装程序 .msi 链接。有 32 位和 64 位选项,但您可能想要 64 位。文件下载完成后,双击它以运行安装向导,如图 A.4(#app01fig04)所示。

图 A.4. Windows .msi 安装程序

图片

接受所有默认选项,然后打开 cmd.exe 以尝试 Node REPL。图 A.5(#app01fig05)显示了 Windows 中的 Node REPL。

图 A.5. Windows 中的 Node REPL

图片

如果您通常不这样安装软件或不想全局安装 Node,请继续阅读以了解 Node 可以以其他方式安装。

A.2. 使用其他方式安装 Node

您可以从源代码、通过操作系统的包管理器或使用 Node 版本管理器安装 Node。如果您从源代码安装,您需要一个工作的构建系统和已安装的 Python。

A.2.1. 从源代码安装 Node

您可以从 nodejs.org 下载页下载 Node 的源代码,但也可以通过 GitHub 上的 Git 获取 (github.com/nodejs/node)。完整的构建指南也位于 GitHub 上的 node/Building.md (github.com/nodejs/node/blob/master/BUILDING.md)。构建 Node 时,您需要以下先决条件:

  • Linux— Python 2.6 或 2.7,gcc 和 g++ 4.8 或更高版本,或者 clang 和 clang++ 3.4 或更高版本。在类似 Debian 的发行版中,最简单的方法是使用构建基本包,或者在其他发行版中找到其等效包。

  • macOS— Xcode 和命令行工具,这些可以通过 Xcode 安装。

  • Windows— Python 2.6 或 2.7,Visual C++ 构建工具,Visual Studio 2015 更新 3。

当您的构建工具准备就绪时,您可以在类 UNIX 操作系统中运行 ./configuremake。在 Windows 上,您可以运行 .\vcbuild nosign

A.2.2. 使用包管理器安装 Node

如果您使用 Linux 或 macOS,您可能希望使用包管理器安装 Node。这可以使更新 Node 更容易。例如,如果您使用的是 Linux 网络服务器,您可能想安装 Node 以便自动获取安全更新。

Node 的网站列出了大量为提供 Node 作为包的操作系统提供的安装说明 (nodejs.org/en/download/package-manager/)。例如,在 Debian 和 Ubuntu 基础系统上,您可以从 NodeSource 二进制发行版仓库获取 Node。它有自己的 GitHub 仓库,其中包含更多详细信息 (github.com/nodesource/distributions)。

在 macOS 上,您可以使用 Homebrew 安装 Node (brew.sh/)。如果您已安装 Homebrew,只需运行 brew install node 即可。

Node 也可以从 Docker Hub 获取。如果您在 Dockerfile 中添加 FROM node:argon,您将获得安装到镜像中的 Node 的 LTS 版本。

附录 B. 使用爬取自动化网络

本附录涵盖

  • 从网页创建结构化数据

  • 使用 cheerio 进行基本的网络爬取

  • 使用 jsdom 处理动态内容

  • 解析和输出结构化数据

在上一章中,你学习了某些通用的 Node 编程技术,但现在我们将开始专注于网络开发。网络爬取是做这件事的理想方式,因为它需要服务器端和客户端编程技能的结合。爬取就是使用编程技术理解网页并将它们转换为结构化数据。想象一下,你被分配了一个任务,要创建一个目前只是由一组过时的静态 HTML 页面组成的图书出版商网站的新版本。你想要下载这些页面并分析它们以提取所有书籍的标题、描述、作者和价格。你不想手动做这件事,所以你编写了一个 Node 程序来完成它。这就是网络爬取

Node 在爬取方面表现卓越,因为它在基于浏览器的技术和通用脚本语言的力量之间取得了完美的平衡。在本章中,你将学习如何使用 HTML 解析库根据 CSS 选择器提取有用的数据,甚至可以在 Node 进程中运行动态网页。

B.1. 理解网络爬取

网络爬取是从网站中提取有用信息的过程。这通常涉及下载所需的页面,解析它们,然后使用 CSS 或 XPath 选择器查询原始 HTML。查询的结果随后作为 CSV 文件导出或保存到数据库中。图 B.1 展示了从开始到结束的爬取过程。

图 B.1. 爬取和存储内容的步骤

图片

由于成本或资源限制,网络爬取可能违反某些网站的使用条款。如果成千上万的爬虫同时访问一个运行在老旧且缓慢服务器上的网站,服务器可能会被关闭。在爬取任何内容之前,你应该确保你有权访问和复制该内容。你可以技术上检查网站的 robots.txt (www.robotstxt.org) 文件以获取此信息,但你应该首先联系网站的所有者。在某些情况下,网站的所有者可能邀请你索引其信息——可能是作为更大规模网络开发合同的一部分。

在本节中,你将了解人们如何使用爬虫处理真实网站,然后你将查看允许 Node 成为网络爬取强者的所需工具。

B.1.1. 网络爬取的用途

Web scraping 的一个很好的例子是垂直搜索引擎 Octopart (octopart.com/)。如图 B.2 所示,Octopart 索引了电子分销商和制造商,以便人们更容易找到电子产品。例如,你可以根据电阻、公差、功率额定值和外壳类型搜索电阻。这样的网站使用网络爬虫下载内容,使用抓取工具理解内容并提取有趣的价值(例如,电阻的公差),并使用内部数据库存储处理后的信息。

图 B.2. Octopart 允许用户搜索电子元件。

图片

Web scraping 不仅仅用于搜索引擎,它还被应用于日益增长的数据科学和数据新闻领域。数据记者使用数据库来制作故事,但由于有大量数据存储在不易访问的格式中,他们可能会使用诸如 web scraping 之类的工具来自动收集和处理数据。这使得记者能够以新的方式呈现信息,通过数据可视化技术,包括信息图表和交互式图表。

B.1.2. 必需的工具

为了进入正题,你需要一些易于访问的工具:一个网络浏览器和 Node。浏览器是其中最实用的抓取工具之一——如果你可以右键点击并选择“检查元素”,你就已经迈出了理解网站并将其转换为原始数据的一半。下一步是使用 Node 解析页面。在本章中,你将了解两种类型的解析器:

  • 轻量级且宽容:cheerio

  • 一个关注 Web 标准的文档对象模型(DOM)模拟器:jsdom

这两个库都是通过 npm 安装的。你可能还需要解析松散结构的人读数据格式,如日期。我们将简要介绍 Java-Script 的Date.parse和 Moment.js。

第一个例子使用了 cheerio,这是一种快速解析大多数静态网页的方法。

B.2. 使用 cheerio 进行基本的 Web 抓取

cheerio 库(www.npmjs.com/package/cheerio),由 Felix Böhm 编写,非常适合抓取,因为它结合了两个关键特性:快速的 HTML 解析,以及类似 jQuery 的 API 用于查询和操作 HTML。

假设你需要从一个出版社网站上提取有关书籍的信息。该出版社还没有公开书籍详细信息的 API,因此你需要下载其网站上的页面,并将它们转换为包含作者姓名和书名的可用的 JSON 输出。图 B.3 显示了使用 cheerio 进行抓取的工作原理。

图 B.3. 使用 cheerio 进行抓取

图片

下面的列表包含一个使用 cheerio 的小型抓取器。已经包括了示例 HTML,所以你不必担心如何下载页面本身。

列表 B.1. 提取书籍的详细信息

图片

列表 B.1 使用 cheerio 通过cheerio.load()方法和 CSS 选择器解析硬编码的 HTML 文档。在这个简单的例子中,CSS 选择器简单明了,但现实世界的 HTML 通常要混乱得多。不幸的是,结构不良的 HTML 是不可避免的,您的网络爬虫技能体现在想出巧妙的方法来提取您需要的值。

理解糟糕的 HTML 需要两个步骤。第一步是可视化文档,第二步是定义针对您感兴趣元素的选择器。您使用 cheerio 的功能来恰当地定义选择器。

幸运的是,现代浏览器提供了一个点选解决方案来查找选择器:如果您的浏览器有开发工具,您通常可以右键单击并选择“检查元素”。不仅您会看到底层的 HTML,浏览器还应显示一个针对元素的选择器表示。

假设您正在尝试从一个使用表格但没有方便 CSS 类的古怪网站中提取书籍信息。HTML 可能看起来像这样:

<html>
  <body>
    <h1>Alex's Dated Book Website</h1>
    <table>
      <tr>
        <td><a href="/book1">Catch-22</a></td>
        <td>Joseph Heller</td>
      </tr>
    </table>
  </body>
</html>

如果您在 Chrome 中打开它并右键单击标题,您会看到类似图 B.4 的内容。

图 B.4. 在 Chrome 中查看 HTML

HTML 下方的白色条形显示“html body table tbody tr td a”——这几乎就是您需要的选择器。但并不完全正确,因为真正的 HTML 没有tbody。Chrome 插入了这个元素。当您使用浏览器可视化文档时,您应该准备好根据真正的底层 HTML 调整您发现的内容。这个例子表明,您需要在一个表格单元格内搜索链接以获取标题,下一个表格单元格是相应的作者。

假设前面的 HTML 存储在一个名为 messy_html_example.html 的文件中,以下列表将提取标题、链接和作者。

列表 B.2. 处理糟糕的 HTML

您使用 fs 模块来加载 HTML;这样您就不必在示例中不断打印 HTML。实际上,您的数据源可能是一个实时网站,但数据也可能来自文件或数据库。在文档被解析后,您使用first()来获取带有锚点的第一个表格单元格。要获取锚点的 URL,您使用 cheerio 的attr()方法;它从元素中返回一个特定属性,就像 jQuery 一样。eq()方法也很有用;在这个列表中,它被用来跳过第一个 td,因为第二个包含作者的文本。

网络解析风险

使用 cheerio 这样的模块是一种快速且简单的方法来解析网络文档。但请注意您尝试解析的内容类型。例如,它可能会在二进制数据上抛出异常,因此在使用 Node.js 网络应用程序时可能会崩溃。如果您的爬虫嵌入在同一个进程中,这将是危险的。

在通过解析器传递内容之前最好检查内容类型,你可能还希望考虑在你的 Node 进程中运行你的网络爬虫以减少任何严重崩溃的影响。

cheerio 的一个限制是它只允许你与文档的静态版本一起工作;它用于处理纯 HTML 文档,而不是使用客户端 JavaScript 的动态页面。在下一节中,你将学习如何使用 jsdom 在你的 Node 应用程序中创建类似浏览器的环境,以便执行客户端 JavaScript。

B.3. 使用 jsdom 处理动态内容

jsdom 是网络爬虫的梦想工具:它下载 HTML,根据在典型浏览器中找到的 DOM 解释它,并运行客户端 JavaScript。你可以指定要运行的客户端 JavaScript,这通常意味着包括 jQuery。这意味着你可以将 jQuery(或你自己的自定义调试脚本)注入到任何页面中。图 B.5 展示了 jsdom 如何结合 HTML 和 JavaScript 使其他难以爬取的内容变得可访问。

图 B.5. 使用 jsdom 进行爬取

jsdom 确实有一些缺点。它并不是一个完美的浏览器模拟,它的速度比 cheerio 慢,HTML 解析器很严格,所以它可能无法处理编写糟糕的标记的页面。然而,一些网站没有客户端 JavaScript 支持就没什么意义,因此对于某些爬取任务来说,它是一个不可或缺的工具。

jsdom 的基本用法是通过 jsdom.env 方法。以下列表展示了如何使用 jsdom 通过注入 jQuery 并提取有用值来爬取一个页面。

列表 B.3. 使用 jsdom 进行爬取

要运行 列表 B.3,你需要将 jQuery 本地保存并安装 jsdom.^([1]) 你可以使用 npm 安装这两个模块。这些模块分别称为 jsdom (www.npmjs.com/package/jsdom) 和 jQuery (www.npmjs.com/package/jquery)。一切设置完成后,此代码应打印出 HTML 片段的标题、作者和描述。

¹

jsdom 6.3.0 是撰写本文时的当前版本。

jsdom.env 方法用于解析文档并注入 jQuery。jQuery 通过从 npm 下载它来注入,但你也可以提供内容分发网络 (CDN) 或你的文件系统上的 jQuery URL;jsdom 会知道该怎么做。jsdom.env 方法是异步的,需要回调来工作。回调接收错误和窗口对象;窗口对象是你访问文档的方式。在这里,窗口的 jQuery 对象已经被别名化,因此可以很容易地用 $ 访问。

使用 jQuery 的 .each 方法与选择器一起使用,以遍历每一本书。这个例子只有一本书,但它证明了 jQuery 的遍历方法确实是可用的。通过使用 jQuery 的遍历方法也可以访问每本书的每个值。

列表 B.3 与之前的 cheerio 示例 列表 B.1 类似,但主要区别在于 jQuery 已经由 Node 在当前进程中解析和运行。列表 B.1 使用 cheerio 提供类似的功能,但 cheerio 提供了自己的类似 jQuery 的层。在这里,您正在运行旨在在浏览器中运行的代码,就像它真的在浏览器中运行一样。

jsdom.env 方法仅适用于处理静态页面。要解析使用客户端 JavaScript 的页面,您需要使用 jsdom.jsdom。这个同步方法返回一个可以与其他 jsdom 工具一起操作的窗口对象。以下列表使用 jsdom 解析带有 script 标签的文档,并使用 jsdom.jQueryify 使抓取更加容易。

列表 B.4. 使用 jsdom 解析动态 HTML

![Images/blis04_alt.jpg]

列表 B.4 需要安装 jQuery,因此如果您手动创建此列表,您需要使用 npm initnpm install --save jquery jsdom 设置一个新的项目。它使用一个简单的 HTML 文档,其中您正在寻找的有用值是使用 script 标签中的客户端 JavaScript 动态插入的。

这次,使用的是 jsdom.jsdom 而不是 jsdom.env。它是同步的,因为文档对象是在内存中创建的,但在您尝试查询或操作它之前不会做很多事情。为此,您使用 jsdom.jQueryify 将您的特定版本的 jQuery 插入文档中。在 jQuery 加载并运行后,回调函数将被执行,它会查询文档以获取您感兴趣的数据,并将它们打印到控制台。输出结果如下所示:

{ title: 'Catch-22', author: 'Joseph Heller' }

这证明了 jsdom 已经调用了必要的客户端 JavaScript。现在想象这是一个真正的网页,您将看到为什么 jsdom 如此强大:即使是使用很少的静态 HTML 和像 Angular 和 React 这样的动态技术构建的网站也可以被抓取。

B.4. 理解原始数据

在您最终从页面获取有用的数据后,您需要对其进行处理,以便将其保存到数据库或用于 CSV 等导出格式。您抓取的数据将是未结构化的纯文本或使用微格式编码。

微格式 是一种轻量级的基于标记的数据格式,用于地址、日历和事件以及标签或关键词等。您可以在 microformats.org 找到已建立的微格式。以下是一个表示为微格式的名称示例:

<a class="h-card" href="http://example.com">Joseph Heller</a>

微格式相对容易解析;使用 cheerio 或 jsdom,一个简单的表达式如 $('.h-card').text() 就足以提取 约瑟夫·海勒。但纯文本需要更多的工作。在本节中,您将了解如何解析日期,然后将它们转换为更友好的数据库格式。

大多数网页不使用微格式。在日期值方面,这是一个问题但可能可以管理的领域。日期可以以许多格式出现,但通常在给定网站上是一致的。在确定格式后,您可以解析并格式化日期。

JavaScript 有一个内置的日期解析器:如果您运行new Date('2016 01 01'),将返回一个Date实例,对应于 2016 年 1 月 1 日。支持的输入格式由Date.parse确定,它基于 RFC 2822 (tools.ietf.org/html/rfc2822#page-14)或 ISO 8601 (www.w3.org/TR/NOTE-datetime)。其他格式可能有效,并且通常值得尝试使用源数据查看会发生什么。

另一种方法是使用正则表达式匹配源数据中的值,然后使用Date构造函数创建新的Date对象。构造函数的签名如下:

new Date(year, month[,day[,hour[,minutes[,seconds[,millis]]]]]);

JavaScript 中的日期解析通常足够处理许多情况,但在日期重新格式化方面会失败。解决这个问题的一个很好的方法是 Moment.js (momentjs.com),这是一个日期解析、验证和格式化库。它有一个流畅的 API,因此可以像这样链式调用:

moment().format("MMM Do YY"); // Sep 7th 15

这对于将抓取数据转换为与 Microsoft Excel 等程序兼容的 CSV 文件非常有用。想象一下,您有一个包含书籍标题和出版日期的网页。您想将这些值保存到数据库中,但您的数据库要求日期格式为 YYYY-MM-DD。以下列表显示了如何使用 Moment 与 cheerio 来完成此操作。

列表 B.5. 解析日期并生成 CSV

列表 B.5 需要安装 cheerio、Moment 和 books。它以 HTML(来自 input.html)作为输入,然后输出 CSV。HTML 中的日期应位于h4元素中,如下所示:

<div>
  <div class="book">
    <h2>Catch-22</h2>
    <h3>Joseph Heller</h3>
    <h4>11 November 1961</h4>
  </div>
  <div class="book">
    <h2>A Handful of Dust</h2>
    <h3>Evelyn Waugh</h3>
    <h4>1934</h4>
  </div>
</div>

在刮削器加载输入文件后,它加载了 Moment,然后通过使用 cheerio 的.map.get方法将每本书映射到一个简单的 JavaScript 对象。.map方法遍历每一本书,回调函数通过使用.find选择器遍历方法提取你感兴趣的所有元素。为了将结果文本值作为数组获取,使用.get

列表 B.5 通过使用console.log输出 CSV。首先打印标题,然后通过遍历每一本书的循环记录每一行。日期通过使用 Moment 转换为与 MySQL 兼容的格式;首先使用new Date解析日期,然后使用 Moment 进行格式化。

在你习惯了解析和格式化日期之后,你可以将类似的技术应用到其他数据格式上。例如,货币和距离测量可以通过正则表达式捕获,然后使用更通用的数字格式化库(如 Numeral)进行格式化(www.npmjs.com/package/numeral)。

B.5. 摘要

  • 网络抓取是将有时结构不良的网页自动转换为计算机友好的格式(如 CSV 或数据库)的过程。

  • 网络抓取不仅用于垂直搜索引擎,也用于数据新闻学。

  • 如果你打算抓取一个网站,你应该先获得许可。你可以通过检查网站的 robots.txt 文件和联系网站所有者来实现这一点。

  • 主要工具包括静态 HTML 解析器(cheerio)和能够运行 JavaScript 的解析器(jsdom),以及用于找到你感兴趣的元素的正确 CSS 选择器的浏览器开发者工具。

  • 有时数据本身格式不佳,因此你可能需要解析日期或货币等事物,以便它们能与数据库兼容。

附录 C. Connect 官方支持的中间件

Connect 是 Node 内置的 HTTP 客户端和服务器模块的最小包装器。Connect 的作者和贡献者还生产了官方支持的中间件组件,这些组件实现了大多数 Web 框架使用的低级功能,包括像 cookie 处理、请求体解析、会话、基本认证和跨站请求伪造(CSRF)等。本附录演示了所有官方支持的模块,以便您可以使用它们构建无需大型框架的轻量级 Web 应用程序。

C.1. 解析 cookies、请求体和查询字符串

Node 的核心不提供解析 cookies、缓冲请求体或解析复杂查询字符串等高级 Web 应用程序概念的模块,因此 Connect 模块实现了这些功能。本节涵盖了四个解析请求数据的模块:

  • cookie-parser— 将来自 Web 浏览器的 cookies 解析到req.cookies

  • qs— 将请求 URL 查询字符串解析到req.query

  • body-parser— 消耗并解析请求体到req.body

我们将要查看的第一个模块是 cookie-parser。这个模块使得检索网站访问者浏览器存储的数据变得容易,这样您可以读取诸如授权状态、网站设置等内容。

cookie-parser模块支持常规 cookies、签名 cookies 和特殊的 JSON cookies (www.npmjs.com/package/cookie-parser)。默认情况下,使用常规未签名的 cookies,填充req.cookies对象。如果您想支持签名 cookies,这有助于防止 cookies 被篡改,您需要在创建cookie-parser实例时传递一个密钥字符串。

在服务器端设置 cookies

cookie-parser模块不提供设置输出 cookies 的任何辅助函数。为此,您应使用res.setHeader()函数,并将Set-Cookie作为头名称。将 Node 的默认res.setHeader()函数与特殊处理的Set-Cookie头连接起来,以便它按预期工作。

常规 cookies

要读取 cookies,您需要加载模块,将其添加到中间件堆栈中,然后在请求中读取 cookies。以下列表说明了这些步骤。

列表 C.1. 读取请求中发送的 cookies

此示例加载中间件组件 。请记住,您需要使用npm install cookie-parser安装中间件才能使其工作。接下来,它将 cookie 解析器的一个实例添加到该应用程序的中间件堆栈中 。最后一步是将 cookies 作为字符串发送回浏览器 ,以便您可以查看其工作情况。

如果你运行这个示例,你需要在请求中设置 cookies。如果你在浏览器中访问 http://localhost:3000,你可能不会看到太多;它应该返回一个空对象({})。你可以使用 cURL 设置一个 cookie,如下所示:

curl http://localhost:3000/ -H "Cookie: foo=bar, bar=baz"
已签名的 cookies

已签名的 cookies 更适合敏感数据,因为可以验证 cookie 数据的完整性,有助于防止中间人攻击。当有效时,已签名的 cookies 将放在req.signedCookies对象中。有两个单独的对象背后的原因是它显示了开发者的意图。如果你将已签名和未签名的 cookies 放在同一个对象中,可以制作一个常规 cookie 来包含模仿已签名 cookie 的数据。

一个已签名的 cookie 看起来像这样s:tobi.DDm3AcVxE9oneYnbmpqxoy[...]^([1]),其中点(.)左侧的内容是 cookie 的值,右侧的内容是在服务器上使用 SHA-256 HMAC(基于哈希的消息认证码)生成的秘密哈希。当 Connect 尝试取消签名 cookie 时,如果值或 HMAC 被更改,它将失败。

¹

已签名的值已被缩短。

假设,例如,你设置了一个带有name键和luna值的已签名 cookie。cookieParser将 cookie 编码为s:luna.PQLM0wNvqOQEObZX[...]。哈希部分在每个请求上都会进行检查,当 cookie 完整发送时,它将作为req.signedCookies.name可用:

$ curl http://localhost:3000/ -H "Cookie:
name=s:luna.PQLM0wNvqOQEObZXU[...]"
{}
{ name: 'luna' }
GET / 200 4ms

如果 cookie 的值发生变化,如下一个curl命令所示,namecookie 将作为req.cookies.name可用,因为它不是有效的。它可能仍然用于调试或特定于应用程序的目的:

$ curl http://localhost:3000/ -H "Cookie:
name=manny.PQLM0wNvqOQEOb[...]"
{ name: 'manny.PQLM0wNvqOQEOb[...]' }
{}
GET / 200 1ms

cookieParser的第一个参数是用于签名 cookies 的秘密。在下面的列表中,秘密是tobi is a cool ferret

列表 C.2. 解析已签名的 cookies

在这个例子中,由于将secret参数传递给了cookieParser中间件组件,所以已签名的 cookies 被自动解析![Images/circ1.jpg]。值可以在request对象上访问![Images/circ2.jpg]。cookie-parser 模块还通过signedCookiesignedCookies方法提供了 cookie 解析功能。

在继续之前,让我们看看如何使用这个示例。与列表 C.1 一样,你可以使用带有-H选项的curl发送一个 cookie。但是,为了使其被视为已签名的 cookie,它需要以某种方式进行编码。

Node 的 crypto 模块用于在signedCookie方法中取消签名 cookies。如果你想测试列表 C.2 并签名一个 cookie,你需要安装cookie-signature,然后使用相同的秘密签名一个字符串:

const signature = require('cookie-signature');
const message = 'luna';
const secret = 'tobi is a cool ferret';
console.log(signature.sign(message, secret);

现在如果签名或消息被修改,服务器将能够检测到。除了已签名的 cookies,此模块还支持 JSON 编码的 cookies。下一节将展示它们是如何工作的。

JSON cookies

特殊的 JSON Cookie 以 j: 为前缀,这通知 Connect 它打算被序列化为 JSON。JSON Cookie 可以是已签名的或未签名的。

诸如 Express 这样的框架可以使用此功能为开发者提供更直观的 Cookie 接口,而不是要求他们手动序列化和解析 JSON Cookie 值。以下是一个 Connect 解析 JSON Cookie 的示例:

$ curl http://localhost:3000/ -H 'Cookie: foo=bar,
bar=j:{"foo":"bar"}'
{ foo: 'bar', bar: { foo: 'bar' } }
{}
GET / 200 1ms

如前所述,JSON Cookie 也可以被签名,如下面的请求所示:

$ curl http://localhost:3000/ -H "Cookie:
cart=j:{\"items\":[1]}.sD5p6xFFBO/4ketA1OP43bcjS3Y"
{}
{ cart: { items: [ 1 ] } }
GET / 200 1ms

如前所述,cookie-parser 模块不提供通过 Set-Cookie 头部将输出头部写入 HTTP 客户端的任何功能。但是,Connect 通过 res.setHeader() 函数提供了对多个 Set-Cookie 头部的显式支持。

假设您想设置一个名为 foo 的 Cookie,其字符串值为 bar。Connect 允许您通过调用 res.setHeader() 在一行代码中完成此操作。您还可以设置 Cookie 的各种选项,例如其过期日期,如第二个 setHeader() 调用所示:

var connect = require('connect');

connect()
  .use((req, res) => {
    res.setHeader('Set-Cookie', 'foo=bar');
    res.setHeader('Set-Cookie',
      'tobi=ferret; Expires=Tue, 08 Jun 2021 10:18:14 GMT'
    );
  res.end();
})
.listen(3000);

如果您使用 curl--head 标志检查服务器发送回 HTTP 请求的头部,您可以看到 Set-Cookie 头部被设置为预期的那样:

$ curl http://localhost:3000/ --head
HTTP/1.1 200 OK
Set-Cookie: foo=bar
Set-Cookie: tobi=ferret; Expires=Tue, 08 Jun 2021 10:18:14 GMT
Connection: keep-alive

这就是使用 HTTP 响应发送 Cookie 的全部内容。您可以在 Cookie 中存储任何类型的文本数据,但通常在客户端存储单个会话 Cookie,以便您可以在服务器上拥有完整的用户状态。这种会话技术封装在 express-session 模块中,您将在本附录的后面了解它。

现在您已经可以处理 Cookie,您可能渴望处理其他接受用户输入的常用方法。接下来的两个部分将涵盖解析查询字符串和请求体,您会发现尽管 Connect 相对底层,但您仍然可以像更复杂的 Web 框架一样获得相同的功能,而无需编写大量代码。

C.1.2. 解析查询字符串

接受输入的一种方法是通过使用 GET 参数。您在 URL 后面放置一个问号,后面跟着由与符号分隔的参数列表:

http://localhost:3000/page?name=tobi&species=ferret

此类 URL 可以通过设置为使用 GET 方法的表单或通过应用程序模板中的锚点元素呈现给您的应用程序。您可能已经看到它被用于分页。

在 Connect 应用程序中传递给每个中间件组件的请求对象包括一个 url 属性,但您想要的是 URL 的最后一部分:即问号之后的部分。Node 内置了 URL 解析模块,因此您可以使用 url.parse 来获取查询字符串。但是 Connect 也需要解析 URL,因此它设置了一个包含解析版本的内部属性。

推荐用于解析查询字符串的模块是 qs (www.npmjs.com/package/qs)。此模块不是 Connect 的官方支持模块,并且通过 npm 提供了替代方案。要使用 qs 和类似模块,您需要从自己的中间件组件中调用其 .parse() 方法。

基本用法

以下列表使用 qs.parse 方法创建一个对象,该对象存储在 req.query 属性上,以便后续中间件组件使用。

列表 C.3. 解析查询字符串

图片

此示例使用自定义中间件组件来获取解析后的 URL,使用 qs.parse 图片 进行解析,然后在后续组件中显示它。

假设您正在设计一个音乐库应用程序。您可以提供一个搜索引擎,并使用查询字符串来构建搜索参数,如下所示:

/songSearch?artist=Bob%20Marley&track=Jammin.

此示例查询生成一个 res.query 对象,如下所示:

{ artist: 'Bob Marley', track: 'Jammin' }

qs.parse 方法支持嵌套数组,因此复杂的查询字符串,如 ?images[]=foo.png&images[]=bar.png,会产生如下对象:

{ images: [ 'foo.png', 'bar.png' ] }

当 HTTP 请求中没有提供查询字符串参数时,例如 /songSearchreq.query 将默认为空对象:

{}

高级框架,如 Express,通常内置查询字符串解析,因为这对于 Web 开发来说是一个常见的需求。Web 框架的另一个常见功能是解析请求主体,这样您就可以接受通过表单提交的数据。下一节将解释如何解析请求主体、处理表单和文件上传,并验证这些请求以确保它们是安全的。

C.1.3. body-parser:解析请求主体

大多数 Web 应用程序都必须接受和处理用户输入。这可能来自表单,甚至在 RESTful API 的情况下来自其他程序。HTTP 请求和响应统称为 HTTP 消息。消息的格式由一系列头和消息体组成。在 Node Web 应用程序中,体通常是一个流,并且可以用各种方式编码:来自表单的 POST 请求通常为 application/x-www-form-urlencoded,而 RESTful JSON 请求可能是 application/json

这意味着您的 Connect 应用程序需要能够解码表单编码数据、JSON 或甚至使用 gzip 或 deflate 压缩数据的中间件。在本节中,我们将展示如何执行以下操作:

  • 处理表单输入

  • 解析 JSON 请求

  • 根据内容和大小验证主体

  • 接受文件上传

表单

假设您想通过表单接受您应用程序的注册信息。您只需在将访问 req.body 对象的任何其他中间件之前添加 body-parser 组件 (www.npmjs.com/package/body-parser)。图 C.1 展示了这是如何工作的。

图 C.1. body-parser 如何处理表单

图片

以下列表显示了如何使用 body-parser 模块处理来自表单的 HTTP POST 请求。

列表 C.4. 解析表单请求

![Images/clis04_alt.jpg]

要使用此示例,您需要安装 body-parser 模块^([2]), 然后您需要一种方法来使用 URL 编码的 body 发送简单的 HTTP 请求。最简单的方法是使用带有-d选项的curl:

²

我们使用了版本 1.11.0。

curl -d name=tobi http://localhost:3000

这应该会导致服务器显示您发送了: {"name":"tobi"}。为了使这生效,body 解析器被添加到中间件堆栈 ![Images/circ1.jpg],然后解析后的 body 在req.body中转换为字符串 ![Images/circ2.jpg],以便更容易显示。urlencoded请求体解析器接受 UTF-8 编码的字符串,并且它会自动解压缩使用 gzip 或 deflate 编码的请求体。

在此示例中,传递给 body 解析器的选项是extended: false。当设置为true时,此选项会导致 body 解析器使用另一个库来解析查询字符串格式。这允许您在表单中使用更复杂、嵌套、类似 JSON 的对象。其他选项将在下一节中介绍,您将了解如何验证请求。

验证请求

body-parser 模块附带的所有解析器都支持两种验证请求的选项:limitverifylimit选项允许您阻止超过一定大小的请求:默认为 100 KB,因此如果您想接受更大的表单,可以将其增加。如果您正在制作类似内容管理系统或博客的东西,人们可能会输入有效但较长的字段,这将很有用。

verify选项允许您使用一个函数来验证请求。如果您想获取原始请求体并检查其格式是否正确,这很有用。例如,您可以使用此选项确保接受 XML 的 API 方法始终以正确的 XML 头开始。以下列表显示了如何使用这两个选项。

列表 C.5. 验证表单请求

![Images/clis05_alt.jpg]

注意,应该使用throw关键字抛出一个Error对象 ![Images/circ1.jpg]。body-parser 模块在解析请求之前会捕获这些错误,因此它会将错误回传给 Connect。在创建了一个请求验证函数之后,您需要通过使用verify选项将此函数传递给 body-parser 中间件组件 ![Images/circ3.jpg]。

请求体大小限制是以字节为单位的;这里相当小,只有 10 字节 ![Images/circ2.jpg]。您可以通过使用之前的curl命令并使用更大的名称值来轻松地看到请求太大时会发生什么。另外,如果您想看到当验证错误被抛出时会发生什么,请使用curl发送另一个值而不是name

为什么需要限制?

让我们看看一个恶意用户如何使一个易受攻击的服务器变得无用。首先,创建以下名为 server.js 的小型 Connect 应用程序,它除了使用bodyParser()中间件组件解析请求体之外,什么都不做:

const connect = require('connect');
const bodyParser = require('body-parser');

connect()
  .use(bodyParser.json({ limit: 99999999, extended: false }))
  .use((req, res, next) => {
    res.end('OK\n');
  })
  .listen(3000);

现在创建一个名为 dos.js 的文件,如下所示。你可以看到恶意用户如何仅通过写入几个兆字节的 JSON 数据来利用 Node 的 HTTP 客户端攻击前面的 Connect 应用程序:

启动服务器并运行攻击脚本:

$ node server.js &
$ node dos.js

如果你使用 top(1) 观察 node 进程,你应该会看到随着 dos.js 的运行,它开始使用更多的 CPU 和 RAM。这是不好的,但幸运的是,这正是所有 body 解析中间件组件接受 limit 选项的原因。

解析 JSON 数据

如果你使用 Node 制作 Web 应用程序,你将需要处理大量的 JSON。body-parser 模块的 JSON 解析器在之前的例子中已经展示了一些实用的选项。以下列表显示了如何解析 JSON 并使用结果值。

列表 C.6. 验证表单请求

在 JSON 解析器加载后 ,你的请求处理器可以将 req.body 值作为 JavaScript 对象而不是字符串来处理。此示例假设已发送一个具有 name 属性的 JSON 对象,并且它将在响应中发送该值 。这意味着你的请求必须具有 Content-Typeapplication/json,并且你需要发送有效的 JSON。默认情况下,json 中间件组件使用严格的解析,但你可以通过将其设置为 false 来放宽编码要求。

设置 JSON Content-Type 选项

你需要了解的一个选项是 type。这允许你更改将被解析为 JSON 的 Content-Type。在下面的例子中,我们使用默认值,即 application/json。但在某些情况下,你的应用程序可能需要与不发送此头信息的 HTTP 客户端交互,所以请小心。

以下 curl 请求可以用来向你的应用程序提交数据,并将包含 username 属性设置为 tobi 的 JSON 对象发送:

curl -d '{"name":"tobi"}' -H "Content-Type: application/json"
http://localhost:3000
Name: tobi
解析 multipart
数据

body-parser 模块不处理 multipart 请求体。你需要处理 multipart 消息以支持文件上传,因此任何如上传用户头像这样的操作都需要 multipart 支持。

Connect 没有官方支持的 multipart 解析器,但一些流行的解析器维护得很好。两个例子是 busboy (www.npmjs.com/package/busboy) 和 multiparty (www.npmjs.com/package/multiparty)。这两个模块都有相关的 connect 模块:connect-busboy 和 connect-multiparty。之所以这样,是因为 multipart 解析器本身依赖于 Node 的底层 HTTP 模块,因此它们可以被广泛的应用框架使用。它们并不是专门绑定到 Connect 的。

以下列表基于 multiparty,将在控制台打印出上传文件的详细信息。

列表 C.7. 处理上传的文件

这个简短的示例添加了 multiparty 中间件组件 并记录接收到的文件 。文件将被上传到临时位置,所以当你应用程序完成使用这些文件时,你必须使用 fs 模块删除这些文件。

要使用此示例,请确保你已经安装了 connect-multiparty.^([3]) 然后启动服务器,并使用curl-F选项发送一个文件:

³

我们使用版本 1.2.5 来测试这个示例。

curl -F file=@index.js http://localhost:3000

文件名放在@符号之后,并且它前面有一个字段名。字段名将在req.files对象中可用,因此你可以区分不同的上传文件。

如果你查看应用程序的输出,你会看到类似于以下示例输出的内容。正如你所看到的,req.files.file.path将可用于你的应用程序,你可以重命名磁盘上的文件,将数据传输到工作进程进行处理,上传到内容分发网络,或者做任何你的应用程序需要的其他事情:

{ fieldName: 'file',
  originalFilename: 'index.js',
  path: '/var/folders/d0/_jqj3lf96g37s5wrf79v_g4c0000gn/T/60201-p4pohc.js',
  headers:
   { 'content-disposition': 'form-data; name="file"; filename="index.js"',
     'content-type': 'application/octet-stream' },

虽然 body-parser 可以处理压缩,但你可能想知道如何压缩发出的响应。继续阅读,了解可以减少你的带宽账单并让你的 Web 应用感觉更快的压缩中间件组件。

C.1.4. 压缩:压缩发出的响应

在前面的章节中,你可能已经注意到,体解析器可以解压缩使用 gzip 或 deflate 的请求。Node 自带一个用于处理压缩的核心模块,名为 zlib,它用于实现压缩和解压缩方法。压缩中间件组件(www.npmjs.com/package/compression)可以用于压缩发出的响应,这意味着你的服务器发送的数据可以被压缩。

Google 的 PageSpeed Insights 工具建议启用 gzip 压缩,^([4]) 如果你查看开发工具中浏览器发出的请求,你应该看到许多网站发送了压缩的响应。压缩会增加 CPU 开销,但由于纯文本和 HTML 等格式压缩得很好,它可以提高你网站的性能并减少带宽使用。

有关更多信息,请参阅developers.google.com/speed/docs/insights/EnableCompression

Deflate 或 gzip?

有两个压缩选项可能会让人感到困惑。你可能想知道哪个最好,以及为什么会有两个。嗯,根据标准(RFC 1950 和 RFC 2616),它们都使用相同的压缩算法,但它们在处理头部和校验和的方式上有所不同。

不幸的是,一些浏览器不能正确处理 deflate,所以一般的建议是使用 gzip。在解析体的情况下,最好能够支持两者,但如果你正在压缩服务器的输出,使用 gzip 以确保安全。

压缩模块会检测来自 Accept-Encoding 头字段的接受编码。如果该字段不存在,则使用身份编码,意味着响应不会被修改。否则,如果该字段包含 gzipdeflate 或两者都包含,则响应将被压缩。

基本用法

你通常应该在 Connect 堆栈中添加压缩,因为它封装了 res.write()res.end() 方法。

在以下示例中,内容将被压缩:

const connect = require('connect');
const compression = require('compression');
connect()
  .use(compression({ threshold: 0 }))
  .use((req, res) => {
    res.setHeader('Content-Type', 'text/plain');
    res.end('This response is compressed!\n');
  })
  .listen(3000);

要运行此示例,你需要从 npm 安装压缩模块。然后,启动服务器并尝试使用设置 Accept-Encodinggzipcurl 发送请求:

$ curl http://localhost:3000 -i -H "Accept-Encoding: gzip"

-i 参数使 cURL 显示头信息,因此你应该看到 Content-Encoding 设置为 gzip。输出应该是乱码的,因为压缩数据不会是标准字符。尝试不带 -i 选项通过 gunzip 来管道化它以查看输出:

$ curl http://localhost:3000 -H "Accept-Encoding: gzip" | gunzip

这功能强大且相对简单易设,但你并不总是想压缩服务器发送的所有内容。要跳过压缩,你可以使用自定义过滤器函数。

使用自定义过滤器函数

默认情况下,compression 在默认的 filter 函数中包含 text/**/json*/java-script MIME 类型,以避免压缩这些数据类型:

exports.filter = function(req, res){
  const type = res.getHeader('Content-Type') || '';
  return type.match(/json|text|javascript/);
};

要改变这种行为,你可以在选项对象中传递一个 filter,如下面的代码片段所示,这将仅压缩纯文本:

function filter(req) {
  const type = req.getHeader('Content-Type') || '';
  return 0 === type.indexOf('text/plain');
}
connect()
  .use(compression({ filter: filter }));
指定压缩和内存级别

Node 的 zlib 绑定提供了调整性能和压缩特性的选项,并且它们也可以传递给 compression 函数。

在以下示例中,压缩 level 设置为 3 以实现更快的压缩但压缩效果较低,memLevel 设置为 8 以使用更多内存来实现更快的压缩。这些值完全取决于你的应用程序及其可用的资源。有关详细信息,请参阅 Node 的 zlib 文档:

connect()
  .use(compression({ level: 3, memLevel: 8 }));

这就是全部内容。接下来,我们将探讨覆盖核心 Web 应用需求的中间件,例如日志记录和会话。

C.2. 实现核心 Web 应用功能

Connect 旨在实现并提供内置中间件以满足最常见的 Web 应用需求,这样它们就不需要每个开发者反复重新实现。核心 Web 应用功能,如日志记录、会话和虚拟主机,都由 Connect 提供。

在本节中,你将了解五个有用的中间件组件,你可能会在应用程序中使用它们:

  • morgan— 提供灵活的请求日志

  • serve-favicon— 处理 /favicon.ico 请求,无需你费心

  • method-override— 允许无能力的客户端透明地覆盖 req.method

  • vhost— 在单个服务器上设置多个网站(虚拟主机)

  • express-session— 管理会话数据

到目前为止,您已经创建了您自己的自定义日志中间件,但 Connect 维护者提供了一个名为 Morgan 的灵活解决方案,因此让我们首先探索它。

C.2.1. morgan: 记录请求

Morgan 模块 (www.npmjs.com/package/morgan) 是一个灵活的请求记录中间件组件,具有可定制的日志格式。它还具有缓冲日志输出以减少磁盘写入的选项,以及如果您想将日志记录到控制台以外的其他位置(如文件或套接字)时指定日志流。

基本用法

要在您的应用程序中使用 Morgan,请将其作为函数调用,以返回一个中间件函数,如下所示列表所示。

列表 C.8. 使用 Morgan 模块进行日志记录

要使用此示例,您需要从 npm 安装 Morgan 模块.^([5]) 它将模块添加到中间件堆栈的顶部 并然后输出简单的文本响应 。通过使用 combined 记录格式参数 ,此 Connect 应用程序将输出 Apache 日志格式。这是一个灵活的格式,许多命令行工具都可以解析,因此您可以将日志通过日志处理应用程序运行,以生成有用的统计数据。如果您尝试从不同的客户端(如 curlwget 和浏览器)发出请求,您应该在日志中看到用户代理字符串。

我们使用了版本 1.5.1。

combined 记录格式定义如下:

:remote-addr - :remote-user [:date[clf]] ":method :url
HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"

每个 :something 部分 标记,在日志条目中它们将包含正在记录的 HTTP 请求的实时值。例如,一个简单的 curl(1) 请求将生成一个类似于以下日志行的记录:

127.0.0.1 - - [Thu, 05 Feb 2015 04:27:07 GMT]
                    "GET / HTTP/1.1" 200 - "-"
                    "curl/7.37.1"
自定义日志格式

您还可以创建自己的日志格式。为此,传递一个自定义的标记字符串。例如,以下格式将输出类似 GET /users 15 ms 的内容:

connect()
  .use(morgan(':method :url :response-time ms'))
  .use(hello)
  .listen(3000);

默认情况下,以下标记可用于使用(注意,头部名称不区分大小写):

  • :req[header] 示例::req[Accept]

  • :res[header] 示例::res[Content-Length]

  • :http-version

  • :response-time

  • :remote-addr

  • :date

  • :method

  • :url

  • :referrer

  • :user-agent

  • :status

您甚至可以定义自定义标记。您只需向 connect.logger.token 函数提供一个标记名称和回调函数即可。例如,假设您想记录每个请求的查询字符串。您可能定义如下:

var url = require('url');
morgan.token('query-string', function(req, res){
  return url.parse(req.url).query;
});

Morgan 模块除了默认格式外,还提供了预定义的格式,例如 shorttiny。另一个预定义的格式是 dev,它为开发环境生成简洁的输出,适用于你通常是网站上的唯一用户且不关心 HTTP 请求的详细信息的情况。此格式还会根据类型对响应状态码进行颜色编码:状态码在 200 范围内的响应为绿色,300 范围内的为蓝色,400 范围内的为黄色,500 范围内的为红色。这种颜色方案非常适合开发使用。

要使用预定义的格式,你需要在 logger() 中提供名称:

connect()
  .use(morgan('dev'))
  .use(hello);
  .listen(3000);

现在你已经知道了如何格式化日志输出,让我们看看你可以提供给它的选项。

日志选项:stream,immediate 和 buffer

如前所述,你可以使用选项来调整 morgan 的行为。

其中一个选项是 stream,它允许你传递一个 Node Stream 实例,日志将写入该实例而不是 stdout。这允许你通过使用从 fs.createWriteStream 创建的 Stream 实例将日志输出定向到自己的日志文件,独立于服务器的输出。

当你使用这些选项时,通常建议还包括 format 属性。以下示例使用自定义格式,并带有 append 标志将日志记录到 /var/log/myapp.log,这样在应用程序启动时文件不会被截断:

const fs = require('fs');
const morgan = require('morgan');
const log = fs.createWriteStream('/var/log/myapp.log', { flags: 'a' })
connect()
  .use(morgan({ format: ':method :url', stream: log }))
  .use('/error', error)
  .use(hello)
  .listen(3000);

另一个有用的选项是 immediate,它在接收到请求时立即写入日志行,而不是等待响应。如果你正在编写一个长时间保持请求打开的服务器,并且想知道连接何时开始,或者你可能用它来调试应用程序的关键部分。像 :status:response-time 这样的令牌不能使用,因为它们与响应相关。要启用立即模式,将 immediate 的值传递为 true,如下所示:

const app = connect()
  .use(connect.logger({ immediate: true }))
  .use('/error', error)
  .use(hello);

日志部分到此结束!接下来,我们将查看 favicon-serving 中间件组件。

C.2.2. serve-favicon:地址栏和书签图标

favicon 是浏览器在地址栏和书签中显示的微小网站图标。为了获取这个图标,浏览器会向 /favicon.ico 的文件发送请求。通常最好尽快提供 favicon 文件,这样其余的应用程序就可以简单地忽略它们。serve-favicon 模块(www.npmjs.com/package/serve-favicon)默认显示 Connect 的图标。这可以通过传递其他图标的参数来配置。此 favicon 如图 C.2 所示。

图 C.2. 一个 favicon

基本用法

serve-favicon 中间件组件可以被放置在堆栈的顶部,这会导致后续的任何日志组件忽略 favicon 请求。图标被缓存在内存中以实现快速响应。

以下示例显示 serve-favicon 通过传递文件路径作为唯一参数发送 .ico 文件:

const connect = require('connect');
const favicon = require('serve-favicon');
connect()
  .use(favicon(__dirname + '/favicon.ico'))
  .use((req, res) => {
    res.end('Hello World!\n');
  });

注意,你需要一个名为 favicon.ico 的文件来测试这一点。可选地,你可以传递一个 maxAge 参数来指定浏览器应该在内存中缓存 favicon 的时间长度。

接下来,我们有一个另一个小但很有用的中间件组件:method-override。它提供了在客户端功能有限时伪造 HTTP 请求方法的方法。

C.2.3. method-override:伪造 HTTP 方法

有时候使用超出常见 GETPOST 方法的 HTTP 动词是有用的。想象一下你正在构建一个博客,并希望允许人们创建、更新和删除文章。说 DELETE /article 比说 GETPOST 更自然。不幸的是,并非每个浏览器都理解 DELETE 方法。

一个常见的解决方案是允许服务器从查询参数、表单值以及有时甚至 HTTP 头部中获取有关要使用哪个 HTTP 方法的提示。这样做的一种方式是添加 <input type=hidden> 并将其值设置为要使用的方程序名。然后服务器可以检查该值并假装它是请求方法。

大多数 Web 框架都支持这种技术,而 method-override 模块(www.npmjs.com/package/method-override)是使用 Connect 实现它的推荐方式。

基本用法

默认情况下,HTML 输入名称是 _method,但你可以向 methodOverride 传递一个自定义值,如下面的代码片段所示:

connect()
const connect = require('connect');
const methodOverride = require('method-override');
connect()
  .use(methodOverride('__method__'))
  .listen(3000)

为了展示 methodOverride() 的实现方式,让我们看看如何创建一个用于更新用户信息的小型应用程序。该应用程序由一个表单组成,当浏览器提交表单并由服务器处理时,它会返回一个简单的成功消息,如图 C.3 所示。

图 C.3. 使用 methodoverride 在浏览器中模拟 PUT 请求以更新表单

图片

该应用程序通过使用两个独立的中间件组件来更新用户数据。在 update 函数中,当请求方法不是 PUT 时,会调用 next()。如前所述,大多数浏览器不尊重表单属性 method="put",所以以下列表中的应用程序将无法正常工作。

列表 C.9. 一个损坏的用户更新应用程序

图片

在这个例子中,已经设置了一个表单,它将向服务器发送 PUT 图片。该表单应该将数据发送到 update 函数,但只有当它以 PUT 发送时 图片。你可以尝试使用不同的浏览器和 HTTP 客户端;你可以使用 -X 选项通过 curl 发送 PUT

为了提高浏览器支持,你将添加 method-override 模块。这里在表单中添加了一个额外的输入,其名称为 _method,并在 bodyParser() 方法下方添加了 methodOverride(),因为它引用 req.body 以访问表单数据。

列表 C.10. 使用 method-override 支持 HTTP PUT

图片

图片

如果你运行这个示例,你应该会看到你现在可以从几乎任何浏览器发送 PUT 请求。

访问原始 req.method

methodOverride() 修改了原始的 req.method 属性,但 Connect 会复制原始方法,你可以始终使用 req.originalMethod 访问它。之前的表单会输出如下值:

console.log(req.method);
  // "PUT"
console.log(req.originalMethod);
  // "POST"

为了避免包含额外的表单变量,HTTP 头部也得到了支持。不同的供应商使用不同的头部,因此你可以创建支持多个头部字段名的服务器。如果你想要支持假设特定头部的客户端工具和库,这将有所帮助。在下面的示例中,支持了三个头部字段名:

基于头部进行路由是一个常见的任务。一个很好的例子是支持虚拟主机。你可能见过 Apache 服务器在你想在较少的 IP 地址上托管多个网站时执行此操作。Apache 和 Nginx 可以根据 Host 头部确定应该服务哪个网站。

Connect 也可以这样做,比你想象的要简单。继续阅读,了解虚拟主机和 vhost 模块。

C.2.4. vhost: 虚拟主机

vhost(虚拟主机)模块 (www.npmjs.com/package/vhost) 是一个简单、轻量级的中间件组件,它通过 Host 请求头部路由请求。这项任务通常由反向代理执行,然后它将请求转发到运行在本地不同端口的 Web 服务器。vhost 组件通过将控制权传递给与 vhost 实例关联的 Node HTTP 服务器来完成这项任务。

基本用法

与大多数中间件一样,只需一行代码就可以启动 vhost 组件。它接受两个参数:第一个是这个 vhost 实例将与之匹配的主机字符串。第二个是在创建匹配主机名的 HTTP 请求时将使用的 http.Server 实例(所有 Connect 应用程序都是 http.Server 的子类,因此应用程序实例也可以使用):

const connect = require('connect');
const server = connect();
const vhost = require('vhost');
const app = require('./sites/expressjs.dev');
server.use(vhost('expressjs.dev', app));
server.listen(3000);

为了使用前面的 ./sites/expressjs.dev 模块,它应该将 HTTP 服务器分配给 module.exports,如下面的示例所示:

const http = require('http')
module.exports = http.createServer((req, res) => {
  res.end('hello from expressjs.com\n');
});
使用多个 vhost 实例

与任何其他中间件一样,你可以在一个应用程序中使用 vhost 多次,将多个主机映射到它们相关的应用程序:

const app = require('./sites/expressjs.dev');
server.use(vhost('expressjs.dev', app));
const app = require('./sites/learnboost.dev');
server.use(vhost('learnboost.dev', app));

而不是像这样手动设置 vhost 中间件,你可以从文件系统中生成一个主机列表。以下是一个示例,其中 fs.readdirSync() 方法返回一个目录条目数组:

const connect = require('connect')
const fs = require('fs');
cons app = connect()
const sites = fs.readdirSync('source/sites');
sites.forEach((site) => {
  console.log('  ... %s', site);
  app.use(vhost(site, require('./sites/' + site)));
});
app.listen(3000);

使用 vhost 而不是反向代理的好处是简单性。它允许你将所有应用程序作为一个单一单元来管理。这对于服务多个较小的网站,或者服务主要由静态内容组成的网站来说很理想,但它也有一个缺点,那就是如果某个网站导致崩溃,所有你的网站都会被关闭(因为它们都在同一个进程中运行)。

接下来,我们将查看 Connect 提供的最基本的中间件组件之一:会话管理组件,命名为 express-session。

C.2.5. express-session: 会话管理

网络应用程序处理会话的方式取决于不同的需求。例如,一个重要的选择是存储后端:一些应用程序受益于高性能数据库,如 Redis;而其他应用程序则需要简单性,并使用与主应用程序相同的数据库。express-session 模块(www.npmjs.com/package/express-session)提供了一个可以扩展以适应不同数据库的 API。它既健壮又易于扩展,因此拥有许多社区支持的扩展。在本节中,您将学习如何使用基于内存的版本和 Redis。

首先,让我们看看如何设置中间件并探索可用的选项。

基本用法

列表 C.11 实现了一个小型应用程序,该应用程序计算给定用户访问页面的次数。数据存储在用户的会话中。默认情况下,cookie 名称为 connect.sid,并且它被设置为httpOnly,这意味着客户端脚本无法访问其值。会话中的数据在服务器上以内存形式存储。列表显示了在 Connect 中使用 express-session 的基本用法.^([6])

这是在 express-session 1.10.2 版本上进行的测试。

列表 C.11. 在 Connect 中使用会话

图片 2

这个简短的示例首先设置了会话,然后操作一个名为 views 的单个会话变量。首先,会话中间件组件使用所需的选项进行初始化:secretresavesaveUninitialized 图片 1secret 选项是必需的,它决定了用于标识会话的 cookie 是否被签名。resave 选项用于强制在每次请求时保存会话,即使它没有发生变化。某些会话存储后端需要这个选项,因此您在启用之前需要检查。最后一个选项 saveUninitialized 会导致即使没有保存任何值也会创建一个会话。如果您想遵守在保存 cookie 之前需要获得同意的法律,您可以关闭此选项。

设置会话过期日期

假设您希望会话在 24 小时后过期,仅在 HTTPS 使用时发送会话 cookie,并配置 cookie 名称。您可以通过在表达式对象上设置 expiresmaxAge 属性来控制会话的持续时间:

const hour = 3600000
req.session.cookie.expires = new Date(Date.now() + hour * 24);
req.session.cookie.maxAge = hour * 24;

当使用 Connect 时,您通常会设置 maxAge,指定从该时间点开始的一段时间(以毫秒为单位)。这种表示未来日期的方法通常更直观地写成 new Date(Date.now() + maxAge)

现在会话已经设置好了,让我们看看在处理会话数据时可用的方法和属性。

使用会话数据

Express-session 数据管理 API 很简单。其基本原理是,当请求完成时,分配给 req.session 对象的任何属性都会被保存;然后它们会在同一用户(浏览器)的后续请求中加载。例如,保存购物车信息就像将一个对象分配给 cart 属性一样简单,如下所示:

req.session.cart = { items: [1,2,3] };

当你在后续请求中访问 req.session.cart 时,.items 数组将是可用的。因为这是一个常规的 JavaScript 对象,你可以在后续请求中对嵌套对象调用方法,就像以下示例中那样,并且它们会按预期保存:

req.session.cart.items.push(4);

有一个重要的事情需要记住,这个会话对象在请求之间会被序列化为 JSON,所以 req.session 对象有与 JSON 相同的限制:不允许循环属性,不能使用 function 对象,Date 对象不能正确序列化,等等。在使用会话对象时,请记住这些限制。

Connect 会自动为你保存会话数据,但内部它调用的是 Session#save([callback]) 方法,这个方法也作为公共 API 提供。另外两个有用的方法是 Session#destroy()Session#regenerate(),它们通常在验证用户时使用,以防止会话固定攻击。当你使用 Express 构建应用程序时,你会使用这些方法进行身份验证。

现在让我们继续操作会话 cookie。

Connect 允许你为会话提供全局 cookie 设置,但也可以通过 Session#cookie 对象操作特定的 cookie,该对象默认为全局设置。

在你开始调整属性之前,让我们看看如何通过将每个属性写入响应 HTML 中的单独 <p> 标签来扩展先前的会话应用程序,如下所示:

...
res.write('<p>views: ' + sess.views + '</p>');
res.write('<p>expires in: ' + (sess.cookie.maxAge / 1000) + 's</p>');
res.write('<p>httpOnly: ' + sess.cookie.httpOnly + '</p>');
res.write('<p>path: ' + sess.cookie.path + '</p>');
res.write('<p>domain: ' + sess.cookie.domain + '</p>');
res.write('<p>secure: ' + sess.cookie.secure + '</p>');
...

Express-session 允许按会话基础程序化地更改所有 cookie 属性(如 expireshttpOnlysecurepathdomain)。例如,你可以这样在 5 秒内使一个活跃的会话过期:

req.session.cookie.expires = new Date(Date.now() + 5000);

对于过期设置,有一个更直观的 API 是 .maxAge 访问器,它允许你相对于当前时间以毫秒为单位获取和设置值。以下代码也会在 5 秒后使会话过期:

req.session.cookie.maxAge = 5000;

剩余的属性,domainpathsecure,限制了 cookie 的 作用域,通过域名、路径或安全连接来限制,而 httpOnly 阻止客户端脚本访问 cookie 数据。这些属性可以以相同的方式操作:

req.session.cookie.path = '/admin';
req.session.cookie.httpOnly = false;

到目前为止,你一直在使用默认的内存存储来存储会话数据,所以让我们看看如何连接替代数据存储。

会话存储

在之前的示例中,我们一直在使用内置的MemoryStore会话存储。它是一个简单的内存数据存储,非常适合运行应用程序测试,因为它不需要其他依赖项。但在开发和生产中,最好有一个持久、可扩展的数据库作为您的会话数据后端;否则,每次重启服务器时,您都会丢失会话。

几乎任何数据库都可以作为会话存储,但对于这种易变数据,低延迟的键/值存储效果最好。Connect 社区为数据库创建了几种会话存储,包括 CouchDB、MongoDB、Redis、Memcached、PostgreSQL 等。

在这里,您将使用 connect-redis 模块与 Redis 一起使用(www.npmjs.com/package/connect-redis)。Redis 是一个很好的后端存储,因为它支持键过期、提供出色的性能,并且易于安装。

调用redis-server以确保您已安装 Redis:

$ redis-server
[11790] 16 Oct 16:11:54 * Server started, Redis version 2.0.4
[11790] 16 Oct 16:11:54 * DB loaded from disk: 0 seconds
[11790] 16 Oct 16:11:54 * The server is now ready to accept
connections on port 6379
[11790] 16 Oct 16:11:55 - DB 0: 522 keys (0 volatile) in 1536 slots HT.

接下来,您需要通过将其添加到您的 package.json 文件并运行npm install,或者直接执行npm install --save connect-redis来安装 connect-redis。^([7]) connect-redis 模块导出一个函数,该函数应该传递给connect,如下面的列表所示。

我们在编写这本书时使用了 2.2.0 版本。

列表 C.12. 使用 Redis 作为会话存储

此示例设置了一个使用 Redis 的会话存储。将connect引用传递给connect-redis允许它从connect.session.Store.prototype继承。这很重要,因为在 Node 中,单个进程可能同时使用多个模块的多个版本;通过传递您的特定 Connect 版本,您可以确保 connect-redis 使用正确的副本。

RedisStore实例传递给session()作为store值,您想要使用的任何选项,例如会话的键前缀,都可以传递给RedisStore构造函数。完成这两个步骤后,您就可以像使用MemoryStore一样访问会话变量。关于这个示例的一个小细节是,我们包括了 favicon 中间件组件以防止会话变量被两次增加;否则,每次浏览器获取页面和/favicon.ico 时,views值看起来都会增加 2。

呼呼!session有很多内容要涵盖,但这完成了所有核心概念中间件。接下来,我们将介绍处理 Web 应用程序安全的内置中间件。这对于需要保护其数据的应用程序来说是一个重要主题。

C.3. 处理 Web 应用程序安全

正如我们多次提到的,Node 的核心 API 故意是低级的。这意味着在构建 Web 应用程序时,它不提供内置的安全或最佳实践。幸运的是,Connect 中间件组件实现了这些安全实践。

本节将向您介绍三个可以从 npm 安装的与安全相关的模块:

  • basic-auth— 为保护数据提供 HTTP 基本认证

  • csurf— 实现对跨站请求伪造(CSRF)攻击的保护

  • errorhandler— 在开发期间帮助您调试

首先,让我们看看如何设置一个使用 basic-auth 提供 HTTP 基本认证的应用程序。

C.3.1. basic-auth: HTTP 基本认证

在 第四章 中,您创建了一个粗略的基本认证中间件组件。好吧,结果证明,几个 Connect 模块可以为您做这件事。如前所述,基本认证是一种简单的 HTTP 认证机制,应该谨慎使用,因为除非基本认证通过 HTTPS 提供,否则用户凭证对攻击者来说很容易被拦截。话虽如此,它对于向小型或个人应用程序添加快速且简单的认证可能很有用。

当您的应用程序使用 basic-auth 模块时,当用户第一次尝试连接到您的应用程序时,网络浏览器会提示输入凭证,如图 C.4 所示。

图 C.4. 基本认证提示

基本用法

basic-auth 模块 (www.npmjs.com/package/basic-auth) 允许您从 HTTP Authorization 头字段获取凭证。以下列表显示了如何使用您自己的密码验证函数。

列表 C.13. 使用基本认证模块

basic-auth 模块仅提供认证过程中的 Authorization 头字段解析部分。您必须自己通过在中间件组件中调用它来检查密码,然后当认证失败时,basic-auth 模块会发送正确的头。此示例在认证成功时调用 next(),以便执行继续到应用程序的保护部分。

curl 的一个示例

现在尝试使用 curl 向服务器发送 HTTP 请求,您将看到您未授权:

$ curl http://localhost:3000 -i
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Authorization Required"
Connection: keep-alive
Transfer-Encoding: chunked
Unauthorized

使用相同的请求和 HTTP 基本认证凭证(注意 URL 的开头)将提供访问权限:

$ curl --user tj:tobi http://localhost:3000 -i
HTTP/1.1 200 OK
Date: Sun, 16 Oct 2011 22:42:06 GMT
Cache-Control: public, max-age=0
Last-Modified: Sun, 16 Oct 2011 22:41:02 GMT
ETag: "13-1318804862000"
Content-Type: text/plain; charset=UTF-8
Accept-Ranges: bytes
Content-Length: 13
Connection: keep-alive
I'm a secret

继续本节的网络安全主题,让我们看看 csurf 模块,该模块旨在帮助防止跨站请求伪造攻击。

C.3.2. csurf: 跨站请求伪造保护

跨站请求伪造(CSRF)是一种攻击形式,它利用了网络浏览器对网站的信任。攻击通过让您的应用程序上的已认证用户访问攻击者创建或破坏的另一个网站来实现,然后代表用户进行请求,而用户并不知道这一点。

通过一个示例更容易理解这个过程。假设在您的应用程序中,请求DELETE /account将触发用户的账户被销毁(尽管仅在用户登录时)。现在假设该用户访问了一个恰好容易受到 CSRF 攻击的论坛。攻击者可以发布一个脚本,发出DELETE /account请求,从而销毁用户的账户。这对您的应用程序来说是一个糟糕的情况,而 csurf 模块可以帮助防止这种攻击。

csurf 模块(www.npmjs.com/package/csurf)通过生成一个 24 字符的唯一 ID,即认证令牌,并将其分配给用户的会话作为req.session._csrf来实现。然后,可以将此令牌包含为名为_csrf的隐藏表单输入,CSRF 组件可以在提交时验证令牌。此过程对每次交互都会重复。

基本用法

为了确保 csurf 可以访问req.body._csrf(隐藏输入值)和req.session._csrf,您需要确保在 body-parser 和 express-session 之后添加模块的中间件函数,如下面的列表所示.^([8])

我们使用 csurf 1.6.6 测试了这个示例。

列表 C.14. CSRF 保护

要使用 csurf,您必须首先加载 body-parser 和 session 中间件组件。此示例显示了一个表单,其中包含一个带有当前 CSRF 令牌的文本字段。此令牌将导致某些方法类型的所有请求根据会话中的密钥进行检查。您可以使用req.csrf-Token获取当前令牌,这是 csurf 添加的方法。带有无效令牌的帖子将由 csurf 自动标记,因此我们包括了一个“令牌成功”处理程序和一个错误处理程序。此示例使用文本字段,以便您可以看到如果更改它会发生什么。

这个示例显示,csurf 会自动对某些类型的请求启动。这是通过传递给 csurf 的ignoreMethods选项定义的。默认情况下,HTTP GETHEADOPTIONS被忽略,但如果需要,您可以添加其他方法。

网络开发的另一个方面是确保在生产环境和开发环境中都提供详尽的日志和详细的错误报告。让我们看看 errorhandler 模块,它正是为此而设计的。

C.3.3. errorhandler:在开发期间显示错误

errorhandler 模块(www.npmjs.com/package/errorhandler)非常适合开发,它根据Accept头字段提供详尽的 HTML、JSON 和纯文本错误响应。它旨在在开发期间使用,不应成为生产配置的一部分。

基本用法

通常,这个组件应该是最后使用的,以便它可以捕获所有错误:

connect()
  .use((req, res, next) => {
    setTimeout(function () {
       next(new Error('something broke!'));
     }, 500);
  })
  .use(errorhandler());
接收 HTML 错误响应

如果你使用这里显示的设置在你的浏览器中查看任何页面,你会看到一个像图 C.5 中显示的 Connect 错误页面,显示错误消息、响应状态和整个堆栈跟踪。

图 C.5. 在 Web 浏览器中显示的默认 errorhandler HTML

图片

接收纯文本错误响应

现在假设你正在测试使用 Connect 构建的 API。以大量 HTML 响应远非理想,所以默认情况下errorHandler()将以text/plain响应,这对于命令行 HTTP 客户端(如curl(1))来说是非常理想的。这在上面的标准输出中得到了说明:

$ curl localhost:3000 -H "Accept: text/plain"
Error: something broke!
    at Object.handle (/Users/tj/Projects/node-in-action/source
    /connect-middleware-errorHandler.js:12:10)
    at next (/Users/tj/Projects/connect/lib/proto.js:179:15)
    at Object.logger [as handle] (/Users/tj/Projects/connect
    /lib/middleware/logger.js:155:5)
    at next (/Users/tj/Projects/connect/lib/proto.js:179:15)
    at Function.handle (/Users/tj/Projects/connect/lib/proto.js:192:3)
    at Server.app (/Users/tj/Projects/connect/lib/connect.js:53:31)
    at Server.emit (events.js:67:17)
    at HTTPParser.onIncoming (http.js:1134:12)
    at HTTPParser.onHeadersComplete (http.js:108:31)
    at Socket.ondata (http.js:1029:22)
接收 JSON 错误响应

如果你发送一个带有Accept: application/json HTTP 头部的 HTTP 请求,你将得到以下 JSON 响应:

$ curl http://localhost:3000 -H "Accept: application/json"
{"error":{"stack":"Error: something broke!\n
            at Object.handle (/Users/tj/Projects/node-in-action
            /source/connect-middleware-errorHandler.js:12:10)\n
            at next (/Users/tj/Projects/connect/lib/proto.js:179:15)\n
            at Object.logger [as handle] (/Users/tj/Projects
            /connect/lib/middleware/logger.js:155:5)\n
            at next (/Users/tj/Projects/connect/lib/proto.js:179:15)\n
            at Function.handle (/Users/tj/Projects/connect/lib/proto.js:192:3)\n
            at Server.app (/Users/tj/Projects/connect/lib/connect.js:53:31)\n
            at Server.emit (events.js:67:17)\n
            at HTTPParser.onIncoming (http.js:1134:12)\n
            at HTTPParser.onHeadersComplete (http.js:108:31)\n
            at Socket.ondata (http.js:1029:22)","message":"something broke!"}}

我们已经对 JSON 响应添加了额外的格式化,使其在页面上更容易阅读,但当 Connect 发送 JSON 响应时,它会被JSON.stringify()方法很好地压缩。

你现在感觉像是一个 Connect 安全专家了吗?可能还不是,但你应该已经掌握了足够的基础知识来使你的应用程序安全。现在让我们继续到一个常见的 Web 应用程序功能:提供静态文件。

C.4. 提供静态文件

提供静态文件是许多 Web 应用程序的共同需求,但 Node 的核心并没有提供。幸运的是,通过一些简单的模块,Connect 在这里也为你提供了支持。

在本节中,你将了解 Connect 的两个更多官方支持的模块——这次将重点放在从文件系统提供文件上。这些类型的功能由 Apache 和 Nginx 等 HTTP 服务器提供,但通过一点配置,你可以将它们添加到你的 Connect 项目中:

  • serve-static— 从给定的根目录从文件系统中提供文件

  • serve-index— 当请求目录时提供漂亮的目录列表

首先,我们将向您展示如何通过使用 server-static 模块用一行代码来提供静态文件。

C.4.1. serve-static:自动向浏览器提供文件

serve-static 模块(www.npmjs.com/package/serve-static)实现了一个高性能、灵活、功能丰富的静态文件服务器,支持 HTTP 缓存机制、Range请求等。它还包括对恶意路径的安全检查,默认不允许访问以点开头的隐藏文件,并拒绝有毒的null字节。本质上,serve-static 是一个安全且符合规范的静态文件提供中间件组件,确保与各种 HTTP 客户端的兼容性。

基本用法

假设你的应用程序遵循从名为./public 的目录提供静态资产的典型场景。这可以通过一行代码实现:

app.use(serveStatic('public'));

在这种配置下,serve-static 将根据请求 URL 检查 ./public/ 中存在的常规文件。如果文件存在,响应的 Content-Type 字段值将默认基于文件的扩展名,并且数据将被传输。如果请求的路径不表示文件,则将调用 next() 回调,允许后续中间件(如果有的话)处理请求。

为了测试它,创建一个名为 ./public/foo.js 的文件,并使用 console.log('tobi'),然后通过使用带有 -i 标志的 curl(1) 向服务器发送请求,告诉它打印 HTTP 头部信息。你会看到 HTTP 缓存相关的头部字段被适当地设置,Content-Type 反映了 .js 扩展名,并且内容被传输:

$ curl http://localhost/foo.js -i
HTTP/1.1 200 OK
Date: Thu, 06 Oct 2011 03:06:33 GMT
Cache-Control: public, max-age=0
Last-Modified: Thu, 06 Oct 2011 03:05:51 GMT
ETag: "21-1317870351000"
Content-Type: application/javascript
Accept-Ranges: bytes
Content-Length: 21
Connection: keep-alive
console.log('tobi');

因为请求路径被原样使用,目录内的文件将按预期提供服务。例如,你可能在服务器上有一个 GET /javascripts/jquery.js 请求和一个 GET /stylesheets/app.css 请求,分别会服务 ./public/javascripts/jquery.js 和 ./public/stylesheets/app.css 文件。

使用 serve-static 和挂载

有时,应用程序会在路径名前加上 /public、/assets、/static 等前缀。使用 Connect 实现的挂载概念,从多个目录中提供服务静态文件变得简单。只需将应用挂载到你想要的位置。如 第五章 中所述,中间件本身并不知道它被挂载,因为前缀已被移除。

例如,当 serve-static 挂载在 /app/files 上时,对 GET /app/files/js/jquery.js 的请求对中间件来说将表现为 GET /js/jquery。这对于前缀功能来说效果很好,因为 /app/files 不会成为文件解析的一部分:

app.use('/app/files', connect.static('public'));

原始的 GET /foo.js 请求将不再有效,因为除非存在挂载点,否则中间件不会被调用,但前缀版本 GET /app/files/foo.js 将会传输文件:

$ curl http://localhost/foo.js
Cannot get /foo.js
$ curl http://localhost/app/files/foo.js
console.log('tobi');
绝对路径与相对路径目录

请记住,传递给 serve-static 的路径相对于当前工作目录。将 "public" 作为路径传递将本质上解析为 process.cwd() + "public"

然而,有时你可能想在指定基本目录时使用绝对路径,__dirname 变量有助于实现这一点:

app.use('/app/files', connect.static(__dirname + '/public'));
当请求目录时提供服务 index.html

serve-static 的另一个有用功能是它能够提供服务 index.html 文件。当一个目录请求被发起,并且该目录中存在 index.html 文件时,它将被提供服务。

为网页应用程序资源提供服务静态文件是有用的,例如 CSS、JavaScript 和图片。但如果你想允许人们从目录列表中下载任意文件列表怎么办?这就是 serve-index 的用武之地。

C.4.2. serve-index:生成目录列表

serve-index 模块 (www.npmjs.com/package/serve-index) 是一个小型目录列表组件,它为用户提供了一种浏览远程文件的方式。图 C.6 展示了该组件提供的界面,包括搜索输入字段、文件图标和可点击的面包屑。

图 C.6. 使用 Connect 的 directory() 中间件组件提供目录列表

图 C.6

基本用法

此组件旨在与 serve-static 一起工作,它将执行文件提供;serve-index 简单地提供列表。设置可以像以下代码片段那样简单,其中请求 GET / 提供了 ./public 目录:

const connect = require('connect');
const serveStatic = require('serve-static');
const serveIndex = require('serve-index');

connect()
  .use(serveIndex('public'))
  .use(serveStatic('public'))
  .listen(3000);
使用 directory() 进行挂载

通过使用中间件挂载,你可以将服务器静态和 serve-index 模块前缀添加到任何你喜欢的路径,例如以下示例中的 GET /files。在这里,icons 选项用于启用图标,hidden 选项对两个组件都启用,以便查看和提供隐藏文件:

connect()
  .use('/files', serveIndex('public', { icons: true, hidden: true }))
  .use('/files', serveStatic('public', { hidden: true }))
  .listen(3000);

现在可以轻松地浏览文件和目录。

posted @ 2025-11-17 09:51  绝不原创的飞龙  阅读(43)  评论(0)    收藏  举报