第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 请求。