简介:这是一份面向PHP后端初学者与实时通信开发者的WebSocket聊天室实战示例,聚焦于用PHP搭建可双向推送的长连接服务,解决在线聊天、消息广播等实时场景的入门难题。压缩包共3个文件,以2个php源码文件和1份docx说明文档为主,包体约222KB,其中php文件分别承担服务端监听与页面入口职责,docx则梳理了实现思路与使用说明。资源已通过调试运行,服务端涵盖连接管理、数据帧解析、消息处理与广播、断线清理等核心环节,客户端借助JavaScript的WebSocket API监听open、message、error、close事件完成交互。目前已有365人学习下载,适合想理解WebSocket握手升级、帧格式解析及PHP实时通信原理的读者,可据此快速跑通一个基础聊天平台,并在此基础上扩展用户验证、消息过滤等功能。
1. 从一份能跑通的 PHP WebSocket 聊天实例说起
很多做 PHP 的朋友都动过做实时聊天的念头,但真到动手时,往往卡在第一步:PHP 天生是「请求—响应」的短连接模型,脚本跑完就释放,怎么让它像常驻进程一样挂着监听、主动推消息?这份 PHP WebSocket 聊天实例就是冲着这个痛点来的——它把握手、帧解析、连接管理、消息广播这几件事用原生 PHP 串了一遍,而且已经调试通过,拿到手就能跑起来看效果。它适合两类人:一类是想搞懂 WebSocket 协议底层到底怎么握手、怎么拆帧的开发者,另一类是想给现有 PHP 项目加个轻量实时聊天、又不想上重型框架的从业者。下面我按「先跑通、再拆原理、最后避坑」的顺序,把这份实例从头到尾过一遍。
2. 环境准备与最小可运行闭环:把实例跑起来
2.1 运行环境与依赖确认
这份实例是纯原生 PHP 实现,不依赖 Swoole、Workerman 这类扩展,所以对环境要求很低,但有几个硬性条件必须先确认,否则后面会莫名其妙跑不起来。
| 项目 | 要求 | 说明 |
|---|---|---|
| PHP 版本 | 7.0 及以上 | 用到stream_socket_server和mb_*系列函数,7.0 以下部分语法不兼容 |
| 必需扩展 | sockets、mbstring | sockets 用于创建监听套接字,mbstring 处理多字节字符长度 |
| 运行方式 | CLI 命令行 | WebSocket 服务端必须常驻,不能用 Apache/Nginx 的 web 模式跑 |
| 浏览器 | 支持 WebSocket 的现代浏览器 | 用于打开前端测试页 |
先确认扩展是否开启,命令行执行:
php -m | grep -E "sockets|mbstring"如果两行都输出,说明扩展齐全。只输出一个或者都没有,就要去php.ini里把对应扩展前面的分号去掉,然后重启 PHP。这一步看着简单,但我见过太多人卡在这里——服务端脚本一跑就报Call to undefined function socket_create(),回头查半天代码,其实是扩展没开。
2.2 启动服务端与前端联调
实例通常包含两个核心文件:一个服务端入口(比如server.php),一个前端页面(比如index.html)。启动顺序不能反,必须先起服务端。
# 在实例根目录执行,监听本地 8080 端口 php server.php正常启动后终端不会有花哨输出,一般就一行提示,类似Server started on 0.0.0.0:8080。这时候别关终端,另开一个窗口或者直接用浏览器打开前端页面。前端页面里会有一段连接逻辑:
// 前端连接服务端,地址要和 server.php 里监听的地址端口一致 var ws = new WebSocket("ws://127.0.0.1:8080"); ws.onopen = function () { console.log("连接已建立"); ws.send("hello"); // 连上后先发一条测试消息 }; ws.onmessage = function (e) { // 收到服务端广播的消息,追加到聊天区 console.log("收到:", e.data); };打开页面后按 F12 看控制台,如果打印出「连接已建立」,再开第二个浏览器标签页打开同一个页面,在其中一个页面发消息,另一个页面能收到,就说明整个闭环通了。这里的关键参数是端口号,server.php里监听哪个端口,前端ws://后面就得跟哪个端口,两边对不上就是连接失败,控制台会报WebSocket connection to ... failed。
提示:如果浏览器打不开或者连接被拒,先确认服务端终端还在前台运行、没报错退出,再确认端口没被其他程序占用。
2.3 一次完整的消息流转过程
把实例跑通只是第一步,更重要的是知道一条消息从发出到被对方看到,中间经历了什么。以 A 页面发「你好」为例:
- A 页面调用
ws.send("你好"),浏览器把这条数据按 WebSocket 协议封装成帧,通过已建立的 TCP 连接发给服务端; - 服务端在主循环里用
socket_select或stream_select检测到有可读数据,读取原始字节流; - 服务端按协议解析出帧的载荷(payload),也就是「你好」这两个字;
- 服务端遍历当前维护的所有客户端连接,把这条消息重新封装成帧,逐个写回去;
- B 页面收到帧,浏览器自动解出「你好」,触发
onmessage回调,渲染到聊天区。
这五步里,第 3 步和第 4 步是这份实例的核心价值所在——它把「怎么从字节流里还原出一条消息」和「怎么把消息推给所有人」这两件事用可读的 PHP 代码写清楚了。很多教程只给个socket_write就完事,真跑起来中文乱码、长消息截断,就是因为帧解析没处理对。
3. 拆开握手与帧解析:协议层到底做了什么
3.1 握手阶段:把 HTTP 请求升级成 WebSocket
WebSocket 连接不是凭空建立的,它借用了 HTTP 的第一次请求来完成「升级」。客户端发过来的请求头里带着几个关键字段:
GET /chat HTTP/1.1 Host: 127.0.0.1:8080 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13服务端要做的,是把Sec-WebSocket-Key取出来,拼上一个固定的 GUID 字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11,做一次 SHA1 哈希,再 Base64 编码,最后把结果放进响应头里回给客户端。这段逻辑在实例里通常长这样:
// 从请求头中提取 Sec-WebSocket-Key preg_match('/Sec-WebSocket-Key: (.*)\r\n/', $request, $matches); $key = trim($matches[1]); // 拼接固定 GUID 后做 SHA1,再 Base64,这是协议规定的算法 $accept = base64_encode(sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true)); // 返回 101 状态码,告诉客户端升级成功 $response = "HTTP/1.1 101 Switching Protocols\r\n"; $response .= "Upgrade: websocket\r\n"; $response .= "Connection: Upgrade\r\n"; $response .= "Sec-WebSocket-Accept: {$accept}\r\n\r\n"; socket_write($client, $response);这里的 GUID 是协议写死的,不能改,改了客户端校验不过就会断开。sha1的第二个参数传true表示返回原始二进制,再 Base64,顺序不能颠倒。握手成功后,这条 TCP 连接就不再走 HTTP 了,后面全是 WebSocket 帧。
3.2 帧结构解析:为什么中文会乱码
WebSocket 的数据帧有固定格式,前两个字节藏着关键信息。第一个字节的最高位是 FIN(是否最后一帧),低四位是 opcode(帧类型,文本帧是 0x1);第二个字节最高位是 MASK(客户端发来的帧必须掩码),低七位是 payload 长度。
长度这块有个坑:如果 payload 长度小于 126,第二个字节低七位直接就是长度;如果等于 126,后面跟 2 个字节表示真实长度;如果等于 127,后面跟 8 个字节。很多简易实现只处理了小于 126 的情况,一发长消息就解析错位,表现就是消息内容变成乱码或者直接断开。
// 读取前两个字节 $firstByte = ord($data[0]); $secondByte = ord($data[1]); $opcode = $firstByte & 0x0f; // 帧类型 $isMasked = ($secondByte & 0x80) != 0; // 客户端帧必须掩码 $payloadLen = $secondByte & 0x7f; // 初步长度 // 长度超过 125 时要额外读取字节 if ($payloadLen == 126) { $payloadLen = unpack('n', substr($data, 2, 2))[1]; $maskOffset = 4; } elseif ($payloadLen == 127) { $payloadLen = unpack('J', substr($data, 2, 8))[1]; $maskOffset = 10; } else { $maskOffset = 2; } // 读取 4 字节掩码 $mask = substr($data, $maskOffset, 4); $payload = substr($data, $maskOffset + 4, $payloadLen); // 用掩码逐字节异或还原真实数据 for ($i = 0; $i < $payloadLen; $i++) { $payload[$i] = $payload[$i] ^ $mask[$i % 4]; }掩码这一步是协议强制的,客户端发来的每一帧都要用这 4 个字节循环异或才能还原。漏掉这一步,收到的就是一堆乱码。中文乱码还有另一个原因:payloadLen算的是字节数不是字符数,一个 UTF-8 中文字符占 3 个字节,如果按字符数去截取,就会把多字节字符切断,所以长度必须按字节处理。
3.3 连接管理与广播:谁在线、发给谁
服务端要维护一个客户端列表,通常用一个数组存所有已连接的 socket 资源。新连接握手成功后push进去,断开时从数组里移除。广播就是遍历这个数组,给每个连接写数据。
// $clients 保存所有已连接的客户端 socket $clients = []; // 新连接握手成功后加入列表 $clients[] = $client; // 广播消息给所有客户端 foreach ($clients as $key => $c) { $frame = encodeFrame($message); // 把消息封装成 WebSocket 帧 $result = @socket_write($c, $frame); if ($result === false) { // 写入失败说明连接已断,移除并关闭 unset($clients[$key]); socket_close($c); } }这里有个容易忽略的点:socket_write失败不代表立刻能感知,有时候客户端异常断开,服务端要等到下一次写或者读的时候才发现。所以广播时对写失败的处理很关键,不处理的话数组里会堆积死连接,越跑越慢。另外$clients数组的键在unset后不连续,遍历时用foreach没问题,但如果用for循环按索引取就会踩空。
4. 避坑与排查:那些调试通过后仍会翻车的地方
4.1 连接建立后立刻断开
现象:浏览器控制台显示连接已建立,但一两秒后自动断开,服务端没报错。
原因:多数是握手响应头格式不对,比如\r\n写成了\n,或者Sec-WebSocket-Accept计算时 GUID 拼错、Base64 前忘了sha1的二进制参数。浏览器对握手响应校验很严格,一个字符不对就断开。
解决:把服务端算出来的Sec-WebSocket-Accept和浏览器开发者工具 Network 面板里请求头的Sec-WebSocket-Key手动对一遍,用在线工具或者自己写个小脚本验证哈希结果。响应头每一行结尾必须是\r\n,最后要有一个空行。
4.2 中文消息显示为乱码
现象:英文消息正常,一发中文就变成问号或者方块。
原因:帧解析时长度按字符数而非字节数处理,或者掩码异或时用了strlen之外的方式导致多字节字符被截断。
解决:确认payloadLen全程按字节计算,substr截取时长度参数用字节数。掩码异或循环的边界是$payloadLen(字节数),不是mb_strlen。如果前端发送前做了编码转换,确保两端都是 UTF-8。
4.3 消息超过 125 字节就丢失
现象:短消息正常,稍长一点的消息对方收不到或者收到残缺内容。
原因:帧解析只处理了payloadLen < 126的情况,没有处理 126 和 127 两种扩展长度,导致读取偏移错位。
解决:按 3.2 节的逻辑补全 126(2 字节长度)和 127(8 字节长度)分支,注意unpack的格式符,2 字节用n,8 字节用J。补全后测试发一条 200 字左右的中文消息验证。
4.4 多个客户端时消息串台或丢失
现象:两个以上客户端同时在线,A 发的消息 B 收到了,C 没收到,或者消息内容错乱。
原因:广播时对$clients数组的遍历和写操作有问题,比如在遍历过程中直接修改数组导致跳过元素,或者多个连接共用了同一个缓冲区。
解决:广播前先把消息编码成帧存到变量,循环里只做写操作;写失败时先收集要移除的键,循环结束后统一unset,避免边遍历边删。每个连接的写操作独立,不要复用同一个帧变量去改。
4.5 服务端跑一段时间后 CPU 飙高
现象:刚启动正常,挂机几小时后 CPU 占用越来越高,消息延迟变大。
原因:主循环里用了阻塞式读取或者忙等待,没有正确使用socket_select的超时机制;或者死连接没被清理,数组越来越大。
解决:主循环用socket_select并设置合理的超时(比如 1 秒),超时后做一次心跳检测或清理。广播和读取时对失败连接及时关闭并移除。如果实例里带了心跳逻辑,确认心跳间隔和超时阈值匹配,别设得太激进。
5. 进阶改造:从能跑到好用
把基础实例跑通、坑填完之后,可以按实际需求做几处改造,让它更接近能上生产的状态。
第一处是加心跳保活。浏览器和中间网络设备会主动断开长时间无数据的连接,服务端可以定时给所有客户端发 ping 帧,客户端回 pong,收不到回应的就清理。改造时在主循环的socket_select超时分支里加一个计时器:
// 每 30 秒给所有客户端发一次 ping if (time() - $lastPing > 30) { foreach ($clients as $key => $c) { $pingFrame = chr(0x89) . chr(0x00); // opcode 0x9 表示 ping,无载荷 if (@socket_write($c, $pingFrame) === false) { unset($clients[$key]); socket_close($c); } } $lastPing = time(); }第二处是消息格式规范化。纯文本广播没法区分系统消息、用户消息、在线列表,建议统一成 JSON:
// 前端发送时带上类型和内容 ws.send(JSON.stringify({ type: "chat", content: "你好", user: "A" }));服务端解析后按type分发,chat走广播,join更新在线列表并通知所有人,leave做清理。这样前端渲染时按类型走不同分支,扩展性比纯文本好很多。
第三处是连接数上限和异常保护。原生 PHP 单进程能扛的连接数有限,实例里可以加一个简单的计数,超过阈值就拒绝新握手。同时给socket_write和socket_read都加上错误抑制和返回值判断,避免单个异常连接把整个服务端拖垮。
| 改造项 | 触发场景 | 关键参数 | 验证方式 |
|---|---|---|---|
| 心跳保活 | 长时间挂机 | 间隔 30s,超时 2 次清理 | 挂机 10 分钟看连接是否还在 |
| JSON 消息 | 多类型消息 | type 字段区分 | 发不同类型看前端渲染 |
| 连接上限 | 并发较高 | 阈值按内存设 | 压测到上限看是否拒绝 |
| 异常保护 | 客户端异常断开 | 写失败即清理 | 手动杀进程看服务端是否稳定 |
我自己的习惯是,每次改完这些逻辑,都会先开三个浏览器窗口,一个发中文长消息、一个挂机不动、一个反复刷新断开重连,跑上半小时看服务端终端有没有报错、内存有没有持续涨。这套土办法帮我提前发现过好几次死连接堆积的问题。从那以后我每次动 WebSocket 相关代码,都强制走一遍「三窗口半小时」的验证,比事后查日志省心得多。希望这份实例和上面的拆解能帮你少走点弯路。
本文还有配套的精品资源,点击获取