0、前言
前面我们已经学过如何实现客户端心跳重连,不过如果我们服务端什么都依赖于客户端,那是不可取、也不合理的。
假设:
按客户端心跳重连操作,一直发包不成功,服务端就会一直等待该连接的请求,如果这时候服务器设置连接自动关闭时间为10分钟,
那么我们服务端是不是一定要等服务器自动回收连接呢?
按我们的想法,应该是服务端定时1分钟检测一次所有用户的心跳重连状态,只要重连次数超过3次,则自动关闭该连接。
1、注意事项
① fd连接身份标记需要加多一个关联参数,记录心跳重连次数
② 使用swoole的swoole_timer_tick()
异步毫秒定时器,该定时器必须放在$ws->start()
之前,同时必须放在$ws->on()
事件中启动,否则无效。
③ 注意swoole的swoole_timer_tick()
异步毫秒定时器只能触发一次,所以我们需要添加一个status
状态,在server启动时只触发执行一次即可。
④ swoole_timer_tick()
不能放在code=1之前,因为那是用户登录的广播请求,那时候还不存在user_id
无法进行强制检测广播,所以必须放在code=1
之后触发,(个人建议放置在客户端心跳重连的请求中触发,)。
⑤ swoole_timer_tick()
定时器每执行一次,便将所有连接中的心跳重连数+1
,而在信息请求事件中将心跳重连数清0
即可。
2、客户端代码修改:
<?php
// +----------------------------------------------------------------------
// 小黄牛blog - websocket
// +----------------------------------------------------------------------
// Copyright (c) 2018 https://xiuxian.junphp.com All rights reserved.
// +----------------------------------------------------------------------
// Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// Author: 小黄牛 <1731223728@qq.com>
// +----------------------------------------------------------------------
?>
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Swoole+Websocket案例 - 小黄牛</title>
<script src="./js/jquery.min.js"></script>
<style>
html,body{margin:0;padding:0;font-size:13px}
.left{width: 20%;height: 600px;border: 1px solid #ddd;float: left;}
.right{width: 79.7%;height: 400px;border: 1px solid #ddd;border-left: 0px;float: left;overflow: auto;}
.bottom{width: 79.7%;height: 199px;border: 1px solid #ddd;border-left: 0px;border-top: 0px;float: left;}
#content{width: 99.5%;height: 165px;}
.blue{color:blue}
.red{color:red}
.div_left{width:100%;float:left}
.div_right{width:100%;float:left;text-align: right;}
.div_centent{width:100%;float:left;text-align: center;}
</style>
</head>
<body>
<div id="USER"></div>
<div class="left">
<ul>
</ul>
</div>
<div class="right">
</div>
<div class="bottom">
<textarea id="content"></textarea>
<button type="button" id="submit">发送消息</button>
</div>
<div id="error"></div>
<h3>使用方法:</h3>
<p>①:CD进您的server.php文件目录</p>
<p>②:如果您是调试阶段,可以直接php server.php,激活程序,这样的话在运行过程中出错,能在cmd界面查看报错内容</p>
<p>③:如果您是部署阶段,可以使用nohup server.php >>/dev/null 2>&1 &命令,后台守护进程运行。</p>
</body>
</html>
<script>
// 生成一个唯一ID,假设这是userID
var USER_ID = "static" + Math.round(Math.random() * 10000);
$('#USER').html('您的USER_ID为:'+USER_ID);
var lockReconnect = false; // 正常情况下我们是关闭心跳重连的
var wsServer = 'ws://47.106.187.208:9502';
var websocket;
var time;
createWebSocket();
// ①开启WebSocket
function createWebSocket() {
try {
websocket = new WebSocket(wsServer);
init();
} catch(e) {
reconnect(wsUrl);
}
}
// ②初始化WebSocket,并设置心跳检测
function init() {
// 接收Socket断开时的消息通知
websocket.onclose = function(evt) {
$('#error').append('<p class="red">Socket断开了...正在试图重新连接...</p>');
reconnect(wsServer);
};
// 接收Socket连接失败时的异常通知
websocket.onerror = function(e){
$('#error').append('<p class="red">Socket断开了...正在试图重新连接...</p>');
reconnect(wsServer);
};
// 连接成功
websocket.onopen = function (evt) {
$('#error').append('<p class="blue">握手成功,打开socket连接了。。。</p>');
var data = {
'code':1, // 我们假设code为1时,是绑定登录请求
'user_id':USER_ID
};
// 前端发送json前,必须先转义成字符串
data = JSON.stringify(data);
console.log(data);
websocket.send(data);
// 心跳检测重置
heartCheck.start();
};
var message = '';
var flag = true;
// 接收服务端广播的消息通知
websocket.onmessage = function(evt){
heartCheck.start();
var obj = JSON.parse(evt.data);
// 登录广播
if (obj.code == 1) {
// 存在修改上线状态
if ($("#"+obj.user_id).length>0) {
$("#"+obj.user_id+' span').removeClass('blue');
$("#"+obj.user_id+' span').addClass('red');
$("#"+obj.user_id+' span').html('离线');
// 不存在,添加用户列表
} else {
$('.left ul').append('<li id="'+obj.user_id+'">'+obj.user_id+' <span class="blue">(在线)</span></li>');
}
console.log(evt.data);
$('.right').append('<div class="div_centent">'+obj.content+'</div>');
// 下线广播 或 服务端强制下线广播
} else if (obj.code == 2 || obj.code == 6) {
$("#"+obj.user_id+' span').removeClass('blue');
$("#"+obj.user_id+' span').addClass('red');
$("#"+obj.user_id+' span').html('离线');
$('.right').append('<div class="div_centent">'+obj.content+'</div>');
// 聊天消息广播
} else if (obj.code == 3) {
$('.right').append('<div class="div_left">'+obj.user_id+':'+obj.content+'</div>');
// 聊天界面默认自动底部
$('.right').scrollTop( $('.right')[0].scrollHeight );
// 如果是心跳检测的广播就不做任何操作
} else if (obj.code == 4){
return false;
// 检测是否后端发起了强制心跳检测,是则发送一次心跳检测
} else if (obj.code == 5) {
$('#error').append('<p class="red">服务端发起了一次强制心跳检测...</p>');
var data = {
'code':4, // 我们假设code为4时,既为心跳检测
'user_id':USER_ID
};
// 前端发送json前,必须先转义成字符串
data = JSON.stringify(data);
websocket.send(data);
}
};
}
// ③ 掉线重连
function reconnect(url) {
if(lockReconnect) {
return;
};
lockReconnect = true;
// 没连接上会一直重连,设置心跳延迟避免请求过多
time && clearTimeout(time);
time = setTimeout(function () {
createWebSocket(url);
lockReconnect = false;
}, 5000);
}
// ④心跳检测
var heartCheck = {
timeout: 5000,
timeoutObj: null,
serverTimeoutObj: null,
start: function() {
var self = this;
this.timeoutObj && clearTimeout(this.timeoutObj);
this.serverTimeoutObj && clearTimeout(this.serverTimeoutObj);
this.timeoutObj = setTimeout(function(){
// 这里发送一个心跳,后端收到后,返回一个心跳消息,
// onmessage拿到返回的心跳就说明连接正常
var data = {
'code':4, // 我们假设code为4时,既为心跳检测
'user_id':USER_ID
};
// 前端发送json前,必须先转义成字符串
data = JSON.stringify(data);
websocket.send(data);
}, this.timeout)
}
}
// 点击发送消息按钮
$('#submit').click(function(){
var content = $('#content').val();
$('.right').append('<div class="div_right">'+content+':'+USER_ID+'</div>');
var data = {
'code':3, // 我们假设code为3时,既为聊天消息广播请求
'user_id':USER_ID,
'content':content
};
// 前端发送json前,必须先转义成字符串
data = JSON.stringify(data);
websocket.send(data);
// 输入表单清空
$('#content').val('');
// 聊天界面默认自动底部
$('.right').scrollTop( $('.right')[0].scrollHeight );
});
</script>
3、服务端代码修改:
<?php
// +----------------------------------------------------------------------
// 小黄牛blog - Swoole 即时通讯交互处理
// +----------------------------------------------------------------------
// Copyright (c) 2018 https://xiuxian.junphp.com All rights reserved.
// +----------------------------------------------------------------------
// Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// Author: 小黄牛 <1731223728@qq.com>
// +----------------------------------------------------------------------
class Server{
/**
* 客户端身份存储器
*/
private $_data = [];
/**
* WS的启动实例
*/
private $_ws;
/**
* host-IP,0.0.0.0表示允许接收所有请求
*/
private $_host = '0.0.0.0';
/**
* 端口号
*/
private $_port = '9502';
/**
* 最大服务端心跳重连次数
*/
private $_max = 3;
/**
* 强制心跳重连启动状态
*/
private $_status = true;
/**
* 这是启动服务端的入口
*/
public function run() {
$this->start_service();
$this->start_handshake();
$this->start_message();
$this->end();
}
/**
* ①启动websocker服务
*/
private function start_service() {
# 创建websocket服务器对象,监听0.0.0.0:9502端口
$this->_ws = new swoole_websocket_server($this->_host, $this->_port);
}
/**
* ②监听WebSocket握手申请
*/
private function start_handshake() {
# 监听WebSocket连接打开事件
$this->_ws->on('open', function ($ws, $request){
# 加入身份存储器
$this->_data[$request->fd]['fd'] = $request->fd;
# 默认离线状态
$this->_data[$request->fd]['status'] = 0;
# 默认心跳重连数
$this->_data[$request->fd]['heartbeat'] = 0;
});
}
/**
* ③监听客户端消息发送请求
*/
private function start_message() {
# 监听WebSocket消息事件
$this->_ws->on('message', function ($ws, $frame) {
# 默认心跳重连数
$this->_data[$frame->fd]['heartbeat'] = 0;
$data = json_decode($frame->data, true);
# 登录广播处理
if ($data['code'] == 1) {
# 修改fd对应的身份
$this->_data[$frame->fd]['user_id'] = $data['user_id'];
# 设置上线状态
$this->_data[$frame->fd]['status'] = 1;
# 发送广播上线消息
$data['content'] = '【'.$data['user_id'].'】骑着小黄牛上线啦~!';
$this->broadcast($ws, $this->json($data), $frame->fd);
# 心跳重连检测
} else if ($data['code'] == 4) {
$this->broadcast($ws, $frame->data, $frame->fd);
$this->timer();
# 其他请求
} else {
# 广播消息
$this->broadcast($ws, $frame->data, $frame->fd);
}
});
}
/**
* ④监听客户端退出事件
*/
private function end() {
# 监听WebSocket连接关闭事件
$this->_ws->on('close', function ($ws, $fd) {
# 获取用户ID
$user_id = $this->_data[$fd]['user_id'];
# 设置离线状态
$this->_data[$fd]['status'] = 0;
$data = [
'code' => 2,
'user_id' => $user_id,
'content' => '【'.$user_id.'】骑着小扫帚灰溜溜的走了~~!'
];
# 广播消息
$this->broadcast($ws, $this->json($data));
});
$this->_ws->start();
}
/**
* 广播消息
* @todo 无
* @author 小黄牛
* @version v1.0.0.1 + 2018.11.12
* @deprecated 暂不弃用
* @global 无
* @param object $wx 实例
* @param string $content 广播内容
* @param array $array 实例数组
* @param bool $status 是否做心跳限制
* @return void
*/
private function broadcast($ws, $content, $id=null, $status=false) {
# 向所有人广播
foreach ($this->_data as $k=>$v) {
# 不向自己广播,并且要在线的
# 注意,这里一定要有上线状态的限制,否则假设用户已经退出,但你的进程还开着,实际上已经关闭,这时候push就会报错
# 只有正常在线的用户才能接收到广播
# 加入心跳检测限制
if ($k != $id && $v['status'] == 1 && $status == true) {
$ws->push($v['fd'], $content);
} else if ($k != $id && $v['status'] == 1 && $v['heartbeat'] == 0) {
$ws->push($v['fd'], $content);
}
}
}
/**
* 数组转json
* @todo 无
* @author 小黄牛
* @version v1.0.0.1 + 2018.11.08
* @deprecated 暂不弃用
* @global 无
* @param array $array 数组
* @return json
*/
private function json($array) {
return json_encode($array, JSON_UNESCAPED_UNICODE);
}
/**
* 服务端定时强制心跳检测
* @todo 无
* @author 小黄牛
* @version v1.0.0.1 + 2018.11.08
* @deprecated 暂不弃用
* @global 无
* @return void
*/
private function timer() {
# 注意强制心跳触发器不能放在open事件里,因为那时候用户还没有提交登录请求,是还没有userID的
# 还有,强制心跳定时器只能触发一次,否则会出现生成多个定时器的情况
if ($this->_status) {
$this->_status = false;
/**
* ⑤服务端强制心跳检测
* 每隔1分钟发送1次,如果连续3次强制心跳检测未通过,服务端将强制断开连接
*/
$obj = $this;
swoole_timer_tick(60000, function ($timer_id) use (&$obj) {
# 广播消息
$obj->broadcast($obj->_ws, $obj->json(['code' => 5]), null, true);
# 所有人的心跳次数+1
foreach ($obj->_data as $k=>$v) {
$obj->_data[$k]['heartbeat'] = $v['heartbeat']+1;
# 心跳次数大于等于_max && 在线的 的连接关闭
if ($v['heartbeat'] >= $obj->_max && $v['status'] == 1) {
if (empty($v['fd'])) {
continue;
}
$data = $v;
# 发送强制掉线广播
$data['code'] = 6;
$data['content'] = '【'.$data['user_id'].'】已被服务端强制下线!';
$obj->broadcast($obj->_ws, $obj->json($data), null, true);
# 这里不需要unset连接,因为在close事件中,已经将这个连接设置为离线了
# 主动关闭连接
$obj->_ws->close($v['fd']);
}
}
});
}
}
}
$socketServer = new Server();
$socketServer->run();
完整的案例DEMO,可以直接到我的开源栏目中进行下载:Swoole聊天室Demo之三
最后推荐大家可以用下我开源的一个基于Swoole4.5+研发的PHP框架。该框架基于注解实现了很多好玩的功能,很适合新人快速上手Swoole扩展。
SW-X框架-专注高性能便捷开发而生的PHP-SwooleX框架www.sw-x.cn