Loading...

PHP WebSocket + HTML5 在线聊天程序开发

闲着没事写了个 PHP WebSocket 的在线聊天,前端使用 SoraChat 修改而来,将 AJAX 换成了 WebSocket,提升效率,降低延迟。

WebSocket 是什么?让我们先来看一个例子,在下面输入你的用户名,然后输入一两句话,点击发送消息试试。

用户名:

消息内容:

说一下开发过程中遇到的几个坑:

  1. 处理多用户问题,多个用户聊天的时候可能出 socket_read 阻塞线程,后来通过多线程解决。
  2. 效率问题,单线程在多用户情况下会导致效率降低,后来通过多线程解决(同上)。
  3. SSL 问题,HTTPS 下要求 WebSocket 也要使用 SSL,后来通过 Nginx 反向代理解决。

本文的例子并没有使用多线程技术,多线程是指 pthreads,关于 pthreads 的更多信息可以参考 php.net 上的官方文档。

首先来看后端代码,后端由这几个部分组成,第一个部分是创建 Socket 并监听端口。

<?php
// PHP Single WebSocket Server
// 设置端口号为 9090,监听在 0.0.0.0
$port = 9090;
$host = '0.0.0.0';
$null = NULL;

// 创建 Socket,并设置端口号
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);  
socket_set_option($socket, SOL_SOCKET, SO_REUSEADDR, 1);  
socket_bind($socket, 0, $port);  
  
// 监听端口 9090
socket_listen($socket);

// clients 用于储存所有连接的客户端
$clients = array($socket);

接下来,创建一个死循环,用于接受连接

while (true) {
    $cd = $clients;
    socket_select($cd, $null, $null, 0, 10);
    
    // 如果有客户端连接到服务器
    if (in_array($socket, $cd)) {
        // 接受并加入新的socket连接
        $s_client = socket_accept($socket);
        $clients[] = $s_client;

        // 与客户端执行 TCP 握手操作
        $header = socket_read($s_client, 1024);  
        ws_handshake($header, $s_client, $host, $port);  
          
        // 获取客户端的 IP 地址
        socket_getpeername($s_client, $ip);
        $response = ws_encrypt(json_encode(array('type'=>'system', 'time' => date("Y-m-d H:i:s"), 'message' => $ip.' 加入了聊天', 'action' => 'online')));  
        ws_send($response);
        $fs = array_search($socket, $cd);  
        unset($cd[$fs]);
    }  
      
    // 递归数组,为每个连接执行一次
    foreach ($cd as $c_client) {      
          
        // 如果客户端发送了数据到服务器
        while(socket_recv($c_client, $data, 1024, 0) >= 1) {  
            // 将数据解密  
            $text = ws_decrypt($data);  
            $text = json_decode($text);    
            $username = $text->name;  
            $message = htmlspecialchars($text->message);
			if($username !== "" && $message !== "") {
				// 将客户端发送的消息广播给所有的客户端 
				$response = ws_encrypt(json_encode(array('type'=>'message', 'name'=> $username, 'time' => date("Y-m-d H:i:s"), 'message' => $message)));  
				ws_send($response);
			}
            break 2;  
        }  
          
        // 如果有客户端离线
        $data = @socket_read($c_client, 1024, PHP_NORMAL_READ);  
        if ($data === false) {
            $fs = array_search($c_client, $clients);
            socket_getpeername($c_client, $ip);
            unset($clients[$fs]);  
            $response = ws_encrypt(json_encode(array('type'=>'system', 'time' => date("Y-m-d H:i:s"), 'message' => $ip . ' 退出了聊天', 'action' => 'offline')));
            ws_send($response);
        }  
    }  
}  
// 如果出现奇奇怪怪的情况跳出了循环,关闭 Socket
socket_close($sock);

然后我们需要给客户端发送消息的时候,就要调用 ws_send() 这个函数。

ws_send() 函数内容:

function ws_send($msg) {
    // 将 clients 作为全局变量
    global $clients;

    // 为每个客户端都执行一次发送消息
    foreach($clients as $c_client) {
        @socket_write($c_client, $msg, strlen($msg));  
    }
    return true;  
}

然后是负责加密和解密数据的部分,函数为 ws_encrypt 和 ws_decrypt:

// ws_encrypt 加密数据  
function ws_encrypt($text) {
    $b1 = 0x80 | (0x1 & 0x0f);
    $length = strlen($text);
    if($length <= 125) {
        $header = pack('CC', $b1, $length);
    } elseif($length > 125 && $length < 65536) {
        $header = pack('CCn', $b1, 126, $length);
    } elseif($length >= 65536) {
        $header = pack('CCNN', $b1, 127, $length);
    }
    return $header . $text;
}

// ws_decrypt 解密数据
function ws_decrypt($text) {
    $length = ord($text[1]) & 127;
    if($length == 126) {
        $masks = substr($text, 4, 4);
        $data = substr($text, 8);
    } elseif($length == 127) {
        $masks = substr($text, 10, 4);
        $data = substr($text, 14);
    } else {
        $masks = substr($text, 2, 4);
        $data = substr($text, 6);
    }
    $text = "";
    for ($i = 0; $i < strlen($data); ++$i) {
        $text .= $data[$i] ^ $masks[$i % 4];
    }
    return $text;
}

最后是负责处理 WebSocket 握手的函数

