坐拥IT高薪职位必备底层知识系列——socket(套接字)专题,网络编程必读

【名词解释】

Internet  因特网。专指从ARPANET发展而来的连接全球各广域网的唯一网络。

Internet Protocol  网际协议。

IPv4  网际协议第4版。

IPv6  网际协议第6版。

TCP/IP  传输控制协议/网际协议。

因特网是互联网的一种(最大的互联网),但互联网并不等同于因特网。

 

 

【套接字相关的基础知识】

 

》》》》》》》》》》》》》》》》》》》》

套接字:一种抽象概念或者框架,各操作系统与编程语言对其有不同的实现。

 

套接字接口:本地(宿主)操作系统提供给客户端以及服务器应用程序进程访问本地的套接字数据结构(等价于和底层操作系统通信)时必须使用的指令集。通常以套接字接口函数的形式存在,供应用程序开发者调用。

套接字接口是网络通信 API 的一种;网络通信 API 的实现有很多种,例如:套接字接口,传输层接口(TLI),STREAM等。

必须指出:在 TCP/IP 协议簇分层中,分组(数据报)或由应用程序产生的字节流数据,在同一个主机的应用层与传输层之间不能直接传递,而是需要传输环境(操作系统)提供的传输对象,载体(套接字数据结构)与传输手段(套接字接口)。

例如,开发人员想开发一个运行在某操作系统环境中,具备网络通信功能的应用程序,则必须使用该操作系统提供的网络通信API函数(例如套接字接口函数,其最初在1980年代由加州大学伯克利分校的计算机系统研究组在一种叫做 BSD 的类 UNIX 系统内核中实现)来编写应用程序。

(如果是基于 C/S,即客户-服务器 网络通信模式,则应分别编写客户端版本与服务器版本的应用程序)

这样,当客户端进程与服务器进程通信时,网络通信API函数会首先将进程与各自的宿主操作系统通信,然后网络通信API函数使用封装在各自宿主操作系统的TCP/IP协议堆栈中的部分或者全部4层协议,来完成实际的(本地与远程)进程间通信。

总而言之,通过调用各自操作系统提供的网络通信API函数,客户与服务器进程可以先分别与各自的宿主操作系统通信,然后再进行网络通信。

如果应用程序在开发时不利用这些API函数已经封装的网络通信功能,则要么开发人员必须自行编写函数来操纵,控制TCP/IP协议堆栈前4层(不包含最高层应用层)的协议来建立连接,发送,接收分组(数据报)以及关闭连接。这会造成极大的编程难度与极低的编程效率。则要么该应用程序就不具备网络通信功能。

一般而言,操作系统的发布厂商会在自己的产品中实现TCP/IP协议簇的低4层,并提供相应的API函数给开发网络应用程序的人员来利用这些协议实现网络通信功能。

 

 

套接字数据结构:客户端以及服务器本地创建的数据结构,用于存储各种类型的变量。该数据结构可以通过调用本地操作系统提供的套接字接口函数来创建。

 

   客户端的套接字数据结构组成

       客户端的IP地址(32位)加上客户端进程的端口号(16位),对客户端而言是本地套接字地址

       服务器的IP地址(32位)加上服务器进程的端口号(16位),对客户端而言是远程套接字地址

   其中,客户端进程的端口号是发送请求时(建立TCP连接),临时分配的。这意味着同一IP的相同客户端进程在每次请求连接时,被分配的临时端口号也不会与前一次相同,且不能和本地其它进程使用的端口号冲突。

   例如,使用 web 浏览器访问同一站点,每次开启的浏览器进程端口号均不同。

   本地套接字地址由运行客户端进程的操作系统提供;远程套接字地址可以通过2种方法获得:

   ① 由编写客户-服务应用程序(即常说的 C/S 架构)的程序员在测试客户端进程能否正常工作时设定,或者由运行客户端进程的用户指定(一种办法是将IP地址作为cmd命令行的参数传递给客户端进程的 main 函数)。

   ② 多数情况下,远程套接字地址的服务器进程端口号都是已知的(即公用熟知端口),关键在于获取服务器的IP地址。如果客户端进程接受 URL 格式的因特网域名输入作为服务器IP地址,则可以通过本地的另一个叫做 DNS 的客户-服务进程将其映射到服务端IP地址。

 

 

   服务器的套接字数据结构组成

       服务器的IP地址(32位)加上服务器进程的端口号(16位),对服务器而言是本地套接字地址

       客户端的IP地址(32位)加上客户端进程的端口号(16位),对服务器而言是远程套接字地址

   其中,本地套接字地址的IP地址部分由服务器操作系统提供;如果是采用因特网管理机构定义的标准应用层协议(如 HTTP,FTP)通信的进程,其端口号部分为默认;如果采用非标准(私人开发且未经批准)的应用层协议通信的进程,需要该进程开发者自行指定不与公认端口冲突的端口;

