在本教程中,我们将构建一个 RESTful API 。除了 Express.js,我们还将通过 Mongoskin 库使用 MongoDB。我们还将使用 Mocha 和 SuperAgent 来编写功能测试。
本教程将指导您使用 Mocha 和 SuperAgent 库编写测试,然后向您展示如何以测试驱动的开发方式使用测试,利用 Express.js 框架和 MongoDB 的 Mongoskin 库构建 Node.js free-JSON REST API 服务器。
注意为了方便起见,测试和应用文件的完整源代码都在https://github.com/azat-co/rest-api-express中。如果您想跳过教程,只运行代码,可以使用:
$ git clone https://github.com/azat-co/rest-api-express.git$ cd rest-api-express$ npm install$ node express.jsIn a new terminal window, enter:$ ./node_modules/mocha/bin/mocha express.test.jsThe source code might be an enhanced version of the code in this chapter because of the ongoing contributions from readers. I encourage you to submit your own pull request!在这个 REST API 服务器中,我们将执行创建、更新、移除和删除(CRUD)操作,并用app.param()和app.use()方法利用 Express.js 中间件 1 概念。本章分为以下几个主题:
- RESTful API 基础知识:RESTful API 初级读本
- 测试覆盖范围:我们将使用测试驱动开发(TDD)方法,首先编写测试
- 服务器依赖关系:我们将安装所需的模块
- 服务器实现:我们将为 Express.js 应用编写代码
注意在本章中,我们的 REST API 和测试示例使用了无分号的风格。JavaScript 中的分号绝对是可选的,除了两种情况:1)在 for 循环中,2)在以括号开头的表达式/语句之前(例如,立即调用的函数表达式或 IIFE)。使用这种风格给你一个不同的视角。键入更少的分号可以提高速度,而且看起来更好,更一致,因为开发人员往往会时不时地错过分号(完美运行的代码允许这样的草率)。此外,一些程序员发现不带分号的代码可读性更好。
RESTful API 基础
RESTful APIs 之所以流行,是因为分布式系统中的每个事务都需要包含足够的客户端状态信息。从某种意义上说,这个标准是无状态的,因为服务器上没有存储任何关于客户机状态的信息,这使得不同的系统为每个请求提供服务成为可能。
RESTful API 的独特特征(即,如果一个 API 是 RESTful 的,它通常遵循这些原则)如下:
- 它具有更好的可伸缩性支持,因为不同的组件可以独立部署到不同的服务器上。
- 它取代了简单对象访问协议(SOAP ),因为 REST 中的动词和名词结构更简单。
- 它使用 HTTP 方法,比如 GET、POST、DELETE、PUT、OPTIONS 等等。
- 它支持 JSON 以外的格式(尽管 JSON 是最流行的)。与 SOAP(一种协议)不同,REST 方法在选择格式方面非常灵活。例如,替代格式可能是可扩展标记语言(XML)或逗号分隔值(CSV)格式。
表 21-1 概述了一个用于消息收集的简单 CRUD REST API 的例子。
表 21-1 。CRUD REST API 结构示例
|方法
|
统一资源定位器
|
意义
|
| --- | --- | --- |
| 得到 | /messages.json | 以 JSON 格式返回消息列表。 |
| 放 | /messages.json | 更新/替换所有消息,并在 JSON 中返回状态/错误。 |
| 邮政 | /messages.json | 创建新消息并以 JSON 格式返回其 ID。 |
| 得到 | /messages/{id}.json | 以 JSON 格式返回 ID 为{id}的消息。 |
| 放 | /messages/{id}.json | 更新/替换 ID 等于{id}值的消息;如果{id}消息不存在,则创建它。 |
| 删除 | /messages/{id}.json | 删除 ID 为{id}的消息,并以 JSON 格式返回状态/错误。 |
休息不是一个协议;它是一种架构,从某种意义上说,它比协议(如 SOAP)更灵活。因此,如果我们想支持这些格式,REST API URLs 可能看起来像/messages/list.html或/messages/list.xml。
PUT 和 DELETE 是幂等方法,这意味着,如果服务器收到两个或更多类似的请求,最终结果是相同的。POST 不是等幂的,可能会影响状态并导致副作用(例如,创建多个重复记录)。GET 是无效,这意味着多次调用它是安全的,因为结果不会改变。
注你可以在维基百科(http://en.wikipedia.org/wiki/Representational_state_transfer)和 Stefan Tilkov 的 InfoQ 文章《REST 简介》(www.infoq.com/articles/rest-introduction)中找到更多关于 REST 的信息。
正如“简介”一章中提到的,在我们的 REST API 服务器中,我们将执行 CRUD 操作,并通过app.param()和app.use()方法利用 Express.js 中间件概念。因此,我们的应用应该能够使用 JSON 格式处理以下命令(collectionName是集合的名称,通常是复数名词,例如,消息、评论、用户等)。):
- POST
/collections/{collectionName}:请求创建一个对象;应用使用新创建的对象 ID 进行响应。 - GET
/collections/{collectionName}/{id}:用 URL 中的 ID 值请求;应用检索具有该 ID 的对象。 - GET
/collections/{collectionName}/:请求从集合中检索任意项目(items);在我们的例子中,我们有以下查询选项:最多 10 个条目,按 ID 排序。 - PUT
/collections/{collectionName}/{id}:用 ID 请求更新一个对象。 - 删除
/collections/{collectionName}/{id}:ID 为的请求删除一个对象。
因此,这个服务器可以处理任何数量的集合,而不仅仅是单个集合,只需要六个端点(例如messages,如表 21-1 所示)。
测试覆盖率
在我们做任何其他事情之前,让我们编写向我们即将创建的 REST API 服务器发出 HTTP 请求的功能测试。如果您知道如何使用 Mocha,或者只是想直接跳到 Express.js 应用实现,请随意。您也可以使用 CURL 终端命令进行测试。
假设我们已经安装了 Node.js、NPM 和 MongoDB,让我们创建一个新的文件夹(或者如果您编写了测试,使用那个文件夹):
$ mkdir rest-api-express
$ cd rest-api-express我们将使用 Mocha、Expect.js ( https://github.com/Automattic/expect.js)和 SuperAgent ( http://visionmedia.github.io/superagent/)库。要安装它们,从项目文件夹运行这些命令:
$ npm install -g mocha@1.18.2 --save-dev
$ npm install expect.js@0.3.1 --save-dev
$ npm install superagent@0.17.0 --save-dev
提示您可以在全球范围内安装 Mocha,因为它是一个命令行工具,但是在本地安装 Mocha 将使您能够同时使用不同版本的 Mocha——一个项目一个版本。要用本地摩卡运行测试,只需指向./node_modules/mocha/bin/mocha。你可以把它复制到 Makefile 中,如第 22 章中的所述,或者复制到package.json的"scripts": {"test": "..."}中。持续集成(CI)的配置也需要本地 Mocha。
现在让我们创建一个express.test.js文件,它在同一个文件夹中有六个测试套件:
- 创建新对象
- 按 ID 检索对象
- 检索整个收藏
- 按 ID 更新对象
- 通过 ID 检查更新的对象
- 按 ID 删除对象
通过 SuperAgent 的链式函数,HTTP 请求变得轻而易举,我们将把这些函数放在每个测试套件中。
所以,我们从依赖关系开始:
var superagent = require('superagent')
var expect = require('expect.js')接下来,我们编写包装在测试用例中的第一个测试用例(describe及其回调)。这个想法很简单。我们向服务器的本地实例发出一个 HTTP 请求。当我们发送请求时,我们传递一些数据,当然,还有 URL 路径,它随着测试用例的不同而变化。主要操作发生在请求(由 SuperAgent 发出)回调中。在那里,我们放置了多个断言,这是 TDD 的主要部分。严格来说,这个测试套件使用了行为驱动开发(BDD)语言,但是这个差异对于我们的项目来说并不重要。T3】
describe('express rest api server', function(){
var id
it('posts an object', function(done){
superagent.post('http://localhost:3000/collections/test')
.send({ name: 'John'
, email: 'john@rpjs.co'
})
.end(function(e,res){
// console.log(res.body)
expect(e).to.eql(null)
expect(res.body.length).to.eql(1)
expect(res.body[0]._id.length).to.eql(24)
id = res.body[0]._id
done()
})
})您可能已经注意到,我们正在检查以下内容:
- 错误对象应该为空(
eql(null))。 - 响应体数组应该有一项(
to.eql(1))。 - 第一个响应主体项应该具有
_id属性,该属性的长度为 24 个字符(即标准 MongoDBObjectId类型的十六进制字符串表示)。
最后,我们将新创建的对象 ID 保存在id全局变量中,这样我们可以在以后使用它进行检索、更新和删除。说到对象检索,我们将在下一个测试用例中测试它们。注意,superagent方法已经变成了get(),URL 路径包含了对象 ID。您可以“取消注释”console.log来检查完整的 HTTP 响应体:
it('retrieves an object', function(done){
superagent.get('http://localhost:3000/collections/test/'+id)
.end(function(e, res){
// console.log(res.body)
expect(e).to.eql(null)
expect(typeof res.body).to.eql('object')
expect(res.body._id.length).to.eql(24)
expect(res.body._id).to.eql(id)
done()
})
})done() 回调允许我们测试异步代码。如果没有它,Mocha 测试用例会突然结束,远远早于缓慢的服务器有时间响应。
下一个测试用例的断言更有趣一些,因为我们对响应结果使用了map()函数来返回一个 id 数组。在这个数组中,我们用contain()方法找到我们的 ID(保存在id变量中),这是一个比原生indexOf()更优雅的替代方法。它之所以有效,是因为结果(限于 10 条记录)是按 id 排序的,还因为我们的对象是刚刚创建的。
it('retrieves a collection', function(done){
superagent.get('http://localhost:3000/collections/test')
.end(function(e, res){
// console.log(res.body)
expect(e).to.eql(null)
expect(res.body.length).to.be.above(0)
expect(res.body.map(function (item){return item._id})).to.contain(id)
done()
})
})当需要更新对象时,我们实际上需要发送一些数据。我们通过将对象传递给 SuperAgent 的函数来实现这一点。然后,我们断言该操作在(msg=success)完成:
it('updates an object', function(done){
superagent.put('http://localhost:3000/collections/test/'+id)
.send({name: 'Peter'
, email: 'peter@yahoo.com'})
.end(function(e, res){
// console.log(res.body)
expect(e).to.eql(null)
expect(typeof res.body).to.eql('object')
expect(res.body.msg).to.eql('success')
done()
})
})最后两个测试用例断言检索更新的对象及其删除,使用的方法与我们以前使用的方法类似。下面是rest-api-express/express.test.js文件的完整源代码:
var superagent = require('superagent')
var expect = require('expect.js')
describe('express rest api server', function(){
var id
it('posts an object', function(done){
superagent.post('http://localhost:3000/collections/test')
.send({ name: 'John'
, email: 'john@rpjs.co'
})
.end(function(e,res){
// console.log(res.body)
expect(e).to.eql(null)
expect(res.body.length).to.eql(1)
expect(res.body[0]._id.length).to.eql(24)
id = res.body[0]._id
done()
})
})
it('retrieves an object', function(done){
superagent.get('http://localhost:3000/collections/test/'+id)
.end(function(e, res){
// console.log(res.body)
expect(e).to.eql(null)
expect(typeof res.body).to.eql('object')
expect(res.body._id.length).to.eql(24)
expect(res.body._id).to.eql(id)
done()
})
})
it('retrieves a collection', function(done){
superagent.get('http://localhost:3000/collections/test')
.end(function(e, res){
// console.log(res.body)
expect(e).to.eql(null)
expect(res.body.length).to.be.above(0)
expect(res.body.map(function (item){return item._id})).to.contain(id)
done()
})
})
it('updates an object', function(done){
superagent.put('http://localhost:3000/collections/test/'+id)
.send({name: 'Peter'
, email: 'peter@yahoo.com'})
.end(function(e, res){
// console.log(res.body)
expect(e).to.eql(null)
expect(typeof res.body).to.eql('object')
expect(res.body.msg).to.eql('success')
done()
})
})
it('checks an updated object', function(done){
superagent.get('http://localhost:3000/collections/test/'+id)
.end(function(e, res){
// console.log(res.body)
expect(e).to.eql(null)
expect(typeof res.body).to.eql('object')
expect(res.body._id.length).to.eql(24)
expect(res.body._id).to.eql(id)
expect(res.body.name).to.eql('Peter')
done()
})
})
it('removes an object', function(done){
superagent.del('http://localhost:3000/collections/test/'+id)
.end(function(e, res){
// console.log(res.body)
expect(e).to.eql(null)
expect(typeof res.body).to.eql('object')
expect(res.body.msg).to.eql('success')
done()
})
})
})为了运行测试,我们可以使用$ mocha express.test.js命令。现在,测试应该会失败,因为我们还没有实现服务器!
对于那些需要多个版本的 Mocha 的人来说,另一个更好的选择是使用本地 Mocha 二进制文件运行测试:
$ ./node_modules/mocha/bin/mocha express.test.js当然,这是假设您已经在本地将 Mocha 安装到了node_modules中。
注意默认情况下,Mocha 不使用任何记者,结果输出乏善可陈。要接收更多的解释性日志,请提供-R <name>选项(例如$ mocha test -R spec或$ mocha test -R list)。
属国
和上一篇教程一样(第 20 章),我们将使用 Mongoskin ,一个 MongoDB 库,它是 Node.js. 欲了解更多信息,请查看https://github.com/kissjs/node-mongoskin#comparation。
Express.js 是核心 Node.js HTTP 模块对象(http://nodejs.org/api/http.html)的包装器。Express.js 框架构建在 Connect 中间件(https://github.com/senchalabs/connect)之上,提供了极大的便利。有些人把这个框架比作 Ruby 的 Sinatra,因为它是非自以为是和可配置的。
如果您在上一节中创建了一个rest-api-express文件夹,只需运行这些命令来为应用安装模块:
$ npm install express@4.8.1 --save
$ npm install mongoskin@1.4.1 --save最终的package.json文件可能如下所示:
{
"name": "rest-api-express",
"version": "0.0.4",
"description": "",
"main": "express.js",
"scripts": {
"start": "node express.js",
"test": "mocha express.test.js"
},
"repository": {
"type": "git",
"url": "https://github.com/azat-co/rest-api-express.git"
},
"author": "Azat Mardan",
"license": "BSD-2-Clause",
"bugs": {
"url": "https://github.com/azat-co/rest-api-express/issues"
},
"dependencies": {
"body-parser": "1.9.2",
"express": "4.10.1",
"mongoskin": "1.4.4",
"morgan": "1.5.0" },
"devDependencies": {
"expect.js": "0.3.1",
"mocha": "2.0.1",
"superagent": "0.20.0" }
}服务器实现
要实现服务器,我们首先需要定义我们的依赖关系:
var express = require('express'),
mongoskin = require('mongoskin'),
bodyParser = require('body-parser')
logger = require('morgan')在 3.x 版之后,Express.js 简化了其应用实例的实例化,因此这一行为我们提供了一个服务器对象:
var app = express()为了从请求体中提取参数,我们将使用body-parser中间件。(如何使用中间件在第 4 章的中讨论过。)以下是 JSON 和 URL 编码函数的语句:
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({extended: true}))morgan ( logger)中间件允许我们查看传入的请求:
app.use(logger('dev'))中间件(在此为 3 等形式 4 )是 Express.js 和 Connect 中一种强大便捷的模式,用于组织和重用代码。
与节省我们编写额外代码(用于解析 HTTP 请求的主体对象)的bodyParser()方法一样, Mongoskin 使连接到 MongoDB 数据库成为可能,与原生 MongoDB 驱动程序代码相比,只需一行代码:
var db = mongoskin.db('@localhost:27017/test', {safe:true});
注意如果您希望连接到一个远程数据库,比如 MongoHQ ( https://www.mongohq.com/home),用您的用户名、密码、主机和端口值替换该字符串。以下是 URI 字符串的格式:
mongodb://[username:password@] host1[:port1][,host2[:port2],... [,hostN[:portN]]] [/[database][?options]]方法是另一个 Express.js 中间件。它基本上是说“每当请求处理程序的 URL 模式中有这个值时,就做一些事情。”在我们的例子中,当一个请求模式包含一个以冒号为前缀的字符串collectionName(您将在后面的 routes 中看到它):时,我们选择一个特定的集合
app.param('collectionName', function(req, res, next, collectionName){
req.collection = db.collection(collectionName)
return next()
})为了方便用户,让我们在根路由中包含一条消息:
app.get('/', function(req, res, next) {
res.send('please select a collection, e.g., /collections/messages')
})现在真正的工作开始了。下面是我们检索一个条目列表的方法,这个列表按照_id (sort: {'_id':-1})排序,并且限制为十个(limit: 10):
app.get('/collections/:collectionName', function(req, res, next) {
req.collection.find({},{
limit: 10, sort: {'_id': -1}
}).toArray(function(e, results){
if (e) return next(e)
res.send(results)
})
})您是否注意到 URL 模式参数中有一个:collectionName字符串?这个中间件和之前的app.param()中间件为我们提供了指向数据库中指定集合的req.collection对象。
创建对象的端点稍微容易理解,因为我们只是将整个有效负载传递给 MongoDB 方法(也称为 free-JSON REST API):
app.post('/collections/:collectionName', function(req, res, next) {
req.collection.insert(req.body, {}, function(e, results){
if (e) return next(e)
res.send(results)
})
})单一对象检索函数(例如,findById())比find()更快,但是它们使用不同的接口。它们直接返回一个对象,而不是一个光标——请注意!ID 来自 URL 路径的:id部分,带有req.params.id Express.js magic:
app.get('/collections/:collectionName/:id', function(req, res, next) {
req.collection.findById(req.params.id, function(e, result){
if (e) return next(e)
res.send(result)
})
})PUT 请求处理程序变得更加有趣,因为updateById() (as update())不返回增强的对象;相反,它返回受影响对象的计数。
另外,{$set: req.body}是一个特殊的 MongoDB 操作符(操作符往往以美元符号开始),它设置值。在这种情况下,我们更新发送给我们的任何机体数据。这被称为 free-JSON API 方法。这对于原型开发来说很棒,但是在大多数系统中,你需要执行验证(你可以使用express-validator中间件,在第 15 章中有所介绍)。
第二个{safe: true, multi: false}参数是一个带有选项的对象,告诉 MongoDB 在运行回调函数之前等待执行,并且只处理一个(第一个)项目:
app.put('/collections/:collectionName/:id', function(req, res, next) {
req.collection.updateById(req.params.id,
{$set: req.body},
{safe: true, multi: false},
function(e, result){
if (e) return next(e)
res.send((result === 1) ? {msg: 'success'} : {msg: 'error'})
}
)
})最后,下面是删除方法 ,它利用了 Mongoskin 的removeById()方法,在成功的情况下输出一个自定义的 JSON 消息({msg: success}):
app.delete('/collections/:collectionName/:id', function(req, res, next) {
req.collection.removeById(req.params.id, function(e, result){
if (e) return next(e)
res.send((result === 1)?{msg: 'success'} : {msg: 'error'})
})
})
注意app.delete()方法是现已废弃的(但仍在旧项目中使用)app.del()的别名。
在本例中,在端口 3000 上实际启动服务器的最后一行是:
app.listen(3000, function(){
console.log('Express server listening on port 3000')
})以防万一,这里有rest-api-express/express.js文件的完整代码:
var express = require('express'),
mongoskin = require('mongoskin'),
bodyParser = require('body-parser'),
logger = require('morgan')
var app = express()
app.use(bodyParser())
app.use(logger('dev'))
var db = mongoskin.db('mongodb://@localhost:27017/test', {safe:true})
app.param('collectionName', function(req, res, next, collectionName){
req.collection = db.collection(collectionName)
return next()
})
app.get('/', function(req, res, next) {
res.send('please select a collection, e.g., /collections/messages')
})
app.get('/collections/:collectionName', function(req, res, next) {
req.collection.find({} ,{limit: 10, sort: {'_id': -1}}).toArray(function(e, results){
if (e) return next(e)
res.send(results)
})
})
app.post('/collections/:collectionName', function(req, res, next) {
req.collection.insert(req.body, {}, function(e, results){
if (e) return next(e)
res.send(results)
})
})
app.get('/collections/:collectionName/:id', function(req, res, next) {
req.collection.findById(req.params.id, function(e, result){
if (e) return next(e)
res.send(result)
})
})
app.put('/collections/:collectionName/:id', function(req, res, next) {
req.collection.updateById(req.params.id, {$set: req.body}, {safe: true, multi: false}, function(e, result){
if (e) return next(e)
res.send((result === 1) ? {msg:'success'} : {msg: 'error'})
})
})
app.delete('/collections/:collectionName/:id', function(req, res, next) {
req.collection.removeById(req.params.id, function(e, result){
if (e) return next(e)
res.send((result === 1)?{msg: 'success'} : {msg: 'error'})
})
})
app.listen(3000, function(){
console.log('Express server listening on port 3000')
})退出编辑器,在终端中运行以下命令:
$ node express.js在另一个窗口中(不关闭第一个窗口,让服务器运行),输入:
$ mocha express.test.js或者
$ ./node_modules/mocha/bin/mocha express.test.js或者
$ npm testMocha 的终端输出应该如下所示:
......
6 passing (57ms)在服务器终端窗口中,您应该会看到如下内容:
Express server listening on port 3000
POST /collections/test 200 35.242 ms - 73
GET /collections/test/54724135101acb1334635994 200 4.254 ms - 71
GET /collections/test 200 5.181 ms - 108
PUT /collections/test/54724135101acb1334635994 200 4.037 ms - 17
GET /collections/test/54724135101acb1334635994 200 1.638 ms - 75
DELETE /collections/test/54724135101acb1334635994 200 1.382 ms - 17如果你真的不喜欢摩卡和/或 BDD,你可以一直用 CURL。例如,下面是如何发出帖子请求:
$ curl -d "name=peter&email=peter337@rpjs.co" http://localhost:3000/collections/proexpressjs-readers
$ curl http://localhost:3000/collections/proexpressjs-readers在这种情况下,输出是:
[{"name":"peter","email":"peter337@rpjs.co","_id":"541714c23f5b557785700d4c"}]%
...
[{"_id":"541714c23f5b557785700d4c","name":"peter","email":"peter337@rpjs.co"}]%GET 请求也适用于浏览器。例如,您可以前往http://localhost:3000/collections/proexpressjs-readers获取收藏中的项目列表。
在本教程中,我们的测试比应用代码本身还要长,所以放弃测试驱动开发可能很有诱惑力,但是请相信我,在任何严肃的开发项目中,当您正在开发的应用非常复杂时,TDD 的好习惯将会节省您的工作时间。
摘要
当您需要用几行代码构建一个简单的 REST API 服务器时,Express.js 和 Mongoskin 库是很好的资源。稍后,如果您需要扩展这些库,它们还提供了一种配置和组织代码的方法。像 MongoDB 这样的 NoSQL 数据库擅长处理 free-REST API,这意味着你不必定义模式,你可以向它抛出任何数据,它就会被保存。
在下一章中,我们将把 REST API 方法与前端框架 Backbone.js 结合起来,它将从服务器获取数据,编译数据,并在浏览器中呈现 HTML(不像第 20 章中的 Todo 应用,它在服务器上处理模板)。
1T0】
2T0】
3T0】
4T0】