// ws_handshake 处理握手数据包
function ws_handshake($ws_header, $connect, $host, $port) {
    $headers = array();
    $lines = preg_split("/\r\n/", $ws_header);
    foreach($lines as $line) {
        $line = chop($line);
        if(preg_match('/\A(\S+): (.*)\z/', $line, $matches)) {  
            $headers[$matches[1]] = $matches[2];
        }
    }
    $secKey = $headers['Sec-WebSocket-Key'];
    $secAccept = base64_encode(pack('H*', sha1($secKey . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')));
    $upgrade  = "HTTP/1.1 101 Web Socket Protocol Handshake\r\n" .
    "Upgrade: websocket\r\n" .
    "Connection: Upgrade\r\n" .
    "WebSocket-Origin: " . $host . "\r\n" .
    "WebSocket-Location: ws://" . $host . ":" . $port . "/server.php\r\n".
    "Sec-WebSocket-Accept: " . $secAccept . "\r\n\r\n";
    @socket_write($connect, $upgrade, strlen($upgrade));
}

接着我们运行一下,打开 cmd,输入 php server.php

然后随手写了个前端测试一下效果

在线用户列表功能还没做(懒

客户端连接服务器的方法:

<div id="msglist"></div>
<p>用户名:<input type="text" id="user"></p>
<p>消息内容:<textarea id="message"></textarea></p>
<button onclick="ws_send(user.value, message.value)">发送消息</button>
<script type="text/javascript">var websocket;
    window.onload = function() {
        var ws_server = 'ws://127.0.0.1:9090/';
        if (window.WebSocket) {
            websocket = new WebSocket(ws_server);

            //连接到服务器的时候
            websocket.onopen = function(event) {
                if (websocket.readyState == 1) {
                    console.log("成功连接到 WebSocket 服务器!");
                    msglist.innerHTML += "<p>WebSocket 连接成功!</p>";
                }
            }
            //收到服务器的消息
            websocket.onmessage = function(event) {
                var msg = JSON.parse(event.data);
                console.log("[INFO] 收到服务器消息:" + event.data);
                if (msg.type == 'system') {
                    console.log("[INFO][SYSTEM] " + msg.message);
                    msglist.innerHTML += "<p>服务器消息:" + msg.message + "</p>";
                } else {
                    console.log("[INFO][" + msg.time + "][" + msg.name + "] " + msg.message);
                    msglist.innerHTML += "<p>" + msg.name + ":" + msg.message + "</p>";
                }
            }

            //发生错误,连接失败等情况  
            websocket.onerror = function(event) {
                console.log("[ERROR] 无法连接至 WebSocket 服务器");
                    msglist.innerHTML += "<p>WebSocket 连接失败</p>";
            }

            //WebSocket 连接状态变化,连接关闭等  
            websocket.onclose = function(event) {
                console.log('[INFO] 与 WebSocket 服务器的连接已断开');
                    msglist.innerHTML += "<p>WebSocket 连接已断开</p>";
            }
        } else {
            console.log("[ERROR] 此浏览器不支持 WebSocket 协议,请使用 Chrome 等现代浏览器");
            msglist.innerHTML += "<p>WebSocket 不支持</p>";
        }
    };
    //发送消息函数 ws_send("用户名", "消息内容")
    function ws_send(user, message) {
        var msg = {
            name: user,
            message: message
        };
        try {
            websocket.send(JSON.stringify(msg));
            return true;
        } catch(ex) {
            console.log(ex);
        }
    }
</script>

效果图:

接下来还要处理 SSL 的问题,如果客户端使用 HTTPS 协议访问的时候,浏览器会阻止 WebSocket 连接,这时候就需要使用 SSL。

此处使用 Nginx 作为反向代理为 WebSocket 增加 SSL 支持,虽然让 WebSocket 直接支持 SSL 的代码很多,但是为了方便理解,此处还是使用反向代理。

首先,安装 Nginx,具体步骤就不说了,这里只讲配置文件,配置文件内容如下:

worker_processes  4;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;
    upstream websocket {
        server localhost:9090;
    }

    server {
        # 监听在端口 9091 上
        listen       9091;
        # 绑定域名
        server_name  ws.sakuramc.org;
        ssl                  on;
        # 设置 SSL 证书
        ssl_certificate      sakuramc.org.crt;
        ssl_certificate_key  sakuramc.org.key;
        ssl_session_timeout  5m;
        ssl_protocols  SSLv2 SSLv3 TLSv1;
        ssl_ciphers  HIGH:!aNULL:!MD5;
        ssl_prefer_server_ciphers   on;

        location / {
            # 设置反向代理到 9090 端口 ( 非 SSL )
	    proxy_pass http://127.0.0.1:9090/;
	    proxy_read_timeout 300s;
	    proxy_set_header Host $host;
            # 将用户的真实 IP 转发过去
	    proxy_set_header X-Real-IP $remote_addr;
	    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
	    proxy_http_version 1.1;
	    proxy_set_header Upgrade $http_upgrade;
	    proxy_set_header Connection "upgrade";
	}
    }
}

接着启动 Nginx,然后修改客户端的代码:

var ws_server = 'ws://127.0.0.1:9090/';

修改为以下内容:

var ws_server = 'wss://ws.sakuramc.org:9091/';

没错,多了一个 s,并且指定到服务器域名 ws.sakuramc.org,端口为 Nginx 绑定的端口 9091

这样就完成了 Nginx 反向代理 WebSocket。

最后还有一个问题,新加入聊天的用户看不到之前的消息,后来我用一个简单的方法解决了,就是创建一个数组,用户每次发送消息的时候将消息存入数组,有新用户加入聊天的时候,递归数组将所有消息发送给该用户,就可以实现查看聊天记录了。

在线聊天测试 Demo(需要用 Sakura Frp 账号登录):

https://www.natfrp.org/chat/

进入后会要求先登录,登陆你的账号之后再访问此地址即可开始在线聊天。

感谢阅读,本文章未经作者允许谢绝转载。

发表评论

》表情