【Linux编程学习笔记】Socket编程

网络套接字

在通信过程中,套接字一定成对出现
一个文件描述符指向一个套接字(该套接字由内核借助两个缓冲区实现)在这里插入图片描述

预备知识

网络字节序

网络字节序是TCP/IP协议中规定的一种数据表示格式,它采用大端排序方式
小端法:高位存在高地址,低位存在低地址(计算机采用)
大端法:高位存在低地址,低位存在高地址(网络采用)
需要进行网络字节序和主机字节序的转换

uint32_t htonl(uint32_t hostlong);本地——>网络 IP
uint16_t htons(uint16_t hostshort);本地——>网络 端口
uint32_t ntohl(uint32_t netlong);网络——>本地 IP
uint16_t ntohs(uint16_t netshort);网络——>本地 端口
h:host n:network l:32位整型数 s:16位整型数

头文件:
	#include <arpa/inet.h>

IP地址转换

int inet_pton(int af, const char *src, void *dst);点八十进制(string)——>二进制(int)

参数:
	af:IP类型
		AF_INET:IPv4
		AF_INET6:IPv6
	src:IP地址(点分十进制)
	dst:传出参数 转换后的 网络字节序的 IP地址

返回:
	成功:1
	异常:0 说明src指向的不是一个有效的IP地址
	失败:-1
	
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);二进制(int)——>点八十进制(string)

参数:
	af:IP类型
		AF_INET:IPv4
		AF_INET6:IPv6
	src:IP地址(二进制)
	dst:传出参数 转换后的 本地字节序的 IP地址
	size:dst的大小

返回:
	成功:dst
	失败:NUL

socketaddr地址结构

struct sockaddr_in {
	sa_family_t    sin_family; /* address family: AF_INET */
	in_port_t      sin_port;   /* 端口的网络字节序 */
	struct in_addr sin_addr;   /* IP地址 */
};

/* Internet address. */
struct in_addr {
   uint32_t       s_addr;     /* 网络字节序 */
};

示例:
	struct socketadr_in addr;
	addr.sin_family = AF_INET;
	addr.sin_port = htons(9527);
	addr.sin_addr.s_addr = htonl(INADD_ANY);	取出系统中任意有效的IP地址,二进制类型
	bind(fd, (struct socketadr)&addr, size)

网络套接字函数

一个服务器和一个客户端通信,一共有 3个套接字(2个用于服务器和客户端之间的连接,1个用于客户端监听)

socket函数

创建套接字

int socket(int domain, int type, int protocol);

参数:
	domain:指定IP协议
		AF_INET		IPv4
		AF_INET6	IPv6
		AF_UNIX		本地套接字
	type:数据传输协议
		SOCK_STREAM		流式		TCP
		SOCK_DGRAM		报式		UDP
	protocol:选用协议的代表协议是什么
		通常:0

返回值:
	成功:新套接字所对应的文件描述符
	失败:-1 errno
	
头文件:
	#include <sys/socket.h>

示例:
	socket(AF_INET, SOCK_STREAM, 0)

bind函数

给socket绑定一个地址结构(IP+端口号)

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数:
	sockfd:socket函数返回值
	addr:地址结构
		struct socketadr_in addr;
		addr.sin_family = AF_INET;
		addr.sin_port = htons(9527);
		addr.sin_addr.s_addr = htonl(INADD_ANY);
	addrlen:地址结构大小
		sizeof(addr)

返回值:
	成功:0
	失败:-1 errno	

listen函数

设置同时与服务器建立连接的上线数(同时进行3次握手的客户端数量)

int listen(int sockfd, int backlog);
参数:
	sockfd:socket函数返回值
	backlog:上限数值。最大值为128
	
返回值:
	成功:0
	失败:-1 errno

accept函数

阻塞等待客户端建立连接。成功,返回一个与客户端成功建立连接的socket文件描述符(新的)

int accept(int socket, struct sockaddr *restrict address, socklen_t *restrict address_len);

参数:
	socket:socket函数返回值
	address:传出参数 成功与客户端成功建立连接的地址结构(IP+端口号)
	address_len:传入传出 地址结构大小

