WebSocket 握手流程在 Swoole 中的实现

解读

国内一线互联网公司在 PHP 高并发面试中,常把“WebSocket 握手”作为区分“只会写业务”与“懂底层通信”的分水岭。
Swoole 作为 PHP 的常驻内存协程扩展,把握手过程封装在 onHandShake 回调里,但面试官想听到的是:

  1. 你能否把 RFC 6455 的 TCP 级细节映射到 Swoole 的 C 层实现;
  2. 你是否知道在 FPM 下无法做 WebSocket,必须依赖 Swoole 的 HTTP Server;
  3. 你是否能给出生产级代码(校验 Sec-WebSocket-Key、协议版本、子协议、Cookie 鉴权、异常返回 426/400),而不是只打印 “hello world”。
    答不到“Key 的 SHA1+Base64 计算”或“握手失败时如何主动关闭 fd”,基本会被判“仅停留在 API 调用层面”。

知识点

  1. RFC 6455 握手字段:Upgrade、Connection、Sec-WebSocket-Key、Sec-WebSocket-Version、Sec-WebSocket-Protocol/Extensions。
  2. 校验规则:版本必须为 13;Key 长度 16 字节 base64 后 24 字节;GUID 固定 258EAFA5-E914-47DA-95CA-C5AB0DC85B11。
  3. Swoole 事件流:TCP 三次握手 → HTTP Request → 触发 onHandShake → 用户层返回 true/false → Swoole 底层发送 101 或 426/400。
  4. 与 onOpen 关系:onHandShake 返回 true 后,Swoole 自动把该连接升级为 WebSocket,随后触发 onOpen;返回 false 则连接关闭。
  5. 安全与性能:
    • 必须在握手阶段完成 JWT/Session 鉴权,避免后续每帧再鉴权;
    • 若握手失败,应调用 $response->status(400)->end()return false,防止半开连接耗尽 fd;
    • 生产环境打开 open_websocket_ping_frame,配合 heartbeat_check_interval 做健康检查。
  6. 常见坑:
    • 忘记 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,是国内面试官期望的“可直接上线”的级别。

拓展思考

  1. 如果业务需要支持 100 万并发长连接,单端口 Reactor 线程会成为瓶颈,可启用 dispatch_mode=7(STREAM)+ reuse_port=1 做多端口监听,配合 SO_REUSEPORT 负载均衡。
  2. 当握手阶段需要访问 Redis 集群拉取用户权限,而 Redis 延迟偶发 50 ms,可以把 onHandShake 拆成两步:先返回 true 完成协议升级,再在 onOpen 协程里做权限校验,失败时主动 $server->close($fd),避免阻塞握手线程。
  3. 在 K8s ingress 环境下,四层 LB 无法识别 WebSocket,需给 Swoole 暴露 NodePort 并在 ingress-nginx 加 nginx.ingress.kubernetes.io/proxy-read-timeout: "3600",否则 60 秒会被断开。
  4. 若需自定义加密握手,可在 onHandShake 里把 Key 再做一次 RSA 验签,通过后继续走 RFC 流程,实现“私有加密 WebSocket 隧道”,用于金融级实时行情推送。