远程套接字地址的IP地址与端口号,需要等到多个客户端请求连接时,从每个客户端的TCP数据报文中获得。这通过系统调用 accept() 实现,后者每次从请求队列中取出一个数据报文,创建一个监听套接字的副本(数据传输套接字),然后根据报文的IP头部的“目标地址”替换数据传输套接字中的“本地套接字地址”字段(

如果通过 htonl(INADDR_ANY) 设置监听套接字的本地套接字地址,那么在调用

bind() 时候,绑定到监听套接字的本地套接字地址就是 0.0.0.0,表示在所有网卡的所有 IP 地址上监听连接请求,当实际的远程数据包到达时,从监听套接字复制创建的数据传输套接字的本地套接字地址0.0.0.0就会被替换成数据报文IP头部的目标地址字段,而数据传输套接字的“远程套接字地址”字段,将被替换为数据报文IP头部的“源地址”字段)

例如我们在本机启动一个 apache httpd 进程(web服务器),发现它的监听套接字为 0.0.0.0:80 ,这就表示apache 的编程模型采用了处理本地所有网卡所有IP地址上的连接请求的策略。假设本机网卡有个 IP为192.168.1.2 ,那么我们在浏览器地址栏输入 192.168.1.2:80 这个请求包将被 httpd进程处理(它监听所有网卡所有IP的80端口),此时,httpd 创建一个 0.0.0.0:80 套接字的副本,将其替换为

192.168.1.2:80 ,然后远程套接字地址为浏览器所在的机器IP地址:浏览器动态端口,如下图所示:

 

wKiom1XhadTw21iXABLVxk_OxoE063.jpg

上图的演示环境基于2台虚拟机:一台192.168.3.57运行httpd进程,一台192.168.3.200使用web浏览器对httpd发起TCP连接。

从二个标识为1的位置可以看到,PID为1240的httpd父进程,启动时的监听套接字为 0.0.0.0:80 这表明它在编程时,对本地套接字地址的赋值采用的函数-参数组合为: htonl(INADDR_ANY),htonl函数将Intel x86/x64 架构兼容机的小端(主机)字节序表示法转换为大端(网络)字节序表示法,远程主机接收后才能正确还原为主机字节序表示法;参数INADDR_ANY的效果就是在所有网卡所有IP地址监听,即上图的0.0.0.0;可以看到监听套接字的远程地址为0.0.0.0:0 此时还没有任何远程IP:端口与其关联;当在192.168.3.200主机上使用浏览器访问httpd时,将与192.168.3.57主机建立TCP连接。我们从1处看到192.168.3.57的内核协议栈根据数据报文中的目标地址和源地址,填充了新建立套接字(绿色高亮区域)的“本地套接字地址”与“远程套接字地址”字段,httpd进程通过系统调用 accept() 来请求内核协议栈执行创建和填充的操作;accept() 执行成功返回新建立套接字的描述符,而实际新建的套接字则是保存在内核空间的数据结构(参考后面的示意图)

因为httpd进程空间中的套接字描述符保有对该数据结构的引用,因此我们在 process explorer 的httpd进程属性的TCP/IP标签中,可以查看到这个描述符引用的套接字;当然 httpd进程不能直接通过套接字描述符直接访问内核空间的相应套接字数据结构,而是需要借助类似 accept() 的系统调用,从用户模式切换到内核模式。现在你明白了为什么 accept() 需要应用进程传递一个监听套接字描述符作为其第一个参数了吧;accetp() 执行后,将处于阻塞状态,这意味着直到接收了远程主机的数据报文,并且创建和填充相应的套接字数据结构后,才返回用户模式(带着相应的描述符)