返回值:
	成功:能与服务器进行数据通信的socket的文件描述符
	失败:-1 errno

connect函数

与服务器建立连接

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数:
	sockfd:socket函数返回值
	addr:传入参数 服务器的地址结构
		struct sockaddr_in ser_addr;	服务器地址结构
		ser_addr.sin_family = AF_INET;
	    ser_addr.sin_port = htons(SERV_PORT);	和服务器设置的端口相同
	    inet_pton(AF_INET,"127.0.0.1",&ser_addr.sin_addr.s_addr);	主机字节序 转 网络字节序
	addrlen:服务器地址结构的长度

返回值:
	成功:0
	失败:-1 errno

如果不使用bind绑定客户端地址结构是可以的,采用“隐式绑定”

C/S模型的TCP通信结构

在这里插入图片描述

  • 服务器端 server.cpp
	#include <bits/stdc++.h>
	#include <unistd.h>
	#include <fcntl.h>
	#include <errno.h>
	#include <sys/socket.h>
	#include <netinet/in.h>
	#include <netinet/ip.h> 
	using namespace std;
	
	#define SERV_PORT 9527
	
	void sys_err(const char *str){
	    perror(str);
	    exit(1);
	}
	
	int main(int argc,char *argv[]){
	    
	    int fd,cfd;
	    struct sockaddr_in ser_addr,clit_addr;
	    socklen_t clit_addr_len;
	    const int bufferSize = 1024;
	    char buf[bufferSize];
	
	    ser_addr.sin_family=AF_INET;
	    ser_addr.sin_port=htons(SERV_PORT);
	    ser_addr.sin_addr.s_addr=htonl(INADDR_ANY);
	
	    fd = socket(AF_INET,SOCK_STREAM,0);
	    if(fd == -1){
	        sys_err("socket error");
	    }
	    bind(fd,(struct sockaddr*)&ser_addr,sizeof(ser_addr));
	    listen(fd,128);
	
	    clit_addr_len = sizeof(clit_addr);
	    cfd=accept(fd,(struct sockaddr*)&clit_addr,&clit_addr_len);
	    if(cfd == -1){
	        sys_err("accept error");
	    }
	
	    while(1){
	        int ret=read(cfd,&buf,sizeof(buf));
	        write(STDOUT_FILENO,&buf,ret);
	        for (ssize_t i = 0; i < ret; ++i) {
	            buf[i] = std::toupper(buf[i]);
	        }
	        write(cfd,&buf,ret);
	    }
	    close(fd);
	    close(cfd);
	    return 0;
	}
  • 客户端 client.cpp
	#include <bits/stdc++.h>
	#include <unistd.h>
	#include <fcntl.h>
	#include <errno.h>
	#include <sys/socket.h>
	#include <netinet/in.h>
	#include <netinet/ip.h> 
	#include <arpa/inet.h>
	
	using namespace std;
	
	#define SERV_PORT 9527
	
	
	void sys_err(const char *str){
	    perror(str);
	    exit(1);
	}
	
	int main(int argc,char *argv[]){
	    int cfd;
	    int ret;
	    struct sockaddr_in ser_addr;
	    const int bufferSize = 1024;
	    char buf[bufferSize];
	
	    ser_addr.sin_family=AF_INET;
	    ser_addr.sin_port=htons(SERV_PORT);
	    inet_pton(AF_INET,"127.0.0.1",&ser_addr.sin_addr.s_addr);
	
	    cfd = socket(AF_INET,SOCK_STREAM,0);
	
	    connect(cfd,(struct sockaddr*)&ser_addr,sizeof(ser_addr));
	    
	    while(1){
	        write(cfd,"hello",5);
	        ret=read(cfd,buf,bufferSize);
	        write(STDOUT_FILENO,buf,ret);
	    }
	    
	    return 0;
	}

UDP实现的C/S模型

recv()/send() 只能用于TCP通信。代替 read/write
recvfrom()/sendto() 用于UDP通信

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);

