简易命令行聊天室程序
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了简易命令行聊天室程序相关的知识,希望对你有一定的参考价值。
最近学习完网络编程,决定写一个简单的聊天服务器。主要用到的技术是socket,I/O复用(epoll),非阻塞IO,进程等知识。下面主要叙述其中的关键技术点以及编写过程中遇到的问题。
0、该程序实现的基本功能
编写了一个简单的聊天室程序,该聊天室程序能够让所有的用户同时在线群聊,它分为服务器和客户端两个部分。
- 服务器:接收客户端数据,并将该客户端数据发送给其他登录到该服务器上的客户端。
- 客户端:从标准输入读入数据,并将数据发送给服务器,同时接收服务器发送的数据。
1、服务器端IO模型。
采用IO复用+非阻塞IO的模型,IO复用采用Linux下的epoll机制。下面介绍epoll具体的函数。
//实现epoll服务器端需要三个函数。 //1)epoll_create:创建保持epoll文件描述符的空间,即epoll例程,size只是建议的例程大小。 #include<sys/epoll.h> int epoll_create(int size);//成功时返回epoll文件描述符,失败时返回-1 /** 2)epoll_ctl:向空间注册并且注销文件描述符。 要使用epoll_event结构体: struct epoll_event{ __uint32_t events; epoll_data_t data; } typedef union epoll_data{ void * ptr; int fd; __uint32_t u32; __uint64_t u64; }epoll_data_t; 这里注意要声明足够大的epoll_event结构体数组后,传递给epoll_eait函数时,发生变化的文件描述符信息被填入该数组。可以直接申明也可以动态分配。 op有三个宏选项: @EPOLL_CTL_ADD:将文件描述符注册到epoll例程。 @EPOLL_CTL_DEL:从epoll例程中删除文件描述符。 @EPOLL_CTL_MOD:更改注册的文件描述符的关注事件发生情况。 events常用的可以保存的常量以及事件类型。 @EPOLLIN:需要读取数据的情况. @EPOLLET:以边缘触发的方式得到事件通知。 @EPOLLONESHOT:发生一次事件后,相应文件描述符不在接收事件通知,需要再次设置事件才能继续使用。 **/ int epoll_ctl(int epfd,int op,int fd,struct epoll_event* event); //成功时返回0,失败时返回-1 int epoll_wait(int wpfd,struct epoll_event* events,int maxevents,int timeout); //成功时返回发生事件的文件描述符数,失败时返回-1
1.1 为什么IO复用需要搭配非阻塞IO?(select/epoll返回可读后还用非阻塞是不是没有意义?)
问题分析:a、输入过程通常分为两个阶段1)等待数据从网络中到达,它被复制到内核中的某个缓冲区。2)从内核向进程复制数据。
阻塞IO模型和非阻塞IO模型如下:
b、文件描述符就绪条件有可读,可写或者出现异常。设置非阻塞的方法有两种一种是使用fcntl函数,另一种是通过socket API创建非阻塞的socket。
int fd_sock = socket(AF_INET,SOCK_STREAM|SOCK_NONBLOCK,0);
答:select/epoll返回了可读,并不代表一定能够读取数据,因为在返回可读到调用read函数之间,是有时间间隙的,这段时间内核可能将数据丢失。也有可以多个线程同时监听该套接字,数据也可能被其他线程读取。
可以参考知乎这个问题 https://www.zhihu.com/question/37271342。
1.2、epoll的条件触发LT和边缘触发ET区别。
答:条件触发方式中,只要输出缓冲中有数据就会一直注册该事件(这次没处理该事件,下次调用epoll_wait还会继续通告该事件)。
边缘触发中输入缓冲收到数据时仅注册一次事件。
边缘触发中,一旦发生输入相关事件,就应该读取输入缓冲中的全部数据,因此需要验证输入缓冲是否为空。read函数返回-1,变量errno中的值为EAGAIN时,说明没有数据可以读。
边缘触发方式下,为什么要将套接字变为非阻塞模式呢?以阻塞方式工作的read&write函数有可能引起服务器端的长时间停顿,没有数据可读,就会一直阻塞进程,所以一定要采用非阻塞的IO函数。
边缘触发的优点是:可以分离接收数据和处理数据的时间点。
1.3、select和epoll的区别
答:select缺点:
1)针对所有文件描述符的循环语句;
2)每次都需要向操作系统传递监视对象信息。
最耗时间的是第二点向操作系统传递监视对象信息。
epoll支持ET模式,而select只支持LT模式。select的优点是:
1)服务器端接入者少的时候适用;
2)兼容性好。
1.4、服务器端发生地址分配错误(提前终止服务器端,重启的时候出现bind() error)
答:原因是先断开的主机需要进过time-wait状态,套接字进过四次挥手最后要发送ACK(A->B),最后B接收到ACK才会正常关闭,如果没有收到,会超时重传。这个时候相应的端口处于正在使用的状态,所以bind()重新分配相同的IP和port就会出错。
关闭方法:在套接字可选项中更改SO_REUSEADDR状态,将0改为1即可。(客户端是调用connect随机分配IP&port,所以不会出现该错误)
2、客户端client
client采用分割读写的方法进行操作,子进程负责发送数据,父进程负责接收数据。
分离流的好处:
1)减低实现难度;
2)与输入无关的输出操作可以提高速度。
pid_t pid = fork(); if(pid == 0){//子进程负责写 write_routine(clntSock,buff); } else{//父进程负责读 read_routine(clntSock,buff); }
3、具体实现代码。
//utility.h #ifndef _UTILITY_ #define _UTILITY_ #include<sys/types.h> #include<sys/socket.h> #include<arpa/inet.h> #include<stdio.h> #include<stdlib.h> #include<unistd.h> #include<errno.h> #include<string.h> #include<fcntl.h> #include<stdlib.h> #include<sys/epoll.h> #include<list> #include<string> using namespace std; /*存储客户端文件描述符*/ list<int> clientLists; #define MAX_EVENT_NUMBER 1024 #define BUFF_SIZE 400 /*服务器ip*/ #define SERVERIP "127.0.0.1" /*端口号(只要在1024~5000都行)*/ #define PORT "6666" /*epoll例程大小*/ #define EPOLLSIZE 50 #define EXIT "exit" /** *将文件描述符设置成非阻塞的 *返回文件描述符旧的状态,以便日后恢复该状态标志 **/ int setNonBlocking(int fd){ int oldOption = fcntl(fd,F_GETFL); int newOption = oldOption | O_NONBLOCK; fcntl(fd,F_SETFL,newOption); return oldOption; } /** * 将文件描述符fd上的EPOLLIN注册到epollfd指示的内核事件表中 * 参数enable_et指定是否对fd启用ET模式 **/ void addfd(int epollfd,int fd,bool enable_et){ epoll_event event; event.data.fd = fd; event.events = EPOLLIN;//主要读取客服端套接字信息 if(enable_et){ event.events |= EPOLLET; } setNonBlocking(fd); epoll_ctl(epollfd,EPOLL_CTL_ADD,fd,&event); } /** * 服务端向其他客户端发送消息 **/ void sendBroadCast(struct epoll_event* waitEvents,int eventsNumber,int epollfd,int listenfd){ int clntSock = 0; struct sockaddr_in clntAdr; char buff[BUFF_SIZE]; for(int i = 0;i < eventsNumber;++i){ if(waitEvents[i].data.fd == listenfd){//未建立连接,先建立连接 socklen_t clientLength = sizeof(clntAdr); clntSock = accept(listenfd,(struct sockaddr*) &clntAdr,&clientLength); addfd(epollfd,clntSock,true); /*第一次connect*/ const char* message = "welcome join chatting!\\n\\n"; printf("%d join chatting!!!\\n",clntSock); write(clntSock,message,strlen(message)); /*将新clientID加入链表*/ clientLists.push_back(clntSock); /*向例程中注册事件*/ addfd(epollfd,clntSock,true); } else{//已经建立连接,需要读取数据,然后发送给其他客户端 clntSock = waitEvents[i].data.fd; bzero(&buff,strlen(buff)); int strLen = sprintf(buff,"te clientID %d saying: ",clntSock); strLen += read(clntSock,buff + strLen,BUFF_SIZE); if(strLen < 0){//客户端读取数据出错 perror("read"); close(clntSock); exit(-1); } else if(strLen == 0){//已经没数据,需要关闭客户端 epoll_ctl(epollfd,EPOLL_CTL_DEL,clntSock,NULL); clientLists.remove(clntSock); close(clntSock); } else{ buff[strLen] = 0; /*发送给其他的所有客户端*/ if(clientLists.size() == 1){ const char *mess = "Atention!only one client in the chatting room!\\n"; write(clntSock,mess,strlen(mess)); printf("Atention!only ID %d client in the chatting room!\\n",clntSock); } printf("saved: %s\\n",buff); list<int> :: iterator iter; for(iter = clientLists.begin();iter != clientLists.end();++iter){ if(*iter == clntSock){ continue; } write(*iter,buff,strLen + 1); } } } } } #endif
//server.cpp #include"utility.h" int main(){ int err = 0; char buff[BUFF_SIZE]; struct sockaddr_in servAddr; bzero(&servAddr,sizeof(servAddr)); servAddr.sin_family = AF_INET; inet_aton(SERVERIP,&servAddr.sin_addr);//将字符串IP地址转化为32位整数型数据 servAddr.sin_port = htons(atoi(PORT)); /*监听套接字描述符*/ int listenfd = socket(PF_INET,SOCK_STREAM,0); if(listenfd == -1){ perror("listenfd"); exit(1); } /*更改服务器套接字的time_wait状态*/ int option = 0; socklen_t optlen; optlen = sizeof(option); option = 1; setsockopt(listenfd,SOL_SOCKET,SO_REUSEADDR,(void*)&option,optlen); /*分配IP地址和端口号*/ err = bind(listenfd,(struct sockaddr*)&servAddr,sizeof(servAddr)); if(err == -1){ perror("bind"); exit(1); } /*转化为可接受请求转态*/ err = listen(listenfd,10); if(err == -1){ perror("listen"); exit(1); } int epfd = epoll_create(EPOLLSIZE); struct epoll_event waitEvents[MAX_EVENT_NUMBER]; //预留足够大的空间来存储后面发生变化的事件,也可以使用动态分配 /*注册监听套接字*/ addfd(epfd,listenfd,true); /*监测文件描述符的变化*/ int eventsNumber = 0; while(1){ eventsNumber = epoll_wait(epfd,waitEvents,EPOLLSIZE,-1);//一直等待事件的发生,除非出错返回 if(eventsNumber == -1){ perror("eventsNumber"); exit(1); } sendBroadCast(waitEvents,eventsNumber,epfd,listenfd);//将waitEvents当作平常的数组,数组名就是指针 } close(listenfd); close(epfd); return 0; }
#include"utility.h" void read_routine(int clntSock,char *buf); void write_routine(int clntSock,char *buf); int main(){ int clntSock; char buff[BUFF_SIZE]; clntSock = socket(PF_INET,SOCK_STREAM,0); if(clntSock == -1){ perror("clntSock"); exit(1); } struct sockaddr_in servAdr; bzero(&servAdr,sizeof(servAdr)); servAdr.sin_family = AF_INET; inet_aton(SERVERIP,&servAdr.sin_addr);//将字符串IP地址转化为32位整数型数据 servAdr.sin_port = htons(atoi(PORT)); int err = connect(clntSock,(struct sockaddr*)&servAdr,sizeof(servAdr)); if(err == -1){ perror("connect"); exit(1); } pid_t pid = fork(); if(pid == 0){//子进程负责写 write_routine(clntSock,buff); } else{//父进程负责读 read_routine(clntSock,buff); } close(clntSock); return 0; } void read_routine(int clntSock,char *buf){ while(1){ int strLen = read(clntSock,buf,BUFF_SIZE); if(strLen == 0){ return; } buf[strLen] = 0; printf("%s",buf); } } void write_routine(int clntSock,char *buf){ while(1){ fgets(buf,BUFF_SIZE,stdin); if(!strcmp(buf,"exit\\n")){ shutdown(clntSock,SHUT_WR); return; } write(clntSock,buf,strlen(buf)); } }
以上是关于简易命令行聊天室程序的主要内容,如果未能解决你的问题,请参考以下文章