C++ Qt 网络编程指南
绪论
网络编程主要依赖于操作系统提供的 Socket API。需要注意的是,C++ 标准库本身并未封装网络编程相关的 API。
关于 Qt 网络编程的几个要点:
Qt 网络编程基于 Socket API,涵盖 UDP、TCP 及 HTTP 协议实现。通过 QUdpSocket 和 QNetworkDatagram 处理无连接通信,利用 QTcpServer 与 QTcpSocket 管理面向连接的流式传输,并使用 QNetworkAccessManager 封装 HTTP 请求。示例包含回显服务器与客户端交互逻辑,强调信号槽机制驱动非阻塞 IO 及内存管理注意事项。

网络编程主要依赖于操作系统提供的 Socket API。需要注意的是,C++ 标准库本身并未封装网络编程相关的 API。
关于 Qt 网络编程的几个要点:
为什么 Qt 要划分出这些模块呢?
Qt 本身是一个非常庞大,包罗万象的框架。如果把所有功能都放到一起那么即使一个简单的程序,都会生成很大的可执行程序。所以进行一个模块化处理,将其他的功能分别封装成不同的模块。默认情况下额外模块不会参与编译,需要在 .pro 文件中,引入对应的模块,才能把对应功能给编译加载。Qt 其实提供了静态库的版本(所有都引入)、动态库的版本(指定引入)。
主要的类有两个:
QUdpSocket(同 Linux 理解的 socket 概念,它本质是打开的一个文件描述符,通过这个文件进行通信)QNetworkDatagram(是 Qt 对 UDP 数据报的完整封装,不仅包含二进制数据,还包含了发送/接收的地址端口等元信息,提供了比原始二进制数据处理更方便的面向对象接口。)QUdpSocket:
| 名称 | 类型 | 说明 | 对标原生 API |
|---|---|---|---|
| bind(const QHostAddress&, quint16) | 方法 | 绑定指定的端口号 | bind |
| receiveDatagram() | 方法 | 返回 QNetworkDatagram 读取一个 UDP 数据报 | recvfrom |
| writeDatagram(const QNetworkDatagram&) | 方法 | 发送一个 UDP 数据报 | sendto |
| readyRead | 信号 | 在收到数据并准备就绪后触发,当 socket 收到请求的时候,QUdpSocket 就会触发这个信号,此时就可以在槽函数里完成请求的操作 | 无 (类似于 IO 多路复用的通知机制) |
QNetworkDatagram:
| 名称 | 类型 | 说明 | 对标原生 API |
|---|---|---|---|
| QNetworkDatagram(const QByteArray&, const QHostAddress& , quint16 ) | 构造函数 | 通过 QByteArray , 目标 IP 地址,目标端口号 构造一个 UDP 数据报。通常用于发送数据时。 | 无 |
| data() | 方法 | 获取数据报内部持有的数据。返回 QByteArray | 无 |
| senderAddress() | 方法 | 获取数据报中包含的对端的 IP 地址。 | 无,recvfrom 包含了该功能。 |
| senderPort() | 方法 | 获取数据报中包含的对端的端口号 | 无,recvfrom 包含了该功能。 |
实现一个带有界面的 UDP 回显服务器(一般来说正经服务器,很少有界面化,一般都是命令行)。
QUdpSocket 成员变量 socket 句柄bindQHostAddress::Any 宏)QMessageBox 对话框提示
critical(严重)方法errorString 显示错误(相当于 linux 中的 perror,本质上也是对相同的 error 的封装,存储着当前的错误信息,当调用的时候就会返回)return 结束receiveDatagram(对比 c 中的 recv)获取请求QNetworkDatagram 进行接收(requestDatagram)data 函数变成 QString 类型方便使用toUtf8 函数,也就是将 QString 转换为字节数组(这里本质是需要 QByteArray 的,但使用 Utf8 是因为他和 QByteArray 都是二进制的,所以可以互通)QNetworkDatagram(这里需要注意是当时接收请求的对象中的 ip 和端口)内部函数 senderAddress、senderPort 中writeDatagramaddItem,构造就填入上述的显示内容 log 即可注意在 .pro 加入 network。
[图片]
fromStdString 将其他类型变成 QString
主要源码如下:
// widget.h
#ifndef WIDGET_H
#define WIDGET_H
#include <QWidget>
#include <QUdpSocket>
#include <QHostAddress>
#include <QMessageBox>
#include <QNetworkDatagram>
#include <QDateTime>
QT_BEGIN_NAMESPACE
namespace Ui { class Widget; }
QT_END_NAMESPACE
class Widget : public QWidget
{
Q_OBJECT
public:
explicit Widget(QWidget *parent = nullptr);
~Widget();
private:
Ui::Widget *ui;
QUdpSocket* socket;
QString process2(const QString& s);
private slots:
void process();
};
#endif // WIDGET_H
// widget.cpp
#include "widget.h"
#include "ui_widget.h"
Widget::Widget(QWidget *parent) : QWidget(parent), ui(new Ui::Widget)
{
ui->setupUi(this);
socket = new QUdpSocket(parent);
ui->listWidget->setObjectName(QString("UDP 服务器"));
connect(socket, &QUdpSocket::readyRead, this, &Widget::process);
// 注意一定要先连接槽函数再绑定端口号
bool isconnect = socket->bind(QHostAddress::Any, 8081); // QHostAddress::Any 宏代表任意 ip 连接
if(!isconnect){
QMessageBox::critical(this, "错误", "服务器启动错误"); // 弹窗提示
return;
}
setWindowTitle("Udp 服务端");
}
Widget::~Widget()
{
delete ui;
}
QString Widget::process2(const QString& request)
{
return request;
}
void Widget::process()
{
// 1. 读取请求并解析
QNetworkDatagram requestPacket = socket->receiveDatagram();
QString request = requestPacket.data();
// QString s(UdpPacket.data());
// QString s2 = QString::fromUtf8(UdpPacket.data());
QString response = process2(request);
QNetworkDatagram responsePacket(response.toUtf8(), requestPacket.senderAddress(), requestPacket.senderPort());
// 使用 Utf8 是因为他和 QByteArray 都是二进制的
// 时间戳部分没有写在步骤里是因为这部分是临时起意可以直接 copy 哟~
socket->writeDatagram(responsePacket);
qint64 msTimestamp = QDateTime::currentMSecsSinceEpoch();
QDateTime dateTime = QDateTime::fromMSecsSinceEpoch(msTimestamp);
QString formatted = dateTime.toString("yyyy-MM-dd hh:mm:ss.zzz");
QString log = "[" + formatted + " : " + requestPacket.senderAddress().toString() + ":" + QString::number(requestPacket.senderPort()) + "] req: " + request + ", resp: " + response;
ui->listWidget->addItem(log);
}
setWindowTitleQNetworkDatagram 对象
toUtf8)QHostAddress 才能使用)clear输入框(单行 LineEdit + 多元素控件 ListWidget),发送按钮 pushButton,显示服务器返回的内容,和显示已经发送的内容。
[图片]
同样对于客户端也要处理下服务器返回回来的响应
readyRead 信号,触发信号槽receiveDatagram,返回一个 QNetworkDatagram 报文类型变量服务器说:text最终效果:
[图片]
此处打印的 ip 为 ffff 它是 ipv6 的环回 ip。
可以通过 build 生成的 exe 文件打开多个客户端
那现在能否将这个 UDP 服务器放到与服务器上呢?
大概率不行。。(取决于 Qt 程序是否安装了图形化界面,因为 Qt 是依赖图形化界面来运行的)
能否使用现在的 Udp 客户端连之前 Linux 阶段的 Udp 服务器—完全 OK!一般也就是说服务器并不会那 Qt 写,Qt 常用于编写客户端。
主要源码如下:
// widget.h
#ifndef WIDGET_H
#define WIDGET_H
#include <QWidget>
#include <QUdpSocket>
#include <QNetworkDatagram>
#include <QHostAddress>
QT_BEGIN_NAMESPACE
namespace Ui { class Widget; }
QT_END_NAMESPACE
class Widget : public QWidget
{
Q_OBJECT
public:
explicit Widget(QWidget *parent = nullptr);
~Widget();
private slots:
void on_pushButton_clicked();
private:
Ui::Widget *ui;
QUdpSocket *socket;
private slots:
void processResponse();
};
#endif // WIDGET_H
// widget.cpp
#include "widget.h"
#include "ui_widget.h"
QString SERVER_IP = "127.0.0.1";
quint16 SERVER_PORT = 8081; // unsigned short
Widget::Widget(QWidget *parent) : QWidget(parent), ui(new Ui::Widget)
{
ui->setupUi(this);
socket = new QUdpSocket(parent);
setWindowTitle("Udp 客户端");
// 接收处理服务器发送回来的数据
connect(socket, &QUdpSocket::readyRead, this, &Widget::processResponse);
// 通过 readyRead 信号知道消息准备完毕
}
Widget::~Widget()
{
delete ui;
}
void Widget::on_pushButton_clicked()
{
QString context = ui->lineEdit->text();
QNetworkDatagram request(context.toUtf8(), QHostAddress(SERVER_IP), SERVER_PORT);
socket->writeDatagram(request);
// QListWidgetItem *item= new QListWidgetItem(context);
// 不会内存泄漏因为 item 给到 ListWidget 进行了管理当父类销毁子类也会消亡,但程序运行的内存会越来越大!
ui->listWidget->addItem("客户端说:" + context);
ui->lineEdit->clear();
}
void Widget::processResponse()
{
const QNetworkDatagram& packet = socket->receiveDatagram();
QString s = QString::fromUtf8(packet.data());
ui->listWidget->addItem("服务器说" + s);
}
常用内容:
QNetworkDatagram 数据报类
QString::fromUtf8 不过也可以直接接收因为 QByteArray 和 utf8 本质都是二进制)QUdpSocket 文件类:一个类在客户端和服务端都使用到了
bind 服务器的 ip 和 port 信息&QUdpSocket::readyRead 消息准备就绪的信号
.pro 中要添加 network其中 Tcp 和 Udp 的区别这里就不具体提及了~
核心 API 概览,核心类是两个:QTcpServer 和 QTcpSocket 分别作用于服务端和客户端(其中 Server 自然就是服务端使用,Socket 则是客户端使用)。
QTcpServer 用于监听端口获取客户端连接:
| 名称 | 类型 | 说明 | 对标原生 API |
|---|---|---|---|
| listen(const QHostAddress&, quint16 port) | 方法 | 绑定指定的地址和端口号,并开始监听。 | bind 和 listen |
| nextPendingConnection() | 方法 | 从系统中获取到一个已经建立好的 tcp 连接。返回一个 QTcpSocket,表示这个客户端的连接。通过这个 socket 对象完成和客户端之间的通信。 | accept |
| newConnection | 信号 | 有新的客户端建立连接好之后触发 | 无 (但是类似于 IO 多路复用中的通知机制) |
QTcpSocket 用于客户端和服务器之间的数据交互。(有点类似 Udp 中的 QNetworkDatagram)
| 名称 | 类型 | 说明 | 对标原生 API |
|---|---|---|---|
| readAll() | 方法 | 读取当前接收缓冲区中的所有数据。返回 QByteArray 对象。 | read |
| write(const QByteArray& ) | 方法 | 把数据写入 socket 中。 | write |
| deleteLater | 方法 | 暂时把 socket 对象标记为无效。Qt 会在下个事件循环中析构释放该对象。 | 无 (但是类似于'半自动化的垃圾回收') |
| peerAddress | 方法 | 获取对端客户端的 ip(通过 toString 查看 ip 的字符串) | 无 |
| peerPort | 方法 | 获取对端客户端 port | 无 |
| readyRead | 信号 | 有数据到达并准备就绪时触发。 | 无 (但是类似于 IO 多路复用中的通知机制) |
| disconnected | 信号 | 连接断开时触发。 | 无 (但是类似于 IO 多路复用中的通知机制) |
[图片]
ListWidgetQTcpServer 类成员变量句柄QTcpServer 的实例,并挂在对象树上nextPendingConnection 获取一个客户端的 socket(clientSocket 变量)
peerAddress、peerPort 对端客户端的 ip 和端口)readyRead 信号,lambda 表达式:
readAll,获取 request 请求报文process 函数完成:内部应该是业务逻辑,但此处只是回显,所以直接返回请求的 QString 即可,得到 response 请求报文write)disconnected 信号,lambda 表达式:ClientSocket(这个东西是可能存在 N 个的,随着客户端越来越多不释放,积累起来就会导致 内存泄漏和文件描述符泄露)listen(监听接收任意 ip,端口)QMessageBox::critical this 服务器启动失败 tcpSerccer 的方法 errorString 显示错误原因上述代码其实不够严谨,作为回显服务器是已经够了的。 实际使用 TCP 的过程中,TCP 是面向字节流的,一个完整的请求,可能会分成多断字节数组进行传输。虽然 TCP 已经帮我们处理了很多棘手的问题了,但是 TCP 本身不负责区分,从哪里到哪里是一个完整的应用层数据报 (粘包问题)。更严谨的做法,应该是每次收到的数据都给放到一个大的字节数组缓冲区中,并且提前约定好应用层协议的格式 (分隔符?长度?其他办法?) 再按照协议格式对缓冲区数据进行更细致的解析处理~~ (当前不打算写这么复杂了)
主要源码:
[图片]
#include "widget.h"
#include "ui_widget.h"
Widget::Widget(QWidget *parent) : QWidget(parent), ui(new Ui::Widget)
{
ui->setupUi(this);
setWindowTitle("服务器");
server = new QTcpServer(parent);
connect(server, &QTcpServer::newConnection, this, &Widget::processConnection); // 处理新链接
// 注意监听前一定要先把上述内容先绑定了!
bool ret = server->listen(QHostAddress::Any, 8080); // 失败会返回 false 所以进行接收处理
if(!ret){
QMessageBox::critical(this, "服务器启动失败", server->errorString()); // 弹出消息框
return;
}
}
Widget::~Widget()
{
delete ui;
}
QString Widget::process(QString& requset) // 主要业务逻辑
{
return requset; // 这里只是最简单的回显服务器所以就直接返回了
}
void Widget::processConnection()
{
QTcpSocket *clientSocket = server->nextPendingConnection(); // 从系统中获取一个已经建立好的 tcp 连接
QHostAddress add = clientSocket->peerAddress(); // ip:add.toString(); 通过转换成 String 就能看到 ip
quint16 port = clientSocket->peerPort(); // 端口
ui->listWidget->addItem("[" + add.toString() + ":" + QString::number(port) + "]:" + "客户端已上线");
// 处理客户端发来的请求(同样的 readyRead 信号)
connect(clientSocket, &QTcpSocket::readyRead, this, [=](){
// 注意 QT 信号槽中使用 lambda 时不要引用(&)获取而是值(=)获取 注意!!!!
QString request = clientSocket->readAll(); // 获取用户的 request 数据报
// 处理请求~~
const QString& response = process(request);
clientSocket->write(response.toUtf8());
QString log = "[" + add.toString() + ":" + QString::number(port) + "] req:" + request + ",res:" + response;
ui->listWidget->addItem(log);
});
// 处理客户端断开(disconnected 信号)
connect(clientSocket, &QTcpSocket::disconnected, this, [=](){
QString log = "[" + add.toString() + ":" + QString::number(port) + "] 已下线";
qDebug() << log; // 日志打印
ui->listWidget->addItem(log);
// 注意一定要释放该 clientSocket,他本质就是文件描述符
// delete clientSocket; 这个容易出现问题:立即删除对象:立即释放内存、可能正在处理信号槽:如果还有未处理的信号,会导致崩溃、可能引发二次删除:如果有父对象,父对象可能再次删除它、线程不安全:如果在事件循环中调用,可能导致不可预测行为
clientSocket->deleteLater(); // 他不是立即销毁的,而是在下一轮事件循环中,再进行上述销毁操作;保证信号槽安全:所有挂起的事件和信号都会先被处理
});
}
[图片]
QTcpSocket 对象 (socket)connectToHost(服务器 ip、端口)开始三次握手(但又不同于原生 api 的 connect 是阻塞的)readyRead 信号 的 信号槽,处理响应(直接使用 lambda):
errorString;并直接终止进程[图片]
主要源码:
[图片]
// widget.cpp
#include "widget.h"
#include "ui_widget.h"
Widget::Widget(QWidget *parent) : QWidget(parent), ui(new Ui::Widget)
{
ui->setupUi(this);
socket = new QTcpSocket(parent);
socket->connectToHost("127.0.0.1", 8080); // 处理返回的消息
connect(socket, &QTcpSocket::readyRead, this, [=](){
QString response = socket->readAll();
ui->listWidget->addItem("服务端说:" + response);
});
// 处理连接失败
if(!socket->waitForConnected()){
QMessageBox::critical(this, "client 连接服务器失败", socket->errorString());
exit(0); // 结束进程
}
}
Widget::~Widget()
{
delete ui;
}
void Widget::on_pushButton_clicked()
{
QString request = ui->lineEdit->text();
socket->write(request.toUtf8());
ui->listWidget->addItem("客户端说:" + request);
// 最后清空 LineEdit
ui->lineEdit->clear();
}
之前在学习 Linux 时写的 TCP 的回显服务器的时候,遇到了一个问题:多个客户端同时访问的时候,就只有一个生效。
后来引入了多线程,每个客户端安排一个单独的线程,问题才得到改善
之前写的那个程序,之所以出现上述问题,和 TCP 和 多线程 都没啥关系…从来没有说法说 TCP 服务器必须使用多线程编写!
之前存在这个问题的本质原因,是写双重循环,里层循环没有及时结束,导致外层循环不能快速的第二次调用到 accept,导致第二个客户端无法进行处理了。
引入多线程,本质上就是把双重循环,化简成两个独立的循环。
咱们 Qt 的服务器程序中,其实一个循环都没写,是通过 Qt 内置的 信号槽 来驱动的
总之:
Qt 中也是提供了 Http 的客户端,Http 协议本质上也就是基于 TCP 协议实现的,实现一个 HTTP 客户端/服务器,本质上也是基于 Tcp Socket 进行封装的。
核⼼ API 关键类主要是三个:
QNetworkAccessManager 提供了 HTTP 的核⼼操作
| ⽅法 | 说明 |
|---|---|
| get(const QNetworkRequest& ) | 发起⼀个 HTTP GET 请求。返回 QNetworkReply 对象。 |
| post(const QNetworkRequest& , const QByteArray& ) | 发起⼀个 HTTP POST 请求。返回 QNetworkReply 对象。 |
QNetworkRequest 表⽰⼀个 HTTP 请求 (不含 body 行):
如果需要发送⼀个带有 body 的请求 (⽐如 post), 会在 QNetworkAccessManager 的 post ⽅法中通过单独的参数来传⼊ body.
| ⽅法 | 说明 |
|---|---|
| QNetworkRequest(const QUrl& ) | 通过 URL 构造⼀个 HTTP 请求。 |
| setHeader(QNetworkRequest::KnownHeaders header, const QVariant &value) | 设置请求头。 |
其中的 QVariant 本质 可以理解成 void*
其中的 QNetworkRequest::KnownHeaders 是⼀个枚举类型,常⽤取值:
| 取值 | 说明 |
|---|---|
| ContentTypeHeader | 描述 body 的类型。 |
| ContentLengthHeader | 描述 body 的⻓度。 |
| LocationHeader | ⽤于重定向报⽂中指定重定向地址。(响应中使⽤,请求⽤不到) |
| CookieHeader | 设置 cookie |
| UserAgentHeader | 设置 User-Agent |
其中的 QNetworkReply 表⽰⼀个 HTTP 响应。这个类同时也是 QIODevice 的⼦类
| ⽅法 | 说明 |
|---|---|
| error() | 获取出错状态。 |
| errorString() | 获取出错原因的⽂本。 |
| readAll() | 读取响应 body。 |
| header(QNetworkRequest::KnownHeaders header) | 读取响应指定 header 的值。 |
[图片]
上面的框框并不是 ListWidget 了而是 QPlainTextEdit,因为它返回的大概率是一个 html(其中不使用 QTextEdit 是因为他会自动渲染 HTML)
.pro中添加 network 标识QNetworkAccessManger 成员变量(client)QUrl 对象获取 QString 能自动转换成 QUrl)– 注意最后这里访问的是 http 的而非 https
[图片]
实际开发中,HTTP Client 获取到的数据,也不一定非得是 HTML,更大的可能性是客户端开发和服务器开发约定好交互的数据格式,按照约定的格式,客户端拿到之后,进行解析,并显示到界面上。
源码:
[图片]
#include "widget.h"
#include "ui_widget.h"
Widget::Widget(QWidget *parent) : QWidget(parent), ui(new Ui::Widget)
{
ui->setupUi(this);
client = new QNetworkAccessManager(parent);
}
Widget::~Widget()
{
delete ui;
}
void Widget::on_pushButton_clicked()
{
QUrl url = ui->lineEdit->text();
QNetworkRequest request(url); // 一个 http 请求
QNetworkReply *response = client->get(request); // 其中这里并不会阻塞,所以需要通过一个信号来处理后续返回
// 这里的 response QNetworkReply 内部有的信号 finished 当触发时代表响应了
connect(response, &QNetworkReply::finished, this, [=](){
if(response->error() == QNetworkReply::NoError){ // 若相等则代表没有错误
QString req = response->readAll();
ui->plainTextEdit->setPlainText(req);
} else {
ui->plainTextEdit->setPlainText(response->errorString());
}
response->deleteLater(); // 下一个事件循环时销毁
});
}
QNetworkRequest 构造一个 http请求,初始化填入 url 代表路径QNetworkAccessManager 中的 get/post(本质就是 http 的 method)函数进行发送请求和并且接收响应 QNetworkReply,但需要注意的是响应处不是阻塞的QNetworkReply::finished 当数据到达时就会触发error 函数和 QNetworkReply::NoError 相等就代表没有问题此时就能在继续处理 http 响应了本章完。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online