PHP 基於 SW-X 框架,搭建WebSocket伺服器(二)
前言
官網地址: SW-X框架-專注高效能便捷開發而生的PHP-SwooleX框架
希望各大佬舉起小手,給小弟一個star: http://github.com/swoolex/swoolex
1、前端模板
最終要實現的效果,如下圖:
該模板可以直接下載: 練習WebSocket使用的前端html模板
也可以直接使用下面的前端程式碼,命名為: index.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="http://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('' + content + ' :說'+getDate()+' 自己');
// 廣播訊息
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('' + msg + '');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('' + obj. msg + '');
// 登入
if ($('#userid_'+body.user_id).html() == undefined) {
$('.user_list ul').append('body.user_id+'">'+body.username+' ');線上
} 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(''+body.username+' ' + getDate() + ' 說:' + obj. msg + '');
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案例原始碼
- Python 資料分析師的基本修養
- 設計模式之介面卡模式
- 如何做好企業數字化轉型?這10份靠譜案例收藏了(附下載)
- 效能提升400倍丨外匯掉期估值計算優化案例
- 如何面向物件程式設計?程式設計師:我也要先有“物件”啊
- 技術分享| 融合排程系統中的電子圍欄功能說明
- #yyds乾貨盤點# leetcode演算法題:環形連結串列 II
- 網站建設流程
- Java池化技術你瞭解多少?
- 如何實時計算日累計逐單資金流
- JAVA面試解析之Spring
- 一文參透分散式儲存系統Ceph的架構設計、叢集搭建(手把手)
- MQTT over QUIC:下一代物聯網標準協議為訊息傳輸場景注入新動力
- DataOps 不是工具,而是幫助企業實現資料價值的最佳實踐
- HTTP快取通天篇,可能有你想要的
- 使用 Tkinter 和 Python 製作文字編輯器
- logo去哪設計?lolgo設計方法分享!
- 效能提升30倍丨基於 DolphinDB 的 mytt 指標庫實現
- 資料庫擴容也可以如此絲滑,MySQL千億級資料生產環境擴容實戰
- 如何製作一個閃屏頁面