本文未经授权,禁止转载
Day1回顾
在Day1中,我们在服务器与客户端分别利用QT内置的MyTcpServer类和MyTcpSocket类,完成了服务器与客户端之间的连接。
连接的目的是为了双方能够接收和发送消息,那么接下来我们需要完成服务器与客户端之间的收发信息的功能。
三、消息协议设计
3.1 TCP的粘包问题
我们知道,TCP协议是面向字节流的协议。
当用户消息通过 TCP 协议传输时,消息可能会被操作系统分组成多个的 TCP 报文,也就是一个完整的用户消息被拆分成多个 TCP 报文进行传输。这时,接收方的程序如果不知道发送方发送的消息的长度,也就是不知道消息的边界时,是无法读出一个有效的用户消息的(会出现粘包问题),因为用户消息被拆分成多个 TCP 报文后,并不能像 UDP 那样,一个 UDP 报文就能代表一个完整的用户消息。
3.1.1 如何解决粘包问题(自定义消息结构体)
粘包的问题出现是因为不知道一个用户消息的边界在哪,如果知道了边界在哪,接收方就可以通过边界来划分出有效的用户消息。
一般有三种方式分包的方式:
固定长度的消息;
特殊字符作为边界; (\r\ndasda消息sasdasdasd\r\n)
自定义消息结构体。
1、固定长度的消息;
这种是最简单方法,即每个用户消息都是固定长度的,比如规定一个消息的长度是 64个字节,当接收方接满 64 个字节,就认为这个内容是一个完整目有效的消息。但是这种方式灵活性不高,实际中很少用。
2、特殊字符作为边界;
我们可以在两个用户消息之间插入一个特殊的字符串,这样接收方在接收数据时,读到了这个特殊字符,就把认为已经读完一个完整的消息。
3、自定义消息结构体。(我们使用这种方式来解决粘包问题)
我们可以自定义一个消息结构,由包头和数据组成,其中包头包是固定大小的,而且包头里有一个字段来说明紧随其后的数据有多大。比如这个消息结构体,首先4个字节大小的变量来表示数据长度,真正的数据则在后面。
而我们是不确定,要发送的消息有多长的。那我们该将存储实际消息的容器大小定义为多大呢?10?100?1000?还是10000?好像都不太准确。(定义的太大,浪费网络资源,定义太小,消息会丢失)
那有没有一种可以随时改变的长度的容器呢?这里就涉及到另一个知识点(柔性数组:变长数组)
3.1.2 柔性数组
1、什么是柔性数组
柔性数组也称为变长数组,是一种动态数组的实现方式。
与普通数组不同的是,柔性数组在定义时不需要明确指定数组大小,在程序运行时可以动态地分配和扩展数组大小。
柔性数组是通过C99标准中提供的“结构体成员为未知长度的数组”的特性来实现的,它需要一个结构体来作为数组的容器,并且在结构体定义中,最后一个数组成员不指定长度,例如:
struct A
{
int a;
int b;
int arr[];//未知大小的数组 - 柔性数组成员
};
2、柔性数组的特点
- 结构中的柔性数组成员前面必须至少一个其他成员。
- sizeof 返回的这种结构大小不包括柔性数组的内存。
- 包含柔性数组成员的结构用malloc ()函数进行内存的动态分配,并且分配的内存应该大于结构的大小,以适应柔性数组的预期大小。
例如我们把上诉结构体的大小计算一下。
struct A
{
char a;
int b;
int arr[];//未知大小的数组 - 柔性数组成员
};
int main()
{
printf("%d\n", sizeof(struct A));
return 0;
}
得到的结构体大小为8,柔性数组目前没有计算大小。
3.2 消息协议设计
3.2.1 消息协议设计(自定义消息结构体)
为了解决TCP协议带来的粘包问题,我们使用了自定义消息结构体来解决这个问题,在消息结构体中,包含整个结构体的总长度(包含柔性数组的大小)和柔性数组。
除了结构体的总长度,还应该给出 柔性数组的长度(也就是实际消息的长度)
通过柔性数组,我们可以在发送消息时,就可以利用柔性数组来动态改变消息的大小。
那么, 消息结构体中,除了结构体的大小(包含柔性数组的大小)和柔性数组,还应该包含什么信息呢?
我们得想想,客户端为什么要给服务器发送消息?
客户端为了实现某种请求才会发送消息,比如说登录或者注册。因此,自定义的消息结构体中,除了结构体的大小(包含柔性数组的大小)和柔性数组,应该还包含消息的类型。否则,服务器收到了客户端的消息,服务器都不知道要干啥,也就没法处理消息了。例如:客户端想要进行登录操作 ,客户端向服务器发送账号和密码,再提供消息类型为登录,这样服务器收到消息之后就知道你是要进行登录操作了。
这样,貌似就完成了一个自定义的消息结构体,但是,如果我们每次发送的消息很少的话,利用柔性数组申请空间似乎是比较浪费的,因此,除了柔性数组,我们还可以提前准备一个固定长度的数组(来存放少量的数据)
因此,该消息协议(自定义消息结构体)中包含的内容如下:
- 消息结构体的总长度
- 消息的类型
- 实际的消息长度
- 一个固定长度的数组(来解决发送少量的数据的情况)
- 柔性数组
因此,需要我们在 客户端:NetdiskClient和服务器:NetdiskServer项目下添加一个头文件protocol.h和protocol.cpp文件
并在头文件中添加一个 协议数据单元- Protocol Data Unit 的结构体 PDU
protocol.h
// 重命名 unsigned int
typedef unsigned int uint;
// 协议数据单元- Protocol Data Unit
struct PDU
{
uint uiPDULen; // 协议的总长度
uint uiMsgType; // 消息的类型
uint uiMsgLen; // 实际的消息长度
char caData[64];// 参数
char caMsg[]; // 柔性数组,用来存放实际的消息
};
而消息的类型,可以创建一个枚举值,利用枚举值来进行赋值,方便后续的调用。
// 消息类型的枚举值
enum ENUM_MSG_TYPE
{
ENUM_MSG_TYPE_MIN=0, // 消息类型的最小值
// 每一种消息类型一般都有两种,一个用来进行请求,一个接收响应。
ENUM_MSG_TYPE_REGIST_REQUEST, // 注册用户的请求
ENUM_MSG_TYPE_REGIST_RESPOND, // 注册用户的响应
ENUM_MSG_TYPE_MAX=0x00ffffff, // 消息类型的最大值
};
这里我们以注册为例,每个消息类型应该有两种,一种是注册用户的请求,一种是注册用户的响应(一般有请求就得有响应)
3.2.2 初始化消息协议的函数
为了方便后续创建消息的结构体,需要实现一个函数,用来初始化自定义的消息结构体。
// PDU初始化函数
PDU* initPDU(uint uiMsgLen)
{
// 计算消息结构体的总长度
uint uiPDULen = sizeof (PDU)+uiMsgLen;
// 给消息结构体申请堆区内存
PDU* pdu = (PDU*)malloc(uiPDULen);
// 未申请到空间,退出
if(pdu == NULL)
{
exit(1);
}
// 初始化堆区内容,防止有残留数据
memset(pdu,0,uiPDULen);
// 给成员变量赋值
pdu->uiPDULen = uiPDULen;
pdu->uiMsgLen = uiMsgLen;
// 返回消息结构体指针
return pdu;
}
在初始化消息结构体时,需要你知道柔性数组的大小(因此需要传入实际消息长度)。
在初始化消息结构体的函数中,计算了消息结构体的总长度 = 结构体长度(不算柔性数组)+实际消息的长度;
根据 计算的 消息结构体的总长度 来动态的申请堆区空间,并将申请的堆区空间进行了赋值操作(全部变为0,防止存在垃圾数据,影响消息发送)。
将消息结构体中的 总长度以及实际消息长度 赋值后,返回完成初始化消息结构体的指针。
protocol.h
#ifndef PROTOCOL_H
#define PROTOCOL_H
// 重命名 unsigned int
typedef unsigned int uint;
// 消息类型的枚举值
enum ENUM_MSG_TYPE
{
ENUM_MSG_TYPE_MIN=0, // 消息类型的最小值
// 每一种消息类型一般都有两种,一个用来进行请求,一个接收响应。
ENUM_MSG_TYPE_REGIST_REQUEST, // 注册用户的请求
ENUM_MSG_TYPE_REGIST_RESPOND, // 注册用户的响应
ENUM_MSG_TYPE_MAX=0x00ffffff, // 消息类型的最大值
};
// 协议数据单元- Protocol Data Unit
struct PDU
{
uint uiPDULen; // 协议的总长度
uint uiMsgType; // 消息的类型
uint uiMsgLen; // 实际的消息长度
char caData[64];// 参数
char caMsg[]; // 柔性数组,用来存放实际的消息
};
#endif // PROTOCOL_H
protocol.cpp
#include "protocol.h"
#include <stdlib.h>
#include <string.h>
// PDU初始化函数
PDU* initPDU(uint uiMsgLen)
{
// 计算协议总长度
uint uiPDULen = sizeof (PDU)+uiMsgLen;
// 申请堆区内存
PDU* pdu = (PDU*)malloc(uiPDULen);
// 未申请到空间,退出
if(pdu == NULL)
{
exit(1);
}
// 初始化堆区内容,防止有残留数据
memset(pdu,0,uiPDULen);
// 给成员变量赋值
pdu->uiPDULen = uiPDULen;
pdu->uiMsgLen = uiMsgLen;
// 返回指针
return pdu;
}
四、客户端发送消息
4.1 思路分析
根据上面的内容,我们完成了自定义消息结构体的设计。
不要忘了我们本来的目标 :进行通信,发送消息。
连接的目的是为了双方能够接收和发送消息,那么接下来我们需要完成服务器与客户端之间的收发信息的功能。
那我们需要考虑一下,发送消息需要什么?
(1)需要一个输入框,来输入消息。
(2)还需要一个发送的按钮,点击这个按钮,就可以发送消息到服务器。
那发送消息的流程是什么?
(1)用户在输入框输入消息,点击发送按钮
(2)点击按钮,触发点击的槽函数
(3)槽函数内,将输入框内的消息取出来
(4)计算输入的消息长度(如果从输入框读到的消息为空,则直接返回)
(5)根据读到的消息长度,初始化一个PDU对象,并且决定,该消息是存储在固定的数组(caData)还是柔性数组中(caMsg)。(这里我们存放在柔性数组中)
(6)给出消息类型(这里我们暂时使用 ENUM_MSG_TYPE_REGIST_REQUEST, // 注册用户的请求)
(7)利用我们创建的成员变量m_tcpSocket,调用write函数,将赋值完成的 PDU对象(自定义消息结构体)发送给客户端。
(8)释放PDU对象。
4.2 UI设计
1、客户端点击 ui 界面
2、在搜索框搜索 Line Edit (输入框),并将其添加到界面中
3、添加一个按钮 PushButton,将其拖动到 窗口中。()
为了界面美观,还可以改变一下字体大小,和控件之间的布局。
4、右键发送按钮 ---> 转到槽 ---> 选择点击信号(clicked())
点击之后,就会自动在client.cpp 中生成一个 槽函数。
4.3 客户端发送消息
通过UI设计,我们已经创建了一个输入框和发送按钮,并且已经创建了发送按钮的槽函数,点击发送按钮,就会触发这个函数,因此,我们在该函数内实现发送消息的功能即可。
那发送消息的流程是什么?
(1)用户在输入框输入消息,点击发送按钮
(2)点击按钮,触发点击的槽函数
(3)槽函数内,将输入框内的消息取出来
(4)计算输入的消息长度(如果从输入框读到的消息为空,则直接返回)
(5)根据读到的消息长度,初始化一个PDU对象,并且决定,该消息是存储在固定的数组(caData)还是柔性数组中(caMsg)。(这里我们存放在柔性数组中)
(6)给出消息类型(这里我们暂时使用 ENUM_MSG_TYPE_REGIST_REQUEST, // 注册用户的请求)
(7)利用我们创建的成员变量m_tcpSocket,调用write函数,将赋值完成的 PDU对象(自定义消息结构体)发送给客户端。
(8)释放PDU对象。
4.3.1 取出输入框中的数据
我们知道,要发送的消息在输入框中,那么该如何取出输入框中的数据?
通过输入框的对象,调用.text() 函数即可取出输入框中的内容,但是,输入框的对象在那里获取呢?
观察 client.h ,可以发现,有一个多余的成员变量 Ui::Client *ui; 还记得我们添加输入框和发送按钮就是在 UI界面进行添加的,因此,输入框的对象和发送按钮的对象就在这个 ui对象中。
// 读取 lineEdit 中的内容
QString strMsg = ui->lineEdit->text();
这样,我们就读取到了输入框中的数据。
是否存在一种可能,输入框中没写数据,但是仍然点击了发送按钮,导致读到是数据为空值,这样的消息是没有意义的。因此,在读到空消息时,应该直接结束这个槽函数。
// 如果当前消息为空,直接返回
if(strMsg.isEmpty())
{
return;
}
4.3.2 计算消息的长度,并初始化一个 PDU对象
上面,我们已经取出了输入框中的数据,接下来,我们应该将数据放到自定义的消息结构体中,因此需要初始化一个PDU的对象(不要忘了,初始化PDU对象时,需要传入实际消息的长度),因此在创建PDU对象前,还需要计算消息的实际长度。
1、计算消息的实际长度
// 读到消息,获取消息的长度,得先将消息转换为标准的字符串,以便求出长度
// 不转的话,中文是没法读全的(一个汉字占好几个字符)
uint uiMsgLen = strMsg.toStdString().size();
这里,我们调用了一个 toStdString() 函数,其目的是将输入的内容转换为标准的字符串,如果不转换的话,在计算中文时,就会出错(一个中文是占好几个字节的,不转换为标准字符串的话,计算长度时,一个中文字符就只占一个字节,就会导致柔性数组的大小不够)
2、初始化一个PDU对象
上面我们已经完成了初始化PDU对象的函数 initPDU(),直接调用这个函数即可。
// 初始化PDU,传入的消息长度加一,多加一个消息结束标志
PDU* pdu = initPDU(uiMsgLen+1);
这里,我们传入的消息大小为uiMsgLen+1,目的是给消息的字符串预留一个结束的标志(我们知道字符串的结尾是有一个'\0'的结束标志的)
4.3.3 传入消息类型和实际消息
1、传入PDU对象的消息类型
给出消息类型(这里我们暂时使用 ENUM_MSG_TYPE_REGIST_REQUEST, // 注册用户的请求)
// 给定消息类型,这里随便给一个就行
pdu->uiMsgType = ENUM_MSG_TYPE_REGIST_REQUEST;
2、将要发送的消息,放到柔性数组中
这里只是用来测试消息是否能够发送,因此,顺便测试柔性数组是否能存储数据,因此我们将数据存储在柔性数组中。
利用memcpy将 从输入框中读取的数据 拷贝到 柔性数组中。
因为memcpy中的前两个参数类型为 char*,因此需要将数据类型转为char*。
第三个参数为,要拷贝的数据长度。
// 利用memcpy函数,将从输入框中读取的消息,存放在PDU中的柔性数组中。
// 这里需要按照 memcpy的参数形式,将前两个参数的类型修改为 char*
memcpy((char*)pdu->caMsg,strMsg.toStdString().c_str(),uiMsgLen);
这样我们完成了消息结构体的内容。
3、添加测试,打印输出一下消息结构体中的消息
// 测试。查看消息长度,和消息内容是否正确
qDebug()<<"uiMsgLen: "<<pdu->uiMsgLen;
qDebug()<<"caMsg: "<<pdu->caMsg;
4.3.4 利用socket向客户端发送消息
我们在Day1中,进行客户端与服务器连接时,我们创建了一个成员变量m_tcpSocket,利用这个socket,调用write(),即可向服务器发送 构建完成的消息结构体。
write函数的第一个参数是发送的消息(参数类型是char*),因此需要将其转换数据类型
第二个参数是PDU对象的总长度(包含柔性数组
// 利用socket发送消息。
m_tcpSocket.write((char*)pdu,pdu->uiPDULen);
这样就完成了客户端向服务器发送消息的测试。
最后,我们还需要释放掉pdu这个对象。避免浪费内存空间。
// 完成消息发送,释放pdu
free(pdu);
// 将pdu置为空
pdu = NULL;
4.3.5 客户端发送消息测试
上面我们完成了客户端向服务器发送消息的代码。
注意,现在服务器虽然能收到客户端发送的消息,但是,因为服务器还没写接收消息的函数。
我们只能在客户端利用打印,查看消息是否正确的存储在消息结构体中。
可以正常存储。
五、服务器接收消息
5.1 思路分析
上面我们完成了客户端发送消息的代码,服务器的socket中已经接收到了客户端发送过来的消息。因此我们需要写一个接收消息的函数(recvMsg),用来接收socket中的数据。
那我们需要考虑一下,服务器怎么知道客户端发来了消息,该怎么接收?
(1)在socket中,存在一个readyRead信号,一旦服务器的socket接收到了客户端发来的消息,就会触发这个信号,因此只需要将这个信号函数与接收消息的槽函数(recvMsg)进行关联,一旦有客户端向服务器发送了消息,就会触发readyRead信号,然后信号槽就会调用接收消息的槽函数,对socket中的数据进行处理。
那处理消息的槽函数应该放在那个文件中呢?
应该放到MyTcpSocket类中,原因是,在真实的场景中,会有很多的客户端和服务器进行连接,而MyTcpSocket类是继承与QTcpSocket,是用来创建socket对象的,因此有关socket的操作都是在这个类中的,而接收消息的槽函数,本质是就是对socke中的数据进行读取,因此,接收消息的槽函数(recvMsg) 应该放到MyTcpSocket类。
(2)因此还需要定义一个接收消息的槽函数(recvMsg)
那接收消息的流程是什么?
(1)MyTcpSocket添加槽函数recvMsg
(2)MyTcpSocket构造函数中利用connect连接readyRead 信号和 recvMsg 槽函数
(3)下面是 接收消息的槽函数(recvMsg)中的内容
(4)打印出 socket 中接收到的数据长度 this->bytesAvailable()
(5)先读出协议总长度
(6)构建 pdu,参数为实际消息长度(协议总长度-PDU 结构体长度)
(7)再读出 socket 中除了协议总长度以外的其他成员
(8)打印出 pdu 中的消息类型、参数、实际消息
(9)释放 pdu
5.2 服务器接收消息
5.2.1 添加接收消息的槽函数,并进行关联
1、添加接收消息的槽函数
在mytcpsocket.h中添加recvMsg()函数的声明,在 在mytcpsocket.cpp中添加recvMsg()函数的实现即可。
2、在mytcpsocket.cpp中 MyTcpSocket类的构造函数中,利用connec进行关联
MyTcpSocket::MyTcpSocket()
{
// 将socket中,接收到信息触发的信号,与取出信息的信号槽函数进行关联
connect(this,&MyTcpSocket::readyRead,this,&MyTcpSocket::recvMsg);
}
5.2.2 打印socket中收到数据的总长度
利用bytesAvailable()函数,即可获得当前socket中可获取的数据字节个数。
因为我们是在MyTcpSocket类中,this指针就是当前socket对象,因此直接使用this->bytesAvailable()即可。
// 打印socket中的数据长度
qDebug()<<"socket中接收到的数据长度:"<<this->bytesAvailable();
5.2.3 读出消息结构体的总长度
这里有两个地方需要注意:
(1)是读出而不是读取
(2)是消息结构体的总长度(包含柔性数组的长度),而不是结构体的长度(不包含柔性数组)。
1、读出和读取的区别
- 读出是将数据从socket中读出来,读出一点,socket中的数据就会减少一点,读完,socket中就没有内容了。
- 而读取只是,读一下socket中有什么,读完之后socket中的内容并不会改变
例如,上面是socket中存储的消息结构体的数据,如果我们将socket中结构体的总长度读出的话,socket中就没有uiPDULen的数据了。socket中的数据就会变成下面的样子。
2、如何从socket中读出结构体的总长度(uiPDULen)
这里就体现出了提前设计好自定义消息结构体的优点,服务器现在是不知道客户端发来的消息结构体有多长的,因此,服务器是不知道他应该从socket中读取多少个字节的数据的(读多或读少了,都会导致消息错误)。
因此我们在设计自定义的消息结构体时,将消息结构体的总长度放在了第一个成员变量,这样就可以保证,我们读到的前4个字节的数据就是这条消息结构体的总长度,有了总长度,就不会出现读多或者读少的问题。
有了消息结构体的总长度,就可以计算实际消息的长度(实际消息的长度 = 消息结构体的总长度 - 结构体的长度(不包含柔性数组)),就可以利用initPDU()函数初始化一个PDU对象,将socket中的数据存放到这个对象中即可。
所以我们只需要定义一个局部变量来接收消息结构体的总长度,先从socket中读取前4个字节就得到了消息结构体的总长度。
// 读出消息结构体的总长度,这部分内容会从socket中读出去
uint uiPDULen = 0; // 传出参数
// 读取的大小为 消息结构体总长度的数据类型大小
this->read((char*)&uiPDULen,sizeof(uint));
5.2.4 计算实际消息的大小,并初始化PDU对象
上面我们得到了消息结构体的大小,就可以计算实际消息的大小了。
1、计算实际消息的大小
实际消息的长度(实际消息的长度 = 消息结构体的总长度 - 结构体的长度(不包含柔性数组))
// 计算实际消息长度--柔性数组长度(消息结构体总长度-结构体大小)
uint uiMsgLen = uiPDULen-sizeof(PDU);
2、初始化一个PDU对象
有了实际消息的大小,就可以初始化一个PDU对象。
// 初始化一个PUD结构体,用于存储
PDU* pdu = initPDU(uiMsgLen);
5.2.5 将剩余的数据读出到PDU对象中
通过上面的操作,我们已经构建了一个PDU的对象,下面只需要把剩余的内容拷贝到PDU对象中即可。
注意:前面说了,是读出,不是读取,现在socket中的数据不是一个完整的PDU对象,因为uiPDULen已经被读出去了。因此需要将PDU对象的指针进行偏移(向后偏移4个字节),相应的,读取长度也应该减少4个字节。如图就是socket中剩余的数据内容。
// 读出socket中剩余的数据,消息结构体的总长度已经被读出去了,剩下的不是一个完整的PDU结构体。
// 因此需要对pdu这个指针进行偏移,偏移的长度就是已经读出的数据长度(消息结构体总长度的数据类型大小)
// 而剩余需要读出的内容为,消息结构体总长度 减去 消息结构体总长度的数据类型大小
this->read((char*)pdu+sizeof(uint),uiPDULen-sizeof(uint));
到这里,服务器就将客户端发送来的数据从socket中读取到了PDU对象中。
5.2.6 打印消息,释放PDU
为了检验读取客户端的内容是否正确,还需要打印输出进行检查,最后再将PDU对象释放,节省内存空间(避免内存泄漏)。
// 测试--打印输出消息结构体的内容
qDebug()<<"结构体总长度:"<<pdu->uiPDULen;
qDebug()<<"消息类型:"<<pdu->uiMsgType;
qDebug()<<"消息长度:"<<pdu->uiMsgLen;
qDebug()<<"参数1:"<<pdu->caData;
qDebug()<<"参数2:"<<pdu->caData+32;
qDebug()<<"接收到的消息:"<<pdu->caMsg;
// 释放pdu
free(pdu);
pdu=NULL;
服务器接收消息测试
Day2总结
- 通过自定义消息结构体,我们解决了TCP的粘包问题
- 在客户端,通过添加输入框和发送按钮,以及发送消息的槽函数,完成了客户端向服务器发送消息的功能测试
- 在服务器,通过readyRead 信号和 recvMsg 槽函数,完成了接收客户端消息的功能测试。
注意:
(1)客户端也应该实现一个接收服务器消息的功能(用来接收服务器发送给客户端的响应)
(2)Day2实现的客户端与服务器的发送接收,只是用作发送接收功能的测试,目的是为了验证客户端与服务器的收发数据能力是否正常,以及验证协议设计是否正确。
完整代码
代码量增加,把所有代码复制粘贴到CSDN是不现实的,想要源码的话,就自己去历史提交里找吧。下面这两次提交。