NodeJS-秘籍-二-

69 阅读33分钟

NodeJS 秘籍(二)

原文:zh.annas-archive.org/md5/B8CF3F6C144C7F09982676822001945F

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章:与数据库交互

在本章中,我们将涵盖:

  • 写入 CSV 文件

  • 连接并向 MySQL 服务器发送 SQL

  • 使用 MongoDB 存储和检索数据

  • 使用 Mongoskin 存储和检索数据

  • 使用 Cradle 将数据存储到 CouchDB

  • 使用 Cradle 从 CouchDB 检索数据

  • 使用 Cradle 访问 CouchDB 更改流

  • 使用 Redis 存储和检索数据

  • 使用 Redis 实现 PubSub

介绍

随着我们代码的复杂性和目标的要求增加,我们很快意识到需要一个地方来存储我们的数据。

然后我们必须问一个问题:存储我们的数据的最佳方式是什么?答案取决于我们正在处理的数据类型,因为不同的挑战需要不同的解决方案。

如果我们正在做一些非常简单的事情,我们可以将我们的数据保存为一个平面的 CSV 文件,这样做的好处是使用户能够在电子表格应用程序中查看 CSV 文件。

如果我们处理的是具有明显关系特性的数据,例如会计数据,其中交易的两个方面之间存在明显的关系,那么我们会选择关系数据库,比如流行的 MySQL。

在许多情况下,关系数据库成为了几乎所有数据场景的事实标准。这导致了对本来松散相关的数据(例如网站内容)施加关系的必要性,试图将其挤入我们的关系心理模型中。

然而,近年来,人们已经开始从关系数据库转向 NoSQL,一种非关系范式。推动力是我们要根据数据最适合我们的技术,而不是试图将我们的数据适应我们的技术。

在本章中,我们将探讨各种数据存储技术,并举例说明它们在 Node 中的使用。

写入 CSV 文件

平面文件结构是最基本的数据库模型之一。列可以是固定长度,也可以使用分隔符。逗号分隔值(CSV)约定符合分隔的平面文件结构数据库的概念。虽然它被称为 CSV,但 CSV 这个术语也被广泛应用于任何基本的分隔结构,每行一个记录(例如,制表符分隔的值)。

我们可以通过使用多维数组和join方法来遵循一个脆弱的方法来构建 CSV 结构:

var data = [['a','b','c','d','e','f','g'], ['h','i','j','k','l','m','n']];
var csv = data.join("\r\n");  /* renders:  a,b,c,d,e,f,g 
                                                             h,i,j,k,l,m,n   */

然而,这种技术的局限性很快变得明显。如果我们的字段中包含逗号怎么办?现在一个字段变成了两个,从而破坏了我们的数据。此外,我们只能使用逗号作为分隔符。

在这个示例中,我们将使用第三方的ya-csv模块以 CSV 格式存储数据。

准备工作

让我们创建一个名为write_to_csv.js的文件,我们还需要检索ya-csv

npm install ya-csv 

如何做...

我们需要ya-csv模块,调用它的createCsvFileWriter方法来实例化一个 CSV 文件写入器,并循环遍历我们的数组,调用 CSV 文件写入器的writeRecord方法:

var csv = require('ya-csv'); 
var writer = csv.createCsvFileWriter('data.csv'); 

var data = [['a','b','c','d','e','f','g'], ['h','i','j','k','l','m','n']]; 

data.forEach(function(rec) { 
  writer.writeRecord(rec); 
});

让我们来看看我们保存的文件,data.csv:

"a","b","c","d","e","f","g"
"h","i","j","k","l","m","n"

它是如何工作的...

写入和读取 CSV 文件的困难之处在于边缘情况,比如嵌入在文本中的逗号或引号。ya-csv为我们处理了这些边缘情况。

我们使用createCsvFileWriterya-csvCsvWriter的实例加载到writer变量中。

然后我们简单地循环遍历每个数组作为rec,将其传递给ya-csvCsvWriterwriteRecord方法。在幕后,它会重新构造每个数组并将其传递给fs.WriteStream的实例。

这个示例取决于我们在代码中使用基本的数据结构。多维对象必须被转换成正确的格式,因为writeRecord只能处理数组。

还有更多...

我们能否轻松地自己创建这个功能?毫无疑问。然而,ya-csv为我们提供了一个 API,可以无缝地定制我们的 CSV 文件的元素,并实现更复杂的 CSV 解析功能。

自定义 CSV 元素

如果我们将我们的配方文件保存为 write_to_custom_csv.js,并将一个 options 对象传递给 createCsvFileWriter,我们可以改变我们的 CSV 文件构造方式:

var writer = csv.createCsvFileWriter('custom_data.csv', { 
    'separator': '~', 
    'quote': '|', 
    'escape': '|' 
});

注意 escape 选项。这将设置防止意外关闭 CSV 字段的字符。让我们将其插入到我们的数组中,看看 ya-csv 如何处理它:

var data = [['a','b','c','d','e|','f','g'], ['h','i','j','k','l','m','n']];

运行我们的新代码后,让我们看看 custom_data.csv

|a|~|b|~|c|~|d|~|e|||~|f|~|g|
|h|~|i|~|j|~|k|~|l|~|m|~|n|

看看我们在 e 字段中的管道字符后面添加了另一个管道以进行转义。

读取 CSV 文件

我们还可以使用 ya-csv 从 CSV 文件中读取,其内置解析器将每个 CSV 记录转换回数组。让我们制作 read_from_csv.js

var csv = require('ya-csv'); 
var reader = csv.createCsvFileReader('data.csv'); 
var data = []; 

reader.on('data', function(rec) { 
  data.push(rec); 
}).on('end', function() { 
  console.log(data); 
}); 

如果我们希望解析替代分隔符和引号,我们只需将它们传递到 createCsvFileReaderoptions 对象中:

var reader = csv.createCsvFileReader('custom_data.csv', { 
    'separator': '~', 
    'quote': '|', 
    'escape': '|' 
});

操作 CSV 作为流

ya-csv 与 CSV 文件交互作为流。这可以减少操作内存,因为流允许我们在加载时处理小块信息,而不是首先将整个文件缓冲到内存中。

var csv = require('ya-csv'); 
var http = require('http'); 

http.createServer(function (request, response) { 
     response.write('['); 
      csv.createCsvFileReader('big_data.csv') 
      .on('data', function(data) { 
        response.write(((this.parsingStatus.rows > 0) ? ',' : '') + JSON.stringify(data)); 
      }).on('end', function() { 
        response.end(']'); 
      }); 
}).listen(8080);

参见

  • 在本章中讨论了连接并向 MySQL 服务器发送 SQL

  • 在本章中讨论了使用 Mongoskin 存储和检索数据

  • 使用 Cradle 将数据存储到 CouchDB 在本章中讨论

  • 在本章中讨论了使用 Redis 存储和检索数据

连接并向 MySQL 服务器发送 SQL

自 1986 年以来,结构化查询语言一直是标准,也是关系数据库的主要语言。MySQL 是最流行的 SQL 关系数据库服务器,经常出现在流行的 LAMP(Linux Apache MySQL PHP)堆栈中。

如果关系数据库在新项目的目标中概念上相关,或者我们正在将基于 MySQL 的项目从另一个框架迁移到 Node,第三方 mysql 模块将特别有用。

在这个任务中,我们将发现如何使用 Node 连接到 MySQL 服务器并在网络上执行 SQL 查询。

准备工作

让我们获取 mysql,这是一个纯 JavaScript(而不是 C++ 绑定)的 MySQL 客户端模块。

npm install mysql 

我们需要一个 MySQL 服务器进行连接。默认情况下,mysql 客户端模块连接到 localhost,因此我们将在本地运行 MySQL。

在 Linux 和 Mac OSX 上,我们可以使用以下命令查看 MySQL 是否已安装:

whereis mysql 

我们可以使用以下命令查看它是否正在运行:

ps -ef | grep mysqld 

如果已安装但未运行,我们可以执行:

sudo service mysql start 

如果 MySQL 没有安装,我们可以使用系统的相关软件包管理器(homebrew、apt-get/synaptic、yum 等),或者如果我们在 Windows 上使用 Node,我们可以前往 dev.mysql.com/downloads/mysql 并下载安装程序。

一旦我们准备好,让我们创建一个文件并将其命名为 mysql.js

如何做...

首先,我们需要第三方的 mysql 驱动程序,并创建与服务器的连接:

var mysql = require('mysql'); 
var client = mysql.createClient({ 
  user: 'root', 
  password: 'OURPASSWORD' ,
//debug: true
});

我们需要连接的数据库。让我们保持有趣,创建一个 quotes 数据库。我们可以通过将 SQL 传递给 query 方法来实现:

client.query('CREATE DATABASE quotes');
client.useDatabase('quotes');

我们还调用了 useDatabase 方法来连接到数据库,尽管我们可以通过 client.query('USE quotes') 实现相同的效果。现在我们将创建一个同名的表。

client.query('CREATE TABLE quotes.quotes (' + 
             'id INT NOT NULL AUTO_INCREMENT, ' + 
             'author VARCHAR( 128 ) NOT NULL, ' + 
             'quote TEXT NOT NULL, PRIMARY KEY (  id )' + 
             ')');

如果我们运行我们的代码超过一次,我们会注意到一个未处理的错误被抛出,程序失败。这是因为 mysql 驱动程序发出了一个错误事件,反映了 MySQL 服务器的错误。它抛出了一个未处理的错误,因为 quotes 数据库(和表)无法创建,因为它们已经存在。

我们希望我们的代码足够灵活,可以在必要时创建数据库,但如果不存在则不会抛出错误。为此,我们将捕获客户端实例发出的任何错误,过滤掉数据库/表存在的错误:

var ignore = [mysql.ERROR_DB_CREATE_EXISTS, 
                      mysql.ERROR_TABLE_EXISTS_ERROR]; 

client.on('error', function (err) { 
  if (ignore.indexOf(err.number) > -1) { return; } 
  throw err; 
}); 

我们将在client.query方法调用之前放置我们的错误捕获器。最后,在我们的代码末尾,我们将向表中插入我们的第一条引用,并发送一个COM_QUIT数据包(使用client.end)到 MySQL 服务器。这将只在所有排队的 SQL 被执行后关闭连接。

client.query('INSERT INTO  quotes.quotes (' + 
              'author, quote) ' + 
             'VALUES ("Bjarne Stroustrup", "Proof by analogy is fraud.");');

client.end();

它是如何工作的...

createClient方法建立与服务器的连接,并为我们返回一个客户端实例以便与之交互。我们可以将其作为一个options对象传递,该对象可能包含host, port, user, password, database, flagsdebug。除了userpassword之外,对于我们的目的来说,默认选项都是可以的。如果我们取消注释debug,我们可以看到被发送到服务器和从服务器接收的原始数据。

client.query将 SQL 发送到我们的数据库,然后由 MySQL 服务器执行。使用它,我们CREATE一个名为quotesDATABASE,还有一个名为quotesTABLE。然后我们将我们的第一条记录(C++的发明者的引用)插入到我们的数据库中。

client.query将每个传递给它的 SQL 语句排队,与我们的其他代码异步执行语句,但在 SQL 语句队列中是顺序执行的。当我们调用client.end时,连接关闭任务将被添加到队列的末尾。如果我们想要忽略语句队列,并立即结束连接,我们将使用client.destroy

