小白看完就能自己写一套实时聊天系统。没什么高深的理论,拿真实场景从头推到尾。
一、这玩意儿解决什么问题
平时做网站是 "请求-响应" 模式:
arduino
浏览器:"服务器,给我这个页面" → 服务器:"给你" → 结束
服务器永远被动,没法主动找浏览器说话。
但现实中有大量场景需要跨客户端通知------A 做了一件事,B 要立刻知道:
| 场景 | 谁操作 | 谁收到通知 | HTTP 能搞定? |
|---|---|---|---|
| 聊天 | A 发消息 | B 收到消息 | ❌ B 没发请求,服务器没法主动找 B |
| 班级大屏 | 后台审核通过 | 教室大屏实时刷新 | ❌ 大屏只是展示页,不知道什么时候有更新 |
| 后台推送通知 | 管理员发公告 | 所有在线用户弹窗 | ❌ 用户没请求,服务器推不了 |
判断标准很简单:自己操作、自己等结果 (比如提交报名后弹"提交成功")→ HTTP 请求-响应就够了。别人操作、自己收到通知(比如后台审核通过后大屏自动刷新)→ 才需要 WebSocket。
GatewayWorker 就是干这个的:在浏览器和服务器之间建一条 WebSocket 长连接,连接不断开,服务器想什么时候推给谁就什么时候推。
传统 HTTP: 浏览器 ──问──→ 服务器 ──答──→ 浏览器 (结束,各走各的)
WebSocket: 浏览器 ←─────长连接─────→ 服务器 (一直连着,随时推)
客户端可以是网页、小程序、App,只要支持 WebSocket 协议就行。
拿"班级报名 + 大屏实时刷新"当例子贯穿全文:
| 角色 | 干什么 |
|---|---|
| 报名页(家长用,走 HTTP) | 填表单、提交 → HTTP 请求-响应,和普通网页一样 |
| 教室大屏(需要 WebSocket) | 连上 GatewayWorker 保持长连接,等后台推送新报名数据 |
| ThinkPHP 后台 | 家长提交后处理报名、存数据库、然后推通知给大屏 |
| GatewayWorker | 维持大屏的 WebSocket 连接,后台告诉它推给大屏 |
| GatewayClient(一个 PHP 库) | 让 ThinkPHP 能操控 GatewayWorker |
一句话:家长走 HTTP 提交报名 → 后台处理完 → 通过 GatewayWorker 推给教室大屏。家长和大屏是两拨人,推送解决的是"一个角色操作,另一个角色收到通知"。
二、启动后到底跑了什么 ------ 三种进程
GatewayWorker 启动后,实际起了三种进程,各自干活不打架:
yaml
Register(注册中心,端口 1238)
告诉 Gateway 和 BusinessWorker 彼此在哪
撮合完就没它事了
╱ ╲
Gateway(门卫) BusinessWorker(工人)
监听 8282 端口 不对外暴露,纯内部干活
接待客户端连接 跑 Events.php
维持长连接 处理完告诉门卫推给谁
↑
客户端连这个端口
| 进程 | 比喻 | 端口 | 对外? | 职责 |
|---|---|---|---|---|
| Register | 花名册 | 1238 | ❌ 仅本机 | 登记所有 Gateway 和 Worker 的地址,进程间互相发现全靠它 |
| Gateway | 门卫 | 8282 | ✅ 浏览器连这个 | 接客、维持长连接、转发消息 |
| BusinessWorker | 工人 | 无 | ❌ | 跑 Events.php,处理全部业务逻辑 |
⚠️ Register 必须监听
127.0.0.1,绝不能暴露到外网。它用的是纯文本协议,没有任何鉴权------任何人连上来都能假装自己是 Gateway 或 Worker 发指令,从而劫持整个推送系统。Gateway 必须监听0.0.0.0,否则外部客户端连不进来。
启动顺序:Register → Gateway → BusinessWorker
Register 必须先起来,不然后面两个启动直接报错------它们一启动就要向 Register 报到。
配置文件对应关系
Applications/YourApp/ 下四个文件:
| 文件 | 起什么 | 关键配置 |
|---|---|---|
start_register.php |
Register | text://127.0.0.1:1238 |
start_gateway.php |
Gateway | tcp://0.0.0.0:8282,2 个进程 |
start_businessworker.php |
BusinessWorker | 4 个进程,指向 Events.php |
Events.php |
唯一要写的 | 业务逻辑全在这 |
start_gateway.php 核心代码:
php
$gateway = new Gateway("tcp://0.0.0.0:8282"); // 对外端口
$gateway->count = 2; // 开 2 个进程分摊连接
$gateway->lanIp = '127.0.0.1'; // 内网通信 IP
$gateway->startPort = 2900; // 内部通信起始端口
$gateway->registerAddress = '127.0.0.1:1238'; // 去哪找 Register
start_businessworker.php:
php
$worker = new BusinessWorker();
$worker->count = 4; // 4 个进程并行处理业务
$worker->registerAddress = '127.0.0.1:1238'; // 找同一个 Register
$worker->eventHandler = 'Events'; // 指向业务类
所以启动后一共 1 + 2 + 4 = 7 个进程。
count 怎么调
| 进程类型 | 建议 | 理由 |
|---|---|---|
| Register | 永远 1 | 无状态,多开没意义 |
| Gateway | 按连接数,不按 CPU | 每进程可处理 5000~10000 连接;开多了反而加大广播开销 |
| BusinessWorker | CPU核数 ~ 2×核数 |
吃 CPU,多了上下文切换反而慢 |
Gateway 的 count 不是越多越好 。Gateway 进程增多后,Register 向所有 Gateway 广播寻址的开销会变大(参考第五章 sendToUid)。官方推荐:1 万连接以内开 2 个,每多 1 万连接加 1 个。因为它主要是 I/O 密集型(维持连接、转发数据),不是 CPU 密集型。
实际:4 核机器、5000 并发 → gateway.count=2,businessworker.count=8。不纠结的话默认值也能跑。
拿到代码后实际就做三件事
- 改端口 ------
start_gateway.php里把8282换成对应端口,防火墙放行 - 调 count ------ 按上面表格改,不纠结就用默认值
- 写
Events.php------ 唯一需要写代码的地方,后面会展开
lanIp、startPort、registerAddress 单机部署不用改。start_register.php 和 start_businessworker.php 也几乎不需要动。
三、一条连接的全过程
把从页面打开到收到推送拆成四个阶段。
阶段 1:连接建立 → 服务端返回 client_id
php
new WebSocket("ws://192.168.1.6:8282")
│
├── TCP 三次握手 → 连接通了
│
├── Gateway 分配一个全局唯一编号:client_id
│ 比如 "7f0000010a5400000001"
│ 注意:这个编号断开重连就会变
│
├── 自动触发 Events::onConnect($client_id)
│ 连接已通,此时可以主动发消息给客户端(sendToClient)
│ 但客户端还没发过任何数据(onMessage 尚未触发)
│
└── onConnect 里主动推 client_id 回去:
Gateway::sendToClient($client_id, '{"type":"connected","client_id":"7f0000..."}')
这条消息走 WebSocket 到客户端,触发 ws.onmessage ✅
至此客户端才知道自己的 client_id
阶段 2:身份绑定
bash
客户端收到 onConnect 推过来的 { type:'connected', client_id:'7f0000...' }
│ ↑ 这条消息来自上一步 onConnect 里的 sendToClient
│
├── 发 HTTP 给后台 /api/bind
│ 带两个东西:client_id + 业务身份(如 classroom_id=1)
│
├── 后台调 Gateway::bindUid("7f0000...", "classroom_1")
│
└── GatewayWorker 内部记下映射:
_uidConnections["classroom_1"] = ["7f0000010a5400000001"]
以后说"给 classroom_1 发",就知道往哪推了
阶段 3:消息推送
arduino
家长提交报名(走 HTTP,普通请求-响应)
│ ↑
├── 后台存数据库 | 注意:家长和后面的大屏是两拨人!
│ | 家长用 HTTP,大屏用 WebSocket
├── 后台调:Gateway::sendToUid("classroom_1", json数据) → 推给大屏
│
└── 内部怎么找到大屏的连接?
→ 构造指令包
→ 广播给所有 Gateway 进程(不知道 classroom_1 连在哪个 Gateway 上)
→ 每个 Gateway 查自己的 _uidConnections
→ 谁管着这个连接谁负责推出,没管着的忽略
为什么广播? 后台只知道 uid,不知道这个用户连在哪个 Gateway 进程上,所以告诉所有人,各自认领。
阶段 4:断线
php
关闭页面 / 网络断开
│
└── 自动触发 Events::onClose($client_id)
GatewayWorker 自动清除绑定关系
可以记日志、通知其他人 "xx 下线了"
四、client_id 的秘密 ------ 核心原理
上一节反复出现了 client_id,现在把它拆开看------这是理解整个 GatewayWorker 的关键。
client_id 不是随机字符串,而是编码后的门牌号:
php
// 每当有客户端连上 Gateway,Gateway 自动生成:
client_id = bin2hex(pack('NnN', $local_ip, $local_port, $connection_id))
// ↑ ↑ ↑
// Gateway 自己的 IP Gateway 内部端口 自增计数器
// (如 127.0.0.1) (如 2900,来自 (每来一个新连接 +1)
// startPort 配置)
重点:这里的 IP 和端口都是 Gateway 进程自己的,跟客户端的 IP、端口没有任何关系。 客户端的地址不参与编码。
解码回去:
bash
$local_ip → Gateway 进程的内网 IP(如 127.0.0.1)
$local_port → Gateway 的内部通信端口(如 2900,来自 start_gateway.php 的 startPort)
$connection_id → 该 Gateway 进程上第几个连接(自增数字)
一个真实例子:
ini
client_id = "7f0000010b5400000001"
解码 → Gateway IP: 127.0.0.1 内部端口: 2900 连接号: 1
为什么同一个客户端重连后 client_id 会变
因为 connection_id 是一个自增计数器,不是客户端的标识:
ini
同一个人,同一台电脑,断开后立刻重连:
第 1 次连接:Gateway IP=127.0.0.1, port=2900, conn_id=1 → "7f0000010b5400000001"
第 2 次连接:Gateway IP=127.0.0.1, port=2900, conn_id=2 → "7f0000010b5400000002"
↑ 只有这里变了
Gateway 的 IP 和端口没变(还是那个进程),但计数器 +1 了,client_id 就不同了。这就是为什么断开重连后必须重新 bindUid------旧的 client_id 已经作废,新连接拿到了新的编号。
既然 client_id 会变,怎么定位目标 Gateway
虽然 connection_id 不同,但 Gateway 的 IP 和端口不变------这两个字段足以让任何进程直接 TCP 连到目标 Gateway。
这意味着:拿到 client_id,就等于拿到了目标 Gateway 的地址。后面的 bindUid 就是利用这一点实现"不走 Register 直达"的。
这是后面一切理解的基础------既然地址都写在里面了,有些操作自然就不需要去 Register 查了。
类比快递单号和人名,后文会展开。
五、bindUid 和 sendToUid ------ 最核心的区别
这是 GatewayWorker 里问得最多、最容易搞混的地方。
bindUid:直达(不走 Register)
php
Gateway::bindUid($client_id, 'user_123');
scss
bindUid(client_id, uid)
→ 解码 client_id → 拿到 Gateway 的 IP + 端口 → 直接 TCP 连过去 ✅
→ 在目标 Gateway 的内存里记下:uid → client_id
→ Register 全程不知道这件事
sendToUid:广播寻人(必须走 Register)
php
Gateway::sendToUid('user_123', $message);
scss
sendToUid(uid, msg)
→ 只有 uid,不知道他在哪个 Gateway 上
→ 去 Register 问:所有 Gateway 的地址列表给我 📋
→ 挨个发给每一个 Gateway
→ 每个 Gateway 查自己的内存:uid 在我这吗?
→ 在的那个负责推送,不在的忽略
一句话判断标准
| 参数里有 | 通信方式 | 走 Register? | 为什么 |
|---|---|---|---|
client_id |
直达目标 Gateway | ❌ 不走 | client_id 自带 IP+端口 |
uid |
广播问所有 Gateway | ✅ 必须走 | uid 只是身份,不含地址 |
跟是前端还是后端、HTTP 还是命令行都无关,只看参数里带没带地址。
快递单号比喻(三个概念一次分清)
client_id= 快递单号,上面写着收件地址,快递员直接送uid= 人名,不知住哪,只能去查通讯录然后挨家挨户问bindUid= 登记"这单快递是张三的",以后找张三就知道往哪送了
一个经典误判
开发时可能会遇到:sendToUid 没反应,但 bindUid 正常。
别急着怀疑代码 ------如果 Register 端口配错了(比如代码写的 1236,实际启动的 1238),bindUid 因为 client_id 自带地址所以能通,容易误以为一切正常。但实际上 sendToUid 需要走 Register,端口不对它就找不到人了。
排查口诀:bindUid 能通不代表 Register 没问题,先拿 sendToUid 验证。
六、两种开发模式 ------ 怎么选
模式一:纯 GatewayWorker(练手 / IoT)
markdown
浏览器 ←→ WebSocket ←→ GatewayWorker
└─ Events.php 写所有逻辑
Events.php 的 onMessage 自己处理业务:
php
public static function onMessage($client_id, $message)
{
$data = json_decode($message, true);
switch ($data['type']) {
case 'login':
Gateway::bindUid($client_id, $data['uid']);
Gateway::sendToClient($client_id, json_encode(['type' => 'login_ok']));
break;
case 'chat':
Gateway::sendToUid($data['to_uid'], json_encode([
'type' => 'msg', 'from' => $data['from_uid'], 'content' => $data['content']
]));
break;
}
}
优点:简单直接,客户端和服务端全走 WebSocket,少一跳。
不是说模式一不能连数据库、不能做鉴权。BusinessWorker 本质就是一个 PHP 进程,能
useThinkPHP 的 Model、能查数据库、能做 Session 校验------下面模式一的鉴权示例就是最好的证明。所以技术上两种模式都能干所有事 ,区别不在能不能,而在代码怎么组织。
模式二:GatewayWorker + 后台框架
客户端 ─── HTTP ───→ ThinkPHP 后台 ─── GatewayClient ───→ GatewayWorker ──推送──→ 客户端
Events.php 极简化,onMessage 甚至可以留空。业务逻辑全部放在 ThinkPHP 控制器里:
php
// Events.php - 只负责返回 client_id
public static function onConnect($client_id)
{
Gateway::sendToClient($client_id, json_encode([
'type' => 'connected', 'client_id' => $client_id
]));
}
// ThinkPHP 控制器 - 绑定
function bind() {
Gateway::bindUid($_POST['client_id'], 'classroom_' . $_POST['classroom_id']);
return json(['code' => 1]);
}
// ThinkPHP 控制器 - 推送
function register() {
// ... 存数据库 ...
Gateway::sendToUid('classroom_1', json_encode(['type' => 'notify', 'msg' => '报名成功']));
}
两种模式真正的区别
重申:技术上没有高下之分,两种都能连数据库、做鉴权。 区别在哪:
| 模式一(纯 WebSocket) | 模式二(HTTP 推送) | |
|---|---|---|
| 业务代码在哪 | Events.php(长驻进程) | ThinkPHP 控制器(请求-响应) |
| 改业务代码后 | 需要重启 Worker | 不需要重启,即时生效 |
| 鉴权方式 | 在 onMessage 里自己做 Session / Token 校验 | 复用框架的中间件、cookie |
| 适合什么 | 聊天、即时通讯------收发消息是高频双向的 | 推送通知、大屏刷新------客户端主要是被动接收 |
| 调试难度 | 稍高,日志要靠 Worker 输出 | 和普通 Web 开发一样,请求可见 |
一句话判断:如果客户端主要是发消息(聊天),用模式一;如果客户端主要是收消息(通知推送),用模式二。 实际项目里两种经常混用。
模式一里做鉴权的真实写法
举个例子,在 ThinkPHP 里把 GatewayWorker 放到 addons 目录,Events.php 直接 use ThinkPHP 的类:
php
public static function onMessage($client_id, $message)
{
$message_data = json_decode($message, true);
if (!$message_data) return;
// Session 鉴权------跟 HTTP 接口里写的一样
if (!isset($_SESSION['user_id']) || !$_SESSION['user_id']) {
return; // 没登录,忽略
}
// 调 ThinkPHP 的 Chat 模型处理业务
$user_id = $_SESSION['user_id'];
$chat = new \addons\xilujob\chatprograms\library\Chat();
$ret = $chat->{$message_data['type']}($user_id, $message_data);
// 回复发送方
Gateway::sendToClient($client_id, json_encode($ret));
// 如果接收人在线,转发
if ($ret['code'] == 1 && in_array($message_data['type'], ['say'])) {
$receive_uid = 'user_' . $ret['data']['to_uid'];
if (Gateway::isUidOnline($receive_uid)) {
$ret['type'] = 'new_msg';
Gateway::sendToUid($receive_uid, json_encode($ret));
}
}
}
这就是模式一的正确打开方式------并不是 Events.php 就只能写 switch-case 转发,它能做任何 ThinkPHP 能做的事。
混合模式(实际项目最常用)
两种模式共存:客户端之间的即时聊天走 WebSocket(模式一),服务器主动推送通知走 HTTP(模式二)。GatewayWorker 天然支持,不用做任何额外配置。
七、完整代码实战 ------ 从零搭聊天系统
这一段是可以照抄跑通的代码,从前端到后端全覆盖。
需要创建 / 修改的文件
| 文件 | 操作 | 说明 |
|---|---|---|
start_register.php |
不动 | 框架模板就是对的 |
start_gateway.php |
不动 | 最多改个端口 |
start_businessworker.php |
🆕 新建 | 启动 Worker |
Events.php |
🆕 在这写代码 | 业务逻辑入口 |
| 前端 HTML | 🆕 新建 | 聊天页面 |
1. start_businessworker.php
php
<?php
use Workerman\Worker;
use GatewayWorker\BusinessWorker;
require_once __DIR__ . '/vendor/autoload.php';
$worker = new BusinessWorker();
$worker->name = 'BusinessWorker';
$worker->count = 4;
$worker->registerAddress = '127.0.0.1:1238';
$worker->eventHandler = 'Events'; // 指向业务处理类
Worker::runAll();
2. Events.php
php
<?php
use GatewayWorker\Lib\Gateway;
class Events
{
// 连接建立 → 返回 client_id 给客户端
public static function onConnect($client_id)
{
Gateway::sendToClient($client_id, json_encode([
'type' => 'connected',
'client_id' => $client_id
]));
}
// 收到客户端消息 → 根据 type 分发
public static function onMessage($client_id, $message)
{
$data = json_decode($message, true);
switch ($data['type']) {
case 'login':
Gateway::bindUid($client_id, $data['uid']);
Gateway::sendToClient($client_id, json_encode(['type' => 'login_ok']));
break;
case 'chat':
Gateway::sendToUid($data['to_uid'], json_encode([
'type' => 'msg',
'from' => $data['from_uid'],
'content' => $data['content']
]));
break;
}
}
// 连接断开
public static function onClose($client_id)
{
// 记日志、通知其他人
}
}
3. Events.php 五个回调速查
| 回调 | 触发时机 | 频率 | 用途 |
|---|---|---|---|
onConnect($client_id) |
客户端连上 | ⭐⭐ | 返回 client_id。可推不可收 |
onMessage($client_id, $data) |
收到客户端消息 | ⭐⭐⭐ | 处理业务:登录、聊天、绑定 |
onClose($client_id) |
客户端断开 | ⭐⭐ | 清理资源,通知"xx 下线" |
onWorkerStart($worker) |
进程启动 | ⭐ | 初始化定时器、连接池 (别放 Worker::runAll() 之前) |
onWorkerStop($worker) |
进程退出 | ⚪ | 保存状态、清理连接 |
4. 前端 HTML(聊天页面)
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>聊天 Demo</title>
</head>
<body>
<p id="status">连接中...</p>
<div id="messages"></div>
<input type="text" id="input" placeholder="输入消息">
<button onclick="sendMsg()">发送</button>
<script>
var ws = new WebSocket('ws://127.0.0.1:8282');
var myUid = 0;
ws.onopen = function() {
document.getElementById('status').innerHTML = '已连接';
};
ws.onmessage = function(e) {
var data = JSON.parse(e.data);
switch (data.type) {
case 'connected':
// 拿到 client_id → 发登录消息绑定身份
myUid = Date.now(); // 用时间戳当临时 uid
ws.send(JSON.stringify({ type: 'login', uid: myUid }));
break;
case 'login_ok':
document.getElementById('status').innerHTML = '已登录 (uid=' + myUid + ')';
break;
case 'msg':
document.getElementById('messages').innerHTML +=
'<p>[' + data.from + '] ' + data.content + '</p>';
break;
}
};
ws.onclose = function() {
document.getElementById('status').innerHTML = '已断开';
};
function sendMsg() {
var text = document.getElementById('input').value;
ws.send(JSON.stringify({
type: 'chat',
from_uid: myUid,
to_uid: prompt('发给谁?输入对方的 uid'),
content: text
}));
document.getElementById('input').value = '';
}
</script>
</body>
</html>
5. 断线重连
把上面 HTML 里这三部分替换成下面的:
var ws = new WebSocket(...)、ws.onopen、ws.onclose。var myUid、ws.onmessage和sendMsg()保持不变。
javascript
var ws = null;
var reconnectTimer = null;
function connect() {
if (ws) {
ws.onclose = null; // 先解绑回调,防止关旧连接也触发重连
ws.close();
}
ws = new WebSocket('ws://127.0.0.1:8282');
ws.onopen = function() {
console.log('连上了');
if (reconnectTimer) {
clearInterval(reconnectTimer);
reconnectTimer = null;
}
// 把上面第4节 onopen 里的逻辑搬过来(显示状态、发 login 等)
};
ws.onclose = function() {
console.log('断了,5 秒后重连');
if (!reconnectTimer) {
reconnectTimer = setInterval(connect, 5000);
}
};
}
connect();
6. ThinkPHP 后台(生产模式)
php
// 绑定接口
function bind() {
$client_id = $_POST['client_id'];
$classroom_id = $_POST['classroom_id'];
Gateway::bindUid($client_id, 'classroom_' . $classroom_id);
return json(['code' => 1, 'msg' => '绑定成功']);
}
// 报名接口(处理业务 + 推送通知)
function register() {
$classroom_id = input('post.classroom_id');
$student_name = input('post.student_name');
// ... 存数据库、校验 ...
Gateway::sendToUid('classroom_' . $classroom_id, json_encode([
'type' => 'notify',
'message' => '有新学生报名: ' . $student_name
]));
}
7. 消息协议规范
所有消息统一走 JSON,用 type 字段区分:
json
// 服务端 → 客户端
{"type":"connected", "client_id":"7f0000010a5400000001"} // 连接确认
{"type":"login_ok"} // 登录成功
{"type":"msg", "from":123, "content":"你好"} // 聊天消息
{"type":"notify", "message":"报名成功"} // 服务端通知
// 客户端 → 服务端
{"type":"login", "uid":123} // 身份登记
{"type":"chat", "from_uid":123, "to_uid":456, "content":"你好"} // 聊天
统一格式的好处:Events.php 里一个 switch($data['type']) 就分完了,不猜消息内容。
八、前端 WebSocket API 速查
这是浏览器原生 API,不是 GatewayWorker 的,文档去 MDN 搜 "WebSocket"。
javascript
var ws = new WebSocket('ws://IP:8282'); // 注意:不是 http://
// 4 个事件
ws.onopen = function(e) { /* 连接成功 */ };
ws.onmessage = function(e) { var data = JSON.parse(e.data); };
ws.onclose = function(e) { /* 断开 */ };
ws.onerror = function(e) { /* 出错 */ };
// 2 个方法
ws.send(JSON.stringify({ type: 'chat', content: '你好' }));
ws.close();
// 1 个属性
ws.readyState // 0=连接中 1=已连接 2=关闭中 3=已关闭
注意: JS 大小写敏感,WebSocket 不能写成 websocket。
九、GatewayWorker API 速查
php
// ===== 配置(调任何方法前必须设好) =====
Gateway::$registerAddress = '127.0.0.1:1238';
// ===== 连接级别(参数带 client_id,不走 Register,直达) =====
Gateway::sendToClient($client_id, $data); // 发给指定连接
Gateway::closeClient($client_id); // 踢人下线
// ===== 用户级别(参数只有 uid,走 Register 寻址) =====
Gateway::bindUid($client_id, $uid); // 绑定 client_id ↔ uid
Gateway::unbindUid($client_id, $uid); // 解绑
Gateway::sendToUid($uid, $data); // 发给指定用户
Gateway::isUidOnline($uid); // 用户在线吗
Gateway::getClientIdListByUid($uid); // 查 uid 对应所有连接
// ===== 广播 =====
Gateway::sendToAll($data); // 发给所有连接的客户端
十、端口说明
| 端口 | 谁连 | 监听 IP | 原因 |
|---|---|---|---|
| 8282 | 浏览器 / 客户端 | 0.0.0.0 |
必须接受外部连接 |
| 1238 | Gateway / Worker 进程 | 127.0.0.1 |
安全,不对外暴露 |
ini
8282 = 大门,谁来都行
1238 = 内部花名册,只有自己人能看
十一、踩坑记录(12 个真实坑)
| # | 现象 | 原因 | 解决 |
|---|---|---|---|
| 1 | stream_socket_client 报错 |
Register 端口不对或进程没启动 | 检查 registerAddress 端口和 Register 实际端口是否一致 |
| 2 | ?? 语法报错 |
Web 服务器 PHP < 7.1 | phpStudy 切换网站 PHP 版本到 7.1+ |
| 3 | CLI PHP 8.3 但网页报语法错 | CLI 和 Web 的 PHP 版本不一致 | CLI 和网站必须用同一版本 |
| 4 | Composer 装了 gateway-worker 4.x,网站跑 PHP 5.6 报错 | CLI 是 8.3,composer 自动选了最新版 | composer.json 改 ^3.0,composer update |
| 5 | sendToUid 没反应但 bindUid 正常 |
Register 端口配错 | 检查 registerAddress 配置和 Register 实际监听端口一致(bindUid 自带地址能通,掩盖了问题) |
| 6 | 客户端连不上 | 防火墙拦了 / IP 配置错 | Gateway 必须 0.0.0.0;先 telnet IP 8282 试 |
| 7 | 调 GatewayClient 没反应 | registerAddress 和 Register 不一致 | 两边都检查,确认 Register 进程在跑 |
| 8 | 消息发出后客户端收不到 | 没调 bindUid 或客户端已断 | 先 isUidOnline() 确认在线 |
| 9 | return 1 前端 res.json() 失败 |
ThinkPHP 返回了非 JSON | 必须 return json([...]) |
| 10 | 打印 Promise{pending} |
fetch 没写 .then() |
post(...).then(data => console.log(data)) |
| 11 | {e.client_id} JS 语法报错 |
点号不是合法对象 key | 写 {'client_id': e.client_id} |
| 12 | Windows 部署:count 无效、start.php start 报错 |
没 pcntl 扩展 | 用 start_for_win.bat,只能单进程 |
十二、完整数据流 ------ 两用户聊天的全过程
把前面所有知识点串起来,追踪一条消息从头到尾走的路径:
css
浏览器A (uid=123) 浏览器B (uid=456)
│ │
│ ws://127.0.0.1:8282 │ ws://127.0.0.1:8282
↓ ↓
Gateway ───→ Register 报到 Gateway ───→ Register 报到
│ 分配到 client_id_A │ 分配到 client_id_B
↓ ↓
onConnect → 返回 {type:'connected', ...} onConnect → 返回 {type:'connected', ...}
↓ ↓
浏览器A 收到 connected 浏览器B 收到 connected
│ │
│ ws.send({type:'login', uid:123}) │ ws.send({type:'login', uid:456})
↓ ↓
BusinessWorker.onMessage BusinessWorker.onMessage
└→ bindUid(client_id_A, 123) └→ bindUid(client_id_B, 456)
└→ 返回 login_ok └→ 返回 login_ok
↓ ↓
╔══════════════ A 给 B 发消息 ═══════════════╗
浏览器A: ws.send({type:'chat', to_uid:456, content:'你好'})
↓
BusinessWorker.onMessage
└→ sendToUid(456, '{"type":"msg","from":123,"content":"你好"}')
│
├→ 去 Register(:1238) 问:所有 Gateway 地址列表
├→ 遍历每个 Gateway:uid=456 在你这吗?
├→ Gateway-1 查内存:没有 → 跳过
├→ Gateway-2 查内存:有!→ 推送
└→ 浏览器B 收到消息 ✅
关键看点:
- 前两步(连接、登录)不走 Register,client_id 自带地址
- 最后一步(sendToUid)必须走 Register,只有 uid 不知道在哪
- 流程中判断标准始终是"参数里有没有地址"
十三、一句不漏 ------ 七句话记全部
- Gateway 管连接,Register 管地址簿,BusinessWorker 写业务 ------ 三种进程各司其职
- client_id 是编码后的门牌号 (IP + 端口 + 连接号),能直接定位;uid 只是名字,没有地址
- bindUid 直达(不走 Register),sendToUid 广播寻人(必须走 Register) ------ 跟谁调无关,看参数有没有地址
- 模式一和模式二都能连数据库、做鉴权 ------ 区别不在技术能不能,在业务代码放哪维护、是不是需要重启 Worker
- Events.php 就是全部业务入口 ------ 数据库、鉴权、推送都在这里
- WebSocket API 是浏览器标准,去 MDN 查 ------ 别和 GatewayWorker 的方法混了
- CLI PHP 和网站 PHP 版本必须对齐 ------ 不然 composer 装的包能跑,网页跑不动