参数:
	sockfd:套接字
	buf:缓冲区
	len:缓冲区大小
	flags:0
	src_addr:对端地址结构
		(struct sockaddr *)&addr
	addrlen:地址结构大小

返回值:
	>0 接收数据字节数
	0 对端关闭
	-1 errno

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);

参数:
	sockfd:套接字
	buf:存储数据的缓冲区
	len:缓冲区大小
	flags:0
	dest_addr:对端地址结构
		(struct sockaddr *)&addr
	addrlen:地址结构大小

服务器端

lfd=socket(AF_INET,SOCK_DGRAM,0);
bind();
while(1){
	recvfrom()-sendto();
}
close();

客户端:

connfc=socket(AF_INET,SOCK_DGRAM,0);
sendto(服务器地址结构,地址结构大小);
recvfrom();
close();

本地套接字(domain)

使用C/S模型实现本地进程通信
对比网络编程TCP C/S模型,注意:

1int socket(int domain, int type, int protocol);
参数:
	domain
		AF_INET ——> AF_UNIX / AF_LOCAL
	type
		SOCK_STREAM / SOCK_DGRAM

2、绑定地址结构:sockaddr_in ——> sockaddr_un
	struct sockaddr_un {
	    sa_family_t sun_family;       /* AF_UNIX */
	    char        sun_path[108];    /* 带有路径的文件名 */
	};
	
	struct sockaddr_un serv_addr;
	serv_addr.sun_family=AF_UNIX;
	strcpy(serv_addr.sun_path,"server.socket")
	len=offsetof(struct sockaddr_un,serv_addr.sun_path)+strlen("server.socket")
	unlink("server.socket");
	bind(cfd,(struct sockaddr*)&serv_addr,len);

3bind()函数调用成功,会创建一个 socket。因此为保证bind成功,通常我们在 bind 之前,可以使用 unlink("server.socket")
	cfd=accept(lfd,(struct sockaddr*)&cli_addr,&len);
	
4、客户端:
	struct sockaddr_un cli_addr;
	cli_addr.sun_family=AF_UNIX;
	strcpy(cli_addr.sun_path,"client.socket")
	len=offsetof(struct sockaddr_un,cli_addr.sun_path)+strlen("client.socket")
	unlink("client.socket");
	bind(cfd,(struct sockaddr*)&cli_addr,len);
	
	struct sockaddr_un serv_addr;
	serv_addr.sun_family=AF_UNIX;
	strcpy(serv_addr.sun_path,"server.socket")
	len=offsetof(struct sockaddr_un,serv_addr.sun_path)+strlen("server.socket")
	connect(cfd,(struct sockaddr*)&serv_addr,len);

出错处理函数封装

高并发服务器

多进程并发服务器

 1. socket()	创建监听套接字 lfd
 2. bind()
 3. listen()
 4. while(1){
	cfd=accept();
	pid=fork();
	if(pid==0){		子进程
		close(lfd);		关闭用于建立连接的套接字
		read(cfd);
		write(cfd);
	} else if(pid>0){	父进程
		close(cfd);		关闭用于通信的套接字
		注册信号捕捉函数,在回调函数中回收子进程
		continus;	
	}
}

多线程并发服务器

 1. socket()	创建监听套接字 lfd
 2. bind()
 3. listen()
 4. while(1){
	cfd=accept();
	tid=pthread_create();
	close(cfd);		关闭用于通信的套接字
	pthread_detach(tid);	回收线程;也可以:创建线程——专门用于回收线程
}
 5. void *tfn(void *arg){
 	close(lfd);		关闭用于建立连接的套接字
	read();
	write();
 }

多路I/O转接服务器

不再由应用程序自己监听客户端连接,取而代之的是内核监听

select函数

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

参数:
	nfds:select监听的最大的那个文件描述符+1
	readfds:传入传出 读文件描述符监听集合
	writefds:传入传出 写文件描述符监听集合		通常:NULL
	exceptfds:传入传出 异常文件描述符监听集合	通常:NULL
	timeout:超时时长
		NULL:阻塞监听
		设置timeval:等待固定时间
		0:非阻塞监听,轮询

