使用Swoole的WebSocket实现网页聊天室

聊天室演示地址:点此打开(温馨提示:可打开多个浏览器窗口体验)

服务端

<?php
// +--------------------------------------------------------------+ //
// | 聊天室·WebSocket服务端                                      | //
// | 备注①:依赖Swoole扩展和Memcached扩展。                      | //
// | 备注②:本脚本需以CLI模式运行(即命令行模式)。              | //
// +--------------------------------------------------------------+ //
set_time_limit(0); // 不限制脚本最大执行时间
date_default_timezone_set('PRC');

define('IS_DEBUG', TRUE);                          // 是否开启debug模式
define('WEBSOCKET_SERVER_IP', '192.168.***.***"'); // WebSocket服务端IP地址或主机名
define('WEBSOCKET_SERVER_PORT', 9502);             // WebSocket服务端端口号
define('MEMCACHED_SERVER_IP', 'localhost');        // memcached服务端IP地址或主机名
define('MEMCACHED_SERVER_PORT', 11211);            // memcached服务端端口号

function debug_log($txt)
{
    if (IS_DEBUG) {
        $datetime = date('Y-m-d H:i:s');
        echo "[{$datetime}] {$txt}";
    }
}

function online($fd)
{
    global $memcached;

    $online = $memcached->get('ONLINE');
    if (empty($online)) $online = array();

    $online[$fd] = '';

    $memcached->set('ONLINE', $online);
}

function offline($fd)
{
    global $memcached;

    $online = $memcached->get('ONLINE');
    if (empty($online)) $online = array();

    if (isset($online[$fd])) unset($online[$fd]);

    $memcached->set('ONLINE', $online);
}

function getOnline()
{
    global $memcached;

    $online = $memcached->get('ONLINE');

    return $online ? $online : array();
}

if (!class_exists('Memcached')) exit("Class 'Memcached' not found");
if (!class_exists('Swoole\WebSocket\Server')) exit("Class 'Swoole\WebSocket\Server' not found");

$memcached = new Memcached();
$memcached->addServer(MEMCACHED_SERVER_IP, MEMCACHED_SERVER_PORT);

$server = new Swoole\WebSocket\Server(WEBSOCKET_SERVER_IP, WEBSOCKET_SERVER_PORT);

$server->on('open', function ($server, $request) {
    debug_log("客户端($request->fd) : [连接服务器]" . PHP_EOL);
    online($request->fd); // 加入在线用户列表

    // 通知用户已成功连接服务器
    $data = array(
        'tag' => 'open',
        'time' => time(),
        'errcode' => 0,
        'errmsg' => 'ok',
    );
    $server->push($request->fd, json_encode($data, 320));
});

$server->on('message', function ($server, $frame) {
    debug_log("客户端($frame->fd) : {$frame->data} [opcode: {$frame->opcode}, finish: {$frame->finish}]" . PHP_EOL);
    // $frame->data   :数据内容,可以是文本内容也可以是二进制数据,可以通过opcode的值来判断
    // $frame->opcode :可能值:WEBSOCKET_OPCODE_TEXT=文本数据|WEBSOCKET_OPCODE_BINARY=二进制数据
    // $frame->finish :表示数据帧是否完整(底层已经实现了自动合并数据帧,现在不用担心接收到的数据帧不完整)

    $nowtime = time();

    // 推送给除自己以外的所有在线用户
    $online = getOnline();
    foreach ($online as $fd => $value) {
        if ($frame->fd != $fd) {
            $data = array(
                'tag' => 'message',
                'time' => $nowtime,
                'errcode' => 0,
                'errmsg' => 'ok',
                'name' => "在线用户($frame->fd)",
                'msg' => $frame->data,
            );
            $server->push($fd, json_encode($data, 320));
        }
    }
});

$server->on('close', function ($server, $fd) {
    debug_log("客户端($fd) : [断开连接]" . PHP_EOL);
    offline($fd); // 移出在线用户列表
});

$server->set(array(
    'max_conn' => 100, // 最大允许的连接数
));

$server->start();

客户端

<!doctype html>
<html>
<head>
    <meta charset="utf-8">
    <title>聊天室</title>
