swoole基础-常见的websocket问题

上一节我们讲述了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内置了心跳检测机制。

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

1
2
3
4
$serv->set([
'heartbeat_check_interval' => N,
'heartbeat_idle_time' => M,
]);

如上,分别配置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=”^manks.top&swoole$”,不包括双引号。

server的代码实现如下(详细的代码参考WebSocketServerValid.php)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<?php

class WebSocketServerValid
{
private $_serv;
public $key = '^manks.top&swoole$';

public function __construct()
{
$this->_serv = new swoole_websocket_server("127.0.0.1", 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();

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

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

连接失败的主要jsdiamante如下(详细代码见源码的websocket-client-faild.html)

1
2
3
4
5
6
7
8
9
10
var ws = new WebSocket('ws://127.0.0.1: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');
};

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

部分php代码和js代码如下(详细代码见源码的websocket-client-success.html)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
$key = '^manks.top&swoole$';
$uid = 100;
$token = md5(md5($uid) . $key);
?>

<script>
var ws = new WebSocket("ws://127.0.0.1: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>

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

1
Server: This is websocket client.

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

客户端重连机制

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

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

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

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

下面贴一段js代码,来解决这个问题(详细代码见commentClient.html)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<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>

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