此文章来自于EasyMouse项目实践中的设计框架,EasyMouse是一款安卓无线鼠标、键盘软件,可以让你的手机当做鼠标和键盘,远程操作电脑。
欢迎下载使用: EasyMouse官网!
下一篇《select多路复用 : EasyMouse的通道模式设计(下)》
概述
当我们需要同时监听多个IO的时候,就需要用到多路复用技术,它可以同时监听文件描述符,网络套接字。常用的多路复用技术有 select, poll, epoll, 他们的功能都差不多,只是内部原理、使用接口和场景不一样而已罢了。这里不做区分介绍,因为EasyMouse的通道设计模式,可以用于任何其中一种技术,这里使用select来做介绍。
通过EasyMouse的通道设计模式,你可以简化程序设计的复杂度,优化程序的逻辑和框架,轻松实现对多并发连接的管理,认证登陆,文件传送,TCP和UDP同时监听等复杂的逻辑。而不会增加程序设计的复杂度,下面逐一介绍设计的各个部分;
通道链表
EasyMouse只采用单个链表来管理所有的连接,而没有针对不同的连接(如监听链接,数据链接等)创建不同的链表,这样就减小了链表维护的复杂度。为此我们对每个连接进行了上一层封装(这里称为通道),通过通道我们保存更多链接的信息,方便管理的同时优化了程序的设计框架。通道链表保存的即是每个连接的封装,而不是原始的套接字信息。如:
typedef struct __channel{
int socket; // 通道链接套接字
int type; // 通道的类型,见下面
sockaddr_in addr; // 终端地址
string ip; // 终端的IP地址:点数字格式
pFunc pfunc; // 通道处理回调函数
pthread_t pid; // 通道处理线程ID(与pfunc互斥)
/****下面内容为通道非核心字段,根据实际情况添加即可 ***/
pDev pdev; // 终端信息
time_t ctime; // 通道创建的时间
}Channel, *pChannel;
说明:
pfunc和pid分别为回调处理函数和线程ID,针对不同的通道设计,我们会采用线程的方式来处理,如:数据通道是采用线程的方式而不是主进程中的回调函数;
pdev: 是自定义结构体,保存接入端的描述信息,如接入的名称,系统,类型等你想要获得信息(非必须);
有了上面对单个链接的封装,我们就可以针对通道创建一个全局链表了,用于管理客户端接入的所有链接。如:
vector<Channel*> g_Channel; // 所有的通道链表,存放所有的链接
通道类型
我们需要对单个链接进行识别并分类,这样才能进行统一的管理和处理。根据实际需求,我们将设计以下不同的5种通道类型
enum{
CHANNEL_TYPE_UNKNOW = 0, // 未识别的通道;
CHANNEL_TYPE_TCP, // tcp监听通道,只有1个
CHANNEL_TYPE_UDP, // udp监听通道,只有1个
CHANNEL_TYPE_CMD, // 命令通道,可以有多个
CHANNEL_TYPE_DATA // 数据通道,可以有多个
};
说明:
CHANNEL_TYPE_UNKNOW:
未识别的通道,所有新建立的链接都初始化为此状态,经过识别之后(如认证)才能转换为命令或数据通道
CHANNEL_TYPE_TCP:
TCP监听通道,用于接收所有的客户端的链接请求,用于创建新的通道。非特殊情况一个服务器只允许有1个;
CHANNEL_TYPE_UDP:
UDP监听通道,有的程序可能需要同时监听TCP和UDP链接,如EasyMouse采用UDP通道来实现广播的接收(局域网搜索服务器);非特殊情况只允许有1个;
CHANNEL_TYPE_CMD:
命令通道,用于传递一些服务或命令请求,可以拥有多个(如多个终端接入);
CHANNEL_TYPE_DATA:
数据通道,用于传递一些数据文件,如手机发送文件到电脑中;可以有多个(如多文件同时传送);
通道处理
为了简化主进程的业务处理逻辑,我们为每一个通道设计了专门的回调处理函数,包括监听通道。数据通道我们采用线程的方式来处理。通过回调和线程的方式处理,主进线逻辑可以简单的认为除了接受新的连接什么也不需要干。
回调函数原型:
typedef void(*pFunc)(Channel* pChl);// 参数为通道结构体
为此我们需要实现以下通道处理逻辑:
未识别通道回调函数:voidOnChannelForUnknow(Channel* pChl);
TCP监听通道回调函数:voidOnChannelForTcp(Channel* pChl);
UDP监听通道回调函数:voidOnChannelForUdp(Channel* pChl);
命令通道回调函数:voidOnChannelForCmd(Channel* pChl);
数据通道回调函数:无(线程处理)
说明:
在后面我们会逐一介绍每个回调函数的实现逻辑(伪代码),以达到整体设计的目标;
服务器select
这里重点介绍服务器主线程的启动和运行的逻辑;
1,因为这里我们只允许TCP和UDP监听通道各有一个,因此程序启动的时候,我们应该首先创建一个TCP的监听通道。并加入到全局链表g_Channel中(非线程);
2,如果你有UDP的监听通道,那么在启动的时候。需要创建一个UDP通道,并加入到全局链表g_Channel中;
3,进行监听循环;
启动服务器的伪代码(根据实际情况拆分为多个函数):
int StartServer(void)
{
// 做一些安全校验,实际项目应该更完善一些,如是否已初始化,运行状态
ASSERT(g_Server.running == false);
// 一,创建TCP监听通道
pChannel pChl = new Channel();
pChl->type = CHANNEL_TYPE_TCP; // 通道类型
pChl->pfunc = OnChannelForTcp; // 通道处理函数
... /* 这里初始化包括创建套接字,绑定端口等信息 */
g_Channel.push_back(pChl); // 添加到全局链表队列g_Channel中
// 二,创建UDP监听通道
pChl = new Channel();
pChl->type = CHANNEL_TYPE_UDP;
pChl->pfunc = OnChannelForUdp;
.../* 这里创建udp套接字,绑定监听的端口信息等 */
g_Channel.push_back(pChl);
// 三,进入消息监听逻辑
fd_setrfds;
g_Server.running = true;
while(g_Server.running){
FD_ZERO(&rfds);
// 这里是关键,只监听需要监听的通道
vector<Channel*>::iteratoritr = g_Channel.begin();
for( ; itr!=g_Channel.end(); ++itr){
if ((*itr)->type != CHANNEL_TYPE_DATA){ // 非数据通道,跳过
FD_SET((*itr)->socket, rfds); // 加入监听序列
}
}
// 监听链接[阻塞],Linux需要计算maxfd, Windows可以直接为0;
int ret = select(maxfd, &rfds, NULL, NULL, NULL) ;
if (ret <= 0){
sleep(1);
continue;
}
if (!g_Server.running){
break; //唤醒时可能服务已被停止
}
// 查找有数据的链接,并进行处理 # Linux和Windows有所区别
for(int i=0; i<rfds.fd_count; i++){
if (FD_ISSET(rfds[i].fd_array[i], &rfds)){
continue;
}
// 查找相应的通道处理函数。 搜索g_Channel
pChl = find_channel_by_socket(rfds[i].fd_array[i]);
if (pChl != NULL && pChl->pfunc != NULL){
pChl->func(pChl); // 调用通道回调处理函数
}else{
// something error;
}
}// end for
}//end while();
}
说明:
上面就是我们整个服务器的主运行逻辑,通过这样的设计,我们不需要再为UDP创建一个监听线程,新接入的链接并不会立即进行识别,需要通过OnChannelForTcp, OnChannelUnknow函数之后才有可能转换为命令或数据通道。这样的设计简化了服务器主逻辑的复杂度;
下面将接着介绍这个几个回调函数的原理,这样整个逻辑会更加清晰;
请看《select多路复用 : EasyMouse的通道模式设计(下)》
你可以下载EmServer来体验实现的效果;