另外, httpd子进程不处理任何与网络连接相关的事务,全都由父进程负责,在多进程模型的 Chrome 浏览器中,我们也可以看到类似的情况;

二个标识为2处的位置是在 cmd 命令行执行 netstat -ano 输出的相同结果,注意,如果没有通过浏览器与httpd建立连接,那么只会显示监听套接字,监听套接字是httpd父进程在启动时通过 socket() 系统调用对应的机器指令创建的(返回监听套接字描述符);

标识为3的位置处是在任务管理器列出的httpd PID,与前面2个工具输出的相同。

 

 

下面的客户-服务TCP连接时序图可以帮助你更清晰的理解上述过程,这张图来自计算机网络教程——自顶向下方法,经过我略微修改以符合上述例子:

 

wKiom1XhdDOQ-grqAAmE0jtaxDQ090.jpg

 

一般而言,web 服务器进程处理大量不同IP用户的并发连接时,在一套接字描述符表中,为每个用户维护单独的套接字描述符,总数可能高达成千上万,每个的本地套接字地址均相同(服务器IP地址与进程端口号);但每个的远程套接字地址都不同,它们表示不同IP地址的客户端连接。

 

 

套接字描述符:整型变量,用于关联(或者引用,绑定)到特定的套接字数据结构。

套接字数据结构的地址:内存地址,用于寻址套接字数据结构。

 

套接字地址是一种特定类型的结构变量(struct socketaddr),属于套接字数据结构的5个字段(成员)之一。

套接字地址本身又包含5个结构成员。例如,对本地套接字地址其中的3个重要成员赋值后,即可通过 bind() 将其绑定(添加)到套接字数据结构中。

 

》》》》》》》》》》》》》》》》》》》》

① 操作系统为本地的每个需要进行网络通信的应用程序的进程空间(或称 虚拟的 内存空间)中,都提供一个 套接字描述符表。

 

② 应用程序(进程)通过 套接字描述符表 中记录的 特定套接字描述符 与 套接字数据结构的地址 之间的映射关系,来访问由操作系统维护的,存储在实际内存中的 套接字数据结构。

 

》》》》》》》》》》》》》》》》》》》》

客户端应用程序(进程)通过向宿主操作系统维护的本地套接字数据结构发送请求,以及从本地套接字数据结构中接收响应,来与服务端应用程序(进程)进行逻辑通信。实际的物理通信由本地TCP/IP协议堆栈的低4层(与对端的低4层)进行。

服务端应用程序进程通过从宿主操作系统维护的本地套接字数据结构中接收请求,以及向本地套接字数据结构发送响应,来与客户端应用程序进程进行逻辑通信。实际的物理通信同样由本地TCP/IP协议堆栈的低4层(与对端的低4层)进行。

 

套接字,套接字描述符,套接字地址,应用缓冲区,内核缓冲区。。。的关系参考下图:

wKioL1XgDPWQrtTqAAigym7T69g824.jpg

》》》》》》》》》》》》》》》》》》》》

每当一个套接字API函数需要一个“指向某特定类型套接字地址结构的|内存地址|的指针”作为参数来调用时,该指针需要强制类型转换成“指向通用类型套接字地址结构的|内存地址|的指针”,然后才能|作为该套接字API函数|的参数来调用。

例如:

struct sockaddr_in servaddr;  //声明一个名为servaddr的“sockaddr_in类型”变量


sockaddr_in 结构的定义类似如下形式,不同的系统上可能略有差别:

struct sockaddr_in {
 
    short sin_family;                //AF_INET
    u_short sin_port;               //16位端口号,网络字节序   
    struct in_addr sin_addr;       //结构,其中一个成员存储了32位IP地址信息
    char sin_zero[8];             // 保留
 
}


后面我们会用 visual studio 内置的调试工具来近距离分析上述结构,看看windows平台是如何实现这个结构的;而在类UNIX系统中,可以查看 socket.h 头文件来获取该结构的定义。

	
struct sockaddr servaddr2;  //定义一个名为servaddr2的“sockaddr类型结构变量”


sockaddr 结构为通用套接字地址结构(注意该结构没有_in后缀)。

