WebSocket 长连接实战
Qt6 QWebSocket 实战:从连接到重连的完整 IM 长连接方案
基于 YunRong 企业协作客户端项目的实际开发过程。每段代码都在生产环境中验证过。
业务场景
即时通讯(IM)客户端的网络层需要解决四个核心问题:
- 连接管理:启动时连上服务端,退出时优雅断开
- 双向消息:发送文本/JSON 消息,接收服务端推送
- 心跳保活:30 秒没人说话时,靠 ping/pong 维持连接不被中间代理掐断
- 断线重连:网线被拔了、服务端重启了,自动恢复
这四个问题不是独立的功能模块——它们在 Qt 的异步事件循环中相互交织,必须作为一个整体设计,否则状态机爆炸。下面从零开始,一步一步构建。
第 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() 是异步的——调用后立即返回,真正的连接结果通过 connected 或 error 信号异步通知。这和传统的阻塞 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 状态机的隐性条件
上面的代码看起来简单,但有几个隐性约束必须遵守:
onConnected必须清零计数器——否则第一次重连成功后,第二次意外断开会从上次的 attempt 值继续open()必须重置m_manualClose——否则close()后再open()不会重连- 心跳超时的
m_socket.close()不设m_manualClose——心跳超时属于意外断开,应触发重连 - 重连定时器是 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 标志区分主动/被动断开 |
这四个要素不是独立模块——心跳超时触发关闭、关闭触发重连、重连成功后重启心跳。它们是一个环,必须整体设计。