C++网络编程学习:升级为select网络模型

网络编程学习记录

  • 使用的语言为C/C++
  • 源码支持的平台为:Windows

笔记一:建立基础TCP服务端/客户端  点我跳转
笔记二:网络数据报文的收发  点我跳转
笔记三:升级为select网络模型  点我跳转
笔记四:跨平台支持Windows、Linux系统  点我跳转
笔记五:源码的封装  点我跳转
笔记六:缓冲区溢出与粘包分包  点我跳转
笔记七:服务端多线程分离业务处理高负载  点我跳转
笔记八:对socket select网络模型的优化  点我跳转
笔记九:消息接收与发送分离  点我跳转
笔记十:项目化 (加入内存池静态库 / 报文动态库)  更多笔记请点我


一、为何要使用select网络模型?

  通过前面的学习,已经实现了简单的网络报文收发。但是可以很明显的看出其中的缺点,那就是整个程序的运行是阻塞模式的。即服务端在与一个客户端进行socket连接时,只要连接不中断,那么就无法接收新的客户端的消息。而客户端在未输入命令时,是阻塞状态,也无法接收服务端发来的消息。
  在之前碰到这个问题时,我的想法是通过多线程来解决程序运行中的阻塞问题,但是在最近的学习中,我了解到可以使用select网络模型来方便快捷的解决小型网络程序运行中的阻塞问题。(I/O多路复用模型相关内容)

二、select系统及其相关

select函数如下:

	WINSOCK_API_LINKAGE int WSAAPI select(
		int nfds,//是指待监听集合里的范围 即待监听数量最大值+1
		fd_set *readfds,//待监听的可读文件集合 
		fd_set *writefds,//待监听的可写文件集合  
		fd_set *exceptfds,//待监听的异常文件集合  
		const PTIMEVAL timeout);//超时设置 传入NULL为阻塞模式 传入timeval结构体为非阻塞模式

	返回值为满足条件的待监听socket数量和,如果出错返回-1,如果超时返回0

通过上面select函数的参数可以发现存在两个特殊的结构体 fd_settimeval,其相关内容如下:

	typedef struct fd_set//可以存放多个socket 
	{
		u_int	fd_count;//记录放了多少个socket
		SOCKET	fd_array[FD_SETSIZE];//socket数组
	} fd_set;
	
	struct timeval//时间结构体 
	{
		long tv_sec;//秒
		long tv_usec;//毫秒
	};

接下来为select的相关函数

void FD_SET(int fd, fd_set *set);//将fd加入set集合
void FD_ZERO(fd_set *set);//使set集合清零 不包含任何socket
void FD_CLR(int fd, fd_set *set);//将fd从set集合中清除
int  FD_ISSET(int fd, fd_set *set);//测试fd是否在集合中 0是不在 1是在

★ select相关使用总结与心得

  在一开始的select使用中,我以为向select函数中传入fd_set地址,select会把待处理事件的socket放在set集合中,但是发现并不是这样。
  经过网络上资料的查询以及我个人的测试,可以发现,用户首先需要把一份socket数组传入到此set中,select函数的作用是移除该set中没有待处理事件的socket,则剩下的socket都存在待处理事件(未决I/O操作)。这个过程可以说是一种“选择”的过程,select函数“选择”出需要操作的socket,这或许就是select(选择)的意思吧。
  在接下来的源码中,对于需要存储所有已连接socket的服务端,我使用动态数组vector进行socket的储存。在进行select筛选前,先把vector中的socket导入到set中,随后set中筛选剩下的即为有待处理事件的socket。
  如果服务端自己的socket提示有待处理事件,则说明有新的客户端尝试进行连接,此时进行accept操作即可。
  对于客户端的多线程问题,需要注意使用detach()方法使主线程与新线程分类,否则可能会出现主线程先结束的情况,导致程序出错。
  在线程中,我们可以引入一个bool变量,用来记录客户端是否仍在连接中,当输入exit命令退出客户端时,通过此bool变量使主线程停止,跳出循环。

三、升级为select网络模型的思路

1.服务端升级(select)

在之前,我们的思路是:

