swoole 第15章 常见的websocket问题

2021-06-09

一、引言

上一节我们讲述了websocket在swoole中的使用,并且我们也给出了一个简单的聊天模型,不同的客户端可以相互发消息。有些同学不以为然,server有swoole提供强大的API,客户端由h5提供websocket API,操作很方便,没感觉到什么问题呀,这一章节是否有存在的必要性呢?

有,非常有。今天我们就针对websocket中常见的几个问题做一个详细的总结说明,具体要说的重点大概有下面3个

  • 心跳检测的必要性
  • 校验客户端连接的有效性
  • 客户端的重连机制

二、心跳检测

还记得我们在进程模型一文中介绍的Master进程吗?当时我们说过,Master进程,包括主线程,多个Reactor线程等。其实主进程内还包括其他线程,比如我们现在讲的心跳检测,在Master进程内就有专门用于心跳检测的线程。

那到底什么是心跳检测呢?说着websocket,怎么谈到要医治病人了?这个心跳检测呢,是server定时检测客户端是否还连接的意思,即server定时检测client是否还活着,所以我们说的专业点就是所谓的心跳检测。

等等,老师你说“定时检测”?是不是说之前学的定时器可以派上用场了?

怎么感觉之前讲的不教你在实际场景中运用一次你就不会似的。当然,你要是用定时器也没问题,不过呢,我们都说有专门的心跳检测线程的存在了,所以,我们只需要简单的配置,开启这个心跳检测线程就可以了。

有同学还有疑问,server我们有onClose回调,客户端断开连接我们可以主动关闭连接或者删除客户端的映射关系,再者说,即使连接无效,断了就断了呗,反正我的server面向的client也没有多少,心跳检测就真的有存在的必要性么?

正常情况下,不需要。客户端断开连接能够通知到server,server自然也就可以主动关闭连接。但是,有很多非正常情况的存在,比如断电断网尤其是移动网络盛行的当下,二者之间建立的友好关系(连接)非常不稳定,这就必然会导致大量的fd(fd的数量是有限的,还记得最大是多少吗?)被浪费!所以为了解决这些问题,swoole内置了心跳检测机制。

我们只需要做如下简单的配置即可

<span style="font-size: 16px;">$serv->set([
    "heartbeat_check_interval" => N,
    "heartbeat_idle_time" => M,
]);
</span>

如上,分别配置heartbeat_check_interval和heartbeat_idle_time参数,二者配合使用,其含义就是N秒检查一次,看看哪些连接M秒内没有活动的,就认为这个连接是无效的,server就会主动关闭这个无效的连接。

是不是说N秒server会主动向客户端发一个心跳包,没有收到客户端响应的才认为这个连接是死连接呢?那还要heartbeat_idle_time做什么,对吧?

swoole的实现原理是这样的:server每次收到客户端的数据包都会记录一个时间戳,N秒内循环检测下所有的连接,如果M秒内该连接还没有活动,才断开这个连接。

心跳检测的问题,记得自己动手实践实践哦,有不懂的可以下面给我留言。

三、校验客户端连接的有效性

按照我们上文创建的websocket server,当然只有本地的ip才能连接上,因为server监听的ip是127.0.0.1。实际项目上线后,如果你的websocket server是对外开放的,就需要把ip修改为服务器外网的ip地址或者修改为0.0.0.0。

如此,也便带来了新的问题:

任意客户端都可以连接到我们的server了,这个“任意”可不止我们自己认为有效的客户端,还包括你的我的所有的非有效或者恶意的连接,这可不是我们想要的。

如何避免这一问题呢?方法有很多种,比如我们可以在连接的时候认为只有get传递的参数valid=1才允许连接;或者我们只允许登录用户才可以连接server;再或者我们可以校验客户端每次send所携带的token,server对该值校验通过后才认为当前是有效连接等等。与此同时,server开启心跳检测,对于恶意无效的连接,直接干掉!

上面简单的介绍了一些解决方案,下面我们以client 连接server时携带token为例做一个实际说明。

首先我们只允许登录用户才可以连接server,假设某用户的唯一标识uid=100,token的生成规则我们约定如下:token=md5(md5(uid)+key),其中key=客户端和服务端双方约定的某个字符串,我们这里假设key="^www.lulublog.cn&swoole$",不包括双引号。

server的代码实现如下

<span style="font-size: 16px;"><?php
class WebSocketServerValid
{
    private $_serv;
    public $key = "^www.lulublog.cn&swoole$";
    public function __construct()
    {
        $this->_serv = new swoole_websocket_server("0.0.0.0", 9501);
        $this->_serv->set([
            "worker_num" => 1,
            "heartbeat_check_interval" => 30,
            "heartbeat_idle_time" => 62,
        ]);
        $this->_serv->on("open", [$this, "onOpen"]);
        $this->_serv->on("message", [$this, "onMessage"]);
        $this->_serv->on("close", [$this, "onClose"]);
    }
    /**
     * @param $serv
     * @param $request
     */
    public function onOpen($serv, $request)
    {
        $this->checkAccess($serv, $request);
    }
    /**
     * @param $serv
     * @param $frame
     */
    public function onMessage($serv, $frame)
    {
        $this->_serv->push($frame->fd, "Server: " . $frame->data);
    }
    public function onClose($serv, $fd)
    {
        echo "client {$fd} closed.\n";
    }
    /**
     * 校验客户端连接的合法性,无效的连接不允许连接
     * @param $serv
     * @param $request
     * @return mixed
     */
    public function checkAccess($serv, $request)
    {
        // get不存在或者uid和token有一项不存在,关闭当前连接
        if (!isset($request->get) || !isset($request->get["uid"]) || !isset($request->get["token"])) {
            $this->_serv->close($request->fd);
            return false;
        }
        $uid = $request->get["uid"];
        $token = $request->get["token"];
        // 校验token是否正确,无效关闭连接
        if (md5(md5($uid) . $this->key) != $token) {
            $this->_serv->close($request->fd);
            return false;
        }
    }
    public function start()
    {
        $this->_serv->start();
    }
}
$server = new WebSocketServerValid;
$server->start();
</span>