返回值:
	成功:监听的三种事件发生的总个数
	失败:-1 errno
	
void FD_ZERO(fd_set *set);置零
	fd_set rset;
	FD_ZERO(&fd_set);
	
void FD_SET(int fd, fd_set *set);将一个文件描述符添加入集合
	FD_SET(3,&fd_set);FD_SET(5,&fd_set);FD_SET(6,&fd_set);
	
void FD_CLR(int fd, fd_set *set);将一个文件描述符从集合中清除出去
	FD_CLR(3,&fd_set);
	
int  FD_ISSET(int fd, fd_set *set);判断是否在集合中

头文件:
	#include <sys/select.h>

select的优缺点:

缺点:

  • 监听上限受文件描述符大小限制,最大1024
  • 检测满足条件的id,需要自己添加业务逻辑提高效率(自定义处理数组)

优点:

  • 跨平台

突破1024文件描述符限制:

  • cat /proc/sys/fs/file-max 一个文件可以打开的socket描述符上限——>受硬件限制
  • ulimit -a 查询当前进程默认打开文件描述符个数
select实现多路I/O转接
思路分析:
	lfd=socket();			
	bind();			
	listen();		
	fd_set rset,allset;
	FD_ZERO(&allset);
	FD_SET(lfd,&allset);
	while(1){
		rset=allset;	保存监听集合
		ret=select(lfd+1,&rest,NULL,NULL,NULL);
		if(ret>0){		有满足监听的描述符
			if(FD_ISSET(lfd,&rset)){	10 不在
				cfd=accept();	建立连接,返回用于通讯的文件描述符
				FD_SET(cfd,&allset);	添加到监听集合中
			}
			for(i=lfd+1;i<=最大文件描述符;i++){
				FD_ISSET(i,&rset);		有read、write事件
				read();
				...
				write();
			}
		}
	}

实现:

	#include <bits/stdc++.h>
	#include <unistd.h>
	#include <fcntl.h>
	#include <errno.h>
	#include <sys/socket.h>
	#include <sys/select.h>
	#include <netinet/in.h>
	using namespace std;
	
	#define SERV_PORT 6666
	
	void sys_err(const char *str){
	    perror(str);
	    exit(1);
	}
	
	int main(int argc,char *argv[]){
	    int i,j,n,nready;
	
	    int maxfd=0;
	
	    int listenfd,connfd;
	
	    char buf[BUFSIZ];
	
	    struct sockaddr_in clie_addr,serv_addr;
	    socklen_t clie_addr_len;
	
	    listenfd=socket(AF_INET,SOCK_STREAM,0);
	    //端口复用
	    int opt=1;
	    setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
	
	    bzero(&serv_addr,sizeof(serv_addr));
	    serv_addr.sin_family=AF_INET;
	    serv_addr.sin_port=htons(SERV_PORT);
	    serv_addr.sin_addr.s_addr=htonl(INADDR_ANY);
	
	    bind(listenfd,(struct sockaddr*)&serv_addr,sizeof(serv_addr));
	
	    listen(listenfd,128);
	
	    fd_set rset,allset;
	    maxfd=listenfd;
	
	    FD_ZERO(&allset);
	    FD_SET(listenfd,&allset);
	    
	    while(1){       //每次循环重新设置select监控集
	        rset=allset;
	        nready=select(maxfd+1,&rset,NULL,NULL,NULL);
	        if(nready<0)
	            sys_err("select error");
	
	        if(FD_ISSET(listenfd,&rset)){   //说明有新的客户端连接请求
	            clie_addr_len=sizeof(clie_addr);
	            connfd=accept(listenfd,(struct sockaddr*)&clie_addr,&clie_addr_len);
	
	            FD_SET(connfd,&allset);
	            if(maxfd < connfd)
	                maxfd=connfd;
	            
	            if(--nready == 0)   //表示只有listenfd一个描述符,不用执行for了
	                continue;
	            
	        }
	        for(i=listenfd+1;i<=maxfd;i++){     //说明有其他数据请求
	            if(FD_ISSET(i,&rset)){
	                if((n=read(i,buf,sizeof(buf)))== 0){ //n==0 表示客户端关闭,服务器端也随之关闭
	                    close(i);
	                    FD_CLR(i,&allset);      //解除对该描述符的监听
	                }else if (n > 0)
	                {
	                    for (ssize_t i = 0; i < n; ++i) {
	                        buf[i] = std::toupper(buf[i]);
	                    }
	                    write(i,&buf,n);
	                }
	                
	            }
	        }
	    }
	    
	    return 0;
	}