1.建立socket
2.绑定端口IP
3.监听端口
4.与客户端连接
while(true)
{
	5.接收数据
	6.发送数据
}
7.关闭socket

  这就导致我们只能与一个客户端进行连接,随后便进入循环,只能接收这一个客户端的消息。且由于send与recv函数都是阻塞函数,所以程序也是阻塞模式的。


接下来,我们需要根据select网络模型,对服务端进行升级。
思路大致如下:

1.建立socket
2.绑定端口IP
3.监听端口
while(true)
{
	4.使用select函数获取存在待监听事件的socket
	5.如果有新的连接则与新的客户端连接
	6.如果有待监听事件,则对其进行处理(接受与发送)
}
7.关闭socket

  按如上思路,即可将程序升级为select网络模型。实现非阻塞模式,可以实现多客户端信息接收。对于select相关的细节与总结,请看上文中的总结。相关代码在下文。

2.客户端升级(select+多线程)

在之前,我们的思路是:

1.建立socket
2.连接服务器
while(true)
{
	3.发送数据
	4.接收数据
}
5.关闭socket

  这就导致我们在与一个服务端连接后,无法被动的接收服务器端发来的消息。因为send与recv函数都是阻塞函数,程序也为阻塞模式。如果我们想要客户端能接收服务端发来的消息,那么就可以使用select模型。


接下来,我们需要根据select网络模型,对客户端进行升级。
思路大致如下:

1.建立socket
2.连接服务器
while(true)
{
	3.使用select函数获取服务器端是否有待处理事件
	4.如果有,就处理它(接收/发送)
}
5.关闭socket

  按如上思路,即可将程序升级为select网络模型。实现非阻塞模式,可以实现服务器端数据的被动接收。


但是,这样的程序结构也有很明显的缺点,因为scanf等数据接收函数也为阻塞函数,如果我们想要主动输入一些命令发送给服务端,就会阻塞程序运行。对此,我们可以引入多线程解决问题。
思路大致如下:

1.建立socket
2.连接服务器
3.建立新线程 用于发送命令
while(true)
{
	4.使用select函数获取服务器端是否有待处理事件
	5.如果有,就处理它(接收/发送)
}
5.关闭socket

新线程:
while(1)
{
	1.键入数据
	2.发送数据
}

  按如上思路,即可将程序变得更加完善。可以被动接受数据且可以主动向服务端发送键入命令。对于select相关的细节与总结以及线程方面的注意事项,请看上文中的总结。相关代码在下文。

四、代码及其详细注释

1.服务端代码

#define WIN32_LEAN_AND_MEAN

#include<winSock2.h>
#include<windows.h>
#include<bits/stdc++.h>

#pragma comment(lib,"ws2_32.lib")//链接此动态链接库 windows特有 

using namespace std; 
 
//枚举类型记录命令 
enum cmd 
{
	CMD_LOGIN,//登录 
	CMD_LOGINRESULT,//登录结果 
	CMD_LOGOUT,//登出 
	CMD_LOGOUTRESULT,//登出结果 
	CMD_NEW_USER_JOIN,//新用户登入 
	CMD_ERROR//错误 
};
//定义数据包头 
struct DateHeader 
{
	short cmd;//命令
	short date_length;//数据的长短	
};
//包1 登录 传输账号与密码
struct Login : public DateHeader 
{
	Login()//初始化包头 
	{
		this->cmd = CMD_LOGIN;
		this->date_length = sizeof(Login); 
	}
	char UserName[32];//用户名 
	char PassWord[32];//密码 
};
//包2 登录结果 传输结果
struct LoginResult : public DateHeader 
{
	LoginResult()//初始化包头 
	{
		this->cmd = CMD_LOGINRESULT;
		this->date_length = sizeof(LoginResult); 
	}
	int Result;
};
//包3 登出 传输用户名 
struct Logout : public DateHeader 
{
	Logout()//初始化包头 
	{
		this->cmd = CMD_LOGOUT;
		this->date_length = sizeof(Logout); 
	}
	char UserName[32];//用户名 
};
//包4 登出结果 传输结果
struct LogoutResult : public DateHeader 
{
	LogoutResult()//初始化包头 
	{
		this->cmd = CMD_LOGOUTRESULT;
		this->date_length = sizeof(LogoutResult); 
	}
	int Result;
};
//包5 新用户登入 传输通告 
struct NewUserJoin : public DateHeader 
{
	NewUserJoin()//初始化包头 
	{
		this->cmd = CMD_NEW_USER_JOIN;
		this->date_length = sizeof(NewUserJoin); 
	}
	char UserName[32];//用户名 
};
 
