引言
聊天室是大一暑假的最后一个项目,同时也是比较值得我们反复思考琢磨的一个项目,因为它其中包含了大量新的知识点的运用比如多路复用,线程池,数据库,Wireshark等等,以及对于老知识点的温故,比如线程与进程的使用等,所以这是每一个人都应该认真对待的一个项目。在假期之前我们曾问学长,TTMS与聊天室相比哪一个更加有挑战性,学长告诉我们TTMS难度更高,当时完成TTMS以后便觉得假期可能不会那么紧张,但直到假期开始聊天室项目,才有了感触,现在以我的角度来说一说这两个项目,TTMS固然不太好写,但老师还是非常仁慈,提供了几乎全部的模板,我们要做的只不过是填充代码而已,难度上来说便降低了不少,算不上困难了。但聊天室自始至终没有一个人给你提示,学长们为了不杀死大家的想象力与创造力,让大家自由发挥,这样当然有好处,但要求我们在进行每一步操作是必须想到绝大多数服务器客户端交互可能出现的情况,从而去建表,构思,从而到最后的代码,代码部分还好说,构思一旦出现问题,等待你的就只有重构了。本文从我的角度来说说我在进行聊天室时的构思与编码阶段遇到的困难以及最终的解决方案。希望能对同样有此困惑的朋友有所帮助。
1.服务器端框架的构建
在最开始的阶段最为困难的就是服务器端的搭建,因为大家都是刚刚学习epoll,对epoll的了解并不是很深,所以我们可以构建一个简单易懂的模型,就是在开始时创建服务器套接字,epoll事件类型检测是服务器套接字则加入epoll,对于客户端连接请求也加入epoll,对于客户端的事件请求则转入一个处理函数进行处理 这样 一个简易的模型就搭建好了
这就是大概的一个框架
epfd=epoll_create(1);
ev.data.fd= sock_fd;
ev.events =EPOLLIN | EPOLLERR | EPOLLHUP | EPOLLRDHUP ; //设置为监听读的状态
//使用默认的LT模式 // epoll 事件只触发一次
epoll_ctl(epfd,EPOLL_CTL_ADD,sock_fd,&ev);
connect_size++;
for(;;)
{
nfds = epoll_wait(epfd,events,EVENTS_MAX_SIZE,-1);//等待可写事件
for(int i=0;i<nfds;i++)
{
connect_size++;
if(events[i].data.fd==sock_fd) //服务器套接字接收到一个连接请求
{
if(connect_size>MAX_CONTECT_SIZE)
{
perror("到达最大连接数!\n");
continue;
}
conn_fd=accept(events[i].data.fd,(struct sokcaddr*)&cli_addr,&cli_len);
//网络字节序转换成字符串输出
if(conn_fd<=0)
{
perror("error in accept\n");
printf("%s\n",strerror(errno));
continue;
}
temp->epfd=epfd;
temp->conn_fd=events[i].data.fd;
//printf("进入线程\n");
pthread_detach(pth1);
pth1=pthread_create(&pth1,NULL,solve,temp);//开一个线程去判断任务类型从而执行 值传递
}
}
}
close(sock_fd);
}
二.处理客户端强制退出与在线状态处理
在项目过程中很重要的一点就是当客户端强制退出时如何让服务器能正确的收到这条消息 从而做出正确的处理呢
1.屏蔽SIGPIPE信号
首先我们TCP是全双工的,客户端强制退出将导致仅关闭了一端的管道,也就是说对端还可发数据,但第一次发送数据将会导致对端被发送RST报文,第二次就会接收到SIGPIPE信号,而SIGPIPE信号的默认行为为程序退出,所以我们要屏蔽SIGPIPE信号。
2.注册正确的epoll事件类型
有一件事情非常重要,就是只有epoll注册过的事件类型才会被触发,也就是说如果我们没有注册错误事件类型,当客户端断开连接时将触发可读事件,这样我们就无法根据事件类型对客户端非正常退出进行处理。
ev.data.fd= conn_fd;
ev.events =EPOLLIN | EPOLLONESHOT | EPOLLRDHUP;
epoll_ctl(epfd,EPOLL_CTL_ADD,conn_fd,&ev);
3.为什么epoll会收到多个可读事件
我在使用epoll时使用了默认的LT模式,所以出现了这种问题,当客户端非正常退出时服务器会收到多条可读事件。原因是因为客户端非正常退出会发送一个包,LT模式下没有收到会从新加入epoll队列,就导致多次触发,解决方案是在solve函数中注册事件类型为EPOLLIN | EPOLLONESHOT,让一个套接字只接收一次消息,每次进入处理函数后刷新,即可解决上述问题
struct epoll_event ev;
ev.data.fd = recv_buf->conn_fd;
ev.events = EPOLLIN | EPOLLONESHOT;
epoll_ctl(recv_buf->epfd, EPOLL_CTL_MOD,recv_buf->conn_fd, &ev);
4.如何正确处理好友状态问题
在上面一番处理后当客户端非正常退出时我们应该会受到一个包且大小为零,根据这一点我们可以判断用户何时退出,但我们如何表示用户在线状态呢,可能你会想到把状态信息放入数据库中,其实把一个频繁变化的数据放入数据库并不是最优选择,我的解决方案是在服务器端建立一个用户链表,登录请求到来时加入,退出后从链表中删除。这样就可以准确高效的判断是否在线。
三.客户端与服务器交互框架
当我们完成了服务器与客户端的框架以后,它们之间的信息的交互就成了一个不太容易的问题,比如说用户的信息如何加载,又比如说当你接收文件的时候如何接收信息,又或者当你与一个人正在聊天时其他人的消息又该如何处理,下面就来分析一下这几种情况
-
用户消息如何加载
我的解决方案是这样的,首先所有的信息都存储在服务器的数据库中,客户端本身是不存储数据的,当客户上线时,首先服务器对账号和密码进行在数据库中进行检验,正确则进行下面步骤,否则退出。
客户端分事件类型循环向客户端发数据包,在一种事件类型结束时发送一个结束包,以便让客户端收到从而退出循环。 -
接收文件时如何接收消息
这是一个框架上的问题,首先如果我们客户端用一个线程的话,在发送大文件的时候一定会阻塞消息,因为一个大文件小则几十MB,大则几十G,而一个包的大小是有限的,有可能一个文件的发送要有成千上万的数据包,那我们该怎么办呢,就是重新开一个线程单独去接收文件,这就相当于把一个任务分成了两个子任务,一个是接收消息,一个是文件,就提升了程序的性能,这就是并行的好处,也就是我们使用多线程的一个原因,感兴趣的同学可以去看看csapp。 -
与一人聊天时其他消息如何处理
其实这个很简单,就是建立一个消息盒子,把所有除文件包以外的的消息放入消息盒子,所谓消息盒子其实就是一个链表,在我们需要的时候进行遍历,(是很慢,但是实现起来很简单)
四.客户端消息如何存储
因为我在编码时没有把消息在本地存储在文件中,而是在每次登录时从服务器加载,因为有好友消息和群消息,况且每一个好友和群都有各自的信息,所以我在数据结构上选择了邻接表+map , 因为账号为主键,所以以账号为键,每次分配一个唯一的值,代表了指针数组的索引,其中为与此好友或群的消息链表,这样就解决了这个问题。
五.收包错误
我相信如果没有人提醒的话大家应该都会遇到这个问题,且这个问题在本机测试不会出现任何问题,只有当你的包经过网卡时才会出现,你会发现你的一个包被分成了好几份,但它们的大小总和确是正确的,这是TCP的可靠性来确保的,但是包会被截成几个却没办法,对于这个问题我们有两中解决方案
- recv中的一个参数
int ret = recv(fact_fd,&pacage,sizeof(pacage),MSG_WAITALL);
这个参数可以保证接到第三个参数大小字节才退出
- 编写一个函数
int my_recv_tmp(int conn_fd,char *data_buf,int len)
{
char *p = data_buf;
//printf(" 需要接收 %d\n",len);
//memset(data_buf, 0, len);
while (len > 0) {
ssize_t n = recv(conn_fd, p, len, 0);
if (n < 0)
perror("error in recv\n");
else if (n == 0)
printf("接收到包大小为零\n");
else {
//printf("recv %zd bytes: %s\n", n, p);
p += n;
len -= n;
}
}
return len;
}
调用这个函数时记得强制类型转换为char*,这个函数与上面的参数作用相同,推荐使用参数,因为方便呀。
六.发包时send的第二个与第三个参数匹配
这是一个比较隐晦的问题,就是参数匹配的问题,我在初始阶段曾写过这样段代码
send(sock->send_fd,BOX_NO_MESSAGES,8096,0);
其中的宏是一个字符串,但肯定没到8096个字节,这会导致什么,会导致发包数据错误,其中没有的数据会在栈上开始扩展,直到8096个字节,这个错误比较难找,当时用了抓包工具才找到这个错误,希望在此警惕。
七.缓冲区问题
这是一个编程习惯的问题,不清空缓冲区通常会造成无法预料的问题,有时候遇到了诸如 const的值被改变等比较奇怪的问题,首先将目标放到缓冲区上
八.数据库操作到底要不要加锁
这就是一个比较复杂的问题,事实上乐观锁悲观锁这些我现在也不是很清楚,所以先不要着急,这个问题等我三个月以后再来解决。