news 2026/10/17 11:09:45

PHP WebSocket聊天实例:从握手到广播的完整实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PHP WebSocket聊天实例:从握手到广播的完整实现

简介:这是一份面向PHP后端初学者与实时通信开发者的WebSocket聊天实例源码,聚焦于用PHP搭建可运行的即时通讯服务,解决长连接建立、消息收发与广播等入门难点。压缩包共3个文件,包含2个php与1个docx,前者分别承担服务端监听与页面入口逻辑,后者为配套说明文档,整体约222KB,结构精简便于快速通读。实例覆盖连接管理、数据帧解析、消息处理、广播推送与断线清理等核心环节,客户端借助JavaScript的WebSocket API监听open、message、error与close事件完成交互,并已通过调试确认可正常收发消息。目前已有365人学习下载,适合想理解WebSocket协议与PHP结合方式、需要一份可跑通参考代码的开发者,通过阅读与改造可掌握实时通信的基本实现思路。

1. 从一份能直接跑通的 PHP WebSocket 聊天实例说起

前阵子有个做后端的朋友接了个内部工具的需求,想给一套老 PHP 系统加个实时消息面板,预算不多、人手就他一个,翻了一圈现成方案,要么是 Node 那一套得另起服务,要么是商业 IM 的 SDK 按量计费。最后他翻出一个 PHP WebSocket 聊天实例,解压、改配置、起进程,半小时内两个浏览器窗口就能互发消息了。这份资源就是干这个的:一个已经调试测试过的 PHP WebSocket 聊天实例,服务端用 PHP 常驻进程监听端口,前端用原生 WebSocket API 连接,不依赖额外中间件,适合想给 PHP 项目补实时能力、又不想引入新语言栈的开发者。它解决的不是高并发 IM 的问题,而是「我手上就是 PHP,怎么用最小代价把消息推起来」这个具体场景。下面按能跑、能看懂、能改的顺序拆一遍。

2. 技术选型与目录结构:为什么是 PHP 常驻进程而不是轮询

2.1 选型逻辑:PHP 做 WebSocket 服务端的现实路径

PHP 传统上跑在「请求进来、脚本执行、响应返回、进程结束」的短生命周期模型里,做长连接天然别扭。所以这份实例的核心思路是绕开 Web 服务器,让 PHP 脚本以 CLI 模式常驻内存,自己监听一个 TCP 端口,在while循环里处理连接和消息。常见做法是用 Socket 扩展手写握手和帧解析,或者引入 Workerman、Swoole 这类常驻内存的框架。这份实例走的是偏底层的路线,好处是依赖少、看得清每一步在干什么,坏处是并发量级有限,适合几十到几百连接的小场景。

为什么不直接用 AJAX 轮询?轮询在消息频率低的时候够用,但延迟取决于轮询间隔,间隔短了请求量爆炸,间隔长了消息不及时。WebSocket 建立一次连接后双向通信,服务端有消息立刻推,这才是聊天面板该有的体验。选 PHP 常驻进程,本质是用一个进程换掉轮询带来的无效请求。

2.2 目录结构:服务端、前端、配置三块

这类实例的目录通常很扁平,我按常见组织方式列一下,你拿到源码后可以对照:

php-websocket-chat/ ├── server.php # WebSocket 服务端主进程,监听端口、处理握手与消息 ├── start.sh # 启动脚本,封装 php server.php 并做进程守护 ├── config.php # 端口、心跳间隔、消息长度上限等参数 ├── client/ │ ├── index.html # 聊天页面,含输入框、消息列表 │ ├── chat.js # 前端 WebSocket 连接、收发、重连逻辑 │ └── style.css # 样式 └── logs/ └── server.log # 运行日志,排查连接问题用

server.php是唯一需要常驻的进程,client/下的静态文件交给任意 Web 服务器(Nginx、Apache 甚至 PHP 内置服务器)托管即可。前后端分离的好处是前端可以单独部署到 CDN,服务端只负责长连接。