epoll函数

能显著提高程序在大量并发连接中只有少量活跃的情况下系统CPU的利用率

int epoll_create(int size);	创建红黑树
参数:
	size:创建的红黑树监听节点数(仅供内核参考)

返回值:
	成功:指向新创建的红黑树的根节点
	失败:-1,errno
	
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);	操作红黑树
参数:
	epfd:epoll_create的返回值
	op:对该监听红黑树所作的操作
		EPOLL_CTL_ADD:添加fd到红黑树
		EPOLL_CTL_MOD:修改fd在红黑树上的监听事件
		EPOLL_CTL_DEL:从红黑树上删除fd(取消监听)
	fd:待监听的fd
	event:本质是struct epoll_event结构体	需要epoll监视的fd对应的事件类型
	     struct epoll_event {
	         uint32_t events;      /* Epoll events */
	         epoll_data_t data;    /* User data variable */
	     };
	     uint32_t events:
	     	EPOLLIN:读事件
			EPOLLOUT:写事件
			EPOLLERR:异常事件
			EPOLLET:ET模式
	     成员 epoll_data_t :联合体(共用体) {
		      void *ptr;		
		      int  fd;		对应监听事件的fd
		      uint32_t u32;	
		      uint64_t u64;	
	    }
 
返回值:
	成功:0
	失败:-1,errno

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);阻塞监听

参数:
	epfd:epoll_create的返回值
	events:传出参数 满足监听条件的fd集合,类似于【数组】
	maxevents:数组元素的总个数
	timeout:超时时长
		-1 阻塞
		0 非阻塞
		>0 设置时间(ms)

返回值:
	成功:
		>0 满足监听的总个数,可以用作循环下上限
		0 没有满足的fd
	失败:-1,errno

头文件:
	#include <sys/epoll.h>

epoll实现多路IO转接
	lfd=socket();							监听连接事件lfd
	bind();
	listen();
	int epfd=epoll_create(1024);			监听红黑树根节点epfd
	struct epoll_event tep,ep[1024];		tep:用来设置单个fd属性
	tep.events=EPOLLIN;						初始化lfd的监听属性
	tep.data.fd=lfd;						
	epoll_ctl(epfd,EPOLL_CTL_ADD,lfd,&tep)		将lfd添加到红黑树上
	while(){
		int ret=epoll_wait(epfd,ep,1024,-1)		实时监听
		for(i=0;i<ret;i++){
			if(ep[i].data.fd == lfd){		lfd满足读事件,有新的客户端连接请求
				cfd=accept();
				tep.events=EPOLLIN;						初始化cfd的监听属性
				tep.data.fd=cfd;
				epoll_ctl(epfd,EPOLL_CTL_ADD,cfd,&tep);
			}else{										cfd们满足读事件,有客户端写数据
				n=read();
				if(n==0){								客户端关闭,关闭服务器连接
					close(ep[i].data.fd);
					epoll_ctl(epfd,EPOLL_CTL_DEL,ep[i].data.fd,NULL);	将关闭的cfd从红黑树摘下
				}else if(n>0){
					对应操作
					write(ep[i].data.fd,buf,n);
				}
			}
		}
	}
	

epoll进阶

事件模型

EPOLL事件有两种模型:。

  • Edge Triggered(ET):边缘触发,只有数据到来才触发,不管缓存区中是否还有数据Level
  • Triggered(LT):水平触发,只要有数据都会触发。——默认采用的模式

ET

epoll的ET模式是 高速模式,但是只支持 非阻塞模式

