博客列表 >PHP 基于 SW-X 框架,搭建WebSocket服务器(二)

PHP 基于 SW-X 框架,搭建WebSocket服务器(二)

广州PHP
广州PHP原创
2022年05月30日 17:19:47666浏览

前言

官网地址:SW-X框架-专注高性能便捷开发而生的PHP-SwooleX框架

希望各大佬举起小手,给小弟一个star:https://github.com/swoolex/swoolex

1、前端模板

最终要实现的效果,如下图:

该模板可以直接下载:练习WebSocket使用的前端html模板

也可以直接使用下面的前端代码,命名为:index.html

  1. <!DOCTYPE HTML>
  2. <html>
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta http-equiv="X-UA-Compatible" content="IE=edge">
  6. <meta name="renderer" content="webkit">
  7. <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
  8. <title>SW-X | WebSocket客户端示例</title>
  9. <script src="https://blog.junphp.com/public/js/jquery.min.js"></script>
  10. <script src="jquery.md5.js"></script>
  11. <script src="tim.js"></script>
  12. <style>
  13. body,html{margin: 0;padding: 10px; height: calc(100% - 30px);font-size: 13px;}
  14. ul,li{list-style: none;padding: 0;margin: 0;}
  15. .user_list{width: 200px; height: 100%; overflow: hidden; overflow-y: auto; padding: 10px;border: 1px solid #ccc;float: left;}
  16. .user_list li{width: 100%;padding: 5px 0;cursor: pointer;}
  17. .user_list li:hover{color: #0077d6;}
  18. .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;}
  19. .content{width: calc(100% - 530px); height: calc(30% - 1px);border: 1px solid #ccc;float: left;border-left: 0;border-top: 0;position: relative;}
  20. #content{width: calc(100% - 20px);;border: 0;height:calc(100% - 25px);padding: 10px;}
  21. #content:focus{outline: none;}
  22. code{padding: 3px 5px;border-radius: 30%; color: #fff;}
  23. .online{background: #35b700;}
  24. .offline{background: red;}
  25. .record{float: left;width: 100%;padding: 5px 0;}
  26. .record span{font-size: 12px; background: #ccc; border-radius: 5px; color: #0037ff;padding: 1px 3px;}
  27. .other p{text-indent: 30px;padding: 0px;}
  28. .own{text-align: right;}
  29. .tips{text-align: center;font-size: 12px; color: #e80000;}
  30. .drift{position: absolute;bottom: 10px; right: 10px; }
  31. #send{background: #009e3f;border: 1px solid #009020;font-size: 14px;padding: 3px 10px;color: #fff;border-radius: 5px;cursor: pointer;}
  32. #send:hover{background: #008234;border: 1px solid #005613;}
  33. #open{background: #009e97;border: 1px solid #007974;font-size: 14px;padding: 3px 10px;color: #fff;border-radius: 5px;cursor: pointer;}
  34. #open:hover{background: #008a84;border: 1px solid #00736e;}
  35. #close{background: #ef0000;border: 1px solid #c30000;font-size: 14px;padding: 3px 10px;color: #fff;border-radius: 5px;cursor: pointer;}
  36. #close:hover{background: #c50000;border: 1px solid #a00000;}
  37. input{padding: 4px;}
  38. .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;}
  39. .log div{width: calc(100% - 20px);padding:10px 10px 0 10px;}
  40. </style>
  41. </head>
  42. <body>
  43. <!--用户列表-->
  44. <div class="user_list">
  45. <ul></ul>
  46. </div>
  47. <!--聊天窗口-->
  48. <div class="main"></div>
  49. <!--输入窗口-->
  50. <div class="content">
  51. <textarea id="content"></textarea>
  52. <div class="drift">
  53. <input id="host" type="text" placeholder="WS地址" style="width: 700px;">
  54. <input id="user_id" type="text" placeholder="输入user_id">
  55. <input id="username" type="text" placeholder="输入用户名">
  56. <button id="open">连接</button>
  57. <button id="close">断开</button>
  58. <button id="send">发送</button>
  59. </div>
  60. </div>
  61. <!--交互记录-->
  62. <div class="log"></div>
  63. </body>
  64. </html>

注意:最上面有一个tim.js文件需要你自行创建,后续的教程都只对该文件进行变更说明而已。

2、服务端鉴权并记录用户信息

A、创建内存表

服务端内部使用内存表来缓存用户信息,以减少推送交互时对Mysql的查询压力。

修改/config/swoole_table.php,改成以下代码:

  1. return [
  2. [
  3. 'table' => 'user',// 用户信息表
  4. 'length' => 100000,// 表最大行数上限
  5. 'field' => [ // 字段信息
  6. 'fd' => [
  7. 'type' => \Swoole\Table::TYPE_INT, // swoole的标识符
  8. 'size' => 13, // 字段长度限制
  9. ],
  10. 'user_id' => [
  11. 'type' => \Swoole\Table::TYPE_STRING, // 客户端ID
  12. 'size' => 64,
  13. ],
  14. 'username' => [
  15. 'type' => \Swoole\Table::TYPE_STRING, // 用户名
  16. 'size' => 64,
  17. ],
  18. 'heart_num' => [
  19. 'type' => \Swoole\Table::TYPE_INT, // 心跳次数
  20. 'size' => 1, // 字段长度限制
  21. ],
  22. ]
  23. ],
  24. [
  25. 'table' => 'fd',// fd标识符反查表
  26. 'length' => 100000,
  27. 'field' => [
  28. 'user_id' => [
  29. 'type' => \Swoole\Table::TYPE_STRING,
  30. 'size' => 64,
  31. ],
  32. ]
  33. ]
  34. ];

B、连接时鉴权

通过客户端在ws时的地址携带GET参数,可以进行open握手阶段的权限控制,防止而已连接,同时还可以记录[更新]客户端的连接信息,修改/box/event/server/onOpen.php代码:

  1. namespace box\event\server;
  2. // 引入内存表组件
  3. use x\swoole\Table;
  4. // 引入websocket控制器基类
  5. use x\controller\WebSocket;
  6. class onOpen
  7. {
  8. /**
  9. * 启动实例
  10. */
  11. public $server;
  12. /**
  13. * 统一回调入口
  14. * @author 小黄牛
  15. * @version v1.0.1 + 2020.05.26
  16. * @param Swoole\WebSocket\Server $server
  17. * @param Swoole\Http\Request $request HTTP请求对象
  18. */
  19. public function run($server, $request) {
  20. $this->server = $server;
  21. // 实例化客户端
  22. $this->websocket = new WebSocket();
  23. // 获取参数
  24. $param = $request->get;
  25. // 参数过滤
  26. $res = $this->_param($param, $request->fd);
  27. if (!$res) return false;
  28. // 参数鉴权
  29. $res = $this->_sign_check($param, $request->fd, $request);
  30. if (!$res) return false;
  31. // 将客户信息记录进table内存表
  32. // 用户信息表
  33. Table::table('user')->name($param['user_id'])->upsert([
  34. 'fd' => $request->fd,
  35. 'user_id' => $param['user_id'],
  36. 'username' => $param['username'],
  37. ]);
  38. // 标识符反查user_id表
  39. Table::table('fd')->name($request->fd)->upsert([
  40. 'user_id' => $param['user_id'],
  41. ]);
  42. // 广播上线消息
  43. $table = Table::table('user')->all();
  44. foreach ($table as $key=>$info) {
  45. $data = ['user_id'=>$param['user_id'], 'username'=>$param['username'], 'status' => 1];
  46. $this->websocket->fetch(10001, $param['username'].' 骑着小黄牛 上线啦~', $data, $info['fd']);
  47. }
  48. return true;
  49. }
  50. /**
  51. * 参数过滤
  52. * @author 小黄牛
  53. */
  54. public function _param($param, $fd) {
  55. if (empty($param['user_id'])) {
  56. $this->websocket->fetch(40001, '缺少user_id');
  57. $this->server->close($fd);
  58. return false;
  59. }
  60. if (empty($param['username'])) {
  61. $this->websocket->fetch(40001, '缺少username');
  62. $this->server->close($fd);
  63. return false;
  64. }
  65. if (empty($param['sign'])) {
  66. $this->websocket->fetch(40001, '缺少sign');
  67. $this->server->close($fd);
  68. return false;
  69. }
  70. if (empty($param['time'])) {
  71. $this->websocket->fetch(40001, '缺少time');
  72. $this->server->close($fd);
  73. return false;
  74. }
  75. return true;
  76. }
  77. /**
  78. * 参数鉴权
  79. * @author 小黄牛
  80. */
  81. public function _sign_check($param, $fd, $request) {
  82. // 过期
  83. $now_time = time();
  84. $max_time = $now_time + 3600;
  85. $min_time = $now_time - 3600;
  86. // 时间戳请求前后60分钟内有效,防止客户端和服务器时间误差
  87. if ($param['time'] &lt; $min_time || $param['time'] > $max_time ){
  88. $this->websocket->fetch(40002, 'time已过期');
  89. $this->server->close($fd);
  90. return false;
  91. }
  92. // 域名来源判断
  93. // 使用 $request->header['origin'] 获取来源域名
  94. // 如果有需要的同学可以自己参考上面的判断写下
  95. // 签名验证
  96. // 生产环境不应该这么简单,自己思考API的鉴权逻辑即可
  97. $sign = md5($param['user_id'].$param['time']);
  98. if ($sign != $param['sign']) {
  99. $this->websocket->fetch(40002, 'sign错误,应该是md5(user_id + time):');
  100. $this->server->close($fd);
  101. return false;
  102. }
  103. return true;
  104. }
  105. }

3、下线广播

通过内存表的支持,我们可以在/box/event/onClose.php阶段对客户端进行下线广播:

  1. namespace box\event\server;
  2. // 引入内存表组件
  3. use x\swoole\Table;
  4. // 引入websocket控制器基类
  5. use x\controller\WebSocket;
  6. class onClose
  7. {
  8. /**
  9. * 启动实例
  10. */
  11. public $server;
  12. /**
  13. * 统一回调入口
  14. * @author 小黄牛
  15. * @version v1.0.1 + 2020.05.26
  16. * @param Swoole\Server $server
  17. * @param int $fd 连接的文件描述符
  18. * @param int $reactorId 来自那个 reactor 线程,主动 close 关闭时为负数
  19. */
  20. public function run($server, $fd, $reactorId) {
  21. $this->server = $server;
  22. // 实例化客户端
  23. $this->websocket = new WebSocket();
  24. // 通过fd反查信息
  25. $user = Table::table('fd')->name($fd)->find();
  26. $user_info = Table::table('user')->name($user['user_id'])->find();
  27. // 广播下线消息
  28. $table = Table::table('user')->all();
  29. foreach ($table as $key=>$info) {
  30. $data = ['user_id'=>$user_info['user_id'], 'username'=>$user_info['username'], 'status' => 2];
  31. // 这样需要注意 close比较特殊,如果需要广播,最后一个参数要传入server实例才行
  32. $this->websocket->fetch(10001, $user_info['username'].' 骑着扫帚 灰溜溜的走了~', $data, $info['fd'], $this->server);
  33. }
  34. return true;
  35. }
  36. }

4、客户端消息处理

本案例客户端只使用到2个路由,分别是处理普通消息的群发通知,还有心跳检测的次数重置。

A、普通消息群发通知

控制器:/app/websocket/user/broadcast.php

  1. // 普通广播
  2. namespace app\websocket\user;
  3. use x\controller\WebSocket;
  4. // 引入内存表组件
  5. use x\swoole\Table;
  6. class broadcast extends WebSocket {
  7. public function index() {
  8. // 接收请求参数
  9. $param = $this->param();
  10. // 获取当前客户端标识符
  11. $fd = $this->get_current_fd();
  12. // 广播消息
  13. $table = Table::table('user')->all();
  14. foreach ($table as $key=>$info) {
  15. // 不推给自己
  16. if ($info['fd'] != $fd) {
  17. $this->fetch(10002, $param['content'], ['username' => $info['username']], $info['fd']);
  18. }
  19. }
  20. return true;
  21. }
  22. }

B、心跳次数重置

控制器:/app/websocket/user/heart.php

  1. // 心跳重置
  2. namespace app\websocket\user;
  3. use x\controller\WebSocket;
  4. // 引入内存表组件
  5. use x\swoole\Table;
  6. class heart extends WebSocket {
  7. public function index() {
  8. // 获取当前客户端标识符
  9. $fd = $this->get_current_fd();
  10. // 通过fd反查信息
  11. $user = Table::table('fd')->name($fd)->find();
  12. $user_info = Table::table('user')->name($user['user_id'])->find();
  13. $user_info['heart_num'] = 0;
  14. // 重置心跳次数
  15. Table::table('user')->name($user['user_id'])->upsert($user_info);
  16. return $this->fetch(10003, '心跳完成');
  17. }
  18. }

5、基于定时器检测心跳超时的客户端

先创建一个定时器:/box/crontab/heartHandle.php

  1. // 心跳检测处理
  2. namespace box\crontab;
  3. use x\Crontab;
  4. // 引入内存表组件
  5. use x\swoole\Table;
  6. // 客户端实例
  7. use x\controller\WebSocket;
  8. class heartHandle extends Crontab{
  9. /**
  10. * 统一入口
  11. * @author 小黄牛
  12. * @version v2.5.0 + 2021.07.20
  13. */
  14. public function run() {
  15. // 获得server实例
  16. $server = $this->get_server();
  17. // 获得客户端实例
  18. $websocket = new WebSocket();
  19. $table = Table::table('user')->all();
  20. foreach ($table as $key=>$info) {
  21. // 检测心跳连续失败次数大于5次的记录进行广播下线
  22. if ($info['heart_num'] > 5) {
  23. $data = ['user_id'=>$info['user_id'], 'username'=>$info['username'], 'status' => 2];
  24. // 这样需要注意 close比较特殊,如果需要广播,最后一个参数要传入server实例才行
  25. $websocket->fetch(10001, $user_info['username'].' 骑着扫帚 灰溜溜的走了~', $data, $info['fd'], $server);
  26. // 关闭它的连接
  27. $server->close($info['fd']);
  28. } else {
  29. // 失败次数+1
  30. Table::table('user')->name($info['user_id'])->setDec('heart_num', 1);
  31. }
  32. }
  33. }
  34. }

然后注册定时器,为5秒执行一次,修改/config/crontab.php为以下代码:

  1. return [
  2. [
  3. 'rule' => 5000,
  4. 'use' => '\box\crontab\heartHandle',
  5. 'status' => true,
  6. ]
  7. ];

6、编写tim.js客户端代码

  1. $(function(){
  2. var lockReconnect = false; // 正常情况下我们是关闭心跳重连的
  3. var wsServer; // 连接地址
  4. var websocket; // ws实例
  5. var time; // 心跳检测定时器指针
  6. var user_id; // 用户ID
  7. var username; // 用户昵称
  8. $('#user_id').val(random(100000, 999999));
  9. $('#username').val(getRandomName(3));
  10. // 点击连接
  11. $('#open').click(function(){createWebSocket();})
  12. // 点击断开
  13. $('#close').click(function(){addLog('主动断开连接');websocket.close();})
  14. // 发送消息
  15. $('#send').click(function(){
  16. var content = $('#content').val();
  17. if (content == '' || content == null) {
  18. alert('请先输入内容');
  19. return false;
  20. }
  21. // 自己
  22. $('.main').append('<div class="record own">'+content+' :说<span>'+getDate()+'</span> <font>自己</font></div>');
  23. // 广播消息
  24. send('user/broadcast', {
  25. 'content':content
  26. })
  27. $('#content').val('');
  28. saveScroll('.main')
  29. })
  30. // 发送数据到服务端
  31. function send(action, data) {
  32. // 补充用户信息
  33. data.user_id = $('#user_id').val()
  34. data.username = $('#username').val()
  35. // 组装SW-X的固定格式
  36. var body = {
  37. 'action' : action,
  38. 'data' : data,
  39. }
  40. body = JSON.stringify(body);
  41. websocket.send(body);
  42. addLog('发送数据:'+body);
  43. }
  44. // 记录log
  45. function addLog(msg) {$('.log').append('<div>'+msg+'</div>');saveScroll('.log')}
  46. // 启动websocket
  47. function createWebSocket() {
  48. var time = Date.now() / 1000;
  49. var host = $('#host').val();
  50. user_id = $('#user_id').val();
  51. username = $('#username').val();
  52. if (host == '' || host == null) {
  53. alert('请先输入host地址');
  54. return false;
  55. }
  56. if (user_id == '' || user_id == null) {
  57. alert('请先输入user_id');
  58. return false;
  59. }
  60. if (username == '' || username == null) {
  61. alert('请先输入用户名');
  62. return false;
  63. }
  64. wsServer = host+'?user_id='+user_id+'&username='+username+'&time='+time+'&sign='+$.md5(user_id+time);
  65. try {
  66. websocket = new WebSocket(wsServer);
  67. init();
  68. } catch(e) {
  69. reconnect();
  70. }
  71. }
  72. // 初始化WebSocket
  73. function init() {
  74. // 接收Socket断开时的消息通知
  75. websocket.onclose = function(evt) {
  76. addLog('Socket断开了...正在试图重新连接...');
  77. reconnect();
  78. };
  79. // 接收Socket连接失败时的异常通知
  80. websocket.onerror = function(e){
  81. addLog('Socket发生异常...正在试图重新连接...');
  82. reconnect();
  83. };
  84. // 连接成功
  85. websocket.onopen = function (evt) {
  86. addLog('连接成功');
  87. // 心跳检测重置
  88. heartCheck.start();
  89. };
  90. // 接收服务端广播的消息通知
  91. websocket.onmessage = function(evt){
  92. var data = evt.data;
  93. addLog('接收到服务端消息:'+data);
  94. var obj = JSON.parse(data);
  95. // 消息处理
  96. switch (obj.action) {
  97. // 上下线
  98. case 10001:
  99. var body = obj.data;
  100. $('.main').append('<div class="record tips">'+obj.msg+'</div>');
  101. // 登录
  102. if ($('#userid_'+body.user_id).html() == undefined) {
  103. $('.user_list ul').append('<li id="userid_'+body.user_id+'"><span>'+body.username+'</span><code class="online">在线</code></li>');
  104. } else {
  105. // 重登
  106. if (body.status == 1) {
  107. $('#userid_'+body.user_id+' code').removeClass('offline');
  108. $('#userid_'+body.user_id+' code').addClass('online');
  109. $('#userid_'+body.user_id+' code').html('在线');
  110. // 下线
  111. } else {
  112. $('#userid_'+body.user_id+' code').removeClass('online');
  113. $('#userid_'+body.user_id+' code').addClass('offline');
  114. $('#userid_'+body.user_id+' code').html('离线');
  115. }
  116. }
  117. saveScroll('.main')
  118. break;
  119. // 收到普通消息
  120. case 10002:
  121. var body = obj.data;
  122. // 对方
  123. $('.main').append('<div class="record other"><font>'+body.username+'</font> <span>'+getDate()+'</span> 说:'+obj.msg+'</div>');
  124. saveScroll('.main')
  125. break;
  126. // 回复了一次心跳
  127. case 10003:
  128. // 心跳检测重置
  129. heartCheck.start();
  130. break;
  131. default:
  132. break;
  133. }
  134. };
  135. }
  136. // 掉线重连
  137. function reconnect() {
  138. if(lockReconnect) {
  139. return;
  140. };
  141. lockReconnect = true;
  142. // 没连接上会一直重连,设置心跳延迟避免请求过多
  143. time && clearTimeout(time);
  144. time = setTimeout(function () {
  145. createWebSocket();
  146. lockReconnect = false;
  147. }, 5000);
  148. }
  149. // 心跳检测
  150. var heartCheck = {
  151. timeout: 5000,
  152. timeoutObj: null,
  153. serverTimeoutObj: null,
  154. start: function() {
  155. var self = this;
  156. this.timeoutObj && clearTimeout(this.timeoutObj);
  157. this.serverTimeoutObj && clearTimeout(this.serverTimeoutObj);
  158. this.timeoutObj = setTimeout(function(){
  159. // 这里需要发送一个心跳包给服务端
  160. send('user/heart', {})
  161. }, this.timeout)
  162. }
  163. }
  164. // 生成ID
  165. function random(min, max) {
  166. return Math.floor(Math.random() * (max - min)) + min;
  167. }
  168. // 解码
  169. function decodeUnicode(str) {
  170. //Unicode显示方式是\u4e00
  171. str = "\\u"+str
  172. str = str.replace(/\\/g, "%");
  173. //转换中文
  174. str = unescape(str);
  175. //将其他受影响的转换回原来
  176. str = str.replace(/%/g, "\\");
  177. return str;
  178. }
  179. // 生成中文名
  180. function getRandomName(NameLength){
  181. let name = ""
  182. for(let i = 0;i<NameLength;i++){
  183. let unicodeNum = ""
  184. unicodeNum = random(0x4e00,0x9fa5).toString(16)
  185. name += decodeUnicode(unicodeNum)
  186. }
  187. return name
  188. }
  189. // 获得当前日期
  190. function getDate() {
  191. var oDate = new Date();
  192. return oDate.getHours()+':'+oDate.getMinutes()+':'+oDate.getSeconds();
  193. }
  194. // 滚动到底部
  195. function saveScroll(id) {
  196. $(id).scrollTop( $(id)[0].scrollHeight );
  197. }
  198. })

7、案例源码下载

如果不想自己一步步组装的,可以直接本次下载源码查看:SW-X WebSocket案例源码

声明:本文内容转载自脚本之家,由网友自发贡献,版权归原作者所有,如您发现涉嫌抄袭侵权,请联系admin@php.cn 核实处理。
全部评论
文明上网理性发言,请遵守新闻评论服务协议