MongoHQ服务是类似于Amazon S3的云服务,只不过它专注于在云中托管MongoDB实例。可以访问http://mongohq.com来注册服务。小于16MB的数据库是免费的,可以用它来运行本章的示例。在线用户界面易于使用,让你可以快速地浏览数据。
设置好MongoHQ账户之后,应该使用在线的用户界面创建名为Lifestream的数据库。你会得到数据库服务器的名称和端口号,对于每个MongoHQ数据库,这些信息是不同的。还必须输入访问数据库的用户名和密码。在线界面将提供数据库登录的详细信息。
在下面的示例中,会把完整的Lifestream应用程序所需的所有文件放在一起。首先,需要得到配置信息,测试到MongoHQ服务的连接,并验证最基本的功能可以实际工作。在第9章,构建了图片上传功能。现在,可以将它放到一边,集中精力完成用户注册功能。介绍将用户添加到系统的基本概念是引出其余功能必不可少的。在本示例中专注于核心功能,下面将完成一个非常简单的用户注册功能。这里不使用密码,而是为每个物理设备分配一个唯一的令牌。最终,将使用OAuth令牌进行用户注册,但现在要实现一个唯一的设备令牌。下面是具体操作步骤。
(1) 进入lifestream/server文件夹(继续使用和第8章、第9章一样的文件夹结构),运行下面的npm模块安装命令。
npm install connect npm install mongodb npm install knox npm install uuid npm install oauth npm install url npm install request npm install cookies
前面已经安装了一些模块,npm将报告已安装的版本信息或将模块升级到最新的版本。
(2) 新建一个名为config.js的文件来存储服务器的配置信息,将下面的代码插入到文件中,用自己的配置信息替换突出显示的内容。
exports.mongohq = { username:''YOUR_DB_USERNAME', password: 'YOUR_DB_PASSWORD', name: 'YOUR_DB_NAME', host: 'YOUR_DB_HOST', port: YOUR_DB_PORT } exports.amazon = { s3bucket: 'YOUR_S3_BUCKET_NAME', keyid: 'YOUR_AWS_KEY_ID', secret: 'YOUR_AWS_SECRET' } exports.twitter = { keyid: 'YOUR_TWITTER_KEY_ID', secret: 'YOUR_TWITTER_SECRET' } exports.facebook = { keyid: 'YOUR_FACEBOOK_KEY_ID', secret: 'YOUR_FACEBOOK_SECRET' } exports.server = 'YOUR_IP_ADDRESS' exports.max_stream_size = 100
代码片段位于lifestream/server/config.js
(3) 使用下面的更新版本替换lifestream/server文件夹下common.js文件的内容。
var util = exports.util = require('util') var connect =exports.connect = require('connect') var knox = exports.knox = require('knox') var uuid =exports.uuid =require('node-uuid') var oauth =exports.oauth =require('oauth') var url =exports.url =require('url') var request =exports.request = require('request') var Cookies =exports.Cookies = require('Cookies') var config = exports.config =require('./config.js') // JSON functions exports.readjson = function(req,win,fail) { var bodyarr= []; req.on('data',function(chunk){ bodyarr.push(chunk); }) req.on('end',function(){ varbodystr = bodyarr.join(''); util.debug('READJSON:'+req.url+':'+bodystr); try { varbody = JSON.parse(bodystr); win&& win(body); } catch(e){ fail&& fail(e) } }) } exports.sendjson = function(res,obj){ res.writeHead(200,{ 'Content-Type': 'text/json', 'Cache-Control': 'private, max-age=0' }); var objstr= JSON.stringify(obj); util.debug('SENDJSON:'+objstr); res.end(objstr ); } // mongo functions var mongodb = require('mongodb') var mongo = { mongo:mongodb, db: null, } mongo.init = function( opts, win, fail ){ util.log('mongo: '+opts.host+':'+opts.port+'/'+opts.name) mongo.db = newmongodb.Db( opts.name, newmongodb.Server(opts.host, opts.port, {}), {native_parser:true,auto_reconnect:true}); mongo.db.open(function(){ if(opts.username ) { mongo.db.authenticate( opts.username, opts.password, function(err){ if(err) { fail && fail(err) } else { win && win(mongo.db) } }) } else { win&& win(mongo.db) } },fail) } mongo.res = function( win, fail ){ returnfunction(err,res) { if( err ){ util.log('mongo:err:'+JSON.stringify(err)); fail&& 'function' == typeof(fail) && fail(err); } else { win&& 'function' == typeof(win) && win(res); } } } mongo.open = function(win,fail){ mongo.db.open(mongo.res(function(){ util.log('mongo:ok'); win&& win(); },fail)) } mongo.coll = function(name,win,fail){ mongo.db.collection(name,mongo.res(win,fail)); } exports.mongo = mongo
代码片段位于lifestream/server/common.js
这个common.js的更新版本支持MongoDB验证,这需要使用MongoHQ的云数据库服务。
(4) 在文件夹lifestream/server下新建一个名为server.mongo.js的文件,将下面的代码插入到该文件中。
var common = require('./common.js') var config = common.config var mongo = common.mongo var util = common.util var connect = common.connect var knox = common.knox var uuid = common.uuid var oauth = common.oauth var url = common.url var request = common.request var Cookies = common.Cookies // API functions function search(req,res){ var merr = mongoerr400(res) mongo.coll( 'user', function(coll){ coll.find( {username:{$regex:new RegExp('^'+req.params.query)}}, {fields:['username']}, merr(function(cursor){ var list = [] cursor.each(merr(function(user){ if( user ) { list.push(user.username) } else { common.sendjson(res,{ok:true,list:list}) } })) }) ) } ) } function loaduser(req,res) { var merr = mongoerr400(res) finduser(true,['username','name','following','followers','stream'], req,res,function(user) { var userout = { username: user.username, name: user.name, followers: user.followers, following: user.following, stream: user.stream } common.sendjson(res,userout) }) } function register(req,res) { var merr = mongoerr400(res) mongo.coll( 'user', function(coll){ coll.findOne( {username:req.json.username}, merr(function(user){ if( user ) { err400(res)() } else { var token = common.uuid() coll.insert( { username: req.json.username, token: token, followers: [], following: [], stream: [] }, merr(function(){ common.sendjson(res,{ok:true,token:token}) }) ) } }) ) } ) } // utility functions function finduser(mustfind,fields,req,res,found){ var merr = mongoerr400(res) mongo.coll( 'user', function(coll){ var options = {} if( fields ) { options.fields = fields } coll.findOne( {username:req.params.username}, options, merr(function(user){ if( mustfind && !user ) { err400(res) } else { found(user,coll) } }) ) } ) } function mongoerr400(res){ return function(win){ return mongo.res( win, function(dataerr) { err400(res)(dataerr) } ) } } function err400(res,why) { return function(details) { util.debug('ERROR 400 '+why+' '+details) res.writeHead(400,''+why) res.end(''+details) } } function collect() { return function(req,res,next) { if( 'POST' == req.method ) { common.readjson( req, function(input) { req.json = input next() }, err400(res,'read-json') ) } else { next() } } } function auth() { return function(req,res,next) { var merr = mongoerr400(res) mongo.coll( 'user', function(coll){ coll.findOne( {token:req.headers['x-lifestream-token']}, {fields:['username']}, merr(function(user){ if( user ) { next() } else { res.writeHead(401) res.end(JSON.stringify({ok:false,err:'unauthorized'})) } }) ) } ) } } var db = null var server = null mongo.init( { name: config.mongohq.name, host: config.mongohq.host, port: config.mongohq.port, username: config.mongohq.username, password: config.mongohq.password, }, function(res){ db = res var prefix = '/lifestream/api/user/' server = connect.createServer( connect.logger(), collect(), connect.router(function(app){ app.post( prefix+'register', register) ,app.get( prefix+'search/:query', search) }), auth(), connect.router(function(app){ app.get( prefix+':username', loaduser) }) ) server.listen(3009) }, function(err){ util.debug(err) } )
代码片段位于lifestream/server/server.mongo.js
(5) 在文件夹lifestream/server下新建一个名为accept.mongo.js的文件,将下面的代码插入到该文件中。
var common = require('./common.js') var config = common.config var util = common.util var request = common.request var assert = require('assert') var eyes = require('eyes') var urlprefix = 'http://'+config.server+':3009/lifestream/api' var headers = {} function handle(cb) { return function (error, response, body) { if( error ) { util.debug(error) } else { var code = response.statusCode var json = JSON.parse(body) util.debug(' '+code+': '+JSON.stringify(json)) assert.equal(null,error) assert.equal(200,code) cb(json) } } } function get(username,uri,cb){ util.debug('GET '+uri) request.get( { uri:uri, headers:headers[username] || {} }, handle(cb) ) } function post(username, uri,json,cb){ util.debug('POST '+uri+': '+JSON.stringify(json)) request.post( { uri:uri, json:json, headers:headers[username] || {} }, handle(cb) ) } module.exports = { api:function() { var foo = (''+Math.random()).substring(10) var bar = (''+Math.random()).substring(10) // create and load ;post( null, urlprefix+'/user/register', {username:foo}, function(json){ assert.ok(json.ok) headers[foo] = { 'x-lifestream-token':json.token } ;get( foo, urlprefix+'/user/'+foo, function(json){ assert.equal(foo,json.username) assert.equal(0,json.followers.length) assert.equal(0,json.following.length) ;post( null, urlprefix+'/user/register', {username:bar}, function(json){ assert.ok(json.ok) headers[bar] = { ‚x-lifestream-token':json.token } ;get( bar, urlprefix+'/user/'+bar, function(json){ assert.equal(bar,json.username) assert.equal(0,json.followers.length) assert.equal(0,json.following.length) // search ;get( null, urlprefix+'/user/search/'+foo.substring(0,4), function(json){ assert.ok(json.ok) assert.equal(1,json.list.length) assert.equal(json.list[0],foo) ;}) // search ;}) // get ;}) // post ;}) // get ;}) // post } }
代码片段位于lifestream/server/accept.mongo.js
这是一个验收测试,用于测试运行中的服务器。每个测试案例都在前一个测试案例的回调函数中运行,要确保测试按顺序运行。
(6) 安装expresso测试框架,需要用它来运行accept.mongo.js脚本。
npm install expresso
(7) 打开一个新的终端窗口,并启动服务器。
node server.mongo.js 21 Mar 13:39:47 - mongo: flame.mongohq.com:27044/lifestream
(8) 打开另一个新的终端窗口,运行验收测试。
expresso accept.mongo.js DEBUG: POST http://192.168.100.112:3009/ lifestream/api/user/register: {"username":"707915425"} DEBUG: 200: {"ok":true, "token":"0C257205-AB94-4768-9FCC-A1B1321AD2A5"} DEBUG: GET http://192.168.100.112:3009/ lifestream/api/user/707915425 DEBUG: 200: {"username":"707915425", "followers":[],"following":[],"stream":[]} ...
服务器和验收测试都会生成调试输出,按顺序显示HTTP请求和响应。
(9) 转到MongoHQ网站,检查user集合的内容。在user集合中,应该看到两个文档
警告:验收测试需要一个真实的网络连接,因为服务器必需和远程的MongoHQ服务通信,以存储和检索数据。这就是它被称为验收测试而非单元测试的原因。根据定义,验收测试要有外部依赖。
示例说明
本章中的应用程序是一个完整的应用程序,包含许多不同的功能,它依赖很多npm模块。前面几章已经用过大多数的模块。以前没有用过的模块包括url、request和 cookies模块,这些模块都是处理HTTP请求的辅助模块。
本章还介绍了使用config.js文件存储服务器配置的概念。这只是前面章节中所使用的keys.js文件的扩展。创建用于生产的应用程序时,从实现中分离出配置,并且不在代码中嵌入配置的设置是一个好主意。
在本章的前面注册了MongoHQ,在前面的章节中也应该有Amazon、Twitter和Facebook的键,可以使用这些键来填写设置。
本章的common.js文件包括了前几章的所有实用功能。这些实用功能让你很容易在HTTP API中处理JSON的请求和响应,以及使用MongoDB的API。还有一个额外的功能。为了使用MongoHQ服务,需要登录到数据库。可以使用下面的代码。
mongo.db.open(function(){ if( opts.username ) { mongo.db.authenticate( opts.username, opts.password, function(err){ ...
为了更便于管理,在本章中添加新功能时,服务器端的代码会存储在单独的server.*.js文件中。该示例的文件名为server.mongo.js。通过本章的介绍可以比较这些文件,以帮助理解。服务器端的代码遵循前面章节中使用的结构。首先,有主要的API函数,然后是一些实用功能,之后是connect模块配置。
在本示例中,实现了搜索函数、用户注册及获取用户详细信息的函数。搜索函数使用MongoDB的正则表达式搜索功能,寻找一个与给定前缀相匹配的用户名。这仅仅是在应用程序中实现用户搜索功能的一个简单方法。代码使用mongoerr400实用函数处理MongoDB出现错误,如果出现问题,将HTTP 400状态码返回给所有客户端。在本节的后面会解释这是如何工作的。下面的代码解释了搜索函数的工作原理。
coll.find( {username:{$regex:new RegExp('^'+req.params.query)}}, {fields:['username']}, merr(function(cursor){ var list = [] cursor.each(merr(function(user){ if( user ) { list.push(user.username) } else { common.sendjson(res,{ok:true,list:list}) }
这段代码中的第一行粗体行显示了在MongoDB中如何使用正则表达式查询。它遵循标准的MongoDB查询语法。查询的值是作为HTTP请求的参数提供的,由传递到函数的req对象暴露。
第二行粗体行显示了如何限制从MongoDB结果返回的字段。这样,可以避免返回每个用户的所有数据。如果只是想要匹配的用户名列表,返回数据的所有字段就是资源浪费。
查询的结果作为cursor对象返回。为了使用该对象,要为它的each函数提供一个回调。对于结果集中的每一项,都会调用回调函数。这和传统的SQL数据库游标工作的方式非常相似。当所有的项都被返回之后,将得到一个空(null)的对象,这是停止的信号。if语句检查用户参数是否为空,如果为空,返回JSON结果。否则,它持续追加用户名到JSON结果中。
loaduser函数将大部分的工作交给finduser实用函数。这个函数不返回纯粹的数据库结果,因为这样做可能会公开内部系统的细节,如MongoDB的id字段。相反,loaduser函数只返回指定的数据集。以这种方式显式地过滤数据可能看起来有点偏执,但它是一个很好的安全经验法则。
继续向下阅读脚本文件,在实用函数部分的finduser实用函数,完成实际到数据库中寻找用户的工作。关键的代码是调用集合对象的findOne函数,执行用户搜索。
coll.findOne( {username:req.params.username},
用户名被指定为HTTP请求的参数。在API使用的URL结构中,对于针对用户的请求,用户名必须是URL路径的一部分。
注册函数与finduser函数非常相似,不同之处在于:如果用户不存在,它会执行一个操作。如果无法找到给定的用户名,说明用户不存在,可以注册。注册是由下面的insert操作执行的。
var token = common.uuid() coll.insert( { username: req.json.username, token: token, followers:[], following:[], stream: [] },
token是一个特殊的字段,用来验证用户的身份。uuid模块提供了一种方式,可以生成一个长的、随机的、唯一的字符串,特别适合作为令牌。因为这个示例的重点放在构建应用程序,而不是用户管理功能,所以没有实现密码系统。相反,代码采用了一条捷径。注册使用了先到先得的机制。令牌返回到客户端应用程序,客户端永久保存它。此令牌可以用来访问API。实际上它是一个永久的登录令牌。在生产环境中不应使用这种设计,但在这里可以用它模拟用户管理的逻辑,从而演示注册和认证,以及后来与Facebook和Twitter的集成。在开发过程中,需要删除全部现有的登录。可以通过删除并重新安装应用程序来实现这一点。如果在浏览器中测试示例,则只需要从本地存储系统中删除user项。
用户名的值来自于标准Node请求对象的json属性,这看起来相当奇怪。json属性是collect实用函数注入请求对象中的自定义属性,它包含了请求提交的任何JSON内容的解析值。collect函数截获HTTP的POST请求,通过使用common.readjson函数取得其内容。
function collect() { return function(req,res,next) { if( 'POST' == req.method ) { common.readjson( req, function(input) { req.json = input next() }, err400(res,'read-json') ) } else { next() } } }
collect函数特殊的另一个原因是,它实际上是connect模块中间件函数。中间件函数可以对HTTP请求做一些处理,然后将请求向前传递给其余的服务器。它处于请求的中间,因此而得名。connect模块是中间件函数的堆栈,每个函数都对请求做了一些工作。在前面的章节中,使用标准的router中间件定义自己的URL终点。在本示例中,建立了自己的中间件!
要定义connect中间件函数,需要编写一个函数,使用一些配置参数(collect还没有用到),并返回一个函数。函数接受三个参数:请求、响应和一个特殊的next函数。这是体现JavaScript强大功能的另一个示例:可以使用函数来动态构建另一个函数。
collect中间件函数的实际工作是在动态函数中完成的。检查POST请求,读取JSON信息,并设置req对象的自定义json属性。处理完后,调用特定的next函数。这样connect知道中间件已经完成处理工作,可以将请求传递到下一阶段进行处理。
如果出现错误该如何处理?因为正在建立一个可以独立于应用程序使用的API,所以需要确保很好地遵循HTTP协议。这意味着,如果是因为输入而产生错误,就需要返回一个400 BadRequest的状态代码。可以使用err400实用函数来执行这项任务,该函数创建了一个函数来完成实际工作。这样,就可以为代码不同的部分定义相应的错误消息。此外,mongoerr400函数针对MongoDB的错误创建了一个特殊的错误处理函数。正如在代码中看到的,可以使用这些函数,通过调用它们来为每个顶层API函数创建自定义的错误函数,如下所示。
var merr = mongoerr400(res)
注意:在本示例中的错误处理代码总是返回400 Bad Request的状态代码。严格地说,如果是因为你而导致的错误(例如,如果数据库连接中断),应该返回一个500 Internal Server Error的状态代码。collect中间件函数不是该服务器代码中唯一的中间件函数。还有一个auth中间件函数用来处理用户身份验证。只有登录的用户才可以调用某些API。这可以防止其他用户访问他人的私人资料。auth中间件函数处于这些API调用之前,用于检查请求是否来自已经登录的用户。这就是为什么代码的connect部分被分成两个路由器部分:第一个是未经验证的动作,如登记和查询;第二个是已通过验证的动作,如获取用户的详细信息或关注其他用户。
auth函数与finduser函数类似,都是通过用户名查找用户。它也需要一个自定义的HTTP头X-Lifestream-Token,其中包含的注册令牌必须与用户存储的令牌匹配。如果验证失败,返回HTTP401状态代码,表示这是未经授权的访问。否则,调用next函数,请求开始处理。下面的代码执行令牌搜索。
coll.findOne( {token:req.headers['x-lifestream-token']}, {fields:['username']}, merr(function(user){ if( user ) { next() } else { res.writeHead(401) res.end(JSON.stringify({ok:false,err:'unauthorized'})) } }) )
最后一部分代码,在创建到MongoDB数据库的连接后,设置了connect中间件堆栈。这些按顺序放在一起的函数实现了API结构的中间件。
不应该只是手动测试该服务器。也应该使用一套标准测试来验证API操作正常与否。可以通过构建验收测试实现这一点。使用Node的expresso模块,构建单元和验收测试。虽然应该创建单元测试和验收测试,但该示例的重点是验收测试。单元测试和验收测试之间的区别是什么呢?验收测试依赖于外部资源,而单元测试则不是。为了测试服务器可以正常使用MongoHQ(外部资源)工作,需要验收测试。
验收测试的代码位于accept.mongo.js脚本中。主服务器运行时,在一个单独的终端中运行该脚本。expresso模块将运行,测试任何被放置在特殊exports变量中的函数。在本示例中的代码只有一个主要测试:api函数。此函数包含了一组测试,按顺序运行并执行API的操作。
在此使用了common.js文件以避免重复代码。handle、get和post函数是实用函数,用来跟踪HTTP的请求,当它们到达时输出结果。因此,可以运行测试,看看直接会发生什么,这对于调试是非常有用的。
测试本身是API调用的序列。运行测试时,会注册两个用户,会请求它们的数据,并执行了一个搜索。
;post( null, urlprefix+'/user/register', {username:foo}, function(json){ ... headers[foo] = { 'x-lifestream-token':json.token } ;get( foo, urlprefix+'/user/'+foo, function(json){ ... ;post( null, urlprefix+'/user/register', {username:bar}, function(json){ ... headers[bar] = { 'x-lifestream-token':json.token } ;get( bar, urlprefix+'/user/'+bar, function(json){ ... // search ;get( null, urlprefix+'/user/search/'+foo.substring(0,4),
使用约定格式化代码,避免了很多恼人的缩进。因为每个测试必须在前一个测试的回调中执行,所以通常会在屏幕右侧结束代码缩进。为了避免这种情况,可以在行开始的地方使用分号(;)字符。这使你能重置缩进级别。要确保正确关闭了所有的括号,这样它们和注释在结尾以相反的顺序列出。还有其他的方法,通过使用各种库来解决这个格式的问题。它们在更复杂的情况是有用的,但在目前这种情况下,有一个简单的线性执行流程与约定,很容易就可以保持代码相对整洁。
《移动云计算应用开发入门经典》试读电子书免费提供,有需要的留下邮箱,一有空即发送给大家。 别忘啦顶哦!