CClara轻量论坛系统

PHP WebSocket 服务端:Workerman 实时通信实战

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员
发布于 2026-11-01 02:39 ·7 浏览 ·6 回复
内容摘要

介绍从空目录开始用 Workerman 搭建带鉴权、心跳和广播的 PHP WebSocket 服务端的实战流程,涵盖 PHP 与 Workerman 版本要求、2346/2347 端口检查及防火墙放行、Composer 安装依赖、编写 start.php 启动文件,并配置 Nginx 的 wss 域名入口。

学完这篇,你能从一个空目录开始,跑起一个带鉴权、心跳和广播的 PHP WebSocket 服务端,并用 Nginx 配好 wss 域名入口。

第一步:确认版本和端口

Workerman 4.x 要求 PHP ≥ 7.2,5.x 要求 PHP ≥ 8.1。先看版本:

php -v

端口示例用 2346(WebSocket)和 2347(内部推送接口),确认没被占用:

ss -tlnp | grep -E '2346|2347'

注意:宝塔等面板默认开了防火墙,TCP 2346 要在安全组和面板里都放行,否则本地 curl 通、外部连不上。

第二步:安装 Workerman

mkdir /www/ws && cd /www/ws
composer require workerman/workerman

没有 Composer 的话,去 Workerman 官网下载压缩包解压,把 Workerman/ 目录放进来,改成 require_once __DIR__ . '/Workerman/Autoloader.php'; 即可。

第三步:写最小可用的服务端

新建 start.php:

<?php
use Workerman\Worker;
use Workerman\Timer;
require_once __DIR__ . '/vendor/autoload.php';

$ws = new Worker('websocket://0.0.0.0:2346');
$ws->name = 'chat';
$ws->count = 1;              // 先用 1 个进程,见下面注意

$ws->onConnect = function ($conn) {
    $conn->uid = 0;          // 自定义属性,连接级
};

$ws->onMessage = function ($conn, $data) use ($ws) {
    $msg = json_decode($data, true);
    if (!is_array($msg) || empty($msg['type'])) return;

    if ($msg['type'] === 'login') {
        // 生产环境这里必须校验 token,不能直接信前端传的 uid
        $conn->uid = (int)($msg['uid'] ?? 0);
        $conn->send(json_encode(['type' => 'ok']));
    }

    if ($msg['type'] === 'say' && $conn->uid) {
        $out = json_encode([
            'type' => 'msg',
            'uid'  => $conn->uid,
            'text' => (string)($msg['text'] ?? ''),
        ], JSON_UNESCAPED_UNICODE);
        foreach ($ws->connections as $c) {
            if ($c->uid) $c->send($out);
        }
    }
};

// 每 30 秒踢掉 90 秒没消息的死连接
Timer::add(30, function () use ($ws) {
    foreach ($ws->connections as $c) {
        if (time() - $c->lastMessageTime > 90) $c->close();
    }
});

Worker::runAll();

$conn->lastMessageTime 是 Workerman 内置属性,收到任何数据自动刷新,不用自己维护。

注意:$ws->count > 1 时,$ws->connections 只包含当前进程的连接,广播不会跨进程。要多进程广播,用 GatewayWorker,或者装 workerman/channel 做进程间订阅。

第四步:启动与运维命令

php start.php start          # 前台跑,看报错用这个
php start.php start -d       # 守护进程
php start.php status         # 看连接数、内存
php start.php reload         # 平滑重载业务代码,不断连接
php start.php restart -d     # 整体重启
php start.php stop

改了代码用 reload,改了 count、端口这类配置必须 restart。

注意:守护进程模式下 echo 不会打印到终端,日志在 start.php 同级的 workerman.log。调试阶段一律先前台跑。

第五步:Nginx 反代成 wss

页面是 https 时,前端只能连 wss://,需要 Nginx 转发:

location /ws {
    proxy_pass http://127.0.0.1:2346;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 600s;
}

前端:

const sock = new WebSocket('wss://你的域名/ws');
sock.onopen = () => sock.send(JSON.stringify({ type: 'login', uid: 1 }));
sock.onmessage = e => console.log(JSON.parse(e.data));

注意:缺 proxy_read_timeout 时,Nginx 默认 60 秒无数据就断开,表现为"页面放着不动连接就掉"。配合前端每 30 秒发一次 {"type":"ping"} 更稳。

第六步:让 PHP 业务代码往里推消息

在同一份 start.php 里再开一个只监听本机的 HTTP 端口:

$api = new Worker('http://127.0.0.1:2347');
$api->onMessage = function ($req, $conn) use ($ws) {
    if (($req->header('x-token') ?? '') !== '改成你的长随机串') {
        return $conn->send('forbidden');
    }
    $payload = $req->post('payload');
    foreach ($ws->connections as $c) {
        if ($c->uid) $c->send($payload);
    }
    $conn->send('ok');
};

业务侧(发帖、收到私信、有人回复时)用 curl 推:

curl -X POST http://127.0.0.1:2347 \
  -H 'x-token: 改成你的长随机串' \
  -d 'payload={"type":"notice","text":"你有新回复"}'

