前言
官网地址:SW- X 框架 - 专一高性能便捷开发而生的 PHP-SwooleX 框架
心愿各大佬举起小手,给小弟一个 star:https://github.com/swoolex/swoolex
1、前端模板
最终要实现的成果,如下图:
该模板能够间接下载:练习 WebSocket 应用的前端 html 模板
也能够间接应用上面的前端代码,命名为:index.html
<!DOCTYPE HTML>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="renderer" content="webkit">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>SW-X | WebSocket 客户端示例 </title>
<script src="https://blog.junphp.com/public/js/jquery.min.js"></script>
<script src="jquery.md5.js"></script>
<script src="tim.js"></script>
<style>
body,html{margin: 0;padding: 10px; height: calc(100% - 30px);font-size: 13px;}
ul,li{list-style: none;padding: 0;margin: 0;}
.user_list{width: 200px; height: 100%; overflow: hidden; overflow-y: auto; padding: 10px;border: 1px solid #ccc;float: left;}
.user_list li{width: 100%;padding: 5px 0;cursor: pointer;}
.user_list li:hover{color: #0077d6;}
.main{width: calc(100% - 550px); height: 70%; overflow: hidden; overflow-y: auto; padding: 10px;border: 1px solid #ccc;float: left;border-left: 0;background: #e9f8ff;}
.content{width: calc(100% - 530px); height: calc(30% - 1px);border: 1px solid #ccc;float: left;border-left: 0;border-top: 0;position: relative;}
#content{width: calc(100% - 20px);;border: 0;height:calc(100% - 25px);padding: 10px;}
#content:focus{outline: none;}
code{padding: 3px 5px;border-radius: 30%; color: #fff;}
.online{background: #35b700;}
.offline{background: red;}
.record{float: left;width: 100%;padding: 5px 0;}
.record span{font-size: 12px; background: #ccc; border-radius: 5px; color: #0037ff;padding: 1px 3px;}
.other p{text-indent: 30px;padding: 0px;}
.own{text-align: right;}
.tips{text-align: center;font-size: 12px; color: #e80000;}
.drift{position: absolute;bottom: 10px; right: 10px;}
#send{background: #009e3f;border: 1px solid #009020;font-size: 14px;padding: 3px 10px;color: #fff;border-radius: 5px;cursor: pointer;}
#send:hover{background: #008234;border: 1px solid #005613;}
#open{background: #009e97;border: 1px solid #007974;font-size: 14px;padding: 3px 10px;color: #fff;border-radius: 5px;cursor: pointer;}
#open:hover{background: #008a84;border: 1px solid #00736e;}
#close{background: #ef0000;border: 1px solid #c30000;font-size: 14px;padding: 3px 10px;color: #fff;border-radius: 5px;cursor: pointer;}
#close:hover{background: #c50000;border: 1px solid #a00000;}
input{padding: 4px;}
.log{width: 326px;height: calc(100% - 40px);border: 1px solid #ccc;float: right;border-left: 0;position: absolute;right: 0;overflow: hidden;overflow-y: auto;}
.log div{width: calc(100% - 20px);padding:10px 10px 0 10px;}
</style>
</head>
<body>
<!-- 用户列表 -->
<div class="user_list">
<ul></ul>
</div>
<!-- 聊天窗口 -->
<div class="main"></div>
<!-- 输出窗口 -->
<div class="content">
<textarea id="content"></textarea>
<div class="drift">
<input id="host" type="text" placeholder="WS 地址" style="width: 700px;">
<input id="user_id" type="text" placeholder="输出 user_id">
<input id="username" type="text" placeholder="输出用户名">
<button id="open"> 连贯 </button>
<button id="close"> 断开 </button>
<button id="send"> 发送 </button>
</div>
</div>
<!-- 交互记录 -->
<div class="log"></div>
</body>
</html>
留神:最下面有一个 tim.js
文件须要你自行创立,后续的教程都只对该文件进行变更阐明而已。
2、服务端鉴权并记录用户信息
A、创立内存表
服务端外部应用内存表来缓存用户信息,以缩小推送交互时对 Mysql 的查问压力。
批改/config/swoole_table.php
,改成以下代码:
return [
[
'table' => 'user',// 用户信息表
'length' => 100000,// 表最大行数下限
'field' => [ // 字段信息
'fd' => [
'type' => \Swoole\Table::TYPE_INT, // swoole 的标识符
'size' => 13, // 字段长度限度
],
'user_id' => [
'type' => \Swoole\Table::TYPE_STRING, // 客户端 ID
'size' => 64,
],
'username' => [
'type' => \Swoole\Table::TYPE_STRING, // 用户名
'size' => 64,
],
'heart_num' => [
'type' => \Swoole\Table::TYPE_INT, // 心跳次数
'size' => 1, // 字段长度限度
],
]
],
[
'table' => 'fd',// fd 标识符反查表
'length' => 100000,
'field' => [
'user_id' => [
'type' => \Swoole\Table::TYPE_STRING,
'size' => 64,
],
]
]
];
B、连贯时鉴权
通过客户端在 ws 时的地址携带 GET 参数,能够进行 open
握手阶段的权限管制,避免而已连贯,同时还能够记录 [更新] 客户端的连贯信息,批改 /box/event/server/onOpen.php
代码:
namespace box\event\server;
// 引入内存表组件
use x\swoole\Table;
// 引入 websocket 控制器基类
use x\controller\WebSocket;
class onOpen
{
/**
* 启动实例
*/
public $server;
/**
* 对立回调入口
* @author 小黄牛
* @version v1.0.1 + 2020.05.26
* @param Swoole\WebSocket\Server $server
* @param Swoole\Http\Request $request HTTP 申请对象
*/
public function run($server, $request) {
$this->server = $server;
// 实例化客户端
$this->websocket = new WebSocket();
// 获取参数
$param = $request->get;
// 参数过滤
$res = $this->_param($param, $request->fd);
if (!$res) return false;
// 参数鉴权
$res = $this->_sign_check($param, $request->fd, $request);
if (!$res) return false;
// 将客户信息记录进 table 内存表
// 用户信息表
Table::table('user')->name($param['user_id'])->upsert([
'fd' => $request->fd,
'user_id' => $param['user_id'],
'username' => $param['username'],
]);
// 标识符反查 user_id 表
Table::table('fd')->name($request->fd)->upsert(['user_id' => $param['user_id'],
]);
// 播送上线音讯
$table = Table::table('user')->all();
foreach ($table as $key=>$info) {$data = ['user_id'=>$param['user_id'], 'username'=>$param['username'], 'status' => 1];
$this->websocket->fetch(10001, $param['username'].'骑着小黄牛 上线啦~', $data, $info['fd']);
}
return true;
}
/**
* 参数过滤
* @author 小黄牛
*/
public function _param($param, $fd) {if (empty($param['user_id'])) {$this->websocket->fetch(40001, '短少 user_id');
$this->server->close($fd);
return false;
}
if (empty($param['username'])) {$this->websocket->fetch(40001, '短少 username');
$this->server->close($fd);
return false;
}
if (empty($param['sign'])) {$this->websocket->fetch(40001, '短少 sign');
$this->server->close($fd);
return false;
}
if (empty($param['time'])) {$this->websocket->fetch(40001, '短少 time');
$this->server->close($fd);
return false;
}
return true;
}
/**
* 参数鉴权
* @author 小黄牛
*/
public function _sign_check($param, $fd, $request) {
// 过期
$now_time = time();
$max_time = $now_time + 3600;
$min_time = $now_time - 3600;
// 工夫戳申请前后 60 分钟内无效,避免客户端和服务器时间误差
if ($param['time'] < $min_time || $param['time'] > $max_time ){$this->websocket->fetch(40002, 'time 已过期');
$this->server->close($fd);
return false;
}
// 域名起源判断
// 应用 $request->header['origin'] 获取起源域名
// 如果有须要的同学能够本人参考下面的判断写下
// 签名验证
// 生产环境不应该这么简略,本人思考 API 的鉴权逻辑即可
$sign = md5($param['user_id'].$param['time']);
if ($sign != $param['sign']) {$this->websocket->fetch(40002, 'sign 谬误,应该是 md5(user_id + time):');
$this->server->close($fd);
return false;
}
return true;
}
}
3、下线播送
通过内存表的反对,咱们能够在 /box/event/onClose.php
阶段对客户端进行下线播送:
namespace box\event\server;
// 引入内存表组件
use x\swoole\Table;
// 引入 websocket 控制器基类
use x\controller\WebSocket;
class onClose
{
/**
* 启动实例
*/
public $server;
/**
* 对立回调入口
* @author 小黄牛
* @version v1.0.1 + 2020.05.26
* @param Swoole\Server $server
* @param int $fd 连贯的文件描述符
* @param int $reactorId 来自那个 reactor 线程,被动 close 敞开时为正数
*/
public function run($server, $fd, $reactorId) {
$this->server = $server;
// 实例化客户端
$this->websocket = new WebSocket();
// 通过 fd 反查信息
$user = Table::table('fd')->name($fd)->find();
$user_info = Table::table('user')->name($user['user_id'])->find();
// 播送下线音讯
$table = Table::table('user')->all();
foreach ($table as $key=>$info) {$data = ['user_id'=>$user_info['user_id'], 'username'=>$user_info['username'], 'status' => 2];
// 这样须要留神 close 比拟非凡,如果须要播送,最初一个参数要传入 server 实例才行
$this->websocket->fetch(10001, $user_info['username'].'骑着扫帚 兴冲冲的走了~', $data, $info['fd'], $this->server);
}
return true;
}
}
4、客户端音讯解决
本案例客户端只应用到 2 个路由,别离是解决一般音讯的群发告诉,还有心跳检测的次数重置。
A、一般音讯群发告诉
控制器:/app/websocket/user/broadcast.php
:
// 一般播送
namespace app\websocket\user;
use x\controller\WebSocket;
// 引入内存表组件
use x\swoole\Table;
class broadcast extends WebSocket {public function index() {
// 接管申请参数
$param = $this->param();
// 获取以后客户端标识符
$fd = $this->get_current_fd();
// 播送音讯
$table = Table::table('user')->all();
foreach ($table as $key=>$info) {
// 不推给本人
if ($info['fd'] != $fd) {$this->fetch(10002, $param['content'], ['username' => $info['username']], $info['fd']);
}
}
return true;
}
}
B、心跳次数重置
控制器:/app/websocket/user/heart.php
:
// 心跳重置
namespace app\websocket\user;
use x\controller\WebSocket;
// 引入内存表组件
use x\swoole\Table;
class heart extends WebSocket {public function index() {
// 获取以后客户端标识符
$fd = $this->get_current_fd();
// 通过 fd 反查信息
$user = Table::table('fd')->name($fd)->find();
$user_info = Table::table('user')->name($user['user_id'])->find();
$user_info['heart_num'] = 0;
// 重置心跳次数
Table::table('user')->name($user['user_id'])->upsert($user_info);
return $this->fetch(10003, '心跳实现');
}
}
5、基于定时器检测心跳超时的客户端
先创立一个定时器:/box/crontab/heartHandle.php
:
// 心跳检测解决
namespace box\crontab;
use x\Crontab;
// 引入内存表组件
use x\swoole\Table;
// 客户端实例
use x\controller\WebSocket;
class heartHandle extends Crontab{
/**
* 对立入口
* @author 小黄牛
* @version v2.5.0 + 2021.07.20
*/
public function run() {
// 取得 server 实例
$server = $this->get_server();
// 取得客户端实例
$websocket = new WebSocket();
$table = Table::table('user')->all();
foreach ($table as $key=>$info) {
// 检测心跳间断失败次数大于 5 次的记录进行播送下线
if ($info['heart_num'] > 5) {$data = ['user_id'=>$info['user_id'], 'username'=>$info['username'], 'status' => 2];
// 这样须要留神 close 比拟非凡,如果须要播送,最初一个参数要传入 server 实例才行
$websocket->fetch(10001, $user_info['username'].'骑着扫帚 兴冲冲的走了~', $data, $info['fd'], $server);
// 敞开它的连贯
$server->close($info['fd']);
} else {
// 失败次数 +1
Table::table('user')->name($info['user_id'])->setDec('heart_num', 1);
}
}
}
}
而后注册定时器,为 5 秒
执行一次,批改 /config/crontab.php
为以下代码:
return [
[
'rule' => 5000,
'use' => '\box\crontab\heartHandle',
'status' => true,
]
];
6、编写 tim.js 客户端代码
$(function(){
var lockReconnect = false; // 失常状况下咱们是敞开心跳重连的
var wsServer; // 连贯地址
var websocket; // ws 实例
var time; // 心跳检测定时器指针
var user_id; // 用户 ID
var username; // 用户昵称
$('#user_id').val(random(100000, 999999));
$('#username').val(getRandomName(3));
// 点击连贯
$('#open').click(function(){createWebSocket();})
// 点击断开
$('#close').click(function(){addLog('被动断开连接');websocket.close();})
// 发送音讯
$('#send').click(function(){var content = $('#content').val();
if (content == '' || content == null) {alert('请先输出内容');
return false;
}
// 本人
$('.main').append('<div class="record own">'+content+':说 <span>'+getDate()+'</span> <font> 本人 </font></div>');
// 播送音讯
send('user/broadcast', {'content':content})
$('#content').val('');
saveScroll('.main')
})
// 发送数据到服务端
function send(action, data) {
// 补充用户信息
data.user_id = $('#user_id').val()
data.username = $('#username').val()
// 组装 SW- X 的固定格局
var body = {
'action' : action,
'data' : data,
}
body = JSON.stringify(body);
websocket.send(body);
addLog('发送数据:'+body);
}
// 记录 log
function addLog(msg) {$('.log').append('<div>'+msg+'</div>');saveScroll('.log')}
// 启动 websocket
function createWebSocket() {var time = Date.now() / 1000;
var host = $('#host').val();
user_id = $('#user_id').val();
username = $('#username').val();
if (host == '' || host == null) {alert('请先输出 host 地址');
return false;
}
if (user_id == '' || user_id == null) {alert('请先输出 user_id');
return false;
}
if (username == '' || username == null) {alert('请先输出用户名');
return false;
}
wsServer = host+'?user_id='+user_id+'&username='+username+'&time='+time+'&sign='+$.md5(user_id+time);
try {websocket = new WebSocket(wsServer);
init();} catch(e) {reconnect();
}
}
// 初始化 WebSocket
function init() {
// 接管 Socket 断开时的音讯告诉
websocket.onclose = function(evt) {addLog('Socket 断开了... 正在试图从新连贯...');
reconnect();};
// 接管 Socket 连贯失败时的异样告诉
websocket.onerror = function(e){addLog('Socket 产生异样... 正在试图从新连贯...');
reconnect();};
// 连贯胜利
websocket.onopen = function (evt) {addLog('连贯胜利');
// 心跳检测重置
heartCheck.start();};
// 接管服务端播送的音讯告诉
websocket.onmessage = function(evt){
var data = evt.data;
addLog('接管到服务端音讯:'+data);
var obj = JSON.parse(data);
// 音讯解决
switch (obj.action) {
// 高低线
case 10001:
var body = obj.data;
$('.main').append('<div class="record tips">'+obj.msg+'</div>');
// 登录
if ($('#userid_'+body.user_id).html() == undefined) {$('.user_list ul').append('<li id="userid_'+body.user_id+'"><span>'+body.username+'</span><code class="online"> 在线 </code></li>');
} else {
// 重登
if (body.status == 1) {$('#userid_'+body.user_id+'code').removeClass('offline');
$('#userid_'+body.user_id+'code').addClass('online');
$('#userid_'+body.user_id+'code').html('在线');
// 下线
} else {$('#userid_'+body.user_id+'code').removeClass('online');
$('#userid_'+body.user_id+'code').addClass('offline');
$('#userid_'+body.user_id+'code').html('离线');
}
}
saveScroll('.main')
break;
// 收到一般音讯
case 10002:
var body = obj.data;
// 对方
$('.main').append('<div class="record other"><font>'+body.username+'</font> <span>'+getDate()+'</span> 说:'+obj.msg+'</div>');
saveScroll('.main')
break;
// 回复了一次心跳
case 10003:
// 心跳检测重置
heartCheck.start();
break;
default:
break;
}
};
}
// 掉线重连
function reconnect() {if(lockReconnect) {return;};
lockReconnect = true;
// 没连贯上会始终重连,设置心跳提早防止申请过多
time && clearTimeout(time);
time = setTimeout(function () {createWebSocket();
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(){
// 这里须要发送一个心跳包给服务端
send('user/heart', {})
}, this.timeout)
}
}
// 生成 ID
function random(min, max) {return Math.floor(Math.random() * (max - min)) + min;
}
// 解码
function decodeUnicode(str) {
//Unicode 显示方式是 \u4e00
str = "\\u"+str
str = str.replace(/\\/g, "%");
// 转换中文
str = unescape(str);
// 将其余受影响的转换回原来
str = str.replace(/%/g, "\\");
return str;
}
// 生成中文名
function getRandomName(NameLength){
let name = ""
for(let i = 0;i<NameLength;i++){
let unicodeNum = ""
unicodeNum = random(0x4e00,0x9fa5).toString(16)
name += decodeUnicode(unicodeNum)
}
return name
}
// 取得以后日期
function getDate() {var oDate = new Date();
return oDate.getHours()+':'+oDate.getMinutes()+':'+oDate.getSeconds();}
// 滚动到底部
function saveScroll(id) {$(id).scrollTop($(id)[0].scrollHeight );
}
})
7、案例源码下载
如果不想本人一步步组装的,能够间接本次下载源码查看:SW-X WebSocket 案例源码