vector<SOCKET> _clients;//储存客户端socket 
 
int _handle(SOCKET _temp_socket)//处理数据 
{
	//接收客户端发送的数据 
	DateHeader _head = {}; 
	int _buf_len = recv(_temp_socket,(char*)&_head,sizeof(DateHeader),0);
	if(_buf_len<=0)
	{
		printf("客户端已退出\n");
		return -1;
	} 
	printf("接收到包头,命令:%d,数据长度:%d\n",_head.cmd,_head.date_length);
	switch(_head.cmd)
	{
		case CMD_LOGIN://登录 接收登录包体 
		{
			Login _login;
			recv(_temp_socket,(char*)&_login+sizeof(DateHeader),sizeof(Login)-sizeof(DateHeader),0);
			/*
			进行判断操作 
			*/
			printf("%s已登录\n密码:%s\n",_login.UserName,_login.PassWord); 
			LoginResult _result;	
			_result.Result = 1;
			send(_temp_socket,(char*)&_result,sizeof(LoginResult),0);//发包体 
		}
		break;
		case CMD_LOGOUT://登出 接收登出包体 
		{
			Logout _logout;
			recv(_temp_socket,(char*)&_logout+sizeof(DateHeader),sizeof(Logout)-sizeof(DateHeader),0);
			/*
			进行判断操作 
			*/
			printf("%s已登出\n",_logout.UserName); 
			LogoutResult _result;
			_result.Result = 1;
			send(_temp_socket,(char*)&_result,sizeof(LogoutResult),0);//发包体
		}
		break;
		default://错误 
		{
			_head.cmd = CMD_ERROR; 
			_head.date_length = 0; 
			send(_temp_socket,(char*)&_head,sizeof(DateHeader),0);//发包头 
		}
		break;
	}
	return 0;	
}
 
