ExpressJS 高级教程(五)

原文:Pro Express.js

协议:CC BY-NC-SA 4.0

二十一、REST API

在本教程中,我们将构建一个 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 中,如第二十二章中的所述,或者复制到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)语言,但是这个差异对于我们的项目来说并不重要。

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 个字符(即标准 MongoDB ObjectId类型的十六进制字符串表示)。

最后,我们将新创建的对象 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)。

属国

和上一篇教程一样(第二十章),我们将使用 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中间件。(如何使用中间件在第四章的中讨论过。)以下是 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中间件,在第十五章中有所介绍)。

第二个{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 test

Mocha 的终端输出应该如下所示:

......
  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(不像第二十章中的 Todo 应用,它在服务器上处理模板)。


1

2

3

4

二十二、HackHall

HackHall app 是一款真正的 MVC 应用。它有 REST API 服务器,前端客户端用 Backbone.js 和下划线编写。出于本章的目的,我们将通过 mongose ORM/ODM(对象-关系映射/对象-文档映射)为后端 REST API 服务器演示如何使用 Express.js 和 MongoDB。此外,该项目直接使用 OAuth,并通过 Passport、sessions 和 Mocha 进行 TDD。它托管在 Heroku 上,正在积极开发中(见附近的注释)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 本章使用的 HackHall 源代码可以在 3.1.0 版本下的公共 GitHub 资源库(https://github.com/azat-co/hackhall)中获得(https://github.com/azat-co/hackhall/releases/tag/v3.1.0https://github.com/azat-co/hackhall/tree/v3.1.0https://github.com/azat-co/hackhall/archive/v3.1.0.zip)。未来的版本可能与本章的例子不同,可能会有更多的特性。

本章结构如下:

  • 什么是 HackHall?
  • 跑步俱乐部
  • 结构
  • Package.json
  • Express.js app
  • 路线
  • 猫鼬模型
  • 摩卡测试

什么是 HackHall?

HackHall 是一个面向在线社区的开源项目。它在http://hackhall.com的实现是一个为黑客、潮人、设计师、企业家和盗版者(开玩笑)策划的社交网络/会员社区和协作工具。HackHall 社区类似于 Reddit、Hacker News 和脸书团体与监管的结合。可以在http://hackhall.com申请成为会员。

HackHall 项目正处于早期阶段,大致处于测试阶段。我们计划在未来扩展代码库,并引入更多的人来分享技能、智慧和编程热情。您可以在http://youtu.be/N1UILNqeW4k观看 HackHall.com 的快速演示视频。

在这一章中,我们将介绍 3.1.0 版本,它有以下特性:

  • 带有oauth模块(https://www.npmjs.org/package/oauth)和 AngelList API ( https://angel.co/api)的 OAuth 1.0
  • 电子邮件和密码验证
  • 密码哈希
  • 猫鼬模型和模式
  • 模块中带有路线的 Express.js 结构
  • JSON REST API
  • Express.js 错误处理
  • 前端客户端 Backbone.js app(关于 Backbone.js 的更多信息,请下载或在线阅读我的快速原型制作 JS 教程,在http://rapidprototypingwithjs.com/ )
  • 工头的.env环境变量
  • 摩卡的 TDD 测试
  • 基本 Makefile 设置
  • SendGrid 电子邮件通知
  • GitHub 登录

跑步大厅

要获得 HackHall 的源代码,您可以导航到hackhall文件夹或从 GitHub 克隆它:

$ git clone https://github.com/azat-co/hackhall.git
$ git checkout v3.1.0
$ npm install

如果你计划测试一个 AngelList,或者 GitHub 集成(可选),那么你应该作为开发者注册他们的 API 密匙。这样做之后,您需要通过环境变量将值传递给应用。HackHall 对这些敏感的 API 键使用 Heroku 和 Foreman ( http://ddollar.github.io/foreman)设置方法(.env文件)。Foreman gem 是一个命令行工具,用于管理基于 Procfile 的应用。Heroku toolbelt 包含它。要在环境变量中存储键,只需像这样添加一个.env文件(用您自己的值替换=后面的值):

ANGELLIST_CLIENT_ID=254C0335-5F9A-4607-87C0
ANGELLIST_CLIENT_SECRET=99F5C1AC-C5F7-44E6-81A1-8DF4FC42B8D9
GITHUB_CLIENT_ID=9F5C1AC-C5F7-44E6
GITHUB_CLIENT_SECRET=9F5C1AC-C5F7-44E69F5C1AC-C5F7-44E6
GITHUB_CLIENT_ID_LOCAL=9F5C1AC-C5F7-44E1
GITHUB_CLIENT_SECRET_LOCAL=9F5C1AC-C5F7-44E69F5C1AC-C5F7-44E6
...

注意等号(=)前后没有空格。

有了.env文件和值之后,使用foremannodemon:

$ foreman run nodemon server

如果您对foreman感到困惑或者不想安装它,那么您可以用您的环境变量创建一个 shell 文件,并用它来启动服务器。

在您创建了一个 AngelList 应用并注册它之后,您可以在https://angel.co/api获得 AngelList API 密钥。同样,对于 GitHub,你需要注册成为一名开发者,才能创建一个应用并获得 API 密钥。SendGrid 通过 Heroku 插件工作,因此您可以从 Heroku web 界面获得用户名和密码。

下面是我的.env寻找 v3.1.0 的样子(键被占位符代替),其中我有两组 GitHub 键,一组用于本地 app,一组用于生产(hackhall.com ) app,因为每一组的回调 URL 都不一样。当你注册应用时,在 GitHub 上设置回调 URL。

ANGELLIST_CLIENT_ID=AAAAAAAAAAAAAA
ANGELLIST_CLIENT_SECRET=AAAAAAAAAAAAAA
GITHUB_CLIENT_ID=AAAAAAAAAAAAAA
GITHUB_CLIENT_SECRET=AAAAAAAAAAAAAA
GITHUB_CLIENT_ID_LOCAL=AAAAAAAAAAAAAA
GITHUB_CLIENT_SECRET_LOCAL=AAAAAAAAAAAAAA
SENDGRID_USERNAME=AAAAAAAAAAAAAA@heroku.com
SENDGRID_PASSWORD=AAAAAAAAAAAAAA
COOKIE_SECRET=AAAAAAAAAAAAAA
SESSION_SECRET=AAAAAAAAAAAAAA
ANGELLIST_CLIENT_ID_LOCAL=AAAAAAAAAAAAAA
ANGELLIST_CLIENT_SECRET_LOCAL= AAAAAAAAAAAAAA
EMAIL=AAAAAAAAAAAAAA

cookie 和会话密码用于加密 Cookie(浏览器)和会话(存储)数据。

将敏感信息放入环境变量允许我将整个 HackHall 源代码公之于众。我还在 Heroku web 界面中为这个应用设置了一个变量(您可以使用 Heroku config [ https://devcenter.heroku.com/articles/config-vars ]将.env同步到云或从云同步,或者使用 web 界面)。这个变量就是NODE_ENV=production。当我需要确定要使用的 GitHub 应用时,我会使用它(本地应用与主要的实时应用)。

如果您还没有 MongoDB,请下载并安装它。数据库和第三方库超出了本书的范围。然而,你可以在网上找到足够的资料(例如,见http://webapplog.com)和之前提到的用 JS 快速成型。在启动应用之前,我建议运行seed-script.js文件或seed.js文件,用信息填充数据库,如下所述。

要通过运行seed-script.js MongoDB 脚本用默认管理员用户播种数据库hackhall,请输入

$ mongo localhost:27017/hackhall seed-script.js

随意修改seed-script.js 到你喜欢的程度(注意这样做会删除所有以前的数据!).例如,使用您的bcryptjs散列密码(跳到种子数据自动散列的seed.js指令)。稍后您将看到一个散列的例子。

首先,我们清理数据库:

db.dropDatabase();

然后,我们用用户信息定义一个对象:

var seedUser ={
  firstName: 'Azat',
  lastName: 'Mardan',
  displayName: 'Azat Mardan',
  password: 'hashed password',
  email: '1@1.com',
  role: 'admin',
  approved: true,
  admin: true
};

最后,使用 MongoDB shell 方法 将对象保存到数据库:

db.users.save(seedUser);

鉴于seed-script.js是一个 MongoDB shell 脚本,seed.js是一个迷你 Node.js 应用,用于播种数据库。您可以使用以下命令运行 Node.js 数据库播种程序:

$ node seed.js

seed.js程序更全面(它有密码哈希!)比 MongoDB shell 脚本seed-script.js。我们从导入模块开始:

var bcrypt = require('bcryptjs');
var async = require('async');
var mongo = require ('mongodb');
var objectId = mongo.ObjectID;

seed-script.js中类似,我们定义用户对象,只是这次密码是明文/未加密的:

seedUsers = [{...},{...}];

数组对象可能看起来像这样(添加你自己的用户对象!):

{
  firstName: "test",
  lastName: "Account",
  displayName: "test Account",
  password: "hashend password",
  email: "1@1.com",
  role: "user",
  admin: false,
  _id: objectId("503cf4730e9f580200000003"),
  photoUrl: "https://s3.amazonaws.com/photos.angel.co/users/68026-medium_jpg?1344297998",
  headline: "Test user 1",
  approved: true
}

这是将散列我们的普通密码的异步函数:

var hashPassword = function (user, callback) {

bcryptjs模块将 salt 存储在哈希密码中,所以不需要单独存储 salt;10是哈希复杂度(越高越好):

  bcrypt.hash(user.password, 10, function(error, hash) {
    if (error) throw error;
    user.password = hash;
    callback(null, user);
  });
};

在这里,我们定义了稍后会用到的变量:

var db;
var invites;
var users;
var posts;

我们用本地驱动程序连接到 MongoDB:

var dbUrl = process.env.MONGOHQ_URL || 'mongodb://@127.0.0.1:27017/hackhall';
mongo.Db.connect(dbUrl, function(error, client){
  if (error) throw error;
    else {
        db=client;

接下来,我们将集合分配给对象并清理所有用户,以防万一:

invites = new mongo.Collection(db, "invites");
users = new mongo.Collection(db, "users");
posts = new mongo.Collection(db, "posts");
invites.remove(function(){});
users.remove(function(){});

如果希望该脚本也删除帖子,可以取消对该行的注释:

// posts.remove();
 invites.insert({code:'smrules'}, function(){});

插入一个虚拟帖子(在此随意发挥创意):

posts.insert({
          title:'test',
          text:'testbody',
          author: {
           name:seedUsers[0].displayName,
           id:seedUsers[0]._id
           }
}, function(){});

我们使用异步函数,因为散列可能会很慢(这是一件好事,因为较慢的散列更难用暴力破解):

    async.map(seedUsers, hashPassword, function(error, result){
      console.log(result);
      seedUsers = result;
      users.insert(seedUsers, function(){});
      db.close();
    });
  }
});

要启动 MongoDB 服务器,打开一个新的终端窗口并运行:

$ mongod

当 MongoDB 在默认端口为 27017 的 localhost 上运行后,返回到项目文件夹并运行foreman(该命令从 Procfile 中读取):

$ foreman start

或者,可以用nodemon(http://nodemon.io;GitHub: https://github.com/remy/nodemon)带有更明确的foreman命令:

$ foreman run nodemon server

如果你打开浏览器到http://localhost:3000,你应该看到一个类似于图 22-1(3 . 1 . 0 版)所示的登录屏幕。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 22-1 。本地运行的 HackHall v3.1.0 登录页面

输入您的用户名和密码(来自您的seed.jsseed-script.js文件)以获得访问权限。使用无哈希(即普通)版本的密码。

认证成功后,用户被重定向到帖子页面,如图图 22-2 (你的数据,比如帖子名称,会有所不同;“测试”帖子是运行 Mocha 测试的副产品)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 22-2 。HackHall 帖子页面

如果你点击一个帖子的“喜欢”按钮,就会出现“你现在喜欢这个帖子了!”应显示消息,该岗位上的类似计数器应增加,如图图 22-3 所示。手表按钮也是如此。作者可以编辑和删除他们自己的帖子。管理员可以编辑和删除任何帖子。有人员和个人资料页面,您将在本章后面看到。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 22-3 。HackHall 发布了一个有赞帖子的页面

现在,您已经看到了 HackHall v3.1.0 在本地机器上开箱后的样子。下面几节将带您了解实现该应用时使用的一些概念和模式。这一章没有前几章详细,因为我假设你已经熟悉了那些章节的主题;重复所有的细节会占用太多的空间,可能会让你感到厌烦。

结构

以下是 HackHall 的结构以及每个文件夹和文件所包含内容的简要描述:

  • /api : App 共享路线
  • /models:猫鼬模型
  • /public:主干 app,静态文件,如前端 JavaScript、CSS、HTML
  • /routes:休息 API 路线
  • /tests:摩卡测试
  • 内部(内部)图书馆
  • .gitignore:应该被git忽略的文件列表
  • Makefile:运行测试的生成文件
  • Heroku 部署所需的 Cedar 堆栈文件
  • package.json : NPM 依赖和 HackHall 元数据
  • readme.md:项目描述
  • server.js:主黑客大厅服务器文件
  • 不想与他人分享或泄露的秘密价值

我的项目文件夹内容如图图 22-4 所示。前端应用是用 Backbone.js 编写的,带有下划线模板引擎(HTML 在客户端呈现),它非常广泛,其覆盖范围超出了本书的范围,因为 Backbone.js 有许多替代方案(Angular.js 是最受欢迎的选择之一)。你可以随时从public文件夹:https://github.com/azat-co/hackhall/tree/v3.1.0/public中查找浏览器 app 的源代码。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 22-4 。HackHall 基本文件夹的内容

Package.json

和往常一样,让我们从package.json文件和依赖项开始。我们在本书之前没有使用的“新”库是passport (OAuth 集成)sendgrid(电子邮件通知)mongoose (MondoDB ORM/ODM)和bcryptjs(密码散列)。其他的都应该是你熟悉的。我们将使用 Express.js 中间件模块和实用程序(asyncmocha)。

这就是package.json的样子(自行决定使用新版本):

{
  "name": "hackhall",
  "version": "3.1.0",
  "private": true,
  "main": "server",
  "scripts": {
    "start": "node server",
    "test": "make test"
  },
  "dependencies": {
    "async": "0.9.0",
    "bcryptjs": "2.0.2",
    "body-parser": "1.6.6",
    "cookie-parser": "1.3.2",
    "csurf": "1.5.0",
    "errorhandler": "1.1.1",
    "express": "4.8.1",
    "express-session": "1.7.6",
    "method-override": "2.1.3",
    "mongodb": "1.4.9",
    "mongoose": "3.8.15",
    "mongoose-findorcreate": "0.1.2",
    "mongoskin": "1.4.4",
    "morgan": "1.2.3",
    "oauth": "0.9.12",
    "passport": "0.2.0",
    "passport-github": "0.1.5",
    "sendgrid": "1.2.0",
    "serve-favicon": "2.1.1"
  },

devDependencies类别是生产中不需要的模块:

  "devDependencies": {
    "mocha": "1.21.4",
    "superagent": "0.18.2"
  },
  "engines": {
    "node": "0.10.x"
  }
}

Express.js App

让我们直接跳到server.js文件,看看它是如何实现的。首先,我们声明依赖关系:

var express = require('express'),
  routes = require('./routes'),
  http = require('http'),
  util = require('util'),
  path = require('path'),
  oauth = require('oauth'),
  querystring = require('querystring');

接下来,我们对 Express.js 中间件模块做同样的事情(不需要单独的var,除了显示模块目的的不同):

var favicon = require('serve-favicon'),
  logger = require('morgan'),
  bodyParser = require('body-parser'),
  methodOverride = require('method-override'),
  cookieParser = require('cookie-parser'),
  session = require('express-session'),
  csrf = require('csurf');

接下来,我们有一个内部电子邮件库,它通过一个 Heroku 附件使用 SendGrid:

var hs = require(path.join(__dirname, 'lib', 'hackhall-sendgrid'));

具有不同字体颜色的日志消息很好,但当然是可选的。我们用lib/colors.js中的转义序列来完成这种着色:

var c = require(path.join(__dirname, 'lib', 'colors'));
require(path.join(__dirname, 'lib', 'env-vars'));

护照(http://passportjs.orghttps://www.npmjs.org/package/passporthttps://github.com/jaredhanson/passport)是给 GitHub OAuth 的。使用passport是比使用oauth:更高级的实现 OAuth 的方法

var GitHubStrategy = require('passport-github').Strategy,
  passport = require('passport');

然后,我们初始化应用并配置中间件。环境变量process.env.PORT由 Heroku 填充,并且在本地设置的情况下,依赖于3000。其余的配置你应该从《??》第四章中熟悉了。

app.set('port', process.env.PORT || 3000 );
app.use(favicon(path.join(__dirname,'public','favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: true}));
app.use(methodOverride());

认证需要传递给cookieParser和会话中间件的值。显然,这些秘密应该是私人的:

app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(session({
  secret: process.env.SESSION_SECRET,
  key: 'sid',
  cookie: {
    secret: true,
    expires: false
  },
  resave: true,
  saveUninitialized: true
}));

这就是我们如何提供前端客户端 Backbone.js 应用和其他静态文件,如 CSS:

app.use(express.static(__dirname + '/public'));

错误处理分为三个函数,其中clientErrorHandler()专用于来自 Backbone.js 应用的 AJAX/XHR 请求(用 JSON 响应)。现在,我们只声明函数。稍后我们将使用app.use()来应用它们。

第一个方法logErrors(),检查err是否是一个字符串,如果是,创建一个Error对象。然后,执行到下一个错误处理程序。

function logErrors(err, req, res, next) {
  if (typeof err === 'string')
    err = new Error (err);
  console.error('logErrors', err.toString());
  next(err);
}

如前所述,clientErrorHandler通过检查req.xhr专用于来自 Backbone.js 应用的 AJAX/XHR 请求(用 JSON 响应),它将发送一个 JSON 消息返回或转到下一个处理程序:

function clientErrorHandler(err, req, res, next) {
  if (req.xhr) {
    console.error('clientErrors response');
    res.status(500).json({ error: err.toString()});
  } else {
    next(err);
  }
}

最后一个错误处理程序errorHandler(),将假设请求不是 AJAX/XHR(否则clientErrorHandler()将会捕获它,但是这个顺序将在后面用app.use()定义),并将发回一个字符串:

function errorHandler(err, req, res, next) {
  console.error('lastErrors response');
  res.status(500).send(err.toString());
}

回想一下,我们使用||确定process.env.PORT并依靠本地设置值3000。我们用 MongoDB 连接字符串做类似的事情。我们从环境变量中提取 Heroku 附件 URI 字符串,或者回退到本地设置:

var dbUrl = process.env.MONGOHQ_URL
  || 'mongodb://@127.0.0.1:27017/hackhall';
var mongoose = require('mongoose');

现在,我们创建一个连接:

var connection = mongoose.createConnection(dbUrl);
connection.on('error', console.error.bind(console,
  'connection error:'));

有时记录连接open事件是个好主意:

connection.once('open', function () {
  console.info('Connected to database')
});

猫鼬模型存放在models文件夹中:

var models = require('./models');

这个中间件将提供对我们路由方法中的两个集合的访问:

function db (req, res, next) {
  req.db = {
    User: connection.model('User', models.User, 'users'),
    Post: connection.model('Post', models.Post, 'posts')
  };
  return next();
}

下面几行只是导入的routes/main.js文件授权函数的新名称:

var checkUser = routes.main.checkUser;
var checkAdmin = routes.main.checkAdmin;
var checkApplicant = routes.main.checkApplicant;

然后,我们转到 AngelList OAuth 路由进行 AngelList 登录。这是一个标准的三足 OAuth 1.0 策略,我们启动 auth ( /auth/angellist),将用户重定向到服务提供商(AngelList),然后等待用户从服务提供商(/auth/angellist):

app.get('/auth/angellist', routes.auth.angelList);
app.get('/auth/angellist/callback',
  routes.auth.angelListCallback,
  routes.auth.angelListLogin,
  db,
  routes.users.findOrAddUser);

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 提示关于 OAuth 和 Node.js OAuth 例子的更多信息,请看我的书用 Node.js 介绍 OAuth(2014),可在https://gumroad.com/l/oauthnode获得。

接下来的几行代码处理 Passport 和 GitHub 登录逻辑。使用 Passport 实现 OAuth 比使用 OAuth 模块需要更少的人工工作。

让我们从app.get('/api/profile')开始,跳到主要的应用途径。Backbone.js 应用使用api/profile,如果用户登录,它将返回一个用户会话。请求通过checkUserdb传输,前者授权,后者填充数据库信息。

*// MAIN*
app.get('/api/profile', checkUser, db, routes.main.profile);
app.delete('/api/profile', checkUser, db, routes.main.delProfile);
app.post('/api/login', db, routes.main.login);
app.post('/api/logout', routes.main.logout);

PostsUsers收藏路线用于操作帖子和用户:

*// POSTS*
app.get('/api/posts', checkUser, db, routes.posts.getPosts);
app.post('/api/posts', checkUser, db, routes.posts.add);
app.get('/api/posts/:id', checkUser, db, routes.posts.getPost);
app.put('/api/posts/:id', checkUser, db, routes.posts.updatePost);
app.delete('/api/posts/:id', checkUser, db, routes.posts.del);

*// USERS*
app.get('/api/users', checkUser, db, routes.users.getUsers);
app.get('/api/users/:id', checkUser, db,routes.users.getUser);
app.post('/api/users', checkAdmin, db, routes.users.add);
app.put('/api/users/:id', checkAdmin, db, routes.users.update);
app.delete('/api/users/:id', checkAdmin, db, routes.users.del);

这些路线适用于尚未获得批准的新成员(即,他们已提交申请):

//APPLICATION
app.post('/api/application', checkAdmin, db, routes.application.add);
app.put('/api/application', checkApplicant, db, routes.application.update);
app.get('/api/application', checkApplicant, db, routes.application.get);

以下是无所不包的路线:

app.get('*', function(req, res){
  res.status(404).send();
});

我们按照我们希望它们被调用的顺序来应用错误处理程序:

app.use(logErrors);
app.use(clientErrorHandler);
app.use(errorHandler);

require.main === module是一个聪明的技巧,用来确定这个文件是作为独立的还是作为导入的模块执行的:

http.createServer(app);
if (require.main === module) {
  app.listen(app.get('port'), function(){

我们显示蓝色日志消息:

    console.info(c.blue + 'Express server listening on port '
      + app.get('port') + c.reset);
  });
}
else {
  console.info(c.blue + 'Running app as a module' + c.reset)
  exports.app = app;
}

为了节省篇幅,我就不列出hackhall/server.js的完整源代码了,不过大家可以在https://github.com/azat-co/hackhall/blob/v3.1.0/server.js查看。

路线

HackHall routes 位于hackhall/routes文件夹中,分为几个模块:

  • hackhall/routes/index.js:文件夹中server.js与其他路径之间的桥梁
  • hackhall/routes/auth.js:处理 OAuth 与 AngelList API“共舞”的路由
  • hackhall/routes/main.js:登录、注销和其他路径
  • hackhall/routes/users.js:与用户休息 API 相关的路线
  • hackhall/routes/application.js:处理成为用户的申请提交的路线
  • hackhall/routes/posts.js:与岗位休息 API 相关的路线

index.js

让我们看看hackhall/routes/index.js,这里我们已经包含了其他模块:

exports.posts = require('./posts');
exports.main = require('./main');
exports.users = require('./users');
exports.application = require('./application');
exports.auth = require('./auth');

auth . js

在这个模块中,我们用 AngelList API 处理 OAuth 舞蹈。为此,我们依赖于https库:

var https = require('https');

AngelList API 客户端 ID 和客户端秘密在https://angel.co/api获得,并存储在环境变量中。我添加了两个应用:一个用于本地开发,另一个用于生产,如图图 22-5 所示。应用会根据环境选择其中之一:

if (process.env.NODE_ENV === 'production') {
  var angelListClientId = process.env.ANGELLIST_CLIENT_ID;
  var angelListClientSecret = process.env.ANGELLIST_CLIENT_SECRET;
} else {
  var angelListClientId = process.env.ANGELLIST_CLIENT_ID_LOCAL;
  var angelListClientSecret = process.env.ANGELLIST_CLIENT_SECRET_LOCAL;
}

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 22-5 。我的 AngelList 应用

exports.angelList()方法将用户重定向到https://angel.co/api网站进行身份验证。当我们导航到/auth/angellist时,这个方法被调用。在https://angel.co/api/oauth/faq的文档中描述了请求的结构。

exports.angelList = function(req, res) {
  res.redirect('https://angel.co/api/oauth/authorize?client_id=' + angelListClientId + '&scope=email&response_type=code');
}

在用户允许我们的应用访问他们的信息后,AngelList 将他们发送回此路由,以允许我们发出新的(HTTPS)请求来检索令牌:

exports.angelListCallback = function(req, res, next) {
  var token;
  var buf = '';
  var data;
  var angelReq = https.request({

hostpath的值是特定于您的服务提供商的,因此在实现 OAuth 时,您需要查阅提供商的文档。这些是 AngelList API 的值:

    host: 'angel.co',
    path: '/api/oauth/token?client_id=' + angelListClientId +
      '&client_secret=' + angelListClientSecret + '&code=' + req.query.code +
      '&grant_type=authorization_code',
    port: 443,
    method: 'POST',
    headers: {
      'content-length': 0
    }

此时,回调应该有带有令牌的响应(或者错误),所以我们解析响应并检查access_token。如果存在,我们在会话中保存令牌,并继续处理/auth/angellist/callback中的下一个中间件,即angelListLogin。首先,让我们在buf中附加一个保存响应的事件监听器:

  }, function(angelRes) {
    angelRes.on('data', function(buffer) {
      buf += buffer;
    });

然后,我们为end事件附加另一个事件监听器:

    angelRes.on('end', function() {

此时的buf对象应该有一个Buffer类型的完整响应体,所以我们需要将其转换为字符串类型并解析。数据应该只有两个属性,access_tokentoken_type ( 'bearer'):

      try {
        data = JSON.parse(buf.toString('utf-8'));
      } catch (e) {
        if (e) return next(e);
      }

让我们检查一下access_token是否 100%确定:

      if (!data || !data.access_token) return next(new Error('No data from AngelList'));
      token = data.access_token;

现在,我们可以在会话中保存token,并调用下一个中间件:

      req.session.angelListAccessToken = token;
      if (token) {
        next();
      }
      else {
        next(new Error('No token from AngelList'));
      }
    });
  });

请求代码的其余部分完成请求并处理一个error事件:

  angelReq.end();
  angelReq.on('error', function(e) {
    console.error(e);
    next(e);
  });
}

因此,用户被授权访问我们的 AngelList 应用,我们拥有令牌(angelListCallback)。现在,我们可以用之前中间件的令牌(angelListLogin)直接调用 AngelList API 来获取用户概要信息。中间件功能的顺序由路由/auth/angellist/callback决定,所以我们从 HTTPS 请求angelListLogin开始:

exports.angelListLogin = function(req, res, next) {
  var token = req.session.angelListAccessToken;
  httpsRequest = https.request({
    host: 'api.angel.co',

同样,每个服务的确切 URL 也是不同的:

    path: '/1/me?access_token=' + token,
    port: 443,
    method: 'GET'
  },
  function(httpsResponse) {
    var userBuffer = '';
    httpsResponse.on('data', function(buffer) {
      userBuffer += buffer;
    });

下一个事件侦听器将缓冲区类型的对象解析为普通的 JavaScript/Node.js 对象:

    httpsResponse.on('end', function(){
      try {
        data = JSON.parse(userBuffer.toString('utf-8'));
      } catch (e) {
        if (e) return next(e);
      }

在执行的这一点上,系统应该有填充了用户信息的数据字段(/1/me?access_token=...端点)。你可以在图 22-6 中看到这种响应数据的例子。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 22-6 。AngelList 用户信息响应示例

我们仍然需要检查对象是否为空,如果不为空,我们将用户数据保存在请求对象上:

      if (data) {
        req.angelProfile = data;
        next();
      } else
        next(new Error('No data from AngelList'));
      });
    }
  );
  httpsRequest.end();
  httpsRequest.on('error', function(e) {
    console.error(e);
  });
};

在撰写本文时,hackhall/routes/auth.js文件的完整源代码在https://github.com/azat-co/hackhall/blob/v3.1.0/routes/auth.js(随着 HackHall 版本的发展会有所变化)。

main.js

hackhall/routes/main.js文件也很有趣,因为它有这些方法:

  • checkAdmin()
  • checkUser()
  • checkApplicant()
  • login()
  • logout()
  • profile()
  • delProfile()

checkAdmin()函数执行管理员权限的认证。如果会话对象没有携带正确的标志,我们调用带有错误对象的 Express.js next()函数:

exports.checkAdmin = function(request, response, next) {
  if (request.session
    && request.session.auth
    && request.session.userId
    && request.session.admin) {
    console.info('Access ADMIN: ' + request.session.userId);
    return next();
  } else {
    next('User is not an administrator.');
  }
};

同样,我们可以只检查批准的用户,而不检查管理员权限:

exports.checkUser = function(req, res, next) {
  if (req.session && req.session.auth && req.session.userId
    && (req.session.user.approved || req.session.admin)) {
    console.info('Access USER: ' + req.session.userId);
    return next();
  } else {
    next('User is not logged in.');
  }
};

如果应用只是一个未批准的用户对象,我们还可以检查:

exports.checkApplicant = function(req, res, next) {
  if (req.session && req.session.auth && req.session.userId
    && (!req.session.user.approved || req.session.admin)) {
    console.info('Access USER: ' + req.session.userId);
    return next();
  } else {
    next('User is not logged in.');
  }
};

在登录功能中,我们搜索电子邮件。因为我们不在数据库中存储普通密码——我们只存储它的加密散列——我们需要使用bcryptjs来比较密码散列。匹配成功后,我们将用户对象存储在会话中,将auth标志设置为true ( req.session.auth = true),然后继续。否则,请求会失败:

var bcrypt = require('bcryptjs');
exports.login = function(req, res, next) {
  console.log('Logging in USER with email:', req.body.email)
  req.db.User.findOne({
      email: req.body.email
    },null, {
      safe: true
    }, function(err, user) {
      if (err) return next(err);
      if (user) {

我们使用异步的bcryptjs方法compare(),如果普通密码与保存的散列密码匹配,它将返回true:

        bcrypt.compare(req.body.password, user.password, function(err, match) {
          if (match) {

所以,一切都很好:系统分配会话标志并在会话中保存用户信息。这些值将用于所有需要授权(受保护)的路由,以识别用户:

            req.session.auth = true;
            req.session.userId = user._id.toHexString();
            req.session.user = user;

管理员有一个单独的布尔值:

            if (user.admin) {
              req.session.admin = true;
            }
            console.info('Login USER: ' + req.session.userId);

JSON {msg: 'Authorized'}对象是一个您可以定制的任意约定,但是您必须在服务器和客户机上保持它相同(以检查服务器响应):

            res.status(200).json({
              msg: 'Authorized'
            });
          } else {
            next(new Error('Wrong password'));
          }
        });
      } else {
        next(new Error('User is not found.'));
      }
    });
};

注销过程会删除所有会话信息:

exports.logout = function(req, res) {
  console.info('Logout USER: ' + req.session.userId);
  req.session.destroy(function(error) {
    if (!error) {
      res.send({
        msg: 'Logged out'
      });
    }
  });
};

该路径用于配置文件页面,也由 Backbone.js 用于用户验证:

exports.profile = function(req, res, next) {

我们不想公开所有的用户字段,所以我们只将我们想要的字段列入白名单:

  var fields = 'firstName lastName displayName' +
    ' headline photoUrl admin approved banned' +
    ' role angelUrl twitterUrl facebookUrl linkedinUrl githubUrl';

这是一个通过 Mongoose 功能创建的自定义方法,因为它具有相当广泛的逻辑,并且被多次调用:

  req.db.User.findProfileById(req.session.userId, fields, function(err, obj) {
    if (err) next(err);
    res.status(200).json(obj);
  });
};

允许用户删除他们的个人资料很重要。我们利用findByIdAndRemove()方法并删除带有destroy()的会话:

exports.delProfile = function(req, res, next) {
  console.log('del profile');
  console.log(req.session.userId);
  req.db.User.findByIdAndRemove(req.session.user._id, {},
    function(err, obj) {
      if (err) next(err);
      req.session.destroy(function(error) {
        if (err) {
          next(err)
        }
      });
      res.status(200).json(obj);
    }
  );
};

https://github.com/azat-co/hackhall/blob/v3.1.0/routes/main.js可以获得hackhall/routes/main.js文件的完整源代码。

users.js

routes/users.js文件负责与用户集合相关的 RESTful 活动。我们有这些方法:

  • getUsers()
  • getUser()
  • add()
  • update()
  • del()
  • findOrAddUser()

首先,我们定义一些变量:

var path = require('path'),
  hs = require(path.join(__dirname, '..', 'lib', 'hackhall-sendgrid'));

var objectId = require('mongodb').ObjectID;

var safeFields = 'firstName lastName displayName headline photoUrl admin approved banned role angelUrl twitterUrl facebookUrl linkedinUrl githubUrl';

然后,我们定义方法getUsers() ,该方法检索用户列表,其中每一项都只有来自safeFields字符串的属性:

exports.getUsers = function(req, res, next) {
  if (req.session.auth && req.session.userId) {
    req.db.User.find({}, safeFields, function(err, list) {
      if (err) return next(err);
      res.status(200).json(list);
    });
  } else {
    return next('User is not recognized.')
  }
}

getUser()方法用于用户资料页面。对于管理员(当前用户,不是我们获取的用户),我们添加一个额外的字段email,并调用定制的静态方法findProfileById():

exports.getUser = function(req, res, next) {
  var fields = safeFields;
  if (req.session.admin) {
    fields = fields + ' email';
  }
  req.db.User.findProfileById(req.params.id, fields, function(err, data){
    if (err) return next(err);
    res.status(200).json(data);
  })
}

要查看getUser()方法的运行情况,您可以导航到一个用户资料页面,如图 22-7 中的所示。管理员可以在个人资料页面上管理用户的帐户。因此,如果您是管理员,您会看到一个额外的角色下拉列表,为该用户设置角色。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 22-7 。以管理员身份登录时的 HackHall 个人资料页面

add()方法很简单:

exports.add = function(req, res, next) {
  var user = new req.db.User(req.body);
  user.save(function(err) {
    if (err) next(err);
    res.json(user);
  });
};

update()方法也用于批准新用户(approvedNow == true)。如果成功,我们使用内部方法notifyApproved()lib/hackhall-sendgrid.js文件发送一封电子邮件:

exports.update = function(req, res, next) {
var obj = req.body;
  obj.updated = new Date();
  delete obj._id;
  var approvedNow = obj.approved && obj.approvedNow;

approvedNow字段不是 Mongoose 模式中的字段,我们不想存储它。该字段的唯一目的是让系统知道它是常规更新呼叫还是批准:

  delete obj.approvedNow;
  req.db.User.findByIdAndUpdate(req.params.id, {
    $set: obj
  }, {

该选项将为我们提供新的对象,而不是原始对象(默认为true):

    new: true
  }, function(err, user) {
    if (err) return next(err);
    if (approvedNow && user.approved) {
      console.log('Approved... sending notification!');

因此,批准成功,我们可以发送电子邮件:

      hs.notifyApproved(user, function(error, user){
        if (error) return next(error);
        console.log('Notification was sent.');
        res.status(200).json(user);
      })
    } else {

如果是定期更新,而不是批准,那么我们只需发回用户对象:

      res.status(200).json(user);
    }
  });
};

图 22-8 显示了当你以管理员权限登录时,用户界面中的批准是什么样子。管理员可以使用下拉菜单来批准、删除或禁止申请人。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 22-8 。具有管理员权限的 HackHall 人员页面(以管理员身份登录)

删除一个用户,我们调用findByIdAndRemove() :

exports.del = function(req, res, next) {
  req.db.User.findByIdAndRemove(req.params.id, function(err, obj) {
    if (err) next(err);
    res.status(200).json(obj);
  });
};

最后,当用户使用 AngelList 登录时,使用findOrAddUser()方法。您可以使用插件提供的findOrCreate(这就是 GitHub OAuth 流中使用的),但是为了便于学习,最好知道如何自己实现相同的功能。当您将findOrCreate与这个函数进行比较时,它还会强化您的异步思维方式和您对如何重构代码的理解:

exports.findOrAddUser = function(req, res, next) {
  var data = req.angelProfile;
  req.db.User.findOne({
    angelListId: data.id
  }, function(err, obj) {
    console.log('angelList Login findOrAddUser');
    if (err) return next(err);

好了,我们在数据库中查询了用户,但是让我们检查用户是否在那里,如果不在,就创建用户:

if (!obj) {
          console.warn('Creating a user', obj, data);
          req.db.User.create({

我们将 AngelList 响应中需要的所有字段映射/规范化到用户对象。

angelListId: data.id,

我们可以使用这个令牌代表用户发出后续的 API 请求,而无需每次都请求授权和许可:

angelToken: req.session.angelListAccessToken,

为了以防万一,我们也将整个 AngelList 对象存储在angelListProfile : 中

angelListProfile: data,
email: data.email,

data.name是全名,所以我们按空格将它分成一个数组,并分别得到第一个和第二个元素:

firstName: data.name.split(' ')[0],
lastName: data.name.split(' ')[1],
displayName: data.name,
headline: data.bio,

图像只是文件的 URL,而不是二进制字段:

        photoUrl: data.image,
        angelUrl: data.angellist_url,
        twitterUrl: data.twitter_url,
        facebookUrl: data.facebook_url,
        linkedinUrl: data.linkedin_url,
        githubUrl: data.github_url
}, function(err, obj) {
        if (err) return next(err);
  console.log('User was created', obj);

好了,用户文档已经成功创建了。但是系统必须马上让用户登录,所以我们将session标志设置为true:

req.session.auth = true;

我们需要在会话中保存新创建的用户 ID,以便我们可以在来自该客户端的其他请求中使用它:

req.session.userId = obj._id;
req.session.user = obj;

该管理员需要由另一个管理员提升。新用户的默认数据库值由 Mongoose 模式负责(默认值为false)。但是这里需要设置会话值,所以我们默认认证为普通用户:

      req.session.admin = false;
      res.redirect('/#application');
      }
    );
} else {

当用户文档在数据库中时,我们只需登录用户并重定向到帖子或他们的会员申请:

        req.session.auth = true;
        req.session.userId = obj._id;
        req.session.user = obj;
        req.session.admin = obj.admin;
        if (obj.approved) {
          res.redirect('/#posts');
        } else {
          res.redirect('/#application');
        }
      }
    })
}

https://github.com/azat-co/hackhall/blob/v3.1.0/routes/users.js可以获得hackhall/routes/users.js文件的完整源代码。

users.js为人员页面的 REST API routes 提供功能,允许用户访问其他用户的个人资料,如图图 22-9 所示。在这个截图中,第一个 Azat 的配置文件来自于播种数据库。第二个 Azat 的简介是我用 GitHub 登录的。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 22-9 。HackHall 人员页面

application.js

hackhall/routes/application.js文件(“应用”是应用的意思,不是 app 里的!)处理申请加入 HackHall 社区的新用户。他们需要得到批准,以确保只有真正和认真的成员加入 HackHall.com。在您的本地版本中,您可能希望禁止有关提交和批准应用的电子邮件通知。

仅仅为了向数据库(电子邮件成员资格应用)添加一个用户对象(默认情况下使用approved=false),我们使用以下方法:

exports.add = function(req, res, next) {
  req.db.User.create({
    firstName: req.body.firstName,
    lastName: req.body.lastName,
    displayName: req.body.displayName,
    headline: req.body.headline,
    photoUrl: req.body.photoUrl,
    password: req.body.password,
    email: req.body.email,
    angelList: {
      blah: 'blah'
    },
    angelUrl: req.body.angelUrl,
    twitterUrl: req.body.twitterUrl,
    facebookUrl: req.body.facebookUrl,
    linkedinUrl: req.body.linkedinUrl,
    githubUrl: req.body.githubUrl
  }, function(err, obj) {
    if (err) return next(err);
    if (!obj) return next('Cannot create.')
    res.status(200).json(obj);
  })
};

我们让用户用这种方法更新他们应用中的信息:

exports.update = function(req, res, next) {
  var data = req.body;

首先需要删除_id,因为我们不想改变它:

  delete data._id;

findByIdAndUpdate()方法中,我们使用来自会话的用户 ID,而不是来自主体的用户 ID,因为它不可信:

  req.db.User.findByIdAndUpdate(req.session.user._id, {
    $set: data
  }, function(err, obj) {
    if (err) return next(err);
    if (!obj) return next('Cannot save.')

大概可以把整个发回去(obj),因为反正是这个用户的信息:

    res.status(200).json(obj);
  });
};

使用get()功能选择特定对象:

exports.get = function(req, res, next) {
  req.db.User.findById(req.session.user._id,

限制我们返回的字段:

    'firstName lastName photoUrl headline displayName'
      + 'angelUrl facebookUrl twitterUrl linkedinUrl'
      + 'githubUrl', {}, function(err, obj) {
      if (err) return next(err);
      if (!obj) return next('cannot find');
      res.status(200).json(obj);
    })
};

以下是hackhall/routes/applications.js文件的完整源代码:

exports.add = function(req, res, next) {
  req.db.User.create({
    firstName: req.body.firstName,
    lastName: req.body.lastName,
    displayName: req.body.displayName,
    headline: req.body.headline,
    photoUrl: req.body.photoUrl,
    password: req.body.password,
    email: req.body.email,
    angelList: {
      blah: 'blah'
    },
    angelUrl: req.body.angelUrl,
    twitterUrl: req.body.twitterUrl,
    facebookUrl: req.body.facebookUrl,
    linkedinUrl: req.body.linkedinUrl,
    githubUrl: req.body.githubUrl
  }, function(err, obj) {
    if (err) return next(err);
    if (!obj) return next('Cannot create.')
    res.status(200).json(obj);
  })
};

exports.update = function(req, res, next) {
  var data = req.body;
  delete data._id;
  req.db.User.findByIdAndUpdate(req.session.user._id, {
    $set: data
  }, function(err, obj) {
    if (err) return next(err);
    if (!obj) return next('Cannot save.')
    res.status(200).json(obj);
  });
};

exports.get = function(req, res, next) {
  req.db.User.findById(req.session.user._id,
    'firstName lastName photoUrl headline displayName angelUrl facebookUrl twitterUrl linkedinUrl githubUrl', {}, function(err, obj) {
      if (err) return next(err);
      if (!obj) return next('cannot find');
      res.status(200).json(obj);
    })
};

图 22-10 显示了会员申请页面此时的样子。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 22-10 。HackHall 会员申请页面

posts.js

我们需要剖析的最后一个 routes 模块是hackhall/routes/posts.js。它负责添加、编辑和删除帖子,以及评论、观看和喜欢。

我们使用对象 ID 将十六进制字符串转换为正确的对象:

objectId = require('mongodb').ObjectID;

帖子分页的默认值如下:

var LIMIT = 10;
var SKIP = 0;

add()函数处理新帖子的创建:

exports.add = function(req, res, next) {
  if (req.body) {

这里的req.db.Post是可用的,因为定制的db中间件用在大多数路线上:

    req.db.Post.create({
      title: req.body.title,
      text: req.body.text || null,
      url: req.body.url || null,

我们从用户的会话信息中设置帖子的作者:

      author: {
        id: req.session.user._id,
        name: req.session.user.displayName
      }
    }, function(err, docs) {
      if (err) {
        console.error(err);
        next(err);
      } else {
        res.status(200).json(docs);
      }

    });
  } else {
    next(new Error('No data'));
  }
};

为了使用来自请求查询的值limitskip或者缺省值来检索文章列表,我们使用下面的代码:

exports.getPosts = function(req, res, next) {
  var limit = req.query.limit || LIMIT;
  var skip = req.query.skip || SKIP;
  req.db.Post.find({}, null, {
    limit: limit,
    skip: skip,

我们按 ID 对结果进行排序,ID 通常按时间顺序列出结果(为了获得更精确的结果,我们可以在这里使用created字段):

    sort: {
      '_id': -1
    }
  }, function(err, obj) {

此时,我们检查在obj中是否有任何帖子,然后,我们执行一个循环来添加一些助手标志,如adminownlikewatch:

 if (!obj) return next('There are not posts.');
var posts = [];
docs.forEach(function(doc, i, list) {

doc对象是一个 Mongoose 文档对象,它有很多魔力,所以最好将数据转换成一个普通的对象:

var item = doc.toObject();

现在,我们可以检查用户是否有管理员权限,如果用户有,那么我们将item.admin设置为true,但是是在新对象item的属性上。这是多余的,因为客户端应用在其他地方有admin标志,但出于表示的目的,在每个帖子上有这些信息很方便,因为管理员可以编辑和删除任何帖子:

if (req.session.user.admin) {
        item.admin = true;
      } else {
        item.admin = false;
}

下一行检查用户是否是这篇文章的作者:

if (doc.author.id == req.session.userId) {
        item.own = true;
} else {
        item.own = false;
}

这一行检查这个用户是否喜欢这个帖子:

if (doc.likes && doc.likes.indexOf(req.session.user._id) > -1) {
        item.like = true;
       } else {
        item.like = false;
       }

这一行检查这个用户是否观看这个帖子:

        if (doc.watches && doc.watches.indexOf(req.session.user._id) > -1) {
          item.watch = true;
          } else {
            item.watch = false;
          }
          posts.push(item);
});

这里是我们形成响应体的地方:

var body = {};
      body.limit = limit;
body.skip = skip;
      body.posts = posts;

为了包含文档(文章)的总数以便分页,我们需要这个快速查询:

    req.db.Post.count({}, function(err, total) {
      if (err) return next(err);
      body.total = total;
      res.status(200).json(body);
    });
  });
};

对于个人帖子页面,我们需要getPost()方法。我们可以传递我们想要的属性,不是像在users.js中那样作为字符串,而是作为对象:

exports.getPost = function(req, res, next) {
  if (req.params.id) {
    req.db.Post.findById(req.params.id, {

这是限制我们希望从数据库返回的字段的另一种方法:

      title: true,
      text: true,
      url: true,
      author: true,
      comments: true,
      watches: true,
      likes: true
    }, function(err, obj) {
      if (err) return next(err);
      if (!obj) {
        next('Nothing is found.');
      } else {
        res.status(200).json(obj);
      }
    });
  } else {
    next('No post id');
  }
};

功能从数据库中删除特定的帖子。这个代码片段使用了 Mongoose 中的findById()remove()方法。然而,同样的事情只用remove()就可以完成。

exports.del = function(req, res, next) {
  req.db.Post.findById(req.params.id, function(err, obj) {
    if (err) return next(err);

以下只是一个完整性检查,以确认客户端是管理员还是我们将要删除的帖子的作者:

    if (req.session.admin || req.session.userId === obj.author.id) {
      obj.remove();
      res.status(200).json(obj);
    } else {
      next('User is not authorized to delete post.');
    }
  })
};

为了喜欢这篇文章,我们通过在post.likes数组前添加用户 ID 来更新文章条目:

function likePost(req, res, next) {
  req.db.Post.findByIdAndUpdate(req.body._id, {

这是一个简单的 MongoDB 操作数,用于向数组中添加值:

    $push: {
      likes: req.session.userId
    }
  }, {}, function(err, obj) {
    if (err) {
      next(err);
    } else {
      res.status(200).json(obj);
    }
  });
};

同样,当用户执行观察动作时,系统会向post.watches数组添加一个新的 ID:

function watchPost(req, res, next) {
  req.db.Post.findByIdAndUpdate(req.body._id, {
    $push: {
      watches: req.session.userId
    }
  }, {}, function(err, obj) {
    if (err) next(err);
    else {
      res.status(200).json(obj);
    }
  });
};

updatePost()方法是调用 like 或 watch 函数,基于随请求发送的动作标志(req.body.action):

exports.updatePost = function(req, res, next) {
  var anyAction = false;
  if (req.body._id && req.params.id) {

此逻辑用于添加 like:

    if (req.body && req.body.action == 'like') {
      anyAction = true;
      likePost(req, res);

下一个条件是添加观察器:

    } else if (req.body && req.body.action == 'watch') {
      anyAction = true;
      watchPost(req, res);

这一个是给帖子添加评论:

    } else if (req.body && req.body.action == 'comment'
      && req.body.comment && req.params.id) {
      anyAction = true;
      req.db.Post.findByIdAndUpdate(req.params.id, {
        $push: {
          comments: {
            author: {
              id: req.session.userId,
              name: req.session.user.displayName
            },
            text: req.body.comment
          }
        }
      }, {
        safe: true,
        new: true
      }, function(err, obj) {
        if (err) throw err;
        res.status(200).json(obj);
      });

最后,当前面的操作条件都不满足时,updatePost()处理帖子本身的更改(标题、文本等。)由作者或管理员制作(req.body.author.id == req.session.user._id || req.session.user.admin):

    } else if (req.session.auth && req.session.userId && req.body
      && req.body.action != 'comment' &&
      req.body.action != 'watch' && req.body != 'like' &&
      req.params.id && (req.body.author.id == req.session.user._id
      || req.session.user.admin)) {
      req.db.Post.findById(req.params.id, function(err, doc) {

在这个上下文中,doc对象是一个 mongose 文档对象,因此我们为它的属性分配新值并调用save(),这将触发模型中定义的预保存钩子(在下一节“mongose 模型”中讨论):

        if (err) next(err);
        doc.title = req.body.title;
        doc.text = req.body.text || null;
        doc.url = req.body.url || null;
        doc.save(function(e, d) {
          if (e) return next(e);

发送回更新的对象是一个规则:

          res.status(200).json(d);
        });
      })
    } else {
      if (!anyAction) next('Something went wrong.');
    }

  } else {
    next('No post ID.');
  }
};

https://github.com/azat-co/hackhall/blob/v3.1.0/routes/posts.js可以获得hackhall/routes/posts.js文件的完整源代码。

这就完成了新帖子页面的路径编码(见图 22-11 ),用户可以在这里创建一个帖子(例如,一个问题)。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 22-11 。HackHall 新帖子页面

我们完成了路线文件!你还记得 HackHall 是一个真正的 MVC 应用吗?接下来,我们将覆盖模型。

猫鼬模型

理想情况下,在一个大的应用中,我们应该将每个模型分解到一个单独的文件中。现在,在 HackHall 应用中,我们在hackhall/models/index.js中拥有它们。

和往常一样,我们的依赖项在顶部看起来更好:

var mongoose = require('mongoose');

该引用将用于 Mongoose 数据类型:

var Schema = mongoose.Schema;

此数组将用作枚举类型:

var roles = 'user staff mentor investor founder'.split(' ');

帖子模型表示一个帖子及其赞、评论和关注。架构中的每个属性都为该属性设置了特定的行为。例如,required表示该属性是必需的,type是猫鼬/BSON 数据类型。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 提示想了解更多关于猫鼬的信息,请查阅其官方文档(https://gumroad.com/l/mongoose)、*实用 Node.js * (Apress,2014),以及新的在线课程。

我们用操作数定义Schema:

var Post = new Schema ({

然后,我们有一个必需的title字段(Stringtype,它会自动删除开头和结尾的空白:

  title: {
    required: true,
    type: String,
    trim: true,

RegExp 表示“一个单词、一个空格或任何字符,.!?”,长度在 1 到 100 个字符之间:

    match: /^([\w ,.!?]{1,100})$/
  },

然后,我们用最多 1000 个字符定义 url(对于长 URL 应该足够了吧?)并打开修剪:

  url: {
    type: String,
    trim: true,
    max: 1000
  },

我们为text 定义类似的字段属性:

  text: {
    type: String,
    trim: true,
    max: 2000
  },

comments 是这篇文章的评论数组。每个评论对象都有一个textauthor。作者id是对User模式的引用:

  comments: [{
    text: {
      type: String,
      trim: true,
      max:2000
    },
    author: {
      id: {
        type: Schema.Types.ObjectId,
        ref: 'User'
      },
      name: String
    }
  }],

帖子可以被用户观看或喜欢。这些特性是通过使用带有用户 id 的数组watcheslikes来实现的:

  watches: [{
    type: Schema.Types.ObjectId,
    ref: 'User'
  }],
  likes: [{
    type: Schema.Types.ObjectId,
    ref: 'User'
  }],

接下来,我们输入作者信息并使嵌套对象中的每个字段成为必填字段:

  author: {
    id: {
      type: Schema.Types.ObjectId,
      ref: 'User',
      required: true
    },
    name: {
      type: String,
      required: true
    }
  },

最后,我们添加了时间和日期字段。最好有事件的时间戳,比如这篇文章是什么时候创建的,最后一次更新是什么时候。为此,我们使用Date.now作为默认字段。updated属性将由预保存钩子设置,也可以在每个save()上手动设置。(预保存钩子代码在这个模式代码之后提供。):

  created: {
    type: Date,
    default: Date.now,
    required: true
  },
  updated: {
    type: Date,
    default: Date.now,
    required: true
  }
});

回到updated字段,为了确保我们不必在每次更新(save())帖子时手动设置时间戳,我们使用了一个预保存挂钩来检查字段是否被修改(是否有新值)。如果它没有被修改,那么我们用一个新的日期和时间来设置它。这个钩子只有在你呼叫save()的时候才起作用;当你使用update()或类似的方法时就不会了。回调有一个异步next()函数,你可能会在 Express.js 中间件中看到:

Post.pre('save', function (next) {
  if (!this.isModified('updated')) this.updated = new Date;
  next();
})

User模型也可以作为应用对象(当approved=false时)。让我们将模式定义如下:

var User = new Schema({
  angelListId: String,

Mixed类型允许我们存储任何东西:

  angelListProfile: Schema.Types.Mixed,
  angelToken: String,
    firstName: {
    type: String,
    required: true,
    trim: true
  },
  lastName: {
    type: String,
    required: true,
    trim: true
  },
  displayName: {
    type: String,
    required: true,
    trim: true
  },
  password: String,
  email: {
    type: String,
    required: true,
    trim: true
  },

角色是enum,因为该值只能是来自roles数组 ( [user, staff, mentor, investor, founder])的值之一:

  role: {
    type: String,
    enum: roles,
    required: true,
    default: roles[0]
  },

以下是一些必需的布尔标志:

  approved: {
    type: Boolean,
    default: false
  },
  banned: {
    type: Boolean,
    default: false
  },
  admin: {
    type: Boolean,
    default: false
  },

现在是简短的简历陈述:

  headline: String,

我们不会存储照片二进制文件,只存储它的 URL:

  photoUrl: String,

angelList是一个松散的类型,将具有 AngelList 配置文件:

  angelList: Schema.Types.Mixed,

最好用日志来跟踪文档的创建时间和最后一次更新时间(我们在users.jsupdate()方法中手动设置时间):

  created: {
    type: Date,
    default: Date.now
  },
  updated: {
    type: Date,
    default: Date.now
  },

我们需要一些社交媒体网址:

  angelUrl: String,
  twitterUrl: String,
  facebookUrl: String,
  linkedinUrl: String,
  githubUrl: String,

我们将该用户创作、喜欢、观看和评论的帖子的 id 引用为对象数组(它们将是ObjectID s):

  posts: {
    own: [Schema.Types.Mixed],
    likes: [Schema.Types.Mixed],
    watches: [Schema.Types.Mixed],
    comments: [Schema.Types.Mixed]
  }
});

为了方便起见,我们应用了findOrCreate插件(https://www.npmjs.org/package/mongoose-findorcreate):

User.plugin(findOrCreate);

Mongoose 插件的行为类似于迷你模块。这允许您向模型添加额外的功能。添加额外功能的另一种方式是编写自己的自定义方法。这种方法可以是静态的(附加到实体的整个类别)或实例(附加到特定的模型)。

在《routes》中,你已经看过两次findProfileById():一次在main.js,一次在users.js。为了避免重复,代码被抽象为User模式的一个 Mongoose 静态方法。它检索信息,如评论、喜欢等。这就是为什么我们有多个嵌套的猫鼬叫声。

findProfileById()方法最初看起来可能有点复杂,但是这里没有什么困难——只需要几个嵌套的数据库调用,这样我们就可以获得完整的用户信息。这些信息不仅包括用户名、电子邮件地址等等,还包括用户发表的所有帖子、喜欢、关注和评论。这些信息用于个人资料页面上的游戏化目的,将评论、喜欢和观看的数量转换为点数。但是让我们从第一个基本查询开始,限制我们请求的字段(以避免泄露密码和电子邮件地址):

User.statics.findProfileById = function(id, fields, callback) {
  var User = this;
  var Post = User.model('Post');

  return User.findById(id, fields, function(err, obj) {
    if (err) return callback(err);
    if (!obj) return callback(new Error('User is not found'));

找到用户后,我们通过使用_iddisplayName找到用户的帖子。字段选项设置为null,这样我们可以传递其他参数,结果按照创建日期排序。在回调中,我们检查错误,如果有错误就退出(callback(err))。

    Post.find({
      author: {
        id: obj._id,
        name: obj.displayName
      }
    }, null, {
      sort: {
        'created': -1
      }
    }, function(err, list) {

处理每个嵌套回调的错误是至关重要的:

      if (err) return callback(err);
      obj.posts.own = list || [];

现在我们已经将该用户的帖子列表保存到了obj.posts.own中,下一个查询将查找该用户喜欢的所有帖子:

      Post.find({
        likes: obj._id
      }, null, {

时间顺序由created保证:

        sort: {
          'created': -1
        }
      }, function(err, list) {
        if (err) return callback(err);

万一这个用户不喜欢任何帖子,我们用一个空数组来解释:

        obj.posts.likes = list || [];

此查询获取该用户观看的帖子:

        Post.find({
          watches: obj._id
        }, null, {
          sort: {
            'created': -1
          }
        }, function(err, list) {

先前上下文中的 errlist对象被这个闭包的errlist所掩盖,但我们并不在乎。这种风格允许变量名重用:

          if (err) return callback(err);
          obj.posts.watches = list || [];

最后一个查询查找该用户留下评论的帖子:

          Post.find({
            'comments.author.id': obj._id
          }, null, {
            sort: {
              'created': -1
            }
          }, function(err, list) {
            if (err) return callback(err);
            obj.posts.comments = [];

在我们获得该用户留下评论的帖子列表后,可能会有一些帖子中该用户留下了不止一条评论。出于这个原因,我们需要仔细检查帖子列表和每个评论,并将作者 ID 与用户 ID 进行比较。如果它们匹配,那么我们将该注释包含到列表中:

            list.forEach(function(post, key, arr) {
              post.comments.forEach(function(comment, key, arr) {
                if (comment.author.id.toString() == obj._id.toString())
                  obj.posts.comments.push(comment);
              });
            });

最后,我们用正确的数据和空错误调用回调:

            callback(null, obj);
          });
        });
      });
    });
  });
}

最后,我们导出模式对象,以便可以将它们编译成另一个文件中的模型:

exports.Post = Post;
exports.User = User;

hackhall/models/index.js的完整源代码可在https://github.com/azat-co/hackhall/blob/v3.1.0/models/index.js获得。

摩卡测试

使用 REST API 服务器架构的一个好处是,每条路线以及整个应用都变得非常容易测试。通过测试的保证是开发过程中的一个很好的补充——所谓的测试驱动开发方法在第二十一章中介绍。

HackHall 测试位于tests文件夹中,包括:

  • hackhall/tests/application.js:未批准用户信息的功能测试
  • hackhall/tests/posts.js:岗位功能测试
  • hackhall/tests/users.js:用户功能测试

为了运行测试,我们利用一个 Makefile。我喜欢在 Makefile 中有不同的目标,因为这给了我更多的灵活性。以下是本例中的任务:

  • test:运行tests文件夹中的所有测试
  • test-w:每次有文件更改时重新运行测试
  • users:对用户相关的路线进行tests/users.js测试
  • posts:运行tests/posts.js岗位相关路线测试
  • application:运行tests/application.js测试应用相关的路由

Makefile 可能是这样的,从 Mocha 的选项开始:

REPORTER = list
MOCHA_OPTS = --ui tdd

然后我们定义一个任务test:

test:
        clear
        echo Seeding **********************
        node seed.js
        echo Starting test **********************
        foreman run ./node_modules/mocha/bin/mocha \
        --reporter $(REPORTER) \
        $(MOCHA_OPTS) \
        tests/*.js
        echo Ending test

同样,我们还定义了其他目标:

test-w:
        ./node_modules/mocha/bin/mocha \
        --reporter $(REPORTER) \
        --growl \
        --watch \
        $(MOCHA_OPTS) \
        tests/*.js

users:
        clear
        echo Starting test **********************
        foreman run ./node_modules/mocha/bin/mocha \
        --reporter $(REPORTER) \
        $(MOCHA_OPTS) \
        tests/users.js
        echo Ending test

posts:
        clear
        echo Starting test **********************
        foreman run ./node_modules/mocha/bin/mocha \
        --reporter $(REPORTER) \
        $(MOCHA_OPTS) \
        tests/posts.js
        echo Ending test

application:
        clear
        echo Starting test **********************
        foreman run ./node_modules/mocha/bin/mocha \
        --reporter $(REPORTER) \
        $(MOCHA_OPTS) \
        tests/application.js
        echo Ending test

.PHONY: test test-w users posts application

因此,我们可以用$ make$ make test命令开始测试(要运行示例中的 Makefile,您必须有foreman工具和.env变量)。

所有 36 个测试都应该通过(在 HackHall v3.1.0 中撰写本文时),如图 22-12 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 22-12 。运行所有摩卡测试的结果

测试使用一个名为superagent ( https://npmjs.org/package/superagent)的库;GitHub: https://github.com/visionmedia/superagent。这些测试在概念上类似于第二十一章中针对 REST API 的测试。我们登录,然后发出一些请求,同时检查它们的正确响应。

例如,这是hackhall/tests/application.js的开始,其中我们有一个带有散列密码的虚拟用户对象(bcrypt.hashSync()):

var bcrypt = require('bcryptjs');

var user3 = {
  firstName: 'Dummy',
  lastName: 'Application',
  displayName: 'Dummy Application',
  password: bcrypt.hashSync('3', 10),
  email: '3@3.com',
  headline: 'Dummy Application',
  photoUrl: '/img/user.png',
  angelList: {blah:'blah'},
  angelUrl: 'http://angel.co.com/someuser',
  twitterUrl: 'http://twitter.com/someuser',
  facebookUrl: 'http://facebook.com/someuser',
  linkedinUrl: 'http://linkedin.com/someuser',
  githubUrl: 'http://github.com/someuser'
}

var app = require ('../server').app,
  assert = require('assert'),
  request = require('superagent');

我们启动服务器:

app.listen(app.get('port'), function(){
  console.log('Express server listening on port ' + app.get('port'));
});

下一行将存储客户机对象,以便我们可以作为该用户登录并发出授权请求:

var user1 = request.agent();
var port = 'http://localhost:'+app.get('port');
var userId;

我们使用由seed.js创建的管理员用户:

var adminUser = {
  email: 'admin-test@test.com',
  password: 'admin-test'
};

接下来,我们创建一个测试套件:

suite('APPLICATION API', function (){

这是一个测试套件准备(目前为空):

  suiteSetup(function(done){
    done();
  });

下面是对/api/login的 POST 调用的第一个测试用例定义:

  test('log in as admin', function(done){
    user1.post(port+'/api/login').send(adminUser).end(function(res){
        assert.equal(res.status,200);
      done();
    });
  });

让我们检查一下我们是否可以获得受保护的资源/api/profile:

  test('get profile for admin',function(done){
    user1.get(port+'/api/profile').end(function(res){
        assert.equal(res.status,200);
      done();
    });
  });
  test('submit application for user 3@3.com', function(done){

在这里,我们使用user3数据和散列密码创建一个新的成员资格应用:

    user1.post(port+'/api/application').send(user3).end(function(res){
      assert.equal(res.status,200);
      userId = res.body._id;
      done();
    });
  });

然后,我们注销user1并检查我们是否已经注销:

  test('logout admin',function(done){
    user1.post(port+'/api/logout').end(function(res){
        assert.equal(res.status,200);
      done();
    });
  });
  test('get profile again after logging out',function(done){
    user1.get(port+'/api/profile').end(function(res){
        assert.equal(res.status,500);
      done();
    });
  });

现在,我们尝试使用普通密码作为user3登录,就像在网页上输入一样(系统将对其进行哈希处理,以便与哈希密码进行比较):

  test('log in as user3 - unapproved', function(done){
    user1.post(port+'/api/login').send({email:'3@3.com', password:'3'}).end(function(res){
        assert.equal(res.status, 200);
      done();
    });
  });

...

假设您已经从这个测试用例中获得了一般的想法,那么就没有必要列出所有平凡的测试用例。当然,你可以在https://github.com/azat-co/hackhall/tree/v3.1.0/tests获得hackhall/tests/application.jshackhall/tests/posts.jshackhall/tests/users.js的全部内容。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意不要在数据库中存储普通密码/密钥。任何严肃的制作应用至少应该在存储密码 1 之前加盐。用bcryptjs代替!

到目前为止,您应该能够在本地运行应用和测试(通过从书中复制或者下载代码)。如果你得到了 API 密匙,你应该可以用 AngelList 和 GitHub 登录,也可以用 SendGrid 收发邮件。至少,您应该能够使用您在数据库播种脚本中指定的电子邮件和密码在本地登录。

摘要

现在你知道了构建 HackHall 所用到的所有技巧和窍门,包括重要的、真实的生产应用组件,比如 REST API 架构、OAuth、Mongoose 及其模型、Express.js 应用的 MVC 结构、环境变量的访问等等。

如本章所述,HackHall 仍在积极开发中,因此代码将继续发展。确保您遵循 GitHub 上的资源库。您可以访问 live HackHall.com 应用,并通过申请会员资格加入社区!当然,您可以通过提交拉取请求来做出贡献。

本章总结了我们对 Express.js 和相关 web 开发主题的研究。涵盖一个不断发展的框架是一项困难的任务,类似于向一个移动的目标射击,所以我在这一章的目标是让你获得最新的信息,最重要的是,向你展示一些更基本的方面,比如代码组织。我还花了很多精力解释和重复中间件模式的例子。如果您面临截止日期的压力,或者只是喜欢即时学习(在需要时学习,而不是为未来学习),那么您会发现大量代码可以复制并粘贴到您自己的项目中。我知道构建自己的项目比从教程中借用另一个抽象应用更有趣。

我希望你喜欢这些例子和这本书!我想通过推特(@azat_co)和电子邮件(hi@azat.co)收到你的来信。下面的附录将作为参考。别忘了领取你的两页纸的 Express.js 4 备忘单(下载链接在附录 C )。


1

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值