【C++/Qt】Qt 实现 MQTT 测试工具:连接 Broker、订阅主题与发布消息

本文基于 C++/Qt 实现一个 MQTT 测试工具,支持连接 MQTT Broker、订阅主题、发布消息、接收消息、断开连接和日志记录。和 HTTP、WebSocket 不同,MQTT 更强调"发布/订阅"通信模型,客户端之间通常不直接通信,而是通过 Broker 转发消息。本文从 MQTT 的基本概念出发,结合 Qt 中 QTcpSocket 的使用,梳理一个 MQTT 测试模块应该如何设计和实现。

一、MQTT 是什么?为什么会有 Broker、Topic、订阅和发布?

MQTT 是一种常用于物联网、设备通信、消息推送的轻量级通信协议。

它和 HTTP、WebSocket 的思路都不太一样。

HTTP 更像是:

复制代码
客户端:我请求一个接口
服务器:我返回一次结果

WebSocket 更像是:

复制代码
客户端和服务器建立长连接,双方可以互相发消息

而 MQTT 更像是一个"消息中转站"模式:

复制代码
客户端 A 把消息发给 Broker
Broker 根据 Topic 把消息转发给订阅了这个 Topic 的客户端
客户端 B 收到消息

这里面有几个非常重要的概念。

1. Broker 是什么?

Broker 可以理解成 MQTT 的"消息服务器"或者"中转站"。

客户端之间一般不直接通信,而是都连接到 Broker。

比如:

复制代码
设备 A 连接 Broker
手机 App 连接 Broker
上位机工具连接 Broker

设备 A 发布消息到某个主题,Broker 再把这个消息转发给订阅了该主题的客户端。

2. Topic 是什么?

Topic 可以理解成"消息频道"。

比如:

复制代码
ndatools/test
sensor/temp
device/001/status

客户端如果想接收某一类消息,就需要订阅对应的 Topic。

比如客户端订阅:

复制代码
ndatools/test

那么只要有其他客户端向 ndatools/test 发布消息,这个客户端就能收到。

3. Publish 和 Subscribe 是什么?

MQTT 里面有两个核心动作:

复制代码
Subscribe:订阅主题,表示我想接收这个 Topic 的消息
Publish:发布消息,表示我要向这个 Topic 发送数据

所以 MQTT 测试工具的核心功能不是"请求一个接口",而是:

复制代码
连接 Broker
订阅 Topic
向 Topic 发布消息
接收 Broker 转发回来的消息

如果用生活化一点的话来说:

复制代码
Broker:微信群服务器
Topic:某个群聊
Subscribe:加入这个群,准备接收群消息
Publish:往这个群里发消息

二、实现 MQTT 测试工具前,先构思哪些功能?

一个 MQTT 测试工具,本质上就是一个 MQTT 客户端。它要让用户可以连接 Broker,并围绕 Topic 进行消息收发。

所以界面上至少需要这些内容:

复制代码
1. Broker 地址:例如 broker.emqx.io 或 127.0.0.1
2. 端口号:普通 MQTT 默认是 1883
3. ClientId:客户端唯一标识
4. 用户名和密码:有些 Broker 需要认证
5. 订阅 Topic:填写要接收消息的主题
6. 发布 Topic:填写要发送消息的主题
7. Payload:真正要发布的消息内容
8. QoS:消息服务质量等级
9. Retain:是否让 Broker 保留最后一条消息
10. 通信日志:显示连接、订阅、发布、接收和错误信息

当前这个 FormMqttTester 类并没有直接使用 QMqttClient,而是使用 QTcpSocket 手动实现 MQTT 报文的发送和解析。类中包含了 TCP Socket、接收缓冲区、MQTT 连接状态、CONNACK 等待标记、超时定时器、报文 ID 和日志缓存等成员。

核心成员可以这样理解:

