简易命令行聊天室程序

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));
    }
}

 

以上是关于简易命令行聊天室程序的主要内容,如果未能解决你的问题,请参考以下文章

Laravel + Swoole 打造IM简易聊天室

Laravel + Swoole 打造IM简易聊天室

Laravel + Swoole 打造IM简易聊天室

nio 代码实现简易多人聊天

电脑指令大全

Windows命令行简易入门