2.3 关键参数:端口、心跳、消息上限

config.php里几个参数直接决定能不能跑通和稳不稳:

参数常见取值作用改错会怎样
listen_port8080 / 9502服务端监听端口被占用则启动失败
heartbeat_interval30心跳间隔(秒)太短浪费流量,太长断线发现慢
max_message_size8192单条消息字节上限太小长消息被截断
max_connections200最大并发连接数超限新连接被拒

端口这块有个血泪经验:别用 80 或 443,那两个通常被 Web 服务器占着,PHP 进程抢不到。选 8080 以上的高位端口,防火墙和安全组记得放行,否则本地能连、外网连不上,排查半天以为是代码问题。

3. 服务端核心拆解:握手、帧解析、广播三段链路

3.1 建立监听:socket_create 到 accept 循环

服务端启动的第一步是创建一个 TCP socket 并绑定端口,然后进入accept循环等待客户端连接。这段是整个服务的骨架:

<?php // server.php 启动部分 $config = require __DIR__ . '/config.php'; // 创建 TCP socket $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); if ($socket === false) { die("socket_create 失败: " . socket_strerror(socket_last_error()) . "\n"); } // 设置端口复用,避免重启时 "Address already in use" socket_set_option($socket, SOL_SOCKET, SO_REUSEADDR, 1); // 绑定并监听 if (!socket_bind($socket, '0.0.0.0', $config['listen_port'])) { die("bind 失败: " . socket_strerror(socket_last_error()) . "\n"); } socket_listen($socket, $config['max_connections']); echo "WebSocket 服务已启动,监听端口 {$config['listen_port']}\n"; $clients = []; // 保存所有已握手连接,key 为 socket 资源 id while (true) { // 用 select 做多路复用,避免阻塞在单个 accept 上 $read = array_merge([$socket], array_column($clients, 'sock')); $write = null; $except = null; if (socket_select($read, $write, $except, 1) < 1) { continue; // 超时,继续循环,顺便处理心跳 } // 后续处理新连接和已有连接的数据... }

socket_set_option那行SO_REUSEADDR是后悔药级别的配置,没有它每次重启服务都要等系统释放端口,开发阶段反复调试会疯。socket_select是这里的关键,它让单进程能同时盯着监听 socket 和所有客户端 socket,谁有数据就处理谁,这是 PHP 单进程做并发的核心手段。参数1是超时秒数,配合心跳逻辑用。

3.2 握手:把 HTTP 升级请求转成 WebSocket 连接

WebSocket 连接建立时,浏览器先发一个带Upgrade: websocket头的 HTTP 请求,服务端要解析出Sec-WebSocket-Key,拼上固定 GUID 做 SHA1 再 Base64,回一个 101 响应。这一步做错,浏览器控制台会直接报握手失败:

// 处理新连接时读取握手请求 function doHandshake($socket, $data) { // 从请求头里提取 Sec-WebSocket-Key if (preg_match('/Sec-WebSocket-Key: (.*)\r\n/', $data, $m)) { $key = trim($m[1]); // 固定 GUID,协议规定,不能改 $accept = base64_encode(sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true)); $response = "HTTP/1.1 101 Switching Protocols\r\n" . "Upgrade: websocket\r\n" . "Connection: Upgrade\r\n" . "Sec-WebSocket-Accept: {$accept}\r\n\r\n"; socket_write($socket, $response, strlen($response)); return true; } return false; }

那个 GUID 字符串是 WebSocket 协议写死的,任何实现都一样,抄错一个字符握手就失败。sha1(..., true)的第二个参数true表示返回二进制原始数据,因为后面要 Base64 编码,用十六进制字符串反而错。握手成功后,这个 socket 才算真正进入 WebSocket 通信状态,才能收发数据帧。

3.3 帧解析与广播:消息怎么从一个人到所有人

