GatewayWorker 是基于 Workerman 的一个实时通信框架,专门解决服务端主动推送。平时做 Web 开发,都是客户端主动请求服务器(HTTP 请求-响应模式),服务器没办法主动找客户端。GatewayWorker 解决了这个问题------客户端和服务器之间建一条 WebSocket 长连接,想推就推。客户端可以是网页、小程序、App,只要支持 WebSocket 就行。
真实用法:报名登记页面,家长填完表单提交,服务器处理完后直接推一条"报名成功"的通知到页面右下角,不用刷新不用轮询。
一、从一个需求出发
拿报名登记页举例:家长填完信息提交 → 后台处理 → 主动推一条通知到页面右下角。整个过程涉及四个角色:
markdown
① WebSocket 长连接(建好就一直连着)
客户端 ←────────────────────────────────────────→ GatewayWorker
│ ↑
│ ② HTTP 请求 │ ③ GatewayClient
│ (提交数据、绑定身份、查数据库) │ (后台调它来操控推送)
↓ │
后台(ThinkPHP) ───────────────────────────────────────┘
| 角色 | 是什么 | 在这个场景里 |
|---|---|---|
| GatewayWorker | 推送引擎 | 维持长连接、转发消息 |
| 客户端 | 家长打开的报名页面 | 连 WebSocket 等推送 |
| 后台 | ThinkPHP 接口 | 处理报名逻辑 |
| GatewayClient | 后台调的一个 PHP 库 | 让后台能操控 GatewayWorker |
记住这句话就够了:客户端连上 GatewayWorker 保持长连接,后台把事情处理完后通过 GatewayWorker 把结果推回去。
二、内部三个"员工"------启动后到底跑了什么
启动 GatewayWorker 后,实际是起了三种进程,各自干活:
yaml
Register(注册中心,端口 1238)
干的事:告诉 Gateway 和 BusinessWorker 彼此在哪
启动后撮合完就没它事了
╱ ╲
Gateway(门卫) BusinessWorker(工人)
监听 8282 端口,接客 不对外暴露,纯内部干活
维持客户端长连接 跑你的 Events.php
消息来了转发给工人 处理完告诉门卫推给谁
↑
客户端连这个端口
简单记:Register 管撮合、Gateway 管连接、BusinessWorker 管逻辑。
⚠️ Register 端口(1238)只能监
127.0.0.1。它用的是纯文本协议,没有任何鉴权------任何人连上来都能假装自己是 Gateway 或 BusinessWorker 发指令,从而劫持整个推送系统。所以必须限制只允许本机进程访问。
配置文件对应关系
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 |
你的业务代码,跑在 BusinessWorker 里面 |
关键看 start_gateway.php:
php
$gateway = new Gateway("tcp://0.0.0.0:8282"); // 对外端口,0.0.0.0 表示接受任何 IP 的连接
$gateway->count = 2; // 开 2 个进程,多进程分摊连接压力
$gateway->lanIp = '127.0.0.1'; // 内网通信用的 IP
$gateway->startPort = 2900; // 内网通信端口从 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
所以启动后一共跑 1 + 2 + 4 = 7 个进程。
count 数量怎么定:
| 进程类型 | 建议 count | 理由 |
|---|---|---|
| Register | 永远 1 | 就一个登记簿,无状态,多开没意义 |
| Gateway | 1 ~ CPU核数 |
主要吃内存(每连接 ~4KB),连接数上去了优先加它 |
| BusinessWorker | CPU核数 ~ 2×CPU核数 |
跑业务逻辑,吃 CPU,设多了上下文切换反而慢 |
实际项目:一台 4 核机器,5000 并发连接 → gateway.count=2,businessworker.count=8。
你到手后实际要做的就三件事:
- 改端口 ------
start_gateway.php里把8282换成你的端口,防火墙放行 - 调 count ------ 按上面表格改
gateway.count和businessworker.count,不用纠结,默认值先跑着也行 - 写
Events.php------ 这是你唯一需要写代码的地方,后面第六节会展开
另外三个配置(lanIp、startPort、registerAddress)单机部署不用改,保持默认就行。start_register.php 和 start_businessworker.php 也几乎不需要动------框架模板就是对的。
三、跟一条连接走完全程
把每个阶段拆开看------从连接建立到消息送达,一共有四个阶段。
阶段 1:连接建立
php
页面打开
│
├── ① new WebSocket("ws://192.168.1.6:8282")
│ TCP 三次握手 → 连接通了
│
├── ② Gateway 给这个连接分配一个全局唯一的编号:client_id
│ 比如 "7f0000010a5400000001"
│ 注意:这个编号断开重连就会变,只给内部用
│
└── ③ 自动触发 Events::onConnect($client_id)
这时候连接已通,但客户端还没发过消息
你能主动推:Gateway::sendToClient($client_id, "...")
但不能收------客户端还没调 ws.send()
阶段 2:身份绑定
bash
客户端收到 { type:'connected', client_id:'7f0000...' }
│
├── ④ 发 HTTP 给后台 /api/bind
│ 带两个东西:client_id + 业务身份(如 classroom_id=1)
│
├── ⑤ 后台调 GatewayClient::bindUid("7f0000...", "classroom_1")
│
└── ⑥ GatewayWorker 内部记下映射关系:
_uidConnections["classroom_1"] = ["7f0000010a5400000001"]
以后说"给 classroom_1 发",就知道往哪推了
阶段 3:消息推送
rust
家长提交报名
│
├── ⑦ 后台存数据库
│
├── ⑧ 后台调:
│ GatewayClient::sendToUid("classroom_1", '{"type":"notify",...}')
│
└── ⑨ GatewayWorker 内部怎么找到连接?
→ 构造内部指令包
→ 广播给所有 Gateway 进程(因为不知道 classroom_1 连在哪个 Gateway 上)
→ 每个 Gateway 查自己的 _uidConnections
→ 谁管着 classroom_1 的连接,谁就负责推出去
→ 没管着的自然忽略
为什么广播给所有 Gateway?因为后台不知道具体连在哪个 Gateway 上了,所以告诉所有人,各自认领。
阶段 4:断线
php
关闭页面 / 网络断开
│
└── ⑩ 自动触发 Events::onClose($client_id)
GatewayWorker 自动清除 _uidConnections 里的绑定
你可以在这里记日志、通知其他人"xx 下线了"
常用的几个 API(备忘)
php
Gateway::sendToClient($client_id, $data); // 发给指定连接
Gateway::sendToAll($data); // 广播给所有人
Gateway::sendToUid($uid, $data); // 发给指定用户(重点)
Gateway::bindUid($client_id, $uid); // 绑定 client_id ↔ uid
Gateway::unbindUid($client_id, $uid); // 解绑
Gateway::isUidOnline($uid); // 查在线状态
Gateway::getClientIdListByUid($uid); // 查 uid 对应的所有连接
四、两种用法------怎么选
模式一:纯 GatewayWorker(练手用)
客户端 ←→ WebSocket ←→ GatewayWorker(Events.php 里写所有逻辑)
Events.php 的 onMessage 自己处理业务。简单直接,但没数据库、没法做鉴权,改需求要重启。
适合:原型验证、IoT 设备通信。
模式二:GatewayWorker + 后台框架(正经项目用)
scss
客户端 ──── HTTP ────→ 后台(ThinkPHP) ──── GatewayClient ────→ GatewayWorker
客户端 ←── WebSocket ←──────────────────── 推送 ────────────────┘
Events.php 极简化,onMessage 甚至可以留空:
php
public static function onMessage($client_id, $message)
{
// 什么都不写,消息全走 HTTP 了
}
优点:数据库、鉴权、业务逻辑全在框架里,和普通 Web 开发没区别。
为什么走 HTTP 而不是 WebSocket 转后台?
技术上可以做------WebSocket 收到消息,GatewayWorker 转给后台。但代价很大:
| 走 WebSocket → 后台 | 走 HTTP → 后台 |
|---|---|
| 消息不持久化,丢了没了 | 后台收到后可存库 |
| 没法做登录校验 | 正常走 cookie/token |
| Events.php 越写越乱 | 业务集中在框架 |
| 发出去的消息撤不回 | 有记录就能撤 |
多一跳 HTTP,换来全套业务基础设施,生产环境必须这么干。
五、client_id 和 uid------这俩最容易搞混
上面已经反复出现了,这里专门拎清楚:
| client_id | uid | |
|---|---|---|
| 谁给的 | GatewayWorker 自动分配 | 你自己定的 |
| 长啥样 | 7f0000010a5400000001 |
user_123 / classroom_1 |
| 断了重连 | 变 | 不变 |
| 用来干啥 | 内部定位一个连接 | 代表业务上的一个人/物 |
类比:
client_id= 快递单号,每次寄件不一样uid= 身份证号,一辈子不变bindUid= 把"这单快递是张三的"登记一下
实际场景:班级大屏绑定 classroom_1,后台调 sendToUid('classroom_1', ...),大屏就收到推送。你不需要关心 classrom_1 的 client_id 是什么,绑定后内部自动查。
六、完整流程代码------从前端到后端跑通
步骤一:客户端建立 WebSocket 连接
javascript
// ========== 1. 建立 WebSocket 连接 ==========
// new WebSocket(url) 一执行,浏览器立刻开始 TCP 握手
// 注意协议前缀是 ws://,不是 http://
var ws = new WebSocket('ws://192.168.1.6:8282');
// ↑ ↑
// GatewayWorker Gateway 端口
// 所在服务器的 IP
var clientId = null; // 后面存 GatewayWorker 分配给我的连接编号
// ========== 2. 连接成功后的回调(TCP 握手完成) ==========
ws.onopen = function() {
console.log('WebSocket 连上了');
// 注意:这里还不知道 client_id
// client_id 要等服务端 Events::onConnect 回调里主动发过来
// 所以不要在这里做绑定,等收到消息再说
};
// ========== 3. 收到服务端消息的回调 ==========
ws.onmessage = function(e) {
// e.data 是服务端发过来的原始字符串
// GatewayWorker 底层帮你封装好了,这里直接拿到 JSON 字符串
var msg = JSON.parse(e.data); // 把 JSON 字符串转成 JS 对象
// 情况 A:收到了连接确认消息(包含 client_id)
if (msg.type === 'connected') {
clientId = msg.client_id; // 拿到自己的连接编号
console.log('我的 client_id:', clientId);
// 拿着 client_id 去后台登记身份
// fetch() 是浏览器内置的 HTTP 请求方法,替代老旧的 XMLHttpRequest
fetch('/api/bind', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// 告诉后台:我是 classroom_1 的大屏,我的连接编号是 xxx
body: JSON.stringify({
client_id: clientId,
classroom_id: 1
})
});
}
// 情况 B:收到了推送消息
if (msg.type === 'batch_send') {
// 这里是后台主动推过来的通知,比如"有新学生报名"
alert(msg.message);
}
};
// ========== 4. 连接断开的回调 ==========
ws.onclose = function() {
console.log('WebSocket 断开了');
clientId = null; // 清除旧的 client_id,重连后会重新分配
// 实际项目里这里应该写重连逻辑,见下方"断线重连"
};
// ========== 5. 连接出错的回调 ==========
ws.onerror = function(err) {
console.log('WebSocket 出错:', err);
// 一般网络不通或端口没开会触发这里
};
断线重连(实际项目必备)
javascript
// 上面 onclose 里提到重连,完整写法是这样:
var ws = null;
var reconnectTimer = null;
function connect() {
// 如果之前有旧连接,先关掉(防止多个连接残留)
if (ws) {
ws.onclose = null; // 先解绑回调,不然关旧连接也会触发重连
ws.close();
}
ws = new WebSocket('ws://192.168.1.6:8282');
ws.onopen = function() {
console.log('连上了');
// 连上后清除重连定时器
if (reconnectTimer) {
clearInterval(reconnectTimer);
reconnectTimer = null;
}
// 发绑定请求...
};
ws.onclose = function() {
console.log('断了,5 秒后重连');
// 断线后启动定时器,每隔 5 秒尝试重连
if (!reconnectTimer) {
reconnectTimer = setInterval(connect, 5000);
}
};
}
// 页面加载时第一次连接
connect();
步骤二:Events.php 返回 client_id
php
// 客户端连上后,GatewayWorker 会自动调这个函数
public static function onConnect($client_id)
{
// 把 client_id 告诉客户端,客户端收到后才能去后台绑定
Gateway::sendToClient($client_id, json_encode([
'type' => 'connected',
'client_id' => $client_id // 这个值是 GatewayWorker 分配的内部编号
]));
}
步骤三:后台绑定 + 推送
php
// ThinkPHP 控制器 ------ 绑定接口
function bind() {
$client_id = $_POST['client_id'];
$classroom_id = $_POST['classroom_id'];
// 告诉 GatewayWorker:这个 client_id 对应 classroom_1
// 之后给 classroom_1 发消息,GatewayWorker 就知道推到哪个连接
Gateway::bindUid($client_id, 'classroom_' . $classroom_id);
return json(['code' => 1, 'msg' => '绑定成功']);
}
// ThinkPHP 控制器 ------ 报名接口
function register() {
// ... 保存报名信息到数据库 ...
// 推送通知给该班级的大屏
Gateway::sendToUid('classroom_' . $classroom_id, json_encode([
'type' => 'batch_send',
'message' => '有新学生报名: ' . $student_name
]));
}
流程小结
php
页面打开
→ new WebSocket() 连上 GatewayWorker
→ onConnect 触发,返回 client_id
→ 客户端收到,拿 client_id + 班级号 发 HTTP 到后台 /api/bind
→ 后台调 Gateway::bindUid,登记身份
→ (用户提交报名)后台调 sendToUid,推送到对应大屏
七、Events.php 五个回调------备忘
| 回调 | 什么时候触发 | 频率 | 用来干嘛 |
|---|---|---|---|
onConnect |
有人连上 | ⭐⭐ | 返回 client_id,记连接日志 |
onMessage |
有人发了数据 | ⭐⭐⭐ | 处理消息(模式一才用) |
onClose |
有人断开 | ⭐⭐ | 清理资源,通知其他人 |
onWorkerStart |
进程启动 | ⭐ | 初始化数据库连接、定时器 |
onWorkerStop |
进程退出 | ⚪ | 保存状态、清理连接 |
记住一个细节:onConnect 时连接已通,能推不能收 ------客户端还没调 ws.send()。
八、踩坑记录 & 排查思路
客户端连不上
按顺序查:
- GatewayWorker 起来了吗?
php start.php status看进程 - 防火墙拦了 8282 端口吗?
- Gateway 监听的 IP 对了吗?
0.0.0.0才能接受外部连接,127.0.0.1只能本机连 - 先
telnet 服务器IP 8282试试,通了再查代码
后台调 GatewayClient 没反应
- GatewayClient 的
registerAddress和你 Register 的地址一致吗?默认都是127.0.0.1:1238 - Register 进程在跑吗?
- 是不是没绑定就直接调
sendToUid了?必须先bindUid再sendToUid
消息发出去了但客户端没收到
- 调
Gateway::getClientIdListByUid($uid)看看绑成功了没有 - 调
Gateway::isUidOnline($uid)确认客户端还在线 - 检查消息格式------客户端
JSON.parse解析成功了吗?
Windows 下坑最多
- ❌ 不能用
php start.php start,要用start_for_win.bat - ❌
count配置无效,只会起一个进程(没 pcntl) - ❌ 端口被占用?
netstat -ano | findstr 8282查一下
九、全景图
ini
┌──────────────────────────────────────────────────────────────┐
│ 服务器 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌────────────────────┐ │
│ │ Register │ │ Gateway │ │ BusinessWorker │ │
│ │ :1238 │◄────►│ :8282 │◄────►│ (Events.php) │ │
│ │ 撮合双方 │ │ count=2 │ │ count=4 │ │
│ │ 只127.0.0.1 │ 接客户端 │ │ 纯内部 │ │
│ └──────────┘ └────┬─────┘ └────────────────────┘ │
│ │ │
└──────────────────────────┼─────────────────────────────────────┘
WebSocket :8282
┌──────────────────────┼──────────────────────────┐
▼ ▼ ▼
┌────────┐ ┌────────────┐ ┌──────────────┐
│ 客户端 │ HTTP │ ThinkPHP │ 调 │ GatewayClient │
│ │─────────→│ │─────────→│ │
│ │ │ 业务后台 │ │ 一个 PHP 库 │
└────────┘ └────────────┘ └──────────────┘
十、核心记住这几句
- GatewayWorker 是根推送管道------不存数据、不管业务,只管送达
- client_id 是内部编号,uid 是你定的业务身份------绑定后只管 uid
- Register 撮合、Gateway 接客、BusinessWorker 干活------三种进程各司其职
- 生产环境消息走 HTTP 进后台,不要走 WebSocket------持久化 + 鉴权 + 集中管理
- Events.php 对接框架后可以几乎留空------这是正确的,不浪费