前言:

在当今信息化社会,网络编程已成为软件开发中不可或缺的一部分。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 概述

核心类是:QTcpServerQTcpSocket

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, QNetworkReplyQNetworkAccessManager 提供了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客户端编程,包括QNetworkAccessManagerQNetworkRequestQNetworkReply等关键类的作用和使用方式。

通过本文的学习,读者应该能够理解Qt网络编程的基本概念,掌握使用Qt进行UDP、TCP以及HTTP通信的方法,并能够根据实际需求编写网络应用程序。Qt的模块化设计和信号槽机制大大简化了网络编程的复杂性,使得开发者可以更加专注于业务逻辑的实现。最后,希望读者能够将本文的知识应用到实际项目中,提升网络编程的能力和效率。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部