因此

	
connect( sockfd, (*)&servaddr, sizeof(servaddr) );

 

上面这种用法错误不被connect这个套接字API函数接受。因为其第二个参数要求 sockaddr 类型指针,而 (*)&servaddr 是sockaddr_in 类型指针(编译器会报错,类型不匹配)

connect( sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr) );

 

  上面是正确用法。因为该函数第二个参数仅接受指向 sockaddr 类型变量起始地址的指针,所以将一个 sockaddr 类型指针指向 servaddr 的起始地址,这样就匹配了。

	
connect( sockfd, (*)&servaddr2, sizeof(servaddr2) );


上面是正确用法。因为servaddr2是 sockaddr 类型变量,(*)&servaddr2 就是指向该变量起始地址的指针。

【编程实践】

下面通过一个简单的服务器端程序来总结前述知识点,该例子源码在 visual studio 2010 上编译,运行,调试通过,使用 windows 网络编程 API ,程序运行后,监听在 tcp 端口 7775 等待客户端连接,并回送客户发来的信息,有了前面的预备知识后,相信你在阅读代码时已经非常清楚系统在底层究竟做了什么事:

#pragma comment (lib, "ws2_32.lib") 
#include <windows.h>
#include <winsock.h>
#include <stdio.h>
 
int main() {
     
     
    SOCKET server_listen_socket_descriptor, server_handle_connection_socket_descriptor;
    struct sockaddr_in server_addr;
    struct sockaddr_in client_addr;
    int server_handle_connection_socket_data_struct_len = sizeof(struct sockaddr_in);
    WSADATA wsa;
    WSAStartup(MAKEWORD(2, 2), &wsa);
    char buffer_in_user_space_to_save_process_data_which_send_and_receive [1024];
    char* point_to_buffer_start_address = buffer_in_user_space_to_save_process_data_which_send_and_receive;
    int user_buffer_len = sizeof(buffer_in_user_space_to_save_process_data_which_send_and_receive);
    int the_sucessful_copyed_bytes_to_user_buffer_from_client_each_call_recv = 0;
    int the_total_bytes_to_send_back_to_client_each_call_send = 0;
    /*下面三行对“本地套接字地址”这个结构体中的3个字段初始化后,才可以调用bind()将其绑定到用于监听的套接字上,bind()执行成功才返回指向
    该套接字(数据结构)的描述符*/
    server_addr.sin_family = AF_INET;
    /*一种检测htonl函数作用的方法是,首先在计算器上输入十进制数1921683200转换成16进制数为728a8f00,但是Intel小端法表示
    造成操作系统内部表示为008f8a72,于是我们可以将下面的htonl参数改为0x008f8a72,然后在main函数按f10单步进入调试,
    点击下方的 local局部变量的动态监视表,发现执行完26行之后,server_addr.sin_addr.s_addr的值变为1921683200
    证实了htonl将小端序数转换为大端序数,以便在互联网上传输*/
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY); /*在所有IP地址上监听*/
     
    /*htonl()将主机字节顺序(在Intel x86/x64 兼容机上就是小端法)表示的长整型IP地址,转换为网络字节顺序的长整型对应值,    从而保证远程主机能够在接收时正确还原IP地址*/
    server_addr.sin_port = htons(7775);   /*htons()将主机字节顺序(在Intel x86/x64 兼容机上就是小端法)表示的短整型端口号,转换为网络字节顺序的短整型对应值,保证远程主机能       够在接收时正确还原端口号。虽然在windows平台上编程时不使用这2个函数转换成网络字节序,编译器检查时也不会报错,但是为了保证程序的可移植性以及可能与异构操作系统通信,
    建议还是将其转换*/
     
    if ((server_listen_socket_descriptor = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
         
        perror("Error:  create socket failed !");
        exit(1);
     
    }
 
    if ((bind(server_listen_socket_descriptor, (struct sockaddr *)&server_addr, sizeof(struct sockaddr))) < 0) {
     
        perror("Error:  binding socket failed !");
        exit(1);
 
    }
 
    if ((listen(server_listen_socket_descriptor, 10)) <0) {
     
        perror("Error:  listen socket failed !");
        exit(1);
 
    }
 
     
    printf("%s\n", hacker);
     
    //下面这个无限 for 循环是作为服务器进程持续运行不可或缺的
    for ( ; ;) {
        /*accept()在内核中复制一个与监听套接字数据结构相同的套接字数据结构(handle_connection_socket),然后通过第2个参数(客户套接字地址的起始地址)填充handle_connection_socket中的未初始化远程套接字地址字段
        accept 执行成功则返回指向handle_connection_socket 起始地址的描述符,应用进程后续的收发数据操作都需要通过这个描述符*/
        if ((server_handle_connection_socket_descriptor = accept(server_listen_socket_descriptor, (struct sockaddr *)&client_addr, &server_handle_connection_socket_data_struct_len)) <0) {
         
            perror("Error:  handle client connection failed !");
            exit(1);
 
        }
 
        while ((the_sucessful_copyed_bytes_to_user_buffer_from_client_each_call_recv = recv(server_handle_connection_socket_descriptor, point_to_buffer_start_address,  user_buffer_len, 0)) >0) {
            /*每成功调用recv()一次,都返回不为0的实际复制字节数,并且point_to_buffer_start_address指针向后移动实际复制字节的长度,更新为下次调用recv的参数*/
            point_to_buffer_start_address += the_sucessful_copyed_bytes_to_user_buffer_from_client_each_call_recv;
            user_buffer_len -= the_sucessful_copyed_bytes_to_user_buffer_from_client_each_call_recv;/*每成功调用recv()一次,用户缓冲的大小都减少实际复制的字节数长度,更新为下次调用recv的参数*/
            the_total_bytes_to_send_back_to_client_each_call_send += the_sucessful_copyed_bytes_to_user_buffer_from_client_each_call_recv;/*每成功调用一次,总共复制的字节数都增加(全局变量),更新为后面调用send返回数据时的参数*/
             
        }
        /*当recv()返回0,所有数据复制到用户缓冲完毕,调用send()回送客户端发过来的数据*/
        send(server_handle_connection_socket_descriptor, buffer_in_user_space_to_save_process_data_which_send_and_receive, the_total_bytes_to_send_back_to_client_each_call_send, 0);
        closesocket(server_handle_connection_socket_descriptor);
 
    }
    /*不关闭监听套接字的原因子在于,它需要持续存在并等待处理下一个客户端连接,当下一个客户连接到来时,再次通过accept()创建处理连接的套接字收发数据*/
     
     
     
}