cpp 复制代码
private:
    Ui::FormMqttTester *ui;

    // MQTT 底层基于 TCP 通信,这里用 QTcpSocket 连接 Broker
    QTcpSocket *m_socket = nullptr;

    // 接收缓冲区,用来缓存 TCP 收到的 MQTT 字节数据
    QByteArray m_receiveBuffer;

    // 当前 MQTT 是否已经连接成功
    bool m_mqttConnected = false;

    // TCP 连接成功后,需要等待 MQTT Broker 返回 CONNACK
    bool m_waitingConnAck = false;

    // MQTT 连接确认超时定时器,避免一直卡在等待状态
    QTimer *m_connAckTimer = nullptr;

    // MQTT 报文 ID,订阅和 QoS 消息会用到
    quint16 m_packetId = 1;

    // 日志缓存,用于保存通信记录
    QStringList m_logEntries;

这里有一个点特别重要:
TCP 连接成功,不代表 MQTT 连接成功。

因为 MQTT 是跑在 TCP 上层的协议。连接 Broker 时,流程其实是:

cpp 复制代码
先建立 TCP 连接
        ↓
发送 MQTT CONNECT 报文
        ↓
等待 Broker 返回 CONNACK 报文
        ↓
CONNACK 返回成功,才算 MQTT 真正连接成功

所以代码中才需要 m_waitingConnAckm_connAckTimer,用来判断 MQTT 连接确认有没有正常返回。

三、初始化界面和信号槽:先把 MQTT 的使用流程搭起来

初始化界面时,需要设置默认 Broker、端口、ClientId、Topic、QoS 和 Payload。

cpp 复制代码
void FormMqttTester::initUi()
{
    // 设置 Broker 地址提示,并默认使用一个公开 Broker
    ui->lineEdit_MqttHost->setPlaceholderText("例如:broker.emqx.io 或 127.0.0.1");
    ui->lineEdit_MqttHost->setText("broker.emqx.io");

    // MQTT 普通 TCP 端口一般是 1883
    ui->spinBox_MqttPort->setRange(1, 65535);
    ui->spinBox_MqttPort->setValue(1883);

    // ClientId 用时间戳生成,避免多个客户端 ID 冲突
    ui->lineEdit_MqttClientId->setText(
        QString("NDATools_%1").arg(QDateTime::currentMSecsSinceEpoch())
    );

    // 用户名和密码可以为空
    ui->lineEdit_MqttUsername->setPlaceholderText("可为空");
    ui->lineEdit_MqttPassword->setPlaceholderText("可为空");
    ui->lineEdit_MqttPassword->setEchoMode(QLineEdit::Password);

    // 当前工具支持 QoS 0 和 QoS 1
    ui->comboBox_MqttSubQos->clear();
    ui->comboBox_MqttSubQos->addItem("0");
    ui->comboBox_MqttSubQos->addItem("1");

    ui->comboBox_MqttPubQos->clear();
    ui->comboBox_MqttPubQos->addItem("0");
    ui->comboBox_MqttPubQos->addItem("1");

    // 默认订阅和发布同一个 Topic,方便本机自测
    ui->lineEdit_MqttSubTopic->setText("ndatools/test");
    ui->lineEdit_MqttPubTopic->setText("ndatools/test");

    // 设置默认发布内容
    ui->textEdit_MqttPayload->setPlaceholderText("请输入要发布的MQTT消息...");
    ui->textEdit_MqttPayload->setPlainText("Hello MQTT.");

    // 日志区域只读
    ui->plainTextEdit_MqttLog->setReadOnly(true);
    ui->label_MqttStatus->setText("状态:未连接");
}

这里有几个设计点。

第一个,端口默认是 1883。因为普通 MQTT TCP 连接常用端口就是 1883。如果是 SSL 或 WebSocket 方式的 MQTT,端口和协议都不一样,当前这个模块暂时不处理。

第二,默认订阅 Topic 和发布 Topic 都是:

cpp 复制代码
ndatools/test

这样做的好处是测试方便。连接成功后,先订阅这个 Topic,再往同一个 Topic 发布消息,如果 Broker 正常转发,就能在日志里看到自己收到的消息。

第三,ClientId 用时间戳生成,主要是为了避免重复。MQTT Broker 通常要求同一个 Broker 上连接的 ClientId 不要冲突,否则可能会把旧连接挤下线。

信号槽部分主要分成两类:

cpp 复制代码
按钮操作:连接、断开、订阅、发布、清空、复制
Socket 事件:TCP连接成功、断开、收到数据、发生错误
cpp 复制代码
void FormMqttTester::initConnections()
{
    // 点击连接按钮
    connect(ui->pushButton_MqttConnect, &QPushButton::clicked,
            this, &FormMqttTester::onConnectClicked);

    // 点击断开按钮
    connect(ui->pushButton_MqttDisconnect, &QPushButton::clicked,
            this, &FormMqttTester::onDisconnectClicked);

    // 点击订阅按钮
    connect(ui->pushButton_MqttSubscribe, &QPushButton::clicked,
            this, &FormMqttTester::onSubscribeClicked);

    // 点击发布按钮
    connect(ui->pushButton_MqttPublish, &QPushButton::clicked,
            this, &FormMqttTester::onPublishClicked);

    // TCP 连接成功
    connect(m_socket, &QTcpSocket::connected,
            this, &FormMqttTester::onSocketConnected);

    // TCP 连接断开
    connect(m_socket, &QTcpSocket::disconnected,
            this, &FormMqttTester::onSocketDisconnected);

    // TCP 收到数据
    connect(m_socket, &QTcpSocket::readyRead,
            this, &FormMqttTester::onSocketReadyRead);

    // TCP 连接错误
    connect(m_socket, &QTcpSocket::errorOccurred,
            this, &FormMqttTester::onSocketError);

    // MQTT CONNACK 确认超时
    connect(m_connAckTimer, &QTimer::timeout,
            this, &FormMqttTester::onMqttConnectTimeout);
}

这部分其实体现了 MQTT 模块的整体逻辑:

cpp 复制代码
用户点击按钮触发操作
QTcpSocket 负责底层网络事件
收到数据后解析 MQTT 报文
根据报文类型更新界面和日志

四、连接 Broker:先连 TCP,再发送 MQTT CONNECT 报文

点击连接按钮后,程序要先检查 Broker 地址和 ClientId。

