GatewayWorker 从零到一完整指南

小白看完就能自己写一套实时聊天系统。没什么高深的理论,拿真实场景从头推到尾。


一、这玩意儿解决什么问题

平时做网站是 "请求-响应" 模式:

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=2businessworker.count=8。不纠结的话默认值也能跑。

拿到代码后实际就做三件事

  1. 改端口 ------ start_gateway.php 里把 8282 换成对应端口,防火墙放行
  2. 调 count ------ 按上面表格改,不纠结就用默认值
  3. Events.php ------ 唯一需要写代码的地方,后面会展开

lanIpstartPortregisterAddress 单机部署不用改。start_register.phpstart_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 进程,能 use ThinkPHP 的 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.onopenws.onclosevar myUidws.onmessagesendMsg() 保持不变。

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.0composer 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 不知道在哪
  • 流程中判断标准始终是"参数里有没有地址"

十三、一句不漏 ------ 七句话记全部

  1. Gateway 管连接,Register 管地址簿,BusinessWorker 写业务 ------ 三种进程各司其职
  2. client_id 是编码后的门牌号 (IP + 端口 + 连接号),能直接定位;uid 只是名字,没有地址
  3. bindUid 直达(不走 Register),sendToUid 广播寻人(必须走 Register) ------ 跟谁调无关,看参数有没有地址
  4. 模式一和模式二都能连数据库、做鉴权 ------ 区别不在技术能不能,在业务代码放哪维护、是不是需要重启 Worker
  5. Events.php 就是全部业务入口 ------ 数据库、鉴权、推送都在这里
  6. WebSocket API 是浏览器标准,去 MDN 查 ------ 别和 GatewayWorker 的方法混了
  7. CLI PHP 和网站 PHP 版本必须对齐 ------ 不然 composer 装的包能跑,网页跑不动
相关推荐
铁皮饭盒1 小时前
Bun.redis真正的强项
javascript
用户938515635071 小时前
Node.js 路径与文件系统:从基础到异步进化之路
javascript·后端·面试
Cobyte2 小时前
23.Vue Vapor 组件 attrs 的实现
前端·javascript·vue.js
触底反弹2 小时前
🚀 Node.js path 和 fs 模块,从回调地狱到 async/await 的进化之路!
javascript·后端·node.js
绝世唐门三哥3 小时前
vue3中页面返回时刷新首页的处理方案
开发语言·前端·javascript
sugar__salt3 小时前
Node.js path 与 fs 核心模块完全指南 —— 从路径处理到异步流程控制
javascript·node.js
XR1234567883 小时前
食品饮料与制药行业GMP合规网络建设:无尘车间的网络不能成为污染源
开发语言·网络·php
张元清4 小时前
React useObjectUrl Hook:预览文件与 Blob,不留内存泄漏(2026)
javascript·react.js
姑苏倾城客4 小时前
Flutter中,html 与 dart 桥接沟通
javascript·flutter·html