QT网盘项目-DAY2-协议设计

本文未经授权,禁止转载

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是不现实的,想要源码的话,就自己去历史提交里找吧。下面这两次提交。

NetdiskProject: QT网盘项目的实现。 (gitee.com)

  • 21
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值