cpp 复制代码
void FormMqttTester::onConnectClicked()
{
    // 读取 Broker 地址和端口
    const QString host = ui->lineEdit_MqttHost->text().trimmed();
    const int port = ui->spinBox_MqttPort->value();

    // Broker 地址不能为空
    if (host.isEmpty()) {
        QMessageBox::warning(this, "提示", "请输入MQTT Broker地址。");
        return;
    }

    // ClientId 不能为空
    if (ui->lineEdit_MqttClientId->text().trimmed().isEmpty()) {
        QMessageBox::warning(this, "提示", "ClientId不能为空。");
        return;
    }

    // 如果当前 Socket 不是未连接状态,先中止旧连接
    if (m_socket->state() != QAbstractSocket::UnconnectedState) {
        m_socket->abort();
    }

    // 清空接收缓冲区,避免旧数据影响本次连接
    m_receiveBuffer.clear();

    // 先设置为未连接状态
    setMqttConnectedState(false);

    // 记录连接日志
    appendLog(QString("[%1] 正在连接Broker:%2:%3")
                  .arg(QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss"))
                  .arg(host)
                  .arg(port));

    // 连接过程中禁用连接按钮
    ui->pushButton_MqttConnect->setEnabled(false);
    setStatusText("状态:正在连接...");

    // 连接 Broker,本质上是先建立 TCP 连接
    m_socket->connectToHost(host, port);
}

这里最关键的是:

cpp 复制代码
m_socket->connectToHost(host, port);

这一步只是建立 TCP 连接。

TCP 连接成功后,会进入:

cpp 复制代码
void FormMqttTester::onSocketConnected()
{
    appendLog(QString("[%1] TCP连接成功,发送MQTT CONNECT报文")
                  .arg(QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss")));

    setStatusText("状态:TCP已连接,等待MQTT确认...");

    // 标记正在等待 Broker 返回 CONNACK
    m_waitingConnAck = true;

    // 启动连接确认超时检测
    if (m_connAckTimer) {
        m_connAckTimer->start();
    }

    // 发送 MQTT CONNECT 报文
    sendConnectPacket();
}

这个地方很关键:
MQTT 连接不是 TCP 连上就结束,还要发送 MQTT CONNECT 报文。

sendConnectPacket() 就是手动组装 MQTT CONNECT 报文:

cpp 复制代码
void FormMqttTester::sendConnectPacket()
{
    QByteArray variableHeader;

    // 写入协议名 MQTT
    appendMqttString(variableHeader, "MQTT");

    // MQTT 3.1.1 协议级别是 0x04
    variableHeader.append(char(0x04));

    // 默认 Clean Session 标志
    quint8 connectFlags = 0x02;

    // 读取用户名和密码
    const QString username = ui->lineEdit_MqttUsername->text();
    const QString password = ui->lineEdit_MqttPassword->text();

    // 如果填写了用户名,就设置用户名标志
    if (!username.isEmpty()) {
        connectFlags |= 0x80;
    }

    // 如果填写了密码,就设置密码标志
    if (!password.isEmpty()) {
        connectFlags |= 0x40;
    }

    // 写入连接标志
    variableHeader.append(char(connectFlags));

    // Keep Alive,这里设置为 60 秒
    variableHeader.append(char(0x00));
    variableHeader.append(char(0x3C));

    QByteArray payload;

    // Payload 里必须包含 ClientId
    appendMqttString(payload, ui->lineEdit_MqttClientId->text().trimmed());

    // 如果有用户名,也写入 Payload
    if (!username.isEmpty()) {
        appendMqttString(payload, username);
    }

    // 如果有密码,也写入 Payload
    if (!password.isEmpty()) {
        appendMqttString(payload, password);
    }

    QByteArray packet;

    // 0x10 表示 CONNECT 报文
    packet.append(buildFixedHeader(0x10, variableHeader.size() + payload.size()));

    // 拼接可变报头和负载
    packet.append(variableHeader);
    packet.append(payload);

    // 通过 TCP Socket 发送 MQTT 报文
    m_socket->write(packet);
    m_socket->flush();
}

这段代码看起来比 HTTP 和 WebSocket 麻烦一些,因为这里没有直接调用高级 MQTT 客户端类,而是自己按照 MQTT 协议格式拼报文。

MQTT 报文大致可以理解成:

cpp 复制代码
固定报头 Fixed Header
可变报头 Variable Header
有效载荷 Payload

当前代码里的 buildFixedHeader() 就是用来构造固定报头的:

cpp 复制代码
QByteArray FormMqttTester::buildFixedHeader(quint8 firstByte, int remainingLength) const
{
    QByteArray header;

    // 第一个字节表示报文类型和标志位
    header.append(char(firstByte));

    // 后面是剩余长度字段
    header.append(encodeRemainingLength(remainingLength));

    return header;
}

因为 MQTT 的剩余长度是变长编码,所以又单独封装了:

cpp 复制代码
QByteArray FormMqttTester::encodeRemainingLength(int length) const
{
    QByteArray encoded;

    do {
        // 每次取低 7 位
        quint8 byte = length % 128;
        length /= 128;

        // 如果后面还有数据,就把最高位置 1
        if (length > 0) {
            byte |= 128;
        }

        encoded.append(char(byte));
    } while (length > 0);

    return encoded;
}

这个地方不用死记 MQTT 报文细节,先理解思路就行:

cpp 复制代码
Qt 用 QTcpSocket 负责发送字节流
程序自己按照 MQTT 协议格式拼出 CONNECT、SUBSCRIBE、PUBLISH 等报文
Broker 收到这些报文后返回对应确认报文

五、订阅、发布和接收消息:围绕 Topic 完成通信闭环

MQTT 最核心的功能就是订阅和发布。

订阅之前,必须先确认 MQTT 已经连接成功:

cpp 复制代码
void FormMqttTester::onSubscribeClicked()
{
    // 未连接时不能订阅
    if (!m_mqttConnected) {
        QMessageBox::warning(this, "提示", "请先连接MQTT Broker。");
        return;
    }

    // 获取订阅 Topic
    const QString topic = ui->lineEdit_MqttSubTopic->text().trimmed();

    // Topic 不能为空
    if (topic.isEmpty()) {
        QMessageBox::warning(this, "提示", "请输入要订阅的Topic。");
        return;
    }

    // 获取订阅 QoS
    const quint8 qos =
        static_cast<quint8>(ui->comboBox_MqttSubQos->currentText().toInt());

    // 发送 SUBSCRIBE 报文
    sendSubscribePacket(topic, qos);
}

真正发送订阅报文的函数是:

cpp 复制代码
void FormMqttTester::sendSubscribePacket(const QString &topic, quint8 qos)
{
    // MQTT SUBSCRIBE 报文需要报文 ID
    const quint16 packetId = nextPacketId();

    QByteArray variableHeader;

    // 写入报文 ID
    variableHeader.append(char(packetId >> 8));
    variableHeader.append(char(packetId & 0xFF));

    QByteArray payload;

    // 写入订阅 Topic
    appendMqttString(payload, topic);

    // 写入订阅 QoS
    payload.append(char(qos));

    QByteArray packet;

    // 0x82 表示 SUBSCRIBE 报文
    packet.append(buildFixedHeader(0x82, variableHeader.size() + payload.size()));
    packet.append(variableHeader);
    packet.append(payload);

    // 发送订阅报文
    m_socket->write(packet);
    m_socket->flush();

    appendLog(QString("[%1] [订阅] Topic:%2,QoS:%3")
                  .arg(QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss"))
                  .arg(topic)
                  .arg(qos));
}

发布消息的流程也类似,先判断连接状态,再读取 Topic 和 Payload:

cpp 复制代码
void FormMqttTester::onPublishClicked()
{
    // 未连接时不能发布
    if (!m_mqttConnected) {
        QMessageBox::warning(this, "提示", "请先连接MQTT Broker。");
        return;
    }

    // 获取发布 Topic 和消息内容
    const QString topic = ui->lineEdit_MqttPubTopic->text().trimmed();
    const QString payload = ui->textEdit_MqttPayload->toPlainText();

    // Topic 不能为空
    if (topic.isEmpty()) {
        QMessageBox::warning(this, "提示", "请输入发布Topic。");
        return;
    }

    // 消息内容不能为空
    if (payload.trimmed().isEmpty()) {
        QMessageBox::warning(this, "提示", "发布内容不能为空。");
        return;
    }

    // 获取发布 QoS 和 Retain 标志
    const quint8 qos =
        static_cast<quint8>(ui->comboBox_MqttPubQos->currentText().toInt());

    const bool retain = ui->checkBox_MqttRetain->isChecked();

    // 发送 PUBLISH 报文
    sendPublishPacket(topic, payload, qos, retain);
}

真正组装 PUBLISH 报文:

cpp 复制代码
void FormMqttTester::sendPublishPacket(
    const QString &topic,
    const QString &payloadText,
    quint8 qos,
    bool retain)
{
    QByteArray variableHeader;

    // PUBLISH 报文中需要写入 Topic
    appendMqttString(variableHeader, topic);

    // 如果 QoS 大于 0,需要带上报文 ID
    if (qos > 0) {
        const quint16 packetId = nextPacketId();
        variableHeader.append(char(packetId >> 8));
        variableHeader.append(char(packetId & 0xFF));
    }

    // 消息内容转成 UTF-8 字节
    const QByteArray payload = payloadText.toUtf8();

    // 0x30 表示普通 PUBLISH 报文
    quint8 firstByte = 0x30;

    // 设置 QoS 标志位
    firstByte |= static_cast<quint8>((qos & 0x03) << 1);

    // 如果勾选 Retain,就设置最低位
    if (retain) {
        firstByte |= 0x01;
    }

    QByteArray packet;

    // 构造固定报头
    packet.append(buildFixedHeader(firstByte, variableHeader.size() + payload.size()));

    // 拼接 Topic 和消息内容
    packet.append(variableHeader);
    packet.append(payload);

    // 发送 PUBLISH 报文
    m_socket->write(packet);
    m_socket->flush();

    appendLog(QString("[%1] [发布] Topic:%2,QoS:%3,内容:%4")
                  .arg(QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss"))
                  .arg(topic)
                  .arg(qos)
                  .arg(payloadText));
}

这里可以顺便解释一下 QoS 和 Retain。

QoS 表示消息服务质量:

cpp 复制代码
QoS 0:最多发送一次,不保证一定到达
QoS 1:至少发送一次,需要确认,可能重复

当前工具支持 QoS 0 和 QoS 1,这对基础测试已经够用。

Retain 表示 Broker 是否保留这个 Topic 的最后一条消息。如果勾选 Retain,后续新客户端订阅这个 Topic 时,可能会立刻收到 Broker 保留的最后一条消息。

接收消息则是通过 QTcpSocket::readyRead 触发的:

cpp 复制代码
void FormMqttTester::onSocketReadyRead()
{
    // 把 TCP 收到的数据追加到 MQTT 接收缓冲区
    m_receiveBuffer.append(m_socket->readAll());

    // 解析缓冲区中的 MQTT 报文
    processReceiveBuffer();
}

因为 TCP 是字节流协议,一次 readyRead 不一定刚好收到一个完整 MQTT 报文,所以这里不能简单地 readAll() 后立刻当作完整消息处理,而是需要一个缓冲区 m_receiveBuffer

解析时,先读取 MQTT 固定报头和剩余长度,确认缓冲区里有完整报文后,再取出一个完整报文处理。当前代码中的 processReceiveBuffer() 就是在循环处理接收缓冲区,解析出完整报文后交给 handleMqttPacket()

cpp 复制代码
void FormMqttTester::handleMqttPacket(
    quint8 packetType,
    quint8 flags,
    const QByteArray &payload)
{
    switch (packetType) {
    case 2:
        // CONNACK:连接确认
        handleConnAck(payload);
        break;

    case 3:
        // PUBLISH:收到发布消息
        handlePublish(flags, payload);
        break;

    case 4:
        // PUBACK:QoS 1 发布确认
        appendLog("收到PUBACK确认");
        break;

    case 9:
        // SUBACK:订阅确认
        handleSubAck(payload);
        break;

    case 13:
        // PINGRESP:心跳响应
        appendLog("收到PINGRESP心跳响应");
        break;

    default:
        appendLog(QString("收到MQTT报文类型:%1,长度:%2")
                      .arg(packetType)
                      .arg(payload.size()));
        break;
    }
}

比如收到 CONNACK 后,才说明 MQTT 真正连接成功:

cpp 复制代码
void FormMqttTester::handleConnAck(const QByteArray &payload)
{
    // 收到 CONNACK 后,不再等待连接确认
    m_waitingConnAck = false;

    // 停止超时定时器
    if (m_connAckTimer) {
        m_connAckTimer->stop();
    }

    // CONNACK 至少需要两个字节
    if (payload.size() < 2) {
        appendLog("CONNACK报文长度异常");
        setMqttConnectedState(false);
        return;
    }

    // 第二个字节是返回码
    const quint8 returnCode = static_cast<quint8>(payload.at(1));

    if (returnCode == 0x00) {
        // 返回码为 0,表示连接成功
        setMqttConnectedState(true);

        appendLog(QString("[%1] MQTT连接成功")
                      .arg(QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss")));
    } else {
        // 非 0 表示连接失败
        setMqttConnectedState(false);

        appendLog(QString("[%1] MQTT连接失败,返回码:%2")
                      .arg(QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss"))
                      .arg(returnCode));

        m_socket->disconnectFromHost();
    }
}

收到 PUBLISH 消息时,需要从 Payload 里解析 Topic 和消息内容:

cpp 复制代码
void FormMqttTester::handlePublish(quint8 flags, const QByteArray &payload)
{
    int pos = 0;

    // 读取 Topic
    const QString topic = readMqttString(payload, pos);

    if (topic.isEmpty() || pos > payload.size()) {
        appendLog("PUBLISH报文Topic解析失败");
        return;
    }

    // 从 flags 中解析 QoS
    const quint8 qos = (flags & 0x06) >> 1;
    quint16 packetId = 0;

    // QoS 1 需要读取报文 ID
    if (qos > 0) {
        if (pos + 2 > payload.size()) {
            appendLog("PUBLISH报文ID解析失败");
            return;
        }

        packetId =
            (static_cast<quint8>(payload.at(pos)) << 8) |
            static_cast<quint8>(payload.at(pos + 1));

        pos += 2;
    }

    // 剩下的内容就是消息体
    const QByteArray messageData = payload.mid(pos);
    const QString message = QString::fromUtf8(messageData);

    appendLog(QString("[%1] [接收] Topic:%2,内容:%3")
                  .arg(QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss"))
                  .arg(topic)
                  .arg(message));

    // 如果 QoS 是 1,需要回复 PUBACK
    if (qos == 1) {
        sendPubAck(packetId);
    }
}

这一段就是 MQTT 测试工具里"接收消息"的核心。

它的逻辑可以概括成:

cpp 复制代码
TCP 收到字节数据
        ↓
放入接收缓冲区
        ↓
解析 MQTT 固定报头和剩余长度
        ↓
判断报文类型
        ↓
如果是 PUBLISH,就解析 Topic 和消息内容
        ↓
显示到通信日志中

总结

MQTT 和 HTTP、WebSocket 最大的区别在于:MQTT 不是简单的一问一答,也不是普通的双向长连接聊天,而是基于 Broker 的发布/订阅模型。

实现一个 Qt MQTT 测试工具,可以按照下面的思路来设计:

cpp 复制代码
1. 使用 QTcpSocket 连接 MQTT Broker
2. TCP 连接成功后,手动发送 MQTT CONNECT 报文
3. 等待 Broker 返回 CONNACK,确认 MQTT 连接成功
4. 通过 SUBSCRIBE 报文订阅 Topic
5. 通过 PUBLISH 报文向 Topic 发布消息
6. 通过接收缓冲区解析 Broker 返回的 MQTT 报文
7. 将连接、订阅、发布、接收和错误信息写入日志区域

从代码结构上看,几个关键函数可以这样理解:

cpp 复制代码
initUi():初始化 Broker、端口、ClientId、Topic 和日志区域
initConnections():绑定按钮和 QTcpSocket 信号
onConnectClicked():检查输入并连接 Broker
onSocketConnected():TCP 连接成功后发送 MQTT CONNECT
sendConnectPacket():构造并发送 CONNECT 报文
sendSubscribePacket():构造并发送 SUBSCRIBE 报文
sendPublishPacket():构造并发送 PUBLISH 报文
processReceiveBuffer():处理 TCP 接收缓冲区
handleMqttPacket():根据 MQTT 报文类型分发处理
handlePublish():解析收到的 Topic 和消息内容

如果只是想快速实现 MQTT 客户端,也可以使用 Qt MQTT 模块里的 QMqttClient。不过手动用 QTcpSocket 拼报文的好处是:能更清楚地理解 MQTT 的底层通信过程,比如 CONNECT、CONNACK、SUBSCRIBE、SUBACK、PUBLISH、PUBACK 这些报文到底是怎么流转的。

整体来看,这个 MQTT 模块补上了网络调试助手中"发布/订阅通信"的能力。HTTP 更适合接口测试,WebSocket 更适合实时双向通信,而 MQTT 更适合物联网设备、消息推送、主题订阅和轻量级消息传输场景。

0voice · GitHub

相关推荐
Ulyanov6 小时前
《现代 Python 桌面应用架构实战:PySide6 + QML 从入门到工程化》:动态数据仪表盘与 NumPy 可视化 —— 从标量到向量的数据驱动进化
开发语言·python·qt·架构·numpy
春蕾夏荷_7282977256 小时前
1、c++ acl udp服务器客户端简单实例-服务器端(1)
服务器·c++·udp
小短腿的代码世界6 小时前
Qt序列化与持久化深度解析:从QDataStream到自定义二进制协议
开发语言·数据库·qt
誰能久伴不乏6 小时前
Qt/C++ 架构之美:用一个“水龙头”隐喻,讲透面向接口编程与彻底解耦
c++·qt·架构
周末也要写八哥6 小时前
Golang语言与Rust语言的对比
开发语言·后端·golang
楼田莉子6 小时前
Linux网络:数据链路层
linux·服务器·开发语言·网络·c++·后端
不甘先生6 小时前
Go 四层架构实战:Handler + Service + Repository + Entity(清晰、可控、可演进)
开发语言·架构·golang
Yang-Never6 小时前
Git -> Git Worktree 工作树
android·开发语言·git·android studio
riNt PTIP6 小时前
GO 快速升级Go版本
开发语言·redis·golang