Linux系统开发: 学习Linux下网络编程
第一章 TCP网络编程
1.1 socket创建套接字
#include <sys/types.h> #include <sys/socket.h> int socket(int domain, int type, int protocol); |
功能
创建网络套接字,用于网络通信使用,类似于文件操作的open函数。该函数在服务器和客户端都会用到。
参数
int domain :网络协议版本指定。
AF_INET IPv4 Internet protocols
AF_INET6 IPv6 Internet protocols
int type:指定通信协议类型。
SOCK_STREAM 表明我们用的是TCP协议 (字节流)
SOCK_DGRAM 表明我们用的是UDP协议 (数据报)
int protocol:指定通信协议类型。Type参数已经指定了协议,该参数直接填0即可!
返回值
成功返回网络套接字,与open函数返回值类似。
示例
Clientfd = socket(PF_INET,SOCK_STREAM,0); |
1.2 bind绑定IP-端口
#include <sys/types.h> /* See NOTES */ #include <sys/socket.h> int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen); |
功能
创建服务器。该函数在服务器端使用。
参数
- int sockfd : 网络套接字
- const struct sockaddr *addr :填充创建服务器所需的地址信息,详细的成员看1.3章节。
- socklen_t addrlen :地址长度,就是该结构体的大小。使用sizeof函数进行计算。
返回值
0表示成功,-1表示失败!
1.3 struct sockaddr地址结构体
1.3.1 结构体成员解析
在实际填充参数的过程中,struct sockaddr结构体被struct sockaddr_in结构体代替。struct sockaddr_in结构体比struct sockaddr可读性强一些,填充参数比较好理解。
struct sockaddr_in和struct sockaddr大小相同。在填充结构体的时候为了方便填充参数,使用struct sockaddr_in结构体,给函数赋值的时候需要强制转换为struct sockaddr类型的结构体。因为底层函数最终还是使用struct sockaddr类型的结构体。
struct sockaddr结构体成员:
struct sockaddr {undefined sa_family_t sa_family; //网络协议版本。填写:AF_INET 或者 AF_INET6。 char sa_data[14]; //IP地址和端口 } |
struct sockaddr_in结构体成员:
查看IPV4协议帮助文档:# man 7 ip
struct sockaddr_in {undefined sa_family_t sin_family; /* address family: AF_INET 协议类型*/ in_port_t sin_port; /* port in network byte order 端口号*/ struct in_addr sin_addr; /* internet address 存放IP地址的结构体*/ }; /* Internet address. */ struct in_addr {undefined uint32_t s_addr; /* address in network byte order IP地址 */ }; |
1.3.2 端口号赋值
计算机数据存储有两种字节优先顺序: 高位字节优先和低位字节优先。 Internet 上数据以高位字节优先顺序在网络上传输, 所以对于在内部是以低位字节优先方式存储数据的机器, 在 Internet 上传输数据时就需要进行转换, 否则就会出现数据不一致。
普通人用的桌面电脑,只要是Intel或AMD的x86/x64架构就一定是小端字节序。
外很多ARM CPU可以选择数据指令字节序,不过通常也都是运行小端字节序(比如我们的智能手机)。
网络设备,像PowerPC核心的一些路由器,默认运行大端字节序。
下面是几个字节顺序转换函数:
·htonl(): 把 32 位值从主机字节序转换成网络字节序
·htons(): 把 16 位值从主机字节序转换成网络字节序
·ntohl(): 把 32 位值从网络字节序转换成主机字节序
·ntohs(): 把 16 位值从网络字节序转换成主机字节序
函数原型
#include <arpa/inet.h> uint32_t htonl(uint32_t hostlong); uint16_t htons(uint16_t hostshort); uint32_t ntohl(uint32_t netlong); uint16_t ntohs(uint16_t netshort); 网际协议在处理这些多字节整数时,使用大端字节序。 在主机本身就使用大端字节序时,这些函数通常被定义为空宏。 |
给struct sockaddr_in结构体的端口成员赋值的时候就需要用到以上大端转小端函数进行转换!
示例:
/*结构体成员赋值*/ tcp_server.sin_family=AF_INET; //IPV4协议类型 tcp_server.sin_port=htons(tcp_server_port);//端口号赋值,将本地字节序转为网络字节序 tcp_server.sin_addr.s_addr=INADDR_ANY; //将本地IP地址赋值给结构体成员 //inet_addr("192.168.18.3"); //IP地址赋值 |
1.3.3 IP地址赋值
struct sockaddr_in结构体存放IP地址的成员是struct in_addr 结构体类型,底层存放地址的成员是一个无符号int类型,而我们生活中的IP地址是使用xxx.xxx.xxx.xxx 这种格式表示的。比如:192.168.1.1。 在赋值的时候就需要进行将”192.168.1.1”这种格式转为无符号int类型才能进行赋值。
以下是几个IP格式转换函数:
将字符串类型IP转为in_addr_t类型(unsigned int)返回。
in_addr_t inet_addr(const char *cp); |
示例:
Serveraddr.sin_addr.s_addr = inet_addr("192.168.18.3");
使用字符串类型的IP直接给结构体成员赋值
int inet_aton(const char *cp, struct in_addr *inp); |
示例:
inet_aton(“192.168.18.3”,&Clientaddr.sin_addr);
将结构体里的IP地址成员转为字符串类型返回
char *inet_ntoa(struct in_addr in); |
该函数与上面两个函数功能刚好相反。是将整型的IP转为字符串类型!
1.3.4 本地计算机大小端判断
首先说明,电脑大小端指的是一种存储模式。
为什么有大小端:
在计算机系统中,我们是以字节为单位的,每个地址单元都对应着一个字节,一个字节为 8bit。但是在C语言中除了8bit的char之外,还有16bit的short型,32bit的long型(要看具体的编译器),另外,对于位数大于 8位的处理器,例如16位或者32位的处理器,由于寄存器宽度大于一个字节,那么必然存在着一个如何将多个字节安排的问题,因此就导致了大端存储模式和小端存储模式。
大小端定义:
大端模式(Big-endian),是指数据的高字节,保存在内存的低地址中,而数据的低字节,保存在内存的高地址中。
小端模式(Little-endian),是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中。
直接来看一个图,详细说明大小端:
例子:int i = 0x12345678 两种模式存入内存:
4. 判断大小端的C语言代码
#include <stdio.h> int CheckSystem() { union check { int i; char ch; }c; c.i=1; return (c.ch==1); } int main() { int check=CheckSystem(); if(check==1) printf("当前系统为小端\n"); else printf("当前系统为大端\n"); return 0; } /// // 公用的四个字节地址 :0x1001 -> 0x1002 -> 0x1003 -> 0x1004 // 小端来说 赋值 1 : 0x01 0x00 0x00 0x00 // 大端来说 赋值 1 : 0x00 0x00 0x00 0x01 //也就是说存数据都是从低地址存放 一个char字节, //他和int开始的地址是一样的 读的话 还是从低字节向高字节完整的读取 |
1.4 listen监听端口的数量
#include <sys/types.h> /* See NOTES */ #include <sys/socket.h> int listen(int sockfd, int backlog); |
功能
设置服务器需要监听的端口数量。决定了能够同时响应连接的服务器数量。
返回值
成功返回0,失败返回-1。
服务器创建,函数调用顺序:
示例:listen(Serverfd,10)
1.5 accept 等待客户端连接
#include <sys/types.h> /* See NOTES */ #include <sys/socket.h> int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); |
功能
以阻塞的形式等待客户端连接。
参数
struct sockaddr *addr :存放已经连接的客户端信息。传入一个结构体地址。
socklen_t *addrlen :表示客户端的结构体大小。该大小需要我们指定,客户端连接成功然后再判断是否与填写的大小一致。
返回值
成功将返回客户端的网络套接字。错误返回-1。
示例:
struct sockaddr_in Clientaddr; len = sizeof(struct sockaddr); Clientfd = accept(Serverfd,(struct sockaddr *)&Clientaddr,&len); |
1.6 connect连接服务器
#include <sys/types.h> /* See NOTES */ #include <sys/socket.h> int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen); |
功能
连接到指定服务器。该函数在客户端使用。
参数
int sockfd :socket函数的网络套接字。
const struct sockaddr *addr :服务器的IP地址信息。 参考:1.2节和1.3.节
socklen_t addrlen :结构体的大小。
返回值
成功返回0,错误返回-1。
示例
connect(Clientfd,(struct sockaddr *)&Clientaddr,sizeof(struct sockaddr)); |
1.7 send/ recv网络数据收发
#include <sys/types.h> #include <sys/socket.h> ssize_t send(int sockfd, const void *buf, size_t len, int flags); ssize_t recv (int sockfd, void *buf, size_t len, int flags); |
功能
客户端与服务器之间的数据收发。
参数
const void *buf 、void *buf :读写的缓冲区。
int flags :填0。
以上两个函数可以使用write和read函数替换。
1.8 shutdown关闭连接
#include <sys/socket.h> int shutdown(int sockfd, int how); |
返回
0—成功,-1—失败。
参数how的值:
SHUT_RD:关闭连接的读这一半,不再接收套接口中的数据且留在套接口缓冲区中的数据都作废。进程不能再对套接口任何读函数。调用此函数后,由TCP套接口接收的任何数据都被确认,但数据本身被扔掉。
SHUT_WR:关闭连接的写这一半,在TCP场合下,这称为半关闭。当前留在套接口发送缓冲区中的数据都被发送,后跟正常的TCP连接终止序列。此半关闭不管套接口描述字的访问计数是否大于0。进程不能再执行对套接口的任何写函数。
SHUT_RDWR:连接的读这一半和写这一半都关闭。这等效于调用shutdown两次:第一次调用时用SHUT_RD,第二次调用时用SHUT_WR。
shutdown(tcp_client_fd,SHUT_WR); //TCP半关闭,保证缓冲区内的数据全部写完 |
直接强制关闭连接示例:
int close(int fd); |
1.9 查看Linux系统当前的网络连接
在/proc/net/tcp目录下面保存了当前系统所有TCP链接的状态信息。
查看示例:
[root@wbyq FileSend2]# cat /proc/net/tcp sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode 0: 00000000:006F 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 13264 1 c16ac5c0 99 0 0 10 -1 1: 00000000:DA10 00000000:0000 0A 00000000:00000000 00:00000000 00000000 29 0 13592 1 c16ac0c0 99 0 0 10 -1 2: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 14400 1 c16acac0 99 0 0 10 -1 3: 0100007F:0277 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 13851 1 c142f080 99 0 0 10 -1 4: 0100007F:0019 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 14753 1 c142fa80 99 0 0 10 -1 5: 813DA8C0:A019 49AAC3CB:0522 01 00000000:00000000 00:00000000 00000000 0 0 123641 1 c142f580 20 3 18 10 -1 |
说明: 这里的IP地址信息和端口号都是使用十六进制保存的。
813DA8C0:A019 49AAC3CB:0522
查看网络状态连接:
[root@wbyq FileSend2]# netstat -ntp Active Internet connections (w/o servers) Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name tcp 0 0 192.168.61.129:40985 203.195.170.73:1314 ESTABLISHED 20955/./app_c |
从上面可得到的信息:
连接类型: TCP协议
本地IP地址和端口号: 192.168.61.129:40985
与其通信的远程IP地址和端口号: 203.195.170.73:1314
状态: ESTABLISHED(已建立的连接)
进程PID号与应用程序名称: 20955/./app_c
socket网络连接的状态如下
1、LISTENING状态
FTP服务启动后首先处于侦听(LISTENING)状态。
2、ESTABLISHED状态
ESTABLISHED的意思是建立连接。表示两台机器正在通信。
3、CLOSE_WAIT
对方主动关闭连接或者网络异常导致连接中断,这时我方的状态会变成CLOSE_WAIT 此时我方要调用close()来使得连接正确关闭
4、TIME_WAIT
我方主动调用close()断开连接,收到对方确认后状态变为TIME_WAIT。TCP协议规定TIME_WAIT状态会一直持续2MSL(即两倍的分 段最大生存期),以此来确保旧的连接状态不会对新连接产生影响。处于TIME_WAIT状态的连接占用的资源不会被内核释放,所以作为服务器,在可能的情 况下,尽量不要主动断开连接,以减少TIME_WAIT状态造成的资源浪费。
目前有一种避免TIME_WAIT资源浪费的方法,就是关闭socket的LINGER选项。但这种做法是TCP协议不推荐使用的,在某些情况下这个操作可能会带来错误。
5、SYN_SENT状态
SYN_SENT状态表示请求连接,当你要访问其它的计算机的服务时首先要发个同步信号给该端口,此时状态为SYN_SENT,如果连接成功了就变为 ESTABLISHED,此时SYN_SENT状态非常短暂。但如果发现SYN_SENT非常多且在向不同的机器发出,那你的机器可能中了冲击波或震荡波 之类的病毒了。这类病毒为了感染别的计算机,它就要扫描别的计算机,在扫描的过程中对每个要扫描的计算机都要发出了同步请求,这也是出现许多 SYN_SENT的原因。
根据TCP协议定义的3次握手断开连接规定,发起socket主动关闭的一方 socket将进入TIME_WAIT状态,TIME_WAIT状态将持续2个MSL(Max Segment Lifetime),在Windows下默认为4分钟,即240秒,TIME_WAIT状态下的socket不能被回收使用. 具体现象是对于一个处理大量短连接的服务器,如果是由服务器主动关闭客户端的连接,将导致服务器端存在大量的处于TIME_WAIT状态的socket, 甚至比处于Established状态下的socket多的多,严重影响服务器的处理能力,甚至耗尽可用的socket,停止服务. TIME_WAIT是TCP协议用以保证被重新分配的socket不会受到之前残留的延迟重发报文影响的机制,是必要的逻辑保证.
第二章 UDP网络编程
2.1 UDP协议创建流程
2.2 数据报收发函数
2.2.1 recvfrom函数
UDP使用recvfrom()函数接收数据,他类似于标准的read(),但是在recvfrom()函数中要指明数据的目的地址。
#include <sys/types.h> #include <sys/socket.h> ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr * from, size_t *addrlen); |
返回值
成功返回接收到数据的长度,负数失败
前三个参数等同于函数read()的前三个参数,flags参数是传输控制标志。最后两个参数类似于accept的最后两个参数(接收客户端的IP地址)。
示例:
/*阻塞方式接收数据*/ int len=0; char buff[1024]; size_t addrlen=sizeof(struct sockaddr); while(1) {undefined len=recvfrom(socketfd,buff,1024,0,(struct sockaddr *)&ClientSocket,&addrlen); buff[len]='\0'; printf("Rx: %s,len=%d\n",buff,len); printf("数据发送方IP地址:%s\n",inet_ntoa(ClientSocket.sin_addr)); printf("数据发送方端口号:%d\n",ntohs(ClientSocket.sin_port)); } |
2.2.2 sendto函数
UDP使用sendto()函数发送数据,他类似于标准的write(),但是在sendto()函数中要指明目的地址。
#include <sys/types.h> #include <sys/socket.h> ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr * to, int addrlen); |
返回值
成功返回发送数据的长度,失败返回-1
前三个参数等同于函数read()的前三个参数,flags参数是传输控制标志。参数to指明数据将发往的协议地址,他的大小由addrlen参数来指定。
示例:
/*向UDP协议服务器发送数据*/ ServerSocket.sin_family=PF_INET; //协议 ServerSocket.sin_port=htons(PROT); //端口 ServerSocket.sin_addr.s_addr=inet_addr(argv[1]); //表示服务器的IP地址 bzero(ServerSocket.sin_zero,8); //初始化空间
char buff[]="1234567890"; int len=0; while(1) {undefined len=sendto(socketfd,buff,strlen(buff),0,(const struct sockaddr*)&ServerSocket,sizeof(struct sockaddr)); printf("Tx: %d\n",len); sleep(1); } |
第三章 设置Socket套接字属性
3.1 函数原型介绍
#include <sys/types.h> /* See NOTES */ #include <sys/socket.h> int getsockopt(int sockfd, int level, int optname,void *optval, socklen_t *optlen); int setsockopt(int sockfd, int level, int optname,const void *optval, socklen_t optlen); |
参数
sockfd:标识一个套接口的描述字。
level:选项定义的层次;目前仅支持SOL_SOCKET和IPPROTO_TCP层次。
optname:需设置的选项。
optval:指针,指向存放选项值的缓冲区。
optlen:optval缓冲区的长度。
3.2 属性功能注释
setsockopt()函数用于任意类型、任意状态套接口的设置选项值。尽管在不同协议层上存在选项,但本函数仅定义了最高的“套接口”层次上的选项。选项影响套接口的操作,诸如加急数据是否在普通数据流中接收,广播数据是否可以从套接口发送等等。
setsockopt()支持的选项定义位置:/usr/include/asm-generic/socket.h
#ifndef __ASM_GENERIC_SOCKET_H #define __ASM_GENERIC_SOCKET_H #include <asm/sockios.h> /* For setsockopt(2) */ #define SOL_SOCKET 1 #define SO_DEBUG 1 #define SO_REUSEADDR 2 #define SO_TYPE 3 #define SO_ERROR 4 #define SO_DONTROUTE 5 #define SO_BROADCAST 6 #define SO_SNDBUF 7 #define SO_RCVBUF 8 #define SO_SNDBUFFORCE 32 #define SO_RCVBUFFORCE 33 #define SO_KEEPALIVE 9 #define SO_OOBINLINE 10 #define SO_NO_CHECK 11 #define SO_PRIORITY 12 #define SO_LINGER 13 #define SO_BSDCOMPAT 14 /* To add :#define SO_REUSEPORT 15 */ #ifndef SO_PASSCRED /* powerpc only differs in these */ #define SO_PASSCRED 16 #define SO_PEERCRED 17 #define SO_RCVLOWAT 18 #define SO_SNDLOWAT 19 #define SO_RCVTIMEO 20 #define SO_SNDTIMEO 21 #endif /* Security levels - as per NRL IPv6 - don't actually do anything */ #define SO_SECURITY_AUTHENTICATION 22 #define SO_SECURITY_ENCRYPTION_TRANSPORT 23 #define SO_SECURITY_ENCRYPTION_NETWORK 24 #define SO_BINDTODEVICE 25 /* Socket filtering */ #define SO_ATTACH_FILTER 26 #define SO_DETACH_FILTER 27 #define SO_PEERNAME 28 #define SO_TIMESTAMP 29 #define SCM_TIMESTAMP SO_TIMESTAMP #define SO_ACCEPTCONN 30 #define SO_PEERSEC 31 #define SO_PASSSEC 34 #define SO_TIMESTAMPNS 35 #define SCM_TIMESTAMPNS SO_TIMESTAMPNS #define SO_MARK 36 #define SO_TIMESTAMPING 37 #define SCM_TIMESTAMPING SO_TIMESTAMPING #define SO_PROTOCOL 38 #define SO_DOMAIN 39 #define SO_RXQ_OVFL 40 #endif /* __ASM_GENERIC_SOCKET_H */ |
setsockopt()支持下列选项。其中“类型”表明optval所指数据的类型。
选项 |
类型 |
意义 |
SO_BROADCAST |
BOOL |
允许套接口传送广播信息。 |
SO_DEBUG |
BOOL |
记录调试信息。 |
SO_DONTLINER |
BOOL |
不要因为数据未发送就阻塞关闭操作。设置本选项相当于将SO_LINGER的l_onoff元素置为零。 |
SO_DONTROUTE |
BOOL |
禁止选径;直接传送。 |
SO_KEEPALIVE |
BOOL |
发送“保持活动”包。 |
SO_LINGER |
struct linger FAR* |
如关闭时有未发送数据,则逗留。 |
SO_OOBINLINE |
BOOL |
在常规数据流中接收带外数据。 |
SO_RCVBUF |
int |
为接收确定缓冲区大小。 |
SO_REUSEADDR |
BOOL |
允许套接口和一个已在使用中的地址捆绑(参见bind())。 |
SO_SNDBUF |
int |
指定发送缓冲区大小。 |
TCP_NODELAY BOOL |
禁止发送合并的Nagle算法。 |
3.3 设置socket具有广播特性
发送UDP数据报的时候,设置socket具有广播特性:(默认情况下socket不支持广播特性)
const int opt = 1; //设置该套接字为广播类型, int nb = 0; nb = setsockopt(client_fd, SOL_SOCKET, SO_BROADCAST, (char *)&opt, sizeof(opt)); if(nb == -1) {undefined printf("设置广播类型错误.\n"); } |
3.4 设置socket发送和接收的缓冲区大小。
系统默认的状态发送和接收一次为8688字节(约为8.5K);在实际的过程中发送数据和接收数据量比较大,可以设置socket缓冲区。
// 接收缓冲区 int nRecvBuf=20*1024;//设置为20K setsockopt(socketfd,SOL_SOCKET,SO_RCVBUF,(const char*)&nRecvBuf,sizeof(int)); //发送缓冲区 int nSendBuf=20*1024;//设置为20K setsockopt(socketfd,SOL_SOCKET,SO_SNDBUF,(const char*)&nSendBuf,sizeof(int)); |
3.5 设置收发时限
在发送和接收过程中有时由于网络状况等原因,发收不能预期进行,而设置收发时限:
int nNetTimeout=1000; //1秒 //发送时限 setsockopt(socketfd,SOL_SOCKET,SO_SNDTIMEO,(char *)&nNetTimeout,sizeof(int)); //接收时限 setsockopt(socketfd,SOL_SOCKET,SO_RCVTIMEO,(char *)&nNetTimeout,sizeof(int)); |
3.6 允许套接字绑定已使用的端口
有时候将服务器关闭之后,端口的释放需要时间,可以设置该数据允许套接字绑定正在被占用的端口。
int on = 1; setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); |
3.7 忽略SIGPIPE信号
往一个已经接收到FIN的套接中写是允许的,接收到的FIN仅仅代表对方不再发送数据。并不能代表我不能发送数据给对方。
往一个FIN结束的进程中写(write),对方会发送一个RST字段过来,TCP重置。
如果再调用write就会产生SIGPIPE信号。
(也就是当服务器向客户端发送数据时,客户端突然断开连接,会导致SIGPIPE信号产生,如果不处理,系统默认的处理方式就终止进程)
signal(SIGPIPE,SIG_IGN); //忽略SIGPIPE信号 |
3.8 获取网络底层缓冲区发送剩余字节数
在网络编程时,发送方调用write(fd)将报文发送的时候实际上只是写入了内核的write buffer。接收方什么时候能收到报文是个未知数。
在某些需要同步状态机的地方,发送方最好能够确认接收方收到报文后再进行下一步动作。
linux提供了ioctl(fd, SIOCOUTQ, &count)方法来查询一个tcp socket的write buffer是否清空。发送方一般可以用这个方法来判断对端是否收到报文。当底层网卡将缓冲区的数据全部发送成功时,获取的count=0
-
#include <sys/ioctl.h>
-
-
#include <linux/sockios.h>
-
-
int value;
-
-
ioctl(client_fd,SIOCOUTQ,&value);
3.9 获取当前网络协议底层发送与接收缓冲区大小
-
int sockfd;
-
-
/*1. 创建socket套接字*/
-
-
sockfd=socket(AF_INET,SOCK_STREAM,0);
-
-
-
-
int nRecvBuf;
-
-
socklen_t len=4;
-
-
getsockopt(sockfd,SOL_SOCKET,SO_RCVBUF,&nRecvBuf,&len);
-
-
printf("接收缓冲区大小=%d\n",nRecvBuf);
-
-
-
-
//发送缓冲区
-
-
int nSendBuf;
-
-
getsockopt(sockfd,SOL_SOCKET,SO_SNDBUF,&nSendBuf,&len);
-
-
printf("发送缓冲区大小=%d\n",nSendBuf);
Redhat6.3系统上输出结果:
接收缓冲区大小=87380
发送缓冲区大小=16384
文章来源: xiaolong.blog.csdn.net,作者:DS小龙哥,版权归原作者所有,如需转载,请联系作者。
原文链接:xiaolong.blog.csdn.net/article/details/119220611
- 点赞
- 收藏
- 关注作者
评论(0)