输入关键词开始搜索

WebSocket 长连接实战

Qt6 QWebSocket 实战:从连接到重连的完整 IM 长连接方案

基于 YunRong 企业协作客户端项目的实际开发过程。每段代码都在生产环境中验证过。


业务场景

即时通讯(IM)客户端的网络层需要解决四个核心问题:

  1. 连接管理:启动时连上服务端,退出时优雅断开
  2. 双向消息:发送文本/JSON 消息,接收服务端推送
  3. 心跳保活:30 秒没人说话时,靠 ping/pong 维持连接不被中间代理掐断
  4. 断线重连:网线被拔了、服务端重启了,自动恢复

这四个问题不是独立的功能模块——它们在 Qt 的异步事件循环中相互交织,必须作为一个整体设计,否则状态机爆炸。下面从零开始,一步一步构建。

完整代码在 yunrong/client/src/app/ws_client.hws_client.cpp


第 1 步:最小可用的连接/断开

// ws_client.h
class WsClient : public QObject
{
    Q_OBJECT
public:
    void open(const QUrl& url);
    void close();

signals:
    void connected();
    void disconnected();

private slots:
    void onConnected();
    void onDisconnected();
    void onError(QAbstractSocket::SocketError error);

private:
    QWebSocket m_socket;
};
// ws_client.cpp — 构造函数
WsClient::WsClient(QObject* parent) : QObject(parent)
{
    connect(&m_socket, &QWebSocket::connected,
            this, &WsClient::onConnected);
    connect(&m_socket, &QWebSocket::disconnected,
            this, &WsClient::onDisconnected);
    connect(&m_socket,
            QOverload<QAbstractSocket::SocketError>::of(&QWebSocket::error),
            this, &WsClient::onError);
}

void WsClient::open(const QUrl& url)
{
    m_socket.open(url);  // 异步,立即返回
}

void WsClient::close()
{
    if (m_socket.state() != QAbstractSocket::UnconnectedState)
        m_socket.close();
}

关键点m_socket.open() 是异步的——调用后立即返回,真正的连接结果通过 connectederror 信号异步通知。这和传统的阻塞 connect() 完全不同,不能写 if (socket.open()) 这样的代码。

踩坑:LNK2001 未解析符号

如果编译时出现 undefined reference to WsClient::metaObject(),说明 Qt 的 MOC(Meta-Object Compiler)没有处理你的头文件。需要两件事:

# CMakeLists.txt 顶层
set(CMAKE_AUTOMOC ON)    # ← 很多人漏了这行

# 并把 .h 文件加入源列表
add_executable(yunrong_client main.cpp ws_client.cpp ws_client.h)

第 2 步:发送和接收 JSON 消息

// 发送 JSON
void WsClient::sendJson(const QJsonObject& obj)
{
    QJsonDocument doc(obj);
    QString text = QString::fromUtf8(
        doc.toJson(QJsonDocument::Compact));   // 紧凑格式,无空格换行
    m_socket.sendTextMessage(text);
}

// 接收 JSON
void WsClient::onTextMessage(const QString& text)
{
    QJsonParseError err;
    QJsonDocument doc = QJsonDocument::fromJson(text.toUtf8(), &err);
    if (err.error != QJsonParseError::NoError) {
        qWarning() << "WS received non-JSON:" << text;
        return;   // ← 非 JSON 消息,忽略但不崩溃
    }
    emit messageReceived(doc.object());
}

业务考虑:服务端发来的数据不可信任。TCP 层不保证每次收到的是一个完整 JSON 帧,但 QWebSocket 的 textMessageReceived 已经帮我们做了帧重组(WebSocket 协议保证 Text 帧的完整性)。我们只需要验证它是合法的 JSON——不是就丢弃并记录。


第 3 步:心跳保活

IM 客户端的连接可能几小时不说话(用户最小化窗口、去开会了)。中间的网络设备(NAT 网关、负载均衡器)会在几分钟无数据后清除连接状态。心跳的作用就是”假装有人说话”,防止被掐。

// 新增成员
QTimer        m_heartbeatTimer;
QElapsedTimer m_lastActivity;    // 记录最后一次收发的时间
bool          m_timedOut = false;

