Stories

Detail Return Return

PHP 基於 SW-X 框架,搭建WebSocket服務器(二) - Stories Detail

前言

官網地址: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'] &lt; $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案例源碼

Add a new Comments

Some HTML is okay.