我们的ignore数组包含两个数字,10071050 — 我们从mysql对象中获取这些数字,该对象包含 MySQL 错误代码。我们希望忽略 MySQL 在表或数据库已经存在时发生的错误,否则我们只能运行mysql.js`一次。第一次运行后,它会崩溃,因为数据库和表已经存在。忽略这些代码意味着我们可以隐式地设置我们的数据库,并且只有一个文件,而不是一个用于设置和一个用于插入代码的单独的应用程序。

error事件监听器中,我们检查err.number是否在我们的ignore数组中。如果是,我们简单地return,从而忽略错误并优雅地继续执行。如果错误是其他性质的,我们将继续执行抛出错误的通常行为。

还有更多...

我们不仅将数据发送到 MySQL,还会检索数据。此外,SQL 查询通常是从用户输入生成的,但如果不采取预防措施,这可能会被利用。

使用和清理用户输入

与其他使用字符串连接构建 SQL 语句的语言一样,我们必须防止 SQL 注入攻击的可能性,以保持服务器的安全。基本上,我们必须清理(即转义)任何用户输入,以消除不需要的 SQL 操纵的可能性。

我们将复制mysql.js并将其命名为insert_quotes.js。为了以简单的方式实现用户输入的概念,我们将从命令行中提取参数,但是数据清理的原则和方法适用于任何输入方法(例如,通过请求的查询字符串)。

我们的基本 API 将是这样的:

node quotes.js "Author Name" "Quote Text Here" 

引号是将命令行参数分隔的必要条件,但为了简洁起见,我们不会实现任何验证检查。

提示

命令行解析模块:optimist

对于更高级的命令行功能,请查看优秀的optimist模块,网址为www.github.com/substack/node-optimist

为了接收作者和引用,我们将两个引用参数加载到一个新的params对象中。

var params = {author: process.argv[2], quote: process.argv[3]};

我们的第一个参数在process.argv数组中是2,因为01分别是nodequotes.js

现在让我们稍微修改我们的INSERT语句:

if (params.author && params.quote) {             
  client.query('INSERT INTO  quotes.quotes (' + 
                'author, quote) ' + 
                'VALUES (?, ?);', [ params.author, params.quote ]); 
}
client.end(); 

我们将这个放在主要的client.end调用之前。mysql模块可以无缝地为我们清理用户输入。我们只需使用问号(?)作为占位符,然后将我们的值(按顺序)作为数组传递到client.query的第二个参数中。

从 MySQL 服务器接收结果

让我们通过输出所有作者的引用来进一步扩展insert_quotes.js,无论是否提供了引用。我们将insert_quotes.js简单保存为quotes.js

在我们的INSERT查询下面,但在最终的client.end之上,我们将添加以下代码:

if (params.author) { 
  client.query('SELECT *  FROM quotes WHERE ' + 
    'author LIKE ' + client.escape(params.author)) 
    .on('row', function (rec) { 
      console.log('%s: %s \n', rec.author, rec.quote); 
    }); 
}
client.end();

在这种情况下,我们使用了另一种方法来清理用户输入,即client.escape。这与前一种方法的效果完全相同,但只转义单个输入。通常,如果有多个变量,前一种方法更可取。

可以通过传递回调函数或监听row事件来访问SELECT语句的结果。row事件监听器允许我们逐行与 MySQL 服务器数据流交互。

我们可以安全地调用client.end,而不必将其放在我们的SELECT查询的end事件中,因为client.end只有在所有查询完成时才会终止连接。

另请参阅

  • 在本章中讨论的使用 MongoDB 存储和检索数据

  • 在本章中讨论的使用 Redis 存储和检索数据

使用 MongoDB 存储和检索数据

MongoDB 是一种 NoSQL 数据库提供,坚持性能优于功能的理念。它专为速度和可扩展性而设计。它实现了一个基于文档的模型,不需要模式(列定义),而不是关系工作。文档模型适用于数据之间关系灵活且最小潜在数据丢失是速度增强的可接受成本的情况(例如博客)。

虽然它属于 NoSQL 家族,但 MongoDB 试图处于两个世界之间,提供类似 SQL 的语法,但以非关系方式运行。

在这个任务中,我们将实现与之前的配方相同的quotes数据库,但使用 MongoDB 而不是 MySQL。

准备工作

我们将要在本地运行一个 MongoDB 服务器。可以从www.mongodb.org/downloads下载。

让我们以默认的调试模式启动 MongoDB 服务mongod

mongod --dbpath [a folder for the database] 

这使我们能够观察mongod与我们的代码交互的活动,如果我们想要将其作为持久后台服务启动,我们将使用。

mongod --fork --logpath [p] --logappend dbpath [p] 

其中[p]是我们想要的路径。

提示

有关启动和正确停止mongod的更多信息,请访问www.mongodb.org/display/DOCS/Starting+and+Stopping+Mongo

要从 Node 与 MongoDB 交互,我们需要安装mongodb本机绑定驱动程序模块。

npm install mongodb 

我们还将为基于 MongoDB 的项目创建一个新文件夹,其中包含一个新的quotes.js文件。

操作步骤...

我们必须要求mongodb驱动程序,启动一个 MongoDB 服务器实例,并创建一个客户端,加载引用数据库并连接到 MongoDB 服务器。

var mongo = require('mongodb'); 
var server = new mongo.Server("localhost", 27017); 
var client = new mongo.Db('quotes', server);

var params = {author: process.argv[2], quote: process.argv[3]};

请注意,我们还插入了我们的params对象,以从命令行读取用户输入。

现在我们打开到我们的quotes数据库的连接,并加载(或创建如果需要)我们的quotes集合(在 SQL 中,表将是最接近的类似概念)。

client.open(function (err, client) { 
  if (err) { throw err; } 
  var collection = new mongo.Collection(client, 'quotes');
  client.close();
});

接下来,我们将根据用户定义的作者和引用插入一个新文档(在 SQL 术语中,这将是一条记录)。

我们还将在控制台上输出指定作者的任何引用。

client.open(function (err, client) { 
  if (err) { throw err; } 

  var collection = new mongo.Collection(client, 'quotes'); 

  if (params.author && params.quote) { 
    collection.insert({author: params.author, quote: params.quote}); 
  }

 if (params.author) { 

    collection.find({author: params.author}).each(function (err, doc) { 
      if (err) { throw err; } 
      if (doc) { console.log('%s: %s \n', doc.author, doc.quote); return; } 
      client.close(); 
    }); 

    return; 
  }

client.close();

});

我们可以在以下截图中看到我们基于 MongoDB 的引用应用程序的运行情况:

操作步骤...

工作原理...

当我们创建一个新的mongo.Db实例时,我们将数据库的名称作为第一个参数传递进去。如果数据库不存在,MongoDB 会智能地创建这个数据库。

我们使用Db实例的open方法,我们将其命名为client,以打开与数据库的连接。一旦连接建立,我们的回调函数就会被执行,我们可以通过client参数与数据库进行交互。

我们首先创建一个Collection实例。Collection类似于 SQL 表,它包含了所有我们的数据库字段。但是,与字段值按列分组不同,集合包含多个文档(类似记录),其中每个字段都包含字段名和其值(文档非常类似于 JavaScript 对象)。

如果quoteauthor都被定义了,我们就调用我们的Collection实例的insert方法,传入一个对象作为我们的文档。

最后,我们使用find,它类似于SELECT SQL 命令,传入一个指定作者字段和所需值的对象。mongodb驱动程序提供了一个方便的方法(each),可以与find方法链接。each执行传递给它的回调,对于每个找到的文档都会执行。each的最后一个循环将doc作为null传递,这方便地表示 MongoDB 已经返回了所有记录。

只要doc是真实的,我们就传递每个找到的docauthorquote属性。一旦docnull,我们允许解释器通过不从回调中提前返回来发现回调的最后部分,client.close

client.open回调的最后,第二个也是最后一个client.close只有在没有通过命令行定义参数时才会被调用。

还有更多...

让我们看看一些其他有用的 MongoDB 功能。

索引和聚合

索引会导致 MongoDB 从所选字段创建一个值列表。索引字段可以加快查询速度,因为可以使用更小的数据集来交叉引用和从更大的数据集中提取数据。我们可以将索引应用到作者字段,并看到性能的提升,特别是当我们的数据增长时。此外,MongoDB 有各种命令允许我们对数据进行聚合。我们可以分组、计数和返回不同的值。

注意

对于更高级的需求或更大的数据集,map/reduce 函数可以进行聚合。CouchDB 也使用 map/reduce 来生成视图(存储的查询),参见使用 Cradle 从 CouchDB 检索数据

让我们创建并输出在我们的数据库中找到的作者列表,并将我们的代码保存到一个名为authors.js的文件中。

var mongo = require('mongodb'); 
var server = new mongo.Server("localhost", 27017); 
var client = new mongo.Db('quotes', server); 

client.open(function (err, client) { 
  if (err) { throw err; } 
  var collection = new mongo.Collection(client, 'quotes');  
  collection.ensureIndex('author', {safe: true}, function (err) { 
    if (err) { throw err; } 
    collection.distinct('author', function (err, result) { 
        if (err) { throw err; } 
        console.log(result.join('\n')); 
        client.close(); 
      });  
    }); 

});

通常情况下,我们打开了与我们的quotes数据库的连接,获取了我们的quotes集合。使用ensureIndex只有在索引不存在时才会创建一个索引。我们传入safe:true,这样 MongoDB 会返回任何错误,并且我们的回调函数可以正常工作。在回调函数中,我们在我们的collection上调用distinct方法,传入author。结果作为数组传递,我们使用换行符将数组join成一个字符串并输出到控制台。

更新修改器、排序和限制

我们可以让一个假设的用户指示他们是否受到引用的启发(例如Like按钮),然后我们可以使用sortlimit命令来输出前十个最具启发性的引用。

实际上,这将通过某种用户界面来实现(例如,在浏览器中),但我们将再次使用命令行来模拟用户交互;让我们创建一个名为quotes_votes.js的新文件。

首先,为了对引用进行投票,我们需要引用它,这可以通过唯一的_id属性完成。因此在quotes_votes.js中,让我们写:

var mongo = require('mongodb'); 
var server = new mongo.Server("localhost", 27017); 
var client = new mongo.Db('quotes', server); 
var params = {id: process.argv[2], voter: process.argv[3]}; 
client.open(function (err, client) { 
  if (err) { throw err; } 
  var collection = new mongo.Collection(client, 'quotes');  

//vote handling to go here

 collection.find().each(function (err, doc) { 
      if (err) { throw err; } 
      if (doc) { console.log(doc._id, doc.quote); return; } 
      client.close(); 
    }); 
});

现在当我们用node运行quotes_votes.js时,我们将看到一个 ID 和引用列表。要为引用投票,我们只需复制一个 ID 并将其用作我们的命令行参数。因此,让我们按照以下代码所示进行投票处理:

  if (params.id) { 
    collection.update({_id : new mongo.ObjectID(params.id)}, 
      {$inc: {votes: 1}}, {safe: true}
      function (err) { 
        if (err) { throw err; } 

        collection.find().sort({votes: -1}).limit(10).each(function (err, doc) { 
          if (err) { throw err; } 
          if (doc) { 
            var votes = (doc.votes) || 0; 
            console.log(doc.author, doc.quote, votes); 
            return; 
          } 
          client.close(); 
        }); 
      }); 
    return; 
  }

MongoDB 的 ID 必须编码为 BSON(二进制 JSON)ObjectID。否则,update命令将查找param.id作为字符串,找不到它。因此,我们创建一个new mongo.ObjectID(param.id)来将param.id从 JavaScript 字符串转换为 BSON ObjectID。

$inc 是一个 MongoDB 修饰符,在 MongoDB 服务器内执行递增操作,从根本上允许我们外包计算。要使用它,我们传递一个文档(对象),其中包含要递增的键和要增加的数量。所以我们传递 votes1

$inc 如果不存在将创建 votes 字段,并将其递增一(我们也可以使用负数递减)。接下来是要传递给 MongoDB 的选项。我们将 safe 设置为 true,这告诉 MongoDB 检查命令是否成功,并在失败时发送任何错误。为了使回调正确工作,必须传递 safe:true,否则错误不会被捕获,回调会立即发生。

提示

Upserting

我们可以设置的另一个有用的选项是 upsert:true。这是 MongoDB 的一个非常方便的功能,可以更新记录,如果记录不存在则插入。

update 回调中,我们运行一系列 find.sort.limit.each. find,不带任何参数,这将返回所有记录。sort 需要键和一个正数或负数 1,表示升序或降序。limit 接受一个最大记录数的整数,each 循环遍历我们的记录。在 each 回调中,我们输出 doc 的每个 author, quotevotes,当没有剩余的 docs 时关闭连接。

另请参阅

  • 连接并向 MySQL 服务器发送 SQL 在本章中讨论

  • 使用 Mongoskin 存储和检索数据 在本章中讨论

  • 使用 Cradle 将数据存储到 CouchDB 在本章中讨论

使用 Mongoskin 存储和检索数据

Mongoskin 是一个方便的库,提供了一个高级接口,用于在不阻塞现有 mongodb 方法的情况下与 mongodb 进行交互。

对于这个示例,我们将使用 Mongoskin 在 MongoDB 中重新实现 quotes 数据库。

准备工作

我们需要 mongoskin 模块。

npm install mongoskin 

我们还可以创建一个新的文件夹,带有一个新的 quotes.js 文件。

如何做...

我们将需要 mongoskin 并使用它来创建一个 client 和一个 collection 实例。我们不需要创建一个 server 实例,也不需要像前一个示例中那样手动打开客户端,mongoskin 会处理这一切。

var mongo = require('mongoskin');
var client = mongo.db('localhost:27017/quotes');
var collection = client.collection('quotes');
var params = {author: process.argv[2], quote: process.argv[3]};

与前一个示例一样,我们已经为用户输入定义了我们的 params 对象。

mongoskin 不需要我们使用 JavaScript 可能出现错误的 new 关键字,它提供了一个构建器方法(mongo.db),允许我们使用熟悉的 URI 模式定义我们的主机、端口和数据库名称。

提示

有关为什么 new 前缀可能被认为是错误的,请参阅 [ www. yuiblog.com/blog/2006/11/13/javascript-we-hardly-new-ya/](http:// www. yuiblog.com/blog/2006/11/13/javascript-we-hardly-new-ya/)。

由于我们不需要手动 open 我们的 client(mongoskin 会为我们打开它),所以我们可以直接实现我们的 insertfind 操作:

if (params.author && params.quote) { 
  collection.insert({author: params.author, quote: params.quote}); 
} 
if (params.author) { 
  collection.findEach({}, function (err, doc) { 
    if (err) { throw err; } 
    if (doc) { console.log('%s: %s \n', doc.author, doc.quote); return; } 
    client.close(); 
  }); 
  return; 
} 
client.close();

然后我们就完成了。

它是如何工作的...

我们使用 Mongoskin 的 db 方法透明地连接到我们的数据库,并立即能够获取我们的集合。

与之前的示例一样,我们检查 authorquote 命令行参数,然后调用 mongodb insert 方法,该方法通过 mongoskin 模块本身就可用。

在检查作者之后,我们使用 mongoskinfindEach 方法。findEach 方法包装了前一个示例中使用的 collection.find.each

findEach 中,我们将每个 docauthorquote 属性输出到控制台。当没有文档剩下时,我们明确地 closeclient 连接。

还有更多...

Mongoskin 在使我们的生活更轻松方面做得非常出色。让我们看看另一个简化与 MongoDB 交互的 Mongoskin 功能。

集合绑定

Mongoskin 提供了一个 bind 方法,使集合作为 client 对象的属性可用。所以如果我们这样做:

client.bind('quotes');

我们可以通过 client.quotes 访问引用集合。

这意味着我们可以丢弃collection变量并改用绑定。bind方法还接受一个方法对象,然后应用于绑定的集合。例如,如果我们定义了一个名为store的方法,我们将按以下方式访问它:

client.quotes.store(params.author, params.quote);

因此,让我们创建一个名为quotes_bind.js的新文件,以使用集合绑定方法重新实现 Mongoskin 中的quotes.js

我们将从我们的顶级变量开始:

var mongo = require('mongoskin');
var client = mongo.db('localhost:27017/quotes');
var params = {author: process.argv[2], quote: process.argv[3]};

由于我们将通过bind访问我们的集合,因此我们不需要collection变量。

现在让我们为插入定义一个store方法和一个用于显示引用的show方法:

client.bind('quotes', { 
  store: function (author, quote) { 
    if (quote) { this.insert({author: author, quote: quote}); } 
  }, 
  show: function (author, cb) { 
    this.findEach({author: author}, function (err, doc) { 
      if (err) { throw err; } 
      if (doc) { console.log('%s: %s \n', doc.author, doc.quote); return; } 
      cb(); 
    }); 
  } 
});

然后我们的逻辑与我们的新绑定方法进行交互:

client.quotes.store(params.author, params.quote); 

if (params.author) { 
  client.quotes.show(params.author, function () { 
    client.close(); 
  }); 
  return; 
} 

client.close();

Mongoskin 的bind方法将复杂的数据库操作无缝地抽象成易于使用的点符号格式。

注意

我们将一些params检查功能嵌入到我们的store方法中,只有在引用存在时才调用insert。在我们所有的示例中,我们只需要检查第二个参数(params.quote),我们不能有params.quote而没有params.author。在之前的示例中,这两个参数都进行了检查,以演示在其他情况下它可能如何工作(例如,如果我们通过 POST 请求接收到我们的参数)。

另请参阅

  • 在本章中讨论了使用 MongoDB 存储和检索数据

  • 在本章中讨论了使用 Cradle 将数据存储到 CouchDB

  • 在本章中讨论了使用 Cradle 从 CouchDB 检索数据

使用 Cradle 将数据存储到 CouchDB

为了实现出色的性能速度,MongoDB 对 ACID(原子性一致性隔离持久性)合规性有一定的放松。然而,这意味着数据可能会变得损坏的可能性(尤其是在操作中断的情况下)。另一方面,CouchDB 在复制和同步时是符合 ACID 的,数据最终变得一致。因此,虽然比 MongoDB 慢,但它具有更高的可靠性优势。

CouchDB 完全通过 HTTP REST 调用进行管理,因此我们可以使用http.request来完成与 CouchDB 的所有工作。尽管如此,我们可以使用 Cradle 以一种简单、高级的方式与 CouchDB 进行交互,同时还可以获得自动缓存的速度增强。

在这个示例中,我们将使用 Cradle 将著名的引用存储到 CouchDB 中。

准备工作

我们需要安装和运行 CouchDB,可以前往wiki.apache.org/couchdb/Installation获取有关如何在特定操作系统上安装的说明。

安装完成后,我们可以检查 CouchDB 是否正在运行,并通过将浏览器指向localhost:5984/_utils来访问 Futon 管理面板。

我们还需要cradle模块。

npm install cradle@0.6.3 

然后我们可以在其中创建一个新的quotes.js文件的新文件夹。

如何做...

首先,我们需要cradle并加载我们的引用数据库,如果需要的话创建它。我们还将定义一个错误处理函数和我们的params对象,以便轻松进行命令行交互:

var cradle = require('cradle'); 
var db = new(cradle.Connection)().database('quotes'); 
var params = {author: process.argv[2], quote: process.argv[3]};
function errorHandler(err) { 
  if (err) { console.log(err); process.exit(); } 
//checkAndSave function here

在我们可以写入数据库之前,我们需要知道它是否存在:

db.exists(function (err, exists) { 
  errorHandler(err); 
  if (!exists) { db.create(checkAndSave); return; } 
  checkAndSave(); 
});

请注意,我们将checkAndSave作为db.create的回调传入,以下函数位于db.exists调用之上:

function checkAndSave(err) { 
  errorHandler(err); 

  if (params.author && params.quote) { 
    db.save({author: params.author, quote: params.quote}, errorHandler); 

  } 

} 

我们在checkAndSave中处理的err参数将从db.create中传入。

工作原理...

CouchDB 通过 HTTP 请求进行管理,但 Cradle 提供了一个接口来进行这些请求。当我们调用db.exists时,Cradle 会向http://localhost:5984/quotes发送一个 HEAD 请求,并检查回复状态是否为404 Not Found200 OK。我们可以使用命令行程序的curlgrep执行相同的检查,如下所示:

curl -Is http://localhost:5984/quotes | grep -c "200 OK" 

如果数据库存在,则会输出1,如果不存在,则会输出0。如果我们的数据库不存在,我们调用cradledb.create方法,该方法会向 CouchDB 服务器发送一个 HTTP PUT 请求。在curl中,这将是:

curl -X PUT http://localhost:5984/quote 

我们将我们的checkAndSave函数作为db.create的回调传入,或者如果数据库存在,我们将它从db.exists的回调中调用。这是必要的。我们不能将数据保存到不存在的数据库中,我们必须等待 HTTP 响应,然后才知道它是否存在(或者是否已创建)。

checkAndSave查找命令行参数,然后相应地保存数据。例如,如果我们从命令行运行以下命令:

node quotes.js "Albert Einstein" "Never lose a holy curiosity." 

checkAndSave会意识到有两个参数传递给authorquote,然后将它们传递给db.save。Cradle 然后会 POST 以下内容,Content-Type设置为application/json:

{"author": "Albert Einstein", "quote": "Never lose a holy curiosity"}

除此之外,Cradle 还添加了一个缓存层,在我们的示例中几乎没有用处,因为缓存数据在应用程序退出时会丢失。然而,在服务器实现中,缓存将在快速高效地响应类似请求时变得非常有用。

还有更多...

Couch 代表Cluster Of Unreliable Commodity Hardware,让我们简要看一下 CouchDB 的集群方面。

使用 BigCouch 扩展 CouchDB

扩展是关于使您的应用程序对预期需求做出响应的,但不同的项目具有不同的特点。因此,每个扩展项目都需要个性化的方法。

如果一个 Web 服务主要建立在数据库交互上,那么在响应服务需求变化时,扩展数据库层将成为一个优先考虑的问题。扩展 CouchDB(或其他任何东西)可能是一个非常深入的过程,对于专门的项目来说是必要的。

然而,开源的 BigCouch 项目具有以透明和通用的方式扩展 CouchDB 的能力。使用 BigCouch,我们可以在服务器之间扩展 CouchDB,但与之交互就像它在一个服务器上一样。BigCouch 可以在[www. github.com/cloudant/bigcouch](https://www. github.com/cloudant/bigcouch)找到。

另请参阅

  • 在本章中讨论的使用 Cradle 从 CouchDB 检索数据

  • 在本章中讨论的使用 MongoDB 存储和检索数据

  • 在本章中讨论的使用 Redis 存储和检索数据

使用 Cradle 从 CouchDB 检索数据

CouchDB 不使用与 MySQL 和 MongoDB 相同的查询范式。相反,它使用预先创建的视图来检索所需的数据。

在这个例子中,我们将使用 Cradle 根据指定的作者获取一个引用数组,并将我们的引用输出到控制台。

准备工作

与前一个配方使用 Cradle 将数据存储到 CouchDB一样,我们需要在系统上安装 CouchDB,以及cradle。我们还可以从该配方中获取quotes.js文件,并将其放在一个新的目录中。

如何做...

我们正在从之前的任务中的quotes.js文件中进行工作,在那里如果我们的数据库存在,我们调用checkAndSave,或者如果它不存在,我们就从db.create的回调中调用它。让我们稍微修改checkAndSave,如下面的代码所示:

function checkAndSave(err) { 
  errorHandler(err); 
  if (params.author && params.quote) { 
    db.save({author: params.author, quote: params.quote}, outputQuotes); 
    return; 
  } 

  outputQuotes(); 
} 

我们在checkAndSave的末尾添加了一个新的函数调用outputQuotes,并且也作为db.save的回调。outputQuotes将访问一个名为视图的特殊 CouchDB _design文档。

在我们查看outputQuotes之前,让我们来看看另一个我们将要创建的新函数,名为createQuotesView。它应该放在errorHandler的下面,但在代码的其余部分之上,如下所示:

function createQuotesView(err) { 
  errorHandler(err); 
  db.save('_design/quotes', { 
    views: { byAuthor: { map: 'function (doc) { emit(doc.author, doc) }'}} 
  }, outputQuotes); 
}

createQuotesView还从db.save的回调参数中调用outputQuotes函数。outputQuotes现在从三个地方调用:checkAndSavedb.save回调,checkAndSave的末尾,以及createQuotesViewdb.save回调。

让我们来看看outputQuotes:

function outputQuotes(err) { 
  errorHandler(err); 

  if (params.author) { 
    db.view('quotes/byAuthor', {key: params.author}, 
    function (err, rowsArray) { 
      if (err && err.error === "not_found") { 
        createQuotesView(); 
        return; 
      } 
      errorHandler(err); 

      rowsArray.forEach(function (doc) { 
        console.log('%s: %s \n', doc.author, doc.quote); return; 
      }); 
    }); 
  } 
}

outputQuotescheckAndSave之前,但在createQuotesView之后。

它是如何工作的...

查询 CouchDB 数据库的关键是视图。有两种类型的视图:永久视图和临时视图。在createQuotesView中,我们使用db.save定义了一个永久视图,将文档 ID 设置为_design/quotes。然后我们定义了一个包含名为byAuthor的对象的views字段,该对象包含一个名为map的键,其值是一个格式化的字符串函数。

临时视图将存储一个 ID 为quotes/_temp_view。然而,这些只应该用于测试,它们在计算上非常昂贵,不应该用于生产。

映射函数是字符串格式化的,因为它通过 HTTP 请求传递给 CouchDB。CouchDB 的map函数不是在 Node 中执行的,它们在 CouchDB 服务器内运行。map函数定义了我们希望通过 CouchDB 服务器的emit函数在数据库上运行的查询。emit的第一个参数指定要查询的字段(在我们的例子中是doc.author),第二个指定查询的结果输出(我们想要整个doc)。如果我们想要搜索 Albert Einstein,我们将发出 GET 请求:http://localhost:5984/quotes/_design/quotes/_view/byAuthor?key="Albert Einstein"

Cradle 为这个请求提供了一个简写方法db.view,它出现在我们的outputQuotes函数中。db.view允许我们简单地传递quotes/byAuthor和一个包含key参数(即我们的查询)的第二个对象,从根本上为我们填充了特殊的下划线路由。

db.view解析传入的 JSON 并通过其回调的第二个参数提供它,我们将其命名为rowsArray。我们使用forEach循环数组,最后通过控制台输出authorquote,就像以前的配方一样。

然而,在循环数组之前,我们需要检查我们的视图是否实际存在。视图只需要生成一次。之后,它们将存储在 CouchDB 数据库中。因此,我们不希望每次运行应用程序时都创建一个视图。因此,当我们调用db.view时,我们会查看db.view回调中是否发生not_found错误。如果我们的视图找不到,我们就调用createQuotesView

总的来说,这个过程大致是这样的:

工作原理...

还有更多...

CouchDB 非常适合立即上手。然而,在将 CouchDB 支持的应用程序部署到网络之前,我们必须注意某些安全考虑。

创建管理员用户

CouchDB 不需要初始授权设置,这对开发来说是可以的。然而,一旦我们将 CouchDB 暴露给外部世界,互联网上的任何人都有权限编辑我们的整个数据库:数据设计、配置、用户等。

因此,在部署之前,我们希望设置用户名和密码。我们可以通过_config API 实现这一点:

curl -X PUT http://localhost:5984/_config/admins/dave -d '"cookit"' 

我们已经创建了管理员用户dave并将密码设置为cookit。现在,未经身份验证将拒绝对某些调用的权限,包括创建或删除数据库,修改设计文档(例如视图),或访问_config API。

例如,假设我们想查看所有管理员用户,我们可以说:

curl http://localhost:5984/_config/admins 

CouchDB 将回复:

{"error":"unauthorized", "reason":"You are not a server admin."} 

但是,如果我们包括身份验证信息:

curl http://dave:cookit@localhost:5984/_config/admins 

我们得到了我们唯一的管理员用户以及他密码的哈希值:

{"dave":"-hashed-42e68653895a4c0a5c67baa3cfb9035d01057b0d,44c62ca1bfd4872b773543872d78e950"} 

使用这种方法远程管理 CouchDB 数据库并不是没有安全漏洞。它强迫我们将密码以明文形式通过非安全的 HTTP 发送。理想情况下,我们需要将 CouchDB 托管在 HTTPS 代理后面,这样密码在发送时就会被加密。参见第七章中讨论的设置 HTTPS 服务器配方,实施安全、加密和身份验证

如果 CouchDB 在 HTTPS 后面,cradle可以连接到它如下:

var db = new (cradle.Connection)({secure:true, 
									     auth: { username: 'dave', 
									                 password: 'cookit' }})
		            .database('quotes'); 

我们在创建连接时传递一个options对象。secure属性告诉cradle我们正在使用 SSL,auth包含一个包含登录详细信息的子对象。

或者,我们创建一个 Node 应用程序,用于与本地 CouchDB 实例进行身份验证(以便不将密码发送到外部 HTTP 地址),并充当外部请求和 CouchDB 之间的中间层。

将所有修改操作锁定到管理员用户

即使设置了管理员用户,未经身份验证的用户仍然有权限修改现有数据库。如果我们只在 CouchDB 服务器端写入(但从服务器或客户端读取),我们可以使用验证函数锁定非管理员用户的所有写操作。

验证函数是用 JavaScript 编写的,并在 CouchDB 服务器上运行(类似于映射函数)。一旦定义了验证函数,它就会针对应用到的数据库的所有用户输入执行。函数中出现三个对象作为参数:新文档(newDoc),先前存储的文档(savedDoc)和用户上下文(userCtx),其中包含经过身份验证的用户信息。

在验证函数中,我们可以检查和限定这些对象,调用 CouchDB 的throw函数来拒绝未能满足我们要求的操作请求。

让我们创建一个名为database_lockdown.js的新文件,并开始连接到我们的数据库:

var cradle = require('cradle'); 
var db = new (cradle.Connection)({auth: 
									     { username: 'dave', 
										  password: 'cookit' }})
     		            .database('quotes'); 

我们向新的 cradle 连接传递一个options对象。它包含了认证信息,如果我们根据上一小节创建管理员用户设置了新的管理员用户,那么现在将需要创建一个验证函数。

让我们创建我们的验证函数,并将其保存为_design文档:

var admin_lock = function (newDoc, savedDoc, userCtx) { 
  if (userCtx.roles.indexOf('_admin') === -1) { 
    throw({unauthorized : 'Only for admin users'}); 
  } 
} 
  db.save('_design/_auth', { 
    views: {}, 
    validate_doc_update: admin_lock.toString() 
  }); 

一旦我们执行:

node database_lockdown.js 

现在所有与写操作相关的操作都需要授权。

与视图一样,我们将验证函数存储在具有_design/前缀 ID 的文档中。ID 的另一部分可以是任何内容,但我们将其命名为_auth,这反映了当验证函数提供此类目的时的传统做法。但是,字段名称必须称为validate_doc_update

默认情况下,Cradle 假定传递给db.save的任何_design文档都是一个视图。为了防止 Cradle 将我们的validate_update_doc字段包装成视图,我们将一个空对象指定给views属性。

validate_update_doc必须传递一个字符串格式的函数,因此我们在admin_lock变量下定义我们的函数,并在传递到db.save时对其调用toString

admin_lock永远不会被 Node 执行。这是一种在传递给 CouchDB 之前构建我们的函数的美学方法。

当数据库发生操作时,我们的admin_lock函数(它成为 CouchDB 的validate_update_doc函数)要求 CouchDB 检查请求操作的用户是否具有_admin用户角色。如果没有,它告诉 CouchDB 抛出未经授权的错误,从而拒绝访问。

将 CouchDB HTTP 接口暴露给远程连接

默认情况下,CouchDB 绑定到127.0.0.1。这确保只有本地连接可以连接到数据库,从而在安全执行之前确保安全性。一旦我们在 HTTPS 后设置了至少一个管理员用户,我们可以将 CouchDB 绑定到0.0.0.0,这样 REST 接口可以通过任何 IP 地址访问。这意味着远程用户可以通过我们服务器的公共 IP 地址或更可能通过我们服务器的域名访问我们的 CouchDB HTTP 接口。我们可以使用_config设置绑定地址如下:

curl -X PUT https://u:p@localhost:5984/_config/httpd/bind_address -d '"0.0.0.0"' 

其中up分别是管理员用户名和密码。

另见

  • 在本章中讨论的使用 Cradle 将数据存储到 CouchDB

  • 在本章中讨论的使用 MongoDB 存储和检索数据

  • 第七章 中讨论的设置和 HTTPS Web 服务器,实施安全、加密和身份验证

使用 Cradle 访问 CouchDB 更改流

CouchDB 最引人注目的功能之一是_changes API。通过它,我们可以通过 HTTP 查看对数据库的所有更改。

例如,要查看对我们的quotes数据库所做的所有更改,我们可以向http://localhost:5984/quotes/_changes发出 GET 请求。更好的是,如果我们想要连接到实时流,我们可以添加查询参数?feed=continuous

Cradle 为_changes API 提供了一个吸引人的接口,我们将在本食谱中探讨。

准备好了

我们需要一个可用的 CouchDB 数据库以及一种写入它的方法。我们可以使用使用 Cradle 将数据存储到 CouchDB中使用的quotes.js示例,所以让我们将其复制到一个新目录中,然后在旁边创建一个名为quotes_stream.js的文件。

如果我们遵循了前一篇食谱创建管理员用户将所有修改操作锁定为管理员用户部分中的步骤,我们需要修改quotes.js的第二行,以便继续向我们的数据库中插入引用:

var db = new (cradle.Connection)({ auth: { username: 'dave', 
									                 password: 'cookit' }})
		      .database('quotes'); 

davecookit是示例用户名和密码。

如何做...

我们需要cradle并连接到我们的quotes数据库。我们的流适用于预先存在的数据库,因此我们不会检查数据库是否存在。

var cradle = require('cradle');
var db = new (cradle.Connection)().database('quotes');

接下来,我们调用cradlechanges方法,并监听其response事件,然后监听传入的response发射器的data事件:

db.changes().on('response', function (response) {

  response.on('data', function (change) {
    var changeIsObj = {}.toString.call(change) === '[object Object]';
    if (change.deleted !changeIsObj) { return; }
    db.get(change.id, function (err, doc) { 
      if (!doc) {return;}
      if (doc.author && doc.quote) { 
        console.log('%s: %s \n', doc.author, doc.quote); 
      } 
    }); 
  });

});

为了测试我们的changes流实现,我们将打开两个终端。在一个终端中,我们将运行以下命令:

node quotes_stream.js 

在另一个终端窗口中,我们可以使用quotes.js添加一些引用:

node quotes.js "Yogi Berra" "I never said most of the things I said"
node quotes.js "Woody Allen" "I'd call him a sadistic hippophilic necrophile, but that would be beating a dead horse"
node quotes.js "Oliver Wendell Holmes" "Man's mind, once stretched by a new idea, never regains its original dimensions" 

如何做...

每当在左侧终端中添加新引用时,它会出现在右侧。

在添加任何新引用之前,quotes_stream.js被打开,并立即显示了在使用 Cradle 将数据存储到 CouchDB食谱中添加的Albert Einstein引用。之后,随着添加的新引用,它们也会出现在流中。

它是如何工作的...

changes方法可以传递一个回调,它简单地返回到目前为止的所有更改,然后退出。如果我们没有将回调传递给changes,它会将?feed=continuous参数添加到 HTTP CouchDB REST 调用中,并返回EventEmitter。然后,CouchDB 将返回一个流式的 HTTP 响应给 Cradle,作为response事件的response参数发送。response参数也是EventEmitter,我们通过data事件监听更改。

在每个data事件上,回调处理change参数。每个更改都会触发两个数据事件,一个是 JSON 字符串,另一个是包含等效 JSON 数据的 JavaScript 对象。在继续之前,我们检查change参数的类型是否为对象(changeIsObj)。change对象保存了我们数据库条目的元数据。它有一个序列号(change.seq),一个修订号(change.changes[0].rev),有时包含一个已删除的属性(changes.deleted),并且始终有一个id属性。

如果找到deleted属性,我们需要提前return,因为db.get无法获取已删除的记录。否则,我们将change.id传递给db.get,这将提供对文档 ID 的访问。doc被传递到db.get的回调中。我们只想输出关于我们的引用的更改,因此我们检查authorquote字段,并将它们记录到控制台。

另请参阅

  • 使用 Cradle 将数据存储到 CouchDB在本章中讨论

  • 使用 Cradle 从 CouchDB 检索数据在本章中讨论

  • 使用 Redis 实现 PubSub在本章中讨论

使用 Redis 存储和检索数据

Redis是一个非传统的数据库,被称为数据结构服务器,在操作内存中具有极快的性能。

Redis 非常适用于某些任务,只要数据模型相对简单,且数据量不会太大以至于淹没服务器的 RAM。Redis 擅长的示例包括网站分析、服务器端会话 cookie 和实时提供已登录用户列表。

以我们的主题精神,我们将使用 Redis 重新实现我们的引用数据库。

准备好了

我们将使用node_redis客户端。

npm install redis

我们还需要安装 Redis 服务器,可以从www.redis.io/download下载,并附有安装说明。

让我们还创建一个新的目录,其中包含一个新的quotes.js文件。

如何做...

让我们创建redis模块,创建一个连接,并监听redis client发出的ready事件,不要忘记将命令行参数加载到params对象中。

var redis = require('redis'); 
var client = redis.createClient(); 
var params = {author: process.argv[2], quote: process.argv[3]};

client.on('ready', function () { 
  //quotes insertion and retrieval code to go here...
});

接下来,我们将通过命令行检查authorquote。如果它们被定义,我们将在我们的ready事件回调中将它们作为哈希(对象结构)插入 Redis 中:

if (params.author && params.quote) { 
  var randKey = "Quotes:" + (Math.random() * Math.random()) 
                           .toString(16).replace('.', ''); 

  client.hmset(randKey, {"author": params.author, 
                                        "quote": params.quote}); 

  client.sadd('Author:' + params.author, randKey); 
}

我们不仅将数据添加到了 Redis 中,还在飞行中构建了一个基本的索引,使我们能够在下一段代码中按作者搜索引用。

我们检查第一个命令行参数,作者的存在,并输出该作者的引用。

  if (params.author) { 
    client.smembers('Author:' + params.author, function (err, keys) { 
      keys.forEach(function (key) { 
        client.hgetall(key, function (err, hash) { 
          console.log('%s: %s \n', hash.author, hash.quote); 
        }); 
      }); 
      client.quit(); 
    }); 
    return; 
  } 
  client.quit(); 

}); // closing brackets of the ready event callback function

它是如何工作的...

如果通过命令行指定了authorquote,我们继续并生成一个以Quote:为前缀的随机键。因此,每个键看起来都像Quote:08d780a57b035f。这有助于我们在调试中识别键,并且在 Redis 键前缀中使用名称是常见的约定。

我们将这个键传递给client.hmset,这是 Redis HMSET命令的包装器,它允许我们创建多个哈希。与原始的HMSET不同,client.hmset还接受 JavaScript 对象(而不是数组)来创建多个键分配。使用标准的 Redis 命令行客户端redis-cli,我们需要这样说:

HMSET author "Steve Jobs" quote "Stay hungry, stay foolish." 

我们可以通过使用包含键和值的数组来保持这种格式,但是对象对于 JavaScript 开发者来说更友好和更熟悉。

每次我们使用client.hmset存储新引用时,我们通过client.sadd的第二个参数将该引用的randKey添加到相关的作者集合中。client.sadd允许我们向 Redis 集合(集合类似于字符串数组)中添加成员。我们的SADD命令的键是基于预期作者的。因此,在上面使用的 Steve Jobs 引用中,传递给client.sadd的键将是Author:Steve Jobs

接下来,如果指定了作者,我们使用client.smembers执行SMEMBERS。这将返回我们存储在特定作者集合中的所有引用的键。

我们使用forEach循环遍历这些键,将每个键传递给client.hgetall。Redis HGETALL返回我们之前传递给client.hmset的哈希(对象)。然后将每个作者和引用记录到控制台,并且一旦所有 Redis 命令都已执行,client.quit就会优雅地退出我们的脚本。

ready事件的结尾处也包括了client.quit,在没有指定命令行参数的情况下会发生这种情况。

还有更多...

Redis 是速度狂人的梦想,但我们仍然可以进行优化。

加速 node Redis 模块

默认情况下,redis模块使用纯 JavaScript 解析器。然而,Redis 项目提供了一个 Node hiredis模块:一个 C 绑定模块,它绑定到官方 Redis 客户端 Hiredis。Hiredis 比 JavaScript 解析器更快(因为它是用 C 编写的)。

如果安装了hiredis模块,redis模块将与hiredis模块进行接口。因此,我们可以通过简单安装hiredis来获得性能优势:

npm install hiredis 

通过流水线化命令来克服网络延迟

Redis 可以一次接收多个命令。redis模块有一个multi方法,可以批量发送汇总的命令。如果每个命令的延迟(数据传输所需的时间)为 20 毫秒,对于 10 个组合命令,我们可以节省 180 毫秒(10 x 20 - 20 = 180)。

如果我们将quotes.js复制到quotes_multi.js,我们可以相应地进行修改:

//top variables, client.ready...

 if (params.author && params.quote) { 
    var randKey = "Quote:" + (Math.random() * Math.random()) 
                  .toString(16).replace('.', ''); 

    client.multi() 
      .hmset(randKey, {"author": params.author, 
                                    "quote": params.quote}) 
      .sadd('Author:' + params.author, randKey) 
      .exec(function (err, replies) { 
        if (err) { throw err; }; 
        if (replies[0] == "OK") { console.log('Added...\n'); } 
      }); 
  } 

//if params.author, client.smembers, client.quit

我们可以看到我们的原始 Redis 命令已经突出显示,只是它们已经与client.multi链接在一起。一旦所有命令都添加到client.multi,我们调用它的exec方法。最后,我们使用exec的回调来验证我们的数据是否成功添加。

我们没有为流水线提供SMEMBERS。必须在引语添加后调用SMEMBERS,否则新引语将不会显示。如果SMEMBERSHMSETSADD结合使用,它将与它们一起异步执行。不能保证新引语将对SMEMBERS可用。实际上,这是不太可能的,因为SMEMBERSSADD更复杂,所以处理时间更长。

另请参阅

  • 在本章中讨论了使用 Mongoskin 存储和检索数据

  • 连接并向 MySQL 服务器发送 SQL在本章中讨论

  • 在本章中讨论了使用 Redis 实现 PubSub

使用 Redis 实现 PubSub

Redis 公开了发布-订阅消息模式(与 CouchDB 的changes流不那么相似),可以用于监听特定数据更改事件。这些事件的数据可以在进程之间传递,例如,立即使用新鲜数据更新 Web 应用程序。

通过 PubSub,我们可以向特定频道发布消息,然后任何数量的订阅者都可以接收到该频道。发布机制不在乎谁在听或有多少人在听,它会继续聊天。

在这个配方中,我们将创建一个发布过程和一个订阅过程。对于发布,我们将扩展我们的quotes.js文件,从上一个配方使用 Redis 存储和检索数据,并为订阅机制编写新文件的代码。

准备就绪

让我们创建一个新目录,从上一个配方中复制quotes.js,并将其重命名为quotes_publish.js。我们还将创建一个名为quotes_subscribe.js的文件。我们需要确保 Redis 正在运行。如果它没有全局安装和运行,我们可以导航到 Redis 解压到的目录,并从src文件夹运行./redis-server

如何做...

quotes_publish.js中,我们在第一个条件语句内添加了一行额外的代码,就在我们的client.sadd调用之后。

  if (params.author && params.quote) { 
    var randKey = "Quote:" + (Math.random() * Math.random()) 
                                               .toString(16).replace('.', '');               
    client.hmset(randKey, {"author": params.author, 
                           "quote": params.quote}); 

    client.sadd('Author:' + params.author, randKey); 

    client.publish(params.author, params.quote); 

  }

这意味着每次我们添加一个作者和引语时,我们都会将引语发布到以作者命名的频道。我们使用quotes_subscribe.js订阅频道,所以让我们编写代码。

首先,它必须要求redis模块并创建一个客户端:

var redis = require('redis');
var client = redis.createClient();

我们将提供订阅多个频道的选项,再次使用命令行作为我们的基本输入方法。为此,我们将循环遍历process.argv:

process.argv.slice(2).forEach(function (authorChannel, i) { 

    client.subscribe(authorChannel, function () { 
      console.log('Subscribing to ' + authorChannel + ' channel'); 
    }); 

});

现在我们正在订阅频道,我们需要监听消息:

client.on('message', function (channel, msg) { 
	console.log("\n%s: %s", channel, msg); 
});

我们可以通过首先运行quotes_subscribe.js来测试我们的 PubSub 功能,并指定一些作者:

node quotes_subscribe.js "Sun Tzu" "Steve Jobs" "Ronald Reagan" 

然后我们打开一个新的终端,并通过quotes_publish.js运行几个作者和引语。

node quotes_publish.js "Ronald Reagan" "One picture is worth 1,000 denials."

node quotes_publish.js "Sun Tzu" "Know thy self, know thy enemy. A thousand battles, a thousand victories."

node quotes_publish.js "David Clements" "Redis is a speed freak's dream"

node quotes_publish.js "Steve Jobs" "Design is not just what it looks like and feels like. Design is how it works." 

让我们看看它的运行情况:

如何做...

只有我们订阅的频道才会出现在quotes_subscribe.js终端上。

它是如何工作的...

我们通过client.publish访问 Redis 的PUBLISH命令,在quotes_publish.js中设置频道名称为作者名称。

quotes_subscribe.js中,我们循环遍历通过命令行给出的任何参数。(我们对process.argv.slice(2)应用forEach。这会删除process.argv数组的前两个元素,这些元素将保存命令(node)和我们脚本的路径。将每个相关参数传递给client.subscribe,告诉 Redis 我们希望SUBSCRIBE到该频道。

当由于订阅而到达消息时,redis模块的client将发出message事件。我们监听此事件,并将传入的channelmsg(将分别是authorquote)传递给console.log

还有更多...

最后,我们将看一下 Redis 安全性。

Redis 身份验证

我们可以在 Redis 的redis.conf文件中设置身份验证,该文件位于我们安装 Redis 的目录中。要在redis.conf中设置密码,我们只需添加(或取消注释)requirepass ourpassword

然后,我们确保我们的 Redis 服务器指向配置文件。如果我们是从src目录运行它,我们将使用以下命令初始化:

./redis-server ../redis.conf 

如果我们想快速设置密码,我们可以说:

echo "requirepass ourpassword" | ./redis-server - 

我们可以使用 Redis 命令CONFIG SET从 Node 中设置密码:

client.config('SET', 'requirepass', 'ourpassword');

要在 Node 中与 Redis 服务器进行身份验证,我们可以使用redis模块的auth方法,在任何其他调用之前(即在client.ready之前)。

client.auth('ourpassword');

密码必须在任何其他命令之前发送。redis模块的auth函数通过将密码推送到redis模块的内部操作来处理重新连接等问题。基本上,我们可以在我们的代码顶部调用auth,然后再也不用担心该脚本的身份验证。

保护 Redis 免受外部连接

如果不需要将 Redis 绑定到127.0.0.1以阻止所有外部流量。

我们可以通过配置文件来实现这一点,例如redis.conf,并添加(或取消注释):

bind 127.0.0.1

然后,如果从src文件夹运行,初始化我们的 Redis 服务器:

./redis-server ../redis.conf 

或者,我们可以这样做:

echo "bind 127.0.0.1" | ./redis-server - 

或者在 Node 中使用redis模块的config方法:

client.config('set', 'bind', '127.0.0.1');

注意

如果我们通过软件包管理器安装了 Redis,它可能已经配置为阻止外部连接。

另请参阅

  • 在本章中讨论的使用 Cradle 访问 CouchDB 更改流

  • 在本章中讨论的使用 Redis 存储和检索数据

第五章:超越 AJAX:使用 WebSocket

在本章中,我们将涵盖:

  • 创建 WebSocket 服务器

  • 使用socket.io实现无缝回退

  • 通过socket.io传输的回调

  • 创建实时小部件

介绍

HTTP 并不适用于许多开发人员今天创建的实时网络应用程序。因此,已经发现了各种各样的解决方法来模拟服务器和客户端之间的双向,不间断的通信的想法。

WebSocket 不会模仿这种行为,它们提供了这种行为。WebSocket 通过剥离 HTTP 连接来工作,使其成为持久的类似 TCP 的交换,从而消除了 HTTP 引入的所有开销和限制。

当浏览器和服务器都支持 WebSocket 时,HTTP 连接被剥离(或升级)。浏览器通过 GET 标头与服务器通信来发现这一点。只有较新的浏览器(IE10+,Google Chrome 14,Safari 5,Firefox 6)支持 WebSocket。

WebSocket 是一个新协议。JavaScript 与 Node 框架通常足够灵活和低级,可以从头开始实现协议,或者无法实现的话,可以编写 C/C++模块来处理更晦涩或革命性的逻辑。幸运的是,我们不需要编写自己的协议实现,开源社区已经提供了。

在本章中,我们将使用一些第三方模块来探索 Node 和 WebSocket 强大组合的潜力。

创建 WebSocket 服务器

对于这个任务,我们将使用非核心的websocket模块来创建一个纯 WebSocket 服务器,该服务器将接收并响应来自浏览器的 WebSocket 请求。

准备就绪

我们将为我们的项目创建一个新文件夹,其中将包含两个文件:server.jsclient.html. server.js。它们提供了服务器端的 websocket 功能并提供client.html文件。对于服务器端的 WebSocket 功能,我们还需要安装websocket模块:

npm install websocket 

注意

有关websocket模块的更多信息,请参见www.github.com/Worlize/WebSocket-Node

如何做...

WebSocket 是 HTTP 升级。因此,WebSocket 服务器在 HTTP 服务器之上运行。因此,我们将需要httpwebsocket服务器,另外我们还将加载我们的client.html文件(我们将很快创建)和url模块:

var http = require('http');
var WSServer = require('websocket').server;
var url = require('url');
var clientHtml = require('fs').readFileSync('client.html');

现在让我们创建 HTTP 服务器,并将其提供给一个新的 WebSocket 服务器:

var plainHttpServer = http.createServer(function (request, response) {
    response.writeHead(200, {'Content-type' : 'text/html'});
    response.end(clientHtml);
  }).listen(8080);

var webSocketServer = new WSServer({httpServer: plainHttpServer});

var accept = [ 'localhost', '127.0.0.1' ];

注意

我们将我们的 HTTP 服务器绑定到端口 8080,因为绑定到低于 1000 的端口需要 root 访问权限。这意味着我们的脚本必须以 root 权限执行,这是一个坏主意。有关如何安全地绑定到 HTTP 端口(80)的更多信息,请参见第十章,搞定它

我们还创建了一个新的数组,称为accept。我们在 WebSocket 服务器内部使用它来限制哪些起始站点可以连接。在我们的示例中,我们只允许来自 localhost 或 127.0.0.1 的连接。如果我们正在进行实时主机,我们将在accept数组中包括指向我们服务器的任何域。

现在我们有了webSocketServer实例,我们可以侦听其request事件并做出相应的响应:

webSocketServer.on('request', function (request) {
  request.origin = request.origin || '*'; //no origin? Then use * as wildcard.
  if (accept.indexOf(url.parse(request.origin).hostname) === -1) {
    request.reject();
    console.log('disallowed ' + request.origin);
    return;
  }

  var websocket = request.accept(null, request.origin);

  websocket.on('message', function (msg) {
    console.log('Recieved "' + msg.utf8Data + '" from ' + request.origin);
    if (msg.utf8Data === 'Hello') {
      websocket.send('WebSockets!');
    }
  });

  websocket.on('close', function (code, desc) {
   console.log('Disconnect: ' + code + ' - ' + desc);
  });

});

在我们的request事件回调中,我们有条件地接受请求,然后监听messageclose事件,如果来自客户端的消息是Hello,则用**WebSockets!**做出响应。

现在对于客户端,我们将放置以下 HTML 结构:

<html>
<head>
</head>
<body>
<input id=msg><button id=send>Send</button>
<div id=output></div>

<script>
//client side JavaScript will go here
</script>

</body>
</html>

我们的script标签的内容应如下所示:

<script>
(function () {
  var ws = new WebSocket("ws://localhost:8080"),
    output = document.getElementById('output'),
    send = document.getElementById('send');

  function logStr(eventStr, msg) {
    return '<div>' + eventStr + ': ' + msg + '</div>';
  }  

  send.addEventListener('click', function () 
      var msg = document.getElementById('msg').value;
      ws.send(msg);
      output.innerHTML += logStr('Sent', msg);
  });

  ws.onmessage = function (e) {
    output.innerHTML += logStr('Recieved', e.data);
  };

  ws.onclose = function (e) {
    output.innerHTML += logStr('Disconnected', e.code + '-' + e.type);
  };

  ws.onerror = function (e) {
    output.innerHTML += logStr('Error', e.data);
  };  

}());

</script>

如果我们使用node server.js初始化我们的服务器,然后将我们的(支持 WebSocket 的)浏览器指向http://localhost:8080,在文本框中输入Hello,然后单击发送。终端控制台将输出:

Recieved "Hello" from http://localhost:8080 

我们的浏览器将显示Hello已发送和**WebSockets!**已接收,如下面的屏幕截图所示:

如何做...

我们可以使用我们的文本框发送任何我们想要的字符串到我们的服务器,但只有Hello会得到响应。

它是如何工作的...

server.js中,当我们需要websocket模块的server方法时,我们将构造函数加载到WSServer中(这就是为什么我们将第一个字母大写)。我们使用new初始化WSServer,并传入我们的plainHttpServer,将其转换为一个启用了 WebSocket 的服务器。

HTTP 服务器仍然会提供普通的 HTTP 请求,但当它接收到 WebSocket 连接握手时,webSocketServer会开始建立与客户端的持久连接。

一旦client.html文件在浏览器中加载(由server.js中的 HTTP 服务器提供),并且内联脚本被执行,WebSocket 升级请求就会发送到服务器。

当服务器收到 WebSocket 升级请求时,webSocketServer会触发一个request事件,我们会使用我们的accept数组来仔细检查,然后决定是否响应。

我们的accept数组保存了一个白名单,允许与我们的 WebSocket 服务器进行接口交互的主机。只允许已知来源使用我们的 WebSocket 服务器,我们可以获得一些安全性。

webSocketServer request事件中,使用url.parse解析request.origin以检索origin URL 的主机名部分。如果在我们的accept白名单中找不到主机名,我们就调用request.reject

如果我们的源主机通过了,我们就从request.accept创建一个websocket变量。request.accept的第一个参数允许我们定义一个自定义子协议。我们可以使用多个具有不同子协议的request.accepts创建一个 WebSocket 数组,这些子协议代表不同的行为。在初始化客户端时,我们将传递一个包含该子协议的额外参数(例如,new WebSocket("ws://localhost:8080", 'myCustomProtocol'))。但是,我们传递null,因为对于我们的目的,不需要这样的功能。第二个参数允许我们通知request.accept我们希望允许的主机(还有第三个参数可用于传递 cookie)。

对于从客户端接收的每条消息,WebSocket都会发出一个message事件。这是我们将接收到的数据记录到console并检查传入消息是否为Hello的地方。如果是,我们使用WebSocket.send方法向客户端回复WebSockets!

最后,我们监听close事件,通知console连接已经被终止。

还有更多...

WebSockets 对于高效、低延迟的实时 Web 应用有很大潜力,但兼容性可能是一个挑战。让我们看看 WebSockets 的其他用途,以及让 WebSockets 在 Firefox 中工作的技巧。

支持旧版 Firefox 浏览器

Firefox 6 到 11 版本支持 WebSockets。但是,它们使用供应商前缀,因此我们的client.html将无法在这些 Firefox 版本上运行。

为了解决这个问题,我们只需在client.html文件中的脚本前面添加以下内容:

window.WebSocket = window.WebSocket || window.MozWebSocket;

如果WebSocket API 不存在,我们尝试MozWebSocket

创建基于 Node 的 WebSocket 客户端

websocket模块还允许我们创建一个 WebSocket 客户端。我们可能希望将 Node 与现有的 WebSocket 服务器进行接口,这主要是为浏览器客户端(如果不是,最好创建一个简单的 TCP 服务器。参见第八章,集成网络范式)。

因此,让我们在client.html中使用 Node 实现相同的功能。我们将在同一目录中创建一个新文件,命名为client.js

var WSClient = require('websocket').client;

new WSClient()

  .on('connect', function (connection) {
    var msg = 'Hello';

    connection.send(msg);
    console.log('Sent: ' + msg);

    connection.on('message', function (msg) {
      console.log("Received: " + msg.utf8Data);
    }).on('close', function (code, desc) {
      console.log('Disconnected: ' + code + ' - ' + desc);
    }).on('error', function (error) {
      console.log("Error: " + error.toString());
    });

  })
  .on('connectFailed', function (error) {
    console.log('Connect Error: ' + error.toString());
  })
  .connect('ws://localhost:8080/', null, 'http://localhost:8080');

为了简洁起见,我们只是简单地将我们的msg变量硬编码,尽管我们可以使用process.stdinprocess.argv来输入自定义消息。我们使用websocket模块的client方法初始化一个新的客户端。然后我们立即开始监听connectconnectFailed事件。

在两个on方法之后,我们链接connect方法。第一个参数是我们的 WebSocket 服务器,第二个是协议(记住,在我们的配方中,对于request.accept,我们有一个空协议),第三个定义了request.origin的值。

来源保护旨在防止仅从浏览器中起作用的攻击。因此,尽管我们可以在浏览器之外制造来源,但它并不构成同样的威胁。最大的威胁来自于对高流量站点进行 JavaScript 注入攻击,这可能导致大量未经授权的连接来自意外来源,从而导致拒绝服务。请参阅第七章, 实施安全、加密和认证

另请参阅

  • 在本章中讨论了使用 socket.io 进行无缝回退

  • 提供静态文件 第一章, 创建 Web 服务器

使用 socket.io 进行无缝回退

旧版浏览器不支持 WebSocket。因此,为了提供类似的体验,我们需要回退到各种浏览器/插件特定的技术,以模拟 WebSocket 功能,以最大程度地利用已弃用浏览器的能力。

这显然是一个雷区,需要数小时的浏览器测试,有时还需要对专有协议(例如 IE 的Active X htmlfile对象)有高度具体的了解。

socket.io为服务器和客户端提供了类似 WebSocket 的 API,以在各种浏览器中(包括旧版 IE 5.5+和移动端 iOS Safari、Android 浏览器)创建最佳实时体验。

除此之外,它还提供了便利功能,比如断开连接发现,允许自动重新连接,自定义事件,命名空间,通过网络调用回调(参见下一个配方通过 socket.io 传输回调),以及其他功能。

在这个配方中,我们将重新实现先前的任务,以实现高兼容性的 WebSocket 类型应用程序。

准备工作

我们将创建一个新的文件夹,其中包含新的client.htmlserver.js文件。我们还将安装socket.io模块:

npm install socket.io 

如何做...

websocket模块一样,socket.io可以附加到 HTTP 服务器(尽管对于socket.io来说并不是必需的)。让我们创建http服务器并加载client.html。在server.js中,我们写道:

var http = require('http');
var clientHtml = require('fs').readFileSync('client.html');

var plainHttpServer = http.createServer(function (request, response) {
    response.writeHead(200, {'Content-type' : 'text/html'});
    response.end(clientHtml);
  }).listen(8080);

现在是socket.io部分(仍在server.js中):

var io = require('socket.io').listen(plainHttpServer);

io.set('origins', ['localhost:8080', '127.0.0.1:8080']) ;

io.sockets.on('connection', function (socket) {
  socket.on('message', function (msg) {
    if (msg === 'Hello') {
      socket.send('socket.io!');
    }
  });
});

这是服务器端,现在让我们创建我们的client.html文件:

<html>
<head>
</head>
<body>
<input id=msg><button id=send>Send</button>
<div id=output></div>

<script src="img/socket.io.js"></script>
<script>
(function () {
  var socket = io.connect('ws://localhost:8080'),
    output = document.getElementById('output'),
    send = document.getElementById('send');

  function logStr(eventStr, msg) {
    return '<div>' + eventStr + ': ' + msg + '</div>';
  } 
  socket.on('connect', function () {
    send.addEventListener('click', function () {
      var msg = document.getElementById('msg').value;
      socket.send(msg);
      output.innerHTML += logStr('Sent', msg);
    });

    socket.on('message', function (msg) {
      output.innerHTML += logStr('Recieved', msg);
    });

  });

}());
</script>
</body>
</html>

最终产品基本上与上一个配方相同,只是它还可以在不兼容 WebSocket 的旧浏览器中无缝运行。我们输入Hello,按下发送按钮,服务器回复socket.io!

它是如何工作的...

我们不再将 HTTP 服务器传递给选项对象,而是简单地将其传递给listen方法。

我们使用io.set来定义我们的来源白名单,socket.io为我们完成了繁重的工作。

接下来,我们监听io.sockets上的connection事件,这为我们提供了一个客户端的socket(就像request.accept在上一个配方中生成了我们的WebSocket连接一样)。

connection中,我们监听socket上的message事件,检查传入的msg是否为Hello。如果是,我们会回复socket.io!

socket.io初始化时,它开始通过 HTTP 提供客户端代码。因此,在我们的client.html文件中,我们从/socket.io/socket.io.js加载socket.io.js客户端脚本。

客户端的socket.io.js提供了一个全局的io对象。通过调用它的connect方法并提供我们服务器的地址,我们可以获得相关的socket

我们向服务器发送我们的Hello msg,并通过#output div元素告诉服务器我们已经这样做了。

当服务器收到Hello时,它会回复socket.io!,这会触发客户端的message事件回调。

现在我们有了msg参数(与我们的msg Hello变量不同),其中包含来自服务器的消息,因此我们将其输出到我们的#output div元素。

还有更多...

socket.io建立在标准的 WebSocket API 之上。让我们探索一些socket.io的附加功能。

自定义事件

socket.io允许我们定义自己的事件,而不仅仅是message, connectdisconnect。我们以相同的方式监听自定义事件(使用on),但使用emit方法来初始化它们。

让我们从服务器向客户端emit一个自定义事件,然后通过向服务器发出另一个自定义事件来响应客户端。

我们可以使用与我们的配方相同的代码,我们将更改的唯一部分是server.jsconnection事件监听器回调的内容(我们将其复制为custom_events_server.js)和client.htmlconnect事件处理程序的内容(我们将其复制为custom_events_client.html)。

因此,对于我们的服务器代码:

//require http, load client.html, create plainHttpServer
//require and initialize socket.io, set origin rules

io.sockets.on('connection', function (socket) {
  socket.emit('hello', 'socket.io!');
  socket.on(''helloback, function (from) {
    console.log('Received a helloback from ' + from);
  });
});

我们的服务器发出一个hello事件,向新连接的客户端发送socket.io!,并等待来自客户端的helloback事件。

因此,我们相应地修改custom_events_client.html中的 JavaScript:

//html structure, #output div, script[src=/socket.io/socket.io.js] tag
socket.on('connect', function () {
  socket.on('hello', function (msg) {
    output.innerHTML += '<div>Hello ' + msg + '</div>';
    socket.emit('helloback', 'the client');
  });
});

当我们收到hello事件时,我们记录到我们的#output div(其中将显示Hello socket.io!),并向服务器emit一个helloback事件,将客户端作为预期的from参数传递。

命名空间

使用socket.io,我们可以描述命名空间或路由,然后在客户端通过io.connect访问它们的 URL:

io.connect('ws://localhost:8080/namespacehere');

命名空间允许我们在共享相同上下文的同时创建不同的范围。在socket.io中,命名空间用作为多个目的共享单个 WebSocket(或其他传输)连接的一种方式。请参阅en.wikipedia.org/wiki/Namespaceen.wikipedia.org/wiki/Namespace_(computer_science)

通过一系列io.connect调用,我们能够定义多个 WebSocket 路由。但是,这不会创建多个连接到我们的服务器。socket.io将它们多路复用(或组合)为一个连接,并在服务器内部管理命名空间逻辑,这样成本就会低得多。

我们将通过将代码从第三章“使用 AJAX 在浏览器和服务器之间传输数据”中讨论的数据序列化的配方升级为基于socket.io的应用程序来演示命名空间。

首先,让我们创建一个文件夹,称之为namespacing,并将原始的index.html, server.js, buildXml.jsprofiles.js文件复制到其中。Profiles.jsbuildXml.js是支持文件,所以我们可以不管它们。

我们可以简化我们的server.js文件,删除与routesmimes有关的所有内容,并将http.createServer回调减少到其最后的response.end行。我们不再需要 path 模块,因此我们将删除它,并最终将我们的服务器包装在socket.io listen方法中:

var http = require('http');
var fs = require('fs');
var profiles = require('./profiles');
var buildXml = require('./buildXml');
var index = fs.readFileSync('index.html');
var io = require('socket.io').listen(
    http.createServer(function (request, response) {
      response.end(index);
    }).listen(8080)
  );

为了声明我们的命名空间及其连接处理程序,我们使用of如下:

io.of('/json').on('connection', function (socket) {
  socket.on('profiles', function (cb) {
    cb(Object.keys(profiles));
  });

  socket.on('profile', function (profile) {
    socket.emit('profile', profiles[profile]);
  });
});

io.of('/xml').on('connection', function (socket) {
  socket.on('profile', function (profile) {
    socket.emit('profile', buildXml(profiles[profile]));
  });
});

在我们的index.html文件中,我们包括socket.io.js,并连接到命名空间:

<script src=socket.io/socket.io.js></script>
<script>
(function () {  // open anonymous function to protect global scope
  var formats = {
    json: io.connect('ws://localhost:8080/json'),
    xml: io.connect('ws://localhost:8080/xml')
  };
formats.json.on('connect', function () {
  $('#profiles').html('<option></option>');
   this.emit('profiles', function (profile_names) {
      $.each(profile_names, function (i, pname) {
       $('#profiles').append('<option>' + pname + '</option>');
      });
   });
});

$('#profiles, #formats').change(function () {
  var socket = formats[$('#formats').val()];  
  socket.emit('profile', $('#profiles').val());
});

formats.json.on('profile', function(profile) {
    $('#raw').val(JSON.stringify(profile));
    $('#output').html('');
    $.each(profile, function (k, v) {
          $('#output').append('<b>' + k + '</b> : ' + v + '<br>');
        });
});

formats.xml.on('profile', function(profile) {
      $('#raw').val(profile);
      $('#output').html('');
       $.each($(profile)[1].nextSibling.childNodes,
          function (k, v) {
            if (v && v.nodeType === 1) {
              $('#output').append('<b>' + v.localName + '</b> : '
		     + v.textContent + '<br>');
            }
          });  
}());

一旦连接,服务器将使用profile_names数组发出profiles事件,我们的客户端接收并处理它。我们的客户端向相关命名空间发出自定义profile事件,并且每个命名空间套接字都会监听来自服务器的profile事件,并根据其格式进行处理(由命名空间确定)。

命名空间允许我们分离关注点,而无需使用多个socket.io客户端(由于多路复用)。与 WebSocket 中的子协议概念类似,我们可以将某些行为限制在某些命名空间中,从而使我们的代码更易读,并减轻多方面实时 Web 应用程序中涉及的心理复杂性。

另请参阅

  • 在本章中讨论的创建 WebSocket 服务器

  • 在本章中讨论的通过 socket.io 传输回调

  • 在本章中讨论的创建实时小部件

通过 socket.io 传输回调

使用socket.io,我们可以通过 WebSockets(或相关的回退)执行回调函数。该函数在客户端定义,但在服务器端调用(反之亦然)。这可以是在客户端和服务器之间共享处理资源和功能的非常强大的方式。

在这个配方中,我们将创建一种让服务器调用客户端函数的方法,该函数可以对一个数字进行平方,并且让客户端调用一个将句子的 Base64 编码(en.wikipedia.org/wiki/Base64)发送回客户端的服务器端函数。

准备工作

我们只需要创建一个新文件夹,其中包括新的client.htmlserver.js文件。

如何做...

在我们的服务器上,与以前一样,我们加载我们的http模块和client.html文件,创建我们的 HTTP 服务器,附加socket.io,并设置origins策略。

var http = require('http');
var clientHtml = require('fs').readFileSync('client.html');

var plainHttpServer = http.createServer(function (request, response) {
    response.writeHead(200, {'Content-type' : 'text/html'});
    response.end(clientHtml);
  }).listen(8080);

var io = require('socket.io').listen(plainHttpServer);
io.set('origins', ['localhost:8080', '127.0.0.1:8080']);

接下来,在我们的connection事件处理程序中,我们监听来自客户端的自定义事件give me a number,并从服务器emit一个自定义事件give me a sentence

io.sockets.on('connection', function (socket) {

  socket.on('give me a number', function (cb) {
    cb(4);
  });

  socket.emit('give me a sentence', function (sentence) {
    socket.send(new Buffer(sentence).toString('base64'));
  });

});

在我们的client.html文件中:

<html>
<head> </head>
<body>
<div id=output></div>
<script src="img/socket.io.js"></script>
<script>
  var socket = io.connect('http://localhost:8080'),
    output = document.getElementById('output');

  function square(num) {
    output.innerHTML = "<div>" + num + " x " + num + " is "
						   + (num * num) + "</div>";
  }

  socket.on('connect', function () {  
    socket.emit('give me a number', square);

    socket.on('give me a sentence', function (cb) {
      cb('Ok, here is a sentence.');
    });

   socket.on('message', function (msg) {
      output.innerHTML += '<div>Recieved: ' + msg + '</div>';
    });
  });

</script>
</body> </html>

它是如何工作的...

连接后立即,服务器和客户端都会相互emit一个自定义的socket.io事件。

有关自定义socket.io事件,请参阅上一个配方与 socket.io 无缝回退的*还有更多..*部分。

对于客户端和服务器,当我们将函数作为emit, socket.io的第二个参数传递时,socket.io会在相应的事件监听器的回调中创建一个特殊的参数(cb)。在这种情况下,cb不是实际的函数(如果是,它将在调用它的上下文中简单运行),而是一个内部的socket.io函数,它将参数传递回到电线的另一侧的emit方法。然后emit将这些参数传递给它的回调函数,从而在本地上下文中执行函数。

我们知道这些函数在自己的上下文中运行。如果服务器端的give me a sentence回调在客户端上执行,它将失败,因为浏览器中没有Buffer对象。如果give me a number在服务器上运行,它将失败,因为 Node 中没有 DOM(文档对象模型)(也就是说,没有 HTML,因此没有document对象和document.getElementById方法)。

还有更多...

socket.io可以成为更高级专业化框架的良好基础。

使用 Nowjs 共享函数

Nowjs 将socket.io的回调功能推断为更简单的 API,允许我们通过客户端上的全局now对象和服务器上的everyone.now对象共享函数。让我们获取now模块:

npm install now 

设置 Nowjs 让人不寒而栗:

var http = require('http');
var clientHtml = require('fs').readFileSync('now_client.html');

var plainHttpServer = http.createServer(function (request, response) {
    response.writeHead(200, {'Content-type' : 'text/html'});
    response.end(clientHtml);
  }).listen(8080);

var everyone = require('now').initialize(plainHttpServer);
everyone.set('origins', ['localhost:8080', '127.0.0.1:8080']);

clientHtml文件加载now_client.html,而不是io,我们有everyone,而不是调用listen,我们调用initialize。到目前为止,其他一切都是一样的(当然,需要now而不是socket.io)。

我们将使用now重新实现我们的配方,以完成我们放置的服务器。

everyone.now.base64 = function(sentence, cb) {
  cb(new Buffer(sentence).toString('base64'));
}

everyone.on('connect', function () {
  this.now.square(4);
});

让我们将服务器保存为now_server.js,在now_client.html中编写以下代码:

<html>
<head></head>
<body>
<div id=output></div>
<script src="img/now.js"></script>
<script>  
var output = document.getElementById('output');
now.square = function (num) {
  output.innerHTML = "<div>" + num + " x " + num + " is " + (num * num) + "</div>";
}

now.ready(function () {  
  now.base64('base64 me server side, function (msg) {
    output.innerHTML += '<div>Recieved: ' + msg + '</div>';
  });
});
</script>
</body></html>

NowJS 使函数共享变得微不足道。在服务器端,我们只需在everyone.now上设置我们的base64方法,这样base64就可以在所有客户端上使用。

然后我们监听connect事件,当它发生时,我们调用this.now.square。在这个上下文中,this是我们客户端的 socket,所以this.now.square调用now_client.html中包含的now.square方法。

在我们的客户端中,我们不是加载socket.io.js,而是包含now.js,其路由在服务器初始化时公开。这为我们提供了全局的now对象,我们在其中设置我们的square函数方法。

一旦建立连接(使用now.ready检测),我们使用回调调用now.base64从服务器到客户端拉取数据。

另请参阅

  • 与 socket.io 无缝回退在本章中讨论

  • 在本章中讨论创建实时小部件

  • 通过 AJAX 进行浏览器-服务器传输在 第三章 中讨论,与数据序列化一起工作

创建一个实时小部件

socket.io 的配置选项和深思熟虑的方法使其成为一个非常灵活的库。让我们通过制作一个实时小部件来探索socket.io的灵活性,该小部件可以放置在任何网站上,并立即与远程socket.io服务器进行接口,以开始提供当前网站上所有用户的不断更新的总数。我们将其命名为“实时在线计数器(loc)”。

我们的小部件是为了方便用户使用,应该需要非常少的知识才能使其工作,因此我们希望有一个非常简单的接口。通过script标签加载我们的小部件,然后使用预制的init方法初始化小部件将是理想的(这样我们可以在初始化之前预定义属性,如果有必要的话)。

准备就绪

我们需要创建一个新的文件夹,其中包含一些新文件:widget_server.js,widget_client.js,server.jsindex.html

在开始之前,让我们还从npm获取socket.io-client模块。我们将使用它来构建我们的自定义socket.io客户端代码。

npm install socket.io-client 

如何做...

让我们创建index.html来定义我们想要的界面类型,如下所示:

<html>
<head>
<style>
#_loc {color:blue;} /* widget customization */
</style>
</head>
<body>
<h1> My Web Page </h1>
<script src=http://localhost:8081/loc/widget_server.js></script>
<script> locWidget.init(); </script>
</body></html>

我们希望向/loc/widget_server.js公开一个路由,其中包含我们的 loc 小部件。在幕后,我们的小部件将保存在widget_client.js中。

所以让我们做一下:

  window.locWidget = {
    style : 'position:absolute;bottom:0;right:0;font-size:3em',
    init : function () {
      var socket = io.connect('http://localhost:8081', {resource: 'loc'}),
        style = this.style;
      socket.on('connect', function () {
        var head = document.getElementsByTagName('head')[0],
          body = document.getElementsByTagName('body')[0],
          loc = document.getElementById('_lo_count');
        if (!loc) {
          head.innerHTML = '<style>#_loc {' + style + '}</style>'
            + head.innerHTML;

          body.innerHTML +=
            '<div id=_loc>Online: <span id=_lo_count></span></div>';

          loc = document.getElementById('_lo_count');
        }

        socket.on('total', function (total) {
          loc.innerHTML = total;
        });
      });
    }
  }

我们需要从多个域测试我们的小部件,因此我们将实现一个快速的 HTTP 服务器(server.js)来提供index.html,以便我们可以通过http://127.0.0.1:8080http://localhost:8080访问它,这样我们就可以获得多个域。

var http = require('http');
var fs = require('fs');
var clientHtml = fs.readFileSync('index.html');

http.createServer(function (request, response) {
    response.writeHead(200, {'Content-type' : 'text/html'});
    response.end(clientHtml);
}).listen(8080);

最后,我们的小部件服务器,在widget_server.js中编写:

var io = require('socket.io').listen(8081);
var sioclient = require('socket.io-client');
var widgetScript = require('fs').readFileSync('widget_client.js');
var url = require('url');

var totals = {};

io.configure(function () {
  io.set('resource', '/loc');
  io.enable('browser client gzip');
});

sioclient.builder(io.transports(), function (err, siojs) {
  if (!err) {
    io.static.add('/widget.js', function (path, callback) {
      callback(null, new Buffer(siojs + ';' + widgetScript));
    });
  }
});

io.sockets.on('connection', function (socket) {
  var origin = (socket.handshake.xdomain)
    ? url.parse(socket.handshake.headers.origin).hostname
    : 'local';

  totals[origin] = (totals[origin]) || 0;
  totals[origin] += 1;
  socket.join(origin);

  io.sockets.to(origin).emit('total', totals[origin]);
  socket.on('disconnect', function () {
    totals[origin] -= 1;
    io.sockets.to(origin).emit('total', totals[origin]);
  });
});

为了测试,我们需要两个终端,在一个终端中执行:

node widget_server.js 

在另一个终端中执行:

node server.js 

如果我们将浏览器指向http://localhost:8080,打开一个新的标签页或窗口并导航到http://localhost:8080。同样,我们将看到计数器增加一个。如果我们关闭任一窗口,它将减少一个。我们还可以导航到http://127.0.0.1:8080以模拟一个独立的来源。该地址上的计数器与http://localhost:8080上的计数器是独立的。

它是如何工作的...

widget_server.js是这个配方的核心。我们首先需要socket.io并调用listen方法。与之前的任务不同,我们不是将其作为httpServer实例传递,而是将其传递给端口号8081。如果我们将ws://localhost:8081视为许多客户端小部件连接到的远程服务器,这将有所帮助。

下一个要求是socket.io-client,我们将其加载到我们的sioclient变量中。

通过sioclient,我们可以访问sioclient.builder方法来生成一个socket.io.js文件。将其连接到widgetScript以有效地创建一个包含socket.io.jswidget_client.js的单个 JavaScript 文件。我们将 HTTP 路由命名为此文件widget.js。将socket.io.js文件连接到我们的widgetScript时,我们在两者之间放置一个分号,以确保脚本不会相互干扰。

我们向builder方法传递了两个参数,第一个是传输数组。这些是创建实时效果的各种方法(例如 WebSockets,AJAX(xhr)轮询)。数组中较早出现的传输方法更受青睐。数组是使用transports方法生成的。由于我们在配置期间没有设置任何传输,因此提供了默认传输数组。第二个参数是回调。在这里,我们可以通过siojs参数获取生成的socket.io.js文件。

我们的小部件纯粹是 JavaScript 的事务,可以插入到任何网站的任何 HTML 页面中。socket.io有一个内部 HTTP 服务器用于提供 JavaScript 客户端文件。我们使用io.static.add(一旦我们有了生成的socket.io.js)将一个新的路由推送到socket.io的内部 HTTP 服务器上,而不是创建一个 HTTP 服务器来提供我们的客户端小部件代码。io.static.add的第二个参数是一个回调函数,其中又有一个传递给它的函数名为callback

callbacksocket.io的一部分,它将内容添加到新定义的路由。第一个参数可以指向一个文字文件,但我们正在动态生成代码,所以我们传递null。对于第二个参数,我们将siojswidgetScript传递给Buffer,然后创建我们的路由。

通过更改resource属性以更改到socket.io的内部 HTTP 服务器路由的路由,io.set帮助我们为我们的小部件进行品牌推广。因此,我们的组合widget.js路由将不再出现在/socket.io/widget.js,而是将出现在/loc/widget.js

为了从客户端连接到我们配置的静态资源路由,我们必须在widget_client.js中向io.connect传递一个options对象。请注意斜杠前缀的缺失。斜杠前缀在服务器端是强制性的,但对于客户端来说是必须省略的。

现在舞台已经为实际的套接字操作做好了准备。我们通过在io.sockets上监听connection事件来等待连接。在事件处理程序内部,我们使用了一些尚未讨论的socket.io特性。

当客户端发起 HTTP 握手请求并且服务器肯定地响应时,WebSocket 就形成了。socket.handshake包含握手的属性。

socket.handshake.xdomain告诉我们握手是否是从同一服务器发起的。在检索socket.handshake.headers.originhostname之前,我们将检查跨服务器握手。

相同域名握手的来源要么是null,要么是undefined(取决于它是本地文件握手还是本地主机握手)。后者会导致url.parse出错,而前者则不理想。因此,对于相同域名握手,我们只需将我们的origin变量设置为local

我们提取(并简化)origin,因为它允许我们区分使用小部件的网站,从而实现特定于网站的计数。

为了计数,我们使用我们的totals对象,并为每个新的origin添加一个初始值为0的属性。在每次连接时,我们将1添加到totals[origin],并监听我们的socket以获取disconnect事件,从totals[origin]中减去1

如果这些值仅用于服务器使用,我们的解决方案将是完整的。但是,我们需要一种方法来向客户端通信总连接数,但仅限于他们所在的网站。

socket.io自版本 7 以来有一个方便的新功能,它允许我们使用socket.join方法将套接字分组到房间中。我们让每个套接字加入以其origin命名的房间,然后我们使用io.sockets.to(origin).emit方法指示socket.io只向属于原始sites房间的套接字emit

io.sockets connection事件和socket disconnect事件中,我们向相应的套接字emit我们特定的totals,以便更新每个客户端连接到用户所在网站的总连接数。

widget_client.js简单地创建一个名为#_locdiv,并使用它更新从widget_server.js接收到的任何新的totals

还有更多...

让我们看看如何使我们的应用程序更具可扩展性,以及 WebSocket 的另一个用途。

为可扩展性做准备

如果我们要为成千上万的网站提供服务,我们需要可扩展的内存存储,Redis 将是一个完美的选择。它在内存中运行,但也允许我们跨多个服务器进行扩展。

注意

我们需要安装 Redis,以及 redis 模块。更多信息请参见第四章,与数据库交互

我们将修改我们的 totals 变量,使其包含一个 Redis 客户端而不是一个 JavaScript 对象:

var totals = require('redis').createClient();

现在我们修改我们的 connection 事件处理程序如下:

io.sockets.on('connection', function (socket) {
  var origin = (socket.handshake.xdomain)
    ? url.parse(socket.handshake.headers.origin).hostname
    : 'local';
  socket.join(origin);

  totals.incr(origin, function (err, total) {
    io.sockets.to(origin).emit('total', total);  
  });

  socket.on('disconnect', function () {
    totals.decr(origin, function (err, total) {
      io.sockets.to(origin).emit('total', total); 
    });
  });
});

我们不再将 totals[origin] 加一,而是使用 Redis 的 INCR 命令来增加一个名为 origin 的 Redis 键。如果键不存在,Redis 会自动创建它。当客户端断开连接时,我们会执行相反的操作,并使用 DECR 调整 totals

WebSockets 作为开发工具

在开发网站时,我们经常在编辑器中更改一些小东西,上传我们的文件(如果需要),刷新浏览器,然后等待看到结果。如果浏览器在我们保存与网站相关的任何文件时自动刷新会怎么样?我们可以通过 fs.watch 和 WebSockets 实现这一点。fs.watch 监视一个目录,在文件夹中的任何文件发生更改时执行回调(但它不监视子文件夹)。

注意

fs.watch 是依赖于操作系统的。到目前为止,fs.watch 也一直存在着历史性的 bug(主要是在 Mac OS X 下)。因此,在进一步改进之前,fs.watch 更适合于开发环境而不是生产环境(您可以通过查看这里的已打开和已关闭的问题来监视 fs.watch 的运行情况:github.com/joyent/node/issues/search?q=fs.watch))。

我们的开发工具可以与任何框架一起使用,从 PHP 到静态文件。对于一个通用的服务器,让我们从第一章中的 提供静态文件 这个配方中测试我们的工具。我们将文件(包括 content 文件夹)从该配方复制到一个新文件夹中,我们可以将其命名为 watcher

对于我们工具的服务器端对应部分,我们将创建 watcher.js

var fs = require('fs');
var io = require('socket.io').listen(8081);
var sioclient = require('socket.io-client');

var watcher = [';(function () {',
               '  var socket = io.connect(\'ws://localhost:8081\');',
               '  socket.on(\'update\', function () {',
               '    location.reload()',
               '  });',
               '}())'].join('');

sioclient.builder(io.transports(), function (err, siojs) {
  if (!err) {
    io.static.add('/watcher.js', function (path, callback) {
      callback(null, new Buffer(siojs + watcher));
    });
  }
});

fs.watch('content', function (e, f) {
  if (f[0] !== '.') {
    io.sockets.emit('update');
  }
});

这段代码大部分都很熟悉。我们创建了一个 socket.io 服务器(在不同的端口上以避免冲突),生成了一个连接的 socket.io.js 加上客户端 watcher 代码文件,并将其添加到 socket.io 的静态资源中。由于这是我们自己开发使用的一个快速工具,我们的客户端代码被写成一个字符串赋值给 watcher 变量。

最后一段代码调用了 fs.watch 方法,其中回调接收事件名称(e)和文件名(f)。

我们检查文件名是否不是隐藏的点文件。在保存事件期间,一些文件系统或编辑器会更改目录中的隐藏文件,从而触发多个回调,发送多个消息,速度很快,这可能会对浏览器造成问题。

要使用它,我们只需将其放置在每个页面中作为脚本(可能使用服务器端模板)。然而,为了演示目的,我们只需将以下代码放入 content/index.html 中:

<script src=http://localhost:8081/socket.io/watcher.js></script>

一旦我们启动了 server.jswatcher.js,我们就可以将浏览器指向 http://localhost:8080,并从第一章中看到熟悉的激动人心的 Yay!。我们所做的任何更改和保存(无论是对 index.html, styles.css, script.js 进行更改,还是添加新文件)几乎会立即在浏览器中反映出来。我们可以做的第一个更改是摆脱 script.js 中的警报框,以便更流畅地看到更改。

另见

  • 创建 WebSocket 服务器 在本章中讨论

  • 使用 socket.io 实现无缝回退 在本章中讨论

  • 使用 Redis 存储和检索数据 在第四章中讨论