static constexpr int kPingIntervalSec = 30;   // 30 秒发一次 ping
static constexpr int kTimeoutSec      = 90;   // 90 秒无响应判定断开

void WsClient::onConnected()
{
    // ... 原有逻辑 ...
    m_timedOut = false;
    resetActivity();
    m_heartbeatTimer.start(kPingIntervalSec * 1000);  // 启动心跳
}

void WsClient::onTextMessage(const QString& text)
{
    resetActivity();   // ← 任何消息都算活动,不只是 pong
    // ... 原有解析逻辑 ...
}

void WsClient::onHeartbeatTick()
{
    if (m_timedOut) return;

    qint64 elapsed = m_lastActivity.elapsed() / 1000;
    if (elapsed > kTimeoutSec) {
        m_timedOut = true;
        m_heartbeatTimer.stop();
        qWarning() << "Heartbeat timeout:" << elapsed << "s";
        m_socket.close();    // 关闭 → 触发 onDisconnected → 自动重连
        emit heartbeatTimeout();
        return;
    }

    QJsonObject ping;
    ping["type"] = "ping";
    sendJson(ping);
}

为什么不是”连续 N 次 ping 无 pong 才超时”?

很多实现用计数器:发 ping → 等 pong → 5 秒超时 → 计数器 +1 → 连续 3 次判定断开。这个方法没问题,但有一个隐蔽 bug:如果服务端在这 15 秒内推了一条正常的 IM 消息过来,客户端收到后心跳计数器没有重置,照样判定超时——但实际上连接是好的。

用时间差判断更准确:只要 90 秒内收到任何消息(pong、IM 消息、通知),就证明连接存活。不需要区分消息类型。

为什么是服务端发 ping?

RFC 6455 建议服务端主导心跳,因为服务端需要检测死连接来回收 goroutine 和文件描述符。客户端被动响应即可。但如果你连的是第三方 WebSocket 服务(它不发 ping),你就必须在客户端主动发。

本项目早期设计也是客户端发 ping,后来改为服务端主导。这里保留客户端主动发送的实现,兼容两种场景。


第 4 步:断线自动重连(最难的部分)

4.1 什么情况需要重连

不是所有断开都要重连:

断开原因行为
用户点了”退出”不重连
网线拔了重连
服务端重启重连
心跳超时重连
用户调用了 close()不重连

区分的关键是 m_manualClose 标志。

4.2 指数退避

重连不能死循环——1 秒一次重连会把 CPU 打满,也容易触发服务端的 rate limit。正确的做法是指数退避:

int WsClient::reconnectDelayMs() const
{
    // 1s → 2s → 4s → 8s → 16s → 32s → 60s → 60s...
    int ms = 1000 * (1 << m_reconnectAttempt);
    return std::min(ms, 60000);
}

4.3 完整实现

// 新增成员
QTimer m_reconnectTimer;
QUrl   m_url;                // 保存原始 URL,重连时复用
int    m_reconnectAttempt = 0;
bool   m_manualClose      = false;
static constexpr int kMaxReconnect = 10;

void WsClient::open(const QUrl& url)
{
    m_url = url;
    m_manualClose = false;       // ← 每次 open() 重置
    m_reconnectAttempt = 0;
    m_socket.open(url);
}

void WsClient::close()
{
    m_manualClose = true;        // ← 标记为主动关闭
    m_heartbeatTimer.stop();
    m_reconnectTimer.stop();
    if (m_socket.state() != QAbstractSocket::UnconnectedState)
        m_socket.close();
}

void WsClient::onConnected()
{
    // ... 原有逻辑 ...
    m_reconnectAttempt = 0;      // ← 连接成功,清零计数器
    m_reconnectTimer.stop();     // ← 停止重连定时器
}

void WsClient::onDisconnected()
{
    m_heartbeatTimer.stop();
    emit disconnected();

    if (m_manualClose) return;   // ← 主动关闭,不重连

    if (m_reconnectAttempt >= kMaxReconnect) {
        qWarning() << "Max reconnect attempts reached, giving up";
        emit maxReconnectReached();
        return;
    }

    int delay = reconnectDelayMs();
    qInfo() << "Reconnecting in" << delay << "ms"
            << "(attempt" << (m_reconnectAttempt + 1)
            << "of" << kMaxReconnect << ")";
    m_reconnectTimer.start(delay);    // ← 单次定时器,触发后自动停止
}

