目录
1. 网络概述
2. UDP Socket
2.1 核心 API 概览
2.2 写一个带有界面的 Udp 回显服务器
3. TCP Socket
3.1 核心 API 概览
3.2 TCP 回显服务器
4. HTTP Client
4.1 核心 API
4.2 客户端代码编写
1. 网络概述
进行网络编程的时候,本质上是在编写 应用层代码,需要传输层提供支持。
传输层最核心的协议,有UDP 和 TCP ,并且这两协议,差别还很大,Qt 也就提供了两套 API。使用 Qt 网络编程的 API ,需要先在 .pro 文件中添加 network 模块!之前学过的 Qt 控件,各种内容,都是包含在 QtCore 模块中的,默认就添加的。
模块化处理:其它的功能分别封装成不同的模块。默认情况下这些额外的模块不会参与编译,需要在 .pro 文件中,引入对应的模块,才能把对应功能给编译加载进来。
Qt 其实提供了 静态库和动态库 的版本
2. UDP Socket
2.1 核心 API 概览
主要的类有两个. QUdpSocket 和 QNetworkDatagram
QUdpSocket 表示⼀个 UDP 的 socket 文件
| 名称 | 类型 | 说明 | 对标原⽣ API |
| bind(const QHostAddress&, quint16) | 方法 | 绑定指定的端口号 | bind |
| receiveDatagram() | 方法 | 返回 QNetworkDatagram . 读取⼀个 UDP 数据报. | recvfrom(阻塞IO) |
| writeDatagram(const QNetworkDatagram&) | 方法 | 发送⼀个 UDP 数据报. | sendto(阻塞IO) |
| readyRead | 信号 | 在收到数据并准备就绪后触发. | 无 (类似于 IO 多路复⽤的通知机制) |
readyRead:当 socket 收到请求的时候,QUdpSocket 就会触发这个信号,此时就可以在槽函数里完成读取请求的操作。
基于信号槽,就天然达成了 “事件驱动” 这样的一种网络编程的方式
QNetworkDatagram 表示⼀个 UDP 数据报.
| 名称 | 类型 | 说明 | 对标原⽣ API |
| QNetworkDatagram(const QByteArray&, const QHostAddress& , quint16 ) | 构造函 数 | 通过 QByteArray , ⽬标 IP 地址,⽬标端⼝号 构造⼀个 UDP 数据报.通常⽤于发送数据时. | 无 |
| data() | 方法 | 获取数据报内部持有的数据. 返回QByteArray | 无 |
| senderAddress() | 方法 | 获取数据报中包含的对端的 IP 地址. | 无,recvfrom 包含了该功 能. |
| senderPort() | 方法 | 获取数据报中包含的对端的端⼝号. | 无, recvfrom 包含了该功 能. |
2.2 写一个带有界面的 Udp 回显服务器
服务端:
// Widget.cpp #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->setWindowTitle("服务器"); // 连接信号槽, connect(socket, &QUdpSocket::readyRead, this, &Widget::processRequest); // 先连接信号槽,再绑定端口号,一旦绑定端口,意味着请求就可以被收到了 // 如果在完成绑定之后,在连接信号槽之前,有客户端把请求发过来了,此时就可能读不到这样的请求了(请求就没了) // 绑定端口号,一个端口号只能被一个 socket 绑定,有可能会绑定失败 bool ret = socket->bind(QHostAddress::Any, 8888); if(!ret){ // 绑定失败!socket->errorString() 本质是对系统的 errno 机制进行封装,相当于 Linux 中用过的 perror QMessageBox::critical(this,"服务器启动出错",socket->errorString()); 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. 把响应写回给客户端,response.toUtf8()取出 QString 内部的字节数 // 客户端是谁?--- 就包含在 requestDatagram 中了 QNetworkDatagram responseDategram(response.toUtf8(),requestDatagram.senderAddress(),requestDatagram.senderPort()); socket->writeDatagram(responseDategram); // 把这次交互的信息,显示到界面上 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; }客户端:
Qt Creator 中是可以同时打开多个项目的,如果,这两项目中存在同名的文件,就非常容易混淆
同样的需要加上 network:
此时写的客户端,要能够主动给服务器发起请求
大概的布局:
// widget.cpp #include "widget.h" #include "ui_widget.h" #include <QNetworkDatagram> // 定义两个常量,描述服务器的 地址 和 端口 // 端口号本质是一个 2 字节的 无符号 整数,quint16 本质上就是一个 unsigned short // 虽然short 通常是2个字节的,但是 C++ 标准中没有明确规定这一点,只是说 short 不应该少于2个字节 const QString& SERVER_IP = "127.0.0.1"; const quint16 SERVER_PORT = 8888; 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->lineEdit->text(); // 2. 构造 UDP 的请求数据 QNetworkDatagram requestDatagram(text.toUtf8(),QHostAddress(SERVER_IP),SERVER_PORT); // 3. 发送请求数据 socket->writeDatagram(requestDatagram); // 4. 把发送的请求也添加到列表框中 ui->listWidget->addItem("客户端说: " + text); // 5. 把输入框的内容也清空一下 ui->lineEdit->setText(""); } void Widget::processResponse() { // 通过这个函数来处理收到的响应 // 1. 读取到响应数据 const QNetworkDatagram& responseDatagram = socket->receiveDatagram(); QString response =responseDatagram.data(); // 2. 把响应数据显示到界面上 ui->listWidget->addItem("服务器说: " + response); }客户端服务器程序测试时候的基本的原则,一定是先启动服务器,后启动客户端。
运行:
同时启动多个客户端:
双击,进入下面的目录:
进入Debug目录:
多次点击exe程序,即可启动多个客户端
问题1:能否把现在的UDP服务器放到云服务器上呢?
大概率是不行的,取决于服务器是否安装了图像化界面,Qt 程序,需要依赖图形化界面来运行的
问题2:能否拿现在的 Udp 客户端连之前的 Linux 阶段写的 Udp 服务器呢?
这个是完全可以的,这也是 网络编程/协议 的意义,一般商业公司的项目,都是通过其它方式编写的服务器程序(大概率不会是 Qt)但是使用 Qt 编写客户端。
3. TCP Socket
3.1 核心 API 概览
核心类是两个: QTcpServer 和 QTcpSocket
QTcpServer ⽤于监听端⼝, 和获取客⼾端连接
| 名称 | 类型 | 说明 | 对标原⽣ API |
| listen(const QHostAddress&, quint16 port) | 方法 | 绑定指定的地址和端⼝号, 并开始监听. | bind 和 listen |
| nextPendingConnection() | 方法 | 从系统中获取到⼀个已经建⽴好的tcp 连接. 返回⼀个 QTcpSocket , 表示这个客户端的连接. 通过这个 socket 对象完成和客户端之间的通信. | accept |
| newConnection | 信号 | 有新的客户端建⽴连接好之后触发. | 无(但是类似于 IO 多路复⽤中的通知机制) |
QTcpSocket 用户客户端和服务器之间的数据交互.
| 名称 | 类型 | 说明 | 对标原生 API |
| readAll() | 方法 | 读取当前接收缓冲区中的所有数据. 返回 QByteArray 对象. | read |
| write(const QByteArray& ) | 方法 | 把数据写入 socket 中. | write |
| deleteLater | 方法 | 暂时把 socket 对象标记为⽆效. Qt会在下个事件循环中析构释放该对象 | 无(但是类似于 "半⾃动化的垃圾回收") |
| readyRead | 信号 | 有数据到达并准备就绪时触发. | 无(但是类似于 IO 多路复⽤ 中的通知机制) |
| disconnected | 信号 | 连接断开时触发. | 无(但是类似于 IO 多路复⽤ 中的通知机制) |
3.2 TCP 回显服务器
服务器:
客户端:
服务端:
// widget.cpp #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, 8888); 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); // 上述代码其实不够严谨,作为回显服务器是已经够了的 // 实际使用 TCP 的过程中,TCP 是面向字节流的,一个完整的请求,可能会分成多段字节数组进行传输 // 虽然 TCP 已经帮我们处理了很多几首的问题了,但是 TCP 本身不负责区分,从哪里到哪里是一个完整的应用层数据报(粘包问题) // 更严谨的做法是,每次收到的数据都是给放到一个大的字节数组缓冲区中,并且提前约定好应用层协议的格式(分割符,长度,其他办法) // 再按照协议格式对缓冲区数据进行更细致的解析处理 }); // 3. 通过信号槽,来处理客户端断开连接的情况 connect(clientSocket, &QTcpSocket::disconnected,this,[=](){ // a) 把断开连接的信息通过日志显示出来 QString log = "[" + clientSocket->peerAddress().toString() + ":" + QString::number(clientSocket->peerPort()) + "] 客户端下线!"; ui->listWidget->addItem(log); // b) 手动释放 clientSocket,每个客户端都有一个这样的对象,存在N个,随着服务器的运行,客户端越来越多,如果不释放,此时累计的 clientSocket 也会越来越多 // 内存泄漏!!!+ 文件描述符泄露!!! // 直接使用 delete 是下策,使用 deleteLator 是更加合适的 // delete clientSocket; clientSocket->deleteLater(); // 这个操作不是立即销毁 clientSocket,而是告诉 Qt ,下一轮事件循环中,再进行上述的销毁操作 // 槽函数都是在事件循环中执行的,进入到下一轮事件循环,意味着上一轮时间循环肯定结束了,也意味着当前的槽函数肯定是执行结束了 }); } // 此处写的回显服务器,请求和响应是一致的 QString Widget::process(const QString request) { return request; }客户端:
//widget.cpp #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",8888); // 调用这个函数,此时系统内核就会和对方的服务器之间进行三次握手了 // 三次握手也是需要消耗一定的时间的 // 此处的这个函数不会阻塞等待三次握手完毕,属于非阻塞的函数 // 原生的 Linux api,也有一个connect函数,一般来说一个socket某人都是阻塞IO通信的, // 此时针对这样的 socket 进行 connect 也就是阻塞的了 // 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(""); }启动多个客户端:
之前在Linux 时写的TCP 的回显服务器的时候,遇到了一个问题,多个客户端同时访问的时候,就只有一个生效,后来引入了多线程,每个客户端安排一个单独的线程,问题才得到改善。
之前存在这个问题的本质原因,是写双重循环,里层循环没有及时结束,导致外层循环不能快速的第二次调用到accept,导致第二个客户端无法进行处理了。
引入多线程,本质上就是吧双重循环,化简成两个独立的循环。
Qt 的服务器程序中,其实一个循环都没写,是通过 Qt 内置的 信号槽 来驱动的。信号槽机制很好的化简了我们的程序。
一个正经的 UDP/TCP 服务器,不太可能会用 Qt 来写,服务器一般都是不需要图形化界面的。
4. HTTP Client
进⾏ Qt 开发时, 和服务器之间的通信很多时候也会⽤到 HTTP 协议.
- 通过 HTTP 从服务器获取数据.
- 通过 HTTP 向服务器提交数据.
Qt 中也提供了HTTP的客户端,HTTP协议本质上就是基于 TCP 协议实现的,实现一个 HTTP客户端/服务器,本质也就是基于 TCP socket 进行封装,Qt 只是提供了 HTTP 客户端,而没有提供 HTTP 服务器的库。
4.1 核心 API
关键类主要是三个. QNetworkAccessManager , QNetworkRequest , QNetworkReply .
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 表示一个 “类型可变”的值,类似于C语言中的 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 的值. |
此外, QNetworkReply 还有⼀个重要的信号 finished 会在客户端收到完整的响应数据之后触发.
4.2 客户端代码编写
此处显示的响应结果,大概率是一个 HTML,QPlainTextEdit 来进行表示,能够看到响应的原始模样。
QTextEdit 天然支持对 HTML 的解析的,会对 HTML 进行解析渲染,最终显示效果,就不是原始的HTML,QTextEdit 背后还做了很多工作,当得到的 HTML 比较大的时候,是会造成卡顿的
运行:
实际开发中,HTTPClient 获取到的数据,也不一定非得是 HTML,更大的可能性是客户端开发和服务器开发约定好的交互数据格式,按照约定的格式,客户端拿到之后,进行解析,并显示到界面上。