第2章 开始漂流瓶之旅 - nswbmw/N-drifter GitHub Wiki
现在,我们从头开始搭建一个简单的漂流瓶服务器。
接口设计
第一步,当然是需要设计好与服务器交互的接口,我们采用 JSON 形式的 API 接口,因为 Node.js 中对 HTTP 一流的支持,以及对 JSON 的友好让我们创建 JSON API 变得格外简单。
我们设定:
以 GET 加参数的形式访问服务器打捞一个漂流瓶,返回 JSON 数据:
GET /?user=xxx[&type=xxx]
// SUCCESS return
// {"code":1,msg:{"time":"...","owner":"...","type":"...","content":"..."}}
// ERROR return
// {"code":0,"msg":"..."}
GET 请求的参数:
user
: 捡漂流瓶的人的用户名或用户 id ,必须唯一。type
: 漂流瓶类型,这里我们设置三种类型:all 代表全部,male 代表男性,female 代表女性,缺省时默认为 all。
返回的 JSON 参数含义:
code
: 标识码,1 代表成功,0 代表出错。msg
: 返回的信息,错误时返回错误的信息,成功时返回漂流瓶的信息:time
: 漂流瓶扔出的时间戳。owner
: 漂流瓶主人,可以是用户名或用户 id ,但必须唯一。type
: 漂流瓶类型,为 male 或 female 之一。content
: 漂流瓶内容。
以 POST 形式请求服务器扔出一个漂流瓶,返回 JSON 数据:
POST owner=xxx&type=xxx&content=xxx[&time=xxx]
// SUCCESS return {"code":1,"msg":"..."}
// ERROR return {"code":0,"msg":"..."}
POST 请求的参数:
time
: 漂流瓶扔出的时间戳,缺省时设置为 Date.now() 。owner
: 漂流瓶主人,可以是用户名或用户 id ,但必须唯一。type
: 漂流瓶类型,为 male 或 female 之一。content
: 漂流瓶内容。
返回的 JSON 参数含义:
code
: 标识码,0 代表错误,1 代表正确。msg
: 返回正确或错误时的信息。
至此,API 已经设计好了,接下来我们根据设计的 API 来编写代码。
新建工程
首先,我们新建一个 drifter 文件夹作为我们的工程目录。然后新建 package.json ,添加 express 和 node_redis 模块:
{
"name": "drifter",
"version": "0.0.1",
"dependencies": {
"express": "^3",
"redis": "*"
}
}
运行 npm install
安装这两个模块。
新建 app.js ,添加如下代码:
var express = require('express');
var redis = require('./models/redis.js');
var app = express();
app.use(express.bodyParser());
// 扔一个漂流瓶
// POST owner=xxx&type=xxx&content=xxx[&time=xxx]
app.post('/', function (req, res) {
if (!(req.body.owner && req.body.type && req.body.content)) {
return res.json({code: 0, msg: "信息不完整"});
}
if (req.body.type && (["male", "female"].indexOf(req.body.type) === -1)) {
return res.json({code: 0, msg: "类型错误"});
}
redis.throw(req.body, function (result) {
res.json(result);
});
});
// 捡一个漂流瓶
// GET /?user=xxx[&type=xxx]
app.get('/', function (req, res) {
if (!req.query.user) {
return res.json({code: 0, msg: "信息不完整"});
}
if (req.query.type && (["male", "female"].indexOf(req.query.type) === -1)) {
return res.json({code: 0, msg: "类型错误"});
}
redis.pick(req.query, function (result) {
res.json(result);
});
});
app.listen(3000);
这里我们使用 Express 的 express.bodyParser()
中间件解析 POST 请求体,因为代码不多,所以我们把路由函数都写在了 app.js 中,并且设置监听 3000 端口。
其中 redis.js 模块有两个方法:
throw
: 扔一个瓶子pick
: 捡一个瓶子
在根目录下新建 models 文件夹,然后在 models 目录下新建 redis.js 用来存放与 Redis 数据库交互的代码。
扔一个
在实现扔瓶子的功能之前,我们再来学习一下 Redis 的一些相关知识。
前面我们介绍了 Redis 的一些基本命令的使用,如:
redis 127.0.0.1:6379> SET foo bar
OK
redis 127.0.0.1:6379> KEYS *
1) "foo"
redis 127.0.0.1:6379> GET foo
"bar"
其实这些操作都是在 Redis 的 0 号数据库中进行的。
什么是 0 号数据库?Redis 默认支持 16 个数据库,分别编号为 0、1、2 ... 14、15 。每个数据库都是独立的,也就是说你在 0 号数据库中插入的数据在 1 号数据库是访问不到的。客户端与 Redis 建立连接后自动选择 0 号数据库,我们可以使用 SELECT 命令来更换数据库,测试如下:
redis 127.0.0.1:6379> SET a 1
OK
redis 127.0.0.1:6379> KEYS *
1) "a"
redis 127.0.0.1:6379> SELECT 16
(error) ERR invalid DB index
redis 127.0.0.1:6379[16]> SELECT 15
OK
redis 127.0.0.1:6379[15]> SET b 2
OK
redis 127.0.0.1:6379[15]> KEYS *
1) "b"
redis 127.0.0.1:6379[15]> SELECT 0
OK
redis 127.0.0.1:6379> KEYS *
1) "a"
需要注意的是,Redis 不支持自定义数据库名字,如把 0 号数据库改为 index 是不行的。因为每个数据库都以编号命名,所以开发者必须要明确哪个数据库存放了哪些数据。另外,Redis 也不支持为每个数据库设置不同的密码,一个客户端要么可以访问全部的数据库,要么一个都不能访问。使用 FLUSHDB 可以清空当前数据库的所有内容,使用 FLUSHALL 则清空所有数据库的所有内容。
我们可以通过配置参数 database 修改支持的数据库个数。另外,当选择的数据库编号超过最大的数据库编号时,默认使用最大数据库编号的数据库,比如上面选择了 16 号这个不存在的数据库,Redis 会自动使用 15 号数据库,测试如下:
redis 127.0.0.1:6379[15]> SELECT 16
(error) ERR invalid DB index
redis 127.0.0.1:6379[16]> SET c 3
OK
redis 127.0.0.1:6379[16]> KEYS *
1) "c"
2) "b"
redis 127.0.0.1:6379[16]> SELECT 15
OK
redis 127.0.0.1:6379[15]> KEYS *
1) "c"
2) "b"
现在,我们来实现扔瓶子的功能,即完善 app.post('/')
。
我们可以把 Redis 想象成一片大海,Redis 中每一条哈希类型的数据就是一个漂流瓶,每个漂流瓶都有一个独一无二的 id(即 Redis 中的键),里面包含了漂流瓶的一些信息(即 Redis 中键的值):扔出漂流瓶的时间(time)、漂流瓶的主人(owner)、漂流瓶的类型(type)以及漂流瓶的内容(content)。
这里我们还需引入另外两个模块:generic-pool 和 node-uuid。打开 package.json,修改如下:
{
"name": "drifter",
"version": "0.0.1",
"dependencies": {
"express": "^3",
"redis": "*",
"node-uuid": "*",
"generic-pool": "*"
}
}
generic-pool 前面提到过,这里我们用它来创建和管理 redis 连接池。node-uuid 用来生成唯一 id 。
打开 redis.js ,添加如下代码:
var redis = require('redis');
var uuid = require('node-uuid');
var poolModule = require('generic-pool');
var pool = poolModule.Pool({
name : 'redisPool',
create : function(callback) {
var client = redis.createClient();
callback(null, client);
},
destroy : function(client) {
client.quit();
},
max : 100,
min : 5,
idleTimeoutMillis : 30000,
log : true
});
// 扔一个瓶子
function throwOneBottle(bottle, callback) {
bottle.time = bottle.time || Date.now();
// 为每个漂流瓶随机生成一个 id
var bottleId = uuid.v4();
var type = {male: 0, female: 1};
pool.acquire(function (err, client) {
if (err) {
return callback({code: 0, msg: err});
}
client.SELECT(type[bottle.type], function() {
// 以 hash 类型保存漂流瓶对象
client.HMSET(bottleId, bottle, function (err, result) {
if (err) {
return callback({code: 0, msg: "过会儿再试试吧!"});
}
// 设置漂流瓶生存期
client.EXPIRE(bottleId, 86400, function () {
// 释放连接
pool.release(client);
});
// 返回结果,成功时返回 OK
callback({code: 1, msg: result});
});
});
});
}
exports.throw = function(bottle, callback) {
throwOneBottle(bottle, function (result) {
callback(result);
});
}
以上代码的意思是:我们为漂流瓶分配了一个 id 当作存入 Redis 中的键,然后根据漂流瓶的 type 将漂流瓶放入不同的数据库。我们设定将 type 为 male 的漂流瓶放在 0 号数据库,将 type 为 female 的漂流瓶放在 1 号数据库。
注意:我们把 type 不同的瓶子放到不同的数据库中是为了方便使用 Redis 的 RANDOMKEY 命令(RANDOMKEY 用于随机返回当前数据库的一个键,不能加任何条件)。
有以下几点需要讲解:
-
我们使用 generic-pool 创建了一个 Redis 连接池,并设置至少有 5 个连接,最大有 100 个连接,连接的生存时间为 30 秒。其中,我们通过
var client = redis.createClient()
创建一个 redis 连接,用client.quit()
关闭一个连接。在创建连接时,我们也可以传入参数指定 host 和 port 和设置其他一些参数。具体使用方法见 node_redis 文档:redis.createClient(port, host, options)
-
为了保证漂流瓶的 id 唯一,我们使用 node-uuid 模块来生成不重复的字符串作为 key。实际生产环境下我们通常采用以冒号分隔的命名方式。
-
Redis 的 HMSET 命令可以同时将多个键值对设置到哈希表中。使用方法如下:
HMSET key field value [field value ...]
在 node_redis 中使用方法如下:
client.hmset(hash, key1, val1, ... keyn, valn, [callback]) // or client.hmset(hash, obj, [callback])
-
Redis 中的 EXPIRE 命令以秒为单位设置某个键的生存时间,PEXPIRE以毫秒为单位设置某个键的生存时间,这也是我们选择 Redis 搭建漂流瓶服务器的原因之一。这里使用
client.EXPIRE(bottleId, 86400);
设置每个漂流瓶的生存时间为 86400 秒即 1 天。过期后,该键值对会自动从数据库中移除,即该漂流瓶自动删除。
至此,我们完成了扔瓶子的功能。接下来,我们实现捡瓶子的功能。
捡一个
打开 redis.js ,添加如下代码:
// 捡一个瓶子
function pickOneBottle(info, callback) {
var type = {all: Math.round(Math.random()), male: 0, female: 1};
info.type = info.type || 'all';
pool.acquire(function (err, client) {
if (err) {
return callback({code: 0, msg: err});
}
// 根据请求的瓶子类型到不同的数据库中取
client.SELECT(type[info.type], function() {
// 随机返回一个漂流瓶 id
client.RANDOMKEY(function (err, bottleId) {
if (err) {
return callback({code: 0, msg: err});
}
if (!bottleId) {
return callback({code: 0, msg: "大海空空如也..."});
}
// 根据漂流瓶 id 取到漂流瓶完整信息
client.HGETALL(bottleId, function (err, bottle) {
if (err) {
return callback({code: 0, msg: "漂流瓶破损了..."});
}
// 从 Redis 中删除该漂流瓶
client.DEL(bottleId, function () {
// 释放连接
pool.release(client);
});
// 返回结果,成功时包含捡到的漂流瓶信息
callback({code: 1, msg: bottle});
});
});
});
});
}
exports.pick = function(info, callback) {
pickOneBottle(info, function (result) {
callback(result);
});
}
以上代码的意思是:根据 type 类型从不同数据库中返回任意一个漂流瓶,我们允许用户捡到自己的瓶子,瓶子被捡走后从 Redis 中删除该漂流瓶。当 type 为 all 时,随机选择一个数据库并随机返回一个漂流瓶。
有以下几点需要讲解:
- Redis 中的 RANDOMKEY 命令用于从当前数据库中随机返回(不删除)一个 key 。当数据库不为空时,返回一个 key ,当数据库为空时,返回 nil (node_redis 中为 null)。
- Redis 中的 HGETALL 命令用于返回哈希表中所有的键和值(node_redis 中返回一个包含所有键值对的对象)。
- Redis 中的 DEL 命令用于删除给定的一个或多个 key 。这里我们设定当某个漂流瓶信息被读取后,从 Redis 中删除该漂流瓶。
至此,一个简单的漂流瓶服务器就搭建成功了。
注意: 目前有个小小的 "bug" 。试考虑以下情况:大海中只有 type 为 male 的漂流瓶而没有 type 为 female 的漂流瓶了,当我们获取 type 为 all 的漂流瓶时,有二分之一可能性访问 famale 数据库,即返回 大海空空如也...。当人数众多时,这种情况几乎不可能发生,即使发生了,再捡几次瓶子就可以了,总会返回 type 为 male 的瓶子。后面会有一种委婉的方式处理这种问题。
使用 Request 测试
现在,我们来测试下漂流瓶服务器是否能正常运行,我们使用 request 模块来发送 POST 请求,往 Redis 中插入一些测试数据。
首先,在 package.json 中添加对 request 模块的依赖:
{
"name": "drifter",
"version": "0.0.1",
"dependencies": {
"express": "*",
"redis": "*",
"node-uuid": "*",
"generic-pool": "*"
},
"devDependencies": {
"request": "*"
}
}
然后 npm install
安装 request 模块。
在根目录下新建 test 文件夹,在该文件夹下新建 init_redis.js ,添加如下代码:
var request = require('request');
for (var i = 1; i <= 5; i++) {
(function(i) {
request.post({
url: "http://127.0.0.1:3000",
json: {"owner": "bottle" + i, "type": "male", "content": "content" + i}
});
})(i);
}
for (var i = 6; i <= 10; i++) {
(function(i) {
request.post({
url: "http://127.0.0.1:3000",
json: {"owner": "bottle" + i, "type": "female", "content": "content" + i}
});
})(i);
}
我们往 http://127.0.0.1:3000
POST 了 10 条 JSON 数据。假如不出错的话,Redis 的 0 号数据库存放了 bottle1 ~ bottle5 ,1 号数据库存放了 bottle6 ~ bottle10 。
为了测试我们的猜想,首先依次运行 redis-server 和 node app
启动我们的漂流瓶服务器,然后运行 node init_redis
插入一些初始化数据,最后运行 redis-cli ,输入如下指令:
redis 127.0.0.1:6379> KEYS *
1) "2644eb55-7e6a-432f-9dbb-ab805b3ea892"
2) "162054bc-de8f-4ae7-b94f-5aa0c75166d4"
3) "681c03bf-74a8-4510-9c06-67b1df5ceee2"
4) "f0554e28-2806-44dc-ae15-29ec9d4f64d7"
5) "7dd74236-8e36-4bbd-913c-81884572057f"
redis 127.0.0.1:6379> HVALS 681c03bf-74a8-4510-9c06-67b1df5ceee2
1) "bottle5"
2) "male"
3) "content5"
4) "1399718931130"
redis 127.0.0.1:6379> SELECT 1
OK
redis 127.0.0.1:6379[1]> KEYS *
1) "17ba9091-9781-4321-8e5c-91f4e9fc9820"
2) "aa7bbc24-6a54-4d78-ad39-c5d133e2a4f9"
3) "1ff77e5f-de1d-402a-b2ba-273b8117c6f3"
4) "92c28ec7-fee3-4f4f-9296-bffa5b108fe7"
5) "c713fa55-681c-47e5-a34c-492288457c1c"
redis 127.0.0.1:6379[1]> HVALS 1ff77e5f-de1d-402a-b2ba-273b8117c6f3
1) "bottle10"
2) "female"
3) "content10"
4) "1399718931149"
redis 127.0.0.1:6379[1]>
Redis 中 HVALS 命令用于返回给定哈希表中所有键和值。
怎么发送 GET 请求获取漂流瓶呢?当然是使用浏览器啦~ 打开浏览器,访问以下几个 url 试试吧:
- 127.0.0.1:3000?user=nswbmw
- 127.0.0.1:3000?user=nswbmw&type=all
- 127.0.0.1:3000?user=nswbmw&type=male
- 127.0.0.1:3000?user=nswbmw&type=female
注意: Chrome 浏览器下可用 Postman 插件模拟常见的 HTTP 请求。