WebSocket 握手流程在 Swoole 中的实现
解读
国内一线互联网公司在 PHP 高并发面试中,常把“WebSocket 握手”作为区分“只会写业务”与“懂底层通信”的分水岭。
Swoole 作为 PHP 的常驻内存协程扩展,把握手过程封装在 onHandShake 回调里,但面试官想听到的是:
- 你能否把 RFC 6455 的 TCP 级细节映射到 Swoole 的 C 层实现;
- 你是否知道在 FPM 下无法做 WebSocket,必须依赖 Swoole 的 HTTP Server;
- 你是否能给出生产级代码(校验 Sec-WebSocket-Key、协议版本、子协议、Cookie 鉴权、异常返回 426/400),而不是只打印 “hello world”。
答不到“Key 的 SHA1+Base64 计算”或“握手失败时如何主动关闭 fd”,基本会被判“仅停留在 API 调用层面”。
知识点
- RFC 6455 握手字段:Upgrade、Connection、Sec-WebSocket-Key、Sec-WebSocket-Version、Sec-WebSocket-Protocol/Extensions。
- 校验规则:版本必须为 13;Key 长度 16 字节 base64 后 24 字节;GUID 固定 258EAFA5-E914-47DA-95CA-C5AB0DC85B11。
- Swoole 事件流:TCP 三次握手 → HTTP Request → 触发 onHandShake → 用户层返回 true/false → Swoole 底层发送 101 或 426/400。
- 与 onOpen 关系:onHandShake 返回 true 后,Swoole 自动把该连接升级为 WebSocket,随后触发 onOpen;返回 false 则连接关闭。
- 安全与性能:
- 必须在握手阶段完成 JWT/Session 鉴权,避免后续每帧再鉴权;
- 若握手失败,应调用
$response->status(400)->end()并return false,防止半开连接耗尽 fd; - 生产环境打开
open_websocket_ping_frame,配合heartbeat_check_interval做健康检查。
- 常见坑:
- 忘记
swoole_http_response->status(101),浏览器报 “Unexpected response code: 200”; - 在
onHandShake里使用co::sleep等协程阻塞函数,会导致握手超时; - 负载均衡层(如 Tengine/Nginx)未配置
proxy_set_header Upgrade $http_upgrade,造成 Key 丢失。
- 忘记
答案
// server.php 基于 Swoole 5.x,PHP 8.2,可直接 php server.php 启动
$http = new Swoole\Http\Server('0.0.0.0', 9501);
$http->set([
'worker_num' => 4,
'open_websocket_protocol' => true,
'heartbeat_check_interval' => 30,
'heartbeat_idle_time' => 60,
]);
// 握手回调:必须返回 bool,决定 Swoole 是否继续 WebSocket 升级
$http->on('handshake', function (\Swoole\Http\Request $request, \Swoole\Http\Response $response) {
// 1. 基础字段校验
$key = $request->header['sec-websocket-key'] ?? '';
$version = $request->header['sec-websocket-version'] ?? '';
if ($version !== '13' || strlen(base64_decode($key)) !== 16) {
$response->status(400)->end('Bad WebSocket Request');
return false;
}
// 2. 业务鉴权(示例:JWT 放在 Cookie)
$jwt = $request->cookie['ws_token'] ?? '';
try {
$uid = validate_jwt($jwt); // 自行实现
} catch (\Throwable $e) {
$response->status(403)->end('Forbidden');
return false;
}
// 3. 计算 Sec-WebSocket-Accept
$accept = base64_encode(sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));
// 4. 发送 101 响应头
$response->status(101);
$response->header('Upgrade', 'websocket');
$response->header('Connection', 'Upgrade');
$response->header('Sec-WebSocket-Accept', $accept);
// 可选:子协议协商
if ($request->header['sec-websocket-protocol'] ?? '') {
$response->header('Sec-WebSocket-Protocol', 'chat');
}
$response->end(); // 必须调用 end(),但 body 为空
// 5. 把 uid 暂存到 fd 维度的连接信息,后续 onMessage 使用
$fd = $request->fd;
$GLOBALS['conn_map'][$fd] = $uid;
return true; // 关键:返回 true 告诉 Swoole 升级成功
});
$http->on('open', function ($server, $req) {
echo "WebSocket fd={$req->fd} uid={$GLOBALS['conn_map'][$req->fd]} connected\n";
});
$http->on('message', function ($server, $frame) {
$uid = $GLOBALS['conn_map'][$frame->fd];
echo "recv from uid=$uid: {$frame->data}\n";
$server->push($frame->fd, "echo: " . $frame->data);
});
$http->on('close', function ($server, $fd) {
unset($GLOBALS['conn_map'][$fd]);
});
$http->start();
function validate_jwt(string $jwt): int
{
// 简化示例,生产请用 firebase/jwt
if ($jwt !== 'valid-token') throw new Exception('invalid');
return 123;
}
以上代码覆盖了 RFC 6455 的完整校验、Swoole 的握手回调约定、业务鉴权、异常状态码返回,并演示了如何把 uid 绑定到 fd,是国内面试官期望的“可直接上线”的级别。
拓展思考
- 如果业务需要支持 100 万并发长连接,单端口 Reactor 线程会成为瓶颈,可启用
dispatch_mode=7(STREAM)+reuse_port=1做多端口监听,配合SO_REUSEPORT负载均衡。 - 当握手阶段需要访问 Redis 集群拉取用户权限,而 Redis 延迟偶发 50 ms,可以把
onHandShake拆成两步:先返回 true 完成协议升级,再在onOpen协程里做权限校验,失败时主动$server->close($fd),避免阻塞握手线程。 - 在 K8s ingress 环境下,四层 LB 无法识别 WebSocket,需给 Swoole 暴露 NodePort 并在 ingress-nginx 加
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600",否则 60 秒会被断开。 - 若需自定义加密握手,可在
onHandShake里把 Key 再做一次 RSA 验签,通过后继续走 RFC 流程,实现“私有加密 WebSocket 隧道”,用于金融级实时行情推送。