由于我直接在代码内关键的逻辑处添加了注释,因此就不再详细解释每行语句的作用;你可以直接复制上面源码,然后在 visual studio 系列 IDE 中创建一个空白 cpp 文件,粘贴代码后直接 F5+F7 编译调试运行。下面是我在 visual studio 2010 中调试该程序的截图,为的是验证 htonl() 函数的作用:上面代码中使用到的变量名虽然长了些,但是并不臭,反而很优雅。因为一眼就能看出变量的用途,不像某些变量名为 s,abc,n,ptr,buffer 等,完全没有任何存在的价值。

 

wKiom1Xh_mqgfopQABRkZFgr01s075.jpg

 

最后,上面程序的精髓部分在 for 循环内嵌的 while 循环中,执行 recv() 系统调用从内核缓冲区复制远程主机发送的数据到用户空间的缓冲区所使用的算法,其实也没那么复杂,因为内核缓冲通常比应用程序缓冲区要大,一次 recv() 调用可能无法复制完所有的数据,特别是远程主机发送类似流媒体格式的数据流时,所以在 while 循环中计算并更新了3个局部变量(声明并初始化在main函数内):

用来追踪每次循环后应用程序缓冲区的使用情况(user_buffer_len)

总共复制的字节数(the_total_bytes_to_send_back_to_client_each_call_send)

本次要把数据复制到缓冲区中的地址(point_to_buffer_start_address )

 

当 recv() 返回0,表明将所有内核接收到的数据复制到应用缓冲,此时满足循环退出条件,于是执行 send() 系统调用“回送”所有数据到发送端。

整个算法的设计思路基于下面这张图(还是取自前面那本书,经过适当修改),由于 recv() 每次执行成功,都返回实际复制到用户缓冲的字节数,并且用变量(the_sucessful_copyed_bytes_to_user_buffer_from_client_each_call_recv)

