Express-高级-Web-应用开发-全-
Express 高级 Web 应用开发(全)
原文:
zh.annas-archive.org/md5/f9ce31154b8066c991717098b933284f译者:飞龙
前言
构建一个可靠、健壮、可维护、可测试且可扩展到单个服务器之外的 Express 应用需要一些额外的思考和努力。需要在生产环境中存活的 Express 应用将需要向Node生态系统以及更广泛的支持寻求帮助。高级 Express Web 应用开发旨在提供一个实际可用的、满足这些目标且允许我们探索 Express 更高级特性的单页应用。
这本书涵盖的内容
第一章,基础,在我们放置骨架应用时奠定基础;我们介绍了我们将用于构建示例单页应用的测试和自动化实践。
第二章,构建 Web API,帮助我们构建我们的应用将消费的 Web API。
第三章,模板化,帮助你创建一个具有工作状态的网络 API 的消费者客户端,并探索客户端和服务器端的模板化。
第四章,实时通信,帮助我们向单页应用中显示的内容添加实时更新。
第五章,安全,指导我们在考虑身份验证、安全漏洞和 SSL 时如何确保我们的应用安全。
第六章,扩展,展示了如何使用 Redis 扩展我们的 Express 应用,并探讨了解耦 Express 应用的好处。
第七章,生产,探讨了实际的 Express 部署问题,如性能、健壮性和可靠性。
你需要这本书的内容
为了创建和运行本书中的示例,你需要一台运行 Windows 或 Linux 的 Mac 或 PC;你可以使用任何文本编辑器。本书将提供安装 Node.js、Express 以及包括 Redis 和 MongoDB 在内的各种依赖项的说明。
这本书面向谁
如果你是一位经验丰富的 JavaScript 开发者,希望使用 Express 构建高度可扩展的、现实世界的应用,这本书非常适合你。这是一本高级书籍,假设读者对 Node.js、JavaScript MVC 网络开发框架有一定经验,并且至少听说过 Express。读者还应具备 Redis 和 MongoDB 的基本理解。这本书不是关于 node 的教程,而是旨在探索你在开发、部署和维护 Express 网络应用时可能会遇到的一些更高级的主题。
惯例
在这本书中,你会找到许多不同风格的文本,以区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“让我们将我们的路由心跳提取到./lib/routes/heartbeat.js 中;以下列表简单地将路由导出为一个名为 index 的函数:”
代码块设置如下:
exports.index = function(req, res){
res.json(200, 'OK');
};
任何命令行输入或输出都写作如下:
npm install -g express
NODE_ENV=COVERAGE mocha -R html-cov > coverage.html
新术语和重要单词以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“为了获取 GitHub 令牌,登录您的 GitHub 账户并转到设置页面的账户部分,您需要输入您的密码。现在点击创建新令牌,如果您愿意,可以命名令牌。点击复制到剪贴板按钮,以便将令牌复制到以下登录中。”
注意
警告或重要注意事项以如下框中的形式出现。
小贴士
技巧和窍门看起来像这样。
读者反馈
我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正能从中获得最大价值的标题非常重要。
要向我们发送一般反馈,只需发送一封电子邮件到<feedback@packtpub.com>,并在邮件主题中提及书名。
如果您在某个主题领域有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大价值。
下载示例代码
您可以从www.packtpub.com的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
错误清单
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。您可以通过选择您的标题从 www.packtpub.com/support 查看任何现有勘误。
盗版
互联网上版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何我们作品的非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者和提供有价值内容方面的帮助。
问题
如果你在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。
第一章. 基础
高级 Express Web 应用程序开发 将指导您通过使用 Express 构建一个非平凡的单页应用程序的过程。
Express 是由 TJ. Holowaychuk 编写的快速、无偏见、极简和灵活的 Web 应用程序框架,适用于 Node.js。它受到了 Ruby 的 Web 框架 Sinatra 的启发。Express 提供了一套强大的功能,用于构建单页、多页和混合 Web 应用程序,并迅速成为 node 最受欢迎的 Web 开发框架。Express 是建立在可扩展的 HTTP 服务器框架之上,该框架也由 TJ. Holowaychuk 开发,称为 Connect。Connect 提供了一组高性能插件,称为中间件。Connect 包含超过 20 个常用中间件,包括日志记录器、会话支持、cookie 解析器等。
本书将指导您构建一个名为 Vision 的单页应用程序的过程;这是一个软件开发项目的仪表板,与 GitHub 集成,为您提供软件项目问题和提交的单屏快照。此项目将使我们能够展示 Express 提供的高级功能,并为我们提供探索在商业开发和 node/Express 应用程序的生产部署中遇到的问题的机会。
功能集
我们现在将开始构建 Vision 应用程序的过程。我们将从零开始,采用先测试后开发的方法。在这个过程中,我们将探讨一些最佳实践,并提供在用 node 和 Express 开发 Web 应用程序时的技巧。
Vision 应用程序将包括以下功能:
Feature: Heartbeat
As an administrator
I want to visit an endpoint
So that I can confirm the server is responding
Feature: List projects
As a vision user
I want to see a list of projects
So that I can select a project I want to monitor
Feature: Create project
As a vision user
I want to create a new project
So that I can monitor the activity of multiple repositories
Feature: Get a project
As a vision user
I want to get a project
So that I can monitor the activity of selected repositories
Feature: Edit a project
As a vision user
I want to update a project
So that I can change the repositories I monitor
Feature: Delete a project
As a vision user
I want to delete a project
So that I can remove projects no longer in use
Feature: List repositories
As a vision user
I want to see a list of all repositories for a GitHub account
So that I can select and monitor repositories for my project
Feature: List issues
As a vision user
I want to see a list of multiple repository issues in real time
So that I can review and fix issues
Feature: List commits
As a vision user
I want to see a list of multiple repository commits in real time
So that I can review those commits
Feature: Master Page
As a vision user
I want the vision application served as a single page
So that I can spend less time waiting for page loads
Feature: Authentication
As a vision user
I want to be able to authenticate via Github
So that I can view project activity
以下截图是我们的 Vision 应用程序;它包含项目列表、仓库、提交和问题。右上角有一个登录链接,我们将用它进行身份验证:

安装
如果您尚未安装 node,请访问:nodejs.org/download/。
如果您不希望或无法使用安装程序,可以在 node GitHub 仓库的 wiki 上找到安装指南:github.com/joyent/node/wiki/Installation。
让我们全局安装 Express:
npm install -g express
小贴士
您可以在此处下载本书的源代码:github.com/AndrewKeig/advanced-express-application-development。
如果您已下载源代码,可以通过运行以下命令安装其依赖项:
npm install
package.json
让我们先创建一个名为 vision 的根项目文件夹,并向其中添加一个 package.json 文件:./package.json:
{
"name": "chapter-1",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "node app.js"
}
"dependencies": {
"express": "3.x"
}
}
小贴士
下载示例代码
你可以从你购买的所有 Packt 书籍的账户中下载所有示例代码文件,账户地址为 www.packtpub.com。如果你在其他地方购买了这本书,你可以访问 www.packtpub.com/support 并注册以将文件直接通过电子邮件发送给你。
使用 Mocha 和 SuperTest 测试 Express
现在我们已经安装了 Express 并设置了 package.json 文件,我们可以开始使用测试优先的方法来驱动我们的应用程序。我们现在将安装两个模块来帮助我们:mocha 和 supertest。
Mocha 是一个针对 node 的测试框架;它灵活,具有良好的异步支持,并允许你以 TDD 和 BDD 风格运行测试。它也可以在客户端和服务器端使用。让我们使用以下命令安装 Mocha:
npm install -g mocha –-save-dev
SuperTest 是一个集成测试框架,它将使我们能够轻松地编写针对 RESTful HTTP 服务器的测试。让我们安装 SuperTest:
npm install supertest –-save-dev
功能:心跳
As an administrator
I want to visit an endpoint
So that I can confirm the server is responding
让我们在 ./test/heartbeat.js 中为我们的 Heartbeat 功能添加一个测试。这个资源将从 /heartbeat 路由获取状态并返回 200 Ok 状态码。让我们使用 Mocha 和 SuperTest 编写我们的第一个集成测试。首先,在你的 vision 文件夹内创建一个名为 /test 的文件夹。
我们的测试描述了 heartbeat;它期望响应具有 JSON 内容类型和状态码等于 200 Ok。
var app = require('../app')
, request = require('supertest');
describe('vision heartbeat api', function(){
describe('when requesting resource /heartbeat', function(){
it('should respond with 200', function(done){
request(app)
.get('/heartbeat')
.expect('Content-Type', /json/)
.expect(200, done);
});
});
});
让我们实现 Heartbeat 功能;我们首先创建一个简单的 Express 服务器,./lib/express/index.js。我们包含 express 和 http 模块并创建一个 Express 应用程序。然后我们通过 app.set 添加一个名为 port 的应用程序设置并将其设置为 3000。我们通过 app.get 定义一个 /heartbeat 路由,并传递一个请求处理器 function,该处理器接受两个参数:req(请求)和 res(响应)。我们使用响应对象返回一个 JSON 响应。我们通过将我们的 Express 应用程序传递给 http.createServer 创建一个 HTTP 服务器;我们在名为 port 的应用程序设置中监听端口 3000。然后我们通过 module.exports 导出应用程序;导出应用程序允许我们对其进行测试。
var express = require('express')
, http = require('http')
, app = express();
app.set('port', 3000);
app.get('/heartbeat', function(req, res){
res.json(200, 'OK')
});
http.createServer(app).listen(app.get('port'));
module.exports = app;
我们现在在项目的根目录下创建 ./app.js 并导出 express 模块:
module.exports = require('./lib/express');
要运行我们的测试,执行以下命令:
mocha
你应该收到以下响应:
1 tests complete (14 ms)
如果成功,尝试通过执行以下命令来运行应用程序:
npm start
应用程序运行后,在新的终端中运行以下 curl 命令,你可以看到我们的 heartbeat JSON 响应返回 200 Ok 状态码:
curl -i http://127.0.0.1:3000/heartbeat
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 4
Date: Fri, 14 Jun 2013 08:28:50 GMT
Connection: keep-alive
使用 Mocha 进行持续测试
与动态语言一起工作的一个优点之一,也是吸引我使用 node 的原因之一,是能够轻松地进行 测试驱动开发 和持续测试。只需运行带有 -w 监视开关的 Mocha,当我们的代码库发生变化时,Mocha 将做出响应,并自动重新运行测试:
mocha -w
使用 Mocha 和 JSCoverage 进行代码覆盖率测试
Mocha 能够在 JSCoverage 的帮助下生成代码覆盖率报告。从 siliconforks.com/jscoverage/ 为您的环境安装 JSCoverage。JSCoverage 将解析源代码并生成一个经过仪器化的版本;这使得 mocha 能够执行此生成的代码并创建报告。我们需要更新 ./app.js。
module.exports = (process.env['NODE_ENV'] === "COVERAGE")
? require('./lib-cov/express')
: require('./lib/express');
JSCoverage 将输入目录和输出目录作为参数:
jscoverage lib lib-cov
根据您的 JSCoverage 版本,您可能需要添加 –no-highlight 开关:
jscoverage lib lib-cov --no-highlight
以下命令将生成覆盖率报告,如下截图所示:
NODE_ENV=COVERAGE mocha -R html-cov > coverage.html

