GatewayWorker 从零到一完整指南 一、这玩意儿解决什么问题平时做网站是请求-响应模式浏览器服务器给我这个页面 → 服务器给你 → 结束服务器永远被动没法主动找浏览器说话。但现实中有大量场景需要跨客户端通知——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 启动后实际起了三种进程各自干活不打架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 → BusinessWorkerRegister 必须先起来不然后面两个启动直接报错——它们一启动就要向 Register 报到。配置文件对应关系Applications/YourApp/下四个文件文件起什么关键配置start_register.phpRegistertext://127.0.0.1:1238start_gateway.phpGatewaytcp://0.0.0.0:82822 个进程start_businessworker.phpBusinessWorker4 个进程指向 Events.phpEvents.php唯一要写的业务逻辑全在这start_gateway.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; // 去哪找 Registerstart_businessworker.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 连接开多了反而加大广播开销BusinessWorkerCPU核数 ~ 2×核数吃 CPU多了上下文切换反而慢Gateway 的 count不是越多越好。Gateway 进程增多后Register 向所有 Gateway 广播寻址的开销会变大参考第五章 sendToUid。官方推荐1 万连接以内开 2 个每多 1 万连接加 1 个。因为它主要是 I/O 密集型维持连接、转发数据不是 CPU 密集型。实际4 核机器、5000 并发 →gateway.count2businessworker.count8。不纠结的话默认值也能跑。拿到代码后实际就做三件事改端口 ——start_gateway.php里把8282换成对应端口防火墙放行调 count —— 按上面表格改不纠结就用默认值写Events.php——唯一需要写代码的地方后面会展开lanIp、startPort、registerAddress单机部署不用改。start_register.php和start_businessworker.php也几乎不需要动。三、一条连接的全过程把从页面打开到收到推送拆成四个阶段。阶段 1连接建立 → 服务端返回 client_idnew 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身份绑定客户端收到 onConnect 推过来的 { type:connected, client_id:7f0000... } │ ↑ 这条消息来自上一步 onConnect 里的 sendToClient │ ├── 发 HTTP 给后台 /api/bind │ 带两个东西client_id 业务身份如 classroom_id1 │ ├── 后台调 Gateway::bindUid(7f0000..., classroom_1) │ └── GatewayWorker 内部记下映射 _uidConnections[classroom_1] [7f0000010a5400000001] 以后说给 classroom_1 发就知道往哪推了阶段 3消息推送家长提交报名走 HTTP普通请求-响应 │ ↑ ├── 后台存数据库 注意家长和后面的大屏是两拨人 │ 家长用 HTTP大屏用 WebSocket ├── 后台调Gateway::sendToUid(classroom_1, json数据) → 推给大屏 │ └── 内部怎么找到大屏的连接 → 构造指令包 → 广播给所有 Gateway 进程不知道 classroom_1 连在哪个 Gateway 上 → 每个 Gateway 查自己的 _uidConnections → 谁管着这个连接谁负责推出没管着的忽略为什么广播后台只知道 uid不知道这个用户连在哪个 Gateway 进程上所以告诉所有人各自认领。阶段 4断线关闭页面 / 网络断开 │ └── 自动触发 Events::onClose($client_id) GatewayWorker 自动清除绑定关系 可以记日志、通知其他人 xx 下线了四、client_id 的秘密 —— 核心原理上一节反复出现了client_id现在把它拆开看——这是理解整个 GatewayWorker 的关键。client_id不是随机字符串而是编码后的门牌号// 每当有客户端连上 GatewayGateway 自动生成 client_id bin2hex(pack(NnN, $local_ip, $local_port, $connection_id)) // ↑ ↑ ↑ // Gateway 自己的 IP Gateway 内部端口 自增计数器 // (如 127.0.0.1) (如 2900来自 (每来一个新连接 1) // startPort 配置)重点这里的 IP 和端口都是 Gateway 进程自己的跟客户端的 IP、端口没有任何关系。客户端的地址不参与编码。解码回去$local_ip → Gateway 进程的内网 IP如 127.0.0.1 $local_port → Gateway 的内部通信端口如 2900来自 start_gateway.php 的 startPort $connection_id → 该 Gateway 进程上第几个连接自增数字一个真实例子client_id 7f0000010b5400000001 解码 → Gateway IP: 127.0.0.1 内部端口: 2900 连接号: 1为什么同一个客户端重连后 client_id 会变因为connection_id是一个自增计数器不是客户端的标识同一个人同一台电脑断开后立刻重连 第 1 次连接Gateway IP127.0.0.1, port2900, conn_id1 → 7f0000010b5400000001 第 2 次连接Gateway IP127.0.0.1, port2900, conn_id2 → 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直达不走 RegisterGateway::bindUid($client_id, user_123);bindUid(client_id, uid) → 解码 client_id → 拿到 Gateway 的 IP 端口 → 直接 TCP 连过去 ✅ → 在目标 Gateway 的内存里记下uid → client_id → Register 全程不知道这件事sendToUid广播寻人必须走 RegisterGateway::sendToUid(user_123, $message);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实际启动的 1238bindUid 因为client_id自带地址所以能通容易误以为一切正常。但实际上 sendToUid 需要走 Register端口不对它就找不到人了。排查口诀bindUid 能通不代表 Register 没问题先拿 sendToUid 验证。六、两种开发模式 —— 怎么选模式一纯 GatewayWorker练手 / IoT浏览器 ←→ WebSocket ←→ GatewayWorker └─ Events.php 写所有逻辑Events.php 的onMessage自己处理业务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 控制器里// 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 直接useThinkPHP 的类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 新建启动 WorkerEvents.php在这写代码业务逻辑入口前端 HTML 新建聊天页面1. start_businessworker.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 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聊天页面!DOCTYPE html html head meta charsetUTF-8 title聊天 Demo/title /head body p idstatus连接中.../p div idmessages/div input typetext idinput placeholder输入消息 button onclicksendMsg()发送/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 /html5. 断线重连把上面 HTML 里这三部分替换成下面的var ws new WebSocket(...)、ws.onopen、ws.onclose。var myUid、ws.onmessage和sendMsg()保持不变。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 后台生产模式// 绑定接口 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字段区分// 服务端 → 客户端 {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。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 速查// 配置调任何方法前必须设好 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必须接受外部连接1238Gateway / Worker 进程127.0.0.1安全不对外暴露8282 大门谁来都行 1238 内部花名册只有自己人能看十一、踩坑记录12 个真实坑#现象原因解决1stream_socket_client报错Register 端口不对或进程没启动检查registerAddress端口和 Register 实际端口是否一致2??语法报错Web 服务器 PHP 7.1phpStudy 切换网站 PHP 版本到 7.13CLI PHP 8.3 但网页报语法错CLI 和 Web 的 PHP 版本不一致CLI 和网站必须用同一版本4Composer 装了 gateway-worker 4.x网站跑 PHP 5.6 报错CLI 是 8.3composer 自动选了最新版composer.json改^3.0composer update5sendToUid没反应但bindUid正常Register 端口配错检查registerAddress配置和 Register 实际监听端口一致bindUid 自带地址能通掩盖了问题6客户端连不上防火墙拦了 / IP 配置错Gateway 必须0.0.0.0先telnet IP 8282试7调 GatewayClient 没反应registerAddress 和 Register 不一致两边都检查确认 Register 进程在跑8消息发出后客户端收不到没调 bindUid 或客户端已断先isUidOnline()确认在线9return 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}12Windows 部署count 无效、start.php start报错没 pcntl 扩展用start_for_win.bat只能单进程十二、完整数据流 —— 两用户聊天的全过程把前面所有知识点串起来追踪一条消息从头到尾走的路径浏览器A (uid123) 浏览器B (uid456) │ │ │ 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 地址列表 ├→ 遍历每个 Gatewayuid456 在你这吗 ├→ Gateway-1 查内存没有 → 跳过 ├→ Gateway-2 查内存有→ 推送 └→ 浏览器B 收到消息 ✅关键看点前两步连接、登录不走 Registerclient_id 自带地址最后一步sendToUid必须走 Register只有 uid 不知道在哪流程中判断标准始终是参数里有没有地址十三、一句不漏 —— 七句话记全部Gateway 管连接Register 管地址簿BusinessWorker 写业务—— 三种进程各司其职client_id 是编码后的门牌号IP 端口 连接号能直接定位uid 只是名字没有地址bindUid 直达不走 RegistersendToUid 广播寻人必须走 Register—— 跟谁调无关看参数有没有地址模式一和模式二都能连数据库、做鉴权—— 区别不在技术能不能在业务代码放哪维护、是不是需要重启 WorkerEvents.php 就是全部业务入口—— 数据库、鉴权、推送都在这里WebSocket API 是浏览器标准去 MDN 查—— 别和 GatewayWorker 的方法混了CLI PHP 和网站 PHP 版本必须对齐—— 不然 composer 装的包能跑网页跑不动