WebSocket 的数据帧有固定格式,第一个字节的低 7 位是操作码(1 是文本、8 是关闭、9 是 ping),第二个字节的最高位是掩码标志,客户端发来的帧必须带掩码,服务端要按掩码逐字节异或还原数据。解析完再广播给其他连接:

// 解析客户端发来的数据帧 function decodeFrame($data) { $len = ord($data[1]) & 127; $maskStart = 2; if ($len === 126) { $len = unpack('n', substr($data, 2, 2))[1]; $maskStart = 4; } elseif ($len === 127) { $len = unpack('J', substr($data, 2, 8))[1]; $maskStart = 10; } $masks = substr($data, $maskStart, 4); $payload = substr($data, $maskStart + 4, $len); // 按掩码还原真实数据 $decoded = ''; for ($i = 0; $i < strlen($payload); $i++) { $decoded .= $payload[$i] ^ $masks[$i % 4]; } return $decoded; } // 广播给除发送者外的所有连接 function broadcast($clients, $senderId, $message) { $frame = encodeFrame($message); // 服务端发出的帧不加掩码 foreach ($clients as $id => $client) { if ($id !== $senderId) { @socket_write($client['sock'], $frame, strlen($frame)); } } }

$len === 126和127是协议规定的扩展长度标记,分别用 2 字节和 8 字节表示真实长度,短消息走默认的 7 位。掩码还原那段循环是必须的,客户端发来的数据不还原就是乱码。广播时服务端发出的帧不加掩码,这是协议对服务端的要求,加掩码反而会让浏览器解析失败。@抑制符是防止某个连接已断开时 write 报错中断整个循环,但更好的做法是捕获错误后把断开的连接从$clients里移除。

4. 前端对接与运行验证:从连接建立到消息上屏

4.1 前端连接与重连:别让断线变成黑匣子

前端核心就是new WebSocket()加几个事件回调,但重连逻辑是新手最容易漏的。网络抖动、服务端重启都会断线,没有重连用户就得手动刷新:

// chat.js 连接与重连 let ws = null; let reconnectTimer = null; function connect() { ws = new WebSocket('ws://' + location.hostname + ':8080'); ws.onopen = () => { console.log('已连接'); clearInterval(reconnectTimer); // 连上就清掉重连定时器 }; ws.onmessage = (e) => { const li = document.createElement('li'); li.textContent = e.data; document.getElementById('messages').appendChild(li); }; ws.onclose = () => { console.log('连接断开,3 秒后重连'); reconnectTimer = setTimeout(connect, 3000); }; ws.onerror = (err) => { console.error('连接出错', err); }; } connect(); // 发送消息 document.getElementById('send').onclick = () => { const input = document.getElementById('input'); if (ws && ws.readyState === WebSocket.OPEN) { ws.send(input.value); input.value = ''; } };

readyState === WebSocket.OPEN这个判断不能省,连接没建立或已关闭时调send会抛异常。重连用setTimeout而不是setInterval,避免连接还没建立就触发下一次。location.hostname让前端自动用当前页面的域名,省得部署时改 IP。

4.2 启动步骤:三条命令跑起来

拿到源码后按这个顺序操作,基本不会翻车:

# 1. 确认 PHP 装了 sockets 扩展 php -m | grep sockets # 2. 启动 WebSocket 服务端(前台运行,方便看日志) php server.php # 3. 另开一个终端,用 PHP 内置服务器托管前端 php -S 0.0.0.0:8000 -t client/

然后浏览器打开http://你的IP:8000,开两个窗口,一个发消息另一个能收到就说明通了。如果php -m里没有 sockets,Linux 下装php-sockets包,Windows 下在php.ini里把extension=sockets前面的分号去掉。

4.3 验证清单:怎么确认真的通了

跑起来之后按这几条确认,比盯着代码猜靠谱:

  • 服务端终端有没有打印「WebSocket 服务已启动」和连接日志
  • 浏览器 F12 的 Network 面板,WS 那一栏状态是不是 101
  • Console 有没有握手失败或跨域报错
  • 两个窗口互发消息,消息列表是否实时更新
  • 关掉服务端,前端是否在 3 秒后尝试重连