使用 Nconf 配置 Express
Nconf 是一个配置工具,我们将使用它来为我们的应用程序创建分层/环境配置文件。让我们安装 Nconf:
npm install nconf --save
我们首先要做的是将以下硬编码的端口号从我们的 Express 应用程序移动到我们的配置中:
app.set('port', 3000);
让我们创建模块 ./lib/configuration/index.js,这将允许我们从 JSON 文件中读取配置数据。我们导入 nconf 模块并定义一个构造函数,Config。然后根据当前环境加载配置文件,并加载包含非环境配置数据的默认配置。我们还定义了一个函数 get(key),它接受一个键并返回一个值。我们将使用此函数来读取配置数据:
var nconf = require('nconf');
function Config(){
nconf.argv().env("_");
var environment = nconf.get("NODE:ENV") || "development";
nconf.file(environment, "config/" + environment + ".json");
nconf.file("default", "config/default.json");
}
Config.prototype.get = function(key) {
return nconf.get(key);
};
module.exports = new Config();
让我们为我们的应用程序编写一些配置。将以下默认配置添加到 ./config/default.json;这将在所有环境中共享:
{
"application": {
"name": "vision"
}
}
现在将以下配置添加到开发、测试和覆盖率配置文件中:./config/development.json、./config/test.json 和 ./config/coverage.json。
{
"express": {
"port": 3000
}
}
让我们更改我们的 Express 服务器 ./lib/express/index.js,使其从配置中读取 express:port:
var express = require('express')
, http = require('http')
, config = require('../configuration')
, app = express();
app.set('port', config.get("express:port"));
app.get('/hearbeat', function(req, res){
res.json(200, 'OK');
});
http.createServer(app).listen(app.get('port'));
module.exports = app;
提取路由
Express 支持多种应用程序结构选项。将 Express 应用程序中的元素提取到单独的文件中是一种选择;对于此选项,路由是一个很好的候选者。
让我们将路由心跳提取到 ./lib/routes/heartbeat.js;以下列表只是将路由作为一个名为 index 的函数导出:
exports.index = function(req, res){
res.json(200, 'OK');
};
让我们对 Express 服务器进行修改,并移除我们传递给 app.get 的匿名函数,并用以下列表中的函数调用替换它。我们导入路由 heartbeat 并传递一个回调函数,heartbeat.index:
var express = require('express')
, http = require('http')
, config = require('../configuration')
, heartbeat = require('../routes/heartbeat')
, app = express();
app.set('port', config.get('express:port'));
app.get('/heartbeat', heartbeat.index);
http.createServer(app).listen(app.get('port'));
module.exports = app;
404 处理中间件
为了处理 404 Not Found 响应,让我们添加一个 404 未找到中间件。让我们编写一个测试,./test/heartbeat.js;返回的内容类型应该是 JSON,期望的状态码应该是 404 Not Found:
describe('vision heartbeat api', function(){
describe('when requesting resource /missing', function(){
it('should respond with 404', function(done){
request(app)
.get('/missing')
.expect('Content-Type', /json/)
.expect(404, done);
})
});
});
现在,将以下中间件添加到./lib/middleware/notFound.js。在这里,我们导出一个名为index的函数并调用res.json,它返回 404 状态码和消息Not Found。下一个参数没有调用,因为我们的 404 中间件通过返回响应来结束请求;调用 next 将调用我们的 Express 堆栈中的下一个中间件;由于这个原因,我们没有更多的中间件,通常将错误中间件和 404 中间件作为服务器中的最后一个中间件添加:
exports.index = function(req, res, next){
res.json(404, 'Not Found.');
};
现在将 404 未找到中间件添加到./lib/express/index.js:
var express = require('express')
, http = require('http')
, config = require('../configuration')
, heartbeat = require('../routes/heartbeat')
, notFound = require('../middleware/notFound')
, app = express();
app.set('port', config.get('express:port'));
app.get('/heartbeat', heartbeat.index);
app.use(notFound.index);
http.createServer(app).listen(app.get('port'));
module.exports = app;
日志中间件
Express 通过 Connect 提供了一个日志中间件;这对于调试 Express 应用程序非常有用。让我们将其添加到我们的 Express 服务器./lib/express/index.js:
var express = require('express')
, http = require('http')
, config = require('../configuration')
, heartbeat = require('../routes/heartbeat')
, notFound = require('../middleware/notFound')
, app = express();
app.set('port', config.get('express:port'));
app.use(express.logger({ immediate: true, format: 'dev' }));
app.get('/heartbeat', heartbeat.index);
app.use(notFound.index);
http.createServer(app).listen(app.get('port'));
module.exports = app;
immediate选项将在请求上而不是在响应上写入日志行。dev选项提供按响应状态着色的简洁输出。日志中间件被放置在 Express 堆栈的较高位置,以便记录所有请求。
使用 Winston 进行日志记录
我们现在将使用Winston对我们的应用程序添加日志记录;让我们安装 Winston:
npm install winston --save
404 中间件需要记录 404 未找到,因此让我们创建一个简单的日志模块,./lib/logger/index.js;我们的日志器配置将与 Nconf 一起进行。我们导入 Winston 和配置模块。我们定义我们的Logger函数,它构建并返回一个文件日志器—winston.transports.File—我们使用config中的值进行配置。我们将日志器的最大大小默认设置为 1 MB,最多三个旋转文件。我们实例化Logger函数,将其作为单例返回。
var winston = require('winston')
, config = require('../configuration');
function Logger(){
return winston.add(winston.transports.File, {
filename: config.get('logger:filename'),
maxsize: 1048576,
maxFiles: 3,
level: config.get('logger:level')
});
}
module.exports = new Logger();
让我们将Logger配置细节添加到我们的配置文件./config/development.json和./config/test.json:
{
"express": {
"port": 3000
},
"logger" : {
"filename": "logs/run.log",
"level": "silly",
}
}
让我们修改./lib/middleware/notFound.js中间件以记录错误。我们导入我们的logger并通过logger记录错误信息,当抛出404 Not Found响应时:
var logger = require("../logger");
exports.index = function(req, res, next){
logger.error('Not Found');
res.json(404, 'Not Found');
};
使用 Grunt 进行任务自动化
Grunt 是一个任务运行器,是自动化 Node 项目的绝佳方式。让我们向我们的项目添加一个简单的 Grunt 脚本,以自动化运行测试和代码覆盖率。让我们安装 Grunt 和 Grunt CLI:
npm install -g grunt-cli
npm install grunt –-save-dev
grunt-cafe-mocha是一个用于运行 mocha 的 Grunt 模块;此模块还将允许我们自动化代码覆盖率报告:
npm install grunt-cafe-mocha –-save-dev
grunt-jscoverage简单地生成我们源代码的仪器版本并将其写入./lib-cov:
npm install grunt-jscoverage –-save-dev
grunt-env允许您设置当前的 Node 环境,NODE_ENV:
npm install grunt-env –-save-dev
让我们创建一个 Grunt 文件./gruntfile.js。我们加载我们刚刚安装的grunt模块,grunt.initConfig包含每个 Grunt 模块的配置:
module.exports = function(grunt) {
grunt.loadNpmTasks('grunt-jscoverage');
grunt.loadNpmTasks('grunt-cafe-mocha');
grunt.loadNpmTasks('grunt-env');
grunt.initConfig({
env: {
test: { NODE_ENV: 'TEST' },
coverage: { NODE_ENV: 'COVERAGE' }
},
cafemocha: {
test: {
src: 'test/*.js',
options: {
ui: 'bdd',
reporter: 'spec',
},
},
coverage: {
src: 'test/*.js',
options: {
ui: 'bdd',
reporter: 'html-cov',
coverage: {
output: 'coverage.html'
}
}
},
},
jscoverage: {
options: {
inputDirectory: 'lib',
outputDirectory: 'lib-cov',
highlight: false
}
}
});
grunt.registerTask('test', [ 'env:test', 'cafemocha:test' ]);
grunt.registerTask('coverage', [ 'env:coverage', 'jscoverage', 'cafemocha:coverage' ]);
};
cafemocha的配置包含两个部分;一个用于运行我们的测试,另一个用于生成代码覆盖率报告。要从 Grunt 运行我们的测试,请执行以下命令:
grunt test
以下行注册了一个任务,该任务使用env设置环境并按顺序运行jscoverage和cafemocha:coverage任务:
grunt.registerTask('coverage', [ 'env:coverage', 'jscoverage', 'cafemocha:coverage' ]);
为了从 grunt 运行我们的覆盖率测试,请执行以下命令:
grunt coverage
此命令将生成之前描述的覆盖率报告。
摘要
我们为我们的 Vision 项目建立了一个相当稳固的框架;我们实现了一个简单的功能,心跳,当访问时,它只会告诉我们我们的 Express 服务器是否正在运行。我们还自动化了各种开发任务,例如运行测试和创建代码覆盖率报告。我们还使用了 Winston 进行了一些日志记录。在下一章中,我们将实现一个 Web API。
第二章:构建 Web API
在打下基础后,我们开始为我们的 Vision 项目构建 Web API 的过程。我们将首先使用 MongoDB 设置持久层。然后,我们将逐个实现 Web API 的各个方面。
使用 MongoDB 和 Mongoose 持久化数据
MongoDB 是一个开源的面向文档的数据库系统。MongoDB 存储结构化数据,如类似 JSON 的文档,简化了集成。
让我们首先为我们的项目创建一个 MongoDB 模式。该模式包含一些与项目相关的基本信息,例如项目的名称、GitHub 访问令牌、用户和存储库列表。
让我们安装 Mongoose,它是 Node.js 的 MongoDB 对象文档映射器;它提供了一个基于模式的解决方案来建模您的数据。
npm install mongoose --save
让我们配置我们的应用程序以使用 MongoDB 和 Mongoose;我们在配置文件 ./lib/config/*.js 中添加 MongoDB 的 URL:
{
"express": {
"port": 3000
},
"logger" : {
"filename": "logs/run.log",
"level": "silly"
},
"mongo": {
"url": "mongodb://localhost/vision"
}
}
让我们创建一个 MongoDB 连接模块,./lib/db/index.js,它只是从我们的 Winston 配置中获取 MongoDB URL 并打开一个连接:
var mongoose = require('mongoose')
, config = require('../configuration')
, connectionString = config.get("mongo:url")
, options = { server: { auto_reconnect: true, poolSize: 10 } };
mongoose.connection.open(connectionString, options);
我们现在创建一个模型类 ./lib/models/index.js,它定义了我们的 ProjectSchema:
var mongoose = require('mongoose'),
Schema = mongoose.Schema;
var ProjectSchema = new Schema({
name : { type: String, required: true, index: true }
, token : { type: String }
, user : { type: String, required: true, index: true }
, created : { type: Date, default: Date.now }
, repositories : [ { type: String } ]
});
mongoose.model('Project', ProjectSchema);
module.exports = mongoose;
为了运行以下示例,我们需要一个运行的 MongoDB 实例。您可以从 www.mongodb.org 下载 MongoDB。运行以下命令以启动 MongoDB:
mongod
GitHub 令牌
为了获取 GitHub 令牌,请登录您的 GitHub 账户并前往您的 设置 页面的 账户 部分。在这里,您需要输入您的密码。现在点击 创建新令牌,如果您愿意的话,还可以为令牌命名。点击 复制到剪贴板 按钮以将令牌复制到下面的 login 文件中。
让我们创建一个名为 login 的文件——./test/login.js——并从 GitHub 获取数据。我们将使用这个文件来调用 GitHub API;在后续章节中,这个文件将被删除。
module.exports = {
user : '#USER#'
token : '#TOKEN#'
}
功能:创建项目
As a vision user
I want to create a new project
So that I can monitor the activity of multiple repositories
让我们在现有的测试集中添加一个针对我们的功能 创建项目 的测试。这个资源将向 /project 路由 POST 一个项目并返回 201 Created 状态码。以下测试:./test/project.js 是 201 Created 测试。
小贴士
本书不会记录一个功能的全部测试集。请参阅源代码以获取完整的测试集。
在这个例子中,SuperTest 执行一个返回响应的 end 函数;这允许我们检查响应的头和体。
describe('when creating a new resource /project', function(){
var project = {
name: "new project"
, user: login.user
, token: login.token
, repositories : [ "12345", "9898" ]
};
it('should respond with 201', function(done){
request(app)
.post('/project')
.send(project)
.expect('Content-Type', /json/)
.expect(201)
.end(function (err, res) {
var proj = JSON.parse(res.text);
assert.equal(proj.name, project.name);
assert.equal(proj.user, login.user);
assert.equal(proj.token, login.token);
assert.equal(proj.repositories[0], project.repositories[0]);
assert.equal(proj.repositories[1], project.repositories[1]);
assert.equal(res.header['location'],'/project/' + proj._id);
done();
});
});
});
为了使一些测试能够运行,我们需要一些测试数据。因此,以下 ./test/project.js 将使用 Mocha 的 beforeEach 钩子销毁任何现有的项目数据,并添加一个新的项目:
beforeEach(function(done){
mongoose.connection.collections['projects'].drop( function(err) {
var proj = {
name: "test name"
, user: login.user
, token: login.token
, repositories : [ "node-plates" ]
};
mongoose.connection.collections['projects'].insert(proj,function(err, docs) {
id = docs[0]._id;
done();
});
});
})
让我们安装 string.js,这是一个轻量级的 JavaScript 库,它提供了额外的字符串方法。这将帮助我们验证请求:
npm install string --save
让我们实现“创建项目”功能。我们首先创建一个Project模块./lib/project/index.js。我们导入一个 Mongoose 模式用于Project模型,并定义一个名为post的函数,该函数接受name和data作为参数。我们调用静态函数Project.findOne来检查项目是否存在,如果项目是唯一的,我们调用project.save函数来保存项目。
var ProjectSchema = require('../models').model('Project');
function Project() {};
Project.prototype.post = function(name, data, callback){
var query = {'name': name};
var project = new ProjectSchema(data);
ProjectSchema.findOne(query, function(error, proj) {
if (error) return callback(error, null);
if (proj != null) return callback(null, null);
project.save(function (error, p) {
if (error) return callback(error, null);
return callback(null, p);
});
});
};
让我们在./lib/routes/project.js中添加一个新的路由。我们导入一个logger变量、一个ProjectService模块,并定义一个名为Post的路由,该路由使用req.body来访问请求中 POST 的项目项。然后我们验证请求,如果请求无效则返回400 Bad Request。如果请求有效,我们将用户和令牌添加到请求体中并调用Project.post;如果发生错误,我们返回500 Internal Server Error,如果项目已存在,我们返回409 Conflict响应。如果请求正常,我们在响应上设置res.location以指向我们的新资源,并返回201 Created响应:
var logger = require("../logger")
, S = require('string')
, login = require('../../test/login')
, ProjectService = require('../project')
, Project = new ProjectService();
exports.post = function(req, res){
logger.info('Post.' + req.body.name);
if (S(req.body.name).isEmpty() )
return res.json(400, 'Bad Request');
req.body.user = login.user;
req.body.token = login.token;
Project.post(req.body.name, req.body, function(error, project) {
if (error) return res.json(500, 'Internal Server Error');
if (project == null) return res.json(409, 'Conflict');
res.location('/project/' + project._id);
return res.json(201, project);
});
};
为了添加我们的新路由并允许我们的应用程序支持 HTTP POST,我们需要对我们的 Express 服务器./lib/express/index.js进行一些修改。
首先,我们导入本章开头创建的db模块,该模块打开到 MongoDB 数据库的连接。然后,我们导入刚刚创建的project路由模块。重要的是,app.use(express.bodyParser())在表单提交时解析请求体。bodyParser中间件支持application/x-www-form-urlencoded、application/json和multipart/form-data。我们在/project路径下添加了一个新的路由用于发布项目。
var express = require('express')
, http = require('http')
, config = require('../configuration')
, db = require('../db')
, heartbeat = require('../routes/heartbeat')
, project = require('../routes/project')
, error = require('../routes/error')
, notFound = require('../middleware/notFound')
, app = express();
app.use(express.bodyParser());
app.set('port', config.get('express:port'));
app.use(express.logger({ immediate: true, format: 'dev' }));
app.get('/heartbeat', heartbeat.index);
app.post('/project', project.post);
app.use(notFound.index);
http.createServer(app).listen(app.get('port'));
module.exports = app;
功能:获取项目
As a vision user
I want to get a project
So that I can monitor the activity of selected repositories
让我们在现有的测试集./test/project.js中为我们的“获取项目”功能添加一个测试。这个资源将 GET 从路由/project/:id获取一个项目,并返回200 OK状态。
让我们安装underscore.js;这是一个提供函数式编程支持的实用工具库:
npm install underscore --save
describe('when requesting an available resource /project/:id', function(){
it('should respond with 200', function(done){
request(app)
.get('/project/' + id)
.expect('Content-Type', /json/)
.expect(200)
.end(function (err, res) {
var proj = JSON.parse(res.text);
assert.equal(proj._id, id);
assert(_.has(proj, '_id'));
assert(_.has(proj, 'name'));
assert(_.has(proj, 'user'));
assert(_.has(proj, 'token'));
assert(_.has(proj, 'created'));
assert(_.has(proj, 'repositories'));
done();
});
});
});
让我们实现“获取项目”功能./lib/project/index.js并添加一个get函数。我们尝试通过调用静态函数Project.findOne来检索项目。如果发生错误,我们返回它;如果找到项目,我们返回项目:
Project.prototype.get = function(id, callback){
var query = {"_id" : id};
ProjectSchema.findOne(query, function(error, project) {
if (error) return callback(error, null);
return callback(null, project);
});
};
让我们在./lib/routes/project.js中添加一个新的路由。我们首先定义一个名为get的路由。我们使用正则表达式验证请求,以检查有效的 Mongoose ObjectId;如果请求无效,则返回400 Bad Request状态。我们尝试通过调用Project.get并传递id来检索项目。如果发生错误,我们返回500 Internal Server Error;如果项目不存在,我们返回404 Not Found。如果我们找到项目,我们返回项目和一个200 OK响应:
exports.get = function(req, res){
logger.info('Request.' + req.url);
Project.get(req.params.id, function(error, project) {
if (error) return res.json(500, 'Internal Server Error');
if (project == null) return res.json(404, 'Not Found');
return res.json(200, project);
});
};
现在将以下路由添加到./lib/express/index.js中:
app.get('/project/:id', project.get);
功能:编辑项目
As a vision user
I want to update a project
So that I can change the repositories I monitor
让我们在现有的测试集 ./test/project.js 中为我们的 编辑一个项目 功能添加一个测试。此资源将向路由 /project/:id 发送一个项目,并返回 204 No Content 状态:
describe('when updating an existing resource /project/:id', function(){
var project = {
name: "new test name"
, user: login.user
, token: login.token
, repositories : [ "12345", "9898" ]
};
it('should respond with 204', function(done){
request(app)
.put('/project/' + id)
.send(project)
.expect(204, done);
});
});
让我们实现 编辑一个项目 功能 ./lib/project/index.js 并添加一个 put 函数。我们尝试通过调用静态函数 Project.findOne 来检索项目。如果发生错误,我们返回错误;如果我们找不到项目,我们返回 null。如果我们找到项目,我们更新它并返回项目:
Project.prototype.put = function(id, update, callback){
var query = {"_id": id};
delete update._id;
ProjectSchema.findOne(query, function(error, project) {
if (error) return callback(error, null);
if (project == null) return callback(null, null);
ProjectSchema.update(query, update, function(error, project) {
if (error) return callback(error, null);
return callback(null, {});
});
});
};
让我们在 ./lib/routes/project.js 中添加一个新的路由。我们首先定义一个名为 put 的路由,然后通过返回 400 Bad Request 来验证请求是否有效。我们在请求体中添加一个登录用户和令牌;这将在后面的章节中删除。我们尝试通过调用 Project.put 并传递 id 来更新项目。如果发生错误,我们返回 500 Internal Server Error;如果项目不存在,我们返回 404 Not Found 状态。如果我们找到项目,则返回 204 No Content 响应:
exports.put = function(req, res){
logger.info('Put.' + req.params.id);
if (S(req.body.name).isEmpty() )
return res.json(400, 'Bad Request');
req.body.user = login.user;
req.body.token = login.token;
Project.put(req.params.id, req.body, function(error, project) {
if (error) return res.json(500, 'Internal Server Error');
if (project == null) return res.json(404, 'Not Found');
return res.json(204, 'No Content');
});
};
现在,将以下路由添加到 Express 服务器 ./lib/express/index.js 中:
app.put('/project/:id', project.put);
功能:删除一个项目
As a vision user
I want to delete a project
So that I can remove projects no longer in use
让我们在 ./test/project.js 中为我们的功能 删除一个项目 添加一个测试。此资源将在路由 /project/:id 删除一个项目并返回 204 No Content 状态:
describe('when deleting an existing resource /project/:id', function(){
it('should respond with 204', function(done){
request(app)
.del('/project/' + id)
.expect(204, done);
});
});
让我们实现 删除一个项目 功能 ./lib/project/index.js 并添加一个 del 函数。我们尝试通过调用静态函数 Project.findOne 来删除项目。如果发生错误,我们返回错误;如果我们找不到项目,我们返回 null。如果我们找到项目,我们将其删除并返回一个空响应。
Project.prototype.del = function(id, callback){
var query = {'_id': id};
ProjectSchema.findOne(query, function(error, project) {
if (error) return callback(error, null);
if (project == null) return callback(null, null);
project.remove(function (error) {
if (error) return callback(error, null);
return callback(null, {});
});
});
};
让我们在 ./lib/routes/project.js 中添加一个新的路由。我们首先定义一个名为 del 的路由。我们尝试通过调用 Project.del 并传递 id 来删除项目。如果发生错误,我们返回 500 Internal Server Error;如果项目不存在,我们返回 404 Not Found。如果我们找到项目,我们返回 204 No Content 响应。
exports.del = function(req, res){
logger.info('Delete.' + req.params.id);
Project.del(req.params.id, function(error, project) {
if (error) return res.json(500, 'Internal Server Error');
if (project == null) return res.json(404, 'Not Found');
return res.json(204, 'No Content');
});
};
现在,将以下路由添加到 Express 服务器 ./lib/express/index.js 中:
app.del('/project/:id', project.del);
功能:列出项目
As a vision user
I want to see a list of projects
So that I can select a project I want to monitor
让我们在 ./test/project.js 中为我们的功能 列出项目 添加一个测试。此资源将从路由 /project 获取所有项目并返回 200 Ok 状态。
describe('when requesting resource get all projects', function(){
it('should respond with 200', function(done){
request(app)
.get('/project/?user=' + login.user)
.expect('Content-Type', /json/)
.expect(200)
.end(function (err, res) {
var proj = _.first(JSON.parse(res.text))
assert(_.has(proj, '_id'));
assert(_.has(proj, 'name'));
assert(_.has(proj, 'user'));
assert(_.has(proj, 'token'));
assert(_.has(proj, 'created'));
assert(_.has(proj, 'repositories'));
done();
});
});
});
让我们实现 列出项目 功能 ./lib/project/index.js 并添加一个 all 函数。我们尝试通过调用静态函数 Project.find 并按用户 id 查询来检索所有项目。如果发生错误,我们返回错误;如果找到项目,我们返回项目:
Project.prototype.all = function(id, callback){
var query = {"user" : id};
ProjectSchema.find(query, function(error, projects) {
if (error) return callback(error, null);
return callback(null, projects);
});
};
让我们在 ./lib/routes/project.js 中添加一个新的路由。我们首先定义一个名为 all 的路由。我们首先检索用户的 id。为了适应我们没有实现认证策略的事实,我们从我们的硬编码 login.user 对象中获取用户详情。我们将在未来的章节中清理它。我们尝试通过调用 Project.all 并传递 userId 来检索一个项目。如果发生错误,我们返回 500 Internal Server Error;如果我们找到项目,我们返回项目和一个 200 OK 响应。
exports.all = function(req, res){
logger.info('Request.' + req.url);
var userId = login.user || req.query.user || req.user.id;
Project.all(userId, function(error, projects) {
if (error) return res.json(500, 'Internal Server Error');
if (projects == null) projects = {};
return res.json(200, projects);
});
};
现在,将以下路由添加到 Express 服务器 ./lib/express/index.js 中:
app.get('/project', project.all);
GitHub API
我们的项目 API 已经完成,但随着我们尝试与 GitHub API 进行通信,事情将变得更加复杂。让我们安装以下模块。
github 模块为 GitHub v3 API 提供了一个面向对象的包装器;该模块的完整 API 可以在 mikedeboer.github.io/node-github/ 找到。
npm install github --save
async 模块是一个实用模块,它提供了大约 20 个强大的函数,用于处理异步 JavaScript。async 模块是一个控制流模块,它将允许我们以干净、可控的方式执行 IO 操作。
npm install async --save
moment.js 是一个用于解析、验证、操作和格式化日期的库。
npm install moment --save
功能:列出仓库
As a vision user
I want to see a list of all repositories for a GitHub account
So that I can select and monitor repositories for my project
让我们在 ./test/github.js 中为我们的功能 列出仓库 添加一个测试。这个资源将从路由 project/:id/repos 获取一个项目的所有仓库,并返回 200 Ok 状态:
describe('when requesting an available resource /project/:id/repos', function(){
it('should respond with 200', function(done){
this.timeout(5000);
request(app)
.get('/project/' + id + '/repos/')
.expect('Content-Type', /json/)
.expect(200)
.end(function (err, res) {
var repo = _.first(JSON.parse(res.text))
assert(_.has(repo, 'id'));
assert(_.has(repo, 'name'));
assert(_.has(repo, 'description'));
done();
});
});
});
我们需要做的第一件事是在 ./lib/github/index.js 中创建一个 GitHubRepo 模块。我们首先导入所需的模块,包括 github。我们定义一个构造函数,它接受一个 GitHub 访问 token 和一个 user 作为输入。然后我们实例化一个 GitHubApi 模块,调用 github.authenticate,它基于 token 进行认证:
var GitHubApi = require("github")
, config = require('../configuration')
, async = require("async")
, moment = require('moment')
, _ = require("underscore")
function GitHubRepo(token, user) {
this.token = token;
this.user = user;
this.github = new GitHubApi({
version: "3.0.0",
timeout: 5000 });
this.github.authenticate({
type: "oauth",
token: token
});
};
module.exports = GitHubRepo;
让我们实现功能 列出仓库 并将其添加到我们新的 GitHubRepo 模块 ./lib/github/index.js 中。我们首先定义我们的原型函数 repositories。我们在 github 模块上调用 getAll。如果发生错误,我们返回错误;如果没有找到仓库,我们返回一个 null 值。如果我们找到仓库,我们使用 map 函数创建一个新的项目数组,使用 underscore pick 函数选择三个属性 id、name 和 description。我们通过 callback 返回这些 items:
GitHubRepo.prototype.repositories = function(callback) {
this.github.repos.getAll({}, function(error, response) {
if (error) return callback(error, null);
if (response == null) return callback(null, null);
var items = response.map(function(model) {
return _.pick(model, ['id','name', 'description']);
});
callback(null, items);
});
};
让我们在./lib/project/index.js中添加一个repos函数。我们首先导入GitHubRepo模块,然后通过调用静态函数Project.findOne尝试检索项目。如果我们得到一个错误,我们返回错误;如果项目不存在,我们返回一个null值。如果我们找到项目,我们创建一个GithubRepo模块,并用token和user初始化它,并将其分配给git。然后我们调用git.repositories,它返回一个响应。如果我们得到一个错误,我们返回一个error,如果我们没有找到任何仓库,我们返回一个null值。如果我们找到仓库,我们使用map函数通过underscore pick函数创建一个新的项目数组,选择包括id、name和description在内的三个属性。我们添加一个第四个属性enabled,表示我们的项目是否分配了仓库,并返回所有仓库:
, GitHubRepo = require('../github')
Project.prototype.repos = function(id, callback){
ProjectSchema.findOne({_id: id}, function(error, project) {
if (error) return callback(error, null);
if (project == null) return callback(null, null);
var git = new GitHubRepo(project.token, project.user);
git.repositories(function(error, response){
if (error) return callback(error, null);
if (response == null) return callback("error", null);
items = response.map(function(model) {
var item = _.pick(model, ['id','name', 'description''description']);
var enabled = _.find(project.repositories, function(p){ return p == item.name; });
(enabled) ? item.enabled = 'checked' : item.enabled = '';
return item;
});
return callback(null, items);
});
});
};
让我们在./lib/routes/github.js中添加一个新的路由repos。我们实例化一个新的ProjectService,然后通过调用函数Project.repos尝试检索项目的仓库。如果我们得到一个错误,我们返回500 Internal Server Error。如果没有返回仓库,我们返回404 Not Found状态。如果我们收到仓库,我们返回一个包含仓库的200 OK状态。
, ProjectService = require('../project')
, Project = new ProjectService();
exports.repos = function(req, res){
logger.info('Request.' + req.url);
Project.repos(req.params.id, function(error, repos) {
if (error) return res.json(500, 'Internal Server Error');
if (repos == null) return res.json(404, 'Not Found');
return res.json(200, repos);
});
};
现在,将以下路由添加到./lib/express/index.js:
app.get('/project/:id/repos', github.repos);
功能:列出提交
As a vision user
I want to see a list of multiple repository commits in real time
So that I can review those commits
让我们在./test/github.js中为我们的List commits功能添加一个测试。这个资源将通过路由project/:id/commits获取项目中所有仓库的 10 个最新提交,并返回200 OK状态:
describe('when requesting an available resource /project/:id/commits', function(){
it('should respond with 200', function(done){
this.timeout(5000);
request(app)
.get('/project/' + id + '/commits')
.expect('Content-Type', /json/)
.expect(200)
.end(function (err, res) {
var commit = _.first(JSON.parse(res.text))
assert(_.has(commit, 'message'));
assert(_.has(commit, 'date'));
assert(_.has(commit, 'login'));
assert(_.has(commit, 'avatar_url'));
assert(_.has(commit, 'ago'));
assert(_.has(commit, 'repository'));
done();
});
});
});
让我们实现List commits功能,并将其添加到我们的新GitHubRepo模块./lib/github/index.js中。我们首先定义我们的函数commits,它接受一个repos列表。我们使用async.each遍历所有repos。async模块允许我们在 IO 上执行异步操作。
然后,我们调用github.repos.getCommits;我们传递我们的 GitHub user和repo。如果github.repos.getCommits()返回错误,我们调用callback。当我们收到响应时,我们使用map函数通过uderscore pick函数选择两个属性:committer和message来创建一个新的项目数组。如果项目有committer,我们使用 underscores 的extend函数添加提交者的login和avatar_url。我们通过callback将项目返回到主函数,并使用 underscores 的sort函数按日期排序项目并选择前 10 个项目。然后,我们通过callback返回提交:
GitHubRepo.prototype.commits = function(repos, callback) {
var me = this;
var items = [];
async.each(repos, function(repo, callback) {
me.github.repos.getCommits({ user: me.user,
repo: repo }, function(error, response) {
if (error) return callback();
if (response == null) return callback();
var repoItems = response.map(function(model) {
var item =_.pick(model.commit, ['message']);
if (model.commit.committer) _.extend(item, _.pick(model.commit.committer, ['date']));
if (model.committer) _.extend(item, _.pick(model.committer, ['login', 'avatar_url']));
item.ago = moment(item.date).fromNow();
item.repository = repo;
return item;
});
items = _.union(items, repoItems);
callback(null, items );
});
}
, function(error) {
var top = _.chain(items)
.sortBy(function(item){ return item.date })
.reverse()
.first(10)
.value();
callback(error, top);
});
};
让我们在 ./lib/project/index.js 中添加一个 commits 函数。我们首先定义一个名为 commits 的函数。我们尝试通过调用静态函数 Project.findOne 来检索项目。如果发生错误,我们返回错误。如果项目不存在,我们返回一个 null 值。如果我们找到了项目,我们创建一个 GithubRepo 模块,并用 token 和用户初始化它,并将其分配给 git。然后我们调用 git.commits 函数,传递一个存储库列表,返回一个响应。如果发生错误,我们返回错误。如果得到一个有效的响应,我们返回提交。
Project.prototype.commits = function(id, callback){
ProjectSchema.findOne({_id: id}, function(error, project) {
if (error) return callback(error, null);
if (project == null) return callback(null, null);
var git = new GitHubRepo(project.token, project.user);
git.commits(project.repositories, function(error, response){
if (error) return callback(error, null);
return callback(null, response);
});
});
};
让我们在 ./lib/routes/github.js 中添加一个新的路由 commits。我们尝试通过调用 Project.commits 来检索提交。如果发生错误,我们返回 500 Internal Server Error。如果没有返回提交,我们返回 404 Not Found。如果我们收到提交,我们返回一个包含提交的 200 OK 响应:
exports.commits = function(req, res){
logger.info('Request.' + req.url);
Project.commits(req.params.id, function(error, commits) {
if (error) return res.json(500, 'Internal Server Error');
if (commits == null) return res.json(404, 'Not Found');
return res.json(200, commits);
});
};
现在,将以下路由添加到 ./lib/express/index.js:
app.get('/project/:id/commits', github.commits);
功能:列出问题
As a vision user
I want to see a list of multiple repository issues in real time
So that I can review and fix issues
让我们在 ./test/project.js 中为我们的 List issues 功能添加一个测试。此资源将获取来自 project/:id/issues 路由的所有项目,并返回一个 200 OK 响应:
describe('when requesting an available resource /project/:id/issues', function(){
it('should respond with 200', function(done){
this.timeout(5000);
request(app)
.get('/project/' + id + '/issues')
.expect('Content-Type', /json/)
.expect(200)
.end(function (err, res) {
var issue = _.first(JSON.parse(res.text))
assert(_.has(issue, 'title'));
assert(_.has(issue, 'state'));
assert(_.has(issue, 'updated_at'));
assert(_.has(issue, 'login'));
assert(_.has(issue, 'avatar_url'));
assert(_.has(issue, 'ago'));
assert(_.has(issue, 'repository'));
done();
});
});
});
让我们实现一个名为 List issues 的功能,并将其添加到我们新的 GitHubRepo 模块 ./lib/github/index.js 中。我们首先定义我们的 issues 函数,它接受一个 repos 列表。我们使用 async.each 来遍历所有 repositories。
然后我们调用 github.repos.repoIssues 并传递我们的 GitHub user 和 repo,如果 github.repos.repoIssues() 返回一个 error,则调用回调。如果得到一个有效的响应,我们使用 map 函数创建一个新的项目数组,使用 underscore pick 函数选择四个属性,包括 id、title、state 和 updated_at。如果项目有用户,我们使用 underscore extend 函数添加用户的 login 和 avatar_url。然后我们通过 callback 将项目返回给主函数,并使用 underscore sort 函数按日期排序项目。然后我们选择前 10 个问题,并通过 callback 返回问题。
GitHubRepo.prototype.issues = function(repos, callback) {
var me = this;
var items = [];
async.each(repos, function(repo, callback) {
me.github.issues.repoIssues({ user: me.user, repo: repo }, function(error, response) {
if (error) return callback();
if (response == null) return callback();
var repoItems = response.map(function(model) {
var item = _.pick(model, ['title', 'state', 'updated_at']);
if (model.user) _.extend(item, _.pick(model.user, ['login', 'avatar_url']));
item.ago = moment(item.updated_at).fromNow();
item.repository = repo;
return item;
});
items = _.union(items, repoItems);
callback(null, items );
});
}
, function(error) {
var top = _.chain(items)
.sortBy(function(item){ return item.updated_at; })
.reverse()
.first(10)
.value();
callback(error, top);
});
};
让我们在 ./lib/project/index.js 中添加一个 issues 函数。我们首先定义一个名为 issues 的函数。我们尝试通过调用静态函数 Project.findOne 来检索项目。如果发生错误,我们返回 error。如果项目不存在,我们返回一个 null 值。如果我们找到了项目,我们创建一个 GitHubRepo 模块,并用 token 和 user 初始化它,并将其分配给 git。然后我们调用 git.issues,传递一个存储库列表,返回一个响应。如果发生错误,我们返回一个 error,如果得到一个有效的响应,我们返回问题和 200 OK 响应:
exports.issues = function(req, res){
logger.info('Request.' + req.url);
Project.findOne({_id: req.params.id}, function(error, project) {
if (error) return res.json(500, 'Internal Server Error');
if (project == null) return res.json(404, 'Page Not Found');
var git = new GitHubRepo(project.token, project.user);
git.issues(project.repositories, function(error, response){
if (error) return res.json(500, 'Internal Server Error');
return res.json(200, response);
});
});
};
让我们在 ./lib/routes/github.js 中添加一个新的路由 issues。我们尝试通过调用 Project.issues 来检索问题。如果发生错误,我们返回 500 Internal Server Error。如果没有返回问题,我们返回 404 Not Found 响应,如果收到问题,我们返回包含问题的 200 OK 响应:
exports.issues = function(req, res){
logger.info('Request.' + req.url);
Project.issues(req.params.id, function(error, issues) {
if (error) return res.json(500, 'Internal Server Error');
if (issues == null) return res.json(404, 'Not Found');
return res.json(200, issues);
});
};
现在,将以下路由添加到./lib/express/index.js:
app.get('/project/:id/issues', github.issues);
使用参数中间件验证参数
你可能已经注意到,我们在每个路由中都重复了id验证。让我们使用app.params来改进这些事情。
这里是检查我们的id是否为有效的 MongoDB id的有问题的代码行:
if (req.params.id.match(/^[0-9a-fA-F]{24}$/) == null)
return res.json(400, 'Bad Request');
让我们添加一个中间件来处理这个./lib/middleware/id.js。我们定义一个validate函数,它接受四个参数,最后一个参数是id的值。然后我们验证id参数,如果它无效,则返回400 Bad Request。然后我们调用next(),这将调用 Express 堆栈中的下一个中间件:
exports.validate = function(req, res, next, id){
if (id.match(/^[0-9a-fA-F]{24}$/) == null)
return res.json(400, 'Bad Request');
next();
}
现在,我们可以在我们的 Express 服务器中使用这个id中间件。让我们包含param中间件,并在第一个路由之前添加此行,以便它适用于所有路由:./lib/express/index.js:
, id = require('../middleware/id')
..
app.param('id', id.validate);
现在,我们可以编辑我们的两个路由模块./lib/routes/project.js和./lib/routes/github.js,并删除有问题的代码行。id参数现在将处理所有路由。
路由改进
现在,我们的 Express 服务器需要很多路由;让我们清理一下。在node.js中,一个常见的模式是包含一个index文件,该文件返回其当前目录中的所有文件。我们将使用require-directory来为我们完成这项工作:
npm install require-directory –save
让我们创建一个新的模块./lib/routes/index.js,代码如下:
var requireDirectory = require('require-directory');
module.exports = requireDirectory(module, __dirname, ignore);
现在,./lib/routes/文件夹中的所有路由都将暴露在单个变量routes下:
var express = require('express')
, http = require('http')
, config = require('../configuration')
, db = require('../db')
, routes = require('../routes')
, notFound = require('../middleware/notFound')
, id = require('../middleware/id')
, app = express();
app.use(express.bodyParser());
app.set('port', config.get('express:port'));
app.use(express.logger({ immediate: true, format: 'dev' }));
app.param('id', id.validate);
app.get('/heartbeat', routes.heartbeat.index);
app.get('/project/:id', routes.project.get);
app.get('/project', routes.project.all);
app.post('/project', routes.project.post);
app.put('/project/:id', routes.project.put);
app.del('/project/:id', routes.project.del);
app.get('/project/:id/repos', routes.github.repos);
app.get('/project/:id/commits', routes.github.commits);
app.get('/project/:id/issues', routes.github.issues);
app.use(notFound.index);
http.createServer(app).listen(app.get('port'));
module.exports = app;
摘要
我们现在已经完成了我们的 Web API。我们实现了一个基本的 MongoDB 提供者;我们使用 Mongoose 为我们提供了一些模式支持。我们还对我们的 Express 服务器进行了一些小的改进,清理了路由。
在下一章中,我们将构建客户端时消费这个 API。
第三章。模板化
我们已经部署了 Web API,现在让我们将注意力转向客户端。在本章中,我们将消费我们的 Web API,并使用服务器端和客户端模板的混合方式来展示我们的数据。我们将使用 Express 从服务器上提供./views/index.html主页面文件,并使用consolidate.js和handlebars.js进行模板化。在客户端,我们将使用backbone.js和预编译的 handlebars 模板,这些模板直接从./public文件夹提供。
服务器端模板化
到目前为止,我们的 Express 服务器只提供了 JSON;让我们安装一些模块,以帮助我们提供 HTML。
consolidate.js是一个模板引擎合并库,它被创建来将所有 Node 的流行模板引擎映射到 Express 的模板约定,允许它们在 Express 中工作:
npm install consolidate --save
handlebars.js是 mustache 模板语言的扩展。Handlebars 是一个无逻辑的模板语言,它将视图和代码分离:
npm install handlebars --save
为了能够提供我们的 handlebar 模板,我们不得不对我们的 Express 服务器做一些修改。让我们通过设置app.engine将默认模板引擎更改为 handlebars:
app.engine('html', cons.handlebars);
现在注册html作为我们的视图文件扩展名。如果我们没有设置这个,我们就需要将我们的视图命名为index.hbs而不是index.html,其中.hbs是 handlebars 模板的扩展名。
app.set('view engine', 'html');
让我们创建我们的单页应用程序视图;这将由我们的 Express 服务器提供:
./views/index.html
接下来,我们定义我们的views文件夹的位置以及静态文件文件夹的位置;这里我们将存储components,例如,CSS 和 JavaScript 文件。
app.set('views', 'views');
app.use(express.static('public'));
app.use(express.static('public/components'));
现在创建一个名为public的文件夹,并添加以下目录结构,以便静态资源以子目录作为前缀提供,例如,vision/vision.css。
./public
./public/components
./public/components/vision
功能:主页面
As a vision user
I want the vision application served as a single page
So that I can spend less time waiting for page loads
让我们在./test/home.js为我们的功能Master Page添加一个测试。这个资源将从路由./获取我们的主页面并返回200 OK响应。响应的Content-Type应该是HTML:
var app = require('../app')
, request = require('supertest');
describe('vision master page', function(){
describe('when requesting resource /', function(){
it('should respond with view', function(done){
request(app)
.get('/')
.expect('Content-Type', /html/)
.expect(200, done)
});
});
});
让我们实现我们的Master Page功能。让我们创建一个新的模块,该模块公开一个路由./lib/routes/home.js并添加一个新的index函数。我们首先定义一个名为index的路由。我们创建一个带有页面元信息的视图model,然后通过传递视图model来渲染视图:
exports.index = function(req, res){
var model = {
title: 'vision.',
description: 'a project based dashboard for github',
author: 'airasoul',
user: 'Andrew Keig'
};
res.render('index', model);
};
让我们在 Express 服务器./lib/express/index.js中添加一个新的路由:
app.get('/', routes.home.index);
使用 Bower 进行包管理
我们现在将安装构成我们客户端的各种组件,即Handlebars.js、Backbone.js和 Twitter Bootstrap 2 版本,使用Bower。
Bower 是 Web 的包管理器。一个 Bower 包可以包含不同类型的资源,如 CSS、JavaScript 和图像。让我们使用以下命令全局安装 Bower:
npm install -g bower
在 Bower 中,依赖关系列在bower.json文件中,类似于 Node 的package.json。让我们创建一个./bower.json文件并定义我们的客户端依赖项:
{
"name": "vision",
"version": "0.0.1",
"dependencies": {
"json2": "*",
"jquery": "*",
"underscore": "*",
"backbone": "*",
"handlebars": "*",
"bootstrap": "2.3.2"
}
}
现在创建以下 Bower 配置文件 ./.bowerrc,它允许我们定义我们的目标目录和 bower.json 文件的名字:
{
"directory": "public/components",
"json": "bower.json"
}
运行以下命令来安装 bower.json 文件中列出的所有依赖项:
bower install
Twitter Bootstrap 的资源存储在以下片段中指定的路径指定的文件夹中,因此让我们添加一个 static 中间件来覆盖我们的 Express 服务器。这将保持客户端路径的一致性:
app.use('/bootstrap', express.
static('public/components/bootstrap/docs/assets/css'));
模板
我们的主页包含以下部分。为了便于使用 backbone.js 进行客户端模板化模型,我们将主页拆分成模板。
让我们创建一个名为 ./templates 的新文件夹,并添加以下文件:
./templates
projects.hbs
project-form.hbs
repositories.hbs
commits.hbs
issues.hbs
为了避免按需编译模板,让我们安装 grunt-contrib-handlebars 这个 grunt 任务,它将预编译我们的 handlebars 模板:
npm install grunt-contrib-handlebars --save-dev
在以下代码中,我们概述了 handlebars 编译的 grunt 配置;它简单地以模板位置 templates/*.hbs 作为输入,将这些模板编译成一个单一的 JavaScript 文件,并将其存储在 public/components/vision/templates.js。
grunt.loadNpmTasks('grunt-contrib-handlebars');
handlebars: {
compile: {
options: {
namespace: "visiontemplates"
},
files: {
"public/components/vision/templates.js": ["templates/*.hbs"]
}
}
},
我们通过查看主模板 ./views/index.html 来完成这一部分。主体包含以下区域:一个头部,包括一个 login 按钮或一个带有 welcome 消息的 logout 按钮,一个 project-list 表单,repository-list,commit-list 和 issue-list。
{{#if user}}
<p class="navbar-text">welcome {{user}},
<a href="/logout" class="navbar-link">
click here to sign out</a>
</p>
{{else}}
<a href="/auth/github">
<img src="img/github.png" id='login'>
</a>
{{/if}}
{{#if user}}
<div class="span3">
<h2>Projects</h2>
<ul id="projects-list" class="nav nav-list"></ul>
<br/><a id="showForm" class="btn btn-large btn-block btn-primary" href="#add">Add project</a>
</div>
<div class="span3">
<h2>Repositories</h2>
<ul id="repository-list" class="nav inline nav-list"></ul>
</div>
<div class="span3">
<h2>Commits</h2>
<ul id="commits-list" class="media-list"></ul>
</div>
<div class="span3">
<h2>Issues</h2>
<ul id="issues-list" class="media-list"></ul>
</div>
{{else}}
<div class="span12">
<div class="hero-unit">
<h1>vision</h1>
<lead>a real-time multiple repository dashboard for GitHub issues and commits</lead>
<p><small>In order to use vision; please login to a valid GitHub Account</small></p>
</div>
</div>
{{/if}}
使用 Backbone.js 进行客户端开发
Backbone.js 是一个轻量级且非常灵活的 JavaScript 模型视图(MV)框架,它简化了复杂 JavaScript 应用程序的构建。它包括一些非常基本的原语,允许我们将客户端的模型和逻辑与其视图解耦。Backbone 支持一个 RESTful JSON 接口,将模型/集合与 RESTful API 相关联。有关 Backbone.js 的更多信息,请访问 backbonejs.org。
功能:列出项目
让我们构建我们的功能 列出项目 的客户端。列表中的每个项目都包含一个项目名称和一个编辑和删除按钮。点击名称将显示一个仓库列表;点击 编辑 将显示一个填充了模型数据的内联表单,点击 删除 将从我们的数据库中删除该条目。我们稍后会回来连接这三个功能。现在,我们只是简单地显示一个项目列表。
接下来是一个用于项目条目的 HTML 模板 ./templates/projects.hbs;它包含一个占位符 {{_id}},它将被我们的 Backbone 应用程序替换:
<a href="#{{_id}}" data-id="{{_id}}">{{name}}</a>
<button class="delete btn btn-mini btn-primary list-btn">del </ button>
<button class="edit btn btn-mini btn-primary list-btn spacer ">edit e</button>
让我们定义一个包含所有组件的骨架 Backbone 应用程序:./public/components/vision/vision.js。我们首先定义 Vision 命名空间;我们向其中添加一个名为 Application 的外部函数,它有一个名为 start 的单一方法。在这里,我们实例化一个 router 并调用 Backbone.history.start() 以启动 Backbone 应用程序。然后我们调用 router.navigate('index', true) 并导航到我们的主页。有了这个函数,我们实例化新的 Vision.Application() 并调用 start()。
var Vision = Vision || {};
Vision.Application = function(){
this.start = function(){
var router = new Vision.Router();
Backbone.history.start();
router.navigate('index', true);
}
};
$(function(){
var app = new Vision.Application();
app.start();
});
现在让我们创建一个名为 Router 的应用程序。一般来说,Backbone 应用程序只有一个这样的组件;路由器是我们应用程序的入口点。
首先,我们添加一个名为 Router 的函数,它扩展了 Backbone 的 Router 类型。我们添加了一个名为 projectListView 的项目列表视图,并添加了一个 routes 哈希,它定义了一个单一的路由。我们应用程序的入口点是一个空路由,映射到名为 index 的方法。当路由器实例化时,会调用 initialize 或构造函数方法;从这里我们调用一个名为 project 的方法,它实例化一个 ProjectListView。index 方法与之前定义的路由相匹配,通过调用 projectApplication.render() 来渲染我们的视图。
Vision.Router = Backbone.Router.extend({
projectListView : "",
routes: {
"" : "index",
},
initialize : function(){
this.project();
},
project : function(){
this.projectListView = new Vision.ProjectListView();
},
index : function(){
this.projectListView.render();
}
});
让我们实现我们的 Project 模型以支持我们的视图。我们首先添加一个名为 Project 的函数,它扩展了 Backbone 的 Model 类型,并包含我们模型中两个属性的默认值哈希。我们覆盖了 idAttribute 参数以适应 MongoDB 标识符。我们将使用 MongoDB 的 _id 作为我们的模型标识符;默认情况下,Backbone 将使用 id。此标识符将被附加到 Backbone 向服务器发出的任何请求中,例如,在执行 GET、POST、PUT 或 DELETE 操作时。我们已经在 第二章 中添加了此模型的 API,构建 Web API。urlRoot 参数将此模型链接到 Web API 路由 /project 以返回一个项目。
Vision.Project = Backbone.Model.extend({
defaults: {
id : ""
, name: ""
},
idAttribute: "_id",
urlRoot: '/project'
});
让我们实现一个集合;为我们的 Project 模型添加一个名为 ProjectList 的集合。我们添加一个名为 ProjectList 的函数,它扩展了 Backbone 的 Collection 类型,并指定模型类型为 Vision.Project。我们添加一个 url 方法,它返回我们的 Web API 路由 /project 以返回项目列表。当集合实例化时,会调用 initialize 方法;从这里我们执行初始的 fetch() 来获取我们的项目;因此调用 API /project。
Vision.ProjectList = Backbone.Collection.extend({
model: Vision.Project,
url: function () {
return "/project/";
},
initialize: function() {
this.fetch();
}
});
在我们实现 ProjectListView 之前,让我们创建 event_aggregator;这将允许我们的视图触发和绑定命名事件,其他视图可以响应这些事件。我们需要这样做,以便 ProjectListView 通知 RepositoryListView 显示 RepositoryList 的时间到了。
让我们使用 underscore.js 的 extend 方法将 Backbone 的 event 模块混合到我们的视图原型中,添加一个名为 event_aggregator 的函数:
Backbone.View.prototype.event_aggregator = _.extend({}, Backbone.Events);
让我们为我们的Project集合实现一个视图——ProjectListView。我们首先定义一个函数ProjectListView,它扩展了 Backbone 的View类型,并为我们的项目列表添加了一个Projects数组。我们将一个 DOM 元素分配给el;一个名为projects-list的无序列表。这是我们视图将被插入的元素。如果你没有将其分配给el,Backbone 将构造一个空的div标签。
当视图实例化时调用initialize方法;在这里,我们实例化一个新的ProjectList,传递我们的Projects数组。然后我们调用collection.on('add'),当从 API 获取数据时将调用add方法。add方法实例化ProjectView,并将其传递给一个project模型。然后我们通过$el将ProjectView追加到我们的 DOM 元素中,并返回视图。
Vision.ProjectListView = Backbone.View.extend({
Projects: [],
el: $("ul#projects-list"),
initialize: function () {
this.collection = new Vision.ProjectList(this.Projects);
this.collection.on('add', this.add, this);
},
add: function (project) {
var projectView = new Vision.ProjectView({
model: project
});
this.$el.append(projectView.render().el);
return projectView;
}
});
我们通过实现单个项目的视图——ProjectView来完成本节。我们首先定义一个函数ProjectView,它扩展了 Backbone 的View类型,并将tagName分配给它,将其设置为li。这个标签将围绕我们的项目视图;我们的 DOM 元素是一个ul标签。
然后我们包含viewTemplate并将预编译的 handlebars 模板分配给它。尽管模板被编译到一个单独的文件——./vision/templates.js——但我们仍然通过名称引用模板;templates/projects.hbs。render方法渲染视图;我们将project模型传递给我们的viewTemplate,然后通过$el添加到我们的 DOM 元素中,并返回视图:
Vision.ProjectView = Backbone.View.extend({
tagName: "li",
viewTemplate: visiontemplates["templates/projects.hbs"],
render: function () {
var project = this.viewTemplate(this.model.toJSON());
this.$el.html(project);
return this;
}
});
如果你进入 MongoDB,并将以下记录添加到 vision 数据库中项目的集合,当在浏览器中访问 Vision 应用程序时,你可以在项目列表视图中看到此记录:
{
"_id" : ObjectId("525c61bcb89855fc09000018"),
"created" : ISODate("2013-10-17T22:58:37Z"),
"name" : "test name",
"token" : "#TOKEN#",
"user" : "#USER#"
}
功能:列出仓库
让我们构建我们的功能List repositories的客户端。列表中的每个项目都由一个仓库名称、一个简短描述和一个复选框组成;这允许我们向项目添加或删除仓库。
接下来是一个用于仓库项的 HTML 模板./templates/repositories.hbs:
<li>
<label class="checkbox inline">
<input id="{{id}}" type="checkbox" {{enabled}} value="{{name}}"><h4 class="media-heading repoItem">{{name}}</h4>
<small>{{description}}</small>
</label>
</li>
让我们添加一个Repository模型。我们添加一个函数Repository,它扩展了 Backbone 的Model类型,并为我们的模型中的四个属性添加了一个默认值的哈希。enabled属性表示仓库包含在所选项目中。
Vision.Repository = Backbone.Model.extend({
defaults: {
id : ""
, name: ""
, description: ""
, enabled: ""
}
});
让我们为我们的Repository模型实现一个集合。我们首先定义一个函数RepositoryList,它扩展了 Backbone 的Collection类型。我们添加了所选项目的projectId,并将模型类型设置为Vision.Repository。然后我们添加了一个url方法,并使用 Web API 路由/project/:id/repos来获取一个项目的仓库列表。
当集合实例化时调用initialize方法;从这里,我们分配所选的projectId。当执行获取操作时调用parse方法,并将解析的响应分配给我们的 MongoDB _id。
Vision.RepositoryList = Backbone.Collection.extend({
projectId: '',
model: Vision.Repository,
url : function() {
return '/project/' + this.projectId + '/repos';
},
initialize: function(items, item) {
this.projectId = item.projectId;
},
parse: function( response ) {
response.id = response._id;
return response;
}
});
我们现在实现一个单个仓库的视图。我们添加一个函数 RepositoryView,它扩展了 Backbone 的 View 类型,并将 tagName 赋值为 li。这个标签将围绕我们的 RepositoryView 函数;我们的 DOM 元素是一个 ul 标签。我们包含一个 viewTemplate 函数,并将预编译的 handlebars 模板 templates/repositories.hbs 赋值给它。render 方法渲染视图;我们向 viewTemplate 函数传递 repository 模型,然后通过 $el 添加到我们的 DOM 元素中,并返回视图。
Vision.RepositoryView = Backbone.View.extend({
tagName: "li",
viewTemplate: visiontemplates["templates/repositories.hbs"],
render: function () {
this.$el.html(this.viewTemplate(this.model.toJSON()));
return this;
}
});
让我们实现一个名为 RepositoryListView 的 RepositoryList 视图。我们首先定义一个函数 RepositoryListView,它扩展了 Backbone 的 View 类型,并为我们的仓库列表添加一个 Repositories 数组。我们添加一个 initialize 方法;如果 projectId 为空,则返回。有效的 projectId 将导致渲染视图;首先,我们清除 DOM 元素,然后我们将一个新的 RepositoryList 函数分配给视图的 collection。我们使用 Repositories 数组和 projectId 初始化列表,然后在我们的集合中调用 fetch,然后调用 render 以成功获取。
render 方法使用 underscore 遍历名为 collection.models 的仓库集合,为每个项目调用 add(item)。我们包含一个 add 方法,它实例化一个 RepositoryView 函数,并将一个 repository 模型传递给它。然后我们通过 $el 将渲染后的 RepositoryView 添加到我们的 DOM 元素中,并返回视图。
Vision.RepositoryListView = Backbone.View.extend({
Repositories: [],
initialize: function (args) {
if (!args.projectId) return;
var me = this;
this.$el.html('');
this.collection = new Vision.RepositoryList(this.Repositories, {
projectId : args.projectId
});
this.collection.fetch({success: function(){
me.render();
}});
},
render: function () {
_.each(this.collection.models, function (item) {
this.add(item);
}, this);
},
add: function (item) {
var repositoryView = new Vision.RepositoryView({
model: item
});
this.$el.append(repositoryView.render(this.editMode).el);
return repositoryView;
}
});
让我们对 ProjectView 进行一些修改,并在选择项目时添加一个点击事件。我们首先定义一个 events 哈希,其中包含一个名为 click 的单个事件,它调用 repository 方法。repository 方法从我们的模型中获取 projectId,然后调用 event_aggregator 上的 trigger 方法,传递事件 repository:join 和 projectId。我们将在 ProjectListView 上监听此事件。
events: {
"click a" : "repository"
},
repository: function() {
var data = { projectId: this.model.toJSON()._id }
this.event_aggregator.trigger('repository:join', data);
},
让我们将上一个事件的另一侧连接起来,并为 ProjectListView 添加一个事件绑定器。我们在 initialize 方法中添加一个 event_aggregator.bind 语句,将事件 repository:join 绑定到 repository 方法。repository 方法在路由器上触发一个 join 事件。
initialize: function () {
this.event_aggregator.on('repository:join', this.repository, this);
this.collection = new Vision.ProjectList(this.Projects);
this.render();
},
repository: function(args){
this.trigger('join', args);
},
让我们完善画面,并将路由器改为监听 join 事件。我们向路由器添加一个 repositoryListView 函数,并在 initialize 方法中添加一个 listenTo 事件,该事件调用 join 方法。join 方法调用 repository,它实例化 RepositoryListView 函数,并传递 projectId。
repositoryListView:'',
initialize : function(){
this.project();
this.listenTo(this.projectListView , 'join', this.join);
},
join : function(args){
this.repository(args);
},
repository : function(args){
this.repositoryListView =new Vision.RepositoryListView({ el: 'ul#repository-list', projectId: args.projectId });
},
现在,当你点击 ProjectView 中的项目项名称时,将显示 RepositoryListView。
功能:创建项目
让我们为我们的功能“创建项目”添加一个项目表单。它包括一个大的添加项目按钮,一个用于项目名称的文本框,以及保存和取消按钮。点击保存将项目 POST 到我们的 Express 服务器,而点击取消将关闭表单。
以下是一个用于存储项的 HTML 模板 ./templates/project-form.hbs:
<form class="form-inline">
<ul class="errors help"></ul>
<label>name</label>
<input class="name" placeholder="project name" required="required" value="{{name}}" autofocus />
<br/><button class="cancel btn btn-mini btn-primary form-btn">cancel</button>
<button class="save btn btn-mini btn-primary form-btn form-spacer">save</button>
</form>
让我们对 router 进行一些修改,并将一个路由连接到我们的“添加项目”按钮。routes 现在包括一个名为 add 的路由,它调用一个名为 add 的方法。我们包括一个 add 方法,该方法调用 projectListView.showForm(),渲染我们的表单:
routes: {
"" : "index",
"add" : "add"
},
add : function(){
this.projectListView.showForm();
}
让我们对 projectListView 进行一些修改,并修改 initialize 方法。我们将此视图绑定到 collection 的 reset、add 和 remove 事件。我们还添加了一个 showForm 方法,如前述代码所示。该方法通过调用 this.add()、传递 new Vision.Project() 并在返回的视图中调用 add() 来渲染项目表单。
initialize: function () {
this.event_aggregator.on('repository:join', this.repository, this);
this.collection = new Vision.ProjectList(this.Projects);
this.collection.on('reset', this.render, this);
this.collection.on('add', this.add, this);
this.collection.on('remove', this.remove, this);
},
showForm: function () {
this.add(new Vision.Project()).add();
}
让我们在 Project 模型中添加一些验证,以便我们可以验证项目表单的输入。我们在 Project 模型中添加了一个 validate 方法,并验证 Project 模型的名称。如果验证失败,我们返回一个包含错误信息的 errors 数组。我们实际上是在重写 validate 方法。Backbone.js 要求您使用自定义验证逻辑重写 validate 方法。默认情况下,validate 方法也是 save 调用的一部分。
validate: function(attrs) {
var errors = [];
if (attrs.name === '') errors.push("Please enter a name");
if (errors.length > 0) return errors;
}
让我们对 projectView 进行一些修改。我们首先添加一个新的模板 formTemplate,该模板显示一个用于添加新项目的表单。我们在 events 哈希中添加了两个新事件——一个按钮 save 事件和一个按钮 cancel 事件。
cancel 方法响应取消事件,将从我们的模型中获取当前的 projectId 并检查 model.isNew。如果是新的,我们只需从 projectListView 中移除 projectView。如果不是新的,我们将渲染我们的视图,并通过调用 repository 渲染 repositoryListView。然后我们使用 history.navigate 导航到 index 页面。
save 方法响应 save 事件,从我们的模型中获取 projectId 和表单数据。然后我们调用 model.isValid,它调用我们项目模型中的 validate 方法。任何返回的错误都会导致调用 formError。如果模型有效,我们将获取我们的选定存储库并将其分配给我们的表单。然后我们尝试通过调用 model.save 将表单作为 Project 保存。任何返回的错误都会导致调用 formError。成功的保存使我们能够在 ProjectListView 中渲染 project。我们还通过调用 repository 渲染 RepositoryListView。然后我们使用 history.navigate 导航到 index 页面。
formTemplate: visiontemplates["templates/project-form.hbs"],
events: {
"click a" : "repository"
"click button.save": "save",
"click button.cancel": "cancel"
},
add: function () {
this.$el.html(this.formTemplate(this.model.toJSON()));
this.repository();
},
cancel: function () {
var projectId = this.model.toJSON()._id;
if (this.model.isNew()) {
this.remove();
} else {
this.render();
this.repository();
}
Backbone.history.navigate('index', true);
},
save: function (e) {
e.preventDefault();
var me = this
, formData = {}
, projectId = this.model.toJSON()._id;
$(e.target).closest("form")
.find(":input").not("button")
.each(function () {
formData[$(this).attr("class")] = $(this).val();
});
if (!this.model.isValid()) {
me.formError(me.model, me.model.validationError, e);
} else {
formData.repositories = $('#repository-list')
.find("input:checkbox:checked")
.map(function(){
return $(this).val();
}).get();
}
this.model.save(formData, {
error: function(model, response) {
me.formError(model, response, e);
},
success: function(model, response) {
me.render();
me.repository();
Backbone.history.navigate('index', true);
}
});
},
formError: function(model, errors, e) {
$(e.target).closest('form').find('.errors').html('');
_.each(errors, function (error) {
$(e.target).closest('form').find('.errors')
.append('<li>' + error + '</li>')
});
}
现在,您将能够完成表单并添加一个新的项目。
功能:编辑项目
让我们为我们的功能“编辑项目”添加一个编辑项目表单。它包括一个项目名称的文本框、一个保存按钮和一个取消按钮。点击保存会将项目发送到我们的 Express 服务器;点击取消会关闭表单。我们将使用与添加项目相同的 handlebars 模板。为了使 RepositoryListView 可编辑,我们需要引入编辑状态的概念。我们将其命名为 editMode。
让我们对 projectView 进行一些修改。我们首先向 events 哈希中添加一个名为 edit 的新事件,该事件调用一个 edit 函数。我们通过向 event_aggregator 传递新的 arg.editMode 参数来更改我们的 repository 方法,这将通知 RepositoryListView 它处于编辑模式。
edit 方法,它显示我们的 formTemplate 并用 project 模型数据填充,调用 repository 方法并将 editMode 设置为 false,通知 RepositoryListView 它处于编辑模式。最后,我们更新我们的 add、cancel 和 save 方法;这些方法中对 repository 方法的调用应传递 {editMode:false}。
Events: {
...
"click button.edit": "edit"
},
repository: function(args) {
var data = { projectId: this.model.toJSON()._id, editMode: args.editMode || false }
...
},
edit: function () {
var model = this.model.toJSON();
this.$el.html(this.formTemplate(model));
this.repository({editMode:true});
},
让我们对 RepositoryListView 进行一些修改。现在,当 collection.fetch 成功请求时,initialize 方法将根据 editMode 启用或禁用表单复选框。enableForm 函数从我们的 RepositoryListView 复选框列表中移除 disabled 标签。disableForm 函数向我们的 RepositoryListView 复选框列表中添加 disabled 标签。
initialize: function (args) {
...
this.collection.fetch({ success: function(){
me.render();
(args.editMode) ? me.enableForm() : me.disableForm();
}});
},
enableForm: function(){
this.$el.find("input:checkbox").remove('disabled');
},
disableForm: function(){
this.$el.find("input:checkbox").attr('disabled', 'disabled');
}
现在,您将能够编辑您现有的项目。
功能:删除项目
让我们为功能 Delete a project 在我们的表单中添加一个 删除 按钮。
让我们对 ProjectView 进行修改,并向 events 哈希中添加一个名为 delete 的新事件,该事件调用 delete 方法。我们添加一个 delete 方法,它销毁模型并移除 ProjectView。然后我们调用 repository,移除 RepositoryListView。
events: {
...
"click button.delete": "delete",
},
delete: function () {
this.model.destroy();
this.remove();
this.repository({editMode:false});
},
让我们对 ProjectListView 进行修改,并在 initialize 中添加一个 collection 事件处理程序。事件处理程序在移除项目时调用 remove 方法。remove 方法获取模型的属性并搜索 Projects 集合,找到时移除项目。
initialize: function () {
...
this.collection.on("remove", this.remove, this);
},
remove: function (removedModel) {
var removed = removedModel.attributes;
_.each(this.Projects, function (project) {
if (_.isEqual(project, removed)) {
this.Projects.splice(_.indexOf(projects, project), 1);
}
});
},
现在,您可以通过点击删除按钮来删除项目。
功能:列出提交
让我们为功能 List Commits 添加一个提交列表。列表中的每个项目由一个提交 message、项目 name、一个 date 和提交者的 username 组成。以下是一个提交项的 HTML 模板 ./templates/commits.hbs:
<a class="pull-left" href="#">
<img class="media-object" src="img/{{avatar_url}}"
style="width:64px; height:64px">
</a>
<div class="media-body">
<h4 class="media-heading">{{message}}</h4>
<small>{{repository}}</small>
<small>{{ago}}</small>
<br/><small>{{login}}</small>
</div>
让我们实现我们的 Commit 模型。我们定义一个名为 Commit 的函数,它扩展了 Backbone Model 类型,并包括我们模型中属性的默认值哈希。
Vision.Commit = Backbone.Model.extend({
defaults: {
date : '',
ago: '',
message : '',
login : '',
avatar_url : ''
}
});
让我们为我们的 Commit 模型实现一个集合,名为 CommitList。我们定义一个名为 CommitList 的函数,它扩展了 Backbone Collection 类型。我们指定模型类型为 Vision.Commit。我们添加一个 url 方法,它使用 Web API 路由 /project/:id/commits 返回提交列表。当集合实例化时调用 initialize 方法;从这里我们分配 projectId。当执行获取操作时调用 parse 方法,它将解析响应。在这里我们将我们的 MongoDB _id 分配给 response.id。
Vision.CommitList = Backbone.Collection.extend({
projectId: '',
model: Vision.Commit,
url : function() {
return '/project/' + this.projectId + '/commits';
},
initialize: function(items, item) {
this.projectId = item.projectId;
},
parse: function( response ) {
response.id = response._id;
return response;
}
});
让我们为我们的Commit集合实现一个视图。我们定义了一个函数CommitListView,它扩展了 Backbone 的View类型,并为我们的提交列表添加了一个Commits数组。当视图实例化时调用initialize方法;从这里我们调用create并实例化一个新的CommitList,传递我们的Commits数组。我们调用refresh,它遍历Commits集合,通过调用render方法渲染视图。render方法使用 underscore 遍历名为collection.models的Commits集合,对每个commit调用add(item)。add方法实例化CommitView,传递一个Commit模型给它,然后通过$el将渲染的CommitView追加到 DOM 元素中,并返回视图。
Vision.CommitListView = Backbone.View.extend({
Commits: [],
initialize: function (args) {
if (!args.projectId) return;
this.Commits = args.commits || [];
this.$el.html('');
this.create(args);
this.refresh();
},
refresh: function(){
var me = this;
if (!this.Commits.length) {
this.collection.fetch({ success: function(){
me.render();
}});
}
},
create: function(args) {
this.collection = new Vision.CommitList(this.Commits, { projectId : args.projectId });
this.render();
},
render: function () {
_.each(this.collection.models, function (item) {
this.add(item);
}, this);
},
add: function (item) {
var commitView = new Vision.CommitView({ model: item });
this.$el.append(commitView.render().el);
return commitView;
}
});
我们继续添加一个用于单个提交项的视图。我们定义了一个函数CommitView,它扩展了 Backbone 的View类型,并为它添加了一个tagName,将其分配为li。这个标签将围绕我们的提交视图;我们的 DOM 元素是一个ul标签。我们包括viewTemplate并将其分配给预编译的 handlebars 模板./templates/commits.hbs。render方法渲染视图;我们传递commit模型到我们的viewTemplate,然后通过$el添加到我们的 DOM 元素中,并返回视图。
Vision.CommitView = Backbone.View.extend({
tagName: 'li',
className: 'media',
viewTemplate: visiontemplates['templates/commits.hbs'],
render: function () {
this.$el.html(this.viewTemplate(this.model.toJSON()));
return this;
}
});
让我们完善画面并更改我们的路由;我们在路由中添加了一个CommitListView,并在join方法中调用commits。commits方法实例化一个CommitListView,传递当前的projectId和提交列表。
CommitListView:'',
join : function(args){
this.repository(args);
this.commits(args);
},
commits : function(args){
this.commitListView = new Vision.CommitListView({ el: 'ul#commits-list', projectId: args.projectId, commits : args.commits});
},
当选择项目时,Vision 将显示提交列表。
功能:列出问题
让我们构建我们的问题列表。列表中的每个项仅由一个问题标题、项目名称、日期、发布者的用户名及其状态组成。
接下来是一个用于问题项的 HTML 模板./templates/issues.hbs:
<a class="pull-left" href="#">
<img class="media-object" src="img/{{avatar_url}}"style="width:64px; height:64px">
</a>
<div class="media-body">
<h4 class="media-heading">{{title}}</h4>
<small>{{repository}}</small>
<small>{{ago}}</small>
<br/><small>{{login}},<b>{{state}}</b></small>
</div>
让我们实现我们的Issue模型;我们定义了一个函数Issue,它扩展了 Backbone 的Model类型,并包括模型属性中的默认值哈希。
Vision.Issue = Backbone.Model.extend({
defaults: {
title : '',
state : '',
date : '',
ago: '',
login : '',
avatar_url : ''
}
});
让我们为我们的Issue模型实现一个名为IssueList的集合。我们定义了一个函数IssueList,它扩展了 Backbone 的Collection类型,并将模型类型指定为Vision.Issue。我们添加了一个url方法,它使用 Web API 路由/project/:id/issues来返回问题列表。当集合实例化时调用initialize方法;从这里我们分配选定的projectId。当执行获取操作时调用parse方法,这里我们将我们的 MongoDB _id分配给response.id。
Vision.IssueList = Backbone.Collection.extend({
projectId: '',
model: Vision.Issue,
url : function() {
return '/project/' + this.projectId + '/issues';
},
initialize: function(items, item) {
this.projectId = item.projectId;
},
parse: function( response ) {
response.id = response._id;
return response;
}
});
让我们为我们的Issue集合实现一个视图。我们定义一个函数,IssueListView,它扩展了 Backbone 的View类型,并为我们的问题列表添加了一个Issues数组。当view被实例化时,会调用initialize方法;从这里我们调用create并实例化一个新的IssueList,传递我们的Issues数组。然后我们调用refresh,它遍历Issues集合,通过调用render来渲染视图。render方法使用 underscore 遍历名为collection.models的Issues集合;并为每个问题调用add(item)。add方法实例化IssueView,并传递一个Issue模型。然后我们通过$el将渲染后的IssueView追加到我们的 DOM 元素中,并返回视图。
Vision.IssueListView = Backbone.View.extend({
Issues: [],
initialize: function (args) {
if (!args.projectId) return;
this.Issues = args.issues || [];
this.$el.html('');
this.create(args);
this.refresh();
},
create: function(args) {
this.collection = new Vision.IssueList(this.Issues, { projectId : args.projectId });
this.render();
},
refresh: function(){
var me = this;
if (!this.Issues.length) {
this.collection.fetch({ success: function(){
me.render();
}});
}
},
render: function () {
_.each(this.collection.models, function (item) {
this.add(item);
}, this);
},
add: function (item) {
var issueView = new Vision.IssueView({ model: item });
this.$el.append(issueView.render().el);
return issueView;
}
});
接下来,我们添加一个用于单个问题的视图。我们定义一个函数,IssueView,它扩展了 Backbone 的View类型,并为它添加了一个tagName,将其赋值为li;这个标签将围绕我们的IssueView函数。我们的 DOM 元素是一个ul标签。我们包含了一个viewTemplate并将其赋值给我们的预编译的 handlebars 模板templates/issues.hbs。render方法渲染视图;我们将issue模型传递给viewTemplate,然后通过$el将其添加到我们的 DOM 元素中,并返回视图。
Vision.IssueView = Backbone.View.extend({
tagName: 'li',
className: 'media',
viewTemplate: visiontemplates['templates/issues.hbs'],
render: function () {
this.$el.html(this.viewTemplate(this.model.toJSON()));
return this;
}
});
让我们完善这个画面并更改我们的路由器;我们在路由器中添加了一个issueListView,并在join方法中调用issues。issues方法实例化IssueListView,传递projectId和问题列表。
issueListView:'',
join : function(args){
this.repository(args);
this.issues(args);
this.commits(args);
},
issues : function(args){
this.issueListView = new Vision.IssueListView({ el: 'ul#issues-list', projectId: args.projectId, issues: args.issues});
},
选择项目时,愿景将现在显示问题列表。
概述
我们现在已经完成了客户端的第一部分。我们实现了一个项目列表视图,允许我们添加、更新和删除项目。我们还实现了一个仓库列表视图,显示了我们访问令牌的仓库列表;这些仓库可以被分配到项目中。我们还显示了我们项目中所有仓库的提交和问题列表。在下一章中,我们将使用 Socket.IO 显示提交和问题的实时列表。
第四章 实时通信
我们的应用程序开始成形。我们有一个项目列表和一个表单,允许我们添加、删除和更新项目。我们还能将这些仓库分配给这些项目,这样我们就可以查看项目中的所有仓库的问题/提交列表。本章将指导你完成客户端设置的下一阶段:使用 Redis 和 Socket.IO 实时显示项目仓库提交和问题列表。
我们理想情况下希望应用程序在 Socket.IO/Redis 关闭的情况下继续工作,这样应用程序就没有实时元素。我们将尝试考虑这些功能来实现这些特性。
使用 Redis 缓存数据
Redis 是一个极快、开源的内存键值存储。Redis 有一个有用的 Pub/Sub 机制,我们将使用它将消息推送到一个 Socket.IO 订阅者,该订阅者将向客户端发出事件。
访问此网站以下载和安装 Redis:redis.io/download。
一旦 Redis 安装完成,你可以使用以下命令启动它:
redis-server
为了启动 Redis 命令行界面,CLI 会发出以下命令:
redis-cli
可以从 CLI 发出以下命令:
-
要监控 Redis 上的活动:
monitor -
要清除 Redis 存储:
flushall -
要查看 Redis 中存储的所有键:
keys * -
要获取键的值:
get <key>
为了在我们的应用程序中使用 Redis,按照以下步骤安装 node-redis 客户端:
npm install redis --save
让我们通过更新 ./lib/config/*.json 配置文件来配置我们的应用程序以使用 Redis,如下所示:
"redis": {
"port": 6379
, "host": "localhost"
}
首先,我们创建一个简单的模块,Redis,它封装了 Redis 连接 ./lib/cache/redis.js。我们首先导入 redis 模块。我们定义一个 Redis 模块,它调用 createClient 来创建 Redis 客户端。
我们从前面拉取 Redis 配置数据:
var redis = require('redis')
, config = require('../configuration');
function Redis() {
this.port = config.get("redis:port");
this.host = config.get("redis:host");
this.password = config.get("redis:password");
this.client = redis.createClient(this.port, this.host);
if (this.password) this.client.auth(this.password, function() {});
}
module.exports = Redis;
让我们扩展我们的 Redis 模块并创建一个 Publisher 模块,该模块将使用 Redis Pub/Sub 功能发布消息,./lib/cache/publisher/index.js。我们首先导入我们的 Redis 模块,并使用 util 模块将 Publisher 模块扩展到 Redis 模块中。然后我们定义我们的 Publisher 模块,它包括一个 save 函数,该函数将对象作为字符串保存到 Redis 中,以及一个 publish 函数,该函数将消息发布到 Redis 中。
Publisher 模块的定义如下所示:
var Redis = require('../../cache/redis')
, util = require('util');
util.inherits(Publisher, Redis);
function Publisher() {
Redis.apply(this, arguments);
};
Redis.prototype.save = function(key, items) {
this.client.set(key, JSON.stringify(items));
};
Redis.prototype.publish = function(key, items) {
this.client.publish(key, JSON.stringify(items));
};
module.exports = Publisher;
接下来,我们扩展我们的 Redis 模块并创建一个 Subscribe 模块 ./lib/cache/subscriber/index.js,它消费发布的消息。我们首先导入我们的 Redis 模块,并使用 util 模块将 Subscriber 模块扩展到 Redis 模块中。然后我们定义我们的 Subscriber 模块,它包括一个 subscribe 函数。这允许用户订阅 key 上的消息:
var Redis = require('../../cache/redis')
, util = require('util');
util.inherits(Subscriber, Redis);
function Subscriber() {
Redis.apply(this, arguments);
};
Subscriber.prototype.subscribe = function(key) {
this.client.subscribe(key);
};
module.exports = Subscriber;
填充 Redis
./lib/cache/populate.js脚本使用我们前面的模块将新的提交/问题填充到 Redis 存储中。我们将在本章后面演示如何安排此脚本。我们首先导入Publisher模块,并使用util.inherits来扩展Publisher模块,添加一个Populate函数,使我们的Populate模块具有发布消息的能力。
我们然后定义Populate函数并添加一个run函数,该函数从 MongoDB 获取所有项目。我们使用async.each遍历每个项目,使用项目的user和token来实例化一个GitHubRepo模块。然后我们调用git.commits,传递一个repositories列表;返回的是按顺序排列的最近 10 个提交的列表。我们使用project._id作为键将响应保存到 Redis 中。然后我们通过publish函数发布project._id和commits,以激活刷新。然后我们重复整个过程以处理issues。
var async = require('async')
, _ = require('underscore')
, util = require('util')
, db = require('../db')
, Publisher = require('../cache/publisher')
, GitHubRepo = require('../github')
, Project = require('../models').model('Project');
util.inherits(Populate, Publisher);
function Populate() {
Publisher.apply(this, arguments);
};
Populate.prototype.run = function(callback) {
var me = this;
Project.find({}, function(error, projects) {
if (error) callback();
if (projects == null) callback();
async.each(projects, function(project, callback) {
var git = new GitHubRepo(project.token, project.user);
git.commits(project.repositories, function(error, commits) {
if (error || !commits) callback();
me.save('commits:' + project._id, commits);
me.publish('commits', { projectId : project._id, commits : commits});
git.issues(project.repositories, function(error, issues) {
if (error || !issues) callback();
me.save('issues' + project._id, issues);
me.publish('issues', { projectId : project._id, issues : issues});
});
});
callback(error);
}
, function(error) {
callback(error);
});
});
};
module.exports = Populate;
Socket.IO
Socket.IO 是一个实时应用程序框架,它允许浏览器和服务器之间进行跨浏览器的实时通信。
由于浏览器和服务器对新兴的 WebSocket 标准的支持不足,我们无法轻松地在浏览器之间实现实时通信。为了实现这一点,Socket.IO 支持包括 WebSockets、长轮询、XHR 和 flashsockets 在内的多种传输协议,这些协议作为旧浏览器的后备机制。不支持 WebSockets 的浏览器将简单地回退到它们支持的传输协议。
Socket.IO 由两部分组成:服务器端模块和客户端脚本。为了使我们的应用程序支持双向全双工通信,两部分都需要安装。让我们通过 NPM 安装服务器部分:
npm install socket.io --save
让我们通过更新我们的./config/*.json配置文件来配置我们的应用程序使用 Socket.IO,如下所示:
"sockets": {
"loglevel": 3
, "pollingduration": 10
, "browserclientminification" : false
, "browserclientetag" : false
, "browserclientgzip" : false
}
下一步是将 Socket.IO 连接到 Express。让我们创建并配置一个典型的 Socket.IO 服务器:./lib/socket/index.js。我们定义我们的Socket模块,它接受一个参数:server。我们引入socket.io模块并创建一个新的 Socket.IO 服务器,将我们的 Express 启用 HTTP 服务器传递给它。然后我们通过设置合理的日志级别、传输协议和轮询持续时间来配置我们的 Socket.IO 服务器,这些值在之前的配置文件中已定义,并返回 Socket.IO 服务器。
var config = require('../configuration');
function Socket(server) {
var socketio = require('socket.io').listen(server);
if (config.get('sockets:browserclientminification'))
socketio.enable('browser client minification');
if (config.get('sockets:browserclientetag'))
socketio.enable('browser client etag');
if (config.get('sockets:browserclientgzip'))
socketio.enable('browser client gzip');
socketio.set("polling duration",
config.get('sockets:pollingduration'));
socketio.set('log level', config.get('sockets:loglevel'));
socketio.set('transports', [
'websocket'
, 'flashsocket'
, 'htmlfile'
, 'xhr-polling'
, 'jsonp-polling'
]);
return socketio;
};
module.exports = Socket;
设置日志级别对调试很有用。Socket.IO 支持以下几种:
-
0: 错误 -
1: 警告 -
2: 信息 -
3: 调试,默认为3
关于配置 Socket.IO 的更多信息可以在github.com/LearnBoost/Socket.IO/wiki/Configuring-Socket.IO找到。
现在我们使用我们的 Socket.IO 服务器并创建一个 Socket.IO 处理器./lib/socket/handler.js。
我们首先导入Socket模块,实例化它,并传递一个启用了 Express 的httpServer参数。我们创建一个 Redis Subscriber模块,并定义一个接受httpServer作为输入的SocketHandler函数。我们为连接事件设置一个 Socket.IO 处理程序。当准备就绪时,这将返回已连接的 socket。
然后,我们订阅两个 Redis 频道——issues和commits——并为新消息事件定义一个 Redis 处理程序。此处理程序将频道和消息广播到监听由message.projectId定义的频道的客户端。
我们定义一个 Socket.IO subscribe处理程序,允许客户端加入或订阅给定项目的事件。我们还定义了一个 Socket.IO unsubscribe处理程序,允许客户端离开或取消订阅给定项目的事件。我们还在 Socket.IO 上定义了一个error处理程序,将任何错误记录到logger:
var http = require('http')
, logger = require("../logger")
, Socket = require('../socket')
, Subscriber = require('../cache/subscriber')
, subscriber = new Subscriber();
function SocketHandler(httpServer) {
var socketIo = new Socket(httpServer)
socketIo.sockets.on('connection', function(socket) {
subscriber.subscribe("issues");
subscriber.subscribe("commits");
subscriber.client.on("message", function (channel, message) {
socket.broadcast.to(message.projectId).emit(channel, JSON.parse(message));
});
socket.on('subscribe', function (data) {
socket.join(data.channel);
});
socket.on('unsubscribe', function () {
var rooms = socketIo.sockets.manager.roomClients[socket.id];
for (var room in rooms) {
if (room.length > 0) {
room = room.substr(1);
socket.leave(room);
}
}
});
});
socketIo.sockets.on('error', function() {
logger.error(arguments);
});
};
module.exports = SocketHandler;
现在我们可以将 Socket.IO 连接到我们的./lib/express/index.js Express 服务器。让我们导入SocketHandler模块,将其传递给一个名为httpServer的 Express 服务器:
, SocketHandler = require('../socket/handler')
..
var httpServer = http.createServer(app).listen(app.get('port'))
socketHandler = new SocketHandler(httpServer);
客户端的 Socket.IO
为了显示这些 Socket.IO 发布的消息,我们需要对客户端进行一些更改。让我们使用 bower 安装 Socket.IO 客户端组件:
bower install socketio-client
让我们对./lib/express/index.js Express 服务器进行一次性的更改,并简化我们的socket.io-client位置,使用static中间件:
app.use('/sockets', express.static('public/components/socket.io-client/dist/'));
我们现在将 Socket.IO 客户端脚本添加到./views/index.html:
<script src="img/socket.io.js"></script>
现在我们将 Socket.IO 集成到我们的 backbone 组件中。让我们更新我们的Backbone.js Router。现在,路由initialise方法接受socket作为参数,并包含两个 Socket.IO 事件处理程序:一个用于问题,调用问题方法;一个用于提交,调用提交方法。join方法现在将发出一个 Socket.IO unsubscribe事件,取消用户对任何当前已订阅项目的订阅。然后它将发出一个 Socket.IO subscribe事件,将用户订阅到新选择的项目。所选项目通过args参数传递给join方法。
Vision.Router = Backbone.Router.extend({
projectListView : '',
repositoryListView:'',
issueListView:'',
commitListView:'',
socket: null,
routes: {
'' : 'index',
'add' : 'add'
},
initialize : function(socket) {
this.socket = socket;
this.project();
this.listenTo(this.projectListView, 'join', this.join);
this.socket.on('issues', this.issues);
this.socket.on('commits', this.commits);
},
join : function(args) {
this.repository(args);
this.issues(args);
this.commits(args);
this.socket.emit('unsubscribe');
this.socket.emit('subscribe', {channel : args.projectId});
},
project : function() {
this.projectListView = new Vision.ProjectListView();
},
repository : function(args) {
this.repositoryListView = new Vision.RepositoryListView(
{el: 'ul#repository-list', projectId: args.projectId,
editMode: args.editMode });
},
issues : function(args) {
this.issueListView = new Vision.IssueListView(
{el: 'ul#issues-list', projectId: args.projectId,
issues : args.issues});
},
commits : function(args) {
this.commitListView = new Vision.CommitListView(
{ el: 'ul#commits-list', projectId: args.projectId,
commits : args.commits});
},
index : function(){
this.projectListView.render();
},
add : function(){
this.projectListView.showForm();
}
});
我们现在需要将我们的 Socket.IO 客户端实例传递给我们的Router。我们调用io.connect,创建一个 socket,并将其传递给我们的Router。
Vision.Application = function() {
this.start = function() {
var socketio = io.connect('/');
var router = new Vision.Router(socketio);
Backbone.history.start();
router.navigate('index', true);
}
};
调度 Redis 人口统计
剩下的唯一事情是创建一个调度器,轮询我们的 Redis populate脚本,./populate.js。
首先,让我们通过 NPM 安装一个名为node-schedule的调度程序:
npm install node-schedule --save
我们首先导入node-schedule,它允许我们进行类似于 cron 的调度。我们使用*/5每五分钟调用schedule.scheduleJob;然而,它也会在脚本启动时立即运行。然后我们调用populate.run来启动人口统计:
var schedule = require('node-schedule')
, logger = require('./lib/logger')
, Populate = require('./lib/cache/populate')
, populate = new Populate();
schedule.scheduleJob('*/5 * * * *', function() {
populate.run(function(err) {
if (err) logger.error('Redis Population error', err);
if (!err) logger.info('Redis Population complete');
});
});
为了使用实时更新运行应用程序,打开一个新的终端并运行以下命令:
npm start
现在,打开另一个终端来运行 Redis 人口统计脚本。
node populate.js
我们已将之前的脚本配置为每五分钟运行一次,因此请前往您的 GitHub 项目仓库添加一些问题/提交,以便查看结果。
摘要
Socket.IO 和 Redis 是强大的工具。我们几乎只是触及了它们所能实现的功能的表面。在本书的后续章节中,我们将重新探讨 Redis 和 Socket.IO,因为 Redis 也被用于扩展 Express 会话和 Socket.IOs 的 Pub/Sub 机制。
下一章将专注于在我们通过 GitHub 使用 Passport 实现身份验证策略并添加 SSL 支持时,如何确保我们的应用程序的安全。
第五章 安全
在本章中,我们将使用 GitHub 账户和OAuth 2.0令牌来认证用户。这将允许我们保护网站并支持多个用户;目前我们有一个硬编码的令牌和用户。我们还将向我们的网站添加 HTTPS,并探索一些我们可以用来保护其他常见安全漏洞的模块。
设置 Passport
Passport 是 Node 的认证中间件,通过插件支持多种认证策略,包括基本认证、OAuth 和 OAuth 2.。Passport 通过定义一个用于认证请求的路由中间件来工作。
让我们安装 Passport:
npm install passport --save
Passport 不包含 GitHub 策略;为此我们需要安装passport-github;这是一个使用 OAuth 2.0 API 进行 GitHub 身份验证的策略:
npm install passport-github --save
使用 Cucumber 和 Zombie.js 进行验收测试
OAuth 认证使用回调机制;这对于使用 SuperTest 等集成测试工具进行测试来说很麻烦;我们需要一点更端到端的东西。
Cucumber 允许团队使用一种简单的纯文本语言Gherkin来描述软件行为。描述此行为的流程有助于开发;输出作为文档,可以作为一系列测试自动运行。让我们安装 cucumber:
npm install -g cucumber
Zombie.js 是一个用于进行无头全栈测试的简单、轻量级框架。让我们安装Zombie.js:
npm install zombie --save-dev
让我们使用 Grunt 任务自动化运行 Cucumber:
npm install grunt-cucumber --save-dev
将以下内容添加到我们的gruntfile ./gruntfile.js中。files部分定义了我们的特征文件的位置,而options:steps定义了我们的步骤定义文件的位置:
cucumberjs: {
files: 'features',
options: {
steps: "features/step_definitions",format: "pretty"
}
},
功能:认证
As a vision user
I want to be able to authenticate via Github
So that I can view project activity
让我们创建我们的第一个特征文件./features/authentication.feature。以下特征文件包含一个Feature部分,对于敏捷开发人员来说,这将定义故事及其对业务的价值,以及一系列场景。我们的验收标准;用 Gherkin 语言编写。
以下Authenticate功能包含两个场景,包括一个用于登录,标题为用户成功登录,以及一个用于登出,标题为用户成功登出:
Feature: Authentication
As a vision user
I want to be able to authenticate via Github
So that I can view project activity
Scenario: User logs in successfully
Given I have a GitHub Account
When I click the GitHub authentication button
Then I should be logged in
And I should see my name and a logout link
Scenario: User logs out successfully
Given I am logged in to Vision
When I click the logout button
Then I should see the GitHub login button
让我们使用我们的 Grunt 任务运行 Cucumber:
grunt cucumberjs
这将生成以下输出:
2 scenarios (2 undefined)
7 steps (7 undefined)
You can implement step definitions for undefined steps with these snippets:
this.Given(/^I have a GitHub Account$/, function(callback) {
callback.pending();
});
this.When(/^I click the GitHub authentication button$/, function(callback) {
callback.pending();
});
this.Then(/^I should be logged in$/, function(callback) {
callback.pending();
});
this.Then(/^I should see my name and a logout link$/, function(callback) {
callback.pending();
});
this.Given(/^I am logged in to Vision$/, function(callback) {
callback.pending();
});
this.When(/^I click the logout button$/, function(callback) {
callback.pending();
});
this.Then(/^I should see the GitHub login button$/, function(callback) {
callback.pending();
});
从前面的输出中,你可以看到 Cucumber 生成了一系列设置为pending的存根步骤。这些步骤代表我们在./features/authentication/authentication.feature特征文件中定义的Given、When和Then场景。
我们可以使用这些步骤来实现我们的 Cucumber 测试。让我们创建一个步骤定义文件./features/step_definitions/authentication/authenticate.js:
var steps = function() {
var Given = When = Then = this.defineStep;
..add generated steps here
};
module.exports = steps;
让我们使用我们的 Grunt 任务运行 Cucumber:
grunt cucumberjs
我们得到以下输出:
2 scenarios (2 pending)
7 steps (2 pending, 5 skipped)
现在,我们已经准备好开始实现我们的第一个场景。
场景:用户成功登录
让我们开始实现这个场景。首先,我们需要一个 GitHub clientId和clientSecret。访问您的 GitHub 账户,点击设置然后应用程序,再次点击注册新应用程序。通过添加homepage URL 和callback URL(与我们的主页相同)来填写表单,并将生成一个clientId和clientSecret。
让我们将这些详细信息添加到我们的配置文件./config/*.json中:
"auth": {
"homepage": "http://127.0.0.1:3000"
, "callback": "http://127.0.0.1:3000/auth/github/callback"
, "clientId": "5bb691b4ebb5417f4ab9"
, "clientSecret": "15310740929666983d52808dda32417d733791d0"
}
让我们移除在第二章中设置的临时登录,构建 Web API,并移除以下行及其所有相关代码./lib/routes/project.js:
, login = require('../../test/login');
我们现在准备好实现我们的 GitHub 策略./lib/github/authentication.js。我们首先定义一个函数GitHubAuth;我们导入passport和passport-github模块。我们实例化一个GitHubStrategy,将其添加到passport中,并传递一个clientID、clientSecret、一个callbackUrl和一个verify函数(所有 passport 策略都需要一个验证函数),当 GitHub 进行身份验证并返回一个accessToken、refreshToken和一个profile时,该函数将被调用。
在这个验证函数内部,我们有通过从callback函数传递一个false来拒绝用户的选择。我们将接受任何拥有 GitHub 访问令牌的人;因此只需返回一个用户资料;我们使用 GitHub 传递给我们的资料创建它。在验证函数内部,我们实例化一个GitHubRepo并调用updateTokens,这将更新它们的访问令牌,以便用于我们的 Redis 缓存填充。
我们的应用程序将支持用户会话,因此我们在passport模块中添加两个函数,包括serializeUser和deserializeUser,这些函数将 GitHub 用户资料序列化和反序列化到用户会话中:
var async = require('async')
, GitHubRepo = require('../github')
, config = require('../configuration');
function GitHubAuth() {
this.passport = require('passport')
var GitHubStrategy = require('passport-github').Strategy;
this.passport.use(new GitHubStrategy({
clientID : config.get('auth:clientId'),
clientSecret : config.get('auth:clientSecret'),
callbackURL : config.get('auth:callback')
},
function(accessToken, refreshToken, profile, done) {
var user = {
id : profile.username,
displayName : profile.displayName,
token : accessToken
};
var git = new GitHubRepo(user.token, user.id);
git.updateTokens(function(){
process.nextTick(function () {
return done(null, user);
});
});
};
));
this.passport.serializeUser(function(user, done) {
done(null, user);
});
this.passport.deserializeUser(function(user, done) {
done(null, user);
});
};
module.exports = new GitHubAuth();
让我们在GitHubRepo中添加一个updateTokens函数,该函数获取所有用户的工程并通过async.each遍历每个工程来更新其令牌:
GitHubRepo.prototype.updateTokens = function(done) {
var query = { "user" : this.user };
Project.find(query, function(error, projects) {
if (error) return done();
if (projects == null) done();
async.each(projects, function(project, callback) {
project.token = this.token;
project.save(function(error, p) {
callback();
});
}
, function(error) {
done();
});
});
};
让我们在配置文件./config/*.json中添加配置,以支持 Express 会话:
"session": {
"secret": "th1$1$a$ecret"
, "maxAge": null
, "secure": true
, "httpOnly": true
}
让我们将 GitHub 策略连接到我们的 Express 服务器:./lib/express/index.js。我们做的第一个更改是包含我们新的 GitHub authentication策略:
var gitHubAuth = require('../github/authentication')
我们创建一个cookieParser中间件,并将其包含在bodyParser中间件之前,这将解析 cookie 头字段并填充req.cookies。我们传递一个secret;这是一个用于创建签名 cookie 的字符串,以便检测修改过的 cookie:
var cookieParser = express.
cookieParser(config.get('session:secret'));
app.use(cookieParser);
应用程序将需要持久登录会话,因此我们将connect session中间件包含在我们的 Express 服务器中,以提供会话支持。我们将使用sessionStore,这是一个内存中的会话存储。我们传递一个secret和一个用于 cookie 的maxAge值(一个 null 值将在关闭浏览器时使会话过期),httpOnly(不允许客户端 JavaScript 访问 cookie;XSS 攻击),以及secure(仅通过 HTTPS 发送 cookie):
app.use(express.bodyParser());
var sessionStore = new express.session.MemoryStore();
app.use(express.session({ store: sessionStore,
secret: config.get('session:secret'),
cookie: { secure: config.get('session:secure'),
httpOnly: config.get('session:httpOnly'),
maxAge: config.get('session:maxAge') }}));
Passport 模块要求我们调用 passport.initialize() 以初始化 passport,并且为了提供会话支持,我们还必须调用 passport.session() 中间件;我们将两者都添加到我们的 Express 服务器中:
app.use(gitHubAuth.passport.initialize());
app.use(gitHubAuth.passport.session());
我们现在定义我们的 Express 服务器上的第一个两个路由;两者都使用 GitHub 策略。第一个路由是登录路由 /auth/github;访问此路由将重定向您到 GitHub 并尝试进行身份验证。如果您未登录到 GitHub,您将被要求登录。如果您是第一次这样做,您将收到提示。您将被询问是否希望授予 Vision 访问权限。第二个路由是 GitHub 在身份验证完成后将回调的路由:
app.get('/auth/github',gitHubAuth.passport.authenticate('github'),routes.auth.login);
app.get('/auth/github/callback',gitHubAuth.passport.authenticate('github',{ failureRedirect: '/' }), routes.auth.callback);
我们已经使用 GitHub passport 策略配置了我们的 Express 服务器。现在让我们向 ./lib/routes/auth.js 中的路由添加两个缺失的路由:一个用于登录,另一个用于回调,正如之前所描述的:
exports.callback = function(req, res) {
logger.info('Request.' + req.url);
res.redirect('/');
};
exports.login = function(req, res){
logger.info('Request.' + req.url);
};
为了模拟我们的项目表单包含一个 user 和 token 的主体,我们将添加一个中间件,该中间件简单地为此数据添加到已认证用户的表单中。我们可以通过使用 app.all 简单地添加 projectForm.addToken 中间件到所有我们的路由中,这将应用此中间件到所有后续的路由。
让我们对我们的 Express 服务器进行进一步更改:./lib/express/index.js,并通过移除涉及它的所有 require 语句和使用 require-directory 与 ./lib/middleware/index.js 文件来清理我们的中间件,就像我们对路由所做的那样。现在我们可以在所有需要身份验证的路由之上添加此 projectForm:
, middleware = require('../middleware')
app.all('*', middleware.projectForm.addToken);
.. all routes below
让我们在 ./lib/middleware/projectForm.js 中创建 projectForm.addToken 中间件。AddToken 中间件检查请求是否通过 req.isAuthenticated 进行了身份验证;我们将 user 和 token 添加到请求中:
exports.addToken = function(req, res, next){
if (req.isAuthenticated()) {
req.body.user = req.session.passport.user.id;
req.body.token = req.session.passport.user.token;
req.user = req.session.passport.user;
};
next();
}
现在我们已经设置了身份验证,让我们从 ./lib/routes/home.js 中移除硬编码的用户:
exports.index = function(req, res){
var model = {
title: 'vision.',
description: 'a project based dashboard for github',
author: 'airasoul',
user: req.isAuthenticated() ? req.user.displayName : ''
};
res.render('index', model);
};
现在我们点击页眉中的 GitHub 标志,我们将被重定向到 GitHub,它将要求您登录。一旦您登录到 GitHub,您必须授权我们的 Vision 应用程序;然而,未来的登录尝试将不需要您授权 Vision。
让我们使用 Zombie.js 完成我们的 Cucumber 登录步骤。./features/step_definitions/authentication/authenticate.js。首先,我们包含 zombie 并定义一个 steps 函数。然后,我们将 silent 和 debug 设置为启用 Zombie.js 调试输出。我们定义 Given = When = Then 作为 Cucumber 步骤,并添加一个 Before 步骤,该步骤在每个测试之前运行。从这里我们实例化一个 zombie Browser:
var Browser = require('zombie')
, assert = require('assert')
S = require('string')
config = require('../../../lib/configuration');
var steps = function() {
var silent = false;
var debug = false;
var Given = When = Then = this.defineStep;
var browser = null;
var me = this;
this.Before(function(callback) {
browser = new Browser();
browser.setMaxListeners(20);
setTimeout(callback(), 5000);
});
};
module.exports = steps;
步骤 I have a GitHub Account 使用 zombie 浏览器访问 GitHub 登录页面,并等待页面加载并填写登录详细信息;然后我们点击登录按钮:
this.Given(/^I have a GitHub Account$/, function(callback) {browser.visit('https://github.com/login',{silent: silent, debug: debug});
browser.wait(function(){
browser
.fill('login', '#LOGIN#')
.fill('password', '#PASSWORD#')
.pressButton('Sign in', function() {
callback();
});
});
});
步骤我点击 GitHub 认证按钮使用 Zombie 浏览器访问 GitHub 登录页面,等待页面加载并填写登录详细信息;然后我们点击登录按钮:
this.When(/^I click the GitHub authentication button$/, function(callback) {
browser.visit(config.get('auth:homepage'),
{silent: silent, debug: debug});
browser.wait(function(){
browser
.clickLink('#login', function() {
callback();
});
});
});
步骤我应该已登录使用 Zombie 浏览器访问 GitHub 登录页面,等待页面加载并填写登录详细信息;然后我们点击登录按钮:
this.Then(/^I should be logged in$/, function(callback) {
assert.ok(browser.success);
callback();
});
步骤我应看到我的名字和一个登出链接使用 Zombie 浏览器访问 GitHub 登录页面,等待页面加载并填写登录详细信息;然后我们点击登录按钮:
this.Then(/^I should see my name and a logout link$/, function(callback) {
assert.equal(browser.text('#welcome'),'welcome Andrew Keig, click here to sign out');
callback();
});
场景:用户成功登出
Given I am logged in to Vision
When I click the logout button
Then I should see the GitHub login button
让我们在 Express 服务器中添加一个登出路由:./lib/express/index.js:
app.get('/logout', routes.auth.logout);
现在将路由添加到我们的路由中:./lib/routes/auth.js:
exports.logout = function(req, res){
logger.info('Request.' + req.url);
req.logout();
res.redirect('/');
};
让我们在./features/step_definitions/authentication/authenticate.js中使用 Zombie.js 完成我们的登出步骤。
步骤我已登录到 Vision使用 Zombie 浏览器访问 Vision 主页,等待页面加载,并点击登录链接:
this.Given(/^I am logged in to Vision$/, function(callback) {
browser.visit(config.get('auth:homepage'),{silent: silent, debug: debug});
browser.wait(function(){
browser
.clickLink('#login', function() {
callback();
});
});
});
步骤我点击登出按钮使用 Zombie 浏览器访问 Vision 主页,等待页面加载,并点击登出链接:
this.When(/^I click the logout button$/, function(callback) {
browser.visit(config.get('auth:homepage'),{silent: silent, debug: debug});
browser.wait(function(){
browser
.clickLink('#logout', function(err) {
callback();
});
});
});
步骤我应该看到 GitHub 登录按钮检查浏览器响应是否返回成功,然后检查 GitHub 登录链接是否可访问:
this.Then(/^I should see the GitHub login button$/, function(callback) {
assert.ok(browser.success);
var containsLogin = S(browser.html('#login')).contains('vision/github.png')
assert.equal(true, containsLogin);
callback();
});
使用 HTTPS 保护我们的站点
为了使我们的站点安全,我们将整个应用程序运行在 HTTPS 下。我们需要两个文件:一个 PEM 编码的 SSL 证书./lib/secure/cert.pem和一个私钥./lib/secure/key.pem。为了创建 SSL 证书,我们首先需要生成一个私钥和证书签名请求(CSR)。出于开发目的,我们将创建一个自签名证书。运行以下命令:
cd ../vision/lib/secure
openssl req -newkey rsa:2048 -new -nodes -x509 -days 3650 -keyout key.pem -out cert.pem
在运行第二个命令后,你将进入一个交互式提示,以生成 2048 位的 RSA 私钥和证书签名请求(CSR)。你需要输入包括地址详情、通用名称或域名、公司详情和电子邮件地址在内的各种信息。
让我们添加一个模块,./lib/express/server.js,它将基于我们刚刚创建的key/cert创建一个 HTTP 服务器。我们导入https模块,从磁盘读取key和cert文件,并将它们添加到选项对象中。然后使用https模块,我们创建一个服务器,传入这些选项:
var fs = require('fs')
, https = require('https');
function Server(app){
var httpsOptions = {
key: fs.readFileSync('./lib/secure/key.pem'),cert: fs.readFileSync('./lib/secure/cert.pem')
};
return https.createServer(httpsOptions,app).listen(app.get('port'));
}
module.exports = Server;
让我们在 Express 服务器./lib/express/index.js中使用server;删除创建我们的 HTTP 服务器的行:
var httpServer = http.createServer(app).listen(app.get('port'));
用对新的 HTTPS 服务器的调用替换它:
var server = require('./server')(app);
现在我们需要将所有对http://127.0.0.1:3000;端口 3000 的引用替换为https://127.0.0.1:8443;端口 8443。我们的配置文件包含两个引用:
"auth": {
"homepage": "https://127.0.0.1:8443"
, "callback": "https://127.0.0.1:8443/auth/github/callback"
, "clientId": "5bb691b4ebb5417f4ab9"
, "clientSecret": "15310740929666983d52808dda32417d733791d0"
},
在我们的 backbone.js 脚本 ./public/components/vision.js 中,我们有一个进一步的参考。当我们连接到我们的 Socket.IO 服务器时,我们传递一个 URL 127.0.0.1:3000。在这里,我们进行了另一个重要的更改;我们连接到 Socket.IO 时传递一个选项对象,设置 secure: true, port: '8443':
Vision.Application = function(){
this.start = function(){
var socketio = io.connect('/', {secure: true, port: '8443'});
var router = new Vision.Router(socketio);
Backbone.history.start();
router.navigate('index', true);
}
};
与 Socket.IO 共享 Express 会话
现在我们已经设置了会话支持,我们可以通过 Socket.IO 共享会话,这样我们就可以根据这些会话数据接受或拒绝连接。Express 和 Socket.IO 使用握手机制来完成此操作。当客户端连接到服务器时,握手被启动,它包括在 Socket.IO 上执行授权函数。在这里,检查与握手请求关联的 cookie,如果无效则拒绝。让我们安装 session.socket.io;这是一个封装了此过程的模块:
npm install session.socket.io --save
首先,让我们更改我们的 Express 服务器 ./lib/express/index.js,并将 sessionStore 和 cookieParser 传递给我们的 SocketHandler 模块:
var socketHandler = new SocketHandler(httpServer, sessionStore, cookieParser);
SocketHandler 模块现在接受参数 httpServer、sessionStore 和 cookieParser。SocketHandler 现在将实例化一个 SessionSockets 模块,传递 socketIo、sessionStore 模块和 cookieParser。我们将 connection 事件更改为监听 SessionSockets 模块而不是 socket.Io 模块,这样我们就可以访问 session。现在,在 subscribe 事件内部,我们可以检查以确保 session.passport.user 是有效的。我们调用 session.touch,它更新会话的 maxAge 和 lastAccess 属性:
function SocketHandler(httpServer, sessionStore, cookieParser) {
var socketIo = new Socket(httpServer)
var sessionSockets = new SessionSockets(socketIo, sessionStore, cookieParser);
sessionSockets.on('connection', function(err, socket, session) {
subscriber.subscribe("issues");
subscriber.subscribe("commits");
subscriber.client.on("message", function (channel, message) {
socket.broadcast.to(message.projectId)
.emit(channel, JSON.parse(message));
});
socket.on('subscribe', function (data) {
var user = session ? session.passport.user : null;
if (!user) return;
socket.join(data.channel);
session.touch();
});
});
sessionSockets.on('error', function() {
logger.error(arguments);
});
};
module.exports = SocketHandler;
跨站请求伪造
跨站请求伪造 (CSRF) 是一种攻击,它欺骗受害者在一个他们已经认证的 Web 应用程序上执行恶意操作。Connect/Express 随包装提供了跨站请求伪造保护中间件。此中间件允许我们确保对更改状态的请求来自有效源。CSRF 中间件创建一个存储在请求会话中的令牌作为 _csrf。然后,我们的 Express 服务器请求需要通过头部字段 X-CSRF-Token 传递令牌。
让我们创建一个安全模块 ./lib/security/index.js,该模块将 csrf 中间件添加到我们的应用程序中。我们定义一个函数,Security,它接受一个 Express app 作为参数,并在 TEST 或 COVERAGE 模式下移除中间件。
var express = require('express');
function Security(app) {
if (process.env['NODE_ENV'] === "TEST" ||process.env['NODE_ENV'] === "COVERAGE") return;
app.use(express.csrf());
};
module.exports = Security;
让我们更改我们的 Express 服务器 ./lib/express/index.js。crsf 中间件需要会话支持,所以我们添加以下行在 session 和 passport 中间件下方:
require('../security')(app);
由于我们使用的是 backbone.js,它底层使用 jQuery 来发送 AJAX 请求,因此我们需要更改我们的 backbone 代码 ./public/components/vision/vision.js。我们现在将覆盖 Backbone.sync 函数,以便所有通过它的请求都在头部传递 X-CSRF-Token。X-CSRF-Token 从主页面中的 meta 标签中提取:
Backbone.sync = (function(original) {
return function(method, model, options) {
options.beforeSend = function(xhr) {
var token = $("meta[name='csrf-token']").attr('content');
xhr.setRequestHeader('X-CSRF-Token', token);
};
original(method, model, options);
};
})(Backbone.sync);
现在,我们需要通过主页面路由将 X-CSRF-Token 传递给我们的主页面。令牌存储在请求会话中作为 _csrf,在以下代码中我们将令牌添加到我们的视图对象的 csrftoken 中:
exports.index = function(req, res){
var model = {
title: 'vision.',
description: 'a project based dashboard for github',
author: 'airasoul',
user: req.isAuthenticated() ? req.user.displayName : '',
csrftoken: req.session._csrf
};
res.render('index', model);
};
csrftoken 在我们的主页面中以名为 csrf-token 的 meta 标签中渲染;骨干同步方法将从此 meta 标签中获取它:
<meta name="csrf-token" content="{{csrftoken}}">
通过 HTTP 头和 helmet 提高安全性
Helmet 是一系列中间件,用于为 Express 实现各种安全头;有关 helmet 的更多信息,请访问 npmjs.org/package/helmet。
Helmet 支持以下功能:
-
csp (内容安全策略)
-
HSTS (HTTP Strict Transport Security)
-
xframe (X-FRAME-OPTIONS)
-
iexss (X-XSS-PROTECTION for IE8+)
-
contentTypeOptions (X-Content-Type-Options nosniff)
-
cacheControl (Cache-Control no-store, no-cache)
让我们扩展我们的安全模块 ./lib/security/index.js,并为之前的问题添加 helmet 安全:
var express = require('express')
, helmet = require('helmet');
function Security(app) {
if (process.env['NODE_ENV'] === "TEST" ||
process.env['NODE_ENV'] === "COVERAGE") return;
app.use(helmet.xframe());
app.use(helmet.hsts());
app.use(helmet.iexss());
app.use(helmet.contentTypeOptions());
app.use(helmet.cacheControl());
app.use(express.csrf());
};
module.exports = Security;
摘要
默认情况下,Express 使用内存中的会话。在下一章中,我们将我们的会话移动到 Redis。我们还将配置 Socket.IO 以使用 Redis,并探索一些其他有趣的扩展 Express 的方法。
第六章。扩展
在本章中,我们将探讨扩展 Express 的选项。我们的当前解决方案无法扩展到单个进程/服务器;引入一些简单的更改将允许我们水平扩展和垂直扩展 Vision。我们还将查看一种替代的 Web 架构,并检查解耦我们的应用程序如何改进我们的应用程序并帮助我们进一步扩展 Express。
使用 Redis 扩展 Express 会话
将 NODE_ENV 设置为 production 运行我们的 Express 应用程序将输出以下消息:
NODE_ENV=production npm start
Warning: connection.session() MemoryStore is not
designed for a production environment, as it will leak
memory, and obviously only work within a single process.
Express 的默认会话存储是一个内存存储;将会话绑定到单个进程无法扩展。
此外,如果服务器崩溃,我们将丢失那些会话。如果我们想将 Express 应用程序扩展到多个服务器,我们需要一个与 Express 应用程序解耦的内存存储。Express 有几个可选的存储方案;在这里,我们将使用 connect-redis 通过 Redis。让我们配置视觉应用程序以使用 Redis 作为会话存储。
npm install connect-redis ––save
现在,我们将对 Express 服务器 ./lib/express/index.js 进行一些更改。我们首先引入我们之前创建的 Redis 模块,该模块配置并连接到 Redis 服务器。我们将其中一个实例化为 redis。然后我们引入 connect-redis,它返回 RedisStore。
, Redis = require('../cache/redis')
, redis = new Redis()
, RedisStore = require('connect-redis')(express);
我们已经有一个现有的 sessionStore,配置为使用 MemoryStore:
var sessionStore = new express.session.MemoryStore();
让我们用新的 RedisStore 替换它:
var sessionStore = new RedisStore({client: redis.client});
我们的应用程序现在可以使用 Redis 存储会话。我们可以通过运行以下命令来通过 redis-cli 监控 Redis 会话活动:
redis-cli
monitor
使用 Redis 扩展 Socket.IO
Socket.IO 也使用内存存储来存储其事件。这里有几个问题;第一个是如果服务器失败,我们将丢失存储在内存中的那些消息。第二个是如果我们尝试通过添加更多服务器来扩展我们的应用程序,Socket.IO 的内存存储将绑定到单个服务器;我们添加的服务器将不知道其他服务器上哪些 Socket.IO 连接是打开的。
我们可以通过使用 Socket.IO 的 RedisStore 解决这些问题。我们首先引入一个 RedisStore,它是 Socket.IO 命名空间中的 redis 模块。我们还可以使用视觉的 Redis 模块来创建三个 Redis 客户端:pub、sub 和 client。为了配置 Socket.IO 使用 RedisStore,我们将 Socket.IO 的 'store' 设置为 RedisStore,并将 redis、pub、sub 和 client 作为参数传递。
var config = require('../configuration')
, RedisStore = require('socket.io/lib/stores/redis')
, redis = require('socket.io/node_modules/redis')
, Redis = require('../cache/redis')
, pub = new Redis().client
, sub = new Redis().client
, client = new Redis().client;
function Socket(server) {
/....
socketio.set('store', new RedisStore({
redis : redis
, redisPub : pub
, redisSub : sub
, redisClient : client
}));
return socketio;
};
水平扩展 Express
我们当前的应用程序架构将 API、消费型网络客户端和填充 Redis 缓存的工人耦合在一起。这种方法适用于许多应用程序,并将在负载均衡器的帮助下允许其水平扩展。
但比如说,如果我们希望我们的 API 支持除了 Web 以外的客户端,比如说,我们引入了一个使用我们 API 的移动客户端;理想情况下,我们希望独立扩展我们的 API 并移除与 Web 客户端相关的所有内容。
水平扩展我们的工作器意味着简单地重复相同的工作,这将是毫无意义的。稍后,我们将讨论如何扩展工作器。
在本章的剩余部分,我们将概述如何拆分我们的应用程序以实现水平扩展。我们将使用vision应用程序的chapter-6版本的源代码。当然,我们将记录任何有助于实现我们目标的有兴趣的内容。我们将创建四个新的项目:vision-core、vision-web、vision-api和vision-worker。
小贴士
您可以在此处下载本章的源代码:
github.com/AndrewKeig/vision-core
github.com/AndrewKeig/vision-web
github.com/AndrewKeig/vision-api
github.com/AndrewKeig/vision-worker
vision-core
我们的首要任务是提取vision-web、vision-api和vision-worker项目之间可以共享的所有内容,并将其放入一个新的vision-core项目中。
这包括以下部分:./cache、./lib/configuration、./lib/db、./lib/github、./lib/logger、./lib/models和./lib/project。
vision-core项目不是一个应用程序,所以我们从项目的根目录中移除了所有内容,包括./app.js和我们的./gruntfile.js,并添加了一个./index.js文件,该文件简单地导出了所有显示的功能:
module.exports.redis = require('./lib/cache/redis');
module.exports.publisher = require('./lib/cache/publisher');
module.exports.subscriber = require('./lib/cache/subscriber');
module.exports.configuration = require('./lib/configuration');
module.exports.db = require('./lib/db');
module.exports.github = require('./lib/github');
module.exports.project = require('./lib/project');
module.exports.logger = require('./lib/logger');
module.exports.models = require('./lib/models');
为了与其他私有项目共享私有的vision-core项目,我们在配置中添加了一个 GitHub 依赖项:./config/packge.json:
"dependencies": {
"vision-core": "git+ssh://git@github.com:AndrewKeig/vision-core.git#master",
vision-api
让我们创建一个包含 Web API 的vision-api项目。在这里,我们需要重用与 API 相关的所有内容,包括以下中间件:./lib/middleware/id、./lib/middleware/notFound、./lib/routes/project、./lib/routes/github和./lib/routes/heartbeat的路线。我们还包含了./config配置文件和所有测试./test。
为了确保vision-api的安全性,我们将使用基本身份验证,它使用用户名和密码来验证用户。这些凭据以纯文本形式传输,因此建议您使用 HTTPS。我们已经向您展示了如何设置 HTTPS,因此这部分将不会重复。为了设置基本身份验证,我们可以使用passport-http;让我们安装它:
npm install passport-http ––save
我们首先将用户名和密码添加到./config/*.json中:
"api": {
"username": "airasoul",
"password": "1234567890"
}
现在,我们准备在 ./lib/auth/index.js 中实现一个 ApiAuth 策略。我们首先定义一个函数 ApiAuth,然后导入 passport 和 passport-http 模块。我们实例化一个 BasicStrategy 函数并将其添加到 passport 中,传递一个验证函数。在这个验证函数内部,我们有通过在回调中传递 false 来拒绝用户的选择。我们调用 findUser 并检查 username 和 password 是否与存储在 ./config/*.json 中的相同。
var config = require('vision-core').configuration;
function ApiAuth() {
this.passport = require('passport');
var BasicStrategy = require('passport-http').BasicStrategy;
this.passport.use(new BasicStrategy({
},
function(username, password, done) {
findUser(username, password, function(err, status) {
return done(null, status);
})
}
));
var findUser = function(username, password, callback){
var usernameOk = config.get('api:username') === username;
var passwordOk = config.get('api:password') === password;
callback(null, usernameOk === passwordOk);
}
};
module.exports = new ApiAuth();
vision-api 项目需要一个新的 Express 服务器 ./express/index.js。我们首先通过 vision-core 引入 config,然后引入处理身份验证的 apiAuth 模块。接着,我们使用 app.all 将 passport 基本中间件应用到所有路由上。我们将 session:false 设置为 false,因为基本身份验证是无状态的。
var express = require('express')
, http = require('http')
, config = require('vision-core').configuration
, db = require('vision-core').db
, apiAuth = require('../auth')
, middleware = require('../middleware')
, routes = require('../routes')
, app = express();
app.set('port', config.get('express:port'));
app.use(express.logger({ immediate: true, format: 'dev' }));
app.use(express.bodyParser());
app.use(apiAuth.passport.initialize());
app.use(app.router);
app.all('*', apiAuth.passport.
authenticate('basic', { session: false }));
app.param('id', middleware.id.validate);
app.get('/heartbeat', routes.heartbeat.index);
app.get('/project/:id', routes.project.get);
app.get('/project', routes.project.all);
app.post('/project', routes.project.post);
app.put('/project/:id', routes.project.put);
app.del('/project/:id', routes.project.del);
app.get('/project/:id/repos', routes.github.repos);
app.get('/project/:id/commits', routes.github.commits);
app.get('/project/:id/issues', routes.github.issues);
app.use(middleware.notFound.index);
http.createServer(app).listen(app.get('port'));
module.exports = app;
由于我们正在转向多个 Express 服务器以支持我们的应用程序,我们将 vision-api 移至端口 3001。让我们在 ./config/*.json 中配置它,如下面的代码所示:
"express": {
"port": 3001
}
vision-worker
让我们继续并创建一个新的项目 vision-worker,它包括两个脚本 ./populate.js 和 ./lib/cache/populate.js。
当然,我们可以使用 RabbitMQ 这样的东西来扩展这个工作进程。这将允许我们产生多个生产者和消费者,从这个角度来看,我们目前的解决方案并不是最优的。如果您有兴趣改进这个应用程序的这部分,请参阅 Packt 的 Instant RabbitMQ Message Application Development。这本书解释了如何使用 RabbitMQ 实现工作模式。
vision-web
最后,我们创建一个新的项目 vision-web,它将包括与网络客户端相关的所有内容;简单地将 第六章 中的所有内容包含进来,并从 core 中移除所有内容,从 ./package.json 中引用 core。我们当前的 routes 需要一些重大的更改;现在,我们已经将服务层解耦到其自己的存储库中,称为 vision-api。vision-web 将不再直接调用项目中的服务调用和 github 服务;这些服务现在存在于 vision-api 项目中,我们将调用在 vision-api 上公开的 API 服务。
让我们将配置添加到 ./config/*.json 中,以供我们的 vision-api 项目使用。vision-api 项目已配置为在端口 3001 上运行,并使用基本身份验证来保证安全,因此我们在 url 中包含了 username 和 password。
"api": {
"url": "http://airasoul:1234567890@127.0.0.1:3001"
}
为了在我们的 vision-api 项目上调用服务,我们将通过使用 Request 模块来简化操作。Request 是一个简单的客户端,允许我们发起 HTTP 请求;让我们安装它:
npm install request --save
在配置就绪后,我们转向我们的项目路由 ./lib/routes/project.js。在这里,我们只是用 vision-api 中的相应调用替换了对我们项目服务的所有调用。我们首先引入上面代码片段中定义的配置。每个路由使用此配置构造一个 URL,我们使用 Request 模块调用 API。我们返回一个响应,该响应由 response.statusCode 和响应体组成:
var logger = require('vision-core').logger
, S = require('string')
, config = require('vision-core').configuration
, request = require('request')
, api = config.get('api:url');
exports.all = function(req, res){
logger.info('Request.' + req.url);
var userId = req.query.user || req.user.id;
var url = api + '/project?user=' + userId ;
request.get(url, function (error, response, body) {
return res.json(response.statusCode, JSON.parse(body));
});
};
exports.get = function(req, res){
logger.info('Request.' + req.url);
var url = api + '/project/' + req.params.id;
request.get(url, function (error, response, body) {
return res.json(response.statusCode, JSON.parse(body));
});
};
exports.put = function(req, res){
logger.info('Put.' + req.params.id);
if (S(req.body.name).isEmpty() )
return res.json(400, 'Bad Request');
var url = api + '/project/' + req.params.id;
request.put(url, { form: req.body },
function (error, response, body) {
return res.json(response.statusCode, body);
});
};
exports.post = function(req, res){
logger.info('Post.' + req.body.name);
if (S(req.body.name).isEmpty() )
return res.json(400, 'Bad Request');
var url = api + '/project/';
request.post(url, { form: req.body },
function (error, response, body) {
var parsed = JSON.parse(body);
res.location('/project/' + parsed._id);
return res.json(response.statusCode, parsed);
});
};
exports.del = function(req, res){
logger.info('Delete.' + req.params.id);
var url = api + '/project/' + req.params.id;
request.del(url, function (error, response, body) {
return res.json(response.statusCode, body);
});
};
让我们为 GitHub 路由 ./lib/routes/github.js 重复相同的流程;移除对 GitHub 服务的调用,并替换为对我们 vision-api 项目相应端点的调用:
var logger = require('vision-core').logger
, config = require('vision-core').configuration
, request = require('request')
, api = config.get('api:url');
exports.repos = function(req, res){
logger.info('Request.' + req.url);
var url = api + '/project/' + req.params.id + "/repos";
request.get(url, function (error, response, body) {
return res.json(response.statusCode, JSON.parse(body));
});
};
exports.commits = function(req, res){
logger.info('Request.' + req.url);
var url = api + '/project/' + req.params.id + "/commits";
request.get(url, function (error, response, body) {
return res.json(response.statusCode, JSON.parse(body));
});
};
exports.issues = function(req, res){
logger.info('Request.' + req.url);
var url = api + '/project/' + req.params.id + "/issues";
request.get(url, function (error, response, body) {
return res.json(response.statusCode, JSON.parse(body));
});
};
让我们更新我们的测试 ./test/project.js、./test/github.js。我们现在移除与 Mongoose 相关的所有内容,使用 Request 模块直接调用 vision-api 以将测试数据种入 MongoDB:
beforeEach(function(done){
var proj = {
name: "test name"
, user: login.user
, token: login.token
, image: "/img/"
, repositories : [ "node-plates" ]
};
var url = api + '/project';
req.post(url, { form: proj },
function (error, response, body) {
id = JSON.parse(body)._id;
done()
});
});
afterEach(function(done){
var url = api + '/project/' + id;
req.del(url, function (error, response, body) {
done()
});
});
使用集群进行垂直扩展
我们当前的 vision-web 和 vision-api Express 应用程序在单个线程中运行。为了垂直扩展我们的应用程序,为了利用多核系统,并在出现故障时提供冗余,我们可以使用集群模块并将负载分散到多个进程上。让我们将 Cluster 模块添加到 vision-core ./lib/cluster/index.js:
var cluster = require('cluster')
, http = require('http')
, numCPUs = require('os').cpus().length
, logger = require('../logger');
function Cluster() {}
Cluster.prototype.run = function(module){
if (cluster.isMaster) {
for (var i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', function(worker, code, signal) {
logger.info('Worker ' + worker.process.pid + ' died');
});
} else {
require(module);
}
}
module.exports = Cluster;
让我们将集群模块从 vision-core 中导出;通过在 ./index.js 中添加以下内容:
module.exports.cluster = require('./lib/cluster');
让我们更改 vision-web 和 vision-api ./app.js 中的 Express 应用程序,并添加一个运行我们应用程序的第三个选项,即使用集群支持运行:
switch (process.env['NODE_ENV']) {
case 'COVERAGE':
module.exports = require('./lib-cov/express');
break;
case 'TEST':
module.exports = require('./lib/express');
break;
default:
var Cluster = require('vision-core').cluster
, cluster = new Cluster();
cluster.run(__dirname + '/lib/express');
break;
}
使用 Hipache 进行负载均衡
Hipache 是一个设计用于路由大量 HTTP 和 WebSocket 流量的分布式代理。Hipache 支持通过 Redis 进行动态配置,因此更改配置和添加虚拟主机不需要重启。基于 node-http-proxy 库,Hipache 提供了对负载均衡 WebSocket、SSL、检测后端死亡以及集群以实现故障转移的支持。让我们安装它:
npm install hipache -g
让我们通过编辑 hosts 文件为 vision-web 和 vision-api 设置一个主机:
sudo nano /private/etc/hosts
添加两个新条目:
127.0.0.1 web.vision.net
127.0.0.1 api.vision.net
然后刷新缓存以使这些更改生效:
dscacheutil -flushcache
为了配置服务器,我们需要为每个我们想要进行负载均衡的应用程序创建一个配置文件。在我们的例子中,是 vision-web 和 vision-api。以下是 vision-api 的配置文件,./config/server.json。重要的是,我们正在端口 8443 上运行 vision-api。我们在 HTTPS 部分配置了一个 SSL 证书,因为 Hipache 会终止 SSL,而不是我们的 Express 服务器:
{
"server": {
"accessLog": "hipache_access.log",
"port": 8443,
"workers": 5,
"maxSockets": 100,
"deadBackendTTL": 30,
"address": ["127.0.0.1"],
"address6": ["::1"],
"https": {
"port": 8443,
"key": "lib/secure/key.pem",
"cert": "lib/secure/cert.pem"
}
},
"redisHost": "127.0.0.1",
"redisPort": 6379,
"redisDatabase": 0
}
让我们对 Express 服务器 ./lib/express/server.js 进行更改,并在生产环境中返回一个标准的 HTTP 服务器;Hipache 现在将终止 SSL。
function Server(app){
if (process.env['NODE_ENV'] === "PRODUCTION")
return http.createServer(app).listen(app.get('port'));
var httpsOptions = {
key: fs.readFileSync('./lib/secure/key.pem'),
cert: fs.readFileSync('./lib/secure/cert.pem')
};
return https.createServer(httpsOptions,app).listen(app.get('port'));
}
现在我们为 vision-api ./config/server.json 添加 Hipache 配置。请注意,我们正在端口 3001 上运行 vision-api。
{
"server": {
"accessLog": "hipache_access.log",
"port": 3001,
"workers": 5,
"maxSockets": 100,
"deadBackendTTL": 30,
"address": ["127.0.0.1"],
"address6": ["::1"]
},
"redisHost": "127.0.0.1",
"redisPort": 6379,
"redisDatabase": 0
}
我们需要重新访问 GitHub,并将 settings/applications/developer applications/vision 下的 URL 更改为 https://web.vision.net:8443。
让我们更新 vision-web 的配置 ./config/*.json,并将 GitHub 认证 URL 更改为 web.vision.net。
"auth": {
"homepage": "https://web.vision.net:8443"
, "callback": "https://web.vision.net:8443/auth/github/callback"
, "clientId": "5bb691b4ebb5417f4ab9"
, "clientSecret": "44c16f4d81c99e1ff5f694a532833298cae10473"
}
让我们也更新同一组配置文件中的 API url 配置:
"api": {
"url": "http://airasoul:1234567890@api.vision.net:3001"
}
我们最后的更改将使我们能够支持每个应用程序的多个端口;我们将更改 Express 服务器中的端口设置 ./lib/express/index.js,以便它检查 process.env.PORT 以获取端口号:
app.set('port', process.env.PORT || config.get('express:port'));
我们现在开始运行应用程序在负载均衡器下的过程。为了启动 vision-api 的 Hipache 负载均衡器,运行以下命令:
cd vision-web
hipache --config ./config/server.json
为了启动 vision-web 的 Hipache 负载均衡器,我们运行以下命令:
cd vision-api
hipache --config ./config/server.json
因此,我们现在为 vision-api 和 vision-web 运行了一个正在运行的 Hipache 实例。让我们在 Redis 中创建一个虚拟主机,并将 Hipache 实例与一系列服务器关联。现在运行 redis 命令行界面:
redis-cli
首先,让我们让 vision-web 应用程序运行起来,并将运行在端口 3003 的后端分配给 web.vision:
rpush frontend:web.vision.net web.vision
rpush frontend:web.vision.net http://127.0.0.1:3003
让我们回顾 web.vision 的配置:
lrange frontend:web.vision.net 0 -1
让我们让 vision-api 应用程序运行起来,并将运行在端口 3005 的后端分配给 api.vision:
rpush frontend:api.vision.net api.vision
rpush frontend:api.vision.net http://127.0.0.1:3005
让我们回顾 api.vision 的配置:
lrange frontend:api.vision.net 0 -1
让我们在负载均衡器下运行应用程序,设置 PORT 环境变量,并在运行 npm start 时将 NODE_ENV 设置为 production:
/vision-web/NODE_ENV=production PORT=3003 npm start
/vision-api/NODE_ENV=production PORT=3005 npm start
/vision-worker/npm start
我们现在有一个在负载均衡器下运行的视觉应用,请访问 https://web.vision.net:844 3。为了向我们的负载均衡器添加更多后端,让我们在另一个端口下启动 vision-api 和 vision-web:
/vision-web/NODE_ENV=production PORT=3004 npm start
/vision-api/NODE_ENV=production PORT=3006 npm start
当我们运行以下命令时,运行在端口 3004 和 3006 的后端将被添加到负载均衡器中:
rpush frontend:web.vision.net http://127.0.0.1:3004
rpush frontend:api.vision.net http://127.0.0.1:3006
摘要
扩展 Web 应用程序并非易事。Node;使用集群模块允许我们垂直扩展。水平扩展需要我们向更广泛的社区寻求帮助。在我们的应用程序中,我们选择了 Hipache;一个基于 Node 的负载均衡器。在下一章中,我们将讨论在考虑性能和可靠性问题时,我们可以对应用程序进行的生产级改进。
第七章:生产
在本章中,我们将讨论将 Express 应用程序投入生产。我们首先通过查看异常处理使我们的 Express 应用程序更加健壮。然后我们看一下为了使应用程序能够在生产环境中生存,我们需要进行的一系列性能改进。
错误处理、域和仅崩溃设计
Node 社区采用了仅崩溃的设计模式,这简单意味着:如果你遇到未捕获的异常,捕获它,记录它,然后重启进程。仅崩溃设计和域作为一个模式工作得相当好,尤其是如果你的应用程序正在使用cluster。让我们在vision-core上的cluster模块./lib/cluster/index.js中做一些更改,这里我们包含了domain模块;而不是简单地包含我们的模块以在集群中运行,我们创建了一个域并调用run方法。然后我们包含了一个基于域的error处理器,它通过process.exit(1)记录并关闭进程。集群的exit处理器将捕获这个信号并fork一个新的进程:
var cluster = require('cluster')
, http = require('http')
, numCPUs = require('os').cpus().length
, logger = require('../logger')
, domain = require('domain');
function Cluster() {}
Cluster.prototype.run = function(module) {
if (cluster.isMaster) {
for (var i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', function(worker, code, signal) {
logger.info('Worker ' + worker.process.pid + ' died');
cluster.fork();
});
} else {
var d = domain.create();
d.on('error', function(err) {
logger.info('Error ', err);
process.exit(1);
});
d.run(function() {
require(module);
});
}
}
module.exports = Cluster;
Redis 会话
在生产环境中,大多数需要会话支持的 Express 应用程序可能会使用 Redis,因此使 Redis 性能良好非常重要。我们的 Redis 客户端node-redis使用纯 JavaScript 解析器;node-redis 文档建议使用一个替代模块进行解析。
Hiredis 是对官方 Hiredis C 库的绑定;它是非阻塞的且速度快。如果你安装了hiredis,node-redis 将默认使用它。让我们在vision-core上安装 Hiredis:
cd vision-core
npm install hiredis redis --save
SSL 终止
SSL 终止是指将 TLS 加密(HTTPS)流解密为纯文本(HTTP)。Node 核心中的 TLS 模块不如一些用于终止 SSL 的其他技术快,通常不用于生产。我们的应用程序完全运行在 HTTPS 上,因此 TLS 性能至关重要。
幸运的是,我们有 SSL 选项;我们将使用stud,这是一个网络代理,它终止 TLS/SSL 连接并将未加密的流量转发到 Web 服务器。Stud 基于libev构建,是非阻塞的;它被设计用来在多核机器上高效地处理数万个连接。让我们克隆 stud 的 GitHub 仓库:
git clone http://github.com/bumptech/stud.git
现在从源代码编译 stud:
cd stud
make
sudo make install
安装完成后,我们可以生成一个 stud 文件。Stud 附带一个默认配置,我们可以通过以下方式请求:
cd vision-web
stud --default-config > stud.conf
我们的研究文件./vision-web/stud.conf需要一些重要的更改才能正常工作;frontend配置应设置为端口8443,而backend配置应设置为托管在端口3003上的 Hipache 负载均衡器vision-web。最后,我们设置pem-file,这是一个包含 SSL 证书和私钥的单个 PEM 文件:
# stud(8), The Scalable TLS Unwrapping Daemon's configuration
# Listening address. REQUIRED.
# type: string
# syntax: [HOST]:PORT
frontend = "[127.0.0.1]:8443"
# Upstream server address. REQUIRED.
# type: string
# syntax: [HOST]:PORT.
backend = "[127.0.0.1]:3003"
# SSL x509 certificate file. REQUIRED.
# List multiple certs to use SNI. Certs are used in the order they
# are listed; the last cert listed will be used if none of the others match
# type: string
pem-file = "lib/secure/vision.pem"
# EOF
现在我们已经设置了 stud 配置,我们的 Hipache 负载均衡器将不再需要终止 SSL。让我们从 Hipache 配置./vision-web/config/server.json中移除 SSL 配置:
{
"server": {
"accessLog": "hipache_access.log",
"port": 3000,
"workers": 5,
"maxSockets": 100,
"deadBackendTTL": 30,
"address": ["127.0.0.1"],
"address6": ["::1"]
},
"redisHost": "127.0.0.1",
"redisPort": 6379,
"redisDatabase": 0
}
在配置就绪后,让我们创建一个带有私钥的单个 PEM 文件证书。
简单地将您的cert.pem和key.pem复制到一个名为./lib/secure/vision.pem的单个文件中;首先放置私钥,然后是您的证书。
现在,我们可以在 Hipache 负载均衡器前面运行 stud;stud 将处理 SSL 并将未加密的流量按如下方式定向到 Hipache:
cd vision-web
stud --config=stud.conf
请运行以下命令集以在 stud 后面运行我们的堆栈:
/vision-web/hipache --config ./config/server-no-ssl.json
/vision-api/hipache --config ./config/server.json
redis-cli (these may already exist in redis)
rpush frontend:web.vision.net web.vision
rpush frontend:web.vision.net http://127.0.0.1:3003
rpush frontend:api.vision.net api.vision
rpush frontend:api.vision.net http://127.0.0.1:3005
/vision-web/NODE_ENV=production PORT=3003 npm start
/vision-api/NODE_ENV=production PORT=3005 npm start
/vision-worker/npm start
缓存
我们的静态文件需求很小;我们提供的唯一静态内容将是应用客户端使用的组件。为了缓存我们的静态文件/组件,让我们对vision-web/lib/express/index.js进行简单的修改。我们将maxAge属性设置为一周,并将其存储在配置中,如下所示:
app.use(express.static('public',
{ maxAge: config.get('express:staticCache') }));
app.use(express.static('public/components',
{ maxAge: config.get('express:staticCache') }));
app.use('/bootstrap',express.static('public/components/bootstrap/docs/assets/css',
{ maxAge: config.get('express:staticCache') }));
app.use('/sockets',
express.static('public/components/socket.io-client/dist/', { maxAge: config.get('express:staticCache') }));
让我们将配置值staticCache添加到vision-web/config/*.json中,如下所示:
"express": {
"port": 8443,
"staticCache" : 6048000000
},
现在我们访问我们的应用时,响应头将包含一个缓存控制头。如果您访问我们应用的首页并通过浏览器工具检查提供的任何资源的响应头,您应该看到以下内容:
Cache-Control:public, max-age = 86400
网站图标
让我们使用connect.favicon中间件为我们的应用添加一个网站图标。从性能角度来看,这有一些价值,因为我们可以缓存它。此外,即使不存在图标,您的浏览器也会请求它,这可能导致抛出 404 错误。我们将使用现有的staticCache配置值来设置图标的maxAge。让我们编辑 Express 服务器/vision-web/lib/express/index.js并添加favicon中间件:
app.set('views', 'views');
app.use(express.favicon('public/components/vision/favicon.ico'), { maxAge: config.get('express:staticCache') });
压缩
我们可以通过压缩我们的静态资源来提高页面加载时间。我们将通过安装以下两个 grunt 任务来压缩我们的 JavaScript 和 CSS 文件:
grunt-contrib-uglify:这允许您压缩 JavaScript 文件:
npm install grunt-contrib-uglify --save-dev
grunt-contrib-cssmin:这允许您压缩 CSS 文件:
npm install grunt-contrib-cssmin --save-dev
让我们将这些压缩任务添加到我们的 grunt 文件中,如下所示:
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-contrib-cssmin');
uglify: {
dist: {
files: {
'public/components/vision/templates.min.js':
'public/components/vision/templates.js',
'public/components/vision/vision.min.js':
'public/components/vision/vision.js',
'public/components/json2/json2.min.js':
'public/components/json2/json2.js',
'public/components/handlebars/handlebars.runtime.min.js':
'public/components/handlebars/handlebars.runtime.js'
}
}
},
cssmin: {
minify: {
expand: true,
src: ['public/components/vision/vision.css'],
ext: '.min.css'
}
}
让我们运行以下命令:
grunt uglify
grunt cssmin
并非我们所有的 JavaScript 组件都有压缩版本,因此我们也对这些组件进行压缩,为 json2 和 handlebars 添加.min版本。
压缩
我们可以通过压缩静态文件进一步提高页面加载时间。Express 包括compress中间件,它将 gzip HTTP 响应。让我们编辑 Express 服务器/vision-web/lib/express/index.js并添加compress中间件,如下所示:
app.set('views', 'views');
app.use(express.logger({ immediate: true, format: 'dev' }));
app.use(express.compress());
如果您访问我们应用的首页并检查通过浏览器工具提供的所有资源的响应头,您应该看到以下内容:
Content-Encoding: gzip
记录日志
Express 服务器,./lib/express/index.js,使用 logger 中间件进行日志记录。Express 日志记录器应仅在开发环境中使用。实际上,在生产环境中,这将对性能产生严重影响,因为控制台函数是同步的。让我们更改 Express 服务器,并在生产时关闭日志,如下面的代码片段所示:
if (process.env['NODE_ENV'] !== "production")
app.use(express.logger({ immediate: true, format: 'dev' }));
摘要
在商业生产环境中,Express 可能看起来有些不同,但这是有充分理由的。许多 Express/Node 支持的任务可以由其他工具更好地执行。在我们的应用程序中,我们尽量保持在 node 堆栈上;我们选择使用 stud 终止 SSL,因为我们的整个应用程序都运行在 SSL 上。Stud 将在这个领域超越所有其他工具,包括 Nginx 和 Haproxy。Stud 将将未加密的响应转发到 Hipache,以平衡负载。Hipache 基于 node-http-proxy;它使用集群进行故障转移。更重要的是,与 node-http-proxy 不同,它可以管理内存,使其成为负载均衡器的合理选择。
Hipache 工作得很好,但如果你真正寻求性能,Nginx 和 Haproxy 是事实上的工具选择。对于故障转移,我们使用 node 的集群模块,结合域名,使我们的应用程序更加健壮。
我们的静态文件需求很小,因此我们选择通过 Express 提供静态资源,包括缓存、压缩和精简。任何偏离这些最小需求的行为都会让我选择 Nginx 或 Haproxy 来提供静态内容,或者使用内容分发网络。
我们已经成功自动化了许多任务。我们的代码覆盖率大约在 80%,在我们的应用程序上运行 YSlow 和 PageSpeed 产生了良好的结果。理想情况下,我们希望通过测试驱动所有需求,通过单元测试驱动一些较小的代码模块,并使用 Cucumber 添加更多验收测试。我希望你至少已经能够感受到所有这些元素,并能够根据自己的判断做出关于测试的明智选择。
Node/Express 堆栈是构建 Web 应用程序的一个非常好的平台。与全栈 JavaScript 一起工作是一种非常好的开发体验。Node 社区和数千名 Node 模块开发者使 Node 成为一个充满活力和有趣的领域来工作。


浙公网安备 33010602011771号