统计在线用户数是一个很常见的功能,在这篇文章,我们主要讨论实现过程中的问题,以及介绍作者本人的实现方式。
以作者的经验看来,统计在线用户数主要有以下2个问题:
1. 何时记录为在线状态,何时记录为离线状态
这个问题白说了就是我们如何来判断用户是在线还是离线。对于在线判断还是比较容易的,只要用户访问了页面,或者调用了API,我们就知道用户是在线的,甚至我们可以给客户端开放一个心跳的接口,让客户端定时调用,表示客户端处于活动状态。但是对于离线的判断就比较困难了,大多数web应用是用http短连接的方式进行交互,不存在状态维护,客户端关掉浏览器甚至关机,服务端是不知道的。因此作者的做法是统计在1分钟之内(或者3分钟之内,随便你喜欢)的在线数,向精确靠拢。
2. 客户端唯一标识
当服务端接收到多个请求时,我们如何确定是同一个客户端发出还是多个客户端发出的?对于必须要注册才能使用的应用来说,这个比较简单,用户的id就是客户端的唯一标识。而对于允许游客访问的应用,我们就要想办法给客户端制造一个唯一标识:
移动应用客户端- 可以使用设备的UUID,硬件序列号等信息,作为客户端唯一标识。
浏览器应用客户端- 可以使用浏览器的user-agent信息,加上客户端ip,作为客户的唯一标识。
但是我们还需要考虑到这样的场景,对于一家公司/机构/单位,或者是一栋大楼,拥有数十台甚至成百上千台电脑,但是对于出口网络,仅仅只有1个或者数个IP。如果仅仅使用 user-agent + client-ip 来作唯一标识,那么很容易造成客户端身份重复,实际一栋大楼里有100个在线用户,但很可能只识别出20个不同的客户端。
那么客户端用硬件的信息(如网卡MAC地址等)来作唯一标识呢?作者认为对于浏览器客户端来说,要获取这样的硬件信息十分困难,而且这样意味着对客户端过分的依赖。
那么作者的做法是这样的:
使用 Redis的有序集 来记录用户的在线时间和客户端唯一标识。使用 ZCOUNT 命令可以很容易的统计出某个时间段内的用户数。
获取请求中 cookies 或 headers 中的 identifyid 数据,如果不存在,则使用: md5(user-agent + client-ip + Date.now()) 这样的规则来生成一个 UUID ,并写到cookies里响应给客户端,下次请求客户端就会附带上这个数据作为我们识别它的唯一标识。
Talk is cheap. Show me the code. -- Linus Torvalds
按照例常,我们先看看工程最后的结构:
我们还是使用 weroll 来构建这个项目,这里我们就不冗述了,请参考:
weroll 项目NPM主页
weroll - 快速搭建Node.js应用程序脚手架 (1)- 2分钟Demo
weroll - 快速搭建Node.js应用程序脚手架 (2)- 使用Schedule实现一个服务器性能监控应用首先我们来定义一个处理客户端心跳请求的API,客户端可以定时调用,保持自己的在线状态。我们将使用 Redis有序集 来维护客户端的在线状态。
Redis有序集的每一条数据由score和member组成,我们可以理解为分数和值。分数的作用是排序,而值是一个字符串,如果同一个值以不同的分数多次写入redis,则最近的一次分数将会覆盖之前的分数,同一个值在有序集中只有一条数据。关于Redis有序集命令可以参考 这里 。
我们把客户的UUID作为有序集的值,而把心跳触发的时间戳作为分数,这样客户的每一次心跳,都会刷新在Redis有序集中的排序位置。
/* ./server/service/UserService.js */ exports.config = { name: "user", enabled: true, security: { //@heartbeat 用户心跳 "heartbeat":{ needLogin:false } } }; var Redis = require("weroll/model/Redis"); //使用Redis的有序集来维护用户的在线时间 function keepAlive(identifyID, offline) { var redisKey = Redis.join("user_alive_sort"); if (offline) { //如果用户登出,则从有序集中删除用户信息 Redis.do("zrem", [ redisKey, identifyID ], function(err) { if (err) return console.error("Redis.zrem('user_alive_sort') error --> ", err); console.log( "user offline: " + identifyID ); }); } else { //如果用户登入或触发心跳,则刷新有序集中的用户最新在线时间 Redis.do("zadd", [ redisKey, Date.now(), identifyID ], function(err) { if (err) return console.error("Redis.zadd('user_alive_sort') error --> ", err); console.log( "keep user alive: " + identifyID ); }); } } exports.heartbeat = function(req, res, params) { //写入在线状态 //req._identifyID 表示客户端的UUID,由weroll自动生成 keepAlive(req._identifyID); //响应客户端 res.sayOK(); }
以上代码我们定义了一个名为 user.heartbeat 的API,用来处理客户端心跳。 keepAlive 方法则封装了对 Redis 的写数据操作。在weroll中如何使用Redis请参考官方文档 weoll - Guide : Redis 。
req._identifyID 表示客户端的UUID,由weroll自动生成。我附上它封装的主要代码给予参考,感兴趣的可以直接看 github上的源码 :
//解析客户端IP req._clientIP = Utils.parseIP(req); //尝试从cookies中获取UUID var identifyid = req.cookies.identifyid; if (!identifyid) { //生成UUID,并写到响应cookies里 identifyid = md5(req.headers["user-agent"] + req._clientIP + Date.now()); res.cookie("identifyid", identifyid); } req._identifyID = identifyid;
req._clientIP 标识客户端IP,weroll也在请求接收时进行了解析,开发者可以直接使用。相关的解析代码是这样的:
exports.parseIP = function (req) { try { //req.headers['X-Real-IP']和req.headers['X-Forwarded-For']用于 //解析由Nginx或其他web运行容器代理转发的请求的客户端IP var ip = req.headers['X-Real-IP'] || req.headers['X-Forwarded-For'] || req.connection.remoteAddress || req.socket.remoteAddress || req.connection.socket.remoteAddress; if (ip == "::1" || ip == "127.0.0.1") ip = "0.0.0.0"; return ip; } catch (err) { return "unknown" } }
以上就是心跳API的定义,我们已经完成了在线状态的写操作。接下来我们来实现读操作。
让我们来定义一个index.html页面的路由,创建 ./server/router/index.js 脚本:
/* ./server/router/index.js */ var Redis = require("weroll/model/Redis"); //计算当前的在线用户数, range表示N分钟以内的在线数 function countOnlineUser(range, callBack) { range = range * 60 * 1000; var now = Date.now(); var fromTime = now - range; Redis.do("zcount", [ Redis.join("user_alive_sort"), fromTime, "+inf" ], function(err, res) { if (err) return callBack(err); //redis返回的结果即是这个时段内的在线用户数 var result = { num:Number(res), fromTime:fromTime, toTime:now }; callBack(null, result); }); } function renderIndexPage(req, res, output, user) { //强制触发一次心跳 req.callAPI("user.heartbeat", {}, function () { //获得1分钟内的在线数 countOnlineUser(1, function(err, result) { //如果有异常,则跳到error.html页面来显示错误 if (err) return output(null, err); //渲染页面 output(result); }); }); } exports.getRouterMap = function() { return [ { url: "/", view: "index", handle: renderIndexPage, needLogin:false }, { url: "/index", view: "index", handle: renderIndexPage, needLogin:false } ]; }
获得在线用户数的关键就是 Redis的ZCOUNT 命令,开发者可以自己定义统计的时间段,比如1分钟内的,那么意味着如果客户端超过1分钟没有触发心跳,则在有序集中的位置就会下降,以至于被排除在时间段内。
最好我们来写一个简单的HTML页面来显示当前在线数:
<!-- ./client/views/index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>OnlineApp</title> </head> <body> <div style="padding: 20px; font-size:24px;"> 当前在线用户数: {{data.num}} </div> </body> </html>
weroll默认使用 nunjucks 作为模板引擎,详细使用请参考 nunjucks官方文档 。
最后启动项目,打开浏览器,使用两个不同的浏览器分别进入网址 http://localhost:3000/ 看看实际效果。
这里我们就不处理客户端定时调用心跳API了,各位看官可以自己用ajax来实现。
完整代码在github上: https://github.com/jayliang70...