void WsClient::onReconnectTick()
{
    m_reconnectTimer.stop();
    m_reconnectAttempt++;
    m_socket.open(m_url);            // ← 用保存的 URL 重连
}

4.4 状态机的隐性条件

上面的代码看起来简单,但有几个隐性约束必须遵守:

  1. onConnected 必须清零计数器——否则第一次重连成功后,第二次意外断开会从上次的 attempt 值继续
  2. open() 必须重置 m_manualClose——否则 close() 后再 open() 不会重连
  3. 心跳超时的 m_socket.close() 不设 m_manualClose——心跳超时属于意外断开,应触发重连
  4. 重连定时器是 single-shot——onReconnectTick 里先 stop()open(),确保不会在连接过程中再次触发

完整时序图

用户启动

  ├── open(url)
  │     └── m_socket.open()  ──────────────► 服务端
  │                                              │
  │◄── connected ──────────────────────────────│
  │     └── startHeartbeat() ── 30s 定时器
  │     └── m_reconnectAttempt = 0

  │       ... 正常收发消息 ...

  │◄── textMessageReceived ──  resetActivity()

  │       ... 30s 无消息 ...

  │     onHeartbeatTick() ── 发送 {"type":"ping"}

  ├── 网线拔了
  │     └── onHeartbeatTick() ── 90s 超时
  │           └── m_socket.close()
  │                 └── onDisconnected()
  │                       └── m_reconnectTimer.start(1s)

  │     1s 后: onReconnectTick()
  │     └── m_socket.open(url) ─── X 失败
  │           └── onDisconnected()
  │                 └── m_reconnectTimer.start(2s)

  │     2s 后: onReconnectTick()
  │     └── m_socket.open(url) ─── ✓ 成功
  │           └── onConnected()
  │                 └── m_reconnectAttempt = 0
  │                 └── startHeartbeat()

关键踩坑记录

1. QObject::connect 的五种重载

// ✅ 推荐:函数指针,编译期检查
connect(&socket, &QWebSocket::connected, this, &WsClient::onConnected);

// ✅ Lambda:灵活但注意生命周期
connect(&wsClient, &WsClient::connected, [&wsClient]() { ... });

// ⚠️ QOverload:同名重载信号必须消歧义
connect(&socket,
        QOverload<QAbstractSocket::SocketError>::of(&QWebSocket::error),
        this, &WsClient::onError);

// ❌ 不推荐:字符串 SIGNAL/SLOT,运行时匹配,拼错不报错
connect(&socket, SIGNAL(connected()), this, SLOT(onConnected()));

2. QElapsedTimer vs QTimer

很多人搞混这两个类:

用途精度
QTimer定时触发回调(如每 30s 发 ping)毫秒级
QElapsedTimer测量时间间隔(如上次收到消息是多久前)纳秒级(单调时钟)

心跳监测需要两者配合:QTimer 定期触发检查,QElapsedTimer 计算距离上次活动过了多久。

3. Lambda 捕获的悬空问题

// ❌ 危险:wsClient 可能在信号触发前被销毁
connect(&someObj, &T::sig, [&wsClient]() { wsClient.sendJson(msg); });

// ✅ 安全条件:wsClient 生命周期 > 信号源
// 本项目中 wsClient 在 main() 栈上,QApplication 销毁后才析构

总结:一个可靠 WebSocket 客户端的四要素

要素本实现
连接QWebSocket::open() 异步,connected/error 信号通知结果
消息sendTextMessage + textMessageReceived,JSON 验证 + 丢弃非法帧
心跳30s 发 ping,90s 无任何消息判定断开,用 QElapsedTimer 测距
重连指数退避 1s→60s,最多 10 次,m_manualClose 标志区分主动/被动断开

这四个要素不是独立模块——心跳超时触发关闭、关闭触发重连、重连成功后重启心跳。它们是一个环,必须整体设计。