File failed to load: /extensions/MathZoom.js

NodeJS-入门指南-全-

NodeJS 入门指南(全)

原文:Beginning Node.js

协议:CC BY-NC-SA 4.0

零、简介

毫无疑问,个人计算已经彻底改变了我们今天的生活和工作方式。网络进一步革新了我们使用应用的方式。当它被首次引入时,互联网被设计成以文档的形式呈现信息。后来,JavaScript 被加入进来,这是我们今天在网络上看到的创新的关键成分。Web 应用独立于平台,无缝更新,默认安全,随时随地可用。毫无疑问,如果不了解 Web 是如何工作的,那么很难开始开发人员的角色。

由于 web 的重要性以及 JavaScript 在 Web 开发中扮演的关键角色,您可以在一些开源 JavaScript 项目中找到大多数技术问题的解决方案。Node.js 允许您在服务器上使用所有这些创新的 JavaScript 项目,就像在客户端浏览器上一样。在服务器上使用 JavaScript 还可以减少在您更改编程语言和相关代码约定时需要在大脑中进行的上下文切换。这就是为什么你应该使用 Node.js 的情感方面。

这本书旨在温和地介绍 Node.js 和 JavaScript。除了基本的编程课程之外,没有软件开发经验。由于我们清楚地展示了创建 Node.js 背后的技术原因,如果您已经习惯于在另一种环境(如 C#或 Java)中编程,并且对围绕 Node.js 的所有大惊小怪感到好奇,那么这本书也很棒。这本书涵盖了 Node.js 软件开发的所有主要领域,从设置到部署,因此当您完成这本书时,您应该能够立即开始使用 Node.js,并准备好与世界共享您的项目。

一、设置 Node.js 开发环境

在本章中,我们将指导您在各种平台上安装 Node.js,并讨论如何设置 Node.js 开发环境。然后,我们将带您浏览 Node.js REPL(读取-评估-打印-循环),并向您展示如何运行 Node.js 应用。最后,我们提供了集成开发环境(IDEs) 的例子,可以帮助您更快地交付应用,让您的旅程更加愉快。

安装 Node.js

开发 Node.js 应用不再需要从源代码构建 Node.js。Node.js 现在提供了 Windows 和 Mac OS X 的安装程序,它可以像这些平台上的任何其他应用一样安装(图 1-1 )。你可以从http://nodejs.org/download/下载 Node.js 安装程序。

9781484201886_Fig01-01.jpg

图 1-1 。列出安装程序的 Node.js 下载页面

在下一节中,我们将指导您完成操作系统(OS)的重要步骤。您可以安全地跳过与您当前操作系统无关的部分。

在 Windows 上安装

Node.js 的网站列出了“Windows 二进制(.exe)”和“Windows Installer(。msi)。”你不希望使用 windows 二进制(.exe)进行开发,因为它不包含重要的东西,如 Node 包管理器(NPM) ,我们将在第四章中介绍。Node.js 为 32 位和 64 位 Windows 提供了单独的安装程序(.msi)。我们建议您根据您的平台安装。您可以像在 Windows 上启动任何其他安装程序一样启动安装程序(图 1-2 )。

9781484201886_Fig01-02.jpg

图 1-2 。Windows 上的 Node.js 安装向导

首次启动时,我们建议您使用默认选项安装到默认目录。尤其重要的是你要让安装程序添加到路径 ( 图 1-3 )

9781484201886_Fig01-03.jpg

图 1-3 。Windows 上 Node.js 安装程序的默认选项

安装后,卸载和重新安装 Node.js 极其容易。如果再次运行安装程序,会提示移除选项,如图图 1-4 所示。

9781484201886_Fig01-04.jpg

图 1-4 。Windows 的 Node.js 卸载程序

由于安装程序设置了系统路径,所以可以从命令提示符运行 Node.js(在 Windows 开始菜单中搜索“命令提示符”)。我们只需在 cmd ( 图 1-5 )中输入node就可以启动 Node.js。这将使您进入 REPL,我们将在下一节对此进行解释。

9781484201886_Fig01-05.jpg

图 1-5 。从命令行运行 Node.js

在 Mac OS X 上安装

http://nodejs.org/download/下载 Node.js 团队提供的 Mac OS X 安装程序。安装程序是一个可以从 Finder 启动的.pkg文件(图 1-6 )。

9781484201886_Fig01-06.jpg

图 1-6 。Mac OS X 的 Node.js 安装程序

开始时,坚持默认设置并为所有用户安装(图 1-7 )。

9781484201886_Fig01-07.jpg

图 1-7 。Node.js 为所有用户设置选项

一旦完成,安装程序会通知你它安装了两个二进制文件(nodenpm),如图图 1-8 所示。

9781484201886_Fig01-08.jpg

图 1-8 。已安装 Node.js 二进制文件

我们将在第四章的中详细介绍npm。你在 Node.js 中运行 JavaScript 的主可执行文件是node ( 图 1-9 )。对于 Mac OS X,您可以从终端启动node(使用 Mac OS X spotlight 搜索终端)。如果您在终端中执行node,它将启动 Node.js REPL,我们接下来将讨论它。

9781484201886_Fig01-09.jpg

图 1-9 。在 Mac OS X 上从命令行运行 Node.js

使用 REPL

Node.js 为您提供了一个 REPL(read-evaluate-print-loop),这样您就可以测试任意的 JavaScript,并试验和探索您试图解决的问题的解决方案。当您不带任何命令行参数运行node时,它会将您置于 REPL。要查看您可用的选项,如图 1-10 中的所示,键入.help并按回车键。

9781484201886_Fig01-10.jpg

图 1-10 。Node.js REPL 帮助

你可以在 REPL 中执行任意的 JavaScript 并立即看到它的结果,如图图 1-11 所示。

9781484201886_Fig01-11.jpg

图 1-11 。在 Node.js REPL 中执行 JavaScript

在每一步,REPL 打印最后执行的语句的结果。REPL 不会执行您的输入代码,直到所有的括号都被平衡。要执行多行,只需用括号将它们括起来。REPL 使用(…)来表示它在执行之前正在等待完整的代码。只需关闭括号并按下回车键,REPL 就可以计算输入的 JavaScript(见图 1-12 )。从块内部退出(...)无需执行您已经输入的内容,只需键入.break或按 Ctrl+C。

9781484201886_Fig01-12.jpg

图 1-12 。在 Node.js REPL 中执行多行

当您想要测试一些 JavaScript 并确保它按照您想要的方式运行时,REPL 非常有用。您可以通过键入.exit(或按 Ctrl+D)退出 REPL。

执行 Node.js 脚本

我们已经看到了如何通过在 REPL 中键入 JavaScript 来执行它。然而,你最常见的是编写 Node.js 程序(脚本文件)并使用 Node.js 执行它们。你可以在 Node.js 中执行一个 JavaScript 源文件,只需在命令行上将文件传递给node(图 1-13)。创建一个名为helloworld.js的新文件,包含一个简单的console.log,如清单 1-1 所示。

9781484201886_Fig01-13.jpg

图 1-13 。在 Node.js 中执行脚本文件

清单 1-1 。helloworld.js

console.log("hello world!");

然后从保存文件的同一个目录中通过在命令行上运行node helloworld.js来运行文件(在我们的例子中是C:\)。

注意,我们使用console.log的方式和我们做前端 web 开发时使用的方式一样。Node.js 的一个理念是,对于前端开发者来说应该是直观的。Node.js 团队试图在任何有意义的时候保持 API 与浏览器的一致性。

Node.js 只是像浏览器一样从上到下执行输入的 JavaScript。然而,通常将应用的主文件命名为app.js,这样人们就知道为了运行应用应该执行哪个文件。

建立集成开发环境

Node.js 非常棒,因为只需一个文本编辑器和终端就能轻松上手。(这并不意味着没有更全功能的开发环境。)Node.js 从 JetBrains(IntelliJ Idea、RubyMine 和 PyCharm 的创造者)的 WebStorm 以及微软的 Visual Studio 中获得了巨大的支持。WebStorm 可以在 Windows、Mac OS X 和 Linux 上使用,而 Visual Studio 只能在 Windows 上使用。

WebStorm Node.js 支持

WebStorm 自称是“最智能的 JavaScript IDE”。它基于 IntelliJ IDEA 平台,如果您是 Java、Ruby 或 Python 背景的人,可能很容易迁移到这个平台。从http://www.jetbrains.com/webstorm/就可以得到。

WebStorm 使用“项目”的概念工作当您启动 WebStorm 时,您会看到一个选项,创建一个新项目。对于这个例子,我们将创建一个简单的空项目(图 1-14 )。

9781484201886_Fig01-14.jpg

图 1-14 。在 WebStorm 中新建一个项目

打开项目窗口后,右键单击项目名称(如图图 1-15 所示)。添加一个新的 JavaScript 文件,并将该文件命名为“main”(也如图 1-15 所示)。

9781484201886_Fig01-15.jpg

图 1-15 。给 WebStorm 项目添加一个新文件

清除文件的内容并简单地放入一个console.log,如清单 1-2 所示。

清单 1-2

console.log("Hello WebStorm!");

因为我们已经安装了 Node.js,所以 WebStorm 足够聪明来解决这个问题。所以,如果你右击文件内的任何地方,WebStorm 会显示选项运行‘main . js’(图 1-16 )。

9781484201886_Fig01-16.jpg

图 1-16 。从 WebStorm 运行 Node.js 中的脚本文件

如果选择该选项,WebStorm 会启动 Node.js,将该文件作为参数传入,并显示输出,如图图 1-17 所示。

9781484201886_Fig01-17.jpg

图 1-17 。脚本执行导致网络风暴

当您要求 WebStorm 运行该文件时,它实际上创建了一个运行配置。您可以查看该运行配置,并通过使用运行image编辑配置对其进行进一步定制,如图图 1-18 所示。

9781484201886_Fig01-18.jpg

图 1-18 。在 WebStorm 中编辑运行配置

这将打开配置编辑器对话框,如图图 1-19 所示。您可以看到为您创建的配置,并根据需要进行编辑。

9781484201886_Fig01-19.jpg

图 1-19 。WebStorm 中的 Node.js 配置选项

WebStorm 比我们在这里展示的容量更大,我们在这里展示的目的是让您快速入门。WebStorm 与 Node.js 内置调试器有很好的集成,将在第十一章中探讨。

Visual Studio Node.js 支持

如果你来自一个.NET背景,你可能会很高兴听到 Visual Studio 有一流的 Node.js 支持。这种支持以“Node.js Tools for Visual Studio”的形式提供,适用于微软的 Visual Studio 2012 和 Visual Studio 2013。你可以从https://nodejstools.codeplex.com下载这些工具。安装这些工具再简单不过了。只需启动下载的.msi安装程序,点击完成。

现在,当您启动 Visual Studio 并创建一个新项目时,您会看到一个新的语言选项, JavaScript 。选择它并创建一个Blank Node.js Console App,指定其名称和位置,如图 1-20 中的所示。

9781484201886_Fig01-20.jpg

图 1-20 。创建一个新 NodeT3。使用 Visual Studio 的 js 项目

应用创建完成后,Visual Studio 打开 app.js ,如图图 1-21 所示。

9781484201886_Fig01-21.jpg

图 1-21 。使用 Visual Studio 创建的 Node.js 应用

此时不要担心 package.jsonnpm 。这些选项将在第四章中解释。现在让我们从 Visual Studio 运行这个简单的控制台应用。点击编辑器侧边栏添加调试断点,如图图 1-22 所示。

9781484201886_Fig01-22.jpg

图 1-22 。在 Visual Studio 中向文件添加调试断点

要在调试模式下运行该应用,请按 F5,Visual Studio 会将 app.js 传递给 Node.js,并在断点处暂停,如图 1-23 所示。Visual Studio 使用 Node.js 内置的 V8 调试器,我们将在第十一章中讨论。

9781484201886_Fig01-23.jpg

图 1-23 。在 Visual Studio 中激活断点 ??

Visual Studio 中的所有常见调试工具,如调用堆栈、局部变量和 watch,都可以很好地与 Node.js 一起工作。您甚至可以在 Node.js 的“内部”看到源代码。例如,图 1-24 中的调用堆栈中显示的 module.js 是 Node.js 的一部分,而不是我们的应用。

9781484201886_Fig01-24.jpg

图 1-24 。Visual Studio 显示局部变量和调用堆栈

按 F5 继续。然后将“Hello world”打印到控制台并退出(图 1-25 )。

9781484201886_Fig01-25.jpg

图 1-25 。从 Visual Studio 执行的 Node.js 应用

使用 Visual Studio 时需要注意的最后一件事是属性窗格。您可以在解决方案资源管理器中右键单击项目,选择属性,修改 Visual Studio 与node.exe的交互方式,如图图 1-26 所示。

9781484201886_Fig01-26.jpg

图 1-26 。Visual Studio 中的 Node.js 配置选项

摘要

Node.js 从一开始就获得了极好的社区支持。感谢安装程序,您不再需要从源代码编译 Node.js 来在您喜欢的平台上创建 Node.js 应用。在设置了 Node.js 之后,我们展示了一些 ide 示例,它们可以使使用 Node.js 变得更加容易,从而使您能够快速启动并运行。

在下一章中,我们将讨论为了成功使用 Node.js 你需要理解的重要 JavaScript 概念。

二、了解 Node.js

要理解 Node.js 是如何工作的,首先需要理解 JavaScript 的一些关键特性,这些特性使它非常适合服务器端开发。JavaScript 是一种简单的语言,但它也非常灵活。这种灵活性是它经受住时间考验的原因。一流的函数和闭包使它成为 web 应用的理想语言。

JavaScript 有一个不可靠的坏名声。然而,这种想法与事实相去甚远。实际上,JavaScript 的坏名声来自于 DOM 的不可靠性。DOM(文档对象模型)是浏览器供应商提供的使用 JavaScript 与浏览器交互的 API(应用编程接口)。不同浏览器厂商的 DOM 各不相同。然而,JavaScript 这种语言是定义良好的,可以跨浏览器和 Node.js 可靠地使用。在本章中,我们将讨论 JavaScript 的一些基础知识,然后讨论 Node.js 如何使用 JavaScript 为 web 应用提供高性能的平台。其他人抱怨 JavaScript 如何处理编程错误(它试图让无效代码工作)。然而,在这种情况下,开发人员确实应该受到责备,因为他们在使用高度动态的语言时需要小心。

变量

变量是在 JavaScript 中使用关键字var定义的。例如,下面的代码段创建了一个变量foo,并将其记录到控制台。(参见清单 2-1 。)正如您在上一章中看到的,您将使用node variable.js从您的控制台(Mac OS X 上的终端和 Windows 上的 cmd)运行这段代码。

清单 2-1 。变量. js

var foo = 123;
console.log(foo); // 123

JavaScript 运行时(浏览器或 Node.js)有机会定义一些我们可以在代码中使用的全局变量。其中之一是console对象,到目前为止我们一直在使用它。console对象包含一个成员函数(log),它接受任意数量的参数并将它们打印到控制台。我们将在使用全局对象时讨论更多的全局对象。正如您将看到的,JavaScript 包含了您期望一个好的编程语言拥有的大部分东西。

数字

JavaScript 中的所有数字都有相同的浮点数类型。算术运算(+、-、*、/、%)如您所料对数字起作用,如清单 2-2 所示。

清单 2-2 。 numbers.js

var foo = 3;
var bar = 5;
console.log(foo + 1); // 4
console.log(foo / bar); // 0.6
console.log(foo * bar); // 15
console.log(foo - bar); // -2;
console.log(foo % 2); // remainder: 1

布尔值

为布尔值定义了两个字面值:truefalse。您可以将这些赋值给变量,并按预期对它们应用布尔运算。(参见清单 2-3 。)

清单 2-3 。布尔型. js

var foo = true;
console.log(foo); // true

// Boolean operations (&&, ||, !) work as expected:
console.log(true && true); // true
console.log(true && false); // false
console.log(true || false); // true
console.log(false || false); // false
console.log(!true); // false
console.log(!false); // true

数组

您可以使用[]在 JavaScript 中非常容易地创建数组。数组有许多有用的功能,其中一些在清单 2-4 中显示。

清单 2-4 。 arrays.js

var foo = [];

foo.push(1); // add at the end
console.log(foo); // prints [1]

foo.unshift(2); // add to the top
console.log(foo); // prints [2,1]

// Arrays are zero index based:
console.log(foo[0]); // prints 2

对象文字

通过解释这几个基本类型,我们已经向您介绍了对象文字。在 JavaScript 中创建对象最常见的方式是使用对象符号{}。对象可以在运行时任意扩展。清单 2-5 给出了一个例子。

清单 2-5 。 objectLiterals1.js

var foo = {};
console.log(foo); // {}
foo.bar = 123; // extend foo
console.log(foo); // { bar: 123 }

不用在运行时扩展它,你可以通过使用清单 2-6 中的对象文字符号来定义对象的属性

清单 2-6 。 objectLiterals2.js

var foo = {
    bar: 123
};
console.log(foo); // { bar: 123 }

您还可以在对象文字中嵌套对象文字,如清单 2-7 所示。

清单 2-7 。 objectLiterals3.js

var foo = {
    bar: 123,
    bas: {
        bas1: 'some string',
        bas2: 345
    }
};
console.log(foo);

当然,你也可以在对象文字中包含数组,如清单 2-8 所示。

清单 2-8 。 objectLiterals4.js

var foo = {
    bar: 123,
    bas: [1, 2, 3]
};
console.log(foo);

而且,你也可以让这些数组本身包含对象文字,正如你在清单 2-9 中看到的。

清单 2-9 。 objectLiterals5.js

var foo = {
    bar: 123,
    bas: [{
        qux: 1
    },
    {
        qux: 2
    },
    {
        qux: 3
    }]
};
console.log(foo.bar); // 123
console.log(foo.bas[0].qux); // 1
console.log(foo.bas[2].qux); // 2

对象文字作为函数参数和返回值非常方便。

功能

JavaScript 中的函数非常强大。JavaScript 的大部分能力来自于它处理函数类型的方式。我们将在后面更复杂的例子中研究 JavaScript 中的函数。

职能 101

JavaScript 中的一个普通函数结构在清单 2-10 中定义。

清单 2-10 。。职能机构。射流研究…

function functionName() {
    // function body
    // optional return;
}

JavaScript 中的所有函数都返回值。在没有显式 return 语句的情况下,函数返回undefined。当您执行清单 2-11 中的代码时,您会在控制台上看到undefined

清单 2-11 。 functionReturn.js

function foo() { return 123; }
console.log(foo()); // 123

function bar() { }
console.log(bar()); // undefined

我们将在本章讨论默认值时更多地讨论undefined函数。

立即执行功能

定义一个函数后,您可以立即执行它。简单地将函数放在括号()中并调用它,如清单 2-12 所示。

清单 2-12 。 ief1.js

(function foo() {
    console.log('foo was executed!');
})();

使用立即执行函数的原因是为了创建一个新的变量范围。在 JavaScript 中,ifelsewhile不会创建新的变量范围。这个事实在清单 2-13 中得到了证明。

清单 2-13 。 ief2.js

var foo = 123;
if (true) {
    var foo = 456;
}
console.log(foo); // 456;

在 JavaScript 中创建新变量作用域的唯一推荐方法是使用函数。因此,为了创建一个新的变量范围,我们可以使用一个立即执行的函数,如清单 2-14 所示。

清单 2-14 。 ief3.js

var foo = 123;
if (true) {
    (function () { // create a new scope
        var foo = 456;
    })();
}
console.log(foo); // 123;

注意,我们选择避免不必要的函数命名。这被称为一个匿名函数,我们将在下面解释。

匿名函数

没有名字的函数叫做匿名函数。在 JavaScript 中,您可以将函数赋给变量。如果你打算用一个函数作为变量,你不需要给函数命名。清单 2-15 展示了两种内联定义函数的方法。这两种方法是等效的。

清单 2-15 。无名氏

var foo1 = function namedFunction() { // no use of name, just wasted characters
    console.log('foo1');
}
foo1(); // foo1

var foo2 = function () { // no function name given i.e. anonymous function
    console.log('foo2');
}
foo2(); // foo2

如果一个函数可以像该语言中的任何其他变量一样被处理,则称该语言具有一级函数。JavaScript 有一流的功能。

高阶函数

因为 JavaScript 允许我们将函数赋给变量,所以我们可以将函数传递给其他函数。以函数为自变量的函数称为高阶函数。高阶函数的一个非常常见的例子是setTimeout。这显示在清单 2-16 中。

清单 2-16 。 higherOrder1.js

setTimeout(function () {
    console.log('2000 milliseconds have passed since this demo started');
}, 2000);

如果您在 Node.js 中运行这个应用,您将在两秒钟后看到console.log消息,然后应用将退出。注意,我们提供了一个匿名函数作为setTimeout的第一个参数。这使得setTimeout成为一个高阶函数。

值得一提的是,没有什么可以阻止我们创建一个函数并将其传入。清单 2-17 中显示了一个例子。

清单 2-17 。 higherOrder2.js

function foo() {
    console.log('2000 milliseconds have passed since this demo started');
}
setTimeout(foo, 2000);

既然我们已经对对象文字和函数有了明确的理解,我们就可以研究闭包的概念了。

关闭

每当我们在一个函数中定义了另一个函数时,内部函数就可以访问外部函数中声明的变量。闭包最好用例子来解释。

在清单 2-18 中,你可以看到内部函数从外部作用域访问变量(variableInOuterFunction)。外部函数中的变量已被内部函数封闭(或绑定)。因此有了关闭 ?? 的说法。这个概念本身足够简单,也相当直观。

清单 2-18 。 closure1.js

function outerFunction(arg) {
    var variableInOuterFunction = arg;

    function bar() {
        console.log(variableInOuterFunction); // Access a variable from the outer scope
    }

    // Call the local function to demonstrate that it has access to arg
    bar();
}

outerFunction('hello closure!'); // logs hello closure!

现在最棒的部分是:内部函数可以从外部作用域访问变量,即使外部函数已经返回了。这是因为变量仍然绑定在内部函数中,不依赖于外部函数。清单 2-19 。显示了一个示例。

清单 2-19 。 closure2.js

function outerFunction(arg) {
    var variableInOuterFunction = arg;
    return function () {
        console.log(variableInOuterFunction);
    }
}

var innerFunction = outerFunction('hello closure!');

// Note the outerFunction has returned
innerFunction(); // logs hello closure!

现在我们已经了解了一流的函数和闭包,我们可以看看是什么让 JavaScript 成为服务器端编程的伟大语言。

了解 Node.js 性能

Node.js 专注于创建高性能的应用。在下一节中,我们将介绍 I/O 伸缩问题。然后我们展示传统上是如何解决的,接着是 Node.js 是如何解决的。

I/O 扩展问题

Node.js 致力于成为编写高性能 web 应用的最佳方式。为了理解它是如何实现的,我们需要了解 I/O 伸缩问题。让我们根据 CPU 周期来粗略估计一下从不同来源访问数据的速度(图 2-1 )。

9781484201886_Fig02-01.jpg

图 2-1 。比较常见的 I/O 源

您可以清楚地看到,磁盘和网络访问与访问 RAM 和 CPU 缓存中的数据完全不同。

大多数 web 应用依赖于从磁盘或其他网络来源(例如,数据库查询)读取数据。当收到一个 HTTP 请求,我们需要从数据库加载数据时,通常这个请求会等待磁盘读取或网络访问调用完成。

这些打开的连接和挂起的请求会消耗服务器资源(内存和 CPU)。为了使用同一个 web 服务器处理来自不同客户端的大量请求,我们遇到了 I/O 伸缩问题。

传统的 Web 服务器对每个请求使用一个进程

传统的服务器过去常常启动一个新的进程来处理每一个 web 请求。为每个请求旋转一个新的进程是一项开销很大的操作,在 CPU 和内存方面都是如此。这是 PHP 等技术最初创建时的工作方式。

这一概念的演示如图 2-2 所示。为了成功回复 HTTP 请求“A”,我们需要来自数据库的一些数据。这种读取可能需要很长时间。在整个读取期间,我们将有一个进程在空闲和等待数据库响应时占用 CPU 和内存。此外,进程启动缓慢,并且在 RAM 空间方面有很大的开销。这不能长期扩展,这就是为什么现代 web 应用使用线程池的原因。

9781484201886_Fig02-02.jpg

图 2-2 。使用进程的传统 web 服务器

使用线程池的传统 Web 服务器

现代服务器使用线程池中的一个线程来服务每个请求。因为我们已经创建了一些操作系统(OS)线程(因此有一个线程池),所以我们不需要为启动和停止 OS 进程付出代价(创建 OS 进程的成本很高,并且占用的内存比线程多得多)。当请求进来时,我们分配一个线程来处理这个请求。在处理请求的整个过程中,该线程都是为请求保留的,如图 2-3 所示。

9781484201886_Fig02-03.jpg

图 2-3 。使用线程池的传统 web 服务器

因为我们节省了每次创建新进程的开销,而且线程比进程轻,所以这种方法比原来的服务器设计好得多。几年前,大多数 web 服务器都使用这种方法,并且今天仍在使用。然而,这种方法并非没有缺点。同样,线程之间存在内存浪费。此外,操作系统需要在线程之间进行上下文切换(即使当它们空闲时),这导致了 CPU 资源的浪费。

Nginx 方式

我们已经看到,创建单独的进程和单独的线程来处理请求会导致操作系统资源的浪费。Node.js 的工作方式是只有一个线程处理请求。对于 Node.js 来说,单线程服务器的性能优于线程池服务器的想法并不新鲜。

Nginx 是一个单线程 web 服务器,可以处理大量并发请求。一个比较 Nginx 和 Apache 的简单基准,两者都服务于文件系统中的一个静态文件,如图 2-4 所示。

9781484201886_Fig02-04.jpg

图 2-4 。Nginx 与 Apache 每秒请求数和并发打开连接数的比较

如您所见,当并发连接数增加时,Nginx 每秒可以处理比 Apache 多得多的请求。更有趣的是内存消耗,如图图 2-5 。

9781484201886_Fig02-05.jpg

图 2-5 。Nginx 与 Apache 内存使用量和并发连接数

随着并发连接越来越多,Apache 需要管理更多的线程,因此消耗更多的内存,而 Nginx 保持在一个稳定的水平。

Node.js 性能秘密

JavaScript 中只有一个执行线程。这是网络浏览器传统的工作方式。如果您有一个长时间运行的操作(例如等待计时器完成或数据库查询返回),您必须使用回调继续操作。清单 2-20 。提供了一个简单的演示程序,它使用 JavaScript runtime setTimeout函数来模拟一个长时间运行的操作。您可以使用 Node.js 运行这段代码。

清单 2-20 。 simulateUserClick.js

function longRunningOperation(callback) {
    // simulate a 3 second operation
    setTimeout(callback, 3000);
}

function userClicked() {
    console.log('starting a long operation');
    longRunningOperation(function () {
        console.log('ending a long operation');
    });
}
// simulate a user action
userClicked();

这种模拟在 JavaScript 中是可能的,因为我们有一级函数和传递函数——回调是该语言中一种受良好支持的模式。当您将一流的函数与闭包的概念结合起来时,事情就变得有趣了。假设我们正在处理一个 web 请求,并且我们有一个长时间运行的操作,比如我们需要做的数据库查询。清单 2-21 中显示了一个模拟版本。

清单 2-21 。 simulateWebRequest.js

function longRunningOperation(callback) {
    // simulate a 3 second operation
    setTimeout(callback, 3000);
}

function webRequest(request) {
    console.log('starting a long operation for request:', request.id);
    longRunningOperation(function () {
        console.log('ending a long operation for request:', request.id);
    });
}
// simulate a web request
webRequest({ id: 1 });
// simulate a second web request
webRequest({ id: 2 });

在清单 2-21 中,由于闭包,我们可以在长时间运行的操作完成后访问正确的用户请求。我们刚刚毫不费力地在单线程上处理了两个请求。现在你应该明白下面这句话了:“Node.js 是高性能的,它使用 JavaScript 是因为 JavaScript 支持一流的函数和闭包。”

当有人告诉你你只有一个单线程来处理请求时,你应该立即想到的问题是,“但是我的计算机有一个四核 CPU。只使用单线程肯定会浪费资源。”答案是肯定的。然而,有一个很好的解决方法,我们将在第十三章讨论部署和可伸缩性时研究它。简单提示一下您将会看到什么:使用 Node.js,通过为每个 CPU 内核使用单独的 JavaScript 进程来使用所有 CPU 内核实际上非常简单。

还需要注意的是,Node.js 在 C 层管理一些线程(比如某些文件系统操作),但是所有的 JavaScript 都在一个线程中执行。这为您提供了 JavaScript 几乎完全拥有至少一个线程的性能优势。

更多 Node.js 内部信息

理解 Node.js 的内部工作原理并不十分重要,但是当您与同行讨论 Node.js 时,更多的讨论会让您更加了解这些术语。Node.js 的核心是一个事件循环

事件循环使任何 GUI 应用都能在任何操作系统上工作。当有事情发生时(例如,用户点击一个按钮),操作系统调用应用中的一个函数,然后应用执行这个函数中包含的逻辑直到完成。之后,您的应用准备好响应可能已经到达(并且在队列中)或者可能稍后到达(基于用户交互)的新事件。

线程饥饿

通常,在 GUI 应用中从一个事件调用一个函数的过程中,不会处理其他事件。因此,如果您在点击处理程序中执行长时间运行的任务,GUI 将变得没有响应。这是我遇到的每个电脑用户都曾经历过的事情。这种 CPU 资源可用性的缺乏被称为饥饿

Node.js 构建在与 GUI 程序相同的事件循环原则上。因此,它也会挨饿。为了更好地理解它,我们来看几个代码示例。清单 2-22 。展示了一小段使用console.timeconsole.timeEnd函数测量时间的代码。

清单 2-22 。 timeit.js

console.time('timer');
setTimeout(function(){
   console.timeEnd('timer');
},1000)

如果您运行这段代码,您应该会看到一个非常接近您预期的数字—换句话说,1000 毫秒。这个超时回调是从 Node.js 事件循环中调用的。

现在让我们写一些需要很长时间来执行的代码,例如,计算第 n 个斐波那契数的非优化方法,如清单 2-23 所示。

清单 2-23 。大型操作. js

console.time('timeit');
function fibonacci(n) {
    if (n < 2)
        return 1;
    elses
        return fibonacci(n - 2) + fibonacci(n - 1);
}
fibonacci(44); // modify this number based on your system performance
console.timeEnd('timeit'); // On my system it takes about 9000ms (i.e. 9 seconds)

现在我们有了一个可以从 Node.js 事件循环中引发的事件(set Timeout)和一个可以让 JavaScript 线程保持忙碌的函数(fibonacci)。我们现在可以在 Node.js 中演示饥饿。但是在这个超时完成之前,我们执行了一个占用大量 CPU 时间的函数,因此占用了 CPU 和 JavaScript 线程。由于这个函数被 JavaScript 线程占用,事件循环不能调用其他任何东西,因此超时被延迟,如清单 2-24 所示。

清单 2-24 。 starveit.js

// utility funcion
function fibonacci(n) {
    if (n < 2)
        return 1;
    else
        return fibonacci(n - 2) + fibonacci(n - 1);
}

// setup the timer
console.time('timer');
setTimeout(function () {
    console.timeEnd('timer'); // Prints much more than 1000ms
}, 1000)

// Start the long running operation
fibonacci(44);

这里的一个教训是,如果您有一个高 CPU 任务,需要在多客户机服务器环境中对客户机请求执行,Node.js 不是最佳选择。然而,如果是这样的话,你将很难在任何平台上找到一个可扩展的软件解决方案。大多数高 CPU 任务应该脱机执行,通常使用物化视图、map reduce 等将任务卸载到数据库服务器。大多数 web 应用通过网络访问这些计算的结果,这就是 Node.js 的亮点——事件网络 I/O。

现在,您已经理解了事件循环的含义以及 Node.js 的 JavaScript 部分是单线程的这一事实的含义,让我们再来看看为什么 Node.js 对 I/O 应用非常有用。

数据密集型应用

Node.js 非常适合数据密集型应用。正如我们所看到的,使用单线程意味着 Node.js 在用作 web 服务器时占用的内存非常少,并且可以处理更多的请求。考虑一个数据密集型应用的简单场景,该应用通过 HTTP 从数据库向客户端提供数据集。我们知道,与执行代码和/或从 RAM 中读取数据相比,收集响应客户端查询所需的数据需要很长时间。图 2-6 显示了一个传统的带有线程池的 web 服务器在响应两个请求时的样子。

9781484201886_Fig02-06.jpg

图 2-6 。传统服务器如何处理两个请求

Node.js 中相同的服务器如图图 2-7 所示。所有的工作都将在一个线程中进行,这导致了更少的内存消耗,并且由于缺少线程上下文切换,CPU 负载也更少。就实现而言,handleClientRequest是一个调用数据库的简单函数(使用回调)。当回调返回时,它使用用 JavaScript 闭包捕获的请求对象来完成请求。清单 2-25 中的伪代码显示了这一点。

9781484201886_Fig02-07.jpg

图 2-7 。Node.js 服务器如何处理两个请求

清单 2-25 。 handleClientRequest.js

function handleClientRequest(request) {
    makeDbCall(request.someInfo, function (result) {
        // The request corresponds to the correct db result because of closure
        request.complete(result);
    });
}

注意,对数据库的 HTTP 请求也是由事件循环管理的。拥有异步 IO 的优势以及 JavaScript + Node.js 非常适合数据密集型应用的原因现在应该很清楚了。

V8 JavaScript 引擎

值得一提的是,Node.js 内部的所有 JavaScript 都是由 V8 JavaScript 引擎执行的。V8 伴随着谷歌 Chrome 项目应运而生。V8 是 Chrome 的一部分,当你访问一个网页时,它运行 JavaScript。

任何做过网络开发的人都知道谷歌 Chrome 对网络来说有多神奇。浏览器使用统计非常清楚地反映了这一点。据 w3schools.org 称,访问他们网站的近 56%的互联网用户现在都在使用谷歌浏览器。这有很多原因,但 V8 和它的速度是一个非常重要的因素。除了速度之外,使用 V8 的另一个原因是谷歌的工程师使它易于集成到其他项目中,并且它是独立于平台的。

更多 JavaScript

现在我们已经理解了使用 Node.js 的动机,让我们更深入地研究 JavaScript,这样我们就可以编写可维护的应用。如果想成为 Node.js 开发人员,除了需要擅长 JavaScript 之外,擅长 JavaScript 的另一个原因是利用围绕 Node.js 和 JavaScript 的蓬勃发展的生态系统。GitHub 上项目数量最多的语言是 JavaScript。Node.js 是 GitHub 上最受欢迎的服务器端技术,如图 2-8 所示,也是第三大受欢迎的存储库。

9781484201886_Fig02-08.jpg

图 2-8 。GitHub 上最流行的存储库

一切都是参考

JavaScript 被设计得很简单,并且只需要有限的计算机资源。每当我们将一个变量赋给另一个变量时,JavaScript 都会复制对该变量的引用。要理解这意味着什么,请看一下清单 2-26 。

清单 2-26 。 reference1.js

var foo = { bas: 123 };
var bar = foo;
bar.bas = 456;
console.log(foo.bas); // 456

无论对象的大小如何,在函数调用中传递对象都是非常轻量级的,因为我们只复制对对象的引用,而不是对象的每个属性。要制作数据的真实副本(打破引用链接),你可以创建一个新的对象文字,如清单 2-27 所示。

清单 2-27 。 reference2.js

var foo = { bas: 123 };
var bar = { bas: foo.bas }; // copy

bar.bas = 456; // change copy
console.log(foo.bas); // 123, original is unchanged

我们可以使用相当多的第三方库来复制任意 JavaScript 对象的属性。(这是一个简单的函数,如果我们愿意,我们可以自己编写。)这些库在第四章中有所介绍。

默认值

JavaScript 中任何变量的默认值都是undefined。你可以在清单 2-28 中看到它被注销,你创建了一个变量,但没有给它赋值。

清单 2-28 。 default1.js

var foo;
console.log(foo); // undefined

类似地,变量上不存在的属性返回undefined ( 清单 2-29 )。

清单 2-29 。默认 2.js

var foo = { bar: 123 };
console.log(foo.bar); // 123
console.log(foo.bas); // undefined

精确相等

JavaScript 中需要注意的一点是=====之间的区别。当 JavaScript 试图抵抗编程错误时,==试图在两个变量之间进行类型强制。例如,它将一个字符串转换成一个数字,这样你就可以将它与一个数字进行比较,如清单 2-30 所示。

清单 2-30 。等于 1.js

console.log(5 == '5'); // true
console.log(5 === '5'); // false

然而,它做出的选择并不总是理想的。例如,在清单 2-31 中,第一条语句为假,因为“”和“0”都是字符串,显然不相等。然而,在第二种情况下,' 0 '和空字符串(')都是 false(换句话说,它们的行为类似 false ),因此相对于==是相等的。当你使用===时,这两种说法都是错误的。

清单 2-31 。等于 2.js

console.log('' == '0'); // false
console.log('' == 0); // true

console.log('' === '0'); // false
console.log('' === 0); // false

这里的提示是不要比较不同的类型。比较不同类型的变量(比如一个字符串和一个数字)是你无论如何都无法在静态类型语言中完成的事情(在静态类型语言中,你必须指定变量的类型)。如果你记住了这一点,你就可以放心地使用==。但是,建议您尽可能使用===

== vs. ===类似,还有不等式运算符!=!==,工作方式相同。换句话说!= does 类型强制,而!==是严格的。

null是一个特殊的 JavaScript 对象,用来表示一个空对象。这与undefined不同,后者被 JavaScript 用于不存在和未初始化的值。您不应该为undefined设置任何东西,因为按照惯例,undefined是您应该留给运行时的默认值。使用null的一个好时机是当你想明确地说某样东西不存在的时候,比如作为一个函数参数。你会在本章的错误处理部分看到它的用法。

真与假

JavaScript 中的一个重要概念是真值和假值。真值是那些在布尔运算中表现得像true的值,假值是那些在布尔运算中表现得像false的值。对于null / undefined,使用if / else / !通常比进行显式检查更容易。清单 2-32 显示了这些值的虚假性质的一个例子。

清单 2-32 。 truthyandfalsy.js

console.log(null == undefined); // true
console.log(null === undefined); // false

// Are these all falsy?
if (!false) {
    console.log('falsy');
}
if (!null) {
    console.log('falsy');
}
if (!undefined) {
    console.log('falsy');
}

其他重要的 falsy 值是0和空字符串('')。所有的对象文字和数组在 JavaScript 中都是真的。

显示模块模式

返回对象的函数是创建相似对象的好方法。这里的对象是指打包成一个漂亮的包的数据和功能,这是面向对象编程(OOP)的最基本形式。揭示模块模式的核心是 JavaScript 对闭包的支持和返回任意(函数+数据)对象文字的能力。清单 2-33 是一个简单的例子,展示了如何使用这个模式创建一个对象。

清单 2-33 。 revealingModules.js

function printableMessage() {
    var message = 'hello';
    function setMessage(newMessage) {
        if (!newMessage) throw new Error('cannot set empty message');
        message = newMessage;
    }
    function getMessage() {
        return message;
    }

    function printMessage() {
        console.log(message);
    }

    return {
        setMessage: setMessage,
        getMessage: getMessage,
        printMessage: printMessage
    };
}

// Pattern in use
var awesome1 = printableMessage();
awesome1.printMessage(); // hello

var awesome2 = printableMessage();
awesome2.setMessage('hi');
awesome2.printMessage(); // hi

// Since we get a new object everytime we call the module function
// awesome1 is unaffected by awesome2
awesome1.printMessage(); // hello

这个例子的优点在于它是一个易于理解的简单模式,因为它只使用了闭包、一级函数和对象文字——这些概念您已经很熟悉了,我们在本章开始时已经详细介绍过了。

理解这一点

JavaScript 关键字 this 在语言中有着非常特殊的地位。它是传递给函数的东西,取决于你如何调用它(有点像函数参数)。最简单的理解是,它指的是调用上下文。调用上下文是用于调用函数的前缀。清单 2-34 。演示其基本用法。

清单 2-34 。 this1.js

var foo = {
    bar: 123,
    bas: function () {
        console.log('inside this.bar is:', this.bar);
    }
}

console.log('foo.bar is: ', foo.bar); // foo.bar is: 123
foo.bas(); // inside this.bar is: 123

在函数bas内部,this引用foo,因为basfoo上被调用,因此是调用上下文。那么,如果我调用一个没有任何前缀的函数,调用上下文是什么呢?默认的调用上下文是 Node.js global变量,如清单 2-35 所示。

清单 2-35 。 this2.js

function foo() {
    console.log('is this called from globals? : ', this === global); // true
}
foo();

注意,如果我们在浏览器中运行它,global变量将是window而不是global

当然,由于 JavaScript 对一级函数有很好的支持,我们可以给任何对象附加一个函数并改变调用上下文,如清单 2-36 所示。

清单 2-36 。 this3.js

var foo = {
    bar: 123
};

function bas() {
    if (this === global)
        console.log('called from global');
    if (this === foo)
        console.log('called from foo');
}

// global context
bas(); // called from global

// from foo
foo.bas = bas;
foo.bas(); // called from foo

关于 JavaScript 中的this,你还需要知道最后一件事。如果用 JavaScript 操作符new调用一个函数,它会创建一个新的 JavaScript 对象,函数中的this会引用这个新创建的对象。再次,清单 2-37 提供了另一个简单的例子。

清单 2-37 。 this4.js

function foo() {
    this.foo = 123;
    console.log('Is this global?: ', this == global);
}

// without the new operator
foo(); // Is this global?: true
console.log(global.foo); // 123

// with the new operator
var newFoo = new foo(); // Is this global?: false
console.log(newFoo.foo); // 123

你可以看到我们在函数内部修改了this.foo,并且newFoo.foo被设置为那个值。

理解原型

一个常见的误解是 JavaScript 不是面向对象的。的确,直到最近 JavaScript 还没有关键字class。但是 JavaScript 中的函数比许多其他语言中的函数更强大,可以用来模仿传统的面向对象原则。秘方是new关键字(你已经见过了)和一个叫做prototype?? 的属性。JavaScript 中的每个对象都有一个到另一个对象的内部链接,这个对象叫做原型。在我们研究用 JavaScript 创建传统类之前,让我们更深入地了解一下 prototype。

当您读取一个对象的属性时(例如,foo.barfoo读取属性bar,JavaScript 检查这个属性是否存在于 foo 上。如果没有,JavaScript 检查属性是否存在于foo.__proto__上,依此类推,直到__proto__本身不存在。如果在任何级别找到一个值,则返回该值。否则,JavaScript 返回undefined(参见清单 2-38 )。

清单 2-38 。原型 1 。射流研究…

var foo = {};
foo.__proto__.bar= 123;
console.log(foo.bar); // 123

尽管这是可行的,JavaScript 中的__前缀通常用于用户代码不应该使用的属性(换句话说,私有/内部实现细节)。所以不要直接用__proto__。好消息是,当你在一个函数上使用new操作符创建一个对象时,__proto__ 被设置为该函数的.prototype成员,这可以用一段简单的代码来验证,如清单 2-39 所示。

清单 2-39 。原型 2.js

// Lets create a test function and set a member on its prototype
function foo() { };
foo.prototype.bar = 123;

// Lets create a object using `new`
// foo.prototype will be copied to bas.__proto__
var bas = new foo();

// Verify the prototype has been copied
console.log(bas.__proto__ === foo.prototype); // true
console.log(bas.bar); // 123

这很棒的原因是因为原型在由同一个函数创建的所有对象(让我们称这些实例)之间共享。这个事实显示在清单 2-40 中。

清单 2-40 。原型 3.js

// Lets create a test function and set a member on its prototype
function foo() { };
foo.prototype.bar = 123;

// Lets create two instances
var bas = new foo();
var qux = new foo();

// Show original value
console.log(bas.bar); // 123
console.log(qux.bar); // 123

// Modify prototype
foo.prototype.bar = 456;

// All instances changed
console.log(bas.bar); // 456
console.log(qux.bar); // 456

假设您需要创建 1000 个实例。你放在prototype上的所有功能都是共享的。因此第一课:prototype节省记忆。

Prototype 非常适合从对象中读取数据。但是,如果您在对象上设置了一个属性,您就断开了与原型的链接,因为(如前所述)只有在对象上不存在该属性时,才能访问原型。在一个对象上设置一个属性所导致的与原型属性的断开如清单 2-41 所示。

清单 2-41 。 prototype4.js

// Lets create a test function and set a member on its prototype
function foo() { };
foo.prototype.bar = 123;

// Lets create two instances
var bas = new foo();
var qux = new foo();

// Overwrite the prototype value for bas
bas.bar = 456;
console.log(bas.bar); // 456 i.e. prototype not accessed

// Other objects remain unaffected
console.log(qux.bar); // 123

你可以看到,当我们修改bas.barbas.__proto__.bar 不再被访问。因此,第二个教训:.prototype对你打算写给的资产没有好处。

问题变成了我们应该对需要写入的属性使用什么。回想一下我们对this的讨论,this指的是用new操作符调用函数时创建的对象。所以this是读/写属性的完美候选,您应该将它用于所有属性。但是功能在创建后一般不会改变。因此函数是继续.prototype的绝佳候选。这样,功能(函数/方法)在所有实例之间共享,属性属于单个对象。现在我们可以理解用 JavaScript 写一个类的模式,如清单 2-42 所示。

清单 2-42 。class . js

// Class definition
function someClass() {
    // Properties go here
    this.someProperty = 'some initial value';
}
// Member functions go here:
someClass.prototype.someMemberFunction = function () {
    /* Do something using this */
    this.someProperty = 'modified value';
}

// Creation
var instance = new someClass();

// Usage
console.log(instance.someProperty); // some initial value
instance.someMemberFunction();
console.log(instance.someProperty); // modified value

在成员函数中,我们可以使用this访问当前实例,即使所有实例共享同一个函数体。根据我们之前对this和调用上下文的讨论,原因应该很明显。这是因为我们在某个实例上调用了一个函数,换句话说,instance.someMemberFunction() 。这就是为什么在函数内部this会引用所使用的instance

这里与显示模块模式的主要区别在于,函数在所有实例之间共享,并且不会为每个新实例占用内存。这是因为功能只在.prototype上而不在this上。core Node.js 中的大多数类都是使用这种模式编写的。

错误处理

错误处理是任何应用的重要组成部分。错误可能是由于您的代码或者甚至是不在您的控件中的代码而发生的,例如,数据库失败。

JavaScript 有一个很好的异常处理机制,您可能已经从其他编程语言中熟悉了。要抛出异常,只需使用throw JavaScript 关键字。为了捕捉一个异常,你可以使用catch 关键字。对于无论是否捕获到异常都要运行的代码,可以使用finally 关键字。清单 2-43 。是演示这一点的一个简单示例。

清单 2-43 。错误 1.js

try {
    console.log('About to throw an error');
    throw new Error('Error thrown');
}
catch (e) {
    console.log('I will only execute if an error is thrown');
    console.log('Error caught: ', e.message);
}
finally {
    console.log('I will execute irrespective of an error thrown');
}

只有在抛出错误时,catch部分才会执行。尽管在try部分中抛出了任何错误,但是finally部分还是会执行。这种异常处理方法非常适合同步 JavaScript。但是,它在异步工作流下不起作用。清单 2-44 。证明了这个缺点。

清单 2-44 。 error2.js

try {
    setTimeout(function () {
        console.log('About to throw an error');
        throw new Error('Error thrown');
    }, 1000);
}
catch (e) {
    console.log('I will never execute!');
}

console.log('I am outside the try block');

它不起作用的原因是因为在执行对setTimeout的回调时,我们已经在 try/catch 块之外了。setTimeout 将调用稍后提供的函数,您可以在这个代码示例中看到这种情况,因为“我在 try 块之外”被执行了。Node.js 中未捕获异常的默认行为是退出进程,这就是我们的应用崩溃的原因。

正确的做法是处理回调中的错误,如清单 2-45 所示。

清单 2-45 。错误 3.js

setTimeout(function () {
    try {
        console.log('About to throw an error');
        throw new Error('Error thrown');
    }
    catch (e) {
        console.log('Error caught!');
    }
}, 1000);

这个方法在一个async函数中运行良好。但是现在我们有一个问题,就是找到一种方法来告诉外部代码这个错误。我们来看一个具体的例子。考虑一个简单的getConnection函数,它接受一个我们需要在成功连接后调用的callback,如清单 2-46 所示。

清单 2-46 。 error4.js

function getConnection(callback) {
    var connection;
    try {
        // Lets assume that the connection failed
        throw new Error('connection failed');

        // Notify callback that connection succeeded?
    }
    catch (error) {
        // Notify callback about error?
    }
}

我们需要通知回调关于成功和失败。这就是为什么 Node.js 有一个惯例,如果出现错误,就用第一个参数error 调用回调。如果没有错误,我们将错误设置为null进行回调。因此,为 Node.js 生态系统设计的getConnection函数将类似于清单 2-47 中所示。

清单 2-47 。错误 5.js

function getConnection(callback) {
    var connection;
    try {
        // Lets assume that the connection failed
        throw new Error('connection failed');

        // Notify callback that connection succeeded?
        callback(null, connection);
    }
    catch (error) {
        // Notify callback about error?
        callback(error, null);
    }
}

// Usage
getConnection(function (error, connection) {
    if (error) {
        console.log('Error:', error.message);
    }
    else {
        console.log('Connection succeeded:', connection);
    }
});

将错误作为第一个参数可以确保错误检查的一致性。这是所有具有错误条件的 Node.js 函数遵循的约定。一个很好的例子是文件系统 API,我们将在第三章中介绍。还要注意,开发人员倾向于使用null的虚假特性来检查错误。

摘要

在本章中,我们讨论了成功使用 Node.js 所必需的重要 JavaScript 概念。现在,您应该对 JavaScript 和 Node.js 在创建数据密集型应用方面的优势以及为何其性能优于之前的技术有了深刻的理解。在下一章,我们将讨论更多 Node.js 特定的模式和实践来创建可维护的应用。

引用的作品

Ryan Dahl (2009) Node.js 来自 JSConf。

" ginx 诉 Apache "," ?? "

http://www.w3schools.com/browsers/browsers_stats.asp“浏览器统计”

https://github.com/search?o=desc&q=stars%3A%3E1&s=stars&type=Repositories“GitHub 知识库按星星搜索”

三、核心 Node.js

Node.js 附带了许多内置模块,这些模块提供了一组我们可以构建的核心特性。在这一章中,我们将展示 Node.js 的重要部分,每个认真的开发者都应该熟悉这些部分。Node.js 的伟大之处在于,对于普通开发人员来说,完全了解所有功能的确切方式是完全可能的。

为了成功地交付大型应用并在相当大的团队中工作,我们需要一种封装复杂性的方法。JavaScript 最初被设计成由 web 浏览器以一种简单的方式从上到下读取,并且使用<script>标签加载文件。随着越来越大的应用被用 JavaScript 编写,两个模块系统(AMD 和 CommonJS)被开发出来。它们使代码更易于管理和重用。存在两种模式,因为浏览器和服务器在模块加载延迟(网络请求与文件系统)方面提出了不同的挑战。在本章中,我们将讨论这些模式,并展示如何在浏览器中重用 Node.js 代码。

关于本章和其他章节中使用多个文件的代码示例,需要注意的是,示例的主入口点通常按照 Node.js 社区惯例被称为app.js。所以您应该能够以node app.js的身份运行一个样本。

基于 Node.js 文件的模块系统

Kevin Dongaoor 在 2009 年创建了 CommonJS,目标是为服务器上的 JavaScript 模块指定一个生态系统。Node.js 遵循 CommonJS 模块规范。以下是模块系统的几个要点:

  • 每个文件都是它自己的模块。
  • 每个文件都可以使用module变量访问当前的模块定义。
  • 当前模块的输出由module.exports变量决定。
  • 要导入一个模块,使用全局可用的require函数。

和往常一样,最好直接进入代码。让我们考虑一个简单的例子,我们希望与应用的不同部分共享文件foo.js中的函数。要从文件中导出函数,我们只需将它分配给module.exports ,如清单 3-1 所示。

清单 3-1 。intro/base/foo.js

module.exports = function () {
    console.log('a function in file foo');
};

为了从文件bar.js中使用这个函数,我们简单地使用全局可用的require函数导入foo,并将返回值存储在一个局部变量中,如清单 3-2 中的所示。

清单 3-2 。intro/base/bar.js

var foo = require('./foo');
foo(); // logs out : "a function in file foo"

Node.js 被设计得很简单,这一点在它的模块系统中有所体现。现在我们已经看到了一个简单的例子,让我们从require函数开始,更深入地研究各种细节。

Node.js 需要函数

Node.js require函数是将模块导入当前文件的主要方法。Node.js 中有三种模块:核心模块文件模块、和外部 node_modules、,它们都使用require函数。我们目前正在讨论文件模块。

当我们使用相对路径进行require调用时——例如像require('./filename')require('../foldername/filename')——node . js 在一个新的范围内运行目标 JavaScript 文件,并返回该文件中module.exports的最终值。这是文件模块的基础。让我们看看这个设计的分支。

Node.js 很安全

许多编程环境中的模块并不安全,并且污染了全局范围。PHP 就是一个简单的例子。假设你有一个文件foo.php,它简单地定义了一个函数foo,如清单 3-3 所示。

清单 3-3 。foo.php

function foo($something){
        return $something;
}

如果你想在一个文件bar.php中重用这个函数,你可以简单地使用include函数包含foo.php,然后文件foo.php中的所有东西都成为bar.php的(全局)作用域的一部分。这允许您使用函数foo,如清单 3-4 中的所示。

清单 3-4 。在 PHP 中包含函数

include('foo.php');
foo();

这个设计有很多负面影响。例如,变量foo在当前文件中的含义可能会根据您导入的内容而改变。因此,如果两个文件foo1foo2有可能有同名的变量,那么您就不能安全地包含这两个文件。另外,所有的都被导入,所以在一个模块中不能只有局部变量。您可以在 PHP 中使用名称空间来解决这个问题,但是 Node.js 完全避免了名称空间污染的可能性。

使用require函数只给你一个module.exports变量,你需要在本地将结果赋给一个变量,以便在作用域内使用它,如清单 3-5 所示。

清单 3-5 。显示您控制名称的代码段

var yourChoiceOfLocalName = require('./foo');

没有偶然的全局范围—有显式名称和具有相似内部局部变量名的文件可以和平共存。

有条件地加载模块

require的行为就像 JavaScript 中的任何其他函数一样。它没有特殊的属性。这意味着你可以根据某些条件选择调用它,因此只有在你需要的时候才加载模块,如清单 3-6 所示。

清单 3-6 。延迟加载模块的代码片段

if(iReallyNeedThisModule){
     var foo = require('./foo');
}

这允许您基于您的需求,仅在第一次使用时延迟加载模块。

阻塞

require函数阻止进一步的代码执行,直到模块加载完毕。这意味着在模块被加载和执行之前,不会执行require调用之后的代码。这允许你避免提供不必要的回调,就像你需要为 Node.js 中的所有异步 I/O 做的那样,这在第二章中讨论过。(参见清单 3-7 。)

清单 3-7 。演示模块同步加载的代码片段

// Blocks execution till module is loaded
var foo = require('./foo');

// Continue execution after it is loaded
console.log('loaded foo');
foo();

缓存的

正如你从第二章中所知道的,从文件系统中读取数据比从 RAM 中读取要慢一个数量级。因此,在第一次对特定文件进行require调用后,module.exports被缓存。下一次调用解析为相同文件的require(换句话说,只要目标文件相同,传递给require调用的原始相对文件路径是什么并不重要),目标文件的module.exports变量从内存中返回,保持速度。清单 3-8 用一个简单的例子展示了这种速度差异。

清单 3-8 。intro/cache/bar . js

var t1 = new Date().getTime();
var foo1 = require('./foo');
console.log(new Date().getTime() - t1); // > 0

var t2 = new Date().getTime();
var foo2 = require('./foo');
console.log(new Date().getTime() - t2); // approx 0

共享状态

拥有某种在模块间共享状态的机制在各种环境中都很有用。由于模块被缓存,如果我们从模块foo.js返回一个对象foo,那么requirefoo.js的每个模块将获得相同的(可变的)对象。清单 3-9 用一个简单的例子展示了这个过程,在这个例子中我们导出了一个对象。该对象在app.js中被修改,如清单 3-10 所示。这个修改影响了bar.jsrequire返回的内容,如清单 3-11 所示。这允许您在模块之间共享内存中的对象,这对于使用模块进行配置是很有用的。清单 3-12 中显示了一个执行示例。

清单 3-9 。intro/shared/foo.js

module.exports = {
    something: 123
};

清单 3-10 。intro/shared/app.js

var foo = require('./foo');
console.log('initial something:', foo.something); // 123

// Now modify something:
foo.something = 456;

// Now load bar:
var bas = require('./bar');

清单 3-11 。intro/shared/bar.js

var foo = require('./foo');
console.log('in another module:', foo.something); // 456

清单 3-12 。intro/shared/app.js 的运行示例

$ node app.js
initial something: 123
in another module: 456

对象工厂

正如我们已经展示的,每次在 Node.js 进程中一个require调用解析到同一个文件时,都返回同一个对象。如果您希望每个require函数调用都有某种形式的新对象创建机制,那么您可以从返回新对象的源模块中导出一个函数。然后在你的目的地require模块并调用这个导入的函数来创建一个新的对象。清单 3-13 中显示了一个例子,我们导出一个函数,然后使用这个函数创建一个新对象,如清单 3-14 中的所示。

清单 3-13 。intro/factory/foo.js

module.exports = function () {
    return {
        something: 123
    };
};

清单 3-14 。intro/factory/app.js

var foo = require('./foo');

// create a new object
var obj = foo();

// use it
console.log(obj.something); // 123

请注意,您甚至可以一步完成此操作(换句话说,require('./foo')();)

Node.js 导出

现在我们对require有了更多的了解,让我们更深入地了解一下module.exports

模块.导出

如前所述,Node.js 中的每个文件都是一个模块。我们打算从模块中导出的项目应该附加到module.exports变量上。需要注意的是module.exports已经在每个文件中被定义为一个新的空对象。也就是说,module.exports = {}是隐性存在的。默认情况下,每个模块导出一个空对象,换句话说,{}。(参见清单 3-15 。)

清单 3-15 。intro/module.exports/app.js

console.log(module.exports); // {}

出口别名

到目前为止,我们只从一个模块中导出了一个对象。这可以很简单地通过将我们需要导出的对象分配给module.exports来完成。然而,从一个模块中导出多个变量是一个常见的需求。实现这一点的一个方法是创建一个新的对象文字并将其赋给module.exports,如清单 3-16 所示。

清单 3-16 。intro/exports/foo1.js

var a = function () {
    console.log('a called');
};

var b = function () {
    console.log('b called');
};

module.exports = {
    a: a,
    b: b
};

然而,这有点难以管理,因为模块返回的内容可能与模块包含的内容相差甚远。在清单 3-16 中,函数a的定义比我们实际将其导出到外部世界的时间要早得多。所以一个常见的惯例是简单地将我们想要导出的对象内联到module.exports,如清单 3-17 所示。这是可能的,因为module.exports被 Node.js 隐式设置为{},正如我们在前面的清单 3-15 中看到的。

清单 3-17 。intro/exports/foo2.js

module.exports.a = function () {
    console.log('a called');
};

module.exports.b = function () {
    console.log('b called');
};

然而,一直输入module.exports也变得很麻烦。因此 Node.js 通过为module.exports创建一个别名exports来帮助我们,所以不用每次都键入module.exports.something,你可以简单地使用exports.something。这显示在清单 3-18 中。

清单 3-18 。intro/exports/foo3.js

exports.a = function () {
    console.log('a called');
};

exports.b = function () {
    console.log('b called');
};

需要注意的是exports就像任何其他 JavaScript 变量一样;Node.js 只是为我们做了exports = module.exports。如果我们添加一些东西,例如,fooexports,也就是exports.foo = 123,我们实际上是在做module.exports.foo = 123,因为 JavaScript 变量是引用,正如在第二章中讨论的。

但是,如果你做了exports = 123,就断了对module.exports的引用;即exports不再指向module.exports。同样,它也不做module.exports = 123。所以,知道应该只使用exports别名给 attach stuff 而不直接给它赋值是很重要的。如果您想分配一个导出,使用module.exports =,就像我们在本节之前一直做的那样。

最后,你可以运行清单 3-19 中所示的代码样本来证明所有这些方法从消费(导入)的角度来看是等价的。

清单 3-19 。intro/export/app . js

var foo1 = require('./foo1');
foo1.a();
foo1.b();

var foo2 = require('./foo2');
foo2.a();
foo2.b();

var foo3 = require('./foo3');
foo3.a();
foo3.b();

模块最佳实践

现在我们已经了解了基于 Node.js 文件的模块系统背后的技术,让我们来看看社区遵循的一些最佳实践。Node.js 和 JavaScript 对编程错误具有很强的弹性,并努力保持灵活性,这就是为什么有各种各样的工作方式。但是,您应该遵循一些约定,我们强调了社区中常见的一些约定。

不要使用。js 扩展

最好是做require('./foo')而不是require('./foo.js'),尽管两者对 Node.js 都很好。

原因:对于基于浏览器的模块系统(比如 RequireJS,我们将在本章后面讨论),假设您没有提供.js扩展,因为我们无法查看服务器文件系统来理解您的意思。为了保持一致,避免添加。js 扩展在你所有的require调用中。

相对路径

在使用基于文件的模块时,需要使用相对路径(换句话说,用require('./foo')代替require('foo'))。

原因:非相对路径是为核心模块和 node_modules 预留的。我们在本章讨论核心模块,在下一章讨论 node_modules。

利用出口

当您想要导出多个内容时,尝试使用exports别名。

原因:它使输出的接近其定义。对于你导出的每一个东西都有一个本地变量也是一个惯例,这样你就可以很容易地在本地使用它。在一行中完成所有这些,如清单 3-20 所示。

清单 3-20 。创建一个局部变量并导出

var foo = exports.foo = /* whatever you want to export as `foo` from this module */ ;

导出整个文件夹

如果你有太多的模块放在一起,你一直在导入到其他文件中,尽量避免重复导入,如清单 3-21 所示。

清单 3-21 。避免重复巨大的导入块

var foo = require('../something/foo');
var bar = require('../something/bar');
var bas = require('../something/bas');
var qux = require('../something/qux');

相反,在something文件夹中创建一个单独的index.js。在index.js中,一次性导入所有模块,然后从该模块中导出,如清单 3-22 所示。

清单 3-22 。index.js 示例

exports.foo = require('./foo');
exports.bar = require('./bar');
exports.bas = require('./bas');
exports.qux = require('./qux');

现在,只要您需要所有这些东西,就可以简单地导入这个index.js:

var something = require('../something/index');

理由:更易维护。在导出方面,单个模块(单个文件)仍然较小——您不需要将所有内容都放在一个文件中,这样您就可以轻松地将它导入到其他地方。你只需要创建一个index.js文件。在导入方面,您需要编写(和维护)更少的require调用。

重要的全局

Node.js 提供了大量全局可用的实用变量。这些变量中有些是真正的全局变量(在所有模块之间共享),有些是局部全局变量(特定于当前模块的变量)。我们已经看到了几个真正的全局变量的例子,即require函数。我们已经看到了一些模块级隐式定义的变量— module(由module.exports使用)和exports。让我们检查几个更重要的全局变量。

控制台

console是可用的最有用的全局变量之一。因为从命令行启动和重新启动 Node.js 应用非常容易,所以当您需要调试应用时,控制台在快速显示应用中发生的情况方面起着重要作用。为了同样的目的,我们在整个例子中使用了console.logconsole有更多的 o 函数,我们将在第十一章中讨论。

计时器

我们之前在第二章中讨论 Node.js 事件循环时已经看到过setTimeout。它设置了一个在指定的延迟时间(毫秒)后调用的函数。请注意,此延迟是调用指定函数之前的最小间隔。它被调用的实际持续时间取决于 JavaScript 线程的可用性,正如我们在第二章的中关于线程饥饿的章节中看到的。它还取决于操作系统何时调度 Node.js 进程执行(通常这不是问题)。清单 3-23 中的显示了一个setTimeout的快速示例,它在 1000 毫秒(换句话说,一秒)后调用一个函数。

清单 3-23 。globals/timers/setTimeout.js

setTimeout(function () {
    console.log('timeout completed');
}, 1000);

setTimeout功能类似的是setInterval功能。setTimeout仅在指定的持续时间后执行一次回调函数。但是setInterval之后重复调用回调,每次经过指定的持续时间。这显示在清单 3-24 中,我们每秒钟打印出second passed。与setTimeout类似,根据 JavaScript 线程的可用性,实际持续时间可能会超过指定值。

清单 3-24 。globals/timers/setInterval.js

setInterval(function () {
    console.log('second passed');
}, 1000);

setTimeoutsetInterval都返回一个对象,该对象可用于使用clearTimeout / clearInterval函数清除超时/间隔。清单 3-25 演示了如何使用clearInterval在五秒钟内每秒调用一个函数,然后清除应用退出的间隔。

清单 3-25 。globals/timers/clear interval . js

var count = 0;
var intervalObject = setInterval(function () {
    count++;
    console.log(count, 'seconds passed');
    if (count == 5) {
        console.log('exiting');
        clearInterval(intervalObject);
    }
}, 1000);

_ _ 文件名和 _ _ 目录名

这些变量在每个文件中都可用,并为您提供当前模块的文件和目录的完整路径。完整路径意味着它们包括所有内容,直到该文件所在的当前驱动器的根目录。使用清单 3-26 中的代码来查看当你将文件移动到文件系统的不同位置并运行它时这些值的变化。

清单 3-26 。globals/fileAndDir/app.js

console.log(__dirname);
console.log(__filename);

过程

process是 Node.js 提供的最重要的全局变量之一。除了我们将在下一节讨论的一些有用的成员函数和属性之外,它还是一些关键事件的来源,我们将在第五章中更深入地研究这些事件。

命令行参数

由于 Node.js 没有传统 C/C++/JAVA/C#意义上的 main 函数,所以使用process对象来访问命令行参数。参数作为成员属性process.argv可用,它是一个数组。第一个元素是node(即 Node 可执行文件),第二个元素是传入 Node.js 以启动进程的 JavaScript 文件的名称,剩下的元素是命令行参数。作为一个例子,考虑一个简单的文件argv.js,它简单地将这些记录到控制台,如清单 3-27 中的所示。如果您以node argv.js foo bar bas的身份运行它,您将得到类似于清单 3-28 中所示的输出。

清单 3-27 。全局/流程/argv.js

// argv.js
console.log(process.argv);

清单 3-28 。argv.js 的示例输出

 ['node',
  '/path/to/file/on/your/filesystem/argv.js',
  'foo',
  'bar',
  'bas']

在 Node.js 中,有一些优秀的库可以以有意义的方式处理命令行参数。在下一章学习 NPM 时,我们将研究一个这样的库。

process.nextTick

process.nextTick是一个采用回调函数的简单函数。它用于将回调放入 Node.js 事件循环的下一个循环中。它被设计得非常高效,并且被许多 Node.js 核心库使用。它的用法简单到足以演示,清单 3-29 中给出了一个例子。该示例的输出如清单 3-30 中的所示。

清单 3-29 。globals/process/nexttick.js

// nexttick.js
process.nextTick(function () {
    console.log('next tick');
});
console.log('immediate');

清单 3-30 。nexttick.js 输出示例

immediate
next tick

如您所见,立即调用首先执行,而nextTick回调在事件循环的下一次运行中执行。您应该知道此函数的原因是,由于 Node.js 的异步特性,此函数通常会出现在调用堆栈中,因为这将是 Node.js 事件循环的起点。这个函数之前的都是 c,调用栈中这个函数之后的都是 JavaScript。

缓冲器

缓冲世界!纯 JavaScript 非常适合 Unicode 字符串。然而,为了处理 TCP 流和文件系统,开发人员添加了本地和快速支持来处理二进制数据。开发人员在 Node.js 中使用全球通用的Buffer类实现了这一点。

作为一名从事应用开发的 Node.js 开发人员,您与 buffer 的主要交互很可能是将Buffer实例转换为string,或者将字符串转换为Buffer实例。为了进行这两种转换,您需要告诉Buffer类每个字符在字节中的含义。这些信息被称为字符编码。Node.js 支持所有流行的编码格式,如 ASCII、UTF 8 和 UTF-16。

将字符串转换成缓冲区非常简单。你只需调用Buffer类构造函数(参见第二章中的原型讨论来回顾 JavaScript 中的类)传入一个字符串和一个编码。将一个Buffer实例转换成一个字符串也同样简单。您调用缓冲区实例的toString方法,传递一个编码方案。这两者都在清单 3-31 中进行了演示。

清单 3-31 。全局/缓冲区/缓冲区. js

// a string
var str = "Hello Buffer World!";

// From string to buffer
var buffer = new Buffer(str, 'utf-8');

// From buffer to string
var roundTrip = buffer.toString('utf-8');
console.log(roundTrip); // Hello

全球的

变量global是我们在 Node.js 中的全局名称空间的句柄,如果你熟悉前端 JavaScript 开发,这有点类似于window对象。我们见过的所有真正的全局变量(consolesetTimeoutprocess)都是global变量的成员。你甚至可以在全局变量中添加成员,使其随处可用,如清单 3-32 所示。这使得变量something随处可用的事实在清单 3-33 中得到证明。

清单 3-32 。globals/global/addToGlobal.js

global.something = 123;

清单 3-33 。全球/全球/app.js

console.log(console === global.console); // true
console.log(setTimeout === global.setTimeout); // true
console.log(process === global.process); // true

// Add something to global
require('./addToGlobal');
console.log(something); // 123

尽管您可以向 global 添加成员,但我们强烈建议不要这样做。原因是它使得知道一个特定的变量来自哪里变得极其困难。模块系统的设计使得大型代码库的分析和维护变得容易。到处都是全局变量是不可维护、不可伸缩或不可重用的。然而,知道这样做是有用的,更重要的是,作为库开发人员,您可以按照自己喜欢的方式扩展 Node.js。

核心模块

Node.js 的设计理念是提供一些经过实战检验的核心模块,并让社区在这些模块的基础上提供高级功能。在本节中,我们将研究几个重要的核心模块。

消耗核心模块

消费核心模块与消费自己编写的基于文件的模块非常相似。你还在用require功能。唯一的区别是,您只需为require函数指定模块的名称,而不是文件的相对路径。例如,要使用核心的path模块,您需要编写一个类似var path = require('path')的 require 语句。与基于文件的模块一样,不存在隐式的全局命名空间污染,您得到的是一个自己命名的局部变量来访问模块的内容。例如,在var path = require('path')中,我们将它存储在一个名为path的局部变量中。现在让我们检查几个核心模块,您应该了解这些模块才能成功使用 Node.js。

路径模块

使用require('path')加载该模块。path 模块导出函数,这些函数提供了使用文件系统时常见的有用的字符串转换。使用path模块的主要动机是消除处理文件系统路径时的不一致性。例如,path.join在 Mac OS X 等基于 UNIX 的系统上使用正斜杠/而在 Windows 系统上使用反斜杠` \ '。下面是一些更有用的函数的快速讨论和示例。

path.normalize(str)

这个函数修复了特定于操作系统的斜线。和..并删除重复的斜杠。展示这些特性的一个简单例子如清单 3-34 所示。

清单 3-34 。core/path/normalize.js

var path = require('path');

// Fixes up .. and .
// logs on Unix: /foo
// logs on Windows: \foo
console.log(path.normalize('/foo/bar/..'));

// Also removes duplicate '//' slashes
// logs on Unix: /foo/bar
// logs on Windows: \foo\bar
console.log(path.normalize('/foo//bar/bas/..'));

path.join([str1],[str2],…)

考虑到操作系统,该函数将任意数量的路径连接在一起。清单 3-35 中显示了一个示例。

清单 3-35 。核心/路径/连接. js

var path = require('path');

// logs on Unix: foo/bar/bas
// logs on Windows: foo\bar\bas
console.log(path.join('foo', '/bar', 'bas'));

dirname、basename 和 extname

这些函数是路径模块中最有用的三个函数。path.dirname给出特定路径字符串的目录部分(独立于操作系统),而path.basename给出文件的名称。path.extname给你文件扩展名。这些功能的一个例子如清单 3-36 所示。

清单 3-36 。核心/路径/目录 _ 基本 _ 扩展. js

var path = require('path');

var completePath = '/foo/bar/bas.html';

// Logs : /foo/bar
console.log(path.dirname(completePath));

// Logs : bas.html
console.log(path.basename(completePath));

// Logs : .html
console.log(path.extname(completePath));

现在你应该对如何使用path以及它的设计目标有所了解。Path 还有一些其他有用的函数,您可以使用 Node.js 官方文档(http://nodejs.org/api/path.html)在线探索。

fs 模块

fs模块提供了对文件系统的访问。使用require('fs')加载该模块。fs模块具有重命名文件、删除文件、读取文件和写入文件的功能。在清单 3-37 中显示了一个简单的写入和读取文件系统的例子。

清单 3-37 。core/fs/create.js

var fs = require('fs');

// write
fs.writeFileSync('test.txt', 'Hello fs!');

// read
console.log(fs.readFileSync('test.txt').toString());

关于fs模块的一个伟大的事情是它有异步和同步功能(使用-Sync后缀)来处理文件系统。例如,要删除一个文件,你可以使用unlinkunlinkSync。同步版本如清单 3-38 所示,相同代码的异步版本如清单 3-39 所示。

清单 3-38 。core/fs/deleteSync.js

var fs = require('fs');
try {
    fs.unlinkSync('./test.txt');
    console.log('test.txt successfully deleted');
}
catch (err) {
    console.log('Error:', err);
}

清单 3-39 。核心/fs/delete.js

var fs = require('fs');
fs.unlink('./test.txt', function (err) {
    if (err) {
        console.log('Error:', err);
    }
    else {
        console.log('test.txt successfully deleted');
    }
});

主要区别在于异步版本接受回调,如果有错误对象的话,就向其传递错误对象。我们在第二章中讨论了使用回调和错误参数的错误处理惯例。

我们在第二章中也看到,访问文件系统比访问 RAM 慢一个数量级。访问文件系统会同步阻塞 JavaScript 线程,直到请求完成。在繁忙的流程中,例如在 web 服务器场景中,最好尽可能使用异步函数。

关于fs模块的更多信息可以在 Node.js 官方文档(http://nodejs.org/api/fs.html)中在线找到。

操作系统模块

os模块提供了一些基本的(但至关重要的)操作系统相关的实用函数和属性。您可以使用require('os')调用来访问它。例如,如果我们想知道当前的系统内存使用情况,我们可以使用os.totalmem()os.freemem()函数。这些在清单 3-40 中进行了演示。

清单 3-40 。core/os/memory.js

var os = require('os');
var gigaByte = 1 / (Math.pow(1024, 3));
console.log('Total Memory', os.totalmem() * gigaByte, 'GBs');
console.log('Available Memory', os.freemem() * gigaByte, 'GBs');
console.log('Percent consumed', 100 * (1 - os.freemem() / os.totalmem()));

os模块提供的一个重要功能是关于可用 CPU 数量的信息,如清单 3-41 所示。

清单 3-41 。core/OS/CPU . js

var os = require('os');
console.log('This machine has', os.cpus().length, 'CPUs');

当我们讨论可伸缩性时,我们将在第十三章中学习如何利用这一事实。

实用程序模块

util模块包含许多有用的通用函数。您可以使用require('util')调用来访问util模块。要用时间戳将一些东西注销到控制台,可以使用util.log函数,如清单 3-42 所示。

清单 3-42 。core/util/log.js

var util = require('util');
util.log('sample message'); // 27 Apr 18:00:35 - sample message

另一个非常有用的特性是使用util.format函数进行字符串格式化。这个函数类似于 C/C++ printf函数。第一个参数是包含零个或多个占位符的字符串。然后,根据占位符的含义,使用剩余的参数替换每个占位符。流行的占位符是%s(用于字符串)和%d(用于数字)。这些在清单 3-43 中进行了演示。

清单 3-43 。核心/实用程序/格式. js

var util = require('util');
var name = 'nate';
var money = 33;

// prints: nate has 33 dollars
console.log(util.format('%s has %d dollars', name, money));

另外,util有几个函数来检查某个东西是否属于特定类型(isArrayisDateisError)。这些功能在清单 3-44 中进行了演示。

清单 3-44 。core/util/isType.js

var util = require('util');
console.log(util.isArray([])); // true
console.log(util.isArray({ length: 0 })); // false

console.log(util.isDate(new Date())); // true
console.log(util.isDate({})); // false

console.log(util.isError(new Error('This is an error'))); // true
console.log(util.isError({ message: 'I have a message' })); // false

在浏览器中重用 Node.js 代码

在我们学习如何在浏览器中重用 Node.js 代码之前,我们需要学习更多关于各种模块系统的知识。我们需要了解 AMD 的需求,以及它与 CommonJS 的区别。

AMD 简介

正如我们在本章开始时讨论的,Node.js 遵循 CommonJS 模块规范。当我们可以直接访问文件系统时,这个模块系统对服务器环境非常有用。我们第一次讨论了从 Node.js 中的文件系统加载模块是一个阻塞调用。考虑加载两个模块的简单情况,如清单 3-45 所示。

清单 3-45 。显示使用 CommonJS 加载两个模块的代码片段

var foo = require('./foo');
var bar = require('./bar');
// continue code here

在这个例子中,直到所有的foo.js都被加载后bar.js才被解析。事实上,Node.js 甚至不知道您将需要bar.js,直到foo.js被加载并且行require('./bar')被解析。在服务器环境中,这种行为是可以接受的,因为它被视为应用引导过程的一部分。在启动服务器时,您通常需要一些东西,然后这些东西会从内存中返回。

然而,如果在浏览器中使用相同的模块系统,每个require语句将需要触发一个到服务器的 HTTP 请求。这比文件系统访问调用慢一个数量级,并且不太可靠。加载大量模块会迅速降低用户在浏览器中的体验。解决方案是异步、并行和提前加载模块。为了支持这种异步加载,我们需要一种方式来声明这个文件将依赖于前面的./foo./bar,并使用回调来继续执行代码。已经有一个专门的规范叫做异步模块定义(AMD) 。AMD 格式的清单 3-45 中的相同示例显示在清单 3-46 中。

清单 3-46 。显示使用 AMD 加载两个模块的代码片段

define(['./foo', './bar'], function(foo, bar){
        // continue code here
});

define函数不是浏览器自带的。这些必须由第三方库提供。其中最受浏览器欢迎的是 RequireJS ( http://requirejs.org/)。

再次重申,浏览器与服务器启动有不同的延迟要求。这使得以异步方式加载模块需要不同的语法。require 调用的不同性质使得在浏览器中重用 Node.js 代码稍微复杂一些。在我们深入研究之前,让我们设置一个 RequireJS 引导应用。

设置要求 j

因为我们需要为 web 浏览器提供 HTML 和 JavaScript,所以我们需要创建一个基本的 web 服务器。我们将使用 Chrome 作为我们的首选浏览器,因为它可以在所有平台上使用,并且拥有出色的开发工具支持。这个示例的源代码可以在chapter3/amd/base文件夹中找到。

启动 Web 服务器

我们将使用server.js,这是一个非常基本的 HTTP web 服务器,我们将在第六章中自己编写。使用 Node.js ( node server.js)启动服务器。服务器将开始在端口 3000 上侦听来自浏览器的传入请求。如果你访问http://localhost:3000,服务器将尝试从与server.js相同的文件夹中提供index.html,如果它可用的话。

下载要求

可以从官方网站(http://requirejs.org/docs/download.html)下载 RequireJS。它是一个简单的 JavaScript 文件,可以包含在项目中。它已经存在于chapter3/amd/base文件夹中。

自举要求

在与server.js相同的文件夹中创建一个简单的index.html,内容如列表 3-47 所示。

清单 3-47 。amd/base/index.html

<html>
<script
    src="./require.js"
    data-main="./client/app">
</script>
<body>
    <p>Press Ctrl + Shift + J (Windows) or Cmd + Opt + J (MacOSX) to open up the console</p>
</body>
</html>

我们有一个简单的脚本标签来加载require.js。当 RequireJS 加载时,它查看加载了 RequireJS 的脚本标签上的data-main属性,并将其视为应用入口点。在我们的例子中,我们将data-main属性设置为./client/app,因此 RequireJS 将尝试加载http://localhost:3000/client/app.js

客户端应用入口点

当我们设置 RequireJS 来加载/client/app.js时,让我们创建一个client文件夹,并在该文件夹中创建一个app.js,它只是将一些东西记录到控制台,如清单 3-48 所示。

清单 3-48 。amd/base/client/app.js

console.log('Hello requirejs!');

现在,如果你打开浏览器http://localhost:3000并打开开发工具(按 F12),你应该看到记录到控制台的消息,如图 3-1 所示。

9781484201886_Fig03-01.jpg

图 3-1 。基本 AMD 样本

这是设置 RequireJS 的基础。该设置将用于本节的剩余演示。你只需要复制这个server.js + index.html + require.js + client/app.js组合,开始随心所欲的黑客攻击。

RequireJS 有更多的配置选项,我们鼓励您浏览在线提供的 API 文档。

玩 AMD

现在我们知道了如何启动一个 RequireJS 浏览器应用,让我们看看如何在模块中导入/导出变量。我们将创建三个模块:app.jsfoo.jsbar.js。我们将使用 AMD 的app.js中的foo.jsbar.js。该演示可在chapter3/amd/play文件夹中获得。

要从一个模块中导出某些东西,你可以简单地从define回调中返回它。例如,让我们创建一个导出简单函数的文件foo.js,如清单 3-49 中的所示。

清单 3-49 。amd/play/客户端/foo.js

define([], function () {
    var foo = function () {
        console.log('foo was called');
    };
    return foo; // function foo is exported
});

坦率地说,我们需要一个文件中的所有模块,文件的根包含一个对define的调用。要将app.js中的模块./foo./bar加载到同一个文件夹中,定义调用如清单 3-50 所示。

清单 3-50 。amd/play/client/app.js

define(['./foo', './bar'], function (foo, bar) {
        // use foo and bar here
});

define可以接受一个名为exports的特殊参数,其行为类似于 Node.js 中的exports变量。让我们使用这个语法创建模块bar.js,如清单 3-51 所示。

清单 3-51 。amd/play/client/bar.js

define(['exports'], function (exports) {
    var bar = exports.log = function () {
        console.log('bar.log was called');
    };
});

请注意,您只能使用exports来附加您想要导出的变量(例如,exports.log = /*something*/),),但是您不能将它分配给其他变量(exports = /*something*/),因为那样会破坏由 RequireJS 监控的exports变量的引用。这在概念上与 Node.js 中的exports变量非常相似。现在,让我们完成app.js并使用这两个模块,如清单 3-52 所示。

清单 3-52 。amd/play/client/app.js

define(['./foo', './bar'], function (foo, bar) {
    foo();
    bar.log();
});

如果您运行这个应用,您将得到如图 3-2 所示的期望结果。

9781484201886_Fig03-02.jpg

图 3-2 。app.js 中使用的 foo 和 bar

当我们查看 chrome 调试工具中的网络选项卡时,对模块使用这种替代(AMD)语法的真正好处变得显而易见,如图 3-3 中的所示。

9781484201886_Fig03-03.jpg

图 3-3 。基本 AMD 样本

可以看到foo.jsbar.js一下载完app.js就并行下载了,RequireJS 发现app.js因为调用define需要foo.jsbar.js才能发挥作用。

关于 AMD 的更多信息

以下是一些关于 AMD 的有用且有趣的事实,您应该了解这些事实以完善您的知识:

  • 模块被缓存。这与 Node.js 中缓存模块的方式类似,即每次都返回相同的对象。
  • 许多要定义的参数都是可选的,并且有各种方式来配置如何在 RequireJS 中扫描模块。
  • 您仍然可以使用一个require调用来有条件地加载特定的模块,这是 RequireJS 提供的另一个功能,如清单 3-53 所示。这个函数也是异步的,不同于require的 Node.js 版本。

清单 3-53 。展示如何在 AMD 中有条件地加载模块的代码片段

define(['./foo', './bar'], function(foo, bar){
        if(iReallyNeedThisModule){
                require(['./bas'], function(bas){
                        // continue code here.
                });
        }
});

这里的目标是给出如何使用 RequireJS 的快速概述,并理解浏览器不同于 Node.js。

将 Node.js 代码转换为浏览器代码

正如您所看到的,浏览器模块系统(AMD)和 Node.js 模块系统(CommonJS)之间存在显著的差异。然而,好消息是 Node.js 社区已经开发了许多工具来获取您的 CommonJS / Node.js 代码,并将其转换为与 AMD / RequireJS 兼容。最常用的(也是其他工具依赖的)是 Browserify ( http://browserify.org/)。

Browserify 是一个命令行工具,作为 NPM 模块提供。NPM 模块将在下一章详细讨论。现在,只要知道如果你按照第一章中的说明安装了 Node.js,你就已经有 npm 可用了。要在命令行工具上安装 Browserify,只需执行清单 3-54 中的命令。(注意:在 Mac OS X 上,你需要以 root 用户身份运行它(sudo npm install –g browserify)。

清单 3-54 。安装浏览器

npm install -g browserify

这将在全局范围内安装 Browserify(这个概念将在下一章中变得清晰),并使它在命令行上进一步可用。现在,如果您运行 browserify,您应该会看到如图图 3-4 所示的输出,表明安装成功。

9781484201886_Fig03-04.jpg

图 3-4 。在命令提示符下使用 browser ify

使用 browserify 最常见的方法是为 Node.js 模块指定一个入口点,并使用–o(--outfile)参数将该文件及其所有依赖文件转换为一个 AMD 兼容文件。和往常一样,让我们开始演示,获得一些实际操作经验。

浏览器验证演示

在本节中,我们将创建几个简单的 Node.js 模块,然后使用 Browserify 将它们转换为 AMD 语法并在浏览器中运行。这个例子的所有代码都在chapter3/amd/browserify文件夹中。

首先,我们将创建三个遵循 Node.js / CommonJS 模块规范的文件(代码在chapter3/amd/browserify/node文件夹中)。我们正在使用来自使用 CommonJS 的app.js ( 清单 3-57 )的foo.js ( 清单 3-55 )和bar.js ( 清单 3-56 )。您可以在 Node.js 中运行这段代码,看看它是否按预期工作。

清单 3-55 。amd/browserify/node/foo.js

module.exports = function () {
    console.log('foo was called');
}

清单 3-56 。amd/browserify/node/bar.js

exports.log = function () {
    console.log('bar.log was called');
}

清单 3-57 。amd/browserify/node/app.js

var foo = require('./foo');
var bar = require('./bar');

foo();
bar.log();

现在让我们转换这段代码,使它成为一个 AMD 兼容的模块。在命令行上,运行如清单 3-58 所示的命令。

清单 3-58 。将 app.js 转换为 AMD 模块的命令行参数

browserify app.js -o amdmodule.js

这会将app.js及其所有依赖项(foo.jsbar.js)转换成同一个文件夹中的单个 AMD 兼容模块amdmodule.js。最后一步,我们简单地从我们的客户端app.js ( 清单 3-59 )加载这个模块,以显示它可以在浏览器中工作。

清单 3-59 。amd/browserify/client/app.js

define(['../node/amdmodule'], function (amdmodule) {
});

现在如果我们启动服务器(server.js)并打开网页浏览器(http://localhost:3000),你会在 chrome 开发工具中看到console.log消息,如图图 3-5 所示。我们已经成功地将 Node.js 代码移植到浏览器中。

9781484201886_Fig03-05.jpg

图 3-5 。在浏览器中重用 Node.js/CommonJS 代码

需要注意的一点是,不可能将每个 Node.js 模块的都转换成浏览器模块。具体来说,依赖于只在服务器上可用的特性(如文件系统)的 Node.js 模块在浏览器中无法工作。

Browserify 有很多选项,也能够导航 NPM 包(node_modules)。您可以在http://browserify.org/在线了解 Browserify 的更多信息。

摘要

在本章中,我们讨论了一些重要的可维护性主题,为了成为一名成功的 Node.js 开发人员,您应该了解这些主题。我们仔细观察了require / module.exports的组合,让您对 Node.js 模块的原理及其简单性有了一个牢固的理解。然后我们讨论了几个核心的内置 Node.js 模块。(当我们了解事件、流和特定领域(如 TCP/HTTP)时,我们将更多地了解这些核心模块。)最后,我们讨论了 AMD 和 CommonJS 的区别,以及如何在浏览器中重用 Node.js 代码。

在下一章,我们将讨论 Node.js 的伟大之处之一——它的开源生态系统。开源 Node.js 项目包中有很多可用的包,我们将向您展示如何使用 NPM 来利用它们。

四、Node.js 包

任何可以用 JavaScript 编写的应用最终都会用 JavaScript 编写。

—杰夫·阿特伍德的《阿特伍德定律》

正如我们在第三章中看到的,core Node.js 并没有提供大量的库。这是有充分理由的。将某些东西作为 core Node.js 的一部分发布可能会扼杀竞争和思想的发展。因此,core Node.js 试图限制它包含的内容,并依赖开源社区来描绘完整的画面。让开源社区找到问题 X 的最佳解决方案比开出万能的解决方案要好。

JavaScript 开发人员的数量比任何其他编程语言都多。此外,越来越多的人正在编写越来越多的库来完成浏览器中的任何给定任务,所有这些工作都可以在应用中使用。

为了方便在应用中使用第三方 JavaScript 库,Node.js 自带了一个名为Node Package Manager(NPM)的包管理系统。在本章中,我们将讨论如何在我们的应用中使用 NPM 包。我们将讨论每个 Node.js 开发人员都应该知道的几个重要问题。我们也将把这作为一个实践机会来学习更多关于 JavaScript 如何工作的知识。

重访 Node 模块

在前一章中,我们了解到有三种 Node.js 模块:基于文件的模块、核心模块和外部 node_modules。我们讨论了基于文件的模块和核心模块,现在我们将看看 node_modules 为了更好地理解它们,让我们更深入地看看 Node.js require函数的文件系统扫描顺序。

  • 如果传入 require 函数的模块名带有前缀。/'或'../'或'/',那么它被认为是一个基于文件的模块,文件被加载,正如我们在第三章中看到的。一些示例调用:require('./bar)require('../bar/bar')require('/full/path/to/a/node/module/file')
  • 否则,我们寻找具有相同名称的核心模块,例如,如果调用是require('bar'),则为'bar'。如果没有找到与这个名字匹配的核心模块,我们就寻找一个叫做'bar'node_module

扫描 Node 模块

我们先来看一个例子。如果一个文件/home/ryo/project/foo.js有一个 require 调用require('bar'),Node.js 按照下面的顺序扫描文件系统中的 node_modules。返回找到的第一个bar.js

  • /home/ryo/project/ node_modules/bar.js
  • /home/ryo/ node_modules/bar.js
  • /home/ node_modules/bar.js
  • /node_modules/bar.js

换句话说,Node.js 在当前文件夹中查找'node_modules/bar.js',然后是每个父文件夹,直到它到达当前文件的文件系统树的根,或者直到找到一个bar.js。一个简单的例子是模块foo.js加载模块node_modules/bar.js,如清单 4-1 和清单 4-2 所示。

清单 4-1 。hello/foo.js

var bar = require('bar');
bar(); // hello node_modules!

清单 4-2 。hello/node_modules/bar.js

module.exports = function () {
    console.log('hello node_modules!');
}

如你所见,我们的模块bar.js看起来完全一样,如果我们只是使用基于文件的模块。这是故意的。基于文件的模块和 node_modules 之间唯一的区别是扫描文件系统以加载 JavaScript 文件的方式。其他所有行为都是一样的。

基于文件夹的模块

在我们讨论 node_modules 机制的所有优点之前,我们需要学习 Node.js require函数支持的最后一个代码组织技巧。几个文件为同一个目标工作的情况并不少见。将这些文件组织到单个模块中是有意义的,该模块可以通过单个 require 调用来加载。我们讨论过将这样的文件组织到一个单独的文件夹中,并在第三章中用一个index.js来代表这个文件夹。

这种情况很常见,Node.js 明确支持这种机制。也就是说,如果模块的路径解析为一个文件夹(而不是一个文件),Node.js 将在该文件夹中查找一个index.js文件,并将其作为模块文件返回。这在一个简单的例子(chapter4/folderbased/indexbased1)中得到演示,在这个例子中,我们使用一个index.js导出两个模块bar1.jsbar2.js,并在一个模块foo中加载模块栏(并隐式地加载bar/index.js),如清单 4-3 所示(运行node folderbased/indexbased1/foo.js)。

清单 4-3 。从文件夹中隐式加载 index.js 的(代码:Folder base/index based 1)

// bar/bar1.js
module.exports = function () {
    console.log('bar1 was called');
}

// bar/bar2.js
module.exports = function () {
    console.log('bar2 was called');
}

// bar/index.js
exports.bar1 = require('./bar1');
exports.bar2 = require('./bar2');

// foo.js
var bar = require('./bar');
bar.bar1();
bar.bar2();

如前所述,基于文件的模块和 node_modules 之间唯一的区别是扫描文件系统的方式。因此,对于像require('./bar')这样的调用,node_modules 的相同代码将是简单地将bar文件夹移动到node_modules/bar文件夹中,并将需求调用从require('./bar')更改为require('bar')

这个例子存在于chapter4/folderbased/indexbased2文件夹中(运行node folderbased/indexbased2/foo.js)。由于调用现在解析到node_modules/bar文件夹,Node.js 寻找node_modules/bar/index.js,既然找到了,那就是为require('bar')返回的内容。(参见清单 4-4 。)

清单 4-4 。从 node_modules/module 文件夹隐式加载 index.js(代码:Folder base/index base 2)

// node_modules/bar/bar1.js
module.exports = function () {
    console.log('bar1 was called');
}

// node_modules/bar/bar2.js
module.exports = function () {
    console.log('bar2 was called');
}

// node_modules/bar/index.js
exports.bar1 = require('./bar1');
exports.bar2 = require('./bar2');

// foo.js
var bar = require('bar'); // look for a node_modules module named bar
bar.bar1();
bar.bar2();

node_modules 的 require 调用语义看起来与核心模块完全相同(比较require('fs')require('bar')函数调用)。这是故意的。使用 node_modules 时,您会有一种扩展内置 Node.js 功能的感觉。

在使用 node_modules 时,使用基于文件夹的代码组织是一种常见的策略,也是您应该尽可能做的事情。换句话说,如果只需要一个文件,就不要在 node_modules 文件夹中创建顶级 JavaScript 文件。然后,用一个node_modules/bar/index.js文件代替node_modules/bar.js

Node 模块的优势

我们现在知道 node_modules 与基于文件的模块是一样的,只是在加载模块 JavaScript 文件时使用了不同的文件系统扫描机制。此时最明显的问题是,“优势是什么?”

简化长文件相对路径

假设您有一个模块foo/foo.js,它提供了许多实用程序,您需要在应用的不同地方使用它们。在区段bar/bar.js中,你会有一个要求呼叫require('../foo/foo.js'),在区段bas/nick/scott.js中,你会有一个要求呼叫require('../../../foo/foo.js')。此时,您应该问自己:“这个foo模块是独立的吗?”如果是这样,这是一个很好的选择,移动到项目文件夹的根目录下的node_modules/foo/index.js。这样你可以简化你的调用,在你的代码中只有require('foo')

增加可重用性

如果你想与另一个项目共享一个模块foo,你只需要复制node_modules/foo到那个项目。事实上,如果你正在处理两个相似的子项目,你可以将node_modules/foo移动到包含两个项目的文件夹中,如清单 4-5 所示。这使您更容易从一个地方维护foo

清单 4-5 。使用共享 node_modules 的子项目代码组织示例

projectroot
   |-- node_modules/foo
   |-- subproject1/project1files
   |-- subproject2/project2files

减少副作用

由于 node_modules 的扫描方式,您可以将模块的可用性限制在代码库的特定部分。这允许你安全地进行部分升级,假设你的原始代码组织如清单 4-6 所示。

清单 4-6 。使用模块 foo 的演示项目

projectroot
   |-- node_modules/foo/fooV1Files
   |-- moduleA/moduleAFiles
   |-- moduleB/moduleBFiles
   |-- moduleC/moduleCFiles

现在,当你正在处理一个新模块(比如说moduleD)需要模块foo的一个新版本(并且向后不兼容)时,你可以简单地组织你的代码,如清单 4-7 所示。

清单 4-7 。模块 foo 的部分升级

projectroot
   |-- node_modules/foo/fooV1Files
   |-- moduleA/moduleAFiles
   |-- moduleB/moduleBFiles
   |-- moduleC/moduleCFiles
   |-- moduleD
          |-- node_modules/foo/fooV2Files
          |-- moduleDFiles

这样,moduleAmoduleBmoduleC继续照常运行,你可以在moduleD中使用新版本的foo

克服模块不兼容性

Node.js 不存在许多传统系统中存在的模块依赖性/不兼容性问题。在许多传统的模块系统中,moduleX不能与moduleY一起工作,因为它们依赖于moduleZ的不同(并且不兼容)版本。在 Node.js 中,每个模块可以有自己的 node_modules 文件夹,不同版本的moduleZ可以共存。模块不需要在 Node.js 中是全局的!

模块缓存和 Node 模块

你可能还记得我们在第三章中的讨论,即require在第一次调用后缓存一个请求调用的结果。原因是您不需要加载 JavaScript 并从文件系统一次又一次地运行它,从而获得了性能提升。我们说过,每次路径将解析到同一个文件时,require都返回同一个对象。

正如我们已经展示的,node_modules 只是扫描基于文件的模块的一种不同方式。因此,它们遵循相同的模块缓存规则。如果你有两个文件夹,其中moduleAmoduleB需要模块 foo,即require('foo'),它存在于某个父文件夹中,如清单 4-8 所示,它们得到相同的对象(在给定的例子中从node_modules/foo/index.js导出)。

清单 4-8 。两个模块获得相同的 foo 模块

projectroot
    |-- node_modules/foo/index.js
    |-- moduleA/a.js
    |-- moduleB/b.js

然而,考虑一下清单 4-9 中所示的代码组织。这里moduleBrequire('foo')调用将解析到moduleB/node_modules/foo/index.js,而moduleA的 require 调用将解析到node_modules/foo/index.js,因此它们没有得到相同的对象。

清单 4-9 。模块 A 和 B 得到不同的 foo 模块

projectroot
    |-- node_modules/foo/index.js
    |-- moduleA/a.js
    |-- moduleB
         |-- node_modules/foo/index.js
         |-- b.js

这是一件好事,因为我们已经看到,它可以防止你陷入依赖问题。但这种脱节是你应该意识到的。

数据

NPM 使用 JSON 文件来配置模块。在我们深入研究 NPM 之前,让我们先来看看 JSON。

JSON 入门

JSON 是一种用于通过网络传输数据的标准格式。在大多数情况下,它可以被视为 JavaScript 对象文字的子集。它基本上限制了哪些 JavaScript 对象被认为是有效的。JSON 对象使规范更容易实现,并保护用户 免受他们需要担心的边缘情况的影响。在这一节中,我们将从实践的角度来看 JSON。

JSON 规范强制实施的限制之一是,您必须对 JavaScript 对象键使用引号。这允许您避免 JavaScript 关键字不能作为对象文字的键的情况。例如,清单 4-10 中的 JavaScript 在 ECMA 脚本 3(JavaScript 的旧版本)中是一个语法错误,因为for是一个 JavaScript 关键字。

清单 4-10 。旧浏览器(ECMAScript 5 之前)中的无效 JS

var foo = { for : 0 }

相反,与所有版本的 JavaScript 兼容的同一个对象的有效表示应该是清单 4-11 中所示的内容。

清单 4-11 。即使在旧浏览器中也有效(ECMAScript 5 之前)

var foo = { "for" : 0 }

此外,JSON 规范限制了给定键的值是 JavaScript 对象的安全子集。值只能是字符串、数字、布尔值(true或 fa lse)、数组、null或其他有效的 JSON 对象。清单 4-12 中的展示了一个 JSON 对象,展示了所有这些。

清单 4-12 。样本 JSON

{
    "firstName": "John",
    "lastName": "Smith",
    "isAlive": true,
    "age": 25,
    "height_cm": 167.64,
    "address": {
        "streetAddress": "21 2nd Street",
        "city": "New York",
        "state": "NY",
    },
    "phoneNumbers": [
        { "type": "home", "number": "212 555-1234" },
        { "type": "fax", "number": "646 555-4567" }
    ],
    "additionalInfo": null
}

firstName值是字符串,age是数字,isAlive是布尔型,phoneNumbers是有效 JSON 对象的数组,additionalInfonulladdress是另一个有效 JSON 对象。这种类型限制的原因是为了简化协议。如果需要将任意 JavaScript 对象作为 JSON 传递,可以尝试将它们序列化/反序列化为一个字符串(常见于日期)或一个数字(常见于枚举)。

另一个限制是最后一个属性不能有多余的逗号。这也是因为旧的浏览器(例如,IE8)对什么是有效的 JavaScript 文字有限制。比如在清单 4-13 中,虽然第一个例子是 Node.js 和现代浏览器中有效的 JavaScript 对象文字,但它不是有效的 JSON。

清单 4-13 。最后一个值后的尾随命令

// Invalid JSON
{
    "foo": "123",
    "bar": "123",
}
// Valid JSON
{
    "foo": "123",
    "bar": "123"
}

重申一下,JSON 基本上只是 JavaScript 对象文字,有一些合理的限制,这些限制只是为了增加实现该规范的便利性,并有助于它作为数据传输协议的普及。

正在 Node.js 中加载 JSON

由于 JSON 是 web 如此重要的一部分,Node.js 已经完全接受它作为一种数据格式,甚至在本地也是如此。可以像加载 JavaScript 模块一样从本地文件系统加载 JSON 对象。每次在模块加载序列中,如果一个file.js未找到,Node.js 寻找一个file.json。如果找到了,它将返回一个表示 JSON 对象的 JavaScript 对象。让我们来看一个简单的例子。创建一个带有单键foo和字符串值的文件config.json(如清单 4-14 所示)。

清单 4-14 。json/filebased/config.js

{
    "foo": "this is the value for foo"
}

现在,让我们将这个文件作为 JavaScript 对象加载到app.js中,并注销键foo的值(如清单 4-15 所示)。

清单 4-15 。json/filebased/app.js

var config = require('./config');
console.log(config.foo); // this is the value for foo

加载 JSON 的简单性解释了为什么 Node.js 社区中如此多的库依赖于使用 JSON 文件作为配置机制。

JSON 全局

网络上的数据传输以字节的形式进行。要将内存中的 JavaScript 对象写到网络上或者保存到文件中,您需要一种方法将该对象转换成 JSON 字符串。JavaScript 中有一个名为JSON的全局对象,它提供了一些实用函数,用于将 JSON 的字符串表示转换为 JavaScript 对象,并将 JavaScript 对象转换为 JSON 字符串,以便通过网络发送或写入文件或进行其他任何操作。Node.js 和所有现代浏览器中都有这个JSON全局变量。

要将 JavaScript 对象转换成 JSON 字符串,只需调用JSON.stringify并将 JavaScript 对象作为参数传入。这个函数返回 JavaScript 对象的 JSON 字符串表示。要将 JSON 字符串转换成 JavaScript 对象,可以使用JSON.parse函数,它只是解析 JSON 字符串并返回一个与 JSON 字符串中包含的信息相匹配的 JavaScript 对象,如清单 4-16 和清单 4-17 所示。

清单 4-16 。json/convert/app.js

var foo = {
    a: 1,
    b: 'a string',
    c: true
};

// convert a JavaScript object to a string
var json = JSON.stringify(foo);
console.log(json);
console.log(typeof json); // string

// convert a JSON string to a JavaScript object
var backToJs = JSON.parse(json);
console.log(backToJs);
console.log(backToJs.a); // 1

清单 4-17 。app.js 的输出

$ node app.js
{"a":1,"b":"a string","c":true}
string
{ a: 1, b: 'a string', c: true }
1

对 JSON 及其与 JavaScript 对象文字的关系的初步理解,将有助于您成为一名成功的 Node.js 开发人员。

新公共管理理论

现在我们知道了如何使用 node_modules 创建可重用的模块。难题的下一部分回答了这个问题,“我如何获得社区与我共享的内容?”

答案:Node 包马槽,爱称 NPM 。如果你按照第一章中的规定安装 Node.js,它不仅在命令行中增加了node,还增加了npm,这只是一个与在线 NPM 注册表(www.npmjs.org/)集成的命令行工具。NPM 截图如图图 4-1 所示。

9781484201886_Fig04-01.jpg

图 4-1 。简单来说,NPM 是一种与社区共享 Node 模块的方式

package.json

NPM 生态系统不可或缺的一部分是一个简单的 JSON 文件,名为 package.json。这个文件对 NPM 有特殊的意义。当你想与世界分享你的模块时,正确地设置它是至关重要的,但是如果你正在使用其他人的模块,它也同样有用。要在当前文件夹中创建 package.json 文件,只需在命令行上运行清单 4-18 中的代码。

清单 4-18 。初始化 package.json 文件

$ npm init

这将询问您几个问题,例如模块的名称及其版本。我倾向于一直按回车直到结束。这将在当前文件夹中创建一个样板文件 package.json,其名称设置为当前文件夹,版本设置为 0.0.0,以及其他一些合理的缺省值,如清单 4-19 所示。

清单 4-19 。默认的 package.json

{
  "name": "foo",
  "version": "0.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

安装 NPM 包

让我们安装一个模块,例如,下划线(www.npmjs.org/package/underscore)到一个文件夹。要下载最新版本的下划线,只需运行清单 4-20 中的命令。

清单 4-20 。安装 NPM 模块

$ npm install underscore

这将从 npmjs.org 下载最新版本的下划线,并将其放入当前文件夹的node_modules/underscore中。要加载这个模块,您现在需要做的就是进行一个require('underscore')调用。这在清单 4-21 中有所展示,我们加载了下划线库,并简单地将数组的最小元素输出到控制台。

清单 4-21 。使用已安装的模块

// npm/install/app.js
var _ = require('underscore');
console.log(_.min([3, 1, 2])); // 1

我们将在本章的后面看一下下划线和其他流行的 NPM 包;然而,在这一点上的重点是 NPM 命令行工具。

保存依赖关系

无论何时运行npm install,你都有一个可选的命令行标志可用(--save,它告诉 NPM 将关于你安装了什么的信息写入 package.json,如清单 4-22 所示。

清单 4-22 。安装 NPM 模块并更新 package.json

$ npm install underscore --save

如果用–-save运行 install,它不仅会将underscore下载到 node_modules 中,还会更新 package.json 中的依赖项,指向已安装的下划线版本,如清单 4-23 所示。

清单 4-23 。package.json 的更新部分

"dependencies": {
    "underscore": "¹.6.0"
  }

以这种方式跟踪依赖关系有很多好处。首先,只需查看 package.json 就可以很容易地知道您使用的是哪个特定库的发布版本。只要打开他们的 package.json,看看他们所依赖的是什么。

刷新 node_modules 文件夹

要刷新 package.json 中的 node_modules 文件夹,可以运行以下命令:

$ npm install

这只是查看您的 package.json 文件,并下载您的 package.json 中指定的依赖项的新副本。

使用 package.json 的另一个优点是,您现在可以从您的源代码控制机制中排除 node_modules,因为您总是可以通过一个简单的npm install命令从 npmjs.org 获得一个副本。

列出所有依赖项

要查看你已经安装了哪些包,你可以运行npm ls命令,如清单 4-24 所示。

清单 4-24 。清单依赖关系

listing4-24.jpg

移除依赖关系

使用npm rm删除依赖关系。例如,npm rm underscore --save在本地从node_modules中删除下划线文件夹,并修改 package.json 的 dependencies 部分。该命令有一个直观的同义词npm uninstall,因为该命令在安装时是npm install

package.json 在线依赖跟踪

使用 package.json 进行依赖项跟踪的另一个好处是,如果在以后某个时候您决定与世界其他地方共享您的模块(即在npmjs.org共享),您不需要发送依赖项,因为您的用户可以在线下载它们。

如果你的 package.json 设置正确,并且他们安装了你的模块,NPM 会自动下载并安装你的模块的依赖项。看一个简单的例子,让我们安装一个有依赖关系的包(request),如清单 4-25 所示。

清单 4-25 。安装具有大量依赖项的模块

listing4-25.jpg

你可以看到 NPM 不仅安装了request,还下载了request所依赖的许多其他软件包。反过来,这些包中的每一个都可以依赖于其他包(例如,form-data依赖于asynccombined-stream),并且它们得到它们所依赖的包的自己的本地副本(并且将被下载到它们自己的 node_modules 文件夹中,例如,node_modules/request/node_modules/form-data/node_modules/async)。如前所述,由于 Node.js 中的require函数的工作方式,您可以安全地使用依赖于同一模块的不同版本的子模块,因为当使用 NPM 设置时,它们每个都有自己的副本。

语义版本控制

好的 Node.js 包/NPM 遵循语义版本化,这是一个行业标准,应该作为一个好的软件开发实践来遵循。语义学是对意义的研究。语义版本化 意味着以一种版本号具有重要意义的方式来版本化你的软件。关于语义版本化有很多可以说的,但下面是对 Node.js 开发人员的一个简单但实用的解释:

  • 简而言之,Node.js 开发人员遵循三位数版本控制方案 X.Y.Z,其中所有 X、Y 和 Z 都是非负整数。x 是主要版本,Y 是次要版本,Z 是补丁版本。
  • 如果引入了向后兼容的补丁,补丁版本必须递增。
  • 如果引入向后兼容的新功能,次要版本必须递增。
  • 如果引入了向后不兼容的修复/特性/变化,主版本必须递增。

记住这几点,您可以看到包的 1.5.0 版应该可以被 1.6.1 版就地替换,因为新版本应该是向后兼容的(主版本 1 也是如此)。这是好的包装所追求的。

然而,现实情况是,新版本有时不可避免地会引入错误,或者代码以包的最初作者没有预料到的方式被使用。在这种情况下,一些突破性的变化可能会不知不觉地被引入。

NPM / package.json 中的语义版本化

NPM 和 package.json 对语义版本化有很大的支持。你可以告诉 NPM 你想要哪个版本的软件包。例如,下面的代码安装下划线的确切版本 1.0.3:

$ npm install underscore@1.0.3

您可以使用代字号“~”告诉 NPM 您可以接受 1.0 的所有补丁版本:

$ npm install underscore@"~1.0.0"

接下来,要告诉 NPM 您可以接受任何微小的版本更改,请使用“^":

$ npm install underscore@"¹.0.0"

支持的其他版本字符串运算符包括“> =”和“>”,具有直观的数学意义,如“> =1.4.2”。同样的还有“<=” and “每次。

您也可以在 package.json 中使用这些语义版本字符串。例如,下面的package.json告诉 NPM,你的包兼容 1.6.0 版下划线的任何小升级:

"dependencies": {
   "underscore": "¹.6.0"
 }

更新依赖关系

每当您使用--save标志时,NPM 用于更新 package.json 依赖项部分的缺省值是“^”,前面是下载的版本。原因是您应该总是尝试使用主版本号没有改变的最新版本。通过这种方式,你可以免费获得任何新特性和最新的错误修复,并且不应该有任何突破性的变化。

例如,如果运行下面的命令,你会得到 package.json dependencies 部分:

$ npm install request@1.0.0 -save

以下是添加到 package.json 的默认版本字符串:

"dependencies": {
  "request": "¹.0.0"
}

然而 1.0.0 并不是最新发布的request版本。要找到与 package.json 中指定的当前语义版本兼容的最新在线版本(在本例中为¹.0.0),可以运行npm outdated,如清单 4-26 所示。

清单 4-26 。检查软件包的最新版本

$ npm outdated
npm http GET https://registry.npmjs.org/request
npm http 304 https://registry.npmjs.org/request
Package Current Wanted Latest Location
request 1.0.0 1.9.9 2.34.0 request

您可以看到与¹.0.0 兼容的最新版本是¹.9.9,这是基于我们的 package.json 中的语义字符串的想要的版本。它还向您显示有一个更新的版本可用。

要将这些包更新到最新的兼容版本,并将结果保存到您的package.json中(要将版本号与下载的相匹配),您可以简单地运行下面的命令。你更新的 package.json 显示在清单 4-27 中。

$ npm update -save

清单 4-27 。更新的 package.json

"dependencies": {
   "request": "¹.9.9"
}

了解 package.json 和命令npm installnpm rmnpm update--save NPM 标志的基本知识,以及对语义版本化的尊重,是您在项目中管理 NPM 包所需要了解的全部内容。

Global Node.js 包

在 Node.js 中制作命令行实用程序非常简单。如今学习 Node.js 的最常见动机之一是,许多前端项目的管理实用程序都是用 Node.js 编写的。有一些项目可以测试您的 web 前端,将 CoffeeScript 和 TypeScript 等各种新的编程语言编译成 JavaScript 和 Sass、stylus,以及 CSS,缩小您的 JavaScript 和 CSS 等等。jQuery、AngularJS、Ember.js、React 等流行的前端 JavaScript 项目都依赖 Node.js 脚本来管理自己的项目。

js 包的目标是提供命令行工具,你可以从命令行使用它。我们看到的所有 NPM 命令都带有一个可选的-g标志,表示您正在使用全局模块。

记得在第三章中,我们使用了一个实用程序 Browserify 将 Node.js 代码转换成浏览器兼容代码。Browserify 是我们全局安装的一个 Node.js 包(npm install -g browserify)。这将把 browserify 放到命令行上,我们在上一章中使用过。

同样,您可以更新全局软件包(npm update -g package-name)、列出全局软件包(npm ls -g)和卸载软件包(npm rm -g package-name)。例如,要卸载 Browserify,您可以运行npm rm -g browserify

在全局安装模块时,NPM 不会修改您的系统配置。命令行实用程序突然变得可用的原因是因为全局模块被放置在一个位置(例如,Mac OSX 上的/usr/local/bin和 Windows 上的用户漫游配置文件的 NPM 文件夹),在那里它们在命令行上变得可用。

对全局模块使用 require

全局安装的模块并不意味着在您的代码中使用require函数调用,尽管许多支持全局标志的包也支持在您的项目中本地安装(node_modules 文件夹)。如果在本地安装,也就是说,没有–g标志,你可以像我们已经看到的那样,通过require函数来使用它们。一个好的简单的例子是rimraf模块(www.npmjs.org/package/rimraf)。

如果rimraf是全局安装的(npm install -g rimraf),它提供了一个命令行实用程序,你可以使用它跨平台递归地、强制地删除一个目录(实际上是 Unix 命令行行话中的rm -rf)。要在全局安装rimraf后删除一个目录foo,只需运行rimraf foo

要从 Node.js 代码中做同样的事情,在本地(npm install rimraf)安装rimraf,创建一个如清单 4-28 所示的app.js,并运行它(node app.js)。

清单 4-28 。global/rimrafdemo/app.js

var rimraf = require('rimraf');
rimraf('./foo', function (err) {
    if (err) console.log('Error occured:', err);
    else console.log('Directory foo deleted!');
})

为了完整起见,值得一提的是,如果您设置了NODE_PATH环境变量,有一种从全局位置加载模块的方法。但是在使用模块时,?? 强烈反对这样做,你应该在本地使用依赖关系(package.json 和 node_modules)。

Package.json 和 require

我们看到的大多数 package.json 都是为 NPM 设计的。它所做的只是管理我们的依赖关系,并将它们放在 node_modules 中。从这一点开始,require以我们已经展示的方式工作。它在 node_modules 中查找与我们要求require加载的内容相匹配的 JavaScript 文件/文件夹,例如require('foo')中的foo。我们已经展示过,如果它解析到一个文件夹,Node.js 会尝试从那个文件夹加载index.js作为模块加载的结果。

关于require函数还有最后一件事你需要知道。您可以使用 package.json 来重定向require以从一个文件夹加载不同的文件,而不是默认文件(它会查找 index.js)。这是通过使用 package.json 中的main属性来完成的。该属性的值是要加载的 JavaScript 文件的路径。让我们看一个例子并创建一个目录结构,如清单 4-29 所示。

清单 4-29 。演示代码第四章/mainproperty 的项目结构

|-- app.js
|-- node_modules
          |-- foo
               |-- package.json
               |-- lib
                    |-- main.js

main.js是一个简单的文件,它记录到控制台以表明它已经被加载,如清单 4-30 所示。

清单 4-30 。主属性/Node 模块/foo/lib/main.js

console.log('foo main.js was loaded');

在 package.json 中,只需将 main 指向lib文件夹中的main.js:

{
    "main" : "./lib/main.js"
}

这意味着如果有人要访问require('foo'),Node.js 会查看 package.json,看到main属性,然后运行'./lib/main.js'。所以让我们在我们的app.js中要求这个模块。如果你运行它(node app.js,你会看到 main.js 确实被加载了。

require('foo');

值得一提的是,“main”是requirenode可执行文件所关心的唯一的属性。package.json 中的所有其他属性都是针对 NPM / npm可执行的,是专门为包管理设计的。

拥有这个“主”属性的好处是,它允许库开发人员完全自由地设计他们的项目,并保持结构清晰。

通常,人们会将简单的 Node.js 包(可以放在文件中的包)放入一个与包名packageName.js匹配的文件名中,然后创建一个 package.json 来指向该文件名。例如,这就是rimraf所做的——它有一个rimraf.js,这就是 package.json 的main属性所指向的,如清单 4-31 所示。

清单 4-31 。显示主要属性的 rimraf npm 模块中的 package.json

{
  "name": "rimraf",
  "version": "2.2.7",
  "main": "rimraf.js",

... truncated...

模块概述

在这一点上,似乎require有很多事情要做。确实如此,但是在我们看来,它都非常简单,这里有一个总结来证明你已经是 Node.js 模块专家了!假设你require('something')。那么接下来就是 Node.js 遵循的逻辑了:

  • 如果something是核心模块,返回。
  • 如果something是相对路径(以'开头)。/' , '../')返回文件或文件夹。
  • 如果没有,向上寻找每一层的node_modules/filenamenode_modules/foldername,直到找到与something匹配的文件或文件夹。

匹配文件或文件夹时,请按照下列步骤操作:

  • 如果它匹配一个文件名,返回它。
  • 如果它匹配一个文件夹名,并且 package.json 包含 main,则返回该文件。
  • 如果它匹配一个文件夹名并且有一个索引文件,则返回它。

当然,该文件可以是一个file.jsfile.json,因为 JSON 是 Node.js 中的第一个类!对于 JSON,我们返回解析后的 JSON,对于 JavaScript 文件,我们只需执行文件并返回'module.exports'。

这就是全部了。有了这些知识,你就可以打开查看npmjs.org和 Github 上成千上万的开源 Node.js 包。

流行的 Node.js 包

现在我们已经知道了使用 Node.js 包的所有重要细节,让我们来看看几个最流行的包。

下划线

下划线(npm install underscore)是目前 NPM 上最流行的 JavaScript 库。它是依赖项最多的库(依赖于此包的其他包)。

它被称为下划线,因为它在浏览器中使用时会创建一个全局变量' _ '。在 node 中,您可以随意命名从require('underscore')返回的变量,但是习惯上仍然使用var _ = require('underscore')

下划线为 JavaScript 提供了很多函数式编程支持,这在 Ruby 和 Python 等其他语言中也能找到。每个优秀的 JavaScript 开发人员都应该熟悉它。注意,在新版本中,下划线的一些功能被添加到核心 JavaScript 中,但是为了在所有浏览器和 Node.js 上工作,建议您使用下划线,如果只是为了一致性和减少认知负荷的话(这样您一次记住的东西就少了)。

假设我们有一个数组,我们只需要大于 100 的数组。用普通的旧 JavaScript 做这件事看起来很乏味,如清单 4-32 所示。

清单 4-32 。popular/下划线/filter/raw.js

var foo = [1, 10, 50, 200, 900, 90, 40];

var rawResults = []
for (i = 0; i < foo.length; i++) {
    if (foo[i] > 100) {
        rawResults.push(foo[i]);
    }
}
console.log(rawResults);

下划线中的相同代码更简单、更整洁。函数_.filter获取一个数组,将数组的每个元素传递给一个函数(第二个参数),并返回一个包含所有元素的数组,其中第二个函数返回true。这在清单 4-33 中有所展示。

清单 4-33 。popular/下划线/过滤器/us.js

var foo = [1, 10, 50, 200, 900, 90, 40];

var _ = require('underscore');
var results = _.filter(foo, function (item) { return item > 100 });
console.log(results);

在我们继续之前,我们将快速介绍一下函数式编程。函数式编程中的函数有明确定义的数学行为。如果输入相同,输出也将始终相同。这是函数的数学定义,而不是我们作为开发人员通常将术语函数联系在一起的编程构造。作为数学函数的一个简单例子,想想加法。如果foobar相同,那么foo+bar将永远相同。因此+就是我们所说的函数。类似地,JavaScript 函数function add(a,b){return a+b}是一个纯函数,因为输出只有依赖于输入。

纯函数易于理解、遵循,因此易于维护。阻止代码纯粹功能化的是状态。状态是通过变异(修改)对象来维护的。这就是我们在原始示例中所做的。我们正在一个循环中改变rawResults数组。这通常被称为一种强制性的编码或思考方式。但是,在下划线示例中,filter 函数接受两个参数,如果参数相同,结果将始终相同。因此,它是功能

同样,这样做的主要动机是可维护性。如果您知道filter是做什么的,那么从这一行就可以立即看出什么被过滤了。关于函数式编程还有很多可以说的,但是这应该已经足够让你发现更多了。

现在让我们看看下划线中的其他函数。_.map函数获取一个数组,为数组中存储返回值的每个元素调用一个函数作为结果,并返回一个包含所有结果的新数组。它通过一个函数将一个输入数组映射到一个输出数组。例如,假设我们要将数组中的每个元素乘以 2。我们可以使用_.map很简单地做到这一点,如清单 4-34 所示。

清单 4-34 。popular/下划线/map/app.js

// using underscore
var foo = [1, 2, 3, 4];

var _ = require('underscore');
var results = _.map(foo, function (item) { return item * 2 });
console.log(results);

集合中常见的另一个场景是获取除了符合条件的元素之外的所有元素。对此,我们可以使用_.reject。清单 4-35 中的显示了一个只获取数组中奇数元素的例子。

清单 4-35 。流行/下划线/拒绝/app.js

var _ = require('underscore');
var odds = _.reject([1, 2, 3, 4, 5, 6], function(num){ return num % 2 == 0; });
console.log(odds); // [1, 3, 5]

要获得数组的最大元素,请使用_.max,要获得最小元素,请使用_.min:

var _ = require('underscore');
var numbers = [10, 5, 100, 2, 1000];
console.log(_.min(numbers)); // 2
console.log(_.max(numbers)); // 1000

这足以让你开始。要了解关于下划线提供的功能的更多信息,请查看位于http://underscorejs.org/的在线文档。

处理命令行参数

我们在第三章的中看到了process.argv。这是一个简单的数组,将所有命令行参数传递给 Node 进程。我们在前一章中承诺,一旦我们了解了 NPM ,我们将会关注一个提供更好命令行处理的库。嗯,在这里。这叫乐观主义者。由于 NPM 上发布了大量的命令行工具,这是下载量最大的软件包之一。

一如既往,使用npm install optimist进行安装。它只是导出一个对象,该对象包含解析后的命令行参数作为argv属性。所以不用process.argv,你只用require('optimist').argv

说够了。我们编码吧。创建一个 JavaScript 文件,简单地记录处理过的参数,如清单 4-36 所示。

清单 4-36 。流行/乐观/app1.js

var argv = require('optimist').argv;
console.log(argv);

如果你现在运行这个,你会注意到类似于清单 4-37 中的输出。

清单 4-37 。popular/optimist/app1.js 的简单运行

$ node app.js
{ _: [],
  '$0': 'node /path/to/your/app.js' }

Optimist 将process.argv数组的前两个成员(分别是node可执行文件和 JavaScript 文件的路径)保留为'$0'。因为我们希望在这个演示中保持我们的输出清晰,所以让我们删除这个属性,这样我们就可以将所有其他内容记录到控制台。为此,修改你的代码,如清单 4-38 所示。

清单 4-38 。popular/optimist/app.js

var argv = require('optimist').argv;
delete argv['$0'];
console.log(argv);

现在,如果您运行该应用,您将获得以下输出:

$ node app.js
{ _: [] }

啊,好多了。属性argv._是所有命令行参数的数组,这些参数不是标志。标志是以减号'-'开头的参数,例如'-f'。让我们运行app.js并传入一组参数,如清单 4-39 所示。

清单 4-39 。使用非标志参数时显示输出

$ node app.js foo bar bas
{ _: [ 'foo', 'bar', 'bas'] }

作为一个用例,考虑一个实现删除文件实用程序的简单场景。如果需要,为了支持接受多个文件进行删除,所有这些文件都将放在'argv._'属性中。

如果我们想支持强制删除(-f)这样的标志,乐观主义者完全支持。您传入的任何简单标志都将成为值设置为trueargv的属性。例如,如果你想检查标志f是否被设置,只需检查argv.f是否真实。乐观主义者甚至支持一些漂亮的快捷方式,如清单 4-40 所示。

清单 4-40 。使用标志时显示输出

$ node app.js -r -f -s
{ _: [], r: true, f: true, s: true }

$ node app.js -rfs
{ _: [], r: true, f: true, s: true }

乐观主义者也支持接受值的标志,比如说,如果你想接受一个超时标志(-t 100)。乐观主义者支持它们,就像支持简单的标志一样。匹配标志名的属性设置在argv(本例中为argv.t)上,值设置为用户传递的值(本例中为100),如清单 4-41 所示。

清单 4-41 。使用带有值的标志时显示输出

$ node app.js -t 100
{ _: [], t: 100 }

$ node app.js -t "la la la la"
{ _: [], t: 'la la la la' }

如您所见,无需任何配置,开箱即可完成大量处理工作。对于大多数需要支持简单标志的情况,这就足够了。

Optimist 还有许多其他选项,允许进行高级配置,例如强制用户传入参数,强制参数为布尔值,列出配置中支持的所有命令行参数,并提供默认参数值。不管你的命令行处理使用什么case,NPM/乐观主义者已经覆盖了你,你肯定应该进一步探索它。

使用时刻处理日期/时间

内置的 JavaScript Date类型相当有限。这对于简单的情况已经足够好了,例如,您可以通过简单的构造函数调用来创建表示当前时间的日期。还有一个构造函数,允许您以想要的分辨率创建日期,例如年、月、日、小时、分钟、秒和毫秒。关于 JavaScript 日期需要注意的一点是,月份是基于 0 索引的。所以一月是 0,二月是 1,以此类推。您可以在清单 4-42 中看到一些创建的日期。

清单 4-42 。popular/moment/rawdate.js

// Now
var now = new Date();
console.log('now is:', now);

// get sections of time
var milliseconds = now.getMilliseconds();
var seconds = now.getSeconds();
var hours = now.getHours();
var minutes = now.getMinutes();
var date = now.getDate();
var month = now.getMonth();
var year = now.getFullYear();

// detailed constructor for a date
var dateCopy = new Date(year, month, date,
                                hours, minutes, seconds, milliseconds);
console.log('copy is:', dateCopy);

// Other dates
// year, month, date
console.log('1 jan 2014:', new Date(2014, 0, 1));
// year, month, date, hour
console.log('1 jan 2014 9am', new Date(2014, 0, 1, 9));

除了 moment ( npm install moment)提供的 JavaScript 基本特性集Date之外,还有很多特性。其核心是,moment 提供了一个函数,可以用来将一个 JavaScript 日期对象包装成一个moment对象。创建力矩对象有很多种方法。最简单的方法是简单地传入一个日期对象。相反,要将 moment 对象转换成 JavaScript 日期,只需调用toDate成员函数。这在清单 4-43 中进行了演示。

清单 4-43 。popular/moment/wrapping.js

var moment = require('moment');

// From date to moment
var wrapped = moment(new Date());
console.log(wrapped);

// From moment to date
var date = wrapped.toDate();
console.log(date);

Moment 提供可靠的字符串解析。解析字符串的结果是一个包装的矩对象。这显示在清单 4-44 中。为了解开包装,我们简单地调用toDate,正如我们已经在清单 4-43 中看到的。

清单 4-44 。popular/moment/parsing.js

var moment = require('moment');

// From string to date
console.log(moment("12-25-1995", "MM-DD-YYYY").toDate());
console.log(moment("2010-10-20 4:30", "YYYY-MM-DD HH:mm").toDate());

moment 提供的另一个伟大特性是日期格式支持(即日期到字符串的转换)。清单 4-45 给出了几个例子。

清单 4-45 。popular/moment/formating . js

var moment = require('moment');

var date = new Date(2010, 1, 14, 15, 25, 50);
var wrapped = moment(date);

// "Sunday, February 14th 2010, 3:25:50 pm"
console.log(wrapped.format('"dddd, MMMM Do YYYY, h:mm:ss a"'));

// "Sun, 3PM"
console.log(wrapped.format("ddd, hA"));

在格式化方面,moment.js提供了很多功能。您甚至可以获得友好的值,如“6 小时后”、“明天上午 9:40”和“上周日晚上 9:40”,如清单 4-46 所示。

清单 4-46 。popular/moment/timeago.js

var moment = require('moment');

var a = moment([2007, 0, 15]); // 15 Jan 2007
var b = moment([2007, 0, 16]); // 16 Jan 2007
var c = moment([2007, 1, 15]); // 15 Feb 2007
var d = moment([2008, 0, 15]); // 15 Jan 2008

console.log(a.from(b)); // "a day ago"
console.log(a.from(c)); // "a month ago"
console.log(a.from(d)); // "a year ago"

console.log(b.from(a)); // "in a day"
console.log(c.from(a)); // "in a month"
console.log(d.from(a)); // "in a year"

moment 提供了许多额外的好东西,希望你现在看到了探索更多的动机,并理解了如何使用它们。

序列化日期

因为我们正在讨论日期,所以让我们讨论一个在序列化日期以保存到 JSON 文件或通过网络发送 JSON 时可以遵循的良好实践。当我们在前面讨论 JSON 时,您可能已经注意到Date不被支持为有效的 JSON 值类型。通过网络传递数据有多种方式,但最简单的是以字符串形式发送。

特定日期字符串就其实际日期值而言的含义因本地区域性而异(例如,月前日期或月前日期),因此最好遵循全球标准。ISO8601 标准特别涉及如何将特定日期表示为字符串。

ISO8601 支持各种格式,但是 JavaScript 本身支持的格式类似于2014-05-08T17:35:16Z,其中日期和时间用相对于 UTC 的同一个字符串表示。因为它总是相对于 UTC,所以与用户时区无关。这是一件好事,因为用户可能与服务器不在同一个时区,而 UTC 是全球时间参考。

如果我们在 JavaScript date 上调用toJSON方法,我们得到的是 ISO8601 格式的字符串。类似地,将这个字符串传递给 JavaScript 日期构造函数会给我们一个新的 JavaScript 日期对象,如清单 4-47 所示。

清单 4-47 。流行/时刻/json.js

var date = new Date(Date.UTC(2007, 0, 1));

console.log('Original', date);

// To JSON
var jsonString = date.toJSON();
console.log(jsonString); // 2007-01-01T00:00:00.000Z

// From JSON
console.log('Round Tripped',new Date(jsonString));

这种支持也在瞬间延续。如果您在一个包装的 moment 对象上调用.toJSON,您会得到与在原始 date 对象上相同的结果。这允许您安全地序列化将日期或时刻对象作为值的对象。

最后值得一提的是,如果任何对象(不仅仅是日期)有一个toJSON方法,那么当JSON.stringify试图将它序列化为 JSON 时,它将被调用。因此,如果我们愿意的话,我们可以用它来定制任何JavaScript 对象的序列化。这在清单 4-48 中的一个简单例子中显示。

清单 4-48 。popular/moment/tojson.js

var foo = {};
var bar = { 'foo': foo };

// Uncustomized serialization
console.log(JSON.stringify(bar)); // {"foo":{}}

// Customize serialization
foo.toJSON = function () { return "custom" };
console.log(JSON.stringify(bar)); // {"foo":"custom"}

自定义控制台颜色

在处理大型 Node.js 项目时,出于监控目的,控制台上会记录相当多的信息,这种情况并不少见。随着时间的推移,这个简单的输出开始变得乏味,这是另一个你需要管理复杂性的地方。语法突出显示有助于管理代码复杂性。颜色包 ( npm install colors)给你的控制台输出带来了类似的好处,使它更容易跟踪正在发生的事情。它也是使用最多的 NPM 软件包之一(每天近 50,000 次下载)。

colors 提供的 API 极其简单。它将函数添加到本地 JavaScript 字符串中,以便您可以执行诸如"some string".red之类的操作,如果您打印这个字符串,它将在控制台上显示为红色。清单 4-49 中显示了所使用的各种选项和输出的一个小样本。

清单 4-49 。popular/colors/1basic.js

// Loading this module modifies String for the entire process
require('colors');

console.log('hello'.green); // outputs green text
console.log('world!'.red); // outputs red text
console.log('Feeling yellow'.yellow); // outputs yellow text
console.log('But you look blue'.blue); // outputs yellow text
console.log('This should cheer you up!'.rainbow); // rainbow

9781484201886_unFig04-01.jpg

用法真的很简单。除了将这种能力带到你的指尖的明显优势之外,我们向你展示这个包的原因是为了进一步定制 JavaScript 内部。让我们看看这个包实际上是如何实现的。在这个过程中,我们将重温原型(我们在第二章中讨论过的一个主题)并了解 JavaScript 属性 getters 和 setters。

它是如何工作的?

这个 API 有两个方面:

  • 如何在控制台上打印颜色
  • 如何修改 JavaScript 字符串并向其添加函数

大多数使用 ANSI 转义码 的控制台(windows 和 UNIX)都支持以特定颜色打印字符串。如果您打印这些代码中的一个,控制台的行为就会改变。创建一个简单的 JavaScript 文件,打印由一些代码包围的 JavaScript 字符串,如清单 4-50 所示。如果您运行它,您将看到控制台记录了一个红色字符串。

清单 4-50 。popular/colors/2raw.js

function getRed(str) {
    // Changes the console foreground to red
    var redCode = '\x1b31m';

    // Resets the console foreground
    var clearCode = '\x1b[39m';

    return redCode + str + clearCode;
}

console.log(getRed('Hello World!'));

这是对我们如何修改控制台行为的充分理解。阅读终端文档并找到匹配的颜色代码是一件简单的事情。作为 JavaScript 开发人员,我们更感兴趣的问题是,“我怎样才能给所有字符串添加成员函数?”

在第二章的[中,我们讨论了当你用 new 操作符创建一个对象时,函数的prototype如何被复制到创建的实例的__proto__成员中。因为它是一个引用,如果你给原始函数prototype添加一个属性,所有使用这个函数创建的对象实例都将获得新的属性。

幸运的是,JavaScript 中的所有本机类型(日期、字符串、数组、数字等等)都是由与类型名称匹配的函数创建的。因此,如果我们向这些函数的原型添加一个成员,我们就可以成功地扩展这些类型的所有实例。清单 4-51 提供了一个简单的例子来演示这个原则,我们给所有的ArraysNumbersStrings添加了一个属性foo

清单 4-51 。popular/colors/3 prototypeintrop . js

Array.prototype.foo = 123;
Number.prototype.foo = 123;
String.prototype.foo = 123;

var arr = [];
var str = '';
var num = 1;

console.log(arr.foo); // 123
console.log(str.foo); // 123
console.log(num.foo); // 123

要给字符串添加一个函数,添加到String.prototyp,如清单 4-52 所示。

清单 4-52 。popular/colors/4addFunction.js

String.prototype.red = function (str) {
    // Changes the console foreground to red
    var redCode = '\x1b31m';

    // Resets the console foreground
    var clearCode = '\x1b[39m';

    return redCode + this + clearCode;
}

console.log('Hello World!'.red());

但是,请注意,在这个例子中,我们在字符串上调用了一个函数,即'Hello World!'.red(),而当我们使用颜色时,我们只是简单地调用了'Hello World!'.red。也就是说,有了颜色,我们就不需要“call()”这个成员了。这是因为颜色将red定义为属性获取器而不是函数

属性 getter/setter 只是一种插入 JavaScript 的 getter/read 值(例如,foo.bar)和 setter/set 值(例如,foo.bar = 123)语义的方法。添加 getter/setter 的一个简单方法是使用所有 JavaScript 对象上都有的__defineGetter__ / __defineSetter__成员函数 。清单 4-53 给出了一个简单的例子来演示这种用法。

[清单 4-53 。popular/colors/5 property intro . js

var foo = {};

foo.__defineGetter__('bar', function () {
    console.log('get bar was called!');
});

foo.__defineSetter__('bar', function (val) {
    console.log('set bar was called with value:',val);
});

// get
foo.bar;
// set
foo.bar = 'something';

所以,最后要在所有字符串上添加'.red'属性,我们只需要将它添加到String.prototype 中,如清单 4-54 所示。

清单 4-54 。popular/colors/6addProperty.js

String.prototype.__defineGetter__('red', function (str) {
    // Changes the console foreground to red
    var redCode = '\x1b[31m';

    // Resets the console foreground
    var clearCode = '\x1b[39m';

    return redCode + this + clearCode;
});

console.log('Hello World!'.red);

至少,您现在对 JavaScript 语言有了更深的理解,并能更好地理解它的成功。在向你们展示了所有这些力量之后,我们给出一个必须的警告。正如我们以前说过的,全球状态是糟糕和不直观的。因此,如果您开始以不受控制的方式(在各种不同的文件中)向这些本机类型(字符串、数字、数组等)添加成员,下一个人将很难理解这种功能来自哪里。将这种能力保留给专门为扩展内置类型而设计的模块,并确保记录下来!还要注意不要覆盖任何现有的或本地的 JavaScript 行为,因为其他库可能依赖于它!

额外资源

NPM 在线注册:http://npmjs.org/

语义版本化官方指南:http://semver.org/

NPM 语义版本解析器:https://github.com/isaacs/node-semver

摘要

在本章中,我们讨论了 Node.js 模块系统剩余的复杂性。在这个过程中,我们展示了为什么模块系统需要以这种方式工作的优势。我们认为最大的优势是没有困扰许多其他环境的依赖地狱问题,在这种情况下,模块不兼容会阻止您使用依赖于第三个模块的不同版本的两个模块。

我们展示了 NPM 是如何工作的。它只是一种管理 Node.js 社区共享的基于 node_modules 的模块的方法。我们浏览了 NPM 提供的重要命令行选项来管理您使用的社区包。

您还了解了 JSON 和语义版本。这两条信息对于所有开发人员(不仅仅是 Node.js 开发人员)都是至关重要的信息。

最后,我们展示了一些重要的 Node.js 包,以及您可以从中吸取的经验教训。这些应该有助于让你成为世界级的 Node.js 和 JavaScript 开发人员,你应该不怕打开 node_modules 文件夹,看看是什么让你喜欢的库打勾

五、事件和流

在我们研究 Node.js 开发的具体领域之前,我们需要解决一些关于 JavaScript 的核心概念,特别是 Node.js。Node.js 致力于成为创建服务器应用的最佳、最简单的方式。事件和流在实现这个目标的过程中扮演着重要的角色。

Node.js 是单线程;我们已经讨论了这个事实的优点。由于 Node.js 的事件性质,它对事件订阅/取消订阅模式提供了一流的支持。这种模式非常类似于您在浏览器中使用 JavaScript 处理事件的方式。

流数据是非常适合 Node.js 的领域之一。流对于改善用户体验和降低服务器资源利用率非常有用。

为了理解我们如何创建自己的事件发射器和流,我们首先需要理解 JavaScript 继承。

JavaScript 中的经典继承

我们在第二章中看到了原型是如何工作的。JavaScript 支持原型继承。在 JavaScript 中,在当前项上查找一个成员(比如item.foo),然后是它的原型(item.__proto__.foo),接着是它的原型的原型(item.__proto__.__proto__.foo),依此类推,直到原型本身(比如item.__proto__.__proto__.__proto__)是null。我们已经看到了如何用 JavaScript 来模拟一个经典的面向对象的结构。现在让我们看看如何用它来实现传统的面向对象的继承。

达成继承模式

让我们创建一个动物类。它有一个简单的成员函数,名为walk。我们已经讨论过,当使用new操作符(例如new Animal)调用函数时,函数中的``this'指的是新创建的对象。我们还讨论了由于使用了new操作符,构造函数的原型成员(Animal.prototype)被对象原型(animal.proto`)引用。(参见清单 5-1 )。

清单 5-1 。oo/1animal.js

function Animal(name) {
    this.name = name;
}
Animal.prototype.walk = function (destination) {
    console.log(this.name, 'is walking to', destination);
};

var animal = new Animal('elephant');
animal.walk('melbourne'); // elephant is walking to melbourne

为了更好地理解“??”上的查找是如何进行的,请看一下图 5-1 中的图表。

9781484201886_Fig05-01.jpg

图 5-1 。从原型中查找成员的示例

现在让我们在一个新的类中继承所有的Animal类功能 ,例如Bird。为此,我们需要做两件事:

  • Bird构造函数中调用Animal构造函数。这确保了为Bird对象(我们示例中的Animal.name)正确设置属性。
  • 想办法让所有父(Animal)原型成员(例如,__proto__.walk)成为子(Bird)实例原型的原型的成员。这将允许Bird实例(例如,bird)在它们自己的原型(bird.__proto__.fly)上拥有它们自己的函数,在它们原型的原型(bird.__proto__.__proto__.walk)上拥有它们的父成员。这叫做建立一个原型链

我们将从充实Bird类开始。基于算法,它将看起来像清单 5-2 中的代码。

清单 5-2 。为继承做准备

function Bird(name){
    // Call the Animal constructor
}
// Setup the prototype chain between Bird and Animal

// Finally create child instance
var bird = new Bird('sparrow');

调用父构造函数

我们不能简单地从Bird调用父Animal构造函数。这是因为如果我们这样做,那么Animal中的“??”将不会引用新创建的Bird对象(从new Bird创建)。因此,我们需要将Animal函数中this的含义指向Bird函数中this的值。幸运的是,我们可以通过使用所有 JavaScript 函数上可用的'.call'成员函数来强制解释含义(它来自Function.prototype)。清单 5-3 展示了call成员。像往常一样,评论解释了正在发生的事情。

清单 5-3 。oo/2call.js

var foo = {};
var bar = {};

// A function that uses `this`
function func(val) {
    this.val = val;
}

// Force this in func to be foo
func.call(foo, 123);

// Force this in func to be bar
func.call(bar, 456);

// Verify:
console.log(foo.val); // 123
console.log(bar.val); // 456

你可以看到我们将func`'函数中的this'强制为foo,然后是bar。太好了。现在我们知道了如何强制this`,让我们用它来调用父 Node,如清单 5-4 所示。

清单 5-4 。调用父构造函数

function Bird(name){
    Animal.call(this,name);

    // Any additional initialization code you want
}
// Copy all Animal prototype members to Bird

每次需要调用父构造函数时,都要使用这种模式(Parent.call(this, /* additional args */))。现在你对为什么会这样有了一个明确的功能理解。

设置原型链

我们需要一种机制,这样当我们创建一个新的Bird(比如,bird = new Bird)时,它的原型链就包含了所有的父原型函数(比如,bird.__proto__.__proto__.walk)。如果我们做Bird.prototype.__proto__ = Animal.prototype,这可以很简单地完成。

这个过程之所以有效,是因为当我们执行bird = new Bird时,我们将有效地获得bird.__proto__.__proto__ = Animal.prototype,这将使父原型成员(例如,Animal.prototype.walk)在子原型(bird.__proto__.__proto__.walk))上可用,这是我们想要的结果。清单 5-5 显示了一个简单的代码样本。

清单 5-5 。oo/3 协议类型. js

// Animal Base class
function Animal(name) {
    this.name = name;
}
Animal.prototype.walk = function (destination) {
    console.log(this.name, 'is walking to', destination);
};

var animal = new Animal('elephant');
animal.walk('melbourne'); // elephant is walking to melbourne

// Bird Child class
function Bird(name) {
    Animal.call(this, name);
}
Bird.prototype.__proto__ = Animal.prototype;
Bird.prototype.fly = function (destination) {
    console.log(this.name, 'is flying to', destination);
}

var bird = new Bird('sparrow');
bird.walk('sydney'); // sparrow is walking to sydney
bird.fly('melbourne'); // sparrow is flying to melbourne

为了理解继承成员(在我们的例子中是bird.walk)的查找是如何执行的,请看一下图 5-2 。

9781484201886_Fig05-02.jpg

图 5-2 。从原型链中查找成员的示例

注意手动修改__proto__属性是不推荐的,因为它不是 ECMAScript 标准的一部分。我们稍后将讨论设置原型的更标准的方法,但是这里展示的原理会让您成为 JavaScript 原型继承的专家。

*构造函数属性

默认情况下,每个函数都有一个名为“prototype”的成员,我们已经看到了。默认情况下,这个成员有一个指向函数本身的constructor属性。清单 5-6 演示了这一点。

清单 5-6 。oo/4 构造器/1basic.js

function Foo() { }
console.log(Foo.prototype.constructor === Foo); // true

有这个属性有什么好处?在使用一个函数(例如,instance = new Foo)创建了一个实例之后,您可以使用一个简单的查找instance.constructor(实际上是查看instance.__proto__.constructor)来访问构造函数。清单 5-7 在一个例子中展示了这一点,在这个例子中,我们使用命名函数的属性name(function Foo)来记录是什么创建了这个对象。

清单 5-7 。oo/4constructor/2new.js

function Foo() { }

var foo = new Foo();
console.log(foo.constructor.name); // Foo
console.log(foo.constructor === Foo); // true

了解构造函数属性使您能够在需要时对实例进行运行时反射。

正确的 Node.js 方式

我们在第三章中讨论的util核心模块 ( require('utils'))提供了一个可爱的小函数来为我们创建原型链,这样你就不需要自己处理__proto__(非标准属性)了。该函数名为 ],接受一个子类,后跟一个父类,如清单 5-8 中的示例所示。Bird类扩展了我们前面看到的Animal`类。

清单 5-8 。oo/5nodejs/util.js

// util function
var inherits = require('util').inherits;

// Bird Child class
function Bird(name) {
    // Call parent constructor
    Animal.call(this, name);

    // Additional construction code
}
inherits(Bird, Animal);

// Additional member functions
Bird.prototype.fly = function (destination) {
    console.log(this.name, 'is flying to', destination);
}

var bird = new Bird('sparrow');
bird.walk('sydney'); // sparrow is walking to sydney
bird.fly('melbourne'); // sparrow is flying to melbourne

有两件事值得注意:

  • 调用父构造函数:Animal.call(this, /* any original arguments */)
  • 设置原型链:inherits(Bird, Animal);

简单到成为第二天性,这就是你继承类所需要做的一切!

覆盖子类中的函数

要覆盖父函数但仍利用一些原始功能,只需执行以下操作:

  • 在子 Nodeprototype上创建一个同名的函数。
  • 调用父函数的方式类似于我们调用父构造函数的方式,基本上是使用Parent.prototype.memberfunction.call(this, /*any original args*/)语法。

清单 5-9 展示了这一点。

清单 5-9 。oo/6override.js

// util function
var inherits = require('util').inherits;

// Base
function Base() { this.message = "message"; };
Base.prototype.foo = function () { return this.message + " base foo" };

// Child
function Child() { Base.call(this); };
inherits(Child, Base);

// Overide parent behaviour in child
Child.prototype.foo = function () {
    // Call base implementation + customize
    return Base.prototype.foo.call(this) + " child foo";
}

// Test:
var child = new Child();
console.log(child.foo()); // message base foo child foo

我们简单地创建了子函数Child.prototype.foo并在Base.prototype.foo.call(this).中调用父函数

检查继承链

正如我们所看到的,建立一个原型链(__proto__.__proto__)有一个额外的好处,它允许你检查一个特定的对象实例是否属于一个特定的类,或者它的父类,或者它的父类,等等。这是使用instanceof操作符完成的。

在伪代码中当你做someObj instanceof Func时你使用这个算法:

  • 检查someObj.__proto__ == Func.prototype,如果是,返回true
  • 如果不是,检查someObj.__proto__.__proto__ == Func.prototype,如果是,返回true
  • 重复向上移动原型链。
  • 如果__proto__null并且我们还没有找到匹配,返回false

从伪代码中,您可以看到它非常类似于如何执行属性查找。我们沿着原型链向上走,直到找到一个等于 ?? 的 ??。当new操作符将prototype复制到__proto__时,找到匹配表示new操作符正在指定的Func上使用。使用instanceof的快速演示如清单 5-10 所示。

清单 5-10 。oo/7instanceof.js

var inherits = require('util').inherits;

function A() { }
function B() { }; inherits(B, A);
function C() { }

var b = new B();
console.log(b instanceof B); // true because b.__proto__ == B.prototype
console.log(b instanceof A); // true because b.__proto__.__proto__ == A.prototype
console.log(b instanceof C); // false

对 util.inherits 内部的更深入的理解

你不需要通过来了解这一部分,但这是值得的,因为你可以坐在酷孩子的桌子旁。我们说过不推荐手动设置__proto__,因为它不是标准化 JavaScript 的一部分。

幸运的是,JavaScript 中有一个函数可以创建一个已经设置了指定的__proto__的空白对象。该功能被称为Object. create ,其工作方式如清单 5-11 所示。

清单 5-11 。oo/8internals/1check.js

var foo = {};
var bar = Object.create(foo);
console.log(bar.__proto__ === foo); // true

在这个例子中,我们简单地验证了新创建的对象(即,bar)的__proto__成员被设置为我们传递给Object.create的成员(换句话说,foo)。它可以用于继承,如清单 5-12 所示。

清单 5-12 。oo/8internals/2inherit.js

// Animal Base class
function Animal() {
}
Animal.prototype.walk = function () {
    console.log('walking');
};

// Bird Child class
function Bird() {
}
Bird.prototype = Object.create(Animal.prototype);

var bird = new Bird();
bird.walk();

与我们之前展示的原始非标准__proto__机制相比,这里我们简单地将Bird.prototype.__proto__ = Animal.prototype替换为有效的Bird.prototype = { __proto__ : Animal.prototype }

这种机制正确地继承了父类的成员,但是它产生了一个小问题。当我们重新分配Bird.prototype时,Bird.prototype.constructor中的constructor信息丢失了,因为我们将Bird.prototype重新分配给了一个全新的对象。要恢复constructor属性,一个简单的解决方案是向Object. create传递第二个参数,该参数指定要添加到要创建的对象的附加属性。在清单 5-13 中,我们指定constructor是一个指向函数本身的属性,这就是Bird.prototype.constructor最初的样子(记住Bird.prototype.constructor === Bird)。

清单 5-13 。oo/8 internals/3 inherit better . js

// Animal Base class
function Animal() {
}
Animal.prototype.walk = function () {
    console.log('walking');
};

// Bird Child class
function Bird() {
}
Bird.prototype = Object.create(Animal.prototype, {
    constructor: {
        value: Bird,
        enumerable: false,
        writable: true,
        configurable: true
    }
});

var bird = new Bird();
bird.walk();
console.log(bird.constructor === Bird); // true

这正是 Node.js util 模块中的实现(用 JavaScript 编写)。清单 5-14 中的显示了直接来自源代码的实现。

清单 5-14 。从 Node.js 源 util.js 检索的代码

exports.inherits = function(ctor, superCtor) {
  ctor.super_ = superCtor;
  ctor.prototype = Object.create(superCtor.prototype, {
    constructor: {
      value: ctor,
      enumerable: false,
      writable: true,
      configurable: true
    }
  });
};

inherits函数做的另一件事是向子类添加一个属性super_,该属性指向父类。这只是约定俗成,这样你就知道在调试或编写基于反射的代码时,这个子函数原型已经从这个super_类接收了成员。

掌握继承非常复杂,因为 JavaScript 是用简单的原型继承设计的。我们只是利用它提供的能力来模仿传统的 OO 层次结构。

Node.js 事件

我们已经有了一种使用回调基于某些事件执行某些代码的方法。处理重要事件的更一般的概念是事件。事件就像广播,而回调就像握手。引发事件的组件对其客户端一无所知,而使用回调的组件却知道很多。这使得事件非常适合于事件的重要性由客户端决定的场景。也许客户想知道,也许不想。注册多个客户端也更加简单,正如我们将在本节中看到的那样。

Node.js 内置了对核心events模块中事件的支持。像往常一样,使用require('events')加载模块。事件模块有一个简单的类“EventEmitter”,我们接下来会介绍它。

EventEmitter 类

EventEmitter是一个被设计用来使发出事件(这并不奇怪)和订阅引发的事件变得容易的类。清单 5-15 提供了一个小代码示例,我们订阅一个事件,然后引发它。

清单 5-15 。events/1basic.js

var EventEmitter = require('events').EventEmitter;

var emitter = new EventEmitter();

// Subscribe
emitter.on('foo', function (arg1, arg2) {
    console.log('Foo raised, Args:', arg1, arg2);
});

// Emit
emitter.emit('foo', { a: 123 }, { b: 456 });

如示例所示,您可以通过一个简单的new EventEmitter`'调用来创建一个新实例。要订阅事件,可以使用on'函数传入事件名称(总是一个字符串),后跟事件处理函数(也称为*监听器*)。最后,我们使用emit`函数引发一个事件,该函数传入事件名,后跟我们希望传入监听器的任意数量的参数(在清单 5-15 中,我们使用了两个参数进行演示)。

多个用户

正如我们之前提到的,使用事件的优势之一是为多个订阅者提供内置的支持。清单 5-16 是一个简单的例子,一个事件有多个订阅者。

清单 5-16 。events/2multiple.js

var EventEmitter = require('events').EventEmitter;
var emitter = new EventEmitter();

emitter.on('foo', function () {
    console.log('subscriber 1');
});

emitter.on('foo', function () {
    console.log('subscriber 2');
});

// Emit
emitter.emit('foo');

在这个例子中需要注意的另一件事是,侦听器是按照它们为事件注册的顺序被调用的。这是 Node.js 单线程特性的一个很好的结果,它使您更容易对代码进行推理。此外,为事件传递的任何参数都在不同的订阅者之间共享,如清单 5-17 所示。

清单 5-17 。事件/3shared.js

var EventEmitter = require('events').EventEmitter;
var emitter = new EventEmitter();

emitter.on('foo', function (ev) {
    console.log('subscriber 1:', ev);
    ev.handled = true;
});

emitter.on('foo', function (ev) {
    if (ev.handled) {
        console.log('event already handled');
    }
});

// Emit
emitter.emit('foo', { handled: false });

在这个示例中,第一个侦听器修改了传递的事件参数,第二个侦听器获得了修改后的对象。你可以潜在地利用这个事实让你摆脱一个棘手的局面,但我要高度警惕这一点。显示事件参数共享的原因是为了警告您在侦听器中直接修改事件对象的危险。

注销

下一个要问的问题是我们如何退订一个事件。EventEmitter有一个removeListener函数,它接受一个事件名,后面跟着一个函数对象,以便从监听队列中删除。需要注意的一点是,您必须有一个对要从监听队列中移除的函数的引用,因此,不应该使用匿名(内联)函数。这是因为如果 JavaScript 中的两个函数体相同,它们就不相等,如下面的清单 5-18 所示,因为这是两个不同的函数对象。

清单 5-18 。演示函数不等式的示例

$ node -e "console.log(function(){} == function(){})"
false

清单 5-19 展示了如何取消订阅一个监听器。

清单 5-19 。events/4unsubscribe.js

var EventEmitter = require('events').EventEmitter;
var emitter = new EventEmitter();

var fooHandler = function () {
    console.log('handler called');

    // Unsubscribe
    emitter.removeListener('foo',fooHandler);
};

emitter.on('foo', fooHandler);

// Emit twice
emitter.emit('foo');
emitter.emit('foo');

在此示例中,我们在事件引发一次后取消订阅该事件。结果,第二个事件被忽略了。

是否引发过此事件?

这是一个常见的用例,您并不关心事件是否每次都被引发——只关心它被引发一次。为此,EventEmitter提供了一个函数“once”,它只调用注册的监听器一次。清单 5-20 演示了它的用法。

清单 5-20 。events/5once.js

var EventEmitter = require('events').EventEmitter;
var emitter = new EventEmitter();

emitter.once('foo', function () {
    console.log('foo has been raised');
});

// Emit twice
emitter.emit('foo');
emitter.emit('foo');

foo的事件监听器只会被调用一次。

听众管理

作为 Node.js 事件处理专家,您需要了解在EventEmitter上提供的一些额外的实用函数。

EventEmitter有一个成员函数listeners,它接受一个事件名并返回订阅该事件的所有侦听器。这在调试事件侦听器时非常有用。清单 5-21 演示了它的用法。

清单 5-21 。events/6listeners.js

var EventEmitter = require('events').EventEmitter;
var emitter = new EventEmitter();

emitter.on('foo', function a() { });
emitter.on('foo', function b() { });

console.log(emitter.listeners('foo')); // [ [Function: a], [Function: b]]

EventEmitter实例还会在添加新的侦听器时引发“newListener”事件,在删除侦听器时引发“removeListener”事件,这在一些棘手的情况下会有所帮助,比如当您想要跟踪事件侦听器注册/取消注册的时刻。当添加或删除监听器时,它对您想要做的任何管理都很有用,如清单 5-22 中的所示。

清单 5-22 。events/7listenerevents.js

var EventEmitter = require('events').EventEmitter;
var emitter = new EventEmitter();

// Listener addition / removal notifications
emitter.on('removeListener', function (eventName, listenerFunction) {
    console.log(eventName, 'listener removed', listenerFunction.name);
});
emitter.on('newListener', function (eventName, listenerFunction) {
    console.log(eventName, 'listener added', listenerFunction.name);
});

function a() { }
function b() { }

// Add
emitter.on('foo', a);
emitter.on('foo', b);

// Remove
emitter.removeListener('foo', a);
emitter.removeListener('foo', b);

请注意,如果您在添加了“newListener”的处理程序之后添加了“removeListener”,那么您也会得到关于添加了“removeListener”的通知,这就是为什么我们习惯上像在本示例中那样首先添加removeListener事件处理程序。

EventEmitter 内存泄漏

处理事件时,内存泄漏的一个常见来源是在回调中订阅事件,但在结束时忘记取消订阅。默认情况下,EventEmitter将允许每种事件类型的有 10 个监听器,并且它将向控制台输出一个警告。此警告是专门为您提供帮助的。您的所有代码都将继续运行。换句话说,将会在没有警告的情况下添加更多的侦听器,并且当一个事件被引发时,所有的侦听器都会被调用,如清单 5-23 所示。

清单 5-23 。events/8maxEventListeners.js

var EventEmitter = require('events').EventEmitter;
var emitter = new EventEmitter();

var listenersCalled = 0;

function someCallback() {
    // Add a listener
    emitter.on('foo', function () { listenersCalled++ });

    // return from callback
}

for (var i = 0; i < 20; i++) {
    someCallback();
}
emitter.emit('foo');
console.log('listeners called:', listenersCalled); // 20

应用的输出显示在清单 5-24 中。您可以看到,尽管有警告,但当我们发出事件时,所有 20 个侦听器都被调用了。

清单 5-24 。运行最大事件监听器演示

$ node 8maxEventListeners.js
(node) warning: possible EventEmitter memory leak detected. 11 listeners added.
Use emitter.setMaxListeners() to increase limit.
Trace
    at EventEmitter.addListener (events.js:160:15)
    at someCallback (/path/to/8maxEventListeners.js:8:13)
    at Object.<anonymous> (/path/to/8maxEventListeners.js:14:5)
    at Module._compile (module.js:456:26)
    at Object.Module._extensions..js (module.js:474:10)
    at Module.load (module.js:356:32)
    at Function.Module._load (module.js:312:12)
    at Function.Module.runMain (module.js:497:10)
    at startup (node.js:119:16)
    at node.js:902:3
listeners called: 20

这种内存泄漏的一个常见原因是在回调出错时忘记取消订阅事件。一个简单的解决方案是在回调中创建一个新的事件发射器。这样,事件发射器就不会被共享,当回调终止时,它会和它的所有订阅者一起被释放。

最后,在有些情况下,拥有 10 个以上的侦听器是一个有效的场景。在这种情况下,您可以使用setMaxListeners成员函数增加这个警告的限制,如清单 5-25 所示。

清单 5-25 。events/9setMaxListeners.js

var EventEmitter = require('events').EventEmitter;
var emitter = new EventEmitter();

// increase limit to 30
emitter.setMaxListeners(30);

// subscribe 20 times
// No warning will be printed
for (var i = 0; i < 20; i++) {
    emitter.on('foo', function () { });
}
console.log('done');

请注意,这增加了该事件发射器上所有事件类型的限制。此外,您可以传入 0,以允许在没有警告的情况下订阅无限数量的事件侦听器。

Node.js 默认情况下尽量做到安全;在服务器环境中工作时,内存泄漏会造成很大的影响,这也是此警告消息存在的原因。

错误事件

一个'error'事件在 Node.js 中被视为一个特殊的异常案例,如果它没有没有监听器,那么默认的动作是打印一个堆栈跟踪并退出程序。清单 5-26 给出了一个简单的例子来说明这一点。

清单 5-26 。事件/10errorEvent.js

var EventEmitter = require('events').EventEmitter;
var emitter = new EventEmitter();

// Emit an error event
// Since there is no listener for this event the process terminates
emitter.emit('error', new Error('Something horrible happened'));

console.log('this line never executes');

如果您运行这段代码,您将得到一个输出,如清单 5-27 中的所示。如果你需要引发一个error事件,你应该使用一个Error对象,就像我们在这个例子中所做的那样。您还可以从示例中看到,当流程终止时,包含console.log的最后一行永远不会执行。

清单 5-27 。错误事件示例的运行示例

$ node 10errorEvent.js

events.js:72
        throw er; // Unhandled 'error' event
              ^
Error: Something horrible happened
    at Object.<anonymous> (/path/to/10errorEvent.js:6:23)
    at Module._compile (module.js:456:26)
    at Object.Module._extensions..js (module.js:474:10)
    at Module.load (module.js:356:32)
    at Function.Module._load (module.js:312:12)
    at Function.Module.runMain (module.js:497:10)
    at startup (node.js:119:16)
    at node.js:902:3

因此,教训是:只有在异常的情况下必须处理,才引发错误event

创建您自己的事件发射器

既然您已经是 Node.js 中处理和引发事件的专家,那么大量的开源外围应用就向您敞开了大门。许多库导出继承自EventEmitter的类,因此遵循相同的事件处理机制。在这个阶段,了解如何扩展EventEmitter并创建一个内置了EventEmitter所有功能的公共类是很有用的。

创建自己的EventEmitter所需要做的就是从类的构造函数中调用EventEmitter构造函数,并使用util.inherits函数来建立原型链。考虑到我们在本章开始时对这个问题的讨论,这应该是你的第二天性。清单 5-28 是演示这一点的一个简单例子。

清单 5-28 。事件/11custom.js

var EventEmitter = require('events').EventEmitter;
var inherits = require('util').inherits;

// Custom class
function Foo() {
    EventEmitter.call(this);
}
inherits(Foo, EventEmitter);

// Sample member function that raises an event
Foo.prototype.connect = function () {
    this.emit('connected');
}

// Usage
var foo = new Foo();
foo.on('connected', function () {
    console.log('connected raised!');
});
foo.connect();

你可以看到你的类的用法就像它是一个EventEmitter一样。有了这两行简单的代码,您就有了一个全功能的自定义事件发射器。

处理事件

core Node.js 中的许多类都继承自EventEmitter。全局process对象也是EventEmitter的一个实例,正如你在清单 5-29 中看到的。

清单 5-29 。演示进程是 EventEmitter 的示例

$ node -e "console.log(process instanceof require('events').EventEmitter)"
true

全局异常处理程序

任何全局未处理的异常都可以通过侦听进程上的“uncaughtException”事件来截获。您不应该在此事件处理程序之外继续执行,因为这只会在应用处于不稳定状态时发生。最好的策略是为了方便起见记录错误,用错误代码退出进程,如清单 5-30 所示。

清单 5-30 。process/1 un catch . js

process.on('uncaughtException', function (err) {
    console.log('Caught exception: ', err);
    console.log('Stack:', err.stack);
    process.exit(1);
});

// Intentionally cause an exception, but don't try/catch it.
nonexistentFunc();

console.log('This line will not run.');

如果你运行清单 5-30 中的代码,你会得到一个不错的错误日志,如清单 5-31 所示。

清单 5-31 。未捕获异常的运行示例

$ node 1uncaught.js
Caught exception: [ReferenceError: nonexistentFunc is not defined]
Stack: ReferenceError: nonexistentFunc is not defined
    at Object.<anonymous> (E:\DRIVE\Google Drive\BEGINNING NODEJS\code\chapter5\
process\1uncaught.js:8:1)
    at Module._compile (module.js:456:26)
    at Object.Module._extensions..js (module.js:474:10)
    at Module.load (module.js:356:32)
    at Function.Module._load (module.js:312:12)
    at Function.Module.runMain (module.js:497:10)
    at startup (node.js:119:16)
    at node.js:902:3

如果任何事件发射器引发“错误”事件,并且没有侦听器订阅该事件的事件发射器,则“uncaughtError”事件也会在流程上引发。

出口

当进程将要退出时,发出exit事件。此时无法中止退出。事件循环已经在拆卸中,所以此时你不能做任何异步操作。(参见清单 5-32 。)

清单 5-32 。process/2exit.js

process.on('exit', function (code) {
    console.log('Exiting with code:', code);
});

process.exit(1);

请注意,事件回调是在进程退出时使用的退出代码中传递的。此事件主要用于调试和日志记录。

信号

Node.js process对象还支持 UNIX 的信号概念,这是一种进程间通信的形式。它还模拟了 Windows 系统上最重要的程序。Windows 和 UNIX 都支持的一个常见场景是,用户试图在终端中使用 Ctrl+C 组合键来中断进程。默认情况下,Node.js 将退出该进程。但是,如果您有一个监听器订阅了SIGINT(信号中断)事件,监听器将被调用,您可以选择是否要退出进程(process.exit)或继续执行。清单 5-33 提供了一个小例子,我们选择继续运行并在五秒钟后退出。

清单 5-33 。process/3 signal . js

setTimeout(function () {
    console.log('5 seconds passed. Exiting');
}, 5000);
console.log('Started. Will exit in 5 seconds');

process.on('SIGINT', function () {
    console.log('Got SIGINT. Ignoring.');
});

如果您执行这个示例并按 Ctrl+C,您将得到一条消息,表明我们选择忽略它。最后,一旦我们没有任何未完成的任务,进程将在五秒钟后自然退出(如清单 5-34 所示)。

清单 5-34 。忽略 Ctrl+C 消息的示例运行演示

$ node 3signals.js
Started. Will exit in 5 seconds
Got SIGINT. Ignoring.
Got SIGINT. Ignoring.
5 seconds passed. Exiting

一滴一滴地是装满水的罐子。

—佛陀

流在创建高性能的 web 应用 中扮演着重要的角色。为了理解流带来了什么,考虑一个简单的例子,从 web 服务器提供一个大文件(1GB)。在没有溪流的情况下,它看起来像图 5-3 。用户将不得不等待很长时间才能得到他们所请求的文件的任何迹象。这叫做缓冲,我们应该尽可能的限制它。除了用户体验明显不好,还浪费资源。在我们开始将文件发送给用户之前,需要加载完整的文件并保存在内存中。

9781484201886_Fig05-03.jpg

图 5-3 。缓冲网络响应

当我们使用流式传输时,同样的场景看起来要好得多。我们开始读取文件,每当我们有一个新的数据块时,我们将它发送到客户端,直到我们到达末尾,如图图 5-4 所示。

9781484201886_Fig05-04.jpg

图 5-4 。流媒体网络响应

用户体验的改善和服务器资源的更好利用是 steams 背后的主要动机。

最重要的概念是Readable流、Writable流、Duplex流、Transform流。一个可读流是一个你可以从中读取数据但不能写入的流。一个很好的例子是process.stdin,它可以用来从标准输入流数据。可写流是可以写入但不能读取的流。一个很好的例子是process.stdout,它可以用来将数据流输出到标准输出。一个双工流是一个你可以读写的流。网络套接字就是一个很好的例子。您可以向网络套接字写入数据,也可以从中读取数据。转换流是双工流的一种特殊情况,流的输出以某种方式从输入中计算出来。这些溪流也被称为溪流。一个很好的例子就是加密和压缩流。

流的所有基本构建块都存在于使用require('stream')加载的 Node.js 核心流模块中。这个模块中有实现流的基类,恰当地称为ReadableWritableDuplexTransform

Node.js 中的流是基于事件的,这就是为什么在我们深入流之前对事件有一个牢固的理解是很重要的。所有这些流类都继承自一个基本抽象Stream类(抽象是因为你不应该直接使用它),它又继承自EventEmitter(我们前面已经看到了)。这个层次结构在清单 5-35 中演示。

清单 5-35 。streams/1 concepts/event based . js

var stream = require('stream');
var EventEmitter = require('events').EventEmitter;

console.log(new stream.Stream() instanceof EventEmitter); // true

console.log(new stream.Readable({}) instanceof stream.Stream); // true
console.log(new stream.Writable({}) instanceof stream.Stream); // true
console.log(new stream.Duplex({}) instanceof stream.Stream); // true
console.log(new stream.Transform({}) instanceof stream.Stream); // true

在我们了解如何创建自己的流之前,让我们看看如何使用 Node.js 库中现有的流。

管道

所有的流都支持管道操作,可以使用pipe成员函数来完成。这是 Node.js 中的流如此出色的原因之一。考虑我们简单的初始场景,从文件系统加载一个文件并将其传输到客户机。这可以像代码段fileSystemStream.pipe(userSocket)一样简单。

您可以通过管道从可读取的流(可读/双工/转换)传输到可写入的流(可写/双工/转换)。这个函数被称为管道,因为它模仿了命令行管道操作符的行为,例如cat file.txt | grep lol

核心模块fs提供了从文件创建可读或可写流的实用函数。清单 5-36 是一个将文件从文件系统传输到用户控制台的例子。

清单 5-36 。streams/2pipe/1basic.js

var fs = require('fs');

// Create readable stream
var readableStream = fs.createReadStream('./cool.txt');

// Pipe it to stdout
readableStream.pipe(process.stdout);

您还可以使用pipe链接多个流。例如,清单 5-37 中的代码从一个文件中创建一个读取流,通过一个 zip 转换流,然后通过管道将其传输到一个可写文件流。这将在文件系统上创建一个 zip 文件。

清单 5-37 。streams/2pipe/2chain.js

var fs = require('fs');
var gzip = require('zlib').createGzip();

var inp = fs.createReadStream('cool.txt');
var out = fs.createWriteStream('cool.txt.gz');

// Pipe chain
inp.pipe(gzip).pipe(out);

Node.js 中的流是基于事件的。pipe操作所做的只是订阅源上的相关事件,并调用目的地上的相关函数。对于大多数目的来说,pipe是作为 API 消费者需要了解的全部内容,但是当您想要更深入地研究流时,了解更多的细节是值得的。

消费可读流

我们已经说过很多次,流是基于事件工作的。可读流最重要的事件是'readable'。每当有新数据要从流中读取时,都会引发此事件。一旦进入事件处理程序,就可以调用流中的read函数从流中读取数据。如果这是流的结尾,read 函数返回null,如清单 5-38 所示。

清单 5-38 。streams/3readable/basic.js

process.stdin.on('readable', function () {
    var buf = process.stdin.read();
    if (buf != null) {
        console.log('Got:');
        process.stdout.write(buf.toString());
    }
    else {
        console.log('Read complete!');
    }
});

清单 5-39 中显示了该代码的一个运行示例,其中我们从命令行将数据传输到process.stdin

清单 5-39 。streams/3readable/basic.js 的运行示例

$ echo 'foo bar bas' | node basic.js
Got:
'foo bar bas'
Read complete!

写入可写流

要写一个流,只需调用write来写一些数据。当你写完(流结束)时,你只需调用end。如果你愿意,你也可以使用end成员函数写一些数据,如清单 5-40 所示。

清单 5-40 。streams/4writable/basic.js

var fs = require('fs');
var ws = fs.createWriteStream('message.txt');

ws.write('foo bar ');
ws.end('bas');

在这个例子中,我们简单地将foo bar bas写到一个可写的文件流中。

创建您自己的流

创建自己的流与创建自己的EventEmitter非常相似。对于从相关基类继承的流,流类和实现一些基方法。这在表 5-1 中有详细说明。

继承机制和我们之前看到的一样。也就是你从你的类构造函数中调用基构造函数,在声明你的类之后调用utils.inherits

表 5-1 。创建您自己的自定义流

|

使用案例

|

班级

|

要实现的方法

|
| --- | --- | --- |
| 只读 | 易读的 | _ 阅读 |
| 只写 | 可写的 | _ 写入 |
| 阅读和写作 | 双层公寓 | _ 读,_ 写 |
| 对读取的数据进行操作并写入结果 | 改变 | _ 转换,_ 刷新 |

创建可读的流

如上所述,您只是从Readable类继承。您在自己的类中实现了_read成员,当有人请求读取数据时,stream API 会在内部调用这个成员。如果您有想要传递(推送)的数据,您可以调用继承的成员函数push来传递数据。如果您调用push(null),这表示读取流结束。

清单 5-41 是一个返回 1-1000 的可读流的简单例子。如果你运行这个,你将会看到所有这些数字被打印出来(当我们用管道连接到stdout)。

清单 5-41 。streams/5 create readable/counter . js

var Readable = require('stream').Readable;
var util = require('util');

function Counter() {
    Readable.call(this);
    this._max = 1000;
    this._index = 1;
}
util.inherits(Counter, Readable);

Counter.prototype._read = function () {
    var i = this._index++;
    if (i > this._max)
        this.push(null);
    else {
        var str = ' ' + i;
        this.push(str);
    }
};

// Usage, same as any other readable stream
var counter = new Counter();
counter.pipe(process.stdout);

如您所见,底层的可读类为您提供了大部分流逻辑。

创建可写流

创建自己的可写流类类似于创建可读流。您从Writable类继承并实现了_write方法。_write方法以需要处理的块作为第一个参数传递。

清单 5-42 是一个简单的可写流,它将所有传入的数据记录到控制台。在这个例子中,我们简单地从可读文件流传输到这个可写流(Logger)。

清单 5-42 。streams/6 create writable/logger . js

var Writable = require('stream').Writable;
var util = require('util');

function Logger() {
    Writable.call(this);
}
util.inherits(Logger, Writable);

Logger.prototype._write = function (chunk) {
    console.log(chunk.toString());
};

// Usage, same as any other Writable stream
var logger = new Logger();

var readStream = require('fs').createReadStream('message.txt');
readStream.pipe(logger);

同样,在大多数情况下,大部分功能由Writable基类在内部处理。

摘要

希望这一章已经让你对 JavaScript 这种语言有了更好的理解。有几个简单的想法提供了很大的表现力。本章开始时,我们提供了 JavaScript 原型继承的速成课程,并解释了在 Node.js 中实现这一点是多么简单。然后,我们展示了 Node.js 如何内置对常见事件处理范例的支持。我们还演示了如何通过简单的继承创建自己的事件发射器。最后,我们看了 streams,以及为什么您想要将它们添加到您的武器库中。您看到了在 Node.js 中消费和写入流是多么容易。Node.js 几乎就像是为它们而设计的一样!在本章的最后,我们讨论了如何利用 Node.js 核心基类提供的内置功能创建自己的定制流。*

六、HTTP 入门

Node.js 是专门为可伸缩的服务器端和网络应用而创建的。它具有久经考验的功能,可以有效地处理网络连接。这为社区构建成熟的应用服务器提供了基础。

在这一章中,我们将看看 Node.js 为创建 web 应用提供的核心功能。然后我们将回顾 connect 中间件框架,它允许您创建可重用的 web 服务器组件。最后,我们将看看用 HTTPS 保护你的 Web 服务器。

Node.js HTTP 的基础知识

以下是在 Node.js 中创建 web 应用的主要核心网络模块:

  • net / require('net'):为创建 TCP 服务器和客户端提供基础
  • dgram / require('dgram'):提供创建 UDP /数据报套接字的功能
  • http / require('http'):为 http 栈提供高性能的基础
  • https / require('https'):为创建 TLS / SSL 客户端和服务器提供 API

我们将从使用http模块创建我们的简单服务器来服务静态文件开始。从头开始创建我们的 web 服务器将使我们对社区 NPM 模块提供的功能有更深的理解,我们将在后面探讨这些功能。

Image 注意我们将使用curl来测试我们的 web 应用。默认情况下,它在 Mac OS X / Linux 上可用。您可以将curl for windows 作为 Cygwin ( www.cygwin.com/)的一部分。

http模块有一个可爱的小函数createServer,它接受一个回调并返回一个 HTTP 服务器。对于每个客户端请求,回调通过两个参数传递——传入的请求流和传出的服务器响应流。要启动返回的 HTTP 服务器,只需调用它的listen函数,传递您想要监听的端口号。

清单 6-1 提供了一个简单的服务器)监听端口 3000,并简单地对每个 HTTP 请求返回“hello client!”。

清单 6-1 。1create/1raw.js

var http = require('http');

var server = http.createServer(function (request, response) {
    console.log('request starting...');

    // respond
    response.write('hello client!');
    response.end();

});

server.listen(3000);
console.log('Server running at http://127.0.0.1:3000/');

要测试服务器,只需使用 Node.js 启动服务器,如清单 6-2 所示。

清单 6-2 。启动服务器

$ node 1raw.js
Server running at http://127.0.0.1:3000/

然后在一个新窗口中使用curl测试一个 HTTP 连接,如清单 6-3 所示。服务器按照我们的预期发送数据。

清单 6-3 。使用 curl 发出客户端请求

$ curl http://127.0.0.1:3000
hello client!

要退出服务器,只需在服务器启动的窗口中按 Ctrl+C。

检查割台

即使在这一点上,很多 HTTP 逻辑已经被默默地处理了。由curl发送的实际请求包含一些重要的 HTTP 头。为了看到这些,让我们修改服务器来记录在客户端请求中收到的头(由curl发送),如清单 6-4 所示。

清单 6-4 。1create/2defaultheaders.js

var http = require('http');

var server = http.createServer(function (req, res) {
    console.log('request headers...');
    console.log(req.headers);

    // respond
    res.write('hello client!');
    res.end();

}).listen(3000);
console.log('server running on port 3000');

现在启动服务器。我们还将要求curl使用-i(即在输出中包含协议头)选项注销服务器响应头,如清单 6-5 所示。

清单 6-5 。发出客户端请求并显示返回的响应头

$ curl http://127.0.0.1:3000 -i
HTTP/1.1 200 OK
Date: Thu, 22 May 2014 11:57:28 GMT
Connection: keep-alive
Transfer-Encoding: chunked

hello client!

curl发送的 HTTP 请求头由 Node.js HTTP 服务器处理,记录在服务器控制台上,如清单 6-6 所示。如您所见,req.headers是一个简单的 JavaScript 对象文字。您可以使用req['header-name']访问任何标题。

清单 6-6 。根据客户请求打印输出请求标题

$ node 2defaultheaders.js
server running on port 3000
request headers...
{ 'user-agent': 'curl/7.30.0',
  host: '127.0.0.1:3000',
  accept: '*/*',
  connection: 'Keep-Alive' }

Image 注意维基百科在http://en.wikipedia.org/wiki/List_of_HTTP_status_codes有很好的 HTTP 状态代码列表。这包括不属于 HTTP/1.1 规范的代码,这些代码将在http://tools.ietf.org/html/rfc2616中描述。

使用调试代理

帮助您探索和试验 HTTP 的一个很好的工具是 web 调试代理。调试代理是位于客户机和服务器之间的应用,它记录客户机和服务器之间交换的所有请求和响应。图 6-1 简要概述了这种交换将如何发生。

9781484201886_Fig06-01.jpg

图 6-1 。web 调试代理位于客户端和服务器之间

一个非常受欢迎的免费调试代理是 fiddler ( www.telerik.com/fiddler),它有一个简单的一键安装程序,可用于 Windows 和 Mac OS X。(注意:在 Mac OS X 上,你需要安装 mono www.mono-project.com/download/才能安装 fiddler)。一旦启动 fiddler,默认情况下它会监听端口 8888。您需要告诉客户端应用使用代理来连接到服务器。对于curl,您可以使用-x(使用代理)选项来实现。启动我们刚刚创建的简单服务器并启动 fiddler。然后运行下面的命令(清单 6-7 )使用 fiddler 作为代理发出一个客户端请求。

清单 6-7 。发出指定代理服务器的 curl 请求

$ curl http://127.0.0.1:3000 -x 127.0.0.1:8888
hello client!

由于 fiddler 正在运行,它将捕获请求和响应。正如你从图 6-2 中看到的,在服务器响应中从服务器发送的实际数据被略微编码,如清单 6-8 所示。

9781484201886_Fig06-02.jpg

图 6-2 。Fiddler 展示了 HTTP 客户端请求和服务器响应的完整细节

清单 6-8 。服务器响应消息正文

d
hello client!
0

这种编码是因为,默认情况下,Node.js 试图将响应流式传输到客户机。您会看到Transfer-Encoding服务器响应头被设置为chunked分块传输编码是 HTTP 协议的一种数据传输机制,允许你使用(也称为)发送数据。在分块传输中,传输的大小(十六进制)在数据块本身之前发送,因此接收方可以知道它何时完成了该数据块的数据接收。Node.js 发送了 d(十进制 13),因为那是']'的长度。通过发送长度为 0 的块(因此结尾为 0)来终止传输。所有这些都由内置的 Node.js HTTP 服务器来完成。

响应流的关键成员

除了响应实现了一个可写的流之外,您还需要了解一些其他有用的方法。响应分为两部分:编写头部和编写主体。这是因为主体可能包含需要流式传输的大量数据。报头指定了该数据将如何呈现,并且需要由客户端在这样的流式传输可以开始之前进行解释。

一旦您调用了response.writeresponse.end,您设置的 HTTP 头就会被发送,随后是您想要编写的正文部分。此后,您不能再修改标题。在任何时候,您都可以使用只读的response.headersSen t 布尔值来检查报头是否被发送。

设置状态代码

默认情况下,状态代码将为 200 OK。只要没有发送消息头,就可以使用statusCode响应成员显式设置状态代码(例如,要发送 404 NOT FOUND,可以使用下面的代码:

response.statusCode = 404;

设置标题

您可以使用response.setHeader(name, value)成员函数将响应中的任何 HTTP 头显式排队。您需要设置的一个常见头是响应的Content-Type,以便客户端知道如何解释服务器在主体中发送的数据。例如,如果您正在向客户端发送一个 HTML 文件,您应该将Content-Type设置为text/html,您可以使用以下代码来实现:

response.setHeader("Content-Type", "text/html");

内容类型头的值的正式术语是 MIME 类型。一些关键内容类型的 MIME 类型如表 6-1 所示。

表 6-1 。流行的 MIME 类型

|

名字

|

MIME 类型

|
| --- | --- |
| 超文本标记语言 | 文本/html |
| 层叠样式表 | 文本/css |
| Java Script 语言 | 应用/javascript |
| JavaScript 对象符号(JSON) | 应用/json |
| JPEG 图像 | 图像/jpeg |
| 便携式网络图形(PNG) | 影像/png |

对于这个以及更多的 mime 类型,有一个简单的 NPM 包叫做 mime ( npm install mime),你可以用它从一个文件扩展名中获取官方的 MIME 类型。清单 6-9 展示了如何使用它。

清单 6-9 。演示使用哑剧 NPM 包

var mime = require('mime');

mime.lookup('/path/to/file.txt'); // => 'text/plain'
mime.lookup('file.txt'); // => 'text/plain'
mime.lookup('.TXT'); // => 'text/plain'
mime.lookup('htm'); // => 'text/html'

回到我们的头讨论,您可以使用response.getHeader函数获得一个排队等待发送的头:

var contentType = response.getHeader('content-type');

您可以使用response.removeHeader功能从队列中删除标题:

response.removeHeader('Content-Encoding');

仅发送邮件头

当您想要显式地发送消息头(而不仅仅是将它们排队)并将响应移入仅主体模式时,您可以调用response.writeHead成员函数。该函数获取状态代码和可选的头,这些头将被添加到您可能已经使用response. setHeader 排队的任何头中。例如,下面的代码片段将状态代码设置为200,并为提供 HTML 设置了Content-Type标题:

response.writeHead(200, { 'Content-Type': 'text/html' });

请求流的主要成员

请求也是一个可读的流。这对于客户端希望将数据传输到服务器的情况非常有用,例如文件上传。客户端 HTTP 请求也被分成头部和主体部分。我们可以得到关于客户端请求 HTTP 头的有用信息。例如,我们已经看到了request.headers属性,它只是标题名和值的只读映射(JavaScript 对象文字)(如清单 6-10 所示)。

清单 6-10 。演示读取请求头的代码片段

// Prints something like:
//
// { 'user-agent': 'curl/7.30.0',
// host: '127.0.0.1:3000',
// accept: '*/*' }
console.log(request.headers);

要检查单个头,可以像索引任何其他 JavaScript 对象文字一样索引该对象:

console.log(request.headers['user-agent']); // 'curl/7.30.0'

响应请求时需要的一条关键信息是客户机发出请求时使用的 HTTP 方法和 URL。为了创建 RESTful web 应用,这些信息是必需的。您可以从request.method只读属性中获取使用的 HTTP 方法。您可以使用request.url属性获得客户端请求的 URL。例如,考虑以下客户端请求:

GET /resources HTTP/1.1
Accept: */*

在这种情况下,request.method将是GETrequest.url将是/resources

创建自己的文件 Web 服务器

提供基本 HTML

既然我们对响应流和 MIME 类型有了更深的理解,我们可以创建一个简单的 web 服务器,从文件夹中返回 HTML 文件。创建一个简单的 HTML 文件,名为index.html ,我们计划在每次请求获取服务器上的“/”时返回该文件,如清单 6-11 中的所示。

清单 6-11 。2server/public/index.html

<html>
<head>
    <title>Hello there</title>
</head>
<body>
    You are looking lovely!
</body>
</html>

首先,让我们创建几个实用函数。将功能分解成独立的函数总比分解成一整块代码要好。如果我们收到一个我们不接受的url请求,我们应该返回一个 404(未找到)HTTP 响应。清单 6-12 提供了一个功能来做这件事。

清单 6-12 。一个实用函数返回 404 未找到 HTTP 响应

function send404(response) {
    response.writeHead(404, { 'Content-Type': 'text/plain' });
    response.write('Error 404: Resource not found.');
    response.end();
}

如果我们能够满足请求,我们应该返回 HTTP 200 以及内容的 MIME 类型。返回 HTML 文件非常简单,只需创建一个读取文件流,并通过管道将其发送到响应。清单 6-13 显示了完整的服务器代码。

清单 6-13 。来自 2server/server.js 的代码

var http = require('http');
var fs = require('fs');

function send404(response) {
    response.writeHead(404, { 'Content-Type': 'text/plain' });
    response.write('Error 404: Resource not found.');
    response.end();
}

var server = http.createServer(function (req, res) {
    if (req.method == 'GET' && req.url == '/') {
        res.writeHead(200, { 'content-type': 'text/html' });
        fs.createReadStream('./public/index.html').pipe(res);
    }
    else {
        send404(res);
    }
}).listen(3000);
console.log('server running on port 3000');

如果你启动服务器(从第六章/第二章服务器目录运行node server.js)并在http://localhost:3000打开浏览器,你会看到我们之前创建的 HTML 页面(图 6-3 )。

9781484201886_Fig06-03.jpg

图 6-3 。浏览器显示 index.html 已成功返回

类似地,如果你访问localhost上的任何其他 URL,你会得到一个 404 错误信息(图 6-4 )。

9781484201886_Fig06-04.jpg

图 6-4 。浏览器显示对不存在的资源请求返回的错误

服务目录

对于快速手写静态文件服务器来说,这是一个好的开始。但是,它只服务于一个文件。让我们把它打开一点,为一个目录的所有内容提供服务。首先,创建一个简单的客户端 JavaScript 文件,在 HTML 加载完成后附加到主体,如清单 6-14 所示。我们计划向服务器请求这个 JavaScript 文件。

清单 6-14 。3serverjs/public/main.js 中的代码

window.onload = function () {
    document.body.innerHTML += '<strong>Talk JavaScript with me</strong>';
}

让我们通过在<head>中添加一个脚本标签来加载客户端 JavaScript 文件,从而修改我们的简单 HTML 文件

<script src="./main.js"></script>

现在,如果我们运行相同的旧服务器,当我们的浏览器解析index.html并试图从服务器加载main.js时,我们将得到 404。为了支持 JavaScript 加载,我们需要做以下工作:

  • 使用 path 模块根据request.url属性解析文件系统上文件的路径
  • 查看我们是否为请求的文件类型注册了 MIME 类型
  • 在我们尝试从文件系统中读取文件之前,请确保该文件存在

基于我们已经知道的,我们可以编写如清单 6-15 所示的服务器。

清单 6-15 。3serverjs/server.js 中的代码

var http = require('http');
var fs = require('fs');
var path = require('path');

function send404(response) {
    response.writeHead(404, { 'Content-Type': 'text/plain' });
    response.write('Error 404: Resource not found.');
    response.end();
}

var mimeLookup = {
    '.js': 'application/javascript',
    '.html': 'text/html'
};

var server = http.createServer(function (req, res) {

    if (req.method == 'GET') {

        // resolve file path to filesystem path
        var fileurl;
        if (req.url == '/') fileurl = '/index.html';
        else fileurl = req.url;
        var filepath = path.resolve('./public' + fileurl);

        // lookup mime type
        var fileExt = path.extname(filepath);
        var mimeType = mimeLookup[fileExt];
        if (!mimeType) {
            send404(res);
            return;
        }

        // see if we have that file
        fs.exists(filepath, function (exists) {

            // if not
            if (!exists) {
                send404(res);
                return;
            };

            // finally stream the file
            res.writeHead(200, { 'content-type': mimeType });
            fs.createReadStream(filepath).pipe(res);
        });
    }
    else {
        send404(res);
    }
}).listen(3000);
console.log('server running on port 3000');

示例中的大部分代码都是不言自明的,并且突出显示了有趣的部分。如果你现在打开浏览器并访问localhost:3000,你会看到 HTML 被请求,JavaScript 被成功加载并运行(图 6-5 )。

9781484201886_Fig06-05.jpg

图 6-5 。显示客户端 JavaScript 被成功请求和呈现的浏览器

我们当前的实现仍然缺少很多功能。首先,它无法抵御恶意 URL。例如,您可以利用我们的实现中有一个简单的path.resolve来从服务器文件系统请求任何文件,如清单 6-16 所示(这里我们从服务器请求服务器代码)。

清单 6-16 。演示我们的简单文件服务器中的文件系统列表漏洞

$ curl 127.0.0.1:3000/../server.js
var http = require('http');
var fs = require('fs');
var path = require('path');

...truncated... the rest of server.js

然后是错误处理和文件缓存,这两者在我们的实现中都是缺乏的。拥有从头构建自己的 Node.js web 服务器的知识是非常宝贵的,但是您不必从头构建 web 服务器。社区已经为您完成了,我们将在稍后探索这些选项。

介绍 Connect

正如我们所看到的,core Node.js 模块为构建您自己的 web 应用提供了基本但重要的特性。NPM 上有很多基于此的 web 框架。很流行的一个是 connect ( npm install connect),这是一个中间件框架。

中间件 基本上是位于你的应用代码和一些低级 API 之间的任何软件。Connect 扩展了内置的 HTTP 服务器功能,并添加了一个插件框架。插件充当中间件,因此 connect 是一个中间件框架。

Connect 最近进行了一次彻底检查(connect 3.0),现在核心 connect 只是中间件框架。每个中间件都是自己独立的 NPM 模块,是更大的 Connect/ExpressJS 生态系统的一部分。我们将在下一章探讨这些中间件。在这里,我们重点关注使用 connect 和创作我们自己的中间件。

创建一个基本的连接应用

connect 的核心是connect功能。调用此函数将创建连接调度程序。connect dispatcher 只是一个接受请求/响应参数的函数,这意味着 dispatcher 可以用作http.createServer(我们前面已经看到)的参数。清单 6-17 显示了基本的连接应用。

清单 6-17 。4connect/1basic.js

var connect = require('connect')
    , http = require('http');

// Create a connect dispatcher
var app = connect();

// Register with http
http.createServer(app)
    .listen(3000);
console.log('server running on port 3000');

如果您运行此服务器,它将为每个客户端请求返回 404(未找到)。这是 connect 提供的内置行为。如果没有一些中间件来处理客户端请求,connect 将返回 404。我们将在下一节创建中间件。

除了可以接受请求和响应对象的函数之外,connect dispatcher 还有一个成员函数use,用于向 connect 注册中间件。当我们创建自己的连接中间件时,我们将很快看到这个函数。

一个效用函数是listen 。我们将在内部调用http.createServer,用它注册 connect dispatcher,就像我们之前展示的那样,(即http.createServer(app)),最后调用创建的服务器的listen函数。所以你可以简单地做清单 6-18 所示的事情,但是知道它仍然是 Node 核心http之上的一个调度器是很有用的。

清单 6-18 。4 连接/2 简单者. js

var connect = require('connect');

// Create a connect dispatcher and register with http
var app = connect()
          .listen(3000);
console.log('server running on port 3000');

创建连接中间件

要向 connect 注册中间件,请调用 connect dispatcher 上的'use'成员方法,传递一个函数,该函数有三个参数—请求、响应和下一个回调:

  • 请求派生自我们前面看到的 Node.js HTTP 请求类
  • response 来自我们前面看到的 Node.js HTTP 响应类
  • next 允许您可选地将控制传递给注册了 connect 的下一个中间件,或者通知 connect 一个错误

最简单的无操作(no operation)中间件是不查看请求、不修改响应、不简单地将控制权移交给下一个中间件的中间件,如清单 6-19 所示。

清单 6-19 。5 中间件/1 OOP . js

var connect = require('connect');

// Create a connect dispatcher and register with http
var app = connect()
          // register a middleware
          .use(function (req, res, next) { next(); })
          .listen(3000);
console.log('server running on port 3000');

现在我们已经熟悉了中间件的基础,让我们创建一个记录客户端请求的methodurl的中间件,如清单 6-20 所示。

清单 6-20 。5 中间件/2logit.js

var util = require('util');

// a simple logging middleware
function logit(req, res, next) {
    util.log(util.format('Request recieved: %s, %s', req.method, req.url));
    next();
}

var connect = require('connect');

connect()
    .use(logit)
    .listen(3000);

让我们创建另一个中间件,它将客户机请求回显给客户机。由于客户端请求是一个读流,而响应是一个写流,我们可以简单地通过管道传输这两个流,如清单 6-21 所示。

清单 6-21 。5 中间件/3echo.js

function echo(req, res, next) {
    req.pipe(res);
}

var connect = require('connect');

connect()
    .use(echo)
    .listen(3000);

现在,如果您运行这个应用并发出 curl 请求,请求体(-d,即curl的数据参数)将成为响应体:

$ curl http://127.0.0.1:3000/ -d "hello world!"
hello world!

通过路径前缀挂载中间件

use函数采用可选的第一个参数来指定将触发指定中间件的端点。这被称为挂载,因为它类似于操作系统磁盘挂载。例如,假设我们希望仅在请求“/echo”时回应。对于所有其他请求,我们将返回消息“Wassup”。这可以实现,如清单 6-22 所示。

清单 6-22 。5 中间件/4prefix.js

function echo(req, res, next) {
    req.pipe(res);
}

var connect = require('connect');

connect()
    .use('/echo', echo)
    .use(function (req, res) { res.end('Wassup!'); })
    .listen(3000);

所有以“/echo”开头的请求将由echo中间件处理,而其他请求将被传递给我们的Wassup!响应器。正如你在清单 6-23 中看到的,它的行为和预期的一样。

清单 6-23 。演示安装

$ curl http://127.0.0.1:3000/echo -d "hello world!"
hello world!
$ curl http://127.0.0.1:3000/ -d "hello world!"
Wassup!

需要路径前缀的一个简单例子是在特定的前缀(例如,'/public ')托管静态文件中间件。

挂载的另一个优点是,它允许您轻松地更改 URL,而不需要更新中间件。你的中间件应该而不是检查req.url。假设它被安装在需要进行处理的地方。

使用对象作为中间件

作为一个中间件作者,您可以选择使用一个对象(而不是一个简单的函数)来创建一个中间件,只要该对象有一个handle方法。例如,echo中间件作为一个对象将如清单 6-24 所示。

清单 6-24 。5 中间件/5object.js

var echo = {
    handle: function (req, res, next) {
        req.pipe(res);
    }
};

var connect = require('connect');

connect()
    .use(echo)
    .listen(3000);

这允许您使用类实例作为中间件,只要它们有一个句柄成员函数。这只是为了方便起见,你可以放心地忽略这一点。

创建可配置的中间件

您可以使用 JavaScript 闭包的力量来创建可配置的中间件。例如,在清单 6-25 中,我们展示了一个中间件,它总是根据它的配置返回相同的消息。配置message由我们返回的函数在闭包中捕获。

清单 6-25 。5 中间件/6configurable.js

// Configurable middleware creator
function greeter(message) {
    return function (req, res, next) {
        res.end(message);
    };
}

var helloWorldGreeter = greeter('Hello world!');
var heyThereGreeter = greeter('Hey there!');

var connect = require('connect');
connect()
    .use('/hello', helloWorldGreeter)
    .use('/hey', heyThereGreeter)
    .listen(3000);

结果显示在清单 6-26 中。

清单 6-26 。演示如何使用已配置的中间件

$ curl http://127.0.0.1:3000/hello
Hello world!
$ curl http://127.0.0.1:3000/hey
Hey there!

连锁的力量

中间件的链接很棒有很多原因。例如,它允许中间件共享处理请求和响应的功能。您还可以使用它来提供授权和身份验证。让我们考虑几个实际的例子。

共享请求/响应信息

传递到每个中间件的请求和响应对象是可变的和共享的。您可以利用这一点让一个中间件为您部分处理请求,使它更容易被后来的中间件使用。作为一个例子,考虑一个简单的中间件,如果它检测到这是一个 JSON 请求,它试图将主体处理成一个 JavaScript 对象,如清单 6-27 所示。

清单 6-27 。来自 6chain/1parse.js 的片段

function parseJSON(req, res, next) {
    if (req.headers['content-type'] == 'application/json') {

        // Load all the data
        var readData = '';
        req.on('readable', function () {
            readData += req.read();
        });

        // Try to parse
        req.on('end', function () {
            try {
                req.body = JSON.parse(readData);
            }
            catch (e) { }
            next();
        })
    }
    else {
        next();
    }
}

下面是它的工作原理:

  • 它只是检查客户机请求是否属于类型application/json。如果没有,它将控制权传递给下一个中间件。
  • 否则,它等待客户机请求完全传输到服务器,一旦完成,就尝试使用JSON.parse解析数据。
  • 如果成功,则设置req.body
  • 无论 JSON 是否被解析和req.body是否被设置,我们仍然将控制权传递给下一个中间件。

因为链接了在我们的parseJSON之后出现的任何中间件,如果请求包含有效的 JSON,中间件将访问在req.body中解析的 JSON 对象。在清单 6-28 中,我们有一个简单的连接服务器,它添加了一个中间件,如果找到有效的 JSON,这个中间件使用parseJSON的结果告诉客户端req.body.foo的值。

清单 6-28 。来自 6chain/1parse.js 的片段

var connect = require('connect');

connect()
    .use(parseJSON)
    .use(function (req, res) {
        if (req.body) {
            res.end('JSON parsed!, value of foo: '+ req.body.foo);
        }
        else {
            res.end('no JSON detected!');
        }
    })
    .listen(3000);

如果您使用curl测试它,您将看到传递的 JSON 对象的foo成员的值(如果存在的话)。否则,如果你传递一个无效的 JSON 或非 JSON 请求,你将得到“没有检测到 JSON”的消息,如清单 6-29 所示。

清单 6-29 。parseJSON 中间件的运行演示

$ curl http://127.0.0.1:3000/ -H "content-type: application/json" -d "{\"foo\":123}"
JSON parsed!, value of foo: 123
$ curl http://127.0.0.1:3000/ -H "content-type: application/json" -d "{\"foo\":123,}"
no JSON detected!

链接示例:验证请求/限制访问

因为我们需要通过调用 next 显式地将控制传递给下一个中间件,所以我们可以通过不调用 next 并自己终止响应(res.end)来随时选择性地停止执行。

让我们实现一个基本访问授权中间件,如果客户端请求没有正确的凭证,它将返回 401 未授权。基本授权是一个简单的标准化协议,其中每个客户端请求都需要包含一个Authorization头。标头需要按如下方式构建:

  • 用户名和密码组合成一个字符串:“用户名:密码”。
  • 然后使用 Base64 对结果字符串进行编码。
  • 然后,授权方法和一个空格(即“Basic ”)被放在编码字符串的前面。

一个示例客户端报头是Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==

此外,为了通知客户机它需要添加一个Authorization头,服务器应该在拒绝客户机请求的响应中发送一个WWW-Authenticate头。这很容易做到,我们可以创建一个如清单 6-30 所示的效用函数。

清单 6-30 。实用程序函数发送 401 未授权的 HTTP 响应,请求基本授权

function send401(){
    res.writeHead(401 , {'WWW-Authenticate': 'Basic'});
    res.end();
}

为了解码客户端的Authorization头,我们反向执行创建步骤。换句话说,我们通过在空间上拆分的方式去掉授权方法,也就是“”,加载第二段为 Base64,转换成一个简单的字符串。最后,我们使用“:”来获取用户名/密码。代码如清单 6-31 所示。

清单 6-31 。读取客户端发送的基本身份验证凭据的代码段

var auth = new Buffer(authHeader.split(' ')[1], 'base64').toString().split(':');
var user = auth[0];
var pass = auth[1];

我们现在有足够的信息来创建添加基本访问授权的中间件,如清单 6-32 所示。

清单 6-32 。7auth/1auth.js 中列出身份验证中间件的代码段

function auth(req, res, next) {
    function send401(){
        res.writeHead(401 , {'WWW-Authenticate': 'Basic'});
        res.end();
    }

    var authHeader = req.headers.authorization;
    if (!authHeader) {
        send401();
        return;
    }

    var auth = new Buffer(authHeader.split(' ')[1], 'base64').toString().split(':');
    var user = auth[0];
    var pass = auth[1];
    if (user == 'foo' && pass == 'bar') {
        next(); // all good
    }
    else {
        send401();
    }
}

作为一个演示,这个中间件目前只接受username = foopassword = bar,但是如果我们想的话,我们可以很容易地使它可配置。请注意,只有当访问被授权时,我们才调用next(),这样就可以使用它来提供针对不良凭证的保护。我们在清单 6-33 中演示了如何使用这个中间件。

清单 6-33 。来自 7auth/1auth.js 的代码段演示了如何使用身份验证中间件

var connect = require('connect');

connect()
    .use(auth)
    .use(function (req, res) { res.end('Authorized!'); })
    .listen(3000);

我们来测试一下(从7auth/1auth.js开始启动服务器)。如果你在http://localhost:3000点打开浏览器,你会看到一个熟悉的用户名/密码提示。因为中间件响应了一个 401 未授权响应和一个WWW-Authenticate头,所以浏览器要求您提供凭证。(参见图 6-6 )。

9781484201886_Fig06-06.jpg

图 6-6 。当服务器请求基本身份验证时,浏览器内置对话框

如果您键入了错误的用户名/密码,它将继续提示您输入凭据,因为我们将不断返回 401,直到成功进行身份验证尝试。在这种情况下,我们的中间件将控制权传递给下一个中间件,后者只是返回消息“Authorized!”如图图 6-7 所示。

9781484201886_Fig06-07.jpg

图 6-7 。浏览器发送有效凭据时的服务器响应

我们可以重用这个认证中间件来限制特定的区域。(例如,在清单 6-34 中,只有'/admin '受到限制。)默认情况下,它会落到公共处理程序。请注意,我们根本不需要更改中间件代码来实现这一点。

清单 6-34 。来自 7auth/2authArea.js 的代码段演示了如何为管理区域安装

connect()
    .use('/admin', auth)
    .use('/admin', function (req, res) { res.end('Authorized!'); })
    .use(function (req, res) { res.end('Public') })
    .listen(3000);

这很好地总结了一个可链接的、可选的中间件框架的力量。

引发连接错误

最后值得一提的是,您可以选择向'next'传递一个参数,通知 connect 您的中间件发生了错误。链中没有其他中间件被调用,错误消息被发送到客户端请求,HTTP 状态代码为 500 内部服务器错误。清单 6-35 是演示这一点的一个简单例子。

清单 6-35 。8 错误/1 错误. js

var connect = require('connect');

connect()
    .use(function (req, res, next) { next('An error has occurred!') })
    .use(function (req, res, next) { res.end('I will never get called'); })
    .listen(3000);

如果您运行此服务器并发出请求,您将收到错误消息“发生了错误!”:

$ curl http://127.0.0.1:3000
An error has occurred!

可以看到第二个中间件从未被调用过。此外,通常使用实际的Error对象,而不是我们在这里使用的字符串——换句话说,next(new Error('An error has occurred'))

如果您想处理其他中间件错误,您可以注册一个带有四个参数的中间件(而不是我们已经看到的三个reqresnext)。这种情况下的第一个参数是错误,所以error,req,res,next是四个参数。这样的中间件只有出错时才会调用。清单 6-36 展示了它是如何工作的。

清单 6-36 。8 错误/2 错误处理程序. js

var connect = require('connect');

connect()
    .use(function (req, res, next) { next(new Error('Big bad error details')); })
    .use(function (req, res, next) { res.end('I will never get called'); })
    .use(function (err, req, res, next) {
        // Log the error on the server
        console.log('Error handled:', err.message);
        console.log('Stacktrace:', err.stack);
        // inform the client
        res.writeHead(500);
        res.end('Unable to process the request');
    })
    .listen(3000);

如果您运行此服务器并发出请求,您将仅获得我们在响应中有意发送的信息:

$ curl http://127.0.0.1:3000
Unable to process the request

然而,服务器日志的信息更加丰富,如清单 6-37 所示(因为我们使用了new Error)。

清单 6-37 。展示来自中间件错误处理程序的服务器日志

node 2errorHandler.js
Error handled: Big bad error details
Stacktrace: Error: Big bad error details
    at Object.handle (2errorHandler.js:4:43)
    at next (/node_modules/connect/lib/proto.js:194:15)
... truncated ...

还要注意,这个错误处理程序是为在这个错误处理程序之前发生在中间件任何地方的所有错误调用的。比如在清单 6-38 中,我们故意抛出一个错误,而不是next(error),它仍然被正确处理。

清单 6-38 。8error/3throwErrorHandler.js

var connect = require('connect');

connect()
    .use(function () { throw new Error('Big bad error details'); })
    .use(function (req, res, next) { res.end('I will never get called'); })
    .use(function (err, req, res, next) {
        console.log('Error handled:', err.message);
        res.writeHead(500);
        res.end('Server error!');
    })
    .listen(3000);

这种错误处理方法实际上使 connect 比 raw http.createServer更安全,在 rawhttp.createServer中,未处理的错误会使服务器崩溃。

请注意,错误处理程序仅在出现错误时调用。例如,在清单 6-39 所示的服务器中,它永远不会被调用。所以你永远不需要检查错误处理程序中的错误——如果错误处理程序被调用,它应该一直在那里。

清单 6-39 。8error/4onlyCalledOnError.js

var connect = require('connect');

connect()
    .use(function (req, res, next) { next(); })
    .use(function (err, req, res, next) {
        res.end('Error occured!');
    })
    .use(function (req, res, next) { res.end('No error'); })
    .listen(3000);

最后,值得注意的是,您可以选择从错误处理程序调用 next,并将控制权传递给链中的任何其他中间件。

安全超文本传输协议

HTTPS 是在许多以前的 web 框架中难以实现的东西之一。Node.js 对 HTTPS 内置有一流的支持。在我们展示通过 Node.js 使用 HTTPS 有多简单之前,我们将为初学者提供一个快速概述。

不对称密码术

使 HTTPS 成为可能的基本概念是公钥加密(也称为非对称加密)。对于这种类型的加密,您需要两个加密密钥:

  • 一个每个人都知道的公钥,甚至可能是恶意用户
  • 只有你知道的私钥

而且,

  • 公钥用于加密。这意味着每个人都可以和你交谈。
  • 解密需要一个私钥。这意味着只有你能理解别人说的话!

你可以看到,它让每个人都可以安全地与你交谈,而没有偷听的机会。(参见图 6-8 )。

9781484201886_Fig06-08.jpg

图 6-8 。使用公钥/私钥组合可以让别人安全地与你交谈

出于您的兴趣,值得一提的是,有大量的算法可以轻松计算这样的密钥对。优势在于,从相应的公钥中确定一个正确生成的私钥几乎是不可能的(在计算上是不可行的)。

通过两种方式确保通信安全

因此,共享公钥使得与服务器开始对话变得安全。你如何安全地回嘴?很简单。用户(基本上是浏览器)即时生成一个预主密钥,并以加密消息(用服务器公钥加密)的形式安全地发送给服务器。预主密钥用于生成会话密钥,该密钥仅对客户端和服务器之间的会话有效。现在,如果服务器和客户机用这个共享的会话密钥加密消息,它们就可以互相交谈了。

这是 SSL(或称为 TLS 的新版本)握手的简化描述。握手之后,实际的标准 HTTP 对话发生,其中整个 HTTP 消息(包括头)使用会话密钥加密。HTTPS 就是通过 SSL 安全通信通道进行的 HTTP 通信。

生成密钥

在本节中,我们将自己生成公钥/私钥对。为此,我们将使用 OpenSSL 命令行工具,它在 Mac OS X 和 Windows 上都可用(作为 Cygwin 的一部分,我们在curl中也需要它)。

为了生成私钥,我们指定一个加密算法及其复杂性。使用清单 6-40 中的命令生成一个 1024 位的 RSA 密钥。创建的key.pem文件的一个样本如清单 6-41 所示。

清单 6-40 。生成私钥

$ openssl genrsa 1024 > key.pem

清单 6-41 。生成的私钥示例

-----BEGIN RSA PRIVATE KEY-----
MIICXQIBAAKBgQDJW6ZZLTawfDyhR8v6/nQMX+PIGtPMO8n7OwRdv1AqqW7a+5Au
... truncated ...
0j/PimhgOvsD0TDxccytEsLgoldWcx4YLGjzDtoyyaVj
-----END RSA PRIVATE KEY-----

接下来,我们需要生成相应的公钥,您可以使用以下命令来完成:

$ openssl req -x509 -new -key key.pem > cert.pem

一旦你运行这个命令,你会被问一些问题,比如你的国家名称,所有这些都有一个简单的答案。这创建了一个我们可以与世界共享的公共证书,看起来像清单 6-42 中的代码。

清单 6-42 。公共证书样本

-----BEGIN CERTIFICATE-----
MIIDLjCCApegAwIBAgIJAMdFJbVshZIGMA0GCSqGSIb3DQEBBQUAMG4xCzAJBgNV
... truncated ...
9i+ULx/F6dKgwTLV5L5urT4kIOitM6+QyT+bd1uZ3MXeKaaaJ+dh93aFuFVvxZ3d
t2E=
-----END CERTIFICATE-----

现在让我们用这些钥匙。

创建一个 HTTPS 服务器

Node.js 有一个核心的https模块,您可以使用require('https')来加载它。它有一个createServer函数,其行为与http.createServer完全相同,除了它额外采用了第一个'options'参数,您可以用它来提供公钥/私钥。

清单 6-43 是我们在本章前面看到的简单 HTTP 服务器,更新后可以使用 HTTPS。

清单 6-43 。9ssl/1basic.js

var https = require('https');

var fs = require('fs');
var options = {
    key: fs.readFileSync('./key.pem'),
    cert: fs.readFileSync('./cert.pem')
};

https.createServer(options, function (req, res) {
    res.end('hello client!');
}).listen(3000);

如您所见,侦听器功能完全相同—它传递一个我们已经熟悉的请求和响应对象。你可以使用curl来测试,如清单 6-44 中的所示。k 参数允许不安全的(insekure)/未验证的证书工作,因为我们自己创建了我们的证书。

清单 6-44 。从 curl 测试我们的 HTTPS 服务器

$ curl https://localhost:3000 -k
hello client!

因为http.createServerhttps.createServer的监听器函数是相同的,所以在 HTTPS 中使用 connect 就像在 HTTP 中使用它一样简单。这在清单 6-45 中有演示。

清单 6-45 。9ssl/2connect.js

var https = require('https');

var fs = require('fs');
var options = {
    key: fs.readFileSync('./key.pem'),
    cert: fs.readFileSync('./cert.pem')
};

var connect = require('connect');

// Create a connect dispatcher
var app = connect();

// Register with https
https.createServer(options, app)
    .listen(3000);

对于面向公众的网站,您需要从可信(用户信任的)认证机构(例如,VeriSign 和 Thawte)获得 SSL 证书。证书颁发机构可以保证这个公钥对于跟你说话来说是唯一安全的。这是因为在没有这种权威的情况下,可能有人坐在客户端和服务器之间(称为中间人 MitM 攻击)并声称自己是服务器,向你发送他们的公钥,而不是服务器公钥。这样,MitM 可以解密您的消息(因为他们有相应的私钥),监视它,然后通过用服务器公钥重新加密它,将它转发给服务器。

默认情况下使用 HTTPS

如果您只使用 HTTPS 并将所有 HTTP 流量重定向到使用 HTTPS,那么创建一个安全的网站会更容易确保。现在很多网站都这么做。例如,如果你访问http://www.facebook.com,它会向你发送一个 301(永久移动)HTTP 响应,其中Location头设置为https://www.facebook.com。你可以自己试试;如果在浏览器中打开http://www.facebook.com,地址将变为https://www.facebook.com。因此,只要有可能,就使用 HTTPS 并将所有 HTTP 流量重定向到 HTTPS。

虽然可能,但在同一个端口上运行 HTTP 和 HTTPS 服务器并不容易。然而,你不必。按照惯例,当您请求一个 HTTP 网站而没有指定端口(例如, http ://127.0.0.1)时,客户端会尝试连接到端口 80。然而,如果你请求一个 HTTPS 网站而没有指定端口(比如https??),客户端会尝试连接到服务器上的端口 443。这允许您在端口 443 上运行一个 HTTPS 服务器,在端口 80 上运行一个 HTTP 服务器,简单地重定向客户端请求以使用 HTTPS。完成这项工作的代码如清单 6-46 所示。

清单 6-46 。10 重定向/secure.js

var https = require('https');

var fs = require('fs');
var options = {
    key: fs.readFileSync('./key.pem'),
    cert: fs.readFileSync('./cert.pem')
};

https.createServer(options, function (req, res) {
    res.end('secure!');
}).listen(443);

// Redirect from http port 80 to https
var http = require('http');
http.createServer(function (req, res) {
    res.writeHead(301, { "Location": "https://" + req.headers['host'] + req.url });
    res.end();
}).listen(80);

现在运行这个服务器。(在 Mac OS X 上,您需要使用sudo—也就是sudo node 10redirect/secure.js—来运行,因为只有超级用户可以监听端口 80 和端口 443)。如果你在浏览器中访问http://127.0.0.1,你会注意到网址变成了https://127.0.0.1,你会得到消息“安全!”。这是因为浏览器知道如何处理 HTTP 重定向响应,并为您无声地完成它。要查看内部工作原理,你可以使用curl。你可以分别在清单 6-47 的和清单 6-48 的中看到 HTTP 和 HTTPS 场景。

清单 6-47 。向我们的安全服务器发出 HTTPS 请求

$ curl https://127.0.0.1 -k
secure!

清单 6-48 。向我们的安全服务器发出 HTTP 请求会得到重定向响应

$ curl http://127.0.0.1 -i
HTTP/1.1 301 Moved Permanently
Location: https://127.0.0.1/
Date: Sun, 01 Jun 2014 06:15:16 GMT
Connection: keep-alive
Transfer-Encoding: chunked

如果您的所有通信都通过 HTTPS 进行,您可以使用基本的 HTML 输入表单接受客户端密码,而不用担心被拦截。

摘要

本章开始时,我们对 core Node.js 中的内置 HTTP 功能进行了彻底的检查。这种仔细的检查是必要的,因为社区框架依赖它来提供高级功能,并且很好地掌握基础知识将有助于使您成为一名成功的 Node.js 开发人员。

随后,我们深入研究了连接中间件框架。Connect 允许您共享和创建可管理的 HTTP 插件。这有助于您管理复杂性,从而提高您的生产力,因为您可以划分并征服软件需求。Connect 专注于 HTTP 应用,而不是非常专注于网站,这是 express js(connect 团队的另一个框架)非常适合的。我们将在本书的后面讨论 ExpressJS。但是要知道所有的 connect 中间件都可以与 ExpressJS 一起使用,因为它具有相同的中间件约定(即use成员函数)。

虽然我们在这一章中写了一些中间件,但是 Node.js 社区已经写了很多优秀的(经过测试的和安全的)中间件。我们将在下一章研究这些。

最后,我们展示了 Node.js 中的 HTTPS 有多简单。只要有可能,您应该对所有服务器使用 HTTPS。

Node.js 专注于应用服务器。这意味着你可以深深地嵌入 HTTP,并完全接受网络提供的所有创新。事实上,网络协议开发人员转向 Node.js 进行原型开发并不少见,这是因为 node . js 的访问级别较低,但内存管理非常好。

七、Express 简介

如果您今天要制作 Node.js 网站,您可能会使用 Express web 应用框架。

在前一章中,我们讨论了 Node.js 提供的 HTTP/HTTPS 功能的核心。我们还演示了 Connect 如何在原始的createServer调用之上提供一个中间件框架。ExpressJS 提供了 Connect 所提供的一切(与我们在上一章中看到的use函数相同,还有一个调度程序),并且走得更远。它构成了许多 web 应用的基础,我们将在本章中探讨它。

在这个过程中,我们将介绍一些与成为 HTTP/Node.js 专家相关的概念。

快速基础

快递在 NPM 有express ( npm install express)。让我们从 Connect 的共同点开始。Express 来自开发 Connect 的同一个开发团队。当你调用require('express')时,你会得到一个函数,你可以调用它来创建一个快速应用。这个应用具有我们在前一章中看到的连接调度程序的所有行为。例如,它可以接受使用'use'函数的中间件,并且可以向http.createServer注册,如清单 7-1 所示。

清单 7-1 。intro/1basic.js

var express = require('express'),
     http = require('http');

// Create an express application
var app = express()
            // register a middleware
            .use(function (req, res, next) {
                res.end('hello express!');
            });

// Register with http
http.createServer(app)
    .listen(3000);

能够注册为 HTTP 的侦听器允许您使用 HTTPS(与 Connect 相同)。类似于 Connect,Express 提供了一个实用程序listen函数向http注册自己。最简单的 Express 应用可以像清单 7-2 中的一样简单。

清单 7-2 。intro/2simpler.js

var express = require('express');

express()
    .use(function (req, res, next) {
        res.end('hello express!');
    })
    .listen(3000);

此外,错误处理与 Connect 的工作方式相同,错误处理中间件接受四个参数。正如你所看到的,我们从上一章学到的知识在这里都适用。

流行的 Connect/ExpressJS 中间件

所有连接中间件都是 Express 中间件。然而,并不是所有的 Express 中间件都是 Connect 中间件,因为为了方便起见,Express 对请求和响应做了更多的修改。对于大多数简单的中间件来说,这不是一个问题,但这是一个你需要知道的事实。

在这一节中,我们将展示核心团队中流行的连接/快捷中间件 。

提供静态页面

最常见的事情之一,你会想马上是服务静态网站内容。serve-static中间件(npm install serve-static)就是专门为此设计的。我们在前一章中介绍了一个类似的概念,但是我们没有完成这件事(例如,容易受到基于路径的攻击),因为虽然这些概念很有价值,但是您最好使用serve-static。(参见清单 7-3 。)

清单 7-3 。static/1basic.js

var express = require('express');
var serveStatic = require('serve-static');

var app = express()
    .use(serveStatic(__dirname + '/public'))
    .listen(3000);

使用 node 运行这段代码,您将得到一个简单的 web 服务器,它提供来自/public目录的 web 页面。这个小服务器做了很多好事,包括:

  • 设置响应的正确 mime 类型
  • 具有良好的 HTTP 响应代码(例如,如果您刷新页面,而 HTML 没有改变,您会注意到它发送的响应是 304 Not Modified,而不是 200 OK。如果你请求一个不存在的文件,你会得到一个 404。如果由于某种原因无法访问该文件,它会发送 500 内部服务器错误响应。)
  • 默认情况下,不允许您获取想要提供服务的目录以上的文件(不容易受到前一章中我们的简单服务器中的../path错误的影响)
  • 如果路径解析为目录,则为目录中的index.html提供服务

Image 注意通过使用“__dirname”,我们确保路径总是相对于当前文件,而不是当前工作目录(CWD)。如果我们从另一个目录运行我们的应用,比如从上一级使用“node static/1basic.js”而不是同一个目录,即“”),CWD 可能与文件目录不同。相对路径名,如']'相对于 CWD 进行解析。利用‘??’,使其独立于 CWD。

您还可以将附加选项作为第二个参数传递给serve-static中间件。例如,要设置它应该查找的索引文件,请使用index选项:

app.use(serveStatic(__dirname + '/public', {'index': ['default.html', 'default.htm']}))

Express 将中间件作为其 NPM 包的一部分。所以如果你使用 Express,你可以使用express.static,它是require('serve-static')的别名。清单 7-4 展示了如何重写这个例子。

清单 7-4 。static/2static.js

var express = require('express');

var app = express()
    .use(express.static(__dirname + '/public'))
    .listen(3000);

列表目录内容

要列出一个目录的内容,有一个serve-index (npm install serve-index)中间件。因为它只列出了目录的内容,所以通常将它与serve-static中间件结合使用,以允许用户获取文件。清单 7-5 演示了它的用法。

清单 7-5 。serveindex/basic.js

var express = require('express');
var serveIndex = require('serve-index');

var app = express()
    .use(express.static(__dirname + '/public'))
    .use(serveIndex(__dirname + '/public'))
    .listen(3000);

默认情况下,它会给出一个漂亮的带有搜索框的目录列表页面,如图 7-1 所示。

9781484201886_Fig07-01.jpg

图 7-1 。服务索引中间件的默认目录列表

请注意,我们在serve-index之前注册了serve-static,因为它给了serve-static一个提供索引文件的机会(如果有的话),而不是serve-index用一个目录列表来响应。

接受 JSON 请求和 HTML 表单输入

主体解析是将基于字符串的客户端请求主体解析成 JavaScript 对象的行为,您可以在您的应用代码中轻松使用该对象。这是 web 开发中一个非常常见的任务,这使得中间件成为你工具箱中的必备工具。它只做以下两件事:

  • 如果content-type匹配 JSON ( application/JSON)或用户提交的 HTML 表单(浏览器将其作为 MIME 类型application/x-www-form-urlencoded发送),则将请求体解析为 JavaScript 对象
  • 将这个 JavaScript 对象(如果解析成功)放在req.body中,以便在以后的中间件中访问

清单 7-6 提供了一个简单的例子,根据body-parser中间件解析的内容来响应客户端。

清单 7-6 。bodyparser/basic.js

var express = require('express');
var bodyParser = require('body-parser');

var app = express()
    .use(bodyParser())
    .use(function (req, res) {
        if (req.body.foo) {
            res.end('Body parsed! Value of foo: ' + req.body.foo);
        }
        else {
            res.end('Body does not have foo!');
        }
    })
    .use(function (err, req, res, next) {
        res.end('Invalid body!');
    })
    .listen(3000);

如果请求主体不包含任何JSONurlencoded有效载荷,那么主体解析器将req.body设置为空对象。然而,如果客户端发送了一个无效的 JSON 内容,它会引发一个明显的错误,您可以使用一个错误处理中间件来处理这个错误(如清单 7-7 所示)。

我们可以像在上一章测试我们自己的 JSON 中间件一样测试它。首先,我们发送一个有效的 JSON 负载,然后我们发送一些无效的 JSON,如清单 7-7 所示。

清单 7-7 。使用 JSON 内容测试 bodyparser/basic.js

$ curl http://127.0.0.1:3000/ -H "content-type: application/json" -d "{\"foo\":123}"
Body parsed! Value of foo: 123
$ curl http://127.0.0.1:3000/ -H "content-type: application/json" -d "{\"foo\":123,}"
Invalid body!

如果客户端发送一个 HTML 表单数据(而不是 JSON),中间件允许我们使用相同的代码,如清单 7-8 所示。

清单 7-8 。使用 HTML 表单内容测试 bodyparser/basic.js

$ curl http://127.0.0.1:3000/ --data-urlencode "foo=123"
Body parsed! Value of foo: 123

在前一章中,我们创建了自己的 JSON 解析器。我们的简单实现存在一些问题。首先,它很容易受到恶意客户端的攻击,导致服务器内存耗尽,因为我们需要在调用JSON.parse之前加载整个主体。默认情况下,body-parser将只解析最大 100KB 的有效负载。这是一个很好的默认设置。您可以在创建中间件时通过传递一个选项参数来指定不同的限制,比如use('/api/v1',bodyParser({limit:'1mb'}))

Image 注意 Body-parser内部使用字节(npm install bytes ) NPM 包解析极限值。这是一个简单的包,它导出了一个函数(var bytes = require('bytes')),允许您将常见的字节字符串解析为字节数,如bytes('1kb'), bytes('2mb')bytes('3gb')

记住,所有的中间件都可以安装在特定的路径上,body-parser也不例外。因此,如果您只想对某些 API 端点(如"/api/v1")进行主体解析,您可以使用use('/api/v1',bodyParser())

处理 Cookies

一个 cookie 是从 Web 服务器发送并存储在用户网络浏览器中的一些数据。每次用户的浏览器向 web 服务器发出请求时,web 浏览器都会发回它从服务器收到的 cookie。cookie 为创建用户会话提供了一个很好的基础。

Express response 对象包含一些有用的成员函数来设置客户端 cookies。要设置 cookie,调用res. cookie (cookieName,value,[options])函数。例如,清单 7-9 中的代码会将一个名为'name'的 cookie 设置为'foo':

清单 7-9 。cookie/1basic.js

var express = require('express');

var app = express()
    .use(function (req, res) {
        res.cookie('name', 'foo');
        res.end('Hello!');
    })
    .listen(3000);

如果您运行这个 web 服务器,您将在响应中看到'set-cookie'头,如清单 7-10 中的所示。

清单 7-10 。用 curl 测试 cookie/1 basic . js

$ curl http://127.0.0.1:3000 -i
HTTP/1.1 200 OK
X-Powered-By: Express
Set-Cookie: name=foo; Path=/
Date: Sun, 08 Jun 2014 01:02:23 GMT
Connection: keep-alive
Transfer-Encoding: chunked

Hello!

如果此响应由浏览器处理,那么如果服务器上的路径以“/”开头,浏览器将总是发送名为“name”且值为“foo”的 cookie。客户端在“cookie”报头中发送 cookie。在清单 7-11 中,修改我们的服务器来记录客户端请求中发送的任何 cookies。

清单 7-11 。cookie/2show.js

var express = require('express');

var app = express()
    .use(function (req, res) {
        console.log('---client request cookies header:\n', req.headers['cookie']);
        res.cookie('name', 'foo');
        res.end('Hello!');
    })
    .listen(3000);

如果您在浏览器中打开http://localhost:3000,您将会看到服务器控制台中记录的 cookie:

---client request cookies header:
 name=foo

虽然头很有用,但是您需要将它解析成 JavaScript 对象。这就是 cookie 解析器 ( npm install cookie-parser)中间件的用武之地。将这个中间件放入你的队列中,它将解析后的 cookies 填充到'req.cookies'对象中,如清单 7-12 所示,以演示它的用法。

清单 7-12 。cookie/3parsed.js

var express = require('express');
var cookieParser = require('cookie-parser');

var app = express()
    .use(cookieParser())
    .use(function (req, res) {
        if (req.cookies.name) {
            console.log('User name:', req.cookies.name);
        }
        else {
            res.cookie('name', 'foo');
        }
        res.end('Hello!');
    })
    .listen(3000);

如果您运行这个服务器,它将记录下在客户端请求中找到的name cookie 的值(例如,User name: foo)。否则,它将设置 cookie。该示例还展示了如何通过简单地检查是否在req.cookies对象中设置了特定的键来检查客户端请求中是否存在特定的 cookie。

您也可以使用 Express 提供的res. clearCookie (cookieName, [options])成员函数清除服务器响应中的客户端 cookies。例如,清单 7-13 中的服务器如果没有找到 cookie 就会设置它,如果找到了就会清除它。

清单 7-13 。cookie/4clear.js

var express = require('express');
var cookieParser = require('cookie-parser');

var app = express()
    .use(cookieParser())
    .use('/toggle', function (req, res) {
        if (req.cookies.name) {
            res.clearCookie('name');
            res.end('name cookie cleared! Was:' + req.cookies.name);
        }
        else {
            res.cookie('name', 'foo');
            res.end('name cookie set!');
        }
    })
    .listen(3000);

如果你在浏览器中访问http://localhost:3000/toggle,你会得到消息“名称 cookie 设置!”和“名称 cookie 已清除!”在交替尝试中。在我们清除它之前,我们还向您展示了浏览器发送的 cookie 值(应该是“foo”)。

Image 注意如果你好奇的话,我们会使用之前看到的用于设置初始 cookie 的旧的set-cookie头来清除 cookie。但是,对于 clearing,该值被设置为空,而 expiry 被设置为 UNIX epoch,即服务器响应中的'Set-Cookie: name=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT'头。这告诉浏览器删除 cookie。

使用签名防止 Cookie 用户修改

由于 cookie 存储在客户端系统上(并在客户端请求中发送),用户有可能伪造 cookie。我们可以对 cookie 进行数字签名,以检测任何客户端 cookie 伪造。这个特性也是由同一个cookie-parser中间件提供的。

数字签名确保了数据的真实性。快速 cookie 签名是使用一个密钥哈希消息认证码(HMAC) 完成的。HMAC 是通过获取一个密钥(只有服务器知道)并将其与哈希算法相结合来计算 cookie 内容的哈希。由于秘密只有服务器知道,HMAC 只能由服务器计算和验证。

如果我们使用 Express(通过提供一个密钥)创建一个签名的 cookie,HMAC 值将被附加到我们发送给客户端的 cookie 值中。因此,当客户端在请求中将 cookie 发回给我们时,我们可以查看 cookie 中的 HMAC 值,并将其与重新计算的 HMAC 值进行比较,以检查它是否与内容匹配。如果不匹配,我们知道饼干是坏的,我们可以丢弃它。所有这些都是由cookie-parser中间件为您完成的。

您可以通过将密钥传递给cookie-parser中间件创建函数来为 cookie 签名设置密钥——换句话说,use(cookieParser('optional secret string')).设置一个签名的 cookie,您只需调用res.cookie(name,value,{ signed:true })(换句话说,名称和值为普通值,并传入一个选项signed=true)。为了读取客户端请求中发送的签名 cookie,使用req.signedCookies就像使用req.cookies一样。在读取时使用不同的属性使您很容易知道 cookie 签名已经过验证。作为一个例子,清单 7-14 展示了我们更新的切换 cookie 服务器,以使用签名 cookie。

清单 7-14 。cookie/5sign.js

var express = require('express');
var cookieParser = require('cookie-parser');

var app = express()
    .use(cookieParser('my super secret sign key'))
    .use('/toggle', function (req, res) {
        if (req.signedCookies.name) {
            res.clearCookie('name');
            res.end('name cookie cleared! Was:' + req.signedCookies.name);
        }
        else {
            res.cookie('name', 'foo', { signed: true });
            res.end('name cookie set!');
        }
    })
    .listen(3000);

httpOnly and Secure

默认情况下,用户的浏览器 JavaScript 可以读取为当前网页设置的 cookie(使用document.cookie)。这使得 cookie 容易受到跨端脚本(XSS)的攻击。也就是说,如果某个恶意用户设法将 JavaScript 注入到您的网站内容中,它将允许该 JavaScript 读取可能包含当前登录用户的敏感信息的 cookies,并将它发送到恶意网站。为了防止 JavaScript 访问 cookies,可以将httpOnly设置为true(res.cookie(name,value,{http only:true}))。这告诉浏览器不应该允许任何 JavaScript 访问这个 cookie,并且只应该在与服务器通信时使用它。

此外,正如我们在前一章中所展示的,你应该在你所有的公共服务器上使用 HTTPS。HTTPS 确保包括cookie报头在内的所有报头都被加密,并且不会受到中间人攻击。要让浏览器永远不要通过 HTTP 发送特定的 cookie,只对 HTTPS 使用,可以设置安全标志,即res.cookie(name,value,{ secure:true })

基于你现在所知道的,对于敏感的 cookies 你应该总是使用设置为truehttpOnlysecure。当然,安全需要一个 HTTPS 服务器。

设置 Cookie 到期时间

Cookies 非常适合在浏览器中存储与特定用户相关的持久信息。但是,如果没有在Set-Cookie标题中提到的 cookie 到期时间,浏览器会在关闭后清除 cookie!这种 cookie 通常被称为浏览器会话 cookie(因为它只对当前浏览器会话有效)。如果您希望 cookies 持续一段时间,您应该总是设置expiry。您可以通过向setCookie传递一个maxAge选项来做到这一点,这需要这个 cookie 有效并在客户端请求中发送的毫秒数。例如:

res.cookie('foo', 'bar', { maxAge: 900000, httpOnly: true })

基于 Cookie 的会话

为了在同一用户的不同 HTTP 请求之间提供一致的用户体验,通常在客户端 HTTP 请求旁边提供用户会话信息。举个简单的例子,我们可能想知道用户是否已经登录。

Cookies 为我们希望与客户端请求相关联的少量特定于用户的信息提供了良好的基础。但是 API 太低级了。例如,cookie 值只能是字符串。如果想要 JavaScript 对象,需要做 JSON 解析。这就是cookie-session中间件(npm install cookie-session)的用武之地。它允许您使用单个 cookie 来存储您认为与该用户会话相关的信息。

当使用cookie-session中间件时,用户会话对象被公开为req.session。您可以使用对req.session成员的简单赋值来设置或更新一个值。您可以通过从req.session删除一个键来清除一个值。默认情况下,cookie-session中间件必须被传递至少一个密钥,正如我们前面看到的,它使用这个密钥通过签名来确保会话 cookie 的完整性。清单 7-15 给出了一个简单的例子来演示如何使用cookie-session中间件。

清单 7-15 。cookiesession/counter.js

var express = require('express');
var cookieSession = require('cookie-session');

var app = express()
    .use(cookieSession({
        keys: ['my super secret sign key']
    }))
    .use('/home', function (req, res) {
        if (req.session.views) {
            req.session.views++;
        }
        else{
            req.session.views = 1;
        }
        res.end('Total views for you: ' + req.session.views);
    })
    .use('/reset',function(req,res){
        delete req.session.views;
        res.end('Cleared all your views');
    })
    .listen(3000);

启动该服务器,打开浏览器,访问http://localhost:3000/home查看计数器的增量,访问http://localhost:3000/reset重置计数器。除了像我们演示的那样从会话中清除单个值之外,您还可以通过将req.session设置为null,换句话说就是req.session=null,来删除整个用户会话。

您可以在cookieSession函数中传递附加选项(除了keys)。要指定存储会话的 cookie 名称,可以传入name选项。默认情况下,cookie 名称为express:sess。还支持其他一些 cookie 选项,例如maxagepathhttpOnly(默认为true)和signed(默认为true)。

谨慎使用 Cookie 会话

请注意,cookie 需要在 HTTP 报头中随每个客户端请求一起发送(如果路径匹配),因此大的 cookie 会影响性能。浏览器允许在 cookie 中存储多少信息也有限制(大多数浏览器的一般指导是 4093 字节,每个站点最多 20 个 cookie)。因此,让所有用户信息成为 cookie 的一部分是不可行的。

更强大的会话管理策略是使用数据库来存储用户会话信息,而不是使用 cookies。在这种情况下,您只需在用户 cookie 中存储一个令牌,该令牌将指向我们可以从服务器数据库中读取的会话信息。为此,您可以将express-session中间件(npm install express-session)与数据库(如 Mongo 或 Redis)一起使用。我们将在第八章中看到一个例子。

压缩

多亏了压缩(npm install compression)中间件,在基于 Express 和 Connect 的应用中,通过网络进行 Zip 压缩非常容易。清单 7-16 是一个简单的服务器,它在将大于1kb的页面发送给客户端之前对其进行压缩。

清单 7-16 。compression/compress.js

var express = require('express');
var compression = require('compression');

var app = express()
    .use(compression())
    .use(express.static(__dirname + '/public'))
    .listen(3000);

您可以使用curl通过指定--compressed命令行标志来测试它,如清单 7-17 所示,告诉服务器您可以处理压缩信息。

清单 7-17 。使用 curl 测试 compression/compress.js

$ curl http://127.0.0.1:3000 -i --compressed
HTTP/1.1 200 OK
...truncated...
Content-Encoding: gzip
Connection: keep-alive
Transfer-Encoding: chunked

<div>Hello compression!</div>
<div>
lalalalalalalalalalalalalalalalalalalala
... truncated ...

请注意,即使启用了压缩,Node.js 仍然可以传输响应。要指定不同于默认1kb的阈值,您可以使用threshold选项来指定字节数。例如,compression({threshold: 512})将压缩长度超过 512 字节的响应。

超时挂起请求

可能会出现这样的情况:一些中间件无法结束请求,而无法调用next。例如,如果您的数据库服务器关闭,而您的中间件正在等待数据库服务器的响应,就会发生这种情况。在这种情况下,客户机 HTTP 请求将被挂起,占用服务器内存。在这些情况下,您应该让客户端请求超时,而不是让它挂起。

这正是连接超时 ( npm install connect-timeout)中间件的用途,如清单 7-18 所示。

清单 7-18 。timeout/basic.js

var express = require('express');
var timeout = require('connect-timeout');

var app = express()
    .use('/api', timeout(5000),
                function (req, res, next) {
                    // simulate a hanging request by doing nothing
                })
    .listen(3000);

如果您启动这个 web 服务器并访问http://localhost:3000/api,请求将挂起五秒钟,之后connect-timeout中间件启动并终止请求,向客户端发送 503 服务不可用 HTTP 响应。

您可以通过添加一个错误处理中间件来定制超时的响应,并通过检查req.timedout属性来检查是否发生了超时,如清单 7-19 所示。

清单 7-19 。timeout/error.js

var express = require('express');
var timeout = require('connect-timeout');

var app = express()
    .use('/api', timeout(5000)
               , function (req, res, next) {
                   // simulate a hanging request by doing nothing
               }
               , function (error, req, res, next) {
                   if (req.timedout) {
                       res.statusCode = 500;
                       res.end('Request timed out');
                   }
                   else {
                       next(error);
                   }
               })
    .listen(3000);

请注意,您不应该在顶层使用这个中间件('/'),因为您可能希望流式传输一些响应,这些响应可能比您之前所想的要长。

小心休眠的中间件

当您使用这个中间件时,您需要注意挂起的中间件突然醒来并调用next的情况(例如,一个数据库请求花费了比预期更长的时间,但最终成功了)。在这种情况下,您应该检查req.timedout并阻止中间件继续操作,因为错误处理响应已经被发送了。这在清单 7-20 中演示过,它会在第一次请求时崩溃。

清单 7-20 。超时/比例错误。射流研究…

var express = require('express');
var timeout = require('connect-timeout');

var app = express()
    .use(timeout(5000))
    .use(function (req, res, next) {
        // simulate database action that takes 6s
        setTimeout(function () {
            next();
        }, 6000)
    })
    .use(function (req, res, next) {
        res.end('Done'); // ERROR request already terminated
    })
    .listen(3000);

为此,你应该在你的链中的每一个中间件之后使用一个实用程序 halt 函数,这很容易被挂起,如清单 7-21 所示。

清单 7-21 。time out/progogateerrorhandled . js

var express = require('express');
var timeout = require('connect-timeout');

var app = express()
    .use(timeout(1000))
    .use(function (req, res, next) {
        // simulate database action that takes 2s
        setTimeout(function () {
            next();
        }, 2000)
    })
    .use(haltOnTimedout)
    .use(function (req, res, next) {
        res.end('Done'); // Will never get called
    })
    .listen(3000);

function haltOnTimedout(req, res, next) {
    if (!req.timedout) next();
}

快递响应对象

Express response 源自我们在前一章中看到的标准 Node.js 服务器响应对象。它还添加了许多有用的实用功能,让您的 web 开发体验更加有趣。其实我们已经看到了res.cookie / res.clearCookie函数,是 Express 提供的。

响应有一个函数res.status,除了它是可链接的之外,与设置res.statusCode的效果相同。例如:

res.status(200).end('Hello world!');

要一次设置单个或多个响应头,你可以使用res.set函数,如清单 7-22 所示。

清单 7-22 。使用 set 方法

res.set('Content-Type', 'text/plain');

res.set({
  'Content-Type': 'text/plain',
  'Content-Length': '123',
  'ETag': '12345'
})

同样,要得到一个排队头,除了res.getHeader上的 good,还有一个res.get,就是case-insensitive:

res.get('content-Type'); // "text/plain"

如果您想做的只是设置content-type(一个常见任务),它提供了一个很好的utility res.type(type)函数,可以直接获取content-type,甚至可以根据文件扩展名或文件扩展名为您查找内容类型。例如,以下所有内容具有相同的效果:

res.type('.html');
res.type('html');
res.type('text/html');

发送重定向响应是一项非常常见的任务。Express 提供了res.redirect([status], url)功能,让您的工作变得非常简单。url参数可以是绝对的,相对于站点根目录,相对于当前 URL,甚至相对于中间件挂载点,如清单 7-23 所示。

清单 7-23 。使用重定向方法

res.redirect('http://example.com'); // absolute
res.redirect('/login'); // relative to site root
res.redirect('../bar'); // relative to current url
res.redirect('foo/bar'); // relative to middleware mount point

// Status code demo
res.redirect(301, 'http://example.com');

默认的状态代码 302 FOUND 是很好的,但是如果您愿意,可以通过传递第一个数字参数来覆盖它。

简化发送

有一个非常有用的功能,一旦你学会了,你就不能停止使用。其res.send([body|status], [body])。每当您想要发送非流响应时,都应该使用这个函数。这极大地简化了我们一直使用的声明状态和发送主体的常见模式,如清单 7-24 所示。

清单 7-24 。发送拯救线路,线路拯救小猫

// instead of
res.statusCode = 404;
res.end('These are not the droids you are looking for');

// you can do
res.send(404, 'These are not the droids you are looking for');

它还允许您一次性将 JavaScript 对象作为 JSON 发送。如果您传入一个 JavaScript 对象作为主体,它也会为您将content-type头设置为application/json:

res.send({ some: 'json' });

最后,您可以发送一个状态代码。如果是已知的状态代码,将自动为您填充正文。例如,在下面的示例中,它将显示 OK:

res.send(200); // OK

快速请求对象

与响应类似,Express request 对象源自我们在第六章中看到的 Node.js 请求对象。Express 增加了一些很好的特性,我们将在本节中探讨这些特性。

Express 用一个req.get函数简化了对请求头的访问(正如我们在上一章看到的req.headers),它允许不区分大小写的查找,如清单 7-25 所示。

清单 7-25 。演示 Get 方法

req.get('Content-Type'); // "text/plain"
req.get('content-type'); // "text/plain"
req.get('not-present'); // undefined

如果你想做的只是查找请求的content-type,你可以使用如清单 7-26 所示的实用程序req.is(type)函数,它甚至会为你做一个 mime 类型的检查。

清单 7-26 。使用 Is 方法

// When Content-Type is application/json
req.is('json'); // true
req.is('application/json'); // true
req.is('application/*'); // true
req.is('html'); // false

您可以使用req.ip属性获取客户端 IP 地址。

要检查请求是否来自 HTTPS,可以使用req.secure标志,如果请求来自 HTTPS,则为true,否则为false

URL 处理

Express 将来自 URL 的查询参数解析到req.query JavaScript 对象中。当您想要返回搜索结果时,查询参数非常有用。清单 7-27 提供了一个如何将 URL 查询部分解析成 JavaScript 对象的例子。

清单 7-27 。展示内置查询解析的演示

// GET /shoes?order=desc&shoe[color]=blue&shoe[type]=converse
req.query.order // "desc"
req.query.shoe.color // "blue"
req.query.shoe.type // "converse"

如果只是想要 URL 的路径段(也就是查询前的段),可以在req.path中找到:

// GET /users?sort=desc
req.path // "/users"

当您的中间件被挂载时,Express 试图让您更容易地只访问req.url的相关部分。例如,如果您在''/api'挂载您的中间件,对于请求'/api/admin',req.url将只是'/admin'。清单 7-28 展示了这一点。

清单 7-28 。requestmount/mountUrl.js

var express = require('express');

express()
    .use('/home', function (req, res, next) {
        console.log('first:', req.url); // GET /home => "first: /"
        next();
    })
    .use(function (req, res, next) {
        console.log('second:', req.url); // GET /home => "second: /home"
        next();
    })
    .listen(3000);

要获得完整的原始 URL,您可以使用req.originalUrl属性。

使请求和响应交叉可见

Express 还将响应对象分配给req.res,将请求对象分配给res.req。这使得您可以只传递其中的一个(请求或响应),并在调试时访问相应的请求或相应的响应对象。

理解休息

REST ( 表述性状态转移)是 Roy Fielding(HTTP 规范的主要作者之一)创造的一个术语,作为一种通用的架构风格,规定了分布式超媒体系统中连接的组件应该如何行为的约束。遵循这些约束的 Web APIs 被称为 RESTful。

在 REST 中,有两大类 URL。指向集合的 URL(如http://example.com/resources),以及指向集合中单个项目的 URL(如http://example.com/resources/item5identifier)。为了实现 RESTful,您需要根据 URL 的种类和客户端使用的 HTTP 方法,坚持如表 7-1 中针对集合和表 7-2 中针对项目 URL 所示的行为。还要注意集合 URL 和集合 URL 中的项之间的关系(集合 URL +集合中的项标识符)。

表 7-1 。用于集合URL 的 RESTful API HTTP 方法行为

|

HTTP 方法

|

行为

|
| --- | --- |
| 得到 | 获取集合成员的汇总详细信息,包括它们的唯一标识符。 |
| 放 | 用新系列替换整个系列。 |
| 邮政 | 在收藏中添加一个新的物品。通常为创建的资源返回一个唯一的标识符。 |
| 删除 | 删除整个收藏 |

表 7-2 。用于项目URL 的 RESTful API HTTP 方法行为

|

HTTP 方法

|

行为

|
| --- | --- |
| 得到 | 获取物品的详细信息。 |
| 放 | 替换该项目。 |
| 邮政 | 会将该项目视为一个集合,并且在集合中添加一个新的子项。通常不使用它,因为您倾向于简单地替换整个项目的属性(换句话说,使用 PUT)。 |
| 删除 | 删除项。 |

建议您将新项目的详细信息放在 put 和 POST 消息的正文中。同样值得一提的是,在 HTTP 中,GET 和 DELETE 方法中不能有请求体。

快速申请路线

由于 HTTP 动词在制作良好的 web APIs 时的重要性,Express 提供了一流的基于动词+ URL 的路由支持。

让我们从基础开始。您可以调用app.get / app.put / app.post /app.delete—换句话说,app.VERB(path, [callback...], callback)—来注册一个中间件链,只有当客户端请求中的 path + HTTP 动词匹配时,才会调用这个中间件链。您还可以调用app.all来注册一个只要路径匹配就被调用的中间件(不考虑 HTTP 动词)。清单 7-29 是一个简单的演示来说明这一点。

清单 7-29 。approute/1verbs.js

var express = require('express');

var app = express();
app.all('/', function (req, res, next) {
    res.write('all\n');
    next();
});
app.get('/', function (req, res, next) {
    res.end('get');
});
app.put('/', function (req, res, next) {
    res.end('put');
});
app.post('/', function (req, res, next) {
    res.end('post');
});
app.delete('/', function (req, res, next) {
    res.end('delete');
});
app.listen(3000);

所有这些方法形成了一个标准的中间件链,其中 order + calling next很重要。如果您运行这个服务器,您会注意到,.all中间件总是在相关动词中间件之后被调用。我们可以通过使用curl并指定要使用的请求(-X)动词来测试它,如清单 7-30 所示。

清单 7-30 。使用 curl 测试 approute/1 verbs . js

$ curl http://127.0.0.1:3000
all
get
$ curl -X PUT http://127.0.0.1:3000
all
put
$ curl -X POST http://127.0.0.1:3000
all
post
$ curl -X DELETE http://127.0.0.1:3000
all
delete

创建路线对象

现在,在这些路线中指定路径可能很麻烦(并且容易出现拼写错误)。因此,Express 有一个很好的小app.route成员函数,只指定一次前缀,它返回一个具有相同all/get/put/post/delete函数的 route 对象。所有这些都在清单 7-31 中的示例中进行了演示。输出与我们在前面的例子中看到的完全一样(清单 7-20 )。

清单 7-31 。approute/2route.js

var express = require('express');

var app = express();
app.route('/')
    .all(function (req, res, next) {
        res.write('all\n');
        next();
    })
    .get(function (req, res, next) {
        res.end('get');
    })
    .put(function (req, res, next) {
        res.end('put');
    })
    .post(function (req, res, next) {
        res.end('post');
    })
    .delete(function (req, res, next) {
        res.end('delete');
    });
app.listen(3000);

深入了解路径选项

与采用路径 前缀app.use函数不同,ExpressJS 中基于动词的路由匹配精确路径(而不是精确 URL ,因为查询字符串部分被忽略)。如果要匹配路径前缀,可以使用*占位符来匹配前缀后面的任何内容。您还可以基于正则表达式设置路由。清单 7-32 展示了所有这些选项。

清单 7-32 。approute/3path.js

var express = require('express');

var app = express();
app.get('/', function (req, res) {
    res.send('nothing passed in!');
});
app.get(/^\/[0-9]+$/, function (req, res) {
    res.send('number!');
});
app.get('/*', function (req, res) {
    res.send('not a number!');
});
app.listen(3000);

第一个中间件仅在路径恰好是'/'时被调用,在这种情况下,它发送响应,并且不将控制传递给任何其他中间件。只有当 number regex 匹配时,number 中间件才会被调用,同样,它会返回一个响应,并且不再传递控制。最后,我们有一个包罗万象的中间件。你可以使用curl来测试这个,如清单 7-33 所示。

清单 7-33 。使用 curl 测试 approute/3path.js

$ curl http://127.0.0.1:3000/
nothing passed in!
$ curl http://127.0.0.1:3000/123
number!
$ curl http://127.0.0.1:3000/foo
not a number!

基于参数的路由

一个更好的选择是使用路径参数,而不是在路径前缀匹配中加入太多的过滤逻辑。您可以使用:parameterName语法指定路径参数。例如,/user/:userId将匹配/user/123并为您填充userId请求参数。Express 将所有参数值放在req.params对象中。清单 7-34 展示了它的用法(以及清单 7-35 中显示的一个示例运行)。

清单 7-34 。逼近/4param.js

var express = require('express');

var app = express();
app.get('/user/:userId', function (req, res) {
    res.send('userId is: ' + req.params['userId']);
});
app.listen(3000);

清单 7-35 。使用 curl 测试 approute/4param.js

$ curl http://127.0.0.1:3000/user/123
userId is: 123

实际上,通过使用app.param函数,你可以注册一个中间件来为你加载相关信息。每当路由中的参数名称匹配时,就会调用app.param中间件函数,并且还会将参数值作为第四个参数传入。这在清单 7-36 中得到了演示。

清单 7-36 。逼近/5paramload.js

var express = require('express');

var app = express();
app.param('userId', function (req, res, next, userId) {
    res.write('Looking up user: ' + userId + '\n');
    // simulate a user lookup and
    // load it into the request object for later middleware
    req.user = { userId: userId };
    next();
});
app.get('/user/:userId', function (req, res) {
    res.end('user is: ' + JSON.stringify(req.user));
});
app.listen(3000);

运行这个服务器并执行一个简单的curl请求。您可以看到,如果具有指定参数的路由在任何其他中间件之前匹配,那么就调用了param函数。这允许您创建一个可重用的参数加载中间件:

$ curl http://127.0.0.1:3000/user/123
Looking up user: 123
user is: {"userId":"123"}

快速路由对象

快速路由是中间件+路由的孤立实例。它可以被认为是一个“迷你”Express 应用。你可以使用express.Router()函数很容易地创建一个路由对象。

在根级别,它有use, all, get, post, put, delete, paramroute函数,它们的行为与我们已经看到的 Express app 完全相同。

除此之外,路由对象的行为就像任何其他中间件一样。也就是说,一旦设置了路由对象,就可以使用app.use函数向 Express 注册它。显然,您可以通过向app.use函数传递第一个参数,在指定的挂载点挂载它,就像我们之前已经看到的那样。

为了展示这种模式的威力,让我们创建一个简单的Router,它遵循 REST 的原则,创建一个 web API 来管理内存中任意对象的集合,如清单 7-37 所示。

清单 7-37 。router/basic.js

var express = require('express');
var bodyParser = require('body-parser');

// An in memory collection of items
var items = [];

// Create a router
var router = express.Router();
router.use(bodyParser());

// Setup the collection routes
router.route('/')
      .get(function (req, res, next) {
          res.send({
              status: 'Items found',
              items: items
          });
      })
      .post(function (req, res, next) {
          items.push(req.body);
          res.send({
              status: 'Item added',
              itemId: items.length - 1
          });
      })
      .put(function (req, res, next) {
          items = req.body;
          res.send({ status: 'Items replaced' });
      })
      .delete(function (req, res, next) {
          items = [];
          res.send({ status: 'Items cleared' });
      });

// Setup the item routes
router.route('/:id')
    .get(function (req, res, next) {
        var id = req.params['id'];
        if (id && items[Number(id)]) {
            res.send({
                status: 'Item found',
                item: items[Number(id)]
            });
        }
        else {
            res.send(404, { status: 'Not found' });
        }
    })
    .all(function (req, res, next) {
        res.send(501, { status: 'Not implemented' });
    });

// Use the router
var app = express()
            .use('/todo', router)
            .listen(3000);

除了我们正在创建一个 Express Router之外,所有这些代码您都已经很熟悉了。我们创建一个内存中对象集合。然后我们创建一个路由,并要求它使用body-parser中间件(我们已经看到了)。我们建立了一个根级别的'/'路由。如果您进行 GET 调用,您将获得集合中的所有项目。如果你发表了一篇文章,我们在集合中创建一个新的条目并返回它的索引。如果您发出上传请求,我们会用您上传的内容替换集合。如果您发出删除调用,我们将清除集合。

我们还支持通过 id 获取项目的项目级途径。对于任何其他 HTTP 动词,我们返回 501 未实现。

请注意,由于路由的可安装性,如果我们愿意,我们可以在另一个点(而不是“/todo”)重用相同的路由。这使得功能高度可重用和可维护。

Image 注意记得我们说过req.originalUrl指向原始 URL,而req.url是基于挂载点的精简版本。这正是让在挂载点注册路由成为可能的原因,因为它只在内部查看req.url,并且只获取与其路由相关的部分。

现在让我们用清单 7-38 中的来测试一下。

清单 7-38 。使用 curl 测试 router/basic . js

$ curl http://127.0.0.1:3000/todo
{"status":"Items found","items":[]}

$ curl http://127.0.0.1:3000/todo -H "content-type: application/json" -d 
"{\"description\":\"test\"}"
{"status":"Item added","itemId":0}
$ curl http://127.0.0.1:3000/todo/0
{"status":"Item found","item":{"description":"test"}}
$ curl http://127.0.0.1:3000/todo/
{"status":"Items found","items":[{"description":"test"}]}

$ curl http://127.0.0.1:3000/todo/ -X DELETE
{"status":"Items cleared"}
$ curl http://127.0.0.1:3000/todo/
{"status":"Items found","items":[]}

$ curl http://127.0.0.1:3000/todo -X PUT -H "content-type: application/json" -d 
"[{\"description\":\"test\"}]"
{"status":"Items replaced"}
$ curl http://127.0.0.1:3000/todo/
{"status":"Items found","items":[{"description":"test"}]}

$ curl http://127.0.0.1:3000/todo/0 -X DELETE
{"status":"Not implemented"}

在这个演示中,)我们得到初始集合,它是空的。然后,我们添加一个条目(使用 POST)并查询该条目(GET /todo/0),返回该条目。然后我们查询所有的条目(GET /todo)。接下来,我们删除所有条目并进行查询验证。然后我们放一个新的集合,再次验证。最后,我们表明您不能删除单个项目,因为我们有意屏蔽了该功能。

当设计一个 API 时,你应该保持一致。最常见的情况是,您将使用 JSON,所以确保您接受有效的 JSON ( bodyParser会为您这样做),并在所有情况下返回一个 JavaScript 对象。这包括错误条件,所以客户端总是获得 JSON。还有,标准化的 HTTP 状态码是你的朋友。

在这一点上,我们似乎为一个简单的 API 做了很多工作,但是请记住,如果没有好的 API 设计,你的 UI + API 将会杂乱无章。我们将在本书的后面看到,我们如何在一个干净的 API 之上开发一个好的前端,它既容易维护,又比过去的页面重载 web 设计表现得更好。

额外资源

罗伊·菲尔丁最初在他的论文中提到休息,可以在www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm找到。

摘要

在本章中,我们深入探讨了各种 HTTP 概念。我们首先展示了用于各种 web 相关任务的流行中间件。这包括提供静态网页和使用 cookies。

我们展示了 Express 提供了与 Connect 相同的中间件框架。然后,我们深入研究了 Express 在标准请求和响应对象之上提供的一些不错的增值特性。

最后,我们讨论了休息的原则。我们展示了 Express 框架如何拥抱 web,并使创建可维护的 RESTful web APIs 变得轻而易举。

我们没有涉及 Express 的视图渲染方面,这将在第九章讨论前端设计时涉及。但是,首先,让我们坚持下去。

八、持久化数据

数据持久性是任何真实应用的重要组成部分。在这一章中,我们将为初学者提供一个可靠的数据持久化策略。我们将介绍 MongoDB 以及相关的概念,如 NoSQL、ORM 和 ODM。

NoSQL 简介

NoSQL(不仅仅是 SQL) 是一个术语,用来概括新一代数据库服务器的总体趋势。创建这些服务器是为了应对传统 SQL(结构化查询语言)关系数据库服务器(例如,Oracle 数据库、Microsoft SQL Server 和 MySQL)无法应对的挑战。这些服务器可以分为四大类:

  • 文档数据库(例如,MongoDB)
  • 键值数据库(例如 Redis)
  • 列族数据库(例如,Cassandra)
  • 图形数据库(例如,Neo4J)

可扩展性是所有这些的一个共同的关键动机。在大多数情况下,文档数据库提供了最大的功能集和可接受的/可伸缩的性能。对于不需要复杂查询需求的简单情况,键值数据库提供了最佳性能。

什么是文档数据库?

文档数据库是基于文档概念工作的数据库。什么是文档?一个文档是一个特定实体的独立的信息。清单 8-1 给出了一个可能的 JSON 文档。

清单 8-1 。JSON 文档示例

{
    "firstName": "John",
    "lastName": "Smith",
    "isAlive": true,
    "age": 25,
    "height_cm": 167.64,
    "address": {
        "streetAddress": "21 2nd Street",
        "city": "New York",
        "state": "NY",
    }
}

我们选择使用 JSON 来表示这个文档,但是也可以使用其他格式,比如 XML,甚至二进制格式。在关系数据库中,这样的文档将存储在两个表中,一个是人员表,另一个是地址表。在文档数据库中,它只是一个文档。

什么是键值存储?

键值存储 实际上是文档数据库的精简版本。密钥是标识文档的惟一 ID,值是实际的文档。键值存储与文档数据库的区别在于数据的查询能力。在大多数键值存储中,您只能查询键。在文档数据库中,也可以通过文档内容进行查询。这为键-值存储提供了一个优化的机会,以实现更快的基于键的查找,并且它们可以对值使用更压缩的存储。

Redis 是键值存储的一个很好的例子。它实际上将整个数据库保存在 RAM 中,并在后台备份到磁盘,以获得闪电般的运行时性能。

为什么是 NoSQL?

使用文档数据库和键值存储有两个原因:

  • 可量测性
  • 易于开发

关系设计的可伸缩性问题

在回答关系数据库难以扩展的原因之前,我们先定义几个术语:

  • 可用性:数据是否可访问?也就是说,用户能否阅读数据并对其采取行动。
  • 一致性:真理是否只有单一来源?如果你的所有数据只在一个地方记录一次,那么它就符合我们讨论的目的。

在单台机器上,可用性一致性彼此紧密相连。如果有数据,就是一致的。一台服务器和一台简单的备份服务器足以满足一般企业的需求。关系服务器就是在这种情况下诞生的,在处理负载方面没有问题。

然而,在 Web 上,没有一台机器能够处理每个客户端请求的所有工作负载。此外,您可能希望将服务器分区,使其更靠近客户端的地理位置(例如,美国客户端与澳大利亚客户端)。为了具有可伸缩性,您需要跨机器对数据进行分区。这些分区需要相互通信,以便维护数据的一致视图。现在让我们引入第三个术语:

  • 分区容忍度 : 面对分区间的通信中断,系统继续运行。

考虑这样一种情况,我们通过一个网络分区复制数据:一台服务器在美国,另一台在澳大利亚。当两者之间的网络中断(通信中断)并且澳大利亚的一个用户请求更新数据时,我们是允许(支持可用性)还是拒绝请求以保持我们的一致性

这就是上限定理 的基础。简化来说,可以表达为:假设你有一个网络分区,你需要在可用性和一致性之间做出选择。这不是一个全有或全无的选择;这是一个浮动范围,您可以根据自己的业务需求做出选择。

上限定理是一个物理上容易理解的极限。为了克服它,我们可以尝试将数据分配给从等式中移除分区。这可以很简单地通过将数据分割成独立的单元(称为碎片)来实现。

例如,考虑一个处理美国和澳大利亚酒店的预订系统。在这里,我们可以对数据进行分片,这样,美国的服务器只包含关于美国酒店的信息,而澳大利亚的服务器只包含关于澳大利亚酒店的信息。通过这种方式,每个服务器都是独立的,并且只处理对其包含的数据的请求。对于澳大利亚酒店要求的关于美国酒店的任何信息,我们访问美国服务器,反之亦然。这让我们又回到了过去美好的单服务器场景,可用性和一致性紧密联系在一起。如果美国的服务器关闭(不可用)或从澳大利亚无法访问(网络中断),那么对澳大利亚人的澳大利亚酒店预订做出响应是没有问题的。这种分区不再对可用性一致性选择产生影响

那么,是什么使得关系数据库很难按照 CAP 定理进行扩展呢?就是一致性边界的问题。在关系数据库模式中,鼓励你在不同的表之间建立关系。关系使切分变得困难

在文档数据库中,文档是一致性边界它从一开始就被设计为独立的,数据可以很容易地进行分片。

除了分片,面向文档的数据库设计还有其他积极的性能影响。在关系数据库中,要加载实体(例如,人)的信息,还需要查询链接表(例如,地址表)。就查询响应时间而言,这是一种浪费。对于复杂的实体,您最终需要多个查询来加载单个实体。在面向文档的设计中,复杂的实体仍然只是一个单一的文档。这使得查询和加载文档更快。

易于开发

对象关系映射是计算机科学的越南。

—特德·纽沃德

关系数据库根据关系和表进行操作。为了从应用中使用或操作关系数据库中的数据,我们需要将这些“表”转换成“对象”,反之亦然。这就是对象关系映射(ORM)的过程。ORM 可能是一个繁琐的过程,通过使用文档数据库可以完全避免。对于我们的 person 文档示例,这是一个简单的JSON.parse问题。当然,文档数据库为您提供了一个 API 来获取从数据库返回的 JavaScript 对象,因此您不必进行任何解析,也不必使用 ORM 。

考虑关系数据库而不是 NoSQL 数据库是有现实原因的,比如复杂的查询和数据分析。然而,这些事情可以通过将关系数据库与服务于网站主要数据需求的 NoSQL 数据库并行使用来完成。

到目前为止,我们希望让你相信在网上记录数据库有真正的技术优势。这意味着您可以更好地享受本章的剩余部分,并在项目中努力考虑非关系选项。

安装 MongoDB

MongoDB 这个名字来自于 hu mongo us。安装 MongoDB 极其简单。它以一个简单的 zip 文件的形式发布,包含一些二进制文件,您可以将它们解压到文件系统的任何地方。压缩二进制文件可用于 Windows 以及 Mac OS X 和 Linux 系统。建议您在生产中使用 64 位版本的 MongoDB 以及 64 位操作系统。这允许 MongoDB 使用 64 位操作系统提供的所有内存地址空间。压缩文件可在www.mongodb.org/downloads获得。

理解二进制文件

下载 zip 文件后,将其解压到文件系统中任何有足够空间的文件夹中。你所需要的一切都包含在你为你的操作系统下载的 zip 文件中的bin文件夹中。MongoDB 是自包含的,只要它有写权限,它就不关心它在文件系统上的位置。它易于安装是其受欢迎的原因之一。

bin文件夹包含相当多的二进制文件。windows 二进制文件有一个.exe扩展名,但 Mac OS X 也有相同的扩展名(例如,windows 的mongod.exe与 Mac OS X 的mongod)。最重要的二进制文件如下:

  • mongod.exe:这是MongoDBDaemon——也就是主服务器二进制。这是您将执行来启动数据库服务器的内容。
  • mongo.exe:这是一个随服务器提供的实用程序 REPL,可用于各种管理和代码探索任务。

其他二进制文件存在于bin文件夹中,用于导入/导出数据、收集系统统计数据以及管理 MongoDB 提供的其他功能,如分片和 MongoDB 的分布式文件系统(称为 GridFS)。这些都可以在入门的时候忽略。

为了更容易理解,在解压 zip 文件后,最好将bin文件夹放入系统路径。这将使mongodmongo在你的命令提示符/终端的任何目录下都可用。

运行您的第一台服务器

MongoDB 需要一个数据目录(在 MongoDB 文献中称为dbpath)来存储所有数据库数据。默认的dbpath/data/db(或者 Windows 中的\data\db,基于你当前工作目录的驱动,比如C:\data\db)。最好只指定一个显式的dbpath

首先创建一个db文件夹,它将包含您所有的 MongoDB 数据库数据,放在您有写权限的地方:

$ mkdir db

现在您可以启动 MongoDB 服务器,如清单 8-2 所示。如果一切顺利,您应该会看到waiting for connections消息。服务器启动后,让终端保持打开状态,以保持服务器运行。

清单 8-2 。用指定的数据库目录启动 MongoDB 服务器

$ mongod --dbpath="./db"
Sun Jun 15 17:05:56.761 [initandlisten] MongoDB starting : pid=6076 port=27017 dbpath=./db 64- ...truncated...
Sun Jun 15 17:05:57.051 [initandlisten] waiting for connections on port 27017
Sun Jun 15 17:05:57.051 [websvr] admin web console waiting for connections on port 28017

MongoDB REPL

mongo可执行文件是 MongoDB 的一个交互式 JavaScript shell 接口。您可以使用它来进行系统管理,以及测试对数据库的查询和操作。只要你一启动它,它就试图在默认端口(27017)上连接到localhost

REPL 提供了对一些全局变量和函数的访问,我们可以用它们来与数据库交互。最重要的是db变量,它是当前数据库连接的句柄。一旦 REPL 启动,你可以随时输入exit退出。您可以通过输入help获得可用选项的帮助。此外,我们将在 REPL 中使用的许多函数都有一个'help'成员。让我们转动mongo REPL,看看db上有哪些选项。(参见清单 8-3 。)

清单 8-3 。使用 Mongo Shell 的示例会话

$ mongo
MongoDB shell version: 2.6.1
connecting to: test
> db.help()
DB methods:
        db.adminCommand(nameOrDocument) - switches to 'admin' db, and runs command [ just calls db.runCommand(...)]
        db.auth(username, password)
        db.cloneDatabase(fromhost)
        ...truncated...
        db.version() current version of the server
> exit
bye

注意,默认情况下,它将我们连接到测试数据库。现在您已经知道了如何进出 REPL,让我们来看看几个关键的 MongoDB 概念。

重要的 MongoDB 概念

一个 MongoDB 部署包含多个数据库每个数据库可以包含多个集合。每个集合可以包含多个文档因此,这是一个从数据库到集合再到文档的层次结构。

文档实际上是一个我们已经熟悉的 JSON 文档,再加上一些细微之处。例如,这些文档对Date数据类型有一流的支持(我们在第四章中说 JSON 规范不允许Date作为有效值,必须序列化为字符串)。代表个人的“个人文档”就是一个例子。

一个集合 就是你给一个文档集合起的名字。将多个文档存储到同一个集合中并不在文档上强加任何模式的概念。这取决于你对你的文档语义的训练。这种无模式性使得以敏捷的方式将文档部分升级到新的模式成为可能。

最后,一个 MongoDB 服务器可以包含多个数据库,允许您对服务器中的一组集合进行逻辑分离。使用多个数据库的一个常见用例是多租户应用。多租户应用是指拥有多个客户的应用。对于每个客户,可以有不同的数据库,每个数据库中有相同的集合名称。这允许您有效地使用服务器资源,同时还允许更容易的开发(相同的集合名称)和可维护性(不同数据库的相同备份过程)。

**现在我们已经了解了文档、集合和数据库,让我们在mongo REPL 中探索它们。您可以用'use'命令指定 REPL 将使用的数据库(例如,use demo将使用demo数据库)。如果演示数据库不存在,将为您创建一个。变量db指的是当前活动的数据库。要在当前数据库中使用一个集合,您只需使用db上的 collection name 属性来访问它(例如,db.people将查看当前数据库中的 people 集合)。接下来,使用收集 API 管理单个文档。清单 8-4 提供了一个简单的例子,我们在 people 集合中插入一个人,然后查询集合中的所有人。

清单 8-4 。使用 Mongo Shell 处理数据库、集合和文档

$ mongo
MongoDB shell version: 2.6.1
connecting to: test
> use demo
switched to db demo
> db.people.insert({name:"John"})
WriteResult({ "nInserted" : 1 })
> db.people.find()
{ "_id" : ObjectId("539ed1d9f7da431c00026e17"), "name" : "John" }
>

这个例子相当简单,但是有趣的是,当我们查询文档时,我们看到它有一个_id字段。让我们深入探讨一下。

MongoDB _id 字段

MongoDB 中的每个文档都必须有一个“_id”字段。您可以为_id使用任何值,只要它在集合中是唯一的。默认情况下,MongoDB(提供的客户端驱动或服务器)会为您创建一个ObjectId

为什么不使用自然主键呢?

数据库开发的基本规则是主键决不能改变。对于一些数据模型,您可能想找到一个自然主键(换句话说,对于实体来说是唯一的,并且在其生命周期中不会改变。)例如,对于一个人,你可能会考虑社会安全号码(SSN) 。但是你会惊讶地发现看似普通的自然的 主键 的变化。例如,在美国,如果你的生命处于危险之中,法律允许你申请新的 SSN。类似地,考虑 ISBN 的情况。如果你改变书名(看似自然的要求),你会得到一个新的 ISBN。

在大多数情况下,您会希望使用代理主键。代理主键是对实体没有自然意义的键,但用于在数据库中唯一标识实体。MongoDB 生成的ObjectId就是这样一个高质量的代理键

关于 ObjectId 的更多信息

既然我们已经满怀希望地让您相信使用生成的主键更好,那么问题是为什么要使用ObjectId?为什么不是一个自动递增的数字?因为自动递增的数字不会扩容;在分布式环境中很难管理,因为下一个号码需要知道最后使用的号码。MongoDB 生成的ObjectId 使用 12 字节的存储。这意味着它需要 24 个十六进制数字(每个字节 2 个数字)在一个字符串中表示,正如我们前面看到的(例如,“539ed1d9f7da431c00026e17”)。MongoDB ObjectId的 12 个字节的产生如图图 8-1 所示。

9781484201886_Fig08-01.jpg

图 8-1 。MongoDB ObjectId 的字节结构

前四个字节是自 EPOCH 以来以秒为单位的时间戳。这意味着使用一个ObjectId可以让你在中按照对象被创建的顺序对它们进行排序。我们说大致是,因为产生ObjectId的所有机器的时钟可能不同步。

接下来的三个字节是机器特有的,通常使用机器主机名的散列来生成。这保证了每台机器的唯一性。

接下来的两个字节取自ObjectId生成进程的进程 id (PID ),使其对于单台机器上的单个进程是唯一的。

重申一下,前 9 个字节保证了在一秒钟内跨机器和进程的唯一性。因此,最后三个字节是一个递增的数字,允许在一秒钟内为单台机器上的单个进程提供唯一性。由于每个字节有 256 个可能的值,这意味着我们可以在一秒钟内为每个进程生成256³ = 16,777,216唯一的ObjectId s 。因此,在大多数情况下,您不需要担心唯一性。

让我们在 REPL 玩一会儿。您可以使用new JavaScript 操作符创建一个新的ObjectId。另外,ObjectId提供了一个有用的 API 来获取ObjectId的创建时间(使用值的前四个字节,正如我们看到的,它包含了足够的信息)。清单 8-5 展示了这一点。

清单 8-5 。从 Mongo Shell 探索 ObjectId 的示例会话

$ mongo --nodb
MongoDB shell version: 2.6.1
> var id = new ObjectId()
> id
ObjectId("53a02d3979d8322ea34c4179")
> id.getTimestamp()
ISODate("2014-06-17T11:57:45Z")
>

请注意,我们可以启动 shell,而不需要它尝试使用--nodb标志连接到服务器。

mongodb 文档格式

一个 MongoDB 文档使用 BSON(Binary JSON)存储在内部。这也是 MongoDB 客户机驱动程序在网络上使用的格式。BSON 实际上是一种存储 JSON 文档的二进制方式。

BSON 提供的一个关键特性是长度前缀。换句话说,文档中的每个值都以值的长度为前缀。这使得阅读文档的人更容易跳过与当前客户端请求无关的字段。在 JSON 中,即使您想跳过一个字段,您也需要读取整个字段才能到达结束指示符(“}”或“]”)。

BSON 文档还包含有关字段值类型的信息,例如数值、字符串或布尔值。这有助于解析和执行存储优化。

此外,BSON 还提供了原始 JSON 不支持的其他原语类型,比如 UTC Datetime、原始二进制和ObjectId

使用 Node.js 的 MongoDB

现在我们已经介绍了 MongoDB 的基础知识,让我们看看如何在 Node.js 应用中使用它。

MongoDB 团队维护了一个官方的 Node.js 包(npm install mongodb),用于从 Node.js 与 MongoDB 服务器进行通信。MongoDB 提供的所有异步 API 都遵循 Node.js 约定,第一个参数是一个Error,后跟实际数据(如果有)。您到 MongoDB 服务器的主要连接是使用从 NPM 包中导出的MongoClient 类。清单 8-6 是一个插入一个对象,查询它,然后删除它的演示。

清单 8-6 。crud/basic . js

var MongoClient = require('mongodb').MongoClient;

var demoPerson = { name: 'John', lastName: 'Smith' };
var findKey = { name: 'John' };

MongoClient.connect('mongodb://127.0.0.1:27017/demo', function (err, db) {
    if (err) throw err;
    console.log('Successfully connected');

    var collection = db.collection('people');
    collection.insert(demoPerson, function (err, docs) {
        console.log('Inserted', docs[0]);
        console.log('ID:', demoPerson._id);

        collection.find(findKey).toArray(function (err, results) {
            console.log('Found results:', results);

            collection.remove(findKey, function (err, results) {
                console.log('Deleted person');

                db.close();
            });
        });
    });
});

在这个演示中,我们连接到demo数据库,然后使用people集合。我们在people系列中插入一个演示人物。注意,服务器返回实际插入的对象。还要注意,它用一个_id字段修改了我们的内存文档。然后,我们使用 find 方法搜索任何具有name:'John'的对象。最后,我们从数据库中删除所有这样的对象并断开连接。如果您有一个 MongoDB 服务器运行在localhost上,并且您运行这个应用,您将看到类似于清单 8-7 的输出。

清单 8-7 。crud/basic.js 的运行示例

$ node basic.js
Successfully connected
Inserted { name: 'John',
  lastName: 'Smith',
  _id: 53a14584e33487a017e6e138 }
ID: 53a14584e33487a017e6e138
Found results: [ { _id: 53a14584e33487a017e6e138,
    name: 'John',
    lastName: 'Smith' } ]
Deleted person

这几乎解决了 CRUD 的创建/读取/删除的基本问题。更新在 MongoDB 中确实很强大,值得拥有自己的一节。

更新文档

更新文档最简单的方法是调用集合的save函数 ,如清单 8-8 所示。

清单 8-8 。update/1save.js

var MongoClient = require('mongodb').MongoClient;

var demoPerson = { name: 'John', lastName: 'Smith' };
var findKey = { name: 'John' };

MongoClient.connect('mongodb://127.0.0.1:27017/demo', function (err, db) {
    if (err) throw err;

    var collection = db.collection('people');

    collection.insert(demoPerson, function (err, docs) {

        demoPerson.lastName = 'Martin';
        collection.save(demoPerson, function (err) {
            console.log('Updated');
            collection.find(findKey).toArray(function (err, results) {
                console.log(results);

                // cleanup
                collection.drop(function () { db.close() });
            });
        });
    });
});

您只需更新对象并将其传递回数据库。数据库通过_id查找对象,并按规定设置新值。save功能替换整个文档。然而,大多数时候你并不想用一个新版本替换整个文档。这在一个分布式的数据密集型环境中是非常糟糕的。许多人可能想同时修改文档的不同字段。这就是collection.update方法和更新操作符的用武之地。

更新运算符

集合的update函数有三个参数,一个匹配/查找想要修改的项目的对象,第二个参数指定我们想要在文档中修改的更新操作符 +属性,最后一个参数是更新完成后调用的回调。

让我们考虑一个简单的网站点击计数器的例子。许多用户可能同时访问同一个网站。如果我们从服务器上读取计数器值,在客户机上增加它的值,然后向服务器发送新值,到我们发送它的时候,原来读取的值可能已经过时了。传统上,数据库客户端会请求数据库锁定文档,通过网络向下发送值,接收更新的值,然后请求解锁文档。那会非常慢,因为网络通信需要时间。

这就是更新操作符发挥作用的地方。我们只是使用$inc update 操作符指示 MongoDB 在一个单个客户端请求中递增特定文档的当前视图计数。一旦 MongoDB 接收到请求,它就锁定文档,读取+递增值,并在服务器上解锁文档。这意味着数据库服务器处理请求的速度几乎与通过网络接收请求的速度一样快,并且没有客户端请求需要等待或重试,因为客户端请求处于挂起状态。清单 8-9 是演示这一点的一个简单例子。

清单 8-9 。update/2update.js

var MongoClient = require('mongodb').MongoClient;

var website = {
    url: 'http://www.google.com',
    visits: 0
};
var findKey = {
    url: 'http://www.google.com'
};

MongoClient.connect('mongodb://127.0.0.1:27017/demo', function (err, db) {
    if (err) throw err;

    var collection = db.collection('websites');

    collection.insert(website, function (err, docs) {

        var done = 0;
        function onDone(err) {
            done++;
            if (done < 4) return;

            collection.find(findKey).toArray(function (err, results) {
                console.log('Visits:', results[0].visits); // 4

                // cleanup
                collection.drop(function () { db.close() });
            });
        }

        var incrementVisits = { '$inc': { 'visits': 1 } };
        collection.update(findKey, incrementVisits, onDone);
        collection.update(findKey, incrementVisits, onDone);
        collection.update(findKey, incrementVisits, onDone);
        collection.update(findKey, incrementVisits, onDone);

    });
});

在这个例子中,我们演示了在不等待响应的情况下向服务器发送四个更新请求。每个请求都要求服务器将访问计数加 1。如您所见,当我们获取结果时,在所有四个请求都完成之后,访问计数确实是 4——没有一个更新请求相互冲突。

MongoDB 支持许多其他更新操作符。操作符用于设置单个字段、删除字段、在字段的当前值大于或小于我们想要的值时有条件地更新字段,等等。

此外,还有更新文档中子集合(数组)的操作符。例如,考虑文档中有一个简单的tags字段的情况,它是一个字符串数组。多个用户可能想要更新这个数组—一些用户想要添加一个标签,另一些用户想要删除一个标签。MongoDB 允许您使用$push(添加一个项目)和$pull(删除一个项目)更新操作符来更新服务器上的数组,这样您就不会覆盖整个数组。

猫鼬 ODM

正如我们所看到的,MongoDB 处理的是非常简单的 JSON 文档。这意味着对文档进行操作的业务逻辑(函数/方法)必须存在于其他地方。使用对象文档映射器(ODM) 我们可以将这些简单的文档映射成完整形式的 JavaScript 对象(使用数据+方法进行验证和其他业务逻辑)。最流行的(并且得到 MongoDB 团队支持的)是 mongose ODM(npm install mongoose)。

正在连接到 MongoDB

您可以使用 Mongoose 连接到 MongoDB,方式类似于我们前面看到的本地驱动程序。清单 8-10 是一个直接来自文档的简单例子。

清单 8-10 。ODM/connection . js

var mongoose = require('mongoose');

mongoose.connect('mongodb://127.0.0.1:27017/demo');

var db = mongoose.connection;
db.on('error', function (err) { throw err });
db.once('open', function callback() {
    console.log('connected!');
    db.close();
});

我们使用connect成员函数进行连接。然后,我们使用mongoose.connection访问数据库对象,并等待 open 事件触发,这表示连接成功。最后,我们关闭连接以退出应用。

猫鼬图式和模型

猫鼬的核心是Schema类。schema定义了文档的所有字段以及它们的类型(用于验证目的)和它们在序列化过程中的行为等。

在定义了Schema之后,编译它来创建一个Model函数,这只是一个将简单的对象文字转换成 JavaScript 对象的奇特构造函数。这些 JavaScript 对象具有您使用Schema设置的行为。除了创建这些域对象的能力之外,模型还具有静态成员函数,您可以使用这些函数来创建、查询、更新和删除数据库中的文档。

作为例子,考虑一个简单的坦克Schema。坦克有名称和尺寸(小型、中型和大型)。我们可以非常简单地定义坦克Schema:

var tankSchema = new mongoose.Schema({ name: 'string', size: 'string' });
tankSchema.methods.print = function () { console.log('I am', this.name, 'the', this.size); };

我们还使用Schema定义了在Model实例上可用的方法(例如,我们指定了print方法)。既然有了Schema,那就做个模型吧。该模型将Schema链接到一个数据库集合,并允许您管理(CRUD)模型实例。从一个Schema创建一个Model非常简单:

// Compile it into a model
var Tank = mongoose.model('Tank', tankSchema);

要创建模型的实例,可以像普通的 JavaScript 构造函数那样调用它,并传入原始文档/对象文字:

var tony = new Tank({ name: 'tony', size: 'small' });
tony.print(); // I am tony the small

你可以看到我们在Schema中定义的方法(比如print)在相应的模型实例上是可用的。此外,所有的Model实例都有成员函数来管理它们与数据库的交互,比如保存/删除/更新。在清单 8-11 中显示了一个save调用的例子。

清单 8-11 。保存/更新一个猫鼬模型实例

var tony = new Tank({ name: 'tony', size: 'small' });
tony.save(function (err) {
  if (err) throw err;

  // saved!
})

另外,Model类有静态(独立于模型实例)成员函数来管理相关集合中的所有数据库文档。例如,要找到一个模型实例,您可以使用findOne静态函数,如清单 8-12 所示。

清单 8-12 。使用猫鼬进行单品查询

Tank.findOne({ name: 'tony' })
    .exec(function (err, tank) {

    // You get a model instance all setup and ready!
    tank.print();
});

结合我们所看到的,清单 8-13 提供了一个完整的例子,你可以自己运行。

清单 8-13 。odm/basic.js

var mongoose = require('mongoose');

// Define a schema
var tankSchema = new mongoose.Schema({ name: 'string', size: 'string' });
tankSchema.methods.print = function () { console.log('I am', this.name, 'the', this.size); };

// Compile it into a model
var Tank = mongoose.model('Tank', tankSchema);

mongoose.connect('mongodb://127.0.0.1:27017/demo');
var db = mongoose.connection;
db.once('open', function callback() {
    console.log('connected!');

    // Use the model
    var tony = new Tank({ name: 'tony', size: 'small' });
    tony.print(); // I am tony the small

    tony.save(function (err) {

        Tank.findOne({ name: 'tony' }).exec(function (err, tank) {

            // You get a model instance all setup and ready!
            tank.print();

            db.collection('tanks').drop(function () { db.close();})
        });
    });
});

最后一件需要特别注意的事情是,查询函数(例如,findfindOne)是可以链接的。这允许您通过添加函数调用来构建高级查询。在您调用exec函数之后,最终的查询被发送到服务器。例如,使用一个假设的Person模型,清单 8-14 中的代码搜索洛杉矶的前 10 个姓氏为 Ghost 的人,年龄在 17 到 66 岁之间。

清单 8-14 。演示复杂查询的示例

Person
.find({ city: 'LA' })
.where('name.last').equals('Ghost')
.where('age').gt(17).lt(66)
.limit(10)
.exec(callback);

使用 MongoDB 作为分布式会话存储

在第七章的中,我们看到了如何通过使用cookie-session中间件来使用 cookies 存储用户会话信息。然而,我们指出,使用 cookie 来存储所有您的会话信息是一个坏主意,因为 cookie 需要来自每个请求的客户端,并且您受到 cookie 大小的限制。

理想情况下,您应该尽可能让您的 web 应用无状态。然而,对于某些类型的应用,您可能需要用户会话中的大量信息。这就是express-session中间件(npm install express-session)的用武之地。

默认情况下,express-session中间件将使用内存存储来维护用户会话信息。发送到客户端的 cookie 将只指向这个服务器内存存储中的密钥。考虑清单 8-15 中的服务器,它基于我们在第七章中看到的 cookie 会话服务器。我们所做的只是用express-session中间件替换了cookie-session中间件。

清单 8-15 。session/inmemory.js

var express = require('express');
var expressSession = require('express-session');

var app = express()
    .use(expressSession({
        secret: 'my super secret sign key'
    }))
    .use('/home', function (req, res) {
        if (req.session.views) {
            req.session.views++;
        }
        else {
            req.session.views = 1;
        }
        res.end('Total views for you: ' + req.session.views);
    })
    .use('/reset', function (req, res) {
        delete req.session.views;
        res.end('Cleared all your views');
    })
    .listen(3000);

如果你打开浏览器并访问http://localhost:3000/home,你会看到它的行为和预期的一样——每次刷新页面,你的浏览量都会增加。但是,如果您重新启动 Node.js 服务器并再次刷新浏览器,计数将回到 1。这是因为服务器内存在重启时被清空。用户 cookie 只包含服务器内存中会话值的密钥,而不包含实际的会话值。对网络性能有利(cookie 是轻量级的),对可伸缩性不利,因为会话值被限制到单个服务器上的单个进程。

这就是express-session中间件的store配置选项的用武之地。商店可用于各种数据库,但是因为我们正在讨论 MongoDB,所以让我们使用它。MongoDB 会话存储由connect-mongo ( npm install connect-mongo ) NPM 包提供。使用它非常简单——获得对MongoStore类的引用,并创建一个 store 实例,为您想要连接的数据库传递连接配置。清单 8-16 提供了完整的例子,突出显示了更改的部分。

清单 8-16 。会话/分布式. js

var express = require('express');
var expressSession = require('express-session');

var MongoStore = require('connect-mongo')(expressSession);
var sessionStore = new MongoStore({
    host: '127.0.0.1',
    port: '27017',
    db: 'session',
});

var app = express()
    .use(expressSession({
        secret: 'my super secret sign key',
        store: sessionStore
    }))
    .use('/home', function (req, res) {
        if (req.session.views) {
            req.session.views++;
        }
        else {
            req.session.views = 1;
        }
        res.end('Total views for you: ' + req.session.views);
    })
    .use('/reset', function (req, res) {
        delete req.session.views;
        res.end('Cleared all your views');
    })
    .listen(3000);

确保本地运行 MongoDB,并启动 Node.js 服务器。如果你现在访问http://localhost:3000/home,你会得到和以前一样的行为,除了这一次,你可以安全地重启你的服务器,如果用户重新加载页面,最后的浏览计数被保留。这里最重要的一点是,可能有许多 Node.js 服务器与 MongoDB farm 对话,无论哪个 Node.js 服务器处理请求,用户行为都是相同的。

经验:当您想在用户会话中只存储少量信息时,请使用cookie-session。当您的会话信息太多时,使用带有后备存储的express-session

管理 MongoDB

当您刚开始使用 MongoDB 时,仅仅使用像mongo这样的 REPL 可能会令人生畏。最终,它对于快速查找是有用的,但是对于初学者来说,更好的 GUI 工具可能是救命稻草。为了管理生产服务器,MongoDB 团队本身提供了托管的 MongoDB 管理服务(MMS ) 。

对于开发时间,我们想推荐 Robomongo 桌面应用(http://robomongo.org/)。这是一个开源应用,正在积极开发中,他们为 Windows ( .msi)和 Mac OS X ( .dmg)提供安装程序。

一旦安装完毕,你只需启动软件,连接到 MongoDB 服务器,你就可以看到数据库、集合和文档,如图 8-2 所示。这个应用的一个优点是它将mongo shell 集成到了它的 GUI 中,因此您所有的终端技能在这里都是相关的。

9781484201886_Fig08-02.jpg

图 8-2 。Robomongo 的截图,突出显示了重要部分

额外资源

MongoDB 中的多租户:http://support.mongohq.com/use-cases/multi-tenant.html

BSON 语言规范:http://bsonspec.org/

蒙戈布 ObjectId: http://api.mongodb.org/java/current/org/bson/types/ObjectId.html

MongoDB 更新操作符:http://docs.mongodb.org/manual/reference/operator/update/

猫鼬 ODM: http://mongoosejs.com/

MongoDB 管理服务:www.mongodb.com/mongodb-management-service

摘要

在这一章中,我们研究了使用文档数据库的动机。然后,我们研究了 MongoDB,解释了重要的概念,并展示了在 Node.js 应用中使用 MongoDB 的支持方式。

关于 MongoDB、Mongoose 和查询/索引还有很多可以说的,但是我们在这里介绍的内容应该足以让您自己轻松地探索这个 API。**

九、前端基础知识

在前几章中,我们已经讨论了如何创建 web 服务器、web 服务和 API。我们还展示了如何在数据库中保存数据。现在我们将探索前端。在这一章中,我们将深入探讨单页应用(SPA)的概念,并使用 AngularJS 创建一个。我们的前端将与一个简单的 Express web 服务通信,该服务将我们的数据存储在一个 MongoDB 数据库中。

像往常一样,在我们开始这一旅程之前,我们将解释这一领域的所有重要概念,并为我们的技术选择提供理由,以便您对其基本原则有深刻的理解。

什么是 SPA?

在 SPA 中,应用的所有基本代码(HTML/CSS/JavaScript)都是在第一次向 web 服务器发出请求时预先加载的。一个常见的 SPA 示例是 Google 的 Gmail ( www.gmail.com)网站。

在传统网站中,当你从一个页面导航到另一个页面时,整个页面被重新加载,如图图 9-1 所示。

9781484201886_Fig09-01.jpg

图 9-1 。传统网站体验

这对于网站来说是一种不错的体验,但对于 web 应用来说却不是一种好的体验。在 SPA 中,一旦您请求一个 web 页面,服务器就会返回主模板(通常称为index.html)以及必要的客户端 JavaScript 和 CSS。一旦这个初始加载完成,用户与网站的交互所做的就是使用客户端 xhr(XMLHttpRequest)从服务器加载更多的数据。然后,这些数据通过 JavaScript 使用已经下载的 HTML/CSS 呈现在客户端上,如图 9-2 所示,让用户体验到更多的桌面应用体验。

9781484201886_Fig09-02.jpg

图 9-2 。单页面应用用户体验

你可以自己编写这些代码;然而,SPA 框架已经解决了一些技术难题(例如,如何将服务器返回的数据与 HTML 结合起来显示一个呈现的页面)。我们将使用这样一个 SPA 框架:AngularJS。

Image 注意XMLHttpRequest【XHR】是一个在所有现代浏览器中都可用的全局类,允许您使用 JavaScript 发出 HTTP 请求。名称是XMLHttpRequest,以确保所有的浏览器都遵循这个类的相同名称。这个名称中有 XML ,因为这是最初用于发出 HTTP 请求的数据格式,但是现在已经没有关系了,因为它可以用于发出任何格式的 HTTP 请求。其实现在大部分人只是用 JSON 格式。

为什么是安圭拉人?

有很多高质量的、社区驱动的单页应用框架,比如 AngularJS、EmberJS、ReactJS、KnockoutJS 和 BackboneJS,但是到目前为止,最大的社区兴趣是围绕 Google 创建的 AngularJS。这可以从图 9-3 中显示的这些框架的谷歌搜索趋势中看出。

9781484201886_Fig09-03.jpg

图 9-3 。各种 SPA/数据绑定框架的 Google 搜索趋势

它受欢迎的主要原因是它来自 Google 的一个团队,它很简单,而且功能丰富:

  • 数据绑定/模板化:允许您根据底层 JavaScript 对象的变化来更新 DOM(呈现的 HTML)
  • URL 处理/路由:处理浏览器的地址栏,根据需要加载和呈现模板,为用户提供流畅的导航体验
  • 依赖注入:给你清晰的指导来组织你的客户端 JavaScript,以增强团队的工作流程并简化可测试性

我们将在本章中使用 AngularJS 的特性,看看它是如何工作的,但现在你有几个理由对 AngularJS 感到兴奋。AngularJS 可以从https://angularjs.org/下载。

Twitter Bootstrap 简介

我们将使用 Twitter 引导来设计/样式我们的应用的前端。HTML/CSS 为你设计 UI 提供了基础。你可以从头开始设计任何你想要的东西,但是你最好还是使用别人已经为你创造的东西。Twitter Bootstrap 来自于twitter.com的一个设计师团队。可以从http://getbootstrap.com/下载 Bootstrap。

在其核心,Bootstrap 基本上是一堆 CSS 类,允许您快速定制呈现 HTML 的方式。例如,考虑一个只有一个按钮的简单 HTML 页面:

<button>I am a button</button>

默认情况下,在 Windows 上看起来如下所示:

9781484201886_unFig09-01.jpg

如果在 HTML 中添加了对引导 CSS 文件的引用,就可以访问许多 CSS 类来设计按钮的样式。这正是我们在清单 9-1 中所做的。

清单 9-1 。bs/bs.html

<!-- Add Bootstrap CSS -->
<link rel="stylesheet" type="text/css" href="./bootstrap/css/bootstrap.css">

<!-- Use Bootstrap CSS classes -->
<button class="btn btn-default">Default style button</button>
<button class="btn btn-primary">Primary style button</button>
<button class="btn btn-success">Success style button</button>
<button class="btn btn-danger">Danger style button</button>

每个平台上都有风格一致的按钮:

9781484201886_unFig09-02.jpg

Bootstrap 还附带了一些提供高级用户交互的 JavaScript 组件。Bootstrap JavaScript 依赖 JQuery ( http://jquery.com/download/)来提供一致的 DOM 操作 API。为了使用 JavaScript 组件,我们需要包含 JQuery 和引导 JavaScript 文件。清单 9-2 展示了如何使用引导工具提示。

清单 9-2 。操作系统/bsjs.html

<!-- Add JQuery + Bootstrap JS + CSS-->
<script src="./jquery/jquery.js"></script>
<script src="./bootstrap/js/bootstrap.js"></script>
<link rel="stylesheet" type="text/css" href="./bootstrap/css/bootstrap.css">

<!-- Use a button with a nice tooltip shown at the bottom -->
<button class="btn btn-default"
        data-toggle="tooltip" data-placement="bottom" title="Nice little tooltip message">
    Hover over me to see the tooltip
</button>

<!-- on page loaded initialize the tooltip plugin -->
<script>
$(function(){ // on document ready
    $('button').tooltip(); // add tooltip to all buttons
});
</script>

在这个代码示例中,有趣的是变量$的用法,它是由 JQuery 提供的,用于注册一个回调函数,一旦浏览器(on document ready)呈现了 HTML 文档,就会调用这个回调函数。然后在回调中,我们使用$通过$('button')选择所有按钮标签,然后调用 bootstrap 工具提示插件根据元素的属性(data-toggledata-placementtitle)对其进行初始化。如果您运行这个应用并将鼠标悬停在按钮上,您将看到一个包含 title 属性内容的漂亮工具提示:

9781484201886_unFig09-03.jpg

我们将使用 Bootstrap 来给我们的 UI 一个好看的外观。Bootstrap 也有一些整页的布局让你开始你的项目,你可以从http://getbootstrap.com/getting-started/下载。

Image 注意 JQuery 是目前最流行的 JavaScript 库。它提供了一致的 API 来跨所有浏览器访问文档对象模型(DOM)。DOM 基本上是浏览器提供的 API,用于使用 JavaScript 与呈现的 HTML 进行交互。由于不同的供应商引入了不同的特性来相互竞争,DOM API 传统上一直受到浏览器之间不一致的困扰。JQuery 处理了这些不一致性,提供了一个统一的 API 和增值特性,比如一个非常棒的 DOM 查询 API(类似于 CSS 选择器)。

建立一个简单的 AngularJS 应用

制作我们的单页应用的第一步是创建一个 Express 服务器来服务客户端 JavaScript HTML 和 CSS,如清单 9-3 所示。这是一个用我们已有的知识完成的琐碎任务(第七章)。

清单 9-3 。angularstart/app.js

var express = require('express');

var app = express()
    .use(express.static(__dirname + '/public'))
    .listen(3000);

这提供来自公共文件夹的 HTML。现在我们将在我们的公共文件夹中创建一个供应商文件夹来包含我们的 JQuery、AngularJS 和 Bootstrap 文件,如清单 9-4 所示。最后,我们有一个简单的index.html文件。

清单 9-4 。angularstart/public/index.html

<html ng-app="demo">
<head>
    <title>Sample App</title>

    <!-- Add JQuery + Bootstrap JS / CSS + AngularJS-->
    <script src="./vendor/jquery/jquery.js"></script>
    <script src="./vendor/bootstrap/js/bootstrap.js"></script>
    <link rel="stylesheet" type="text/css" href="./vendor/bootstrap/css/bootstrap.css">
    <script src="./vendor/angular/angular.js"></script>
    <script src="./vendor/angular/angular-route.js"></script>

    <!-- Our Script -->
    <script>
        var demoApp = angular.module('demo', []);
        demoApp.controller('MainController', ['$scope', function ($scope) {
            $scope.vm = {
                name: "foo",
                clearName: function () {
                    this.name = ""
                }
            };
        }]);
    </script>
</head>
<body ng-controller="MainController">
    <!-- Our HTML -->
    <label>Type your name:</label>
    <input type="text" ng-model="vm.name" />
    <button class="btn btn-danger" ng-click="vm.clearName()">Clear Name</button>
</body>
</html>

突出显示了该文件的重要部分。为了简单起见,我们将把整个客户端脚本放在一个位置。(我们将在未来对此使用简单的单个脚本标记,而不是将其内联,如此处所示)。此外,我们的整个 HTML 将位于body标签中的一个位置。我们将在进行过程中充实这些部分。如果您现在运行这个 Express 服务器并访问http://localhost:3000,您将看到一个简单的 AngularJS 应用。如果在输入框中输入你的名字,其下方的 div 会实时更新,如图图 9-4 所示。此外,您也可以按“清除姓名”按钮来清除姓名。

9781484201886_Fig09-04.jpg

图 9-4 。在浏览器中运行的 Angularstart 示例

现在让我们进一步检查我们的 HTML 页面。重要的部分如下:

  • 在我们的 HTML ng-app/ng-controller/ng-model里面有棱角的指令??。
  • 主角 模块在我们的 JavaScript 中创建,名为demo。也在ng-app指令中使用,它将 JS 模块粘合到 HTML。
  • 主角度控制器在我们 JS 里叫Main Controllerng-controller指令将 JS 控制器粘合到 HTML 上。
  • $scope 通过角度注入控制器。范围是 HTML 和控制器之间的双向数据绑定粘合剂。

现在你已经对一个简单而实用的 Angular 应用有了一个大致的了解,让我们看看 AngularJS 中的模块、指令、控制器和作用域是什么意思。

AngularJS 中的模块

AngularJS 中的模块允许您包含和管理所有的控制器、指令等等。您可以使用ng-app指令关联一个特定的模块来管理 HTML 的一部分(这就是我们所做的)。我们还使用该模块将MainController注册到 Angular ( demoApp.controller)。模块只是一个便于管理的容器。

角度中的指令

指令基本上是当 Angular 在 HTML 中找到匹配的字符串时,您希望 Angular 执行(以提供行为)的代码段。例如,在我们的应用中,我们要求 Angular 使用我们的html标签(<html ng-app="demo">))上的ng-app HTML 属性来运行ng-app指令。类似地,我们触发了ng-controllerng-model指令。

带有前缀的命名空间指令是一种惯例。Angular 附带的指令使用ng-前缀。使得在 HTML 中更容易观察到这个标签上有一个指令。如果初学者看到ng-app,他或她会得到一个提示,这是一个角度指令,一些自定义行为将被应用。

创建你自己的指令和严肃的 AngularJS 应用开发的组成部分是非常容易的。但是现在我们将坚持 Angular 附带的指令,它可以带你走很长的路。

控制器和$scope

控制器是 AngularJS 的心脏和灵魂。这些是双向数据绑定的一半。我们所看到的其他一切(模块、指令)都可以被认为是控制器之旅。

控制器之所以称为控制器,是因为模型-视图-控制器(MVC) 模式。在 MVC 模式中,控制器负责保持视图和模型的同步。在 Angular 中,视图和模型之间的同步是由 Angular 使用双向数据绑定来完成的。两者(视图和模型)之间的粘合剂是角度$scope,它被传递给控制器。控制器基于我们的应用逻辑设置$scope。视图和模型之间的$scope同步如图图 9-5 所示。

9781484201886_Fig09-05.jpg

图 9-5 。演示$scope 是视图和模型之间的粘合剂

由于控制器中的这个模型实际上是用于视图的,所以通常称之为 ViewModel 或者简称为vm,正如我们在示例中所称的那样。还要注意的是,$scope通过 Angular 注入控制器。我们通过在数组成员中指定来明确要求$scope。相关片段再次显示:

demoApp.controller('MainController', '$scope', function ($scope) {

初始数组成员(本例中只有'$scope')驱动作为参数传递给最终数组成员的内容,这是我们的控制器功能。这是 Angular 支持的依赖注入的一种形式。我们将在本章的其他例子中看到更多 Angular 的依赖注入。

我们使用 HTML using 指令中的$scope。在我们的例子中,下面的 HTML 的ng-model保持输入元素与vm.name属性同步:

<input type="text" ng-model="vm.name" />

类似地,我们可以在用户点击时使用ng-click指令调用控制器上的函数:

<button class="btn btn-danger" ng-click="vm.clearName()">Clear Name</button>

创建一个简单的待办事项列表应用

Angular 的伟大之处在于,创建和设计完全独立于任何服务器代码的前端极其简单。准备就绪后,您可以将其连接到后端,这正是我们在这里要做的。现在我们已经基本了解了$scope是视图和模型之间的粘合剂,我们将为待办事项列表设计一个简单的 JavaScript 模型,并为它设计一个前端。我们的整个模型(vm ) JavaScript 如[清单 9-5 所示。

清单 9-5 。todostart/public/main.js

var demoApp = angular.module('demo', []);
demoApp.controller('MainController', ['$scope', 'guidService', function ($scope, guidService) {

    // Setup a view model
    var vm = {};

    vm.list = [
        { _id: guidService.createGuid(), details: 'Demo First Item' },
        { _id: guidService.createGuid(), details: 'Demo Second Item' }
    ];

    vm.addItem = function () {
        // TODO: send to server then,
        vm.list.push({
            _id: guidService.createGuid(),
            details: vm.newItemDetails
        });
        vm.newItemDetails = '';
    };

    vm.removeItem = function (itemToRemove) {
        // TODO: delete from the server then
        vm.list = vm.list.filter(function (item) { return item._id !== itemToRemove._id; });
    };

    // For new items:
    vm.newItemDetails = '';

    // expose the vm using the $scope
    $scope.vm = vm;
}]);

demoApp.service('guidService', function () {
    return {
        createGuid: function () {
            return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
                var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
                return v.toString(16);
            });
        }
    };
});

在第八章的中,我们展示了为数据库中的每个条目创建一个不可变的唯一 id 总是最好的(自然主键与代理主键的讨论)。这里我们使用一个 createGuid函数在客户端创建这样一个惟一的 id。这个函数本来可以放在控制器内部,但是我们选择将其作为一个可重用的角度服务,称为 guidService。Angular 服务只是 Angular 调用一次来获取对象的函数。然后,它将这个对象传递给通过键(这里的键是'guidService')请求它的任何其他服务、控制器等等。

在我们的控制器中,我们将'guidService'指定为依赖注入数组的成员:

demoApp.controller('MainController', '$scope', 'guidService', function ($scope, guidService) {

Angular 将寻找并传递从我们的guidService服务注册函数返回的值。这里的值只有一个成员(函数 ??),我们在控制器(guidService.createGuid)中使用。createGuid函数本身是一个非常标准的函数,使用随机化算法从 JavaScript 创建全局唯一标识符(GUIDs)。

创建和消费角度服务之后,我们可以看到 MainController函数本身的其余部分非常简单。它用于管理项目列表(vm.list)—添加到列表(vm.addItem)和删除列表(vm.removeItem)—以及一个简单的成员,允许我们从视图中的数据绑定字段获取用户输入的详细信息(vm.newItemDetails)。现在我们可以在此基础上设计一个简单的 Angular + Bootstrap HTML 用户界面。我们的 HTML 如[清单 9-6 所示。

清单 9-6 。todostart/public/index.html

<body ng-controller="MainController">
    <!-- Our HTML -->
    <div class="container">
        <h1>List</h1>

        <!-- Existing items rows -->
        <div class="row">
            <div ng-repeat="item in vm.list track by item._id" style="padding:10px">
                <button class="btn btn-danger" ng-click="vm.removeItem(item)">x</button>
                {{item.details}}
            </div>
        </div>

        <!-- New Item row -->
        <div class="row">
            <form role="form">
                <div class="form-group">
                    <label for="newItemDetails">New Item Details:</label>
                    <input type="text" class="form-control"
                           placeholder="Details of new todo item"
                           ng-model="vm.newItemDetails">
                </div>
                <button type="submit" class="btn btn-primary"
                        ng-click="vm.addItem()"
                        ng-disabled="!vm.newItemDetails">Add</button>
            </form>
        </div>
    </div>
</body>

我们有一堆 HTML 标签,它们有特定的引导类(例如,containerrowform-groupbtnbtn-primarybtn-danger等等)。这些给了应用一个体面的外观——设计者会定制这些类(并使用 CSS 创建更多的类),使应用看起来更好,但即使在当前状态下,它也不坏。(参见图 9-6 。)

9781484201886_Fig09-06.jpg

图 9-6 。todostart 示例在浏览器中运行

更让人印象深刻的是功能齐全!单击 x 按钮从列表中删除该项目。通过输入一些新的条目细节,我们启用 Add 按钮,按下该按钮将条目添加到列表中。所有这些都要感谢 Angular 附带的指令,这些指令与您已经看到的视图模型(vm)进行对话。HTML 中的条目列表是用一个ng-repeat指令生成的,再次显示在清单 9-7 中。

清单 9-7 。来自 todostart/public/index.html 的片段

<div ng-repeat="item in vm.list track by item._id" style="padding:10px">
     <button class="btn btn-danger" ng-click="vm.removeItem(item)">x</button>
     {{item.details}}
</div>

ng-repeat指令获取指定的 DOM 元素,并为列表中的每个元素克隆它(vm.list)。作为一种优化,我们告诉它条目的惟一性是由_id属性(track by)决定的,这有助于 Angular 将 DOM 元素与列表中的条目关联起来。ng-repeat指令还在重复元素(我们称之为item)内的作用域(item in vm.list)中创建了一个新项,您可以进一步绑定到({{item.details}})并在其他指令中使用它(例如,我们有一个ng-click,通过传递给vm.removeItem函数来移除该项)。现在让我们检查一下在清单 9-8 中再次显示的添加项目 HTML 。

清单 9-8 。来自 todostart/public/index.html 的片段

<form role="form">
    <div class="form-group">
        <label for="newItemDetails">New Item Details:</label>
        <input type="text" class="form-control"
               placeholder="Details of new todo item"
               ng-model="vm.newItemDetails">
    </div>
         <button type="submit" class="btn btn-primary"
                 ng-click="vm.addItem()"
                 ng-disabled="!vm.newItemDetails">Add</button>
</form>

我们使用一个ng-model将简单输入连接到vm.newItemDetails,使用一个ng-click指令将添加按钮连接到vm.addItem功能。如果当前的vm.newItemDetails是 falsy(记住空字符串在 JavaScript 中是 falsy),我们还使用ng-disabled指令禁用添加按钮。

就这样!我们在客户端上有一个功能齐全的待办事项列表。现在,它需要做的就是与服务器通信,以便保存和加载信息。

创建 REST API

当我们详细研究 ExpressJS 时,我们已经有了从第七章创建 REST API 的经验。对于这个简单的应用,我们的 REST API 需要做的只是获取列表中的所有项目,向列表中添加项目的 POST(它应该返回 ID),以及从列表中删除项目的 DELETE。在第八章中,我们看到了如何使用 MongoDB。结合我们对这两者的了解,清单 9-9 为我们的基于 ExpressJS 路由的 API 提供了一个简单的设置,它将数据持久化到 MongoDB。

清单 9-9 。todocomplete/app.js

var express = require('express');
var bodyParser = require('body-parser');

// The express app
var app = express();

// Create a mongodb connection
// and only start express listening once the connection is okay
var MongoClient = require('mongodb').MongoClient;
var db, itemsCollection;
MongoClient.connect('mongodb://127.0.0.1:27017/demo', function (err, database) {
    if (err) throw err;

    // Connected!
    db = database;
    itemsCollection = db.collection('items');

    app.listen(3000);
    console.log('Listening on port 3000');
});

// Create a router that can accept JSON
var router = express.Router();
router.use(bodyParser.json());

// Setup the collection routes
router.route('/')
      .get(function (req, res, next) {
          itemsCollection.find().toArray(function (err, docs) {
              res.send({
                  status: 'Items found',
                  items: docs
              });
          });
      })
      .post(function (req, res, next) {
          var item = req.body;
          itemsCollection.insert(item, function (err, docs) {
              res.send({
                  status: 'Item added',
                  itemId: item._id
              });
          });
      })

// Setup the item routes
router.route('/:id')
      .delete(function (req, res, next) {
          var id = req.params['id'];
          var lookup = { _id: new mongodb.ObjectID(id) };
          itemsCollection.remove(lookup, function (err, results) {
              res.send({ status: 'Item cleared' });
          });
      });

app.use(express.static(__dirname + '/public'))
   .use('/todo', router);

将 MongoDB 与 Express 集成的重要部分是,我们只有在确认与 MongoDB 的连接正常后,才启动 Express 服务器。我们还存储了对包含待办事项的items集合的引用。

代码的其余部分是不言自明的,这里没有什么是你不知道的。我们有用于 GET(获取列表)和 POST(向列表中添加一个项目并返回其 ID)的集合级路由,以及用于删除单个项目的项目路由。此时,你可以使用curl来测试你的 API,就像我们在第七章中所做的那样。现在让我们完成我们的前端,以便它与后端对话。

用 REST API 连接前端

与 Angular 的休息服务中心交谈再简单不过了。Angular 附带了一个$http服务,它包装了浏览器的XMLHttpRequest对象,以便与 Angular digest 循环一起工作。它还使 API 在不同浏览器之间保持一致,并通过使用承诺使其更容易使用。承诺是我们将在下一章详细讨论的主题,但是在你看过代码之后,我们将在这里给出一个简要的概述。

您可以访问$http服务,就像您访问我们自己的自定义服务guidService一样,我们在前面已经看到了。为了访问 REST API,我们将创建自己的定制 Angular 服务,该服务将使用 Angular 的内置$http服务与服务器通信。包括控制器在内的完整客户端 JavaScript 如清单 9-10 所示。

清单 9-10 。todocomplete/public/main.js

var demoApp = angular.module('demo', []);
demoApp.controller('MainController', ['$scope', 'todoWebService', function ($scope, todoWebService) {

    // Setup a view model
    var vm = {};

    vm.list = [];

    // Start the initial load of lists
    todoWebService.getItems().then(function (response) {
        vm.list = response.data.items;
    });

    vm.addItem = function () {
        var item = {
            details: vm.newItemDetails
        };

        // Clear it from the UI
        vm.newItemDetails = '';

        // Send the request to the server and add the item once done
        todoWebService.addItem(item).then(function (response) {
            vm.list.push({
                _id: response.data.itemId,
                details: item.details
            });
        });
    };

    vm.removeItem = function (itemToRemove) {
        // Remove it from the list and send the server request
        vm.list = vm.list.filter(function (item) { return item._id !== itemToRemove._id; });
        todoWebService.removeItem(itemToRemove);
    };

    // For new items:
    vm.newItemDetails = '';

    // expose the vm using the $scope
    $scope.vm = vm;
}]);

demoApp.service('todoWebService', ['$http', function ($http) {
    var root = '/todo';
    return {
        getItems: function () {
            return $http.get(root);
        },
        addItem: function (item) {
            return $http.post(root, item);
        },
        removeItem: function (item) {
            return $http.delete(root + '/' + item._id);
        }
    }
}]);

同样,代码实际上非常容易管理。为了简单起见,我们放弃了任何错误检查或 UI 通知。首先,请注意我们名为todoWebService 的定制角度服务。这里面的逻辑是不言自明的。它只有一些获取、添加和删除项目的函数。它使用 Angular 的$http服务来针对我们的 REST API 端点(换句话说,'/todo ')发出 get、post 和 delete HTTP 请求,这些请求位于为我们的 HTML 提供服务的同一台服务器上。值得一提的是,$http的每个方法都返回一个承诺,因此getItems/addItem/removeItem也同样返回承诺。

我们在我们的MainController中使用我们的todoWebService,自从我们最后一次看到它以来,它基本上没有变化。唯一改变的是,它现在使用todoWebService在正确的时间调用服务器。我们提到了todoWebService成员的回报承诺。对于这个应用来说,知道一个承诺有一个then成员函数就足够了,一旦承诺被解析,这个函数就会被调用。现在有一种方法来考虑它们:不是直接传递回调,而是将它传递给 promise 的then成员函数。例如,考虑清单 9-11 中重复的初始载荷。

清单 9-11 。来自 todocomplete/public/main.js 的片段

// Start the initial load of lists
todoWebService.getItems().then(function (response) {
    vm.list = response.data.items;
});

当浏览器发送这个获取列表的网络请求时,它不会阻塞 UI/JavaScript 线程。相反,它需要一个回调函数,一旦从服务器收到 get 响应,就会调用这个回调函数。承诺只是提供了一种更简洁的方式来提供回调。承诺的主要动机是承诺提供的链能力和更好的错误处理,这个话题我们将在下一章详细讨论。

仅此而已。我们已经完成了一个端到端的待办事项列表应用。如果您运行了 MongoDB】,启动 Node.js 服务器并访问您的本地主机。(参见图 9-7 。)

9781484201886_Fig09-07.jpg

图 9-7 。在浏览器中运行的 todocomplete 示例

现在让我们后退一步,看看我们的应用架构。我们可以很容易地在分布式团队中开发这样的应用。前端的 JavaScript 大师可以创建你的控制器,CSS 忍者可以设计你的 HTML,后端的 JavaScript 专家可以创作你的 REST API。最后,我们将它们连接起来,您的 shinny 应用就准备好了。这是使用良好的 SPA 框架(如 AngularJS)结合 REST API 的优势之一。

后续步骤

关于 AngularJS 还有很多可以说的。例如,我们只使用了内置指令,比如ng-click,但是您可以编写自己的指令来创建强大的 web 组件。此外,我们只看到了依赖注入(DI)在 Angular 中的基本用法。在 Angular 中 DI 的主要动机是可测试性。要了解更多关于可测试性的知识,最好查看 AngularJS 团队https://github.com/angular/angular-seed提供的 angular-seed 项目。angular-seed 项目还包含关于如何将客户端项目分割成多个 JavaScript 文件以实现可维护性的指导。

额外资源

推特自举:http://getbootstrap.com/

安圭拉语:??

有角的种子:https://github.com/angular/angular-seed

摘要

在这一章中,我们看到了如何使用成熟的框架如 AngularJS 来消费 web 服务。我们努力弄清楚代码的作用。我们专注于引导您通过代码,以便您确切地知道发生了什么。这将允许你探索更大的代码库,有更深的理解和更大的信心。

在本章中,我们也试图证明我们使用库的合理性。一路上,我们解释了 SPA 的含义以及您应该关注的原因。在本章的介绍之后,你应该有信心自己探索更多的 JQuery、Bootstrap 和 AngularJS。在下一章,我们将看看承诺和其他简化回调的方法。

十、简化回调

Node.js 的事件/异步特性意味着有可能以深度嵌套的回调结束。JavaScript 中有多种策略可以帮助减少回调嵌套。在这一章中,我们将探索这些模式。这些有助于保持可读性,但更重要的是,这些策略将有助于降低 bug 爬上你的可能性。我保证。

复试地狱

请注意,在我们探讨这一部分时,情况似乎很糟糕。别担心。当我们在下一节看应许时,你会发现彩虹尽头的那罐金子。

回调的一个明显问题是增加的缩进量。这被亲切地称为回调末日金字塔。考虑一下清单 10-1 中的简单情况,我们需要调用三个异步函数(例如,这些函数可能是数据库搜索、选择和保存项目)。

清单 10-1 。金字塔/indented.js

function first(data, cb) {
    console.log('Executing first');
    setTimeout(cb, 1000, data);
}

function second(data, cb) {
    console.log('Executing second');
    setTimeout(cb, 1000, data);
}

function third(data, cb) {
    console.log('Executing third');
    setTimeout(cb, 1000, data);
}

first('data', function (text1) {
    second(text1, function (text2) {
        third(text2, function (text3) {
            console.log('done:', text3); // indented
        });
    });
});

正如你所看到的,这对眼睛来说并不容易。一个简单的解决办法是命名处理程序,这样你就可以组合它们而不必将它们放在内联,如清单 10-2 所示。

清单 10-2 。pyramid/simplify.js

function first(data, cb) {
    console.log('Executing first');
    setTimeout(cb, 1000, data);
}

function second(data, cb) {
    console.log('Executing second');
    setTimeout(cb, 1000, data);
}

function third(data, cb) {
    console.log('Executing third');
    setTimeout(cb, 1000, data);
}

// Named handlers
function handleThird(text3) {
    console.log('done:', text3); // no indent!
}

function handleSecond(text2) {
    third(text2, handleThird);
}

function handleFirst(text1) {
    second(text1, handleSecond);
}

// Start the chain
first('data', handleFirst);

这就解决了金字塔问题。注意,我们有相反的处理程序(third, second, first),因为在使用它们之前声明函数是好的。

然而,除了我们已经修复的明显的缩进问题之外,与简单的同步编程相比,对控制流使用回调还有真正的技术问题。首先,它混淆了输入和输出——也就是说,我们使用一个回调函数,它是一个输入,实际上是返回值,它是同步函数中的一个输出。

此外,它不能很好地处理控制流原语(if、else、for 和 while)。此外,错误处理可能很难正确。让我们更深入地研究一下这些问题,以便理解这些概念。

如果/否则在异步世界中

如果您有条件地需要在一个函数中进行异步操作,您必须确保整个函数是异步的。清单 10-3 是展示这种复杂性的一个简单例子。

清单 10-3 。ifelse/bad.js

// WARNING! DO NOT USE!
function maybeSync(arg, cb) {
    if (arg) { // We already have data
        // BAD! Do not call synchronously!
        cb('cached data');
    }
    else { // We need to load data
        // simulate a db load
        setTimeout(function () {
            cb('loaded data')
        }, 500);
    }
}
// Without the intimate details of maybeSync
// its difficult to determine if
//     - foo is called first
//     OR
//     - bar is called first

maybeSync(true, function (data) {
    foo();
});
bar();

function foo() { console.log('foo') }
function bar() { console.log('bar') }

不看maybeSync函数的代码,开发者不可能知道是先调用foo还是先调用bar。事实上,在我们的例子中,foo将被立即调用,而异步开发者会假设bar将被首先调用,而foo将在稍后被调用,就像任何其他异步操作一样。这里之所以不是这种行为,是因为maybeSync写得很差,根据某种条件立即调用回调。正确的方法是使用process.nextTick函数(正如我们在第三章中看到的)为事件循环的下一个滴答安排回调。清单 10-4 显示了修复的maybeSync功能(重命名为alwaysAsync)。

清单 10-4 。ifelse/good.js

function alwaysAsync(arg, cb) {
    if (arg) { // We already have data
        // setup call for next tick
        process.nextTick(function () {
            cb('cached data');
        });
    }
    else { // We need to load data
        // simulate a db load
        setTimeout(function () {
            cb('loaded data')
        }, 500);
    }
}

alwaysAsync(true, function (data) {
    foo();
});
bar();

function foo() { console.log('foo') }
function bar() { console.log('bar') }

简单的教训:如果一个函数接受回调,那么它是异步的,它永远不应该直接调用回调——process.nextTick是你的朋友。

同样值得一提的是,对于基于浏览器的代码,您可以使用setImmediate(如果有的话)或setTimeout

异步世界中的循环

考虑通过 HTTP 请求获取两个项目并使用其中包含的数据的简单情况。一个简单的方法如清单 10-5 所示。

清单 10-5 。loop/simple.js

// an async function to load an item
function loadItem(id, cb) {
    setTimeout(function () {
        cb(null, { id: id });
    }, 500);
}

// functions to manage loading
var loadedItems = [];
function itemsLoaded() {
    console.log('Do something with:', loadedItems);
}
function itemLoaded(err, item) {
    loadedItems.push(item);
    if (loadedItems.length == 2) {
        itemsLoaded();
    }
}

// calls to load
loadItem(1, itemLoaded);
loadItem(2, itemLoaded);

在这里,我们简单地维护一个数组(loadedItems)来存储落下的条目,然后在获得所有条目后运行itemsLoaded函数。有一些库可以使这样的控制流操作更加简单。其中最突出的是异步(npm install async)。使用async重写的相同示例显示在清单 10-6 中。

清单 10-6 。loop/async.js

// an async function to load an item
function loadItem(id, cb) {
    setTimeout(function () {
        cb(null, { id: id });
    }, 500);
}

// when all items loaded
function itemsLoaded(err, loadedItems) {
    console.log('Do something with:', loadedItems);
}

// load in parallel
var async = require('async');
async.parallel([
    function (cb) {
        loadItem(1, cb);
    },
    function (cb) {
        loadItem(2, cb);
    }
], itemsLoaded)

如您所见,我们不再需要手动维护已完成/已提取项目的列表。async.parallel函数将一个函数数组作为它的第一个参数。每个函数都被传递了一个回调函数,您应该以标准的 Node 方式调用这个回调函数——换句话说,首先是错误参数,然后是实际的返回值。我们的loadItem函数已经正确地调用了它的回调函数,所以我们只是把async的回调函数交给它。最后,async将调用作为第二个参数传入的函数(itemsLoaded),一旦数组中的所有函数都调用了它们的回调函数。我们得到的行为与我们在前面的例子中手动完成的行为完全相同。

还要注意的是async支持我们在这个例子中免费获得的单个项目之间的错误聚合(尽管我们在这里没有出错的机会)。如果您需要的话,async还支持其他控制流原语(比如串行控制流)。

这里的教训是,与简单的同步编程相比,异步确实会使控制流变得更加复杂,尽管并不令人望而却步。现在让我们看看回调的最大问题。

错误处理

对异步任务使用回调的最大问题是错误处理的复杂性。让我们看一个具体的例子来巩固这个概念。考虑一个简单的例子,创作一个从文件加载 JSON 的异步版本。这种任务的同步版本如清单 10-7 所示。

清单 10-7 。错误/同步. js

var fs = require('fs');

function loadJSONSync(filename) {
    return JSON.parse(fs.readFileSync(filename));
}

// good json file
console.log(loadJSONSync('good.json'));

// non-existent json file
try {
    console.log(loadJSONSync('absent.json'));
}
catch (err) {
    console.log('absent.json error', err.message);
}

// invalid json file
try {
    console.log(loadJSONSync('bad.json'));
}
catch (err) {
    console.log('bad.json error', err.message);
}

这个简单的loadJSONSync函数有三种行为:一个有效的返回值、一个文件系统错误或者一个JSON.parse错误。我们用一个简单的try/catch来处理错误,就像你在用其他语言进行同步编程时所习惯的那样。明显的性能缺点是,当从文件系统中读取文件时,没有其他 JavaScript 可以执行。现在让我们制作一个这样的函数的异步版本。清单 10-8 中的展示了一个简单的错误检查逻辑。

清单 10-8 。摘自 errors/asyncsimple.js

var fs = require('fs');

function loadJSON(filename, cb) {
    fs.readFile(filename, function (err, data) {
        if (err) cb(err);
        else cb(null, JSON.parse(data));
    });
}

很简单——它接受回调并将任何文件系统错误传递给回调。如果没有文件系统错误,它返回JSON. parse 结果。使用基于回调的异步函数时,需要记住以下几点:

  1. 千万不要打两次回电。
  2. 永远不要抛出错误。

然而,这个简单的函数不能适应第二点。事实上,JSON.parse如果传递给 bad JSON 就会抛出一个错误,回调永远不会被调用,应用崩溃,如清单 10-9 所示。

清单 10-9 。来自 errors/asyncsimple.js 的片段

// load invalid json
loadJSON('bad.json', function (err, data) {
    // NEVER GETS CALLED!
    if (err) console.log('bad.json error', err.message);
    else console.log(data);
});

解决这个问题的一个天真的尝试是将JSON.parse包装在try / catch中,如清单 10-10 中的所示。

清单 10-10 。errors/asyncbadcatch.js

var fs = require('fs');

function loadJSON(filename, cb) {
    fs.readFile(filename, function (err, data) {
        if (err) {
            cb(err);
        }
        else {
            try {
                cb(null, JSON.parse(data));
            }
            catch (err) {
                cb(err);
            }
        }
    });
}

// load invalid json
loadJSON('bad.json', function (err, data) {
    if (err) console.log('bad.json error', err.message);
    else console.log(data);
});

然而,在这段代码中有一个微妙的错误。如果回调(cb)而不是JSON.parse)抛出一个错误,catch执行,我们再次调用回调,因为我们把它包装在了try/catch中。换句话说,回调被调用了两次!这在清单 10-11 中有所展示。清单 10-12 中的示例执行。

清单 10-11 。errors/asyncbadcatchdemo.js

var fs = require('fs');

function loadJSON(filename, cb) {
    fs.readFile(filename, function (err, data) {
        if (err) {
            cb(err);
        }
        else {
            try {
                cb(null, JSON.parse(data));
            }
            catch (err) {
                cb(err);
            }
        }
    });
}

// a good file but a bad callback ... gets called again!
loadJSON('good.json', function (err, data) {
    console.log('our callback called');

    if (err) console.log('Error:', err.message);
    else {
        // lets simulate an error by trying to access a property on an undefined variable
        var foo;
        console.log(foo.bar);
    }
});

清单 10-12 。errors/asyncbadcatchdemo.js 的运行示例

$ node asyncbadcatchdemo.js
our callback called
our callback called
Error: Cannot read property 'bar' of undefined

回调被调用两次的原因是因为我们的loadJSON函数错误地将回调包装在了try块中。这里有一个简单的教训需要记住。

简单的教训:把你所有的同步代码包含在一个try / catch中,除了当你调用回调的时候。

根据这个简单的经验,我们有了一个全功能的异步版本的loadJSON,如清单 10-13 所示。

清单 10-13 。错误/asyncfinal.js

var fs = require('fs');

function loadJSON(filename, cb) {
    fs.readFile(filename, function (err, data) {
        if (err) return cb(err);
        try {
            var parsed = JSON.parse(data);
        }
        catch (err) {
            return cb(err);
        }
        return cb(null, parsed);
    });
}

我们唯一一次调用回调是在任何try/catch之外。包装中的其他东西都是一个try/catch。也是我们return对任何电话的回拨。

诚然,一旦你做了几次,这并不难做到,但尽管如此,为了良好的错误处理,还是要编写大量的模板代码。现在让我们看看使用 promises 处理异步 JavaScript 的更好的方法。

承诺介绍

在我们看到承诺如何极大地简化异步 JavaScript 之前,我们需要很好地理解Promise的行为。承诺很快(当 ECMAScript 6 最终完成时)将成为标准 JavaScript 运行时的一部分。在此之前,我们需要使用第三方库。到目前为止,最流行的是 Q ( npm install q),我们这里就用这个。围绕 promises 的效用函数在各个库中是不同的,但是来自所有好的库中的Promise实例是相互兼容的,因为它们都遵循“Promises/A+”规范。该规范也将成为 ECMAScript 6 的一部分,因此您的知识是未来安全的。在本节中,我们将解释这个概念,然后指出承诺相对于回调的优势。

创造一个承诺

在详细解释承诺之前,我们将查看代码。先说创造一个承诺,Q 风格。在清单 10-14 中,我们创建了一个承诺,使用promise.then成员函数订阅其完成,并最终解析该承诺。

清单 10-14 。 promiseintro/create.js

var Q = require('q');

var deferred = Q.defer();
var promise = deferred.promise;

promise.then(function (val) {
    console.log('done with:', val);
});

deferred.resolve('final value'); // done with: final value

then函数还有很多,我们将在下一节详细讨论。我们在这个例子中的重点是创造一个承诺。 Q.defer()为你提供了一个对象(一个deferred)

  1. 包含了承诺(deferred.promise),并且
  2. 包含解决(deferred.resolve)或拒绝(deferred.reject)上述承诺的功能。

promise和控制承诺的事物(即deferred对象)之间的这种分离是有充分理由的。它允许你给任何人promise,并且仍然控制何时以及如何解决它,如清单 10-15 中的所示。

清单 10-15 。promise intro/separate . js

var Q = require('q');

function getPromise() {
    var deferred = Q.defer();

    // Resolve the promise after a second
    setTimeout(function () {
        deferred.resolve('final value');
    }, 1000);

    return deferred.promise;
}

var promise = getPromise();

promise.then(function (val) {
    console.log('done with:', val);
});

所以现在我们知道如何创造一个承诺。使用承诺的一个直接好处是功能输入和输出被清楚地定义。

Promise 优点:我们没有使用回调(这是一个输入)来提供输出,而是使用returnPromise,它可以在您方便的时候用来订阅输出。

现在让我们看看承诺状态(已解决、已拒绝和待定)。

承诺状态

承诺只能是三种状态之一:待定、履行或拒绝。它们之间有一个状态转换图,如图图 10-1 所示。

9781484201886_Fig10-01.jpg

图 10-1 。Promsie 国家和命运

基于这些箭头,承诺只能从一种状态转换到另一种状态。例如,一个已经实现的承诺不可能被拒绝。此外,它的实现值或被拒绝的原因不能改变。图表中还显示,如果承诺被履行或拒绝,我们说它已经解决。

Promise advantage: 由于 Promise 到 fulfilled 或 rejected 的转换是不可变的,所以所有单个的onFulfilled / onRejected处理程序将只被调用一次。承诺不会有再叫回调的问题。

您可以使用我们前面看到的延迟对象手动转换一个承诺。但是,最常见的是(几乎总是),一些函数会给你一个承诺,从那时起,你使用then函数来创建、履行或拒绝承诺。

Then 和 Catch 基础

成员函数是 promise API 的核心。在最基本的层面上,您可以使用它来订阅承诺结算结果。它有两个函数(称为onFulfilledonRejected处理程序),根据承诺的最终状态(完成或拒绝)调用。然而,我们建议只将onFulfilled处理程序传递给then函数。

类似于承诺上的then函数,还有一个catch函数。catch函数只接受onRejected处理程序。因此,传统上只有then函数中的onFulfilled处理程序,后面是带有onRejected处理程序的catch函数。这两个函数如清单 10-16 所示。

清单 10-16 。 thencatch/settle.js

var Q = require('q');

var willFulfillDeferred = Q.defer();
var willFulfill = willFulfillDeferred.promise;
willFulfillDeferred.resolve('final value');

willFulfill
    .then(function (val) {
        console.log('success with', val); // Only fulfill handler is called
    })
    .catch(function (reason) {
        console.log('failed with', reason);
    });

var willRejectDeferred = Q.defer();
var willReject = willRejectDeferred.promise;
willRejectDeferred.reject(new Error('rejection reason')); // Note the use of Error

willReject
    .then(function (val) {
        console.log('success with', val);
    })
    .catch(function (reason) {
        console.log('failed with', reason); // Only reject handler is called
    });

注意,用一个Error对象拒绝一个承诺是很常见的,因为它提供了一个堆栈跟踪。这类似于向回调传递错误参数时使用Error的建议。另外,then/catch模式应该让你想起同步编程中的try/catch模式。

同样值得一提的是,catch(function(){})对于then(null,function(){})来说只是糖。所以catch的行为在很多方面会和then非常相似。

注意,如清单 10-16 中的所示,当我们调用then/catch方法时,承诺是否已经完成并不重要。如果当承诺完成时(这可能已经发生了,就像我们的例子一样),处理程序将被调用

您可以使用清单 10-17 中的成员函数创建一个已经履行的承诺。

清单 10-17 。thencatch/fulfilled.js

var Q = require('q');

Q.when(null).then(function (val) {
    console.log(val == null); // true
});

Q.when('kung foo').then(function (val) {
    console.log(val); // kung foo
});

console.log('I will print first because *then* is always async!');

当您使用Q.when开始一个承诺链时(我们接下来将查看承诺链),通常使用when(null)。否则,您可以创建具有任意值的已解决承诺(例如,kung foo)。注意,如本例所示,then回调(onFulfilled/onRejected)在同步代码执行后异步执行。

承诺优势:承诺不会遭受也许同步回调的问题。如果您想返回一个即时承诺,只需使用Q.when返回一个已解决的承诺,用户注册的任何then必定会被异步调用。

类似于when函数,有一个Q.reject函数创建一个已经被拒绝的承诺,如清单 10-18 中的所示。如果在某个输入参数中发现错误,您可能希望从函数中返回一个被拒绝的承诺。

清单 10-18 。然后 catch/rejected.js

var Q = require('q');

Q.reject(new Error('denied')).catch(function (err) {
    console.log(err.message); // denied
});

那时的连锁能力

承诺的连锁能力是他们最重要的特征。一旦你有了一个承诺,你使用then函数来创建一个履行或拒绝承诺的链。

最重要的行为是从onFulfilled处理程序(或onRejected处理程序)返回的值被包装在一个新的承诺中。这个新的承诺是从then函数返回的,该函数允许您一个接一个地连锁承诺。这显示在清单 10-19 中。

清单 10-19 。chainability/chain.js

var Q = require('q');

Q.when(null)
    .then(function () {
        return 'kung foo';
    })
    .then(function (val) {
        console.log(val); // kung foo
        return Q.when('panda');
    })
    .then(function (val) {
        console.log(val); // panda
        // Nothing returned
    })
    .then(function (val) {
        console.log(val == undefined); // true
    });

注意,如果您从then处理程序返回一个承诺(例如,我们在第二个then函数中将解决的承诺返回给一只熊猫),下一个then处理程序将在调用适当的处理程序之前等待承诺解决(解决或拒绝)。

如果在任何时候有一个未被捕获的异常,或者处理程序返回一个被(或将被)拒绝的承诺,那么不再调用进一步的onFulfilled处理程序。该链继续运行,直到找到某个onRejected处理程序,此时该链被重置并基于从onRejected处理程序返回的值继续运行,如清单 10-20 中的所示。

清单 10-20 。chain ability/chainwitherror . js

var Q = require('q');

Q.when(null)
    .then(function () {
        throw new Error('panda'); // uncaught exception
    })
    .then(function (val) {
        console.log('!!!!!', val); // I will never get called
    })
    .catch(function (reason) {
        console.log('Someone threw a', reason.message);
        return 'all good';
    })
    .then(function (val) {
        console.log(val); // all good
        return Q.reject(new Error('taco'));
    })
    .then(function (val) {
        console.log('!!!!!', val); // I will never get called
    })
    .catch(function (reason) {
        console.log('Someone threw a', reason.message);
    });

在这个例子中,无论何时出现错误或拒绝承诺,都不会调用进一步的onFulfilled处理程序(我们使用then函数注册它),直到某个onRejected处理程序(使用catch函数注册)处理了错误。

承诺优势:onFulfilled/onRejected处理程序中未被捕获的异常不会破坏应用。相反,它们会导致链中的承诺被拒绝,您可以使用最终的onRejected处理程序优雅地处理这个问题。

最常见的是,你的承诺链看起来就像清单 10-21 中的那样。注意它和同步编程的try / catch语义是多么的相似。

清单 10-21 。chainability/demoChain.js 片段

somePromise
    .then(function (val) { /* do something */ })
    .then(function (val) { /* do something */ })
    .then(function (val) { /* do something */ })
    .then(function (val) { /* do something */ })
    .then(function (val) { /* do something */ })
    .catch(function (reason) { /* handle the error */ });

将回电转化为承诺

在这一节中,我们将看到如何用经典的 Node.js 异步模式互操作Promises。有了这些知识,我们将重温一下loadJSON的例子,看看它有多简单。

与 nodeback 接口

既然您已经部分理解了 Promise API,那么最直接的问题很可能是与 Node 回调风格函数(亲切地称为 nodeback )的互操作性。Node 返回只是一个函数,它

  • 接受 n 个参数,最后一个是回调,并且
  • (error)(null, value)(null, value1, value2,...)调用回调。

这是核心 Node.js 模块以及可靠的社区编写模块中函数的风格。

将 nodeback 样式的函数转换为 promises 是一个简单的任务,调用 Q.nbind,它接受一个 nodeback 样式的函数,包装它,并返回一个新函数,该函数执行以下操作:

  • 接受与 nodeback 函数相同的前 n-1 个参数(即除回调参数之外的所有参数),并将它们与内部回调函数一起静默传递给 nodeback 函数
  • 返回一个承诺
    • 如果内部回调是由带有非空错误参数的 nodeback 函数调用的(换句话说,(error)情况),则被拒绝,
    • 如果回调被 nodeback 函数调用,如(null, value),则解析为value,并且
    • 如果回调被 nodeback 函数调用,如(null, value1,value2,...),则解析为数组[value1, value2,...]

在清单 10-22 中,我们展示了一个将 nodeback 风格的函数转换成带有承诺的函数的实例。

清单 10-22 。interop/nodeback.js

function data(delay, cb) {
    setTimeout(function () {
        cb(null, 'data');
    }, delay);
}

function error(delay, cb) {
    setTimeout(function () {
        cb(new Error('error'));
    }, delay);
}

// Callback style
data(1000, function (err, data) { console.log(data); });
error(1000, function (err, data) { console.log(err.message); });

// Convert to promises
var Q = require('q');
var dataAsync = Q.nbind(data);
var errorAsync = Q.nbind(error);

// Usage
dataAsync(1000)
    .then(function (data) { console.log(data); });

errorAsync(1000)
    .then(function (data) { })
    .catch(function (err) { console.log(err.message); });

该示例说明了这种转换的简单性。事实上,你甚至可以内联地调用它,例如,Q.nbind(data)(1000)。注意我们使用 -Async后缀来表示返回承诺的转换 Node 返回函数的约定。这是对核心 Node 使用的 -Sync后缀的一种玩法,用于 nodeback 函数的同步版本。你会在社区中找到其他类似的例子。

现在让我们重温一下我们的loadJSON例子,重写一个使用承诺的异步版本。我们需要做的就是读取文件内容,然后将它们解析为 JSON,这样就完成了。这在清单 10-23 中有所说明。

清单 10-23 。 interop/ loadJSONAsync.js

var Q = require('q');
var fs = require('fs');
var readFileAsync = Q.nbind(fs.readFile);

function loadJSONAsync(filename) {
    return readFileAsync(filename)
                .then(function (res) {
                    return JSON.parse(res);
                });
}

// good json file
loadJSONAsync('good.json')
    .then(function (val) { console.log(val); })
    .catch(function (err) {
        console.log('good.json error', err.message); // never called
    })
// non-existent json file
    .then(function () {
        return loadJSONAsync('absent.json');
    })
    .then(function (val) { console.log(val); }) // never called
    .catch(function (err) {
        console.log('absent.json error', err.message);
    })
// invalid json file
    .then(function () {
        return loadJSONAsync('bad.json');
    })
    .then(function (val) { console.log(val); }) // never called
    .catch(function (err) {
        console.log('bad.json error', err.message);
    });

注意,由于承诺的链能力,我们不需要在我们的loadJSONAsync函数中做任何错误处理,因为任何错误(无论是来自fs.readFile回调还是由JSON.parse抛出)都会被推到第一个catch ( onRejected处理程序)。在前面的例子中,除了更简单的错误处理,注意我们有一个很长的异步调用链,没有任何缩进问题。

承诺优势:承诺将你从不必要的死亡金字塔中拯救出来。

现在您知道将简单的 nodeback 函数转换成通过简单调用Q.nbind来返回承诺的函数是多么容易。当使用Q.nbind转换函数时,需要注意的另一件事是,一个实例的成员函数可能依赖于this作为正确的调用上下文,正如我们在第二章中看到的。这个调用上下文可以简单地作为第二个参数传递给Q.nbind,如清单 10-24 所示,这里我们将foo作为第二个参数传递给Q.nbind以确保正确的this

清单 10-24 。interop/context.js

var foo = {
    bar: 123,
    bas: function (cb) {
        cb(null, this.bar);
    }
};

var Q = require('q');
var basAsync = Q.nbind(foo.bas, foo);

basAsync().then(function (val) {
    console.log(val); // 123;
});

转换非 nodeback 回调函数

浏览器中的许多函数(例如setTimeout)并不遵循错误作为第一个参数的 nodeback 约定。为了便于代码重用,这些函数被原样移植到 Node.js 。要将这些(以及其他可能不遵循 nodeback 接口的函数)转换为返回承诺,可以使用我们已经熟悉的延迟 API ( deferred.resolve/deferred.reject)。例如,在清单 10-25 中,我们从setTimeout中创建了一个简单的基于承诺的sleepAsync函数。

清单 10-25 。interop/sleep.js

var Q = require('q');
function sleepAsync(ms) {
    var deferred = Q.defer();
    setTimeout(function () {
        deferred.resolve();
    }, ms);
    return deferred.promise;
}

console.time('sleep');
sleepAsync(1000).then(function () {
    console.timeEnd('sleep'); // around 1000ms
});

提供 Promise + nodeback 接口

既然我们已经介绍了如何将基于 Node 返回和回调的函数转换成返回承诺的函数,那么有必要考虑一下相反的场景,让不熟悉承诺的人更容易使用带有回调的 API。毕竟,回调对于任何初学者来说都很容易掌握。

Q promises 提供了一个简单的函数,promise.nodeify(callback),其中如果callback是一个函数,它假设它是一个 nodeback,如果承诺被拒绝,它就用(error)调用它,如果承诺被实现,它就用(null,resolvedValue)调用它。否则,promise.nodeify干脆回敬诺言。

作为一个例子,我们可以转换我们的基于承诺的loadJSONAsync来支持两个承诺以及 nodeback 约定,如清单 10-26 中的所示。

清单 10-26 。interop/dual.js

var Q = require('q');
var fs = require('fs');
var readFileAsync = Q.nbind(fs.readFile);

function loadJSONAsync(filename, callback) {
    return readFileAsync(filename)
                .then(JSON.parse)
                .nodeify(callback);
}

// Use as a promise
loadJSONAsync('good.json').then(function (val) {
    console.log(val);
});

// Use with a callback
loadJSONAsync('good.json', function (err, val) {
    console.log(val);
});

请注意,我们不需要在我们的loadJSONAsync函数中进行任何复杂的错误处理,因为我们有纯粹的回调代码,而且由于承诺,我们仍然设法支持 nodeback。这也允许您部分地和增量地更新您的应用的部分以使用承诺。

Promise advantage: 你可以无缝地支持 promises + nodeback,并且仍然可以获得 API 中 promises 的所有好处(比如更简单的错误检查)。

关于 Promise API 的更多注释

既然我们已经涵盖了承诺中最有价值的领域,那么有必要提一下周围的一些领域,这样你就可以自称为真正的Promise专家。

承诺作为一种价值观支持其他承诺

当您看到一个值被传递到一个承诺中时,您实际上可以传递另一个承诺,下一个onFulfilled/onRejected处理程序(取决于事情的进展)将被调用,并带有最终确定的值。

当我们在关于链接then的讨论中从我们的onFulfilled / onRejected处理程序返回一个承诺时,我们已经看到了这种情况的发生。链中的下一个onFulfilled / onRejected处理者得到承诺的最终结算价值。对于Q.whendeferred.resolve也是如此,如清单 10-27 所示,以及任何其他时候你试图通过一个承诺作为决心的值。

清单 10-27 。further/thenable.js

var Q = require('Q');

Q.when(Q.when('foo')).then(function (val) {
    console.log(val); // foo
});

var def = Q.defer();
def.resolve(Q.when('foo'));
def.promise.then(function (val) {
    console.log(val); // foo
});

Q.when(null).then(function () {
    return Q.when('foo');
})
.then(function (val) {
    console.log(val); // foo
});

这种行为非常有用,因为如果您有值,就可以传递该值,或者如果您需要发出异步请求将它加载到您的链中,就可以传递对该值的承诺。

不礼貌地终止承诺链(故意)

正如我们前面看到的,onFulfilled / onRejected处理程序中未捕获的异常只会导致链中的下一个承诺被拒绝,但它们不会在应用主循环中抛出错误。这很好,因为它允许您从函数返回承诺,并将错误级联到处理程序,处理程序可以可靠地看到承诺失败的原因。考虑清单 10-28 中的简单例子。

清单 10-28 。further/gracefulcatch.js

var Q = require('q');

function iAsync() {
    return Q.when(null).then(function () {
        var foo;
        // Simulate an uncaught exception because of a programming error
        foo.bar; // access a member on an undefined variable
    });
}

iAsync()
    .then(function () { }) // not called
    .catch(function (err) { console.log(err.message); });

然而,当在您的应用的根级别,您没有将这个承诺返回给任何人时,一个简单的catch ( onRejected处理程序)是不够的。如果我们自己的onRejected处理程序有错误,没有人会得到通知。清单 10-29 提供了一个例子,我们的 catch 回调本身有一个错误,并且在运行时没有通知。这是因为所发生的一切是下一个承诺被拒绝,没有人关心。

清单 10-29 。further/badcatch.js

var Q = require('q');

function iAsync() {
    return Q.when(null).then(function () {
        var foo; foo.bar; // Programming error. Will get caught since we return the chain
    });
}

iAsync()
    .catch(function (err) {
        var foo; foo.bar; // Uncaught exception, rejects the next promise
    });
    // But no one is listening to the returned promise

在这种情况下,如果最终的承诺被拒绝,您只想将错误抛出到主事件循环中。这就是promise.done方法的用途。如果最后一个承诺被拒绝,它会在主事件循环中抛出一个错误,如清单 10-30 所示。

清单 10-30 。进一步/done.js

iAsync()
    .catch(function (err) {
        var foo; foo.bar; // Uncaught exception, rejects the next promise
    })
    .done(); // Since previous promise is rejected throws the rejected value as an error

这里,您将在控制台上看到一个错误,应用将退出。这将帮助您修复代码中可能的错误(在您的catch中的所有地方!)而不是默默的忽略它们。

Promise 库兼容性

承诺中最重要的是‘??’的行为。你如何在你的图书馆里创造承诺是次要的。事实上,Promises/A+规范只规定了then函数(和onFulfilled / onRejected处理程序)的行为,因为这是一个库的 Promises 与另一个库互操作所需要的。在清单 10-31 中,我们展示了一个在蓝鸟和 q 之间无缝使用承诺的演示

清单 10-31 。进一步/librarycompat.js

var Q = require('q');
var BlueBird = require('bluebird');

new BlueBird(function (resolve) { // A bluebird promise
    resolve('foo');
})
    .then(function (val) {
        console.log(val); // foo
        return Q.when('bar'); // A q promise
    })
    .then(function (val) {
        console.log(val); // bar
    });

这意味着,如果您正在使用返回承诺的 Node.js 库,那么一旦 ES6 完成,您可以将 then 与 Q 或您喜欢的其他库一起使用,甚至可以使用原生承诺。

检查承诺的状态

Q promises 提供了一些有用的效用函数来查看承诺的状态。你可以用promise.isFulfilled()/ promise.isRejected()/ promise.isPending()来确定承诺的当前状态。还有一个实用程序 inspect 方法promise.inspect,它返回当前状态的快照。清单 10-32 展示了这一点。

清单 10-32 。进一步/inspect.js

var Q = require('q');

var p1 = Q.defer().promise; // pending
var p2 = Q.when('fulfill'); // fulfilled
var p3 = var p3 = Q.reject(new Error('reject')); // rejected

process.nextTick(function () {
    console.log(p1.isPending()); // true
    console.log(p2.isFulfilled()); // true
    console.log(p3.isRejected()); // true

    console.log(p1.inspect()); // { state: 'pending' }
    console.log(p2.inspect()); // { state: 'fulfilled', value: 'fulfill' }
    console.log(p3.inspect()); // { state: 'rejected', reason: [Error: reject] }
});

并行流量控制

我们已经看到用承诺做一系列异步任务是多么微不足道。这只是一个链接调用的问题。

但是,您可能希望运行一系列异步任务,然后对所有这些任务的结果进行处理。q(以及大多数其他的 promise 库)提供了一个静态的all(也就是Q.all)成员函数,可以用来等待 n 个 promise 完成。这在清单 10-33 中得到了演示,在那里我们开始了许多异步任务,然后在它们完成后继续。

清单 10-33 。进一步/并行. js

var Q = require('q');

// an async function to load an item
var loadItem = Q.nbind(function (id, cb) {
    setTimeout(function () {
        cb(null, { id: id });
    }, 500);
});

Q.all([loadItem(1), loadItem(2)])
    .then(function (items) {
        console.log('Items:', items); // Items: [ { id: 1 }, { id: 2 }]
    })
    .catch(function (reason) { console.log(reason) });

Q.all返回的承诺将解析为一个数组,该数组包含各个承诺的所有解析值,如清单 10-33 中的所示。如果任何一个单独的承诺被拒绝,完整的承诺也会以同样的理由被拒绝。(此时,如果您想知道到底是哪个承诺出了问题,您可以检查这些承诺。)这个例子还展示了使用 promises 来快速(Q.all)且毫不费力地简化回调逻辑的简单性(我们仍然使用了 nodeback 函数,只是将它包装在一个Q.nbind中)。

发电机

我相信在这一点上,你会同意承诺是简化回访的一个很好的方法。但是我们实际上甚至可以做得更好,几乎使异步编程成为该语言的一流成员。作为一个思想实验,想象一下:一种方法告诉 JavaScript 运行时暂停执行 promise 上使用的关键字await上的代码,并且只在函数返回的 promise 确定后恢复执行。(参见清单 10-34 )。

清单 10-34 。 generators/thought.js 片段

// Not actual code. A thought experiment
async function foo() {
    try {
        var val = await getMeAPromise();
        console.log(val);
    }
    catch(err){
        console.log('Error: ',err.message);
    }
}

当承诺完成时,如果履行了,执行继续。然后 await 将返回值。如果被拒绝,就会同步抛出一个错误,我们可以捕捉到。这突然(神奇地)让异步编程像同步编程一样简单需要三样东西:

  • 能够暂停功能执行
  • 能够在函数中返回一个值
  • 能够在函数内部抛出异常

好消息是这种魔法非常真实,今天就可以尝试一下。语法将略有不同,因为我们将使用的技术不是专为这个而设计的。这是可能的,因为 JavaScript 生成器,这是 ECMAScript 6 附带的一项技术,您今天可以使用它。要在今天使用它,您将需要两样东西,一旦 ECMAScript 6 最终完成,这两样东西都将变得没有必要:

  • Node.js 的不稳定版本。目前这意味着表单的某个版本v0.1,例如v0.11.13。你可以从http://nodejs.org/dist下载预建的 Windows 和 Mac OS X 的二进制文件/安装程序,并像我们在第一章中所做的那样安装它们。
  • 使用'--harmony'标志运行 Node.js 可执行文件。例如,要运行文件app.js,您需要执行以下操作:
$ node --harmony app.js

发电机的动机

向 JavaScript 添加生成器的主要动机是为了能够在迭代期间进行惰性评估。一个简单的例子是希望从函数中返回一个无限列表。清单 10-35 展示了这一点。

清单 10-35 。generators/infiniteSequence.js

function* infiniteSequence(){
    var i = 0;
    while(true){
        yield i++;
    }
}

var iterator = infiniteSequence();
while (true){
    console.log(iterator.next()); // { value: xxxx, done: false }
}

通过使用function*(而不是function)而不是return关键字,你用yield表示一个函数将返回一个迭代器。每次你yield,控制离开发电机回到iterator。每次调用iterator.next时,发电机功能从最后一次产出恢复。如果您运行这个简单的例子,您将看到迭代器返回(产生)一个无限的数字列表。这个例子展示了向语言中添加生成器的动机。

JavaScript 中生成器的力量

JavaScript 中的生成器比许多其他语言中的强大得多。首先,让我们来看一个例子,这个例子更能说明他们的行为。清单 10-37 是应用的一个运行示例。

清单 10-36 。发电机/outside.js

function* generator(){
    console.log('Execution started');
    yield 0;
    console.log('Execution resumed');
    yield 1;
    console.log('Execution resumed');
}

var iterator = generator();
console.log('Starting iteration');
console.log(iterator.next()); // { value: 0, done: false }
console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: undefined, done: true }

清单 10-37 。generators/outside.js 的执行示例

$ node --harmony outside.js
Starting iteration
Execution started
{ value: 0, done: false }
Execution resumed
{ value: 1, done: false }
Execution resumed
{ value: undefined, done: true }

如示例所示,简单地调用生成器并不会执行它。它只是返回迭代器。第一次在迭代器上调用next是在执行开始时,并且只持续到对yield关键字求值,这时iterator.next返回传递给yield关键字的值。每次你调用iterator.next,函数体继续执行,直到我们最终到达函数体的末尾,此时iterator.next返回一个done设置为true的对象。正是这种行为使得以一种懒惰的方式生成类似无限斐波那契数列的东西成为可能。

我们使用生成器的通信主要是单向的,生成器为迭代器返回值。JavaScript 中生成器的一个非常强大的特性是它们允许双向通信!给定一个迭代器,你可以调用iterator.next(someValue) ,该值将通过yield关键字在生成器函数内返回。清单 10-38 一个简单的例子来演示我们注入值bar的过程。

清单 10-38 。 generators/insideValue.js

function* generator(){
    var bar = yield 'foo';
    console.log(bar); // bar!
}

var iterator = generator();
// Start execution till we get first yield value
var foo = iterator.next();
console.log(foo.value); // foo
// Resume execution injecting bar
var nextThing = iterator.next('bar');

类似地,我们甚至可以使用iterator.throw(errorObject)函数在生成器内部抛出一个异常,如清单 10-39 所示。

清单 10-39 。 generators/insideThrow.js

function* generator(){
    try{
        yield 'foo';
    }
    catch(err){
        console.log(err.message); // bar!
    }
}

var iterator = generator();
// Start execution till we get first yield value
var foo = iterator.next();
console.log(foo.value); // foo
// Resume execution throwing an exception 'bar'
var nextThing = iterator.throw(new Error('bar'));

我们已经知道yield允许我们暂停函数的执行。现在我们也知道我们有办法在函数内部为关键字yield返回值,甚至抛出一个异常。正如本节开始时所讨论的,这就是我们对于某种形式的 async/await 语义所需要的!事实上,Q附带了一个函数Q.spawn,它封装了等待承诺解决、传入已解决的值以及拒绝承诺时抛出异常的所有复杂性。

承诺和发电机

生成器和 promises 的结合允许你进行接近同步风格的编程,并拥有异步 JavaScript 的所有性能优势。关键要素是包装在Q.spawn函数调用中的生成承诺(??)的生成器函数(function *)。让我们看一个例子。清单 10-40 显示了两种情况(履行和拒绝)产生一个包含在Q.spawn中的发生器的承诺。

清单 10-40 。spawn/basics.js

var Q = require('q');

Q.spawn(function* (){
    // fulfilled
    var foo = yield Q.when('foo');
    console.log(foo); // foo

    // rejected
    try{
        yield Q.reject(new Error('bar'));
    }
    catch(err){
        console.log(err.message); // bar
    }
});

如果承诺被拒绝,生成器内部就会抛出一个同步错误。否则,从yield调用返回承诺的value

q 还有Q.async函数,它接受一个生成器函数并将其包装成一个函数,当调用该函数时,它将返回一个承诺

  • 解析为生成器的最终返回值,并且
  • 如果生成器中存在未捕获的错误,或者最后一个值是被拒绝的承诺,则拒绝。

在列出的 10-41 中,我们演示了如何使用Q.async编写一个承诺消费+承诺返回函数。

上市 10-41 。spawn/async.js

var Q = require('q');

// an async function to load an item
var loadItem = Q.nbind(function (id, cb) {
    setTimeout(function () {
        cb(null, { id: id });
    }, 500); // simulate delay
});

// An async function to load items
var loadItems = Q.async(function* (ids){
    var items = [];
    for (var i = 0; i < ids.length; i++) {
        items.push(yield loadItem(ids[i]));
    }
    return items;
});

Q.spawn(function* (){
    console.log(yield loadItems([1,2,3]));
});

允许你使用生成器创作 API,这样你就可以使用近乎同步的开发风格,只需返回一个承诺,其他人就可以在他们自己的生成器中使用它。

Image 注意当你在应用的根级运行时,使用Q.spawn(这类似于使用promise.done函数)。当你创作一个函数,也就是编写代码返回一个结果时,只需将函数包装在Q.async中。

未来

ECMAScript 7 已经推荐将async / await作为该语言的一级可用关键字,它提供了非常少量的糖来完成类似于我们所演示的转换。也就是说,ECMAScript 7 可能会在 JavaScript VM 中悄悄地重写清单 10-42 中的代码:

清单 10-42 。内部使用生成器的建议未来语法

async function <name>?<argumentlist><body>

=>

function <name>?<argumentlist>{ return spawn(function*() <body>); }

结果如何还不得而知,但是看起来很有希望。注意,最终的语法可能会有很大的不同(例如,没有使用async关键字),但是行为似乎基本上是固定的。

承诺优势:如果你使用承诺,你就能更好地应对未来 JavaScript 语言的变化。

额外资源

异步(npm 异步安装)文档:www.npmjs.org/package/async

承诺/A+规格:http://promises-aplus.github.io/promises-spec/

Promise状态和命运的描述:https://github.com/domenic/promises-unwrapping/blob/master/docs/states-and-fates.md

ECMAScript ES7 异步/等待:https://github.com/lukehoban/ecmascript-asyncawait

摘要

在这一章中,我们看到了在编写高质量回调风格代码时需要注意的所有方面。一旦你开始使用 promises,编写好的基于回调函数的经验可能与你的代码不相关,但是仍然可以帮助你调试其他人的代码。

在演示了回调可能变得棘手之后,我们提供了承诺作为一种替代方案,可以极大地简化您的代码流。我们讨论了Promise链的行为,并展示了它如何简化错误处理并允许您的异步代码遵循自然的顺序模式。我们还演示了将现有的 nodeback 样式的函数转换为返回承诺是多么容易,以及如何使您的承诺返回和消费函数仍然支持 nodeback,以便您可以增量更新您的 API 和应用。

最后,我们讨论了为 JavaScript 代码创新提供令人兴奋的机会的生成器。我们将生成器与承诺相结合,以获得同步编程的最佳好处——换句话说,简化的错误处理、错误冒泡和对高性能异步代码的函数执行。

posted @   绝不原创的飞龙  阅读(16)  评论(0编辑  收藏  举报
编辑推荐:
· 一个奇形怪状的面试题:Bean中的CHM要不要加volatile?
· [.NET]调用本地 Deepseek 模型
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· .NET Core 托管堆内存泄露/CPU异常的常见思路
· PostgreSQL 和 SQL Server 在统计信息维护中的关键差异
阅读排行:
· DeepSeek “源神”启动!「GitHub 热点速览」
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 我与微信审核的“相爱相杀”看个人小程序副业
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· DeepSeek R1 简明指南:架构、训练、本地部署及硬件要求
点击右上角即可分享
微信分享提示