来保存,因此上面3个变量与该变量是紧密相关的:

wKioL1Xh6v-iOouMAATi4njrqUs470.jpg

 

》》》》》》》》》》》》》》》》》》》》

数据包在网络上传输的大小上限

位于发送端操作系统TCP/IP协议栈的传输层(TCP),把来自其上层(应用层进程或程序)产生的字节流数据,按顺序经分割后封装成各个TCP分段,并(逻辑上的)传送给接收端的传输层。

 

字节流数据是指:由应用程序通过一次或多次“输出”操作,“写入”到本地(发送端)传输层的套接字数据结构中的数据。该数据实际被存储在本地操作系统的缓冲区中。

 

是否分割由应用程序产生的原始数据:

√ 取决于在TCP连接建立阶段,由对端传输层所通告支持的最大分节大小(Maximum Segment Size,MSS)。可以将MSS视为(发送端与接收端各自的)应用层与传输层之间的接口属性。

√ 取决于链路层的最大传输单元(Maximum Transmission Unit,MTU)大小。

如果因特网上支持IPv6(IP协议第6版)的路由器具备“链路层MTU发现”功能,则可以确定在转发数据包时可能经过的路径(链路)中,存在“最小MTU”的必经路径(链路)。

此时分割后的原始应用程序数据块的大小必须保证不得超过此条路径(链路)的MTU值。

 

分割操作可由发送端操作系统TCP/IP协议栈完成,也可以由因特网上的路由器完成。

为了避免因特网上主干节点的路由器过载和提高转发效率,IPv6通过“链路层MTU发现”功能,来获取所有必经链路中MTU值最小者;或者下一跳直连链路的MTU值。同时确保分割操作是由发送端完成而非路由器。

可以将MTU视为(发送端与接收端各自的)网络层与链路层之间的接口属性。

 

发送端同接收端网络层逻辑交换的“协议数据单元”(protocol data unit,PDU)称为IP分组。虽然IPv4分组最大能有65535字节(65K Bytes);IPv6分组最大能有65575字节,但在链路层中,一个封装这些IP分组的”帧“(两端链路层逻辑交换的PDU名称)通常只有1500字节(1.5K Bytes),这意味着需要将过大的网络层IP分组进行分割才能发送。

IPv6通过“路径MTU发现”功能,确保对过大IP分组的分割操作由发送端网络层——链路层完成,而非因特网上的路由器。

 

 

》》》》》》》》》》》》》》》》》》》》

当应用程序要从某个TCP套接字数据结构(或对应的由操作系统维护的缓冲区中)读取由对端发送的完整数据时,由于应用程序单次调用read()并不能确保从操作系统缓冲区中读取完所有发送端的数据,所以通常将read()调用放进一个循环。

当read()调用返回0(表示缓冲区中已无待读取的数据);

或返回小于0(表示read()调用发生错误)则结束该循环。

 

 

》》》》》》》》》》》》》》》》》》》》

Unix socket API函数与Unix线程函数 的错误处理对比

 

Unix socket API函数发生错误时:

√把全局变量errno的值设置为标识该错误类型的整型正值。

√该socket API函数本身返回-1。

 

Unix线程函数(以pthread_开头的函数)发生错误时:

√不会把标识该错误类型的整型正值保存在全局变量errno中。

√该线程函数本身返回标识该错误类型的整型正值。

 

由此可见,对于线程函数,必须额外定义一个整型变量来保存其返回的错误类型标识值。并把其值赋给全局变量errno。

这样,后续调用err_sys()函数时,它才能根据errno的值(错误类型)来输出相应的出错提示消息。

在<sys/errno.h>系统头文件中定义了所有Unix系统错误常量(以E开头的全大写字面值)对应的整型错误类型标识值。

综上所述,“connect()返回ECONNREFUSED”这种描述形式表明:

√这是一个标准的Unix socket API函数;

√调用connect函数时发生错误,该函数本身返回-1;

√该函数将全局变量errno的值设为ECONNREFUSED字面值常量对应的整型错误类型标识值。这个值应该与<sys/errno.h>系统头文件中定义ECONNREFUSED的值相符。

 

转载于:https://my.oschina.net/slagga/blog/1588894

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值