可以看到,checkAccess是授权方法,我们在onOpen回调内对uid以及token进行了校验,无效则关闭连接。

为了模拟效果,我们分别贴上两种客户端代码,连接失败和连接成功

连接失败的主要jsdiamante如下

<span style="font-size: 16px;">var ws = new WebSocket("ws://139.199.201.210:9501");
ws.onopen = function(event) {
    ws.send("This is websocket client.");
};
ws.onmessage = function(event) {
    console.log(event.data);
};
ws.onclose = function(event) {
    console.log("Client has closed.\n");
};
</span>

无论是console控制台还是server终端我们都可以看到客户端连接被关闭的提醒。下面我们再看模拟一种成功的结果

php代码和js代码如下

<span style="font-size: 16px;"><?php
$key = "^www.lulublog.cn&swoole$";
$uid = 100;
$token = md5(md5($uid) . $key);
?>
<script>
var ws = new WebSocket("ws://139.199.201.210:9501?uid=<?php echo $uid; ?>&token=<?php echo $token; ?>");
ws.onopen = function(event) {
    ws.send("This is websocket client.");
};
ws.onmessage = function(event) {
    console.log(event.data);
};
ws.onclose = function(event) {
    console.log("Client has closed.\n");
};
</script>
</span>

可以看到,这次连接没有被关闭且console控制台会正常输出一些信息

<span style="font-size: 16px;">Server: This is websocket client.
</span>

即我们完成了校验连接有效性的案例,下面我们接着看最后一个问题

四、客户端重连机制

有同学注意到,我们刚刚设置的心跳检测时间是30秒,如果客户端62秒内没有与server通信,server会关闭该连接,即部分人在上述success案例中的console控制台上会看到Client has closed.的提醒。这是我们设置的机制,属于正常现象。

那我们要说的重连机制又是什么呢?

客户端重连机制又可以理解为一种保活机制,你也可以跟服务端的心跳检测在一起理解为双向心跳。即我们有一种需求是,如何能保证客户端和服务端的连接一直是有效的,不断开的。

其实很简单,对客户端而言,只要触发error或者close再或者连接失败,就主动重连server,这便是我们的目的。

下面贴一段代码,来解决这个问题

<span style="font-size: 16px;"><script>
    var ws; //websocket实例
    var lockReconnect = false; //避免重复连接
    var wsUrl = "ws://127.0.0.1:9501";

    function createWebSocket(url) {
        try {
            ws = new WebSocket(url);
            initEventHandle();
        } catch (e) {
            reconnect(url);
        }
    }

    function initEventHandle() {
        ws.onclose = function() {
            reconnect(wsUrl);
        };
        ws.onerror = function() {
            reconnect(wsUrl);
        };
        ws.onopen = function() {
            //心跳检测重置
            heartCheck.reset().start();
        };
        ws.onmessage = function(event) {
            //如果获取到消息,心跳检测重置
            //拿到任何消息都说明当前连接是正常的
            heartCheck.reset().start();
        }
    }

    function reconnect(url) {
        if (lockReconnect) return;
        lockReconnect = true;
        //没连接上会一直重连,设置延迟避免请求过多
        setTimeout(function() {
            createWebSocket(url);
            lockReconnect = false;
        }, 2000);
    }

    //心跳检测
    var heartCheck = {
        timeout: 60000, //60秒
        timeoutObj: null,
        serverTimeoutObj: null,
        reset: function() {
            clearTimeout(this.timeoutObj);
            clearTimeout(this.serverTimeoutObj);
            return this;
        },
        start: function() {
            var self = this;
            this.timeoutObj = setTimeout(function() {
                //这里发送一个心跳,后端收到后,返回一个心跳消息,
                //onmessage拿到返回的心跳就说明连接正常
                ws.send("");
                self.serverTimeoutObj = setTimeout(function() { //如果超过一定时间还没重置,说明后端主动断开了
                    ws.close(); //如果onclose会执行reconnect,我们执行ws.close()就行了.如果直接执行reconnect 会触发onclose导致重连两次
                }, self.timeout);
            }, this.timeout);
        }
    }

    createWebSocket(wsUrl);
</script>
</span>

在这种情况下,你可以尝试把server中断或者断网试试,结果是client会不停的每隔一定时间尝试连接server,直至连接成功。

五、完整代码

完整代码


阅读 294