【C++/Qt】Qt 实现 TCP Client:从功能构思到消息收发与日志保存

在网络调试工具中,TCP 客户端的作用很明确:主动连接服务器,向服务器发送数据,并接收服务器返回的数据。相比 UDP 客户端,TCP 是面向连接的,因此在实现时不能只考虑"发送数据",还要重点管理连接状态、按钮状态、异常提示和日志记录。

本文基于 Qt 实现一个可视化 TCP Client,主要功能包括:连接服务器、断开连接、发送消息、接收服务器回复、自动测试发送以及日志保存。项目中 FormTCPClient 类负责 TCP 客户端界面逻辑,内部通过 NetworkClient 封装网络通信行为,并使用 connected 标记当前连接状态。

一、实现 TCP 客户端前,先明确整体思路

做 TCP 客户端时,不能一上来就写发送函数,而是要先想清楚整个流程。

一个 TCP 客户端最基本的运行流程是:

复制代码
输入服务器 IP 和端口
        ↓
点击连接
        ↓
客户端向服务器发起连接
        ↓
连接成功后,允许发送数据
        ↓
收到服务器回复后,显示到日志框
        ↓
需要时断开连接或关闭程序

所以界面上至少需要这些控件:

复制代码
IP 输入框 / 下拉框
端口输入框
连接按钮
断开按钮
发送按钮
发送内容输入框
消息日志显示框
自动测试复选框
关闭按钮

这里有一个比较重要的设计点:按钮状态不能一直都是可用的。

比如程序刚启动时,还没有连接服务器,这时候"发送"和"断开"按钮就不应该能点。只有连接成功后,才允许发送数据和断开连接。项目初始化时就先禁用了断开、发送、自动测试和发送内容输入框,并给发送框设置了默认测试内容。

关键初始化思路可以写成这样:

cpp 复制代码
// 程序刚启动时,还没有连接服务器
ui->pushButton_TCPClientDisconnect->setEnabled(false);
ui->pushButton_TCPClientSendMsg->setEnabled(false);
ui->checkBox_TCPClientAutoTesting->setEnabled(false);
ui->plainTextEdit_TCPClientSendData->setEnabled(false);

// 提供一个默认测试内容,方便连接后快速发送
ui->plainTextEdit_TCPClientSendData->setPlainText("Hello TCP Server.");

这样做的好处是:

用户不会在未连接状态下误点发送,也不会在错误状态下触发无效操作。

二、连接服务器:先校验输入,再发起连接

TCP 客户端要连接服务器,最关键的两个参数是:

cpp 复制代码
服务器 IP
服务器端口

所以点击"连接"按钮后,不能马上连接,而应该先做输入校验。因为 IP 或端口不合法时,连接必然失败,而且用户也不知道自己哪里填错了。

连接按钮的核心思路是:

cpp 复制代码
1. 判断当前是否允许点击连接
2. 获取 IP 和端口
3. 校验 IP 是否合法
4. 校验端口是否合法
5. 保存最近一次连接参数
6. 调用网络对象发起连接
7. 在日志框中显示"正在连接"

项目中点击连接时,会读取 IP 和端口,然后分别调用输入校验工具进行检查;校验通过后,使用 QSettings 保存最近一次连接的 IP 和端口,最后调用 NetworkClient.ClientConnectionToServer(ipAddress, port) 发起连接。

关键代码可以这样写:

cpp 复制代码
void FormTCPClient::on_pushButton_TCPClientConnect_clicked()
{
    // 防止按钮被禁用后仍然触发逻辑
    if (!ui->pushButton_TCPClientConnect->isEnabled()) {
        return;
    }

    connected = false;

    QString ipAddress = ui->comboBox_TCPClientIP->currentText();
    int port = ui->spinBox_TCPClientPort->value();

    // 校验 IP 地址
    auto ipValidation = InputValidator::validatorNetworkAddress(ipAddress);
    if (!ipValidation.isValid) {
        HANDLE_ERROR(ErrorHandler::ValidationError,
                     ErrorHandler::Warning,
                     ipValidation.errorMessage,
                     this);
        return;
    }

    // 校验端口
    auto portValidation = InputValidator::validatorPort(port);
    if (!portValidation.isValid) {
        HANDLE_ERROR(ErrorHandler::ValidationError,
                     ErrorHandler::Warning,
                     portValidation.errorMessage,
                     this);
        return;
    }

    // 保存最近一次连接的 IP 和端口
    QSettings settings;
    settings.setValue("TCPClient/lastIP", ipAddress);
    settings.setValue("TCPClient/lastPort", port);
    settings.sync();

    // 发起连接
    NetworkClient.ClientConnectionToServer(ipAddress, port);
}