int main() 
{
	//启动windows socket 2,x环境 windows特有 
	WORD ver = MAKEWORD(2,2);//WinSock库版本号 
	WSADATA dat;//网络结构体 储存WSAStartup函数调用后返回的Socket数据 
	if(0 != WSAStartup(ver,&dat))//正确初始化后返回0 
	{
		return 0;
	}
	
	//建立一个socket 
	SOCKET _mysocket = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);//IPV4 数据流类型 TCP类型 
	if(INVALID_SOCKET == _mysocket)//建立失败 
    {   
        return 0;  
    } 
    
	//绑定网络端口和IP地址 
	sockaddr_in _myaddr = {};//建立sockaddr结构体  sockaddr_in结构体方便填写 但是下面要进行类型转换 
	_myaddr.sin_family = AF_INET;//IPV4
	_myaddr.sin_port = htons(8888);//端口 host to net unsigned short
	_myaddr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");//网络地址 INADDR_ANY监听所有网卡的端口 
	if(SOCKET_ERROR == bind(_mysocket,(sockaddr*)&_myaddr,sizeof(sockaddr_in)))//socket (强制转换)sockaddr结构体 结构体大小 
	{
		cout<<"绑定不成功"<<endl;
	}
	else
	{
		//cout<<"绑定成功"<<endl; 
	}
	
	//监听网络端口
	if(SOCKET_ERROR == listen(_mysocket,5))//套接字 最大多少人连接 
	{
		cout<<"监听失败"<<endl;
	}
	else
	{
		//cout<<"监听成功"<<endl; 
	}
	
	while(true)
	{
		//select相关 
		/*
		WINSOCK_API_LINKAGE int WSAAPI select(
		int nfds,//是指待监听集合里的范围 即待监听数量最大值+1
		fd_set *readfds,//待监听的可读文件集合 
		fd_set *writefds,//待监听的可写文件集合  
		fd_set *exceptfds,//待监听的异常文件集合  
		const PTIMEVAL timeout);//超时设置 传入NULL为阻塞模式 传入timeval结构体为非阻塞模式
		
		typedef struct fd_set//可以存放多个socket 
		{
			u_int	fd_count;//记录放了多少个socket
			SOCKET	fd_array[FD_SETSIZE];//socket数组
		} fd_set;
		
		struct timeval//时间结构体 
		{
			long tv_sec;//秒
			long tv_usec;//毫秒
		};
		*/
		fd_set _fdRead;//建立集合 
		fd_set _fdWrite;
		fd_set _fdExcept;
		FD_ZERO(&_fdRead);//清空集合 
		FD_ZERO(&_fdWrite); 
		FD_ZERO(&_fdExcept); 
		FD_SET(_mysocket,&_fdRead);//放入集合 
		FD_SET(_mysocket,&_fdWrite); 
		FD_SET(_mysocket,&_fdExcept);
		timeval _t = {1,0};//select最大响应时间 
		
		for(int n=_clients.size()-1; n>=0; --n)//把连接的客户端 放入read集合 
		{
			FD_SET(_clients[n],&_fdRead);
		}
		//select函数筛选select 
		int _ret = select(_mysocket+1,&_fdRead,&_fdWrite,&_fdExcept,&_t); 
		if(_ret<0)
		{
			printf("select任务结束\n");
			break;
		}
		if(FD_ISSET(_mysocket,&_fdRead))//获取是否有新socket连接 
		{
			FD_CLR(_mysocket,&_fdRead);//清理
			//等待接收客户端连接
			sockaddr_in _clientAddr = {};//新建sockadd结构体接收客户端数据 
			int _addr_len = sizeof(sockaddr_in);//获取sockadd结构体长度 
			SOCKET _temp_socket = INVALID_SOCKET;//声明客户端套接字 
			
			_temp_socket = accept(_mysocket,(sockaddr*)&_clientAddr,&_addr_len);//自身套接字 客户端结构体 结构体大小 
			if(INVALID_SOCKET == _temp_socket)//接收失败 
			{
				cout<<"接收到无效客户端Socket"<<endl;
			}
			else
			{
				cout<<"新客户端加入"<<endl; 
				printf("IP地址为:%s \n", inet_ntoa(_clientAddr.sin_addr));  
				//群发所有客户端 通知新用户登录 
				NewUserJoin _user_join; 
				strcpy(_user_join.UserName,inet_ntoa(_clientAddr.sin_addr));
				for(int n=0;n<_clients.size();n++)
				{
					send(_clients[n],(const char*)&_user_join,sizeof(NewUserJoin),0);	
				} 
				//将新的客户端加入动态数组
				_clients.push_back(_temp_socket); 
			} 
		}
		for(int n=0; n<_fdRead.fd_count; ++n)//在read数组里挨个处理 
		{
			if(-1 == _handle(_fdRead.fd_array[n]))//处理请求 客户端退出的话 
			{
				vector<SOCKET>::iterator iter = find(_clients.begin(),_clients.end(),_fdRead.fd_array[n]);
				if(iter != _clients.end())//如果找到了的话 就在动态数组里删除掉 
				{
					_clients.erase(iter);
				}
			}
		}
		printf("空闲时间处理其他业务\n");
	}
	 
	//关闭客户端socket
	for(int n=0; n<_clients.size(); ++n)
	{
		closesocket(_clients[n]);
	}

	//关闭socket 
	closesocket(_mysocket); 
	
	//清除windows socket 环境 
	WSACleanup();
	
	printf("任务结束,程序已退出"); 
	 
	getchar(); 
	
	return 0;
}

2.客户端代码

#define WIN32_LEAN_AND_MEAN

#include<winSock2.h>
#include<windows.h>
#include<bits/stdc++.h>
#include<thread>

#pragma comment(lib,"ws2_32.lib")//链接此动态链接库 windows特有 

using namespace std; 
 
