Express-js-蓝图-全-
Express.js 蓝图(全)
原文:
zh.annas-archive.org/md5/9c3ced3a53720027486a1c785afc0e9c译者:飞龙
前言
API 是每个严肃 Web 应用程序的核心。Node.js 是一个特别令人兴奋的工具,易于使用,允许你构建 API,并在 JavaScript 中开发后端代码。它为包括 PayPal、Netflix 和 Zenhub 在内的 Web 应用程序的后端提供动力。
Express.js 是 Node.js 上最流行的框架之一,可以用来构建强大的 Web 应用程序——它提供了开发健壮 Web 应用程序的基本抽象级别。随着这个最小化和灵活的 Node.js Web 应用程序框架的出现,创建 Node.js 应用程序变得更加简单、快速,并且需要最小的努力。
本书采用实用主义方法,充分利用 Express.js 所能提供的功能,介绍了关键库,并全面装备你从零开始构建可扩展 API 所需的所有技能和工具,同时提供多年经验积累的微妙细节和智慧结晶。
本书涵盖的内容
第一章, 构建基本的 Express 网站,将提供一个基本的应用程序(脚手架),我们将用它来演示接下来的示例。你将了解 Express 应用程序的外观。
第二章, 强大的电影 API,将指导你构建一个电影 API,允许你将演员和电影信息添加到数据库中,并将演员与电影以及反之连接起来。
第三章, 多人游戏 API – 连接 4,将围绕构建多人游戏 API 展开。我们还将使用测试驱动开发(TDD)和最大代码覆盖率来构建应用程序。
第四章, 多人在线文字游戏,将教你如何使用 Express 和 SocketIO 构建实时应用程序,执行 socket 握手的身份验证,并使用 MongoDB 的原子更新处理竞争条件。
第五章, 与陌生人共进咖啡,将使你能够编写一个允许用户去喝咖啡的 API!它将包含一个简单且可扩展的用户匹配系统。
第六章, Koa.js 上的 Hacker News API,将带你构建一个用于发布链接和点赞的 CRUD 后端,我们将使用 thunks 来集中处理错误,避免回调地狱。
附录, 连接 4 – 游戏逻辑,展示了我们在第三章“多人游戏 API – 连接 4”中省略的配套游戏逻辑。
你需要为本书准备的内容
要开始使用本书中的示例,你需要以下内容:
-
MongoDB:
www.mongodb.org/downloads -
RoboMongo:
robomongo.org/ -
Mocha:使用
npm i -g mocha命令下载
Mac OS 是首选,但不是必需的。
本书面向对象
本书适合 Node.js 初学者,也适合技术先进的读者。本书结束时,每位开发者都将具备使用 Express 构建 Web 应用程序的专业知识。
惯例
在这本书中,您将找到许多不同的文本样式,以区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"如果它是,那么我们使用req.user作为数据渲染users/profile.jade模板"。
代码块应如下设置:
var express = require('express');
var app = express();
app.get('/', function(req, res, next) {
res.send('Hello, World!');
});
app.listen(3000);
console.log('Express started on port 3000');
任何命令行输入或输出都应如下编写:
$ npm install --save express
新术语和重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:"您还可以右键单击页面,并选择检查元素"。
注意
警告或重要提示将以如下框的形式出现。
小贴士
小技巧和窍门如下所示。
读者反馈
我们欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中受益的书籍。
要向我们发送一般反馈,请简单地发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及书籍的标题。
如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您在www.packtpub.com的账户下载示例代码文件,以获取您购买的所有 Packt Publishing 书籍。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
错误清单
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现了错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详细信息。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站或添加到该标题的错误部分下的现有错误清单中。
要查看之前提交的勘误表,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分。
海盗行为
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
询问
如果您对本书的任何方面有问题,请通过 <questions@packtpub.com> 联系我们,我们将尽力解决问题。
第一章. 构建一个基本的 Express 网站
Express 是 Node.js 的 Web 开发框架。Node.js 是一个开源的、跨平台的运行时环境,用于服务器端和网络应用程序。它使用 Google Chrome 的 JavaScript 引擎 V8 来执行代码。Node.js 是单线程和事件驱动的。它使用非阻塞 I/O 来榨取 CPU 的每一分处理能力。Express 建立在 Node.js 之上,提供了开发健壮 Web 应用程序所需的所有工具。
此外,通过使用 Express,你可以访问大量的开源软件来帮助解决开发中的常见痛点。该框架是中立的,这意味着它不会在实现或接口方面引导你走向一个方向或另一个方向。因为它是不偏不倚的,开发者有更多的控制权,可以使用该框架完成几乎任何任务;然而,Express 提供的功能很容易被滥用。在这本书中,你将通过探索以下不同风格的应用程序来学习如何正确使用该框架:
-
为静态网站设置 Express
-
本地用户身份验证
-
OAuth 与护照
-
个人资料页面
-
测试
为静态网站设置 Express
为了让我们熟悉环境,我们首先会讲解如何响应基本的 HTTP 请求。在这个例子中,我们将处理几个GET请求,首先以纯文本形式响应,然后以静态 HTML 响应。然而,在我们开始之前,你必须安装两个基本工具:node 和 npm,即 node 包管理器。
注意
导航到nodejs.org/download/来安装 node 和 npm。
在 Express 中使用 Hello, World
对于那些不熟悉 Express 的人来说,我们将从一个基本的例子开始——Hello World!我们将从一个空目录开始。与任何 Node.js 项目一样,我们将运行以下代码来生成我们的package.json文件,该文件跟踪有关项目元数据的信息,例如依赖项、脚本、许可证,甚至代码托管的位置:
$ npm init
package.json文件跟踪我们所有的依赖项,这样我们就不需要处理版本问题,不需要将依赖项包含在我们的代码中,并且可以无畏地部署。你将收到几个问题提示。除了入口点外,所有默认值都可以选择,你应该将其设置为server.js。
有许多生成器可以帮助你生成新的 Express 应用程序,但这次我们将自己创建框架。让我们安装 Express。要安装一个模块,我们使用npm来安装包。我们使用--save标志来告诉 npm 将依赖项添加到我们的package.json文件中;这样,我们就不需要将依赖项提交到源代码控制中。我们可以根据package.json文件的内容安装它们(npm 使这变得很容易):
$ npm install --save express
在这本书中,我们将使用 Express v4.4.0。
注意
警告:Express v4.x 与其之前的版本不向后兼容。
你可以创建一个新的文件server.js,如下所示:
var express = require('express');
var app = express();
app.get('/', function(req, res, next) {
res.send('Hello, World!');
});
app.listen(3000);
console.log('Express started on port 3000');
此文件是应用程序的入口点。在这里,我们生成应用程序,注册路由,并最终在端口 3000 上监听传入的请求。require('express') 方法返回一个应用程序生成器。
我们可以不断地创建尽可能多的应用程序;在这种情况下,我们只创建了一个,并将其分配给变量 app。接下来,我们注册一个 GET 路由,该路由监听服务器根目录上的 GET 请求,并在请求时向客户端发送字符串 'Hello, World'。Express 有所有 HTTP 动词的方法,所以我们也可以做 app.post、app.put、app.delete 或甚至 app.all,它响应所有 HTTP 动词。最后,我们启动应用程序监听端口 3000,然后记录到标准输出。
现在是时候启动我们的服务器并确保一切按预期工作。
$ node server.js
我们可以通过在浏览器中导航到 http://localhost:3000 或在终端中执行 curl -v localhost:3000 来验证一切是否正常工作。
Jade 模板
现在,我们将把发送给客户端的 HTML 提取到一个单独的模板中。毕竟,仅仅通过使用 res.send 来渲染完整的 HTML 页面会相当困难。为了实现这一点,我们将使用与 Express 配合频繁使用的模板语言——Jade。你可以使用许多模板语言与 Express 一起使用。我们选择 Jade,因为它极大地简化了 HTML 的编写,并且是由 Express 框架的同一开发者创建的。
$ npm install --save jade
安装 Jade 后,我们必须将以下代码添加到 server.js 中:
app.set('view engine', 'jade');
app.set('views', __dirname + '/views');
app.get('/', function(req, res, next) {
res.render('index');
});
之前的代码为 Express 设置了默认视图引擎——有点像告诉 Express,在未来,除非有其他指定,否则应该假设模板位于 Jade 模板语言中。调用 app.set 为 Express 内部设置一个键值对。你可以将这种应用程序视为宽配置。我们可以随时调用 app.get(视图引擎)来检索我们设置的值。
我们还指定了 Express 应该查找以找到视图文件的文件夹。这意味着我们应该在我们的应用程序中创建一个 views 目录,并向其中添加一个文件,index.jade。或者,如果你想包含许多不同的模板类型,你可以执行以下操作:
app.engine('jade', require('jade').__express);
app.engine('html', require('ejs').__express);
app.get('/html', function(req, res, next) {
res.render('index.html');
});
app.get(/'jade, function(req, res, next) {
res.render('index.jade');
});
在这里,我们根据我们想要渲染的模板的扩展名设置自定义模板渲染。我们使用 Jade 渲染器为 .jade 扩展名,使用 ejs 渲染器为 .html 扩展名,并通过不同的路由公开我们的索引文件。如果你选择了一种模板选项,后来又想以渐进的方式切换到新的选项,这很有用。你可以参考最基本模板的源代码。
本地用户身份验证
大多数应用程序都需要用户账户。有些应用程序只允许通过第三方进行认证,但并非所有用户都希望因为隐私原因通过第三方进行认证,因此包含本地选项很重要。在这里,我们将讨论在 Express 应用程序中实现本地用户认证的最佳实践。我们将使用 MongoDB 来存储我们的用户,并使用 Mongoose 作为 ODM(对象文档映射器)。然后,我们将利用 passport 来简化会话处理并提供统一的认证视图。
小贴士
下载示例代码
您可以从您在 www.packtpub.com 的账户下载示例代码文件,以获取您购买的所有 Packt Publishing 书籍。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
用户对象建模
我们将利用 passportjs 来处理用户认证。Passport 集中了所有的认证逻辑,并提供方便的方式在本地进行认证,同时支持第三方认证,例如 Twitter、Google、Github 等等。首先,按照以下步骤安装 passport 和本地认证策略:
$ npm install --save passport-local
在我们的第一次尝试中,我们将实现本地认证策略,这意味着用户将能够本地注册账户。我们首先使用 Mongoose 定义一个用户模型。Mongoose 提供了一种定义我们想要存储在 MongoDB 中的对象模式的方法,并提供了一种方便的方式来在数据库中的存储记录和内存表示之间进行映射。
Mongoose 还提供了方便的语法来执行许多 MongoDB 查询,并在模型上执行 CRUD 操作。我们的用户模型目前只包含电子邮件、密码和时间戳。在开始之前,我们需要安装 Mongoose:
$ npm install --save mongoose bcrypt validator
现在我们将在 models/user.js 文件中定义我们的用户模式,如下所示:
Var mongoose = require('mongoose');
var userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true
},
password: {
type: String,
required: true
},
created_at: {
type: Date,
default: Date.now
}
});
userSchema.pre('save', function(next) {
if (!this.isModified('password')) {
return next();
}
this.password = User.encryptPassword(this.password);
next();
});
在这里,我们创建一个模式来描述我们的用户。Mongoose 提供了方便的方式来描述所需的唯一字段以及每个属性应持有的数据类型。Mongoose 在幕后执行所有必要的验证。对于我们的第一个样板应用程序,我们不需要许多用户字段——电子邮件、密码和时间戳足以开始。
我们还使用 Mongoose 中间件来重新散列用户的密码,如果他们决定更改密码。Mongoose 提供了几个钩子来运行用户定义的回调。在我们的示例中,我们定义了一个在 Mongoose 保存模型之前被调用的回调。这样,每次用户被保存时,我们都会检查他们的密码是否已更改。
没有这个中间件,就有可能以明文形式存储用户的密码,这不仅是一个安全漏洞,还会破坏认证。Mongoose 支持两种中间件类型——串行和并行。并行中间件可以运行异步函数,并得到一个额外的回调函数来调用;你将在本书的后面部分了解更多关于 Mongoose 中间件的内容。
现在,我们想要添加验证以确保我们的数据是正确的。我们将使用 validator 库来完成这项任务,如下所示:
Var validator = require('validator');
User.schema.path('email').validate(function(email) {
return validator.isEmail(email);
});
User.schema.path('password').validate(function(password) {
return validator.isLength(password, 6);
});
var User = mongoose.model('User', userSchema);
module.exports = User;
我们使用名为 validator 的库添加了对电子邮件和密码长度的验证,该库为不同类型的字段提供了许多方便的验证器。Validator 提供基于长度、URL、整数、大写的验证;基本上,任何你想验证的内容(别忘了验证所有用户输入!)
我们还添加了一系列关于注册、认证以及加密密码的辅助函数,你可以在 models/user.js 文件中找到它们。我们将这些添加到用户模型中,以帮助封装我们想要使用用户抽象的各种交互。
注意
想了解更多关于 Mongoose 的信息,请参阅 mongoosejs.com/。有关 passportjs 的更多信息,请访问 passportjs.org/。
这概述了名为 MVC 的设计模式的开始——模型、视图、控制器。基本思想是将不同的关注点封装在不同的对象中:模型代码了解数据库、存储和查询;控制器代码了解路由和请求/响应;视图代码知道为用户渲染什么。
介绍 Express 中间件
Passport 是一种可以与 Express 应用程序一起使用的认证中间件。在深入研究 Passport 之前,我们应该回顾一下 Express 中间件。Express 是一个 connect 框架,这意味着它使用 connect 中间件。内部连接有一个处理请求的函数堆栈。
当请求到来时,堆栈中的第一个函数会接收到请求和响应对象以及 next() 函数。当调用 next() 函数时,它将委托给中间件堆栈中的下一个函数。此外,你可以指定中间件的路径,这样它就只为特定的路径调用。
Express 允许你使用 app.use() 函数向应用程序添加中间件。实际上,我们之前编写的 HTTP 处理程序是一种特殊的中间件。内部,Express 为路由器有一个中间件级别,它委托给适当的处理程序。
中间件对于日志记录、提供静态文件、错误处理等非常有用。实际上,Passport 利用中间件进行认证。在发生任何其他事情之前,Passport 会查找请求中的 cookie,找到元数据,然后从数据库中加载用户,将其添加到 req 和 user 中,然后继续沿着中间件堆栈向下。
设置 passport
在我们可以充分利用 passport 之前,我们需要告诉它如何完成一些重要的事情。首先,我们需要指导 passport 如何将用户序列化到会话中。然后,我们需要从会话信息中反序列化用户。最后,我们需要告诉 passport 如何判断给定的电子邮件/密码组合是否代表一个有效的用户,如下所示:
// passport.js
var passport = require('passport');
var LocalStrategy = require('passport-local').Strategy;
var User = require('mongoose').model('User');
passport.serializeUser(function(user, done) {
done(null, user.id);
});
passport.deserializeUser(function(id, done) {
User.findById(id, done);
});
在这里,我们告诉 passport 当我们序列化用户时,我们只需要该用户的 id。然后,当我们想要从会话数据中反序列化用户时,我们只需通过他们的 ID 查找用户!这用于 passport 的中间件,在请求完成后,我们取 req.user 并将他们的 ID 序列化到我们的持久会话中。当我们第一次收到请求时,我们取存储在我们的会话中的 ID,从数据库中检索记录,并用 user 属性填充请求对象。所有这些功能都由 passport 透明地提供,只要我们提供以下两个函数的定义:
function authFail(done) {
done(null, false, { message: 'incorrect email/password combination' });
}
passport.use(new LocalStrategy(function(email, password, done) {
User.findOne({
email: email
}, function(err, user) {
if (err) return done(err);
if (!user) {
return authFail(done);
}
if (!user.validPassword(password)) {
return authFail(done);
}
return done(null, user);
});
}));
我们告诉 passport 如何在本地验证用户。我们创建一个新的 LocalStrategy() 函数,当给定电子邮件和密码时,将尝试通过电子邮件查找用户。我们可以这样做,因为我们要求电子邮件字段必须是唯一的,所以应该只有一个用户。如果没有用户,我们返回一个错误。如果有用户,但提供了无效的密码,我们仍然返回一个错误。如果有用户并且他们提供了正确的密码,那么我们通过调用 done 回调来告诉 passport 验证请求是成功的,从而告诉 passport 验证请求成功。
为了使用护照,我们需要添加我们之前提到的中间件。实际上,我们需要添加几种不同的中间件。Express 中间件的好处在于它鼓励开发者编写小型、专注的模块,这样你就可以引入你想要的功能,排除你不需要的功能。
// server.js
var mongoose = require('mongoose');
var User = require('./models/user');
var passport = require('./passport');
mongoose.connect('mongodb://localhost/chapter01', function(err) {
if (err) throw err;
});
…
app.use(require('cookie-parser')('my secret string'));
app.use(require('express-session')({ secret: "my other secret string" }));
app.use(require('body-parser')());
app.use(passport.initialize());
app.use(passport.session());
为了使用 passport,我们必须为我们的服务器启用一些功能。首先,我们需要启用 cookies 和会话支持。为了启用会话支持,我们添加了一个 cookie 解析器。这个中间件将 cookie 对象解析为 req.cookies。会话中间件允许我们修改 req.session 并使这些数据在请求之间持久化。默认情况下,它使用 cookies,但它有多种会话存储方式,你可以进行配置。然后,我们必须添加 body-parsing 中间件,它将 HTTP 请求的正文解析为一个 JavaScript 对象 req.body。
在我们的用例中,我们需要这个中间件从 POST 请求中提取电子邮件和密码字段。最后,我们添加了 passport 中间件和会话支持。
注册用户
现在,我们添加了注册路由,包括一个带有基本表单的后端逻辑来创建用户。首先,我们将创建一个用户控制器。到目前为止,我们一直在将路由扔到 server.js 文件中,但这通常是一个不好的做法。我们想要做的是为每种我们想要的路线都有单独的控制器。我们已经看到了 MVC 的模型部分。现在是时候看看控制器了。我们的用户控制器将包含所有操作用户模型的路线。让我们在新的目录中创建一个新文件,controllers/user.js:
// controllers/user.js
var User = require('mongoose').model('User');
module.exports.showRegistrationForm = function(req, res, next) {
res.render('register');
};
module.exports.createUser = function(req, res, next) {
User.register(req.body.email, req.body.password, function(err, user) {
if (err) return next(err);
req.login(user, function(err) {
if (err) return next(err);
res.redirect('/');
});
});
};
注意
注意,User 模型负责验证和注册逻辑;我们只需提供回调。这样做有助于整合错误处理,并且通常使注册逻辑更容易理解。如果注册成功,我们调用 passport 添加的 req.login 函数,为该用户创建一个新的会话,并且该用户将在后续请求中作为 req.user 可用。
最后,我们注册了路由。在这个阶段,我们还提取了之前添加到 server.js 的路由到它们自己的文件。让我们创建一个名为 routes.js 的新文件,如下所示:
// routes.js
app.get('/users/register', userRoutes.showRegistrationForm);
app.post('/users/register', userRoutes.createUser);
现在,我们有一个专门用于将控制器处理程序与用户可以访问的实际路径关联的文件。这通常是一个好的做法,因为现在我们有一个地方可以访问并查看我们定义的所有路由。这也帮助清理了我们的 server.js 文件,该文件应该专门用于服务器配置。
注意
有关详细信息以及使用的注册模板,请参阅前面的代码。
用户认证
我们已经完成了认证用户所需的大部分工作(或者说,passport 已经完成了)。实际上,我们只需要设置认证路由和一个允许用户输入其凭据的表单。首先,我们将在用户控制器中添加处理程序:
// controllers/user.js
module.exports.showLoginForm = function(req, res, next) {
res.render('login');
};
module.exports.createSession = passport.authenticate('local', {
successRedirect: '/',
failureRedirect: '/login'
});
让我们分解一下我们的登录 post 中的发生的事情。我们创建了一个处理程序,它是调用 passport.authenticate('local', …) 的结果。这告诉 passport,该处理程序使用本地认证策略。因此,当有人访问该路由时,passport 将将任务委托给我们的 LocalStrategy。如果他们提供了有效的电子邮件/密码组合,我们的 LocalStrategy 将给 passport 提供现在认证的用户,passport 将将用户重定向到服务器根目录。如果电子邮件/密码组合不成功,passport 将将用户重定向到 /login,以便他们可以再次尝试。
然后,我们将这些回调绑定到 routes.js 中的路由:
app.get('/users/login', userRoutes.showLoginForm);
app.post('/users/login', userRoutes.createSession);
在这个阶段,我们应该能够使用相同的凭据注册账户并登录。(有关我们目前所在位置的详细信息,请参阅标签 0.2)。
使用 passport 进行 OAuth
现在,我们将添加对使用 Twitter、Google 和 GitHub 登录我们应用程序的支持。如果用户不想为您的应用程序注册单独的账户,这个功能非常有用。对于这些用户,通过这些提供者允许 OAuth 将提高转化率,并且通常会让用户的注册过程更加简单。
将 OAuth 添加到用户模型
在添加 OAuth 之前,我们需要在我们的用户模型上跟踪几个额外的属性。我们跟踪这些属性以确保如果有信息可以查找用户账户,我们不会允许重复的账户,并允许用户通过以下代码链接多个第三方账户:
var userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true
},
password: {
type: String,
},
created_at: {
type: Date,
default: Date.now
},
twitter: String,
google: String,
github: String,
profile: {
name: { type: String, default: '' },
gender: { type: String, default: '' },
location: { type: String, default: '' },
website: { type: String, default: '' },
picture: { type: String, default: '' }
},
});
首先,我们为每个提供者添加一个属性,我们将存储提供者在授权时给我们提供的唯一标识符。接下来,我们将存储一个令牌数组,这样我们可以方便地访问与该账户链接的提供者列表;如果您想允许用户通过一个注册然后链接到其他账户进行病毒式营销或获取更多信息,这将非常有用。最后,我们跟踪一些提供者给我们提供的关于用户的统计数据,以便我们可以为用户提供更好的体验。
获取 API 令牌
现在,我们需要前往适当的第三方并注册我们的应用程序以接收应用程序密钥和秘密令牌。我们将把这些添加到我们的配置中。我们将为开发和生产目的使用不同的令牌(出于明显的原因!)。出于安全考虑,我们只会在最终的部署服务器上以环境变量的形式拥有我们的生产令牌,不会提交到版本控制中。
我会等待您导航到第三方网站,并将它们的令牌按照以下方式添加到您的配置中:
// config.js
twitter: {
consumerKey: process.env.TWITTER_KEY || 'VRE4lt1y0W3yWTpChzJHcAaVf',
consumerSecret: process.env.TWITTER_SECRET || 'TOA4rNzv9Cn8IwrOi6MOmyV894hyaJks6393V6cyLdtmFfkWqe',
callbackURL: '/auth/twitter/callback'
},
google: {
clientID: process.env.GOOGLE_ID || '627474771522-uskkhdsevat3rn15kgrqt62bdft15cpu.apps.googleusercontent.com',
clientSecret: process.env.GOOGLE_SECRET || 'FwVkn76DKx_0BBaIAmRb6mjB',
callbackURL: '/auth/google/callback'
},
github: {
clientID: process.env.GITHUB_ID || '81b233b3394179bfe2bc',
clientSecret: process.env.GITHUB_SECRET || 'de0322c0aa32eafaa84440ca6877ac5be9db9ca6',
callbackURL: '/auth/github/callback'
}
小贴士
当然,您也不应该将开发密钥公开提交。请确保不要提交此文件或使用私有源代码控制。最好的办法是只让秘密在机器上短暂存在(通常作为环境变量)。您尤其不应该使用我这里提供的密钥!
第三方注册和登录
现在,我们需要安装和实现各种第三方注册策略。要安装第三方注册策略,请运行以下命令:
npm install --save passport-twitter passport-google-oAuth passport-github
这些大多数都非常相似,所以我只会展示TwitterStrategy,如下所示:
passport.use(new TwitterStrategy(config.twitter, function(req, accessToken, tokenSecret, profile, done) {
User.findOne({ twitter: profile.id }, function(err, existingUser) {
if (existingUser) return done(null, existingUser);
var user = new User();
// Twitter will not provide an email address. Period.
// But a person's twitter username is guaranteed to be unique
// so we can "fake" a twitter email address as follows:
// username@twitter.mydomain.com
user.email = profile.username + "@twitter." + config.domain + ".com";
user.twitter = profile.id;
user.tokens.push({ kind: 'twitter', accessToken: accessToken, tokenSecret: tokenSecret });
user.profile.name = profile.displayName;
user.profile.location = profile._json.location;
user.profile.picture = profile._json.profile_image_url;
user.save(function(err) {
done(err, user);
});
});
}));
在这里,我包括了一个示例,说明我们如何做这件事。首先,我们向 passport 传递一个新的 TwitterStrategy。TwitterStrategy 接受我们的 Twitter 密钥和回调信息,回调用于确保我们可以使用这些信息注册用户。如果用户已经注册,则不执行任何操作;否则,我们保存他们的信息,并将错误和/或成功保存的用户传递给回调。对于其他情况,请参阅源代码。
个人资料页面
现在是时候为我们的每个用户添加个人资料页面了。为了做到这一点,我们将进一步讨论 Express 路由以及如何将请求特定数据传递给 Jade 模板。在编写服务器时,通常希望捕获 URL 的一部分用于控制器;这可能是一个用户 ID、用户名,或者任何东西!我们将使用 Express 捕获 URL 部分的能力来获取请求个人资料页面的用户 ID。
URL 参数
Express,就像任何好的 Web 框架一样,支持从 URL 部分提取数据。例如,你可以这样做:
app.get('/users/:id', function(req, res, next) {
console.log(req.params.id);
}
在前面的例子中,我们将打印出请求 URL 中/users/之后的内容。这提供了一种简单的方式来指定针对每个用户的路由,或者只在特定用户上下文中才有意义的路由,也就是说,只有当你指定了特定用户时,个人资料页面才有意义。我们将使用这种路由来实现我们的个人资料页面。目前,我们想要确保只有登录用户能看到自己的个人资料页面(我们可以在以后更改这个功能):
app.get('/users/:id', function(req, res, next) {
if (!req.user || (req.user.id != req.params.id)) {
return next('Not found');
}
res.render('users/profile', { user: req.user.toJSON() });
});
在这里,我们首先检查用户是否已登录,以及请求的用户 ID 是否与登录用户的 ID 相同。如果不是,则返回错误。如果是,则使用req.user作为数据渲染users/profile.jade模板。
个人资料模板
我们已经详细地讨论了模型和控制器,但我们的模板一直很平淡。最后,我们将展示如何编写一些基本的 Jade 模板。本节将作为对 Jade 模板语言的简要介绍,但并不试图全面介绍。个人资料模板的代码如下:
html
body
h1
=user.email
h2
=user.created_at
- for (var prop in user.profile)
if user.profile[prop]
h4
=prop + "=" + user.profile[prop]
显然,因为在控制器中我们传递了用户到视图中,我们可以访问变量user,它指的是登录用户!我们可以通过在前面加上=前缀来执行任意 JavaScript 以渲染到模板中。在这些块中,我们可以做任何我们通常会做的事情,包括字符串连接、方法调用等等。
类似地,我们可以通过在前面加上-前缀,就像我们处理for循环那样,包含不打算作为 HTML 编写的 JavaScript 代码。这个基本模板会打印出用户的电子邮件、created_at时间戳,以及他们个人资料中的所有属性(如果有的话)。
注意
要深入了解 Jade,请参阅jade-lang.com/reference/。
测试
测试对于任何应用程序都是必不可少的。我不会过多地讨论原因,而是假设你因为我跳过了前几节中的这个话题而对我感到愤怒。测试 Express 应用程序通常相对简单且痛苦较少。一般格式是我们创建模拟请求,然后对响应做出某些断言。
我们也可以为更复杂的逻辑实现更细粒度的单元测试,但到目前为止,我们做的几乎所有事情都足够直接,可以按路由进行测试。此外,在 API 级别进行测试可以更真实地反映真实客户如何与您的网站互动,并使测试在面对代码重构时更加稳健。
介绍 Mocha
Mocha 是一个简单、灵活的测试框架运行器。首先,我建议全局安装 Mocha,这样您就可以轻松地从命令行运行测试,如下所示:
$ npm install --save-dev –g mocha
--save-dev 选项将 mocha 保存为开发依赖项,这意味着我们不需要在生产服务器上安装 Mocha。Mocha 只是一个测试运行器。我们还需要一个断言库。有各种各样的解决方案,但由 Express 和 Mocha 的同一人编写的 should.js 语法提供了一个干净的语法来制作断言:
$ npm install --save-dev should
should.js 语法提供了 BDD 断言,例如 'hello'.should.equal('hello') 和 [1,2].should.have.length(2)。我们可以通过创建一个名为 test 的目录并包含一个名为 hello-world.js 的单个文件来开始一个 Hello World 测试示例,如下面的代码所示:
var should = require('should');
describe('The World', function() {
it('should say hello', function() {
'Hello, World'.should.equal('Hello, World');
});
it('should say hello asynchronously!', function(done) {
setTimeout(function() {
'Hello, World'.should.equal('Hello, World');
done();
}, 300);
});
});
我们在同一命名空间 The World 中有两个不同的测试。第一个测试是一个同步测试的例子。Mocha 执行我们给它提供的函数,看到没有抛出异常,测试通过。如果我们接受一个 done 参数作为回调,就像第二个例子中那样,Mocha 将智能地等待我们调用回调函数后再检查测试的有效性。大部分情况下,我们将使用第二种版本,我们必须显式调用 done 参数来完成我们的测试,因为这样做对测试 Express 应用更有意义。
现在,如果我们回到命令行,我们应该能够运行 Mocha(或者如果您没有全局安装,则是 node_modules/.bin/mocha)并看到我们编写的两个测试都通过了!
测试 API 端点
现在我们已经基本了解了如何使用 Mocha 运行测试以及如何使用 should 语法进行断言,我们可以将其应用于测试本地用户注册。首先,我们需要引入另一个 npm 模块,它将帮助我们以编程方式测试我们的服务器并对我们期望的响应类型进行断言。这个库叫做 supertest:
$ npm install --save-dev supertest
这个库使测试 Express 应用变得轻而易举,并提供链式断言。让我们看看以下代码中如何测试我们的创建用户路由的示例:
var should = require('should'),
request = require('supertest'),
app = require('../server').app,
User = require('mongoose').model('User');
describe('Users', function() {
before(function(done) {
User.remove({}, done);
});
describe('registration', function() {
it('should register valid user', function(done) {
request(app)
.post('/users/register')
.send({
email: "test@example.com",
password: "hello world"
})
.expect(302)
.end(function(err, res) {
res.text.should.containEql("Redirecting to /");
done(err);
});
});
});
});
首先,请注意我们使用了两个命名空间:Users 和 registration。现在,在我们运行任何测试之前,我们从数据库中删除所有用户。这样做有助于确保我们知道测试的起点。这将删除您保存的所有用户,因此在使用测试环境时使用不同的数据库是有用的。Node 通过查看 NODE_ENV 环境变量来检测环境。通常它是测试、开发、预发布或生产。我们可以通过更改配置文件中的数据库 URL 来使用不同的本地数据库,在测试环境中运行 Mocha 测试,命令为 NODE_ENV=test mocha。
现在,让我们来看看有趣的部分!Supertest 提供了一个链式 API,用于发送请求并对响应进行断言。要发送请求,我们使用 request(app)。从那里,我们指定 HTTP 方法和方法路径。然后,我们可以指定要发送到服务器的 JSON 主体;在这种情况下,是一个示例用户注册表单。在注册时,我们期望发生重定向,即 302 响应。如果该断言失败,那么我们回调中的 err 参数将被填充,并且当使用 done(err) 时测试将失败。此外,我们验证是否被重定向到我们期望的路由,即服务器根 /。
自动化构建和部署
所有这些开发如果没有一个顺畅的构建和部署应用程序的过程都是相对无用的。幸运的是,Node 社区编写了各种任务运行器。其中包含 Grunt 和 Gulp,它们是最受欢迎的任务运行器之一。两者都与 Express 无缝协作,为我们提供了一系列实用工具,包括连接和压缩 JavaScript、编译 sass/less,以及在本地文件更改时重新加载服务器。我们将专注于 Grunt,以保持简单。
介绍 Gruntfile
Grunt 本身是一个简单的任务运行器,但它的可扩展性和插件架构允许您安装第三方脚本以在预定义的任务中运行。为了让我们了解如何使用 Grunt,我们将使用 sass 编写我们的 css,然后使用 Grunt 将 sass 编译为 css。通过这个示例,我们将探索 Grunt 引入的不同想法。首先,您需要全局安装 cli 以安装编译 sass 为 css 的插件:
$ npm install -g grunt-cli
$ npm install --save grunt grunt-contrib-sass
现在我们需要创建 Gruntfile.js,它包含了所有我们需要执行的任务和构建目标。为此,请执行以下操作:
// Gruntfile.js
module.exports = function(grunt) {
grunt.loadNpmTasks('grunt-contrib-sass');
grunt.initConfig({
sass: {
dist: {
files: [{
expand: true,
cwd: "public/styles",
src: ["**.scss"],
dest: "dist/styles",
ext: ".css"
}]
}
}
});
}
让我们来看看主要部分。在最上面,我们引入了我们将使用的插件,grunt-contrib-sass。这告诉 grunt 我们将配置一个名为 sass 的任务。在我们的任务定义 sass 中,我们指定了一个目标,dist,它通常用于生成生产文件的任务(最小化、连接等)。
在那个任务中,我们动态构建文件列表,告诉 Grunt 递归地在 /public/styles/ 中查找所有 .scss 文件,然后将它们编译到 /dist/styles 的相同路径。拥有两个并行的静态目录很有用,一个用于开发,一个用于生产,这样我们就不必在开发时查看压缩代码。我们可以通过执行 grunt sass 或 grunt sass:dist 来调用这个目标。
注意
值得注意的是,我们在这个任务中没有明确地连接文件,但如果我们使用 @imports 在我们的主 sass 文件中,编译器会为我们连接所有内容。
我们还可以配置 Grunt 来运行我们的测试套件。为此,让我们添加另一个插件 -- npm install --save-dev grunt-mocha-test。现在我们必须将以下代码添加到我们的 Gruntfile.js 文件中:
grunt.loadNpmTasks('grunt-mocha-test');
grunt.registerTask('test', 'mochaTest');
...
mochaTest: {
test: {
src: ["test/**.js"]
}
}
在这里,任务被称作 mochaTest,我们注册了一个名为 test 的新任务,它简单地委托给 mochaTest 任务。这样,记住如何运行测试就更容易了。同样,如果我们把字符串数组作为 registerTask 的第二个参数传递,我们也可以指定要运行的任务列表。这是 Grunt 可以实现的功能的样本。要查看一个更健壮的 Gruntfile 示例,请查看源代码。
与 Travis 的持续集成
Travis CI 为开源项目提供免费的持续集成服务,同时也为闭源应用程序提供付费选项。它使用 git 钩子在每次推送后自动测试你的应用程序。这有助于确保没有引入回归。此外,可能只有 CI 才能揭示的依赖性问题,而本地开发可能会掩盖这些问题;Travis 是这些错误的防线。它获取你的源代码,运行 npm install 以安装 package.json 中指定的依赖项,然后运行 npm test 来运行你的测试套件。
Travis 接受一个名为 travis.yml 的配置文件。这些通常看起来像这样:
language: node_js
node_js:
- "0.11"
- "0.10"
- "0.8"
services:
- mongodb
我们可以指定要测试的 node 版本以及我们依赖的服务(特别是 MongoDB)。现在我们必须更新 package.json 中的测试命令,以运行 grunt test。最后,我们必须为相关的仓库设置一个 webhook。我们可以在 Travis 上通过启用仓库来完成此操作。现在我们只需推送我们的更改,Travis 就会确保所有测试通过!Travis 非常灵活,你可以用它来完成与持续集成相关的几乎所有任务,包括自动部署成功的构建。
部署 Node.js 应用程序
部署 Node.js 应用程序的最简单方法之一是利用 Heroku,这是一个平台即服务提供商。Heroku 有自己的工具包,可以从你的机器上创建和部署 Heroku 应用程序。在开始使用 Heroku 之前,你需要安装其工具包。
注意
请访问 toolbelt.heroku.com/ 下载 Heroku 工具包。
一旦安装,你可以通过 Web UI 登录 Heroku 或注册,然后运行 Heroku login。Heroku 使用一个特殊的文件,称为 Procfile,它指定了如何运行你的应用程序。
-
我们的 Procfile 看起来像这样:
web: node server.js非常简单:为了运行 Web 服务器,只需运行 node server.js。
-
为了验证我们的 Procfile 是否正确,我们可以在本地运行以下命令:
$ foreman start -
Foreman 会查看 Procfile,并使用它来尝试启动我们的服务器。一旦成功运行,我们需要创建一个新的应用程序,然后将我们的应用程序部署到 Heroku。请确保将 Procfile 提交到版本控制:
$ heroku create $ git push heroku masterHeroku 将在 Heroku 上创建一个新的应用程序和 URL,以及一个名为 heroku 的 git 远程仓库。推送该远程仓库实际上会触发你的代码部署。
如果你做了所有这些,不幸的是,你的应用程序将无法工作。我们没有为我们的应用程序提供 Mongo 实例进行通信!
-
首先,我们必须从 Heroku 请求 MongoDB:
$ heroku addons:add mongolab // don't worry, it's free这将启动一个共享的 MongoDB 实例,并给我们的应用程序一个名为
MONOGOLAB_URI的环境变量,我们应该将其用作我们的 MongoDB 连接 URI。我们需要更改我们的配置文件以反映这些更改。在我们的配置文件中,在生产环境中,对于我们的数据库 URL,我们应该查看环境变量
MONGOLAB_URI。同时,确保 Express 正在监听process.env.PORT || 3000,否则你将收到奇怪的错误和/或超时。 -
在设置好所有这些之后,我们可以提交我们的更改,并将更改再次推送到 Heroku。希望这次能成功!为了查看应用程序日志进行调试,只需使用 Heroku 工具包:
$ heroku logs -
关于部署 Express 应用程序的最后一件事:有时应用程序会崩溃,软件并不完美。我们应该预料到崩溃,并让我们的应用程序相应地做出反应(通过自动重启)。有许多服务器监控工具,包括 pm2 和 forever。我们使用 forever 是因为它的简单性。
$ npm install --save forever -
然后,我们更新我们的 Procfile 以反映我们对 forever 的使用:
// Procfile web: node_modules/.bin/forever server.js
现在,如果应用程序因为任何奇怪的原因崩溃,forever 将自动重启我们的应用程序。你还可以设置 Travis 以自动将成功的构建推送到你的服务器,但这超出了本书中我们将进行的部署。
摘要
在本章中,我们在 node 的世界里涉足,并使用 Express 框架。我们从 Hello World 和 MVC 到测试和部署都进行了介绍。你应该能够舒适地使用基本的 Express API,同时也应该有能力掌握整个Node.js应用程序栈。
在以下章节中,我们将基于本章介绍的核心思想来创建丰富的用户体验和引人入胜的应用程序。
第二章.一个健壮的电影 API
我们将构建一个电影 API,允许您将演员和电影信息添加到数据库中,并将演员与电影连接起来,反之亦然。这将利用在第一章中介绍的信息,构建基本的 Express 网站,并让您亲身体验 Express.js 能提供什么。在本章中,我们将涵盖以下主题:
-
文件夹结构和组织
-
响应 CRUD 操作
-
使用 Mongoose 进行对象建模
-
生成唯一 ID
-
测试
文件夹结构和组织
文件夹结构是一个非常有争议的话题。尽管有许多干净的方式来组织你的项目,但我们将使用以下代码作为我们后续章节的示例:
chapter2
├── app.js
├── package.json ├── node_modules
│└── npm package folders ├── src
│├── lib
│├── models
│├── routes
└── test
让我们详细看看:
-
app.js:在根目录中通常有一个主要的app.js文件。app.js是应用程序的入口点,将用于启动服务器。 -
package.json:与任何 Node.js 应用程序一样,我们在根目录中都有package.json,指定我们的应用程序名称和版本以及所有 npm 依赖项。 -
node_modules:node_modules文件夹及其内容是通过 npm 安装生成的,通常应该在你的版本控制中忽略,因为它依赖于应用程序运行的平台。话虽如此,根据 npm FAQ,将node_modules文件夹提交可能更好。注意
将部署的项目,如网站和应用程序,的
node_modules检查到 git 中。不要将用于重用的库和模块的node_modules检查到 git 中。参考以下文章了解更多关于这一做法的原理:
-
src:src文件夹包含应用程序的所有逻辑。 -
lib:在src文件夹中,我们有lib文件夹,它包含应用程序的核心。这包括中间件、路由以及创建数据库连接。 -
models:models文件夹包含我们的mongoose模型,它定义了我们想要操作和保存的模型的结构和逻辑。 -
routes:routes文件夹包含 API 能够服务的所有端点的代码。 -
test:test文件夹将包含我们使用 Mocha 以及两个其他 node 模块should和supertest的功能测试,以使达到 100%覆盖率变得更容易。
响应 CRUD 操作
CRUD 术语指的是可以在数据上执行的四项基本操作:创建、读取、更新和删除。Express 通过支持基本方法GET、POST、PUT和DELETE,为我们处理这些操作提供了一个简单的方法:
-
GET:此方法用于从数据库中检索现有数据。这可以用来从数据库中读取单行或多行(对于 SQL)或文档(对于 MongoDB)。 -
POST:此方法用于将新数据写入数据库,并且通常也会包含一个符合数据模型的 JSON 有效负载。 -
PUT:此方法用于在数据库中更新现有数据,并且通常也会包含一个符合数据模型的 JSON 有效负载。 -
DELETE:此方法用于从数据库中删除现有行或文档。
Express 4 与版本 3 相比发生了巨大变化。为了使其更加轻量级和减少依赖,许多核心模块已被移除。因此,我们需要在需要时显式地 require 模块。
一个有用的模块是 body-parser。它允许我们在接收到 POST 或 PUT HTTP 请求时获取一个格式良好的体。我们必须在业务逻辑之前添加此中间件,以便稍后使用其结果。我们在 src/lib/parser.js 中写入以下内容:
var bodyParser = require('body-parser');
module;exports = function(app) {
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
};
上述代码随后被用于 src/lib/app.js 中,如下所示:
var express = require('express'); var app = express();
require('./parser')(app);
module.exports = app;
以下示例允许您对 http://host/path 上的 GET 请求做出响应。一旦请求击中我们的 API,Express 将运行它通过必要的中间件以及以下函数:
app.get('/path/:id', function(req, res, next) {
res.status(200).json({ hello: 'world'});
});
第一个参数是我们想要处理的 GET 函数的路径。路径可以包含以 : 为前缀的参数。这些路径参数随后将在请求对象中解析。
第二个参数是当服务器收到请求时将执行的回调。此函数包含三个参数:req、res 和 next。
req 参数代表由 Express 和我们在应用程序中添加的中间件定制的 HTTP 请求对象。使用路径 http://host/path/:id,假设向 http://host/path/1?a=1&b=2 发送了一个 GET 请求。req 对象将是以下内容:
{
params: { id: 1 }, query: { a: 1, b: 2 }
}
params 对象是路径参数的表示。query 是查询字符串,它是 URL 中 ? 后的值。在 POST 请求中,我们的请求对象中通常还会有一个体,其中包含我们希望放入数据库中的数据。
res 参数代表该请求的响应对象。一些方法,如 status() 或 json(),被提供以便告诉 Express 如何响应用户。
最后,next() 函数将执行我们应用程序中定义的下一个中间件。
使用 GET 检索演员
从数据库中检索电影或演员包括向路由 /movies/:id 或 /actors/:id 提交一个 GET 请求。我们需要一个唯一的 ID 来引用一个唯一的电影或演员:
app.get('/actors/:id', function(req, res, next) {
//Find the actor object with this :id
//Respond to the client
});
在这里,URL 参数 :id 将被放置在我们的请求对象中。由于我们在回调函数中之前将第一个变量命名为 req,因此我们可以通过调用 req.params.id 来访问 URL 参数。
由于一个演员可能参演多部电影,而一部电影可能有多个演员,因此我们需要一个嵌套端点来反映这一点:
app.get('/actors/:id/movies', function(req, res, next) {
//Find all movies the actor with this :id is in
//Respond to the client
});
如果提交了一个坏的GET请求或者没有找到指定 ID 的演员,那么将返回适当的错误状态码400(错误请求)或404(未找到)。如果找到了演员,则将发送成功请求200,并附带演员信息。成功时,响应的 JSON 将如下所示:
{
"_id": "551322589911fefa1f656cc5", "id": 1,
"name": "AxiomZen", "birth_year": 2012, "__v": 0, "movies": []
}
使用 POST 创建新的演员
在我们的 API 中,在数据库中创建一个新的电影涉及向/movies或为新的演员向/actors提交一个POST请求:
app.post('/actors', function(req, res, next) {
//Save new actor
//Respond to the client
});
在这个例子中,访问我们的 API 的用户发送一个包含将被放置到request.body中的数据的POST请求。在这里,我们将回调函数中的第一个变量称为req。因此,要访问请求体,我们调用req.body。
请求体以 JSON 字符串的形式发送;如果发生错误,将返回400(错误请求)状态。否则,将向响应对象发送201(已创建)状态。在成功请求的情况下,响应将如下所示:
{
"__v": 0, "id": 1,
"name": "AxiomZen", "birth_year": 2012,
"_id": "551322589911fefa1f656cc5", "movies": []
}
使用 PUT 更新演员
要更新电影或演员条目,我们首先创建一个新的路由,并向/movies/:id或/actors/:id提交一个PUT请求,其中id参数是现有movie/actor的唯一标识。更新有两个步骤。我们首先使用唯一的 ID 找到电影或演员,然后使用请求对象的主体更新该条目,如下面的代码所示:
app.put('/actors/:id', function(req, res) {
//Find and update the actor with this :id
//Respond to the client
});
在请求中,我们需要request.body是一个 JSON 对象,它反映了要更新的演员字段。request.params.id仍然是一个唯一的标识符,它引用数据库中现有的演员,就像之前一样。在成功更新后,响应的 JSON 看起来如下所示:
{
"_id": "551322589911fefa1f656cc5",
"id": 1,
"name": "Axiomzen", "birth_year": 99, "__v": 0, "movies": []
}
这里,响应将反映我们对数据所做的更改。
使用 DELETE 删除演员
删除电影就像提交一个DELETE请求到之前使用的相同路由(指定 ID)一样简单。找到具有适当 ID 的演员,然后将其删除:
app.delete('/actors/:id', function(req, res) {
//Remove the actor with this :id
//Respond to the client
});
如果找到具有唯一 ID 的演员,则将其删除,并返回响应代码204。如果演员找不到,则返回响应代码400。对于DELETE()方法没有响应体;它将简单地返回成功删除的状态代码204。
我们这个简单应用的最终端点将如下所示:
//Actor endpoints
app.get('/actors', actors.getAll);
app.post('/actors', actors.createOne);
app.get('/actors/:id', actors.getOne);
app.put('/actors/:id', actors.updateOne);
app.delete('/actors/:id', actors.deleteOne)
app.post('/actors/:id/movies', actors.addMovie);
app.delete('/actors/:id/movies/:mid', actors.deleteMovie);
//Movie endpoints
app.get('/movies', movies.getAll);
app.post('/movies', movies.createOne);
app.get('/movies/:id', movies.getOne);
app.put('/movies/:id', movies.updateOne);
app.delete('/movies/:id', movies.deleteOne);
app.post('/movies/:id/actors', movies.addActor);
app.delete('/movies/:id/actors/:aid', movies.deleteActor);
在 Express 4 中,有一种描述路由的替代方法。具有相同 URL 但使用不同 HTTP 动词的路由可以如下分组:
app.route('/actors')
.get(actors.getAll)
.post(actors.createOne);
app.route('/actors/:id')
.get(actors.getOne)
.put(actors.updateOne)
.delete(actors.deleteOne);
app.post('/actors/:id/movies', actors.addMovie);
app.delete('/actors/:id/movies/:mid', actors.deleteMovie);
app.route('/movies')
.get(movies.getAll)
.post(movies.createOne);
app.route('/movies/:id')
.get(movies.getOne)
.put(movies.updateOne)
.delete(movies.deleteOne);
app.post('/movies/:id/actors', movies.addActor);
app.delete('/movies/:id/actors/:aid', movies.deleteActor);
无论你是否喜欢这种方式,这取决于你。至少现在你有选择!
我们还没有讨论每个端点运行函数的逻辑。我们很快就会讨论这个问题。
Express 允许我们轻松地 CRUD 我们的数据库对象,但我们如何对对象进行建模呢?
使用 Mongoose 进行对象建模
Mongoose 是一个对象数据建模库(ODM),允许你为你的数据集合定义模式。你可以在项目网站上了解更多关于 Mongoose 的信息:mongoosejs.com/。
要使用mongoose变量连接到 MongoDB 实例,我们首先需要安装 npm 并保存 Mongoose。save标志会自动将模块添加到你的package.json中,并使用最新版本,因此,始终建议使用save标志安装你的模块。对于你只需要本地使用的模块(例如,Mocha),你可以使用savedev标志。
对于这个项目,我们在/src/lib/db.js下创建了一个新的文件db.js,它需要 Mongoose。在mongoose.connect中建立到mongodb数据库的本地连接,如下所示:
var mongoose = require('mongoose');
module.exports = function(app)
{
mongoose.connect('mongodb://localhost/movies', {
mongoose: { safe: true
}
}, function(err) { if (err)
{
return console.log('Mongoose - connection error:', err);
}
});
return mongoose;
};
在我们的电影数据库中,我们需要为演员和电影分别创建模式。作为一个例子,我们将通过在演员数据库/src/models/actor.js中创建演员模式来遍历对象建模,如下所示:
// /src/models/actor.js
var mongoose = require('mongoose');
var generateId = require('./plugins/generateId');
var actorSchema = new mongoose.Schema({
id: {
type: Number,
required: true,
index: {
unique: true
}
},
name: {
type: String,
required: true
},
birth_year: {
type: Number,
required: true
},
movies: [{
type : mongoose.Schema.ObjectId,
ref : 'Movie'
}]
});
actorSchema.plugin(generateId());
module.exports = mongoose.model('Actor', actorSchema);
每个演员都有一个唯一的 id、一个名字和一个出生年份。条目还包含所需的有效验证器,如类型和布尔值。模型在定义后导出(module.exports),这样我们就可以直接在应用中重用它。
或者,你也可以通过 Mongoose 使用mongoose.model('Actor', actorSchema)来获取每个模型,但这与我们的直接要求它的方法相比,感觉不那么明确耦合。
类似地,我们还需要一个电影模式。我们定义电影模式如下:
// /src/models/movies.js
var movieSchema = new mongoose.Schema({
id: {
type: Number,
required: true,
index: {
unique: true
}
},
title: {
type: String,
required: true
},
year: {
type: Number,
required: true
},
actors: [{
type : mongoose.Schema.ObjectId,
ref : 'Actor'
}]
});
movieSchema.plugin(generateId());
module.exports = mongoose.model('Movie', movieSchema);
生成唯一 ID
在我们的电影和演员模式中,我们使用了一个名为generateId()的插件。
虽然 MongoDB 自动使用_id字段为每个文档生成ObjectID,但我们想生成更易于阅读的 ID,因此更友好。我们还希望给用户选择他们自己的 id 的机会。
然而,能够选择一个 id 可能会导致冲突。如果你选择了一个已经存在的 id,你的POST请求将被拒绝。如果用户没有明确传递 id,我们应该自动生成一个 ID。
如果没有这个插件,如果用户没有明确传递 ID,无论是创建演员还是电影,服务器都会抱怨,因为 ID 是必需的。
我们可以为 Mongoose 创建中间件,在持久化对象之前分配一个id,如下所示:
// /src/models/plugins/generateId.js
module.exports = function() {
return function generateId(schema){
schema.pre('validate',function(next, done) {
var instance = this;
var model = instance.model(instance.constructor.modelName);
if( instance.id == null ) {
model.findOne().sort("-id").exec(function(err,maxInstance) {
if (err){
return done(err);
} else {
var maxId = maxInstance.id || 0;
instance.id = maxId+1;
done();
}
})
} else {
done();
}
})
}
};
关于此代码有几个重要的注意事项。
看看我们是如何得到var模型的?这使得插件通用,因此它可以应用于多个 Mongoose 模式。
注意,有两个回调可用:next和done。next变量将代码传递给下一个预验证中间件。这通常是好事,因为异步调用的一项优点是你可以同时运行许多事情。
然而,在这种情况下,我们不能调用next变量,因为它会与我们的 id 模式定义冲突。因此,当逻辑完成时,我们只使用done变量。
由于 MongoDB 不支持事务,因此可能会出现一些边缘情况导致此函数失败。例如,如果同时发生两个对 POST /actor 的调用,它们都将自动递增到相同的 ID 值。
现在我们有了 generateId() 插件的代码,我们按照以下方式在我们的演员和电影模式中引入它:
var generateId = require('./plugins/generateId');
actorSchema.plugin(generateId());
验证您的数据库
Mongoose 模式中每个键都定义了一个与 SchemaType 关联的属性。例如,在我们的 actors.js 模式中,演员的姓名键与字符串 SchemaType 关联。字符串、数字、日期、缓冲区、布尔值、混合、objectId 和数组都是有效的模式类型。
除了模式类型之外,数字有最小和最大验证器,字符串有枚举和匹配验证器。验证发生在文档正在保存 (.save()) 时,如果验证失败,将返回一个包含类型、路径和值属性的错误对象。
将函数提取为可重用的中间件
我们可以使用我们的匿名或命名函数作为中间件。为此,我们通过在 routes/actors.js 和 routes/movies.js 中调用 module.exports 来导出我们的函数:
让我们看看我们的 routes/actors.js 文件。在这个文件的顶部,我们引入了我们之前定义的 Mongoose 模式:
var Actor = require('../models/actor');
这允许我们的变量 actor 使用 mongo 函数(如 find()、create() 和 update())访问我们的 MongoDB。它将遵循 /models/actor 文件中定义的模式。
由于演员在电影中,我们还需要通过以下方式引入 Movie 模式来显示这种关系。
var Movie = require('../models/movie');
现在我们有了我们的模式,我们可以开始定义我们在端点中描述的函数的逻辑。例如,端点 GET /actors/:id 将从我们的数据库中检索具有相应 ID 的演员。让我们称这个函数为 getOne()。它定义如下:
getOne: function(req, res, next) { Actor.findOne({ id: req.params.id })
.populate('movies')
.exec(function(err, actor) {
if (err) return res.status(400).json(err); if (!actor) return res.status(404).json(); res.status(200).json(actor);
});
},
在这里,我们使用 mongo 的 findOne() 方法来检索具有 id: req.params.id 的演员。MongoDB 中没有连接,所以我们使用 .populate() 方法来检索演员参演的电影。
.populate() 方法将根据其 ObjectId 从单独的集合中检索文档。
如果我们的 Mongoose 驱动程序出现错误,此函数将返回状态 400,如果找不到具有 :id 的演员,则返回状态 404,最后,如果找到演员,它将返回状态 200 并附带演员对象的 JSON。
我们在这个文件中定义了所有用于演员端点的函数。结果如下:
// /src/routes/actors.js
var Actor = require('../models/actor');
var Movie = require('../models/movie');
module.exports = {
getAll: function(req, res, next) {
Actor.find(function(err, actors) {
if (err) return res.status(400).json(err);
res.status(200).json(actors);
});
},
createOne: function(req, res, next) {
Actor.create(req.body, function(err, actor) {
if (err) return res.status(400).json(err);
res.status(201).json(actor);
});
},
getOne: function(req, res, next) {
Actor.findOne({ id: req.params.id })
.populate('movies')
.exec(function(err, actor) {
if (err) return res.status(400).json(err);
if (!actor) return res.status(404).json();
res.status(200).json(actor);
});
},
updateOne: function(req, res, next) {
Actor.findOneAndUpdate({ id: req.params.id }, req.body,function(err, actor) {
if (err) return res.status(400).json(err);
if (!actor) return res.status(404).json();
res.status(200).json(actor);
});
},
deleteOne: function(req, res, next) {
Actor.findOneAndRemove({ id: req.params.id }, function(err) {
if (err) return res.status(400).json(err);
res.status(204).json();
});
},
addMovie: function(req, res, next) {
Actor.findOne({ id: req.params.id }, function(err, actor) {
if (err) return res.status(400).json(err);
if (!actor) return res.status(404).json();
Movie.findOne({ id: req.body.id }, function(err, movie) {
if (err) return res.status(400).json(err);
if (!movie) return res.status(404).json();
actor.movies.push(movie);
actor.save(function(err) {
if (err) return res.status(500).json(err);
res.status(201).json(actor);
});
})
});
},
deleteMovie: function(req, res, next) {
Actor.findOne({ id: req.params.id }, function(err, actor) {
if (err) return res.status(400).json(err);
if (!actor) return res.status(404).json();
actor.movies = [];
actor.save(function(err) {
if (err) return res.status(400).json(err);
res.status(204).json(actor);
})
});
}
};
对于我们所有的电影端点,我们需要相同的函数,但应用于电影集合。
导出这两个文件后,我们在 app.js (/src/lib/app.js) 中通过简单地添加以下内容来引入它们:
require('../routes/movies'); require('../routes/actors');
通过将我们的函数作为可重用的中间件导出,我们保持我们的代码整洁,并且可以在 /routes 文件夹中的 CRUD 调用中引用函数。
测试
Mocha 被用作测试框架,同时使用 should.js 和 supertest。为什么我们在应用程序中使用测试以及 Mocha 的基础知识在 第一章 Building a Basic Express Site 中进行了介绍。使用 supertest 进行测试可以让你测试你的 HTTP 断言和 API 端点。
测试被放置在根目录 /test 中。测试与任何源代码完全分离,并编写为纯英文可读,也就是说,你应该能够通过阅读它们来了解正在测试的内容。编写良好的测试用例,具有良好的覆盖率,可以作为其 API 的说明文档,因为它清楚地描述了整个应用程序的行为。
测试我们的电影 API 的初始设置对 /test/actors.js 和 /test/movies.js 都是相同的,如果你已经阅读了 第一章 Building a Basic Express Site,那么这应该看起来很熟悉。
var should = require('should'); var assert = require('assert');
var request = require('supertest');
var app = require('../src/lib/app');
在 src/test/actors.js 中,我们测试基本的 CRUD 操作:创建新的演员对象、检索、编辑和删除演员对象。以下是一个创建新演员的示例测试:
describe('Actors', function() {
describe('POST actor', function(){
it('should create an actor', function(done){
var actor = {
'id': '1',
'name': 'AxiomZen', 'birth_year': '2012',
};
request(app)
.post('/actors')
.send(actor)
.expect(201, done)
});
我们可以看到,测试用例可以用纯英文阅读。我们为数据库中的新演员创建一个新的 POST 请求,该演员的 id 为 1,name 为 AxiomZen,birth_year 为 2012。然后,我们使用 .send() 函数发送请求。以下代码中给出了 GET 和 DELETE 请求的类似测试。
describe('GET actor', function() {
it('should retrieve actor from db', function(done){
request(app)
.get('/actors/1')
.expect(200, done);
});
describe('DELETE actor', function() {
it('should remove a actor', function(done) {
request(app)
.delete('/actors/1')
.expect(204, done);
});
});
要测试我们的 PUT 请求,我们将编辑第一个演员的 name 和 birth_year,如下所示:
describe('PUT actor', function() {
it('should edit an actor', function(done) {
var actor = {
'name': 'ZenAxiom',
'birth_year': '2011'
};
request(app)
.put('/actors/1')
.send(actor)
.expect(200, done);
});
it('should have been edited', function(done) {
request(app)
.get('/actors/1')
.expect(200)
.end(function(err, res) {
res.body.name.should.eql('ZenAxiom');
res.body.birth_year.should.eql(2011);
done();
});
});
});
测试的第一部分修改了演员的 name 和 birth_year 键,向 /actors/1 发送 PUT 请求(1 是演员的 id),然后将新信息保存到数据库中。测试的第二部分检查具有 id 1 的演员的数据库条目是否已更改。使用 .should.eql() 检查 name 和 birth_year 的值是否与预期值相符。
除了对演员对象执行 CRUD 操作外,我们还可以对每个演员添加的电影(通过演员的 ID 关联)执行这些操作。以下代码片段展示了向我们的第一个演员(id 为 1)添加新电影的测试。
describe('POST /actors/:id/movies', function() {
it('should successfully add a movie to the actor',function(done) {
var movie = {
'id': '1',
'title': 'Hello World',
'year': '2013'
}
request(app)
.post('/actors/1/movies')
.send(movie)
.expect(201, done)
});
});
it('actor should have array of movies now', function(done){
request(app)
.get('/actors/1')
.expect(200)
.end(function(err, res) {
res.body.movies.should.eql(['1']);
done();
});
});
});
测试的第一部分创建了一个新的电影对象,包含 id、title 和 year 键,并向具有 id 为 1 的演员发送一个 POST 请求,将电影数组添加到该演员。测试的第二部分发送一个 GET 请求以检索具有 id 为 1 的演员,此时应该包含一个包含新电影输入的数组。
我们可以类似地删除 actors.js 测试文件中的电影条目:
describe('DELETE /actors/:id/movies/:movie_id', function() {
it('should successfully remove a movie from actor', function(done){
request(app)
.delete('/actors/1/movies/1')
.expect(200, done);
});
it('actor should no longer have that movie id', function(done){
request(app)
.get('/actors/1')
.expect(201)
.end(function(err, res) {
res.body.movies.should.eql([]);
done();
});
});
});
再次强调,这个代码片段应该对您来说很熟悉。第一部分测试发送指定演员 ID 和电影 ID 的 DELETE 请求将删除该电影条目。在第二部分中,我们通过提交一个 GET 请求来查看演员的详细信息(不应列出任何电影),以确保该条目不再存在。
除了确保基本的 CRUD 操作正常工作外,我们还测试了我们的模式验证。以下代码测试确保没有两个具有相同 ID 的演员存在(ID 被指定为唯一的):
it('should not allow you to create duplicate actors', function(done) {
var actor = {
'id': '1',
'name': 'AxiomZen',
'birth_year': '2012',
};
request(app)
.post('/actors')
.send(actor)
.expect(400, done);
});
如果我们尝试创建一个已在数据库中存在的演员,我们应该期望代码返回 400(错误请求)。
对于 tests/movies.js,也存在类似的测试集。每个测试的功能和结果现在应该很清楚。
摘要
在本章中,我们创建了一个基本的 API,该 API 连接到 MongoDB 并支持 CRUD 方法。现在您应该能够为任何数据(而不仅仅是电影和演员)设置一个包含测试的完整 API!
聪明的读者会注意到,我们尚未在本章中解决一些问题,例如在 MongoDB 中处理竞争条件。这些问题将在接下来的章节中详细说明。
我们希望您发现这一章节为 Express 和 API 设置奠定了良好的基础。
第三章. 多人游戏 API – 连接 4
连接 4 是一个回合制的两人游戏,每个玩家会向下投掷一个棋子,目标是让同一颜色的四个棋子连成一线。可以是垂直、水平或对角线。
在本章中,我们将构建 Connect4-as-a-Service。一个 API,允许你在任何客户端上构建一个 Connect 4 游戏,无论是网站、移动应用,还是从命令行玩;为什么不呢?
在第一章,构建基本的 Express 网站和第二章,MMO 文字游戏中,我们介绍了 Express 支持的 API 最通用的用例,即从数据库中服务和持久化数据。在本章中,我们将介绍一些更有趣的内容。我们将构建一个多人游戏 API!
我们将涵盖的一些主题包括身份验证、游戏状态建模和验证中间件。此外,我们将使用测试驱动开发构建一个具有最大代码覆盖率的 app。
为了您的参考,这是我们应用文件夹的结构,我们将在本章中构建它:

你如何创建一个游戏?你如何加入一个游戏?你如何移动?以及你如何将游戏状态持久化到数据库中?
总是先从数据结构开始是一个好主意。那么,让我们开始吧!
使用 Mongoose 建模游戏状态
我们将用二维数组来表示棋盘,其中的值是 'x'、'o' 或 ' ',代表网格上每个位置的三个可能状态。以下是一个示例,其中玩家 2 赢得了游戏:

这个游戏状态将如下所示表示为一个数组:
[ [' ',' ',' ',' ',' ',' ',' ',' '],
[' ',' ',' ',' ',' ',' ',' ',' '],
[' ','o',' ',' ',' ',' ',' ',' '],
[' ','x','o','o','o',' ',' ',' '],
[' ','x','x','o','x',' ',' ',' '],
['o','x','x','x','o',' ',' ',' '] ]
如果游戏是在本地进行,并且状态存储在内存中,那么这将是足够的。在我们的情况下,我们想在互联网上玩游戏,所以我们需要一种方法来识别我们在玩哪个游戏,以及你是哪个玩家,以及轮到谁了。一个游戏文档将如下所示:
{
boardId: '<id>',
p1Key: '<p1key>',
p1Name: 'express',
p2Key: '<p2key>',
p2Name: 'koa',
columns: 7,
rows: 6,
status: 'Game in progress',
winner: undefined,
turn: 1,
board: [...]
}
这里是参数:
| 参数 | 描述 |
|---|---|
boardId |
如果你想要查看当前游戏状态,这将是一个唯一的 ID。 |
p1Key |
这是一个用于识别玩家 1 的秘密令牌;我们当然想避免作弊的可能性 |
p1Name |
这是玩家 1 的名字 |
p2Key |
这是一个用于识别玩家 2 的秘密令牌 |
p2Name |
这是玩家 2 的名字 |
turn |
这是这个棋盘上玩过的总回合数 |
rows |
这是游戏板的行数 |
columns |
这是游戏板的列数 |
board |
这是一个存储在二维数组中的游戏状态 |
status |
这将是“游戏进行中”或“游戏结束”。 |
winner |
游戏结束后,这是获胜者的名字 |
让我们使用与第二章第二章。构建一个基本的 Express 网站中介绍相同的 app 文件夹结构,并在 src/models/game.js 中定义前面的内容作为一个 Mongoose 模型:
var mongoose = require('mongoose');
var gameSchema = new mongoose.Schema({
type: String,
required: true
},
p2Key: {
type: String,
required: true
},
p1Name: {
type: String,
required: true
},
p2Name: {
type: String
},
turn: {
type: Number,
required: true
},
boardId: {
type: String,
required: true,
index: {
unique: true
}
},
board: {
type: Array,
required: true
},
rows: {
type: Number,
required: true
},
columns: {
type: Number,
required: true
},
status: {
type: String
},
winner: {
type: String
}
});
module.exports = mongoose.model('Game', gameSchema);
创建新游戏
现在我们已经定义了游戏的数据结构,让我们开始实现创建和持久化新游戏文档到数据库的逻辑,同时遵循测试驱动开发实践。
为了创建一个新的游戏,我们需要接受一个 POST 请求到 /create,并在 POST 主体中包含你的名字:
{ name: 'player1' }
我们应该考虑以下几点:
-
我们需要将棋盘信息返回给用户,以及游戏创建是否成功
-
我们需要确保玩家可以访问他们刚刚创建的游戏,因此我们必须发送给他们
boardId -
为了让玩家能够识别自己,我们还需要确保我们发送给他们
p1Key,这将用于玩家一未来想要在此棋盘上进行的所有移动
由于我们正在构建游戏,我们有权力改变游戏的规则。所以让我们允许玩家 1 可选地配置游戏棋盘的大小!尽管如此,我们应该有一个最小尺寸为 6x7。
因此,让我们从创建游戏和获取游戏信息的测试开始:
var expect = require('chai').expect,
request = require('supertest');
var app = require('../src/lib/app');
describe('Create new game | ', function() {
var boardId;
it('should return a game object with key for player 1', function(done) {
request(app).post('/create')
.send({name: 'express'})
.expect(200)
.end(function(err, res) {
var b = res.body;
expect(b.boardId).to.be.a('string');
expect(b.p1Key).to.be.a('string');
expect(b.p1Name).to.be.a('string').and.equal('express');
expect(b.turn).to.be.a('number').and.equal(1);
expect(b.rows).to.be.a('number');
expect(b.columns).to.be.a('number');
// Make sure the board is a 2D array
expect(b.board).to.be.an('array');
for(var i = 0; i < b.board.length; i++){
expect(b.board[i]).to.be.an('array');
}
// Store the boardId for reference
boardId = b.boardId;
done();
});
});
})
注意
在本章中,我们将使用 expect 断言库。与 should 的唯一区别是语法,以及它处理 undefined 的方式更为优雅。should 库修补了对象原型,这意味着如果对象是 undefined,它将抛出 TypeError: Cannot read property 'should' of undefined。
测试将使用 supertest 模拟向 /create 端点发送 POST 数据,并且我们描述了我们期望从响应中获取的所有内容。
-
现在让我们在
src/routes/games.js中创建一个POST路由来在数据库中创建游戏,并使第一个测试通过:var Utils = require('../lib/utils'); var connect4 = require('../lib/connect4'); var Game = require('../models/game'); app.post('/create', function(req, res) { if(!req.body.name) { res.status(400).json({ "Error": "Must provide name field!" }); } var newGame = { p1Key: Utils.randomValueHex(25), p2Key: Utils.randomValueHex(25), boardId: Utils.randomValueHex(6), p1Name: req.body.name, board: connect4.initializeBoard(req.body.rows, req.body.columns), rows: req.body.rows || app.get('config').MIN_ROWS, columns: req.body.columns || app.get('config').MIN_COLUMNS, turn: 1, status: 'Game in progress' }; Game.create(newGame, function(err, game) { if (err) { return res.status(400).json(err); } game.p2Key = undefined; res.status(201).json(game); }); });注意
注意,API 应该处理所有可能的输入,并确保在输入验证未通过时返回
400错误;关于这一点将在以下内容中详细介绍。 -
Utils.randomValueHex()方法将返回一个随机字符串,我们用它来生成令牌以及boardId。而不是在前面文件中定义它,让我们在src/lib/utils.js中将其包装得更好:var crypto = require('crypto'); module.exports = { randomValueHex: function(len) { return crypto.randomBytes(Math.ceil(len/2)) .toString('hex') .slice(0,len); } }Connect4 的所有游戏逻辑都在
src/lib/connect4.js中,你可以在附录中找到它。我们将使用这个库来初始化棋盘。 -
注意,行和列是可选参数。我们不希望在代码中硬编码默认值,因此我们在根目录中有一个
config.js文件:module.exports = { MIN_ROWS: 6, MIN_COLUMNS: 7 }; -
当我们在
src/lib/app.js中初始化应用程序时,我们可以将这个config对象附加到app对象上,这样我们就可以在应用程序范围内访问配置:var express = require('express'), app = express(), config = require('../../config'), db = require('./db'); app.set('config', config); db.connectMongoDB(); require('./parser')(app); require('../routes/games')(app); module.exports = app;到现在为止,你的第一次尝试应该通过了——恭喜!我们现在可以放心,
POST端点是正常工作的,并且会按预期继续工作。这种感觉很好,因为如果我们将来破坏了某些东西,测试将会失败。现在你不再需要担心它了,可以专注于你的下一个任务。 -
你确实需要勤奋地获取尽可能多的代码覆盖率。例如,我们允许客户端自定义棋盘大小,但我们还没有编写测试来测试这个功能,所以让我们立即着手:
it('should allow you to customize the size of the board', function(done) { request(app).post('/create') .send({ name: 'express', columns: 8, rows: 16 }) .expect(200) .end(function(err, res) { var b = res.body; expect(b.columns).to.equal(8); expect(b.rows).to.equal(16); expect(b.board).to.have.length(16); expect(b.board[0]).to.have.length(8); done(); }); }); -
我们还应该强制棋盘的最小尺寸;否则,游戏无法进行。还记得我们在
config.js文件中定义了MIN_ROWS和MIN_COLUMNS吗?我们可以在测试中重用它,而无需硬编码测试。现在,如果我们想更改游戏的最小尺寸,我们可以在一个地方完成!如下所示:it('should not accept sizes < ' + MIN_COLUMNS + ' for columns', function(done) { request(app).post('/create') .send({ name: 'express', columns: 5, rows: 16 }) .expect(400) .end(function(err, res) { expect(res.body.error).to.equal('Number of columns has to be >= ' + MIN_COLUMNS); done(); }); }); it('should not accept sizes < ' + MIN_ROWS + ' rows', function(done) { request(app).post('/create') .send({ name: 'express', columns: 8, rows: -6 }) .expect(400) .end(function(err, res) { expect(res.body.error).to.equal('Number of rows has to be >= ' + MIN_ROWS); done(); }); });
如前述测试用例所述,我们应该确保如果玩家正在自定义棋盘大小,那么大小不能小于最小尺寸。我们还将进行更多验证检查,所以让我们开始变得更加有条理。
输入验证
我们应该始终检查从POST请求接收到的输入是否确实是我们预期的,如果不是,则返回400输入错误。这需要尽可能多地考虑边缘情况。当一个 API 被成千上万的用户使用时,可以保证一些用户会滥用或误用它,无论是故意的还是无意的。然而,你的责任是尽可能使 API 对用户友好。
在前述/create路由中,我们唯一覆盖的输入验证是确保POST体中有一个名称。现在我们只需添加两个额外的if块来覆盖棋盘大小的情况,以便通过测试。
在真正的 TDD(测试驱动开发)哲学中,你应该首先编写最少的代码来使测试通过。他们称之为红-绿-重构。首先,编写失败的测试(红色),尽可能快地使其通过(绿色),然后重构。
我们敦促你首先尝试上述代码。以下是在重构后的结果。
-
许多输入验证检查可以在多个路由中使用,所以让我们将其优雅地打包成
src/lib/validators.js中的中间件集合:// A collection of validation middleware module.exports = function(app) { var MIN_COLUMNS = app.get('config').MIN_COLUMNS, MIN_ROWS = app.get('config').MIN_ROWS; // Helper to return 400 error with a custom message var _return400Error = function(res, message) { return res.status(400).json({ error: message }); }; return { name: function(req, res, next) { if(!req.body.name) { return _return400Error(res, 'Must provide name field!'); } next(); }, columns: function(req, res, next) { if(req.body.columns && req.body.columns < MIN_COLUMNS) { return _return400Error(res, 'Number of columns has to be >= ' + MIN_COLUMNS); } next(); }, rows: function(req, res, next) { if(req.body.rows && req.body.rows < MIN_ROWS) { return _return400Error(res, 'Number of rows has to be >= ' + MIN_ROWS); } next(); } } }上述代码以可重用的方式打包了三个验证检查器。它返回一个包含三个中间件的对象。注意我们如何使用
private _return400Error辅助函数来 DRY(Don't Repeat Yourself)代码,使其更加简洁。 -
现在,我们可以按照以下方式重构
/create路由:module.exports = function(app) { // Initialize Validation middleware with app to use config.js var Validate = require('../lib/validators')(app); app.post('/create', [Validate.name, Validate.columns, Validate.rows], function(req, res) { var newGame = { p1Key: Utils.randomValueHex(25), p2Key: Utils.randomValueHex(25), boardId: Utils.randomValueHex(6), p1Name: req.body.name, board: connect4.initializeBoard(req.body.rows, req.body.columns), rows: req.body.rows || app.get('config').MIN_ROWS, columns: req.body.columns || app.get('config').MIN_COLUMNS, turn: 1, status: 'Game in progress' }; Game.create(newGame, function(err, game) { if (err) return res.status(400).json(err); game.p2Key = undefined; return res.status(201).json(game); }); }); }
这将创建一个很好的关注点分离,其中我们将定义的每个路由都将接受一个(可重用的!)验证中间件数组,它必须通过这些中间件,才能到达路由的控制逻辑。
小贴士
在继续进行下一个端点之前,请确保你的测试仍然通过。
获取游戏状态
两位玩家都需要一种方式来检查他们感兴趣的游戏的状况。为此,我们可以向/board/{boardId}发送GET请求。这将返回游戏的当前状态,允许玩家看到棋盘的状态,以及下一个轮到谁。
我们将创建另一个端点来获取棋盘,所以让我们首先为它编写测试:
it('should be able to fetch the board', function(done) {
request(app).get("/board/" + boardId)
.expect(200)
.end(function(err, res) {
var b = res.body;
expect(b.boardId).to.be.a('string').and.equal(boardId);
expect(b.turn).to.be.a('number').and.equal(1);
expect(b.rows).to.be.a('number');
expect(b.columns).to.be.a('number');
expect(b.board).to.be.an('array');
done();
});
});
注意,我们想要确保我们不会意外地泄露玩家令牌。响应应该基本上与最近移动的玩家收到的响应相同,如下所示:
app.get('/board/:id', function(req, res) {
Game.findOne({boardId: req.params.id}, function(err, game) {
if (err) return res.status(400).json(err);
res.status(200).json(_sanitizeReturn(game));
});
});
在这里,_sanitizeReturn(game)是一个简单的辅助函数,它只是复制游戏对象,除了玩家令牌。
// Given a game object, return the game object without tokens
function _sanitizeReturn(game) {
return {
boardId: game.boardId,
board: game.board,
rows: game.rows,
columns: game.columns,
turn: game.turn,
status: game.status,
winner: game.winner,
p1Name: game.p1Name,
p2Name: game.p2Name
};
}
加入游戏
如果只有一个人玩这个游戏,那就没有意思了,所以我们需要允许第二个玩家加入游戏。
-
为了加入一个游戏,我们需要接受
POST到/join,并在POST体中包含玩家 2 的名字:{ name: 'player2' }注意
为了使这可行,我们需要实现一个基本的匹配系统。一个简单的方法是简单地有一个可加入状态的游戏队列,当
/joinAPI 被调用时从中弹出。我们选择使用 Redis 作为我们的队列实现来跟踪可加入的游戏。一旦加入游戏,我们将向玩家发送
boardId和p2Key,这样他们就可以与玩家 1 在这个板上玩游戏。这将内在地避免游戏被多次加入。 -
我们需要做的只是添加这一行,将
boardId推入队列,一旦游戏在数据库中创建并存储:client.lpush('games', game.boardId); -
当我们展示
app.js时,我们浏览了数据库连接。在第二章中介绍了设置 MongoDB 连接的方法,一个健壮的电影 API。以下是我们将在src/lib/db.js中连接到redis数据库的方式:var redis = require('redis'); var url = require('url'); exports.connectRedis = function() { var urlRedisToGo = process.env.REDISTOGO_URL; var client = {}; if (urlRedisToGo) { console.log('using redistogo'); rtg = url.parse(urlRedisToGo); client = redis.createClient(rtg.port, rtg.hostname); client.auth(rtg.auth.split(':')[1]); } else { console.log('using local redis'); // This would use the default redis config: { port 6347, host: 'localhost' } client = redis.createClient(); } return client; };注意
注意,在生产环境中,我们将连接到 Redis To Go(你可以从免费的 2MB 实例开始)。对于本地开发,你所需要做的就是
redis.createClient()。 -
现在我们可以编写测试来加入游戏,TDD 风格:
var expect = require('chai').expect, request = require('supertest'), redis = require('redis'), client = redis.createClient(); var app = require('../src/lib/app'); describe('Create and join new game | ', function() { before(function(done){ client.flushall(function(err, res){ if (err) return done(err); done(); }); }); -
注意,我们每次运行这个测试套件时都会刷新
redis队列,只是为了确保栈是空的。一般来说,编写可以独立运行的原子测试是一个好主意,而不依赖于外部状态。it('should not be able to join a game without a name', function(done) { request(app).post('/join') .expect(400) .end(function(err, res) { expect(res.body.error).to.equal("Must provide name field!"); done(); }); }); it('should not be able to join a game if none exists', function(done) { request(app).post('/join') .send({name: 'koa'}) .expect(418) .end(function(err, res) { expect(res.body.error).to.equal("No games to join!"); done(); }); }); -
总是记得要覆盖输入的边缘情况!在前面的测试中,我们确保覆盖了没有剩余游戏可加入的情况。如果没有,我们可能会使服务器崩溃或返回
500错误(我们应该尝试消除,因为这意味着这是你的责任,而不是用户的错误!)现在让我们编写以下代码:it('should create a game and add it to the queue', function(done) { request(app).post('/create') .send({name: 'express'}) .expect(200) .end(function(err, res) { done(); }); }); it('should join the game on the queue', function(done) { request(app).post('/join') .send({name: 'koa'}) .expect(200) .end(function(err, res) { var b = res.body; expect(b.boardId).to.be.a('string'); expect(b.p1Key).to.be.undefined; expect(b.p1Name).to.be.a('string').and.equal('express'); expect(b.p2Key).to.be.a('string'); expect(b.p2Name).to.be.a('string').and.equal('koa'); expect(b.turn).to.be.a('number').and.equal(1); expect(b.rows).to.be.a('number'); expect(b.columns).to.be.a('number'); done(); }); }); }); -
这些测试描述了创建游戏和加入游戏的核心逻辑。足够的测试来描述这个端点。现在让我们编写相应的代码:
app.post('/join', Validate.name, function(req, res) { client.rpop('games', function(err, boardId) { if (err) return res.status(418).json(err); if (!boardId) { return res.status(418).json({ error: 'No games to join!' }); } Game.findOne({ boardId: boardId }, function (err, game){ if (err) return res.status(400).json(err); game.p2Name = req.body.name; game.save(function(err, game) { if (err) return res.status(500).json(err); game.p1Key = undefined; res.status(200).json(game); }); }); }); });
我们在这里重用Validate.name中间件来确保我们为玩家 2 有一个名字。如果是这样,我们将寻找队列中的下一个可加入的游戏。当没有可加入的游戏时,我们将返回一个适当的418错误。
如果我们成功检索到下一个可加入的 boardId,我们将从数据库中获取棋盘,并在其中存储玩家 2 的名字。我们还要确保我们不将玩家 1 的 Token 与游戏对象一起返回。
现在两位玩家都已经获取了各自的 Token,游戏可以开始了!
进行游戏
游戏状态存储在数据库中,可以通过对 /board/{boardId} 的 GET 请求检索。移动的本质是对游戏状态的更改。在熟悉的 CRUD 术语中,我们将会更新文档。尽可能遵循 REST 规范,对 /board/{boardId} 发送 PUT 请求将是进行移动的合逻辑选择。
为了进行有效的移动,玩家需要在他们的请求头中包含一个 X-Player-Token,该 Token 与对应玩家的 Token 匹配,以及一个请求体,指明要在哪个列进行移动:
{ column: 2 }
然而,并非所有移动都是合法的,例如,我们需要确保玩家只能在他们的回合进行移动。对于每一次移动,还需要检查一些其他的事情:
-
移动是否有效?列参数是否指定了一个实际的列?
-
这列还有空间吗?
-
X-Player-Token 是否是当前游戏的合法 Token?
-
轮到你了?
-
这一步是否创造了胜利条件?这位玩家是否通过这一步赢得了比赛?
-
这一步是否填满了棋盘并导致了平局?
现在我们将模拟所有这些场景。
-
让我们用以下测试进行一整场游戏:
var expect = require('chai').expect, request = require('supertest'), redis = require('redis'), client = redis.createClient(); var app = require('../src/lib/app'), p1Key, p2Key, boardId; describe('Make moves | ', function() { before(function(done){ client.flushall(function(err, res){ if (err) return done(err); done(); }); }); it('create a game', function(done) { request(app).post('/create') .send({name: 'express'}) .expect(200) .end(function(err, res) { p1Key = res.body.p1Key; boardId = res.body.boardId; done(); }); }); it('join a game', function(done) { request(app).post('/join') .send({name: 'koa'}) .expect(200) .end(function(err, res) { p2Key = res.body.p2Key; done(); }); });第一个测试创建游戏,第二个测试加入游戏。接下来的六个测试是验证测试,以确保请求是有效的。
-
确保 X-Player-Token 存在:
it('Cannot move without X-Player-Token', function(done) { request(app).put('/board/' + boardId) .send({column: 1}) .expect(400) .end(function(err, res) { expect(res.body.error).to.equal('Missing X-Player-Token!'); done(); }); }); -
确保 X-Player-Token 是正确的:
it('Cannot move with wrong X-Player-Token', function(done) { request(app).put('/board/' + boardId) .set('X-Player-Token', 'wrong token!') .send({column: 1}) .expect(400) .end(function(err, res) { expect(res.body.error).to.equal('Wrong X-Player-Token!'); done(); }); }); -
确保你移动的棋盘存在:
it('Cannot move on unknown board', function(done) { request(app).put('/board/3213') .set('X-Player-Token', p1Key) .send({column: 1}) .expect(404) .end(function(err, res) { expect(res.body.error).to.equal('Cannot find board!'); done(); }); }); -
确保在移动时发送了列参数:
it('Cannot move without a column', function(done) { request(app).put('/board/' + boardId) .set('X-Player-Token', p2Key) .expect(400) .end(function(err, res) { expect(res.body.error).to.equal('Move where? Missing column!'); done(); }); }); -
确保列没有超出棋盘范围:
it('Cannot move outside of the board', function(done) { request(app).put('/board/' + boardId) .set('X-Player-Token', p1Key) .send({column: 18}) .expect(200) .end(function(err, res) { expect(res.body.error).to.equal('Bad move.'); done(); }); }); -
确保错误的玩家不能移动:
it('Player 2 should not be able to move!', function(done) { request(app).put('/board/' + boardId) .set('X-Player-Token', p2Key) .send({column: 1}) .expect(400) .end(function(err, res) { console.log(res.body); expect(res.body.error).to.equal('It is not your turn!'); done(); }); }); -
现在我们已经涵盖了所有验证案例,让我们测试整个游戏流程:
it('Player 1 can move', function(done) { request(app).put('/board/' + boardId) .set('X-Player-Token', p1Key) .send({column: 1}) .expect(200) .end(function(err, res) { var b = res.body; expect(b.p1Key).to.be.undefined; expect(b.p2Key).to.be.undefined; expect(b.turn).to.equal(2); expect(b.board[b.rows-1][0]).to.equal('x'); done(); }); }); -
在玩家 2 移动之前,快速检查玩家 1 不能再次移动:
it('Player 1 should not be able to move!', function(done) { request(app).put('/board/' + boardId) .set('X-Player-Token', p1Key) .send({column: 1}) .expect(400) .end(function(err, res) { expect(res.body.error).to.equal('It is not your turn!'); done(); }); }); it('Player 2 can move', function(done) { request(app).put('/board/' + boardId) .set('X-Player-Token', p2Key) .send({column: 1}) .expect(200) .end(function(err, res) { var b = res.body; expect(b.p1Key).to.be.undefined; expect(b.p2Key).to.be.undefined; expect(b.turn).to.equal(3); expect(b.board[b.rows-2][0]).to.equal('o'); done(); }); }); -
本测试套件的剩余部分将进行一整场的游戏。我们不会在这里展示所有内容,但你可以参考源代码。尽管如此,最后三个测试仍然很有趣,因为我们涵盖了最终的游戏状态并阻止了任何更多的移动。
it('Player 1 can double-check victory', function(done) { request(app).get('/board/' + boardId) .set('X-Player-Token', p1Key) .expect(200) .end(function(err, res) { var b = res.body; expect(b.winner).to.equal('express'); expect(b.status).to.equal('Game Over.'); done(); }); }); it('Player 2 is a loser, to be sure', function(done) { request(app).get('/board/' + boardId) .set('X-Player-Token', p2Key) .expect(200) .end(function(err, res) { var b = res.body; expect(b.winner).to.equal('express'); expect(b.status).to.equal('Game Over.'); done(); }); }); it('Player 1 cannot move anymore', function(done) { request(app).put('/board/' + boardId) .set('X-Player-Token', p1Key) .send({column: 3}) .expect(400) .end(function(err, res) { expect(res.body.error).to.equal('Game Over. Cannot move anymore!'); done(); }); }); });
既然我们已经描述了预期的行为,让我们开始实现移动端点。
-
首先,让我们覆盖验证部分,使前 8 个测试通过。
app.put('/board/:id', [Validate.move, Validate.token], function(req, res) { Game.findOne({boardId: req.params.id }, function(err, game) { -
我们获取移动发送到的棋盘。如果我们找不到棋盘,我们应该返回一个 400 错误。这将使测试 'Cannot move on unknown board' 通过。
if (!game) { return res.status(400).json({ error: 'Cannot find board!' }); } -
如果游戏结束了,你不能进行任何移动。
if(game.status !== 'Game in progress') { return res.status(400).json({ error: 'Game Over. Cannot move anymore!' }); } -
以下代码将确保 Token 要么是
p1Key,要么是p2Key。如果不是,返回带有相应信息的400错误:if(req.headers['x-player-token'] !== game.p1Key && req.headers['x-player-token'] !== game.p2Key) { return res.status(400).json({ error: 'Wrong X-Player-Token!' }); }
现在我们已经验证了 Token 确实是有效的,我们仍然需要检查是否轮到你了。
game.turn() 方法将在每个回合中递增,所以我们必须取模来检查是谁的回合。递增回合,而不是切换,将有一个好处,即可以记录已进行的回合数,这在稍后检查棋盘是否已满并结束平局时也会很有用。
现在我们知道要比较令牌的哪个键了。
-
如果你的令牌不匹配,那么就不是你的回合:
var currentPlayer = (game.turn % 2) === 0 ? 2 : 1; var currentPlayerKey = (currentPlayer === 1) ? game.p1Key : game.p2Key; if(currentPlayerKey !== req.headers['x-player-token']){ return res.status(400).json({ error: 'It is not your turn!' }); } -
我们为这个路由添加了两个额外的验证中间件,移动和令牌,我们可以将它们添加到
src/lib/validators.js中的验证库中:move: function(req, res, next) { if (!req.body.column) { return _return400Error(res, 'Move where? Missing column!'); } next(); }, token: function(req, res, next) { if (!req.headers['x-player-token']) { return _return400Error(res, 'Missing X-Player-Token!'); } next(); } -
在前面的代码中,我们发送了四次
400错误,让我们简化代码并重用validators.js中的同一个辅助函数,通过将这个辅助函数提取到src/lib/utils.js中:var crypto = require('crypto'); module.exports = { randomValueHex: function(len) { return crypto.randomBytes(Math.ceil(len/2)) .toString('hex') .slice(0,len); }, // Helper to return 400 error with a custom message return400Error: function(res, message) { return res.status(400).json({ error: message }); } } -
不要忘记更新
src/lib/validators.js以使用这个utils,通过替换以下行:var _return400Error = require('./utils').return400Error; -
现在,我们可以重构移动路由,按照以下方式执行移动:
app.put('/board/:id', [Validate.move, Validate.token], function(req, res) { Game.findOne({boardId: req.params.id }, function(err, game) { if (!game) { return _return400Error(res, 'Cannot find board!'); } if(game.status !== 'Game in progress') { return _return400Error(res, 'Game Over. Cannot move anymore!'); } if(req.headers['x-player-token'] !== game.p1Key && req.headers['x-player-token'] !== game.p2Key) { return _return400Error(res, 'Wrong X-Player-Token!'); } var currentPlayer = (game.turn % 2) === 0 ? 2 : 1; var currentPlayerKey = game['p' + currentPlayer + 'Key']; if(currentPlayerKey !== req.headers['x-player-token']){ return _return400Error(res, 'It is not your turn!');
清洁多了,不是吗!
对于控制器逻辑的其余部分,我们将使用 connect4.js 库(见附录),该库实现了 makeMove() 和 checkForVictory() 方法。
makeMove() 方法将返回移动后的新棋盘,或者如果移动无效则返回 false。这里的无效意味着该列已满,或者列超出范围。这里没有进行回合验证。
// Make a move, which returns a new board; returns false if the move is invalid
var newBoard = connect4.makeMove(currentPlayer, req.body.column, game.board);
if(newBoard){
game.board = newBoard;
game.markModified('board');
} else {
return _return400Error(res, 'Bad move.');
}
需要特别指出的一点是这一行代码 game.markModified('board')。由于我们使用二维数组作为棋盘,Mongoose 无法自动检测任何更改。它只能对基本字段类型进行操作。因此,如果我们没有明确地将棋盘标记为已更改,那么在调用 game.save 时,它将不会持久化任何更改!
// Check if you just won
var win = connect4.checkForVictory(currentPlayer, req.body.column, newBoard);
if(win) {
game.winner = game['p'+ currentPlayer + 'Name'];
game.status = 'Game Over.';
} else if(game.turn >= game.columns*game.rows) {
game.winner = 'Game ended in a tie!';
game.status = 'Game Over.';
}
checkForVictory() 方法是一个谓词,它将根据最后一位玩家的最后一步移动来检查胜利。我们不需要每次都检查整个棋盘。如果最后一步是胜利的一步,这个方法将返回 true;否则,它将返回 false。
// Increment turns
game.turn++;
game.save(function(err, game){
if (err) return res.status(500).json(err);
return res.status(200).json(_sanitizeReturn(game));
});
});
});
保持控制器逻辑尽可能简单,并将尽可能多的业务逻辑委托给库或模型是一个好主意。这种解耦和关注点的分离提高了可维护性、可测试性,以及模块化和可重用性。考虑到当前的架构,很容易在我们的 Express 项目中重用应用程序的核心组件。
测试平局情况
在我们的测试套件中,我们还没有涵盖到平局游戏。我们可以创建另一个测试套件,手动使用 42 个单独的步骤填满整个棋盘,但这会非常繁琐。所以,让我们通过程序来填充棋盘。
这听起来可能很简单,但用 JavaScript 的异步控制流来做可能会有些棘手。如果我们简单地将移动请求包裹在一个 for 循环中,会发生什么?
简而言之,这将是一团糟。所有请求将同时发出,并且没有任何顺序。您如何知道所有移动都已完成?您需要维护一个全局状态计数器,每次回调时都会增加。
这就是为什么从 Github 来的async库变得不可或缺。
Async 是一个实用模块,它提供了直接且强大的函数来处理异步 JavaScript。
使用 async 您可以做很多事情,这会让您的生活更轻松;绝对是一个您应该熟悉并添加到您的工具箱中的库。
在我们的情况下,我们将使用async.series,它允许我们串行发送一系列请求。每个请求将等待先前的请求返回。
注意
按顺序运行任务数组中的函数,每个函数在先前的函数完成后运行一次。如果系列中的任何函数将其错误传递给回调,则无法运行更多函数,并且回调立即以错误值调用;否则,当任务完成时,回调接收一个结果数组。
因此,为了准备传递给async.series的移动,我们将使用以下辅助函数来创建 thunk:
function makeMoveThunk(player, column) {
return function(done) {
var token = player === 1 ? p1Key : p2Key;
request(app).put('/board/' + boardId)
.set('X-Player-Token', token)
.send({column: column})
.end(done);
};
}
Thunk 只是一个子程序;在这种情况下,调用 API 进行移动,它被封装在一个函数中,稍后执行。在这种情况下,我们创建了一个接受回调参数的 thunk(如 async 所需),它通知 async 我们已经完成。
现在我们可以通过编程填充棋盘并检查平局状态:
it('Fill the board! Tie the game!', function(done) {
var moves = [],
turn = 1,
nextMove = 1;
for(var r = 0; r < rows; r++) {
for(var c = 1; c <= columns; c++) {
moves.push(makeMoveThunk(turn, nextMove));
turn = turn === 1 ? 2 : 1;
nextMove = ((nextMove + 2) % columns) + 1;
}
}
async.series(moves, function(err, res) {
var lastResponse = res[rows*columns-1].body;
console.log(lastResponse);
expect(lastResponse.winner).to.equal('Game ended in a tie!');
expect(lastResponse.status).to.equal('Game Over.');
done();
});
});
摘要
恭喜!到目前为止,所有您的测试都应该通过,您的游戏应该已经完成。您已经掌握了开发一个健壮且经过良好测试的 API,并使用可重用的中间件处理验证。在这个过程中,您还学会了如何使用 Redis 进行简单的队列。
现在,您可以部署您的 API,并且 Connect4-as-a-Service 将可供全世界使用,他们可以在自己的平台上构建自己的四子棋游戏。无论是 HTML5 界面、移动应用还是命令行界面,这一切都将由您的后端提供支持!
在下一章中,我们将把游戏开发提升到另一个层次——它将是一个实时大规模多人在线游戏!
第四章:MMO 单词游戏
单词链游戏是一个实时、大规模多人在线游戏。在玩游戏时,每位玩家都将能够看到其他在线玩家,以及一个分数排行榜。在本章中,我们将介绍 Promise 模式,并解释 Promises 如何简化异步操作。你将学习如何使用 Express 和 SocketIO 构建实时应用程序,在套接字握手期间进行身份验证,以及如何使用 MongoDB 的原子更新处理竞争条件。你还将学习如何构建游戏客户端以通过套接字连接到游戏服务器,以及如何使用 Chrome 开发者工具在客户端上调试 WebSocket。
一旦你掌握了这个技巧,你就可以构建类似在线问答比赛的游戏。
游戏玩法
游戏从随机选择的英语单词开始,每位玩家都尝试提交一个单词,其中提交的第一个字母与当前单词的最后一个字母匹配;我们称这为与当前单词的链式连接。例如,如果游戏从单词Today开始,那么玩家可以发送单词如Yes或Yellow。
首位提交有效单词的人,其单词将成为下一轮的起始单词,并获得该轮的分数。一旦新单词被接受,服务器将向所有在线玩家广播这一变化。玩家将看到新单词,并提交另一个单词与它相连。
例如,如果玩家 1 发送Yes来与Today相连,服务器将接受这个单词并将当前单词Yes广播给所有其他玩家。如果玩家提交的单词根据我们拥有的字典无效,或者之前已被其他玩家提交,游戏服务器将忽略该请求。如果多个玩家同时提交有效单词,服务器将只接受第一个提交的单词。

实时应用程序概述
在这个游戏中,我们将介绍 Promise 模式,并解释 Promise 如何简化异步操作。
尽管这是一个实时游戏,但我们不会一开始就急于实现实时功能。相反,我们首先构建一个游戏模型,其中包含所有游戏逻辑。
在游戏逻辑中,我们首先介绍如何跟踪活跃用户,然后解释我们如何验证用户的输入。在更新游戏状态阶段,我们通过利用 MongoDB 的原子操作来处理竞争条件。我们还探讨了如何通过测试用例来覆盖竞争条件。
游戏逻辑完成后,我们将介绍如何使用 Socket.IO 向所有玩家广播游戏状态变化。
最后,我们将创建一个 Express 应用、一个 Socket.IO 服务器和一个游戏客户端,该客户端可以使用socket.io-client库与我们的服务器通信。
跟踪活跃用户
由于游戏是多人游戏,玩家可以看到玩家的数量和他们的用户名。为了跟踪活跃用户,我们需要跟踪玩家何时加入游戏以及何时离开游戏。
模式设计
每个玩家可以简单地用一个包含单个名称字段的文档来表示:
{ name: 'leo' }
用户模式
我们将使用 Mongoose 进行数据建模。让我们从设计我们的用户模式开始。模式放在应用中的models文件夹中。下面的截图显示了文件夹结构。该模式将有一个必填字段name,这是通过在模式中的名称对象中添加required: true来实现的。

为了快速通过名称查询用户,我们可以在name上添加索引。默认情况下,只有 MongoDB 生成的_id字段会被索引。这意味着,为了通过名称进行搜索,数据库需要遍历集合中的所有文档以找到匹配的名称。当你为name添加索引时,你可以像查询_id一样快速地通过名称进行查询。现在,当用户离开时,我们可以直接通过名称找到该用户并删除该用户。
此外,我们还在索引中添加了unique : true属性,以避免有多个用户拥有相同的名称,如下面的代码所示:
var mongoose = require('mongoose');
var schema = new mongoose.Schema({
name: {
type: String,
required: true,
index: {
unique: true
}
}
});
var User = mongoose.model('user', schema);
module.exports = User;
用户加入
当用户加入游戏时,我们创建一个具有name键的用户,并将此用户保存到 MongoDB 中,如下所示:
schema.statics.join = function(name, callback) {
var user = new User({
name: name
});
user.save(function(err, doc) {
if (!callback) { return ; }
if (err) { return callback(err); }
callback(null, doc);
});
};
前面代码中的save()方法使用了回调模式,这也被称为回调地狱。如果发生错误,我们调用回调函数并将错误作为参数传递;否则,操作成功并返回更新后的文档。
前面的回调模式涉及大量的逻辑和条件检查。JavaScript 的嵌套回调模式可以迅速变成意大利面般的噩梦。一个好的替代方案是使用 Promises 来简化问题。
Mongoose 的model.create()方法(mongoosejs.com/docs/api.html#model_Model.create)可以在数据库中创建并保存一个新的文档,如果有效。函数和文档,如对象和数组,是model.create()方法的有效参数。create方法返回一个 Promise。
使用这个 Promise,join方法的调用者可以定义成功和失败的回调,从而简化代码:
schema.statics.join = function(name) {
return User.create({
name: name
});
};
Promises
Promise 是异步操作最终的结果,就像给人一个承诺一样。Promises 帮助处理错误,这导致编写没有回调的更干净的代码。你不需要为每个函数传递一个额外的函数,该函数接受错误和结果作为参数,只需简单地调用你的函数并传递其参数即可获得一个 Promise:
getUserinfo('leo', function(err, user){
if (err) {
// handle error
onFail(err);
return;
}
onSuccess(user);
});
与之相对的是
var promiseUserInfo = getUserinfo('leo');
promiseUserInfo.then(function(user) {
onSuccess(user);
});
promiseUserInfo.catch(function(error) {
// code to handle error
onFail(user);
});
如果只有一个异步操作,使用 Promise 的好处并不明显。如果有许多异步操作,其中一个依赖于另一个,回调模式会迅速变成一个深层嵌套的结构,而 Promise 可以使你的代码更浅显,更容易阅读。
Promise 可以集中处理你的错误,当发生异常时,你会得到引用实际函数名称而不是匿名函数名称的堆栈跟踪。
在我们的文字游戏中,你可以使用 Promise 将这个:
var onJoinSuccess = function(user) {console.log('user', user.name, 'joined game!');
return user;
};
var onJoinFail = function(err) {console.error('user fails to join, err', err);
};
User.join('leo', function(err, user) {if (err) {return onJoinFail(err);
}
onJoinSuccess(user);
});
变成这样:
User.join('leo')
.then(function(user) {onJoinSuccess(user);})
.catch(function(err) {onJoinFail(err);
});
或者甚至更简单:
User.join('leo')
.then(onJoinSuccess)
.catch(onJoinFail);
为了理解前面的执行流程,让我们创建一个完整的示例,调用用户模型的 join() 方法,然后添加一些日志语句来查看输出:
var User = require('../app/models/user.js');
var db = require('../db');
var onJoinSuccess = function(user) {
console.log('user', user.name, 'joined game!');
return user;
};
var onJoinFail = function(err) {
console.error('user fails to join, err', err);
};
console.log('Before leo send req to join game');
User.join('leo')
.then(onJoinSuccess)
.catch(onJoinFail);
console.log('After leo send req to join game');
如果用户成功加入游戏,User.join() 方法返回的 Promise 将被解决。一个新创建的用户文档对象将被传递给 onJoinSuccess 回调,输出结果如下所示:

如果我们再次运行此脚本,我们会看到用户未能加入游戏,错误被打印出来。它失败是因为用户模型已经在名称属性上有一个索引,因为当我们第一次运行脚本时创建了一个名为 leo 的用户。当我们再次运行它时,我们无法创建另一个具有相同名称 leo 的用户,因此 Promise 失败,错误传递到 onJoinFail。

Promise 有三种状态:挂起、已解决或被拒绝;Promise 的初始状态是挂起,然后它承诺它将要么成功(已解决)要么失败(被拒绝)。一旦它被解决或被拒绝,它就不能再改变。这个的主要好处是你可以将多个 Promise 连接起来,并定义一个错误处理器来处理所有错误。
由于 join() 方法返回一个 Promise,我们可以定义成功和失败回调如下。
then 和 catch 方法
then 和 catch 方法用于定义成功和失败回调;你可能想知道它们实际上何时被调用。当调用 User.create() 方法时,它将返回一个 Promise 对象,并同时向 MongoDB 发送一个异步查询。成功回调 onJoinSuccess 然后被传递到 then 方法,并在异步查询成功完成时调用,解决 Promise。
一旦 Promise 被解决,它就不能再次被解决,因此 onJoinSuccess 不会再次被调用,最多只会被调用一次。
连接多个 Promise
你可以通过在先前的 then() 函数返回的 Promise 上调用它们来连接 Promise 操作。当我们想要对 Promise 的结果(一旦 x 解决,然后做 y)做些什么时,我们使用 .then() 方法,如下所示:
var User = require('../app/models/user.js');
var db = require('../db');
var onJoinSuccess = function(user) {
console.log('user', user.name, 'joined game!');
return user;
};
var onJoinFail = function(err) {
console.error('user fails to join, err', err);
};
console.log('Before leo send req to join game');
User.join('leo')
.then(onJoinSuccess)
.then(function(user) {
return User.findAllUsers();
})
.then(function(allUsers) {
return JSON.stringify(allUsers);
})
.then(function(val) {
console.log('all users json string:', val);
})
.catch(onJoinFail);
console.log('After leo send req to join game');
我们可以在最后集中处理错误。使用 Promise 链处理错误要容易得多。如果我们运行代码,我们会得到以下结果:

现在我们已经通过了创建新用户的逻辑和错误处理,让我们看看我们将如何确保具有相同名称的多个用户不能加入。
防止重复
在我们定义用户模式时,我们已经在名称字段上添加了 index 并将其唯一集设置为 true:
var schema = new mongoose.Schema({
name: {
type: String,
required: true,
index: {
unique: true
}
}
});
MongoDB 将发出一个查询以查看是否存在具有相同唯一属性值的另一个记录,如果该查询返回空,则允许保存或更新操作进行。如果另一个用户以相同的名称加入,Mongo 将抛出错误:重复键错误。这阻止了用户被保存,玩家必须选择另一个名称加入。
为了确保我们的代码按预期工作,我们需要创建测试;我们将使用 Mocha 创建一个测试用例。测试用例将向 User.join 方法传递一个用户名,并期望新创建的用户名是有效的。User.join 方法返回一个 Promise。如果成功,Promise 返回的对象将被发送到 then 方法;否则它将失败,Promise 将通过 .reject 方法返回一个错误,该错误将被 catch 方法捕获。
在成功回调的情况下,我们有新创建的用户,我们可以通过期望 user.name 返回 leo 来检查它是否正确,因为 leo 被输入为用户名(如下面的代码所示)。
在失败回调的情况下,我们可以将错误对象传递给 Mocha,done(error),以失败测试用例。由于我们第一次创建了一个名为 leo 的用户,我们预计这个测试会通过。由于 Mocha 测试是同步的,而 Promises 是异步的,我们需要等待函数执行完成。当代码成功时,它将调用 done() 函数并向 Mocha 报告成功;如果失败,catch 方法将捕获错误并将错误返回给 done 方法,这将告诉 Mocha 测试用例失败。
var User = require('../../app/models/user');
describe('when leo joins', function() {
it('should return leo', function(done) {
User.join('leo')
.then(function(user) {
expect(user.name).to.equal('leo');
done();
})
.catch(function(error) {
done(error);
});
});
});
Mocha 1.18.0 或更高版本允许你在测试用例中返回一个 Promise。如果 Promise 失败而不需要显式捕获错误,Mocha 将失败测试用例,如下所示:
describe('when leo joins', function() {
it('should return leo', function() {
return User.join('leo')
.then(function(user) {
expect(user.name).to.equal('leo');
});
});
});
现在我们已经测试了提交具有唯一名称的第一个用户可以正常工作,我们想要测试当另一个具有相同名称的用户加入时会发生什么:
describe('when another leo joins', function() {
it('should be rejected', function() {
return User.join('leo')
.then(function() {
throw new Error('should return Error');
})
.catch(function(err) {
expect(err.code).to.equal(11000);
return true;
});
});
});
当我们再次提交 leo 作为用户名时,Game.join 的 Promise 被拒绝并进入 .catch 方法。return true 将失败的 Promise 转换为成功,这告诉我们它成功拒绝了第二个 leo,并且我们成功捕获了错误;我们基本上吞下了错误,告诉 Mocha 这是我们期望的正确行为。
用户离开游戏
当用户离开游戏时,我们需要从数据库中删除他们的记录;这也会释放他们的用户名,以便新用户可以取用。Mongoose 有一个名为 findOneAndRemove 的 delete 方法,可以通过名称找到该玩家,然后将其删除,如下面的代码所示。
对于我们的 Promises,我们使用 Bluebird (github.com/petkaantonov/bluebird) (规范:PromiseA),因为它具有更好的性能、实用性和普及度(支持)。
我们调用 Promise.resolve 方法,该方法创建一个已解析的 Promise,其值为内部值:Promise.resolve(value)。因此,我们可以将不通常返回 Promise 的方法用 Bluebird 的 Promise.resolve 方法包装,以获取一个 Promise,然后如果成功则使用 then 链接,如果失败则使用 catch。从我们的方法接收 Promises 将确保我们高效地处理成功和错误,并允许调用者在其运行时处理错误(.exec())。
schema.statics.leave = function(name) {
return Promise.resolve(this.findOneAndRemove({name: name
})
.exec());
};
显示所有活跃用户
到目前为止,我们已经演示了如何添加和删除用户,现在我们将深入了解如何向已加入的用户展示游戏数据。为了显示总活跃用户数,我们可以简单地返回所有用户,因为离线用户已经被移除。为了返回仅包含用户名的数组,而不是整个用户对象的数组,我们可以使用 Promise.map() 方法将数组中的每个用户对象转换为用户名。
由于 User.find 返回用户数组,我们使用 Promise.map() 方法从名称键返回值。这有效地将用户对象数组转换为用户名数组。再次注意,我们使用 promise.resolve() 方法从我们的输入中获取一个 Promise。这将允许我们通过用户名显示当前登录用户列表。
schema.statics.findAllUsers = function() {
return Promise.resolve(User.find({}).exec())
.map(function(user) {
return user.name;
});
};
单词 - 子文档
我们已经了解了涉及创建、显示和删除用户的游戏逻辑,但关于游戏本身的实质——单词呢?
在 app/models/stat.js 文件中,我们可以看到我们如何对单词数据进行建模。word 字段显示当前单词,而 used 字段保存游戏的历史记录。
我们将 used 列作为子文档嵌入到 Stat 文档中,这样我们就可以原子性地更新统计数据。我们将在稍后解释这一点。
{
'word': 'what',
'used': [
{ 'user': 'admin', 'word': 'what' },
{ 'user': 'player1', 'word': 'tomorrow' },
{ 'user': 'player2', 'word': 'when' },
{ 'user': 'player2', 'word': 'nice' },
{ 'user': 'player1', 'word': 'egg' },
]
}
上述代码概述了我们将存储在数据库中的内容。
我们首先为单词输入创建一个模型,新单词(word)和已使用单词(used),与用户模型类似的方法,通过定义类型(新单词为字符串,旧单词为数组)。旧单词存储在数组中,以便在检查新单词是否已被使用之前可以访问。
var schema = new mongoose.Schema({word: {type: String,required: true},
used: {type: Array
},
});
在创建新游戏之后,我们将描述有关验证单词输入和计分的进一步逻辑。
当我们创建一个新游戏时,我们想确保没有旧游戏数据存在,并且我们数据库中的所有值都被重置,因此我们将首先删除现有的游戏,然后创建一个新的,如下面的代码所示:
schema.statics.newGame = function() {return Promise.resolve(Stat.remove().exec())
.then(function() {return Stat.create({word: 'what',used: [{word: 'what',user: 'admin'}]
});
});
};
在前面的例子中,我们使用 Stat.remove() 删除所有旧的游戏数据,当 Promise 被满足时,我们通过传递一个新单词 'what' 来使用 Stat.create() 创建一个新的游戏,以开始新的一轮,并将单词和提交单词的用户提交到 used 数组中。我们希望除了单词外还提交用户,这样其他用户可以看到谁提交了当前单词,并利用这些信息来计算分数。
验证输入
我们不能接受用户可能输入的任何单词;用户可能输入一个无效的单词(根据我们的内部词典确定),一个不能与当前单词链式连接的单词,或者一个在此游戏中之前已经使用过的单词。
我们的内词典模型位于 models/dictionary.js 中,由词典 json 组成。对于包含无效单词的请求应被忽略,并且不应改变游戏状态(见 app/controllers/game.js);如果单词不在词典中,Promise 将被拒绝,并且不会进入 Stat.chain()。
在以下代码示例中,我们说明了如何检查提交的单词是否与当前单词链式连接:
schema.statics.chain = function(word, user) {var first = word.substr(0, 1).toLowerCase();
return Promise.resolve(Stat.findOne({}).exec())
.then(function(stat) {var currentWord = stat.word;
if (currentWord.substr(-1) !== first) {throw Helper.build400Error('not match');
}
return currentWord;
})
.then(function(currentWord ) {return Promise.resolve(Stat.findOneAndUpdate({word: currentWord,}, {$push: {used: { 'word': word, 'user': user }}
}, {upsert: false}).exec();
});
});
第一步是查询 Stat 集合以获取当前游戏状态。从游戏状态中,我们可以通过调用 stat.word 并将其分配给变量 currentWord 来知道需要匹配的当前单词。
然后我们比较当前单词与用户的输入。首先,我们使用 calling substr(0, 1) 确定提交单词的第一个字母,然后通过调用 substr(-1) 将其与当前单词的最后一个字母(currentWord)进行比较。如果用户输入的第一个字符与游戏当前单词的最后一个字符不匹配,我们将抛出一个 400 错误。Promise 将捕获这个错误,并调用 catch 回调来处理错误。
在模型的方法中,我们让模型对象返回一个 Promise 对象。稍后,我们将介绍如何在控制器的方法中捕获这个错误,并向用户返回一个 400 响应。
throw Helper.build400Error('not match');
Helper.build400Error() 函数是一个实用函数,它返回一个带有错误信息的 400 错误:
exports.build400Error = function(message) {var error = new Error(message);
error.status = 400;
return error;
};
如果单词可以与当前单词链式连接,则这是一个有效的请求。我们将得到一个成功的 Promise,这允许我们使用下一个 then 链式连接并将单词以及玩家的用户名保存到数据库中。
为了将数据保存到数据库中,我们使用 Mongoose 的 findOneAndUpdate 方法,它接受三个参数。第一个是一个查询对象,用于找到要更新的文档。我们找到 Stat.findOnequery 获取的 currentWord 对应的 stat 文档。第二个参数是 update 对象。它定义了要更新什么。
我们使用 Mongo 的修饰符 $push 将单词链式历史记录推送到 used 字段,它是一个数组。最后一个参数是选项。
我们使用{ upsert: false }选项,这意味着如果我们无法找到第一个参数中定义的查询的文档,我们不会更新或插入一个新文档。这确保在找到文档和更新文档之间没有其他操作发生,也就是说,如果当前单词找不到,我们不会插入一个新单词。因此,游戏状态不会改变,因为当前单词被分配给word并且仍然是相同的。
如果我们成功找到单词,我们将在使用单词数组中添加一个新的已使用单词对象,包括新的有效单词和提交它的用户名。
Stat.findOneAndUpdate({word: currentWord,}, {$push: {used: { 'word': word, 'user': user }
}
}, {upsert: false
}).exec();
处理竞态条件
你可能对前面的代码有疑问。查找文档和更新文档看起来像是两个独立的操作;如果两个用户发送相同的请求怎么办?它可能会导致竞态条件。
例如,如果当前单词是Today,玩家 1 提交yes,玩家 2 提交yellow;两位玩家都链出了一个有效的单词。虽然这两个单词都是有效的,但我们不能接受它们,有两个原因;每个回合只有一个玩家可以获胜,而且如果有两个或更多获胜的单词,这些单词可能以不同的字母结尾,这会影响下一个单词链。
如果yes首先到达服务器并被接受,那么下一个单词应该以s开头,并且玩家 2 的yellow将变得无效并被拒绝。这被称为竞态条件。
我们如何解决这个问题?我们需要将两个数据库操作,查找文档和更新文档,合并为一个操作。我们可以使用 Mongoose 模型的findOneAndUpdate方法。实际上,这个方法会调用 MongoDB 的findAndModify方法,这是一个隔离的更新和返回操作。由于它变成了一个数据库操作,MongoDB 将原子性地更新文档。
schema.statics.chain = function(word, user) {var first = word.substr(0, 1);
return Promise.resolve(Stat.findOne({}).exec())
.then(function(stat) {var currentWord = stat.word;
if (currentWord.substr(-1).toLowerCase() !== first.toLowerCase()) {throw Helper.build400Error('not match');
}
return currentWord;
})
.then(function(currentWord) {return Promise.resolve(Stat.findOneAndUpdate({word: currentWord,'used.word': { $ne: word }
}, {word: word,$push: {used: { 'word': word, 'user': user }}
}, {upsert: false,
})
.exec());
})
.then(function(result) {if (!result) {throw Helper.build404Error('not found');
}
return result;
});
};
当用户提交一个单词时,我们首先查询当前游戏状态,当 Promise 解析并成功后,然后检查我们提交的单词的第一个字母(first)和当前单词的最后一个字母(currentWord)是否相同。
如果它们相同,我们调用findOneAndUpdate()来搜索提交的单词并确保它不在先前使用的单词数组中。used.word: { $ne: word }然后返回一个 Promise 对象。
如果 Promise 得到满足,那么我们将提交的单词和用户推送到已使用单词数组中。
如果 Promise 被拒绝和/或条件不满足,则不会将任何数据推送到数组中(upsert: false)。
最后的then语句返回新的结果;如果没有返回,则抛出not found错误。
测试用例以测试竞态条件
现在我们实现了逻辑,我们想要测试它。测试用例如下所示:
describe('when player1 and player2 send different valid word together', function() {it('should accept player1\'s word, and reject player2\'s word', function(done) {Game.chain('geoffrey', 'hello')
.then(function(state) {expect(state.used.length).to.equal(4);
expect(state.used[3].word).to.equal('hello');
expect(state.used[3].user).to.equal('geoffrey');
expect(state.word).to.equal('hello');
});
Game.chain('marc', 'helium')
.then(function(state) {done(new Error('should return Error'));
})
.catch(function(err) {expect(err.status).to.equal(400);
done();
});
});
});
由于玩家 1 的单词先进入,玩家 1 的hello单词应该增加使用数组的长度到4,当前单词在数组中的位置应该等于hello,并且成功提交的用户的名称应该更新为geoffrey。
当 marc 提交以h开头的单词时,应该返回一个错误,因为当前单词的最后一个字母是o,而氦气不以o开头。
Socket.IO
当我们提交用户信息或单词时,我们可以向服务器发送信息,但我们如何让服务器在不手动请求更新的情况下更新我们?我们使用 Socket.IO 来启用基于事件的实时双向通信。Socket.IO 的文档可在socket.io/docs找到。我们通过执行以下代码来安装它:
npm install --save socket.io
套接字握手,用户加入
首先,我们在socket.js中引入socket.io和我们的游戏:
var socketIO = require('socket.io');
var Game = require('./app/controllers/game');
授权发生在握手过程中,这是套接字连接建立的时候。如果没有握手,我们就不知道哪个套接字连接属于哪个 Express 会话。如下面的代码所示:
module.exports = function(server) {
var io = socketIO(server, {transports: ['websocket']});
io.use(function(socket, next) {var handshakeData = socket.request;
console.log('socket handshaking', handshakeData._query.username);
socket.user = handshakeData._query.username;
Game.join(socket.user)
.then(function(users) {console.log('game joined successfully', socket.user);
socket.broadcast.emit('join', users);
next();
})
.catch(function(err) {console.log('game joined fail', socket.user);
next(err);
});
});
};
io.use()方法允许你在套接字创建后运行 Socket.IO 服务器函数。
客户端发送的请求(由 URL 和名称组成)将被存储在handshakeData中。控制台将输出用户名并确保套接字正在握手。
接下来,它将用户名分配给socket.user,以便可以传递给join()函数。套接字将调用Game.join()函数,如果用户能够加入,控制台将显示一条消息,内容为game joined successfully以及用户的姓名。
Socket.broadcast.emit方法将消息发送给所有其他客户端,除了新创建的连接,告诉他们有一个新用户加入了。
如果用户没有成功创建(即,有两个用户具有相同的名称),错误将被发送到catch方法,控制台将记录用户无法加入游戏。然后,next(err)将错误消息发送回连接的客户端,这样在客户端我们就可以显示一个弹出消息,告诉用户该名称正在被使用。
添加并推送更新到客户端
使用 Socket.IO,你可以发送和接收任何你想要的任何事件以及任何你想要的 JSON 格式的数据。
我们的游戏需要三个额外的套接字事件(在连接之后):断开连接、链(将新单词添加到上一个单词),以及游戏状态。
在socket.js中添加这三个套接字事件:
io.sockets.on('connection', function(socket) {console.log('client connected via socket', socket.user);
socket.on('disconnect', function() {console.log('socket user', socket.user, 'leave');
Game.leave(socket.user)
.then(function(users) {socket.broadcast.emit('leave', users);
});
});
socket.on('chain', function(word, responseFunc) {console.log('socket chain', word);
Game.chain(socket.user, word)
.then(function(stat) {console.log('successful to chain', stat);
if (responseFunc) {responseFunc({status: 200,
resp: stat
});
}
console.log('broadcasting from', socket.user, stat);
socket.broadcast.emit('stat', stat);
})
.catch(function(err) {console.log('fail to chain', err);
if (responseFunc) {responseFunc(err);
}
});
});
socket.on('game', function(query, responseFunc) {console.log('socket stat', socket.user);
Game.state()
.then(function(game) {console.log('socket stat end', game);
if (responseFunc) {responseFunc(game);
}
});
});
socket.on('error', function(err) {console.error('error', err);
});
});
我们订阅的第一个套接字事件connection将在用户与服务器建立套接字连接时触发。一旦客户端连接,我们记录该事件并在控制台上显示他们的名字,这样我们就知道谁连接了。
当用户与服务器断开连接时,将触发第二个事件 disconnect。这发生在他们离开游戏或网络连接中断时。一旦此事件被触发,我们就通过 socket.broadcast.emit 向所有其他套接字广播用户已离开的消息,这样其他用户就可以看到断开连接的用户不再在活跃玩家列表中。
最后两个套接字事件,链和游戏,是游戏动作。
链接接收用户的提交单词并调用 Game.chain() 函数;如果成功,则记录链接成功并将状态广播给所有其他用户。
game 响应最新的游戏状态。
启动 Socket.IO 应用程序
要启动我们的游戏,让我们创建一个名为 www 的启动脚本,并将其放置在 bin 文件夹下。以下是我们 ./bin/www 的代码,如下所示:
#!/usr/bin/env node
var app = require('../app');
var socket = require('../socket');
app.set('port', process.env.PORT || 3000);
var server = app.listen(app.get('port'), function() {console.log('Express server listening on port ' + server.address().port);
});
socket(server);
第一行告诉 shell 应使用哪个解释器来执行此脚本。在这里,我们告诉 shell 解释器是 node。然后,我们可以使用以下命令在本地启动服务器:
./bin/www
接下来,在 bin/www 中,我们将设置一个监听端口的 Express 应用程序,该端口由环境变量定义,如果没有则默认为 3000。
我们然后将套接字绑定到我们的 HTTP 服务器,该服务器由我们的 Express 应用程序创建。由于 Socket.IO 服务器需要附加到 HTTP 服务器,我们将服务器对象传递给套接字函数,在那里我们初始化套接字服务器。
因此,我们现在已经设置了启动脚本。如果我们本地启动服务器,我们将在控制台看到以下消息打印出来:
$ ./bin/www
connecting db...
Express server listening on port 3000
db connected
使用 Socket.IO 客户端测试 Socket.IO 应用程序
我们将编写客户端前端应用的 JavaScript 代码,我们将用这个游戏进行测试。
您可以在 public/javascripts/app.js 下找到 JavaScript 文件,在 app/views/index.jade 下找到视图。在这本书中,我们不会涵盖前端组件,如 jade 和 stylus/css。
我们首先设置我们的游戏变量,这些变量是 index.jade 文件中的类,我们将引用它们。我们还使用 init() 函数初始化我们的游戏,该函数将在下一个代码块中描述:
$(function() {var game = new Game({$viewLogin: $('.view-login'),$viewGame: $('.view-game'),$username: $('.username'),$wordInput: $('.word-input'),$word: $('.word'),$bywho: $('.bywho'),$users: $('.users'),});
});
var Game = function(views) {this.views = views;
this.init();
};
Game.prototype 向 app/controllers/game.js 中的 Game 方法添加函数。我们将将其拆分为几个较小的代码块,以展示我们正在处理的客户端逻辑。
init() 函数首先将用户名输入框置于焦点,然后当提交按钮被按下时,获取用户输入的值并将其分配给变量 username。
然后,我们将用户名发送到以下列出的 join() 函数,在下一个代码块中。
我们还设置了一个函数,该函数将获取提交按钮的输入 chain(这是您输入要与前一个单词链接的单词的地方),将其存储在链变量中,然后将它发送到链函数(稍后讨论)并清除文本输入框。
Game.prototype = {init: function() {var me = this;
this.views.$username.focus();
this.views.$viewLogin.submit(function() {var username = me.views.$username.val();me.join(username);
return false;
});
this.views.$viewGame.submit(function() {var word = me.views.$wordInput.val();
me.chain(word);
me.views.$chain.val('');
return false;
});
};
登录用户界面将看起来像这样:

当用户提交用户名时,它会被传递到 join 函数,该函数首先建立套接字连接,然后在服务器(game.js)上调用 User.join()(前面已介绍)并初始化套接字握手(配置为仅使用 WebSocket 作为传输协议)与提交的用户名和一个由 /?username= + username 组成的 URL。
当连接建立时,套接字会发出游戏状态和用户列表(updateStat() 和 updateUsers() 函数,我们将在后面讨论)并调用 showGameView() 函数。
showGameView() 函数(见以下代码块)隐藏登录表单,显示可以输入 word 进行链式反应的视图游戏表单,并聚焦于链式输入框。
join: function(username) {var socket = io.connect('/?username=' + username, {transports: ['websocket'],
});
this.socket = socket;
var me = this;
this.socket.on('connect', function() {console.log('connect');
me.socket.emit('game', null, function(game) {console.log('stat', game);
me.updateStat(game.stat);
me.updateUsers(game.users);
});
me.showGameView();
});
this.socket.on('join', function(users) {me.updateUsers(users);
});
this.socket.on('leave', function(users) {me.updateUsers(users);
});
this.socket.on('stat', function(stat) {me.updateStat(stat);
});
},
showGameView: function() {this.views.$viewLogin.hide();
this.views.$viewGame.show();
this.views.$wordInput.focus();
},
当用户加入或离开游戏时,它会被传递到套接字服务器(game.js)的 join 或 leave 函数,并调用客户端的 updateUsers() 函数。
updateUsers() 函数将服务器返回的用户数组映射到以列表形式显示的用户名。
类似地,当对服务器进行状态调用时,updateStat() 方法被调用,它从服务器接收当前单词(stat.word)并显示它。
此外,输入框将包含该单词的最后一个字母作为占位符,并且可以通过访问用户数组并弹出最后一个用户来显示提交当前单词的用户。
updateStat: function(stat) {this.views.$word.html(stat.word);
this.views.$wordInput.attr('placeholder', stat.word.substr(-1));
this.views.$bywho.html('current word updated by: ' + stat.used.pop().user);
},
updateUsers: function(users) {this.views.$users.html(users.map(function(user) {return '<li>' + user + '</li>';
}).join(''));
},
以下警告中给出的 chain 函数会在用户尝试提交未输入单词时提醒用户;然后它向服务器的 chain 函数发送调用,输入的单词和回调函数,该回调函数将输出从服务器接收到的数据(即响应单词和使用的数组)。
在服务器的套接字代码(socket.js 第 47 行)中查看,如果存在回调,并且函数执行成功,则发送状态 200。
如果客户端收到状态 200,则将调用 updateStat() 函数,其中 data.resp 是包含单词和使用的单词的统计对象;否则,如果没有从服务器接收到数据或链式反应失败并且返回的状态码不是 200,用户将看到一个警告,告诉他们他们的输入单词无法与当前单词链式反应。
chain: function(word) {if (!word) {return alert('Please input a word');
}
var me = this;
this.socket.emit('chain', word, function(data) {console.log('chain', data);
if (!data || data.status !== 200) {return alert('Your word "' + word + ' can\'t chain with current word.');
}
me.updateStat(data.resp);
});
}
};

使用 Chrome 开发者工具调试 Socket.IO
要调试 Socket.IO,我们想知道我们向服务器发送了什么套接字请求,请求的参数是什么,以及广播消息看起来像什么。Chrome 内置了一个强大的 WebSocket 调试工具;让我们看看如何使用它。
要打开 Chrome 开发者工具,请转到菜单,选择 查看 | 开发者 | 开发者工具。您也可以右键单击页面,并选择 检查元素。
从开发者工具中选择 网络 面板。

现在,当我们回到页面并加入游戏时,我们将在 Chrome 开发者工具的 Network 面板中看到一个 Socket.IO 请求。请求 URL 是 ws://127.0.01:3000/socket.io/?username=marc&EIO=2&transport=websocket,状态码是 101 Switching Protocols,这意味着我们通过了握手并与服务器的套接字连接建立。
现在,点击右侧面板上的 Frames 选项卡。我们将在表格中看到一些消息。白色行是我们客户端发送给服务器的消息,绿色行是服务器发送给客户端的消息。

让我们逐行查看并了解游戏中发生了什么。
0{"sid":"XNhi9CiZ-rbgbS5VAAAC","upgrades":[],"pingInterval":25000,"pingTimeout":60000}: 连接建立后,服务器向客户端返回了一些配置,例如套接字会话 ID (sid)、pingInterval 和 pingTimeout。
420["game",null]: 客户发送了一个套接字请求,以获取最新的游戏状态。
430[{"users":["leo"],"stat":{"word":"what","_id":"54cec37c0ffeb2cca1778ae6","__v":0,"used":[{"word":"what","user":"admin"}]}}]: 服务器响应了最新的游戏状态,显示当前单词是 what。
421["chain","tomorrow"]: 客户发送了一个请求,要将当前单词 what 与 tomorrow 链接起来。
431[{"status":200,"resp":{"__v":0,"_id":"54cec37c0ffeb2cca1778ae6","word":"tomorrow","used":[{"word":"what","user":"admin"},{"user":"leo","word":"tomorrow"}]}}]: 服务器接受了请求并返回了更新后的游戏状态。因此,现在的当前单词是 tomorrow
42["join",["leo","marc"]]: marc 加入游戏。现在游戏中我们有 leo 和 marc。
42["stat",{"__v":0,"_id":"54cec37c0ffeb2cca1778ae6","word":"we","used":[{"word":"what","user":"admin"},{"user":"leo","word":"tomorrow"},{"user":"marc","word":"we"}]}]: marc 将当前单词 tomorrow 与 we 链接起来。因此,服务器将游戏状态推送给客户端。
42["join",["leo","marc","geoffrey"]]: geoffrey 加入游戏。现在游戏中我们有三位玩家:leo、marc 和 geoffrey。
42["leave",["leo","geoffrey"]]: marc 离开了游戏,leo 和 geoffrey 仍然在游戏中。
现在,你已经有机会实际测试为这个应用开发的游戏,并可以看到不同方面是如何交织在一起的。
摘要
我们创建了一个 Express 应用程序、一个 Socket.IO 服务器和一个可以与我们的服务器通过 socket.io-client 库通信的游戏客户端,并接收来自服务器的推送更新。我们还经历了用户创建和单词链接逻辑,以便我们可以验证新用户和要链接的单词。在这个过程中,我们深入了解了 Promises 的世界;希望这能说明它们的通用性和如何简化你的代码。
在下一章中,我们将介绍如何构建一个用户匹配系统,并将其打造成一个服务。你还将学习如何使用 node-cron 设置周期性任务。
第五章. 与陌生人喝咖啡
在这一章中,我们将编写一个 API,允许用户去喝咖啡!这包括一个简单但可扩展的用户匹配系统。
初始时,我们只需让用户输入他们的姓名和电子邮件,这些信息将存储在 MongoDB 中。每当我们可以匹配到最近的其他用户时,就会给双方发送电子邮件,然后就是喝咖啡的时间。在设置好基础之后,我们需要确保我们记录匹配情况,以避免重复发生,从而提供更好的用户体验。
很快,让我们做好准备,使其全球化,并考虑他们的地理位置。
假设一切顺利(这是一个错误),我们已经进行了验证。所以是时候重构到一个更易于维护的架构中,其中配对本身成为一个服务。
最后,让我们允许我们的用户对他们的会议进行评分,并告诉我们它是否是一个成功的会议,在现实世界应用中,用户生成的反馈是无价的!
我们期望这种应用程序结构将为读者提供灵感,以创建现实世界的匹配应用程序。
代码结构
在进入实际代码之前,我们想要提供关于本章代码结构的提示,这比之前有所不同,我们希望它能为 Express 和 Node.js 的代码结构提供另一个视角。
有些人可能会称之为工厂模式;它由将每个文件的代码包裹在一个可以用来配置或测试它的函数中组成。虽然这需要更多的脚手架,但它使我们的代码摆脱了对静态状态的依赖。它通常会看起来如下:
'''javascript
module.exports = function (dependency1){
// these will be public
var methods = {}
// individual for each instance
var state = 0
// some core functionality of this file
methods.addToState = function(x) {
state += x
};
methods.getResult = function() {
return dependency1.getYforX(state)
};
return methods
}
'''
这种结构的推论是,这个文件的每次调用都将有自己的状态,就像类的实例一样,但我们不依赖于它,而是依赖于永远不会丢失的作用域。
再进一步,我们将尝试将每个文件夹的组件结构集中化,每个文件夹都有一个相应的index.js,其主要职责是在需要时初始化实例,保留将被传递下来的依赖项的引用,并仅返回公共方法。
定义路由
让我们先定义我们需要的第一个路由以及我们希望它们如何表现,并按照 TDD 风格,先构建严格必要的简单逻辑步骤。
-
第一件事是我们需要让用户能够注册;注册我们的用户的最小测试用例如下:
'''javascript var dbCleanup = require('./utils/db') var expect = require('chai').expect; var request = require('supertest'); var app = require('../src/app'); describe('Registration', function() { it("shoots a valid request", function(done){ var user = { 'email': 'supertest'+Math.random()+'@example.com', 'name': 'Super'+Math.random(), }; request(app) .post('/register') .send(user) .expect(200, done); }) }) -
假设你已经通过
npm i -g mocha安装了 Mocha,执行mocha。 -
看到 404 了吗?这是个好开始!现在让我们扩展并创建一个文件,
src/route/index.js,它将声明应用所知道的所有路由。它使用控制器来处理每个关注点。 -
从
user.js开始,它实现了一个创建动作,如下面的代码所示:'''javascript // src/routes/index.js module.exports = function() { var router = require('express').Router(); var register = require('./user)(); router.post("/user", user.create); return router; }; // src/routes/user.js module.exports = function() { var methods = {}; methods.create = function(req,res,next) { res.send({}); } return methods; }; -
这段代码应该足以让 Mocha 通过测试。
![定义路由]()
-
对于这个应用,我们将所有的路由定义放在一个地方,即
routes/index.js。
在这个阶段,我们知道测试设置是有效的。接下来,让我们转向持久性和一些业务逻辑!
持久化数据
在库中添加一些多样性,让我们尝试 Mongojs (github.com/mafintosh/mongojs),这是一个旨在尽可能接近原生客户端的简单 MongoDB 库。
-
首先,让我们创建一个小的配置文件,
./config.js,用于存储所有常用数据,并返回一个包含每个环境相关配置的简单对象。现在让我们确保我们有一个 Mongojs 接受的 URL 格式。 -
此文件应能够存储应用的所有全局配置。它确保我们根据环境有不同的设置。
module.exports = function(env) { var configs = {}; configs.dbUrl = "localhost/coffee_"+env; return configs; }; -
此文件需要位于
app.js中,这是一个初始化和收集依赖项的中心位置,它将被传递给我们的数据库,然后返回公共方法。让我们在以下代码中看看这是如何发生的:'''javascript //.. var config = require('../config')(app.get('env')); var models = require('./models')(config.dbUrl); app.set('models', models); //.. -
对于我们的模型,让我们定义一个文件来管理所有模型
src/models/index.js`,其主要职责是实例化数据库并公开公共方法给其他模块,以便存储细节保持封装,使代码保持清洁和松耦合。'''javascript module.exports = function(dbUrl) { var mongojs = require('mongojs'); var db = mongojs(dbUrl); var models = { User: require('./user')(db) }; return models; }; -
我们的第一个模型,
user,具有创建一个用户的能力。请注意,在这个模型中我们没有进行任何验证以保持简单。在没有对模型进行双重检查的情况下不要投入生产。'''javascript module.exports = function (db) { var methods = {}; var User = db.collection('users'); methods.create = function(name,email,cb) { User.insert({ name: name, email: email }, cb) }; return methods; } -
让我们更新我们的
user.js路由以使用我们的数据库:'''javascript module.exports = function(Model) { var methods = {}; methods.create = function(req,res,next) { Model.User.create(req.param('name'), req.param('email'), function(err, user) { if(err) return next(err); res.send(user); }); } -
通过这个简单的更改,我们应该在我们的数据库中创建了一个用户。
让我们打开 Robomongo (robomongo.org/)来查看创建了哪些用户数据;无论我们使用什么库,查找我们在 MongoDB 中的数据都非常方便。

异常处理
让我们在这里打开一个括号,并讨论if(err) return next(err);。这是一个用于在单个操作中抽象错误处理的模式,该操作应该在 Express 的后续堆栈中进一步处理,通过app.use。
-
为了保持整洁,我们可以将错误处理抽象到一个单独的文件中,我们将为每种类型的错误定义特定的处理程序
src/routes/errorHandler.js。 -
让我们先定义一个
catchAll()方法。Express 将知道这个函数的用途,因为它的功能是 4。''' module.exports = function() { var methods = {}; methods.catchAll = function(err, req, res, next) { console.warn("catchAll ERR:", err); res.status(500).send({ error: err.toString ? err.toString() : err }); } return methods; }; ''' -
最后,它在
routes/index.js中被激活。错误处理应该是最后一个中间件(s)://.. router.use(errorHandler.catchAll); return router; };
简单配对
我们可以实现的 simplest 配对系统是,当有人注册时,简单地查找是否有其他未配对的可用用户。
为了做到这一点,我们将开始一个新的集合和模型:Meeting,它将成为我们将扩展的基本匹配结构。基本思想是每个文档将代表一个会议;无论是请求阶段、已经设置或发生,最后也会存储反馈。
我们将随着进展详细阐述并定义其结构。对于初始实现,让我们在用户决定配对时立即运行调度逻辑。策略将是查找一个只有第一个用户被设置的会议文档,并更新它。如果没有这样的文档,让我们创建一个新的。
可能会触发一些竞争条件,我们当然希望避免。具体如下:
-
正在尝试找到安排对象的用户在过程中被安排。
-
可用于安排的用户被选中,但随后被其他人保留。
幸运的是,MongoDB 提供了 findAndModify() 方法,它可以在单个文档上自动查找和更新,同时返回更新后的文档。请注意,它还提供了一个 update() 方法来更新多个方法。
备注
让我们从一个新的集合 Meeting 开始,我们将跟踪用户寻找配对的兴趣以及跟踪会议,如下所示:
-
此文档将包含用户到该时间点的所有信息,因此我们可以将其用作历史记录,同时也可以使用其内容发送电子邮件和设置审查。
-
让我们看看
src/models/meeting.js中的代码是什么样的:'''javascript var arrangeTime = function() { var time = moment().add(1,'d'); time.hour(12); time.minute(0); time.second(0); return time.toDate(); }; methods.pairNaive = function(user, done) { /** * Try to find an unpaired User in Meeting collection, * at the same time, update with an pair id, it either: * 1\. Add the new created user to Meeting collection, or * 2\. The newly created user was added to a Meeting document */ Meeting.findAndModify({ new: true, query: { user2: { $exists: false }, }, update: { $set: { user2: user, at: arrangeTime() } } }, function(err, newPair) { if (err) { return done(err) } if (newPair){ return done(null, newPair); } // no user currently waiting for a match Meeting.insert({user1: user}, function(err,meeting) { done(); }) }); }; ''' -
在成功配对的情况下,
user2将被设置在Meeting对象中,以便第二天中午进行会面,正如您在属性at中所看到的,我们通过aux的arrangeTime()函数和轻量级库 moment.js (momentjs.com/) 来设置。以这种方式处理日期非常易于阅读。建议您查看并熟悉它。
此外,请注意作为参数的 new: true。它确保 MongoDB 返回对象的更新版本,因此我们不需要在应用程序中重复逻辑。
需要创建一个新的对象 Meeting,因为它携带了当时用户的信息,并且可以用来为双方编写电子邮件/通知。
这是一个定义我们后续测试的基本结构的好机会,这些测试将遵循对端点进行多次调用并断言响应的模式。有关立即实施测试的决定有详细的解释,如下面的代码所示:
'''
describe('Naive Meeting Setup', function() {
// will go over each collection and remove all documents
before(dbCleanup);
var userRes1, userRes2;
it("register user 1", function(done){
var seed = Math.random()
var user = {
'name': 'Super'+seed,
'email': 'supertest'+seed+'@example.com',
}
request(app)
.post('/register')
.send(user)
.expect(200, function(err,res){
userRes1 = res.body
done(err)
})
});
it('should be no meeting for one user', function(done) {
models.Meeting.all(function(err,meetings) {
expect(meetings).to.have.length(1);
var meeting = meetings[0];
expect(meeting.user1).to.be.an("object");
expect(meeting.user2).to.be.an("undefined");
done(err);
});
});
it("register user 2", function(done){
var seed = Math.random();
var user = {
'name': 'Super'+seed,
'email': 'supertest'+seed+'@example.com',
};
request(app)
.post('/register')
.send(user)
.expect(200, function(err,res){
userRes2 = res.body
done(err)
});
});
it('should be a meeting setup with the 2 users', function(done) {
models.Meeting.all(function(err,meetings) {
expect(meetings).to.have.length(1)
var meeting = meetings[0]
expect(meeting.user1.email).to.equal(userRes1.email)
expect(meeting.user2.email).to.equal(userRes2.email)
done(err)
});
});
});
'''
(来源:git checkout e4fbf672d409482028de7c7427eab769ab0a20d2)

测试笔记
当使用 Mocha 时,测试就像任何 JavaScript 文件一样,按预期执行,允许进行任何常规 Node.js require 操作。
describe()方法是我们测试执行的上下文;在我们的情况下,它是一定功能性的完整运行。before()方法将只运行一次;在这种情况下,我们的逻辑是清理所有的 MongoDB 集合。
它代表了一个简单的期望得以实现。它将以声明的相同顺序运行,我们将尽可能使断言小而可预测。这些函数中的每一个都定义了步骤,在这种情况下,因为我们正在进行端到端测试,我们向 API 发送请求并检查结果,有时将其保存到稍后用于断言的变量中。
有建议说测试不应该依赖于之前的状态,但这些通常不是测试应用程序流程,而是逻辑的各个部分。对于这个特定的测试场景,如果发生失败,重要的是从第一个失败的it中解释错误;修复它可能会修复之后的错误。您可以通过使用-b标志来配置 Mocha 在第一个错误时停止。
在测试时,最重要的要点是确保我们的测试用例检查了所有预期的案例,并且不会发生不良行为。当然,我们永远无法预测可能出错的所有事情,但测试我们确信的常见问题点仍然是我们的责任。
考虑用户历史
我们的用户可能希望始终配对以结识新朋友,因此我们必须避免重复的会议。我们该如何处理这种情况?
首先,我们需要允许设置新会议的方法。想象一下,它就像一个应用中的按钮,会触发对POST/meeting/new路由的请求。
当请求被允许并且找到一对或没有一对但现在附属于一个meeting对象并且可以与另一个用户匹配时,此端点将以状态200回复;如果用户已经安排在另一个会议中,则回复412,如果用户期望的电子邮件未发送,则回复400;在这种情况下,它不能被满足,因为用户未指定。
小贴士
状态码的使用有些主观,(在维基百科上查看更全面的列表:en.wikipedia.org/wiki/List_of_HTTP_status_codes)。然而,拥有不同的响应是很重要的,这样客户端就可以向用户显示有意义的消息。
让我们实现一个 Express.js 中间件,该中间件要求所有代表用户提出的请求都提供电子邮件。它还应加载他们的文档并将其附加到res.locals,这可以在后续的路由中使用。
我们的src/routes/index.js将看起来像这样:
'''javascript
//...
app.post("/register", register.create);
app.post("/meeting", [filter.requireUser], meeting.create);
//...
'''
The filter in 'src/routes/filter.js' is:
'''javascript
module.exports = function(Model) {
var methods = {};
methods.loadUser = function(req,res,next) {
var email = req.query.email || req.body.email
if(!email) return res.status(400).send({error: "email missing, it should be either in the body or querystring"});
Model.User.loadByEmail(email, function(err,user) {
if(err) return next(err);
if(!user) return res.status(400).send({error: "email not associated with an user"});
res.locals.user = user;
next();
})
}
return methods;
};
这个中间件的目标是对于每个没有用户电子邮件的请求停止并返回错误消息。这是一个通常需要用户名和密码或秘密令牌的验证。
让我们为这个中间件设置一个小型但重要的测试套件:
-
清空数据库
-
尝试不使用电子邮件获取我并失败
-
创建一个有效的用户;它成功了
-
尝试使用另一个电子邮件获取我;它失败了
-
使用我们注册的电子邮件访问我,它成功了!
现在我们有了一种加载请求用户的方法,让我们回到不重复匹配人的目标。作为先决条件,他们的过去会议时间必须已经过去,否则它将返回412代码。
如果我们想为我们的用户安排会议,但任何安排的会议都将定在明天,我们如何测试它?时间记录器(github.com/vesln/timekeeper),一个具有简单界面的 Node.js 库,可以更改系统日期;这对于测试特别有用。仔细查看以下测试片段:
'''javascript
describe('Meeting Setup', function() {
before(dbCleanup);
after(function() {
timekeeper.reset();
});
// ...
it('should try matching an already matched user', function(done) {
request(app)
.post('/meeting')
.send({email:userRes1.email})
.expect(412, done);
});
it('should be able match the user again, 2 days later', function() {
var nextNextDay = moment().add(2,'d');
timekeeper.travel(nextNextDay.toDate());
request(app)
.post('/meeting')
.send({email:userRes1.email})
.expect(200, function(err,res){
done(err);
});
});
设置一个after钩子来重置时间记录器非常重要,这样在场景成功或失败完成后,日期才能恢复正常;否则,有可能改变其他测试的结果。还值得检查如何使用moment()方法轻松地进行日期操作,一旦使用timekeeper.travel()函数,时间就会扭曲到那个日期。对于所有 Node.js 来说,新的扭曲时间实际上是实际时间(尽管它不会影响任何其他应用程序)。我们也可以根据需要来回切换。
执行此检查的Meeting方法(在models/meeting.js中定义)如下:
methods.isUserScheduled = function(user, cb) {
Meeting.count({
$or:[
{'user1.email': user.email},
{'user2.email': user.email}
],
at: {$gt: new Date()}
}, function(err,count) {
cb(err, count > 0);
});
};
$or运算符是必要的,因为我们不知道我们正在寻找的用户将是user1还是user2,所以我们利用 MongoDB 的查询功能,可以在文档中的对象内部查找,并将email作为String匹配,以及前面提到的at字段。
我们新创建的src/routes/meeting.js如下所示:
'''javascript
module.exports = function(Model) {
var methods = {};
methods.create = function(req,res,next) {
var user = res.locals.user;
Model.Meeting.isUserScheduled(user, function(err,isScheduled) {
if(err) return next(err);
if(isScheduled) return res.status(412).send({error: "user is already scheduled"});
Model.Meeting.pair(user, function(err,result) {
// we don't really expect this function to fail, if that's the case it should be an internal error
if(err) return next(err);
res.send({});
})
})
}
return methods;
};
接下来,我们将定义一个非常重要的辅助函数,该函数用于查找涉及请求用户的先前会议,并返回他们匹配到的每个人的电子邮件,这样我们就可以避免再次将这两位用户匹配在一起。
这样的辅助函数在处理复杂的逻辑时非常有用,可以保持代码的可理解性。一般来说,当一大块代码可以抽象成一个概念时,总是将其分离成更小的函数。
/**
* the callback returns an array with emails that have previously been
* matched with this user
*/
methods.userMatchHistory = function(user,cb) {
var email = user.email;
Meeting.find({
$or:[
{'user1.email': email},
{'user2.email': email}
],
user1: {$exists: true},
user2: {$exists: true}
}, function(err, meetings) {
if(err) return cb(err);
var pastMatches = meetings.map(function(m) {
if( m.user1.email != email) return m.user1.email;
else return m.user2.email;
});
// avoid matching themselves!
pastMatches.push(user.email);
cb(null, pastMatches);
})
}
userMatchHistory对象的键是通过 MongoDB 的$nin运算符实现的,它在元素不匹配数组中的内容时执行匹配。匹配逻辑与我们在简单配对中使用的逻辑完全相同。
在我们的Meeting模型中,我们用pair方法替换了之前的pairNaive方法,它做的是类似的事情,但首先构建一个先前匹配的列表,以确保我们不会再次匹配这些用户。
methods.pair = function(user,done) {
// find the people we shouldn't be matched with again
methods.userMatchHistory(user, function(err, emailList) {
if(err) return done(err);
Meeting.findAndModify({
new: true,
query: {
user2: { $exists: false },
'user1.email': {$nin: emailList}
},
update: {
$set: {
user2: user,
at: arrangeTime()
}
}
}, function(err, newPair) {
if (err) { return done(err); }
if (newPair){
return done(null, newPair);
}
Meeting.insert({user1: user}, function(err,meeting) {
done();
})
return;
});
})
}
优化距离
让我们用一个接地气的地理位置方法(啊!我真是太有趣了)来匹配。我们必须现实。我们的服务起源于小城,但正在走向全球,我们不能匹配距离太远的人。
由于我们的会议在Meeting集合上没有竞态条件,我们希望保持这种状态,因此让我们调整现有的pair方法以包含用户的位置。我们可以假设在注册时,他们将提供他们的位置(或者我们也可以很容易地更新会议文档,一旦他们提供位置)。在我们的现有策略中,有一个用户创建会议文档;在这种情况下,让我们也设置他们的位置,这样下一个寻找匹配的用户就必须在相似的位置,作为额外的约束,如下面的代码所示:
'''javascript
Meeting.ensureIndex({location1: "2dsphere"});
//..
methods.pair = function(user,done) {
methods.userMatchHistory(user, function(err, emailList) {
if(err) return done(err);
Meeting.findAndModify({
new: true,
query: {
user2: {$exists: false},
'user1.email': {$nin: emailList},
'location1': {$nearSphere:{
$geometry :
{ type : 'Point',
coordinates : user.location } ,
$maxDistance : 7*1000
}}
},
update: {
$set: {
user2: user,
at: arrangeTime()
}
}
}, function(err, newPair) {
if (err) { return done(err); }
if (newPair){
return done(null, newPair);
}
Meeting.insert({
user1: user,
location1: user.location
}, function(err,meeting) {
done();
})
return;
});
});
}
'''
我们的Meeting集合现在将location1索引为2dsphere。可以使用操作符$nearSphere轻松地将此字段的 geo 查询与之前的查询集成,以匹配球对象中的地理位置。$maxDistance是匹配的最大半径。它以米为单位表示,在这种情况下,我们使用Point与坐标相交,Point是一个之前注册的用户。7km是任意选择的,因为它似乎是一个足够合理的半径来与人见面。
如果我们将$maxDistance改为相当小的值,一些测试将失败,因为匹配不会发生;请参阅test/meeting_near.js。
-
清空数据库
-
在圣地亚哥创建用户 1
-
在瓦尔帕莱索创建用户 4
-
检查是否有匹配
-
在圣地亚哥创建用户 2
-
检查 1 和 2 之间是否有匹配
-
在温哥华创建用户 3
-
检查 3 是否有匹配
-
在瓦尔帕莱索创建用户 5
-
检查 4 和 5 之间是否有匹配
(来源:git checkout 52e8f80b7fe3b9482ff27ea1bcc410270752a796)
邮件跟进
用户现在可以进行匹配。会议是独特的,并且是在附近的人之间进行的,这真是太棒了!匹配系统可能的改进没有尽头;因此,现在让我们收集一些关于他们的会议如何进行的数据!
为了做到这一点,我们将向每位与会者发送一封电子邮件,其中包含一些简单的选项来促进参与。其中一些如下列出:
-
真棒
-
真糟糕
-
嘿...
-
我的搭档没有出现!
这些值被添加到src/models/meeting.js中作为键值对,我们可以存储它们以供评分,并使用它们向用户传达信息。
methods.outcomes = function() {
return {
awesome : "It was awesome",
awful : "It was awful",
meh : "Meh",
noshow : "My pair didn't show up!"
}
}
我们可以将这些响应存储在相应的meeting对象中,将其与回复的用户关联起来。
为了这个目的,我们将主要依赖包Nodemailer(github.com/andris9/Nodemailer)。它被广泛使用,并提供了对许多集成(包括传输提供者和模板)的支持,这样我们就可以使我们的电子邮件动态化。
来到设置决策,正如你可能意识到的,Node.js 和 Express 在如何设置代码方面没有约定,因为这些应用程序可能做非常不同的事情,没有一种适合所有情况的解决方案。让我们将邮件作为一个单独的关注点,就像持久性和路由一样,它们是分离的关注点,集成到src/app.js中。
src/mailer/index.js将是我们的入口点,其主要责任是实例化nodemailer变量并提供其他文件可以引用的公共方法。
'''
var nodemailer = require('nodemailer')
module.exports = function (mailConfig){
var methods = {};
var transporter;
// Setup transport
if(process.env.NODE_ENV == 'test'){
var stubTransport = require('nodemailer-stub-transport');
transporter = nodemailer.createTransport(stubTransport());
} else if( mailConfig.service === 'Mailgun'){
transporter = nodemailer.createTransport({
service: 'Mailgun',
auth: {
user: mailConfig.user,
pass: mailConfig.password
}
});
} else {
throw new Error("email service missing");
}
// define a simple function to deliver mails
methods.send = function(recipients, subject, body, cb) {
// small trick to ensure dev & tests emails go to myself
if(process.env.NODE_ENV !== 'production') {
recipients = ["my.own.email@provider.com"];
}
transporter.sendMail({
to: recipients,
from: mailConfig.from,
subject: subject,
generateTextFromHTML: true,
html: body
}, function(err, info) {
// console.info("nodemailer::send",err,info)
if(typeof cb === 'function'){
cb(err,info);
}
})
}
return methods;
}
当涉及到测试环境时,我们绝对不希望发送真实的电子邮件,这就是为什么我们注册了存根传输。对于其他环境,我们决定使用Mailgun,但我们也可以选择任何通过 SMTP 集成的服务(记住使用 Gmail,因为存在发送电子邮件失败的风险,因为它们有一系列启发式方法来防止垃圾邮件)。
当涉及到测试时,这个部分是较难测试的部分之一,我们将在test/send_mail.js中实现一些非常基本的测试。
var dbCleanup = require('./utils/db');
var app = require('../src/app');
var mailer = app.get('mailer');
describe('Meeting Setup', function() {
it('just send one.', function(done) {
this.timeout(10*1000);
mailer.send(
"my.own.email@provider.com",
"Test "+(new Date()).toLocaleString(),
"Body "+Math.random()+"<br>"+Math.random()
, done);
})
})
将其添加到config.js中,并定义相应的环境变量,因为将我们的秘密保留在代码中不是一个好主意。
var ENV = process.env;
configs.email = {
service: "Mailgun",
from: ENV.MAIL_FROM,
user: ENV.MAIL_USER,
password: ENV.MAIL_PASSWORD
};
当我禁用test环境时,我实际上可以在我的收件箱中看到电子邮件。胜利!为了让服务看起来更好,让我们尝试一些模板,这正是 email-templates (github.com/niftylettuce/node-email-templates)所关注的。
这使得实现动态电子邮件变得容易,包括将 CSS 内联打包;许多电子邮件客户端要求这些内容必须内联。
在src/mailer/followUp.js中
'''javascript
module.exports = function(sendMail, models) {
//..
function sendForUser (user1, user2, id, date, cb) {
emailTemplates(templatesDir, function(err,template) {
if(err) return cb(err);
template('followup', {
meetingId: id.toString(),
user1 : user1,
user2 : user2,
date : date,
outcomes : Meeting.outcomes()
}, function(err,html) {
if(err) return cb(err);
sendMail(
user1.email,
"How was your meeting with "+user2.name+"?",
html,
cb
)
});
});
}
// call done() when both emails are sent
return function followUp(meeting, done) {
async.parallel([
function(cb) {
sendForUser(meeting.user1, meeting.user2, meeting._id, meeting.at, cb);
},
function(cb) {
sendForUser(meeting.user2, meeting.user1, meeting._id, meeting.at, cb);
},
], done)
}
}
实际上,我们发送两封相同的电子邮件,以便从两个用户那里获得反馈。这里有一些复杂性,我们将通过使用async.parallel()方法来管理。它允许我们启动两个异步操作和回调(完成),当两者都完成时。请参阅github.com/caolan/async#parallel。
电子邮件的实际打印由两个文件创建,分别是src/mailer/templates/followup/followUp.html.swig和style.css,它们分别通过我们的传输解决方案组合和设置:
'''html
<h4 class="title">
Hey {{user1.name}},
</h4>
<div class="text">
We hope you just had an awesome meeting with {{user2.name}}!
You guys were supposed to meetup at {{date|date('jS \o\f F H:i')}}, how did it go?
</div>
<ul>
{% for id, text in outcomes %}
<li><a href="http://127.0.0.1:8000/followup/{{meetingId}}/{{user2._id.toString()}}/{{id}}">{{text}}</a></li>
{% endfor %}
</ul>
<div class="text">
Hope to see you back soon!
</div>
'''
'''css
body{
background: #EEE;
padding: 20px;
}
.text{
margin-top: 30px;
}
ul{
list-style-type: circle;
}
ul li{1
line-height: 150%;
}
a{
text-decoration: none;
}
我们可以从许多模板解决方案中选择。swig (paularmstrong.github.io/swig/docs/)提供了方便的辅助工具,使得处理列表变得容易,并且具有熟悉的 HTML 视觉。以下是一些见解:
-
{{string}}是通用的插值方法 -
|用于辅助工具(也称为过滤器);您可以使用内置的或定义自己的 -
for k,v in obj是一个标签,用于遍历键值对

当涉及到跟进链接的逻辑时,我们让用户提供反馈变得非常简单;通常,摩擦越少,对卓越的用户体验越好。他们只需点击链接,他们的评论就会立即记录!在 Express.js 方面,这意味着我们必须设置一个路由,将所有数据片段连接起来;在这种情况下,在src/routes/index.js中:
app.get("/followup/:meetingId/:reviewedUserId/:feedback", meeting.followUp);
要有一个实际更改数据的GET端点,这是 HTTP & REST 约定的例外,但原因是电子邮件客户端将请求作为GET发送;我们对此无能为力。
方法在src/routes/meeting.js中定义如下:
methods.followUp = function(req,res,next) {
var meetingId = req.param("meetingId");
var reviewedUserId = req.param("reviewedUserId");
var feedback = req.param("feedback");
// validate feedback
if(!(feedback in Model.Meeting.outcomes())) return res.status(400).send("Feedback not recognized");
Model.Meeting.didMeetingHappened(meetingId, function(err, itDid) {
if(err){
if(err.message == "no meeting found by this id"){
return res.status(404).send(err.message);
} else {
return next(err);
}
}
if(!itDid){
return res.status(412).send("The meeting didn't happen yet, come back later!");
}
Model.Meeting.rate(meetingId, reviewedUserId, feedback, function(err, userName, text) {
if(err) return next(err);
res.send("You just rated your meeting with "+userName+" as "+text+". Thanks!");
});
});
}
此方法进行了相当多的检查,这是因为有相当多的输入需要验证,并提供适当的响应。首先,我们检查提供的feedback是否有效,因为我们只接受定量数据。didMeetingHappened返回关于会议的两条重要信息;ID 可能完全错误,或者它可能还没有发生。这两种情况都应该提供不同的结果。最后,如果一切看起来都很好,我们尝试评分会议,这应该会正常工作并返回一些数据以响应,并以隐含的200状态完成请求。
上述方法的实现可在src/models/meeting.js中找到
'''
// cb(err, itDid)
methods.didMeetingHappened = function(meetingId, cb) {
if(!db.ObjectId.isValid(meetingId)) return cb(new Error("bad ObjectId"));
Meeting.findOne({
user1: {$exists: true},
user2: {$exists: true},
_id: new db.ObjectId(meetingId)
}, function(err, meeting) {
if(err) return cb(err);
if(!meeting) return cb(new Error('no meeting found by this id'));
if(meeting.at > new Date()) return cb(null,false);
cb(null,true);
})
}
// cb(err, userName, text)
methods.rate = function(meetingId, reviewedUserId, feedback, cb) {
Meeting.findOne({
_id: new db.ObjectId(meetingId),
}, function(err,meeting) {
if(err) return cb(err)
var update = {};
// check the ids present at the meeting object, if user 1 is being reviewed then the review belongs to user 2
var targetUser = (meeting.user1._id.toString() == reviewedUserId) ? '1' : '2';
update["user"+targetUser+"review"] = feedback;
Meeting.findAndModify({
new: true,
query: {
_id: new db.ObjectId(meetingId),
},
update: {
$set: update
}
}, function(err, meeting) {
if(err) return cb(err);
var userName = (meeting["user"+targetUser].name);
var text = methods.outcomes()[feedback];
cb(null, userName, text);
})
})
}
'''
实现方法应该是相当易读的。didMeetingHappened()方法寻找最多一个带有_id的文档,其中user1和user2被填写。当找到这个文档时,我们查看at字段并与当前时间比较,以检查它是否已经发生。
速率略长,但同样简单。我们找到meeting对象,并确定哪个用户正在被评分。这种属于相反用户的反馈存储在原子操作中,设置字段user1reviewed或user2reviewed与反馈的键。
我们为这种情况实现了一个详尽的测试套件,我们关注成功和失败的情况。可以通过简单地调用带有NODE_ENV=development mocha test/meeting_followup.js的测试来检查电子邮件,这将覆盖测试环境为开发模式,并将电子邮件发送到我们的提供商,这样我们就可以看到它的样子并进行微调。
我们对这个整个场景的测试略长,但我们需要测试几个事情!
-
清理数据库
-
设置会议
-
注册用户 1
-
在相同位置注册用户 2
-
测试不存在会议无法被审查
-
状态 412 在尚未发生的会议审查中
-
提前两天计算旅行时间
-
发送电子邮件
-
选择有意义的复习内容
-
用户 1 应该能够审查会议
-
用户 2 也应该能够审查会议
看起来我们现在可以发送电子邮件并接收评论,这很好,但我们如何以时间敏感的方式发送电子邮件?会议开始后几分钟,电子邮件应该发送给双方。
(来源:git checkout 7f5303ef10bfa3d3bfb33469dea957f63e0ab1dc)
使用 node-cron 进行周期性任务
也许你熟悉 cron (en.wikipedia.org/wiki/Cron)。它是一个基于 Unix 的任务调度系统,使得运行任务变得容易。它的问题之一是与你的平台相关联,从代码中开启和关闭它并不简单。
认识一下node-cron(github.com/ncb000gt/node-cron)。它基本上是一个相同的任务调度器,但它直接从您的 Node 应用程序运行,因此只要它运行,您的作业就应该运行。
我们的策略很简单:定期选择所有需要邮寄的会议,调用我们的邮件发送者,并针对这些会议中的每一个,将其标记为已邮寄。
按照此应用程序的约定,让我们将关注点分离到它们自己的文件夹中,从以下代码中的src/tasks/index.js开始:
var CronJob = require('cron').CronJob;
module.exports = function(models, mailer) {
var tasks = {};
tasks.followupMail = require('./followupMail')(models,mailer);
tasks.init = function() {
(new CronJob('00 */15 * * * *', tasks.followupMail)).start();
};
return tasks;
}
它需要将models和mailer作为参数,这些参数可以在任务中使用。followupMail是目前唯一定义的用户,因为我们只需要它。导出的init方法将启动 cron 作业,定时器分别表示:00定义为秒,意味着它将在每*/15分钟的第00秒运行,任何小时,任何月份的任何一天,任何月份,任何星期的任何一天。关于实际任务,请参阅src/mailer/followUp.js
'''
module.exports = function(Model, mailer) {
return function() {
Model.Meeting.needMailing(function(err,meetings) {
if(err) return console.warn("needMailing", err);
if(!meetings || meetings.length < 1) return;
meetings.forEach(function(meeting) {
mailer.followUp(meeting, function(err) {
if(err) return console.warn("needMailing followup failed "+meeting._id.toString(), err);
Model.Meeting.markAsMailed(meeting._id);
});
});
Model.Meeting.markAsMailed(meetings);
});
};
};
'''
它返回一个函数,当执行时,查找所有仍需要邮寄的会议文档,并对每个文档使用我们之前定义的mailer.followUp,完成后,将每封电子邮件标记为已发送。请注意,这里的失败没有地方可以通信,因为这是一个自动化的任务。对于 Web 服务器来说,有意义的日志报告很重要,因此在这种情况下,警告消息应该被报告。
当然,这需要我们在src/models/meeting.js中添加两个方法,你现在应该能够轻松理解:
// all meetings that are due and not mailed yet
methods.needMailing = function(cb) {
Meeting.find({
at: {$lt: new Date},
mailed: {$exists: false}
},cb);
};
// mark a meeting as mailed
methods.markAsMailed = function(id,cb) {
Meeting.findAndModify({
query: {
_id: id
},
update:{
$set: {mailed: new Date()}
}
},cb);
};
在我们的最终测试中,我们将创建四个用户,意味着有 2 个会议,提前 2 天旅行,并通过任务尝试发送电子邮件,它应该工作并标记两封电子邮件为已发送。
-
清空数据库
-
在同一地点注册用户 1、2、3 和 4
-
会议结束后,旅行时间
-
任务应发送电子邮件
-
验证电子邮件是否已发送
摘要
在本章中,我们创建了一个 API,该 API 可以在考虑用户的匹配历史和经纬度对的情况下为用户安排会议,同时给他们提供机会对会议进行反馈——这些信息可以以多种方式使用,以进一步改进算法!
我们希望您了解了许多有趣且实用的概念,例如进行地理查询、测试时间敏感的代码、以风格发送电子邮件以及定期运行的任务。
除了技术细节之外,希望您玩得开心,也许能够激发一些关于匹配应用框架的洞察!
接下来,我们将通过利用生成器的力量来了解Koa.js的工作原理,将同步代码的可读性置于 Node.js 的异步功能之上。
第六章:Koa.js 上的 Hacker News API
在本章中,我们将构建一个 API 来为我们的 Hacker News 提供动力!虽然从技术上讲,这与前几章不会有很大不同,但我们将使用完全不同的框架,即 Koa.js (koajs.com/)。
Koa.js 是由 Express 背后的团队设计的新型 Web 框架。他们为什么创建一个新的框架?因为它是从底层设计的,具有最小化的核心以实现更多模块化,并利用 ECMAScript 6 中提出的新的生成器语法,尽管它已经在 node 0.11 中实现。
注意
节点的不规则版本被认为是不可稳定的。在撰写本文时,最新的稳定版本是 0.10 版本。然而,当这本书付印时,node 0.12 终于发布,是当时的最新稳定版本。
node 0.11 的一个替代方案是 io.js,在撰写本文时达到了 1.0 版本,并实现了 ES6 的特性(从 Node.js 分叉并由少数几个 node 核心贡献者维护)。在本章中,我们将坚持使用 node 0.11。(当这本书付印时,node 0.12 最终发布,是当时的最新稳定版本。)
生成器语法的主要好处之一是您可以非常优雅地避免回调地狱,而无需使用复杂的 promise 模式。您甚至可以比以前更干净地编写您的 API。我们将讨论一些细微差别以及一些与尖端技术相关的注意事项。
本章我们将涵盖以下内容:
-
生成器语法
-
中间件哲学
-
上下文,与 req,res 对比
-
集中错误处理
-
Koa.js 中的 Mongoose 模型
-
使用 Thunkify 来使用 Express 模块
-
使用 Mocha 测试生成器函数
-
使用 co-mocha 进行并行 HTTP 请求
-
使用 koa-render 渲染视图
-
使用 koa-mount 和 koa-static 提供静态资源
生成器语法
生成器函数是 Koa.js 的核心,所以让我们直接深入剖析这个怪物。生成器允许熟练的 JavaScript 用户以全新的方式实现函数。Koa.js 利用新的语法以同步的方式编写代码,同时保持异步流程的性能优势。
以下定义了一个简单的生成器函数src/helloGenerator.js(注意星号语法):
module.exports = function *() {
return 'hello generator';
};
要在 Koa.js 中使用 Mocha:
-
您需要包含
co-mocha以添加生成器支持,在测试文件的第一个行引入它是安全的方式。现在您可以将生成器函数传递给 Mocha 的it函数,如下所示:require('co-mocha'); var expect = require('chai').expect; var helloGenerator = require('../src/helloGenerator'); describe('Hello Generator', function() { it('should yield to the function and return hello', function *() { var ans = yield helloGenerator(); expect(ans).to.equal('hello generator'); }); }); -
为了运行此代码,您需要安装 node 0.11 版本,并在运行 Mocha 时使用
--harmony-generators标志:./node_modules/mocha/bin/mocha --harmony-generators -
如果一切顺利,恭喜您,您刚刚编写了您的第一个生成器函数!现在让我们更深入地探索生成器函数的执行流程。
注意
注意
yield关键字的神奇用法。yield关键字只能在Generator函数中使用,其工作方式与return类似,期望传递一个值,这个值也可以是一个生成器函数(也接受其他可yield的对象——稍后详细介绍),并将流程传递给该函数。当传递一个
function*时,执行流程将等待该函数返回后才继续向下执行。本质上,它等同于以下回调模式:helloGenerator(function(ans) { expect(ans).to.equal('hello generator'); });更干净,对吧?比较以下代码:
var A = yield foo(); var B = yield bar(A); var C = yield baz(A, B);如果没有生成器函数,会有一个讨厌的回调 hello:
var A, B, C; foo(function(A) { bar(A, function(B) { baz(A, B, function(C) { return C; }); }); });另一个很酷的优势是超级干净的错误处理,我们稍后会详细介绍。
前面的例子不太有趣,因为
helloGenerator()函数本质上是一个同步函数,所以即使我们没有使用生成器函数,它也会以相同的方式工作! -
让我们让它更有趣一些,将
helloGenerator.js改为以下内容:module.exports = function *() { setTimeout(function(){ return 'hello generator'; }, 1000); }等等!你的测试失败了?!这里发生了什么?嗯,
yield应该将流程交给helloGenerator()函数,让它异步运行,并在完成后再继续。然而,ans是未定义的。没有人撒谎。它之所以是未定义的,是因为
generator()函数在调用setTimeout函数后立即返回,该函数被设置为ans。本应从setTimeout函数内部返回的消息被广播到无尽的虚空,从此再也无处可见。注意
在生成器函数方面,有一点需要注意,一旦你使用了生成器函数,你最好坚持使用,不要在调用栈中回退到回调!回想一下,我们提到
yield期望一个生成器函数。setTimeout函数不是一个生成器函数,所以我们怎么办?yield方法也可以接受一个 Promise 或 Thunk(稍后详细介绍)。 -
setTimeout()函数不是一个 Promise,所以我们还有两个选择;我们可以将函数 thunkify,这基本上是将具有回调模式的普通 node 函数转换为一个 Thunk,这样我们就可以向它 yield;或者,我们可以使用 co-sleep,这是一个基本的小型 node 包,它已经为你做了以下事情:module.exports = sleep; function sleep(ms) { return function (cb) { setTimeout(cb, ms); }; } -
我们将在稍后讨论如何实现 thunkify,所以让我们先使用
co-sleep。通常,重用现有资源的一个好方法就是快速在 npm 注册表中搜索。那里有大量的co包!var sleep = require('co-sleep'); module.exports = function *() { yield sleep(1000); return 'hello generator'; } -
现在应该一切正常;在休眠 1 秒后,你的测试应该通过。
-
注意,
co库是 Koa.js 底层的实现,为其提供了基于生成器的控制流特性。如果你想在 Koa.js 之外使用这种流程,可以使用类似以下的方法:var co = require('co'); var sleep = require('co-sleep'); co(function*(){ console.log('1'); yield sleep(10); console.log('3'); }); console.log('2');
中间件哲学
到现在为止,你应该已经熟悉 Express 中的中间件了。我们大量使用它们来简化代码,尤其是在验证和认证方面。在 Express 中,中间件被放置在接收请求的服务器和响应请求的处理程序之间。请求单向流动,直到在 res.send 或等效操作处终止。
在 Koa.js 中,一切都是中间件,包括处理程序本身。实际上,一个 Koa.js 应用程序只是一个对象,它包含一个中间件生成函数的数组!请求在整个中间件堆栈中流动,然后再返回。这最好用一个简单的例子来解释:
var koa = require('koa');
var app = koa();
app.use(function *(next){
var start = new Date();
yield next;
var ms = new Date() - start;
this.set('X-Response-Time', ms + 'ms');
});
app.use(function *(){
this.body = 'Hello World';
});
app.listen(3000);
这里有一个包含两个中间件的 Koa.js 应用程序。第一个中间件为响应添加一个 X-Response-Time 头部,而第二个中间件简单地为每个请求设置响应体为 Hello World。流程如下:
-
请求在端口
3000上到来。 -
第一个中间件接收执行流程。
-
创建一个新的
Date对象并将其分配给start。 -
流量将控制权交给堆栈中的下一个中间件。
-
第二个中间件将
body设置为 Context 中的Hello World。 -
由于没有更多的中间件在堆栈中要
yield,流程返回上游。 -
第一个中间件再次接收执行流程并继续向下。
-
计算响应时间并设置响应头。
-
请求已到达顶部,Context 被返回。
注意
Koa.js 不再使用 req 和 res;它们被封装到一个单一的 Context 中。
要运行此应用程序,我们可以使用以下命令:
node --harmony app.js
Context 与 req,res 的比较
为每个传入的请求创建一个 Koa.js Context。在每个中间件中,你可以使用 this 对象访问 Context。它包括 this.request 和 this.response 中的请求和响应对象,尽管大多数方法和访问器都直接从 Context 中可用。
最重要的属性是 this.body,它设置响应体。当设置响应体时,响应状态自动设置为 200。你可以通过手动设置 this.status 来覆盖它。
另一个非常有用的语法糖是 this.throw,它允许你通过简单地调用 this.throw(400) 来返回错误响应,或者如果你想覆盖标准的 HTTP 错误消息,你可以传递一个带有错误消息的第二个参数。我们将在本章后面讨论 Koa.js 的流畅错误处理。
既然我们已经掌握了基础知识,让我们开始构建一个 Hacker News API 吧!
链接模型
以下代码描述了 src/models/links.js 中的直接链接文档模型:
var mongoose = require('mongoose');
var schema = new mongoose.Schema({
title: { type: String, require: true },
URL: { type: String, require: true },
upvotes: { type: Number, require: true, 'default': 0 },
timestamp: { type: Date, require: true, 'default': Date.now }
});
schema.statics.upvote = function *(linkId) {
return yield this.findByIdAndUpdate(linkId, {
$inc: {
upvotes: 1
}
}).exec();
};
var Links = mongoose.model('links', schema);
module.exports = Links;
注意,这基本上与你在 Express 中定义模型的方式相同,只有一个例外:upvotes 静态方法。由于 findByIdAndUpdate 是一个异步 I/O 操作,我们需要确保我们 yield 到它,以确保在继续执行之前等待此操作完成。
之前我们提到,不仅生成器函数可以被传递,它还接受 Promises,这很棒,因为它们非常普遍。例如,使用 Mongoose,我们可以通过调用 exec() 方法将 Mongoose 查询实例转换为 Promises。
链接路由
在放置好链接模型后,让我们在 src/routes/links.js 中设置一些路由:
var model = require('../models/links');
module.exports = function(app) {
app.get('/links', function *(next) {
var links = yield model.find({}).sort({upvotes: 'desc'}).exec();
this.body = links;
});
app.post('/links', function *(next) {
var link = yield model.create({
title: this.request.body.title,
URL: this.request.body.URL
});
this.body = link;
});
app.delete('/links/:id', function *(next) {
var link = yield model.remove({ _id: this.params.id }).exec();
this.body = link;
});
app.put('/links/:id/upvote', function *(next) {
var link = yield model.upvote(this.params.id);
this.body = link;
});
};
这应该开始看起来熟悉了。我们不再使用在 Express 中我们习惯的带有 (req, res) 签名的函数处理器,而是简单地使用中间件生成函数,并在 this.body 中设置响应体。
整合在一起
现在我们已经定义了我们的模型和路由,请执行以下步骤:
-
让我们在 Koa.js 应用程序中
src/app.js中将其整合起来:var koa = require('koa'), app = koa(), bodyParser = require('koa-body-parser'), router = require('koa-router'); // Connect to DB require('./db'); app.use(bodyParser()); app.use(router(app)); require('./routes/links')(app); module.exports = app;注意
注意,我们使用
koa-body-parser来解析请求体this.request.body和koa-router,它允许您定义类似 Express 风格的路由,就像您之前看到的那些。 -
接下来,我们连接到数据库,这与前面的章节没有不同,所以我们将在这里省略代码。
-
最后,我们定义 Koa 应用程序,挂载中间件,并加载路由。然后,在根目录中,我们有
/app.js,如下所示:var app = require('./src/app.js'); app.listen(3000); console.log('Koa app listening on port 3000');
这只是加载应用程序并启动一个监听端口 3000 的 HTTP 服务器。现在要启动服务器,请确保您使用 --harmony-generators 标志。您现在应该有一个可以驱动类似 Hacker News 网站的 Koa API 了!
验证和错误处理
错误处理是 Koa.js 的一个强项。使用生成器函数,我们不需要在回调的每一层处理错误处理,避免了 Node.js 流行起来的 (err, res) 签名回调的使用。我们甚至不需要使用 Promises 的 .error 或 .catch 方法。我们可以使用 JavaScript 自带的 try/catch。
这意味着我们现在可以有以下集中的错误处理中间件:
var logger = console;
module.exports = function *(next) {
try {
yield next;
} catch (err) {
this.status = err.status || 500;
this.body = err.message;
this.app.emit('error', err, this);
}
};
当我们将这个中间件作为 Koa 栈中的第一个中间件之一包含时,它将基本上将整个栈(向下传递)包裹在一个巨大的 try/catch 子句中。现在我们不需要担心异常被抛入空中。事实上,现在您被鼓励抛出常见的 JavaScript 错误,因为您知道这个中间件会优雅地为您解包它,并将其呈现给客户端。
然而,这并不总是您想要的。例如,如果您尝试对一个不是有效 BSON 格式的 ID 进行点赞,Mongoose 将会抛出带有消息 Cast to ObjectId failed for value xxx at path _id 的 CastError。虽然对您来说很有信息量,但对客户端来说却相当糟糕。所以,您可以这样通过返回一个带有良好、清晰信息的 400 错误来覆盖这个错误:
app.put('/links/:id/upvote', function *(next) {
var link;
try {
link = yield model.upvote(this.params.id);
} catch (err) {
if (err.name === 'CastError') {
this.throw(404, 'link can not be found');
}
}
// Check that a link document is returned
this.assert(link, 404, 'link not found');
this.body = link;
});
我们基本上在错误发生的地方捕获它,而不是让它一直冒泡到错误处理器。虽然我们可以抛出一个带有 status 和 message 字段设置的 JavaScript 错误对象,将其传递给错误处理器中间件,但我们也可以直接使用上下文对象的 this.throw 辅助函数来处理它。
现在如果你传递了一个有效的 BSON ID,但链接不存在,Mongoose 不会抛出错误。因此,你仍然需要检查 link 的值是否不是 undefined。这里又是上下文对象的一个漂亮的辅助函数:this.assert。它基本上断言一个条件是否满足,如果不满足,它将返回一个带有消息 link not found 的 400 错误,该消息作为第二个和第三个参数传递。
这里还有一些对链接提交的验证:
app.post('/links', function *(next) {
this.assert(typeof this.request.body.title === 'string', 400, 'title is required');
this.assert(this.request.body.title.length > 0, 400, 'title is required');
this.assert(utils.isValidURL(this.request.body.URL), 400, 'URL is invalid');
// If the above assertion fails, the following code won't be executed.
var link = yield model.create({
title: this.request.body.title,
URL: this.request.body.URL
});
this.body = link;
});
我们确保传递了一个标题以及一个有效的 URL,为此我们使用了以下正则表达式工具:
module.exports = {
isValidURL: function(url) {
return /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/;
}
};
现在仍然有方法可以将验证检查重构为模块化中间件;类似于我们在 第三章 中所做的那样,多人游戏 API - Connect 这留作读者的练习。
更新路由
一个 CRUD API 如果没有更新路由是不完整的!如果你是 Hacker News 的常客,你会知道提交的标题可能会改变(但 URL 不会变)。这个路由应该是直接的,但有一个注意事项!是的,你可以使用 findByIdAndUpdate,这是 upvote 使用的,但如果你想使用 Mongoose 的实例方法 .save() 呢?
嗯,它不返回 Promise,所以我们不能 yield 到它。实际上,在撰写本文时,关于这一点仍然有一个开放的问题。使用 save(),我们只能使用传统的回调模式。然而,记住规则——不要将生成器函数与回调混合!
那么,接下来该做什么呢?嗯,对于某些节点模块只以回调格式提供的情况将相当普遍。虽然大多数常见模块都已转换为 Koa 版本,但你仍然可以使用 Express 包;你只需要将它们 thunkify。实际上,你可以将任何回调风格的函数转换为 thunk。
npm install --save thunkify
现在来看看如何将接受回调的函数转换为可 yield 的 thunk:
var thunk = require('thunkify');
...
// Thunkify save method
Links.prototype.saveThunk = thunk(Links.prototype.save);
将前面的代码添加到 model/links.js 中后,我们可以在更新路由中执行以下操作:
app.put('/links/:id', function *(next) {
this.assert((this.request.body.title || '').length > 0, 400, 'title is required');
var link;
try {
link = yield model.findById(this.params.id).exec();
} catch (err) {
if (err.name === 'CastError') {
this.throw(400, 'invalid link id');
}
}
// Check that a link document is returned
this.assert(link, 400, 'link not found');
link.title = this.request.body.title;
link = yield link.saveThunk()[0];
this.body = link;
});
注意底部附近对 saveThunk() 的使用。它基本上是原始 save() 方法的 thunkified 版本。这意味着原本作为回调的第一个参数传递的错误现在被抛出为一个 Error。我们可以承担不将其包裹在 try/catch 块中的风险,因为错误处理中间件会捕获它并抛出一个 500 错误,这在这种情况下是合适的。
此外,请注意 thunk 返回一个数组。这是因为原始回调的参数数量为3。第一个参数是错误,第二个参数是新的文档,第三个参数是受影响的文档数量。thunk 返回的数组包含后两个值。如果回调的参数数量是2,它将只返回值;这是一个需要注意的点。
让我们进行一些测试
在本章中,我们省略了纪律性的 TDD 方法,因为在前面的章节中已经多次介绍过。然而,在 Koa.js 中,测试略有不同,所以让我们突出一些这些差异。
我们仍然可以使用 supertest 以我们之前干净的方式使用,但需要稍作调整,如下所示:
var app = require('../src/app').callback();
我们需要调用.callback()方法来返回一个对象,我们可以将其传递给 supertest。实际上,返回的对象甚至可以挂载在 Express 应用之上。
测试提交链接的路由相当直接:
var app = require('../src/app').callback(),
Links = require('../src/models/links');
describe('Submit a link', function() {
before(function(done) {
Links.remove({}, function(err) {
done();
});
});
it('should successfully submit a link', function (done) {
request(app).post('/links')
.send({title: 'google', URL: 'http://google.com'})
.expect(200, done);
});
在这个测试套件的开始,我们在数据库中清空集合,并通过 POST 请求提交一个链接。这里没有什么特别的;请注意,我们使用 Mocha 的默认回调函数处理异步请求,而不是co-mocha。
让我们提交更多链接,并检查它们是否确实存储在数据库中:
it('should successfully submit another link', function (done) {
request(app).post('/links')
.send({title: 'Axiom Zen', URL: 'http://axiomzen.co'})
.expect(200, done);
});
it('should successfully submit a third link', function (done) {
request(app).post('/links')
.send({title: 'Hacker News', URL: 'http://news.ycombinator.com'})
.expect(200, done);
});
// To be used in next test
var linkIDs = [];
it('should list all links', function (done) {
request(app).get('/links')
.expect(200)
.end(function(err, res) {
var body = res.body;
expect(body).to.have.length(3);
// Store Link ids for next test
for(var i = 0; i < body.length; i++) {
linkIDs.push(body[i]._id);
}
done();
});
});
注意,我们将链接 ID 存储在一个数组中,以便在下一个测试用例中演示 Koa.js 的最终、最酷的附加功能——开箱即用的并行异步请求!
并行请求
Hacker News 的后端应该能够处理竞争条件,也就是说,它应该能够处理数百个并发的upvote请求而不会丢失数据(回忆第四章,关于竞争条件的MMO Word Game)。因此,让我们编写一个模拟并行请求的测试用例。
传统上,你可能会立即想到使用功能强大且流行的async库,它提供了许多非常有用的工具来处理复杂的异步执行流程。async提供的一个最有用的工具是async.parallel,你可以用它来并行执行异步请求。它曾经是并行请求的首选解决方案,但现在 Koa 提供了开箱即用的功能,并且语法更加简洁!
请记住,co实际上是赋予 Koa 生成器功能力量的东西,因此请参考co项目的 readme 页面,了解更多它提供的所有模式。
到目前为止,我们已经产出到了生成器函数、Promises 和 thunks。然而,这还不是全部。你还可以产出一个前一个数组的数组,这将并行执行它们!下面是如何做的:
// Add to top of file
require('co-mocha');
var corequest = require('co-supertest');
…
it('should upvote all links in parallel', function *() {
var res = yield linkIDs.map(function(id) {
return corequest(app)
.put('/links/' + id + '/upvote')
.end()
});
;
// Assert that all Links have been upvoted
for(var i = 0; i < res.length; i++) {
expect(res[i].body.upvotes).to.equal(1);
}
});
首先,注意我们使用了一个生成器函数,所以请确保你在测试文件顶部有require(co-mocha)。
其次,supertest不返回一个 thunk 或 promise,我们可以从中产出,因此我们需要为这个测试用例使用co-supertest:
npm install co-supertest --save-dev
第三,我们构建一个稍后要执行的请求数组。我们基本上是将 thunks 推入一个数组;它们也可以是承诺。现在当我们产生这个数组时,它将并行执行所有请求,并返回所有响应对象的数组!
如果你习惯了使用 async.parallel 来做这些事情,这会让你感到非常震撼,对吧?
渲染 HTML 页面
到目前为止,我们有一个简单的 Koa API,它已经对所有基本功能进行了相当充分的测试。现在让我们在上面添加一个简单的视图层,以展示如何从 Koa 应用程序中提供静态文件。所以如果应用程序收到浏览器对 HTML 内容的请求,我们将提供一个功能性的网页,我们可以看到提交的链接,提交一个链接,以及点赞一个链接。
让我们在这里暂停一下,快速分享一个真实开发者的生活趣事来实现前面的功能。模块化的趋势是开源社区的一个强大动力。现代开发者可以访问大量经过良好测试的模块。通常,开发者的主要工作只是将几个这样的模块组合成一个应用程序。我们从以往的经验、书籍、新闻网站、社交媒体等地方了解到这些模块。那么我们如何选择合适的工具而不是重新发明轮子呢?
总是建议先进行简单的搜索,看看是否已经有现成的模块可用。在这种情况下,我们感兴趣的是使用 Koa.js 渲染视图,所以让我们在 www.npmjs.com 上尝试搜索词 koa-render。出现了两个流行的包,看起来非常适合我们的需求,如下面的截图所示:

koa-views 是 Koa 的模板渲染中间件,支持许多模板引擎。听起来很有希望!koa-render 为 Koa 添加了一个 render() 方法,允许你渲染几乎任何模板引擎。也不算差。如下面的截图所示:

我们可以查看的一些指导我们选择的事项之一是下载量;这两个包都有相当多的下载量,这表明它们有一定的可信度。koa-views 每个月的下载量是 koa-render 的约 5 倍。虽然这些徽章只是小细节,但它确实表明作者非常关心,并且很可能支持它。项目 GitHub 页面上的最近提交次数也是一个很好的指标,包括已解决的 issue 数量等。
在撰写本文时,这两个项目的 GitHub 链接都重定向到 koa-views,这出人意料,但对我们来说是个好消息!查看 koa-render 的作者 GitHub 账户,我们找不到这个项目了,所以可以安全地假设它已经被废弃;避免使用它!当你有机会时,尽量避免使用不可维护的包,因为考虑到 Node.js(和 io.js)是快速发展的生态系统,这可能会构成威胁。
回到渲染 HTML 页面,与 Express 不同,Koa 对视图的渲染没有预设的意见。然而,它确实为我们提供了一些内容协商的机制,其中一些我们可以用来增强和重用我们为 API 已经拥有的路由。让我们看看我们的 /links 处理器将是什么样子:
app.get('/links', function *(next) {
var links = yield model.find({}).sort({upvotes: 'desc'}).exec();
if( this.accepts('text/html') ){
yield this.render('index', {links: links});
} else {
this.body = links;
}
我们的使用案例相当简单;我们要么提供 JSON 或 HTML。当请求头 accepts 设置为 text/html,这是浏览器自动设置的,我们将渲染 HTML。为了使动态 jade 视图渲染正常工作,我们必须在 app.js 中在路由中间件之前某处包含 koa-views 中间件:
var views = require('koa-views');
...
app.use(views('./views', {default: 'jade'}));
中间件指向一个包含模板的文件夹,其相对路径如下。目前,我们只需要一个单独的模板 views/index.jade:
doctype html
html(lang="en")
head
title Koa News
body
h1 Koa News
div
each link in links
.link-row
a(href='#', onclick="upvote('#{link._id}', $(this))") ^
span
a(href=link.URL)= link.title
.count= link.upvotes
| votes
h2 Submit your link:
form(action='/links', method='post')
label Title:
input(name='title', placeholder="Title")
br
label URL:
input(name='URL', placeholder="https://")
br
br
button.submit-btn Submit
script(src="img/jquery-2.1.3.min.js")
script.
var upvote = function(id, elem) {
$.ajax({url:'/links/'+id+'/upvote', type:'put' })
.done(function(data) {
elem.siblings('.count').text(data.upvotes + ' votes');
})
}
这是一个与本书中之前展示的类似的 jade 文件。它遍历控制器中加载的每个链接,控制器有一个单动作用于点赞。链接按投票数降序显示,这仅在页面重新加载时发生。还有一个简单的表单,允许用户提交新的链接。
我们选择从 CDN 加载 jQuery,只是为了简化 PUT 请求点赞的过程。请注意,除了使这个例子易于理解之外,我们强烈不建议使用内联 JavaScript 以及使用 onclick 元素添加点击事件。
现在如果您已经运行了您的应用程序,并且您访问 localhost:3000/links,这里的结果如下:

所以,从功能角度来看,这是一个起点!如果我们想添加更多的前端 JavaScript 和 CSS 样式,这显然还不够好;我们仍然需要能够提供静态文件。
服务器静态资源
虽然通常您会被鼓励为您的资源创建一个单独的服务器,但让我们保持简单,直接进入目标。我们想要从某个文件夹向某个基本路径提供任何文件。为此,我们需要两个小的中间件,分别是 koa-static 和 koa-mount。在 src/app.js 中,我们添加以下内容:
var serve = require('koa-static');
var mount = require('koa-mount');
// ..
app.use(mount('/public', serve('./public') ));
函数 mount() 将为每个后续的中间件命名空间请求,在这个特定的情况下,它是与 serve 结合使用的,这将服务于 public/ 目录内的任何文件。如果我们决定不对任何特定的 URL 进行挂载,文件服务仍然可以工作;只是它不会有漂亮的命名空间。
现在您只需要在根目录中创建一个 public/ 目录,并包含 filepublic/main.css 文件,它将能够提供样式表。
此方法允许提供您期望的所有静态文件;CSS、JavaScript、图像,甚至是视频。
为了更进一步,有许多前端资源构建工具和最佳实践,包括使用 Grunt、Gulp、Browserify、SASS、CoffeeScript 以及许多其他工具来设置资源管道。更不用说前端框架,如 Angular、Ember、React 等。这只是开始。
希望您喜欢 Koa.js 的介绍!
摘要
我们构建了一个 API,您现在可以使用它来托管自己的 Hacker News of X!显然,我们仍然缺少排序和衰减算法,以及评论功能,但既然您已经走到这一步,这应该对您来说是一个简单的练习。
这章的目的实际上是为了让您领略 Koa.js 的便捷特性,并展示生成器函数模式的使用,该模式将在 ECMAScript 6 中提供。如果您喜欢走在技术前沿,并且喜欢生成器语法,那么这绝对是一个比 Express.js 更好的替代方案。
附录 A. 四子棋 – 游戏逻辑
在第三章中,我们构建了一个用于四子棋游戏的多玩家游戏 API,其中我们专注于创建游戏、加入游戏和玩游戏的一般机制。本附录展示了我们在第三章中省略的伴随游戏逻辑,即多玩家游戏 API正文部分。
src/lib/connect4.js
/*
Connect 4 Game logic
Written for Blueprints: Express.js, Chapter 3
*/
var MIN_ROWS = 6,
MIN_COLUMNS = 7,
players = ['x','o'];
// Initializes and returns the board as a 2D array.
// Arguments accepted are int rows, int columns,
// Default values: rows = 6, columns = 7
exports.initializeBoard = function initializeBoard(rows, columns){
var board = [];
rows = rows || MIN_ROWS;
columns = columns || MIN_COLUMNS;
// Default values is minimum size of the game
if (rows < MIN_ROWS) {
rows = MIN_ROWS;
}
if (columns < MIN_COLUMNS) {
columns = MIN_COLUMNS;
}
// Generate board
for (var i = 0; i < rows; i++){
var row = [];
for (var j = 0; j < columns; j++){
row.push(' ');
}
board.push(row);
}
return board;
};
// Used to draw the board to console, mainly for debugging
exports.drawBoard = function drawBoard(board){
var numCols = board[0].length,
numRows = board.length;
consolePrint(' ');
for (var i = 1; i <= numCols; i++){
consolePrint(i+'');
consolePrint(' ');
}
consolePrint('\n');
for (var j = 0; j < numCols*2+1; j++){
consolePrint('-');
}
consolePrint('\n');
for (i = 0; i < numRows; i++){
consolePrint('|');
for (j = 0; j < numCols; j++){
consolePrint(board[i][j]+'');
consolePrint('|');
}
consolePrint('\n');
for (j = 0; j < numCols*2+1; j++){
consolePrint('-');
}
consolePrint('\n');
}
};
// Make a move for the specified player, at the indicated column for this board
// Player should be the player number, 1 or 2
exports.makeMove = function makeMove(player, column, board){
if (player !== 1 && player !== 2) {
return false;
}
var p = players[player-1];
for (var i = board.length-1; i >= 0; i--){
if (board[i][column-1] === ' '){
board[i][column-1] = p;
return board;
}
}
return false;
}
// Check for victory on behalf of the player on this board, starting at location (row, column)
// Player should be the player number, 1 or 2
exports.checkForVictory = function checkForVictory(player, lastMoveColumn, board){
if (player !== 1 && player !== 2) {
return false;
}
var p = players[player-1],
directions = [[1,0],[1,1],[0,1],[1,-1]],
rows = board.length,
columns = board[0].length,
lastMoveRow;
lastMoveColumn--;
// Get the lastMoveRow based on the lastMoveColumn
for (var r = 0; r < rows; r++) {
if(board[r][lastMoveColumn] !== ' ') {
lastMoveRow = r;
break;
}
}
for (var i = 0; i<directions.length; i++){
var matches = 0;
// Check in the 'positive' direction
for (var j = 1; j < Math.max(rows,columns); j++){
if (board[lastMoveRow + j*directions[i][1]] && p === board[lastMoveRow + j*directions[i][1]][lastMoveColumn + j*directions[i][0]]){
matches++;
} else {
break;
}
}
// Check in the 'negative' direction
for (j = 1; j < Math.max(rows,columns); j++){
if (board[lastMoveRow - j*directions[i][1]] && p === board[lastMoveRow - j*directions[i][1]][lastMoveColumn - j*directions[i][0]]){
matches++;
} else {
break;
}
}
// If there are greater than three matches, then that means there are at least 4 in a row
if (matches >= 3){
return true;
}
}
return false;
};
function consolePrint(msg) {
process.stdout.write(msg);
}
And the accompanying unit tests:
var expect = require('chai').expect;
var connect4 = require('../src/lib/connect4');
describe('Connect 4 Game Logic | ', function() {
describe('#Create a board ', function() {
var board = connect4.initializeBoard();
it('should return game boards of the defaults length when too small', function(done) {
var board2 = connect4.initializeBoard(3,3),
board3 = connect4.initializeBoard(5),
board4 = connect4.initializeBoard(3,10),
board5 = connect4.initializeBoard(10,3);
// Make sure the board is a 2D array
expect(board2).to.be.an('array');
expect(board2.length).to.equal(board.length);
expect(board2[0].length).to.equal(board[0].length);
for(var i = 0; i < board2.length; i++){
expect(board2[i]).to.be.an('array');
}
// Make sure the board is a 2D array
expect(board3).to.be.an('array');
expect(board3.length).to.equal(board.length);
expect(board3[0].length).to.equal(board[0].length);
for(var i = 0; i < board3.length; i++){
expect(board3[i]).to.be.an('array');
}
// Board initialized with 3 rows, but should default to 6
expect(board4).to.be.an('array');
expect(board4.length).to.equal(board.length);
for(var i = 0; i < board4.length; i++){
expect(board4[i]).to.be.an('array');
}
// Board initialized with 3 columns, but should default to 7
expect(board5).to.be.an('array');
expect(board5[0].length).to.equal(board[0].length);
for(var i = 0; i < board5.length; i++){
expect(board5[i]).to.be.an('array');
}
done();
});
it('should only allow pieces to be placed #row amount of times', function(done) {
board = connect4.initializeBoard();
for (var i = 0; i < board.length; i++) {
board = connect4.makeMove(1, 1, board);
}
// Column should be full
expect(connect4.makeMove(1, 1, board)).to.be.an('boolean').and.equal(false);
// Out of bounds
expect(connect4.makeMove(1, 0, board)).to.be.an('boolean').and.equal(false);
expect(connect4.makeMove(1, board[0].length+1, board)).to.be.an('boolean').and.equal(false);
done();
});
it('should return victory if there are 4 in a row', function(done) {
// Vertical Win
board = connect4.initializeBoard();
for (var i = 0; i < 3; i++) {
board = connect4.makeMove(1, 1, board);
expect(connect4.checkForVictory(1, 1, board)).to.equal(false);
}
board = connect4.makeMove(1, 1, board);
expect(connect4.checkForVictory(1, 1, board)).to.equal(true);
// Horizontal Win
board = connect4.initializeBoard();
for (var i = 1; i < 4; i++) {
board = connect4.makeMove(1, i, board);
expect(connect4.checkForVictory(1, 1, board)).to.equal(false);
}
board = connect4.makeMove(1, 4, board);
expect(connect4.checkForVictory(1, 4, board)).to.equal(true);
// Diagonal Win
board = connect4.initializeBoard();
for (var i = 1; i < 4; i++) {
for (var j = 1; j <= i; j++){
if (j===i){
board = connect4.makeMove(1, i, board);
} else {
board = connect4.makeMove(2, i, board);
}
expect(connect4.checkForVictory(1, 1, board)).to.equal(false);
}
}
for (var i = 0; i < 3; i++) {
board = connect4.makeMove(2, 4, board);
expect(connect4.checkForVictory(2, 4, board)).to.equal(false);
}
board = connect4.makeMove(1, 4, board);
expect(connect4.checkForVictory(1, 4, board)).to.equal(true);
done();
});
});
});



浙公网安备 33010602011771号