这五条过了,说明实例本身没问题,可以进入改造阶段。

5. 避坑与常见问题:那些调试时最容易卡住的地方

5.1 端口被占用,服务起不来

现象是启动时提示bind 失败: Address already in use。原因是上次的服务进程没退干净,或者端口被别的程序占了。解决:lsof -i:8080找到占用进程 kill 掉,或者直接改config.php里的listen_port。开发阶段建议固定一个不常用端口,别用 8080 这种谁都想抢的。

5.2 握手失败,浏览器报 101 之外的错误

现象是 Network 里 WS 请求返回非 101,Console 提示WebSocket connection failed。原因通常是Sec-WebSocket-Accept算错了,或者响应头少了Upgrade、Connection字段。解决:对照 3.2 的代码逐字符检查 GUID,确认sha1第二个参数是true,响应头结尾必须是\r\n\r\n两个换行。

5.3 消息乱码或收不到

现象是发出去的消息对方收到是乱码,或者干脆没反应。原因是帧解析时掩码没还原,或者长度字段读错。解决:确认decodeFrame里对 payload 做了逐字节异或,$len === 126和127的分支都覆盖到。调试时可以在解析前后打印原始字节和还原后的字符串对比。

5.4 连接数一多就卡死

现象是几个连接正常,十几个之后消息延迟明显甚至服务无响应。原因是单进程socket_select在连接多时轮询开销大,且广播是同步写。解决:小场景够用,真要上量得换 Workerman 或 Swoole,它们用事件循环和协程处理并发。这份实例的定位就是小规模,别硬扛。

5.5 外网连不上但本地正常

现象是本地两个窗口能聊,同事从别的机器连不上。原因是防火墙或云服务器安全组没放行监听端口。解决:检查服务器防火墙规则,云主机还要在控制台的安全组里加一条入站规则,放行listen_port对应的 TCP 端口。

6. 二次开发:把实例改成能用的业务消息面板

6.1 加消息类型:区分聊天、系统通知、在线列表

原始实例只传纯文本,实际业务里消息得带类型。改造思路是前端发 JSON 字符串,服务端解析后按type字段分发:

// 前端发送带类型的消息 ws.send(JSON.stringify({ type: 'chat', // chat / notice / online from: 'user_001', content: input.value, ts: Date.now() }));
// 服务端按类型处理 $msg = json_decode($decoded, true); switch ($msg['type']) { case 'chat': broadcast($clients, $senderId, json_encode($msg)); break; case 'online': // 只回给发送者当前在线人数 $count = count($clients); @socket_write($clients[$senderId]['sock'], encodeFrame(json_encode([ 'type' => 'online', 'count' => $count ])), /* 长度 */); break; case 'notice': // 系统通知广播给所有人,包括发送者 broadcastAll($clients, json_encode($msg)); break; }

type字段是分发的依据,加新类型只要在switch里加分支,不用动连接层。from和ts让前端能显示发送者和时间,比裸文本实用得多。注意json_decode失败时要兜底,别让一条脏数据把服务搞崩。

6.2 加持久化:消息落库和离线拉取

常驻进程里连数据库要小心,别每次消息都新建连接。常见做法是启动时建一个 PDO 连接复用,或者用连接池。落库逻辑放在广播之前:

// 启动时建立数据库连接 $pdo = new PDO('mysql:host=127.0.0.1;dbname=chat', 'user', 'pass', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_PERSISTENT => true, // 持久连接,减少握手开销 ]); // 收到消息后落库 $stmt = $pdo->prepare('INSERT INTO messages (from_user, content, type, created_at) VALUES (?, ?, ?, NOW())'); $stmt->execute([$msg['from'], $msg['content'], $msg['type']]);

