文章目录
- 前言:
- 1. Qt 网络编程介绍
- 1.1 什么是网络编程?
- 1.2 Qt的模块
- 2. UDP Socket
- 2.1 核心 API 概述
- 2.2 写一个带有界面的 Udp 回显服务器
- 2.3 写一个带有界面的 Udp 客户端
- 3. TCP Socket
- 3.1 核心 API 概述
- 3.2 代码:
- 4. HTTP Client
- 4.1 核心API
- 4.2 代码示例
- 总结:
前言:
在当今信息化社会,网络编程已成为软件开发中不可或缺的一部分。Qt,作为一个跨平台的应用程序框架,提供了丰富的网络编程API,使得开发者能够便捷地实现客户端和服务器之间的通信。本文将深入探讨Qt网络编程的基本概念、核心API以及实际应用示例,帮助读者理解并掌握使用Qt进行网络编程的方法。
1. Qt 网络编程介绍
1.1 什么是网络编程?
网络编程,操作系统提供了一组API(Socket API)
C++标准库中,并没有提供网络编程的 api 的封装。
进行网络编程的时候,本质是在编写应用层代码,需要传输层进行支持。
传输层最核心的协议,有 UDP 和 TCP,并且这两协议,差别还很大。
Qt 也提供了两套 API。
1.2 Qt的模块
使用 Qt 网络编程的 API,需要在 .pro 文件中添加 network 模块!
之前我们学过的 Qt 的各种控件,各种内容,都是包含在QtCore 模块中(默认就添加的)
为什么Qt要划分出这些模块呢?
Qt本身是一个非常庞大,包罗万象的框架。
如果把所有的 Qt 的功能都放到一起,即使咱们就只写一个简单的 hello world, 此时生产的可执行文件也会非常庞大。(这里就包含了大量其实没有使用的功能)
模块化处理:
其他的功能分别封装成不同的模块,默认情况下这些额外的模块不会参与编译。
需要在.pro文件中引入对应的模块才能把对应功能给编译加载进来。
Qt 其实提供了静态库的版本和动态库的版本。
2. UDP Socket
2.1 核心 API 概述
主要有两个 QUdpSocket
(一个文件) 和 QNetworkDatagram
(数据包,UDP是面向数据报的)
QUdpSocket
表示一个 UDP 的 socket 文件。
readyRead
:当socket 收到请求的时候,QUdpSocket 就会触发这个信号。
此时就可在槽函数里完成读取请求的操作了。
基于信号槽,就天然达成了"事件驱动"这样的一种网络编程的方式!
QNetWorkDatagram
表示一个UDP数据报
2.2 写一个带有界面的 Udp 回显服务器
一个正经的服务器很少会有图形化界面(一般都是命令行)
Qt 也是完全可以编写控制台程序的。
在 .pro 文件中添加一个network
注意:一定是先连接信号槽,后绑定端口号。 一旦绑定端口了,意味着请求就可以被收到了!
如果在绑定之后,在连接信号槽之前,有客户端把请求发过来了,此时就可能读不到这样的请求(就没了)
// 绑定端口号
socket->bind(QHostAddress::Any, 9090);
一个端口号只能被一个socket绑定,万一9090被别人绑定了呢? 返回绑定失败信息
代码:
#include "widget.h"
#include "ui_widget.h"
#include <QMessageBox>
#include <QNetworkDatagram>
Widget::Widget(QWidget *parent)
: QWidget(parent)
, ui(new Ui::Widget)
{
ui->setupUi(this);
// 创建出这个对象
socket = new QUdpSocket(this); // this 是用于通过对象树自动delete掉对象的
// 不然就要在析构中去手动delete掉
// 设置窗口标题
this->setWindowTitle("服务器");
// 连接信号槽
connect(socket, &QUdpSocket::readyRead, this, &Widget::processRequest);
// 绑定端口号
bool ret = socket->bind(QHostAddress::Any, 9090);
if (!ret) {
// 绑定失败!
QMessageBox::critical(this, "服务器启动出错", socket->errorString());
// socket->本质上也是对系统的errno机制进行封装, 相当与Linux中的perror
return;
}
}
Widget::~Widget()
{
delete ui;
}
// 这个函数完成的逻辑,就是服务器的最核心逻辑了
void Widget::processRequest()
{
// 1. 读取请求并解析
const QNetworkDatagram& requestDatagram = socket->receiveDatagram();
QString request = requestDatagram.data(); // .data() 返回的是一个QByteArray
// QByteArray 是可以赋值给QString
// 2. 根据请求计算响应(由于是回显服务器,响应不需要计算,就是请求本身)
const QString& response = process(request);
// 3. 把响应写回客户端
QNetworkDatagram responseDatagram(response.toUtf8(), requestDatagram.senderAddress(), requestDatagram.senderPort());
// .toUtf8 取出QString 内部的字节数组,客户端是谁就包含在requestDatagram中了
socket->writeDatagram(responseDatagram);
// 把这次交互的信息,显示到界面上
QString log = "[" + requestDatagram.senderAddress().toString() + ":" + QString::number(requestDatagram.senderPort())
+ "] req:" + request + ", resp: " + response;
ui->listWidget->addItem(log);
}
QString Widget::process(const QString &request)
{
// 由于当前回显服务器,响应就是和请求完全一样的
// 对于一个成熟的商业服务器,这里请求->响应的计算过程可能是非常复杂的(业务逻辑)
return request;
}
2.3 写一个带有界面的 Udp 客户端
Qt Creator 中是可以同时打开多个项目。此时,如果这俩项目中存在同名文件就非常容易混淆。
此时写的客户端,要能够主动给服务器发起请求
// 定义两个常量,来描述服务器的 地址 和 端口
const QString& SERVER_IP = "127.0.0.1";
const quint16 PORT = 9090;
端口号本质上是一个 2字节的 无符号 整数,
quint16
本质上就是一个unsigned short
,虽然short
通常都是2字节但是C++标准中没有明确规定这一点,只是说short
不应该少于2个字节。
什么时候用引用?什么时候用赋值?
const QNetworkDatagram& responseDatagram = socket->receiveDatagram();
QString response = responseDatagram.data();
啥时候使用引用类型,啥时候使用值类型,需要平时写代码的时候,多去思考,多去注意的 !
大的原则,肯定是能用引用尽量用引用,但有的时候注意到,尤其是上面这种不同的值进行互相转换的时候,大概率是要用值类型的!
代码:
#include "widget.h"
#include "ui_widget.h"
#include <QNetworkDatagram>
// 定义两个常量,来描述服务器的 地址 和 端口
const QString& SERVER_IP = "127.0.0.1";
const quint16 SERVER_PORT = 9090;
Widget::Widget(QWidget *parent)
: QWidget(parent)
, ui(new Ui::Widget)
{
ui->setupUi(this);
socket = new QUdpSocket(this);
// 修改窗口标题,方便咱们区分这是一个客户端程序
this->setWindowTitle("客户端");
// 通过信号槽,来处理服务器返回的数据
connect(socket, &QUdpSocket::readyRead, this, &Widget::processResponse);
}
Widget::~Widget()
{
delete ui;
}
void Widget::on_pushButton_clicked()
{
// 1. 获取到输入框的内容
const QString& text = ui->textEdit->toPlainText();
// 2. 构造 UDP 的请求数据
QNetworkDatagram requestDatagram(text.toUtf8(), QHostAddress(SERVER_IP), SERVER_PORT); // 这里字符串的IP要转换为点分十进制的IP
// 3. 发送请求数据
socket->writeDatagram(requestDatagram);
// 4. 把发送的请求也添加到列表框中
ui->listWidget->addItem("客户端说:" + text);
// 5. 把输入框的内容也清空一下
ui->textEdit->setText("");
}
void Widget::processResponse()
{
// 通过这个函数来处理收到的响应
// 1. 读取到响应数据
const QNetworkDatagram& responseDatagram = socket->receiveDatagram();
QString response = responseDatagram.data();
// 啥时候使用引用类型,啥时候使用值类型,需要平时写代码的时候,多去思考,多去注意的
// 大的原则,肯定是能用引用尽量用引用,但有的时候注意到,尤其是上面这种不同的值进行互相转换的时候,大概率是要用值类型的!
// 2. 把响应数据显示到界面上
ui->listWidget->addItem("服务器说:" + response);
}
如何启动多个客户端?
多启动几个可执行程序就好!
之前学Linux网络编程的时候,是使用云服务器部署服务器程序,其它的同学们也能连上。
- 能否把现在的UDP服务器放到云服务器上呢?
大概率不行,取决于你的服务器是否安装了图形化界面,Qt程序需要依赖于图形化界面来运行的!Linux的云服务器,一般都是没有图形化界面的如果需要你需要手动额外安装。作为一个服务器,本身就是没有图形界面的(此处只是为了演示Qt网络编程的情况),也不会使用Qt来写服务器程序!
- 能否使用先在的UDP客户端连接,Linux上写的UDP服务器呢?
这是完全OK的,这也是网络编程/协议的意义!
一般商业公司的项目,都是通过其它方式编写的服务器程序(大概率不会是Qt),但是使用Qt编写客户端!
3. TCP Socket
- UDP 属于无连接,不可靠传输,面向数据报,全双工
- TCP 有连接,可靠传输,面向字节流,全双工
因此,TCP的代码,要比UDP稍微复杂一点点!
三次握手四次挥手(TCP建立链接或断开链接的时候完成的),操作系统系统内核负责完成的!
应用层的代码,只能告诉内核,我要发起一个连接(客户端),或者告诉内核,我要拿到一个已经建立好的连接(服务器)
3.1 核心 API 概述
核心类是:QTcpServer
和 QTcpSocket
QTcpServer
用于监听客户端口,和获取客户端连接
QTcpSocket
用户客户端和服务器之间的数据交互
事件循环:简单理解,可以认为是Qt程序内部带有一个“生物钟”这样的东西!周期性的执行一些逻辑!
QByteArray
⽤于表⽰⼀个字节数组. 可以很⽅便的和 QString 进⾏相互转换. 例如:
- 使⽤
QString
的构造函数即可把QByteArray
转成QString
。- 使⽤
QString
的 toUtf8 函数即可把QString
转成QByteArray
。
// 2. 通过信号槽,来处理客户端发来请求的情况
// 2. 通过信号槽,来处理客户端发来请求的情况
connect(clientSocket, &QTcpSocket::readyRead, this, [=](){
// a)读取出请求数据,此处readAll 返回的是QByteArray, 通过赋值转成 QString
QString request = clientSocket->readAll();
// b)根据请求处理响应
const QString& response = process(request);
// c) 把响应写回到客户端
clientSocket->write(response.toUtf8());
// d)把上述信息记录到日志中
QString log = "[" + clientSocket->peerAddress().toString() + ":" + QString::number(clientSocket->peerPort()) + "]"
+ " req: " + request + ", resp:" + response;
ui->listWidget->addItem(log);
});
在Linux 网络编程,需要搞一个循环,循环的读取请求,循环处理…
在 Qt 中基于信号槽就不必循环了!
每次客户端发来请求,都能触发 readyRead 信号。
即使多个请求,槽函数也是可以顺利的执行到的!
上述的代码其实是不够严谨,作为回显服务器是已经够了的!
实际上使用TCP的过程中,TCP是面向字节流的,一个完整的请求,可能分成多段字节数组进行传输!
虽然TCP已经帮我们处理了很多棘手的问题了,但是TCP本身不负责区分,从哪里到哪里是一个完整的应用层数据报(粘包问题)。
更严谨的做法,应该是每次收到的数据都给放到一个大的字节数组缓冲区中,并且提前约定好应用层的协议的格式(分隔符?长度?其它办法?)
再按照协议格式对缓冲区进行更细致的解析处理(当前不打算写这么复杂了)再按照协议格式对缓冲区数据进行更细致的解析处理
QTcpSocket* clientSocket
每个客户端都有一个这样的对象,存在N个的,随着服务器的运行,客户端越来越多,如果不释放,此时累计的clientSocket也会越来越多!
QTcpServer QUdpSocket都是只有一份的(就算不释放,影响不大) 内存泄漏其实影响不大,但是如果是文件描述符泄漏呢?
现在的机器内存都很大,而文件描述符表的长度,则是操作系统的一个参数。Linux可以通过ulimits 命令来查看和调整!
// b) 手动释放 clientSocket
delete clientSocket;
一旦要是 delete 就意味着其它逻辑无法使用 clientSocket
务必要保证delete 是这个槽函数的最后一步,而且也要保证 delete 肯定能执行到,不会被return / 抛出异常 给跳过…
clientSocket->deleteLater();
直接使用delete是下策,使用deleteLater 更加合适 这个操作,不是立即销毁 clientSocket,
而是告诉Qt,下一轮事件循环中(槽函数都是在事件循环中执行的,进入到下一轮事件循环,意味着上一轮事件循环肯定结束了,也以为着当前的槽函数肯定是结束了),再进行上述销毁操作!当然,上述做法都是权宜之计,相比之下,Java/Python/Go 等全自动化垃圾回收更好用一些!
socket->connectToHost("127.0.0.1", 9090);
在Linux写的TCP的回显服务器的时候,遇到了一个问题,多个客户端同时访问的时候,就只会有一个生效;后来引入了多线程,每个客户端安排一个单独的
线程,每个客户端安排一个单独的线程,问题才得到改善
在Linux中,之所以出现上述问题,和TCP,和多线程都没啥关系。从来没有说法,说TCP服务器必须使用多线程编写!
之前存在这个问题的本质原因,是写了一个双重循环,里层循环没有及时结束,导致外层循环不能快速的第二次调用到accept,
导致第二个客户端无法处理了!
引入多线程,本质上就是把双重循环,化简成为两个独立的循环。
而在咱们Qt的服务器中,其实一个循环都没写,是通过Qt内置的信号槽来驱动的!
信号槽机制很好的化简了咱们的程序!但是基本没有正经的服务器用Qt来写!
3.2 代码:
服务端:
#include "widget.h"
#include "ui_widget.h"
#include <QMessageBox>
#include <QTcpSocket>
Widget::Widget(QWidget *parent)
: QWidget(parent)
, ui(new Ui::Widget)
{
ui->setupUi(this);
// 1. 修改窗口标题
this->setWindowTitle("服务器");
// 2. 创建 QTcpServer 的实例
tcpServer = new QTcpServer(this);
// 3.通过信号槽,指定如何处理连接
connect(tcpServer, &QTcpServer::newConnection, this, &Widget::processConnection);
// 4. 绑定并监听端口号?一定要确保准备工作充分了,再开张营业
// 这个操作得是初始化得最后一步,都是需要把如何处理连接,如何处理请求...都准备好之后,才能真正得端口号并监听
bool ret = tcpServer->listen(QHostAddress::Any, 9090);
if (!ret) {
QMessageBox::critical(this, "服务器启动失败!", tcpServer->errorString());
exit(1);
}
}
Widget::~Widget()
{
delete ui;
}
void Widget::processConnection()
{
// 1. 通过tcpServer拿到一个socket对象,通过这个对象和客户端进行通信
QTcpSocket* clientSocket = tcpServer->nextPendingConnection();
QString log = "[" + clientSocket->peerAddress().toString() + ":" + QString::number(clientSocket->peerPort()) + "]客户端上线!";
ui->listWidget->addItem(log);
// 2. 通过信号槽,来处理客户端发来请求的情况
connect(clientSocket, &QTcpSocket::readyRead, this, [=](){
// a)读取出请求数据,此处readAll 返回的是QByteArray, 通过赋值转成 QString
QString request = clientSocket->readAll();
// b)根据请求处理响应
const QString& response = process(request);
// c) 把响应写回到客户端
clientSocket->write(response.toUtf8());
// d)把上述信息记录到日志中
QString log = "[" + clientSocket->peerAddress().toString() + ":" + QString::number(clientSocket->peerPort()) + "]"
+ " req: " + request + ", resp:" + response;
ui->listWidget->addItem(log);
});
// 3. 通过信号槽,来处理客户端断开连接的情况
connect(clientSocket, &QTcpSocket::disconnected, this, [=](){
// a) 把断开连接的连接的信息通过日志显示出来
QString log = "[" + clientSocket->peerAddress().toString() + ":" + QString::number(clientSocket->peerPort()) + "] 客户端下线!";
ui->listWidget->addItem(log);
// // b) 手动释放 clientSocket, 直接使用delete是下策,使用deleteLater 更加合适
// delete clientSocket;
clientSocket->deleteLater();
});
}
// 此处写的是回显服务器
QString Widget::process(const QString request)
{
return request;
}
客户端:
#include "widget.h"
#include "ui_widget.h"
#include <QMessageBox>
Widget::Widget(QWidget *parent)
: QWidget(parent)
, ui(new Ui::Widget)
{
ui->setupUi(this);
// 1. 设置窗口标题
this->setWindowTitle("客户端");
// 2. 创建socket对象实例
socket = new QTcpSocket(this);
// 3. 和服务器建立连接
socket->connectToHost("127.0.0.1", 9090);
// 调用者个函数,此时系统内核就会和对方的服务器之间进行三次握手了
// 三次握手也是需要消耗一定的时间的
// 4. 连接信号槽,处理响应
connect(socket, &QTcpSocket::readyRead, this, [=](){
// a) 读取出响应内容
QString response = socket->readAll();
// b) 把显示内容显示到界面上
ui->listWidget->addItem("服务器说:" + response);
});
// 5. 等待连接建立的结果,确认是否连接成功
bool ret = socket->waitForConnected();
if (!ret){
QMessageBox::critical(this, "连接服务器出错", socket->errorString());
exit(1);
}
}
Widget::~Widget()
{
delete ui;
}
void Widget::on_pushButton_clicked()
{
// 1. 获取到输入框内容中的
const QString& text = ui->lineEdit->text();
// 2. 发送数据给服务器
socket->write(text.toUtf8());
// 3. 把发送的消息显示到界面上
ui->listWidget->addItem("客户端说:" + text);
// 4. 清空输入框的内容
ui->lineEdit->setText("");
}
4. HTTP Client
进行Qt开发时,和服务器之间的通信很多时候也会用到HTTP协议
- 通过HTTP从服务器获取数据
- 通过HTTP向服务端提交数据
HTTP 使用比 TCP/UDP 更多一些。
Qt中也提供了HTTP的客户端,HTTP协议本质上也就是基于TCP协议实现的,实现一个HTTP客户端/服务器,本质上就是基于TCP
socket 进行封装。 HTTP客户端:Qt只是提供了HTTP客户端,而没有提供HTTP服务器的库
4.1 核心API
关键类主要是三个 QNetworkAccessManager
, QNetworkRequest
, QNetworkReply
,QNetworkAccessManager
提供了HTTP的核心操作。
-
QNetworkAccessManager
提供了HTTP的核心操作
-
QNetworkRequest
表示一个HTTP请求(不含body)
如果需要发送一个带有
body
的请求(比如post
),会在QNetworkAccessManager
的post方法中通过单独的参数来传入body
。
QVariant
表示一个“类型可变”的值,类似于 C 语言中的void*
,泛型编程,虽然在C++中还是挺常见的,但是使用门槛还是比较高,里面有一些坑!
- 其中的
QNetworkRequest::KnownHeaders
是一个枚举类型,常用取值:
QNetworkReply
表示一个HTTP响应,这个类同时也是QIODevice
的子类
此外,QNetworkReply
还有一个重要的信号finished
会在客户端收到完整的响应数据之后触发!
4.2 代码示例
给服务器发送一个GET请求
此处显示的响应结果,大概率是一个HTML,
QPlainTextEdit
来进行表示,能够看到响应的原始模样!
QTextEidit
(天然支持对HTML的解析),会对HTML进行解析渲染,最终显示的效果就不是原始的HTML。QTextEdit
背后还做了很多工作,当得到的HTML比较大的时候,也会造成卡顿!
QNetworkReply* response = manager->get(request);
get本身不是阻塞函数,get只是负责发出去请求,不负责等待响应回来
finished 信号
代码:
#include "mainwindow.h"
#include "ui_mainwindow.h"
#include <QNetworkReply>
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
, ui(new Ui::MainWindow)
{
ui->setupUi(this);
this->setWindowTitle("客户端");
manager = new QNetworkAccessManager(this);
}
MainWindow::~MainWindow()
{
delete ui;
}
void MainWindow::on_pushButton_clicked()
{
// 1. 获取到输入框中的URL
QUrl url(ui->lineEdit->text());
// 2. 构造一个 HTTP 请求对象
QNetworkRequest request(url);
// 3. 发送请求
QNetworkReply* response = manager->get(request);
// 4. 通过信号槽,来处理响应
connect(response, &QNetworkReply::finished, this, [=](){
if (response->error() == QNetworkReply::NoError) {
// 响应正确获取到了
QString html = response->readAll();
ui->plainTextEdit->setPlainText(html);
} else {
// 响应出错了
ui->plainTextEdit->setPlainText(response->errorString());
}
// 还需要对 response 进行释放
response->deleteLater();
});
}
实际开发中,HTTP Client
获取到的数据,并不一定非得是HTML,更大的可能是客户端开发和服务器开发约定好交换的数据格式,按照约定的格式,客户端拿到之后,进行解析,并显示到界面上!
总结:
本文首先介绍了网络编程的基础知识,解释了网络编程中传输层的核心协议UDP和TCP,以及Qt框架如何通过提供相应的API简化了网络编程的过程。接着,文章详细讲解了UDP和TCP Socket编程,包括核心API的概述、如何编写带有界面的UDP回显服务器和客户端,以及如何实现TCP服务器和客户端的通信。此外,还介绍了Qt中的HTTP客户端编程,包括QNetworkAccessManager
、QNetworkRequest
和QNetworkReply
等关键类的作用和使用方式。
通过本文的学习,读者应该能够理解Qt网络编程的基本概念,掌握使用Qt进行UDP、TCP以及HTTP通信的方法,并能够根据实际需求编写网络应用程序。Qt的模块化设计和信号槽机制大大简化了网络编程的复杂性,使得开发者可以更加专注于业务逻辑的实现。最后,希望读者能够将本文的知识应用到实际项目中,提升网络编程的能力和效率。