这里为什么要用 QSettings

因为网络调试工具经常反复连接同一个服务器,如果每次打开程序都要重新输入 IP 和端口,使用体验会比较差。把最近一次连接参数保存起来,下次启动时自动恢复,就更接近一个完整工具软件的体验。

三、连接结果不要直接猜,要交给信号槽处理

TCP 连接不是点击按钮后马上就一定成功,它可能成功,也可能失败。失败原因可能是服务器没启动、IP 错误、端口错误、防火墙拦截等。

所以连接按钮只负责"发起连接",真正的连接结果应该通过网络模块的信号来处理。

项目中绑定了三个核心信号:

cpp 复制代码
connectionEstablished:连接成功
connectionFailed:连接失败
dataReceived:收到服务器数据

连接成功时,界面需要切换到"已连接状态":禁用连接按钮,启用断开按钮、发送按钮、发送输入框和自动测试,并把 connected 改成 true。连接失败时则反过来,恢复连接按钮,禁用发送相关控件,并把 connected 改成 false。这些状态变化都在 NetworkClient 的信号槽中完成。

连接成功的处理逻辑可以写成:

cpp 复制代码
connect(&NetworkClient, &Network::connectionEstablished, this, [this]() {
    // 连接成功后,进入可通信状态
    ui->pushButton_TCPClientConnect->setEnabled(false);
    ui->pushButton_TCPClientDisconnect->setEnabled(true);
    ui->pushButton_TCPClientSendMsg->setEnabled(true);
    ui->plainTextEdit_TCPClientSendData->setEnabled(true);
    ui->checkBox_TCPClientAutoTesting->setEnabled(true);

    connected = true;

    QString log = QString("[%1] 连接服务器成功")
                      .arg(QDateTime::currentDateTime()
                      .toString("yyyy-MM-dd hh:mm:ss"));

    ui->plainTextEdit_TCPClientMsgList->appendPlainText(log);
    trimLog();
});

连接失败的处理逻辑可以写成:

cpp 复制代码
connect(&NetworkClient, &Network::connectionFailed, this,
        [this](const QString &errorString) {
    // 连接失败后,恢复未连接状态
    ui->pushButton_TCPClientConnect->setEnabled(true);
    ui->pushButton_TCPClientDisconnect->setEnabled(false);
    ui->pushButton_TCPClientSendMsg->setEnabled(false);
    ui->plainTextEdit_TCPClientSendData->setEnabled(false);
    ui->checkBox_TCPClientAutoTesting->setEnabled(false);

    connected = false;

    QString log = QString("[%1] 连接服务器失败: %2")
                      .arg(QDateTime::currentDateTime()
                      .toString("yyyy-MM-dd hh:mm:ss"))
                      .arg(errorString);

    ui->plainTextEdit_TCPClientMsgList->appendPlainText(log);
    trimLog();
});

这里的重点不是代码本身,而是设计思想:

TCP 连接结果是异步发生的,不应该在点击函数里强行判断成功失败,而应该让网络对象通过信号通知界面。

这样写后,按钮函数和连接结果处理逻辑就分开了,结构更清晰。

四、发送和接收:发送前看状态,接收后写日志

连接成功后,用户就可以发送消息了。但发送之前仍然要做两件事:

cpp 复制代码
1. 发送内容不能为空
2. 当前必须已经连接服务器

如果没有这两个判断,用户可能在未连接状态下发送,或者发送一个空字符串,最后只会得到不可控的错误。

项目中的发送逻辑正是这样处理的:先获取发送框内容并去除首尾空白,再判断内容是否为空、当前是否已连接,最后调用 NetworkClient.ClientSendMsgToServer(message) 发送消息。

核心代码可以写成:

cpp 复制代码
void FormTCPClient::on_pushButton_TCPClientSendMsg_clicked()
{
    QString message = ui->plainTextEdit_TCPClientSendData
                          ->toPlainText()
                          .trimmed();

    if (message.isEmpty()) {
        HANDLE_ERROR(ErrorHandler::ValidationError,
                     ErrorHandler::Warning,
                     "发送内容不能为空!",
                     this);
        return;
    }

    if (!connected) {
        HANDLE_ERROR(ErrorHandler::NetWorkError,
                     ErrorHandler::Warning,
                     "未连接服务器,无法发送。",
                     this);
        return;
    }

    NetworkClient.ClientSendMsgToServer(message);

    QString timestamp = QDateTime::currentDateTime()
                            .toString("yyyy/MM/dd hh:mm:ss");

    QString logEntry = QString("\n[%1]\n客户端发送:%2")
                           .arg(timestamp, message);

    ui->plainTextEdit_TCPClientMsgList->appendPlainText(logEntry);

    trimLog();
}

接收数据时,思路更简单:网络模块收到服务器数据后发出 dataReceived 信号,界面层只需要把数据追加到日志框中即可。项目中使用 appendPlainText() 追加接收内容,并在追加后调用 trimLog() 控制日志长度。

cpp 复制代码
connect(&NetworkClient, &Network::dataReceived, this,
        [this](const QString &data) {
    QString log = QString("\n[%1] 接收: %2")
                      .arg(QDateTime::currentDateTime()
                      .toString("yyyy-MM-dd hh:mm:ss"))
                      .arg(data);

    ui->plainTextEdit_TCPClientMsgList->appendPlainText(log);
    trimLog();
});

这里可以看出一个比较好的分层思路:

cpp 复制代码
Network 负责真正的 TCP 收发
FormTCPClient 负责界面状态和日志显示

界面类不直接操作底层 socket,这样代码会更容易维护。

五、自动测试、日志裁剪和日志保存:让工具更像"产品"

如果只是能连接和发送,这个 TCP 客户端还只是一个练习项目。想让它更接近一个工具软件,还需要考虑一些产品化细节。

1. 自动测试

自动测试的场景是:连接服务器后,反复发送同一段测试数据,用来观察服务器是否稳定响应。

自动测试开启前需要检查:

cpp 复制代码
是否已经连接服务器
发送内容是否为空

项目中如果未连接服务器或发送内容为空,会取消复选框勾选并提示错误;校验通过后,会把当前发送内容设置为自动测试消息,并启动定时发送。

关键逻辑可以写成:

cpp 复制代码
void FormTCPClient::on_checkBox_TCPClientAutoTesting_clicked()
{
    bool isAutoTesting = ui->checkBox_TCPClientAutoTesting->isChecked();

    if (isAutoTesting) {
        if (!connected) {
            HANDLE_ERROR(ErrorHandler::NetWorkError,
                         ErrorHandler::Warning,
                         "未连接服务器,无法开始自动测试。",
                         this);
            ui->checkBox_TCPClientAutoTesting->setChecked(false);
            return;
        }

        QString message = ui->plainTextEdit_TCPClientSendData
                              ->toPlainText()
                              .trimmed();

        if (message.isEmpty()) {
            HANDLE_ERROR(ErrorHandler::ValidationError,
                         ErrorHandler::Warning,
                         "发送内容不能为空,无法开始自动测试。",
                         this);
            ui->checkBox_TCPClientAutoTesting->setChecked(false);
            return;
        }

        NetworkClient.setAutoTestMessage(message);
        NetworkClient.StartTimeOutFunc();

        ui->plainTextEdit_TCPClientSendData->setEnabled(false);
        ui->pushButton_TCPClientSendMsg->setEnabled(false);
    } else {
        NetworkClient.StopTimerOutFunc();
        enableCommunicationControls(true);
    }
}

自动测试时禁用发送输入框,是为了避免定时发送过程中用户修改内容,导致测试结果不稳定。

2. 日志裁剪

网络调试工具运行时间一长,日志会越来越多。如果一直往 QPlainTextEdit 里追加内容,界面会越来越卡。

所以日志不能无限增长。项目中通过 trimLog() 控制日志行数:当日志超过一定行数后,从开头批量删除旧日志,只保留最近的内容。

核心思路是:

cpp 复制代码
获取日志文档对象
统计当前行数
如果未超过限制,不处理
如果超过限制,从开头删除多余行