//枚举类型记录命令 
enum cmd 
{
	CMD_LOGIN,//登录 
	CMD_LOGINRESULT,//登录结果 
	CMD_LOGOUT,//登出 
	CMD_LOGOUTRESULT,//登出结果 
	CMD_NEW_USER_JOIN,//新用户登入 
	CMD_ERROR//错误 
};
//定义数据包头 
struct DateHeader 
{
	short cmd;//命令
	short date_length;//数据的长短	
};
//包1 登录 传输账号与密码
struct Login : public DateHeader 
{
	Login()//初始化包头 
	{
		this->cmd = CMD_LOGIN;
		this->date_length = sizeof(Login); 
	}
	char UserName[32];//用户名 
	char PassWord[32];//密码 
};
//包2 登录结果 传输结果
struct LoginResult : public DateHeader 
{
	LoginResult()//初始化包头 
	{
		this->cmd = CMD_LOGINRESULT;
		this->date_length = sizeof(LoginResult); 
	}
	int Result;
};
//包3 登出 传输用户名 
struct Logout : public DateHeader 
{
	Logout()//初始化包头 
	{
		this->cmd = CMD_LOGOUT;
		this->date_length = sizeof(Logout); 
	}
	char UserName[32];//用户名 
};
//包4 登出结果 传输结果
struct LogoutResult : public DateHeader 
{
	LogoutResult()//初始化包头 
	{
		this->cmd = CMD_LOGOUTRESULT;
		this->date_length = sizeof(LogoutResult); 
	}
	int Result;
};
//包5 新用户登入 传输通告 
struct NewUserJoin : public DateHeader 
{
	NewUserJoin()//初始化包头 
	{
		this->cmd = CMD_NEW_USER_JOIN;
		this->date_length = sizeof(NewUserJoin); 
	}
	char UserName[32];//用户名 
};
 
int _handle(SOCKET _temp_socket)//处理数据 
{
	//接收客户端发送的数据 
	DateHeader _head = {}; 
	int _buf_len = recv(_temp_socket,(char*)&_head,sizeof(DateHeader),0);
	if(_buf_len<=0)
	{
		printf("与服务器断开连接,任务结束\n");
		return -1;
	} 
	printf("接收到包头,命令:%d,数据长度:%d\n",_head.cmd,_head.date_length);
	switch(_head.cmd)
	{
		case CMD_LOGINRESULT://登录结果 接收登录包体 
		{
			LoginResult _result; 
			recv(_temp_socket,(char*)&_result+sizeof(DateHeader),sizeof(LoginResult)-sizeof(DateHeader),0);
			printf("登录结果:%d\n",_result.Result);
		}
		break;
		case CMD_LOGOUTRESULT://登出结果 接收登出包体 
		{
			LogoutResult _result; 
			recv(_temp_socket,(char*)&_result+sizeof(DateHeader),sizeof(LogoutResult)-sizeof(DateHeader),0);
			printf("登录结果:%d\n",_result.Result);
		}
		break;
		case CMD_NEW_USER_JOIN://新用户登录通知 
		{
			NewUserJoin _result; 
			recv(_temp_socket,(char*)&_result+sizeof(DateHeader),sizeof(NewUserJoin)-sizeof(DateHeader),0);
			printf("用户:%s已登录\n",_result.UserName);
		} 
	}
	return 0;	
}

bool _run = true;//当前程序是否还在运行中 
void _cmdThread(SOCKET _mysocket)//命令线程 
{
	while(true)
	{
		//输入请求 
		char _msg[256] = {};
		scanf("%s",_msg);
		//处理请求 
		if(0 == strcmp(_msg,"exit"))
		{
			_run = false; 
			printf("程序退出\n"); 
			break;
		}
		else if(0 == strcmp(_msg,"login"))
		{
			//发送 
			Login _login;
			strcpy(_login.UserName,"河边小咸鱼");
			strcpy(_login.PassWord,"123456");
			send(_mysocket,(const char*)&_login,sizeof(_login),0);
			//这里就不用接收了 由select用来检测接收
		}
		else if(0 == strcmp(_msg,"logout"))
		{
			//发送 
			Logout _logout;
			strcpy(_logout.UserName,"河边小咸鱼");
			send(_mysocket,(const char*)&_logout,sizeof(_logout),0);
			//这里就不用接收了 由select用来检测接收
		}
		else
		{
			printf("不存在的命令\n");
		} 
	}
}

