NodeJS-秘籍-全-

NodeJS 秘籍(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

异步事件驱动编程的原则非常适合当今的 Web,其中高效的实时应用程序和可伸缩性处于前沿。服务器端 JavaScript 自上世纪九十年代以来就一直存在,但 Node 做得很好。随着蓬勃发展的社区和互联网巨头的兴趣,它可能成为明天的 PHP。

《Node Cookbook》向您展示如何将您的 JavaScript 技能转移到服务器端编程。通过简单的示例和支持代码,《Node Cookbook》带您了解各种服务器端场景,通常通过演示最佳实践并向您展示如何避免安全错误,从而节省时间、精力和麻烦。

从制作自己的 Web 服务器开始,本书中的实用食谱旨在平稳地引导您制作完整的 Web 应用程序、命令行应用程序和 Node 模块。《Node Cookbook》带您了解与各种数据库后端的接口,如 MySQL、MongoDB 和 Redis,使用 Web 套接字,并与网络协议进行接口,如 SMTP。此外,还有关于处理数据流、安全实现、编写自己的 Node 模块以及将应用程序上线的不同方法的食谱。

本书涵盖内容

第一章,“制作 Web 服务器”,涵盖了提供动态和静态内容,将文件缓存在内存中,直接从磁盘上 HTTP 流式传输大文件以及保护您的 Web 服务器。

第二章,“探索 HTTP 对象”,解释了如何接收和处理 POST 请求和文件上传,使用 Node 作为 HTTP 客户端,并讨论了如何限制下载速度。

第三章,“数据序列化”,解释了如何将应用程序中的数据转换为 XML 和 JSON 格式,以便发送到浏览器或第三方 API。

第四章,“与数据库接口”,涵盖了如何使用 Redis、CouchDB、MongoDB、MySQL 或普通 CSV 文件实现持久数据存储。

第五章,“超越 AJAX:使用 WebSockets”,帮助您使用现代浏览器 WebSocket 技术制作实时网络应用程序,并优雅地降级到长轮询和其他方法,使用Socket.io

第六章,“使用 Express 加速开发”,解释了如何利用 Express 框架实现快速 Web 开发。它还涵盖了使用模板语言和 CSS 引擎,如 LESS 和 Stylus。

第七章,“实施安全、加密和身份验证”,解释了如何设置 SSL 安全的 Web 服务器,使用加密模块创建强密码哈希,并保护用户免受跨站点请求伪造攻击。

第八章,“集成网络范式”,讨论了发送电子邮件和创建自己的电子邮件服务器,发送短信,实施虚拟主机,以及使用原始 TCP 进行有趣和有趣的事情。

第九章,“编写自己的 Node 模块”,解释了如何创建测试套件,编写解决方案,重构,改进和扩展,然后部署自己的 Node 模块。

第十章,“上线”,讨论了如何将您的 Web 应用程序部署到实时服务器,确保您的应用程序通过崩溃恢复技术保持在线,实施持续部署工作流程,或者简单地使用作为服务提供商。

您需要什么

  • Windows、Mac OS X 或 Linux

  • Node 0.6.x 或 Node 0.8.x 可从www.nodejs.org免费获取

将继续适用于 Node 的 1.x.x 版本

这本书适合谁

如果您对 JavaScript 有一些了解,并且想要构建快速、高效、可扩展的客户端-服务器解决方案,那么Node Cookbook就是为您准备的。有经验的 Node 用户将提高他们的技能,即使您以前没有使用过 Node,这些实用的配方也将使您轻松上手。

约定

在这本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是一些这些样式的示例,以及它们的含义解释。

文本中的代码单词显示如下:“为了创建服务器,我们需要http模块。”

一块代码设置如下:

	var http = require('http');
	http.createServer(function (request, response) {
	response.writeHead(200, {'Content-Type': 'text/html'}); 
	response.end('Woohoo!');
	}).listen(8080);

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

	var http = require('http');
	var path = require('path'); 
	http.createServer(function (request, response) {
	var lookup=path.basename(decodeURI(request.url)); 

任何命令行输入或输出都是这样写的:

sudo npm -g install express 

新术语重要单词以粗体显示。您在屏幕上看到的单词,比如菜单或对话框中的单词,会在文本中出现,就像这样:“我们可以让一个假设的用户表明他们是否受到了一句引语的启发,比如一个喜欢按钮。”

注意

警告或重要说明会出现在这样的框中。

提示

提示和技巧会出现在这样。

第一章:创建 Web 服务器

在本章中,我们将涵盖:

  • 设置路由

  • 提供静态文件

  • 在内存中缓存内容以立即提供

  • 使用流优化性能

  • 防止文件系统黑客攻击

介绍

Node 的一个伟大特点是它的简单性。与 PHP 或 ASP 不同,它没有将 web 服务器和代码分开,也不需要定制大型配置文件来获得我们想要的行为。使用 Node,我们可以创建服务器,自定义它,并在代码级别提供内容。本章演示了如何使用 Node 创建 web 服务器,并通过它提供内容,同时实现安全性和性能增强以满足各种情况。

设置路由

为了提供 web 内容,我们需要使 URI 可用。本教程将指导我们创建一个公开路由的 HTTP 服务器。

准备工作

首先,让我们创建我们的服务器文件。如果我们的主要目的是公开服务器功能,通常的做法是将文件命名为server.js,然后将其放在一个新文件夹中。安装和使用hotnode也是一个好主意:

sudo npm -g install hotnode
hotnode server.js

当我们保存更改时,hotnode将方便地自动重新启动服务器。

如何做...

为了创建服务器,我们需要http模块,所以让我们加载它并使用http.createServer方法:

	var http = require('http');
	http.createServer(function (request, response) {
	response.writeHead(200, {'Content-Type': 'text/html'});
	response.end('Woohoo!');
	}).listen(8080);

提示

下载示例代码

您可以从您在www.PacktPub.com的帐户中下载您购买的所有 Packt 图书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.PacktPub.com/support并注册,以便直接通过电子邮件接收文件。

现在,如果我们保存我们的文件并在 web 浏览器上或使用 curl 访问localhost:8080,我们的浏览器(或 curl)将会呼喊:'Woohoo!'。然而,在localhost:8080/foo上也会发生同样的事情。实际上,任何路径都会产生相同的行为,因此让我们构建一些路由。我们可以使用path模块提取路径的basename(路径的最后一部分),并使用decodeURI从客户端反转任何 URI 编码:

	var http = require('http');
	var path = require('path'); 
	http.createServer(function (request, response) {
	var lookup = path.basename(decodeURI(request.url)); 

现在我们需要一种定义路由的方法。一种选择是使用对象数组:

	var pages = [
	  {route: '', output: 'Woohoo!'},
	  {route: 'about', output: 'A simple routing with Node example'},
	  {route: 'another page', output: function() {return 'Here\'s '+this.route;}},
	];

我们的pages数组应该放在http.createServer调用之上。

在我们的服务器内部,我们需要循环遍历我们的数组,并查看查找变量是否与我们的路由中的任何一个匹配。如果匹配,我们可以提供输出。我们还将实现一些404处理:

	http.createServer(function (request, response) {
	  var lookup=path.basename(decodeURI(request.url));
	  pages.forEach(function(page) {
	    if (page.route === lookup) {
	      response.writeHead(200, {'Content-Type': 'text/html'});
	      response.end(typeof page.output === 'function' 
	                   ? page.output() : page.output);
	    }
	  });
	  if (!response.finished) {
	     response.writeHead(404);
	     response.end('Page Not Found!');
	  }
	}).listen(8080);

工作原理...

我们提供给http.createServer的回调函数为我们提供了通过requestresponse对象与服务器进行交互所需的所有功能。我们使用request来获取请求的 URL,然后我们使用path获取它的basename。我们还使用decodeURI,如果没有它,我们的another page路由将失败,因为我们的代码将尝试将another%20page与我们的pages数组进行匹配并返回false

一旦我们有了basename,我们可以以任何我们想要的方式进行匹配。我们可以将其发送到数据库查询以检索内容,使用正则表达式进行部分匹配,或者将其与文件名匹配并加载其内容。

我们本可以使用switch语句来处理路由,但我们的pages数组有几个优点。它更容易阅读和扩展,并且可以无缝转换为 JSON。我们使用forEach循环遍历我们的pages数组。

Node 是建立在谷歌的 V8 引擎上的,它为我们提供了许多 ECMAScript 5 功能。这些功能不能在所有浏览器中使用,因为它们尚未普遍实现,但在 Node 中使用它们没有问题!forEach是 ES5 的实现,但 ES3 的方法是使用不太方便的for循环。

在循环遍历每个对象时,我们检查它的route属性。如果我们找到匹配,我们将写入200 OK状态和content-type头。然后我们用对象的输出属性结束响应。

response.end允许我们向其传递参数,在完成响应之前写入。在response.end中,我们使用了一个三元运算符(?:)来有条件地调用page.output作为函数或简单地将其作为字符串传递。请注意,another page路由包含一个函数而不是一个字符串。该函数通过this变量可以访问其父对象,并允许更灵活地组装我们想要提供的输出。如果在我们的forEach循环中没有匹配,response.end将永远不会被调用。因此,客户端将继续等待响应,直到超时。为了避免这种情况,我们检查response.finished属性,如果为 false,我们写入一个404头并结束响应。

response.finished取决于forEach回调,但它并不嵌套在回调内部。回调函数主要用于异步操作。因此,表面上看起来像是潜在的竞争条件,但forEach并不是异步操作。它会继续阻塞,直到所有循环完成。

还有更多...

有许多方法可以扩展和修改这个示例。还有一些非核心模块可供我们使用。

简单多级路由

到目前为止,我们的路由只处理单级路径。多级路径(例如,/about/node)将简单地返回404。我们可以修改我们的对象以反映子目录结构,删除path,并使用request.url而不是path.basename来作为我们的路由。

	var http=require('http');
	var pages = [
	  {route: '/', output: 'Woohoo!'},
	  {route: '/about/this', output: 'Multilevel routing with Node'},
	  {route: '/about/node', output: 'Evented I/O for V8 JavaScript.'},
	  {route: '/another page', output: function () {return 'Here\'s ' + this.route; }}
	];
	http.createServer(function (request, response) {
	  var lookup = decodeURI(request.url);

注意

在提供静态文件时,必须在获取给定文件之前清理request.url。请查看本章中讨论的防止文件系统黑客攻击部分。

多级路由可以进一步进行,允许我们构建然后遍历一个更复杂的对象。

	{route: 'about', childRoutes: [
	  {route: 'node', output: 'Evented I/O for V8 Javascript'},
	  {route: 'this', output: 'Complex Multilevel Example'}
	]}

在第三或第四级之后,查看这个对象将变得非常庞大。我们可以创建一个辅助函数来定义我们的路由,从而为我们拼接对象。或者,我们可以使用开源 Node 社区提供的出色的非核心路由模块之一。已经存在出色的解决方案,提供了帮助方法来处理可扩展多级路由的不断增加的复杂性(请参阅本章和第六章中讨论的路由模块使用 Express 加速开发)。

解析查询字符串

另外两个有用的核心模块是urlquerystringurl.parse方法允许两个参数。首先是 URL 字符串(在我们的情况下,这将是request.url),第二个是名为parseQueryString的布尔参数。如果设置为true,它会延迟加载querystring模块,省去了我们需要要求它来解析查询为对象。这使我们可以轻松地与 URL 的查询部分交互。

	var http = require('http');
	var url = require('url');
	var pages = [
		{id: '1', route: '', output: 'Woohoo!'},
		{id: '2', route: 'about', output: 'A simple routing with Node example'},
		{id: '3', route: 'another page', output: function () {return 'Here\'s ' + this.route; }},
	];
	http.createServer(function (request, response) {
		var id = url.parse(decodeURI(request.url), true).query.id;
	if (id) {
		pages.forEach(function (page) {
			if (page.id === id) {
				response.writeHead(200, {'Content-Type': 'text/html'});
				response.end(typeof page.output === 'function'
					? page.output() : page.output);
			}
		});
	}
	if (!response.finished) {
		response.writeHead(404);
		response.end('Page Not Found');
	}
}).listen(8080);

通过添加id属性,我们可以通过localhost:8080?id=2等方式访问我们的对象数据。

路由模块

有关 Node 的各种路由模块的最新列表,请访问www.github.com/joyent/node/wiki/modules#wiki-web-frameworks-routers。这些由社区制作的路由器适用于各种场景。在将其引入生产环境之前,重要的是要研究模块的活动和成熟度。在第六章中,使用 Express 加速开发,我们将更详细地讨论使用内置的 Express/Connect 路由器来实现更全面的路由解决方案。

另请参阅

  • 本章中讨论的提供静态文件防止文件系统黑客攻击

  • 在第六章中讨论的动态路由

提供静态文件

如果我们在磁盘上存储了要作为 Web 内容提供的信息,我们可以使用fs(文件系统)模块加载我们的内容并通过createServer回调传递。这是提供静态文件的基本概念起点。正如我们将在接下来的示例中学到的,还有更高效的解决方案。

准备工作

我们需要一些要提供的文件。让我们创建一个名为content的目录,其中包含以下三个文件:

index.html:

	<html>
	<head>
	<title>Yay Node!</title>
	<link rel=stylesheet href=styles.css type=text/css>
	<script src=script.js type=text/javascript></script>
	</head>
	<body>
	<span id=yay>Yay!</span>
	</body>
	</html>

script.js:

window.onload=function() {alert('Yay Node!');};

styles.css:

#yay {font-size:5em;background:blue;color:yellow;padding:0.5em}

操作步骤...

与之前的示例一样,我们将使用核心模块httppath。我们还需要访问文件系统,因此我们也需要fs模块。让我们创建我们的服务器:

	var http = require('http');
	var path = require('path');
	var fs = require('fs');
	http.createServer(function (request, response) {
	  var lookup = path.basename(decodeURI(request.url)) || 'index.html',
	    f = 'content/' + lookup;
	  path.exists(f, function (exists) {
	    console.log(exists ? lookup + " is there" : lookup + " doesn't exist");
	  });
	}).listen(8080);

如果我们还没有,我们可以初始化我们的server.js文件:

 hotnode server.js 

尝试加载localhost:8080/foo,控制台将显示foo 不存在,因为它确实不存在。localhost:8080/script.js将告诉我们script.js 存在,因为它确实存在。在保存文件之前,我们应该让客户端知道content-type,我们可以从文件扩展名中确定。因此,让我们使用对象快速创建一个映射:

	var mimeTypes = {
	  '.js' : 'text/javascript',
	  '.html': 'text/html',
	  '.css' : 'text/css'
	};

我们以后可以扩展我们的mimeTypes映射以支持更多类型。

注意

现代浏览器可能能够解释某些 MIME 类型(例如text/javascript)而无需服务器发送content-type头。然而,旧版浏览器或较少使用的 MIME 类型将依赖服务器发送正确的content-type头。

请记住,将mimeTypes放在服务器回调之外,因为我们不希望在每个客户端请求上初始化相同的对象。如果请求的文件存在,我们可以通过将path.extname传递给mimeTypes,然后将我们检索到的content-type传递给response.writeHead来将我们的文件扩展名转换为content-type。如果请求的文件不存在,我们将写出404并结束响应。

	//requires variables, mimeType object...
	http.createServer(function (request, response) {
		var lookup = path.basename(decodeURI(request.url)) || 'index.html',
			f = 'content/' + lookup;
		fs.exists(f, function (exists) {
			if (exists) {
				fs.readFile(f, function (err, data) {
					if (err) { response.writeHead(500);
						response.end('Server Error!'); return; }
					var headers = {'Content-type': mimeTypes[path. extname(lookup)]};
					response.writeHead(200, headers);
					response.end(data);
				});
				return;
			}
			response.writeHead(404); //no such file found!
			response.end();
		});
}).listen(8080);

目前,仍然没有内容发送到客户端。我们必须从我们的文件中获取这些内容,因此我们将响应处理包装在fs.readFile方法的回调中。

	//http.createServer, inside path.exists:
	if (exists) {
	  fs.readFile(f, function(err, data) {
	    var headers={'Content-type': mimeTypes[path.extname(lookup)]};
	    response.writeHead(200, headers);
	    response.end(data);
	  });
	 return;
	}

在我们完成之前,让我们对我们的fs.readFile回调应用一些错误处理,如下所示:

	//requires variables, mimeType object...
	//http.createServer,  path exists, inside if(exists):  
	fs.readFile(f, function(err, data) {
	    if (err) {response.writeHead(500); response.end('Server Error!');  return; }
	    var headers = {'Content-type': mimeTypes[path.extname(lookup)]};
	    response.writeHead(200, headers);
	    response.end(data);            
	  });
	 return;
	}

请注意,return保持在fs.readFile回调之外。我们从fs.exists回调中返回,以防止进一步的代码执行(例如,发送404)。在if语句中放置return类似于使用else分支。然而,在 Node 中,if return模式通常比使用if else更可取,因为它消除了另一组花括号。

现在我们可以导航到localhost:8080,这将提供我们的index.html文件。index.html文件调用我们的script.jsstyles.css文件,我们的服务器也以适当的 MIME 类型提供这些文件。结果可以在以下截图中看到:

操作步骤...

这个示例用来说明提供静态文件的基本原理。请记住,这不是一个高效的解决方案!在现实世界的情况下,我们不希望每次请求到达服务器时都进行 I/O 调用,尤其是对于较大的文件来说,这是非常昂贵的。在接下来的示例中,我们将学习更好的方法来提供静态文件。

工作原理...

我们的脚本创建了一个服务器并声明了一个名为lookup的变量。我们使用双管道(||)运算符为lookup赋值。这定义了一个默认路由,如果path.basename为空的话。然后我们将lookup传递给一个新变量,我们将其命名为f,以便将我们的content目录前置到预期的文件名。接下来,我们通过fs.exists方法运行f并检查回调中的exist参数,以查看文件是否存在。如果文件存在,我们使用fs.readFile进行异步读取。如果访问文件出现问题,我们将写入500服务器错误,结束响应,并从fs.readFile回调中返回。我们可以通过从index.html中删除读取权限来测试错误处理功能。

chmod -r index.html 

这样做将导致服务器抛出500服务器错误状态码。要再次设置正确,请运行以下命令:

chmod +r index.html 

只要我们可以访问文件,就可以使用我们方便的mimeTypes映射对象来获取content-type,编写标头,使用从文件加载的数据结束响应,最后从函数返回。如果请求的文件不存在,我们将绕过所有这些逻辑,写入404,并结束响应。

还有更多...

需要注意的一点是...

网站图标陷阱

当使用浏览器测试我们的服务器时,有时会观察到意外的服务器请求。这是浏览器请求服务器可以提供的默认favicon.ico图标文件。除了看到额外的请求之外,这通常不是问题。如果网站图标请求开始干扰,我们可以这样处理:

	if (request.url === '/favicon.ico') {
	  response.end();
	  return;
	}

如果我们想对客户端更有礼貌,还可以在发出response.end之前使用response.writeHead(404)通知它404

另请参阅

  • 在本章中讨论的将内容缓存在内存中以进行即时传递

  • 在本章中讨论的使用流来优化性能

  • 在本章中讨论的防止文件系统黑客攻击

将内容缓存在内存中以进行即时传递

直接在每个客户端请求上访问存储并不理想。在本例中,我们将探讨如何通过仅在第一次请求时访问磁盘、为第一次请求缓存文件数据以及从进程内存中提供所有后续请求来增强服务器效率。

准备工作

我们将改进上一个任务中的代码,因此我们将使用server.js,以及content目录中的index.html,styles.cssscript.js

操作步骤...

让我们首先看一下上一个配方“提供静态文件”的脚本

	var http = require('http');
	var path = require('path');
	var fs = require('fs');  

	var mimeTypes = {
	  '.js' : 'text/javascript',
	  '.html': 'text/html',
	  '.css' : 'text/css'
	} ;

	http.createServer(function (request, response) {
	  var lookup = path.basename(decodeURI(request.url)) || 'index.html';
	  var f = 'content/'+lookup;
	  path.exists(f, function (exists) {
	    if (exists) {
	      fs.readFile(f, function(err,data) {
	      if (err) {response.writeHead(500); response.end('Server Error!'); return; }
	      var headers = {'Content-type': mimeTypes[path.extname(lookup)]};
	        response.writeHead(200, headers);
	        response.end(data);            
	      });
	      return;
	    }
	      response.writeHead(404); //no such file found!
	      response.end('Page Not Found!');
	  });

我们需要修改这段代码,只读取文件一次,将其内容加载到内存中,然后从内存中响应所有对该文件的请求。为了保持简单和可维护性,我们将缓存处理和内容传递提取到一个单独的函数中。因此,在http.createServer上方,并在mimeTypes下方,我们将添加以下内容:

	var cache = {};
	function cacheAndDeliver(f, cb) {
	  if (!cache[f]) {
	    fs.readFile(f, function(err, data) {
	      if (!err) {
	        cache[f] = {content: data} ;
	      }     
	      cb(err, data);
	    });
	    return;
	  }
	  console.log('loading ' + f + ' from cache');
	  cb(null, cache[f].content);
	}
	//http.createServer …..

添加了一个新的cache对象,用于将文件存储在内存中,以及一个名为cacheAndDeliver的新函数。我们的函数接受与fs.readFile相同的参数,因此我们可以在http.createServer回调中替换fs.readFile,同时保持其余代码不变:

	//...inside http.createServer:
	path.exists(f, function (exists) {
	    if (exists) {
	      cacheAndDeliver(f, function(err, data) {
	        if (err) {response.writeHead(500); response.end('Server Error!'); return; }
	        var headers = {'Content-type': mimeTypes[path.extname(f)]};
	        response.writeHead(200, headers);
	        response.end(data);      
	      });
	  return;
	    }
	//rest of path exists code (404 handling)...

当我们执行server.js文件并连续两次访问localhost:8080时,第二个请求会导致控制台输出以下内容:

 loading content/index.html from cache
	loading content/styles.css from cache
	loading content/script.js from cache

工作原理...

我们定义了一个名为cacheAndDeliver的函数,类似于fs.readFile,它接受文件名和回调作为参数。这很棒,因为我们可以将完全相同的fs.readFile回调传递给cacheAndDeliver,在不向http.createServer回调内部添加任何额外可视复杂性的情况下,为服务器添加缓存逻辑。目前来看,将我们的缓存逻辑抽象成外部函数的价值是有争议的,但是随着我们不断增强服务器的缓存能力,这种抽象变得越来越可行和有用。我们的cacheAndDeliver函数检查所请求的内容是否已经缓存,如果没有,我们调用fs.readFile并从磁盘加载数据。一旦我们有了这些数据,我们可能会保留它,因此它被放入由其文件路径引用的cache对象中(f变量)。下次有人请求文件时,cacheAndDeliver将看到我们在cache对象中存储了文件,并将发出包含缓存数据的替代回调。请注意,我们使用另一个新对象填充了cache[f]属性,其中包含一个content属性。这样做可以更容易地扩展将来的缓存功能,因为我们只需要将额外的属性放入我们的cache[f]对象中,并提供与这些属性相对应的接口逻辑。

还有更多...

如果我们修改正在提供的文件,任何更改都不会反映在我们重新启动服务器之前。我们可以解决这个问题。

反映内容更改

要检测请求的文件自上次缓存以来是否发生了更改,我们必须知道文件何时被缓存以及上次修改时间。为了记录文件上次缓存的时间,让我们扩展cache[f]对象:

	cache[f] = {content: data,
	                      timestamp: Date.now() //store a Unix time stamp
	                     };

现在我们需要找出文件上次更新的时间。fs.stat方法在其回调的第二个参数中返回一个对象。该对象包含与命令行 GNU coreutils stat.fs.stat提供的相同有用信息:上次访问时间(atime)、上次修改时间(mtime)和上次更改时间(ctime)。mtimectime之间的区别在于ctime将反映对文件的任何更改,而mtime只会反映对文件内容的更改。因此,如果我们更改了文件的权限,ctime会更新,但mtime会保持不变。我们希望在发生权限更改时注意到,因此让我们使用ctime属性:

	//requires and mimeType object....
	var cache = {};
	function cacheAndDeliver(f, cb) {
		fs.stat(f, function (err, stats) {
			var lastChanged = Date.parse(stats.ctime),
				isUpdated = (cache[f]) && lastChanged > cache[f].timestamp;
			if (!cache[f] || isUpdated) {
				fs.readFile(f, function (err, data) {
					console.log('loading ' + f + ' from file');
					//rest of cacheAndDeliver
		}); //end of fs.stat
	} // end of cacheAndDeliver

cacheAndDeliver的内容已经包装在fs.stat回调中。添加了两个变量,并修改了if(!cache[f])语句。我们解析了第二个参数statsctime属性,使用Date.parse将其转换为自 1970 年 1 月 1 日午夜以来的毫秒数(Unix 纪元),并将其分配给我们的lastChanged变量。然后我们检查所请求文件的上次更改时间是否大于我们缓存文件的时间(假设文件确实已缓存),并将结果分配给我们的isUpdated变量。之后,只需通过||(或)运算符将isUpdated布尔值添加到条件if(!cache[f])语句中。如果文件比我们缓存的版本更新(或者尚未缓存),我们将文件从磁盘加载到缓存对象中。

另请参阅

  • 在本章中讨论了通过流优化性能

  • 第三章 中讨论了通过 AJAX 进行浏览器-服务器传输数据序列化处理

通过流优化性能

缓存内容确实改进了每次请求时从磁盘读取文件。但是,使用fs.readFile时,我们是在将整个文件读入内存后再将其发送到response中。为了提高性能,我们可以从磁盘流式传输文件,并将其直接传输到response对象,一次发送一小部分数据到网络套接字。

准备工作

我们正在构建上一个示例中的代码,所以让我们准备好server.js, index.html, styles.cssscript.js

如何做...

我们将使用fs.createReadStream来初始化一个流,可以将其传输到response对象。在这种情况下,在我们的cacheAndDeliver函数中实现fs.createReadStream并不理想,因为fs.createReadStream的事件监听器将需要与requestresponse对象进行接口。为了简单起见,这些最好在http.createServer回调中处理。为了简洁起见,我们将放弃我们的cacheAndDeliver函数,并在服务器回调中实现基本的缓存:

	//requires, mime types, createServer, lookup and f vars...
	path.exists(f, function (exists) {
	    if (exists) {  
	      var headers = {'Content-type': mimeTypes[path.extname(f)]};
	      if (cache[f]) {
	        response.writeHead(200, headers);              
	        response.end(cache[f].content);  
	        return;
	      } //...rest of server code...

稍后,当我们与readStream对象进行接口时,我们将填充cache[f].content。以下是我们如何使用fs.createReadStream:

var s = fs.createReadStream(f);

这将返回一个readStream对象,该对象流式传输由f变量指向的文件。readStream发出我们需要监听的事件。我们可以使用addEventListener进行监听,也可以使用简写的on:

var s = fs.createReadStream(f).on('open', function () {
//do stuff when the readStream opens
});

由于createReadStream返回readStream对象,我们可以使用点符号的方法链接将我们的事件监听器直接附加到它上面。每个流只会打开一次,我们不需要继续监听它。因此,我们可以使用once方法而不是on方法,在第一次事件发生后自动停止监听:

var s = fs.createReadStream(f).once('open', function () {
//do stuff when the readStream opens
});

在我们填写open事件回调之前,让我们按照以下方式实现错误处理:

	var s = fs.createReadStream(f).once('open', function () {
	//do stuff when the readStream opens
	}).once('error', function (e) {
	    console.log(e);
	    response.writeHead(500);
	    response.end('Server Error!');
	});

整个努力的关键是stream.pipe方法。这使我们能够直接从磁盘获取文件并将其直接通过我们的response对象流式传输到网络套接字。

	var s = fs.createReadStream(f).once('open', function () {
	    response.writeHead(200, headers);      
	    this.pipe(response);
	}).once('error', function (e) {
	    console.log(e);
	    response.writeHead(500);
	    response.end('Server Error!');
	});

结束响应怎么办?方便的是,stream.pipe会检测流何时结束,并为我们调用response.end。出于缓存目的,我们需要监听另一个事件。在我们的fs.exists回调中,在createReadStream代码块下面,我们编写以下代码:

	 fs.stat(f, function(err, stats) {
		        var bufferOffset = 0;
	      	  cache[f] = {content: new Buffer(stats.size)};
		       s.on('data', function (chunk) {
	             chunk.copy(cache[f].content, bufferOffset);
	             bufferOffset += chunk.length;
	        });
	      }); 

我们使用data事件来捕获正在流式传输的缓冲区,并将其复制到我们提供给cache[f].content的缓冲区中,使用fs.stat来获取文件的缓冲区大小。

它是如何工作的...

客户端不需要等待服务器从磁盘加载完整的文件然后再发送给客户端,我们使用流来以小的、有序的片段加载文件,并立即发送给客户端。对于较大的文件,这是特别有用的,因为在文件被请求和客户端开始接收文件之间几乎没有延迟。

我们通过使用fs.createReadStream来开始从磁盘流式传输我们的文件。fs.createReadStream创建了readStream,它继承自EventEmitter类。

EventEmitter类实现了 Node 标语中的evented部分:Evented I/O for V8 JavaScript。因此,我们将使用监听器而不是回调来控制流逻辑的流程。

然后我们使用once方法添加了一个open事件监听器,因为我们希望一旦触发就停止监听open。我们通过编写标头并使用stream.pipe方法将传入的数据直接传输到客户端来响应open事件。

stream.pipe处理数据流。如果客户端在处理过程中变得不堪重负,它会向服务器发送一个信号,服务器应该通过暂停流来予以尊重。在底层,stream.pipe使用stream.pausestream.resume来管理这种相互作用。

当响应被传输到客户端时,内容缓存同时被填充。为了实现这一点,我们必须为cache[f].content属性创建一个Buffer类的实例。Buffer必须提供一个大小(或数组或字符串),在我们的情况下是文件的大小。为了获取大小,我们使用了异步的fs.stat并在回调中捕获了size属性。data事件将Buffer作为其唯一的回调参数返回。

流的默认bufferSize为 64 KB。任何大小小于bufferSize的文件将只触发一个data事件,因为整个文件将适合第一个数据块中。但是,对于大于bufferSize的文件,我们必须一次填充我们的cache[f].content属性的一部分。

注意

更改默认的readStream缓冲区大小:

我们可以通过传递一个options对象并在fs.createReadStream的第二个参数中添加一个bufferSize属性来更改readStream的缓冲区大小。

例如,要将缓冲区加倍,可以使用fs.createReadStream(f,{bufferSize: 128 * 1024})

我们不能简单地将每个chunkcache[f].content连接起来,因为这样会将二进制数据强制转换为字符串格式,尽管不再是二进制格式,但以后会被解释为二进制格式。相反,我们必须将所有小的二进制缓冲区chunks复制到我们的二进制cache[f].content缓冲区中。

我们创建了一个bufferOffset变量来帮助我们。每次我们向我们的cache[f].content缓冲区添加另一个chunk时,我们通过将chunk缓冲区的长度添加到它来更新我们的新bufferOffset。当我们在chunk缓冲区上调用Buffer.copy方法时,我们将bufferOffset作为第二个参数传递,以便我们的cache[f].content缓冲区被正确填充。

此外,使用Buffer类进行操作可以提高性能,因为它可以绕过 V8 的垃圾回收方法。这些方法往往会使大量数据碎片化,从而减慢 Node 处理它们的能力。

还有更多...

虽然流解决了等待文件加载到内存中然后传递它们的问题,但我们仍然通过我们的cache对象将文件加载到内存中。对于较大的文件或大量文件,这可能会产生潜在的影响。

防止进程内存溢出

进程内存有限。默认情况下,V8 的内存在 64 位系统上设置为 1400 MB,在 32 位系统上设置为 700 MB。可以通过在 Node 中运行--max-old-space-size=N来改变这个值,其中N是以兆字节为单位的数量(实际可以设置的最大值取决于操作系统和可用的物理 RAM 数量)。如果我们绝对需要占用大量内存,我们可以在大型云平台上运行服务器,分割逻辑,并使用child_process类启动新的 node 实例。

在这种情况下,高内存使用并不一定是必需的,我们可以优化我们的代码,显著减少内存溢出的可能性。对于缓存较大的文件,好处较少。与总下载时间相比,轻微的速度提高是微不足道的,而缓存它们的成本相对于我们可用的进程内存来说是相当显著的。我们还可以通过在缓存对象上实现过期时间来提高缓存效率,然后用它来清理缓存,从而删除低需求的文件,并优先处理高需求的文件以实现更快的传递。让我们稍微重新排列一下我们的cache对象:

	var cache = {
	  store: {},
	  maxSize : 26214400, //(bytes) 25mb
	}

为了更清晰的思维模型,我们要区分缓存作为一个功能实体和缓存作为存储(这是更广泛的缓存实体的一部分)。我们的第一个目标是只缓存一定大小的文件。我们为此定义了cache.maxSize。现在我们只需要在fs.stat回调中插入一个if条件:

	 fs.stat(f, function (err, stats) {
	        if (stats.size < cache.maxSize) {
	          var bufferOffset = 0;
	          cache.store[f] = {content: new Buffer(stats.size),
	                                     timestamp: Date.now() };
	          s.on('data', function (data) {
	            data.copy(cache.store[f].content, bufferOffset);
	            bufferOffset += data.length;
	          });
	        }  
	      });

请注意,我们还在我们的cache.store[f]中悄悄地添加了一个新的timestamp属性。这是为了清理缓存,这是我们的第二个目标。让我们扩展cache:

	var cache = {
	  store: {},
	  maxSize: 26214400, //(bytes) 25mb
	  maxAge: 5400 * 1000, //(ms) 1 and a half hours
	  clean: function(now) {
	      var that = this;
	      Object.keys(this.store).forEach(function (file) {
	        if (now > that.store[file].timestamp + that.maxAge) {
	          delete that.store[file];      
	        }
	      });
	  }
	};

因此,除了maxSize,我们创建了一个maxAge属性并添加了一个clean方法。我们在服务器底部调用cache.clean,如下所示:

	//all of our code prior
	  cache.clean(Date.now());
	}).listen(8080); //end of the http.createServer

cache.clean循环遍历cache.store,并检查它是否已超过指定的生命周期。如果是,我们就从store中移除它。我们将再添加一个改进,然后就完成了。cache.clean在每个请求上都会被调用。这意味着cache.store将在每次服务器命中时被循环遍历,这既不必要也不高效。如果我们每隔两个小时或者更长时间清理一次缓存,效果会更好。我们将向cache添加两个属性。第一个是cleanAfter,用于指定清理缓存的时间间隔。第二个是cleanedAt,用于确定自上次清理缓存以来的时间。

	var cache = {
	  store: {},
	  maxSize: 26214400, //(bytes) 25mb
	  maxAge : 5400 * 1000, //(ms) 1 and a half hours
	   cleanAfter: 7200 * 1000,//(ms) two hours
	  cleanedAt: 0, //to be set dynamically
	  clean: function (now) {
	     if (now - this.cleanAfter > this.cleanedAt) {
	      this.cleanedAt = now;
	      that = this;
	        Object.keys(this.store).forEach(function (file) {
	          if (now > that.store[file].timestamp + that.maxAge) {
	            delete that.store[file];      
	          }
	        });
	    }
	  }
	};

我们将我们的cache.clean方法包裹在一个if语句中,只有当它距离上次清理已经超过两个小时(或者cleanAfter设置为其他值)时,才允许对cache.store进行循环。

另请参阅

  • 处理文件上传在第二章中讨论过,探索 HTTP 对象

  • 防止文件系统黑客攻击在本章中讨论。

防止文件系统黑客攻击

要使 Node 应用程序不安全,必须有攻击者可以与之交互以进行利用的东西。由于 Node 的极简主义方法,大部分责任都落在程序员身上,以确保他们的实现不会暴露安全漏洞。这个配方将帮助识别在处理文件系统时可能出现的一些安全风险反模式。

准备工作

我们将使用与以前的配方中相同的content目录,但我们将从头开始创建一个新的insecure_server.js文件(名字中有提示!)来演示错误的技术。

如何做...

我们以前的静态文件配方倾向于使用path.basename来获取路由,但这会使所有请求都处于平级。如果我们访问localhost:8080/foo/bar/styles.css,我们的代码会将styles.css作为basename,并将content/styles.css交付给我们。让我们在content文件夹中创建一个子目录,称之为subcontent,并将我们的script.jsstyles.css文件移动到其中。我们需要修改index.html中的脚本和链接标签:

	<link rel=stylesheet type=text/css href=subcontent/styles.css>
	<script src=subcontent/script.js type=text/javascript></script>

我们可以使用url模块来获取整个pathname。所以让我们在我们的新的insecure_server.js文件中包含url模块,创建我们的 HTTP 服务器,并使用pathname来获取整个请求路径:

	var http = require('http'); var path = require('path'); 
	var url = require('url');
	var fs = require('fs'); 
	http.createServer(function (request, response) {
	  var lookup = url.parse(decodeURI(request.url)).pathname;
	  lookup = (lookup === "/") ? '/index.html' : lookup;
	  var f = 'content' + lookup;
	  console.log(f);
	  fs.readFile(f, function (err, data) {
	    response.end(data);
	  });
	}).listen(8080);

如果我们导航到localhost:8080,一切都很顺利。我们已经多级了,万岁。出于演示目的,一些东西已经从以前的配方中剥离出来(比如fs.exists),但即使有了它们,以下代码也会呈现相同的安全隐患:

curl localhost:8080/../insecure_server.js 

现在我们有了我们服务器的代码。攻击者也可以通过几次猜测相对路径来访问/etc/passwd

curl localhost:8080/../../../../../../../etc/passwd 

为了测试这些攻击,我们必须使用 curl 或其他等效工具,因为现代浏览器会过滤这些请求。作为解决方案,如果我们为要提供的每个文件添加一个唯一的后缀,并且要求服务器在提供文件之前必须存在这个后缀,会怎么样?这样,攻击者就可以请求/etc/passwd或我们的insecure_server.js,因为它们没有唯一的后缀。为了尝试这个方法,让我们复制content文件夹,并将其命名为content-pseudosafe,并将我们的文件重命名为index.html-servescript.js-servestyles.css-serve。让我们创建一个新的服务器文件,并将其命名为pseudosafe_server.js。现在我们只需要让-serve后缀成为必需的:

	//requires section...
	http.createServer(function (request, response) {
	  var lookup = url.parse(decodeURI(request.url)).pathname;
	  lookup = (lookup === "/") ? '/index.html-serve' : lookup + '-serve';
	  var f = 'content-pseudosafe' + lookup;

出于反馈目的,我们还将使用fs.exists来处理一些404

	//requires, create server etc  
	path.exists(f, function (exists) {
	    if (!exists) {  
	      response.writeHead(404);
	      response.end('Page Not Found!');
	      return;
	    }
	//read file etc

让我们启动我们的pseudosafe_server.js文件,并尝试相同的攻击:

curl -i localhost:8080/../insecure_server.js 

我们使用了-i参数,以便 curl 输出头部。结果是什么?一个404,因为它实际上正在寻找的文件是../insecure_server.js-serve,这个文件不存在。这种方法有什么问题?嗯,它很不方便,容易出错。然而,更重要的是,攻击者仍然可以绕过它!

curl localhost:8080/../insecure_server.js%00/index.html 

然后!这是我们的服务器代码。我们问题的解决方案是path.normalize,它可以在fs.readFile之前清理我们的pathname

	http.createServer(function (request, response) {
	  var lookup = url.parse(decodeURI(request.url)).pathname;
	  lookup = path.normalize(lookup);
	  lookup = (lookup === "/") ? '/index.html' : lookup;
	  var f = 'content' + lookup

之前的示例没有使用path.normalize,但它们仍然相对安全。path.basename给出了路径的最后部分,因此任何前导的相对目录指针(../)都被丢弃,从而防止了目录遍历的利用。

它是如何工作的...

在这里,我们有两种文件系统利用技术:相对目录遍历毒空字节攻击。这些攻击可以采取不同的形式,比如在 POST 请求中或来自外部文件。它们可能会产生不同的影响。例如,如果我们正在写入文件而不是读取它们,攻击者可能会开始对我们的服务器进行更改。在所有情况下,安全性的关键是验证和清理来自用户的任何数据。在insecure_server.js中,我们将用户请求传递给我们的fs.readFile方法。这是愚蠢的,因为它允许攻击者利用我们操作系统中相对路径功能,通过使用../来访问本应禁止访问的区域。通过添加-serve后缀,我们没有解决问题。我们只是贴了一张创可贴,这可以被毒空字节绕过。这种攻击的关键是%00,这是空字节的 URL 十六进制代码。在这种情况下,空字节使 Node 对../insecure_server.js部分变得盲目,但当同样的空字节通过我们的fs.readFile方法发送时,它必须与内核进行接口。然而,内核对index.html部分变得盲目。所以我们的代码看到的是index.html,但读取操作看到的是../insecure_server.js。这就是空字节毒害。为了保护自己,我们可以使用regex语句来删除路径中的../部分。我们还可以检查空字节并输出400 Bad Request语句。但我们不需要,因为path.normalize已经为我们过滤了空字节和相对部分。

还有更多...

让我们进一步探讨在提供静态文件时如何保护我们的服务器。

白名单

如果安全性是一个极端重要的优先事项,我们可以采用严格的白名单方法。在这种方法中,我们将为我们愿意交付的每个文件创建一个手动路由。不在我们的白名单上的任何内容都将返回404。我们可以在http.createServer上方放置一个whitelist数组,如下面的代码所示:

	var whitelist = [
	  '/index.html',
	  '/subcontent/styles.css',
	  '/subcontent/script.js'
	];

在我们的http.createServer回调中,我们将放置一个if语句来检查请求的路径是否在whitelist数组中:

	if (whitelist.indexOf(lookup) === -1) {
	  response.writeHead(404);
	  response.end('Page Not Found!');
	  return;
	}

就是这样。我们可以通过在我们的content目录中放置一个文件non-whitelisted.html来测试这个。

curl -i localhost:8080/non-whitelisted.html 

上述命令将返回404,因为non-whitelisted.html不在白名单上。

Node-static

github.com/joyent/node/wiki/modules#wiki-web-frameworks-static列出了可用于不同目的的静态文件服务器模块的列表。在依赖它来提供您的内容之前,确保项目是成熟和活跃的是一个好主意。Node-static 是一个开发完善的模块,内置缓存。它还符合 RFC2616 HTTP 标准规范。这定义了如何通过 HTTP 传递文件。Node-static 实现了本章讨论的所有基本要点,以及更多。这段代码略有改动,来自 node-static 的 Github 页面github.com/cloudhead/node-static:

	var static = require('node-static');
	var fileServer = new static.Server('./content');
	require('http').createServer(function (request, response) {
	  request.addListener('end', function () {
	    fileServer.serve(request, response);
	  });
	}).listen(8080);

上述代码将与node-static模块进行接口,以处理服务器端和客户端缓存,使用流来传递内容,并过滤相对请求和空字节,等等。

另请参阅

  • 防止跨站点请求伪造在第七章中讨论,实施安全、加密和身份验证

  • 设置 HTTPS Web 服务器 在第七章 实施安全、加密和认证

  • 部署到服务器环境 在第十章 上线

  • 密码哈希加密 在第七章 实施安全、加密和认证

第二章:探索 HTTP 对象

在本章中,我们将涵盖:

  • 处理 POST 数据

  • 处理文件上传

  • 使用 Node 作为 HTTP 客户端

  • 实现下载节流

介绍

在上一章中,我们使用http模块创建了一个 Web 服务器。现在我们将探讨一些与简单地从服务器向客户端推送内容之外的一些相关用例。前三个示例将探讨如何通过客户端发起的 HTTP POST(和 PUT)请求接收数据,最后一个示例将演示如何对出站数据流进行节流。

处理 POST 数据

如果我们想要接收 POST 数据,我们必须指示服务器如何接受和处理 POST 请求。在 PHP 中,我们可以无缝访问我们的 POST 值$_POST['fieldname'],因为它会阻塞,直到数组值被填充。相比之下,Node 提供了与 HTTP 数据流的低级交互,允许我们与传入的消息体接口,完全由开发人员将该流转换为可用数据。

准备工作

让我们创建一个准备好我们的代码的server.js文件,以及一个名为form.html的 HTML 文件,其中包含以下代码:

<form method=post>
  <input type=text name=userinput1><br>
  <input type=text name=userinput2><br>
  <input type=submit>
</form>

提示

对于我们的目的,我们将把form.html放在与server.js相同的文件夹中,尽管这通常不是推荐的做法。通常,我们应该将我们的公共代码放在与服务器代码不同的文件夹中。

如何做...

我们将为我们的服务器提供 GET 和 POST 请求。让我们从 GET 开始,通过要求http模块并通过createServer加载form.html进行服务:

var http = require('http');
var form = require('fs').readFileSync('form.html');
http.createServer(function (request, response) {
  if (request.method === "GET") {
    response.writeHead(200, {'Content-Type': 'text/html'});
    response.end(form);
  }
}).listen(8080);

我们在初始化时同步加载form.html,而不是在每个请求上访问磁盘。如果我们导航到localhost:8080,我们将看到一个表单。但是,如果我们填写我们的表单,什么也不会发生,因为我们需要处理 POST 请求:

  if (request.method === "POST") {
  	var postData = '';
request.on('data', function (chunk) {
    		postData += chunk;
 	}).on('end', function() {
 	   console.log('User Posted:\n' + postData);
  	   response.end('You Posted:\n' + postData);
});
  }

一旦表单完成并提交,浏览器和控制台将输出从客户端发送的原始查询字符串。将postData转换为对象提供了一种与提交的信息进行交互和操作的简单方法。querystring模块有一个parse方法,可以将查询字符串转换为对象,由于表单提交以查询字符串格式到达,我们可以使用它将我们的数据转换为对象,如下所示:

var http = require('http');
var querystring = require('querystring');
var util = require('util');
var form = require('fs').readFileSync('form.html');

http.createServer(function (request, response) {
  if (request.method === "POST") {
    var postData = '';
    request.on('data', function (chunk) {
      postData += chunk;
    }).on('end', function () {
      var postDataObject = querystring.parse(postData);
      console.log('User Posted:\n', postData);
      response.end('You Posted:\n' + util.inspect(postDataObject));
    });

  }
  if (request.method === "GET") {
    response.writeHead(200, {'Content-Type': 'text/html'});
    response.end(form);
  }
}).listen(8080);

注意util模块。我们需要它来使用其inspect方法,以简单地将我们的postDataObject输出到浏览器。

最后,我们将保护我们的服务器免受内存超载攻击。

提示

保护 POST 服务器

V8(因此 Node)具有基于处理器架构和操作系统约束的虚拟内存限制。这些限制远远超出了大多数用例的需求。然而,如果我们不限制我们的 POST 服务器将接受的数据量,我们可能会使自己暴露于一种拒绝服务攻击。如果没有保护,一个非常大的 POST 请求可能会导致我们的服务器显著减速甚至崩溃。

为了实现这一点,我们将为最大可接受的数据大小设置一个变量,并将其与我们的postData变量不断增长的长度进行比较。

var http = require('http');
var querystring = require('querystring');
var util = require('util');
var form = require('fs').readFileSync('form.html');
var maxData = 2 * 1024 * 1024; //2mb
http.createServer(function (request, response) {
  if (request.method === "POST") {
    var postData = '';
    request.on('data', function (chunk) {
      postData += chunk;
      if (postData.length > maxData) {
        postData = '';
        this.pause();
        response.writeHead(413); // Request Entity Too Large
        response.end('Too large');
      }
    }).on('end', function () {
      if (!postData) { response.end(); return; } //prevents empty post requests from crashing the server
      var postDataObject = querystring.parse(postData);

      console.log('User Posted:\n', postData);

      response.end('You Posted:\n' + util.inspect(postDataObject));

    });
//rest of our code....

它是如何工作的...

一旦我们知道服务器已经发出了 POST 请求(通过检查request.method),我们通过request对象上的data事件监听器将我们的传入数据聚合到我们的postData变量中。但是,如果我们发现提交的数据超过了我们的maxData限制,我们将清除我们的postData变量,并pause传入流,阻止客户端进一步传入数据。使用stream.destroy而不是stream.pause似乎会干扰我们的响应机制。一旦流暂停了一段时间,它就会被 v8 的垃圾收集器自动从内存中删除。

然后我们发送一个413 Request Entity Too Large的 HTTP 头。在end事件监听器中,只要postData没有因超过maxData(或者一开始就不是空的)而被清除,我们就使用querystring.parse将我们的 POST 消息体转换成一个对象。从这一点开始,我们可以执行任意数量的有趣活动:操作、分析、传递到数据库等等。然而,对于这个例子,我们只是将postDataObject输出到浏览器,将postData输出到控制台。

还有更多...

如果我们希望我们的代码看起来更加优雅,而且我们不太关心处理 POST 数据流,我们可以使用一个用户自定义(非核心)模块来为我们的语法增添一些便利。

使用 connect.bodyParser 访问 POST 数据

Connect 是 Node 的一个出色的中间件框架,提供了一个方法框架,为常见的服务器任务提供了更高级别的抽象。Connect 实际上是 Express Web 框架的基础,将在第六章中讨论,使用 Express 加速开发

Connect 捆绑的一个中间件是bodyParser。通过将connect.bodyParser链接到普通的回调函数,我们突然可以通过request.body访问 POST 数据(当数据通过 POST 请求发送时,它被保存在消息体中)。结果,request.body与我们在配方中生成的postDataObject完全相同。

首先,让我们确保已安装 Connect:

npm install connect 

我们需要使用connect来代替http,因为它为我们提供了createServer的功能。要访问createServer方法,我们可以使用connect.createServer,或者简写版本,即connect。Connect 允许我们通过将它们作为参数传递给createServer方法来将多个中间件组合在一起。以下是如何使用 Connect 实现类似的行为,就像在配方中一样:

var connect = require('connect');
var util = require('util');
var form = require('fs').readFileSync('form.html');
connect(connect.limit('64kb'), connect.bodyParser(),
  function (request, response) {
    if (request.method === "POST") {
      console.log('User Posted:\n', request.body);
      response.end('You Posted:\n' + util.inspect(request.body));
    }
    if (request.method === "GET") {
      response.writeHead(200, {'Content-Type': 'text/html'});
      response.end(form);
    }
  }).listen(8080);

请注意,我们不再直接使用http模块。我们将connect.limit作为第一个参数传递,以实现主要示例中实现的相同的maxData限制。

接下来,我们传入bodyParser,允许connect为我们检索 POST 数据,将数据对象化为request.body。最后,有我们的回调函数,除了用于将我们的数据对象(现在是request.body)回显到控制台和浏览器的代码之外,我们剥离了所有以前的 POST 功能。这是我们与原始配方略有不同的地方。

在配方中,我们将原始的postData返回到控制台,而在这里我们返回request.body对象。要使用 Connect 输出原始数据,要么需要无意义地拆解我们的对象以重新组装原始查询字符串,要么需要扩展bodyParser函数。这就是使用第三方模块的权衡之处:我们只能轻松地与模块作者期望我们交互的信息进行交互。

让我们来看一下内部情况。如果我们启动一个没有任何参数的node实例,我们可以访问 REPL(Read-Eval-Print-Loop),这是 Node 的命令行环境。在 REPL 中,我们可以写:

console.log(require('connect').bodyParser.toString()); 

如果我们查看输出,我们会看到它的connect.bodyParser函数代码,并且应该能够轻松地从connect.bodyParser代码中识别出我们的配方中的基本元素。

参见

  • 处理文件上传在本章中讨论

  • 通过 AJAX 进行浏览器-服务器传输在第三章中讨论,数据序列化处理

  • 初始化和使用会话在第六章中讨论,使用 Express 加速开发

处理文件上传

我们无法像处理其他 POST 数据那样处理上传的文件。当文件输入以表单形式提交时,浏览器会将文件处理成多部分消息

多部分最初是作为一种电子邮件格式开发的,允许将多个混合内容组合成一条消息。如果我们直觉地尝试接收上传作为流并将其写入文件,我们将得到一个充满多部分数据而不是文件本身的文件。我们需要一个多部分解析器,其编写超出了一篇食谱的范围。因此,我们将使用众所周知且经过考验的formidable模块将我们的上传数据转换为文件。

准备工作

让我们为存储上传文件创建一个新的uploads目录,并准备修改我们上一个食谱中的server.js文件。

我们还需要安装formidable,如下所示:

npm install formidable@1.x.x 

最后,我们将对上一个食谱中的form.html进行一些更改:

<form method=POST enctype=multipart/form-data>
  <input type=file name=userfile1><br>
  <input type=file name=userfile2><br>
  <input type=submit>
</form>

我们已经包含了一个enctype属性为multipart/form-data,以向浏览器表示表单将包含上传数据,并用文件输入替换了文本输入。

操作步骤...

让我们看看当我们使用修改后的表单从上一个食谱中上传文件到服务器时会发生什么。让我们上传form.html本身作为我们的文件:

操作步骤...

我们的 POST 服务器只是将原始的 HTTP 消息主体记录到控制台中,这种情况下是多部分数据。我们在表单上有两个文件输入。虽然我们只上传了一个文件,但第二个输入仍然包含在多部分请求中。每个文件都由Content-TypeHTTP 头的次要属性中设置的预定义边界分隔。我们需要使用formidable来解析这些数据,提取其中包含的每个文件。

var http = require('http');
var formidable = require('formidable');
var form = require('fs').readFileSync('form.html');

http.createServer(function (request, response) {
  if (request.method === "POST") {
    var incoming = new formidable.IncomingForm();
    incoming.uploadDir = 'uploads';
    incoming.on('file', function (field, file) {
      if (!file.size) { return; }
      response.write(file.name + ' received\n');
    }).on('end', function () {
      response.end('All files received');
    });
    incoming.parse(request);
  }
  if (request.method === "GET") {
    response.writeHead(200, {'Content-Type': 'text/html'});
    response.end(form);
  }
}).listen(8080);

我们的 POST 服务器现在已经成为一个上传服务器。

它是如何工作的...

我们创建一个formidable IncomingForm类的新实例,并告诉它在哪里上传文件。为了向用户提供反馈,我们可以监听我们的incoming实例。IncomingForm类会发出自己的高级事件,因此我们不是监听request对象的事件并在数据到来时处理数据,而是等待formidable解析多部分消息中的文件,然后通过其自定义的file事件通知我们。

file事件回调为我们提供了两个参数:fieldfilefile参数是一个包含有关上传文件信息的对象。我们使用这个来过滤空文件(通常是由空输入字段引起的),并获取文件名,然后向用户显示确认。当formidable完成解析多部分消息时,它会发送一个end事件,我们在其中结束响应。

还有更多...

我们可以从浏览器中发布不仅仅是简单的表单字段和值。让我们来看看如何从浏览器传输文件到服务器。

使用 formidable 接受所有 POST 数据

formidable不仅处理上传的文件,还会处理一般的 POST 数据。我们只需要为field事件添加一个监听器,以处理同时包含文件和用户数据的表单。

 incoming.on('file', function (field, file) {
      response.write(file.name + ' received\n');
    })
    .on('field', function (field, value) {
      response.write(field + ' : ' + value + '\n');
    })
    .on('end', function () {
      response.end('All files received');
    });

无需手动实现字段数据大小限制,因为formidable会为我们处理这些。但是,我们可以使用incoming.maxFieldsSize更改默认设置,这允许我们限制所有字段的总字节数。这个限制不适用于文件上传。

使用 formidable 保留文件名

formidable将我们的文件放入uploads目录时,它会为它们分配一个由随机生成的十六进制数字组成的名称。这可以防止同名文件被覆盖。但是如果我们想知道哪些文件是哪些,同时保留唯一文件名的优势呢?我们可以在fileBegin事件中修改formidable命名每个文件的方式,如下面的代码所示:

  if (request.method === "POST") {
  var incoming = new formidable.IncomingForm();
  incoming.uploadDir = 'uploads';
   incoming.on('fileBegin', function (field, file) {
    if (file.name){
      file.path += "-" + file.name;
    } //...rest of the code
  }).on('file', function (field, file) {
//...rest of the code

我们已经将原始文件名附加到formidable分配的随机文件名的末尾,并用破折号分隔它们。现在我们可以轻松地识别我们的文件。然而,对于许多情况来说,这可能并不是必要的,因为我们可能会将文件信息输出到数据库,并将其与随机生成的名称进行交叉引用。

通过 PUT 上传

也可以通过 HTTP PUT 请求上传文件。虽然我们每次只能发送一个文件,但在服务器端我们不需要进行任何解析,因为文件将直接流向我们的服务器,这意味着更少的服务器端处理开销。如果我们可以通过将表单的method属性从POST更改为PUT来实现这一点就太好了,但遗憾的是不行。然而,由于即将到来的XMLHttpRequest Level 2(xhr2),我们现在可以在一些浏览器中通过 JavaScript 传输二进制数据(参见www.caniuse.com/#search=xmlhttprequest%202))。我们使用文件元素上的change事件监听器来获取文件指针,然后打开一个 PUT 请求并发送文件。以下是用于form.html的代码,我们将其保存为put_upload_form.html

<form id=frm>
  <input type=file id=userfile name=userfile><br>
  <input type=submit>
</form>
<script>
(function () {
  var userfile = document.getElementById('userfile'),
    frm = document.getElementById('frm'),
    file;
  userfile.addEventListener('change', function () {
    file = this.files[0];
  });
  frm.addEventListener('submit', function (e) {
    e.preventDefault();
    if (file) {
      var xhr = new XMLHttpRequest();
      xhr.file = file;
      xhr.open('put', window.location, true);
      xhr.setRequestHeader("x-uploadedfilename", file.fileName || file.name);
      xhr.send(file);
      file = '';
      frm.reset();
    }
  });
}());
</script>

在表单和文件输入中添加了Id,同时删除了methodenctype属性。我们只使用一个文件元素,因为我们只能在一个请求中发送一个文件,尽管示例可以扩展为异步流式传输多个文件到我们的服务器。

我们的脚本为文件输入元素附加了一个change监听器。当用户选择文件时,我们能够捕获文件的指针。在提交表单时,我们阻止默认行为,检查是否选择了文件,初始化xhr对象,向我们的服务器打开一个 PUT 请求,设置自定义标头以便稍后获取文件名,并将文件发送到我们的服务器。我们的服务器代码如下:

var http = require('http');
var fs = require('fs');
var form = fs.readFileSync('put_upload.html');
http.createServer(function (request, response) {
  if (request.method === "PUT") {
    var fileData = new Buffer(+request.headers['content-length']);
    var bufferOffset = 0;
    request.on('data', function(chunk) {
      chunk.copy(fileData, bufferOffset);
      bufferOffset += chunk.length;
    }).on('end', function() {
        var rand = (Math.random()*Math.random())
                          .toString(16).replace('.','');
      var to = 'uploads/' + rand + "-" +
                     request.headers['x-uploadedfilename'];
      fs.writeFile(to, fileData, function(err) {
        if (err) { throw err; }
	  console.log('Saved file to ' + to);
        response.end();
      });
    });
  }
  if (request.method === "GET") {
  response.writeHead(200, {'Content-Type': 'text/html'});
  response.end(form);
  }
}).listen(8080);

我们的 PUT 服务器遵循了处理 POST 数据中简单 POST 服务器的类似模式。我们监听数据事件并将块拼接在一起。然而,我们不是将我们的数据串联起来,而是必须将我们的块放入缓冲区,因为缓冲区可以处理包括二进制在内的任何数据类型,而字符串对象总是将非字符串数据强制转换为字符串格式。这会改变底层二进制,导致文件损坏。一旦触发了end事件,我们会生成一个类似于formidable命名约定的随机文件名,并将文件写入我们的uploads文件夹。

注意

这个通过 PUT 上传的演示在旧版浏览器中无法工作,因此在生产环境中应提供替代方案。支持此方法的浏览器包括 IE 10 及以上版本、Firefox、Chrome、Safari、iOS 5+ Safari 和 Android 浏览器。然而,由于浏览器供应商对相同功能的实现不同,示例可能需要一些调整以实现跨浏览器兼容性。

另请参阅

  • 在第八章中讨论的发送电子邮件 第八章,集成网络范式

  • 在本章中讨论的将 Node 用作 HTTP 客户端

使用 Node 作为 HTTP 客户端

HTTP 对象不仅提供了服务器功能,还为我们提供了客户端功能。在这个任务中,我们将使用http.getprocess通过命令行动态获取外部网页。

准备就绪

我们不是在创建服务器,因此在命名约定中,我们应该为我们的新文件使用不同的名称,让我们称之为fetch.js

如何做...

http.request允许我们发出任何类型的请求(例如 GET、POST、DELETE、OPTION 等),但对于 GET 请求,我们可以使用http.get方法进行简写,如下所示:

var http = require('http');
var urlOpts = {host: 'www.nodejs.org', path: '/', port: '80'};
http.get(urlOpts, function (response) {
  response.on('data', function (chunk) {
    console.log(chunk.toString());
  });
});

基本上我们已经完成了。

node fetch.js 

如果我们运行上述命令,我们的控制台将输出nodejs.org的 HTML。然而,让我们用一些交互和错误处理来填充它,如下所示的代码所示:

var http = require('http');
var url = require('url');
var urlOpts = {host: 'www.nodejs.org', path: '/', port: '80'};
if (process.argv[2]) {
  if (!process.argv[2].match('http://')) {
    process.argv[2] = 'http://' + process.argv[2];
  }
  urlOpts = url.parse(process.argv[2]);
}
http.get(urlOpts, function (response) {
  response.on('data', function (chunk) {
    console.log(chunk.toString());
  });
}).on('error', function (e) {
  console.log('error:' + e.message);
});

现在我们可以像这样使用我们的脚本:

node fetch.js www.google.com 

它是如何工作的...

http.get接受一个定义我们所需请求条件的对象。我们为此目的定义了一个名为urlOpts的变量,并将我们的主机设置为www.nodejs.org。我们使用process.argv属性检查是否通过命令行指定了网址。像console一样,process是一个在 Node 运行环境中始终可用的全局变量。process.argv[2]是第三个命令行参数,nodefetch.js分别分配给[0][1]

如果process.argv[2]存在(也就是说,如果已经指定了地址),我们会追加http://。如果不存在(url.parse需要它),则用url.parse的输出替换我们默认的urlOpts中的对象。幸运的是,url.parse返回一个具有与http.get所需属性相同的对象。

作为客户端,我们与服务器对我们的响应进行交互,而不是与客户端对我们的请求进行交互。因此,在http.get回调中,我们监听response上的data事件,而不是(与我们的服务器示例一样)request。随着response数据流的到达,我们将块输出到控制台。

还有更多...

让我们探索一下http.get的底层http.request方法的一些可能性。

发送 POST 请求

我们需要启动我们的server.js应用程序来接收我们的 POST 请求。让我们创建一个新文件,将其命名为post.js,我们将使用它来向我们的 POST 服务器发送 POST 请求。

var http = require('http');
var urlOpts = {host: 'localhost', path: '/', port: '8080', method: 'POST'};
var request = http.request(urlOpts, function (response) {
    response.on('data', function (chunk) {
      console.log(chunk.toString());
    });
  }).on('error', function (e) {
    console.log('error:' + e.stack);
  });
process.argv.forEach(function (postItem, index) {
  if (index > 1) { request.write(postItem + '\n'); }
});
request.end();

由于我们使用的是更通用的http.request,我们必须在urlOpts变量中定义我们的 HTTP 动词。我们的urlOpts变量还指定了服务器为localhost:8080(我们必须确保我们的 POST 服务器正在运行,以便此代码能够工作)。

与以前一样,我们在response对象的data回调中设置了一个事件监听器。http.request返回一个clientRequest对象,我们将其加载到一个名为request的变量中。这是一个新声明的变量,它保存了从http.request方法返回的clientRequest对象。

在我们的事件监听器之后,我们使用 Ecmascript 5 的forEach方法循环遍历命令行参数(在 Node 中是安全的,但在浏览器中还不是)。在运行此脚本时,nodepost.js将分别是第 0 个和第 1 个参数,因此我们在发送任何参数作为 POST 数据之前检查数组索引是否大于 1。我们使用request.write发送数据,类似于我们在构建服务器时使用response.write。尽管它使用了回调,但forEach不是异步的(它会阻塞直到完成),因此只有在处理完每个元素后,我们的 POST 数据才会被写入,我们的请求才会结束。这是我们使用它的方式:

node post.js foo=bar&x=y&anotherfield=anothervalue 

作为客户端的多部分文件上传

我们将使用处理文件上传中的上传服务器来接收来自我们上传客户端的文件。为了实现这一点,我们必须处理多部分数据格式。为了告知服务器客户端打算发送多部分数据,我们将content-type头设置为multipart/form-data,并添加一个名为boundary的额外属性,这是一个自定义命名的分隔符,用于分隔多部分数据中的文件。

var http = require('http');
var fs = require('fs');
var urlOpts = { host: 'localhost', path: '/', port: '8080', method: 'POST'};
var boundary = Date.now();
urlOpts.headers = {
  'Content-Type': 'multipart/form-data; boundary="' + boundary + '"'
};

我们在这里也需要fs模块,因为我们稍后将需要加载我们的文件。

我们将我们的boundary设置为当前的 Unix 时间(1970 年 1 月 1 日午夜以来的毫秒数)。我们不需要再以这种格式使用boundary,所以让我们用所需的多部分双破折号(--)前缀更新它,并设置我们的http.request调用:

boundary = "--" + boundary;
var request = http.request(urlOpts, function (response) {
    response.on('data', function (chunk) {
      console.log(chunk.toString());
    });
  }).on('error', function (e) {
    console.log('error:' + e.stack);
  });

我们希望能够将多部分数据流式传输到服务器,这些数据可能由多个文件编译而成。如果我们同时尝试将这些文件流式传输并将它们同时编译成多部分格式,数据很可能会从不同的文件流中混合在一起,顺序难以预测,变得无法解析。因此,我们需要一种方法来保留数据顺序。

我们可以一次性构建所有内容,然后将其发送到服务器。然而,一个更有效(并且类似于 Node 的)的解决方案是,通过逐步将每个文件组装成多部分格式来构建多部分消息,同时在构建时即时流式传输多部分数据。

为了实现这一点,我们可以使用一个自迭代的函数,从end事件回调中调用每个递归,以确保每个流都被单独捕获并按顺序进行。

(function multipartAssembler(files) {
  var f = files.shift(), fSize = fs.statSync(f).size;
  fs.createReadStream(f)
    .on('end', function () {
      if (files.length) { multipartAssembler(files); return; //early finish}
	//any code placed here wont execute until no files are left
	//due to early return from function.
    });
}(process.argv.splice(2, process.argv.length)));

这也是一个自调用函数,因为我们已经将它从声明更改为表达式,通过在其周围加括号。然后我们通过附加括号来调用它,同时传入命令行参数,指定要上传的文件:

node upload.js file1 file2 fileN 

我们在process.argv数组上使用splice来删除前两个参数(即nodeupload.js)。结果作为我们的files参数传递到我们的multipartAssembler函数中。

在我们的函数内部,我们立即将第一个文件从files数组中移除,并将其加载到变量f中,然后将其传递到createReadStream中。一旦读取完成,我们将任何剩余的文件再次通过我们的multipartAssembler函数,并重复该过程,直到数组为空。现在让我们用多部分的方式来完善我们的自迭代函数,如下所示:

(function multipartAssembler(files) {
  var f = files.shift(), fSize = fs.statSync(f).size,
	progress = 0;
  fs.createReadStream(f)
    .once('open', function () {
      request.write(boundary + '\r\n' +
                   'Content-Disposition: ' +
                   'form-data; name="userfile"; filename="' + f + '"\r\n' +
                   'Content-Type: application/octet-stream\r\n' +
                   'Content-Transfer-Encoding: binary\r\n\r\n');
    }).on('data', function(chunk) {
      request.write(chunk);
      progress += chunk.length;
      console.log(f + ': ' + Math.round((progress / fSize) * 10000)/100 + '%');
    }).on('end', function () {
      if (files.length) { multipartAssembler(files); return; //early finish }
      request.end('\r\n' + boundary + '--\r\n\r\n\r\n');    
    });
}(process.argv.splice(2, process.argv.length)));

我们在content-type头部中首先设置了预定义边界的部分。每个部分都需要以一个头部开始,我们利用open事件来发送这个头部。

content-disposition有三个部分。在这种情况下,第一部分将始终是form-data。第二部分定义了字段的名称(例如,文件输入的name属性)和原始文件名。content-type可以设置为任何相关的 mime。然而,通过将所有文件设置为application/octet-stream并将content-transfer-encoding设置为binary,如果我们只是将文件保存到磁盘而没有任何中间处理,我们可以安全地以相同的方式处理所有文件。我们在每个多部分头部的末尾使用双 CRLF(\r\n\r\n)来结束我们的request.write

还要注意,我们在multipartAssembler函数的顶部分配了一个新的progress变量。我们使用这个变量来通过将到目前为止接收到的块数(progress)除以总文件大小(fSize)来确定上传的相对百分比。这个计算是在我们的data事件回调中执行的,我们也在那里将每个块流到服务器上。

在我们的end事件中,如果没有更多的文件需要处理,我们将以与其他边界分区相同的最终多部分边界结束请求,除了它有前导和尾随斜杠。

另请参阅

  • 使用真实数据:获取热门推文 在第三章中讨论了使用数据序列化

实施下载限速

对于传入的流,Node 提供了pauseresume方法,但对于传出的流则不然。基本上,这意味着我们可以在 Node 中轻松地限制上传速度,但下载限速需要更有创意的解决方案。

准备工作

我们需要一个新的server.js以及一个很大的文件来提供服务。使用dd命令行程序,我们可以生成一个用于测试的文件。

dd if=/dev/zero of=50meg count=50 bs=1048576 

这将创建一个名为50meg的 50MB 文件,我们将提供服务。

提示

对于一个类似的 Windows 工具,可以用来生成一个大文件,请查看www.bertel.de/software/rdfc/index-en.html

如何做...

为了尽可能简单,我们的下载服务器将只提供一个文件,但我们将以一种方式来实现,可以轻松地插入一些路由代码来提供多个文件。首先,我们将需要我们的模块并设置一个options对象来设置文件和速度设置。

var http = require('http');
var fs = require('fs');

var options = {}
options.file = '50meg';
options.fileSize = fs.statSync(options.file).size;
options.kbps = 32;

如果我们正在提供多个文件,我们的 options 对象将大部分是多余的。但是,在这里我们使用它来模拟用户确定的文件选择概念。在多文件情况下,我们将根据请求的 URL 加载特定文件信息。

注意

要了解这个方法如何配置以服务和限制多个文件,请查看 第一章 中的路由方法,制作 Web 服务器

http 模块用于服务器,而 fs 模块用于创建 readStream 并获取我们文件的大小。

我们将限制一次发送多少数据,但首先我们需要获取数据。所以让我们创建我们的服务器并初始化一个 readStream

http.createServer(function(request, response) {
  var download = Object.create(options);
  download.chunks = new Buffer(download.fileSize);
  download.bufferOffset = 0;

  response.writeHeader(200, {'Content-Length': options.fileSize});

   fs.createReadStream(options.file)
    .on('data', function(chunk) {  
      chunk.copy(download.chunks,download.bufferOffset);
      download.bufferOffset += chunk.length;
    })
    .once('open', function() {
    	 //this is where the throttling will happen
     });    
}).listen(8080);

我们已经创建了我们的服务器并指定了一个叫做 download 的新对象,它继承自我们的 options 对象。我们向我们的请求绑定的 download 对象添加了两个属性:一个 chunks 属性,它在 readStream 数据事件监听器中收集文件块,以及一个 bufferOffset 属性,它将用于跟踪从磁盘加载的字节数。

现在我们所要做的就是实际的限流。为了实现这一点,我们只需每秒从我们的缓冲区中分配指定数量的千字节,从而实现指定的每秒千字节。我们将为此创建一个函数,它将放在 http.createServer 之外,并且我们将称我们的函数为 throttle

function throttle(download, cb) {
  var chunkOutSize = download.kbps * 1024,
      timer = 0;

  (function loop(bytesSent) {
    var remainingOffset;
    if (!download.aborted) {
      setTimeout(function () {      
        var bytesOut = bytesSent + chunkOutSize;

        if (download.bufferOffset > bytesOut) {
          timer = 1000;         
          cb(download.chunks.slice(bytesSent,bytesOut));
          loop(bytesOut);
          return;
        }

        if (bytesOut >= download.chunks.length) {
            remainingOffset = download.chunks.length - bytesSent;
            cb(download.chunks.slice(remainingOffset,bytesSent));
            return;
        }

          loop(bytesSent); //continue to loop, wait for enough data
      },timer);
    }  
   }(0));

   return function () { //return a function to handle an abort scenario
    download.aborted = true;
   };

}

throttle 与每个服务器请求上创建的 download 对象交互,根据我们预定的 options.kbps 速度分配每个块。对于第二个参数(cb),throttle 接受一个功能回调。cb 反过来接受一个参数,即 throttle 确定要发送的数据块。我们的 throttle 函数返回一个方便的函数,用于在中止时结束循环,避免无限循环。我们通过在服务器回调中调用我们的 throttle 函数来初始化下载限流时钟,当 readStream 打开时。

//...previous code
  fs.createReadStream(options.file)
      .on('data', function (chunk) {  
        chunk.copy(download.chunks,download.bufferOffset);
        download.bufferOffset += chunk.length;
      })
      .once('open', function () {
         var handleAbort = throttle(download, function (send) {
                       			      response.write(send);
                           		    });

         request.on('close', function () {
            handleAbort();
         }); 
       });    

}).listen(8080);

它是如何工作的...

这个方法的关键是我们的 throttle 函数。让我们来看看它。为了实现指定的速度,我们每秒发送一定大小的数据块。大小由所需的每秒千字节数量确定。因此,如果 download.kbps 是 32,我们将每秒发送 32 KB 的数据块。

缓冲区以字节为单位工作,所以我们设置一个新变量叫做 chunkOutSize,并将 download.kbps 乘以 1024 以实现适当的块大小(以字节为单位)。接下来,我们设置一个 timer 变量,它被传递给 setTimeout。它首先设置为 0 有两个原因。首先,它消除了不必要的初始 1000 毫秒开销,使我们的服务器有机会立即发送第一块数据(如果可用)。其次,如果 download.chunks 缓冲区不足以满足 chunkOutSize 的需求,嵌入的 loop 函数在不改变 timer 的情况下进行递归。这会导致 CPU 实时循环,直到缓冲区加载足够的数据以传递一个完整的块(这个过程应该在一秒钟内完成)。

一旦我们有了第一个块的足够数据,timer 就设置为 1000,因为从这里开始我们希望每秒推送一个块。

loop 是我们限流引擎的核心。它是一个自递归函数,它使用一个参数 bytesSent 调用自身。bytesSent 参数允许我们跟踪到目前为止发送了多少数据,并且我们使用它来确定从我们的 download.chunks 缓冲区中切出哪些字节,使用 Buffer.sliceBuffer.slice 接受两个参数,startend。这两个参数分别由 bytesSentbytesOut 实现。bytesOut 也用于与 download.bufferOffset 对比,以确保我们加载了足够的数据以便发送一个完整的块。

如果有足够的数据,我们继续将timer设置为 1000,以启动我们的每秒一个块的策略,然后将download.chunks.slice的结果传递给cb,这将成为我们的send参数。

回到服务器内部,我们的send参数被传递到throttle回调中的response.write,因此每个块都被流式传输到客户端。一旦我们将切片的块传递给cb,我们调用loop(bytesOut)进行新的迭代(因此bytesOut变成bytesSent),然后我们从函数中返回,以防止进一步执行。

bytesOut第三次出现的地方是在setTimeout回调的第二个条件语句中,我们将其与download.chunks.length进行比较。这对于处理最后一块数据很重要。我们不希望在最后一块数据发送后再次循环,如果options.kbps不能完全整除总文件大小,最后的bytesOut将大于缓冲区的大小。如果未经检查地传递给slice方法,这将导致对象越界(oob)错误。

因此,如果bytesOut等于或大于分配给download.chunks缓冲区的内存(即我们文件的大小),我们将从download.chunks缓冲区中切片剩余的字节,并在不调用loop的情况下从函数中返回,有效地终止递归。

为了防止连接意外关闭时出现无限循环(例如在连接失败或客户端中止期间),throttle返回另一个函数,该函数在handleAbort变量中捕获并在responseclose事件中调用。该函数简单地向download对象添加一个属性,表示下载已中止。这在loop函数的每次递归中都会进行检查。只要download.aborted不是true,它就会继续迭代,否则循环会提前停止。

注意

操作系统上有(可配置的)限制,定义了可以同时打开多少文件。我们可能希望在生产下载服务器中实现缓存,以优化文件系统访问。有关 Unix 系统上的文件限制,请参阅www.stackoverflow.com/questions/34588/how-do-i-change-the-number-of-open-files-limit-in-linux

启用断点续传

如果连接中断,或用户意外中止下载,客户端可以通过向服务器发送Range HTTP 头来发起恢复请求。Range头可能如下所示:

Range: bytes=512-1024

当服务器同意处理Range头时,它会发送206 Partial Content状态,并在响应中添加Content-Range头。如果整个文件大小为 1 MB,对先前的Range头的Content-Range回复可能如下所示:

Content-Range: bytes 512-1024/1024

请注意,在Content-Range头中bytes后面没有等号(=)。我们可以将对象传递给fs.createReadStream的第二个参数,指定从哪里开始和结束读取。由于我们只是处理恢复,因此只需要设置start属性。

//requires, options object, throttle function, create server etc...
download.readStreamOptions = {};
download.headers = {'Content-Length': download.fileSize};
download.statusCode = 200;
  if (request.headers.range) {
    download.start = request.headers.range.replace('bytes=','').split('-')[0];
    download.readStreamOptions = {start: +download.start};
    download.headers['Content-Range'] = "bytes " + download.start + "-" + 											     download.fileSize + "/" + 												     download.fileSize;
    download.statusCode = 206; //partial content
  }
  response.writeHeader(download.statusCode, download.headers);
  fs.createReadStream(download.file, download.readStreamOptions)
//...rest of the code....

通过向download添加一些属性,并使用它们有条件地响应Range头,我们现在可以处理恢复请求。

另请参阅

  • 设置路由器讨论在第一章中,制作 Web 服务器

  • 在内存中缓存内容以进行即时交付讨论在第一章中,制作 Web 服务器

  • 通过 TCP 通信讨论在第八章中,集成网络范式

第三章:使用数据序列化

在本章中,我们将涵盖:

  • 将对象转换为 JSON,然后再转换回来

  • 将对象转换为 XML,然后再转换回来

  • 通过 AJAX 进行浏览器-服务器传输

  • 使用真实数据:获取热门推文

介绍

如果我们想让第三方安全地访问原始数据,我们可以使用序列化将其发送到请求者能够理解的格式中。在本章中,我们将研究两种著名标准中的数据序列化,JSON 和 XML。

将对象转换为 JSON,然后再转换回来

JSON(JavaScript 对象表示法)与 JavaScript 对象非常相关,因为它是 JavaScript 的子集。这项任务将演示如何使用 JSON 转换的构建块:JSON.parseJSON.stringify

准备工作

我们需要创建两个名为profiles.jsjson_and_back.js的新文件。

如何做...

让我们创建一个对象,稍后将其转换为 JSON。

module.exports = {
  ryan : {
           name: "Ryan Dahl",
           irc:'ryah',
           twitter:'ryah',
           github:'ry',
           location:'San Francisco, USA',
           description: "Creator of node.js"
          },
  isaac : {
            name: "Isaac Schlueter",
            irc:'isaacs',
            twitter:'izs',
            github:'isaacs',
            location:'San Francisco, USA',
            description: "Author of npm, core contributor"
           },
  bert : {
           name: "Bert Belder",
           irc:'piscisaureus',
           twitter:'piscisaureus',
           github:'piscisaureus',
           location:'Netherlands',
           description: "Windows support, overall contributor"
          },
  tj : {
          name: "TJ Holowaychuk",
          irc:'tjholowaychuk',
          twitter:'tjholowaychuk',
          github:'visionmedia',
          location:'Victoria, BC, Canada',
          description: "Author of express, jade and other popular modules"
          },
  felix : {
          name: "Felix Geisendorfer",
          irc:'felixge',
          twitter:'felixge',
          github:'felixge',
          location:'Berlin, Germany',
          description: "Author of formidable, active core developer"
          }
};

这个对象包含了 Node 社区一些领先成员的个人资料信息(尽管它并不全面,甚至不包含所有的核心开发团队)。这里需要注意的一点是使用了module.exports。我们将在第九章中看到更多关于这个的内容,编写自己的模块。我们在这里使用module.exports来模块化我们的profiles对象,以保持我们的代码整洁。我们可以将任何表达式加载到module.exports中,将其保存为一个单独的文件(在我们的情况下,我们将称之为profiles.js),并在我们的主文件中使用require来动态加载它进行初始化。

var profiles = require('./profiles'); // note the .js suffix is optional

整洁而清晰。为了将我们的profiles对象转换为 JSON 表示,我们使用JSON.stringify,它将返回由 JSON 数据组成的字符串。我们将使用replace从根本上改变我们的对象(现在是一个字符串)。

profiles = JSON.stringify(profiles).replace(/name/g, 'fullname');

在这里,我们调用了replace,使用全局g选项的正则表达式来将我们的 JSON 字符串中的每个name更改为fullname

但等等!似乎出现了某种错误。Felix 的姓缺少一个分音符!让我们通过将我们的 JSON 数据转换回对象,并通过修改重新指定的fullname属性的值来纠正他的名字:

profiles = JSON.parse(profiles);
profiles.felix.fullname = "Felix Geisendörfer";
console.log(profiles.felix);

当我们运行我们的应用程序时,console.log将输出以下内容:

{ fullname: 'Felix Geisendörfer',
  irc: 'felixge',
  twitter: 'felixge',
  github: 'felixge',
  location: 'Berlin, Germany',
  description: 'Author of formidable, active core developer' }

第一个键现在是fullname,而Geisendörfer的拼写是正确的。

它是如何工作的...

首先,我们有一个日常的 JavaScript 对象,我们将其序列化为 JSON 表示。我们还在我们的 JSON 字符串上调用String.replace方法,将每个name的出现更改为fullname

以这种方式使用 replace 并不是一个明智的做法,因为任何name的出现都会被替换。字符串中很容易有其他地方可能存在name,这样会意外地被替换。我们在这里使用replace来确认配置文件已经成为 JSON 字符串,因为我们无法在对象上使用replace

然后,我们使用JSON.parse将修改后的 JSON 字符串转换回对象。为了测试我们的键确实从name转换为fullname,并确认我们再次使用对象,我们通过profiles.felix.fullname纠正felix配置文件,然后将profiles.felix记录到控制台。

还有更多...

JSON 是一种非常灵活和多功能的跨平台通信工具。让我们看看标准的更高级应用。

构建 JSONP 响应

JSONP(带填充的 JSON)是一个跨域策略的变通方法,允许开发人员与其他域上的资源进行接口。它涉及在客户端定义一个回调函数,通过它的第一个参数处理 JSON,然后将这个回调函数的名称作为查询参数传递给script元素的src属性,该元素指向另一个域上的 web 服务。然后,web 服务返回 JSON 数据,包装在一个根据客户端设置的查询参数命名的函数中。可能更容易通过代码来说明这一点。

<html>
<head>
<script>
  var who = 'ryan';
  function cb(o) {
    alert(o.name + ' : ' + o.description);
  }
  var s = document.createElement('script');
  s.src = 'http://localhost:8080/?callback=cb&who=' + who;
  document.getElementsByTagName("head")[0].appendChild(s);
</script>
</head>
</html>

我们定义了一个名为cb的函数,它以一个对象作为参数,然后输出namedescription属性。在此之前,我们设置了一个名为who的变量,它将被传递给服务器以为我们获取特定的数据。然后,我们动态注入一个新的脚本元素,将src设置为一个象征性的第三方域(为了方便演示,是 localhost),并添加callbackwho查询参数。callback的值与我们的函数cb函数的名称匹配。我们的服务器使用此参数将 JSON 包装在函数调用中。

var http = require('http');
var url = require('url');
var profiles = require('./profiles');

http.createServer(function (request, response) {
  var urlObj = url.parse(request.url, true), 
    cb = urlObj.query.callback, who = urlObj.query.who,
    profile;

  if (cb && who) {
    profile = cb + "(" + JSON.stringify(profiles[who]) + ")";
    response.end(profile);
  }

}).listen(8080);

我们创建一个服务器,提取callbackwho查询参数,并写一个包含传递我们的 JSON 数据作为参数的函数调用的响应。这个脚本由我们的客户端加载,其中调用cb函数并将 JSON 作为对象接收到函数中(因为它看起来像一个对象)。

安全和 JSONP

由于 JSONP 使用脚本注入,任何脚本都可以插入到我们的页面中。因此,强烈建议只在受信任的来源使用此方法。不受信任的来源可能在页面上运行恶意代码。

另请参阅

  • 在本章中讨论的通过 AJAX 进行浏览器-服务器传输

  • 在本章中讨论的使用真实数据:获取热门推文

将对象转换为 XML,然后再转回来

由于 JSON 是 JavaScript 对象的基于字符串的表示,因此在两者之间进行转换是简单的。但是,XML 不方便处理。尽管如此,可能有时我们不得不使用它,例如,如果 API 只能使用 XML,或者如果我们与要求 XML 支持的项目签约。

有各种非核心 XML 解析器可用。其中一个解析器是非核心模块xml2jsxml2js的前提是,使用 JavaScript 中的对象比使用 XML 更合适。xml2js为我们提供了一个基础,让我们通过将 XML 转换为 JavaScript 对象来与 XML 交互。

在这个任务中,我们将编写一个函数,使用前一个配方中的profiles对象来创建一个有效的 XML 字符串,然后将其通过xml2js,从而将其转换回对象。

准备工作

在开始之前,让我们创建我们的文件xml_and_back.js,确保我们的单独模块化的profiles.js也在同一个目录中。我们还应该安装xml2js

npm install xml2js 

如何做...

首先,我们需要引入我们的profiles对象以及xml2js

var profiles = require('./profiles');
var xml2js = new (require('xml2js')).Parser();

请注意,我们不仅仅需要xml2js模块,还初始化了它的Parser方法的一个新实例,并将其加载为我们的xml2js变量。这与xml2js模块的工作方式有关。我们必须创建一个新的Parser实例,以便将一段 XML 解析为一个对象。由于我们的代码相对简单,我们可能会在需要时进行初始化工作。

就像 XML 具有树状结构一样,对象可以在其中嵌套对象。我们需要一个函数,可以循环遍历我们的对象和所有子对象,将所有属性转换为父 XML 节点,将所有非对象值转换为文本 XML 节点:

function buildXml(rootObj, rootName) {
  var xml = "<?xml version='1.0' encoding='UTF-8'?>\n";
  rootName = rootName || 'xml';
  xml += "<" + rootName + ">\n";
  (function traverse(obj) {
    Object.keys(obj).forEach(function (key) {
     var open = "<" + key + ">",
        close = "</" + key + ">\n",
        isTxt = (obj[key]
          && {}..toString.call(obj[key]) !== "[object Object]");

      xml += open;

      if (isTxt) {
        xml += obj[key];
        xml += close;
        return;
      }

      xml += "\n";
      traverse(obj[key]);
      xml += close;
    });
  }(rootObj));

  xml += "</" + rootName + ">";
  return xml;
}

buildXml接受两个参数,对象和一个字符串来命名第一个根 XML 节点,并返回表示我们对象的 XML 数据的字符串。

让我们将所有name的出现替换为fullname,就像我们的将对象转换为 JSON,然后再转回来配方中一样。

profiles = buildXml(profiles, 'profiles').replace(/name/g, 'fullname');
console.log(profiles); // <-- show me the XML!

现在我们将profiles转回为一个对象,使用重命名的fullname属性来更正 Felix Geisendörfer 的名字,然后将 Felix 记录到控制台上以显示它已经生效。

xml2js.parseString(profiles, function (err, obj) {
  profiles = obj;
  profiles.felix.fullname = "Felix Geisendörfer";
  console.log(profiles.felix);
});

xml2js.parseString接受 XML(此时保存在profiles变量中)并将其组装成一个对象,作为其回调中的obj参数传递。

它是如何工作的...

JavaScript 对象是一个键值存储,而 XML 是一种以资源为中心的标记语言。在 XML 中,键和值可以用两种方式表示:要么作为父节点和子节点,要么作为 XML 节点上的属性。我们将我们的键和值转换为父节点和子节点,主要是因为单个 XML 节点充满了大量的属性,而有效的 XML 似乎违反了 XML 的精神。

我们通过buildXml实现了我们的转换,它是一个包装另一个自调用递归函数traverse的函数。我们这样做是为了利用 JavaScript 中的闭包原理,它允许我们在内部和外部函数之间共享变量。这使我们能够使用外部的xml变量来组装我们的序列化 XML。

在我们的外部函数中,我们从<?xml?>声明开始,设置所需的version属性和可选的encoding属性为UTF-8。我们还将traverse渲染的任何输出都包装在一个以我们的rootName参数命名的结束和关闭标签中。因此,在我们的情况下,buildXml将以下内容放入我们的xml变量中:

<?xml version='1.0' encoding='UTF-8'?>
<profiles>
	<!-- Traverse XML Output Here -->
</profiles>

如果rootName丢失,我们默认为<xml>作为根节点。我们的traverse内部函数接受一个参数,即要转换为 XML 的对象。我们将rootObj传递给调用括号:

(function traverse(obj) {
	// traverse function code here...
  }(rootObj));  // ? passing in our root object parameter

traverse使用forEach循环遍历此对象的键,通过forEach回调的第一个参数访问每个键。我们使用每个key的名称来生成 XML 标签的开头和结尾,并将open标签附加到我们共享的xml变量上。然后我们检查我们的isTxt变量,它测试嵌套对象并在不是对象时返回true(假设它必须是文本)。如果isTxttrue,我们输出当前属性的值并从forEach回调返回,继续到下一个属性。这就是我们获取文本节点的方式——值。否则,我们在xml中附加一个换行符,并在子对象上调用traverse,通过完全相同的过程进行,只是这次它嵌入在父traverse函数中。一旦我们嵌套调用traverse返回,我们就在xml中附加close标签,我们的traverse函数就完成了。最后,我们的外部函数附加了关闭根节点标签,并返回所有生成的 XML。

还有更多...

我们可以进一步调整我们的代码,以更好地与xml2js库集成,通过将其对某些 XML 特性的解释反映到 JavaScript 对象等价物中。我们还可以将其扩展为将更复杂的 JavaScript 对象转换为有效的 XML。

包含数组和函数的对象

除了对象和字符串之外,对象属性还可以包含函数和数组。就目前而言,我们的方法将这些解释为文本,对于数组,输出一个逗号分隔的值列表,并在文本节点中返回函数的内容。

这并不理想,所以我们将修改我们的traverse函数来处理这些类型:

  (function traverse(obj) {
    Object.keys(obj).forEach(function (key) {
     var open = "<" + key + ">",
        close = "</" + key + ">\n",
        nonObj = (obj[key]  
          && {}.toString.call(obj[key]) !== "[object Object]"),
        isArray = Array.isArray(obj[key]),
        isFunc =(typeof obj[key] === "function");

      if (isArray) {
        obj[key].forEach(function (xmlNode) {
          var childNode = {};
          childNode[key] = xmlNode;
          traverse(childNode);
        });
        return;
      }

      xml += open;      
      if (nonObj) {
        xml += (isFunc) ? obj[key]() : obj[key];
        xml += close;
        return;
      }
//rest of traverse function

我们将保存我们修改后的代码为xml_with_arrays_and_functions.js。为了语义上的完整,我们将isTxt重命名为nonObj,并添加了两个更多的测试变量,isArrayisFunc。如果我们遍历的对象的值是一个数组,我们创建一个临时的childNode对象,然后将其传回traverse。我们对数组的每个值都做同样的操作,每次创建一个新的childNode对象,其中键相同但值是下一个数组元素。这有效地创建了多个相同名称的子节点。

为了测试数组支持,让我们将profiles.js文件复制到profiles_with_arrays_and_functions.js,并要求它而不是profiles.js。Ryan Dahl 还推送到另一个 Github 帐户:joyent。所以让我们用 Github 帐户的数组更新他的个人资料:

module.exports = {
  ryan : {
           name: "Ryan Dahl",
           irc:"ryah",
           twitter:"ryah",
           github:["ry","joyent"],
           location:"San Francisco, USA",
           description: "Creator of node.js"
          },
//...rest of profiles...

现在,如果我们这样做:

profiles = buildXml(profiles, 'profiles');
console.log(profiles); // <-- show me the XML!

看一下输出,我们会发现 Ryan 有两个 Github XML 节点:

<?xml encoding='UTF-8'?>
<profiles>
<ryan>
<name>Ryan Dahl</name>
<irc>ryah</irc>
<twitter>ryah</twitter>
<github>ry</github>
<github>joyent</github>
<location>San Francisco, USA</location>
<description>Creator of node.js</description>
</ryan>
<!-- REST OF THE XML OUTPUT -->

我们的另一个变量isFuncnonObj条件语句内进行检查。我们用它来确定我们是应该只将对象属性的文本添加到我们的xml变量中,还是调用对象属性以获得其返回值。Bert 的 IRC、Twitter 和 Github 帐户都是一样的,所以让我们添加从他的 Github 值中提取 IRC 和 Twitter 值的方法:

//...prior profiles code.
bert : {
           name: "Bert Belder",
           irc:function () { return this.github; },
           twitter:function () { return this.github; },
           github:"piscisaureus",
           location:"Netherlands",
           description: "Windows support, overall contributor"
          },
//..rest of profiles code...

如果我们从对象构建 XML,然后使用xml2js将其转换回对象,这些属性不应再是函数,而应该是函数/方法的返回值:

xml2js.parseString(profiles, function (err, obj) {
  profiles = obj;
  console.log(profiles.bert);
});

输出将如下所示:

{ name: 'Bert Belder',
  irc: 'piscisaureus',
  twitter: 'piscisaureus',
  github: 'piscisaureus',
  location: 'Netherlands',
  description: 'Windows support, overall contributor' }

生成 XML 属性

在 XML 中,我们可以用父节点、子节点和文本节点来表示数据关系,也可以使用属性。如果我们想让我们的buildXml函数能够处理 XML 属性,我们需要一个约定来定义对象中的属性。在从 XML 转换为对象时,xml2js通过添加一个包含特殊@属性的对象来解释属性,该对象又包含属性的另一个子对象。通过在buildXml中实现相同的约定,我们可以使我们的代码与xml2js很好地配合。让我们取profiles_with_arrays_and_functions.js中的profiles对象,并进一步更新location属性如下:

module.exports = {
  ryan : {
		//ryans other keys here...
           location:{'@':{city: 'San Francisco',country: 'USA'}},
           description: 'Creator of node.js'
          },
  isaac : {
		//isaacs other keys here...
            location:{'@':{city: 'San Francisco',country: 'USA'}},
            description: 'Author of npm, core contributor'
           },
  bert : {
		//berts other keys here...
           location:{'@':{country: 'Netherlands'}},
           description: 'Windows support, overall contributor'
          },
  tj: {}, //<-- TJs keys
  felix: {}, //<-- Felix's keys
};

我们将其保存为profiles_with_attributes.js,并在xml_and_back_with_arrays_and_functions.js代码中更改profiles变量的require位置,保存为xml_and_back_with_attributes.js

var profiles = require('./profiles_with_attributes');

让我们编写另一个函数,应该放在buildXml函数内部来处理我们的属性:

function attributes(obj, key) {
    if (obj[key].hasOwnProperty("@")) {
     xml = xml.substr(0, xml.length – 1); //remove the “>” part of open tag

     Object.keys(obj[key]['@']).forEach(function (attrKey) {
        xml += ' ' + attrKey + '="' + obj[key]['@'][attrKey] + '"';
      });

     xml += ">"; // add the “>” back on

     delete obj[key]['@']; //remove the key so it isn't traversed as an object
    }
  }

我们的新attributes函数应该放在我们的buildXml函数内,并且将在traverse内部调用,就在我们将键的open标签变量添加到xml变量之后,以及在检查nonObj节点之前:

(function traverse(obj) {
  //...prior traverse function code...
  xml += open;
  attributes(obj, key);
  If (nonObj) {
  //rest of traverse function code...

我们将当前由我们的traverse函数处理的对象和键传递进去,检查obj的这个特定属性是否包含一个名为@的属性。我们还在隐式地检查我们当前对象键的值是否本身是一个对象,因为只有对象才有属性。

当前的属性@属性对应于当前标签。因此,如果找到一个@属性,我们会删除xml的最后一个字符(这将是一个右尖括号>),并循环遍历我们子对象(obj[key][@])的键,将每个键及其值添加到最后的open标签中,以便附加到xml,完成后重新添加右尖括号。如果我们将@对象留在profiles对象中,它将稍后被传回traverse函数,导致以下行为:

<@>
<city>San Francisco</city>
<country>USA</country>
</@>

我们不想要那样,所以我们最后删除了对象中的attributes子对象。在我们的buildXml函数下面,我们有以下代码:

profiles = buildXml(profiles, 'profiles').replace(/name/g, 'fullname');
console.log(profiles; //show me the xml!

这将把name键更改为fullname,并将我们的 XML 输出到控制台,呈现出带有属性的location标签。

<ryan>
<fullname>Ryan Dahl</fullname>
<irc>ryah</irc>
<twitter>ryah</twitter>
<github>ry</github>
<github>joyent</github>
<location city="San Francisco" country="USA">
</location>
<description>Creator of node.js</description>
</ryan>
<!-- rest of the XML output -->

文本值与属性声明并列

我们的属性解决方案揭示了另一个问题。没有办法让带属性的节点包含文本节点,因为我们将字符串类型转换为文本节点,但使用对象来声明属性。xml2js通过charkey属性解决了这个问题的敌意。通过以下代码,我们可以完全兼容xml2js

//previous code
      if (key === '#') { //explicit text
        xml += obj[key] + '\n';
        return;
      }
      xml += open;
      attributes(obj, key);
      if (nonObj) {
//rest of the code

现在这个困境已经解决,我们可以明确地添加包含文本节点的属性节点,就像这样:

//prior profiles
 tj : {
          name: "TJ Holowaychuk",
          irc:"tjholowaychuk",
          twitter:"tjholowaychuk",
          github:"visionmedia",
          location:{'@':{city: 'Victoria',country: 'Canada'},region: {'#' :'British Columbia','@':{type:'province'}}},
          description: "Author of express, jade and other popular modules"
          },
//rest of profiles

这导致:

<irc>tjholowaychuk</irc>
<twitter>tjholowaychuk</twitter>
<github>visionmedia</github>
<github s="special">
</github>
<location city="Victoria" country="Canada">
<region type="province">
British Columbia
</region>
</location>
<description>Author of express, jade and other popular modules</description>
</tj>

另请参阅

  • 在本章中讨论的将对象转换为 JSON 然后再转换回来

  • 本章讨论了通过 AJAX 进行浏览器-服务器传输

  • 本章讨论了使用真实数据:获取热门推文

通过 AJAX 进行浏览器-服务器传输

我们可以通过 AJAX 直接将新内容加载到页面中,而不是为每个内容请求加载新页面,从而增强用户体验。

在本示例中,我们将根据用户请求将序列化数据传输到浏览器,然后与我们的客户端数据进行交互。我们将在浏览器中实现一个配置文件查看器,该查看器以 JSON 或 XML 格式检索所选配置文件,并输出该配置文件的键值或父子节点。

准备工作

我们将继续使用我们的profiles.js对象模块(来自本章的前两个示例)。对于 XML 传递,我们还将从将对象转换为 XML 并再次转换示例中获取我们的buildXml函数,并将其转换为一个简单的模块(就像我们在上一个示例中对profiles对象所做的那样):

module.exports = function buildXml(rootObj, rootName) {
//..buildXml function code
}

我们将将此保存为buildXml.js并将其放在一个文件夹中,该文件夹中包含我们的profiles.js文件的副本,以及两个新创建的文件:server.jsindex.html

如何做...

让我们从我们的index.html文件开始。我们将快速实现一个粗略的布局,用于我们的个人资料查看器,包括一个带有两个select元素的form,一个用于输出格式化对象数据的div,以及一个用于呈现原始序列化数据的textarea元素。

<!doctype html>
<html>
<head>
<script src=http://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js>
</script>
<style>
#frm, #raw {display:block; float:left; width:210px}
#raw {height:150px; width:310px; margin-left:0.5em}
</style>
</head>
<body>
<form id=frm>
Profile: <select id=profiles>
		 <option></option>
		 </select> <br>
Format:<select id=formats>
		  <option value=json> JSON </option>
		  <option value=xml> XML </option>
		  </select><br> <br>
<div id=output></div>
</form>  
<textarea id=raw></textarea>
</body>
</html>

请注意,我们已经包含了 jQuery 以获得跨浏览器的好处,特别是在 AJAX 请求的领域。我们将很快在客户端脚本中使用 jQuery,但首先让我们制作我们的服务器。

对于我们的模块,我们将需要http,pathfs以及我们自定义的profilesbuildXml模块。为了使我们的代码工作,我们需要在我们的服务器中托管index.html,以防止跨域策略错误。

var http = require('http');
var fs = require('fs');
var path = require('path');
var profiles = require('./profiles');
var buildXml = require('./buildXml');

var index = fs.readFileSync('index.html');
var routes,
  mimes = {xml: "application/xml", json: "application/json"};

我们还定义了routesmimes变量,以便我们可以回答来自客户端的特定数据请求,并附上正确的Content-Type标头。我们将创建两个路由,一个将提供配置文件名称列表,另一个将指示对特定配置文件的请求。

routes = {
  'profiles': function (format) {
    return output(Object.keys(profiles), format);
  },
  '/profile': function (format, basename) {
    return output(profiles[basename], format, basename);
  }
};

我们刚刚在routes中提到的output函数应放置在routes对象上方,并且看起来像以下代码:

function output(content, format, rootNode) {
  if (!format || format === 'json') {
    return JSON.stringify(content);
  }
  if (format === 'xml') {
    return buildXml(content, rootNode);
  }
}

要完成我们的服务器,我们只需调用http.createServer并在回调中与我们的routes对象进行交互,在找不到路由的情况下输出index.html

http.createServer(function (request, response) {
  var dirname = path.dirname(request.url), 
    extname = path.extname(request.url), 
    basename = path.basename(request.url, extname); 
    extname = extname.replace('.',''); //remove period 

  response.setHeader("Content-Type", mimes[extname] || 'text/html');

  if (routes.hasOwnProperty(dirname)) {
    response.end(routesdirname);
    return;
  }
  if (routes.hasOwnProperty(basename)) {
    response.end(routesbasename);
    return;
  }
  response.end(index);
}).listen(8080);

最后,我们需要编写我们的客户端代码,以通过 AJAX 与我们的服务器进行交互,该代码应放置在我们的index.html文件的#raw文本区域的下方的脚本标签中,但在</body>标签的上方(以确保 HTML 元素在脚本执行之前已加载):

<script>
$.get('http://localhost:8080/profiles',
  function (profile_names) {
    $.each(profile_names, function (i, pname) {
      $('#profiles').append('<option>' + pname + '</option>');
    });
  }, 'json');
$('#formats, #profiles').change(function () {
  var format = $('#formats').val();
  $.get('http://localhost:8080/profile/' + $('#profiles').val() + '.' + format,
    function (profile, stat, jqXHR) {
      var cT = jqXHR.getResponseHeader('Content-Type');
      $('#raw').val(profile);
      $('#output').html('');
      if (cT === 'application/json') {
        $.each($.parseJSON(profile), function (k, v) {
          $('#output').append('<b>' + k + '</b> : ' + v + '<br>');
        });
        return;
      }

      if (cT === 'application/xml') {
        profile = jqXHR.responseXML.firstChild.childNodes;
        $.each(profile,
          function (k, v) {
            if (v && v.nodeType === 1) {
              $('#output').append('<b>' + v.tagName + '</b> : ' +
		   v.textContent + '<br>');
            }
          });

      }
    }, 'text');

});
</script>

它是如何工作的...

让我们从服务器开始。在我们的http.createServer回调中,我们设置了适当的标头,并检查routes对象是否具有指定的目录名。如果routes中存在目录名,我们将其作为函数调用,并传入basenameextname(我们使用extname来确定所需的格式)。在没有目录名匹配的情况下,我们检查是否存在与basename匹配的属性。如果有,我们调用它并传入扩展名(如果有)。如果这两个测试都不成立,我们只需输出我们的index.html文件的内容。

我们的两个路由是profiles/profile,后者有一个前导斜杠,对应于path.dirname返回路径的目录名的方式。我们的/profile路由旨在允许包含所请求的配置文件和格式的子路径。例如,http://localhost:8080/profile/ryan.json将以 JSON 格式返回 Ryan 的配置文件(如果未给出扩展名,则默认为 JSON 格式)。

profiles/profile方法都利用我们的自定义output函数,该函数使用format参数(最初在http.createServer回调中为extname)从传递给它的content生成 JSON(使用JSON.stringify)或 XML(使用我们自己的buildXml函数)。output还接受一个条件性的第三个参数,该参数传递给buildXml以定义生成的 XML 的rootNode

在客户端,我们要做的第一件事是调用 jQuery 的$.get方法获取http://localhost:8080/profiles。这会导致服务器调用route对象上的profiles方法。这将调用我们的output函数,并传入来自我们的profiles.js对象的顶级属性数组。由于我们没有在$.get中指定扩展名,output函数将默认为 JSON 格式,并将JSON.stringify的结果传递给response.end

回到客户端,我们在第一个$.get调用中的第三个参数是'json',这确保$.get将传入的数据解释为 JSON,并将其转换为对象。对象作为$.get的回调函数的第一个参数($.get的第二个参数)传递给我们命名为profile_names的函数。我们使用 jQuery 的$.each循环遍历profile_names,通过将 jQuery 的append方法应用于元素,并在循环$.each时将每个配置文件名称添加到<option>元素中,从而填充第一个select元素(#profiles)。

接下来,我们为我们的两个select元素应用一个监听器(change),其回调根据用户的选择组装一个 URL,并将此 URL 传递给另一个使用$.get的 AJAX 请求。

这次在服务器端,调用/profile route方法,将对应的配置文件从我们的profiles对象传递给output。此属性将包含所请求个人的配置文件信息的对象。

在我们的第二个$.get调用中,我们将第三个参数设置为'text'。这将强制 jQuery 不自动将传入的数据解释为 JSON 或 XML。这给了我们更多的控制,并使得更容易将原始数据输出到textarea中。在$.get回调中,我们使用jqXHR参数来确定Content-Type,以查看我们是否有 JSON 或 XML。我们根据其类型(Object 或 XMLObject)循环返回的数据,并将其附加到我们的#output div中。

还有更多...

我们还可以在浏览器中将我们的对象转换为 JSON 和 XML,然后将它们发送到服务器,我们可以再次将它们作为对象进行交互。

从客户端发送序列化数据到服务器

让我们扩展我们的示例,使用我们的浏览器界面将新配置文件添加到服务器上的profiles对象中。

index.html开始(我们将其复制到add_profile_index.html - 我们还将server.js复制到add_profile_server.js),让我们添加一个名为#add的表单,并对其进行样式设置。这是表单:

<form id=add>
<div><label>profile name</label><input name="profileName"></div>
<div><label>name</label><input name="name"></div>
<div><label>irc</label><input name="irc"></div>
<div><label>twitter</label><input name="twitter"></div>
<div><label>github</label><input name="github"></div>
<div><label>location</label><input name="location"></div>
<div><label>description</label><input name="description"></div>
<div><button>Add</button></div>
</form>

还有一些额外的样式:

<style>
#frm, #raw {display:block; float:left; width:210px}
#raw {height:150px; width:310px; margin-left:0.5em}
#add {display:block; float:left; margin-left:1.5em}
#add div {display:table-row}
#add label {float:left; width:5.5em}
div button {float:right}
</style>

我们将在客户端使用我们的buildXml函数(我们在将对象转换为 XML 并再次转换回来中创建了buildXml)。这个函数已经在我们的服务器上可用,所以我们将它转换为字符串,并在服务器启动时提供一个路由供客户端访问:

var index = fs.readFileSync('add_profile_index.html');
var buildXmljs = buildXml.toString();
var routes,
  mimes = {
   js: "application/JavaScript",
   json: "application/json",
   xml: "application/xml"
  };
routes = {
  'profiles': function (format) {
    return output(Object.keys(profiles), format);
  },
  '/profile': function (format, basename) {
    return output(profiles[basename], format, basename);
  },
  'buildXml' : function(ext) {
    if (ext === 'js') { return buildXmljs; }
  }
};

我们还更新了我们的mimes对象,准备交付application/javascript Content-Type,并修改了索引变量以使用我们的新的add_profile_index.html文件。回到客户端代码,我们通过在头部部分包含另一个<script>标签来获取我们的buildXml函数:

<script src=buildXml.js></script>

我们将我们对服务器的初始$.get调用(用于获取select元素的所有配置文件名称)包装在一个名为load的函数中。这使我们能够在添加配置文件后动态重新加载配置文件名称:

function load() {
$.get('http://localhost:8080/profiles',
  function (profile_names) {
    $.each(profile_names, function (i, pname) {
      $('#profiles').append('<option>' + pname + '</option>');
    });

  }, 'json');
}
load();

现在我们为#add表单定义一个处理程序:

$('#add').submit(function(e) {
  var output, obj = {}, format = $('#formats').val();
  e.preventDefault();
  $.each($(this).serializeArray(), function(i,nameValPair) {
    obj[nameValPair.name] = nameValPair.value; //form an object
  });  
  output = (format === 'json') ? JSON.stringify(obj) : buildXml(obj,'xml');

  $.ajax({ type: 'POST', url: '/', data: output,
    contentrendingTopicsype: 'application/' + format, dataType: 'text',
    success: function(response) {
      $('#raw').val(response);
      $('#profiles').html('<option></option>');
      load();
    }
  });
}); 

我们的处理程序从表单输入构建一个对象,将其序列化为指定格式。它使用jQuery.ajax将序列化数据发送到我们的服务器,然后重新加载配置文件。在我们的服务器上,我们将编写一个处理 POST 请求的函数:

function addProfile(request,cb) {
  var newProf, profileName, pD = ''; //post data
  request
    .on('data', function (chunk) { pD += chunk; })
    .on('end',function() {
      var contentrendingTopicsype = request.headers['content-type'];
      if (contentrendingTopicsype === 'application/json') {
        newProf = JSON.parse(pD);
      }

      if (contentrendingTopicsype === 'application/xml') {
        xml2js.parseString(pD, function(err,obj) {
          newProf = obj;  
        });
      }
      profileName = newProf.profileName;
      profiles[profileName] = newProf;    
      delete profiles[profileName].profileName;
      cb(output(profiles[profileName],
        contentrendingTopicsype.replace('application/', ''), profileName));
});
}

为了使我们的新addProfile函数工作,我们需要包含xml2js模块,该模块用于将序列化的 XML 转换回对象。因此,除了我们所有的初始变量,我们还添加了以下内容:

var xml2js = new (require('xml2js')).Parser();

在第二章的第一个食谱中,探索 HTTP 对象,在处理 POST 数据时,addProfile将所有传入的数据汇编在一起。在end事件中,我们使用适合其类型的方法将序列化数据转换为对象。我们将这个对象添加到我们的profiles对象中,使用profileName属性作为子对象的键。一旦我们添加了对象,我们就会delete冗余的profileName属性。

为了将数据返回给客户端,addProfile函数调用回调(cb)参数,传入我们自定义的output函数,该函数将根据指定的格式返回序列化数据(通过在Content-Type头上使用replace确定)。

我们像这样在我们的服务器中包含我们的addProfile函数:

http.createServer(function (request, response) {
//initial server variables...
  if (request.method === 'POST') {
    addProfile(request, function(output) {
      response.end(output);
    });
    return;
  }
//..rest of the server code (GET handling..)

在我们的addProfile回调函数中,我们只需使用从output函数返回的数据结束响应,通过output参数访问这个数据,这个参数在addProfile回调中定义。新的配置文件只保存在操作内存中,所以在服务器重新启动时会丢失。如果我们要将这些数据存储在磁盘上,理想情况下我们会希望将其保存在数据库中,这将在下一章与数据库交互中讨论。

另请参阅

  • 设置路由在第一章中讨论,制作 Web 服务器

  • 处理 POST 数据在第二章中讨论,探索 HTTP 对象

  • 将对象转换为 JSON 然后再转换回来在本章中讨论

  • 将对象转换为 XML 然后再转换回来在本章中讨论

处理真实数据:获取热门推文

许多在线实体将他们的响应数据格式化为 JSON 和 XML,以在他们的应用程序编程接口(API)中向第三方开发人员公开相关信息,这些开发人员随后可以将这些数据集成到他们的应用程序中。

一个这样的在线实体是 Twitter。在这个食谱中,我们将制作一个命令行应用程序,向 Twitter 的 REST 服务发出两个请求。第一个将检索 Twitter 上当前最受欢迎的话题,第二个将返回关于 Twitter 上最热门话题的最新推文。

准备工作

让我们创建一个文件,命名为twitter_trends.js。我们可能还希望安装第三方colors模块,使我们的输出更加美观:

npm install colors

如何做...

我们需要http模块来进行请求,并且需要colors模块来在控制台输出中添加一些颜色:

var http = require('http');
var colors = require('colors');

我们将在另一个 GET 请求内部进行 GET 请求。在这些请求之间,我们将处理 JSON 数据,要么传递到后续请求,要么输出到控制台。为了遵循 DRY(不要重复自己)的精神,并演示如何避免意大利面代码,我们将抽象出我们的 GET 请求和 JSON 处理到一个名为makeCall的函数中。

function makeCall(urlOpts, cb) {
  http.get(urlOpts, function (response) { //make a call to the twitter API  
    trendingTopics.jsonHandler(response, cb);
  }).on('error', function (e) {
    console.log("Connection Error: " + e.message);
  });
}
}

注意trendingTopics及其jsonHandler方法的神秘出现。trendingTopics是一个将为我们的 Twitter 交互提供所有设置和方法的对象。jsonHandlertrendingTopics对象上的一个方法,用于接收响应流并将 JSON 转换为对象。

我们需要为我们对趋势和推文 API 的调用设置选项,以及一些与 Twitter 交互相关的功能。因此,在我们的makeCall函数之上,我们将创建trendingTopics对象,如下所示:

var trendingTopics = module.exports = {
  trends: {
    urlOpts: {
      host: 'api.twitter.com',
      path: '/1/trends/1.json', //1.json provides global trends,
      headers: {'User-Agent': 'Node Cookbook: Twitter Trends'}
    }
  },
  tweets: {
    maxResults: 3, //twitter applies this very loosely for the "mixed" type
    resultsType: 'realtime', //choice of mixed, popular or realtime
    language: 'en', //ISO 639-1 code
    urlOpts: {
      host: 'search.twitter.com',
      headers: {'User-Agent': 'Node Cookbook: Twitter Trends'}
    }
  },
  jsonHandler: function (response, cb) {
    var json = '';
    response.setEncoding('utf8');
    if (response.statusCode === 200) {
      response.on('data', function (chunk) {
        json += chunk;
      }).on('end', function () {
        cb(JSON.parse(json));
      });
    } else {
      throw ("Server Returned statusCode error: " + response.statusCode);
    }
  },
  tweetPath: function (q) {
    var p = '/search.json?lang=' + this.tweets.language + '&q=' + q +
        '&rpp=' + this.tweets.maxResults + '&include_entities=true' +
        '&with_twitter_user_id=true&result_type=' +
        this.tweets.resultsType;
    this.tweets.urlOpts.path = p;
  }
};

在创建trendingTopics变量时,我们还将对象转换为模块,同时将其加载到module.exports中。看看我们如何在还有更多...部分中使用它。

在我们的trendingTopics对象中,我们有trendstweets对象以及两个方法:jsonHandlertweetPath

最后,我们将调用我们的makeCall函数来请求来自 Twitter 趋势 API 的全球热门趋势,将返回的 JSON 转换为对象,并使用该对象来确定请求关于最热门话题的推文的路径,使用另一个嵌入的makeCall调用。

makeCall(trendingTopics.trends.urlOpts, function (trendsArr) {
  trendingTopics.tweetPath(trendsArr[0].trends[0].query);
  makeCall(trendingTopics.tweets.urlOpts, function (tweetsObj) {
    tweetsObj.results.forEach(function (tweet) {
      console.log("\n" + tweet.from_user.yellow.bold + ': ' + tweet.text);
    });
  });
});

工作原理...

让我们来分析一下trendingTopics对象。trendstweets提供了与 Twitter API 相关的设置。对于trends来说,这只是一个 URL 选项对象,稍后将传递给http.get。在tweets对象中,我们有 URL 对象以及一些其他属性,涉及我们可以在对 Twitter 搜索 API 的 REST 调用中设置的选项。

Twitter API 和 User-Agent 头

请注意,我们已经费心设置了User-Agent头。这是由于 Twitter API 政策,对缺少User-Agent字符串的惩罚是降低速率限制。

我们在trendingTopics对象上的jsonHandler方法接受responsecb(回调)参数。trendingTopics.jsonHandler使用http.get调用中的response对象来捕获传入数据流到一个变量(json)中。当流结束时,使用response上的end事件监听器来检测,cb调用转换后的 JSON 作为参数。trendingTopics.jsonHandler的回调找到了它的方式进入makeCall的回调。

makeCall抽象地结合了 GET 请求和 JSON 处理,并提供了一个带有单个参数的回调函数,该参数是 Twitter 返回的解析 JSON 数据(在本例中,它是一个对象数组)。

在外部的makeCall调用中,我们将参数命名为trendsArr,因为 Twitter 将其 JSON 数据返回在一个数组包装器中。我们使用trendsArr来定位 Twitter 的顶级趋势的查询片段表示,并将其传递给我们的trendingTopics对象的最终方法:trendingTopics.tweetPath。该方法以查询片段(q)作为其单个参数。然后,它使用此参数以及trendingTopics.tweets中的选项来构建最终的 Search API 路径。它将此路径注入到trendingTopics.tweetsurlOpts对象中,然后传递到内部的makeCall调用中。

在内部的makeCall调用中,我们将参数命名为tweetsArr。这是一个包含推文数据的对象数组,是对前一个对 Trend API 的调用中返回的 Twitter 搜索 API 的查询的顶级趋势的响应。我们使用可变的forEach(ES5)循环函数循环遍历数组,处理通过循环传递的每个元素作为tweet

tweetsArr数组中包含很多数据,如时间信息,转发次数等。但是,我们只对推文的内容和发推者感兴趣。因此,我们将每个tweetfrom_usertext属性记录到控制台上:

Twitter API 和 User-Agent 头

这也是colors模块派上用场的地方,因为在console.log中我们有tweet.from_user.yellow.bold。颜色不是 Twitter 返回的对象的属性,而是colors模块执行的一些技巧,提供了一个易于使用的界面来为控制台文本设置样式。

还有更多...

让我们来看看如何使用基于 XML 的服务。

将 Google 热门趋势与 Twitter 推文进行交叉引用

可以注意到,热门推文往往受到 Twitter 社区内部产生的时尚影响。Google 热门趋势是另一个热门信息的来源。它提供最热门搜索的每小时更新。

我们可以扩展我们的示例来访问和处理 Google 的热门趋势 XML 原子源,并将顶部结果集成到我们的 Twitter 搜索 API 请求中。为此,让我们创建一个名为google_trends.twitter.js的新文件。将 XML 数据作为 JavaScript 对象处理很好,因此我们将在本章的将对象转换为 XML,然后再次转换为对象配方中引入非核心的xml2js,以及http,colors和我们自己的trendingTopics模块。

var http = require('http');
var xml2js = new (require('xml2js')).Parser(); 
var colors = require('colors'); //for prettifying the console output
var trendingTopics = require('./twitter_trends'); //load trendingTopics obj

现在我们将通过使用 EcmaScript 5 的Object.create方法从中继承来扩展我们的trendingTopics对象。

var hotTrends = Object.create(trendingTopics, {trends: {value: {urlOpts: {
    host: 'www.google.com',
    path: '/trends/hottrends/atom/hourly',
    headers: {'User-Agent': 'Node Cookbook: Twitter Trends'}
  }
    }}});

hotTrends.xmlHandler = function (response, cb) {
  var hotTrendsfeed = '';
  response.on('data', function (chunk) {
    hotTrendsfeed += chunk;
  }).on('end', function () {
    xml2js.parseString(hotTrendsfeed, function (err, obj) {
      if (err) { throw (err.message); }
      xml2js.parseString(obj.entry.content['#'],
	function (err, obj) {
        if (err) { throw (err.message); }
        cb(encodeURIComponent(obj.li[0].span.a['#']));
      });
    });
  });
};

我们声明了一个名为hotTrends的变量,并使用Object.create来初始化一个trendingTopics的实例,通过属性声明对象(Object.create的第二个参数)重新实例化了trends属性。这意味着trends不再是一个继承属性,而是属于hotTrends,当将其添加到新的hotTrends对象时,我们没有覆盖trendingTopics中的trends属性。

然后我们添加了一个新的方法:hotTrends.xmlHandler。这将所有传入的块组合成hotTrendsfeed变量。一旦流结束,它会调用xml2js.parseString并将hotTrendsfeed中包含的 XML 传递给它。在第一个parseString方法的回调中,我们再次调用xml2js.parseString。为什么?因为我们必须解析两组 XML,或者说一组 XML 和一组格式良好的 HTML。(如果我们前往www.google.com/trends/hottrends/atom/hourly,它将被呈现为 HTML。如果我们查看源代码,然后会看到一个包含嵌入式 HTML 内容的 XML 文档。)

Google 的热门趋势 XML 源以 HTML 的形式包含在其content XML 节点中。

HTML 被包裹在CDATA部分中,因此第一次不会被xml2js解析。因此,我们创建了一个新的Parser,然后通过obj.entry.content['#']解析 HTML。

最后,hotTrends.xmlHandler方法在第二个嵌入的xml2js回调中完成,其中执行了它自己的回调参数(cb),生成了从 HTML 中的顶部列表项元素生成的查询片段。

现在我们只需要对makeCall进行一些调整:

function makeCall(urlOpts, handler, cb) {
  http.get(urlOpts, function (response) { //make a call to the twitter api  
    handler(response, cb);
  }).on('error', function (e) {
    console.log("Connection Error: " + e.message);
  });
}

makeCall(hotTrends.trends.urlOpts, hotTrends.xmlHandler, function (query) {
  hotTrends.tweetPath(query);
  makeCall(hotTrends.tweets.urlOpts, hotTrends.jsonHandler, function (tweetsObj) {
    tweetsObj.results.forEach(function (tweet) {
      console.log("\n" + tweet.from_user.yellow.bold + ': ' + tweet.text);
    });
  });
});

由于我们现在处理 JSON 和 XML,我们在makeCall函数声明中添加了另一个参数:handlerhandler参数允许我们指定是使用继承的jsonHander方法还是我们补充的xmlHandler方法。

当我们调用外部的makeCall时,我们传入hotTrends.xmlHandler,将参数命名为query。这是因为我们直接传入了由xmlHandler生成的查询片段,而不是从 Twitter 返回的数组。这直接传递到tweetPath方法中,因此更新了hotTrends.tweets.urlOpts对象的path属性。

我们将hotTrends.tweets.urlOpts传递给第二个makeCall,这次将handler参数设置为hotTrends.jsonHandler

第二个makeCall回调的行为与主要的配方完全相同。它将推文输出到控制台。但是这次,它基于 Google 热门趋势输出推文。

另请参阅

  • *在第二章中讨论了使用 Node 作为 HTTP 客户端,探索 HTTP 对象

  • 在本章中讨论的将对象转换为 JSON,然后再次转换为对象*

  • 在本章中讨论的将对象转换为 XML,然后再次转换为对象*

第四章:与数据库交互

在本章中,我们将涵盖:

  • 写入 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 存储和检索数据 在第四章中讨论

第六章:使用 Express 加速开发

在本章中,我们将涵盖:

  • 生成 Express 脚手架

  • 定义和应用环境

  • 动态路由

  • Express 中的模板

  • Express 中的 CSS 引擎

  • 初始化和使用会话

  • 创建一个 Express Web 应用程序

介绍

尽管 Node 的 HTTP 模块非常出色,但 Express 重新打包和简化了其功能,为我们提供了一个流畅的接口,几乎没有摩擦的快速 Web 开发。

在本章中,我们将从生成一个普通的 Express 项目基础开始,到一个完整的 Express Web 应用程序基础,MongoDB 提供后端数据支持。

提示

Express 2 到 Express 3

在本章中,有一些有用的提示框,比如这个,演示了如何将代码从 Express 2 迁移到 Express 3。支持代码文件包含了 Express 2 和 3 的代码(3 被注释掉)。代码文件可以从www.packtpub.com/support下载。

生成 Express 脚手架

Express 既可以作为一个 Node 模块,也可以作为一个命令行可执行文件。当我们从命令行运行express时,它会为我们生成一个项目骨架,加快准备过程。

准备工作

我们需要使用-g标志(全局安装)来安装express,以便从任何目录运行express可执行文件。

sudo npm -g install express 

我们使用sudo来确保我们获得全局安装的权限。这在 Windows 下不适用。

如何做...

首先,我们决定我们应用的名称。让我们称之为nca(Node Cookbook App),然后简单地执行:

express nca 

这将在一个名为nca的新目录下生成所有项目文件。在我们运行应用之前,我们必须确保所有依赖项都已安装。我们可以在nca/package.json中找到应用的依赖项:

{
    "name": "application-name"
  , "version": "0.0.1"
  , "private": true
  , "dependencies": {
      "express": "2.5.8"
    , "jade": ">= 0.0.1"
  }
}

为了可移植性,重要的是在project文件夹中安装相关模块。为了实现这一点,我们只需在命令行中cd进入nca目录,然后输入:

npm install 

这将在我们的project文件夹中创建一个新的node_modules目录,其中包含所有的依赖项。

它是如何工作的...

当我们运行express可执行文件时,它会创建一个适合 Express 开发的文件夹结构。在项目根目录中,我们有app.jspackage.json文件。

package.json是由 CommonJS 组(一个 Javascript 标准社区)建立的约定,并已成为描述 Node 中模块和应用的已建立方法。

npm install命令从package.json中解析依赖项,在node_modules文件夹中本地安装它们。

这很重要,因为它确保了稳定性。Node 的require函数在搜索父目录之前会在当前工作目录中寻找node_modules文件夹。如果我们在父目录中升级任何模块,我们的项目将继续使用构建时的相同版本。本地安装模块允许我们将项目与其依赖项一起分发。

app.js文件是我们项目的样板。我们用以下命令运行我们的应用:

node app.js 

express可执行文件将三个子目录添加到项目文件夹中:public, routesviews

publicapp.js传递给express.static方法的默认文件夹,所有静态文件都放在这里。它包含images, javascriptsstylesheets文件夹,每个文件夹都有自己明显的目的。

routes文件夹包含index.js,被app.js所需。为了定义我们的路由,我们将它们推送到 Node 的exports对象上(我们将在第九章中学到更多关于编写自己的 Node 模块)。使用routes/index.js有助于避免app.js中的混乱,并将服务器代码与路由代码分开。这样我们可以纯粹地专注于我们的服务器,或者纯粹地专注于我们的路由。

最后,views包含模板文件,这可以真正帮助加速开发。我们将在Express 中的模板中了解如何处理视图。

还有更多...

让我们花一些时间深入了解我们生成的项目。

解析 app.js

让我们来看一下我们生成的app.js文件:

 var express = require('express')
  , routes = require('./routes')
var app = module.exports = express.createServer();
// Configuration
app.configure(function(){
  app.set('views', __dirname + '/views');
  app.set('view engine', 'jade');
  app.use(express.bodyParser());
  app.use(express.methodOverride());
  app.use(app.router);
  app.use(express.static(__dirname + '/public'));
});

app.configure('development', function(){
  app.use(express.errorHandler({ dumpExceptions: true, showStack: true }));
});

app.configure('production', function(){
  app.use(express.errorHandler());
});

// Routes

app.get('/', routes.index);

app.listen(3000, function(){

  console.log("Express server listening on port %d in %s mode", app.address().port, app.settings.env);

});

app变量保存了express.createServer的结果,这实质上是一个增强的http.createServer

configure方法被调用了三次:一次用于全局设置,一次用于生产环境,一次用于开发环境。我们将在下一个示例中更详细地查看生产环境和开发环境。

在全局configure回调中,设置了默认的视图目录(views)和引擎(jade),并告诉app使用“express.bodyParser, express.methodOverride, app.router”和express.static中间件。

bodyParser在第二章探索 HTTP 对象的第一个示例的还有更多..部分中简要出现,以connect.bodyParser的形式。

Express 包含所有标准的 Connect 中间件,并兼容附加的 Connect 中间件。因此,在 Express 项目中,使用“express.bodyParser”加载bodyParser。bodyParser 使我们能够访问从客户端发送的任何数据(例如在 POST 请求中)。

methodOverride允许我们使用名为_method的隐藏输入元素从浏览器表单中进行伪DELETEPUT请求。例如:

<input type=hidden name='_method' value='DELETE'>

在超文本传输协议文档中定义了许多 HTTP 方法(参见www.w3.org/Protocols/rfc2616/rfc2616-sec9.html))。然而,浏览器通常只支持 GET 和 POST,其他方法留给特定客户端支持。Express 通过使用隐藏输入来模拟DELETE请求来解决浏览器支持不足的问题,同时还支持来自支持该方法的客户端的真实 DELETE 请求。

app.router包含了所有定义的路由(传递给app.get, app.post等)。路由本身就是中间件。如果没有将app.router传递给app.use,则路由将自动附加到中间件堆栈。但是,通过手动包含,我们可以在app.router中间件之后放置其他中间件。

中间件通常构造如下:

function (req, res, next) {
	//do stuff
	next();
}

next参数是一种回调机制,它加载任何随后的中间件。因此,当app.router位于express.static之上时,客户端访问的任何动态路由都不会不必要地触发静态服务器去寻找不存在的文件,除非这些路由调用next(或者可以将next作为“req: req.next()”的方法调用)。有关中间件的更多信息,请参阅www.expressjs.com/guide.html#middleware

查看 routes/index.js

app.js, routes/index.js中加载了一个require:

, routes = require('./routes')

请注意,index没有指定,但是如果将目录传递给require,Node 将自动查找index.js。让我们来看一下:

exports.index = function(req, res){
  res.render('index', { title: 'Express' })
};

index推送到exports对象中,使其在app.js中作为routes.index可用,并将其传递给app.get如下:

app.get('/', routes.index);

routes.index函数应该看起来很熟悉。它遵循了http.createServer回调的模式,但是特定于路由。请求(req)和响应(res)参数由 Express 增强。我们将在接下来的示例中详细了解这些内容。该函数本身只是调用res.render方法,该方法从views/index.jade加载模板,将title作为变量传递,然后将生成的内容输出到客户端。

另请参阅

  • 在本章中讨论的定义和应用环境

  • 在本章中讨论的动态路由

  • 在本章中讨论的Express 中的模板

定义和应用环境

开发和生产代码有不同的要求。例如,在开发过程中,我们很可能希望向客户端输出详细的错误信息,以进行调试。在生产环境中,我们通过尽可能少地暴露内部信息来保护自己免受机会主义性的利用。

Express 通过app.configure来满足这些差异,它允许我们定义具有特定设置的环境。

准备工作

我们需要从上一个配方中获取我们的项目文件夹(nca)。

如何做...

让我们来看看预配置的环境:

app.configure('development', function(){
  app.use(express.errorHandler({ dumpExceptions: true, showStack: true }));
});

app.configure('production', function(){
  app.use(express.errorHandler());
});

生成的文件为每个环境定义了定制的错误报告级别。让我们为我们的生产服务器添加缓存,这在开发中可能会成为障碍。

我们使用express.staticCache来实现这一点。但是,它必须在express.static之前调用,所以我们将express.static从全局configure中移动到开发和生产环境中,并在生产环境中加入staticCache,如下所示:

app.configure(function(){
  app.set('views', __dirname + '/views');
  app.set('view engine', 'jade');
  app.use(express.bodyParser());
  app.use(express.methodOverride());
  app.use(app.router);
});

app.configure('development', function(){
  app.use(express.static(__dirname + '/public'));
  app.use(express.errorHandler({ dumpExceptions: true, showStack: true }));
});

app.configure('production', function(){
  app.use(express.staticCache());
  app.use(express.static(__dirname + '/public'));
  app.use(express.errorHandler({dumpExceptions: true}));
});

我们还为生产errorHandler设置了dumpExceptionstrue。这将使我们能够快速识别一旦启动我们的应用程序可能出现的任何问题。

要使用一个环境,我们在执行node时在命令行上设置特殊的NODE_ENV变量:

NODE_ENV=production node app.js 

或者在 Windows 上:

set NODE_ENV=production
node app.js 

开发环境是默认的,因此无需使用NODE_ENV来设置它。

它是如何工作的...

Express 为我们提供了一个非常方便的方法来分离我们的工作流程。我们所要做的就是传入我们的环境名称和特定设置。

在内部,Express 将使用process.env来确定NODE_ENV变量,检查是否与任何定义的环境匹配。如果未设置NODE_ENV,Express 默认加载开发环境。

还有更多...

让我们来看看一些管理我们环境的方法。

设置其他环境

我们的工作流程中可能有其他阶段,可以从特定设置中受益。例如,我们可能有一个分阶段,在这个阶段,我们在开发机器上尽可能模拟生产环境,以进行测试。

例如,如果我们的生产服务器要求我们在特定端口上运行进程(比如端口 80),而我们在开发服务器上无法实现(例如如果我们没有 root 权限),我们可以添加一个分阶段环境,并在生产环境中设置一个只在生产环境中设置为80port变量。

参见第十章,“上线”,了解如何安全地在端口 80 上运行 Node 的信息。

让我们按照以下方式在开发环境下添加分阶段环境:

app.configure('development', function(){
  app.use(express.errorHandler({ dumpExceptions: true, showStack: true }));
});

//our extra staging environment
app.configure('staging', function(){
  app.use(express.errorHandler({dumpExceptions: true}));
});

现在我们将添加端口逻辑,如下面的代码所示:

var port;
app.configure('production', function(){
  port = 80;
  app.use(express.errorHandler({dumpExceptions: true}));
});

// Routes
app.get('/', routes.index);

app.listen(port || 3000);

因此我们的port是根据环境设置的,如果port为空,我们默认为3000

我们可以用以下方式初始化我们的生产服务器:

sudo NODE_ENV=production node app.js 

或者对于 Windows:

set NODE_ENV=production
node app.js 

当尝试使用NODE_ENV设置为production运行服务器时,如果收到TypeError: Cannot read property 'port' of null,很可能是端口 80 上已经运行了一个服务。我们需要停止这个服务以测试我们的代码。例如,如果 Apache 在我们的系统上运行,它可能是通过端口80进行托管。我们可以使用sudo apachectl -k stop(或者在 Windows 上使用net stop apache2.2)来停止 Apache。

永久更改 NODE_ENV

如果我们处于一个分阶段的过程中,我们可能不希望每次加载我们的应用程序时都要输入NODE_ENV=staging。同样的情况也适用于生产环境。虽然服务器启动的次数会少得多,但我们必须记得在重新启动时设置NODE_ENV

我们可以在类 Unix 系统(Linux 或 Max OS X)上使用export shell 命令来简化操作,如下所示:

export NODE_ENV=staging 

这只在我们的终端打开时设置NODE_ENV。要使其永久生效,我们将这行添加到我们的主目录的rc文件中。rc文件的名称取决于 shell。对于 bash,它位于~/.bashrc(其中~是主文件夹)。其他 shell,如shksh,将是~/.shrc, ~/.kshrc等。

要永久设置NODE_ENV,我们可以使用:

echo -e "export NODE_ENV=staging\n" >> ~/.bashrc 

其中,staging 是我们期望的环境,bash 是我们的 shell。

在 Windows 中,我们使用setsetx:

set NODE_ENV=staging
setx NODE_ENV=staging 

set立即生效,但一旦命令提示符关闭就会丢失。setx永久应用,但直到我们打开一个新的命令提示符,所以我们两者都使用。

另请参阅

  • 生成 Express 脚手架在本章中讨论

  • 部署到服务器环境在第十章中讨论,上线

  • 制作 Express Web 应用程序在本章中讨论

  • 初始化和使用会话在本章中讨论

动态路由

在本烹饪书的第一个食谱中,设置路由,我们探讨了在 Node 中设置路由的各种方法。Express 提供了一个远远优越且非常强大的路由接口,我们将在本食谱中探讨。

准备工作

我们将使用我们的nca文件夹。

如何做...

假设我们想为一个名为 Mr Page 的虚构角色添加一个页面。我们将路由命名为page,因此在app.jsroutes部分中,我们添加以下代码:

app.get('/page', function (req, res) {
  res.send('Hello I am Mr Page');
});

我们还可以定义灵活的路由,并使用req.params来获取请求的路由,如下所示:

app.get('/:page', function (req, res) {
  res.send('Welcome to the ' + req.params.page + ' page');
});

在开发过程中,直接将回调函数放入app.get是可以的,但为了使app.js更整洁,让我们将回调函数从routes/index.js中加载,如下所示:

exports.index = function(req, res){
  res.render('index', { title: 'Express' })
};

exports.mrpage =  function (req, res) {
  res.send('Hello I am Mr Page');
};

exports.anypage = function (req, res) {
  res.send('Welcome to the ' + req.params.page + ' page');
};

然后在我们的app.js文件中,我们的路由变成了:

// Routes
app.get('/', routes.index);
app.get('/page', routes.mrpage);
app.get('/:page', routes.anypage);

它是如何工作的...

我们使用app.get创建了/page路由。然后在app.get的回调中概述我们希望如何响应该路由。在我们的示例中,我们使用res.send(增强的res.write)来输出简单的文本。这是我们不灵活的动态路由。

Express 还提供了使用占位符的灵活路由功能。在主要的示例中,我们定义了一个:page占位符。当请求填充占位符时(例如,/anyPageYouLike),占位符的实现将根据其名称添加到req.params中。因此,在这种情况下,req.params.page将保存/anyPageYouLike

当用户加载localhost:3000/page时,他们会看到Hello I am Mr Page,当他们访问localhost:3000/absolutelyAnythingElse时,他们会得到Welcome to the absolutelyAnythingElse page

还有更多...

Express 路由还可以做哪些其他事情?

路由验证

我们可以使用正则表达式语法的部分来限制灵活路由到特定的字符范围,如下所示:

app.get('/:page([a-zA-Z]+)', routes.anypage);

我们传递一个字符匹配,[a-zA-Z]以及一个加号(+)。这将匹配一个或多个字符。因此,我们将我们的:page参数限制为仅包含字母。

因此,http://localhost:3000/moo将给出Welcome to the moo page,而http://localhost:3000/moo1将返回404错误。

可选路由

我们还可以使用问号(?)来定义可选路由:

app.get('/:page/:admin?', routes.anypageAdmin);

我们将把这个放在我们的app.js文件中,在我们定义的其他路由下面。

我们在routes/index.js中的anypageAdmin函数可能是这样的:

exports.anypageAdmin = function (req, res) {
  var admin = req.params.admin
  if (admin) {
    if (['add','delete'].indexOf(admin) !== -1) {
      res.send('So you want to ' + req.params.admin +  ' ' + req.params.page + '?');
      return;
    }
    res.send(404);
  }
}

我们检查:admin占位符是否存在。如果路由满足它,我们验证它是否被允许(添加或删除),并发送一个定制的响应。如果路由不被允许,我们发送一个404错误。

虽然查询通配符(?)可能适用于许多类似的路由,但如果我们只有我们的adddelete路由,并且没有可能以后添加更多路由,我们可以以更简洁的方式实现这个功能。

app.js中,我们可以放置:

app.get('/:page/:admin((add|delete))', routes.anypageAdmin);

index/routes.js中:

exports.anypageAdmin = function (req, res) {
  res.send('So you want to ' + req.params.admin +  ' ' + req.params.page + '?');
}

星号通配符

我们可以使用星号(*)作为通配符来进行一般匹配。例如,让我们添加以下路由:

app.get('/:page/*', routes.anypage);

并将routes.anypage更改为以下内容:

exports.anypage = function (req, res) {

  var subpage = req.params[0],
    parentPage = subpage ? ' of the ' + req.params.page + ' page' : '';

  res.send('Welcome to the ' +
    (subpage || req.params.page) + ' page' + parentPage);

};

现在,如果我们访问localhost:3000/foo/bar,我们会看到欢迎来到 foo 页面的 bar 页面,但如果我们只访问localhost:3000/foo,我们会看到欢迎来到 foo 页面

我们还可以稍微疯狂一点,将其应用到 Mr Page 的路由上,如下所示:

app.get('/*page*', routes.mrpage);

现在,任何包含单词page的路由都将收到Mr Page的消息。

另请参阅

  • 在第一章中讨论的设置路由器,制作 Web 服务器

  • 在本章中讨论的制作 Express Web 应用

  • 在本章中讨论的 Express 中的模板

在 Express 中使用模板

Express 框架的一个基本部分是其使用视图。视图只是保存模板代码的文件。Express 帮助我们将代码分离为操作上不同的关注点。我们在app.js中有服务器代码,在routes/index.js中有特定于路由的功能,然后我们在views文件夹中有我们的输出生成逻辑。模板语言提供了定义动态逻辑驱动内容的基础,模板(或视图)引擎将我们的逻辑转换为最终提供给用户的 HTML。在这个示例中,我们将使用 Express 的默认视图引擎 Jade 来处理和呈现一些数据。

注意

还有更多..部分,我们将了解如何更改视图引擎。

可以在www.github.com/visionmedia/express/wiki找到支持的模板引擎列表。可以在paularmstrong.github.com/node-templates/找到各种模板引擎的比较。

准备工作

对于我们的数据,我们将使用我们在第三章中创建的profiles.js对象。我们需要将其复制到nca文件夹的根目录中。

如何做...

让我们保持简单,并删除我们添加到app.js的任何路由。我们只想要我们的顶级路由。

由于 Jade 被设置为app.configure中的默认视图引擎,在这个示例中我们不需要在app.js中做其他事情。

routes/index.js中,我们将删除除index之外的所有路由。

exports.index = function(req, res){
  res.render('index', { title: 'Express'})
};

res.render方法加载views/index.jade中的 Jade 模板。我们将使用index.jade作为我们的profiles.js对象数据的视图,因此我们需要将其提供给我们的index视图。

我们通过将其传递给res.renderoptions对象来实现这一点:

var profiles = require('../profiles.js');
exports.index = function(req, res){
  res.render('index', { title: 'Profiles', profiles: profiles})
};

请注意,我们还将title属性更改为'Profiles'

现在我们只需编辑views/index.jade。生成的index.jade包含以下内容:

h1= title
p Welcome to #{title}

我们将在页面上添加一个表格,输出profiles.js对象中每个人的详细信息:

table#profiles
  tr
    th Name
    th Irc
    th Twitter
    th Github
    th Location
    th Description
      each profile, id in profiles
        tr(id=id)
          each val in profile
            td #{val}

要测试,我们启动我们的应用程序:

node app.js 

然后导航到http://localhost:3000,看到类似以下内容:

如何做...

它是如何工作的...

res.renderviews文件夹中提取index.jade,即使第一个参数只是index。Express 知道views目录中的 Jade 文件是有意的,因为app.jsapp.configure包含以下代码:

  app.set('views', __dirname + '/views');
  app.set('view engine', 'jade');

第二个参数是一个对象,包含两个属性:titleprofiles。这些对象属性在 Jade 视图中成为局部变量。我们通过使用等号(=)符号进行返回值缓冲来输出变量,或者通过使用 Jade 的插值,像这样包装它:#{title}

Jade 是一种精简的模板语言。它使用去除括号的标记和基于缩进的语法,还有一个替代块扩展选项(我们使用冒号而不是缩进来表示嵌套)。它还有一组最小的语法集,用于使用井号(#)和点(.)分别定义idclass属性。

例如,以下 Jade:

table#profiles
  th Name

将创建以下 HTML:

<table id=profiles><th>Name</th></table>

提示

要了解有关 Jade 语言的更多信息,请访问其 GitHub 页面:www.github.com/visionmedia/jade

Jade 还处理迭代逻辑。我们使用两个each Jade 迭代器从我们的profiles对象中提取值,如下所示:

  each profile, id in profiles
    tr(id=id)
      each val in profile
        td #{val}

这段代码遍历profiles对象,将每个 ID(ryan, isaac, bert等)加载到一个新的id变量中,将包含配置文件信息的每个对象加载到一个profile对象变量中。

在我们的第一个each下面,我们缩进tr(id=id)。与 JavaScript 不同,Jade 中的缩进是逻辑的一部分,因此正确的缩进至关重要。

这告诉 Jade,对于每个配置文件,我们要输出一个<tr>标签,其id属性设置为profile的 ID。在这种情况下,我们不使用井号(#)缩写来设置id属性,因为我们需要 Jade 来评估我们的id变量。tr#id会为每个配置文件生成<tr id=id>,而tr(id=id)会生成<tr id=ryan>isaacbert等。

tr下面再次缩进,表示接下来的内容应该嵌套在<tr>标签内。我们再次使用each来遍历每个子对象的值,并在td下缩进,其中包含每个配置文件的值。

还有更多...

让我们看看 Express 还提供了哪些其他模板功能和特性。

使用其他模板引擎

Express 支持各种替代模板引擎,不支持的引擎可以适应 Express 而不会带来过多的麻烦。

express可执行文件只会生成基于 Jade 或 EJS 的项目脚手架。要生成 EJS,我们只需将ejs传递给-t标志:

express -t ejs nca 

我们可以将现有项目转换为 EJS 作为默认视图引擎的 Express 项目(我们将首先将其复制到nca_ejs)。

首先,我们需要编辑package.json中的依赖项:

{
    "name": "application-name"
  , "version": "0.0.1"
  , "private": true
  , "dependencies": {
      "express": "2.5.8"
    , "ejs": "0.7.1"
  }
}

我们只是删除了jade,并用ejs代替。现在我们这样做:

npm install 

所以npm会将 EJS 模块放入node_modules文件夹中。

最后,在app.configure中更改我们的视图引擎如下:

app.configure(function(){
  app.set('views', __dirname + '/views');
  app.set('view engine', 'ejs');
  app.use(express.bodyParser());
  app.use(express.methodOverride());
  app.use(app.router);
});

这种技术适用于任何 Express 支持的模板引擎。无需require EJS 模块,Express 会在后台处理。

EJS 模板

由于我们已经设置了nca_ejs,我们可以继续在嵌入式 JavaScript 中重写我们的索引视图。

nca_ejs/views中添加一个新文件index.ejs,并写入:

<h1> <%= title %></h1>
<p> Welcome to <%= title %></p>

<table>
<tr><th>Name</th><th>Irc</th><th>Twitter</th>
<th>Github</th><th>Location</th><th>Description</th></tr>

<% Object.keys(profiles).forEach(function (id) {%>
  <tr>
   <% Object.keys(profiles[id]).forEach(function (val) { %>
   <td><%= profiles[id][val]; %></td>
   <% }); %>

  </tr>
<% }); %>
</table>

<%%>表示嵌入式 JavaScript。如果 JavaScript 恰好包裹任何 HTML 代码,则 HTML 将被处理为 JavaScript 的一部分。例如,在我们的forEach回调中,我们有<tr><td>,这些都包含在每次循环的输出中。

当开放标签伴随等号(<%=)时,它会评估任何给定的 JavaScript 变量,并将其拉入生成的输出中。例如,在我们的第一个<h1>中,我们输出title变量。

Jade 中的字面 JavaScript

Jade 也可以处理纯 JavaScript。让我们利用这一点,以更简洁、干燥的方式输出我们的表头:

- var headers = ['Name', 'Irc', 'Twitter', 'Github', 'Location', 'Description'];
table#profiles
  tr
    each header in headers
      th= header
    each profile, id in profiles
      tr(id=id)
        each val in profile
          td #{val}

行的开头有一个破折号(—),告诉 Jade 我们正在使用纯 JavaScript。在这里,我们简单地创建一个名为headers的新数组,然后使用 Jade 的each迭代器输出我们的标题,使用等号(=)来评估header变量。

我们可以在 Jade 中创建我们的数组如下:

headers = ['Name', 'Irc', 'Twitter', 'Github', 'Location', 'Description'];

Jade 然后将其编译为前面示例中的嵌入式 JavaScript,包括var声明。

Jade 部分

部分被描述为迷你视图或文档片段。它们主要用于自动模板化对数组(集合)的迭代,尽管它们也可以与对象一起使用。

例如,而不是说:

tr(id=id)
  each val in profile
    td #{val}

我们可以创建一个视图文件,我们将其称为row.jade,在其中写入:

td= row

回到index.jade,我们将我们的each迭代器替换为partial,如下所示:

    each profile, id in profiles
      tr(id=id)
        != partial('row', {collection: profile})

!=告诉 Jade 不仅要缓冲partial返回的内容,还要避免转义返回的 HTML。如果我们不包含感叹号,Jade 会用特殊实体代码替换 HTML 字符(例如,<变成&lt;)。

我们将'row'传递给partial,告诉 Jade 使用row.jade视图作为部分。我们将一个具有collection属性的对象作为下一个参数传递。如果我们的资料是一个简单的数组,我们可以简单地传递数组,Jade 会为数组中的每个值生成一个td标签。但是,profile变量是对象,因此将其传递给collection会导致 Jade 遍历值,就好像它们是一个简单的数组一样。

我们的collection中的每个值(Ryan Dahl, ryah, Node.js 的创建者等)都由视图的名称引用。因此,在我们的row.jade视图中,我们使用row变量来获取每个值。我们可以通过使用as属性来自定义它,如下所示:

!= partial('row', {collection: profile, as: 'line'})

然后在row.jade中,我们将row更改为line:

td= line

提示

Express 2 到 Express 3

为了简化查看系统内部并使模板引擎更容易集成到 Express 中,版本 3 将不再支持部分。在 Express 3 中,我们可以说,而不是使用partial调用row.jade文件:

    each profile, id in profiles
      tr(id=id)
       each row in profile
        td= row

Express 部分

部分的一个很棒的地方是我们可以在 Express 路由上使用它们在响应(res)对象上。这是特别了不起的,因为它允许我们无缝地将 HTML 片段发送到 AJAX 或 WebSocket 请求,同时从相同的片段(在我们的视图中)生成整个页面请求的内容。

index.jade(部分版本)的末尾,我们将插入一个小的概念验证脚本:

script(src='http://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js')        
script
  setTimeout(function () {
    $.get('/', function (row) {
      $('#profiles tbody').append(row);
    });
  }, 1500);

这将等待一秒半,然后向我们的index路由发出一个 AJAX 请求。因此,让我们修改routes/index.js中的index路由:

var profiles = require('../profiles.js');

exports.index = function(req, res){
  if (req.xhr) {
    res.partial('row', {collection: profiles.ryan});
  }
  res.render('index', { title: 'Profiles', profiles: profiles});
};

如果请求是一个XmlHttpRequest(AJAX),我们会根据ryan的资料生成一个新的表格行。

现在当我们加载http://localhost:3000时,经过短暂的延迟,Ryan 的资料将出现在表格底部。

提示

Express 2 到 Express 3

Express 3 不支持部分(无论在模板逻辑还是在应用程序代码中),因此我们必须以不同的方式处理。例如,我们可以发送资料的 JSON 表示,并让浏览器循环遍历以填充表格。

在撰写本文时,还没有部分的替代中间件,但在不久的将来可能会有。

Jade 包括

包含帮助我们分离和重用模板代码的部分。让我们将我们的profiles表格放入自己的视图中。我们将其称为profiles.jade

要从index.jade文件中包含profiles.jade,我们只需执行以下操作:

h1= title
p Welcome to #{title}

include profiles

layout.jade

提示

Express 2 到 Express 3

在 Express 3 中,布局也被取消,以支持块继承。因此,不再将任何渲染的视图隐式地包装到body变量中并在layout.jade中渲染,现在我们必须明确声明一个块,然后将该块插入到我们的 body 中。

生成的项目中还包括layout.jade视图。这是一个与 Express 逻辑交织在一起的特殊视图。任何渲染的视图都被打包到一个body变量中,然后传递到layout.jade中。因此,在我们的情况下,我们告诉res.render组装index.jade。Express 将index.jade转换为 HTML,然后在内部渲染layout.jade,将生成的 HTML 传递给body变量。layout.jade允许我们为视图添加头部和底部。要禁用整个应用程序的此功能,我们使用app.set('view options', {layout:false})。要防止它应用于特定的渲染,我们只需将layout:false传递给res.render的选项对象。

提示

Express 2 到 Express 3

因此,在layout.jade中,我们不再使用body!=body,而是使用以下内容:

body
block content

index.jade的顶部,我们将使用extendlayout.jade继承,然后定义content块,该块将加载到layout.jade的 body 中:

extends layout
block content
  h1= title
  p Welcome to #{title}
//- the rest of our template...

提示

所有 Jade Express 代码示例都有一个名为views-Express3的额外文件夹,其中包含等效的模板,这些模板遵循显式块继承模式,而不是隐式布局包装。

另请参阅

  • 在本章中讨论的 Express 的 CSS 引擎

  • 在本章中讨论的创建 Express web 应用程序

  • 在本章中讨论的生成 Express 脚手架

使用 Express 的 CSS 引擎

一旦我们有了我们的 HTML,我们就会想要为它设置样式。当然,我们可以使用原始的 CSS,但 Express 与一些选择的 CSS 引擎集成得很好。

Stylus 就是这样一个引擎。它是为 Express 编写的,并且作为一种语法,它遵循了 Jade 中发现的许多设计原则。

在这个教程中,我们将把 Stylus 放在聚光灯下,学习如何使用它来为我们之前教程中的profiles表应用样式。

准备工作

我们需要我们之前教程中留下的nca文件夹。

如何做...

首先,我们需要设置我们的应用程序来使用 Stylus。

如果我们要开始一个新项目,我们可以使用express可执行文件来生成一个基于 Stylus 的 Express 项目,如下所示:

express -c stylus ourNewAppName 

这将生成一个项目,其中styluspackage.json中的一个依赖项,在app.configure中的app.js中有一行额外的代码:

app.use(require('stylus').middleware({ src: __dirname + '/public' }));

然而,既然我们已经有一个项目在热板上,让我们修改我们现有的应用程序来使用 Stylus。

package.json中:

{
    "name": "application-name"
  , "version": "0.0.1"
  , "private": true
  , "dependencies": {
      "express": "2.5.8"
    , "jade": ">= 0.0.1"
    , "stylus": "0.27.x"
  }
}

然后在命令行中运行以下命令:

npm install 

最后在app.js中,在app.configure中插入以下代码:

  app.use(require('stylus').middleware({
    src: __dirname + '/views',
    dest: __dirname + '/public'
  }));

请注意,我们设置了不同的src并添加了dest属性到生成的代码中。

我们将把我们的 Stylus 文件放在views/stylesheets中。所以让我们创建这个目录,并在其中放置一个新文件,我们将其命名为style.styl。Express 将找到这个文件,将生成的 CSS 放在public目录的相应文件夹(stylesheets)中。

为了开始我们的 Stylus 文件,我们将从/stylesheets/style.css中复制当前的 CSS,如下所示:

body {
  padding: 50px;
  font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
}
a {
  color: #00b7ff;
}

Stylus 完全兼容纯 CSS,但为了学习目的,让我们将其转换为最小缩进格式:

body
  padding 50px
  font 14px "Lucida Grande", Helvetica, Arial, sans-serif;
a
  color #00B7FF

现在我们将为之前教程中的#profiles表设置样式。

我们可以使用 Stylus 的@extend指令为我们的tdth标签以及我们的#profile表应用一致的填充:

.pad
  padding 0.5em
#profiles
  @extend .pad
  th
    @extend .pad
  td
    @extend .pad

当新的 CSS 属性被引入到浏览器中时,它们通常带有特定于供应商的前缀,直到实现被认为是成熟和稳定的。其中一个属性是border-radius,在 Mozilla 浏览器上是-moz-border-radius,在 WebKit 类型上被引用为-webkit-border-radius

编写和维护这种 CSS 可能会相当复杂,所以让我们使用一个 Stylus mixin 来简化我们的生活:

borderIt(rad = 0, size = 1px, type = solid, col = #000)
  border size type col
  if rad
    -webkit-border-radius rad
    -moz-border-radius rad
    border-radius rad

现在,我们将我们的 mixin 应用到#profiles表和所有的td元素上:

#profiles
  borderIt 20px 2px
  @extend .pad
  th
    @extend .pad
  td
    @extend .pad    
    borderIt(col: #000 + 80%)

因此,我们的#profiles表现在看起来如下截图所示:

如何做...

它是如何工作的...

作为一个模块,stylus可以独立于 Express 运行。然而,它也有一个方便的middleware方法,可以传递到app.use中。

express可执行文件生成一个使用 Stylus 的项目时,只设置了src属性,这意味着 Stylus 从一个地方加载.styl文件,并将它们转换为.css文件放在同一个文件夹中。当我们设置dest时,我们从一个地方加载我们的 Stylus 代码,并将它保存在另一个地方。

我们的srcviewsdestpublic,但即使我们把我们的styles.styl放在views的子目录中,Stylus 仍然可以找到它,并将它放在dest文件夹的相应子目录中。

layout.jade文件包括一个到/stylesheets/style.csslink标签。因此,当我们在views/stylesheets中创建style.styl文件时,生成的 CSS 将被写入public/stylesheets。由于我们的静态服务器目录设置为public,对/stylesheets/style.css的请求将从public/stylesheets/style.css中提供。

我们使用了几个 Stylus 功能来创建我们的样式表。

@extend指令基于继承的概念。我们创建一个类,然后使用@extend将该类的所有特性应用到另一个元素上。我们在这个教程中使用@extend创建了以下 CSS:

.pad,
#profiles,
#profiles th,
#profiles td { padding: 0.5em;}

我们的样式基础越大,@extend指令就越能简化维护和可读性。

通过使用 mixin,我们可以更容易地定义边框,如果需要的话,可以使用圆角。Stylus mixins 允许我们在设置参数时定义默认值。如果我们没有参数混合borderIt,它将根据其默认值生成一个 1 像素宽的直角实心黑色边框。

我们首先在#profiles表上使用borderIt,传入20px2px。无需使用括号 - Stylus 会理解它是一个 mixin。我们 mixin 中的第一个参数(20px)被命名为rad。由于rad已经指定了borderIt,mixin 继续输出各种供应商前缀以及所需的半径。第二个参数覆盖了我们的border-width默认值。

当我们将borderIt应用于td元素时,我们需要括号,因为我们使用kwarg(关键字参数)来定义我们的选项。我们只需要设置颜色,所以我们不需要提供所有前面的参数,我们只需将所需的参数引用为属性。我们传递的颜色是#000 + 80%。这不是有效的 CSS,但 Stylus 理解。

还有更多...

让我们探索一些更多的 Stylus 功能,并找出如何使用替代的 CSS 引擎 LESS 作为 Express 中间件。

嵌套 mixin 和 rest 参数

让我们看看如何在其他 mixin 中重用 mixin,以及 Stylus 的 rest 参数语法(本质上是一个消耗任何后续参数的单个参数,将它们编译成一个数组)。

我们可以通过使角<td>元素的相关角更圆来进一步软化我们表格的边缘,使它们与外边框的圆角性质相匹配。

我们需要能够为单个角设置半径。供应商的实现在这方面有所不同。在基于 Mozilla 的浏览器中,角在半径之后定义,没有破折号,例如:

-moz-border-radius-topleft: 9px

而 WebKit 符合规范(除了前缀)的代码如下:

-webkit-border-top-left-radius

让我们创建另一个专门用于创建圆角 CSS 的 mixin,无论角是否相等。

rndCorner(rad, sides...)
  if length(sides) is 2
    -moz-border-radius-{sides[0]}{sides[1]} rad
    -webkit-border-{sides[0]}-{sides[1]}-radius rad
    border-{sides[0]}-{sides[1]}-radius rad
  else
    -webkit-border-radius rad
    -moz-border-radius rad
    border-radius rad

sides是一个 rest 参数。它吸收了所有剩余的参数。我们需要两个边来形成一个角,例如,左上角。因此,我们使用条件语句来检查剩余参数的长度是否为 2(而不是is,我们可以使用==)。

如果我们有我们的边,我们将它们整合到各种特定于浏览器的 CSS 中。请注意,当在属性中包含变量时,我们用大括号({})进行转义。如果未指定边,我们将边的半径设置为所有边,就像我们的配方一样。

现在我们可以从我们的borderIt mixin 中调用这个 mixin,如下所示:

borderIt(rad = 0, size = 1px, type = solid, col = #000)
  border size type col
  if rad { rndCorner(rad) }

我们不必用大括号包裹条件语句。这只是让我们可以将我们的if语句和 mixin 调用放在同一行上。这相当于以下代码:

borderIt(rad = 0, size = 1px, type = solid, col = #000)
  border size type col
  if rad
    rndCorner(rad)

最后,我们将单个角应用于相关的td元素:

tdRad = 9px
#profiles
  borderIt 20px 2px
  @extend .cell
  th
    @extend .cell    
  td
    @extend .cell    
    borderIt(col: #000 + 80%)
  tr
    &:nth-child(2)
        td:first-child
         rndCorner tdRad top left
        td:last-child
         rndCorner tdRad top right
    &:last-child
        td:first-child
         rndCorner tdRad bottom left
        td:last-child
         rndCorner tdRad bottom right

我们的第一个borderIt现在通过推理调用rndCorner mixin,因为它设置了半径。第二个borderIt不会调用rndCorner,这很好,因为我们希望在特定元素上自己调用它。

我们使用特殊的&引用符来引用父tr元素。我们使用 CSS 的:nth-child(2)来选择表格的第二行。第一行由th元素组成。对于first-childlast-child也是一样,我们用它们来对我们的td元素应用适当的角。

虽然:nth-child:last-child伪选择器在 Internet Explorer 8 及以下版本中不起作用,border-radius也不会起作用,因此这是我们可以在更现代的浏览器中使用它并且仍然具有跨浏览器兼容性的少数情况之一。

玩转颜色

Stylus 对颜色做了一些令人惊讶的事情。它有函数允许我们调整颜色的明暗度,(去)饱和度,色调调整,甚至混合颜色。

让我们给我们的表格上色:

#profiles
  borderIt 20px, 2px
  @extend .pad
  background: #000;
  color: #fff;
  th
    @extend .pad
  td
    @extend .pad    
    background blue + 35%
    borderIt(col: @background)
    color pink - green - brown + salmon - yellow + gray - salmon + pink
    color desaturate(@color, 100)
    &:hover
      color @background + 180deg
      background desaturate(@background, 40)
      border-color @background

我们可以引用已为元素设置的任何属性的值,我们在这段代码中始终使用@background属性查找变量,但在许多情况下它保存了不同的值。

首先,我们反转我们的#profile表,将color设置为白色,background设置为黑色。接下来,我们对我们的td元素应用颜色,通过添加35%来获得浅蓝色。我们使用@background属性查找将我们的td边框与它们的background颜色匹配。

然后我们可以随意混合颜色,最终将我们的td文本颜色设置为与原始粉色相差不远的颜色。然后,我们通过+@color传递给去饱和,同时使其变亮。接下来,我们通过向我们的@background颜色添加 180 度来设置悬停文本颜色,获得互补色。我们还对我们的background进行了去饱和,并匹配了border-color(现在@background去饱和的背景匹配,而当我们设置悬停颜色时,它匹配了悬停前的背景颜色)。

现在我们的表格看起来如下截图所示:

Playing with colors

使用 LESS 引擎

LESS 可能是一个更熟悉和冗长的 Stylus 替代方案。我们可以通过替换来使用 LESS 与 Express:

app.use(require('stylus').middleware({
    src: __dirname + '/views',
    dest: __dirname + '/public'
  }));

使用:

  app.use(express.compiler({
    src: __dirname + '/views',
    dest: __dirname + '/public',
    enable: ['less']
}));

为了确保这个工作,我们还应该按照以下方式更改我们的package.json文件:

{
    "name": "application-name"
  , "version": "0.0.1"
  , "private": true
  , "dependencies": {
      "express": "2.5.8"
    , "jade": ">= 0.0.1"
    , "less": "1.3.x"
  }

然后运行以下命令:

npm install 

为了测试它,我们将用 LESS 重写我们的配方。

一些 Stylus 功能在 LESS 中没有等价物。我们将@extend用于继承我们的pad类,我们将其转换为 mixin。LESS 中也没有if条件,因此我们将两次声明.borderIt mixin,第二次使用when语句。

body { padding: 50px;
      font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; }

a { color: #00B7FF; }

 .pad() { padding: 0.5em; }

.borderIt (@rad:0, @size:1px, @type: solid, @col: #000) {
  border: @size @type @col;
}

.borderIt (@rad:0) when (@rad > 0) {
  -webkit-border-radius: @rad;
  -moz-border-radius: @rad;
  border-radius: @rad;
}

#profiles {
.borderIt(20px, 2px);
.pad();
th { .pad(); }
td { .pad();
  .borderIt(0,1px,solid,lighten(#000, 80%));
  }
}

我们将这保存到views/styles.less。Express 将其编译为public/styles.css,再次我们的#profiles表具有圆角。

另请参阅

  • 在本章中讨论的 Express 模板

  • 在本章中讨论的生成 Express 脚手架

初始化和使用会话

如果我们想在页面请求之间保持状态,我们使用会话。Express 提供了大部分管理会话复杂性的中间件。在这个配方中,我们将使用 Express 在浏览器和服务器之间建立一个会话,以便促进用户登录过程。

准备工作

让我们创建一个新的项目:

express login 

这将创建一个名为login的新的 Express 骨架。

如何做...

在我们的app.js文件中,我们对app.configure进行以下更改:

  app.set('views', __dirname + '/views');
  app.set('view engine', 'jade');
  app.use(express.bodyParser());
  app.use(express.methodOverride());
  app.use(express.cookieParser());
  app.use(express.session({secret: 'kooBkooCedoN'}));
  app.use(app.router);
  app.use(express.static(__dirname + '/public'));
});

会话依赖于 cookies,因此我们需要cookieParsersession中间件。

提示

Express 2 到 Express 3

在 Express 3 中,我们通过cookieParser而不是session设置秘密字符串:

app.use(express.cookieParser('kooBkooCedoN'));
app.use(express.session());

我们将用以下路由处理完成app.js

app.get('/', routes.index);
app.post('/', routes.login, routes.index);
app.del('/', routes.logout, routes.index);

GET请求将正常提供页面,POST请求将被解释为登录尝试。这些将首先传递到一个验证路由,检查有效的用户数据。DELETE请求将清除routes.logout的会话,然后传递到routes.index

现在编辑routes/index.js文件:

var users = {'dave' : 'expressrocks'}; //fake user db:
exports.login = function (req, res, next) {
  var user = req.body.user;
  if (user) {
    Object.keys(users).forEach(function (name) {
      if (user.name === name && user.pwd === users[name]) {
        req.session.user = {
          name: user.name,
          pwd: user.pwd
        };
      }
    });
  }
  next();
};

exports.logout = function (req, res, next) {
 delete req.session.user;
 next();
}

exports.index = function (req, res) {
  res.render('index', {title: 'Express', user: req.session.user});
};

最后,让我们在一个文件中组合一个登录表单。我们将称之为login.jade

if user
 form(method='post', action='/')
   input(name="_method", type="hidden", value="DELETE")
   p Hello #{user.name}!
    a(href='javascript:', onClick='forms[0].submit()') [logout]

else
  p Please log in
  form(method='post', action='/')
    fieldset
      legend Login
      p
        label(for="user[name]") Username:
        input(name="user[name]")
      p
        label(for="user[pwd]") Password:
        input(type="password", name="user[pwd]")

      input(type="submit")

提示

_method

注意我们的注销表单如何使用名为_method的隐藏输入。将此值设置为DELETE会覆盖表单设置的POST方法。这是由app.js文件中app.configure内的methodOverride中间件实现的。

我们将在index.jade中包含这个表单:

h1= title
p Welcome to #{title}

include login.jade 

现在,如果我们运行我们的应用程序,并导航到http://localhost:3000,我们将看到一个登录表单。我们输入用户名dave,密码expressrocks,现在我们看到一个问候语,并有注销选项。

它是如何工作的...

为了使用会话,我们必须包含一些额外的中间件。我们在app.configure中进行这样做。express.parseCookie首先出现,因为express.session依赖于它。

express.session接受一个包含secret属性的强制对象(或者在 Express 3 中,通过向express.cookieParser传递字符串参数来设置密钥)。secret用于生成会话哈希,因此需要是唯一的,并且对外部人员是未知的。

当我们设置我们的路由时,我们假设对/路径的POST请求是登录尝试,因此首先将它们传递给login路由,对/DELETE请求首先由logout路由处理。

我们的login路由检查已发布的登录详细信息(使用bodyParser中间件提供给我们的req.body)与我们的占位符users对象相匹配。在真实世界的情况下,login可能会对用户数据库进行验证。

如果一切正常,我们将一个user对象添加到会话中,并将name和密码(pwd)放入其中。

当将用户详细信息推送到会话时,我们可以采取捷径并说:

req.session.user = req.body.user; 

然而,这样做可能会让攻击者填充req.session.user对象,填充任何他们想要的内容,可能会有大量内容。虽然任何输入会话的数据都是由受信任的用户(具有登录详细信息的用户)输入的,而且bodyParser对 POST 数据有内置的安全限制,但总是更倾向于保守而不是方便。

index路由保持不变,只是我们设置了一个user属性,我们将req.session.user传递给它。

这使login.jade能够检查user变量。如果设置了,login.jade会显示一个问候语,并包含一个小表单,其中包含一个链接,该链接发送带有DELETE覆盖的 POST 请求到服务器,从而通过app.del触发logout路由。

logout路由只是从req.session中删除user对象,将控制传递给index.route(使用next),然后通过res.render将不存在的req.session.user推送回 Jade。

当 Jade 发现没有user时,它会显示登录表单,当然也会输出到预登录请求。

还有更多...

我们可以改进与会话的交互方式。

用于全站点会话管理的自定义中间件

如果我们想要将我们的登录和注销请求传递到一个路由,那么这个配方就很好。然而,随着我们的路由和视图增加,管理会话的复杂性可能会变得繁重。我们可以通过为处理会话目的创建自定义中间件来在一定程度上减轻这种情况。

为了从不同的 URL 进行测试,我们将修改我们的路由如下:

app.get('/', routes.index);
app.post('/', routes.index);
app.del('/', routes.index);
app.get('/:page', routes.index);

我们不再使用路由来控制我们的会话逻辑,因此我们已经删除了中间路由,直接发送到routes.index:page可能指向另一个路由,但出于简洁起见,我们将其保留为routes.index

routes/index.js中,我们现在可以简单地使用以下代码:

exports.index = function (req, res) {
  res.render('index', {title: 'Express'});
};

现在让我们创建一个文件,命名为login.js,编写以下代码:

var users = {'dave' : 'expressrocks'};

module.exports = function (req, res, next) {
  var method = req.method.toLowerCase(), //cache the method
    user = req.body.user,
    logout = (method === 'delete'),
    login = (method === 'post' && user),

    routes = req.app.routes.routes[method];

  if (!routes) { next(); return; }

  if (login || logout) {    
    routes.forEach(function (route) {
      if (!(req.url.match(route.regexp))) {
        console.log(req.url);
        req.method = 'GET';
      }
    });
  }
  if (logout) {
    delete req.session.user;
  }

  if (login) {
    Object.keys(users).forEach(function (name) {
      if (user.name === name && user.pwd === users[name]) {
        req.session.user = {
          name: user.name,
          pwd: user.pwd
        };
      }
    });
  }
  if (!req.session.user) { req.url = '/'; }  
  next();
};

由于我们不再使用路由,我们没有机会通过res.render传递req.session.user。但是,我们可以使用动态助手。动态助手可以访问reqres对象,在视图呈现之前调用。我们传递给动态助手对象的任何属性都会作为本地变量推送到 Jade 视图中。在app.js中,我们在我们的路由上方放置:

app.dynamicHelpers({
  user: function (req, res) {
    return req.session.user;
  }
});

提示

Express 2 到 Express 3

在 Express 3 中,使用app.locals.use设置动态助手:

app.locals.use( function (req, res) {
        res.locals.user = req.session.user;
    });

而是通过发送包含所需本地变量的对象,通过将它们添加到res.locals对象中来显式设置本地变量。

现在我们只需在app.jsapp.configure回调中将login.js包含为中间件:

app.configure(function()
  app.set('views', __dirname + '/views');
  app.set('view engine', 'jade');
  app.use(express.bodyParser());
  app.use(express.methodOverride());
  app.use(express.cookieParser());
  app.use(express.session({secret: 'kooBkooCedoN'}));
  app.use(require('./login'));
  app.use(app.router);
  app.use(express.static(__dirname + '/public'));
});

最后,我们将修改login.jade,使其不再是:

form(method='post', action='/')

我们有:

form(method='post')

这使表单 POST 到从中提交的任何地址。

现在所有的肌肉工作都是由login.js执行的。导出函数的下半部分执行与我们的配方相同的操作。由于我们没有使用 Express 路由器,我们必须手动检查方法。

在上半部分,我们访问req.app。在app.js中,app变量是express.createServer的结果。Express 允许我们在中间件和路由中访问我们的服务器,使用对服务器实例的引用req.app

在我们与req.app的交互中,我们使用req.app.routes.routes,将其存储在routes变量中。这个属性保存了我们使用app.get, app.post等定义的任何路由。路由按请求方法类型存储,例如,req.app.routes.routes.post保存了所有app.post路由的数组。如果路由方法没有被定义,我们简单地调用nextreturn。这样 Express 可以处理未定义方法的问题。数组中的每个项目都是一个对象,包含path, method, callbacks, keysregexp属性。我们循环遍历request方法的所有路由,并使用regexp属性来确定请求的 URL 是否有匹配项。如果没有,我们将方法重置为GET。我们这样做是为了透明地确保POSTDELETE请求可以通过任何 URL 进行服务,并且如果没有为它们定义postdel路由,不会返回404 错误

如果缺少这段代码,登录或注销机制仍会发生,但用户将收到未找到消息。例如,如果我们导航到http://localhost:3000/anypage,并尝试登录,我们的中间件将首先捕获请求。它将确定是否满足登录条件(在请求体中有user的 POST 请求),并相应地处理它。如果没有为/anypage定义 POST 路由,我们将方法重置为 GET。稍后中间件调用next,将控制权传递给app.router,后者永远不会看到 POST 方法,因此重新加载/:page GET 路由。

回到app.js,我们有 Express 的dynamicHelpers方法。dynamicHelpers方法注册了助手,但直到视图渲染之前才调用它(这意味着动态助手在所有路由回调之后执行)。这很方便,因为它允许我们的路由在需要时进一步与req.session.user交互。我们将一个包含user属性的对象传递给dynamicHelpersuser属性的最终值直接加载到我们的视图中作为变量。以同样的方式,我们可以通过路由中的res.render选项对象将变量传递给视图。user属性包含一个回调,由 Express 进行评估。它的工作方式类似于路由或中间件回调,只是期望有一个return值。我们return req.session.user,因此与主要配方一样,req.session.user现在在login.jade中作为user可用。如果没有会话,我们确保将 URL 重置为/,以便其他路由不能用来绕过我们的授权过程。

最后,我们调用next,将控制权传递给下一个中间件,在我们的例子中是app.router

闪现消息

Express 提供了一个基于会话的闪现消息的简单接口。闪现消息保存在会话对象中,仅用于一个请求,然后消失。这是一种生成请求相关信息或错误消息的简单方法。

提示

Express 2 到 Express 3

Express 3 不支持开箱即用的会话闪现消息。但是,connect-flash 作为中间件提供了这个功能。

在我们的package.json文件的依赖项部分,我们将添加:

, "connect-flash": "0.1.0"

然后执行:

npm install 

最后,我们需要引入connect-flash,并在app.configure回调中调用它,放在 cookie 和会话中间件之后:

var express = require('express')
	, routes = require('./routes')
	, flash = require('connect-flash')
var app = module.exports = express.createServer();
app.configure(function(){
//prior middleware
	app.use(express.cookieParser('kooBkooCedoN'));
	app.use(express.session());
	app.use(flash());
//rest of app.configure callback

让我们修改上一个扩展中的login.js文件到我们的配方(网站范围会话管理的自定义中间件)。我们将修改它以便在登录详情无效时闪现错误消息。首先,我们需要修改导出函数底部的代码,位于if (login)条件内:

if (login) {
  var valid = Object.keys(users).some(function (name) { 
    return (user.name === name && user.pwd === users[name]); 
  });
  if (valid) {
    req.session.user = {
    name: req.body.user.name,
    pwd: req.body.user.pwd
    };
  } else {
    req.flash('error','Login details invalid!');
  }
}

这样做是有效的,但我们可以做得更好。让我们通过将验证代码提取到一个单独的函数中来整理它:

function validate(user, cb) {
  var valid = Object.keys(users).some(function (name) { 
    return (user.name === name && user.pwd === users[name]); 
  });
  cb((!valid && {msg: 'Login details invalid!'} ));

}

尽管validate中发生的一切都是同步的,但我们以异步的方式编写它(即通过回调传递值而不是返回值)。这是因为实际上,我们不会使用对象来存储用户详细信息。我们将使用远程数据库,必须异步访问。在下一个示例中,我们将把用户详细信息存储在 MongoDB 数据库中,并异步读取以验证登录请求。validate函数的结构考虑到了这一点。

现在我们用以下代码替换我们的中间件用户验证代码:

  if (login) {
    validate(user, function (err) {
      if (err) { req.flash('error', err.msg); return; }
        req.session.user = {
          name: user.name,
          pwd: user.pwd
        };
    });   
  }
  if (!req.session.user) { req.url = '/'; }  
  next();
}; //closing bracket of module.exports

我们的validate函数使用相同的布尔方法。但是,它被隐藏了。还要注意到对next的各种策略性调用——无论是在从错误中提前退出时,添加用户会话,还是在最后。在回调上下文中放置这些next调用,为异步操作未来证明了我们的验证函数,这对于数据库交互非常重要。

我们使用validate函数中的callback(err)样式来让我们的中间件知道登录是否成功。err只是一个包含错误消息(msg)的对象,只有在valid不为真时才传递。

如果存在err,我们调用req.flash,这是内置的 Express 方法,将一个名为flash的对象推送到req.session中。一旦请求被满足,该对象将被清空所有属性。

我们需要使这个对象在login.jade中可用,所以我们将在app.js中添加另一个动态帮助程序。

  app.dynamicHelpers({
    user: function (req, res) {
      return req.session.user;
    },
    flash: function(req, res) {
      return req.flash();
    }
  });

提示

从 Express 2 到 Express 3

要在 Express 3 中添加 flash 消息,我们只需将其添加到我们现有的app.locals.use回调中的res.locals对象中:

   app.locals.use( function (req, res) {
   res.locals.user = req.session.user;
   res.locals.flash= req.flash();});

我们可以使用req.session.flash,但req.flash()也可以做同样的事情。

最后,在login.jade的顶部,我们写入:

if flash.error
  hr
  b= flash.error
  hr

如果登录详细信息不正确,用户将在水平线之间收到粗体错误通知。

参见

  • 在本章中讨论创建一个 Express web 应用程序

  • 在本章中讨论 Express 中的模板

  • 动态路由在本章中讨论

创建一个 Express web 应用程序

在这个示例中,我们将把许多以前的示例组合在一起,还加入了一些额外的 Express 功能(如应用程序挂载),以创建一个具有集成管理功能的基于 Express 的 Web 应用程序的基础。

准备工作

让我们从命令行开始:

express profiler 

Profiler 是我们的新应用的名称,它将是 Node 社区成员的个人资料管理器。

我们需要编辑package.json,写入:

{
    "name": "Profiler"
  , "version": "0.0.1"
  , "private": true
  , "dependencies": {
      "express": "2.5.8"
    , "jade": ">= 0.0.1"
    , "stylus": "0.27.x"
    , "mongoskin": "0.3.6"     
  }
}

我们将名称设置为Profiler,添加stylusmongoskin,为mongoskin设置更严格的版本要求。Jade 和 Stylus 是为 Express 设计的,因此它们可能会与新版本保持兼容(尽管我们将 Stylus 限制为次要版本更新)。Mongoskin 有自己的开发流程。为了确保我们的项目不会因为可能的 API 更改而被未来版本破坏,我们将版本锁定为 0.3.6(尽管这并不意味着我们不能在以后升级)。

所以我们用以下代码获取我们的依赖项:

npm install 

我们还需要确保 MongoDB 已安装并在我们的系统上运行,请参阅第四章的使用 MongoDB 存储和检索数据,了解详情。

简而言之,我们用以下命令启动 Mongo:

sudo mongod --dbpath [a folder for the database] 

我们还将向 MongoDB 中推送一些数据以开始。让我们在profiler目录中创建一个新文件夹,并将其命名为tools。然后将我们的profiles.js模块从第一章中,创建 Web 服务器,移到其中,创建一个名为prepopulate.js的新文件。在其中,我们写入以下代码:

var mongo = require('mongoskin'),
 db = mongo.db('localhost:27017/profiler'),
  profiles = require('./profiles'),
  users = [{name : 'dave', pwd: 'expressrocks'},
           {name : 'MrPage', pwd: 'hellomynamesmrpage'}
          ];
//make sure collection is empty before populating
db.collection('users').remove({});
db.collection('profiles').remove({});
db.collection('users').insert(users);
Object.keys(profiles).forEach(function (key) {
  db.collection('profiles').insert(profiles[key]);
});

db.close();

执行后,这将给我们一个名为profiler的数据库,其中包含profilesusers集合。

最后,我们将使用上一个示例中的整个登录应用程序。但是,我们希望它具有站点范围的会话管理和闪存消息(在代码示例中,此文件夹称为login_flash_messages)。因此,让我们将login文件夹复制到我们的新配置文件目录中,命名为profiler/login

如何做...

创建数据库桥接

让我们从一些后端编码开始。我们将创建一个名为models的新文件夹,并在其中创建一个名为profiles.js的文件。这将用于管理我们与 MongoDB 配置文件集合的所有交互。在models/profiles.js中,我们放置:

var mongo = require('mongoskin'),
    db = mongo.db('localhost:27017/profiler'),
    profs = db.collection('profiles');

exports.pull = function pull(page, cb) {
  var p = {},
    //rowsPer = 10, //realistic rowsPer
    rowsPer = 2, 
    skip, errs;   
  page = page || 1;
  skip = (page - 1) * rowsPer;

  profs.findEach({}, {limit: rowsPer, skip: skip}, function (err, doc) {
    if (err) { errs = errs || []; errs.push(err); }

    if (doc) {
      p[doc._id] = doc;
      delete p[doc._id]._id;
      return;
    }

    cb(errs, p);
  });
}

exports.del = function (profile, cb) {
  profs.removeById(profile, cb);
}

exports.add = function (profile, cb) {
  profs.insert(profile.profile, cb);
}

我们定义了三种方法:pull, deladd。每个方法都是异步操作数据库,并在数据返回或操作完成时执行用户回调。我们设置了一个较低的每页行数限制(rowsPer),以便我们可以使用我们拥有的少量记录来测试我们的分页工作(将内容分成页面)。

我们还必须修改login/login.js,这是我们在上一个示例中创建的,以将我们的登录应用程序连接到 MongoDB 用户集合。主模块可以保持不变。我们只需要改变验证用户的方式,module.exports之上的所有内容都会改变为:

var mongo = require('mongoskin'),
  db = mongo.db('localhost:27017/profiler'),
  users = db.collection('users');

function validate(user, cb) {
  users.findOne({name: user.name, pwd: user.pwd}, function (err, user) {
    if (err) { throw err; }
    if (!user) {
      cb({msg: 'Invalid login details!'});
      return;
    }
    cb();
  });

}

配置 app.js 文件

现在让我们修改app.js

app.configure应该如下所示:

app.configure(function(){
  app.set('views', __dirname + '/views');
  app.set('view engine', 'jade');

  app.use(express.bodyParser());
  app.use(express.methodOverride());

  app.use(require('stylus').middleware({
    src: __dirname + '/views',
    dest: __dirname + '/public'
  }));

  app.use(express.favicon());

  app.use(app.router);
  app.use(express.static(__dirname + '/public'));

  app.use('/admin', require('./login/app'));
});

我们已经启动了 Stylus 引擎,并添加了一个网站图标服务器以确保一切正常。最后一行app.use实际上将我们的登录应用程序挂载到/admin路由(我们在准备就绪中将login复制到我们的配置文件目录中)。

接下来,让我们将我们唯一的路由添加到我们的主app.j中,如下所示:

app.get('/:pagenum([0-9]+)?', routes.index); 

我们指定了一个可选的占位符叫做:pagenum,它必须由一个或多个数字组成。因此,/, /1, /12/125452都是有效的路由,但/alphaCharsPage不是。

现在让我们在login应用程序的app.js中的login/app.js中设置一些额外的配置细节,如下所示:

app.configure(function(){
  app.set('views', __dirname + '/views');
  app.set('view engine', 'jade');
  app.use(express.bodyParser());
  app.use(express.methodOverride());  
  app.use(express.cookieParser());
  app.use(express.session({secret: 'kooBkooCedoN'}));  

  app.use(require('stylus').middleware({
    src: __dirname + '/views',
    dest: __dirname + '/public'
  }));  

  app.mounted(function (parent) {
    this.helpers({masterviews: parent._locals.settings.views + '/'});
  });

  app.use(require('./login'));
  app.use(app.router);
  app.use(express.static(__dirname + '/public'));
});

提示

Express 2 到 Express 3

请记住,在 Express 3 中,秘密放在express.cookieParser中作为字符串,而不是传递给express.session的对象内部。

login应用程序将从profiler应用程序中拉取我们的配置文件表。我们已经配置它使用 Stylus,因为我们将应用额外的特定于管理员的 Stylus 生成的 CSS。我们还添加了一个名为masterviews的辅助变量。稍后将用它来定位我们应用程序的主视图目录的绝对路径。login应用程序需要知道这一点,以便从其父profiler应用程序加载视图。

接下来,我们将修改login/app.js文件中的路由:

app.get('/:pagenum([0-9]+)?', routes.index);
app.post('/:pagenum([0-9]+)?', routes.index);
app.del('/:pagenum([0-9]+)?', routes.index);

app.get('/del', routes.delprof);
app.post('/add', routes.addprof, routes.index);

将可选的:pagenum添加到get方法路由中,使得可以像在主应用程序中一样导航配置文件表。将:pagenum添加到post方法路由中允许用户从他们之前导航到的页面登录(例如,如果用户的会话已过期,这允许从http://localhost:/admin/2提供登录表单)。同样,del方法路由将允许我们从任何有效页面注销。

我们还为处理管理员任务添加了/del/add路由。

当一个 Express 应用程序被挂载到另一个 Express 应用程序中时,在子应用程序上调用listen方法会导致端口冲突。子应用程序不必监听,它们的父应用程序会为它们监听。

因此,我们修改login/app.js中的listen调用为:

if (!module.parent) {
  app.listen(3000, function(){
    console.log("Express server listening on port %d in %s mode",   app.address().port, app.settings.env);
  });
}

module是一个内置的 Node 全局变量。parent 属性告诉我们我们的应用程序是否被另一个应用程序加载。由于我们的登录应用程序是由配置文件应用程序加载的,app.listen不会触发。

登录应用程序是我们管理部分的门卫,我们将能够添加和删除配置文件。

现在我们的主应用程序和挂载的应用程序都已经准备就绪,我们可以继续编辑我们的视图、样式和路由。

修改配置文件

让我们从profiler应用程序的index.jade视图开始。

h1= title
p Welcome to #{title}
p: a(href='admin/') [ Admin Login ]
include profiles

由于我们包含了profiles.jade,让我们将其写成如下形式:

masterviews = typeof(masterviews) !== 'undefined' ? masterviews : '' 
table#profiles
  tfoot
    page = (page) || 1
    s = (page > 1) ? null : 'display:none'
    td
     a#bck(href="{(+page-1)}", style=s) &laquo;
     a#fwd(href="{(+page+1)}") &raquo;
  thead
    tr
      th Name
      th Irc
      th Twitter
      th Github
      th Location
      th Description
      if typeof user !== 'undefined'
        th Action
  tbody   
    each profile, id in profiles
      tr(id=id)
        != partial(masterviews + 'row', {collection: profile})
        mixin del(id)
mixin add

mixin adminScript

提示

Express 2 到 Express 3

我们在profiles.jade中使用了一个partial(参见Express 中的模板中讨论的Jade Partials)。Express 3 不再支持 partials,因此我们需要手动遍历行:

tbody
	each profile, id in profiles
		tr(id=id)
			each row in profile
			td= row
		mixin del(id)

profiles.jade应该保存到profiler/views目录中,它是基于我们之前的 recipes 中的profiles表。但是,我们添加了一些代码来支持与登录应用程序的无缝集成,并为分页添加了一些额外的 HTML 结构。

profiles.jade的顶部,我们包含了一个安全网,以确保我们的视图在masterviews未定义时不会中断。对于分页,我们添加了一个tfoot元素来保存前进和后退链接,以及一个补充的thead来包装th元素。

我们使用一个 partial 来加载每一行,这将从row.jade中加载如下:

td= row

我们还包括了一些 Jade mixin 调用。当我们来编辑login应用程序视图时,我们将定义这些 mixin。

让我们在views下创建一个新的stylesheets目录,并在其中放置一个名为style.styl的文件。

views/stylesheets/style.styl中,我们编写以下代码:

body
  padding 50px
  font 14px "Lucida Grande", Helvetica, Arial, sans-serif;
a
  color #00B7FF

rndCorner(rad, sides...)
  if length(sides) is 2
    -moz-border-radius-{sides[0]}{sides[1]} rad
    -webkit-border-{sides[0]}-{sides[1]}-radius rad
    border-{sides[0]}-{sides[1]}-radius rad
  else
    -webkit-border-radius rad
    -moz-border-radius rad
    border-radius rad

borderIt(rad = 0, size = 1px, type = solid, col = #000)
  border size type col
  if rad {rndCorner(rad)}

.pad
  padding 0.5em

tdRad = 9px

#profiles
  width 950px
  borderIt 20px, 2px
  @extend .pad
  background: #000;
  color: #fff;
  th
    @extend .pad
  tbody
    td
      @extend .pad    
      background blue + 35%
      borderIt(col: @background)
      color pink - green - brown + salmon - yellow + gray - salmon + pink
      color desaturate(@color, 100)
      &:hover
        color @background + 180deg
        background desaturate(@background, 40)
        border-color @background

    tr
      &:first-child
          td:first-child
           rndCorner tdRad top left
          td:last-child
           rndCorner tdRad top right
      &:last-child
          td:first-child
           rndCorner tdRad bottom left
          td:last-child
           rndCorner tdRad bottom right

  tfoot
    font-size 1.5em
    td
    a
      text-decoration none
      color #fff - 10%
      &:hover
        color #fff

这是来自Express 中的 CSS 引擎Playing with color部分下的There's more..的相同 Stylus 表,但进行了一些修改。

由于我们将th元素放在thead下,我们可以简单地通过:first-child选择我们的tbody tr元素,而不是:nth-child(2)。我们还为新的tfoot元素添加了一些样式。

最后,我们将编写routes/index.js文件。

var profiles = require('../models/profiles'); 

function patchMixins(req, mixins) {
  if (!req.session || !req.session.user) {
    var noop = function(){},
      dummies = {};
    mixins.forEach(function (mixin) {
      dummies[mixin + '_mixin'] = noop;
    });  
    req.app.helpers(dummies);
  }
}

exports.index = function (req, res) {
  profiles.pull(req.params.pagenum, function (err, profiles) {
    if (err) { console.log(err); }
    //output no-ops to avoid choking on non-existant admin mixins
    patchMixins(req, ['add','del','adminScript']);

    res.render('index', { title: 'Profiler', profiles: profiles,
      page: req.params.pagenum });
  });
};

我们的index路由通过我们的models/profiles.js模块向 MongoDB 发出调用,传递所需的页码,并检索一些要显示的 profiles。

它还调用我们的patchMixins函数,在我们的路由之前包含了一个在profiles.jade中找到的 mixin 名称数组。这些 mixin 目前还不存在。此外,只有当我们登录到http://localhost:8080/admin时,这些 mixin 才可用。这是有意的,这些 mixin 将提供管理控件,这些控件位于我们的profiles表的顶部,我们只希望它们在用户登录时出现。

但是,如果我们不在 admin mixin 的位置包含虚拟 mixin,Node 将抛出错误。

在内部,Jade mixins 在执行视图模板之前被编译为 JavaScript 函数。因此,我们创建了虚拟的no-op(无操作)函数来防止服务器错误。然后当我们登录时,它们将被替换为管理 mixin。

如果我们导航到localhost:3000,我们现在应该有一个正常运行的profiler应用程序。

修改已挂载的登录应用程序

login/views中,我们目前有index.jade, login.jadelayout.jade。在login.jade中,我们想要添加两个includes,如下所示:

if flash.error
  hr
  b= flash.error
  hr

if user
  form(method='post')
    input(name="_method", type="hidden", value="DELETE")
    p Hello #{user.name}!
      a(href='javascript:', onClick='forms[0].submit()') [logout]

  include admin
  include ../../views/profiles

else
  p Please log in

// rest of the login.jade...

我们不再重复代码,而是使用相对路径从主应用程序重用我们的profiles.jade视图。这意味着我们对前端站点所做的任何更改也会应用到我们的管理部分!admin.jade将包含 Jade mixins(在概念上类似于 Stylus mixins)。这些 mixin 有条件地包含在profiles.jade中(请参见前面的修改 profiler 应用程序部分)。

因此在admin.jade中:

mixin del(id)
  td
    a.del(href='/admin/del?id=#{id}&p=#{page}')
      &#10754;

mixin add
  #ctrl
    a#add(href='#') &oplus;

mixin adminScript
  include adminScript

include addfrm

admin.jade中有两个包含,一个作为 mixin 的一部分,另一个作为直接包含。

addfrm.jade应该如下所示:

fields = ['Name', 'Irc', 'Twitter', 'Github', 'Location', 'Description'];

form#addfrm(method='post', action='/admin/add')
  fieldset
    legend Add
    each field, i in fields
      div
      label= field
      input(name="profile[#{field.toLowerCase()}]")
    .btns
      button.cancel(type='button') Cancel
      input(type='submit', value='Add')

adminScript.jade应包含以下代码:

script(src='http://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js')    
script
  document.getElementsByTagName('body')[0].id = 'js';

  $('#add').click(function (e) {
    e.preventDefault();
    $('#profiles, #ctrl').fadeOut(function () {
      $('#addfrm').fadeIn();       
    });

  $('#addfrm .cancel').click(function () {
    $('#addfrm').fadeOut(function () {
      $('#profiles, #ctrl').fadeIn();       
    });
  });

  });

login.jade中,admin位于profiles上方,因此#addfrm将位于#profiles表的上方。但是,我们的adminScript mixin 隐藏了表格,在单击添加按钮时显示它。

我们在login/views下创建一个stylesheets文件夹,在其中创建admin.styl并编写以下代码:

@import '../../../views/stylesheets/style.styl'

tbody
  td
    .del
      text-decoration none
      color blue + 35% + 180deg
      float right
      &:hover
        color red
#ctrl
  width 950px
  text-align center
  margin-top -2.5em
  a
    color white - 10%
    font-size 1.8em
    text-decoration none
    &:hover
      color @color + 111%

#js
  #addfrm
    display none

#addfrm
  width 250px
  label
    display block
    float left
    width 100px
    font-weight bold
  .btns
    width @width
    text-align right

现在我们还在主应用程序中重用 Stylus 表。@import声明由服务器端的 Stylus 处理(除非扩展名为.css)。因此,我们的主应用程序的styles.styl表与admin.styl合并,并编译为login/public/stylesheets/admin.css中的一个 CSS 文件。

要加载我们的admin.css文件,我们必须修改登录应用程序的layout.jade视图,如下面的代码所示:

!!!
html
  head
    title= title
    link(rel='stylesheet', href='/admin/stylesheets/admin.css')
  body!= body

我们已将link href属性从/stylesheet/style.css更改为/admin/stylesheets/admin.css,确保 CSS 从我们子应用程序的静态服务器上加载。

最后,我们完成了我们的管理员路由,在login/routes/index.js中如下所示:

var profiles = require('../../models/profiles');

exports.index = function (req, res) {
  profiles.pull(req.params.pagenum, function (err, profiles) {
    if (err) {  console.log(err); }
    res.render('index', { title: 'Profiler Admin', profiles: profiles, page: req.params.pagenum });
  });
};

exports.delprof = function (req, res) {
  profiles.del(req.query.id, function (err) {
    if (err) { console.log(err); }
      profiles.pull(req.query.p, function (err, profiles) {
        req.app.helpers({profiles: profiles});
        res.redirect(req.header('Referrer') || '/');
      });
  });

}

exports.addprof = function (req, res) {
  profiles.add(req.body, function (err) {
    if (err) { console.log(err); }
      res.redirect(req.header('Referrer') || '/');
  });

}

现在我们应该能够登录到http://localhost:3000/admin,以Dave的身份删除和添加配置文件,密码为expressrocks,或者以Mr.Page的身份,密码为hellomynamesmrpage

提示

登录安全

在下一章中,我们将学习如何对我们的密码进行哈希处理并通过 SSL 登录。

它是如何工作的...

我们的应用程序包含许多组件共同工作。所以让我们从不同的角度来看一下。

应用程序挂载

在这个示例中,我们有两个应用程序与同一个数据库一起工作,共享视图和 Stylus 样式表。我们将登录应用程序导入到我们的新的profiler文件夹中,并使用app.use设置/admin作为其路由。

这是因为 Express 应用程序是中间件的组合,所以当我们挂载登录应用程序时,它只是作为中间件插件与我们的应用程序集成。中间件在请求和响应对象上工作。通过将/admin路由传递给app.use,我们限制了登录应用程序仅在该路由下的请求中工作。

数据流

我们的应用程序由 MongoDB 数据库支持,我们使用prepopulate.js工具进行设置。数据如下所示流向和从数据库流向:

数据流

models/profiles.js从配置文件集合中提取和推送数据,为主应用程序和子应用程序中的routes/index.js文件提供接口。我们的路由集成在各自的app.js文件中,并与models/profiles.js交互,执行所需的任务。

login.js只是验证用户的凭据,使用用户提供的输入进行搜索。login.js作为一个中间件坐落在login/app.js中,等待响应包含用户名和密码的 POST 请求。

路由处理

在两个应用程序中,index路由提供了显示和导航配置文件表的基础。在两者中,我们调用profiles.pull,传入req.params.pagenumpagenum参数加载到req.params上。它永远不会是除了数字之外的任何东西 - 这要归功于我们对它的限制,尽管它是可选的,因此可能不存在。

我们的profiles.pull接受两个参数:页码和回调。如果页码不存在,则将page设置为1。我们通过将我们的内部rowsPer变量乘以page-1(我们想要从第一页开始,因此对于第一页,我们跳过 0 行)来确定要提取的行。结果作为skip修饰符传递给 MongoDB,同时rowsPer作为limit属性传递。skip将在输出之前跳过预定数量的行,limit限制输出的数量;因此我们实现了分页。

profiles.pull回调要么出现错误,要么包含配置文件的对象。在我们的index路由中,我们执行最小的错误处理。Express 倾向于捕获错误并将其输出到浏览器以进行调试。profiles被传递给res.render,稍后在profiles.jade视图中使用。

login/app.js中,定义了两个不可变的路由:/add/del/del路由是一个基本的 GET 请求,指向routes.delprof,它期望两个查询参数:idp. id被传递给profiles.del,它调用 Mongoskin 的removeByID方法,有效地从集合中删除了一个配置文件。我们直接将cb参数传递给removeById回调,使profiles.del回调直接成为removeById的结果。

回到login/routes/index.js,只要没有发生错误,我们调用profiles.pull,使用p查询参数更新profiles对象到视图中使用app.helpers。这确保了对数据库的更改会反映给用户。最后,我们将用户重定向回他们来的地方。

/add路由的工作方式基本相同,只是作为 POST 请求。req.body作为一个对象返回,我们可以直接将这个对象插入到 MongoDB 中(因为它类似于 JSON)。

Views

我们在视图中使用了很多includes,有时在应用程序之间的关系看起来如下图所示:

Views

在我们的主应用程序中,索引视图加载 profiles 视图,并且 profiles 在partial语句中使用 rows 视图。

在登录应用程序中,索引视图包括登录视图。登录视图加载 profiles 视图,并在适当的条件下,还包括 admin 视图(在 profiles 之前)以提供管理层。Admin 包括addfrmadminScript视图。在 admin 中定义的 mixins 对 profiles 可用。

profiles.jade视图对整个 Web 应用程序非常重要,它输出我们的数据,提供可选的管理覆盖层,并提供导航功能。让我们来看看导航部分:

table#profiles
  tfoot
    page = (page) || 1
    s = (page > 1) : null ? 'display:none'
    td
     a#bck(href="#{settings.basepath}/#{(+page-1)}", style=s) &laquo;
     a#fwd(href="#{settings.basepath}/#{(+page+1)}") &raquo;

page变量是从index路由传递的,并且是从req.params.pagenum确定的。如果 page 是0(或 false),我们将其设置为1,这在用户心中是第一页。然后我们创建一个名为s的变量。在 Jade 中,我们不必使用var,Jade 会处理这些复杂性。如果我们在第一页,那么链接到前一页是不必要的,因此添加一个包含display:none的 style 属性(如果我们想更整洁,我们可以设置一个 CSS 类设置display并添加一个 class 属性)。通过传递null,如果page大于 1,我们告诉 Jade 我们根本不想设置 style 属性。

Mixins

我们只在login/views/admin.jade视图中使用 Jade mixins,但它们对于管理部分和顶层站点之间的协同作用至关重要。除非用户已登录并在/admin路由下,否则 mixins 不会出现在profiles.jade中。它们只适用于特权用户。

我们使用 mixins 来补充profiles表与管理层。admin.jade中唯一不是 mixin 的部分是最终的include addfrm.jade。由于 admin 在 profiles 之前被包含,#addfrm位于profiles表上方。

adminScript mixin 就像其名称所示,是一个script块,快速地将id设置为js添加到body标签上。我们在admin.styl中使用它来隐藏我们的#addfrm(生成的 CSS 将是#js #addfrm {display:none})。这比直接使用 JavaScript 隐藏元素更快,并且最小化了在页面加载时隐藏页面元素可能出现的不良内容闪烁效果。因此,#addfrm最初是不可见的。在下面的截图中,我们可以看到在管理部分的#profiles表中显示的可见 mixins:

Mixins

点击Add按钮会导致#profiles表淡出,#addfrm淡入。del mixin 接受一个id参数,然后使用它为每个 profile 生成一个链接,例如/del?id=4f3336f369cca0310e000003&p=1p变量是从res.render时间中传递给index路由的page属性确定的。

Helpers

我们在登录应用程序中同时使用静态和动态 helpers。动态 helpers 位于路由中间件的最后一部分和视图渲染之间。因此,它们对发送出去的内容有最后的控制权。我们应用程序中的动态 helpers 保持不变。

静态助手在应用程序启动时设置,并且可以随时被覆盖。这些助手存储在app._locals中,以及其他 Express 预设(例如我们在profiles视图中用于base变量的settings对象)。我们在我们的登录应用程序中使用app.mounted来访问父应用程序对象,以从parent._locals.settings.views中发现父应用程序的视图目录。然后我们将其作为masterviews助手传递给我们登录应用程序的视图。

profiles.jade中,我们这样做:

masterviews = typeof(masterviews) !== 'undefined' ? masterviews : ''

如果在我们的子应用程序中,我们包含来自父应用程序的视图,并且该视图包含另一个父视图,或者加载一个包含父视图的部分,我们可以使用masterviews来确保部分是从父目录加载的。masterviews使profiles.jade能够在两个领域中运行。

样式

我们的 Stylus 文件也具有一定程度的互连性,如下图所示:

样式

用户流程

所有这些都共同作用,为网站提供了一个管理部分。

用户可以浏览配置文件表,使用返回和前进链接,并且可以链接到表上的特定页面。

特权用户可以导航到/admin,输入他们的登录详细信息,并继续添加和删除记录。/add/delete路由受中间件保护。除非用户已登录,否则他们只能访问登录应用程序的index路由,要求登录详细信息。

还有更多...

让我们看看如何监视和分析我们的 Web 应用程序。

基准测试

对 Node 网站进行基准测试可能非常令人满意,但总会有改进的空间。

Apache Bench(ab)与 Apache 服务器捆绑在一起,虽然 Apache 与 NodeJS 无关,但他们的 HTTP 基准测试工具是测试我们应用程序响应大量同时请求能力的绝佳工具。

我们可以使用它来测试对我们应用程序的任何更改的性能优势或劣势。让我们快速地向站点和管理部分分别抛出 1000 个请求,每次 50 个请求,如下所示:

ab -n 1000 -c 50 http://localhost:3000/
ab -n 1000 -c 50 http://localhost:3000/admin 

里程将根据系统功能而异。然而,由于测试是在同一台机器上运行的,因此可以从测试之间的差异中得出结论。

在我们对两个部分的测试中,/每秒传送 120 个请求,而/admin每秒仅传送近 160 个请求。这是有道理的,因为/admin页面只会提供登录表单,而/路由会从 MongoDB 获取数据,对profiles对象执行迭代逻辑,并使用部分来显示行。

使用记录器

Express 带有 Connect 的记录器中间件,它可以输出我们应用程序中有用的自定义信息。在生产场景中,这些信息可能是网站维护的重要部分。

要使用记录器,我们在所有其他中间件之前包含它,如下所示:

app.configure(function(){
  app.set('views', __dirname + '/views');
  app.set('view engine', 'jade');

  app.use(express.logger());

  app.use(express.bodyParser());
  app.use(express.methodOverride());
//rest of configure

默认情况下,记录器会输出如下所示的内容:

127.0.0.1 - - [Thu, 09 Feb 2012 15:56:40 GMT] "GET / HTTP/1.1" 200 908 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.3 Safari/535.19"
127.0.0.1 - - [Thu, 09 Feb 2012 15:56:40 GMT] "GET /stylesheets/style.css HTTP/1.1" 200 1395 "http://localhost:3000/" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.3 Safari/535.19"

它包含有关请求的有用信息,客户端的 IP 地址,用户代理字符串以及内容长度(在示例输出中分别为 908 和 1395)。我们可以使用这些信息来确定一系列事实(例如浏览器统计数据,地理位置统计数据等)。

在开发过程中,我们可能希望设置logger如下所示:

app.use(express.logger('dev'));

这将以颜色格式输出请求信息到控制台,包括请求方法(例如 GET),请求的路由,状态码(例如 200),以及请求完成的时间。

我们还可以传递令牌。例如:

app.use(express.logger(':date -- URL::url Referrer::referrer '));

我们甚至可以像下面的代码中所示定义自己的令牌:

  express.logger.token('external-referrer',function (req, res) {
    var ref = req.header('Referrer'),
      host = req.headers.host;

      if (ref && ref.replace(/http[s]?:\/\//, '').split('/')[0] !== host) {
        return ref;
      } else {
        return '#local#';
      }
  });
  app.use(express.logger(':date -- URL::url Referrer::external-referrer'));

如果引荐者与我们的主机相同,我们用#local#掩盖它。稍后,我们可以过滤掉所有包含#local#的行。

默认情况下,logger输出到控制台。但是,我们可以将其传递给一个流以进行输出。记录器可以让我们通过 TCP、HTTP 或简单地输出到文件来流式传输日志。让我们将我们的日志写入文件:

app.use(express.logger( //writing external sites to log file
  {format: ':date -- URL::url Referrer::external-referrer',
  stream: require('fs').createWriteStream('external-referrers.log')   
}));

这次我们给logger一个对象而不是一个字符串。为了通过对象设置格式,我们设置format属性。为了重定向输出流,我们创建一个writeStream到我们想要的日志文件。

提示

有关更多日志记录器选项,请参阅www.senchalabs.org/connect/middleware-logger.html.

另请参阅

  • 本章讨论的动态路由

  • 本章讨论的 Express 中的模板

  • 本章讨论的 Express 中的 CSS 引擎

  • 本章讨论的初始化和使用会话

第七章:实施安全性、加密和身份验证

在本章中,我们将涵盖:

  • 实施基本身份验证

  • 密码加密哈希

  • 实施摘要身份验证

  • 设置 HTTPS Web 服务器

  • 防止跨站点请求伪造

介绍

在生产 Web 服务器方面,安全性是至关重要的。安全性的重要性与我们提供的数据或服务的重要性相关。但即使对于最小的项目,我们也希望确保我们的系统不容易受到攻击。

许多 Web 开发框架提供了内置的安全性,这是一个双刃剑。一方面,我们不必过分关注细节(除了基本的事情,比如在将用户输入传递到 SQL 语句之前清理用户输入),但另一方面,我们隐含地相信供应商已经堵住了所有的漏洞。

如果发现一个广泛使用的服务器端脚本平台,比如 PHP,包含安全漏洞,这可能会很快成为公开的知识,运行易受攻击版本的框架的每个站点都会面临攻击。

对于 Node 来说,服务器端的安全性几乎完全取决于我们自己。因此,我们只需要教育自己关于潜在的漏洞,并加固我们的系统和代码。

在大多数情况下,Node 是最简约的:如果我们没有明确地概述某些事情,它就不会发生。这使得我们的系统或模糊的配置设置的未知部分很难被利用,因为我们是通过手工编码和配置我们的系统。

攻击发生在两个方面:利用技术缺陷和利用用户的天真。我们可以通过教育自己并认真检查和反复检查我们的代码来保护我们的系统。我们也可以通过教育用户来保护我们的用户。

在本章中,我们将学习如何实施各种类型的用户身份验证登录,如何保护这些登录,并加密任何传输的数据,以及一种防止经过身份验证的用户成为浏览器安全模型漏洞的受害者的技术。

实施基本身份验证

基本身份验证标准自上世纪 90 年代以来一直存在,并且可以是提供用户登录的最简单方式。当在 HTTP 上使用时,它绝对不安全,因为明文密码会从浏览器发送到服务器的连接中。

注意

有关基本身份验证的信息,请参见en.wikipedia.org/wiki/Basic_authentication

然而,当与 SSL(HTTPS)结合使用时,基本身份验证可以是一种有用的方法,如果我们不关心自定义样式的登录表单。

注意

我们将在本章的设置 HTTPS Web 服务器配方中讨论 SSL/TLS(HTTPS)。有关更多信息,请参见en.wikipedia.org/wiki/SSL/TLS

在这个配方中,我们将学习如何在纯 HTTP 上启动和处理基本访问身份验证请求。在接下来的配方中,我们将实施一个 HTTPS 服务器,并看到基本身份验证(摘要身份验证)的进展。

准备工作

我们只需要在一个新的文件夹中创建一个新的server.js文件。

如何做...

基本身份验证指定用户名、密码和领域,并且它在 HTTP 上工作。因此,我们将需要 HTTP 模块,并设置一些变量:

var http = require('http');

var username = 'dave',
  password = 'ILikeBrie_33',
  realm = "Node Cookbook";

现在我们将设置我们的 HTTP 服务器:

http.createServer(function (req, res) {
  var auth, login;

  if (!req.headers.authorization) {
    authenticate(res);
    return;
  }

  //extract base64 encoded username:password string from client
  auth = req.headers.authorization.replace(/^Basic /, '');
  //decode base64 to utf8
  auth = (new Buffer(auth, 'base64').toString('utf8'));

  login = auth.split(':'); //[0] is username [1] is password

  if (login[0] === username && login[1] === password) {
    res.end('Someone likes soft cheese!');
    return;
  }

  authenticate(res);

}).listen(8080);

请注意,我们对名为authenticate的函数进行了两次调用。我们需要创建这个函数,并将其放在我们的createServer调用之上:

function authenticate(res) {
  res.writeHead(401,
     {'WWW-Authenticate' : 'Basic realm="' + realm + '"'});
  res.end('Authorization required.');
}

当我们在浏览器中导航到localhost:8080时,我们被要求为Node Cookbook领域提供用户名和密码。如果我们提供了正确的详细信息,我们对软奶酪的热情就会显露出来。

它是如何工作的...

基本身份验证通过服务器和浏览器之间发送的一系列标头进行工作。当浏览器访问服务器时,WWW-Authenticate标头被发送到浏览器,浏览器会打开一个对话框让用户登录。

浏览器的登录对话框会阻止加载其他内容,直到用户取消或尝试登录。如果用户按下取消按钮,他们会看到authenticate函数中使用res.end发送的需要授权消息。

然而,如果用户尝试登录,浏览器会向服务器发送另一个请求。这次响应WWW-Authenticate头部包含一个Authorization头部。我们在createServer回调的顶部检查它是否存在,使用req.headers.authorization。如果头部存在,我们跳过对authenticate的调用,继续验证用户凭据。Authorization头部如下所示:

Authorization: Basic ZGF2ZTpJTGlrZUJyaWVfMzM=

Basic后面的文本是一个 Base64 编码的字符串,其中包含用冒号分隔的用户名和密码,因此解码后的 Base64 文本是:

dave:ILikeBrie_33

在我们的createServer回调中,我们首先从中剥离Basic部分,然后将其加载到一个将 Base64 转换为二进制的缓冲区中,然后对结果运行toString,将其转换为 UTF8 字符串。

有关 Base64 和 UTF-8 等字符串编码的信息,请参阅en.wikipedia.org/wiki/Base64en.wikipedia.org/wiki/Comparison_of_Unicode_encodings

最后,我们用冒号split登录详细信息,如果提供的用户名和密码与我们存储的凭据匹配,用户将获得对授权内容的访问权限。

还有更多...

基本身份验证作为中间件捆绑在 Express 框架中。

使用 Express 进行基本身份验证

Express(通过 Connect)提供了basicAuth中间件,它为我们实现了这种模式。要在 Express 中实现相同的功能:

var express = require('express');

var username = 'dave',
  password = 'ILikeBrie_33',
  realm = "Node Cookbook";

var app = express.createServer();

app.use(express.basicAuth(function (user, pass) {
  return username === user && password === pass;
}, realm));

app.get('/:route?', function (req, res) {
  res.end('Somebody likes soft cheese!');
});

app.listen(8080);

如果我们现在转到http://localhost:8080,我们的 Express 服务器将与我们的主要示例表现出相同的行为。

提示

有关使用 Express 开发 Web 解决方案的信息,请参阅第六章使用 Express 加速开发

另请参阅

  • 在第一章中讨论了设置路由*,制作 Web 服务器

  • 本章讨论了实现摘要身份验证

  • 本章讨论了设置 HTTPS Web 服务器

密码加密哈希

有效的加密是在线安全的基本部分。Node 提供了crypto模块,我们可以使用它来为用户密码生成我们自己的 MD5 或 SHA1 哈希。诸如 MD5 和 SHA1 之类的加密哈希被称为消息摘要。一旦输入数据被摘要(加密),它就不能被放回其原始形式(当然,如果我们知道原始密码,我们可以重新生成哈希并将其与我们存储的哈希进行比较)。

我们可以使用哈希来加密用户的密码,然后再存储它们。如果我们存储的密码被攻击者窃取,他们无法用来登录,因为攻击者没有实际的明文密码。然而,由于哈希算法总是产生相同的结果,攻击者可能通过将其与从密码字典生成的哈希进行匹配来破解哈希(有关缓解此问题的方法,请参见还有更多...部分)。

注意

有关哈希的更多信息,请参阅en.wikipedia.org/wiki/Cryptographic_hash_function

在这个例子中,我们将创建一个简单的注册表单,并使用crypto模块来生成通过用户输入获得的密码的 MD5 哈希。

与基本身份验证一样,我们的注册表单应该通过 HTTPS 发布,否则密码将以明文形式发送。

准备工作

在一个新的文件夹中,让我们创建一个新的server.js文件,以及一个用于注册表单的 HTML 文件。我们将称其为regform.html

我们将使用 Express 框架来提供外围机制(解析 POST 请求,提供regform.html等),因此应该安装 Express。我们在上一章中更详细地介绍了 Express 以及如何安装它。

如何做...

首先,让我们组合我们的注册表单(regform.html):

<form method=post>
  User  <input name=user>
  Pass <input type=password name=pass>
  <input type=submit>
</form>

对于server.js,我们将需要expresscrypto。然后创建我们的服务器如下:

var express = require('express');
var crypto = require('crypto');

var userStore = {},
  app = express.createServer().listen(8080);

app.use(express.bodyParser());

bodyParser给了我们 POST 的能力,我们的userStore对象用于存储注册用户的详细信息。在生产中,我们会使用数据库。

现在设置一个 GET 路由,如下所示的代码:

app.get('/', function (req, res) {
  res.sendfile('regform.html');
});

这使用 Express 的sendfile方法来流式传输我们的regform.html文件。

最后,我们的 POST 路由将检查userpass输入的存在,将用户指定的密码转换为 MD5 哈希值。

app.post('/', function (req, res) {
  if (req.body && req.body.user && req.body.pass) {  
    var hash = crypto
      .createHash("md5")
      .update(req.body.pass)
      .digest('hex');

    userStore[req.body.user] = hash;
    res.send('Thanks for registering ' + req.body.user);
    console.log(userStore);
  }
});

当我们使用我们的表单进行注册时,控制台将输出userStore对象,其中包含所有注册的用户名和密码哈希。

它是如何工作的...

这个配方的密码哈希部分是:

    var hash = crypto
      .createHash("md5")
      .update(req.body.pass)
      .digest('hex');

我们使用点符号将一些crypto方法链接在一起。

首先,我们使用createHash创建一个普通的 MD5 哈希(请参阅还有更多...部分,了解如何创建唯一的哈希)。我们也可以通过传递sha1作为参数来创建(更强大的)SHA1 哈希。对于 Node 捆绑的openssl版本(截至 Node 0.6.17 为止)支持的任何其他加密方法也是一样的。

有关不同哈希函数的比较,请参见ehash.iaik.tugraz.at/wiki/The_Hash_Function_Zoo

注意

该网站将某些哈希函数标记为破解,这意味着已经发现并发布了一个弱点。然而,利用这种弱点所需的工作量通常远远超过我们正在保护的数据的价值。

然后我们调用update来将用户的密码传递给初始哈希。

最后,我们调用digest方法,它返回一个完成的密码哈希。没有任何参数,digest会以二进制格式返回哈希值。我们传递hex(二进制数据的十六进制表示格式,参见en.wikipedia.org/wiki/Hexadecimal))以使其在控制台上更易读。

还有更多...

crypto模块提供了一些更高级的哈希方法,用于创建更强大的密码。

使用 HMAC 使哈希值唯一化

HMAC代表基于哈希的消息认证码。这是一个带有秘密密钥的哈希(认证码)。

要将我们的配方转换为使用 HMAC,我们将我们的crypto部分更改为:

    var hash = crypto
        .createHmac("md5",'SuperSecretKey')
        .update(req.body.pass)
        .digest('hex');

使用 HMAC 可以保护我们免受彩虹表(从大量可能的密码列表中预先计算的哈希)的影响。秘密密钥会改变我们的哈希值,使得彩虹表无效(除非攻击者发现我们的秘密密钥,例如,通过某种方式获得了对我们服务器操作系统的根访问权限,在这种情况下,彩虹表将不再是必要的)。

使用 PBKDF2 进行加固的哈希值

PBKDF2是基于密码的密钥派生函数的第二个版本,它是基于密码的加密标准的一部分。

PBKDF2 的一个强大特性是它生成数千次的哈希值。多次迭代哈希值可以增强加密,指数级增加了可能结果的数量,使得生成或存储所有可能的哈希值所需的硬件变得不可行。

pbkdf2需要四个组件:所需的密码,盐值,所需的迭代次数和生成哈希值的指定长度。

盐在概念上类似于 HMAC 中的秘密密钥,因为它与我们的哈希混合在一起,创建了一个不同的哈希。然而,盐的目的不同。盐只是为哈希添加了独特性,并不需要像秘密一样受到保护。一个强大的方法是使每个盐对生成的哈希是唯一的,并将其存储在哈希旁边。如果数据库中的每个哈希都是从不同的盐生成的,攻击者就被迫为每个哈希基于其盐生成一个彩虹表,而不是整个数据库。有了 PBKDF2,由于我们的盐,我们有了唯一哈希的唯一哈希,这为潜在攻击者增加了更多的复杂性。

对于强盐,我们将使用cryptorandomBytes方法生成 128 字节的随机数据,然后通过pbkdf2方法将其与用户提供的密码迭代 7,000 次,最终创建一个 256 字节长的哈希。

为了实现这一点,让我们修改配方中的 POST 路由。

app.post('/', function (req, res) {
  if (req.body && req.body.user && req.body.pass) {
    crypto.randomBytes(128, function (err, salt) {
      if (err) { throw err;}
      salt = new Buffer(salt).toString('hex');
      crypto.pbkdf2(req.body.pass, salt, 7000, 256, function (err, hash) {
        if (err) { throw err; }
        userStore[req.body.user] = {salt : salt,
          hash : (new Buffer(hash).toString('hex')) };
        res.send('Thanks for registering ' + req.body.user);
        console.log(userStore);
      });
    });
  }
});

randomBytespbkdf2是异步的,这很有帮助,因为它允许我们执行其他任务,或者通过立即将用户带到新页面来改善用户体验,而他们的凭据正在被加密。这是通过简单地将res.send放在回调之外来完成的(我们在这里没有这样做,但这可能是一个好主意,因为这样大量的加密可能需要大约一秒钟来计算)。

一旦我们有了哈希和盐值,我们将它们放入我们的userStore对象中。要实现相应的登录,我们只需以相同的方式使用用户存储的盐来计算哈希。

我们选择迭代 7,000 次。当 PBKDF2 被标准化时,推荐的迭代次数是 1,000。然而,我们需要更多的迭代来考虑技术进步和设备成本的降低。

参见

  • 在本章中讨论的实现摘要身份验证

  • 设置 HTTPS Web 服务器在本章中讨论

  • 生成 Express 脚手架在第六章中讨论,使用 Express 加速开发

实现摘要身份验证

摘要身份验证将基本身份验证与 MD5 加密结合在一起,从而避免传输明文密码,在普通 HTTP 上实现了更安全的登录方法。

单独使用摘要身份验证仍然不安全,没有 SSL/TLS 安全的 HTTPS 连接。在普通 HTTP 上的任何内容都容易受到中间人攻击的威胁,对手可以拦截请求并伪造响应。攻击者可以冒充服务器,用基本身份验证响应替换预期的摘要响应,从而获得明文密码。

尽管在没有 SSL/TLS 的情况下,摘要身份验证至少为我们提供了一些在明文密码方面需要更高级规避技术的防御。

因此,在这个配方中,我们将创建一个摘要身份验证服务器。

准备工作

首先,我们只需创建一个新的文件夹和一个新的server.js文件。

如何做到...

就像基本身份验证配方中我们创建了一个 HTTP 服务器一样,我们还将使用crypto模块来处理 MD5 哈希:

var http = require('http');
var crypto = require('crypto');

var username = 'dave',
  password = 'digestthis!',
  realm = "Node Cookbook",
  opaque;

function md5(msg) {
  return crypto.createHash('md5').update(msg).digest('hex');
}

opaque = md5(realm);

我们创建了一个md5函数作为crypto哈希方法的简写接口。opaque变量是Digest标准的必要部分。它只是realm的 MD5 哈希(也用于基本身份验证)。客户端将 opaque 值返回给服务器,以提供额外的响应验证手段。

现在我们将创建两个额外的辅助函数,一个用于身份验证,另一个用于解析Authorization头,如下所示:

function authenticate(res) {
  res.writeHead(401, {'WWW-Authenticate' : 'Digest realm="' + realm + '"'
    + ',qop="auth",nonce="' + Math.random() + '"'
    + ',opaque="' + opaque + '"'});

  res.end('Authorization required.');
}

function parseAuth(auth) {
  var authObj = {};
  auth.split(', ').forEach(function (pair) {
    pair = pair.split('=');
    authObj[pair[0]] = pair[1].replace(/"/g, '');
  });
  return authObj;
}

最后,我们按照以下代码实现服务器:

http.createServer(function (req, res) {
  var auth, user, digest = {};

  if (!req.headers.authorization) {
    authenticate(res);
    return;
  }
  auth = req.headers.authorization.replace(/^Digest /, '');
  auth = parseAuth(auth); //object containing digest headers from client
  //don't waste resources generating MD5 if username is wrong
  if (auth.username !== username) { authenticate(res); return; }

  digest.ha1 = md5(auth.username + ':' + realm + ':' + password);
  digest.ha2 = md5(req.method + ':' + auth.uri);
  digest.response = md5([
    digest.ha1,
    auth.nonce, auth.nc, auth.cnonce, auth.qop,
    digest.ha2
  ].join(':'));

  if (auth.response !== digest.response) { authenticate(res); return; }
  res.end('You made it!');

}).listen(8080);

在浏览器中,这看起来与基本身份验证完全相同,这是不幸的,因为摘要和基本对话框之间的明显区别可能会提醒用户可能发生攻击。

它是如何工作的...

当服务器向浏览器发送WWW-Authenticate头时,包括几个属性,包括:realm, qop, nonceopaque

realm与基本身份验证相同,opaquerealm的 MD5 哈希。

qop代表 Quality of Protection,设置为auth. qop也可以设置为auth-int或者简单地省略。通过将其设置为auth,我们使浏览器计算出更安全的最终 MD5 哈希。auth-int更加强大,但浏览器对其的支持很少。

nonce类似于盐的概念,它使最终的 MD5 哈希在攻击者的视角下更不可预测。

当用户通过浏览器的身份验证对话框提交其登录详细信息时,将返回一个包含服务器发送的所有属性以及username, uri, nc, cnonceresponse属性的Authorization头。

username是用户指定的别名,uri是正在访问的路径(我们可以使用它来基于路由进行安全保护),nc是一个序列计数器,每次认证尝试时都会递增,cnonce是浏览器自己生成的nonce值,response是最终计算出的哈希。

为了确认经过身份验证的用户,我们的服务器必须匹配response的值。为此,它删除Authorization头中的Digest字符串(包括前面的空格),然后将剩下的内容传递给parseAuth函数。parseAuth将所有属性转换为一个方便的对象,并将其加载回我们的auth变量中。

我们对auth的第一件事是检查用户名是否正确。如果没有匹配,我们会再次要求进行身份验证。这可以节省我们的服务器一些不必要的 MD5 哈希运算。

最终计算出的 MD5 哈希是由两个先前加密的 MD5 哈希以及服务器的nonceqop值以及客户端的cnoncenc值的组合生成的。

我们称第一个哈希为digest.ha1。它包含一个以冒号(:)分隔的字符串,其中包括username, realmpassword的值。digest.ha2request方法(GET)和uri属性,同样用冒号分隔。

digest.response属性必须与浏览器生成的auth.response匹配,因此排序和特定元素必须精确。为了创建我们的digest.response,我们将digest.ha1nonce, nccnonce, qopdigest.ha2分别用冒号分隔,然后将这些值放入数组中,运行 JavaScript 的join方法生成最终字符串,然后传递给我们的md5函数。

如果给定的用户名和密码正确,并且我们正确生成了digest.response,它应该与浏览器的response头属性(auth.response)匹配。如果不匹配,用户将被要求进行另一次身份验证对话框。如果匹配,我们到达最终的res.end。我们成功了!

还有更多...

让我们解决注销问题。

退出经过身份验证的区域

在基本或摘要身份验证下,浏览器几乎没有任何官方注销方法的支持,除了关闭整个浏览器。

然而,我们可以通过更改WWW-Authenticate头中的realm属性来强制浏览器基本上失去其会话。

在多用户情况下,如果我们更改全局realm变量,将导致所有用户注销(如果有多个用户)。因此,如果用户希望注销,我们必须为他们分配一个唯一的领域,这将导致只有他们的会话退出。

为了模拟多个用户,我们将删除我们的usernamepassword变量,并用一个users对象替换它们:

var users = {
              'dave' : {password : 'digestthis!'},
              'bob' : {password : 'MyNamesBob:-D'},
            },
  realm = "Node Cookbook",
  opaque;

我们的子对象(当前包含password)可能会获得三个额外的属性:uRealm, uOpaqueforceLogOut

接下来,我们将修改我们的authenticate函数如下:

ffunction authenticate(res, username) {
  var uRealm = realm, uOpaque = opaque;
  if (username) {
    uRealm = users[username].uRealm;
    uOpaque = users[username].uOpaque;
  }
  res.writeHead(401, {'WWW-Authenticate' :
     'Digest realm="' + uRealm + '"'
    + ',qop="auth",nonce="' + Math.random() + '"'
    + ',opaque="' + uOpaque + '"'});

  res.end('Authorization required.');
}

我们向authenticate函数添加了一个可选的username参数。如果username存在,我们加载该用户的唯一realm和相应的opaque值,并将它们发送到标头中。

在我们的服务器回调中,我们替换了这段代码:

  //don't waste resources generating MD5 if username is wrong
  if (auth.username !== username) { authenticate(res); return; }

使用以下代码:

  //don't waste resources generating MD5 if username is wrong
  if (!users[auth.username]) { authenticate(res); return; }

  if (req.url === '/logout') {
    users[auth.username].uRealm = realm + ' [' + Math.random() + ']';
    users[auth.username].uOpaque = md5(users[auth.username].uRealm);
    users[auth.username].forceLogOut = true;
    res.writeHead(302, {'Location' : '/'});
    res.end();
    return;
  }

  if (users[auth.username].forceLogOut) {
    delete users[auth.username].forceLogOut;
    authenticate(res, auth.username);
  }

我们检查指定的用户名是否存在于我们的“用户”对象中,如果不存在,则无需进行进一步处理。只要用户有效,我们就检查路由(我们将为用户提供注销链接)。如果已命中/logout路由,我们会在已登录用户的对象上设置一个uRealm属性和一个包含uRealm的 MD5 哈希的相应uOpaque属性。我们还添加一个forceLogOut布尔属性,并将其设置为true。然后我们将用户从/logout重定向到/

重定向触发另一个请求,服务器检测到当前经过身份验证的用户的forceLogOut属性的存在。然后,从“用户”子对象中删除forceLogOut,以防止它在以后造成干扰。最后,我们将控制权交给具有特殊username参数的authenticate函数。

因此,authenticateWWW-Authenticate标头中包含了与用户关联的uRealmuOpaque值,从而中断了会话。

最后,我们进行了一些微不足道的调整。

digest.ha1需要“密码”和realm值,因此更新如下:

  digest.ha1 = md5(auth.username + ':'
    + (users[auth.username].uRealm || realm) + ':'
    + users[auth.username].password);

通过我们的新“用户”对象输入“密码”值,并根据我们已登录的用户是否设置了唯一领域(uRealm属性)来选择realm值。

我们将服务器代码的最后一部分更改为以下内容:

if (auth.response !== digest.response) {
    users[auth.username].uRealm = realm + ' [' + Math.random() + ']';
    users[auth.username].uOpaque = md5(users[auth.username].uRealm);
    authenticate(res, (users[auth.username].uRealm && auth.username));
    return;
  }
  res.writeHead(200, {'Content-type':'text/html'});
  res.end('You made it! <br> <a href="logout"> [ logout ] </a>');

注意到包含了注销链接,这是最后一块拼图。

如果哈希不匹配,则会生成新的uRealmuOpaque属性。这可以防止浏览器和服务器之间的永久循环。如果没有这个,当我们以有效用户身份登录然后注销时,我们将被呈现另一个登录对话框。如果我们输入一个不存在的用户,新的登录尝试将像正常情况下一样被服务器拒绝。然而,浏览器试图提供帮助,并回到我们第一个已登录用户和原始领域的旧身份验证详细信息。但是,当服务器接收到旧的登录详细信息时,它将用户与其唯一领域进行匹配,要求对uRealm而不是realm进行身份验证。浏览器看到uRealm值并将我们的不存在的用户与其匹配,尝试再次作为该用户进行身份验证,从而重复循环。

通过设置新的uRealm,我们打破了循环,因为引入了一个浏览器没有记录的额外领域,所以它会要求用户输入。

另请参阅

  • 本章讨论了实施基本身份验证

  • 本章讨论了密码哈希加密

  • 本章讨论了设置 HTTPS Web 服务器

设置 HTTPS Web 服务器

在大多数情况下,HTTPS 是解决许多安全漏洞(如网络嗅探和中间人攻击)的解决方案。

感谢核心的https模块。设置起来非常简单。

准备就绪

更大的挑战可能在于实际获取必要的 SSL/TLS 证书。

为了获得证书,我们必须生成加密的私钥,然后从中生成证书签名请求。然后将其传递给证书颁发机构(一个专门受到浏览器供应商信任的商业实体——这自然意味着我们必须为此付费)。或者,CA 可以代表您生成您的私钥和证书签名请求。

注意

StartSSL 公司提供免费证书。关于如何在 Node 上使用 StartSSL 证书的文章可以在www.tootallnate.net/setting-up-free-ssl-on-your-node-server找到。

经过验证过程后,证书颁发机构(CA)将颁发一个公共证书,使我们能够加密我们的连接。

我们可以简化这个过程并授权我们自己的证书(自签名),将自己命名为 CA。不幸的是,如果 CA 对浏览器不知情,它将明确警告用户我们的网站不值得信任,他们可能受到攻击。这对于积极的品牌形象并不好。因此,虽然在开发过程中我们可以自签名,但在生产中我们很可能需要一个受信任的 CA。

对于开发,我们可以快速使用openssl可执行文件(在 Linux 和 Mac OS X 上默认可用,我们可以从www.openssl.org/related/binaries.html获取 Windows 版本)来生成必要的私钥和公共证书:

openssl req -new -newkey rsa:1024 -nodes -subj '/O=Node Cookbook' -keyout key.pem -out csr.pem && openssl x509 -req -in csr.pem -signkey key.pem -out cert.pem 

这在命令行上执行openssl两次:一次用于生成基本的私钥和证书签名请求,再次用于自签名私钥,从而生成证书(cert.pem)。

在实际生产场景中,我们的-subj标志将包含更多细节,我们还需要从合法的 CA 获取我们的cert.pem文件。但这对于私人、开发和测试目的来说是可以的。

现在我们有了密钥和证书,我们只需要创建一个新的server.js文件来启动我们的服务器。

如何做...

server.js中,我们编写以下代码:

var https = require('https');
var fs = require('fs');

var opts = {key: fs.readFileSync('key.pem'),
  cert: fs.readFileSync('cert.pem')};

https.createServer(opts, function (req, res) {
  res.end('secured!');
}).listen(4443); //443 for prod

就是这样!

工作原理...

https模块依赖于httptls模块,而这些模块又依赖于netcrypto模块。SSL/TLS 是传输层加密,这意味着它在 HTTP 的下层,在 TCP 层起作用。tlsnet模块一起提供 SSL/TLS 加密的 TCP 连接,HTTPS 则在其之上。

当客户端通过 HTTPS 连接(在我们的情况下,地址为https://localhost:4443)时,它会尝试与我们的服务器进行 TLS/SSL 握手。https模块使用tls模块来响应握手,通过一系列的信息交换来确认浏览器和服务器之间的握手。(例如,你支持什么 SSL/TLS 版本?你想使用什么加密方法?我可以拿到你的公钥吗?)

在这个初始的交换结束时,客户端和服务器有了一个约定的共享密钥。这个密钥用于加密和解密双方之间发送的内容。这就是crypto模块发挥作用的地方,提供所有的数据加密和解密功能。

对我们来说,只需引入https模块,提供我们的证书,然后像使用http服务器一样使用它。

还有更多...

让我们看一些 HTTPS 使用案例。

在 Express 中使用 HTTPS

在 Express 中启用 HTTPS 同样简单:

var express = require('express'),
  fs = require('fs');

var opts = {key: fs.readFileSync('key.pem'),
  cert: fs.readFileSync('cert.pem')};

var app = express.createServer(opts).listen(8080);

app.get('/', function (req, res) {
  res.send('secured!');
});

使用 SSL/TLS 保护基本身份验证

我们可以在我们的https服务器中构建任何东西,就像在http服务器中一样。要在我们的基本身份验证配方中启用 HTTPS,我们只需修改:

https.createServer(function (req, res) {

以下是:

var opts = {key: fs.readFileSync('key.pem'),
  cert: fs.readFileSync('cert.pem')};

https.createServer(opts, function (req, res) {

另请参阅

  • 本章讨论的密码哈希

  • 在本章讨论的基本身份验证中实施

防止跨站点请求伪造

每个浏览器的安全模型都存在问题,作为开发者,我们必须意识到这一点。

当用户登录到网站后,通过经过身份验证的浏览器发出的任何请求都被视为合法的——即使这些请求的链接来自电子邮件,或者在另一个窗口中执行。一旦浏览器有了会话,所有窗口都可以访问该会话。

这意味着攻击者可以通过特制的链接或自动的 AJAX 调用来操纵用户在已登录的网站上的操作,而无需用户交互,只需在包含恶意 AJAX 的页面上。

例如,如果一个银行网站应用程序没有得到适当的 CSRF 保护,攻击者可以说服用户在登录到在线银行时访问另一个网站。然后,这个网站可以运行一个 POST 请求,将资金从受害者的账户转移到攻击者的账户,而受害者并不知情也没有同意。

这被称为跨站点请求伪造(CSRF)攻击。在这个 recipe 中,我们将实现一个带有 CSRF 保护的安全 HTML 登录系统。

准备工作

我们将从第六章中讨论的制作一个 Express Web 应用程序recipe 中保护我们的 Profiler Web 应用程序。我们将想要获取我们的profiler应用程序,打开profiler/app.jsprofiler/login/app.js文件进行编辑。

没有 SSL/TLS 加密,基于 HTML 的登录至少会受到与基本授权相同的漏洞的影响。因此,为了基本安全,我们将在我们的应用程序中添加 HTTPS。因此,我们需要从上一个 recipe 中获取我们的cert.pemkey.pem文件。

我们还需要让 MongoDB 运行,并从第六章中的 recipes 中存储用户数据,因为我们的profiler应用程序依赖于它。

sudo mongod --dbpath [PATH TO DB] 

如何做...

首先,让我们用 SSL 保护整个应用程序,profiler/app.js的顶部应该如下所示的代码:

var express = require('express')
  , routes = require('./routes')
    , fs = require('fs');

var opts = {key: fs.readFileSync('key.pem'),
  cert: fs.readFileSync('cert.pem')};

var app = module.exports = express.createServer(opts);

profileradmin部分是一个 CSRF 攻击可能发生的地方,所以让我们打开profiler/login/app.js并添加express.csrf中间件。profiler/login/app.jsapp.configure回调的顶部应该如下所示的代码:

app.configure(function(){
  app.set('views', __dirname + '/views');
  app.set('view engine', 'jade');

  app.use(express.bodyParser());
  app.use(express.methodOverride());

  app.use(express.cookieParser()); 
  app.use(express.session({secret: 'kooBkooCedoN'}));

  app.use(express.csrf());
//rest of configure callback

提示

Express 3.x.x

不要忘记,在 Express 3.x.x 中,秘密作为字符串进入cookieParser而不是作为对象进入session: express.cookieParser('kooBkooCedoN')

csrf中间件依赖于bodyParsersession中间件,因此必须放在这些中间件之下。

现在,如果我们导航到https://localhost:3000/admin并尝试登录(dave, expressrocks),我们将收到一个403 Forbidden的响应,即使我们使用了正确的详细信息。

这是因为我们的登录应用程序现在在所有的 POST 表单中寻找一个名为_csrf的额外的 POST 参数,它必须与用户session对象中存储的_csrf值匹配。

我们的视图需要知道_csrf的值,这样它就可以被放置在我们的表单中作为一个隐藏元素。

我们将使用dynamicHelper为所有登录视图提供req.session._csrf

app.dynamicHelpers({ //Express 3.x.x would use app.locals.use instead
  user: function (req, res) { //see Chapter 6 for details. 
    return req.session.user;
  },
  flash: function (req, res) {
    return req.flash();
  },
  _csrf: function (req) {
    return req.session._csrf;
  }
});

接下来,我们将在login/views文件夹中创建一个名为csrf.jade的视图,如下所示:

input(type="hidden", name="_csrf", value=_csrf);

现在我们在每个 POST 表单中包含csrf.jade

login.jade:

//prior login jade code above
if user
  form(method='post')
    input(name="_method", type="hidden", value="DELETE")
    include csrf
    p Hello #{user.name}!
      a(href='javascript:', onClick='forms[0].submit()') [logout]

  include admin
  include ../../views/profiles

else
  p Please log in
  form(method="post")
    include csrf
    fieldset
      legend Login
//rest of login.jade

addfrm.jade:

fields = ['Name', 'Irc', 'Twitter', 'Github', 'Location', 'Description'];
form#addfrm(method='post', action='/admin/add')
  include csrf
  fieldset
    legend Add
//rest of addfrm.jade

提示

更新和维护一个有许多不同 POST 表单的网站可能会带来挑战。我们将不得不手动修改每一个表单。看看我们如何在还有更多...部分为所有表单自动生成 CSRF 值。

现在我们可以登录,添加配置文件,并且不会收到403 Forbidden的响应。

然而,我们的/del路由仍然容易受到 CSRF 的攻击。GET 请求通常不应该触发服务器上的任何更改。它们只是用来检索信息。然而,像许多其他应用程序一样,开发人员(也就是我们)在构建这个特定功能时懒惰,并决定强迫 GET 请求来执行他们的命令。

我们可以将这个转换为一个 POST 请求,然后用 CSRF 进行保护,但是对于一个有数百个这种异常 GET 请求的应用程序呢?

让我们找出如何保护我们的/del路由。在login/routes/index.js中添加以下代码:

exports.delprof = function (req, res) {
  if (req.query._csrf !== req.session._csrf) {
    res.send(403);
    return;
  };
    profiles.del(req.query.id, function (err) {
      if (err) { console.log(err); }
        profiles.pull(req.query.p, function (err, profiles) {
          req.app.helpers({profiles: profiles});
          res.redirect(req.header('Referrer') || '/');
        });
    });

}

我们的更改使得我们不能删除配置文件,直到我们在查询字符串中包含_csrf,所以在views/admin.jade:中:

mixin del(id)
  td
    a.del(href='/admin/del?id=#{id}&p=#{page}&_csrf=#{_csrf}') 
      &#10754;
//rest of admin.jade

工作原理...

csrf中间件生成一个唯一的令牌,保存在用户的会话中。这个令牌必须包含在任何操作请求(登录、登出、添加或删除)中,作为名为_csrf的属性。

如果请求体中(或 GET 的查询字符串中)的_csrf值与session对象中存储的_csrf令牌不匹配,服务器将拒绝访问该路由,从而阻止操作的发生。

这如何防止 CSRF 攻击?在普通的 CSRF 攻击中,攻击者无法知道_csrf值是多少,因此他们无法伪造必要的 POST 请求。

我们的/del路由保护不够安全。它在地址中暴露了_csrf值,可能为攻击者抓取_csrf值创造了一个非常小但仍然可信的机会。这就是为什么最好是我们坚持使用 POST/DELETE/PUT 请求来处理所有与操作相关的努力,将 GET 请求留给简单的检索。

跨站脚本(XSS)规避

在伴随的 XSS 攻击事件中,这种保护变得无效,攻击者能够在网站中植入自己的 JavaScript(例如,通过利用输入漏洞)。JavaScript 可以读取页面上的任何元素,并查看document.cookie中的非 HttpOnly cookie。

还有更多...

我们将研究一种自动生成登录表单 CSRF 令牌的方法,但我们也应该记住 CSRF 保护只有我们编写严密的能力才能做到。

自动保护带有 CSRF 元素的 POST 表单

确保我们应用程序中的所有 POST 表单都包含一个隐藏的_csrf输入元素可能是一个艰巨的任务。

我们可以直接与一些 Jade 内部交互,自动包含这些元素。

首先,在login/app.js中,我们向配置中添加了以下设置:

  app.set('view options', {compiler: require('./customJadeCompiler')});

Express 允许我们将特定选项推送到我们正在使用的视图引擎。Jade 选项之一(我们的视图引擎)是compile,它允许我们定义我们自己的自定义 Jade 解释器。

让我们创建customJadeCompiler.js并将其放在login目录中。

首先,我们需要一些模块并设置我们的新编译器类如下:

var jade = require('jade');
var util = require('util');

//inherit from Jades Compiler
var CompileWithCsrf = function (node, options) {
  jade.Compiler.call(this, node, options);
};

接下来,我们使用util.inherits从 Jades 编译器继承我们新编译器的原型。

//inherit from the prototype
util.inherits(CompileWithCsrf, jade.Compiler);

然后我们修改 Jade 的内部visitTag方法(我们从jade.Compiler那里继承的):

CompileWithCsrf.prototype.visitTag = function (tag) {

   if (tag.name === 'form' && tag.getAttribute('method').match(/post/i)) {

    var csrfInput = new jade.nodes.Tag('input')
      .setAttribute('type', '"hidden"')
      .setAttribute('name', '"csrf"')
      .setAttribute('value', '_csrf');

    tag.block.push(csrfInput);

  }
  jade.Compiler.prototype.visitTag.call(this, tag);
};

最后,我们将我们的新编译器加载到module.exports中,以便通过require传递给app.jsview options设置的compiler选项:

module.exports = CompileWithCsrf;

我们创建了一个新的类类型函数,将call方法应用于jade.Compiler。当我们将this对象传递给call方法时,我们实质上将jade.Compiler的主要功能继承到我们自己的CompileWithCsrf类类型函数中。这是重复使用代码的好方法。

然而,jade.Compiler还有一个修改过的原型,必须合并到我们的CompileWithCsrf中,以便完全模仿jade.Compiler

我们使用了util.inherits,但我们也可以这样说:

CompileWithCsrf.prototype = new jade.Compiler();

或者:

CompileWithCsrf = Object.create(jade.Compiler);

甚至可以说:

CompileWithCsrf.prototype.__proto__ = jade.Compiler.prototype;

Object.create是 Ecmascript5 的方法,new是旧的方法,__proto__是应该避免使用的非标准方法。它们都继承了jade.Compiler的附加方法和属性。但是,util.inherits是首选的,因为它还添加了一个特殊的super属性,其中包含我们最初继承的对象。

使用callutil.inherits本质上允许我们将jade.Compiler对象克隆为CompileWithCsrf,这意味着我们可以修改它而不影响jade.Compiler,然后允许它代替jade.Compiler运行。

我们修改visitTag方法,该方法处理 Jade 视图中的每个标签(例如,p,div等)。然后,我们使用正则表达式寻找method属性设置为postform标签,因为method属性可能是大写或小写,用双引号或单引号括起来。

如果我们找到一个使用 POST 格式的form,我们使用jade.Nodes构造函数创建一个新的输入node(在这种情况下作为 HTML 元素),然后调用setAttribute(一个内部的 Jade 方法)三次来设置type, namevalue字段。注意name设置为'"_csrf"'value包含'_csrf'。内部的双引号告诉 Jade 我们打算使用一个字符串。没有它们,它会将第二个参数视为一个变量,这正是我们在value的情况下想要的。因此,value属性根据app.js中定义的_csrf dynamicHelper(同样是从express.csrf中间件生成的req.session._csrf中获取)进行渲染。

现在我们的_csrf令牌已经自动包含在每个 POST 表单中,我们可以从login.jadeaddfrm.jade视图中删除csrf.jade的包含。

消除跨站脚本(XSS)漏洞

跨站脚本攻击通常是可以预防的,我们所要做的就是确保任何用户输入都经过验证和编码。棘手的部分在于我们在不正确或不充分地对用户输入进行编码的地方。

当我们接受用户输入时,大部分时间我们会在稍后的阶段将其输出到浏览器中,这意味着我们必须将其嵌入到我们的 HTML 中。

XSS 攻击主要是破坏上下文。例如,想象一下,我们有一些 Jade 代码,通过用户名链接到用户个人资料:

a (href=username) !{username}

这段代码有两种可利用的方式。首先,我们在锚链接元素的文本部分使用了!{username}而不是#{username}。在 Jade 中,#{}插值会转义给定变量中的任何 HTML。因此,如果攻击者能够插入:

<script>alert('This is where you get hacked!')</script>

作为他们的用户名,#{username}会渲染为:

&lt;script&gt;alert('This is where you get hacked!')&lt;/script&gt;

!{username}将不会被转义(例如,HTML 不会被转换为转义字符,如&lt;代替<)。攻击代码可以从一个无辜(尽管活泼)的alert消息改变为成功发起的伪造请求,而我们的 CSRF 保护将是徒劳的,因为攻击是从同一个页面操作的(JavaScript 可以访问页面上的所有数据,攻击者通过 XSS 获得了对我们页面的 JavaScript 的访问权限)。

Jade 默认对 HTML 进行转义,这是一件好事。然而,适当的转义必须具有上下文意识,简单地将 HTML 语法转换为相应的实体代码是不够的。

我们糟糕的 Jade 代码中的另一个易受攻击的区域是href属性。属性是一个与简单嵌套 HTML 不同的上下文。未引用的属性特别容易受到攻击,例如,考虑以下代码:

<a href=profile>some text</a>

如果我们将profile设置为profile onClick=javascript:alert('gotcha'),我们的 HTML 将会显示:

<a href=profile onClick=javascript:alert('gotcha')>some text</a>

同样,Jade 在这方面部分保护了我们,通过自动引用插入到属性中的变量。然而,我们的易受攻击的属性是href属性,这是 URL 类型的另一个子上下文。由于它没有任何前缀,攻击者可能会将他们的用户名输入为javascript:alert('oh oh!'),因此输出为:

a (href=username) !{username}

将是:

<a href="javascript:alert('oh oh!')"> javascript:alert('oh oh!') </a>

javascript:协议允许我们在链接级别执行 JavaScript,当一个无意中的用户点击一个恶意链接时,可以发起 CSRF 攻击。

注意

这些简单的例子是基础的。XSS 攻击可能会更加复杂和复杂。然而,我们可以遵循 Open Web Application Security Projects 8 输入消毒规则,这些规则提供了对 XSS 的广泛保护:

www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet

提示

验证器模块

一旦我们了解了如何清理用户输入,我们就可以使用正则表达式快速应用特定的验证和净化方法。然而,为了简化生活,我们也可以使用第三方的validator模块,该模块可以通过npm安装。文档可在 Github 页面上找到:www.github.com/chriso/node-validator

另请参阅

  • 在本章讨论的设置 HTTPS Web 服务器

  • 初始化和使用会话在第六章中讨论,使用 Express 加速开发

  • 创建 Express Web 应用程序在第六章中讨论,使用 Express 加速开发

第八章:集成网络范式

在本章中,我们将涵盖:

  • 发送电子邮件

  • 发送短信

  • 与 TCP 通信

  • 创建 SMTP 服务器

  • 实施虚拟主机范式

介绍

Node 的能力远不止于简单地提供网页服务。Node 的核心重点是支持各种计算任务和网络目标,其简单易懂的界面使得像我们这样的开发人员能够释放创造力,创新日益相互连接的解决方案和想法。

在本章中,我们将以一些基本示例来看待这种相互连接的知识,我们知道我们可以将这些原型应用到更大的应用程序中。

了解如何实施网络范式可以帮助我们超越 Web 应用程序的正常界限,为我们的用户提供高级功能,并实现更多与我们服务连接的方式。

发送电子邮件

在许多平台上,发送电子邮件的能力是标准的,但 Node 的方法将电子邮件功能留给开发人员。

值得庆幸的是,Node 社区中有一些出色的模块创建者已经为发送电子邮件创建了模块。在本教程中,我们将使用功能齐全的第三方nodemailer模块向一组收件人发送虚构的通讯。

准备工作

为了发送电子邮件,我们需要一个可以连接的功能正常的 SMTP 服务器。在后面的教程中,我们将创建自己的 SMTP 服务器,但现在我们需要获取一些我们的 SMTP 的细节来使用我们的客户端。

如果我们有一个电子邮件地址,我们就可以访问 SMTP 服务器。我们可以从我们的提供商那里找到 SMTP 主机地址。

如果需要,我们可以通过注册 Gmail 帐户(在mail.google.com))来获得访问 SMTP 服务器的权限。一旦我们有了帐户,我们就可以使用smtp.gmail.com作为主机,使用我们的 Gmail 地址作为用户名。

我们将创建一个新的文件夹,其中包含一个名为mailout.js的新文件来保存我们的代码。

如何做...

使用nodemailer有三个主要元素。它们如下:

  1. 设置 SMTP 传输

  2. 组装消息对象(包括传输)

  3. 将对象传递给sendMail方法。

让我们添加nodemailer模块,并按照以下代码创建传输:

var nodemailer = require('nodemailer');

var transport = nodemailer.createTransport('SMTP', {
    host: 'smtp.gmail.com',
    secureConnection: true,
    port: 465,
    auth: {
      user: "ourGmailAddress@googlemail.com",
      pass: "ourPassword"
    }
  });

我们需要填写我们自己的 SMTP 设置,包括userpass值。

我们已经使用了secureConnection设置,并将端口设置为465,因此我们可以使用 Gmail 的 SSL/TLS 安全的 SMTP 服务器。

现在我们将我们配置的传输合并到一个名为msg的对象中,如下所示:

var msg = {
  transport: transport,
  text:    "Hello! This is your newsletter, :D",
  from:    "Definitely Not Spammers <spamnot@ok.com>",
  subject: "Your Newsletter"
};

请注意,我们还没有在对象上设置to属性。我们将要发送邮件到多个地址,所以to将动态设置。为了测试目的,我们将创建一个mailinator电子邮件地址的数组。Mailinator (www.mailinator.com)是一个免费服务,允许我们通过向虚构的地址发送电子邮件来快速创建临时电子邮件地址。

var maillist = [
  'Mr One <mailtest1@mailinator.com>',
  'Mr Two <mailtest2@mailinator.com>',
  'Mr Three <mailtest3@mailinator.com>',
  'Mr Four <mailtest4@mailinator.com>',
  'Mr Five <mailtest5@mailinator.com>'
];

现在我们只需循环发送我们的通讯给每个地址,如下所示:

var i = 0;
maillist.forEach(function (to) {
  msg.to = to;
  nodemailer.sendMail(msg, function (err) {

    if (err) { console.log('Sending to ' + to + ' failed: ' + err); }

    console.log('Sent to ' + to);

    i += 1
    if (i === maillist.length) { msg.transport.close();  }
  });
});

如果我们将浏览器指向mailtest1.mailinator.com(或mailtest2, mailtest3等),我们应该在 Mailinator 的临时收件箱中看到我们的消息。

工作原理...

使用 Nodemailer 的createTransport方法,我们可以快速配置我们的应用程序所需的 SMTP 设置,然后将这些设置包含在sendMail方法使用的msg对象中。

我们没有设置初始的to属性,因为它在每次maillist.forEach迭代中被修改,然后传递到sendMail方法中。

sendMail是异步的,就像大多数带有回调的方法一样(forEach是一个例外)。每次调用sendMail后,forEach都会继续调用下一个sendMail,而不会等待sendMail调用完成。这意味着forEach循环将在所有sendMail调用完成之前结束。因此,为了知道所有邮件何时发送完毕,我们使用一个计数器(i)。

每次发送一封邮件,我们都会将我们的i变量增加1。一旦i等于我们的maillist数组的大小,所有邮件都已发送,所以我们调用transport.close

Nodemailer 打开多个连接(连接池)到 SMTP 服务器,并重用这些连接发送所有的邮件。这确保了快速和高效的发送邮件,消除了为每封邮件打开和关闭连接的开销。transport.close关闭连接池,从而允许我们的应用程序完成执行。

还有更多...

Nodemailer 是一个功能齐全、高度可配置的邮件模块,我们将会看到。

使用 sendmail 作为替代传输

许多托管提供商都有一个sendmail服务,连接到默认的 SMTP 服务器,我们不需要知道其详细信息。如果我们简单地修改我们的transport对象为sendmail,Nodemailer 将与sendmail进行接口。

var transport = nodemailer.createTransport("Sendmail");

如果sendmail不在我们主机服务器的环境PATH变量中(要找出来,只需在 SSH 提示符中键入sendmail),我们可以指定sendmail的位置如下:

var transport = nodemailer.createTransport("Sendmail", "/to/sendmail");

HTML 邮件

电子邮件可以包含 HTML,并在基本用户代理中优雅地降级为纯文本。要发送 HTML 电子邮件,我们只需将html属性添加到我们的msg对象中:

var msg = {
//prior properties: transport
 text:    "Hello! This is your newsletter, :D",
 html: "<b>Hello!</b><p>This is your newsletter, :D</p>",
//following properties: from, subject
};

纯文本应该与 HTML 一起包含,为不支持 HTML 的电子邮件客户端提供备用。

如果我们不想单独编写文本部分,我们可以让 Nodemailer 从 HTML 中提取文本,使用generateTextFromHtml属性,如下面的代码所示:

var msg = {
  transport: transport,
  html: "<b>Hello!</b><p>This is your newsletter, :D</p>",
  createTextFromHtml: true,
  from:    "Definitely Not Spammers <spamnot@ok.com>",
  subject: "Your Newsletter"
};

发送附件

如果我们想通过电子邮件附件讲一个非常糟糕的笑话,该怎么办?

我们将动态创建一个文本文件,并从磁盘加载一个图像文件,然后将它们附加到一封电子邮件中。

对于图片,我们将使用deer.jpg(可以在支持代码文件中找到)。这应该放在与我们的邮件文件相同的文件夹中(让我们称之为mailout_attachments.js)。

var nodemailer = require('nodemailer');

var msg = {
  transport: nodemailer.createTransport('SMTP', {
    host: 'smtp.gmail.com',
    secureConnection: true,
    port: 465,
    auth: {
      user: "ourGmailAddress@googlemail.com",
      pass: "ourPassword"
    }
  }),
  text:    "Answer in the attachment",
  from:    "The Attacher <attached@files.com>",
  subject: "What do you call a deer with no eyes?",
  to: "anyemail@anyaddress.com",
  attachments: [
    {fileName: 'deer.txt', contents:'no eye deer.'},
    {fileName: 'deerWithEyes.jpg', filePath: 'deer.jpg'}
  ]
};

nodemailer.sendMail(msg, function (err) {
  if (err) { console.log('Sending to ' + msg.to + ' failed: ' + err); }
  console.log('Sent to ' + msg.to);
  msg.transport.close();
});

当然,这是一个附件的概念验证,并不是电子邮件的最佳用法。附件作为msg对象中的对象数组提供。每个附件对象必须有一个fileName属性,这是在电子邮件中附件的文件名。这不必与从磁盘加载的实际文件的名称匹配。

文件内容可以直接通过contents属性写入,使用字符串或Buffer对象,或者我们可以使用filePath从磁盘流式传输文件(我们也可以直接将流传递给sourceStream属性)。

另请参阅

  • 在本章中讨论的发送短信

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

发送短信

能够向用户发送短信文本消息是我们与他们联系的另一种方式

我们可以将计算机连接到 GSM 调制解调器,与专门的库进行交互(如 Asterisk,asterisk.org,结合 ngSMS,ozekisms.com),并与库和电话设备进行接口,发送短信。

当然还有更简单的方法。像 Twilio 这样的服务提供网关短信服务,我们可以通过 HTTP REST API 与它们联系,它们会为我们处理短信发送。

在这个教程中,我们将使用twilio模块将我们的通讯通讯应用程序转换为一个通用的短信服务。

准备就绪

这需要一个 Twilio 账户(www.twilio.com/try-twilio)。一旦注册并登录,我们应该注意我们的账户 SID、Auth Token 和沙箱电话号码(我们可能需要选择我们的居住国家以获得适当的沙箱号码)。

我们需要一些电话号码用于测试发送短信。在沙盒模式下(这是我们在开发中将要使用的模式),我们想要发送短信或打电话的任何号码都必须经过验证过程。我们通过从账户部分选择Numbers链接,然后点击Verify a Number来完成这个过程。然后 Twilio 将拨打该号码,并期望在屏幕上输入提供的 PIN 以进行确认。

让我们创建一个新文件,smsout.js,并安装twilio助手模块:

npm install twilio 

如何做...

首先,我们需要twilio并组合一些配置设置:

var twilio = require('twilio');
var settings =  {
  sid : 'Ad054bz5be4se5dd211295c38446da2ffd',
  token: '3e0345293rhebt45r6erta89xc89v103',
  hostname : 'dummyhost',
  phonenumber: '+14155992671' //sandbox number
}

提示

Twilio 电话号码

在我们开始与 Twilio 服务进行交互之前,我们必须指定一个注册的 Twilio 电话号码,以创建我们的phone。在开发过程中,我们可以简单地使用沙盒号码,可以从 Twilio 仪表板中找到(www.twilio.com/user/account)。在生产环境中,我们需要升级我们的账户并从 Twilio 购买一个唯一的电话号码。

有了我们的settings正确,我们准备创建一个 Twilio 客户端,用它来初始化一个虚拟电话:

var restClient = new (twilio.RestClient)(settings.sid, settings.token);

var client = new (twilio.Client)(settings.sid,
                                  settings.token,
                                  settings.hostname);

var phone = client.getPhoneNumber(settings.phonenumber);

我们在这里也创建了restClient,它提供了反映 Twilio 原始 REST API 的 API 调用。我们将使用restClient来检查我们的短信消息的状态,以确定消息是否已从 Twilio 发送到目标电话。

现在我们定义了一个要发送短信的号码列表(我们必须提供自己的号码),就像我们在上一个示例中的maillist数组一样:

var smslist = [
  '+44770xxxxxx1',
  '+44770xxxxxx2',
  '+44770xxxxxx3',  
  '+44770xxxxxx4',  
  '+44770xxxxxx5'  
];

提示

除非我们升级了我们的账户,否则smslist上的任何号码都必须经过 Twilio 的预验证。这可以通过 Twilio Numbers 账户部分完成(www.twilio.com/user/account/phone-numbers/)。

然后,我们简单地循环遍历smslist并使用phone向每个接收者发送短信消息,如下所示:

var msg = 'SMS Ahoy!';
smslist.forEach(function (to) {
  phone.sendSms(to, msg, {}, function(sms) { console.log(sms);  });
});

这应该可以正常工作,除了这个过程不会退出(因为twilio初始化了一个服务器来接收 Twilio 的回调),我们不知道 Twilio 何时向接收者发送短信。一种检查的方法是向 Twilio REST API 发出另一个请求,请求状态更新。twilio RestClient使我们可以轻松实现这一点,具体如下:

  phone.sendSms(to, msg, {}, function(sms) {
      restClient.getSmsInstance(sms.smsDetails.sid, function (presentSms) {
		//process presentSms using it's status property.
      });
  });

如果我们的短信在第一次调用时没有发送,我们需要等待并再次检查。让我们进行一些最终的改进,如下面的代码所示:

var msg = 'SMS Ahoy!',  i = 0;
smslist.forEach(function (to) {
  phone.sendSms(to, msg, {}, function (sms) {

  function checkStatus(smsInstance) {  
      restClient.getSmsInstance(smsInstance.sid, function (presentSms) {
       if (presentSms.status === 'sent') {  
          console.log('Sent to ' + presentSms.to);
        } else {
          if (isNaN(presentSms.status)) {
            //retry: if its not a number (like 404, 401), it's not an error
            setTimeout(checkStatus, 1000, presentSms);
            return;
          }
          //it seems to be a number, let's notify, but carry on
          console.log('API error: ', presentSms.message);
        }
        i += 1;
        if (i === smslist.length) { process.exit(); }
      });
    };

    checkStatus(sms.smsDetails);

  });
});

现在控制台将在每次确认发送号码后输出。当所有号码都已发送消息时,该过程退出。

它是如何工作的...

twilio.RestClient通过twilio助手为我们提供了对 Twitter API 的低级交互访问。这简单地为我们预设的授权设置包装了通用的 API 调用,代表我们进行 HTTP 请求。

twilio.Client是由twilio助手提供的更高级的 API,处理 Twilio 和客户端之间的双向交互。初始化新客户端时必须提供的第三个参数是hostname参数。twilio模块提供了这个参数给 Twilio,作为从 Twilio 服务器请求的回调 URL 的一部分,以确认短信消息是否已发送。

我们忽略了这种行为,并提供了一个虚拟的主机名,实现了我们自己的确认短信已发送的方法。我们的方法不需要我们拥有一个可以从 Web 访问的实时服务器地址(请参阅还有更多..部分,了解使用hostname属性作为预期的回调 URL 的实时服务器实现)。

我们使用我们创建的phone对象的sendSms方法通过twilio模块向 Twilio API 发出 HTTP 请求,传入所需的接收者、消息和回调函数。

一旦请求发出,我们的回调就会触发初始的sms对象。我们使用这个对象来确定 Twilio 给我们的sendSMS请求分配的 ID,即smsInstance.sid(即sms.smsDetails.sid)。

smsInstance.sid被传递给restClient.getSmsInstance,它为我们提供了我们的smsIntance对象的更新实例,我们称之为presentSms。这个调用是从一个名为checkStatus的自定义自调用函数表达式中进行的,它将我们的初始sms.smsDetails对象传递给它。

我们希望看到 Twilio 是否已经发送了我们的短信。如果是,presentSms.status将是sent。如果不是,我们希望等一会儿,然后要求 Twilio 再次更新我们排队的短信消息的状态。也就是说,除非返回的状态是404,在这种情况下出现了问题,我们需要通知用户,但继续处理下一个短信消息。

就像发送电子邮件的示例一样,我们会记录发送的消息数量。一旦它们总数达到收件人的数量,我们就会退出进程,因为smsout.js是一个命令行应用程序(在服务器场景中,我们不需要也不想这样做)。

还有更多...

Twilio 模块的多功能性不仅限于发送短信。它还可以通过发出事件透明地处理 Twilio 的回调。

使用 processed 事件监听器

在实时公共服务器上,最好向twilio.Client提供我们的主机名,以便它可以管理回调 URL 请求。

注意

为了使这段代码工作,它必须托管在一个实时公共服务器上。有关在实时服务器上托管 Node 的更多信息,请参阅第十章,携手共进

我们将删除restClient并将我们的settings对象更改为以下内容:

var settings =  {
  sid : 'Ad054bz5be4se5dd211295c38446da2ffd',
  token: '3e0345293rhebt45r6erta89xc89v103',
  hostname : 'nodecookbook.com',
  phonenumber: '+14155992671' //sandbox number
};

然后我们的短信发送代码就是:

var i = 0;
smslist.forEach(function (to) {
  phone.sendSms(to, msg, {}, function(sms) {
    sms.on('processed', function(req) {  
      i += 1;  
      console.log('Message to ' + req.To +
      ' processed with status: ' + req.SmsStatus);
      if (i === smslist.length) {process.exit();}
    });
  });
});

twilio客户端提供了一个statusCallback URL 给 Twilio。Twilio 将请求这个 URL(类似http://nodecookbook.com:31337/autoprovision/0)来确认,twilio辅助模块将发出一个processed事件来通知我们 Twilio 的确认。我们监听这个事件,通过req对象检查给定的SmsStatus来确认成功。

进行自动电话呼叫

为了使下一个示例工作,我们需要一个有效的主机名,并且在一个公开的服务器上运行我们的应用程序,就像在前一节中一样。

注意

为了使这段代码工作,它必须托管在一个实时公共服务器上。有关在实时服务器上托管 Node 的更多信息,请参阅第十章,携手共进

要进行呼叫,我们从通常的设置开始。

var twilio = require('twilio');

var settings =  {
  sid : 'Ad054bz5be4se5dd211295c38446da2ffd',
  token: '3e0345293rhebt45r6erta89xc89v103',
  hostname : 'nodecookbook.com',
  phonenumber: '+14155992671' //sandbox number
};

var client = new (twilio.Client)(settings.sid,
                                  settings.token,
                                  settings.hostname);

var phone = client.getPhoneNumber(settings.phonenumber);

然后我们使用makeCall方法如下:

phone.makeCall('+4477xxxxxxx1', {}, function(call) {
  call.on('answered', function (req, res) {
    console.log('answered');
    res.append(new (twilio.Twiml).Say('Meet us in the abandoned factory'));
    res.append(new (twilio.Twiml).Say('Come alone', {voice: 'woman'}));
    res.send();
  }).on('ended', function (req) {
    console.log('ended', req);
    process.exit();
  })
});

如果我们的帐户没有升级,我们提供给makeCall的任何号码都必须通过 Twilio 帐户部分的Numbers区域进行验证(www.twilio.com/user/account/phone-numbers/)。

makeCall调用一个带有call参数的回调。call是一个事件发射器,具有answeredended事件。twilio模块将 Twilio 的确认回调透明地转换为这些事件。

answered事件将reqres对象传递给它的回调函数。req包含有关传出呼叫的信息,而res具有允许我们与呼叫交互的方法,即res.appendres.send

要向接收者发送计算机化的文本到语音消息,我们实例化twilio模块的Twiml类的新实例,并使用Say方法(注意给非类的大写 S 的不寻常约定,使用小写 s 会引发错误)。

TwiML是 Twilio 的标记语言。它只是特定于 API 的 XML。twilio模块提供了Twiml类来处理形成必要 XML 的繁琐工作。我们用它来创建两个Say动词。在twilio的背后,两个append调用后跟着的发送调用将创建并发出以下TwiMLtwilio

<?xml version="1.0" encoding="UTF-8" ?>
<Response>
  <Say>
	  Meet us in the abandoned factory
  </Say>
  <Say voice="woman">
	  Come alone
  </Say>
</Response>

TwiML代码由twilio接收并转换为语音。在女性的声音说“独自来”之后,电话结束,触发我们应用程序中的ended事件(twilio模块作为接收来自twilio的 HTTP 请求的结果发出的,表示通话已经结束)。

我们监听ended事件,确定twilio(或接收方)何时挂断电话。一旦触发ended,我们就退出进程,并输出req对象作为通话的概述。

另请参阅

  • 本章讨论的发送电子邮件

  • 本章讨论的 TCP 通信

  • 第十章,实时运行

使用 TCP 通信

传输控制协议(TCP)提供了 HTTP 通信的基础。通过 TCP,我们可以在运行在不同服务器主机上的进程之间打开接口,并且与 HTTP 相比,可以更少的开销和更少的复杂性远程通信。

Node 为我们提供了net模块,用于创建 TCP 接口。在扩展、可靠性、负载平衡、同步或实时社交通信方面,TCP 是一个基本要素。

在这个示例中,我们将演示建立一个 TCP 连接所需的基础,以便通过网络远程监视和过滤网站访问的 HTTP 标头。

准备工作

我们需要两个新文件:server.jsmonitor.js。让我们把它们放在一个新的文件夹里。

操作步骤...

首先,让我们在server.js中创建我们的第一个 TCP 服务器,如下所示:

var net = require('net');
var fauxHttp = net.createServer(function(socket) {
    socket.write('Hello, this is TCP\n');
    socket.end();

   socket.on('data', function (data) {
    console.log(data.toString());
  });

}).listen(8080);

我们可以使用nc(netcat)命令行程序在另一个终端中进行测试,如下所示:

echo "testing 1 2 3" | nc localhost 8080 

注意

如果我们使用 Windows,我们可以从www.joncraton.org/blog/netcat-for-windows下载 netcat。

响应应该是Hello, this is TCP\nserver.js控制台应该输出testing 1 2 3

请记住,HTTP 建立在 TCP 之上,因此我们可以向 TCP 服务器发出 HTTP 请求。如果我们将浏览器导航到http://localhost:8080并观察控制台,我们将看到浏览器的 HTTP 请求中的所有标头显示在控制台中,并且浏览器显示Hello this is TCP

我们给 TCP 服务器命名为fauxHttp。我们将使用它来记录来自浏览器客户端的 HTTP 标头(通过一些调整,我们可以很容易地调整我们的代码以与实际的 HTTP 服务器一起工作)。

仍然在server.js中,我们将创建另一个 TCP 服务器,为monitor.js打开第二个端口,以便与我们的服务器通信。不过,在此之前,我们将创建一个新的EventEmitter对象,作为我们的两个server.js TCP 服务器之间的桥梁:

var net = require('net'),
  stats = new (require('events').EventEmitter),
  filter = 'User-Agent';

var fauxHttp = net.createServer(function(socket) {
    socket.write('Hello, this is TCP\n');
    socket.end();    

   socket.on('data', function (data) {
    stats.emit('stats', data.toString());
  });

}).listen(8080);

我们在socketdata监听器中用新的stats EventEmitter替换了console.log,它将在接收到 TCP 数据时emit一个自定义的stats事件。

我们还在server.js的第二个 TCP 接口中包含了一个filter变量,如下所示:

var monitorInterface = net.createServer(function(socket) {

  stats.on('stats', function (stats) {
    var header = stats.match(filter + ':') || stats.match('');
    header = header.input.substr(header.index).split('\r\n')[0];
    socket.write(header);
  });

  socket.write('Specify a filter [e.g. User-Agent]');
  socket.on('data', function(data) {
    filter = data.toString().replace('\n','');

    socket.write('Attempting to filter by: ' + filter);
  });

}).listen(8081);

我们的monitorInterface服务器监听我们的stats发射器,以确定第一个 TCP 服务器何时接收到信息,并将此信息(在经过过滤后)发送到端口8081上连接的客户端。

现在我们只需要创建这个客户端。在monitor.js中,我们编写以下代码:

var net = require('net');
var client = net.connect(8081, 'localhost', function () {
  process.stdin.resume();
  process.stdin.pipe(client);
}).on('data', function (data) {
  console.log(data.toString());
}).on('end', function () {
  console.log('session ended');
});

当我们在两个终端中打开server.jsmonitor.js,并在浏览器中导航到http://localhost:8080时,server.js会将每个请求的 HTTP 标头中的User-Agent字符串传输到monitor.js

我们可以应用不同的过滤器,比如Accept。只需将其输入到运行中的monitor.js进程中,任何不匹配的过滤器都将默认返回初步请求行(GET /, POST /route/here等)。

要在不同的系统上运行,我们只需将server.js放在远程主机上,然后将net.connect的第二个参数从localhost更新为我们服务器的名称,例如:

var client = net.connect(8081, 'nodecookbook.com', function () {

工作原理...

HTTP 层建立在 TCP 之上。因此,当我们的浏览器发出请求时,TCP 服务器接收所有 HTTP 头信息。http.createServer将处理这些头信息和其他 HTTP 协议交互。但是,net.createServer只是接收 TCP 数据。

socket.writesocket.end类似于 HTTP 服务器回调函数中的response.writeresponse.end,但没有参考 HTTP 协议的要求。尽管如此,这些相似之处足以使我们的浏览器能够从socket.write接收数据。

我们的fauxHttp TCP 服务器所做的就是通过端口8080接收请求,向客户端输出Hello, this is TCP\n,并直接通过我们的stats事件发射器读取客户端的任何数据。

我们的monitorInterface TCP 服务器监听一个单独的端口(8081),基本上给了我们一种(完全不安全的)管理员界面。在monitorInterface回调中,我们监听stats发射器,每当浏览器(或任何 TCP 客户端)访问localhost:8080时触发。

stats的监听器回调中,我们检索所需的header,使用filter变量搜索具有match对象indexinput属性的 HTTP 头,使我们能够提取指定的头。如果没有匹配项,我们将匹配一个空字符串,从而返回一个包含index0match对象,从而提取 HTTP 头的第一行(请求路径和方法)。

monitorInterface TCP 服务器回调的最后一部分监听socketdata事件,并将filter变量设置为客户端发送的内容。这使得monitor.js客户端可以通过将process.stdin流直接传输到 TCPclient来改变filter。这意味着我们可以直接在monitor.js的运行过程中输入,并且monitorInterfacesocketdata事件将在server.js中触发,接收monitor.js的 STDIN 中键入的任何内容。

monitor.js通过将process.stdin流直接传输到 TCPclient来利用这个功能。这意味着我们可以直接在运行过程中输入,并且monitor.jssocket中的data事件,这将触发从monitor.js的 STDIN 中键入的任何内容。

还有更多...

让我们看看一些进一步利用 TCP 强大功能的方法。

端口转发

转发端口有各种原因。例如,如果我们希望通过移动连接 SSH 到我们的服务器,可能会发现端口22已被阻止。公司防火墙也可能会有同样的情况(这可能是因为所有特权端口都被屏蔽,除了最常见的端口,如80和`443)。

我们可以使用net模块将 TCP 流量从一个端口转发到另一个端口,从本质上绕过防火墙。因此,这应该只用于合法情况,并且需要任何必要的许可。

首先,我们将需要net并定义要转发的端口:

var net = require('net');
var fromPort = process.argv[2] || 9000;
var toPort = process.argv[3] || 22;

因此,我们可以通过命令行定义端口,或者默认将任意端口9000转发到 SSH 端口。

现在我们创建一个 TCP 服务器,通过fromPort接收连接,创建一个到toPort的 TCP 客户端连接,并在这些连接之间传递所有数据,如下所示:

net.createServer(function (socket) {
  var client;
  socket.on('connect', function () {
    client = net.connect(toPort);
    client.on('data', function (data) {
      socket.write(data);
    });
  })
  .on('data', function (data) {
    client.write(data);
   })
  .on('end', function() {
      client.end();
  });
}).listen(fromPort, function () {
  console.log('Forwarding ' + this.address().port + ' to ' + toPort);
});

我们使用data事件在client(我们的桥接连接)和socket(传入连接)之间接收和推送数据。

如果我们现在在远程服务器上运行我们的脚本(不带参数),我们可以使用端口9000从本地计算机登录到安全 shell。

ssh -l username domain -p 9000 

使用 pcap 来监视 TCP 流量

使用第三方的pcap模块,我们还可以观察 TCP 数据包在系统内外的传输。这对于分析和优化预期行为、性能和完整性非常有用。

在命令行上:

npm install pcap 

对于我们的代码:

var pcap = require('pcap');
var pcapSession = pcap.createSession("","tcp"); //may need to put wlan0, 
                                                                             //eth0, etc. as 1st arg.
var tcpTracker = new pcap.TCP_tracker();

tcpTracker.on('end', function (session) {
    console.log(session);
});

pcapSession.on('packet', function (packet) {
  tcpTracker.track_packet(pcap.decode.packet(packet));
});

提示

如果pcap无法选择正确的设备,将没有输出(或者可能是无关的输出)。在这种情况下,我们需要知道要嗅探哪个设备。如果我们是无线连接,可能是wlan0wlan1,如果我们是有线连接,可能是eth0/eth1。我们可以通过在命令行上输入ifconfig(Linux,Mac OS X)或ipconfig(Windows)来找出哪个设备具有与我们路由器 IP 地址的网络部分匹配的inet 地址(例如192.168.1.xxx)。

如果我们将此保存为tcp_stats.js,我们可以使用以下命令运行它:

sudo node tcp_stats.js 

pcap模块与特权端口进行接口,并且必须以 root 身份运行(对于强制执行特权端口的 Linux 和 Mac OS X 等操作系统)。

如果我们导航到任何网站然后刷新页面,pcaptcpTracker end事件会被触发,我们会输出session对象。

为了初始化tcpTracker,我们创建一个pcap会话,并为packet事件附加一个监听器,将每个解码的packet传递给tcpTracker

在创建pcap会话时,我们传递一个空字符串,然后是tcpcreateSession方法。空字符串会导致pcap自动选择一个接口(如果这不起作用,我们可以指定适当的接口,例如eth0,wlan1lo,如果我们想要分析localhost的 TCP 数据包)。第二个参数tcp指示pcap仅监听 TCP 数据包。

另请参阅

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

  • 在本章中讨论的虚拟主机范式的实现

创建 SMTP 服务器

我们不必依赖第三方 SMTP 服务器,我们可以创建我们自己的!

在这个配方中,我们将使用第三方的simplesmtp模块创建我们自己的内部 SMTP 服务器(就像第一个 SMTP 服务器一样),这是第一个配方中nodemailer模块的基础库。有关将内部 SMTP 服务器转换为外部公开的 MX 记录服务器的信息,请参阅本配方末尾的还有更多..部分。

准备工作

让我们创建一个文件并将其命名为server.js,然后创建一个名为mailboxes的新文件夹,其中包含三个子文件夹:bob,bibsusie。我们还需要准备好第一个配方中的mailout.js文件。

如何做...

首先,我们将设置一些初始变量:

var simplesmtp = require('simplesmtp');
var fs = require('fs');
var path = require('path');
var users = [{user: 'node', pass: 'cookbook'}],
  mailboxDir = './mailboxes/',
  catchall = fs.createWriteStream(mailboxDir + 'caught', {flags : 'a'});

现在,我们启用带有身份验证的 SMTP 服务器:

var smtp = simplesmtp
  .createServer({requireAuthentication: true})
  .on('authorizeUser', function (envelope, user, pass, cb) {
    var authed;
    users.forEach(function (userObj) {
      if (userObj.user === user && userObj.pass === pass) {
        authed = true;
      }
});
    cb(null, authed);
  });

接下来,我们将对一些simplesmtp事件做出反应,以处理传入的邮件,从startData事件开始:

smtp.on('startData', function (envelope) {
  var rcpt, saveTo;
  envelope.mailboxes = [];
  envelope.to.forEach(function (to) {
    path.exists(mailboxDir + to.split('@')[0], function (exists) {
      rcpt = to.split('@')[0];
          if (exists) {
        envelope.mailboxes.unshift(rcpt);
        saveTo = mailboxDir + rcpt + '/' + envelope.from
          + ' - ' + envelope.date;
        envelope[rcpt] = fs.createWriteStream(saveTo, {flags: 'a'});
        return;
      }
      console.log(rcpt + ' has no mailbox, sending to caught file');
      envelope[rcpt] = catchall;  
    });
  });
});

然后datadataReady事件将如下所示:

smtp.on('data', function (envelope, chunk) {
  envelope.mailboxes.forEach(function (rcpt) {
    envelope[rcpt].write(chunk);
  });
}).on('dataReady', function (envelope, cb) {
  envelope.mailboxes.forEach(function (rcpt) {
      envelope[rcpt].end();
  });

  cb(null, Date.now());
});

为了更简洁的代码,我们使用点符号将这两个事件链接在一起。最后,我们告诉我们的 SMTP 服务器要监听哪个端口:

smtp.listen(2525);

在生产环境中,指定端口为25(或在更高级的情况下为465587)会更加方便。

现在让我们通过将发送电子邮件配方中的mailout.js文件转换来测试我们的服务器。

首先,我们修改我们的createTransport调用以反映我们自定义 SMTP 服务器的值:

var transport = nodemailer.createTransport('SMTP', {
    host: 'localhost',
    secureConnection: false,
    port: 2525,
    auth: {
      user: "node",
      pass: "cookbook"
    }
  });

接下来,我们修改maillist数组以反映我们的邮箱,如下面的代码所示:

var maillist = [
  'Bob <bob@nodecookbook.com>, Bib <bib@nodecookbook.com>',
  'Miss Susie <susie@nodecookbook.com>',
  'Mr Nobody <nobody@nodecookbook.com>',
];

Bob 和 Bib 一起发送。我们还添加了一个没有邮箱的地址(<nobody@nodecookbook.com>)以测试我们的捕获所有功能。

现在,如果我们在一个终端中运行server.js,在另一个终端中运行mailout.jsmailout.js的输出应该是这样的:

Sent to Miss Susie <susie@nodecookbook.com>
Sent to Mr Nobody <nobody@nodecookbook.com>
Sent to Bob <bob@nodecookbook.com>, Bib <bib@nodecookbook.com> 

如果我们查看mailboxes/bob目录,我们会看到来自spamnot@ok.com的电子邮件,susiebib也是一样。

server.js应该有以下输出:

nobody has no mailbox, sending to caught file 

因此,当分析mailboxes/caught的内容时,我们将看到我们发送给 Mr Nobody 的电子邮件。

工作原理...

SMTP 基于 SMTP 客户端和服务器之间的一系列纯文本通信,在 TCP 连接上进行。simplesmtp模块为我们执行这些通信,为开发人员交互提供了更高级的 API。

当我们调用 simplesmtp.createServer 时,将 requireAuthorization 设置为 true,我们的新服务器(简称 smtp)将触发一个 authorizeUser 事件,并且在我们调用第四个参数 cb(回调)之前不会继续处理。cb 接受两个参数。第一个参数可以通过 Error 对象指定拒绝访问的原因(我们只是传递 null)。第二个参数是一个布尔值,表示用户是否被授权(如果没有,并且错误参数为 null,则会向邮件客户端发送一个通用的拒绝访问错误)。

我们通过循环遍历我们的 users 数组来确定第二个 cb 参数,找出用户名和密码是否正确(实际上,我们可能希望使用数据库来完成这部分)。如果匹配成功,我们的 auth 变量将设置为 true 并传递给 cb,否则它保持为 false,客户端将被拒绝。

如果客户端被授权,smtp 将为每个信封(一个包含该电子邮件所有收件人、正文文本、电子邮件头部、附件等的电子邮件包裹)发出多个事件。

startData 事件中,我们会得到一个 envelope 参数,我们使用 envelope.to 属性来检查我们的收件人是否有邮箱。SMTP 允许在一封电子邮件中指定多个收件人,因此 envelope.to 总是一个数组,即使它只包含一个收件人。因此,我们使用 forEach 循环遍历 envelope.to,以便为每个指定的收件人检查邮箱。

我们通过将地址按 @ 字符拆分来找出预期的收件人邮箱,将其加载到我们的 rcpt 变量中。我们对地址的域部分不进行验证,尽管 simplesmtp 在触发任何事件之前会自动验证域是否真实。

rcpt 被添加到我们的 envelope.mailboxes 数组中,在循环遍历 envelope.to 之前,我们将其添加到 envelope 中。我们在后续的 datadataReady 事件中使用 envelope.mailboxes

envelope.to forEach 循环中,我们为 envelope 添加一个名为邮箱名称(rcpt)的最终属性。如果邮箱存在,我们创建 writeStreamsaveTo(路径和文件名由 envelope.fromenvelope.date 组合确定)。现在我们已经准备好接收每个收件人邮箱的数据。如果收件人的邮箱不存在,我们将 envelope[rcpt] 设置为 catchallcatchall 是我们在文件顶部设置的全局变量。它是一个带有 a 标志的 writeStream,以便 caught 文件累积孤立的电子邮件。我们在初始化时创建 catchall writeStream,然后重用相同的 writeStream 用于所有发送到不存在邮箱的邮件。这样可以避免为每封错误寄往的电子邮件创建 writeStream,从而节省资源。

data 事件会在服务器接收到电子邮件正文的每个块时触发,给我们提供 envelopechunk。我们使用 envelope[rcpt].write 将每个 chunk 保存到相应的文件中,通过循环遍历我们自定义的 envelope.mailboxes 数组来确定 rcpt

dataReady 事件表示所有数据已经接收完毕,数据已经准备好进行处理。由于我们已经存储了数据,我们使用这个事件来结束我们 mailboxes 中每个 rcpt 的相关 writeStreamdataReady 事件还需要一个回调(cb)。第一个参数可以是一个 Error 对象,允许最终拒绝电子邮件(例如,如果分析电子邮件的内容发现是垃圾邮件)。第二个参数期望一个队列 ID 发送到邮件客户端,在我们的情况下,我们只是给出 Date.now

还有更多...

让我们看看如何将我们的 SMTP 服务器转换为公共邮件交换处理程序。

从外部 SMTP 服务器接收电子邮件

通过删除授权设置并远程托管我们的 SMTP 服务器,监听端口25,我们可以允许其他邮件服务器与我们的 SMTP 服务器通信,以便可以将电子邮件从一个网络传输到另一个网络(例如,从 Gmail 帐户到我们托管的 SMTP 服务器)。

让我们将文件保存为mx_smtp.js,并相应地修改以下内容:

var simplesmtp = require('simplesmtp');
var fs = require('fs');
var path = require('path');
var  mailboxDir = './mailboxes/',
    catchall = fs.createWriteStream(mailboxDir + 'caught', {flags : 'a'});
var smtp = simplesmtp.createServer();

我们已经丢弃了users变量,并更改了smtp变量,因此具有requireAuthentication属性和相应的authorizeUser事件的对象已被删除。为了使外部邮件程序转发到我们的 SMTP 服务器,它必须能够连接。由于其他邮件程序没有认证详细信息,我们必须打开我们的服务器以允许它们这样做。

startData数据和dataReady事件都保持不变。最终的变化是端口:

smtp.listen(25);

为了使其工作,我们必须拥有一个我们可以更改邮件交换(MX)记录的域的实时服务器(例如,亚马逊 EC2 微实例)和根访问权限。

例如,假设我们将我们的 SMTP 服务器托管在mysmtpserver.net上,并且我们想要接收bob@nodecookbook.com的电子邮件。我们将nodecookbook.com的 MX 记录指向mysmtpserver.net,优先级为 10。

注意

有关如何更改注册商的 DNS 记录的示例,请参见support.godaddy.com/help/article/680。有关 MX 记录的更多信息,请查看en.wikipedia.org/wiki/MX_record

一旦更改完成,它们可能需要一段时间才能传播(最多 48 小时,尽管通常更快)。我们可以使用dig mx(Mac OS X 和 Linux)或nslookup set q=MX(Windows)来确定我们的 MX 记录是否已更新。

我们必须在我们的远程主机上安装 Node,并确保端口25是公开的,并且没有被其他程序使用。要检查其他程序是否使用端口25,请使用 SSH 登录并键入netstat -l。如果在活动互联网连接(仅服务器)部分中看到*:smtp,则表示已经有程序在使用该端口,并且必须停止(尝试ps -ef查找任何嫌疑人)。

在实际服务器上,我们创建包含bob bibsusiemailboxes文件夹,将我们的mx_smtp.js文件复制过去,并安装simplesmtp

npm install simplesmtp 

现在,如果一切都设置正确,并且我们的 MX 记录已更新,我们可以在实际服务器上执行我们的mx_smtp.js文件。然后向<bob@nodecookbook.com>发送测试电子邮件(或者@我们已更改 MX 记录的任何域),等待几秒钟,然后检查mailboxes/bob文件夹。电子邮件应该已经出现。

另请参阅

  • 发送电子邮件在本章中讨论

  • 部署到服务器环境在第十章中讨论,上线

实施虚拟托管范式

如果我们希望在一个服务器上托管多个站点,我们可以使用虚拟托管来实现。虚拟托管是一种根据名称独特处理多个域名的方法。这种技术非常简单:我们只需查看传入的Host标头并相应地做出响应。在这个任务中,我们将为静态站点实现基于名称的简单虚拟托管。

准备工作

我们将创建一个名为sites的文件夹,其中localhost-sitenodecookbook作为子目录。在localhost-site/index.html中,我们将编写以下内容:

<b> This is localhost </b>

nodecookbook/index.html中,我们将添加以下代码:

<h1>Welcome to the Node Cookbook Site!</h1>

对于本地测试,我们将希望配置我们的系统以使用一些额外的主机名,以便我们可以将不同的域指向我们的服务器。要做到这一点,我们在 Linux 和 Max OS X 上编辑/etc/hosts,或者在 Windows 系统上编辑%SystemRoot%\system32\drivers\etc\hosts

在文件的顶部,它将我们的本地环回 IP127.0.0.1映射到localhost。让我们将这一行改为:

127.0.0.1 localhost nodecookbook

最后,我们想要创建两个新文件:mappings.jsserver.jsmappings.js文件将为每个域名提供静态文件服务器,而server.js将提供虚拟托管逻辑。

我们将使用node-static模块来提供我们的站点,我们的虚拟主机将只提供静态网站。如果我们还没有它,我们可以通过npm安装它,如下所示:

npm install node-static 

如何做到...

让我们从mappings.js开始:

var static = require('node-static');

function staticServe (dir) {
  return new (static.Server)('sites/' + dir)
}

exports.sites = {
  'nodecookbook' : staticServe('nodecookbook'),
  'localhost' : staticServe('localhost-site')
} ;

我们已经使用了系统hosts文件中列出的域。在生产场景中,域将通过 DNS 记录指向我们。

现在是server.js的时候了:

var http = require('http');

var port = 8080,
  mappings = require('./mappings');

var server = http.createServer(function (req, res) {
  var domain = req.headers.host.replace(new RegExp(':' + port + '$'), ''),
    site = mappings.sites[domain] ||
      mappings.sites[mappings.aliases[domain]];

  if (site) { site.serve(req, res); return; }
  res.writeHead(404);
  res.end('Not Found\n');

}).listen(port);

现在当我们导航到http://localhost:8080http://localhost.localdomain:8080时,我们会得到sites/localhost-site/index.html中的内容。而如果我们转到http://nodecookbook:8080,我们会得到大的 Node Cookbook 欢迎消息。

它是如何工作的...

每当我们的服务器收到一个请求,我们都会去掉端口号(对于端口80服务器来说是不必要的),以确定domain

然后我们将domain与我们的mappings.sites对象进行交叉引用。如果找到一个站点,我们调用它的serve方法,该方法是从node-static库继承的。在mappings.js中,每个exports.sites属性都包含一个指向相关站点目录的node-static Server实例。我们使用我们的自定义staticServer函数作为包装器,以使代码更加整洁。

要使用静态Server实例,我们调用其serve方法,通过reqres对象传递,就像在server.js中一样:

  if (site) { site.serve(req, res); return; }

site变量是指向给定域名的适当站点文件夹的static.Server实例。

如果server.js在请求的域中找不到mapping.js中的站点,我们只需向客户端传递404错误。

还有更多...

超越静态托管进入动态托管,或者如果我们想要在我们的网站上使用 SSL/TLS 证书呢?

虚拟托管 Express 应用程序

Express/Connect 带有vhost中间件,它允许我们轻松实现基于 Express 的动态虚拟托管。

首先,我们需要设置两个 Express 应用程序。让我们删除nodecookbooklocalhost-site文件夹,并使用express二进制文件重新生成它们,如下所示:

rm -fr nodecookbook && express nodecookbook
rm -fr localhost-site && express localhost-site
cd nodecookbook && npm -d install
cd ../localhost-site && npm -d install 

我们还需要修改每个站点文件中app.js的最后部分,将app.listen方法包装在if module.parent条件中:

if (!module.parent) {

  app.listen(3000);
  console.log("Express server listening on port %d in %s mode",        	app.address().port, app.settings.env);

}

在我们的nodecookbook应用程序中,让我们在index.jade中添加以下内容:

h1 Welcome to the Node Cookbook site!

localhost-site应用程序中,我们将以下代码添加到index.jade中:

b this is localhost

有了我们的站点设置,我们可以修改mappings.js如下:

function appServe (dir) {
  return require('./sites/' + dir + '/app.js')
}

exports.sites = {
  'nodecookbook' : appServe('nodecookbook'),
  'localhost' : appServe('localhost-site')
};

我们已经删除了node-static模块,因为我们改用了 Express。我们的staticServe便利函数已被修改为appServe,它只是根据exports.servers中的映射使用require加载每个 Express 应用程序。

我们将更新server.js如下:

var express = require('express'),
  mappings = require('./mappings'),
  app = express.createServer();

Object.keys(mappings.sites).forEach(function (domain) {
  app.use(express.vhost(domain, mappings.sites[domain]));
});

app.listen(8080);

我们创建一个主app,然后循环遍历mappings.sites,将每个子应用程序传递到app.use中与express.vhost一起使用。vhost中间件接受两个参数。第一个是域名。我们从mappings.sites键中获取每个domain。第二个是一个 Express 应用程序。我们从mappings.sites中的值中检索每个 Express 应用程序。

我们只需请求域和vhost中间件就会将相关域与相关应用程序对齐,以提供正确的站点。

服务器名称指示

在服务器名称指示(SNI)之前,基于名称的 SSL/TLS 虚拟托管对于网站是一个复杂的管理问题(需要将每个主机名存储在多域证书中)。

这是因为在服务器接收到任何 HTTP 头之前,基于指定域名的证书建立了加密连接。因此,服务器无法提供特定于一个域的证书。因此,浏览器会明确警告用户连接可能不安全,因为证书上列出的域名与正在访问的域名不匹配。为了避免这种情况,虚拟主机必须购买包含托管的每个域的证书,然后每次添加或删除新域时重新申请新证书。

SNI 在 SSL/TLS 握手开始时将请求的域名转发到服务器,允许我们的服务器为域名选择适当的证书,并防止浏览器告诉我们的用户他们可能受到攻击。

https.Server(继承自tls.Server)具有addContext方法,允许我们为多个单独的域指定主机名和证书凭据。

让我们通过进行一些更改来启用 TLS 兼容的虚拟主机。首先,在mappings.js中,我们将添加另一个方便函数,称为secureShare:

function secureShare(domain) {
  var site = {
    content: staticServe(domain),
    cert: fs.readFileSync('sites/' + domain + '/certs/cert.pem'),
    key: fs.readFileSync('sites/' + domain + '/certs/key.pem')
  };
  return site;
} ;

接下来,我们将改变加载站点的方式,调用secureShare而不是staticServe:

exports.sites = {
  'nodecookbook.com' : secureShare('nodecookbook.com'),
  'davidmarkclements.com' : secureShare('davidmarkclements.com')
};

为了使这个示例在生产环境中工作,我们必须用我们控制的域名替换示例域名,并获得由受信任的证书颁发机构签发的真正证书。

注意

我们可以通过按照本章的支持代码文件中的说明(在secure_virtual_hosting/howto下)在本地进行测试。

让我们通过将sites文件夹结构更改为符合mappings.js中所做的更改,将nodecookbook重命名为nodecookbook.com,将localhost-site重命名为davidmarkclements.com,并将后者的index.html文件更改为以下内容:

<b>This is DavidMarkClements.com virtually AND secure</b>

每个site文件夹还需要一个包含我们的cert.pemkey.pem文件的certs文件夹。这些文件必须是专门为该域购买的证书。

server.js中,我们将脚本顶部更改为以下内容:

var https = require('https');
var fs = require('fs');

需要fs模块来加载我们的凭据。由于我们已经用https替换了http,我们将修改我们的createServer调用如下:

var server = https.createServer({}, function (req, res) {

只需将http添加s即可。在这种情况下,没有 SSL/TLS 凭据,因为我们将使用addContext方法按域加载这些凭据。因此,我们只传递一个空对象。

mappings.js中,我们的secureShare函数返回一个包含三个属性content, keycert的对象,其中content保存静态服务器。因此,在server.js中,我们更新这一行:

  if (site) { site.serve(req, res); return; }

至:

  if (site) { site.content.serve(req, res); return; }

由于我们正在托管在实时服务器上,我们通过绑定到0.0.0.0:将其暴露给传入的 Web 连接

}).listen(port, '0.0.0.0');

我们还可以将port变量更改为443,以直接通过 HTTPS 端口提供服务(我们必须以 root 身份运行服务器才能做到这一点,在实际环境中这具有安全风险,请参见第十章,上线,了解如何安全地执行此操作)。

最后,我们在server.js的底部添加以下内容:

Object.keys(mappings.sites).forEach(function (hostname) {
  server.addContext(hostname, {key: mappings.sites[hostname].key,
    cert: mappings.sites[hostname].cert});    
});

这将根据mappings.js中的设置加载每个域的keycert属性。

只要我们为每个指定的域拥有受信任的 CA 认证凭据,并且我们使用现代浏览器,我们就可以在不接收警告的情况下使用 HTTPS 访问每个站点。

提示

注意

有一个问题:服务器名称指示仅适用于现代浏览器。在这种情况下,现代浏览器不包括在 Windows XP 上运行时的 Internet Explorer 7/8 和 Safari,以及 Android Gingerbread(2.x 版本)和 Blackberry 浏览器。如果我们通过https.createServer的选项对象提供了默认证书,用户仍然可以在旧版浏览器上查看网站,但他们将收到与我们不使用 SNI 时相同的警告(旧版浏览器在 SSL/TLS 协商中不指示主机名,因此我们的 SNI 处理永远不会发生)。根据预期的市场,我们可能必须使用替代方法,直到这些旧版浏览器在与我们的目的相关的数量上使用得足够少。

另请参阅

  • 提供静态文件在第一章中讨论,搭建 Web 服务器

  • 动态路由在第六章中讨论,使用 Express 加速开发

  • 设置 HTTPS Web 服务器在第七章中讨论,实现安全、加密和认证

  • 部署到服务器环境在第十章中讨论,上线

第九章:编写自己的 Node 模块

在本章中,我们将涵盖:

  • 创建一个测试驱动的模块 API

  • 编写一个功能模块的模拟

  • 从功能到原型的重构

  • 扩展模块的 API

  • 将模块部署到 npm

介绍

自从诞生以来,一个蓬勃发展的模块生态系统一直是 Node 的核心目标之一。该框架倾向于模块化。即使是核心功能(如 HTTP)也是通过模块系统提供的。

创建我们自己的模块几乎和使用核心和第三方模块一样容易。我们只需要了解模块系统的工作原理和一些最佳实践。

一个优秀的模块是执行特定功能的高标准,而优秀的代码是多个开发周期的结果。在本章中,我们将从头开始开发一个模块,从定义其应用程序编程接口(API)开始,逐步创建我们的模块。最后,我们将把它部署到 npm 以造福所有人。

创建一个测试驱动的模块 API

我们将通过松散地遵循测试驱动开发(TDD)模型(参见en.wikipedia.org/wiki/Test-driven_development了解更多信息)来创建我们的模块。JavaScript 是异步的,因此代码可以在多个时间流中执行。这有时可能会构成一个具有挑战性的心智难题。

测试套件在 JavaScript 开发中是一个特别强大的工具。当测试通过时,它提供了一个质量保证过程,并且能够激发模块用户的信心。

此外,我们可以预先定义我们的测试,作为一种在开始开发之前规划预期 API 的方式。

在这个示例中,我们将通过创建一个模块的测试套件来提取 MP3 文件的统计信息。

准备工作

让我们创建一个名为mp3dat的新文件夹,里面有一个名为index.js的文件。然后再创建两个子文件夹:libtest,它们都包含index.js

我们还需要 MP3 文件进行测试。为简单起见,我们的模块只支持关闭错误保护的 MPEG-1 Layer 3 文件。其他类型的 MP3 文件包括 MPEG-2 和 MPEG-2.5。MPEG-1(无错误保护)可能是最常见的类型,但我们的模块可以很容易地进行扩展。我们可以从www.paul.sladen.org/pronunciation/torvalds-says-linux.mp3获取一个 MPEG-1 Layer 3 文件。让我们把这个文件放在我们的新mp3dat/test文件夹中,并将其命名为test.mp3

本章的重点是创建一个完全功能的模块,不需要对 MP3 文件结构有先验知识。在本章中,关于 MP3 的细节可以安全地略过,而有关模块创建的信息则至关重要。然而,我们可以从en.wikipedia.org/wiki/MP3了解更多关于 MP3 文件及其结构的信息。

如何做...

让我们打开test/index.js并设置一些变量,如下所示:

var assert = require('assert');
var mp3dat = require('../index.js');
var testFile = 'test/test.mp3';

assert是一个专门用于构建测试套件的核心 Node 模块。总体思路是我们断言某件事应该是真的(或假的),如果断言是正确的,测试就通过了。mp3dat变量需要我们的主要(当前为空白的)index.js文件,该文件将加载lib/index.js文件,其中包含实际的模块代码。

testFile变量从我们模块的根目录(mp3dat文件夹)的角度指向我们的test.mp3文件。这是因为我们从模块目录的根目录运行测试。

现在我们将决定我们的 API 并编写相应的测试。让我们模仿fs.stat方法来设计我们的模块。我们将使用mp3dat.stat方法获取有关 MP3 文件的数据,该方法将接受两个参数:文件路径和一次收集统计信息后要调用的回调函数。

mp3dat.stat回调将接受两个参数。第一个将是错误对象,如果没有错误,应将其设置为null,第二个将包含我们的stats对象。

stats对象将包含duration, bitrate, filesize, timestamptimesig属性。duration属性将包含一个对象,其中包含hours, minutes, secondsmilliseconds键。

例如,我们的test.mp3文件应该返回类似以下的内容:

{ duration: { hours: 0, minutes: 0, seconds: 5, milliseconds: 186 },
  bitrate: 128000,
  filesize: 82969,
  timestamp: 5186,
  timesig: '00:00:05' }

现在我们已经构想出我们的 API,我们可以将其映射到断言测试中,作为强制执行 API 在整个模块开发过程中的手段。

让我们从mp3datmp3dat.stat开始。

assert(mp3dat, 'mp3dat failed to load');

assert(mp3dat.stat, 'there should be a stat method');

assert(mp3dat.stat instanceof Function, 'stat should be a Function');

要测试mp3dat.stat函数,我们实际上必须调用它,然后在其回调函数中执行进一步的测试。

mp3dat.stat(testFile, function (err, stats) {

  assert.ifError(err);

  //expected properties
  assert(stats.duration, 'should be a truthy duration property');
  assert(stats.bitrate, 'should be a truthy bitrate property');
  assert(stats.filesize, 'should be a truthy filesize property');
  assert(stats.timestamp, 'should be a truthy timestamp property');
  assert(stats.timesig, 'should be a truthy timesig property');

现在我们已经确定了预期的stats属性,我们可以进一步指定这些属性应该是什么样子的,在回调函数中,我们写下以下代码:

  //expected types
  assert.equal(typeof stats.duration, 'object', 'duration should be an object type');
  assert(stats.duration instanceof Object, 'durations should be an instance of Object');
  assert(!isNaN(stats.bitrate), 'bitrate should be a number');
  assert(!isNaN(stats.filesize), 'filesize should be a number');
  assert(!isNaN(stats.timestamp), 'timestamp should be a number');

  assert(stats.timesig.match(/^\d+:\d+:\d+$/), 'timesig should be in HH:MM:SS format');

  //expected duration properties
  assert.notStrictEqual(stats.duration.hours, undefined,  'should be a duration.hours property');
  assert.notStrictEqual(stats.duration.minutes, undefined, 'should be a duration.minutes property');
  assert.notStrictEqual(stats.duration.seconds, undefined, 'should be a duration.seconds property');
  assert.notStrictEqual(stats.duration.milliseconds, undefined, 'should be a duration.milliseconds property');

  //expected duration types
  assert(!isNaN(stats.duration.hours), 'duration.hours should be a number');
  assert(!isNaN(stats.duration.minutes), 'duration.minutes should be a number');
  assert(!isNaN(stats.duration.seconds), 'duration.seconds should be a number');
  assert(!isNaN(stats.duration.milliseconds), 'duration.milliseconds should be a number');

  //expected duration properties constraints
  assert(stats.duration.minutes < 60, 'duration.minutes should be no greater than 59');
  assert(stats.duration.seconds < 60, 'duration.seconds should be no greater than 59');
  assert(stats.duration.milliseconds < 1000, 'duration.seconds should be no greater than 999');

  console.log('All tests passed');  //if we've gotten this far we are done.
});

现在让我们运行我们的测试。从mp3dat文件夹中,我们说:

node test 

这应该返回包含以下内容的文本:

AssertionError: there should be a stat method

这完全正确,我们还没有编写stat方法。

它是如何工作的...

当运行测试时,assert模块将抛出AssertionError,以让开发人员知道他们的代码目前与他们对所需 API 的预定义断言不一致。

在我们的单元测试文件(test/index.js)中,我们主要使用简单的assert函数(assert.ok的别名)。assert要求传递给它的第一个参数为真值。否则,它会抛出AssertionError,其中第二个提供的参数用于错误消息(assert.ok的相反是assert.fail,它期望一个假值)。

我们的测试失败了:

assert(mp3dat.stat, 'there should be a stat method');

这是因为mp3dat.statundefined(一个假值)。

assert的第一个参数可以是一个表达式。例如,我们使用stats.duration.minutes < 60来为duration.minutes属性设置约束,并在timesig上使用match方法来验证正确的时间模式 HH:MM:SS。

我们还使用assert.equalassert.notStrictEqualassert.equal是一个应用类型强制相等的测试(例如,等同于==),assert.strictEqual要求值和类型匹配,assert.notEqualassert.notStrictEqual是相应的对立面。

我们使用assert.notStrictEqual来确保duration对象的子属性(hours, minutes等)的存在。

还有更多...

有许多测试框架提供额外的描述性语法、增强功能、异步测试能力等。让我们尝试一个。

使用 should.js 进行单元测试

第三方的should模块很好地放在核心assert模块之上,为我们的测试添加了一些语法糖,以简化和增强描述能力。让我们安装它。

npm install should 

现在我们可以使用should来重写我们的测试,如下面的代码所示:

var should = require('should');
var mp3dat = require('../index.js');
var testFile = 'test/test.mp3';

should.exist(mp3dat);
mp3dat.should.have.property('stat');
mp3dat.stat.should.be.an.instanceof(Function);

mp3dat.stat(testFile, function (err, stats) {
  should.ifError(err);

  //expected properties
  stats.should.have.property('duration');
  stats.should.have.property('bitrate');
  stats.should.have.property('filesize');    
  stats.should.have.property('timestamp');
  stats.should.have.property('timesig');  

  //expected types
  stats.duration.should.be.an.instanceof(Object);
  stats.bitrate.should.be.a('number');
  stats.filesize.should.be.a('number');
  stats.timestamp.should.be.a('number');  

  stats.timesig.should.match(/^\d+:\d+:\d+$/);

  //expected duration properties
  stats.duration.should.have.keys('hours', 'minutes', 'seconds', 'milliseconds');

  //expected duration types and constraints
  stats.duration.hours.should.be.a('number');
  stats.duration.minutes.should.be.a('number').and.be.below(60);
  stats.duration.seconds.should.be.a('number').and.be.below(60);
  stats.duration.milliseconds.should.be.a('number').and.be.below(1000);  

  console.log('All tests passed');

});

should允许我们编写更简洁和描述性的测试。它的语法是自然和不言自明的。我们可以在其 Github 页面上阅读各种should方法:www.github.com/visionmedia/should.js.

另请参阅

  • 在本章讨论的编写功能模块原型

  • 在本章讨论的模块 API 扩展

  • 在本章讨论的将模块部署到 npm

编写一个功能模块原型

现在我们已经编写了我们的测试(参见前面的配方),我们准备创建我们的模块(顺便说一句,从现在开始,我们将使用我们的单元测试的should版本,而不是assert)。

在这个配方中,我们将以简单的功能风格编写我们的模块,以证明概念。在下一个配方中,我们将把我们的代码重构成一个更常见的以可重用性和可扩展性为中心的模块化格式。

准备工作

让我们打开我们的主要index.js文件,并通过module.exports将其链接到lib目录。

module.exports = require('./lib');

这使我们可以将模块代码的核心整齐地放在lib目录中。

如何做...

我们将打开lib/index.js,并开始引入fs模块,用于读取 MP3 文件,并设置一个bitrates映射,将十六进制表示的值与 MPEG-1 规范定义的比特率值进行交叉引用。

var fs = require('fs');

//half-byte (4bit) hex values to interpreted bitrates (bps)
//only MPEG-1 bitrates supported
var bitrates = { 1 : 32000, 2 : 40000, 3 : 48000, 4 : 56000, 5 : 64000,
  6 : 80000, 7 : 96000, 8 : 112000, 9 : 128000, A : 160000, B : 192000,
  C : 224000, D : 256000, E : 320000 };

现在我们将定义两个函数,findBitRate用于定位和转换半字节比特率,buildStats用于将所有收集到的信息压缩成先前确定的最终stats对象。

function buildStats(bitrate, size, cb) {
  var magnitudes = [ 'hours', 'minutes', 'seconds', 'milliseconds'],
    duration = {}, stats,
    hours = (size / (bitrate / 8) / 3600);

  (function timeProcessor(time, counter) {
      var timeArray = [], factor = (counter < 3) ? 60 : 1000 ;
      if (counter) {        
        timeArray = (factor * +('0.' + time)).toString().split('.');
      }

      if (counter < magnitudes.length - 1) {
        duration[magnitudes[counter]] = timeArray[0] || Math.floor(time);
        duration[magnitudes[counter]] = +duration[magnitudes[counter]];
        counter += 1;
        timeProcessor(timeArray[1] || time.toString().split('.')[1], counter);
        return;
      }
        //round off the final magnitude
        duration[magnitudes[counter]] = Math.round(timeArray.join('.'));
  }(hours, 0));

  stats = {
    duration: duration,
    bitrate: bitrate,
    filesize: size,
    timestamp: Math.round(hours * 3600000),
    timesig: ''
  };

  function pad(n){return n < 10 ? '0'+n : n}  
   magnitudes.forEach(function (mag, i) {
   if (i < 3) {
    stats.timesig += pad(duration[mag]) + ((i < 2) ? ':' : '');
   }
  });

  cb(null, stats);
}

buildStats接受bitrate, sizecb参数。它使用bitratesize来计算音轨中的秒数,然后使用这些信息生成stats对象,并通过cb函数传递。

为了将bitrate传递给buildStats,让我们按照以下代码编写findBitRate函数:

function findBitRate(f, cb) {
   fs.createReadStream(f)
    .on('data', function (data) {
      var i;
      for (i = 0; i < data.length; i += 2) {
        if (data.readUInt16LE(i) === 64511) {
          this.destroy();
          cb(null, bitrates[data.toString('hex', i + 2, i + 3)[0]]);
          break;
        };
    }
  }).on('end', function () {
    cb(new Error('could not find bitrate, is this definitely an MPEG-1 MP3?'));
  });   
}

最后,我们公开了一个stat方法,它利用我们的函数来生成stats对象:

exports.stat = function (f, cb) {
  fs.stat(f, function (err, fstats) {
    findBitRate(f, function (err, bitrate) {
      if (err) { cb(err); return; }
      buildStats(bitrate, fstats.size, cb);    
    });
  });
}

现在让我们运行我们从上一个示例中的(should)测试:

node test 

它应该输出以下内容:

All tests passed

它是如何工作的...

exports对象是 Node 平台的核心部分。它是require的另一半。当我们需要一个模块时,添加到exports的任何属性都通过require暴露出来。因此,当我们这样做时:

var mp3dat = require('mp3dat');

我们可以通过mp3dat.stat访问exports.stat,甚至可以通过require('mp3dat').stat访问(假设我们已经将mp3dat安装为一个模块,参见将模块部署到 npm)。

如果我们想为整个模块公开一个函数,我们使用module.exports,就像我们在本示例的准备就绪部分中设置的顶级index.js文件一样。

我们的stat方法首先调用fs.stat,并传递用户提供的文件名(f)。我们使用提供的fstats对象来检索文件的大小,然后将其传递给buildStats。也就是说,在我们调用findBitRate来检索 MP3 的bitrate之后,我们也将其传递给buildStats

buildStats回调直接通过我们的stat方法的回调传递,用户回调的执行起源于buildStats

findBitRate创建了用户提供文件(f)的readStream,并循环遍历每个发出的data块,每次两个字节,从而减少搜索时间。我们之所以能这样做,是因为我们正在寻找两个字节的同步字,它们总是在可以被二整除的位置。在十六进制中,同步字是FFFB,作为 16 字节的小端无符号整数是64511(这仅适用于没有错误保护的 MPEG-1 MP3 文件)。

MP3 同步字后面的四个比特(半字节)包含比特率值。因此,我们通过Buffer.toString方法将其传递,要求十六进制输出,然后与我们的bitrates对象映射进行匹配。在我们的test.mp3文件的情况下,半字节的十六进制值为9,表示每秒128000比特的比特率。

一旦我们找到比特率,我们执行回调并调用this.destroy,这会突然终止我们的readStream,防止end事件被触发。只有在没有发现比特率的情况下,end事件才会发生,此时我们通过回调发送错误。

buildStats接收bitrate并将其除以8得到每秒的字节数(8 位为 1 字节)。将 MP3 的总字节数除以每秒的字节数得到秒数。然后我们再除以 3,600 得到hours变量,然后将其传递到嵌入的timeProcessor函数中。timeProcessor简单地通过magnitudes数组(小时,分钟,秒,毫秒)进行递归,直到seconds被准确转换并分配到每个数量级,从而得到我们的duration对象。然后,我们使用计算出的持续时间(以任何形式)来构建我们的timestamptimesig属性。

还有更多...

模块的使用示例可以成为最终用户的重要资源。让我们为我们的新模块编写一个示例。

编写模块使用示例

我们将在mp3dat文件夹中创建一个examples文件夹,并创建一个名为basic.js的文件(用于基本用法示例),将以下内容写入其中:

var mp3dat = require('../index.js');

mp3dat.stat('../test/test.mp3', function (err, stats) {
  console.log(stats);
});

这应该导致控制台输出以下内容:

{ duration: { hours: 0, minutes: 0, seconds: 5, milliseconds: 186 },
  bitrate: 128000,
  filesize: 82969,
  timestamp: 5186,
  timesig: '00:00:05' }

另请参阅

  • 在本章讨论的创建测试驱动模块 API

  • 从功能到原型的重构在本章中讨论

  • 将模块部署到 npm在本章中讨论

从功能到原型的重构

在上一篇文章中创建的功能模拟可以帮助我们对概念有所了解,并且对于范围较小、简单的模块可能是完全足够的。

然而,原型模式(以及其他模式)通常被模块创建者使用,经常用于 Node 的核心模块,并且是原生 JavaScript 方法和对象的基础。

原型继承稍微更节省内存。原型上的方法在调用之前不会被实例化,并且它们会被重复使用,而不是在每次调用时重新创建。

另一方面,它可能比我们上一个配方的过程式风格稍慢,因为 JavaScript 引擎需要额外的开销来遍历原型链。尽管如此,将模块视为用户可以创建实例的实体,并以(例如,基于原型的方法)实现它们可能更合适。首先,这样可以更容易地通过克隆和原型修改进行编程扩展。这为最终用户提供了极大的灵活性,同时模块代码的核心完整性保持不变。

在这个配方中,我们将根据原型模式重写上一个任务中的代码。

准备工作

让我们开始编辑mp3dat/lib中的index.js

如何做...

首先,我们需要创建一个构造函数(使用new调用的函数),我们将其命名为Mp3dat

var fs = require('fs');

function Mp3dat(f, size) {
  if (!(this instanceof Mp3dat)) {
    return new Mp3dat(f, size);
  }
  this.stats = {duration:{}};
}

我们还像上一个任务一样需要fs模块。

让我们向构造函数的原型添加一些对象和方法:

Mp3dat.prototype._bitrates = { 1 : 32000, 2 : 40000, 3 : 48000, 4 : 56000, 5 : 64000, 6 : 80000, 7 : 96000, 8 : 112000, 9 : 128000, A : 160000, B : 192000, C : 224000, D : 256000, E : 320000 };

Mp3dat.prototype._magnitudes = [ 'hours', 'minutes', 'seconds', 'milliseconds'];

Mp3dat.prototype._pad = function (n) { return n < 10 ? '0' + n : n; }  

Mp3dat.prototype._timesig = function () {
  var ts = '', self = this;;
  self._magnitudes.forEach(function (mag, i) {
   if (i < 3) {
    ts += self._pad(self.stats.duration[mag]) + ((i < 2) ? ':' : '');
   }
  });
  return ts;
}

我们的新Mp3dat属性中的三个(_magnitudes、_pad_timesig)在buildStats函数中以某种形式被包含。我们在它们的名称前加上下划线(_)来表示它们是私有的。这只是一个约定,JavaScript 实际上并没有将它们私有化。

现在我们将上一个配方的findBitRate函数转换如下:

Mp3dat.prototype._findBitRate = function(cb) {
  var self = this;
   fs.createReadStream(self.f)
    .on('data', function (data) {
      var i = 0;
       for (i; i < data.length; i += 2) {
        if (data.readUInt16LE(i) === 64511) {
          self.bitrate = self._bitrates[data.toString('hex', i + 2, i + 3)[0]];
          this.destroy();
          cb(null);
          break;
        };
    }
  }).on('end', function () {
    cb(new Error('could not find bitrate, is this definitely an MPEG-1 MP3?'));
  });
}

唯一的区别是我们从对象(self.f)中加载文件名,而不是通过第一个参数,我们将bitrate加载到对象上,而不是通过cb的第二个参数发送它。

现在,为了将buildStats转换为原型模式,我们编写以下代码:

Mp3dat.prototype._buildStats = function (cb) {
  var self = this,
  hours = (self.size / (self.bitrate / 8) / 3600);

  self._timeProcessor(hours, function (duration) {
    self.stats = {
      duration: duration,
      bitrate: self.bitrate,
      filesize: self.size,
      timestamp: Math.round(hours * 3600000),
      timesig: self._timesig(duration, self.magnitudes)
    };
    cb(null, self.stats);

  });
}

我们的_buildStats原型方法比上一个任务中的buildStats方法要小得多。我们不仅提取了它的内部magnitudes数组、pad实用函数和时间签名功能(将其包装成自己的_timesig方法),还将内部递归的timeProcessor函数外包到了一个原型方法中。

Mp3dat.prototype._timeProcessor = function (time, counter, cb) {
  var self = this, timeArray = [], factor = (counter < 3) ? 60 : 1000,
    magnitudes = self._magnitudes, duration = self.stats.duration;

  if (counter instanceof Function) {
    cb = counter;
    counter = 0;
  }

  if (counter) {        
    timeArray = (factor * +('0.' + time)).toString().split('.');
  }
  if (counter < magnitudes.length - 1) {
    duration[magnitudes[counter]] = timeArray[0] || Math.floor(time);
    duration[magnitudes[counter]] = +duration[magnitudes[counter]];
    counter += 1;
    self._timeProcessor.call(self, timeArray[1] || time.toString().split('.')[1], counter, cb);
    return;
  }
    //round off the final magnitude (milliseconds)
    duration[magnitudes[counter]] = Math.round(timeArray.join('.'));
    cb(duration);
}

最后,我们编写stat方法(没有下划线前缀,因为它是供公共使用的),并导出Mp3dat对象。

Mp3dat.prototype.stat = function (f, cb) {
  var self = this;
  fs.stat(f, function (err, fstats) {
    self.size = fstats.size;
    self.f = f;
    self._findBitRate(function (err, bitrate) {
      if (err) { cb(err); return; }
      self._buildStats(cb);
    });    
  });
}

module.exports = Mp3dat();

我们可以通过运行我们在第一个配方中构建的测试来确保一切都正确。在mp3dat文件夹的命令行中,我们说:

node test 

应该输出:

All tests passed

它是如何工作的...

在上一个配方中,我们有一个exports.stat函数,它调用findBitRatebuildStats函数来获取stats对象。在我们重构的模块中,我们将stat方法添加到原型上,并通过module.exports导出整个Mp3dat构造函数。

我们不必使用newMp3dat传递给module.exports。我们的函数在直接调用时生成新的实例,代码如下:

  if (!(this instanceof Mp3dat)) {
    return new Mp3dat();
  }

这真的是一个保险策略。使用new初始化构造函数更有效(尽管在边缘上)。

我们重构的代码中的stat方法与先前任务中的exports.stat函数不同。它不是将文件名和指定 MP3 的大小作为参数传递给findBitRatebuildStats,而是通过this将它们分配给父对象(将其分配给self以避免this的新回调范围重新分配)。

然后调用_findBitRate_buildStats方法,最终生成stats对象并将其传递回用户的回调。

在我们的test.mp3文件上运行mp3dat.stats之后,我们重构的mp3dat模块对象将包含以下内容:

{ stats:
   { duration: { hours: 0, minutes: 0, seconds: 5, milliseconds: 186 },
     bitrate: 128000,
     filesize: 82969,
     timestamp: 5186,
     timesig: '00:00:05' },
  size: 82969,
  f: 'test/test.mp3',
  bitrate: 128000 }

然而,在前一个示例中,返回的对象将简单地如下所示:

{ stat: [Function] }

功能风格揭示了 API。我们重构的代码允许用户以多种方式与信息进行交互(通过statsmp3dat对象)。我们还可以扩展我们的模块,并在stats对象之外的其他时间填充mp3dat

还有更多...

我们可以构建我们的模块,使其更容易使用。

将 stat 函数添加到初始化的 mp3dat 对象中

如果我们想直接将我们的stat函数暴露给mp3dat对象,从而允许我们直接查看 API(例如,使用console.log),我们可以通过删除Mp3dat.prototype.stat并修改Mp3dat来添加它如下:

function Mp3dat() {
  var self = this;
  if (!(this instanceof Mp3dat)) {
    return new Mp3dat();
  }
  self.stat = function (f, cb) {
    fs.stat(f, function (err, fstats) {
      self.size = fstats.size;
      self.f = f;
      self._findBitRate(function (err, bitrate) {
        if (err) { cb(err); return; }
        self._buildStats(cb);
      });    
    });
  }  
  self.stats = {duration:{}};
}

然后我们的最终对象变成:

{ stat: [Function],
  stats:
   { duration: { hours: 0, minutes: 0, seconds: 5, milliseconds: 186 },
     bitrate: 128000,
     filesize: 82969,
     timestamp: 5186,
     timesig: '00:00:05' },
  size: 82969,
  f: 'test/test.mp3',
  bitrate: 128000 }

或者,如果我们不关心将stats对象和其他Mp3dat属性推送到模块用户,我们可以将一切保持原样,只需更改以下代码:

module.exports = Mp3dat()

到:

exports.stat = function (f, cb) {
  var m = Mp3dat();
  return Mp3dat.prototype.stat.call(m, f, cb);
}

这使用call方法将Mp3dat范围应用于stat方法(允许我们依赖stat方法),并将返回一个具有以下内容的对象:

{ stat: [Function] }

就像我们的模块的第一次写作一样,只是我们仍然保留了原型模式。这种第二种方法稍微更有效。

允许多个实例

我们的模块是一个单例,因为它返回已初始化的Mp3dat对象。这意味着无论我们需要多少次并将其分配给变量,模块用户始终将引用相同的对象,即使Mp3dat在父脚本加载的不同子模块中被需要。

这意味着如果我们尝试同时运行两个mp3dat.stat方法,将会发生糟糕的事情。在需要多次引用我们的模块的情况下,持有相同对象的两个变量最终可能会互相覆盖属性,导致不可预测(和令人沮丧)的代码。最有可能的结果是readStreams会发生冲突。

克服这一点的一种方法是修改以下内容:

module.exports = Mp3dat()

到:

module.exports = Mp3dat

然后使用以下代码加载两个实例:

var Mp3dat = require('../index.js'),
	mp3dat = Mp3dat(),
      mp3dat2 = Mp3dat();

如果我们想要提供单例和多个实例,我们可以在构造函数的原型中添加一个spawnInstance方法:

Mp3dat.prototype.spawnInstance = function () {
  return Mp3dat();
}

module.exports = Mp3dat();

然后我们可以做如下事情:

var mp3dat = require('../index.js'),
   mp3dat2 = mp3dat.spawnInstance();

mp3datmp3dat2都将是单独的Mp3dat实例,而在以下情况下:

var mp3dat = require('../index.js'),
   mp3dat2 = require('../index.js');

两者将是相同的实例。

另请参阅

  • 在本章中讨论的编写功能模块模拟

  • 在本章中讨论的扩展模块的 API

  • 在本章中讨论的将模块部署到 npm

扩展模块的 API

我们可以以许多方式扩展我们的模块,例如,我们可以使其支持更多的 MP3 类型,但这只是例行工作。只需找出不同的同步字和不同类型的 MP3 的比特率,然后将它们添加到相关位置。

对于更有趣的冒险,我们可以扩展 API,为我们的模块用户创建更多选项。

由于我们使用流来读取我们的 MP3 文件,我们可以允许用户传入文件名或 MP3 数据流,提供简单(使用简单文件名)和灵活(使用流)两种方式。这样我们就可以启动下载流、STDIN 流,或者实际上任何 MP3 数据流。

准备工作

我们将从前一篇食谱的允许多个实例部分的还有更多..中继续使用我们的模块。

如何做...

首先,我们将为我们的新 API 添加一些更多的测试。在tests/index.js中,我们将从mp3dat.stat调用中提取回调函数到全局范围,并将其命名为cb:

function cb (err, stats) {
  should.ifError(err);

  //expected properties
  stats.should.have.property('duration');

  //...all the other unit tests here

  console.log('passed');

};

现在我们将调用stat以及一个我们将要编写并命名为statStream的方法:

mp3dat.statStream({stream: fs.createReadStream(testFile),
  size: fs.statSync(testFile).size}, cb);

mp3dat2.stat(testFile, cb);

注意我们使用了两个Mp3dat实例(mp3datmp3dat2)。所以我们可以同时运行statstatStream测试。由于我们正在创建一个readStream,我们需要在我们的[tests/index.js]文件的顶部引入fs

var should = require('should');
var fs = require('fs');
var mp3dat = require('../index.js'),
  mp3dat2 = mp3dat.spawnInstance();

我们还将为statStream方法添加一些顶层的should测试,如下所示:

should.exist(mp3dat);
mp3dat.should.have.property('stat');
mp3dat.stat.should.be.an.instanceof(Function);
mp3dat.should.have.property('statStream');
mp3dat.statStream.should.be.an.instanceof(Function);

现在来满足我们测试的期望。

lib/index.js中,我们向Mp3dat的原型添加了一个新的方法。它不再接受第一个参数的文件名,而是接受一个对象(我们将其称为opts),该对象必须包含streamsize属性:

Mp3dat.prototype.statStream = function (opts, cb) {
  var self = this,
    errTxt = 'First arg must be options object with stream and size',
    validOpts = ({}).toString.call(opts) === '[object Object]'
      && opts.stream
      && opts.size
      && 'pause' in opts.stream
      && !isNaN(+opts.size);
   lib
  if (!validOpts) {
    cb(new Error(errTxt));
    return;
  }

  self.size = opts.size;
  self.f = opts.stream.path;

  self.stream = opts.stream;

  self._findBitRate(function (err, bitrate) {
    if (err) { cb(err); return; }
    self._buildStats(cb);
  });    

}

最后,对_findBitRate进行一些修改,我们就完成了。

Mp3dat.prototype._findBitRate = function(cb) {
  var self = this,
    stream = self.stream || fs.createReadStream(self.f);
  stream
    .on('data', function (data) {
      var i = 0;
       for (i; i < data.length; i += 2) {
        if (data.readUInt16LE(i) === 64511) {
          self.bitrate = self._bitrates[data.toString('hex', i + 2, i + 3)[0]];
          this.destroy();
          cb(null);
          break;
        };
//rest of the _findBitRate function...

我们有条件地挂接到传入的流,或者我们从给定的文件名创建一个流。

让我们运行我们的测试(从mp3dat文件夹):

node tests 

结果应该是:

passed
passed

一个用于stat,一个用于statStream

它是如何工作的...

我们已经在使用流来检索我们的数据。我们只需通过修改_findBitRate来向用户公开这个接口,使其可以从文件名生成自己的流,或者如果流存在于父构造函数的属性中(self.stream),它只需将该流插入到已经存在的流程中。

然后,我们通过定义一个新的 API 方法statStream来使这个功能对模块用户可用。我们首先通过为其制定测试来概念化它,然后通过Mp3dat.prototype来定义它。

statStream方法类似于stat方法(实际上,我们可以将它们合并,参见还有更多..)。除了检查输入的有效性之外,它只是向Mp3dat实例添加了一个属性:stream属性,它取自opts.stream。为了方便起见,我们将opts.stream.pathself.f进行交叉引用(这取决于流的类型,这可能有或者没有)。这基本上是多余的,但对于用户的调试目的可能是有用的。

statStream的顶部,我们有validOpts变量,其中有一系列由&&条件连接的表达式。这是一堆if语句的简写。如果这些表达式中的任何一个测试失败,opts对象就无效。一个有趣的表达式是opts.stream中的'pause',它测试opts.stream是否绝对是一个流或者是从流继承而来的(所有流都有一个pause方法,in检查整个原型链中的属性)。在validOpts测试中另一个值得注意的表达式是!isNaN(+opts.size),它检查opts.size是否是一个有效的数字。前面的+将其转换为Number类型,!isNaN检查它是否不是"not a number"(JavaScript 中没有isNumber,所以我们使用!isNaN)。

还有更多...

现在我们有了这个新方法。让我们写一些更多的例子。我们还将看到如何将statStreamstat合并在一起,并通过使其发出事件来进一步增强我们的模块。

制作标准输入流示例

为了演示与其他流的使用,我们可以编写一个使用process.stdin流的示例,如下所示:

//to use try :
// cat ../test/test.mp3 | node stdin_stream.js 82969
// the argument (82969) is the size in bytes of the mp3

if (!process.argv[2]) {
  process.stderr.write('\nNeed mp3 size in bytes\n\n');
  process.exit();
}

var mp3dat = require('../');
process.stdin.resume();
mp3dat.statStream({stream : process.stdin, size: process.argv[2]}, function (err, stats) {
  if (err) { console.log(err); }
  console.log(stats);
});

我们在示例中包含了注释,以确保我们的用户了解如何使用它。我们在这里所做的就是接收process.stdin流和文件大小,然后将它们传递给我们的statStream方法。

制作 PUT 上传流示例

在第二章的处理文件上传食谱中,探索 HTTP 对象,我们在那个食谱的还有更多..部分创建了一个 PUT 上传实现。

我们将从该示例中获取put_upload_form.html文件,并在mp3dat/examples文件夹中创建一个名为HTTP_PUT_stream.js的新文件。

var mp3dat = require('../../mp3dat');
var http = require('http');
var fs = require('fs');
var form = fs.readFileSync('put_upload_form.html');
http.createServer(function (req, res) {
  if (req.method === "PUT") {
    mp3dat.statStream({stream: req, size:req.headers['content-length']}, function (err, stats) {
      if (err) { console.log(err); return; }
      console.log(stats);
    });

  }
  if (req.method === "GET") {
    res.writeHead(200, {'Content-Type': 'text/html'});
    res.end(form);
  }
}).listen(8080);

在这里,我们创建了一个服务器,用于提供put_upload_form.html文件。HTML 文件允许我们指定要上传的文件(必须是有效的 MP3 文件),然后将其发送到服务器。

在我们的服务器中,我们将req(一个流)传递给stream属性和req.headers['content-length'],这样我们就可以得到 MP3 的大小(以浏览器通过Content-Length头部指定的字节为单位)。

然后我们通过在控制台上记录stats来完成(我们还可以通过以 JSON 形式将stats发送回浏览器来扩展此示例)。

合并 stat 和 statStream

statstatStream之间有很多相似的代码。通过一些重构,我们可以将它们合并成一个方法,允许用户将包含文件名的字符串或包含流和大小属性的对象直接传递给stat方法。

首先,我们需要更新我们的测试和示例。在test/index.js中,我们应该删除以下代码:

mp3dat.should.have.property('statStream');
mp3dat.statStream.should.be.an.instanceof(Function);

由于我们正在将statStream合并到stat中,我们对statstatStream的两次调用应该变成:

mp3dat.stat({stream: fs.createReadStream(testFile),
    size: fs.statSync(testFile).size}, cb);
mp3dat2.stat(testFile, cb);

examples/stdin_stream.js中的statStream行应该变成:

mp3dat.stat({stream : process.stdin, size: process.argv[2]}

HTTP_PUT_stream.js中应该是:

mp3dat.stat({stream: req, size: req.headers['content-length']}

lib/index.js中,我们删除streamStat方法,插入_compile方法:

Mp3dat.prototype._compile =  function (err, fstatsOpts, cb) {
  var self = this;
  self.size = fstatsOpts.size;
  self.stream = fstatsOpts.stream;
    self._findBitRate(function (err, bitrate) {
    if (err) { cb(err); return; }
    self._buildStats(cb);
  });    
}

最后,我们修改我们的Mp3dat.prototype.stat方法如下:

Mp3dat.prototype.stat = function (f, cb) {
  var self = this, isOptsObj = ({}).toString.call(f) === '[object Object]';

  if (isOptsObj) {
    var opts = f, validOpts = opts.stream && opts.size
      && 'pause' in opts.stream && !isNaN(+opts.size);
    errTxt = 'First arg must be options object with stream and size'

    if (!validOpts) { cb(new Error(errTxt)); return; }

    self.f = opts.stream.path;
    self._compile(null, opts, cb);
    return;
  }

  self.f = f;
  fs.stat(f, function (err, fstats) {
    self._compile.call(self, err, fstats, cb);
  });
}

实际生成stats的代码已放入_compile方法中。如果第一个参数是一个对象,我们假设是一个流,stats就承担了以前的statStream的角色,调用_compile并从函数中提前返回。如果不是,我们假设是一个文件名,并使用 JavaScript 的call方法在fs.stat回调中调用_compile,确保我们的this/self变量通过_compile方法传递。

集成 EventEmitter

在本书中,我们通常通过回调参数或通过侦听事件来从模块中接收数据。我们可以进一步扩展我们的modules接口,允许用户通过使 Node 的EventEmitter采用我们的Mp3dat构造函数来侦听事件。

我们需要引入eventsutil模块,然后通过将Mp3datthis对象分配给它来将Mp3datEventEmitter连接起来,然后使用util.inherits给它Mp3dat EventEmitter的超级能力:

var fs = require('fs'),
  EventEmitter = require('events').EventEmitter,
  util = require('util');

function Mp3dat() {
  if (!(this instanceof Mp3dat)) {
    return new Mp3dat();
  }  
  EventEmitter.call(this);
  this.stats = {duration:{}};
}

util.inherits(Mp3dat, EventEmitter);

现在我们只需遍历Mp3dat的现有方法,并在相关位置插入emit事件。一旦找到bitrate,我们就可以像下面这样emit它:

Mp3dat.prototype._findBitRate = function(cb) {
//beginning of _findBitRate method
       for (i; i < data.length; i += 2) {
        if (data.readUInt16LE(i) === 64511) {
          self.bitrate = self._bitrates[data.toString('hex', i + 2, i + 3)[0]];
          this.destroy();
          self.emit('bitrate', self.bitrate);
          cb(null);
          break;
        };
 //rest of _findBitRate method

在我们回调错误的地方,我们也可以像下面的代码一样发出错误:

//last part of _findBitRate method
  }).on('end', function () {
    var err = new Error('could not find bitrate, is this definately an MPEG-1 MP3?');
    self.emit('error', err);
    cb(err);
  });

然后是时间签名:

Mp3dat.prototype._timesig = function () {
 //_timesig function code....
  self.emit('timesig', ts);
  return ts;
}

当然,stats对象:

Mp3dat.prototype._buildStats = function (cb) {
//_buildStats code
  self._timeProcessor(hours, function (duration) {
   //_timeProcessor code
    self.emit('stats', self.stats);
    if (cb) { cb(null, self.stats); }    
  });
}

我们还在_buildStats中添加了if (cb),因为如果用户选择侦听事件,则回调可能不再必要。

如果模块用户动态生成Mp3dat实例,他们可能希望有一种方法来连接到生成的实例事件:

Mp3dat.prototype.spawnInstance = function () {
  var m = Mp3dat();
  this.emit('spawn', m);
  return m;
}

最后,为了允许链接,我们还可以从两个地方的stat函数返回Mp3dat实例。首先在isOptsObj块内如下:

Mp3dat.prototype.stat = function (f, cb) {
//stat code
  if (isOptsObj) {
    //other code here
    self._compile(null, opts, cb);
    return self;
  }

然后在函数的最后,如下所示的代码:

  //prior stat code
  self.f = f;
  fs.stat(f, function (err, fstats) {
    self._compile.call(self, err, fstats, cb);
  });
  return self;
}

这是因为我们根据检测到的输入(文件名或流)从函数中提前返回,所以我们必须从两个地方返回self

现在我们可以为我们的新用户界面编写一个示例。让我们在mp3dat/examples中创建一个名为event_emissions.js的新文件。

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

mp3dat
  .stat('../test/test.mp3')
  .on('bitrate', function (bitrate) {
    console.log('Got bitrate:', bitrate);
  })
  .on('timesig', function (timesig) {
     console.log('Got timesig:', timesig);
  })
  .on('stats', function (stats) {
     console.log('Got stats:', stats);
     mp3dat.spawnInstance();
  })
  .on('error', function (err) {
     console.log('Error:', err);
  })
  .on('spawn', function (mp3dat2) {
    console.log('Second mp3dat', mp3dat2);
  });

另请参阅

  • 在本章中讨论创建一个测试驱动的模块 API

  • 处理文件上传在第二章中讨论,探索 HTTP 对象

  • 在本章中讨论将模块部署到 npm

将模块部署到 npm

现在我们已经创建了一个模块,我们可以使用相同的集成工具与世界其他地方分享它:npm

做好准备

在我们可以部署到 npm 之前,我们需要创建一个package.json文件,所以让我们为我们的模块做这个。在mp3dat中,我们将创建package.json并添加以下代码:

{
  "author": "David Mark Clements <contact@davidmarkclements.com> (http://davidmarkclements.com)",
  "name": "mp3dat",
  "description": "A simple MP3 parser that returns stat infos in a similar style to fs.stat for MP3 files or streams. (MPEG-1 compatible only)",
  "version": "0.0.1",
  "homepage": "http://nodecookbook.com/mp3dat",
  "repository": {
    "type": "git",
    "url": "git://github.com/davidmarkclements/mp3dat.git"
  },
  "main": "./lib/index.js",
  "scripts": {
    "test": "node test"
  },
  "engines": {
    "node": "~0.6.13"
  },
  "dependencies": {},
  "devDependencies": {}
}

当然,我们可以插入我们自己的名称和包的名称。创建package.json文件的另一种方法是使用npm init,它通过命令行询问一系列问题,然后生成package.json文件。

我们可以在package.json中指定一个存储库。使用在线存储库(如 GitHub)来管理版本控制、共享代码并允许其他人在您的代码上工作是一个好主意。请参阅help.github.com/开始使用。

main属性很重要。它定义了我们模块的入口点,在我们的情况下是./lib/index.js(尽管我们也可以指定./index.js来加载./lib/index.js)。通过将scripts.test定义为node test,我们现在可以运行npm test(或者一旦通过npm安装了mp3dat,就可以运行npm mp3dat test)来执行我们的单元测试。

我们将按照上一个步骤中的方式将我们的模块部署到 npm,其中statstatStream都合并到了stat中,并且我们已经集成了EventEmitter

如何做...

要部署到npm,我们必须拥有开发者账户。我们通过执行以下操作来实现这一点:

npm adduser 

填写我们想要的用户名、密码和联系邮箱。就这样,我们现在注册了。

在继续发布我们的模块之前,我们会想要测试npm是否会在我们的系统上安装它。在mp3dat中,我们执行以下操作:

sudo npm install -g 

然后,如果我们从命令行运行node,我们应该能够:

require('mp3dat') 

如果没有收到错误消息。如果成功了,我们可以继续发布我们的模块!在mp3dat中,我们说以下内容:

npm publish 

现在,如果我们转到一个完全不同的文件夹(比如我们的主文件夹)并输入以下内容:

npm uninstall mp3dat
npm install mp3dat 

npm应该从其存储库中安装我们的包。

我们可以用以下命令来双重检查它是否存在:

npm search mp3dat 

或者,如果这花费的时间太长,我们可以在浏览器中转到search.npmjs.org/。我们的模块可能会出现在主页上(其中包含最近发布的模块)。或者我们可以点击search.npmjs.org/#/mp3dat直接转到我们模块的 npm 注册页面。

它是如何工作的...

npm是一个用 Node 编写的命令行脚本,为开发和发布模块提供了一些出色的工具。这些工具确实做到了它们所说的,adduser添加用户,install安装,publish发布。这真的非常优雅。

在服务器端,npm注册表由一个 CouchDB 数据库支持,该数据库保存了每个包的类似 JSON 的数据。甚至有一个我们可以连接到的 CouchDB _changes字段。在命令行上,我们可以这样做:

curl http://isaacs.couchone.com/registry/_changes?feed=continuous&include_docs=true 

观察模块在实时中添加和修改。如果没有发生任何事情,我们可以打开另一个终端并输入以下命令:

mp3dat unpublish
mp3dat publish 

这将导致 CouchDB 更改反馈更新。

还有更多...

npm有一些非常好的功能,让我们来看看其中的一些。

npm link命令对于模块作者来说可能很有用。

在开发过程中,如果我们想要将mp3dat作为全局模块进行引用,例如require('mp3dat'),每次进行更改时,我们可以通过运行以下命令来更新全局包:

sudo npm install . -g 

然而,当我们运行以下命令时,npm link提供了一个更简单的解决方案:

sudo npm link 

在我们的mp3dat文件夹中,从我们的全局node_modules文件夹到我们的工作目录创建了一个符号链接。这会导致 Node 将mp3dat视为已安装的全局模块,但我们对开发副本所做的任何更改都将在全局范围内反映出来。当我们完成开发并希望在系统上冻结模块时,我们只需取消链接,如下所示:

sudo npm unlink -g mp3dat 

.npmignore 和 npm 版本

我们的example文件可能在 GitHub 上很方便,但我们可能会决定它们在npm中没有什么好处。我们可以使用.npmignore文件来阻止某些文件被发布到npm包中。让我们在mp3dat文件夹中创建.npmignore,并放入:

examples/

现在,当我们重新发布到npm注册表时,我们的新包将不包括examples文件夹。在我们可以发布之前,我们要么取消发布,要么更改我们包的版本(或者我们可以使用--force参数)。让我们改变版本,然后再次发布:

npm version 0.0.2 --message "added .npmignore"
npm publish 

更改版本还将改变我们的package.json文件到新的版本号。

参见

  • 编写一个功能模块的模拟在本章讨论

  • 从功能到原型的重构在本章讨论

  • 在本章讨论的扩展模块的 API

  • 使用 Cradle 访问 CouchDB 更改流在第四章中讨论,与数据库交互

第十章:让它上线

在本章中,我们将涵盖:

  • 部署到服务器环境

  • 自动崩溃恢复

  • 持续部署

  • 使用平台即服务提供商进行托管

介绍

Node 是构建和提供在线服务的绝佳平台选择。无论是简单的、精简的网站,还是高度灵活的 Web 应用程序,或者超越 HTTP 的服务,我们都必须在某个时候部署我们的创作。

本章重点介绍了将我们的 Node 应用程序上线所需的步骤。

部署到服务器环境

虚拟专用服务器(VPS)、专用服务器或基础设施即服务(例如,亚马逊 EC2 或 Rackspace 等)以及拥有我们自己的服务器机器都有一个共同点:对服务器环境的完全控制。

然而,伴随着巨大的权力而来的是巨大的责任,我们需要意识到一些挑战。本配方将演示如何克服这些挑战,安全地在端口80上初始化一个 Node Web 应用程序。

准备就绪

当然,我们需要一个远程服务器环境(或我们自己的设置)。研究找到最适合我们需求的最佳套餐非常重要。

专用服务器可能很昂贵。硬件与软件的比例是一比一,我们实际上是在租用一台机器。

VPS 可能更便宜,因为它们共享单台机器(或集群)的资源,因此我们只租用托管操作系统实例所需的资源。然而,如果我们开始使用超出分配的资源,我们可能会受到处罚(停机时间,额外费用),因为过度使用可能会影响其他 VPS 用户。

IaaS 可能相对便宜,特别是在涉及扩展时(当我们需要更多资源时),尽管 IaaS 往往包含按使用量计费的元素,这意味着成本不是固定的,可能需要额外的监控。

我们的配方假设使用运行sshd(SSH 服务)的 Unix/Linux 服务器。此外,我们应该有一个指向我们服务器的域名。在这个配方中,我们将假设域名为nodecookbook.com。最后,我们必须在远程服务器上安装 Node。如果出现困难,我们可以使用www.github.com/joyent/node/wiki/Installation上提供的说明,或者通过包管理器安装,我们可以使用www.github.com/joyent/node/wiki/Installing-Node.js-via-package-manager上的说明。

我们将从第六章使用 Express 加速开发的倒数第二个配方中部署login应用程序,所以我们需要这个。

如何做...

为了准备我们的应用程序传输到远程服务器,我们将删除node_modules文件夹(我们可以在服务器上重建它):

rm -fr login/node_modules 

然后我们通过执行以下命令压缩login目录:

npm pack login 

这将生成一个压缩的存档,名称与package.json文件中给出的应用程序名称和版本相同,对于未经修改的 Express 生成的package.json文件,将生成文件名application-name-0.0.1.tgz

无论npm pack称其为什么,让我们将其重命名为login.tgz

mv application-name-0.0.1.tgz login.tgz #Linux/Mac OS X
rename application-name-0.0.1.tgz login.tgz ::Windows. 

接下来,我们将login.tgz上传到我们的服务器。例如,我们可以使用 SFTP:

sftp root@nodecookbook.com 

一旦通过 SFTP 登录,我们可以发出以下命令:

cd /var/www
put login.tgz 

将上传到/var/www目录并不是必需的,这只是放置网站的一个自然位置。

这假设我们已经通过 SFTP 从包含login.tgz的目录 SFTP 到我们的服务器。

接下来,我们通过 SSH 登录到服务器:

ssh -l root nodecookbook.com 

提示

如果我们使用 Windows 桌面,我们可以使用 putty 进行 SFTP 和 SSH 登录到我们的服务器:www.chiark.greenend.org.uk/~sgtatham/putty/

一旦登录到远程服务器,我们就导航到/var/www并解压login.tar.gz

tar -xvf login.tar.gz 

login.tar.gz解压缩时,它会在服务器上重新创建我们的login文件夹。

要重建node_modules文件夹,我们进入login文件夹并使用npm重新生成依赖项。

cd login
npm -d install

大多数服务器都有基于 shell 的编辑器,如nano,vimemacs。我们可以使用这些编辑器之一来更改app.js中的一行(或者通过 SFTP 传输修改后的app.js):

app.listen(80, function () { process.setuid('www-data'); });

我们现在正在监听标准 HTTP 端口,这意味着我们可以访问我们的应用程序而无需在其 Web 地址后加上端口号。但是,由于我们将以root身份启动应用程序(为了绑定到端口80是必要的),我们还将回调传递给listen方法,该方法将应用程序的访问权限从root更改为www-data

在某些情况下,根据文件权限,从我们的应用程序读取或写入文件可能不再起作用。我们可以通过更改所有权来解决这个问题:

chown -R www-data login 

最后,我们可以用以下方式启动我们的应用程序:

cd login
nohup node app.js & 

我们可以确保我们的应用程序正在作为www-data运行:

ps -ef | grep node 

它是如何工作的...

我们修改了app.listen以绑定到端口80,并添加了一个回调函数,该函数将用户 ID 从root重置为www-data

listen添加回调不仅限于 Express,它在使用普通的httpServer实例时也是一样的。

root身份运行 Web 服务器是不好的做法。如果我们的应用程序被攻击者入侵,他们将通过我们应用程序的特权状态获得对我们系统的root访问权限。

为了降级我们的应用程序,我们调用process.setuid并传入www-data. process.setuid。这要么是用户的名称,要么是用户的 UID。通过传递一个名称,我们导致process.setuid阻塞事件循环(基本上冻结操作),同时它交叉引用用户字符串到其 UID。这消除了应用程序绑定到端口80并作为root运行的潜在时间。实质上,将字符串传递给process.setuid而不是底层 UID 意味着在应用程序不再是root之前什么都不会发生。

我们使用nohup调用我们的进程,然后跟上&。这意味着我们可以自由结束我们的 SSH 会话,而不会导致我们的应用程序随着会话终止而终止。

安德符号将我们的进程转换为后台任务,因此我们可以在其运行时做其他事情(比如退出)。nohup意味着忽略挂断信号(HUP)。每当 SSH 会话终止时,HUP 被发送到通过 SSH 启动的任何运行进程。基本上,使用nohup允许我们的 Web 应用程序在 SSH 会话结束后继续存在。

还有更多...

有其他方法可以独立于我们的会话启动我们的应用程序,并绑定到端口80而不以root身份运行应用程序。此外,我们还可以运行多个应用程序并使用http-proxy将它们代理到端口80

使用screen而不是nohup

实现与使用nohup独立于我们的 SSH 会话的另一种方法是使用screen。我们将使用它如下:

screen -S myAppName 

这将给我们一个虚拟终端,我们可以说:

cd login
node app.js 

然后我们可以通过按下Ctrl + A,然后按D来离开虚拟终端。我们将返回到我们最初的终端。虚拟终端将在我们注销 SSH 后继续运行。我们随时可以重新登录 SSH 并说:

screen -r myAppName 

在那里我们可以看到任何控制台输出并停止(Ctrl + C)和启动应用程序。

使用特权端口的 authbind

在这个例子中,我们应该以非 root 用户的身份 SSH 到我们的服务器:

ssh -l dave nodecookbook.com 

绑定到端口80的另一种方法是使用authbind,可以通过我们服务器的软件包管理器安装。例如,如果我们的软件包管理器是apt-get,我们可以说:

sudo apt-get install authbind 

authbind通过抢占端口绑定的操作系统策略并在执行时利用一个名为LD_PRELOAD的环境变量来工作。因此,它永远不需要以root权限运行。

为了使它为我们工作,我们必须进行一些简单的配置工作,如下所示:

sudo touch /etc/authbind/byport 80
sudo chown dave /etc/authbind/byport 80
sudo chmod 500 /etc/authbind/byport 80 

这告诉authbind允许用户dave将进程绑定到端口80

我们不再需要更改进程 UID,因此我们编辑app.js的倒数第二行为:

app.listen(80);

我们还应该按以下方式更改login文件夹的所有权:

chown -R dave login 

现在我们可以在完全不触及root访问的情况下启动我们的服务器:

nohup authbind node app.js & 

authbind可以使我们的应用立即运行,无需任何修改。但是,它目前缺乏 IPv6 支持,因此尚不具备未来的兼容性。

从端口 80 托管多个进程

如何使用默认 HTTP 端口提供多个进程?

我们可以使用第三方http-proxy模块来实现这一点。

npm install http-proxy 

假设我们有两个应用程序,一个(我们的login应用程序)托管在login.nodecookbook.com,另一个(本书第一个示例中的server.js文件)简单地托管在nodecookbook.com。这两个域指向同一个 IP。

server.js将监听端口8080,我们将修改login/app.js以再次监听端口3000,如下面的代码所示:

app.listen(3000, '127.0.0.1');

我们还添加了第二个参数,定义绑定到哪个地址(而不是任何地址)。这可以防止我们的服务器通过端口被访问。

让我们在一个新文件夹中创建一个文件,称之为proxy.js,并写入以下内容:

require('http-proxy')
  .createServer({router : {
    'login.nodecookbook.com': 'localhost:3000',
    'nodecookbook.com': 'localhost:8080'
  }}).listen(80, function () {
    process.setuid('www-data');
  });

createServer传递的对象包含一个路由器属性,该属性本身是一个对象,指示http-proxy根据其端口将特定域上的传入流量路由到正确的本地托管进程。

最后,我们绑定到端口80,并从root降级到www-data

要初始化,我们必须执行:

nohup node login/app.js &
nohup node server.js &
nohup node proxy.js & 

由于我们将代理服务器绑定到端口80,这些命令必须以root身份运行。如果我们正在使用非 root 帐户操作 SSH,我们只需在这三个命令前加上sudo

另请参阅

  • 本章讨论的自动崩溃恢复

  • 本章讨论的持续部署

  • 本章讨论的作为服务提供商的平台托管

自动崩溃恢复

当我们创建一个站点时,服务器和站点逻辑都绑定在一个进程中。而在其他平台上,服务器代码已经就位。如果我们的站点代码有错误,服务器很不可能崩溃,因此在许多情况下,即使其中一部分出现问题,站点也可以保持活动状态。

对于基于 Node 的网站,一个小错误可能会导致整个进程崩溃,而这个错误可能只会在很长时间内触发一次。

作为一个假设的例子,错误可能与 POST 请求的字符编码有关。当像 Felix Geisendörfer 这样的人完成并提交表单时,突然间我们整个服务器崩溃了,因为它无法处理变音符号。

在这个示例中,我们将使用 Upstart,这是一个可用于 Linux 服务器的事件驱动的 init 服务,它不是基于 Node,但仍然是一个非常方便的助手。

准备工作

我们需要在服务器上安装 Upstart。upstart.ubuntu.com包含有关如何下载和安装的说明。如果我们已经在使用 Ubuntu 或 Fedora 远程服务器,则 Upstart 将已经集成。

如何做...

让我们创建一个新的服务器,当我们通过 HTTP 访问它时故意崩溃:

var http = require('http');
http.createServer(function (req, res) {
  res.end("Oh oh! Looks like I'm going to crash...");
  throw crashAhoy;
}).listen(8080);

在第一页加载后,服务器将崩溃,站点将下线。

让我们将这段代码称为server.js,将其放在远程服务器上的/var/www/crashingserver

现在我们创建我们的 Upstart 配置文件,将其保存在服务器上的/etc/init/crashingserver.conf

start on started network-services

respawn
respawn limit 100 5

setuid www-data

exec /usr/bin/node /var/www/crashingserver/server.js >> \ /var/log/crashingserver.log 2>&1 

post-start exec echo "Server was (re)started on $(date)" | mail -s "Crashing Server (re)starting" dave@nodecookbook.com

最后,我们初始化我们的服务器如下:

start crashingserver 

当我们访问http://nodecookbook.com:8080并刷新页面时,我们的网站仍然可以访问。快速查看/var/log/crashingserver.log,我们可以发现服务器确实崩溃了。我们还可以检查我们的收件箱以查找服务器重新启动通知。

它是如何工作的...

Upstart 服务的名称取自特定的 Upstart 配置文件名。我们使用start crashingserver来启动/etc/init/crashingserver.conf Upstart 服务。

配置的第一行确保我们的 Web 服务器在远程服务器的操作系统重新启动时自动恢复(例如,由于停电或需要重新启动等)。

respawn被声明两次,一次用于打开重生,然后设置一个“重生限制 - 每 5 秒最多 100 次重启”。限制必须根据我们自己的情况进行设置。如果网站流量较低,这个数字可能会调整为在 8 秒内重启 10 次。

我们希望尽可能保持在线,但如果问题持续存在,我们可以将其视为一个警示,表明错误对用户体验或系统资源产生了不利影响。

下一行将我们的服务器初始化为www-data用户,并将输出发送到/var/log/crashingserver.log

最后一行在我们的服务器启动或重新启动后立即发送电子邮件。这样我们就可以得知可能需要解决服务器问题。

还有更多...

让我们实现另一个 Upstart 脚本,如果服务器崩溃超出其“重生限制”,我们还将看另一种方法来保持我们的服务器在线。

检测重生限制违规

如果我们的服务器超出了“重生限制”,很可能存在严重问题,应尽快解决。我们需要立即了解情况。为了在 Upstart 中实现这一点,我们可以创建另一个 Upstart 配置文件,监视crashingserver守护程序,如果“重生限制”被违反,则发送电子邮件。

task

start on stopped crashingserver PROCESS=respawn

script
  if [ "$JOB" != ''  ]
    then echo "Server "$JOB" has crashed on $(date)" | mail -s \
    $JOB" site down!!" dave@nodecookbook.com
  fi
end script

让我们把它保存到/etc/init/sitedownmon.conf

然后我们做:

start crashingserver
start sitedownmon 

我们将这个 Upstart 进程定义为一个任务(它只有一件事要做,之后就退出了)。我们不希望在我们的服务器崩溃后它继续存在。

crashingserver守护程序在重生期间停止时执行该任务(例如,当“重生限制”被打破时)。

我们的脚本段(指令)包含一个小的 bash 脚本,用于检查JOB环境变量的存在(在我们的情况下,它将设置为crashingserver),然后相应地发送电子邮件。如果我们不检查它的存在,当它首次启动并发送一个带有空JOB变量的电子邮件时,sitedownmon似乎会触发错误的警报。

稍后我们可以通过在每个服务器的sitedownmon.conf中添加一行来扩展此脚本:

start on stopped anotherserver PROCESS=respawn

使用 forever 保持在线

有一个更简单的基于 Node 的替代方案叫做forever:

npm -g install forever 

如果我们只是用以下方式启动我们的服务器:

forever server.js 

然后访问我们的网站,一些终端输出将包含以下内容:

warn: Forever detected script exited with code: 1
warn: Forever restarting script for 1 time

但我们仍然可以访问我们的网站(尽管它已经崩溃并重新启动)。

要在远程服务器上部署我们的网站,我们通过 SSH 登录到服务器,安装forever并说:

forever start server.js 

虽然这种技术确实较少复杂,但也较不稳健。Upstart 提供了核心内核功能,因此是系统关键性的。如果 Upstart 失败,内核就会发生恐慌,整个服务器就会重新启动。

然而,在 Nodejitsu 的 PaaS 堆栈上广泛使用forever,其吸引人的简单性可能适用于不太关键的生产环境。

另请参阅

  • 本章讨论了部署到服务器环境

  • 本章讨论了使用平台即服务提供商进行托管

  • 本章讨论了持续部署

持续部署

我们的流程越简化,我们就能更加高效。持续部署是指将小的持续改进提交到生产服务器,以节省时间、高效地进行。

持续部署对团队协作项目尤为重要。与其在代码的不同分支上工作并花费额外的时间、金钱和精力进行集成,不如让每个人都在同一代码库上工作,这样集成就会更加顺畅。

在这个示例中,我们将使用 Git 作为版本控制工具创建部署流程。虽然这可能不是 Node,但它肯定可以提高编码、部署和管理 Node 项目的生产力。

注意

如果我们对 Git 有点陌生,我们可以从 Github 的帮助文档中获得见解,help.github.com

准备就绪

我们需要在服务器和桌面系统上都安装 Git,不同系统的说明可以在这里找到book.git-scm.com/2_installing_git.html。如果我们使用带有apt-get软件包管理器的 Linux,我们可以执行:

sudo apt-get install git git-core 

如果我们是第一次安装 Git,我们将不得不按照以下方式设置个人信息配置设置:

git config --global user.name "Dave Clements"
git config --global user.email "dave@nodecookbook.com" 

我们将使用我们的login应用程序,在第一个教程中我们将其部署到服务器上。因此,让我们 SSH 到服务器并进入/var/www/login目录。

ssh -l root nodecookbook.com -t "cd /var/www/login; bash" 

由于我们将不会以 root 身份运行我们的应用程序,因此我们将简化事情并将login/app.js中的监听端口更改为8000

app.listen(8000);

如何做...

一旦我们登录到服务器并在login文件夹中安装了 Git(请参阅准备工作),我们说:

git init
git add *
git commit -m "initial commit" 

接下来,我们创建一个裸存储库(它记录了所有更改,但没有实际的工作文件),我们将向其推送更改。这有助于保持一致。

我们将称这个裸存储库为repo,因为这是我们将推送更改的存储库,并且我们将在login文件夹中创建它:

mkdir repo
echo repo > .gitignore
cd repo
git --bare init 

接下来,我们将裸repo连接到login应用程序存储库,并将所有提交推送到repo

cd ..
git remote add repo ./repo
git push repo master 

现在我们将编写一个 Git 挂钩,指示login存储库从裸repo存储库中拉取任何更改,然后在通过远程 Git 推送更新repo时重新启动我们的login应用程序。

cd repo/hooks
touch post-update
chmod +x post-update
nano post-update 

nano中打开文件后,我们编写以下代码:

#!/bin/sh

cd /root/login
env -i git pull repo master

exec forever restart /root/login/app.js

使用Ctrl + O保存我们的挂钩,然后使用Ctrl + X退出。

如果我们对login存储库进行 Git 提交,这两个存储库可能会不同步。为了解决这个问题,我们为login存储库创建另一个挂钩:

#!/bin/sh
git push repo

我们将其存储在login/.git/hooks/post-commit中,并确保使用chmod +x post-commit使其可执行。

我们将通过 SSH 协议远程向repo进行提交。理想情况下,我们希望为 Git 交互创建一个系统用户。

useradd git
passwd git   #set a password

mkdir /home/git
chown git /home/git 

我们还为git用户创建了主目录,以便forever可以轻松存储日志和 PID 文件。我们需要将git设置为login应用程序的所有者,以便我们可以通过 SSH 使用 Git 来管理它:

cd /var/www
chown -R git login 

最后(对于服务器端设置),我们以git用户身份登录并使用forever启动我们的应用程序。

su git
forever start /var/www/login/app.js 

假设我们的服务器托管在nodecookbook.com,我们现在可以在http://nodecookbook.com:8000访问login应用程序。

回到桌面,我们克隆repo存储库:

git clone ssh://git@nodecookbook.com/var/www/login/repo 

这将给我们一个repo目录,其中包含与我们原始的login文件夹完全匹配的所有生成的文件。然后我们可以进入repo文件夹并更改我们的代码(比如,在app.js中更改端口)。

app.listen(9000);

然后我们提交更改并推送到我们的服务器。

git commit -a -m "changed port"
git push 

在服务器端,我们的应用程序应该已自动重新启动,因此我们的应用程序现在是从http://nodecookbook.com:9000而不是http://nodecookbook.com:8000托管的。

它是如何工作的...

我们创建了两个 Git 存储库。第一个是login应用程序本身。当我们运行git init时,.git目录将添加到login文件夹中。git add *添加文件夹中的所有文件,commit -m "initial commit"将我们的添加放入 Git 的版本控制系统中。因此,现在我们的整个代码库都被 Git 识别。

第二个是repo,它是使用--bare标志创建的。这是一种骨架存储库,提供了所有预期的 Git 功能,但缺少实际文件(它没有工作树)。

虽然使用两个存储库可能看起来过于复杂,但实际上大大简化了事情。由于 Git 不允许将推送到当前签出的分支,因此我们必须创建一个单独的虚拟分支,以便我们可以从主分支签出并进入虚拟分支。这会导致 Git 挂钩和重新启动我们的应用程序出现问题。挂钩尝试启动错误的分支的应用程序。分支也很快会不同步,而挂钩只会火上浇油。

由于repo位于login目录中,我们创建一个.gitignore文件,告诉 Git 忽略这个子目录。尽管loginrepo在同一台服务器上,我们将repo添加为remote存储库。这在存储库之间增加了一些必要的距离,并允许我们稍后使用我们的第一个 Git 钩子使loginrepo拉取更改。从repologin的 Git 推送不会导致login更新其工作目录,而从repologin的拉取确实会启动合并。

在我们的remote add之后,我们从主分支(login)向repo执行初始推送,现在它们在同一张乐谱上演奏。

然后我们创建了我们的钩子。

Git 钩子是可执行文件,驻留在存储库的hook文件夹中。有各种可用的钩子(已经在文件夹中,但后缀为.sample)。我们使用了两个:post-updatepost-commit。一个在更新后执行(例如,一旦更改已被拉取并集成到repo中),另一个在提交后执行。

第一个钩子login/repo/hooks/post-update基本上提供了我们的持续部署功能。它使用cd将其工作目录从repo更改为login,并命令git pullgit pull命令前缀为env -i。这可以防止某些 Git 功能出现问题,否则会执行 Git 命令代表repo,无论我们将我们的钩子脚本发送到什么目录。Git 利用$GIT_DIR环境变量将我们锁定到调用钩子的存储库。env -i通过告诉git pull忽略(-i)所有环境变量来处理这个问题。

更新工作目录后,我们的钩子继续调用forever restart,从而使我们的应用程序重新初始化并应用提交的更改。

我们的第二个钩子只是一个填充物,以确保在直接提交到login存储库时代码库的一致性。直接向login目录提交不会更新工作树,也不会导致我们的应用程序重新启动,但loginrepo之间的代码至少会保持同步。

为了限制损害(如果我们曾经受到攻击),我们为处理 SSH 上的 Git 更新创建了一个特定的账户,为其提供一个主目录,接管login应用程序并执行我们应用程序的主要初始化。

一旦服务器配置完成,一切都很顺利。在将repo存储库克隆到我们的本地开发环境后,我们只需进行更改,添加和提交,然后推送到服务器。

服务器接收我们的推送请求,更新repo,启动post-update钩子,使loginrepo拉取更改,之后post-update钩子使用forever重新启动app.js,因此我们有了一个持续部署工作流程。

我们可以从任意位置克隆任意数量的克隆,因此这种方法非常适合于地理位置独立的团队协作项目,无论规模大小。

还有更多...

我们可以通过在 post-update 钩子中使用npm install来避免上传模块。此外,Git 钩子不一定要用 shell 脚本编写,我们可以用 Node 来编写它们!

构建模块依赖关系的更新

一些 Node 模块是纯 JavaScript 编写的,另一些具有 C 或 C++绑定。具有 C 或 C++绑定的模块必须从源代码构建-这是一个特定于系统的任务。除非我们的实时服务器环境与我们的开发环境完全相同,否则我们不应该简单地将为一个系统构建的代码推送到另一个系统上。

另外,为了节省传输带宽并实现更快的同步,我们可以让我们的 Git 钩子安装所有模块(本地绑定和 JavaScript),并让 Git 完全忽略node_modules文件夹。

因此,在我们的本地存储库中,让我们做以下事情:

echo node_modules >> .gitignore 

然后我们将我们裸远程存储库(login/repo/hooks)中的post-update钩子更改为:

#!/bin/sh

cd /root/login

env -i git pull repo master && npm rebuild && npm install

exec forever restart /root/login/app.js

我们已经在git pull行中添加了&& npm rebuild && npm install(使用&&确保它们受益于env -i命令)。

现在,如果我们向package.json添加了一个模块,并执行了git commit -a,然后执行git push,我们的本地repopackage.json推送到远程repo。这将触发post-update挂钩将更改拉入主login存储库,并随后执行npm rebuild(重新构建任何 C / C++依赖项)和npm install(安装任何新模块)。

编写一个用于集成测试的 Node Git 挂钩

持续部署是持续集成的延伸,通常期望对任何代码更改运行彻底的测试套件以进行质量保证。

我们的login应用(作为一个基本的演示站点)没有测试套件(有关测试套件的信息,请参见第九章中,编写自己的 Node 模块),但我们仍然可以编写一个挂钩,以便在将来为login添加任何测试时执行。

此外,我们可以用 Node 编写它,这样做的额外好处是可以跨平台运行(例如在 Windows 上)。

#!/usr/bin/env node

var npm = require("npm");

npm.load(function (err) {
    if (err) { throw err; }

    npm.commands.test(function (err) {
        if (err) { process.exit(1); }       
    });

});

我们将把这段代码放在服务器上的login/repo/hooks/pre-commit中,并使其可执行(chmod +x pre-commit)。

第一行将node设置为脚本解释器指令(就像#!/bin/sh为 shell 脚本设置sh shell 一样)。现在我们进入了 Node 的领域。

我们使用npm的可编程性,加载我们应用的package.json文件,然后运行测试脚本(如果有指定的话)。

然后,我们将以下内容添加到我们的package.json文件中:

{
    "name": "application-name"
  , "version": "0.0.1"
  , "private": true
  , "dependencies": {
      "express": "2.5.5"
    , "jade": ">= 0.0.1"
  },
   "scripts": {
    "test": "node test"
  },
  "devDependencies": {"npm": "1.1.18"}
}

然后执行以下操作:

npm -d install 

现在,每当我们推送到repo时,只有通过测试的更改才会被提交。只要我们有一个良好编写的测试套件,这是保持良好代码的好方法。

提示

对于我们的scripts.test属性,我们使用了node test(就像在第九章中,编写自己的 Node 模块中一样)。然而,我们还可以使用更高级的测试框架,比如 Mocha visionmedia.github.com/mocha/

注意

这个 Node Git 挂钩是根据 Domenic Denicola 的一个 gist(经过许可)进行调整的,可以在gist.github.com/2238951找到。

另请参阅

  • 本章讨论的部署到服务器环境

  • 本章讨论的自动崩溃恢复

  • 创建一个测试驱动的模块 API,在第九章中讨论,编写自己的 Node 模块

  • 本章讨论的使用平台即服务提供商进行托管

使用平台即服务提供商进行托管

Node 的平台即服务提供商(PaaS)包含了前三章讨论的所有概念,并将部署简化为一组非常基本但强大的命令。在部署方面,PaaS 可以让我们的生活变得非常简单。只需一个简单的命令,我们的应用就可以部署,另一个命令可以无缝更新和重新初始化。

在这个例子中,我们将学习如何部署到 Nodejitsu,这是领先的 Node 托管平台提供商之一。

做好准备

首先,我们将安装jitsu,Nodejitsu 的部署和应用管理命令行应用程序。

sudo npm -g install jitsu 

在继续之前,我们必须按照以下步骤注册一个帐户:

jitsu signup 

该应用程序将引导我们完成简单的注册过程,并为我们创建一个帐户,我们必须通过电子邮件确认。

提示

Nodejitsu 并不是唯一的 Node PaaS,还有其他类似的平台,如 no.de、Nodester 和 Cloud Foundry,它们遵循类似的流程。

一旦我们收到了我们的电子邮件,我们就可以使用提供的凭证,例如:

jitsu users confirm daveclements _sCjXz46in-6IBpl 

与第一个示例一样,我们将使用第六章中的初始化和使用会话配方中的login应用程序,使用login应用程序。

如何做...

首先,我们进入login文件夹并对package.json进行一些修改:

{
  "name": "ncb-login",
  "version": "0.0.1",
  "private": true,
  "dependencies": {
    "express": "2.5.5",
    "jade": ">= 0.0.1"
  },
  "subdomain": "login",
  "scripts": {
    "start": "app.js"
  },
  "engines": {
    "node": "0.6.x"
  }
}

现在我们部署!

Jitsu deploy

如果我们在http://login.nodejitsu.com或者http://login.jit.su导航到我们指定的子域,我们将看到我们的login应用程序(如果子域不可用,jitsu将建议替代方案)。

它是如何工作的...

我们对package.json进行了一些修改。我们的应用程序名称是唯一必须直接编辑package.json进行的更改。其他添加可能已经由jitsu可执行文件代表我们完成。设置应用程序的名称很重要,因为在jitsu中,应用程序是通过其名称进行管理的。

如果我们没有将subdomain, scriptsengines属性附加到package.json中,当我们运行jitsu deploy并由jitsu代表我们重新生成package.json时,jitsu将要求我们提供详细信息。

subdomain指定了nodejistu.com的标签前缀,我们从中托管我们的应用程序(例如,login.nodejitsu.com)。scripts,带有start子属性,通知 Nodejitsu 我们的启动脚本,启动应用程序的文件。engines定义了我们的应用程序设计的 Node 的哪些版本。

还有更多...

让我们看看如何通过自定义域名访问我们的 Nodejitsu 应用,并通过jitsu可执行文件为其提供数据库后端。

为 Nodejitsu 应用分配自定义域名

为了为我们的应用程序准备通过自定义域名提供服务,我们对package.json进行了修改,如下所示:

//prior package.json data
 "subdomain": "login",
  "domains": "login.nodecookbook.com",
  "scripts": {
    "start": "app.js"
  },
//rest of package.json data

然后我们使用jitsu推送我们的更改,如下所示:

jitsu apps update ncb-login 

现在应用程序已准备好通过login.nodecookbook.com接收流量,但在流量到达之前,我们必须将我们的域的 A 记录与 Nodejitsu 的 A 记录匹配。

我们可以使用dig(或类似的命令行应用程序)获取当前的 Nodejitsu A 记录列表:

dig nodejitsu.com 

更改 A 记录的过程取决于我们的域名提供商。通常可以在提供商的控制面板/管理区域的 DNS 区域找到它。

使用 jitsu 为数据库提供服务

在第六章的最后一个配方中,使用 Express 加速开发,我们构建了一个使用 MongoDB 支持的 Express 应用程序。现在我们将使用 Nodejitsu 将profiler应用程序上线,并利用jitsu的数据库提供功能。

所以让我们为profiler数据库提供一个 Mongo 数据库,如下所示:

jitsu databases create mongo profiler 

jitsu将通过第三方数据库 PaaS 提供商(在 Mongo 的情况下,PaaS 提供商是 MongoHQ)为我们提供数据库。输出的倒数第二行为我们提供了新数据库的 MongoDB URI,看起来像以下代码:

info: Connection url: mongodb://nodejitsu:14dce01bda24e5fe53bbdaa8f2f6547b@flame.mongohq.com:10019/nodejitsudb169742247544

因此,我们将profiler/tools/prepopulate.js的第二行更新为:

client = mongo.db('mongodb://nodejitsu:14dce01bda24e5fe53bbdaa8f2f6547b@flame.mongohq.com:10019/nodejitsudb169742247544'),

然后我们从profiler/tools文件夹运行它:

node prepulate.js 

这将填充我们的远程数据库与配置文件和登录数据。

我们在另外两个地方profiler/profiles.jsprofiler/login/login.js中更新了我们的数据库 URI,在这两个地方,第二行被修改为:

db = mongo.db('mongodb://nodejitsu:14dce01bda24e5fe53bbdaa8f2f6547b@flame.mongohq.com:10019/nodejitsudb169742247544'),

最后,我们输入以下内容:

jitsu deploy 

jitsu将要求我们设置某些设置(子域,scripts.startengines),我们可以只需按下Enter并使用默认设置(除非profiler.nodejitsu.com已被占用,这种情况下我们应该选择不同的 URL)。

然后jitsu将部署我们的应用程序,我们应该能够在profiler.nodejitsu.com上访问它。

另请参阅

  • 在本章讨论的部署到服务器环境

  • 在本章讨论的自动崩溃恢复

  • 在本章讨论的持续部署

posted @ 2024-05-23 15:59  绝不原创的飞龙  阅读(6)  评论(0编辑  收藏  举报