struct epoll_event event;
event.events=EPOLLIN | EPOLLET;

flag=fcnt(connfd,F_GETFL);		修改connfd为非阻塞读
flag|=O_NONBLOCK;
fcnt(connfd,F_SETFL,flag);

event.data.fd=connfd;

LT

struct epoll_event event;
event.events=EPOLLIN;

epoll优缺点

优点:高效、能突破1024文件描述符
缺点:不能跨平台。Linux

epoll反应堆模型

epoll ET模式 + 非阻塞 + void *ptr

原来:socket、bind、listen —— epoll_create() 创建监听红黑树 —— 返回 epfd ——epoll_ctl() 添加一个监听fd —— epoll_wait() 阻塞监听 —— 对应监听描述符有事件产生 —— 返回 监听满足数组 —— 判断返回数组元素 —— lfd满足 —— accept() —— cfd满足 —— read() —— 小写转大写 —— write()回去

epoll反应堆:不但要监听cfd的读事件,还要监听cfd的写事件

socket、bind、listen —— epoll_create() 创建监听红黑树 —— 返回 epfd ——epoll_ctl() 添加一个监听fd —— epoll_wait() 阻塞监听 —— 对应监听描述符有事件产生 —— 返回 监听满足数组 —— 判断返回数组元素 —— lfd满足 —— accept() —— cfd满足 —— read() —— 小写转大写 ——cfd 从监听红黑树摘下 —— EPOLLOUT —— 回调函数 —— epoll_ctl() —— EPOLL_CTL_ADD 重新放到监听红黑树上监听写事件 —— 等待epoll_wait() 返回 —— 说明cfd可写 —— write()回去 —— cfd 从监听红黑树摘下 —— EPOLLIN —— epoll_ctl() —— EPOLL_CTL_ADD 重新放到监听红黑树上监听读事件 —— epoll_wait() 阻塞监听

线程池

线程池模块分析:
1、main()
创建线程池
向线程池添加任务,回调函数处理任务
销毁线程池
2、pthreadpool_create()
创建线程池结构体指针
初始化线程池结构体
创建N个任务线程
创建1个管理者线程
失败时,释放开辟的空间
3、threadpool_thread()
进入子线程回调函数
接收参数 void* arg——》pool 线程池
加锁——》线程池结构体锁
判断条件变量——》wait
4、管理者线程
循环10s执行一次
进入管理者线程回调函数
接收参数 void* arg——》pool 线程池
加锁——》线程池结构体锁
获取管理线程池要用到的变量:live_num busy_num task_num
根据既定算法使用三变量,判断是否应该创建、销毁线程池中的 指定步长的 线程
5、threadpool_add()
加锁——》线程池结构体锁
模拟产生任务process,使用回调函数处理任务
使用process的 回调函数 和 参数 初始化任务队列结构体成员
利用环形队列,实现添加任务。借助队尾指针挪移 % 实现
唤醒阻塞在条件变量上的线程
解锁——》线程池结构体锁
6、从3、wait之后处理任务
加锁——》线程池结构体锁
获取任务处理回调函数和参数
利用环形队列,实现处理任务。借助队头指针挪移 % 实现
唤醒阻塞在条件变量上的server
解锁——》线程池结构体锁
加锁——》记录忙状态线程个数的锁
改忙线程数++
解锁——》记录忙状态线程个数的锁
执行处理任务的线程
加锁——》记录忙状态线程个数的锁
改忙线程数–
解锁——》记录忙状态线程个数的锁
7、创建、销毁线程
管理者线程根据 task_num live_num,busy_num
根据既定算法,使用上述3变量,判断是否应该 创建、销毁线程池中 指定步长的线程。
如果满足 创建条件
pthread_create(),回调 任务线程函数。 live_num++
如果满足 销毁条件
wait_exit_thr_num=10;
signal给阻塞在条件变量上的线程,发送 假条件满足信号
跳转至 wait 阻塞线程会被 假信号 唤醒。wait_exit_thr_num>0 执行 pthread_exit()

  • 30
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

一往而情深

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值