int main()
{
	//启动windows socket 2,x环境 windows特有 
	WORD ver = MAKEWORD(2,2);//WinSock库版本号 
	WSADATA dat;//网络结构体 储存WSAStartup函数调用后返回的Socket数据 
	if(0 != WSAStartup(ver,&dat))//正确初始化后返回0 
	{
		return 0;
	}
	
	//建立一个socket 
	SOCKET _mysocket = socket(AF_INET,SOCK_STREAM,0);//IPV4 数据流类型 类型可以不用写 
	if(INVALID_SOCKET == _mysocket)//建立失败 
    {   
        return 0;  
    } 
    
    //连接服务器
    sockaddr_in _sin = {};//sockaddr结构体 
    _sin.sin_family = AF_INET;//IPV4
    _sin.sin_port = htons(8888);//想要连接的端口号 
	_sin.sin_addr.S_un.S_addr =  inet_addr("127.0.0.1");//想要连接的IP 
	if(SOCKET_ERROR == connect(_mysocket,(sockaddr*)&_sin,sizeof(sockaddr_in)))
	{
		cout<<"连接失败"<<endl;
		closesocket(_mysocket);
	}
	else
	{
		cout<<"连接成功"<<endl; 
	}
	
	//创建新线程
	thread t1(_cmdThread,_mysocket);
	t1.detach();//线程分离 
	
	while(_run)
	{
		fd_set _fdRead;//建立集合 
		FD_ZERO(&_fdRead);//清空集合  
		FD_SET(_mysocket,&_fdRead);//放入集合 
		timeval _t = {1,0};//select最大响应时间 
		//新建seclect 
		int _ret = select(_mysocket+1,&_fdRead,NULL,NULL,&_t);
		if(_ret<0)
		{
			printf("seclect任务结束\n");
			break; 
		}
		if(FD_ISSET(_mysocket,&_fdRead))//获取是否有可读socket 
		{
			FD_CLR(_mysocket,&_fdRead);//清理计数器 
			if(-1 == _handle(_mysocket))
			{
				printf("seclect任务结束\n");
				break; 
			}
		}
	}

	//关闭socket
	closesocket(_mysocket); 
	
	//清除windows socket 环境 
	WSACleanup(); 
	
	return 0;
} 
  • 3
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
当谈到C++网络编程面试题时,以下是一些常见的问题和答案: 1. 什么是套接字(Socket)? 套接字是一种用于网络通信的编程接口,它提供了一种机制,使得不同计算机上的进程可以通过网络进行通信。 2. TCP和UDP有什么区别? TCP(传输控制协议)是一种可靠的、面向连接的协议,它提供了数据传输的保证和错误检测机制。UDP(用户数据报协议)是一种不可靠的、无连接的协议,它提供了一种简单的数据传输机制。 3. 什么是阻塞和非阻塞IO? 阻塞IO是指当一个IO操作发生时,程序会一直等待直到操作完成。非阻塞IO是指当一个IO操作发生时,程序会立即返回并继续执行其他任务,而不会等待操作完成。 4. 什么是同步和异步IO? 同步IO是指当一个IO操作发生时,程序会一直等待直到操作完成,并且在操作完成后立即处理结果。异步IO是指当一个IO操作发生时,程序会立即返回并继续执行其他任务,而在操作完成后再处理结果。 5. 什么是select函数? select函数是一种多路复用IO模型,它可以同时监视多个文件描述符的可读、可写和异常事件,并在有事件发生时通知程序进行处理。 6. 什么是IO多路复用? IO多路复用是一种同时监视多个IO事件的机制,它可以通过一种或多种方式(如select、poll、epoll等)来实现。 7. 什么是TCP粘包问题? TCP粘包问题是指在TCP传输过程中,由于数据的发送和接收速度不一致,导致接收方无法正确解析出发送方发送的数据包。 8. 如何解决TCP粘包问题? 解决TCP粘包问题的方法有多种,常见的方法包括使用固定长度的消息、使用特殊字符作为消息的分隔符、在消息头部添加消息长度等。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值