</head>
<body>
<style type="text/css">
    :root {
        --border-radius: 3px;
        --border-color: #d9d9d9;
        --default-avatar: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACMAAAAjBAMAAADs965qAAAAJFBMVEXx8fHJycnj4+PMzMzr6+vw8PDg4ODU1NTPz8/u7u7Hx8fl5eWbFfygAAAAX0lEQVQoz2MY7MDJAV1koqAgupCgoKABqgg7UEgcVYgZKISmk5EoIQ5Ms1iBQiIMGDonMGBYyYCuClOoUVBwAboIEJgiiyQKgoEDstkQIIUQUhSEggR8QtFKUMAwmAEAOPULqT5ByxcAAAAASUVORK5CYII=");
    }

    /********** 针对Chrome浏览器滚动条 **********/
    ::-webkit-scrollbar{width:7px;height:7px;}
    ::-webkit-scrollbar-track{background:0 0;}
    ::-webkit-scrollbar-thumb{border-radius:6px;background:#d2d2d2;}
    ::-webkit-scrollbar-thumb:hover{background:#d2d2d2;}

    /********** 针对火狐浏览器滚动条 **********/
    *{scrollbar-color:#d2d2d2 transparent;scrollbar-width:thin;}

    *{margin:0;padding:0;outline:0;color:#333;font-size:14px;font-family:微软雅黑;line-height:1;-webkit-text-size-adjust:none;}
    body{display:flex;justify-content:center;}
    a{-webkit-tap-highlight-color:rgba(255,255,255,0);}
    .panel{overflow:hidden;margin-top:calc((100vh - 720px)/ 2);width:908px;height:718px;border:1px solid var(--border-color);border-radius:var(--border-radius);background:#f5f5f5;}
    .panel .menu{width:55px;background:#2e2e2e;color:#fff;}
    .panel .menu,.panel .online{float:left;display:flex;height:100%;justify-content:center;align-items:center;user-select:none;}
    .panel .online{width:250px;background:#e9e7e6;}
    .panel .chat{float:left;width:602px;height:100%;border-left:1px solid var(--border-color);}
    .panel .chat .title{clear:both;display:flex;width:100%;height:60px;border-bottom:1px solid var(--border-color);font-size:18px;justify-content:center;align-items:center;user-select:none;}
    .panel .chat .record{overflow-x:hidden;overflow-y:auto;width:100%;height:calc(100% - 192px);}
    .panel .chat .record .datetime{clear:both;margin:10px auto;width:100%;height:auto;text-align:center;}
    .panel .chat .record .datetime span{display:inline-block;padding:4px 6px;border-radius:var(--border-radius);background:#dadada;color:#fff;font-size:12px;user-select:none;}
    .panel .chat .record .other{margin:0 0 10px 30px;width:360px;height:auto;}
    .panel .chat .record .other:after{clear:both;display:block;content:"";}
    .panel .chat .record .other .name{overflow:hidden;margin:0 0 0 45px;color:#999;text-overflow:ellipsis;white-space:nowrap;user-select:none;}
    .panel .chat .record .other .avatar{float:left;overflow:hidden;width:35px;height:35px;border-radius:var(--border-radius);background-image:var(--default-avatar);background-size:100% 100%;background-repeat:no-repeat;}
    .panel .chat .record .other .msg{position:relative;float:left;display:inline-block;margin:5px 0 0 10px;padding:7.5px 10px;width:auto;height:auto;max-width:295px;border-radius:var(--border-radius);background:#fff;text-align:justify;line-height:1.45;}
    .panel .chat .record .other .msg:after{position:absolute;top:7.5px;left:-5px;width:0;height:0;border-top:10px solid transparent;border-right:10px solid #fff;border-bottom:10px solid transparent;content:"";}
    .panel .chat .record .other .msg:hover{background:#ebebeb;}
    .panel .chat .record .other .msg:hover:after{border-right-color:#ebebeb;}
    .panel .chat .record .my{margin:0 0 10px 212px;width:360px;height:auto;}
    .panel .chat .record .my:after{clear:both;display:block;content:"";}
    .panel .chat .record .my .avatar{float:right;overflow:hidden;width:35px;height:35px;border-radius:var(--border-radius);background-image:var(--default-avatar);background-size:100% 100%;background-repeat:no-repeat;}
    .panel .chat .record .my .msg{position:relative;float:right;display:inline-block;margin:0 10px 0 0;padding:7.5px 10px;width:auto;height:auto;max-width:295px;border-radius:var(--border-radius);background:#95ec69;text-align:justify;line-height:1.45;}
    .panel .chat .record .my .msg:after{position:absolute;top:7.5px;right:-5px;width:0;height:0;border-top:10px solid transparent;border-bottom:10px solid transparent;border-left:10px solid #95ec69;content:"";}
    .panel .chat .record .my .msg:hover{background:#89d961;}
    .panel .chat .record .my .msg:hover:after{border-left-color:#89d961;}
    .panel .chat .input{padding:15px 0 0;width:100%;height:115px;border-top:1px solid var(--border-color);}
    .panel .chat .input textarea[id=msg]{clear:both;display:block;margin:0 0 0 30px;padding:0 7px 0 0;width:calc(100% - 67px);height:53px;outline:0;border:none;background:0 0;text-align:justify;line-height:1.9;resize:none;}
    .panel .chat .input button[id=send]{float:right;display:block;margin:15px 30px 0 0;width:100px;height:30px;border:none;border-radius:var(--border-radius);background:#e9e9e9;color:#07c160;cursor:pointer;user-select:none;}
    .panel .chat .input button[id=send]:hover{background:#d2d2d2;}
    .panel .chat .input button[id=send]:active{background:#c6c6c6;}
    .panel .chat .input button[id=send][disabled]{background:#dadada;color:#fff;cursor:not-allowed;}
</style>
<div class="panel">
    <div class="menu">菜单</div>
    <div class="online">在线用户</div>
    <div class="chat">
        <div class="title">聊天室</div>
        <div class="record" id="record"></div>
        <div class="input">
            <textarea id="msg"></textarea>
            <button id="send" disabled>发送(S)</button>
        </div>
    </div>
</div>
<script type="text/javascript">
    function date(format,timestamp){var jsdate,f;var txtWords=["Sun","Mon","Tues","Wednes","Thurs","Fri","Satur","January","February","March","April","May","June","July","August","September","October","November","December"];var formatChr=/\\?(.?)/gi;var formatChrCb=function(t,s){return f[t]?f[t]():s};var _pad=function(n,c){n=String(n);while(n.length<c){n="0"+n}return n};f={d:function(){return _pad(f.j(),2)},D:function(){return f.l().slice(0,3)},j:function(){return jsdate.getDate()},l:function(){return txtWords[f.w()]+"day"},N:function(){return f.w()||7},S:function(){var j=f.j();var i=j%10;if(i<=3&&parseInt((j%100)/10,10)===1){i=0}return["st","nd","rd"][i-1]||"th"},w:function(){return jsdate.getDay()},z:function(){var a=new Date(f.Y(),f.n()-1,f.j());var b=new Date(f.Y(),0,1);return Math.round((a-b)/86400000)},W:function(){var a=new Date(f.Y(),f.n()-1,f.j()-f.N()+3);var b=new Date(a.getFullYear(),0,4);return _pad(1+Math.round((a-b)/86400000/7),2)},F:function(){return txtWords[6+f.n()]},m:function(){return _pad(f.n(),2)},M:function(){return f.F().slice(0,3)},n:function(){return jsdate.getMonth()+1},t:function(){return(new Date(f.Y(),f.n(),0)).getDate()},L:function(){var j=f.Y();return j%4===0&j%100!==0|j%400===0},o:function(){var n=f.n();var W=f.W();var Y=f.Y();return Y+(n===12&&W<9?1:n===1&&W>9?-1:0)},Y:function(){return jsdate.getFullYear()},y:function(){return f.Y().toString().slice(-2)},a:function(){return jsdate.getHours()>11?"pm":"am"},A:function(){return f.a().toUpperCase()},B:function(){var H=jsdate.getUTCHours()*3600;var i=jsdate.getUTCMinutes()*60;var s=jsdate.getUTCSeconds();return _pad(Math.floor((H+i+s+3600)/86.4)%1000,3)},g:function(){return f.G()%12||12},G:function(){return jsdate.getHours()},h:function(){return _pad(f.g(),2)},H:function(){return _pad(f.G(),2)},i:function(){return _pad(jsdate.getMinutes(),2)},s:function(){return _pad(jsdate.getSeconds(),2)},u:function(){return _pad(jsdate.getMilliseconds()*1000,6)},e:function(){var msg="Not supported (see source code of date() for timezone on how to add support)";throw new Error(msg)},I:function(){var a=new Date(f.Y(),0);var c=Date.UTC(f.Y(),0);var b=new Date(f.Y(),6);var d=Date.UTC(f.Y(),6);return((a-c)!==(b-d))?1:0},O:function(){var tzo=jsdate.getTimezoneOffset();var a=Math.abs(tzo);return(tzo>0?"-":"+")+_pad(Math.floor(a/60)*100+a%60,4)},P:function(){var O=f.O();return(O.substr(0,3)+":"+O.substr(3,2))},T:function(){return"UTC"},Z:function(){return -jsdate.getTimezoneOffset()*60},c:function(){return"Y-m-d\\TH:i:sP".replace(formatChr,formatChrCb)},r:function(){return"D, d M Y H:i:s O".replace(formatChr,formatChrCb)},U:function(){return jsdate/1000|0}};var _date=function(format,timestamp){jsdate=(timestamp===undefined?new Date():(timestamp instanceof Date)?new Date(timestamp):new Date(timestamp*1000));return format.replace(formatChr,formatChrCb)};return _date(format,timestamp)};
</script>
<script type="text/javascript">
    var webSocketServerIp = '192.168.***.***'; // WebSocket服务器IP地址
    var webSocketServerPort = 9502; // WebSocket服务端口号

    function buttonSendEnable() {
        document.getElementById('send').removeAttribute('disabled');
    }

    function buttonSendDisabled() {
        document.getElementById('send').setAttribute('disabled', true);
    }

    function addMyRecord(msg) {
        var html = '';
        html += '<div class="my">';
        html += '  <div class="avatar"></div>';
        html += '  <div class="msg">' + msg + '</div>';
        html += '</div>';
        document.getElementById('record').insertAdjacentHTML('beforeend', html);
    }

    function addOtherRecord(name, msg) {
        var html = '';
        html += '<div class="other">';
        html += '  <div class="avatar"></div>';
        html += '  <div class="name">' + name + '</div>';
        html += '  <div class="msg">' + msg + '</div>';
        html += '</div>';
        document.getElementById('record').insertAdjacentHTML('beforeend', html);
    }

    function addDatetime(datetime) {
        var html = '<div class="datetime"><span>' + datetime + '</span></div>';
        document.getElementById('record').insertAdjacentHTML('beforeend', html);
    }

    document.getElementById('send').addEventListener('click', function () {
        buttonSendDisabled();

        var msg = document.getElementById('msg').value;
        if (msg) {
            webSocket.send(msg); // 发送消息到服务器

            document.getElementById('msg').value = ''; // 清空输入框内容

            msg = msg.replace(/\r\n/g, '<br>');
            msg = msg.replace(/\n/g, '<br>');
            addMyRecord(msg);
        } else {
            alert('不能发送空白信息');
        }

        buttonSendEnable();
    });

    var webSocket = new WebSocket('ws://' + webSocketServerIp + ':' + webSocketServerPort);

    // 客户端连接服务器成功回调
    webSocket.onopen = function (evt) {}

    // 客户端收到服务端推送消息回调,其中event.data为消息内容
    webSocket.onmessage = function (evt) {
        var data = JSON.parse(evt.data);
        if (data.tag == 'message') { // 普通消息
            data.msg = data.msg.replace(/\r\n/g, '<br>');
            data.msg = data.msg.replace(/\n/g, '<br>');
            addOtherRecord(data.name, data.msg);
        } else if (data.tag == 'open') { // 成功连接服务器    
            addDatetime(date('H:i', data.time));
            buttonSendEnable();
        }
    }

    // 客户端收到服务端断开连接请求回调
    webSocket.onclose = function (evt) {
        buttonSendDisabled();
    }

    // 出现错误回调(连接、处理、接收或发送数据等操作失败都会触发)
    webSocket.onerror = function (evt) {
        alert('onerror');
    }
</script>
</body>
</html>

Copyright © 2024 码农人生. All Rights Reserved