NodeJS-入门指南-四-

65 阅读56分钟

NodeJS 入门指南(四)

原文:Beginning Node.js

协议:CC BY-NC-SA 4.0

八、持久化数据

数据持久性是任何真实应用的重要组成部分。在这一章中,我们将为初学者提供一个可靠的数据持久化策略。我们将介绍 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 代码创新提供令人兴奋的机会的生成器。我们将生成器与承诺相结合,以获得同步编程的最佳好处——换句话说,简化的错误处理、错误冒泡和对高性能异步代码的函数执行。