ATTR_PERSISTENT让连接在脚本间复用,但常驻进程里本来就不销毁,这个选项更多是习惯。落库失败不能影响广播,用 try-catch 包住,记日志就行。离线拉取就是前端连上后先发一个history请求,服务端查最近 N 条返回。

6.3 验证改造是否成功:三个必测场景

改完之后别急着上线,按这三个场景测一遍:一是正常聊天,两个窗口互发带类型的消息,确认前端能按类型渲染不同样式;二是断线重连,手动 kill 服务端再重启,前端应自动重连且历史消息能拉回来;三是脏数据,手动用ws.send('不是JSON')发一条,服务端应记日志但不崩溃。这三个过了,基本能扛住内部工具的日常使用。

我自己的习惯是,每次改完 WebSocket 服务端,都强制走一遍「双窗口互发 + kill 重启 + 脏数据」这三步,因为长连接的 bug 往往在断线和异常输入时才暴露,正常路径测不出来。希望这份拆解能帮你少走点弯路,把这份实例真正用起来。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 11:09:05

双AI协同写作实战:Claude Code与Codex交叉审稿的论文创作流程

把“claude code、codex双AI协同论文写作”这件事讲清楚&#xff0c;我决定不绕弯子。这两个工具&#xff0c;一个叫claude code、一个叫codex&#xff0c;本来都是给程序员写代码用的终端助手&#xff0c;但我这几个月一直在用它们干一件看起来不太沾边的事——写论文。不是让…

作者头像 李华
网站建设 2026/10/17 11:07:33

LLM应用开发五层架构:从模型接入到交互体验的实战指南

这几年我接触了不少LLM应用开发项目&#xff0c;也见过太多团队卡在同一个位置上&#xff1a;API调通了&#xff0c;Demo也能聊&#xff0c;朋友看完都说“哇塞”&#xff0c;可一旦认真做产品&#xff0c;立刻开始原地打转——回答胡说八道、上下文一长就失忆、接了知识库还是…

作者头像 李华
网站建设 2026/10/17 11:06:14

TCP协议聊天与文件传输系统设计:从socket到粘包拆包实战

很多做网络编程的朋友第一次认真写的项目&#xff0c;都绕不开“聊天软件”这个东西。项目标题叫“基于TCP协议的聊天与文件传输系统设计与实现”&#xff0c;名字听着像课程设计&#xff0c;但真正做完你会发现&#xff0c;这一个小项目把网络编程里最容易踩坑的几个地方全串起…

作者头像 李华
网站建设 2026/10/17 11:05:36

AI论文降重实战:从30%重复率到安全区

又是一年毕业季&#xff0c;办公室的玻璃门外面&#xff0c;总能看到抱着笔记本来回踱步的学生。大部分人手里攥着的&#xff0c;不是论文正文&#xff0c;而是那份查重报告。30%这个数字&#xff0c;像一道无形的分界线——压线的人想往下降一点&#xff0c;超线的人急着往上拉…

作者头像 李华
网站建设 2026/10/17 11:05:21

从代码补全到项目级理解:AI编程工具的五个关键瓶颈

我现在每天在编辑器里泡七八个小时&#xff0c;用过的AI编程工具不下十款。从最早只会补全半个函数名的“聪明输入法”&#xff0c;到如今能一口气生成几十行代码的对话式助手&#xff0c;坦白说&#xff0c;进步确实肉眼可见。但如果你像我一样&#xff0c;手头维护的是一个十…

作者头像 李华
网站建设 2026/10/17 11:04:43

Flutter网络层鸿蒙化适配:全量请求拦截与流量审计实践

把一套跑了好几年的 Flutter 业务框架往鸿蒙设备上迁移的时候&#xff0c;我第一个改崩的模块不是 UI&#xff0c;反而是平时最不受重视的网络层。页面能渲染、路由能跳转&#xff0c;但线上请求的所有拦截都像被静音一样消失了。原本靠 http_client_interceptor 做的请求统一打…

作者头像 李华