闲着没事写了个 PHP WebSocket 的在线聊天,前端使用 SoraChat 修改而来,将 AJAX 换成了 WebSocket,提升效率,降低延迟。
WebSocket 是什么?让我们先来看一个例子,在下面输入你的用户名,然后输入一两句话,点击发送消息试试。
说一下开发过程中遇到的几个坑:
- 处理多用户问题,多个用户聊天的时候可能出 socket_read 阻塞线程,后来通过多线程解决。
- 效率问题,单线程在多用户情况下会导致效率降低,后来通过多线程解决(同上)。
- SSL 问题,HTTPS 下要求 WebSocket 也要使用 SSL,后来通过 Nginx 反向代理解决。
本文的例子并没有使用多线程技术,多线程是指 pthreads,关于 pthreads 的更多信息可以参考 php.net 上的官方文档。
首先来看后端代码,后端由这几个部分组成,第一个部分是创建 Socket 并监听端口。
// 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
然后随手写了个前端测试一下效果
在线用户列表功能还没做(懒
客户端连接服务器的方法:
效果图:
接下来还要处理 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/ (由于网站转让,演示站已挂)
进入后会要求先登录,登陆你的账号之后再访问此地址即可开始在线聊天。
感谢阅读,本文章未经作者允许谢绝转载。
1 条评论
[…] WebSocket 的话,可以看看我的这篇文章:https://lo-li.cn/444 […]