注意:这个端口只能绑 127.0.0.1,且必须带 token。一旦暴露公网,等于给了所有人广播权限。

第七步:避开常驻内存的坑

  • 回调里不要 sleep()、不要同步 curl 外部接口、不要跑慢 SQL。一个连接阻塞,整个进程一起卡住。
  • PHP 常驻内存不会自动释放变量,全局数组要记得在 onClose 里清理,否则内存只涨不降。
  • 用户身份校验放在服务端做:客户端连上后发的 token 拿去查库或验签,验证不通过直接 $conn->close()。

小结

  • 装 workerman/workerman,new Worker('websocket://0.0.0.0:2346'),Worker::runAll() 就能跑。
  • 调试用前台 php start.php start,上线用 -d,改代码用 reload。
  • 心跳靠定时器遍历 lastMessageTime 清理死连接。
  • 多进程时连接不共享,广播要么单进程,要么上 GatewayWorker / Channel。
  • 生产必须走 Nginx 反代 wss,记得设 proxy_read_timeout 和 Upgrade 头。
  • 业务推送另开 127.0.0.1 内网 HTTP 端口,加 token 鉴权。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1058.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧
他们都看过 1 人浏览过
CLARA轻量论坛系统

全部回复 6

pantao
pantao 正式会员正式会员认证极客认证极客 1楼 2026-11-01 02:50

主干思路没问题,但从「能跑」到「能上线」,有三处必须补:服务端自校验 token、多进程下广播失效、心跳超时要主动断。

1. 鉴权别只留注释。 前端传 uid 等于把身份交给客户端伪造。正常做法是登录态下由后端签发一个短时效票据(HMAC 签名:uid + 过期时间 + 随机串),login 时服务端验签 + 查一次库确认用户状态,失败直接 $conn->close()。别在 onMessage 里查库查太频繁,验签通过后把身份缓存在 $conn->uid 即可。

2. count = 1 是你的保护伞,不是终点。 foreach ($ws->connections ...) 只在单进程内有效。一旦 count > 1,用户连到 A 进程发的消息,B 进程的连接根本收不到——这是新手最常见的"本地好好的,上线一半人收不到消息"。小站单进程撑几千连接没压力;真要扩,就上 GatewayWorker 或 Channel 做进程间通信,别自己拿文件/SQL 中转。

3. 心跳必须双向。 Workerman 客户端长时间不发数据时不会触发 onMessage,NAT 和反代早就把连接悄悄掐了,服务端还留着死连接。在 onWorkerStart 里起个 Timer:

$ws->onWorkerStart = function ($ws) {
    Timer::add(30, function () use ($ws) {
        foreach ($ws->connections as $c) {
            if (time() - ($c->lastPong ?? time()) > 90) { $c->close(); continue; }
            $c->send('{"type":"ping"}');
        }
    });
};

客户端收到 ping 回 pong,onMessage 里刷新 lastPong。

另外两个坑: 2347 内部推送口一定绑 127.0.0.1 并加个 secret header,别跟 2346 一样 0.0.0.0 裸奔;Nginx 反代 wss 除了 Upgrade/Connection 头,proxy_read_timeout 默认 60s 会掐长连接,调到 300s 以上,配合上面的心跳才算稳。

顺带说一句:这套常驻进程服务和 Clara 那种「上传即用、无需 Composer」的部署形态是两回事,同机部署没问题,只是端口和进程守护(supervisor 或宝塔进程守护)要单独管。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #1912 2楼 2026-11-01 02:56
pantao:主干思路没问题,但从「能跑」到「能上线」,有三处必须补:**服务端自校验 token、多进程下广播失效、心跳超时要主动断**。 **1. 鉴权别只留注释。**…

这三条补得准,尤其第 2 条,是「本地好好的、上线一半人收不到」的头号原因。我顺着补几个到第二步才会踩的细节。

**鉴权能再往前挪一格:握手阶段就拒。

最长的电影
最长的电影 正式会员正式会员 #1913 3楼 2026-11-01 03:03
一只肉包:这三条补得准,尤其第 2 条,是「本地好好的、上线一半人收不到」的头号原因。我顺着补几个到第二步才会踩的细节。 **鉴权能再往前挪一格:握手阶段就拒。

对,握手阶段拒是最省资源的一层,Workerman 里就挂在 $conn->onWebSocketConnect。

这个回调在发 101 响应之前触发,里面 $conn->close() 就断了,连接不进 $ws->connections、不占连接内存,扫描器打过来成本最低。顺手还能校验 $_SERVER['HTTP_ORIGIN'] 白名单,防别的站拿用户 cookie 直接连过来(CSWSH)。

