资讯动态

PHP原生WebSocket聊天室实战:握手、帧解析、广播与调试详解

发布时间:2026/10/15 2:54:22 来源:尧图企业网站定制
简介这是一份基于PHP的WebSocket实时聊天实例面向想学习PHP长连接与即时通信的开发者。资源以实际可运行的聊天应用为载体展示了从连接管理、数据帧解析、消息广播到断线处理的核心流程并配有客户端的JavaScript通信代码适合作为入门WebSocket协议的练习项目或二次开发底座。压缩包共3个文件包含2个PHP源码文件和1个说明文档整体仅222KB结构轻量便于快速部署查看。目前已有365人学习下载对于希望掌握PHP环境下WebSocket服务器搭建与实时消息推送原理的读者而言这份实例能提供直接可参考的实现模板。通过阅读源码并结合说明文档可以理解如何监听端口、解析帧数据、维护在线连接以及向所有客户端广播消息也能在此基础上扩展用户认证、消息过滤等业务逻辑是理解WebSocket与PHP结合机制的实用起点。1. 这个 PHP WebSocket 聊天实例解决的是实时通信最后的“一公里”假设有个小需求客服会话或直播弹幕要实时推给在线用户。最常见的做法是前端轮询数据一多服务器扛不住消息也慢几拍。手头是 PHP 环境又不想为了这点功能上消息中间件就有了这个能让浏览器直连的 PHP WebSocket 聊天服务端启动一个常驻 PHP 进程监听端口浏览器连上来后消息实时广播给所有在线用户。标题里的“已通过调试测试”意味着服务端脚本可以直接跑起来双人聊天、多人广播、上下线提醒都能验证不依赖数据库。这个实例适合三类人想理解 WebSocket 握手和帧格式的入门者需要给现有 PHP 项目加实时通道又不换技术栈的业务开发以及刚接触常驻进程的同学。下面从握手、帧解析、前端联调写到上线前的验证方法照着敲就能跑通一个可用的聊天室。2. 服务端两板斧用 PHP 原生 Socket 打通握手与帧收发2.1 为什么 PHP 能写 WebSocketCLI 常驻与流事件的配合传统 PHP 每个请求跑完就销毁很多人第一反应是“PHP 做不了长连接”。但 WebSocket 服务端本质是一个持续监听 TCP 端口的进程PHP 的 CLI 模式完全可以胜任stream_socket_server创建监听 socketstream_select在同一进程里监控多个连接的读写事件没有请求生命周期也没有“跑完就销毁”。这也是为什么这个实例能只用php server.php一条命令跑起来。和 HTTP 轮询相比WebSocket 的优势在于连接一旦建立双方后续通信只走这一个 TCP 通道省掉了每次请求的 HTTP 头、Cookie、DNS 和 TCP 握手开销。聊天场景里消息频率高、单条消息又短轮询的浪费非常明显。代价是 PHP 端必须自己维护连接状态并且在同一进程里处理所有连接的读写内存和 CPU 都要省着用。单机几百个连接以内这套方案是稳的上千并发才需要考虑 Swoole 或 Workerman 这类常驻框架。2.2 握手响应Sec-WebSocket-Key 到 Accept 的计算与代码WebSocket 连接开始于一次 HTTP Upgrade 请求。浏览器发来的握手头部里带一个Sec-WebSocket-Key服务端需要把这个 key 拼上一个固定的 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11做 SHA-1 后 base64 编码写进响应头的Sec-WebSocket-Accept同时返回101 Switching Protocols。这是 RFC 6455 规定的固定流程写错任一个字符浏览器都会直接报WebSocket connection failed。function doHandshake(string $data): string { // 解析客户端发来的握手请求取 Sec-WebSocket-Key if (!preg_match(/Sec-WebSocket-Key:\s*(.*)\r\n/i, $data, $matches)) { return HTTP/1.1 400 Bad Request\r\n\r\n; } $key trim($matches[1]); // 固定 GUID 拼接后 sha1再 base64这是握手成功的唯一正确算法 $accept base64_encode(sha1($key . 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, true)); return HTTP/1.1 101 Switching Protocols\r\n . Upgrade: websocket\r\n . Connection: Upgrade\r\n . Sec-WebSocket-Accept: $accept\r\n\r\n; }逻辑不复杂但有两个容易踩点的参数正则里的/i是为了兼容客户端头部大小写不一致很多自写服务端死在这里sha1的第二个参数必须是true否则返回的是 40 位十六进制字符串base64 之后对不上浏览器算出的值。调试时如果发现连接总是立刻断开优先怀疑这一行。提示握手响应必须严格以\r\n换行不能写成\n。浏览器对响应头的解析很严格部分代理服务器也会因为换行符不规范而拒绝转发。2.3 帧的读与写mask 位、长度扩展与中文 UTF-8 的处理握手之后所有数据都封装在 WebSocket 帧里。一帧的头部至少两个字节第一个字节的高位是 FIN低 4 位是 opcode第二个字节的最高位是 MASK 位低 7 位是 payload 长度。客户端发给服务端的帧 MASK 位必须是 1服务端回给客户端的帧 MASK 位必须是 0这条规则正好是新手最容易绕晕的地方。function decodeFrames(string $buffer): array { $frames []; $len strlen($buffer); $offset 0; while ($offset $len) { $b1 ord($buffer[$offset]); $b2 ord($buffer[$offset 1] ?? ); $opcode $b1 0x0F; $masked ($b2 0x80) ! 0; $payloadLen $b2 0x7F; $offset 2; if ($payloadLen 126) { // 16 位长度 $payloadLen unpack(n, substr($buffer, $offset, 2))[1]; $offset 2; } elseif ($payloadLen 127) { // 64 位长度 $payloadLen unpack(J, substr($buffer, $offset, 8))[1]; $offset 8; } $maskKey ; if ($masked) { $maskKey substr($buffer, $offset, 4); $offset 4; } $payload substr($buffer, $offset, $payloadLen); $offset $payloadLen; if ($masked) { // 按位异或还原mask 是循环异或 for ($i 0; $i $payloadLen; $i) { $payload[$i] $payload[$i] ^ $maskKey[$i % 4]; } } $frames[] [opcode $opcode, payload $payload]; } return $frames; }长度字段的三段式是 WebSocket 协议里最经典的细节7 位长度 0-125 直接用长度 126 表示后面还有 16 位真实长度长度 127 表示后面还有 64 位真实长度。聊天消息很少超过 125 字节的 UTF-8 编码但中文用户昵称、长消息会把长度推到 126 分支所以 16 位分支不能省。unpack(J)是按网络字节序读取无符号 64 位整数注意 PHP 的pack/unpack大小端设置必须和 WebSocket 协议一致WebSocket 规定长度必须是网络字节序大端。发送帧的函数逻辑正好相反function encodeFrame(string $text): string { $len strlen($text); $header chr(0x81); // FIN1, opcode1(文本帧) if ($len 125) { $header . chr($len); } elseif ($len 65535) { $header . chr(126) . pack(n, $len); } else { $header . chr(127) . pack(J, $len); } return $header . $text; }服务端发送时控制帧和文本帧都要把 MASK 位设成 0所以这里头部第二字节的最高位没有置 1。中文不需要特殊编码WebSocket 帧里直接放 UTF-8 字节但如果你把字符串做了urlencode客户端JSON.parse会直接失败等于给自己挖坑。2.4 广播与上下线把消息统一成 JSON 结构聊天室的核心操作是广播服务端把一条消息编码成文本帧逐个写到所有在线连接。为了区分消息类型我习惯把载荷统一成 JSON 结构包含类型、发言人、内容、时间四个字段。这样的好处是前端只解析一个 JSON后续加私聊、加图片只需扩展 type。function broadcast(array $clients, string $type, string $from, string $content): void { $msg json_encode([ type $type, from $from, content $content, time date(H:i:s), ], JSON_UNESCAPED_UNICODE); $frame encodeFrame($msg); foreach ($clients as $fd $sock) { if (!is_resource($sock)) { unset($clients[$fd]); // 顺带清掉已断开的连接 continue; } fwrite($sock, $frame); } }fwrite是防写失败的兜底但注意只屏蔽错误提示返回值还是要判断。如果返回值不等于strlen($frame)说明连接缓冲已满或对端异常应该交给上层做断开处理。JSON_UNESCAPED_UNICODE会把中文原样输出前端 JSON.parse 后直接显示不会出现\uXXXX这种需要二次解码的内容。配合上面的函数主循环里的消息处理就是按类型分发function handleMessage(int $fd, array $frame): void { global $clients, $users; if ($frame[opcode] 8) { // 客户端主动关闭 closeConnection($fd); return; } if ($frame[opcode] 9) { // ping 控制帧回 pong 即可 fwrite($clients[$fd], chr(0x8A) . chr(0x00)); return; } $msg json_decode($frame[payload], true); if (!$msg) return; switch ($msg[type] ?? ) { case login: $name trim($msg[name] ?? ); if ($name ! ) { $users[$fd] $name; broadcast($clients, system, notice, {$name} 加入聊天室); } break; case chat: $name $users[$fd] ?? 匿名; $content trim($msg[content] ?? ); if ($content ! ) { broadcast($clients, chat, $name, mb_substr($content, 0, 500)); } break; } }这个 switch 是聊天室业务的核心登录和聊天是两种消息类型对应$users[$fd]的写入和读取。整个服务端脚本不需要 MySQL$users数组就是当前在线名单进程重启数据就清空这对聊天演示足够也符合这个实例“不依赖数据库”的定位。消息类型的约定建议固定下来前后端按同一张表对齐type方向含义login客户端到服务端携带 name登录聊天室chat双向携带 content广播聊天内容system服务端到客户端系统通知上下线提醒ping客户端到服务端心跳服务端不广播3. 客户端联调JavaScript WebSocket 与 PHP 服务端如何对齐3.1 前端最小封装onopen、onmessage、onclose 三件事服务端就绪之后前端就简单了。浏览器原生WebSocketAPI 只有四个事件和两个方法最核心的是 onopen、onmessage、onclose。我给前端写的最小封装是连接建立时发登录消息收到消息时统一走一个渲染函数断开时做重连。这里有个细节容易被忽略event.data在服务端发来二进制帧时是 Blob发文本帧时是 string统一处理要判断类型否则JSON.parse直接抛异常。script const ws new WebSocket(ws://127.0.0.1:9502); ws.onopen () { // 连接建立后立刻告诉服务端昵称 ws.send(JSON.stringify({type: login, name: prompt(请输入昵称, 访客)})); }; ws.onmessage async (event) { // 兼容 string 和 Blob 两种数据类型 let text typeof event.data string ? event.data : await event.data.text(); let data JSON.parse(text); renderMessage(data); }; ws.onclose () { // 掉线后三秒重连重连后要重新登录 setTimeout(connect, 3000); }; /script这个封装里值得注意的参数是重连间隔。3 秒对聊天场景足够但要注意重连成功后服务端不会自动知道你是谁必须重新发送 login 消息所以我把创建 WebSocket 的逻辑抽成connect()函数重连时从 onopen 里重新登录。另一个容易忽略的场景是页面进入后台浏览器可能挂起定时器重连会随之延迟真正生产环境建议在visibilitychange事件里手动检查连接状态。3.2 昵称与登录消息让服务端记住每一个连接服务端区分用户靠的是$users[$fd]fd 是连接句柄的整数 ID所以前端必须在连接后尽快把昵称发给服务端。上面的 onopen 里直接发送 login 就是干这个。服务端处理 login 时把昵称写进映射后续 chat 消息的 from 字段就从这个映射取而不是信任前端发来的昵称。这是前端发聊天的函数function sendChat(content) { if (ws ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({type: chat, content: content})); } }服务端接收 chat 后from 一律从$users[$fd]取。这里有一个安全习惯不要在前端 chat 消息里带 from 字段否则任何人都能伪造发言者某社区聊天室翻车大多栽在这里。同理服务端也不该把$users[$fd]直接塞进消息体而是在广播前再查一次映射避免旧的昵称残留在连接缓存里。3.3 定时心跳前端负责发服务端负责超时踢人浏览器原生 WebSocket 不提供内置心跳连接长时间没数据路由器、云厂商的负载均衡设备都会掐掉空闲连接。最稳妥的做法是前端每 30 秒发一个很小的 JSON 心跳服务端记录每个连接最后活跃时间超过 90 秒没活跃就主动关闭。这个时间窗口的设计考虑是30 秒的心跳间隔留足网络抖动余量90 秒的超时阈值又远小于大多数代理的默认空闲超时。setInterval(() { if (ws ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({type: ping})); } }, 30000);前端发的心跳其实也走 Data 帧opcode1不是协议里的 Ping 控制帧。服务端在 handleMessage 里收到 typeping 时只需要把它当作“这个连接还活着”的消息更新最后活跃时间然后忽略内容。为什么不用真 Ping 帧因为前端代码更简单而且服务端更新活跃时间的逻辑不需要区分帧类型省掉一层判断。4. 调试测试不是玄学WebSocket 联调的 5 个常见问题排查与避坑4.1 握手成功但消息乱码mask 位的锅现象浏览器能连上服务端日志也能打出消息但收到的文本要么是乱码要么首字母正常后面全乱。原因解码时没有做 mask 异或还原或者发送时错误地给服务端帧加了 mask 位。解决检查 decodeFrames 里$masked分支确认$payload[$i] ^ $maskKey[$i % 4]这段逻辑没有被删除检查 encodeFrame 的第二个字节chr($len)最高位是否被设置成 1服务端发送必须为 0。这个坑是自写 WebSocket 最经典的一处因为它不报错只出乱码非常像“玄学”。我调试时习惯先把收到的帧用bin2hex打出来如果 mask 位为 1帧头第二字节会出现0x80开头的形态一眼就能定位。4.2 中文显示成乱码或半个字字节处理与 UTF-8 校验现象英文消息正常中文消息显示乱码或者 JSON.parse 抛 unexpected token。原因帧长度按字节计算没问题但mb_substr按字符截断substr按字节截断如果某个地方用substr做截断再拼 JSON会把一个完整汉字劈成两半另外服务端收到乱码后json_decode失败直接 return前端连系统消息都看不到。解决广播前统一用mb_substr($content, 0, 500, UTF-8)截断调试阶段先看看json_last_error_msg()如果指向 Malformed UTF-8说明有半截字节混进来了。还有一个习惯值得养成所有文本消息统一走JSON_UNESCAPED_UNICODE不要手动 urlencode减少一层转码出错的可能。4.3 连接十几秒就断开反向代理的 Upgrade 头现象本地直连 127.0.0.1:9502 一切正常一部署到 Nginx 后面浏览器连接 10 到 60 秒内必然断开服务端日志没有任何报错。原因Nginx 反向代理默认按 HTTP/1.0 处理不会透传 Upgrade 头WebSocket 连接建起来后立刻被当成普通 HTTP 长连接掐断另外proxy_read_timeout默认 60 秒空闲心跳一停就断开。解决Nginx 配置文件里给 WebSocket 路径加三行配置这个问题在第 5 章有完整写法。要先确认是否走了代理浏览器 Network 面板看 WS 的 Request URL如果开头是 wss 或域名而不是ws://IP:端口就是走了代理优先查服务器配置。4.4 内存持续上涨死连接没清理现象服务端跑一晚上内存从 20MB 涨到 200MB连接数不减少新用户连不上。原因客户端断网、强制关闭页面时TCP 可能发不出 FIN服务端fread返回空串但代码没处理或者广播时只fwrite没检查返回值把已经断开的 socket 一直留在$clients数组里。解决fread返回或false时立即走 closeConnectionfwrite返回值小于帧长度时也走 closeConnection。再加一个兜底主循环每 60 秒扫描一遍所有连接的最后活跃时间把超时连接关掉。这个问题的现象要半小时以后才看得见属于上线前排查清单的必检项。4.5 页面是 HTTPS 但连接失败wss 与证书现象页面从 http 迁到 https 后聊片区直接白屏控制台报 Mixed Content。原因HTTPS 页面不允许发起ws://明文连接浏览器会直接拦截这个属于浏览器安全策略不是服务器问题。解决把前端ws://改成wss://并让 Nginx 做 SSL 终止后转发到后端的 ws 端口。注意这里有个细微差别Nginx 对外用 wss和后端通信仍然可以是 ws。proxy_pass http://127.0.0.1:9502不变只是前端 URL 写wss://域名/ws证书交给 Nginx 处理。自签名证书在浏览器一样报证书错误调试阶段可以先用 http 页面上生产再切 wss。5. 把聊天室推向更多人连接管理、心跳保活与反向代理5.1 连接对象数组fd 到用户信息的映射与清理当连接变多整个服务端的核心数据结构就是那张连接映射表。我的做法是三个数组$clients存 fd 到 socket 句柄$users存 fd 到昵称$lastSeen存 fd 到最后活跃时间。所有读写都通过 fd 找到 socket广播就是遍历$clients。$clients []; // fd socket 资源 $users []; // fd 昵称 $lastSeen []; // fd 最后活跃时间戳注意 PHP 的array_merge在连接数量大时会复制整个数组主循环每次迭代都要避免无谓复制。我常用的写法是$read array_values($clients); $read[] $server; $write null; $except null; stream_select($read, $write, $except, 5); if (in_array($server, $read)) { // 新连接进来accept 后加入连接表 $conn stream_socket_accept($server); if ($conn) { $fd (int)$conn; $clients[$fd] $conn; $lastSeen[$fd] time(); } unset($read[array_search($server, $read)]); } foreach ($read as $sock) { $fd (int)$sock; $data fread($sock, 8192); if ($data || $data false) { closeConnection($fd); continue; } $lastSeen[$fd] time(); // 按 2.3 节的 decodeFrames 处理 $data }这样$read里既有监听 socket也有所有客户端 socketstream_select会把它修改成真正可读的列表。循环结束先判断in_array($server, $read)如果是说明有新连接进来接受后把 fd 写进$clients并在$read中剔除$server剩下的才是普通客户端事件。这个写法比直接array_merge([$server], $clients)省内存连接数到几千时差别很明显。5.2 心跳与僵尸连接服务端超时踢人的时间窗口服务端不能只依赖前端心跳一定要有超时踢人的兜底。具体做法是每次从连接读取到数据就刷新$lastSeen[$fd]每循环一次检查所有$lastSeen超过阈值就 closeConnection。阈值设 90 秒配合前端 30 秒心跳窗口足够宽不至于在网络抖动时误杀正常用户。foreach ($lastSeen as $fd $ts) { if (time() - $ts 90) { closeConnection($fd); } }这里有一个容易踩的坑stream_select用 5 秒超时但 PHP 的time()是秒级精度如果刚好在 89.9 秒和 90.1 秒的边界会有一秒误差无伤大雅。想要更精确就换hrtime(true)但聊天场景不需要。真正需要注意的是僵尸连接清理必须在每次 select 之后做如果写在循环末尾进程阻塞在fread时心跳检查也阻塞了踢人逻辑失效。所以我把清理代码放在每轮事件分发之后、下一次 select 之前。5.3 Nginx 反向代理让 WebSocket 不被“拆台”生产环境通常不会直接暴露 PHP 端口Nginx 做反向代理时WebSocket 的 Upgrade 头必须显式透传。这是第 4 章“连接十几秒就断开”的根治配置location /ws { proxy_pass http://127.0.0.1:9502; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffering off; }注意proxy_set_header Connection upgrade是硬编码字符串不能省。proxy_read_timeout调大是因为 WebSocket 连接建好后连接是长期空闲的如果按默认 60 秒空闲心跳过了就会被 Nginx 掐断。另外一个细节是proxy_buffering off;WebSocket 不适用 HTTP 缓冲否则消息可能积压在 Nginx 侧不往下发前端收不到实时消息。实际踩坑记录里很多人把 timeout 调大了但忘了关缓冲现象是“消息延迟 10 秒才到”。提示如果前后端直连 IP 而不是走域名这一段完全不需要直接连 9502 端口即可。只有浏览器访问的是域名或公网地址时才需要检查 Nginx 配置。6. 验证这套 PHP WebSocket 的最稳方法一条命令加多浏览器实测6.1 命令行自检脚本模拟握手、登录与收广播每次改完服务端代码我不急着开浏览器而是先跑一个十几行的自检脚本用fsockopen连接端口模拟一次握手、发一条登录消息、再发一条聊天消息然后看服务端有没有广播回来。这个脚本能过滤掉八成“前端问题”把排查范围锁死在协议和消息路由。$fp fsockopen(127.0.0.1, 9502, $errno, $errstr, 10); $key base64_encode(random_bytes(16)); $req GET / HTTP/1.1\r\n . Host: 127.0.0.1:9502\r\n . Upgrade: websocket\r\n . Connection: Upgrade\r\n . Sec-WebSocket-Key: $key\r\n . Sec-WebSocket-Version: 13\r\n\r\n; fwrite($fp, $req); $resp ; while (strpos($resp, \r\n\r\n) false) { $resp . fread($fp, 1024); // 读到响应头结束 } if (strpos($resp, 101) false) { exit(握手失败\n); } // 发登录帧客户端构造帧必须带 mask 位 $payload json_encode([type login, name test], JSON_UNESCAPED_UNICODE); $mask random_bytes(4); $frame chr(0x81) . chr(0x80 | strlen($payload)) . $mask; for ($i 0; $i strlen($payload); $i) { $frame . $payload[$i] ^ $mask[$i % 4]; // 用 mask 异或载荷 } fwrite($fp, $frame); sleep(1); echo fread($fp, 2048); // 打印服务端广播回来的登录通知这段脚本里最关键的参数是第二个字节的0x80 | strlen($payload)手动构造客户端帧时必须把 mask 位置 1和 2.3 节服务端发送正好相反。跑通之后前端连接不上基本就是前端代码或网络问题不要再怀疑服务端了。6.2 浏览器三开看真实聊天场景的终审自检脚本只能验证服务端逻辑真实交互还是要在浏览器里过一遍。我的终审流程是开两个隐身窗口和一个普通窗口三个用户依次登录A 发消息、B 回复、C 只看不说话然后关掉 C 的标签页确认服务端广播“C 离开聊天室”。这一步是检查 onclose 触发、服务端断开清理、广播循环这三条链路是否联动正常。顺手看一眼浏览器 Network 面板WS 连接一栏能看到每一条帧的时间戳和载荷如果有红色错误点进去看具体是握手失败还是数据异常。我个人的习惯是每次改动协议字段都先跑自检脚本再开浏览器三窗口验证一整套下来不到五分钟比前端控制台看不到服务端日志时干着急快得多。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