1

统计在线用户数是一个很常见的功能,在这篇文章,我们主要讨论实现过程中的问题,以及介绍作者本人的实现方式。

以作者的经验看来,统计在线用户数主要有以下2个问题:

1. 何时记录为在线状态,何时记录为离线状态

这个问题白说了就是我们如何来判断用户是在线还是离线。对于在线判断还是比较容易的,只要用户访问了页面,或者调用了API,我们就知道用户是在线的,甚至我们可以给客户端开放一个心跳的接口,让客户端定时调用,表示客户端处于活动状态。但是对于离线的判断就比较困难了,大多数web应用是用http短连接的方式进行交互,不存在状态维护,客户端关掉浏览器甚至关机,服务端是不知道的。因此作者的做法是统计在1分钟之内(或者3分钟之内,随便你喜欢)的在线数,向精确靠拢。

2. 客户端唯一标识

当服务端接收到多个请求时,我们如何确定是同一个客户端发出还是多个客户端发出的?对于必须要注册才能使用的应用来说,这个比较简单,用户的id就是客户端的唯一标识。而对于允许游客访问的应用,我们就要想办法给客户端制造一个唯一标识:
移动应用客户端 - 可以使用设备的UUID,硬件序列号等信息,作为客户端唯一标识。
浏览器应用客户端 - 可以使用浏览器的user-agent信息,加上客户端ip,作为客户的唯一标识。

但是我们还需要考虑到这样的场景,对于一家公司/机构/单位,或者是一栋大楼,拥有数十台甚至成百上千台电脑,但是对于出口网络,仅仅只有1个或者数个IP。如果仅仅使用 user-agent + client-ip 来作唯一标识,那么很容易造成客户端身份重复,实际一栋大楼里有100个在线用户,但很可能只识别出20个不同的客户端。

那么客户端用硬件的信息(如网卡MAC地址等)来作唯一标识呢?作者认为对于浏览器客户端来说,要获取这样的硬件信息十分困难,而且这样意味着对客户端过分的依赖。

那么作者的做法是这样的:

  1. 使用 Redis的有序集 来记录用户的在线时间和客户端唯一标识。使用 ZCOUNT 命令可以很容易的统计出某个时间段内的用户数。

  2. 获取请求中 cookiesheaders 中的 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

首先我们来定义一个处理客户端心跳请求的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...


哼亨哈唏
43 声望7 粉丝

主业奶爸,副业码农。