关键代码如下:

cpp 复制代码
void FormTCPClient::trimLog(int keepBlocks, int trimStep)
{
    static const int kKeepDefault = 1000;
    static const int kTrimStep = 200;

    const int keep = keepBlocks > 0 ? keepBlocks : kKeepDefault;
    const int step = trimStep > 0 ? trimStep : kTrimStep;

    auto doc = ui->plainTextEdit_TCPClientMsgList->document();
    int blocks = doc->blockCount();

    if (blocks <= keep + step) {
        return;
    }

    int removeBlocks = blocks - keep;

    QTextCursor cursor(doc);
    cursor.movePosition(QTextCursor::Start);

    for (int i = 0; i < removeBlocks; i++) {
        cursor.movePosition(QTextCursor::NextBlock,
                            QTextCursor::KeepAnchor);
    }

    cursor.removeSelectedText();
    cursor.deleteChar();
}

这个设计的好处是:日志仍然能持续显示,但不会因为内容过多导致界面性能下降。

3. 日志保存

日志只显示在界面上还不够,实际调试时经常需要保存下来,方便之后分析问题。

项目中关闭窗口时会调用 saveLog(),而 saveLog() 内部会把日志框内容写入本地文件。头文件中也把 closeEvent()savePlainTextEditToFile()saveLog() 作为 TCP 客户端日志保存相关接口进行了声明。

关闭事件可以这样写:

cpp 复制代码
void FormTCPClient::closeEvent(QCloseEvent *event)
{
    saveLog();
    event->accept();
}

保存日志时,基本思路是:

cpp 复制代码
获取应用数据目录
创建日志目录
拼接日志文件名
如果文件存在就追加写入
如果文件不存在就新建写入
设置 UTF-8 编码
写入日志框内容

这样做比直接保存到程序目录更稳妥,因为程序目录可能没有写权限,而应用数据目录更适合存放配置和日志。

总结

实现一个 TCP 客户端,重点不是简单地写一个"发送函数",而是要围绕连接状态设计整个流程。

一个比较完整的 TCP Client 应该考虑:

cpp 复制代码
1. 启动时控件状态如何初始化
2. 连接前如何校验 IP 和端口
3. 连接成功和失败后界面状态如何切换
4. 发送前如何判断内容和连接状态
5. 接收数据后如何显示和记录日志
6. 日志过多时如何裁剪
7. 关闭程序时如何保存日志

本文中的实现思路可以概括为:

cpp 复制代码
用 Network 封装底层通信
用 FormTCPClient 管理界面状态
用 connected 标记当前连接状态
用信号槽处理连接结果和接收数据
用 QSettings 保存最近一次连接参数
用 QTextCursor 裁剪日志
用 QFile/QTextStream 保存日志文件

这样实现出来的 TCP 客户端,不只是"能连、能发、能收",而是具备输入校验、状态控制、日志管理和自动测试能力,更接近一个可以实际使用的 Qt 网络调试工具。

0voice · GitHub

相关推荐
qq_283720051 小时前
Qt5.12.8 QML Canvas ctx.setLineDash 失效终极解决方案
开发语言·qt
Z文的博客2 小时前
嵌入式LINUX QT 开发 .gitignore 文件编写指南
linux·git·qt·elasticsearch·嵌入式
掘根2 小时前
【微服务即时通讯】客户端数据中心
qt·微服务·架构
西门吹牛2 小时前
Pycharm编译器中部署了pyqt5,Qtdesigner无法打开了,解决方案
ide·qt·pycharm
buhuizhiyuci2 小时前
[QT]QT入门的项目创建和项目代码的介绍
开发语言·qt
想成为优秀工程师的爸爸2 小时前
第二十四篇技术笔记:郭大侠学DoIP - 从“偶睡破庙”到“天字一号”
网络·笔记·网络协议·tcp/ip·信息与通信
夜瞬2 小时前
HTTP基础教程:请求方法、状态码、JSON、鉴权、超时、重试与流式返回
网络协议·http·json
wefg13 小时前
【计算机网络】传输层协议(UDP/TCP)
tcp/ip·计算机网络·udp
水木流年追梦3 小时前
CodeTop Top 300 热门题目10-验证IP地址
python·网络协议·tcp/ip·算法·leetcode