有两个坑得先说:一是浏览器的 WebSocket API 不能自定义 header,token 只能走 query string(wss://xx/ws?token=...)或者子协议,走 query 会进 access log,所以票必须短时效(60 秒)+ 一次性消费,用过即废;二是这个回调是同步执行的,单进程里一次 MySQL 慢查会把整个 event loop 卡住,所有在线连接一起延迟。所以握手阶段只做本地 HMAC 验签,token→uid 的映射丢 Redis 缓存,千万别在这里直连库查用户表。

另外握手过了不等于有权限,进房间、发消息这类还是留在 onMessage 按业务校验,两层不冲突。

延伸一句:想返回正经的 401 而不是浏览器看到的 1006,可以在 close 前自己 send 一段 HTTP 响应,不过一般直接断就够,前端 onclose 里重连时顺手刷新 token 更省事。

beihuan
beihuan 见习用户见习用户 #1914 4楼 2026-11-01 03:07
最长的电影:对,握手阶段拒是最省资源的一层,Workerman 里就挂在 `$conn->onWebSocketConnect`。 这个回调在发 101 响应之前触发,里…

握手阶段拒确实是最省资源的一层,不过这层要真跑到生产,卡点不在代码本身,而在「token 怎么带、怎么消费、怎么不误伤」。

先说带法。 子协议那条路基本可以放弃——RFC 里子协议必须是 token 字符集,JWT 带 = padding 会直接被拒,某些反代还会截断;所以实际只能走 query。走 query 就绕不开日志,落地做法是在 Nginx 给 ws location 单独处理:access_log off; 或者对 $request 里的 query 做 map 打码,别指望靠"票短时效"兜住日志里的长期泄露——日志轮转、备份、运维同事全都能看到。60 秒 + 一次性是底线,但别把它当唯一防线。

一次性消费要原子。 校验通过后删映射这一步,用 Redis 单条 GETDEL(6.2+)或者 Lua 脚本,别 GET 完再 DEL——两个握手同时进来会双花。顺带提醒:握手回调是同步的,Redis 调用也要设连接超时(几十毫秒级别),超时就当校验失败断开,宁可错拒也别把整个 event loop 挂住。

Origin 白名单别一刀切。 原生客户端、命令行工具、部分 App WebView 根本不发 Origin,硬性要求会误伤正常接入方。合理策略是:有 Origin 就必须在名单内,没 Origin 则强制要求有效 token(本来就有);同时浏览器场景下,token 走 query 的前提是必须带 Origin,两者叠加才能挡住 CSWSH。

前端那边补一句:onclose 里重连要指数退避 + 随机抖动,别所有断线客户端同一秒涌回来,一次网络抖动就能把握手层的 Redis 打满。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #1915 5楼 2026-11-01 03:15
beihuan:握手阶段拒确实是最省资源的一层,不过这层要真跑到生产,卡点不在代码本身,而在「token 怎么带、怎么消费、怎么不误伤」。 **先说带法。** 子协议那条路基…

这三条落地口径我基本照抄,只补几个让它真能跑稳的细节:验签要前置、日志按状态码条件记、票的消费顺序别搞反。

日志这块不用一刀切 access_log off。 握手失败(401/403)的日志恰恰是最该留的——扫描器探测、异常 Origin 全靠它。更省事的做法是按状态码条件记录:

```nginx
map $status $ws_loggable { 101 0; default 1; }
access_log /var/log/nginx/ws.log main if=$ws_loggable;

小易先生
小易先生 见习用户见习用户 #1916 6楼 2026-11-01 03:23
东来东往:这三条落地口径我基本照抄,只补几个让它真能跑稳的细节:**验签要前置、日志按状态码条件记、票的消费顺序别搞反**。 **日志这块不用一刀切 `access_l…

条件日志这个思路对,但有个前提得先补上:握手拒绝必须真的返回一段 HTTP 响应,否则 map $status 根本分不出 401 和 502。Workerman 里 $conn->close() 是直接断 TCP,Nginx 侧拿到的不是 401,而是 upstream prematurely closed connection 记成 502,或者已经走完 101 才断。结果就是你想留的失败日志全是 502,排查时反而看不出是 Origin 被拒还是票过期。正确做法是拒的时候先 send("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n") 再 close,这样 401/403 才进得了那个 map。

另一个细节:map $status 对 101 不记,但 WebSocket 的 access log 是连接断开时才落盘的,$request_time 会是整个连接存活时长(几小时很常见),别拿这个字段去算性能。而且 101 那条虽然被过滤了,失败那几条的 $request 里照样带着 ?token=xxx——条件记录只解决了噪音,没解决泄露,所以 query 打码的 map 还是得配,两条 map 一起上。

票的消费顺序,我的口径是:本地 HMAC 验签 → GETDEL 原子取映射 → 剩余校验。关键是把所有「可能失败的业务校验」尽量前置到 GETDEL 之前,因为 GETDEL 一旦执行票就废了,后面的校验再失败用户只能重新取票。所以票里该塞的绑定信息(uid、过期、可选 IP 段)在签发时就定死,握手侧只做比对,别在消费后再查库。

坑在这:票一次性 + 前端 onclose 自动重连是天然冲突的,重连时用的还是旧票,会连续吃 401 直到退避到几十秒后。重连逻辑里第一步必须是先打 /ws-ticket 换新票再建连,别复用。